1use crate::{
9 frame::{Frame, Size},
10 markup::Dim,
11};
12
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15pub enum OverlayAnchor {
16 #[default]
18 Center,
19 TopLeft,
21 Top,
23 TopRight,
25 Right,
27 BottomRight,
29 Bottom,
31 BottomLeft,
33 Left,
35}
36
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39pub struct OverlayMargin {
40 pub top: u16,
42 pub right: u16,
44 pub bottom: u16,
46 pub left: u16,
48}
49
50impl OverlayMargin {
51 pub const fn uniform(n: u16) -> Self {
53 Self { top: n, right: n, bottom: n, left: n }
54 }
55}
56
57#[non_exhaustive]
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub struct OverlayOptions {
61 pub width: Option<Dim>,
63 pub min_width: Option<u16>,
65 pub max_height: Option<Dim>,
67 pub anchor: OverlayAnchor,
69 pub offset_x: i16,
71 pub offset_y: i16,
73 pub row: Option<Dim>,
75 pub col: Option<Dim>,
77 pub margin: OverlayMargin,
79 pub z: i16,
82 pub min_viewport: Size,
84 pub modal: bool,
92 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 #[must_use]
124 pub const fn width(mut self, width: Dim) -> Self {
125 self.width = Some(width);
126 self
127 }
128
129 #[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 #[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 #[must_use]
145 pub const fn z(mut self, z: i16) -> Self {
146 self.z = z;
147 self
148 }
149
150 #[must_use]
152 pub const fn anchor(mut self, anchor: OverlayAnchor) -> Self {
153 self.anchor = anchor;
154 self
155 }
156
157 #[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 #[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 #[must_use]
173 pub const fn row(mut self, row: Dim) -> Self {
174 self.row = Some(row);
175 self
176 }
177
178 #[must_use]
180 pub const fn col(mut self, col: Dim) -> Self {
181 self.col = Some(col);
182 self
183 }
184
185 #[must_use]
187 pub const fn margin(mut self, margin: OverlayMargin) -> Self {
188 self.margin = margin;
189 self
190 }
191
192 #[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 #[must_use]
205 pub const fn non_modal(mut self) -> Self {
206 self.modal = false;
207 self
208 }
209
210 #[must_use]
213 pub const fn fill_height(mut self) -> Self {
214 self.fill_height = true;
215 self
216 }
217}
218
219#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
221pub struct OverlayId(pub(crate) u32);
222
223#[derive(Clone, Copy, Debug, PartialEq, Eq)]
225pub struct OverlayExtent {
226 pub width: u16,
227 pub max_height: u16,
228}
229
230#[derive(Clone, Copy, Debug, PartialEq, Eq)]
232pub struct OverlayBand {
233 pub x: u16,
235 pub y: u16,
237 pub src_top: u16,
239 pub rows: u16,
241}
242
243pub struct Layer<'a> {
248 pub frame: &'a Frame,
250 pub options: &'a OverlayOptions,
252 pub active: bool,
258}
259
260impl Layer<'_> {
261 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}