Skip to main content

rtcom_tui/menu/
screen_options.rs

1//! Screen-options dialog — edits the TUI's modal-render style.
2//!
3//! Three radio options (Overlay / Dimmed overlay / Fullscreen) plus
4//! three action buttons (Apply live / Apply + Save / Cancel) — six
5//! cursor positions total. `scrollback_rows` is shown as a read-only
6//! display for v0.2; full editing arrives post-v1.0.
7
8use 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
21/// Index of the `Overlay` radio row.
22const RADIO_OVERLAY: usize = 0;
23/// Index of the `Dimmed overlay` radio row.
24const RADIO_DIMMED_OVERLAY: usize = 1;
25/// Index of the `Fullscreen` radio row.
26const RADIO_FULLSCREEN: usize = 2;
27/// Index of the `[Apply live]` action button.
28const ACTION_APPLY_LIVE: usize = 3;
29/// Index of the `[Apply + Save]` action button.
30const ACTION_APPLY_SAVE: usize = 4;
31/// Index of the `[Cancel]` action button.
32const ACTION_CANCEL: usize = 5;
33
34/// Total cursor slots (3 radios + 3 actions).
35const CURSOR_MAX: usize = 6;
36
37/// Scrollback rows display value — fixed at 10 000 for v0.2. Made
38/// editable post-v1.0 when the TUI backing buffer grows the knob.
39const SCROLLBACK_ROWS_DISPLAY: &str = "10000";
40
41/// Screen-options dialog.
42///
43/// Holds a snapshot of the initial [`ModalStyle`] and a mutable
44/// `pending` copy that tracks the user's radio selection. Emits
45/// [`DialogAction::ApplyModalStyleLive`] on `F2` / `Enter` on
46/// `[Apply live]`, [`DialogAction::ApplyModalStyleAndSave`] on `F10`
47/// / `Enter` on `[Apply + Save]`, and [`DialogOutcome::Close`] on
48/// `Esc` / `Enter` on `[Cancel]`. Pressing `Enter` on a radio row
49/// sets `pending` to that option without moving the cursor.
50pub 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    /// Construct a dialog seeded with the given initial [`ModalStyle`].
59    /// The cursor starts on the first radio option.
60    #[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    /// Current cursor position. Valid range is `0..6`: `0..=2` select
70    /// a radio option, `3..=5` select an action button.
71    #[must_use]
72    pub const fn cursor(&self) -> usize {
73        self.cursor
74    }
75
76    /// The currently pending [`ModalStyle`] — what will be emitted by
77    /// the next `Apply live` / `Apply + Save` action.
78    #[must_use]
79    pub const fn pending(&self) -> ModalStyle {
80        self.pending
81    }
82
83    /// Move the cursor up one row (wraps).
84    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    /// Move the cursor down one row (wraps).
93    const fn move_down(&mut self) {
94        self.cursor = (self.cursor + 1) % CURSOR_MAX;
95    }
96
97    /// Handle `Enter` over the current cursor position.
98    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    /// Build a radio row for the given cursor slot.
124    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    /// Build an action-button row for the given cursor slot.
144    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        // F2 / F10 act as global "apply now" shortcuts regardless of
210        // cursor position.
211        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); // wrap
267    }
268
269    #[test]
270    fn enter_on_dimmed_radio_sets_pending() {
271        let mut d = default_dialog();
272        d.handle_key(key(KeyCode::Down)); // cursor=1 dimmed
273        d.handle_key(key(KeyCode::Enter));
274        assert_eq!(d.pending(), ModalStyle::DimmedOverlay);
275        // cursor does not move
276        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)); // cursor=2 fullscreen
284        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        // Change pending to DimmedOverlay first.
293        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}