557 lines
17 KiB
Rust
557 lines
17 KiB
Rust
use std::sync::{Arc, LazyLock};
|
|
|
|
use freya::prelude::*;
|
|
use freya::text_edit::Clipboard;
|
|
use regex::Regex;
|
|
|
|
use crate::components::{Icon, IconType, ScrollArea, ScrollAreaCtx, corrected_scroll};
|
|
use crate::theme::{self, colors};
|
|
|
|
const LINE_H: f32 = 16.;
|
|
const BODY_PAD_TOP: f32 = 10.;
|
|
const BODY_PAD_BOTTOM: f32 = 20.;
|
|
const LINE_FONT_SIZE: f32 = 12.;
|
|
const NUM_GAP: f32 = 12.;
|
|
const CHAR_W: f32 = LINE_FONT_SIZE * 0.6;
|
|
const ROW_PAD_X: f32 = 10.;
|
|
const TAB_WIDTH: usize = 4;
|
|
const OVERSCAN: i64 = 4;
|
|
|
|
static LOG_RE: LazyLock<Regex> = LazyLock::new(|| {
|
|
Regex::new(r"^\[((?:\S+ )?\d+:\d+:\d+(?:\.\d+)?)\] \[([^/\n\r]*)/(\w+)\]:? (?:\[(CHAT)\])?")
|
|
.expect("valid log regex")
|
|
});
|
|
|
|
fn level_color(level: &str) -> Color {
|
|
match level.to_ascii_uppercase().as_str() {
|
|
"ERROR" | "FATAL" | "SEVERE" => colors::code_error(),
|
|
"WARN" | "WARNING" => colors::code_warn(),
|
|
"DEBUG" | "TRACE" => colors::code_debug(),
|
|
_ => colors::code_info(),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ParsedLine {
|
|
spans: Vec<(String, Color)>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct WidthCache {
|
|
len: usize,
|
|
last_ptr: usize,
|
|
max: usize,
|
|
}
|
|
|
|
fn line_ptr(line: &Arc<str>) -> usize {
|
|
Arc::as_ptr(line) as *const u8 as usize
|
|
}
|
|
|
|
impl WidthCache {
|
|
fn max_cols(&mut self, lines: &Arc<Vec<Arc<str>>>) -> usize {
|
|
let len = lines.len();
|
|
let appended = len >= self.len
|
|
&& self.len > 0
|
|
&& lines.get(self.len - 1).map(line_ptr) == Some(self.last_ptr);
|
|
|
|
let start = if appended {
|
|
if len == self.len {
|
|
return self.max;
|
|
}
|
|
self.len
|
|
} else {
|
|
self.max = 0;
|
|
0
|
|
};
|
|
|
|
for line in &lines[start..] {
|
|
self.max = self.max.max(visual_len(line));
|
|
}
|
|
self.len = len;
|
|
self.last_ptr = lines.last().map(line_ptr).unwrap_or(0);
|
|
self.max
|
|
}
|
|
}
|
|
|
|
fn clean_line(raw: &str) -> String {
|
|
let mut out = String::with_capacity(raw.len());
|
|
let mut chars = raw.chars();
|
|
let mut col = 0usize;
|
|
while let Some(c) = chars.next() {
|
|
match c {
|
|
'§' => {
|
|
chars.next();
|
|
}
|
|
'\t' => {
|
|
let spaces = TAB_WIDTH - (col % TAB_WIDTH);
|
|
for _ in 0..spaces {
|
|
out.push(' ');
|
|
}
|
|
col += spaces;
|
|
}
|
|
'\r' => {}
|
|
_ => {
|
|
out.push(c);
|
|
col += 1;
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn visual_len(raw: &str) -> usize {
|
|
let mut chars = raw.chars();
|
|
let mut col = 0usize;
|
|
while let Some(c) = chars.next() {
|
|
match c {
|
|
'§' => {
|
|
chars.next();
|
|
}
|
|
'\t' => col += TAB_WIDTH - (col % TAB_WIDTH),
|
|
'\r' => {}
|
|
_ => col += 1,
|
|
}
|
|
}
|
|
col
|
|
}
|
|
|
|
fn parse_line(raw: &str) -> ParsedLine {
|
|
let cleaned = clean_line(raw);
|
|
|
|
let Some(caps) = LOG_RE.captures(&cleaned) else {
|
|
return ParsedLine {
|
|
spans: vec![(cleaned, colors::fg_primary())],
|
|
};
|
|
};
|
|
|
|
let matched = caps.get(0).map(|m| m.end()).unwrap_or(0);
|
|
let time = caps.get(1).map(|m| m.as_str()).unwrap_or_default();
|
|
let thread = caps.get(2).map(|m| m.as_str()).unwrap_or_default();
|
|
let level = caps.get(3).map(|m| m.as_str()).unwrap_or_default();
|
|
let is_chat = caps.get(4).is_some();
|
|
let lvl_color = level_color(level);
|
|
|
|
let mut spans = vec![
|
|
(format!("[{time}] "), colors::code_muted()),
|
|
(format!("[{thread}/{level}]"), lvl_color),
|
|
];
|
|
if is_chat {
|
|
spans.push((" [CHAT]".to_string(), colors::code_chat()));
|
|
}
|
|
let msg_color = if is_chat {
|
|
colors::code_chat()
|
|
} else {
|
|
colors::fg_primary()
|
|
};
|
|
spans.push((format!(" {}", &cleaned[matched..]), msg_color));
|
|
|
|
ParsedLine { spans }
|
|
}
|
|
|
|
#[derive(PartialEq)]
|
|
pub struct LogViewer {
|
|
title: String,
|
|
lines: Arc<Vec<Arc<str>>>,
|
|
streaming: bool,
|
|
header: Option<Element>,
|
|
}
|
|
|
|
impl LogViewer {
|
|
pub fn new(title: impl Into<String>, lines: impl Into<Arc<Vec<Arc<str>>>>) -> Self {
|
|
Self {
|
|
title: title.into(),
|
|
lines: lines.into(),
|
|
streaming: false,
|
|
header: None,
|
|
}
|
|
}
|
|
|
|
pub fn streaming(mut self, streaming: bool) -> Self {
|
|
self.streaming = streaming;
|
|
self
|
|
}
|
|
|
|
pub fn header(mut self, header: impl IntoElement) -> Self {
|
|
self.header = Some(header.into_element());
|
|
self
|
|
}
|
|
}
|
|
|
|
impl Component for LogViewer {
|
|
fn render(&self) -> impl IntoElement {
|
|
let streaming = self.streaming;
|
|
let lines: Arc<Vec<Arc<str>>> = self.lines.clone();
|
|
let count = lines.len();
|
|
|
|
let content_h = count as f32 * LINE_H + BODY_PAD_TOP + BODY_PAD_BOTTOM;
|
|
let width_cache =
|
|
use_hook(|| std::rc::Rc::new(std::cell::RefCell::new(WidthCache::default())));
|
|
|
|
let max_chars = width_cache.borrow_mut().max_cols(&lines);
|
|
let max_digits = (count.max(1) as f32).log10().floor() as usize + 1;
|
|
let num_col_w = max_digits as f32 * (LINE_FONT_SIZE * 0.62) + 6.;
|
|
let content_w = ROW_PAD_X + num_col_w + NUM_GAP + max_chars as f32 * CHAR_W + ROW_PAD_X;
|
|
|
|
let scroll = use_scroll_controller(ScrollConfig::default);
|
|
let mut auto_scroll = use_state(|| streaming);
|
|
|
|
let sel_anchor = use_state::<Option<(usize, usize)>>(|| None);
|
|
let sel_focus = use_state::<Option<(usize, usize)>>(|| None);
|
|
let selecting = use_state(|| false);
|
|
|
|
let stick_bottom = streaming && *auto_scroll.read();
|
|
|
|
rect()
|
|
.vertical()
|
|
.width(Size::fill())
|
|
.height(Size::fill())
|
|
.overflow(Overflow::Clip)
|
|
.background(colors::page_elevated())
|
|
.corner_radius(CornerRadius::new_all(12.))
|
|
.maybe_child(
|
|
(!self.title.is_empty() || streaming)
|
|
.then(|| toolbar(self.title.clone(), streaming, auto_scroll)),
|
|
)
|
|
.maybe_child(self.header.clone())
|
|
.child(
|
|
ScrollArea::new()
|
|
.scroll_controller(scroll)
|
|
.horizontal(content_w)
|
|
.stick_bottom(stick_bottom)
|
|
.on_user_scroll(move |_| {
|
|
if streaming {
|
|
auto_scroll.set_if_modified(false);
|
|
}
|
|
})
|
|
.content(move |ctx: ScrollAreaCtx| {
|
|
scroll_body(BodyArgs {
|
|
ctx,
|
|
scroll,
|
|
lines: lines.clone(),
|
|
content_h,
|
|
content_w,
|
|
num_col_w,
|
|
count,
|
|
sel_anchor,
|
|
sel_focus,
|
|
selecting,
|
|
})
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn toolbar(title: String, streaming: bool, auto_scroll: State<bool>) -> impl IntoElement {
|
|
rect()
|
|
.horizontal()
|
|
.width(Size::fill())
|
|
.cross_align(Alignment::Center)
|
|
.content(Content::Flex)
|
|
.padding(Gaps::new(12., 20., 0., 20.))
|
|
.spacing(12.)
|
|
.child(
|
|
label()
|
|
.text(title)
|
|
.font_size(18.)
|
|
.font_weight(FontWeight::SEMI_BOLD)
|
|
.color(colors::fg_primary()),
|
|
)
|
|
.child(
|
|
rect()
|
|
.horizontal()
|
|
.width(Size::flex(1.0))
|
|
.main_align(Alignment::End)
|
|
.cross_align(Alignment::Center)
|
|
.maybe_child(streaming.then(|| auto_scroll_toggle(auto_scroll))),
|
|
)
|
|
.into_element()
|
|
}
|
|
|
|
fn auto_scroll_toggle(mut auto_scroll: State<bool>) -> impl IntoElement {
|
|
let on = *auto_scroll.read();
|
|
rect()
|
|
.horizontal()
|
|
.cross_align(Alignment::Center)
|
|
.spacing(6.)
|
|
.padding(Gaps::new_symmetric(5., 10.))
|
|
.corner_radius(CornerRadius::new_all(7.))
|
|
.background(if on {
|
|
colors::brand().with_a(40)
|
|
} else {
|
|
colors::component_bg()
|
|
})
|
|
.cursor(CursorIcon::Pointer)
|
|
.on_press(move |_| auto_scroll.toggle())
|
|
.child(Icon::new(IconType::ChevronDown).size(13.).color(if on {
|
|
colors::brand()
|
|
} else {
|
|
colors::fg_secondary()
|
|
}))
|
|
.child(label().text("Auto-scroll").font_size(11.).color(if on {
|
|
colors::brand()
|
|
} else {
|
|
colors::fg_secondary()
|
|
}))
|
|
.into_element()
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct PosGeom {
|
|
count: usize,
|
|
num_col_w: f32,
|
|
viewport_top_px: f32,
|
|
viewport_left_px: f32,
|
|
corrected_x: f32,
|
|
corrected_y: f32,
|
|
}
|
|
|
|
fn pos_at_screen(
|
|
lines: &[Arc<str>],
|
|
geom: PosGeom,
|
|
screen_x: f32,
|
|
screen_y: f32,
|
|
) -> (usize, usize) {
|
|
let region_y = screen_y - geom.viewport_top_px;
|
|
let band_y = region_y - geom.corrected_y - BODY_PAD_TOP;
|
|
let line = ((band_y / LINE_H).floor() as i64).clamp(0, (geom.count as i64 - 1).max(0)) as usize;
|
|
|
|
let text_origin_x = ROW_PAD_X + geom.num_col_w + NUM_GAP;
|
|
let local_x = screen_x - geom.viewport_left_px - geom.corrected_x - text_origin_x;
|
|
let raw_col = (local_x / CHAR_W).round().max(0.) as usize;
|
|
let len = lines.get(line).map(|l| visual_len(l)).unwrap_or(0);
|
|
(line, raw_col.min(len))
|
|
}
|
|
|
|
struct BodyArgs {
|
|
ctx: ScrollAreaCtx,
|
|
scroll: ScrollController,
|
|
lines: Arc<Vec<Arc<str>>>,
|
|
content_h: f32,
|
|
content_w: f32,
|
|
num_col_w: f32,
|
|
count: usize,
|
|
sel_anchor: State<Option<(usize, usize)>>,
|
|
sel_focus: State<Option<(usize, usize)>>,
|
|
selecting: State<bool>,
|
|
}
|
|
|
|
fn scroll_body(args: BodyArgs) -> Element {
|
|
let BodyArgs {
|
|
ctx,
|
|
mut scroll,
|
|
lines,
|
|
content_h,
|
|
content_w,
|
|
num_col_w,
|
|
count,
|
|
mut sel_anchor,
|
|
mut sel_focus,
|
|
mut selecting,
|
|
} = args;
|
|
|
|
let corrected_y = ctx.corrected_y;
|
|
let corrected_x = ctx.corrected_x;
|
|
let viewport_h = ctx.viewport_h;
|
|
let viewport_top_px = ctx.viewport_top;
|
|
let viewport_left_px = ctx.viewport_left;
|
|
let body_w = content_w.max(ctx.viewport_w);
|
|
|
|
let selection = match (*sel_anchor.read(), *sel_focus.read()) {
|
|
(Some(a), Some(f)) => Some((a.min(f), a.max(f))),
|
|
_ => None,
|
|
};
|
|
|
|
let geom = PosGeom {
|
|
count,
|
|
num_col_w,
|
|
viewport_top_px,
|
|
viewport_left_px,
|
|
corrected_x,
|
|
corrected_y,
|
|
};
|
|
|
|
let lines_down = lines.clone();
|
|
let on_body_down = move |e: Event<PointerEventData>| {
|
|
let pos = pos_at_screen(
|
|
&lines_down,
|
|
geom,
|
|
e.global_location().x as f32,
|
|
e.global_location().y as f32,
|
|
);
|
|
sel_anchor.set(Some(pos));
|
|
sel_focus.set(Some(pos));
|
|
selecting.set(true);
|
|
};
|
|
|
|
let lines_move = lines.clone();
|
|
let on_global_move = move |e: Event<PointerEventData>| {
|
|
if !*selecting.read() {
|
|
return;
|
|
}
|
|
let cursor_y = e.global_location().y as f32;
|
|
let pos = pos_at_screen(&lines_move, geom, e.global_location().x as f32, cursor_y);
|
|
sel_focus.set(Some(pos));
|
|
|
|
const EDGE: f32 = 24.;
|
|
let region_y = cursor_y - viewport_top_px;
|
|
let (_, cur_scroll) = scroll.into();
|
|
let delta = if region_y < EDGE {
|
|
(EDGE - region_y).min(EDGE)
|
|
} else if region_y > viewport_h - EDGE {
|
|
-((region_y - (viewport_h - EDGE)).min(EDGE))
|
|
} else {
|
|
0.
|
|
};
|
|
if delta != 0. {
|
|
let target = cur_scroll as f32 + delta;
|
|
let clamped = corrected_scroll(content_h, viewport_h, target) as i32;
|
|
scroll.scroll_to_y(clamped);
|
|
}
|
|
};
|
|
|
|
let on_global_release = move |_: Event<PointerEventData>| {
|
|
if *selecting.read() {
|
|
selecting.set(false);
|
|
}
|
|
};
|
|
|
|
let lines_for_copy = lines.clone();
|
|
let on_key_down = move |e: Event<KeyboardEventData>| {
|
|
let copy = matches!(&e.key, Key::Character(c) if c.as_str().eq_ignore_ascii_case("c"))
|
|
&& (e.modifiers.contains(Modifiers::CONTROL) || e.modifiers.contains(Modifiers::META));
|
|
if !copy {
|
|
return;
|
|
}
|
|
if let (Some(a), Some(f)) = (*sel_anchor.read(), *sel_focus.read()) {
|
|
let ((s_line, s_col), (e_line, e_col)) = (a.min(f), a.max(f));
|
|
let mut parts: Vec<String> = Vec::with_capacity(e_line - s_line + 1);
|
|
for line in s_line..=e_line {
|
|
let Some(raw) = lines_for_copy.get(line) else {
|
|
continue;
|
|
};
|
|
let cleaned = clean_line(raw);
|
|
let from = if line == s_line { s_col } else { 0 };
|
|
let to = if line == e_line {
|
|
e_col
|
|
} else {
|
|
cleaned.chars().count()
|
|
};
|
|
let slice: String = cleaned
|
|
.chars()
|
|
.skip(from)
|
|
.take(to.saturating_sub(from))
|
|
.collect();
|
|
parts.push(slice);
|
|
}
|
|
let text = parts.join("\n");
|
|
if let Err(err) = Clipboard::set(text) {
|
|
tracing::warn!("clipboard copy failed: {err:?}");
|
|
}
|
|
}
|
|
};
|
|
|
|
rect()
|
|
.width(Size::px(body_w))
|
|
.height(Size::px(content_h))
|
|
.on_pointer_down(on_body_down)
|
|
.on_capture_global_pointer_move(on_global_move)
|
|
.on_capture_global_pointer_press(on_global_release)
|
|
.on_global_key_down(on_key_down)
|
|
.child(log_body(
|
|
&lines[..],
|
|
content_h,
|
|
corrected_y,
|
|
viewport_h,
|
|
body_w,
|
|
num_col_w,
|
|
selection,
|
|
))
|
|
.into_element()
|
|
}
|
|
|
|
fn log_body(
|
|
lines: &[Arc<str>],
|
|
content_h: f32,
|
|
corrected_y: f32,
|
|
viewport_h: f32,
|
|
body_w: f32,
|
|
num_col_w: f32,
|
|
selection: Option<((usize, usize), (usize, usize))>,
|
|
) -> impl IntoElement {
|
|
let count = lines.len();
|
|
let scrolled = -corrected_y;
|
|
let first = (((scrolled - BODY_PAD_TOP) / LINE_H).floor() as i64 - OVERSCAN).max(0) as usize;
|
|
let visible = (viewport_h / LINE_H).ceil() as usize + OVERSCAN as usize * 2 + 1;
|
|
let last = (first + visible).min(count);
|
|
|
|
let mut body = rect().width(Size::px(body_w)).height(Size::px(content_h));
|
|
for (i, raw) in lines.iter().enumerate().take(last).skip(first) {
|
|
let y = BODY_PAD_TOP + i as f32 * LINE_H;
|
|
let sel_cols = selection.and_then(|((sl, sc), (el, ec))| {
|
|
if i < sl || i > el {
|
|
return None;
|
|
}
|
|
let len = visual_len(raw);
|
|
let c0 = if i == sl { sc } else { 0 };
|
|
let c1 = if i == el { ec } else { len };
|
|
(c1 > c0).then_some((c0, c1))
|
|
});
|
|
let parsed = parse_line(raw);
|
|
body = body.child(log_line_row(i, &parsed, y, body_w, num_col_w, sel_cols));
|
|
}
|
|
body
|
|
}
|
|
|
|
fn log_line_row(
|
|
index: usize,
|
|
parsed: &ParsedLine,
|
|
y: f32,
|
|
body_w: f32,
|
|
num_col_w: f32,
|
|
sel_cols: Option<(usize, usize)>,
|
|
) -> impl IntoElement {
|
|
let mut message = paragraph()
|
|
.max_lines(1)
|
|
.width(Size::fill())
|
|
.height(Size::px(LINE_H))
|
|
.font_family(theme::MONO_FONT)
|
|
.font_size(LINE_FONT_SIZE);
|
|
for (text, color) in &parsed.spans {
|
|
message = message.span(Span::new(text.clone()).color(*color));
|
|
}
|
|
|
|
let highlight = sel_cols.map(|(c0, c1)| {
|
|
rect()
|
|
.position(
|
|
Position::new_absolute()
|
|
.top(0.)
|
|
.left(num_col_w + NUM_GAP + c0 as f32 * CHAR_W),
|
|
)
|
|
.width(Size::px((c1 - c0) as f32 * CHAR_W))
|
|
.height(Size::px(LINE_H))
|
|
.corner_radius(CornerRadius::new_all(2.))
|
|
.background(colors::selection_bg())
|
|
.into_element()
|
|
});
|
|
|
|
rect()
|
|
.horizontal()
|
|
.position(Position::new_absolute().top(y).left(ROW_PAD_X))
|
|
.width(Size::px((body_w - ROW_PAD_X * 2.).max(0.)))
|
|
.height(Size::px(LINE_H))
|
|
.cross_align(Alignment::Center)
|
|
.spacing(NUM_GAP)
|
|
.maybe_child(highlight)
|
|
.child(
|
|
rect().width(Size::px(num_col_w)).child(
|
|
label()
|
|
.text(format!("{}", index + 1))
|
|
.font_family(theme::MONO_FONT)
|
|
.font_size(LINE_FONT_SIZE)
|
|
.text_align(TextAlign::Right)
|
|
.width(Size::fill())
|
|
.color(colors::code_muted()),
|
|
),
|
|
)
|
|
.child(message)
|
|
}
|