feat(tui): rewrite UI with line numbers, soft wrap, and scrolling
This commit is contained in:
@@ -29,7 +29,7 @@ fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
while !app.should_quit {
|
||||
terminal.draw(|frame| ui::render(frame, &app))?;
|
||||
terminal.draw(|frame| ui::render(frame, &mut app))?;
|
||||
if crossterm::event::poll(std::time::Duration::from_millis(100))? {
|
||||
match crossterm::event::read()? {
|
||||
crossterm::event::Event::Key(key) => app.handle_key(key),
|
||||
|
||||
@@ -1,26 +1,139 @@
|
||||
use crate::app::App;
|
||||
|
||||
pub fn render(frame: &mut ratatui::Frame, _app: &App) {
|
||||
pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::Paragraph;
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
let outer = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
// ── Title bar ──────────────────────────────────────────────────
|
||||
let title_text = if app.is_loaded() {
|
||||
let name = app.file_name().unwrap_or("unknown");
|
||||
format!(" {} [{}/{}]", name, app.cursor_line + 1, app.total_lines())
|
||||
} else {
|
||||
" Log Viewer".to_string()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(" Log Viewer").style(ratatui::style::Style::default().bold()),
|
||||
chunks[0],
|
||||
Paragraph::new(title_text).style(Style::default().bold()),
|
||||
outer[0],
|
||||
);
|
||||
|
||||
// Main area — 使用 Block::new()(ratatui 0.30 推荐风格)
|
||||
frame.render_widget(
|
||||
Block::new().borders(Borders::ALL).title("No file loaded"),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
frame.render_widget(Paragraph::new(" Press '?' for help | q to quit"), chunks[2]);
|
||||
// ── Content area ───────────────────────────────────────────────
|
||||
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 q:quit"),
|
||||
outer[2],
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
let content_height = area.height as usize;
|
||||
|
||||
app.content_height = area.height;
|
||||
app.content_width = area.width;
|
||||
|
||||
let total_lines = app.total_lines();
|
||||
let line_num_width = if total_lines > 0 {
|
||||
total_lines.to_string().len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let gutter_width = if total_lines > 0 {
|
||||
line_num_width + 1 + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let actual_content_width = content_width.saturating_sub(gutter_width);
|
||||
|
||||
if content_height == 0 || actual_content_width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
app.recompute_wrap_cache(actual_content_width);
|
||||
let mut visual_acc: usize = 0;
|
||||
let mut start_logical: usize = 0;
|
||||
let mut offset_in_line: usize = 0;
|
||||
let v_offset = app.v_offset;
|
||||
|
||||
for (i, &h) in app.visual_heights.iter().enumerate() {
|
||||
if visual_acc.saturating_add(h) > v_offset {
|
||||
start_logical = i;
|
||||
offset_in_line = v_offset.saturating_sub(visual_acc);
|
||||
break;
|
||||
}
|
||||
visual_acc += h;
|
||||
if i == app.visual_heights.len() - 1 {
|
||||
start_logical = i;
|
||||
offset_in_line = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let mut current_visual_offset: usize = 0;
|
||||
let available_rows = content_height;
|
||||
|
||||
for logical_line in start_logical..total_lines {
|
||||
let wrapped = &app.wrap_cache[logical_line];
|
||||
let start_row = if logical_line == start_logical {
|
||||
offset_in_line
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
for (visual_row, text) in wrapped.iter().enumerate().skip(start_row) {
|
||||
if current_visual_offset >= available_rows {
|
||||
break;
|
||||
}
|
||||
|
||||
let is_cursor = logical_line == app.cursor_line;
|
||||
let style = if is_cursor {
|
||||
Style::default().bg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let gutter_text = if visual_row == 0 {
|
||||
format!(
|
||||
"{:>width$} \u{2502}",
|
||||
logical_line + 1,
|
||||
width = line_num_width
|
||||
)
|
||||
} else {
|
||||
format!("{:width$} \u{2502}", "", width = line_num_width)
|
||||
};
|
||||
|
||||
let full_line = format!("{}{}", gutter_text, text);
|
||||
lines.push(Line::styled(full_line, style));
|
||||
|
||||
current_visual_offset += 1;
|
||||
}
|
||||
|
||||
if current_visual_offset >= available_rows {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while lines.len() < available_rows {
|
||||
lines.push(Line::styled(String::new(), Style::default()));
|
||||
}
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user