diff --git a/README.md b/README.md index 589a04e..b517319 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ TUI-Journal is a terminal-based application written in Rust that allows you to w - Intuitive, responsive and user-friendly text-based user interface (TUI). - Create, edit, and delete entries easily. - Edit journal content with the built-in editor or use your favourite terminal text editor from within the app. -- Add custom tags to the journals and use them in the built-in filter. +- Add custom colored tags to the journals and use them in the built-in filter. - Fuzzy Finder: Locate your desired journal with lightning-fast speed. - Search functions for journals title and content in the built-in filter. - Sort the journals based on their date, priority and title. @@ -219,6 +219,8 @@ sync_os_clipboard = false # Syncs editor clipboard actions with operating syste history_limit = 10 # Sets the maximum changes limit for the undo & redo stacks. Use 0 to disable it. +colored_tags = true # Sets if automatically coloring for tags is enabled. + [export] default_path = "" # Optional default path to export multiple journals or a single journal's content. Falls back to the current directory if not specified. show_confirmation = true # Show confirmation after successful export. diff --git a/src/app/colored_tags.rs b/src/app/colored_tags.rs new file mode 100644 index 0000000..480caf2 --- /dev/null +++ b/src/app/colored_tags.rs @@ -0,0 +1,142 @@ +use std::collections::HashMap; + +use ratatui::style::Color; + +/// Hard coded colors for the tags. +/// Note: the order to pick the colors is from bottom to top because we are popping the colors from +/// the end of the stack. +const TAG_COLORS: &[TagColors] = &[ + TagColors::new(Color::Black, Color::LightMagenta), + TagColors::new(Color::Red, Color::Cyan), + TagColors::new(Color::Yellow, Color::Blue), + TagColors::new(Color::Reset, Color::Red), + TagColors::new(Color::Black, Color::LightYellow), + TagColors::new(Color::Reset, Color::DarkGray), + TagColors::new(Color::Black, Color::LightGreen), + TagColors::new(Color::Black, Color::LightRed), + TagColors::new(Color::Black, Color::LightCyan), +]; + +#[derive(Debug, Clone)] +/// Manages assigning colors to the tags, keeping track on the assigned colors and providing +/// functions to updating them. +pub struct ColoredTagsManager { + tag_colors_map: HashMap, + available_colors: Vec, +} + +impl ColoredTagsManager { + pub fn new() -> Self { + let available_colors = TAG_COLORS.to_vec(); + + Self { + tag_colors_map: HashMap::new(), + available_colors, + } + } + + /// Updates the tag_color map with the provided tags, removing the not existing tags and + /// assigning colors to the newly added ones. + pub fn update_tags(&mut self, current_tags: Vec) { + // First: Clear the non-existing anymore tags. + let tags_to_remove: Vec<_> = self + .tag_colors_map + .keys() + .filter(|t| !current_tags.contains(t)) + .cloned() + .collect(); + + for tag in tags_to_remove { + let color = self.tag_colors_map.remove(&tag).unwrap(); + self.available_colors.push(color) + } + + // Second: Add the new tags to the map + for tag in current_tags { + match self.tag_colors_map.entry(tag) { + std::collections::hash_map::Entry::Occupied(_) => {} + std::collections::hash_map::Entry::Vacant(vacant_entry) => { + let color = self.available_colors.pop().unwrap_or_default(); + vacant_entry.insert(color); + } + } + } + } + + /// Gets the matching color for the giving tag if tag exists. + pub fn get_tag_color(&self, tag: &str) -> Option { + self.tag_colors_map.get(tag).copied() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +/// Represents the needed colors for a colored tag +pub struct TagColors { + pub foreground: Color, + pub background: Color, +} + +impl TagColors { + pub const fn new(foreground: Color, background: Color) -> Self { + Self { + foreground, + background, + } + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_colored_tags() { + const TAG_ONE: &str = "Tag 1"; + const TAG_TWO: &str = "Tag 2"; + const ADDED_TAG: &str = "Added Tag"; + + let mut tags = vec![ + String::from(TAG_ONE), + String::from(TAG_TWO), + String::from("Tag 3"), + String::from("Tag 4"), + ]; + + let mut manager = ColoredTagsManager::new(); + manager.update_tags(tags.clone()); + + // Ensure all tags have colors. + for tag in tags.iter() { + assert!(manager.get_tag_color(tag).is_some()); + } + + // Ensure non existing tags are none + assert!(manager.get_tag_color("Non Existing Tag").is_none()); + + // Keep track on colors before updating. + let tag_one_color = manager.get_tag_color(TAG_ONE).unwrap(); + let tag_two_color = manager.get_tag_color(TAG_TWO).unwrap(); + + // Remove Tag one with changing the order of the tags. + assert_eq!(tags.swap_remove(0), TAG_ONE); + + tags.push(ADDED_TAG.into()); + + manager.update_tags(tags.clone()); + + // Ensure all current tags have colors. + for tag in tags.iter() { + assert!(manager.get_tag_color(tag).is_some()); + } + + // Tag one should have no color after remove. + assert!(manager.get_tag_color(TAG_ONE).is_none()); + + // Tag two color must remain the same after update. + assert_eq!(manager.get_tag_color(TAG_TWO).unwrap(), tag_two_color); + + // Added tag should take the color of tag one because we removed it then added the new tag. + assert_eq!(manager.get_tag_color(ADDED_TAG).unwrap(), tag_one_color); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 0c4157f..36c589c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -7,6 +7,7 @@ use crate::settings::Settings; use anyhow::{anyhow, bail, Context}; use backend::{DataProvider, EntriesDTO, Entry, EntryDraft}; use chrono::{DateTime, Utc}; +use colored_tags::ColoredTagsManager; use history::{Change, HistoryManager, HistoryStack}; use rayon::prelude::*; use std::{ @@ -15,6 +16,7 @@ use std::{ path::PathBuf, }; +mod colored_tags; mod external_editor; mod filter; mod history; @@ -30,6 +32,8 @@ pub use runner::run; pub use runner::HandleInputReturnType; pub use ui::UIComponents; +pub use colored_tags::TagColors; + pub struct App where D: DataProvider, @@ -47,6 +51,7 @@ where state: AppState, /// Keeps history of the changes on entries, enabling undo & redo operations history: HistoryManager, + colored_tags: Option, } impl App @@ -58,6 +63,8 @@ where let selected_entries = HashSet::new(); let filtered_out_entries = HashSet::new(); let history = HistoryManager::new(settings.history_limit); + let colored_tags = settings.colored_tags.then(ColoredTagsManager::new); + Self { data_provide, entries, @@ -69,6 +76,7 @@ where filter: None, state: Default::default(), history, + colored_tags, } } @@ -122,6 +130,8 @@ where self.update_filtered_out_entries(); + self.update_colored_tags(); + Ok(()) } @@ -163,6 +173,7 @@ where self.sort_entries(); self.update_filtered_out_entries(); + self.update_colored_tags(); Ok(entry_id) } @@ -221,6 +232,7 @@ where self.update_filter(); self.update_filtered_out_entries(); + self.update_colored_tags(); Ok(()) } @@ -286,6 +298,7 @@ where self.update_filter(); self.update_filtered_out_entries(); + self.update_colored_tags(); Ok(()) } @@ -385,6 +398,26 @@ where } } + /// Updates the colors tags mapping, assigning colors to new one and removing the non existing + /// tags from the colors map. + fn update_colored_tags(&mut self) { + if self.colored_tags.is_none() { + return; + } + + let tags = { self.get_all_tags() }; + if let Some(colored_tags) = self.colored_tags.as_mut() { + colored_tags.update_tags(tags); + } + } + + /// Gets the matching color for the giving tag if colored tags are enabled and tag exists. + pub fn get_color_for_tag(&self, tag: &str) -> Option { + self.colored_tags + .as_ref() + .and_then(|c| c.get_tag_color(tag)) + } + /// Assigns priority to all entries that don't have a priority assigned to async fn assign_priority_to_entries(&self, priority: u32) -> anyhow::Result<()> { self.data_provide @@ -429,7 +462,7 @@ where /// Apply undo on entries returning the id of the effected entry. pub async fn undo(&mut self) -> anyhow::Result> { match self.history.pop_undo() { - Some(change) => self.apply_change(change, HistoryStack::Redo).await, + Some(change) => self.apply_history_change(change, HistoryStack::Redo).await, None => Ok(None), } } @@ -437,12 +470,12 @@ where /// Apply redo on entries returning the id of the effected entry. pub async fn redo(&mut self) -> anyhow::Result> { match self.history.pop_redo() { - Some(change) => self.apply_change(change, HistoryStack::Undo).await, + Some(change) => self.apply_history_change(change, HistoryStack::Undo).await, None => Ok(None), } } - async fn apply_change( + async fn apply_history_change( &mut self, change: Change, history_target: HistoryStack, diff --git a/src/app/ui/entries_list/mod.rs b/src/app/ui/entries_list/mod.rs index b007c9d..da71f5b 100644 --- a/src/app/ui/entries_list/mod.rs +++ b/src/app/ui/entries_list/mod.rs @@ -131,26 +131,42 @@ impl<'a> EntriesList { // *** Tags *** if !entry.tags.is_empty() { - let tags: Vec = entry.tags.iter().map(String::from).collect(); - let tag_line = tags.join(" | "); - - // Text wrapping - let tag_line = - textwrap::wrap(&tag_line, area.width as usize - LIST_INNER_MARGIN); - - lines_count += tag_line.len(); - - tag_line - .into_iter() - .map(|line| { - Line::from(Span::styled( - line.to_string(), - Style::default() - .fg(Color::LightCyan) - .add_modifier(Modifier::DIM), - )) - }) - .for_each(|span| spans.push(span)); + const TAGS_SEPARATOR: &str = " | "; + let tags_default_style: Style = Style::default() + .fg(Color::LightCyan) + .add_modifier(Modifier::DIM); + + let mut added_lines = 1; + spans.push(Line::default()); + + for tag in entry.tags.iter() { + let mut last_line = spans.last_mut().unwrap(); + let allowd_width = area.width as usize - LIST_INNER_MARGIN; + if !last_line.spans.is_empty() { + if last_line.width() + TAGS_SEPARATOR.len() > allowd_width { + added_lines += 1; + spans.push(Line::default()); + last_line = spans.last_mut().unwrap(); + } + last_line.push_span(Span::styled(TAGS_SEPARATOR, tags_default_style)) + } + + let style = app + .get_color_for_tag(tag) + .map(|c| Style::default().bg(c.background).fg(c.foreground)) + .unwrap_or(tags_default_style); + let span_to_add = Span::styled(tag.to_owned(), style); + + if last_line.width() + tag.len() < allowd_width { + last_line.push_span(span_to_add); + } else { + added_lines += 1; + let line = Line::from(span_to_add); + spans.push(line); + } + } + + lines_count += added_lines; } ListItem::new(spans) diff --git a/src/settings/mod.rs b/src/settings/mod.rs index ed0a6b7..958060a 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -47,6 +47,8 @@ pub struct Settings { #[serde(default = "default_history_limit")] /// Set the maximum size of the history stacks (undo & redo) size. pub history_limit: usize, + #[serde(default = "default_colored_tags")] + pub colored_tags: bool, } impl Default for Settings { @@ -63,6 +65,7 @@ impl Default for Settings { scroll_per_page: Default::default(), sync_os_clipboard: Default::default(), history_limit: default_history_limit(), + colored_tags: default_colored_tags(), } } } @@ -79,6 +82,10 @@ const fn default_history_limit() -> usize { 10 } +const fn default_colored_tags() -> bool { + true +} + impl Settings { pub async fn new() -> anyhow::Result { let settings_path = get_settings_path()?; @@ -131,6 +138,7 @@ impl Settings { scroll_per_page: _, sync_os_clipboard: _, history_limit: _, + colored_tags: _, } = self; if self.backend_type.is_none() {