Fix TUI details viewport scrolling
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user