feat(tui): add Span-based rendering with level colors and settings panel
Replace Line::styled with Span-based rendering: gutter (DarkGray fg) + content (level-based fg color). Add centered settings popup (S key) with j/k navigation, arrow key color cycling, number key shortcuts, Enter to save, Esc to cancel. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2305,6 +2305,7 @@ dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
]
|
||||
@@ -2328,6 +2329,7 @@ dependencies = [
|
||||
"log-viewer-core",
|
||||
"ratatui",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,8 +1,35 @@
|
||||
use crate::app::App;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
|
||||
use crate::app::{App, AppMode};
|
||||
use crate::color::level_fg;
|
||||
use log_viewer_core::config::ColorConfig;
|
||||
use log_viewer_core::types::LogLevel;
|
||||
|
||||
pub(crate) fn build_line_spans(
|
||||
gutter_text: String,
|
||||
content_text: String,
|
||||
is_cursor: bool,
|
||||
level: Option<&LogLevel>,
|
||||
config: &ColorConfig,
|
||||
) -> Line<'static> {
|
||||
let bg_color = if is_cursor {
|
||||
Color::DarkGray
|
||||
} else {
|
||||
Color::Reset
|
||||
};
|
||||
let level_fg = level_fg(level, config).unwrap_or(Color::White);
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
gutter_text,
|
||||
Style::default().fg(Color::DarkGray).bg(bg_color),
|
||||
),
|
||||
Span::styled(content_text, Style::default().fg(level_fg).bg(bg_color)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::Paragraph;
|
||||
|
||||
let outer = Layout::vertical([
|
||||
@@ -13,7 +40,9 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
|
||||
.split(frame.area());
|
||||
|
||||
// ── Title bar ──────────────────────────────────────────────────
|
||||
let title_text = if app.is_loaded() {
|
||||
let title_text = if app.mode == AppMode::Settings {
|
||||
" Color Settings".to_string()
|
||||
} else if app.is_loaded() {
|
||||
let name = app.file_name().unwrap_or("unknown");
|
||||
let cursor_display = if app.total_lines() == 0 {
|
||||
0
|
||||
@@ -30,22 +59,98 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
|
||||
);
|
||||
|
||||
// ── Content area ───────────────────────────────────────────────
|
||||
if !app.is_loaded() {
|
||||
if app.mode == AppMode::Settings {
|
||||
render_settings(frame, app, outer[1]);
|
||||
} else if !app.is_loaded() {
|
||||
frame.render_widget(Paragraph::new(" No file loaded"), outer[1]);
|
||||
} else {
|
||||
render_content(frame, app, outer[1]);
|
||||
}
|
||||
|
||||
// ── Status bar ─────────────────────────────────────────────────
|
||||
frame.render_widget(
|
||||
Paragraph::new(" j/k:scroll d/u:half-page f/b:page G/gg:jump Tab:format q:quit"),
|
||||
outer[2],
|
||||
);
|
||||
let status_text = if app.mode == AppMode::Settings {
|
||||
" j/k:navigate ←/→:change 1-8:jump Enter:save Esc:cancel"
|
||||
} else {
|
||||
" j/k:scroll d/u:half-page f/b:page G/gg:jump Tab:format S:settings q:quit"
|
||||
};
|
||||
frame.render_widget(Paragraph::new(status_text), outer[2]);
|
||||
}
|
||||
|
||||
pub fn render_settings(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layout::Rect) {
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
||||
|
||||
use crate::color::AVAILABLE_COLORS;
|
||||
|
||||
frame.render_widget(Clear, area);
|
||||
|
||||
let popup_w = ((area.width as u32 * 4 / 5).max(40)).min(area.width as u32) as u16;
|
||||
let popup_h = ((area.height as u32 * 4 / 5).max(14)).min(area.height as u32) as u16;
|
||||
let popup_x = area.width.saturating_sub(popup_w) / 2;
|
||||
let popup_y = area.height.saturating_sub(popup_h) / 2;
|
||||
let popup = ratatui::layout::Rect::new(popup_x, popup_y, popup_w, popup_h);
|
||||
|
||||
let block = Block::new().borders(Borders::ALL).title(" Color Settings ");
|
||||
let inner = block.inner(popup);
|
||||
frame.render_widget(block, popup);
|
||||
|
||||
let levels = [
|
||||
("ERROR", &app.settings_draft.error),
|
||||
("WARN", &app.settings_draft.warn),
|
||||
("INFO", &app.settings_draft.info),
|
||||
("DEBUG", &app.settings_draft.debug),
|
||||
("TRACE", &app.settings_draft.trace),
|
||||
("UNKNOWN", &app.settings_draft.unknown),
|
||||
];
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for (i, (level_name, color_name)) in levels.iter().enumerate() {
|
||||
let is_selected = i == app.settings_cursor;
|
||||
let cursor_marker = if is_selected { "▶ " } else { " " };
|
||||
|
||||
let preview_color = color_name.parse::<Color>().unwrap_or(Color::White);
|
||||
let color_block = format!("██ {}", color_name);
|
||||
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
cursor_marker.to_string(),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{:<8}", level_name),
|
||||
Style::default()
|
||||
.fg(preview_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(color_block, Style::default().fg(preview_color)),
|
||||
];
|
||||
|
||||
if is_selected {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled("←/→ ", Style::default().fg(Color::DarkGray)));
|
||||
for &c in AVAILABLE_COLORS {
|
||||
let marker = if c == *color_name { "●" } else { "○" };
|
||||
spans.push(Span::styled(
|
||||
format!("{}{} ", marker, c),
|
||||
Style::default().fg(c.parse::<Color>().unwrap_or(Color::White)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
lines.push(Line::raw(""));
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
" j/k: navigate ←/→: change 1-8: jump Enter: save Esc: cancel",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)]));
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layout::Rect) {
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
|
||||
let content_width = area.width as usize;
|
||||
@@ -109,11 +214,7 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo
|
||||
}
|
||||
|
||||
let is_cursor = logical_line == app.cursor_line;
|
||||
let style = if is_cursor {
|
||||
Style::default().bg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let level = app.level_cache.get(logical_line).and_then(|l| l.as_ref());
|
||||
|
||||
let gutter_text = if visual_row == 0 {
|
||||
format!(
|
||||
@@ -125,8 +226,13 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo
|
||||
format!("{:width$} \u{2502}", "", width = line_num_width)
|
||||
};
|
||||
|
||||
let full_line = format!("{}{}", gutter_text, text);
|
||||
lines.push(Line::styled(full_line, style));
|
||||
lines.push(build_line_spans(
|
||||
gutter_text,
|
||||
text.clone(),
|
||||
is_cursor,
|
||||
level,
|
||||
&app.color_config,
|
||||
));
|
||||
|
||||
current_visual_offset += 1;
|
||||
}
|
||||
@@ -137,8 +243,65 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo
|
||||
}
|
||||
|
||||
while lines.len() < available_rows {
|
||||
lines.push(Line::styled(String::new(), Style::default()));
|
||||
lines.push(Line::default());
|
||||
}
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use log_viewer_core::config::ColorConfig;
|
||||
use log_viewer_core::types::LogLevel;
|
||||
|
||||
#[test]
|
||||
fn test_build_line_spans_gutter_fg_darkgray() {
|
||||
let line = build_line_spans(
|
||||
"1 │".to_string(),
|
||||
"hello".to_string(),
|
||||
false,
|
||||
Some(&LogLevel::Error),
|
||||
&ColorConfig::default(),
|
||||
);
|
||||
assert_eq!(line.spans[0].style.fg, Some(Color::DarkGray));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_line_spans_content_fg_level_color() {
|
||||
let line = build_line_spans(
|
||||
"1 │".to_string(),
|
||||
"hello".to_string(),
|
||||
false,
|
||||
Some(&LogLevel::Error),
|
||||
&ColorConfig::default(),
|
||||
);
|
||||
assert_eq!(line.spans[1].style.fg, Some(Color::Red));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_line_spans_cursor_bg_darkgray() {
|
||||
let line = build_line_spans(
|
||||
"1 │".to_string(),
|
||||
"hello".to_string(),
|
||||
true,
|
||||
Some(&LogLevel::Error),
|
||||
&ColorConfig::default(),
|
||||
);
|
||||
assert_eq!(line.spans[0].style.bg, Some(Color::DarkGray));
|
||||
assert_eq!(line.spans[1].style.bg, Some(Color::DarkGray));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_line_spans_non_cursor_bg_reset() {
|
||||
let line = build_line_spans(
|
||||
"1 │".to_string(),
|
||||
"hello".to_string(),
|
||||
false,
|
||||
Some(&LogLevel::Error),
|
||||
&ColorConfig::default(),
|
||||
);
|
||||
assert_eq!(line.spans[0].style.bg, Some(Color::Reset));
|
||||
assert_eq!(line.spans[1].style.bg, Some(Color::Reset));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user