1use ratatui::Frame;
6use ratatui::layout::{Constraint, Layout, Margin, Rect};
7use ratatui::style::{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::{App, Focus, MessageKind, Mode, SETTINGS_ITEMS, UpdateActivity};
19use crate::banner;
20use crate::due;
21use crate::form::Field;
22use crate::theme::Theme;
23
24pub const SIDEBAR_WIDTH: u16 = 26;
26pub const DONE_MARK_WIDTH: u16 = 3;
28const PREVIEW_SPLIT_MIN: u16 = 16;
30const LIST_MIN: u16 = 6;
32const PREVIEW_MIN: u16 = 8;
34const LIST_WIDTH_MIN: u16 = 24;
36const PREVIEW_WIDTH_MIN: u16 = 28;
38const PREVIEW_SIDE_MIN: u16 = LIST_WIDTH_MIN + PREVIEW_WIDTH_MIN + 1;
40pub const MIN_TERMINAL_WIDTH: u16 = 60;
41pub const MIN_TERMINAL_HEIGHT: u16 = 16;
42
43pub fn draw(f: &mut Frame, app: &mut App) {
44 let area = f.area();
45 app.areas = crate::app::Areas::default();
48 if let Some(form) = &mut app.form {
49 form.areas = crate::form::FieldAreas::default();
50 form.body_menu_area = None;
51 form.image_hits.clear();
52 if let Some(picker) = &mut form.picker {
53 picker.layout = crate::duepicker::PickerLayout::default();
54 }
55 }
56 if let Some(form) = &mut app.category_form {
57 form.name_area = Rect::ZERO;
58 form.description_area = Rect::ZERO;
59 }
60
61 if area.width < MIN_TERMINAL_WIDTH || area.height < MIN_TERMINAL_HEIGHT {
62 let p = Paragraph::new(format!(
63 "too small · need {MIN_TERMINAL_WIDTH}×{MIN_TERMINAL_HEIGHT}"
64 ))
65 .centered();
66 f.render_widget(p, area);
67 return;
68 }
69
70 let theme = app.theme();
71 let [content, status] =
72 Layout::vertical([Constraint::Min(3), Constraint::Length(3)]).areas(area);
73 let [sidebar, right] =
78 Layout::horizontal([Constraint::Length(SIDEBAR_WIDTH), Constraint::Min(20)])
79 .spacing(1)
80 .areas(content);
81
82 let mut modal_task_form = false;
83 draw_sidebar(f, app, &theme, sidebar);
84 if let Some((list, preview_rect)) =
85 split_tasks_and_preview(right, &app.settings.preview_position)
86 {
87 app.areas.preview = preview_rect;
88 draw_tasks(f, app, &theme, list);
89 match app.mode {
90 Mode::TaskForm => match docked_task_form_layout(preview_rect) {
91 Some(layout) => draw_task_form(f, app, &theme, preview_rect, layout),
92 None => {
93 draw_task_preview(f, app, &theme, preview_rect);
94 modal_task_form = true;
95 }
96 },
97 _ => draw_task_preview(f, app, &theme, preview_rect),
98 }
99 } else {
100 app.areas.preview = Rect::ZERO;
101 draw_tasks(f, app, &theme, right);
102 if app.mode == Mode::TaskForm {
103 modal_task_form = true;
104 }
105 }
106 draw_status(f, app, &theme, status);
107 if app.mode == Mode::Slash {
109 draw_slash_palette(f, app, &theme, status);
110 }
111
112 match app.mode {
113 Mode::Help => draw_help(f, app, &theme, area),
114 Mode::Settings => draw_settings(f, app, &theme, area),
115 Mode::Welcome => draw_welcome(f, &theme, area),
116 Mode::WhatsNew => draw_whats_new(f, &theme, area),
117 Mode::CategoryForm => draw_category_form(f, app, &theme, area),
118 Mode::TaskForm if modal_task_form => {
119 draw_task_form(f, app, &theme, area, TaskFormLayout::Modal);
121 }
122 Mode::TaskForm => {} _ => {}
124 }
125}
126
127fn split_tasks_and_preview(right: Rect, position: &str) -> Option<(Rect, Rect)> {
131 if position == "right"
132 && let Some(pair) = split_preview_right(right)
133 {
134 return Some(pair);
135 }
136 split_preview_bottom(right)
137}
138
139fn split_preview_bottom(right: Rect) -> Option<(Rect, Rect)> {
140 if right.height < PREVIEW_SPLIT_MIN {
141 return None;
142 }
143 let [list, preview] = Layout::vertical([
144 Constraint::Min(LIST_MIN),
145 Constraint::Length((right.height / 2).max(PREVIEW_MIN)),
146 ])
147 .spacing(0)
148 .areas(right);
149 if list.height < LIST_MIN || preview.height < PREVIEW_MIN {
150 return None;
151 }
152 Some((list, preview))
153}
154
155fn split_preview_right(right: Rect) -> Option<(Rect, Rect)> {
156 if right.width < PREVIEW_SIDE_MIN || right.height < PREVIEW_MIN {
157 return None;
158 }
159 let preview_w = (right.width / 2).max(PREVIEW_WIDTH_MIN);
160 let [list, preview] = Layout::horizontal([
161 Constraint::Min(LIST_WIDTH_MIN),
162 Constraint::Length(preview_w),
163 ])
164 .spacing(1)
165 .areas(right);
166 if list.width < LIST_WIDTH_MIN || preview.width < PREVIEW_WIDTH_MIN {
167 return None;
168 }
169 Some((list, preview))
170}
171
172const TASK_FORM_WIDE_CHROME: u16 = 9;
175const TASK_FORM_COMPACT_CHROME: u16 = 15;
176const TASK_FORM_MIN_BODY_HEIGHT: u16 = 3;
177const TASK_FORM_WIDE_MIN_WIDTH: u16 = 56;
178
179#[derive(Clone, Copy, Debug, PartialEq, Eq)]
180enum TaskFormLayout {
181 DockedWide,
182 DockedCompact,
183 Modal,
184}
185
186impl TaskFormLayout {
187 fn is_docked(self) -> bool {
188 !matches!(self, Self::Modal)
189 }
190
191 fn is_compact(self) -> bool {
192 matches!(self, Self::DockedCompact)
193 }
194}
195
196fn docked_task_form_layout(area: Rect) -> Option<TaskFormLayout> {
197 if area.width >= TASK_FORM_WIDE_MIN_WIDTH
198 && area.height >= TASK_FORM_WIDE_CHROME + TASK_FORM_MIN_BODY_HEIGHT
199 {
200 Some(TaskFormLayout::DockedWide)
201 } else if area.width >= PREVIEW_WIDTH_MIN
202 && area.height >= TASK_FORM_COMPACT_CHROME + TASK_FORM_MIN_BODY_HEIGHT
203 {
204 Some(TaskFormLayout::DockedCompact)
205 } else {
206 None
207 }
208}
209
210fn draw_task_form(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect, layout: TaskFormLayout) {
216 let App {
219 form,
220 images: store,
221 ..
222 } = app;
223 let Some(form) = form.as_mut() else { return };
224
225 let rect = if layout.is_docked() {
226 area
227 } else {
228 let width = 92.min(area.width.saturating_sub(4));
229 let body_height = area
230 .height
231 .saturating_sub(TASK_FORM_WIDE_CHROME)
232 .clamp(TASK_FORM_MIN_BODY_HEIGHT, 22);
233 centered(
234 area,
235 width,
236 (TASK_FORM_WIDE_CHROME + body_height).min(area.height),
237 )
238 };
239 let h_pad = if layout.is_docked() { 1 } else { 2 };
240 let block = Block::bordered()
241 .border_type(BorderType::Thick)
242 .border_style(theme.accent_text())
243 .title(Span::styled(
244 format!(" {} ", form.title_text()),
245 theme.accent_text().bold(),
246 ))
247 .padding(Padding::new(h_pad, h_pad, 0, 0));
248 let inner = block.inner(rect);
249 f.render_widget(Clear, rect);
250 f.render_widget(block, rect);
251
252 let (title_box, category_box, due_box, importance_box, body_box, hint) = if layout.is_compact()
253 {
254 let [title, category, due, importance, body, hint] = Layout::vertical([
255 Constraint::Length(3),
256 Constraint::Length(3),
257 Constraint::Length(3),
258 Constraint::Length(3),
259 Constraint::Min(TASK_FORM_MIN_BODY_HEIGHT),
260 Constraint::Length(1),
261 ])
262 .areas(inner);
263 (title, category, due, importance, body, hint)
264 } else {
265 let [title, metadata, body, hint] = Layout::vertical([
266 Constraint::Length(3),
267 Constraint::Length(3),
268 Constraint::Min(TASK_FORM_MIN_BODY_HEIGHT),
269 Constraint::Length(1),
270 ])
271 .areas(inner);
272 let [category, due, importance] = Layout::horizontal([
275 Constraint::Fill(1),
276 Constraint::Length(20),
277 Constraint::Length(9),
278 ])
279 .spacing(1)
280 .areas(metadata);
281 (title, category, due, importance, body, hint)
282 };
283
284 let focused = form.field == Field::Title;
286 let box_inner = render_field_box(f, field_block("Title", focused, None, theme), title_box);
287 form.areas.title = box_inner;
288 let view = form.title.visible(box_inner.width as usize);
289 if view.text.is_empty() {
290 render_or_placeholder(f, box_inner, "", "what needs doing?", theme);
291 } else {
292 f.render_widget(
293 Paragraph::new(line_with_selection(
294 &view.text,
295 view.sel_cols,
296 Style::new(),
297 theme,
298 )),
299 box_inner,
300 );
301 }
302 if focused {
303 f.set_cursor_position((box_inner.x.saturating_add(view.cursor_col), box_inner.y));
304 }
305
306 let focused = form.field == Field::Category;
308 let box_inner = render_field_box(
309 f,
310 field_block("Category", focused, None, theme),
311 category_box,
312 );
313 form.areas.category = box_inner;
314 let category = format!("‹ {} ›", form.category_label());
315 f.render_widget(
316 Paragraph::new(truncate(&category, box_inner.width as usize)),
317 box_inner,
318 );
319
320 let focused = form.field == Field::Due;
324 let box_inner = render_field_box(f, field_block("Due", focused, None, theme), due_box);
325 form.areas.due = due_box;
326 let view = form.due.visible(box_inner.width as usize);
327 render_or_placeholder(f, box_inner, &view.text, "↵ Enter", theme);
328
329 let focused = form.field == Field::Importance;
331 let box_inner = render_field_box(
332 f,
333 field_block("Flags", focused, None, theme),
334 importance_box,
335 );
336 form.areas.importance = box_inner;
337 let marks = crate::model::importance_marks(form.importance);
338 if marks.is_empty() {
339 render_or_placeholder(f, box_inner, "", "→", theme);
340 } else {
341 f.render_widget(
342 Paragraph::new(Line::styled(marks, Style::new().fg(theme.error_color()))),
343 box_inner,
344 );
345 }
346
347 let focused = form.field == Field::Body;
349 let (done, total) = form.body.progress();
350 let progress = (total > 0).then(|| format!("{done}/{total}"));
351 let box_inner = render_field_box(f, field_block("Body", focused, progress, theme), body_box);
352 form.areas.body = box_inner;
353 draw_body(f, form, store, theme, box_inner, focused);
354 scrollbar(
355 f,
356 theme,
357 body_box,
358 form.body.content_height(),
359 box_inner.height as usize,
360 form.body.scroll(),
361 focused,
362 );
363
364 let footer = match &form.error {
366 Some(error) => Line::styled(
367 truncate(error, hint.width as usize),
368 Style::new()
369 .fg(theme.error_color())
370 .add_modifier(Modifier::BOLD),
371 ),
372 None => Line::styled(
373 match layout {
374 TaskFormLayout::DockedWide => "/ commands · Ctrl+Z undo · Ctrl+S save · Esc list",
375 TaskFormLayout::DockedCompact => "/ · Ctrl+S save · Esc list",
376 TaskFormLayout::Modal => "/ commands · Ctrl+Z undo · Ctrl+S save · Esc cancel",
377 },
378 Style::new().fg(theme.muted_color()),
379 ),
380 };
381 f.render_widget(Paragraph::new(footer), hint);
382
383 let overlay = f.area();
386 if let Some(picker) = form.picker.as_mut() {
387 draw_due_picker(f, theme, picker, form.areas.due, overlay);
388 }
389
390 if form.preview
392 && let Some(path) = form
393 .body
394 .selected_image()
395 .or_else(|| form.body.images().first().cloned())
396 {
397 draw_image_preview(f, store, form, theme, &path, overlay);
398 }
399}
400
401fn draw_task_preview(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
403 let focused = false;
404 let block = panel("Task preview", focused, theme);
405 let inner = block.inner(area);
406 f.render_widget(block, area);
407 if inner.height == 0 || inner.width == 0 {
408 return;
409 }
410
411 let Some(task) = app.selected_task().cloned() else {
412 app.invalidate_preview();
413 let style = Style::new().fg(theme.muted_color());
414 draw_box(f, inner, "Select a task · Enter to edit", style);
415 return;
416 };
417
418 let image_paths: Vec<_> = task
421 .body
422 .iter()
423 .filter_map(|block| match block {
424 crate::model::Block::Image { attachment_id } => Some(app.images.resolve(attachment_id)),
425 _ => None,
426 })
427 .collect();
428 let todo = crate::model::todo_progress(&task);
429 let title = task.title;
430 let done = task.done;
431 let due_s = due::display(&task.due, &app.settings.date_format);
432 let importance = task.importance;
433 let body_empty = task.body.is_empty();
434
435 app.images.prefetch(image_paths);
437
438 let flags = crate::model::importance_marks(importance);
439 let mut meta = String::new();
440 if !due_s.is_empty() {
441 meta.push_str(&due_s);
442 }
443 if !flags.is_empty() {
444 if !meta.is_empty() {
445 meta.push_str(" ");
446 }
447 meta.push_str(&flags);
448 }
449 if let Some((d, t)) = todo {
450 if !meta.is_empty() {
451 meta.push_str(" ");
452 }
453 meta.push_str(&format!("{d}/{t}"));
454 }
455
456 let title_style = if done {
457 Style::new()
458 .fg(theme.muted_color())
459 .add_modifier(Modifier::CROSSED_OUT | Modifier::BOLD)
460 } else {
461 Style::new().add_modifier(Modifier::BOLD)
462 };
463
464 let (title_row, meta_row, body_area) = if meta.is_empty() {
465 let [t, b] = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(inner);
466 (t, None, b)
467 } else {
468 let [t, m, b] = Layout::vertical([
469 Constraint::Length(1),
470 Constraint::Length(1),
471 Constraint::Min(1),
472 ])
473 .areas(inner);
474 (t, Some(m), b)
475 };
476
477 f.render_widget(
478 Paragraph::new(Line::styled(
479 truncate(&title, title_row.width as usize),
480 title_style,
481 )),
482 title_row,
483 );
484 if let Some(meta_row) = meta_row {
485 f.render_widget(
486 Paragraph::new(Line::styled(
487 truncate(&meta, meta_row.width as usize),
488 Style::new().fg(theme.muted_color()),
489 )),
490 meta_row,
491 );
492 }
493
494 if body_area.height == 0 {
495 return;
496 }
497 if body_empty {
498 f.render_widget(
499 Paragraph::new(Line::styled(
500 "Enter to edit",
501 Style::new().fg(theme.muted_color()),
502 )),
503 body_area,
504 );
505 return;
506 }
507
508 app.ensure_preview();
509 let App {
510 images: store,
511 preview_form,
512 ..
513 } = app;
514 if let Some(paint) = preview_form.as_mut() {
515 draw_body(f, paint, store, theme, body_area, false);
516 if paint.body.content_height() > usize::from(body_area.height) && body_area.height > 0 {
517 let indicator = Rect {
518 y: body_area.bottom() - 1,
519 height: 1,
520 ..body_area
521 };
522 f.render_widget(
523 Paragraph::new(Line::styled(
524 "↓ more · Enter to edit",
525 Style::new()
526 .fg(theme.muted_color())
527 .add_modifier(Modifier::BOLD),
528 )),
529 indicator,
530 );
531 }
532 }
533}
534
535fn field_block<'a>(
537 label: &'a str,
538 focused: bool,
539 note: Option<String>,
540 theme: &Theme,
541) -> Block<'a> {
542 let (border, label_style) = if focused {
544 (theme.accent_text(), theme.accent_text().bold())
545 } else {
546 (
547 Style::new().fg(theme.muted_color()),
548 Style::new()
549 .fg(theme.muted_color())
550 .add_modifier(Modifier::BOLD),
551 )
552 };
553 let mut block = Block::bordered()
554 .border_type(BorderType::Thick)
555 .border_style(border)
556 .title(Span::styled(format!(" {label} "), label_style))
557 .padding(Padding::horizontal(1));
558 if let Some(note) = note {
559 block = block.title_top(
560 Line::styled(format!(" {note} "), Style::new().fg(theme.muted_color())).right_aligned(),
561 );
562 }
563 block
564}
565
566fn draw_category_form(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
569 let Some(form) = &mut app.category_form else {
570 return;
571 };
572 const CHROME: u16 = 6;
574 let text_height = area.height.saturating_sub(CHROME).clamp(3, 12);
575 let width = 72.min(area.width.saturating_sub(4));
576 let rect = centered(area, width, (CHROME + text_height).min(area.height));
577
578 let block = Block::bordered()
579 .border_type(BorderType::Thick)
580 .border_style(theme.accent_text())
581 .title(Span::styled(
582 format!(" {} ", form.title_text()),
583 theme.accent_text().bold(),
584 ))
585 .padding(Padding::horizontal(1));
586 let inner = block.inner(rect);
587 f.render_widget(Clear, rect);
588 f.render_widget(block, rect);
589
590 let [name_box, text_box, hint] = Layout::vertical([
591 Constraint::Length(3),
592 Constraint::Length(text_height),
593 Constraint::Length(1),
594 ])
595 .areas(inner);
596
597 let focused = !form.on_description;
598 let box_inner = render_field_box(f, field_block("Name", focused, None, theme), name_box);
599 form.name_area = box_inner;
600 let view = form.name.visible(box_inner.width as usize);
601 if view.text.is_empty() {
602 render_or_placeholder(f, box_inner, "", "What to call it", theme);
603 } else {
604 f.render_widget(
605 Paragraph::new(line_with_selection(
606 &view.text,
607 view.sel_cols,
608 Style::new(),
609 theme,
610 )),
611 box_inner,
612 );
613 }
614 if focused {
615 f.set_cursor_position((box_inner.x.saturating_add(view.cursor_col), box_inner.y));
616 }
617
618 let focused = form.on_description;
619 let box_inner = render_field_box(
620 f,
621 field_block("Description", focused, None, theme),
622 text_box,
623 );
624 form.description_area = box_inner;
625 let (lines, cursor) = form
626 .description
627 .layout(box_inner.width as usize, box_inner.height);
628 if form.description.is_empty() && form.description.menu.is_none() {
629 render_or_placeholder(f, box_inner, "", "Press / for commands", theme);
630 }
631 for placed in lines {
632 if matches!(placed.block, crate::body::Painted::Text { .. }) {
633 draw_placed_text(f, theme, box_inner, &placed);
634 }
635 }
636 if let (true, Some((row, col))) = (focused, cursor) {
637 f.set_cursor_position((
638 box_inner.x.saturating_add(col),
639 box_inner.y.saturating_add(row),
640 ));
641 }
642 if focused {
643 draw_slash_menu(f, &form.description, theme, box_inner, cursor);
644 }
645 scrollbar(
646 f,
647 theme,
648 text_box,
649 form.description.content_height(),
650 box_inner.height as usize,
651 form.description.scroll(),
652 focused,
653 );
654
655 let footer = match &form.error {
656 Some(error) => Line::styled(
657 truncate(error, hint.width as usize),
658 Style::new()
659 .fg(theme.error_color())
660 .add_modifier(Modifier::BOLD),
661 ),
662 None => Line::styled(
663 "/ commands · Ctrl+Z undo · Ctrl+S save · Esc cancel",
664 Style::new().fg(theme.muted_color()),
665 ),
666 };
667 f.render_widget(Paragraph::new(footer), hint);
668}
669
670fn draw_body(
672 f: &mut Frame,
673 form: &mut crate::form::TaskForm,
674 store: &mut crate::image::ImageStore,
675 theme: &Theme,
676 area: Rect,
677 focused: bool,
678) {
679 let menu_open = form.body.menu.is_some();
680 if form.body.is_empty() && form.body.menu.is_none() {
681 render_or_placeholder(f, area, "", "Press / for commands", theme);
682 }
683 let (blocks, cursor) = form.body.layout(area.width as usize, area.height);
684 let scroll = form.body.scroll();
685 if (form.menu_was_open && !menu_open) || form.body_scroll != scroll {
689 store.clear_cache();
690 f.render_widget(Clear, area);
691 }
692 form.menu_was_open = menu_open;
693 form.body_scroll = scroll;
694 let menu_rect = slash_menu_rect(&form.body, area, cursor);
698 form.body_menu_area = menu_rect;
699 form.image_hits.clear();
700 for placed in blocks {
701 match &placed.block {
702 crate::body::Painted::Image(path) => {
703 let row = Rect {
704 y: area.y.saturating_add(placed.y),
705 height: placed.rows,
706 ..area
707 };
708 let covered = menu_rect.is_some_and(|m| rects_overlap(m, row));
709 let show_frame = focused && placed.selected;
713 if covered {
714 f.render_widget(Clear, row);
715 let hit = letterbox_rect(row, 4, 3);
716 draw_image_placeholder(f, theme, hit, show_frame);
717 form.image_hits.push((placed.line, hit));
718 } else if let Some(hit) = draw_image(f, store, theme, path, row, show_frame) {
719 form.image_hits.push((placed.line, hit));
720 }
721 }
722 crate::body::Painted::Text { .. } => {
723 draw_placed_text(f, theme, area, &placed);
724 }
725 }
726 }
727 if focused && let Some((row, col)) = cursor {
728 f.set_cursor_position((area.x.saturating_add(col), area.y.saturating_add(row)));
729 }
730
731 draw_slash_menu(f, &form.body, theme, area, cursor);
732}
733
734fn rects_overlap(a: Rect, b: Rect) -> bool {
735 a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom()
736}
737
738fn slash_menu_rect(
740 body: &crate::body::BodyEditor,
741 area: Rect,
742 cursor: Option<(u16, u16)>,
743) -> Option<Rect> {
744 body.menu.as_ref()?;
745 let commands = body.menu_commands();
746 if commands.is_empty() {
747 return None;
748 }
749 let width = 48.min(area.width);
750 let height = u16::try_from(commands.len())
751 .unwrap_or(u16::MAX)
752 .saturating_add(2);
753 let cursor_row = cursor.map(|(row, _)| row).unwrap_or(0);
754 let below = area.y.saturating_add(cursor_row).saturating_add(1);
755 let y = if area.bottom().saturating_sub(below) >= height {
756 below
757 } else {
758 area.y.saturating_add(cursor_row).saturating_sub(height)
759 };
760 Some(Rect {
761 x: area.x.saturating_add(
762 cursor
763 .map(|(_, col)| col)
764 .unwrap_or(0)
765 .min(area.width.saturating_sub(width)),
766 ),
767 y,
768 width,
769 height,
770 })
771}
772
773fn draw_placed_text(f: &mut Frame, theme: &Theme, area: Rect, placed: &crate::body::Placed) {
775 let crate::body::Painted::Text { rows, kind } = &placed.block else {
776 return;
777 };
778 let indent = kind.indent();
779 let max_rows = placed.rows as usize;
780 for (i, wr) in rows.iter().enumerate().take(max_rows) {
781 let y = area
782 .y
783 .saturating_add(placed.y)
784 .saturating_add(u16::try_from(i).unwrap_or(u16::MAX));
785 if y >= area.bottom() {
786 break;
787 }
788 let row = Rect {
789 x: area.x,
790 y,
791 width: area.width,
792 height: 1,
793 };
794 let base = match kind {
795 crate::body::TextKind::Link => Style::new()
796 .fg(theme.accent)
797 .add_modifier(Modifier::UNDERLINED),
798 crate::body::TextKind::Todo { done: true } => Style::new()
799 .fg(theme.muted_color())
800 .add_modifier(Modifier::CROSSED_OUT),
801 _ => Style::new(),
802 };
803 let body = line_with_selection(&wr.text, wr.sel, base, theme);
804 let line = if i == 0 {
805 match kind {
806 crate::body::TextKind::Todo { done: true } => Line::from(
807 [
808 vec![Span::styled("[✓] ", Style::new().fg(theme.success_color()))],
809 body.spans,
810 ]
811 .concat(),
812 ),
813 crate::body::TextKind::Todo { done: false } => Line::from(
814 [
815 vec![Span::styled("[ ] ", Style::new().fg(theme.muted_color()))],
816 body.spans,
817 ]
818 .concat(),
819 ),
820 crate::body::TextKind::Bullet => Line::from(
821 [
822 vec![Span::styled("• ", Style::new().fg(theme.muted_color()))],
823 body.spans,
824 ]
825 .concat(),
826 ),
827 crate::body::TextKind::Number(n) => Line::from(
828 [
829 vec![Span::styled(
830 format!("{n}. "),
831 Style::new().fg(theme.muted_color()),
832 )],
833 body.spans,
834 ]
835 .concat(),
836 ),
837 crate::body::TextKind::Link => Line::from(
838 [
839 vec![Span::styled("↗ ", Style::new().fg(theme.muted_color()))],
840 body.spans,
841 ]
842 .concat(),
843 ),
844 crate::body::TextKind::Plain => body,
845 }
846 } else if indent > 0 {
847 Line::from([vec![Span::raw(" ".repeat(indent))], body.spans].concat())
849 } else {
850 body
851 };
852 f.render_widget(Paragraph::new(line), row);
853 }
854}
855
856fn draw_image_placeholder(f: &mut Frame, theme: &Theme, area: Rect, selected: bool) {
858 if area.width == 0 || area.height == 0 {
859 return;
860 }
861 let rect = Rect { height: 1, ..area };
862 let style = if selected {
863 theme.accent_text()
864 } else {
865 Style::new().fg(theme.muted_color())
866 };
867 f.render_widget(Clear, rect);
868 f.render_widget(Paragraph::new(Line::styled(" [image] ", style)), rect);
869}
870
871enum ImageSlotKind<'a> {
873 Loading,
874 Broken { detail: &'a str },
875}
876
877fn letterbox_rect(area: Rect, aspect_w: u16, aspect_h: u16) -> Rect {
881 if area.width < 3 || area.height < 3 {
882 return area;
883 }
884 let inner = area.inner(Margin {
885 horizontal: 1,
886 vertical: 1,
887 });
888 let aw = u32::from(aspect_w.max(1));
889 let ah = u32::from(aspect_h.max(1));
890 let iw = u32::from(inner.width);
891 let ih = u32::from(inner.height);
892 let (pw, ph) = if iw * ah <= ih * aw {
893 let pw = iw;
894 let ph = (iw * ah / aw).clamp(1, ih);
895 (pw as u16, ph as u16)
896 } else {
897 let ph = ih;
898 let pw = (ih * aw / ah).clamp(1, iw);
899 (pw as u16, ph as u16)
900 };
901 centered(inner, pw, ph)
902}
903
904fn preview_slot_area(inner: Rect) -> Rect {
906 Rect {
907 x: inner.x.saturating_sub(1),
908 y: inner.y.saturating_sub(1),
909 width: inner.width.saturating_add(2),
910 height: inner.height.saturating_add(2),
911 }
912}
913
914fn draw_image_slot(
915 f: &mut Frame,
916 theme: &Theme,
917 area: Rect,
918 kind: ImageSlotKind<'_>,
919 selected: bool,
920) {
921 if area.width < 3 || area.height < 2 {
922 return;
923 }
924 let border = if selected {
925 theme.accent_text()
926 } else {
927 Style::new().fg(theme.muted_color())
928 };
929 let (icon, title, title_style, detail) = match kind {
930 ImageSlotKind::Loading => ("▢", "loading", Style::new().fg(theme.muted_color()), None),
931 ImageSlotKind::Broken { detail } => (
932 "✕",
933 "broken image",
934 Style::new().fg(theme.error_color()),
935 Some(detail),
936 ),
937 };
938 let block = Block::bordered()
939 .border_type(BorderType::Rounded)
940 .border_style(border);
941 let inner = block.inner(area);
942 f.render_widget(Clear, area);
943 f.render_widget(block, area);
944 if inner.width == 0 || inner.height == 0 {
945 return;
946 }
947
948 let mut lines: Vec<Line> = Vec::new();
949 let content_rows: u16 = if detail.is_some() { 3 } else { 2 };
951 let pad = inner.height.saturating_sub(content_rows) / 2;
952 for _ in 0..pad {
953 lines.push(Line::raw(""));
954 }
955 lines.push(
956 Line::from(Span::styled(
957 truncate(icon, inner.width as usize),
958 title_style,
959 ))
960 .centered(),
961 );
962 lines.push(
963 Line::from(Span::styled(
964 truncate(title, inner.width as usize),
965 title_style,
966 ))
967 .centered(),
968 );
969 if let Some(d) = detail {
970 let d = d.trim();
971 if !d.is_empty() {
972 lines.push(
973 Line::from(Span::styled(
974 truncate(d, inner.width as usize),
975 Style::new().fg(theme.muted_color()),
976 ))
977 .centered(),
978 );
979 }
980 }
981 f.render_widget(Paragraph::new(lines), inner);
982}
983
984fn draw_slash_menu(
986 f: &mut Frame,
987 body: &crate::body::BodyEditor,
988 theme: &Theme,
989 area: Rect,
990 cursor: Option<(u16, u16)>,
991) {
992 let Some(menu) = &body.menu else { return };
993 let Some(rect) = slash_menu_rect(body, area, cursor) else {
994 return;
995 };
996 let commands = body.menu_commands();
997 let row_width = rect.width.saturating_sub(2) as usize;
999 let lines: Vec<Line> = commands
1000 .iter()
1001 .enumerate()
1002 .map(|(i, command)| {
1003 let selected = i == menu.index.min(commands.len() - 1);
1004 dropdown_row(
1005 theme,
1006 selected,
1007 &format!("{:<14}", command.label()),
1008 command.hint(),
1009 row_width,
1010 )
1011 })
1012 .collect();
1013 let block = Block::bordered()
1014 .border_type(BorderType::Thick)
1015 .border_style(theme.accent_text())
1016 .title(Span::styled(
1017 format!(" /{} ", menu.query),
1018 Style::new().fg(theme.muted_color()),
1019 ));
1020 f.render_widget(Clear, rect);
1021 f.render_widget(Paragraph::new(lines).block(block), rect);
1022}
1023
1024fn draw_due_picker(
1027 f: &mut Frame,
1028 theme: &Theme,
1029 picker: &mut crate::duepicker::DuePicker,
1030 field: Rect,
1031 area: Rect,
1032) {
1033 use crate::duepicker::{PickerFocus, PickerLayout};
1034
1035 let Some(day) = crate::duepicker::to_time_date(picker.day) else {
1036 return;
1037 };
1038 let mut events = ratatui::widgets::calendar::CalendarEventStore::today(
1039 Style::new().fg(theme.success_color()),
1040 );
1041 events.add(day, theme.selection().add_modifier(Modifier::UNDERLINED));
1043
1044 const CAL_COLS: u16 = 21;
1048 let width = (CAL_COLS + 2).max(field.width).min(area.width);
1049 let height = 13;
1051 let below = field.bottom(); let rect = Rect {
1053 x: field.x.min(area.right().saturating_sub(width)),
1055 y: if area.bottom().saturating_sub(below) >= height {
1056 below
1057 } else {
1058 field.y.saturating_sub(height)
1059 },
1060 width,
1061 height,
1062 };
1063 let block = Block::bordered()
1064 .border_type(BorderType::Thick)
1065 .border_style(theme.accent_text())
1066 .title_bottom(
1067 Line::styled(" Tab · clear(x) ", Style::new().fg(theme.muted_color())).left_aligned(),
1068 );
1069 f.render_widget(Clear, rect);
1070 let inner = block.inner(rect);
1071 f.render_widget(block, rect);
1072
1073 let [cal_area, _gap, time_area] = Layout::vertical([
1075 Constraint::Length(8),
1076 Constraint::Length(1),
1077 Constraint::Length(1),
1078 ])
1079 .areas(inner);
1080 let cal_area = Rect {
1081 width: CAL_COLS.min(cal_area.width),
1082 ..cal_area
1083 };
1084 let time_area = Rect {
1085 width: CAL_COLS.min(time_area.width),
1086 ..time_area
1087 };
1088
1089 let days = Rect {
1091 x: cal_area.x,
1092 y: cal_area.y.saturating_add(2),
1093 width: cal_area.width,
1094 height: cal_area.height.saturating_sub(2),
1095 };
1096
1097 let calendar = ratatui::widgets::calendar::Monthly::new(day, events)
1098 .show_month_header(theme.accent_text().add_modifier(Modifier::BOLD))
1099 .show_weekdays_header(Style::new().fg(theme.muted_color()))
1100 .show_surrounding(
1101 Style::new()
1102 .fg(theme.muted_color())
1103 .add_modifier(Modifier::DIM),
1104 );
1105 f.render_widget(calendar, cal_area);
1106
1107 let hour = format!("{:02}", picker.hour);
1109 let minute = format!("{:02}", picker.minute);
1110 let unit = |label: &str, on: bool| {
1111 if on {
1112 Span::styled(
1113 label.to_string(),
1114 theme.selection().add_modifier(Modifier::UNDERLINED),
1115 )
1116 } else {
1117 Span::styled(label.to_string(), Style::new())
1118 }
1119 };
1120 let time_line = Line::from(vec![
1121 unit(&hour, picker.focus == PickerFocus::Hour),
1122 Span::styled(":", Style::new().fg(theme.muted_color())),
1123 unit(&minute, picker.focus == PickerFocus::Minute),
1124 ])
1125 .centered();
1126 f.render_widget(Paragraph::new(time_line), time_area);
1127
1128 let clock_w = 5u16;
1130 let clock_x = time_area
1131 .x
1132 .saturating_add(time_area.width.saturating_sub(clock_w) / 2);
1133 picker.layout = PickerLayout {
1134 frame: rect,
1135 days,
1136 hour: Rect {
1137 x: clock_x,
1138 y: time_area.y,
1139 width: 2,
1140 height: 1,
1141 },
1142 minute: Rect {
1143 x: clock_x.saturating_add(3),
1144 y: time_area.y,
1145 width: 2,
1146 height: 1,
1147 },
1148 time_row: time_area,
1149 };
1150}
1151
1152fn draw_image_preview(
1154 f: &mut Frame,
1155 store: &mut crate::image::ImageStore,
1156 form: &mut crate::form::TaskForm,
1157 theme: &Theme,
1158 path: &std::path::Path,
1159 area: Rect,
1160) {
1161 let rect = centered(
1162 area,
1163 (u32::from(area.width) * 9 / 10) as u16,
1164 (u32::from(area.height) * 9 / 10) as u16,
1165 );
1166 let title = truncate(
1167 &path.file_name().unwrap_or_default().to_string_lossy(),
1168 rect.width.saturating_sub(10) as usize,
1169 );
1170 let kind = crate::image::type_label(path);
1171 let anim_note = form
1172 .gif
1173 .as_ref()
1174 .map(|(_, g)| g)
1175 .filter(|g| g.is_animated())
1176 .map(|g| format!(" · {}/{}", g.frame_number(), g.frame_count()))
1177 .unwrap_or_default();
1178 let block = Block::bordered()
1179 .border_type(BorderType::Thick)
1180 .border_style(theme.accent_text())
1181 .title(Span::styled(
1182 format!(" {title} "),
1183 theme.accent_text().bold(),
1184 ))
1185 .title_top(
1186 Line::styled(
1187 format!(" {kind}{anim_note} "),
1188 Style::new().fg(theme.muted_color()),
1189 )
1190 .right_aligned(),
1191 )
1192 .title_bottom(
1193 Line::styled(
1194 match form.gif.as_ref().map(|(_, g)| g) {
1195 Some(g) if g.is_animated() && g.is_paused() => {
1196 " Esc closes · click/space resume "
1197 }
1198 Some(g) if g.is_animated() => " Esc closes · click/space pause ",
1199 _ => " Esc closes ",
1200 },
1201 Style::new().fg(theme.muted_color()),
1202 )
1203 .right_aligned(),
1204 );
1205 let inner = block.inner(rect);
1206 f.render_widget(Clear, rect);
1207 f.render_widget(block, rect);
1208
1209 if let Some((_, gif)) = form.gif.as_ref() {
1211 match store.preview_frame(gif) {
1212 Ok(protocol) => {
1213 let _ = render_protocol(f, protocol, inner, theme, None);
1214 }
1215 Err(err) => {
1216 let slot = letterbox_rect(preview_slot_area(inner), 4, 3);
1217 draw_image_slot(
1218 f,
1219 theme,
1220 slot,
1221 ImageSlotKind::Broken { detail: &err },
1222 false,
1223 );
1224 }
1225 }
1226 } else {
1227 match store.get_preview(path) {
1228 crate::image::ImageReady::Ready(protocol) => {
1229 let _ = render_protocol(f, protocol, inner, theme, None);
1230 }
1231 crate::image::ImageReady::Loading => {
1232 let slot = letterbox_rect(preview_slot_area(inner), 4, 3);
1234 draw_image_slot(f, theme, slot, ImageSlotKind::Loading, false);
1235 }
1236 crate::image::ImageReady::Failed(err) => {
1237 let slot = letterbox_rect(preview_slot_area(inner), 4, 3);
1238 draw_image_slot(
1239 f,
1240 theme,
1241 slot,
1242 ImageSlotKind::Broken { detail: &err },
1243 false,
1244 );
1245 }
1246 }
1247 }
1248}
1249
1250fn draw_image(
1253 f: &mut Frame,
1254 store: &mut crate::image::ImageStore,
1255 theme: &Theme,
1256 path: &std::path::Path,
1257 area: Rect,
1258 selected: bool,
1259) -> Option<Rect> {
1260 if area.width < 3 || area.height < 3 {
1261 return None;
1262 }
1263 let inner = area.inner(Margin {
1266 horizontal: 1,
1267 vertical: 1,
1268 });
1269 match store.get(path) {
1270 crate::image::ImageReady::Ready(protocol) => Some(render_protocol(
1271 f,
1272 protocol,
1273 inner,
1274 theme,
1275 selected.then_some(path),
1276 )),
1277 crate::image::ImageReady::Loading => {
1278 let slot = letterbox_rect(area, 4, 3);
1280 draw_image_slot(f, theme, slot, ImageSlotKind::Loading, selected);
1281 Some(slot)
1282 }
1283 crate::image::ImageReady::Failed(err) => {
1284 let name = path
1285 .file_name()
1286 .and_then(|n| n.to_str())
1287 .unwrap_or(err.as_str());
1288 let slot = letterbox_rect(area, 4, 3);
1289 draw_image_slot(
1290 f,
1291 theme,
1292 slot,
1293 ImageSlotKind::Broken { detail: name },
1294 selected,
1295 );
1296 Some(slot)
1297 }
1298 }
1299}
1300
1301fn render_protocol(
1304 f: &mut Frame,
1305 protocol: &mut ratatui_image::protocol::StatefulProtocol,
1306 inner: Rect,
1307 theme: &Theme,
1308 frame: Option<&std::path::Path>,
1309) -> Rect {
1310 let size = protocol.size_for(Resize::Scale(None), inner.as_size());
1314 let picture = centered(
1315 inner,
1316 size.width.min(inner.width),
1317 size.height.min(inner.height),
1318 );
1319 f.render_stateful_widget(
1320 StatefulImage::default().resize(Resize::Scale(None)),
1321 picture,
1322 protocol,
1323 );
1324 let hit = if let Some(path) = frame {
1325 let border = Rect {
1326 x: picture.x.saturating_sub(1),
1327 y: picture.y.saturating_sub(1),
1328 width: picture.width.saturating_add(2),
1329 height: picture.height.saturating_add(2),
1330 };
1331 let kind = crate::image::type_label(path);
1332 f.render_widget(
1333 Block::bordered()
1334 .border_type(BorderType::Thick)
1335 .border_style(theme.accent_text())
1336 .title_top(
1337 Line::styled(format!(" {kind} "), Style::new().fg(theme.muted_color()))
1338 .right_aligned(),
1339 ),
1340 border,
1341 );
1342 border
1343 } else {
1344 picture
1345 };
1346 if let Some(Err(err)) = protocol.last_encoding_result() {
1347 let line = Line::styled(
1348 truncate(&format!("image: {err}"), inner.width as usize),
1349 Style::new().fg(theme.error_color()),
1350 );
1351 f.render_widget(Paragraph::new(line), inner);
1352 }
1353 hit
1354}
1355
1356fn render_field_box(f: &mut Frame, block: Block, area: Rect) -> Rect {
1357 let inner = block.inner(area);
1358 f.render_widget(block, area);
1359 inner
1360}
1361
1362fn line_with_selection(
1365 text: &str,
1366 sel: Option<(u16, u16)>,
1367 base: Style,
1368 theme: &Theme,
1369) -> Line<'static> {
1370 let Some((a, b)) = sel else {
1371 return Line::from(Span::styled(text.to_string(), base));
1372 };
1373 let a = a as usize;
1374 let b = b as usize;
1375 if a >= b {
1376 return Line::from(Span::styled(text.to_string(), base));
1377 }
1378 let sel_style = theme.selection();
1379 let mut spans = Vec::new();
1380 let mut col = 0usize;
1381 let mut chunk = String::new();
1382 let mut chunk_in_sel = false;
1383 let flush = |spans: &mut Vec<Span<'static>>, chunk: &mut String, in_sel: bool| {
1384 if chunk.is_empty() {
1385 return;
1386 }
1387 let style = if in_sel { sel_style } else { base };
1388 spans.push(Span::styled(std::mem::take(chunk), style));
1389 };
1390 for grapheme in text.graphemes(true) {
1391 let w = grapheme.width();
1392 let in_sel = col >= a && col < b;
1393 if !chunk.is_empty() && in_sel != chunk_in_sel {
1394 flush(&mut spans, &mut chunk, chunk_in_sel);
1395 }
1396 chunk_in_sel = in_sel;
1397 chunk.push_str(grapheme);
1398 col += w;
1399 }
1400 flush(&mut spans, &mut chunk, chunk_in_sel);
1401 Line::from(spans)
1402}
1403
1404fn render_or_placeholder(f: &mut Frame, area: Rect, text: &str, placeholder: &str, theme: &Theme) {
1405 let line = if text.is_empty() {
1406 Line::styled(
1407 truncate(placeholder, area.width as usize),
1408 Style::new()
1409 .fg(theme.muted_color())
1410 .add_modifier(Modifier::DIM),
1411 )
1412 } else {
1413 Line::raw(text.to_string())
1414 };
1415 f.render_widget(Paragraph::new(line), area);
1416}
1417
1418fn panel<'a>(title: &'a str, focused: bool, theme: &Theme) -> Block<'a> {
1421 let (border, title_style) = if focused {
1422 (theme.accent_text(), theme.accent_text().bold())
1423 } else {
1424 (
1425 Style::new().fg(theme.muted_color()),
1426 Style::new()
1427 .fg(theme.muted_color())
1428 .add_modifier(Modifier::BOLD),
1429 )
1430 };
1431 Block::bordered()
1432 .border_type(BorderType::Thick)
1433 .border_style(border)
1434 .title(Span::styled(format!(" {title} "), title_style))
1435 .padding(Padding::horizontal(1))
1436}
1437
1438fn scrollbar(
1440 f: &mut Frame,
1441 theme: &Theme,
1442 area: Rect,
1443 total: usize,
1444 visible: usize,
1445 offset: usize,
1446 focused: bool,
1447) {
1448 paint_scrollbar(f, theme, area, total, visible, offset, focused, 1);
1449}
1450
1451#[allow(clippy::too_many_arguments)]
1452fn paint_scrollbar(
1453 f: &mut Frame,
1454 theme: &Theme,
1455 area: Rect,
1456 total: usize,
1457 visible: usize,
1458 offset: usize,
1459 focused: bool,
1460 vertical_margin: u16,
1461) {
1462 let max_offset = total.saturating_sub(visible);
1467 if max_offset == 0 || area.height <= vertical_margin.saturating_mul(2) {
1468 return;
1469 }
1470 let mut state = ScrollbarState::new(max_offset + 1).position(offset.min(max_offset));
1471 let style = if focused {
1472 theme.accent_text()
1473 } else {
1474 Style::new().fg(theme.muted_color())
1475 };
1476 f.render_stateful_widget(
1477 Scrollbar::new(ScrollbarOrientation::VerticalRight)
1478 .symbols(ratatui::symbols::scrollbar::VERTICAL)
1479 .begin_symbol(None)
1480 .end_symbol(None)
1481 .thumb_style(style)
1482 .track_style(style),
1483 area.inner(Margin {
1484 horizontal: 0,
1485 vertical: vertical_margin,
1486 }),
1487 &mut state,
1488 );
1489}
1490
1491fn draw_sidebar(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
1494 let focused = app.focus == Focus::Sidebar;
1495 let chrome_focus = focused && !app.mode.command_bar_focused();
1496 let block = panel("Categories", chrome_focus, theme);
1497 let inner = block.inner(area);
1498 app.areas.sidebar = inner;
1499 if inner.height == 0 || inner.width == 0 {
1500 f.render_widget(block, area);
1501 return;
1502 }
1503
1504 let width = inner.width as usize;
1505 let scores: Vec<String> = app
1507 .categories
1508 .iter()
1509 .map(|cat| {
1510 let (done, total) = app.category_progress(&cat.id);
1511 format!("{done}/{total}")
1512 })
1513 .collect();
1514 let count_width = scores.iter().map(|s| s.width()).max().unwrap_or(3).max(3);
1515 let name_field = width.saturating_sub(count_width + 1);
1516 let items: Vec<ListItem> = app
1517 .categories
1518 .iter()
1519 .zip(scores.iter())
1520 .map(|(cat, score)| {
1521 let count = format!("{score:>count_width$}");
1522 let name = truncate(&cat.name, name_field);
1523 let pad = " ".repeat(width.saturating_sub(name.width() + count.width()));
1524 ListItem::new(Line::from(vec![
1525 Span::raw(name),
1526 Span::raw(pad),
1527 Span::styled(count, Style::new().fg(theme.muted_color())),
1528 ]))
1529 })
1530 .collect();
1531 let rows = items.len();
1532
1533 app.cat_state.select(Some(app.cat_index));
1534 let list = List::new(items).block(block).highlight_style(if focused {
1535 theme.selection()
1536 } else {
1537 theme.selection_unfocused()
1538 });
1539 f.render_stateful_widget(list, area, &mut app.cat_state);
1540 scrollbar(
1541 f,
1542 theme,
1543 area,
1544 rows,
1545 inner.height as usize,
1546 app.cat_state.offset(),
1547 chrome_focus,
1548 );
1549}
1550
1551fn draw_tasks(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
1554 let focused = app.focus == Focus::Tasks;
1555 let chrome_focus = focused && app.mode != Mode::TaskForm && !app.mode.command_bar_focused();
1558 let mut block = panel("Tasks", chrome_focus, theme);
1561 if app.searching {
1563 let context = format!(" search: {} · {} found ", app.search_query, app.view.len());
1564 block = block
1565 .title_top(Line::styled(context, Style::new().fg(theme.muted_color())).right_aligned());
1566 }
1567 let inner = block.inner(area);
1568 app.areas.tasks = inner;
1569 if inner.height == 0 || inner.width == 0 {
1570 f.render_widget(block, area);
1571 return;
1572 }
1573
1574 if app.view.is_empty() {
1575 f.render_widget(block, area);
1576 let text = if app.searching {
1577 banner::NO_SEARCH_RESULTS
1578 } else {
1579 banner::EMPTY_TASKS
1580 };
1581 let style = if chrome_focus {
1582 theme.accent_text()
1583 } else {
1584 Style::new().fg(theme.muted_color())
1585 };
1586 draw_box(f, inner, text, style);
1587 return;
1588 }
1589
1590 let flags_width = crate::model::MAX_IMPORTANCE as usize;
1595 let presentations: Vec<_> = app
1596 .view
1597 .iter()
1598 .map(|task_index| TaskPresentation::new(&app.tasks[*task_index], &app.settings.date_format))
1599 .collect();
1600
1601 const TITLE_MIN: usize = 8;
1605 let available = inner.width as usize;
1606 let flags_visible = DONE_MARK_WIDTH as usize + 1 + TITLE_MIN + 1 + flags_width <= available;
1607 let mut widths = vec![
1608 Constraint::Length(DONE_MARK_WIDTH), Constraint::Fill(1), ];
1611 if flags_visible {
1612 widths.push(Constraint::Length(flags_width as u16));
1613 }
1614 let column_gaps = widths.len().saturating_sub(1);
1615 let content_width = available
1616 .saturating_sub(DONE_MARK_WIDTH as usize)
1617 .saturating_sub(column_gaps)
1618 .saturating_sub(if flags_visible { flags_width } else { 0 });
1619 let mut presentations = presentations.into_iter().enumerate();
1620 let rows: Vec<Row> = app
1621 .list_rows
1622 .iter()
1623 .map(|row| match row {
1624 crate::app::TaskListRow::Separator { .. } => {
1627 Row::new(std::iter::repeat_n(Cell::new(""), widths.len()))
1628 }
1629 crate::app::TaskListRow::Task(view_idx) => {
1630 let (presentation_index, presentation) = presentations
1631 .next()
1632 .expect("task rows and task presentations must stay aligned");
1633 debug_assert_eq!(*view_idx, presentation_index);
1634 task_row(
1635 presentation,
1636 theme,
1637 *view_idx == app.task_index,
1638 content_width,
1639 flags_visible,
1640 )
1641 }
1642 })
1643 .collect();
1644 debug_assert!(presentations.next().is_none());
1645 let table = Table::new(rows, widths)
1647 .block(block)
1648 .column_spacing(1)
1649 .row_highlight_style(if chrome_focus {
1650 theme.selection()
1651 } else {
1652 theme.selection_unfocused()
1653 });
1654
1655 app.areas.done_x = Some(inner.x);
1658 app.areas.flag_x = flags_visible.then_some(inner.right().saturating_sub(flags_width as u16));
1659
1660 let vis = app.selected_visual_row();
1661 app.task_state.select(vis);
1662 if let Some(vis) = vis {
1666 pin_section_header(app, vis);
1667 }
1668 f.render_stateful_widget(table, area, &mut app.task_state);
1669
1670 let offset = app.task_state.offset();
1672 let rule_style = Style::new().fg(theme.muted_color());
1673 for (vis_i, row) in app.list_rows.iter().enumerate().skip(offset) {
1674 let y = inner
1675 .y
1676 .saturating_add(u16::try_from(vis_i - offset).unwrap_or(u16::MAX));
1677 if y >= inner.bottom() {
1678 break;
1679 }
1680 let crate::app::TaskListRow::Separator { title } = row else {
1681 continue;
1682 };
1683 let title_x = (DONE_MARK_WIDTH + 1) as usize;
1685 let line = category_rule(title, inner.width as usize, title_x);
1686 f.render_widget(
1687 Paragraph::new(Span::styled(line, rule_style)),
1688 Rect {
1689 x: inner.x,
1690 y,
1691 width: inner.width,
1692 height: 1,
1693 },
1694 );
1695 }
1696
1697 scrollbar(
1698 f,
1699 theme,
1700 area,
1701 app.list_rows.len(),
1702 inner.height as usize,
1703 app.task_state.offset(),
1704 chrome_focus,
1705 );
1706}
1707
1708fn pin_section_header(app: &mut App, vis: usize) {
1711 if vis == 0 {
1712 return;
1713 }
1714 let header = vis - 1;
1715 if !matches!(
1716 app.list_rows.get(header),
1717 Some(crate::app::TaskListRow::Separator { .. })
1718 ) {
1719 return;
1720 }
1721 if app.task_state.offset() > header {
1722 *app.task_state.offset_mut() = header;
1723 }
1724}
1725
1726fn extras(task: &crate::model::Task) -> String {
1728 let notes = if crate::model::has_prose_or_image(task) {
1729 "≡"
1730 } else {
1731 ""
1732 };
1733 match crate::model::todo_progress(task) {
1734 Some((done, total)) => format!("{notes} {done}/{total}").trim_start().to_string(),
1735 None => notes.to_string(),
1736 }
1737}
1738
1739struct TaskPresentation {
1741 title: String,
1742 extras: String,
1743 due: String,
1744 flags: String,
1745 done: bool,
1746}
1747
1748impl TaskPresentation {
1749 fn new(task: &crate::model::Task, date_format: &str) -> Self {
1750 Self {
1751 title: task.title.clone(),
1752 extras: extras(task),
1753 due: due::display_compact(&task.due, date_format),
1754 flags: crate::model::importance_marks(task.importance),
1755 done: task.done,
1756 }
1757 }
1758}
1759
1760fn category_rule(title: &str, width: usize, title_x: usize) -> String {
1763 if width == 0 {
1764 return String::new();
1765 }
1766 let label = format!(" {title} ");
1769 let label_w = label.width();
1770 let pad = title_x.saturating_sub(1).min(width);
1771 if pad + label_w >= width {
1772 let head = "─".repeat(pad);
1773 return truncate(&format!("{head}{label}"), width);
1774 }
1775 format!(
1776 "{}{label}{}",
1777 "─".repeat(pad),
1778 "─".repeat(width - pad - label_w)
1779 )
1780}
1781
1782fn task_row(
1783 presentation: TaskPresentation,
1784 theme: &Theme,
1785 selected: bool,
1786 content_width: usize,
1787 flags_visible: bool,
1788) -> Row<'static> {
1789 let TaskPresentation {
1790 title,
1791 extras,
1792 due,
1793 flags,
1794 done,
1795 } = presentation;
1796 let title_style = if done && !selected {
1800 Style::new().fg(theme.muted_color())
1801 } else {
1802 theme.plain()
1803 };
1804 let title_style = if done {
1805 title_style.add_modifier(Modifier::CROSSED_OUT)
1806 } else {
1807 title_style
1808 };
1809
1810 let mut cells = Vec::with_capacity(5);
1811 let (mark, mark_style) = if done {
1812 ("[✓]", Style::new().fg(theme.success_color()))
1813 } else {
1814 ("[ ]", Style::new().fg(theme.muted_color()))
1815 };
1816 cells.push(Cell::new(mark).style(mark_style));
1817 let metadata_style = if done {
1818 Style::new()
1819 .fg(theme.muted_color())
1820 .add_modifier(Modifier::CROSSED_OUT)
1821 } else {
1822 Style::new().fg(theme.muted_color())
1823 };
1824 let due_style = if done {
1825 title_style
1826 } else {
1827 Style::new().fg(theme.accent)
1828 };
1829 cells.push(Cell::new(task_content_line(
1830 title,
1831 title_style,
1832 extras,
1833 metadata_style,
1834 due,
1835 due_style,
1836 content_width,
1837 )));
1838 if flags_visible {
1839 let flag_style = if done {
1840 metadata_style
1841 } else {
1842 Style::new().fg(theme.error_color())
1843 };
1844 cells.push(Cell::new(Text::from(
1845 Line::from(Span::styled(flags, flag_style)).right_aligned(),
1846 )));
1847 }
1848 Row::new(cells)
1849}
1850
1851fn task_content_line(
1855 title: String,
1856 title_style: Style,
1857 extras: String,
1858 extras_style: Style,
1859 due: String,
1860 due_style: Style,
1861 width: usize,
1862) -> Line<'static> {
1863 const TITLE_MIN: usize = 8;
1864 const META_GAP: usize = 1;
1865
1866 let title_floor = title.width().min(TITLE_MIN);
1867 let mut show_due = false;
1868 let mut show_extras = false;
1869 let mut metadata_width = 0;
1870
1871 if !due.is_empty() && title_floor + META_GAP + due.width() <= width {
1872 show_due = true;
1873 metadata_width = due.width();
1874 }
1875 if !extras.is_empty() {
1876 let joined_width = if metadata_width == 0 {
1877 extras.width()
1878 } else {
1879 extras.width() + META_GAP + metadata_width
1880 };
1881 if title_floor + META_GAP + joined_width <= width {
1882 show_extras = true;
1883 metadata_width = joined_width;
1884 }
1885 }
1886
1887 if metadata_width == 0 {
1888 return Line::from(Span::styled(truncate(&title, width), title_style));
1889 }
1890
1891 let title_width = width.saturating_sub(META_GAP + metadata_width);
1892 let title = truncate(&title, title_width);
1893 let padding = width.saturating_sub(title.width() + metadata_width);
1894 let mut spans = vec![
1895 Span::styled(title, title_style),
1896 Span::raw(" ".repeat(padding)),
1897 ];
1898 if show_extras {
1899 spans.push(Span::styled(extras, extras_style));
1900 if show_due {
1901 spans.push(Span::raw(" "));
1902 }
1903 }
1904 if show_due {
1905 spans.push(Span::styled(due, due_style));
1906 }
1907 Line::from(spans)
1908}
1909
1910fn draw_status(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
1913 let typing = matches!(app.mode, Mode::Slash | Mode::Search);
1916 let update_activity = (!typing).then(|| app.update_activity()).flatten();
1917 let downloading = matches!(update_activity, Some(UpdateActivity::Downloading(_)));
1918 let block = Block::bordered()
1919 .border_type(BorderType::Thick)
1920 .border_style(if typing {
1921 theme.accent_text()
1922 } else {
1923 Style::new().fg(theme.muted_color())
1924 })
1925 .padding(if downloading {
1926 Padding::ZERO
1927 } else {
1928 Padding::horizontal(1)
1929 });
1930 let inner = block.inner(area);
1931 f.render_widget(block, area);
1932 let area = inner;
1933
1934 if let Some(UpdateActivity::Downloading(progress)) = update_activity {
1935 draw_download_progress(f, progress, theme, area);
1936 return;
1937 }
1938
1939 let right = Line::from(Span::styled(
1940 due::now_string(&app.settings.date_format),
1941 Style::new().fg(theme.muted_color()),
1942 ));
1943 let right_width = right.width() as u16;
1944 let [left_area, right_area] = Layout::horizontal([
1945 Constraint::Min(0),
1946 Constraint::Length(right_width.min(area.width)),
1947 ])
1948 .areas(area);
1949 app.areas.command_bar = area;
1952 f.render_widget(Paragraph::new(right), right_area);
1953
1954 let field = left_area.width.saturating_sub(2) as usize;
1955 let left = match app.mode {
1956 Mode::Slash | Mode::Search => {
1957 let view = app.input.visible(field);
1958 f.set_cursor_position((
1959 left_area
1960 .x
1961 .saturating_add(1)
1962 .saturating_add(view.cursor_col),
1963 left_area.y,
1964 ));
1965 let body = line_with_selection(&view.text, view.sel_cols, Style::new(), theme);
1966 Line::from([vec![Span::styled("/", theme.accent_text())], body.spans].concat())
1967 }
1968 _ if update_activity == Some(UpdateActivity::Checking) => {
1969 Line::from(Span::styled("Checking for updates…", theme.accent_text()))
1970 }
1971 _ => match app.status_message() {
1972 Some((text, kind)) => {
1973 let style = match kind {
1974 MessageKind::Error => Style::new()
1975 .fg(theme.error_color())
1976 .add_modifier(Modifier::BOLD),
1977 MessageKind::Info => theme.accent_text(),
1978 };
1979 Line::from(Span::styled(truncate(text, field), style))
1980 }
1981 None => {
1982 let hint = if app.searching {
1983 format!("search: {} · Esc clears", app.search_query)
1984 } else {
1985 "/ commands".to_string()
1986 };
1987 if (left_area.width as usize) >= hint.width() + 2 {
1988 Line::from(Span::styled(hint, Style::new().fg(theme.muted_color())))
1989 } else {
1990 Line::raw("")
1991 }
1992 }
1993 },
1994 };
1995 f.render_widget(Paragraph::new(left), left_area);
1996}
1997
1998fn draw_download_progress(
1999 f: &mut Frame,
2000 progress: crate::update::DownloadProgress,
2001 theme: &Theme,
2002 area: Rect,
2003) {
2004 let Some(total) = progress.total.filter(|total| *total > 0) else {
2005 f.render_widget(
2006 Paragraph::new(Line::from(Span::styled(
2007 format!(
2008 "Downloading update… {}",
2009 readable_bytes(progress.downloaded)
2010 ),
2011 theme.accent_text(),
2012 )))
2013 .centered(),
2014 area,
2015 );
2016 return;
2017 };
2018 let ratio = progress.downloaded.min(total) as f64 / total as f64;
2019 let percent = (ratio * 100.0).round() as u64;
2020 let label = format!("Downloading update {percent}%");
2021 f.render_widget(
2022 Gauge::default()
2023 .ratio(ratio)
2024 .label(label)
2025 .use_unicode(true)
2026 .style(Style::new().fg(theme.muted_color()))
2027 .gauge_style(theme.accent_text().add_modifier(Modifier::BOLD)),
2028 area,
2029 );
2030}
2031
2032fn readable_bytes(bytes: u64) -> String {
2033 const MIB: u64 = 1024 * 1024;
2034 const KIB: u64 = 1024;
2035 if bytes >= MIB {
2036 format!("{:.1} MiB", bytes as f64 / MIB as f64)
2037 } else if bytes >= KIB {
2038 format!("{:.1} KiB", bytes as f64 / KIB as f64)
2039 } else {
2040 format!("{bytes} B")
2041 }
2042}
2043
2044fn draw_slash_palette(f: &mut Frame, app: &mut App, theme: &Theme, status: Rect) {
2046 let query = app.input.value();
2047 let commands = crate::slash::matching(&query);
2048 if commands.is_empty() {
2049 return;
2050 }
2051 let width = 53.min(status.width.saturating_sub(2)).max(24);
2052 let height = u16::try_from(commands.len())
2053 .unwrap_or(u16::MAX)
2054 .saturating_add(2)
2055 .min(status.y.max(3));
2056 let rect = Rect {
2057 x: status.x,
2058 y: status.y.saturating_sub(height),
2059 width,
2060 height,
2061 };
2062 app.areas.slash_menu = rect;
2063 let row_width = width.saturating_sub(2) as usize;
2064 let lines: Vec<Line> = commands
2065 .iter()
2066 .enumerate()
2067 .map(|(i, cmd)| {
2068 let selected = i == app.slash_index.min(commands.len() - 1);
2069 dropdown_row(
2070 theme,
2071 selected,
2072 &format!("/{:<12}", cmd.id()),
2073 cmd.hint(),
2074 row_width,
2075 )
2076 })
2077 .collect();
2078 let block = Block::bordered()
2079 .border_type(BorderType::Thick)
2080 .border_style(theme.accent_text())
2081 .title(Span::styled(
2082 format!(" /{} ", query),
2083 Style::new().fg(theme.muted_color()),
2084 ));
2085 f.render_widget(Clear, rect);
2086 f.render_widget(Paragraph::new(lines).block(block), rect);
2087}
2088
2089fn dropdown_row(
2092 theme: &Theme,
2093 selected: bool,
2094 label: &str,
2095 hint: &str,
2096 row_width: usize,
2097) -> Line<'static> {
2098 let label_part = format!(" {label} ");
2099 let hint_part = format!("{hint} ");
2100 let used = label_part.width() + hint_part.width();
2101 let pad = " ".repeat(row_width.saturating_sub(used));
2102
2103 let (label_style, hint_style, pad_style) = if selected {
2104 let selection = theme.selection();
2105 (selection, selection, selection)
2106 } else {
2107 (
2108 Style::new(),
2109 Style::new().fg(theme.muted_color()),
2110 Style::new(),
2111 )
2112 };
2113 Line::from(vec![
2114 Span::styled(label_part, label_style),
2115 Span::styled(hint_part, hint_style),
2116 Span::styled(pad, pad_style),
2117 ])
2118}
2119
2120fn draw_help(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
2123 const COLUMN_WIDTH: usize = 40;
2124 const WIDE_WIDTH: u16 = COLUMN_WIDTH as u16 * 2 + 7;
2125 const NARROW_WIDTH: u16 = 58;
2126
2127 let wide = area.width >= WIDE_WIDTH;
2128 let width = if wide {
2129 WIDE_WIDTH
2130 } else {
2131 NARROW_WIDTH.min(area.width)
2132 };
2133 let mut lines = wordmark_lines(theme, width);
2134 if !lines.is_empty() {
2135 lines.push(Line::raw(""));
2136 }
2137 let row_style = |heading| {
2138 if heading {
2139 theme.accent_text().add_modifier(Modifier::BOLD)
2140 } else {
2141 Style::new()
2142 }
2143 };
2144 if wide {
2145 for banner::HelpRow {
2146 left,
2147 right,
2148 heading,
2149 } in banner::HELP_COLUMNS
2150 {
2151 let style = row_style(heading);
2152 lines.push(Line::from(vec![
2153 Span::raw(" "),
2154 Span::styled(format!("{left:<COLUMN_WIDTH$}"), style),
2155 Span::styled(right, style),
2156 ]));
2157 }
2158 lines.push(Line::raw(""));
2159 } else {
2160 for side in 0..2 {
2162 for banner::HelpRow {
2163 left,
2164 right,
2165 heading,
2166 } in banner::HELP_COLUMNS
2167 {
2168 let text = if side == 0 { left } else { right };
2169 if text.is_empty() {
2170 lines.push(Line::raw(""));
2171 continue;
2172 }
2173 let prefix = if heading { "" } else { " " };
2174 lines.push(Line::styled(format!("{prefix}{text}"), row_style(heading)));
2175 }
2176 lines.push(Line::raw(""));
2177 }
2178 }
2179 let store = format!("Data store: {}", app.data_dir().display());
2180 lines.push(
2181 Line::styled(
2182 truncate(&store, width.saturating_sub(4) as usize),
2183 Style::new().fg(theme.muted_color()),
2184 )
2185 .centered(),
2186 );
2187 lines.push(Line::styled(banner::HELP_FOOTER, theme.accent_text()).centered());
2188
2189 let height = u16::try_from(lines.len())
2190 .unwrap_or(u16::MAX)
2191 .saturating_add(2)
2192 .min(area.height);
2193 let rect = centered(area, width, height);
2194 let viewport = rect.height.saturating_sub(2) as usize;
2195 let max_scroll = lines.len().saturating_sub(viewport);
2196 app.help_scroll = app.help_scroll.min(max_scroll);
2197 let title = Line::from(vec![
2198 Span::raw(" mach "),
2199 Span::styled(
2200 format!("v{} ", crate::VERSION),
2201 Style::new().fg(theme.muted_color()),
2202 ),
2203 ]);
2204 let block = Block::bordered()
2205 .border_type(BorderType::Thick)
2206 .title(title)
2207 .border_style(theme.accent_text())
2208 .padding(ratatui::widgets::Padding::horizontal(1));
2209 f.render_widget(Clear, rect);
2210 f.render_widget(
2211 Paragraph::new(lines)
2212 .block(block)
2213 .scroll((app.help_scroll.min(u16::MAX as usize) as u16, 0)),
2214 rect,
2215 );
2216}
2217
2218fn draw_settings(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2219 let mut lines: Vec<Line> = Vec::new();
2220 for (i, item) in SETTINGS_ITEMS.iter().enumerate() {
2221 let selected = i == app.settings_index;
2222 let value = app.setting_value(i);
2223 let marker = if selected { "❯ " } else { " " };
2224 let name_style = if selected {
2225 Style::new().add_modifier(Modifier::BOLD)
2226 } else {
2227 Style::new()
2228 };
2229 lines.push(Line::from(vec![
2230 Span::styled(marker, theme.accent_text()),
2231 Span::styled(format!("{item:<14}"), name_style),
2232 Span::styled(value, theme.accent_text()),
2233 ]));
2234 }
2235 lines.push(Line::raw(""));
2236 lines.push(Line::styled(
2237 "↑↓ select · ←→ change · Esc close",
2238 Style::new().fg(theme.muted_color()),
2239 ));
2240
2241 let width = 48.min(area.width);
2242 let height = u16::try_from(lines.len())
2243 .unwrap_or(u16::MAX)
2244 .saturating_add(2)
2245 .min(area.height);
2246 let rect = centered(area, width, height);
2247 let block = Block::bordered()
2248 .border_type(BorderType::Thick)
2249 .title(Line::from(" Settings "))
2250 .border_style(theme.accent_text())
2251 .padding(ratatui::widgets::Padding::horizontal(2));
2252 f.render_widget(Clear, rect);
2253 f.render_widget(Paragraph::new(lines).block(block), rect);
2254}
2255
2256fn draw_welcome(f: &mut Frame, theme: &Theme, area: Rect) {
2257 let mut lines = wordmark_lines(theme, area.width);
2258 if !lines.is_empty() {
2259 lines.push(Line::raw(""));
2260 }
2261 lines.push(
2262 Line::styled(
2263 format!("Welcome to mach v{}", crate::VERSION),
2264 Style::new().add_modifier(Modifier::BOLD),
2265 )
2266 .centered(),
2267 );
2268 lines.push(Line::raw(""));
2269 lines.push(Line::raw("Written in Rust with ratatui.").centered());
2270 lines.push(Line::raw("Your tasks stay local in ~/.mach.").centered());
2271 lines.push(Line::raw(""));
2272 lines.push(
2273 Line::styled(
2274 "Press Enter to start · /help for the key list",
2275 Style::new().fg(theme.muted_color()),
2276 )
2277 .centered(),
2278 );
2279
2280 let width = 50.min(area.width);
2281 let height = u16::try_from(lines.len())
2282 .unwrap_or(u16::MAX)
2283 .saturating_add(2)
2284 .min(area.height);
2285 let rect = centered(area, width, height);
2286 let block = Block::bordered()
2287 .border_type(BorderType::Thick)
2288 .border_style(theme.accent_text());
2289 f.render_widget(Clear, rect);
2290 f.render_widget(Paragraph::new(lines).block(block), rect);
2291}
2292
2293fn wordmark_lines(theme: &Theme, available_width: u16) -> Vec<Line<'static>> {
2294 if available_width < banner::BANNER_WIDTH + 8 {
2295 return Vec::new();
2296 }
2297 banner::BANNER
2298 .iter()
2299 .map(|row| Line::styled(*row, theme.accent_text()).centered())
2300 .collect()
2301}
2302
2303fn draw_whats_new(f: &mut Frame, theme: &Theme, area: Rect) {
2304 let mut lines = vec![
2305 Line::styled(
2306 format!("What's new in mach v{}", crate::VERSION),
2307 Style::new().add_modifier(Modifier::BOLD),
2308 )
2309 .centered(),
2310 Line::raw(""),
2311 ];
2312 for (index, (title, description)) in banner::WHATS_NEW.into_iter().enumerate() {
2313 lines.push(Line::from(vec![
2314 Span::styled("• ", theme.accent_text()),
2315 Span::styled(title, Style::new().add_modifier(Modifier::BOLD)),
2316 ]));
2317 lines.push(Line::raw(format!(" {description}")));
2318 if index + 1 < banner::WHATS_NEW.len() {
2319 lines.push(Line::raw(""));
2320 }
2321 }
2322 lines.push(Line::raw(""));
2323 lines
2324 .push(Line::styled("Full release notes:", Style::new().fg(theme.muted_color())).centered());
2325 lines.push(
2326 Line::styled(
2327 format!("github.com/Q1CHENL/mach/releases/tag/v{}", crate::VERSION),
2328 Style::new().fg(theme.muted_color()),
2329 )
2330 .centered(),
2331 );
2332 lines.push(
2333 Line::styled(
2334 "Press Enter or Esc to continue",
2335 Style::new().fg(theme.muted_color()),
2336 )
2337 .centered(),
2338 );
2339
2340 let height = u16::try_from(lines.len())
2341 .unwrap_or(u16::MAX)
2342 .saturating_add(2)
2343 .min(area.height);
2344 let rect = centered(area, 62.min(area.width), height);
2345 let block = Block::bordered()
2346 .border_type(BorderType::Thick)
2347 .border_style(theme.accent_text())
2348 .padding(Padding::horizontal(2));
2349 f.render_widget(Clear, rect);
2350 f.render_widget(Paragraph::new(lines).block(block), rect);
2351}
2352
2353fn draw_box(f: &mut Frame, area: Rect, text: &str, style: Style) {
2356 let width = u16::try_from(text.width())
2357 .unwrap_or(u16::MAX)
2358 .saturating_add(8)
2359 .min(area.width);
2360 let rect = centered(area, width, 3);
2361 let block = Block::bordered()
2362 .border_type(BorderType::Thick)
2363 .border_style(style);
2364 f.render_widget(Clear, rect);
2365 f.render_widget(
2366 Paragraph::new(Line::styled(text.to_string(), style))
2367 .centered()
2368 .block(block),
2369 rect,
2370 );
2371}
2372
2373pub fn centered(area: Rect, width: u16, height: u16) -> Rect {
2374 let width = width.min(area.width);
2375 let height = height.min(area.height);
2376 Rect {
2377 x: area.x.saturating_add((area.width - width) / 2),
2378 y: area.y.saturating_add((area.height - height) / 2),
2379 width,
2380 height,
2381 }
2382}
2383
2384pub fn truncate(s: &str, width: usize) -> String {
2386 if s.width() <= width {
2387 return s.to_string();
2388 }
2389 let mut out = String::new();
2390 let mut used = 0;
2391 for grapheme in s.graphemes(true) {
2392 let w = grapheme.width();
2393 if used + w > width {
2394 break;
2395 }
2396 used += w;
2397 out.push_str(grapheme);
2398 }
2399 out
2400}