diff --git a/PROJECT.md b/PROJECT.md index 25e90f2..a23f6a7 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -76,7 +76,13 @@ Research URLs: - [x] Responsive wide/compact rendering and mouse row/command/view interaction. - [x] Application Preferences persisted as typed TOML at `~/.config/rogue-agenda/preferences.toml`: theme, command bar, rule/return/item - markers, autosave, confirmations, date/time display, and number separators. + markers, autosave, confirmations, date/time display, and number separators; + predefined values use Space-open keyboard/mouse choice popovers. +- [x] Seven bundled palettes: Lotus-inspired Classic, Mono, Amber, Nord, + Solarized Dark, Solarized Light, and Catppuccin Mocha. The four modern themes + enforce at least 7:1 selected-row contrast without changing the original three. +- [x] Dedicated, contrast-checked canvas-heading colors across all themes for + column titles, sections, Datebook dates, aggregates, and empty-view messages. - [x] Transactional per-document settings embedded in `.agnd`: description, backup-on-open, Trash and completed-item policies, automatic filing, numeric date order, week boundary, natural-language named times, and note tab width. diff --git a/README.md b/README.md index a04e615..36b24d9 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,9 @@ or build failure stops the gate. | `?` or `F1` | Help | | `q` | Quit | -Inside forms, `Tab` moves between fields, `Enter` accepts, and `Esc` cancels. +Inside forms, `Tab` moves between fields, `Space` opens a choice popover for +predefined settings, `Enter` accepts, and `Esc` cancels. Choice popovers support +arrow keys, `j`/`k`, `Enter`/`Space`, and mouse clicks. In the `F5` note editor, `Enter` inserts a line, arrows/Home/End move the cursor, and `Ctrl-S` saves. Mouse clicks select rows, switch views, and activate the bottom command strip. @@ -114,13 +116,19 @@ bottom command strip. Press `F10`, then `p` for application-wide Preferences. Rogue Agenda saves these as typed TOML at `~/.config/rogue-agenda/preferences.toml`. The screen controls: -- `classic`, `mono`, and `amber` themes +- `classic`, `mono`, `amber`, `nord`, `solarized-dark`, `solarized-light`, and + `catppuccin-mocha` themes - function-key command bar and category-rule detail visibility - carriage-return and item markers - autosave checkpoint interval and destructive-action confirmations - ISO, US, European, or long date display and 12/24-hour time - decimal and thousands separators +Nord, both Solarized variants, and Catppuccin Mocha use dedicated selected-row +foreground/background pairs with at least 7:1 relative-luminance contrast. Every +theme also defines a contrast-checked canvas-heading color for column titles, +sections, dates, aggregate rows, and empty-view messages. + The file is created when Preferences are first saved. It can also be edited with a text editor while Rogue Agenda is closed; unknown keys and invalid values are reported instead of silently ignored. @@ -133,6 +141,10 @@ named times, and note-editor tab width. `this week` and `next week` honor the chosen week boundary; `morning`, `afternoon`, and `evening` honor their configured times. +Enumerated preferences and document settings use choice popovers, so values such +as themes, yes/no switches, policies, date formats, and week boundaries cannot be +mistyped. Free-form values such as times and marker characters remain text fields. + Use `F10`, then `b` to create a consistent sibling backup named `document.agnd.bak`, or enable backup-on-open. Use `F10`, then `t` to empty Trash. Permanent operations request confirmation unless that preference is disabled. diff --git a/src/app.rs b/src/app.rs index 3371ffc..014e688 100644 --- a/src/app.rs +++ b/src/app.rs @@ -55,6 +55,19 @@ pub struct FormState { pub field: usize, } +#[derive(Debug, Clone)] +pub struct ChoiceOption { + pub label: String, + pub value: String, +} + +#[derive(Debug, Clone)] +pub struct ChoiceState { + pub form: FormState, + pub options: Vec, + pub selected: usize, +} + #[derive(Debug, Clone)] pub enum Mode { Normal, @@ -75,6 +88,7 @@ pub enum Mode { prompt: String, action: ConfirmAction, }, + Choice(ChoiceState), } #[derive(Debug, Clone)] @@ -107,6 +121,7 @@ pub struct App { pub item_rows: Vec<(Rect, i64)>, pub command_regions: Vec, pub view_regions: Vec, + pub choice_regions: Vec, last_autosave: Instant, current_day: NaiveDate, } @@ -158,6 +173,7 @@ impl App { item_rows: vec![], command_regions: vec![], view_regions: vec![], + choice_regions: vec![], last_autosave: Instant::now(), current_day: Local::now().date_naive(), }; @@ -211,6 +227,7 @@ impl App { Mode::CategoryManager => self.handle_category_manager(key), Mode::Menu => self.handle_menu(key), Mode::Confirm { .. } => self.handle_confirm(key), + Mode::Choice(_) => self.handle_choice(key), } } @@ -750,13 +767,13 @@ impl App { yes_no(self.preferences.show_command_bar), yes_no(self.preferences.show_rule_info), yes_no(self.preferences.show_carriage_returns), - self.preferences.item_marker.clone(), + display_space(&self.preferences.item_marker), self.preferences.autosave_minutes.to_string(), yes_no(self.preferences.confirm_destructive), self.preferences.date_format.clone(), yes_no(self.preferences.clock_24h), self.preferences.decimal_separator.clone(), - self.preferences.thousands_separator.clone(), + display_space(&self.preferences.thousands_separator), ], field: 0, }); @@ -866,6 +883,7 @@ impl App { let Mode::Form(mut form) = self.mode.clone() else { return Ok(()); }; + let choices = form_choices(&form.kind, form.field); match key.code { KeyCode::Esc => { self.mode = match form.kind { @@ -880,7 +898,22 @@ impl App { form.field = (form.field + form.values.len() - 1) % form.values.len() } KeyCode::Backspace => { - form.values[form.field].pop(); + if choices.is_none() { + form.values[form.field].pop(); + } + } + KeyCode::Char(' ') if choices.is_some() => { + let options = choices.unwrap_or_default(); + let selected = options + .iter() + .position(|option| option.value == form.values[form.field]) + .unwrap_or(0); + self.mode = Mode::Choice(ChoiceState { + form, + options, + selected, + }); + return Ok(()); } KeyCode::Enter => { match form.kind { @@ -932,7 +965,7 @@ impl App { show_command_bar: parse_yes_no(&form.values[1])?, show_rule_info: parse_yes_no(&form.values[2])?, show_carriage_returns: parse_yes_no(&form.values[3])?, - item_marker: form.values[4].clone(), + item_marker: decode_space(&form.values[4]), autosave_minutes: form.values[5] .parse() .context("autosave interval must be a number")?, @@ -940,7 +973,7 @@ impl App { date_format: form.values[7].trim().to_lowercase(), clock_24h: parse_yes_no(&form.values[8])?, decimal_separator: form.values[9].clone(), - thousands_separator: form.values[10].clone(), + thousands_separator: decode_space(&form.values[10]), }; preferences.save_to(&self.preferences_path)?; self.preferences = preferences; @@ -975,7 +1008,9 @@ impl App { } return Ok(()); } - KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Char(c) + if choices.is_none() && !key.modifiers.contains(KeyModifiers::CONTROL) => + { form.values[form.field].push(c) } _ => {} @@ -984,6 +1019,40 @@ impl App { Ok(()) } + fn handle_choice(&mut self, key: KeyEvent) -> Result<()> { + let Mode::Choice(mut choice) = self.mode.clone() else { + return Ok(()); + }; + match key.code { + KeyCode::Esc => self.mode = Mode::Form(choice.form), + KeyCode::Up | KeyCode::Char('k') => { + choice.selected = choice.selected.saturating_sub(1); + self.mode = Mode::Choice(choice); + } + KeyCode::Down | KeyCode::Char('j') => { + choice.selected = (choice.selected + 1).min(choice.options.len().saturating_sub(1)); + self.mode = Mode::Choice(choice); + } + KeyCode::Home => { + choice.selected = 0; + self.mode = Mode::Choice(choice); + } + KeyCode::End => { + choice.selected = choice.options.len().saturating_sub(1); + self.mode = Mode::Choice(choice); + } + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(option) = choice.options.get(choice.selected) { + let field = choice.form.field; + choice.form.values[field] = option.value.clone(); + } + self.mode = Mode::Form(choice.form); + } + _ => self.mode = Mode::Choice(choice), + } + Ok(()) + } + fn open_dependencies(&mut self) -> Result<()> { let Some(item_id) = self.selected_item().map(|i| i.id) else { return Ok(()); @@ -1044,6 +1113,19 @@ impl App { return Ok(()); } let (x, y) = (event.column, event.row); + if let Mode::Choice(mut choice) = self.mode.clone() { + if let Some(index) = self + .choice_regions + .iter() + .position(|region| inside(*region, x, y)) + && let Some(option) = choice.options.get(index) + { + let field = choice.form.field; + choice.form.values[field] = option.value.clone(); + self.mode = Mode::Form(choice.form); + } + return Ok(()); + } if let Some((_, id)) = self.item_rows.iter().find(|(r, _)| inside(*r, x, y)) { if let Some(pos) = self.items.iter().position(|i| i.id == *id) { self.selected = pos; @@ -1082,6 +1164,74 @@ fn parse_yes_no(value: &str) -> Result { } } +fn display_space(value: &str) -> String { + if value == " " { "space" } else { value }.into() +} + +fn decode_space(value: &str) -> String { + if value.eq_ignore_ascii_case("space") { + " ".into() + } else { + value.into() + } +} + +pub(crate) fn form_choices(kind: &FormKind, field: usize) -> Option> { + let entries: &[(&str, &str)] = match kind { + FormKind::Preferences => match field { + 0 => &[ + ("Classic — Lotus-inspired blue", "classic"), + ("Mono — grayscale", "mono"), + ("Amber — warm dark", "amber"), + ("Nord — arctic blue dark", "nord"), + ("Solarized Dark — balanced low contrast", "solarized-dark"), + ("Solarized Light — warm paper light", "solarized-light"), + ("Catppuccin Mocha — cozy pastel dark", "catppuccin-mocha"), + ], + 1..=3 | 6 | 8 => YES_NO_CHOICES, + 7 => &[ + ("ISO — 2026-08-16", "iso"), + ("US — 08/16/2026", "us"), + ("European — 16/08/2026", "european"), + ("Long — 16 Aug 2026", "long"), + ], + _ => return None, + }, + FormKind::DocumentSettings => match field { + 1 | 4 => YES_NO_CHOICES, + 2 => &[ + ("On demand", "on-demand"), + ("On close", "on-close"), + ("End of day", "end-of-day"), + ("Immediately", "immediate"), + ], + 3 => &[ + ("Keep completed items", "keep"), + ("Move them to Trash", "trash"), + ], + 5 => &[ + ("Year / month / day", "ymd"), + ("Month / day / year", "mdy"), + ("Day / month / year", "dmy"), + ], + 6 => &[("Monday", "monday"), ("Sunday", "sunday")], + _ => return None, + }, + FormKind::Category(_) | FormKind::View(_) => return None, + }; + Some( + entries + .iter() + .map(|(label, value)| ChoiceOption { + label: (*label).into(), + value: (*value).into(), + }) + .collect(), + ) +} + +const YES_NO_CHOICES: &[(&str, &str)] = &[("Yes", "yes"), ("No", "no")]; + fn normalize_date(db: &Database, value: &str) -> Result> { let s = value.trim(); if s.is_empty() { diff --git a/src/preferences.rs b/src/preferences.rs index 40678c0..0cdfe57 100644 --- a/src/preferences.rs +++ b/src/preferences.rs @@ -88,8 +88,20 @@ impl AppPreferences { } pub fn validate(&self) -> Result<()> { - if !matches!(self.theme.as_str(), "classic" | "mono" | "amber") { - bail!("theme must be classic, mono, or amber") + if !matches!( + self.theme.as_str(), + "classic" + | "mono" + | "amber" + | "nord" + | "solarized-dark" + | "solarized-light" + | "catppuccin-mocha" + ) { + bail!( + "theme must be classic, mono, amber, nord, solarized-dark, \ + solarized-light, or catppuccin-mocha" + ) } if !matches!( self.date_format.as_str(), @@ -151,4 +163,23 @@ mod tests { }; assert!(invalid.validate().is_err()); } + + #[test] + fn preferences_accept_all_bundled_themes() { + for theme in [ + "classic", + "mono", + "amber", + "nord", + "solarized-dark", + "solarized-light", + "catppuccin-mocha", + ] { + let preferences = AppPreferences { + theme: theme.into(), + ..Default::default() + }; + preferences.validate().unwrap(); + } + } } diff --git a/src/ui.rs b/src/ui.rs index be3e91a..5e7ce06 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,7 +10,7 @@ use ratatui::{ }; use crate::{ - app::{App, FormKind, Mode}, + app::{App, FormKind, Mode, form_choices}, filter, model::{Item, ViewColumn}, parser::format_when, @@ -21,7 +21,9 @@ struct Palette { primary: Color, accent: Color, canvas: Color, + canvas_heading: Color, selection: Color, + selection_foreground: Color, foreground: Color, } @@ -31,26 +33,76 @@ fn palette(theme: &str) -> Palette { primary: Color::Black, accent: Color::White, canvas: Color::Gray, + canvas_heading: Color::Black, selection: Color::DarkGray, + selection_foreground: rgb(0xffffff), foreground: Color::Black, }, "amber" => Palette { primary: Color::Black, accent: Color::Rgb(255, 191, 0), canvas: Color::Rgb(32, 24, 0), + canvas_heading: Color::Rgb(255, 191, 0), selection: Color::Rgb(112, 56, 0), + selection_foreground: rgb(0xffffff), foreground: Color::Rgb(255, 191, 0), }, + "nord" => Palette { + primary: rgb(0x2e3440), + accent: rgb(0x88c0d0), + canvas: rgb(0x3b4252), + canvas_heading: rgb(0xeceff4), + selection: rgb(0xeceff4), + selection_foreground: rgb(0x2e3440), + foreground: rgb(0xeceff4), + }, + "solarized-dark" => Palette { + primary: rgb(0x002b36), + accent: rgb(0x2aa198), + canvas: rgb(0x073642), + canvas_heading: rgb(0xfdf6e3), + selection: rgb(0xfdf6e3), + selection_foreground: rgb(0x002b36), + foreground: rgb(0x839496), + }, + "solarized-light" => Palette { + primary: rgb(0x586e75), + accent: rgb(0xeee8d5), + canvas: rgb(0xfdf6e3), + canvas_heading: rgb(0x002b36), + selection: rgb(0x002b36), + selection_foreground: rgb(0xfdf6e3), + foreground: rgb(0x657b83), + }, + "catppuccin-mocha" => Palette { + primary: rgb(0x181825), + accent: rgb(0xcba6f7), + canvas: rgb(0x1e1e2e), + canvas_heading: rgb(0xcdd6f4), + selection: rgb(0xcba6f7), + selection_foreground: rgb(0x11111b), + foreground: rgb(0xcdd6f4), + }, _ => Palette { primary: Color::Rgb(0, 0, 128), accent: Color::Cyan, canvas: Color::Rgb(192, 192, 192), + canvas_heading: Color::Rgb(0, 0, 128), selection: Color::Rgb(128, 0, 0), + selection_foreground: rgb(0xffffff), foreground: Color::Black, }, } } +const fn rgb(value: u32) -> Color { + Color::Rgb( + ((value >> 16) & 0xff) as u8, + ((value >> 8) & 0xff) as u8, + (value & 0xff) as u8, + ) +} + pub fn draw(frame: &mut Frame<'_>, app: &mut App) { let colors = palette(&app.preferences.theme); frame.render_widget( @@ -180,7 +232,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) { } else { " No items match this search. Press Esc to clear it." }) - .style(Style::default().fg(colors.primary).bg(colors.canvas)), + .style(Style::default().fg(colors.canvas_heading).bg(colors.canvas)), area, ); return; @@ -260,7 +312,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) { DisplayLine::Section(heading) => lines.push(Line::from(Span::styled( format!("── {heading} "), Style::default() - .fg(colors.primary) + .fg(colors.canvas_heading) .bg(colors.canvas) .add_modifier(Modifier::BOLD), ))), @@ -268,7 +320,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) { columns.iter().map(|c| c.heading.clone()).collect(), &widths, Style::default() - .fg(colors.primary) + .fg(colors.canvas_heading) .bg(colors.canvas) .add_modifier(Modifier::BOLD), )), @@ -276,7 +328,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) { let item = &app.items[*index]; let style = if *index == app.selected { Style::default() - .fg(Color::White) + .fg(colors.selection_foreground) .bg(colors.selection) .add_modifier(Modifier::BOLD) } else { @@ -304,7 +356,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) { cells, &widths, Style::default() - .fg(colors.primary) + .fg(colors.canvas_heading) .bg(colors.canvas) .add_modifier(Modifier::BOLD), )); @@ -440,7 +492,7 @@ fn draw_datebook(frame: &mut Frame<'_>, app: &mut App, area: Rect) { lines.push(Line::from(Span::styled( format!("── {date} ──"), Style::default() - .fg(colors.primary) + .fg(colors.canvas_heading) .bg(colors.canvas) .add_modifier(Modifier::BOLD), ))); @@ -448,7 +500,9 @@ fn draw_datebook(frame: &mut Frame<'_>, app: &mut App, area: Rect) { } let selected = app.items.get(app.selected).map(|s| s.id) == Some(i.id); let style = if selected { - Style::default().fg(Color::White).bg(colors.selection) + Style::default() + .fg(colors.selection_foreground) + .bg(colors.selection) } else { Style::default().fg(colors.foreground).bg(colors.canvas) }; @@ -558,8 +612,9 @@ fn draw_commands(frame: &mut Frame<'_>, app: &mut App, area: Rect) { } } -fn draw_overlay(frame: &mut Frame<'_>, app: &App) { +fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) { let colors = palette(&app.preferences.theme); + app.choice_regions.clear(); match &app.mode { Mode::Normal => {} Mode::Help => { @@ -659,11 +714,17 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { )), Line::from(Span::styled( format!("> {}", p.values[i]), - Style::default().fg(Color::White).bg(if i == p.field { - colors.selection - } else { - colors.primary - }), + Style::default() + .fg(if i == p.field { + colors.selection_foreground + } else { + Color::White + }) + .bg(if i == p.field { + colors.selection + } else { + colors.primary + }), )), ] }) @@ -712,7 +773,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { FormKind::Preferences => ( "Preferences (~/.config/rogue-agenda/preferences.toml)", vec![ - "Theme (classic/mono/amber)", + "Theme", "Show command bar (yes/no)", "Show rule info (yes/no)", "Show return markers (yes/no)", @@ -768,11 +829,16 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { .iter() .enumerate() .map(|(i, name)| { + let indicator = if form_choices(&form.kind, i).is_some() { + "▾" + } else { + " " + }; Line::from(Span::styled( - format!("{name:<50} {}", form.values[i]), + format!("{indicator} {name:<48} {}", form.values[i]), Style::default() .fg(if i == form.field { - Color::White + colors.selection_foreground } else { colors.accent }) @@ -787,7 +853,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { Line::from(""), Line::from(hint), Line::from(preview), - Line::from("Tab fields · Enter save · Esc back"), + Line::from("Space opens choices · Tab fields · Enter save · Esc back"), ]) .collect::>(); popup( @@ -798,6 +864,49 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { colors, ); } + Mode::Choice(choice) => { + let items = choice + .options + .iter() + .map(|option| ListItem::new(option.label.as_str())) + .collect::>(); + let mut state = ListState::default().with_selected(Some(choice.selected)); + let area = center( + frame.area(), + 58, + (choice.options.len() as u16 + 2).clamp(5, 18), + ); + let inner = area.inner(Margin { + horizontal: 1, + vertical: 1, + }); + app.choice_regions + .extend( + (0..choice.options.len().min(inner.height as usize)).map(|index| Rect { + x: inner.x, + y: inner.y + index as u16, + width: inner.width, + height: 1, + }), + ); + frame.render_widget(Clear, area); + frame.render_stateful_widget( + List::new(items) + .block( + Block::bordered() + .title(" Choose (↑↓ · Enter/Space · Esc) ") + .style(Style::default().fg(Color::White).bg(colors.primary)), + ) + .highlight_style( + Style::default() + .fg(colors.selection_foreground) + .bg(colors.selection) + .add_modifier(Modifier::BOLD), + ), + area, + &mut state, + ); + } Mode::Dependencies { item_id, choices, @@ -830,7 +939,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { ) .highlight_style( Style::default() - .fg(Color::White) + .fg(colors.selection_foreground) .bg(colors.selection) .add_modifier(Modifier::BOLD), ), @@ -872,7 +981,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { ) .highlight_style( Style::default() - .fg(Color::White) + .fg(colors.selection_foreground) .bg(colors.selection) .add_modifier(Modifier::BOLD), ), @@ -992,7 +1101,7 @@ fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: boo ) .highlight_style( Style::default() - .fg(Color::White) + .fg(colors.selection_foreground) .bg(colors.selection) .add_modifier(Modifier::BOLD), ), @@ -1034,7 +1143,9 @@ fn center(area: Rect, width: u16, height: u16) -> Rect { mod tests { use super::*; use crate::{app::App, db::Database, model::ItemChanges, preferences::AppPreferences}; - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, + }; use ratatui::{ Terminal, backend::{Backend, TestBackend}, @@ -1261,6 +1372,149 @@ mod tests { )); } + #[test] + fn predefined_preferences_open_choice_popovers_for_keyboard_and_mouse() { + let directory = tempdir().unwrap(); + let path = directory.path().join("choice-popovers.agnd"); + let db = Database::open(&path).unwrap(); + let mut app = App::new_with_preferences( + db, + path, + AppPreferences::default(), + directory.path().join("preferences.toml"), + ) + .unwrap(); + + app.handle_key(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE)) + .unwrap(); + app.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)) + .unwrap(); + app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)) + .unwrap(); + assert!(matches!(app.mode, Mode::Choice(_))); + + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| draw(frame, &mut app)).unwrap(); + let screen = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(screen.contains("Classic — Lotus-inspired blue")); + assert!(screen.contains("Amber — warm dark")); + assert!(screen.contains("Nord — arctic blue dark")); + assert!(screen.contains("Solarized Dark — balanced low contrast")); + assert!(screen.contains("Solarized Light — warm paper light")); + assert!(screen.contains("Catppuccin Mocha — cozy pastel dark")); + + app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .unwrap(); + app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)) + .unwrap(); + let Mode::Form(form) = &app.mode else { + panic!("choice selection did not return to the form"); + }; + assert_eq!(form.values[0], "mono"); + + app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)) + .unwrap(); + terminal.draw(|frame| draw(frame, &mut app)).unwrap(); + let amber_region = app.choice_regions[2]; + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: amber_region.x, + row: amber_region.y, + modifiers: KeyModifiers::NONE, + }) + .unwrap(); + let Mode::Form(form) = &app.mode else { + panic!("mouse choice did not return to the form"); + }; + assert_eq!(form.values[0], "amber"); + + assert_eq!(palette("nord").primary, Color::Rgb(46, 52, 64)); + assert_eq!(palette("solarized-dark").canvas, Color::Rgb(7, 54, 66)); + assert_eq!(palette("solarized-light").canvas, Color::Rgb(253, 246, 227)); + assert_eq!( + palette("catppuccin-mocha").foreground, + Color::Rgb(205, 214, 244) + ); + + let trash_choices = form_choices(&FormKind::DocumentSettings, 2).unwrap(); + assert_eq!( + trash_choices + .iter() + .map(|choice| choice.value.as_str()) + .collect::>(), + ["on-demand", "on-close", "end-of-day", "immediate"] + ); + } + + #[test] + fn added_themes_keep_selected_rows_at_high_contrast() { + for theme in [ + "nord", + "solarized-dark", + "solarized-light", + "catppuccin-mocha", + ] { + let colors = palette(theme); + let ratio = contrast_ratio(colors.selection_foreground, colors.selection); + assert!( + ratio >= 7.0, + "{theme} selected-row contrast was only {ratio:.2}:1" + ); + } + } + + #[test] + fn every_theme_keeps_canvas_headings_readable() { + for theme in [ + "classic", + "mono", + "amber", + "nord", + "solarized-dark", + "solarized-light", + "catppuccin-mocha", + ] { + let colors = palette(theme); + let ratio = contrast_ratio(colors.canvas_heading, colors.canvas); + assert!( + ratio >= 4.5, + "{theme} canvas-heading contrast was only {ratio:.2}:1" + ); + } + } + + fn contrast_ratio(first: Color, second: Color) -> f64 { + let first = relative_luminance(first); + let second = relative_luminance(second); + (first.max(second) + 0.05) / (first.min(second) + 0.05) + } + + fn relative_luminance(color: Color) -> f64 { + let (red, green, blue) = match color { + Color::Black => (0, 0, 0), + Color::White => (255, 255, 255), + Color::Gray => (128, 128, 128), + Color::Rgb(red, green, blue) => (red, green, blue), + _ => panic!("contrast test requires RGB, black, white, or gray"), + }; + let linear = |component: u8| { + let component = f64::from(component) / 255.0; + if component <= 0.04045 { + component / 12.92 + } else { + ((component + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue) + } + #[test] fn document_editor_and_confirmation_use_saved_settings() { let directory = tempdir().unwrap();