diff --git a/.config/config.toml b/.config/config.toml index d148245..e18b1ce 100644 --- a/.config/config.toml +++ b/.config/config.toml @@ -25,6 +25,7 @@ "" = "Search" "" = "Escape" "" = "Open" +"" = "EditTask" "" = "ReloadVault" # Scrolling "" = "ViewUp" diff --git a/src/action.rs b/src/action.rs index ac3ace7..c3aa582 100644 --- a/src/action.rs +++ b/src/action.rs @@ -36,6 +36,7 @@ pub enum Action { TabRight, TabLeft, Open, + EditTask, FocusExplorer, FocusFilter, } diff --git a/src/components/explorer_tab.rs b/src/components/explorer_tab.rs index 2550085..b1ef757 100644 --- a/src/components/explorer_tab.rs +++ b/src/components/explorer_tab.rs @@ -1,12 +1,16 @@ +use std::path::PathBuf; + use color_eyre::eyre::bail; use color_eyre::Result; use crossterm::event::Event; +use layout::Flex; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Paragraph}; use tokio::sync::mpsc::UnboundedSender; use tracing::{debug, error, info}; use tui_input::backend::crossterm::EventHandler; +use tui_input::Input; use tui_scrollview::ScrollViewState; use tui_widget_list::{ListBuilder, ListState, ListView}; @@ -14,11 +18,12 @@ use super::Component; use crate::app::Mode; use crate::task_core::filter::parse_search_input; +use crate::task_core::parser::task::parse_task; use crate::task_core::vault_data::VaultData; use crate::task_core::{TaskManager, WARNING_EMOJI}; use crate::tui::Tui; use crate::widgets::help_menu::HelpMenu; -use crate::widgets::search_bar::SearchBar; +use crate::widgets::input_bar::InputBar; use crate::widgets::task_list::TaskList; use crate::{action::Action, config::Config}; @@ -42,10 +47,11 @@ pub struct ExplorerTab<'a> { state_center_view: ListState, entries_center_view: Vec<(String, String)>, entries_right_view: Vec, - search_bar_widget: SearchBar<'a>, + search_bar_widget: InputBar<'a>, task_list_widget_state: ScrollViewState, show_help: bool, help_menu_wigdet: HelpMenu<'a>, + edit_task_bar: InputBar<'a>, } impl<'a> ExplorerTab<'a> { @@ -158,7 +164,8 @@ impl<'a> ExplorerTab<'a> { return; }; - self.entries_right_view = match self.task_mgr.get_vault_data_from_path(&path_to_preview) { + self.entries_right_view = match self.task_mgr.get_vault_data_from_path(&path_to_preview, 1) + { Ok(res) => res, Err(e) => vec![VaultData::Directory(e.to_string(), vec![])], }; @@ -214,10 +221,7 @@ impl<'a> ExplorerTab<'a> { ) } - fn open_current_file(&self, tui_opt: Option<&mut Tui>) -> Result<()> { - let Some(tui) = tui_opt else { - bail!("Could not open current entry, Tui was None") - }; + fn get_current_path_to_file(&self) -> PathBuf { let mut path = self.config.tasks_config.vault_path.clone(); for e in &self .get_preview_path() @@ -231,7 +235,13 @@ impl<'a> ExplorerTab<'a> { } path.push(e); } - + path + } + fn open_current_file(&self, tui_opt: Option<&mut Tui>) -> Result<()> { + let Some(tui) = tui_opt else { + bail!("Could not open current entry, Tui was None") + }; + let path = self.get_current_path_to_file(); info!("Opening {:?} in default editor.", path); if let Some(tx) = &self.command_tx { tui.exit()?; @@ -278,6 +288,107 @@ impl<'a> ExplorerTab<'a> { footer, } } + fn render_search_bar(&mut self, frame: &mut Frame, area: Rect) { + // Search Bar + if self.search_bar_widget.is_focused { + let width = area.width.max(3) - 3; // 2 for borders, 1 for cursor + let scroll = self.search_bar_widget.input.visual_scroll(width as usize); + + // Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering + frame.set_cursor_position(( + // Put cursor past the end of the input text + area.x.saturating_add( + ((self.search_bar_widget.input.visual_cursor()).max(scroll) - scroll) as u16, + ) + 1, + // Move one line down, from the border to the input line + area.y + 1, + )); + } + + self.search_bar_widget.block = Some(Block::bordered().title("Search").style( + if self.search_bar_widget.is_focused { + *self + .config + .styles + .get(&crate::app::Mode::Explorer) + .unwrap() + .get("highlighted_searchbar") + .unwrap() + } else { + Style::new() + }, + )); + self.search_bar_widget + .clone() + .render(area, frame.buffer_mut()); + } + fn render_preview(&mut self, frame: &mut Frame, area: Rect, highlighted_style: Style) { + // If we have tasks, then render a TaskList widget + match self.entries_right_view.first() { + Some(VaultData::Task(_) | VaultData::Header(_, _, _)) => { + TaskList::new(&self.config, &self.entries_right_view, false) + .header_style( + *self + .config + .styles + .get(&crate::app::Mode::Explorer) + .unwrap() + .get("preview_headers") + .unwrap(), + ) + .render(area, frame.buffer_mut(), &mut self.task_list_widget_state); + } + // Else render a ListView widget + Some(VaultData::Directory(_, _)) => Self::build_list( + Self::apply_prefixes( + &self + .task_mgr + .get_explorer_entries( + &self + .get_preview_path() + .unwrap_or_else(|_| self.current_path.clone()), + ) + .unwrap_or_default(), + ), + Block::new(), + highlighted_style, + ) + .render(area, frame.buffer_mut(), &mut ListState::default()), + None => (), + } + } + fn render_edit_bar(&mut self, frame: &mut Frame, area: Rect) { + let vertical = Layout::vertical([Constraint::Length(3)]).flex(Flex::Center); + let horizontal = Layout::horizontal([Constraint::Percentage(75)]).flex(Flex::Center); + let [area] = vertical.areas(area); + let [area] = horizontal.areas(area); + + let width = area.width.max(3) - 3; // 2 for borders, 1 for cursor + let scroll = self.edit_task_bar.input.visual_scroll(width as usize); + + // Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering + frame.set_cursor_position(( + // Put cursor past the end of the input text + area.x.saturating_add( + ((self.edit_task_bar.input.visual_cursor()).max(scroll) - scroll) as u16, + ) + 1, + // Move one line down, from the border to the input line + area.y + 1, + )); + + self.edit_task_bar.block = Some( + Block::bordered().title("Edit").style( + *self + .config + .styles + .get(&crate::app::Mode::Explorer) + .unwrap() + .get("highlighted_searchbar") + .unwrap(), + ), + ); + self.edit_task_bar.clone().render(area, frame.buffer_mut()); + } } impl<'a> Component for ExplorerTab<'a> { @@ -310,9 +421,13 @@ impl<'a> Component for ExplorerTab<'a> { vec![Action::Enter, Action::Escape] } fn blocking_mode(&self) -> bool { - self.is_focused && (self.search_bar_widget.is_focused || self.show_help) + self.is_focused + && (self.search_bar_widget.is_focused + || self.show_help + || self.edit_task_bar.is_focused) } + #[allow(clippy::too_many_lines)] fn update(&mut self, tui: Option<&mut Tui>, action: Action) -> Result> { if !self.is_focused { match action { @@ -327,13 +442,58 @@ impl<'a> Component for ExplorerTab<'a> { } return Ok(None); } - - if self.search_bar_widget.is_focused { + if self.edit_task_bar.is_focused { + match action { + Action::Enter => { + // We're already sure it exists since we entered the task editing mode + if let VaultData::Task(task) = self + .task_mgr + .get_vault_data_from_path(&self.current_path, 0) + .unwrap()[self.state_center_view.selected.unwrap_or_default()] + .clone() + { + // Get input + let mut input = self.edit_task_bar.input.value(); + // Parse it + let Ok(mut parsed_task) = parse_task( + &mut input, + self.get_current_path_to_file() + .to_str() + .unwrap() + .to_string(), + &self.config, + ) else { + // Don't accept invalid input + return Ok(None); + }; + // Write changes + parsed_task.line_number = task.line_number; + parsed_task + .fix_task_attributes(&self.config, &self.get_current_path_to_file())?; + // Quit editing mode + self.edit_task_bar.is_focused = !self.edit_task_bar.is_focused; + // Reload vault + return Ok(Some(Action::ReloadVault)); + } + } + Action::Escape => { + // Cancel editing + self.edit_task_bar.input.reset(); + self.edit_task_bar.is_focused = !self.edit_task_bar.is_focused; + } + Action::Key(key_event) => { + self.edit_task_bar + .input + .handle_event(&Event::Key(key_event)); + } + _ => (), + } + } else if self.search_bar_widget.is_focused { match action { Action::Enter | Action::Escape => { self.search_bar_widget.is_focused = !self.search_bar_widget.is_focused; } - Action::Key(key_event) if self.search_bar_widget.is_focused => { + Action::Key(key_event) => { self.search_bar_widget .input .handle_event(&Event::Key(key_event)); @@ -377,6 +537,27 @@ impl<'a> Component for ExplorerTab<'a> { Action::Search => { self.search_bar_widget.is_focused = !self.search_bar_widget.is_focused; } + Action::EditTask => { + let entries = self + .task_mgr + .get_vault_data_from_path(&self.current_path, 0)?; + if entries.len() <= self.state_center_view.selected.unwrap_or_default() { + error!("Cannot edit: Index of selected entry > list of entries"); + return Ok(None); + } + let entry = + entries[self.state_center_view.selected.unwrap_or_default()].clone(); + debug!("{entry:#?}"); + if let VaultData::Task(task) = entry { + self.edit_task_bar.input = + Input::new(task.get_fixed_attributes(&self.config, 0)); + self.edit_task_bar.is_focused = !self.edit_task_bar.is_focused; + } else { + info!("Only tasks can be edited"); + return Ok(None); + } + } + // Navigation Action::Up => { self.state_center_view.previous(); @@ -408,8 +589,8 @@ impl<'a> Component for ExplorerTab<'a> { Ok(None) } - fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + // If not focused, don't draw anything if !self.is_focused { return Ok(()); } @@ -420,40 +601,11 @@ impl<'a> Component for ExplorerTab<'a> { } let areas = Self::split_frame(area); Self::render_footer(areas.footer, frame); - // Search Bar - if self.search_bar_widget.is_focused { - let width = areas.search.width.max(3) - 3; // 2 for borders, 1 for cursor - let scroll = self.search_bar_widget.input.visual_scroll(width as usize); - // Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering - frame.set_cursor_position(( - // Put cursor past the end of the input text - areas.search.x.saturating_add( - ((self.search_bar_widget.input.visual_cursor()).max(scroll) - scroll) as u16, - ) + 1, - // Move one line down, from the border to the input line - areas.search.y + 1, - )); - } - - self.search_bar_widget.block = Some(Block::bordered().title("Search").style( - if self.search_bar_widget.is_focused { - *self - .config - .styles - .get(&crate::app::Mode::Explorer) - .unwrap() - .get("highlighted_searchbar") - .unwrap() - } else { - Style::new() - }, - )); - self.search_bar_widget - .clone() - .render(areas.search, frame.buffer_mut()); + // Search Bar + self.render_search_bar(frame, areas.search); - // Current path + // Current Path frame.render_widget(self.path_to_paragraph(), areas.path); let highlighted_style = *self @@ -463,6 +615,7 @@ impl<'a> Component for ExplorerTab<'a> { .unwrap() .get("highlighted_entry") .unwrap(); + // Left Block let left_entries_list = Self::build_list( Self::apply_prefixes(&self.entries_left_view), @@ -482,44 +635,9 @@ impl<'a> Component for ExplorerTab<'a> { lateral_entries_list.render(areas.current, frame.buffer_mut(), state); // Right Block - // If we have tasks, then render a TaskList widget - match self.entries_right_view.first() { - Some(VaultData::Task(_) | VaultData::Header(_, _, _)) => { - TaskList::new(&self.config, &self.entries_right_view, false) - .header_style( - *self - .config - .styles - .get(&crate::app::Mode::Explorer) - .unwrap() - .get("preview_headers") - .unwrap(), - ) - .render( - areas.preview, - frame.buffer_mut(), - &mut self.task_list_widget_state, - ); - } - // Else render a ListView widget - Some(VaultData::Directory(_, _)) => Self::build_list( - Self::apply_prefixes( - &self - .task_mgr - .get_explorer_entries( - &self - .get_preview_path() - .unwrap_or_else(|_| self.current_path.clone()), - ) - .unwrap_or_default(), - ), - Block::new(), - highlighted_style, - ) - .render(areas.preview, frame.buffer_mut(), &mut ListState::default()), - None => (), - } + self.render_preview(frame, areas.preview, highlighted_style); + // Help Menu if self.show_help { self.help_menu_wigdet.clone().render( area, @@ -527,6 +645,9 @@ impl<'a> Component for ExplorerTab<'a> { &mut self.help_menu_wigdet.state, ); } + if self.edit_task_bar.is_focused { + self.render_edit_bar(frame, area); + } Ok(()) } diff --git a/src/components/filter_tab.rs b/src/components/filter_tab.rs index 87e910b..709ee64 100644 --- a/src/components/filter_tab.rs +++ b/src/components/filter_tab.rs @@ -15,7 +15,7 @@ use crate::task_core::vault_data::VaultData; use crate::task_core::TaskManager; use crate::tui::Tui; use crate::widgets::help_menu::HelpMenu; -use crate::widgets::search_bar::SearchBar; +use crate::widgets::input_bar::InputBar; use crate::widgets::task_list::TaskList; use crate::{action::Action, config::Config}; use tui_input::backend::crossterm::EventHandler; @@ -34,7 +34,7 @@ pub struct FilterTab<'a> { is_focused: bool, matching_entries: Vec, matching_tags: Vec, - search_bar_widget: SearchBar<'a>, + search_bar_widget: InputBar<'a>, task_mgr: TaskManager, task_list_widget_state: ScrollViewState, show_help: bool, diff --git a/src/task_core.rs b/src/task_core.rs index 7e26809..2cb48f1 100644 --- a/src/task_core.rs +++ b/src/task_core.rs @@ -208,14 +208,17 @@ impl TaskManager { /// Follows the `selected_header_path` to retrieve the correct `VaultData`. /// Returns a vector of `VaultData` with the items to display in TUI, preserving the recursive nature. + /// task_preview_offset: add offset to return a task instead of onne of its subtasks pub fn get_vault_data_from_path( &self, selected_header_path: &[String], + task_preview_offset: usize, ) -> Result> { fn aux( file_entry: VaultData, selected_header_path: &[String], path_index: usize, + task_preview_offset: usize, ) -> Result> { if path_index == selected_header_path.len() { Ok(vec![file_entry]) @@ -225,9 +228,12 @@ impl TaskManager { if name == selected_header_path[path_index] { let mut res = vec![]; for child in children { - if let Ok(mut found) = - aux(child, selected_header_path, path_index + 1) - { + if let Ok(mut found) = aux( + child, + selected_header_path, + path_index + 1, + task_preview_offset, + ) { res.append(&mut found); } } @@ -240,8 +246,7 @@ impl TaskManager { if task.name == selected_header_path[path_index] { let mut res = vec![]; - // Returns early the task to allow previewing its attributes + children - if path_index + 1 == selected_header_path.len() { + if path_index + task_preview_offset == selected_header_path.len() { res.push(VaultData::Task(task)); } else { for child in task.subtasks { @@ -249,6 +254,7 @@ impl TaskManager { VaultData::Task(child), selected_header_path, path_index + 1, + task_preview_offset, ) { res.append(&mut found); } @@ -271,7 +277,7 @@ impl TaskManager { match filtered_tasks { Some(VaultData::Directory(_, entries)) => { for entry in entries { - if let Ok(res) = aux(entry, selected_header_path, 0) { + if let Ok(res) = aux(entry, selected_header_path, 0, task_preview_offset) { return Ok(res); } } @@ -546,7 +552,7 @@ mod tests { }; let path = vec![String::from("Test"), String::from("1"), String::from("2")]; - let res = task_mgr.get_vault_data_from_path(&path).unwrap(); + let res = task_mgr.get_vault_data_from_path(&path, 0).unwrap(); assert_eq!(vec![expected_header], res); let path = vec![ @@ -555,7 +561,7 @@ mod tests { String::from("2"), String::from("3"), ]; - let res = task_mgr.get_vault_data_from_path(&path).unwrap(); + let res = task_mgr.get_vault_data_from_path(&path, 0).unwrap(); assert_eq!(expected_tasks, res); } } diff --git a/src/task_core/task.rs b/src/task_core/task.rs index 4c13db4..f163e2f 100644 --- a/src/task_core/task.rs +++ b/src/task_core/task.rs @@ -158,7 +158,7 @@ impl fmt::Display for Task { } } impl Task { - fn get_fixed_attributes(&self, config: &Config, indent_length: usize) -> String { + pub fn get_fixed_attributes(&self, config: &Config, indent_length: usize) -> String { let indent = " ".repeat(indent_length); let state_str = match self.state { diff --git a/src/widgets.rs b/src/widgets.rs index 0572124..8bf447a 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -1,4 +1,4 @@ pub mod help_menu; -pub mod search_bar; +pub mod input_bar; pub mod task_list; pub mod task_list_item; diff --git a/src/widgets/search_bar.rs b/src/widgets/input_bar.rs similarity index 90% rename from src/widgets/search_bar.rs rename to src/widgets/input_bar.rs index 65f0702..f4b88c2 100644 --- a/src/widgets/search_bar.rs +++ b/src/widgets/input_bar.rs @@ -2,18 +2,18 @@ use ratatui::{ buffer::Buffer, layout::Rect, style::Style, - widgets::{Block, Paragraph, Widget}, + widgets::{Block, Clear, Paragraph, Widget}, }; use tui_input::Input; #[derive(Default, Clone)] -pub struct SearchBar<'a> { +pub struct InputBar<'a> { pub input: Input, pub is_focused: bool, pub block: Option>, } -impl<'a> Widget for SearchBar<'a> { +impl<'a> Widget for InputBar<'a> { fn render(self, area: Rect, buf: &mut Buffer) { let width = area.width.max(3) - 3; // 2 for borders, 1 for cursor let scroll = self.input.visual_scroll(width as usize); @@ -21,6 +21,7 @@ impl<'a> Widget for SearchBar<'a> { .style(Style::reset()) .scroll((0, scroll as u16)); + Clear.render(area, buf); if let Some(block) = &self.block { res.block(block.clone()) } else { @@ -40,11 +41,11 @@ mod tests { }; use tui_input::Input; - use crate::widgets::search_bar::SearchBar; + use crate::widgets::input_bar::InputBar; #[test] fn test_render_search_bar() { - let bar = SearchBar { + let bar = InputBar { input: Input::new("input".to_owned()), is_focused: true, block: Some(Block::bordered().title_top("test")), @@ -58,7 +59,7 @@ mod tests { #[test] fn test_render_search_bar_line() { let input = Input::new("initial".to_owned()); - let bar = SearchBar { + let bar = InputBar { input, is_focused: true, block: Some(Block::bordered().title_top("test")), diff --git a/src/widgets/snapshots/vault_tasks__widgets__input_bar__tests__render_search_bar.snap b/src/widgets/snapshots/vault_tasks__widgets__input_bar__tests__render_search_bar.snap new file mode 100644 index 0000000..bcd70b8 --- /dev/null +++ b/src/widgets/snapshots/vault_tasks__widgets__input_bar__tests__render_search_bar.snap @@ -0,0 +1,24 @@ +--- +source: src/widgets/input_bar.rs +expression: terminal.backend() +--- +"┌test──────────────────────────────────────────────────────────────────────────┐" +"│input │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"└──────────────────────────────────────────────────────────────────────────────┘" diff --git a/src/widgets/snapshots/vault_tasks__widgets__input_bar__tests__render_search_bar_line.snap b/src/widgets/snapshots/vault_tasks__widgets__input_bar__tests__render_search_bar_line.snap new file mode 100644 index 0000000..615135e --- /dev/null +++ b/src/widgets/snapshots/vault_tasks__widgets__input_bar__tests__render_search_bar_line.snap @@ -0,0 +1,24 @@ +--- +source: src/widgets/input_bar.rs +expression: terminal.backend() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" ┌test──────────────────────────────────────────┐ " +" │initial │ " +" │ │ " +" └──────────────────────────────────────────────┘ " +" " +" " +" " +" " +" " +" " +" " +" "