refactor logviewer

This commit is contained in:
Jana Dönszelmann 2026-02-25 11:34:30 +01:00
parent 7ea9d84228
commit 79639be9da
No known key found for this signature in database
9 changed files with 425 additions and 239 deletions

View file

@ -46,7 +46,7 @@ impl Matcher {
.all_fields() .all_fields()
.fields .fields
.get(name) .get(name)
.is_some_and(|v| value.matches(&pretty_print_value(&v))), .is_some_and(|v| value.matches(v)),
Matcher::Message { value } => { Matcher::Message { value } => {
entry.message_or_name().is_some_and(|v| value.matches(&v)) entry.message_or_name().is_some_and(|v| value.matches(&v))
} }
@ -59,7 +59,7 @@ impl Matcher {
let value = lv.footer_fields().get(lv.footer_list.selected?)?.clone(); let value = lv.footer_fields().get(lv.footer_list.selected?)?.clone();
Some(Self::Field { Some(Self::Field {
name: value.0, name: value.0,
value: MatcherValue::from_field_matcher(fm?, Some(value.1.to_string()))?, value: MatcherValue::from_field_matcher(fm?, Some(value.1))?,
}) })
} }
InputTarget::Text(fm) => Some(Self::Message { InputTarget::Text(fm) => Some(Self::Message {

View file

@ -4,7 +4,9 @@ use crate::tui::{
filter::Filter, filter::Filter,
model::LogEntry, model::LogEntry,
processing::{IntoLogStream, LogStream}, processing::{IntoLogStream, LogStream},
widgets::styled::Styled,
}; };
use ratatui::{buffer::Buffer, layout::Rect, text::Line, widgets::Widget};
use tui_widget_list::ListState; use tui_widget_list::ListState;
pub struct LogView { pub struct LogView {
@ -77,6 +79,17 @@ pub enum InputState {
Target(InputTarget), Target(InputTarget),
} }
impl Widget for Styled<'_, &InputState> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
Line::from(self.inner.show())
.style(self.styles.default)
.render(area, buf)
}
}
impl InputState { impl InputState {
pub fn capture_string(&mut self) -> Option<&mut String> { pub fn capture_string(&mut self) -> Option<&mut String> {
match self { match self {
@ -274,13 +287,12 @@ impl LogViewer {
self.last_height = num_visible_items; self.last_height = num_visible_items;
} }
pub fn footer_fields(&self) -> Vec<(String, serde_json::Value)> { pub fn footer_fields(&self) -> Vec<(String, String)> {
if let Some((selected, _)) = self.selected() { if let Some((selected, _)) = self.selected() {
let ret = match selected.as_ref() { let ret = match selected.as_ref() {
LogEntry::Single { .. } => Default::default(), LogEntry::Single { .. } => Default::default(),
LogEntry::Sub { sub_entries, .. } => sub_entries.last().and_then(|i| { LogEntry::Sub { sub_entries, .. } => sub_entries.last().and_then(|i| {
i.all_fields() i.all_fields()
.fields
.get_key_value("return") .get_key_value("return")
.map(|(k, v)| (k.clone(), v.clone())) .map(|(k, v)| (k.clone(), v.clone()))
}), }),

View file

@ -1,4 +1,3 @@
use itertools::Itertools;
use ratatui_themes::{Theme, ThemeName}; use ratatui_themes::{Theme, ThemeName};
use regex::bytes::Regex; use regex::bytes::Regex;
use std::{ use std::{
@ -15,11 +14,13 @@ use crate::tui::{
filter::FilterKind, filter::FilterKind,
log_viewer::{InputState, InputTarget, LogViewer}, log_viewer::{InputState, InputTarget, LogViewer},
model::pretty_print_value, model::pretty_print_value,
widgets::{hyperlink::Hyperlink, items::Items, line_text::Highlighted},
}; };
use crate::tui::{ use crate::tui::{
filter::{Filter, Matcher}, filter::{Filter, Matcher},
log_viewer::FieldMatcher, log_viewer::FieldMatcher,
reader::LogfileReader, reader::LogfileReader,
widgets::styled::IntoStyled,
}; };
use ratatui::{ use ratatui::{
DefaultTerminal, DefaultTerminal,
@ -27,7 +28,7 @@ use ratatui::{
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
layout::{Constraint, HorizontalAlignment, Layout, Rect}, layout::{Constraint, HorizontalAlignment, Layout, Rect},
style::Style, style::Style,
text::{Line, Span, Text}, text::Line,
widgets::{ widgets::{
Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap, Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap,
}, },
@ -38,6 +39,7 @@ pub mod log_viewer;
pub mod model; pub mod model;
pub mod processing; pub mod processing;
pub mod reader; pub mod reader;
pub mod widgets;
const HELP_TEXT: &str = "Generic: const HELP_TEXT: &str = "Generic:
? show help ? show help
@ -367,6 +369,42 @@ impl App {
} }
} }
} }
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);
Styles {
default,
highlighted,
border,
border_highlighted,
}
}
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
} else {
styles.border_highlighted
});
let inner = block.inner(area);
block.render(area, buf);
inner
}
}
struct Styles {
default: Style,
highlighted: Style,
border: Style,
border_highlighted: Style,
} }
impl Widget for &mut App { impl Widget for &mut App {
@ -374,12 +412,7 @@ impl Widget for &mut App {
where where
Self: Sized, Self: Sized,
{ {
let palette = self.theme.palette(); let styles = self.styles();
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([ let [header_area, main_area, footer_area] = Layout::vertical([
Constraint::Length(2), Constraint::Length(2),
Constraint::Fill(1), Constraint::Fill(1),
@ -411,32 +444,8 @@ impl Widget for &mut App {
Tab::Empty => (false, false), Tab::Empty => (false, false),
}; };
let main_area = { let main_area = self.block_around(main_area, buf, header_focused);
let block = Block::bordered() let footer_area = self.block_around(footer_area, buf, footer_focused);
.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([ let [left, middle, right] = Layout::horizontal([
Constraint::Ratio(1, 3), Constraint::Ratio(1, 3),
@ -453,16 +462,16 @@ impl Widget for &mut App {
.join(""); .join("");
Paragraph::new(breadcrumbs) Paragraph::new(breadcrumbs)
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.style(default) .style(styles.default)
.render(left, buf); .render(left, buf);
Paragraph::new(self.current_tab().name(current_file_path.as_deref())) Paragraph::new(self.current_tab().name(current_file_path.as_deref()))
.alignment(HorizontalAlignment::Center) .alignment(HorizontalAlignment::Center)
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.style(default) .style(styles.default)
.render(middle, buf); .render(middle, buf);
Line::from("-").style(default).render(right, buf); Paragraph::new("").style(styles.default).render(right, buf);
for tab in &mut self.tabs { for tab in &mut self.tabs {
match tab { match tab {
@ -474,8 +483,8 @@ impl Widget for &mut App {
let list = List::new(files.iter().map(|file| { let list = List::new(files.iter().map(|file| {
ListItem::new(file.file_name().to_string_lossy().into_owned()) ListItem::new(file.file_name().to_string_lossy().into_owned())
})) }))
.style(default) .style(styles.default)
.highlight_style(highlighted); .highlight_style(styles.highlighted);
*last_height = main_area.height as usize; *last_height = main_area.height as usize;
@ -488,129 +497,7 @@ impl Widget for &mut App {
.items(main_area.height as usize) .items(main_area.height as usize)
.unwrap_or_else(|| (Vec::new(), 0)); .unwrap_or_else(|| (Vec::new(), 0));
Line::from(lv.input_state.show()) lv.input_state.styled_ref(&styles).render(right, buf);
.style(default)
.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); Clear.render(footer_area, buf);
let [first_line, footer_area] = let [first_line, footer_area] =
@ -625,37 +512,50 @@ impl Widget for &mut App {
{ {
let full_file_path = canonical_rustc_root.join(&file); let full_file_path = canonical_rustc_root.join(&file);
Hyperlink::new( Hyperlink::new(
Line::from(format!("In file: {}", file.display())).style(default), Line::from(format!("In file: {}", file.display()))
.style(styles.default),
format!("file://{}:{line}", full_file_path.display()), format!("file://{}:{line}", full_file_path.display()),
) )
.render(first_line, buf); .render(first_line, buf);
} else { } else {
Line::from(format!("In file: {}:{line}", file.display())) Line::from(format!("In file: {}:{line}", file.display()))
.style(default) .style(styles.default)
.render(first_line, buf); .render(first_line, buf);
} }
} }
Items::new(
items,
selected_offset,
&lv.input_state,
lv.footer_list.selected.and_then(|idx| {
lv.footer_fields()
.get(idx)
.map(|(a, b)| (a.clone(), b.clone()))
}),
)
.styled_ref(&styles)
.render(main_area, buf);
let items = lv.footer_fields(); let items = lv.footer_fields();
let width = 20; let width = 20;
let builder = ListBuilder::new(|cx| { let builder = ListBuilder::new(|cx| {
let Some((k, v)) = &items.get(cx.index) else { let Some((k, v)) = &items.get(cx.index) else {
return (Paragraph::new(""), 1); return (Paragraph::new(""), 1);
}; };
let contents = pretty_print_value(&v);
let mut res = Paragraph::new(format!("{k:width$} {contents}")) let mut res =
.wrap(Wrap { trim: false }); Paragraph::new(format!("{k:width$} {v}")).wrap(Wrap { trim: false });
if cx.is_selected { if cx.is_selected {
res = res.style(highlighted); res = res.style(styles.highlighted);
} }
let height = res.line_count(footer_area.width) as u16; let height = res.line_count(footer_area.width) as u16;
(res, height) (res, height)
}); });
let list = ListView::new(builder, items.len()).style(default); let list = ListView::new(builder, items.len()).style(styles.default);
StatefulWidget::render(list, footer_area, buf, &mut lv.footer_list); StatefulWidget::render(list, footer_area, buf, &mut lv.footer_list);
} }
Tab::Empty => {} Tab::Empty => {}
@ -664,9 +564,9 @@ impl Widget for &mut App {
let popup_area = { let popup_area = {
let block = Block::bordered() let block = Block::bordered()
.title_top("help") .title_top("help")
.style(default) .style(styles.default)
.padding(Padding::symmetric(3, 1)) .padding(Padding::symmetric(3, 1))
.border_style(border_selected); .border_style(styles.border_highlighted);
let inner = block.inner(popup_area); let inner = block.inner(popup_area);
block.render(popup_area, buf); block.render(popup_area, buf);
inner inner
@ -678,40 +578,3 @@ impl Widget for &mut App {
} }
} }
} }
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());
}
}
}

View file

@ -10,6 +10,8 @@ use jiff::Timestamp;
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
use crate::tui::widgets::line_text::LineText;
pub fn pretty_print_value(v: &Value) -> String { pub fn pretty_print_value(v: &Value) -> String {
match v { match v {
Value::Null => "null".to_string(), Value::Null => "null".to_string(),
@ -112,10 +114,8 @@ impl LogEntry {
match self { match self {
LogEntry::Single { raw } => raw.fields.message().map(|i| i.to_string()), LogEntry::Single { raw } => raw.fields.message().map(|i| i.to_string()),
LogEntry::Sub { enter, .. } => { LogEntry::Sub { enter, .. } => {
if let Some(val) = enter.all_fields().fields.get("name") if let Some(val) = enter.all_fields().fields.get("name") {
&& let Some(s) = val.as_str() Some(val.clone())
{
Some(s.to_string())
} else { } else {
enter.fields.message().map(|i| i.to_string()) enter.fields.message().map(|i| i.to_string())
} }
@ -123,63 +123,87 @@ impl LogEntry {
} }
} }
pub fn line_text(&self, accessed: bool, inline_depth: usize) -> String { pub fn line_text(&self, inline_depth: usize) -> LineText {
const NO_MESSAGE: &str = "<no message>"; const NO_MESSAGE: &str = "<no message>";
const SPACES_BEFORE: &str = " "; const SPACES_BEFORE: &str = " ";
let indent = " >".repeat(inline_depth);
let single_field = |raw: &RawLogEntry| { let single_field = |raw: &RawLogEntry| {
raw.fields raw.fields
.message() .message()
.map(|i| i.to_string()) .map(|i| i.to_string())
.or_else(|| { .or_else(|| raw.fields.fields.get("return").map(|v| format!("{v}")))
raw.fields
.fields
.get("return")
.map(|v| format!("{}", pretty_print_value(v)))
})
.or_else(|| { .or_else(|| {
raw.fields raw.fields
.fields .fields
.iter() .iter()
.next() .next()
.map(|(k, v)| format!("{k} = {}", pretty_print_value(v))) .map(|(k, v)| format!("{k} = {v}"))
}) })
.unwrap_or_else(|| NO_MESSAGE.to_string()) .unwrap_or_else(|| NO_MESSAGE.to_string())
}; };
match self { match self {
LogEntry::Single { raw } => { LogEntry::Single { raw } => LineText::new(
format!("{SPACES_BEFORE}{indent}{}", single_field(raw)).into() SPACES_BEFORE.to_string(),
} single_field(raw),
self.message_or_name(),
inline_depth,
),
LogEntry::Sub { LogEntry::Sub {
enter, sub_entries, .. enter, sub_entries, ..
} => { } => {
if let Some(val) = enter.all_fields().fields.get("name") if let Some(val) = enter.all_fields().fields.get("name") {
&& let Some(s) = val.as_str() LineText::new(
{ format!(
format!( "{:4}⭣{:4}⇊ ",
"{:4}⭣{:4}⇊ ┃{indent}↪ {s}", sub_entries.len(),
sub_entries.len(), self.count().wrapping_sub(1)
self.count().wrapping_sub(1) ),
format!("{val}"),
self.message_or_name(),
inline_depth,
) )
} else { } else {
format!("{SPACES_BEFORE}{indent}{}", single_field(enter)).into() LineText::new(
SPACES_BEFORE.to_string(),
single_field(enter),
self.message_or_name(),
inline_depth,
)
} }
} }
} }
} }
} }
#[derive(Deserialize)]
struct LogFieldsWrapper {
#[serde(flatten)]
pub fields: BTreeMap<String, Value>,
}
impl From<LogFieldsWrapper> for LogFields {
fn from(value: LogFieldsWrapper) -> Self {
Self {
fields: value
.fields
.into_iter()
.map(|(k, v)| (k, pretty_print_value(&v)))
.collect(),
}
}
}
#[derive(Deserialize, Debug, Clone, Hash)] #[derive(Deserialize, Debug, Clone, Hash)]
#[serde(from = "LogFieldsWrapper")]
pub struct LogFields { pub struct LogFields {
#[serde(flatten)] #[serde(flatten)]
pub fields: BTreeMap<String, serde_json::Value>, pub fields: BTreeMap<String, String>,
} }
impl LogFields { impl LogFields {
pub fn message(&self) -> Option<&str> { pub fn message(&self) -> Option<&str> {
self.fields.get("message").and_then(|i| i.as_str()) self.fields.get("message").map(|i| i.as_str())
} }
pub fn merge(&self, other: &Self) -> Self { pub fn merge(&self, other: &Self) -> Self {
@ -192,8 +216,15 @@ impl LogFields {
.collect(), .collect(),
} }
} }
}
pub fn get(&self, key: impl AsRef<str>) -> Option<&String> {
self.fields.get(key.as_ref())
}
pub fn get_key_value(&self, key: impl AsRef<str>) -> Option<(&String, &String)> {
self.fields.get_key_value(key.as_ref())
}
}
#[derive(Deserialize, Debug, Hash)] #[derive(Deserialize, Debug, Hash)]
pub struct RawLogEntry { pub struct RawLogEntry {
pub timestamp: Timestamp, pub timestamp: Timestamp,

View file

@ -0,0 +1,39 @@
use itertools::Itertools;
use ratatui::{buffer::Buffer, layout::Rect, text::Text, widgets::Widget};
pub struct Hyperlink<'content> {
text: Text<'content>,
url: String,
}
impl<'content> Hyperlink<'content> {
pub 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());
}
}
}

128
src/tui/widgets/items.rs Normal file
View file

@ -0,0 +1,128 @@
use std::rc::Rc;
use ratatui::widgets::{List, ListItem, Widget};
use regex::Regex;
use serde_json::Value;
use crate::tui::{
log_viewer::{FieldMatcher, InputState, InputTarget},
model::{LogEntry, pretty_print_value},
widgets::{
line_text::Highlighted,
styled::{IntoStyled, Styled},
},
};
pub struct Items<'a> {
items: Vec<(Rc<LogEntry>, usize)>,
selected_offset: usize,
input_state: &'a InputState,
selected_footer_field: Option<(String, String)>,
}
impl<'a> Items<'a> {
pub fn new(
items: Vec<(Rc<LogEntry>, usize)>,
selected_offset: usize,
input_state: &'a InputState,
selected_footer_field: Option<(String, String)>,
) -> Self {
Self {
items,
selected_offset,
input_state,
selected_footer_field,
}
}
pub fn selected(&self) -> Option<&Rc<LogEntry>> {
self.items.get(self.selected_offset).map(|(s, _)| s)
}
}
impl Widget for Styled<'_, &Items<'_>> {
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized,
{
let list = List::new(self.inner.items.iter().enumerate().map(
|(idx, (i, inline_depth))| {
let line_text = i.line_text(*inline_depth);
let mut line = line_text.styled(&self.styles);
if idx == self.selected_offset
&& let InputState::None | InputState::Target(InputTarget::This) =
self.input_state
{
line.highlight(Highlighted::All);
} else if let InputState::Target(InputTarget::Text(s)) = self.input_state
&& let Some(msg) = &line.message_text
{
match s {
FieldMatcher::EqualTo => {
if self
.selected()
.and_then(|i| i.message_or_name())
.is_some_and(|m| &m == msg)
{
line.highlight(Highlighted::All);
}
}
FieldMatcher::Prefix(p) => {
if msg.starts_with(p)
&& let Some(offset) = line.message.find(msg)
{
line.highlight(Highlighted::Range {
from: offset,
to: offset + p.len(),
});
}
}
FieldMatcher::Regex(r) => {
if let Ok(regex) = Regex::new(r)
&& let Some(start_offset) = line.message.find(msg)
&& let Some(m) = regex.find(msg)
{
let from = start_offset + m.start();
let to = start_offset + m.end();
line.highlight(Highlighted::Range { from, to });
}
}
FieldMatcher::Contains(c) => {
if msg.contains(c)
&& let Some(start_offset) = line.message.find(msg)
&& let Some(contains_offset) = line.message[start_offset..].find(c)
{
let start = start_offset + contains_offset;
line.highlight(Highlighted::Range {
from: start,
to: start + c.len(),
});
}
}
}
} else if let InputState::Target(InputTarget::Fields(Some(f))) = self.input_state
&& let Some((name, value)) = &self.selected_footer_field
&& let Some(current_log_value) = i.all_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))
}
FieldMatcher::Contains(c) => current_log_value.to_string().contains(c),
};
if matches {
line.highlight(Highlighted::All);
}
}
ListItem::new(line)
},
));
Widget::render(list, area, buf);
}
}

View file

@ -0,0 +1,69 @@
use ratatui::text::{Line, Span, Text};
use crate::tui::widgets::styled::Styled;
pub enum Highlighted {
None,
All,
Range { from: usize, to: usize },
}
pub struct LineText {
prefix: String,
pub message: String,
inline_depth: usize,
pub message_text: Option<String>,
highlighted: Highlighted,
}
impl LineText {
pub fn new(
prefix: String,
message: String,
message_text: Option<String>,
inline_depth: usize,
) -> Self {
Self {
prefix,
message,
message_text,
inline_depth,
highlighted: Highlighted::None,
}
}
pub fn highlight(&mut self, highlighted: Highlighted) {
self.highlighted = highlighted;
}
}
impl Into<Line<'static>> for Styled<'_, LineText> {
fn into(self) -> Line<'static> {
let mut spans = Vec::new();
spans.push(Span::from(self.inner.prefix));
spans.push(Span::from(""));
match self.inner.highlighted {
Highlighted::None => {
spans.push(Span::from(self.inner.message).style(self.styles.default))
}
Highlighted::All => {
spans.push(Span::from(self.inner.message).style(self.styles.highlighted))
}
Highlighted::Range { from, to } => spans.extend_from_slice(&[
Span::from(self.inner.message[..from].to_string()).style(self.styles.default),
Span::from(self.inner.message[from..to].to_string()).style(self.styles.highlighted),
Span::from(self.inner.message[to..].to_string()).style(self.styles.default),
]),
};
Line::from(spans)
}
}
impl Into<Text<'static>> for Styled<'_, LineText> {
fn into(self) -> Text<'static> {
Text::from(Into::<Line<'static>>::into(self))
}
}

4
src/tui/widgets/mod.rs Normal file
View file

@ -0,0 +1,4 @@
pub mod hyperlink;
pub mod items;
pub mod line_text;
pub mod styled;

40
src/tui/widgets/styled.rs Normal file
View file

@ -0,0 +1,40 @@
use std::ops::{Deref, DerefMut};
use crate::tui::Styles;
pub struct Styled<'a, T> {
pub styles: &'a Styles,
pub inner: T,
}
pub trait IntoStyled<'a>: Sized {
fn styled_ref(&self, styles: &'a Styles) -> Styled<'a, &Self>;
fn styled(self, styles: &'a Styles) -> Styled<'a, Self>;
}
impl<'a, T> IntoStyled<'a> for T {
fn styled_ref(&self, styles: &'a Styles) -> Styled<'a, &Self> {
Styled {
styles,
inner: self,
}
}
fn styled(self, styles: &'a Styles) -> Styled<'a, Self> {
Styled {
styles,
inner: self,
}
}
}
impl<T> Deref for Styled<'_, T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<T> DerefMut for Styled<'_, T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}