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
//! **The chrome every modal wears**, in one place.
//!
//! Before this, newt-tui drew four different dialogs four different ways and
//! only one of them had a border at all:
//!
//! | surface | border | title | hint |
//! |---|---|---|---|
//! | `config_panel::render_panel` | plain, unstyled | plain | dim |
//! | `transcript_pager::run_pager` | none | bold + grey | dim |
//! | the spill pager | none | bold + grey | dim |
//! | `interaction_view` | none | none | in the body |
//!
//! The two pagers were near-verbatim copies of each other, and the panel's
//! border carried no `border_style` at all — so a dialog that had TAKEN THE
//! KEYBOARD drew in whatever colour the terminal last set.
//!
//! [`frame`] is now the only thing that draws a modal's edge. It reads
//! [`crate::theme`], so `NEWT_THEME='modal-border=cyan'` moves every dialog at
//! once, and a fifth surface gets the house style by asking rather than by
//! copying whichever neighbour it happened to read first.
//!
//! # What belongs here and what does not
//!
//! The chrome does: the border, the title, the subtitle register, the dim key
//! legend. It does NOT own the body or the key loop — those differ honestly
//! between a scrolling pager, a dial panel and a permission prompt, and a
//! shared "scroller" that fit all three would fit none of them well. The win
//! is one edge, not one widget.
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
use ratatui::Frame;
#[cfg(test)]
use crate::theme::color;
use crate::theme::Role;
/// What a modal says about itself, above and below its body.
#[derive(Default)]
pub(crate) struct Chrome<'a> {
/// The dialog's name, in the border.
pub(crate) title: &'a str,
/// The register beside the title — `· 3 of 12`, `· 215 lines`. Absent for
/// a dialog with nothing to count.
pub(crate) subtitle: Option<String>,
/// The dim key legend along the bottom. A modal that takes the keyboard
/// and does not say how to leave is the complaint `SpillEligibility`
/// documents about unactionable refusals, wearing a different hat.
pub(crate) hint: Option<&'a str>,
}
/// Draw a modal's edge and return the area its body may use.
///
/// The caller renders whatever it likes into the returned [`Rect`]; this owns
/// only the parts that should look the same everywhere.
pub(crate) fn frame(f: &mut Frame, area: Rect, chrome: &Chrome<'_>) -> Rect {
// A modal owns these cells as well as the keyboard. Empty body cells
// must occlude the editor or transcript painted underneath the window.
f.render_widget(Clear, area);
// The title carries the subtitle so both ride the border rather than
// spending a body row — the pagers used to spend one, which is why a
// 3-row terminal showed them one line of content.
let mut spans = vec![Span::styled(
format!(" {} ", chrome.title),
crate::theme::style(Role::ModalTitle),
)];
if let Some(subtitle) = &chrome.subtitle {
spans.push(Span::styled(
format!("{subtitle} "),
crate::theme::style(Role::Muted),
));
}
let block = Block::default()
.borders(Borders::ALL)
// Rounded, because a modal is the one surface allowed to look like a
// separate thing sitting on top of the transcript.
.border_type(BorderType::Rounded)
.border_style(crate::theme::style(Role::ModalBorder))
.title(Line::from(spans));
let inner = block.inner(area);
f.render_widget(block, area);
let Some(hint) = chrome.hint else {
return inner;
};
// The legend takes the last inner row, so the body never has to know the
// hint exists.
let [body, legend] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
f.render_widget(
Paragraph::new(Span::styled(hint, crate::theme::style(Role::Dim))),
legend,
);
body
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn render(chrome: &Chrome<'_>, w: u16, h: u16) -> (Vec<String>, ratatui::buffer::Buffer, Rect) {
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
let mut body = Rect::default();
term.draw(|f| {
body = frame(f, f.area(), chrome);
f.render_widget(Paragraph::new("BODY"), body);
})
.unwrap();
let buf = term.backend().buffer().clone();
let rows = (0..h)
.map(|y| {
(0..w)
.map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
.collect::<String>()
})
.collect();
(rows, buf, body)
}
/// Every modal gets an edge, and the edge is THEMED — the defect this
/// module exists for is a dialog that owns the keyboard drawing in
/// whatever colour the terminal last set.
#[test]
fn the_edge_is_drawn_and_themed() {
let chrome = Chrome {
title: "settings",
subtitle: Some("· 3 of 12".to_string()),
hint: Some("q quit"),
};
let (rows, buf, _) = render(&chrome, 40, 6);
assert!(rows[0].starts_with('╭'), "rounded edge: {:?}", rows[0]);
assert!(rows[5].starts_with('╰'), "and it closes: {:?}", rows[5]);
let corner = buf.cell((0u16, 0u16)).unwrap();
assert_eq!(corner.fg, color(Role::ModalBorder));
assert_ne!(
corner.fg,
ratatui::style::Color::Reset,
"an unstyled border is the defect, not the fix"
);
}
#[test]
fn a_modal_occludes_the_underlying_composer() {
let mut term = Terminal::new(TestBackend::new(40, 8)).unwrap();
term.draw(|f| {
f.render_widget(
Paragraph::new("normal prompt\nsecret draft\nvi INSERT"),
f.area(),
);
let body = frame(
f,
f.area(),
&Chrome {
title: "decision required",
..Chrome::default()
},
);
f.render_widget(Paragraph::new("Allow this connection?"), body);
})
.unwrap();
let text: String = term
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("Allow this connection?"));
assert!(
!text.contains("secret draft"),
"the dialog must paint an opaque body: {text}"
);
assert!(
!text.contains("vi INSERT"),
"the normal composer must be hidden: {text}"
);
}
/// Title and subtitle ride the BORDER, not a body row. The pagers used to
/// spend a row on each, which is why a short terminal showed almost no
/// content.
#[test]
fn the_title_rides_the_border_and_the_body_keeps_its_rows() {
let chrome = Chrome {
title: "spill 3",
subtitle: Some("· 215 lines".to_string()),
hint: Some("q quit · ↑↓ scroll"),
};
let (rows, _, body) = render(&chrome, 44, 8);
assert!(rows[0].contains("spill 3"), "{:?}", rows[0]);
assert!(rows[0].contains("215 lines"), "{:?}", rows[0]);
assert!(rows[1].contains("BODY"), "the body starts immediately");
// 8 rows: 2 border + 1 legend = 5 for content.
assert_eq!(body.height, 5);
assert!(
rows[6].contains("q quit"),
"legend above the edge: {:?}",
rows[6]
);
}
/// A modal with nothing to count and no keys to advertise still gets the
/// same edge — the shape is the shared thing.
#[test]
fn a_bare_modal_is_the_same_shape_and_spends_no_row_on_an_absent_hint() {
let chrome = Chrome {
title: "permission required",
..Chrome::default()
};
let (rows, _, body) = render(&chrome, 40, 6);
assert!(rows[0].starts_with('╭'));
assert!(rows[0].contains("permission required"));
assert_eq!(body.height, 4, "no hint means no reserved legend row");
}
/// A terminal too small for chrome must not panic or underflow. Three rows
/// is two borders and one legend, leaving nothing — the honest answer is a
/// zero-height body, not a wrapped-around `Rect`.
#[test]
fn a_terminal_with_no_room_yields_an_empty_body_rather_than_panicking() {
let chrome = Chrome {
title: "t",
subtitle: None,
hint: Some("q"),
};
let (_, _, body) = render(&chrome, 12, 3);
assert_eq!(body.height, 0);
let (_, _, tiny) = render(&chrome, 12, 2);
assert_eq!(tiny.height, 0);
let (_, _, degenerate) = render(&chrome, 12, 1);
assert_eq!(degenerate.height, 0);
}
}