1use std::sync::Arc;
5
6use kimun_core::NoteVault;
7use kimun_core::nfs::VaultPath;
8use kimun_core::note::{
9 FrontmatterFormat, PropertyEntry, PropertyInput, PropertyKind, PropertyValue,
10 is_list_property_key, property_keys_match,
11};
12use ratatui::Frame;
13use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
14use ratatui::layout::{Position, Rect};
15use ratatui::style::{Modifier, Style};
16use ratatui::widgets::Paragraph;
17use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
18
19use crate::components::button_row::ButtonRow;
20use crate::components::event_state::EventState;
21use crate::components::events::{AppEvent, AppTx, OverlayData};
22use crate::components::key_picker::{KeyPicker, PickerOutcome};
23use crate::components::panel::{ModalSpec, modal_chrome};
24use crate::components::single_line_input::{InputOutcome, SingleLineInput};
25use crate::settings::themes::Theme;
26
27#[derive(Debug, Clone)]
32pub enum PropertyWriteError {
33 Invalid(String),
35 Malformed(String),
37 Other(String),
39}
40
41impl PropertyWriteError {
42 fn from_vault(e: kimun_core::error::VaultError) -> Self {
43 use kimun_core::error::{FSError, VaultError};
44 match &e {
45 VaultError::InvalidProperty { message, .. } => Self::Invalid(message.clone()),
46 VaultError::FSError(FSError::InvalidFrontmatter { .. }) => {
47 Self::Malformed(e.to_string())
48 }
49 _ => Self::Other(e.to_string()),
50 }
51 }
52
53 pub fn message(&self) -> &str {
54 match self {
55 Self::Invalid(m) | Self::Malformed(m) | Self::Other(m) => m,
56 }
57 }
58}
59
60pub struct PropertiesDialog {
65 path: VaultPath,
66 vault: Arc<NoteVault>,
67 entries: Vec<PropertyEntry>,
68 load_error: Option<String>,
69 loading: bool,
70 busy: bool,
74 selected: usize,
75 scroll: usize,
76 mode: Mode,
77 buttons: ButtonRow,
79 confirm: ButtonRow,
81 focus: ListFocus,
82 known_keys: Vec<String>,
83 row_rects: Vec<(Rect, usize)>,
85 pending_rename: Option<(String, String)>,
88 pub(crate) error: Option<String>,
89}
90
91enum Mode {
92 List,
93 ConfirmDelete(String),
94 Form(Box<PropertyForm>),
95}
96
97#[derive(Clone, Copy, PartialEq)]
98enum ListFocus {
99 Rows,
100 Buttons,
101}
102
103const BTN_ADD: usize = 0;
104const BTN_EDIT: usize = 1;
105const BTN_DELETE: usize = 2;
106const BTN_CLOSE: usize = 3;
107
108const KEY_COL: usize = 14;
109const TYPE_COL: usize = 9;
110
111impl PropertiesDialog {
116 pub fn new(path: VaultPath, vault: Arc<NoteVault>, tx: &AppTx) -> Self {
117 let mut d = Self::new_unloaded(path, vault);
118 d.reload(tx);
119 super::spawn_property_keys(d.vault.clone(), tx);
120 d
121 }
122
123 pub(crate) fn new_unloaded(path: VaultPath, vault: Arc<NoteVault>) -> Self {
124 let mut d = Self {
125 path,
126 vault,
127 entries: Vec::new(),
128 load_error: None,
129 loading: true,
130 busy: false,
131 selected: 0,
132 scroll: 0,
133 mode: Mode::List,
134 buttons: ButtonRow::new(&["+ Add", "Edit", "Delete", "Close"]),
135 confirm: ButtonRow::new(&["Yes", "No"]),
136 focus: ListFocus::Rows,
137 known_keys: Vec::new(),
138 row_rects: Vec::new(),
139 pending_rename: None,
140 error: None,
141 };
142 d.sync_buttons();
143 d
144 }
145
146 fn reload(&mut self, tx: &AppTx) {
147 self.loading = true;
148 let vault = self.vault.clone();
149 let path = self.path.clone();
150 let tx = tx.clone();
151 tokio::spawn(async move {
152 let result = vault.get_properties(&path).await.map_err(|e| e.to_string());
153 tx.send(AppEvent::OverlayData(OverlayData::PropertiesLoaded {
154 path,
155 result,
156 }))
157 .ok();
158 });
159 }
160
161 pub(crate) fn handle_loaded(&mut self, result: &Result<Vec<PropertyEntry>, String>) {
162 self.loading = false;
163 match result {
164 Ok(entries) => {
165 self.entries = entries.clone();
166 self.load_error = None;
167 self.selected = self.selected.min(self.entries.len().saturating_sub(1));
168 }
169 Err(e) => {
170 self.entries.clear();
171 self.load_error = Some(e.clone());
172 }
173 }
174 self.sync_buttons();
175 }
176
177 fn read_only(&self) -> bool {
178 self.load_error.is_some()
179 }
180
181 fn sync_buttons(&mut self) {
182 let writable = !self.read_only() && !self.busy && !self.loading;
183 let has_row = writable && !self.entries.is_empty();
184 self.buttons.set_enabled(BTN_ADD, writable);
185 self.buttons.set_enabled(BTN_EDIT, has_row);
186 self.buttons.set_enabled(BTN_DELETE, has_row);
187 let idle = !self.busy;
188 self.buttons.set_enabled(BTN_CLOSE, idle);
189 if let Mode::Form(form) = &mut self.mode {
190 form.buttons.set_enabled(0, idle);
191 form.buttons.set_enabled(1, idle);
192 form.store_btn.set_enabled(0, idle);
193 }
194 }
195
196 pub(crate) fn set_keys(&mut self, keys: Vec<String>) {
197 self.known_keys = keys;
198 if let Mode::Form(form) = &mut self.mode {
199 form.key.set_keys(self.known_keys.clone());
200 }
201 }
202
203 pub(crate) fn handle_written(
208 &mut self,
209 result: &Result<String, PropertyWriteError>,
210 tx: &AppTx,
211 ) {
212 self.busy = false;
213 let renamed = self.pending_rename.take();
214 match result {
215 Ok(flash) => {
216 self.mode = Mode::List;
217 tx.send(AppEvent::FlashMessage(flash.clone())).ok();
218 self.reload(tx);
219 super::spawn_property_keys(self.vault.clone(), tx);
220 }
221 Err(PropertyWriteError::Malformed(msg)) => {
222 self.mode = Mode::List;
223 self.load_error = Some(msg.clone());
224 }
225 Err(e) => {
226 if let Some((_, new)) = renamed {
227 self.reload(tx);
231 super::spawn_property_keys(self.vault.clone(), tx);
232 if let Mode::Form(form) = &mut self.mode {
233 form.orig_key = Some(new);
234 }
235 }
236 self.show_write_error(e);
237 }
238 }
239 self.sync_buttons();
240 }
241
242 pub(crate) fn on_loaded(
244 &mut self,
245 path: &VaultPath,
246 result: &Result<Vec<PropertyEntry>, String>,
247 ) {
248 if *path == self.path {
249 self.handle_loaded(result);
250 }
251 }
252
253 pub(crate) fn on_written(
255 &mut self,
256 path: &VaultPath,
257 result: &Result<String, PropertyWriteError>,
258 tx: &AppTx,
259 ) {
260 if *path == self.path {
261 self.handle_written(result, tx);
262 }
263 }
264
265 pub(crate) fn row_cells(&self, i: usize) -> (String, String, String) {
267 let (key, value) = &self.entries[i];
268 match value {
269 Some(v) => (key.clone(), v.kind().as_str().to_string(), v.to_string()),
270 None => (key.clone(), "—".to_string(), String::new()),
271 }
272 }
273
274 fn can_write(&self) -> bool {
275 !self.busy && !self.read_only() && !self.loading
276 }
277
278 fn start_delete(&mut self) {
279 if self.busy || self.read_only() {
280 return;
281 }
282 if let Some((key, _)) = self.entries.get(self.selected) {
283 self.mode = Mode::ConfirmDelete(key.clone());
284 self.confirm.set_focused(Some(1)); }
286 }
287
288 fn confirm_delete(&mut self, tx: &AppTx) {
289 let Mode::ConfirmDelete(key) = std::mem::replace(&mut self.mode, Mode::List) else {
290 return;
291 };
292 self.busy = true;
293 self.sync_buttons();
294 let vault = self.vault.clone();
295 let path = self.path.clone();
296 let tx = tx.clone();
297 tokio::spawn(async move {
298 let result = vault
299 .remove_property(&path, &key)
300 .await
301 .map(|_| "property removed".to_string())
302 .map_err(PropertyWriteError::from_vault);
303 if result.is_ok() {
304 tx.send(AppEvent::NoteReloadFromDisk(path.clone())).ok();
305 }
306 tx.send(AppEvent::OverlayData(OverlayData::PropertyWritten {
307 path,
308 result,
309 }))
310 .ok();
311 });
312 }
313
314 fn open_add(&mut self) {
315 self.mode = Mode::Form(Box::new(PropertyForm::add(&self.known_keys)));
316 }
317
318 fn open_edit(&mut self) {
319 if let Some((key, value)) = self.entries.get(self.selected) {
320 self.mode = Mode::Form(Box::new(PropertyForm::edit(
321 key,
322 value.as_ref(),
323 &self.known_keys,
324 )));
325 }
326 }
327
328 fn press(&mut self, button: usize, tx: &AppTx) {
329 if self.busy || (button != BTN_CLOSE && !self.can_write()) {
330 return;
331 }
332 match button {
333 BTN_ADD => self.open_add(),
334 BTN_EDIT => self.open_edit(),
335 BTN_DELETE => self.start_delete(),
336 BTN_CLOSE => {
337 tx.send(AppEvent::CloseOverlay).ok();
338 }
339 _ => {}
340 }
341 }
342
343 fn select_next(&mut self) {
344 self.selected = (self.selected + 1).min(self.entries.len().saturating_sub(1));
345 }
346
347 pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
348 match &self.mode {
349 Mode::Form(_) => return self.handle_form_key(key, tx),
350 Mode::ConfirmDelete(_) => {
351 match key.code {
352 KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_delete(tx),
353 KeyCode::Enter if self.confirm.focused() == Some(0) => self.confirm_delete(tx),
354 KeyCode::Left | KeyCode::Right | KeyCode::Tab => {
355 let next = if self.confirm.focused() == Some(0) {
356 1
357 } else {
358 0
359 };
360 self.confirm.set_focused(Some(next));
361 }
362 _ => self.mode = Mode::List, }
364 return EventState::Consumed;
365 }
366 Mode::List => {}
367 }
368 self.error = None;
369 let can_write = self.can_write();
370 match key.code {
371 KeyCode::Esc if !self.busy => {
372 tx.send(AppEvent::CloseOverlay).ok();
373 }
374 KeyCode::Tab | KeyCode::BackTab => {
375 self.focus = match self.focus {
376 ListFocus::Rows => {
377 self.buttons.set_focused(None);
378 self.buttons.focus_next();
379 ListFocus::Buttons
380 }
381 ListFocus::Buttons => {
382 self.buttons.set_focused(None);
383 ListFocus::Rows
384 }
385 };
386 }
387 KeyCode::Left if self.focus == ListFocus::Buttons => {
388 self.buttons.focus_prev();
389 }
390 KeyCode::Right if self.focus == ListFocus::Buttons => {
391 self.buttons.focus_next();
392 }
393 KeyCode::Enter if self.focus == ListFocus::Buttons => {
394 if let Some(b) = self.buttons.focused() {
395 self.press(b, tx);
396 }
397 }
398 KeyCode::Up | KeyCode::Char('k') => self.selected = self.selected.saturating_sub(1),
399 KeyCode::Down | KeyCode::Char('j') => self.select_next(),
400 KeyCode::Enter | KeyCode::Char('e') if can_write => self.open_edit(),
401 KeyCode::Char('a') if can_write => self.open_add(),
402 KeyCode::Char('d') | KeyCode::Delete if can_write => self.start_delete(),
403 _ => {}
404 }
405 EventState::Consumed
406 }
407
408 pub fn handle_mouse(&mut self, ev: &MouseEvent, tx: &AppTx) -> EventState {
409 if let Mode::Form(_) = self.mode {
410 return self.handle_form_mouse(ev, tx);
411 }
412 match ev.kind {
413 MouseEventKind::ScrollDown => self.select_next(),
414 MouseEventKind::ScrollUp => self.selected = self.selected.saturating_sub(1),
415 MouseEventKind::Down(MouseButton::Left) => {
416 if let Mode::ConfirmDelete(_) = self.mode {
417 match self.confirm.hit(ev) {
418 Some(0) => self.confirm_delete(tx),
419 Some(_) => self.mode = Mode::List,
420 None => {}
421 }
422 return EventState::Consumed;
423 }
424 if let Some(b) = self.buttons.hit(ev) {
425 self.press(b, tx);
426 return EventState::Consumed;
427 }
428 let pos = Position {
429 x: ev.column,
430 y: ev.row,
431 };
432 if let Some(&(_, i)) = self.row_rects.iter().find(|(r, _)| r.contains(pos)) {
433 let again = self.selected == i;
436 self.selected = i;
437 self.focus = ListFocus::Rows;
438 if again && self.can_write() {
439 self.open_edit();
440 }
441 }
442 }
443 _ => {}
444 }
445 EventState::Consumed
446 }
447
448 #[cfg(test)]
449 pub(crate) fn row_origin(&self, i: usize) -> Option<(u16, u16)> {
450 self.row_rects
451 .iter()
452 .find(|(_, idx)| *idx == i)
453 .map(|(r, _)| (r.x, r.y))
454 }
455}
456
457fn fit(s: &str, width: usize) -> String {
463 if s.width() <= width {
464 return format!("{s}{}", " ".repeat(width - s.width()));
465 }
466 let mut out = String::new();
467 let mut w = 0;
468 for c in s.chars() {
469 let cw = c.width().unwrap_or(0);
470 if w + cw + 1 > width {
471 break;
472 }
473 out.push(c);
474 w += cw;
475 }
476 out.push('…');
477 w += 1;
478 out.push_str(&" ".repeat(width.saturating_sub(w)));
479 out
480}
481
482impl PropertiesDialog {
483 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme) {
484 let popup = super::fixed_centered_rect(60, 16, rect);
485 let title = match &self.mode {
486 Mode::Form(form) if form.orig_key.is_some() => " Edit property ".to_string(),
487 Mode::Form(_) => " Add property ".to_string(),
488 _ => format!(" Properties: {} ", self.path.get_clean_name()),
489 };
490 let inner = modal_chrome(
491 f,
492 popup,
493 theme,
494 ModalSpec {
495 title: Some(&title),
496 border: None,
497 bg: crate::components::panel::ModalBg::Panel,
498 },
499 );
500 self.row_rects.clear();
501 if inner.height < 4 {
502 return;
503 }
504 if let Mode::Form(_) = self.mode {
505 self.render_form(f, inner, theme);
506 return;
507 }
508 let bg = theme.bg_panel.to_ratatui();
509 let normal = Style::default().fg(theme.fg.to_ratatui()).bg(bg);
510 let gray = Style::default().fg(theme.gray.to_ratatui()).bg(bg);
511 let row_at = |i: u16| Rect {
512 x: inner.x,
513 y: inner.y + i,
514 width: inner.width,
515 height: 1,
516 };
517 let buttons_row = inner.height - 2;
518 let footer_row = inner.height - 1;
519
520 f.render_widget(
521 Paragraph::new(" Key Type Value").style(gray),
522 row_at(0),
523 );
524
525 let has_error_row = self.error.is_some();
526 let body_rows = (buttons_row as usize - 1).saturating_sub(has_error_row as usize);
527
528 if self.loading {
529 f.render_widget(Paragraph::new(" Loading…").style(gray), row_at(1));
530 } else if let Some(msg) = &self.load_error {
531 super::render_error_row(f, row_at(1), msg, theme);
532 f.render_widget(
533 Paragraph::new(" Read-only: fix the frontmatter in the editor.").style(gray),
534 row_at(2),
535 );
536 } else if self.entries.is_empty() {
537 f.render_widget(
538 Paragraph::new(" No properties. Press a or click + Add.").style(gray),
539 row_at(1),
540 );
541 } else {
542 if self.selected < self.scroll {
543 self.scroll = self.selected;
544 } else if body_rows > 0 && self.selected >= self.scroll + body_rows {
545 self.scroll = self.selected + 1 - body_rows;
546 }
547 let value_w = (inner.width as usize).saturating_sub(2 + KEY_COL + 1 + TYPE_COL + 1);
548 let visible: Vec<usize> = (self.scroll..self.entries.len()).take(body_rows).collect();
549 for (n, i) in visible.into_iter().enumerate() {
550 let (key, kind, value) = self.row_cells(i);
551 let marker = if i == self.selected { "▶ " } else { " " };
552 let text = format!(
553 "{marker}{} {} {}",
554 fit(&key, KEY_COL),
555 fit(&kind, TYPE_COL),
556 fit(&value, value_w)
557 );
558 let style = if i == self.selected {
559 Style::default()
560 .fg(theme.selection_fg.to_ratatui())
561 .bg(theme.selection_bg.to_ratatui())
562 } else {
563 normal
564 };
565 let r = row_at(1 + n as u16);
566 f.render_widget(Paragraph::new(text).style(style), r);
567 self.row_rects.push((r, i));
568 }
569 }
570
571 if let Some(msg) = &self.error {
572 super::render_error_row(f, row_at(buttons_row - 1), msg, theme);
573 }
574
575 let brow = row_at(buttons_row);
576 if let Mode::ConfirmDelete(key) = &self.mode {
577 let label = format!(" Delete \"{key}\"? y/n ");
578 let lw = (label.width() as u16).min(brow.width);
579 f.render_widget(
580 Paragraph::new(label).style(normal.add_modifier(Modifier::BOLD)),
581 Rect { width: lw, ..brow },
582 );
583 self.confirm.render(
584 f,
585 Rect {
586 x: brow.x + lw,
587 width: brow.width - lw,
588 ..brow
589 },
590 theme,
591 );
592 } else {
593 self.buttons.render(f, brow, theme);
594 }
595 f.render_widget(
596 Paragraph::new(" ↑↓ select · Enter edit · a add · d delete · Esc close").style(gray),
597 row_at(footer_row),
598 );
599 }
600}
601
602const TYPES: [Option<PropertyKind>; 7] = [
608 None,
609 Some(PropertyKind::Text),
610 Some(PropertyKind::Number),
611 Some(PropertyKind::Bool),
612 Some(PropertyKind::Date),
613 Some(PropertyKind::DateTime),
614 Some(PropertyKind::List),
615];
616
617const LABEL_W: u16 = 8;
619
620#[derive(Clone, Copy, PartialEq)]
621enum FormFocus {
622 Key,
623 Kind,
624 Value,
625 StoreAnyway,
626 Buttons,
627}
628
629struct PropertyForm {
630 orig_key: Option<String>,
632 current_kind: Option<PropertyKind>,
634 was_bare: bool,
636 orig_text: Option<String>,
639 key: KeyPicker,
640 kind_idx: usize,
642 value: SingleLineInput,
643 focus: FormFocus,
644 buttons: ButtonRow,
646 error: Option<String>,
648 store_anyway: Option<PropertyKind>,
650 store_btn: ButtonRow,
652 value_rect: Option<Rect>,
654 kind_prev_rect: Option<Rect>,
655 kind_next_rect: Option<Rect>,
656}
657
658enum FormAction {
660 None,
661 Submit,
662 StoreAnyway,
663 Cancel,
664}
665
666impl PropertyForm {
667 fn blank(orig_key: Option<String>, key: &str, known_keys: &[String]) -> Self {
668 let mut picker = KeyPicker::new(key);
669 picker.set_keys(known_keys.to_vec());
670 Self {
671 orig_key,
672 current_kind: None,
673 was_bare: false,
674 orig_text: None,
675 key: picker,
676 kind_idx: 0,
677 value: SingleLineInput::new(),
678 focus: FormFocus::Key,
679 buttons: ButtonRow::new(&["Save", "Cancel"]),
680 error: None,
681 store_anyway: None,
682 store_btn: ButtonRow::new(&["Store anyway"]),
683 value_rect: None,
684 kind_prev_rect: None,
685 kind_next_rect: None,
686 }
687 }
688
689 fn add(known_keys: &[String]) -> Self {
690 Self::blank(None, "", known_keys)
691 }
692
693 fn edit(key: &str, value: Option<&PropertyValue>, known_keys: &[String]) -> Self {
694 let mut form = Self::blank(Some(key.to_string()), key, known_keys);
695 match value {
696 Some(v) => {
697 form.current_kind = Some(v.kind());
698 let text = match v {
700 PropertyValue::List(items) if items.is_empty() => String::new(),
701 other => other.to_string(),
702 };
703 form.orig_text = Some(text.clone());
704 form.value = SingleLineInput::with_value(text);
705 }
706 None => form.was_bare = true,
707 }
708 form
709 }
710
711 fn kind(&self) -> Option<PropertyKind> {
712 TYPES[self.kind_idx]
713 }
714
715 fn input_values(&self) -> Vec<String> {
719 let as_list = match self.kind() {
720 Some(k) => k == PropertyKind::List,
721 None => {
722 self.current_kind == Some(PropertyKind::List)
723 || is_list_property_key(self.key.value().trim())
724 }
725 };
726 if as_list {
727 self.value
728 .value()
729 .split(',')
730 .map(str::trim)
731 .filter(|s| !s.is_empty())
732 .map(str::to_string)
733 .collect()
734 } else {
735 vec![self.value.value().to_string()]
736 }
737 }
738
739 fn inferred_kind(&self) -> PropertyKind {
741 let v = self.input_values();
742 if v.len() > 1 {
743 PropertyKind::List
744 } else {
745 PropertyValue::infer(v.first().map_or("", String::as_str)).kind()
746 }
747 }
748
749 fn clear_feedback(&mut self) {
750 self.error = None;
751 self.store_anyway = None;
752 if self.focus == FormFocus::StoreAnyway {
753 self.focus = FormFocus::Value;
754 }
755 }
756
757 fn cycle_kind(&mut self, forward: bool) {
758 let n = TYPES.len();
759 self.kind_idx = if forward {
760 (self.kind_idx + 1) % n
761 } else {
762 (self.kind_idx + n - 1) % n
763 };
764 self.clear_feedback();
765 }
766
767 fn set_focus(&mut self, focus: FormFocus) {
768 if focus != FormFocus::Buttons {
769 self.buttons.set_focused(None);
770 }
771 self.focus = focus;
772 }
773
774 fn focus_next(&mut self) {
776 match self.focus {
777 FormFocus::Key => self.set_focus(FormFocus::Kind),
778 FormFocus::Kind => self.set_focus(FormFocus::Value),
779 FormFocus::Value if self.store_anyway.is_some() => {
780 self.set_focus(FormFocus::StoreAnyway)
781 }
782 FormFocus::Value | FormFocus::StoreAnyway => {
783 self.focus = FormFocus::Buttons;
784 self.buttons.set_focused(None);
785 if !self.buttons.focus_next() {
786 self.set_focus(FormFocus::Key);
787 }
788 }
789 FormFocus::Buttons => {
790 if !self.buttons.focus_next() {
791 self.set_focus(FormFocus::Key);
792 }
793 }
794 }
795 }
796
797 fn focus_prev(&mut self) {
798 match self.focus {
799 FormFocus::Key => {
800 self.focus = FormFocus::Buttons;
801 self.buttons.set_focused(None);
802 if !self.buttons.focus_prev() {
803 self.focus_prev_field();
804 }
805 }
806 FormFocus::Buttons => {
807 if !self.buttons.focus_prev() {
808 self.focus_prev_field();
809 }
810 }
811 FormFocus::StoreAnyway => self.set_focus(FormFocus::Value),
812 FormFocus::Value => self.set_focus(FormFocus::Kind),
813 FormFocus::Kind => self.set_focus(FormFocus::Key),
814 }
815 }
816
817 fn focus_prev_field(&mut self) {
819 if self.store_anyway.is_some() {
820 self.set_focus(FormFocus::StoreAnyway);
821 } else {
822 self.set_focus(FormFocus::Value);
823 }
824 }
825
826 fn handle_key(&mut self, key: KeyEvent) -> FormAction {
827 if self.focus == FormFocus::Key {
829 let before = self.key.value().to_string();
830 match self.key.handle_key(&key) {
831 PickerOutcome::Accepted(_) | PickerOutcome::Changed | PickerOutcome::Consumed => {
832 if self.key.value() != before {
833 self.clear_feedback();
834 }
835 return FormAction::None;
836 }
837 PickerOutcome::Submit => return FormAction::Submit,
838 PickerOutcome::Cancel => return FormAction::Cancel,
839 PickerOutcome::NotConsumed => {} }
841 }
842 match key.code {
843 KeyCode::Esc => FormAction::Cancel,
844 KeyCode::Tab | KeyCode::Down => {
846 self.focus_next();
847 FormAction::None
848 }
849 KeyCode::BackTab | KeyCode::Up => {
850 self.focus_prev();
851 FormAction::None
852 }
853 KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')
854 if self.focus == FormFocus::Kind =>
855 {
856 self.cycle_kind(key.code != KeyCode::Left);
857 FormAction::None
858 }
859 KeyCode::Left if self.focus == FormFocus::Buttons => {
860 self.buttons.focus_prev();
861 FormAction::None
862 }
863 KeyCode::Right if self.focus == FormFocus::Buttons => {
864 self.buttons.focus_next();
865 FormAction::None
866 }
867 KeyCode::Enter => match self.focus {
868 FormFocus::StoreAnyway => FormAction::StoreAnyway,
869 FormFocus::Buttons if self.buttons.focused() == Some(1) => FormAction::Cancel,
870 _ => FormAction::Submit,
871 },
872 _ if self.focus == FormFocus::Value => {
873 if self.value.handle_key(&key) == InputOutcome::Changed {
874 self.clear_feedback();
875 }
876 FormAction::None
877 }
878 _ => FormAction::None,
879 }
880 }
881
882 fn handle_click(&mut self, col: u16, row: u16) -> FormAction {
883 let pos = Position { x: col, y: row };
884 let hit = |r: Option<Rect>| r.is_some_and(|r| r.contains(pos));
885 match self.key.handle_click(col, row) {
886 PickerOutcome::Accepted(_) => {
887 self.set_focus(FormFocus::Key);
888 self.clear_feedback();
889 return FormAction::None;
890 }
891 PickerOutcome::Consumed => {
892 self.set_focus(FormFocus::Key);
893 self.key.cursor_to_end();
894 return FormAction::None;
895 }
896 _ => {}
897 }
898 if hit(self.value_rect) {
899 self.set_focus(FormFocus::Value);
900 let text = self.value.value().to_string();
902 self.value.set_value(text);
903 return FormAction::None;
904 }
905 if hit(self.kind_prev_rect) || hit(self.kind_next_rect) {
906 self.set_focus(FormFocus::Kind);
907 self.cycle_kind(hit(self.kind_next_rect));
908 return FormAction::None;
909 }
910 if self.store_anyway.is_some() && self.store_btn.hit_at(col, row).is_some() {
911 return FormAction::StoreAnyway;
912 }
913 match self.buttons.hit_at(col, row) {
914 Some(0) => FormAction::Submit,
915 Some(_) => FormAction::Cancel,
916 None => FormAction::None,
917 }
918 }
919}
920
921impl PropertiesDialog {
922 fn handle_form_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
923 let Mode::Form(form) = &mut self.mode else {
924 return EventState::Consumed;
925 };
926 let action = form.handle_key(key);
927 self.run_form_action(action, tx);
928 EventState::Consumed
929 }
930
931 fn handle_form_mouse(&mut self, ev: &MouseEvent, tx: &AppTx) -> EventState {
932 if ev.kind != MouseEventKind::Down(MouseButton::Left) {
933 return EventState::Consumed;
934 }
935 let Mode::Form(form) = &mut self.mode else {
936 return EventState::Consumed;
937 };
938 let action = form.handle_click(ev.column, ev.row);
939 self.run_form_action(action, tx);
940 EventState::Consumed
941 }
942
943 fn run_form_action(&mut self, action: FormAction, tx: &AppTx) {
944 match action {
945 FormAction::None => {}
946 FormAction::Submit => self.submit(None, tx),
947 FormAction::StoreAnyway => self.press_store_anyway(tx),
948 FormAction::Cancel if self.busy => {}
950 FormAction::Cancel => self.mode = Mode::List,
951 }
952 }
953
954 pub(crate) fn press_store_anyway(&mut self, tx: &AppTx) {
956 if let Mode::Form(form) = &self.mode
957 && let Some(kind) = form.store_anyway
958 {
959 self.submit(Some(kind), tx);
960 }
961 }
962
963 fn submit(&mut self, forced: Option<PropertyKind>, tx: &AppTx) {
966 if self.busy {
967 return;
968 }
969 let Mode::Form(form) = &mut self.mode else {
970 return;
971 };
972 let new_key = form.key.value().trim().to_string();
973 if new_key.is_empty() {
974 form.error = Some("key required".into());
975 form.store_anyway = None;
976 return;
977 }
978 if form.orig_key.is_none()
980 && self
981 .entries
982 .iter()
983 .any(|(k, _)| property_keys_match(k, &new_key))
984 {
985 form.error = Some(format!("'{new_key}' already exists — edit it"));
986 form.store_anyway = None;
987 return;
988 }
989 if forced.is_none()
992 && form.kind().is_none()
993 && form.orig_key.as_deref() == Some(new_key.as_str())
994 && form.orig_text.as_deref() == Some(form.value.value())
995 {
996 self.mode = Mode::List;
997 return;
998 }
999 let values = form.input_values();
1000 let value_empty = values.iter().all(|v| v.trim().is_empty());
1001 let renamed = form
1002 .orig_key
1003 .as_deref()
1004 .filter(|o| *o != new_key)
1005 .map(str::to_string);
1006 let rename_only = form.was_bare && value_empty;
1008 if value_empty && !rename_only {
1009 form.error = Some("value required".into());
1010 form.store_anyway = None;
1011 return;
1012 }
1013 if rename_only && renamed.is_none() {
1014 self.mode = Mode::List;
1015 return;
1016 }
1017 let mut input = PropertyInput::new(values).forced(forced.or(form.kind()));
1018 if let Some(kind) = form.current_kind {
1021 input = input.implied(kind);
1022 }
1023 form.clear_feedback();
1024 self.pending_rename = renamed.clone().map(|old| (old, new_key.clone()));
1025 self.busy = true;
1026 self.sync_buttons();
1027 let vault = self.vault.clone();
1028 let path = self.path.clone();
1029 let tx = tx.clone();
1030 tokio::spawn(async move {
1031 let mut changed = false;
1034 let result = async {
1035 if let Some(old) = &renamed {
1036 vault
1039 .rename_property(&path, old, &new_key)
1040 .await
1041 .map_err(|e| match PropertyWriteError::from_vault(e) {
1042 PropertyWriteError::Invalid(m) => PropertyWriteError::Other(m),
1043 other => other,
1044 })?;
1045 changed = true;
1046 }
1047 if !rename_only {
1048 vault
1049 .set_property_from_input(&path, &new_key, &input, FrontmatterFormat::Toml)
1050 .await
1051 .map_err(PropertyWriteError::from_vault)?;
1052 changed = true;
1053 }
1054 Ok::<_, PropertyWriteError>("property saved".to_string())
1055 }
1056 .await;
1057 if changed {
1060 tx.send(AppEvent::NoteReloadFromDisk(path.clone())).ok();
1061 }
1062 tx.send(AppEvent::OverlayData(OverlayData::PropertyWritten {
1063 path,
1064 result,
1065 }))
1066 .ok();
1067 });
1068 }
1069
1070 fn show_write_error(&mut self, e: &PropertyWriteError) {
1073 let Mode::Form(form) = &mut self.mode else {
1074 self.error = Some(e.message().to_string());
1075 return;
1076 };
1077 form.error = Some(e.message().to_string());
1078 form.store_anyway = match e {
1079 PropertyWriteError::Invalid(_) if form.kind().is_none() => Some(form.inferred_kind()),
1080 _ => None,
1081 };
1082 if let Some(kind) = form.store_anyway {
1083 form.store_btn = ButtonRow::new(&[&format!("Store as {kind} anyway")]);
1084 }
1085 }
1086
1087 #[cfg(test)]
1088 pub(crate) fn form_error(&self) -> Option<String> {
1089 match &self.mode {
1090 Mode::Form(form) => form.error.clone(),
1091 _ => None,
1092 }
1093 }
1094
1095 #[cfg(test)]
1096 pub(crate) fn store_anyway_kind(&self) -> Option<PropertyKind> {
1097 match &self.mode {
1098 Mode::Form(form) => form.store_anyway,
1099 _ => None,
1100 }
1101 }
1102
1103 #[cfg(test)]
1104 pub(crate) fn form_kind(&self) -> Option<PropertyKind> {
1105 match &self.mode {
1106 Mode::Form(form) => form.kind(),
1107 _ => None,
1108 }
1109 }
1110
1111 #[cfg(test)]
1112 pub(crate) fn form_orig_key(&self) -> Option<String> {
1113 match &self.mode {
1114 Mode::Form(form) => form.orig_key.clone(),
1115 _ => None,
1116 }
1117 }
1118
1119 #[cfg(test)]
1120 fn form_focus(&self) -> Option<FormFocus> {
1121 match &self.mode {
1122 Mode::Form(form) => Some(form.focus),
1123 _ => None,
1124 }
1125 }
1126
1127 #[cfg(test)]
1128 pub(crate) fn kind_next_origin(&self) -> Option<(u16, u16)> {
1129 match &self.mode {
1130 Mode::Form(form) => form.kind_next_rect.map(|r| (r.x, r.y)),
1131 _ => None,
1132 }
1133 }
1134
1135 fn render_form(&mut self, f: &mut Frame, inner: Rect, theme: &Theme) {
1136 let Mode::Form(form) = &mut self.mode else {
1137 return;
1138 };
1139 let bg = theme.bg_panel.to_ratatui();
1140 let normal = Style::default().fg(theme.fg.to_ratatui()).bg(bg);
1141 let gray = Style::default().fg(theme.gray.to_ratatui()).bg(bg);
1142 let accent = Style::default()
1143 .fg(theme.accent.to_ratatui())
1144 .bg(bg)
1145 .add_modifier(Modifier::BOLD);
1146 let row_at = |i: u16| Rect {
1147 x: inner.x,
1148 y: inner.y + i,
1149 width: inner.width,
1150 height: 1,
1151 };
1152 let field_at = |i: u16| Rect {
1153 x: inner.x + LABEL_W,
1154 width: inner.width.saturating_sub(LABEL_W + 1),
1155 ..row_at(i)
1156 };
1157 let label = |f: &mut Frame, text: &str, row: u16, focused: bool| {
1158 let style = if focused { accent } else { normal };
1159 f.render_widget(
1160 Paragraph::new(format!(" {text}")).style(style),
1161 Rect {
1162 width: LABEL_W.min(inner.width),
1163 ..row_at(row)
1164 },
1165 );
1166 };
1167 const KEY_ROW: u16 = 1;
1168 const KIND_ROW: u16 = 2;
1169 const VALUE_ROW: u16 = 3;
1170
1171 label(f, "Key", KEY_ROW, form.focus == FormFocus::Key);
1172 label(f, "Type", KIND_ROW, form.focus == FormFocus::Kind);
1173 label(f, "Value", VALUE_ROW, form.focus == FormFocus::Value);
1174
1175 let kind_row = field_at(KIND_ROW);
1177 let kind = form.kind().map_or("auto", PropertyKind::as_str);
1178 let kind_text = format!(" {kind} ");
1179 let kind_w = kind_text.width() as u16;
1180 let kind_style = if form.focus == FormFocus::Kind {
1181 Style::default()
1182 .fg(theme.selection_fg.to_ratatui())
1183 .bg(theme.selection_bg.to_ratatui())
1184 } else {
1185 normal
1186 };
1187 let cell = |x: u16, w: u16| Rect {
1188 x,
1189 width: w.min(kind_row.right().saturating_sub(x)),
1190 ..kind_row
1191 };
1192 let prev = cell(kind_row.x, 1);
1193 let text = cell(kind_row.x + 1, kind_w);
1194 let next = cell(kind_row.x + 1 + kind_w, 1);
1195 f.render_widget(Paragraph::new("‹").style(normal), prev);
1196 f.render_widget(Paragraph::new(kind_text).style(kind_style), text);
1197 f.render_widget(Paragraph::new("›").style(normal), next);
1198 form.kind_prev_rect = Some(prev);
1199 form.kind_next_rect = Some(next);
1200 if let Some(current) = form.current_kind {
1201 let hint_x = next.x + 1;
1202 f.render_widget(
1203 Paragraph::new(format!(" (currently {current})")).style(gray),
1204 cell(hint_x, kind_row.right().saturating_sub(hint_x)),
1205 );
1206 }
1207
1208 let value_rect = field_at(VALUE_ROW);
1209 form.value_rect = Some(value_rect);
1210 let vstyle = Style::default()
1211 .fg(theme.fg_bright.to_ratatui())
1212 .bg(theme.bg.to_ratatui());
1213 form.value
1214 .render(f, value_rect, vstyle, 0, form.focus == FormFocus::Value);
1215
1216 if let Some(msg) = &form.error {
1217 f.render_widget(
1218 Paragraph::new(msg.as_str())
1219 .style(Style::default().fg(theme.red.to_ratatui()).bg(bg)),
1220 field_at(VALUE_ROW + 1),
1221 );
1222 }
1223 if form.store_anyway.is_some() {
1224 form.store_btn
1225 .set_focused((form.focus == FormFocus::StoreAnyway).then_some(0));
1226 let r = field_at(VALUE_ROW + 2);
1227 form.store_btn.render(
1229 f,
1230 Rect {
1231 x: r.x.saturating_sub(1),
1232 width: r.width + 1,
1233 ..r
1234 },
1235 theme,
1236 );
1237 }
1238
1239 let btn_row = inner.height.saturating_sub(2);
1240 form.buttons.render(f, row_at(btn_row), theme);
1241 f.render_widget(
1242 Paragraph::new(" Tab/↑↓ next field · ←→ type · Enter save · Esc back").style(gray),
1243 row_at(inner.height.saturating_sub(1)),
1244 );
1245 form.key.render(
1247 f,
1248 field_at(KEY_ROW),
1249 inner,
1250 theme,
1251 form.focus == FormFocus::Key,
1252 );
1253 }
1254}
1255
1256impl super::Dialog for PropertiesDialog {
1257 fn key(
1258 &mut self,
1259 key: ratatui::crossterm::event::KeyEvent,
1260 tx: &crate::components::events::AppTx,
1261 ) -> crate::components::event_state::EventState {
1262 self.handle_key(key, tx)
1263 }
1264
1265 fn mouse(
1266 &mut self,
1267 m: &ratatui::crossterm::event::MouseEvent,
1268 tx: &crate::components::events::AppTx,
1269 ) -> crate::components::event_state::EventState {
1270 self.handle_mouse(m, tx)
1271 }
1272
1273 fn draw(
1274 &mut self,
1275 f: &mut ratatui::Frame,
1276 rect: ratatui::layout::Rect,
1277 theme: &crate::settings::themes::Theme,
1278 ) {
1279 self.render(f, rect, theme)
1280 }
1281
1282 fn set_error(&mut self, msg: String) {
1283 self.error = Some(msg);
1284 }
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289 use super::*;
1290 use kimun_core::note::PropertyValue;
1291 use ratatui::crossterm::event::{KeyCode, KeyEvent};
1292 use ratatui::{Terminal, backend::TestBackend};
1293 use tokio::sync::mpsc::unbounded_channel;
1294
1295 use crate::components::events::InputEvent;
1296 use crate::settings::themes::Theme;
1297 use crate::test_support::{mouse_down_at, temp_vault};
1298
1299 fn k(code: KeyCode) -> KeyEvent {
1300 KeyEvent::from(code)
1301 }
1302
1303 async fn dialog_with(
1304 entries: Vec<PropertyEntry>,
1305 ) -> (
1306 PropertiesDialog,
1307 AppTx,
1308 tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
1309 ) {
1310 let vault = temp_vault("props_dialog").await;
1311 let (tx, rx) = unbounded_channel();
1312 let mut d = PropertiesDialog::new_unloaded(VaultPath::note_path_from("/n.md"), vault);
1313 d.handle_loaded(&Ok(entries));
1314 (d, tx, rx)
1315 }
1316
1317 fn sample() -> Vec<PropertyEntry> {
1318 vec![
1319 ("status".into(), Some(PropertyValue::Text("draft".into()))),
1320 (
1321 "tags".into(),
1322 Some(PropertyValue::List(vec!["work".into(), "q4".into()])),
1323 ),
1324 ("due".into(), None),
1325 ]
1326 }
1327
1328 fn draw(d: &mut PropertiesDialog) {
1329 let theme = Theme::gruvbox_dark();
1330 let mut t = Terminal::new(TestBackend::new(80, 24)).unwrap();
1331 t.draw(|f| d.render(f, f.area(), &theme)).unwrap();
1332 }
1333
1334 fn mouse(col: u16, row: u16) -> MouseEvent {
1335 match mouse_down_at(col, row) {
1336 InputEvent::Mouse(m) => m,
1337 _ => unreachable!(),
1338 }
1339 }
1340
1341 #[tokio::test]
1342 async fn rows_show_key_type_value() {
1343 let (d, _tx, _rx) = dialog_with(sample()).await;
1344 assert_eq!(
1345 d.row_cells(0),
1346 ("status".into(), "text".into(), "draft".into())
1347 );
1348 assert_eq!(
1349 d.row_cells(1),
1350 ("tags".into(), "list".into(), "work, q4".into())
1351 );
1352 assert_eq!(d.row_cells(2), ("due".into(), "—".into(), String::new()));
1353 }
1354
1355 #[tokio::test]
1356 async fn arrows_and_jk_move_selection() {
1357 let (mut d, tx, _rx) = dialog_with(sample()).await;
1358 d.handle_key(k(KeyCode::Down), &tx);
1359 d.handle_key(k(KeyCode::Char('j')), &tx);
1360 assert_eq!(d.selected, 2);
1361 d.handle_key(k(KeyCode::Down), &tx);
1362 assert_eq!(d.selected, 2, "stops at the end");
1363 d.handle_key(k(KeyCode::Char('k')), &tx);
1364 assert_eq!(d.selected, 1);
1365 }
1366
1367 #[tokio::test]
1368 async fn click_selects_then_second_click_edits() {
1369 let (mut d, tx, _rx) = dialog_with(sample()).await;
1370 draw(&mut d);
1371 let (x, y) = d.row_origin(1).unwrap();
1372 d.handle_mouse(&mouse(x + 1, y), &tx);
1373 assert_eq!(d.selected, 1);
1374 assert!(matches!(d.mode, Mode::List));
1375 d.handle_mouse(&mouse(x + 1, y), &tx);
1376 assert!(
1377 matches!(d.mode, Mode::Form(_)),
1378 "second click opens the form"
1379 );
1380 }
1381
1382 #[tokio::test]
1383 async fn delete_asks_and_y_removes_from_disk() {
1384 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1385 d.vault
1386 .create_note(&d.path, "+++\nstatus = \"draft\"\n+++\nbody")
1387 .await
1388 .unwrap();
1389 d.handle_key(k(KeyCode::Char('d')), &tx);
1390 assert!(matches!(d.mode, Mode::ConfirmDelete(ref key) if key == "status"));
1391 d.handle_key(k(KeyCode::Char('y')), &tx);
1392 assert!(d.busy, "write in flight");
1393 assert!(next_written(&mut rx).await.is_ok());
1396 assert!(d.vault.get_properties(&d.path).await.unwrap().is_empty());
1397 }
1398
1399 #[tokio::test]
1400 async fn delete_cancelled_by_n_or_esc() {
1401 for code in [KeyCode::Char('n'), KeyCode::Esc] {
1402 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1403 d.handle_key(k(KeyCode::Delete), &tx);
1404 d.handle_key(k(code), &tx);
1405 assert!(matches!(d.mode, Mode::List));
1406 assert!(rx.try_recv().is_err(), "nothing written");
1407 }
1408 }
1409
1410 #[tokio::test]
1411 async fn malformed_frontmatter_is_read_only() {
1412 let vault = temp_vault("props_dialog").await;
1413 let (tx, mut rx) = unbounded_channel();
1414 let mut d = PropertiesDialog::new_unloaded(VaultPath::note_path_from("/n.md"), vault);
1415 d.handle_loaded(&Err("'a' appears more than once".into()));
1416 for code in [
1417 KeyCode::Char('a'),
1418 KeyCode::Char('e'),
1419 KeyCode::Char('d'),
1420 KeyCode::Enter,
1421 ] {
1422 d.handle_key(k(code), &tx);
1423 }
1424 assert!(matches!(d.mode, Mode::List));
1425 assert!(rx.try_recv().is_err());
1426 assert!(!d.buttons.is_enabled(BTN_ADD));
1427 assert!(d.buttons.is_enabled(BTN_CLOSE));
1428 }
1429
1430 #[tokio::test]
1431 async fn esc_closes_dialog() {
1432 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1433 d.handle_key(k(KeyCode::Esc), &tx);
1434 assert!(matches!(rx.try_recv(), Ok(AppEvent::CloseOverlay)));
1435 }
1436
1437 #[tokio::test]
1438 async fn successful_write_reloads_list_and_flashes() {
1439 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1440 d.busy = true;
1441 d.handle_written(&Ok("property saved".into()), &tx);
1442 assert!(!d.busy);
1443 let mut saw_reload = false;
1444 let mut saw_flash = false;
1445 while let Ok(evt) = rx.try_recv() {
1446 match evt {
1447 AppEvent::NoteReloadFromDisk(_) => saw_reload = true,
1448 AppEvent::FlashMessage(m) => saw_flash = m == "property saved",
1449 _ => {}
1450 }
1451 }
1452 assert!(saw_flash);
1453 assert!(
1454 !saw_reload,
1455 "the write task sends the editor reload, not the result handler"
1456 );
1457 assert!(d.loading, "list re-read requested");
1458 }
1459
1460 fn add_via_keys(d: &mut PropertiesDialog, tx: &AppTx, key: &str, value: &str) {
1462 d.handle_key(k(KeyCode::Char('a')), tx);
1463 type_into(d, tx, key);
1464 d.handle_key(k(KeyCode::Tab), tx);
1465 d.handle_key(k(KeyCode::Tab), tx);
1466 type_into(d, tx, value);
1467 d.handle_key(k(KeyCode::Enter), tx);
1468 }
1469
1470 async fn next_reload(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> VaultPath {
1471 loop {
1472 let evt = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
1473 .await
1474 .expect("timely reload")
1475 .expect("open channel");
1476 if let AppEvent::NoteReloadFromDisk(p) = evt {
1477 return p;
1478 }
1479 }
1480 }
1481
1482 #[tokio::test]
1483 async fn closing_mid_save_still_reloads_the_editor() {
1484 let (mut d, tx, mut rx) = dialog_with(vec![]).await;
1485 vault_note(&d, "body").await;
1486 let path = d.path.clone();
1487 let vault = d.vault.clone();
1488 add_via_keys(&mut d, &tx, "k", "v");
1489 assert!(d.busy, "write in flight");
1490 drop(d);
1492 assert_eq!(next_reload(&mut rx).await, path);
1493 assert_eq!(
1494 vault.get_property(&path, "k").await.unwrap(),
1495 Some(Some(PropertyValue::Text("v".into())))
1496 );
1497 }
1498
1499 #[tokio::test]
1500 async fn closing_mid_delete_still_reloads_the_editor() {
1501 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1502 vault_note(&d, "+++\nstatus = \"draft\"\n+++\nbody").await;
1503 let path = d.path.clone();
1504 d.handle_key(k(KeyCode::Char('d')), &tx);
1505 d.handle_key(k(KeyCode::Char('y')), &tx);
1506 drop(d);
1507 assert_eq!(next_reload(&mut rx).await, path);
1508 }
1509
1510 #[tokio::test]
1511 async fn results_for_another_note_are_ignored() {
1512 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1513 let other = VaultPath::note_path_from("/other.md");
1514 d.busy = true;
1515 d.on_written(&other, &Ok("property saved".into()), &tx);
1516 assert!(d.busy, "another note's write result is not this dialog's");
1517 assert!(rx.try_recv().is_err(), "nothing flashed or reloaded");
1518 d.on_loaded(&other, &Ok(vec![]));
1519 assert_eq!(d.entries.len(), 3, "another note's entries are not loaded");
1520 let own = d.path.clone();
1522 d.on_written(&own, &Ok("property saved".into()), &tx);
1523 assert!(!d.busy);
1524 d.on_loaded(&own, &Ok(vec![]));
1525 assert!(d.entries.is_empty());
1526 }
1527
1528 #[tokio::test]
1529 async fn unchanged_edit_save_writes_nothing() {
1530 let (mut d, tx, mut rx) = dialog_with(vec![(
1531 "authors".into(),
1532 Some(PropertyValue::List(vec!["Doe, John".into()])),
1533 )])
1534 .await;
1535 let text = "+++\nauthors = [\"Doe, John\"]\n+++\nbody";
1536 vault_note(&d, text).await;
1537 d.handle_key(k(KeyCode::Enter), &tx); d.handle_key(k(KeyCode::Enter), &tx); assert!(matches!(d.mode, Mode::List), "back to the list");
1540 assert!(!d.busy, "no write spawned");
1541 let spawned =
1542 tokio::time::timeout(std::time::Duration::from_millis(300), next_written(&mut rx))
1543 .await;
1544 assert!(spawned.is_err(), "nothing written");
1545 assert_eq!(d.vault.get_note_text(&d.path).await.unwrap(), text);
1546 }
1547
1548 #[tokio::test]
1549 async fn auto_edit_keeps_the_notes_own_kind_when_the_vault_has_none() {
1550 let (mut d, tx, mut rx) = dialog_with(vec![(
1551 "code".into(),
1552 Some(PropertyValue::Text("42".into())),
1553 )])
1554 .await;
1555 vault_note(&d, "+++\ncode = \"42\"\n+++\nbody").await;
1556 d.handle_key(k(KeyCode::Enter), &tx); d.handle_key(k(KeyCode::Tab), &tx); d.handle_key(k(KeyCode::Tab), &tx); d.handle_key(k(KeyCode::Backspace), &tx);
1560 type_into(&mut d, &tx, "3");
1561 d.handle_key(k(KeyCode::Enter), &tx);
1562 assert!(next_written(&mut rx).await.is_ok());
1563 assert_eq!(
1564 d.vault.get_property(&d.path, "code").await.unwrap(),
1565 Some(Some(PropertyValue::Text("43".into())))
1566 );
1567 }
1568
1569 #[tokio::test]
1570 async fn add_with_an_existing_key_is_refused_locally() {
1571 let (mut d, tx, mut rx) = dialog_with(vec![(
1572 "status".into(),
1573 Some(PropertyValue::Text("draft".into())),
1574 )])
1575 .await;
1576 let text = "+++\nstatus = \"draft\"\n+++\nbody";
1577 vault_note(&d, text).await;
1578 add_via_keys(&mut d, &tx, "Status", "done");
1579 assert_eq!(
1580 d.form_error().as_deref(),
1581 Some("'Status' already exists — edit it")
1582 );
1583 assert!(!d.busy, "nothing spawned");
1584 let spawned =
1585 tokio::time::timeout(std::time::Duration::from_millis(300), next_written(&mut rx))
1586 .await;
1587 assert!(spawned.is_err(), "nothing written");
1588 assert_eq!(d.vault.get_note_text(&d.path).await.unwrap(), text);
1589 }
1590
1591 #[tokio::test]
1592 async fn malformed_write_error_turns_read_only() {
1593 let (mut d, tx, _rx) = dialog_with(sample()).await;
1594 d.busy = true;
1595 d.handle_written(&Err(PropertyWriteError::Malformed("bad block".into())), &tx);
1596 assert!(d.read_only());
1597 assert!(!d.buttons.is_enabled(BTN_ADD));
1598 }
1599
1600 async fn vault_note(d: &PropertiesDialog, text: &str) {
1601 d.vault.create_note(&d.path, text).await.unwrap();
1602 }
1603
1604 async fn next_written(
1605 rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
1606 ) -> Result<String, PropertyWriteError> {
1607 loop {
1608 let evt = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
1609 .await
1610 .expect("timely")
1611 .expect("open channel");
1612 if let AppEvent::OverlayData(OverlayData::PropertyWritten { result, .. }) = evt {
1613 return result;
1614 }
1615 }
1616 }
1617
1618 fn type_into(d: &mut PropertiesDialog, tx: &AppTx, s: &str) {
1619 for c in s.chars() {
1620 d.handle_key(k(KeyCode::Char(c)), tx);
1621 }
1622 }
1623
1624 #[tokio::test]
1625 async fn add_writes_a_new_property() {
1626 let (mut d, tx, mut rx) = dialog_with(vec![]).await;
1627 vault_note(&d, "body").await;
1628 d.handle_key(k(KeyCode::Char('a')), &tx);
1629 type_into(&mut d, &tx, "priority");
1630 d.handle_key(k(KeyCode::Tab), &tx); d.handle_key(k(KeyCode::Tab), &tx); type_into(&mut d, &tx, "2");
1633 d.handle_key(k(KeyCode::Enter), &tx);
1634 assert!(next_written(&mut rx).await.is_ok());
1635 assert_eq!(
1636 d.vault.get_property(&d.path, "priority").await.unwrap(),
1637 Some(Some(PropertyValue::Number(2.0)))
1638 );
1639 }
1640
1641 #[tokio::test]
1642 async fn empty_key_and_empty_value_are_refused_locally() {
1643 let (mut d, tx, mut rx) = dialog_with(vec![]).await;
1644 d.handle_key(k(KeyCode::Char('a')), &tx);
1645 d.handle_key(k(KeyCode::Enter), &tx);
1646 assert_eq!(d.form_error().as_deref(), Some("key required"));
1647 type_into(&mut d, &tx, "k");
1648 d.handle_key(k(KeyCode::Enter), &tx);
1649 assert_eq!(d.form_error().as_deref(), Some("value required"));
1650 assert!(rx.try_recv().is_err(), "nothing spawned");
1651 }
1652
1653 #[tokio::test]
1659 async fn key_change_renames_then_sets() {
1660 let (mut d, tx, mut rx) = dialog_with(vec![(
1661 "status".into(),
1662 Some(PropertyValue::Text("draft".into())),
1663 )])
1664 .await;
1665 vault_note(&d, "+++\na = 1\nstatus = \"draft\"\nz = 2\n+++\nbody").await;
1666 d.handle_key(k(KeyCode::Enter), &tx); for _ in 0.."status".len() {
1668 d.handle_key(k(KeyCode::Backspace), &tx);
1669 }
1670 type_into(&mut d, &tx, "state");
1671 d.handle_key(k(KeyCode::Tab), &tx);
1672 d.handle_key(k(KeyCode::Tab), &tx);
1673 for _ in 0.."draft".len() {
1674 d.handle_key(k(KeyCode::Backspace), &tx);
1675 }
1676 type_into(&mut d, &tx, "done");
1677 d.handle_key(k(KeyCode::Enter), &tx);
1678 assert!(next_written(&mut rx).await.is_ok());
1679 assert_eq!(
1680 d.vault.get_note_text(&d.path).await.unwrap(),
1681 "+++\na = 1\nstate = \"done\"\nz = 2\n+++\nbody"
1682 );
1683 }
1684
1685 #[tokio::test]
1686 async fn bare_key_rename_keeps_it_valueless() {
1687 let (mut d, tx, mut rx) = dialog_with(vec![("due".into(), None)]).await;
1688 vault_note(&d, "---\ndue:\n---\nbody").await;
1689 d.handle_key(k(KeyCode::Enter), &tx);
1690 for _ in 0.."due".len() {
1691 d.handle_key(k(KeyCode::Backspace), &tx);
1692 }
1693 type_into(&mut d, &tx, "deadline");
1694 d.handle_key(k(KeyCode::Enter), &tx);
1695 assert!(next_written(&mut rx).await.is_ok());
1696 assert_eq!(
1697 d.vault.get_note_text(&d.path).await.unwrap(),
1698 "---\ndeadline:\n---\nbody"
1699 );
1700 }
1701
1702 #[tokio::test]
1703 async fn mismatch_offers_store_anyway_which_forces_the_kind() {
1704 let (mut d, tx, mut rx) = dialog_with(vec![]).await;
1705 for n in ["/o1.md", "/o2.md"] {
1707 d.vault
1708 .create_note(&VaultPath::note_path_from(n), "+++\npriority = 1\n+++\nx")
1709 .await
1710 .unwrap();
1711 }
1712 vault_note(&d, "body").await;
1713 d.handle_key(k(KeyCode::Char('a')), &tx);
1714 type_into(&mut d, &tx, "priority");
1715 d.handle_key(k(KeyCode::Tab), &tx);
1716 d.handle_key(k(KeyCode::Tab), &tx);
1717 type_into(&mut d, &tx, "high");
1718 d.handle_key(k(KeyCode::Enter), &tx);
1719 let result = next_written(&mut rx).await;
1720 assert!(matches!(result, Err(PropertyWriteError::Invalid(_))));
1721 d.handle_written(&result, &tx);
1722 assert_eq!(d.store_anyway_kind(), Some(PropertyKind::Text));
1723 d.press_store_anyway(&tx);
1724 assert!(next_written(&mut rx).await.is_ok());
1725 assert_eq!(
1726 d.vault.get_property(&d.path, "priority").await.unwrap(),
1727 Some(Some(PropertyValue::Text("high".into())))
1728 );
1729 }
1730
1731 #[tokio::test]
1732 async fn forced_type_parse_error_has_no_store_anyway() {
1733 let (mut d, tx, mut rx) = dialog_with(vec![]).await;
1734 vault_note(&d, "body").await;
1735 d.handle_key(k(KeyCode::Char('a')), &tx);
1736 type_into(&mut d, &tx, "when");
1737 d.handle_key(k(KeyCode::Tab), &tx); for _ in 0..4 {
1739 d.handle_key(k(KeyCode::Right), &tx); }
1741 d.handle_key(k(KeyCode::Tab), &tx);
1742 type_into(&mut d, &tx, "not a date");
1743 d.handle_key(k(KeyCode::Enter), &tx);
1744 let result = next_written(&mut rx).await;
1745 d.handle_written(&result, &tx);
1746 assert!(d.form_error().is_some());
1747 assert_eq!(d.store_anyway_kind(), None);
1748 }
1749
1750 #[tokio::test]
1751 async fn rename_then_failed_set_reloads_and_keeps_new_key() {
1752 let (mut d, tx, mut rx) = dialog_with(vec![(
1753 "status".into(),
1754 Some(PropertyValue::Text("draft".into())),
1755 )])
1756 .await;
1757 vault_note(&d, "+++\nstatus = \"draft\"\n+++\nbody").await;
1758 d.handle_key(k(KeyCode::Enter), &tx); for _ in 0.."status".len() {
1760 d.handle_key(k(KeyCode::Backspace), &tx);
1761 }
1762 type_into(&mut d, &tx, "when");
1763 d.handle_key(k(KeyCode::Tab), &tx); for _ in 0..4 {
1765 d.handle_key(k(KeyCode::Right), &tx); }
1767 d.handle_key(k(KeyCode::Tab), &tx);
1768 for _ in 0.."draft".len() {
1769 d.handle_key(k(KeyCode::Backspace), &tx);
1770 }
1771 type_into(&mut d, &tx, "not a date");
1772 d.handle_key(k(KeyCode::Enter), &tx);
1773 assert_eq!(next_reload(&mut rx).await, d.path);
1775 let result = next_written(&mut rx).await;
1776 assert!(result.is_err());
1777 d.handle_written(&result, &tx);
1778 assert_eq!(
1780 d.vault.get_note_text(&d.path).await.unwrap(),
1781 "+++\nwhen = \"draft\"\n+++\nbody"
1782 );
1783 assert!(matches!(d.mode, Mode::Form(_)), "form stays open");
1784 assert!(d.form_error().is_some());
1785 assert_eq!(d.form_orig_key().as_deref(), Some("when"));
1786 assert!(d.loading, "list re-read requested");
1787 }
1788
1789 #[tokio::test]
1790 async fn second_save_while_busy_is_ignored() {
1791 let (mut d, tx, mut rx) = dialog_with(vec![]).await;
1792 vault_note(&d, "body").await;
1793 d.handle_key(k(KeyCode::Char('a')), &tx);
1794 type_into(&mut d, &tx, "k");
1795 d.handle_key(k(KeyCode::Tab), &tx);
1796 d.handle_key(k(KeyCode::Tab), &tx);
1797 type_into(&mut d, &tx, "v");
1798 d.handle_key(k(KeyCode::Enter), &tx);
1799 d.handle_key(k(KeyCode::Enter), &tx); assert!(next_written(&mut rx).await.is_ok());
1801 let second =
1802 tokio::time::timeout(std::time::Duration::from_millis(300), next_written(&mut rx))
1803 .await;
1804 assert!(second.is_err(), "only one write was spawned");
1805 }
1806
1807 #[tokio::test]
1808 async fn esc_in_form_returns_to_list_without_writing() {
1809 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1810 d.handle_key(k(KeyCode::Char('a')), &tx);
1811 d.handle_key(k(KeyCode::Esc), &tx); assert!(matches!(d.mode, Mode::List));
1813 assert!(rx.try_recv().is_err());
1814 }
1815
1816 #[tokio::test]
1817 async fn arrows_move_between_form_fields() {
1818 let (mut d, tx, _rx) = dialog_with(vec![]).await;
1819 d.handle_key(k(KeyCode::Char('a')), &tx);
1820 assert!(d.form_focus() == Some(FormFocus::Key));
1821 d.handle_key(k(KeyCode::Down), &tx);
1822 assert!(d.form_focus() == Some(FormFocus::Kind));
1823 d.handle_key(k(KeyCode::Down), &tx);
1824 assert!(d.form_focus() == Some(FormFocus::Value));
1825 d.handle_key(k(KeyCode::Down), &tx);
1826 assert!(d.form_focus() == Some(FormFocus::Buttons), "Value → Save");
1827 d.handle_key(k(KeyCode::Up), &tx);
1828 assert!(d.form_focus() == Some(FormFocus::Value));
1829 d.handle_key(k(KeyCode::Up), &tx);
1830 d.handle_key(k(KeyCode::Up), &tx);
1831 assert!(d.form_focus() == Some(FormFocus::Key));
1832 }
1833
1834 #[tokio::test]
1835 async fn open_key_suggestions_keep_the_arrows() {
1836 let (mut d, tx, _rx) = dialog_with(vec![]).await;
1837 d.set_keys(vec!["due".into(), "status".into()]);
1838 d.handle_key(k(KeyCode::Char('a')), &tx);
1839 d.handle_key(k(KeyCode::Char('u')), &tx); d.handle_key(k(KeyCode::Down), &tx);
1841 assert!(
1842 d.form_focus() == Some(FormFocus::Key),
1843 "Down moves in the open list, not to the next field"
1844 );
1845 d.handle_key(k(KeyCode::Esc), &tx); d.handle_key(k(KeyCode::Down), &tx);
1847 assert!(d.form_focus() == Some(FormFocus::Kind));
1848 }
1849
1850 #[tokio::test]
1851 async fn clicking_type_arrows_cycles_kind() {
1852 let (mut d, tx, _rx) = dialog_with(vec![]).await;
1853 d.handle_key(k(KeyCode::Char('a')), &tx);
1854 draw(&mut d);
1855 let (x, y) = d.kind_next_origin().unwrap();
1856 d.handle_mouse(&mouse(x, y), &tx);
1857 assert_eq!(d.form_kind(), Some(PropertyKind::Text));
1858 }
1859
1860 #[tokio::test]
1861 async fn buttons_disabled_before_first_load() {
1862 let vault = temp_vault("props_dialog").await;
1863 let d = PropertiesDialog::new_unloaded(VaultPath::note_path_from("/n.md"), vault);
1864 assert!(!d.buttons.is_enabled(BTN_ADD));
1865 assert!(!d.buttons.is_enabled(BTN_EDIT));
1866 assert!(!d.buttons.is_enabled(BTN_DELETE));
1867 assert!(d.buttons.is_enabled(BTN_CLOSE));
1868 }
1869
1870 #[tokio::test]
1871 async fn single_click_on_keyboard_selected_row_edits() {
1872 let (mut d, tx, _rx) = dialog_with(sample()).await;
1873 d.handle_key(k(KeyCode::Down), &tx);
1874 assert_eq!(d.selected, 1);
1875 draw(&mut d);
1876 let (x, y) = d.row_origin(1).unwrap();
1877 d.handle_mouse(&mouse(x + 1, y), &tx);
1878 assert!(
1879 matches!(d.mode, Mode::Form(ref f) if f.orig_key.as_deref() == Some("tags")),
1880 "one click on the selected row opens it"
1881 );
1882 }
1883
1884 #[tokio::test]
1885 async fn busy_ignores_write_actions() {
1886 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1887 draw(&mut d);
1888 d.busy = true;
1889 d.sync_buttons();
1890 for code in [KeyCode::Char('d'), KeyCode::Char('a'), KeyCode::Enter] {
1891 d.handle_key(k(code), &tx);
1892 assert!(matches!(d.mode, Mode::List), "{code:?} opened nothing");
1893 }
1894 d.busy = false;
1896 d.sync_buttons();
1897 draw(&mut d);
1898 let spots: Vec<(usize, u16, u16)> = [BTN_ADD, BTN_EDIT, BTN_DELETE]
1899 .into_iter()
1900 .map(|b| {
1901 let (x, y) = (0..24u16)
1902 .flat_map(|y| (0..80u16).map(move |x| (x, y)))
1903 .find(|&(x, y)| d.buttons.hit_at(x, y) == Some(b))
1904 .expect("button rendered");
1905 (b, x, y)
1906 })
1907 .collect();
1908 d.busy = true;
1909 d.sync_buttons();
1910 for (b, x, y) in spots {
1911 d.handle_mouse(&mouse(x, y), &tx);
1912 assert!(matches!(d.mode, Mode::List), "button {b} opened nothing");
1913 }
1914 d.press(BTN_ADD, &tx);
1916 d.press(BTN_EDIT, &tx);
1917 d.press(BTN_DELETE, &tx);
1918 assert!(matches!(d.mode, Mode::List));
1919 assert!(rx.try_recv().is_err(), "nothing written");
1920 }
1921
1922 #[tokio::test]
1923 async fn confirm_footer_shows_yn_hint() {
1924 let (mut d, tx, _rx) = dialog_with(sample()).await;
1925 d.handle_key(k(KeyCode::Char('d')), &tx);
1926 let theme = Theme::gruvbox_dark();
1927 let mut t = Terminal::new(TestBackend::new(80, 24)).unwrap();
1928 t.draw(|f| d.render(f, f.area(), &theme)).unwrap();
1929 let buf = t.backend().buffer().clone();
1930 let text: String = (0..buf.area.height)
1931 .map(|y| {
1932 (0..buf.area.width)
1933 .map(|x| buf[(x, y)].symbol().to_string())
1934 .collect::<String>()
1935 })
1936 .collect::<Vec<_>>()
1937 .join("\n");
1938 assert!(text.contains("Delete \"status\"? y/n"), "{text}");
1939 }
1940
1941 #[tokio::test]
1942 async fn auto_on_a_core_list_key_splits_on_commas() {
1943 let (mut d, tx, mut rx) = dialog_with(vec![]).await;
1944 vault_note(&d, "body").await;
1945 d.handle_key(k(KeyCode::Char('a')), &tx);
1946 type_into(&mut d, &tx, "aliases");
1947 d.handle_key(k(KeyCode::Tab), &tx);
1948 d.handle_key(k(KeyCode::Tab), &tx);
1949 type_into(&mut d, &tx, "a, b");
1950 d.handle_key(k(KeyCode::Enter), &tx);
1951 assert!(next_written(&mut rx).await.is_ok());
1952 assert_eq!(
1953 d.vault.get_property(&d.path, "aliases").await.unwrap(),
1954 Some(Some(PropertyValue::List(vec!["a".into(), "b".into()])))
1955 );
1956 }
1957
1958 #[tokio::test]
1959 async fn refused_rename_offers_no_store_anyway() {
1960 let (mut d, tx, mut rx) = dialog_with(vec![
1961 ("status".into(), Some(PropertyValue::Text("draft".into()))),
1962 ("due".into(), Some(PropertyValue::Text("soon".into()))),
1963 ])
1964 .await;
1965 let text = "+++\nstatus = \"draft\"\ndue = \"soon\"\n+++\nbody";
1966 vault_note(&d, text).await;
1967 d.handle_key(k(KeyCode::Enter), &tx); for _ in 0.."status".len() {
1969 d.handle_key(k(KeyCode::Backspace), &tx);
1970 }
1971 type_into(&mut d, &tx, "due");
1972 d.handle_key(k(KeyCode::Enter), &tx);
1973 let result = next_written(&mut rx).await;
1974 assert!(result.is_err());
1975 d.handle_written(&result, &tx);
1976 assert!(d.form_error().is_some(), "refusal shown");
1977 assert_eq!(d.store_anyway_kind(), None);
1978 assert_eq!(d.vault.get_note_text(&d.path).await.unwrap(), text);
1979 }
1980
1981 #[tokio::test]
1982 async fn clicking_the_key_field_puts_the_cursor_at_the_end() {
1983 let (mut d, tx, _rx) = dialog_with(vec![(
1984 "status".into(),
1985 Some(PropertyValue::Text("draft".into())),
1986 )])
1987 .await;
1988 d.handle_key(k(KeyCode::Enter), &tx);
1989 d.handle_key(k(KeyCode::Home), &tx);
1990 draw(&mut d);
1991 let Mode::Form(form) = &d.mode else {
1992 panic!("form")
1993 };
1994 let r = form.key.field_rect().unwrap();
1995 d.handle_mouse(&mouse(r.x, r.y), &tx);
1996 type_into(&mut d, &tx, "x");
1997 let Mode::Form(form) = &d.mode else {
1998 panic!("form")
1999 };
2000 assert_eq!(form.key.value(), "statusx");
2001 }
2002
2003 fn button_spot(buttons: &ButtonRow, b: usize) -> (u16, u16) {
2005 (0..24u16)
2006 .flat_map(|y| (0..80u16).map(move |x| (x, y)))
2007 .find(|&(x, y)| buttons.hit_at(x, y) == Some(b))
2008 .expect("button rendered")
2009 }
2010
2011 fn closed(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> bool {
2012 let mut closed = false;
2013 while let Ok(evt) = rx.try_recv() {
2014 closed |= matches!(evt, AppEvent::CloseOverlay);
2015 }
2016 closed
2017 }
2018
2019 #[tokio::test]
2022 async fn busy_list_cannot_be_closed_until_the_result_arrives() {
2023 let (mut d, tx, mut rx) = dialog_with(sample()).await;
2024 draw(&mut d);
2025 let (x, y) = button_spot(&d.buttons, BTN_CLOSE);
2026 d.busy = true;
2027 d.sync_buttons();
2028 d.handle_key(k(KeyCode::Esc), &tx);
2029 d.handle_mouse(&mouse(x, y), &tx);
2030 d.press(BTN_CLOSE, &tx);
2031 assert!(!closed(&mut rx), "no CloseOverlay while saving");
2032 d.handle_written(&Ok("property removed".into()), &tx);
2033 closed(&mut rx);
2034 d.handle_key(k(KeyCode::Esc), &tx);
2035 assert!(closed(&mut rx), "Esc closes once the write landed");
2036 }
2037
2038 #[tokio::test]
2041 async fn busy_form_cancel_is_ignored_until_the_result_arrives() {
2042 let (mut d, tx, mut rx) = dialog_with(sample()).await;
2043 d.handle_key(k(KeyCode::Char('a')), &tx);
2044 draw(&mut d);
2045 let Mode::Form(form) = &d.mode else {
2046 panic!("form")
2047 };
2048 let (x, y) = button_spot(&form.buttons, 1);
2049 d.busy = true;
2050 d.sync_buttons();
2051 d.handle_key(k(KeyCode::Esc), &tx);
2052 assert!(matches!(d.mode, Mode::Form(_)), "Esc ignored while saving");
2053 d.handle_mouse(&mouse(x, y), &tx);
2054 assert!(matches!(d.mode, Mode::Form(_)), "Cancel click ignored");
2055 d.run_form_action(FormAction::Cancel, &tx);
2056 assert!(matches!(d.mode, Mode::Form(_)), "Cancel ignored");
2057 assert!(!closed(&mut rx));
2058 d.handle_written(&Err(PropertyWriteError::Other("nope".into())), &tx);
2059 d.handle_key(k(KeyCode::Esc), &tx);
2060 assert!(matches!(d.mode, Mode::List), "Esc cancels once idle");
2061 d.handle_key(k(KeyCode::Esc), &tx);
2062 assert!(closed(&mut rx), "and the next Esc closes the dialog");
2063 }
2064}