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}