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
1256#[cfg(test)]
1257mod tests {
1258 use super::*;
1259 use kimun_core::note::PropertyValue;
1260 use ratatui::crossterm::event::{KeyCode, KeyEvent};
1261 use ratatui::{Terminal, backend::TestBackend};
1262 use tokio::sync::mpsc::unbounded_channel;
1263
1264 use crate::components::events::InputEvent;
1265 use crate::settings::themes::Theme;
1266 use crate::test_support::{mouse_down_at, temp_vault};
1267
1268 fn k(code: KeyCode) -> KeyEvent {
1269 KeyEvent::from(code)
1270 }
1271
1272 async fn dialog_with(
1273 entries: Vec<PropertyEntry>,
1274 ) -> (
1275 PropertiesDialog,
1276 AppTx,
1277 tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
1278 ) {
1279 let vault = temp_vault("props_dialog").await;
1280 let (tx, rx) = unbounded_channel();
1281 let mut d = PropertiesDialog::new_unloaded(VaultPath::note_path_from("/n.md"), vault);
1282 d.handle_loaded(&Ok(entries));
1283 (d, tx, rx)
1284 }
1285
1286 fn sample() -> Vec<PropertyEntry> {
1287 vec![
1288 ("status".into(), Some(PropertyValue::Text("draft".into()))),
1289 (
1290 "tags".into(),
1291 Some(PropertyValue::List(vec!["work".into(), "q4".into()])),
1292 ),
1293 ("due".into(), None),
1294 ]
1295 }
1296
1297 fn draw(d: &mut PropertiesDialog) {
1298 let theme = Theme::gruvbox_dark();
1299 let mut t = Terminal::new(TestBackend::new(80, 24)).unwrap();
1300 t.draw(|f| d.render(f, f.area(), &theme)).unwrap();
1301 }
1302
1303 fn mouse(col: u16, row: u16) -> MouseEvent {
1304 match mouse_down_at(col, row) {
1305 InputEvent::Mouse(m) => m,
1306 _ => unreachable!(),
1307 }
1308 }
1309
1310 #[tokio::test]
1311 async fn rows_show_key_type_value() {
1312 let (d, _tx, _rx) = dialog_with(sample()).await;
1313 assert_eq!(
1314 d.row_cells(0),
1315 ("status".into(), "text".into(), "draft".into())
1316 );
1317 assert_eq!(
1318 d.row_cells(1),
1319 ("tags".into(), "list".into(), "work, q4".into())
1320 );
1321 assert_eq!(d.row_cells(2), ("due".into(), "—".into(), String::new()));
1322 }
1323
1324 #[tokio::test]
1325 async fn arrows_and_jk_move_selection() {
1326 let (mut d, tx, _rx) = dialog_with(sample()).await;
1327 d.handle_key(k(KeyCode::Down), &tx);
1328 d.handle_key(k(KeyCode::Char('j')), &tx);
1329 assert_eq!(d.selected, 2);
1330 d.handle_key(k(KeyCode::Down), &tx);
1331 assert_eq!(d.selected, 2, "stops at the end");
1332 d.handle_key(k(KeyCode::Char('k')), &tx);
1333 assert_eq!(d.selected, 1);
1334 }
1335
1336 #[tokio::test]
1337 async fn click_selects_then_second_click_edits() {
1338 let (mut d, tx, _rx) = dialog_with(sample()).await;
1339 draw(&mut d);
1340 let (x, y) = d.row_origin(1).unwrap();
1341 d.handle_mouse(&mouse(x + 1, y), &tx);
1342 assert_eq!(d.selected, 1);
1343 assert!(matches!(d.mode, Mode::List));
1344 d.handle_mouse(&mouse(x + 1, y), &tx);
1345 assert!(
1346 matches!(d.mode, Mode::Form(_)),
1347 "second click opens the form"
1348 );
1349 }
1350
1351 #[tokio::test]
1352 async fn delete_asks_and_y_removes_from_disk() {
1353 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1354 d.vault
1355 .create_note(&d.path, "+++\nstatus = \"draft\"\n+++\nbody")
1356 .await
1357 .unwrap();
1358 d.handle_key(k(KeyCode::Char('d')), &tx);
1359 assert!(matches!(d.mode, Mode::ConfirmDelete(ref key) if key == "status"));
1360 d.handle_key(k(KeyCode::Char('y')), &tx);
1361 assert!(d.busy, "write in flight");
1362 assert!(next_written(&mut rx).await.is_ok());
1365 assert!(d.vault.get_properties(&d.path).await.unwrap().is_empty());
1366 }
1367
1368 #[tokio::test]
1369 async fn delete_cancelled_by_n_or_esc() {
1370 for code in [KeyCode::Char('n'), KeyCode::Esc] {
1371 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1372 d.handle_key(k(KeyCode::Delete), &tx);
1373 d.handle_key(k(code), &tx);
1374 assert!(matches!(d.mode, Mode::List));
1375 assert!(rx.try_recv().is_err(), "nothing written");
1376 }
1377 }
1378
1379 #[tokio::test]
1380 async fn malformed_frontmatter_is_read_only() {
1381 let vault = temp_vault("props_dialog").await;
1382 let (tx, mut rx) = unbounded_channel();
1383 let mut d = PropertiesDialog::new_unloaded(VaultPath::note_path_from("/n.md"), vault);
1384 d.handle_loaded(&Err("'a' appears more than once".into()));
1385 for code in [
1386 KeyCode::Char('a'),
1387 KeyCode::Char('e'),
1388 KeyCode::Char('d'),
1389 KeyCode::Enter,
1390 ] {
1391 d.handle_key(k(code), &tx);
1392 }
1393 assert!(matches!(d.mode, Mode::List));
1394 assert!(rx.try_recv().is_err());
1395 assert!(!d.buttons.is_enabled(BTN_ADD));
1396 assert!(d.buttons.is_enabled(BTN_CLOSE));
1397 }
1398
1399 #[tokio::test]
1400 async fn esc_closes_dialog() {
1401 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1402 d.handle_key(k(KeyCode::Esc), &tx);
1403 assert!(matches!(rx.try_recv(), Ok(AppEvent::CloseOverlay)));
1404 }
1405
1406 #[tokio::test]
1407 async fn successful_write_reloads_list_and_flashes() {
1408 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1409 d.busy = true;
1410 d.handle_written(&Ok("property saved".into()), &tx);
1411 assert!(!d.busy);
1412 let mut saw_reload = false;
1413 let mut saw_flash = false;
1414 while let Ok(evt) = rx.try_recv() {
1415 match evt {
1416 AppEvent::NoteReloadFromDisk(_) => saw_reload = true,
1417 AppEvent::FlashMessage(m) => saw_flash = m == "property saved",
1418 _ => {}
1419 }
1420 }
1421 assert!(saw_flash);
1422 assert!(
1423 !saw_reload,
1424 "the write task sends the editor reload, not the result handler"
1425 );
1426 assert!(d.loading, "list re-read requested");
1427 }
1428
1429 fn add_via_keys(d: &mut PropertiesDialog, tx: &AppTx, key: &str, value: &str) {
1431 d.handle_key(k(KeyCode::Char('a')), tx);
1432 type_into(d, tx, key);
1433 d.handle_key(k(KeyCode::Tab), tx);
1434 d.handle_key(k(KeyCode::Tab), tx);
1435 type_into(d, tx, value);
1436 d.handle_key(k(KeyCode::Enter), tx);
1437 }
1438
1439 async fn next_reload(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> VaultPath {
1440 loop {
1441 let evt = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
1442 .await
1443 .expect("timely reload")
1444 .expect("open channel");
1445 if let AppEvent::NoteReloadFromDisk(p) = evt {
1446 return p;
1447 }
1448 }
1449 }
1450
1451 #[tokio::test]
1452 async fn closing_mid_save_still_reloads_the_editor() {
1453 let (mut d, tx, mut rx) = dialog_with(vec![]).await;
1454 vault_note(&d, "body").await;
1455 let path = d.path.clone();
1456 let vault = d.vault.clone();
1457 add_via_keys(&mut d, &tx, "k", "v");
1458 assert!(d.busy, "write in flight");
1459 drop(d);
1461 assert_eq!(next_reload(&mut rx).await, path);
1462 assert_eq!(
1463 vault.get_property(&path, "k").await.unwrap(),
1464 Some(Some(PropertyValue::Text("v".into())))
1465 );
1466 }
1467
1468 #[tokio::test]
1469 async fn closing_mid_delete_still_reloads_the_editor() {
1470 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1471 vault_note(&d, "+++\nstatus = \"draft\"\n+++\nbody").await;
1472 let path = d.path.clone();
1473 d.handle_key(k(KeyCode::Char('d')), &tx);
1474 d.handle_key(k(KeyCode::Char('y')), &tx);
1475 drop(d);
1476 assert_eq!(next_reload(&mut rx).await, path);
1477 }
1478
1479 #[tokio::test]
1480 async fn results_for_another_note_are_ignored() {
1481 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1482 let other = VaultPath::note_path_from("/other.md");
1483 d.busy = true;
1484 d.on_written(&other, &Ok("property saved".into()), &tx);
1485 assert!(d.busy, "another note's write result is not this dialog's");
1486 assert!(rx.try_recv().is_err(), "nothing flashed or reloaded");
1487 d.on_loaded(&other, &Ok(vec![]));
1488 assert_eq!(d.entries.len(), 3, "another note's entries are not loaded");
1489 let own = d.path.clone();
1491 d.on_written(&own, &Ok("property saved".into()), &tx);
1492 assert!(!d.busy);
1493 d.on_loaded(&own, &Ok(vec![]));
1494 assert!(d.entries.is_empty());
1495 }
1496
1497 #[tokio::test]
1498 async fn unchanged_edit_save_writes_nothing() {
1499 let (mut d, tx, mut rx) = dialog_with(vec![(
1500 "authors".into(),
1501 Some(PropertyValue::List(vec!["Doe, John".into()])),
1502 )])
1503 .await;
1504 let text = "+++\nauthors = [\"Doe, John\"]\n+++\nbody";
1505 vault_note(&d, text).await;
1506 d.handle_key(k(KeyCode::Enter), &tx); d.handle_key(k(KeyCode::Enter), &tx); assert!(matches!(d.mode, Mode::List), "back to the list");
1509 assert!(!d.busy, "no write spawned");
1510 let spawned =
1511 tokio::time::timeout(std::time::Duration::from_millis(300), next_written(&mut rx))
1512 .await;
1513 assert!(spawned.is_err(), "nothing written");
1514 assert_eq!(d.vault.get_note_text(&d.path).await.unwrap(), text);
1515 }
1516
1517 #[tokio::test]
1518 async fn auto_edit_keeps_the_notes_own_kind_when_the_vault_has_none() {
1519 let (mut d, tx, mut rx) = dialog_with(vec![(
1520 "code".into(),
1521 Some(PropertyValue::Text("42".into())),
1522 )])
1523 .await;
1524 vault_note(&d, "+++\ncode = \"42\"\n+++\nbody").await;
1525 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);
1529 type_into(&mut d, &tx, "3");
1530 d.handle_key(k(KeyCode::Enter), &tx);
1531 assert!(next_written(&mut rx).await.is_ok());
1532 assert_eq!(
1533 d.vault.get_property(&d.path, "code").await.unwrap(),
1534 Some(Some(PropertyValue::Text("43".into())))
1535 );
1536 }
1537
1538 #[tokio::test]
1539 async fn add_with_an_existing_key_is_refused_locally() {
1540 let (mut d, tx, mut rx) = dialog_with(vec![(
1541 "status".into(),
1542 Some(PropertyValue::Text("draft".into())),
1543 )])
1544 .await;
1545 let text = "+++\nstatus = \"draft\"\n+++\nbody";
1546 vault_note(&d, text).await;
1547 add_via_keys(&mut d, &tx, "Status", "done");
1548 assert_eq!(
1549 d.form_error().as_deref(),
1550 Some("'Status' already exists — edit it")
1551 );
1552 assert!(!d.busy, "nothing spawned");
1553 let spawned =
1554 tokio::time::timeout(std::time::Duration::from_millis(300), next_written(&mut rx))
1555 .await;
1556 assert!(spawned.is_err(), "nothing written");
1557 assert_eq!(d.vault.get_note_text(&d.path).await.unwrap(), text);
1558 }
1559
1560 #[tokio::test]
1561 async fn malformed_write_error_turns_read_only() {
1562 let (mut d, tx, _rx) = dialog_with(sample()).await;
1563 d.busy = true;
1564 d.handle_written(&Err(PropertyWriteError::Malformed("bad block".into())), &tx);
1565 assert!(d.read_only());
1566 assert!(!d.buttons.is_enabled(BTN_ADD));
1567 }
1568
1569 async fn vault_note(d: &PropertiesDialog, text: &str) {
1570 d.vault.create_note(&d.path, text).await.unwrap();
1571 }
1572
1573 async fn next_written(
1574 rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
1575 ) -> Result<String, PropertyWriteError> {
1576 loop {
1577 let evt = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
1578 .await
1579 .expect("timely")
1580 .expect("open channel");
1581 if let AppEvent::OverlayData(OverlayData::PropertyWritten { result, .. }) = evt {
1582 return result;
1583 }
1584 }
1585 }
1586
1587 fn type_into(d: &mut PropertiesDialog, tx: &AppTx, s: &str) {
1588 for c in s.chars() {
1589 d.handle_key(k(KeyCode::Char(c)), tx);
1590 }
1591 }
1592
1593 #[tokio::test]
1594 async fn add_writes_a_new_property() {
1595 let (mut d, tx, mut rx) = dialog_with(vec![]).await;
1596 vault_note(&d, "body").await;
1597 d.handle_key(k(KeyCode::Char('a')), &tx);
1598 type_into(&mut d, &tx, "priority");
1599 d.handle_key(k(KeyCode::Tab), &tx); d.handle_key(k(KeyCode::Tab), &tx); type_into(&mut d, &tx, "2");
1602 d.handle_key(k(KeyCode::Enter), &tx);
1603 assert!(next_written(&mut rx).await.is_ok());
1604 assert_eq!(
1605 d.vault.get_property(&d.path, "priority").await.unwrap(),
1606 Some(Some(PropertyValue::Number(2.0)))
1607 );
1608 }
1609
1610 #[tokio::test]
1611 async fn empty_key_and_empty_value_are_refused_locally() {
1612 let (mut d, tx, mut rx) = dialog_with(vec![]).await;
1613 d.handle_key(k(KeyCode::Char('a')), &tx);
1614 d.handle_key(k(KeyCode::Enter), &tx);
1615 assert_eq!(d.form_error().as_deref(), Some("key required"));
1616 type_into(&mut d, &tx, "k");
1617 d.handle_key(k(KeyCode::Enter), &tx);
1618 assert_eq!(d.form_error().as_deref(), Some("value required"));
1619 assert!(rx.try_recv().is_err(), "nothing spawned");
1620 }
1621
1622 #[tokio::test]
1628 async fn key_change_renames_then_sets() {
1629 let (mut d, tx, mut rx) = dialog_with(vec![(
1630 "status".into(),
1631 Some(PropertyValue::Text("draft".into())),
1632 )])
1633 .await;
1634 vault_note(&d, "+++\na = 1\nstatus = \"draft\"\nz = 2\n+++\nbody").await;
1635 d.handle_key(k(KeyCode::Enter), &tx); for _ in 0.."status".len() {
1637 d.handle_key(k(KeyCode::Backspace), &tx);
1638 }
1639 type_into(&mut d, &tx, "state");
1640 d.handle_key(k(KeyCode::Tab), &tx);
1641 d.handle_key(k(KeyCode::Tab), &tx);
1642 for _ in 0.."draft".len() {
1643 d.handle_key(k(KeyCode::Backspace), &tx);
1644 }
1645 type_into(&mut d, &tx, "done");
1646 d.handle_key(k(KeyCode::Enter), &tx);
1647 assert!(next_written(&mut rx).await.is_ok());
1648 assert_eq!(
1649 d.vault.get_note_text(&d.path).await.unwrap(),
1650 "+++\na = 1\nstate = \"done\"\nz = 2\n+++\nbody"
1651 );
1652 }
1653
1654 #[tokio::test]
1655 async fn bare_key_rename_keeps_it_valueless() {
1656 let (mut d, tx, mut rx) = dialog_with(vec![("due".into(), None)]).await;
1657 vault_note(&d, "---\ndue:\n---\nbody").await;
1658 d.handle_key(k(KeyCode::Enter), &tx);
1659 for _ in 0.."due".len() {
1660 d.handle_key(k(KeyCode::Backspace), &tx);
1661 }
1662 type_into(&mut d, &tx, "deadline");
1663 d.handle_key(k(KeyCode::Enter), &tx);
1664 assert!(next_written(&mut rx).await.is_ok());
1665 assert_eq!(
1666 d.vault.get_note_text(&d.path).await.unwrap(),
1667 "---\ndeadline:\n---\nbody"
1668 );
1669 }
1670
1671 #[tokio::test]
1672 async fn mismatch_offers_store_anyway_which_forces_the_kind() {
1673 let (mut d, tx, mut rx) = dialog_with(vec![]).await;
1674 for n in ["/o1.md", "/o2.md"] {
1676 d.vault
1677 .create_note(&VaultPath::note_path_from(n), "+++\npriority = 1\n+++\nx")
1678 .await
1679 .unwrap();
1680 }
1681 vault_note(&d, "body").await;
1682 d.handle_key(k(KeyCode::Char('a')), &tx);
1683 type_into(&mut d, &tx, "priority");
1684 d.handle_key(k(KeyCode::Tab), &tx);
1685 d.handle_key(k(KeyCode::Tab), &tx);
1686 type_into(&mut d, &tx, "high");
1687 d.handle_key(k(KeyCode::Enter), &tx);
1688 let result = next_written(&mut rx).await;
1689 assert!(matches!(result, Err(PropertyWriteError::Invalid(_))));
1690 d.handle_written(&result, &tx);
1691 assert_eq!(d.store_anyway_kind(), Some(PropertyKind::Text));
1692 d.press_store_anyway(&tx);
1693 assert!(next_written(&mut rx).await.is_ok());
1694 assert_eq!(
1695 d.vault.get_property(&d.path, "priority").await.unwrap(),
1696 Some(Some(PropertyValue::Text("high".into())))
1697 );
1698 }
1699
1700 #[tokio::test]
1701 async fn forced_type_parse_error_has_no_store_anyway() {
1702 let (mut d, tx, mut rx) = dialog_with(vec![]).await;
1703 vault_note(&d, "body").await;
1704 d.handle_key(k(KeyCode::Char('a')), &tx);
1705 type_into(&mut d, &tx, "when");
1706 d.handle_key(k(KeyCode::Tab), &tx); for _ in 0..4 {
1708 d.handle_key(k(KeyCode::Right), &tx); }
1710 d.handle_key(k(KeyCode::Tab), &tx);
1711 type_into(&mut d, &tx, "not a date");
1712 d.handle_key(k(KeyCode::Enter), &tx);
1713 let result = next_written(&mut rx).await;
1714 d.handle_written(&result, &tx);
1715 assert!(d.form_error().is_some());
1716 assert_eq!(d.store_anyway_kind(), None);
1717 }
1718
1719 #[tokio::test]
1720 async fn rename_then_failed_set_reloads_and_keeps_new_key() {
1721 let (mut d, tx, mut rx) = dialog_with(vec![(
1722 "status".into(),
1723 Some(PropertyValue::Text("draft".into())),
1724 )])
1725 .await;
1726 vault_note(&d, "+++\nstatus = \"draft\"\n+++\nbody").await;
1727 d.handle_key(k(KeyCode::Enter), &tx); for _ in 0.."status".len() {
1729 d.handle_key(k(KeyCode::Backspace), &tx);
1730 }
1731 type_into(&mut d, &tx, "when");
1732 d.handle_key(k(KeyCode::Tab), &tx); for _ in 0..4 {
1734 d.handle_key(k(KeyCode::Right), &tx); }
1736 d.handle_key(k(KeyCode::Tab), &tx);
1737 for _ in 0.."draft".len() {
1738 d.handle_key(k(KeyCode::Backspace), &tx);
1739 }
1740 type_into(&mut d, &tx, "not a date");
1741 d.handle_key(k(KeyCode::Enter), &tx);
1742 assert_eq!(next_reload(&mut rx).await, d.path);
1744 let result = next_written(&mut rx).await;
1745 assert!(result.is_err());
1746 d.handle_written(&result, &tx);
1747 assert_eq!(
1749 d.vault.get_note_text(&d.path).await.unwrap(),
1750 "+++\nwhen = \"draft\"\n+++\nbody"
1751 );
1752 assert!(matches!(d.mode, Mode::Form(_)), "form stays open");
1753 assert!(d.form_error().is_some());
1754 assert_eq!(d.form_orig_key().as_deref(), Some("when"));
1755 assert!(d.loading, "list re-read requested");
1756 }
1757
1758 #[tokio::test]
1759 async fn second_save_while_busy_is_ignored() {
1760 let (mut d, tx, mut rx) = dialog_with(vec![]).await;
1761 vault_note(&d, "body").await;
1762 d.handle_key(k(KeyCode::Char('a')), &tx);
1763 type_into(&mut d, &tx, "k");
1764 d.handle_key(k(KeyCode::Tab), &tx);
1765 d.handle_key(k(KeyCode::Tab), &tx);
1766 type_into(&mut d, &tx, "v");
1767 d.handle_key(k(KeyCode::Enter), &tx);
1768 d.handle_key(k(KeyCode::Enter), &tx); assert!(next_written(&mut rx).await.is_ok());
1770 let second =
1771 tokio::time::timeout(std::time::Duration::from_millis(300), next_written(&mut rx))
1772 .await;
1773 assert!(second.is_err(), "only one write was spawned");
1774 }
1775
1776 #[tokio::test]
1777 async fn esc_in_form_returns_to_list_without_writing() {
1778 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1779 d.handle_key(k(KeyCode::Char('a')), &tx);
1780 d.handle_key(k(KeyCode::Esc), &tx); assert!(matches!(d.mode, Mode::List));
1782 assert!(rx.try_recv().is_err());
1783 }
1784
1785 #[tokio::test]
1786 async fn arrows_move_between_form_fields() {
1787 let (mut d, tx, _rx) = dialog_with(vec![]).await;
1788 d.handle_key(k(KeyCode::Char('a')), &tx);
1789 assert!(d.form_focus() == Some(FormFocus::Key));
1790 d.handle_key(k(KeyCode::Down), &tx);
1791 assert!(d.form_focus() == Some(FormFocus::Kind));
1792 d.handle_key(k(KeyCode::Down), &tx);
1793 assert!(d.form_focus() == Some(FormFocus::Value));
1794 d.handle_key(k(KeyCode::Down), &tx);
1795 assert!(d.form_focus() == Some(FormFocus::Buttons), "Value → Save");
1796 d.handle_key(k(KeyCode::Up), &tx);
1797 assert!(d.form_focus() == Some(FormFocus::Value));
1798 d.handle_key(k(KeyCode::Up), &tx);
1799 d.handle_key(k(KeyCode::Up), &tx);
1800 assert!(d.form_focus() == Some(FormFocus::Key));
1801 }
1802
1803 #[tokio::test]
1804 async fn open_key_suggestions_keep_the_arrows() {
1805 let (mut d, tx, _rx) = dialog_with(vec![]).await;
1806 d.set_keys(vec!["due".into(), "status".into()]);
1807 d.handle_key(k(KeyCode::Char('a')), &tx);
1808 d.handle_key(k(KeyCode::Char('u')), &tx); d.handle_key(k(KeyCode::Down), &tx);
1810 assert!(
1811 d.form_focus() == Some(FormFocus::Key),
1812 "Down moves in the open list, not to the next field"
1813 );
1814 d.handle_key(k(KeyCode::Esc), &tx); d.handle_key(k(KeyCode::Down), &tx);
1816 assert!(d.form_focus() == Some(FormFocus::Kind));
1817 }
1818
1819 #[tokio::test]
1820 async fn clicking_type_arrows_cycles_kind() {
1821 let (mut d, tx, _rx) = dialog_with(vec![]).await;
1822 d.handle_key(k(KeyCode::Char('a')), &tx);
1823 draw(&mut d);
1824 let (x, y) = d.kind_next_origin().unwrap();
1825 d.handle_mouse(&mouse(x, y), &tx);
1826 assert_eq!(d.form_kind(), Some(PropertyKind::Text));
1827 }
1828
1829 #[tokio::test]
1830 async fn buttons_disabled_before_first_load() {
1831 let vault = temp_vault("props_dialog").await;
1832 let d = PropertiesDialog::new_unloaded(VaultPath::note_path_from("/n.md"), vault);
1833 assert!(!d.buttons.is_enabled(BTN_ADD));
1834 assert!(!d.buttons.is_enabled(BTN_EDIT));
1835 assert!(!d.buttons.is_enabled(BTN_DELETE));
1836 assert!(d.buttons.is_enabled(BTN_CLOSE));
1837 }
1838
1839 #[tokio::test]
1840 async fn single_click_on_keyboard_selected_row_edits() {
1841 let (mut d, tx, _rx) = dialog_with(sample()).await;
1842 d.handle_key(k(KeyCode::Down), &tx);
1843 assert_eq!(d.selected, 1);
1844 draw(&mut d);
1845 let (x, y) = d.row_origin(1).unwrap();
1846 d.handle_mouse(&mouse(x + 1, y), &tx);
1847 assert!(
1848 matches!(d.mode, Mode::Form(ref f) if f.orig_key.as_deref() == Some("tags")),
1849 "one click on the selected row opens it"
1850 );
1851 }
1852
1853 #[tokio::test]
1854 async fn busy_ignores_write_actions() {
1855 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1856 draw(&mut d);
1857 d.busy = true;
1858 d.sync_buttons();
1859 for code in [KeyCode::Char('d'), KeyCode::Char('a'), KeyCode::Enter] {
1860 d.handle_key(k(code), &tx);
1861 assert!(matches!(d.mode, Mode::List), "{code:?} opened nothing");
1862 }
1863 d.busy = false;
1865 d.sync_buttons();
1866 draw(&mut d);
1867 let spots: Vec<(usize, u16, u16)> = [BTN_ADD, BTN_EDIT, BTN_DELETE]
1868 .into_iter()
1869 .map(|b| {
1870 let (x, y) = (0..24u16)
1871 .flat_map(|y| (0..80u16).map(move |x| (x, y)))
1872 .find(|&(x, y)| d.buttons.hit_at(x, y) == Some(b))
1873 .expect("button rendered");
1874 (b, x, y)
1875 })
1876 .collect();
1877 d.busy = true;
1878 d.sync_buttons();
1879 for (b, x, y) in spots {
1880 d.handle_mouse(&mouse(x, y), &tx);
1881 assert!(matches!(d.mode, Mode::List), "button {b} opened nothing");
1882 }
1883 d.press(BTN_ADD, &tx);
1885 d.press(BTN_EDIT, &tx);
1886 d.press(BTN_DELETE, &tx);
1887 assert!(matches!(d.mode, Mode::List));
1888 assert!(rx.try_recv().is_err(), "nothing written");
1889 }
1890
1891 #[tokio::test]
1892 async fn confirm_footer_shows_yn_hint() {
1893 let (mut d, tx, _rx) = dialog_with(sample()).await;
1894 d.handle_key(k(KeyCode::Char('d')), &tx);
1895 let theme = Theme::gruvbox_dark();
1896 let mut t = Terminal::new(TestBackend::new(80, 24)).unwrap();
1897 t.draw(|f| d.render(f, f.area(), &theme)).unwrap();
1898 let buf = t.backend().buffer().clone();
1899 let text: String = (0..buf.area.height)
1900 .map(|y| {
1901 (0..buf.area.width)
1902 .map(|x| buf[(x, y)].symbol().to_string())
1903 .collect::<String>()
1904 })
1905 .collect::<Vec<_>>()
1906 .join("\n");
1907 assert!(text.contains("Delete \"status\"? y/n"), "{text}");
1908 }
1909
1910 #[tokio::test]
1911 async fn auto_on_a_core_list_key_splits_on_commas() {
1912 let (mut d, tx, mut rx) = dialog_with(vec![]).await;
1913 vault_note(&d, "body").await;
1914 d.handle_key(k(KeyCode::Char('a')), &tx);
1915 type_into(&mut d, &tx, "aliases");
1916 d.handle_key(k(KeyCode::Tab), &tx);
1917 d.handle_key(k(KeyCode::Tab), &tx);
1918 type_into(&mut d, &tx, "a, b");
1919 d.handle_key(k(KeyCode::Enter), &tx);
1920 assert!(next_written(&mut rx).await.is_ok());
1921 assert_eq!(
1922 d.vault.get_property(&d.path, "aliases").await.unwrap(),
1923 Some(Some(PropertyValue::List(vec!["a".into(), "b".into()])))
1924 );
1925 }
1926
1927 #[tokio::test]
1928 async fn refused_rename_offers_no_store_anyway() {
1929 let (mut d, tx, mut rx) = dialog_with(vec![
1930 ("status".into(), Some(PropertyValue::Text("draft".into()))),
1931 ("due".into(), Some(PropertyValue::Text("soon".into()))),
1932 ])
1933 .await;
1934 let text = "+++\nstatus = \"draft\"\ndue = \"soon\"\n+++\nbody";
1935 vault_note(&d, text).await;
1936 d.handle_key(k(KeyCode::Enter), &tx); for _ in 0.."status".len() {
1938 d.handle_key(k(KeyCode::Backspace), &tx);
1939 }
1940 type_into(&mut d, &tx, "due");
1941 d.handle_key(k(KeyCode::Enter), &tx);
1942 let result = next_written(&mut rx).await;
1943 assert!(result.is_err());
1944 d.handle_written(&result, &tx);
1945 assert!(d.form_error().is_some(), "refusal shown");
1946 assert_eq!(d.store_anyway_kind(), None);
1947 assert_eq!(d.vault.get_note_text(&d.path).await.unwrap(), text);
1948 }
1949
1950 #[tokio::test]
1951 async fn clicking_the_key_field_puts_the_cursor_at_the_end() {
1952 let (mut d, tx, _rx) = dialog_with(vec![(
1953 "status".into(),
1954 Some(PropertyValue::Text("draft".into())),
1955 )])
1956 .await;
1957 d.handle_key(k(KeyCode::Enter), &tx);
1958 d.handle_key(k(KeyCode::Home), &tx);
1959 draw(&mut d);
1960 let Mode::Form(form) = &d.mode else {
1961 panic!("form")
1962 };
1963 let r = form.key.field_rect().unwrap();
1964 d.handle_mouse(&mouse(r.x, r.y), &tx);
1965 type_into(&mut d, &tx, "x");
1966 let Mode::Form(form) = &d.mode else {
1967 panic!("form")
1968 };
1969 assert_eq!(form.key.value(), "statusx");
1970 }
1971
1972 fn button_spot(buttons: &ButtonRow, b: usize) -> (u16, u16) {
1974 (0..24u16)
1975 .flat_map(|y| (0..80u16).map(move |x| (x, y)))
1976 .find(|&(x, y)| buttons.hit_at(x, y) == Some(b))
1977 .expect("button rendered")
1978 }
1979
1980 fn closed(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> bool {
1981 let mut closed = false;
1982 while let Ok(evt) = rx.try_recv() {
1983 closed |= matches!(evt, AppEvent::CloseOverlay);
1984 }
1985 closed
1986 }
1987
1988 #[tokio::test]
1991 async fn busy_list_cannot_be_closed_until_the_result_arrives() {
1992 let (mut d, tx, mut rx) = dialog_with(sample()).await;
1993 draw(&mut d);
1994 let (x, y) = button_spot(&d.buttons, BTN_CLOSE);
1995 d.busy = true;
1996 d.sync_buttons();
1997 d.handle_key(k(KeyCode::Esc), &tx);
1998 d.handle_mouse(&mouse(x, y), &tx);
1999 d.press(BTN_CLOSE, &tx);
2000 assert!(!closed(&mut rx), "no CloseOverlay while saving");
2001 d.handle_written(&Ok("property removed".into()), &tx);
2002 closed(&mut rx);
2003 d.handle_key(k(KeyCode::Esc), &tx);
2004 assert!(closed(&mut rx), "Esc closes once the write landed");
2005 }
2006
2007 #[tokio::test]
2010 async fn busy_form_cancel_is_ignored_until_the_result_arrives() {
2011 let (mut d, tx, mut rx) = dialog_with(sample()).await;
2012 d.handle_key(k(KeyCode::Char('a')), &tx);
2013 draw(&mut d);
2014 let Mode::Form(form) = &d.mode else {
2015 panic!("form")
2016 };
2017 let (x, y) = button_spot(&form.buttons, 1);
2018 d.busy = true;
2019 d.sync_buttons();
2020 d.handle_key(k(KeyCode::Esc), &tx);
2021 assert!(matches!(d.mode, Mode::Form(_)), "Esc ignored while saving");
2022 d.handle_mouse(&mouse(x, y), &tx);
2023 assert!(matches!(d.mode, Mode::Form(_)), "Cancel click ignored");
2024 d.run_form_action(FormAction::Cancel, &tx);
2025 assert!(matches!(d.mode, Mode::Form(_)), "Cancel ignored");
2026 assert!(!closed(&mut rx));
2027 d.handle_written(&Err(PropertyWriteError::Other("nope".into())), &tx);
2028 d.handle_key(k(KeyCode::Esc), &tx);
2029 assert!(matches!(d.mode, Mode::List), "Esc cancels once idle");
2030 d.handle_key(k(KeyCode::Esc), &tx);
2031 assert!(closed(&mut rx), "and the next Esc closes the dialog");
2032 }
2033}