Skip to main content

term_wm_layout_engine/
floating.rs

1use crate::rect::{LayoutRect, rect_contains};
2
3/// Minimum width for a floating window (in cells).
4pub const FLOATING_MIN_WIDTH: u16 = 6;
5
6/// Minimum height for a floating window (in cells).
7pub const FLOATING_MIN_HEIGHT: u16 = 3;
8
9/// Identifies which edge(s) of a floating window are being dragged.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ResizeEdge {
12    Left,
13    Right,
14    Top,
15    Bottom,
16    TopLeft,
17    TopRight,
18    BottomLeft,
19    BottomRight,
20}
21
22/// A single-cell hit-test handle at a corner or along an edge of a floating window.
23#[derive(Debug, Clone, Copy)]
24pub struct ResizeHandle<R: Copy + Eq + Ord> {
25    pub id: R,
26    pub rect: LayoutRect,
27    pub edge: ResizeEdge,
28}
29
30/// State of an in-progress resize drag.
31#[derive(Debug, Clone, Copy)]
32pub struct ResizeDrag<R: Copy + Eq + Ord> {
33    pub id: R,
34    pub edge: ResizeEdge,
35    pub start_col: u16,
36    pub start_row: u16,
37    pub start_x: i32,
38    pub start_y: i32,
39    pub start_width: u16,
40    pub start_height: u16,
41}
42
43/// State of an in-progress header drag (move).
44#[derive(Debug, Clone, Copy)]
45pub struct HeaderDrag<R: Copy + Eq + Ord> {
46    pub id: R,
47    pub initial_x: i32,
48    pub initial_y: i32,
49    pub start_x: u16,
50    pub start_y: u16,
51}
52
53/// A hit-test region for the title bar of a floating window.
54#[derive(Debug, Clone, Copy)]
55pub struct DragHandle<R: Copy + Eq + Ord> {
56    pub id: R,
57    pub rect: LayoutRect,
58}
59
60/// Generate all 8 resize handles (4 corners + 4 edges) for a floating region.
61pub fn resize_handles_for_region<R: Copy + Eq + Ord>(
62    id: R,
63    rect: LayoutRect,
64    _bounds: LayoutRect,
65) -> Vec<ResizeHandle<R>> {
66    if rect.width == 0 || rect.height == 0 {
67        return Vec::new();
68    }
69
70    let x1 = rect.x;
71    let y1 = rect.y;
72    let x2 = rect
73        .x
74        .saturating_add(i32::from(rect.width.saturating_sub(1)));
75    let y2 = rect
76        .y
77        .saturating_add(i32::from(rect.height.saturating_sub(1)));
78
79    let mut handles = Vec::with_capacity(8);
80
81    // Corners (1×1)
82    handles.push(ResizeHandle {
83        id,
84        rect: LayoutRect {
85            x: x1,
86            y: y1,
87            width: 1,
88            height: 1,
89        },
90        edge: ResizeEdge::TopLeft,
91    });
92    handles.push(ResizeHandle {
93        id,
94        rect: LayoutRect {
95            x: x2,
96            y: y1,
97            width: 1,
98            height: 1,
99        },
100        edge: ResizeEdge::TopRight,
101    });
102    handles.push(ResizeHandle {
103        id,
104        rect: LayoutRect {
105            x: x1,
106            y: y2,
107            width: 1,
108            height: 1,
109        },
110        edge: ResizeEdge::BottomLeft,
111    });
112    handles.push(ResizeHandle {
113        id,
114        rect: LayoutRect {
115            x: x2,
116            y: y2,
117            width: 1,
118            height: 1,
119        },
120        edge: ResizeEdge::BottomRight,
121    });
122
123    // Edge handles (span the full dimension minus corners)
124    if rect.height > 2 {
125        let inner_h = rect.height.saturating_sub(2);
126        handles.push(ResizeHandle {
127            id,
128            rect: LayoutRect {
129                x: x1,
130                y: y1.saturating_add(1),
131                width: 1,
132                height: inner_h,
133            },
134            edge: ResizeEdge::Left,
135        });
136        handles.push(ResizeHandle {
137            id,
138            rect: LayoutRect {
139                x: x2,
140                y: y1.saturating_add(1),
141                width: 1,
142                height: inner_h,
143            },
144            edge: ResizeEdge::Right,
145        });
146    }
147    if rect.width > 2 {
148        let inner_w = rect.width.saturating_sub(2);
149        handles.push(ResizeHandle {
150            id,
151            rect: LayoutRect {
152                x: x1.saturating_add(1),
153                y: y1,
154                width: inner_w,
155                height: 1,
156            },
157            edge: ResizeEdge::Top,
158        });
159        handles.push(ResizeHandle {
160            id,
161            rect: LayoutRect {
162                x: x1.saturating_add(1),
163                y: y2,
164                width: inner_w,
165                height: 1,
166            },
167            edge: ResizeEdge::Bottom,
168        });
169    }
170
171    handles
172}
173
174/// Generate a drag handle for the title bar of a floating window.
175pub fn floating_header_for_region<R: Copy + Eq + Ord>(
176    id: R,
177    rect: LayoutRect,
178    bounds: LayoutRect,
179) -> Option<DragHandle<R>> {
180    if rect.width < 3 || rect.height < 3 {
181        return None;
182    }
183    let header_rect = LayoutRect {
184        x: rect.x.saturating_add(1),
185        y: rect.y.saturating_add(1),
186        width: rect.width.saturating_sub(2),
187        height: 1,
188    };
189    if !rect_contains(&bounds, header_rect.x as u16, header_rect.y as u16) {
190        return None;
191    }
192    Some(DragHandle {
193        id,
194        rect: header_rect,
195    })
196}
197
198/// Apply a resize drag delta to a floating window's geometry.
199///
200/// Returns the new [`LayoutRect`] after applying the delta, enforcing
201/// minimum size constraints and bounds clamping.
202#[allow(clippy::too_many_arguments)]
203pub fn apply_resize_drag_signed(
204    start_x: i32,
205    start_y: i32,
206    start_width: u16,
207    start_height: u16,
208    edge: ResizeEdge,
209    column: u16,
210    row: u16,
211    start_col: u16,
212    start_row: u16,
213    bounds: LayoutRect,
214    allow_offscreen: bool,
215) -> LayoutRect {
216    let dx = i32::from(column).saturating_sub(i32::from(start_col));
217    let dy = i32::from(row).saturating_sub(i32::from(start_row));
218
219    let mut x = start_x;
220    let mut y = start_y;
221    let mut w = i32::from(start_width);
222    let mut h = i32::from(start_height);
223
224    // Apply delta to edges
225    match edge {
226        ResizeEdge::Left | ResizeEdge::TopLeft | ResizeEdge::BottomLeft => {
227            x = x.saturating_add(dx);
228            w = w.saturating_sub(dx);
229        }
230        ResizeEdge::Right | ResizeEdge::TopRight | ResizeEdge::BottomRight => {
231            w = w.saturating_add(dx);
232        }
233        _ => {}
234    }
235    match edge {
236        ResizeEdge::Top | ResizeEdge::TopLeft | ResizeEdge::TopRight => {
237            y = y.saturating_add(dy);
238            h = h.saturating_sub(dy);
239        }
240        ResizeEdge::Bottom | ResizeEdge::BottomLeft | ResizeEdge::BottomRight => {
241            h = h.saturating_add(dy);
242        }
243        _ => {}
244    }
245
246    // Enforce minimum size
247    let min_w = i32::from(FLOATING_MIN_WIDTH);
248    let min_h = i32::from(FLOATING_MIN_HEIGHT);
249
250    if w < min_w {
251        match edge {
252            ResizeEdge::Left | ResizeEdge::TopLeft | ResizeEdge::BottomLeft => {
253                x = x.saturating_sub(min_w.saturating_sub(w));
254            }
255            _ => {}
256        }
257        w = min_w;
258    }
259    if h < min_h {
260        match edge {
261            ResizeEdge::Top | ResizeEdge::TopLeft | ResizeEdge::TopRight => {
262                y = y.saturating_sub(min_h.saturating_sub(h));
263            }
264            _ => {}
265        }
266        h = min_h;
267    }
268
269    // Convert to u16 with safety clamp
270    let mut width = w.max(1).min(i32::from(u16::MAX)) as u16;
271    let mut height = h.max(1).min(i32::from(u16::MAX)) as u16;
272
273    // Bounds clamping
274    if !allow_offscreen {
275        width = width.min(bounds.width);
276        height = height.min(bounds.height);
277
278        let bounds_x1 = bounds.x.saturating_add(i32::from(bounds.width));
279        let bounds_y1 = bounds.y.saturating_add(i32::from(bounds.height));
280        let max_x = bounds_x1.saturating_sub(i32::from(width));
281        let max_y = bounds_y1.saturating_sub(i32::from(height));
282
283        x = x.max(bounds.x).min(max_x);
284        y = y.max(bounds.y).min(max_y);
285    }
286
287    LayoutRect {
288        x,
289        y,
290        width,
291        height,
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn area() -> LayoutRect {
300        LayoutRect {
301            x: 0,
302            y: 0,
303            width: 80,
304            height: 24,
305        }
306    }
307
308    #[test]
309    fn resize_handles_count() {
310        let rect = LayoutRect {
311            x: 10,
312            y: 10,
313            width: 20,
314            height: 15,
315        };
316        let handles = resize_handles_for_region(1u8, rect, area());
317        assert_eq!(handles.len(), 8);
318    }
319
320    #[test]
321    fn resize_handles_small_rect() {
322        let rect = LayoutRect {
323            x: 10,
324            y: 10,
325            width: 1,
326            height: 1,
327        };
328        let handles = resize_handles_for_region(1u8, rect, area());
329        // Only corners (4), no edge handles since dims <= 2
330        assert_eq!(handles.len(), 4);
331    }
332
333    #[test]
334    fn floating_header_normal() {
335        let rect = LayoutRect {
336            x: 10,
337            y: 10,
338            width: 20,
339            height: 20,
340        };
341        let header = floating_header_for_region(1u8, rect, area());
342        assert!(header.is_some());
343        let h = header.unwrap();
344        assert_eq!(h.rect.width, 18);
345        assert_eq!(h.rect.height, 1);
346        assert_eq!(h.rect.y, 11);
347    }
348
349    #[test]
350    fn floating_header_too_small() {
351        let rect = LayoutRect {
352            x: 10,
353            y: 10,
354            width: 2,
355            height: 2,
356        };
357        assert!(floating_header_for_region(1u8, rect, area()).is_none());
358    }
359
360    #[test]
361    fn apply_resize_drag_right_edge() {
362        let result = apply_resize_drag_signed(
363            10,
364            10,
365            20,
366            15,
367            ResizeEdge::Right,
368            50,
369            10,
370            30,
371            10,
372            area(),
373            false,
374        );
375        assert_eq!(result.width, 40); // 20 + (50-30)
376        assert_eq!(result.x, 10);
377    }
378
379    #[test]
380    fn apply_resize_drag_left_edge() {
381        let result = apply_resize_drag_signed(
382            20,
383            10,
384            20,
385            15,
386            ResizeEdge::Left,
387            10,
388            10,
389            30,
390            10,
391            area(),
392            false,
393        );
394        assert_eq!(result.x, 0); // 20 + (10-30) = 0
395        assert_eq!(result.width, 40); // 20 - (10-30) = 40
396    }
397
398    #[test]
399    fn apply_resize_drag_enforces_min_size() {
400        // Drag left edge rightward to shrink width below minimum
401        let result = apply_resize_drag_signed(
402            10,
403            10,
404            10,
405            10,
406            ResizeEdge::Left,
407            20,
408            10,
409            5,
410            10,
411            area(),
412            false,
413        );
414        assert_eq!(result.width, FLOATING_MIN_WIDTH);
415    }
416
417    #[test]
418    fn apply_resize_drag_offscreen_allowed() {
419        let result =
420            apply_resize_drag_signed(0, 0, 80, 24, ResizeEdge::Left, 40, 0, 0, 0, area(), true);
421        assert_eq!(result.x, 40);
422        assert_eq!(result.width, 40);
423    }
424}