feat: show horizontal scroll title

Show a horizontal scroll title, with arrows, if available
This commit is contained in:
Jack Wills
2025-08-18 14:02:00 +00:00
parent 7e892af838
commit 8939ac0345
6 changed files with 78 additions and 19 deletions
+69 -10
View File
@@ -201,6 +201,7 @@ impl<T> StatefulList<T> {
} }
/// Return the current status of the select list, e.g. 2/5, /// Return the current status of the select list, e.g. 2/5,
/// MAYBE add up down arrows, check if at start or end etc
pub fn get_state_title(&self) -> String { pub fn get_state_title(&self) -> String {
if self.items.is_empty() { if self.items.is_empty() {
String::new() String::new()
@@ -597,13 +598,12 @@ impl LogsTz {
/// stateful list dependent on whether the timestamp is in the HashSet or not /// stateful list dependent on whether the timestamp is in the HashSet or not
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Logs { pub struct Logs {
// should just be list of spans?
lines: StatefulList<Text<'static>>, lines: StatefulList<Text<'static>>,
tz: HashSet<LogsTz>, tz: HashSet<LogsTz>,
// could probably be a u16
offset: u16, offset: u16,
max_log_len: usize, max_log_len: usize,
adjusted_max_width: usize, adjusted_max_width: usize,
adjust_max_width_text_len: usize,
} }
impl Default for Logs { impl Default for Logs {
@@ -615,6 +615,7 @@ impl Default for Logs {
tz: HashSet::new(), tz: HashSet::new(),
offset: 0, offset: 0,
adjusted_max_width: 0, adjusted_max_width: 0,
adjust_max_width_text_len: 0,
max_log_len: 0, max_log_len: 0,
} }
} }
@@ -629,10 +630,24 @@ impl Logs {
} }
} }
// TODO test me!
/// If scrolling horiztonally along the logs, display a counter of the position in the in the scroll, `x/y` /// 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> { pub fn get_scroll_title(&mut self, width: u16) -> Option<String> {
if self.offset > 0 { if self.horizontal_scroll_able(width) {
Some(format!(" {}/{} ", self.offset, self.adjusted_max_width)) let text_width = self.adjust_max_width_text_len;
let arrow_left = if self.offset > 0 { "" } else { " " };
let arrow_right = if usize::from(self.offset) < self.adjusted_max_width {
""
} else {
" "
};
Some(format!(
"{left} {offset:>text_width$}/{adjusted_max_width} {right}",
offset = self.offset,
adjusted_max_width = self.adjusted_max_width,
left = arrow_left,
right = arrow_right,
))
} else { } else {
None None
} }
@@ -709,13 +724,23 @@ impl Logs {
self.lines.get_state_title() self.lines.get_state_title()
} }
/// Return true it currently selected cotnainer logs are wide enough to horizontally scroll
pub fn horizontal_scroll_able(&mut self, width: u16) -> bool {
if self.lines.items.is_empty() {
return false;
}
self.adjusted_max_width = self.max_log_len.saturating_sub(width.into()) + 4;
self.adjust_max_width_text_len = self.adjusted_max_width.to_string().chars().count();
self.max_log_len + 4 > usize::from(width)
}
/// Add a padding so one char will always be visilbe? /// 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) { pub fn forward(&mut self, width: u16) {
let offset = usize::from(self.offset); let offset = usize::from(self.offset);
self.adjusted_max_width = self.max_log_len.saturating_sub(width.into()) + 6; if self.horizontal_scroll_able(width) {
if self.adjusted_max_width > 0 && offset < self.adjusted_max_width { if self.adjusted_max_width > 0 && offset < self.adjusted_max_width {
self.offset = self.offset.saturating_add(1); self.offset = self.offset.saturating_add(1);
}
} }
} }
@@ -913,7 +938,7 @@ mod tests {
text::{Line, Text}, text::{Line, Text},
}; };
use crate::{ use crate::{
app_data::{ContainerImage, Logs, LogsTz, RunningState}, app_data::{ContainerImage, Logs, LogsTz, RunningState},
ui::log_sanitizer, ui::log_sanitizer,
}; };
@@ -1153,4 +1178,38 @@ mod tests {
result result
); );
} }
#[test]
/// Test the get_scroll_title methods
fn test_scroll_title() {
let mut logs = Logs::default();
let result = logs.get_scroll_title(10);
assert!(result.is_none());
let input = "short".to_owned();
let (tz, _) = LogsTz::splitter(&input);
logs.insert(Text::from(input), tz);
let result = logs.get_scroll_title(10);
assert!(result.is_none());
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 result = logs.get_scroll_title(10);
assert_eq!(result, Some(" 0/51 → ".to_owned()));
logs.forward(10);
let result = logs.get_scroll_title(10);
assert_eq!(result, Some(" ← 1/51 → ".to_owned()));
for _ in 0..=49 {
logs.forward(10);
}
let result = logs.get_scroll_title(10);
assert_eq!(result, Some(" ← 51/51 ".to_owned()));
}
} }
+3 -3
View File
@@ -638,9 +638,9 @@ impl AppData {
} }
/// If scrolling horiztonally along the logs, display a counter of the position in the in the scroll, `x/y` /// 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> { pub fn get_scroll_title(&mut self, width: u16) -> Option<String> {
self.get_selected_container() self.get_mut_selected_container()
.and_then(|i| i.logs.get_scroll_title()) .and_then(|i| i.logs.get_scroll_title(width))
} }
/// Increase the logs offset, basically moving an invisible cursor back /// Increase the logs offset, basically moving an invisible cursor back
+1 -1
View File
@@ -168,7 +168,7 @@ impl DockerData {
(None, None) (None, None)
}; };
// TODO is hardcoded eth0 a good idea here? - Could use first() instead? // TODO is hardcoded eth0 a good idea here?
let (rx, tx) = stats.networks.as_ref().map_or((0, 0), |i| { let (rx, tx) = stats.networks.as_ref().map_or((0, 0), |i| {
i.get("eth0").map_or((0, 0), |x| { i.get("eth0").map_or((0, 0), |x| {
( (
-1
View File
@@ -356,7 +356,6 @@ mod tests {
insert_logs(&setup); insert_logs(&setup);
let fd = FrameData::from((&setup.app_data, &setup.gui_state)); let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup setup
.terminal .terminal
.draw(|f| { .draw(|f| {
+3 -2
View File
@@ -158,7 +158,7 @@ pub mod tests {
/// Create a FrameData struct from two Arc<mutex>'s, instead of from UI /// Create a FrameData struct from two Arc<mutex>'s, instead of from UI
impl From<(&Arc<Mutex<AppData>>, &Arc<Mutex<GuiState>>)> for FrameData { impl From<(&Arc<Mutex<AppData>>, &Arc<Mutex<GuiState>>)> for FrameData {
fn from(data: (&Arc<Mutex<AppData>>, &Arc<Mutex<GuiState>>)) -> Self { fn from(data: (&Arc<Mutex<AppData>>, &Arc<Mutex<GuiState>>)) -> Self {
let (app_data, gui_data) = (data.0.lock(), data.1.lock()); let (mut app_data, gui_data) = (data.0.lock(), data.1.lock());
// let container_section_height = app_data.get_container_len(); // let container_section_height = app_data.get_container_len();
// let container_section_height = if container_section_height < 12 { // let container_section_height = if container_section_height < 12 {
@@ -185,7 +185,7 @@ pub mod tests {
loading_icon: gui_data.get_loading().to_string(), loading_icon: gui_data.get_loading().to_string(),
log_height: gui_data.get_log_height(), log_height: gui_data.get_log_height(),
log_title: app_data.get_log_title(), log_title: app_data.get_log_title(),
scroll_title: app_data.get_scroll_title(), scroll_title: app_data.get_scroll_title(gui_data.get_screen_width()),
port_max_lens: app_data.get_longest_port(), port_max_lens: app_data.get_longest_port(),
ports: app_data.get_selected_ports(), ports: app_data.get_selected_ports(),
selected_panel: gui_data.get_selected_panel(), selected_panel: gui_data.get_selected_panel(),
@@ -216,6 +216,7 @@ pub mod tests {
let gui_state = Arc::new(Mutex::new(gui_state)); let gui_state = Arc::new(Mutex::new(gui_state));
let fd = FrameData::from((&app_data, &gui_state)); let fd = FrameData::from((&app_data, &gui_state));
let area = Rect::new(0, 0, w, h); let area = Rect::new(0, 0, w, h);
gui_state.lock().set_screen_width(w);
TuiTestSetup { TuiTestSetup {
app_data, app_data,
gui_state, gui_state,
+2 -2
View File
@@ -312,7 +312,7 @@ pub struct FrameData {
impl From<&Ui> for FrameData { impl From<&Ui> for FrameData {
fn from(ui: &Ui) -> Self { fn from(ui: &Ui) -> Self {
let (app_data, gui_data) = (ui.app_data.lock(), ui.gui_state.lock()); let (mut app_data, gui_data) = (ui.app_data.lock(), ui.gui_state.lock());
let (filter_by, filter_term) = app_data.get_filter(); let (filter_by, filter_term) = app_data.get_filter();
Self { Self {
@@ -333,7 +333,7 @@ impl From<&Ui> for FrameData {
log_title: app_data.get_log_title(), log_title: app_data.get_log_title(),
port_max_lens: app_data.get_longest_port(), port_max_lens: app_data.get_longest_port(),
ports: app_data.get_selected_ports(), ports: app_data.get_selected_ports(),
scroll_title: app_data.get_scroll_title(), scroll_title: app_data.get_scroll_title(gui_data.get_screen_width()),
selected_panel: gui_data.get_selected_panel(), selected_panel: gui_data.get_selected_panel(),
sorted_by: app_data.get_sorted(), sorted_by: app_data.get_sorted(),
status: gui_data.get_status(), status: gui_data.get_status(),