use crate::{float::FloatContent, hint::Shortcut, shortcuts, theme::Theme};
use linutil_core::Command;
use ratatui::{
crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind},
prelude::*,
symbols::border,
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};
use tree_sitter_bash as hl_bash;
use tree_sitter_highlight::{self as hl, HighlightEvent};
macro_rules! style {
($r:literal, $g:literal, $b:literal) => {{
Style::new().fg(Color::Rgb($r, $g, $b))
}};
}
const SYNTAX_HIGHLIGHT_STYLES: [(&str, Style); 8] = [
("function", style!(220, 220, 170)), ("string", style!(206, 145, 120)), ("property", style!(156, 220, 254)), ("comment", style!(92, 131, 75)), ("embedded", style!(206, 145, 120)), ("constant", style!(79, 193, 255)), ("keyword", style!(197, 134, 192)), ("number", style!(181, 206, 168)), ];
pub struct FloatingText<'a> {
inner_area_size: (usize, usize),
mode_title: String,
processed_text: Text<'a>,
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",
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') {
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));
}
}
}
}
}
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 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) {
self.scroll.0 = self.scroll.0.saturating_sub(1);
}
fn scroll_left(&mut self) {
self.scroll.1 = self.scroll.1.saturating_sub(1);
}
fn scroll_right(&mut self) {
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, theme: &Theme) {
let block = Block::default()
.borders(Borders::ALL)
.border_set(border::PLAIN)
.border_style(Style::default().fg(theme.focused_color()))
.title(self.mode_title.as_str())
.title_alignment(Alignment::Center)
.title_style(Style::default().fg(theme.tab_color()).bold())
.style(Style::default());
let inner_area = block.inner(area);
self.inner_area_size = (inner_area.width as usize, inner_area.height as usize);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
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)
};
frame.render_widget(paragraph, inner_area);
}
fn handle_mouse_event(&mut self, event: &MouseEvent) -> bool {
match event.kind {
MouseEventKind::ScrollDown => self.scroll_down(),
MouseEventKind::ScrollUp => self.scroll_up(),
MouseEventKind::ScrollLeft => self.scroll_left(),
MouseEventKind::ScrollRight => self.scroll_right(),
_ => {}
}
false
}
fn handle_key_event(&mut self, key: &KeyEvent) -> bool {
use KeyCode::{Char, Down, Left, Right, Up};
match key.code {
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
}
fn is_finished(&self) -> bool {
true
}
fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>) {
(
&self.mode_title,
shortcuts!(
("Scroll down", ["j", "Down"]),
("Scroll up", ["k", "Up"]),
("Scroll left", ["h", "Left"]),
("Scroll right", ["l", "Right"]),
("Close window", ["Enter", "q"])
),
)
}
}