Revamp desktop usability and multiline fields
This commit is contained in:
@@ -332,10 +332,19 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect, capability: ColorCap
|
||||
Mode::Editor => app.editor().map_or_else(
|
||||
|| Paragraph::new(main_text(app)),
|
||||
|editor| {
|
||||
Paragraph::new(editor_lines(editor)).scroll((
|
||||
u16::try_from(editor.focused_index().unwrap_or_default()).unwrap_or(u16::MAX),
|
||||
0,
|
||||
))
|
||||
let focused = editor.focused_index().unwrap_or_default();
|
||||
let scroll = editor
|
||||
.document()
|
||||
.fields()
|
||||
.iter()
|
||||
.take(focused)
|
||||
.map(|field| {
|
||||
std::str::from_utf8(field.contents().expose())
|
||||
.map_or(1, |value| value.split('\n').count())
|
||||
})
|
||||
.sum::<usize>();
|
||||
Paragraph::new(editor_lines(editor))
|
||||
.scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0))
|
||||
},
|
||||
),
|
||||
Mode::Browser if app.git_view().is_some() => {
|
||||
@@ -524,8 +533,22 @@ fn viewer_scroll_in_content(
|
||||
})
|
||||
.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 field_heights = viewer
|
||||
.document()
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| {
|
||||
std::str::from_utf8(field.value()).map_or(1, |value| value.split('\n').count())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let focused_line = field_heights.iter().take(focused).sum();
|
||||
let focused_lines = field_heights.get(focused).copied().unwrap_or(1);
|
||||
let focused_start = row_heights.iter().take(focused_line).sum();
|
||||
let focused_height = row_heights
|
||||
.iter()
|
||||
.skip(focused_line)
|
||||
.take(focused_lines)
|
||||
.sum();
|
||||
let total_rows = row_heights.iter().sum();
|
||||
let scroll = viewer.ensure_focus_visible(
|
||||
focused_start,
|
||||
@@ -694,39 +717,52 @@ fn viewer_lines<'a>(
|
||||
return vec![Line::from("This entry is empty.")];
|
||||
}
|
||||
|
||||
viewer
|
||||
.document()
|
||||
.fields()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, field)| {
|
||||
let metadata = field.metadata();
|
||||
let label = metadata.name().map_or_else(
|
||||
|| match metadata.kind() {
|
||||
ironstorage::document::EntryFieldKind::Note => format!("note {}", index + 1),
|
||||
ironstorage::document::EntryFieldKind::Blank => format!("blank {}", index + 1),
|
||||
kind => format!("{kind:?}").to_ascii_lowercase(),
|
||||
},
|
||||
str::to_owned,
|
||||
);
|
||||
let value = if field.value().is_empty() {
|
||||
Span::styled("(empty)", Style::default().fg(Color::DarkGray))
|
||||
} else {
|
||||
match std::str::from_utf8(field.value()) {
|
||||
Ok(value) => Span::raw(value),
|
||||
Err(_) => Span::styled("[non-UTF-8 value]", Style::default().fg(Color::Yellow)),
|
||||
}
|
||||
};
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
let mut lines = Vec::new();
|
||||
for (index, field) in viewer.document().fields().iter().enumerate() {
|
||||
let metadata = field.metadata();
|
||||
let label = metadata.name().map_or_else(
|
||||
|| match metadata.kind() {
|
||||
ironstorage::document::EntryFieldKind::Note => format!("note {}", index + 1),
|
||||
ironstorage::document::EntryFieldKind::Blank => format!("blank {}", index + 1),
|
||||
kind => format!("{kind:?}").to_ascii_lowercase(),
|
||||
},
|
||||
str::to_owned,
|
||||
);
|
||||
let values = std::str::from_utf8(field.value()).ok();
|
||||
let value_lines = values.map_or(1, |value| value.split('\n').count());
|
||||
for line_index in 0..value_lines {
|
||||
let mut spans = Vec::new();
|
||||
if line_index == 0 {
|
||||
spans.push(Span::styled(
|
||||
format!("{label}: "),
|
||||
Style::default()
|
||||
.fg(ACCENT_COLOR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
value,
|
||||
];
|
||||
if let Some(otp) = metadata.otp() {
|
||||
));
|
||||
} else {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
match values {
|
||||
Some("") if line_index == 0 => spans.push(Span::styled(
|
||||
"(empty)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
Some(value) => spans.push(Span::raw(
|
||||
value
|
||||
.split('\n')
|
||||
.nth(line_index)
|
||||
.unwrap_or_default()
|
||||
.strip_suffix('\r')
|
||||
.unwrap_or_else(|| value.split('\n').nth(line_index).unwrap_or_default()),
|
||||
)),
|
||||
None => spans.push(Span::styled(
|
||||
"[non-UTF-8 value]",
|
||||
Style::default().fg(Color::Yellow),
|
||||
)),
|
||||
}
|
||||
if line_index == 0
|
||||
&& let Some(otp) = metadata.otp()
|
||||
{
|
||||
let timing = otp.period().map_or_else(
|
||||
|| format!("counter {}", otp.counter().unwrap_or_default()),
|
||||
|period| format!("period {period}s"),
|
||||
@@ -767,13 +803,14 @@ fn viewer_lines<'a>(
|
||||
);
|
||||
}
|
||||
}
|
||||
if focused == Some(index) {
|
||||
lines.push(if focused == Some(index) {
|
||||
selected_line(spans)
|
||||
} else {
|
||||
Line::from(spans)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
|
||||
@@ -784,29 +821,38 @@ fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
|
||||
)];
|
||||
}
|
||||
|
||||
editor
|
||||
.document()
|
||||
.fields()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, field)| {
|
||||
let selected = focused == Some(index);
|
||||
let contents = editor
|
||||
.focused_contents(field.id())
|
||||
.unwrap_or_else(|| field.contents().expose());
|
||||
let label = field.metadata().name().map_or_else(
|
||||
|| format!("{:?}", field.metadata().kind()),
|
||||
|name| format!("{:?} ({name})", field.metadata().kind()),
|
||||
);
|
||||
let mut spans = vec![Span::styled(
|
||||
format!("#{:02} {label}: ", index + 1),
|
||||
Style::default()
|
||||
.fg(ACCENT_COLOR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)];
|
||||
if let Ok(value) = std::str::from_utf8(contents) {
|
||||
if selected && editor.is_input_active() && value.is_char_boundary(editor.cursor()) {
|
||||
let (before, after) = value.split_at(editor.cursor());
|
||||
let mut lines = Vec::new();
|
||||
for (index, field) in editor.document().fields().iter().enumerate() {
|
||||
let selected = focused == Some(index);
|
||||
let contents = editor
|
||||
.focused_contents(field.id())
|
||||
.unwrap_or_else(|| field.contents().expose());
|
||||
let label = field.metadata().name().map_or_else(
|
||||
|| format!("{:?}", field.metadata().kind()),
|
||||
|name| format!("{:?} ({name})", field.metadata().kind()),
|
||||
);
|
||||
if let Ok(value) = std::str::from_utf8(contents) {
|
||||
let mut offset = 0;
|
||||
for (line_index, value_line) in value.split('\n').enumerate() {
|
||||
let value_line = value_line.strip_suffix('\r').unwrap_or(value_line);
|
||||
let mut spans = vec![Span::styled(
|
||||
if line_index == 0 {
|
||||
format!("#{:02} {label}: ", index + 1)
|
||||
} else {
|
||||
" ".to_owned()
|
||||
},
|
||||
Style::default()
|
||||
.fg(ACCENT_COLOR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)];
|
||||
let cursor = editor.cursor().saturating_sub(offset);
|
||||
if selected
|
||||
&& editor.is_input_active()
|
||||
&& editor.cursor() >= offset
|
||||
&& cursor <= value_line.len()
|
||||
&& value_line.is_char_boundary(cursor)
|
||||
{
|
||||
let (before, after) = value_line.split_at(cursor);
|
||||
spans.push(Span::raw(before));
|
||||
spans.push(Span::styled("▏", Style::default().fg(Color::Yellow)));
|
||||
spans.push(Span::raw(after));
|
||||
@@ -816,21 +862,36 @@ fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
} else {
|
||||
spans.push(Span::raw(value));
|
||||
spans.push(Span::raw(value_line));
|
||||
}
|
||||
} else {
|
||||
spans.push(Span::styled(
|
||||
lines.push(if selected {
|
||||
selected_line(spans)
|
||||
} else {
|
||||
Line::from(spans)
|
||||
});
|
||||
offset += value_line.len() + 1;
|
||||
}
|
||||
} else {
|
||||
let spans = vec![
|
||||
Span::styled(
|
||||
format!("#{:02} {label}: ", index + 1),
|
||||
Style::default()
|
||||
.fg(ACCENT_COLOR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
"[non-UTF-8 field; editing will preserve bytes]",
|
||||
Style::default().fg(Color::Yellow),
|
||||
));
|
||||
}
|
||||
if selected {
|
||||
),
|
||||
];
|
||||
lines.push(if selected {
|
||||
selected_line(spans)
|
||||
} else {
|
||||
Line::from(spans)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn grep_lines(view: &crate::search::GrepView) -> Vec<Line<'_>> {
|
||||
@@ -1512,6 +1573,36 @@ mod tests {
|
||||
assert!(!format!("{app:?}").contains("pässwörd-猫"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_fields_render_copy_and_select_as_one_tui_field() {
|
||||
let (_store, document) = fixture_document_from_plaintext(
|
||||
"documents/multiline",
|
||||
b"password\ncomments: Recovery codes:\none\ntwo\nurl: https://example.test\n",
|
||||
);
|
||||
assert_eq!(document.fields().len(), 3);
|
||||
let mut app = App::new();
|
||||
app.open_test_document("documents/multiline", document);
|
||||
app.dispatch(crate::action::Action::FocusNext);
|
||||
|
||||
let viewer = app.viewer().expect("viewer");
|
||||
assert_eq!(
|
||||
viewer.copy_focused().expect("multiline copy").expose(),
|
||||
b"Recovery codes:\none\ntwo"
|
||||
);
|
||||
let lines = viewer_lines(viewer, None);
|
||||
assert_eq!(lines.len(), 5);
|
||||
for line in &lines[1..4] {
|
||||
assert!(line.spans.iter().all(|span| {
|
||||
span.style.fg == Some(SELECTED_FOREGROUND)
|
||||
&& span.style.bg == Some(SELECTED_BACKGROUND)
|
||||
}));
|
||||
}
|
||||
let rendered = render(80, 14, &app);
|
||||
for expected in ["comments: Recovery codes:", "one", "two"] {
|
||||
assert!(rendered.contains(expected), "missing {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_viewer_renders_every_value_until_entry_close_or_relock() {
|
||||
let (_store, document) = fixture_document_from_plaintext(
|
||||
|
||||
Reference in New Issue
Block a user