748 lines
29 KiB
Rust
748 lines
29 KiB
Rust
use itertools::Itertools;
|
|
use ratatui_themes::{Theme, ThemeName};
|
|
use regex::bytes::Regex;
|
|
use std::{
|
|
fs::{self, DirEntry},
|
|
io,
|
|
ops::ControlFlow,
|
|
path::{Path, PathBuf},
|
|
process::exit,
|
|
rc::Rc,
|
|
};
|
|
use tui_widget_list::{ListBuilder, ListView};
|
|
|
|
use crate::tui::{
|
|
filter::FilterKind,
|
|
log_viewer::{InputState, InputTarget, LogViewer},
|
|
model::pretty_print_value,
|
|
};
|
|
use crate::tui::{
|
|
filter::{Filter, Matcher},
|
|
log_viewer::FieldMatcher,
|
|
reader::LogfileReader,
|
|
};
|
|
use ratatui::{
|
|
DefaultTerminal,
|
|
buffer::Buffer,
|
|
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
|
|
layout::{Constraint, HorizontalAlignment, Layout, Rect},
|
|
style::Style,
|
|
text::{Line, Span, Text},
|
|
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;
|
|
|
|
const HELP_TEXT: &str = "Generic:
|
|
? show help
|
|
<esc> 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
|
|
r ... matching a regex
|
|
/ ... matching a regex
|
|
e ... equal to selected
|
|
c ... containing
|
|
|
|
───────────────────────────────────────────────────────
|
|
perform action on selected target:
|
|
|
|
control-d delete
|
|
control-i inline
|
|
";
|
|
|
|
pub fn run(logs_dir: PathBuf, compiler_root: Option<PathBuf>, theme: ThemeName) {
|
|
let terminal = ratatui::init();
|
|
let theme = Theme::new(theme);
|
|
let app_result = App::new(logs_dir, compiler_root, theme).run(terminal);
|
|
ratatui::restore();
|
|
|
|
if let Err(e) = app_result {
|
|
eprintln!("error in tui: {e:?}");
|
|
exit(1);
|
|
}
|
|
}
|
|
|
|
enum Tab {
|
|
FileChooser {
|
|
files: Vec<DirEntry>,
|
|
state: ListState,
|
|
last_height: 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<Tab>,
|
|
logs_dir: PathBuf,
|
|
compiler_root: Option<PathBuf>,
|
|
current_file: Option<LogfileReader>,
|
|
theme: Theme,
|
|
}
|
|
|
|
impl App {
|
|
fn new(logs_dir: PathBuf, compiler_root: Option<PathBuf>, theme: Theme) -> Self {
|
|
let mut res = Self {
|
|
tabs: Vec::new(),
|
|
current_file: None,
|
|
logs_dir,
|
|
compiler_root,
|
|
theme,
|
|
};
|
|
res.replace_tab(res.choose_file());
|
|
res
|
|
}
|
|
|
|
fn current_file_path(&self) -> Option<PathBuf> {
|
|
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<Vec<DirEntry>> {
|
|
let mut files = Vec::new();
|
|
|
|
for file in fs::read_dir(logs_dir)? {
|
|
let file = file?;
|
|
files.push(file);
|
|
}
|
|
|
|
Ok(files)
|
|
}
|
|
|
|
match init(&self.logs_dir) {
|
|
Ok(files) => Tab::FileChooser {
|
|
files,
|
|
state: ListState::default(),
|
|
last_height: 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 handle_current_tab_keycode(&mut self, key: KeyEvent) {
|
|
match self.tabs.last_mut().unwrap() {
|
|
Tab::Help => {}
|
|
Tab::Empty => {}
|
|
Tab::FileChooser {
|
|
files,
|
|
state,
|
|
last_height,
|
|
} => 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)
|
|
{
|
|
match LogfileReader::new(&selected.path()) {
|
|
Ok(i) => {
|
|
self.current_file = Some(i.clone());
|
|
self.replace_tab(Tab::LogViewer(LogViewer::new(i.iter())));
|
|
}
|
|
Err(_) => {
|
|
panic!()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
},
|
|
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(Rc::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(Rc::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('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.stack.is_empty() => {
|
|
lv.input_state.target(InputTarget::Surround);
|
|
}
|
|
KeyCode::Char('t') => {
|
|
lv.input_state.target(InputTarget::This);
|
|
}
|
|
KeyCode::Char('r') | 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 = ratatui::init();
|
|
}
|
|
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, mut terminal: DefaultTerminal) -> io::Result<()> {
|
|
loop {
|
|
terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
|
|
|
|
if let Event::Key(key) = event::read()? {
|
|
// to initialize, but we then do get manually for borrow reasons
|
|
self.current_tab();
|
|
|
|
// always active
|
|
if self.handle_generic_keycode(key, &mut terminal).is_break() {
|
|
break Ok(());
|
|
}
|
|
|
|
// match (key.code, self.tabs.last_mut().unwrap()) {
|
|
// (KeyCode::Tab, Tab::LogViewer(lv)) => {
|
|
// lv.switch_focus();
|
|
// }
|
|
// (KeyCode::Char('r'), Tab::LogViewer(lv)) => {
|
|
// let filter = initialize_filter(lv, Some(FilterKind::Remove));
|
|
// self.push_tab(Tab::CreateFilter { filter });
|
|
// }
|
|
// (KeyCode::Char('i'), Tab::LogViewer(lv)) => {
|
|
// let filter = initialize_filter(lv, Some(FilterKind::Inline));
|
|
// self.push_tab(Tab::CreateFilter { filter });
|
|
// }
|
|
|
|
// if let FilterSelection::Confirm = filter.selection {
|
|
// let filter_clone = filter.clone();
|
|
// if let Some(lv) = self.tabs.iter_mut().rev().find_map(|i| {
|
|
// if let Tab::LogViewer(lv) = i {
|
|
// Some(lv)
|
|
// } else {
|
|
// None
|
|
// }
|
|
// }) && let Some(filter) = filter_clone.validate()
|
|
// {
|
|
// lv.add_filter(Rc::new(filter));
|
|
// self.pop_tab();
|
|
|
|
// if let Tab::LogViewer(lv) = self.current_tab() {
|
|
// lv.footer_selected = false;
|
|
// }
|
|
// }
|
|
// } else {
|
|
// filter.selection.next();
|
|
// }
|
|
// },
|
|
// _ => {}
|
|
// }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Widget for &mut App {
|
|
fn render(self, area: Rect, buf: &mut Buffer)
|
|
where
|
|
Self: Sized,
|
|
{
|
|
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_selected = Style::new().fg(palette.secondary).bg(palette.bg);
|
|
|
|
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 = {
|
|
let block = Block::bordered()
|
|
.style(default)
|
|
.border_style(if header_focused {
|
|
border_selected
|
|
} else {
|
|
border
|
|
});
|
|
let inner = block.inner(main_area);
|
|
block.render(main_area, buf);
|
|
inner
|
|
};
|
|
|
|
let footer_area = {
|
|
let block = Block::bordered()
|
|
.style(default)
|
|
.border_style(if footer_focused {
|
|
border_selected
|
|
} else {
|
|
border
|
|
});
|
|
|
|
let inner = block.inner(footer_area);
|
|
block.render(footer_area, buf);
|
|
inner
|
|
};
|
|
|
|
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::<Vec<_>>()
|
|
.join("►");
|
|
Paragraph::new(breadcrumbs)
|
|
.wrap(Wrap { trim: false })
|
|
.render(left, buf);
|
|
|
|
Paragraph::new(self.current_tab().name(current_file_path.as_deref()))
|
|
.alignment(HorizontalAlignment::Center)
|
|
.wrap(Wrap { trim: false })
|
|
.render(middle, buf);
|
|
|
|
for tab in &mut self.tabs {
|
|
match tab {
|
|
Tab::FileChooser {
|
|
files,
|
|
state,
|
|
last_height,
|
|
} => {
|
|
let list = List::new(files.iter().map(|file| {
|
|
ListItem::new(file.file_name().to_string_lossy().into_owned())
|
|
}))
|
|
.style(default)
|
|
.highlight_style(highlighted);
|
|
|
|
*last_height = main_area.height as usize;
|
|
|
|
StatefulWidget::render(list, main_area, buf, state);
|
|
}
|
|
Tab::LogViewer(lv) => {
|
|
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));
|
|
|
|
Line::from(lv.input_state.show()).render(right, buf);
|
|
|
|
let list = List::new(items.into_iter().enumerate().map(
|
|
|(idx, (i, inline_depth))| {
|
|
let line_text = i.line_text(false, inline_depth);
|
|
|
|
let mut line = Line::from(line_text.clone());
|
|
if idx == selected_offset
|
|
&& let InputState::None | InputState::Target(InputTarget::This) =
|
|
lv.input_state
|
|
{
|
|
line = Line::from(line_text).style(highlighted);
|
|
} else if let InputState::Target(InputTarget::Text(s)) = &lv.input_state
|
|
&& let Some(msg) = i.message_or_name()
|
|
{
|
|
match s {
|
|
FieldMatcher::EqualTo => {
|
|
if lv
|
|
.selected()
|
|
.and_then(|(i, _)| i.message_or_name())
|
|
.is_some_and(|m| m == msg)
|
|
{
|
|
line = line.style(highlighted);
|
|
}
|
|
}
|
|
FieldMatcher::Prefix(p) => {
|
|
if msg.starts_with(p)
|
|
&& let Some(offset) = line_text.find(&msg)
|
|
{
|
|
let spans = vec![
|
|
Span::from(line_text[..offset].to_string()),
|
|
Span::from(
|
|
line_text[offset..(offset + p.len())]
|
|
.to_string(),
|
|
)
|
|
.style(highlighted),
|
|
Span::from(
|
|
line_text[(offset + p.len())..].to_string(),
|
|
),
|
|
];
|
|
|
|
line = Line::from(spans);
|
|
}
|
|
}
|
|
FieldMatcher::Regex(r) => {
|
|
if let Ok(regex) = Regex::new(r)
|
|
&& let Some(start_offset) = line_text.find(&msg)
|
|
&& let Some(m) = regex.find(msg.as_bytes())
|
|
{
|
|
let spans = vec![
|
|
Span::from(
|
|
line_text[..start_offset + m.start()]
|
|
.to_string(),
|
|
),
|
|
Span::from(
|
|
line_text[start_offset + m.start()
|
|
..start_offset + m.end()]
|
|
.to_string(),
|
|
)
|
|
.style(highlighted),
|
|
Span::from(
|
|
line_text[start_offset + m.end()..].to_string(),
|
|
),
|
|
];
|
|
|
|
line = Line::from(spans);
|
|
}
|
|
}
|
|
FieldMatcher::Contains(c) => {
|
|
if msg.contains(c)
|
|
&& let Some(start_offset) = line_text.find(&msg)
|
|
&& let Some(contains_offset) =
|
|
line_text[start_offset..].find(c)
|
|
{
|
|
let start = start_offset + contains_offset;
|
|
let spans = vec![
|
|
Span::from(line_text[..start].to_string()),
|
|
Span::from(
|
|
line_text[start..(start + c.len())].to_string(),
|
|
)
|
|
.style(highlighted),
|
|
Span::from(
|
|
line_text[(start + c.len())..].to_string(),
|
|
),
|
|
];
|
|
|
|
line = Line::from(spans);
|
|
}
|
|
}
|
|
}
|
|
} else if let InputState::Target(InputTarget::Fields(Some(f))) =
|
|
&lv.input_state
|
|
&& let Some(selected_field_offset) = lv.footer_list.selected
|
|
&& let Some((name, value)) =
|
|
lv.footer_fields().get(selected_field_offset)
|
|
&& let Some(current_log_value) = i.all_fields().fields.get(name)
|
|
{
|
|
let matches = match f {
|
|
FieldMatcher::EqualTo => value == current_log_value,
|
|
FieldMatcher::Prefix(v) => {
|
|
current_log_value.to_string().starts_with(v)
|
|
}
|
|
FieldMatcher::Regex(r) => Regex::new(r).is_ok_and(|i| {
|
|
i.is_match(current_log_value.to_string().as_bytes())
|
|
}),
|
|
FieldMatcher::Contains(c) => {
|
|
current_log_value.to_string().contains(c)
|
|
}
|
|
};
|
|
|
|
if matches {
|
|
line = line.style(highlighted);
|
|
}
|
|
}
|
|
|
|
let list_item = ListItem::new(line);
|
|
|
|
list_item
|
|
},
|
|
));
|
|
Widget::render(list, main_area, 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: {}", file.display())).style(default),
|
|
format!("file://{}:{line}", full_file_path.display()),
|
|
)
|
|
.render(first_line, buf);
|
|
} else {
|
|
Line::from(format!("In file: {}:{line}", file.display()))
|
|
.style(default)
|
|
.render(first_line, buf);
|
|
}
|
|
}
|
|
|
|
let items = lv.footer_fields();
|
|
let width = 20;
|
|
let builder = ListBuilder::new(|cx| {
|
|
let Some((k, v)) = &items.get(cx.index) else {
|
|
return (Paragraph::new(""), 1);
|
|
};
|
|
let contents = pretty_print_value(&v);
|
|
|
|
let mut res = Paragraph::new(format!("{k:width$} {contents}"))
|
|
.wrap(Wrap { trim: false });
|
|
|
|
if cx.is_selected {
|
|
res = res.style(highlighted);
|
|
}
|
|
|
|
let height = res.line_count(footer_area.width) as u16;
|
|
(res, height)
|
|
});
|
|
|
|
let list = ListView::new(builder, items.len()).style(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(default)
|
|
.padding(Padding::symmetric(3, 1))
|
|
.border_style(border_selected);
|
|
let inner = block.inner(popup_area);
|
|
block.render(popup_area, buf);
|
|
inner
|
|
};
|
|
|
|
Paragraph::new(HELP_TEXT).render(popup_area, buf);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Hyperlink<'content> {
|
|
text: Text<'content>,
|
|
url: String,
|
|
}
|
|
|
|
impl<'content> Hyperlink<'content> {
|
|
fn new(text: impl Into<Text<'content>>, url: impl Into<String>) -> Self {
|
|
Self {
|
|
text: text.into(),
|
|
url: url.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Widget for Hyperlink<'_> {
|
|
fn render(self, area: Rect, buffer: &mut Buffer) {
|
|
(&self.text).render(area, buffer);
|
|
|
|
// this is a hacky workaround for https://github.com/ratatui/ratatui/issues/902, a bug
|
|
// in the terminal code that incorrectly calculates the width of ANSI escape sequences. It
|
|
// works by rendering the hyperlink as a series of 2-character chunks, which is the
|
|
// calculated width of the hyperlink text.
|
|
for (i, two_chars) in self
|
|
.text
|
|
.to_string()
|
|
.chars()
|
|
.chunks(2)
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let text = two_chars.collect::<String>();
|
|
let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", self.url, text);
|
|
buffer[(area.x + i as u16 * 2, area.y)].set_symbol(hyperlink.as_str());
|
|
}
|
|
}
|
|
}
|