kimun_notes/components/preferences/
server_section.rs1use 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
12pub const DEFAULT_SERVER_URL_HINT: &str = "http://localhost:7573";
15
16pub struct ServerSection {
23 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 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), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), 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 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}