[Wip] CLI & Daemon
This commit is contained in:
parent
3bc5cc959a
commit
6a535099bb
44 changed files with 4417 additions and 303 deletions
|
|
@ -1,4 +1,4 @@
|
|||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::Rect,
|
||||
|
|
@ -35,6 +35,10 @@ pub struct ConsoleCard {
|
|||
last_swap: Arc<Mutex<Instant>>,
|
||||
pending_restore: Arc<Mutex<Option<String>>>,
|
||||
pending_confirmation: Option<String>,
|
||||
history: Vec<String>,
|
||||
history_index: Option<usize>,
|
||||
history_draft: String,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
impl ConsoleCard {
|
||||
|
|
@ -51,6 +55,10 @@ impl ConsoleCard {
|
|||
last_swap: Arc::new(Mutex::new(Instant::now())),
|
||||
pending_restore: Arc::new(Mutex::new(None)),
|
||||
pending_confirmation: None,
|
||||
history: Vec::new(),
|
||||
history_index: None,
|
||||
history_draft: String::new(),
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -151,6 +159,9 @@ impl ConsoleCard {
|
|||
}
|
||||
|
||||
fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Span<'static>> {
|
||||
if let Some(message) = &self.message {
|
||||
return vec![Span::styled(message.clone(), theme.console.error)];
|
||||
}
|
||||
if let Some(command) = &self.pending_confirmation {
|
||||
return vec![Span::styled(
|
||||
format!("Confirm `{command}`? [y/N]"),
|
||||
|
|
@ -163,11 +174,17 @@ impl ConsoleCard {
|
|||
fn is_destructive(command: &str) -> bool {
|
||||
matches!(
|
||||
command.trim_start_matches('/').trim(),
|
||||
"restart" | "reload" | "stop" | "shutdown" | "regenerate keys"
|
||||
"restart"
|
||||
| "reload"
|
||||
| "stop"
|
||||
| "shutdown"
|
||||
| "regenerate keys"
|
||||
| "identity rotate"
|
||||
) || command
|
||||
.trim_start_matches('/')
|
||||
.trim_start()
|
||||
.starts_with("user remove ")
|
||||
.split_once(" remove ")
|
||||
.is_some_and(|(noun, _)| matches!(noun, "user" | "users"))
|
||||
}
|
||||
|
||||
fn dispatch_command(&self, command: String) {
|
||||
|
|
@ -214,6 +231,69 @@ impl ConsoleCard {
|
|||
self.content.insert(idx, c);
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
|
||||
pub fn handle_paste(&mut self, text: &str) {
|
||||
let sanitized = text.replace(['\r', '\n'], " ");
|
||||
let index = self.byte_index();
|
||||
self.content.insert_str(index, &sanitized);
|
||||
self.cursor_position += sanitized.chars().count();
|
||||
self.message = None;
|
||||
}
|
||||
|
||||
fn set_editor(&mut self, value: String) {
|
||||
self.content = value;
|
||||
self.cursor_position = self.content.chars().count();
|
||||
}
|
||||
|
||||
fn history_previous(&mut self) {
|
||||
if self.history.is_empty() {
|
||||
return;
|
||||
}
|
||||
let index = match self.history_index {
|
||||
None => {
|
||||
self.history_draft = self.content.clone();
|
||||
self.history.len() - 1
|
||||
}
|
||||
Some(index) => index.saturating_sub(1),
|
||||
};
|
||||
self.history_index = Some(index);
|
||||
self.set_editor(self.history[index].clone());
|
||||
self.message = None;
|
||||
}
|
||||
|
||||
fn history_next(&mut self) {
|
||||
let Some(index) = self.history_index else {
|
||||
return;
|
||||
};
|
||||
if index + 1 < self.history.len() {
|
||||
self.history_index = Some(index + 1);
|
||||
self.set_editor(self.history[index + 1].clone());
|
||||
} else {
|
||||
self.history_index = None;
|
||||
let draft = std::mem::take(&mut self.history_draft);
|
||||
self.set_editor(draft);
|
||||
}
|
||||
self.message = None;
|
||||
}
|
||||
|
||||
fn complete(&mut self) -> bool {
|
||||
let completions = iota_ipc::text_commands::completions(&self.content);
|
||||
if completions.len() == 1 {
|
||||
let leading_slash = self.content.starts_with('/');
|
||||
self.set_editor(format!(
|
||||
"{}{}",
|
||||
if leading_slash { "/" } else { "" },
|
||||
completions[0]
|
||||
));
|
||||
self.message = None;
|
||||
true
|
||||
} else if completions.len() > 1 {
|
||||
self.message = Some(format!("Matches: {}", completions.join(", ")));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for ConsoleCard {
|
||||
|
|
@ -226,6 +306,21 @@ impl Element for ConsoleCard {
|
|||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
|
||||
if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||
let inner = crate::controls::panel::render_panel(
|
||||
f,
|
||||
r,
|
||||
&self.title,
|
||||
self.focused,
|
||||
context.theme,
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(self.render_cursor_spans(context.theme)))
|
||||
.style(context.theme.console.text),
|
||||
inner,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let block = Block::default()
|
||||
.borders(self.borders)
|
||||
.title(self.title.clone())
|
||||
|
|
@ -304,6 +399,7 @@ impl InteractableElement for ConsoleCard {
|
|||
if let Some(restored) = self.pending_restore.lock().unwrap().take() {
|
||||
self.content = restored;
|
||||
self.cursor_position = self.content.chars().count();
|
||||
self.message = Some("Command failed; restored for retry.".into());
|
||||
}
|
||||
|
||||
if let Some(command) = self.pending_confirmation.take() {
|
||||
|
|
@ -320,6 +416,22 @@ impl InteractableElement for ConsoleCard {
|
|||
}
|
||||
|
||||
let command = self.content.clone();
|
||||
if let Some(error) = iota_ipc::text_commands::validation_error(&command) {
|
||||
self.message = Some(error);
|
||||
return InteractionResult::Handled;
|
||||
}
|
||||
if command.trim_start_matches('/').trim() == "help" {
|
||||
self.message = Some(format!(
|
||||
"Commands: {}",
|
||||
iota_ipc::text_commands::COMMANDS.join(", ")
|
||||
));
|
||||
return InteractionResult::Handled;
|
||||
}
|
||||
if self.history.last() != Some(&command) {
|
||||
self.history.push(command.clone());
|
||||
}
|
||||
self.history_index = None;
|
||||
self.history_draft.clear();
|
||||
self.content.clear();
|
||||
self.cursor_position = 0;
|
||||
if Self::is_destructive(&command) {
|
||||
|
|
@ -330,10 +442,12 @@ impl InteractableElement for ConsoleCard {
|
|||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.message = None;
|
||||
self.delete_at_cursor();
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
self.message = None;
|
||||
let len = self.content.chars().count();
|
||||
if self.cursor_position < len {
|
||||
let start = self.byte_index();
|
||||
|
|
@ -363,15 +477,36 @@ impl InteractableElement for ConsoleCard {
|
|||
self.cursor_position = self.content.chars().count();
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled,
|
||||
_ => {
|
||||
if let Some(c) = key.code.as_char() {
|
||||
self.insert_at_cursor(c);
|
||||
KeyCode::Up => {
|
||||
self.history_previous();
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Down => {
|
||||
self.history_next();
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Tab if !self.content.is_empty() => {
|
||||
if self.complete() {
|
||||
InteractionResult::Handled
|
||||
} else {
|
||||
InteractionResult::Unhandled
|
||||
self.message = Some("No command completion.".into());
|
||||
InteractionResult::Handled
|
||||
}
|
||||
}
|
||||
KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled,
|
||||
_ => {
|
||||
if !key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||
{
|
||||
if let Some(c) = key.code.as_char() {
|
||||
self.insert_at_cursor(c);
|
||||
self.message = None;
|
||||
return InteractionResult::Handled;
|
||||
}
|
||||
}
|
||||
InteractionResult::Unhandled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,15 +34,15 @@ impl GRAPHS {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn get_graph(&self, state: &ClientState) -> Vec<(f64, f64)> {
|
||||
pub fn get_graph(&self, state: &ClientState, sample_width: usize) -> Vec<(f64, f64)> {
|
||||
let state = match state.app.try_lock() {
|
||||
Ok(state) => state,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
match self {
|
||||
GRAPHS::Ram => state.with_width(28).ram.clone(),
|
||||
GRAPHS::Cpu => state.with_width(28).cpu.clone(),
|
||||
GRAPHS::Ping => state.with_width(28).ping.clone(),
|
||||
GRAPHS::Ram => state.with_width(sample_width.min(u16::MAX as usize) as u16).ram.clone(),
|
||||
GRAPHS::Cpu => state.with_width(sample_width.min(u16::MAX as usize) as u16).cpu.clone(),
|
||||
GRAPHS::Ping => state.with_width(sample_width.min(u16::MAX as usize) as u16).ping.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +69,7 @@ pub struct GraphCard {
|
|||
joins: Borders,
|
||||
|
||||
open: bool,
|
||||
sample_width: usize,
|
||||
}
|
||||
|
||||
impl GraphCard {
|
||||
|
|
@ -82,12 +83,17 @@ impl GraphCard {
|
|||
borders: Borders::ALL,
|
||||
joins: Borders::NONE,
|
||||
open: true,
|
||||
sample_width: 28,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_open(&mut self, open: bool) {
|
||||
self.open = open;
|
||||
}
|
||||
|
||||
pub fn set_sample_width(&mut self, sample_width: usize) {
|
||||
self.sample_width = sample_width.max(1);
|
||||
}
|
||||
}
|
||||
impl Element for GraphCard {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
|
|
@ -100,7 +106,24 @@ impl Element for GraphCard {
|
|||
|
||||
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
|
||||
if self.open {
|
||||
let graph = self.graph_type.get_graph(&self.state);
|
||||
let graph = self.graph_type.get_graph(&self.state, self.sample_width);
|
||||
if graph.is_empty() {
|
||||
let block = Block::default()
|
||||
.title(format!(" {} ", self.title))
|
||||
.borders(self.borders)
|
||||
.border_style(if self.focused {
|
||||
context.theme.graphs.focused_border
|
||||
} else {
|
||||
context.theme.graphs.border
|
||||
});
|
||||
f.render_widget(
|
||||
ratatui::widgets::Paragraph::new("No metric samples yet.")
|
||||
.style(context.theme.text.muted)
|
||||
.block(block),
|
||||
r,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let unit = self.graph_type.get_unit();
|
||||
let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0);
|
||||
let max_x = graph.last().map(|(x, _)| *x).unwrap_or(100.0);
|
||||
|
|
@ -117,16 +140,19 @@ impl Element for GraphCard {
|
|||
GRAPHS::Ping => (max_y * 1.2).max(10.0),
|
||||
};
|
||||
|
||||
let surface = matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces);
|
||||
let title = format!("{}: {}{} {}min/{}max", self.title, graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, unit, min_y as i64, max_y as i64);
|
||||
let plot_area = if surface { crate::controls::panel::render_panel(f, r, &title, self.focused, context.theme) } else { r };
|
||||
let block = Block::default()
|
||||
.title(format!(
|
||||
.title(if surface { String::new() } else { format!(
|
||||
"{}:─{}{}─{}min/{}max",
|
||||
self.title,
|
||||
graph.last().unwrap_or(&(0.0, 0.0)).1 as i64,
|
||||
unit,
|
||||
min_y as i64,
|
||||
max_y as i64,
|
||||
))
|
||||
.borders(self.borders)
|
||||
) })
|
||||
.borders(if surface { Borders::NONE } else { self.borders })
|
||||
.border_style(if self.focused {
|
||||
context.theme.graphs.focused_border
|
||||
} else {
|
||||
|
|
@ -148,7 +174,7 @@ impl Element for GraphCard {
|
|||
});
|
||||
}
|
||||
});
|
||||
f.render_widget(canvas, r);
|
||||
f.render_widget(canvas, plot_area);
|
||||
} else {
|
||||
let block = Block::default()
|
||||
.title("")
|
||||
|
|
@ -160,7 +186,7 @@ impl Element for GraphCard {
|
|||
});
|
||||
f.render_widget(block, r);
|
||||
}
|
||||
draw_block_joins(
|
||||
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins(
|
||||
f,
|
||||
r,
|
||||
self.borders,
|
||||
|
|
@ -170,7 +196,7 @@ impl Element for GraphCard {
|
|||
} else {
|
||||
context.theme.borders.normal
|
||||
},
|
||||
);
|
||||
); }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::elements::elements::{Element, InteractableElement, JoinableElement};
|
||||
use crate::util::borders::draw_block_joins;
|
||||
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use iota_state::{ClientState, UiLogEntry};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
|
|
@ -10,7 +10,10 @@ use ratatui::{
|
|||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
use std::any::Any;
|
||||
use std::{
|
||||
any::Any,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
|
|
@ -55,8 +58,11 @@ pub struct LogCard {
|
|||
focused: bool,
|
||||
selected: bool,
|
||||
scroll_offset: usize,
|
||||
last_total_lines: usize,
|
||||
last_visible_height: usize,
|
||||
last_total_lines: AtomicUsize,
|
||||
last_visible_height: AtomicUsize,
|
||||
last_width: AtomicUsize,
|
||||
filter: String,
|
||||
filtering: bool,
|
||||
pub borders: Borders,
|
||||
pub joins: Borders,
|
||||
}
|
||||
|
|
@ -68,8 +74,11 @@ impl LogCard {
|
|||
focused: false,
|
||||
selected: false,
|
||||
scroll_offset: 0,
|
||||
last_total_lines: 0,
|
||||
last_visible_height: 10,
|
||||
last_total_lines: AtomicUsize::new(0),
|
||||
last_visible_height: AtomicUsize::new(1),
|
||||
last_width: AtomicUsize::new(1),
|
||||
filter: String::new(),
|
||||
filtering: false,
|
||||
borders: Borders::ALL,
|
||||
joins: Borders::NONE,
|
||||
}
|
||||
|
|
@ -80,9 +89,15 @@ impl LogCard {
|
|||
Ok(state) => state,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let needle = self.filter.to_ascii_lowercase();
|
||||
state
|
||||
.get_logs()
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
needle.is_empty()
|
||||
|| entry.sender.to_ascii_lowercase().contains(&needle)
|
||||
|| entry.message.to_ascii_lowercase().contains(&needle)
|
||||
})
|
||||
.map(|e| UiLogEntry {
|
||||
timestamp_ms: e.timestamp_ms,
|
||||
sender: e.sender.clone(),
|
||||
|
|
@ -214,11 +229,13 @@ impl LogCard {
|
|||
}
|
||||
|
||||
fn get_title_hints(&self) -> (bool, bool) {
|
||||
if self.last_total_lines == 0 || self.last_total_lines <= self.last_visible_height {
|
||||
let total_lines = self.last_total_lines.load(Ordering::Relaxed);
|
||||
let visible_height = self.last_visible_height.load(Ordering::Relaxed);
|
||||
if total_lines == 0 || total_lines <= visible_height {
|
||||
return (false, false);
|
||||
}
|
||||
|
||||
let max_offset = self.last_total_lines - self.last_visible_height;
|
||||
let max_offset = total_lines - visible_height;
|
||||
let can_scroll_up = self.scroll_offset < max_offset;
|
||||
let can_scroll_down = self.scroll_offset > 0;
|
||||
|
||||
|
|
@ -226,6 +243,12 @@ impl LogCard {
|
|||
}
|
||||
|
||||
fn build_title(&self) -> String {
|
||||
if self.filtering {
|
||||
return format!("Logs filter: {}_", self.filter);
|
||||
}
|
||||
if !self.filter.is_empty() {
|
||||
return format!("Logs [filter: {}]", self.filter);
|
||||
}
|
||||
if !self.focused {
|
||||
return "Logs".to_string();
|
||||
}
|
||||
|
|
@ -250,7 +273,8 @@ impl LogCard {
|
|||
fn scroll_up(&mut self) {
|
||||
let max_offset = self
|
||||
.last_total_lines
|
||||
.saturating_sub(self.last_visible_height);
|
||||
.load(Ordering::Relaxed)
|
||||
.saturating_sub(self.last_visible_height.load(Ordering::Relaxed));
|
||||
self.scroll_offset = (self.scroll_offset + 1).min(max_offset);
|
||||
}
|
||||
|
||||
|
|
@ -297,17 +321,14 @@ impl Element for LogCard {
|
|||
fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
|
||||
let entries = self.get_logs();
|
||||
|
||||
let block = Block::default()
|
||||
.title(self.build_title())
|
||||
.borders(self.borders)
|
||||
.border_style(if self.focused {
|
||||
context.theme.logs.focused_border
|
||||
} else {
|
||||
context.theme.logs.border
|
||||
});
|
||||
|
||||
let inner_area = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
let inner_area = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||
crate::controls::panel::render_panel(f, area, &self.build_title(), self.focused, context.theme)
|
||||
} else {
|
||||
let block = Block::default().title(self.build_title()).borders(self.borders).border_style(if self.focused { context.theme.logs.focused_border } else { context.theme.logs.border });
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
inner
|
||||
};
|
||||
|
||||
if inner_area.width == 0 || inner_area.height == 0 {
|
||||
draw_block_joins(
|
||||
|
|
@ -327,6 +348,11 @@ impl Element for LogCard {
|
|||
let all_lines = self.build_all_lines(entries, inner_area.width as usize);
|
||||
let total_lines = all_lines.len();
|
||||
let visible_height = inner_area.height as usize;
|
||||
self.last_width
|
||||
.store(inner_area.width as usize, Ordering::Relaxed);
|
||||
self.last_total_lines.store(total_lines, Ordering::Relaxed);
|
||||
self.last_visible_height
|
||||
.store(visible_height, Ordering::Relaxed);
|
||||
|
||||
let (start, end) = self.calculate_view_window(total_lines, visible_height);
|
||||
let visible_lines = &all_lines[start..end];
|
||||
|
|
@ -337,6 +363,7 @@ impl Element for LogCard {
|
|||
let mut spans = Vec::new();
|
||||
|
||||
let (prefix, rest) = Self::split_line_prefix(line);
|
||||
let prefix = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { "" } else { prefix };
|
||||
|
||||
if !prefix.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
|
|
@ -377,7 +404,7 @@ impl Element for LogCard {
|
|||
f.render_widget(Paragraph::new(line.clone()), line_area);
|
||||
}
|
||||
|
||||
draw_block_joins(
|
||||
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins(
|
||||
f,
|
||||
area,
|
||||
self.borders,
|
||||
|
|
@ -387,7 +414,7 @@ impl Element for LogCard {
|
|||
} else {
|
||||
context.theme.borders.normal
|
||||
},
|
||||
);
|
||||
); }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -435,14 +462,44 @@ impl InteractableElement for LogCard {
|
|||
}
|
||||
|
||||
fn interact(&mut self, key: KeyEvent) -> InteractionResult {
|
||||
if self.filtering {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.filtering = false;
|
||||
self.filter.clear();
|
||||
}
|
||||
KeyCode::Enter => self.filtering = false,
|
||||
KeyCode::Backspace => {
|
||||
self.filter.pop();
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
if !key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
|
||||
{
|
||||
self.filter.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.scroll_offset = 0;
|
||||
return InteractionResult::Handled;
|
||||
}
|
||||
let entries = self.get_logs();
|
||||
let estimated_width = 80usize;
|
||||
let all_lines = self.build_all_lines(entries, estimated_width);
|
||||
let width = self.last_width.load(Ordering::Relaxed).max(1);
|
||||
let all_lines = self.build_all_lines(entries, width);
|
||||
|
||||
self.last_total_lines = all_lines.len();
|
||||
let visible_height = self.last_visible_height.max(1);
|
||||
self.last_total_lines
|
||||
.store(all_lines.len(), Ordering::Relaxed);
|
||||
let total_lines = all_lines.len();
|
||||
let visible_height = self.last_visible_height.load(Ordering::Relaxed).max(1);
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('/') => {
|
||||
self.filtering = true;
|
||||
self.filter.clear();
|
||||
self.scroll_offset = 0;
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
self.selected = !self.selected;
|
||||
InteractionResult::Handled
|
||||
|
|
@ -476,8 +533,8 @@ impl InteractableElement for LogCard {
|
|||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Home => {
|
||||
if self.last_total_lines > visible_height {
|
||||
self.scroll_offset = self.last_total_lines - visible_height;
|
||||
if total_lines > visible_height {
|
||||
self.scroll_offset = total_lines - visible_height;
|
||||
}
|
||||
InteractionResult::Handled
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue