返回 CodeWhale
window_control.rs
根目录 / crates / tui / src / tui / window_control.rs
1 //! Host terminal window control (Windows): pin-to-top mini-window toggle.
2 //!
3 //! The TUI runs inside a terminal emulator, so its window is owned by the
4 //! OS. This module drives that host window directly through Win32: a window
5 //! handle is resolved via `GetConsoleWindow` (classic console hosts) or, when
6 //! that yields nothing — ConPTY hosts such as Windows Terminal and VS Code's
7 //! integrated terminal have no classic console window — by validating the
8 //! foreground window and then walking the parent process chain for the
9 //! nearest visible top-level window. `SetWindowPos` then toggles
10 //! always-on-top while shrinking/restoring the size: the "pin" action, like
11 //! a video player's PiP button. On non-Windows platforms every entry is a
12 //! no-op.
13 //!
14 //! The interaction entry is the right-click context menu (see
15 //! `crate::tui::mouse_ui::build_context_menu_entries`): a single pin item
16 //! toggles the host window between its normal state and a small
17 //! always-on-top window.
18 //!
19 //! Known limitation: with multiple host windows (several Windows Terminal or
20 //! VS Code windows) the ancestor-window fallback may resolve to a sibling
21 //! window rather than the one containing this tab — Win32 exposes no public
22 //! tab→window mapping. The foreground-window check mitigates this for the
23 //! common case (the user just right-clicked inside the host).
24
25 /// Default pixel size of the pinned (always-on-top) mini window.
26 /// The user can resize the terminal window while pinned; this is the default.
27 #[cfg(windows)]
28 const PINNED_W: i32 = 640;
29 #[cfg(windows)]
30 const PINNED_H: i32 = 400;
31
32 /// How many parent hops the fallback window walk may take before giving up
33 /// (guards against pathological process chains / loops).
34 #[cfg(windows)]
35 const MAX_ANCESTOR_HOPS: u32 = 8;
36
37 #[cfg(windows)]
38 mod imp {
39 use super::*;
40 use anyhow::{Context, Result, bail};
41 use std::mem::size_of;
42 use std::sync::{
43 Mutex,
44 atomic::{AtomicBool, Ordering},
45 };
46 use std::time::{Duration, Instant};
47 use windows::Win32::Foundation::{CloseHandle, HWND, LPARAM, RECT};
48 use windows::Win32::System::Console::GetConsoleWindow;
49 use windows::Win32::System::Diagnostics::ToolHelp::{
50 CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
51 TH32CS_SNAPPROCESS,
52 };
53 use windows::Win32::UI::WindowsAndMessaging::{
54 EnumWindows, GW_OWNER, GetForegroundWindow, GetWindow, GetWindowInfo, GetWindowRect,
55 GetWindowThreadProcessId, HWND_NOTOPMOST, HWND_TOPMOST, IsWindowVisible, SW_MAXIMIZE,
56 SW_RESTORE, SWP_ASYNCWINDOWPOS, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_SHOWWINDOW,
57 SetWindowPos, ShowWindowAsync, WINDOWINFO, WS_EX_TOPMOST, WS_MAXIMIZE,
58 };
59 use windows_core::BOOL;
60
61 /// Pin state: remembers the pre-pin window rect so unpinning restores it,
62 /// plus whether the window was maximized (unpin restores maximized then,
63 /// not the ordinary recorded rect).
64 struct State {
65 host: Option<HostWindow>,
66 saved_rect: Option<RECT>,
67 was_maximized: bool,
68 }
69
70 impl State {
71 const fn new() -> Self {
72 Self {
73 host: None,
74 saved_rect: None,
75 was_maximized: false,
76 }
77 }
78 }
79
80 // Only the worker touches restore geometry. Rendering never takes this lock.
81 static STATE: Mutex<State> = Mutex::new(State::new());
82 static PINNED: AtomicBool = AtomicBool::new(false);
83 static BUSY: AtomicBool = AtomicBool::new(false);
84
85 struct BusyGuard;
86
87 impl Drop for BusyGuard {
88 fn drop(&mut self) {
89 BUSY.store(false, Ordering::Release);
90 }
91 }
92
93 #[derive(Clone, Copy)]
94 struct HostWindow {
95 handle: isize,
96 owner_pid: u32,
97 }
98
99 impl HostWindow {
100 fn capture() -> Result<Self> {
101 let hwnd = console_hwnd().context("no terminal host window found")?;
102 let mut owner_pid = 0;
103 unsafe {
104 GetWindowThreadProcessId(hwnd, Some(&mut owner_pid));
105 }
106 if owner_pid == 0 {
107 bail!("terminal host window no longer exists");
108 }
109 Ok(Self {
110 handle: hwnd.0 as isize,
111 owner_pid,
112 })
113 }
114
115 fn hwnd(self) -> Result<HWND> {
116 let hwnd = HWND(self.handle as *mut _);
117 let mut owner_pid = 0;
118 unsafe {
119 GetWindowThreadProcessId(hwnd, Some(&mut owner_pid));
120 }
121 if owner_pid != self.owner_pid {
122 bail!("terminal host window owner changed");
123 }
124 Ok(hwnd)
125 }
126 }
127
128 #[derive(Clone, Copy)]
129 struct Observation {
130 rect: RECT,
131 topmost: bool,
132 maximized: bool,
133 }
134
135 impl Observation {
136 fn matches(self, target: Self) -> bool {
137 self.topmost == target.topmost
138 && self.maximized == target.maximized
139 && (target.maximized || self.rect == target.rect)
140 }
141 }
142
143 fn observe(host: HostWindow) -> Result<Observation> {
144 let hwnd = host.hwnd()?;
145 let mut info = WINDOWINFO {
146 cbSize: size_of::<WINDOWINFO>() as u32,
147 ..Default::default()
148 };
149 let mut rect = RECT::default();
150 unsafe {
151 GetWindowInfo(hwnd, &mut info)?;
152 GetWindowRect(hwnd, &mut rect)?;
153 }
154 let observed = Observation {
155 rect,
156 topmost: info.dwExStyle.contains(WS_EX_TOPMOST),
157 maximized: info.dwStyle.contains(WS_MAXIMIZE),
158 };
159 PINNED.store(observed.topmost, Ordering::Release);
160 Ok(observed)
161 }
162
163 fn wait_for(
164 host: HostWindow,
165 timeout: Duration,
166 applied: impl Fn(Observation) -> bool,
167 ) -> Result<bool> {
168 let deadline = Instant::now() + timeout;
169 loop {
170 if applied(observe(host)?) {
171 return Ok(true);
172 }
173 if Instant::now() >= deadline {
174 return Ok(false);
175 }
176 std::thread::sleep(Duration::from_millis(15));
177 }
178 }
179
180 pub(super) fn start_toggle(
181 completion_tx: Option<tokio::sync::mpsc::Sender<crate::tui::app::DispatchApplyFn>>,
182 ) -> Result<()> {
183 // Reserve delivery before changing the window. A headless test App has
184 // no mailbox and cannot accidentally manipulate its real terminal.
185 let permit = completion_tx
186 .context("window completion mailbox is unavailable")?
187 .try_reserve_owned()
188 .context("window completion mailbox is full or closed")?;
189 BUSY.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
190 .map_err(|_| anyhow::anyhow!("a window change is already in progress"))?;
191 let busy = BusyGuard;
192 // Foreground selection belongs to the user's action, before dispatch;
193 // the worker must not pick a different window after focus changes.
194 let host = HostWindow::capture()?;
195 std::thread::Builder::new()
196 .name("window-pin".into())
197 .spawn(move || {
198 let result = std::panic::catch_unwind(|| toggle_pin(host))
199 .unwrap_or_else(|_| Err(anyhow::anyhow!("window worker panicked")));
200 let apply: crate::tui::app::DispatchApplyFn = Box::new(move |app, _, _| {
201 super::show_result(app, result);
202 Ok(())
203 });
204 permit.send(apply);
205 drop(busy);
206 })?;
207 Ok(())
208 }
209
210 /// The host window the user sees, if one can be resolved.
211 ///
212 /// Classic console hosts (conhost, legacy cmd windows) hand back a real
213 /// window from `GetConsoleWindow`. ConPTY hosts (Windows Terminal, VS
214 /// Code integrated terminal) have no classic console window, so first
215 /// check the foreground window (the user just right-clicked inside the
216 /// host, so it is almost certainly the host window) and then fall back
217 /// to the parent process chain.
218 fn console_hwnd() -> Option<HWND> {
219 // ConPTY hosts (Windows Terminal sets WT_SESSION, VS Code sets
220 // TERM_PROGRAM) have no meaningful console window: GetConsoleWindow
221 // may return a hidden ConPTY window whose SetWindowPos visibly does
222 // nothing. Skip it entirely and resolve the real host window.
223 let conpty = std::env::var("WT_SESSION").is_ok() || std::env::var("TERM_PROGRAM").is_ok();
224 if !conpty {
225 // SAFETY: no preconditions; invalid return handled.
226 let hwnd = unsafe { GetConsoleWindow() };
227 // SAFETY: invalid handles return false.
228 if !hwnd.is_invalid() && unsafe { IsWindowVisible(hwnd) }.as_bool() {
229 tracing::debug!("window_control: host window resolved via GetConsoleWindow");
230 return Some(hwnd);
231 }
232 }
233 tracing::debug!(
234 conpty,
235 "resolving host window (GetConsoleWindow skipped or unusable)"
236 );
237 if let Some(hwnd) = foreground_window_in_parent_chain(std::process::id()) {
238 tracing::debug!("window_control: host window resolved via foreground check");
239 return Some(hwnd);
240 }
241 if let Some(hwnd) = ancestor_top_level_window(std::process::id()) {
242 tracing::debug!("window_control: host window resolved via ancestor walk");
243 return Some(hwnd);
244 }
245 None
246 }
247
248 /// The foreground window, if it is visible and its process belongs to
249 /// this process's parent chain (i.e. it is the host application's
250 /// window). Visibility is required — a hidden foreground window cannot
251 /// be the host the user is looking at.
252 fn foreground_window_in_parent_chain(pid: u32) -> Option<HWND> {
253 // SAFETY: no preconditions; invalid return handled.
254 let foreground = unsafe { GetForegroundWindow() };
255 // SAFETY: invalid handles return false.
256 if foreground.is_invalid() || !unsafe { IsWindowVisible(foreground) }.as_bool() {
257 return None;
258 }
259 let mut fg_pid = 0u32;
260 // SAFETY: `fg_pid` is live for the call.
261 unsafe {
262 GetWindowThreadProcessId(foreground, Some(&mut fg_pid));
263 }
264 if fg_pid == 0 {
265 return None;
266 }
267 let mut current = parent_process_id(pid);
268 for _ in 0..MAX_ANCESTOR_HOPS {
269 let p = current?;
270 if p == fg_pid {
271 return Some(foreground);
272 }
273 current = parent_process_id(p);
274 }
275 None
276 }
277
278 /// Nearest visible top-level window owned by the given process or any of
279 /// its ancestors (parents first, then grandparents, …). Desktop-shell
280 /// processes (explorer.exe owns the taskbar/desktop windows) are skipped
281 /// — pinning those would be nonsensical.
282 fn ancestor_top_level_window(pid: u32) -> Option<HWND> {
283 let mut current = parent_process_id(pid);
284 let mut hops = 0u32;
285 while let Some(pid) = current {
286 if hops >= MAX_ANCESTOR_HOPS {
287 return None;
288 }
289 hops += 1;
290 if let Some((_, name)) = process_entry(pid)
291 && is_desktop_shell(&name)
292 {
293 current = parent_process_id(pid);
294 continue;
295 }
296 if let Some(hwnd) = visible_top_level_window_for_pid(pid) {
297 return Some(hwnd);
298 }
299 current = parent_process_id(pid);
300 }
301 None
302 }
303
304 /// Skip processes whose top-level windows are the desktop/taskbar or
305 /// other shell chrome — never something to pin.
306 fn is_desktop_shell(name: &str) -> bool {
307 matches!(
308 name.to_ascii_lowercase().as_str(),
309 "explorer.exe" | "dwm.exe" | "shell experience host.exe"
310 )
311 }
312
313 /// Look up a process's parent PID and image name from a toolhelp
314 /// snapshot. The snapshot handle is always closed.
315 fn process_entry(pid: u32) -> Option<(u32, String)> {
316 // SAFETY: returned handle is owned here; closed below.
317 let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }.ok()?;
318 let mut entry = PROCESSENTRY32W {
319 dwSize: size_of::<PROCESSENTRY32W>() as u32,
320 ..Default::default()
321 };
322 let mut found = None;
323 // SAFETY: `entry` is live with dwSize initialized above.
324 let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) }.is_ok();
325 while ok {
326 if entry.th32ProcessID == pid {
327 let name_len = entry
328 .szExeFile
329 .iter()
330 .position(|&c| c == 0)
331 .unwrap_or(entry.szExeFile.len());
332 let name = String::from_utf16_lossy(&entry.szExeFile[..name_len]);
333 found = Some((entry.th32ParentProcessID, name));
334 break;
335 }
336 // SAFETY: `entry` is live with dwSize initialized above.
337 ok = unsafe { Process32NextW(snapshot, &mut entry) }.is_ok();
338 }
339 // SAFETY: `snapshot` is owned here and not used after.
340 unsafe {
341 let _ = CloseHandle(snapshot);
342 }
343 found
344 }
345
346 fn parent_process_id(pid: u32) -> Option<u32> {
347 process_entry(pid).map(|(ppid, _)| ppid)
348 }
349
350 /// First visible, unowned (true top-level) window owned by `pid`, if any.
351 fn visible_top_level_window_for_pid(pid: u32) -> Option<HWND> {
352 struct Ctx {
353 target: u32,
354 found: Option<HWND>,
355 }
356 let mut ctx = Ctx {
357 target: pid,
358 found: None,
359 };
360 unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL {
361 // SAFETY: lparam carries the live `ctx` below; EnumWindows is synchronous.
362 let ctx = unsafe { &mut *(lparam.0 as *mut Ctx) };
363 let mut wpid = 0u32;
364 // SAFETY: `hwnd` is valid per EnumWindows; `wpid` is live.
365 unsafe {
366 GetWindowThreadProcessId(hwnd, Some(&mut wpid));
367 if wpid == ctx.target && IsWindowVisible(hwnd).as_bool() {
368 // Skip owned popups/child windows; only true top-levels.
369 let owner = GetWindow(hwnd, GW_OWNER).unwrap_or_default();
370 if owner.0.is_null() {
371 ctx.found = Some(hwnd);
372 return BOOL(0); // stop enumeration
373 }
374 }
375 }
376 BOOL(1)
377 }
378 // SAFETY: `ctx` outlives the synchronous enumeration.
379 unsafe {
380 let _ = EnumWindows(Some(enum_proc), LPARAM(&mut ctx as *mut Ctx as isize));
381 }
382 ctx.found
383 }
384
385 fn toggle_pin(captured_host: HostWindow) -> Result<bool> {
386 let mut state = STATE.lock().unwrap_or_else(|poison| poison.into_inner());
387 // An unconfirmed request keeps its original target and restore data.
388 // The next request restores that window, even if focus has changed.
389 let restoring = state.host.is_some();
390 let host = state.host.unwrap_or(captured_host);
391 if let Err(error) = host.hwnd() {
392 // Retire geometry only when the original owner is gone. A failed
393 // observation must preserve it for the next restore attempt.
394 *state = State::new();
395 PINNED.store(false, Ordering::Release);
396 return Err(error);
397 }
398 let before = observe(host)?;
399 if !restoring {
400 state.host = Some(host);
401 state.was_maximized = before.maximized;
402 state.saved_rect = Some(before.rect);
403 if before.maximized {
404 if !unsafe { ShowWindowAsync(host.hwnd()?, SW_RESTORE) }.as_bool() {
405 bail!("terminal restore request was rejected");
406 }
407 if !wait_for(host, Duration::from_millis(800), |observed| {
408 !observed.maximized
409 })? {
410 bail!("terminal restore was not observed before the deadline");
411 }
412 state.saved_rect = Some(observe(host)?.rect);
413 }
414 }
415
416 let saved = state
417 .saved_rect
418 .context("terminal restore geometry is unavailable")?;
419 let target = Observation {
420 topmost: !restoring,
421 maximized: restoring && state.was_maximized,
422 rect: if restoring {
423 saved
424 } else {
425 RECT {
426 left: saved.left,
427 top: saved.top,
428 right: saved.left + PINNED_W,
429 bottom: saved.top + PINNED_H,
430 }
431 },
432 };
433 // Both mutations post to the foreign window's input queue. Do not
434 // activate it after the user has moved focus while the worker runs.
435 let flags = SWP_SHOWWINDOW | SWP_ASYNCWINDOWPOS | SWP_NOACTIVATE;
436 for attempt in 0..2 {
437 let hwnd = host.hwnd()?;
438 unsafe {
439 SetWindowPos(
440 hwnd,
441 Some(if restoring {
442 HWND_NOTOPMOST
443 } else {
444 HWND_TOPMOST
445 }),
446 target.rect.left,
447 target.rect.top,
448 target.rect.right - target.rect.left,
449 target.rect.bottom - target.rect.top,
450 if target.maximized {
451 flags | SWP_NOMOVE | SWP_NOSIZE
452 } else {
453 flags
454 },
455 )?;
456 if target.maximized && !ShowWindowAsync(hwnd, SW_MAXIMIZE).as_bool() {
457 bail!("terminal maximize request was rejected");
458 }
459 }
460 let applied = wait_for(host, Duration::from_millis(400), |observed| {
461 observed.matches(target)
462 })?;
463 tracing::info!(
464 applied,
465 restoring,
466 attempt,
467 "window_control: observed window result"
468 );
469 if applied {
470 if restoring {
471 *state = State::new();
472 }
473 return Ok(target.topmost);
474 }
475 }
476 bail!("terminal window change was not observed before the deadline")
477 }
478
479 pub(super) fn pinned() -> bool {
480 PINNED.load(Ordering::Acquire)
481 }
482
483 #[cfg(test)]
484 mod tests {
485 use super::*;
486
487 #[test]
488 fn pin_receipt_requires_observed_geometry_and_topmost_state() {
489 let target = Observation {
490 rect: RECT {
491 left: 20,
492 top: 30,
493 right: 660,
494 bottom: 430,
495 },
496 topmost: true,
497 maximized: false,
498 };
499 assert!(target.matches(target));
500 assert!(
501 !Observation {
502 topmost: false,
503 ..target
504 }
505 .matches(target)
506 );
507 assert!(
508 !Observation {
509 maximized: true,
510 ..target
511 }
512 .matches(target)
513 );
514 assert!(
515 !Observation {
516 rect: RECT {
517 right: 1020,
518 ..target.rect
519 },
520 ..target
521 }
522 .matches(target)
523 );
524 assert!(
525 !Observation {
526 rect: RECT {
527 left: 30,
528 right: 670,
529 ..target.rect
530 },
531 ..target
532 }
533 .matches(target)
534 );
535 }
536
537 #[test]
538 fn restore_receipt_requires_observed_maximize_and_unpin() {
539 let target = Observation {
540 rect: RECT::default(),
541 topmost: false,
542 maximized: true,
543 };
544 assert!(
545 Observation {
546 rect: RECT {
547 left: 0,
548 top: 0,
549 right: 1920,
550 bottom: 1080
551 },
552 ..target
553 }
554 .matches(target)
555 );
556 assert!(
557 !Observation {
558 maximized: false,
559 ..target
560 }
561 .matches(target)
562 );
563 assert!(
564 !Observation {
565 topmost: true,
566 ..target
567 }
568 .matches(target)
569 );
570 }
571
572 #[test]
573 fn renderer_snapshot_does_not_wait_for_worker_state_lock() {
574 let state = STATE.lock().unwrap_or_else(|poison| poison.into_inner());
575 let (tx, rx) = std::sync::mpsc::sync_channel(1);
576 let reader = std::thread::spawn(move || tx.send(pinned()).unwrap());
577 let observed = rx.recv_timeout(Duration::from_secs(1));
578 // Release even on failure, so a regression cannot hang the suite.
579 drop(state);
580 reader.join().unwrap();
581 assert!(observed.is_ok(), "rendering waited for the window worker");
582 }
583
584 #[test]
585 fn headless_dispatch_rejects_before_resolving_or_changing_a_window() {
586 let error = start_toggle(None).unwrap_err();
587 assert!(error.to_string().contains("mailbox is unavailable"));
588 }
589 }
590 }
591
592 #[cfg(not(windows))]
593 mod imp {
594 pub(super) fn start_toggle(
595 _completion_tx: Option<tokio::sync::mpsc::Sender<crate::tui::app::DispatchApplyFn>>,
596 ) -> anyhow::Result<()> {
597 anyhow::bail!("window pinning is only supported on Windows")
598 }
599
600 pub(super) fn pinned() -> bool {
601 false
602 }
603 }
604
605 /// Whether host-window control is available on this platform.
606 /// Only Windows consoles can be driven from inside the TUI.
607 pub(crate) fn available() -> bool {
608 cfg!(windows)
609 }
610
611 /// Request a window change without blocking input or claiming it has applied.
612 /// Both entry points share the worker and its observed completion receipt.
613 pub(crate) fn toggle_pin(app: &mut crate::tui::app::App) {
614 if let Err(error) = imp::start_toggle(app.dispatch_completion_tx.clone()) {
615 show_result(app, Err(error));
616 }
617 }
618
619 fn show_result(app: &mut crate::tui::app::App, result: anyhow::Result<bool>) {
620 use crate::tui::app::StatusToastLevel;
621 use codewhale_localization::MessageId;
622 let (message, level) = match result {
623 Ok(true) => (MessageId::WindowPinActive, StatusToastLevel::Info),
624 Ok(false) => (MessageId::WindowPinReleased, StatusToastLevel::Info),
625 Err(error) => {
626 tracing::warn!(%error, "window_control: window change failed or unconfirmed");
627 (MessageId::WindowPinFailed, StatusToastLevel::Warning)
628 }
629 };
630 app.push_status_toast(app.tr(message).into_owned(), level, Some(8_000));
631 app.needs_redraw = true;
632 }
633
634 /// Whether the host window is currently the pinned (always-on-top mini)
635 /// state. The TUI reads this each frame to switch to the mini-window layout.
636 pub(crate) fn pinned() -> bool {
637 imp::pinned()
638 }
639
639 lines RUST