Skip to main content

term_wm_layout_engine/
floating.rs

1use crate::rect::{LayoutRect, rect_contains};
2
3// TODO: Remove these constants; the layout engine should be agnostic to these
4/// Minimum width for a floating window (in cells).
5pub const FLOATING_MIN_WIDTH: u16 = 6;
6
7/// Minimum height for a floating window (in cells).
8pub const FLOATING_MIN_HEIGHT: u16 = 3;
9
10/// Identifies which edge(s) of a floating window are being dragged.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ResizeEdge {
13    Left,
14    Right,
15    Top,
16    Bottom,
17    TopLeft,
18    TopRight,
19    BottomLeft,
20    BottomRight,
21}
22
23/// A single-cell hit-test handle at a corner or along an edge of a floating window.
24#[derive(Debug, Clone, Copy)]
25pub struct ResizeHandle<R: Copy + Eq + Ord> {
26    pub id: R,
27    pub rect: LayoutRect,
28    pub edge: ResizeEdge,
29}
30
31/// State of an in-progress resize drag.
32#[derive(Debug, Clone, Copy)]
33pub struct ResizeDrag<R: Copy + Eq + Ord> {
34    pub id: R,
35    pub edge: ResizeEdge,
36    pub start_col: u16,
37    pub start_row: u16,
38    pub start_x: i32,
39    pub start_y: i32,
40    pub start_width: u16,
41    pub start_height: u16,
42}
43
44/// State of an in-progress header drag (move).
45#[derive(Debug, Clone, Copy)]
46pub struct HeaderDrag<R: Copy + Eq + Ord> {
47    pub id: R,
48    pub initial_x: i32,
49    pub initial_y: i32,
50    pub start_x: u16,
51    pub start_y: u16,
52}
53
54/// A hit-test region for the title bar of a floating window.
55#[derive(Debug, Clone, Copy)]
56pub struct DragHandle<R: Copy + Eq + Ord> {
57    pub id: R,
58    pub rect: LayoutRect,
59}
60
61/// Generate all 8 resize handles (4 corners + 4 edges) for a floating region.
62pub fn resize_handles_for_region<R: Copy + Eq + Ord>(
63    id: R,
64    rect: LayoutRect,
65    _bounds: LayoutRect,
66) -> Vec<ResizeHandle<R>> {
67    if rect.width == 0 || rect.height == 0 {
68        return Vec::new();
69    }
70
71    let x1 = rect.x;
72    let y1 = rect.y;
73    let x2 = rect
74        .x
75        .saturating_add(i32::from(rect.width.saturating_sub(1)));
76    let y2 = rect
77        .y
78        .saturating_add(i32::from(rect.height.saturating_sub(1)));
79
80    let mut handles = Vec::with_capacity(8);
81
82    // Corners (1×1)
83    handles.push(ResizeHandle {
84        id,
85        rect: LayoutRect {
86            x: x1,
87            y: y1,
88            width: 1,
89            height: 1,
90        },
91        edge: ResizeEdge::TopLeft,
92    });
93    handles.push(ResizeHandle {
94        id,
95        rect: LayoutRect {
96            x: x2,
97            y: y1,
98            width: 1,
99            height: 1,
100        },
101        edge: ResizeEdge::TopRight,
102    });
103    handles.push(ResizeHandle {
104        id,
105        rect: LayoutRect {
106            x: x1,
107            y: y2,
108            width: 1,
109            height: 1,
110        },
111        edge: ResizeEdge::BottomLeft,
112    });
113    handles.push(ResizeHandle {
114        id,
115        rect: LayoutRect {
116            x: x2,
117            y: y2,
118            width: 1,
119            height: 1,
120        },
121        edge: ResizeEdge::BottomRight,
122    });
123
124    // Edge handles (span the full dimension minus corners)
125    if rect.height > 2 {
126        let inner_h = rect.height.saturating_sub(2);
127        handles.push(ResizeHandle {
128            id,
129            rect: LayoutRect {
130                x: x1,
131                y: y1.saturating_add(1),
132                width: 1,
133                height: inner_h,
134            },
135            edge: ResizeEdge::Left,
136        });
137        handles.push(ResizeHandle {
138            id,
139            rect: LayoutRect {
140                x: x2,
141                y: y1.saturating_add(1),
142                width: 1,
143                height: inner_h,
144            },
145            edge: ResizeEdge::Right,
146        });
147    }
148    if rect.width > 2 {
149        let inner_w = rect.width.saturating_sub(2);
150        handles.push(ResizeHandle {
151            id,
152            rect: LayoutRect {
153                x: x1.saturating_add(1),
154                y: y1,
155                width: inner_w,
156                height: 1,
157            },
158            edge: ResizeEdge::Top,
159        });
160        handles.push(ResizeHandle {
161            id,
162            rect: LayoutRect {
163                x: x1.saturating_add(1),
164                y: y2,
165                width: inner_w,
166                height: 1,
167            },
168            edge: ResizeEdge::Bottom,
169        });
170    }
171
172    handles
173}
174
175// TODO: The hardcoded magic numbers have to go; the layout engine should be agnostic to these as well/
176/// Generate a drag handle for the title bar of a floating window.
177pub fn floating_header_for_region<R: Copy + Eq + Ord>(
178    id: R,
179    rect: LayoutRect,
180    bounds: LayoutRect,
181) -> Option<DragHandle<R>> {
182    if rect.width < 3 || rect.height < 3 {
183        return None;
184    }
185    let header_rect = LayoutRect {
186        x: rect.x.saturating_add(1),
187        y: rect.y.saturating_add(1),
188        width: rect.width.saturating_sub(2),
189        height: 1,
190    };
191    if !rect_contains(&bounds, header_rect.x as u16, header_rect.y as u16) {
192        return None;
193    }
194    Some(DragHandle {
195        id,
196        rect: header_rect,
197    })
198}
199
200/// Apply a resize drag delta to a floating window's geometry.
201///
202/// Returns the new [`LayoutRect`] after applying the delta, enforcing
203/// minimum size constraints and bounds clamping.
204#[allow(clippy::too_many_arguments)]
205pub fn apply_resize_drag_signed(
206    start_x: i32,
207    start_y: i32,
208    start_width: u16,
209    start_height: u16,
210    edge: ResizeEdge,
211    column: u16,
212    row: u16,
213    start_col: u16,
214    start_row: u16,
215    bounds: LayoutRect,
216    allow_offscreen: bool,
217) -> LayoutRect {
218    let dx = i32::from(column).saturating_sub(i32::from(start_col));
219    let dy = i32::from(row).saturating_sub(i32::from(start_row));
220
221    let mut x = start_x;
222    let mut y = start_y;
223    let mut w = i32::from(start_width);
224    let mut h = i32::from(start_height);
225
226    // Apply delta to edges
227    match edge {
228        ResizeEdge::Left | ResizeEdge::TopLeft | ResizeEdge::BottomLeft => {
229            x = x.saturating_add(dx);
230            w = w.saturating_sub(dx);
231        }
232        ResizeEdge::Right | ResizeEdge::TopRight | ResizeEdge::BottomRight => {
233            w = w.saturating_add(dx);
234        }
235        _ => {}
236    }
237    match edge {
238        ResizeEdge::Top | ResizeEdge::TopLeft | ResizeEdge::TopRight => {
239            y = y.saturating_add(dy);
240            h = h.saturating_sub(dy);
241        }
242        ResizeEdge::Bottom | ResizeEdge::BottomLeft | ResizeEdge::BottomRight => {
243            h = h.saturating_add(dy);
244        }
245        _ => {}
246    }
247
248    // Enforce minimum size
249    let min_w = i32::from(FLOATING_MIN_WIDTH);
250    let min_h = i32::from(FLOATING_MIN_HEIGHT);
251
252    if w < min_w {
253        match edge {
254            ResizeEdge::Left | ResizeEdge::TopLeft | ResizeEdge::BottomLeft => {
255                x = x.saturating_sub(min_w.saturating_sub(w));
256            }
257            _ => {}
258        }
259        w = min_w;
260    }
261    if h < min_h {
262        match edge {
263            ResizeEdge::Top | ResizeEdge::TopLeft | ResizeEdge::TopRight => {
264                y = y.saturating_sub(min_h.saturating_sub(h));
265            }
266            _ => {}
267        }
268        h = min_h;
269    }
270
271    // Convert to u16 with safety clamp
272    let mut width = w.max(1).min(i32::from(u16::MAX)) as u16;
273    let mut height = h.max(1).min(i32::from(u16::MAX)) as u16;
274
275    // Bounds clamping
276    if !allow_offscreen {
277        width = width.min(bounds.width);
278        height = height.min(bounds.height);
279
280        let bounds_x1 = bounds.x.saturating_add(i32::from(bounds.width));
281        let bounds_y1 = bounds.y.saturating_add(i32::from(bounds.height));
282        let max_x = bounds_x1.saturating_sub(i32::from(width));
283        let max_y = bounds_y1.saturating_sub(i32::from(height));
284
285        x = x.max(bounds.x).min(max_x);
286        y = y.max(bounds.y).min(max_y);
287    }
288
289    LayoutRect {
290        x,
291        y,
292        width,
293        height,
294    }
295}
296
297/// Clamp a floating window rect into `bounds`, preserving its size.
298///
299/// When `allow_offscreen` is true the window may sit partially off-screen but
300/// must keep at least `min_visible_margin` cells visible so its chrome can
301/// still be grabbed; when false the rect is fully contained. The size is
302/// enlarged to the floating minimums and, when `!allow_offscreen`, capped at
303/// `bounds`' dimensions.
304pub fn clamp_floating_to_bounds(
305    rect: LayoutRect,
306    bounds: LayoutRect,
307    min_visible_margin: u16,
308    allow_offscreen: bool,
309) -> LayoutRect {
310    let min_w = FLOATING_MIN_WIDTH.min(bounds.width.max(1));
311    let min_h = FLOATING_MIN_HEIGHT.min(bounds.height.max(1));
312
313    let width = if allow_offscreen {
314        rect.width.max(min_w)
315    } else {
316        rect.width.max(min_w).min(bounds.width)
317    };
318    let height = if allow_offscreen {
319        rect.height.max(min_h)
320    } else {
321        rect.height.max(min_h).min(bounds.height)
322    };
323
324    let max_x = if allow_offscreen {
325        bounds
326            .x
327            .saturating_add(i32::from(bounds.width))
328            .saturating_sub(i32::from(min_visible_margin.min(width)))
329    } else {
330        bounds
331            .x
332            .saturating_add(i32::from(bounds.width.saturating_sub(width)))
333    };
334
335    let max_y = if allow_offscreen {
336        bounds
337            .y
338            .saturating_add(i32::from(bounds.height))
339            .saturating_sub(i32::from(min_visible_margin.min(height)))
340    } else {
341        bounds
342            .y
343            .saturating_add(i32::from(bounds.height.saturating_sub(height)))
344    };
345
346    let out_x = rect.x.saturating_add(i32::from(rect.width)) <= bounds.x
347        || rect.x >= bounds.x.saturating_add(i32::from(bounds.width));
348    let out_y = rect.y.saturating_add(i32::from(rect.height)) <= bounds.y
349        || rect.y >= bounds.y.saturating_add(i32::from(bounds.height));
350
351    let x = if out_x || !allow_offscreen {
352        rect.x.clamp(bounds.x.min(max_x), max_x)
353    } else {
354        let visible_width = min_visible_margin.min(width);
355        let left_allowed = bounds
356            .x
357            .saturating_sub(i32::from(width.saturating_sub(visible_width)));
358        rect.x.clamp(left_allowed.min(max_x), max_x)
359    };
360
361    let y = if out_y || !allow_offscreen {
362        rect.y.clamp(bounds.y.min(max_y), max_y)
363    } else {
364        let visible_height = min_visible_margin.min(height);
365        let top_allowed = bounds
366            .y
367            .saturating_sub(i32::from(height.saturating_sub(visible_height)));
368        rect.y.clamp(top_allowed.min(max_y), max_y)
369    };
370
371    LayoutRect {
372        x,
373        y,
374        width,
375        height,
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    fn area() -> LayoutRect {
384        LayoutRect {
385            x: 0,
386            y: 0,
387            width: 80,
388            height: 24,
389        }
390    }
391
392    #[test]
393    fn resize_handles_count() {
394        let rect = LayoutRect {
395            x: 10,
396            y: 10,
397            width: 20,
398            height: 15,
399        };
400        let handles = resize_handles_for_region(1u8, rect, area());
401        assert_eq!(handles.len(), 8);
402    }
403
404    #[test]
405    fn resize_handles_small_rect() {
406        let rect = LayoutRect {
407            x: 10,
408            y: 10,
409            width: 1,
410            height: 1,
411        };
412        let handles = resize_handles_for_region(1u8, rect, area());
413        // Only corners (4), no edge handles since dims <= 2
414        assert_eq!(handles.len(), 4);
415    }
416
417    #[test]
418    fn floating_header_normal() {
419        let rect = LayoutRect {
420            x: 10,
421            y: 10,
422            width: 20,
423            height: 20,
424        };
425        let header = floating_header_for_region(1u8, rect, area());
426        assert!(header.is_some());
427        let h = header.unwrap();
428        assert_eq!(h.rect.width, 18);
429        assert_eq!(h.rect.height, 1);
430        assert_eq!(h.rect.y, 11);
431    }
432
433    #[test]
434    fn floating_header_too_small() {
435        let rect = LayoutRect {
436            x: 10,
437            y: 10,
438            width: 2,
439            height: 2,
440        };
441        assert!(floating_header_for_region(1u8, rect, area()).is_none());
442    }
443
444    #[test]
445    fn apply_resize_drag_right_edge() {
446        let result = apply_resize_drag_signed(
447            10,
448            10,
449            20,
450            15,
451            ResizeEdge::Right,
452            50,
453            10,
454            30,
455            10,
456            area(),
457            false,
458        );
459        assert_eq!(result.width, 40); // 20 + (50-30)
460        assert_eq!(result.x, 10);
461    }
462
463    #[test]
464    fn apply_resize_drag_left_edge() {
465        let result = apply_resize_drag_signed(
466            20,
467            10,
468            20,
469            15,
470            ResizeEdge::Left,
471            10,
472            10,
473            30,
474            10,
475            area(),
476            false,
477        );
478        assert_eq!(result.x, 0); // 20 + (10-30) = 0
479        assert_eq!(result.width, 40); // 20 - (10-30) = 40
480    }
481
482    #[test]
483    fn apply_resize_drag_enforces_min_size() {
484        // Drag left edge rightward to shrink width below minimum
485        let result = apply_resize_drag_signed(
486            10,
487            10,
488            10,
489            10,
490            ResizeEdge::Left,
491            20,
492            10,
493            5,
494            10,
495            area(),
496            false,
497        );
498        assert_eq!(result.width, FLOATING_MIN_WIDTH);
499    }
500
501    #[test]
502    fn apply_resize_drag_offscreen_allowed() {
503        let result =
504            apply_resize_drag_signed(0, 0, 80, 24, ResizeEdge::Left, 40, 0, 0, 0, area(), true);
505        assert_eq!(result.x, 40);
506        assert_eq!(result.width, 40);
507    }
508
509    #[test]
510    fn clamp_floating_keeps_min_margin_when_offscreen_allowed() {
511        // Window dragged fully left off-screen: with allow_offscreen it may
512        // stick out but must keep `min_visible_margin` cells visible.
513        let bounds = LayoutRect {
514            x: 0,
515            y: 0,
516            width: 10,
517            height: 10,
518        };
519        let rect = LayoutRect {
520            x: -4,
521            y: 0,
522            width: 6,
523            height: 3,
524        };
525        let clamped = clamp_floating_to_bounds(rect, bounds, 4, true);
526        // left edge clamped so 4 cells stay visible: x = -(6 - 4) = -2
527        assert_eq!(clamped.x, -2);
528        assert_eq!(clamped.width, 6);
529    }
530
531    #[test]
532    fn clamp_floating_keeps_min_margin_vertically() {
533        let bounds = LayoutRect {
534            x: 0,
535            y: 0,
536            width: 10,
537            height: 10,
538        };
539        let rect = LayoutRect {
540            x: 0,
541            y: -3,
542            width: 6,
543            height: 4,
544        };
545        let clamped = clamp_floating_to_bounds(rect, bounds, 4, true);
546        assert!(
547            clamped.y >= -1,
548            "must keep >= 4 visible rows: y={}",
549            clamped.y
550        );
551        assert!(clamped.y + i32::from(clamped.height) >= 4);
552    }
553
554    #[test]
555    fn clamp_floating_contains_when_offscreen_not_allowed() {
556        let bounds = LayoutRect {
557            x: 0,
558            y: 0,
559            width: 10,
560            height: 10,
561        };
562        let rect = LayoutRect {
563            x: -4,
564            y: 8,
565            width: 6,
566            height: 3,
567        };
568        let clamped = clamp_floating_to_bounds(rect, bounds, 4, false);
569        assert_eq!(clamped.x, 0);
570        assert_eq!(clamped.y, 7); // bounds.height - height
571        assert_eq!(clamped.width, 6);
572        assert_eq!(clamped.height, 3);
573    }
574
575    #[test]
576    fn clamp_floating_enforces_minimum_size() {
577        let bounds = LayoutRect {
578            x: 0,
579            y: 0,
580            width: 10,
581            height: 10,
582        };
583        let rect = LayoutRect {
584            x: 3,
585            y: 3,
586            width: 1,
587            height: 1,
588        };
589        let clamped = clamp_floating_to_bounds(rect, bounds, 4, true);
590        assert_eq!(clamped.width, FLOATING_MIN_WIDTH);
591        assert_eq!(clamped.height, FLOATING_MIN_HEIGHT);
592    }
593
594    #[test]
595    fn clamp_floating_zero_size_bounds_does_not_panic() {
596        let bounds = LayoutRect::default();
597        let rect = LayoutRect {
598            x: 2,
599            y: 2,
600            width: 6,
601            height: 4,
602        };
603        let clamped = clamp_floating_to_bounds(rect, bounds, 4, true);
604        // No panic; result stays a valid rect even for degenerate bounds.
605        assert!(clamped.width >= 1);
606    }
607}