• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jzombie / term-wm / 20796173160

07 Jan 2026 08:53PM UTC coverage: 38.847% (+2.9%) from 35.923%
20796173160

Pull #5

github

web-flow
Merge 6220a88ab into bbd85351a
Pull Request #5: Add splash screen, debug logging, and unified scroll support

603 of 1185 new or added lines in 16 files covered. (50.89%)

8 existing lines in 6 files now uncovered.

3018 of 7769 relevant lines covered (38.85%)

2.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

9.2
/src/window/window_manager.rs
1
use super::Window;
2
use std::collections::{BTreeMap, BTreeSet};
3
use std::time::{Duration, Instant};
4

5
use crossterm::event::{Event, KeyCode, MouseEventKind};
6
use ratatui::prelude::Rect;
7
use ratatui::widgets::Clear;
8

9
use super::decorator::{DefaultDecorator, HeaderAction, WindowDecorator};
10
use crate::components::{
11
    Component, ConfirmAction, ConfirmOverlayComponent, DebugLogComponent, DialogOverlayComponent,
12
    HelpOverlayComponent, install_panic_hook, set_global_debug_log,
13
};
14
use crate::layout::floating::*;
15
use crate::layout::{
16
    FloatingPane, InsertPosition, LayoutNode, LayoutPlan, RectSpec, RegionMap, SplitHandle,
17
    TilingLayout, rect_contains, render_handles_masked,
18
};
19
use crate::panel::Panel;
20
use crate::state::AppState;
21
use crate::ui::UiFrame;
22

23
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24
/// Describes who owns layout placement and how WM-level input is handled.
25
///
26
/// - AppManaged: the app owns regions; `Esc` passes through.
27
/// - WindowManaged: the WM owns layout; `Esc` enters WM mode/overlay.
28
pub enum LayoutContract {
29
    AppManaged,
30
    WindowManaged,
31
}
32

33
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
34
pub enum SystemWindowId {
35
    DebugLog,
36
}
37

38
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
39
pub enum WindowId<R: Copy + Eq + Ord> {
40
    App(R),
41
    System(SystemWindowId),
42
}
43

44
impl<R: Copy + Eq + Ord> WindowId<R> {
45
    fn app(id: R) -> Self {
1✔
46
        Self::App(id)
1✔
47
    }
1✔
48

49
    fn system(id: SystemWindowId) -> Self {
×
50
        Self::System(id)
×
51
    }
×
52

53
    fn as_app(self) -> Option<R> {
×
54
        match self {
×
55
            Self::App(id) => Some(id),
×
56
            Self::System(_) => None,
×
57
        }
58
    }
×
59
}
60

61
#[derive(Debug, Clone, Copy, Default)]
62
pub struct ScrollState {
63
    pub offset: usize,
64
    pending: isize,
65
}
66

67
impl ScrollState {
68
    pub fn reset(&mut self) {
×
69
        self.offset = 0;
×
70
        self.pending = 0;
×
71
    }
×
72

73
    pub fn bump(&mut self, delta: isize) {
1✔
74
        self.pending = self.pending.saturating_add(delta);
1✔
75
    }
1✔
76

77
    pub fn apply(&mut self, total: usize, view: usize) {
11✔
78
        let max_offset = total.saturating_sub(view);
11✔
79
        if self.pending != 0 {
11✔
80
            let delta = self.pending;
1✔
81
            self.pending = 0;
1✔
82
            let next = if delta.is_negative() {
1✔
83
                self.offset.saturating_sub(delta.unsigned_abs())
×
84
            } else {
85
                self.offset.saturating_add(delta as usize)
1✔
86
            };
87
            self.offset = next.min(max_offset);
1✔
88
        } else if self.offset > max_offset {
10✔
89
            self.offset = max_offset;
1✔
90
        }
9✔
91
    }
11✔
92
}
93

94
#[derive(Debug, Clone, Copy)]
95
pub struct WindowSurface {
96
    pub full: Rect,
97
    pub inner: Rect,
98
}
99

100
#[derive(Debug, Clone, Copy)]
101
pub struct AppWindowDraw<R: Copy + Eq + Ord> {
102
    pub id: R,
103
    pub surface: WindowSurface,
104
    pub focused: bool,
105
}
106

107
#[derive(Debug, Clone)]
108
pub struct FocusRing<T: Copy + Eq> {
109
    order: Vec<T>,
110
    current: T,
111
}
112

113
impl<T: Copy + Eq> FocusRing<T> {
114
    pub fn new(current: T) -> Self {
1✔
115
        Self {
1✔
116
            order: Vec::new(),
1✔
117
            current,
1✔
118
        }
1✔
119
    }
1✔
120

121
    pub fn set_order(&mut self, order: Vec<T>) {
1✔
122
        self.order = order;
1✔
123
    }
1✔
124

125
    pub fn current(&self) -> T {
4✔
126
        self.current
4✔
127
    }
4✔
128

129
    pub fn set_current(&mut self, current: T) {
×
130
        self.current = current;
×
131
    }
×
132

133
    pub fn advance(&mut self, forward: bool) {
3✔
134
        if self.order.is_empty() {
3✔
135
            return;
×
136
        }
3✔
137
        let idx = self
3✔
138
            .order
3✔
139
            .iter()
3✔
140
            .position(|item| *item == self.current)
6✔
141
            .unwrap_or(0);
3✔
142
        let step = if forward { 1isize } else { -1isize };
3✔
143
        let next = ((idx as isize + step).rem_euclid(self.order.len() as isize)) as usize;
3✔
144
        self.current = self.order[next];
3✔
145
    }
3✔
146
}
147

148
pub struct WindowManager<W: Copy + Eq + Ord, R: Copy + Eq + Ord> {
149
    app_focus: FocusRing<W>,
150
    wm_focus: FocusRing<WindowId<R>>,
151
    windows: BTreeMap<WindowId<R>, Window>,
152
    regions: RegionMap<WindowId<R>>,
153
    scroll: BTreeMap<W, ScrollState>,
154
    handles: Vec<SplitHandle>,
155
    resize_handles: Vec<ResizeHandle<WindowId<R>>>,
156
    floating_headers: Vec<DragHandle<WindowId<R>>>,
157
    managed_draw_order: Vec<WindowId<R>>,
158
    managed_draw_order_app: Vec<R>,
159
    managed_layout: Option<TilingLayout<WindowId<R>>>,
160
    // queue of app ids removed this frame; runner drains via `take_closed_app_windows`
161
    closed_app_windows: Vec<R>,
162
    managed_area: Rect,
163
    panel: Panel<WindowId<R>>,
164
    drag_header: Option<HeaderDrag<WindowId<R>>>,
165
    last_header_click: Option<(WindowId<R>, Instant)>,
166
    drag_resize: Option<ResizeDrag<WindowId<R>>>,
167
    hover: Option<(u16, u16)>,
168
    capture_deadline: Option<Instant>,
169
    pending_deadline: Option<Instant>,
170
    state: AppState,
171
    layout_contract: LayoutContract,
172
    wm_overlay_opened_at: Option<Instant>,
173
    esc_passthrough_window: Duration,
174
    wm_overlay: DialogOverlayComponent,
175
    help_overlay: HelpOverlayComponent,
176
    // Central default for whether ScrollViewComponent keyboard handling should be enabled
177
    // for UI components that opt into it. Individual components can override.
178
    scroll_keyboard_enabled_default: bool,
179
    exit_confirm: ConfirmOverlayComponent,
180
    decorator: Box<dyn WindowDecorator>,
181
    floating_resize_offscreen: bool,
182
    z_order: Vec<WindowId<R>>,
183
    drag_snap: Option<(Option<WindowId<R>>, InsertPosition, Rect)>,
184
    debug_log: DebugLogComponent,
185
    debug_log_id: WindowId<R>,
186
    next_window_seq: usize,
187
}
188

189
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190
pub enum WmMenuAction {
191
    CloseMenu,
192
    NewWindow,
193
    ToggleDebugWindow,
194
    ExitUi,
195
    BringFloatingFront,
196
    MinimizeWindow,
197
    MaximizeWindow,
198
    CloseWindow,
199
    ToggleMouseCapture,
200
}
201

202
impl<W: Copy + Eq + Ord, R: Copy + Eq + Ord + std::fmt::Debug> WindowManager<W, R>
203
where
204
    R: PartialEq<W>,
205
{
206
    fn window_mut(&mut self, id: WindowId<R>) -> &mut Window {
×
207
        let seq = &mut self.next_window_seq;
×
208
        self.windows.entry(id).or_insert_with(|| {
×
209
            let order = *seq;
×
210
            *seq = order.saturating_add(1);
×
NEW
211
            tracing::debug!(window_id = ?id, seq = order, "opened window");
×
212
            Window::new(order)
×
213
        })
×
214
    }
×
215

216
    fn window(&self, id: WindowId<R>) -> Option<&Window> {
×
217
        self.windows.get(&id)
×
218
    }
×
219

220
    fn is_minimized(&self, id: WindowId<R>) -> bool {
×
221
        self.window(id).is_some_and(|window| window.minimized)
×
222
    }
×
223

224
    fn set_minimized(&mut self, id: WindowId<R>, value: bool) {
×
225
        self.window_mut(id).minimized = value;
×
226
    }
×
227

228
    fn floating_rect(&self, id: WindowId<R>) -> Option<RectSpec> {
×
229
        self.window(id).and_then(|window| window.floating_rect)
×
230
    }
×
231

232
    fn set_floating_rect(&mut self, id: WindowId<R>, rect: Option<RectSpec>) {
×
233
        self.window_mut(id).floating_rect = rect;
×
234
    }
×
235

236
    fn clear_floating_rect(&mut self, id: WindowId<R>) {
×
237
        self.window_mut(id).floating_rect = None;
×
238
    }
×
239

240
    fn set_prev_floating_rect(&mut self, id: WindowId<R>, rect: Option<RectSpec>) {
×
241
        self.window_mut(id).prev_floating_rect = rect;
×
242
    }
×
243

244
    fn take_prev_floating_rect(&mut self, id: WindowId<R>) -> Option<RectSpec> {
×
245
        self.window_mut(id).prev_floating_rect.take()
×
246
    }
×
247
    fn is_window_floating(&self, id: WindowId<R>) -> bool {
×
248
        self.window(id).is_some_and(|window| window.is_floating())
×
249
    }
×
250

251
    fn window_title(&self, id: WindowId<R>) -> String {
×
252
        self.window(id)
×
253
            .map(|window| window.title_or_default(id))
×
254
            .unwrap_or_else(|| match id {
×
255
                WindowId::App(app_id) => format!("{:?}", app_id),
×
256
                WindowId::System(SystemWindowId::DebugLog) => "Debug Log".to_string(),
×
257
            })
×
258
    }
×
259

260
    fn clear_all_floating(&mut self) {
×
261
        for window in self.windows.values_mut() {
×
262
            window.floating_rect = None;
×
263
            window.prev_floating_rect = None;
×
264
        }
×
265
    }
×
266

267
    pub fn new(current: W) -> Self {
×
268
        Self {
×
269
            app_focus: FocusRing::new(current),
×
270
            wm_focus: FocusRing::new(WindowId::system(SystemWindowId::DebugLog)),
×
271
            windows: BTreeMap::new(),
×
272
            regions: RegionMap::default(),
×
273
            scroll: BTreeMap::new(),
×
274
            handles: Vec::new(),
×
275
            resize_handles: Vec::new(),
×
276
            floating_headers: Vec::new(),
×
277
            managed_draw_order: Vec::new(),
×
278
            managed_draw_order_app: Vec::new(),
×
279
            managed_layout: None,
×
280
            closed_app_windows: Vec::new(),
×
281
            managed_area: Rect::default(),
×
282
            panel: Panel::new(),
×
283
            drag_header: None,
×
284
            last_header_click: None,
×
285
            drag_resize: None,
×
286
            hover: None,
×
287
            capture_deadline: None,
×
288
            pending_deadline: None,
×
289
            state: AppState::new(),
×
290
            layout_contract: LayoutContract::AppManaged,
×
291
            wm_overlay_opened_at: None,
×
292
            esc_passthrough_window: esc_passthrough_window_default(),
×
NEW
293
            wm_overlay: DialogOverlayComponent::new(),
×
NEW
294
            help_overlay: {
×
NEW
295
                let mut h = HelpOverlayComponent::new();
×
NEW
296
                h.set_visible(true);
×
NEW
297
                h
×
NEW
298
            },
×
NEW
299
            scroll_keyboard_enabled_default: true,
×
NEW
300
            exit_confirm: ConfirmOverlayComponent::new(),
×
301
            decorator: Box::new(DefaultDecorator),
×
302
            floating_resize_offscreen: true,
×
303
            z_order: Vec::new(),
×
304
            drag_snap: None,
×
305
            debug_log: {
×
306
                let (component, handle) = DebugLogComponent::new_default();
×
NEW
307
                set_global_debug_log(handle);
×
NEW
308
                // Initialize tracing now that the global debug log handle exists
×
NEW
309
                // so tracing will write into the in-memory debug buffer by default.
×
NEW
310
                crate::tracing_sub::init_default();
×
311
                install_panic_hook();
×
312
                component
×
313
            },
×
314
            debug_log_id: WindowId::system(SystemWindowId::DebugLog),
×
315
            next_window_seq: 0,
×
316
        }
×
317
    }
×
318

319
    pub fn new_managed(current: W) -> Self {
×
320
        let mut manager = Self::new(current);
×
321
        manager.layout_contract = LayoutContract::WindowManaged;
×
322
        manager
×
323
    }
×
324

325
    pub fn set_layout_contract(&mut self, contract: LayoutContract) {
×
326
        self.layout_contract = contract;
×
327
    }
×
328

329
    /// Drain and return any app ids whose windows were closed since the last call.
330
    pub fn take_closed_app_windows(&mut self) -> Vec<R> {
×
331
        std::mem::take(&mut self.closed_app_windows)
×
332
    }
×
333

334
    pub fn layout_contract(&self) -> LayoutContract {
×
335
        self.layout_contract
×
336
    }
×
337

338
    pub fn set_floating_resize_offscreen(&mut self, enabled: bool) {
×
339
        self.floating_resize_offscreen = enabled;
×
340
    }
×
341

342
    pub fn floating_resize_offscreen(&self) -> bool {
×
343
        self.floating_resize_offscreen
×
344
    }
×
345

346
    pub fn begin_frame(&mut self) {
×
347
        self.regions = RegionMap::default();
×
348
        self.handles.clear();
×
349
        self.resize_handles.clear();
×
350
        self.floating_headers.clear();
×
351
        self.managed_draw_order.clear();
×
352
        self.managed_draw_order_app.clear();
×
353
        self.panel.begin_frame();
×
354
        // If a panic occurred earlier, ensure the debug log is shown and focused.
355
        if crate::components::take_panic_pending() {
×
356
            self.state.set_debug_log_visible(true);
×
357
            self.ensure_debug_log_in_layout();
×
358
            self.focus_window_id(self.debug_log_id);
×
359
        }
×
360
        if self.layout_contract == LayoutContract::AppManaged {
×
361
            self.clear_capture();
×
362
        } else {
×
363
            // Refresh deadlines so overlay badges can expire without events.
×
364
            self.refresh_capture();
×
365
        }
×
366
    }
×
367

368
    pub fn arm_capture(&mut self, timeout: Duration) {
×
369
        self.capture_deadline = Some(Instant::now() + timeout);
×
370
        self.pending_deadline = None;
×
371
    }
×
372

373
    pub fn arm_pending(&mut self, timeout: Duration) {
×
374
        // Shows an "Esc pending" badge while waiting for the chord.
375
        self.pending_deadline = Some(Instant::now() + timeout);
×
376
    }
×
377

378
    pub fn clear_capture(&mut self) {
×
379
        self.capture_deadline = None;
×
380
        self.pending_deadline = None;
×
381
        self.state.set_overlay_visible(false);
×
382
        self.wm_overlay_opened_at = None;
×
383
        self.wm_overlay.set_visible(false);
×
384
        self.state.set_wm_menu_selected(0);
×
385
    }
×
386

387
    pub fn capture_active(&mut self) -> bool {
×
388
        if !self.state.mouse_capture_enabled() {
×
389
            return false;
×
390
        }
×
391
        if self.layout_contract == LayoutContract::WindowManaged && self.state.overlay_visible() {
×
392
            return true;
×
393
        }
×
394
        self.refresh_capture();
×
395
        self.capture_deadline.is_some()
×
396
    }
×
397

398
    pub fn mouse_capture_enabled(&self) -> bool {
×
399
        self.state.mouse_capture_enabled()
×
400
    }
×
401

402
    pub fn set_mouse_capture_enabled(&mut self, enabled: bool) {
×
403
        self.state.set_mouse_capture_enabled(enabled);
×
404
        if !self.state.mouse_capture_enabled() {
×
405
            self.clear_capture();
×
406
        }
×
407
    }
×
408

409
    pub fn toggle_mouse_capture(&mut self) {
×
410
        self.state.toggle_mouse_capture();
×
411
        if !self.state.mouse_capture_enabled() {
×
412
            self.clear_capture();
×
413
        }
×
414
    }
×
415

416
    pub fn take_mouse_capture_change(&mut self) -> Option<bool> {
×
417
        self.state.take_mouse_capture_change()
×
418
    }
×
419

420
    fn refresh_capture(&mut self) {
×
421
        if let Some(deadline) = self.capture_deadline
×
422
            && Instant::now() > deadline
×
423
        {
×
424
            self.capture_deadline = None;
×
425
        }
×
426
        if let Some(deadline) = self.pending_deadline
×
427
            && Instant::now() > deadline
×
428
        {
×
429
            self.pending_deadline = None;
×
430
        }
×
431
    }
×
432

433
    pub fn open_wm_overlay(&mut self) {
×
434
        self.state.set_overlay_visible(true);
×
435
        self.wm_overlay_opened_at = Some(Instant::now());
×
436
        self.wm_overlay.set_visible(true);
×
437
        self.state.set_wm_menu_selected(0);
×
438
    }
×
439

440
    pub fn close_wm_overlay(&mut self) {
×
441
        self.state.set_overlay_visible(false);
×
442
        self.wm_overlay_opened_at = None;
×
443
        self.wm_overlay.set_visible(false);
×
444
        self.state.set_wm_menu_selected(0);
×
445
    }
×
446

447
    pub fn open_exit_confirm(&mut self) {
×
448
        self.exit_confirm.open(
×
449
            "Exit App",
×
450
            "Exit the application?\nUnsaved changes will be lost.",
×
451
        );
452
    }
×
453

454
    pub fn close_exit_confirm(&mut self) {
×
455
        self.exit_confirm.close();
×
456
    }
×
457

458
    pub fn exit_confirm_visible(&self) -> bool {
×
459
        self.exit_confirm.visible()
×
460
    }
×
461

NEW
462
    pub fn help_overlay_visible(&self) -> bool {
×
NEW
463
        self.help_overlay.visible()
×
NEW
464
    }
×
465

NEW
466
    pub fn open_help_overlay(&mut self) {
×
NEW
467
        self.help_overlay.set_visible(true);
×
468
        // respect central default: if globally disabled, ensure the overlay doesn't enable keys
NEW
469
        if !self.scroll_keyboard_enabled_default {
×
NEW
470
            self.help_overlay.set_keyboard_enabled(false);
×
NEW
471
        }
×
NEW
472
    }
×
473

NEW
474
    pub fn close_help_overlay(&mut self) {
×
NEW
475
        self.help_overlay.set_visible(false);
×
NEW
476
        self.help_overlay.set_keyboard_enabled(false);
×
NEW
477
    }
×
478

479
    /// Set the central default for enabling scroll-keyboard handling.
NEW
480
    pub fn set_scroll_keyboard_enabled(&mut self, enabled: bool) {
×
NEW
481
        self.scroll_keyboard_enabled_default = enabled;
×
NEW
482
    }
×
483

NEW
484
    pub fn handle_help_event(&mut self, event: &Event) -> bool {
×
NEW
485
        if !self.help_overlay.visible() {
×
NEW
486
            return false;
×
NEW
487
        }
×
NEW
488
        self.help_overlay.handle_help_event(event)
×
NEW
489
    }
×
490

491
    pub fn wm_overlay_visible(&self) -> bool {
×
492
        self.state.overlay_visible()
×
493
    }
×
494

495
    pub fn toggle_debug_window(&mut self) {
×
496
        self.state.toggle_debug_log_visible();
×
497
        if self.state.debug_log_visible() {
×
498
            self.ensure_debug_log_in_layout();
×
499
            self.focus_window_id(self.debug_log_id);
×
500
        } else {
×
501
            self.remove_debug_log_from_layout();
×
502
            if self.wm_focus.current() == self.debug_log_id {
×
503
                self.select_fallback_focus();
×
504
            }
×
505
        }
506
    }
×
507

508
    fn ensure_debug_log_in_layout(&mut self) {
×
509
        if self.layout_contract != LayoutContract::WindowManaged {
×
510
            return;
×
511
        }
×
512
        if self.layout_contains(self.debug_log_id) {
×
513
            return;
×
514
        }
×
515
        if self.managed_layout.is_none() {
×
516
            self.managed_layout = Some(TilingLayout::new(LayoutNode::leaf(self.debug_log_id)));
×
517
            return;
×
518
        }
×
519
        let _ = self.tile_window_id(self.debug_log_id);
×
520
    }
×
521

522
    fn remove_debug_log_from_layout(&mut self) {
×
523
        self.clear_floating_rect(self.debug_log_id);
×
524
        if let Some(layout) = &mut self.managed_layout {
×
525
            if matches!(layout.root(), LayoutNode::Leaf(id) if *id == self.debug_log_id) {
×
526
                self.managed_layout = None;
×
527
            } else {
×
528
                layout.root_mut().remove_leaf(self.debug_log_id);
×
529
            }
×
530
        }
×
531
        self.z_order.retain(|id| *id != self.debug_log_id);
×
532
    }
×
533

534
    pub fn esc_passthrough_active(&self) -> bool {
×
535
        self.esc_passthrough_remaining().is_some()
×
536
    }
×
537

538
    pub fn esc_passthrough_remaining(&self) -> Option<Duration> {
×
539
        if !self.wm_overlay_visible() {
×
540
            return None;
×
541
        }
×
542
        let opened_at = self.wm_overlay_opened_at?;
×
543
        let elapsed = opened_at.elapsed();
×
544
        if elapsed >= self.esc_passthrough_window {
×
545
            return None;
×
546
        }
×
547
        Some(self.esc_passthrough_window.saturating_sub(elapsed))
×
548
    }
×
549

550
    pub fn focus(&self) -> W {
×
551
        self.app_focus.current()
×
552
    }
×
553

554
    pub fn set_focus(&mut self, focus: W) {
×
555
        self.app_focus.set_current(focus);
×
556
    }
×
557

558
    pub fn set_focus_order(&mut self, order: Vec<W>) {
×
559
        self.app_focus.set_order(order);
×
560
        if !self.app_focus.order.is_empty()
×
561
            && !self.app_focus.order.contains(&self.app_focus.current)
×
562
        {
×
563
            self.app_focus.current = self.app_focus.order[0];
×
564
        }
×
565
    }
×
566

567
    pub fn advance_focus(&mut self, forward: bool) {
×
568
        self.app_focus.advance(forward);
×
569
    }
×
570

571
    pub fn wm_focus(&self) -> WindowId<R> {
×
572
        self.wm_focus.current()
×
573
    }
×
574

575
    pub fn wm_focus_app(&self) -> Option<R> {
×
576
        self.wm_focus.current().as_app()
×
577
    }
×
578

579
    fn set_wm_focus(&mut self, focus: WindowId<R>) {
×
580
        self.wm_focus.set_current(focus);
×
581
        if let Some(app_id) = focus.as_app()
×
582
            && let Some(app_focus) = self.focus_for_region(app_id)
×
583
        {
×
584
            self.app_focus.set_current(app_focus);
×
585
        }
×
586
    }
×
587

588
    /// Unified focus API: set WM focus, bring the window to front, update draw order,
589
    /// and sync app-level focus if applicable.
590
    fn focus_window_id(&mut self, id: WindowId<R>) {
×
591
        self.set_wm_focus(id);
×
592
        self.bring_to_front_id(id);
×
593
        self.managed_draw_order = self.z_order.clone();
×
594
        if let Some(app_id) = id.as_app()
×
595
            && let Some(app_focus) = self.focus_for_region(app_id)
×
596
        {
×
597
            self.app_focus.set_current(app_focus);
×
598
        }
×
599
    }
×
600

601
    fn set_wm_focus_order(&mut self, order: Vec<WindowId<R>>) {
×
602
        self.wm_focus.set_order(order);
×
603
        if !self.wm_focus.order.is_empty() && !self.wm_focus.order.contains(&self.wm_focus.current)
×
604
        {
×
605
            self.wm_focus.current = self.wm_focus.order[0];
×
606
        }
×
607
    }
×
608

609
    fn rebuild_wm_focus_ring(&mut self, active_ids: &[WindowId<R>]) {
×
610
        if active_ids.is_empty() {
×
611
            self.set_wm_focus_order(Vec::new());
×
612
            return;
×
613
        }
×
614
        let active: BTreeSet<_> = active_ids.iter().copied().collect();
×
615
        let mut next_order: Vec<WindowId<R>> = Vec::with_capacity(active.len());
×
616
        let mut seen: BTreeSet<WindowId<R>> = BTreeSet::new();
×
617

618
        for &id in &self.wm_focus.order {
×
619
            if active.contains(&id) && seen.insert(id) {
×
620
                next_order.push(id);
×
621
            }
×
622
        }
623
        for &id in active_ids {
×
624
            if seen.insert(id) {
×
625
                next_order.push(id);
×
626
            }
×
627
        }
628
        self.set_wm_focus_order(next_order);
×
629
    }
×
630

631
    fn advance_wm_focus(&mut self, forward: bool) {
×
632
        if self.wm_focus.order.is_empty() {
×
633
            return;
×
634
        }
×
635
        self.wm_focus.advance(forward);
×
636
        let focused = self.wm_focus.current();
×
637
        self.focus_window_id(focused);
×
638
    }
×
639

640
    fn select_fallback_focus(&mut self) {
×
641
        if let Some(fallback) = self.wm_focus.order.first().copied() {
×
642
            self.set_wm_focus(fallback);
×
643
        }
×
644
    }
×
645

646
    pub fn scroll(&self, id: W) -> ScrollState {
×
647
        self.scroll.get(&id).copied().unwrap_or_default()
×
648
    }
×
649

650
    pub fn scroll_mut(&mut self, id: W) -> &mut ScrollState {
×
651
        self.scroll.entry(id).or_default()
×
652
    }
×
653

654
    pub fn scroll_offset(&self, id: W) -> usize {
×
655
        self.scroll(id).offset
×
656
    }
×
657

658
    pub fn reset_scroll(&mut self, id: W) {
×
659
        self.scroll_mut(id).reset();
×
660
    }
×
661

662
    pub fn apply_scroll(&mut self, id: W, total: usize, view: usize) {
×
663
        self.scroll_mut(id).apply(total, view);
×
664
    }
×
665

666
    pub fn set_region(&mut self, id: R, rect: Rect) {
×
667
        self.regions.set(WindowId::app(id), rect);
×
668
    }
×
669

670
    pub fn full_region(&self, id: R) -> Rect {
×
671
        self.full_region_for_id(WindowId::app(id))
×
672
    }
×
673

674
    pub fn region(&self, id: R) -> Rect {
×
675
        self.region_for_id(WindowId::app(id))
×
676
    }
×
677

678
    fn full_region_for_id(&self, id: WindowId<R>) -> Rect {
×
679
        self.regions.get(id).unwrap_or_default()
×
680
    }
×
681

682
    fn region_for_id(&self, id: WindowId<R>) -> Rect {
×
683
        let rect = self.regions.get(id).unwrap_or_default();
×
684
        if self.layout_contract == LayoutContract::WindowManaged {
×
685
            let area = if self.floating_resize_offscreen {
×
686
                // If we allow off-screen resizing/dragging, we shouldn't clamp the
687
                // logical region to the bounds, otherwise the PTY will be resized
688
                // (shrinking the content) instead of just being clipped during render.
689
                rect
×
690
            } else {
691
                clamp_rect(rect, self.managed_area)
×
692
            };
693
            if area.width < 3 || area.height < 4 {
×
694
                return Rect::default();
×
695
            }
×
696
            Rect {
×
697
                x: area.x + 1,
×
698
                y: area.y + 2,
×
699
                width: area.width.saturating_sub(2),
×
700
                height: area.height.saturating_sub(3),
×
701
            }
×
702
        } else {
703
            rect
×
704
        }
705
    }
×
706

707
    pub fn set_regions_from_layout(&mut self, layout: &LayoutNode<R>, area: Rect) {
×
708
        self.regions = RegionMap::default();
×
709
        for (id, rect) in layout.layout(area) {
×
710
            self.regions.set(WindowId::app(id), rect);
×
711
        }
×
712
    }
×
713

714
    pub fn register_tiling_layout(&mut self, layout: &TilingLayout<R>, area: Rect) {
×
715
        let (regions, handles) = layout.root().layout_with_handles(area);
×
716
        for (id, rect) in regions {
×
717
            self.regions.set(WindowId::app(id), rect);
×
718
        }
×
719
        self.handles.extend(handles);
×
720
    }
×
721

722
    pub fn set_managed_layout(&mut self, layout: TilingLayout<R>) {
×
723
        self.managed_layout = Some(TilingLayout::new(map_layout_node(layout.root())));
×
724
        self.clear_all_floating();
×
725
        if self.state.debug_log_visible() {
×
726
            self.ensure_debug_log_in_layout();
×
727
        }
×
728
    }
×
729

730
    pub fn set_panel_visible(&mut self, visible: bool) {
×
731
        self.panel.set_visible(visible);
×
732
    }
×
733

734
    pub fn set_panel_height(&mut self, height: u16) {
×
735
        self.panel.set_height(height);
×
736
    }
×
737

738
    pub fn register_managed_layout(&mut self, area: Rect) {
×
739
        let (_, managed_area) = self.panel.split_area(self.panel_active(), area);
×
740
        self.managed_area = managed_area;
×
741
        self.clamp_floating_to_bounds();
×
742
        if self.state.debug_log_visible() {
×
743
            self.ensure_debug_log_in_layout();
×
744
        }
×
745
        let z_snapshot = self.z_order.clone();
×
746
        let mut active_ids: Vec<WindowId<R>> = Vec::new();
×
747

748
        if let Some(layout) = self.managed_layout.as_ref() {
×
749
            let (regions, handles) = layout.root().layout_with_handles(self.managed_area);
×
750
            for (id, rect) in &regions {
×
751
                if self.is_window_floating(*id) {
×
752
                    continue;
×
753
                }
×
754
                // skip minimized windows
755
                if self.is_minimized(*id) {
×
756
                    continue;
×
757
                }
×
758
                self.regions.set(*id, *rect);
×
759
                if let Some(header) = floating_header_for_region(*id, *rect, self.managed_area) {
×
760
                    self.floating_headers.push(header);
×
761
                }
×
762
                active_ids.push(*id);
×
763
            }
764
            let filtered_handles: Vec<SplitHandle> = handles
×
765
                .into_iter()
×
766
                .filter(|handle| {
×
767
                    let Some(LayoutNode::Split { children, .. }) =
×
768
                        layout.root().node_at_path(&handle.path)
×
769
                    else {
770
                        return false;
×
771
                    };
772
                    let left = children.get(handle.index);
×
773
                    let right = children.get(handle.index + 1);
×
774
                    left.is_some_and(|node| node.subtree_any(|id| !self.is_window_floating(id)))
×
775
                        || right
×
776
                            .is_some_and(|node| node.subtree_any(|id| !self.is_window_floating(id)))
×
777
                })
×
778
                .collect();
×
779
            self.handles.extend(filtered_handles);
×
780
        }
×
781
        let mut floating_ids: Vec<WindowId<R>> = self
×
782
            .windows
×
783
            .iter()
×
784
            .filter_map(|(&id, window)| {
×
785
                if window.is_floating() && !window.minimized {
×
786
                    Some(id)
×
787
                } else {
788
                    None
×
789
                }
790
            })
×
791
            .collect();
×
792
        floating_ids.sort_by_key(|id| {
×
793
            z_snapshot
×
794
                .iter()
×
795
                .position(|existing| existing == id)
×
796
                .unwrap_or(usize::MAX)
×
797
        });
×
798
        for floating_id in floating_ids {
×
799
            let Some(spec) = self.floating_rect(floating_id) else {
×
800
                continue;
×
801
            };
802
            let rect = spec.resolve(self.managed_area);
×
803
            self.regions.set(floating_id, rect);
×
804
            self.resize_handles.extend(resize_handles_for_region(
×
805
                floating_id,
×
806
                rect,
×
807
                self.managed_area,
×
808
            ));
809
            if let Some(header) = floating_header_for_region(floating_id, rect, self.managed_area) {
×
810
                self.floating_headers.push(header);
×
811
            }
×
812
            active_ids.push(floating_id);
×
813
        }
814

815
        self.z_order.retain(|id| active_ids.contains(id));
×
816
        for &id in &active_ids {
×
817
            if !self.z_order.contains(&id) {
×
818
                self.z_order.push(id);
×
819
            }
×
820
        }
821
        self.managed_draw_order = self.z_order.clone();
×
822
        self.managed_draw_order_app = self
×
823
            .managed_draw_order
×
824
            .iter()
×
825
            .filter_map(|id| id.as_app())
×
826
            .collect();
×
827
        self.rebuild_wm_focus_ring(&active_ids);
×
828
        // Ensure the current focus is actually on top and synced after layout registration.
829
        // Only bring the focused window to front if it's not already the topmost window
830
        // to avoid repeatedly forcing focus every frame.
831
        let focused = self.wm_focus.current();
×
832
        if self.z_order.last().copied() != Some(focused) {
×
833
            self.focus_window_id(focused);
×
834
        }
×
835
    }
×
836

837
    pub fn managed_draw_order(&self) -> &[R] {
×
838
        &self.managed_draw_order_app
×
839
    }
×
840

841
    /// Build a stable display order for UI components.
842
    /// By default this returns the canonical creation order filtered to active managed windows,
843
    /// appending any windows that are active but not yet present in the canonical ordering.
844
    pub fn build_display_order(&self) -> Vec<WindowId<R>> {
×
845
        let mut ordered: Vec<(WindowId<R>, &Window)> = self
×
846
            .windows
×
847
            .iter()
×
848
            .map(|(id, window)| (*id, window))
×
849
            .collect();
×
850
        ordered.sort_by_key(|(_, window)| window.creation_order);
×
851

852
        let mut out: Vec<WindowId<R>> = Vec::new();
×
853
        for (id, window) in ordered {
×
854
            if self.managed_draw_order.contains(&id) || window.minimized {
×
855
                out.push(id);
×
856
            }
×
857
        }
858
        for id in &self.managed_draw_order {
×
859
            if !out.contains(id) {
×
860
                out.push(*id);
×
861
            }
×
862
        }
863
        out
×
864
    }
×
865

866
    /// Set a user-visible title for an app window. This overrides the default
867
    /// Debug-derived title displayed for the given `id`.
868
    pub fn set_window_title(&mut self, id: R, title: impl Into<String>) {
×
869
        self.window_mut(WindowId::app(id)).title = Some(title.into());
×
870
    }
×
871

872
    pub fn handle_managed_event(&mut self, event: &Event) -> bool {
×
873
        if self.layout_contract != LayoutContract::WindowManaged {
×
874
            return false;
×
875
        }
×
876
        if let Event::Mouse(mouse) = event
×
877
            && self.panel_active()
×
878
            && rect_contains(self.panel.area(), mouse.column, mouse.row)
×
879
        {
880
            if self.panel.hit_test_menu(event) {
×
881
                if self.wm_overlay_visible() {
×
882
                    self.close_wm_overlay();
×
883
                } else {
×
884
                    self.open_wm_overlay();
×
885
                }
×
886
            } else if self.panel.hit_test_mouse_capture(event) {
×
887
                self.toggle_mouse_capture();
×
888
            } else if let Some(id) = self.panel.hit_test_window(event) {
×
889
                // If the clicked window is minimized, restore it first so it appears
890
                // in the layout; otherwise just focus and bring to front.
891
                if self.is_minimized(id) {
×
892
                    self.restore_minimized(id);
×
893
                }
×
894
                self.focus_window_id(id);
×
895
            }
×
896
            return true;
×
897
        }
×
898
        if self.state.debug_log_visible() {
×
899
            match event {
×
900
                Event::Mouse(mouse) => {
×
901
                    let rect = self.full_region_for_id(self.debug_log_id);
×
902
                    if rect_contains(rect, mouse.column, mouse.row) {
×
903
                        if matches!(mouse.kind, MouseEventKind::Down(_)) {
×
904
                            self.focus_window_id(self.debug_log_id);
×
905
                        }
×
906
                        if self.debug_log.handle_event(event) {
×
907
                            return true;
×
908
                        }
×
909
                    } else if matches!(mouse.kind, MouseEventKind::Down(_))
×
910
                        && self.wm_focus.current() == self.debug_log_id
×
911
                    {
×
912
                        self.select_fallback_focus();
×
913
                    }
×
914
                }
915
                Event::Key(_) if self.wm_focus.current() == self.debug_log_id => {
×
916
                    if self.debug_log.handle_event(event) {
×
917
                        return true;
×
918
                    }
×
919
                }
920
                _ => {}
×
921
            }
922
        }
×
923
        if let Event::Mouse(mouse) = event {
×
924
            self.hover = Some((mouse.column, mouse.row));
×
925
        }
×
926
        if self.handle_resize_event(event) {
×
927
            return true;
×
928
        }
×
929
        if self.handle_header_drag_event(event) {
×
930
            return true;
×
931
        }
×
932
        if let Some(layout) = self.managed_layout.as_mut() {
×
933
            return layout.handle_event(event, self.managed_area);
×
934
        }
×
935
        false
×
936
    }
×
937

938
    pub fn minimize_window(&mut self, id: WindowId<R>) {
×
939
        if self.is_minimized(id) {
×
940
            return;
×
941
        }
×
942
        // remove from floating and regions; keep canonical order so it can be restored
943
        self.clear_floating_rect(id);
×
944
        self.z_order.retain(|x| *x != id);
×
945
        self.managed_draw_order.retain(|x| *x != id);
×
946
        self.set_minimized(id, true);
×
947
        // ensure focus moves if needed
948
        if self.wm_focus.current() == id {
×
949
            self.select_fallback_focus();
×
950
        }
×
951
    }
×
952

953
    pub fn restore_minimized(&mut self, id: WindowId<R>) {
×
954
        if !self.is_minimized(id) {
×
955
            return;
×
956
        }
×
957
        self.set_minimized(id, false);
×
958
        // reinstall into z_order and draw order
959
        if !self.z_order.contains(&id) {
×
960
            self.z_order.push(id);
×
961
        }
×
962
        if !self.managed_draw_order.contains(&id) {
×
963
            self.managed_draw_order.push(id);
×
964
        }
×
965
    }
×
966

967
    pub fn toggle_maximize(&mut self, id: WindowId<R>) {
×
968
        // maximize toggles the floating rect to full managed_area
969
        let full = RectSpec::Absolute(self.managed_area);
×
970
        if let Some(current) = self.floating_rect(id) {
×
971
            if current == full {
×
972
                if let Some(prev) = self.take_prev_floating_rect(id) {
×
973
                    self.set_floating_rect(id, Some(prev));
×
974
                }
×
975
            } else {
×
976
                self.set_prev_floating_rect(id, Some(current));
×
977
                self.set_floating_rect(id, Some(full));
×
978
            }
×
979
            self.bring_floating_to_front_id(id);
×
980
            return;
×
981
        }
×
982
        // not floating: add floating pane covering full area
983
        // Save the current region (if available) so we can restore later.
984
        let prev_rect = if let Some(rect) = self.regions.get(id) {
×
985
            RectSpec::Absolute(rect)
×
986
        } else {
987
            RectSpec::Percent {
×
988
                x: 0,
×
989
                y: 0,
×
990
                width: 100,
×
991
                height: 100,
×
992
            }
×
993
        };
994
        self.set_prev_floating_rect(id, Some(prev_rect));
×
995
        self.set_floating_rect(id, Some(full));
×
996
        self.bring_floating_to_front_id(id);
×
997
    }
×
998

999
    pub fn close_window(&mut self, id: WindowId<R>) {
×
NEW
1000
        tracing::debug!(window_id = ?id, "closing window");
×
1001
        if id == self.debug_log_id {
×
1002
            self.toggle_debug_window();
×
1003
            return;
×
1004
        }
×
1005

1006
        // Remove references to this window
1007
        self.clear_floating_rect(id);
×
1008
        self.z_order.retain(|x| *x != id);
×
1009
        self.managed_draw_order.retain(|x| *x != id);
×
1010
        self.set_minimized(id, false);
×
1011
        self.regions.remove(id);
×
1012
        // update focus
1013
        if self.wm_focus.current() == id {
×
1014
            self.select_fallback_focus();
×
1015
        }
×
1016
        // If this window corresponded to an app id, enqueue it for the runner to drain.
1017
        if let Some(app_id) = id.as_app() {
×
1018
            self.closed_app_windows.push(app_id);
×
1019
        }
×
1020
    }
×
1021

1022
    fn handle_header_drag_event(&mut self, event: &Event) -> bool {
×
1023
        use crossterm::event::MouseEventKind;
1024
        let Event::Mouse(mouse) = event else {
×
1025
            return false;
×
1026
        };
1027
        match mouse.kind {
×
1028
            MouseEventKind::Down(_) => {
1029
                // Check if the mouse is blocked by a window above
1030
                let topmost_hit = if self.layout_contract == LayoutContract::WindowManaged
×
1031
                    && !self.managed_draw_order.is_empty()
×
1032
                {
1033
                    self.hit_test_region_topmost(mouse.column, mouse.row, &self.managed_draw_order)
×
1034
                } else {
1035
                    None
×
1036
                };
1037

1038
                if let Some(header) = self
×
1039
                    .floating_headers
×
1040
                    .iter()
×
1041
                    .rev()
×
1042
                    .find(|handle| rect_contains(handle.rect, mouse.column, mouse.row))
×
1043
                    .copied()
×
1044
                {
1045
                    // If we hit a window body that is NOT the owner of this header,
1046
                    // then the header is obscured.
1047
                    if let Some(hit_id) = topmost_hit
×
1048
                        && hit_id != header.id
×
1049
                    {
1050
                        return false;
×
1051
                    }
×
1052

1053
                    let rect = self.full_region_for_id(header.id);
×
1054
                    match self.decorator.hit_test(rect, mouse.column, mouse.row) {
×
1055
                        HeaderAction::Minimize => {
1056
                            self.minimize_window(header.id);
×
1057
                            self.last_header_click = None;
×
1058
                            return true;
×
1059
                        }
1060
                        HeaderAction::Maximize => {
1061
                            self.toggle_maximize(header.id);
×
1062
                            self.last_header_click = None;
×
1063
                            return true;
×
1064
                        }
1065
                        HeaderAction::Close => {
1066
                            self.close_window(header.id);
×
1067
                            self.last_header_click = None;
×
1068
                            return true;
×
1069
                        }
1070
                        HeaderAction::Drag => {
1071
                            // Double-click on header toggles maximize/restore.
1072
                            let now = Instant::now();
×
1073
                            if let Some((prev_id, prev)) = self.last_header_click
×
1074
                                && prev_id == header.id
×
1075
                                && now.duration_since(prev) <= Duration::from_millis(500)
×
1076
                            {
1077
                                self.toggle_maximize(header.id);
×
1078
                                self.last_header_click = None;
×
1079
                                return true;
×
1080
                            }
×
1081
                            // Record this click time and proceed to drag below.
1082
                            self.last_header_click = Some((header.id, now));
×
1083
                        }
1084
                        HeaderAction::None => {
×
1085
                            // Should not happen as we already checked rect contains
×
1086
                        }
×
1087
                    }
1088

1089
                    // Standard floating drag start
1090
                    if self.is_window_floating(header.id) {
×
1091
                        self.bring_floating_to_front_id(header.id);
×
1092
                    } else {
×
1093
                        // If Tiled: We detach immediately to floating (responsive drag).
×
1094
                        // Keep the tiling slot reserved so the sibling doesn't expand to full screen.
×
1095
                        let _ = self.detach_to_floating(header.id, rect);
×
1096
                    }
×
1097

1098
                    self.drag_header = Some(HeaderDrag {
×
1099
                        id: header.id,
×
1100
                        offset_x: mouse.column.saturating_sub(rect.x),
×
1101
                        offset_y: mouse.row.saturating_sub(rect.y),
×
1102
                        start_x: mouse.column,
×
1103
                        start_y: mouse.row,
×
1104
                    });
×
1105
                    return true;
×
1106
                }
×
1107
            }
1108
            MouseEventKind::Drag(_) => {
1109
                if let Some(drag) = self.drag_header {
×
1110
                    if self.is_window_floating(drag.id) {
×
1111
                        self.move_floating(
×
1112
                            drag.id,
×
1113
                            mouse.column,
×
1114
                            mouse.row,
×
1115
                            drag.offset_x,
×
1116
                            drag.offset_y,
×
1117
                        );
1118
                        // Only show snap preview if dragged a bit
1119
                        let dx = mouse.column.abs_diff(drag.start_x);
×
1120
                        let dy = mouse.row.abs_diff(drag.start_y);
×
1121
                        if dx + dy > 2 {
×
1122
                            self.update_snap_preview(drag.id, mouse.column, mouse.row);
×
1123
                        } else {
×
1124
                            self.drag_snap = None;
×
1125
                        }
×
1126
                    }
×
1127
                    return true;
×
1128
                }
×
1129
            }
1130
            MouseEventKind::Up(_) => {
1131
                if let Some(drag) = self.drag_header.take() {
×
1132
                    if self.drag_snap.is_some() {
×
1133
                        self.apply_snap(drag.id);
×
1134
                    }
×
1135
                    return true;
×
1136
                }
×
1137
            }
1138
            _ => {}
×
1139
        }
1140
        false
×
1141
    }
×
1142

1143
    fn handle_resize_event(&mut self, event: &Event) -> bool {
×
1144
        use crossterm::event::MouseEventKind;
1145
        let Event::Mouse(mouse) = event else {
×
1146
            return false;
×
1147
        };
1148
        match mouse.kind {
×
1149
            MouseEventKind::Down(_) => {
1150
                // Check if the mouse is blocked by a window above
1151
                let topmost_hit = if self.layout_contract == LayoutContract::WindowManaged
×
1152
                    && !self.managed_draw_order.is_empty()
×
1153
                {
1154
                    self.hit_test_region_topmost(mouse.column, mouse.row, &self.managed_draw_order)
×
1155
                } else {
1156
                    None
×
1157
                };
1158

1159
                let hit = self
×
1160
                    .resize_handles
×
1161
                    .iter()
×
1162
                    .rev()
×
1163
                    .find(|handle| rect_contains(handle.rect, mouse.column, mouse.row))
×
1164
                    .copied();
×
1165
                if let Some(handle) = hit {
×
1166
                    // If we hit a window body that is NOT the owner of this handle,
1167
                    // then the handle is obscured.
1168
                    if let Some(hit_id) = topmost_hit
×
1169
                        && hit_id != handle.id
×
1170
                    {
1171
                        return false;
×
1172
                    }
×
1173

1174
                    let rect = self.full_region_for_id(handle.id);
×
1175
                    if !self.is_window_floating(handle.id) {
×
1176
                        return false;
×
1177
                    }
×
1178
                    self.bring_floating_to_front_id(handle.id);
×
1179
                    self.drag_resize = Some(ResizeDrag {
×
1180
                        id: handle.id,
×
1181
                        edge: handle.edge,
×
1182
                        start_rect: rect,
×
1183
                        start_col: mouse.column,
×
1184
                        start_row: mouse.row,
×
1185
                    });
×
1186
                    return true;
×
1187
                }
×
1188
            }
1189
            MouseEventKind::Drag(_) => {
1190
                if let Some(drag) = self.drag_resize.as_ref()
×
1191
                    && self.is_window_floating(drag.id)
×
1192
                {
1193
                    let resized = apply_resize_drag(
×
1194
                        drag.start_rect,
×
1195
                        drag.edge,
×
1196
                        mouse.column,
×
1197
                        mouse.row,
×
1198
                        drag.start_col,
×
1199
                        drag.start_row,
×
1200
                        self.managed_area,
×
1201
                        self.floating_resize_offscreen,
×
1202
                    );
1203
                    self.set_floating_rect(drag.id, Some(RectSpec::Absolute(resized)));
×
1204
                    return true;
×
1205
                }
×
1206
            }
1207
            MouseEventKind::Up(_) => {
1208
                if self.drag_resize.take().is_some() {
×
1209
                    return true;
×
1210
                }
×
1211
            }
1212
            _ => {}
×
1213
        }
1214
        false
×
1215
    }
×
1216

1217
    fn detach_to_floating(&mut self, id: WindowId<R>, rect: Rect) -> bool {
×
1218
        if self.is_window_floating(id) {
×
1219
            return true;
×
1220
        }
×
1221
        if self.managed_layout.is_none() {
×
1222
            return false;
×
1223
        }
×
1224

1225
        let width = rect.width.max(1);
×
1226
        let height = rect.height.max(1);
×
1227
        let x = rect.x;
×
1228
        let y = rect.y;
×
1229
        self.set_floating_rect(
×
1230
            id,
×
1231
            Some(RectSpec::Absolute(Rect {
×
1232
                x,
×
1233
                y,
×
1234
                width,
×
1235
                height,
×
1236
            })),
×
1237
        );
1238
        self.bring_to_front_id(id);
×
1239
        true
×
1240
    }
×
1241

1242
    fn layout_contains(&self, id: WindowId<R>) -> bool {
×
1243
        self.managed_layout
×
1244
            .as_ref()
×
1245
            .is_some_and(|layout| layout.root().subtree_any(|node_id| node_id == id))
×
1246
    }
×
1247

1248
    fn move_floating(
×
1249
        &mut self,
×
1250
        id: WindowId<R>,
×
1251
        column: u16,
×
1252
        row: u16,
×
1253
        offset_x: u16,
×
1254
        offset_y: u16,
×
1255
    ) {
×
1256
        let panel_active = self.panel_active();
×
1257
        let bounds = self.managed_area;
×
1258
        let Some(RectSpec::Absolute(rect)) = self.floating_rect(id) else {
×
1259
            return;
×
1260
        };
1261
        let width = rect.width.max(1);
×
1262
        let height = rect.height.max(1);
×
1263
        let x = column.saturating_sub(offset_x);
×
1264
        let mut y = row.saturating_sub(offset_y);
×
1265
        if panel_active && y < bounds.y {
×
1266
            y = bounds.y;
×
1267
        }
×
1268
        self.set_floating_rect(
×
1269
            id,
×
1270
            Some(RectSpec::Absolute(Rect {
×
1271
                x,
×
1272
                y,
×
1273
                width,
×
1274
                height,
×
1275
            })),
×
1276
        );
1277
    }
×
1278

1279
    fn update_snap_preview(&mut self, dragging_id: WindowId<R>, mouse_x: u16, mouse_y: u16) {
×
1280
        self.drag_snap = None;
×
1281
        let area = self.managed_area;
×
1282

1283
        // 1. Check Window Snap first (more specific)
1284
        // We iterate z-order (top-to-bottom) to find the first valid target under mouse.
1285
        // We only allow snapping to windows that are already tiled, unless the layout is empty.
1286
        let target = self.z_order.iter().rev().find_map(|&id| {
×
1287
            if id == dragging_id {
×
1288
                return None;
×
1289
            }
×
1290
            // If we have a layout, ignore floating windows as snap targets
1291
            // to prevent "bait and switch" (offering to split a float, then splitting root).
1292
            if self.managed_layout.is_some() && self.is_window_floating(id) {
×
1293
                return None;
×
1294
            }
×
1295

1296
            let rect = self.regions.get(id)?;
×
1297
            if rect_contains(rect, mouse_x, mouse_y) {
×
1298
                Some((id, rect))
×
1299
            } else {
1300
                None
×
1301
            }
1302
        });
×
1303

1304
        if let Some((target_id, rect)) = target {
×
1305
            let h = rect.height;
×
1306

1307
            // Distance to edges
1308
            let d_top = mouse_y.saturating_sub(rect.y);
×
1309
            let d_bottom = (rect.y + h).saturating_sub(1).saturating_sub(mouse_y);
×
1310

1311
            // Sensitivity: Allow a reasonable localized zone.
1312
            // Reduced sensitivity to prevent accidental snaps when crossing windows.
1313
            // w/10 is 10%. Clamped to [2, 6] means you must be quite close to the edge.
1314
            let sens_y = (h / 10).clamp(1, 4);
×
1315

1316
            // Check if the closest edge is within its sensitivity limit
1317
            // Only allow snapping on horizontal seams (top/bottom of tiled panes).
1318
            let snap = if d_top < sens_y && d_top <= d_bottom {
×
1319
                Some((
×
1320
                    InsertPosition::Top,
×
1321
                    Rect {
×
1322
                        height: h / 2,
×
1323
                        ..rect
×
1324
                    },
×
1325
                ))
×
1326
            } else if d_bottom < sens_y {
×
1327
                Some((
×
1328
                    InsertPosition::Bottom,
×
1329
                    Rect {
×
1330
                        y: rect.y + h / 2,
×
1331
                        height: h / 2,
×
1332
                        ..rect
×
1333
                    },
×
1334
                ))
×
1335
            } else {
1336
                None
×
1337
            };
1338

1339
            if let Some((pos, preview)) = snap {
×
1340
                self.drag_snap = Some((Some(target_id), pos, preview));
×
1341
                return;
×
1342
            }
×
1343
        }
×
1344

1345
        // 2. Check Screen Edge Snap (fallback, less specific)
1346
        let sensitivity = 2; // Strict sensitivity for screen edge
×
1347

1348
        let d_left = mouse_x.saturating_sub(area.x);
×
1349
        let d_right = (area.x + area.width)
×
1350
            .saturating_sub(1)
×
1351
            .saturating_sub(mouse_x);
×
1352
        let d_top = mouse_y.saturating_sub(area.y);
×
1353
        let d_bottom = (area.y + area.height)
×
1354
            .saturating_sub(1)
×
1355
            .saturating_sub(mouse_y);
×
1356

1357
        let min_screen_dist = d_left.min(d_right).min(d_top).min(d_bottom);
×
1358

1359
        let position = if min_screen_dist < sensitivity {
×
1360
            if d_left == min_screen_dist {
×
1361
                Some(InsertPosition::Left)
×
1362
            } else if d_right == min_screen_dist {
×
1363
                Some(InsertPosition::Right)
×
1364
            } else if d_top == min_screen_dist {
×
1365
                Some(InsertPosition::Top)
×
1366
            } else if d_bottom == min_screen_dist {
×
1367
                Some(InsertPosition::Bottom)
×
1368
            } else {
1369
                None
×
1370
            }
1371
        } else {
1372
            None
×
1373
        };
1374

1375
        if let Some(pos) = position {
×
1376
            let mut preview = match pos {
×
1377
                InsertPosition::Left => Rect {
×
1378
                    width: area.width / 2,
×
1379
                    ..area
×
1380
                },
×
1381
                InsertPosition::Right => Rect {
×
1382
                    x: area.x + area.width / 2,
×
1383
                    width: area.width / 2,
×
1384
                    ..area
×
1385
                },
×
1386
                InsertPosition::Top => Rect {
×
1387
                    height: area.height / 2,
×
1388
                    ..area
×
1389
                },
×
1390
                InsertPosition::Bottom => Rect {
×
1391
                    y: area.y + area.height / 2,
×
1392
                    height: area.height / 2,
×
1393
                    ..area
×
1394
                },
×
1395
            };
1396

1397
            // If there's no layout to split, dragging to edge just re-tiles to full screen.
1398
            if self.managed_layout.is_none() {
×
1399
                preview = area;
×
1400
            }
×
1401

1402
            self.drag_snap = Some((None, pos, preview));
×
1403
        }
×
1404
    }
×
1405

1406
    fn apply_snap(&mut self, id: WindowId<R>) {
×
1407
        if let Some((target, position, preview)) = self.drag_snap.take() {
×
1408
            // Check if we should tile or float-snap
1409
            // We float-snap if we are snapping to a screen edge (target is None)
1410
            // AND the layout is empty (no other tiled windows).
1411
            let other_windows_exist = if let Some(layout) = &self.managed_layout {
×
1412
                !layout.regions(self.managed_area).is_empty()
×
1413
            } else {
1414
                false
×
1415
            };
1416

1417
            if target.is_none() && !other_windows_exist {
×
1418
                // Single window edge snap -> Floating Resize
1419
                if self.is_window_floating(id) {
×
1420
                    self.set_floating_rect(id, Some(RectSpec::Absolute(preview)));
×
1421
                }
×
1422
                return;
×
1423
            }
×
1424

1425
            if self.is_window_floating(id) {
×
1426
                self.clear_floating_rect(id);
×
1427
            }
×
1428

1429
            if self.layout_contains(id)
×
1430
                && let Some(layout) = &mut self.managed_layout
×
1431
            {
1432
                let should_retile = match target {
×
1433
                    Some(target_id) => target_id != id,
×
1434
                    None => true,
×
1435
                };
1436
                if should_retile {
×
1437
                    layout.root_mut().remove_leaf(id);
×
1438
                } else {
×
1439
                    self.bring_to_front_id(id);
×
1440
                    return;
×
1441
                }
1442
            }
×
1443

1444
            // Handle case where target is floating (and thus not in layout yet)
1445
            if let Some(target_id) = target
×
1446
                && self.is_window_floating(target_id)
×
1447
            {
1448
                // Target is floating. We must initialize layout with it.
1449
                self.clear_floating_rect(target_id);
×
1450
                if self.managed_layout.is_none() {
×
1451
                    self.managed_layout = Some(TilingLayout::new(LayoutNode::leaf(target_id)));
×
1452
                } else {
×
1453
                    // This case is tricky: managed_layout exists (implied other windows), but target is floating.
×
1454
                    // We need to tile 'id' based on 'target'.
×
1455
                    // However, 'target' itself isn't in the tree.
×
1456
                    // This implies we want to perform a "Merge" of two floating windows into a new tiled group?
×
1457
                    // But we only support one root.
×
1458
                    // If managed_layout exists, we probably shouldn't be here if target is floating?
×
1459
                    // Actually, if we have {C} tiled, and {B} floating. Snap A to B.
×
1460
                    // We want {A, B} tiled?
×
1461
                    // Current logic: If managed_layout is Some, we try insert_leaf.
×
1462
                    // If insert_leaf fails, we fallback to split_root.
×
1463
                    // If we fall back to split_root, A is added to root. B remains floating.
×
1464
                    // This is acceptable/safe.
×
1465
                    // The critical case is when managed_layout is None (the 2-window case).
×
1466
                }
×
1467
            }
×
1468

1469
            if let Some(layout) = &mut self.managed_layout {
×
1470
                let success = if let Some(target_id) = target {
×
1471
                    layout.root_mut().insert_leaf(target_id, id, position)
×
1472
                } else {
1473
                    false
×
1474
                };
1475

1476
                if !success {
×
1477
                    // If insert failed (e.g. target was missing or we are splitting root),
×
1478
                    // If target was the one we just initialized (in the floating case above), it should be at root.
×
1479
                    // insert_leaf should have worked if target is root.
×
1480
                    layout.split_root(id, position);
×
1481
                }
×
1482

1483
                // Ensure the snapped window is brought to front/focused
1484
                if let Some(pos) = self.z_order.iter().position(|&z_id| z_id == id) {
×
1485
                    self.z_order.remove(pos);
×
1486
                }
×
1487
                self.z_order.push(id);
×
1488
                self.managed_draw_order = self.z_order.clone();
×
1489
            } else {
×
1490
                self.managed_layout = Some(TilingLayout::new(LayoutNode::leaf(id)));
×
1491
            }
×
1492

1493
            // If we snapped a window into place, any other floating windows should snap as well.
1494
            let mut pending_snap = Vec::new();
×
1495
            for r_id in self.regions.ids() {
×
1496
                if r_id != id && self.is_window_floating(r_id) {
×
1497
                    pending_snap.push(r_id);
×
1498
                }
×
1499
            }
1500
            for float_id in pending_snap {
×
1501
                self.tile_window_id(float_id);
×
1502
            }
×
1503
        }
×
1504
    }
×
1505

1506
    /// Smartly insert a window into the tiling layout.
1507
    /// If there is a focused tiled window, split it.
1508
    /// Otherwise, split the root.
1509
    pub fn tile_window(&mut self, id: R) -> bool {
×
1510
        self.tile_window_id(WindowId::app(id))
×
1511
    }
×
1512

1513
    fn tile_window_id(&mut self, id: WindowId<R>) -> bool {
×
1514
        // If already in layout or floating, do nothing (or move it?)
1515
        // For now, assume this is for new windows.
1516
        if self.layout_contains(id) {
×
1517
            if self.is_window_floating(id) {
×
1518
                self.clear_floating_rect(id);
×
1519
            }
×
1520
            self.bring_to_front_id(id);
×
1521
            return true;
×
1522
        }
×
1523
        if self.managed_layout.is_none() {
×
1524
            self.managed_layout = Some(TilingLayout::new(LayoutNode::leaf(id)));
×
1525
            self.bring_to_front_id(id);
×
1526
            return true;
×
1527
        }
×
1528

1529
        // Try to find a focused node that is in the layout
1530
        let current_focus = self.wm_focus.current();
×
1531

1532
        let mut target_r = None;
×
1533
        for r_id in self.regions.ids() {
×
1534
            if r_id == current_focus {
×
1535
                target_r = Some(r_id);
×
1536
                break;
×
1537
            }
×
1538
        }
1539

1540
        let Some(layout) = self.managed_layout.as_mut() else {
×
1541
            return false;
×
1542
        };
1543

1544
        // If we found a focused region, split it
1545
        if let Some(target) = target_r {
×
1546
            // Prefer splitting horizontally (side-by-side) for wide windows, vertically for tall?
1547
            // Or just default to Right/Bottom.
1548
            // Let's default to Right for now as it's common.
1549
            if layout
×
1550
                .root_mut()
×
1551
                .insert_leaf(target, id, InsertPosition::Right)
×
1552
            {
1553
                self.bring_to_front_id(id);
×
1554
                return true;
×
1555
            }
×
1556
        }
×
1557

1558
        // Fallback: split root
1559
        layout.split_root(id, InsertPosition::Right);
×
1560
        self.bring_to_front_id(id);
×
1561
        true
×
1562
    }
×
1563

1564
    pub fn bring_to_front(&mut self, id: R) {
×
1565
        self.bring_to_front_id(WindowId::app(id));
×
1566
    }
×
1567

1568
    fn bring_to_front_id(&mut self, id: WindowId<R>) {
×
1569
        if let Some(pos) = self.z_order.iter().position(|&x| x == id) {
×
1570
            let item = self.z_order.remove(pos);
×
1571
            self.z_order.push(item);
×
1572
        }
×
1573
    }
×
1574

1575
    pub fn bring_all_floating_to_front(&mut self) {
×
1576
        let ids: Vec<WindowId<R>> = self
×
1577
            .z_order
×
1578
            .iter()
×
1579
            .copied()
×
1580
            .filter(|id| self.is_window_floating(*id))
×
1581
            .collect();
×
1582
        for id in ids {
×
1583
            self.bring_to_front_id(id);
×
1584
        }
×
1585
    }
×
1586

1587
    fn bring_floating_to_front_id(&mut self, id: WindowId<R>) {
×
1588
        self.bring_to_front_id(id);
×
1589
    }
×
1590

1591
    fn bring_floating_to_front(&mut self, id: R) {
×
1592
        self.bring_floating_to_front_id(WindowId::app(id));
×
1593
    }
×
1594

1595
    fn clamp_floating_to_bounds(&mut self) {
×
1596
        let bounds = self.managed_area;
×
1597
        if bounds.width == 0 || bounds.height == 0 {
×
1598
            return;
×
1599
        }
×
1600
        // Collect updates first to avoid borrowing `self` mutably while iterating
1601
        let mut updates: Vec<(WindowId<R>, RectSpec)> = Vec::new();
×
1602
        let floating_ids: Vec<WindowId<R>> = self
×
1603
            .windows
×
1604
            .iter()
×
1605
            .filter_map(|(&id, window)| window.floating_rect.as_ref().map(|_| id))
×
1606
            .collect();
×
1607
        for id in floating_ids {
×
1608
            let Some(RectSpec::Absolute(rect)) = self.floating_rect(id) else {
×
1609
                continue;
×
1610
            };
1611
            if rects_intersect(rect, bounds) {
×
1612
                continue;
×
1613
            }
×
1614
            // Only recover panes that are fully off-screen; keep normal dragging untouched.
1615
            let rect_right = rect.x.saturating_add(rect.width);
×
1616
            let rect_bottom = rect.y.saturating_add(rect.height);
×
1617
            let bounds_right = bounds.x.saturating_add(bounds.width);
×
1618
            let bounds_bottom = bounds.y.saturating_add(bounds.height);
×
1619
            // Clamp only the axis that is fully outside the viewport.
1620
            let out_x = rect_right <= bounds.x || rect.x >= bounds_right;
×
1621
            let out_y = rect_bottom <= bounds.y || rect.y >= bounds_bottom;
×
1622
            let min_w = FLOATING_MIN_WIDTH.min(bounds.width.max(1));
×
1623
            let min_h = FLOATING_MIN_HEIGHT.min(bounds.height.max(1));
×
1624

1625
            // Ensure at least a small portion of the window (e.g. handle) is always visible
1626
            // so the user can grab it back.
1627
            let min_visible_margin = 4u16;
×
1628

1629
            let width = if self.floating_resize_offscreen {
×
1630
                rect.width.max(min_w)
×
1631
            } else {
1632
                rect.width.max(min_w).min(bounds.width)
×
1633
            };
1634
            let height = if self.floating_resize_offscreen {
×
1635
                rect.height.max(min_h)
×
1636
            } else {
1637
                rect.height.max(min_h).min(bounds.height)
×
1638
            };
1639

1640
            let max_x = if self.floating_resize_offscreen {
×
1641
                bounds
×
1642
                    .x
×
1643
                    .saturating_add(bounds.width)
×
1644
                    .saturating_sub(min_visible_margin.min(width))
×
1645
            } else {
1646
                bounds.x.saturating_add(bounds.width.saturating_sub(width))
×
1647
            };
1648

1649
            let max_y = if self.floating_resize_offscreen {
×
1650
                bounds.y.saturating_add(bounds.height).saturating_sub(1) // Header is usually top line
×
1651
            } else {
1652
                bounds
×
1653
                    .y
×
1654
                    .saturating_add(bounds.height.saturating_sub(height))
×
1655
            };
1656

1657
            let x = if out_x || !self.floating_resize_offscreen {
×
1658
                rect.x.clamp(bounds.x, max_x)
×
1659
            } else {
1660
                rect.x.max(bounds.x).min(max_x)
×
1661
            };
1662

1663
            let y = if out_y || !self.floating_resize_offscreen {
×
1664
                rect.y.clamp(bounds.y, max_y)
×
1665
            } else {
1666
                rect.y.max(bounds.y).min(max_y)
×
1667
            };
1668
            updates.push((
×
1669
                id,
×
1670
                RectSpec::Absolute(Rect {
×
1671
                    x,
×
1672
                    y,
×
1673
                    width,
×
1674
                    height,
×
1675
                }),
×
1676
            ));
×
1677
        }
1678
        for (id, spec) in updates {
×
1679
            self.set_floating_rect(id, Some(spec));
×
1680
        }
×
1681
    }
×
1682

1683
    pub fn window_draw_plan(&mut self, frame: &mut UiFrame<'_>) -> Vec<AppWindowDraw<R>> {
×
1684
        let mut plan = Vec::new();
×
1685
        let focused_app = self.wm_focus.current().as_app();
×
1686
        for &id in &self.managed_draw_order {
×
1687
            let full = self.full_region_for_id(id);
×
1688
            if full.width == 0 || full.height == 0 {
×
1689
                continue;
×
1690
            }
×
1691
            frame.render_widget(Clear, full);
×
1692
            let WindowId::App(app_id) = id else {
×
1693
                continue;
×
1694
            };
1695
            let inner = self.region(app_id);
×
1696
            if inner.width == 0 || inner.height == 0 {
×
1697
                continue;
×
1698
            }
×
1699
            plan.push(AppWindowDraw {
×
1700
                id: app_id,
×
1701
                surface: WindowSurface { full, inner },
×
1702
                focused: focused_app == Some(app_id),
×
1703
            });
×
1704
        }
1705
        plan
×
1706
    }
×
1707

1708
    pub fn render_overlays(&mut self, frame: &mut UiFrame<'_>) {
×
1709
        let hovered = self.hover.and_then(|(column, row)| {
×
1710
            self.handles
×
1711
                .iter()
×
1712
                .find(|handle| rect_contains(handle.rect, column, row))
×
1713
        });
×
1714
        let hovered_resize = self.hover.and_then(|(column, row)| {
×
1715
            self.resize_handles
×
1716
                .iter()
×
1717
                .find(|handle| rect_contains(handle.rect, column, row))
×
1718
        });
×
1719
        let obscuring: Vec<Rect> = self
×
1720
            .managed_draw_order
×
1721
            .iter()
×
1722
            .filter_map(|&id| self.regions.get(id))
×
1723
            .collect();
×
1724
        let is_obscured =
×
1725
            |x: u16, y: u16| -> bool { obscuring.iter().any(|r| rect_contains(*r, x, y)) };
×
1726
        render_handles_masked(frame, &self.handles, hovered, is_obscured);
×
1727
        let focused = self.wm_focus.current();
×
1728

1729
        for (i, &id) in self.managed_draw_order.iter().enumerate() {
×
1730
            let Some(rect) = self.regions.get(id) else {
×
1731
                continue;
×
1732
            };
1733
            if rect.width < 3 || rect.height < 3 {
×
1734
                continue;
×
1735
            }
×
1736

1737
            if id == self.debug_log_id && self.state.debug_log_visible() {
×
1738
                let area = self.region_for_id(id);
×
1739
                if area.width > 0 && area.height > 0 {
×
1740
                    self.debug_log.render(frame, area, id == focused);
×
1741
                }
×
1742
            }
×
1743

1744
            // Collect obscuring rects (windows above this one)
1745
            let obscuring: Vec<Rect> = self.managed_draw_order[i + 1..]
×
1746
                .iter()
×
1747
                .filter_map(|&above_id| self.regions.get(above_id))
×
1748
                .collect();
×
1749

1750
            let is_obscured =
×
1751
                |x: u16, y: u16| -> bool { obscuring.iter().any(|r| rect_contains(*r, x, y)) };
×
1752

1753
            let title = self.window_title(id);
×
1754
            let focused_window = id == focused;
×
1755
            self.decorator.render_window(
×
1756
                frame,
×
1757
                rect,
×
1758
                self.managed_area,
×
1759
                &title,
×
1760
                focused_window,
×
1761
                &is_obscured,
×
1762
            );
1763
        }
1764

1765
        // Build floating panes list from per-window entries for resize outline rendering
1766
        let floating_panes: Vec<FloatingPane<WindowId<R>>> = self
×
1767
            .windows
×
1768
            .iter()
×
1769
            .filter_map(|(&id, window)| window.floating_rect.map(|rect| FloatingPane { id, rect }))
×
1770
            .collect();
×
1771

1772
        render_resize_outline(
×
1773
            frame,
×
1774
            hovered_resize.map(|handle| handle.id),
×
1775
            self.drag_resize.as_ref().map(|drag| drag.id),
×
1776
            &self.regions,
×
1777
            self.managed_area,
×
1778
            &floating_panes,
×
1779
            &self.managed_draw_order,
×
1780
        );
1781

1782
        if let Some((_, _, rect)) = self.drag_snap {
×
1783
            let buffer = frame.buffer_mut();
×
1784
            let color = crate::theme::accent();
×
1785
            let clip = rect.intersection(buffer.area);
×
1786
            if clip.width > 0 && clip.height > 0 {
×
1787
                for y in clip.y..clip.y.saturating_add(clip.height) {
×
1788
                    for x in clip.x..clip.x.saturating_add(clip.width) {
×
1789
                        if let Some(cell) = buffer.cell_mut((x, y)) {
×
1790
                            let mut style = cell.style();
×
1791
                            style.bg = Some(color);
×
1792
                            cell.set_style(style);
×
1793
                        }
×
1794
                    }
1795
                }
1796
            }
×
1797
        }
×
1798

1799
        let status_line = if self.wm_overlay_visible() {
×
1800
            let esc_state = if let Some(remaining) = self.esc_passthrough_remaining() {
×
1801
                format!("Esc passthrough: active ({}ms)", remaining.as_millis())
×
1802
            } else {
1803
                "Esc passthrough: inactive".to_string()
×
1804
            };
1805
            Some(format!("{esc_state} · Tab/Shift-Tab: cycle windows"))
×
1806
        } else {
1807
            None
×
1808
        };
1809
        let display = self.build_display_order();
×
1810
        // Build a small title map to avoid borrowing `self` inside the panel closure
1811
        let titles_map: BTreeMap<WindowId<R>, String> = self
×
1812
            .windows
×
1813
            .keys()
×
1814
            .map(|id| (*id, self.window_title(*id)))
×
1815
            .collect();
×
1816

1817
        self.panel.render(
×
1818
            frame,
×
1819
            self.panel_active(),
×
1820
            self.wm_focus.current(),
×
1821
            &display,
×
1822
            status_line.as_deref(),
×
1823
            self.mouse_capture_enabled(),
×
1824
            self.wm_overlay_visible(),
×
1825
            move |id| {
×
1826
                titles_map.get(&id).cloned().unwrap_or_else(|| match id {
×
1827
                    WindowId::App(app_id) => format!("{:?}", app_id),
×
1828
                    WindowId::System(SystemWindowId::DebugLog) => "Debug Log".to_string(),
×
1829
                })
×
1830
            },
×
1831
        );
1832
        let menu_labels = wm_menu_items(self.mouse_capture_enabled())
×
1833
            .iter()
×
1834
            .map(|item| (item.icon, item.label))
×
1835
            .collect::<Vec<_>>();
×
1836
        let bounds = frame.area();
×
1837
        self.panel.render_menu(
×
1838
            frame,
×
1839
            self.wm_overlay_visible(),
×
1840
            bounds,
×
1841
            &menu_labels,
×
1842
            self.state.wm_menu_selected(),
×
1843
        );
1844
        self.panel.render_menu_backdrop(
×
1845
            frame,
×
1846
            self.wm_overlay_visible(),
×
1847
            self.managed_area,
×
1848
            self.panel.area(),
×
1849
        );
1850
        if self.exit_confirm.visible() {
×
1851
            self.exit_confirm.render(frame, frame.area(), false);
×
1852
        }
×
NEW
1853
        if self.help_overlay.visible() {
×
NEW
1854
            self.help_overlay.render(frame, frame.area(), false);
×
NEW
1855
        }
×
UNCOV
1856
    }
×
1857

1858
    pub fn clear_window_backgrounds(&self, frame: &mut UiFrame<'_>) {
×
1859
        for id in self.regions.ids() {
×
1860
            let rect = self.full_region_for_id(id);
×
1861
            frame.render_widget(Clear, rect);
×
1862
        }
×
1863
    }
×
1864

1865
    pub fn set_regions_from_plan(&mut self, plan: &LayoutPlan<R>, area: Rect) {
×
1866
        let plan_regions = plan.regions(area);
×
1867
        self.regions = RegionMap::default();
×
1868
        for id in plan_regions.ids() {
×
1869
            if let Some(rect) = plan_regions.get(id) {
×
1870
                self.regions.set(WindowId::app(id), rect);
×
1871
            }
×
1872
        }
1873
    }
×
1874

1875
    pub fn hit_test_region(&self, column: u16, row: u16, ids: &[R]) -> Option<R> {
×
1876
        for id in ids {
×
1877
            if let Some(rect) = self.regions.get(WindowId::app(*id))
×
1878
                && rect_contains(rect, column, row)
×
1879
            {
1880
                return Some(*id);
×
1881
            }
×
1882
        }
1883
        None
×
1884
    }
×
1885

1886
    /// Hit-test regions by draw order so overlapping panes pick the topmost one.
1887
    /// This avoids clicks "falling through" floating panes to windows behind them.
1888
    fn hit_test_region_topmost(
×
1889
        &self,
×
1890
        column: u16,
×
1891
        row: u16,
×
1892
        ids: &[WindowId<R>],
×
1893
    ) -> Option<WindowId<R>> {
×
1894
        for id in ids.iter().rev() {
×
1895
            if let Some(rect) = self.regions.get(*id)
×
1896
                && rect_contains(rect, column, row)
×
1897
            {
1898
                return Some(*id);
×
1899
            }
×
1900
        }
1901
        None
×
1902
    }
×
1903

1904
    pub fn handle_focus_event<F, G>(
×
1905
        &mut self,
×
1906
        event: &Event,
×
1907
        hit_targets: &[R],
×
1908
        map: F,
×
1909
        map_focus: G,
×
1910
    ) -> bool
×
1911
    where
×
1912
        F: Fn(R) -> W,
×
1913
        G: Fn(W) -> Option<R>,
×
1914
    {
1915
        match event {
×
1916
            Event::Key(key) => match key.code {
×
1917
                KeyCode::Tab => {
1918
                    if self.layout_contract == LayoutContract::WindowManaged {
×
1919
                        self.advance_wm_focus(true);
×
1920
                    } else {
×
1921
                        self.app_focus.advance(true);
×
1922
                        // Ensure app-level tab switching also brings the corresponding window to front
1923
                        let focused_app = self.app_focus.current();
×
1924
                        if let Some(region) = map_focus(focused_app) {
×
1925
                            self.set_wm_focus(WindowId::app(region));
×
1926
                            self.bring_to_front_id(WindowId::app(region));
×
1927
                            self.managed_draw_order = self.z_order.clone();
×
1928
                        }
×
1929
                    }
1930
                    true
×
1931
                }
1932
                KeyCode::BackTab => {
1933
                    if self.layout_contract == LayoutContract::WindowManaged {
×
1934
                        self.advance_wm_focus(false);
×
1935
                    } else {
×
1936
                        self.app_focus.advance(false);
×
1937
                        // Mirror behavior for reverse tabbing as well
1938
                        let focused_app = self.app_focus.current();
×
1939
                        if let Some(region) = map_focus(focused_app) {
×
1940
                            self.set_wm_focus(WindowId::app(region));
×
1941
                            self.bring_to_front_id(WindowId::app(region));
×
1942
                            self.managed_draw_order = self.z_order.clone();
×
1943
                        }
×
1944
                    }
1945
                    true
×
1946
                }
1947
                _ => false,
×
1948
            },
1949
            Event::Mouse(mouse) => {
×
1950
                self.hover = Some((mouse.column, mouse.row));
×
1951
                match mouse.kind {
×
1952
                    MouseEventKind::Down(_) => {
1953
                        if self.layout_contract == LayoutContract::WindowManaged
×
1954
                            && !self.managed_draw_order.is_empty()
×
1955
                        {
1956
                            let hit = self.hit_test_region_topmost(
×
1957
                                mouse.column,
×
1958
                                mouse.row,
×
1959
                                &self.managed_draw_order,
×
1960
                            );
1961
                            if let Some(id) = hit {
×
1962
                                self.set_wm_focus(id);
×
1963
                                self.bring_floating_to_front_id(id);
×
1964
                                return true;
×
1965
                            }
×
1966
                            return false;
×
1967
                        }
×
1968
                        let hit = self.hit_test_region(mouse.column, mouse.row, hit_targets);
×
1969
                        if let Some(hit) = hit {
×
1970
                            self.app_focus.set_current(map(hit));
×
1971
                            if self.layout_contract == LayoutContract::WindowManaged {
×
1972
                                self.set_wm_focus(WindowId::app(hit));
×
1973
                                self.bring_floating_to_front(hit);
×
1974
                            }
×
1975
                            true
×
1976
                        } else {
1977
                            false
×
1978
                        }
1979
                    }
1980
                    _ => false,
×
1981
                }
1982
            }
1983
            _ => false,
×
1984
        }
1985
    }
×
1986

1987
    fn panel_active(&self) -> bool {
×
1988
        self.layout_contract == LayoutContract::WindowManaged
×
1989
            && self.panel.visible()
×
1990
            && self.panel.height() > 0
×
1991
    }
×
1992

1993
    fn focus_for_region(&self, id: R) -> Option<W> {
×
1994
        if self.app_focus.order.is_empty() {
×
1995
            if id == self.app_focus.current {
×
1996
                Some(self.app_focus.current)
×
1997
            } else {
1998
                None
×
1999
            }
2000
        } else {
2001
            self.app_focus
×
2002
                .order
×
2003
                .iter()
×
2004
                .copied()
×
2005
                .find(|focus| id == *focus)
×
2006
        }
2007
    }
×
2008

2009
    pub fn handle_wm_menu_event(&mut self, event: &Event) -> Option<WmMenuAction> {
×
2010
        if !self.wm_overlay_visible() {
×
2011
            return None;
×
2012
        }
×
2013
        if let Event::Mouse(mouse) = event
×
2014
            && matches!(mouse.kind, MouseEventKind::Down(_))
×
2015
        {
2016
            if let Some(index) = self.panel.hit_test_menu_item(event) {
×
2017
                let items = wm_menu_items(self.mouse_capture_enabled());
×
2018
                let selected = index.min(items.len().saturating_sub(1));
×
2019
                self.state.set_wm_menu_selected(selected);
×
2020
                return items.get(selected).map(|item| item.action);
×
2021
            }
×
2022
            if self.panel.menu_icon_contains_point(mouse.column, mouse.row) {
×
2023
                return Some(WmMenuAction::CloseMenu);
×
2024
            }
×
2025
            if !self.panel.menu_contains_point(mouse.column, mouse.row) {
×
2026
                return Some(WmMenuAction::CloseMenu);
×
2027
            }
×
2028
        }
×
2029
        let Event::Key(key) = event else {
×
2030
            return None;
×
2031
        };
2032
        match key.code {
×
2033
            KeyCode::Up => {
2034
                let total = wm_menu_items(self.mouse_capture_enabled()).len();
×
2035
                if total > 0 {
×
2036
                    let current = self.state.wm_menu_selected();
×
2037
                    if current == 0 {
×
2038
                        self.state.set_wm_menu_selected(total - 1);
×
2039
                    } else {
×
2040
                        self.state.set_wm_menu_selected(current - 1);
×
2041
                    }
×
2042
                }
×
2043
                None
×
2044
            }
2045
            KeyCode::Down => {
2046
                let total = wm_menu_items(self.mouse_capture_enabled()).len();
×
2047
                if total > 0 {
×
2048
                    let current = self.state.wm_menu_selected();
×
2049
                    self.state.set_wm_menu_selected((current + 1) % total);
×
2050
                }
×
2051
                None
×
2052
            }
2053
            KeyCode::Char('k') => {
2054
                let total = wm_menu_items(self.mouse_capture_enabled()).len();
×
2055
                if total > 0 {
×
2056
                    let current = self.state.wm_menu_selected();
×
2057
                    if current == 0 {
×
2058
                        self.state.set_wm_menu_selected(total - 1);
×
2059
                    } else {
×
2060
                        self.state.set_wm_menu_selected(current - 1);
×
2061
                    }
×
2062
                }
×
2063
                None
×
2064
            }
2065
            KeyCode::Char('j') => {
2066
                let total = wm_menu_items(self.mouse_capture_enabled()).len();
×
2067
                if total > 0 {
×
2068
                    let current = self.state.wm_menu_selected();
×
2069
                    self.state.set_wm_menu_selected((current + 1) % total);
×
2070
                }
×
2071
                None
×
2072
            }
2073
            KeyCode::Enter => wm_menu_items(self.mouse_capture_enabled())
×
2074
                .get(self.state.wm_menu_selected())
×
2075
                .map(|item| item.action),
×
2076
            _ => None,
×
2077
        }
2078
    }
×
2079

2080
    pub fn handle_exit_confirm_event(&mut self, event: &Event) -> Option<ConfirmAction> {
×
2081
        if !self.exit_confirm.visible() {
×
2082
            return None;
×
2083
        }
×
2084
        self.exit_confirm.handle_confirm_event(event)
×
2085
    }
×
2086

2087
    pub fn wm_menu_consumes_event(&self, event: &Event) -> bool {
×
2088
        if !self.wm_overlay_visible() {
×
2089
            return false;
×
2090
        }
×
2091
        let Event::Key(key) = event else {
×
2092
            return false;
×
2093
        };
2094
        matches!(
×
2095
            key.code,
×
2096
            KeyCode::Up | KeyCode::Down | KeyCode::Enter | KeyCode::Char('j') | KeyCode::Char('k')
2097
        )
2098
    }
×
2099
}
2100

2101
#[derive(Debug, Clone, Copy)]
2102
struct WmMenuItem {
2103
    label: &'static str,
2104
    icon: Option<&'static str>,
2105
    action: WmMenuAction,
2106
}
2107
fn wm_menu_items(mouse_capture_enabled: bool) -> [WmMenuItem; 6] {
×
2108
    let mouse_label = if mouse_capture_enabled {
×
2109
        "Mouse Capture: On"
×
2110
    } else {
2111
        "Mouse Capture: Off"
×
2112
    };
2113
    [
×
2114
        WmMenuItem {
×
2115
            label: "Resume",
×
2116
            icon: None,
×
2117
            action: WmMenuAction::CloseMenu,
×
2118
        },
×
2119
        WmMenuItem {
×
2120
            label: mouse_label,
×
2121
            icon: Some("🖱"),
×
2122
            action: WmMenuAction::ToggleMouseCapture,
×
2123
        },
×
2124
        WmMenuItem {
×
2125
            label: "Floating Front",
×
2126
            icon: Some("↑"),
×
2127
            action: WmMenuAction::BringFloatingFront,
×
2128
        },
×
2129
        WmMenuItem {
×
2130
            label: "New Window",
×
2131
            icon: Some("+"),
×
2132
            action: WmMenuAction::NewWindow,
×
2133
        },
×
2134
        WmMenuItem {
×
2135
            label: "Debug Log",
×
2136
            icon: Some("≣"),
×
2137
            action: WmMenuAction::ToggleDebugWindow,
×
2138
        },
×
2139
        WmMenuItem {
×
2140
            label: "Exit UI",
×
2141
            icon: Some("⏻"),
×
2142
            action: WmMenuAction::ExitUi,
×
2143
        },
×
2144
    ]
×
2145
}
×
2146

2147
fn esc_passthrough_window_default() -> Duration {
1✔
2148
    #[cfg(windows)]
2149
    {
2150
        Duration::from_millis(1200)
2151
    }
2152
    #[cfg(not(windows))]
2153
    {
2154
        Duration::from_millis(600)
1✔
2155
    }
2156
}
1✔
2157

2158
fn clamp_rect(area: Rect, bounds: Rect) -> Rect {
2✔
2159
    let x0 = area.x.max(bounds.x);
2✔
2160
    let y0 = area.y.max(bounds.y);
2✔
2161
    let x1 = area
2✔
2162
        .x
2✔
2163
        .saturating_add(area.width)
2✔
2164
        .min(bounds.x.saturating_add(bounds.width));
2✔
2165
    let y1 = area
2✔
2166
        .y
2✔
2167
        .saturating_add(area.height)
2✔
2168
        .min(bounds.y.saturating_add(bounds.height));
2✔
2169
    if x1 <= x0 || y1 <= y0 {
2✔
2170
        return Rect::default();
1✔
2171
    }
1✔
2172
    Rect {
1✔
2173
        x: x0,
1✔
2174
        y: y0,
1✔
2175
        width: x1 - x0,
1✔
2176
        height: y1 - y0,
1✔
2177
    }
1✔
2178
}
2✔
2179

2180
fn rects_intersect(a: Rect, b: Rect) -> bool {
2✔
2181
    if a.width == 0 || a.height == 0 || b.width == 0 || b.height == 0 {
2✔
2182
        return false;
×
2183
    }
2✔
2184
    let a_right = a.x.saturating_add(a.width);
2✔
2185
    let a_bottom = a.y.saturating_add(a.height);
2✔
2186
    let b_right = b.x.saturating_add(b.width);
2✔
2187
    let b_bottom = b.y.saturating_add(b.height);
2✔
2188
    a.x < b_right && a_right > b.x && a.y < b_bottom && a_bottom > b.y
2✔
2189
}
2✔
2190

2191
fn map_layout_node<R: Copy + Eq + Ord>(node: &LayoutNode<R>) -> LayoutNode<WindowId<R>> {
1✔
2192
    match node {
1✔
2193
        LayoutNode::Leaf(id) => LayoutNode::leaf(WindowId::app(*id)),
1✔
2194
        LayoutNode::Split {
2195
            direction,
×
2196
            children,
×
2197
            weights,
×
2198
            constraints,
×
2199
            resizable,
×
2200
        } => LayoutNode::Split {
×
2201
            direction: *direction,
×
2202
            children: children.iter().map(map_layout_node).collect(),
×
2203
            weights: weights.clone(),
×
2204
            constraints: constraints.clone(),
×
2205
            resizable: *resizable,
×
2206
        },
×
2207
    }
2208
}
1✔
2209

2210
#[cfg(test)]
2211
mod tests {
2212
    use super::*;
2213
    use ratatui::layout::Rect;
2214

2215
    #[test]
2216
    fn clamp_rect_inside_and_outside() {
1✔
2217
        let area = Rect {
1✔
2218
            x: 2,
1✔
2219
            y: 2,
1✔
2220
            width: 4,
1✔
2221
            height: 4,
1✔
2222
        };
1✔
2223
        let bounds = Rect {
1✔
2224
            x: 0,
1✔
2225
            y: 0,
1✔
2226
            width: 10,
1✔
2227
            height: 10,
1✔
2228
        };
1✔
2229
        let r = clamp_rect(area, bounds);
1✔
2230
        assert_eq!(r.x, 2);
1✔
2231
        assert_eq!(r.y, 2);
1✔
2232

2233
        // non-overlapping
2234
        let area2 = Rect {
1✔
2235
            x: 50,
1✔
2236
            y: 50,
1✔
2237
            width: 1,
1✔
2238
            height: 1,
1✔
2239
        };
1✔
2240
        let r2 = clamp_rect(area2, bounds);
1✔
2241
        assert_eq!(r2, Rect::default());
1✔
2242
    }
1✔
2243

2244
    #[test]
2245
    fn rects_intersect_true_and_false() {
1✔
2246
        let a = Rect {
1✔
2247
            x: 0,
1✔
2248
            y: 0,
1✔
2249
            width: 5,
1✔
2250
            height: 5,
1✔
2251
        };
1✔
2252
        let b = Rect {
1✔
2253
            x: 4,
1✔
2254
            y: 4,
1✔
2255
            width: 5,
1✔
2256
            height: 5,
1✔
2257
        };
1✔
2258
        assert!(rects_intersect(a, b));
1✔
2259
        let c = Rect {
1✔
2260
            x: 10,
1✔
2261
            y: 10,
1✔
2262
            width: 1,
1✔
2263
            height: 1,
1✔
2264
        };
1✔
2265
        assert!(!rects_intersect(a, c));
1✔
2266
    }
1✔
2267

2268
    #[test]
2269
    fn map_layout_node_maps_leaf_to_windowid_app() {
1✔
2270
        let node = LayoutNode::leaf(3usize);
1✔
2271
        let mapped = map_layout_node(&node);
1✔
2272
        match mapped {
1✔
2273
            LayoutNode::Leaf(id) => match id {
1✔
2274
                WindowId::App(r) => assert_eq!(r, 3usize),
1✔
2275
                _ => panic!("expected App window id"),
×
2276
            },
2277
            _ => panic!("expected leaf"),
×
2278
        }
2279
    }
1✔
2280

2281
    #[test]
2282
    fn esc_passthrough_default_nonzero() {
1✔
2283
        let d = esc_passthrough_window_default();
1✔
2284
        assert!(d.as_millis() > 0);
1✔
2285
    }
1✔
2286

2287
    #[test]
2288
    fn focus_ring_wraps_and_advances() {
1✔
2289
        let mut ring = FocusRing::new(2usize);
1✔
2290
        ring.set_order(vec![1usize, 2usize, 3usize]);
1✔
2291
        assert_eq!(ring.current(), 2);
1✔
2292
        ring.advance(true);
1✔
2293
        assert_eq!(ring.current(), 3);
1✔
2294
        ring.advance(true);
1✔
2295
        assert_eq!(ring.current(), 1);
1✔
2296
        ring.advance(false);
1✔
2297
        assert_eq!(ring.current(), 3);
1✔
2298
    }
1✔
2299

2300
    #[test]
2301
    fn scroll_state_apply_and_bump() {
1✔
2302
        let mut s = ScrollState::default();
1✔
2303
        s.bump(5);
1✔
2304
        s.apply(100, 10);
1✔
2305
        assert_eq!(s.offset, 5usize);
1✔
2306

2307
        s.offset = 1000;
1✔
2308
        s.apply(20, 5);
1✔
2309
        let max_off = 20usize.saturating_sub(5usize);
1✔
2310
        assert_eq!(s.offset, max_off);
1✔
2311
    }
1✔
2312
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc