From 76a4ffc70eb728dde8cf805da7b4809440773e58 Mon Sep 17 00:00:00 2001 From: cartercanedy <99052281+cartercanedy@users.noreply.github.com> Date: Sun, 22 Sep 2024 08:56:30 -0700 Subject: [PATCH] feat: implement syntax highlighting for preview text (#568) * implement syntax highlighting for preview text * remove errant lifetimes Co-authored-by: Adam Perkowski * remove errant lifetimes Co-authored-by: Adam Perkowski * update Cargo.lock * only iterate over visible lines * break lines up on init * implement side scrolling * Remove unnecessary #![soft_unstable] This was accidentally left in from benching --------- Co-authored-by: Adam Perkowski --- Cargo.lock | 129 +++++++++++++++++++++ tui/Cargo.toml | 5 + tui/src/floating_text.rs | 240 +++++++++++++++++++++++++++++++++------ tui/src/state.rs | 7 +- 4 files changed, 338 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba1f829f..bae032a2 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 681d645d..ba269522 100644 --- a/tui/Cargo.toml +++ b/tui/Cargo.toml @@ -25,6 +25,11 @@ tui-term = "0.1.12" unicode-width = "0.1.13" rand = { version = "0.8.5", optional = true } 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 878066f7..f4fc3859 100644 --- a/tui/src/floating_text.rs +++ b/tui/src/floating_text.rs @@ -1,9 +1,18 @@ +use std::{ + borrow::Cow, + collections::VecDeque, + 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}, @@ -11,53 +20,187 @@ use ratatui::{ 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, + pub src: Vec, + max_line_width: usize, + v_scroll: usize, + h_scroll: usize, mode: FloatingTextMode, - scroll: usize, +} + +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: [(&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(s: &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) +} + +macro_rules! max_width { + ($($lines:tt)+) => {{ + $($lines)+.iter().fold(0, |accum, val| accum.max(val.len())) + }} +} + +#[inline] +fn get_lines(s: &str) -> Vec<&str> { + s.lines().collect::>() +} + +#[inline] +fn get_lines_owned(s: &str) -> Vec { + get_lines(s).iter().map(|s| s.to_string()).collect() } impl FloatingText { - pub fn new(text: Vec, mode: FloatingTextMode) -> Self { + pub fn new(text: String, mode: FloatingTextMode) -> Self { + let src = get_lines(&text) + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let max_line_width = max_width!(src); + Self { - text, - scroll: 0, + src, mode, + max_line_width, + v_scroll: 0, + h_scroll: 0, } } pub fn from_command(command: &Command, mode: FloatingTextMode) -> Option { - let lines = match command { + let (max_line_width, 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 + (max_width!(get_lines(cmd)), Some(cmd.clone())) } + Command::LocalFile(file_path) => { - let file_contents = std::fs::read_to_string(file_path) + // have to read from tmp dir to get cmd src + let raw = 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() + + (max_width!(get_lines(&raw)), Some(raw)) } + // If command is a folder, we don't display a preview - Command::None => return None, + Command::None => (0usize, None), }; - Some(Self::new(lines, mode)) + + let src = get_lines_owned(&get_highlighted_string(&src?)?); + + Some(Self { + src, + mode, + max_line_width, + h_scroll: 0, + v_scroll: 0, + }) } fn scroll_down(&mut self) { - if self.scroll + 1 < self.text.len() { - self.scroll += 1; + if self.v_scroll + 1 < self.src.len() { + self.v_scroll += 1; } } fn scroll_up(&mut self) { - if self.scroll > 0 { - self.scroll -= 1; + if self.v_scroll > 0 { + self.v_scroll -= 1; + } + } + + fn scroll_left(&mut self) { + if self.h_scroll > 0 { + self.h_scroll -= 1; + } + } + + fn scroll_right(&mut self) { + if self.h_scroll + 1 < self.max_line_width { + self.h_scroll += 1; } } } @@ -82,25 +225,43 @@ 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 + let Rect { height, .. } = inner_area; + let lines = self + .src .iter() - .skip(self.scroll) - .flat_map(|line| { - if line.is_empty() { - return vec![String::new()]; + .skip(self.v_scroll) + .take(height as usize) + .flat_map(|l| l.into_text().unwrap()) + .map(|line| { + let mut skipped = 0; + let mut spans = line + .into_iter() + .skip_while(|span| { + let skip = (skipped + span.content.len()) <= self.h_scroll; + if skip { + skipped += span.content.len(); + true + } else { + false + } + }) + .collect::>(); + + if spans.is_empty() { + Line::raw(Cow::Owned(String::new())) + } else { + if skipped < self.h_scroll { + let to_split = spans.pop_front().unwrap(); + let new_content = to_split.content.clone().into_owned() + [self.h_scroll - skipped..] + .to_owned(); + spans.push_front(to_split.content(Cow::Owned(new_content))); + } + + Line::from(Vec::from(spans)) } - 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(); + .collect::>(); // Create list widget let list = List::new(lines) @@ -115,9 +276,12 @@ impl FloatContent for FloatingText { } fn handle_key_event(&mut self, key: &KeyEvent) -> bool { + use KeyCode::*; match key.code { - KeyCode::Down | KeyCode::Char('j') => self.scroll_down(), - KeyCode::Up | KeyCode::Char('k') => self.scroll_up(), + Down | Char('j') => self.scroll_down(), + Up | Char('k') => self.scroll_up(), + Left | Char('h') => self.scroll_left(), + Right | Char('l') => self.scroll_right(), _ => {} } false @@ -133,7 +297,9 @@ impl FloatContent for FloatingText { hints: vec![ Shortcut::new(vec!["j", "Down"], "Scroll down"), Shortcut::new(vec!["k", "Up"], "Scroll up"), - Shortcut::new(vec!["Enter", "q"], "Close window"), + Shortcut::new(vec!["h", "Left"], "Scroll left"), + Shortcut::new(vec!["l", "Right"], "Scroll right"), + Shortcut::new(vec!["Enter", "p", "d"], "Close window"), ], } } diff --git a/tui/src/state.rs b/tui/src/state.rs index dfc6787a..26f041e5 100644 --- a/tui/src/state.rs +++ b/tui/src/state.rs @@ -495,12 +495,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); } }