use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Margin, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Text;
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, BorderType, Cell, Clear, Gauge, List, ListItem, Padding, Paragraph, Row, Scrollbar,
ScrollbarOrientation, ScrollbarState, Table,
};
use ratatui_image::{Resize, StatefulImage};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::app::{App, Focus, MessageKind, Mode, SETTINGS_ITEMS, UpdateActivity};
use crate::banner;
use crate::due;
use crate::form::Field;
use crate::theme::Theme;
pub const SIDEBAR_WIDTH: u16 = 26;
pub const DONE_MARK_WIDTH: u16 = 3;
const PREVIEW_SPLIT_MIN: u16 = 16;
const LIST_MIN: u16 = 6;
const PREVIEW_MIN: u16 = 8;
const LIST_WIDTH_MIN: u16 = 24;
const PREVIEW_WIDTH_MIN: u16 = 28;
const PREVIEW_SIDE_MIN: u16 = LIST_WIDTH_MIN + PREVIEW_WIDTH_MIN + 1;
pub const MIN_TERMINAL_WIDTH: u16 = 60;
pub const MIN_TERMINAL_HEIGHT: u16 = 16;
pub fn draw(f: &mut Frame, app: &mut App) {
let area = f.area();
app.areas = crate::app::Areas::default();
if let Some(form) = &mut app.form {
form.areas = crate::form::FieldAreas::default();
form.body_menu_area = None;
form.image_hits.clear();
if let Some(picker) = &mut form.picker {
picker.layout = crate::duepicker::PickerLayout::default();
}
}
if let Some(form) = &mut app.category_form {
form.name_area = Rect::ZERO;
form.description_area = Rect::ZERO;
}
if area.width < MIN_TERMINAL_WIDTH || area.height < MIN_TERMINAL_HEIGHT {
let p = Paragraph::new(format!(
"too small · need {MIN_TERMINAL_WIDTH}×{MIN_TERMINAL_HEIGHT}"
))
.centered();
f.render_widget(p, area);
return;
}
let theme = app.theme();
let [content, status] =
Layout::vertical([Constraint::Min(3), Constraint::Length(3)]).areas(area);
let [sidebar, right] =
Layout::horizontal([Constraint::Length(SIDEBAR_WIDTH), Constraint::Min(20)])
.spacing(1)
.areas(content);
let mut modal_task_form = false;
draw_sidebar(f, app, &theme, sidebar);
if let Some((list, preview_rect)) =
split_tasks_and_preview(right, &app.settings.preview_position)
{
app.areas.preview = preview_rect;
draw_tasks(f, app, &theme, list);
match app.mode {
Mode::TaskForm => match docked_task_form_layout(preview_rect) {
Some(layout) => draw_task_form(f, app, &theme, preview_rect, layout),
None => {
draw_task_preview(f, app, &theme, preview_rect);
modal_task_form = true;
}
},
_ => draw_task_preview(f, app, &theme, preview_rect),
}
} else {
app.areas.preview = Rect::ZERO;
draw_tasks(f, app, &theme, right);
if app.mode == Mode::TaskForm {
modal_task_form = true;
}
}
draw_status(f, app, &theme, status);
if app.mode == Mode::Slash {
draw_slash_palette(f, app, &theme, status);
}
match app.mode {
Mode::Help => draw_help(f, app, &theme, area),
Mode::Settings => draw_settings(f, app, &theme, area),
Mode::Welcome => draw_welcome(f, &theme, area),
Mode::WhatsNew => draw_whats_new(f, &theme, area),
Mode::CategoryForm => draw_category_form(f, app, &theme, area),
Mode::TaskForm if modal_task_form => {
draw_task_form(f, app, &theme, area, TaskFormLayout::Modal);
}
Mode::TaskForm => {} _ => {}
}
}
fn split_tasks_and_preview(right: Rect, position: &str) -> Option<(Rect, Rect)> {
if position == "right"
&& let Some(pair) = split_preview_right(right)
{
return Some(pair);
}
split_preview_bottom(right)
}
fn split_preview_bottom(right: Rect) -> Option<(Rect, Rect)> {
if right.height < PREVIEW_SPLIT_MIN {
return None;
}
let [list, preview] = Layout::vertical([
Constraint::Min(LIST_MIN),
Constraint::Length((right.height / 2).max(PREVIEW_MIN)),
])
.spacing(0)
.areas(right);
if list.height < LIST_MIN || preview.height < PREVIEW_MIN {
return None;
}
Some((list, preview))
}
fn split_preview_right(right: Rect) -> Option<(Rect, Rect)> {
if right.width < PREVIEW_SIDE_MIN || right.height < PREVIEW_MIN {
return None;
}
let preview_w = (right.width / 2).max(PREVIEW_WIDTH_MIN);
let [list, preview] = Layout::horizontal([
Constraint::Min(LIST_WIDTH_MIN),
Constraint::Length(preview_w),
])
.spacing(1)
.areas(right);
if list.width < LIST_WIDTH_MIN || preview.width < PREVIEW_WIDTH_MIN {
return None;
}
Some((list, preview))
}
const TASK_FORM_WIDE_CHROME: u16 = 9;
const TASK_FORM_COMPACT_CHROME: u16 = 15;
const TASK_FORM_MIN_BODY_HEIGHT: u16 = 3;
const TASK_FORM_WIDE_MIN_WIDTH: u16 = 56;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TaskFormLayout {
DockedWide,
DockedCompact,
Modal,
}
impl TaskFormLayout {
fn is_docked(self) -> bool {
!matches!(self, Self::Modal)
}
fn is_compact(self) -> bool {
matches!(self, Self::DockedCompact)
}
}
fn docked_task_form_layout(area: Rect) -> Option<TaskFormLayout> {
if area.width >= TASK_FORM_WIDE_MIN_WIDTH
&& area.height >= TASK_FORM_WIDE_CHROME + TASK_FORM_MIN_BODY_HEIGHT
{
Some(TaskFormLayout::DockedWide)
} else if area.width >= PREVIEW_WIDTH_MIN
&& area.height >= TASK_FORM_COMPACT_CHROME + TASK_FORM_MIN_BODY_HEIGHT
{
Some(TaskFormLayout::DockedCompact)
} else {
None
}
}
fn draw_task_form(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect, layout: TaskFormLayout) {
let App {
form,
images: store,
..
} = app;
let Some(form) = form.as_mut() else { return };
let rect = if layout.is_docked() {
area
} else {
let width = 92.min(area.width.saturating_sub(4));
let body_height = area
.height
.saturating_sub(TASK_FORM_WIDE_CHROME)
.clamp(TASK_FORM_MIN_BODY_HEIGHT, 22);
centered(
area,
width,
(TASK_FORM_WIDE_CHROME + body_height).min(area.height),
)
};
let h_pad = if layout.is_docked() { 1 } else { 2 };
let block = Block::bordered()
.border_type(BorderType::Thick)
.border_style(theme.accent_text())
.title(Span::styled(
format!(" {} ", form.title_text()),
theme.accent_text().bold(),
))
.padding(Padding::new(h_pad, h_pad, 0, 0));
let inner = block.inner(rect);
f.render_widget(Clear, rect);
f.render_widget(block, rect);
let (title_box, category_box, due_box, importance_box, body_box, hint) = if layout.is_compact()
{
let [title, category, due, importance, body, hint] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(TASK_FORM_MIN_BODY_HEIGHT),
Constraint::Length(1),
])
.areas(inner);
(title, category, due, importance, body, hint)
} else {
let [title, metadata, body, hint] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(TASK_FORM_MIN_BODY_HEIGHT),
Constraint::Length(1),
])
.areas(inner);
let [category, due, importance] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(20),
Constraint::Length(9),
])
.spacing(1)
.areas(metadata);
(title, category, due, importance, body, hint)
};
let focused = form.field == Field::Title;
let box_inner = render_field_box(f, field_block("Title", focused, None, theme), title_box);
form.areas.title = box_inner;
let view = form.title.visible(box_inner.width as usize);
if view.text.is_empty() {
render_or_placeholder(f, box_inner, "", "what needs doing?", theme);
} else {
f.render_widget(
Paragraph::new(line_with_selection(
&view.text,
view.sel_cols,
Style::new(),
theme,
)),
box_inner,
);
}
if focused {
f.set_cursor_position((box_inner.x.saturating_add(view.cursor_col), box_inner.y));
}
let focused = form.field == Field::Category;
let box_inner = render_field_box(
f,
field_block("Category", focused, None, theme),
category_box,
);
form.areas.category = box_inner;
let category = format!("‹ {} ›", form.category_label());
f.render_widget(
Paragraph::new(truncate(&category, box_inner.width as usize)),
box_inner,
);
let focused = form.field == Field::Due;
let box_inner = render_field_box(f, field_block("Due", focused, None, theme), due_box);
form.areas.due = due_box;
let view = form.due.visible(box_inner.width as usize);
render_or_placeholder(f, box_inner, &view.text, "↵ Enter", theme);
let focused = form.field == Field::Importance;
let box_inner = render_field_box(
f,
field_block("Flags", focused, None, theme),
importance_box,
);
form.areas.importance = box_inner;
let marks = crate::model::importance_marks(form.importance);
if marks.is_empty() {
render_or_placeholder(f, box_inner, "", "→", theme);
} else {
f.render_widget(
Paragraph::new(Line::styled(marks, Style::new().fg(theme.error_color()))),
box_inner,
);
}
let focused = form.field == Field::Body;
let (done, total) = form.body.progress();
let progress = (total > 0).then(|| format!("{done}/{total}"));
let box_inner = render_field_box(f, field_block("Body", focused, progress, theme), body_box);
form.areas.body = box_inner;
draw_body(f, form, store, theme, box_inner, focused);
scrollbar(
f,
theme,
body_box,
form.body.content_height(),
box_inner.height as usize,
form.body.scroll(),
focused,
);
let footer = match &form.error {
Some(error) => Line::styled(
truncate(error, hint.width as usize),
Style::new()
.fg(theme.error_color())
.add_modifier(Modifier::BOLD),
),
None => Line::styled(
match layout {
TaskFormLayout::DockedWide => "/ commands · Ctrl+Z undo · Ctrl+S save · Esc list",
TaskFormLayout::DockedCompact => "/ · Ctrl+S save · Esc list",
TaskFormLayout::Modal => "/ commands · Ctrl+Z undo · Ctrl+S save · Esc cancel",
},
Style::new().fg(theme.muted_color()),
),
};
f.render_widget(Paragraph::new(footer), hint);
let overlay = f.area();
if let Some(picker) = form.picker.as_mut() {
draw_due_picker(f, theme, picker, form.areas.due, overlay);
}
if form.preview
&& let Some(path) = form
.body
.selected_image()
.or_else(|| form.body.images().first().cloned())
{
draw_image_preview(f, store, form, theme, &path, overlay);
}
}
fn draw_task_preview(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
let focused = false;
let block = panel("Task preview", focused, theme);
let inner = block.inner(area);
f.render_widget(block, area);
if inner.height == 0 || inner.width == 0 {
return;
}
let Some(task) = app.selected_task().cloned() else {
app.invalidate_preview();
let style = Style::new().fg(theme.muted_color());
draw_box(f, inner, "Select a task · Enter to edit", style);
return;
};
let image_paths: Vec<_> = task
.body
.iter()
.filter_map(|block| match block {
crate::model::Block::Image { attachment_id } => Some(app.images.resolve(attachment_id)),
_ => None,
})
.collect();
let todo = crate::model::todo_progress(&task);
let title = task.title;
let done = task.done;
let due_s = due::display(&task.due, &app.settings.date_format);
let importance = task.importance;
let body_empty = task.body.is_empty();
app.images.prefetch(image_paths);
let flags = crate::model::importance_marks(importance);
let mut meta = String::new();
if !due_s.is_empty() {
meta.push_str(&due_s);
}
if !flags.is_empty() {
if !meta.is_empty() {
meta.push_str(" ");
}
meta.push_str(&flags);
}
if let Some((d, t)) = todo {
if !meta.is_empty() {
meta.push_str(" ");
}
meta.push_str(&format!("{d}/{t}"));
}
let title_style = if done {
Style::new()
.fg(theme.muted_color())
.add_modifier(Modifier::CROSSED_OUT | Modifier::BOLD)
} else {
Style::new().add_modifier(Modifier::BOLD)
};
let (title_row, meta_row, body_area) = if meta.is_empty() {
let [t, b] = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(inner);
(t, None, b)
} else {
let [t, m, b] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.areas(inner);
(t, Some(m), b)
};
f.render_widget(
Paragraph::new(Line::styled(
truncate(&title, title_row.width as usize),
title_style,
)),
title_row,
);
if let Some(meta_row) = meta_row {
f.render_widget(
Paragraph::new(Line::styled(
truncate(&meta, meta_row.width as usize),
Style::new().fg(theme.muted_color()),
)),
meta_row,
);
}
if body_area.height == 0 {
return;
}
if body_empty {
f.render_widget(
Paragraph::new(Line::styled(
"Enter to edit",
Style::new().fg(theme.muted_color()),
)),
body_area,
);
return;
}
app.ensure_preview();
let App {
images: store,
preview_form,
..
} = app;
if let Some(paint) = preview_form.as_mut() {
draw_body(f, paint, store, theme, body_area, false);
if paint.body.content_height() > usize::from(body_area.height) && body_area.height > 0 {
let indicator = Rect {
y: body_area.bottom() - 1,
height: 1,
..body_area
};
f.render_widget(
Paragraph::new(Line::styled(
"↓ more · Enter to edit",
Style::new()
.fg(theme.muted_color())
.add_modifier(Modifier::BOLD),
)),
indicator,
);
}
}
}
fn field_block<'a>(
label: &'a str,
focused: bool,
note: Option<String>,
theme: &Theme,
) -> Block<'a> {
let (border, label_style) = if focused {
(theme.accent_text(), theme.accent_text().bold())
} else {
(
Style::new().fg(theme.muted_color()),
Style::new()
.fg(theme.muted_color())
.add_modifier(Modifier::BOLD),
)
};
let mut block = Block::bordered()
.border_type(BorderType::Thick)
.border_style(border)
.title(Span::styled(format!(" {label} "), label_style))
.padding(Padding::horizontal(1));
if let Some(note) = note {
block = block.title_top(
Line::styled(format!(" {note} "), Style::new().fg(theme.muted_color())).right_aligned(),
);
}
block
}
fn draw_category_form(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
let Some(form) = &mut app.category_form else {
return;
};
const CHROME: u16 = 6;
let text_height = area.height.saturating_sub(CHROME).clamp(3, 12);
let width = 72.min(area.width.saturating_sub(4));
let rect = centered(area, width, (CHROME + text_height).min(area.height));
let block = Block::bordered()
.border_type(BorderType::Thick)
.border_style(theme.accent_text())
.title(Span::styled(
format!(" {} ", form.title_text()),
theme.accent_text().bold(),
))
.padding(Padding::horizontal(1));
let inner = block.inner(rect);
f.render_widget(Clear, rect);
f.render_widget(block, rect);
let [name_box, text_box, hint] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(text_height),
Constraint::Length(1),
])
.areas(inner);
let focused = !form.on_description;
let box_inner = render_field_box(f, field_block("Name", focused, None, theme), name_box);
form.name_area = box_inner;
let view = form.name.visible(box_inner.width as usize);
if view.text.is_empty() {
render_or_placeholder(f, box_inner, "", "What to call it", theme);
} else {
f.render_widget(
Paragraph::new(line_with_selection(
&view.text,
view.sel_cols,
Style::new(),
theme,
)),
box_inner,
);
}
if focused {
f.set_cursor_position((box_inner.x.saturating_add(view.cursor_col), box_inner.y));
}
let focused = form.on_description;
let box_inner = render_field_box(
f,
field_block("Description", focused, None, theme),
text_box,
);
form.description_area = box_inner;
let (lines, cursor) = form
.description
.layout(box_inner.width as usize, box_inner.height);
if form.description.is_empty() && form.description.menu.is_none() {
render_or_placeholder(f, box_inner, "", "Press / for commands", theme);
}
for placed in lines {
if matches!(placed.block, crate::body::Painted::Text { .. }) {
draw_placed_text(f, theme, box_inner, &placed);
}
}
if let (true, Some((row, col))) = (focused, cursor) {
f.set_cursor_position((
box_inner.x.saturating_add(col),
box_inner.y.saturating_add(row),
));
}
if focused {
draw_slash_menu(f, &form.description, theme, box_inner, cursor);
}
scrollbar(
f,
theme,
text_box,
form.description.content_height(),
box_inner.height as usize,
form.description.scroll(),
focused,
);
let footer = match &form.error {
Some(error) => Line::styled(
truncate(error, hint.width as usize),
Style::new()
.fg(theme.error_color())
.add_modifier(Modifier::BOLD),
),
None => Line::styled(
"/ commands · Ctrl+Z undo · Ctrl+S save · Esc cancel",
Style::new().fg(theme.muted_color()),
),
};
f.render_widget(Paragraph::new(footer), hint);
}
fn draw_body(
f: &mut Frame,
form: &mut crate::form::TaskForm,
store: &mut crate::image::ImageStore,
theme: &Theme,
area: Rect,
focused: bool,
) {
let menu_open = form.body.menu.is_some();
if form.body.is_empty() && form.body.menu.is_none() {
render_or_placeholder(f, area, "", "Press / for commands", theme);
}
let (blocks, cursor) = form.body.layout(area.width as usize, area.height);
let scroll = form.body.scroll();
if (form.menu_was_open && !menu_open) || form.body_scroll != scroll {
store.clear_cache();
f.render_widget(Clear, area);
}
form.menu_was_open = menu_open;
form.body_scroll = scroll;
let menu_rect = slash_menu_rect(&form.body, area, cursor);
form.body_menu_area = menu_rect;
form.image_hits.clear();
for placed in blocks {
match &placed.block {
crate::body::Painted::Image(path) => {
let row = Rect {
y: area.y.saturating_add(placed.y),
height: placed.rows,
..area
};
let covered = menu_rect.is_some_and(|m| rects_overlap(m, row));
let show_frame = focused && placed.selected;
if covered {
f.render_widget(Clear, row);
let hit = letterbox_rect(row, 4, 3);
draw_image_placeholder(f, theme, hit, show_frame);
form.image_hits.push((placed.line, hit));
} else if let Some(hit) = draw_image(f, store, theme, path, row, show_frame) {
form.image_hits.push((placed.line, hit));
}
}
crate::body::Painted::Text { .. } => {
draw_placed_text(f, theme, area, &placed);
}
}
}
if focused && let Some((row, col)) = cursor {
f.set_cursor_position((area.x.saturating_add(col), area.y.saturating_add(row)));
}
draw_slash_menu(f, &form.body, theme, area, cursor);
}
fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom()
}
fn slash_menu_rect(
body: &crate::body::BodyEditor,
area: Rect,
cursor: Option<(u16, u16)>,
) -> Option<Rect> {
body.menu.as_ref()?;
let commands = body.menu_commands();
if commands.is_empty() {
return None;
}
let width = 48.min(area.width);
let height = u16::try_from(commands.len())
.unwrap_or(u16::MAX)
.saturating_add(2);
let cursor_row = cursor.map(|(row, _)| row).unwrap_or(0);
let below = area.y.saturating_add(cursor_row).saturating_add(1);
let y = if area.bottom().saturating_sub(below) >= height {
below
} else {
area.y.saturating_add(cursor_row).saturating_sub(height)
};
Some(Rect {
x: area.x.saturating_add(
cursor
.map(|(_, col)| col)
.unwrap_or(0)
.min(area.width.saturating_sub(width)),
),
y,
width,
height,
})
}
fn draw_placed_text(f: &mut Frame, theme: &Theme, area: Rect, placed: &crate::body::Placed) {
let crate::body::Painted::Text { rows, kind } = &placed.block else {
return;
};
let indent = kind.indent();
let max_rows = placed.rows as usize;
for (i, wr) in rows.iter().enumerate().take(max_rows) {
let y = area
.y
.saturating_add(placed.y)
.saturating_add(u16::try_from(i).unwrap_or(u16::MAX));
if y >= area.bottom() {
break;
}
let row = Rect {
x: area.x,
y,
width: area.width,
height: 1,
};
let base = match kind {
crate::body::TextKind::Link => Style::new()
.fg(theme.accent)
.add_modifier(Modifier::UNDERLINED),
crate::body::TextKind::Todo { done: true } => Style::new()
.fg(theme.muted_color())
.add_modifier(Modifier::CROSSED_OUT),
_ => Style::new(),
};
let body = line_with_selection(&wr.text, wr.sel, base, theme);
let line = if i == 0 {
match kind {
crate::body::TextKind::Todo { done: true } => Line::from(
[
vec![Span::styled("[✓] ", Style::new().fg(theme.success_color()))],
body.spans,
]
.concat(),
),
crate::body::TextKind::Todo { done: false } => Line::from(
[
vec![Span::styled("[ ] ", Style::new().fg(theme.muted_color()))],
body.spans,
]
.concat(),
),
crate::body::TextKind::Bullet => Line::from(
[
vec![Span::styled("• ", Style::new().fg(theme.muted_color()))],
body.spans,
]
.concat(),
),
crate::body::TextKind::Number(n) => Line::from(
[
vec![Span::styled(
format!("{n}. "),
Style::new().fg(theme.muted_color()),
)],
body.spans,
]
.concat(),
),
crate::body::TextKind::Link => Line::from(
[
vec![Span::styled("↗ ", Style::new().fg(theme.muted_color()))],
body.spans,
]
.concat(),
),
crate::body::TextKind::Plain => body,
}
} else if indent > 0 {
Line::from([vec![Span::raw(" ".repeat(indent))], body.spans].concat())
} else {
body
};
f.render_widget(Paragraph::new(line), row);
}
}
fn draw_image_placeholder(f: &mut Frame, theme: &Theme, area: Rect, selected: bool) {
if area.width == 0 || area.height == 0 {
return;
}
let rect = Rect { height: 1, ..area };
let style = if selected {
theme.accent_text()
} else {
Style::new().fg(theme.muted_color())
};
f.render_widget(Clear, rect);
f.render_widget(Paragraph::new(Line::styled(" [image] ", style)), rect);
}
enum ImageSlotKind<'a> {
Loading,
Broken { detail: &'a str },
}
fn letterbox_rect(area: Rect, aspect_w: u16, aspect_h: u16) -> Rect {
if area.width < 3 || area.height < 3 {
return area;
}
let inner = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
let aw = u32::from(aspect_w.max(1));
let ah = u32::from(aspect_h.max(1));
let iw = u32::from(inner.width);
let ih = u32::from(inner.height);
let (pw, ph) = if iw * ah <= ih * aw {
let pw = iw;
let ph = (iw * ah / aw).clamp(1, ih);
(pw as u16, ph as u16)
} else {
let ph = ih;
let pw = (ih * aw / ah).clamp(1, iw);
(pw as u16, ph as u16)
};
centered(inner, pw, ph)
}
fn preview_slot_area(inner: Rect) -> Rect {
Rect {
x: inner.x.saturating_sub(1),
y: inner.y.saturating_sub(1),
width: inner.width.saturating_add(2),
height: inner.height.saturating_add(2),
}
}
fn draw_image_slot(
f: &mut Frame,
theme: &Theme,
area: Rect,
kind: ImageSlotKind<'_>,
selected: bool,
) {
if area.width < 3 || area.height < 2 {
return;
}
let border = if selected {
theme.accent_text()
} else {
Style::new().fg(theme.muted_color())
};
let (icon, title, title_style, detail) = match kind {
ImageSlotKind::Loading => ("▢", "loading", Style::new().fg(theme.muted_color()), None),
ImageSlotKind::Broken { detail } => (
"✕",
"broken image",
Style::new().fg(theme.error_color()),
Some(detail),
),
};
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(border);
let inner = block.inner(area);
f.render_widget(Clear, area);
f.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return;
}
let mut lines: Vec<Line> = Vec::new();
let content_rows: u16 = if detail.is_some() { 3 } else { 2 };
let pad = inner.height.saturating_sub(content_rows) / 2;
for _ in 0..pad {
lines.push(Line::raw(""));
}
lines.push(
Line::from(Span::styled(
truncate(icon, inner.width as usize),
title_style,
))
.centered(),
);
lines.push(
Line::from(Span::styled(
truncate(title, inner.width as usize),
title_style,
))
.centered(),
);
if let Some(d) = detail {
let d = d.trim();
if !d.is_empty() {
lines.push(
Line::from(Span::styled(
truncate(d, inner.width as usize),
Style::new().fg(theme.muted_color()),
))
.centered(),
);
}
}
f.render_widget(Paragraph::new(lines), inner);
}
fn draw_slash_menu(
f: &mut Frame,
body: &crate::body::BodyEditor,
theme: &Theme,
area: Rect,
cursor: Option<(u16, u16)>,
) {
let Some(menu) = &body.menu else { return };
let Some(rect) = slash_menu_rect(body, area, cursor) else {
return;
};
let commands = body.menu_commands();
let row_width = rect.width.saturating_sub(2) as usize;
let lines: Vec<Line> = commands
.iter()
.enumerate()
.map(|(i, command)| {
let selected = i == menu.index.min(commands.len() - 1);
dropdown_row(
theme,
selected,
&format!("{:<14}", command.label()),
command.hint(),
row_width,
)
})
.collect();
let block = Block::bordered()
.border_type(BorderType::Thick)
.border_style(theme.accent_text())
.title(Span::styled(
format!(" /{} ", menu.query),
Style::new().fg(theme.muted_color()),
));
f.render_widget(Clear, rect);
f.render_widget(Paragraph::new(lines).block(block), rect);
}
fn draw_due_picker(
f: &mut Frame,
theme: &Theme,
picker: &mut crate::duepicker::DuePicker,
field: Rect,
area: Rect,
) {
use crate::duepicker::{PickerFocus, PickerLayout};
let Some(day) = crate::duepicker::to_time_date(picker.day) else {
return;
};
let mut events = ratatui::widgets::calendar::CalendarEventStore::today(
Style::new().fg(theme.success_color()),
);
events.add(day, theme.selection().add_modifier(Modifier::UNDERLINED));
const CAL_COLS: u16 = 21;
let width = (CAL_COLS + 2).max(field.width).min(area.width);
let height = 13;
let below = field.bottom(); let rect = Rect {
x: field.x.min(area.right().saturating_sub(width)),
y: if area.bottom().saturating_sub(below) >= height {
below
} else {
field.y.saturating_sub(height)
},
width,
height,
};
let block = Block::bordered()
.border_type(BorderType::Thick)
.border_style(theme.accent_text())
.title_bottom(
Line::styled(" Tab · clear(x) ", Style::new().fg(theme.muted_color())).left_aligned(),
);
f.render_widget(Clear, rect);
let inner = block.inner(rect);
f.render_widget(block, rect);
let [cal_area, _gap, time_area] = Layout::vertical([
Constraint::Length(8),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(inner);
let cal_area = Rect {
width: CAL_COLS.min(cal_area.width),
..cal_area
};
let time_area = Rect {
width: CAL_COLS.min(time_area.width),
..time_area
};
let days = Rect {
x: cal_area.x,
y: cal_area.y.saturating_add(2),
width: cal_area.width,
height: cal_area.height.saturating_sub(2),
};
let calendar = ratatui::widgets::calendar::Monthly::new(day, events)
.show_month_header(theme.accent_text().add_modifier(Modifier::BOLD))
.show_weekdays_header(Style::new().fg(theme.muted_color()))
.show_surrounding(
Style::new()
.fg(theme.muted_color())
.add_modifier(Modifier::DIM),
);
f.render_widget(calendar, cal_area);
let hour = format!("{:02}", picker.hour);
let minute = format!("{:02}", picker.minute);
let unit = |label: &str, on: bool| {
if on {
Span::styled(
label.to_string(),
theme.selection().add_modifier(Modifier::UNDERLINED),
)
} else {
Span::styled(label.to_string(), Style::new())
}
};
let time_line = Line::from(vec![
unit(&hour, picker.focus == PickerFocus::Hour),
Span::styled(":", Style::new().fg(theme.muted_color())),
unit(&minute, picker.focus == PickerFocus::Minute),
])
.centered();
f.render_widget(Paragraph::new(time_line), time_area);
let clock_w = 5u16;
let clock_x = time_area
.x
.saturating_add(time_area.width.saturating_sub(clock_w) / 2);
picker.layout = PickerLayout {
frame: rect,
days,
hour: Rect {
x: clock_x,
y: time_area.y,
width: 2,
height: 1,
},
minute: Rect {
x: clock_x.saturating_add(3),
y: time_area.y,
width: 2,
height: 1,
},
time_row: time_area,
};
}
fn draw_image_preview(
f: &mut Frame,
store: &mut crate::image::ImageStore,
form: &mut crate::form::TaskForm,
theme: &Theme,
path: &std::path::Path,
area: Rect,
) {
let rect = centered(
area,
(u32::from(area.width) * 9 / 10) as u16,
(u32::from(area.height) * 9 / 10) as u16,
);
let title = truncate(
&path.file_name().unwrap_or_default().to_string_lossy(),
rect.width.saturating_sub(10) as usize,
);
let kind = crate::image::type_label(path);
let anim_note = form
.gif
.as_ref()
.map(|(_, g)| g)
.filter(|g| g.is_animated())
.map(|g| format!(" · {}/{}", g.frame_number(), g.frame_count()))
.unwrap_or_default();
let block = Block::bordered()
.border_type(BorderType::Thick)
.border_style(theme.accent_text())
.title(Span::styled(
format!(" {title} "),
theme.accent_text().bold(),
))
.title_top(
Line::styled(
format!(" {kind}{anim_note} "),
Style::new().fg(theme.muted_color()),
)
.right_aligned(),
)
.title_bottom(
Line::styled(
match form.gif.as_ref().map(|(_, g)| g) {
Some(g) if g.is_animated() && g.is_paused() => {
" Esc closes · click/space resume "
}
Some(g) if g.is_animated() => " Esc closes · click/space pause ",
_ => " Esc closes ",
},
Style::new().fg(theme.muted_color()),
)
.right_aligned(),
);
let inner = block.inner(rect);
f.render_widget(Clear, rect);
f.render_widget(block, rect);
if let Some((_, gif)) = form.gif.as_ref() {
match store.preview_frame(gif) {
Ok(protocol) => {
let _ = render_protocol(f, protocol, inner, theme, None);
}
Err(err) => {
let slot = letterbox_rect(preview_slot_area(inner), 4, 3);
draw_image_slot(
f,
theme,
slot,
ImageSlotKind::Broken { detail: &err },
false,
);
}
}
} else {
match store.get_preview(path) {
crate::image::ImageReady::Ready(protocol) => {
let _ = render_protocol(f, protocol, inner, theme, None);
}
crate::image::ImageReady::Loading => {
let slot = letterbox_rect(preview_slot_area(inner), 4, 3);
draw_image_slot(f, theme, slot, ImageSlotKind::Loading, false);
}
crate::image::ImageReady::Failed(err) => {
let slot = letterbox_rect(preview_slot_area(inner), 4, 3);
draw_image_slot(
f,
theme,
slot,
ImageSlotKind::Broken { detail: &err },
false,
);
}
}
}
}
fn draw_image(
f: &mut Frame,
store: &mut crate::image::ImageStore,
theme: &Theme,
path: &std::path::Path,
area: Rect,
selected: bool,
) -> Option<Rect> {
if area.width < 3 || area.height < 3 {
return None;
}
let inner = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
match store.get(path) {
crate::image::ImageReady::Ready(protocol) => Some(render_protocol(
f,
protocol,
inner,
theme,
selected.then_some(path),
)),
crate::image::ImageReady::Loading => {
let slot = letterbox_rect(area, 4, 3);
draw_image_slot(f, theme, slot, ImageSlotKind::Loading, selected);
Some(slot)
}
crate::image::ImageReady::Failed(err) => {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(err.as_str());
let slot = letterbox_rect(area, 4, 3);
draw_image_slot(
f,
theme,
slot,
ImageSlotKind::Broken { detail: name },
selected,
);
Some(slot)
}
}
}
fn render_protocol(
f: &mut Frame,
protocol: &mut ratatui_image::protocol::StatefulProtocol,
inner: Rect,
theme: &Theme,
frame: Option<&std::path::Path>,
) -> Rect {
let size = protocol.size_for(Resize::Scale(None), inner.as_size());
let picture = centered(
inner,
size.width.min(inner.width),
size.height.min(inner.height),
);
f.render_stateful_widget(
StatefulImage::default().resize(Resize::Scale(None)),
picture,
protocol,
);
let hit = if let Some(path) = frame {
let border = Rect {
x: picture.x.saturating_sub(1),
y: picture.y.saturating_sub(1),
width: picture.width.saturating_add(2),
height: picture.height.saturating_add(2),
};
let kind = crate::image::type_label(path);
f.render_widget(
Block::bordered()
.border_type(BorderType::Thick)
.border_style(theme.accent_text())
.title_top(
Line::styled(format!(" {kind} "), Style::new().fg(theme.muted_color()))
.right_aligned(),
),
border,
);
border
} else {
picture
};
if let Some(Err(err)) = protocol.last_encoding_result() {
let line = Line::styled(
truncate(&format!("image: {err}"), inner.width as usize),
Style::new().fg(theme.error_color()),
);
f.render_widget(Paragraph::new(line), inner);
}
hit
}
fn render_field_box(f: &mut Frame, block: Block, area: Rect) -> Rect {
let inner = block.inner(area);
f.render_widget(block, area);
inner
}
fn line_with_selection(
text: &str,
sel: Option<(u16, u16)>,
base: Style,
theme: &Theme,
) -> Line<'static> {
let Some((a, b)) = sel else {
return Line::from(Span::styled(text.to_string(), base));
};
let a = a as usize;
let b = b as usize;
if a >= b {
return Line::from(Span::styled(text.to_string(), base));
}
let sel_style = theme.selection();
let mut spans = Vec::new();
let mut col = 0usize;
let mut chunk = String::new();
let mut chunk_in_sel = false;
let flush = |spans: &mut Vec<Span<'static>>, chunk: &mut String, in_sel: bool| {
if chunk.is_empty() {
return;
}
let style = if in_sel { sel_style } else { base };
spans.push(Span::styled(std::mem::take(chunk), style));
};
for grapheme in text.graphemes(true) {
let w = grapheme.width();
let in_sel = col >= a && col < b;
if !chunk.is_empty() && in_sel != chunk_in_sel {
flush(&mut spans, &mut chunk, chunk_in_sel);
}
chunk_in_sel = in_sel;
chunk.push_str(grapheme);
col += w;
}
flush(&mut spans, &mut chunk, chunk_in_sel);
Line::from(spans)
}
fn render_or_placeholder(f: &mut Frame, area: Rect, text: &str, placeholder: &str, theme: &Theme) {
let line = if text.is_empty() {
Line::styled(
truncate(placeholder, area.width as usize),
Style::new()
.fg(theme.muted_color())
.add_modifier(Modifier::DIM),
)
} else {
Line::raw(text.to_string())
};
f.render_widget(Paragraph::new(line), area);
}
fn panel<'a>(title: &'a str, focused: bool, theme: &Theme) -> Block<'a> {
let (border, title_style) = if focused {
(theme.accent_text(), theme.accent_text().bold())
} else {
(
Style::new().fg(theme.muted_color()),
Style::new()
.fg(theme.muted_color())
.add_modifier(Modifier::BOLD),
)
};
Block::bordered()
.border_type(BorderType::Thick)
.border_style(border)
.title(Span::styled(format!(" {title} "), title_style))
.padding(Padding::horizontal(1))
}
fn scrollbar(
f: &mut Frame,
theme: &Theme,
area: Rect,
total: usize,
visible: usize,
offset: usize,
focused: bool,
) {
paint_scrollbar(f, theme, area, total, visible, offset, focused, 1);
}
#[allow(clippy::too_many_arguments)]
fn paint_scrollbar(
f: &mut Frame,
theme: &Theme,
area: Rect,
total: usize,
visible: usize,
offset: usize,
focused: bool,
vertical_margin: u16,
) {
let max_offset = total.saturating_sub(visible);
if max_offset == 0 || area.height <= vertical_margin.saturating_mul(2) {
return;
}
let mut state = ScrollbarState::new(max_offset + 1).position(offset.min(max_offset));
let style = if focused {
theme.accent_text()
} else {
Style::new().fg(theme.muted_color())
};
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.symbols(ratatui::symbols::scrollbar::VERTICAL)
.begin_symbol(None)
.end_symbol(None)
.thumb_style(style)
.track_style(style),
area.inner(Margin {
horizontal: 0,
vertical: vertical_margin,
}),
&mut state,
);
}
fn draw_sidebar(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
let focused = app.focus == Focus::Sidebar;
let chrome_focus = focused && !app.mode.command_bar_focused();
let block = panel("Categories", chrome_focus, theme);
let inner = block.inner(area);
app.areas.sidebar = inner;
if inner.height == 0 || inner.width == 0 {
f.render_widget(block, area);
return;
}
let width = inner.width as usize;
let scores: Vec<String> = app
.categories
.iter()
.map(|cat| {
let (done, total) = app.category_progress(&cat.id);
format!("{done}/{total}")
})
.collect();
let count_width = scores.iter().map(|s| s.width()).max().unwrap_or(3).max(3);
let name_field = width.saturating_sub(count_width + 1);
let items: Vec<ListItem> = app
.categories
.iter()
.zip(scores.iter())
.map(|(cat, score)| {
let count = format!("{score:>count_width$}");
let name = truncate(&cat.name, name_field);
let pad = " ".repeat(width.saturating_sub(name.width() + count.width()));
ListItem::new(Line::from(vec![
Span::raw(name),
Span::raw(pad),
Span::styled(count, Style::new().fg(theme.muted_color())),
]))
})
.collect();
let rows = items.len();
app.cat_state.select(Some(app.cat_index));
let list = List::new(items).block(block).highlight_style(if focused {
theme.selection()
} else {
theme.selection_unfocused()
});
f.render_stateful_widget(list, area, &mut app.cat_state);
scrollbar(
f,
theme,
area,
rows,
inner.height as usize,
app.cat_state.offset(),
chrome_focus,
);
}
fn draw_tasks(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
let focused = app.focus == Focus::Tasks;
let chrome_focus = focused && app.mode != Mode::TaskForm && !app.mode.command_bar_focused();
let mut block = panel("Tasks", chrome_focus, theme);
if app.searching {
let context = format!(" search: {} · {} found ", app.search_query, app.view.len());
block = block
.title_top(Line::styled(context, Style::new().fg(theme.muted_color())).right_aligned());
}
let inner = block.inner(area);
app.areas.tasks = inner;
if inner.height == 0 || inner.width == 0 {
f.render_widget(block, area);
return;
}
if app.view.is_empty() {
f.render_widget(block, area);
let text = if app.searching {
banner::NO_SEARCH_RESULTS
} else {
banner::EMPTY_TASKS
};
let style = if chrome_focus {
theme.accent_text()
} else {
Style::new().fg(theme.muted_color())
};
draw_box(f, inner, text, style);
return;
}
let flags_width = crate::model::MAX_IMPORTANCE as usize;
let presentations: Vec<_> = app
.view
.iter()
.map(|task_index| TaskPresentation::new(&app.tasks[*task_index], &app.settings.date_format))
.collect();
const TITLE_MIN: usize = 8;
let available = inner.width as usize;
let flags_visible = DONE_MARK_WIDTH as usize + 1 + TITLE_MIN + 1 + flags_width <= available;
let mut widths = vec![
Constraint::Length(DONE_MARK_WIDTH), Constraint::Fill(1), ];
if flags_visible {
widths.push(Constraint::Length(flags_width as u16));
}
let column_gaps = widths.len().saturating_sub(1);
let content_width = available
.saturating_sub(DONE_MARK_WIDTH as usize)
.saturating_sub(column_gaps)
.saturating_sub(if flags_visible { flags_width } else { 0 });
let mut presentations = presentations.into_iter().enumerate();
let rows: Vec<Row> = app
.list_rows
.iter()
.map(|row| match row {
crate::app::TaskListRow::Separator { .. } => {
Row::new(std::iter::repeat_n(Cell::new(""), widths.len()))
}
crate::app::TaskListRow::Task(view_idx) => {
let (presentation_index, presentation) = presentations
.next()
.expect("task rows and task presentations must stay aligned");
debug_assert_eq!(*view_idx, presentation_index);
task_row(
presentation,
theme,
*view_idx == app.task_index,
content_width,
flags_visible,
)
}
})
.collect();
debug_assert!(presentations.next().is_none());
let table = Table::new(rows, widths)
.block(block)
.column_spacing(1)
.row_highlight_style(if chrome_focus {
theme.selection()
} else {
theme.selection_unfocused()
});
app.areas.done_x = Some(inner.x);
app.areas.flag_x = flags_visible.then_some(inner.right().saturating_sub(flags_width as u16));
let vis = app.selected_visual_row();
app.task_state.select(vis);
if let Some(vis) = vis {
pin_section_header(app, vis);
}
f.render_stateful_widget(table, area, &mut app.task_state);
let offset = app.task_state.offset();
let rule_style = Style::new().fg(theme.muted_color());
for (vis_i, row) in app.list_rows.iter().enumerate().skip(offset) {
let y = inner
.y
.saturating_add(u16::try_from(vis_i - offset).unwrap_or(u16::MAX));
if y >= inner.bottom() {
break;
}
let crate::app::TaskListRow::Separator { title } = row else {
continue;
};
let title_x = (DONE_MARK_WIDTH + 1) as usize;
let line = category_rule(title, inner.width as usize, title_x);
f.render_widget(
Paragraph::new(Span::styled(line, rule_style)),
Rect {
x: inner.x,
y,
width: inner.width,
height: 1,
},
);
}
scrollbar(
f,
theme,
area,
app.list_rows.len(),
inner.height as usize,
app.task_state.offset(),
chrome_focus,
);
}
fn pin_section_header(app: &mut App, vis: usize) {
if vis == 0 {
return;
}
let header = vis - 1;
if !matches!(
app.list_rows.get(header),
Some(crate::app::TaskListRow::Separator { .. })
) {
return;
}
if app.task_state.offset() > header {
*app.task_state.offset_mut() = header;
}
}
fn extras(task: &crate::model::Task) -> String {
let notes = if crate::model::has_prose_or_image(task) {
"≡"
} else {
""
};
match crate::model::todo_progress(task) {
Some((done, total)) => format!("{notes} {done}/{total}").trim_start().to_string(),
None => notes.to_string(),
}
}
struct TaskPresentation {
title: String,
extras: String,
due: String,
flags: String,
done: bool,
}
impl TaskPresentation {
fn new(task: &crate::model::Task, date_format: &str) -> Self {
Self {
title: task.title.clone(),
extras: extras(task),
due: due::display_compact(&task.due, date_format),
flags: crate::model::importance_marks(task.importance),
done: task.done,
}
}
}
fn category_rule(title: &str, width: usize, title_x: usize) -> String {
if width == 0 {
return String::new();
}
let label = format!(" {title} ");
let label_w = label.width();
let pad = title_x.saturating_sub(1).min(width);
if pad + label_w >= width {
let head = "─".repeat(pad);
return truncate(&format!("{head}{label}"), width);
}
format!(
"{}{label}{}",
"─".repeat(pad),
"─".repeat(width - pad - label_w)
)
}
fn task_row(
presentation: TaskPresentation,
theme: &Theme,
selected: bool,
content_width: usize,
flags_visible: bool,
) -> Row<'static> {
let TaskPresentation {
title,
extras,
due,
flags,
done,
} = presentation;
let title_style = if done && !selected {
Style::new().fg(theme.muted_color())
} else {
theme.plain()
};
let title_style = if done {
title_style.add_modifier(Modifier::CROSSED_OUT)
} else {
title_style
};
let mut cells = Vec::with_capacity(5);
let (mark, mark_style) = if done {
("[✓]", Style::new().fg(theme.success_color()))
} else {
("[ ]", Style::new().fg(theme.muted_color()))
};
cells.push(Cell::new(mark).style(mark_style));
let metadata_style = if done {
Style::new()
.fg(theme.muted_color())
.add_modifier(Modifier::CROSSED_OUT)
} else {
Style::new().fg(theme.muted_color())
};
let due_style = if done {
title_style
} else {
Style::new().fg(theme.accent)
};
cells.push(Cell::new(task_content_line(
title,
title_style,
extras,
metadata_style,
due,
due_style,
content_width,
)));
if flags_visible {
let flag_style = if done {
metadata_style
} else {
Style::new().fg(theme.error_color())
};
cells.push(Cell::new(Text::from(
Line::from(Span::styled(flags, flag_style)).right_aligned(),
)));
}
Row::new(cells)
}
fn task_content_line(
title: String,
title_style: Style,
extras: String,
extras_style: Style,
due: String,
due_style: Style,
width: usize,
) -> Line<'static> {
const TITLE_MIN: usize = 8;
const META_GAP: usize = 1;
let title_floor = title.width().min(TITLE_MIN);
let mut show_due = false;
let mut show_extras = false;
let mut metadata_width = 0;
if !due.is_empty() && title_floor + META_GAP + due.width() <= width {
show_due = true;
metadata_width = due.width();
}
if !extras.is_empty() {
let joined_width = if metadata_width == 0 {
extras.width()
} else {
extras.width() + META_GAP + metadata_width
};
if title_floor + META_GAP + joined_width <= width {
show_extras = true;
metadata_width = joined_width;
}
}
if metadata_width == 0 {
return Line::from(Span::styled(truncate(&title, width), title_style));
}
let title_width = width.saturating_sub(META_GAP + metadata_width);
let title = truncate(&title, title_width);
let padding = width.saturating_sub(title.width() + metadata_width);
let mut spans = vec![
Span::styled(title, title_style),
Span::raw(" ".repeat(padding)),
];
if show_extras {
spans.push(Span::styled(extras, extras_style));
if show_due {
spans.push(Span::raw(" "));
}
}
if show_due {
spans.push(Span::styled(due, due_style));
}
Line::from(spans)
}
fn draw_status(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
let typing = matches!(app.mode, Mode::Slash | Mode::Search);
let update_activity = (!typing).then(|| app.update_activity()).flatten();
let downloading = matches!(update_activity, Some(UpdateActivity::Downloading(_)));
let block = Block::bordered()
.border_type(BorderType::Thick)
.border_style(if typing {
theme.accent_text()
} else {
Style::new().fg(theme.muted_color())
})
.padding(if downloading {
Padding::ZERO
} else {
Padding::horizontal(1)
});
let inner = block.inner(area);
f.render_widget(block, area);
let area = inner;
if let Some(UpdateActivity::Downloading(progress)) = update_activity {
draw_download_progress(f, progress, theme, area);
return;
}
let right = Line::from(Span::styled(
due::now_string(&app.settings.date_format),
Style::new().fg(theme.muted_color()),
));
let right_width = right.width() as u16;
let [left_area, right_area] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(right_width.min(area.width)),
])
.areas(area);
app.areas.command_bar = area;
f.render_widget(Paragraph::new(right), right_area);
let field = left_area.width.saturating_sub(2) as usize;
let left = match app.mode {
Mode::Slash | Mode::Search => {
let view = app.input.visible(field);
f.set_cursor_position((
left_area
.x
.saturating_add(1)
.saturating_add(view.cursor_col),
left_area.y,
));
let body = line_with_selection(&view.text, view.sel_cols, Style::new(), theme);
Line::from([vec![Span::styled("/", theme.accent_text())], body.spans].concat())
}
_ if update_activity == Some(UpdateActivity::Checking) => {
Line::from(Span::styled("Checking for updates…", theme.accent_text()))
}
_ => match app.status_message() {
Some((text, kind)) => {
let style = match kind {
MessageKind::Error => Style::new()
.fg(theme.error_color())
.add_modifier(Modifier::BOLD),
MessageKind::Info => theme.accent_text(),
};
Line::from(Span::styled(truncate(text, field), style))
}
None => {
let hint = if app.searching {
format!("search: {} · Esc clears", app.search_query)
} else {
"/ commands".to_string()
};
if (left_area.width as usize) >= hint.width() + 2 {
Line::from(Span::styled(hint, Style::new().fg(theme.muted_color())))
} else {
Line::raw("")
}
}
},
};
f.render_widget(Paragraph::new(left), left_area);
}
fn draw_download_progress(
f: &mut Frame,
progress: crate::update::DownloadProgress,
theme: &Theme,
area: Rect,
) {
let Some(total) = progress.total.filter(|total| *total > 0) else {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(
"Downloading update… {}",
readable_bytes(progress.downloaded)
),
theme.accent_text(),
)))
.centered(),
area,
);
return;
};
let ratio = progress.downloaded.min(total) as f64 / total as f64;
let percent = (ratio * 100.0).round() as u64;
let label = format!("Downloading update {percent}%");
f.render_widget(
Gauge::default()
.ratio(ratio)
.label(label)
.use_unicode(true)
.style(Style::new().fg(theme.muted_color()))
.gauge_style(theme.accent_text().add_modifier(Modifier::BOLD)),
area,
);
}
fn readable_bytes(bytes: u64) -> String {
const MIB: u64 = 1024 * 1024;
const KIB: u64 = 1024;
if bytes >= MIB {
format!("{:.1} MiB", bytes as f64 / MIB as f64)
} else if bytes >= KIB {
format!("{:.1} KiB", bytes as f64 / KIB as f64)
} else {
format!("{bytes} B")
}
}
fn draw_slash_palette(f: &mut Frame, app: &mut App, theme: &Theme, status: Rect) {
let query = app.input.value();
let commands = crate::slash::matching(&query);
if commands.is_empty() {
return;
}
let width = 53.min(status.width.saturating_sub(2)).max(24);
let height = u16::try_from(commands.len())
.unwrap_or(u16::MAX)
.saturating_add(2)
.min(status.y.max(3));
let rect = Rect {
x: status.x,
y: status.y.saturating_sub(height),
width,
height,
};
app.areas.slash_menu = rect;
let row_width = width.saturating_sub(2) as usize;
let lines: Vec<Line> = commands
.iter()
.enumerate()
.map(|(i, cmd)| {
let selected = i == app.slash_index.min(commands.len() - 1);
dropdown_row(
theme,
selected,
&format!("/{:<12}", cmd.id()),
cmd.hint(),
row_width,
)
})
.collect();
let block = Block::bordered()
.border_type(BorderType::Thick)
.border_style(theme.accent_text())
.title(Span::styled(
format!(" /{} ", query),
Style::new().fg(theme.muted_color()),
));
f.render_widget(Clear, rect);
f.render_widget(Paragraph::new(lines).block(block), rect);
}
fn dropdown_row(
theme: &Theme,
selected: bool,
label: &str,
hint: &str,
row_width: usize,
) -> Line<'static> {
let label_part = format!(" {label} ");
let hint_part = format!("{hint} ");
let used = label_part.width() + hint_part.width();
let pad = " ".repeat(row_width.saturating_sub(used));
let (label_style, hint_style, pad_style) = if selected {
let selection = theme.selection();
(selection, selection, selection)
} else {
(
Style::new(),
Style::new().fg(theme.muted_color()),
Style::new(),
)
};
Line::from(vec![
Span::styled(label_part, label_style),
Span::styled(hint_part, hint_style),
Span::styled(pad, pad_style),
])
}
fn draw_help(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
const COLUMN_WIDTH: usize = 40;
const WIDE_WIDTH: u16 = COLUMN_WIDTH as u16 * 2 + 7;
const NARROW_WIDTH: u16 = 58;
let wide = area.width >= WIDE_WIDTH;
let width = if wide {
WIDE_WIDTH
} else {
NARROW_WIDTH.min(area.width)
};
let mut lines = wordmark_lines(theme, width);
if !lines.is_empty() {
lines.push(Line::raw(""));
}
let row_style = |heading| {
if heading {
theme.accent_text().add_modifier(Modifier::BOLD)
} else {
Style::new()
}
};
if wide {
for banner::HelpRow {
left,
right,
heading,
} in banner::HELP_COLUMNS
{
let style = row_style(heading);
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("{left:<COLUMN_WIDTH$}"), style),
Span::styled(right, style),
]));
}
lines.push(Line::raw(""));
} else {
for side in 0..2 {
for banner::HelpRow {
left,
right,
heading,
} in banner::HELP_COLUMNS
{
let text = if side == 0 { left } else { right };
if text.is_empty() {
lines.push(Line::raw(""));
continue;
}
let prefix = if heading { "" } else { " " };
lines.push(Line::styled(format!("{prefix}{text}"), row_style(heading)));
}
lines.push(Line::raw(""));
}
}
let store = format!("Data store: {}", app.data_dir().display());
lines.push(
Line::styled(
truncate(&store, width.saturating_sub(4) as usize),
Style::new().fg(theme.muted_color()),
)
.centered(),
);
lines.push(Line::styled(banner::HELP_FOOTER, theme.accent_text()).centered());
let height = u16::try_from(lines.len())
.unwrap_or(u16::MAX)
.saturating_add(2)
.min(area.height);
let rect = centered(area, width, height);
let viewport = rect.height.saturating_sub(2) as usize;
let max_scroll = lines.len().saturating_sub(viewport);
app.help_scroll = app.help_scroll.min(max_scroll);
let title = Line::from(vec![
Span::raw(" mach "),
Span::styled(
format!("v{} ", crate::VERSION),
Style::new().fg(theme.muted_color()),
),
]);
let block = Block::bordered()
.border_type(BorderType::Thick)
.title(title)
.border_style(theme.accent_text())
.padding(ratatui::widgets::Padding::horizontal(1));
f.render_widget(Clear, rect);
f.render_widget(
Paragraph::new(lines)
.block(block)
.scroll((app.help_scroll.min(u16::MAX as usize) as u16, 0)),
rect,
);
}
fn draw_settings(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
for (i, item) in SETTINGS_ITEMS.iter().enumerate() {
let selected = i == app.settings_index;
let value = app.setting_value(i);
let marker = if selected { "❯ " } else { " " };
let name_style = if selected {
Style::new().add_modifier(Modifier::BOLD)
} else {
Style::new()
};
lines.push(Line::from(vec![
Span::styled(marker, theme.accent_text()),
Span::styled(format!("{item:<14}"), name_style),
Span::styled(value, theme.accent_text()),
]));
}
lines.push(Line::raw(""));
lines.push(Line::styled(
"↑↓ select · ←→ change · Esc close",
Style::new().fg(theme.muted_color()),
));
let width = 48.min(area.width);
let height = u16::try_from(lines.len())
.unwrap_or(u16::MAX)
.saturating_add(2)
.min(area.height);
let rect = centered(area, width, height);
let block = Block::bordered()
.border_type(BorderType::Thick)
.title(Line::from(" Settings "))
.border_style(theme.accent_text())
.padding(ratatui::widgets::Padding::horizontal(2));
f.render_widget(Clear, rect);
f.render_widget(Paragraph::new(lines).block(block), rect);
}
fn draw_welcome(f: &mut Frame, theme: &Theme, area: Rect) {
let mut lines = wordmark_lines(theme, area.width);
if !lines.is_empty() {
lines.push(Line::raw(""));
}
lines.push(
Line::styled(
format!("Welcome to mach v{}", crate::VERSION),
Style::new().add_modifier(Modifier::BOLD),
)
.centered(),
);
lines.push(Line::raw(""));
lines.push(Line::raw("Written in Rust with ratatui.").centered());
lines.push(Line::raw("Your tasks stay local in ~/.mach.").centered());
lines.push(Line::raw(""));
lines.push(
Line::styled(
"Press Enter to start · /help for the key list",
Style::new().fg(theme.muted_color()),
)
.centered(),
);
let width = 50.min(area.width);
let height = u16::try_from(lines.len())
.unwrap_or(u16::MAX)
.saturating_add(2)
.min(area.height);
let rect = centered(area, width, height);
let block = Block::bordered()
.border_type(BorderType::Thick)
.border_style(theme.accent_text());
f.render_widget(Clear, rect);
f.render_widget(Paragraph::new(lines).block(block), rect);
}
fn wordmark_lines(theme: &Theme, available_width: u16) -> Vec<Line<'static>> {
if available_width < banner::BANNER_WIDTH + 8 {
return Vec::new();
}
banner::BANNER
.iter()
.map(|row| Line::styled(*row, theme.accent_text()).centered())
.collect()
}
fn draw_whats_new(f: &mut Frame, theme: &Theme, area: Rect) {
let mut lines = vec![
Line::styled(
format!("What's new in mach v{}", crate::VERSION),
Style::new().add_modifier(Modifier::BOLD),
)
.centered(),
Line::raw(""),
];
for (index, (title, description)) in banner::WHATS_NEW.into_iter().enumerate() {
lines.push(Line::from(vec![
Span::styled("• ", theme.accent_text()),
Span::styled(title, Style::new().add_modifier(Modifier::BOLD)),
]));
lines.push(Line::raw(format!(" {description}")));
if index + 1 < banner::WHATS_NEW.len() {
lines.push(Line::raw(""));
}
}
lines.push(Line::raw(""));
lines
.push(Line::styled("Full release notes:", Style::new().fg(theme.muted_color())).centered());
lines.push(
Line::styled(
format!("github.com/Q1CHENL/mach/releases/tag/v{}", crate::VERSION),
Style::new().fg(theme.muted_color()),
)
.centered(),
);
lines.push(
Line::styled(
"Press Enter or Esc to continue",
Style::new().fg(theme.muted_color()),
)
.centered(),
);
let height = u16::try_from(lines.len())
.unwrap_or(u16::MAX)
.saturating_add(2)
.min(area.height);
let rect = centered(area, 62.min(area.width), height);
let block = Block::bordered()
.border_type(BorderType::Thick)
.border_style(theme.accent_text())
.padding(Padding::horizontal(2));
f.render_widget(Clear, rect);
f.render_widget(Paragraph::new(lines).block(block), rect);
}
fn draw_box(f: &mut Frame, area: Rect, text: &str, style: Style) {
let width = u16::try_from(text.width())
.unwrap_or(u16::MAX)
.saturating_add(8)
.min(area.width);
let rect = centered(area, width, 3);
let block = Block::bordered()
.border_type(BorderType::Thick)
.border_style(style);
f.render_widget(Clear, rect);
f.render_widget(
Paragraph::new(Line::styled(text.to_string(), style))
.centered()
.block(block),
rect,
);
}
pub fn centered(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect {
x: area.x.saturating_add((area.width - width) / 2),
y: area.y.saturating_add((area.height - height) / 2),
width,
height,
}
}
pub fn truncate(s: &str, width: usize) -> String {
if s.width() <= width {
return s.to_string();
}
let mut out = String::new();
let mut used = 0;
for grapheme in s.graphemes(true) {
let w = grapheme.width();
if used + w > width {
break;
}
used += w;
out.push_str(grapheme);
}
out
}