diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index 8cc4cb7..e9c6705 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -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::>(); @@ -394,21 +421,31 @@ fn render_large_otp(frame: &mut Frame, display: &crate::app::OtpDisplay, area: R .collect::>(); 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>, + 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> { 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> { } 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::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 { + 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