Skip to main content

kimun_notes/components/dialogs/
properties_dialog.rs

1//! The note's frontmatter properties: list, delete, and the add/edit form. Core calls are async; the dialog spawns each one and the
2//! result returns through `OverlayData`.
3
4use 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// ---------------------------------------------------------------------------
28// Write errors
29// ---------------------------------------------------------------------------
30
31#[derive(Debug, Clone)]
32pub enum PropertyWriteError {
33    /// Core refused the value (`VaultError::InvalidProperty`).
34    Invalid(String),
35    /// The note's frontmatter block does not parse; the dialog turns read-only.
36    Malformed(String),
37    /// Anything else (IO, lock timeout).
38    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
60// ---------------------------------------------------------------------------
61// Dialog state
62// ---------------------------------------------------------------------------
63
64pub struct PropertiesDialog {
65    path: VaultPath,
66    vault: Arc<NoteVault>,
67    entries: Vec<PropertyEntry>,
68    load_error: Option<String>,
69    loading: bool,
70    /// A write is in flight: every write action is ignored, and so is every
71    /// way to close the dialog or its form — it stays up until the result
72    /// lands, so nothing typed in the editor meanwhile meets the reload.
73    busy: bool,
74    selected: usize,
75    scroll: usize,
76    mode: Mode,
77    /// `[+ Add] [Edit] [Delete] [Close]`
78    buttons: ButtonRow,
79    /// `[Yes] [No]`
80    confirm: ButtonRow,
81    focus: ListFocus,
82    known_keys: Vec<String>,
83    /// Rects of visible list rows from the last render (index into entries).
84    row_rects: Vec<(Rect, usize)>,
85    /// (old, new) of a rename sent with the in-flight write: if the set
86    /// after it fails, the rename already landed and the list must reload.
87    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
111// ---------------------------------------------------------------------------
112// List state
113// ---------------------------------------------------------------------------
114
115impl 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    /// A write finished. The editor reload is not sent here: the write task
204    /// sends it itself whenever the file changed, so it still arrives when
205    /// the dialog was dropped before this result (the user can't close it
206    /// while busy, but a file operation can dismiss every overlay).
207    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                    // The rename landed before the set failed: the file
228                    // changed (the task already told the editor), so reload
229                    // the list and stop treating `old` as the key.
230                    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    /// A read result addressed to `path`: ignored unless it is this dialog's note.
243    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    /// A write result addressed to `path`: ignored unless it is this dialog's note.
254    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    /// (key, type, value) as the list shows them.
266    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)); // default No
285        }
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, // n, N, Esc, Enter-on-No: cancel
363                }
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                    // A click on the already-selected row (so also the second
434                    // half of a double-click) opens it for editing.
435                    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
457// ---------------------------------------------------------------------------
458// List rendering
459// ---------------------------------------------------------------------------
460
461/// Pad or truncate (with `…`) `s` to exactly `width` display columns.
462fn 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
602// ---------------------------------------------------------------------------
603// Form state
604// ---------------------------------------------------------------------------
605
606/// `None` = auto (let core decide against the vault's type).
607const 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
617/// Width of the form's field labels (`" Key    "`).
618const 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    /// `None` for Add; the original key for Edit.
631    orig_key: Option<String>,
632    /// The original value's kind (Edit), shown as "(currently <kind>)".
633    current_kind: Option<PropertyKind>,
634    /// The original entry had no value (YAML `due:`).
635    was_bare: bool,
636    /// The Value text the Edit form was pre-filled with (`None` for Add or a
637    /// bare key): saving it unchanged under auto writes nothing.
638    orig_text: Option<String>,
639    key: KeyPicker,
640    /// Index into TYPES; 0 = auto.
641    kind_idx: usize,
642    value: SingleLineInput,
643    focus: FormFocus,
644    /// `[Save] [Cancel]`
645    buttons: ButtonRow,
646    /// Shown under Value: core's refusal or a local check.
647    error: Option<String>,
648    /// Offered after an `auto` save was refused: retry forced to this kind.
649    store_anyway: Option<PropertyKind>,
650    /// `[Store as <kind> anyway]`
651    store_btn: ButtonRow,
652    // Rects from the last render.
653    value_rect: Option<Rect>,
654    kind_prev_rect: Option<Rect>,
655    kind_next_rect: Option<Rect>,
656}
657
658/// What a form key or click asks the dialog to do once the form borrow ends.
659enum 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                // An empty list displays as `[]`; pre-fill it as empty.
699                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    /// The field as `PropertyInput` values: comma-split for a list (chosen,
716    /// or `auto` on a key that was a list or that core always keeps as a
717    /// list), one untrimmed value otherwise.
718    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    /// The kind the input infers to: the "Store as <kind> anyway" target.
740    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    /// Key → Kind → Value → [StoreAnyway] → Save → Cancel → Key.
775    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    /// The field before the button row.
818    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        // The key picker's open list owns Enter/Esc/arrows first.
828        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 => {} // Tab etc. below
840            }
841        }
842        match key.code {
843            KeyCode::Esc => FormAction::Cancel,
844            // Up/Down reach here only when the key picker's list is closed.
845            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            // Cursor at the end, as a click into the field promises.
901            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            // A save in flight keeps the form up until its result.
949            FormAction::Cancel if self.busy => {}
950            FormAction::Cancel => self.mode = Mode::List,
951        }
952    }
953
954    /// The `[Store as <kind> anyway]` button: retry forced to that kind.
955    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    /// Validate and spawn the write. `forced` overrides the type cycler
964    /// (the "store anyway" retry).
965    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        // Add never overwrites: a key the note already has is edited instead.
979        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        // An Edit saved as pre-filled (same key, type auto, same text) would
990        // only re-run inference on Display text: write nothing.
991        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        // A bare key whose value is still empty: rename only (or nothing).
1007        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        // Edit under auto: the note's own kind is the guess when the vault
1019        // has none (core still checks it against the vault's kind).
1020        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            // Whether the file changed: the rename alone counts, even if the
1032            // set after it fails.
1033            let mut changed = false;
1034            let result = async {
1035                if let Some(old) = &renamed {
1036                    // A refused rename is never a type mismatch: report it as
1037                    // `Other` so the form offers no "Store as … anyway".
1038                    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            // Sent by the task, not the result handler: the editor must
1058            // reload even when the dialog closed before the result arrives.
1059            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    /// A failed write: under Value in the form (with `[Store as … anyway]`
1071    /// after an `auto` mismatch), or the list's error row after a delete.
1072    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        // Type cycler: ‹ kind ›   (currently <kind>)
1176        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            // ButtonRow pads one cell on the left; align the bracket with the field.
1228            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        // The picker renders last so its suggestion list sits on top.
1246        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        // The spawned remove reports back as PropertyWritten (after the
1363        // editor reload it sends itself).
1364        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    /// Open the form on a fresh Add and save `key = value` (type auto).
1430    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        // Esc / Close: the editor drops the dialog before the result lands.
1460        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        // The dialog's own path still lands.
1490        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); // edit row 0
1507        d.handle_key(k(KeyCode::Enter), &tx); // save unchanged
1508        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); // edit row 0
1526        d.handle_key(k(KeyCode::Tab), &tx); // Type (auto)
1527        d.handle_key(k(KeyCode::Tab), &tx); // Value
1528        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); // → Type
1600        d.handle_key(k(KeyCode::Tab), &tx); // → Value
1601        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    // The brief's tests pressed Esc after typing a key "to close the
1623    // suggestion list if open". With no known keys the list never opens, and
1624    // Esc on a closed list return to the list, so those Esc
1625    // presses are dropped here.
1626
1627    #[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); // edit row 0
1636        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        // Two other notes make `priority` a number key.
1675        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); // Type
1707        for _ in 0..4 {
1708            d.handle_key(k(KeyCode::Right), &tx); // auto→text→number→bool→date
1709        }
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); // edit row 0
1728        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); // Type
1733        for _ in 0..4 {
1734            d.handle_key(k(KeyCode::Right), &tx); // → date
1735        }
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        // The task reports the changed file to the editor before the result.
1743        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        // The rename landed even though the set failed.
1748        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); // double press
1769        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); // key list closed already → back to list
1781        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); // "u" matches both → list opens
1809        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); // close the list
1815        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        // Click where Add, Edit and Delete rendered while enabled.
1864        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        // A press that bypasses the disabled-button hit test is gated too.
1884        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); // edit row 0 (status)
1937        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    /// Where `buttons` renders button `b` on the 80x24 test frame.
1973    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    /// A write in flight keeps the dialog open: Esc, the Close button and a
1989    /// direct press are ignored until the result lands, then Esc closes.
1990    #[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    /// The form's Cancel (Esc, the button by key or click) is ignored while
2008    /// its save is in flight: the form stays up until the result.
2009    #[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}