ratada 0.4.0

A ratatui widget toolkit: driver, modals, forms, pickers, theming
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! Scrollable, fuzzy-searchable help overlay listing key bindings in sections.
//!
//! A thin wrapper over [`overlay::popup`]: the dimmed backdrop, box and loop
//! come from there; this module owns the search state and the sectioned body.
//! Bindings are grouped into [`HelpSection`]s; `Tab`/`BackTab` jump between
//! sections, the arrows (plus `PageUp`/`PageDown` and `Home`/`End`) move within
//! the flat list, and typing filters fuzzily while keeping the section headers
//! of any section that still has a match.

use std::{cell::Cell, io};

use crossterm::event::KeyCode;
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    style::Modifier,
    text::{Line, Span},
    widgets::Paragraph,
};

use super::{
    chrome, fuzzy, input,
    layout::centered_fraction,
    list,
    modal::ModalSignal,
    nav,
    overlay::{self, PopupFlow, popup_with_paste},
    shortcut_hints, style,
    terminal::Tui,
};
use crate::theme::Skin;

/// The prefix of the query line; its width is taken off the caret line's.
const SEARCH_LABEL: &str = "search ";

/// A titled group of key bindings shown under one header in the overlay.
pub struct HelpSection<'a, B: AsRef<str>> {
    /// The section header.
    pub title: &'a str,
    /// The `(key, description)` bindings listed under the header.
    pub bindings: &'a [(B, B)],
}

/// The search state of the help overlay.
struct Help {
    query: String,
    /// Index into the currently selectable (item) rows.
    cursor: usize,
    /// Persistent list scroll offset so the view and scrollbar follow the
    /// cursor across frames.
    offset: Cell<usize>,
    /// The list viewport height captured at render, driving page jumps.
    viewport: Cell<usize>,
}

/// One rendered row: a section header or a selectable binding.
enum Row<'a> {
    Header(&'a str),
    Item { key: &'a str, description: &'a str },
}

/// The rows to render plus the navigation index maps for the current query.
struct RowLayout<'a> {
    rows: Vec<Row<'a>>,
    /// Row index of each selectable item, in order.
    selectable: Vec<usize>,
    /// Position within `selectable` of each visible section's first item.
    section_starts: Vec<usize>,
}

/// Shows the help overlay until the user closes it.
///
/// A query filters the bindings fuzzily (keeping the header of every section
/// that still matches); the arrows (plus `PageUp`/`PageDown` and `Home`/`End`)
/// move the selection, `Tab`/`BackTab` jump
/// to the next/previous section, and `Esc` or `?` close the overlay.
pub fn show<B: AsRef<str>>(
    tui: &mut Tui,
    skin: &Skin,
    sections: &[HelpSection<'_, B>],
    render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<()>> {
    let mut state = Help {
        query: String::new(),
        cursor: 0,
        offset: Cell::new(0),
        viewport: Cell::new(1),
    };
    popup_with_paste(
        tui,
        &mut state,
        |area, _| centered_fraction(area, 2, 3, 40, 8),
        |frame, _| render_bg(frame),
        |frame, rect, state: &Help| {
            let inner = overlay::framed(frame, rect, skin, "Help");
            render_body(frame, inner, skin, sections, state);
            // Section headers are rows but not positions: the badge counts the
            // selectable bindings.
            let count = layout_rows(sections, &state.query).selectable.len();
            let cursor = state.cursor.min(count.saturating_sub(1));
            let badge = chrome::position_badge(cursor, count);
            chrome::render_badge(frame, rect, skin, &badge);
        },
        |state, key| {
            // The overlay binds no chord of its own, so a Ctrl command is not
            // ours to act on: without this `Ctrl+U` types a `u` into the search
            // instead of clearing the line, and `Ctrl+?` would close the help.
            // Alt alone and AltGr (Ctrl+Alt) still type, as they do in every
            // text field - see `input::is_command`.
            if input::is_command(key) {
                return PopupFlow::Continue;
            }
            match key.code {
                KeyCode::Esc | KeyCode::Char('?') => PopupFlow::Done(()),
                KeyCode::Up => {
                    let count =
                        layout_rows(sections, &state.query).selectable.len();
                    state.cursor = nav::cycle(state.cursor, count, -1);
                    PopupFlow::Continue
                }
                KeyCode::Down => {
                    let count =
                        layout_rows(sections, &state.query).selectable.len();
                    state.cursor = nav::cycle(state.cursor, count, 1);
                    PopupFlow::Continue
                }
                KeyCode::PageUp => {
                    let count =
                        layout_rows(sections, &state.query).selectable.len();
                    let page = state.viewport.get().max(1) as isize;
                    state.cursor =
                        nav::step_clamped(state.cursor, count, -page);
                    PopupFlow::Continue
                }
                KeyCode::PageDown => {
                    let count =
                        layout_rows(sections, &state.query).selectable.len();
                    let page = state.viewport.get().max(1) as isize;
                    state.cursor = nav::step_clamped(state.cursor, count, page);
                    PopupFlow::Continue
                }
                KeyCode::Home => {
                    state.cursor = 0;
                    PopupFlow::Continue
                }
                KeyCode::End => {
                    let count =
                        layout_rows(sections, &state.query).selectable.len();
                    state.cursor = count.saturating_sub(1);
                    PopupFlow::Continue
                }
                KeyCode::Tab => {
                    jump_section(state, sections, 1);
                    PopupFlow::Continue
                }
                KeyCode::BackTab => {
                    jump_section(state, sections, -1);
                    PopupFlow::Continue
                }
                KeyCode::Backspace => {
                    state.query.pop();
                    state.cursor = 0;
                    PopupFlow::Continue
                }
                KeyCode::Char(ch) => {
                    state.query.push(ch);
                    state.cursor = 0;
                    PopupFlow::Continue
                }
                _ => PopupFlow::Continue,
            }
        },
        |state, text| {
            state
                .query
                .extend(text.chars().filter(|ch| !ch.is_control()));
            state.cursor = 0;
            PopupFlow::Continue
        },
    )
}

/// Moves the cursor to the first item of the next (`+1`) or previous (`-1`)
/// visible section, wrapping around.
fn jump_section<B: AsRef<str>>(
    state: &mut Help,
    sections: &[HelpSection<'_, B>],
    direction: isize,
) {
    let starts = layout_rows(sections, &state.query).section_starts;
    if starts.is_empty() {
        return;
    }
    // The section the cursor is currently in: the last start at or before it.
    let current = starts
        .iter()
        .rposition(|&start| start <= state.cursor)
        .unwrap_or(0);
    let next = nav::cycle(current, starts.len(), direction);
    state.cursor = starts[next];
}

/// Builds the rows and navigation index maps for `sections` filtered by `query`.
/// A section is included only if it keeps at least one matching binding; the
/// bindings stay in their original order (no score re-sorting).
fn layout_rows<'a, B: AsRef<str>>(
    sections: &'a [HelpSection<'a, B>],
    query: &str,
) -> RowLayout<'a> {
    let query = query.trim();
    let mut rows: Vec<Row<'a>> = Vec::new();
    let mut selectable: Vec<usize> = Vec::new();
    let mut section_starts: Vec<usize> = Vec::new();

    for section in sections {
        let matches: Vec<&(B, B)> = section
            .bindings
            .iter()
            .filter(|(key, description)| {
                is_match(key.as_ref(), description.as_ref(), query)
            })
            .collect();
        if matches.is_empty() {
            continue;
        }
        section_starts.push(selectable.len());
        rows.push(Row::Header(section.title));
        for (key, description) in matches {
            selectable.push(rows.len());
            rows.push(Row::Item {
                key: key.as_ref(),
                description: description.as_ref(),
            });
        }
    }
    RowLayout {
        rows,
        selectable,
        section_starts,
    }
}

/// Whether a binding matches `query` (everything matches an empty query).
fn is_match(key: &str, description: &str, query: &str) -> bool {
    if query.is_empty() {
        return true;
    }
    fuzzy::score(&format!("{key} {description}"), query).is_some()
}

fn render_body<B: AsRef<str>>(
    frame: &mut Frame,
    inner: Rect,
    skin: &Skin,
    sections: &[HelpSection<'_, B>],
    state: &Help,
) {
    let palette = &skin.palette;
    let layout = layout_rows(sections, &state.query);

    // The popup footer always reserves its row (popup hints ignore the global
    // F1 toggle, which governs only the main-app footer).
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Min(1),
            Constraint::Length(shortcut_hints::footer_height(1)),
        ])
        .split(inner);

    let mut search =
        vec![Span::styled(SEARCH_LABEL, style::secondary(palette))];
    search.extend(input::query_spans(
        &state.query,
        palette,
        (rows[0].width as usize).saturating_sub(SEARCH_LABEL.len()),
    ));
    frame.render_widget(Paragraph::new(Line::from(search)), rows[0]);

    let header_style =
        style::fg(palette.accent_dim).add_modifier(Modifier::BOLD);
    let entries: Vec<Line<'static>> = layout
        .rows
        .iter()
        .map(|row| match row {
            Row::Header(title) => {
                Line::from(Span::styled(title.to_uppercase(), header_style))
            }
            Row::Item { key, description } => {
                let mut spans = vec![Span::styled(
                    format!("  {key:<12}"),
                    style::fg(palette.accent).add_modifier(Modifier::BOLD),
                )];
                spans.extend(fuzzy::highlight(
                    description,
                    &state.query,
                    style::secondary(palette),
                    palette,
                ));
                Line::from(spans)
            }
        })
        .collect();

    // Delegate to the shared list widget so the cursor highlight, scroll and
    // scrollbar-on-overflow are consistent with every other list. The selected
    // row is the current item's flat row index; headers are never selected.
    let selected = layout
        .selectable
        .get(state.cursor.min(layout.selectable.len().saturating_sub(1)))
        .copied()
        .unwrap_or(0);
    let viewport = list::render(
        frame,
        rows[1],
        skin,
        list::ListView {
            rows: entries,
            selected,
            offset: &state.offset,
        },
    );
    state.viewport.set(viewport);

    let hint = footer_hint(skin, rows[2].width as usize);
    frame.render_widget(Paragraph::new(hint), rows[2]);
}

/// The footer hint line for the overlay.
fn footer_hint(skin: &Skin, width: usize) -> Line<'static> {
    shortcut_hints::lines(
        &[
            ("\u{2191}\u{2193}", "move"),
            ("tab", "section"),
            ("esc", "close"),
        ],
        skin.palette.accent_dim,
        width,
    )
    .into_iter()
    .next()
    .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sections() -> Vec<HelpSection<'static, &'static str>> {
        vec![
            HelpSection {
                title: "Navigation",
                bindings: &[("k", "up"), ("j", "down")],
            },
            HelpSection {
                title: "Tasks",
                bindings: &[("a", "add task"), ("d", "delete")],
            },
        ]
    }

    #[test]
    fn empty_query_keeps_every_section_and_item() {
        let secs = sections();
        let layout = layout_rows(&secs, "");
        // 2 headers + 4 items = 6 rows; 4 selectable; 2 section starts.
        assert_eq!(layout.rows.len(), 6);
        assert_eq!(layout.selectable.len(), 4);
        assert_eq!(layout.section_starts, vec![0, 2]);
    }

    #[test]
    fn query_filters_items_and_drops_empty_sections() {
        let secs = sections();
        let layout = layout_rows(&secs, "add");
        // Only the Tasks section keeps a match ("add task").
        assert_eq!(layout.selectable.len(), 1);
        assert_eq!(layout.section_starts, vec![0]);
        assert!(matches!(layout.rows[0], Row::Header("Tasks")));
    }

    #[test]
    fn section_jump_lands_on_the_first_item_of_the_target() {
        let secs = sections();
        let mut state = Help {
            query: String::new(),
            cursor: 0,
            offset: Cell::new(0),
            viewport: Cell::new(1),
        };
        // From the first section, Tab moves to the Tasks section start (index 2
        // in the selectable list).
        jump_section(&mut state, &secs, 1);
        assert_eq!(state.cursor, 2);
        // Wraps back to the first section.
        jump_section(&mut state, &secs, 1);
        assert_eq!(state.cursor, 0);
        // BackTab from the first section wraps to the last.
        jump_section(&mut state, &secs, -1);
        assert_eq!(state.cursor, 2);
    }
}