diff --git a/crates/owlen-tui/Cargo.toml b/crates/owlen-tui/Cargo.toml index da29156..17332e8 100644 --- a/crates/owlen-tui/Cargo.toml +++ b/crates/owlen-tui/Cargo.toml @@ -27,6 +27,8 @@ tree-sitter = "0.20" tree-sitter-rust = "0.20" dirs = { workspace = true } toml = { workspace = true } +syntect = "5.3" +once_cell = "1.19" # Async runtime tokio = { workspace = true } diff --git a/crates/owlen-tui/src/highlight.rs b/crates/owlen-tui/src/highlight.rs new file mode 100644 index 0000000..c642e0d --- /dev/null +++ b/crates/owlen-tui/src/highlight.rs @@ -0,0 +1,102 @@ +use once_cell::sync::Lazy; +use ratatui::style::{Color as TuiColor, Modifier, Style as TuiStyle}; +use std::path::{Path, PathBuf}; +use syntect::easy::HighlightLines; +use syntect::highlighting::{FontStyle, Style as SynStyle, Theme, ThemeSet}; +use syntect::parsing::{SyntaxReference, SyntaxSet}; + +static SYNTAX_SET: Lazy = Lazy::new(SyntaxSet::load_defaults_newlines); +static THEME_SET: Lazy = Lazy::new(ThemeSet::load_defaults); +static THEME: Lazy = Lazy::new(|| { + THEME_SET + .themes + .get("base16-ocean.dark") + .cloned() + .or_else(|| THEME_SET.themes.values().next().cloned()) + .unwrap_or_default() +}); + +fn select_syntax(path_hint: Option<&Path>) -> &'static SyntaxReference { + if let Some(path) = path_hint { + if let Ok(Some(syntax)) = SYNTAX_SET.find_syntax_for_file(path) { + return syntax; + } + if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) + && let Some(syntax) = SYNTAX_SET.find_syntax_by_extension(ext) + { + return syntax; + } + if let Some(name) = path.file_name().and_then(|name| name.to_str()) + && let Some(syntax) = SYNTAX_SET.find_syntax_by_token(name) + { + return syntax; + } + } + + SYNTAX_SET.find_syntax_plain_text() +} + +fn path_hint_from_components(absolute: Option<&Path>, display: Option<&str>) -> Option { + if let Some(abs) = absolute { + return Some(abs.to_path_buf()); + } + display.map(PathBuf::from) +} + +fn style_from_syntect(style: SynStyle) -> TuiStyle { + let mut tui_style = TuiStyle::default().fg(TuiColor::Rgb( + style.foreground.r, + style.foreground.g, + style.foreground.b, + )); + + let mut modifiers = Modifier::empty(); + if style.font_style.contains(FontStyle::BOLD) { + modifiers |= Modifier::BOLD; + } + if style.font_style.contains(FontStyle::ITALIC) { + modifiers |= Modifier::ITALIC; + } + if style.font_style.contains(FontStyle::UNDERLINE) { + modifiers |= Modifier::UNDERLINED; + } + + if !modifiers.is_empty() { + tui_style = tui_style.add_modifier(modifiers); + } + + tui_style +} + +pub fn build_highlighter( + absolute: Option<&Path>, + display: Option<&str>, +) -> HighlightLines<'static> { + let hint_path = path_hint_from_components(absolute, display); + let syntax = select_syntax(hint_path.as_deref()); + HighlightLines::new(syntax, &THEME) +} + +pub fn highlight_line( + highlighter: &mut HighlightLines<'static>, + line: &str, +) -> Vec<(TuiStyle, String)> { + let mut segments = Vec::new(); + match highlighter.highlight_line(line, &SYNTAX_SET) { + Ok(result) => { + for (style, piece) in result { + let tui_style = style_from_syntect(style); + segments.push((tui_style, piece.to_string())); + } + } + Err(_) => { + segments.push((TuiStyle::default(), line.to_string())); + } + } + + if segments.is_empty() { + segments.push((TuiStyle::default(), String::new())); + } + + segments +} diff --git a/crates/owlen-tui/src/lib.rs b/crates/owlen-tui/src/lib.rs index 80be015..d770d61 100644 --- a/crates/owlen-tui/src/lib.rs +++ b/crates/owlen-tui/src/lib.rs @@ -17,6 +17,7 @@ pub mod code_app; pub mod commands; pub mod config; pub mod events; +pub mod highlight; pub mod model_info_panel; pub mod slash; pub mod state; diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index ca8ba8a..7337b2e 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -12,6 +12,7 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MessageRenderContext, ModelSelectorItemKind}; +use crate::highlight; use crate::state::{ CodePane, EditorTab, FileFilterMode, FileNode, LayoutNode, PaletteGroup, PaneId, RepoSearchRowKind, SplitAxis, VisibleFileEntry, @@ -2338,6 +2339,8 @@ fn render_code_pane( .add_modifier(Modifier::ITALIC), ))); } else { + let mut highlighter = + highlight::build_highlighter(pane.absolute_path(), pane.display_path()); for (idx, content) in pane.lines.iter().enumerate() { let number = format!("{:>4} ", idx + 1); let mut spans = vec![Span::styled( @@ -2347,12 +2350,22 @@ fn render_code_pane( .add_modifier(Modifier::DIM), )]; - let mut line_style = Style::default().fg(theme.text); - if !is_active { - line_style = line_style.add_modifier(Modifier::DIM); + let segments = highlight::highlight_line(&mut highlighter, content); + if segments.is_empty() { + let mut line_style = Style::default().fg(theme.text); + if !is_active { + line_style = line_style.add_modifier(Modifier::DIM); + } + spans.push(Span::styled(content.clone(), line_style)); + } else { + for (segment_style, text) in segments { + let mut style = segment_style; + if !is_active { + style = style.add_modifier(Modifier::DIM); + } + spans.push(Span::styled(text, style)); + } } - - spans.push(Span::styled(content.clone(), line_style)); lines.push(Line::from(spans)); } }