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] 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.
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -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.
|
||||||
|
|||||||
162
src/app.rs
162
src/app.rs
@@ -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() {
|
||||||
|
|||||||
@@ -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
298
src/ui.rs
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user