diff --git a/Cargo.toml b/Cargo.toml index 2b648c6..b7da009 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,8 @@ nucleo-matcher = "0.3.1" tokio = {version="1.49.0", features=["full"]} tokio-stream = {version="0.1.18", features = ["sync"]} log4rs_test_utils = "0.2.3" +yamlpatch = "0.11.0" +yamlpath = "0.33.0" # The profile that 'dist' will build with [profile.dist] diff --git a/docs/gui.md b/docs/gui.md index f2b6b58..8960c2a 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -39,7 +39,7 @@ There are three main views: 1. *Help view*: Shows available commands. -Use Tab to switch between the first two views, and Ctrl+H for help. +Use Tab to switch between the first two views, and Ctrl+h for help. * Enter: Go to selected directory @@ -59,6 +59,8 @@ Use Tab to switch between the first two views, and Ctrl+H * Ctrl+f Switch between exact and fuzzy search +* F12: Open the configuration view + Also, you can simply type a string to filter directories history or shortcuts. ## Search diff --git a/src/config.rs b/src/config.rs index ae91e62..4d619ec 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,13 +1,23 @@ -use std::{env, fs, io::Write, path::PathBuf}; +use std::{ + env, fs, + io::Write, + path::PathBuf, + sync::{Arc, OnceLock}, +}; use chrono::{DateTime, Local}; use log::{debug, error, info, trace}; use serde::{Deserialize, Serialize}; +use serde_yaml::Value; +use yamlpatch::{Op, Patch, apply_yaml_patches}; +use yamlpath::route; use crate::theme::{Theme, ThemeStyles}; pub(crate) const CDIR_CONFIG_VAR: &str = "CDIR_CONFIG"; +static CONFIG_FILE_PATH: OnceLock = OnceLock::new(); + const DEFAULT_DB_PATH: fn() -> Option = || { let mut path = dirs::data_dir().unwrap(); path.push("cdir"); @@ -41,8 +51,8 @@ const DEFAULT_THEME: fn() -> Option = || Some(String::from("default")); const DEFAULT_COLORS: fn() -> Theme = || serde_yaml::from_str("").unwrap(); -const DEFAULT_DATE_FORMATER: fn() -> Box String> = - || Box::from(|_| String::from("")); +const DEFAULT_DATE_FORMATER: fn() -> Arc String + Send + Sync> = + || Arc::from(|_| String::from("")); const DEFAULT_NONE: fn() -> Option = || None; @@ -99,13 +109,9 @@ pub struct Config { pub styles: ThemeStyles, #[serde(skip, default = "DEFAULT_DATE_FORMATER")] - pub date_formater: Box String>, + pub date_formater: Arc String + Send + Sync>, } -// Not really true, but good enough for our use case as it is the case after initialization (immutable config) -unsafe impl Send for Config {} -unsafe impl Sync for Config {} - impl Config { fn build_config_file_path(config_file_path: Option) -> PathBuf { if let Some(path) = config_file_path { @@ -125,7 +131,7 @@ impl Config { path } - pub fn load(config_file_path: Option) -> Result { + pub fn initialize_and_load(config_file_path: Option) -> Result { let path = Self::build_config_file_path(config_file_path); if !path.exists() { @@ -133,6 +139,12 @@ impl Config { Self::install_themes(path.clone()); } + CONFIG_FILE_PATH.get_or_init(|| path.clone()); + + Self::load(path) + } + + pub fn load(path: PathBuf) -> Result { let file = std::fs::File::open(path.clone()); match serde_yaml::from_reader(file.unwrap()) { @@ -148,7 +160,7 @@ impl Config { self.styles = ThemeStyles::from(&actual_theme); let date_format = self.date_format.clone(); - self.date_formater = Box::from(move |s: i64| { + self.date_formater = Arc::from(move |s: i64| { DateTime::from_timestamp(s, 0) .unwrap() .with_timezone(&Local::now().timezone()) @@ -394,6 +406,88 @@ impl Config { std::fs::write(&theme_path, content) .unwrap_or_else(|_| panic!("Failed create the theme file {:?}", theme_path)); } + + // Save the configuration by applying a patch to the existing config file, to preserve comments and formatting as much as possible + pub(crate) fn save(&self) -> Result<(), String> { + info!("Saving configuration to {:?}", CONFIG_FILE_PATH.get()); + + let config_path = CONFIG_FILE_PATH + .get() + .ok_or_else(|| String::from("Config file path not initialized"))? + .clone(); + let config_source = fs::read_to_string(&config_path) + .map_err(|e| format!("Failed to read config file {:?}: {}", config_path, e))?; + let document = yamlpath::Document::new(config_source.clone()) + .map_err(|e| format!("Failed to parse config file {:?}: {}", config_path, e))?; + + let config_from_file: Config = serde_yaml::from_str(&config_source) + .map_err(|e| format!("Failed to parse config file {:?}: {}", config_path, e))?; + + let mut patches: Vec = Vec::new(); + let mut add_or_replace = |key: &str, value: Value| { + let key_owned = key.to_string(); + let key_route = route!(key_owned.clone()); + if document.query_exists(&key_route) { + patches.push(Patch { + route: key_route, + operation: Op::Replace(value), + }); + } else { + patches.push(Patch { + route: route!(), + operation: Op::Add { + key: key_owned, + value, + }, + }); + } + }; + + if config_from_file.smart_suggestions_active != self.smart_suggestions_active { + add_or_replace( + "smart_suggestions_active", + Value::Bool(self.smart_suggestions_active), + ); + } + + if config_from_file.smart_suggestions_count != self.smart_suggestions_count { + add_or_replace( + "smart_suggestions_count", + Value::Number(serde_yaml::Number::from( + self.smart_suggestions_count as u64, + )), + ); + } + + if config_from_file.smart_suggestions_depth != self.smart_suggestions_depth { + add_or_replace( + "smart_suggestions_depth", + Value::Number(serde_yaml::Number::from( + self.smart_suggestions_depth as u64, + )), + ); + } + + if config_from_file.path_search_include_shortcuts != self.path_search_include_shortcuts { + add_or_replace( + "path_search_include_shortcuts", + Value::Bool(self.path_search_include_shortcuts), + ); + } + + if patches.is_empty() { + return Ok(()); + } + + let updated_document = apply_yaml_patches(&document, &patches) + .map_err(|e| format!("Failed to apply config patch {:?}: {}", config_path, e))?; + let updated_source = updated_document.source().to_string(); + + fs::write(&config_path, updated_source) + .map_err(|e| format!("Failed to write config file {:?}: {}", config_path, e))?; + + Ok(()) + } } impl Default for Config { @@ -410,7 +504,7 @@ impl Default for Config { smart_suggestions_depth: DEFAULT_SMART_SUGGESTIONS_DEPTH(), smart_suggestions_count: DEFAULT_SMART_SUGGESTIONS_COUNT(), themes_directory_path: Default::default(), - date_formater: Box::new(|date| date.to_string()), + date_formater: Arc::new(|date| date.to_string()), db_path: Default::default(), log_config_path: Default::default(), path_search_include_shortcuts: true, @@ -439,7 +533,7 @@ impl Clone for Config { path_search_include_shortcuts: self.path_search_include_shortcuts, date_format: self.date_format.clone(), // Provide a new default closure for date_formater - date_formater: Box::new(|date| date.to_string()), + date_formater: Arc::new(|date| date.to_string()), } } } diff --git a/src/config_button.rs b/src/config_button.rs new file mode 100644 index 0000000..579df9f --- /dev/null +++ b/src/config_button.rs @@ -0,0 +1,67 @@ +use std::{ + rc::Rc, + sync::{Arc, Mutex}, +}; + +use ratatui::{ + layout::{Alignment, Position, Rect}, + prelude::Style, + widgets::Paragraph, +}; + +use crate::{ + config::Config, + config_view::ConfigView, + tui::{ManagerAction, View, ViewBuilder, ViewManager}, +}; + +pub struct ConfigButton { + vm: Rc, + config: Arc>, +} + +impl ConfigButton { + pub fn builder(vm: Rc, config: Arc>) -> ViewBuilder { + ViewBuilder::from(Box::new(ConfigButton { config, vm })) + } +} + +impl View for ConfigButton { + fn draw(&mut self, frame: &mut ratatui::Frame, area: Rect, _active: bool) { + let config_lock = self.config.lock().unwrap(); + // Fill the frame with the background color if defined + if let Some(bg_color) = &config_lock.styles.background_color { + // let area = frame.area(); + let background = Paragraph::new("").style(Style::default().bg(*bg_color)); + frame.render_widget(background, area); + } + + let pa = Paragraph::new("F12: Config") + .style( + Style::default() + .bg(config_lock.styles.header_bg_color.unwrap()) + .fg(config_lock.styles.header_fg_color.unwrap()), + ) + .alignment(Alignment::Center); + frame.render_widget(pa, area); + } + + fn handle_mouse_event( + &mut self, + area: Rect, + mouse_event: crossterm::event::MouseEvent, + ) -> crate::tui::ManagerAction { + let mouse_position = Position::new(mouse_event.column, mouse_event.row); + if mouse_event.kind + == crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) + && area.contains(mouse_position) + { + self.vm.show_modal_generic( + ConfigView::builder(self.vm.clone(), self.config.clone()), + None, + ); + return ManagerAction::new(true); + } + ManagerAction::new(false) + } +} diff --git a/src/config_view.rs b/src/config_view.rs new file mode 100644 index 0000000..0654a87 --- /dev/null +++ b/src/config_view.rs @@ -0,0 +1,422 @@ +use std::{ + rc::Rc, + sync::{Arc, Mutex}, +}; + +use crossterm::event::{KeyCode, KeyEvent}; +use log::{error, info}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Clear, Paragraph}, +}; +use tokio::sync::broadcast::Sender; +use tui_textarea::{Input, TextArea}; + +use crate::{ + config::Config, + tui::{ + EventCaptured, GenericEvent, ManagerAction, View, ViewBuilder, ViewManager, + event::ApplicationEvent, + }, +}; + +#[derive(Copy, Clone, PartialEq)] +enum ConfigField { + SmartSuggestionActiveCheckbox, + PathSearchCheckbox, + SmartSuggestionCountField, + YesButton, + CancelButton, +} + +pub struct ConfigView { + tx: Sender, + config: Arc>, + smart_suggestions_field: ConfigField, + smart_suggestions_active: bool, + path_search_include_shortcuts: bool, + count_textarea: Option>, +} + +impl ConfigView { + pub fn builder(view_manager: Rc, config: Arc>) -> ViewBuilder { + ViewBuilder::from(Box::new(Self { + tx: view_manager.tx(), + config, + smart_suggestions_field: ConfigField::SmartSuggestionActiveCheckbox, + smart_suggestions_active: false, + path_search_include_shortcuts: false, + count_textarea: None, + })) + } + + fn save_config(&mut self) { + if let Ok(mut config) = self.config.lock() { + info!( + "Saving config: smart_suggestions_active={}", + self.smart_suggestions_active + ); + config.smart_suggestions_active = self.smart_suggestions_active; + config.path_search_include_shortcuts = self.path_search_include_shortcuts; + + // Save smart_suggestions_count from TextArea + if let Some(textarea) = &self.count_textarea + && !textarea.lines().is_empty() + { + let count_str = textarea.lines()[0].as_str(); + if let Ok(count) = count_str.parse::() { + info!("Saving smart_suggestions_count={}", count); + config.smart_suggestions_count = count; + } else { + error!("Invalid count value '{}', keeping current value", count_str); + } + } + if let Err(e) = config.save() { + error!("Failed to save config: {}", e); + } + self.publish(); + } else { + error!("Failed to lock config to save state"); + } + } + + fn cancel_config(&mut self) { + // nop + } + + fn publish(&self) { + let event = GenericEvent::ApplicationEvent(ApplicationEvent { + id: String::from("data.reload"), + payload: None, + }); + let result = self.tx.send(event); + if let Err(e) = result { + error!("Failed to send 'data.reload' event: {}", e); + } + } +} + +impl View for ConfigView { + fn init(&mut self) { + if let Ok(config_lock) = self.config.lock() { + self.smart_suggestions_active = config_lock.smart_suggestions_active; + self.path_search_include_shortcuts = config_lock.path_search_include_shortcuts; + + // Initialize count textarea + let mut count_textarea = TextArea::default(); + count_textarea.set_block( + Block::default() + .borders(Borders::ALL) + .title("Smart Suggestions Count") + .title_style(config_lock.styles.title_style) + .border_style(Style::default().fg(config_lock.styles.border_color.unwrap())), + ); + count_textarea.set_cursor_line_style(config_lock.styles.text_style); + count_textarea.insert_str(config_lock.smart_suggestions_count.to_string()); + self.count_textarea = Some(count_textarea); + } else { + error!("Failed to lock config to get initial state, using defaults"); + self.smart_suggestions_active = false; + + // Initialize with default textarea + let mut count_textarea = TextArea::default(); + count_textarea.set_block( + Block::default() + .borders(Borders::ALL) + .title("Smart Suggestions Count"), + ); + count_textarea.insert_str("3"); // Default value + self.count_textarea = Some(count_textarea); + }; + } + fn handle_key_event(&mut self, key_event: KeyEvent) -> (EventCaptured, ManagerAction) { + let mut close = false; + + match key_event.code { + KeyCode::Esc => { + // Cancel - restore original state and close + self.cancel_config(); + close = true; + } + KeyCode::Tab | KeyCode::Down => { + // Navigate between fields + self.smart_suggestions_field = match self.smart_suggestions_field { + ConfigField::SmartSuggestionActiveCheckbox => { + ConfigField::SmartSuggestionCountField + } + ConfigField::SmartSuggestionCountField => ConfigField::PathSearchCheckbox, + ConfigField::PathSearchCheckbox => ConfigField::YesButton, + ConfigField::YesButton => ConfigField::CancelButton, + ConfigField::CancelButton => ConfigField::SmartSuggestionActiveCheckbox, + }; + } + KeyCode::BackTab | KeyCode::Up => { + // Navigate backwards between fields (Shift+Tab) + self.smart_suggestions_field = match self.smart_suggestions_field { + ConfigField::SmartSuggestionActiveCheckbox => ConfigField::CancelButton, + ConfigField::SmartSuggestionCountField => { + ConfigField::SmartSuggestionActiveCheckbox + } + ConfigField::PathSearchCheckbox => ConfigField::SmartSuggestionCountField, + ConfigField::YesButton => ConfigField::PathSearchCheckbox, + ConfigField::CancelButton => ConfigField::YesButton, + }; + } + KeyCode::Left | KeyCode::Right + if matches!( + self.smart_suggestions_field, + ConfigField::YesButton | ConfigField::CancelButton + ) => + { + // Toggle between Yes and Cancel buttons + self.smart_suggestions_field = match self.smart_suggestions_field { + ConfigField::YesButton => ConfigField::CancelButton, + ConfigField::CancelButton => ConfigField::YesButton, + _ => self.smart_suggestions_field, + }; + } + KeyCode::Char(' ') + if self.smart_suggestions_field == ConfigField::SmartSuggestionActiveCheckbox => + { + self.smart_suggestions_active = !self.smart_suggestions_active; + } + KeyCode::Char(' ') + if self.smart_suggestions_field == ConfigField::PathSearchCheckbox => + { + self.path_search_include_shortcuts = !self.path_search_include_shortcuts; + } + KeyCode::Enter => { + match self.smart_suggestions_field { + ConfigField::SmartSuggestionActiveCheckbox => { + // Toggle smart suggestions + self.smart_suggestions_active = !self.smart_suggestions_active; + } + ConfigField::PathSearchCheckbox => { + // Toggle path search include shortcuts + self.path_search_include_shortcuts = !self.path_search_include_shortcuts; + } + ConfigField::SmartSuggestionCountField => { + // Move to next field (Yes button) when Enter is pressed on TextArea + self.smart_suggestions_field = ConfigField::YesButton; + } + ConfigField::YesButton => { + // Save and close + self.save_config(); + close = true; + } + ConfigField::CancelButton => { + // Cancel and close + self.cancel_config(); + close = true; + } + } + } + _ => { + // Handle input for the count textarea - only allow numeric input and navigation + if self.smart_suggestions_field == ConfigField::SmartSuggestionCountField + && let Some(textarea) = self.count_textarea.as_mut() + { + match key_event.code { + // Allow numeric keys (but limit to 3 characters) + KeyCode::Char(c) if c.is_ascii_digit() => { + let current_text = if textarea.lines().is_empty() { + String::new() + } else { + textarea.lines()[0].clone() + }; + if current_text.len() < 2 { + textarea.input(Input::from(key_event)); + } + } + // Allow text editing keys + KeyCode::Backspace | KeyCode::Delete => { + textarea.input(Input::from(key_event)); + } + // Allow cursor navigation within the field + KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End => { + textarea.input(Input::from(key_event)); + } + // Ignore all other keys + _ => {} + } + } + } + } + + ( + EventCaptured::Yes, + ManagerAction::new(true).with_close(close), + ) + } + + fn handle_mouse_event( + &mut self, + _area: Rect, + _mouse_event: crossterm::event::MouseEvent, + ) -> ManagerAction { + ManagerAction::new(false) + } + + fn draw(&mut self, frame: &mut ratatui::Frame, modal_area: Rect, _active: bool) { + let layout = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(9), + Constraint::Fill(1), + ]); + let chunks: [Rect; 3] = layout.areas(modal_area); + let center_layout = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(60), + Constraint::Fill(1), + ]); + let chunks: [Rect; 3] = center_layout.areas(chunks[1]); + let modal_area = chunks[1]; + + frame.render_widget(Clear, modal_area); + + let config_lock = self.config.lock().unwrap(); + + // Fill the frame with the background color if defined + if let Some(bg_color) = &config_lock.styles.background_color { + let background = Paragraph::new("").style(Style::default().bg(*bg_color)); + frame.render_widget(background, modal_area); + } + + let block = Block::default() + .title("Configuration") + .title_style(config_lock.styles.title_style) + .borders(Borders::ALL) + .border_style(Style::default().fg(config_lock.styles.border_color.unwrap())) + .style(config_lock.styles.text_style); + + frame.render_widget(block, modal_area); + + // Create content area inside the block + let inner_area = ratatui::layout::Rect { + x: modal_area.x + 1, + y: modal_area.y + 1, + width: modal_area.width.saturating_sub(2), + height: modal_area.height.saturating_sub(2), + }; + + // Create checkbox content + let checkbox_symbol = if self.smart_suggestions_active { + "[X]" + } else { + "[ ]" + }; + let checkbox_text = format!("{} Smart suggestions", checkbox_symbol); + + let content_layout = Layout::vertical([ + Constraint::Length(1), // Empty line at top + Constraint::Length(1), // Checkbox line + Constraint::Length(1), // Path search checkbox line + Constraint::Length(1), // TextArea (3 lines with border) + Constraint::Length(1), // Empty line before buttons + Constraint::Length(1), // Buttons + ]); + let [ + _top_spacer, + suggestions_active_area, + suggestions_count_area, + path_search_area, + _spacer3, + buttons_area, + ]: [Rect; 6] = content_layout.areas(inner_area); + + // Render checkbox with highlighting if selected + let checkbox_style = + if self.smart_suggestions_field == ConfigField::SmartSuggestionActiveCheckbox { + config_lock + .styles + .text_style + .add_modifier(Modifier::REVERSED) + } else { + config_lock.styles.text_style + }; + let checkbox = Paragraph::new(checkbox_text).style(checkbox_style); + frame.render_widget(checkbox, suggestions_active_area); + + // Render count field as formatted text [XX] message + if let Some(count_textarea) = &self.count_textarea { + let count_value = if count_textarea.lines().is_empty() { + "3".to_string() + } else { + count_textarea.lines()[0].clone() + }; + + // Pad with spaces to always show 2 characters + let padded_count = format!("{:>2}", count_value); + let count_display = format!("[{}] Smart suggestions count", padded_count); + + let count_style = + if self.smart_suggestions_field == ConfigField::SmartSuggestionCountField { + config_lock + .styles + .text_style + .add_modifier(Modifier::REVERSED) + } else { + config_lock.styles.text_style + }; + + let count_paragraph = Paragraph::new(count_display).style(count_style); + frame.render_widget(count_paragraph, suggestions_count_area); + } + + // Render path search checkbox + let path_checkbox_symbol = if self.path_search_include_shortcuts { + "[X]" + } else { + "[ ]" + }; + let path_checkbox_text = + format!("{} Include shortcuts in path search", path_checkbox_symbol); + let path_checkbox_style = if self.smart_suggestions_field == ConfigField::PathSearchCheckbox + { + config_lock + .styles + .text_style + .add_modifier(Modifier::REVERSED) + } else { + config_lock.styles.text_style + }; + let path_checkbox = Paragraph::new(path_checkbox_text).style(path_checkbox_style); + frame.render_widget(path_checkbox, path_search_area); + + // Render buttons at the bottom + let button_layout: [Rect; 3] = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(10), + Constraint::Fill(1), + Constraint::Length(10), + ]) + .areas(buttons_area); + + let yes_style = if self.smart_suggestions_field == ConfigField::YesButton { + Style::default() + .fg(Color::Black) + .bg(Color::Green) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Green) + }; + let cancel_style = if self.smart_suggestions_field == ConfigField::CancelButton { + Style::default() + .fg(Color::Black) + .bg(Color::Red) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Red) + }; + let yes = Paragraph::new(" Yes ") + .style(yes_style) + .alignment(ratatui::layout::Alignment::Center); + let cancel = Paragraph::new(" Cancel ") + .style(cancel_style) + .alignment(ratatui::layout::Alignment::Center); + frame.render_widget(yes, button_layout[0]); + frame.render_widget(cancel, button_layout[2]); + } +} diff --git a/src/confirmation.rs b/src/confirmation.rs index f250a4f..908c073 100644 --- a/src/confirmation.rs +++ b/src/confirmation.rs @@ -73,14 +73,14 @@ impl View for Confirmation { width: modal_area.width - 2, height: modal_area.height - 2, }; - let vchunks = Layout::default() + let vchunks: [Rect; 3] = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // title (empty, since Block has title) Constraint::Fill(2), // message Constraint::Length(1), // buttons ]) - .split(inner); + .areas(inner); // Multi-line message in the middle, centered let message_height = vchunks[1].height as usize; @@ -98,14 +98,14 @@ impl View for Confirmation { frame.render_widget(msg, vchunks[1]); // Buttons at the bottom - let button_layout = Layout::default() + let button_layout: [Rect; 3] = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Length(10), Constraint::Fill(1), Constraint::Length(10), ]) - .split(vchunks[2]); + .areas(vchunks[2]); let yes_style = if self.selected == ConfirmationButton::Yes { Style::default() .fg(Color::Black) diff --git a/src/gui.rs b/src/gui.rs index 43f4df3..325fc19 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -14,6 +14,7 @@ use ratatui::{ use crate::{ config::Config, + config_view::ConfigView, help::Help, history_view_container::HistoryViewContainer, search_text_view::SearchTextState, @@ -229,7 +230,7 @@ impl Gui { /// Return a function that formats a row for the history view fn build_format_history_row_builder( - config: Arc, + config: Arc>, table_view_state: Arc>, ) -> RowifyFn { let table_view_state = table_view_state.clone(); @@ -240,25 +241,24 @@ impl Gui { .iter() .map(move |path| { let path_init = path.clone(); + let config_lock = config.lock().unwrap(); // format the date let date: Line = if !path_init.smart_path { Line::from( - Span::from((config.date_formater)(path_init.date)) - .style(config.styles.date_style), + Span::from((config_lock.date_formater)(path_init.date)) + .style(config_lock.styles.date_style), ) } else { Line::from( Span::from(" @ ") - .style(config.styles.date_style /*.bg(bgc)*/), + .style(config_lock.styles.date_style /*.bg(bgc)*/), ) }; // format the path using the embedded shortcut let shortened_line = match table_view_state.lock().unwrap().display_with_shortcuts { - true => { - Self::shorten_path_for_path(config.as_ref(), &path_init, size[1]) - } + true => Self::shorten_path_for_path(&config_lock, &path_init, size[1]), false => None, }; let path = shortened_line @@ -266,10 +266,10 @@ impl Gui { Self::reduce_path( path_init.path, size[1], - config.styles.home_tilde_style, + config_lock.styles.home_tilde_style, ) }) - .style(config.styles.path_style); + .style(config_lock.styles.path_style); let path = if path_init.smart_path { path.style(Style::default().add_modifier(Modifier::ITALIC)) //.bg(bgc)) } else { @@ -287,7 +287,7 @@ impl Gui { &mut self, view_manager: Rc, store: Store, - config: Arc, + config: Arc>, search_text_state: Arc>, ) { self.history_view_container = Some(HistoryViewContainer::builder( @@ -321,7 +321,7 @@ impl Gui { /// Return a function that formats a row for the history view fn build_format_shortcut_row_builder( store: Store, - config: Arc, + config: Arc>, table_view_state: Arc>, ) -> RowifyFn { let table_view_state = table_view_state.clone(); @@ -333,13 +333,14 @@ impl Gui { .map(|shortcut| { // format the path let shortcut = shortcut.clone(); + let config_lock = config.lock().unwrap(); let shortened_line = match table_view_state.lock().unwrap().display_with_shortcuts { true => { let all_shortcuts: Vec = store.list_all_shortcuts().unwrap(); Self::shorten_path_for_shortcut( - config.as_ref(), + &config_lock, &all_shortcuts, &shortcut.path, size[1], @@ -352,15 +353,15 @@ impl Gui { Self::reduce_path( shortcut.path, size[1], - config.styles.home_tilde_style, + config_lock.styles.home_tilde_style, ) }) - .style(config.styles.path_style); + .style(config_lock.styles.path_style); Row::new(vec![ Line::from( Span::from(shortcut.name.clone()) - .style(config.styles.shortcut_name_style), + .style(config_lock.styles.shortcut_name_style), ), path, Line::from( @@ -369,7 +370,7 @@ impl Gui { .clone() .unwrap_or_else(|| "".to_string()), ) - .style(config.styles.description_style), + .style(config_lock.styles.description_style), ]) }) .collect() @@ -381,7 +382,7 @@ impl Gui { &mut self, view_manager: Rc, store: Store, - config: Arc, + config: Arc>, search_text_state: Arc>, ) { let modal_store = store.clone(); @@ -432,7 +433,7 @@ impl Gui { } /// Instantiate the application GUI - fn new(view_manager: Rc, store: store::Store, config: Arc) -> Gui { + fn new(view_manager: Rc, store: store::Store, config: Arc>) -> Gui { let mut gui = Gui { table_view_state: Arc::new(Mutex::new(TableViewState::new())), history_view_container: None, @@ -472,15 +473,30 @@ impl Gui { } /// Launch the GUI. Returns the selected path or None if the user quit. -pub(crate) async fn gui(store: store::Store, config: Arc) -> Option { +pub(crate) async fn gui(store: store::Store, config: Arc>) -> Option { debug!("gui"); - let mut view_manager: Rc = Rc::new(ViewManager::new()); - if let Some(vm) = Rc::get_mut(&mut view_manager) { - let config = config.clone(); - vm.set_global_help_view(Box::new(move || Help::builder(config.styles.clone()))) + let view_manager: Rc = Rc::new(ViewManager::new()); + + { + // Set the global help view + let config1 = config.clone(); + let help_builder = Box::new(move || Help::builder(config1.clone())); + view_manager.set_global_help_view(help_builder); } - let mut gui = Gui::new(view_manager.clone(), store, config); - gui.run(view_manager).await + { + // Set the global config view + let config2 = config.clone(); + let view_manager2 = view_manager.clone(); + let config_builder = + Box::new(move || ConfigView::builder(view_manager2.clone(), config2.clone())); + view_manager.set_global_config_view(config_builder); + } + + { + // Launch the GUI + let mut gui = Gui::new(view_manager.clone(), store, config); + gui.run(view_manager).await + } } diff --git a/src/help.rs b/src/help.rs index 0a32167..4ed0bbd 100644 --- a/src/help.rs +++ b/src/help.rs @@ -1,3 +1,5 @@ +use std::sync::{Arc, Mutex}; + use crossterm::event::{KeyCode, KeyEvent}; use log::debug; use ratatui::{ @@ -8,17 +10,17 @@ use ratatui::{ }; use crate::{ - theme::ThemeStyles, + config::Config, tui::{EventCaptured, ManagerAction, View, ViewBuilder}, }; pub struct Help { - styles: ThemeStyles, + config: Arc>, } impl Help { - pub fn builder(styles: ThemeStyles) -> ViewBuilder { - ViewBuilder::from(Box::new(Self { styles })) + pub fn builder(config: Arc>) -> ViewBuilder { + ViewBuilder::from(Box::new(Self { config })) } } @@ -41,19 +43,20 @@ impl View for Help { Constraint::Length(19), Constraint::Fill(1), ]); - let chunks = layout.split(modal_area); + let chunks: [Rect; 3] = layout.areas(modal_area); let center_layout = Layout::horizontal([ Constraint::Fill(1), Constraint::Length(100), Constraint::Fill(1), ]); - let chunks = center_layout.split(chunks[1]); + let chunks: [Rect; 3] = center_layout.areas(chunks[1]); let modal_area = chunks[1]; frame.render_widget(Clear, modal_area); - let ts = self.styles.text_style; - let es = self.styles.text_em_style; + let styles = self.config.lock().unwrap().styles.clone(); + let ts = styles.text_style; + let es = styles.text_em_style; let message = Paragraph::new(vec![ Line::from(vec![ @@ -120,6 +123,11 @@ impl View for Help { Span::styled("ctrl+h", es), Span::styled(" for the help screen.", ts), ]), + Line::from(vec![ + Span::styled("Use ", ts), + Span::styled("F12", es), + Span::styled(" to open the configuration view.", ts), + ]), Line::from(""), Line::from(vec![Span::styled("Enter a text to filter.", ts)]), Line::from(""), @@ -132,12 +140,12 @@ impl View for Help { .block( Block::default() .padding(Padding::new(1, 1, 1, 1)) - .title(Span::styled(" cdir help ", self.styles.title_style)) + .title(Span::styled(" cdir help ", styles.title_style)) .borders(Borders::ALL) ); // Fill the frame with the background color if defined - if let Some(bg_color) = &self.styles.background_color { + if let Some(bg_color) = &styles.background_color { let background = Paragraph::new("").style(Style::default().bg(*bg_color)); frame.render_widget(background, modal_area); } diff --git a/src/history_view_container.rs b/src/history_view_container.rs index f239476..372e680 100644 --- a/src/history_view_container.rs +++ b/src/history_view_container.rs @@ -9,6 +9,7 @@ use ratatui::layout::{Constraint, Layout, Rect}; use crate::{ config::Config, + config_button::ConfigButton, list_indicator_view::ListIndicatorView, model::ListFunction, search_text_view::{SearchTextState, SearchTextView}, @@ -19,7 +20,8 @@ use crate::{ const PATH_HISTORY_VIEW_ID: u16 = 0; const SEARCH_TEXT_VIEW_1: u16 = 1; -const LIST_INDICATOR_VIEW: u16 = 2; +const CONFIGURATION_VIEW: u16 = 2; +const LIST_INDICATOR_VIEW: u16 = 3; pub struct HistoryViewContainer {} @@ -31,7 +33,7 @@ impl HistoryViewContainer { list_fn: Box>, rowify: RowifyFn, stringify: fn(&Path) -> String, - config: Arc, + config: Arc>, view_state: Arc>, delete_fn: DeleteFn, editor_modal_view_builder: Option>, @@ -41,7 +43,7 @@ impl HistoryViewContainer { .child( PATH_HISTORY_VIEW_ID, TableView::builder( - vm, + vm.clone(), "path".to_string(), column_names, column_constraints, @@ -60,9 +62,13 @@ impl HistoryViewContainer { SEARCH_TEXT_VIEW_1, SearchTextView::builder(config.clone(), search_text_state.clone()), ) + .child( + CONFIGURATION_VIEW, + ConfigButton::builder(vm.clone(), config.clone()), + ) .child( LIST_INDICATOR_VIEW, - ListIndicatorView::builder(config.clone(), "path".to_string()), + ListIndicatorView::builder(vm.clone(), config.clone(), "path".to_string()), ) } } @@ -74,14 +80,20 @@ impl View for HistoryViewContainer { let vertical = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).spacing(0); let [main, bottom] = vertical.areas(area); - let horizontal = - Layout::horizontal([Constraint::Fill(1), Constraint::Length(14)]).spacing(0); - let [search_text_area, right] = horizontal.areas(bottom); + let horizontal = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(14), + Constraint::Length(1), + Constraint::Length(12), + ]) + .spacing(0); + let [search_text_area, list_indicator_rect, _, config_rect] = horizontal.areas(bottom); vec![ (PATH_HISTORY_VIEW_ID, main), (SEARCH_TEXT_VIEW_1, search_text_area), - (LIST_INDICATOR_VIEW, right), + (LIST_INDICATOR_VIEW, list_indicator_rect), + (CONFIGURATION_VIEW, config_rect), ] } fn draw(&mut self, _frame: &mut ratatui::Frame, area: ratatui::prelude::Rect, active: bool) { diff --git a/src/list_indicator_view.rs b/src/list_indicator_view.rs index 26108a3..0ea8ab3 100644 --- a/src/list_indicator_view.rs +++ b/src/list_indicator_view.rs @@ -1,8 +1,11 @@ -use std::sync::Arc; +use std::{ + rc::Rc, + sync::{Arc, Mutex}, +}; use log::debug; use ratatui::{ - layout::{Alignment, Rect}, + layout::{Alignment, Position, Rect}, prelude::Style, style::{Color, Stylize}, widgets::Paragraph, @@ -10,8 +13,9 @@ use ratatui::{ use crate::{ config::Config, + help::Help, model::DataStatePayload, - tui::{View, ViewBuilder, event::ApplicationEvent}, + tui::{ManagerAction, View, ViewBuilder, ViewManager, event::ApplicationEvent}, }; pub struct ListIndicatorState { @@ -29,13 +33,19 @@ impl ListIndicatorState { } pub struct ListIndicatorView { + vm: Rc, state: ListIndicatorState, - config: Arc, + config: Arc>, } impl ListIndicatorView { - pub fn builder(config: Arc, objects_type: String) -> ViewBuilder { + pub fn builder( + vm: Rc, + config: Arc>, + objects_type: String, + ) -> ViewBuilder { ViewBuilder::from(Box::new(ListIndicatorView { + vm, state: ListIndicatorState::new(objects_type), config, })) @@ -45,8 +55,9 @@ impl ListIndicatorView { impl View for ListIndicatorView { fn draw(&mut self, frame: &mut ratatui::Frame, area: Rect, _active: bool) { + let config_lock = self.config.lock().unwrap(); // Fill the frame with the background color if defined - if let Some(bg_color) = &self.config.styles.background_color { + if let Some(bg_color) = &config_lock.styles.background_color { // let area = frame.area(); let background = Paragraph::new("").style(Style::default().bg(*bg_color)); frame.render_widget(background, area); @@ -55,11 +66,9 @@ impl View for ListIndicatorView { let pa = if self.state.is_empty { Paragraph::new("no entry") .style( - Style::default().fg(Color::Black).bg(self - .config - .styles - .free_text_area_bg_color - .unwrap()), + Style::default() + .fg(Color::Black) + .bg(config_lock.styles.free_text_area_bg_color.unwrap()), ) .bg(Color::Red) .alignment(Alignment::Center) @@ -67,13 +76,29 @@ impl View for ListIndicatorView { Paragraph::new("ctrl+h: help") .style( Style::default() - .bg(self.config.styles.header_bg_color.unwrap()) - .fg(self.config.styles.header_fg_color.unwrap()), + .bg(config_lock.styles.header_bg_color.unwrap()) + .fg(config_lock.styles.header_fg_color.unwrap()), ) .alignment(Alignment::Center) }; frame.render_widget(pa, area); } + fn handle_mouse_event( + &mut self, + area: Rect, + mouse_event: crossterm::event::MouseEvent, + ) -> crate::tui::ManagerAction { + let mouse_position = Position::new(mouse_event.column, mouse_event.row); + if mouse_event.kind + == crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) + && area.contains(mouse_position) + { + self.vm + .show_modal_generic(Help::builder(self.config.clone()), None); + return ManagerAction::new(true); + } + ManagerAction::new(false) + } fn handle_application_event(&mut self, ae: &ApplicationEvent) { debug!("handle_application_event"); if ae.id == "data.payload" diff --git a/src/main.rs b/src/main.rs index 8c78108..bd29f2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,6 @@ mod config; +mod config_button; +mod config_view; mod confirmation; mod expimp; mod gui; @@ -20,7 +22,7 @@ use std::{ fs::File, io::Write, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, Mutex}, }; use clap::{Parser, Subcommand}; @@ -91,7 +93,7 @@ fn initialize_logs(config_path: &Option) { async fn main() -> Result<(), Box> { color_eyre::install()?; let args = Args::parse(); - let mut config = match Config::load(args.config_file.clone()) { + let mut config = match Config::initialize_and_load(args.config_file.clone()) { Ok(config) => config, Err(e) => { error!("{}", e); @@ -103,10 +105,12 @@ async fn main() -> Result<(), Box> { info!("Starting with args={args:?}"); - let config = Arc::new(config); + let config = Arc::new(Mutex::new(config)); let store = Store::new( config + .lock() + .unwrap() .db_path .as_ref() .expect("missing db_path into the configuration"), @@ -174,25 +178,30 @@ async fn main() -> Result<(), Box> { } Some(Commands::Lasts) => { let list = store.list_paths(0, 10, "", false).unwrap(); + let config_lock = config.lock().unwrap(); list.iter() - .for_each(|s| println!("{} {}", (config.date_formater)(s.date), s.path)); + .for_each(|s| println!("{} {}", (config_lock.date_formater)(s.date), s.path)); } Some(Commands::PrettyPrintPath { path, style, max_width, }) => { - let config1 = config.clone(); - let config2 = config.clone(); let max_width = max_width.unwrap_or(u16::MAX); let shortcuts: Vec = store.list_all_shortcuts().unwrap(); + let config_lock = config.lock().unwrap(); + let path_entry = store::Path::new(0, path.clone(), 0, &shortcuts); let shortened_line = - gui::Gui::shorten_path_for_shortcut(config.as_ref(), &shortcuts, path, max_width); + gui::Gui::shorten_path_for_path(&config_lock, &path_entry, max_width); let shortened_line = shortened_line .unwrap_or_else(|| { - gui::Gui::reduce_path(path.clone(), max_width, config1.styles.home_tilde_style) + gui::Gui::reduce_path( + path.clone(), + max_width, + config_lock.styles.home_tilde_style, + ) }) - .style(config2.styles.path_style); + .style(config_lock.styles.path_style); if style.is_none_or(|s| s) { print!("{}", text_to_ansi(&Text::from(shortened_line))); } else { diff --git a/src/search_text_view.rs b/src/search_text_view.rs index 7435bf5..1865707 100644 --- a/src/search_text_view.rs +++ b/src/search_text_view.rs @@ -61,12 +61,12 @@ impl SearchTextState { } pub struct SearchTextView { - config: Arc, + config: Arc>, state: Arc>, } impl SearchTextView { - pub fn builder(config: Arc, state: Arc>) -> ViewBuilder { + pub fn builder(config: Arc>, state: Arc>) -> ViewBuilder { ViewBuilder::from(Box::new(SearchTextView { config, state })) } @@ -80,8 +80,9 @@ impl SearchTextView { impl View for SearchTextView { fn draw(&mut self, frame: &mut ratatui::Frame, area: Rect, active: bool) { debug!("draw area='{}' active='{}", area, active); + let config_lock = self.config.lock().unwrap(); // Fill the frame with the background color if defined - if let Some(bg_color) = &self.config.styles.free_text_area_bg_color { + if let Some(bg_color) = &config_lock.styles.free_text_area_bg_color { // let area = frame.area(); let background = Paragraph::new("").style(Style::default().bg(*bg_color)); frame.render_widget(background, area); @@ -108,22 +109,20 @@ impl View for SearchTextView { Paragraph::new("[e]") }; pa = pa.style( - self.config.styles.date_style.bg(self - .config + config_lock .styles - .free_text_area_bg_color - .unwrap()), + .date_style + .bg(config_lock.styles.free_text_area_bg_color.unwrap()), ); frame.render_widget(pa, left); // Draw the free text area let pa = Paragraph::new(format!("{}{}", SEARCH_PROMPT, search_string.as_str())).style( - self.config.styles.path_style.bg(self - .config + config_lock .styles - .free_text_area_bg_color - .unwrap()), + .path_style + .bg(config_lock.styles.free_text_area_bg_color.unwrap()), ); frame.render_widget(pa, search_text_area); } diff --git a/src/shortcut_editor.rs b/src/shortcut_editor.rs index ca0cd78..e179981 100644 --- a/src/shortcut_editor.rs +++ b/src/shortcut_editor.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use crossterm::event::{KeyCode, KeyEvent}; use log::{debug, error}; @@ -27,7 +27,7 @@ enum EditorField { pub struct ShortcutEditor { store: store::Store, - config: Arc, + config: Arc>, shortcut: Option, name_textarea: Option>, description_textarea: Option>, @@ -35,7 +35,11 @@ pub struct ShortcutEditor { } impl ShortcutEditor { - pub fn builder(store: store::Store, config: Arc, shortcut: Shortcut) -> ViewBuilder { + pub fn builder( + store: store::Store, + config: Arc>, + shortcut: Shortcut, + ) -> ViewBuilder { ViewBuilder::from(Box::new(Self { store, config, @@ -83,16 +87,18 @@ impl View for ShortcutEditor { fn init(&mut self) { debug!("Initializing ShortcutEditor view"); + let config_lock = self.config.lock().unwrap(); + // Initialize name textarea let mut name_textarea = TextArea::default(); name_textarea.set_block( Block::default() .borders(Borders::ALL) .title("Name") - .title_style(self.config.styles.title_style) - .border_style(Style::default().fg(self.config.styles.border_color.unwrap())), + .title_style(config_lock.styles.title_style) + .border_style(Style::default().fg(config_lock.styles.border_color.unwrap())), ); - name_textarea.set_cursor_line_style(self.config.styles.text_style); + name_textarea.set_cursor_line_style(config_lock.styles.text_style); if let Some(shortcut) = self.shortcut.as_ref() { name_textarea.insert_str(shortcut.name.as_str()); } @@ -104,10 +110,10 @@ impl View for ShortcutEditor { Block::default() .borders(Borders::ALL) .title("Description") - .title_style(self.config.styles.title_style) - .border_style(Style::default().fg(self.config.styles.border_color.unwrap())), + .title_style(config_lock.styles.title_style) + .border_style(Style::default().fg(config_lock.styles.border_color.unwrap())), ); - description_textarea.set_cursor_line_style(self.config.styles.text_style); + description_textarea.set_cursor_line_style(config_lock.styles.text_style); if let Some(description) = self.shortcut.as_ref().unwrap().description.as_ref() { description_textarea.insert_str(description.as_str()); } @@ -122,24 +128,26 @@ impl View for ShortcutEditor { return; } + let config_lock = self.config.lock().unwrap(); + // Create a modal that's centered on the screen let layout = Layout::vertical([ Constraint::Fill(1), Constraint::Length(12), Constraint::Fill(1), ]); - let chunks = layout.split(modal_area); + let chunks: [Rect; 3] = layout.areas(modal_area); let center_layout = Layout::horizontal([ Constraint::Fill(5), Constraint::Length(80), Constraint::Fill(5), ]); - let chunks = center_layout.split(chunks[1]); + let chunks: [Rect; 3] = center_layout.areas(chunks[1]); let modal_area = chunks[1]; frame.render_widget(Clear, modal_area); // Fill the frame with the background color if defined - if let Some(bg_color) = &self.config.styles.background_color { + if let Some(bg_color) = &config_lock.styles.background_color { let background = Paragraph::new("").style(Style::default().bg(*bg_color)); frame.render_widget(background, modal_area); } @@ -147,10 +155,10 @@ impl View for ShortcutEditor { // Draw the outer border let block = Block::default() .title("Edit Shortcut") - .title_style(self.config.styles.title_style) + .title_style(config_lock.styles.title_style) .borders(Borders::ALL) - .border_style(Style::default().fg(self.config.styles.border_color.unwrap())) - .style(self.config.styles.text_style); + .border_style(Style::default().fg(config_lock.styles.border_color.unwrap())) + .style(config_lock.styles.text_style); frame.render_widget(block, modal_area); // Split modal into name, description, and buttons @@ -160,7 +168,7 @@ impl View for ShortcutEditor { width: modal_area.width - 2, height: modal_area.height - 2, }; - let vchunks = Layout::default() + let vchunks: [Rect; 4] = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // name textarea @@ -168,7 +176,7 @@ impl View for ShortcutEditor { Constraint::Fill(1), // spacing Constraint::Length(1), // buttons ]) - .split(inner); + .areas(inner); // Update border styles and cursor visibility based on selected field if let Some(name_textarea) = self.name_textarea.as_mut() { @@ -176,8 +184,8 @@ impl View for ShortcutEditor { Block::default() .borders(Borders::ALL) .title("Name") - .title_style(self.config.styles.text_style) - .border_style(Style::default().fg(self.config.styles.border_color.unwrap())), + .title_style(config_lock.styles.text_style) + .border_style(Style::default().fg(config_lock.styles.border_color.unwrap())), ); // Show cursor only if this field is selected if self.selected_field == EditorField::Name { @@ -195,8 +203,8 @@ impl View for ShortcutEditor { Block::default() .borders(Borders::ALL) .title("Description") - .title_style(self.config.styles.text_style) - .border_style(Style::default().fg(self.config.styles.border_color.unwrap())), + .title_style(config_lock.styles.text_style) + .border_style(Style::default().fg(config_lock.styles.border_color.unwrap())), ); // Show cursor only if this field is selected if self.selected_field == EditorField::Description { @@ -211,14 +219,14 @@ impl View for ShortcutEditor { } // Buttons at the bottom - let button_layout = Layout::default() + let button_layout: [Rect; 3] = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Length(10), Constraint::Fill(1), Constraint::Length(10), ]) - .split(vchunks[3]); + .areas(vchunks[3]); let yes_style = if self.selected_field == EditorField::YesButton { Style::default() diff --git a/src/shortcut_view_container.rs b/src/shortcut_view_container.rs index e226506..94af119 100644 --- a/src/shortcut_view_container.rs +++ b/src/shortcut_view_container.rs @@ -9,6 +9,7 @@ use ratatui::layout::{Constraint, Layout, Rect}; use crate::{ config::Config, + config_button::ConfigButton, list_indicator_view::ListIndicatorView, model::ListFunction, search_text_view::{SearchTextState, SearchTextView}, @@ -19,7 +20,8 @@ use crate::{ const SHORTCUT_VIEW_ID: u16 = 0; const SEARCH_TEXT_VIEW_1: u16 = 1; -const LIST_INDICATOR_VIEW: u16 = 2; +const CONFIGURATION_VIEW: u16 = 2; +const LIST_INDICATOR_VIEW: u16 = 3; pub struct ShortcutViewContainer {} @@ -31,7 +33,7 @@ impl ShortcutViewContainer { list_fn: Box>, rowify: RowifyFn, stringify: fn(&Shortcut) -> String, - config: Arc, + config: Arc>, view_state: Arc>, delete_fn: DeleteFn, editor_modal_view_builder: Option>, @@ -41,7 +43,7 @@ impl ShortcutViewContainer { .child( SHORTCUT_VIEW_ID, TableView::builder( - vm, + vm.clone(), "shortcut".to_string(), column_names, column_constraints, @@ -60,9 +62,13 @@ impl ShortcutViewContainer { SEARCH_TEXT_VIEW_1, SearchTextView::builder(config.clone(), search_text_state.clone()), ) + .child( + CONFIGURATION_VIEW, + ConfigButton::builder(vm.clone(), config.clone()), + ) .child( LIST_INDICATOR_VIEW, - ListIndicatorView::builder(config.clone(), "shortcut".to_string()), + ListIndicatorView::builder(vm.clone(), config.clone(), "shortcut".to_string()), ) } } @@ -74,14 +80,20 @@ impl View for ShortcutViewContainer { let vertical = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).spacing(0); let [main, bottom] = vertical.areas(area); - let horizontal = - Layout::horizontal([Constraint::Fill(1), Constraint::Length(14)]).spacing(0); - let [search_text_area, right] = horizontal.areas(bottom); + let horizontal = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(14), + Constraint::Length(1), + Constraint::Length(12), + ]) + .spacing(0); + let [search_text_area, list_indicator_rect, _, config_rect] = horizontal.areas(bottom); vec![ (SHORTCUT_VIEW_ID, main), (SEARCH_TEXT_VIEW_1, search_text_area), - (LIST_INDICATOR_VIEW, right), + (LIST_INDICATOR_VIEW, list_indicator_rect), + (CONFIGURATION_VIEW, config_rect), ] } fn draw(&mut self, _frame: &mut ratatui::Frame, area: ratatui::prelude::Rect, active: bool) { diff --git a/src/store.rs b/src/store.rs index 5ea7e74..f689633 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,7 +1,7 @@ use std::{ fmt, fs, rc::Rc, - sync::Arc, + sync::{Arc, Mutex}, time::{SystemTime, UNIX_EPOCH}, }; @@ -167,7 +167,7 @@ impl SmartRanker { /// db_conn: the SQLite database connection pub(crate) struct Store { db_conn: Rc, - config: Arc, + config: Arc>, } impl Store { @@ -178,7 +178,7 @@ impl Store { /// /// ### Returns /// a new Store instance - pub(crate) fn new(dir_path: &std::path::Path, config: Arc) -> Store { + pub(crate) fn new(dir_path: &std::path::Path, config: Arc>) -> Store { info!("db file={}", dir_path.display()); if !dir_path.exists() @@ -472,7 +472,7 @@ impl Store { trace!("Scoring path '{}' initial score={:?}", path.path, max_score); - if !self.config.path_search_include_shortcuts { + if !self.config.lock().unwrap().path_search_include_shortcuts { return max_score; } @@ -583,7 +583,7 @@ impl Store { // Find shortcuts where name or description matches the like_text let like_lower = like_text.to_lowercase(); - if self.config.path_search_include_shortcuts { + if self.config.lock().unwrap().path_search_include_shortcuts { let matching_shortcut_paths: Vec<&str> = shortcuts .iter() .filter(|s| { @@ -643,14 +643,15 @@ impl Store { let mut len = len; let mut smart_rows = vec![]; - if self.config.smart_suggestions_active && like_text.is_empty() { + if self.config.lock().unwrap().smart_suggestions_active && like_text.is_empty() { // get current working directory let cwd = std::env::current_dir().unwrap(); + let config_lock = self.config.lock().unwrap(); smart_rows = self .list_path_history_smart_suggestions( cwd.to_str().unwrap(), - self.config.smart_suggestions_depth, - self.config.smart_suggestions_count, + config_lock.smart_suggestions_depth, + config_lock.smart_suggestions_count, shortcuts, ) .unwrap(); @@ -1228,7 +1229,7 @@ impl Store { pub(crate) fn setup_test_store() -> Store { let store = Store { db_conn: Rc::from(Connection::open_in_memory().unwrap()), - config: Arc::new(Config::default()), + config: Arc::new(Mutex::new(Config::default())), }; store.init_schema(); store diff --git a/src/store_tests.rs b/src/store_tests.rs index faecd94..ad23c3d 100644 --- a/src/store_tests.rs +++ b/src/store_tests.rs @@ -995,7 +995,7 @@ fn test_path_search_include_shortcuts_disabled() { let store = Store { db_conn: Rc::from(Connection::open_in_memory().unwrap()), - config: Arc::new(config), + config: Arc::new(Mutex::new(config)), }; store.init_schema(); @@ -1044,7 +1044,7 @@ fn test_path_search_include_shortcuts_filter_by_description_disabled() { let store = Store { db_conn: Rc::from(Connection::open_in_memory().unwrap()), - config: Arc::new(config), + config: Arc::new(Mutex::new(config)), }; store.init_schema(); @@ -1073,7 +1073,7 @@ fn test_path_search_include_shortcuts_direct_path_match_always_works() { let store = Store { db_conn: Rc::from(Connection::open_in_memory().unwrap()), - config: Arc::new(config), + config: Arc::new(Mutex::new(config)), }; store.init_schema(); @@ -1122,7 +1122,7 @@ fn test_list_path_fuzzy_with_shortcut_scoring_disabled() { let store = Store { db_conn: Rc::from(Connection::open_in_memory().unwrap()), - config: Arc::new(config), + config: Arc::new(Mutex::new(config)), }; store.init_schema(); diff --git a/src/tableview.rs b/src/tableview.rs index e22f490..bc073e0 100644 --- a/src/tableview.rs +++ b/src/tableview.rs @@ -219,6 +219,16 @@ impl View for TableView { payload.fuzzy_match, ); + let _ = self + .tx + .send(GenericEvent::ViewManagerEvent(ViewManagerEvent::Redraw)); + } else if ae.id == "data.reload" { + self.data_model.reload(); + self.table_state.select_cell(Some(( + (self.find_focus_fn)(self.data_model.entries.as_ref().unwrap()), + 0, + ))); + let _ = self .tx .send(GenericEvent::ViewManagerEvent(ViewManagerEvent::Redraw)); @@ -249,12 +259,13 @@ impl TableView { list_fn: Box>, rowify: RowifyFn, stringify: fn(&T) -> String, - config: Arc, + config: Arc>, view_state: Arc>, delete_fn: DeleteFn, editor_modal_view_builder: Option>, find_focus_fn: FindFocusFn, ) -> ViewBuilder { + let styles = config.lock().unwrap().styles.clone(); ViewBuilder::from(Box::new(TableView { vm: vm.clone(), tx: vm.tx(), @@ -265,7 +276,7 @@ impl TableView { table_rows_count: 0, rowify, stringify, - styles: config.styles.clone(), + styles, view_state, delete_fn, editor_modal_view_builder, diff --git a/src/tui/view_manager.rs b/src/tui/view_manager.rs index df66c4f..32831be 100644 --- a/src/tui/view_manager.rs +++ b/src/tui/view_manager.rs @@ -25,6 +25,7 @@ mod view_manager_tests; type ModalCallBack = Box ManagerAction>; type HelpViewBuilderCallBack = Box ViewBuilder>; +type ConfigViewBuilderCallBack = Box ViewBuilder>; /// Represents a modal view entry with its associated parent and close callback. struct ModalEntry { @@ -47,7 +48,8 @@ pub struct ViewManager { modal_views: RefCell>>>, context_view: RefCell>>>, - global_help_view_builder_cb: Option, + global_help_view_builder_cb: RefCell>, + global_config_view_builder_cb: RefCell>, exit_string: RefCell>, } @@ -68,15 +70,21 @@ impl ViewManager { active_view: RefCell::new(vec![]), modal_views: RefCell::new(vec![]), context_view: RefCell::new(None), - global_help_view_builder_cb: None, + global_help_view_builder_cb: RefCell::new(None), + global_config_view_builder_cb: RefCell::new(None), exit_string: RefCell::new(None), } } pub fn tx(&self) -> broadcast::Sender { self.tx.clone() } - pub fn set_global_help_view(&mut self, help_view: HelpViewBuilderCallBack) { - self.global_help_view_builder_cb = Some(help_view); + pub fn set_global_help_view(&self, help_view: HelpViewBuilderCallBack) { + self.global_help_view_builder_cb.replace(Some(help_view)); + } + + pub fn set_global_config_view(&self, config_view: ConfigViewBuilderCallBack) { + self.global_config_view_builder_cb + .replace(Some(config_view)); } /// Returns a centered rectangle of the specified width and height within the given area. @@ -293,20 +301,20 @@ impl ViewManager { true } - /// Handles key events for the topmost modal view if one exists. - /// - /// This method processes key events in the modal context and manages the modal lifecycle, - /// including closing modals and executing their associated callbacks. + /// Generic helper function to handle modal event processing with callback management. /// /// # Returns /// - `Some(ManagerAction)` if a modal handled the event /// - `None` if there are no active modals - fn handle_modal_key_event(&self, key_event: KeyEvent) -> Option { + fn handle_modal_event(&self, event_handler: F) -> Option + where + F: FnOnce(&mut ModalEntry) -> ManagerAction, + { // Check if there's an active modal view let last_modal = self.modal_views.borrow().last()?.clone(); let mut modal_entry = last_modal.borrow_mut(); - let (_event_captured, action) = modal_entry.modal_view.view.handle_key_event(key_event); + let action = event_handler(&mut modal_entry); // If the modal should close and has a callback, execute it let final_action = if action.close() && modal_entry.on_close.is_some() { @@ -337,6 +345,13 @@ impl ViewManager { Some(final_action) } + fn handle_modal_key_event(&self, key_event: KeyEvent) -> Option { + self.handle_modal_event(|modal_entry| { + let (_event_captured, action) = modal_entry.modal_view.view.handle_key_event(key_event); + action + }) + } + /// Handles key events for the active view hierarchy. /// /// Processes a single view's key event handling and optionally broadcasts to children. @@ -502,6 +517,15 @@ impl ViewManager { Some(active_view_vec) } + fn handle_modal_mouse_event(&self, mouse_event: MouseEvent) -> Option { + self.handle_modal_event(|modal_entry| { + let area = modal_entry.modal_view.area; + modal_entry + .modal_view + .view + .handle_mouse_event(area, mouse_event) + }) + } pub fn handle_mouse_event(&self, mouse_event: MouseEvent) -> ManagerAction { //trace!("handle_mouse_event {:?}", mouse_event); @@ -509,6 +533,11 @@ impl ViewManager { return ManagerAction::new(false); } + // First, check if there's an active modal view that should handle the event + if let Some(modal_action) = self.handle_modal_mouse_event(mouse_event) { + return modal_action; + } + // on mouse down, activate the view at the mouse position let position = Position::new(mouse_event.column, mouse_event.row); self.activate_view_at_position(position); @@ -536,6 +565,7 @@ impl ViewManager { /// - `None` if no active views exist fn handle_active_view_mouse_event(&self, mouse_event: MouseEvent) -> Option { debug!("handle_active_view_mouse_event {:?}", mouse_event); + let top_level_view_idx = *self.top_level_view_idx.borrow(); let active_view_vec = &self.active_view.borrow()[top_level_view_idx]; let views = active_view_vec.as_ref()?; @@ -668,7 +698,7 @@ impl ViewManager { &self, crossterm_event: Option>, ) -> ManagerAction { - debug!("received crossterm event: {:?}", crossterm_event); + //trace!("received crossterm event: {:?}", crossterm_event); let mut manager_action: ManagerAction = ManagerAction::new(false); match crossterm_event { Some(Ok(event)) => match event { @@ -695,10 +725,16 @@ impl ViewManager { } else if key_event.modifiers.contains(KeyModifiers::CONTROL) && let KeyCode::Char('h') = key_event.code && let Some(global_help_view_builder_cb) = - &self.global_help_view_builder_cb + &self.global_help_view_builder_cb.borrow().as_ref() { self.show_modal_generic(global_help_view_builder_cb(), None); manager_action.redraw = true; + } else if key_event.code == KeyCode::F(12) + && let Some(global_config_view_builder_cb) = + &self.global_config_view_builder_cb.borrow().as_ref() + { + self.show_modal_generic(global_config_view_builder_cb(), None); + manager_action.redraw = true; } else { manager_action = self.handle_key_event(key_event); }