Skip to main content

quadraui/
layout.rs

1//! Shared foundation for layout() and hit_test() primitives.
2//!
3//! This module provides shared infrastructure for primitives to converge on
4//! common method shapes and geometry math: `fn layout(&self, bounds: Rect,
5//! m: &Self::Measure)`, `fn hit_test(&self, p: Point)`, with bounds in
6//! ABSOLUTE (target-surface) coordinates. All returned bounds use the same
7//! coordinate frame, eliminating the LOCAL/ABSOLUTE ambiguity tracked in
8//! `docs/PRIMITIVE_RULES.md`. Converting a primitive to use this module's
9//! [`VisibleItem`] and [`Anchor`] is a rule-8 breaking change (see
10//! `docs/PRIMITIVE_RULES.md`), landing with a `#[deprecated]` shim.
11//!
12//! ## Target convention (for primitives converging onto this module)
13//!
14//! - `fn layout(&self, bounds: Rect, m: &Self::Measure) -> Self::Layout`
15//!   — one argument shape, replacing the four in use today.
16//! - `fn hit_test(&self, p: Point) -> Self::Hit`, with a `Miss` (or
17//!   equivalent empty) variant on every `Hit` enum — no primitive
18//!   returns `Option<T>` or omits `hit_test` entirely once converged.
19//! - Every returned bound is in **ABSOLUTE** (target-surface)
20//!   coordinates. Once every primitive has converged, the LOCAL/ABSOLUTE
21//!   table in `docs/PRIMITIVE_RULES.md` is deleted — a coordinate frame
22//!   that is always the same frame needs no table.
23//!
24//! This module does not force those two method shapes into a Rust
25//! trait: `Measure`/`Layout`/`Hit` differ per primitive by design, and a
26//! trait bound gains nothing that a doc-comment convention plus review
27//! doesn't already give the per-primitive PRs landing this. What *is*
28//! shared and worth centralising is the geometry math underneath both
29//! methods — anchoring an overlay against a viewport, and walking a
30//! scrollable range of variable-size items — which is what the rest of
31//! this module provides.
32
33use crate::event::{Point, Rect};
34use serde::{Deserialize, Serialize};
35
36// ── Anchor: shared overlay positioning ──────────────────────────────────
37
38/// Side of an [`Anchor`]'s `rect` that an overlay prefers to open on.
39///
40/// Unifies four near-identical enums that each primitive invented for
41/// the same "which side, with overflow fallback" concept:
42/// `TooltipPlacement` (`Top`/`Bottom`/`Left`/`Right`), `PopupPlacement`
43/// (`Above`/`Below`), `CompletionsPlacement` (`Below`/`Above`), and
44/// `ContextMenuPlacement` (`AnchorPoint`/`Below`/`Above`).
45///
46/// `Serialize`/`Deserialize`: [`Float`](crate::Float) is
47/// the first primitive to store an [`Anchor`] directly on a
48/// serde-describable descriptor (rather than taking one as a transient
49/// `layout()` argument the way `Tooltip`/`ContextMenu` do), so this and
50/// [`ResolvedSide`]/[`Anchor`] itself need to round-trip through JSON
51/// for a Lua-extension-declared float to deserialise cleanly.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
53pub enum Side {
54    Top,
55    #[default]
56    Bottom,
57    Left,
58    Right,
59    /// No preferred side — the anchor rect already *is* the desired
60    /// point (e.g. a right-click cursor position). [`Anchor::resolve`]
61    /// never flips an `AtPoint` anchor; it only clamps to the viewport.
62    AtPoint,
63}
64
65/// Which [`Side`] an overlay actually resolved to, after
66/// [`Anchor::resolve`] applied the overflow-flip fallback. Same
67/// variants as [`Side`] minus the "preference" framing — this is the
68/// resolved fact, not the request.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70pub enum ResolvedSide {
71    Top,
72    Bottom,
73    Left,
74    Right,
75    AtPoint,
76}
77
78/// Common shape for every overlay primitive that positions a box
79/// relative to an anchor rect (or point) inside a containing viewport:
80/// `Tooltip`, `Completions`, `ContextMenu`, `RichTextPopup`, `Dialog`.
81///
82/// Provides the logic to try the preferred side; if the box would
83/// overflow the viewport, try the opposite side; if both overflow, pin
84/// to the viewport edge on the preferred side. See [`Anchor::resolve`].
85#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
86pub struct Anchor {
87    /// Bounds of the element the overlay is positioned against, in
88    /// ABSOLUTE (target-surface) coordinates. A cursor-position anchor
89    /// (right-click context menu) is a zero-size `Rect` at that point.
90    pub rect: Rect,
91    /// Side of `rect` the overlay prefers to open on.
92    pub preferred: Side,
93    /// Gap between `rect` and the overlay along the placement axis.
94    /// Ignored when `preferred` is [`Side::AtPoint`].
95    pub margin: f32,
96}
97
98impl Anchor {
99    /// An anchor at a rect, preferring `preferred`, with no margin.
100    pub const fn new(rect: Rect, preferred: Side) -> Self {
101        Self {
102            rect,
103            preferred,
104            margin: 0.0,
105        }
106    }
107
108    /// An anchor at a single point (e.g. the mouse cursor on
109    /// right-click) that never flips.
110    pub const fn at_point(p: Point) -> Self {
111        Self {
112            rect: Rect::new(p.x, p.y, 0.0, 0.0),
113            preferred: Side::AtPoint,
114            margin: 0.0,
115        }
116    }
117
118    /// Builder: set the gap between the anchor and the resolved overlay.
119    pub const fn with_margin(mut self, margin: f32) -> Self {
120        self.margin = margin;
121        self
122    }
123
124    /// Resolve the top-left position for an overlay box of `size`
125    /// (width, height), clamped to stay inside `viewport`.
126    ///
127    /// # Placement fallback
128    ///
129    /// The preferred side is tried first. If the box would extend past
130    /// a `viewport` edge, the opposite side is tried. If both overflow
131    /// (an anchor near the middle of a viewport too small for the box),
132    /// the box is pinned to the viewport edge on the preferred axis
133    /// while still centering on the anchor along the cross axis.
134    /// [`Side::AtPoint`] never flips: the box opens at `rect`'s origin,
135    /// clamped to stay inside `viewport`.
136    pub fn resolve(&self, size: (f32, f32), viewport: Rect) -> (Point, ResolvedSide) {
137        let (w, h) = size;
138        let r = self.rect;
139
140        if self.preferred == Side::AtPoint {
141            let x = r.x.clamp(
142                viewport.x,
143                (viewport.x + viewport.width - w).max(viewport.x),
144            );
145            let y = r.y.clamp(
146                viewport.y,
147                (viewport.y + viewport.height - h).max(viewport.y),
148            );
149            return (Point::new(x, y), ResolvedSide::AtPoint);
150        }
151
152        let candidate = |side: Side| -> (f32, f32) {
153            match side {
154                Side::Top => (r.x + (r.width - w) * 0.5, r.y - self.margin - h),
155                Side::Bottom => (r.x + (r.width - w) * 0.5, r.y + r.height + self.margin),
156                Side::Left => (r.x - self.margin - w, r.y + (r.height - h) * 0.5),
157                Side::Right => (r.x + r.width + self.margin, r.y + (r.height - h) * 0.5),
158                Side::AtPoint => (r.x, r.y),
159            }
160        };
161
162        let fits = |x: f32, y: f32| -> bool {
163            x >= viewport.x
164                && y >= viewport.y
165                && x + w <= viewport.x + viewport.width
166                && y + h <= viewport.y + viewport.height
167        };
168
169        let opposite = |side: Side| -> Side {
170            match side {
171                Side::Top => Side::Bottom,
172                Side::Bottom => Side::Top,
173                Side::Left => Side::Right,
174                Side::Right => Side::Left,
175                Side::AtPoint => Side::AtPoint,
176            }
177        };
178        let resolved_of = |side: Side| -> ResolvedSide {
179            match side {
180                Side::Top => ResolvedSide::Top,
181                Side::Bottom => ResolvedSide::Bottom,
182                Side::Left => ResolvedSide::Left,
183                Side::Right => ResolvedSide::Right,
184                Side::AtPoint => ResolvedSide::AtPoint,
185            }
186        };
187
188        let (px, py) = candidate(self.preferred);
189        if fits(px, py) {
190            return (Point::new(px, py), resolved_of(self.preferred));
191        }
192
193        let flipped = opposite(self.preferred);
194        let (fx, fy) = candidate(flipped);
195        if fits(fx, fy) {
196            return (Point::new(fx, fy), resolved_of(flipped));
197        }
198
199        // Neither side fits: pin to the viewport edge on the preferred
200        // side, clamping the cross axis so the box stays fully visible.
201        let (mut x, mut y) = (px, py);
202        x = x.clamp(
203            viewport.x,
204            (viewport.x + viewport.width - w).max(viewport.x),
205        );
206        y = y.clamp(
207            viewport.y,
208            (viewport.y + viewport.height - h).max(viewport.y),
209        );
210        (Point::new(x, y), resolved_of(self.preferred))
211    }
212}
213
214// ── Shared visible-range walk ────────────────────────────────────────────
215
216/// Axis a [`visible_range_walk`] lays items out along.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum Axis {
219    /// Items stack top-to-bottom; `measure` returns each item's height.
220    Vertical,
221    /// Items stack left-to-right; `measure` returns each item's width.
222    Horizontal,
223}
224
225/// One entry produced by [`visible_range_walk`]: the absolute index of a
226/// visible item (into the caller's backing `Vec`, *not* a position
227/// within the visible slice) and its painted bounds in ABSOLUTE
228/// coordinates.
229///
230/// Replaces the per-primitive `Visible*{idx, bounds}` structs
231/// (`VisibleListItem`, `VisibleTreeRow`, `VisibleTextDisplayLine`,
232/// `VisibleTab`, `VisibleMenuBarItem`, ...). A primitive whose
233/// visible-item type carries extra per-item data (`VisibleTab::close_bounds`,
234/// `VisibleMenuBarItem::clickable`) still benefits from [`visible_range_walk`]
235/// for the index/bounds walk itself, zipping its extra fields onto the result.
236#[derive(Debug, Clone, Copy, PartialEq)]
237pub struct VisibleItem {
238    pub idx: usize,
239    pub bounds: Rect,
240}
241
242/// Walk `first_idx..count`, laying each item out along `axis` starting
243/// at `origin`, until `available` (the viewport's remaining extent
244/// along the walk axis) is exhausted. `cross` is the fixed extent
245/// perpendicular to the walk axis (item width for a vertical list, item
246/// height for a horizontal one). `measure(idx)` returns that item's
247/// extent along the walk axis.
248///
249/// The last visible item is clipped to the remaining space, matching
250/// every existing hand-written walk (`ListView::layout`,
251/// `TreeView::layout`, ...): its `bounds` extent is `measure(idx).min(remaining)`,
252/// but the walk still advances by the item's *full* (unclipped) extent,
253/// so a zero-or-negative remainder on the next item naturally ends the
254/// walk. An item whose clipped extent is `<= 0.0` is not emitted and
255/// ends the walk (there is no space left to paint it).
256///
257/// `scroll_offset`/clamping is the caller's responsibility — pass the
258/// already-clamped `first_idx` (see
259/// `primitives::scrollbar::clamp_scroll_offset`).
260pub fn visible_range_walk(
261    first_idx: usize,
262    count: usize,
263    origin: Point,
264    axis: Axis,
265    cross: f32,
266    available: f32,
267    mut measure: impl FnMut(usize) -> f32,
268) -> Vec<VisibleItem> {
269    let mut out = Vec::new();
270    let mut pos = 0.0f32;
271
272    for idx in first_idx..count {
273        if pos >= available {
274            break;
275        }
276        let extent = measure(idx);
277        let remaining = available - pos;
278        let clipped = extent.min(remaining).max(0.0);
279        if clipped <= 0.0 {
280            break;
281        }
282        let bounds = match axis {
283            Axis::Vertical => Rect::new(origin.x, origin.y + pos, cross, clipped),
284            Axis::Horizontal => Rect::new(origin.x + pos, origin.y, clipped, cross),
285        };
286        out.push(VisibleItem { idx, bounds });
287        pos += extent;
288    }
289
290    out
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    // ── Anchor ───────────────────────────────────────────────────────
298
299    const VIEWPORT: Rect = Rect::new(0.0, 0.0, 100.0, 100.0);
300
301    #[test]
302    fn resolve_preferred_side_when_it_fits() {
303        let anchor = Anchor::new(Rect::new(40.0, 40.0, 10.0, 10.0), Side::Bottom).with_margin(2.0);
304        let (p, side) = anchor.resolve((20.0, 10.0), VIEWPORT);
305        assert_eq!(side, ResolvedSide::Bottom);
306        assert_eq!(p, Point::new(35.0, 52.0));
307    }
308
309    #[test]
310    fn resolve_flips_to_opposite_side_on_overflow() {
311        // Anchor near the bottom edge: preferred Bottom would overflow,
312        // so it should flip to Top.
313        let anchor = Anchor::new(Rect::new(40.0, 95.0, 10.0, 5.0), Side::Bottom).with_margin(1.0);
314        let (p, side) = anchor.resolve((20.0, 10.0), VIEWPORT);
315        assert_eq!(side, ResolvedSide::Top);
316        assert_eq!(p, Point::new(35.0, 84.0));
317    }
318
319    #[test]
320    fn resolve_pins_to_edge_when_both_sides_overflow() {
321        // Viewport too short for the box on either Top or Bottom.
322        let short_viewport = Rect::new(0.0, 0.0, 100.0, 12.0);
323        let anchor = Anchor::new(Rect::new(40.0, 4.0, 10.0, 4.0), Side::Bottom).with_margin(1.0);
324        let (p, side) = anchor.resolve((20.0, 10.0), short_viewport);
325        // Preferred side reported even though it had to be clamped.
326        assert_eq!(side, ResolvedSide::Bottom);
327        assert!(p.y >= short_viewport.y);
328        assert!(p.y + 10.0 <= short_viewport.y + short_viewport.height);
329    }
330
331    #[test]
332    fn anchor_serde_round_trips() {
333        // `Float` stores an `Anchor` directly on a serde-describable
334        // descriptor, so a Lua-extension-declared float must be able to
335        // round-trip one through JSON.
336        let anchor = Anchor::new(Rect::new(1.0, 2.0, 3.0, 4.0), Side::Right).with_margin(5.0);
337        let json = serde_json::to_string(&anchor).unwrap();
338        let back: Anchor = serde_json::from_str(&json).unwrap();
339        assert_eq!(anchor, back);
340    }
341
342    #[test]
343    fn resolve_at_point_never_flips_and_clamps() {
344        let anchor = Anchor::at_point(Point::new(98.0, 98.0));
345        let (p, side) = anchor.resolve((20.0, 10.0), VIEWPORT);
346        assert_eq!(side, ResolvedSide::AtPoint);
347        // Clamped so the whole box stays inside the viewport.
348        assert_eq!(p, Point::new(80.0, 90.0));
349    }
350
351    // ── visible_range_walk ──────────────────────────────────────────
352
353    #[test]
354    fn vertical_walk_stops_when_viewport_is_full() {
355        // Ten items of height 10 in a viewport that only fits 5.
356        let items = visible_range_walk(
357            0,
358            10,
359            Point::new(0.0, 0.0),
360            Axis::Vertical,
361            30.0,
362            50.0,
363            |_| 10.0,
364        );
365        assert_eq!(items.len(), 5);
366        assert_eq!(items[0].idx, 0);
367        assert_eq!(items[0].bounds, Rect::new(0.0, 0.0, 30.0, 10.0));
368        assert_eq!(items[4].idx, 4);
369        assert_eq!(items[4].bounds, Rect::new(0.0, 40.0, 30.0, 10.0));
370    }
371
372    #[test]
373    fn vertical_walk_starts_at_first_idx() {
374        // Scrolled down by 3: only items 3.. are visible.
375        let items = visible_range_walk(
376            3,
377            10,
378            Point::new(0.0, 0.0),
379            Axis::Vertical,
380            30.0,
381            25.0,
382            |_| 10.0,
383        );
384        let idxs: Vec<usize> = items.iter().map(|v| v.idx).collect();
385        // idx 5 starts at pos 20 with remaining 5 < its own height (10),
386        // so it's clipped to height 5 rather than dropped — same
387        // "clip, don't drop, the last partially-visible item" behaviour
388        // as `ListView::layout`.
389        assert_eq!(idxs, vec![3, 4, 5]);
390        assert_eq!(items.last().unwrap().bounds.height, 5.0);
391    }
392
393    #[test]
394    fn vertical_walk_stops_at_count_not_just_available_space() {
395        // Only 3 items exist even though the viewport could fit more.
396        let items = visible_range_walk(
397            0,
398            3,
399            Point::new(0.0, 0.0),
400            Axis::Vertical,
401            30.0,
402            100.0,
403            |_| 10.0,
404        );
405        assert_eq!(items.len(), 3);
406    }
407
408    #[test]
409    fn horizontal_walk_lays_out_along_x() {
410        let items = visible_range_walk(
411            0,
412            5,
413            Point::new(10.0, 0.0),
414            Axis::Horizontal,
415            8.0,
416            25.0,
417            |_| 10.0,
418        );
419        assert_eq!(items.len(), 3);
420        assert_eq!(items[0].bounds, Rect::new(10.0, 0.0, 10.0, 8.0));
421        assert_eq!(items[1].bounds, Rect::new(20.0, 0.0, 10.0, 8.0));
422        // Third item clipped to the remaining 5.0.
423        assert_eq!(items[2].bounds, Rect::new(30.0, 0.0, 5.0, 8.0));
424    }
425
426    #[test]
427    fn walk_at_nonzero_origin_is_absolute_not_local() {
428        let items = visible_range_walk(
429            0,
430            1,
431            Point::new(7.0, 9.0),
432            Axis::Vertical,
433            30.0,
434            10.0,
435            |_| 10.0,
436        );
437        assert_eq!(items[0].bounds, Rect::new(7.0, 9.0, 30.0, 10.0));
438    }
439
440    #[test]
441    fn walk_skips_zero_extent_items_and_stops() {
442        let items = visible_range_walk(
443            0,
444            3,
445            Point::new(0.0, 0.0),
446            Axis::Vertical,
447            10.0,
448            50.0,
449            |_| 0.0,
450        );
451        assert!(items.is_empty());
452    }
453
454    #[test]
455    fn walk_variable_height_items_via_closure() {
456        let heights = [5.0, 15.0, 8.0];
457        let items = visible_range_walk(
458            0,
459            heights.len(),
460            Point::new(0.0, 0.0),
461            Axis::Vertical,
462            30.0,
463            100.0,
464            |idx| heights[idx],
465        );
466        assert_eq!(items.len(), 3);
467        assert_eq!(items[0].bounds.y, 0.0);
468        assert_eq!(items[1].bounds.y, 5.0);
469        assert_eq!(items[2].bounds.y, 20.0);
470        assert_eq!(items[2].bounds.height, 8.0);
471    }
472}