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 0000000..c71a391 Binary files /dev/null and b/assets/dock-window-list-preview.png differ diff --git a/settings/src/config.rs b/settings/src/config.rs index 16c3ae7..5792925 100644 --- a/settings/src/config.rs +++ b/settings/src/config.rs @@ -24,6 +24,7 @@ pub struct Config { pub dock: bool, pub dock_autohide: bool, pub hide_taskbar: bool, + pub window_list: String, } impl Default for Config { @@ -54,6 +55,8 @@ impl Config { choice(&backdrop, &["acrylic", "mica", "solid"], "backdrop")?; let position = string("position")?; choice(&position, &["center", "bottom"], "position")?; + let window_list = string("window_list")?; + choice(&window_list, &["always", "multiple", "off"], "window_list")?; let max_results = get("max_results") .as_integer() .filter(|value| *value >= 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,