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] Responsive wide/compact rendering and mouse row/command/view interaction.
- [x] Application Preferences persisted as typed TOML at - [x] Application Preferences persisted as typed TOML at
`~/.config/rogue-agenda/preferences.toml`: theme, command bar, rule/return/item `~/.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, - [x] Transactional per-document settings embedded in `.agnd`: description,
backup-on-open, Trash and completed-item policies, automatic filing, numeric backup-on-open, Trash and completed-item policies, automatic filing, numeric
date order, week boundary, natural-language named times, and note tab width. 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 | | `?` or `F1` | Help |
| `q` | Quit | | `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, 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 and `Ctrl-S` saves. Mouse clicks select rows, switch views, and activate the
bottom command strip. bottom command strip.
@@ -114,13 +116,19 @@ bottom command strip.
Press `F10`, then `p` for application-wide Preferences. Rogue Agenda saves these Press `F10`, then `p` for application-wide Preferences. Rogue Agenda saves these
as typed TOML at `~/.config/rogue-agenda/preferences.toml`. The screen controls: 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 - function-key command bar and category-rule detail visibility
- carriage-return and item markers - carriage-return and item markers
- autosave checkpoint interval and destructive-action confirmations - autosave checkpoint interval and destructive-action confirmations
- ISO, US, European, or long date display and 12/24-hour time - ISO, US, European, or long date display and 12/24-hour time
- decimal and thousands separators - 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 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 a text editor while Rogue Agenda is closed; unknown keys and invalid values are
reported instead of silently ignored. 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 chosen week boundary; `morning`, `afternoon`, and `evening` honor their configured
times. 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 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. `document.agnd.bak`, or enable backup-on-open. Use `F10`, then `t` to empty Trash.
Permanent operations request confirmation unless that preference is disabled. Permanent operations request confirmation unless that preference is disabled.

View File

@@ -55,6 +55,19 @@ pub struct FormState {
pub field: usize, 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)] #[derive(Debug, Clone)]
pub enum Mode { pub enum Mode {
Normal, Normal,
@@ -75,6 +88,7 @@ pub enum Mode {
prompt: String, prompt: String,
action: ConfirmAction, action: ConfirmAction,
}, },
Choice(ChoiceState),
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -107,6 +121,7 @@ pub struct App {
pub item_rows: Vec<(Rect, i64)>, pub item_rows: Vec<(Rect, i64)>,
pub command_regions: Vec<Rect>, pub command_regions: Vec<Rect>,
pub view_regions: Vec<Rect>, pub view_regions: Vec<Rect>,
pub choice_regions: Vec<Rect>,
last_autosave: Instant, last_autosave: Instant,
current_day: NaiveDate, current_day: NaiveDate,
} }
@@ -158,6 +173,7 @@ impl App {
item_rows: vec![], item_rows: vec![],
command_regions: vec![], command_regions: vec![],
view_regions: vec![], view_regions: vec![],
choice_regions: vec![],
last_autosave: Instant::now(), last_autosave: Instant::now(),
current_day: Local::now().date_naive(), current_day: Local::now().date_naive(),
}; };
@@ -211,6 +227,7 @@ impl App {
Mode::CategoryManager => self.handle_category_manager(key), Mode::CategoryManager => self.handle_category_manager(key),
Mode::Menu => self.handle_menu(key), Mode::Menu => self.handle_menu(key),
Mode::Confirm { .. } => self.handle_confirm(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_command_bar),
yes_no(self.preferences.show_rule_info), yes_no(self.preferences.show_rule_info),
yes_no(self.preferences.show_carriage_returns), yes_no(self.preferences.show_carriage_returns),
self.preferences.item_marker.clone(), display_space(&self.preferences.item_marker),
self.preferences.autosave_minutes.to_string(), self.preferences.autosave_minutes.to_string(),
yes_no(self.preferences.confirm_destructive), yes_no(self.preferences.confirm_destructive),
self.preferences.date_format.clone(), self.preferences.date_format.clone(),
yes_no(self.preferences.clock_24h), yes_no(self.preferences.clock_24h),
self.preferences.decimal_separator.clone(), self.preferences.decimal_separator.clone(),
self.preferences.thousands_separator.clone(), display_space(&self.preferences.thousands_separator),
], ],
field: 0, field: 0,
}); });
@@ -866,6 +883,7 @@ impl App {
let Mode::Form(mut form) = self.mode.clone() else { let Mode::Form(mut form) = self.mode.clone() else {
return Ok(()); return Ok(());
}; };
let choices = form_choices(&form.kind, form.field);
match key.code { match key.code {
KeyCode::Esc => { KeyCode::Esc => {
self.mode = match form.kind { self.mode = match form.kind {
@@ -880,7 +898,22 @@ impl App {
form.field = (form.field + form.values.len() - 1) % form.values.len() form.field = (form.field + form.values.len() - 1) % form.values.len()
} }
KeyCode::Backspace => { 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 => { KeyCode::Enter => {
match form.kind { match form.kind {
@@ -932,7 +965,7 @@ impl App {
show_command_bar: parse_yes_no(&form.values[1])?, show_command_bar: parse_yes_no(&form.values[1])?,
show_rule_info: parse_yes_no(&form.values[2])?, show_rule_info: parse_yes_no(&form.values[2])?,
show_carriage_returns: parse_yes_no(&form.values[3])?, 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] autosave_minutes: form.values[5]
.parse() .parse()
.context("autosave interval must be a number")?, .context("autosave interval must be a number")?,
@@ -940,7 +973,7 @@ impl App {
date_format: form.values[7].trim().to_lowercase(), date_format: form.values[7].trim().to_lowercase(),
clock_24h: parse_yes_no(&form.values[8])?, clock_24h: parse_yes_no(&form.values[8])?,
decimal_separator: form.values[9].clone(), 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)?; preferences.save_to(&self.preferences_path)?;
self.preferences = preferences; self.preferences = preferences;
@@ -975,7 +1008,9 @@ impl App {
} }
return Ok(()); 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) form.values[form.field].push(c)
} }
_ => {} _ => {}
@@ -984,6 +1019,40 @@ impl App {
Ok(()) 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<()> { fn open_dependencies(&mut self) -> Result<()> {
let Some(item_id) = self.selected_item().map(|i| i.id) else { let Some(item_id) = self.selected_item().map(|i| i.id) else {
return Ok(()); return Ok(());
@@ -1044,6 +1113,19 @@ impl App {
return Ok(()); return Ok(());
} }
let (x, y) = (event.column, event.row); 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((_, id)) = self.item_rows.iter().find(|(r, _)| inside(*r, x, y)) {
if let Some(pos) = self.items.iter().position(|i| i.id == *id) { if let Some(pos) = self.items.iter().position(|i| i.id == *id) {
self.selected = pos; 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>> { fn normalize_date(db: &Database, value: &str) -> Result<Option<String>> {
let s = value.trim(); let s = value.trim();
if s.is_empty() { if s.is_empty() {

View File

@@ -88,8 +88,20 @@ impl AppPreferences {
} }
pub fn validate(&self) -> Result<()> { pub fn validate(&self) -> Result<()> {
if !matches!(self.theme.as_str(), "classic" | "mono" | "amber") { if !matches!(
bail!("theme must be classic, mono, or amber") 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!( if !matches!(
self.date_format.as_str(), self.date_format.as_str(),
@@ -151,4 +163,23 @@ mod tests {
}; };
assert!(invalid.validate().is_err()); 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::{ use crate::{
app::{App, FormKind, Mode}, app::{App, FormKind, Mode, form_choices},
filter, filter,
model::{Item, ViewColumn}, model::{Item, ViewColumn},
parser::format_when, parser::format_when,
@@ -21,7 +21,9 @@ struct Palette {
primary: Color, primary: Color,
accent: Color, accent: Color,
canvas: Color, canvas: Color,
canvas_heading: Color,
selection: Color, selection: Color,
selection_foreground: Color,
foreground: Color, foreground: Color,
} }
@@ -31,26 +33,76 @@ fn palette(theme: &str) -> Palette {
primary: Color::Black, primary: Color::Black,
accent: Color::White, accent: Color::White,
canvas: Color::Gray, canvas: Color::Gray,
canvas_heading: Color::Black,
selection: Color::DarkGray, selection: Color::DarkGray,
selection_foreground: rgb(0xffffff),
foreground: Color::Black, foreground: Color::Black,
}, },
"amber" => Palette { "amber" => Palette {
primary: Color::Black, primary: Color::Black,
accent: Color::Rgb(255, 191, 0), accent: Color::Rgb(255, 191, 0),
canvas: Color::Rgb(32, 24, 0), canvas: Color::Rgb(32, 24, 0),
canvas_heading: Color::Rgb(255, 191, 0),
selection: Color::Rgb(112, 56, 0), selection: Color::Rgb(112, 56, 0),
selection_foreground: rgb(0xffffff),
foreground: Color::Rgb(255, 191, 0), 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 { _ => Palette {
primary: Color::Rgb(0, 0, 128), primary: Color::Rgb(0, 0, 128),
accent: Color::Cyan, accent: Color::Cyan,
canvas: Color::Rgb(192, 192, 192), canvas: Color::Rgb(192, 192, 192),
canvas_heading: Color::Rgb(0, 0, 128),
selection: Color::Rgb(128, 0, 0), selection: Color::Rgb(128, 0, 0),
selection_foreground: rgb(0xffffff),
foreground: Color::Black, 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) { pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
let colors = palette(&app.preferences.theme); let colors = palette(&app.preferences.theme);
frame.render_widget( frame.render_widget(
@@ -180,7 +232,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
} else { } else {
" No items match this search. Press Esc to clear it." " 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, area,
); );
return; 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( DisplayLine::Section(heading) => lines.push(Line::from(Span::styled(
format!("── {heading} "), format!("── {heading} "),
Style::default() Style::default()
.fg(colors.primary) .fg(colors.canvas_heading)
.bg(colors.canvas) .bg(colors.canvas)
.add_modifier(Modifier::BOLD), .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(), columns.iter().map(|c| c.heading.clone()).collect(),
&widths, &widths,
Style::default() Style::default()
.fg(colors.primary) .fg(colors.canvas_heading)
.bg(colors.canvas) .bg(colors.canvas)
.add_modifier(Modifier::BOLD), .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 item = &app.items[*index];
let style = if *index == app.selected { let style = if *index == app.selected {
Style::default() Style::default()
.fg(Color::White) .fg(colors.selection_foreground)
.bg(colors.selection) .bg(colors.selection)
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
} else { } else {
@@ -304,7 +356,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
cells, cells,
&widths, &widths,
Style::default() Style::default()
.fg(colors.primary) .fg(colors.canvas_heading)
.bg(colors.canvas) .bg(colors.canvas)
.add_modifier(Modifier::BOLD), .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( lines.push(Line::from(Span::styled(
format!("── {date} ──"), format!("── {date} ──"),
Style::default() Style::default()
.fg(colors.primary) .fg(colors.canvas_heading)
.bg(colors.canvas) .bg(colors.canvas)
.add_modifier(Modifier::BOLD), .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 selected = app.items.get(app.selected).map(|s| s.id) == Some(i.id);
let style = if selected { let style = if selected {
Style::default().fg(Color::White).bg(colors.selection) Style::default()
.fg(colors.selection_foreground)
.bg(colors.selection)
} else { } else {
Style::default().fg(colors.foreground).bg(colors.canvas) 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); let colors = palette(&app.preferences.theme);
app.choice_regions.clear();
match &app.mode { match &app.mode {
Mode::Normal => {} Mode::Normal => {}
Mode::Help => { Mode::Help => {
@@ -659,11 +714,17 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!("> {}", p.values[i]), format!("> {}", p.values[i]),
Style::default().fg(Color::White).bg(if i == p.field { Style::default()
colors.selection .fg(if i == p.field {
} else { colors.selection_foreground
colors.primary } 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 => ( FormKind::Preferences => (
"Preferences (~/.config/rogue-agenda/preferences.toml)", "Preferences (~/.config/rogue-agenda/preferences.toml)",
vec![ vec![
"Theme (classic/mono/amber)", "Theme",
"Show command bar (yes/no)", "Show command bar (yes/no)",
"Show rule info (yes/no)", "Show rule info (yes/no)",
"Show return markers (yes/no)", "Show return markers (yes/no)",
@@ -768,11 +829,16 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, name)| { .map(|(i, name)| {
let indicator = if form_choices(&form.kind, i).is_some() {
""
} else {
" "
};
Line::from(Span::styled( Line::from(Span::styled(
format!("{name:<50} {}", form.values[i]), format!("{indicator} {name:<48} {}", form.values[i]),
Style::default() Style::default()
.fg(if i == form.field { .fg(if i == form.field {
Color::White colors.selection_foreground
} else { } else {
colors.accent colors.accent
}) })
@@ -787,7 +853,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
Line::from(""), Line::from(""),
Line::from(hint), Line::from(hint),
Line::from(preview), Line::from(preview),
Line::from("Tab fields · Enter save · Esc back"), Line::from("Space opens choices · Tab fields · Enter save · Esc back"),
]) ])
.collect::<Vec<_>>(); .collect::<Vec<_>>();
popup( popup(
@@ -798,6 +864,49 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
colors, 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 { Mode::Dependencies {
item_id, item_id,
choices, choices,
@@ -830,7 +939,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
) )
.highlight_style( .highlight_style(
Style::default() Style::default()
.fg(Color::White) .fg(colors.selection_foreground)
.bg(colors.selection) .bg(colors.selection)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
@@ -872,7 +981,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
) )
.highlight_style( .highlight_style(
Style::default() Style::default()
.fg(Color::White) .fg(colors.selection_foreground)
.bg(colors.selection) .bg(colors.selection)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
@@ -992,7 +1101,7 @@ fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: boo
) )
.highlight_style( .highlight_style(
Style::default() Style::default()
.fg(Color::White) .fg(colors.selection_foreground)
.bg(colors.selection) .bg(colors.selection)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
@@ -1034,7 +1143,9 @@ fn center(area: Rect, width: u16, height: u16) -> Rect {
mod tests { mod tests {
use super::*; use super::*;
use crate::{app::App, db::Database, model::ItemChanges, preferences::AppPreferences}; 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::{ use ratatui::{
Terminal, Terminal,
backend::{Backend, TestBackend}, 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] #[test]
fn document_editor_and_confirmation_use_saved_settings() { fn document_editor_and_confirmation_use_saved_settings() {
let directory = tempdir().unwrap(); let directory = tempdir().unwrap();