From d9339018afe2b10f2eea3cb9bd46cff5e7f2d538 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:42:10 +0200 Subject: [PATCH] [Add] better Logging in UI --- src/gui/elements/console_card.rs | 244 ++++++++++++++++++- src/gui/elements/log_card.rs | 390 +++++++++++++++++++++++++++---- src/util/logger.rs | 45 +++- 3 files changed, 622 insertions(+), 57 deletions(-) diff --git a/src/gui/elements/console_card.rs b/src/gui/elements/console_card.rs index a9c1d72..ca697a9 100644 --- a/src/gui/elements/console_card.rs +++ b/src/gui/elements/console_card.rs @@ -22,15 +22,25 @@ use crate::{ users::{user_manager, user_profile::UserProfile}, util::file_util, }; -use std::{any::Any, time::Duration}; +use std::{ + any::Any, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::time::Instant; pub struct ConsoleCard { focused: bool, pub title: String, pub content: String, + pub cursor_position: usize, borders: Borders, joins: Borders, + + cursor: Arc>, + last_swap: Arc>, + tab_index: usize, } impl ConsoleCard { @@ -39,10 +49,179 @@ impl ConsoleCard { focused: false, title: title.to_string(), content: content.to_string(), + cursor_position: content.chars().count(), borders: Borders::ALL, joins: Borders::NONE, + cursor: Arc::new(Mutex::new(true)), + last_swap: Arc::new(Mutex::new(Instant::now())), + tab_index: 0, } } + + fn byte_index(&self) -> usize { + self.content + .char_indices() + .nth(self.cursor_position) + .map(|(i, _)| i) + .unwrap_or(self.content.len()) + } + + fn cursor_visible(&self) -> bool { + if !self.focused { + return false; + } + + let mut visible = self.cursor.lock().unwrap(); + let mut last = self.last_swap.lock().unwrap(); + let now = Instant::now(); + + if now.duration_since(*last) >= Duration::from_millis(500) { + *visible = !*visible; + *last = now; + } + + *visible + } + + fn current_prefix(&self) -> Option<&str> { + if self.content.starts_with('/') { + Some("/") + } else { + None + } + } + + fn get_placeholder_text(&self) -> Option { + if self.content.is_empty() { + Some(" send message (start commands with /)".to_string()) + } else { + None + } + } + + fn cursor_spans(&self) -> Vec> { + let cursor_visible = self.cursor_visible(); + let cursor_style = Style::default().fg(Color::White).bg(Color::DarkGray); + let mut spans = Vec::new(); + + if self.content.is_empty() { + if self.focused { + if cursor_visible { + spans.push(Span::styled(" ", cursor_style)); + } else { + spans.push(Span::styled(" ", Style::default().fg(Color::White))); + } + spans.push(Span::styled( + "send message (start commands with /)", + Style::default().fg(Color::DarkGray), + )); + } else { + spans.push(Span::styled( + " send message (start commands with /)", + Style::default().fg(Color::DarkGray), + )); + } + return spans; + } + + let byte_index = self.byte_index(); + let before = self.content[..byte_index].to_string(); + let after = self.content[byte_index..].to_string(); + + let prefix_len = self.current_prefix().map(|s| s.len()).unwrap_or(0); + + if prefix_len > 0 && before.len() >= prefix_len { + let prefix = &before[..prefix_len]; + let rest = &before[prefix_len..]; + spans.push(Span::styled( + prefix.to_string(), + Self::style_for_part(true, false, false), + )); + if !rest.is_empty() { + spans.push(Span::styled( + rest.to_string(), + Style::default().fg(Color::White), + )); + } + } else if !before.is_empty() { + spans.push(Span::styled( + before.clone(), + Style::default().fg(Color::White), + )); + } + + if cursor_visible { + spans.push(Span::styled(" ", cursor_style)); + } + + if !after.is_empty() { + spans.push(Span::styled(after, Style::default().fg(Color::White))); + } + + spans + } + + fn style_for_part(is_prefix: bool, is_hint: bool, is_error: bool) -> Style { + if is_error { + return Style::default().fg(Color::Red); + } + + if is_hint { + return Style::default().fg(Color::DarkGray); + } + + if is_prefix { + return Style::default().fg(Color::DarkGray); + } + + Style::default().fg(Color::White) + } + + fn split_before_cursor(&self) -> (String, String) { + let byte_index = self.byte_index(); + let before = self.content[..byte_index].to_string(); + let after = self.content[byte_index..].to_string(); + (before, after) + } + + fn render_cursor_spans(&self) -> Vec> { + self.cursor_spans() + } + + fn move_cursor_left(&mut self) { + if self.cursor_position > 0 { + self.cursor_position -= 1; + } + } + + fn move_cursor_right(&mut self) { + let len = self.content.chars().count(); + if self.cursor_position < len { + self.cursor_position += 1; + } + } + + fn delete_at_cursor(&mut self) { + if self.content.is_empty() || self.cursor_position == 0 { + return; + } + + let start = self + .content + .char_indices() + .nth(self.cursor_position.saturating_sub(1)) + .map(|(i, _)| i) + .unwrap_or(0); + let end = self.byte_index(); + self.content.replace_range(start..end, ""); + self.cursor_position -= 1; + } + + fn insert_at_cursor(&mut self, c: char) { + let idx = self.byte_index(); + self.content.insert(idx, c); + self.cursor_position += 1; + } } impl Element for ConsoleCard { @@ -70,7 +249,8 @@ impl Element for ConsoleCard { Style::default() }); - let par = Paragraph::new(Line::from(Span::from(self.content.clone()))) + let spans = self.render_cursor_spans(); + let par = Paragraph::new(Line::from(spans)) .block(block) .scroll((0, 0)); f.render_widget(par, r); @@ -87,11 +267,11 @@ impl JoinableElement for ConsoleCard { self } - fn as_element(&self) -> &dyn Element { + fn as_element(&self) -> &(dyn Element + 'static) { self } - fn as_element_mut(&mut self) -> &mut dyn Element { + fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) { self } @@ -113,11 +293,11 @@ impl InteractableElement for ConsoleCard { self } - fn as_element(&self) -> &dyn Element { + fn as_element(&self) -> &(dyn Element + 'static) { self } - fn as_element_mut(&mut self) -> &mut dyn Element { + fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) { self } @@ -128,26 +308,69 @@ impl InteractableElement for ConsoleCard { log!(""); return InteractionResult::Handled; } + let command = self.content.clone(); let id = Uuid::new_v4(); let id = id.to_string(); let id = id.split_at(8).0; let task_id = format!("command_{}_{}", command, id); ACTIVE_TASKS.insert(task_id.clone()); + tokio::spawn(async move { run_command(&command).await; ACTIVE_TASKS.remove(&task_id); }); - self.content = "".to_string(); + + self.content.clear(); + self.cursor_position = 0; + self.tab_index = 0; InteractionResult::Handled } KeyCode::Backspace => { - self.content.pop(); + self.delete_at_cursor(); + InteractionResult::Handled + } + KeyCode::Delete => { + let len = self.content.chars().count(); + if self.cursor_position < len { + let start = self.byte_index(); + let end = self + .content + .char_indices() + .nth(self.cursor_position + 1) + .map(|(i, _)| i) + .unwrap_or(self.content.len()); + self.content.replace_range(start..end, ""); + } + InteractionResult::Handled + } + KeyCode::Left => { + self.move_cursor_left(); + InteractionResult::Handled + } + KeyCode::Right => { + self.move_cursor_right(); + InteractionResult::Handled + } + KeyCode::Home => { + self.cursor_position = 0; + InteractionResult::Handled + } + KeyCode::End => { + self.cursor_position = self.content.chars().count(); + InteractionResult::Handled + } + KeyCode::Tab => { + if let Some(prefix) = self.current_prefix() { + if prefix == "/" { + self.tab_index = self.tab_index.saturating_add(1); + } + } InteractionResult::Handled } _ => { if let Some(c) = key.code.as_char() { - self.content.push(c); + self.insert_at_cursor(c); InteractionResult::Handled } else { InteractionResult::Unhandled @@ -168,6 +391,7 @@ impl InteractableElement for ConsoleCard { self.focused = f; } } + pub async fn run_command(command: &str) { log!(":{}", command); @@ -267,8 +491,6 @@ pub async fn run_command(command: &str) { } pub async fn ping(time: u64) { - let time = time; - let conn = OMIKRON_CONNECTION.clone(); let response_cv = conn diff --git a/src/gui/elements/log_card.rs b/src/gui/elements/log_card.rs index fe125c8..51e2b35 100755 --- a/src/gui/elements/log_card.rs +++ b/src/gui/elements/log_card.rs @@ -1,41 +1,277 @@ -use crate::gui::elements::elements::{InteractableElement, JoinableElement}; +use crate::APP_STATE; +use crate::gui::elements::elements::{Element, InteractableElement, JoinableElement}; use crate::gui::interaction_result::InteractionResult; use crate::gui::util::borders::draw_block_joins; -use crate::{APP_STATE, gui::elements::elements::Element}; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, layout::Rect, style::{Color, Style}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Wrap}, + widgets::{Block, Borders, Paragraph}, }; use std::any::Any; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Sender { + User, + System, +} + +impl Sender { + pub fn prefix_color(self) -> Color { + match self { + Sender::User => Color::Magenta, + Sender::System => Color::Blue, + } + } +} + +impl Sender { + pub fn color(self) -> Color { + match self { + Sender::User => Color::Magenta, + Sender::System => Color::Blue, + } + } +} + +#[derive(Clone, Debug)] +pub struct LogEntry { + pub timestamp_ms: u128, + pub sender: Sender, + pub message: String, + pub is_error: bool, +} + +impl LogEntry { + pub fn new(sender: Sender, message: String, is_error: bool) -> Self { + Self { + timestamp_ms: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(), + sender, + message, + is_error, + } + } + + pub fn format_timestamp(&self) -> String { + let secs = (self.timestamp_ms / 1000) as i64; + let hours = (secs / 3600) % 24; + let minutes = (secs / 60) % 60; + let seconds = secs % 60; + format!("{:02}:{:02}:{:02}", hours, minutes, seconds) + } +} + #[derive(Clone)] pub struct UiLogEntry { - pub line: String, + pub sender: Sender, + pub message: String, + pub timestamp_ms: u128, + pub is_error: bool, pub color: Color, } pub struct LogCard { focused: bool, - scroll: u16, - + selected: bool, + scroll_offset: usize, + last_total_lines: usize, + last_visible_height: usize, pub borders: Borders, pub joins: Borders, } +struct RenderedLine { + prefix: String, + sender: Sender, + timestamp: String, + content: String, + is_error: bool, + is_first: bool, +} + impl LogCard { pub fn new() -> Self { Self { focused: false, - scroll: 1, + selected: false, + scroll_offset: 0, + last_total_lines: 0, + last_visible_height: 10, borders: Borders::ALL, joins: Borders::NONE, } } + + fn get_logs(&self) -> Vec { + let state = APP_STATE.lock().unwrap(); + state.get_logs().iter().cloned().collect() + } + + fn find_split_point(s: &str, max_width: usize) -> usize { + if max_width == 0 { + return s.len(); + } + + let mut current_width = 0usize; + let mut last_boundary = 0usize; + + for (idx, ch) in s.char_indices() { + let char_width = if ch.is_ascii() { 1 } else { 2 }; + if current_width + char_width > max_width { + if last_boundary == 0 { + return idx + ch.len_utf8(); + } + return last_boundary; + } + current_width += char_width; + last_boundary = idx + ch.len_utf8(); + } + + s.len() + } + + fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(bool, String)> { + let mut result = Vec::new(); + let continuation_prefix_width = 2; + let first_line_width = available_width.saturating_sub(continuation_prefix_width); + let continuation_content_width = available_width.saturating_sub(continuation_prefix_width); + + let paragraphs: Vec<&str> = entry.message.split('\n').collect(); + + for (para_idx, paragraph) in paragraphs.iter().enumerate() { + if paragraph.is_empty() { + if para_idx == 0 { + result.push((true, String::new())); + } else { + result.push((false, String::new())); + } + continue; + } + + let mut remaining = *paragraph; + let mut is_first_line = para_idx == 0; + + while !remaining.is_empty() { + let current_width = if is_first_line { + first_line_width + } else { + continuation_content_width + }; + + let split_point = Self::find_split_point(remaining, current_width); + let line_content = &remaining[..split_point]; + + result.push((is_first_line, line_content.to_string())); + + remaining = &remaining[split_point..]; + is_first_line = false; + } + } + + if result.is_empty() { + result.push((true, String::new())); + } + + result + } + + fn build_all_lines(&self, entries: Vec, width: usize) -> Vec { + let mut lines = Vec::new(); + + for entry in entries { + let wrapped = Self::wrap_entry(&entry, width); + let timestamp = { + let secs = (entry.timestamp_ms / 1000) as i64; + let hours = (secs / 3600) % 24; + let minutes = (secs / 60) % 60; + let seconds = secs % 60; + format!("{:02}:{:02}:{:02}", hours, minutes, seconds) + }; + + for (is_first, content) in wrapped { + lines.push(RenderedLine { + prefix: "│ ".to_string(), + sender: entry.sender, + timestamp: if is_first { + timestamp.clone() + } else { + String::new() + }, + content, + is_error: entry.is_error, + is_first, + }); + } + } + + lines + } + + fn calculate_view_window(&self, total_lines: usize, visible_height: usize) -> (usize, usize) { + if total_lines <= visible_height { + return (0, total_lines); + } + + let max_offset = total_lines - visible_height; + let clamped_offset = self.scroll_offset.min(max_offset); + + let end = total_lines - clamped_offset; + let start = end.saturating_sub(visible_height); + + (start, end) + } + + fn get_title_hints(&self) -> (bool, bool) { + if self.last_total_lines == 0 || self.last_total_lines <= self.last_visible_height { + return (false, false); + } + + let max_offset = self.last_total_lines - self.last_visible_height; + let can_scroll_up = self.scroll_offset < max_offset; + let can_scroll_down = self.scroll_offset > 0; + + (can_scroll_up, can_scroll_down) + } + + fn build_title(&self) -> String { + if !self.focused { + return "Logs".to_string(); + } + + let (can_up, can_down) = self.get_title_hints(); + + if !can_up && !can_down { + return "Logs".to_string(); + } + + let nav_symbol = if self.selected { "↑" } else { "j" }; + let down_symbol = if self.selected { "↓" } else { "k" }; + + match (can_up, can_down) { + (true, true) => format!("Logs ({} older {} newer)", nav_symbol, down_symbol), + (true, false) => format!("Logs ({} older)", nav_symbol), + (false, true) => format!("Logs ({} newer)", down_symbol), + (false, false) => "Logs".to_string(), + } + } + + fn scroll_up(&mut self) { + let max_offset = self + .last_total_lines + .saturating_sub(self.last_visible_height); + self.scroll_offset = (self.scroll_offset + 1).min(max_offset); + } + + fn scroll_down(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } } + impl Element for LogCard { fn as_any(&self) -> &dyn Any { self @@ -46,22 +282,10 @@ impl Element for LogCard { } fn render(&self, f: &mut Frame, area: Rect) { - let state = APP_STATE.lock().unwrap(); - let logs = state.get_logs(); - - let lines: Vec = logs - .iter() - .map(|log| { - Line::from(Span::raw(log.line.clone())).style(Style::default().fg(log.color)) - }) - .collect(); + let entries = self.get_logs(); let block = Block::default() - .title(if self.focused { - "Logs J/K to scroll" - } else { - "Logs" - }) + .title(self.build_title()) .borders(self.borders) .border_style(if self.focused { Style::default().fg(Color::Yellow) @@ -69,22 +293,68 @@ impl Element for LogCard { Style::default() }); - let inner_height = area.height.saturating_sub(2) as usize; + let inner_area = block.inner(area); + f.render_widget(block, area); - let total_lines = lines.len(); - let base_scroll = total_lines.saturating_sub(inner_height) as u16; + if inner_area.width == 0 || inner_area.height == 0 { + draw_block_joins(f, area, self.borders, self.joins); + return; + } - let scroll = base_scroll.saturating_sub(self.scroll); + let all_lines = self.build_all_lines(entries, inner_area.width as usize); + let total_lines = all_lines.len(); + let visible_height = inner_area.height as usize; - let paragraph = Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }) - .scroll((scroll, 0)); + let (start, end) = self.calculate_view_window(total_lines, visible_height); + let visible_lines = &all_lines[start..end]; + + let lines: Vec = visible_lines + .iter() + .map(|rl| { + let mut spans = Vec::new(); + + spans.push(Span::styled( + &rl.prefix, + Style::default().fg(rl.sender.prefix_color()), + )); + + if !rl.content.is_empty() { + let content_color = if rl.is_error { + Color::Red + } else { + Color::White + }; + spans.push(Span::styled( + &rl.content, + Style::default().fg(content_color), + )); + } + + if rl.is_first { + spans.push(Span::styled( + format!(" {}", rl.timestamp), + Style::default().fg(Color::DarkGray), + )); + } + + Line::from(spans) + }) + .collect(); + + for (idx, line) in lines.iter().enumerate() { + let line_area = Rect { + x: inner_area.x, + y: inner_area.y + idx as u16, + width: inner_area.width, + height: 1, + }; + f.render_widget(Paragraph::new(line.clone()), line_area); + } - f.render_widget(paragraph, area); draw_block_joins(f, area, self.borders, self.joins); } } + impl JoinableElement for LogCard { fn as_any(&self) -> &dyn Any { self @@ -94,11 +364,11 @@ impl JoinableElement for LogCard { self } - fn as_element(&self) -> &dyn Element { + fn as_element(&self) -> &(dyn Element + 'static) { self } - fn as_element_mut(&mut self) -> &mut dyn Element { + fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) { self } @@ -110,6 +380,7 @@ impl JoinableElement for LogCard { self.joins = joins; } } + impl InteractableElement for LogCard { fn as_any(&self) -> &dyn Any { self @@ -119,30 +390,65 @@ impl InteractableElement for LogCard { self } - fn as_element(&self) -> &dyn Element { + fn as_element(&self) -> &(dyn Element + 'static) { self } - fn as_element_mut(&mut self) -> &mut dyn Element { + fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) { self } fn interact(&mut self, key: KeyEvent) -> InteractionResult { + let entries = self.get_logs(); + let estimated_width = 80usize; + let all_lines = self.build_all_lines(entries, estimated_width); + + self.last_total_lines = all_lines.len(); + let visible_height = self.last_visible_height.max(1); + match key.code { - KeyCode::Char('J') | KeyCode::Char('j') => { - let state = APP_STATE.lock().unwrap(); - let logs = state.get_logs(); - if self.scroll < logs.len() as u16 { - self.scroll += 1; + KeyCode::Enter | KeyCode::Char(' ') => { + self.selected = !self.selected; + InteractionResult::Handled + } + KeyCode::Char('j') | KeyCode::Char('J') => { + let (can_up, _) = self.get_title_hints(); + if can_up { + self.scroll_up(); } InteractionResult::Handled } - KeyCode::Char('K') | KeyCode::Char('k') => { - if self.scroll > 1 { - self.scroll -= 1; + KeyCode::Char('k') | KeyCode::Char('K') => { + let (_, can_down) = self.get_title_hints(); + if can_down { + self.scroll_down(); } InteractionResult::Handled } + KeyCode::Up if self.selected => { + let (can_up, _) = self.get_title_hints(); + if can_up { + self.scroll_up(); + } + InteractionResult::Handled + } + KeyCode::Down if self.selected => { + let (_, can_down) = self.get_title_hints(); + if can_down { + self.scroll_down(); + } + InteractionResult::Handled + } + KeyCode::Home => { + if self.last_total_lines > visible_height { + self.scroll_offset = self.last_total_lines - visible_height; + } + InteractionResult::Handled + } + KeyCode::End => { + self.scroll_offset = 0; + InteractionResult::Handled + } _ => InteractionResult::Unhandled, } } diff --git a/src/util/logger.rs b/src/util/logger.rs index 2c09c33..5ec3bfd 100755 --- a/src/util/logger.rs +++ b/src/util/logger.rs @@ -13,7 +13,10 @@ use ttp_core::{CommunicationValue, DataTypes, DataValue}; use crate::{ APP_STATE, - gui::{elements::log_card::UiLogEntry, ui::UNIQUE}, + gui::{ + elements::log_card::{LogEntry, Sender, UiLogEntry}, + ui::UNIQUE, + }, langu::language_manager, }; @@ -72,14 +75,40 @@ pub fn startup() { let ts = fixed_box(&msg.timestamp_ms.to_string(), 13); - let line = format!("{} {}", msg.prefix, resolved_message); + let sender = match msg.kind { + PrintType::Client => Sender::User, + PrintType::Call + | PrintType::Iota + | PrintType::Omikron + | PrintType::Omega + | PrintType::General => Sender::System, + }; - let _ = writeln!(file, "{} {}", ts, line); + let entry = LogEntry::new(sender, resolved_message, msg.is_error); + + let prefix = if msg.prefix.is_empty() { + String::new() + } else { + format!("{} ", msg.prefix) + }; + + let line = format!("{}| {}", prefix, entry.message,); + + let _ = writeln!( + file, + "{} {} {}", + ts, + line, + format_timestamp_inline(entry.timestamp_ms) + ); let color = colorize(msg.kind, msg.is_error); let ui_entry = UiLogEntry { - line: line.clone(), + sender, + message: entry.message.clone(), + timestamp_ms: entry.timestamp_ms, + is_error: msg.is_error, color, }; @@ -91,6 +120,14 @@ pub fn startup() { }); } +fn format_timestamp_inline(timestamp_ms: u128) -> String { + let secs = (timestamp_ms / 1000) as i64; + let hours = (secs / 3600) % 24; + let minutes = (secs / 60) % 60; + let seconds = secs % 60; + format!("[{:02}:{:02}:{:02}]", hours, minutes, seconds) +} + fn colorize(kind: PrintType, is_error: bool) -> Color { if is_error { return Color::Red;