preferences added
This commit is contained in:
529
src/ui.rs
529
src/ui.rs
@@ -13,15 +13,46 @@ use crate::{
|
||||
app::{App, FormKind, Mode},
|
||||
filter,
|
||||
model::{Item, ViewColumn},
|
||||
parser::pretty_when,
|
||||
parser::format_when,
|
||||
};
|
||||
|
||||
const BLUE: Color = Color::Rgb(0, 0, 128);
|
||||
const CYAN: Color = Color::Cyan;
|
||||
const CANVAS: Color = Color::Rgb(192, 192, 192);
|
||||
const RED: Color = Color::Rgb(128, 0, 0);
|
||||
#[derive(Clone, Copy)]
|
||||
struct Palette {
|
||||
primary: Color,
|
||||
accent: Color,
|
||||
canvas: Color,
|
||||
selection: Color,
|
||||
foreground: Color,
|
||||
}
|
||||
|
||||
fn palette(theme: &str) -> Palette {
|
||||
match theme {
|
||||
"mono" => Palette {
|
||||
primary: Color::Black,
|
||||
accent: Color::White,
|
||||
canvas: Color::Gray,
|
||||
selection: Color::DarkGray,
|
||||
foreground: Color::Black,
|
||||
},
|
||||
"amber" => Palette {
|
||||
primary: Color::Black,
|
||||
accent: Color::Rgb(255, 191, 0),
|
||||
canvas: Color::Rgb(32, 24, 0),
|
||||
selection: Color::Rgb(112, 56, 0),
|
||||
foreground: Color::Rgb(255, 191, 0),
|
||||
},
|
||||
_ => Palette {
|
||||
primary: Color::Rgb(0, 0, 128),
|
||||
accent: Color::Cyan,
|
||||
canvas: Color::Rgb(192, 192, 192),
|
||||
selection: Color::Rgb(128, 0, 0),
|
||||
foreground: Color::Black,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
@@ -30,7 +61,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
|
||||
if height < 10 || frame.area().width < 40 {
|
||||
frame.render_widget(
|
||||
Paragraph::new("Rogue Agenda needs at least 40×10")
|
||||
.style(Style::default().fg(Color::Yellow).bg(BLUE))
|
||||
.style(Style::default().fg(colors.accent).bg(colors.primary))
|
||||
.alignment(Alignment::Center),
|
||||
frame.area(),
|
||||
);
|
||||
@@ -39,7 +70,11 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(if app.preferences.show_command_bar {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(1),
|
||||
@@ -50,16 +85,21 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
|
||||
draw_view_tabs(frame, app, chunks[1]);
|
||||
draw_items(frame, app, chunks[2]);
|
||||
draw_status(frame, app, chunks[3]);
|
||||
draw_commands(frame, app, chunks[4]);
|
||||
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!(
|
||||
"{} {}{}",
|
||||
Local::now().format("%Y-%m-%d"),
|
||||
Local::now().format("%H:%M"),
|
||||
"{}{}",
|
||||
formatted_when(app, Some(&now)),
|
||||
if alarms > 0 {
|
||||
format!(" ALARM:{alarms}")
|
||||
} else {
|
||||
@@ -67,7 +107,15 @@ fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
}
|
||||
);
|
||||
let width = area.width as usize;
|
||||
let left1 = format!("File: {}", app.path.display());
|
||||
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!(
|
||||
@@ -83,7 +131,7 @@ fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
Paragraph::new(vec![Line::from(line1), Line::from(line2)]).style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(BLUE)
|
||||
.bg(colors.primary)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
area,
|
||||
@@ -91,9 +139,13 @@ fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
}
|
||||
|
||||
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(CANVAS)), area);
|
||||
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() {
|
||||
@@ -103,11 +155,11 @@ fn draw_view_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
app.view_regions.push(r);
|
||||
let st = if i == app.view_index {
|
||||
Style::default()
|
||||
.fg(BLUE)
|
||||
.fg(colors.primary)
|
||||
.bg(Color::White)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White).bg(BLUE)
|
||||
Style::default().fg(Color::White).bg(colors.primary)
|
||||
};
|
||||
frame.render_widget(Paragraph::new(format!(" {} ", v.name)).style(st), r);
|
||||
x += w + 1;
|
||||
@@ -115,7 +167,11 @@ fn draw_view_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
}
|
||||
|
||||
fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
frame.render_widget(Block::default().style(Style::default().bg(CANVAS)), area);
|
||||
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(
|
||||
@@ -124,7 +180,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(BLUE).bg(CANVAS)),
|
||||
.style(Style::default().fg(colors.primary).bg(colors.canvas)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
@@ -204,16 +260,16 @@ 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(BLUE)
|
||||
.bg(CANVAS)
|
||||
.fg(colors.primary)
|
||||
.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(BLUE)
|
||||
.bg(CANVAS)
|
||||
.fg(colors.primary)
|
||||
.bg(colors.canvas)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
DisplayLine::Item(index) => {
|
||||
@@ -221,16 +277,16 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
let style = if *index == app.selected {
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(RED)
|
||||
.bg(colors.selection)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(if item.done_at.is_some() {
|
||||
Color::DarkGray
|
||||
} else {
|
||||
Color::Black
|
||||
colors.foreground
|
||||
})
|
||||
.bg(CANVAS)
|
||||
.bg(colors.canvas)
|
||||
};
|
||||
let cells = columns.iter().map(|c| column_value(c, item, app)).collect();
|
||||
lines.push(cells_line(cells, &widths, style));
|
||||
@@ -242,21 +298,21 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
DisplayLine::Aggregate(indices) => {
|
||||
let cells = columns
|
||||
.iter()
|
||||
.map(|c| aggregate_value(c, indices, &app.items))
|
||||
.map(|c| aggregate_value(c, indices, &app.items, app))
|
||||
.collect();
|
||||
lines.push(cells_line(
|
||||
cells,
|
||||
&widths,
|
||||
Style::default()
|
||||
.fg(BLUE)
|
||||
.bg(CANVAS)
|
||||
.fg(colors.primary)
|
||||
.bg(colors.canvas)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines).style(Style::default().bg(CANVAS)),
|
||||
Paragraph::new(lines).style(Style::default().bg(colors.canvas)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
@@ -317,27 +373,34 @@ fn column_value(column: &ViewColumn, item: &Item, app: &App) -> String {
|
||||
} 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" => pretty_when(item.when_at.as_deref()),
|
||||
"when" => formatted_when(app, item.when_at.as_deref()),
|
||||
"priority" => item.priority.to_string(),
|
||||
"note" => item.note.replace('\n', " "),
|
||||
"note" => item.note.replace(
|
||||
'\n',
|
||||
if app.preferences.show_carriage_returns {
|
||||
"↵ "
|
||||
} else {
|
||||
" "
|
||||
},
|
||||
),
|
||||
"value" => item
|
||||
.numeric_value
|
||||
.map(|v| format!("{v:.2}"))
|
||||
.map(|v| format_number(v, app))
|
||||
.unwrap_or_default(),
|
||||
"done" => pretty_when(item.done_at.as_deref()),
|
||||
"alarm" => pretty_when(item.alarm_at.as_deref()),
|
||||
"done" => formatted_when(app, item.done_at.as_deref()),
|
||||
"alarm" => formatted_when(app, item.alarm_at.as_deref()),
|
||||
"recurrence" => item.recurrence.clone(),
|
||||
"created" => pretty_when(Some(&item.created_at)),
|
||||
"updated" => pretty_when(Some(&item.updated_at)),
|
||||
"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]) -> String {
|
||||
fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item], app: &App) -> String {
|
||||
if column.aggregate == "none" {
|
||||
return String::new();
|
||||
}
|
||||
@@ -362,50 +425,43 @@ fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item]) -> St
|
||||
"max" => values.iter().copied().fold(f64::NEG_INFINITY, f64::max),
|
||||
_ => return String::new(),
|
||||
};
|
||||
format!("{} {value:.2}", column.aggregate)
|
||||
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 date = pretty_when(i.when_at.as_deref())
|
||||
.chars()
|
||||
.take(10)
|
||||
.collect::<String>();
|
||||
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(BLUE)
|
||||
.bg(CANVAS)
|
||||
.fg(colors.primary)
|
||||
.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(Color::White).bg(RED)
|
||||
Style::default().fg(Color::White).bg(colors.selection)
|
||||
} else {
|
||||
Style::default().fg(Color::Black).bg(CANVAS)
|
||||
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!(
|
||||
" {} {}",
|
||||
pretty_when(i.when_at.as_deref())
|
||||
.chars()
|
||||
.skip(11)
|
||||
.collect::<String>(),
|
||||
i.text
|
||||
),
|
||||
format!(" {} {}", time, i.text),
|
||||
style,
|
||||
)));
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines).style(Style::default().bg(CANVAS)),
|
||||
Paragraph::new(lines).style(Style::default().bg(colors.canvas)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
@@ -421,7 +477,37 @@ fn format_categories(s: String) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -431,9 +517,9 @@ fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
" Σ {:.2} avg {:.2}",
|
||||
totals.iter().sum::<f64>(),
|
||||
totals.iter().sum::<f64>() / totals.len() as f64
|
||||
" Σ {} 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);
|
||||
@@ -443,12 +529,13 @@ fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
as usize;
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(" {}{}{right}", app.status, " ".repeat(pad)))
|
||||
.style(Style::default().fg(Color::Yellow).bg(BLUE)),
|
||||
.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",
|
||||
];
|
||||
@@ -465,20 +552,23 @@ fn draw_commands(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
Line::from(labels[i]),
|
||||
])
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default().fg(Color::White).bg(BLUE)),
|
||||
.style(Style::default().fg(Color::White).bg(colors.primary)),
|
||||
*r,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
let colors = palette(&app.preferences.theme);
|
||||
match &app.mode {
|
||||
Mode::Normal => {}
|
||||
Mode::Help => {
|
||||
let text = Text::from(vec![
|
||||
Line::from(Span::styled(
|
||||
"ROGUE AGENDA HELP",
|
||||
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
|
||||
Style::default()
|
||||
.fg(colors.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(
|
||||
@@ -499,6 +589,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
center(frame.area(), 74, 15),
|
||||
"Help",
|
||||
Paragraph::new(text).wrap(Wrap { trim: false }),
|
||||
colors,
|
||||
);
|
||||
}
|
||||
Mode::Input(input) => {
|
||||
@@ -522,7 +613,13 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)))
|
||||
} else {
|
||||
Text::from(input.value.as_str())
|
||||
Text::from(
|
||||
if app.preferences.show_carriage_returns && input.multiline {
|
||||
input.value.replace('\n', "↵\n")
|
||||
} else {
|
||||
input.value.clone()
|
||||
},
|
||||
)
|
||||
};
|
||||
popup(
|
||||
frame,
|
||||
@@ -532,6 +629,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
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,
|
||||
@@ -553,14 +651,18 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
[
|
||||
Line::from(Span::styled(
|
||||
*n,
|
||||
Style::default().fg(if i == p.field { Color::Yellow } else { CYAN }),
|
||||
Style::default().fg(if i == p.field {
|
||||
Color::Yellow
|
||||
} else {
|
||||
colors.accent
|
||||
}),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("> {}", p.values[i]),
|
||||
Style::default().fg(Color::White).bg(if i == p.field {
|
||||
RED
|
||||
colors.selection
|
||||
} else {
|
||||
BLUE
|
||||
colors.primary
|
||||
}),
|
||||
)),
|
||||
]
|
||||
@@ -575,6 +677,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
center(frame.area(), 78, 18),
|
||||
"Item Properties",
|
||||
Paragraph::new(lines),
|
||||
colors,
|
||||
);
|
||||
}
|
||||
Mode::Form(form) => {
|
||||
@@ -606,35 +709,79 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
],
|
||||
"Filters: category=Work and (open or priority<=2)",
|
||||
),
|
||||
FormKind::Preferences => (
|
||||
"Preferences (~/.config/rogue-agenda/preferences.toml)",
|
||||
vec![
|
||||
"Theme (classic/mono/amber)",
|
||||
"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 filter_source = match form.kind {
|
||||
FormKind::Category(_) => form.values.get(5),
|
||||
FormKind::View(_) => form.values.get(3),
|
||||
}
|
||||
.map(String::as_str)
|
||||
.unwrap_or("");
|
||||
let preview = match app.db.count_matching_filter(filter_source) {
|
||||
Ok(n) => format!("Preview: {n} existing item(s) match"),
|
||||
Err(e) => format!("Preview error: {e}"),
|
||||
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()
|
||||
.flat_map(|(i, name)| {
|
||||
[
|
||||
Line::from(Span::styled(
|
||||
*name,
|
||||
Style::default().fg(if i == form.field { Color::Yellow } else { CYAN }),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("> {}", form.values[i]),
|
||||
Style::default().fg(Color::White).bg(if i == form.field {
|
||||
RED
|
||||
.map(|(i, name)| {
|
||||
Line::from(Span::styled(
|
||||
format!("{name:<50} {}", form.values[i]),
|
||||
Style::default()
|
||||
.fg(if i == form.field {
|
||||
Color::White
|
||||
} else {
|
||||
BLUE
|
||||
colors.accent
|
||||
})
|
||||
.bg(if i == form.field {
|
||||
colors.selection
|
||||
} else {
|
||||
colors.primary
|
||||
}),
|
||||
)),
|
||||
]
|
||||
))
|
||||
})
|
||||
.chain([
|
||||
Line::from(""),
|
||||
@@ -645,9 +792,10 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
.collect::<Vec<_>>();
|
||||
popup(
|
||||
frame,
|
||||
center(frame.area(), 96, 24),
|
||||
center(frame.area(), 110, (names.len() as u16 + 6).clamp(12, 22)),
|
||||
title,
|
||||
Paragraph::new(lines),
|
||||
colors,
|
||||
);
|
||||
}
|
||||
Mode::Dependencies {
|
||||
@@ -678,12 +826,12 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title(" Prerequisites (Space toggles · Enter closes) ")
|
||||
.style(Style::default().fg(Color::White).bg(BLUE)),
|
||||
.style(Style::default().fg(Color::White).bg(colors.primary)),
|
||||
)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(RED)
|
||||
.bg(colors.selection)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
area,
|
||||
@@ -720,12 +868,12 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
.title(
|
||||
" View Manager (n add · e edit · Ctrl-↑↓ move · Delete discard) ",
|
||||
)
|
||||
.style(Style::default().fg(Color::White).bg(BLUE)),
|
||||
.style(Style::default().fg(Color::White).bg(colors.primary)),
|
||||
)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(RED)
|
||||
.bg(colors.selection)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
area,
|
||||
@@ -735,24 +883,43 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
Mode::Menu => {
|
||||
popup(
|
||||
frame,
|
||||
center(frame.area(), 46, 10),
|
||||
center(frame.area(), 58, 14),
|
||||
"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(" 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -785,18 +952,21 @@ fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: boo
|
||||
.and_then(|p| names.get(&p))
|
||||
.map(|p| format!(" ({p})"))
|
||||
.unwrap_or_default();
|
||||
let rule = 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();
|
||||
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,
|
||||
@@ -818,12 +988,12 @@ fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: boo
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title(format!(" {title} "))
|
||||
.style(Style::default().fg(Color::White).bg(BLUE)),
|
||||
.style(Style::default().fg(Color::White).bg(colors.primary)),
|
||||
)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(RED)
|
||||
.bg(colors.selection)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
area,
|
||||
@@ -831,18 +1001,18 @@ fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: boo
|
||||
);
|
||||
}
|
||||
|
||||
fn popup(frame: &mut Frame<'_>, area: Rect, title: &str, widget: Paragraph<'_>) {
|
||||
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(CYAN))
|
||||
.style(Style::default().bg(BLUE)),
|
||||
.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(BLUE)),
|
||||
widget.style(Style::default().fg(Color::White).bg(colors.primary)),
|
||||
area.inner(Margin {
|
||||
horizontal: 1,
|
||||
vertical: 1,
|
||||
@@ -863,7 +1033,7 @@ fn center(area: Rect, width: u16, height: u16) -> Rect {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{app::App, db::Database, model::ItemChanges};
|
||||
use crate::{app::App, db::Database, model::ItemChanges, preferences::AppPreferences};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{
|
||||
Terminal,
|
||||
@@ -876,7 +1046,13 @@ mod tests {
|
||||
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(db, p).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();
|
||||
@@ -920,7 +1096,13 @@ mod tests {
|
||||
"Urgent work|priority=1",
|
||||
)
|
||||
.unwrap();
|
||||
let mut app = App::new(db, p).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);
|
||||
@@ -945,7 +1127,13 @@ mod tests {
|
||||
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(db, p).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();
|
||||
@@ -966,7 +1154,13 @@ mod tests {
|
||||
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(db, p).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() {
|
||||
@@ -1006,4 +1200,105 @@ mod tests {
|
||||
.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 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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user