1use ratatui::buffer::Buffer;
2use ratatui::layout::Rect;
3use ratatui::style::{Modifier, Style};
4use ratatui::text::{Line, Span, Text};
5use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
6use ratatui::Frame;
7use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
8
9use crate::app::{App, FloatingSelection, HelpTab, Swatch, SWATCH_CAPACITY};
10use crate::emoji;
11use crate::theme;
12use dartboard_core::CellValue;
13use dartboard_editor::{HelpEntry as KeyMapHelpEntry, HelpSection as KeyMapHelpSection, KeyMap};
14use dartboard_tui::{
15 CanvasStyle, CanvasWidget, CanvasWidgetState, FloatingView,
16 SelectionShape as TuiSelectionShape, SelectionView,
17};
18
19const USER_LIST_MIN_WIDTH: u16 = 12;
20const USER_LIST_MAX_WIDTH: u16 = 24;
21
22const SWATCH_BOX_WIDTH: u16 = 16;
23const SWATCH_BOX_HEIGHT: u16 = 8;
24const SWATCH_GAP: u16 = 1;
25const SWATCH_MARGIN_RIGHT: u16 = 1;
26const SWATCH_MARGIN_BOTTOM: u16 = 1;
27const PIN_UNPINNED: char = '📌';
28const PIN_PINNED: char = '📍';
29
30fn canvas_style() -> CanvasStyle {
31 CanvasStyle {
32 oob_bg: theme::OOB_BG,
33 default_glyph_fg: theme::TEXT,
34 selection_bg: theme::SELECTION_BG,
35 selection_fg: theme::HIGHLIGHT,
36 floating_bg: theme::FLOAT_BG,
37 }
38}
39
40fn selection_view_from(app: &App) -> Option<SelectionView> {
41 if !app.mode.is_selecting() {
42 return None;
43 }
44 let selection = app.selection()?;
45 Some(SelectionView {
46 anchor: selection.anchor,
47 cursor: selection.cursor,
48 shape: match selection.shape {
49 crate::app::SelectionShape::Rect => TuiSelectionShape::Rect,
50 crate::app::SelectionShape::Ellipse => TuiSelectionShape::Ellipse,
51 },
52 })
53}
54
55fn floating_view_from<'a>(app: &'a App, floating: &'a FloatingSelection) -> FloatingView<'a> {
56 FloatingView {
57 width: floating.clipboard.width,
58 height: floating.clipboard.height,
59 cells: floating.clipboard.cells(),
60 anchor: app.cursor,
61 transparent: floating.transparent,
62 active_color: app.active_user_color(),
63 }
64}
65
66pub fn draw(frame: &mut Frame, app: &mut App) {
67 let area = frame.area();
68 app.sync_active_user_slot();
69
70 let title = if let Some(ref floating) = app.floating {
71 if floating.transparent {
72 " lifted (see-thru) \u{00b7} Esc to cancel ".to_string()
73 } else {
74 " lifted \u{00b7} Esc to cancel ".to_string()
75 }
76 } else {
77 let peers = app.peer_count();
78 if !app.is_embedded() && peers > 1 {
79 format!(
80 " {} help \u{00b7} {} glyphs \u{00b7} {} peers \u{00b7} {} quit ",
81 "^P", "^]", peers, "^Q"
82 )
83 } else {
84 format!(
85 " {} help \u{00b7} {} glyphs \u{00b7} {} quit ",
86 "^P", "^]", "^Q"
87 )
88 }
89 };
90 let title_cols = display_width(&title) as u16;
91 let outer = Block::default()
92 .borders(Borders::ALL)
93 .border_type(BorderType::Rounded)
94 .border_style(Style::default().fg(theme::BORDER))
95 .title(Span::styled(title, Style::default().fg(theme::ACCENT)));
96
97 let canvas_area = outer.inner(area);
98 frame.render_widget(outer, area);
99 render_pan_indicators(frame.buffer_mut(), area, app, title_cols);
100
101 app.set_viewport(canvas_area);
102
103 let mut canvas_state = CanvasWidgetState::new(&app.canvas, app.viewport_origin);
104 if let Some(view) = selection_view_from(app) {
105 canvas_state = canvas_state.selection(view);
106 }
107 if let Some(ref floating) = app.floating {
108 canvas_state = canvas_state.floating(floating_view_from(app, floating));
109 }
110 frame.render_widget(
111 CanvasWidget::new(&canvas_state).style(canvas_style()),
112 canvas_area,
113 );
114 let user_list_rect = render_user_list(frame, canvas_area, app);
115 render_swatch_strip(frame, canvas_area, app);
116
117 let cursor_visible = !app.show_help
119 && !app.emoji_picker_open
120 && app.cursor.x >= app.viewport_origin.x
121 && app.cursor.y >= app.viewport_origin.y
122 && app.cursor.x < app.viewport_origin.x + canvas_area.width as usize
123 && app.cursor.y < app.viewport_origin.y + canvas_area.height as usize;
124 if cursor_visible {
125 let cx = (app.cursor.x - app.viewport_origin.x) as u16 + canvas_area.x;
126 let cy = (app.cursor.y - app.viewport_origin.y) as u16 + canvas_area.y;
127 let point_in = |rect: &Rect| {
128 cx >= rect.x && cx < rect.x + rect.width && cy >= rect.y && cy < rect.y + rect.height
129 };
130 let under_overlay = app.swatch_body_hits.iter().flatten().any(point_in)
131 || user_list_rect.as_ref().is_some_and(point_in);
132 if !under_overlay {
133 frame.set_cursor_position((cx, cy));
134 }
135 }
136
137 if app.show_help {
138 render_help(frame, area, app);
139 } else {
140 app.help_tab_hits.clear();
141 }
142
143 if app.emoji_picker_open {
144 if let Some(catalog) = app.icon_catalog.as_ref() {
145 emoji::picker::render(frame, area, &app.emoji_picker_state, catalog);
146 }
147 }
148}
149
150fn render_swatch_strip(frame: &mut Frame, canvas_area: Rect, app: &mut App) {
151 app.swatch_body_hits = [None; SWATCH_CAPACITY];
152 app.swatch_pin_hits = [None; SWATCH_CAPACITY];
153
154 if canvas_area.width < SWATCH_BOX_WIDTH + SWATCH_MARGIN_RIGHT
155 || canvas_area.height < SWATCH_BOX_HEIGHT + SWATCH_MARGIN_BOTTOM
156 {
157 return;
158 }
159
160 let right_edge = canvas_area.x + canvas_area.width - SWATCH_MARGIN_RIGHT;
161 let available_width = right_edge - canvas_area.x;
162 let strip_right = right_edge;
163 let n_visible = ((available_width + SWATCH_GAP) / (SWATCH_BOX_WIDTH + SWATCH_GAP))
164 .min(SWATCH_CAPACITY as u16);
165 if n_visible == 0 {
166 return;
167 }
168 let box_y = canvas_area.y + canvas_area.height - SWATCH_MARGIN_BOTTOM - SWATCH_BOX_HEIGHT;
169
170 let active_idx = app
171 .floating
172 .as_ref()
173 .and_then(|floating| floating.source_index);
174
175 for idx in 0..SWATCH_CAPACITY {
176 if (idx as u16) >= n_visible {
177 continue;
178 }
179 let offset_from_right = (n_visible - 1 - idx as u16) * (SWATCH_BOX_WIDTH + SWATCH_GAP);
180 let box_x = strip_right - offset_from_right - SWATCH_BOX_WIDTH;
181 let rect = Rect::new(box_x, box_y, SWATCH_BOX_WIDTH, SWATCH_BOX_HEIGHT);
182
183 frame.render_widget(Clear, rect);
184
185 let swatch = app.swatches[idx].as_ref();
186 let is_active = active_idx == Some(idx);
187 let is_transparent = is_active
188 && app
189 .floating
190 .as_ref()
191 .map(|floating| floating.transparent)
192 .unwrap_or(false);
193
194 let (body_rect, pin_rect) =
195 render_swatch_box(frame.buffer_mut(), rect, swatch, is_active, is_transparent);
196 app.swatch_body_hits[idx] = Some(body_rect);
197 app.swatch_pin_hits[idx] = pin_rect;
198 }
199}
200
201fn render_swatch_box(
202 buf: &mut Buffer,
203 rect: Rect,
204 swatch: Option<&Swatch>,
205 is_active: bool,
206 is_transparent: bool,
207) -> (Rect, Option<Rect>) {
208 let inner = Rect::new(rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2);
209 for dy in 0..inner.height {
210 for dx in 0..inner.width {
211 buf[(inner.x + dx, inner.y + dy)]
212 .set_char(' ')
213 .set_bg(theme::OOB_BG)
214 .set_fg(theme::TEXT);
215 }
216 }
217
218 let border_style = if is_active {
219 Style::default().fg(theme::HIGHLIGHT)
220 } else if swatch.is_some() {
221 Style::default().fg(theme::ACCENT)
222 } else {
223 Style::default().fg(theme::MUTED_GREATER)
224 };
225
226 let top_row = rect.y;
227 let bottom_row = rect.y + rect.height - 1;
228 let left_col = rect.x;
229 let right_col = rect.x + rect.width - 1;
230
231 buf[(left_col, top_row)]
232 .set_char('╭')
233 .set_style(border_style);
234 buf[(right_col, top_row)]
235 .set_char('╮')
236 .set_style(border_style);
237 buf[(left_col, bottom_row)]
238 .set_char('╰')
239 .set_style(border_style);
240 buf[(right_col, bottom_row)]
241 .set_char('╯')
242 .set_style(border_style);
243 for x in (left_col + 1)..right_col {
244 buf[(x, top_row)].set_char('─').set_style(border_style);
245 buf[(x, bottom_row)].set_char('─').set_style(border_style);
246 }
247 for y in (top_row + 1)..bottom_row {
248 buf[(left_col, y)].set_char('│').set_style(border_style);
249 buf[(right_col, y)].set_char('│').set_style(border_style);
250 }
251
252 if let Some(swatch) = swatch {
253 render_swatch_preview(buf, inner, &swatch.clipboard);
254 }
255
256 if is_transparent {
257 buf[(right_col - 1, inner.y)]
258 .set_char('◌')
259 .set_style(Style::default().fg(theme::HIGHLIGHT).bg(theme::OOB_BG));
260 }
261
262 let pin_rect = swatch.map(|swatch| {
263 let pin_char = if swatch.pinned {
264 PIN_PINNED
265 } else {
266 PIN_UNPINNED
267 };
268 let pin_col = right_col - 2;
269 let pin_row = inner.y + inner.height - 1;
270 let pin_style = Style::default().bg(theme::OOB_BG).fg(if swatch.pinned {
271 theme::HIGHLIGHT
272 } else {
273 theme::MUTED
274 });
275 buf[(pin_col, pin_row)]
276 .set_char(pin_char)
277 .set_style(pin_style);
278 buf[(pin_col + 1, pin_row)]
279 .set_char(' ')
280 .set_style(pin_style);
281 Rect::new(pin_col, pin_row, 2, 1)
282 });
283
284 let body_rect = Rect::new(rect.x, rect.y, rect.width, rect.height);
285 (body_rect, pin_rect)
286}
287
288fn render_swatch_preview(buf: &mut Buffer, inner: Rect, clipboard: &crate::app::Clipboard) {
289 let (crop_x, crop_y) = clipboard_preview_offset(clipboard);
290 let preview_style = Style::default().fg(theme::TEXT).bg(theme::FLOAT_BG);
291
292 for dy in 0..inner.height {
293 let cy = crop_y + dy as usize;
294 if cy >= clipboard.height {
295 break;
296 }
297
298 let mut dx: u16 = 0;
299 while dx < inner.width {
300 let cx = crop_x + dx as usize;
301 if cx >= clipboard.width {
302 break;
303 }
304
305 match clipboard.get(cx, cy) {
306 Some(CellValue::Narrow(ch)) => {
307 buf[(inner.x + dx, inner.y + dy)]
308 .set_char(ch)
309 .set_style(preview_style);
310 dx += 1;
311 }
312 Some(CellValue::Wide(ch)) => {
313 buf[(inner.x + dx, inner.y + dy)]
314 .set_char(ch)
315 .set_style(preview_style);
316 if dx + 1 < inner.width {
317 buf[(inner.x + dx + 1, inner.y + dy)]
318 .set_char(' ')
319 .set_style(preview_style);
320 }
321 dx += 2;
322 }
323 Some(CellValue::WideCont) | None => {
324 buf[(inner.x + dx, inner.y + dy)]
325 .set_char(' ')
326 .set_style(preview_style);
327 dx += 1;
328 }
329 }
330 }
331 }
332}
333
334fn clipboard_preview_offset(clipboard: &crate::app::Clipboard) -> (usize, usize) {
335 let has_visible = (0..clipboard.height)
336 .any(|y| (0..clipboard.width).any(|x| cell_is_visible(clipboard.get(x, y))));
337 if !has_visible {
338 return (0, 0);
339 }
340
341 let mut first_row = 0;
342 'outer_row: for y in 0..clipboard.height {
343 for x in 0..clipboard.width {
344 if cell_is_visible(clipboard.get(x, y)) {
345 first_row = y;
346 break 'outer_row;
347 }
348 }
349 }
350
351 let mut first_col = 0;
352 'outer_col: for x in 0..clipboard.width {
353 for y in 0..clipboard.height {
354 if cell_is_visible(clipboard.get(x, y)) {
355 first_col = x;
356 break 'outer_col;
357 }
358 }
359 }
360
361 (first_col, first_row)
362}
363
364fn cell_is_visible(cell: Option<CellValue>) -> bool {
365 match cell {
366 Some(CellValue::Narrow(ch) | CellValue::Wide(ch)) => ch != ' ',
367 Some(CellValue::WideCont) => true,
368 None => false,
369 }
370}
371
372fn render_pan_indicators(buf: &mut Buffer, area: Rect, app: &App, title_cols: u16) {
373 if area.width < 3 || area.height < 3 {
374 return;
375 }
376
377 let can_pan_left = app.viewport_origin.x > 0;
378 let can_pan_up = app.viewport_origin.y > 0;
379 let can_pan_right = app.viewport_origin.x + (app.viewport.width as usize) < app.canvas.width;
380 let can_pan_down = app.viewport_origin.y + (app.viewport.height as usize) < app.canvas.height;
381
382 let indicator_style = Style::default().fg(theme::HIGHLIGHT);
383 let mid_x = area.x + area.width / 2;
384 let mid_y = area.y + area.height / 2;
385
386 if can_pan_left && area.height >= 5 {
387 for (offset, ch) in [(-1_i32, '◂'), (0, '◀'), (1, '◂')] {
388 let y = (mid_y as i32 + offset) as u16;
389 buf[(area.x, y)].set_char(ch).set_style(indicator_style);
390 }
391 }
392
393 if can_pan_right && area.height >= 5 {
394 let x = area.x + area.width - 1;
395 for (offset, ch) in [(-1_i32, '▸'), (0, '▶'), (1, '▸')] {
396 let y = (mid_y as i32 + offset) as u16;
397 buf[(x, y)].set_char(ch).set_style(indicator_style);
398 }
399 }
400
401 let title_right_col = area.x.saturating_add(title_cols);
405 let top_indicator_fits = title_right_col + 1 < mid_x;
406 if can_pan_up && area.width >= 5 && top_indicator_fits {
407 for (offset, ch) in [(-1_i32, '▴'), (0, '▲'), (1, '▴')] {
408 let x = (mid_x as i32 + offset) as u16;
409 buf[(x, area.y)].set_char(ch).set_style(indicator_style);
410 }
411 }
412
413 if can_pan_down && area.width >= 5 {
414 let y = area.y + area.height - 1;
415 for (offset, ch) in [(-1_i32, '▾'), (0, '▼'), (1, '▾')] {
416 let x = (mid_x as i32 + offset) as u16;
417 buf[(x, y)].set_char(ch).set_style(indicator_style);
418 }
419 }
420}
421
422fn render_user_list(frame: &mut Frame, canvas_area: Rect, app: &App) -> Option<Rect> {
423 if canvas_area.width < 6 || canvas_area.height < 3 {
424 return None;
425 }
426
427 let longest_name = app
428 .users()
429 .iter()
430 .map(|user| user.name.chars().count() as u16)
431 .max()
432 .unwrap_or(0);
433 let width = (longest_name + 2)
434 .clamp(USER_LIST_MIN_WIDTH, USER_LIST_MAX_WIDTH)
435 .min(canvas_area.width);
436 let height = (app.users().len() as u16 + 2).min(canvas_area.height);
437 if width < 4 || height < 3 {
438 return None;
439 }
440
441 let panel = Rect::new(
442 canvas_area.x + canvas_area.width - width,
443 canvas_area.y,
444 width,
445 height,
446 );
447 let inner = Rect::new(
448 panel.x.saturating_add(1),
449 panel.y.saturating_add(1),
450 panel.width.saturating_sub(2),
451 panel.height.saturating_sub(2),
452 );
453
454 frame.render_widget(Clear, panel);
455
456 let title_text = if app.is_embedded() {
457 " colors "
458 } else {
459 " users "
460 };
461 let mut block = Block::default()
462 .borders(Borders::ALL)
463 .border_type(BorderType::Rounded)
464 .border_style(Style::default().fg(theme::ACCENT))
465 .title(Span::styled(
466 title_text,
467 Style::default().fg(theme::HIGHLIGHT),
468 ));
469 if app.is_embedded() && app.users().len() > 1 {
470 block = block.title(
471 Line::from(Span::styled(
472 " \u{21e5} ",
473 Style::default().fg(theme::ACCENT),
474 ))
475 .right_aligned(),
476 );
477 }
478 frame.render_widget(block, panel);
479
480 if inner.width == 0 || inner.height == 0 {
481 return Some(panel);
482 }
483
484 let max_name_width = inner.width as usize;
485 let text = Text::from(
486 app.users()
487 .iter()
488 .take(inner.height as usize)
489 .enumerate()
490 .map(|(idx, user)| {
491 let label = truncate_label(&user.name, max_name_width.saturating_sub(2));
492 let line = format!(" {}", label);
493 if idx == app.active_user_index() {
494 Line::from(Span::styled(
495 format!("{:<width$}", line, width = max_name_width),
496 Style::default()
497 .fg(theme::rat(user.color))
498 .bg(theme::SELECTION_BG)
499 .add_modifier(Modifier::BOLD),
500 ))
501 } else {
502 Line::from(Span::styled(
503 format!("{:<width$}", line, width = max_name_width),
504 Style::default().fg(theme::rat(user.color)),
505 ))
506 }
507 })
508 .collect::<Vec<_>>(),
509 );
510 frame.render_widget(
511 Paragraph::new(text).style(Style::default().fg(theme::TEXT)),
512 inner,
513 );
514
515 Some(panel)
516}
517
518const HELP_TAB_COLS: usize = 3;
519const HELP_TAB_ROWS: u16 = 2;
520const HELP_TAB_GAP: u16 = 2;
521
522fn render_help(frame: &mut Frame, area: Rect, app: &mut App) {
523 app.help_tab_hits.clear();
524 let width = 64u16.min(area.width.saturating_sub(4));
525 let height = 22u16.min(area.height.saturating_sub(2));
526 let x = (area.width.saturating_sub(width)) / 2 + area.x;
527 let y = (area.height.saturating_sub(height)) / 2 + area.y;
528 let popup = Rect::new(x, y, width, height);
529
530 frame.render_widget(Clear, popup);
531
532 let block = Block::default()
533 .borders(Borders::ALL)
534 .border_type(BorderType::Rounded)
535 .border_style(Style::default().fg(theme::ACCENT))
536 .title(Span::styled(
537 " help ",
538 Style::default().fg(theme::HIGHLIGHT),
539 ))
540 .title(
541 Line::from(vec![
542 Span::styled("tab", Style::default().fg(theme::ACCENT)),
543 Span::raw(" "),
544 Span::styled("switch ", Style::default().fg(theme::MUTED)),
545 ])
546 .right_aligned(),
547 );
548
549 let inner = block.inner(popup);
550 frame.render_widget(block, popup);
551
552 if inner.height < HELP_TAB_ROWS + 2 || inner.width < 10 {
553 return;
554 }
555
556 let tabs_area = Rect::new(inner.x, inner.y, inner.width, HELP_TAB_ROWS);
557 app.help_tab_hits = render_help_tabs(frame.buffer_mut(), tabs_area, app.help_tab);
558
559 let (_, sep, _, _) = help_styles();
560 let divider_y = inner.y + HELP_TAB_ROWS;
561 let divider_area = Rect::new(inner.x, divider_y, inner.width, 1);
562 frame.render_widget(
563 Paragraph::new(Line::from(Span::styled(
564 "─".repeat(inner.width as usize),
565 sep,
566 ))),
567 divider_area,
568 );
569
570 let content_y = divider_y + 1;
571 let content = Rect::new(
572 inner.x,
573 content_y,
574 inner.width,
575 inner.height.saturating_sub(HELP_TAB_ROWS + 1),
576 );
577
578 render_help_section(frame, content, app.help_tab, &mut app.help_scroll);
579}
580
581fn render_help_tabs(buf: &mut Buffer, area: Rect, active: HelpTab) -> Vec<(HelpTab, Rect)> {
582 let mut hits: Vec<(HelpTab, Rect)> = Vec::with_capacity(HelpTab::ALL.len());
583 let tabs = HelpTab::ALL;
584
585 let mut col_widths = [0u16; HELP_TAB_COLS];
587 for (i, tab) in tabs.iter().enumerate() {
588 let col = i % HELP_TAB_COLS;
589 let cell = 4 + display_width(tab.label()) as u16;
590 if cell > col_widths[col] {
591 col_widths[col] = cell;
592 }
593 }
594
595 for (i, tab) in tabs.iter().enumerate() {
596 let col = i % HELP_TAB_COLS;
597 let row = (i / HELP_TAB_COLS) as u16;
598 if row >= area.height {
599 break;
600 }
601 let mut x = area.x + 1;
602 for w in col_widths.iter().take(col) {
603 x = x.saturating_add(*w).saturating_add(HELP_TAB_GAP);
604 }
605 let y = area.y + row;
606 let is_active = *tab == active;
607 let indicator = if is_active { "•" } else { " " };
608 let cell_style = if is_active {
609 Style::default()
610 .fg(theme::HIGHLIGHT)
611 .add_modifier(Modifier::BOLD)
612 } else {
613 Style::default().fg(theme::MUTED)
614 };
615 let text = format!("[{indicator}] {}", tab.label());
616 let start_x = x;
617 for ch in text.chars() {
618 if x >= area.x + area.width {
619 break;
620 }
621 buf[(x, y)].set_char(ch).set_style(cell_style);
622 x += 1;
623 }
624 if x > start_x {
625 hits.push((*tab, Rect::new(start_x, y, x - start_x, 1)));
626 }
627 }
628 hits
629}
630
631fn help_styles() -> (Style, Style, Style, Style) {
632 let heading = Style::default()
633 .fg(theme::ACCENT)
634 .add_modifier(Modifier::BOLD);
635 let sep = Style::default().fg(theme::MUTED_GREATER);
636 let key = Style::default().fg(theme::HIGHLIGHT);
637 let desc = Style::default().fg(theme::MUTED);
638 (heading, sep, key, desc)
639}
640
641fn keymap_help_entries() -> Vec<KeyMapHelpEntry> {
642 KeyMap::default_standalone().help_entries()
643}
644
645fn keymap_help_rows(
646 entries: &[KeyMapHelpEntry],
647 section: KeyMapHelpSection,
648) -> Vec<(&'static str, &'static str)> {
649 entries
650 .iter()
651 .filter(|entry| entry.section == section)
652 .map(|entry| (entry.keys, entry.description))
653 .collect()
654}
655
656fn help_rows_for_tab(tab: HelpTab) -> Vec<(&'static str, &'static str)> {
657 let entries = keymap_help_entries();
658 match tab {
659 HelpTab::Guide => Vec::new(),
660 HelpTab::Drawing => keymap_help_rows(&entries, KeyMapHelpSection::Drawing),
661 HelpTab::Selection => {
662 let mut rows = keymap_help_rows(&entries, KeyMapHelpSection::Selection);
663 rows.extend([
664 ("click+drag", "block select with mouse"),
665 ("right-drag", "pan viewport"),
666 ("alt+click", "extend selection"),
667 ("esc / move", "cancel selection"),
668 ]);
669 rows
670 }
671 HelpTab::Clipboard => {
672 let mut rows = keymap_help_rows(&entries, KeyMapHelpSection::Clipboard);
673 rows.push(("📌", "pin"));
674 rows
675 }
676 HelpTab::Transform => keymap_help_rows(&entries, KeyMapHelpSection::Transform),
677 HelpTab::Session => vec![
678 ("^Z / ^R", "undo / redo"),
679 ("^P", "help toggle"),
680 ("^Q", "quit"),
681 ],
682 }
683}
684
685const GUIDE_PROSE: &[&str] = &[
686 "Move the caret with ←↑↓→ and type to draw.",
687 "",
688 "Hold shift with ←↑↓→ (or click + drag) to select a region.",
689 "Type to fill the selection. Use ^X / ^C / ^V to cut / copy",
690 "/ paste into one of five swatches. Click a swatch to use it",
691 "as a brush.",
692 "",
693 "^Q quits the artboard. ^] opens the emoji / glyph picker.",
694 "",
695 "^P toggles this help. Tab or ←/→ switches between these",
696 "tabs; ↑/↓ (or j/k) scrolls the content of the current help",
697 "tab.",
698 "",
699 "The other help tabs list the keys by category.",
700];
701
702fn build_guide_lines(desc: Style) -> Vec<Line<'static>> {
703 GUIDE_PROSE
704 .iter()
705 .map(|prose| Line::from(Span::styled(format!(" {prose}"), desc)))
706 .collect()
707}
708
709fn render_help_section(frame: &mut Frame, area: Rect, tab: HelpTab, scroll: &mut u16) {
710 let (_, _, key, desc) = help_styles();
711 let width = area.width as usize;
712
713 let lines: Vec<Line<'static>> = if tab == HelpTab::Guide {
714 build_guide_lines(desc)
715 } else {
716 let rows = help_rows_for_tab(tab);
717 let widest_key = rows
718 .iter()
719 .map(|(k, _)| display_width(k))
720 .max()
721 .unwrap_or(0);
722 let key_width = widest_key.min(width.saturating_sub(2));
723 rows.iter()
724 .map(|(k, d)| help_entry_line_with_key_width(k, d, width, key_width, key, desc))
725 .collect()
726 };
727
728 let visible = area.height as usize;
729 let max_scroll = lines.len().saturating_sub(visible) as u16;
730 if *scroll > max_scroll {
731 *scroll = max_scroll;
732 }
733
734 frame.render_widget(Paragraph::new(Text::from(lines)).scroll((*scroll, 0)), area);
735}
736
737fn help_entry_line_with_key_width(
738 k: &str,
739 d: &str,
740 width: usize,
741 key_width: usize,
742 ks: Style,
743 ds: Style,
744) -> Line<'static> {
745 if width == 0 {
746 return Line::default();
747 }
748
749 let key_width = key_width.min(width.saturating_sub(1));
750 let key_label = truncate_display(k, key_width);
751 let key_padded = pad_right_display(&key_label, key_width);
752 let left = format!(" {key_padded} ");
753 let desc_width = width.saturating_sub(display_width(&left));
754 let desc_label = truncate_display(d, desc_width);
755 let desc_padded = pad_right_display(&desc_label, desc_width);
756
757 Line::from(vec![Span::styled(left, ks), Span::styled(desc_padded, ds)])
758}
759
760fn display_width(s: &str) -> usize {
761 UnicodeWidthStr::width(s)
762}
763
764fn truncate_display(text: &str, max_width: usize) -> String {
765 if display_width(text) <= max_width {
766 return text.to_string();
767 }
768 if max_width == 0 {
769 return String::new();
770 }
771 if max_width <= 3 {
772 return ".".repeat(max_width);
773 }
774
775 let prefix_budget = max_width - 3;
776 let mut out = String::new();
777 let mut width = 0usize;
778 for ch in text.chars() {
779 let w = UnicodeWidthChar::width(ch).unwrap_or(0);
780 if width + w > prefix_budget {
781 break;
782 }
783 out.push(ch);
784 width += w;
785 }
786 format!("{out}...")
787}
788
789fn pad_right_display(s: &str, width: usize) -> String {
790 let d = display_width(s);
791 if d >= width {
792 return s.to_string();
793 }
794 let mut out = String::with_capacity(s.len() + (width - d));
795 out.push_str(s);
796 for _ in 0..(width - d) {
797 out.push(' ');
798 }
799 out
800}
801
802fn truncate_label(text: &str, max_width: usize) -> String {
803 truncate_display(text, max_width)
804}