use crate::field::ChoiceState;
use crate::session::AnnotId;
use crate::tab::Rect;
pub const LIST_BORDER: f32 = 1.0;
pub const MAX_LIST_HEIGHT: f32 = 140.0;
pub const MIN_POPUP_ROWS: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Placement {
Below,
Above,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PopupGeometry {
pub rect: kurbo::Rect,
pub placement: Placement,
pub row_height: f32,
}
pub(crate) fn widen(rect: Rect) -> kurbo::Rect {
kurbo::Rect::new(
f64::from(rect.left),
f64::from(rect.bottom),
f64::from(rect.right),
f64::from(rect.top),
)
}
impl PopupGeometry {
#[expect(
clippy::cast_possible_truncation,
reason = "every value in `rect` was widened from an f32 by `widen`"
)]
fn narrow(&self) -> Rect {
Rect::new(
self.rect.x0 as f32,
self.rect.y0 as f32,
self.rect.x1 as f32,
self.rect.y1 as f32,
)
}
#[must_use]
pub fn plate(&self) -> kurbo::Rect {
widen(self.plate_f32())
}
pub(crate) fn plate_f32(&self) -> Rect {
let border = LIST_BORDER;
let rect = self.narrow();
if rect.right - rect.left <= border * 2.0 || rect.top - rect.bottom <= border * 2.0 {
return Rect::new(rect.left, rect.bottom, rect.left, rect.bottom);
}
Rect::new(
rect.left + border,
rect.bottom + border,
rect.right - border,
rect.top - border,
)
}
#[must_use]
pub fn row_rect(&self, offset: usize) -> kurbo::Rect {
let plate = self.plate_f32();
#[expect(
clippy::cast_precision_loss,
reason = "a row offset past 2^53 has no rectangle worth naming"
)]
let down = f64::from(self.row_height) * offset as f64;
#[expect(
clippy::cast_possible_truncation,
reason = "narrowed once, after the multiply, so the clamp is the f32 range"
)]
let top = plate.top - down as f32;
widen(Rect::new(
plate.left,
top - self.row_height,
plate.right,
top,
))
}
pub(crate) fn row_at(&self, x: f32, y: f32) -> Option<usize> {
let plate = self.plate_f32();
if x < plate.left || x > plate.right || y < plate.bottom || y > plate.top {
return None;
}
if self.row_height <= 0.0 {
return None;
}
let offset = ((plate.top - y) / self.row_height).floor();
if !offset.is_finite() || offset < 0.0 {
return None;
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "non-negative and bounded by the plate height over the row height"
)]
let offset = offset as usize;
Some(offset)
}
#[must_use]
pub fn visible_rows(&self) -> usize {
if self.row_height <= 0.0 {
return 0;
}
let plate = self.plate_f32();
let rows = ((plate.top - plate.bottom) / self.row_height).ceil();
if !rows.is_finite() || rows <= 0.0 {
return 0;
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "a row count is bounded by the popup's height in points"
)]
let rows = rows as usize;
rows
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PopupView {
pub annot: AnnotId,
pub anchor: kurbo::Rect,
pub geometry: PopupGeometry,
pub options: Vec<String>,
pub selected: Option<usize>,
pub hovered: Option<usize>,
pub top_visible: usize,
pub edit_text: Option<String>,
}
impl PopupView {
#[must_use]
pub fn label_at(&self, offset: usize) -> Option<&str> {
let index = self.top_visible.checked_add(offset)?;
self.options.get(index).map(String::as_str)
}
#[must_use]
pub fn is_banded(&self, offset: usize) -> bool {
let Some(index) = self.top_visible.checked_add(offset) else {
return false;
};
self.hovered == Some(index) || (self.hovered.is_none() && self.selected == Some(index))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScrollView {
pub top_visible: usize,
pub visible_rows: usize,
pub total: usize,
}
impl ScrollView {
#[must_use]
pub fn is_scrollable(&self) -> bool {
self.total > self.visible_rows
}
}
pub(crate) fn place(
anchor: Rect,
page_height: f32,
rows: usize,
row_height: f32,
) -> Option<PopupGeometry> {
if row_height <= 0.0 || rows == 0 {
return None;
}
let border = LIST_BORDER * 2.0;
#[expect(
clippy::cast_precision_loss,
reason = "an option count past 2^24 has already exceeded any page"
)]
let content = row_height * rows as f32;
if content <= 0.0 {
return None;
}
let floor = if rows > MIN_POPUP_ROWS {
row_height * 3.0 + border
} else {
0.0
};
let ceiling = content + border;
let wanted = MAX_LIST_HEIGHT.max(floor).min(ceiling.max(floor));
let above = page_height - anchor.top;
let below = anchor.bottom;
let (placement, height) = if below > wanted {
(Placement::Below, wanted)
} else if above > wanted {
(Placement::Above, wanted)
} else if above > below {
(Placement::Above, above)
} else {
(Placement::Below, below)
};
if height <= 0.0 {
return None;
}
let rect = match placement {
Placement::Below => Rect::new(
anchor.left,
anchor.bottom - height,
anchor.right,
anchor.bottom,
),
Placement::Above => Rect::new(anchor.left, anchor.top, anchor.right, anchor.top + height),
};
Some(PopupGeometry {
rect: widen(rect),
placement,
row_height,
})
}
#[must_use]
pub fn option_at(state: &ChoiceState, offset: usize) -> Option<usize> {
let index = state.top_visible.checked_add(offset)?;
(index < state.options.len()).then_some(index)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn two_options_open_downward_at_their_own_height() {
let anchor = Rect::new(165.7, 315.9, 315.7, 330.1);
let popup = place(anchor, 342.0, 2, 13.392).expect("a two-row list fits");
assert_eq!(popup.placement, Placement::Below);
assert!((popup.rect.y1 - 315.9).abs() < 1e-4, "{:?}", popup.rect);
assert!(
((popup.rect.y1 - popup.rect.y0) - 28.784).abs() < 1e-3,
"{:?}",
popup.rect
);
assert!((popup.rect.x0 - 165.7).abs() < 1e-4);
assert!((popup.rect.x1 - 315.7).abs() < 1e-4);
}
#[test]
fn three_options_open_downward() {
let anchor = Rect::new(70.0, 135.0, 150.0, 155.0);
let popup = place(anchor, 200.0, 3, 13.392).expect("a three-row list fits");
assert_eq!(popup.placement, Placement::Below);
assert!(
((popup.rect.y1 - popup.rect.y0) - 42.176).abs() < 1e-3,
"{:?}",
popup.rect
);
assert!((popup.rect.y1 - 135.0).abs() < 1e-4);
}
#[test]
fn no_room_below_opens_upward() {
let anchor = Rect::new(10.0, 4.0, 100.0, 24.0);
let popup = place(anchor, 200.0, 2, 13.392).expect("there is room above");
assert_eq!(popup.placement, Placement::Above);
assert!((popup.rect.y0 - 24.0).abs() < 1e-4, "{:?}", popup.rect);
assert!(((popup.rect.y1 - popup.rect.y0) - 28.784).abs() < 1e-3);
}
#[test]
fn squeezed_takes_the_larger_side_whole() {
let anchor = Rect::new(0.0, 6.0, 50.0, 26.0);
let popup = place(anchor, 36.0, 2, 13.392).expect("ten units is still a popup");
assert_eq!(popup.placement, Placement::Above);
assert!(
((popup.rect.y1 - popup.rect.y0) - 10.0).abs() < 1e-4,
"{:?}",
popup.rect
);
}
#[test]
fn a_long_list_is_capped_at_one_hundred_and_forty() {
let anchor = Rect::new(0.0, 400.0, 100.0, 420.0);
let popup = place(anchor, 800.0, 40, 13.392).expect("a forty-row list opens");
assert!(
((popup.rect.y1 - popup.rect.y0) - 140.0).abs() < 1e-4,
"{:?}",
popup.rect
);
}
#[test]
fn the_three_row_floor_outranks_the_cap() {
let anchor = Rect::new(0.0, 400.0, 100.0, 420.0);
let popup = place(anchor, 800.0, 4, 60.0).expect("a four-row list opens");
assert!(
((popup.rect.y1 - popup.rect.y0) - 182.0).abs() < 1e-4,
"{:?}",
popup.rect
);
}
#[test]
fn three_tall_options_are_still_capped() {
let anchor = Rect::new(0.0, 400.0, 100.0, 420.0);
let popup = place(anchor, 800.0, 3, 60.0).expect("a three-row list opens");
assert!(
((popup.rect.y1 - popup.rect.y0) - 140.0).abs() < 1e-4,
"{:?}",
popup.rect
);
}
#[test]
fn nowhere_to_open_refuses() {
let anchor = Rect::new(0.0, 0.0, 100.0, 200.0);
assert_eq!(place(anchor, 200.0, 2, 13.392), None);
}
#[test]
fn an_empty_list_refuses() {
let anchor = Rect::new(0.0, 100.0, 100.0, 120.0);
assert_eq!(place(anchor, 400.0, 0, 13.392), None);
assert_eq!(place(anchor, 400.0, 2, 0.0), None);
}
#[test]
fn rows_stack_downward_from_the_plate() {
let anchor = Rect::new(165.7, 315.9, 315.7, 330.1);
let popup = place(anchor, 342.0, 2, 13.392).expect("a two-row list fits");
let plate = popup.plate_f32();
assert!((plate.top - 314.9).abs() < 1e-4, "{plate:?}");
assert!((plate.left - 166.7).abs() < 1e-4);
let first = popup.row_rect(0);
assert!((first.y1 - 314.9).abs() < 1e-4, "{first:?}");
assert!((first.y0 - (314.9 - 13.392)).abs() < 1e-3);
let second = popup.row_rect(1);
assert!((second.y1 - first.y0).abs() < 1e-6);
}
#[test]
fn a_point_finds_its_row() {
let anchor = Rect::new(165.7, 315.9, 315.7, 330.1);
let popup = place(anchor, 342.0, 2, 13.392).expect("a two-row list fits");
assert_eq!(popup.row_at(312.0, 310.0), Some(0));
assert_eq!(popup.row_at(312.0, 295.0), Some(1));
assert_eq!(popup.row_at(312.0, 324.0), None);
assert_eq!(popup.row_at(312.0, 280.0), None);
assert_eq!(popup.row_at(100.0, 310.0), None);
}
#[test]
fn scroll_view_reports_whether_it_scrolls() {
assert!(
ScrollView {
top_visible: 0,
visible_rows: 3,
total: 9,
}
.is_scrollable()
);
assert!(
!ScrollView {
top_visible: 0,
visible_rows: 9,
total: 9,
}
.is_scrollable()
);
}
}