mirror of
https://github.com/ChrisTitusTech/linutil.git
synced 2024-11-23 21:51:56 +00:00
Merge ae9d58bc57
into 76f8e6438b
This commit is contained in:
commit
81b83c5d1f
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",
|
||||
]
|
||||
|
|
|
@ -21,15 +21,11 @@ ratatui = "0.29.0"
|
|||
tui-term = "0.2.0"
|
||||
temp-dir = "0.1.14"
|
||||
time = { version = "0.3.36", features = ["local-offset", "macros", "formatting"] }
|
||||
unicode-width = "0.2.0"
|
||||
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]]
|
||||
name = "linutil"
|
||||
|
|
|
@ -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},
|
||||
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::<String>();
|
||||
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,26 +83,25 @@ impl FloatContent for ConfirmPrompt {
|
|||
})
|
||||
.collect::<Text>();
|
||||
|
||||
frame.render_widget(Clear, inner_area);
|
||||
frame.render_widget(List::new(paths_text), inner_area);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -118,8 +119,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"]),
|
||||
]),
|
||||
)
|
||||
|
|
|
@ -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<char> to handle multi-byte characters like emojis
|
||||
search_input: Vec<char>,
|
||||
in_search_mode: bool,
|
||||
input_position: usize,
|
||||
items: Vec<ListEntry>,
|
||||
// No complex string manipulation is done with completion_preview so we can use String unlike search_input
|
||||
completion_preview: Option<String>,
|
||||
}
|
||||
|
||||
|
@ -62,13 +64,11 @@ impl Filter {
|
|||
.collect();
|
||||
} else {
|
||||
self.items.clear();
|
||||
|
||||
let query_lower = self.search_input.iter().collect::<String>().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::<String>().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::<String>().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::<String>().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
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
use crate::{hint::Shortcut, theme::Theme};
|
||||
use ratatui::{
|
||||
crossterm::event::{KeyCode, KeyEvent},
|
||||
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 is_finished(&self) -> bool;
|
||||
fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>);
|
||||
|
@ -48,9 +47,9 @@ impl<Content: FloatContent + ?Sized> Float<Content> {
|
|||
.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);
|
||||
}
|
||||
|
||||
// Returns true if the floating window is finished.
|
||||
|
|
|
@ -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},
|
||||
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<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
|
||||
|
@ -53,243 +28,185 @@ const SYNTAX_HIGHLIGHT_STYLES: [(&str, anstyle::Style); 8] = [
|
|||
("number", style!(181, 206, 168)), // light green
|
||||
];
|
||||
|
||||
fn get_highlighted_string(s: &str) -> Option<String> {
|
||||
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::<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)
|
||||
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::<Vec<_>>()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn get_lines_owned(s: &str) -> Vec<String> {
|
||||
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<Self> {
|
||||
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<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) {
|
||||
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::<VecDeque<_>>();
|
||||
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::<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
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use ratatui::{
|
||||
style::{Style, Stylize},
|
||||
text::{Line, Span},
|
||||
|
@ -64,7 +62,7 @@ impl Shortcut {
|
|||
Self {
|
||||
key_sequences: key_sequences
|
||||
.iter()
|
||||
.map(|s| Span::styled(Cow::<'static, str>::Borrowed(s), Style::default().bold()))
|
||||
.map(|s| Span::styled(*s, Style::default().bold()))
|
||||
.collect(),
|
||||
desc,
|
||||
}
|
||||
|
|
|
@ -7,15 +7,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::{
|
||||
|
@ -27,6 +20,11 @@ use ratatui::{
|
|||
Terminal,
|
||||
};
|
||||
use state::AppState;
|
||||
use std::{
|
||||
io::{self, stdout},
|
||||
path::PathBuf,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
// Linux utility toolbox
|
||||
#[derive(Debug, Parser)]
|
||||
|
|
|
@ -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,7 +7,7 @@ use portable_pty::{
|
|||
use ratatui::{
|
||||
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
|
||||
layout::{Rect, Size},
|
||||
style::{Color, Style, Stylize},
|
||||
style::{Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders},
|
||||
Frame,
|
||||
|
@ -22,6 +22,7 @@ use tui_term::{
|
|||
vt100::{self, Screen},
|
||||
widget::PseudoTerminal,
|
||||
};
|
||||
|
||||
pub struct RunningCommand {
|
||||
/// A buffer to save all the command output (accumulates, until the command exits)
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
|
@ -42,7 +43,7 @@ pub struct RunningCommand {
|
|||
}
|
||||
|
||||
impl FloatContent for RunningCommand {
|
||||
fn draw(&mut self, frame: &mut Frame, area: Rect) {
|
||||
fn draw(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
// Calculate the inner size of the terminal area, considering borders
|
||||
let inner_size = Size {
|
||||
width: area.width - 2, // Adjust for border width
|
||||
|
@ -64,13 +65,13 @@ impl FloatContent for RunningCommand {
|
|||
Line::from(
|
||||
Span::default()
|
||||
.content("SUCCESS!")
|
||||
.style(Style::default().fg(Color::Green).reversed()),
|
||||
.style(Style::default().fg(theme.success_color()).reversed()),
|
||||
)
|
||||
} else {
|
||||
Line::from(
|
||||
Span::default()
|
||||
.content("FAILED!")
|
||||
.style(Style::default().fg(Color::Red).reversed()),
|
||||
.style(Style::default().fg(theme.fail_color()).reversed()),
|
||||
)
|
||||
};
|
||||
|
||||
|
|
|
@ -229,7 +229,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()
|
||||
|
@ -461,8 +461,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),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
@ -748,11 +748,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -798,7 +796,7 @@ impl AppState {
|
|||
.map(|node| node.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let prompt = ConfirmPrompt::new(&cmd_names[..]);
|
||||
let prompt = ConfirmPrompt::new(&cmd_names);
|
||||
self.focus = Focus::ConfirmationPrompt(Float::new(Box::new(prompt), 40, 40));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue
Block a user