better prefs and more themes

This commit is contained in:
Hermes Agent
2026-08-16 20:33:02 +00:00
parent f53ed36f31
commit 93fb3fcdb1
5 changed files with 486 additions and 33 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<ChoiceOption>,
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<Rect>,
pub view_regions: Vec<Rect>,
pub choice_regions: Vec<Rect>,
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<bool> {
}
}
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<Vec<ChoiceOption>> {
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<Option<String>> {
let s = value.trim();
if s.is_empty() {

View File

@@ -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();
}
}
}

298
src/ui.rs
View File

@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<String>();
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::<Vec<_>>(),
["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();