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 = 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) -> usize { Arc::as_ptr(line) as *const u8 as usize } impl WidthCache { fn max_cols(&mut self, lines: &Arc>>) -> 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>>, streaming: bool, header: Option, } impl LogViewer { pub fn new(title: impl Into, lines: impl Into>>>) -> 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>> = 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::>(|| None); let sel_focus = use_state::>(|| 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) -> 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) -> 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], 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>>, content_h: f32, content_w: f32, num_col_w: f32, count: usize, sel_anchor: State>, sel_focus: State>, selecting: State, } 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| { 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| { 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| { if *selecting.read() { selecting.set(false); } }; let lines_for_copy = lines.clone(); let on_key_down = move |e: Event| { 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 = 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], 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) }