working prototype
This commit is contained in:
935
src/ui.rs
Normal file
935
src/ui.rs
Normal file
@@ -0,0 +1,935 @@
|
||||
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, Mode},
|
||||
filter,
|
||||
model::{Item, ViewColumn},
|
||||
parser::pretty_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);
|
||||
|
||||
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
|
||||
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(Color::Yellow).bg(BLUE))
|
||||
.alignment(Alignment::Center),
|
||||
frame.area(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(2),
|
||||
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]);
|
||||
draw_commands(frame, app, chunks[4]);
|
||||
draw_overlay(frame, app);
|
||||
}
|
||||
|
||||
fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
let alarms = app.db.due_alarm_count().unwrap_or(0);
|
||||
let right = format!(
|
||||
"{} {}{}",
|
||||
Local::now().format("%Y-%m-%d"),
|
||||
Local::now().format("%H:%M"),
|
||||
if alarms > 0 {
|
||||
format!(" ALARM:{alarms}")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
let width = area.width as usize;
|
||||
let left1 = format!("File: {}", app.path.display());
|
||||
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)
|
||||
}
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![Line::from(line1), Line::from(line2)]).style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(BLUE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_view_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
app.view_regions.clear();
|
||||
let mut x = area.x;
|
||||
frame.render_widget(Block::default().style(Style::default().bg(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(BLUE)
|
||||
.bg(Color::White)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White).bg(BLUE)
|
||||
};
|
||||
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) {
|
||||
frame.render_widget(Block::default().style(Style::default().bg(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(BLUE).bg(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(§ion.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(BLUE)
|
||||
.bg(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)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
DisplayLine::Item(index) => {
|
||||
let item = &app.items[*index];
|
||||
let style = if *index == app.selected {
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(RED)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(if item.done_at.is_some() {
|
||||
Color::DarkGray
|
||||
} else {
|
||||
Color::Black
|
||||
})
|
||||
.bg(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))
|
||||
.collect();
|
||||
lines.push(cells_line(
|
||||
cells,
|
||||
&widths,
|
||||
Style::default()
|
||||
.fg(BLUE)
|
||||
.bg(CANVAS)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines).style(Style::default().bg(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 {
|
||||
"•"
|
||||
};
|
||||
format!("{marker} {}", item.text.replace('\n', " "))
|
||||
}
|
||||
"categories" => format_categories(item.category_names()),
|
||||
"when" => pretty_when(item.when_at.as_deref()),
|
||||
"priority" => item.priority.to_string(),
|
||||
"note" => item.note.replace('\n', " "),
|
||||
"value" => item
|
||||
.numeric_value
|
||||
.map(|v| format!("{v:.2}"))
|
||||
.unwrap_or_default(),
|
||||
"done" => pretty_when(item.done_at.as_deref()),
|
||||
"alarm" => pretty_when(item.alarm_at.as_deref()),
|
||||
"recurrence" => item.recurrence.clone(),
|
||||
"created" => pretty_when(Some(&item.created_at)),
|
||||
"updated" => pretty_when(Some(&item.updated_at)),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item]) -> 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!("{} {value:.2}", column.aggregate)
|
||||
}
|
||||
|
||||
fn draw_datebook(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
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>();
|
||||
if !dates.contains_key(&date) {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("── {date} ──"),
|
||||
Style::default()
|
||||
.fg(BLUE)
|
||||
.bg(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)
|
||||
} else {
|
||||
Style::default().fg(Color::Black).bg(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
|
||||
),
|
||||
style,
|
||||
)));
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines).style(Style::default().bg(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 draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
let totals = app
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| i.numeric_value)
|
||||
.collect::<Vec<_>>();
|
||||
let aggregate = if totals.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
" Σ {:.2} avg {:.2}",
|
||||
totals.iter().sum::<f64>(),
|
||||
totals.iter().sum::<f64>() / totals.len() as f64
|
||||
)
|
||||
};
|
||||
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(Color::Yellow).bg(BLUE)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_commands(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
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(BLUE)),
|
||||
*r,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||
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),
|
||||
)),
|
||||
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("[ ] views r prerequisites Delete trash/recover"),
|
||||
Line::from("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 }),
|
||||
);
|
||||
}
|
||||
Mode::Input(input) => {
|
||||
let h = if input.multiline { 18 } else { 7 };
|
||||
let shown = if input.value.is_empty() {
|
||||
Span::styled("Type here…", Style::default().fg(Color::DarkGray))
|
||||
} else {
|
||||
Span::raw(input.value.clone())
|
||||
};
|
||||
popup(
|
||||
frame,
|
||||
center(frame.area(), 78, h),
|
||||
&input.title,
|
||||
Paragraph::new(Line::from(shown)).wrap(Wrap { trim: false }),
|
||||
);
|
||||
}
|
||||
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 { CYAN }),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("> {}", p.values[i]),
|
||||
Style::default().fg(Color::White).bg(if i == p.field {
|
||||
RED
|
||||
} else {
|
||||
BLUE
|
||||
}),
|
||||
)),
|
||||
]
|
||||
})
|
||||
.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),
|
||||
);
|
||||
}
|
||||
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)",
|
||||
),
|
||||
};
|
||||
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 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
|
||||
} else {
|
||||
BLUE
|
||||
}),
|
||||
)),
|
||||
]
|
||||
})
|
||||
.chain([
|
||||
Line::from(""),
|
||||
Line::from(hint),
|
||||
Line::from(preview),
|
||||
Line::from("Tab fields · Enter save · Esc back"),
|
||||
])
|
||||
.collect::<Vec<_>>();
|
||||
popup(
|
||||
frame,
|
||||
center(frame.area(), 96, 24),
|
||||
title,
|
||||
Paragraph::new(lines),
|
||||
);
|
||||
}
|
||||
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(BLUE)),
|
||||
)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(RED)
|
||||
.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(BLUE)),
|
||||
)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(RED)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
area,
|
||||
&mut state,
|
||||
);
|
||||
}
|
||||
Mode::Menu => {
|
||||
popup(
|
||||
frame,
|
||||
center(frame.area(), 46, 10),
|
||||
"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(" q Quit"),
|
||||
Line::from(""),
|
||||
Line::from(" Esc closes the menu"),
|
||||
])
|
||||
.style(Style::default().fg(Color::White)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: bool) {
|
||||
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 = 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();
|
||||
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(BLUE)),
|
||||
)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(RED)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
area,
|
||||
&mut state,
|
||||
);
|
||||
}
|
||||
|
||||
fn popup(frame: &mut Frame<'_>, area: Rect, title: &str, widget: Paragraph<'_>) {
|
||||
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)),
|
||||
area,
|
||||
);
|
||||
frame.render_widget(
|
||||
widget.style(Style::default().fg(Color::White).bg(BLUE)),
|
||||
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};
|
||||
use ratatui::{Terminal, 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(db, p).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(db, p).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(db, p).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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user