feat(app): 활성 창 캡쳐·캡쳐 목적지 선택·단축키 Ctrl+Alt 재배치 + UX 손질
- Ctrl+Alt+A/Z 맨 앞 창 통째 캡쳐(DWM 확장 프레임 경계), S/X 드래그 캡쳐. A/S 새 대화, Z/X 마지막 대화 - 캡쳐 후 창을 확실히 앞으로(AttachThreadInput), capture.image 에 목적지 path 실어 그 Composer 만 소비 - 드래그 선택영역 원본 밝기·더블버퍼(번쩍임) — code-assistant-v2 복사본에서 이식 - 단축키 Ctrl+Alt+Q/W: Eclipse·ADT·SAP GUI 바인딩 목록 대조해 비어 있는 조합으로 - 버튼 cursor:pointer(Tailwind v4), 가운데 스피너, 스니펫→챗 버튼, 스니펫 검색창 높이 84→88 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
2869985139
commit
47d31dbe06
@@ -18,6 +18,8 @@ use crate::shell::{paste, paste::PasteState, window};
|
||||
pub struct Routes {
|
||||
pub current: String,
|
||||
pub last_snap: String,
|
||||
/// 마지막으로 열었던 기존 대화(`/snap/s/<id>`). 캡쳐를 기존 대화에 붙일 때 씀.
|
||||
pub last_session: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Routes {
|
||||
@@ -26,6 +28,7 @@ impl Default for Routes {
|
||||
Self {
|
||||
current: "/snap".into(),
|
||||
last_snap: "/snap".into(),
|
||||
last_session: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +38,9 @@ impl Routes {
|
||||
if path == "/snap" || path.starts_with("/snap/") {
|
||||
path.clone_into(&mut self.last_snap);
|
||||
}
|
||||
if path.starts_with("/snap/s/") {
|
||||
self.last_session = Some(path.to_string());
|
||||
}
|
||||
path.clone_into(&mut self.current);
|
||||
}
|
||||
}
|
||||
@@ -67,7 +73,8 @@ pub enum SnippetStage {
|
||||
#[tauri::command]
|
||||
pub fn window_snippet_layout(app: AppHandle, stage: SnippetStage) -> Result<(), String> {
|
||||
let (width, height) = match stage {
|
||||
SnippetStage::Search => (640.0, 84.0),
|
||||
// 제목줄 h-8(32) + 검색줄 h-14(56) = 88. 이보다 작으면 프레임 overflow-auto 가 스크롤바를 띄움.
|
||||
SnippetStage::Search => (640.0, 88.0),
|
||||
SnippetStage::Results => (640.0, 440.0),
|
||||
SnippetStage::Preview => (840.0, 520.0),
|
||||
SnippetStage::Editor => (960.0, 600.0),
|
||||
|
||||
@@ -30,9 +30,10 @@ pub enum Push {
|
||||
#[serde(rename = "paste.target")]
|
||||
PasteTarget { name: String, app: String },
|
||||
|
||||
/// 영역 캡쳐 PNG를 프론트의 새 대화 Composer에 붙임.
|
||||
/// 영역 캡쳐 PNG를 지정한 대화 Composer에 붙임.
|
||||
#[serde(rename = "capture.image")]
|
||||
CaptureImage {
|
||||
path: String,
|
||||
#[serde(rename = "dataUrl")]
|
||||
data_url: String,
|
||||
},
|
||||
|
||||
@@ -18,15 +18,29 @@ use shell::window;
|
||||
const HOTKEYS: &[HotkeySpec] = &[
|
||||
HotkeySpec {
|
||||
id: "snap",
|
||||
accelerator: "Ctrl+Shift+8",
|
||||
accelerator: "Ctrl+Alt+W",
|
||||
},
|
||||
HotkeySpec {
|
||||
id: "snippet",
|
||||
accelerator: "Ctrl+Shift+7",
|
||||
accelerator: "Ctrl+Alt+Q",
|
||||
},
|
||||
// 캡쳐 4종: 윗줄(A/S) = 새 대화, 아랫줄(Z/X) = 마지막 기존 대화(없으면 새 대화).
|
||||
// 왼쪽(A/Z) = 맨 앞 창 통째, 오른쪽(S/X) = 드래그 영역.
|
||||
HotkeySpec {
|
||||
id: "capture",
|
||||
accelerator: "Ctrl+Shift+9",
|
||||
accelerator: "Ctrl+Alt+A",
|
||||
},
|
||||
HotkeySpec {
|
||||
id: "capture_area",
|
||||
accelerator: "Ctrl+Alt+S",
|
||||
},
|
||||
HotkeySpec {
|
||||
id: "capture_last",
|
||||
accelerator: "Ctrl+Alt+Z",
|
||||
},
|
||||
HotkeySpec {
|
||||
id: "capture_area_last",
|
||||
accelerator: "Ctrl+Alt+X",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -149,6 +163,35 @@ fn show_palette(app: &AppHandle) -> tauri::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 캡쳐 핫키 id 로 목적지 route 결정. `*_last` 는 마지막 기존 대화, 없으면 새 대화.
|
||||
fn capture_target(id: &str, last_session: Option<&str>) -> String {
|
||||
if id.ends_with("_last") {
|
||||
last_session.unwrap_or("/snap/new").to_string()
|
||||
} else {
|
||||
"/snap/new".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 캡쳐 결과 공통 처리 — 팔레트 소환 → 목적지 대화 화면 → 그 화면 Composer 에 이미지 첨부.
|
||||
fn on_captured(app: AppHandle, target: String, data_url: String) {
|
||||
if let Err(error) = show_palette(&app) {
|
||||
eprintln!("[capture] 창 소환 실패: {error}");
|
||||
}
|
||||
bridge::push(
|
||||
&app,
|
||||
Push::Navigate {
|
||||
path: target.clone(),
|
||||
},
|
||||
);
|
||||
bridge::push(
|
||||
&app,
|
||||
Push::CaptureImage {
|
||||
path: target,
|
||||
data_url,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 표준 전역 단축키가 실행할 CodeAssist 동작을 한 곳에서 정함.
|
||||
fn on_hotkey(app: &AppHandle, id: &str) {
|
||||
match id {
|
||||
@@ -162,17 +205,20 @@ fn on_hotkey(app: &AppHandle, id: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
"capture" => {
|
||||
shell::capture::start(app.clone(), |app, data_url| {
|
||||
let _ = show_palette(&app);
|
||||
bridge::push(
|
||||
&app,
|
||||
Push::Navigate {
|
||||
path: "/snap/new".into(),
|
||||
},
|
||||
);
|
||||
bridge::push(&app, Push::CaptureImage { data_url });
|
||||
});
|
||||
"capture" | "capture_area" | "capture_last" | "capture_area_last" => {
|
||||
let target = app
|
||||
.state::<commands::RouteState>()
|
||||
.lock()
|
||||
.map(|routes| capture_target(id, routes.last_session.as_deref()))
|
||||
.unwrap_or_else(|_| "/snap/new".to_string());
|
||||
let deliver =
|
||||
move |app: AppHandle, data_url: String| on_captured(app, target, data_url);
|
||||
if id.starts_with("capture_area") {
|
||||
shell::capture::start(app.clone(), deliver);
|
||||
} else {
|
||||
let own_hwnd = window::native_handle(app);
|
||||
shell::capture::start_foreground_window(app.clone(), own_hwnd, deliver);
|
||||
}
|
||||
}
|
||||
|
||||
// 챗봇 토글: 이미 챗봇 화면이 떠 있으면 숨기고, 아니면 마지막 챗봇 위치로 데려온다.
|
||||
@@ -199,7 +245,7 @@ fn on_hotkey(app: &AppHandle, id: &str) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{chat_toggle_target, commands::Routes};
|
||||
use super::{capture_target, chat_toggle_target, commands::Routes};
|
||||
|
||||
#[test]
|
||||
fn 챗봇_토글은_현재_화면과_창_표시에_따라_결정한다() {
|
||||
@@ -219,13 +265,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 마지막_챗봇_위치는_챗봇_화면에서만_바뀐다() {
|
||||
fn 마지막_기존_대화는_새_대화나_다른_화면으로_가도_유지된다() {
|
||||
let mut routes = Routes::default();
|
||||
routes.report("/snap/session-1");
|
||||
routes.report("/snippet");
|
||||
routes.report("/snap/s/session-1");
|
||||
routes.report("/snap/new");
|
||||
routes.report("/files");
|
||||
|
||||
assert_eq!(routes.current, "/files");
|
||||
assert_eq!(routes.last_snap, "/snap/session-1");
|
||||
assert_eq!(routes.last_snap, "/snap/new");
|
||||
assert_eq!(routes.last_session.as_deref(), Some("/snap/s/session-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 캡쳐_핫키에_따라_새_대화나_마지막_기존_대화를_고른다() {
|
||||
assert_eq!(capture_target("capture", Some("/snap/s/last")), "/snap/new");
|
||||
assert_eq!(
|
||||
capture_target("capture_area", Some("/snap/s/last")),
|
||||
"/snap/new"
|
||||
);
|
||||
assert_eq!(
|
||||
capture_target("capture_last", Some("/snap/s/last")),
|
||||
"/snap/s/last"
|
||||
);
|
||||
assert_eq!(capture_target("capture_area_last", None), "/snap/new");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! 화면 영역 선택 + PNG 캡쳐. legacy `CaptureOverlayWindow`/`ScreenCapture`의 Tauri 이식판.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::io::Cursor;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
@@ -8,27 +9,30 @@ use base64::Engine;
|
||||
use tauri::AppHandle;
|
||||
use windows::core::{w, PCWSTR};
|
||||
use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, RECT, WPARAM};
|
||||
use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
BeginPaint, BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, CreateSolidBrush, DeleteDC,
|
||||
DeleteObject, EndPaint, FillRect, FrameRect, GetDC, GetDIBits, InvalidateRect, ReleaseDC,
|
||||
SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HGDIOBJ, PAINTSTRUCT,
|
||||
SRCCOPY,
|
||||
SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HBITMAP, HDC, HGDIOBJ,
|
||||
PAINTSTRUCT, SRCCOPY,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{ReleaseCapture, SetCapture, SetFocus};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetClientRect, GetMessageW,
|
||||
GetSystemMetrics, GetWindowLongPtrW, LoadCursorW, PostQuitMessage, RegisterClassW,
|
||||
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetForegroundWindow,
|
||||
GetMessageW, GetSystemMetrics, GetWindowLongPtrW, LoadCursorW, PostQuitMessage, RegisterClassW,
|
||||
SetForegroundWindow, SetLayeredWindowAttributes, SetWindowLongPtrW, ShowWindow,
|
||||
TranslateMessage, CREATESTRUCTW, CS_HREDRAW, CS_VREDRAW, GWLP_USERDATA, IDC_CROSS, LWA_ALPHA,
|
||||
MSG, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SW_SHOW,
|
||||
WA_INACTIVE, WINDOW_EX_STYLE, WM_ACTIVATE, WM_DESTROY, WM_ERASEBKGND, WM_KEYDOWN,
|
||||
WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, WM_NCCREATE, WM_NCDESTROY, WM_PAINT, WNDCLASSW,
|
||||
WS_EX_LAYERED, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_POPUP,
|
||||
LWA_COLORKEY, MSG, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
|
||||
SM_YVIRTUALSCREEN, SW_SHOW, WA_INACTIVE, WINDOW_EX_STYLE, WM_ACTIVATE, WM_DESTROY,
|
||||
WM_ERASEBKGND, WM_KEYDOWN, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, WM_NCCREATE,
|
||||
WM_NCDESTROY, WM_PAINT, WNDCLASSW, WS_EX_LAYERED, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_POPUP,
|
||||
};
|
||||
|
||||
const MIN_SELECTION: i32 = 4;
|
||||
const OVERLAY_ALPHA: u8 = 115;
|
||||
// 선택 영역에만 칠해 layered window에서 완전히 투명하게 만드는 색.
|
||||
const SELECTION_COLOR_KEY: COLORREF = COLORREF(0x00ff00ff);
|
||||
// Samsung Blue #1428A0. COLORREF는 0x00BBGGRR 순서임.
|
||||
const CAPTURE_BORDER_COLOR: COLORREF = COLORREF(0x00a02814);
|
||||
static CAPTURING: AtomicBool = AtomicBool::new(false);
|
||||
@@ -54,17 +58,36 @@ fn selection_rect(start: (i32, i32), end: (i32, i32), origin: (i32, i32)) -> Opt
|
||||
})
|
||||
}
|
||||
|
||||
/// 이미 캡쳐 중이면 두 번째 핫키는 무시함.
|
||||
/// 드래그로 영역 골라서 캡쳐. 이미 캡쳐 중이면 두 번째 핫키는 무시함.
|
||||
pub fn start<F>(app: AppHandle, on_captured: F)
|
||||
where
|
||||
F: FnOnce(AppHandle, String) + Send + 'static,
|
||||
{
|
||||
spawn_capture(app, on_captured, run_overlay);
|
||||
}
|
||||
|
||||
/// 핫키 누른 순간 맨 앞에 떠 있는 창을 통째로 캡쳐. 앱 종류 안 가림(SAP·Eclipse·브라우저 다 됨).
|
||||
/// 자기 창(팔레트)이 맨 앞이면 아무것도 안 함.
|
||||
pub fn start_foreground_window<F>(app: AppHandle, excluded_hwnd: Option<isize>, on_captured: F)
|
||||
where
|
||||
F: FnOnce(AppHandle, String) + Send + 'static,
|
||||
{
|
||||
spawn_capture(app, on_captured, move || {
|
||||
foreground_window_rect(excluded_hwnd)
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_capture<F, P>(app: AppHandle, on_captured: F, pick_rect: P)
|
||||
where
|
||||
F: FnOnce(AppHandle, String) + Send + 'static,
|
||||
P: FnOnce() -> Result<Option<CaptureRect>, String> + Send + 'static,
|
||||
{
|
||||
if CAPTURING.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let result = run_overlay().and_then(|rect| match rect {
|
||||
let result = pick_rect().and_then(|rect| match rect {
|
||||
Some(rect) => capture_png_data_url(rect),
|
||||
None => Ok(None),
|
||||
});
|
||||
@@ -77,10 +100,59 @@ where
|
||||
});
|
||||
}
|
||||
|
||||
struct PaintBuffer {
|
||||
dc: HDC,
|
||||
bitmap: HBITMAP,
|
||||
previous: HGDIOBJ,
|
||||
}
|
||||
|
||||
impl PaintBuffer {
|
||||
unsafe fn new(hwnd: HWND, width: i32, height: i32) -> Option<Self> {
|
||||
let window_dc = GetDC(Some(hwnd));
|
||||
if window_dc.is_invalid() {
|
||||
return None;
|
||||
}
|
||||
let dc = CreateCompatibleDC(Some(window_dc));
|
||||
let bitmap = CreateCompatibleBitmap(window_dc, width, height);
|
||||
let _ = ReleaseDC(Some(hwnd), window_dc);
|
||||
if dc.is_invalid() || bitmap.is_invalid() {
|
||||
if !dc.is_invalid() {
|
||||
let _ = DeleteDC(dc);
|
||||
}
|
||||
if !bitmap.is_invalid() {
|
||||
let _ = DeleteObject(HGDIOBJ(bitmap.0));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let previous = SelectObject(dc, HGDIOBJ(bitmap.0));
|
||||
if previous.is_invalid() {
|
||||
let _ = DeleteObject(HGDIOBJ(bitmap.0));
|
||||
let _ = DeleteDC(dc);
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
dc,
|
||||
bitmap,
|
||||
previous,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PaintBuffer {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
SelectObject(self.dc, self.previous);
|
||||
let _ = DeleteObject(HGDIOBJ(self.bitmap.0));
|
||||
let _ = DeleteDC(self.dc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OverlayState {
|
||||
origin: (i32, i32),
|
||||
start: Option<(i32, i32)>,
|
||||
current: Option<(i32, i32)>,
|
||||
buffer: Option<PaintBuffer>,
|
||||
sender: mpsc::Sender<Option<CaptureRect>>,
|
||||
finished: bool,
|
||||
}
|
||||
@@ -106,6 +178,24 @@ fn mouse_point(lparam: LPARAM) -> (i32, i32) {
|
||||
((value & 0xffff) as i16 as i32, (value >> 16) as i16 as i32)
|
||||
}
|
||||
|
||||
fn selection_client_rect(start: (i32, i32), end: (i32, i32)) -> RECT {
|
||||
RECT {
|
||||
left: start.0.min(end.0),
|
||||
top: start.1.min(end.1),
|
||||
right: start.0.max(end.0),
|
||||
bottom: start.1.max(end.1),
|
||||
}
|
||||
}
|
||||
|
||||
fn union_rect(a: RECT, b: RECT) -> RECT {
|
||||
RECT {
|
||||
left: a.left.min(b.left),
|
||||
top: a.top.min(b.top),
|
||||
right: a.right.max(b.right),
|
||||
bottom: a.bottom.max(b.bottom),
|
||||
}
|
||||
}
|
||||
|
||||
fn inset_rect(rect: RECT, amount: i32) -> Option<RECT> {
|
||||
let inset = RECT {
|
||||
left: rect.left + amount,
|
||||
@@ -116,6 +206,28 @@ fn inset_rect(rect: RECT, amount: i32) -> Option<RECT> {
|
||||
(inset.left < inset.right && inset.top < inset.bottom).then_some(inset)
|
||||
}
|
||||
|
||||
unsafe fn draw_overlay(dc: HDC, background: &RECT, selected: Option<RECT>) {
|
||||
let shade = CreateSolidBrush(COLORREF(0x000000));
|
||||
FillRect(dc, background, shade);
|
||||
let _ = DeleteObject(HGDIOBJ(shade.0));
|
||||
|
||||
let Some(selected) = selected else { return };
|
||||
let clear = CreateSolidBrush(SELECTION_COLOR_KEY);
|
||||
FillRect(dc, &selected, clear);
|
||||
let _ = DeleteObject(HGDIOBJ(clear.0));
|
||||
|
||||
let white = CreateSolidBrush(COLORREF(0x00ffffff));
|
||||
let accent = CreateSolidBrush(CAPTURE_BORDER_COLOR);
|
||||
FrameRect(dc, &selected, white);
|
||||
for amount in 1..4 {
|
||||
if let Some(border) = inset_rect(selected, amount) {
|
||||
FrameRect(dc, &border, accent);
|
||||
}
|
||||
}
|
||||
let _ = DeleteObject(HGDIOBJ(white.0));
|
||||
let _ = DeleteObject(HGDIOBJ(accent.0));
|
||||
}
|
||||
|
||||
unsafe extern "system" fn overlay_proc(
|
||||
hwnd: HWND,
|
||||
message: u32,
|
||||
@@ -141,9 +253,13 @@ unsafe extern "system" fn overlay_proc(
|
||||
}
|
||||
WM_MOUSEMOVE => {
|
||||
if let Some(state) = state {
|
||||
if state.start.is_some() {
|
||||
state.current = Some(mouse_point(lparam));
|
||||
let _ = InvalidateRect(Some(hwnd), None, false);
|
||||
if let Some(start) = state.start {
|
||||
let current = mouse_point(lparam);
|
||||
let old = selection_client_rect(start, state.current.unwrap_or(start));
|
||||
let new = selection_client_rect(start, current);
|
||||
state.current = Some(current);
|
||||
let dirty = union_rect(old, new);
|
||||
let _ = InvalidateRect(Some(hwnd), Some(&dirty), false);
|
||||
}
|
||||
}
|
||||
LRESULT(0)
|
||||
@@ -172,33 +288,34 @@ unsafe extern "system" fn overlay_proc(
|
||||
WM_PAINT => {
|
||||
let mut paint = PAINTSTRUCT::default();
|
||||
let dc = BeginPaint(hwnd, &mut paint);
|
||||
let mut client = RECT::default();
|
||||
let _ = GetClientRect(hwnd, &mut client);
|
||||
let shade = CreateSolidBrush(COLORREF(0x000000));
|
||||
FillRect(dc, &client, shade);
|
||||
if let Some(state) = state {
|
||||
if let (Some(start), Some(end)) = (state.start, state.current) {
|
||||
let selected = RECT {
|
||||
left: start.0.min(end.0),
|
||||
top: start.1.min(end.1),
|
||||
right: start.0.max(end.0),
|
||||
bottom: start.1.max(end.1),
|
||||
};
|
||||
// 반투명 오버레이에서도 경계가 묻히지 않게 흰색 외곽선과
|
||||
// 포인트색 안쪽선을 겹쳐 4px 테두리로 그림.
|
||||
let white = CreateSolidBrush(COLORREF(0x00ffffff));
|
||||
let accent = CreateSolidBrush(CAPTURE_BORDER_COLOR);
|
||||
FrameRect(dc, &selected, white);
|
||||
for amount in 1..4 {
|
||||
if let Some(border) = inset_rect(selected, amount) {
|
||||
FrameRect(dc, &border, accent);
|
||||
}
|
||||
}
|
||||
let _ = DeleteObject(HGDIOBJ(white.0));
|
||||
let _ = DeleteObject(HGDIOBJ(accent.0));
|
||||
let selected = state.as_ref().and_then(|state| {
|
||||
state
|
||||
.start
|
||||
.zip(state.current)
|
||||
.map(|(start, end)| selection_client_rect(start, end))
|
||||
});
|
||||
|
||||
if let Some(buffer) = state.as_ref().and_then(|state| state.buffer.as_ref()) {
|
||||
draw_overlay(buffer.dc, &paint.rcPaint, selected);
|
||||
let width = paint.rcPaint.right - paint.rcPaint.left;
|
||||
let height = paint.rcPaint.bottom - paint.rcPaint.top;
|
||||
if width > 0 && height > 0 {
|
||||
let _ = BitBlt(
|
||||
dc,
|
||||
paint.rcPaint.left,
|
||||
paint.rcPaint.top,
|
||||
width,
|
||||
height,
|
||||
Some(buffer.dc),
|
||||
paint.rcPaint.left,
|
||||
paint.rcPaint.top,
|
||||
SRCCOPY,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
draw_overlay(dc, &paint.rcPaint, selected);
|
||||
}
|
||||
let _ = DeleteObject(HGDIOBJ(shade.0));
|
||||
|
||||
let _ = EndPaint(hwnd, &paint);
|
||||
LRESULT(0)
|
||||
}
|
||||
@@ -246,6 +363,7 @@ fn run_overlay() -> Result<Option<CaptureRect>, String> {
|
||||
origin: (x, y),
|
||||
start: None,
|
||||
current: None,
|
||||
buffer: None,
|
||||
sender,
|
||||
finished: false,
|
||||
});
|
||||
@@ -270,8 +388,13 @@ fn run_overlay() -> Result<Option<CaptureRect>, String> {
|
||||
return Err(error.to_string());
|
||||
}
|
||||
};
|
||||
if let Err(error) = SetLayeredWindowAttributes(hwnd, COLORREF(0), OVERLAY_ALPHA, LWA_ALPHA)
|
||||
{
|
||||
(*state_ptr).buffer = PaintBuffer::new(hwnd, width, height);
|
||||
if let Err(error) = SetLayeredWindowAttributes(
|
||||
hwnd,
|
||||
SELECTION_COLOR_KEY,
|
||||
OVERLAY_ALPHA,
|
||||
LWA_ALPHA | LWA_COLORKEY,
|
||||
) {
|
||||
let _ = DestroyWindow(hwnd);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
@@ -288,6 +411,30 @@ fn run_overlay() -> Result<Option<CaptureRect>, String> {
|
||||
Ok(receiver.recv().ok().flatten())
|
||||
}
|
||||
|
||||
/// 맨 앞 창의 화면 좌표. Win11 은 GetWindowRect 가 그림자 여백까지 잡아서 DWM 확장 프레임 경계를 씀.
|
||||
fn foreground_window_rect(excluded_hwnd: Option<isize>) -> Result<Option<CaptureRect>, String> {
|
||||
let window = unsafe { GetForegroundWindow() };
|
||||
if window.0.is_null() || excluded_hwnd == Some(window.0 as isize) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut bounds = RECT::default();
|
||||
unsafe {
|
||||
DwmGetWindowAttribute(
|
||||
window,
|
||||
DWMWA_EXTENDED_FRAME_BOUNDS,
|
||||
&mut bounds as *mut RECT as *mut c_void,
|
||||
std::mem::size_of::<RECT>() as u32,
|
||||
)
|
||||
}
|
||||
.map_err(|error| format!("창 경계를 못 얻었어: {error}"))?;
|
||||
Ok(Some(CaptureRect {
|
||||
x: bounds.left,
|
||||
y: bounds.top,
|
||||
width: bounds.right - bounds.left,
|
||||
height: bounds.bottom - bounds.top,
|
||||
}))
|
||||
}
|
||||
|
||||
fn capture_png_data_url(rect: CaptureRect) -> Result<Option<String>, String> {
|
||||
if rect.width <= 0 || rect.height <= 0 {
|
||||
return Ok(None);
|
||||
@@ -413,4 +560,21 @@ mod tests {
|
||||
assert_eq!((inset.right, inset.bottom), (27, 47));
|
||||
assert!(inset_rect(rect, 10).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 이동한_선택_영역의_합집합만_다시_그린다() {
|
||||
let old = selection_client_rect((100, 100), (300, 250));
|
||||
let new = selection_client_rect((100, 100), (320, 220));
|
||||
let dirty = union_rect(old, new);
|
||||
|
||||
assert_eq!(
|
||||
dirty,
|
||||
RECT {
|
||||
left: 100,
|
||||
top: 100,
|
||||
right: 320,
|
||||
bottom: 250,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,10 @@ where
|
||||
{
|
||||
eprintln!("[capture] 영역 캡처는 Windows 에서만 됨");
|
||||
}
|
||||
|
||||
pub fn start_foreground_window<F>(_app: AppHandle, _excluded_hwnd: Option<isize>, _on_captured: F)
|
||||
where
|
||||
F: FnOnce(AppHandle, String) + Send + 'static,
|
||||
{
|
||||
eprintln!("[capture] 창 캡처는 Windows 에서만 됨");
|
||||
}
|
||||
|
||||
@@ -11,6 +11,17 @@ use std::sync::Mutex;
|
||||
use tauri::{
|
||||
AppHandle, LogicalSize, Manager, PhysicalPosition, PhysicalRect, PhysicalSize, WebviewWindow,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::Foundation::HWND;
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::Threading::{AttachThreadInput, GetCurrentThreadId};
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::SetFocus;
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
BringWindowToTop, GetForegroundWindow, GetWindowThreadProcessId, SetForegroundWindow,
|
||||
ShowWindow, SW_RESTORE,
|
||||
};
|
||||
|
||||
/// tauri.conf.json 의 창 label 과 같아야 함.
|
||||
const MAIN: &str = "main";
|
||||
@@ -19,7 +30,6 @@ pub fn main_window(app: &AppHandle) -> Option<WebviewWindow> {
|
||||
app.get_webview_window(MAIN)
|
||||
}
|
||||
|
||||
/// 창 표시 + 포커스. (핫키/트레이 소환용)
|
||||
/// 붙여넣기 대상에서 우리 창을 빼거나 포커스 비교할 때 쓰는 네이티브 창 핸들(HWND).
|
||||
/// Windows 에서만 값이 있고 다른 OS 는 None — 호출부가 알아서 "대상 없음"으로 처리함.
|
||||
pub fn native_handle(app: &AppHandle) -> Option<isize> {
|
||||
@@ -36,10 +46,45 @@ pub fn native_handle(app: &AppHandle) -> Option<isize> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 창 표시 + 포커스. (핫키/트레이 소환용)
|
||||
/// Windows 에선 캡처 오버레이가 돌려준 foreground 를 현재 입력 큐에 잠시 붙여
|
||||
/// 이미 보이던 창도 뒤에 남지 않게 직접 활성화함(Tauri set_focus 만으론 외부 앱이 다시 가져감).
|
||||
pub fn show(app: &AppHandle) -> tauri::Result<()> {
|
||||
if let Some(w) = main_window(app) {
|
||||
w.show()?;
|
||||
w.set_focus()?;
|
||||
let _ = w.set_focus();
|
||||
#[cfg(windows)]
|
||||
force_foreground(&w)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn force_foreground(w: &WebviewWindow) -> tauri::Result<()> {
|
||||
let raw = w.hwnd()?;
|
||||
let hwnd = HWND(raw.0 as *mut std::ffi::c_void);
|
||||
unsafe {
|
||||
let foreground = GetForegroundWindow();
|
||||
let current_thread = GetCurrentThreadId();
|
||||
let foreground_thread = if foreground.0.is_null() {
|
||||
0
|
||||
} else {
|
||||
GetWindowThreadProcessId(foreground, None)
|
||||
};
|
||||
let attached = foreground_thread != 0
|
||||
&& foreground_thread != current_thread
|
||||
&& AttachThreadInput(current_thread, foreground_thread, true).as_bool();
|
||||
|
||||
let _ = ShowWindow(hwnd, SW_RESTORE);
|
||||
let _ = BringWindowToTop(hwnd);
|
||||
if !SetForegroundWindow(hwnd).as_bool() {
|
||||
eprintln!("[window] foreground 활성화 실패");
|
||||
}
|
||||
let _ = SetFocus(Some(hwnd));
|
||||
|
||||
if attached {
|
||||
let _ = AttachThreadInput(current_thread, foreground_thread, false);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user