Use accent border for TUI pane focus

This commit is contained in:
2026-08-10 19:33:58 +02:00
parent de5dea28ef
commit 8879df142d

View File

@@ -20,6 +20,7 @@ const MINIMUM_WIDTH: u16 = 40;
const MINIMUM_HEIGHT: u16 = 8; const MINIMUM_HEIGHT: u16 = 8;
const LARGE_OTP_HEIGHT: u16 = 8; const LARGE_OTP_HEIGHT: u16 = 8;
const LARGE_OTP_ROWS: usize = 5; const LARGE_OTP_ROWS: usize = 5;
const ACCENT_COLOR: Color = Color::Cyan;
const SELECTED_FOREGROUND: Color = Color::Black; const SELECTED_FOREGROUND: Color = Color::Black;
const SELECTED_BACKGROUND: Color = Color::White; 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) { 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 { if capability == ColorCapability::Monochrome {
for cell in &mut frame.buffer_mut().content { for cell in &mut frame.buffer_mut().content {
if cell.fg == SELECTED_FOREGROUND && cell.bg == SELECTED_BACKGROUND { 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(); let area = frame.area();
if layout_class(area) == LayoutClass::TooSmall { if layout_class(area) == LayoutClass::TooSmall {
frame.render_widget( frame.render_widget(
@@ -104,17 +105,17 @@ fn draw_inner(frame: &mut Frame, app: &App) {
Constraint::Length(1), Constraint::Length(1),
]) ])
.split(area); .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(status_line(app), rows[1]);
frame.render_widget(context_line(app), rows[2]); frame.render_widget(context_line(app), rows[2]);
frame.render_widget(prompt_line(app), rows[3]); 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 { if app.mode() == Mode::Locked {
frame.render_widget( frame.render_widget(
Paragraph::new("The password store is locked. Authentication is required.") 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 }), .wrap(Wrap { trim: true }),
area, area,
); );
@@ -143,7 +144,11 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
}; };
frame.render_widget( frame.render_widget(
Paragraph::new(text) 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 }), .wrap(Wrap { trim: false }),
area, area,
); );
@@ -152,7 +157,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
if let Some(uri) = app.uri_popup() { if let Some(uri) = app.uri_popup() {
frame.render_widget( frame.render_widget(
Paragraph::new(String::from_utf8_lossy(uri.expose()).into_owned()) 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 }), .wrap(Wrap { trim: false }),
area, area,
); );
@@ -163,7 +168,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
if let Some(help) = app.command_help() { if let Some(help) = app.command_help() {
frame.render_widget( frame.render_widget(
Paragraph::new(help) Paragraph::new(help)
.block(Block::bordered().title("Command help")) .block(pane_block("Command help", true, capability))
.wrap(Wrap { trim: false }), .wrap(Wrap { trim: false }),
area, area,
); );
@@ -195,10 +200,11 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
}; };
frame.render_widget( frame.render_widget(
Paragraph::new(lines) Paragraph::new(lines)
.block(Block::bordered().title(format!( .block(pane_block(
"Contextual help — {}", format!("Contextual help — {}", mode_title(app.help_context_mode())),
mode_title(app.help_context_mode()) true,
))) capability,
))
.wrap(Wrap { trim: false }), .wrap(Wrap { trim: false }),
area, area,
); );
@@ -210,7 +216,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
{ {
frame.render_widget( frame.render_widget(
Paragraph::new(workflow.rows().join("\n")) Paragraph::new(workflow.rows().join("\n"))
.block(Block::bordered().title(workflow.title())) .block(pane_block(workflow.title(), true, capability))
.wrap(Wrap { trim: false }), .wrap(Wrap { trim: false }),
area, area,
); );
@@ -240,7 +246,11 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
let panes = Layout::new(direction, constraints).split(area); let panes = Layout::new(direction, constraints).split(area);
frame.render_widget( frame.render_widget(
Paragraph::new(sidebar_lines(app)) 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 }), .wrap(Wrap { trim: false }),
panes[0], panes[0],
); );
@@ -255,6 +265,8 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
frame, frame,
app.otp_display().expect("large OTP was checked"), app.otp_display().expect("large OTP was checked"),
main_rows[0], main_rows[0],
app.focus() == PaneFocus::Main,
capability,
); );
let lines = viewer_lines(viewer, None); let lines = viewer_lines(viewer, None);
let scroll = viewer_scroll(viewer, &lines, main_rows[1]); 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( .block(pane_block(
mode_title(app.mode()), mode_title(app.mode()),
app.focus() == PaneFocus::Main, app.focus() == PaneFocus::Main,
capability,
)) ))
.wrap(Wrap { trim: false }), .wrap(Wrap { trim: false }),
main_rows[1], main_rows[1],
@@ -282,7 +295,13 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
(panes[1], None) (panes[1], None)
}; };
if let Some(details_area) = details_area { 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 lines = viewer_lines(viewer, None);
let scroll = viewer_scroll(viewer, &lines, details_area); let scroll = viewer_scroll(viewer, &lines, details_area);
frame.render_widget( frame.render_widget(
@@ -291,12 +310,13 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
.block(pane_block( .block(pane_block(
mode_title(app.mode()), mode_title(app.mode()),
app.focus() == PaneFocus::Main, app.focus() == PaneFocus::Main,
capability,
)) ))
.wrap(Wrap { trim: false }), .wrap(Wrap { trim: false }),
details_area, details_area,
); );
} else { } else {
render_compact_viewer(frame, app, viewer, display, otp_area); render_compact_viewer(frame, app, viewer, display, otp_area, capability);
} }
return; return;
} }
@@ -330,6 +350,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
main.block(pane_block( main.block(pane_block(
mode_title(app.mode()), mode_title(app.mode()),
app.focus() == PaneFocus::Main, app.focus() == PaneFocus::Main,
capability,
)) ))
.wrap(Wrap { trim: false }), .wrap(Wrap { trim: false }),
panes[1], panes[1],
@@ -374,7 +395,13 @@ fn matching_totp_display<'a>(
.then_some(display) .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) let glyphs = (0..LARGE_OTP_ROWS)
.map(|row| otp_ascii_row(display.code().expose(), row)) .map(|row| otp_ascii_row(display.code().expose(), row))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -394,21 +421,31 @@ fn render_large_otp(frame: &mut Frame, display: &crate::app::OtpDisplay, area: R
.collect::<Vec<_>>(); .collect::<Vec<_>>();
lines.push(Line::styled( lines.push(Line::styled(
countdown_bar(remaining, period, inner_width), countdown_bar(remaining, period, inner_width),
Style::default().fg(Color::Cyan), Style::default().fg(ACCENT_COLOR),
)); ));
frame.render_widget( frame.render_widget(
Paragraph::new(lines) Paragraph::new(lines)
.alignment(Alignment::Center) .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, 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( frame.render_widget(
Paragraph::new(compact_otp_line(display)) Paragraph::new(compact_otp_line(display))
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block(Block::bordered().title("TOTP code")), .block(pane_block("TOTP code", focused, capability)),
area, area,
); );
} }
@@ -433,9 +470,14 @@ fn render_compact_viewer(
viewer: &EntryViewer, viewer: &EntryViewer,
display: &crate::app::OtpDisplay, display: &crate::app::OtpDisplay,
area: Rect, area: Rect,
capability: ColorCapability,
) { ) {
frame.render_widget( 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, area,
); );
let inner = area.inner(Margin::new(1, 1)); 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 { let style = if focused {
selected_style() match capability {
ColorCapability::Color => Style::default().fg(ACCENT_COLOR),
ColorCapability::Monochrome => Style::default().add_modifier(Modifier::BOLD),
}
} else { } else {
Style::default() Style::default()
}; };
@@ -672,7 +721,7 @@ fn viewer_lines<'a>(
Span::styled( Span::styled(
format!("{label}: "), format!("{label}: "),
Style::default() Style::default()
.fg(Color::Cyan) .fg(ACCENT_COLOR)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
value, value,
@@ -752,7 +801,7 @@ fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
let mut spans = vec![Span::styled( let mut spans = vec![Span::styled(
format!("#{:02} {label}: ", index + 1), format!("#{:02} {label}: ", index + 1),
Style::default() Style::default()
.fg(Color::Cyan) .fg(ACCENT_COLOR)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)]; )];
if let Ok(value) = std::str::from_utf8(contents) { if let Ok(value) = std::str::from_utf8(contents) {
@@ -801,7 +850,7 @@ fn grep_lines(view: &crate::search::GrepView) -> Vec<Line<'_>> {
} else { } else {
Line::from(entry_line).style( Line::from(entry_line).style(
Style::default() Style::default()
.fg(Color::Cyan) .fg(ACCENT_COLOR)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
) )
}); });
@@ -831,7 +880,7 @@ fn git_lines(view: &crate::app::GitView) -> Vec<Line<'_>> {
Line::styled( Line::styled(
view.message(), view.message(),
Style::default() Style::default()
.fg(Color::Cyan) .fg(ACCENT_COLOR)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Line::raw(format!("repository: {}", snapshot.root().display())), Line::raw(format!("repository: {}", snapshot.root().display())),
@@ -982,7 +1031,7 @@ fn prompt_line(app: &App) -> Paragraph<'static> {
mod tests { mod tests {
use std::ffi::OsStr; use std::ffi::OsStr;
use ratatui::{Terminal, backend::TestBackend}; use ratatui::{Terminal, backend::TestBackend, buffer::Buffer};
use super::*; use super::*;
use crate::app::Transition; use crate::app::Transition;
@@ -1006,6 +1055,30 @@ mod tests {
.join("\n") .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] #[test]
fn sizes_have_explicit_layout_classes() { fn sizes_have_explicit_layout_classes() {
assert_eq!(layout_class(Rect::new(0, 0, 39, 20)), LayoutClass::TooSmall); 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] #[test]
fn minimum_size_message_is_clear() { fn minimum_size_message_is_clear() {
let output = render(39, 8, &App::new()); let output = render(39, 8, &App::new());
@@ -1647,7 +1760,7 @@ mod tests {
} }
#[test] #[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 mut app = App::new();
let document = fixture_document("otp/totp"); let document = fixture_document("otp/totp");
let otp_field = document let otp_field = document