diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..4c3a01c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo fmt:*)", + "Bash(cargo build:*)", + "Bash(rustup default:*)", + "Bash(cargo check:*)" + ] + } +} diff --git a/.gitignore b/.gitignore index e8777ce..a35b418 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ temp_images/ tmp/ logo.inspirations/ flamegraph.svg +.claude/ diff --git a/src/comments.rs b/src/comments.rs index 0226622..6fc9bbe 100644 --- a/src/comments.rs +++ b/src/comments.rs @@ -68,6 +68,7 @@ pub struct Comment { pub chapter_href: String, pub target: CommentTarget, pub content: String, + pub selected_text: Option, // The text that was highlighted/selected pub updated_at: DateTime, } @@ -77,6 +78,8 @@ struct CommentModernSerde { #[serde(flatten)] pub target: CommentTarget, pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_text: Option, pub updated_at: DateTime, } @@ -88,6 +91,8 @@ struct CommentLegacySerde { #[serde(default)] pub word_range: Option<(usize, usize)>, pub content: String, + #[serde(default)] + pub selected_text: Option, pub updated_at: DateTime, } @@ -107,6 +112,7 @@ impl From for Comment { word_range: legacy.word_range, }, content: legacy.content, + selected_text: legacy.selected_text, updated_at: legacy.updated_at, } } @@ -118,6 +124,7 @@ impl From for Comment { chapter_href: modern.chapter_href, target: modern.target, content: modern.content, + selected_text: modern.selected_text, updated_at: modern.updated_at, } } @@ -129,6 +136,7 @@ impl From<&Comment> for CommentModernSerde { chapter_href: comment.chapter_href.clone(), target: comment.target.clone(), content: comment.content.clone(), + selected_text: comment.selected_text.clone(), updated_at: comment.updated_at, } } @@ -401,6 +409,7 @@ mod tests { word_range: None, }, content: content.to_string(), + selected_text: None, updated_at: Utc::now(), } } @@ -418,6 +427,7 @@ mod tests { line_range, }, content: content.to_string(), + selected_text: None, updated_at: Utc::now(), } } diff --git a/src/main_app.rs b/src/main_app.rs index 083a96b..15089fd 100644 --- a/src/main_app.rs +++ b/src/main_app.rs @@ -2107,7 +2107,8 @@ impl App { } _ => "Search mode active".to_string(), } - } else if self.text_reader.has_text_selection() { + } else if self.text_reader.has_text_selection() || self.text_reader.is_visual_mode_active() + { "a: Add comment | c/Ctrl+C: Copy to clipboard | ESC: Clear selection".to_string() } else { let help_text = match self.focused_panel { @@ -2115,7 +2116,7 @@ impl App { "j/k: Navigate | Enter: Select | h/l: Fold/Unfold | H/L: Fold/Unfold All | Tab: Switch | q: Quit" } FocusedPanel::Main(MainPanel::Content) => { - "j/k: Scroll | h/l: Chapter | Ctrl+d/u: Half-screen | Tab: Switch | Space+o: Open | q: Quit" + "j/k: Scroll | h/l: Chapter | Ctrl+d/u: Half-screen | v/V: Visual Mode | Tab: Switch | Space+o: Open | q: Quit" } FocusedPanel::Popup(PopupWindow::ReadingHistory) => { "j/k/Scroll: Navigate | Enter/DblClick: Open | ESC: Close" @@ -3119,6 +3120,22 @@ impl App { let visual_mode = self.text_reader.get_visual_mode(); match key.code { + KeyCode::Char('a') => { + let success = self.text_reader.convert_visual_to_text_selection() + && self.text_reader.start_comment_input(); + + if success { + debug!("Started comment input mode from visual selection"); + } else { + debug!("Failed to start comment input from visual selection"); + self.notifications + .show_warning("Cannot annotate this selection"); + } + + // Always exit visual mode when 'a' is pressed + self.text_reader.exit_visual_mode(); + return None; + } KeyCode::Char('y') => { if let Some(text) = self.text_reader.yank_visual_selection() { let _ = self.text_reader.copy_to_clipboard(text); diff --git a/src/markdown.rs b/src/markdown.rs index 36297e5..42ba604 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -433,11 +433,11 @@ impl Text { self.0.len() } - pub fn iter(&self) -> std::slice::Iter { + pub fn iter(&self) -> std::slice::Iter<'_, TextOrInline> { self.0.iter() } - pub fn iter_mut(&mut self) -> std::slice::IterMut { + pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, TextOrInline> { self.0.iter_mut() } diff --git a/src/panic_handler.rs b/src/panic_handler.rs index 1a7400e..26f88fa 100644 --- a/src/panic_handler.rs +++ b/src/panic_handler.rs @@ -33,7 +33,3 @@ fn restore_terminal() { let _ = execute!(io::stderr(), crossterm::cursor::Show); let _ = writeln!(io::stderr()); } - -/// Initialize human-panic metadata for release builds -#[cfg(not(debug_assertions))] -use human_panic::Metadata; diff --git a/src/settings.rs b/src/settings.rs index fb713e9..3c7183f 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -49,6 +49,9 @@ pub struct Settings { #[serde(default)] pub margin: u16, + #[serde(default = "default_annotation_highlight_color")] + pub annotation_highlight_color: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub custom_themes: Vec, } @@ -61,12 +64,17 @@ fn default_theme() -> String { "Oceanic Next".to_string() } +fn default_annotation_highlight_color() -> String { + "7FB4CA".to_string() // Cyan (base0C) from Kanagawa theme +} + impl Default for Settings { fn default() -> Self { Self { version: CURRENT_VERSION, theme: default_theme(), margin: 0, + annotation_highlight_color: default_annotation_highlight_color(), custom_themes: Vec::new(), } } @@ -161,6 +169,15 @@ fn generate_settings_yaml(settings: &Settings) -> String { content.push_str(&format!("theme: \"{}\"\n", settings.theme)); content.push_str(&format!("margin: {}\n", settings.margin)); content.push('\n'); + content.push_str( + "# Annotation highlight color (hex color without #, e.g., \"7FB4CA\" for cyan)\n", + ); + content.push_str("# Set to \"none\" or \"disabled\" to disable highlighting\n"); + content.push_str(&format!( + "annotation_highlight_color: \"{}\"\n", + settings.annotation_highlight_color + )); + content.push('\n'); content.push_str(CUSTOM_THEMES_TEMPLATE); @@ -257,3 +274,10 @@ pub fn get_custom_themes() -> Vec { .map(|s| s.custom_themes.clone()) .unwrap_or_default() } + +pub fn get_annotation_highlight_color() -> String { + SETTINGS + .read() + .map(|s| s.annotation_highlight_color.clone()) + .unwrap_or_else(|_| default_annotation_highlight_color()) +} diff --git a/src/theme.rs b/src/theme.rs index 674e702..f537357 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -404,9 +404,9 @@ impl Base16Palette { pub fn get_panel_colors(&self, is_focused: bool) -> (Color, Color, Color) { if is_focused { - (self.base_07, self.base_04, self.base_00) + (self.base_05, self.base_04, self.base_00) // Use base_05 (Default text) when focused } else { - (self.base_03, self.base_03, self.base_00) + (self.base_04, self.base_03, self.base_00) // Use base_04 (Dark foreground) when unfocused } } diff --git a/src/widget/navigation_panel/table_of_contents.rs b/src/widget/navigation_panel/table_of_contents.rs index f6d00c3..5ecfc83 100644 --- a/src/widget/navigation_panel/table_of_contents.rs +++ b/src/widget/navigation_panel/table_of_contents.rs @@ -424,7 +424,7 @@ impl TableOfContents { } /// Get the selected item (either back button or TOC item) - pub fn get_selected_item(&self) -> Option { + pub fn get_selected_item(&self) -> Option> { if let Some(ref current_book_info) = self.current_book_info { if self.selected_index == 0 { Some(SelectedTocItem::BackToBooks) diff --git a/src/widget/text_reader/comments.rs b/src/widget/text_reader/comments.rs index 47592f6..a865e22 100644 --- a/src/widget/text_reader/comments.rs +++ b/src/widget/text_reader/comments.rs @@ -198,6 +198,11 @@ impl crate::markdown_text_reader::MarkdownTextReader { if !comment_text.trim().is_empty() { if let Some(target) = self.comment_input.target.clone() { + // Extract the selected text before clearing the selection + let selected_text = self + .text_selection + .extract_selected_text(&self.raw_text_lines); + if let Some(comments_arc) = &self.book_comments { if let Ok(mut comments) = comments_arc.lock() { use chrono::Utc; @@ -219,6 +224,7 @@ impl crate::markdown_text_reader::MarkdownTextReader { chapter_href: chapter_file.clone(), target, content: comment_text.clone(), + selected_text, updated_at: Utc::now(), }; diff --git a/src/widget/text_reader/rendering.rs b/src/widget/text_reader/rendering.rs index 90b5a7b..b73fede 100644 --- a/src/widget/text_reader/rendering.rs +++ b/src/widget/text_reader/rendering.rs @@ -923,6 +923,22 @@ impl crate::markdown_text_reader::MarkdownTextReader { TextOrInline::Inline(Inline::Image { url, .. }) => { // If we have accumulated text before the image, render it first if !current_rich_spans.is_empty() { + // Apply word-range highlighting for any annotations on this paragraph + if let Some(node_idx) = node_index { + if let Some(comments) = self.current_chapter_comments.get(&node_idx) { + let word_ranges: Vec<(usize, usize)> = comments + .iter() + .filter_map(|c| c.target.word_range()) + .collect(); + if !word_ranges.is_empty() { + current_rich_spans = self.apply_word_ranges_highlighting( + current_rich_spans, + &word_ranges, + ); + } + } + } + self.render_text_spans( ¤t_rich_spans, None, // no prefix @@ -950,6 +966,20 @@ impl crate::markdown_text_reader::MarkdownTextReader { // Render any remaining text spans if !current_rich_spans.is_empty() { + // Apply word-range highlighting for any annotations on this paragraph + if let Some(node_idx) = node_index { + if let Some(comments) = self.current_chapter_comments.get(&node_idx) { + let word_ranges: Vec<(usize, usize)> = comments + .iter() + .filter_map(|c| c.target.word_range()) + .collect(); + if !word_ranges.is_empty() { + current_rich_spans = + self.apply_word_ranges_highlighting(current_rich_spans, &word_ranges); + } + } + } + let add_empty_line = context == RenderContext::TopLevel; self.render_text_spans( ¤t_rich_spans, @@ -1300,7 +1330,8 @@ impl crate::markdown_text_reader::MarkdownTextReader { if allowed == 0 { comment_line = "…".to_string(); } else { - let truncated: String = comment_line.chars().take(allowed).collect(); + let truncated: String = + comment_line.chars().take(allowed).collect(); comment_line = format!("{truncated}…"); } } @@ -2529,6 +2560,194 @@ impl crate::markdown_text_reader::MarkdownTextReader { self.raw_text_lines.push(String::new()); *total_height += 1; } + + /// Apply multiple word-range highlights to spans for annotated text + /// This highlights the text that has been annotated with a background color + fn apply_word_ranges_highlighting( + &self, + spans: Vec, + word_ranges: &[(usize, usize)], + ) -> Vec { + use crate::color_mode::smart_color; + use crate::settings; + use crate::widget::text_reader::types::RichSpan; + + if word_ranges.is_empty() { + return spans; + } + + // Get the configurable highlight color from settings + let highlight_color_hex = settings::get_annotation_highlight_color(); + + // Check if highlighting is disabled + if highlight_color_hex.is_empty() + || highlight_color_hex.eq_ignore_ascii_case("none") + || highlight_color_hex.eq_ignore_ascii_case("disabled") + { + return spans; // Skip highlighting entirely + } + + let highlight_color = match u32::from_str_radix(&highlight_color_hex, 16) { + Ok(value) => smart_color(value), + Err(_) => smart_color(0x7FB4CA), // Fallback to default cyan + }; + + // Convert all word ranges to character ranges + let mut char_ranges: Vec<(usize, usize)> = Vec::new(); + + for &(start_word, end_word) in word_ranges { + let mut current_char = 0; + let mut word_count = 0; + let mut start_char = None; + let mut end_char = None; + + for rich_span in &spans { + let text = match rich_span { + RichSpan::Text(span) => span.content.to_string(), + RichSpan::Link { span, .. } => span.content.to_string(), + }; + let chars: Vec = text.chars().collect(); + + let mut i = 0; + while i < chars.len() { + if chars[i].is_whitespace() { + current_char += 1; + i += 1; + continue; + } + + // Start of a word + let word_start = current_char; + while i < chars.len() && !chars[i].is_whitespace() { + current_char += 1; + i += 1; + } + + // We've completed a word + if word_count == start_word && start_char.is_none() { + start_char = Some(word_start); + } + word_count += 1; + if word_count == end_word { + end_char = Some(current_char); + break; + } + } + + if end_char.is_some() { + break; + } + } + + if let Some(start) = start_char { + let end = end_char.unwrap_or(current_char); + char_ranges.push((start, end)); + } + } + + if char_ranges.is_empty() { + return spans; + } + + // Sort and merge overlapping ranges + char_ranges.sort_by_key(|r| r.0); + let mut merged_ranges: Vec<(usize, usize)> = Vec::new(); + for range in char_ranges { + if let Some(last) = merged_ranges.last_mut() { + if range.0 <= last.1 { + // Overlapping or adjacent, merge + last.1 = last.1.max(range.1); + } else { + merged_ranges.push(range); + } + } else { + merged_ranges.push(range); + } + } + + // Helper to check if a character position is in any highlight range + let is_highlighted = |pos: usize| -> bool { + merged_ranges + .iter() + .any(|(start, end)| pos >= *start && pos < *end) + }; + + // Apply highlighting to all ranges in one pass + let mut result_spans = Vec::new(); + let mut current_pos = 0; + + for rich_span in spans { + let (span, maybe_link) = match rich_span { + RichSpan::Text(span) => (span, None), + RichSpan::Link { span, info } => (span, Some(info)), + }; + + let text = span.content.to_string(); + let chars: Vec = text.chars().collect(); + let char_count = chars.len(); + let span_start = current_pos; + let span_end = current_pos + char_count; + + // Check if this span overlaps with any highlight range + let has_highlight = merged_ranges + .iter() + .any(|(start, end)| !(span_end <= *start || span_start >= *end)); + + if !has_highlight { + // No overlap, keep span as is + if let Some(link_info) = maybe_link { + result_spans.push(RichSpan::Link { + span, + info: link_info, + }); + } else { + result_spans.push(RichSpan::Text(span)); + } + } else { + // Span has highlighted portions - split it + let mut i = 0; + while i < char_count { + let char_pos = span_start + i; + let is_current_highlighted = is_highlighted(char_pos); + + // Find the end of this segment (highlighted or not) + let mut j = i + 1; + while j < char_count { + let next_pos = span_start + j; + if is_highlighted(next_pos) != is_current_highlighted { + break; + } + j += 1; + } + + let segment: String = chars[i..j].iter().collect(); + let segment_style = if is_current_highlighted { + span.style.bg(highlight_color) + } else { + span.style + }; + + if is_current_highlighted { + // Highlighted text loses link status + result_spans.push(RichSpan::Text(Span::styled(segment, segment_style))); + } else if let Some(ref link_info) = maybe_link { + result_spans.push(RichSpan::Link { + span: Span::styled(segment, segment_style), + info: link_info.clone(), + }); + } else { + result_spans.push(RichSpan::Text(Span::styled(segment, segment_style))); + } + + i = j; + } + } + + current_pos = span_end; + } + + result_spans + } } #[cfg(test)] diff --git a/src/widget/text_reader/selection.rs b/src/widget/text_reader/selection.rs index e068e9e..a812adb 100644 --- a/src/widget/text_reader/selection.rs +++ b/src/widget/text_reader/selection.rs @@ -96,6 +96,26 @@ impl crate::markdown_text_reader::MarkdownTextReader { self.text_selection.has_selection() } + pub fn convert_visual_to_text_selection(&mut self) -> bool { + if let Some((start_line, start_col, end_line, end_col)) = self.get_visual_selection_range() + { + use crate::widget::text_reader::text_selection::SelectionPoint; + + self.text_selection.start = Some(SelectionPoint { + line: start_line, + column: start_col, + }); + self.text_selection.end = Some(SelectionPoint { + line: end_line, + column: end_col.saturating_sub(1), + }); + self.text_selection.is_selecting = false; + true + } else { + false + } + } + pub fn copy_selection_to_clipboard(&mut self) -> Result<(), String> { if let Some(selected_text) = self .text_selection