Files
RogueAgenda/src/ui.rs
2026-08-16 21:39:18 +00:00

1824 lines
65 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::HashMap;
use chrono::Local;
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::{
app::{App, FormKind, InputKind, Mode, form_choices},
filter,
model::{Item, ViewColumn},
parser::format_when,
};
#[derive(Clone, Copy)]
struct Palette {
primary: Color,
accent: Color,
canvas: Color,
canvas_heading: Color,
selection: Color,
selection_foreground: Color,
foreground: Color,
}
fn palette(theme: &str) -> Palette {
match theme {
"mono" => 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),
},
"greenscreen" => Palette {
primary: Color::Black,
accent: rgb(0x79a86b),
canvas: rgb(0x030704),
canvas_heading: rgb(0x9ac58d),
selection: rgb(0x284b2f),
selection_foreground: rgb(0xdce8d8),
foreground: rgb(0x8fbd80),
},
"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),
},
"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(
Block::default().style(Style::default().bg(Color::Black)),
frame.area(),
);
let height = frame.area().height;
if height < 10 || frame.area().width < 40 {
frame.render_widget(
Paragraph::new("Rogue Agenda needs at least 40×10")
.style(Style::default().fg(colors.accent).bg(colors.primary))
.alignment(Alignment::Center),
frame.area(),
);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(if app.preferences.show_command_bar {
2
} else {
0
}),
Constraint::Length(1),
Constraint::Min(3),
Constraint::Length(1),
Constraint::Length(2),
])
.split(frame.area());
draw_header(frame, app, chunks[0]);
draw_view_tabs(frame, app, chunks[1]);
draw_items(frame, app, chunks[2]);
draw_status(frame, app, chunks[3]);
if app.preferences.show_command_bar {
draw_commands(frame, app, chunks[4]);
} else {
app.command_regions.clear();
}
draw_overlay(frame, app);
}
fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) {
let colors = palette(&app.preferences.theme);
let alarms = app.db.due_alarm_count().unwrap_or(0);
let now = Local::now().to_rfc3339();
let right = format!(
"{}{}",
formatted_when(app, Some(&now)),
if alarms > 0 {
format!(" ALARM:{alarms}")
} else {
String::new()
}
);
let width = area.width as usize;
let left1 = format!(
"File: {}{}",
app.path.display(),
if app.document_settings.description.is_empty() {
String::new()
} else {
format!("{}", app.document_settings.description)
}
);
let pad = width.saturating_sub(left1.chars().count() + right.chars().count());
let line1 = format!("{left1}{}{right}", " ".repeat(pad));
let line2 = format!(
"View: {}{}{}",
app.current_view().name,
if app.search.is_empty() {
String::new()
} else {
format!(" /{}", app.search)
},
app.recording
.as_ref()
.map(|recording| format!(" LEARN:{}", recording.name))
.unwrap_or_default()
);
frame.render_widget(
Paragraph::new(vec![Line::from(line1), Line::from(line2)]).style(
Style::default()
.fg(Color::White)
.bg(colors.primary)
.add_modifier(Modifier::BOLD),
),
area,
);
}
fn draw_view_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
let colors = palette(&app.preferences.theme);
app.view_regions.clear();
let mut x = area.x;
frame.render_widget(
Block::default().style(Style::default().bg(colors.canvas)),
area,
);
for (i, v) in app.views.iter().enumerate() {
let w = (v.name.chars().count() + 2).min(20) as u16;
if x + w > area.right() {
break;
}
let r = Rect::new(x, area.y, w, 1);
app.view_regions.push(r);
let st = if i == app.view_index {
Style::default()
.fg(colors.primary)
.bg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White).bg(colors.primary)
};
frame.render_widget(Paragraph::new(format!(" {} ", v.name)).style(st), r);
x += w + 1;
}
}
fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
let colors = palette(&app.preferences.theme);
frame.render_widget(
Block::default().style(Style::default().bg(colors.canvas)),
area,
);
app.item_rows.clear();
if app.items.is_empty() {
frame.render_widget(
Paragraph::new(if app.search.is_empty() {
" No items. Press Insert or n to capture one."
} else {
" No items match this search. Press Esc to clear it."
})
.style(Style::default().fg(colors.canvas_heading).bg(colors.canvas)),
area,
);
return;
}
if app.current_view().kind == "datebook" {
draw_datebook(frame, app, area);
return;
}
let mut columns = app.current_view().columns.clone();
if area.width < 70 && columns.len() > 2 {
let item = columns
.iter()
.find(|c| c.field == "item")
.cloned()
.unwrap_or_else(|| columns[0].clone());
let detail = columns
.iter()
.find(|c| c.field == "when")
.cloned()
.or_else(|| columns.iter().find(|c| c.field != item.field).cloned());
columns = vec![item];
if let Some(detail) = detail {
columns.push(detail)
}
}
let widths = column_widths(&columns, area.width);
let mut display: Vec<DisplayLine> = vec![];
let sections = app.current_view().sections.clone();
if sections.is_empty() {
display.push(DisplayLine::Header);
for index in 0..app.items.len() {
display.push(DisplayLine::Item(index));
}
if columns.iter().any(|c| c.aggregate != "none") {
display.push(DisplayLine::Aggregate((0..app.items.len()).collect()));
}
} else {
for section in sections {
display.push(DisplayLine::Section(format!(
"{}{}",
if section.collapsed { "" } else { "" },
section.heading
)));
if section.collapsed {
continue;
}
display.push(DisplayLine::Header);
let indices = app
.items
.iter()
.enumerate()
.filter(|(_, item)| filter::matches(&section.filter_expr, item).unwrap_or(false))
.map(|(i, _)| i)
.collect::<Vec<_>>();
for index in &indices {
display.push(DisplayLine::Item(*index));
}
if columns.iter().any(|c| c.aggregate != "none") {
display.push(DisplayLine::Aggregate(indices));
}
}
}
let selected_line = display
.iter()
.position(|line| matches!(line,DisplayLine::Item(i) if *i==app.selected))
.unwrap_or(0);
let visible = area.height as usize;
if selected_line < app.scroll {
app.scroll = selected_line
}
if selected_line >= app.scroll + visible {
app.scroll = selected_line + 1 - visible
}
let mut lines = vec![];
for (shown, line) in display.iter().skip(app.scroll).take(visible).enumerate() {
match line {
DisplayLine::Section(heading) => lines.push(Line::from(Span::styled(
format!("── {heading} "),
Style::default()
.fg(colors.canvas_heading)
.bg(colors.canvas)
.add_modifier(Modifier::BOLD),
))),
DisplayLine::Header => lines.push(cells_line(
columns.iter().map(|c| c.heading.clone()).collect(),
&widths,
Style::default()
.fg(colors.canvas_heading)
.bg(colors.canvas)
.add_modifier(Modifier::BOLD),
)),
DisplayLine::Item(index) => {
let item = &app.items[*index];
let style = if *index == app.selected {
Style::default()
.fg(colors.selection_foreground)
.bg(colors.selection)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(if item.done_at.is_some() {
Color::DarkGray
} else {
colors.foreground
})
.bg(colors.canvas)
};
let cells = columns.iter().map(|c| column_value(c, item, app)).collect();
lines.push(cells_line(cells, &widths, style));
app.item_rows.push((
Rect::new(area.x, area.y + shown as u16, area.width, 1),
item.id,
));
}
DisplayLine::Aggregate(indices) => {
let cells = columns
.iter()
.map(|c| aggregate_value(c, indices, &app.items, app))
.collect();
lines.push(cells_line(
cells,
&widths,
Style::default()
.fg(colors.canvas_heading)
.bg(colors.canvas)
.add_modifier(Modifier::BOLD),
));
}
}
}
frame.render_widget(
Paragraph::new(lines).style(Style::default().bg(colors.canvas)),
area,
);
}
enum DisplayLine {
Section(String),
Header,
Item(usize),
Aggregate(Vec<usize>),
}
fn column_widths(columns: &[ViewColumn], total: u16) -> Vec<usize> {
let spacing = columns.len().saturating_sub(1) as u16;
let available = total.saturating_sub(spacing).max(columns.len() as u16);
let weights = columns
.iter()
.map(|c| c.width.max(1) as u32)
.sum::<u32>()
.max(1);
let mut widths = columns
.iter()
.map(|c| ((available as u32 * c.width.max(1) as u32 / weights).max(1)) as usize)
.collect::<Vec<_>>();
let used = widths.iter().sum::<usize>();
if let Some(first) = widths.first_mut() {
*first += available as usize - used;
}
widths
}
fn cells_line(values: Vec<String>, widths: &[usize], style: Style) -> Line<'static> {
let text = values
.into_iter()
.zip(widths)
.map(|(v, w)| fit(&v, *w))
.collect::<Vec<_>>()
.join(" ");
Line::from(Span::styled(text, style))
}
fn fit(value: &str, width: usize) -> String {
let mut chars = value.chars();
let mut out = chars.by_ref().take(width).collect::<String>();
let count = out.chars().count();
if chars.next().is_some() && width > 0 {
out.pop();
out.push('…')
} else if count < width {
out.push_str(&" ".repeat(width - count))
}
out
}
fn column_value(column: &ViewColumn, item: &Item, app: &App) -> String {
match column.field.as_str() {
"item" => {
let marker = if app.marked.contains(&item.id) {
""
} else if item.done_at.is_some() {
""
} else if app.db.unmet_dependencies(item.id).unwrap_or(0) > 0 {
""
} else {
&app.preferences.item_marker
};
format!("{marker} {}", item.text.replace('\n', " "))
}
"categories" => format_categories(item.category_names()),
"when" => formatted_when(app, item.when_at.as_deref()),
"priority" => item.priority.to_string(),
"note" => item.note.replace(
'\n',
if app.preferences.show_carriage_returns {
""
} else {
" "
},
),
"value" => item
.numeric_value
.map(|v| format_number(v, app))
.unwrap_or_default(),
"done" => formatted_when(app, item.done_at.as_deref()),
"alarm" => formatted_when(app, item.alarm_at.as_deref()),
"recurrence" => item.recurrence.clone(),
"created" => formatted_when(app, Some(&item.created_at)),
"updated" => formatted_when(app, Some(&item.updated_at)),
_ => String::new(),
}
}
fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item], app: &App) -> String {
if column.aggregate == "none" {
return String::new();
}
if column.aggregate == "count" {
return format!("count {}", indices.len());
}
let values = indices
.iter()
.filter_map(|i| match column.field.as_str() {
"value" => items[*i].numeric_value,
"priority" => Some(items[*i].priority as f64),
_ => None,
})
.collect::<Vec<_>>();
if values.is_empty() {
return String::new();
}
let value = match column.aggregate.as_str() {
"sum" => values.iter().sum(),
"avg" => values.iter().sum::<f64>() / values.len() as f64,
"min" => values.iter().copied().fold(f64::INFINITY, f64::min),
"max" => values.iter().copied().fold(f64::NEG_INFINITY, f64::max),
_ => return String::new(),
};
format!("{} {}", column.aggregate, format_number(value, app))
}
fn draw_datebook(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
let colors = palette(&app.preferences.theme);
let mut lines: Vec<Line> = vec![];
let mut dates: HashMap<String, bool> = HashMap::new();
for i in &app.items {
let formatted = formatted_when(app, i.when_at.as_deref());
let (date, time) = formatted.rsplit_once(' ').unwrap_or((&formatted, ""));
let date = date.to_owned();
if !dates.contains_key(&date) {
lines.push(Line::from(Span::styled(
format!("── {date} ──"),
Style::default()
.fg(colors.canvas_heading)
.bg(colors.canvas)
.add_modifier(Modifier::BOLD),
)));
dates.insert(date.clone(), true);
}
let selected = app.items.get(app.selected).map(|s| s.id) == Some(i.id);
let style = if selected {
Style::default()
.fg(colors.selection_foreground)
.bg(colors.selection)
} else {
Style::default().fg(colors.foreground).bg(colors.canvas)
};
let y = area.y + lines.len() as u16;
app.item_rows
.push((Rect::new(area.x, y, area.width, 1), i.id));
lines.push(Line::from(Span::styled(
format!(" {} {}", time, i.text),
style,
)));
}
frame.render_widget(
Paragraph::new(lines).style(Style::default().bg(colors.canvas)),
area,
);
}
fn format_categories(s: String) -> String {
if s.is_empty() {
String::new()
} else {
s.split(", ")
.map(|v| format!("·{v}"))
.collect::<Vec<_>>()
.join(" ")
}
}
fn formatted_when(app: &App, value: Option<&str>) -> String {
format_when(
value,
&app.preferences.date_format,
app.preferences.clock_24h,
)
}
fn format_number(value: f64, app: &App) -> String {
let negative = value.is_sign_negative();
let raw = format!("{:.2}", value.abs());
let (integer, fraction) = raw.split_once('.').unwrap_or((&raw, "00"));
let mut grouped = String::new();
for (index, c) in integer.chars().rev().enumerate() {
if index > 0 && index % 3 == 0 {
grouped.push_str(&app.preferences.thousands_separator);
}
grouped.push(c);
}
let integer = grouped.chars().rev().collect::<String>();
format!(
"{}{}{}{}",
if negative { "-" } else { "" },
integer,
app.preferences.decimal_separator,
fraction
)
}
fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
let colors = palette(&app.preferences.theme);
let totals = app
.items
.iter()
.filter_map(|i| i.numeric_value)
.collect::<Vec<_>>();
let aggregate = if totals.is_empty() {
String::new()
} else {
format!(
" Σ {} avg {}",
format_number(totals.iter().sum::<f64>(), app),
format_number(totals.iter().sum::<f64>() / totals.len() as f64, app)
)
};
let right = format!("{} item(s){}", app.items.len(), aggregate);
let pad = area
.width
.saturating_sub((app.status.chars().count() + right.chars().count() + 2) as u16)
as usize;
frame.render_widget(
Paragraph::new(format!(" {}{}{right}", app.status, " ".repeat(pad)))
.style(Style::default().fg(colors.accent).bg(colors.primary)),
area,
);
}
fn draw_commands(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
let colors = palette(&app.preferences.theme);
let labels = [
"Help", "Edit", "Choices", "Done", "Note", "Props", "Mark", "Vw Mgr", "Cat Mgr", "Menu",
];
app.command_regions.clear();
let cells = Layout::default()
.direction(Direction::Horizontal)
.constraints((0..10).map(|_| Constraint::Ratio(1, 10)))
.split(area);
for (i, r) in cells.iter().enumerate() {
app.command_regions.push(*r);
frame.render_widget(
Paragraph::new(vec![
Line::from(format!("F{}", i + 1)),
Line::from(labels[i]),
])
.alignment(Alignment::Center)
.style(Style::default().fg(Color::White).bg(colors.primary)),
*r,
);
}
}
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 => {
let text = Text::from(vec![
Line::from(Span::styled(
"ROGUE AGENDA HELP",
Style::default()
.fg(colors.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(
"Capture a sentence first; assign categories and shape views as patterns emerge.",
),
Line::from(""),
Line::from("↑↓/jk move Insert/n new Enter/F2 edit F3/c categories"),
Line::from("F4/d done F5 note F6/p properties F7/Space mark"),
Line::from("F8/v views F9 categories F10/m menu / search"),
Line::from("h/l views ([/]) r prerequisites Delete trash/recover"),
Line::from("Ctrl-G macros Ctrl-S save q quit"),
Line::from(""),
Line::from("Mouse: click items, view tabs, or function-key tiles."),
Line::from("Press any key to close."),
]);
popup(
frame,
center(frame.area(), 74, 15),
"Help",
Paragraph::new(text).wrap(Wrap { trim: false }),
colors,
);
}
Mode::Input(input) => {
let macro_editor = matches!(input.kind, InputKind::MacroEditor(_));
let h = if macro_editor {
frame.area().height.saturating_sub(4)
} else if input.multiline {
18
} else {
7
};
let width = if macro_editor {
frame.area().width.saturating_sub(4)
} else {
78
};
let area = center(frame.area(), width, h);
let inner = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
let before_cursor = &input.value[..input.cursor];
let cursor_line = before_cursor.bytes().filter(|byte| *byte == b'\n').count();
let current_line_start = before_cursor.rfind('\n').map_or(0, |index| index + 1);
let cursor_column = Line::from(&before_cursor[current_line_start..]).width();
let vertical_scroll =
cursor_line.saturating_sub(inner.height.saturating_sub(1) as usize);
let horizontal_scroll =
cursor_column.saturating_sub(inner.width.saturating_sub(1) as usize);
let shown = if macro_editor {
macro_highlight(&input.value)
} else if input.value.is_empty() {
Text::from(Line::from(Span::styled(
"Type here…",
Style::default().fg(Color::DarkGray),
)))
} else {
Text::from(
if app.preferences.show_carriage_returns && input.multiline {
input.value.replace('\n', "\n")
} else {
input.value.clone()
},
)
};
popup(
frame,
area,
&input.title,
Paragraph::new(shown).scroll((
vertical_scroll.min(u16::MAX as usize) as u16,
horizontal_scroll.min(u16::MAX as usize) as u16,
)),
colors,
);
frame.set_cursor_position((
inner.x + cursor_column.saturating_sub(horizontal_scroll) as u16,
inner.y + cursor_line.saturating_sub(vertical_scroll) as u16,
));
}
Mode::Properties(p) => {
let names = [
"Priority (1 highest)",
"When (phrase or RFC3339)",
"Alarm (phrase or RFC3339)",
"Numeric value",
"Recurrence (daily/weekly/monthly/every N days)",
];
let lines = names
.iter()
.enumerate()
.flat_map(|(i, n)| {
[
Line::from(Span::styled(
*n,
Style::default().fg(if i == p.field {
Color::Yellow
} else {
colors.accent
}),
)),
Line::from(Span::styled(
format!("> {}", p.values[i]),
Style::default()
.fg(if i == p.field {
colors.selection_foreground
} else {
Color::White
})
.bg(if i == p.field {
colors.selection
} else {
colors.primary
}),
)),
]
})
.chain([
Line::from(""),
Line::from("Tab fields · Enter save · Esc cancel"),
])
.collect::<Vec<_>>();
popup(
frame,
center(frame.area(), 78, 18),
"Item Properties",
Paragraph::new(lines),
colors,
);
}
Mode::Form(form) => {
let (title, names, hint) = match form.kind {
FormKind::Category(_) => (
"Category Definition",
vec![
"Name",
"Parent category",
"Kind",
"Match phrases (comma separated)",
"Mutually exclusive (yes/no)",
"Rule condition",
"Rule action",
],
"Rule: category=Work and priority<=2 → assign:Important",
),
FormKind::View(_) => (
"Live View Definition",
vec![
"Name",
"Kind",
"Filter value",
"Boolean filter",
"Sort key",
"Show done (yes/no)",
"Columns (field:width:heading:aggregate, …)",
"Sections (heading|filter; …)",
],
"Filters: category=Work and (open or priority<=2)",
),
FormKind::Preferences => (
"Preferences (~/.config/rogue-agenda/preferences.toml)",
vec![
"Theme",
"Show command bar (yes/no)",
"Show rule info (yes/no)",
"Show return markers (yes/no)",
"Item marker (one character)",
"Autosave minutes (0-60)",
"Confirm destructive actions",
"Date format (iso/us/european/long)",
"24-hour clock (yes/no)",
"Decimal separator",
"Thousands separator",
],
"Application-wide settings are stored as TOML in your home directory.",
),
FormKind::DocumentSettings => (
"Document Settings (stored inside this .agnd)",
vec![
"Description",
"Backup on open (yes/no)",
"Trash (on-demand/on-close/end-of-day/immediate)",
"Completed items (keep/trash)",
"Automatic filing (yes/no)",
"Numeric date order (ymd/mdy/dmy)",
"Week starts (monday/sunday)",
"Default time (HH:MM)",
"Morning time (HH:MM)",
"Afternoon time (HH:MM)",
"Evening time (HH:MM)",
"Note tab width (1-16)",
],
"These settings travel with the SQLite document.",
),
};
let preview = match form.kind {
FormKind::Category(_) | FormKind::View(_) => {
let filter_source = match form.kind {
FormKind::Category(_) => form.values.get(5),
FormKind::View(_) => form.values.get(3),
FormKind::Preferences | FormKind::DocumentSettings => None,
}
.map(String::as_str)
.unwrap_or("");
match app.db.count_matching_filter(filter_source) {
Ok(n) => format!("Preview: {n} existing item(s) match"),
Err(e) => format!("Preview error: {e}"),
}
}
FormKind::Preferences => format!("File: {}", app.preferences_path.display()),
FormKind::DocumentSettings => {
"Document preferences are saved transactionally".into()
}
};
let lines = names
.iter()
.enumerate()
.map(|(i, name)| {
let indicator = if form_choices(&form.kind, i).is_some() {
""
} else {
" "
};
Line::from(Span::styled(
format!("{indicator} {name:<48} {}", form.values[i]),
Style::default()
.fg(if i == form.field {
colors.selection_foreground
} else {
colors.accent
})
.bg(if i == form.field {
colors.selection
} else {
colors.primary
}),
))
})
.chain([
Line::from(""),
Line::from(hint),
Line::from(preview),
Line::from("Space opens choices · Tab fields · Enter save · Esc back"),
])
.collect::<Vec<_>>();
popup(
frame,
center(frame.area(), 110, (names.len() as u16 + 6).clamp(12, 22)),
title,
Paragraph::new(lines),
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,
selected,
} => {
let assigned = app.db.dependency_ids(*item_id).unwrap_or_default();
let items = choices
.iter()
.map(|item| {
ListItem::new(format!(
"{} {}",
if assigned.contains(&item.id) {
"[×]"
} else {
"[ ]"
},
item.text
))
})
.collect::<Vec<_>>();
let mut state = ListState::default().with_selected(Some(*selected));
let area = center(frame.area(), 76, (choices.len() as u16 + 4).clamp(8, 22));
frame.render_widget(Clear, area);
frame.render_stateful_widget(
List::new(items)
.block(
Block::bordered()
.title(" Prerequisites (Space toggles · Enter closes) ")
.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::Categories => draw_category_list(
frame,
app,
"Assign Categories (Space toggles · Enter closes)",
true,
),
Mode::CategoryManager => draw_category_list(
frame,
app,
"Category Manager (n add · e edit · Delete discard)",
false,
),
Mode::Views => {
let items = app
.views
.iter()
.map(|v| {
ListItem::new(format!("{:<18} {:<9} sort: {}", v.name, v.kind, v.sort_key))
})
.collect::<Vec<_>>();
let mut state = ListState::default().with_selected(Some(app.view_selected));
let area = center(frame.area(), 64, (app.views.len() as u16 + 4).min(20));
frame.render_widget(Clear, area);
frame.render_stateful_widget(
List::new(items)
.block(
Block::bordered()
.title(
" View Manager (n add · e edit · Ctrl-↑↓ move · Delete discard) ",
)
.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::Menu => {
popup(
frame,
center(frame.area(), 58, 15),
"Rogue Agenda Menu",
Paragraph::new(vec![
Line::from(" n New item"),
Line::from(" s Save / checkpoint"),
Line::from(" h Help"),
Line::from(" r Prerequisites"),
Line::from(" p Preferences (user TOML)"),
Line::from(" d Document settings (.agnd)"),
Line::from(" x Macro Manager (Ctrl-G)"),
Line::from(" b Back up document now"),
Line::from(" t Empty Trash permanently"),
Line::from(" q Quit"),
Line::from(""),
Line::from(" Esc closes the menu"),
])
.style(Style::default().fg(Color::White)),
colors,
);
}
Mode::Confirm { prompt, .. } => {
popup(
frame,
center(frame.area(), 66, 7),
"Confirm",
Paragraph::new(vec![
Line::from(prompt.as_str()),
Line::from(""),
Line::from("Enter/Y confirms · N/Esc cancels"),
]),
colors,
);
}
Mode::MacroManager => {
let items = if app.macros.is_empty() {
vec![ListItem::new("(no macros — press Insert to create one)")]
} else {
app.macros
.iter()
.map(|definition| {
ListItem::new(format!(
"{:<35} {}",
definition.name,
if definition.key_binding.is_empty() {
String::new()
} else {
definition.key_binding.clone()
}
))
})
.collect()
};
let mut state = ListState::default().with_selected(if app.macros.is_empty() {
None
} else {
Some(app.macro_selected)
});
let area = center(
frame.area(),
76,
(app.macros.len() as u16 + 5).clamp(10, 24),
);
let inner = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
frame.render_widget(Clear, area);
frame.render_stateful_widget(
List::new(items)
.block(
Block::bordered()
.title(" Macro Manager ")
.title_bottom(Line::from(
" Ins add F2 edit Enter run F7/r record a append F6/b key Del ",
))
.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,
);
let offset = state.offset();
app.choice_regions
.extend(std::iter::repeat_n(Rect::default(), offset));
app.choice_regions.extend(
(offset..app.macros.len().min(offset + inner.height as usize)).map(|index| {
Rect::new(inner.x, inner.y + (index - offset) as u16, inner.width, 1)
}),
);
}
Mode::MacroMenu {
title,
prompt,
choices,
selected,
} => {
let items = choices
.iter()
.map(|choice| ListItem::new(choice.label.as_str()))
.collect::<Vec<_>>();
let mut state = ListState::default().with_selected(Some(*selected));
let area = center(frame.area(), 64, (choices.len() as u16 + 5).clamp(8, 22));
let inner = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
frame.render_widget(Clear, area);
frame.render_stateful_widget(
List::new(items)
.block(
Block::bordered()
.title(format!(" {title} "))
.title_bottom(Line::from(format!(
" {prompt} · Enter chooses · Esc cancels "
)))
.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,
);
let offset = state.offset();
app.choice_regions
.extend(std::iter::repeat_n(Rect::default(), offset));
app.choice_regions.extend(
(offset..choices.len().min(offset + inner.height as usize)).map(|index| {
Rect::new(inner.x, inner.y + (index - offset) as u16, inner.width, 1)
}),
);
}
Mode::MacroMessage { title, message } => popup(
frame,
center(frame.area(), 70, 9),
title,
Paragraph::new(vec![
Line::from(message.as_str()),
Line::from(""),
Line::from("Press any key to continue the macro"),
])
.wrap(Wrap { trim: false }),
colors,
),
Mode::MacroGetKey { .. } => popup(
frame,
center(frame.area(), 58, 7),
"Macro Input",
Paragraph::new("Press a key for the macro, or Esc to cancel"),
colors,
),
}
}
fn macro_highlight(source: &str) -> Text<'static> {
let mut lines = Vec::new();
for line in source.split('\n') {
let mut spans = Vec::new();
let mut rest = line;
while let Some(open) = rest.find('{') {
if open > 0 {
spans.push(Span::styled(
rest[..open].to_owned(),
Style::default().fg(Color::White),
));
}
let command = &rest[open + 1..];
let Some(close) = command.find('}') else {
spans.push(Span::styled(
rest[open..].to_owned(),
Style::default()
.fg(Color::LightRed)
.add_modifier(Modifier::BOLD),
));
rest = "";
break;
};
let body = &command[..close];
let parts = body.split(';').collect::<Vec<_>>();
let is_comment = parts
.first()
.is_some_and(|name| name.eq_ignore_ascii_case("COMMENT"));
spans.push(Span::styled(
"{".to_owned(),
Style::default().fg(Color::DarkGray),
));
for (index, part) in parts.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(
";".to_owned(),
Style::default().fg(Color::DarkGray),
));
}
let style = if is_comment {
Style::default().fg(Color::DarkGray)
} else if index == 0 {
Style::default()
.fg(if part.eq_ignore_ascii_case("LABEL") {
Color::LightGreen
} else {
Color::LightCyan
})
.add_modifier(Modifier::BOLD)
} else if part.trim_start().starts_with("%%") {
Style::default()
.fg(Color::LightYellow)
.add_modifier(Modifier::BOLD)
} else if part.trim_start().starts_with('%') {
Style::default().fg(Color::Yellow)
} else if part.trim_start().starts_with('#') {
Style::default().fg(Color::LightMagenta)
} else if part.parse::<f64>().is_ok() {
Style::default().fg(Color::LightBlue)
} else {
Style::default().fg(Color::White)
};
spans.push(Span::styled((*part).to_owned(), style));
}
spans.push(Span::styled(
"}".to_owned(),
Style::default().fg(Color::DarkGray),
));
rest = &command[close + 1..];
}
if !rest.is_empty() {
spans.push(Span::styled(
rest.to_owned(),
Style::default().fg(Color::White),
));
}
lines.push(Line::from(spans));
}
Text::from(lines)
}
fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: bool) {
let colors = palette(&app.preferences.theme);
let assigned = app
.selected_item()
.and_then(|i| app.db.item_category_ids(i.id).ok())
.unwrap_or_default();
let excluded = app
.selected_item()
.and_then(|i| app.db.excluded_category_ids(i.id).ok())
.unwrap_or_default();
let names: HashMap<i64, &str> = app
.categories
.iter()
.map(|c| (c.id, c.name.as_str()))
.collect();
let items = app
.categories
.iter()
.map(|c| {
let indent = if c.parent_id.is_some() { "" } else { "" };
let marker = if assign && assigned.contains(&c.id) {
"[×]"
} else if assign && excluded.contains(&c.id) {
"[-]"
} else if assign {
"[ ]"
} else {
""
};
let parent = c
.parent_id
.and_then(|p| names.get(&p))
.map(|p| format!(" ({p})"))
.unwrap_or_default();
let rule = if app.preferences.show_rule_info {
app.db
.category_rules(c.id)
.ok()
.and_then(|rules| rules.into_iter().next())
.map(|r| {
format!(
" rule: {}{}:{}",
r.condition_expr, r.action_kind, r.action_value
)
})
.unwrap_or_default()
} else {
String::new()
};
ListItem::new(format!(
"{marker} {indent}{}{} {}{}",
c.name,
parent,
if c.match_text.is_empty() {
String::new()
} else {
format!("matches: {}", c.match_text)
},
rule
))
})
.collect::<Vec<_>>();
let mut state = ListState::default().with_selected(Some(app.category_selected));
let area = center(frame.area(), 78, (app.categories.len() as u16 + 4).min(22));
frame.render_widget(Clear, area);
frame.render_stateful_widget(
List::new(items)
.block(
Block::bordered()
.title(format!(" {title} "))
.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,
);
}
fn popup(frame: &mut Frame<'_>, area: Rect, title: &str, widget: Paragraph<'_>, colors: Palette) {
frame.render_widget(Clear, area);
frame.render_widget(
Block::default()
.borders(Borders::ALL)
.title(format!(" {title} "))
.border_style(Style::default().fg(colors.accent))
.style(Style::default().bg(colors.primary)),
area,
);
frame.render_widget(
widget.style(Style::default().fg(Color::White).bg(colors.primary)),
area.inner(Margin {
horizontal: 1,
vertical: 1,
}),
);
}
fn center(area: Rect, width: u16, height: u16) -> Rect {
let w = width.min(area.width.saturating_sub(2));
let h = height.min(area.height.saturating_sub(2));
Rect::new(
area.x + (area.width - w) / 2,
area.y + (area.height - h) / 2,
w,
h,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{app::App, db::Database, model::ItemChanges, preferences::AppPreferences};
use crossterm::event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::{
Terminal,
backend::{Backend, TestBackend},
};
use tempfile::tempdir;
#[test]
fn renders_main_workspace() {
let d = tempdir().unwrap();
let p = d.path().join("ui.agnd");
let mut db = Database::open(&p).unwrap();
db.add_item("Call Grace tomorrow").unwrap();
let mut app = App::new_with_preferences(
db,
p,
AppPreferences::default(),
d.path().join("preferences.toml"),
)
.unwrap();
let backend = TestBackend::new(100, 30);
let mut term = Terminal::new(backend).unwrap();
term.draw(|f| draw(f, &mut app)).unwrap();
let screen = term
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect::<String>();
assert!(screen.contains("View: All Items"));
assert!(screen.contains("Call Grace"));
assert!(screen.contains("Cat Mgr"));
}
#[test]
fn renders_persistent_columns_sections_and_aggregates() {
let d = tempdir().unwrap();
let p = d.path().join("designed.agnd");
let mut db = Database::open(&p).unwrap();
let item = db.add_item("File the urgent report").unwrap();
db.update_item(
item,
&ItemChanges {
priority: Some(1),
numeric_value: Some(Some(75.0)),
..Default::default()
},
)
.unwrap();
let view_id = db
.save_view_design(
None,
"Dashboard",
"list",
"",
"priority",
true,
"open",
"item:70:Action:none,value:30:Cost:sum",
"Urgent work|priority=1",
)
.unwrap();
let mut app = App::new_with_preferences(
db,
p,
AppPreferences::default(),
d.path().join("preferences.toml"),
)
.unwrap();
app.view_index = app.views.iter().position(|v| v.id == view_id).unwrap();
app.refresh().unwrap();
let backend = TestBackend::new(90, 24);
let mut term = Terminal::new(backend).unwrap();
term.draw(|f| draw(f, &mut app)).unwrap();
let screen = term
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect::<String>();
assert!(screen.contains("Urgent work"));
assert!(screen.contains("Action"));
assert!(screen.contains("Cost"));
assert!(screen.contains("sum 75.00"));
}
#[test]
fn compact_layout_keeps_primary_fields_visible() {
let d = tempdir().unwrap();
let p = d.path().join("compact.agnd");
let mut db = Database::open(&p).unwrap();
db.add_item("Compact item tomorrow").unwrap();
let mut app = App::new_with_preferences(
db,
p,
AppPreferences::default(),
d.path().join("preferences.toml"),
)
.unwrap();
let backend = TestBackend::new(50, 15);
let mut term = Terminal::new(backend).unwrap();
term.draw(|f| draw(f, &mut app)).unwrap();
let screen = term
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect::<String>();
assert!(screen.contains("Compact item"));
assert!(screen.contains("When"));
}
#[test]
fn main_screen_h_and_l_cycle_views_with_wraparound() {
let directory = tempdir().unwrap();
let path = directory.path().join("view-navigation.agnd");
let db = Database::open(&path).unwrap();
let mut app = App::new_with_preferences(
db,
path,
AppPreferences::default(),
directory.path().join("preferences.toml"),
)
.unwrap();
assert!(app.views.len() > 1);
app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.view_index, 1);
app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.view_index, 0);
app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.view_index, app.views.len() - 1);
app.handle_key(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.view_index, 0);
}
#[test]
fn note_editor_renders_and_saves_entered_line_breaks() {
let d = tempdir().unwrap();
let p = d.path().join("notes.agnd");
let mut db = Database::open(&p).unwrap();
db.add_item("Write release notes").unwrap();
let mut app = App::new_with_preferences(
db,
p,
AppPreferences::default(),
d.path().join("preferences.toml"),
)
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE))
.unwrap();
for c in "first line".chars() {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
for c in "second line".chars() {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
let backend = TestBackend::new(100, 30);
let mut term = Terminal::new(backend).unwrap();
term.draw(|frame| draw(frame, &mut app)).unwrap();
let cursor = term.backend_mut().get_cursor_position().unwrap();
let rows = term
.backend()
.buffer()
.content
.chunks(100)
.map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
.collect::<Vec<_>>();
let first_row = rows
.iter()
.position(|row| row.contains("first line"))
.unwrap();
let second_row = rows
.iter()
.position(|row| row.contains("second line"))
.unwrap();
assert_eq!(second_row, first_row + 1);
assert_eq!(cursor.y as usize, second_row);
app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL))
.unwrap();
assert_eq!(app.items[0].note, "first line\nsecond line");
}
#[test]
fn application_preferences_change_the_workspace_and_open_from_the_menu() {
let directory = tempdir().unwrap();
let path = directory.path().join("preferences-ui.agnd");
let mut db = Database::open(&path).unwrap();
let item = db.add_item("Configured display item").unwrap();
db.update_item(
item,
&ItemChanges {
when_at: Some(Some("2026-08-17T15:30:00+00:00".into())),
numeric_value: Some(Some(1234.5)),
..Default::default()
},
)
.unwrap();
let preferences = AppPreferences {
theme: "amber".into(),
show_command_bar: false,
item_marker: ">".into(),
date_format: "us".into(),
clock_24h: false,
decimal_separator: ",".into(),
thousands_separator: ".".into(),
..Default::default()
};
let mut app = App::new_with_preferences(
db,
path,
preferences,
directory.path().join("preferences.toml"),
)
.unwrap();
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("> Configured display item"));
assert!(screen.contains("08/17/2026 3:30pm"));
assert!(screen.contains("1.234,50"));
assert!(!screen.contains("Cat Mgr"));
app.handle_key(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE))
.unwrap();
assert!(matches!(
app.mode,
Mode::Form(crate::app::FormState {
kind: FormKind::Preferences,
..
})
));
}
#[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("Greenscreen — muted phosphor green"));
assert!(screen.contains("Nord — arctic blue dark"));
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("greenscreen").foreground, Color::Rgb(143, 189, 128));
assert_eq!(palette("nord").primary, Color::Rgb(46, 52, 64));
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 additional_themes_keep_selected_rows_at_high_contrast() {
for theme in ["greenscreen", "nord", "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",
"greenscreen",
"nord",
"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"
);
}
}
#[test]
fn macro_source_highlighting_distinguishes_language_elements() {
let highlighted = macro_highlight(
"{demo}\n{defstr;%local;%%global}\n{type;#date}\n{comment;ignored}\n{broken",
);
assert_eq!(
highlighted.lines[0].spans[1].style.fg,
Some(Color::LightCyan)
);
assert_eq!(highlighted.lines[1].spans[3].style.fg, Some(Color::Yellow));
assert_eq!(
highlighted.lines[1].spans[5].style.fg,
Some(Color::LightYellow)
);
assert_eq!(
highlighted.lines[2].spans[3].style.fg,
Some(Color::LightMagenta)
);
assert_eq!(
highlighted.lines[3].spans[1].style.fg,
Some(Color::DarkGray)
);
assert_eq!(
highlighted.lines[4].spans[0].style.fg,
Some(Color::LightRed)
);
}
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();
let path = directory.path().join("document-ui.agnd");
let mut db = Database::open(&path).unwrap();
let mut settings = db.document_settings().unwrap();
settings.note_tab_width = 3;
settings.trash_policy = "immediate".into();
db.save_document_settings(&settings).unwrap();
db.add_item("Disposable item").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(5), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL))
.unwrap();
assert_eq!(app.items[0].note, " x");
app.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE))
.unwrap();
assert!(matches!(app.mode, Mode::Confirm { .. }));
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.unwrap();
assert_eq!(app.items.len(), 1);
app.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert!(app.items.is_empty());
}
}