winspaces-ui 0.1.0

Overview overlay, settings window and tray UI for WinSpaces
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Full-screen overview overlay: a spaces bar of live space thumbnails
//! plus a window-card grid for the active space, with drag-and-drop between
//! them. State and lifecycle live here; see the sibling modules for the pure
//! geometry, DWM thumbnail bookkeeping, painting, and input handling.

mod cards;
mod geometry;
mod input;
mod render;

pub use geometry::neighbor_slot;

use std::cell::RefCell;
use std::ptr::null_mut;
use std::sync::OnceLock;
use windows_sys::Win32::Foundation::{HWND, POINT, RECT};
use windows_sys::Win32::Graphics::Dwm::DwmUnregisterThumbnail;
use windows_sys::Win32::Graphics::Gdi::{InvalidateRect, HFONT};
use windows_sys::Win32::UI::WindowsAndMessaging::{
    CreateWindowExW, GetClientRect, SetForegroundWindow, SetWindowPos, ShowWindow, HICON,
    SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOZORDER, SW_HIDE, SW_SHOW, WS_EX_TOOLWINDOW,
    WS_EX_TOPMOST, WS_POPUP,
};
use winspaces_common::log_info;
use winspaces_core::spaces::SpaceManager;
use winspaces_win32::display;
use winspaces_win32::dpi;
use winspaces_win32::dwm;
use winspaces_win32::module::app_instance;
use winspaces_win32::text::encode_wide;
use winspaces_win32::window_class::register_class;

const OVERVIEW_CLASS_NAME: &str = "WinSpacesOverview";

/// One-shot timer that flushes the final frame of a throttled space drag.
const TIMER_DRAG_PAINT: usize = 1;

/// Actions Overview cannot perform itself because they coordinate state
/// this module must not see (the daemon's application state: config, hotkeys,
/// tray badge, persisted layouts). The host installs this once at startup.
///
/// Fn pointers, deliberately, not posted messages: `WM_LBUTTONUP` completes a
/// reorder *and* the overlay refresh synchronously before it returns. Deferring
/// either to the next message-loop turn would change the frame in which the
/// overlay repaints after a drop.
///
/// Every entry is expected to take the host's state borrow at the instant it is
/// called and release it before returning — the same borrow window the inline
/// code had before the indirection, so the drag state machine's re-entrancy
/// behaviour is unchanged.
pub struct OverviewHost {
    pub add_space: fn(mon: usize),
    pub remove_space: fn(mon: usize, space: usize),
    pub reorder_space: fn(mon: usize, from: usize, to: usize),
    /// Move the monitor's *active* space one slot in `delta`'s direction. A
    /// separate entry from `reorder_space` because the source slot is the live
    /// `current`, which only the host can read.
    pub reorder_space_neighbor: fn(mon: usize, delta: i32),
    pub switch_space: fn(mon: usize, space: usize),
    pub move_window_to_space: fn(hwnd: HWND, mon: usize, space: usize),
    pub move_window_to_new_space: fn(hwnd: HWND, mon: usize),
    pub close_window: fn(hwnd: HWND),
    pub toggle_window_sticky: fn(hwnd: HWND),
}

static HOST: OnceLock<&'static OverviewHost> = OnceLock::new();

/// Install the host vtable. First call wins; later calls are ignored.
pub fn install_host(host: &'static OverviewHost) {
    let _ = HOST.set(host);
}

/// The installed host, or `None` before startup finished wiring it up. Every
/// caller is a user gesture on a visible overlay, so `None` cannot happen in
/// practice — it is a no-op rather than a panic all the same.
fn host() -> Option<&'static OverviewHost> {
    HOST.get().copied()
}

#[derive(Clone)]
pub struct SpaceCard {
    pub space_idx: usize,
    pub rect: RECT,
    pub window_count: usize,
    pub is_active: bool,
    pub is_tiled: bool,
}

#[derive(Clone)]
pub struct WindowCard {
    pub hwnd: HWND,
    pub h_thumb: isize,
    pub h_icon: HICON,
    pub card_rect: RECT,
    pub thumb_rect: RECT,
    pub title: String,
    pub is_sticky: bool,
}

pub struct Overview {
    pub hwnd: HWND,
    pub is_visible: bool,
    pub active_mon_idx: usize,
    pub active_space_idx: usize,
    pub scale: f32,
    pub space_cards: Vec<SpaceCard>,
    pub window_cards: Vec<WindowCard>,
    /// The "+" tile sits *outside* `space_cards` on purpose: every consumer of
    /// that vector (click-switch, drag-drop, digit render) may then assume it
    /// contains real spaces only.
    pub plus_rect: RECT,
    pub plus_visible: bool,
    /// Measured width of the "New Space" label in `h_font_small`, device
    /// pixels, so the tile fits the current language. Refreshed with the
    /// fonts; drag-time relayouts in `input.rs` reuse it.
    pub plus_label_w: i32,
    pub hovered_plus: bool,
    pub hovered_space: Option<usize>,
    /// Space card whose close button the pointer is over. Distinct from
    /// `hovered_space`: the button sits inside the card, and clicking it must
    /// remove the space rather than switch to it.
    pub hovered_close: Option<usize>,
    pub hovered_window: Option<usize>,
    /// Window card whose close button the pointer is over.
    pub hovered_window_close: Option<usize>,
    /// Window card whose pin/sticky button the pointer is over.
    pub hovered_window_pin: Option<usize>,
    /// Window card whose close button the pointer went *down* on. Closing an
    /// app is not undoable, so — unlike the space close button — the action
    /// waits for the button-up over the same target, leaving a misclick a way
    /// out by sliding off the circle before releasing.
    pub pressed_window_close: Option<usize>,
    /// Window card whose pin button the pointer went *down* on.
    pub pressed_window_pin: Option<usize>,
    pub dragging_window: Option<usize>,
    /// True once the pressed pointer travels past the system drag threshold;
    /// separates a click-to-focus from a real drag so the ghost never
    /// flickers on a plain click.
    pub drag_active: bool,
    pub drag_offset: POINT,
    pub dragging_space: Option<usize>,
    pub drag_space_active: bool,
    pub drag_space_current_x: i32,
    pub drag_space_target_slot: Option<usize>,
    /// Tick of the last space-drag repaint, and the display's frame interval.
    /// A mouse reports far faster than the panel refreshes, and this window is
    /// DWM-composited: blitting more than once per frame lets the compositor
    /// sample a half-updated surface, which reads as tearing across the card.
    pub drag_paint_tick: u32,
    pub drag_frame_ms: u32,
    pub h_font_title: HFONT,
    pub h_font_card: HFONT,
    pub h_font_small: HFONT,
    /// The ✕ in the close button. Its own size because the button is a fixed
    /// 20px circle — it cannot follow `h_font_small` when that grows.
    pub h_font_close: HFONT,
    /// Segoe Fluent Icons, for the "new space" tile's glyph.
    pub h_font_glyph: HFONT,
    /// Segoe Fluent Icons, for the window pin/sticky button glyph.
    pub h_font_pin: HFONT,
}

thread_local! {
    static OVERVIEW_STATE: RefCell<Overview> = const { RefCell::new(Overview::new()) };
}

impl Overview {
    pub const fn new() -> Self {
        Self {
            hwnd: null_mut(),
            is_visible: false,
            active_mon_idx: 0,
            active_space_idx: 0,
            scale: 1.0,
            space_cards: Vec::new(),
            window_cards: Vec::new(),
            plus_rect: RECT {
                left: 0,
                top: 0,
                right: 0,
                bottom: 0,
            },
            plus_visible: false,
            plus_label_w: 0,
            hovered_plus: false,
            hovered_space: None,
            hovered_close: None,
            hovered_window: None,
            hovered_window_close: None,
            hovered_window_pin: None,
            pressed_window_close: None,
            pressed_window_pin: None,
            dragging_window: None,
            drag_active: false,
            drag_offset: POINT { x: 0, y: 0 },
            dragging_space: None,
            drag_space_active: false,
            drag_space_current_x: 0,
            drag_space_target_slot: None,
            drag_paint_tick: 0,
            drag_frame_ms: 16,
            h_font_title: null_mut(),
            h_font_card: null_mut(),
            h_font_small: null_mut(),
            h_font_close: null_mut(),
            h_font_glyph: null_mut(),
            h_font_pin: null_mut(),
        }
    }
}

impl Default for Overview {
    fn default() -> Self {
        Self::new()
    }
}

pub fn init_overview() {
    log_info!("Initialized Overview subsystem");
}

pub fn is_overview_active() -> bool {
    OVERVIEW_STATE.with(|s| s.borrow().is_visible)
}

pub fn toggle_overview(mgr: &mut SpaceManager) {
    if is_overview_active() {
        hide_overview();
    } else {
        show_overview(mgr);
    }
}

pub fn show_overview(mgr: &mut SpaceManager) {
    mgr.scan_untracked_windows();
    unsafe {
        // 1. Determine active monitor & active space
        let mon_idx = mgr.get_active_monitor_index();
        if mon_idx >= mgr.monitors.len() {
            return;
        }
        let space_idx = mgr.monitors[mon_idx].current;
        let hmon = mgr.monitors[mon_idx].hmon;

        let Some(mon_rect) = display::monitor_rect_of(hmon) else {
            return;
        };
        let width = mon_rect.right - mon_rect.left;
        let height = mon_rect.bottom - mon_rect.top;

        OVERVIEW_STATE.with(|s| {
            let mut mc = s.borrow_mut();
            if mc.is_visible {
                return;
            }

            mc.active_mon_idx = mon_idx;
            mc.active_space_idx = space_idx;
            mc.space_cards.clear();
            mc.window_cards.clear();
            mc.hovered_space = None;
            mc.hovered_window = None;
            mc.hovered_window_close = None;
            mc.pressed_window_close = None;
            mc.dragging_window = None;
            mc.drag_active = false;
            mc.dragging_space = None;
            mc.drag_space_active = false;
            mc.drag_space_current_x = 0;
            mc.drag_space_target_slot = None;

            // 2. Ensure Window Class & HWND
            if mc.hwnd.is_null() {
                register_class(OVERVIEW_CLASS_NAME, Some(input::overview_wnd_proc));
                let hinst = app_instance();
                let class_name = encode_wide(OVERVIEW_CLASS_NAME);

                mc.hwnd = CreateWindowExW(
                    WS_EX_TOPMOST | WS_EX_TOOLWINDOW,
                    class_name.as_ptr(),
                    std::ptr::null(),
                    WS_POPUP,
                    mon_rect.left,
                    mon_rect.top,
                    width,
                    height,
                    null_mut(),
                    null_mut(),
                    hinst,
                    null_mut(),
                );

                if mc.hwnd.is_null() {
                    log_info!("Failed to create Overview overlay window");
                    return;
                }

                dwm::set_dark_mode(mc.hwnd, true);
                dwm::set_backdrop(mc.hwnd, dwm::Backdrop::Acrylic);
            } else {
                SetWindowPos(
                    mc.hwnd,
                    null_mut(),
                    mon_rect.left,
                    mon_rect.top,
                    width,
                    height,
                    SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED,
                );
            }

            // 3. Compute DPI-scaled metrics
            let scale = dpi::scale_for_window(mc.hwnd);
            cards::update_fonts_for_dpi(&mut mc, scale);

            // 4/5. Build spaces bar + window grid + thumbnails
            cards::rebuild_cards(&mut mc, mgr, mon_idx, space_idx, width, height);

            mc.is_visible = true;
            ShowWindow(mc.hwnd, SW_SHOW);
            SetForegroundWindow(mc.hwnd);
            InvalidateRect(mc.hwnd, std::ptr::null(), 1);
            log_info!(
                "Overview shown on Mon {} (Space {}) with {} window thumbnails",
                mon_idx + 1,
                space_idx + 1,
                mc.window_cards.len()
            );
        });
    }
}

/// Re-sync an already-visible overlay with the live space state in place —
/// no hide/show, so switching spaces from inside Overview (space-card
/// click, digit keys, global hotkeys) never flashes the overlay.
pub fn refresh_overview(mgr: &mut SpaceManager) {
    unsafe {
        OVERVIEW_STATE.with(|s| {
            let mut mc = s.borrow_mut();
            if !mc.is_visible || mc.hwnd.is_null() {
                return;
            }
            let mon_idx = mc.active_mon_idx;
            if mon_idx >= mgr.monitors.len() {
                return;
            }
            let space_idx = mgr.monitors[mon_idx].current;
            mc.active_space_idx = space_idx;

            let mut client_rect: RECT = std::mem::zeroed();
            GetClientRect(mc.hwnd, &mut client_rect);
            let width = client_rect.right - client_rect.left;
            let height = client_rect.bottom - client_rect.top;

            cards::rebuild_cards(&mut mc, mgr, mon_idx, space_idx, width, height);

            // Card indexes changed; stale hover/drag/press state must not
            // survive — an armed close button would resolve to whichever
            // window inherited that slot.
            mc.pressed_window_close = None;
            mc.dragging_window = None;
            mc.drag_active = false;
            mc.dragging_space = None;
            mc.drag_space_active = false;
            mc.drag_space_current_x = 0;
            mc.drag_space_target_slot = None;
            // Hover is re-derived rather than cleared: the pointer has not
            // moved, so leaving it blank would drop the highlight off the card
            // under the cursor until the user jiggles the mouse.
            input::resync_hover(&mut mc);

            // A switch may have activated another window; take the keyboard
            // back so Esc and the digit keys keep working.
            SetForegroundWindow(mc.hwnd);
            InvalidateRect(mc.hwnd, std::ptr::null(), 1);
        });
    }
}

pub fn hide_overview() {
    unsafe {
        OVERVIEW_STATE.with(|s| {
            let mut mc = s.borrow_mut();
            if !mc.is_visible {
                return;
            }

            for card in &mc.window_cards {
                if card.h_thumb != 0 {
                    DwmUnregisterThumbnail(card.h_thumb);
                }
            }
            mc.window_cards.clear();
            mc.space_cards.clear();

            if !mc.hwnd.is_null() {
                ShowWindow(mc.hwnd, SW_HIDE);
            }

            // Every show rebuilds the fonts for the target DPI, so keeping
            // them across the close retained five GDI handles for nothing.
            cards::release_fonts(&mut mc);

            mc.is_visible = false;
            mc.dragging_window = None;
            mc.drag_active = false;
            mc.dragging_space = None;
            mc.drag_space_active = false;
            mc.drag_space_current_x = 0;
            mc.drag_space_target_slot = None;
            mc.hovered_close = None;
            mc.hovered_window_close = None;
            mc.pressed_window_close = None;
            mc.hovered_plus = false;
            log_info!("Overview hidden");
        });
    }
}