Skip to main content

omp_tui/
overlay.rs

1//! Viewport-anchored sizing and placement for z-ordered layers.
2//!
3//! Geometry is resolved for each presentation, so layer cells never enter
4//! native terminal scrollback. Retained stacks use [`crate::Ui::show_overlay`],
5//! while raw-frame hosts pass [`Layer`]s to
6//! [`crate::Renderer::present_overlaid`].
7
8use crate::{
9	frame::{Frame, Size},
10	markup::Dim,
11};
12
13/// A viewport edge or corner used to position an overlay.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15pub enum OverlayAnchor {
16	/// Centers the overlay horizontally and vertically.
17	#[default]
18	Center,
19	/// Places the overlay at the top-left corner.
20	TopLeft,
21	/// Centers the overlay along the top edge.
22	Top,
23	/// Places the overlay at the top-right corner.
24	TopRight,
25	/// Centers the overlay along the right edge.
26	Right,
27	/// Places the overlay at the bottom-right corner.
28	BottomRight,
29	/// Centers the overlay along the bottom edge.
30	Bottom,
31	/// Places the overlay at the bottom-left corner.
32	BottomLeft,
33	/// Centers the overlay along the left edge.
34	Left,
35}
36
37/// Insets that keep an overlay away from viewport edges.
38#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39pub struct OverlayMargin {
40	/// Inset from the top edge.
41	pub top:    u16,
42	/// Inset from the right edge.
43	pub right:  u16,
44	/// Inset from the bottom edge.
45	pub bottom: u16,
46	/// Inset from the left edge.
47	pub left:   u16,
48}
49
50impl OverlayMargin {
51	/// Creates equal insets on all four sides.
52	pub const fn uniform(n: u16) -> Self {
53		Self { top: n, right: n, bottom: n, left: n }
54	}
55}
56
57/// Declarative sizing, placement, and visibility options for an overlay.
58#[non_exhaustive]
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub struct OverlayOptions {
61	/// Requested width, resolved against the full viewport width.
62	pub width:        Option<Dim>,
63	/// Minimum width applied before fitting the overlay to its margins.
64	pub min_width:    Option<u16>,
65	/// Maximum height, resolved against the full viewport height.
66	pub max_height:   Option<Dim>,
67	/// Viewport position used when no explicit row or column is supplied.
68	pub anchor:       OverlayAnchor,
69	/// Horizontal displacement from the resolved position.
70	pub offset_x:     i16,
71	/// Vertical displacement from the resolved position.
72	pub offset_y:     i16,
73	/// Explicit absolute or percentage row position.
74	pub row:          Option<Dim>,
75	/// Explicit absolute or percentage column position.
76	pub col:          Option<Dim>,
77	/// Insets from viewport edges.
78	pub margin:       OverlayMargin,
79	/// Stacking height: higher layers composite above lower ones; ties stack by
80	/// creation order.
81	pub z:            i16,
82	/// Smallest viewport in which the overlay is visible.
83	pub min_viewport: Size,
84	/// Whether the layer captures the keyboard while visible (the default).
85	///
86	/// A non-modal layer — a sidebar, a status rail — leaves keys and paste
87	/// with the base tree unless explicitly focused through
88	/// [`crate::Ui::focus_overlay`], and never triggers the [`crate::App`]
89	/// alternate-screen hold: the document keeps committing to native
90	/// scrollback beneath it while the layer rides the live viewport.
91	pub modal:        bool,
92	/// Stretches a retained overlay tree to the full available viewport
93	/// height (after `margin` and `max_height`) instead of its content
94	/// height, so `grow`/`valign` fill the band like a full-height rail.
95	///
96	/// Raw-frame [`Layer`] hosts control their frame height directly; the
97	/// band there always follows the frame.
98	pub fill_height:  bool,
99}
100
101impl Default for OverlayOptions {
102	fn default() -> Self {
103		Self {
104			width:        None,
105			min_width:    None,
106			max_height:   None,
107			anchor:       OverlayAnchor::Center,
108			offset_x:     0,
109			offset_y:     0,
110			row:          None,
111			col:          None,
112			margin:       OverlayMargin::default(),
113			z:            0,
114			min_viewport: Size::new(0, 0),
115			modal:        true,
116			fill_height:  false,
117		}
118	}
119}
120
121impl OverlayOptions {
122	/// Sets the requested width.
123	#[must_use]
124	pub const fn width(mut self, width: Dim) -> Self {
125		self.width = Some(width);
126		self
127	}
128
129	/// Sets the minimum width in cells.
130	#[must_use]
131	pub const fn min_width(mut self, min_width: u16) -> Self {
132		self.min_width = Some(min_width);
133		self
134	}
135
136	/// Sets the maximum height.
137	#[must_use]
138	pub const fn max_height(mut self, max_height: Dim) -> Self {
139		self.max_height = Some(max_height);
140		self
141	}
142
143	/// Sets the stacking height.
144	#[must_use]
145	pub const fn z(mut self, z: i16) -> Self {
146		self.z = z;
147		self
148	}
149
150	/// Sets the fallback anchor position.
151	#[must_use]
152	pub const fn anchor(mut self, anchor: OverlayAnchor) -> Self {
153		self.anchor = anchor;
154		self
155	}
156
157	/// Sets the horizontal offset in cells.
158	#[must_use]
159	pub const fn offset_x(mut self, offset_x: i16) -> Self {
160		self.offset_x = offset_x;
161		self
162	}
163
164	/// Sets the vertical offset in cells.
165	#[must_use]
166	pub const fn offset_y(mut self, offset_y: i16) -> Self {
167		self.offset_y = offset_y;
168		self
169	}
170
171	/// Sets an explicit absolute or percentage row.
172	#[must_use]
173	pub const fn row(mut self, row: Dim) -> Self {
174		self.row = Some(row);
175		self
176	}
177
178	/// Sets an explicit absolute or percentage column.
179	#[must_use]
180	pub const fn col(mut self, col: Dim) -> Self {
181		self.col = Some(col);
182		self
183	}
184
185	/// Sets the viewport-edge insets.
186	#[must_use]
187	pub const fn margin(mut self, margin: OverlayMargin) -> Self {
188		self.margin = margin;
189		self
190	}
191
192	/// Sets the minimum viewport required for visibility.
193	#[must_use]
194	pub const fn min_viewport(mut self, min_viewport: Size) -> Self {
195		self.min_viewport = min_viewport;
196		self
197	}
198
199	/// Leaves keys and paste with the base tree while the layer is visible.
200	///
201	/// [`crate::Ui::focus_overlay`] hands the keyboard to the layer on
202	/// demand; a click inside its band does the same, and a click outside
203	/// (or an unconsumed `Esc`) returns it.
204	#[must_use]
205	pub const fn non_modal(mut self) -> Self {
206		self.modal = false;
207		self
208	}
209
210	/// Stretches a retained overlay tree to the full available viewport
211	/// height.
212	#[must_use]
213	pub const fn fill_height(mut self) -> Self {
214		self.fill_height = true;
215		self
216	}
217}
218
219/// Identity handle returned by [`crate::Ui::show_overlay`].
220#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
221pub struct OverlayId(pub(crate) u32);
222
223/// Resolved overlay dimensions before content-height clipping.
224#[derive(Clone, Copy, Debug, PartialEq, Eq)]
225pub struct OverlayExtent {
226	pub width:      u16,
227	pub max_height: u16,
228}
229
230/// Resolved viewport band of a layer.
231#[derive(Clone, Copy, Debug, PartialEq, Eq)]
232pub struct OverlayBand {
233	/// Leftmost viewport column of the band.
234	pub x:       u16,
235	/// Topmost viewport row of the band.
236	pub y:       u16,
237	/// First source-frame row composited into the band.
238	pub src_top: u16,
239	/// Number of source rows in the band; `0` means not composited.
240	pub rows:    u16,
241}
242
243/// One z-ordered viewport layer: a rendered frame placed declaratively at
244/// present time.
245///
246/// Stacking follows [`OverlayOptions::z`], then slice order.
247pub struct Layer<'a> {
248	/// Source frame containing the layer cells.
249	pub frame:   &'a Frame,
250	/// Placement, sizing, z, and visibility, resolved per present.
251	pub options: &'a OverlayOptions,
252	/// Whether this layer owns the keyboard — and with it the hardware
253	/// cursor: an active layer's frame cursor places the caret (no frame
254	/// cursor suppresses it), while passive layers let the base document's
255	/// caret show through. At most one layer should be active per present;
256	/// among several, the topmost wins.
257	pub active:  bool,
258}
259
260impl Layer<'_> {
261	/// Resolved viewport band for `viewport`; `rows == 0` means gated or empty.
262	pub fn band(&self, viewport: Size) -> OverlayBand {
263		if !visible_at(self.options, viewport) {
264			return OverlayBand { x: 0, y: 0, src_top: 0, rows: 0 };
265		}
266		resolve_band(self.options, viewport, self.frame.size().width, self.frame.size().height)
267	}
268}
269
270fn resolve_dim(dim: Dim, reference: u16) -> u16 {
271	match dim {
272		Dim::Cells(cells) => cells,
273		Dim::Pct(percent) => {
274			(u32::from(reference) * u32::from(percent) / 100).min(u32::from(u16::MAX)) as u16
275		},
276	}
277}
278
279fn offset(value: u16, amount: i16) -> u16 {
280	(i32::from(value) + i32::from(amount)).clamp(0, i32::from(u16::MAX)) as u16
281}
282
283pub fn resolve_extent(options: &OverlayOptions, viewport: Size) -> OverlayExtent {
284	let avail_width = viewport
285		.width
286		.saturating_sub(options.margin.left)
287		.saturating_sub(options.margin.right)
288		.max(1);
289	let avail_height = viewport
290		.height
291		.saturating_sub(options.margin.top)
292		.saturating_sub(options.margin.bottom)
293		.max(1);
294
295	let mut width = options
296		.width
297		.map_or_else(|| avail_width.min(80), |dim| resolve_dim(dim, viewport.width));
298	if let Some(min_width) = options.min_width {
299		width = width.max(min_width);
300	}
301	width = width.clamp(1, avail_width);
302
303	let max_height = options
304		.max_height
305		.map_or(avail_height, |dim| resolve_dim(dim, viewport.height))
306		.clamp(1, avail_height);
307
308	OverlayExtent { width, max_height }
309}
310
311pub fn resolve_band(
312	options: &OverlayOptions,
313	viewport: Size,
314	width: u16,
315	content_height: u16,
316) -> OverlayBand {
317	let extent = resolve_extent(options, viewport);
318	let effective_rows = content_height.min(extent.max_height);
319	let avail_width = viewport
320		.width
321		.saturating_sub(options.margin.left)
322		.saturating_sub(options.margin.right)
323		.max(1);
324	let avail_height = viewport
325		.height
326		.saturating_sub(options.margin.top)
327		.saturating_sub(options.margin.bottom)
328		.max(1);
329	let row_span = avail_height.saturating_sub(effective_rows);
330	let col_span = avail_width.saturating_sub(width);
331
332	let row = match options.row {
333		Some(Dim::Cells(row)) => row,
334		Some(Dim::Pct(percent)) => options.margin.top.saturating_add(
335			(u32::from(row_span) * u32::from(percent) / 100).min(u32::from(u16::MAX)) as u16,
336		),
337		None => match options.anchor {
338			OverlayAnchor::TopLeft | OverlayAnchor::Top | OverlayAnchor::TopRight => {
339				options.margin.top
340			},
341			OverlayAnchor::BottomLeft | OverlayAnchor::Bottom | OverlayAnchor::BottomRight => {
342				options.margin.top.saturating_add(row_span)
343			},
344			OverlayAnchor::Center | OverlayAnchor::Left | OverlayAnchor::Right => {
345				options.margin.top.saturating_add(row_span / 2)
346			},
347		},
348	};
349	let col = match options.col {
350		Some(Dim::Cells(col)) => col,
351		Some(Dim::Pct(percent)) => options.margin.left.saturating_add(
352			(u32::from(col_span) * u32::from(percent) / 100).min(u32::from(u16::MAX)) as u16,
353		),
354		None => match options.anchor {
355			OverlayAnchor::TopLeft | OverlayAnchor::Left | OverlayAnchor::BottomLeft => {
356				options.margin.left
357			},
358			OverlayAnchor::TopRight | OverlayAnchor::Right | OverlayAnchor::BottomRight => {
359				options.margin.left.saturating_add(col_span)
360			},
361			OverlayAnchor::Center | OverlayAnchor::Top | OverlayAnchor::Bottom => {
362				options.margin.left.saturating_add(col_span / 2)
363			},
364		},
365	};
366
367	let max_row = viewport
368		.height
369		.saturating_sub(options.margin.bottom)
370		.saturating_sub(effective_rows);
371	let max_col = viewport
372		.width
373		.saturating_sub(options.margin.right)
374		.saturating_sub(width);
375	let y = offset(row, options.offset_y)
376		.min(max_row)
377		.max(options.margin.top);
378	let x = offset(col, options.offset_x)
379		.min(max_col)
380		.max(options.margin.left);
381	let rows = effective_rows.min(viewport.height.saturating_sub(y));
382	let src_top = if content_height > effective_rows
383		&& matches!(
384			options.anchor,
385			OverlayAnchor::BottomLeft | OverlayAnchor::Bottom | OverlayAnchor::BottomRight
386		) {
387		content_height - rows
388	} else {
389		0
390	};
391
392	OverlayBand { x, y, src_top, rows }
393}
394
395pub const fn visible_at(options: &OverlayOptions, viewport: Size) -> bool {
396	viewport.width >= options.min_viewport.width && viewport.height >= options.min_viewport.height
397}
398
399#[cfg(test)]
400mod tests {
401	use super::*;
402
403	#[test]
404	fn defaults_to_centered_eighty_column_overlay() {
405		let options = OverlayOptions::default();
406		let viewport = Size::new(120, 40);
407
408		assert_eq!(resolve_extent(&options, viewport), OverlayExtent {
409			width:      80,
410			max_height: 40,
411		});
412		assert_eq!(resolve_band(&options, viewport, 80, 10), OverlayBand {
413			x:       20,
414			y:       15,
415			src_top: 0,
416			rows:    10,
417		});
418	}
419
420	#[test]
421	fn percentages_resolve_against_full_viewport() {
422		let options = OverlayOptions::default()
423			.width(Dim::Pct(50))
424			.max_height(Dim::Pct(25))
425			.margin(OverlayMargin::uniform(3));
426
427		assert_eq!(resolve_extent(&options, Size::new(120, 40)), OverlayExtent {
428			width:      60,
429			max_height: 10,
430		});
431	}
432
433	#[test]
434	fn margins_position_all_corner_anchors() {
435		let viewport = Size::new(100, 40);
436		let margin = OverlayMargin { top: 2, right: 3, bottom: 4, left: 5 };
437		let cases = [
438			(OverlayAnchor::TopLeft, (5, 2)),
439			(OverlayAnchor::TopRight, (77, 2)),
440			(OverlayAnchor::BottomLeft, (5, 26)),
441			(OverlayAnchor::BottomRight, (77, 26)),
442		];
443
444		for (anchor, (x, y)) in cases {
445			let options = OverlayOptions::default().anchor(anchor).margin(margin);
446			let band = resolve_band(&options, viewport, 20, 10);
447			assert_eq!((band.x, band.y), (x, y));
448		}
449	}
450
451	#[test]
452	fn explicit_percent_positions_use_remaining_space() {
453		let options = OverlayOptions::default()
454			.row(Dim::Pct(25))
455			.col(Dim::Pct(50))
456			.margin(OverlayMargin { top: 2, right: 3, bottom: 4, left: 5 });
457
458		let band = resolve_band(&options, Size::new(100, 40), 20, 10);
459		assert_eq!((band.x, band.y), (41, 8));
460	}
461
462	#[test]
463	fn offsets_clamp_at_margin_edges() {
464		let viewport = Size::new(100, 40);
465		let margin = OverlayMargin::uniform(2);
466		let top_left = OverlayOptions::default()
467			.anchor(OverlayAnchor::TopLeft)
468			.margin(margin)
469			.offset_x(i16::MIN)
470			.offset_y(i16::MIN);
471		let bottom_right = OverlayOptions::default()
472			.anchor(OverlayAnchor::BottomRight)
473			.margin(margin)
474			.offset_x(i16::MAX)
475			.offset_y(i16::MAX);
476
477		let top_left = resolve_band(&top_left, viewport, 20, 10);
478		let bottom_right = resolve_band(&bottom_right, viewport, 20, 10);
479		assert_eq!((top_left.x, top_left.y), (2, 2));
480		assert_eq!((bottom_right.x, bottom_right.y), (78, 28));
481	}
482
483	#[test]
484	fn bottom_anchor_slices_content_tail() {
485		let bottom = OverlayOptions::default()
486			.anchor(OverlayAnchor::Bottom)
487			.max_height(Dim::Cells(10));
488		let top = OverlayOptions::default()
489			.anchor(OverlayAnchor::Top)
490			.max_height(Dim::Cells(10));
491
492		assert_eq!(resolve_band(&bottom, Size::new(100, 40), 20, 30).src_top, 20);
493		assert_eq!(resolve_band(&top, Size::new(100, 40), 20, 30).src_top, 0);
494	}
495
496	#[test]
497	fn minimum_viewport_gates_visibility() {
498		let options = OverlayOptions::default().min_viewport(Size::new(80, 24));
499
500		assert!(visible_at(&options, Size::new(80, 24)));
501		assert!(!visible_at(&options, Size::new(79, 24)));
502		assert!(!visible_at(&options, Size::new(80, 23)));
503	}
504
505	#[test]
506	fn one_cell_viewport_clamps_without_panicking() {
507		let options = OverlayOptions::default()
508			.width(Dim::Pct(100))
509			.max_height(Dim::Pct(100))
510			.offset_x(i16::MAX)
511			.offset_y(i16::MAX);
512		let viewport = Size::new(1, 1);
513
514		assert_eq!(resolve_extent(&options, viewport), OverlayExtent {
515			width:      1,
516			max_height: 1,
517		});
518		assert_eq!(resolve_band(&options, viewport, 1, 10), OverlayBand {
519			x:       0,
520			y:       0,
521			src_top: 0,
522			rows:    1,
523		});
524	}
525}