From 79639be9dac60bdc7ccb9887037a5eb12dea799e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jana=20D=C3=B6nszelmann?= Date: Wed, 25 Feb 2026 11:34:30 +0100 Subject: [PATCH] refactor logviewer --- src/tui/filter.rs | 4 +- src/tui/log_viewer.rs | 16 +- src/tui/mod.rs | 279 +++++++++-------------------------- src/tui/model.rs | 85 +++++++---- src/tui/widgets/hyperlink.rs | 39 +++++ src/tui/widgets/items.rs | 128 ++++++++++++++++ src/tui/widgets/line_text.rs | 69 +++++++++ src/tui/widgets/mod.rs | 4 + src/tui/widgets/styled.rs | 40 +++++ 9 files changed, 425 insertions(+), 239 deletions(-) create mode 100644 src/tui/widgets/hyperlink.rs create mode 100644 src/tui/widgets/items.rs create mode 100644 src/tui/widgets/line_text.rs create mode 100644 src/tui/widgets/mod.rs create mode 100644 src/tui/widgets/styled.rs diff --git a/src/tui/filter.rs b/src/tui/filter.rs index 02260ae..6275ab6 100644 --- a/src/tui/filter.rs +++ b/src/tui/filter.rs @@ -46,7 +46,7 @@ impl Matcher { .all_fields() .fields .get(name) - .is_some_and(|v| value.matches(&pretty_print_value(&v))), + .is_some_and(|v| value.matches(v)), Matcher::Message { value } => { 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(); Some(Self::Field { 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 { diff --git a/src/tui/log_viewer.rs b/src/tui/log_viewer.rs index 399d901..80f874a 100644 --- a/src/tui/log_viewer.rs +++ b/src/tui/log_viewer.rs @@ -4,7 +4,9 @@ use crate::tui::{ filter::Filter, model::LogEntry, processing::{IntoLogStream, LogStream}, + widgets::styled::Styled, }; +use ratatui::{buffer::Buffer, layout::Rect, text::Line, widgets::Widget}; use tui_widget_list::ListState; pub struct LogView { @@ -77,6 +79,17 @@ pub enum InputState { 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 { pub fn capture_string(&mut self) -> Option<&mut String> { match self { @@ -274,13 +287,12 @@ impl LogViewer { 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() { let ret = match selected.as_ref() { LogEntry::Single { .. } => Default::default(), LogEntry::Sub { sub_entries, .. } => sub_entries.last().and_then(|i| { i.all_fields() - .fields .get_key_value("return") .map(|(k, v)| (k.clone(), v.clone())) }), diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 9d81111..e6b6e6b 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,4 +1,3 @@ -use itertools::Itertools; use ratatui_themes::{Theme, ThemeName}; use regex::bytes::Regex; use std::{ @@ -15,11 +14,13 @@ use crate::tui::{ filter::FilterKind, log_viewer::{InputState, InputTarget, LogViewer}, model::pretty_print_value, + widgets::{hyperlink::Hyperlink, items::Items, line_text::Highlighted}, }; use crate::tui::{ filter::{Filter, Matcher}, log_viewer::FieldMatcher, reader::LogfileReader, + widgets::styled::IntoStyled, }; use ratatui::{ DefaultTerminal, @@ -27,7 +28,7 @@ use ratatui::{ crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, layout::{Constraint, HorizontalAlignment, Layout, Rect}, style::Style, - text::{Line, Span, Text}, + text::Line, widgets::{ Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap, }, @@ -38,6 +39,7 @@ pub mod log_viewer; pub mod model; pub mod processing; pub mod reader; +pub mod widgets; const HELP_TEXT: &str = "Generic: ? 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 { @@ -374,12 +412,7 @@ impl Widget for &mut App { 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 styles = self.styles(); let [header_area, main_area, footer_area] = Layout::vertical([ Constraint::Length(2), Constraint::Fill(1), @@ -411,32 +444,8 @@ impl Widget for &mut App { 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 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), @@ -453,16 +462,16 @@ impl Widget for &mut App { .join("►"); Paragraph::new(breadcrumbs) .wrap(Wrap { trim: false }) - .style(default) + .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(default) + .style(styles.default) .render(middle, buf); - Line::from("-").style(default).render(right, buf); + Paragraph::new("").style(styles.default).render(right, buf); for tab in &mut self.tabs { match tab { @@ -474,8 +483,8 @@ impl Widget for &mut App { let list = List::new(files.iter().map(|file| { ListItem::new(file.file_name().to_string_lossy().into_owned()) })) - .style(default) - .highlight_style(highlighted); + .style(styles.default) + .highlight_style(styles.highlighted); *last_height = main_area.height as usize; @@ -488,129 +497,7 @@ impl Widget for &mut App { .items(main_area.height as usize) .unwrap_or_else(|| (Vec::new(), 0)); - Line::from(lv.input_state.show()) - .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); + lv.input_state.styled_ref(&styles).render(right, buf); Clear.render(footer_area, buf); let [first_line, footer_area] = @@ -625,37 +512,50 @@ impl Widget for &mut App { { let full_file_path = canonical_rustc_root.join(&file); 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()), ) .render(first_line, buf); } else { Line::from(format!("In file: {}:{line}", file.display())) - .style(default) + .style(styles.default) .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 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 }); + let mut res = + Paragraph::new(format!("{k:width$} {v}")).wrap(Wrap { trim: false }); if cx.is_selected { - res = res.style(highlighted); + 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(default); + let list = ListView::new(builder, items.len()).style(styles.default); StatefulWidget::render(list, footer_area, buf, &mut lv.footer_list); } Tab::Empty => {} @@ -664,9 +564,9 @@ impl Widget for &mut App { let popup_area = { let block = Block::bordered() .title_top("help") - .style(default) + .style(styles.default) .padding(Padding::symmetric(3, 1)) - .border_style(border_selected); + .border_style(styles.border_highlighted); let inner = block.inner(popup_area); block.render(popup_area, buf); 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>, url: impl Into) -> 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::(); - 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()); - } - } -} diff --git a/src/tui/model.rs b/src/tui/model.rs index 43b593a..ef01496 100644 --- a/src/tui/model.rs +++ b/src/tui/model.rs @@ -10,6 +10,8 @@ use jiff::Timestamp; use serde::Deserialize; use serde_json::Value; +use crate::tui::widgets::line_text::LineText; + pub fn pretty_print_value(v: &Value) -> String { match v { Value::Null => "null".to_string(), @@ -112,10 +114,8 @@ impl LogEntry { match self { LogEntry::Single { raw } => raw.fields.message().map(|i| i.to_string()), LogEntry::Sub { enter, .. } => { - if let Some(val) = enter.all_fields().fields.get("name") - && let Some(s) = val.as_str() - { - Some(s.to_string()) + if let Some(val) = enter.all_fields().fields.get("name") { + Some(val.clone()) } else { 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 = ""; const SPACES_BEFORE: &str = " "; - let indent = " >".repeat(inline_depth); let single_field = |raw: &RawLogEntry| { raw.fields .message() .map(|i| i.to_string()) - .or_else(|| { - raw.fields - .fields - .get("return") - .map(|v| format!("↩ {}", pretty_print_value(v))) - }) + .or_else(|| raw.fields.fields.get("return").map(|v| format!("↩ {v}"))) .or_else(|| { raw.fields .fields .iter() .next() - .map(|(k, v)| format!("{k} = {}", pretty_print_value(v))) + .map(|(k, v)| format!("{k} = {v}")) }) .unwrap_or_else(|| NO_MESSAGE.to_string()) }; match self { - LogEntry::Single { raw } => { - format!("{SPACES_BEFORE}┃{indent}{}", single_field(raw)).into() - } + LogEntry::Single { raw } => LineText::new( + SPACES_BEFORE.to_string(), + single_field(raw), + self.message_or_name(), + inline_depth, + ), LogEntry::Sub { enter, sub_entries, .. } => { - if let Some(val) = enter.all_fields().fields.get("name") - && let Some(s) = val.as_str() - { - format!( - "{:4}⭣{:4}⇊ ┃{indent}↪ {s}", - sub_entries.len(), - self.count().wrapping_sub(1) + if let Some(val) = enter.all_fields().fields.get("name") { + LineText::new( + format!( + "{:4}⭣{:4}⇊ ", + sub_entries.len(), + self.count().wrapping_sub(1) + ), + format!("↪ {val}"), + self.message_or_name(), + inline_depth, ) } 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, +} + +impl From 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)] +#[serde(from = "LogFieldsWrapper")] pub struct LogFields { #[serde(flatten)] - pub fields: BTreeMap, + pub fields: BTreeMap, } impl LogFields { 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 { @@ -192,8 +216,15 @@ impl LogFields { .collect(), } } -} + pub fn get(&self, key: impl AsRef) -> Option<&String> { + self.fields.get(key.as_ref()) + } + + pub fn get_key_value(&self, key: impl AsRef) -> Option<(&String, &String)> { + self.fields.get_key_value(key.as_ref()) + } +} #[derive(Deserialize, Debug, Hash)] pub struct RawLogEntry { pub timestamp: Timestamp, diff --git a/src/tui/widgets/hyperlink.rs b/src/tui/widgets/hyperlink.rs new file mode 100644 index 0000000..5600b7d --- /dev/null +++ b/src/tui/widgets/hyperlink.rs @@ -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>, url: impl Into) -> 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::(); + 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()); + } + } +} diff --git a/src/tui/widgets/items.rs b/src/tui/widgets/items.rs new file mode 100644 index 0000000..57b06e4 --- /dev/null +++ b/src/tui/widgets/items.rs @@ -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, usize)>, + selected_offset: usize, + input_state: &'a InputState, + + selected_footer_field: Option<(String, String)>, +} + +impl<'a> Items<'a> { + pub fn new( + items: Vec<(Rc, 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> { + 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); + } +} diff --git a/src/tui/widgets/line_text.rs b/src/tui/widgets/line_text.rs new file mode 100644 index 0000000..cfebffe --- /dev/null +++ b/src/tui/widgets/line_text.rs @@ -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, + highlighted: Highlighted, +} + +impl LineText { + pub fn new( + prefix: String, + message: String, + message_text: Option, + 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> 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> for Styled<'_, LineText> { + fn into(self) -> Text<'static> { + Text::from(Into::>::into(self)) + } +} diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs new file mode 100644 index 0000000..90da227 --- /dev/null +++ b/src/tui/widgets/mod.rs @@ -0,0 +1,4 @@ +pub mod hyperlink; +pub mod items; +pub mod line_text; +pub mod styled; diff --git a/src/tui/widgets/styled.rs b/src/tui/widgets/styled.rs new file mode 100644 index 0000000..99371a3 --- /dev/null +++ b/src/tui/widgets/styled.rs @@ -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 Deref for Styled<'_, T> { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for Styled<'_, T> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +}