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",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
@@ -2328,6 +2329,7 @@ dependencies = [
|
|||||||
"log-viewer-core",
|
"log-viewer-core",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[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) {
|
pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
|
||||||
use ratatui::layout::{Constraint, Layout};
|
use ratatui::layout::{Constraint, Layout};
|
||||||
use ratatui::style::Style;
|
|
||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
|
|
||||||
let outer = Layout::vertical([
|
let outer = Layout::vertical([
|
||||||
@@ -13,7 +40,9 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
|
|||||||
.split(frame.area());
|
.split(frame.area());
|
||||||
|
|
||||||
// ── Title bar ──────────────────────────────────────────────────
|
// ── 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 name = app.file_name().unwrap_or("unknown");
|
||||||
let cursor_display = if app.total_lines() == 0 {
|
let cursor_display = if app.total_lines() == 0 {
|
||||||
0
|
0
|
||||||
@@ -30,22 +59,98 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── Content area ───────────────────────────────────────────────
|
// ── 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]);
|
frame.render_widget(Paragraph::new(" No file loaded"), outer[1]);
|
||||||
} else {
|
} else {
|
||||||
render_content(frame, app, outer[1]);
|
render_content(frame, app, outer[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Status bar ─────────────────────────────────────────────────
|
// ── Status bar ─────────────────────────────────────────────────
|
||||||
frame.render_widget(
|
let status_text = if app.mode == AppMode::Settings {
|
||||||
Paragraph::new(" j/k:scroll d/u:half-page f/b:page G/gg:jump Tab:format q:quit"),
|
" j/k:navigate ←/→:change 1-8:jump Enter:save Esc:cancel"
|
||||||
outer[2],
|
} 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) {
|
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;
|
use ratatui::widgets::Paragraph;
|
||||||
|
|
||||||
let content_width = area.width as usize;
|
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 is_cursor = logical_line == app.cursor_line;
|
||||||
let style = if is_cursor {
|
let level = app.level_cache.get(logical_line).and_then(|l| l.as_ref());
|
||||||
Style::default().bg(Color::DarkGray)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let gutter_text = if visual_row == 0 {
|
let gutter_text = if visual_row == 0 {
|
||||||
format!(
|
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)
|
format!("{:width$} \u{2502}", "", width = line_num_width)
|
||||||
};
|
};
|
||||||
|
|
||||||
let full_line = format!("{}{}", gutter_text, text);
|
lines.push(build_line_spans(
|
||||||
lines.push(Line::styled(full_line, style));
|
gutter_text,
|
||||||
|
text.clone(),
|
||||||
|
is_cursor,
|
||||||
|
level,
|
||||||
|
&app.color_config,
|
||||||
|
));
|
||||||
|
|
||||||
current_visual_offset += 1;
|
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 {
|
while lines.len() < available_rows {
|
||||||
lines.push(Line::styled(String::new(), Style::default()));
|
lines.push(Line::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
frame.render_widget(Paragraph::new(lines), area);
|
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