1use crossterm::event::{KeyCode, KeyEvent};
9use ratatui::{
10 buffer::Buffer,
11 layout::Rect,
12 style::{Modifier, Style},
13 text::{Line, Span},
14 widgets::{Block, Paragraph, Widget},
15};
16
17use rtcom_config::ModalStyle;
18
19use crate::modal::{centred_rect, Dialog, DialogAction, DialogOutcome};
20
21const RADIO_OVERLAY: usize = 0;
23const RADIO_DIMMED_OVERLAY: usize = 1;
25const RADIO_FULLSCREEN: usize = 2;
27const ACTION_APPLY_LIVE: usize = 3;
29const ACTION_APPLY_SAVE: usize = 4;
31const ACTION_CANCEL: usize = 5;
33
34const CURSOR_MAX: usize = 6;
36
37const SCROLLBACK_ROWS_DISPLAY: &str = "10000";
40
41pub struct ScreenOptionsDialog {
51 #[allow(dead_code, reason = "reserved for T17 revert-on-cancel path")]
52 initial: ModalStyle,
53 pending: ModalStyle,
54 cursor: usize,
55}
56
57impl ScreenOptionsDialog {
58 #[must_use]
61 pub const fn new(initial: ModalStyle) -> Self {
62 Self {
63 initial,
64 pending: initial,
65 cursor: RADIO_OVERLAY,
66 }
67 }
68
69 #[must_use]
72 pub const fn cursor(&self) -> usize {
73 self.cursor
74 }
75
76 #[must_use]
79 pub const fn pending(&self) -> ModalStyle {
80 self.pending
81 }
82
83 const fn move_up(&mut self) {
85 self.cursor = if self.cursor == 0 {
86 CURSOR_MAX - 1
87 } else {
88 self.cursor - 1
89 };
90 }
91
92 const fn move_down(&mut self) {
94 self.cursor = (self.cursor + 1) % CURSOR_MAX;
95 }
96
97 const fn activate(&mut self) -> DialogOutcome {
99 match self.cursor {
100 RADIO_OVERLAY => {
101 self.pending = ModalStyle::Overlay;
102 DialogOutcome::Consumed
103 }
104 RADIO_DIMMED_OVERLAY => {
105 self.pending = ModalStyle::DimmedOverlay;
106 DialogOutcome::Consumed
107 }
108 RADIO_FULLSCREEN => {
109 self.pending = ModalStyle::Fullscreen;
110 DialogOutcome::Consumed
111 }
112 ACTION_APPLY_LIVE => {
113 DialogOutcome::Action(DialogAction::ApplyModalStyleLive(self.pending))
114 }
115 ACTION_APPLY_SAVE => {
116 DialogOutcome::Action(DialogAction::ApplyModalStyleAndSave(self.pending))
117 }
118 ACTION_CANCEL => DialogOutcome::Close,
119 _ => DialogOutcome::Consumed,
120 }
121 }
122
123 fn radio_line(&self, slot: usize, label: &'static str, style_for_slot: ModalStyle) -> Line<'_> {
125 let selected = self.cursor == slot;
126 let marker = if self.pending == style_for_slot {
127 "(*)"
128 } else {
129 "( )"
130 };
131 let prefix = if selected { "> " } else { " " };
132 let text = format!(" {prefix}{marker} {label}");
133 if selected {
134 Line::from(Span::styled(
135 text,
136 Style::default().add_modifier(Modifier::REVERSED),
137 ))
138 } else {
139 Line::from(Span::raw(text))
140 }
141 }
142
143 fn action_line(&self, slot: usize, label: &'static str, shortcut: &'static str) -> Line<'_> {
145 let selected = self.cursor == slot;
146 let prefix = if selected { "> " } else { " " };
147 let text = format!(" {prefix}{label:<18} {shortcut}");
148 if selected {
149 Line::from(Span::styled(
150 text,
151 Style::default().add_modifier(Modifier::REVERSED),
152 ))
153 } else {
154 Line::from(Span::raw(text))
155 }
156 }
157}
158
159impl Dialog for ScreenOptionsDialog {
160 #[allow(
161 clippy::unnecessary_literal_bound,
162 reason = "trait signature must remain &str"
163 )]
164 fn title(&self) -> &str {
165 "Screen options"
166 }
167
168 fn preferred_size(&self, outer: Rect) -> Rect {
169 centred_rect(outer, 40, 16)
170 }
171
172 fn render(&self, area: Rect, buf: &mut Buffer) {
173 let block = Block::bordered().title("Screen options");
174 let inner = block.inner(area);
175 block.render(area, buf);
176
177 let sep_width = usize::from(inner.width);
178 let sep_line = Line::from(Span::styled(
179 "-".repeat(sep_width),
180 Style::default().add_modifier(Modifier::DIM),
181 ));
182
183 let lines = vec![
184 Line::from(Span::raw("")),
185 Line::from(Span::raw(" Modal style:")),
186 self.radio_line(RADIO_OVERLAY, "Overlay", ModalStyle::Overlay),
187 self.radio_line(
188 RADIO_DIMMED_OVERLAY,
189 "Dimmed overlay",
190 ModalStyle::DimmedOverlay,
191 ),
192 self.radio_line(RADIO_FULLSCREEN, "Fullscreen", ModalStyle::Fullscreen),
193 Line::from(Span::raw("")),
194 Line::from(Span::raw(format!(
195 " Scrollback rows: {SCROLLBACK_ROWS_DISPLAY}"
196 ))),
197 Line::from(Span::raw("")),
198 sep_line,
199 Line::from(Span::raw("")),
200 self.action_line(ACTION_APPLY_LIVE, "[Apply live]", "(F2)"),
201 self.action_line(ACTION_APPLY_SAVE, "[Apply + Save]", "(F10)"),
202 self.action_line(ACTION_CANCEL, "[Cancel]", "(Esc)"),
203 ];
204
205 Paragraph::new(lines).render(inner, buf);
206 }
207
208 fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome {
209 match key.code {
212 KeyCode::F(2) => {
213 return DialogOutcome::Action(DialogAction::ApplyModalStyleLive(self.pending));
214 }
215 KeyCode::F(10) => {
216 return DialogOutcome::Action(DialogAction::ApplyModalStyleAndSave(self.pending));
217 }
218 _ => {}
219 }
220
221 match key.code {
222 KeyCode::Up | KeyCode::Char('k') => {
223 self.move_up();
224 DialogOutcome::Consumed
225 }
226 KeyCode::Down | KeyCode::Char('j') => {
227 self.move_down();
228 DialogOutcome::Consumed
229 }
230 KeyCode::Esc => DialogOutcome::Close,
231 KeyCode::Enter => self.activate(),
232 _ => DialogOutcome::Consumed,
233 }
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::modal::DialogAction;
241 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
242
243 const fn key(code: KeyCode) -> KeyEvent {
244 KeyEvent::new(code, KeyModifiers::NONE)
245 }
246
247 const fn default_dialog() -> ScreenOptionsDialog {
248 ScreenOptionsDialog::new(ModalStyle::Overlay)
249 }
250
251 #[test]
252 fn starts_at_overlay_radio() {
253 let d = default_dialog();
254 assert_eq!(d.cursor(), RADIO_OVERLAY);
255 assert_eq!(d.pending(), ModalStyle::Overlay);
256 }
257
258 #[test]
259 fn down_moves_through_six_slots() {
260 let mut d = default_dialog();
261 for _ in 0..5 {
262 d.handle_key(key(KeyCode::Down));
263 }
264 assert_eq!(d.cursor(), 5);
265 d.handle_key(key(KeyCode::Down));
266 assert_eq!(d.cursor(), 0); }
268
269 #[test]
270 fn enter_on_dimmed_radio_sets_pending() {
271 let mut d = default_dialog();
272 d.handle_key(key(KeyCode::Down)); d.handle_key(key(KeyCode::Enter));
274 assert_eq!(d.pending(), ModalStyle::DimmedOverlay);
275 assert_eq!(d.cursor(), RADIO_DIMMED_OVERLAY);
277 }
278
279 #[test]
280 fn enter_on_fullscreen_radio_sets_pending() {
281 let mut d = default_dialog();
282 d.handle_key(key(KeyCode::Down));
283 d.handle_key(key(KeyCode::Down)); d.handle_key(key(KeyCode::Enter));
285 assert_eq!(d.pending(), ModalStyle::Fullscreen);
286 assert_eq!(d.cursor(), RADIO_FULLSCREEN);
287 }
288
289 #[test]
290 fn f2_emits_apply_modal_style_live() {
291 let mut d = default_dialog();
292 d.handle_key(key(KeyCode::Down));
294 d.handle_key(key(KeyCode::Enter));
295 let out = d.handle_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
296 assert!(matches!(
297 out,
298 DialogOutcome::Action(DialogAction::ApplyModalStyleLive(ModalStyle::DimmedOverlay))
299 ));
300 }
301
302 #[test]
303 fn f10_emits_apply_modal_style_and_save() {
304 let mut d = default_dialog();
305 let out = d.handle_key(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE));
306 assert!(matches!(
307 out,
308 DialogOutcome::Action(DialogAction::ApplyModalStyleAndSave(_))
309 ));
310 }
311
312 #[test]
313 fn esc_closes() {
314 let mut d = default_dialog();
315 let out = d.handle_key(key(KeyCode::Esc));
316 assert!(matches!(out, DialogOutcome::Close));
317 }
318
319 #[test]
320 fn enter_on_apply_live_button_emits_action() {
321 let mut d = default_dialog();
322 for _ in 0..ACTION_APPLY_LIVE {
323 d.handle_key(key(KeyCode::Down));
324 }
325 assert_eq!(d.cursor(), ACTION_APPLY_LIVE);
326 let out = d.handle_key(key(KeyCode::Enter));
327 assert!(matches!(
328 out,
329 DialogOutcome::Action(DialogAction::ApplyModalStyleLive(_))
330 ));
331 }
332
333 #[test]
334 fn enter_on_apply_save_button_emits_action() {
335 let mut d = default_dialog();
336 for _ in 0..ACTION_APPLY_SAVE {
337 d.handle_key(key(KeyCode::Down));
338 }
339 assert_eq!(d.cursor(), ACTION_APPLY_SAVE);
340 let out = d.handle_key(key(KeyCode::Enter));
341 assert!(matches!(
342 out,
343 DialogOutcome::Action(DialogAction::ApplyModalStyleAndSave(_))
344 ));
345 }
346
347 #[test]
348 fn enter_on_cancel_closes() {
349 let mut d = default_dialog();
350 for _ in 0..ACTION_CANCEL {
351 d.handle_key(key(KeyCode::Down));
352 }
353 assert_eq!(d.cursor(), ACTION_CANCEL);
354 let out = d.handle_key(key(KeyCode::Enter));
355 assert!(matches!(out, DialogOutcome::Close));
356 }
357
358 #[test]
359 fn j_k_nav() {
360 let mut d = default_dialog();
361 d.handle_key(key(KeyCode::Char('j')));
362 assert_eq!(d.cursor(), 1);
363 d.handle_key(key(KeyCode::Char('k')));
364 assert_eq!(d.cursor(), 0);
365 }
366
367 #[test]
368 fn preferred_size_40x16() {
369 let d = default_dialog();
370 let outer = Rect {
371 x: 0,
372 y: 0,
373 width: 80,
374 height: 24,
375 };
376 let pref = d.preferred_size(outer);
377 assert_eq!(pref.width, 40);
378 assert_eq!(pref.height, 16);
379 }
380
381 #[test]
382 fn cursor_max_is_six() {
383 assert_eq!(CURSOR_MAX, 6);
384 }
385}