mirror of
https://github.com/ChrisTitusTech/linutil.git
synced 2024-11-24 22:14:28 +00:00
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:
parent
2d1f5dbc80
commit
10352c6254
73
Cargo.lock
generated
73
Cargo.lock
generated
|
@ -29,19 +29,6 @@ version = "0.2.18"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "anstream"
|
||||
version = "0.6.15"
|
||||
|
@ -441,8 +428,6 @@ dependencies = [
|
|||
name = "linutil_tui"
|
||||
version = "24.9.28"
|
||||
dependencies = [
|
||||
"ansi-to-tui",
|
||||
"anstyle",
|
||||
"clap",
|
||||
"linutil_core",
|
||||
"oneshot",
|
||||
|
@ -450,13 +435,11 @@ dependencies = [
|
|||
"rand",
|
||||
"ratatui",
|
||||
"temp-dir",
|
||||
"textwrap",
|
||||
"time",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-highlight",
|
||||
"tui-term",
|
||||
"unicode-width 0.2.0",
|
||||
"zips",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -505,12 +488,6 @@ 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"
|
||||
|
@ -538,16 +515,6 @@ 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-conv"
|
||||
version = "0.1.0"
|
||||
|
@ -907,24 +874,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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
|
||||
|
||||
[[package]]
|
||||
name = "smawk"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
|
@ -991,17 +946,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "thiserror"
|
||||
version = "1.0.64"
|
||||
|
@ -1147,12 +1091,6 @@ version = "1.0.13"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-linebreak"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.11.0"
|
||||
|
@ -1390,14 +1328,3 @@ 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",
|
||||
]
|
||||
|
|
|
@ -25,10 +25,6 @@ rand = { version = "0.8.5", optional = true }
|
|||
linutil_core = { path = "../core", version = "24.9.28" }
|
||||
tree-sitter-highlight = "0.24.3"
|
||||
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"
|
||||
|
||||
[[bin]]
|
||||
|
|
|
@ -1,43 +1,23 @@
|
|||
use crate::{float::FloatContent, hint::Shortcut, theme::Theme};
|
||||
use ansi_to_tui::IntoText;
|
||||
use linutil_core::Command;
|
||||
use ratatui::{
|
||||
crossterm::event::{KeyCode, KeyEvent},
|
||||
layout::Rect,
|
||||
style::{Style, Stylize},
|
||||
text::Line,
|
||||
widgets::{Block, Borders, Clear, List},
|
||||
style::{Color, Style, Stylize},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, Clear, Paragraph, Wrap},
|
||||
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_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 {
|
||||
($r:literal, $g:literal, $b:literal) => {{
|
||||
use anstyle::{Color, RgbColor, Style};
|
||||
Style::new().fg_color(Some(Color::Rgb(RgbColor($r, $g, $b))))
|
||||
Style::new().fg(Color::Rgb($r, $g, $b))
|
||||
}};
|
||||
}
|
||||
|
||||
const SYNTAX_HIGHLIGHT_STYLES: [(&str, anstyle::Style); 8] = [
|
||||
const SYNTAX_HIGHLIGHT_STYLES: [(&str, Style); 8] = [
|
||||
("function", style!(220, 220, 170)), // yellow
|
||||
("string", style!(206, 145, 120)), // brown
|
||||
("property", style!(156, 220, 254)), // light blue
|
||||
|
@ -48,7 +28,61 @@ const SYNTAX_HIGHLIGHT_STYLES: [(&str, anstyle::Style); 8] = [
|
|||
("number", style!(181, 206, 168)), // light green
|
||||
];
|
||||
|
||||
fn get_highlighted_string(s: &str) -> Option<String> {
|
||||
pub struct FloatingText<'a> {
|
||||
// Width, Height
|
||||
inner_area_size: (usize, usize),
|
||||
mode_title: String,
|
||||
// Cache the text to avoid reprocessing it every frame
|
||||
processed_text: Text<'a>,
|
||||
// Vertical, Horizontal
|
||||
scroll: (u16, u16),
|
||||
wrap_words: bool,
|
||||
}
|
||||
|
||||
impl<'a> FloatingText<'a> {
|
||||
pub fn new(text: String, title: &str, wrap_words: bool) -> Self {
|
||||
let processed_text = Text::from(text);
|
||||
|
||||
Self {
|
||||
inner_area_size: (0, 0),
|
||||
mode_title: title.to_string(),
|
||||
processed_text,
|
||||
scroll: (0, 0),
|
||||
wrap_words,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_command(command: &Command, title: &str, wrap_words: bool) -> Self {
|
||||
let src = match command {
|
||||
Command::Raw(cmd) => Some(cmd.clone()),
|
||||
Command::LocalFile { file, .. } => std::fs::read_to_string(file)
|
||||
.map_err(|_| format!("File not found: {:?}", file))
|
||||
.ok(),
|
||||
Command::None => None,
|
||||
}
|
||||
.unwrap();
|
||||
|
||||
let processed_text = Self::get_highlighted_string(&src).unwrap_or_else(|| Text::from(src));
|
||||
|
||||
Self {
|
||||
inner_area_size: (0, 0),
|
||||
mode_title: title.to_string(),
|
||||
processed_text,
|
||||
scroll: (0, 0),
|
||||
wrap_words,
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
|
@ -58,24 +92,13 @@ fn get_highlighted_string(s: &str) -> Option<String> {
|
|||
)
|
||||
.ok()?;
|
||||
|
||||
let matched_tokens = &SYNTAX_HIGHLIGHT_STYLES
|
||||
.iter()
|
||||
.map(|hl| hl.0)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
hl_conf.configure(matched_tokens);
|
||||
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![]);
|
||||
let events = hl.highlight(&hl_conf, s.as_bytes(), None, |_| None).ok()?;
|
||||
|
||||
for event in events {
|
||||
match event.unwrap() {
|
||||
match event.ok()? {
|
||||
HighlightEvent::HighlightStart(h) => {
|
||||
style_stack.push(SYNTAX_HIGHLIGHT_STYLES.get(h.0)?.1);
|
||||
}
|
||||
|
@ -85,206 +108,105 @@ fn get_highlighted_string(s: &str) -> Option<String> {
|
|||
}
|
||||
|
||||
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 style = *style_stack.last()?;
|
||||
let content = &s[start..end];
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
zip_result!(
|
||||
buf.seek(SeekFrom::Start(0)),
|
||||
buf.read_to_string(&mut output),
|
||||
)?;
|
||||
|
||||
Some(output)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
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 {
|
||||
let max_line_width = 80;
|
||||
let wrapped_lines = if wrap_words {
|
||||
wrap(&text, max_line_width)
|
||||
.into_iter()
|
||||
.map(|cow| cow.into_owned())
|
||||
.collect()
|
||||
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 {
|
||||
get_lines_owned(&text)
|
||||
};
|
||||
|
||||
Self {
|
||||
src: text,
|
||||
wrapped_lines,
|
||||
mode_title: title.to_string(),
|
||||
max_line_width,
|
||||
v_scroll: 0,
|
||||
h_scroll: 0,
|
||||
wrap_words,
|
||||
frame_height: 0,
|
||||
current_line.push(Span::styled(part.to_owned(), style));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_command(command: &Command, title: String) -> Option<Self> {
|
||||
let src = match command {
|
||||
Command::Raw(cmd) => Some(cmd.clone()),
|
||||
Command::LocalFile { file, .. } => std::fs::read_to_string(file)
|
||||
.map_err(|_| format!("File not found: {:?}", file))
|
||||
.ok(),
|
||||
Command::None => None,
|
||||
}?;
|
||||
// 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));
|
||||
}
|
||||
|
||||
let max_line_width = 80;
|
||||
let wrapped_lines = get_lines_owned(&get_highlighted_string(&src)?);
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
src,
|
||||
wrapped_lines,
|
||||
mode_title: title,
|
||||
max_line_width,
|
||||
h_scroll: 0,
|
||||
v_scroll: 0,
|
||||
wrap_words: false,
|
||||
frame_height: 0,
|
||||
})
|
||||
Some(Text::from(lines))
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self) {
|
||||
let visible_lines = self.frame_height.saturating_sub(2);
|
||||
if self.v_scroll + visible_lines < self.wrapped_lines.len() {
|
||||
self.v_scroll += 1;
|
||||
}
|
||||
let max_scroll = self
|
||||
.processed_text
|
||||
.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) {
|
||||
if self.v_scroll > 0 {
|
||||
self.v_scroll -= 1;
|
||||
}
|
||||
self.scroll.0 = self.scroll.0.saturating_sub(1);
|
||||
}
|
||||
|
||||
fn scroll_left(&mut self) {
|
||||
if self.h_scroll > 0 {
|
||||
self.h_scroll -= 1;
|
||||
}
|
||||
self.scroll.1 = self.scroll.1.saturating_sub(1);
|
||||
}
|
||||
|
||||
fn scroll_right(&mut self) {
|
||||
if self.h_scroll + 1 < self.max_line_width {
|
||||
self.h_scroll += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn update_wrapping(&mut self, width: usize) {
|
||||
if self.max_line_width != width {
|
||||
self.max_line_width = width;
|
||||
self.wrapped_lines = if self.wrap_words {
|
||||
wrap(&self.src, width)
|
||||
.into_iter()
|
||||
.map(|cow| cow.into_owned())
|
||||
.collect()
|
||||
let visible_length = self.inner_area_size.0.saturating_sub(1);
|
||||
let max_scroll = if self.wrap_words {
|
||||
0
|
||||
} else {
|
||||
get_lines_owned(&get_highlighted_string(&self.src).unwrap_or(self.src.clone()))
|
||||
self.processed_text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.width())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.saturating_sub(visible_length) as u16
|
||||
};
|
||||
}
|
||||
self.scroll.1 = (self.scroll.1 + 1).min(max_scroll);
|
||||
}
|
||||
}
|
||||
|
||||
impl FloatContent for FloatingText {
|
||||
impl<'a> FloatContent for FloatingText<'a> {
|
||||
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()
|
||||
.borders(Borders::ALL)
|
||||
.border_set(ratatui::symbols::border::ROUNDED)
|
||||
.title(self.mode_title.clone())
|
||||
.title(self.mode_title.as_str())
|
||||
.title_alignment(ratatui::layout::Alignment::Center)
|
||||
.title_style(Style::default().reversed())
|
||||
.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 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
|
||||
.wrapped_lines
|
||||
.iter()
|
||||
.skip(self.v_scroll)
|
||||
.take(height as usize)
|
||||
.flat_map(|l| {
|
||||
if self.wrap_words {
|
||||
vec![Line::raw(l.clone())]
|
||||
let paragraph = if self.wrap_words {
|
||||
Paragraph::new(self.processed_text.clone())
|
||||
.scroll(self.scroll)
|
||||
.wrap(Wrap { trim: false })
|
||||
} 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<_>>();
|
||||
Paragraph::new(self.processed_text.clone()).scroll(self.scroll)
|
||||
};
|
||||
|
||||
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))
|
||||
}
|
||||
})
|
||||
.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);
|
||||
frame.render_widget(paragraph, inner_area);
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, key: &KeyEvent) -> bool {
|
||||
use KeyCode::*;
|
||||
use KeyCode::{Char, Down, Left, Right, Up};
|
||||
match key.code {
|
||||
Down | Char('j') => self.scroll_down(),
|
||||
Up | Char('k') => self.scroll_up(),
|
||||
Left | Char('h') => self.scroll_left(),
|
||||
Right | Char('l') => self.scroll_right(),
|
||||
Down | Char('j') | Char('J') => self.scroll_down(),
|
||||
Up | Char('k') | Char('K') => self.scroll_up(),
|
||||
Left | Char('h') | Char('H') => self.scroll_left(),
|
||||
Right | Char('l') | Char('L') => self.scroll_right(),
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
|
|
|
@ -748,13 +748,11 @@ impl AppState {
|
|||
|
||||
fn enable_preview(&mut self) {
|
||||
if let Some(list_node) = self.get_selected_node() {
|
||||
let mut preview_title = "[Preview] - ".to_string();
|
||||
preview_title.push_str(list_node.name.as_str());
|
||||
if let Some(preview) = FloatingText::from_command(&list_node.command, preview_title) {
|
||||
let preview_title = format!("[Preview] - {}", list_node.name.as_str());
|
||||
let preview = FloatingText::from_command(&list_node.command, &preview_title, false);
|
||||
self.spawn_float(preview, 80, 80);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enable_description(&mut self) {
|
||||
if let Some(command_description) = self.get_selected_description() {
|
||||
|
|
Loading…
Reference in New Issue
Block a user