diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-03-12 19:04:12 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-03-12 19:04:12 +0800 |
| commit | 72c57380883a1c1cc796dea6d35048ab5bed5f53 (patch) | |
| tree | 936e04d2ec0f5bae54667beac6bf069208900a80 /src/systems/helpdoc/helpdoc_viewer.rs | |
| parent | 9d812580557cdc343378816cd65678b8aa75d944 (diff) | |
Add helpdoc system with interactive viewer
Diffstat (limited to 'src/systems/helpdoc/helpdoc_viewer.rs')
| -rw-r--r-- | src/systems/helpdoc/helpdoc_viewer.rs | 636 |
1 files changed, 636 insertions, 0 deletions
diff --git a/src/systems/helpdoc/helpdoc_viewer.rs b/src/systems/helpdoc/helpdoc_viewer.rs new file mode 100644 index 0000000..1968775 --- /dev/null +++ b/src/systems/helpdoc/helpdoc_viewer.rs @@ -0,0 +1,636 @@ +use crate::systems::helpdoc::{get_helpdoc, get_helpdoc_list}; +use cli_utils::{display::markdown::Markdown, env::locales::current_locales}; +use crossterm::{ + cursor::{Hide, MoveTo, Show}, + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, + execute, + style::{Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor}, + terminal::{ + Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, + enable_raw_mode, + }, +}; +use rust_i18n::t; +use std::{ + collections::{BTreeMap, HashMap}, + io::{Write, stdout}, +}; + +struct HelpdocViewer { + /// Current language + lang: String, + + /// Document tree structure + doc_tree: DocTree, + + /// Currently selected document path + current_doc: String, + + /// Scroll position history + scroll_history: HashMap<String, usize>, + + /// Current focus area + focus: FocusArea, + + /// Currently selected node index in tree view + tree_selection_index: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum FocusArea { + Tree, + Content, +} + +#[derive(Debug, Clone)] +struct DocTreeNode { + /// Node name + name: String, + + /// Full document path + path: String, + + /// Child nodes + children: BTreeMap<String, DocTreeNode>, + + /// Whether it is a document file + is_document: bool, +} + +#[derive(Debug, Clone)] +struct DocTree { + /// Root node + root: DocTreeNode, + + /// Flattened document list + flat_docs: Vec<String>, +} + +impl HelpdocViewer { + fn new(default_doc: &str, lang: &str) -> Self { + // Build the document tree + let doc_tree = Self::build_doc_tree(); + + // Validate if the default document exists + let current_doc = if doc_tree.contains_doc(default_doc) { + default_doc.to_string() + } else { + // If the default document does not exist, use the first document + doc_tree.flat_docs.first().cloned().unwrap_or_default() + }; + + // Calculate the initial tree selection index + let tree_selection_index = doc_tree + .flat_docs + .iter() + .position(|doc| *doc == current_doc) + .unwrap_or(0); + + Self { + lang: lang.to_string(), + doc_tree, + current_doc, + scroll_history: HashMap::new(), + focus: FocusArea::Content, + tree_selection_index, + } + } + + /// Build document tree + fn build_doc_tree() -> DocTree { + // Get all document list + let doc_list = get_helpdoc_list(); + + // Create root node + let mut root = DocTreeNode { + name: "helpdoc".to_string(), + path: "".to_string(), + children: BTreeMap::new(), + is_document: false, + }; + + // Build tree structure for each document path + for doc_path in doc_list { + Self::add_doc_to_tree(&mut root, doc_path); + } + + // Build flattened document list + let flat_docs = Self::flatten_doc_tree(&root); + + DocTree { root, flat_docs } + } + + /// Add document to tree + fn add_doc_to_tree(root: &mut DocTreeNode, doc_path: &str) { + let parts: Vec<&str> = doc_path.split('/').collect(); + + // Use recursive helper function to avoid borrowing issues + Self::add_doc_to_tree_recursive(root, &parts, 0); + } + + /// Recursively add document to tree + fn add_doc_to_tree_recursive(node: &mut DocTreeNode, parts: &[&str], depth: usize) { + if depth >= parts.len() { + return; + } + + let part = parts[depth]; + let is_document = depth == parts.len() - 1; + let current_path = parts[0..=depth].join("/"); + + // Check if node already exists, create if not + if !node.children.contains_key(part) { + let new_node = DocTreeNode { + name: part.to_string(), + path: current_path.clone(), + children: BTreeMap::new(), + is_document, + }; + node.children.insert(part.to_string(), new_node); + } + + // Get mutable reference to child node + if let Some(child) = node.children.get_mut(part) { + // If this is a document node, ensure it's marked as document + if is_document { + child.is_document = true; + } + // Recursively process next part + Self::add_doc_to_tree_recursive(child, parts, depth + 1); + } + } + + /// Flatten document tree + fn flatten_doc_tree(node: &DocTreeNode) -> Vec<String> { + let mut result = Vec::new(); + + if node.is_document && !node.path.is_empty() { + result.push(node.path.clone()); + } + + // Traverse child nodes in alphabetical order + for child in node.children.values() { + result.extend(Self::flatten_doc_tree(child)); + } + + result + } + + /// Get current document content + fn current_doc_content(&self) -> String { + let content = get_helpdoc(&self.current_doc, &self.lang); + if content.is_empty() { + format!("Document '{}.{}' not found", self.current_doc, self.lang) + } else { + content.to_string() + } + } + + /// Get formatted document content + fn formatted_doc_content(&self) -> Vec<String> { + let content = self.current_doc_content(); + let formatted = content.markdown(); + formatted.lines().map(|s| s.to_string()).collect() + } + + /// Run viewer + async fn run(&mut self) -> std::io::Result<()> { + enable_raw_mode()?; + execute!(stdout(), EnterAlternateScreen, Hide)?; + + let mut should_exit = false; + + while !should_exit { + self.draw()?; + + if event::poll(std::time::Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + should_exit = self.handle_key(key); + } + } + } + + execute!(stdout(), Show, LeaveAlternateScreen)?; + disable_raw_mode()?; + + Ok(()) + } + + /// Draw interface + fn draw(&self) -> std::io::Result<()> { + let (width, height) = crossterm::terminal::size()?; + + if height <= 3 { + let content = self.current_doc_content(); + println!("{}", content); + return Ok(()); + } + + execute!(stdout(), Clear(ClearType::All))?; + + let tree_width = 25; + let content_width = width - tree_width - 1; + + // Draw title + execute!( + stdout(), + MoveTo(0, 0), + SetForegroundColor(Color::Cyan), + Print(format!("JVCS Help Docs - {}", self.lang)), + ResetColor + )?; + + // Draw separator line + for y in 1..height { + execute!(stdout(), MoveTo(tree_width, y), Print("│"))?; + } + + // Draw tree view + self.draw_tree(0, 2, tree_width, height - 4)?; + + // Draw content area + self.draw_content(tree_width + 1, 2, content_width, height - 4)?; + + // Draw status bar + self.draw_status_bar(height - 1, width)?; + + stdout().flush()?; + Ok(()) + } + + /// Draw tree view + fn draw_tree(&self, x: u16, y: u16, width: u16, height: u16) -> std::io::Result<()> { + // Draw tree view title + execute!( + stdout(), + MoveTo(x, y - 1), + SetForegroundColor(Color::Yellow), + Print("Documents"), + ResetColor + )?; + + // Recursively draw tree structure + let mut line_counter = 0; + let max_lines = height as usize; + + // Skip root node, start drawing from children + for child in self.doc_tree.root.children.values() { + line_counter = self.draw_tree_node(child, x, y, width, 0, line_counter, max_lines)?; + if line_counter >= max_lines { + break; + } + } + + Ok(()) + } + + /// Recursively draw tree node + fn draw_tree_node( + &self, + node: &DocTreeNode, + x: u16, + start_y: u16, + width: u16, + depth: usize, + mut line_counter: usize, + max_lines: usize, + ) -> std::io::Result<usize> { + if line_counter >= max_lines { + return Ok(line_counter); + } + + let line_y = start_y + line_counter as u16; + line_counter += 1; + + // Build indentation and suffix + let indent = " ".repeat(depth); + let suffix = if node.children.is_empty() { "" } else { "/" }; + + // If this is the currently selected document, highlight it (white background, black text) + let is_selected = node.path == self.current_doc; + + if is_selected { + // Highlight with white background and black text + execute!( + stdout(), + MoveTo(x, line_y), + SetForegroundColor(Color::Black), + SetBackgroundColor(Color::White), + Print(" ".repeat(width as usize)), + MoveTo(x, line_y), + SetForegroundColor(Color::Black), + )?; + } else { + // Normal display + execute!( + stdout(), + MoveTo(x, line_y), + SetForegroundColor(Color::White), + SetBackgroundColor(Color::Black), + )?; + } + + // Display node name + let display_text = format!("{} {}{}", indent, node.name, suffix); + execute!(stdout(), Print(display_text))?; + execute!(stdout(), ResetColor, SetBackgroundColor(Color::Black))?; + + // Recursively draw child nodes + if !node.children.is_empty() { + for child in node.children.values() { + line_counter = self.draw_tree_node( + child, + x, + start_y, + width, + depth + 1, + line_counter, + max_lines, + )?; + if line_counter >= max_lines { + break; + } + } + } + + Ok(line_counter) + } + + /// Draw content area + fn draw_content(&self, x: u16, y: u16, width: u16, height: u16) -> std::io::Result<()> { + // Draw content area title + execute!( + stdout(), + MoveTo(x, y - 1), + SetForegroundColor(Color::Yellow), + Print(format!("> {}", self.current_doc)), + ResetColor + )?; + + // Get formatted content + let content_lines = self.formatted_doc_content(); + let scroll_pos = self + .scroll_history + .get(&self.current_doc) + .copied() + .unwrap_or(0); + let start_line = scroll_pos.min(content_lines.len().saturating_sub(1)); + let end_line = (start_line + height as usize).min(content_lines.len()); + + for (i, line) in content_lines + .iter() + .enumerate() + .take(end_line) + .skip(start_line) + { + let line_y = y + i as u16 - start_line as u16; + let display_line = if line.len() > width as usize { + let mut end = width as usize; + while end > 0 && !line.is_char_boundary(end) { + end -= 1; + } + if end == 0 { + // If the first character is multi-byte, display at least one character + let mut end = 1; + while end < line.len() && !line.is_char_boundary(end) { + end += 1; + } + &line[..end] + } else { + &line[..end] + } + } else { + line + }; + + execute!(stdout(), MoveTo(x, line_y), Print(display_line))?; + } + + // Display scroll position indicator + if content_lines.len() > height as usize && content_lines.len() > 0 { + let scroll_percent = if content_lines.len() > 0 { + (scroll_pos * 100) / content_lines.len() + } else { + 0 + }; + execute!( + stdout(), + MoveTo(x + width - 5, y - 1), + SetForegroundColor(Color::DarkGrey), + Print(format!("{:3}%", scroll_percent)), + ResetColor + )?; + } + + Ok(()) + } + + /// Draw status bar + fn draw_status_bar(&self, y: u16, width: u16) -> std::io::Result<()> { + // Draw status bar background + execute!( + stdout(), + MoveTo(0, y), + SetForegroundColor(Color::Black), + Print(" ".repeat(width as usize)), + MoveTo(0, y), + SetForegroundColor(Color::White), + )?; + + let status_text = match self.focus { + FocusArea::Tree => t!("helpdoc_viewer.tree_area_hint").to_string().markdown(), + FocusArea::Content => t!("helpdoc_viewer.content_area_hint") + .to_string() + .markdown(), + } + .to_string(); + + let truncated_text = if status_text.len() > width as usize { + &status_text[..width as usize] + } else { + &status_text + }; + execute!(stdout(), Print(truncated_text))?; + execute!(stdout(), ResetColor)?; + + Ok(()) + } + + /// Handle key input + fn handle_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return true, + KeyCode::Char(' ') => self.toggle_focus(), + KeyCode::Left => self.move_left(), + KeyCode::Right => self.move_right(), + KeyCode::Up => self.move_up(), + KeyCode::Down => self.move_down(), + KeyCode::Char('g') if key.modifiers == KeyModifiers::NONE => self.go_to_top(), + KeyCode::Char('G') if key.modifiers == KeyModifiers::SHIFT => self.go_to_bottom(), + KeyCode::Enter => self.select_item(), + _ => {} + } + false + } + + /// Toggle focus area + fn toggle_focus(&mut self) { + self.focus = match self.focus { + FocusArea::Tree => FocusArea::Content, + FocusArea::Content => FocusArea::Tree, + }; + } + + /// Move left + fn move_left(&mut self) { + if self.focus == FocusArea::Content { + self.focus = FocusArea::Tree; + } + } + + /// Move right + fn move_right(&mut self) { + if self.focus == FocusArea::Tree { + self.focus = FocusArea::Content; + } + } + + /// Move up + fn move_up(&mut self) { + match self.focus { + FocusArea::Tree => self.previous_doc(), + FocusArea::Content => self.scroll_up(), + } + } + + /// Move down + fn move_down(&mut self) { + match self.focus { + FocusArea::Tree => self.next_doc(), + FocusArea::Content => self.scroll_down(), + } + } + + /// Scroll to top + fn go_to_top(&mut self) { + match self.focus { + FocusArea::Content => { + self.scroll_history.insert(self.current_doc.clone(), 0); + } + FocusArea::Tree => { + // Select first document + self.tree_selection_index = 0; + if let Some(first_doc) = self.doc_tree.flat_docs.first() { + self.current_doc = first_doc.clone(); + } + } + } + } + + /// Scroll to bottom + fn go_to_bottom(&mut self) { + match self.focus { + FocusArea::Content => { + let content_lines = self.formatted_doc_content(); + if content_lines.len() > 10 { + self.scroll_history + .insert(self.current_doc.clone(), content_lines.len() - 10); + } + } + FocusArea::Tree => { + // Select last document + self.tree_selection_index = self.doc_tree.flat_docs.len().saturating_sub(1); + if let Some(last_doc) = self.doc_tree.flat_docs.last() { + self.current_doc = last_doc.clone(); + } + } + } + } + + /// Select current item + fn select_item(&mut self) { + match self.focus { + FocusArea::Tree => { + // Update current document to the one selected in tree view + if let Some(doc) = self.doc_tree.flat_docs.get(self.tree_selection_index) { + self.current_doc = doc.clone(); + } + // Switch focus to content area + self.focus = FocusArea::Content; + } + _ => {} + } + } + + /// Previous document + fn previous_doc(&mut self) { + if self.tree_selection_index > 0 { + self.tree_selection_index -= 1; + if let Some(doc) = self.doc_tree.flat_docs.get(self.tree_selection_index) { + self.current_doc = doc.clone(); + // Reset scroll position + self.scroll_history.remove(&self.current_doc); + } + } + } + + /// Next document + fn next_doc(&mut self) { + if self.tree_selection_index + 1 < self.doc_tree.flat_docs.len() { + self.tree_selection_index += 1; + if let Some(doc) = self.doc_tree.flat_docs.get(self.tree_selection_index) { + self.current_doc = doc.clone(); + // Reset scroll position + self.scroll_history.remove(&self.current_doc); + } + } + } + + /// Scroll up + fn scroll_up(&mut self) { + let current_scroll = self + .scroll_history + .get(&self.current_doc) + .copied() + .unwrap_or(0); + if current_scroll > 0 { + self.scroll_history + .insert(self.current_doc.clone(), current_scroll - 1); + } + } + + /// Scroll down + fn scroll_down(&mut self) { + let content_lines = self.formatted_doc_content(); + let current_scroll = self + .scroll_history + .get(&self.current_doc) + .copied() + .unwrap_or(0); + if current_scroll + 1 < content_lines.len() { + self.scroll_history + .insert(self.current_doc.clone(), current_scroll + 1); + } + } +} + +impl DocTree { + /// Check if document exists + fn contains_doc(&self, doc_path: &str) -> bool { + self.flat_docs.contains(&doc_path.to_string()) + } +} + +/// Display help document viewer +pub async fn display_with_lang(default_focus_doc: &str, lang: &str) { + let mut viewer = HelpdocViewer::new(default_focus_doc, lang); + + if let Err(e) = viewer.run().await { + eprintln!("Error running helpdoc viewer: {}", e); + } +} + +/// Display help document viewer +pub async fn display(default_focus_doc: &str) { + display_with_lang(default_focus_doc, current_locales().as_str()).await; +} |
