diff --git a/Cargo.lock b/Cargo.lock index 07123221..e87e2df9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,18 +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", - "smallvec", - "thiserror", -] - [[package]] name = "anstyle" version = "1.0.8" @@ -395,8 +383,6 @@ dependencies = [ name = "linutil_tui" version = "24.9.28" dependencies = [ - "ansi-to-tui", - "anstyle", "clap", "linutil_core", "nix 0.29.0", @@ -405,13 +391,11 @@ dependencies = [ "rand", "ratatui", "temp-dir", - "textwrap", "time", "tree-sitter-bash", "tree-sitter-highlight", "tui-term", "unicode-width 0.2.0", - "zips", ] [[package]] @@ -460,12 +444,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" @@ -505,16 +483,6 @@ dependencies = [ "libc", ] -[[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" @@ -940,12 +908,6 @@ dependencies = [ "libc", ] -[[package]] -name = "textwrap" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" - [[package]] name = "thiserror" version = "1.0.64" @@ -1328,14 +1290,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", -] diff --git a/tui/Cargo.toml b/tui/Cargo.toml index 5094b815..105a3b56 100644 --- a/tui/Cargo.toml +++ b/tui/Cargo.toml @@ -26,10 +26,6 @@ rand = { version = "0.8.5", optional = true } linutil_core = { version = "24.9.28", path = "../core" } tree-sitter-highlight = "0.24.3" tree-sitter-bash = "0.23.1" -textwrap = { version = "0.16.1", default-features = false } -anstyle = { version = "1.0.8", default-features = false } -ansi-to-tui = { version = "7.0.0", default-features = false } -zips = "0.1.7" nix = { version = "0.29.0", features = [ "user" ] } [[bin]] diff --git a/tui/src/confirmation.rs b/tui/src/confirmation.rs index 2ed5898b..f9ae42ad 100644 --- a/tui/src/confirmation.rs +++ b/tui/src/confirmation.rs @@ -1,13 +1,11 @@ -use std::borrow::Cow; - -use crate::{float::FloatContent, hint::Shortcut}; - +use crate::{float::FloatContent, hint::Shortcut, theme}; use ratatui::{ crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind}, layout::Alignment, prelude::*, widgets::{Block, Borders, Clear, List}, }; +use std::borrow::Cow; pub enum ConfirmStatus { Confirm, @@ -16,35 +14,30 @@ pub enum ConfirmStatus { } pub struct ConfirmPrompt { - pub names: Box<[String]>, - pub status: ConfirmStatus, + inner_area_height: usize, + names: Box<[String]>, scroll: usize, + pub status: ConfirmStatus, } impl ConfirmPrompt { pub fn new(names: &[&str]) -> Self { - let max_count_str = format!("{}", names.len()); let names = names .iter() .zip(1..) - .map(|(name, n)| { - let count_str = format!("{n}"); - let space_str = (0..(max_count_str.len() - count_str.len())) - .map(|_| ' ') - .collect::(); - format!("{space_str}{n}. {name}") - }) + .map(|(name, n)| format!(" {n}. {name}")) .collect(); Self { + inner_area_height: 0, names, - status: ConfirmStatus::None, scroll: 0, + status: ConfirmStatus::None, } } pub fn scroll_down(&mut self) { - if self.scroll < self.names.len() - 1 { + if self.scroll + self.inner_area_height < self.names.len() - 1 { self.scroll += 1; } } @@ -57,19 +50,28 @@ impl ConfirmPrompt { } impl FloatContent for ConfirmPrompt { - fn draw(&mut self, frame: &mut Frame, area: Rect) { + fn draw(&mut self, frame: &mut Frame, area: Rect, theme: &theme::Theme) { let block = Block::default() .borders(Borders::ALL) .border_set(ratatui::symbols::border::ROUNDED) .title(" Confirm selections ") - .title_bottom(" [y] to continue, [n] to abort ") + .title_bottom(Line::from(vec![ + Span::styled(" [", Style::default()), + Span::styled("y", Style::default().fg(theme.success_color())), + Span::styled("] to continue ", Style::default()), + Span::styled("[", Style::default()), + Span::styled("n", Style::default().fg(theme.fail_color())), + Span::styled("] to abort ", Style::default()), + ])) .title_alignment(Alignment::Center) .title_style(Style::default().bold()) .style(Style::default()); - frame.render_widget(block.clone(), area); - let inner_area = block.inner(area); + self.inner_area_height = inner_area.height as usize; + + frame.render_widget(Clear, area); + frame.render_widget(block, area); let paths_text = self .names @@ -81,7 +83,6 @@ impl FloatContent for ConfirmPrompt { }) .collect::(); - frame.render_widget(Clear, inner_area); frame.render_widget(List::new(paths_text), inner_area); } @@ -99,21 +100,21 @@ impl FloatContent for ConfirmPrompt { } fn handle_key_event(&mut self, key: &KeyEvent) -> bool { - use KeyCode::*; + use ConfirmStatus::*; + use KeyCode::{Char, Down, Esc, Up}; self.status = match key.code { - Char('y') | Char('Y') => ConfirmStatus::Confirm, - Char('n') | Char('N') | Esc | Char('q') => ConfirmStatus::Abort, - Char('j') => { + Char('y') | Char('Y') => Confirm, + Char('n') | Char('N') | Esc | Char('q') => Abort, + Char('j') | Char('J') | Down => { self.scroll_down(); - ConfirmStatus::None + None } - Char('k') => { + Char('k') | Char('K') | Up => { self.scroll_up(); - ConfirmStatus::None + None } - _ => ConfirmStatus::None, + _ => None, }; - false } @@ -131,8 +132,8 @@ impl FloatContent for ConfirmPrompt { Box::new([ Shortcut::new("Continue", ["Y", "y"]), Shortcut::new("Abort", ["N", "n", "q", "Esc"]), - Shortcut::new("Scroll up", ["k"]), - Shortcut::new("Scroll down", ["j"]), + Shortcut::new("Scroll up", ["k", "Up"]), + Shortcut::new("Scroll down", ["j", "Down"]), Shortcut::new("Close linutil", ["CTRL-c"]), ]), ) diff --git a/tui/src/filter.rs b/tui/src/filter.rs index f44e89a1..60a64223 100644 --- a/tui/src/filter.rs +++ b/tui/src/filter.rs @@ -3,7 +3,7 @@ use linutil_core::{ego_tree::NodeId, Tab}; use ratatui::{ crossterm::event::{KeyCode, KeyEvent, KeyModifiers}, layout::{Position, Rect}, - style::{Color, Style}, + style::Style, text::Span, widgets::{Block, Borders, Paragraph}, Frame, @@ -17,10 +17,12 @@ pub enum SearchAction { } pub struct Filter { + // Use Vec to handle multi-byte characters like emojis search_input: Vec, in_search_mode: bool, input_position: usize, items: Vec, + // No complex string manipulation is done with completion_preview so we can use String unlike search_input completion_preview: Option, } @@ -62,13 +64,11 @@ impl Filter { .collect(); } else { self.items.clear(); - let query_lower = self.search_input.iter().collect::().to_lowercase(); - for tab in tabs.iter() { + for tab in tabs { let mut stack = vec![tab.tree.root().id()]; while let Some(node_id) = stack.pop() { let node = tab.tree.get(node_id).unwrap(); - if node.value().name.to_lowercase().contains(&query_lower) && !node.has_children() { @@ -78,31 +78,29 @@ impl Filter { has_children: false, }); } - stack.extend(node.children().map(|child| child.id())); } } - self.items.sort_by(|a, b| a.node.name.cmp(&b.node.name)); + self.items + .sort_unstable_by(|a, b| a.node.name.cmp(&b.node.name)); } - self.update_completion_preview(); } fn update_completion_preview(&mut self) { - if self.search_input.is_empty() { - self.completion_preview = None; - return; + self.completion_preview = if self.items.is_empty() || self.search_input.is_empty() { + None + } else { + let input = self.search_input.iter().collect::().to_lowercase(); + self.items.iter().find_map(|item| { + let item_name_lower = &item.node.name.to_lowercase(); + if item_name_lower.starts_with(&input) { + Some(item_name_lower[input.len()..].to_string()) + } else { + None + } + }) } - - let input = self.search_input.iter().collect::().to_lowercase(); - self.completion_preview = self.items.iter().find_map(|item| { - let item_name_lower = item.node.name.to_lowercase(); - if item_name_lower.starts_with(&input) { - Some(item_name_lower[input.len()..].to_string()) - } else { - None - } - }); } pub fn draw_searchbar(&self, frame: &mut Frame, area: Rect, theme: &Theme) { @@ -135,24 +133,32 @@ impl Filter { // Render cursor in search bar if self.in_search_mode { - let cursor_position: usize = self.search_input[..self.input_position] + // Calculate the visual width of search input so that completion preview can be displayed after the search input + let search_input_size: u16 = self + .search_input .iter() - .map(|c| c.width().unwrap_or(1)) + .map(|c| c.width().unwrap_or(1) as u16) .sum(); - let x = area.x + cursor_position as u16 + 1; + + let cursor_position: u16 = self.search_input[..self.input_position] + .iter() + .map(|c| c.width().unwrap_or(1) as u16) + .sum(); + let x = area.x + cursor_position + 1; let y = area.y + 1; frame.set_cursor_position(Position::new(x, y)); if let Some(preview) = &self.completion_preview { - let preview_span = Span::styled(preview, Style::default().fg(Color::DarkGray)); - let preview_paragraph = Paragraph::new(preview_span).style(Style::default()); + let preview_x = area.x + search_input_size + 1; + let preview_span = + Span::styled(preview, Style::default().fg(theme.search_preview_color())); let preview_area = Rect::new( - x, + preview_x, y, - (preview.len() as u16).min(area.width - cursor_position as u16 - 1), + (preview.len() as u16).min(area.width - search_input_size - 1), // Ensure the completion preview stays within the search bar bounds 1, ); - frame.render_widget(preview_paragraph, preview_area); + frame.render_widget(Paragraph::new(preview_span), preview_area); } } } @@ -220,10 +226,18 @@ impl Filter { } fn complete_search(&mut self) -> SearchAction { - if let Some(completion) = self.completion_preview.take() { - self.search_input.extend(completion.chars()); + if self.completion_preview.is_some() { + let input = &self.search_input.iter().collect::().to_lowercase(); + if let Some(search_completion) = self + .items + .iter() + .find(|item| item.node.name.to_lowercase().starts_with(input)) + { + self.search_input = search_completion.node.name.chars().collect(); + } + self.input_position = self.search_input.len(); - self.update_completion_preview(); + self.completion_preview = None; SearchAction::Update } else { SearchAction::None diff --git a/tui/src/float.rs b/tui/src/float.rs index 4d6ac006..d9c8d6de 100644 --- a/tui/src/float.rs +++ b/tui/src/float.rs @@ -1,13 +1,12 @@ +use crate::{hint::Shortcut, theme::Theme}; use ratatui::{ crossterm::event::{KeyCode, KeyEvent, MouseEvent}, layout::{Constraint, Direction, Layout, Rect}, Frame, }; -use crate::hint::Shortcut; - pub trait FloatContent { - fn draw(&mut self, frame: &mut Frame, area: Rect); + fn draw(&mut self, frame: &mut Frame, area: Rect, theme: &Theme); fn handle_key_event(&mut self, key: &KeyEvent) -> bool; fn handle_mouse_event(&mut self, key: &MouseEvent) -> bool; fn is_finished(&self) -> bool; @@ -49,9 +48,9 @@ impl Float { .split(hor_float)[1] } - pub fn draw(&mut self, frame: &mut Frame, parent_area: Rect) { + pub fn draw(&mut self, frame: &mut Frame, parent_area: Rect, theme: &Theme) { let popup_area = self.floating_window(parent_area); - self.content.draw(frame, popup_area); + self.content.draw(frame, popup_area, theme); } pub fn handle_mouse_event(&mut self, event: &MouseEvent) { diff --git a/tui/src/floating_text.rs b/tui/src/floating_text.rs index 6a2546cc..18074ddb 100644 --- a/tui/src/floating_text.rs +++ b/tui/src/floating_text.rs @@ -1,48 +1,23 @@ -use std::{ - borrow::Cow, - collections::VecDeque, - io::{Cursor, Read as _, Seek, SeekFrom, Write as _}, -}; - -use crate::{float::FloatContent, hint::Shortcut}; - +use crate::{float::FloatContent, hint::Shortcut, theme::Theme}; use linutil_core::Command; - use ratatui::{ crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind}, 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 ansi_to_tui::IntoText; - -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, - 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 @@ -53,234 +28,176 @@ const SYNTAX_HIGHLIGHT_STYLES: [(&str, anstyle::Style); 8] = [ ("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) +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, } -#[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 { +impl<'a> FloatingText<'a> { 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() - } else { - get_lines_owned(&text) - }; + let processed_text = Text::from(text); Self { - src: text, - wrapped_lines, + inner_area_size: (0, 0), mode_title: title.to_string(), - max_line_width, - v_scroll: 0, - h_scroll: 0, + processed_text, + scroll: (0, 0), wrap_words, - frame_height: 0, } } - pub fn from_command(command: &Command, title: String) -> Option { + 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 max_line_width = 80; - let wrapped_lines = get_lines_owned(&get_highlighted_string(&src)?); + let processed_text = Self::get_highlighted_string(&src).unwrap_or_else(|| Text::from(src)); - Some(Self { - src, - wrapped_lines, - mode_title: title, - max_line_width, - h_scroll: 0, - v_scroll: 0, - wrap_words: false, - frame_height: 0, - }) + 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> { + let matched_tokens = SYNTAX_HIGHLIGHT_STYLES + .iter() + .map(|hl| hl.0) + .collect::>(); + + 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) { - 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() - } else { - get_lines_owned(&get_highlighted_string(&self.src).unwrap_or(self.src.clone())) - }; - } + let visible_length = self.inner_area_size.0.saturating_sub(1); + let max_scroll = if self.wrap_words { + 0 + } else { + 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 { - fn draw(&mut self, frame: &mut Frame, area: Rect) { - self.frame_height = area.height as usize; - - // Define the Block with a border and background color +impl<'a> FloatContent for FloatingText<'a> { + fn draw(&mut self, frame: &mut Frame, area: Rect, _theme: &Theme) { 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())] - } 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::>(); + let paragraph = if self.wrap_words { + Paragraph::new(self.processed_text.clone()) + .scroll(self.scroll) + .wrap(Wrap { trim: false }) + } else { + 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::>(); - - // 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_mouse_event(&mut self, event: &MouseEvent) -> bool { @@ -295,12 +212,12 @@ impl FloatContent for FloatingText { } 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 diff --git a/tui/src/hint.rs b/tui/src/hint.rs index 82c265c8..2ab4b99d 100644 --- a/tui/src/hint.rs +++ b/tui/src/hint.rs @@ -1,9 +1,8 @@ -use std::borrow::Cow; - use ratatui::{ style::{Style, Stylize}, text::{Line, Span}, }; +use std::borrow::Cow; pub struct Shortcut { pub key_sequences: Vec>, diff --git a/tui/src/main.rs b/tui/src/main.rs index d391f9e6..536b10c0 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -8,15 +8,8 @@ mod running_command; pub mod state; mod theme; -use std::{ - io::{self, stdout}, - path::PathBuf, - time::Duration, -}; - use crate::theme::Theme; use clap::Parser; - use ratatui::{ backend::CrosstermBackend, crossterm::{ @@ -28,6 +21,11 @@ use ratatui::{ Terminal, }; use state::AppState; +use std::{ + io::{self, stdout}, + path::PathBuf, + time::Duration, +}; // Linux utility toolbox #[derive(Debug, Parser)] diff --git a/tui/src/root.rs b/tui/src/root.rs index 1b02b938..46432d06 100644 --- a/tui/src/root.rs +++ b/tui/src/root.rs @@ -8,7 +8,7 @@ This means you have full system access and commands can potentially damage your Please proceed with caution and make sure you understand what each script does before executing it."; #[cfg(unix)] -pub fn check_root_status() -> Option { +pub fn check_root_status<'a>() -> Option> { (Uid::effective().is_root()).then_some(FloatingText::new( ROOT_WARNING.into(), "Root User Warning", diff --git a/tui/src/running_command.rs b/tui/src/running_command.rs index c3b3d3d4..9a9ea7a9 100644 --- a/tui/src/running_command.rs +++ b/tui/src/running_command.rs @@ -1,4 +1,4 @@ -use crate::{float::FloatContent, hint::Shortcut}; +use crate::{float::FloatContent, hint::Shortcut, theme::Theme}; use linutil_core::Command; use oneshot::{channel, Receiver}; use portable_pty::{ @@ -7,8 +7,8 @@ use portable_pty::{ use ratatui::{ crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}, layout::{Rect, Size}, - style::{Color, Style, Stylize}, - text::{Line, Span}, + style::{Style, Stylize}, + text::Line, widgets::{Block, Borders}, Frame, }; @@ -19,9 +19,10 @@ use std::{ }; use time::{macros::format_description, OffsetDateTime}; use tui_term::{ - vt100::{self, Screen}, + vt100::{Parser, Screen}, widget::PseudoTerminal, }; + pub struct RunningCommand { /// A buffer to save all the command output (accumulates, until the command exits) buffer: Arc>>, @@ -42,13 +43,7 @@ pub struct RunningCommand { } impl FloatContent for RunningCommand { - fn draw(&mut self, frame: &mut Frame, area: Rect) { - // Calculate the inner size of the terminal area, considering borders - let inner_size = Size { - width: area.width - 2, // Adjust for border width - height: area.height - 2, - }; - + fn draw(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { // Define the block for the terminal display let block = if !self.is_finished() { // Display a block indicating the command is running @@ -60,42 +55,33 @@ impl FloatContent for RunningCommand { .title_bottom(Line::from("Press Ctrl-C to KILL the command")) } else { // Display a block with the command's exit status - let mut title_line = if self.get_exit_status().success() { - Line::from( - Span::default() - .content("SUCCESS!") - .style(Style::default().fg(Color::Green).reversed()), + let title_line = if self.get_exit_status().success() { + Line::styled( + "SUCCESS! Press to close this window", + Style::default().fg(theme.success_color()).reversed(), ) } else { - Line::from( - Span::default() - .content("FAILED!") - .style(Style::default().fg(Color::Red).reversed()), + Line::styled( + "FAILED! Press to close this window", + Style::default().fg(theme.fail_color()).reversed(), ) }; - title_line.push_span( - Span::default() - .content(" Press to close this window ") - .style(Style::default()), - ); + let log_path = if let Some(log_path) = &self.log_path { + Line::from(format!(" Log saved: {} ", log_path)).centered() + } else { + Line::from(" Press 'l' to save command log ").centered() + }; - let mut block = Block::default() + Block::default() .borders(Borders::ALL) .border_set(ratatui::symbols::border::ROUNDED) - .title_top(title_line.centered()); - - if let Some(log_path) = &self.log_path { - block = - block.title_bottom(Line::from(format!(" Log saved: {} ", log_path)).centered()); - } else { - block = - block.title_bottom(Line::from(" Press 'l' to save command log ").centered()); - } - - block + .title_top(title_line.centered()) + .title_bottom(log_path) }; + // Calculate the inner size of the terminal area, considering borders + let inner_size = block.inner(area).as_size(); // Process the buffer and create the pseudo-terminal widget let screen = self.screen(inner_size); let pseudo_term = PseudoTerminal::new(&screen).block(block); @@ -179,7 +165,7 @@ impl FloatContent for RunningCommand { } impl RunningCommand { - pub fn new(commands: Vec) -> Self { + pub fn new(commands: &[&Command]) -> Self { let pty_system = NativePtySystem::default(); // Build the command based on the provided Command enum variant @@ -199,10 +185,10 @@ impl RunningCommand { if let Some(parent_directory) = file.parent() { script.push_str(&format!("cd {}\n", parent_directory.display())); } - script.push_str(&executable); + script.push_str(executable); for arg in args { script.push(' '); - script.push_str(&arg); + script.push_str(arg); } script.push('\n'); // Ensures that each command is properly separated for execution preventing directory errors } @@ -285,7 +271,7 @@ impl RunningCommand { // Process the buffer with a parser with the current screen size // We don't actually need to create a new parser every time, but it is so much easier this // way, and doesn't cost that much - let mut parser = vt100::Parser::new(size.height, size.width, 1000); + let mut parser = Parser::new(size.height, size.width, 1000); let mutex = self.buffer.lock(); let buffer = mutex.as_ref().unwrap(); parser.process(buffer); diff --git a/tui/src/state.rs b/tui/src/state.rs index 7f96aee9..485b13fc 100644 --- a/tui/src/state.rs +++ b/tui/src/state.rs @@ -9,7 +9,7 @@ use crate::{ theme::Theme, }; -use linutil_core::{ego_tree::NodeId, Config, ListNode, TabList}; +use linutil_core::{ego_tree::NodeId, Command, Config, ListNode, TabList}; #[cfg(feature = "tips")] use rand::Rng; use ratatui::{ @@ -243,7 +243,7 @@ impl AppState { MIN_HEIGHT, )) .alignment(Alignment::Center) - .style(Style::default().fg(ratatui::style::Color::Red).bold()) + .style(Style::default().fg(self.theme.fail_color()).bold()) .wrap(ratatui::widgets::Wrap { trim: true }); let centered_layout = Layout::default() @@ -480,8 +480,8 @@ impl AppState { frame.render_stateful_widget(disclaimer_list, list_chunks[1], &mut self.selection); match &mut self.focus { - Focus::FloatingWindow(float) => float.draw(frame, chunks[1]), - Focus::ConfirmationPrompt(prompt) => prompt.draw(frame, chunks[1]), + Focus::FloatingWindow(float) => float.draw(frame, chunks[1], &self.theme), + Focus::ConfirmationPrompt(prompt) => prompt.draw(frame, chunks[1], &self.theme), _ => {} } @@ -820,11 +820,9 @@ 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) { - self.spawn_float(preview, 80, 80); - } + 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); } } @@ -870,7 +868,7 @@ impl AppState { .map(|node| node.name.as_str()) .collect::>(); - let prompt = ConfirmPrompt::new(&cmd_names[..]); + let prompt = ConfirmPrompt::new(&cmd_names); self.focus = Focus::ConfirmationPrompt(Float::new(Box::new(prompt), 40, 40)); } } @@ -879,13 +877,13 @@ impl AppState { } fn handle_confirm_command(&mut self) { - let commands = self + let commands: Vec<&Command> = self .selected_commands .iter() - .map(|node| node.command.clone()) + .map(|node| &node.command) .collect(); - let command = RunningCommand::new(commands); + let command = RunningCommand::new(&commands); self.spawn_float(command, 80, 80); self.selected_commands.clear(); } diff --git a/tui/src/theme.rs b/tui/src/theme.rs index d87e87ee..9e10ce70 100644 --- a/tui/src/theme.rs +++ b/tui/src/theme.rs @@ -72,14 +72,14 @@ impl Theme { pub fn success_color(&self) -> Color { match self { - Theme::Default => Color::Rgb(199, 55, 44), + Theme::Default => Color::Rgb(5, 255, 55), Theme::Compatible => Color::Green, } } pub fn fail_color(&self) -> Color { match self { - Theme::Default => Color::Rgb(5, 255, 55), + Theme::Default => Color::Rgb(199, 55, 44), Theme::Compatible => Color::Red, } } @@ -91,6 +91,13 @@ impl Theme { } } + pub fn search_preview_color(&self) -> Color { + match self { + Theme::Default => Color::DarkGray, + Theme::Compatible => Color::DarkGray, + } + } + pub fn unfocused_color(&self) -> Color { match self { Theme::Default => Color::Gray,