From 57c98a14cad841dd078f9311b7dbaa07bf542c7d Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Sat, 7 Nov 2026 17:32:02 +0100 Subject: [PATCH] Add compact dock window list --- README.md | 1 + assets/dock-window-list-preview.png | Bin 0 -> 13203 bytes settings/src/config.rs | 11 + settings/src/ui.rs | 35 ++++ src/app.rs | 1 + src/config.rs | 23 ++ src/default-config.toml | 3 + src/dock.rs | 315 +++++++++++++++++++++++++++- src/dock/logic.rs | 21 ++ src/dock/render.rs | 58 +++++ 10 files changed, 463 insertions(+), 5 deletions(-) create mode 100644 assets/dock-window-list-preview.png diff --git a/README.md b/README.md index 9deb80e..8343f96 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ Open **Settings** from Wisp's tray icon, or edit `%APPDATA%\wisp\config.toml`. T | `hotkey` | `"Alt+Space"` | Shows or hides the launcher | | `dock` | `true` | Shows the dock | | `dock_autohide` | `true` | Hides the dock until the pointer touches the bottom edge. `false` keeps it visible. | +| `window_list` | `"multiple"` | Shows the app's window list for multiple windows, always, or never | | `hide_taskbar` | `true` | Hides the Windows taskbar while Wisp runs | | `position` | `"bottom"` | `"bottom"` grows the launcher from the dock; `"center"` opens it in the middle | | `theme` | `"system"` | `"system"`, `"dark"` or `"light"` | diff --git a/assets/dock-window-list-preview.png b/assets/dock-window-list-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..c71a391a3176943ca8647511d07df6f41c42dd93 GIT binary patch literal 13203 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 ikAd3c2SEKJahqF)Qu= 0) @@ -83,6 +86,7 @@ impl Config { dock: boolean("dock")?, dock_autohide: boolean("dock_autohide")?, hide_taskbar: boolean("hide_taskbar")?, + window_list, }) } } @@ -205,6 +209,10 @@ fn validate_edit(key: &str, value: &Value) -> Result<(), String> { | "dock" | "dock_autohide" | "hide_taskbar" => {} + "window_list" if !matches!(value.as_str(), Some("always" | "multiple" | "off")) => { + return Err("window_list must be always, multiple or off.".into()); + } + "window_list" => {} _ => return Err(format!("Unknown setting: {key}")), } Ok(()) @@ -230,6 +238,8 @@ mod tests { assert!(!old.dock && !old.hide_taskbar); assert_eq!(old.position, "center"); assert_eq!(old.max_results, config.max_results); + assert_eq!(config.window_list, "multiple"); + assert_eq!(Config::from_document(&DEFAULT_CONFIG.parse().unwrap()).unwrap(), config); } #[test] @@ -312,6 +322,7 @@ mod tests { ("max_results", 0.into()), ("max_results", 21.into()), ("theme", "blue".into()), + ("window_list", "sometimes".into()), ("unknown", true.into()), ] { assert!(store.set(key, value).is_err(), "{key}"); diff --git a/settings/src/ui.rs b/settings/src/ui.rs index 0a20c1c..4360cf3 100644 --- a/settings/src/ui.rs +++ b/settings/src/ui.rs @@ -25,6 +25,11 @@ const ICON_SVG: &[u8] = include_bytes!("../../assets/icon.svg"); /// Config values and the labels the dropdowns show for them. const THEMES: [(&str, &str); 3] = [("system", "System"), ("dark", "Dark"), ("light", "Light")]; const BACKDROPS: [(&str, &str); 3] = [("acrylic", "Acrylic"), ("mica", "Mica"), ("solid", "Solid")]; +const WINDOW_LISTS: [(&str, &str); 3] = [ + ("multiple", "Only for multiple windows"), + ("always", "Always"), + ("off", "Off"), +]; const ONBOARDING_STEPS: usize = 3; type Choices = SelectState>; @@ -84,6 +89,7 @@ pub struct Settings { folder: Entity, theme: Entity, backdrop: Entity, + window_list: Entity, results: Entity, autostart: bool, powertoys: Result, @@ -141,6 +147,14 @@ impl Settings { cx, ) }); + let window_list = cx.new(|cx| { + SelectState::new( + SearchableVec::new(labels(&WINDOW_LISTS)), + index_of(&WINDOW_LISTS, &config.window_list), + window, + cx, + ) + }); let results = cx.new(|_| { SliderState::new() .min(1.) @@ -193,6 +207,14 @@ impl Settings { } }) .detach(); + cx.subscribe_in(&window_list, window, |this, _, event, window, cx| { + if let SelectEvent::Confirm(Some(label)) = event + && let Some(value) = value_of(&WINDOW_LISTS, label) + { + this.save("window_list", value.into(), window, cx); + } + }) + .detach(); cx.subscribe_in(&results, window, |this, _, event, window, cx| { if let SliderEvent::Change(value) = event { let value = (value.start().round() as i64).clamp(1, 20); @@ -227,6 +249,7 @@ impl Settings { folder, theme, backdrop, + window_list, results, autostart: system::autostart_enabled(), powertoys: system::powertoys_run_enabled(), @@ -310,6 +333,9 @@ impl Settings { let backdrop = index_of(&BACKDROPS, &self.config.backdrop); self.backdrop .update(cx, |state, cx| state.set_selected_index(backdrop, window, cx)); + let window_list = index_of(&WINDOW_LISTS, &self.config.window_list); + self.window_list + .update(cx, |state, cx| state.set_selected_index(window_list, window, cx)); let results = self.config.max_results as f32; self.results .update(cx, |state, cx| state.set_value(results, window, cx)); @@ -641,6 +667,15 @@ impl Settings { p, )) .child(divider(p)) + .child(row( + "App window list", + "Show a compact window list when hovering a running app icon.", + Select::new(&self.window_list) + .accessibility_label("App window list") + .w(px(190.)), + p, + )) + .child(divider(p)) .child(row( "Hide the taskbar", "Hide the Windows taskbar while Wisp runs. The taskbar comes back when Wisp quits.", diff --git a/src/app.rs b/src/app.rs index 839e56b..6d2fa87 100644 --- a/src/app.rs +++ b/src/app.rs @@ -611,6 +611,7 @@ impl Launcher { if let Some(dock) = &mut self.dock { dock.pace_frames(); dock.set_apps(&self.apps, &self.icons); + dock.set_window_list_mode(self.config.window_list); // Notification icons need a home while the taskbar is hidden. dock.set_tray(self.taskbar.as_ref().is_some_and(TaskbarHider::is_active)); dock.set_autohide(self.config.dock_autohide); diff --git a/src/config.rs b/src/config.rs index c21fbe9..870526d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,6 +40,15 @@ pub enum Position { /// Keys missing from the user's file take their value from `config-0.3.toml`, /// then from the default config, as in wisp-settings, so both apps agree on /// what an old config means. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum WindowListMode { + #[default] + Multiple, + Always, + Off, +} + #[derive(Debug, Deserialize)] pub struct Config { pub hide_taskbar: bool, @@ -54,6 +63,7 @@ pub struct Config { pub search_url: String, pub file_search: bool, pub file_search_exclude: Vec, + pub window_list: WindowListMode, } impl Default for Config { @@ -111,6 +121,19 @@ mod tests { fn legacy_pins_are_ignored() { let config = Config::parse("dock = true\ndock_apps = ['old.app']").unwrap(); assert!(config.dock); + assert_eq!(config.window_list, super::WindowListMode::Multiple); + } + + #[test] + fn window_list_modes_deserialize_and_older_config_defaults_to_multiple() { + for (value, expected) in [ + ("multiple", super::WindowListMode::Multiple), + ("always", super::WindowListMode::Always), + ("off", super::WindowListMode::Off), + ] { + let config = Config::parse(&format!("window_list = '{value}'")).unwrap(); + assert_eq!(config.window_list, expected); + } } #[test] diff --git a/src/default-config.toml b/src/default-config.toml index 60c6a20..64ec42a 100644 --- a/src/default-config.toml +++ b/src/default-config.toml @@ -44,3 +44,6 @@ dock = true # false, the dock stays on the main screen and windows stop above it while the # taskbar is hidden. dock_autohide = true +# App window list shown when hovering a running app icon: +# "multiple" (only when the app has multiple windows), "always", or "off". +window_list = "multiple" diff --git a/src/dock.rs b/src/dock.rs index 2b444e6..5c18fca 100644 --- a/src/dock.rs +++ b/src/dock.rs @@ -41,7 +41,7 @@ use windows::Win32::UI::WindowsAndMessaging::*; use windows::core::{Error, GUID, Result, w}; use crate::commands::open_windows::{self, Window}; -use crate::config::Backdrop; +use crate::config::{Backdrop, WindowListMode}; use crate::indexer::{AppEntry, IconPixels}; use crate::theme::Palette; use crate::tray_host::{self, Action, TrayHost}; @@ -59,6 +59,11 @@ const TIP_TIMER: usize = 104; const TIP_MS: u32 = 450; /// Tooltips wrap past this width, in DIPs. const TIP_MAX: f32 = 320.0; +/// Lists an app's windows after the same hover, and hides it once the pointer +/// has left both the list and the dock. +const WINDOWS_TIMER: usize = 107; +const WINDOWS_HIDE_TIMER: usize = 108; +const WINDOWS_HIDE_MS: u32 = 220; const REFRESH_TIMER: usize = 101; /// Watches the pointer for the bottom edge while the dock is hidden, and /// for leaving while it shows. @@ -91,6 +96,7 @@ struct State { items: Vec, palette: Palette, backdrop: Backdrop, + window_list_mode: WindowListMode, renderer: render::Renderer, dpi: u32, hover: Option, @@ -146,6 +152,14 @@ struct State { /// What the pointer rests on, and the text the tooltip shows for it. tip_target: Option, tip_text: String, + /// The window list above the hovered app icon: which slot it belongs to, + /// its rows and their windows, and the row under the pointer. + windows: HWND, + windows_renderer: render::Renderer, + windows_slot: Option, + windows_rows: Vec, + windows_targets: Vec, + windows_hover: Option, /// The last click on a notification icon, to tell double clicks. tray_click: Option<(tray_host::Key, Instant)>, /// The demo's flyout and its hovered icon, applied as icons arrive. @@ -181,6 +195,7 @@ impl Dock { items: Vec::new(), palette: Palette::new(true, true), backdrop: Backdrop::Acrylic, + window_list_mode: WindowListMode::Always, renderer: render::Renderer::new()?, dpi: 96, hover: None, @@ -222,6 +237,12 @@ impl Dock { tip_renderer: render::Renderer::new()?, tip_target: None, tip_text: String::new(), + windows: HWND::default(), + windows_renderer: render::Renderer::new()?, + windows_slot: None, + windows_rows: Vec::new(), + windows_targets: Vec::new(), + windows_hover: None, tray_click: None, held_flyout: None, })); @@ -280,6 +301,11 @@ impl Dock { WS_EX_TOOLWINDOW | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TRANSPARENT, w!("Wisp Dock tooltip"), ), + // The window list takes clicks, but not the focus. + ( + WS_EX_TOOLWINDOW | WS_EX_TOPMOST | WS_EX_NOACTIVATE, + w!("Wisp Dock windows"), + ), ] { let window = unsafe { CreateWindowExW( @@ -300,8 +326,10 @@ impl Dock { let mut s = state.borrow_mut(); if s.flyout.is_invalid() { s.flyout = window; - } else { + } else if s.tip.is_invalid() { s.tip = window; + } else { + s.windows = window; } } EVENT_TARGET.with(|target| target.set(hwnd)); @@ -353,7 +381,8 @@ impl Dock { } pub fn owns_message(msg: u32, wp: WPARAM) -> bool { - msg == WM_TIMER && (101..=106).contains(&wp.0) || matches!(msg, WM_REFRESH | WM_TRAY_CHANGED) + msg == WM_TIMER && (REFRESH_TIMER..=WINDOWS_HIDE_TIMER).contains(&wp.0) + || matches!(msg, WM_REFRESH | WM_TRAY_CHANGED) } pub fn mostly_visible(&self) -> bool { @@ -398,6 +427,7 @@ impl Dock { let mut s = self.state.borrow_mut(); s.close_flyout(); s.hide_tip(); + s.hide_windows(); let mut rect = RECT::default(); unsafe { let _ = GetWindowRect(self.hwnd, &mut rect); @@ -453,6 +483,14 @@ impl Dock { s.refresh(); } + pub fn set_window_list_mode(&mut self, mode: WindowListMode) { + let mut s = self.state.borrow_mut(); + s.window_list_mode = mode; + if mode == WindowListMode::Off { + s.hide_windows(); + } + } + pub fn refresh(&mut self) { self.state.borrow_mut().refresh(); } @@ -557,6 +595,7 @@ impl Dock { let mut s = self.state.borrow_mut(); s.close_flyout(); s.hide_tip(); + s.hide_windows(); s.visible = false; s.stop_slide(); s.revealed = false; @@ -580,9 +619,11 @@ impl Drop for Dock { let _ = KillTimer(Some(self.hwnd), TIP_TIMER); let _ = KillTimer(Some(self.hwnd), HOVER_TIMER); let _ = KillTimer(Some(self.hwnd), DESKTOP_TIMER); + let _ = KillTimer(Some(self.hwnd), WINDOWS_TIMER); + let _ = KillTimer(Some(self.hwnd), WINDOWS_HIDE_TIMER); // Remove the pointer before synchronous destruction can reenter the wndproc. let s = self.state.borrow(); - for hwnd in [s.flyout, s.tip, self.hwnd] { + for hwnd in [s.flyout, s.tip, s.windows, self.hwnd] { SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); } drop(s); @@ -590,6 +631,7 @@ impl Drop for Dock { if self.shared { let _ = DestroyWindow(self.state.borrow().flyout); let _ = DestroyWindow(self.state.borrow().tip); + let _ = DestroyWindow(self.state.borrow().windows); } else { let _ = DestroyWindow(self.hwnd); } @@ -643,7 +685,7 @@ impl State { fn apply_material(&mut self, dark: bool, backdrop: Backdrop) { self.backdrop = backdrop; self.palette = crate::theme::apply_backdrop(self.hwnd, dark, backdrop, true); - for hwnd in [self.flyout, self.tip] { + for hwnd in [self.flyout, self.tip, self.windows] { crate::theme::apply_backdrop(hwnd, dark, backdrop, true); unsafe { let _ = InvalidateRect(Some(hwnd), None, false); @@ -694,6 +736,11 @@ impl State { self.items = logic::stable_order(std::mem::take(&mut self.items), items, |old, new| { old.app.id.eq_ignore_ascii_case(&new.app.id) }); + if let Some(slot) = self.windows_slot + && self.item_at(slot).is_none_or(|item| item.windows.is_empty()) + { + self.hide_windows(); + } self.status = status::read(); self.place(); if self.suspended { @@ -735,6 +782,7 @@ impl State { self.revealed = false; self.close_flyout(); self.hide_tip(); + self.hide_windows(); unsafe { let _ = ShowWindow(self.hwnd, SW_HIDE); } @@ -774,9 +822,18 @@ impl State { let slack = (12 * self.dpi / 96) as i32; // The gap below the dock counts as on it, so the pointer can rest on the edge. // The dock stays while its flyout is open. + let mut windows_rect = RECT::default(); + let windows_open = unsafe { IsWindowVisible(self.windows) }.as_bool() + && unsafe { GetWindowRect(self.windows, &mut windows_rect) }.is_ok(); + let over_windows = windows_open + && cursor.x >= windows_rect.left + && cursor.x < windows_rect.right + && cursor.y >= windows_rect.top + && cursor.y < windows_rect.bottom; let near = !self.autohide || self.held || self.flyout_open + || over_windows || cursor.x >= rect.left - slack && cursor.x < rect.right + slack && cursor.y >= rect.top - slack; if near { self.away = 0; @@ -784,6 +841,7 @@ impl State { self.away += 1; if self.away >= HIDE_AFTER { self.hide_tip(); + self.hide_windows(); self.revealed = false; self.start_slide(1.0); return; @@ -1023,6 +1081,28 @@ impl State { }) } + /// An app slot's rectangle on screen where the dock rests, in physical pixels. + fn item_rect(&self, slot: usize) -> RECT { + let scale = self.dpi as f32 / 96.0; + let px = |dip: f32| (dip * scale).round() as i32; + let rect = self.surface_rect; + let x = |dip| match rect { + Some(rect) => { + rect.left + (px(dip) as f32 * (rect.right - rect.left) as f32 / self.width.max(1) as f32).round() as i32 + } + None => self.left + px(dip), + }; + let top = rect.map_or(self.top + self.slide_offset(px(logic::HEIGHT)), |r| { + r.bottom - px(logic::HEIGHT) + }); + RECT { + left: x(logic::left(slot)), + top, + right: x(logic::left(slot) + logic::SLOT), + bottom: top + px(logic::HEIGHT), + } + } + /// A flyout cell's rectangle on screen, in physical pixels. fn cell_rect(&self, index: usize) -> RECT { let scale = self.dpi as f32 / 96.0; @@ -1123,6 +1203,7 @@ impl State { fn open_flyout(&mut self) { self.hide_tip(); + self.hide_windows(); self.flyout_open = true; self.flyout_hover = None; self.layout_flyout(); @@ -1237,6 +1318,135 @@ impl State { } } + /// Lists the windows of the app under the pointer, or of none. + fn hover_windows(&mut self, slot: Option) { + unsafe { + let _ = KillTimer(Some(self.hwnd), WINDOWS_HIDE_TIMER); + } + let slot = slot.filter(|&slot| { + self.window_list_mode != WindowListMode::Off + && self + .item_at(slot) + .is_some_and(|item| logic::show_window_list(self.window_list_mode, item.windows.len())) + }); + if self.windows_slot == slot { + return; + } + let shown = self.windows_slot.is_some(); + self.hide_windows(); + self.windows_slot = slot; + if slot.is_some() { + unsafe { + let _ = SetTimer(Some(self.hwnd), WINDOWS_TIMER, if shown { 0 } else { TIP_MS }, None); + } + } + } + + /// The item in a visible slot. + fn item_at(&self, slot: usize) -> Option<&Item> { + slot.checked_sub(1).and_then(|i| self.items.get(i + self.scroll)) + } + + /// Hides the window list and forgets its windows. + fn hide_windows(&mut self) { + self.windows_slot = None; + self.windows_hover = None; + self.windows_rows.clear(); + self.windows_targets.clear(); + unsafe { + let _ = KillTimer(Some(self.hwnd), WINDOWS_TIMER); + let _ = KillTimer(Some(self.hwnd), WINDOWS_HIDE_TIMER); + let _ = ShowWindow(self.windows, SW_HIDE); + } + } + + /// Keeps the list a moment while the pointer crosses to it, then hides it. + fn leave_windows(&mut self) { + if self.windows_slot.is_none() { + return; + } + let shown = unsafe { IsWindowVisible(self.windows) }.as_bool(); + if !shown { + // It never appeared, so a pending hover is now stale. + self.hide_windows(); + return; + } + unsafe { + let _ = SetTimer(Some(self.hwnd), WINDOWS_HIDE_TIMER, WINDOWS_HIDE_MS, None); + } + } + + /// Draws the hovered app's windows above its icon. + fn show_windows(&mut self) { + unsafe { + let _ = KillTimer(Some(self.hwnd), WINDOWS_TIMER); + } + let Some(slot) = self.windows_slot else { return }; + let Some(item) = self.item_at(slot) else { + self.hide_windows(); + return; + }; + if !logic::show_window_list(self.window_list_mode, item.windows.len()) { + self.hide_windows(); + return; + } + let rows: Vec = item + .windows + .iter() + .map(|window| match window.title.trim() { + "" => item.app.name.clone(), + title => title.to_string(), + }) + .collect(); + let targets: Vec = item.windows.iter().map(|window| window.hwnd).collect(); + if rows.is_empty() { + self.hide_windows(); + return; + } + let scale = self.dpi as f32 / 96.0; + let Ok((width, height)) = self.windows_renderer.measure_windows(&rows) else { + return; + }; + let (width, height) = ((width * scale).ceil() as i32, (height * scale).ceil() as i32); + let mut info = MONITORINFO { + cbSize: size_of::() as u32, + ..Default::default() + }; + unsafe { + let _ = GetMonitorInfoW(MonitorFromWindow(self.hwnd, MONITOR_DEFAULTTONEAREST), &mut info); + } + let anchor = self.item_rect(slot); + let gap = (6.0 * scale).round() as i32; + let x = ((anchor.left + anchor.right - width) / 2) + .min(info.rcMonitor.right - gap - width) + .max(info.rcMonitor.left + gap); + self.windows_hover = None; + self.windows_rows = rows; + self.windows_targets = targets; + unsafe { + let _ = SetWindowPos( + self.windows, + Some(HWND_TOPMOST), + x, + anchor.top - gap - height, + width, + height, + SWP_NOACTIVATE | SWP_SHOWWINDOW, + ); + let _ = InvalidateRect(Some(self.windows), None, false); + } + } + + /// The row of the window list under a popup pointer position. + fn windows_hit(&self, lp: LPARAM) -> Option { + let scale = self.dpi as f32 / 96.0; + let y = ((lp.0 >> 16) as i16) as f32 / scale; + let row = (y - logic::LIST_PAD.1) / logic::LIST_ROW; + (row >= 0.0) + .then_some(row as usize) + .filter(|&row| row < self.windows_rows.len()) + } + fn hide_tip(&mut self) { self.tip_target = None; self.desktop_pill = None; @@ -1307,6 +1517,7 @@ impl State { fn chevron_menu(&mut self) { self.close_flyout(); self.hide_tip(); + self.hide_windows(); unsafe { let Ok(menu) = CreatePopupMenu() else { return; @@ -1711,6 +1922,25 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) } return LRESULT(0); } + WM_TIMER if wp.0 == WINDOWS_TIMER => { + s.show_windows(); + return LRESULT(0); + } + WM_TIMER if wp.0 == WINDOWS_HIDE_TIMER => { + let _ = KillTimer(Some(hwnd), WINDOWS_HIDE_TIMER); + let mut cursor = POINT::default(); + let _ = GetCursorPos(&mut cursor); + let over = |window: HWND| { + let mut rect = RECT::default(); + GetWindowRect(window, &mut rect).is_ok() + && (rect.left..rect.right).contains(&cursor.x) + && (rect.top..rect.bottom).contains(&cursor.y) + }; + if !over(s.hwnd) && !over(s.windows) { + s.hide_windows(); + } + return LRESULT(0); + } WM_TRAY_CHANGED => { s.sync_tray(); return LRESULT(0); @@ -1739,6 +1969,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) Some(logic::Part::Tray(i)) => Some(tray::Tip::Inline(i)), _ => None, }); + s.hover_windows(if hover_part.is_none() { hover } else { None }); let _ = TrackMouseEvent(&mut TRACKMOUSEEVENT { cbSize: size_of::() as u32, dwFlags: TME_LEAVE, @@ -1752,6 +1983,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) s.animate_hover(); s.hover_part = None; s.hover_tip(None); + s.leave_windows(); let _ = InvalidateRect(Some(hwnd), None, false); return LRESULT(0); } @@ -1760,6 +1992,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) if s.hit_part(lp) != Some(logic::Part::Chevron) { s.close_flyout(); } + s.hide_windows(); if let Some(index) = s.hit(lp) { s.click(index, msg == WM_MBUTTONUP); } else if let Some(part) = s.hit_part(lp) @@ -1771,6 +2004,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) } WM_RBUTTONUP => { s.close_flyout(); + s.hide_windows(); if let Some(index) = s.hit(lp) { s.menu(index); } else { @@ -1805,6 +2039,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) s.wheel_desktops(delta.into()); return LRESULT(0); } + s.hide_windows(); s.scroll = if delta < 0 { (s.scroll + 1).min(s.items.len().saturating_sub(s.capacity)) } else { @@ -1828,6 +2063,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) return LRESULT(0); } WM_CLOSE => { + s.hide_windows(); s.stop_slide(); s.visible = false; let _ = ShowWindow(hwnd, SW_HIDE); @@ -1871,6 +2107,75 @@ unsafe extern "system" fn popup_wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LP } else if msg == WM_NCHITTEST { return LRESULT(HTCLIENT as isize); } + let windows = cell.try_borrow().is_ok_and(|s| s.windows == hwnd); + if windows { + match msg { + // Clicks land here, but the list never takes the focus. + WM_MOUSEACTIVATE => return LRESULT(MA_NOACTIVATE as isize), + WM_MOUSEMOVE => { + let Ok(mut s) = cell.try_borrow_mut() else { + return DefWindowProcW(hwnd, msg, wp, lp); + }; + s.away = 0; + let _ = KillTimer(Some(s.hwnd), WINDOWS_HIDE_TIMER); + let hover = s.windows_hit(lp); + if s.windows_hover != hover { + s.windows_hover = hover; + let _ = InvalidateRect(Some(hwnd), None, false); + } + let _ = TrackMouseEvent(&mut TRACKMOUSEEVENT { + cbSize: size_of::() as u32, + dwFlags: TME_LEAVE, + hwndTrack: hwnd, + dwHoverTime: 0, + }); + return LRESULT(0); + } + WM_MOUSELEAVE => { + let Ok(mut s) = cell.try_borrow_mut() else { + return DefWindowProcW(hwnd, msg, wp, lp); + }; + s.leave_windows(); + return LRESULT(0); + } + WM_LBUTTONUP => { + let Ok(mut s) = cell.try_borrow_mut() else { + return DefWindowProcW(hwnd, msg, wp, lp); + }; + if let Some(row) = s.windows_hit(lp) + && let Some(&target) = s.windows_targets.get(row) + { + s.hide_windows(); + if let Err(error) = open_windows::focus(target) { + eprintln!("Dock window: {error}"); + } + } + return LRESULT(0); + } + WM_PAINT => { + let Ok(mut s) = cell.try_borrow_mut() else { + return DefWindowProcW(hwnd, msg, wp, lp); + }; + let mut paint = PAINTSTRUCT::default(); + let _ = BeginPaint(hwnd, &mut paint); + let State { + windows_renderer, + palette, + dpi, + windows_rows, + windows_hover, + .. + } = &mut *s; + if let Err(error) = windows_renderer.draw_windows(hwnd, *dpi, palette, windows_rows, *windows_hover) + { + eprintln!("Dock window list: {error}"); + } + let _ = EndPaint(hwnd, &paint); + return LRESULT(0); + } + _ => return DefWindowProcW(hwnd, msg, wp, lp), + } + } let Ok(mut s) = cell.try_borrow_mut() else { return DefWindowProcW(hwnd, msg, wp, lp); }; diff --git a/src/dock/logic.rs b/src/dock/logic.rs index 81d470c..b60c154 100644 --- a/src/dock/logic.rs +++ b/src/dock/logic.rs @@ -1,5 +1,6 @@ //! Matching and DIP geometry. +use crate::config::WindowListMode; use crate::indexer::AppEntry; pub const ICON: f32 = 32.0; @@ -45,6 +46,15 @@ pub const TRAY_W: f32 = 32.0; pub const VOLUME_W: f32 = 46.0; pub const BATTERY_W: f32 = 50.0; pub const CLOCK_W: f32 = 88.0; +/// The window list above an app icon: its padding, one row per window, and +/// the widest a row may be before it is clipped, in DIPs. +pub const LIST_PAD: (f32, f32) = (7.0, 3.0); +pub const LIST_ROW: f32 = 22.0; +pub const LIST_TEXT_MAX: f32 = 280.0; + +pub fn show_window_list(mode: WindowListMode, windows: usize) -> bool { + windows > 0 && (mode == WindowListMode::Always || mode == WindowListMode::Multiple && windows > 1) +} /// Notification-area icons shown in the dock; the rest are behind the chevron. pub const MAX_INLINE: usize = 5; /// Tray icons share the dock's enlarged visual rhythm. @@ -224,6 +234,17 @@ pub fn stable_order(previous: Vec, current: Vec, same: impl Fn(&T, &T) #[cfg(test)] mod tests { use super::*; + + #[test] + fn window_list_modes_follow_the_selected_policy() { + assert!(show_window_list(WindowListMode::Always, 1)); + assert!(show_window_list(WindowListMode::Always, 3)); + assert!(!show_window_list(WindowListMode::Multiple, 1)); + assert!(show_window_list(WindowListMode::Multiple, 2)); + assert!(!show_window_list(WindowListMode::Off, 1)); + assert!(!show_window_list(WindowListMode::Off, 2)); + assert!(!show_window_list(WindowListMode::Always, 0)); + } #[test] fn slide_endpoints_and_reversal_are_continuous() { assert_eq!(slide_value(1.0, 0.0, 0.0), 1.0); diff --git a/src/dock/render.rs b/src/dock/render.rs index b5d1717..635307e 100644 --- a/src/dock/render.rs +++ b/src/dock/render.rs @@ -42,6 +42,8 @@ struct Formats { chevron: IDWriteTextFormat, /// Tooltips: left-aligned and wrapping. tip: IDWriteTextFormat, + /// One line of the window list, clipped at the card's edge. + row: IDWriteTextFormat, } pub struct Renderer { @@ -125,6 +127,8 @@ impl Renderer { tip.SetTextAlignment(DWRITE_TEXT_ALIGNMENT_LEADING)?; tip.SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_NEAR)?; tip.SetWordWrapping(DWRITE_WORD_WRAPPING_WRAP)?; + let row = format(text, DWRITE_FONT_WEIGHT_NORMAL, 11.5)?; + row.SetTextAlignment(DWRITE_TEXT_ALIGNMENT_LEADING)?; Ok(Self { factory, formats: Formats { @@ -135,6 +139,7 @@ impl Renderer { glyph: format(icons, DWRITE_FONT_WEIGHT_NORMAL, 17.0)?, chevron: format(icons, DWRITE_FONT_WEIGHT_NORMAL, 12.0)?, tip, + row, }, dw, target: None, @@ -198,6 +203,59 @@ impl Renderer { } } + /// Draws an app's windows as a list, highlighting the row under the pointer. + pub fn draw_windows( + &mut self, + hwnd: HWND, + dpi: u32, + palette: &Palette, + rows: &[String], + hover: Option, + ) -> Result<()> { + self.paint(hwnd, dpi, palette, |target, formats| { + background(target, palette); + let size = unsafe { target.rt.GetSize() }; + for (index, row) in rows.iter().enumerate() { + let top = logic::LIST_PAD.1 + index as f32 * logic::LIST_ROW; + let line = rect( + logic::LIST_PAD.0, + top, + size.width - logic::LIST_PAD.0, + top + logic::LIST_ROW, + ); + if hover == Some(index) { + unsafe { + target.brush.SetColor(&palette.row_selected); + target.rt.FillRoundedRectangle(&rounded(line, 4.0), &target.brush); + } + } + unsafe { target.brush.SetColor(&palette.text) }; + text(&target.rt, &target.brush, &formats.row, row, line); + } + Ok(()) + }) + } + + /// The window list's window size in DIPs. + pub fn measure_windows(&self, rows: &[String]) -> Result<(f32, f32)> { + let mut width = 0.0f32; + for row in rows { + let wide: Vec = row.encode_utf16().collect(); + unsafe { + let layout = self + .dw + .CreateTextLayout(&wide, &self.formats.row, logic::LIST_TEXT_MAX, 1000.0)?; + let mut metrics = DWRITE_TEXT_METRICS::default(); + layout.GetMetrics(&mut metrics)?; + width = width.max(metrics.widthIncludingTrailingWhitespace); + } + } + Ok(( + width.ceil().min(logic::LIST_TEXT_MAX) + 2.0 * logic::LIST_PAD.0, + rows.len() as f32 * logic::LIST_ROW + 2.0 * logic::LIST_PAD.1, + )) + } + /// Draws the desktop pill: a dot per desktop, the current one long, then its name. pub fn draw_desktops( &mut self,