Skip to main content

kimun_notes/components/dialogs/
quick_note_modal.rs

1use std::sync::Arc;
2
3use kimun_core::NoteVault;
4use ratatui::Frame;
5use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent};
6use ratatui::layout::{Constraint, Direction, Layout, Rect};
7use ratatui::style::Style;
8use ratatui::widgets::Paragraph;
9
10use crate::components::event_state::EventState;
11use crate::components::events::{AppEvent, AppTx, FileOp, OverlayData};
12use crate::components::hint_row::HintRow;
13use crate::components::panel::{ModalSpec, modal_chrome};
14use crate::components::single_line_input::{InputOutcome, SingleLineInput};
15use crate::settings::themes::Theme;
16
17pub struct QuickNoteModal {
18    input: SingleLineInput,
19    vault: Arc<NoteVault>,
20    pub error: Option<String>,
21    save_hints: HintRow,
22    cancel_hint: HintRow,
23}
24
25impl QuickNoteModal {
26    pub fn new(vault: Arc<NoteVault>) -> Self {
27        Self {
28            input: SingleLineInput::new(),
29            vault,
30            error: None,
31            save_hints: HintRow::new(&[
32                (KeyCode::Enter, "Enter", "Save"),
33                (KeyCode::Enter, "Shift+Enter", "Save & Open"),
34            ])
35            .with_modifiers(1, KeyModifiers::SHIFT),
36            cancel_hint: HintRow::new(&[(KeyCode::Esc, "Esc", "Cancel")]),
37        }
38    }
39
40    /// A click on a hint chip runs its key. Modal: every mouse event is
41    /// consumed.
42    pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
43        if let Some(key) = self.save_hints.hit(m).or_else(|| self.cancel_hint.hit(m)) {
44            self.handle_key(key, tx);
45        }
46        EventState::Consumed
47    }
48
49    pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
50        // Enter — possibly with Shift to open the new note after creating it.
51        if let KeyCode::Enter = key.code {
52            if self.input.value().trim().is_empty() {
53                tx.send(AppEvent::CloseOverlay).ok();
54            } else {
55                self.submit(tx, key.modifiers.contains(KeyModifiers::SHIFT));
56            }
57            return EventState::Consumed;
58        }
59        match self.input.handle_key(&key) {
60            InputOutcome::Cancel => {
61                tx.send(AppEvent::CloseOverlay).ok();
62                EventState::Consumed
63            }
64            InputOutcome::Changed => {
65                self.error = None;
66                EventState::Consumed
67            }
68            InputOutcome::Consumed | InputOutcome::Submit => EventState::Consumed,
69            InputOutcome::NotConsumed => EventState::NotConsumed,
70        }
71    }
72
73    fn submit(&self, tx: &AppTx, open_after: bool) {
74        let text = self.input.value().to_string();
75        let vault = Arc::clone(&self.vault);
76        let tx_clone = tx.clone();
77        tokio::spawn(async move {
78            match vault.quick_note(&text).await {
79                Ok(details) => {
80                    // quick_note always materialises a fresh note — tell the
81                    // sidebar so it refreshes if browsing that directory.
82                    tx_clone
83                        .send(AppEvent::FileOp(FileOp::Created(details.path.clone())))
84                        .ok();
85                    if open_after {
86                        tx_clone.send(AppEvent::open(details.path)).ok();
87                    } else {
88                        tx_clone.send(AppEvent::CloseOverlay).ok();
89                    }
90                }
91                Err(e) => {
92                    tx_clone
93                        .send(AppEvent::OverlayData(OverlayData::Error(e.to_string())))
94                        .ok();
95                }
96            }
97        });
98    }
99
100    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
101        let height = if self.error.is_some() { 9 } else { 8 };
102        let popup_area = super::fixed_centered_rect(62, height, rect);
103
104        let fg = theme.fg.to_ratatui();
105        let gray = theme.gray.to_ratatui();
106        let bg = theme.bg_panel.to_ratatui();
107
108        let inner = modal_chrome(
109            f,
110            popup_area,
111            theme,
112            ModalSpec {
113                title: Some(" Quick Note "),
114                border: Some(Style::default().fg(theme.focus_border.to_ratatui())),
115                ..Default::default()
116            },
117        );
118
119        let rows = Layout::default()
120            .direction(Direction::Vertical)
121            .constraints([
122                Constraint::Length(1), // 0: spacer
123                Constraint::Length(1), // 1: input
124                Constraint::Length(1), // 2: separator
125                Constraint::Length(1), // 3: hint line 1
126                Constraint::Length(1), // 4: hint line 2
127                Constraint::Length(1), // 5: error (optional)
128                Constraint::Min(0),    // 6: remainder
129            ])
130            .split(inner);
131
132        if self.input.is_empty() {
133            // Placeholder text + caret in muted style.
134            f.render_widget(
135                Paragraph::new("  Type your thought...").style(Style::default().fg(gray).bg(bg)),
136                rows[1],
137            );
138            f.set_cursor_position((rows[1].x + 2, rows[1].y));
139        } else {
140            // 2-space indent matches the placeholder above.
141            self.input
142                .render(f, rows[1], Style::default().fg(fg).bg(bg), 2, true);
143        }
144
145        super::render_separator(f, rows[2], gray, bg);
146
147        let hint_style = Style::default().fg(gray).bg(bg);
148        self.save_hints.render(f, rows[3], hint_style, theme);
149        self.cancel_hint.render(f, rows[4], hint_style, theme);
150
151        if let Some(msg) = &self.error {
152            super::render_error_row(f, rows[5], msg, theme);
153        }
154    }
155}