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
//! A selectable, vertically scrollable list with a scrollbar and a bottom-right
//! `position/total` badge.
use std::cell::Cell;
use ratatui::{
Frame,
layout::Rect,
text::{Line, Span},
widgets::Paragraph,
};
use super::{chrome, nav, scroll, style};
use crate::theme::Skin;
/// The content and cursor state a list renders: the built `rows`, the
/// `selected` index to highlight, and a `Cell` persisting the scroll `offset`
/// across frames.
pub struct ListView<'a> {
/// The pre-built row content (callers apply any per-row styling).
pub rows: Vec<Line<'static>>,
/// The index of the row to highlight.
pub selected: usize,
/// The scroll offset, kept across frames so the list scrolls smoothly.
pub offset: &'a Cell<usize>,
}
/// Renders `view` in `area`, highlighting the selected row and scrolling to
/// keep it visible. A `position/total` badge is drawn in the bottom-right
/// corner.
///
/// Callers build each row's content (and any per-row styling such as dimming);
/// this widget overlays the selection highlight (a subtle `selection` tint), the
/// scrollbar and the position badge.
pub fn render(frame: &mut Frame, area: Rect, skin: &Skin, view: ListView) {
let total = view.rows.len();
let selected = view.selected;
render_core(frame, area, skin, view);
render_position(frame, area, skin, selected, total);
}
/// Like [`render`], but wrapped in a rounded box (see [`chrome::BoxDecor`]) when
/// `force` is set; the box's bottom-right badge then shows `position/total`.
/// Without `force` it behaves exactly like [`render`].
pub fn render_boxed(
frame: &mut Frame,
area: Rect,
skin: &Skin,
view: ListView,
decor: &chrome::BoxDecor,
force: bool,
) {
if force {
// The box carries the badge in its border, so the inner list skips its
// own overlay (`render_core`) to avoid drawing it twice.
let badge = position_text(view.selected, view.rows.len());
let inner = chrome::framed_decor(frame, area, skin, decor, &badge);
render_core(frame, inner, skin, view);
} else {
render(frame, area, skin, view);
}
}
/// Draws the list rows (with the selection highlight) and the scrollbar.
fn render_core(frame: &mut Frame, area: Rect, skin: &Skin, view: ListView) {
let viewport = area.height as usize;
let total = view.rows.len();
let selected = view.selected;
let scroll = nav::ScrollView {
total,
offset: view.offset.get(),
viewport,
};
let visible_offset = nav::keep_visible(scroll, selected);
view.offset.set(visible_offset);
let highlight = style::bg(skin.palette.selection);
let visible: Vec<Line> = view
.rows
.into_iter()
.enumerate()
.skip(visible_offset)
.take(viewport)
.map(|(index, line)| {
if index == selected {
line.style(highlight)
} else {
line
}
})
.collect();
frame.render_widget(Paragraph::new(visible), area);
scroll::render_scrollbar(
frame,
area,
skin,
nav::ScrollView {
total,
offset: visible_offset,
viewport,
},
);
}
/// The `position/total` label (1-based), e.g. `"3/12"`.
fn position_text(selected: usize, total: usize) -> String {
format!("{}/{}", selected + 1, total)
}
/// Overlays a `position/total` chip in the bottom-right of `area`, left of the
/// scrollbar column. Nothing is drawn for an empty list or a too-narrow area.
fn render_position(
frame: &mut Frame,
area: Rect,
skin: &Skin,
selected: usize,
total: usize,
) {
if total == 0 || area.height == 0 || area.width == 0 {
return;
}
let label = format!(" {} ", position_text(selected, total));
let width = label.len() as u16;
// Leave the rightmost column for the scrollbar when the list overflows.
let reserve = u16::from(total > area.height as usize);
if width + reserve > area.width {
return;
}
let chip = Rect {
x: area.x + area.width - width - reserve,
y: area.y + area.height - 1,
width,
height: 1,
};
let style = style::fg(skin.palette.foreground)
.bg(style::to_ratatui(skin.palette.panel));
frame.render_widget(
Paragraph::new(Line::from(Span::styled(label, style))),
chip,
);
}