better prefs and more themes
This commit is contained in:
@@ -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.
|
||||
|
||||
16
README.md
16
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.
|
||||
|
||||
160
src/app.rs
160
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<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,8 +898,23 @@ impl App {
|
||||
form.field = (form.field + form.values.len() - 1) % form.values.len()
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
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 {
|
||||
FormKind::Category(id) => {
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
290
src/ui.rs
290
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,7 +714,13 @@ 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 {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user