ratcn 0.0.1

Themeable terminal UI components and interaction runtime for Ratatui
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
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
//! The index arithmetic behind moving through an ordered list of things.
//!
//! Every control with items in a row or column — [`List`](crate::List),
//! [`Tabs`](crate::Tabs), and any Select or Menu added later — needs the same
//! handful of answers. Where does Down go from here? What if the next three
//! items are disabled? Where does Page Down land near the end? Getting those
//! subtly wrong in each control separately is how a UI ends up feeling
//! inconsistent, so the math lives here once.
//!
//! These are pure functions over plain numbers. They hold no state, do no
//! hit-testing, and know nothing about rendering: a caller that wants to map a
//! click to an item checks the [`Rect`](ratatui::layout::Rect) itself and
//! subtracts the area's origin before calling [`index_at_row`]. Which item is
//! focused, what is selected, and where the view is scrolled all stay in app
//! state, stored however the app prefers.

use crate::runtime::{KeyCode, KeyEvent, Step};

/// The next *enabled* index after moving one step from `from` toward
/// `direction`, skipping indices for which `disabled` holds. Clamps at the last
/// enabled index in that direction — no wrap — and returns `from` unchanged when
/// there is no other enabled index to move to. `from` itself need not be
/// enabled, so a parked selection on a disabled item still steps to a neighbor.
///
/// This is the one-step move for controls whose items can be individually
/// disabled, such as tabs and lists.
#[must_use]
pub fn step_enabled(
    len: usize,
    from: usize,
    direction: Step,
    disabled: impl Fn(usize) -> bool,
) -> usize {
    let next = match direction {
        Step::Forward => (from.saturating_add(1)..len).find(|&i| !disabled(i)),
        Step::Backward => (0..from.min(len)).rev().find(|&i| !disabled(i)),
    };
    next.unwrap_or(from)
}

/// The first enabled index in `0..len` (the target of a Home key), or `None`
/// when every item is disabled or the collection is empty.
#[must_use]
pub fn first_enabled(len: usize, disabled: impl Fn(usize) -> bool) -> Option<usize> {
    (0..len).find(|&i| !disabled(i))
}

/// The last enabled index in `0..len` (the target of an End key), or `None`
/// when every item is disabled or the collection is empty.
#[must_use]
pub fn last_enabled(len: usize, disabled: impl Fn(usize) -> bool) -> Option<usize> {
    (0..len).rev().find(|&i| !disabled(i))
}

/// Move `page_size` physical rows from `from`, landing on an enabled item. At
/// either end, this clamps to the furthest enabled item in that direction.
/// An out-of-range `from` is clamped before movement.
#[must_use]
pub fn page_enabled(
    len: usize,
    from: usize,
    direction: Step,
    page_size: usize,
    disabled: impl Fn(usize) -> bool,
) -> usize {
    if len == 0 {
        return from;
    }
    let from = from.min(len - 1);
    let target = match direction {
        Step::Forward => from.saturating_add(page_size).min(len - 1),
        Step::Backward => from.saturating_sub(page_size),
    };
    match direction {
        Step::Forward => (target..len)
            .find(|&index| !disabled(index))
            .or_else(|| last_enabled(len, disabled))
            .unwrap_or(from),
        Step::Backward => (0..=target)
            .rev()
            .find(|&index| !disabled(index))
            .or_else(|| first_enabled(len, disabled))
            .unwrap_or(from),
    }
}

/// Where a navigation key lands, resolved by [`nav_key_target`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavOutcome {
    /// The cursor moves to this index.
    Move(usize),
    /// The key was a navigation key but the cursor is already where it would
    /// land (Up at the top, Home on the first item). Controls usually consume
    /// the key without emitting anything.
    Stay,
}

/// How far a recognised navigation key moves the cursor.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NavMove {
    /// One item.
    Step(Step),
    /// All the way to the first or last enabled item.
    Edge(Step),
    /// A viewport's worth of items.
    Page(Step),
    /// Half a viewport, the granularity Ctrl+D and Ctrl+U carry from `vi`.
    HalfPage(Step),
}

/// The movement a key asks for, or `None` if it is not a navigation key.
///
/// This is the whole vertical key map in one place, and the reason it takes a
/// [`KeyEvent`] rather than a [`KeyCode`]: a third of it is modifier chords.
///
/// Alt never navigates — it belongs to the app. Neither does Shift: `J` and `K`
/// are not `j` and `k`, and leaving Shift free keeps range-selection available
/// later without changing what any key means today.
fn nav_move(key: KeyEvent) -> Option<NavMove> {
    if key.modifiers.alt || key.modifiers.shift {
        return None;
    }
    if key.modifiers.ctrl {
        // The readline and `vi` chords. They are here rather than left to the
        // app because every control with a cursor wants the same four.
        return match key.code {
            KeyCode::Char('n') => Some(NavMove::Step(Step::Forward)),
            KeyCode::Char('p') => Some(NavMove::Step(Step::Backward)),
            KeyCode::Char('d') => Some(NavMove::HalfPage(Step::Forward)),
            KeyCode::Char('u') => Some(NavMove::HalfPage(Step::Backward)),
            _ => None,
        };
    }
    match key.code {
        KeyCode::Up | KeyCode::Char('k') => Some(NavMove::Step(Step::Backward)),
        KeyCode::Down | KeyCode::Char('j') => Some(NavMove::Step(Step::Forward)),
        KeyCode::Home => Some(NavMove::Edge(Step::Backward)),
        KeyCode::End => Some(NavMove::Edge(Step::Forward)),
        KeyCode::PageUp => Some(NavMove::Page(Step::Backward)),
        KeyCode::PageDown => Some(NavMove::Page(Step::Forward)),
        _ => None,
    }
}

/// Does this key step a cursor by exactly one item — an arrow, `j`/`k`, or
/// Ctrl+N/Ctrl+P?
///
/// A collapsed control asks this to decide whether a key should open it: what
/// would move a cursor should first reveal the cursor. [`Select`](crate::Select)
/// uses it for its closed state.
#[must_use]
pub fn is_step_key(key: KeyEvent) -> bool {
    matches!(nav_move(key), Some(NavMove::Step(_)))
}

/// Does a held modifier rule this key out of a control's own handling?
///
/// Modified keys belong to the app: a control claims only unmodified presses,
/// so `Ctrl+S` reaches the save handler even while a list has focus.
///
/// The navigation chords are the one exception, and they are why this must be
/// checked *after* [`nav_key_target`] rather than before — `Ctrl+N` is a
/// control's own key, and asking this first would reject it.
#[must_use]
pub fn has_reserved_modifier(key: KeyEvent) -> bool {
    key.modifiers.any()
}

/// Resolve one navigation key against a cursor over `len` items, skipping
/// disabled indices.
///
/// This is the key map the vertically-navigated list controls share —
/// [`List`](crate::List) and [`Select`](crate::Select)'s option list:
///
/// | Keys | Moves |
/// |---|---|
/// | Up / Down, `k` / `j`, Ctrl+P / Ctrl+N | one item |
/// | Home / End | to the first / last enabled item |
/// | `PageUp` / `PageDown` | one viewport (`page_size` items) |
/// | Ctrl+U / Ctrl+D | half a viewport |
///
/// Three sets of names for the same movements: arrows for everyone, `hjkl` for
/// `vi`, and the Ctrl chords readline put in every shell and text field. None
/// of them collide, so a control can offer all three and let the user reach for
/// whichever they already know.
///
/// Commit keys (Enter, Space) are not navigation and stay with the calling
/// control, which decides what a commit means. A horizontally-navigated
/// control does not use this: [`Tabs`](crate::Tabs) maps Left/Right, `h`/`l`,
/// and the Ctrl chords itself, and reaches for the index helpers here directly.
///
/// - `None` — not a navigation key, or no enabled item exists to land on;
///   ignore the key.
/// - `Some(NavOutcome::Move(index))` — move the cursor there.
/// - `Some(NavOutcome::Stay)` — a navigation key with nowhere new to go;
///   consume it.
///
/// A `cursor` of `None` means the cursor is nowhere yet; any navigation key
/// then targets the first enabled item.
#[must_use]
pub fn nav_key_target(
    key: KeyEvent,
    len: usize,
    cursor: Option<usize>,
    page_size: usize,
    disabled: impl Fn(usize) -> bool,
) -> Option<NavOutcome> {
    let movement = nav_move(key)?;
    let Some(cursor) = cursor else {
        return first_enabled(len, &disabled).map(NavOutcome::Move);
    };
    // Half a page still moves at least one item, so Ctrl+D in a one-row
    // viewport behaves like Down rather than doing nothing.
    let half = (page_size / 2).max(1);
    let target = match movement {
        NavMove::Step(direction) => step_enabled(len, cursor, direction, &disabled),
        NavMove::Edge(Step::Backward) => first_enabled(len, &disabled)?,
        NavMove::Edge(Step::Forward) => last_enabled(len, &disabled)?,
        NavMove::Page(direction) => page_enabled(len, cursor, direction, page_size, &disabled),
        NavMove::HalfPage(direction) => page_enabled(len, cursor, direction, half, &disabled),
    };
    Some(if target == cursor {
        NavOutcome::Stay
    } else {
        NavOutcome::Move(target)
    })
}

/// The scroll offset that keeps `cursor` visible in a `viewport_height`-row
/// viewport, starting from a `requested` offset.
///
/// The requested offset is first clamped by [`clamp_scroll_offset`]. A cursor above
/// the viewport pulls the offset up to itself; a cursor below pulls the offset
/// just far enough that the cursor becomes the last visible row; a cursor
/// already visible (or `None`) leaves the clamped offset alone. This is the
/// single keep-cursor-visible policy — every scrolling control computes its
/// painted offset through it, so two controls can never disagree about when a
/// list scrolls.
#[must_use]
pub fn cursor_visible_offset(
    len: usize,
    viewport_height: usize,
    requested: usize,
    cursor: Option<usize>,
) -> usize {
    let offset = clamp_scroll_offset(len, viewport_height, requested);
    let Some(cursor) = cursor else {
        return offset;
    };
    if viewport_height == 0 {
        offset
    } else if cursor < offset {
        cursor
    } else if cursor >= offset.saturating_add(viewport_height) {
        cursor.saturating_add(1).saturating_sub(viewport_height)
    } else {
        offset
    }
}

/// Clamp a scroll `offset` to `0..=len.saturating_sub(viewport_height)`, so
/// the last page of `len` rows never scrolls past the bottom of a
/// `viewport_height`-row viewport. Shared by [`wheel_offset`] and by a
/// component seeding its scroll offset from app state before render.
///
/// The offset counts *items*, not cells. The similarly shaped
/// [`runtime::clamp_offset`](crate::runtime::clamp_offset) is unrelated: it
/// keeps a dragged box inside an area, in terminal cells.
#[must_use]
pub fn clamp_scroll_offset(len: usize, viewport_height: usize, offset: usize) -> usize {
    offset.min(len.saturating_sub(viewport_height))
}

/// The scroll offset after moving one wheel notch by `step` rows from
/// `current`, clamped by [`clamp_scroll_offset`].
#[must_use]
pub fn wheel_offset(
    len: usize,
    viewport_height: usize,
    current: usize,
    direction: ScrollStep,
    step: usize,
) -> usize {
    match direction {
        ScrollStep::Up => clamp_scroll_offset(len, viewport_height, current.saturating_sub(step)),
        ScrollStep::Down => clamp_scroll_offset(len, viewport_height, current.saturating_add(step)),
    }
}

/// The vertical direction of a wheel-driven scroll-offset change (as opposed
/// to [`crate::runtime::ScrollDirection`], which also carries the horizontal
/// notches a row-oriented control ignores).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollStep {
    /// Scroll toward the start of the list, decreasing the offset.
    Up,
    /// Scroll toward the end of the list, increasing the offset.
    Down,
}

/// The item index for a `local_row` (a row already known to be inside the
/// control's viewport, e.g. `row - area.y`) given `scroll_offset` (the index
/// of the top visible row). `None` past the last item. The caller owns the
/// `Rect.contains(...)` gate and the row-within-area subtraction — this is
/// pure index math, not a hit-test.
#[must_use]
pub fn index_at_row(len: usize, scroll_offset: usize, local_row: usize) -> Option<usize> {
    let index = scroll_offset.checked_add(local_row)?;
    (index < len).then_some(index)
}

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

    #[test]
    fn step_enabled_skips_disabled_indices() {
        // Items 1 and 2 disabled; forward from 0 lands on 3, not 1.
        let disabled = |i: usize| i == 1 || i == 2;
        assert_eq!(step_enabled(4, 0, Step::Forward, disabled), 3);
        assert_eq!(step_enabled(4, 3, Step::Backward, disabled), 0);
    }

    #[test]
    fn step_enabled_stays_put_at_the_edge() {
        let none_disabled = |_: usize| false;
        assert_eq!(step_enabled(3, 2, Step::Forward, none_disabled), 2);
        assert_eq!(step_enabled(3, 0, Step::Backward, none_disabled), 0);
        // Only enabled item: no move in either direction.
        let all_but_one = |i: usize| i != 1;
        assert_eq!(step_enabled(3, 1, Step::Forward, all_but_one), 1);
        assert_eq!(step_enabled(3, 1, Step::Backward, all_but_one), 1);
    }

    #[test]
    fn step_enabled_steps_off_a_parked_disabled_index() {
        // `from` is disabled; a step still finds the nearest enabled neighbor.
        let disabled = |i: usize| i == 1;
        assert_eq!(step_enabled(3, 1, Step::Forward, disabled), 2);
        assert_eq!(step_enabled(3, 1, Step::Backward, disabled), 0);
    }

    #[test]
    fn first_and_last_enabled_find_the_edges_or_none() {
        let disabled = |i: usize| i == 0 || i == 3;
        assert_eq!(first_enabled(4, disabled), Some(1));
        assert_eq!(last_enabled(4, disabled), Some(2));
        assert_eq!(first_enabled(0, |_| false), None);
        assert_eq!(first_enabled(3, |_| true), None);
        assert_eq!(last_enabled(3, |_| true), None);
    }

    #[test]
    fn page_enabled_moves_by_rows_and_clamps_to_enabled_edges() {
        let disabled = |i: usize| i == 0 || i == 4;
        assert_eq!(page_enabled(5, 1, Step::Forward, 3, disabled), 3);
        assert_eq!(page_enabled(5, 3, Step::Backward, 3, disabled), 1);
    }

    #[test]
    fn page_enabled_clamps_out_of_range_origins_before_scanning() {
        let in_range = |index: usize| {
            assert!(index < 3);
            false
        };

        assert_eq!(page_enabled(3, usize::MAX, Step::Forward, 1, in_range), 2);
        assert_eq!(page_enabled(3, usize::MAX, Step::Backward, 1, in_range), 1);
    }

    #[test]
    fn clamp_scroll_offset_caps_at_the_viewport_aware_maximum() {
        // 10 items, 4-row viewport: max offset is 6.
        assert_eq!(clamp_scroll_offset(10, 4, 0), 0);
        assert_eq!(clamp_scroll_offset(10, 4, 6), 6);
        assert_eq!(clamp_scroll_offset(10, 4, 99), 6);
        // No viewport height: clamps to the last item.
        assert_eq!(clamp_scroll_offset(10, 0, 99), 10);
    }

    #[test]
    fn wheel_offset_clamps_at_zero_and_the_viewport_aware_maximum() {
        // 10 items, 4-row viewport: max offset is 6.
        assert_eq!(wheel_offset(10, 4, 0, ScrollStep::Up, 3), 0);
        assert_eq!(wheel_offset(10, 4, 5, ScrollStep::Down, 3), 6);
        assert_eq!(wheel_offset(10, 4, 2, ScrollStep::Down, 3), 5);
        assert_eq!(wheel_offset(10, 4, 2, ScrollStep::Up, 3), 0);
    }

    #[test]
    fn wheel_offset_with_no_viewport_height_clamps_to_the_last_item() {
        assert_eq!(wheel_offset(10, 0, 0, ScrollStep::Down, 100), 10);
    }

    #[test]
    fn wheel_offset_saturates_before_clamping() {
        assert_eq!(wheel_offset(10, 4, usize::MAX, ScrollStep::Down, 1), 6);
    }

    #[test]
    fn wheel_offset_clamps_an_out_of_range_current_offset() {
        assert_eq!(wheel_offset(10, 4, 99, ScrollStep::Up, 1), 6);
    }

    #[test]
    fn index_at_row_respects_scroll_offset_and_item_count() {
        // First visible row, no scroll.
        assert_eq!(index_at_row(10, 0, 0), Some(0));
        // Third visible local row, with a scroll offset of 4.
        assert_eq!(index_at_row(10, 4, 2), Some(6));
        // Past the last item.
        assert_eq!(index_at_row(10, 8, 2), None);
    }

    #[test]
    fn index_at_row_returns_none_on_overflow() {
        assert_eq!(index_at_row(usize::MAX, usize::MAX, 1), None);
    }

    #[test]
    fn nav_key_target_maps_each_navigation_key() {
        let none = |_: usize| false;
        assert_eq!(
            nav_key_target(KeyCode::Down.into(), 5, Some(1), 2, none),
            Some(NavOutcome::Move(2))
        );
        assert_eq!(
            nav_key_target(KeyCode::Up.into(), 5, Some(1), 2, none),
            Some(NavOutcome::Move(0))
        );
        assert_eq!(
            nav_key_target(KeyCode::Home.into(), 5, Some(3), 2, none),
            Some(NavOutcome::Move(0))
        );
        assert_eq!(
            nav_key_target(KeyCode::End.into(), 5, Some(3), 2, none),
            Some(NavOutcome::Move(4))
        );
        assert_eq!(
            nav_key_target(KeyCode::PageDown.into(), 5, Some(0), 2, none),
            Some(NavOutcome::Move(2))
        );
        assert_eq!(
            nav_key_target(KeyCode::PageUp.into(), 5, Some(4), 2, none),
            Some(NavOutcome::Move(2))
        );
    }

    #[test]
    fn nav_key_target_with_nowhere_new_to_go_stays() {
        let none = |_: usize| false;
        assert_eq!(
            nav_key_target(KeyCode::Up.into(), 3, Some(0), 1, none),
            Some(NavOutcome::Stay)
        );
        assert_eq!(
            nav_key_target(KeyCode::Home.into(), 3, Some(0), 1, none),
            Some(NavOutcome::Stay)
        );
    }

    #[test]
    fn nav_key_target_without_a_cursor_lands_on_the_first_enabled_item() {
        let disabled = |i: usize| i == 0;
        assert_eq!(
            nav_key_target(KeyCode::Down.into(), 3, None, 1, disabled),
            Some(NavOutcome::Move(1))
        );
        assert_eq!(
            nav_key_target(KeyCode::End.into(), 3, None, 1, disabled),
            Some(NavOutcome::Move(1))
        );
    }

    #[test]
    fn nav_key_target_ignores_commit_keys_and_all_disabled_lists() {
        assert_eq!(
            nav_key_target(KeyCode::Enter.into(), 3, Some(0), 1, |_| false),
            None
        );
        assert_eq!(
            nav_key_target(KeyCode::Down.into(), 3, None, 1, |_| true),
            None
        );
        assert_eq!(
            nav_key_target(KeyCode::Home.into(), 3, Some(1), 1, |_| true),
            None
        );
    }

    #[test]
    fn cursor_visible_offset_pulls_the_viewport_to_the_cursor() {
        // 10 items, 4-row viewport.
        assert_eq!(cursor_visible_offset(10, 4, 0, None), 0);
        // Cursor above the viewport pulls the offset up to it.
        assert_eq!(cursor_visible_offset(10, 4, 5, Some(2)), 2);
        // Cursor below pulls it just far enough to become the last row.
        assert_eq!(cursor_visible_offset(10, 4, 0, Some(6)), 3);
        // Cursor already visible leaves the clamped offset alone.
        assert_eq!(cursor_visible_offset(10, 4, 2, Some(3)), 2);
        // Requested offset past the end clamps first.
        assert_eq!(cursor_visible_offset(10, 4, 99, None), 6);
    }

    #[test]
    fn cursor_visible_offset_with_no_viewport_keeps_the_clamped_offset() {
        assert_eq!(cursor_visible_offset(10, 0, 3, Some(7)), 3);
    }
}