better prefs and more themes
This commit is contained in:
298
src/ui.rs
298
src/ui.rs
@@ -10,7 +10,7 @@ use ratatui::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::{App, FormKind, Mode},
|
||||
app::{App, FormKind, Mode, form_choices},
|
||||
filter,
|
||||
model::{Item, ViewColumn},
|
||||
parser::format_when,
|
||||
@@ -21,7 +21,9 @@ struct Palette {
|
||||
primary: Color,
|
||||
accent: Color,
|
||||
canvas: Color,
|
||||
canvas_heading: Color,
|
||||
selection: Color,
|
||||
selection_foreground: Color,
|
||||
foreground: Color,
|
||||
}
|
||||
|
||||
@@ -31,26 +33,76 @@ fn palette(theme: &str) -> Palette {
|
||||
primary: Color::Black,
|
||||
accent: Color::White,
|
||||
canvas: Color::Gray,
|
||||
canvas_heading: Color::Black,
|
||||
selection: Color::DarkGray,
|
||||
selection_foreground: rgb(0xffffff),
|
||||
foreground: Color::Black,
|
||||
},
|
||||
"amber" => Palette {
|
||||
primary: Color::Black,
|
||||
accent: Color::Rgb(255, 191, 0),
|
||||
canvas: Color::Rgb(32, 24, 0),
|
||||
canvas_heading: Color::Rgb(255, 191, 0),
|
||||
selection: Color::Rgb(112, 56, 0),
|
||||
selection_foreground: rgb(0xffffff),
|
||||
foreground: Color::Rgb(255, 191, 0),
|
||||
},
|
||||
"nord" => Palette {
|
||||
primary: rgb(0x2e3440),
|
||||
accent: rgb(0x88c0d0),
|
||||
canvas: rgb(0x3b4252),
|
||||
canvas_heading: rgb(0xeceff4),
|
||||
selection: rgb(0xeceff4),
|
||||
selection_foreground: rgb(0x2e3440),
|
||||
foreground: rgb(0xeceff4),
|
||||
},
|
||||
"solarized-dark" => Palette {
|
||||
primary: rgb(0x002b36),
|
||||
accent: rgb(0x2aa198),
|
||||
canvas: rgb(0x073642),
|
||||
canvas_heading: rgb(0xfdf6e3),
|
||||
selection: rgb(0xfdf6e3),
|
||||
selection_foreground: rgb(0x002b36),
|
||||
foreground: rgb(0x839496),
|
||||
},
|
||||
"solarized-light" => Palette {
|
||||
primary: rgb(0x586e75),
|
||||
accent: rgb(0xeee8d5),
|
||||
canvas: rgb(0xfdf6e3),
|
||||
canvas_heading: rgb(0x002b36),
|
||||
selection: rgb(0x002b36),
|
||||
selection_foreground: rgb(0xfdf6e3),
|
||||
foreground: rgb(0x657b83),
|
||||
},
|
||||
"catppuccin-mocha" => Palette {
|
||||
primary: rgb(0x181825),
|
||||
accent: rgb(0xcba6f7),
|
||||
canvas: rgb(0x1e1e2e),
|
||||
canvas_heading: rgb(0xcdd6f4),
|
||||
selection: rgb(0xcba6f7),
|
||||
selection_foreground: rgb(0x11111b),
|
||||
foreground: rgb(0xcdd6f4),
|
||||
},
|
||||
_ => Palette {
|
||||
primary: Color::Rgb(0, 0, 128),
|
||||
accent: Color::Cyan,
|
||||
canvas: Color::Rgb(192, 192, 192),
|
||||
canvas_heading: Color::Rgb(0, 0, 128),
|
||||
selection: Color::Rgb(128, 0, 0),
|
||||
selection_foreground: rgb(0xffffff),
|
||||
foreground: Color::Black,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fn rgb(value: u32) -> Color {
|
||||
Color::Rgb(
|
||||
((value >> 16) & 0xff) as u8,
|
||||
((value >> 8) & 0xff) as u8,
|
||||
(value & 0xff) as u8,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
|
||||
let colors = palette(&app.preferences.theme);
|
||||
frame.render_widget(
|
||||
@@ -180,7 +232,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
} else {
|
||||
" No items match this search. Press Esc to clear it."
|
||||
})
|
||||
.style(Style::default().fg(colors.primary).bg(colors.canvas)),
|
||||
.style(Style::default().fg(colors.canvas_heading).bg(colors.canvas)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
@@ -260,7 +312,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
DisplayLine::Section(heading) => lines.push(Line::from(Span::styled(
|
||||
format!("── {heading} "),
|
||||
Style::default()
|
||||
.fg(colors.primary)
|
||||
.fg(colors.canvas_heading)
|
||||
.bg(colors.canvas)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))),
|
||||
@@ -268,7 +320,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
columns.iter().map(|c| c.heading.clone()).collect(),
|
||||
&widths,
|
||||
Style::default()
|
||||
.fg(colors.primary)
|
||||
.fg(colors.canvas_heading)
|
||||
.bg(colors.canvas)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
@@ -276,7 +328,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
let item = &app.items[*index];
|
||||
let style = if *index == app.selected {
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.fg(colors.selection_foreground)
|
||||
.bg(colors.selection)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
@@ -304,7 +356,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
cells,
|
||||
&widths,
|
||||
Style::default()
|
||||
.fg(colors.primary)
|
||||
.fg(colors.canvas_heading)
|
||||
.bg(colors.canvas)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
@@ -440,7 +492,7 @@ fn draw_datebook(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("── {date} ──"),
|
||||
Style::default()
|
||||
.fg(colors.primary)
|
||||
.fg(colors.canvas_heading)
|
||||
.bg(colors.canvas)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
@@ -448,7 +500,9 @@ fn draw_datebook(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
}
|
||||
let selected = app.items.get(app.selected).map(|s| s.id) == Some(i.id);
|
||||
let style = if selected {
|
||||
Style::default().fg(Color::White).bg(colors.selection)
|
||||
Style::default()
|
||||
.fg(colors.selection_foreground)
|
||||
.bg(colors.selection)
|
||||
} else {
|
||||
Style::default().fg(colors.foreground).bg(colors.canvas)
|
||||
};
|
||||
@@ -558,8 +612,9 @@ fn draw_commands(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
|
||||
let colors = palette(&app.preferences.theme);
|
||||
app.choice_regions.clear();
|
||||
match &app.mode {
|
||||
Mode::Normal => {}
|
||||
Mode::Help => {
|
||||
@@ -659,11 +714,17 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("> {}", p.values[i]),
|
||||
Style::default().fg(Color::White).bg(if i == p.field {
|
||||
colors.selection
|
||||
} else {
|
||||
colors.primary
|
||||
}),
|
||||
Style::default()
|
||||
.fg(if i == p.field {
|
||||
colors.selection_foreground
|
||||
} else {
|
||||
Color::White
|
||||
})
|
||||
.bg(if i == p.field {
|
||||
colors.selection
|
||||
} else {
|
||||
colors.primary
|
||||
}),
|
||||
)),
|
||||
]
|
||||
})
|
||||
@@ -712,7 +773,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
FormKind::Preferences => (
|
||||
"Preferences (~/.config/rogue-agenda/preferences.toml)",
|
||||
vec![
|
||||
"Theme (classic/mono/amber)",
|
||||
"Theme",
|
||||
"Show command bar (yes/no)",
|
||||
"Show rule info (yes/no)",
|
||||
"Show return markers (yes/no)",
|
||||
@@ -768,11 +829,16 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
let indicator = if form_choices(&form.kind, i).is_some() {
|
||||
"▾"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
Line::from(Span::styled(
|
||||
format!("{name:<50} {}", form.values[i]),
|
||||
format!("{indicator} {name:<48} {}", form.values[i]),
|
||||
Style::default()
|
||||
.fg(if i == form.field {
|
||||
Color::White
|
||||
colors.selection_foreground
|
||||
} else {
|
||||
colors.accent
|
||||
})
|
||||
@@ -787,7 +853,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
Line::from(""),
|
||||
Line::from(hint),
|
||||
Line::from(preview),
|
||||
Line::from("Tab fields · Enter save · Esc back"),
|
||||
Line::from("Space opens choices · Tab fields · Enter save · Esc back"),
|
||||
])
|
||||
.collect::<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