From be9c4c718298b912ad99a892ad0bcbf335ed7d0c Mon Sep 17 00:00:00 2001
From: jooaf
Date: Sun, 13 Apr 2025 19:06:14 -0500
Subject: [PATCH 1/3] Feature: Adding code block copy selection
---
Cargo.lock | 2 +-
src/code_block_popup.rs | 141 ++++++++++++++++++++++++++++++++++++++++
src/lib.rs | 2 +
src/ui.rs | 86 ++++++++++++++++++++++++
src/ui_handler.rs | 123 ++++++++++++++++++++++++++++++++++-
src/utils.rs | 98 ++++++++++++++++++++++++++++
6 files changed, 448 insertions(+), 4 deletions(-)
create mode 100644 src/code_block_popup.rs
diff --git a/Cargo.lock b/Cargo.lock
index ef77289..ee0c9fe 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1436,7 +1436,7 @@ dependencies = [
[[package]]
name = "thoth-cli"
-version = "0.1.80"
+version = "0.1.81"
dependencies = [
"anyhow",
"arboard",
diff --git a/src/code_block_popup.rs b/src/code_block_popup.rs
new file mode 100644
index 0000000..aca838b
--- /dev/null
+++ b/src/code_block_popup.rs
@@ -0,0 +1,141 @@
+#[derive(Clone)]
+pub struct CodeBlockPopup {
+ pub code_blocks: Vec,
+ pub filtered_blocks: Vec,
+ pub selected_index: usize,
+ pub visible: bool,
+ pub scroll_offset: usize,
+}
+
+#[derive(Clone)]
+pub struct CodeBlock {
+ pub content: String,
+ pub language: String,
+ pub start_line: usize,
+ pub end_line: usize,
+}
+
+impl CodeBlock {
+ pub fn new(content: String, language: String, start_line: usize, end_line: usize) -> Self {
+ Self {
+ content,
+ language,
+ start_line,
+ end_line,
+ }
+ }
+}
+
+impl Default for CodeBlockPopup {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl CodeBlockPopup {
+ pub fn new() -> Self {
+ CodeBlockPopup {
+ code_blocks: Vec::new(),
+ filtered_blocks: Vec::new(),
+ selected_index: 0,
+ visible: false,
+ scroll_offset: 0,
+ }
+ }
+
+ pub fn set_code_blocks(&mut self, code_blocks: Vec) {
+ self.code_blocks = code_blocks;
+ self.filtered_blocks = self.code_blocks.clone();
+ self.selected_index = 0;
+ self.scroll_offset = 0;
+ }
+
+ pub fn move_selection_up(&mut self, visible_items: usize) {
+ if self.filtered_blocks.is_empty() {
+ return;
+ }
+
+ if self.selected_index > 0 {
+ self.selected_index -= 1;
+ } else {
+ self.selected_index = self.filtered_blocks.len() - 1;
+ }
+
+ if self.selected_index <= self.scroll_offset {
+ self.scroll_offset = self.selected_index;
+ }
+ if self.selected_index == self.filtered_blocks.len() - 1 {
+ self.scroll_offset = self.filtered_blocks.len().saturating_sub(visible_items);
+ }
+ }
+
+ pub fn move_selection_down(&mut self, visible_items: usize) {
+ if self.filtered_blocks.is_empty() {
+ return;
+ }
+
+ if self.selected_index < self.filtered_blocks.len() - 1 {
+ self.selected_index += 1;
+ } else {
+ self.selected_index = 0;
+ self.scroll_offset = 0;
+ }
+
+ let max_scroll = self.filtered_blocks.len().saturating_sub(visible_items);
+ if self.selected_index >= self.scroll_offset + visible_items {
+ self.scroll_offset = (self.selected_index + 1).saturating_sub(visible_items);
+ if self.scroll_offset > max_scroll {
+ self.scroll_offset = max_scroll;
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_new_code_block_popup() {
+ let popup = CodeBlockPopup::new();
+ assert!(popup.code_blocks.is_empty());
+ assert_eq!(popup.selected_index, 0);
+ assert!(!popup.visible);
+ }
+
+ #[test]
+ fn test_set_code_blocks() {
+ let mut popup = CodeBlockPopup::new();
+ let blocks = vec![
+ CodeBlock::new("fn main() {}".to_string(), "rust".to_string(), 0, 2),
+ CodeBlock::new("def hello(): pass".to_string(), "python".to_string(), 3, 5),
+ ];
+ popup.set_code_blocks(blocks);
+ assert_eq!(popup.code_blocks.len(), 2);
+ assert_eq!(popup.code_blocks[0].language, "rust");
+ assert_eq!(popup.code_blocks[1].language, "python");
+ }
+
+ #[test]
+ fn test_move_selection() {
+ let mut popup = CodeBlockPopup::new();
+ let blocks = vec![
+ CodeBlock::new("Block 1".to_string(), "rust".to_string(), 0, 2),
+ CodeBlock::new("Block 2".to_string(), "python".to_string(), 3, 5),
+ CodeBlock::new("Block 3".to_string(), "js".to_string(), 6, 8),
+ ];
+ popup.set_code_blocks(blocks);
+
+ popup.move_selection_down(2);
+ assert_eq!(popup.selected_index, 1);
+
+ popup.move_selection_up(2);
+ assert_eq!(popup.selected_index, 0);
+
+ popup.move_selection_up(2);
+ assert_eq!(popup.selected_index, 2);
+
+ popup.move_selection_down(2);
+ assert_eq!(popup.selected_index, 0);
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 2f269bf..8e75d83 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,5 +1,6 @@
pub mod cli;
pub mod clipboard;
+pub mod code_block_popup;
pub mod config;
pub mod formatter;
pub mod markdown_renderer;
@@ -13,6 +14,7 @@ pub mod utils;
pub use clipboard::ClipboardTrait;
pub use clipboard::EditorClipboard;
+pub use code_block_popup::CodeBlockPopup;
pub use config::{ThemeMode, ThothConfig};
use dirs::home_dir;
pub use formatter::{format_json, format_markdown};
diff --git a/src/ui.rs b/src/ui.rs
index 8ed8928..426625f 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -1,4 +1,7 @@
+use crate::code_block_popup::CodeBlockPopup;
use crate::{ThemeColors, TitlePopup, TitleSelectPopup, BORDER_PADDING_SIZE, DARK_MODE_COLORS};
+use ratatui::style::Color;
+use ratatui::widgets::Wrap;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
@@ -133,6 +136,7 @@ pub fn render_header(f: &mut Frame, area: Rect, is_edit_mode: bool, theme: &Them
"^n:Add",
"^d:Del",
"^y:Copy",
+ "^c:Copy Code",
"^v:Paste",
"Enter:Edit",
"^f:Focus",
@@ -239,6 +243,88 @@ pub fn render_title_popup(f: &mut Frame, popup: &TitlePopup, theme: &ThemeColors
f.render_widget(text, area);
}
+pub fn render_code_block_popup(f: &mut Frame, popup: &CodeBlockPopup, theme: &ThemeColors) {
+ if !popup.visible || popup.filtered_blocks.is_empty() {
+ return;
+ }
+
+ let area = centered_rect(80, 80, f.size());
+ f.render_widget(ratatui::widgets::Clear, area);
+
+ // Split the area into two parts: selection list and code preview
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3), // Title + borders
+ Constraint::Min(1), // Code content area
+ ])
+ .split(area);
+
+ let title_area = chunks[0];
+ let code_area = chunks[1];
+
+ // Create the title block
+ let title_block = Block::default()
+ .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
+ .border_style(Style::default().fg(theme.primary))
+ .title(format!(
+ "Code Block {}/{} [{}]",
+ popup.selected_index + 1,
+ popup.filtered_blocks.len(),
+ if !popup.filtered_blocks.is_empty() {
+ popup.filtered_blocks[popup.selected_index].language.clone()
+ } else {
+ String::new()
+ }
+ ));
+
+ // Render title with navigation instructions
+ let title_text = vec![Line::from(vec![
+ Span::raw(" "),
+ Span::styled("↑/↓", Style::default().fg(theme.accent)),
+ Span::raw(": Navigate "),
+ Span::styled("Enter", Style::default().fg(theme.accent)),
+ Span::raw(": Copy "),
+ Span::styled("Esc", Style::default().fg(theme.accent)),
+ Span::raw(": Cancel"),
+ ])];
+
+ let title_paragraph = Paragraph::new(title_text).block(title_block);
+
+ f.render_widget(title_paragraph, title_area);
+
+ if !popup.filtered_blocks.is_empty() {
+ let selected_block = &popup.filtered_blocks[popup.selected_index];
+
+ let code_content = selected_block.content.clone();
+ let _language = &selected_block.language;
+
+ let lines: Vec = code_content
+ .lines()
+ .enumerate()
+ .map(|(i, line)| {
+ Line::from(vec![
+ Span::styled(
+ format!("{:3} │ ", i + 1),
+ Style::default().fg(Color::DarkGray),
+ ),
+ Span::raw(line),
+ ])
+ })
+ .collect();
+
+ let code_block = Block::default()
+ .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
+ .border_style(Style::default().fg(theme.primary));
+
+ let code_paragraph = Paragraph::new(lines)
+ .block(code_block)
+ .wrap(Wrap { trim: false });
+
+ f.render_widget(code_paragraph, code_area);
+ }
+}
+
pub fn render_title_select_popup(f: &mut Frame, popup: &TitleSelectPopup, theme: &ThemeColors) {
let area = centered_rect(80, 80, f.size());
f.render_widget(ratatui::widgets::Clear, area);
diff --git a/src/ui_handler.rs b/src/ui_handler.rs
index 75283d1..92fa99c 100644
--- a/src/ui_handler.rs
+++ b/src/ui_handler.rs
@@ -1,4 +1,7 @@
-use crate::{get_save_backup_file_path, EditorClipboard, ThemeMode, ThothConfig};
+use crate::{
+ get_save_backup_file_path, utils::extract_code_blocks, CodeBlockPopup, EditorClipboard,
+ ThemeMode, ThothConfig,
+};
use anyhow::{bail, Result};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEventKind, KeyModifiers},
@@ -15,8 +18,8 @@ use tui_textarea::TextArea;
use crate::{
format_json, format_markdown, get_save_file_path, load_textareas, save_textareas,
ui::{
- render_edit_commands_popup, render_header, render_title_popup, render_title_select_popup,
- render_ui_popup, EditCommandsPopup, UiPopup,
+ render_code_block_popup, render_edit_commands_popup, render_header, render_title_popup,
+ render_title_select_popup, render_ui_popup, EditCommandsPopup, UiPopup,
},
ScrollableTextArea, TitlePopup, TitleSelectPopup,
};
@@ -34,6 +37,7 @@ pub struct UIState {
pub help_popup: UiPopup,
pub copy_popup: UiPopup,
pub edit_commands_popup: EditCommandsPopup,
+ pub code_block_popup: CodeBlockPopup,
pub clipboard: Option,
pub last_draw: Instant,
pub config: ThothConfig,
@@ -63,6 +67,7 @@ impl UIState {
copy_popup: UiPopup::new("Block Copied".to_string(), 60, 20),
help_popup: UiPopup::new("Keyboard Shortcuts".to_string(), 60, 80),
edit_commands_popup: EditCommandsPopup::new(),
+ code_block_popup: CodeBlockPopup::new(),
clipboard: EditorClipboard::try_new(),
last_draw: Instant::now(),
config,
@@ -105,6 +110,8 @@ pub fn draw_ui(
render_title_popup(f, &state.title_popup, theme);
} else if state.title_select_popup.visible {
render_title_select_popup(f, &state.title_select_popup, theme);
+ } else if state.code_block_popup.visible {
+ render_code_block_popup(f, &state.code_block_popup, theme);
}
if state.edit_commands_popup.visible {
@@ -129,6 +136,101 @@ pub fn draw_ui(
Ok(())
}
+fn handle_code_block_popup_input(state: &mut UIState, key: event::KeyEvent) -> Result {
+ let visible_items =
+ (state.scrollable_textarea.viewport_height as f32 * 0.8).floor() as usize - 4;
+
+ match key.code {
+ KeyCode::Enter => {
+ if !state.code_block_popup.filtered_blocks.is_empty() {
+ let selected_index = state.code_block_popup.selected_index;
+ let content = state.code_block_popup.filtered_blocks[selected_index]
+ .content
+ .clone();
+ let language = state.code_block_popup.filtered_blocks[selected_index]
+ .language
+ .clone();
+
+ if let Err(e) = copy_code_block_content_to_clipboard(state, &content, &language) {
+ state.error_popup.show(format!("{}", e));
+ }
+
+ state.code_block_popup.visible = false;
+ }
+ }
+ KeyCode::Esc => {
+ state.code_block_popup.visible = false;
+ }
+ KeyCode::Up => {
+ state.code_block_popup.move_selection_up(visible_items);
+ }
+ KeyCode::Down => {
+ state.code_block_popup.move_selection_down(visible_items);
+ }
+ _ => {}
+ }
+ Ok(false)
+}
+
+fn extract_and_show_code_blocks(state: &mut UIState) -> Result<()> {
+ let content = state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
+ .lines()
+ .join("\n");
+
+ let code_blocks = extract_code_blocks(&content);
+
+ if code_blocks.is_empty() {
+ state
+ .error_popup
+ .show("No code blocks found in the current note.".to_string());
+ return Ok(());
+ }
+
+ state.code_block_popup.set_code_blocks(code_blocks);
+ state.code_block_popup.visible = true;
+ Ok(())
+}
+
+fn copy_code_block_content_to_clipboard(
+ state: &mut UIState,
+ content: &str,
+ language: &str,
+) -> Result<()> {
+ match &mut state.clipboard {
+ Some(clip) => {
+ if let Err(e) = clip.set_contents(content.to_string()) {
+ let backup_path = crate::get_clipboard_backup_file_path();
+ std::fs::write(&backup_path, content)?;
+
+ return Err(anyhow::anyhow!(
+ "Clipboard error: {}.\nContent saved to: {}\nPlease use 'thoth read_clipboard' to read the contents from STDOUT.",
+ e.to_string().split('\n').next().unwrap_or("Unknown error"),
+ backup_path.display()
+ ));
+ }
+
+ state.copy_popup.show(format!(
+ "Copied code block [{}] to clipboard",
+ if language.is_empty() {
+ "no language"
+ } else {
+ language
+ }
+ ));
+ }
+ None => {
+ let backup_path = crate::get_clipboard_backup_file_path();
+ std::fs::write(&backup_path, content)?;
+
+ return Err(anyhow::anyhow!(
+ "Clipboard unavailable.\nContent saved to: {}\nPlease use 'thoth read_clipboard' to read the contents from STDOUT.",
+ backup_path.display()
+ ));
+ }
+ }
+ Ok(())
+}
+
pub fn handle_input(
terminal: &mut Terminal>,
state: &mut UIState,
@@ -144,6 +246,8 @@ pub fn handle_input(
handle_title_popup_input(state, key)
} else if state.title_select_popup.visible {
handle_title_select_popup_input(state, key)
+ } else if state.code_block_popup.visible {
+ handle_code_block_popup_input(state, key)
} else {
handle_normal_input(terminal, state, key)
}
@@ -349,6 +453,18 @@ fn handle_normal_input(
};
state.config.set_theme(new_theme.clone())?;
}
+ KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
+ if !state.scrollable_textarea.edit_mode {
+ if let Err(e) = extract_and_show_code_blocks(state) {
+ state
+ .error_popup
+ .show(format!("Error extracting code blocks: {}", e));
+ }
+ } else {
+ state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
+ .input(key);
+ }
+ }
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
match state.scrollable_textarea.copy_focused_textarea_contents() {
Ok(_) => {
@@ -402,6 +518,7 @@ CLIPBOARD:
• ^y: Copy current block
• ^v: Paste from clipboard
• ^b: Copy selection (in edit mode)
+ • ^c: Copy code block from current note
FORMATTING:
• ^j: Format as JSON
diff --git a/src/utils.rs b/src/utils.rs
index 5982a4d..eb7dacc 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -1,9 +1,56 @@
+use crate::code_block_popup::CodeBlock;
use anyhow::Result;
use std::io::{BufRead, Write};
use std::path::PathBuf;
use std::{fs::File, io::BufReader};
use tui_textarea::TextArea;
+pub fn extract_code_blocks(content: &str) -> Vec {
+ let mut code_blocks = Vec::new();
+ let mut in_code_block = false;
+ let mut current_block = String::new();
+ let mut current_language = String::new();
+ let mut start_line = 0;
+
+ for (i, line) in content.lines().enumerate() {
+ if line.trim().starts_with("```") {
+ if in_code_block {
+ // End of code block
+ code_blocks.push(CodeBlock::new(
+ current_block.trim_end().to_string(),
+ current_language.clone(),
+ start_line,
+ i,
+ ));
+ current_block.clear();
+ current_language.clear();
+ in_code_block = false;
+ } else {
+ // Start of code block
+ in_code_block = true;
+ start_line = i;
+
+ let lang_part = line.trim_start_matches('`').trim();
+ current_language = lang_part.to_string();
+ }
+ } else if in_code_block {
+ current_block.push_str(line);
+ current_block.push('\n');
+ }
+ }
+
+ if in_code_block && !current_block.is_empty() {
+ code_blocks.push(CodeBlock::new(
+ current_block.trim_end().to_string(),
+ current_language,
+ start_line,
+ content.lines().count() - 1,
+ ));
+ }
+
+ code_blocks
+}
+
pub fn save_textareas(textareas: &[TextArea], titles: &[String], file_path: PathBuf) -> Result<()> {
let mut file = File::create(file_path)?;
for (textarea, title) in textareas.iter().zip(titles.iter()) {
@@ -71,3 +118,54 @@ pub fn load_textareas(file_path: PathBuf) -> Result<(Vec