Files
RogueAgenda/src/ui.rs
Chili Palmer 16150b5a8f complete category-scoped values, rules, and report parity
- Add category-bound date and numeric values
- Complete rule execution and conflict handling
- Expand Markdown and HTML report output
2026-08-20 21:01:02 +02:00

1905 lines
67 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},
date::format_when,
filter,
model::{Aggregate, Item, ViewColumn, ViewKind},
};
mod theme;
use theme::{Palette, palette};
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();
app.item_columns.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 == ViewKind::Datebook {
draw_datebook(frame, app, area);
return;
}
let mut base_columns = app
.current_view()
.columns
.iter()
.cloned()
.enumerate()
.collect::<Vec<_>>();
if area.width < 70 && base_columns.len() > 2 {
let item = base_columns
.iter()
.find(|(_, column)| column.field == "item")
.cloned()
.unwrap_or_else(|| base_columns[0].clone());
let detail = base_columns
.iter()
.find(|(_, column)| column.field == "when")
.cloned()
.or_else(|| {
base_columns
.iter()
.find(|(_, column)| column.field != item.1.field)
.cloned()
});
base_columns = vec![item];
if let Some(detail) = detail {
base_columns.push(detail)
}
}
let columns = base_columns
.into_iter()
.flat_map(|(base_index, column)| {
let percent = column.percent_total.then(|| DisplayColumn {
column: column.clone(),
percent: true,
base_index,
});
std::iter::once(DisplayColumn {
column,
percent: false,
base_index,
})
.chain(percent)
})
.collect::<Vec<_>>();
let widths = column_widths(&columns, area.width);
let mut x = area.x;
for (column, width) in columns.iter().zip(&widths) {
app.item_columns.push((
Rect::new(
x,
area.y,
(*width).min(u16::MAX as usize) as u16,
area.height,
),
column.base_index,
));
x = x.saturating_add((*width).min(u16::MAX as usize) as u16 + 1);
}
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,
section: (0..app.items.len()).collect(),
});
}
if columns
.iter()
.any(|column| column.column.aggregate != 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: *index,
section: indices.clone(),
});
}
if columns
.iter()
.any(|column| column.column.aggregate != Aggregate::None)
{
display.push(DisplayLine::Aggregate(indices));
}
}
}
let selected_line = display
.iter()
.position(|line| matches!(line,DisplayLine::Item { index, .. } if *index==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(DisplayColumn::heading).collect(),
&widths,
Style::default()
.fg(colors.canvas_heading)
.bg(colors.canvas)
.add_modifier(Modifier::BOLD),
)),
DisplayLine::Item { index, section } => {
let item = &app.items[*index];
let style = Style::default()
.fg(if item.done_at.is_some() {
Color::DarkGray
} else {
colors.foreground
})
.bg(colors.canvas);
let cells = columns
.iter()
.map(|column| column_value(column, item, section, &app.items, app))
.collect();
lines.push(cells_line_selected(
cells,
&columns,
&widths,
style,
(*index == app.selected).then_some(app.selected_column),
Style::default()
.fg(colors.selection_foreground)
.bg(colors.selection)
.add_modifier(Modifier::BOLD),
));
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(|column| {
if column.percent {
String::new()
} else {
aggregate_value(&column.column, 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 { index: usize, section: Vec<usize> },
Aggregate(Vec<usize>),
}
#[derive(Clone)]
struct DisplayColumn {
column: ViewColumn,
percent: bool,
base_index: usize,
}
impl DisplayColumn {
fn heading(&self) -> String {
if self.percent {
format!("{} %", self.column.heading)
} else {
self.column.heading.clone()
}
}
fn width(&self) -> u16 {
if self.percent { 10 } else { self.column.width }
}
}
fn column_widths(columns: &[DisplayColumn], 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(|column| column.width().max(1) as u32)
.sum::<u32>()
.max(1);
let mut widths = columns
.iter()
.map(|column| ((available as u32 * column.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_selected(
values: Vec<String>,
columns: &[DisplayColumn],
widths: &[usize],
style: Style,
selected: Option<usize>,
selected_style: Style,
) -> Line<'static> {
let mut spans = Vec::with_capacity(values.len() * 2);
for (index, ((value, width), column)) in values.into_iter().zip(widths).zip(columns).enumerate()
{
if index > 0 {
spans.push(Span::styled(" ", style));
}
spans.push(Span::styled(
fit_aligned(&value, *width, &column.column.alignment),
if selected == Some(column.base_index) && !column.percent {
selected_style
} else {
style
},
));
}
Line::from(spans)
}
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 fit_aligned(value: &str, width: usize, alignment: &str) -> String {
let fitted = fit(value, width);
let trimmed = fitted.trim_end();
let padding = width.saturating_sub(trimmed.chars().count());
match alignment {
"right" => format!("{}{trimmed}", " ".repeat(padding)),
"center" => format!(
"{}{}{}",
" ".repeat(padding / 2),
trimmed,
" ".repeat(padding - padding / 2)
),
_ => fitted,
}
}
fn column_value(
display: &DisplayColumn,
item: &Item,
section: &[usize],
items: &[Item],
app: &App,
) -> String {
let column = &display.column;
if display.percent {
let total = section
.iter()
.filter_map(|index| column.numeric_value(&items[*index]))
.sum::<f64>();
return column
.numeric_value(item)
.filter(|_| total != 0.0)
.map(|value| format!("{:.2}%", value * 100.0 / total))
.unwrap_or_default();
}
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" | "numeric" => column
.numeric_value(item)
.map(|value| column.format_number(value))
.unwrap_or_default(),
"date" => column
.category
.as_deref()
.and_then(|category| item.date_value_for(category))
.map(|value| formatted_when(app, Some(value)))
.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 {
let _ = app;
column.aggregate_value(indices.iter().map(|index| &items[*index]))
}
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 draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
let colors = palette(&app.preferences.theme);
let column = app
.current_view()
.columns
.get(app.selected_column)
.map(|column| format!(" Cell: {}", column.heading))
.unwrap_or_default();
let right = format!("{} item(s){column}", app.items.len());
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)",
"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, 16),
"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",
],
"Condition files live; action fires on entry (numeric:Hours=7.5)",
),
FormKind::View(_) => (
"Live View Definition",
vec![
"Name",
"Kind",
"Filter value",
"Boolean filter",
"Sort key",
"Show done (yes/no)",
"Columns (numeric[Hours]: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)",
"Report headers and footers (yes/no)",
],
"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(), 64, 17),
"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(" u Utilities Execute conditions/actions"),
Line::from(" f Inspect rule conflicts"),
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::Execute { selected } => {
let choices = [
"Current item",
"Marked items",
"Current section",
"Current view",
"Whole document",
];
let items = choices
.iter()
.map(|choice| ListItem::new(*choice))
.collect::<Vec<_>>();
let mut state = ListState::default().with_selected(Some(*selected));
let area = center(frame.area(), 62, 9);
frame.render_widget(Clear, area);
frame.render_stateful_widget(
List::new(items)
.block(
Block::bordered()
.title(" Utilities Execute ")
.title_bottom(" Enter applies conditions/actions · Esc cancels ")
.style(Style::default().fg(Color::White).bg(colors.primary)),
)
.highlight_style(
Style::default()
.fg(colors.selection_foreground)
.bg(colors.selection),
),
area,
&mut state,
);
}
Mode::RuleConflicts => {
let conflicts = app.db.rule_conflicts().unwrap_or_default();
let lines = if conflicts.is_empty() {
vec![Line::from("No rule conflicts.")]
} else {
conflicts
.iter()
.map(|conflict| {
Line::from(format!(
"#{} {}{}",
conflict.item_id, conflict.item_text, conflict.message
))
})
.collect()
};
popup(
frame,
center(frame.area(), 90, (lines.len() as u16 + 4).clamp(7, 22)),
"Rule Conflicts (any key closes)",
Paragraph::new(lines).wrap(Wrap { trim: false }),
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::2:.:comma:minus:percent:right",
"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("Total 75.00"));
assert!(screen.contains("100.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("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 = crate::model::TrashPolicy::Immediate;
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());
}
}