Fix TUI details viewport scrolling

This commit is contained in:
Hermes Agent
2026-08-10 12:48:00 +00:00
parent cec1cc3500
commit e0ce21337d
6 changed files with 354 additions and 53 deletions

View File

@@ -1,6 +1,6 @@
//! Secret-aware presentation state for one storage-owned entry document.
use std::fmt;
use std::{cell::Cell, fmt};
use ironstorage::{
document::{DocumentError, EntryDocument, EntryField, EntryFieldId, EntrySensitivity},
@@ -15,7 +15,7 @@ pub struct EntryViewer {
document: EntryDocument,
focused: usize,
revealed: Option<EntryFieldId>,
scroll: usize,
scroll: Cell<usize>,
}
impl EntryViewer {
@@ -24,7 +24,7 @@ impl EntryViewer {
document,
focused: 0,
revealed: None,
scroll: 0,
scroll: Cell::new(0),
}
}
@@ -52,7 +52,6 @@ impl EntryViewer {
self.hide_revealed();
if !self.document.fields().is_empty() {
self.focused = (self.focused + 1) % self.document.fields().len();
self.scroll = self.focused;
}
}
@@ -63,7 +62,6 @@ impl EntryViewer {
.focused
.checked_sub(1)
.unwrap_or(self.document.fields().len() - 1);
self.scroll = self.focused;
}
}
@@ -90,23 +88,56 @@ impl EntryViewer {
}
pub fn scroll(&self) -> usize {
self.scroll
self.scroll.get()
}
/// Keep the focused rendered row range inside the supplied viewport while
/// retaining the current offset whenever it is already visible.
pub fn ensure_focus_visible(
&self,
focused_start: usize,
focused_height: usize,
total_rows: usize,
viewport_rows: usize,
) -> usize {
if self.document.fields().is_empty() || total_rows == 0 {
self.scroll.set(0);
return 0;
}
if viewport_rows == 0 {
let scroll = self.scroll.get().min(total_rows.saturating_sub(1));
self.scroll.set(scroll);
return scroll;
}
let maximum = total_rows.saturating_sub(viewport_rows);
let mut scroll = self.scroll.get().min(maximum);
let focused_height = focused_height.max(1);
let focused_end = focused_start.saturating_add(focused_height);
let viewport_end = scroll.saturating_add(viewport_rows);
if focused_height > viewport_rows {
if focused_end <= scroll {
scroll = focused_end.saturating_sub(1);
} else if focused_start >= viewport_end {
scroll = focused_start;
}
} else if focused_start < scroll {
scroll = focused_start;
} else {
if focused_end > viewport_end {
scroll = focused_end.saturating_sub(viewport_rows);
}
}
scroll = scroll.min(maximum);
self.scroll.set(scroll);
scroll
}
pub fn scroll_down(&mut self, rows: usize) {
self.scroll = self.scroll.saturating_add(rows).min(self.scroll_limit());
self.scroll.set(self.scroll.get().saturating_add(rows));
}
pub fn scroll_up(&mut self, rows: usize) {
self.scroll = self.scroll.saturating_sub(rows);
}
fn scroll_limit(&self) -> usize {
self.document
.fields()
.iter()
.map(|field| field.value().len().saturating_add(1))
.sum()
self.scroll.set(self.scroll.get().saturating_sub(rows));
}
}
@@ -117,7 +148,7 @@ impl fmt::Debug for EntryViewer {
.field("document", &self.document)
.field("focused", &self.focused)
.field("revealed", &self.revealed.map(|_| "[REDACTED]"))
.field("scroll", &self.scroll)
.field("scroll", &self.scroll.get())
.finish()
}
}
@@ -201,4 +232,69 @@ mod tests {
viewer.scroll_up(usize::MAX);
assert_eq!(viewer.scroll(), 0);
}
#[test]
fn focus_only_scrolls_when_it_crosses_a_viewport_edge() {
let mut viewer = EntryViewer::new(fixture_document("email/personal"));
assert_eq!(viewer.document().fields().len(), 3);
assert_eq!(viewer.ensure_focus_visible(0, 1, 3, 3), 0);
viewer.focus_next();
assert_eq!(viewer.ensure_focus_visible(1, 1, 3, 3), 0);
viewer.focus_next();
assert_eq!(viewer.ensure_focus_visible(2, 1, 3, 3), 0);
viewer.focus_next();
assert_eq!(viewer.ensure_focus_visible(0, 1, 3, 3), 0);
viewer.focus_previous();
assert_eq!(viewer.focused_index(), Some(2));
assert_eq!(viewer.ensure_focus_visible(2, 1, 3, 2), 1);
viewer.focus_previous();
assert_eq!(viewer.focused_index(), Some(1));
assert_eq!(viewer.ensure_focus_visible(1, 1, 3, 2), 1);
viewer.focus_previous();
assert_eq!(viewer.focused_index(), Some(0));
assert_eq!(viewer.ensure_focus_visible(0, 1, 3, 2), 0);
}
#[test]
fn viewport_resize_clamps_then_minimally_reveals_the_focus() {
let mut viewer = EntryViewer::new(fixture_document("email/personal"));
viewer.focus_next();
viewer.focus_next();
assert_eq!(viewer.ensure_focus_visible(2, 1, 3, 1), 2);
assert_eq!(viewer.ensure_focus_visible(2, 1, 3, 2), 1);
assert_eq!(viewer.ensure_focus_visible(2, 1, 3, 3), 0);
assert_eq!(viewer.ensure_focus_visible(2, 1, 3, 1), 2);
viewer.focus_previous();
assert_eq!(viewer.ensure_focus_visible(1, 1, 3, 1), 1);
viewer.focus_previous();
assert_eq!(viewer.ensure_focus_visible(0, 1, 3, 1), 0);
}
#[test]
fn wrapped_fields_use_rendered_row_ranges_for_minimal_scrolling() {
let mut viewer = EntryViewer::new(fixture_document("email/personal"));
viewer.focus_next();
viewer.focus_next();
assert_eq!(viewer.ensure_focus_visible(4, 2, 6, 4), 2);
viewer.focus_previous();
assert_eq!(viewer.ensure_focus_visible(2, 2, 6, 4), 2);
viewer.focus_previous();
assert_eq!(viewer.ensure_focus_visible(0, 2, 6, 4), 0);
}
#[test]
fn a_field_taller_than_the_viewport_allows_needed_manual_scrolling() {
let mut viewer = EntryViewer::new(fixture_document("email/personal"));
viewer.focus_next();
assert_eq!(viewer.ensure_focus_visible(1, 5, 7, 3), 0);
viewer.scroll_down(2);
assert_eq!(viewer.ensure_focus_visible(1, 5, 7, 3), 2);
viewer.scroll_down(usize::MAX);
assert_eq!(viewer.ensure_focus_visible(1, 5, 7, 3), 4);
}
}