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