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

@@ -2,7 +2,7 @@
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
@@ -217,6 +217,10 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
let class = layout_class(frame.area());
let (direction, constraints) = match class {
LayoutClass::Narrow if app.mode() == Mode::Viewer && area.height < 10 => (
Direction::Vertical,
[Constraint::Length(0), Constraint::Min(1)],
),
LayoutClass::Narrow => (
Direction::Vertical,
[Constraint::Percentage(40), Constraint::Percentage(60)],
@@ -250,9 +254,11 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
app.otp_display().expect("large OTP was checked"),
main_rows[0],
);
let lines = viewer_lines(viewer, None);
let scroll = viewer_scroll(viewer, &lines, main_rows[1]);
frame.render_widget(
Paragraph::new(viewer_lines(viewer, None))
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
Paragraph::new(lines)
.scroll((scroll, 0))
.block(pane_block(
mode_title(app.mode()),
app.focus() == PaneFocus::Main,
@@ -273,11 +279,13 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
} else {
(panes[1], None)
};
render_compact_otp(frame, display, otp_area);
if let Some(details_area) = details_area {
render_compact_otp(frame, display, otp_area);
let lines = viewer_lines(viewer, None);
let scroll = viewer_scroll(viewer, &lines, details_area);
frame.render_widget(
Paragraph::new(viewer_lines(viewer, None))
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
Paragraph::new(lines)
.scroll((scroll, 0))
.block(pane_block(
mode_title(app.mode()),
app.focus() == PaneFocus::Main,
@@ -285,6 +293,8 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
.wrap(Wrap { trim: false }),
details_area,
);
} else {
render_compact_viewer(frame, app, viewer, display, otp_area);
}
return;
}
@@ -292,8 +302,9 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
Mode::Viewer => app.viewer().map_or_else(
|| Paragraph::new(main_text(app)),
|viewer| {
Paragraph::new(viewer_lines(viewer, app.otp_display()))
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
let lines = viewer_lines(viewer, app.otp_display());
let scroll = viewer_scroll(viewer, &lines, panes[1]);
Paragraph::new(lines).scroll((scroll, 0))
},
),
Mode::Editor => app.editor().map_or_else(
@@ -392,24 +403,95 @@ fn render_large_otp(frame: &mut Frame, display: &crate::app::OtpDisplay, area: R
}
fn render_compact_otp(frame: &mut Frame, display: &crate::app::OtpDisplay, area: Rect) {
let code = String::from_utf8_lossy(display.code().expose());
let remaining = display.remaining_seconds().unwrap_or_default();
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
code.as_ref(),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!("{remaining}s remaining")),
]))
.alignment(Alignment::Center)
.block(Block::bordered().title("TOTP code")),
Paragraph::new(compact_otp_line(display))
.alignment(Alignment::Center)
.block(Block::bordered().title("TOTP code")),
area,
);
}
fn compact_otp_line(display: &crate::app::OtpDisplay) -> Line<'_> {
let code = String::from_utf8_lossy(display.code().expose());
let remaining = display.remaining_seconds().unwrap_or_default();
Line::from(vec![
Span::styled(
code,
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!("{remaining}s remaining")),
])
}
fn render_compact_viewer(
frame: &mut Frame,
app: &App,
viewer: &EntryViewer,
display: &crate::app::OtpDisplay,
area: Rect,
) {
frame.render_widget(
pane_block(mode_title(app.mode()), app.focus() == PaneFocus::Main),
area,
);
let inner = area.inner(Margin::new(1, 1));
if inner.height == 0 {
return;
}
let rows = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(inner);
frame.render_widget(Paragraph::new(compact_otp_line(display)), rows[0]);
if rows[1].height == 0 {
return;
}
let lines = viewer_lines(viewer, None);
let scroll = viewer_scroll_in_content(viewer, &lines, rows[1].width, rows[1].height);
frame.render_widget(
Paragraph::new(lines)
.scroll((scroll, 0))
.wrap(Wrap { trim: false }),
rows[1],
);
}
fn viewer_scroll(viewer: &EntryViewer, lines: &[Line<'_>], area: Rect) -> u16 {
viewer_scroll_in_content(
viewer,
lines,
area.width.saturating_sub(2),
area.height.saturating_sub(2),
)
}
fn viewer_scroll_in_content(
viewer: &EntryViewer,
lines: &[Line<'_>],
width: u16,
height: u16,
) -> u16 {
let row_heights = lines
.iter()
.map(|line| {
Paragraph::new(line.clone())
.wrap(Wrap { trim: false })
.line_count(width)
.max(1)
})
.collect::<Vec<_>>();
let focused = viewer.focused_index().unwrap_or_default();
let focused_start = row_heights.iter().take(focused).sum();
let focused_height = row_heights.get(focused).copied().unwrap_or(1);
let total_rows = row_heights.iter().sum();
let scroll = viewer.ensure_focus_visible(
focused_start,
focused_height,
total_rows,
usize::from(height),
);
u16::try_from(scroll).unwrap_or(u16::MAX)
}
fn otp_ascii_row(code: &[u8], row: usize) -> Zeroizing<String> {
let mut rendered = Zeroizing::new(String::with_capacity(
code.len().saturating_mul(4).saturating_sub(1),
@@ -1157,6 +1239,116 @@ mod tests {
assert!(wide.contains("Browser"));
}
#[test]
fn viewer_scroll_changes_only_when_focus_crosses_the_rendered_viewport() {
let mut document = fixture_document("email/personal");
for index in 0..5 {
let insertion = document.fields().len();
document
.add(
insertion,
ironstorage::document::EntryFieldDraft::field(
format!("extra-{index}"),
format!("value-{index}").into_bytes(),
)
.expect("field draft"),
)
.expect("append test field");
}
let mut app = App::new();
app.open_test_document("many/fields", document);
for action in [
crate::action::Action::FocusNext,
crate::action::Action::Next,
crate::action::Action::FocusNext,
crate::action::Action::Next,
crate::action::Action::FocusNext,
crate::action::Action::Next,
crate::action::Action::FocusNext,
] {
render(100, 20, &app);
assert_eq!(app.viewer().expect("viewer").scroll(), 0);
app.dispatch(action);
}
let mut small = App::new();
let mut document = fixture_document("email/personal");
for index in 0..5 {
let insertion = document.fields().len();
document
.add(
insertion,
ironstorage::document::EntryFieldDraft::field(
format!("extra-{index}"),
format!("value-{index}").into_bytes(),
)
.expect("field draft"),
)
.expect("append test field");
}
small.open_test_document("many/fields", document);
for (expected_scroll, action) in [
(0, crate::action::Action::Next),
(0, crate::action::Action::FocusNext),
(0, crate::action::Action::Next),
(1, crate::action::Action::FocusNext),
(2, crate::action::Action::Next),
] {
let output = render(40, 8, &small);
assert!(output.contains("Viewer"));
assert_eq!(small.viewer().expect("viewer").scroll(), expected_scroll);
small.dispatch(action);
}
small.dispatch(crate::action::Action::Previous);
render(40, 8, &small);
assert_eq!(small.viewer().expect("viewer").scroll(), 2);
small.dispatch(crate::action::Action::FocusPrevious);
render(40, 8, &small);
assert_eq!(small.viewer().expect("viewer").scroll(), 2);
small.dispatch(crate::action::Action::Previous);
render(40, 8, &small);
assert_eq!(small.viewer().expect("viewer").scroll(), 2);
small.dispatch(crate::action::Action::FocusPrevious);
render(40, 8, &small);
assert_eq!(small.viewer().expect("viewer").scroll(), 1);
render(100, 20, &small);
assert_eq!(small.viewer().expect("viewer").scroll(), 0);
}
#[test]
fn wrapped_viewer_fields_keep_the_selected_field_on_screen() {
let mut document = fixture_document("email/personal");
document
.add(
1,
ironstorage::document::EntryFieldDraft::field(
"url",
"xxxxxx ".repeat(15).into_bytes(),
)
.expect("field draft"),
)
.expect("prepend wrapped field");
let mut app = App::new();
app.open_test_document("many/wrapped-fields", document);
render(40, 8, &app);
assert_eq!(app.viewer().expect("viewer").scroll(), 0);
app.dispatch(crate::action::Action::Next);
let wrapped = render(40, 8, &app);
assert!(wrapped.contains("url: xxxxxx"));
let wrapped_scroll = app.viewer().expect("viewer").scroll();
assert_eq!(wrapped_scroll, 0);
app.dispatch(crate::action::Action::FocusNext);
let login = render(40, 8, &app);
assert!(login.contains("login: alice@example.test"));
assert!(app.viewer().expect("viewer").scroll() > wrapped_scroll);
render(140, 20, &app);
assert_eq!(app.viewer().expect("viewer").scroll(), 0);
}
#[test]
fn lease_warning_is_concise_and_never_contains_entry_state() {
let mut app = App::new();
@@ -1276,11 +1468,13 @@ mod tests {
assert!(large.contains("222 333"));
assert!(!large.contains("code 123456"));
assert!(!large.contains("JBSWY3DPEHPK3PXP"));
assert_eq!(app.viewer().expect("viewer").scroll(), 0);
let full_bar = large.matches('█').count();
let compact = render(60, 16, &app);
assert!(compact.contains("123456 — 12s remaining"));
assert!(!compact.contains("TOTP code —"));
assert_eq!(app.viewer().expect("viewer").scroll(), 0);
app.observe_time(66);
let progressed = render(120, 20, &app);
@@ -1321,6 +1515,8 @@ mod tests {
assert!(refreshed.matches('█').count() > full_bar);
let minimum = render(40, 8, &app);
assert!(minimum.contains("654321 — 30s remaining"));
assert!(minimum.contains("password: ••••••••"));
assert_eq!(app.viewer().expect("viewer").scroll(), 0);
let payload = ironstorage::repository::SecretBytes::new(
b"otpauth://totp/test?secret=NEVER-RENDER".to_vec(),