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 pub tree_state: ListState,
73 pub tree_items: Vec<TreeItem>,
74 pub tree_width: u16,
75
76 pub note_actions_state: ListState,
78
79 pub directory_actions_state: ListState,
81
82 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(¬e.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}