use crate::events::TuiEvent;
use crate::lua_console::{render_console_footer, render_console_output};
use crate::record;
use crate::settings::string_to_style;
use crate::state::{Mode, TuiState};
use crate::utils::{ansi_to_style, clean_ansi_text, parse_tabs, reverse_style};
use crossterm::ExecutableCommand;
use ratatui::{prelude::*, widgets::*};
use std::cmp::max;
use std::cmp::min;
use std::io;
use std::sync::mpsc;
pub struct TuiChrome {
pub terminal: Terminal<CrosstermBackend<io::Stdout>>,
pub tx: mpsc::Sender<TuiEvent>,
pub rx: mpsc::Receiver<TuiEvent>,
}
#[derive(Debug, Clone)]
struct StyleChange {
position: usize,
style: Style,
}
impl TuiChrome {
pub fn new() -> io::Result<TuiChrome> {
let terminal = ratatui::init();
let (tx, rx) = mpsc::channel();
Ok(TuiChrome { terminal, tx, rx })
}
pub fn update_state(&mut self, state: &mut TuiState) -> io::Result<()> {
let visible_width = self.terminal.size()?.width as i32;
if visible_width != state.visible_width as i32 {
state.visible_width = visible_width as usize;
}
let mut visible_lines = self.terminal.size()?.height as i32 - 2; if state.view_details && state.records.visible_records.len() > 0 {
if let Some(record) = state.records.visible_records.get(state.position - 1) {
visible_lines = visible_lines - 3 - 2; visible_lines = visible_lines - record.data.len() as i32;
visible_lines =
visible_lines - ((record.data.len() as i32) / (visible_width / 2)) as i32 + 1;
}
}
if visible_lines < 0 {
visible_lines = 0;
}
if visible_lines != state.visible_height as i32 {
state.visible_height = visible_lines as usize;
}
if state.pending_refresh {
self.refresh_screen(state);
state.pending_refresh = false;
}
Ok(())
}
pub fn render(&mut self, state: &TuiState) -> io::Result<()> {
let size = self.terminal.size()?;
if state.mode == Mode::LuaRepl {
self.render_repl_mode(state)?;
} else {
self.render_normal_mode(state, size)?;
}
self.set_cursor_for_mode(state, size)?;
Ok(())
}
fn render_repl_mode(&mut self, state: &TuiState) -> io::Result<()> {
let footer = Self::render_footer(state);
let repl_output = Self::render_repl_output(state);
self.terminal
.draw(|rect| {
let layout = Layout::default().direction(Direction::Vertical);
let chunks = layout
.constraints([Constraint::Min(0), Constraint::Length(1)].as_ref())
.split(rect.area());
rect.render_widget(repl_output, chunks[0]);
rect.render_widget(footer, chunks[1]);
})
.unwrap();
Ok(())
}
fn render_normal_mode(&mut self, state: &TuiState, size: Size) -> io::Result<()> {
let footer = Self::render_footer(state);
let mainarea = Self::render_records_table(state, size);
let constraints = self.calculate_layout_constraints(state, size);
let current_record = self.get_current_record_for_details(state);
self.terminal
.draw(|rect| {
let layout = Layout::default().direction(Direction::Vertical);
let chunks = layout.constraints(&constraints).split(rect.area());
rect.render_widget(mainarea, chunks[0]);
if let Some(record) = current_record {
rect.render_widget(Self::render_record_details(state, record), chunks[1]);
rect.render_widget(footer, chunks[2]);
} else {
rect.render_widget(footer, chunks[1]);
}
})
.unwrap();
Ok(())
}
fn calculate_layout_constraints(&self, state: &TuiState, size: Size) -> Vec<Constraint> {
if let Some(current_record) = self.get_current_record_for_details(state) {
let main_area_height = min(
size.height / 2,
current_record.data.len() as u16
+ 3
+ Self::record_wrap_lines_count(current_record, state) as u16,
);
vec![
Constraint::Min(0),
Constraint::Length(main_area_height),
Constraint::Length(1),
]
} else {
vec![Constraint::Min(0), Constraint::Length(1)]
}
}
fn get_current_record_for_details<'a>(
&self,
state: &'a TuiState,
) -> Option<&'a crate::record::Record> {
if state.view_details {
state.records.visible_records.get(state.position - 1)
} else {
None
}
}
fn set_cursor_for_mode(&mut self, state: &TuiState, size: Size) -> io::Result<()> {
self.terminal
.backend_mut()
.execute(crossterm::cursor::Hide)
.unwrap();
Ok(())
}
pub fn render_records_table<'a>(state: &'a TuiState, size: Size) -> Table<'a> {
let settings = &state.settings;
let current_rules = &state.current_rule;
let columns = ¤t_rules.columns;
let start = state.scroll_offset_top;
let end = min(
start + state.visible_height,
state.records.visible_records.len(),
);
let records = &state.records.visible_records;
let mut rows = Vec::new();
for record in records[start..end].iter() {
let mut cells: Vec<Cell> = columns
.iter()
.map(|column| {
let binding = "".to_string();
let value = record.data.get(&column.name).unwrap_or(&binding);
let cell =
Cell::from(Line::from(value.clone()).alignment(match column.align {
crate::settings::Alignment::Left => ratatui::layout::Alignment::Left,
crate::settings::Alignment::Center => {
ratatui::layout::Alignment::Center
}
crate::settings::Alignment::Right => ratatui::layout::Alignment::Right,
}));
cell
})
.collect();
let gutter = Cell::from(Self::get_gutter_from_record(state, &record));
cells.insert(0, gutter);
let cell = Cell::from(Self::render_record_original(&state, &record));
cells.push(cell);
let style = Self::get_row_style(state, &record);
let row = Row::new(cells).style(style);
rows.push(row);
}
let mut header = columns
.iter()
.map(|column| Cell::from(column.name.clone()))
.collect::<Vec<Cell>>();
header.insert(0, Cell::from(" "));
header.push(Cell::from("Original"));
let header = Row::new(header).style(Style::from(settings.colors.table.header));
let mut columns = columns
.iter()
.map(|column| column.width as u16)
.collect::<Vec<u16>>();
columns.insert(0, 1);
columns.push(min(
size.width as i32 - state.records.max_record_size("Original") as i32,
80,
) as u16);
let table = Table::new(rows, columns).header(header);
table
}
fn process_text_styles(text: &str, search: &str, initial_style: Style) -> Vec<StyleChange> {
let mut style_changes = Vec::new();
let mut current_style = initial_style;
let mut in_ansi_escape = false;
let mut ansi_code = String::new();
let mut plain_text = String::new();
let mut current_pos = 0;
for c in text.chars() {
if in_ansi_escape {
if c == 'm' {
in_ansi_escape = false;
current_style = ansi_to_style(current_style, &ansi_code);
style_changes.push(StyleChange {
position: current_pos,
style: current_style,
});
ansi_code.clear();
} else {
ansi_code.push(c);
}
} else if c == 0o33 as char {
in_ansi_escape = true;
ansi_code.push(c);
} else {
plain_text.push(c);
current_pos += 1;
}
}
if !search.is_empty() {
let text_lower = plain_text.to_lowercase();
let search_lower = search.to_lowercase();
let mut start = 0;
while let Some(pos) = text_lower[start..].find(&search_lower) {
let match_start = start + pos;
let match_end = match_start + search.len();
let style_at_match = style_changes
.iter()
.rev()
.find(|change| change.position <= match_start)
.map(|change| change.style)
.unwrap_or(initial_style);
style_changes.push(StyleChange {
position: match_start,
style: reverse_style(style_at_match),
});
style_changes.push(StyleChange {
position: match_end,
style: style_at_match,
});
start = match_end;
}
}
style_changes.sort_by_key(|change| change.position);
style_changes
}
fn render_record_original<'a>(state: &'a TuiState, record: &record::Record) -> Line<'a> {
let original = &record.original;
let original = parse_tabs(original);
let voffset = state.scroll_offset_left;
let initial_style = Self::get_row_style(state, &record);
let mut skip_chars = voffset;
let mut start_pos = 0;
for (i, c) in original.char_indices() {
if skip_chars > 0 {
skip_chars -= 1;
start_pos = i + c.len_utf8();
} else {
break;
}
}
let style_changes = Self::process_text_styles(&original, &state.search, initial_style);
let clean_original = clean_ansi_text(&original);
let mut spans = Vec::new();
let mut current_pos = start_pos;
let mut current_style = initial_style;
for change in style_changes {
if change.position > current_pos {
let text = clean_original[current_pos..change.position].to_string();
spans.push(Span::styled(text, current_style));
}
current_style = change.style;
current_pos = max(current_pos, change.position);
}
let text = &clean_original[current_pos..];
if text.len() > 0 {
spans.push(Span::styled(text.to_string(), current_style));
}
Line::from(spans)
}
pub fn get_gutter_from_record<'a>(state: &'a TuiState, record: &'a record::Record) -> Span<'a> {
let filters = &state.current_rule.filters;
for filter in filters {
if record.matches(&filter.expression) {
if filter.gutter.is_some() {
return Span::styled(
filter.gutter_symbol.clone(),
Style::from(filter.gutter.unwrap()),
);
}
}
}
return Span::styled(" ", Style::from(state.settings.colors.normal));
}
pub fn get_row_style(state: &TuiState, record: &record::Record) -> Style {
let settings = &state.settings;
let filters = &state.current_rule;
let mark = record.get("mark");
let is_mark = mark.is_some();
let is_selected = record.index == state.position;
match (is_selected, is_mark) {
(true, true) => return Style::from(settings.colors.mark_highlight),
(true, false) => return Style::from(settings.colors.highlight),
(false, true) => {
let style = string_to_style(mark.unwrap());
let style = if style.is_ok() {
style.unwrap()
} else {
settings.colors.mark
};
return Style::from(style);
}
_ => {}
}
for filter in &filters.filters {
if record.matches(&filter.expression) {
if filter.highlight.is_some() {
return Style::from(filter.highlight.unwrap());
}
}
}
return Style::from(settings.colors.normal);
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
for word in text.split_whitespace() {
let word_width = word.chars().count();
if current_width + word_width + (if current_width > 0 { 1 } else { 0 }) > width {
if !current_line.is_empty() {
lines.push(current_line.trim().to_string());
}
current_line = word.to_string();
current_width = word_width;
} else {
if current_width > 0 {
current_line.push(' ');
current_width += 1;
}
current_line.push_str(word);
current_width += word_width;
}
}
if !current_line.is_empty() {
lines.push(current_line.trim().to_string());
}
lines
}
fn record_wrap_lines_count(record: &record::Record, state: &TuiState) -> usize {
let title_width = state.visible_width - 2; let title_text = clean_ansi_text(&record.original);
let wrapped_title = Self::wrap_text(&title_text, title_width);
wrapped_title.len()
}
pub fn render_record_details<'a>(
state: &'a TuiState,
record: &'a record::Record,
) -> Paragraph<'a> {
let settings = &state.settings;
let mut lines = vec![];
let title_width = state.visible_width - 2; let title_text = clean_ansi_text(&record.original);
let wrapped_title = Self::wrap_text(&title_text, title_width);
for line in &wrapped_title {
lines.push(Line::from(vec![Span::styled(
line.clone(),
Style::from(settings.colors.details.title),
)]));
}
if !wrapped_title.is_empty() {
lines.push(Line::from(""));
}
let mut keys: Vec<&String> = record.data.keys().collect();
keys.sort();
for key in keys {
lines.push(Line::from(vec![
Span::styled(
format!("{} = ", key),
Style::from(settings.colors.details.key),
),
Span::styled(
record.data.get(key).unwrap(),
Style::from(settings.colors.details.value),
),
]));
}
let text = Text::from(lines);
Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::from(settings.colors.details.border)),
)
.style(Style::from(settings.colors.details.border))
}
pub fn render_footer<'a>(state: &'a TuiState) -> Block<'a> {
match state.mode {
Mode::Normal => Self::render_footer_normal(state),
Mode::Search => Self::render_footer_search(state),
Mode::Filter => Self::render_footer_filter(state),
Mode::Command => Self::render_footer_command(state),
Mode::Warning => Self::render_footer_warning(state),
Mode::ScriptInput => Self::render_footer_script_input(state),
Mode::LuaRepl => Self::render_footer_lua_repl(state),
}
}
pub fn render_footer_search(state: &TuiState) -> Block {
Self::render_textinput_block(
"Search",
&state.search,
state.text_edit_position,
state.settings.colors.footer.search,
&state.settings.global.symbols,
)
}
pub fn render_footer_filter(state: &TuiState) -> Block {
let style = if state.filter_ok {
state.settings.colors.footer.filter
} else {
Style::default().fg(Color::Red).bg(Color::Black)
};
Self::render_textinput_block(
"Filter",
&state.filter,
state.text_edit_position,
style,
&state.settings.global.symbols,
)
}
pub fn render_footer_command(state: &TuiState) -> Block {
Self::render_textinput_block(
"Command",
&state.command,
state.text_edit_position,
state.settings.colors.footer.command,
&state.settings.global.symbols,
)
}
pub fn render_footer_warning(state: &TuiState) -> Block {
Block::default().title(state.warning.clone()).style(
Style::default()
.fg(Color::Black)
.bg(Color::LightYellow)
.bold(),
)
}
pub fn render_footer_script_input(state: &TuiState) -> Block {
Self::render_textinput_block(
&state.script_prompt,
&state.script_input,
state.text_edit_position,
state.settings.colors.footer.command, &state.settings.global.symbols,
)
}
pub fn render_footer_lua_repl(state: &TuiState) -> Block {
render_console_footer(&state.lua_console, &state.settings)
}
pub fn render_repl_output<'a>(state: &'a TuiState) -> Paragraph<'a> {
render_console_output(&state.lua_console, state.visible_height)
}
pub fn render_tag(
spans: &mut Vec<Span>,
label: &str,
value: &str,
style: Style,
symbols: &crate::settings::SymbolSettings,
) {
let rstyle = reverse_style(style);
spans.push(Span::styled(
format!("{}{}{}", symbols.tag_initial, label, symbols.tag_mid_left),
rstyle,
));
spans.push(Span::styled(
format!("{}{}{}", symbols.tag_mid_right, value, symbols.tag_end),
style,
));
}
pub fn render_textinput_block<'a>(
label: &'a str,
value: &'a str,
position: usize,
style: Style,
symbols: &crate::settings::SymbolSettings,
) -> Block<'a> {
let mut spans = vec![];
let rstyle = reverse_style(style);
spans.push(Span::styled(
format!("{}{}{}", symbols.tag_initial, label, symbols.tag_mid_left),
rstyle,
));
let before_cursor = value.chars().take(position).collect::<String>();
let cursor = value.chars().nth(position).unwrap_or(' ');
let after_cursor = value.chars().skip(position + 1).collect::<String>();
spans.push(Span::styled(
format!("{}{}", symbols.tag_mid_right, before_cursor),
style,
));
spans.push(Span::styled(cursor.to_string(), rstyle));
spans.push(Span::styled(
format!("{}{}", after_cursor, symbols.tag_end),
style,
));
let line = Line::from(spans);
Block::default().title(line)
}
pub fn render_footer_normal(state: &TuiState) -> Block {
let mut spans = vec![];
Self::render_tag(
&mut spans,
"F1",
"help",
state.settings.colors.footer.other,
&state.settings.global.symbols,
);
Self::render_tag(
&mut spans,
":",
"commands",
state.settings.colors.footer.other,
&state.settings.global.symbols,
);
if state.search != "" {
Self::render_tag(
&mut spans,
"Search",
&state.search,
state.settings.colors.footer.search,
&state.settings.global.symbols,
);
}
if state.filter != "" {
Self::render_tag(
&mut spans,
"Filter",
&state.filter,
state.settings.colors.footer.filter,
&state.settings.global.symbols,
);
}
Self::render_tag(
&mut spans,
"Rule",
&state.current_rule.name,
state.settings.colors.footer.rule,
&state.settings.global.symbols,
);
Self::render_tag(
&mut spans,
"Line",
format!(
" {:5} / {:5} ",
state.position, state.records.visible_records.len()
)
.as_str(),
state.settings.colors.footer.line_number,
&state.settings.global.symbols,
);
let right_line = Line::from(spans);
let version = format!("v{}", env!("CARGO_PKG_VERSION"));
let mut spans = vec![];
Self::render_tag(
&mut spans,
"Tailtales",
version.as_str(),
state.settings.colors.footer.version,
&state.settings.global.symbols,
);
let left_line = Line::from(spans);
Block::default()
.title_style(Style::default().fg(Color::Black).bg(Color::LightGreen))
.title(left_line)
.title(right_line.right_aligned())
}
fn refresh_screen(&mut self, _state: &TuiState) {
self.terminal
.draw(|rect| {
let chunks = Layout::default()
.constraints([Constraint::Percentage(100)].as_ref())
.split(rect.area());
rect.render_widget(
Block::default().style(Style::default().bg(Color::Black)),
chunks[0],
);
})
.unwrap();
}
}
impl Drop for TuiChrome {
fn drop(&mut self) {
ratatui::restore();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_record_original_tab_and_colors() {
let original = "\x1b[32mINFO\x1b[0m\tLog line\t\x1b[31m\tError\x1b[0m";
let state = TuiState::new().unwrap();
let record = record::Record::new(original.to_string());
let line = TuiChrome::render_record_original(&state, &record);
println!("line: {:?}", line);
assert_eq!(line.spans.len(), 3);
let line0 = line.spans.get(0).unwrap();
let line1 = line.spans.get(1).unwrap();
let line2 = line.spans.get(2).unwrap();
assert_eq!(line0.content, "INFO");
assert_eq!(line0.style.fg.unwrap(), Color::Green);
assert_eq!(line1.content, " Log line ");
assert!(line1.style.fg.is_none());
assert_eq!(line2.content, " Error");
assert_eq!(line2.style.fg.unwrap(), Color::Red);
}
#[test]
fn test_render_record_original_vscroll() {
let original = "\x1b[32mINFO\x1b[0m\tLog line\t\x1b[31m\tError\x1b[0m";
let mut state = TuiState::new().unwrap();
let record = record::Record::new(original.to_string());
let line = TuiChrome::render_record_original(&state, &record);
println!("line: {:?}", line);
let texts: Vec<Vec<&str>> = vec![
vec!["INFO", " Log line ", " Error"],
vec!["NFO", " Log line ", " Error"],
vec!["FO", " Log line ", " Error"],
vec!["O", " Log line ", " Error"],
vec![" Log line ", " Error"],
vec![" Log line ", " Error"],
vec![" Log line ", " Error"],
vec![" Log line ", " Error"],
vec!["Log line ", " Error"],
vec!["og line ", " Error"],
vec!["g line ", " Error"],
vec![" line ", " Error"],
vec!["line ", " Error"],
vec!["ine ", " Error"],
vec!["ne ", " Error"],
vec!["e ", " Error"],
vec![" ", " Error"],
vec![" ", " Error"],
vec![" ", " Error"],
vec![" ", " Error"],
vec![" ", " Error"],
vec![" ", " Error"],
vec![" ", " Error"],
vec![" ", " Error"],
vec![" Error"],
vec![" Error"],
vec![" Error"],
vec![" Error"],
vec![" Error"],
vec![" Error"],
vec![" Error"],
vec![" Error"],
vec!["Error"],
vec!["rror"],
vec!["ror"],
vec!["or"],
vec!["r"],
vec![],
];
for (i, text) in texts.iter().enumerate() {
state.scroll_offset_left = i;
let line = TuiChrome::render_record_original(&state, &record);
println!("line: {:?}", line);
assert_eq!(line.spans.len(), text.len());
for (j, span) in line.spans.iter().enumerate() {
assert_eq!(span.content, text[j]);
}
}
}
}