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:
lee-hyeon-cheol
2026-09-17 16:34:48 +09:00
co-authored by Claude Fable 5.1
parent 2869985139
commit 47d31dbe06
27 changed files with 607 additions and 142 deletions
@@ -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),
+2 -1
View File
@@ -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,
},
+80 -19
View File
@@ -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");
}
}
+204 -40
View File
@@ -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 에서만 됨");
}
+47 -2
View File
@@ -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(())
}