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