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:
dailz
2026-04-12 10:52:45 +08:00
parent c6e83cb8c2
commit 2ac3eb99c7
2 changed files with 183 additions and 18 deletions

View File

@@ -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));
}
}