Replace ansi and text wrapping code with ratatui

Replaced ansi related code for tree sitter highlight with direct ratatui::text. Cache the processed text in appstate to remove processing of text for every frame render.Create paragraph instead of list so that scroll and wrapping can be done without external crates. Add caps keys for handle_key_event.
This commit is contained in:
Jeevitha Kannan K S 2024-11-11 14:51:37 +05:30
parent 2d1f5dbc80
commit 10352c6254
No known key found for this signature in database
GPG Key ID: 5904C34A2F7CE333
4 changed files with 138 additions and 295 deletions

73
Cargo.lock generated
View File

@ -29,19 +29,6 @@ version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
[[package]]
name = "ansi-to-tui"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67555e1f1ece39d737e28c8a017721287753af3f93225e4a445b29ccb0f5912c"
dependencies = [
"nom",
"ratatui",
"simdutf8",
"smallvec",
"thiserror",
]
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "0.6.15" version = "0.6.15"
@ -441,8 +428,6 @@ dependencies = [
name = "linutil_tui" name = "linutil_tui"
version = "24.9.28" version = "24.9.28"
dependencies = [ dependencies = [
"ansi-to-tui",
"anstyle",
"clap", "clap",
"linutil_core", "linutil_core",
"oneshot", "oneshot",
@ -450,13 +435,11 @@ dependencies = [
"rand", "rand",
"ratatui", "ratatui",
"temp-dir", "temp-dir",
"textwrap",
"time", "time",
"tree-sitter-bash", "tree-sitter-bash",
"tree-sitter-highlight", "tree-sitter-highlight",
"tui-term", "tui-term",
"unicode-width 0.2.0", "unicode-width 0.2.0",
"zips",
] ]
[[package]] [[package]]
@ -505,12 +488,6 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.0.2" version = "1.0.2"
@ -538,16 +515,6 @@ dependencies = [
"pin-utils", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@ -907,24 +874,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "simdutf8"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.13.2" version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
[[package]]
name = "smawk"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]] [[package]]
name = "static_assertions" name = "static_assertions"
version = "1.1.0" version = "1.1.0"
@ -991,17 +946,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "textwrap"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width 0.1.14",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.64" version = "1.0.64"
@ -1147,12 +1091,6 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
version = "1.11.0" version = "1.11.0"
@ -1390,14 +1328,3 @@ dependencies = [
"quote", "quote",
"syn", "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",
]

View File

@ -25,10 +25,6 @@ rand = { version = "0.8.5", optional = true }
linutil_core = { path = "../core", version = "24.9.28" } linutil_core = { path = "../core", version = "24.9.28" }
tree-sitter-highlight = "0.24.3" tree-sitter-highlight = "0.24.3"
tree-sitter-bash = "0.23.1" tree-sitter-bash = "0.23.1"
textwrap = "0.16.1"
anstyle = "1.0.8"
ansi-to-tui = "7.0.0"
zips = "0.1.7"
unicode-width = "0.2.0" unicode-width = "0.2.0"
[[bin]] [[bin]]

View File

@ -1,43 +1,23 @@
use crate::{float::FloatContent, hint::Shortcut, theme::Theme}; use crate::{float::FloatContent, hint::Shortcut, theme::Theme};
use ansi_to_tui::IntoText;
use linutil_core::Command; use linutil_core::Command;
use ratatui::{ use ratatui::{
crossterm::event::{KeyCode, KeyEvent}, crossterm::event::{KeyCode, KeyEvent},
layout::Rect, layout::Rect,
style::{Style, Stylize}, style::{Color, Style, Stylize},
text::Line, text::{Line, Span, Text},
widgets::{Block, Borders, Clear, List}, widgets::{Block, Borders, Clear, Paragraph, Wrap},
Frame, Frame,
}; };
use std::{
borrow::Cow,
collections::VecDeque,
io::{Cursor, Read as _, Seek, SeekFrom, Write as _},
};
use textwrap::wrap;
use tree_sitter_bash as hl_bash; use tree_sitter_bash as hl_bash;
use tree_sitter_highlight::{self as hl, HighlightEvent}; use tree_sitter_highlight::{self as hl, HighlightEvent};
use zips::zip_result;
pub struct FloatingText {
pub src: String,
wrapped_lines: Vec<String>,
max_line_width: usize,
v_scroll: usize,
h_scroll: usize,
mode_title: String,
wrap_words: bool,
frame_height: usize,
}
macro_rules! style { macro_rules! style {
($r:literal, $g:literal, $b:literal) => {{ ($r:literal, $g:literal, $b:literal) => {{
use anstyle::{Color, RgbColor, Style}; Style::new().fg(Color::Rgb($r, $g, $b))
Style::new().fg_color(Some(Color::Rgb(RgbColor($r, $g, $b))))
}}; }};
} }
const SYNTAX_HIGHLIGHT_STYLES: [(&str, anstyle::Style); 8] = [ const SYNTAX_HIGHLIGHT_STYLES: [(&str, Style); 8] = [
("function", style!(220, 220, 170)), // yellow ("function", style!(220, 220, 170)), // yellow
("string", style!(206, 145, 120)), // brown ("string", style!(206, 145, 120)), // brown
("property", style!(156, 220, 254)), // light blue ("property", style!(156, 220, 254)), // light blue
@ -48,243 +28,185 @@ const SYNTAX_HIGHLIGHT_STYLES: [(&str, anstyle::Style); 8] = [
("number", style!(181, 206, 168)), // light green ("number", style!(181, 206, 168)), // light green
]; ];
fn get_highlighted_string(s: &str) -> Option<String> { pub struct FloatingText<'a> {
let mut hl_conf = hl::HighlightConfiguration::new( // Width, Height
hl_bash::LANGUAGE.into(), inner_area_size: (usize, usize),
"bash", mode_title: String,
hl_bash::HIGHLIGHT_QUERY, // Cache the text to avoid reprocessing it every frame
"", processed_text: Text<'a>,
"", // Vertical, Horizontal
) scroll: (u16, u16),
.ok()?; wrap_words: bool,
let matched_tokens = &SYNTAX_HIGHLIGHT_STYLES
.iter()
.map(|hl| hl.0)
.collect::<Vec<_>>();
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)
} }
#[inline] impl<'a> FloatingText<'a> {
fn get_lines(s: &str) -> Vec<&str> {
s.lines().collect::<Vec<_>>()
}
#[inline]
fn get_lines_owned(s: &str) -> Vec<String> {
get_lines(s).iter().map(|s| s.to_string()).collect()
}
impl FloatingText {
pub fn new(text: String, title: &str, wrap_words: bool) -> Self { pub fn new(text: String, title: &str, wrap_words: bool) -> Self {
let max_line_width = 80; let processed_text = Text::from(text);
let wrapped_lines = if wrap_words {
wrap(&text, max_line_width)
.into_iter()
.map(|cow| cow.into_owned())
.collect()
} else {
get_lines_owned(&text)
};
Self { Self {
src: text, inner_area_size: (0, 0),
wrapped_lines,
mode_title: title.to_string(), mode_title: title.to_string(),
max_line_width, processed_text,
v_scroll: 0, scroll: (0, 0),
h_scroll: 0,
wrap_words, wrap_words,
frame_height: 0,
} }
} }
pub fn from_command(command: &Command, title: String) -> Option<Self> { pub fn from_command(command: &Command, title: &str, wrap_words: bool) -> Self {
let src = match command { let src = match command {
Command::Raw(cmd) => Some(cmd.clone()), Command::Raw(cmd) => Some(cmd.clone()),
Command::LocalFile { file, .. } => std::fs::read_to_string(file) Command::LocalFile { file, .. } => std::fs::read_to_string(file)
.map_err(|_| format!("File not found: {:?}", file)) .map_err(|_| format!("File not found: {:?}", file))
.ok(), .ok(),
Command::None => None, Command::None => None,
}?; }
.unwrap();
let max_line_width = 80; let processed_text = Self::get_highlighted_string(&src).unwrap_or_else(|| Text::from(src));
let wrapped_lines = get_lines_owned(&get_highlighted_string(&src)?);
Some(Self { Self {
src, inner_area_size: (0, 0),
wrapped_lines, mode_title: title.to_string(),
mode_title: title, processed_text,
max_line_width, scroll: (0, 0),
h_scroll: 0, wrap_words,
v_scroll: 0, }
wrap_words: false, }
frame_height: 0,
}) fn get_highlighted_string(s: &str) -> Option<Text<'a>> {
let matched_tokens = SYNTAX_HIGHLIGHT_STYLES
.iter()
.map(|hl| hl.0)
.collect::<Vec<_>>();
let mut lines = Vec::with_capacity(s.lines().count());
let mut current_line = Vec::new();
let mut style_stack = vec![Style::default()];
let mut hl_conf = hl::HighlightConfiguration::new(
hl_bash::LANGUAGE.into(),
"bash",
hl_bash::HIGHLIGHT_QUERY,
"",
"",
)
.ok()?;
hl_conf.configure(&matched_tokens);
let mut hl = hl::Highlighter::new();
let events = hl.highlight(&hl_conf, s.as_bytes(), None, |_| None).ok()?;
for event in events {
match event.ok()? {
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()?;
let content = &s[start..end];
for part in content.split_inclusive('\n') {
if let Some(stripped) = part.strip_suffix('\n') {
// Push the text that is before '\n' and then start a new line
// After a new line clear the current line to start a new one
current_line.push(Span::styled(stripped.to_owned(), style));
lines.push(Line::from(current_line.to_owned()));
current_line.clear();
} else {
current_line.push(Span::styled(part.to_owned(), style));
}
}
}
}
}
// Makes sure last line of the file is pushed
// If no newline at the end of the file we need to push the last line
if !current_line.is_empty() {
lines.push(Line::from(current_line));
}
if lines.is_empty() {
return None;
}
Some(Text::from(lines))
} }
fn scroll_down(&mut self) { fn scroll_down(&mut self) {
let visible_lines = self.frame_height.saturating_sub(2); let max_scroll = self
if self.v_scroll + visible_lines < self.wrapped_lines.len() { .processed_text
self.v_scroll += 1; .lines
} .len()
.saturating_sub(self.inner_area_size.1) as u16;
self.scroll.0 = (self.scroll.0 + 1).min(max_scroll);
} }
fn scroll_up(&mut self) { fn scroll_up(&mut self) {
if self.v_scroll > 0 { self.scroll.0 = self.scroll.0.saturating_sub(1);
self.v_scroll -= 1;
}
} }
fn scroll_left(&mut self) { fn scroll_left(&mut self) {
if self.h_scroll > 0 { self.scroll.1 = self.scroll.1.saturating_sub(1);
self.h_scroll -= 1;
}
} }
fn scroll_right(&mut self) { fn scroll_right(&mut self) {
if self.h_scroll + 1 < self.max_line_width { let visible_length = self.inner_area_size.0.saturating_sub(1);
self.h_scroll += 1; let max_scroll = if self.wrap_words {
} 0
} } else {
self.processed_text
fn update_wrapping(&mut self, width: usize) { .lines
if self.max_line_width != width { .iter()
self.max_line_width = width; .map(|line| line.width())
self.wrapped_lines = if self.wrap_words { .max()
wrap(&self.src, width) .unwrap_or(0)
.into_iter() .saturating_sub(visible_length) as u16
.map(|cow| cow.into_owned()) };
.collect() self.scroll.1 = (self.scroll.1 + 1).min(max_scroll);
} else {
get_lines_owned(&get_highlighted_string(&self.src).unwrap_or(self.src.clone()))
};
}
} }
} }
impl FloatContent for FloatingText { impl<'a> FloatContent for FloatingText<'a> {
fn draw(&mut self, frame: &mut Frame, area: Rect, _theme: &Theme) { fn draw(&mut self, frame: &mut Frame, area: Rect, _theme: &Theme) {
self.frame_height = area.height as usize;
// Define the Block with a border and background color
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_set(ratatui::symbols::border::ROUNDED) .border_set(ratatui::symbols::border::ROUNDED)
.title(self.mode_title.clone()) .title(self.mode_title.as_str())
.title_alignment(ratatui::layout::Alignment::Center) .title_alignment(ratatui::layout::Alignment::Center)
.title_style(Style::default().reversed()) .title_style(Style::default().reversed())
.style(Style::default()); .style(Style::default());
frame.render_widget(Clear, area);
frame.render_widget(block.clone(), area);
// Calculate the inner area to ensure text is not drawn over the border
let inner_area = block.inner(area); let inner_area = block.inner(area);
let Rect { width, height, .. } = inner_area; self.inner_area_size = (inner_area.width as usize, inner_area.height as usize);
self.update_wrapping(width as usize); frame.render_widget(Clear, area);
frame.render_widget(block, area);
let lines = self let paragraph = if self.wrap_words {
.wrapped_lines Paragraph::new(self.processed_text.clone())
.iter() .scroll(self.scroll)
.skip(self.v_scroll) .wrap(Wrap { trim: false })
.take(height as usize) } else {
.flat_map(|l| { Paragraph::new(self.processed_text.clone()).scroll(self.scroll)
if self.wrap_words { };
vec![Line::raw(l.clone())]
} else {
l.into_text().unwrap().lines
}
})
.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::<VecDeque<_>>();
if spans.is_empty() { frame.render_widget(paragraph, inner_area);
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))
}
})
.collect::<Vec<_>>();
// Create list widget
let list = List::new(lines)
.block(Block::default())
.highlight_style(Style::default().reversed());
// Render the list inside the bordered area
frame.render_widget(list, inner_area);
} }
fn handle_key_event(&mut self, key: &KeyEvent) -> bool { fn handle_key_event(&mut self, key: &KeyEvent) -> bool {
use KeyCode::*; use KeyCode::{Char, Down, Left, Right, Up};
match key.code { match key.code {
Down | Char('j') => self.scroll_down(), Down | Char('j') | Char('J') => self.scroll_down(),
Up | Char('k') => self.scroll_up(), Up | Char('k') | Char('K') => self.scroll_up(),
Left | Char('h') => self.scroll_left(), Left | Char('h') | Char('H') => self.scroll_left(),
Right | Char('l') => self.scroll_right(), Right | Char('l') | Char('L') => self.scroll_right(),
_ => {} _ => {}
} }
false false

View File

@ -748,11 +748,9 @@ impl AppState {
fn enable_preview(&mut self) { fn enable_preview(&mut self) {
if let Some(list_node) = self.get_selected_node() { if let Some(list_node) = self.get_selected_node() {
let mut preview_title = "[Preview] - ".to_string(); let preview_title = format!("[Preview] - {}", list_node.name.as_str());
preview_title.push_str(list_node.name.as_str()); let preview = FloatingText::from_command(&list_node.command, &preview_title, false);
if let Some(preview) = FloatingText::from_command(&list_node.command, preview_title) { self.spawn_float(preview, 80, 80);
self.spawn_float(preview, 80, 80);
}
} }
} }