1use super::keys::Key;
5use super::{Frame, Style};
6use crate::profile::{self, Profile, DEFAULT_URL, FIELDS, OUTPUTS};
7
8pub const NAME: usize = 0;
9pub const OUTPUT: usize = 5;
10pub const DEFAULT: usize = 9;
11pub const SAVE: usize = 10;
12pub const TEST: usize = 11;
13pub const CANCEL: usize = 12;
14const ITEMS: usize = 13;
15const LABEL_WIDTH: usize = 18;
16
17const LABELS: [&str; 10] = [
18 "Name",
19 "url",
20 "token-file",
21 "token",
22 "ca-file",
23 "output",
24 "connect-timeout",
25 "timeout",
26 "max-attempts",
27 "Default profile",
28];
29
30#[derive(Debug, Clone, Default)]
31struct Text {
32 value: String,
33 cursor: usize,
35}
36
37impl Text {
38 fn from(value: &str) -> Text {
39 Text {
40 value: value.to_owned(),
41 cursor: value.chars().count(),
42 }
43 }
44
45 fn byte_at(&self, chars: usize) -> usize {
46 self.value
47 .char_indices()
48 .nth(chars)
49 .map_or(self.value.len(), |(i, _)| i)
50 }
51
52 fn insert(&mut self, c: char) {
53 let at = self.byte_at(self.cursor);
54 self.value.insert(at, c);
55 self.cursor += 1;
56 }
57
58 fn backspace(&mut self) -> bool {
59 if self.cursor == 0 {
60 return false;
61 }
62 let at = self.byte_at(self.cursor - 1);
63 self.value.remove(at);
64 self.cursor -= 1;
65 true
66 }
67
68 fn delete(&mut self) -> bool {
69 if self.cursor >= self.value.chars().count() {
70 return false;
71 }
72 let at = self.byte_at(self.cursor);
73 self.value.remove(at);
74 true
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum FormEvent {
81 None,
82 Save,
83 Test,
84 Cancel,
85 Quit,
86}
87
88#[derive(Debug, Clone)]
89pub struct Form {
90 pub original: Option<String>,
92 texts: Vec<Text>,
93 output: usize,
95 pub is_default: bool,
96 focus: usize,
97 pub status: Option<(bool, String)>,
99 dirty: bool,
100 confirm_discard: bool,
101 taken_names: Vec<String>,
102}
103
104impl Form {
105 pub fn new(
108 existing: Option<(&str, &Profile)>,
109 is_default: bool,
110 taken_names: Vec<String>,
111 ) -> Form {
112 let mut texts = vec![Text::default(); 9];
113 let mut output = 0;
114 if let Some((name, profile)) = existing {
115 texts[NAME] = Text::from(name);
116 for (n, field) in FIELDS.iter().enumerate() {
117 if let Some(value) = profile.get(field) {
118 if *field == "output" {
119 output = OUTPUTS
120 .iter()
121 .position(|o| *o == value)
122 .map_or(0, |p| p + 1);
123 } else {
124 texts[n + 1] = Text::from(value);
125 }
126 }
127 }
128 }
129 Form {
130 original: existing.map(|(n, _)| n.to_owned()),
131 texts,
132 output,
133 is_default,
134 focus: NAME,
135 status: None,
136 dirty: false,
137 confirm_discard: false,
138 taken_names,
139 }
140 }
141
142 pub fn name(&self) -> &str {
143 self.texts[NAME].value.trim()
144 }
145
146 pub fn dirty(&self) -> bool {
147 self.dirty
148 }
149
150 fn value(&self, item: usize) -> String {
151 if item == OUTPUT {
152 return match self.output {
153 0 => String::new(),
154 n => OUTPUTS[n - 1].to_owned(),
155 };
156 }
157 self.texts[item].value.trim().to_owned()
158 }
159
160 pub fn profile(&self) -> Profile {
162 let mut profile = Profile::default();
163 for (n, field) in FIELDS.iter().enumerate() {
164 let value = self.value(n + 1);
165 if !value.is_empty() {
166 let _ = profile.set(field, &value);
167 }
168 }
169 profile
170 }
171
172 pub fn errors(&self) -> Vec<Option<String>> {
174 let mut errors = vec![None; ITEMS];
175 let name = self.name();
176 errors[NAME] = if name.is_empty() {
177 Some("the name is required".to_owned())
178 } else if let Err(e) = profile::validate_name(name) {
179 Some(e)
180 } else if self.original.as_deref() != Some(name)
181 && self.taken_names.iter().any(|n| n == name)
182 {
183 Some(format!("a profile `{name}` already exists"))
184 } else {
185 None
186 };
187 for (n, field) in FIELDS.iter().enumerate() {
188 let value = self.value(n + 1);
189 if !value.is_empty() {
190 errors[n + 1] = profile::validate_field(field, &value).err();
191 }
192 }
193 errors
194 }
195
196 pub fn is_valid(&self) -> bool {
197 self.errors().iter().all(Option::is_none)
198 }
199
200 fn touched(&mut self) {
201 self.dirty = true;
202 self.status = None;
203 }
204
205 fn move_focus(&mut self, delta: isize) {
206 self.focus = (self.focus as isize + delta).rem_euclid(ITEMS as isize) as usize;
207 }
208
209 pub fn handle(&mut self, key: Key) -> FormEvent {
210 if self.confirm_discard {
211 self.confirm_discard = false;
212 self.status = None;
213 if key == Key::Esc {
214 return FormEvent::Cancel;
215 }
216 }
217 match key {
218 Key::Ctrl('c') => return FormEvent::Quit,
219 Key::Ctrl('s') => return FormEvent::Save,
220 Key::Esc => {
221 if self.dirty {
222 self.confirm_discard = true;
223 self.status = Some((
224 true,
225 "There are unsaved changes. Esc again discards; any other key goes back."
226 .to_owned(),
227 ));
228 return FormEvent::None;
229 }
230 return FormEvent::Cancel;
231 }
232 Key::Up | Key::BackTab => self.move_focus(-1),
233 Key::Down | Key::Tab => self.move_focus(1),
234 _ => {}
235 }
236 match self.focus {
237 SAVE | TEST | CANCEL => match key {
238 Key::Enter | Key::Char(' ') => {
239 return match self.focus {
240 SAVE => FormEvent::Save,
241 TEST => FormEvent::Test,
242 _ => self.cancel_or_confirm(),
243 };
244 }
245 Key::Left => self.move_focus(-1),
246 Key::Right => self.focus = (self.focus + 1).min(CANCEL),
247 _ => {}
248 },
249 DEFAULT => match key {
250 Key::Char(' ') | Key::Left | Key::Right => {
251 self.is_default = !self.is_default;
252 self.touched();
253 }
254 Key::Enter => self.move_focus(1),
255 _ => {}
256 },
257 OUTPUT => match key {
258 Key::Left => {
259 self.output = (self.output + OUTPUTS.len()) % (OUTPUTS.len() + 1);
260 self.touched();
261 }
262 Key::Right | Key::Char(' ') => {
263 self.output = (self.output + 1) % (OUTPUTS.len() + 1);
264 self.touched();
265 }
266 Key::Enter => self.move_focus(1),
267 _ => {}
268 },
269 item => {
270 let text = &mut self.texts[item];
271 let changed = match key {
272 Key::Char(c) => {
273 text.insert(c);
274 true
275 }
276 Key::Backspace => text.backspace(),
277 Key::Delete => text.delete(),
278 Key::Left => {
279 text.cursor = text.cursor.saturating_sub(1);
280 false
281 }
282 Key::Right => {
283 text.cursor = (text.cursor + 1).min(text.value.chars().count());
284 false
285 }
286 Key::Home => {
287 text.cursor = 0;
288 false
289 }
290 Key::End => {
291 text.cursor = text.value.chars().count();
292 false
293 }
294 Key::Ctrl('u') => {
295 let had = !text.value.is_empty();
296 *text = Text::default();
297 had
298 }
299 Key::Enter => {
300 self.move_focus(1);
301 false
302 }
303 _ => false,
304 };
305 if changed {
306 self.touched();
307 }
308 }
309 }
310 FormEvent::None
311 }
312
313 fn cancel_or_confirm(&mut self) -> FormEvent {
314 if self.dirty {
315 self.confirm_discard = true;
316 self.status = Some((
317 true,
318 "There are unsaved changes. Esc again discards; any other key goes back."
319 .to_owned(),
320 ));
321 FormEvent::None
322 } else {
323 FormEvent::Cancel
324 }
325 }
326
327 fn placeholder(&self, item: usize) -> Option<String> {
329 match item {
330 1 => Some(DEFAULT_URL.to_owned()),
331 2 | 3 => Some("none".to_owned()),
332 4 => Some("system roots".to_owned()),
333 5 => Some("json".to_owned()),
334 6 => Some("5".to_owned()),
335 7 => Some("30".to_owned()),
336 8 => Some("3".to_owned()),
337 _ => None,
338 }
339 }
340
341 pub fn render(&self, style: Style, width: usize, height: usize) -> Frame {
342 let errors = self.errors();
343 let title = match &self.original {
344 Some(name) => format!(" Profile {name}"),
345 None => " New profile".to_owned(),
346 };
347 let mut lines = vec![style.paint("1", &title), String::new()];
348 let mut focus_line = 0;
349 let mut cursor = None;
350 for item in 0..=DEFAULT {
351 let focused = self.focus == item;
352 let marker = if focused {
353 style.paint("36", " ▸ ")
354 } else {
355 " ".to_owned()
356 };
357 let label = format!("{:<LABEL_WIDTH$}", LABELS[item]);
358 let label = if focused {
359 style.paint("1;36", &label)
360 } else {
361 label
362 };
363 let shown = match item {
364 OUTPUT => {
365 let current = match self.output {
366 0 => style.paint("2", &format!("({})", OUTPUTS[0])),
367 n => OUTPUTS[n - 1].to_owned(),
368 };
369 format!("‹ {current} ›")
370 }
371 DEFAULT => format!("[{}]", if self.is_default { "x" } else { " " }),
372 _ => {
373 let text = &self.texts[item];
374 if text.value.is_empty() {
375 match self.placeholder(item) {
376 Some(p) => style.paint("2", &format!("({p})")),
377 None => String::new(),
378 }
379 } else {
380 text.value.clone()
381 }
382 }
383 };
384 if focused {
385 focus_line = lines.len();
386 if item != OUTPUT && item != DEFAULT {
387 cursor = Some((lines.len(), 3 + LABEL_WIDTH + self.texts[item].cursor));
388 }
389 }
390 lines.push(format!("{marker}{label}{shown}"));
391 if let Some(message) = &errors[item] {
392 lines.push(format!(
393 "{}{}",
394 " ".repeat(3 + LABEL_WIDTH),
395 style.paint("31", message)
396 ));
397 }
398 }
399 lines.push(String::new());
400 let button = |item: usize, text: &str| {
401 if self.focus == item {
402 if style.color {
403 style.paint("7", &format!("[ {text} ]"))
404 } else {
405 format!("[>{text}<]")
406 }
407 } else {
408 format!("[ {text} ]")
409 }
410 };
411 if matches!(self.focus, SAVE | TEST | CANCEL) {
412 focus_line = lines.len();
413 }
414 lines.push(format!(
415 " {} {} {}",
416 button(SAVE, "Save"),
417 button(TEST, "Test connection"),
418 button(CANCEL, "Cancel")
419 ));
420 if let Some((is_error, message)) = &self.status {
421 lines.push(String::new());
422 lines.push(format!(
423 " {}",
424 style.paint(if *is_error { "31" } else { "32" }, message)
425 ));
426 }
427 let footer = " ↑↓/Tab field ←→ option Space toggle Ctrl+S save Esc cancel";
428 window(lines, focus_line, cursor, footer, style, width, height)
429 }
430}
431
432pub fn window(
434 lines: Vec<String>,
435 focus_line: usize,
436 cursor: Option<(usize, usize)>,
437 footer: &str,
438 style: Style,
439 _width: usize,
440 height: usize,
441) -> Frame {
442 let body = height.saturating_sub(2).max(1);
443 let start = if lines.len() <= body {
444 0
445 } else {
446 focus_line.saturating_sub(body - 1).min(lines.len() - body)
447 };
448 let mut shown: Vec<String> = lines.into_iter().skip(start).take(body).collect();
449 while shown.len() < body {
450 shown.push(String::new());
451 }
452 shown.push(String::new());
453 shown.push(style.paint("2", footer));
454 Frame {
455 cursor: cursor.and_then(|(line, col)| {
456 line.checked_sub(start)
457 .filter(|l| *l < body)
458 .map(|l| (l, col))
459 }),
460 lines: shown,
461 }
462}
463
464#[cfg(test)]
465mod tests;