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#[cfg(test)]
298mod tests {
299    use super::*;
300
301    fn area() -> LayoutRect {
302        LayoutRect {
303            x: 0,
304            y: 0,
305            width: 80,
306            height: 24,
307        }
308    }
309
310    #[test]
311    fn resize_handles_count() {
312        let rect = LayoutRect {
313            x: 10,
314            y: 10,
315            width: 20,
316            height: 15,
317        };
318        let handles = resize_handles_for_region(1u8, rect, area());
319        assert_eq!(handles.len(), 8);
320    }
321
322    #[test]
323    fn resize_handles_small_rect() {
324        let rect = LayoutRect {
325            x: 10,
326            y: 10,
327            width: 1,
328            height: 1,
329        };
330        let handles = resize_handles_for_region(1u8, rect, area());
331        // Only corners (4), no edge handles since dims <= 2
332        assert_eq!(handles.len(), 4);
333    }
334
335    #[test]
336    fn floating_header_normal() {
337        let rect = LayoutRect {
338            x: 10,
339            y: 10,
340            width: 20,
341            height: 20,
342        };
343        let header = floating_header_for_region(1u8, rect, area());
344        assert!(header.is_some());
345        let h = header.unwrap();
346        assert_eq!(h.rect.width, 18);
347        assert_eq!(h.rect.height, 1);
348        assert_eq!(h.rect.y, 11);
349    }
350
351    #[test]
352    fn floating_header_too_small() {
353        let rect = LayoutRect {
354            x: 10,
355            y: 10,
356            width: 2,
357            height: 2,
358        };
359        assert!(floating_header_for_region(1u8, rect, area()).is_none());
360    }
361
362    #[test]
363    fn apply_resize_drag_right_edge() {
364        let result = apply_resize_drag_signed(
365            10,
366            10,
367            20,
368            15,
369            ResizeEdge::Right,
370            50,
371            10,
372            30,
373            10,
374            area(),
375            false,
376        );
377        assert_eq!(result.width, 40); // 20 + (50-30)
378        assert_eq!(result.x, 10);
379    }
380
381    #[test]
382    fn apply_resize_drag_left_edge() {
383        let result = apply_resize_drag_signed(
384            20,
385            10,
386            20,
387            15,
388            ResizeEdge::Left,
389            10,
390            10,
391            30,
392            10,
393            area(),
394            false,
395        );
396        assert_eq!(result.x, 0); // 20 + (10-30) = 0
397        assert_eq!(result.width, 40); // 20 - (10-30) = 40
398    }
399
400    #[test]
401    fn apply_resize_drag_enforces_min_size() {
402        // Drag left edge rightward to shrink width below minimum
403        let result = apply_resize_drag_signed(
404            10,
405            10,
406            10,
407            10,
408            ResizeEdge::Left,
409            20,
410            10,
411            5,
412            10,
413            area(),
414            false,
415        );
416        assert_eq!(result.width, FLOATING_MIN_WIDTH);
417    }
418
419    #[test]
420    fn apply_resize_drag_offscreen_allowed() {
421        let result =
422            apply_resize_drag_signed(0, 0, 80, 24, ResizeEdge::Left, 40, 0, 0, 0, area(), true);
423        assert_eq!(result.x, 40);
424        assert_eq!(result.width, 40);
425    }
426}