Skip to main content

dartboard_cli/
ui.rs

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    // Cursor position
118    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    // Top indicator sits at [mid_x - 1, mid_x + 1] on the top border row.
402    // The title is painted starting at col area.x + 1. Hide the indicator
403    // when the title would overlap it rather than let them fight for cells.
404    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    // Column widths: [ ] label (4 + label chars) — pad to widest in each column.
586    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}