glues_tui/context/
notebook.rs

1mod tree_item;
2
3use {
4    crate::{
5        action::{Action, TuiAction},
6        input::{Input, KeyCode, KeyEvent, to_textarea_input},
7        logger::*,
8    },
9    glues_core::{
10        NotebookEvent,
11        data::Note,
12        state::notebook::{DirectoryItem, Tab},
13        types::{Id, NoteId},
14    },
15    ratatui::{text::Line, widgets::ListState},
16    std::collections::HashMap,
17    tui_textarea::TextArea,
18};
19
20#[cfg(not(target_arch = "wasm32"))]
21use arboard::Clipboard;
22
23pub use tree_item::{TreeItem, TreeItemKind};
24
25pub const REMOVE_NOTE: &str = "Remove note";
26pub const RENAME_NOTE: &str = "Rename note";
27
28pub const ADD_NOTE: &str = "Add note";
29pub const ADD_DIRECTORY: &str = "Add directory";
30pub const RENAME_DIRECTORY: &str = "Rename directory";
31pub const REMOVE_DIRECTORY: &str = "Remove directory";
32
33pub const CLOSE: &str = "Close";
34
35pub const NOTE_ACTIONS: [&str; 3] = [RENAME_NOTE, REMOVE_NOTE, CLOSE];
36pub const DIRECTORY_ACTIONS: [&str; 5] = [
37    ADD_NOTE,
38    ADD_DIRECTORY,
39    RENAME_DIRECTORY,
40    REMOVE_DIRECTORY,
41    CLOSE,
42];
43
44#[derive(Clone, Copy, PartialEq)]
45pub enum ContextState {
46    NoteTreeBrowsing,
47    NoteTreeNumbering,
48    NoteTreeGateway,
49    NoteActionsDialog,
50    DirectoryActionsDialog,
51    MoveMode,
52    EditorNormalMode { idle: bool },
53    EditorVisualMode,
54    EditorInsertMode,
55}
56
57impl ContextState {
58    pub fn is_editor(&self) -> bool {
59        matches!(
60            self,
61            ContextState::EditorNormalMode { .. }
62                | ContextState::EditorInsertMode
63                | ContextState::EditorVisualMode
64        )
65    }
66}
67
68pub struct NotebookContext {
69    pub state: ContextState,
70
71    // note tree
72    pub tree_state: ListState,
73    pub tree_items: Vec<TreeItem>,
74    pub tree_width: u16,
75
76    // note actions
77    pub note_actions_state: ListState,
78
79    // directory actions
80    pub directory_actions_state: ListState,
81
82    // editor
83    pub editor_height: u16,
84    pub tabs: Vec<Tab>,
85    pub tab_index: Option<usize>,
86    pub editors: HashMap<NoteId, EditorItem>,
87
88    pub show_line_number: bool,
89    pub show_browser: bool,
90    pub line_yanked: bool,
91    pub yank: Option<String>,
92}
93
94pub struct EditorItem {
95    pub editor: TextArea<'static>,
96    pub dirty: bool,
97}
98
99impl Default for NotebookContext {
100    fn default() -> Self {
101        Self {
102            state: ContextState::NoteTreeBrowsing,
103            tree_state: ListState::default().with_selected(Some(0)),
104            tree_items: vec![],
105            tree_width: 45,
106
107            note_actions_state: ListState::default(),
108            directory_actions_state: ListState::default(),
109
110            editor_height: 0,
111            tabs: vec![],
112            tab_index: None,
113            editors: HashMap::new(),
114
115            show_line_number: true,
116            show_browser: true,
117            line_yanked: false,
118            yank: None,
119        }
120    }
121}
122
123impl NotebookContext {
124    pub fn get_opened_note(&self) -> Option<&Note> {
125        self.tab_index
126            .and_then(|i| self.tabs.get(i))
127            .map(|t| &t.note)
128    }
129
130    pub fn get_editor(&self) -> &TextArea<'static> {
131        let note_id = &self
132            .tab_index
133            .and_then(|i| self.tabs.get(i))
134            .log_expect("[NotebookContext::get_editor] no opened note")
135            .note
136            .id;
137
138        &self
139            .editors
140            .get(note_id)
141            .log_expect("[NotebookContext::get_editor] editor not found")
142            .editor
143    }
144
145    pub fn get_editor_mut(&mut self) -> &mut TextArea<'static> {
146        let note_id = &self
147            .tab_index
148            .and_then(|i| self.tabs.get(i))
149            .log_expect("[NotebookContext::get_editor_mut] no opened note")
150            .note
151            .id;
152
153        &mut self
154            .editors
155            .get_mut(note_id)
156            .log_expect("[NotebookContext::get_editor_mut] editor not found")
157            .editor
158    }
159
160    pub fn mark_dirty(&mut self) {
161        if let Some(editor_item) = self
162            .tab_index
163            .and_then(|i| self.tabs.get(i))
164            .and_then(|tab| self.editors.get_mut(&tab.note.id))
165        {
166            editor_item.dirty = true;
167        }
168    }
169
170    pub fn mark_clean(&mut self, note_id: &NoteId) {
171        if let Some(editor_item) = self.editors.get_mut(note_id) {
172            editor_item.dirty = false;
173        }
174    }
175
176    pub fn update_items(&mut self, directory_item: &DirectoryItem) {
177        self.tree_items = self.flatten(directory_item, 0, true);
178    }
179
180    fn flatten(
181        &self,
182        directory_item: &DirectoryItem,
183        depth: usize,
184        selectable: bool,
185    ) -> Vec<TreeItem> {
186        let id = self
187            .tree_state
188            .selected()
189            .and_then(|i| self.tree_items.get(i))
190            .map(|item| item.id());
191        let is_move_mode = matches!(self.state, ContextState::MoveMode);
192        let selectable = !is_move_mode || (selectable && Some(&directory_item.directory.id) != id);
193
194        let mut items = vec![TreeItem {
195            depth,
196            target: Some(&directory_item.directory.id) == id,
197            selectable,
198            kind: TreeItemKind::Directory {
199                directory: directory_item.directory.clone(),
200                opened: directory_item.children.is_some(),
201            },
202        }];
203
204        if let Some(children) = &directory_item.children {
205            for item in &children.directories {
206                items.extend(self.flatten(item, depth + 1, selectable));
207            }
208
209            for note in &children.notes {
210                items.push(TreeItem {
211                    depth: depth + 1,
212                    target: Some(&note.id) == id,
213                    selectable: !is_move_mode,
214                    kind: TreeItemKind::Note { note: note.clone() },
215                })
216            }
217        }
218
219        items
220    }
221
222    pub fn select_item(&mut self, id: &Id) {
223        for (i, item) in self.tree_items.iter().enumerate() {
224            if item.id() == id {
225                self.tree_state.select(Some(i));
226                break;
227            }
228        }
229    }
230
231    pub fn select_first(&mut self) {
232        let i = self
233            .tree_items
234            .iter()
235            .enumerate()
236            .find(|(_, item)| item.selectable)
237            .map(|(i, _)| i);
238
239        if i.is_some() {
240            self.tree_state.select(i);
241        }
242    }
243
244    pub fn select_last(&mut self) {
245        let i = self
246            .tree_items
247            .iter()
248            .enumerate()
249            .rev()
250            .find(|(_, item)| item.selectable)
251            .map(|(i, _)| i);
252
253        if i.is_some() {
254            self.tree_state.select(i);
255        }
256    }
257
258    pub fn select_next(&mut self, step: usize) {
259        let i = match self.tree_state.selected().unwrap_or_default() + step {
260            i if i >= self.tree_items.len() => self.tree_items.len() - 1,
261            i => i,
262        };
263
264        let i = self
265            .tree_items
266            .iter()
267            .enumerate()
268            .skip(i)
269            .find(|(_, item)| item.selectable)
270            .map(|(i, _)| i);
271
272        if i.is_some() {
273            self.tree_state.select(i);
274        }
275    }
276
277    pub fn select_prev(&mut self, step: usize) {
278        let i = self
279            .tree_state
280            .selected()
281            .unwrap_or_default()
282            .saturating_sub(step);
283
284        let i = self
285            .tree_items
286            .iter()
287            .enumerate()
288            .rev()
289            .skip(self.tree_items.len() - i - 1)
290            .find(|(_, item)| item.selectable)
291            .map(|(i, _)| i);
292
293        if i.is_some() {
294            self.tree_state.select(i);
295        }
296    }
297
298    pub fn select_next_dir(&mut self) {
299        let i = self.tree_state.selected().unwrap_or_default() + 1;
300
301        if i >= self.tree_items.len() {
302            return;
303        }
304
305        let i = self
306            .tree_items
307            .iter()
308            .enumerate()
309            .skip(i)
310            .filter(|(_, item)| item.is_directory())
311            .find(|(_, item)| item.selectable)
312            .map(|(i, _)| i);
313
314        if i.is_some() {
315            self.tree_state.select(i);
316        }
317    }
318
319    pub fn select_prev_dir(&mut self) {
320        let i = self
321            .tree_state
322            .selected()
323            .unwrap_or_default()
324            .saturating_sub(1);
325
326        let i = self
327            .tree_items
328            .iter()
329            .enumerate()
330            .rev()
331            .skip(self.tree_items.len() - i - 1)
332            .filter(|(_, item)| item.is_directory())
333            .find(|(_, item)| item.selectable)
334            .map(|(i, _)| i);
335
336        if i.is_some() {
337            self.tree_state.select(i);
338        }
339    }
340
341    pub fn selected(&self) -> &TreeItem {
342        self.tree_state
343            .selected()
344            .and_then(|i| self.tree_items.get(i))
345            .log_expect("[NotebookContext::selected] selected must not be empty")
346    }
347
348    pub fn open_note(&mut self, note_id: NoteId, content: String) {
349        let item = EditorItem {
350            editor: TextArea::from(content.lines()),
351            dirty: false,
352        };
353
354        self.editors.insert(note_id, item);
355    }
356
357    pub fn apply_yank(&mut self) {
358        if self.tabs.is_empty() {
359            return;
360        }
361
362        if let Some(yank) = self.yank.as_ref().cloned() {
363            self.get_editor_mut().set_yank_text(yank);
364        }
365    }
366
367    pub fn update_yank(&mut self) {
368        let text = self.get_editor().yank_text();
369
370        #[cfg(not(target_arch = "wasm32"))]
371        if let Ok(mut clipboard) = Clipboard::new() {
372            let _ = clipboard.set_text(&text);
373        }
374
375        #[cfg(target_arch = "wasm32")]
376        crate::web::copy_to_clipboard(&text);
377
378        self.yank = Some(text);
379    }
380
381    pub fn consume(&mut self, input: &Input) -> Action {
382        let code = match input {
383            Input::Key(key) => key.code,
384            _ => return Action::None,
385        };
386
387        match self.state {
388            ContextState::NoteTreeBrowsing => self.consume_on_note_tree_browsing(code),
389            ContextState::NoteTreeGateway
390            | ContextState::NoteTreeNumbering
391            | ContextState::MoveMode => Action::PassThrough,
392            ContextState::EditorNormalMode { idle } => self.consume_on_editor_normal(input, idle),
393            ContextState::EditorVisualMode => Action::PassThrough,
394            ContextState::EditorInsertMode => self.consume_on_editor_insert(input),
395            ContextState::NoteActionsDialog => self.consume_on_note_actions(code),
396            ContextState::DirectoryActionsDialog => self.consume_on_directory_actions(code),
397        }
398    }
399
400    fn consume_on_note_tree_browsing(&mut self, code: KeyCode) -> Action {
401        match code {
402            KeyCode::Char('m') => {
403                if self
404                    .tree_state
405                    .selected()
406                    .and_then(|idx| self.tree_items.get(idx))
407                    .log_expect("[NotebookContext::consume] selected must not be empty")
408                    .is_directory()
409                {
410                    self.directory_actions_state.select_first();
411                } else {
412                    self.note_actions_state.select_first();
413                }
414
415                Action::PassThrough
416            }
417            KeyCode::Esc => TuiAction::OpenNotebookQuitMenu {
418                save_before_open: false,
419            }
420            .into(),
421            _ => Action::PassThrough,
422        }
423    }
424
425    fn consume_on_editor_normal(&mut self, input: &Input, idle: bool) -> Action {
426        let code = match input {
427            Input::Key(key) => key.code,
428            _ => return Action::None,
429        };
430
431        match code {
432            KeyCode::Esc if idle => TuiAction::OpenNotebookQuitMenu {
433                save_before_open: true,
434            }
435            .into(),
436            KeyCode::Tab if idle => {
437                self.show_browser = true;
438                self.update_yank();
439
440                TuiAction::SaveAndPassThrough.into()
441            }
442            _ => Action::PassThrough,
443        }
444    }
445
446    fn consume_on_editor_insert(&mut self, input: &Input) -> Action {
447        match input {
448            Input::Key(KeyEvent {
449                code: KeyCode::Esc, ..
450            }) => Action::Dispatch(NotebookEvent::ViewNote.into()),
451            Input::Key(KeyEvent {
452                code: KeyCode::Char('h'),
453                modifiers,
454                ..
455            }) if modifiers.ctrl => TuiAction::ShowEditorKeymap.into(),
456            Input::Key(KeyEvent {
457                code: KeyCode::Char('c' | 'x' | 'w' | 'k' | 'j'),
458                modifiers,
459                ..
460            }) if modifiers.ctrl => {
461                self.line_yanked = false;
462                if let Some(text_input) = to_textarea_input(input) {
463                    self.get_editor_mut().input(text_input);
464                }
465                Action::None
466            }
467            _ => {
468                if let Some(text_input) = to_textarea_input(input) {
469                    self.get_editor_mut().input(text_input);
470                }
471                Action::None
472            }
473        }
474    }
475
476    fn consume_on_note_actions(&mut self, code: KeyCode) -> Action {
477        match code {
478            KeyCode::Char('j') | KeyCode::Down => {
479                self.note_actions_state.select_next();
480                Action::None
481            }
482            KeyCode::Char('k') | KeyCode::Up => {
483                self.note_actions_state.select_previous();
484                Action::None
485            }
486            KeyCode::Esc => Action::Dispatch(NotebookEvent::CloseNoteActionsDialog.into()),
487            KeyCode::Enter => {
488                match NOTE_ACTIONS[self
489                    .note_actions_state
490                    .selected()
491                    .log_expect("note action must not be empty")]
492                {
493                    RENAME_NOTE => TuiAction::Prompt {
494                        message: vec![Line::raw("Enter new note name:")],
495                        action: Box::new(TuiAction::RenameNote.into()),
496                        default: Some(self.selected().name()),
497                    }
498                    .into(),
499                    REMOVE_NOTE => TuiAction::Confirm {
500                        message: "Confirm to remove note?".to_owned(),
501                        action: Box::new(TuiAction::RemoveNote.into()),
502                    }
503                    .into(),
504                    CLOSE => Action::Dispatch(NotebookEvent::CloseNoteActionsDialog.into()),
505                    _ => Action::None,
506                }
507            }
508            _ => Action::PassThrough,
509        }
510    }
511
512    fn consume_on_directory_actions(&mut self, code: KeyCode) -> Action {
513        match code {
514            KeyCode::Char('j') | KeyCode::Down => {
515                self.directory_actions_state.select_next();
516                Action::None
517            }
518            KeyCode::Char('k') | KeyCode::Up => {
519                self.directory_actions_state.select_previous();
520                Action::None
521            }
522            KeyCode::Enter => {
523                match DIRECTORY_ACTIONS[self
524                    .directory_actions_state
525                    .selected()
526                    .log_expect("directory action must not be empty")]
527                {
528                    ADD_NOTE => TuiAction::Prompt {
529                        message: vec![Line::raw("Enter note name:")],
530                        action: Box::new(TuiAction::AddNote.into()),
531                        default: None,
532                    }
533                    .into(),
534                    ADD_DIRECTORY => TuiAction::Prompt {
535                        message: vec![Line::raw("Enter directory name:")],
536                        action: Box::new(TuiAction::AddDirectory.into()),
537                        default: None,
538                    }
539                    .into(),
540                    RENAME_DIRECTORY => TuiAction::Prompt {
541                        message: vec![Line::raw("Enter new directory name:")],
542                        action: Box::new(TuiAction::RenameDirectory.into()),
543                        default: Some(self.selected().name()),
544                    }
545                    .into(),
546                    REMOVE_DIRECTORY => TuiAction::Confirm {
547                        message: "Confirm to remove directory?".to_owned(),
548                        action: Box::new(TuiAction::RemoveDirectory.into()),
549                    }
550                    .into(),
551                    CLOSE => Action::Dispatch(NotebookEvent::CloseDirectoryActionsDialog.into()),
552                    _ => Action::None,
553                }
554            }
555            KeyCode::Esc => Action::Dispatch(NotebookEvent::CloseDirectoryActionsDialog.into()),
556            _ => Action::PassThrough,
557        }
558    }
559}