diff --git a/Cargo.lock b/Cargo.lock index ba1f829f1..bae032a2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.18" @@ -35,6 +44,19 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi-to-tui" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c4af0bef1b514c9b6a32a773caf604c1390fa7913f4eaa23bfe76f251d6a42" +dependencies = [ + "nom", + "ratatui", + "simdutf8", + "smallvec", + "thiserror", +] + [[package]] name = "anstream" version = "0.6.15" @@ -483,6 +505,8 @@ dependencies = [ name = "linutil_tui" version = "24.9.19" dependencies = [ + "ansi-to-tui", + "anstyle", "chrono", "clap", "crossterm", @@ -492,8 +516,11 @@ dependencies = [ "portable-pty", "rand 0.8.5", "ratatui", + "tree-sitter-bash", + "tree-sitter-highlight", "tui-term", "unicode-width", + "zips", ] [[package]] @@ -542,6 +569,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.0.2" @@ -569,6 +602,16 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -770,6 +813,35 @@ dependencies = [ "bitflags 2.6.0", ] +[[package]] +name = "regex" +version = "1.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" + [[package]] name = "remove_dir_all" version = "0.5.3" @@ -933,6 +1005,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simdutf8" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a" + [[package]] name = "smallvec" version = "1.13.2" @@ -1057,6 +1135,46 @@ dependencies = [ "winnow", ] +[[package]] +name = "tree-sitter" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f4cd3642c47a85052a887d86704f4eac272969f61b686bdd3f772122aabaff" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-bash" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aa5e1c6bd02c0053f3f68edcf5d8866b38a8640584279e30fca88149ce14dda" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-highlight" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395d7a477a4504fd7d5e4d003e0dd41bd5b9c4985d53592a943a8354ec452dae" +dependencies = [ + "lazy_static", + "regex", + "thiserror", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2545046bd1473dac6c626659cc2567c6c0ff302fc8b84a56c4243378276f7f57" + [[package]] name = "tui-term" version = "0.1.13" @@ -1362,3 +1480,14 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zips" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba09194204fda6b1e206faf9096a3c0658ddf7606560f6edce112da3fcc9b111" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/tui/Cargo.toml b/tui/Cargo.toml index b6e57edcd..65dd2488d 100644 --- a/tui/Cargo.toml +++ b/tui/Cargo.toml @@ -30,6 +30,11 @@ tui-term = "0.1.12" unicode-width = "0.1.13" rand = "0.8.5" linutil_core = { path = "../core", version = "24.9.19" } +tree-sitter-highlight = "0.23.0" +tree-sitter-bash = "0.23.1" +anstyle = "1.0.8" +ansi-to-tui = "6.0.0" +zips = "0.1.7" [build-dependencies] chrono = "0.4.33" diff --git a/tui/src/floating_text.rs b/tui/src/floating_text.rs index 878066f79..ac1ef0517 100644 --- a/tui/src/floating_text.rs +++ b/tui/src/floating_text.rs @@ -1,56 +1,153 @@ +use std::io::{Cursor, Read as _, Seek, SeekFrom, Write as _}; + use crate::{ float::FloatContent, hint::{Shortcut, ShortcutList}, }; -use crossterm::event::{KeyCode, KeyEvent}; + use linutil_core::Command; + +use crossterm::event::{KeyCode, KeyEvent}; + use ratatui::{ layout::Rect, style::{Style, Stylize}, - text::Line, widgets::{Block, Borders, Clear, List}, Frame, }; + +use ansi_to_tui::IntoText; + +use tree_sitter_bash as hl_bash; +use tree_sitter_highlight::{self as hl, HighlightEvent}; +use zips::zip_result; + pub enum FloatingTextMode { Preview, Description, } + pub struct FloatingText { - text: Vec, - mode: FloatingTextMode, + src: String, scroll: usize, + n_lines: usize, + mode: FloatingTextMode, +} + +macro_rules! style { + ($r:literal, $g:literal, $b:literal) => {{ + use anstyle::{Color, RgbColor, Style}; + Style::new().fg_color(Some(Color::Rgb(RgbColor($r, $g, $b)))) + }}; +} + +const SYNTAX_HIGHLIGHT_STYLES: [(&'static str, anstyle::Style); 8] = [ + ("function", style!(220, 220, 170)), // yellow + ("string", style!(206, 145, 120)), // brown + ("property", style!(156, 220, 254)), // light blue + ("comment", style!(92, 131, 75)), // green + ("embedded", style!(206, 145, 120)), // blue (string expansions) + ("constant", style!(79, 193, 255)), // dark blue + ("keyword", style!(197, 134, 192)), // magenta + ("number", style!(181, 206, 168)), // light green +]; + +fn get_highlighted_string<'a>(s: &'a str) -> Option { + let mut hl_conf = hl::HighlightConfiguration::new( + hl_bash::LANGUAGE.into(), + "bash", + hl_bash::HIGHLIGHT_QUERY, + "", + "", + ) + .ok()?; + + let matched_tokens = &SYNTAX_HIGHLIGHT_STYLES + .iter() + .map(|hl| hl.0) + .collect::>(); + + hl_conf.configure(matched_tokens); + + let mut hl = hl::Highlighter::new(); + + let mut style_stack = vec![anstyle::Style::new()]; + let src = s.as_bytes(); + + let events = hl.highlight(&hl_conf, src, None, |_| None).ok()?; + + let mut buf = Cursor::new(vec![]); + + for event in events { + match event.unwrap() { + HighlightEvent::HighlightStart(h) => { + style_stack.push(SYNTAX_HIGHLIGHT_STYLES.get(h.0)?.1); + } + + HighlightEvent::HighlightEnd => { + style_stack.pop(); + } + + HighlightEvent::Source { start, end } => { + let style = style_stack.last()?; + zip_result!( + write!(&mut buf, "{}", style), + buf.write_all(&src[start..end]), + write!(&mut buf, "{style:#}"), + )?; + } + } + } + + let mut output = String::new(); + + zip_result!( + buf.seek(SeekFrom::Start(0)), + buf.read_to_string(&mut output), + )?; + + Some(output) } impl FloatingText { - pub fn new(text: Vec, mode: FloatingTextMode) -> Self { + pub fn new(text: String, mode: FloatingTextMode) -> Self { + let mut n_lines = 0; + + text.split("\n").for_each(|_| n_lines += 1); + Self { - text, + src: text, scroll: 0, + n_lines, mode, } } pub fn from_command(command: &Command, mode: FloatingTextMode) -> Option { - let lines = match command { + let src = match command { Command::Raw(cmd) => { - // Reconstruct the line breaks and file formatting after the - // 'include_str!()' call in the node - cmd.lines().map(|line| line.to_string()).collect() + // just apply highlights directly + get_highlighted_string(cmd) } + Command::LocalFile(file_path) => { + // have to read from tmp dir to get cmd src let file_contents = std::fs::read_to_string(file_path) .map_err(|_| format!("File not found: {:?}", file_path)) .unwrap(); - file_contents.lines().map(|line| line.to_string()).collect() + + get_highlighted_string(&file_contents) } + // If command is a folder, we don't display a preview - Command::None => return None, + Command::None => None, }; - Some(Self::new(lines, mode)) + + Some(Self::new(src?, mode)) } fn scroll_down(&mut self) { - if self.scroll + 1 < self.text.len() { + if self.scroll + 1 < self.n_lines { self.scroll += 1; } } @@ -82,25 +179,12 @@ impl FloatContent for FloatingText { // Calculate the inner area to ensure text is not drawn over the border let inner_area = block.inner(area); - - // Create the list of lines to be displayed - let lines: Vec = self - .text - .iter() + let lines = self + .src + .lines() .skip(self.scroll) - .flat_map(|line| { - if line.is_empty() { - return vec![String::new()]; - } - line.chars() - .collect::>() - .chunks(inner_area.width as usize) - .map(|chunk| chunk.iter().collect()) - .collect::>() - }) - .take(inner_area.height as usize) - .map(Line::from) - .collect(); + .map(|l| l.into_text().unwrap()) + .collect::>(); // Create list widget let list = List::new(lines) diff --git a/tui/src/state.rs b/tui/src/state.rs index 2a8297470..1221a517f 100644 --- a/tui/src/state.rs +++ b/tui/src/state.rs @@ -494,12 +494,7 @@ impl AppState { } fn enable_description(&mut self) { if let Some(command_description) = self.get_selected_description() { - let description_content: Vec = vec![] - .into_iter() - .chain(command_description.lines().map(|line| line.to_string())) // New line when \n is given in toml - .collect(); - - let description = FloatingText::new(description_content, FloatingTextMode::Description); + let description = FloatingText::new(command_description, FloatingTextMode::Description); self.spawn_float(description, 80, 80); } }