Skip to main content

netget/tui/modal/
text_editor.rs

1//! Multi-line editor for instruction text, script code and JSON fields,
2//! backed by tui-textarea. Parse-on-accept for JSON targets, so a malformed
3//! value is caught before it reaches a form.
4//!
5//! Tab leaves the text for the `[ Accept ]` / `[ Cancel ]` buttons; typing
6//! returns to it. Nothing here requires a chord.
7
8use tui_textarea::TextArea;
9
10use crate::tui::hit::ModalAction;
11
12pub struct TextEditorModel {
13    pub label: String,
14    pub help: String,
15    pub textarea: TextArea<'static>,
16    /// Validate as JSON on accept (routing/params fields).
17    pub json: bool,
18    pub error: Option<String>,
19    /// `Some(i)` when focus sits on the i-th button rather than in the text.
20    pub focused_button: Option<usize>,
21}
22
23impl std::fmt::Debug for TextEditorModel {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        f.debug_struct("TextEditorModel")
26            .field("label", &self.label)
27            .field("json", &self.json)
28            .field("error", &self.error)
29            .field("focused_button", &self.focused_button)
30            .finish()
31    }
32}
33
34impl Clone for TextEditorModel {
35    fn clone(&self) -> Self {
36        Self {
37            label: self.label.clone(),
38            help: self.help.clone(),
39            textarea: self.textarea.clone(),
40            json: self.json,
41            error: self.error.clone(),
42            focused_button: self.focused_button,
43        }
44    }
45}
46
47impl TextEditorModel {
48    pub fn new(label: &str, help: &str, initial: &str, json: bool) -> Self {
49        let lines: Vec<String> = if initial.is_empty() {
50            vec![String::new()]
51        } else {
52            initial.split('\n').map(|l| l.to_string()).collect()
53        };
54        Self {
55            label: label.to_string(),
56            help: help.to_string(),
57            textarea: TextArea::new(lines),
58            json,
59            error: None,
60            focused_button: None,
61        }
62    }
63
64    /// The buttons, in Tab order.
65    pub fn buttons(&self) -> Vec<ModalAction> {
66        vec![ModalAction::EditorAccept, ModalAction::EditorCancel]
67    }
68
69    pub fn focused_action(&self) -> Option<ModalAction> {
70        self.focused_button
71            .and_then(|index| self.buttons().get(index).copied())
72    }
73
74    /// text → Accept → Cancel → text.
75    pub fn cycle_focus(&mut self, backward: bool) {
76        let buttons = self.buttons().len();
77        // Position 0 is the text itself; 1..=buttons are the buttons.
78        let current = self.focused_button.map(|i| i + 1).unwrap_or(0);
79        let total = buttons + 1;
80        let next = if backward {
81            (current + total - 1) % total
82        } else {
83            (current + 1) % total
84        };
85        self.focused_button = if next == 0 { None } else { Some(next - 1) };
86    }
87
88    pub fn text(&self) -> String {
89        self.textarea.lines().join("\n")
90    }
91
92    /// Validate and return the text, or an error to display in place.
93    pub fn accept(&mut self) -> Option<String> {
94        let text = self.text();
95        if self.json && !text.trim().is_empty() {
96            if let Err(e) = serde_json::from_str::<serde_json::Value>(&text) {
97                self.error = Some(format!("invalid JSON: {e}"));
98                return None;
99            }
100        }
101        self.error = None;
102        Some(text)
103    }
104}