ratada 0.5.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
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
//! A scrollable Markdown view widget and a blocking viewer modal.
//!
//! [`MarkdownView`] renders a Markdown source into an area, scrolls it and lets
//! the user cycle its hyperlinks; [`viewer`] wraps it in a popup and returns the
//! link the user picked (the host opens it - the toolkit stays policy-free).

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

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

use super::{Link, StyleSheet};
use crate::{
    chrome, input,
    layout::centered_fraction,
    modal::ModalSignal,
    nav,
    overlay::{self, PopupFlow, popup},
    scroll, style,
    terminal::Tui,
    text::truncate,
    theme::Skin,
};

/// A scrollable Markdown view: renders a source wrapped to the render width,
/// keeps a scroll offset across frames and cycles the document's hyperlinks with
/// `Tab`/`BackTab`. The styling comes from an explicit [`StyleSheet`] or, by
/// default, from the skin via [`StyleSheet::from_skin`].
pub struct MarkdownView {
    source: String,
    sheet: Option<StyleSheet>,
    offset: Cell<usize>,
    total: Cell<usize>,
    viewport: Cell<usize>,
    links: Vec<Link>,
    active_link: usize,
    decor: Option<chrome::BoxDecor>,
}

impl MarkdownView {
    /// A view over `source`, scrolled to the top.
    ///
    /// # Examples
    ///
    /// ```
    /// use ratada::markdown::MarkdownView;
    ///
    /// let view = MarkdownView::new("# Title\n\nSome **text**.");
    /// assert!(view.links().is_empty());
    /// ```
    pub fn new(source: impl Into<String>) -> Self {
        let source = source.into();
        let links = super::links(&source);
        Self {
            source,
            sheet: None,
            offset: Cell::new(0),
            total: Cell::new(0),
            viewport: Cell::new(1),
            links,
            active_link: 0,
            decor: None,
        }
    }

    /// Draws the view inside a rounded box with the given caption/badge.
    #[must_use]
    pub fn boxed(mut self, decor: chrome::BoxDecor) -> Self {
        self.decor = Some(decor);
        self
    }

    /// Renders with an explicit `sheet` instead of the skin-derived default.
    #[must_use]
    pub fn with_stylesheet(mut self, sheet: StyleSheet) -> Self {
        self.sheet = Some(sheet);
        self
    }

    /// Replaces the source, resetting the scroll position and link cursor.
    pub fn set_source(&mut self, source: impl Into<String>) {
        self.source = source.into();
        self.links = super::links(&self.source);
        self.active_link = 0;
        self.offset.set(0);
    }

    /// The hyperlinks in the source, in document order.
    pub fn links(&self) -> &[Link] {
        &self.links
    }

    /// The currently highlighted link, if the source has any.
    pub fn selected_link(&self) -> Option<&Link> {
        self.links.get(self.active_link)
    }

    /// Handles a scroll or link-navigation key; returns whether it was consumed.
    ///
    /// `Up`/`k`, `Down`/`j`, `PageUp`/`PageDown`, `Home`/`End` scroll (clamped,
    /// not cyclic); `Tab`/`BackTab` cycle the active link.
    ///
    /// Scrolling is bare keys only: a Ctrl chord is left unconsumed, so `Ctrl+J`
    /// cannot scroll and a host stays free to bind `Ctrl+<key>` itself.
    pub fn handle_key(&mut self, key: KeyEvent) -> bool {
        if input::is_command(key) {
            return false;
        }
        let page = self.viewport.get().max(1);
        let offset = self.offset.get();
        match key.code {
            KeyCode::Up | KeyCode::Char('k') => {
                self.scroll_to(offset.saturating_sub(1));
            }
            KeyCode::Down | KeyCode::Char('j') => {
                self.scroll_to(offset + 1);
            }
            KeyCode::PageUp => self.scroll_to(offset.saturating_sub(page)),
            KeyCode::PageDown => self.scroll_to(offset + page),
            KeyCode::Home => self.scroll_to(0),
            KeyCode::End => self.scroll_to(usize::MAX),
            KeyCode::Tab => self.cycle_link(1),
            KeyCode::BackTab => self.cycle_link(-1),
            _ => return false,
        }
        true
    }

    /// Renders the view into `area`, scrolling so the offset stays in range, plus
    /// a scrollbar on overflow. Boxed when a decoration was set, in which case
    /// the box's bottom border carries the scroll percentage.
    pub fn render(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
        let inner = match &self.decor {
            Some(decor) => chrome::framed_decor(frame, area, skin, decor, ""),
            None => area,
        };
        let width = (inner.width as usize).max(1);
        let viewport = (inner.height as usize).max(1);
        let sheet = self
            .sheet
            .clone()
            .unwrap_or_else(|| StyleSheet::from_skin(skin));
        let lines = super::render_block(&self.source, width, &sheet);

        let total = lines.len();
        self.total.set(total);
        self.viewport.set(viewport);
        let offset = self.offset.get().min(total.saturating_sub(viewport));
        self.offset.set(offset);

        let visible: Vec<Line> =
            lines.into_iter().skip(offset).take(viewport).collect();
        frame.render_widget(Paragraph::new(visible), inner);
        scroll::render_scrollbar(
            frame,
            inner,
            skin,
            nav::ScrollView {
                total,
                offset,
                viewport,
            },
        );

        // The line count only exists once the source has been wrapped to the
        // render width, so an `Auto` badge is painted after the fact rather
        // than handed to `framed_decor` up front.
        if let Some(decor) = &self.decor
            && matches!(decor.badge, chrome::Badge::Auto)
        {
            chrome::render_badge(frame, area, skin, &self.percent_badge());
        }
    }

    /// The scroll position shown in a frame's bottom border, e.g. `"42%"`.
    /// Meaningful only after a render has sized the viewport.
    fn percent_badge(&self) -> String {
        let percent = nav::scroll_percent(nav::ScrollView {
            total: self.total.get(),
            offset: self.offset.get(),
            viewport: self.viewport.get(),
        });
        format!("{percent}%")
    }

    /// Clamps `offset` to the scrollable range and stores it.
    fn scroll_to(&self, offset: usize) {
        let max_offset = self.total.get().saturating_sub(self.viewport.get());
        self.offset.set(offset.min(max_offset));
    }

    /// Moves the active link by `delta` (wrapping), a no-op without links.
    fn cycle_link(&mut self, delta: isize) {
        if !self.links.is_empty() {
            self.active_link =
                nav::cycle(self.active_link, self.links.len(), delta);
        }
    }
}

/// Opens `source` in a blocking Markdown viewer over `render_bg`. `Tab` cycles
/// the document's links (shown in the footer); `Enter`/`o` returns the
/// highlighted link, `Esc` cancels, the global quit chord yields `Quit`.
///
/// # Errors
///
/// Returns an I/O error if drawing or reading terminal events fails.
pub fn viewer(
    tui: &mut Tui,
    skin: &Skin,
    title: &str,
    source: &str,
    render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<Link>> {
    let mut view = MarkdownView::new(source);
    popup(
        tui,
        &mut view,
        |area, _| centered_fraction(area, 3, 4, 40, 8),
        |frame, _| render_bg(frame),
        |frame, rect, view: &MarkdownView| {
            let inner = overlay::framed(frame, rect, skin, title);
            render_viewer_body(frame, inner, skin, view);
            // The body has just wrapped the source, so the percentage matches
            // what is on screen.
            chrome::render_badge(frame, rect, skin, &view.percent_badge());
        },
        handle_viewer_key,
    )
}

/// Applies one key to the viewer's `view`, or reports that the modal is done.
///
/// A named function rather than a closure inside [`popup`], so the guard below
/// is reachable from a test: everything in `popup` needs a live terminal. Named
/// apart from [`MarkdownView::handle_key`], which it delegates the scroll and
/// link-cycling keys to.
fn handle_viewer_key(
    view: &mut MarkdownView,
    key: KeyEvent,
) -> PopupFlow<Link> {
    match key.code {
        KeyCode::Esc => PopupFlow::Cancelled,
        KeyCode::Enter => open_selected(view),
        // `o` is a plain letter, so only a bare one opens the link: a host
        // binding `Ctrl+O` must not have the viewer act on it too.
        KeyCode::Char('o') if input::is_bare_character(key) => {
            open_selected(view)
        }
        _ => {
            view.handle_key(key);
            PopupFlow::Continue
        }
    }
}

/// The highlighted link as a picked value, or `Continue` when the document has
/// no links to open.
fn open_selected(view: &MarkdownView) -> PopupFlow<Link> {
    match view.selected_link() {
        Some(link) => PopupFlow::Done(link.clone()),
        None => PopupFlow::Continue,
    }
}

/// Splits `inner` into the scrolling body and, when the source has links, a
/// footer showing the active link.
fn render_viewer_body(
    frame: &mut Frame,
    inner: Rect,
    skin: &Skin,
    view: &MarkdownView,
) {
    let footer = u16::from(!view.links().is_empty());
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(footer)])
        .split(inner);
    view.render(frame, rows[0], skin);
    if footer > 0 {
        let width = rows[1].width as usize;
        frame.render_widget(
            Paragraph::new(link_hint_line(view, skin, width)),
            rows[1],
        );
    }
}

/// A dim footer line for the active link (`› text · url`), clipped to `width`.
fn link_hint_line(
    view: &MarkdownView,
    skin: &Skin,
    width: usize,
) -> Line<'static> {
    match view.selected_link() {
        Some(link) => {
            let text = truncate(
                &format!(" \u{203a} {} \u{b7} {} ", link.text, link.url),
                width,
            );
            Line::from(Span::styled(text, style::secondary(&skin.palette)))
        }
        None => Line::from(""),
    }
}

#[cfg(test)]
mod tests {
    use crossterm::event::KeyModifiers;

    use super::*;

    fn press(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }

    fn ctrl(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::CONTROL)
    }

    /// `Ctrl+J`/`Ctrl+K` are commands, not motions: in raw mode crossterm
    /// reports Ctrl+J as `Char('j') + CONTROL`, so without the guard a chord
    /// would scroll the view. The key must be left for the host.
    #[test]
    fn handle_key_leaves_ctrl_chords_to_the_host() {
        let mut view = MarkdownView::new("a\n\nb\n\nc");
        view.total.set(3);
        view.viewport.set(1);
        for code in [
            KeyCode::Char('j'),
            KeyCode::Char('k'),
            KeyCode::Down,
            KeyCode::End,
            KeyCode::PageDown,
        ] {
            assert!(
                !view.handle_key(ctrl(code)),
                "Ctrl+{code:?} must not be consumed as scrolling"
            );
            assert_eq!(
                view.offset.get(),
                0,
                "Ctrl+{code:?} must not scroll the view"
            );
        }
        // The bare key still scrolls.
        assert!(view.handle_key(press(KeyCode::Down)));
        assert_eq!(view.offset.get(), 1);
    }

    /// The viewer's `o` opens the highlighted link. `Ctrl+O` is already caught
    /// by `MarkdownView::handle_key`'s own guard, so this arm's real job is
    /// `AltGr+O` (reported as `Ctrl+Alt`), which types an `o` and must not open
    /// a link. Dropping `is_bare_character` here would let it through.
    #[test]
    fn altgr_o_does_not_open_the_link() {
        let mut view = MarkdownView::new("[one](http://a)");
        let altgr = KeyEvent::new(
            KeyCode::Char('o'),
            KeyModifiers::CONTROL | KeyModifiers::ALT,
        );
        assert!(matches!(
            handle_viewer_key(&mut view, altgr),
            PopupFlow::Continue
        ));
        assert!(matches!(
            handle_viewer_key(&mut view, ctrl(KeyCode::Char('o'))),
            PopupFlow::Continue
        ));
    }

    #[test]
    fn bare_o_still_opens_the_link() {
        let mut view = MarkdownView::new("[one](http://a)");
        let opened = handle_viewer_key(&mut view, press(KeyCode::Char('o')));
        assert!(
            matches!(opened, PopupFlow::Done(link) if link.url == "http://a")
        );
        // Enter opens it too, and Esc leaves without a link.
        assert!(matches!(
            handle_viewer_key(&mut view, press(KeyCode::Enter)),
            PopupFlow::Done(_)
        ));
        assert!(matches!(
            handle_viewer_key(&mut view, press(KeyCode::Esc)),
            PopupFlow::Cancelled
        ));
    }

    #[test]
    fn scroll_is_clamped_to_the_content() {
        let mut view = MarkdownView::new("a\n\nb\n\nc");
        view.total.set(3);
        view.viewport.set(2);
        // Down once moves to offset 1 (max = total - viewport = 1).
        assert!(view.handle_key(press(KeyCode::Down)));
        assert_eq!(view.offset.get(), 1);
        // End clamps to the max, not past it.
        view.handle_key(press(KeyCode::End));
        assert_eq!(view.offset.get(), 1);
        view.handle_key(press(KeyCode::Home));
        assert_eq!(view.offset.get(), 0);
    }

    #[test]
    fn tab_cycles_the_links() {
        let mut view = MarkdownView::new("[one](http://a) and [two](http://b)");
        assert_eq!(view.links().len(), 2);
        assert_eq!(
            view.selected_link().map(|l| l.url.as_str()),
            Some("http://a")
        );
        view.handle_key(press(KeyCode::Tab));
        assert_eq!(
            view.selected_link().map(|l| l.url.as_str()),
            Some("http://b")
        );
        view.handle_key(press(KeyCode::Tab));
        assert_eq!(
            view.selected_link().map(|l| l.url.as_str()),
            Some("http://a")
        );
    }

    #[test]
    fn set_source_rebuilds_links_and_resets_scroll() {
        let mut view = MarkdownView::new("no links here");
        view.offset.set(5);
        view.set_source("see [x](http://x)");
        assert_eq!(view.offset.get(), 0);
        assert_eq!(view.links().len(), 1);
    }
}