feat: horizontally scroll across log
By default, use left and right arrow keys to horizontally scroll over the lines of logs, also has various refactors to reduced to size of the vec of logs sent to the ui renderer
This commit is contained in:
+160
-40
@@ -7,10 +7,7 @@ use std::{
|
||||
|
||||
use bollard::service::Port;
|
||||
use jiff::{Timestamp, tz::TimeZone};
|
||||
use ratatui::{
|
||||
style::Color,
|
||||
widgets::{ListItem, ListState},
|
||||
};
|
||||
use ratatui::{layout::Size, style::Color, text::Text, widgets::ListState};
|
||||
|
||||
use crate::config::AppColors;
|
||||
|
||||
@@ -563,81 +560,166 @@ impl LogsTz {
|
||||
/// stateful list dependent on whether the timestamp is in the HashSet or not
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Logs {
|
||||
logs: StatefulList<ListItem<'static>>,
|
||||
// should just be list of spans?
|
||||
lines: StatefulList<Text<'static>>,
|
||||
tz: HashSet<LogsTz>,
|
||||
// could probably be a u16
|
||||
offset: u16,
|
||||
max_log_len: usize,
|
||||
adjusted_max_width: usize,
|
||||
}
|
||||
|
||||
impl Default for Logs {
|
||||
fn default() -> Self {
|
||||
let mut logs = StatefulList::new(vec![]);
|
||||
logs.end();
|
||||
let mut lines = StatefulList::new(vec![]);
|
||||
lines.end();
|
||||
Self {
|
||||
logs,
|
||||
lines,
|
||||
tz: HashSet::new(),
|
||||
offset: 0,
|
||||
adjusted_max_width: 0,
|
||||
max_log_len: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Logs {
|
||||
/// Only allow a new log line to be inserted if the log timestamp isn't in the tz HashSet
|
||||
pub fn insert(&mut self, line: ListItem<'static>, tz: LogsTz) {
|
||||
pub fn insert(&mut self, line: Text<'static>, tz: LogsTz) {
|
||||
if self.tz.insert(tz) {
|
||||
self.logs.items.push(line);
|
||||
self.max_log_len = self.max_log_len.max(line.width());
|
||||
self.lines.items.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the logs vec, but instead of cloning to whole vec, only clone items with x of the currently selected index
|
||||
/// If scrolling horiztonally along the logs, display a counter of the position in the in the scroll, `x/y`
|
||||
pub fn get_scroll_title(&self) -> Option<String> {
|
||||
if self.offset > 0 {
|
||||
Some(format!(" {}/{} ", self.offset, self.adjusted_max_width))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a log lone. Only return screen width amount of chars
|
||||
/// If offset set, remove `char_offset` number of chars from a Text
|
||||
/// `text` *should* only be a single line, so just use the .first() method rather than trying to iterate
|
||||
fn format_log_line(text: &Text<'static>, char_offset: usize, width: u16) -> Text<'static> {
|
||||
let mut skipped = 0;
|
||||
Text::from(
|
||||
text.lines
|
||||
.first()
|
||||
.map(|line| {
|
||||
ratatui::text::Line::from(
|
||||
line.spans
|
||||
.iter()
|
||||
.filter_map(|span| {
|
||||
if skipped >= char_offset {
|
||||
return Some(ratatui::text::Span::styled(
|
||||
span.content.chars().take(width.into()).collect::<String>(),
|
||||
span.style,
|
||||
));
|
||||
}
|
||||
let span_len = span.content.chars().count();
|
||||
if skipped + span_len <= char_offset {
|
||||
skipped += span_len;
|
||||
None
|
||||
} else {
|
||||
let start_index = char_offset - skipped;
|
||||
skipped = char_offset;
|
||||
let new_content = span
|
||||
.content
|
||||
.chars()
|
||||
.skip(start_index)
|
||||
.take(width.into())
|
||||
.collect::<String>();
|
||||
Some(ratatui::text::Span::styled(new_content, span.style))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the logs vec, but instead of cloning to whole vec, only clone items within x of the currently selected index, as ell as only the current screen widths number of chars
|
||||
/// Where x is the abs different of the index plus the panel height & a padding
|
||||
/// Take into account the char offset, so that can scroll a line
|
||||
/// The rest can be just empty list items
|
||||
pub fn to_vec(&self, height: usize, padding: usize) -> Vec<ListItem<'static>> {
|
||||
let current_index = self.logs.state.selected().unwrap_or_default();
|
||||
self.logs
|
||||
pub fn get_visible_logs(&self, size: Size, padding: usize) -> Vec<Text<'static>> {
|
||||
let current_index = self.lines.state.selected().unwrap_or_default();
|
||||
let height_padding = usize::from(size.height) + padding;
|
||||
let char_offset = if usize::from(self.offset) > self.max_log_len {
|
||||
self.max_log_len
|
||||
} else {
|
||||
self.offset.into()
|
||||
};
|
||||
|
||||
self.lines
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
if current_index.abs_diff(index) <= height + padding {
|
||||
item.clone()
|
||||
if current_index.abs_diff(index) <= height_padding {
|
||||
Self::format_log_line(item, char_offset, size.width)
|
||||
} else {
|
||||
ListItem::from("")
|
||||
Text::from("")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The rest of the methods are basically forwarding from the underlying StatefulList
|
||||
pub fn get_state_title(&self) -> String {
|
||||
self.logs.get_state_title()
|
||||
self.lines.get_state_title()
|
||||
}
|
||||
|
||||
/// Add a padding so one char will always be visilbe?
|
||||
/// +6 is to account for borders & the selection triangle and a little bit of padding
|
||||
pub fn forward(&mut self, width: u16) {
|
||||
let offset = usize::from(self.offset);
|
||||
self.adjusted_max_width = self.max_log_len.saturating_sub(width.into()) + 6;
|
||||
if self.adjusted_max_width > 0 && offset < self.adjusted_max_width {
|
||||
self.offset = self.offset.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reduce the char offset
|
||||
pub const fn back(&mut self) {
|
||||
self.offset = self.offset.saturating_sub(1);
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
self.logs.next();
|
||||
self.lines.next();
|
||||
}
|
||||
|
||||
pub fn previous(&mut self) {
|
||||
self.logs.previous();
|
||||
self.lines.previous();
|
||||
}
|
||||
|
||||
pub fn end(&mut self) {
|
||||
self.logs.end();
|
||||
self.lines.end();
|
||||
}
|
||||
pub fn start(&mut self) {
|
||||
self.logs.start();
|
||||
self.lines.start();
|
||||
}
|
||||
|
||||
// TODO remove this once zigbuild uses Rust v1.87.0
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(clippy::missing_const_for_fn)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.logs.items.len()
|
||||
}
|
||||
// // TODO remove this once zigbuild uses Rust v1.87.0
|
||||
// #[cfg(target_os = "macos")]
|
||||
// #[allow(clippy::missing_const_for_fn)]
|
||||
// pub fn len(&self) -> usize {
|
||||
// self.logs.items.len()
|
||||
// }
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
// #[cfg(not(target_os = "macos"))]
|
||||
pub const fn len(&self) -> usize {
|
||||
self.logs.items.len()
|
||||
self.lines.items.len()
|
||||
}
|
||||
|
||||
pub const fn state(&mut self) -> &mut ListState {
|
||||
&mut self.logs.state
|
||||
&mut self.lines.state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,7 +883,10 @@ impl Columns {
|
||||
mod tests {
|
||||
|
||||
use jiff::tz::TimeZone;
|
||||
use ratatui::widgets::ListItem;
|
||||
use ratatui::{
|
||||
layout::Size,
|
||||
text::{Line, Text},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app_data::{ContainerImage, Logs, LogsTz, RunningState},
|
||||
@@ -941,21 +1026,21 @@ mod tests {
|
||||
let mut logs = Logs::default();
|
||||
let line = log_sanitizer::remove_ansi(input);
|
||||
|
||||
logs.insert(ListItem::new(line.clone()), tz.clone());
|
||||
logs.insert(ListItem::new(line.clone()), tz.clone());
|
||||
logs.insert(ListItem::new(line), tz);
|
||||
logs.insert(Text::from(line.clone()), tz.clone());
|
||||
logs.insert(Text::from(line.clone()), tz.clone());
|
||||
logs.insert(Text::from(line), tz);
|
||||
|
||||
assert_eq!(logs.logs.items.len(), 1);
|
||||
assert_eq!(logs.lines.items.len(), 1);
|
||||
|
||||
let input = "2023-01-15T19:13:30.783138328Z Lorem ipsum dolor sit amet";
|
||||
let (tz, _) = LogsTz::splitter(input);
|
||||
let line = log_sanitizer::remove_ansi(input);
|
||||
|
||||
logs.insert(ListItem::new(line.clone()), tz.clone());
|
||||
logs.insert(ListItem::new(line.clone()), tz.clone());
|
||||
logs.insert(ListItem::new(line), tz);
|
||||
logs.insert(Text::from(line.clone()), tz.clone());
|
||||
logs.insert(Text::from(line.clone()), tz.clone());
|
||||
logs.insert(Text::from(line), tz);
|
||||
|
||||
assert_eq!(logs.logs.items.len(), 2);
|
||||
assert_eq!(logs.lines.items.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1008,4 +1093,39 @@ mod tests {
|
||||
let input = State::from(("oxker", &healthy));
|
||||
assert_eq!(input, State::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Test the format_log_line methods, should ideally check colours are being correct kept as well
|
||||
fn test_to_vec() {
|
||||
let mut logs = Logs::default();
|
||||
|
||||
let input = "2023-01-14T19:13:30.783138328Z Hello world some long line".to_owned();
|
||||
let (tz, _) = LogsTz::splitter(&input);
|
||||
logs.insert(Text::from(input), tz);
|
||||
|
||||
let input = "2023-01-14T19:13:31.783138328Z Hello world some line".to_owned();
|
||||
let (tz, _) = LogsTz::splitter(&input);
|
||||
logs.insert(Text::from(input), tz);
|
||||
|
||||
let input = "2023-01-14T19:13:32.783138328Z Hello world".to_owned();
|
||||
let (tz, _) = LogsTz::splitter(&input);
|
||||
logs.insert(Text::from(input), tz);
|
||||
|
||||
logs.offset = 43;
|
||||
let result = logs.get_visible_logs(
|
||||
Size {
|
||||
width: 14,
|
||||
height: 10,
|
||||
},
|
||||
10,
|
||||
);
|
||||
assert_eq!(
|
||||
vec![
|
||||
Text::from(Line::from("some long line")),
|
||||
Text::from(Line::from("some line")),
|
||||
Text::from(Line::default())
|
||||
],
|
||||
result
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+70
-19
@@ -1,7 +1,7 @@
|
||||
use bollard::models::ContainerSummary;
|
||||
use core::fmt;
|
||||
use parking_lot::Mutex;
|
||||
use ratatui::widgets::{ListItem, ListState};
|
||||
use ratatui::{layout::Size, text::Text, widgets::ListState};
|
||||
use std::{
|
||||
hash::Hash,
|
||||
sync::Arc,
|
||||
@@ -644,6 +644,28 @@ impl AppData {
|
||||
})
|
||||
}
|
||||
|
||||
/// If scrolling horiztonally along the logs, display a counter of the position in the in the scroll, `x/y`
|
||||
pub fn get_scroll_title(&self) -> Option<String> {
|
||||
self.get_selected_container()
|
||||
.and_then(|i| i.logs.get_scroll_title())
|
||||
}
|
||||
|
||||
/// Increase the logs offset, basically moving an invisible cursor back
|
||||
pub fn log_back(&mut self) {
|
||||
if let Some(i) = self.get_mut_selected_container() {
|
||||
i.logs.back();
|
||||
self.redraw.update();
|
||||
}
|
||||
}
|
||||
|
||||
/// Increase the logs offset, basically moving an invisible cursor forward
|
||||
pub fn log_forward(&mut self, width: u16) {
|
||||
if let Some(i) = self.get_mut_selected_container() {
|
||||
i.logs.forward(width);
|
||||
self.redraw.update();
|
||||
}
|
||||
}
|
||||
|
||||
/// select next selected log line
|
||||
pub fn log_next(&mut self) {
|
||||
if let Some(i) = self.get_mut_selected_container() {
|
||||
@@ -677,12 +699,12 @@ impl AppData {
|
||||
}
|
||||
|
||||
/// Get mutable Vec of current containers logs
|
||||
pub fn get_logs(&self, height: u16, padding: usize) -> Vec<ListItem<'static>> {
|
||||
pub fn get_logs(&self, size: Size, padding: usize) -> Vec<Text<'static>> {
|
||||
self.containers
|
||||
.state
|
||||
.selected()
|
||||
.and_then(|i| self.containers.items.get(i))
|
||||
.map_or(vec![], |i| i.logs.to_vec(height.into(), padding))
|
||||
.map_or(vec![], |i| i.logs.get_visible_logs(size, padding))
|
||||
}
|
||||
|
||||
/// Get mutable Option of the currently selected container Logs state
|
||||
@@ -965,7 +987,7 @@ impl AppData {
|
||||
} else {
|
||||
log_sanitizer::remove_ansi(&i)
|
||||
};
|
||||
container.logs.insert(ListItem::new(lines), log_tz);
|
||||
container.logs.insert(Text::from(lines), log_tz);
|
||||
}
|
||||
|
||||
// Set the logs selected row for each container
|
||||
@@ -1945,14 +1967,19 @@ mod tests {
|
||||
let logs = (1..=3).map(|i| format!("{i} {i}")).collect::<Vec<_>>();
|
||||
|
||||
app_data.update_log_by_id(logs, &ids[0]);
|
||||
// app_data.log_start();
|
||||
|
||||
let result = app_data.get_log_state();
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.as_ref().unwrap().selected(), Some(2));
|
||||
assert_eq!(result.unwrap().offset(), 0);
|
||||
|
||||
let result = app_data.get_logs(4, 1);
|
||||
let result = app_data.get_logs(
|
||||
Size {
|
||||
width: 20,
|
||||
height: 4,
|
||||
},
|
||||
1,
|
||||
);
|
||||
assert_eq!(result.len(), 3);
|
||||
|
||||
let result = app_data.get_log_title();
|
||||
@@ -2340,44 +2367,68 @@ mod tests {
|
||||
|
||||
app_data.update_log_by_id(logs, &ids[0]);
|
||||
|
||||
let result = app_data.get_logs(10, 10);
|
||||
let result = app_data.get_logs(
|
||||
Size {
|
||||
width: 20,
|
||||
height: 10,
|
||||
},
|
||||
10,
|
||||
);
|
||||
for (index, item) in result.iter().enumerate() {
|
||||
if index < 979 {
|
||||
assert_eq!(item, &ListItem::new(""));
|
||||
assert_eq!(item, &Text::from(""));
|
||||
} else {
|
||||
assert_eq!(item, &ListItem::new(format!("{index}")));
|
||||
assert_eq!(item, &Text::from(format!("{index}")));
|
||||
}
|
||||
}
|
||||
|
||||
let result = app_data.get_logs(100, 20);
|
||||
let result = app_data.get_logs(
|
||||
Size {
|
||||
width: 20,
|
||||
height: 100,
|
||||
},
|
||||
20,
|
||||
);
|
||||
for (index, item) in result.iter().enumerate() {
|
||||
if index < 879 {
|
||||
assert_eq!(item, &ListItem::new(""));
|
||||
assert_eq!(item, &Text::from(""));
|
||||
} else {
|
||||
assert_eq!(item, &ListItem::new(format!("{index}")));
|
||||
assert_eq!(item, &Text::from(format!("{index}")));
|
||||
}
|
||||
}
|
||||
|
||||
app_data.log_start();
|
||||
let result = app_data.get_logs(10, 10);
|
||||
|
||||
let result = app_data.get_logs(
|
||||
Size {
|
||||
width: 20,
|
||||
height: 10,
|
||||
},
|
||||
10,
|
||||
);
|
||||
for (index, item) in result.iter().enumerate() {
|
||||
if index > 20 {
|
||||
assert_eq!(item, &ListItem::new(""));
|
||||
assert_eq!(item, &Text::from(""));
|
||||
} else {
|
||||
assert_eq!(item, &ListItem::new(format!("{index}")));
|
||||
assert_eq!(item, &Text::from(format!("{index}")));
|
||||
}
|
||||
}
|
||||
|
||||
for _ in 0..=500 {
|
||||
app_data.log_next();
|
||||
}
|
||||
|
||||
let result = app_data.get_logs(10, 10);
|
||||
let result = app_data.get_logs(
|
||||
Size {
|
||||
width: 20,
|
||||
height: 10,
|
||||
},
|
||||
10,
|
||||
);
|
||||
for (index, item) in result.iter().enumerate() {
|
||||
if (481..=521).contains(&index) {
|
||||
assert_eq!(item, &ListItem::new(format!("{index}")));
|
||||
assert_eq!(item, &Text::from(format!("{index}")));
|
||||
} else {
|
||||
assert_eq!(item, &ListItem::new(""));
|
||||
assert_eq!(item, &Text::from(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user