1use ratatui::Frame;
6use ratatui::layout::{Constraint, Layout, Margin, Rect};
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::Text;
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{
11 Block, BorderType, Cell, Clear, Gauge, List, ListItem, Padding, Paragraph, Row, Scrollbar,
12 ScrollbarOrientation, ScrollbarState, Table,
13};
14use ratatui_image::{Resize, StatefulImage};
15use unicode_segmentation::UnicodeSegmentation;
16use unicode_width::UnicodeWidthStr;
17
18use crate::app::{
19 App, Focus, HoverPaint, HoverTarget, MessageKind, Mode, SETTINGS_ITEMS, UpdateActivity,
20};
21use crate::banner;
22use crate::due;
23use crate::form::Field;
24use crate::model::{LabelColor, labels_for_task};
25use crate::theme::Theme;
26
27pub const SIDEBAR_WIDTH: u16 = 26;
29pub const DONE_MARK_WIDTH: u16 = 3;
31const PREVIEW_SPLIT_MIN: u16 = 16;
33const LIST_MIN: u16 = 6;
35const PREVIEW_MIN: u16 = 8;
37const LIST_WIDTH_MIN: u16 = 24;
39const PREVIEW_WIDTH_MIN: u16 = 28;
41const PREVIEW_SIDE_MIN: u16 = LIST_WIDTH_MIN + PREVIEW_WIDTH_MIN + 1;
43const LABEL_PICKER_MAX_ROWS: usize = LabelColor::ALL.len() + 1;
45pub const MIN_TERMINAL_WIDTH: u16 = 60;
46pub const MIN_TERMINAL_HEIGHT: u16 = 16;
47
48pub fn draw(f: &mut Frame, app: &mut App) {
49 let area = f.area();
50 app.areas.reset();
53 if let Some(form) = &mut app.form {
54 form.areas = crate::form::FieldAreas::default();
55 form.form_area = Rect::ZERO;
56 form.description_menu_area = None;
57 form.image_hits.clear();
58 if let Some(picker) = &mut form.picker {
59 picker.layout = crate::duepicker::PickerLayout::default();
60 }
61 }
62 if let Some(form) = &mut app.category_form {
63 form.form_area = Rect::ZERO;
64 form.name_area = Rect::ZERO;
65 form.description_area = Rect::ZERO;
66 form.description_menu_area = None;
67 }
68
69 if area.width < MIN_TERMINAL_WIDTH || area.height < MIN_TERMINAL_HEIGHT {
70 let p = Paragraph::new(format!(
71 "too small · need {MIN_TERMINAL_WIDTH}×{MIN_TERMINAL_HEIGHT}"
72 ))
73 .centered();
74 f.render_widget(p, area);
75 app.finish_hover_frame();
76 return;
77 }
78
79 let theme = app.theme();
80 let labels_layout = (app.mode == Mode::Labels).then(|| label_manager_layout(app, area));
81 let preview_image_occlusion = labels_layout.as_ref().map(|layout| layout.rect);
82 let [content, status] =
83 Layout::vertical([Constraint::Min(3), Constraint::Length(3)]).areas(area);
84 let [sidebar, right] =
89 Layout::horizontal([Constraint::Length(SIDEBAR_WIDTH), Constraint::Min(20)])
90 .spacing(1)
91 .areas(content);
92
93 let mut modal_task_form = false;
94 draw_sidebar(f, app, &theme, sidebar);
95 if let Some((list, preview_rect)) =
96 split_tasks_and_preview(right, &app.settings.preview_position)
97 {
98 app.areas.preview = preview_rect;
99 draw_tasks(f, app, &theme, list);
100 match app.mode {
101 Mode::TaskForm => match docked_task_form_layout(preview_rect) {
102 Some(layout) => draw_task_form(f, app, &theme, preview_rect, layout),
103 None => {
104 draw_task_preview(f, app, &theme, preview_rect, preview_image_occlusion);
105 modal_task_form = true;
106 }
107 },
108 _ => draw_task_preview(f, app, &theme, preview_rect, preview_image_occlusion),
109 }
110 } else {
111 app.areas.preview = Rect::ZERO;
112 draw_tasks(f, app, &theme, right);
113 if app.mode == Mode::TaskForm {
114 modal_task_form = true;
115 }
116 }
117 draw_status(f, app, &theme, status);
118 if app.mode == Mode::Slash {
120 draw_slash_palette(f, app, &theme, status);
121 }
122
123 match app.mode {
124 Mode::Help => draw_help(f, app, &theme, area),
125 Mode::Settings => draw_settings(f, app, &theme, area),
126 Mode::Labels => draw_labels(
127 f,
128 app,
129 &theme,
130 labels_layout.as_ref().expect("labels mode owns its layout"),
131 ),
132 Mode::Welcome => draw_welcome(f, app, &theme, area),
133 Mode::WhatsNew => draw_whats_new(f, &theme, area),
134 Mode::CategoryForm => draw_category_form(f, app, &theme, area),
135 Mode::TaskForm if modal_task_form => {
136 draw_task_form(f, app, &theme, area, TaskFormLayout::Modal);
138 }
139 Mode::TaskForm => {} _ => {}
141 }
142 draw_hover(f, app, &theme);
143}
144
145fn draw_hover(f: &mut Frame, app: &mut App, theme: &Theme) {
149 let hit = app
150 .mouse_position()
151 .and_then(|position| app.areas.hover_hit_at(position));
152 if let Some(hit) = hit {
153 match hit.paint {
154 HoverPaint::Fill(rect) => paint_hover_background(f, rect, theme.hover()),
155 HoverPaint::Badge => f.buffer_mut().set_style(hit.hit, theme.label_hover()),
156 HoverPaint::None => {}
157 }
158 }
159 app.finish_hover_frame();
160}
161
162fn paint_hover_background(f: &mut Frame, rect: Rect, style: Style) {
163 let buffer = f.buffer_mut();
164 let rect = buffer.area.intersection(rect);
165 for y in rect.top()..rect.bottom() {
166 for x in rect.left()..rect.right() {
167 let cell = &mut buffer[(x, y)];
168 if cell.bg == Color::Reset && !cell.modifier.contains(Modifier::REVERSED) {
169 cell.set_style(style);
170 }
171 }
172 }
173}
174
175fn split_tasks_and_preview(right: Rect, position: &str) -> Option<(Rect, Rect)> {
179 if position == "right"
180 && let Some(pair) = split_preview_right(right)
181 {
182 return Some(pair);
183 }
184 split_preview_bottom(right)
185}
186
187fn split_preview_bottom(right: Rect) -> Option<(Rect, Rect)> {
188 if right.height < PREVIEW_SPLIT_MIN {
189 return None;
190 }
191 let [list, preview] = Layout::vertical([
192 Constraint::Min(LIST_MIN),
193 Constraint::Length((right.height / 2).max(PREVIEW_MIN)),
194 ])
195 .spacing(0)
196 .areas(right);
197 if list.height < LIST_MIN || preview.height < PREVIEW_MIN {
198 return None;
199 }
200 Some((list, preview))
201}
202
203fn split_preview_right(right: Rect) -> Option<(Rect, Rect)> {
204 if right.width < PREVIEW_SIDE_MIN || right.height < PREVIEW_MIN {
205 return None;
206 }
207 let preview_w = (right.width / 2).max(PREVIEW_WIDTH_MIN);
208 let [list, preview] = Layout::horizontal([
209 Constraint::Min(LIST_WIDTH_MIN),
210 Constraint::Length(preview_w),
211 ])
212 .spacing(1)
213 .areas(right);
214 if list.width < LIST_WIDTH_MIN || preview.width < PREVIEW_WIDTH_MIN {
215 return None;
216 }
217 Some((list, preview))
218}
219
220const TASK_FORM_WIDE_CHROME: u16 = 9;
223const TASK_FORM_COMPACT_CHROME: u16 = 18;
224const TASK_FORM_MIN_DESCRIPTION_HEIGHT: u16 = 3;
225const TASK_FORM_WIDE_MIN_WIDTH: u16 = 56;
226
227#[derive(Clone, Copy, Debug, PartialEq, Eq)]
228enum TaskFormLayout {
229 DockedWide,
230 DockedCompact,
231 Modal,
232}
233
234impl TaskFormLayout {
235 fn is_docked(self) -> bool {
236 !matches!(self, Self::Modal)
237 }
238
239 fn is_compact(self) -> bool {
240 matches!(self, Self::DockedCompact)
241 }
242}
243
244fn docked_task_form_layout(area: Rect) -> Option<TaskFormLayout> {
245 if area.width >= TASK_FORM_WIDE_MIN_WIDTH
246 && area.height >= TASK_FORM_WIDE_CHROME + TASK_FORM_MIN_DESCRIPTION_HEIGHT
247 {
248 Some(TaskFormLayout::DockedWide)
249 } else if area.width >= PREVIEW_WIDTH_MIN
250 && area.height >= TASK_FORM_COMPACT_CHROME + TASK_FORM_MIN_DESCRIPTION_HEIGHT
251 {
252 Some(TaskFormLayout::DockedCompact)
253 } else {
254 None
255 }
256}
257
258fn draw_task_form(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect, layout: TaskFormLayout) {
264 let App {
267 form,
268 images: store,
269 areas,
270 ..
271 } = app;
272 let Some(form) = form.as_mut() else { return };
273
274 let rect = if layout.is_docked() {
275 area
276 } else {
277 let width = 92.min(area.width.saturating_sub(4));
278 let description_height = area
279 .height
280 .saturating_sub(TASK_FORM_WIDE_CHROME)
281 .clamp(TASK_FORM_MIN_DESCRIPTION_HEIGHT, 22);
282 centered(
283 area,
284 width,
285 (TASK_FORM_WIDE_CHROME + description_height).min(area.height),
286 )
287 };
288 form.form_area = rect;
289 areas.occlude_hover(rect);
290 let h_pad = if layout.is_docked() { 1 } else { 2 };
291 let block = Block::bordered()
292 .border_type(BorderType::Thick)
293 .border_style(theme.accent_text())
294 .title(Span::styled(
295 format!(" {} ", form.title_text()),
296 theme.accent_text().bold(),
297 ))
298 .padding(Padding::new(h_pad, h_pad, 0, 0));
299 let inner = block.inner(rect);
300 f.render_widget(Clear, rect);
301 f.render_widget(block, rect);
302
303 let (title_box, category_box, labels_box, due_box, importance_box, description_box, hint) =
304 if layout.is_compact() {
305 let [title, category, labels, due, importance, description, hint] = Layout::vertical([
306 Constraint::Length(3),
307 Constraint::Length(3),
308 Constraint::Length(3),
309 Constraint::Length(3),
310 Constraint::Length(3),
311 Constraint::Min(TASK_FORM_MIN_DESCRIPTION_HEIGHT),
312 Constraint::Length(1),
313 ])
314 .areas(inner);
315 (title, category, labels, due, importance, description, hint)
316 } else {
317 let [title, metadata, description, hint] = Layout::vertical([
318 Constraint::Length(3),
319 Constraint::Length(3),
320 Constraint::Min(TASK_FORM_MIN_DESCRIPTION_HEIGHT),
321 Constraint::Length(1),
322 ])
323 .areas(inner);
324 let [category, labels, due, importance] = Layout::horizontal([
327 Constraint::Fill(1),
328 Constraint::Fill(1),
329 Constraint::Length(20),
330 Constraint::Length(9),
331 ])
332 .spacing(1)
333 .areas(metadata);
334 (title, category, labels, due, importance, description, hint)
335 };
336
337 let focused = form.field == Field::Title;
339 let box_inner = render_field_box(f, field_block("Title", focused, None, theme), title_box);
340 form.areas.title = box_inner;
341 draw_text_input(
342 f,
343 &mut form.title,
344 box_inner,
345 "what needs doing?",
346 focused,
347 theme,
348 );
349
350 let focused = form.field == Field::Category;
352 let box_inner = render_field_box(
353 f,
354 field_block("Category", focused, None, theme),
355 category_box,
356 );
357 form.areas.category = box_inner;
358 let category = format!("‹ {} ›", form.category_label());
359 f.render_widget(
360 Paragraph::new(truncate(&category, box_inner.width as usize)),
361 box_inner,
362 );
363
364 let focused = form.field == Field::Labels;
367 let box_inner = render_field_box(f, field_block("Labels", focused, None, theme), labels_box);
368 form.areas.labels = labels_box;
369 let labels = form
370 .selected_labels()
371 .into_iter()
372 .map(|(name, color)| LabelToken::new(name, color))
373 .collect::<Vec<_>>();
374 if labels.is_empty() {
375 render_or_placeholder(f, box_inner, "", "↵ choose", theme);
376 } else {
377 let shown = compact_badge_tokens(&labels, box_inner.width as usize);
378 f.render_widget(
379 Paragraph::new(label_badges_line(&shown, theme, false)),
380 box_inner,
381 );
382 }
383
384 let focused = form.field == Field::Due;
388 let box_inner = render_field_box(f, field_block("Due", focused, None, theme), due_box);
389 form.areas.due = due_box;
390 let view = form.due.visible(box_inner.width as usize);
391 render_or_placeholder(f, box_inner, &view.text, "↵ Enter", theme);
392
393 let focused = form.field == Field::Importance;
395 let box_inner = render_field_box(
396 f,
397 field_block("Flags", focused, None, theme),
398 importance_box,
399 );
400 form.areas.importance = box_inner;
401 let marks = crate::model::importance_marks(form.importance);
402 if marks.is_empty() {
403 render_or_placeholder(f, box_inner, "", "→", theme);
404 } else {
405 f.render_widget(
406 Paragraph::new(Line::styled(marks, Style::new().fg(theme.error_color()))),
407 box_inner,
408 );
409 }
410
411 let focused = form.field == Field::Description;
413 let (done, total) = form.description.progress();
414 let progress = (total > 0).then(|| format!("{done}/{total}"));
415 let box_inner = render_field_box(
416 f,
417 field_block("Description", focused, progress, theme),
418 description_box,
419 );
420 form.areas.description = box_inner;
421 let overlay = f.area();
422 let image_occlusion = task_form_image_occlusion(form, overlay);
423 draw_description(f, form, store, theme, box_inner, focused, image_occlusion);
424 register_task_description_hover(areas, form);
425 scrollbar(
426 f,
427 theme,
428 description_box,
429 form.description.content_height(),
430 box_inner.height as usize,
431 form.description.scroll(),
432 focused,
433 );
434
435 let footer = match &form.error {
437 Some(error) => Line::styled(
438 truncate(error, hint.width as usize),
439 Style::new()
440 .fg(theme.error_color())
441 .add_modifier(Modifier::BOLD),
442 ),
443 None => Line::styled(
444 match layout {
445 TaskFormLayout::DockedWide => "/ commands · Ctrl+Z undo · Ctrl+S save · Esc list",
446 TaskFormLayout::DockedCompact => "/ · Ctrl+S save · Esc list",
447 TaskFormLayout::Modal => "/ commands · Ctrl+Z undo · Ctrl+S save · Esc cancel",
448 },
449 Style::new().fg(theme.muted_color()),
450 ),
451 };
452 f.render_widget(Paragraph::new(footer), hint);
453
454 if let Some(picker) = form.picker.as_mut() {
457 draw_due_picker(f, theme, picker, form.areas.due, overlay, areas);
458 }
459 if form.label_picker_open() {
460 draw_label_picker(f, theme, form, form.areas.labels, overlay, areas);
461 }
462
463 if form.preview
465 && let Some(path) = form
466 .description
467 .selected_image()
468 .or_else(|| form.description.images().first().cloned())
469 {
470 areas.occlude_hover(overlay);
471 draw_image_preview(f, store, form, theme, &path, overlay);
472 }
473}
474
475fn draw_task_preview(
477 f: &mut Frame,
478 app: &mut App,
479 theme: &Theme,
480 area: Rect,
481 image_occlusion: Option<Rect>,
482) {
483 let focused = false;
484 let block = panel("Task preview", focused, theme);
485 let inner = block.inner(area);
486 f.render_widget(block, area);
487 if inner.height == 0 || inner.width == 0 {
488 return;
489 }
490
491 let Some(task) = app.selected_task() else {
492 app.invalidate_preview();
493 let style = Style::new().fg(theme.muted_color());
494 draw_box(f, inner, "Select a task · Enter to edit", style);
495 return;
496 };
497
498 let todo = crate::model::todo_progress(task);
499 let labels = labels_for_task(task, &app.labels)
500 .map(LabelToken::from)
501 .collect::<Vec<_>>();
502 let title = task.title.clone();
503 let done = task.done;
504 let due_s = due::display(&task.due, &app.settings.date_format);
505 let importance = task.importance;
506 let description_empty = task.description.is_empty();
507
508 if !description_empty {
509 app.ensure_preview();
510 }
511
512 let flags = crate::model::importance_marks(importance);
513 let mut meta = String::new();
514 if !due_s.is_empty() {
515 meta.push_str(&due_s);
516 }
517 if !flags.is_empty() {
518 if !meta.is_empty() {
519 meta.push_str(" ");
520 }
521 meta.push_str(&flags);
522 }
523 if let Some((d, t)) = todo {
524 if !meta.is_empty() {
525 meta.push_str(" ");
526 }
527 meta.push_str(&format!("{d}/{t}"));
528 }
529
530 let title_style = if done {
531 Style::new()
532 .fg(theme.muted_color())
533 .add_modifier(Modifier::CROSSED_OUT | Modifier::BOLD)
534 } else {
535 Style::new().add_modifier(Modifier::BOLD)
536 };
537
538 let label_lines = wrapped_label_badges(&labels, inner.width as usize, theme, done);
539 let label_height = u16::try_from(label_lines.len()).unwrap_or(u16::MAX);
540 let meta_height = u16::from(!meta.is_empty());
541 let [title_row, meta_row, labels_area, description_area] = Layout::vertical([
542 Constraint::Length(1),
543 Constraint::Length(meta_height),
544 Constraint::Length(label_height),
545 Constraint::Min(0),
546 ])
547 .areas(inner);
548 app.areas.preview_description = description_area;
549
550 f.render_widget(
551 Paragraph::new(Line::styled(
552 truncate(&title, title_row.width as usize),
553 title_style,
554 )),
555 title_row,
556 );
557 if meta_height > 0 {
558 f.render_widget(
559 Paragraph::new(Line::styled(
560 truncate(&meta, meta_row.width as usize),
561 Style::new().fg(theme.muted_color()),
562 )),
563 meta_row,
564 );
565 }
566 if label_height > 0 {
567 f.render_widget(Paragraph::new(label_lines), labels_area);
568 }
569
570 if description_area.height == 0 {
571 return;
572 }
573 if description_empty {
574 f.render_widget(
575 Paragraph::new(Line::styled(
576 "Enter to edit",
577 Style::new().fg(theme.muted_color()),
578 )),
579 description_area,
580 );
581 return;
582 }
583
584 let App {
585 images: store,
586 preview_form,
587 ..
588 } = app;
589 if let Some(paint) = preview_form.as_mut() {
590 draw_description(
591 f,
592 paint,
593 store,
594 theme,
595 description_area,
596 false,
597 image_occlusion,
598 );
599 let visible = usize::from(description_area.height);
600 let max_scroll = paint.description.content_height().saturating_sub(visible);
601 if paint.description.scroll() < max_scroll && description_area.height > 0 {
602 let indicator = Rect {
603 y: description_area.bottom() - 1,
604 height: 1,
605 ..description_area
606 };
607 f.render_widget(
608 Paragraph::new(Line::styled(
609 "↓ more · Enter to edit",
610 Style::new()
611 .fg(theme.muted_color())
612 .add_modifier(Modifier::BOLD),
613 )),
614 indicator,
615 );
616 }
617 }
618}
619
620fn field_block<'a>(
622 label: &'a str,
623 focused: bool,
624 note: Option<String>,
625 theme: &Theme,
626) -> Block<'a> {
627 let (border, label_style) = if focused {
629 (theme.accent_text(), theme.accent_text().bold())
630 } else {
631 (
632 Style::new().fg(theme.muted_color()),
633 Style::new()
634 .fg(theme.muted_color())
635 .add_modifier(Modifier::BOLD),
636 )
637 };
638 let mut block = Block::bordered()
639 .border_type(BorderType::Thick)
640 .border_style(border)
641 .title(Span::styled(format!(" {label} "), label_style))
642 .padding(Padding::horizontal(1));
643 if let Some(note) = note {
644 block = block.title_top(
645 Line::styled(format!(" {note} "), Style::new().fg(theme.muted_color())).right_aligned(),
646 );
647 }
648 block
649}
650
651fn draw_category_form(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
654 let App {
655 category_form,
656 areas,
657 ..
658 } = app;
659 let Some(form) = category_form else {
660 return;
661 };
662 const CHROME: u16 = 6;
664 let text_height = area.height.saturating_sub(CHROME).clamp(3, 12);
665 let width = 72.min(area.width.saturating_sub(4));
666 let rect = centered(area, width, (CHROME + text_height).min(area.height));
667 form.form_area = rect;
668 areas.occlude_hover(rect);
669
670 let block = Block::bordered()
671 .border_type(BorderType::Thick)
672 .border_style(theme.accent_text())
673 .title(Span::styled(
674 format!(" {} ", form.title_text()),
675 theme.accent_text().bold(),
676 ))
677 .padding(Padding::horizontal(1));
678 let inner = block.inner(rect);
679 f.render_widget(Clear, rect);
680 f.render_widget(block, rect);
681
682 let [name_box, text_box, hint] = Layout::vertical([
683 Constraint::Length(3),
684 Constraint::Length(text_height),
685 Constraint::Length(1),
686 ])
687 .areas(inner);
688
689 let focused = !form.on_description;
690 let box_inner = render_field_box(f, field_block("Name", focused, None, theme), name_box);
691 form.name_area = box_inner;
692 draw_text_input(
693 f,
694 &mut form.name,
695 box_inner,
696 "What to call it",
697 focused,
698 theme,
699 );
700
701 let focused = form.on_description;
702 let box_inner = render_field_box(
703 f,
704 field_block("Description", focused, None, theme),
705 text_box,
706 );
707 form.description_area = box_inner;
708 let (lines, cursor) = form
709 .description
710 .layout(box_inner.width as usize, box_inner.height);
711 if form.description.is_empty() && form.description.menu.is_none() {
712 render_or_placeholder(f, box_inner, "", "Press / for commands", theme);
713 }
714 for placed in lines {
715 if matches!(placed.block, crate::description::Painted::Text { .. }) {
716 draw_placed_text(f, theme, box_inner, &placed);
717 }
718 }
719 if let (true, Some((row, col))) = (focused, cursor) {
720 f.set_cursor_position((
721 box_inner.x.saturating_add(col),
722 box_inner.y.saturating_add(row),
723 ));
724 }
725 if focused {
726 form.description_menu_area = slash_menu_rect(&form.description, box_inner, cursor);
727 draw_slash_menu(f, &form.description, theme, box_inner, cursor);
728 if let Some(menu) = form.description_menu_area {
729 register_description_menu_hover(
730 areas,
731 menu,
732 form.description.menu_commands().len(),
733 HoverTarget::CategoryDescriptionCommand,
734 );
735 }
736 }
737 scrollbar(
738 f,
739 theme,
740 text_box,
741 form.description.content_height(),
742 box_inner.height as usize,
743 form.description.scroll(),
744 focused,
745 );
746
747 let footer = match &form.error {
748 Some(error) => Line::styled(
749 truncate(error, hint.width as usize),
750 Style::new()
751 .fg(theme.error_color())
752 .add_modifier(Modifier::BOLD),
753 ),
754 None => Line::styled(
755 "/ commands · Ctrl+Z undo · Ctrl+S save · Esc cancel",
756 Style::new().fg(theme.muted_color()),
757 ),
758 };
759 f.render_widget(Paragraph::new(footer), hint);
760}
761
762fn draw_description(
764 f: &mut Frame,
765 form: &mut crate::form::TaskForm,
766 store: &mut crate::image::ImageStore,
767 theme: &Theme,
768 area: Rect,
769 focused: bool,
770 external_occlusion: Option<Rect>,
771) {
772 let crate::form::TaskForm {
773 description,
774 description_scroll,
775 description_menu_area,
776 image_hits,
777 image_occlusions,
778 image_layout,
779 ..
780 } = form;
781 draw_block_editor(
782 f,
783 description,
784 store,
785 theme,
786 area,
787 focused,
788 description_scroll,
789 description_menu_area,
790 image_hits,
791 image_occlusions,
792 image_layout,
793 external_occlusion,
794 );
795}
796
797fn register_task_description_hover(areas: &mut crate::app::Areas, form: &crate::form::TaskForm) {
798 if let Some(menu) = form.description_menu_area {
799 register_description_menu_hover(
800 areas,
801 menu,
802 form.description.menu_commands().len(),
803 HoverTarget::TaskDescriptionCommand,
804 );
805 }
806}
807
808fn register_description_menu_hover(
809 areas: &mut crate::app::Areas,
810 menu: Rect,
811 count: usize,
812 target: impl Fn(usize) -> HoverTarget,
813) {
814 areas.occlude_hover(menu);
815 let inner = menu.inner(Margin {
816 horizontal: 1,
817 vertical: 1,
818 });
819 for index in 0..count.min(inner.height as usize) {
820 areas.hover_fill(
821 target(index),
822 Rect {
823 y: inner
824 .y
825 .saturating_add(u16::try_from(index).unwrap_or(u16::MAX)),
826 height: 1,
827 ..inner
828 },
829 );
830 }
831}
832
833#[allow(clippy::too_many_arguments)]
834fn draw_block_editor(
835 f: &mut Frame,
836 editor: &mut crate::description::DescriptionEditor,
837 store: &mut crate::image::ImageStore,
838 theme: &Theme,
839 area: Rect,
840 focused: bool,
841 previous_scroll: &mut usize,
842 menu_area: &mut Option<Rect>,
843 image_hits: &mut Vec<(usize, Rect)>,
844 previous_image_occlusions: &mut Vec<Rect>,
845 previous_image_layout: &mut Vec<(std::path::PathBuf, u16, u16)>,
846 external_occlusion: Option<Rect>,
847) {
848 if editor.is_empty() && editor.menu.is_none() {
849 render_or_placeholder(f, area, "", "Press / for commands", theme);
850 }
851 let (blocks, cursor) = editor.layout(area.width as usize, area.height);
852 let scroll = editor.scroll();
853 let image_layout: Vec<_> = blocks
854 .iter()
855 .filter_map(|placed| match &placed.block {
856 crate::description::Painted::Image(path) => Some((path.clone(), placed.y, placed.rows)),
857 crate::description::Painted::Text { .. } => None,
858 })
859 .collect();
860 let menu_rect = slash_menu_rect(editor, area, cursor);
861 *menu_area = menu_rect;
862 let image_occlusions = [menu_rect, external_occlusion]
863 .into_iter()
864 .flatten()
865 .filter(|rect| rect.width > 0 && rect.height > 0 && rects_overlap(*rect, area))
866 .collect::<Vec<_>>();
867 if *previous_image_occlusions != image_occlusions
871 || *previous_scroll != scroll
872 || *previous_image_layout != image_layout
873 {
874 store.clear_cache();
875 f.render_widget(Clear, area);
876 }
877 *previous_image_occlusions = image_occlusions.clone();
878 *previous_scroll = scroll;
879 *previous_image_layout = image_layout;
880 image_hits.clear();
883 for placed in blocks {
884 match &placed.block {
885 crate::description::Painted::Image(path) => {
886 let row = Rect {
887 y: area.y.saturating_add(placed.y),
888 height: placed.rows,
889 ..area
890 };
891 let covered = image_occlusions
892 .iter()
893 .any(|occlusion| rects_overlap(*occlusion, row));
894 let show_frame = focused && placed.selected;
898 if covered {
899 f.render_widget(Clear, row);
900 let hit = letterbox_rect(row, 4, 3);
901 draw_image_placeholder(f, theme, hit, show_frame);
902 image_hits.push((placed.line, hit));
903 } else if let Some(hit) = draw_image(f, store, theme, path, row, show_frame) {
904 image_hits.push((placed.line, hit));
905 }
906 }
907 crate::description::Painted::Text { .. } => {
908 draw_placed_text(f, theme, area, &placed);
909 }
910 }
911 }
912 if focused && let Some((row, col)) = cursor {
913 f.set_cursor_position((area.x.saturating_add(col), area.y.saturating_add(row)));
914 }
915
916 draw_slash_menu(f, editor, theme, area, cursor);
917}
918
919fn rects_overlap(a: Rect, b: Rect) -> bool {
920 a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom()
921}
922
923fn slash_menu_rect(
925 description: &crate::description::DescriptionEditor,
926 area: Rect,
927 cursor: Option<(u16, u16)>,
928) -> Option<Rect> {
929 description.menu.as_ref()?;
930 let commands = description.menu_commands();
931 if commands.is_empty() {
932 return None;
933 }
934 let width = 48.min(area.width);
935 let height = u16::try_from(commands.len())
936 .unwrap_or(u16::MAX)
937 .saturating_add(2);
938 let cursor_row = cursor.map(|(row, _)| row).unwrap_or(0);
939 let below = area.y.saturating_add(cursor_row).saturating_add(1);
940 let y = if area.bottom().saturating_sub(below) >= height {
941 below
942 } else {
943 area.y.saturating_add(cursor_row).saturating_sub(height)
944 };
945 Some(Rect {
946 x: area.x.saturating_add(
947 cursor
948 .map(|(_, col)| col)
949 .unwrap_or(0)
950 .min(area.width.saturating_sub(width)),
951 ),
952 y,
953 width,
954 height,
955 })
956}
957
958fn draw_placed_text(f: &mut Frame, theme: &Theme, area: Rect, placed: &crate::description::Placed) {
960 let crate::description::Painted::Text { rows, kind } = &placed.block else {
961 return;
962 };
963 let indent = kind.indent();
964 let max_rows = placed.rows as usize;
965 for (i, wr) in rows.iter().enumerate().take(max_rows) {
966 let y = area
967 .y
968 .saturating_add(placed.y)
969 .saturating_add(u16::try_from(i).unwrap_or(u16::MAX));
970 if y >= area.bottom() {
971 break;
972 }
973 let row = Rect {
974 x: area.x,
975 y,
976 width: area.width,
977 height: 1,
978 };
979 let base = match kind {
980 crate::description::TextKind::Link => Style::new()
981 .fg(theme.accent)
982 .add_modifier(Modifier::UNDERLINED),
983 crate::description::TextKind::Todo { done: true } => Style::new()
984 .fg(theme.muted_color())
985 .add_modifier(Modifier::CROSSED_OUT),
986 _ => Style::new(),
987 };
988 let description = line_with_selection(&wr.text, wr.sel, base, theme);
989 let line = if i == 0 {
990 match kind {
991 crate::description::TextKind::Todo { done: true } => Line::from(
992 [
993 vec![Span::styled("[✓] ", Style::new().fg(theme.success_color()))],
994 description.spans,
995 ]
996 .concat(),
997 ),
998 crate::description::TextKind::Todo { done: false } => Line::from(
999 [
1000 vec![Span::styled("[ ] ", Style::new().fg(theme.muted_color()))],
1001 description.spans,
1002 ]
1003 .concat(),
1004 ),
1005 crate::description::TextKind::Bullet => Line::from(
1006 [
1007 vec![Span::styled("• ", Style::new().fg(theme.muted_color()))],
1008 description.spans,
1009 ]
1010 .concat(),
1011 ),
1012 crate::description::TextKind::Number(n) => Line::from(
1013 [
1014 vec![Span::styled(
1015 format!("{n}. "),
1016 Style::new().fg(theme.muted_color()),
1017 )],
1018 description.spans,
1019 ]
1020 .concat(),
1021 ),
1022 crate::description::TextKind::Link => Line::from(
1023 [
1024 vec![Span::styled("↗ ", Style::new().fg(theme.muted_color()))],
1025 description.spans,
1026 ]
1027 .concat(),
1028 ),
1029 crate::description::TextKind::Plain => description,
1030 }
1031 } else if indent > 0 {
1032 Line::from([vec![Span::raw(" ".repeat(indent))], description.spans].concat())
1034 } else {
1035 description
1036 };
1037 f.render_widget(Paragraph::new(line), row);
1038 }
1039}
1040
1041fn draw_image_placeholder(f: &mut Frame, theme: &Theme, area: Rect, selected: bool) {
1043 if area.width == 0 || area.height == 0 {
1044 return;
1045 }
1046 let rect = Rect { height: 1, ..area };
1047 let style = if selected {
1048 theme.accent_text()
1049 } else {
1050 Style::new().fg(theme.muted_color())
1051 };
1052 f.render_widget(Clear, rect);
1053 f.render_widget(Paragraph::new(Line::styled(" [image] ", style)), rect);
1054}
1055
1056enum ImageSlotKind<'a> {
1058 Loading,
1059 Broken { detail: &'a str },
1060}
1061
1062fn letterbox_rect(area: Rect, aspect_w: u16, aspect_h: u16) -> Rect {
1066 if area.width < 3 || area.height < 3 {
1067 return area;
1068 }
1069 let inner = area.inner(Margin {
1070 horizontal: 1,
1071 vertical: 1,
1072 });
1073 let aw = u32::from(aspect_w.max(1));
1074 let ah = u32::from(aspect_h.max(1));
1075 let iw = u32::from(inner.width);
1076 let ih = u32::from(inner.height);
1077 let (pw, ph) = if iw * ah <= ih * aw {
1078 let pw = iw;
1079 let ph = (iw * ah / aw).clamp(1, ih);
1080 (pw as u16, ph as u16)
1081 } else {
1082 let ph = ih;
1083 let pw = (ih * aw / ah).clamp(1, iw);
1084 (pw as u16, ph as u16)
1085 };
1086 centered(inner, pw, ph)
1087}
1088
1089fn preview_slot_area(inner: Rect) -> Rect {
1091 Rect {
1092 x: inner.x.saturating_sub(1),
1093 y: inner.y.saturating_sub(1),
1094 width: inner.width.saturating_add(2),
1095 height: inner.height.saturating_add(2),
1096 }
1097}
1098
1099fn draw_image_slot(
1100 f: &mut Frame,
1101 theme: &Theme,
1102 area: Rect,
1103 kind: ImageSlotKind<'_>,
1104 selected: bool,
1105) {
1106 if area.width < 3 || area.height < 2 {
1107 return;
1108 }
1109 let border = if selected {
1110 theme.accent_text()
1111 } else {
1112 Style::new().fg(theme.muted_color())
1113 };
1114 let (icon, title, title_style, detail) = match kind {
1115 ImageSlotKind::Loading => ("▢", "loading", Style::new().fg(theme.muted_color()), None),
1116 ImageSlotKind::Broken { detail } => (
1117 "✕",
1118 "broken image",
1119 Style::new().fg(theme.error_color()),
1120 Some(detail),
1121 ),
1122 };
1123 let block = Block::bordered()
1124 .border_type(BorderType::Rounded)
1125 .border_style(border);
1126 let inner = block.inner(area);
1127 f.render_widget(Clear, area);
1128 f.render_widget(block, area);
1129 if inner.width == 0 || inner.height == 0 {
1130 return;
1131 }
1132
1133 let mut lines: Vec<Line> = Vec::new();
1134 let content_rows: u16 = if detail.is_some() { 3 } else { 2 };
1136 let pad = inner.height.saturating_sub(content_rows) / 2;
1137 for _ in 0..pad {
1138 lines.push(Line::raw(""));
1139 }
1140 lines.push(
1141 Line::from(Span::styled(
1142 truncate(icon, inner.width as usize),
1143 title_style,
1144 ))
1145 .centered(),
1146 );
1147 lines.push(
1148 Line::from(Span::styled(
1149 truncate(title, inner.width as usize),
1150 title_style,
1151 ))
1152 .centered(),
1153 );
1154 if let Some(d) = detail {
1155 let d = d.trim();
1156 if !d.is_empty() {
1157 lines.push(
1158 Line::from(Span::styled(
1159 truncate(d, inner.width as usize),
1160 Style::new().fg(theme.muted_color()),
1161 ))
1162 .centered(),
1163 );
1164 }
1165 }
1166 f.render_widget(Paragraph::new(lines), inner);
1167}
1168
1169fn draw_slash_menu(
1171 f: &mut Frame,
1172 description: &crate::description::DescriptionEditor,
1173 theme: &Theme,
1174 area: Rect,
1175 cursor: Option<(u16, u16)>,
1176) {
1177 let Some(menu) = &description.menu else {
1178 return;
1179 };
1180 let Some(rect) = slash_menu_rect(description, area, cursor) else {
1181 return;
1182 };
1183 let commands = description.menu_commands();
1184 let row_width = rect.width.saturating_sub(2) as usize;
1186 let lines: Vec<Line> = commands
1187 .iter()
1188 .enumerate()
1189 .map(|(i, command)| {
1190 let selected = i == menu.index.min(commands.len() - 1);
1191 dropdown_row(
1192 theme,
1193 selected,
1194 &format!("{:<14}", command.label()),
1195 description.command_hint(*command),
1196 row_width,
1197 )
1198 })
1199 .collect();
1200 let block = Block::bordered()
1201 .border_type(BorderType::Thick)
1202 .border_style(theme.accent_text())
1203 .title(Span::styled(
1204 format!(" /{} ", menu.query),
1205 Style::new().fg(theme.muted_color()),
1206 ));
1207 f.render_widget(Clear, rect);
1208 f.render_widget(Paragraph::new(lines).block(block), rect);
1209}
1210
1211fn task_form_image_occlusion(form: &crate::form::TaskForm, area: Rect) -> Option<Rect> {
1212 if form.picker.is_some() {
1213 Some(due_picker_rect(form.areas.due, area))
1214 } else if form.label_picker_open() {
1215 let total_rows = form.label_choices().count().saturating_add(1);
1216 Some(label_picker_rect(total_rows, form.areas.labels, area))
1217 } else {
1218 None
1219 }
1220}
1221
1222const DUE_PICKER_CAL_COLS: u16 = 21;
1223const DUE_PICKER_TRAILING_COLS: u16 = 1;
1224const DUE_PICKER_HEIGHT: u16 = 13;
1225
1226fn due_picker_rect(field: Rect, area: Rect) -> Rect {
1227 let width = (DUE_PICKER_CAL_COLS + DUE_PICKER_TRAILING_COLS + 2)
1228 .max(field.width)
1229 .min(area.width);
1230 let below = field.bottom();
1231 Rect {
1232 x: field.x.min(area.right().saturating_sub(width)),
1233 y: if area.bottom().saturating_sub(below) >= DUE_PICKER_HEIGHT {
1234 below
1235 } else {
1236 field.y.saturating_sub(DUE_PICKER_HEIGHT)
1237 },
1238 width,
1239 height: DUE_PICKER_HEIGHT,
1240 }
1241}
1242
1243fn draw_due_picker(
1246 f: &mut Frame,
1247 theme: &Theme,
1248 picker: &mut crate::duepicker::DuePicker,
1249 field: Rect,
1250 area: Rect,
1251 areas: &mut crate::app::Areas,
1252) {
1253 use crate::duepicker::{PickerFocus, PickerLayout};
1254
1255 let Some(day) = crate::duepicker::to_time_date(picker.day) else {
1256 return;
1257 };
1258 let mut events = ratatui::widgets::calendar::CalendarEventStore::today(
1259 Style::new().fg(theme.success_color()),
1260 );
1261 events.add(day, theme.selection().add_modifier(Modifier::UNDERLINED));
1263
1264 let rect = due_picker_rect(field, area);
1268 areas.occlude_hover(rect);
1269 let block = Block::bordered()
1270 .border_type(BorderType::Thick)
1271 .border_style(theme.accent_text())
1272 .title_bottom(
1273 Line::styled(" Tab · clear(x) ", Style::new().fg(theme.muted_color())).left_aligned(),
1274 );
1275 f.render_widget(Clear, rect);
1276 let inner = block.inner(rect);
1277 f.render_widget(block, rect);
1278
1279 let [cal_area, _gap, time_area] = Layout::vertical([
1281 Constraint::Length(8),
1282 Constraint::Length(1),
1283 Constraint::Length(1),
1284 ])
1285 .areas(inner);
1286 let cal_area = Rect {
1287 width: DUE_PICKER_CAL_COLS.min(cal_area.width),
1288 ..cal_area
1289 };
1290 let time_area = Rect {
1291 width: DUE_PICKER_CAL_COLS.min(time_area.width),
1292 ..time_area
1293 };
1294
1295 let days = Rect {
1297 x: cal_area.x,
1298 y: cal_area.y.saturating_add(2),
1299 width: cal_area.width,
1300 height: cal_area.height.saturating_sub(2),
1301 };
1302
1303 let calendar = ratatui::widgets::calendar::Monthly::new(day, events)
1304 .show_month_header(theme.accent_text().add_modifier(Modifier::BOLD))
1305 .show_weekdays_header(Style::new().fg(theme.muted_color()))
1306 .show_surrounding(
1307 Style::new()
1308 .fg(theme.muted_color())
1309 .add_modifier(Modifier::DIM),
1310 );
1311 let day_rows = calendar.height().saturating_sub(2).min(days.height);
1312 f.render_widget(calendar, cal_area);
1313
1314 let hour = format!("{:02}", picker.hour);
1316 let minute = format!("{:02}", picker.minute);
1317 let unit = |label: &str, on: bool| {
1318 if on {
1319 Span::styled(
1320 label.to_string(),
1321 theme.selection().add_modifier(Modifier::UNDERLINED),
1322 )
1323 } else {
1324 Span::styled(label.to_string(), Style::new())
1325 }
1326 };
1327 let time_line = Line::from(vec![
1328 unit(&hour, picker.focus == PickerFocus::Hour),
1329 Span::styled(":", Style::new().fg(theme.muted_color())),
1330 unit(&minute, picker.focus == PickerFocus::Minute),
1331 ])
1332 .centered();
1333 f.render_widget(Paragraph::new(time_line), time_area);
1334
1335 let clock_w = 5u16;
1337 let clock_x = time_area
1338 .x
1339 .saturating_add(time_area.width.saturating_sub(clock_w) / 2);
1340 picker.layout = PickerLayout {
1341 frame: rect,
1342 days,
1343 hour: Rect {
1344 x: clock_x,
1345 y: time_area.y,
1346 width: 2,
1347 height: 1,
1348 },
1349 minute: Rect {
1350 x: clock_x.saturating_add(3),
1351 y: time_area.y,
1352 width: 2,
1353 height: 1,
1354 },
1355 time_row: time_area,
1356 };
1357 for row in 0..day_rows {
1358 for column in 0..7u16 {
1359 let x = days.x.saturating_add(column.saturating_mul(3));
1360 let cell = Rect {
1361 x,
1362 y: days.y.saturating_add(row),
1363 width: 3.min(days.right().saturating_sub(x)),
1364 height: 1,
1365 };
1366 let Some(date) = picker.day_at(cell.x, cell.y) else {
1367 continue;
1368 };
1369 if date == picker.day {
1370 areas.hover_no_paint(HoverTarget::DueDay(date), cell);
1371 } else {
1372 let day_text = Rect {
1373 x: cell.x.saturating_add(1),
1374 width: cell.width.saturating_sub(1),
1375 ..cell
1376 };
1377 areas.hover_fill_with_paint(HoverTarget::DueDay(date), cell, day_text);
1378 }
1379 }
1380 }
1381}
1382
1383fn label_picker_rect(total_rows: usize, field: Rect, area: Rect) -> Rect {
1386 let desired_rows = total_rows.clamp(1, LABEL_PICKER_MAX_ROWS) as u16;
1387 let desired_height = desired_rows.saturating_add(2).min(area.height);
1388 let width = field.width.min(area.width);
1389 let below = field.bottom();
1390 let below_space = area.bottom().saturating_sub(below);
1391 let above_space = field.y.saturating_sub(area.y);
1392 let place_below = below_space >= 3 || below_space >= above_space;
1393 let available_height = if place_below {
1394 below_space
1395 } else {
1396 above_space
1397 };
1398 let height = desired_height.min(available_height);
1399 Rect {
1400 x: field.x.min(area.right().saturating_sub(width)),
1401 y: if place_below {
1402 below
1403 } else {
1404 field.y.saturating_sub(height)
1405 },
1406 width,
1407 height,
1408 }
1409}
1410
1411fn draw_label_picker(
1412 f: &mut Frame,
1413 theme: &Theme,
1414 form: &mut crate::form::TaskForm,
1415 field: Rect,
1416 area: Rect,
1417 areas: &mut crate::app::Areas,
1418) {
1419 let choices = form
1420 .label_choices()
1421 .map(|(_, name, color, selected)| (name.to_string(), color, selected))
1422 .collect::<Vec<_>>();
1423 let total_rows = choices.len().saturating_add(1);
1424 let selected = form
1425 .label_picker
1426 .as_ref()
1427 .map(|picker| picker.index)
1428 .unwrap_or_default()
1429 .min(total_rows.saturating_sub(1));
1430 let rect = label_picker_rect(total_rows, field, area);
1431 areas.occlude_hover(rect);
1432 let width = rect.width;
1433 let footer = if let Some(error) = &form.error {
1434 Line::styled(
1435 format!(" {} ", truncate(error, width.saturating_sub(4) as usize)),
1436 Style::new()
1437 .fg(theme.error_color())
1438 .add_modifier(Modifier::BOLD),
1439 )
1440 } else {
1441 Line::styled(" Space toggle ", Style::new().fg(theme.muted_color()))
1442 };
1443 let block = Block::bordered()
1444 .border_type(BorderType::Thick)
1445 .border_style(theme.accent_text())
1446 .title_bottom(footer.right_aligned());
1447 let inner = block.inner(rect);
1448 let visible = inner.height as usize;
1449 let start = selected
1450 .saturating_add(1)
1451 .saturating_sub(visible)
1452 .min(total_rows.saturating_sub(visible));
1453 form.set_label_picker_layout(rect, start);
1454 f.render_widget(Clear, rect);
1455 f.render_widget(block, rect);
1456 if inner.width == 0 || inner.height == 0 {
1457 return;
1458 }
1459
1460 let row_width = inner.width as usize;
1461 let lines = (start..total_rows)
1462 .take(visible)
1463 .map(|index| {
1464 let mut line = if let Some((name, color, checked)) = choices.get(index) {
1465 let marker = if *checked { "[✓]" } else { "[ ]" };
1466 let name = truncate(name, row_width.saturating_sub(6));
1467 let used = marker.width().saturating_add(3 + name.width());
1468 Line::from(vec![
1469 Span::raw(format!("{marker} ")),
1470 Span::styled("■", theme.label_swatch(*color)),
1471 Span::raw(" "),
1472 Span::raw(name),
1473 Span::raw(" ".repeat(row_width.saturating_sub(used))),
1474 ])
1475 } else {
1476 let available = row_width.saturating_sub(6);
1477 let label = if "Manage labels ↵".width() <= available {
1478 "Manage labels ↵"
1479 } else {
1480 "Manage ↵"
1481 };
1482 let content = truncate(label, available);
1483 let padding = " ".repeat(row_width.saturating_sub(6 + content.width()));
1484 Line::from(vec![
1485 Span::raw(" "),
1486 Span::raw(content),
1487 Span::raw(padding),
1488 ])
1489 };
1490 if index == selected {
1491 line = line.style(theme.selection());
1492 }
1493 line
1494 })
1495 .collect::<Vec<_>>();
1496 f.render_widget(Paragraph::new(lines), inner);
1497 for index in start..total_rows.min(start.saturating_add(visible)) {
1498 areas.hover_fill(
1499 HoverTarget::TaskLabel(index),
1500 Rect {
1501 y: inner
1502 .y
1503 .saturating_add(u16::try_from(index - start).unwrap_or(u16::MAX)),
1504 height: 1,
1505 ..inner
1506 },
1507 );
1508 }
1509 paint_scrollbar(f, theme, rect, total_rows, visible, start, true, 1);
1510}
1511
1512fn draw_image_preview(
1514 f: &mut Frame,
1515 store: &mut crate::image::ImageStore,
1516 form: &crate::form::TaskForm,
1517 theme: &Theme,
1518 path: &std::path::Path,
1519 area: Rect,
1520) {
1521 let rect = centered(
1522 area,
1523 (u32::from(area.width) * 9 / 10) as u16,
1524 (u32::from(area.height) * 9 / 10) as u16,
1525 );
1526 let title = truncate(
1527 &path.file_name().unwrap_or_default().to_string_lossy(),
1528 rect.width.saturating_sub(10) as usize,
1529 );
1530 let kind = crate::image::type_label(path);
1531 let anim_note = form
1532 .gif
1533 .as_ref()
1534 .map(|(_, g)| g)
1535 .filter(|g| g.is_animated())
1536 .map(|g| format!(" · {}/{}", g.frame_number(), g.frame_count()))
1537 .unwrap_or_default();
1538 let block = Block::bordered()
1539 .border_type(BorderType::Thick)
1540 .border_style(theme.accent_text())
1541 .title(Span::styled(
1542 format!(" {title} "),
1543 theme.accent_text().bold(),
1544 ))
1545 .title_top(
1546 Line::styled(
1547 format!(" {kind}{anim_note} "),
1548 Style::new().fg(theme.muted_color()),
1549 )
1550 .right_aligned(),
1551 )
1552 .title_bottom(
1553 Line::styled(
1554 match form.gif.as_ref().map(|(_, g)| g) {
1555 Some(g) if g.is_animated() && g.is_paused() => {
1556 " Esc closes · click/space resume "
1557 }
1558 Some(g) if g.is_animated() => " Esc closes · click/space pause ",
1559 _ => " Esc closes ",
1560 },
1561 Style::new().fg(theme.muted_color()),
1562 )
1563 .right_aligned(),
1564 );
1565 let inner = block.inner(rect);
1566 f.render_widget(Clear, rect);
1567 f.render_widget(block, rect);
1568
1569 if let Some((_, gif)) = form.gif.as_ref() {
1571 match store.preview_frame(gif) {
1572 Ok(protocol) => {
1573 let _ = render_protocol(f, protocol, inner, theme, None);
1574 }
1575 Err(err) => {
1576 let slot = letterbox_rect(preview_slot_area(inner), 4, 3);
1577 draw_image_slot(
1578 f,
1579 theme,
1580 slot,
1581 ImageSlotKind::Broken { detail: &err },
1582 false,
1583 );
1584 }
1585 }
1586 } else {
1587 match store.get_preview(path) {
1588 crate::image::ImageReady::Ready(protocol) => {
1589 let _ = render_protocol(f, protocol, inner, theme, None);
1590 }
1591 crate::image::ImageReady::Loading => {
1592 let slot = letterbox_rect(preview_slot_area(inner), 4, 3);
1594 draw_image_slot(f, theme, slot, ImageSlotKind::Loading, false);
1595 }
1596 crate::image::ImageReady::Failed(err) => {
1597 let slot = letterbox_rect(preview_slot_area(inner), 4, 3);
1598 draw_image_slot(
1599 f,
1600 theme,
1601 slot,
1602 ImageSlotKind::Broken { detail: &err },
1603 false,
1604 );
1605 }
1606 }
1607 }
1608}
1609
1610fn draw_image(
1613 f: &mut Frame,
1614 store: &mut crate::image::ImageStore,
1615 theme: &Theme,
1616 path: &std::path::Path,
1617 area: Rect,
1618 selected: bool,
1619) -> Option<Rect> {
1620 if area.width < 3 || area.height < 3 {
1621 return None;
1622 }
1623 let inner = area.inner(Margin {
1626 horizontal: 1,
1627 vertical: 1,
1628 });
1629 match store.get(path) {
1630 crate::image::ImageReady::Ready(protocol) => Some(render_protocol(
1631 f,
1632 protocol,
1633 inner,
1634 theme,
1635 selected.then_some(path),
1636 )),
1637 crate::image::ImageReady::Loading => {
1638 let slot = letterbox_rect(area, 4, 3);
1640 draw_image_slot(f, theme, slot, ImageSlotKind::Loading, selected);
1641 Some(slot)
1642 }
1643 crate::image::ImageReady::Failed(err) => {
1644 let name = path
1645 .file_name()
1646 .and_then(|n| n.to_str())
1647 .unwrap_or(err.as_str());
1648 let slot = letterbox_rect(area, 4, 3);
1649 draw_image_slot(
1650 f,
1651 theme,
1652 slot,
1653 ImageSlotKind::Broken { detail: name },
1654 selected,
1655 );
1656 Some(slot)
1657 }
1658 }
1659}
1660
1661fn render_protocol(
1664 f: &mut Frame,
1665 protocol: &mut ratatui_image::protocol::StatefulProtocol,
1666 inner: Rect,
1667 theme: &Theme,
1668 frame: Option<&std::path::Path>,
1669) -> Rect {
1670 let size = protocol.size_for(Resize::Scale(None), inner.as_size());
1674 let picture = centered(
1675 inner,
1676 size.width.min(inner.width),
1677 size.height.min(inner.height),
1678 );
1679 f.render_stateful_widget(
1680 StatefulImage::default().resize(Resize::Scale(None)),
1681 picture,
1682 protocol,
1683 );
1684 let hit = if let Some(path) = frame {
1685 let border = Rect {
1686 x: picture.x.saturating_sub(1),
1687 y: picture.y.saturating_sub(1),
1688 width: picture.width.saturating_add(2),
1689 height: picture.height.saturating_add(2),
1690 };
1691 let kind = crate::image::type_label(path);
1692 f.render_widget(
1693 Block::bordered()
1694 .border_type(BorderType::Thick)
1695 .border_style(theme.accent_text())
1696 .title_top(
1697 Line::styled(format!(" {kind} "), Style::new().fg(theme.muted_color()))
1698 .right_aligned(),
1699 ),
1700 border,
1701 );
1702 border
1703 } else {
1704 picture
1705 };
1706 if let Some(Err(err)) = protocol.last_encoding_result() {
1707 let line = Line::styled(
1708 truncate(&format!("image: {err}"), inner.width as usize),
1709 Style::new().fg(theme.error_color()),
1710 );
1711 f.render_widget(Paragraph::new(line), inner);
1712 }
1713 hit
1714}
1715
1716fn render_field_box(f: &mut Frame, block: Block, area: Rect) -> Rect {
1717 let inner = block.inner(area);
1718 f.render_widget(block, area);
1719 inner
1720}
1721
1722fn line_with_selection(
1725 text: &str,
1726 sel: Option<(u16, u16)>,
1727 base: Style,
1728 theme: &Theme,
1729) -> Line<'static> {
1730 let Some((a, b)) = sel else {
1731 return Line::from(Span::styled(text.to_string(), base));
1732 };
1733 let a = a as usize;
1734 let b = b as usize;
1735 if a >= b {
1736 return Line::from(Span::styled(text.to_string(), base));
1737 }
1738 let sel_style = theme.selection();
1739 let mut spans = Vec::new();
1740 let mut col = 0usize;
1741 let mut chunk = String::new();
1742 let mut chunk_in_sel = false;
1743 let flush = |spans: &mut Vec<Span<'static>>, chunk: &mut String, in_sel: bool| {
1744 if chunk.is_empty() {
1745 return;
1746 }
1747 let style = if in_sel { sel_style } else { base };
1748 spans.push(Span::styled(std::mem::take(chunk), style));
1749 };
1750 for grapheme in text.graphemes(true) {
1751 let w = grapheme.width();
1752 let in_sel = col >= a && col < b;
1753 if !chunk.is_empty() && in_sel != chunk_in_sel {
1754 flush(&mut spans, &mut chunk, chunk_in_sel);
1755 }
1756 chunk_in_sel = in_sel;
1757 chunk.push_str(grapheme);
1758 col += w;
1759 }
1760 flush(&mut spans, &mut chunk, chunk_in_sel);
1761 Line::from(spans)
1762}
1763
1764fn render_or_placeholder(f: &mut Frame, area: Rect, text: &str, placeholder: &str, theme: &Theme) {
1765 let line = if text.is_empty() {
1766 Line::styled(
1767 truncate(placeholder, area.width as usize),
1768 Style::new()
1769 .fg(theme.muted_color())
1770 .add_modifier(Modifier::DIM),
1771 )
1772 } else {
1773 Line::raw(text.to_string())
1774 };
1775 f.render_widget(Paragraph::new(line), area);
1776}
1777
1778#[derive(Clone)]
1779struct LabelToken {
1780 name: String,
1781 color: Option<LabelColor>,
1782}
1783
1784impl LabelToken {
1785 fn new(name: &str, color: LabelColor) -> Self {
1786 Self {
1787 name: name.to_string(),
1788 color: Some(color),
1789 }
1790 }
1791
1792 fn remainder(hidden: usize) -> Self {
1793 Self {
1794 name: format!("+{hidden}"),
1795 color: None,
1796 }
1797 }
1798}
1799
1800impl From<&crate::model::Label> for LabelToken {
1801 fn from(label: &crate::model::Label) -> Self {
1802 Self::new(&label.name, label.color)
1803 }
1804}
1805
1806fn label_badges_width(labels: &[LabelToken]) -> usize {
1807 labels
1808 .iter()
1809 .map(|label| {
1810 label
1811 .name
1812 .width()
1813 .saturating_add(if label.color.is_some() { 2 } else { 0 })
1814 })
1815 .sum::<usize>()
1816 .saturating_add(labels.len().saturating_sub(1))
1817}
1818
1819fn label_badges_spans(labels: &[LabelToken], theme: &Theme, done: bool) -> Vec<Span<'static>> {
1820 let mut spans = Vec::with_capacity(labels.len().saturating_mul(2));
1821 for (index, label) in labels.iter().enumerate() {
1822 if index > 0 {
1823 spans.push(Span::raw(" "));
1824 }
1825 match label.color {
1826 Some(color) => spans.push(Span::styled(
1827 format!(" {} ", label.name),
1828 theme.label_badge(color, done),
1829 )),
1830 None => spans.push(Span::styled(
1831 label.name.clone(),
1832 Style::new().fg(theme.muted_color()),
1833 )),
1834 }
1835 }
1836 spans
1837}
1838
1839fn label_badges_line(labels: &[LabelToken], theme: &Theme, done: bool) -> Line<'static> {
1840 Line::from(label_badges_spans(labels, theme, done))
1841}
1842
1843fn wrapped_label_badges(
1844 labels: &[LabelToken],
1845 width: usize,
1846 theme: &Theme,
1847 done: bool,
1848) -> Vec<Line<'static>> {
1849 if labels.is_empty() || width == 0 {
1850 return Vec::new();
1851 }
1852 let mut lines = Vec::new();
1853 let mut row = Vec::new();
1854 let mut row_width = 0usize;
1855 for label in labels {
1856 let name = truncate(&label.name, width.saturating_sub(2));
1857 let badge_width = name.width().saturating_add(2);
1858 let gap = usize::from(!row.is_empty());
1859 if !row.is_empty() && row_width.saturating_add(gap + badge_width) > width {
1860 lines.push(label_badges_line(&row, theme, done));
1861 row.clear();
1862 row_width = 0;
1863 }
1864 row_width = row_width
1865 .saturating_add(usize::from(!row.is_empty()))
1866 .saturating_add(badge_width);
1867 row.push(LabelToken {
1868 name,
1869 color: label.color,
1870 });
1871 }
1872 if !row.is_empty() {
1873 lines.push(label_badges_line(&row, theme, done));
1874 }
1875 lines
1876}
1877
1878fn draw_text_input(
1879 f: &mut Frame,
1880 input: &mut crate::text_input::TextInput,
1881 area: Rect,
1882 placeholder: &str,
1883 focused: bool,
1884 theme: &Theme,
1885) {
1886 let view = input.visible(area.width as usize);
1887 if view.text.is_empty() {
1888 render_or_placeholder(f, area, "", placeholder, theme);
1889 } else {
1890 f.render_widget(
1891 Paragraph::new(line_with_selection(
1892 &view.text,
1893 view.sel_cols,
1894 Style::new(),
1895 theme,
1896 )),
1897 area,
1898 );
1899 }
1900 if focused {
1901 f.set_cursor_position((area.x.saturating_add(view.cursor_col), area.y));
1902 }
1903}
1904
1905fn panel<'a>(title: &'a str, focused: bool, theme: &Theme) -> Block<'a> {
1908 field_block(title, focused, None, theme)
1909}
1910
1911fn scrollbar(
1913 f: &mut Frame,
1914 theme: &Theme,
1915 area: Rect,
1916 total: usize,
1917 visible: usize,
1918 offset: usize,
1919 focused: bool,
1920) {
1921 paint_scrollbar(f, theme, area, total, visible, offset, focused, 1);
1922}
1923
1924#[allow(clippy::too_many_arguments)]
1925fn paint_scrollbar(
1926 f: &mut Frame,
1927 theme: &Theme,
1928 area: Rect,
1929 total: usize,
1930 visible: usize,
1931 offset: usize,
1932 focused: bool,
1933 vertical_margin: u16,
1934) {
1935 let max_offset = total.saturating_sub(visible);
1940 if max_offset == 0 || area.height <= vertical_margin.saturating_mul(2) {
1941 return;
1942 }
1943 let mut state = ScrollbarState::new(max_offset + 1).position(offset.min(max_offset));
1944 let style = if focused {
1945 theme.accent_text()
1946 } else {
1947 Style::new().fg(theme.muted_color())
1948 };
1949 f.render_stateful_widget(
1950 Scrollbar::new(ScrollbarOrientation::VerticalRight)
1951 .symbols(ratatui::symbols::scrollbar::VERTICAL)
1952 .begin_symbol(None)
1953 .end_symbol(None)
1954 .thumb_style(style)
1955 .track_style(style),
1956 area.inner(Margin {
1957 horizontal: 0,
1958 vertical: vertical_margin,
1959 }),
1960 &mut state,
1961 );
1962}
1963
1964fn draw_sidebar(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
1967 let focused = app.focus == Focus::Sidebar;
1968 let chrome_focus = focused && !app.mode.command_bar_focused();
1969 let block = panel("Categories", chrome_focus, theme);
1970 let inner = block.inner(area);
1971 if inner.height == 0 || inner.width == 0 {
1972 f.render_widget(block, area);
1973 return;
1974 }
1975
1976 let (list_area, hint_area) = if chrome_focus && inner.height > 1 {
1977 let [list_area, hint_area] =
1978 Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
1979 (list_area, Some(hint_area))
1980 } else {
1981 (inner, None)
1982 };
1983 app.areas.sidebar = list_area;
1984
1985 let width = inner.width as usize;
1986 let scores: Vec<String> = app
1987 .categories
1988 .iter()
1989 .enumerate()
1990 .map(|(index, _)| {
1991 let (done, total) = app.category_progress_at(index);
1992 if app.settings.hide_done {
1993 (total - done).to_string()
1994 } else {
1995 format!("{done}/{total}")
1996 }
1997 })
1998 .collect();
1999 let count_width = scores.iter().map(|s| s.width()).max().unwrap_or(3).max(3);
2000 let name_field = width.saturating_sub(count_width + 1);
2001 let items: Vec<ListItem> = app
2002 .categories
2003 .iter()
2004 .zip(scores.iter())
2005 .map(|(cat, score)| {
2006 let count = format!("{score:>count_width$}");
2007 let name = truncate(&cat.name, name_field);
2008 let pad = " ".repeat(width.saturating_sub(name.width() + count.width()));
2009 ListItem::new(Line::from(vec![
2010 Span::raw(name),
2011 Span::raw(pad),
2012 Span::styled(count, Style::new().fg(theme.muted_color())),
2013 ]))
2014 })
2015 .collect();
2016 let rows = items.len();
2017
2018 app.cat_state.select(Some(app.cat_index));
2019 let list = List::new(items).highlight_style(if focused {
2020 theme.selection()
2021 } else {
2022 theme.selection_unfocused()
2023 });
2024 f.render_widget(block, area);
2025 f.render_stateful_widget(list, list_area, &mut app.cat_state);
2026 if panels_accept_mouse(app) && !app.searching {
2027 let start = app.cat_state.offset();
2028 for index in start..app.categories.len() {
2029 let y = list_area
2030 .y
2031 .saturating_add(u16::try_from(index - start).unwrap_or(u16::MAX));
2032 if y >= list_area.bottom() {
2033 break;
2034 }
2035 app.areas.hover_fill(
2036 HoverTarget::Sidebar(index),
2037 Rect {
2038 y,
2039 height: 1,
2040 ..list_area
2041 },
2042 );
2043 }
2044 }
2045 if let Some(hint_area) = hint_area {
2046 f.render_widget(
2047 Paragraph::new(Line::from(Span::styled(
2048 "⌥↑↓ reorder",
2049 Style::new().fg(theme.muted_color()),
2050 )))
2051 .right_aligned(),
2052 hint_area,
2053 );
2054 }
2055
2056 let scrollbar_area = Rect {
2057 height: list_area.height.saturating_add(2).min(area.height),
2058 ..area
2059 };
2060 scrollbar(
2061 f,
2062 theme,
2063 scrollbar_area,
2064 rows,
2065 list_area.height as usize,
2066 app.cat_state.offset(),
2067 chrome_focus,
2068 );
2069}
2070
2071fn draw_tasks(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
2074 let focused = app.focus == Focus::Tasks;
2075 let chrome_focus = focused && app.mode != Mode::TaskForm && !app.mode.command_bar_focused();
2078 let mut block = panel("Tasks", chrome_focus, theme);
2081 if app.searching {
2083 let context = format!(" search: {} · {} found ", app.search_query, app.view.len());
2084 block = block
2085 .title_top(Line::styled(context, Style::new().fg(theme.muted_color())).right_aligned());
2086 }
2087 let inner = block.inner(area);
2088 app.areas.tasks = inner;
2089 if inner.height == 0 || inner.width == 0 {
2090 f.render_widget(block, area);
2091 return;
2092 }
2093
2094 if app.view.is_empty() {
2095 f.render_widget(block, area);
2096 let text = if app.searching {
2097 banner::NO_SEARCH_RESULTS
2098 } else {
2099 banner::EMPTY_TASKS
2100 };
2101 let style = if chrome_focus {
2102 theme.accent_text()
2103 } else {
2104 Style::new().fg(theme.muted_color())
2105 };
2106 draw_box(f, inner, text, style);
2107 return;
2108 }
2109
2110 let flags_width = crate::model::MAX_IMPORTANCE as usize;
2115 let today = chrono::Local::now().date_naive();
2116
2117 const TITLE_MIN: usize = 8;
2121 let available = inner.width as usize;
2122 let flags_visible = DONE_MARK_WIDTH as usize + 1 + TITLE_MIN + 1 + flags_width <= available;
2123 let mut widths = vec![
2124 Constraint::Length(DONE_MARK_WIDTH), Constraint::Fill(1), ];
2127 if flags_visible {
2128 widths.push(Constraint::Length(flags_width as u16));
2129 }
2130 let column_gaps = widths.len().saturating_sub(1);
2131 let content_width = available
2132 .saturating_sub(DONE_MARK_WIDTH as usize)
2133 .saturating_sub(column_gaps)
2134 .saturating_sub(if flags_visible { flags_width } else { 0 });
2135 let rows: Vec<Row> = app
2136 .list_rows
2137 .iter()
2138 .map(|row| match row {
2139 crate::app::TaskListRow::Separator { .. } => {
2142 Row::new(std::iter::repeat_n(Cell::new(""), widths.len()))
2143 }
2144 crate::app::TaskListRow::Task(view_idx) => {
2145 let task = &app.tasks[app.view[*view_idx]];
2146 task_row(
2147 TaskPresentation::new(task, &app.labels, &app.settings.date_format, today),
2148 theme,
2149 (*view_idx == app.task_index).then(|| {
2150 if chrome_focus {
2151 theme.selection()
2152 } else {
2153 theme.selection_unfocused()
2154 }
2155 }),
2156 content_width,
2157 flags_visible,
2158 )
2159 }
2160 })
2161 .collect();
2162 let table = Table::new(rows, widths).block(block).column_spacing(1);
2164
2165 app.areas.done_x = Some(inner.x);
2168 app.areas.flag_x = flags_visible.then_some(inner.right().saturating_sub(flags_width as u16));
2169
2170 let vis = app.selected_visual_row();
2171 app.task_state.select(vis);
2172 if let Some(vis) = vis {
2176 pin_section_header(app, vis);
2177 }
2178 f.render_stateful_widget(table, area, &mut app.task_state);
2179
2180 let offset = app.task_state.offset();
2182 let rule_style = Style::new().fg(theme.muted_color());
2183 for (vis_i, row) in app.list_rows.iter().enumerate().skip(offset) {
2184 let y = inner
2185 .y
2186 .saturating_add(u16::try_from(vis_i - offset).unwrap_or(u16::MAX));
2187 if y >= inner.bottom() {
2188 break;
2189 }
2190 match row {
2191 crate::app::TaskListRow::Separator { title } => {
2192 let title_x = (DONE_MARK_WIDTH + 1) as usize;
2194 let line = category_rule(title, inner.width as usize, title_x);
2195 f.render_widget(
2196 Paragraph::new(Span::styled(line, rule_style)),
2197 Rect {
2198 x: inner.x,
2199 y,
2200 width: inner.width,
2201 height: 1,
2202 },
2203 );
2204 }
2205 crate::app::TaskListRow::Task(view_index) if panels_accept_mouse(app) => {
2206 app.areas.hover_fill(
2207 HoverTarget::Task(*view_index),
2208 Rect {
2209 x: inner.x,
2210 y,
2211 width: inner.width,
2212 height: 1,
2213 },
2214 );
2215 }
2216 crate::app::TaskListRow::Task(_) => {}
2217 }
2218 }
2219
2220 scrollbar(
2221 f,
2222 theme,
2223 area,
2224 app.list_rows.len(),
2225 inner.height as usize,
2226 app.task_state.offset(),
2227 chrome_focus,
2228 );
2229}
2230
2231fn panels_accept_mouse(app: &App) -> bool {
2232 matches!(
2233 app.mode,
2234 Mode::Normal | Mode::Search | Mode::TaskForm | Mode::CategoryForm
2235 ) && !app.form.as_ref().is_some_and(|form| form.preview)
2236}
2237
2238fn pin_section_header(app: &mut App, vis: usize) {
2241 if vis == 0 {
2242 return;
2243 }
2244 let header = vis - 1;
2245 if !matches!(
2246 app.list_rows.get(header),
2247 Some(crate::app::TaskListRow::Separator { .. })
2248 ) {
2249 return;
2250 }
2251 if app.task_state.offset() > header {
2252 *app.task_state.offset_mut() = header;
2253 }
2254}
2255
2256fn extras(task: &crate::model::Task) -> String {
2258 let mut has_prose_or_image = false;
2259 let mut done = 0usize;
2260 let mut total = 0usize;
2261 for block in &task.description {
2262 match block {
2263 crate::model::Block::Todo { done: is_done, .. } => {
2264 total += 1;
2265 done += usize::from(*is_done);
2266 }
2267 block if !block.is_empty() => has_prose_or_image = true,
2268 _ => {}
2269 }
2270 }
2271 match (has_prose_or_image, total) {
2272 (true, 0) => "≡".to_string(),
2273 (true, _) => format!("≡ {done}/{total}"),
2274 (false, 0) => String::new(),
2275 (false, _) => format!("{done}/{total}"),
2276 }
2277}
2278
2279struct TaskPresentation<'a> {
2281 title: &'a str,
2282 labels: Vec<LabelToken>,
2283 extras: String,
2284 due: String,
2285 flags: String,
2286 done: bool,
2287}
2288
2289impl<'a> TaskPresentation<'a> {
2290 fn new(
2291 task: &'a crate::model::Task,
2292 labels: &[crate::model::Label],
2293 date_format: &str,
2294 today: chrono::NaiveDate,
2295 ) -> Self {
2296 Self {
2297 title: &task.title,
2298 labels: labels_for_task(task, labels)
2299 .map(LabelToken::from)
2300 .collect(),
2301 extras: extras(task),
2302 due: due::display_compact_at(&task.due, date_format, today),
2303 flags: crate::model::importance_marks(task.importance),
2304 done: task.done,
2305 }
2306 }
2307}
2308
2309fn category_rule(title: &str, width: usize, title_x: usize) -> String {
2312 if width == 0 {
2313 return String::new();
2314 }
2315 let label = format!(" {title} ");
2318 let label_w = label.width();
2319 let pad = title_x.saturating_sub(1).min(width);
2320 if pad + label_w >= width {
2321 let head = "─".repeat(pad);
2322 return truncate(&format!("{head}{label}"), width);
2323 }
2324 format!(
2325 "{}{label}{}",
2326 "─".repeat(pad),
2327 "─".repeat(width - pad - label_w)
2328 )
2329}
2330
2331fn task_row(
2332 presentation: TaskPresentation<'_>,
2333 theme: &Theme,
2334 selection: Option<Style>,
2335 content_width: usize,
2336 flags_visible: bool,
2337) -> Row<'static> {
2338 let done = presentation.done;
2339 let selected = selection.is_some();
2340 let title_style = if done && !selected {
2344 Style::new().fg(theme.muted_color())
2345 } else {
2346 theme.plain()
2347 };
2348 let title_style = if done {
2349 title_style.add_modifier(Modifier::CROSSED_OUT)
2350 } else {
2351 title_style
2352 };
2353
2354 let mut cells = Vec::with_capacity(5);
2355 let (mark, mark_style) = if done {
2356 ("[✓]", Style::new().fg(theme.success_color()))
2357 } else {
2358 ("[ ]", Style::new().fg(theme.muted_color()))
2359 };
2360 cells.push(Cell::new(mark).style(mark_style));
2361 let metadata_style = if done {
2362 Style::new()
2363 .fg(theme.muted_color())
2364 .add_modifier(Modifier::CROSSED_OUT)
2365 } else {
2366 Style::new().fg(theme.muted_color())
2367 };
2368 let due_style = if done {
2369 title_style
2370 } else {
2371 Style::new().fg(theme.accent)
2372 };
2373 cells.push(Cell::new(task_content_line(
2374 &presentation,
2375 TaskContentStyles {
2376 title: title_style,
2377 extras: metadata_style,
2378 due: due_style,
2379 },
2380 theme,
2381 content_width,
2382 )));
2383 if flags_visible {
2384 let flag_style = if done {
2385 metadata_style
2386 } else {
2387 Style::new().fg(theme.error_color())
2388 };
2389 cells.push(Cell::new(Text::from(
2390 Line::from(Span::styled(presentation.flags, flag_style)).right_aligned(),
2391 )));
2392 }
2393 let row = Row::new(cells);
2394 if let Some(style) = selection {
2395 row.style(style)
2396 } else {
2397 row
2398 }
2399}
2400
2401#[derive(Clone, Copy)]
2406struct TaskContentStyles {
2407 title: Style,
2408 extras: Style,
2409 due: Style,
2410}
2411
2412fn task_content_line(
2413 presentation: &TaskPresentation<'_>,
2414 styles: TaskContentStyles,
2415 theme: &Theme,
2416 width: usize,
2417) -> Line<'static> {
2418 const TITLE_MIN: usize = 8;
2419 const META_GAP: usize = 1;
2420
2421 let title_floor = presentation.title.width().min(TITLE_MIN);
2422 let mut show_due = false;
2423 let mut show_extras = false;
2424 let mut shown_labels = Vec::new();
2425 let mut metadata_width = 0;
2426
2427 if !presentation.due.is_empty() && title_floor + META_GAP + presentation.due.width() <= width {
2428 show_due = true;
2429 metadata_width = presentation.due.width();
2430 }
2431
2432 if !presentation.labels.is_empty() {
2433 let reserved = title_floor
2434 .saturating_add(META_GAP)
2435 .saturating_add(metadata_width)
2436 .saturating_add(usize::from(metadata_width > 0));
2437 shown_labels = compact_badge_tokens(&presentation.labels, width.saturating_sub(reserved));
2438 if !shown_labels.is_empty() {
2439 metadata_width = metadata_width
2440 .saturating_add(usize::from(metadata_width > 0))
2441 .saturating_add(label_badges_width(&shown_labels));
2442 }
2443 }
2444 if !presentation.extras.is_empty() {
2445 let joined_width = if metadata_width == 0 {
2446 presentation.extras.width()
2447 } else {
2448 presentation.extras.width() + META_GAP + metadata_width
2449 };
2450 if title_floor + META_GAP + joined_width <= width {
2451 show_extras = true;
2452 metadata_width = joined_width;
2453 }
2454 }
2455
2456 if metadata_width == 0 {
2457 return Line::from(Span::styled(
2458 truncate(presentation.title, width),
2459 styles.title,
2460 ));
2461 }
2462
2463 let title_width = width.saturating_sub(META_GAP + metadata_width);
2464 let title = truncate(presentation.title, title_width);
2465 let padding = width.saturating_sub(title.width() + metadata_width);
2466 let mut spans = vec![
2467 Span::styled(title, styles.title),
2468 Span::raw(" ".repeat(padding)),
2469 ];
2470 if !shown_labels.is_empty() {
2471 spans.extend(label_badges_spans(&shown_labels, theme, presentation.done));
2472 if show_extras || show_due {
2473 spans.push(Span::raw(" "));
2474 }
2475 }
2476 if show_extras {
2477 spans.push(Span::styled(presentation.extras.clone(), styles.extras));
2478 if show_due {
2479 spans.push(Span::raw(" "));
2480 }
2481 }
2482 if show_due {
2483 spans.push(Span::styled(presentation.due.clone(), styles.due));
2484 }
2485 Line::from(spans)
2486}
2487
2488fn compact_badge_tokens(labels: &[LabelToken], width: usize) -> Vec<LabelToken> {
2489 for shown in (0..=labels.len()).rev() {
2490 let hidden = labels.len() - shown;
2491 let mut parts = labels[..shown].to_vec();
2492 if hidden > 0 {
2493 parts.push(LabelToken::remainder(hidden));
2494 }
2495 if label_badges_width(&parts) <= width {
2496 return parts;
2497 }
2498 }
2499 Vec::new()
2500}
2501
2502fn draw_status(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
2505 let typing = matches!(app.mode, Mode::Slash | Mode::Search);
2508 let update_activity = (!typing).then(|| app.update_activity()).flatten();
2509 let archive_activity = (!typing && app.message.is_none())
2510 .then(|| app.archive_activity_text())
2511 .flatten();
2512 let downloading = matches!(update_activity, Some(UpdateActivity::Downloading(_)));
2513 let block = Block::bordered()
2514 .border_type(BorderType::Thick)
2515 .border_style(if typing {
2516 theme.accent_text()
2517 } else {
2518 Style::new().fg(theme.muted_color())
2519 })
2520 .padding(if downloading {
2521 Padding::ZERO
2522 } else {
2523 Padding::horizontal(1)
2524 });
2525 let inner = block.inner(area);
2526 f.render_widget(block, area);
2527 let area = inner;
2528
2529 if let Some(UpdateActivity::Downloading(progress)) = update_activity {
2530 draw_download_progress(f, progress, theme, area);
2531 return;
2532 }
2533
2534 let right = Line::from(Span::styled(
2535 due::now_string(&app.settings.date_format),
2536 Style::new().fg(theme.muted_color()),
2537 ));
2538 let show_clock = typing || update_activity.is_some() || app.message.is_none();
2542 let right_width = if show_clock { right.width() as u16 } else { 0 };
2543 let [left_area, right_area] = Layout::horizontal([
2544 Constraint::Min(0),
2545 Constraint::Length(right_width.min(area.width)),
2546 ])
2547 .areas(area);
2548 app.areas.command_bar = area;
2551 if show_clock {
2552 f.render_widget(Paragraph::new(right), right_area);
2553 }
2554
2555 let field = left_area.width.saturating_sub(2) as usize;
2556 let left = match app.mode {
2557 Mode::Slash | Mode::Search => {
2558 let view = app.input.visible(field);
2559 f.set_cursor_position((
2560 left_area
2561 .x
2562 .saturating_add(1)
2563 .saturating_add(view.cursor_col),
2564 left_area.y,
2565 ));
2566 let description = line_with_selection(&view.text, view.sel_cols, Style::new(), theme);
2567 Line::from(
2568 [
2569 vec![Span::styled("/", theme.accent_text())],
2570 description.spans,
2571 ]
2572 .concat(),
2573 )
2574 }
2575 _ => {
2576 if let Some(text) = archive_activity.as_deref() {
2577 Line::from(Span::styled(truncate(text, field), theme.accent_text()))
2578 } else if update_activity == Some(UpdateActivity::Checking) {
2579 Line::from(Span::styled("Checking for updates…", theme.accent_text()))
2580 } else {
2581 match app.status_message() {
2582 Some((text, kind)) => {
2583 let style = match kind {
2584 MessageKind::Error => Style::new()
2585 .fg(theme.error_color())
2586 .add_modifier(Modifier::BOLD),
2587 MessageKind::Info => theme.plain(),
2588 };
2589 Line::from(Span::styled(truncate(text, field), style))
2590 }
2591 None => {
2592 let hint = if app.searching {
2593 format!("search: {} · Esc clears", app.search_query)
2594 } else {
2595 "/ commands".to_string()
2596 };
2597 if (left_area.width as usize) >= hint.width() + 2 {
2598 Line::from(Span::styled(hint, Style::new().fg(theme.muted_color())))
2599 } else {
2600 Line::raw("")
2601 }
2602 }
2603 }
2604 }
2605 }
2606 };
2607 f.render_widget(Paragraph::new(left), left_area);
2608}
2609
2610fn draw_download_progress(
2611 f: &mut Frame,
2612 progress: crate::update::DownloadProgress,
2613 theme: &Theme,
2614 area: Rect,
2615) {
2616 let Some(total) = progress.total.filter(|total| *total > 0) else {
2617 f.render_widget(
2618 Paragraph::new(Line::from(Span::styled(
2619 format!(
2620 "Downloading update… {}",
2621 readable_bytes(progress.downloaded)
2622 ),
2623 theme.accent_text(),
2624 )))
2625 .centered(),
2626 area,
2627 );
2628 return;
2629 };
2630 let ratio = progress.downloaded.min(total) as f64 / total as f64;
2631 let percent = (ratio * 100.0).round() as u64;
2632 let label = format!("Downloading update {percent}%");
2633 f.render_widget(
2634 Gauge::default()
2635 .ratio(ratio)
2636 .label(label)
2637 .use_unicode(true)
2638 .style(Style::new().fg(theme.muted_color()))
2639 .gauge_style(theme.accent_text().add_modifier(Modifier::BOLD)),
2640 area,
2641 );
2642}
2643
2644fn readable_bytes(bytes: u64) -> String {
2645 const MIB: u64 = 1024 * 1024;
2646 const KIB: u64 = 1024;
2647 if bytes >= MIB {
2648 format!("{:.1} MiB", bytes as f64 / MIB as f64)
2649 } else if bytes >= KIB {
2650 format!("{:.1} KiB", bytes as f64 / KIB as f64)
2651 } else {
2652 format!("{bytes} B")
2653 }
2654}
2655
2656fn draw_slash_palette(f: &mut Frame, app: &mut App, theme: &Theme, status: Rect) {
2658 let query = app.input.value();
2659 let commands = crate::slash::matching(&query);
2660 if commands.is_empty() {
2661 return;
2662 }
2663 let desired_width = commands
2664 .iter()
2665 .map(|command| format!(" /{:<15}{} ", command.usage(), command.hint()).width() as u16)
2666 .max()
2667 .unwrap_or(22)
2668 .saturating_add(3);
2669 let width = desired_width.min(status.width.saturating_sub(2)).max(24);
2670 let height = u16::try_from(commands.len())
2671 .unwrap_or(u16::MAX)
2672 .saturating_add(2)
2673 .min(status.y.max(3));
2674 let rect = Rect {
2675 x: status.x,
2676 y: status.y.saturating_sub(height),
2677 width,
2678 height,
2679 };
2680 app.areas.slash_menu = rect;
2681 let visible_rows = usize::from(height.saturating_sub(2));
2682 let selected_index = app.slash_index.min(commands.len() - 1);
2683 let start = selected_index
2684 .saturating_add(1)
2685 .saturating_sub(visible_rows)
2686 .min(commands.len().saturating_sub(visible_rows));
2687 app.areas.slash_menu_start = start;
2688 let row_width = width.saturating_sub(2) as usize;
2689 let lines: Vec<Line> = commands
2690 .iter()
2691 .enumerate()
2692 .skip(start)
2693 .take(visible_rows)
2694 .map(|(index, cmd)| {
2695 let selected = index == selected_index;
2696 dropdown_row(
2697 theme,
2698 selected,
2699 &format!("/{:<15}", cmd.usage()),
2700 cmd.hint(),
2701 row_width,
2702 )
2703 })
2704 .collect();
2705 let block = Block::bordered()
2706 .border_type(BorderType::Thick)
2707 .border_style(theme.accent_text())
2708 .title(Span::styled(
2709 format!(" /{} ", query),
2710 Style::new().fg(theme.muted_color()),
2711 ));
2712 f.render_widget(Clear, rect);
2713 f.render_widget(Paragraph::new(lines).block(block), rect);
2714 app.areas.occlude_hover(rect);
2715 let inner = rect.inner(Margin {
2716 horizontal: 1,
2717 vertical: 1,
2718 });
2719 for index in start..commands.len().min(start.saturating_add(visible_rows)) {
2720 app.areas.hover_fill(
2721 HoverTarget::SlashCommand(index),
2722 Rect {
2723 y: inner
2724 .y
2725 .saturating_add(u16::try_from(index - start).unwrap_or(u16::MAX)),
2726 height: 1,
2727 ..inner
2728 },
2729 );
2730 }
2731}
2732
2733fn dropdown_row(
2736 theme: &Theme,
2737 selected: bool,
2738 label: &str,
2739 hint: &str,
2740 row_width: usize,
2741) -> Line<'static> {
2742 let label_part = truncate(&format!(" {label} "), row_width);
2743 let hint_space = row_width.saturating_sub(label_part.width());
2744 let hint_part = if hint_space == 0 {
2745 String::new()
2746 } else {
2747 format!("{} ", truncate(hint, hint_space - 1))
2748 };
2749 let used = label_part.width() + hint_part.width();
2750 let pad = " ".repeat(row_width.saturating_sub(used));
2751
2752 let (label_style, hint_style, pad_style) = if selected {
2753 let selection = theme.selection();
2754 (selection, selection, selection)
2755 } else {
2756 (
2757 Style::new(),
2758 Style::new().fg(theme.muted_color()),
2759 Style::new(),
2760 )
2761 };
2762 Line::from(vec![
2763 Span::styled(label_part, label_style),
2764 Span::styled(hint_part, hint_style),
2765 Span::styled(pad, pad_style),
2766 ])
2767}
2768
2769fn draw_help(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
2772 const COLUMN_WIDTH: usize = 40;
2773 const WIDE_WIDTH: u16 = COLUMN_WIDTH as u16 * 2 + 7;
2774 const NARROW_WIDTH: u16 = 58;
2775
2776 let wide = area.width >= WIDE_WIDTH;
2777 let width = if wide {
2778 WIDE_WIDTH
2779 } else {
2780 NARROW_WIDTH.min(area.width)
2781 };
2782 let mut lines = wordmark_lines(theme, width);
2783 if !lines.is_empty() {
2784 lines.push(Line::raw(""));
2785 }
2786 let row_style = |heading| {
2787 if heading {
2788 theme.accent_text().add_modifier(Modifier::BOLD)
2789 } else {
2790 Style::new()
2791 }
2792 };
2793 if wide {
2794 for banner::HelpRow {
2795 left,
2796 right,
2797 heading,
2798 } in banner::HELP_COLUMNS
2799 {
2800 let style = row_style(heading);
2801 lines.push(Line::from(vec![
2802 Span::raw(" "),
2803 Span::styled(format!("{left:<COLUMN_WIDTH$}"), style),
2804 Span::styled(right, style),
2805 ]));
2806 }
2807 lines.push(Line::raw(""));
2808 } else {
2809 for side in 0..2 {
2811 for banner::HelpRow {
2812 left,
2813 right,
2814 heading,
2815 } in banner::HELP_COLUMNS
2816 {
2817 let text = if side == 0 { left } else { right };
2818 if text.is_empty() {
2819 lines.push(Line::raw(""));
2820 continue;
2821 }
2822 let prefix = if heading { "" } else { " " };
2823 lines.push(Line::styled(format!("{prefix}{text}"), row_style(heading)));
2824 }
2825 lines.push(Line::raw(""));
2826 }
2827 }
2828 let store = format!("Data store: {}", app.data_dir().display());
2829 lines.push(
2830 Line::styled(
2831 truncate(&store, width.saturating_sub(4) as usize),
2832 Style::new().fg(theme.muted_color()),
2833 )
2834 .centered(),
2835 );
2836 lines.push(Line::styled(banner::HELP_FOOTER, theme.accent_text()).centered());
2837
2838 let height = u16::try_from(lines.len())
2839 .unwrap_or(u16::MAX)
2840 .saturating_add(2)
2841 .min(area.height);
2842 let rect = centered(area, width, height);
2843 let viewport = rect.height.saturating_sub(2) as usize;
2844 let max_scroll = lines.len().saturating_sub(viewport);
2845 app.help_scroll = app.help_scroll.min(max_scroll);
2846 let title = Line::from(vec![
2847 Span::raw(" mach "),
2848 Span::styled(
2849 format!("v{} ", crate::VERSION),
2850 Style::new().fg(theme.muted_color()),
2851 ),
2852 ]);
2853 let block = Block::bordered()
2854 .border_type(BorderType::Thick)
2855 .title(title)
2856 .border_style(theme.accent_text())
2857 .padding(ratatui::widgets::Padding::horizontal(1));
2858 f.render_widget(Clear, rect);
2859 f.render_widget(
2860 Paragraph::new(lines)
2861 .block(block)
2862 .scroll((app.help_scroll.min(u16::MAX as usize) as u16, 0)),
2863 rect,
2864 );
2865}
2866
2867fn draw_settings(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2868 let mut lines: Vec<Line> = Vec::new();
2869 for (i, item) in SETTINGS_ITEMS.iter().enumerate() {
2870 let selected = i == app.settings_index;
2871 let value = app.setting_value(i);
2872 let marker = if selected { "❯ " } else { " " };
2873 let name_style = if selected {
2874 Style::new().add_modifier(Modifier::BOLD)
2875 } else {
2876 Style::new()
2877 };
2878 lines.push(Line::from(vec![
2879 Span::styled(marker, theme.accent_text()),
2880 Span::styled(format!("{item:<14}"), name_style),
2881 Span::styled(value, theme.accent_text()),
2882 ]));
2883 }
2884 lines.push(Line::raw(""));
2885 lines.push(Line::styled(
2886 "↑↓ select · ←→ change · Esc close",
2887 Style::new().fg(theme.muted_color()),
2888 ));
2889
2890 let width = 48.min(area.width);
2891 let height = u16::try_from(lines.len())
2892 .unwrap_or(u16::MAX)
2893 .saturating_add(2)
2894 .min(area.height);
2895 let rect = centered(area, width, height);
2896 let block = Block::bordered()
2897 .border_type(BorderType::Thick)
2898 .title(Line::from(" Settings "))
2899 .border_style(theme.accent_text())
2900 .padding(ratatui::widgets::Padding::horizontal(2));
2901 f.render_widget(Clear, rect);
2902 f.render_widget(Paragraph::new(lines).block(block), rect);
2903}
2904
2905struct LabelManagerLayout {
2906 rect: Rect,
2907 flow: Vec<(String, Rect)>,
2908 flow_rows: u16,
2909}
2910
2911fn label_manager_layout(app: &App, area: Rect) -> LabelManagerLayout {
2912 let editing = app.label_editor.is_some();
2913 let width = 48.min(area.width);
2914 let content_width = width.saturating_sub(4);
2915 let flow = label_flow_layout(&app.labels, content_width);
2916 let flow_rows = flow.last().map_or(1, |(_, rect)| rect.y.saturating_add(1));
2917 let desired_rows = flow_rows.clamp(3, 10);
2918 let height = desired_rows
2919 .saturating_add(if editing { 10 } else { 2 })
2920 .min(area.height);
2921 let rect = centered(area, width, height);
2922 LabelManagerLayout {
2923 rect,
2924 flow,
2925 flow_rows,
2926 }
2927}
2928
2929fn draw_labels(f: &mut Frame, app: &mut App, theme: &Theme, layout: &LabelManagerLayout) {
2930 let editing = app.label_editor.is_some();
2931 let LabelManagerLayout {
2932 rect,
2933 flow,
2934 flow_rows,
2935 } = layout;
2936 let rect = *rect;
2937 app.areas.occlude_hover(rect);
2938 let width = rect.width;
2939 let hint = if let Some(error) = &app.label_error {
2940 Line::styled(
2941 format!(" {} ", truncate(error, width.saturating_sub(4) as usize)),
2942 Style::new()
2943 .fg(theme.error_color())
2944 .add_modifier(Modifier::BOLD),
2945 )
2946 } else if editing {
2947 Line::styled(" Ctrl+S save ", Style::new().fg(theme.muted_color()))
2948 } else {
2949 Line::styled(
2950 " Ctrl+A new · Backspace delete ",
2951 Style::new().fg(theme.muted_color()),
2952 )
2953 };
2954 let block = Block::bordered()
2955 .border_type(BorderType::Thick)
2956 .border_style(theme.accent_text())
2957 .title(Span::styled(" Labels ", theme.accent_text().bold()))
2958 .title_bottom(hint.right_aligned())
2959 .padding(Padding::horizontal(1));
2960 let inner = block.inner(rect);
2961 f.render_widget(Clear, rect);
2962 f.render_widget(block, rect);
2963 if inner.width == 0 || inner.height == 0 {
2964 return;
2965 }
2966
2967 let (list_area, input_area) = if editing {
2968 let [list, input] =
2969 Layout::vertical([Constraint::Min(1), Constraint::Length(8)]).areas(inner);
2970 (list, Some(input))
2971 } else {
2972 (inner, None)
2973 };
2974
2975 if app.labels.is_empty() {
2976 f.render_widget(
2977 Paragraph::new(Line::styled(
2978 "No labels yet",
2979 Style::new().fg(theme.muted_color()),
2980 )),
2981 list_area,
2982 );
2983 } else {
2984 let selected = app.label_index.min(app.labels.len() - 1);
2985 let visible_rows = list_area.height;
2986 let selected_row = flow.get(selected).map_or(0, |(_, badge)| badge.y);
2987 let start_row = selected_row
2988 .saturating_add(1)
2989 .saturating_sub(visible_rows)
2990 .min(flow_rows.saturating_sub(visible_rows));
2991 for (index, (name, badge)) in flow.iter().enumerate() {
2992 if badge.y < start_row || badge.y >= start_row.saturating_add(visible_rows) {
2993 continue;
2994 }
2995 let screen = Rect {
2996 x: list_area.x.saturating_add(badge.x),
2997 y: list_area.y.saturating_add(badge.y - start_row),
2998 width: badge.width,
2999 height: 1,
3000 };
3001 app.areas.label_hits.push((index, screen));
3002 if index == selected {
3003 app.areas.hover_fill(HoverTarget::Label(index), screen);
3004 } else {
3005 app.areas.hover_badge(HoverTarget::Label(index), screen);
3006 }
3007 let style = if index == selected {
3008 theme.label_focus()
3009 } else {
3010 theme.label_badge(app.labels[index].color, false)
3011 };
3012 f.render_widget(
3013 Paragraph::new(Line::styled(format!(" {name} "), style)),
3014 screen,
3015 );
3016 }
3017 paint_scrollbar(
3018 f,
3019 theme,
3020 rect,
3021 *flow_rows as usize,
3022 visible_rows as usize,
3023 start_row as usize,
3024 true,
3025 1,
3026 );
3027 }
3028
3029 if let Some(input_area) = input_area
3030 && let Some(editor) = &mut app.label_editor
3031 {
3032 let label = if editor.editing_id.is_some() {
3033 "Edit label"
3034 } else {
3035 "New label"
3036 };
3037 let editor_inner = render_field_box(
3038 f,
3039 field_block(label, true, None, theme).padding(Padding::ZERO),
3040 input_area,
3041 );
3042 let [name_box, color_box] =
3043 Layout::vertical([Constraint::Length(3), Constraint::Length(3)]).areas(editor_inner);
3044 let name_area = render_field_box(
3045 f,
3046 field_block("Name", !editor.color_focused, None, theme),
3047 name_box,
3048 );
3049 let color_area = render_field_box(
3050 f,
3051 field_block("Color", editor.color_focused, None, theme),
3052 color_box,
3053 );
3054 app.areas.label_name_input = name_area;
3055 draw_text_input(
3056 f,
3057 &mut editor.name,
3058 name_area,
3059 "",
3060 !editor.color_focused,
3061 theme,
3062 );
3063
3064 const SWATCH_SLOT_WIDTH: u16 = 3;
3065 let palette_width = SWATCH_SLOT_WIDTH * LabelColor::SWATCHES.len() as u16;
3066 if color_area.width >= palette_width {
3067 let ring_style = if editor.color_focused {
3068 theme.accent_text().bold()
3069 } else {
3070 Style::new().fg(theme.muted_color())
3071 };
3072 let mut x = color_area
3073 .x
3074 .saturating_add(color_area.width.saturating_sub(palette_width) / 2);
3075 for color in LabelColor::SWATCHES {
3076 let slot = Rect {
3077 x,
3078 y: color_area.y,
3079 width: SWATCH_SLOT_WIDTH,
3080 height: 1,
3081 };
3082 app.areas.label_color_hits.push((color, slot));
3083 let selected = color == editor.color;
3084 f.render_widget(
3085 Paragraph::new(Line::from(vec![
3086 Span::styled(if selected { "[" } else { " " }, ring_style),
3087 Span::styled("■", theme.label_swatch(color)),
3088 Span::styled(if selected { "]" } else { " " }, ring_style),
3089 ])),
3090 slot,
3091 );
3092 x = x.saturating_add(SWATCH_SLOT_WIDTH);
3093 }
3094 }
3095 }
3096}
3097
3098fn label_flow_layout(labels: &[crate::model::Label], width: u16) -> Vec<(String, Rect)> {
3099 if width == 0 {
3100 return Vec::new();
3101 }
3102 let mut x: u16 = 0;
3103 let mut y: u16 = 0;
3104 labels
3105 .iter()
3106 .map(|label| {
3107 let name = truncate(&label.name, width.saturating_sub(2) as usize);
3108 let badge_width = u16::try_from(name.width())
3109 .unwrap_or(u16::MAX)
3110 .saturating_add(2)
3111 .min(width);
3112 if x > 0 && x.saturating_add(1).saturating_add(badge_width) > width {
3113 x = 0;
3114 y = y.saturating_add(1);
3115 } else if x > 0 {
3116 x = x.saturating_add(1);
3117 }
3118 let rect = Rect {
3119 x,
3120 y,
3121 width: badge_width,
3122 height: 1,
3123 };
3124 x = x.saturating_add(badge_width);
3125 (name, rect)
3126 })
3127 .collect()
3128}
3129
3130fn draw_welcome(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
3131 let mut lines = wordmark_lines(theme, area.width);
3132 if !lines.is_empty() {
3133 lines.push(Line::raw(""));
3134 }
3135 lines.push(
3136 Line::styled(
3137 format!("Welcome to mach v{}", crate::VERSION),
3138 Style::new().add_modifier(Modifier::BOLD),
3139 )
3140 .centered(),
3141 );
3142 lines.push(Line::raw(""));
3143 lines.push(Line::raw("Written in Rust with ratatui.").centered());
3144 let storage = format!("Your tasks stay local in {}.", app.data_dir().display());
3145 lines.push(Line::raw(storage.clone()).centered());
3146 lines.push(Line::raw(""));
3147 lines.push(
3148 Line::styled(
3149 "Press Enter to start · /help for the key list",
3150 Style::new().fg(theme.muted_color()),
3151 )
3152 .centered(),
3153 );
3154
3155 let width = u16::try_from(storage.width())
3156 .unwrap_or(u16::MAX)
3157 .saturating_add(4)
3158 .max(50)
3159 .min(area.width);
3160 let height = u16::try_from(lines.len())
3161 .unwrap_or(u16::MAX)
3162 .saturating_add(2)
3163 .min(area.height);
3164 let rect = centered(area, width, height);
3165 let block = Block::bordered()
3166 .border_type(BorderType::Thick)
3167 .border_style(theme.accent_text());
3168 f.render_widget(Clear, rect);
3169 f.render_widget(Paragraph::new(lines).block(block), rect);
3170}
3171
3172fn wordmark_lines(theme: &Theme, available_width: u16) -> Vec<Line<'static>> {
3173 if available_width < banner::BANNER_WIDTH + 8 {
3174 return Vec::new();
3175 }
3176 banner::BANNER
3177 .iter()
3178 .map(|row| Line::styled(*row, theme.accent_text()).centered())
3179 .collect()
3180}
3181
3182fn draw_whats_new(f: &mut Frame, theme: &Theme, area: Rect) {
3183 const OVERLAY_WIDTH: u16 = 62;
3184 const BULLET_PREFIX: &str = "• ";
3185 const DESCRIPTION_PREFIX: &str = " ";
3186 const RELEASE_NOTES_LABEL: &str = "Full release notes:";
3187 const CONTINUE_HINT: &str = "Press Enter or Esc to continue";
3188
3189 let heading = format!("What's new in mach v{}", crate::VERSION);
3190 let release_url = format!("github.com/Q1CHENL/mach/releases/tag/v{}", crate::VERSION);
3191 let width = OVERLAY_WIDTH.min(area.width);
3192 let block = Block::bordered()
3193 .border_type(BorderType::Thick)
3194 .border_style(theme.accent_text())
3195 .padding(Padding::horizontal(2));
3196 let content_width = usize::from(block.inner(Rect::new(0, 0, width, area.height)).width);
3197 let description_width = content_width.saturating_sub(DESCRIPTION_PREFIX.width());
3198
3199 let mut lines = vec![
3200 Line::styled(heading, Style::new().add_modifier(Modifier::BOLD)).centered(),
3201 Line::raw(""),
3202 ];
3203 for (index, (title, description)) in banner::WHATS_NEW.into_iter().enumerate() {
3204 lines.push(Line::from(vec![
3205 Span::styled(BULLET_PREFIX, theme.accent_text()),
3206 Span::styled(title, Style::new().add_modifier(Modifier::BOLD)),
3207 ]));
3208 let graphemes = description
3209 .graphemes(true)
3210 .map(str::to_owned)
3211 .collect::<Vec<_>>();
3212 lines.extend(
3213 crate::text_input::wrap_breaks(&graphemes, description_width)
3214 .into_iter()
3215 .map(|(start, end)| {
3216 let text = graphemes[start..end].concat();
3217 Line::raw(format!("{DESCRIPTION_PREFIX}{}", text.trim_end()))
3218 }),
3219 );
3220 if index + 1 < banner::WHATS_NEW.len() {
3221 lines.push(Line::raw(""));
3222 }
3223 }
3224 lines.push(Line::raw(""));
3225 lines.push(Line::styled(RELEASE_NOTES_LABEL, Style::new().fg(theme.muted_color())).centered());
3226 lines.push(Line::styled(release_url, Style::new().fg(theme.muted_color())).centered());
3227 lines.push(Line::styled(CONTINUE_HINT, Style::new().fg(theme.muted_color())).centered());
3228
3229 if lines.len().saturating_add(2) > usize::from(area.height) {
3230 lines.retain(|line| line.width() > 0);
3231 }
3232 let height = u16::try_from(lines.len())
3233 .unwrap_or(u16::MAX)
3234 .saturating_add(2)
3235 .min(area.height);
3236 let rect = centered(area, width, height);
3237 f.render_widget(Clear, rect);
3238 f.render_widget(Paragraph::new(lines).block(block), rect);
3239}
3240
3241fn draw_box(f: &mut Frame, area: Rect, text: &str, style: Style) {
3244 let width = u16::try_from(text.width())
3245 .unwrap_or(u16::MAX)
3246 .saturating_add(8)
3247 .min(area.width);
3248 let rect = centered(area, width, 3);
3249 let block = Block::bordered()
3250 .border_type(BorderType::Thick)
3251 .border_style(style);
3252 f.render_widget(Clear, rect);
3253 f.render_widget(
3254 Paragraph::new(Line::styled(text.to_string(), style))
3255 .centered()
3256 .block(block),
3257 rect,
3258 );
3259}
3260
3261pub fn centered(area: Rect, width: u16, height: u16) -> Rect {
3262 let width = width.min(area.width);
3263 let height = height.min(area.height);
3264 Rect {
3265 x: area.x.saturating_add((area.width - width) / 2),
3266 y: area.y.saturating_add((area.height - height) / 2),
3267 width,
3268 height,
3269 }
3270}
3271
3272pub fn truncate(s: &str, width: usize) -> String {
3274 if s.width() <= width {
3275 return s.to_string();
3276 }
3277 let mut out = String::new();
3278 let mut used = 0;
3279 for grapheme in s.graphemes(true) {
3280 let w = grapheme.width();
3281 if used + w > width {
3282 break;
3283 }
3284 used += w;
3285 out.push_str(grapheme);
3286 }
3287 out
3288}