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