feat(console): Add scrolling support for System Logs and Editor panels
System Logs: - Add scroll_offset tracking with auto-scroll to bottom on new logs - Up/Down/j/k keys to scroll line by line - PageUp/PageDown for page scrolling - Home/End to jump to top/bottom - Show scroll indicators in title: [^v], [SCROLL] when not auto-scrolling - Display log count in title Editor: - Fix scroll_offset to follow cursor when moving up/down - Add PageUp/PageDown for faster navigation - Add Ctrl+Home/Ctrl+End to jump to start/end of file - ensure_cursor_visible() keeps cursor in view Tab Navigation: - FileTree -> Editor (if open) or Logs -> Chat -> back to start - Consistent cycling through all panels
This commit is contained in:
parent
824b12365b
commit
715a60315e
3 changed files with 277 additions and 22 deletions
|
|
@ -1,6 +1,7 @@
|
|||
use crate::shared::state::AppState;
|
||||
use color_eyre::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct Editor {
|
||||
file_path: String,
|
||||
bucket: String,
|
||||
|
|
@ -8,6 +9,7 @@ pub struct Editor {
|
|||
content: String,
|
||||
cursor_pos: usize,
|
||||
scroll_offset: usize,
|
||||
visible_lines: usize,
|
||||
modified: bool,
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +46,7 @@ impl Editor {
|
|||
content,
|
||||
cursor_pos: 0,
|
||||
scroll_offset: 0,
|
||||
visible_lines: 20,
|
||||
modified: false,
|
||||
})
|
||||
}
|
||||
|
|
@ -63,11 +66,34 @@ impl Editor {
|
|||
pub fn file_path(&self) -> &str {
|
||||
&self.file_path
|
||||
}
|
||||
pub fn set_visible_lines(&mut self, lines: usize) {
|
||||
self.visible_lines = lines.max(5);
|
||||
}
|
||||
|
||||
fn get_cursor_line(&self) -> usize {
|
||||
self.content[..self.cursor_pos].lines().count()
|
||||
}
|
||||
|
||||
fn ensure_cursor_visible(&mut self) {
|
||||
let cursor_line = self.get_cursor_line();
|
||||
|
||||
// Scroll up if cursor is above visible area
|
||||
if cursor_line < self.scroll_offset {
|
||||
self.scroll_offset = cursor_line;
|
||||
}
|
||||
|
||||
// Scroll down if cursor is below visible area (leave some margin)
|
||||
let visible = self.visible_lines.saturating_sub(3);
|
||||
if cursor_line >= self.scroll_offset + visible {
|
||||
self.scroll_offset = cursor_line.saturating_sub(visible) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self, cursor_blink: bool) -> String {
|
||||
let lines: Vec<&str> = self.content.lines().collect();
|
||||
let total_lines = lines.len().max(1);
|
||||
let visible_lines = 25;
|
||||
let cursor_line = self.content[..self.cursor_pos].lines().count();
|
||||
let visible_lines = self.visible_lines;
|
||||
let cursor_line = self.get_cursor_line();
|
||||
let cursor_col = self.content[..self.cursor_pos]
|
||||
.lines()
|
||||
.last()
|
||||
|
|
@ -121,6 +147,7 @@ impl Editor {
|
|||
self.cursor_pos = (self.cursor_pos - prev_line_end - 1).min(prev_line_end);
|
||||
}
|
||||
}
|
||||
self.ensure_cursor_visible();
|
||||
}
|
||||
pub fn move_down(&mut self) {
|
||||
if let Some(next_line_start) = self.content[self.cursor_pos..].find('\n') {
|
||||
|
|
@ -140,6 +167,37 @@ impl Editor {
|
|||
self.cursor_pos = target_pos;
|
||||
}
|
||||
}
|
||||
self.ensure_cursor_visible();
|
||||
}
|
||||
|
||||
pub fn page_up(&mut self) {
|
||||
for _ in 0..self.visible_lines.saturating_sub(2) {
|
||||
if self.content[..self.cursor_pos].rfind('\n').is_none() {
|
||||
break;
|
||||
}
|
||||
self.move_up();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_down(&mut self) {
|
||||
for _ in 0..self.visible_lines.saturating_sub(2) {
|
||||
if self.content[self.cursor_pos..].find('\n').is_none() {
|
||||
break;
|
||||
}
|
||||
self.move_down();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_up(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
|
||||
pub fn scroll_down(&mut self) {
|
||||
let total_lines = self.content.lines().count().max(1);
|
||||
let max_scroll = total_lines.saturating_sub(self.visible_lines.saturating_sub(3));
|
||||
if self.scroll_offset < max_scroll {
|
||||
self.scroll_offset += 1;
|
||||
}
|
||||
}
|
||||
pub fn move_left(&mut self) {
|
||||
if self.cursor_pos > 0 {
|
||||
|
|
@ -167,5 +225,20 @@ impl Editor {
|
|||
self.modified = true;
|
||||
self.content.insert(self.cursor_pos, '\n');
|
||||
self.cursor_pos += 1;
|
||||
self.ensure_cursor_visible();
|
||||
}
|
||||
|
||||
pub fn goto_line(&mut self, line: usize) {
|
||||
let lines: Vec<&str> = self.content.lines().collect();
|
||||
let target_line = line.saturating_sub(1).min(lines.len().saturating_sub(1));
|
||||
|
||||
self.cursor_pos = 0;
|
||||
for (i, line_content) in lines.iter().enumerate() {
|
||||
if i == target_line {
|
||||
break;
|
||||
}
|
||||
self.cursor_pos += line_content.len() + 1; // +1 for newline
|
||||
}
|
||||
self.ensure_cursor_visible();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
use chrono::Local;
|
||||
use log::{LevelFilter, Log, Metadata, Record, SetLoggerError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub struct LogPanel {
|
||||
logs: Vec<String>,
|
||||
max_logs: usize,
|
||||
scroll_offset: usize,
|
||||
auto_scroll: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LogPanel {
|
||||
|
|
@ -11,39 +14,130 @@ impl std::fmt::Debug for LogPanel {
|
|||
f.debug_struct("LogPanel")
|
||||
.field("logs_count", &self.logs.len())
|
||||
.field("max_logs", &self.max_logs)
|
||||
.field("scroll_offset", &self.scroll_offset)
|
||||
.field("auto_scroll", &self.auto_scroll)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl LogPanel {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
logs: Vec::with_capacity(1000),
|
||||
max_logs: 1000,
|
||||
scroll_offset: 0,
|
||||
auto_scroll: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_log(&mut self, entry: &str) {
|
||||
if self.logs.len() >= self.max_logs {
|
||||
self.logs.remove(0);
|
||||
// Adjust scroll offset if we removed a log
|
||||
if self.scroll_offset > 0 {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
self.logs.push(entry.to_string());
|
||||
|
||||
// Auto-scroll to bottom if enabled
|
||||
if self.auto_scroll {
|
||||
self.scroll_to_bottom();
|
||||
}
|
||||
}
|
||||
pub fn render(&self) -> String {
|
||||
let visible_logs = if self.logs.len() > 10 {
|
||||
&self.logs[self.logs.len() - 10..]
|
||||
|
||||
pub fn scroll_up(&mut self, lines: usize) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(lines);
|
||||
self.auto_scroll = false;
|
||||
}
|
||||
|
||||
pub fn scroll_down(&mut self, lines: usize, visible_lines: usize) {
|
||||
let max_scroll = self.logs.len().saturating_sub(visible_lines);
|
||||
self.scroll_offset = (self.scroll_offset + lines).min(max_scroll);
|
||||
|
||||
// Re-enable auto-scroll if we're at the bottom
|
||||
if self.scroll_offset >= max_scroll {
|
||||
self.auto_scroll = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_to_bottom(&mut self) {
|
||||
// This will be adjusted when rendering based on visible lines
|
||||
self.scroll_offset = usize::MAX;
|
||||
self.auto_scroll = true;
|
||||
}
|
||||
|
||||
pub fn scroll_to_top(&mut self) {
|
||||
self.scroll_offset = 0;
|
||||
self.auto_scroll = false;
|
||||
}
|
||||
|
||||
pub fn page_up(&mut self, visible_lines: usize) {
|
||||
self.scroll_up(visible_lines.saturating_sub(1));
|
||||
}
|
||||
|
||||
pub fn page_down(&mut self, visible_lines: usize) {
|
||||
self.scroll_down(visible_lines.saturating_sub(1), visible_lines);
|
||||
}
|
||||
|
||||
pub fn render(&self, visible_lines: usize) -> String {
|
||||
if self.logs.is_empty() {
|
||||
return " Waiting for logs...".to_string();
|
||||
}
|
||||
|
||||
let total_logs = self.logs.len();
|
||||
|
||||
// Calculate actual scroll offset
|
||||
let max_scroll = total_logs.saturating_sub(visible_lines);
|
||||
let actual_offset = if self.scroll_offset == usize::MAX {
|
||||
max_scroll
|
||||
} else {
|
||||
&self.logs[..]
|
||||
self.scroll_offset.min(max_scroll)
|
||||
};
|
||||
|
||||
// Get visible slice
|
||||
let end = (actual_offset + visible_lines).min(total_logs);
|
||||
let start = actual_offset;
|
||||
|
||||
let visible_logs = &self.logs[start..end];
|
||||
visible_logs.join("\n")
|
||||
}
|
||||
|
||||
pub fn render_with_scroll_indicator(&self, visible_lines: usize) -> (String, bool, bool) {
|
||||
let content = self.render(visible_lines);
|
||||
let total_logs = self.logs.len();
|
||||
let max_scroll = total_logs.saturating_sub(visible_lines);
|
||||
let actual_offset = if self.scroll_offset == usize::MAX {
|
||||
max_scroll
|
||||
} else {
|
||||
self.scroll_offset.min(max_scroll)
|
||||
};
|
||||
|
||||
let can_scroll_up = actual_offset > 0;
|
||||
let can_scroll_down = actual_offset < max_scroll;
|
||||
|
||||
(content, can_scroll_up, can_scroll_down)
|
||||
}
|
||||
|
||||
pub fn logs_count(&self) -> usize {
|
||||
self.logs.len()
|
||||
}
|
||||
|
||||
pub fn is_auto_scroll(&self) -> bool {
|
||||
self.auto_scroll
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UiLogger {
|
||||
log_panel: Arc<Mutex<LogPanel>>,
|
||||
filter: LevelFilter,
|
||||
}
|
||||
|
||||
impl Log for UiLogger {
|
||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||
metadata.level() <= self.filter
|
||||
}
|
||||
|
||||
fn log(&self, record: &Record) {
|
||||
if self.enabled(record.metadata()) {
|
||||
let timestamp = Local::now().format("%H:%M:%S");
|
||||
|
|
@ -60,8 +154,10 @@ impl Log for UiLogger {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
pub fn init_logger(log_panel: Arc<Mutex<LogPanel>>) -> Result<(), SetLoggerError> {
|
||||
let logger = Box::new(UiLogger {
|
||||
log_panel,
|
||||
|
|
|
|||
|
|
@ -506,8 +506,10 @@ impl XtreeUI {
|
|||
.border_style(Style::default().fg(border))
|
||||
.style(Style::default().bg(bg));
|
||||
|
||||
// Calculate visible lines for log panel (area height minus borders)
|
||||
let logs_visible_lines = main_chunks[2].height.saturating_sub(2) as usize;
|
||||
let logs_content = if let Ok(panel) = self.log_panel.lock() {
|
||||
panel.render()
|
||||
panel.render(logs_visible_lines)
|
||||
} else {
|
||||
String::from(" Waiting for logs...")
|
||||
};
|
||||
|
|
@ -720,12 +722,24 @@ impl XtreeUI {
|
|||
title_bg: Color,
|
||||
title_fg: Color,
|
||||
) {
|
||||
// Calculate visible lines (area height minus borders)
|
||||
let visible_lines = area.height.saturating_sub(2) as usize;
|
||||
|
||||
let log_panel = self.log_panel.try_lock();
|
||||
let log_lines = if let Ok(panel) = log_panel {
|
||||
panel.render()
|
||||
} else {
|
||||
"Loading logs...".to_string()
|
||||
};
|
||||
let (log_lines, can_scroll_up, can_scroll_down, logs_count, auto_scroll) =
|
||||
if let Ok(panel) = log_panel {
|
||||
let (content, up, down) = panel.render_with_scroll_indicator(visible_lines);
|
||||
(
|
||||
content,
|
||||
up,
|
||||
down,
|
||||
panel.logs_count(),
|
||||
panel.is_auto_scroll(),
|
||||
)
|
||||
} else {
|
||||
("Loading logs...".to_string(), false, false, 0, true)
|
||||
};
|
||||
|
||||
let is_active = self.active_panel == ActivePanel::Logs;
|
||||
let border_color = if is_active {
|
||||
border_active
|
||||
|
|
@ -740,8 +754,26 @@ impl XtreeUI {
|
|||
} else {
|
||||
Style::default().fg(title_fg).bg(title_bg)
|
||||
};
|
||||
|
||||
// Build title with scroll indicators
|
||||
let scroll_indicator = if can_scroll_up && can_scroll_down {
|
||||
" [^v] "
|
||||
} else if can_scroll_up {
|
||||
" [^] "
|
||||
} else if can_scroll_down {
|
||||
" [v] "
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let auto_indicator = if auto_scroll { "" } else { " [SCROLL] " };
|
||||
let title_text = format!(
|
||||
" SYSTEM LOGS ({}) {}{}",
|
||||
logs_count, scroll_indicator, auto_indicator
|
||||
);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(" SYSTEM LOGS ", title_style))
|
||||
.title(Span::styled(title_text, title_style))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(border_color))
|
||||
.style(Style::default().bg(bg));
|
||||
|
|
@ -816,7 +848,11 @@ impl XtreeUI {
|
|||
}
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.active_panel = ActivePanel::Chat;
|
||||
if self.editor.is_some() {
|
||||
self.active_panel = ActivePanel::Editor;
|
||||
} else {
|
||||
self.active_panel = ActivePanel::Logs;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('q') => {
|
||||
self.should_quit = true;
|
||||
|
|
@ -841,6 +877,19 @@ impl XtreeUI {
|
|||
KeyCode::Down => editor.move_down(),
|
||||
KeyCode::Left => editor.move_left(),
|
||||
KeyCode::Right => editor.move_right(),
|
||||
KeyCode::PageUp => editor.page_up(),
|
||||
KeyCode::PageDown => editor.page_down(),
|
||||
KeyCode::Home => {
|
||||
if modifiers.contains(KeyModifiers::CONTROL) {
|
||||
editor.goto_line(1);
|
||||
}
|
||||
}
|
||||
KeyCode::End => {
|
||||
if modifiers.contains(KeyModifiers::CONTROL) {
|
||||
let line_count = editor.file_path().lines().count().max(1);
|
||||
editor.goto_line(line_count);
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => editor.insert_char(c),
|
||||
KeyCode::Backspace => editor.backspace(),
|
||||
KeyCode::Enter => editor.insert_newline(),
|
||||
|
|
@ -857,9 +906,52 @@ impl XtreeUI {
|
|||
}
|
||||
}
|
||||
}
|
||||
ActivePanel::Logs => match key {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if let Ok(mut panel) = self.log_panel.lock() {
|
||||
panel.scroll_up(1);
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if let Ok(mut panel) = self.log_panel.lock() {
|
||||
panel.scroll_down(1, 10); // approximate visible lines
|
||||
}
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
if let Ok(mut panel) = self.log_panel.lock() {
|
||||
panel.page_up(10);
|
||||
}
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
if let Ok(mut panel) = self.log_panel.lock() {
|
||||
panel.page_down(10);
|
||||
}
|
||||
}
|
||||
KeyCode::Home => {
|
||||
if let Ok(mut panel) = self.log_panel.lock() {
|
||||
panel.scroll_to_top();
|
||||
}
|
||||
}
|
||||
KeyCode::End => {
|
||||
if let Ok(mut panel) = self.log_panel.lock() {
|
||||
panel.scroll_to_bottom();
|
||||
}
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.active_panel = ActivePanel::Chat;
|
||||
}
|
||||
KeyCode::Char('q') => {
|
||||
self.should_quit = true;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ActivePanel::Chat => match key {
|
||||
KeyCode::Tab => {
|
||||
self.active_panel = ActivePanel::FileTree;
|
||||
if self.editor.is_some() {
|
||||
self.active_panel = ActivePanel::Editor;
|
||||
} else {
|
||||
self.active_panel = ActivePanel::FileTree;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let (Some(chat_panel), Some(file_tree), Some(app_state)) =
|
||||
|
|
@ -887,13 +979,7 @@ impl XtreeUI {
|
|||
},
|
||||
ActivePanel::Status => match key {
|
||||
KeyCode::Tab => {
|
||||
self.active_panel = ActivePanel::Logs;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ActivePanel::Logs => match key {
|
||||
KeyCode::Tab => {
|
||||
self.active_panel = ActivePanel::FileTree;
|
||||
self.active_panel = ActivePanel::Chat;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue