Skip to main content

kimun_notes/components/preferences/
server_section.rs

1use ratatui::Frame;
2use ratatui::layout::{Constraint, Direction, Layout, Rect};
3use ratatui::style::Style;
4use ratatui::widgets::{Block, Borders, Paragraph};
5
6use crate::components::Component;
7use crate::components::event_state::EventState;
8use crate::components::events::{AppTx, InputEvent};
9use crate::components::single_line_input::{InputOutcome, SingleLineInput};
10use crate::settings::themes::Theme;
11
12/// Shown grayed out when the field is empty. Mirrors the server's built-in
13/// bind default (`127.0.0.1:7573` in `kimun-server`'s config).
14pub const DEFAULT_SERVER_URL_HINT: &str = "http://localhost:7573";
15
16/// Preferences section for the optional Kimün server connection.
17///
18/// One combined address field (URL including port) mapped to
19/// `GlobalConfig::kimun_server_url`. An empty field means the server
20/// features (semantic search, Q&A) stay off — matching the `None`
21/// semantics of the config value.
22pub struct ServerSection {
23    /// Committed value: `None` when the field is empty.
24    pub server_url: Option<String>,
25    input: SingleLineInput,
26}
27
28impl ServerSection {
29    pub fn new(server_url: Option<String>) -> Self {
30        let input = match &server_url {
31            Some(url) => SingleLineInput::with_value(url.clone()),
32            None => SingleLineInput::new(),
33        };
34        Self { server_url, input }
35    }
36
37    fn commit(&mut self) {
38        let trimmed = self.input.value().trim();
39        self.server_url = if trimmed.is_empty() {
40            None
41        } else {
42            Some(trimmed.to_string())
43        };
44    }
45}
46
47impl Component for ServerSection {
48    fn handle_input(&mut self, event: &InputEvent, _tx: &AppTx) -> EventState {
49        let InputEvent::Key(key) = event else {
50            return EventState::NotConsumed;
51        };
52        match self.input.handle_key(key) {
53            InputOutcome::Changed => {
54                self.commit();
55                EventState::Consumed
56            }
57            InputOutcome::Consumed => EventState::Consumed,
58            // Enter/Esc bubble up so the screen keeps its save/close flow.
59            InputOutcome::Submit | InputOutcome::Cancel | InputOutcome::NotConsumed => {
60                EventState::NotConsumed
61            }
62        }
63    }
64
65    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
66        let block = Block::default()
67            .title("Server")
68            .borders(Borders::ALL)
69            .border_style(theme.border_style(focused))
70            .style(theme.base_style());
71        let inner = block.inner(rect);
72        f.render_widget(block, rect);
73
74        let rows = Layout::default()
75            .direction(Direction::Vertical)
76            .constraints([
77                Constraint::Length(1), // label
78                Constraint::Length(1), // value input
79                Constraint::Length(1), // spacer
80                Constraint::Length(1), // hint
81                Constraint::Min(0),
82            ])
83            .split(inner);
84
85        f.render_widget(
86            Paragraph::new("Kimün Server Address").style(theme.base_style()),
87            rows[0],
88        );
89
90        let gray = Style::default()
91            .fg(theme.gray.to_ratatui())
92            .bg(theme.bg.to_ratatui());
93        if self.input.is_empty() {
94            // Placeholder with the default address; caret stays at the start.
95            f.render_widget(
96                Paragraph::new(format!("  {}", DEFAULT_SERVER_URL_HINT)).style(gray),
97                rows[1],
98            );
99            if focused {
100                f.set_cursor_position(ratatui::layout::Position {
101                    x: rows[1].x + 2,
102                    y: rows[1].y,
103                });
104            }
105        } else {
106            let value_style = if focused {
107                Style::default()
108                    .fg(theme.accent.to_ratatui())
109                    .bg(theme.bg.to_ratatui())
110            } else {
111                theme.base_style()
112            };
113            self.input.render(f, rows[1], value_style, 2, focused);
114        }
115
116        f.render_widget(
117            Paragraph::new(format!(
118                "  URL with port, e.g. {} — leave empty to disable",
119                DEFAULT_SERVER_URL_HINT
120            ))
121            .style(gray),
122            rows[3],
123        );
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
131
132    fn key(code: KeyCode) -> InputEvent {
133        InputEvent::Key(KeyEvent {
134            code,
135            modifiers: KeyModifiers::NONE,
136            kind: KeyEventKind::Press,
137            state: KeyEventState::NONE,
138        })
139    }
140
141    fn type_str(section: &mut ServerSection, s: &str) {
142        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
143        for c in s.chars() {
144            section.handle_input(&key(KeyCode::Char(c)), &tx);
145        }
146    }
147
148    #[test]
149    fn new_with_none_starts_empty() {
150        let section = ServerSection::new(None);
151        assert_eq!(section.server_url, None);
152    }
153
154    #[test]
155    fn new_with_value_preserves_it() {
156        let section = ServerSection::new(Some("http://myhost:9000".to_string()));
157        assert_eq!(section.server_url.as_deref(), Some("http://myhost:9000"));
158    }
159
160    #[test]
161    fn typing_commits_value_live() {
162        let mut section = ServerSection::new(None);
163        type_str(&mut section, "http://myhost:9000");
164        assert_eq!(section.server_url.as_deref(), Some("http://myhost:9000"));
165    }
166
167    #[test]
168    fn clearing_field_commits_none() {
169        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
170        let mut section = ServerSection::new(Some("x".to_string()));
171        section.handle_input(&key(KeyCode::Backspace), &tx);
172        assert_eq!(section.server_url, None);
173    }
174
175    #[test]
176    fn whitespace_only_commits_none() {
177        let mut section = ServerSection::new(None);
178        type_str(&mut section, "   ");
179        assert_eq!(section.server_url, None);
180    }
181
182    #[test]
183    fn value_is_trimmed() {
184        let mut section = ServerSection::new(None);
185        type_str(&mut section, " http://h:1 ");
186        assert_eq!(section.server_url.as_deref(), Some("http://h:1"));
187    }
188
189    #[test]
190    fn enter_and_esc_bubble_up() {
191        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
192        let mut section = ServerSection::new(None);
193        assert_eq!(
194            section.handle_input(&key(KeyCode::Enter), &tx),
195            EventState::NotConsumed
196        );
197        assert_eq!(
198            section.handle_input(&key(KeyCode::Esc), &tx),
199            EventState::NotConsumed
200        );
201    }
202
203    #[test]
204    fn renders_placeholder_hint_when_empty() {
205        use ratatui::Terminal;
206        use ratatui::backend::TestBackend;
207        let backend = TestBackend::new(60, 8);
208        let mut terminal = Terminal::new(backend).unwrap();
209        let mut section = ServerSection::new(None);
210        let theme = Theme::gruvbox_dark();
211        terminal
212            .draw(|f| section.render(f, f.area(), &theme, true))
213            .unwrap();
214        let buf = terminal.backend().buffer().clone();
215        let flat: String = buf.content.iter().map(|c| c.symbol()).collect();
216        assert!(
217            flat.contains(DEFAULT_SERVER_URL_HINT),
218            "expected default address hint when empty"
219        );
220    }
221
222    #[test]
223    fn renders_value_when_set() {
224        use ratatui::Terminal;
225        use ratatui::backend::TestBackend;
226        let backend = TestBackend::new(60, 8);
227        let mut terminal = Terminal::new(backend).unwrap();
228        let mut section = ServerSection::new(Some("http://myhost:9000".to_string()));
229        let theme = Theme::gruvbox_dark();
230        terminal
231            .draw(|f| section.render(f, f.area(), &theme, false))
232            .unwrap();
233        let buf = terminal.backend().buffer().clone();
234        let flat: String = buf.content.iter().map(|c| c.symbol()).collect();
235        assert!(flat.contains("http://myhost:9000"));
236    }
237}