use crossterm::{ event::{ DisableMouseCapture, EnableMouseCapture, KeyEventKind, KeyEventState, MouseButton, MouseEventKind, }, terminal::{EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui_themes::{Theme, ThemeName}; use std::{ fs::{self, DirEntry}, io::{self, Stdout}, ops::ControlFlow, path::{Path, PathBuf}, process::exit, sync::Arc, time::Duration, }; use tui_widget_list::{ListBuilder, ListView}; use crate::tui::{ filter::{Filter, FilterKind, Matcher}, log_viewer::{ LogViewer, input::{FieldMatcher, InputState, InputTarget}, }, reader::LogfileReader, widgets::{hyperlink::Hyperlink, items::Items, last_error::LastError, styled::IntoStyled}, }; use ratatui::{ DefaultTerminal, Terminal, buffer::Buffer, crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, layout::{Constraint, HorizontalAlignment, Layout, Rect}, prelude::CrosstermBackend, style::Style, text::Line, widgets::{ Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap, }, }; pub mod filter; pub mod log_viewer; pub mod model; pub mod processing; pub mod reader; pub mod widgets; const HELP_TEXT: &str = "Generic: ? show help cancel focus or close tab u undo r redo pgdwn&pgup move page down&up move single j&k move single Home/G move to start <- / backspace / h exit nested view -> / enter / l enter nested view ─────────────────────────────────────────────────────── targeting logs: f fields t the selected log s surrounding element either a field after `f` or the text of the current log: p ... with a prefix / ... matching a regex e ... equal to selected c ... containing ─────────────────────────────────────────────────────── perform action on selected target: alt-d delete alt-i inline "; fn setup_terminal() -> io::Result>> { let mut stdout = io::stdout(); crossterm::terminal::enable_raw_mode()?; { use ::std::io::Write; crossterm::queue!(stdout, EnterAlternateScreen, EnableMouseCapture) .and_then(|()| ::std::io::Write::flush(stdout.by_ref())) }?; Terminal::new(CrosstermBackend::new(stdout)) } fn teardown_terminal(_terminal: &mut Terminal>) -> io::Result<()> { let mut stdout = io::stdout(); crossterm::terminal::disable_raw_mode()?; crossterm::execute!(stdout, LeaveAlternateScreen, DisableMouseCapture,)?; Ok(()) } pub fn run(logs_dir: PathBuf, compiler_root: Option, theme: ThemeName) { let mut terminal = setup_terminal().unwrap(); let theme = Theme::new(theme); let app_result = App::new(logs_dir, compiler_root, theme).run(&mut terminal); teardown_terminal(&mut terminal).unwrap(); if let Err(e) = app_result { eprintln!("error in tui: {e:?}"); exit(1); } } enum Tab { FileChooser { files: Vec, state: ListState, last_height: usize, last_offset: usize, }, LogViewer(LogViewer), Empty, Help, } impl Tab { fn name(&self, path: Option<&Path>) -> String { match (self, path) { (Tab::Empty, _) => "dummy".to_string(), (Tab::FileChooser { .. }, _) => "choose a file".to_string(), (Tab::LogViewer(_), Some(path)) => format!("logs of {}", path.display()), (Tab::LogViewer(_), None) => "logs".to_string(), (Tab::Help, _) => "help".to_string(), } } } struct App { tabs: Vec, logs_dir: PathBuf, compiler_root: Option, current_file: Option, theme: Theme, last_error: LastError, } impl App { fn new(logs_dir: PathBuf, compiler_root: Option, theme: Theme) -> Self { let mut res = Self { tabs: Vec::new(), current_file: None, logs_dir, compiler_root, theme, last_error: LastError::new(), }; res.replace_tab(res.choose_file()); res } fn current_file_path(&self) -> Option { self.current_file.as_ref().map(|i| i.path()) } fn replace_tab(&mut self, tab: Tab) { if let Some(last) = self.tabs.last_mut() { *last = tab; } else { self.tabs = vec![tab]; } } fn push_tab(&mut self, tab: Tab) { self.tabs.push(tab); } fn pop_tab(&mut self) { let _ = self.tabs.pop(); } fn choose_file(&self) -> Tab { fn init(logs_dir: &Path) -> io::Result> { let mut files = Vec::new(); for file in fs::read_dir(logs_dir)? { let file = file?; if file.path().extension().is_some_and(|ext| ext == "log") { files.push(file); } } Ok(files) } match init(&self.logs_dir) { Ok(files) => Tab::FileChooser { files, state: ListState::default(), last_height: 0, last_offset: 0, }, Err(_) => Tab::Empty, } } fn current_tab(&mut self) -> &mut Tab { if self.tabs.is_empty() { self.tabs.push(Tab::Empty); } self.tabs.last_mut().unwrap() } fn start_log_viewer(&mut self, path: PathBuf) { let filters_path = path.with_added_extension("filters.json"); match LogfileReader::new(&path) { Ok(i) => { self.current_file = Some(i.clone()); if let Some(first) = i.first() { self.replace_tab(Tab::LogViewer(LogViewer::new( first, filters_path, self.last_error.clone(), ))); } else { panic!("no log entries"); } } Err(_) => { panic!() } } } fn handle_current_tab_keycode(&mut self, key: KeyEvent) { match self.tabs.last_mut().unwrap() { Tab::Help => {} Tab::Empty => {} Tab::FileChooser { files, state, last_height, last_offset: _, } => match key.code { KeyCode::Char('j') | KeyCode::Down => state.select_next(), KeyCode::Char('k') | KeyCode::Up => state.select_previous(), KeyCode::PageUp => state.scroll_up_by(*last_height as u16), KeyCode::PageDown => state.scroll_down_by(*last_height as u16), KeyCode::Char('G') | KeyCode::Home => state.select_first(), KeyCode::Char('g') | KeyCode::End => state.select_last(), KeyCode::Enter => { if let Some(selected) = state.selected() && let Some(selected) = files.get(selected) { let path = selected.path(); self.start_log_viewer(path); } } _ => {} }, Tab::LogViewer(lv) => match key.code { // delete KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => { if let InputState::None = lv.input_state { lv.input_state = InputState::Target(InputTarget::This); } if let InputState::Target(t) = lv.input_state.clone() && let Some(m) = Matcher::from_input(t, lv) { lv.add_filter(Arc::new(Filter { matcher: m, kind: FilterKind::Remove, })); lv.input_state.reset(); } } // inline KeyCode::Char('i') if key.modifiers.contains(KeyModifiers::ALT) => { if let InputState::None = lv.input_state { lv.input_state = InputState::Target(InputTarget::This); } if let InputState::Target(t) = lv.input_state.clone() && let Some(m) = Matcher::from_input(t, lv) { lv.add_filter(Arc::new(Filter { matcher: m, kind: FilterKind::Inline, })); lv.input_state.reset(); } } KeyCode::Char(c) if lv.input_state.captures_input() => { lv.input_state.capture_string().unwrap().push(c); } KeyCode::Backspace if lv.input_state.captures_input() => { lv.input_state.capture_string().unwrap().pop(); } KeyCode::Esc if lv.input_state.captures_input() => { if let InputState::Target(InputTarget::Fields(s @ Some(_))) = &mut lv.input_state { *s = None } else { lv.input_state.reset(); } } _ if lv.input_state.captures_input() => {} KeyCode::Char('j') | KeyCode::Down => lv.next(), KeyCode::Char('k') | KeyCode::Up => lv.prev(), KeyCode::PageUp => lv.page_up(), KeyCode::PageDown => lv.page_down(), KeyCode::Char('G') | KeyCode::Home => lv.home(), KeyCode::Char('g') | KeyCode::End => todo!(), KeyCode::Backspace | KeyCode::Left => lv.back(), KeyCode::Right => lv.enter(), KeyCode::Enter => lv.enter(), KeyCode::Char('u') => lv.undo(), KeyCode::Char('r') => lv.redo(), KeyCode::Char('f') => { lv.input_state.target(InputTarget::Fields(None)); lv.footer_list.select(Some(0)); } KeyCode::Esc => lv.input_state.reset(), KeyCode::Char('s') if !lv.view.cursor.toplevel() => { lv.input_state.target(InputTarget::Surround); } KeyCode::Char('t') => { lv.input_state.target(InputTarget::This); } KeyCode::Char('/') => { let v = FieldMatcher::Regex(String::new()); if let InputState::Target(InputTarget::Fields(f @ None)) = &mut lv.input_state { *f = Some(v); } else { lv.input_state.target(InputTarget::Text(v)); } } KeyCode::Char('p') => { let v = FieldMatcher::Prefix(String::new()); if let InputState::Target(InputTarget::Fields(f @ None)) = &mut lv.input_state { *f = Some(v); } else { lv.input_state.target(InputTarget::Text(v)); } } KeyCode::Char('c') => { let v = FieldMatcher::Contains(String::new()); if let InputState::Target(InputTarget::Fields(f @ None)) = &mut lv.input_state { *f = Some(v); } else { lv.input_state.target(InputTarget::Text(v)); } } KeyCode::Char('e') => { let v = FieldMatcher::EqualTo; if let InputState::Target(InputTarget::Fields(f @ None)) = &mut lv.input_state { *f = Some(v); } else { lv.input_state.target(InputTarget::Text(v)); } } _ => {} }, } } fn handle_generic_keycode( &mut self, key: KeyEvent, terminal: &mut DefaultTerminal, ) -> ControlFlow<()> { let num_tabs = self.tabs.len(); match key.code { KeyCode::Char('q') => return ControlFlow::Break(()), KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { return ControlFlow::Break(()); } KeyCode::Char('z') if key.modifiers.contains(KeyModifiers::CONTROL) => { ratatui::restore(); let self_pid = nix::unistd::getpid(); let _ = nix::sys::signal::kill(self_pid, nix::sys::signal::SIGTSTP); *terminal = setup_terminal().unwrap(); } KeyCode::Char('?') => { if matches!(self.current_tab(), Tab::Help) { self.pop_tab(); } else { self.push_tab(Tab::Help); } } KeyCode::Esc if num_tabs > 1 => { self.pop_tab(); } _ => { self.handle_current_tab_keycode(key); } } ControlFlow::Continue(()) } fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { loop { self.last_error.check_expired(); terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?; // update every 250ms or more if there's an event if !event::poll(Duration::from_millis(250))? { continue; } match event::read()? { Event::Key(key) => { // to initialize, but we then do get manually for borrow reasons self.current_tab(); // always active if self.handle_generic_keycode(key, terminal).is_break() { break Ok(()); } } Event::FocusGained => {} Event::FocusLost => {} Event::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollDown => { if self .handle_generic_keycode( KeyEvent { code: KeyCode::Down, modifiers: KeyModifiers::empty(), kind: KeyEventKind::Press, state: KeyEventState::empty(), }, terminal, ) .is_break() { break Ok(()); } } Event::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollUp => { if self .handle_generic_keycode( KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::empty(), kind: KeyEventKind::Press, state: KeyEventState::empty(), }, terminal, ) .is_break() { break Ok(()); } } Event::Mouse(mouse_event) => { if let MouseEventKind::Up(MouseButton::Left) = mouse_event.kind { if let Tab::LogViewer(lv) = self.current_tab() { lv.click(mouse_event.row); } else if let Tab::FileChooser { files, state, last_height: _, last_offset, } = self.current_tab() { if mouse_event.row as usize > *last_offset { let row = mouse_event.row as usize - *last_offset; if row < files.len() { if state.selected() == Some(row) && let Some(selected) = files.get(row) { let path = selected.path(); self.start_log_viewer(path); } else { state.select(Some(row)); } } } } } } Event::Paste(_) => {} Event::Resize(_, _) => {} } } } fn styles(&self) -> Styles { let palette = self.theme.palette(); let default = Style::new().fg(palette.fg).bg(palette.bg); let highlighted = Style::new().fg(palette.accent).bg(palette.selection); let border = Style::new().fg(palette.fg).bg(palette.bg); let border_highlighted = Style::new().fg(palette.secondary).bg(palette.bg); let error = Style::new().fg(palette.error).bg(palette.bg); Styles { default, highlighted, border, border_highlighted, error, } } pub fn block_around(&self, area: Rect, buf: &mut Buffer, selected: bool) -> Rect { let styles = self.styles(); let block = Block::bordered() .style(styles.default) .border_style(if selected { styles.border_highlighted } else { styles.border }); let inner = block.inner(area); block.render(area, buf); inner } } pub struct Styles { default: Style, highlighted: Style, border: Style, border_highlighted: Style, error: Style, } impl Widget for &mut App { fn render(self, area: Rect, buf: &mut Buffer) where Self: Sized, { let styles = self.styles(); let [header_area, main_area, footer_area] = Layout::vertical([ Constraint::Length(2), Constraint::Fill(1), Constraint::Ratio(1, 4), ]) .areas(area); let [_, popup_area, _] = Layout::vertical([ Constraint::Fill(1), Constraint::Min(40), Constraint::Fill(1), ]) .areas(area); let [_, popup_area, _] = Layout::horizontal([ Constraint::Fill(1), Constraint::Min(40), Constraint::Fill(1), ]) .areas(popup_area); let (footer_focused, header_focused) = match self.current_tab() { Tab::Help => (false, false), Tab::FileChooser { .. } => (false, true), Tab::LogViewer(lv) => { let target_fields = matches!(lv.input_state, InputState::Target(InputTarget::Fields(..))); (target_fields, !target_fields) } Tab::Empty => (false, false), }; let main_area = self.block_around(main_area, buf, header_focused); let footer_area = self.block_around(footer_area, buf, footer_focused); let [left, middle, right] = Layout::horizontal([ Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), Constraint::Fill(1), ]) .areas(header_area); let current_file_path = self.current_file_path(); let breadcrumbs = self.tabs[..self.tabs.len() - 1] .iter() .map(|i| i.name(current_file_path.as_deref())) .collect::>() .join("►"); Paragraph::new(breadcrumbs) .wrap(Wrap { trim: false }) .style(styles.default) .render(left, buf); Paragraph::new(self.current_tab().name(current_file_path.as_deref())) .alignment(HorizontalAlignment::Center) .wrap(Wrap { trim: false }) .style(styles.default) .render(middle, buf); let [right, error] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(right); Paragraph::new("").style(styles.default).render(right, buf); for tab in &mut self.tabs { match tab { Tab::FileChooser { files, state, last_height, last_offset, } => { let list = List::new(files.iter().map(|file| { ListItem::new(file.file_name().to_string_lossy().into_owned()) })) .style(styles.default) .highlight_style(styles.highlighted); *last_height = main_area.height as usize; *last_offset = main_area.y as usize; StatefulWidget::render(list, main_area, buf, state); } Tab::LogViewer(lv) => { lv.last_offset = main_area.y as usize; lv.update_num_items(main_area.height as usize); let (items, selected_offset) = lv .items(main_area.height as usize) .unwrap_or_else(|| (Vec::new(), 0)); lv.input_state.styled_ref(&styles).render(right, buf); Clear.render(footer_area, buf); let [first_line, footer_area] = Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]) .areas(footer_area); if let Some((e, _)) = lv.selected() { let rustc_root = self.compiler_root.as_ref(); let (file, line) = e.file_line_string(); if let Some(rustc_root) = rustc_root && let Ok(canonical_rustc_root) = rustc_root.canonicalize() { let full_file_path = canonical_rustc_root.join(&file); Hyperlink::new( Line::from(format!("In file: {}:{line}", file.display())) .style(styles.default), format!("file://{}", full_file_path.display()), ) .render(first_line, buf); } else { Line::from(format!("In file: {}:{line}", file.display())) .style(styles.default) .render(first_line, buf); } } Items::new( items, &lv.filters, selected_offset, &lv.input_state, lv.footer_list.selected.and_then(|idx| { lv.footer_fields() .get(idx) .map(|(a, b)| (a.clone(), b.clone())) }), self.last_error.clone(), ) .styled_ref(&styles) .render(main_area, buf); let items = lv.footer_fields(); lv.last_fields_offset = footer_area.y as usize; lv.last_fields_height = items.len(); let width = 20; let builder = ListBuilder::new(|cx| { let Some((k, v)) = &items.get(cx.index) else { return (Paragraph::new(""), 1); }; let mut res = Paragraph::new(format!("{k:width$} {v}")).wrap(Wrap { trim: false }); if cx.is_selected { res = res.style(styles.highlighted); } let height = res.line_count(footer_area.width) as u16; (res, height) }); let list = ListView::new(builder, items.len()).style(styles.default); StatefulWidget::render(list, footer_area, buf, &mut lv.footer_list); } Tab::Empty => {} Tab::Help => { Clear.render(popup_area, buf); let popup_area = { let block = Block::bordered() .title_top("help") .style(styles.default) .padding(Padding::symmetric(3, 1)) .border_style(styles.border_highlighted); let inner = block.inner(popup_area); block.render(popup_area, buf); inner }; Paragraph::new(HELP_TEXT).render(popup_area, buf); } } self.last_error.clone().styled(&styles).render(error, buf); } } }