use crate::event::Key;
use crate::render::{Cell, Modifier};
use crate::style::Color;
use crate::utils::{char_width, truncate_to_width};
use crate::widget::theme::{DISABLED_FG, LIGHT_GRAY};
use crate::widget::traits::{RenderContext, View, WidgetProps};
use crate::{impl_props_builders, impl_styled_view};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum LogLevel {
Trace,
Debug,
#[default]
Info,
Warning,
Error,
Fatal,
}
impl LogLevel {
pub fn color(&self) -> Color {
match self {
LogLevel::Trace => DISABLED_FG,
LogLevel::Debug => LIGHT_GRAY,
LogLevel::Info => Color::CYAN,
LogLevel::Warning => Color::YELLOW,
LogLevel::Error => Color::RED,
LogLevel::Fatal => Color::rgb(255, 50, 50),
}
}
pub fn icon(&self) -> char {
match self {
LogLevel::Trace => '·',
LogLevel::Debug => '○',
LogLevel::Info => '●',
LogLevel::Warning => '⚠',
LogLevel::Error => '✗',
LogLevel::Fatal => '☠',
}
}
pub fn label(&self) -> &'static str {
match self {
LogLevel::Trace => "TRACE",
LogLevel::Debug => "DEBUG",
LogLevel::Info => "INFO",
LogLevel::Warning => "WARN",
LogLevel::Error => "ERROR",
LogLevel::Fatal => "FATAL",
}
}
}
#[derive(Clone, Debug)]
pub struct LogEntry {
pub message: String,
pub level: LogLevel,
pub timestamp: Option<String>,
pub source: Option<String>,
pub expanded: bool,
pub details: Vec<String>,
}
impl LogEntry {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
level: LogLevel::Info,
timestamp: None,
source: None,
expanded: false,
details: Vec::new(),
}
}
pub fn level(mut self, level: LogLevel) -> Self {
self.level = level;
self
}
pub fn trace(mut self) -> Self {
self.level = LogLevel::Trace;
self
}
pub fn debug(mut self) -> Self {
self.level = LogLevel::Debug;
self
}
pub fn info(mut self) -> Self {
self.level = LogLevel::Info;
self
}
pub fn warning(mut self) -> Self {
self.level = LogLevel::Warning;
self
}
pub fn error(mut self) -> Self {
self.level = LogLevel::Error;
self
}
pub fn fatal(mut self) -> Self {
self.level = LogLevel::Fatal;
self
}
pub fn timestamp(mut self, ts: impl Into<String>) -> Self {
self.timestamp = Some(ts.into());
self
}
pub fn source(mut self, src: impl Into<String>) -> Self {
self.source = Some(src.into());
self
}
pub fn detail(mut self, line: impl Into<String>) -> Self {
self.details.push(line.into());
self
}
pub fn details(mut self, lines: Vec<String>) -> Self {
self.details.extend(lines);
self
}
pub fn toggle(&mut self) {
self.expanded = !self.expanded;
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LogFormat {
Simple,
#[default]
Standard,
Detailed,
Custom,
}
pub struct RichLog {
entries: Vec<LogEntry>,
scroll: usize,
selected: Option<usize>,
min_level: LogLevel,
format: LogFormat,
show_timestamps: bool,
show_sources: bool,
show_icons: bool,
show_labels: bool,
auto_scroll: bool,
max_entries: usize,
wrap: bool,
bg: Option<Color>,
timestamp_fg: Color,
source_fg: Color,
props: WidgetProps,
}
impl RichLog {
pub fn new() -> Self {
Self {
entries: Vec::new(),
scroll: 0,
selected: None,
min_level: LogLevel::Trace,
format: LogFormat::Standard,
show_timestamps: true,
show_sources: true,
show_icons: true,
show_labels: false,
auto_scroll: true,
max_entries: 1000,
wrap: false,
bg: None,
timestamp_fg: DISABLED_FG,
source_fg: LIGHT_GRAY,
props: WidgetProps::new(),
}
}
pub fn log(&mut self, entry: LogEntry) {
if entry.level >= self.min_level {
self.entries.push(entry);
if self.max_entries > 0 && self.entries.len() > self.max_entries {
let excess = self.entries.len() - self.max_entries;
self.entries.drain(0..excess);
if self.scroll >= excess {
self.scroll -= excess;
} else {
self.scroll = 0;
}
}
if self.auto_scroll {
self.scroll_to_bottom();
}
}
}
pub fn write(&mut self, level: LogLevel, message: impl Into<String>) {
self.log(LogEntry::new(message).level(level));
}
pub fn info(&mut self, message: impl Into<String>) {
self.write(LogLevel::Info, message);
}
pub fn debug(&mut self, message: impl Into<String>) {
self.write(LogLevel::Debug, message);
}
pub fn warn(&mut self, message: impl Into<String>) {
self.write(LogLevel::Warning, message);
}
pub fn error(&mut self, message: impl Into<String>) {
self.write(LogLevel::Error, message);
}
pub fn format(mut self, format: LogFormat) -> Self {
self.format = format;
self
}
pub fn min_level(mut self, level: LogLevel) -> Self {
self.min_level = level;
self
}
pub fn timestamps(mut self, show: bool) -> Self {
self.show_timestamps = show;
self
}
pub fn sources(mut self, show: bool) -> Self {
self.show_sources = show;
self
}
pub fn icons(mut self, show: bool) -> Self {
self.show_icons = show;
self
}
pub fn auto_scroll(mut self, enable: bool) -> Self {
self.auto_scroll = enable;
self
}
pub fn max_entries(mut self, max: usize) -> Self {
self.max_entries = max;
self
}
pub fn wrap(mut self, enable: bool) -> Self {
self.wrap = enable;
self
}
pub fn bg(mut self, color: Color) -> Self {
self.bg = Some(color);
self
}
pub fn scroll_up(&mut self, lines: usize) {
self.scroll = self.scroll.saturating_sub(lines);
self.auto_scroll = false;
}
pub fn scroll_down(&mut self, lines: usize) {
let max_scroll = self.entries.len().saturating_sub(1);
self.scroll = (self.scroll + lines).min(max_scroll);
}
pub fn scroll_to_top(&mut self) {
self.scroll = 0;
self.auto_scroll = false;
}
pub fn scroll_to_bottom(&mut self) {
if !self.entries.is_empty() {
self.scroll = self.entries.len().saturating_sub(1);
}
self.auto_scroll = true;
}
pub fn clear(&mut self) {
self.entries.clear();
self.scroll = 0;
self.selected = None;
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn visible_entries(&self) -> Vec<&LogEntry> {
self.entries
.iter()
.filter(|e| e.level >= self.min_level)
.collect()
}
pub fn select_next(&mut self) {
let count = self.visible_entries().len();
match self.selected {
Some(i) if i < count - 1 => self.selected = Some(i + 1),
None if count > 0 => self.selected = Some(0),
_ => {}
}
}
pub fn select_prev(&mut self) {
if let Some(i) = self.selected {
if i > 0 {
self.selected = Some(i - 1);
}
}
}
pub fn toggle_selected(&mut self) {
if let Some(i) = self.selected {
if let Some(entry) = self.entries.get_mut(i) {
entry.toggle();
}
}
}
pub fn handle_key(&mut self, key: &Key) -> bool {
match key {
Key::Up | Key::Char('k') => {
self.scroll_up(1);
true
}
Key::Down | Key::Char('j') => {
self.scroll_down(1);
true
}
Key::PageUp => {
self.scroll_up(10);
true
}
Key::PageDown => {
self.scroll_down(10);
true
}
Key::Home | Key::Char('g') => {
self.scroll_to_top();
true
}
Key::End | Key::Char('G') => {
self.scroll_to_bottom();
true
}
Key::Char('c') => {
self.clear();
true
}
_ => false,
}
}
}
impl Default for RichLog {
fn default() -> Self {
Self::new()
}
}
impl View for RichLog {
crate::impl_view_meta!("RichLog");
fn render(&self, ctx: &mut RenderContext) {
let area = ctx.area;
let entries = self.visible_entries();
if entries.is_empty() {
return;
}
let timestamp_width = if self.show_timestamps { 12 } else { 0 };
let icon_width = if self.show_icons { 2 } else { 0 };
let label_width = if self.show_labels { 7 } else { 0 };
let source_width = if self.show_sources { 15 } else { 0 };
let prefix_width = timestamp_width + icon_width + label_width + source_width;
let message_width = area.width.saturating_sub(prefix_width);
let visible_height = area.height as usize;
let start = self.scroll;
for (i, entry) in entries.iter().enumerate().skip(start).take(visible_height) {
let y = (i - start) as u16;
if y >= area.height {
break;
}
let is_selected = self.selected == Some(i);
let level_color = entry.level.color();
if let Some(bg) = self.bg {
for x in 0..area.width {
let mut cell = Cell::new(' ');
cell.bg = Some(bg);
ctx.set(x, y, cell);
}
}
let mut x: u16 = 0;
if self.show_timestamps {
if let Some(ref ts) = entry.timestamp {
let ts_display = truncate_to_width(ts, timestamp_width as usize - 1);
for ch in ts_display.chars() {
let cw = char_width(ch) as u16;
let mut cell = Cell::new(ch);
cell.fg = Some(self.timestamp_fg);
cell.bg = self.bg;
ctx.set(x, y, cell);
x += cw;
}
}
x = timestamp_width;
}
if self.show_icons {
let icon = entry.level.icon();
let mut cell = Cell::new(icon);
cell.fg = Some(level_color);
cell.bg = self.bg;
ctx.set(x, y, cell);
x += icon_width;
}
if self.show_labels {
let label = entry.level.label();
for ch in label.chars() {
let mut cell = Cell::new(ch);
cell.fg = Some(level_color);
cell.bg = self.bg;
cell.modifier |= Modifier::BOLD;
ctx.set(x, y, cell);
x += 1;
}
x = timestamp_width + icon_width + label_width;
}
if self.show_sources {
if let Some(ref src) = entry.source {
let src_display = truncate_to_width(src, source_width as usize - 1);
for ch in src_display.chars() {
let cw = char_width(ch) as u16;
let mut cell = Cell::new(ch);
cell.fg = Some(self.source_fg);
cell.bg = self.bg;
ctx.set(x, y, cell);
x += cw;
}
}
x = prefix_width;
}
let msg_fg = if is_selected {
Color::WHITE
} else {
level_color
};
let msg_truncated = truncate_to_width(&entry.message, message_width as usize);
for ch in msg_truncated.chars() {
let cw = char_width(ch) as u16;
if x + cw > prefix_width + message_width {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(msg_fg);
cell.bg = self.bg;
if is_selected {
cell.modifier |= Modifier::BOLD;
}
if entry.level >= LogLevel::Error {
cell.modifier |= Modifier::BOLD;
}
ctx.set(x, y, cell);
x += cw;
}
}
if entries.len() > visible_height {
let scroll_pos = if entries.len() <= visible_height {
0
} else {
(self.scroll * (area.height as usize - 1)) / (entries.len() - visible_height)
};
let indicator_y = scroll_pos as u16;
if indicator_y < area.height {
let mut cell = Cell::new('█');
cell.fg = Some(DISABLED_FG);
ctx.set(area.width - 1, indicator_y, cell);
}
}
}
}
impl_styled_view!(RichLog);
impl_props_builders!(RichLog);
pub fn richlog() -> RichLog {
RichLog::new()
}
pub fn log_entry(message: impl Into<String>) -> LogEntry {
LogEntry::new(message)
}
#[cfg(test)]
mod tests {
#[test]
fn test_log_level_private_methods() {
use super::*;
let entry = LogEntry::new("Test");
assert_eq!(entry.message, "Test");
assert_eq!(entry.level, LogLevel::Info);
let entry = LogEntry::new("Test").level(LogLevel::Error);
assert_eq!(entry.level, LogLevel::Error);
}
#[test]
fn test_rich_log_private_initialization() {
use super::*;
let log = RichLog::new();
assert!(log.entries.is_empty());
assert_eq!(log.scroll, 0);
assert_eq!(log.min_level, LogLevel::Trace);
}
}