use retroglyph_core::grid::{Pos, Rect};
fn thumb_len(track: u16, total_len: usize, visible_len: usize) -> u16 {
let track_f = f32::from(track);
#[allow(clippy::cast_precision_loss)]
let ratio = visible_len as f32 / total_len as f32;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let len = retroglyph_core::math::round(track_f * ratio).clamp(1.0, track_f) as u16;
len
}
struct TrackMap {
max_offset: usize,
max_start: u16,
len: u16,
}
impl TrackMap {
fn new(track: u16, total_len: usize, visible_len: usize) -> Option<Self> {
if track == 0 || visible_len == 0 || total_len <= visible_len {
return None;
}
let len = thumb_len(track, total_len, visible_len);
Some(Self {
max_offset: total_len - visible_len, max_start: track.saturating_sub(len), len,
})
}
fn row_for(&self, offset: usize) -> u16 {
if self.max_offset == 0 {
return 0;
}
let offset = offset.min(self.max_offset);
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
let row = retroglyph_core::math::round(
(offset as f32 / self.max_offset as f32) * f32::from(self.max_start),
) as u16;
row.min(self.max_start)
}
fn offset_for(&self, row: u16) -> usize {
if self.max_start == 0 {
return self.max_offset;
}
let row = row.min(self.max_start);
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
let offset = retroglyph_core::math::round(
(f32::from(row) / f32::from(self.max_start)) * self.max_offset as f32,
) as usize;
offset.min(self.max_offset)
}
}
#[must_use]
pub fn thumb_geometry(
area: Rect,
total_len: usize,
visible_len: usize,
offset: usize,
) -> Option<(u16, u16)> {
let map = TrackMap::new(area.height(), total_len, visible_len)?;
Some((map.row_for(offset), map.len))
}
#[must_use]
pub fn offset_for_pos(area: Rect, total_len: usize, visible_len: usize, pos: Pos) -> Option<usize> {
if !area.contains_pos(pos) {
return None;
}
let row = pos.y.checked_sub(area.top())?;
let map = TrackMap::new(area.height(), total_len, visible_len)?;
Some(map.offset_for(row))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nothing_to_scroll_when_everything_fits() {
assert_eq!(thumb_geometry(Rect::new(0, 0, 1, 10), 5, 10, 0), None);
assert_eq!(thumb_geometry(Rect::new(0, 0, 1, 10), 10, 10, 0), None);
}
#[test]
fn thumb_shrinks_with_the_visible_fraction() {
let (_, len) = thumb_geometry(Rect::new(0, 0, 1, 20), 20, 10, 0).unwrap();
assert_eq!(len, 10);
let (_, len) = thumb_geometry(Rect::new(0, 0, 1, 20), 2000, 1, 0).unwrap();
assert_eq!(len, 1);
}
#[test]
fn thumb_moves_from_top_to_bottom_as_offset_increases() {
let area = Rect::new(0, 0, 1, 20);
let (start_at_zero, len) = thumb_geometry(area, 40, 10, 0).unwrap();
assert_eq!(start_at_zero, 0);
let (start_at_max, _) = thumb_geometry(area, 40, 10, 30).unwrap(); assert_eq!(start_at_max, area.height() - len);
let (start_at_mid, _) = thumb_geometry(area, 40, 10, 15).unwrap();
assert!(start_at_mid > start_at_zero && start_at_mid < start_at_max);
}
#[test]
fn offset_for_pos_round_trips_thumb_geometry_endpoints() {
let area = Rect::new(0, 0, 1, 20);
assert_eq!(
offset_for_pos(area, 40, 10, Pos::new(0, area.top())),
Some(0)
);
assert_eq!(
offset_for_pos(area, 40, 10, Pos::new(0, area.bottom() - 1)),
Some(30) );
}
#[test]
fn offset_for_pos_outside_the_area_is_none() {
let area = Rect::new(5, 5, 1, 10);
assert_eq!(offset_for_pos(area, 40, 10, Pos::new(0, 0)), None);
}
#[test]
fn offset_for_pos_mirrors_thumb_geometry_for_a_zero_height_viewport() {
let area = Rect::new(0, 0, 1, 10);
assert_eq!(thumb_geometry(area, 40, 0, 0), None); assert_eq!(offset_for_pos(area, 40, 0, Pos::new(0, 5)), None);
}
#[test]
fn offset_for_pos_clamps_to_max_offset_for_any_row_the_thumb_can_start_on() {
let area = Rect::new(0, 0, 1, 20);
let (max_start, len) = thumb_geometry(area, 40, 10, 30).unwrap(); assert_eq!((max_start, len), (15, 5));
for row in max_start..area.height() {
assert_eq!(
offset_for_pos(area, 40, 10, Pos::new(0, area.top() + row)),
Some(30),
"row {row}"
);
}
}
#[test]
fn offset_for_pos_round_trips_thumb_geometry_within_one_row() {
let area = Rect::new(0, 0, 1, 20);
let (total, visible) = (40, 10);
let max_offset = total - visible;
for offset in 0..=max_offset {
let (start, _len) = thumb_geometry(area, total, visible, offset).unwrap();
let back =
offset_for_pos(area, total, visible, Pos::new(0, area.top() + start)).unwrap();
let diff = back.abs_diff(offset);
assert!(
diff <= 1,
"offset {offset} -> row {start} -> {back} (diff {diff})"
);
}
}
#[test]
fn track_map_row_then_offset_round_trips_exactly() {
for height in [1u16, 2, 3, 5, 7, 10, 20, 50] {
for total_len in [2usize, 5, 10, 40, 100, 1000] {
for visible_len in [1usize, 2, 5, 10, 50] {
if total_len <= visible_len {
continue;
}
let area = Rect::new(0, 0, 1, height);
let Some((_, len)) = thumb_geometry(area, total_len, visible_len, 0) else {
continue;
};
let max_start = height.saturating_sub(len);
let max_offset = total_len - visible_len;
if usize::from(max_start) > max_offset {
continue; }
for row in 0..=max_start {
let offset = offset_for_pos(
area,
total_len,
visible_len,
Pos::new(0, area.top() + row),
)
.unwrap();
let (back_row, _) =
thumb_geometry(area, total_len, visible_len, offset).unwrap();
assert_eq!(
back_row, row,
"height={height} total_len={total_len} visible_len={visible_len} row={row}"
);
}
}
}
}
}
}