Use accent border for TUI pane focus
This commit is contained in:
@@ -20,6 +20,7 @@ const MINIMUM_WIDTH: u16 = 40;
|
||||
const MINIMUM_HEIGHT: u16 = 8;
|
||||
const LARGE_OTP_HEIGHT: u16 = 8;
|
||||
const LARGE_OTP_ROWS: usize = 5;
|
||||
const ACCENT_COLOR: Color = Color::Cyan;
|
||||
const SELECTED_FOREGROUND: Color = Color::Black;
|
||||
const SELECTED_BACKGROUND: Color = Color::White;
|
||||
|
||||
@@ -71,7 +72,7 @@ pub fn draw(frame: &mut Frame, app: &App) {
|
||||
}
|
||||
|
||||
pub fn draw_with_color_capability(frame: &mut Frame, app: &App, capability: ColorCapability) {
|
||||
draw_inner(frame, app);
|
||||
draw_inner(frame, app, capability);
|
||||
if capability == ColorCapability::Monochrome {
|
||||
for cell in &mut frame.buffer_mut().content {
|
||||
if cell.fg == SELECTED_FOREGROUND && cell.bg == SELECTED_BACKGROUND {
|
||||
@@ -82,7 +83,7 @@ pub fn draw_with_color_capability(frame: &mut Frame, app: &App, capability: Colo
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_inner(frame: &mut Frame, app: &App) {
|
||||
fn draw_inner(frame: &mut Frame, app: &App, capability: ColorCapability) {
|
||||
let area = frame.area();
|
||||
if layout_class(area) == LayoutClass::TooSmall {
|
||||
frame.render_widget(
|
||||
@@ -104,17 +105,17 @@ fn draw_inner(frame: &mut Frame, app: &App) {
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
render_content(frame, app, rows[0]);
|
||||
render_content(frame, app, rows[0], capability);
|
||||
frame.render_widget(status_line(app), rows[1]);
|
||||
frame.render_widget(context_line(app), rows[2]);
|
||||
frame.render_widget(prompt_line(app), rows[3]);
|
||||
}
|
||||
|
||||
fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
fn render_content(frame: &mut Frame, app: &App, area: Rect, capability: ColorCapability) {
|
||||
if app.mode() == Mode::Locked {
|
||||
frame.render_widget(
|
||||
Paragraph::new("The password store is locked. Authentication is required.")
|
||||
.block(Block::bordered().title("Locked"))
|
||||
.block(pane_block("Locked", true, capability))
|
||||
.wrap(Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
@@ -143,7 +144,11 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(text)
|
||||
.block(Block::bordered().title(format!("{} — Esc closes", popup.title())))
|
||||
.block(pane_block(
|
||||
format!("{} — Esc closes", popup.title()),
|
||||
true,
|
||||
capability,
|
||||
))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
@@ -152,7 +157,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
if let Some(uri) = app.uri_popup() {
|
||||
frame.render_widget(
|
||||
Paragraph::new(String::from_utf8_lossy(uri.expose()).into_owned())
|
||||
.block(Block::bordered().title("OTP URI — Esc closes"))
|
||||
.block(pane_block("OTP URI — Esc closes", true, capability))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
@@ -163,7 +168,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
if let Some(help) = app.command_help() {
|
||||
frame.render_widget(
|
||||
Paragraph::new(help)
|
||||
.block(Block::bordered().title("Command help"))
|
||||
.block(pane_block("Command help", true, capability))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
@@ -195,10 +200,11 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.block(Block::bordered().title(format!(
|
||||
"Contextual help — {}",
|
||||
mode_title(app.help_context_mode())
|
||||
)))
|
||||
.block(pane_block(
|
||||
format!("Contextual help — {}", mode_title(app.help_context_mode())),
|
||||
true,
|
||||
capability,
|
||||
))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
@@ -210,7 +216,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
{
|
||||
frame.render_widget(
|
||||
Paragraph::new(workflow.rows().join("\n"))
|
||||
.block(Block::bordered().title(workflow.title()))
|
||||
.block(pane_block(workflow.title(), true, capability))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
@@ -240,7 +246,11 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let panes = Layout::new(direction, constraints).split(area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(sidebar_lines(app))
|
||||
.block(pane_block("Passwords", app.focus() == PaneFocus::Sidebar))
|
||||
.block(pane_block(
|
||||
"Passwords",
|
||||
app.focus() == PaneFocus::Sidebar,
|
||||
capability,
|
||||
))
|
||||
.wrap(Wrap { trim: false }),
|
||||
panes[0],
|
||||
);
|
||||
@@ -255,6 +265,8 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
frame,
|
||||
app.otp_display().expect("large OTP was checked"),
|
||||
main_rows[0],
|
||||
app.focus() == PaneFocus::Main,
|
||||
capability,
|
||||
);
|
||||
let lines = viewer_lines(viewer, None);
|
||||
let scroll = viewer_scroll(viewer, &lines, main_rows[1]);
|
||||
@@ -264,6 +276,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
.block(pane_block(
|
||||
mode_title(app.mode()),
|
||||
app.focus() == PaneFocus::Main,
|
||||
capability,
|
||||
))
|
||||
.wrap(Wrap { trim: false }),
|
||||
main_rows[1],
|
||||
@@ -282,7 +295,13 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
(panes[1], None)
|
||||
};
|
||||
if let Some(details_area) = details_area {
|
||||
render_compact_otp(frame, display, otp_area);
|
||||
render_compact_otp(
|
||||
frame,
|
||||
display,
|
||||
otp_area,
|
||||
app.focus() == PaneFocus::Main,
|
||||
capability,
|
||||
);
|
||||
let lines = viewer_lines(viewer, None);
|
||||
let scroll = viewer_scroll(viewer, &lines, details_area);
|
||||
frame.render_widget(
|
||||
@@ -291,12 +310,13 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
.block(pane_block(
|
||||
mode_title(app.mode()),
|
||||
app.focus() == PaneFocus::Main,
|
||||
capability,
|
||||
))
|
||||
.wrap(Wrap { trim: false }),
|
||||
details_area,
|
||||
);
|
||||
} else {
|
||||
render_compact_viewer(frame, app, viewer, display, otp_area);
|
||||
render_compact_viewer(frame, app, viewer, display, otp_area, capability);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -330,6 +350,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
main.block(pane_block(
|
||||
mode_title(app.mode()),
|
||||
app.focus() == PaneFocus::Main,
|
||||
capability,
|
||||
))
|
||||
.wrap(Wrap { trim: false }),
|
||||
panes[1],
|
||||
@@ -374,7 +395,13 @@ fn matching_totp_display<'a>(
|
||||
.then_some(display)
|
||||
}
|
||||
|
||||
fn render_large_otp(frame: &mut Frame, display: &crate::app::OtpDisplay, area: Rect) {
|
||||
fn render_large_otp(
|
||||
frame: &mut Frame,
|
||||
display: &crate::app::OtpDisplay,
|
||||
area: Rect,
|
||||
focused: bool,
|
||||
capability: ColorCapability,
|
||||
) {
|
||||
let glyphs = (0..LARGE_OTP_ROWS)
|
||||
.map(|row| otp_ascii_row(display.code().expose(), row))
|
||||
.collect::<Vec<_>>();
|
||||
@@ -394,21 +421,31 @@ fn render_large_otp(frame: &mut Frame, display: &crate::app::OtpDisplay, area: R
|
||||
.collect::<Vec<_>>();
|
||||
lines.push(Line::styled(
|
||||
countdown_bar(remaining, period, inner_width),
|
||||
Style::default().fg(Color::Cyan),
|
||||
Style::default().fg(ACCENT_COLOR),
|
||||
));
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::bordered().title(format!("TOTP code — {remaining}s remaining"))),
|
||||
.block(pane_block(
|
||||
format!("TOTP code — {remaining}s remaining"),
|
||||
focused,
|
||||
capability,
|
||||
)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_compact_otp(frame: &mut Frame, display: &crate::app::OtpDisplay, area: Rect) {
|
||||
fn render_compact_otp(
|
||||
frame: &mut Frame,
|
||||
display: &crate::app::OtpDisplay,
|
||||
area: Rect,
|
||||
focused: bool,
|
||||
capability: ColorCapability,
|
||||
) {
|
||||
frame.render_widget(
|
||||
Paragraph::new(compact_otp_line(display))
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::bordered().title("TOTP code")),
|
||||
.block(pane_block("TOTP code", focused, capability)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
@@ -433,9 +470,14 @@ fn render_compact_viewer(
|
||||
viewer: &EntryViewer,
|
||||
display: &crate::app::OtpDisplay,
|
||||
area: Rect,
|
||||
capability: ColorCapability,
|
||||
) {
|
||||
frame.render_widget(
|
||||
pane_block(mode_title(app.mode()), app.focus() == PaneFocus::Main),
|
||||
pane_block(
|
||||
mode_title(app.mode()),
|
||||
app.focus() == PaneFocus::Main,
|
||||
capability,
|
||||
),
|
||||
area,
|
||||
);
|
||||
let inner = area.inner(Margin::new(1, 1));
|
||||
@@ -547,9 +589,16 @@ fn countdown_bar(remaining: u64, period: u64, width: usize) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn pane_block(title: &'static str, focused: bool) -> Block<'static> {
|
||||
fn pane_block<'a>(
|
||||
title: impl Into<Line<'a>>,
|
||||
focused: bool,
|
||||
capability: ColorCapability,
|
||||
) -> Block<'a> {
|
||||
let style = if focused {
|
||||
selected_style()
|
||||
match capability {
|
||||
ColorCapability::Color => Style::default().fg(ACCENT_COLOR),
|
||||
ColorCapability::Monochrome => Style::default().add_modifier(Modifier::BOLD),
|
||||
}
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
@@ -672,7 +721,7 @@ fn viewer_lines<'a>(
|
||||
Span::styled(
|
||||
format!("{label}: "),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.fg(ACCENT_COLOR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
value,
|
||||
@@ -752,7 +801,7 @@ fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
|
||||
let mut spans = vec![Span::styled(
|
||||
format!("#{:02} {label}: ", index + 1),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.fg(ACCENT_COLOR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)];
|
||||
if let Ok(value) = std::str::from_utf8(contents) {
|
||||
@@ -801,7 +850,7 @@ fn grep_lines(view: &crate::search::GrepView) -> Vec<Line<'_>> {
|
||||
} else {
|
||||
Line::from(entry_line).style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.fg(ACCENT_COLOR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
});
|
||||
@@ -831,7 +880,7 @@ fn git_lines(view: &crate::app::GitView) -> Vec<Line<'_>> {
|
||||
Line::styled(
|
||||
view.message(),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.fg(ACCENT_COLOR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Line::raw(format!("repository: {}", snapshot.root().display())),
|
||||
@@ -982,7 +1031,7 @@ fn prompt_line(app: &App) -> Paragraph<'static> {
|
||||
mod tests {
|
||||
use std::ffi::OsStr;
|
||||
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
use ratatui::{Terminal, backend::TestBackend, buffer::Buffer};
|
||||
|
||||
use super::*;
|
||||
use crate::app::Transition;
|
||||
@@ -1006,6 +1055,30 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn render_buffer(width: u16, height: u16, app: &App, capability: ColorCapability) -> Buffer {
|
||||
let backend = TestBackend::new(width, height);
|
||||
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||
terminal
|
||||
.draw(|frame| draw_with_color_capability(frame, app, capability))
|
||||
.expect("draw");
|
||||
terminal.backend().buffer().clone()
|
||||
}
|
||||
|
||||
fn border_symbols(buffer: &Buffer, area: Rect) -> Vec<String> {
|
||||
let right = area.right().saturating_sub(1);
|
||||
let bottom = area.bottom().saturating_sub(1);
|
||||
let mut symbols = Vec::new();
|
||||
for x in area.left()..=right {
|
||||
symbols.push(buffer[(x, area.top())].symbol().to_owned());
|
||||
symbols.push(buffer[(x, bottom)].symbol().to_owned());
|
||||
}
|
||||
for y in area.top().saturating_add(1)..bottom {
|
||||
symbols.push(buffer[(area.left(), y)].symbol().to_owned());
|
||||
symbols.push(buffer[(right, y)].symbol().to_owned());
|
||||
}
|
||||
symbols
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_have_explicit_layout_classes() {
|
||||
assert_eq!(layout_class(Rect::new(0, 0, 39, 20)), LayoutClass::TooSmall);
|
||||
@@ -1066,6 +1139,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_focus_changes_only_the_shared_accent_border_style() {
|
||||
let mut app = App::new();
|
||||
let sidebar = Rect::new(0, 0, 35, 17);
|
||||
let main = Rect::new(35, 0, 65, 17);
|
||||
let sidebar_focused = render_buffer(100, 20, &app, ColorCapability::Color);
|
||||
assert_eq!(sidebar_focused[(0, 0)].fg, ACCENT_COLOR);
|
||||
assert_eq!(sidebar_focused[(0, 0)].bg, Color::Reset);
|
||||
assert!(!sidebar_focused[(0, 0)].modifier.contains(Modifier::BOLD));
|
||||
assert_eq!(sidebar_focused[(35, 0)].fg, Color::Reset);
|
||||
assert!(!sidebar_focused[(35, 0)].modifier.contains(Modifier::BOLD));
|
||||
|
||||
app.dispatch(crate::action::Action::FocusNext);
|
||||
let main_focused = render_buffer(100, 20, &app, ColorCapability::Color);
|
||||
assert_eq!(main_focused[(0, 0)].fg, Color::Reset);
|
||||
assert!(!main_focused[(0, 0)].modifier.contains(Modifier::BOLD));
|
||||
assert_eq!(main_focused[(35, 0)].fg, ACCENT_COLOR);
|
||||
assert_eq!(main_focused[(35, 0)].bg, Color::Reset);
|
||||
assert!(!main_focused[(35, 0)].modifier.contains(Modifier::BOLD));
|
||||
assert_eq!(
|
||||
border_symbols(&sidebar_focused, sidebar),
|
||||
border_symbols(&main_focused, sidebar)
|
||||
);
|
||||
assert_eq!(
|
||||
border_symbols(&sidebar_focused, main),
|
||||
border_symbols(&main_focused, main)
|
||||
);
|
||||
|
||||
let main_monochrome = render_buffer(100, 20, &app, ColorCapability::Monochrome);
|
||||
assert_eq!(main_monochrome[(0, 0)].fg, Color::Reset);
|
||||
assert_eq!(main_monochrome[(35, 0)].fg, Color::Reset);
|
||||
assert!(!main_monochrome[(0, 0)].modifier.contains(Modifier::BOLD));
|
||||
assert!(main_monochrome[(35, 0)].modifier.contains(Modifier::BOLD));
|
||||
|
||||
app.dispatch(crate::action::Action::Help);
|
||||
let help = render_buffer(100, 20, &app, ColorCapability::Color);
|
||||
assert_eq!(help[(0, 0)].fg, ACCENT_COLOR);
|
||||
assert_eq!(help[(0, 0)].bg, Color::Reset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_size_message_is_clear() {
|
||||
let output = render(39, 8, &App::new());
|
||||
@@ -1647,7 +1760,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_and_active_surfaces_use_one_high_contrast_style() {
|
||||
fn selected_fields_status_and_results_keep_one_high_contrast_style() {
|
||||
let mut app = App::new();
|
||||
let document = fixture_document("otp/totp");
|
||||
let otp_field = document
|
||||
|
||||
Reference in New Issue
Block a user