From 90ff578dbf6714c7fcf836851633f2656dd4e629 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 30 Mar 2026 12:15:47 +0300 Subject: [PATCH 1/3] add per-pane pause state to UIState Add pane_paused: [bool; 3] indexed by logical table order (0=processes, 1=remote_addresses, 2=connections). Guard each sort_and_prune write so frozen panes hold their last snapshot while live data keeps accumulating. Signed-off-by: NotAShelf Change-Id: Ice1e2564862613ee8e5cc1e940b30cd46a6a6964 --- src/display/ui_state.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/display/ui_state.rs b/src/display/ui_state.rs index a32a6dac..74b306e6 100644 --- a/src/display/ui_state.rs +++ b/src/display/ui_state.rs @@ -94,6 +94,10 @@ pub struct UIState { pub processes_map: HashMap, pub remote_addresses_map: HashMap, pub connections_map: HashMap, + /// Per-pane pause state. Index matches logical table order: + /// 0 = processes, 1 = remote_addresses, 2 = connections. + /// When a pane is paused its display data is frozen at the last snapshot. + pub pane_paused: [bool; 3], /// Used for reducing logging noise. known_orphan_sockets: VecDeque, } @@ -221,9 +225,15 @@ impl UIState { self.total_bytes_downloaded = total_bytes_downloaded / divide_by; self.total_bytes_uploaded = total_bytes_uploaded / divide_by; } - self.processes = sort_and_prune(&mut self.processes_map); - self.remote_addresses = sort_and_prune(&mut self.remote_addresses_map); - self.connections = sort_and_prune(&mut self.connections_map); + if !self.pane_paused[0] { + self.processes = sort_and_prune(&mut self.processes_map); + } + if !self.pane_paused[1] { + self.remote_addresses = sort_and_prune(&mut self.remote_addresses_map); + } + if !self.pane_paused[2] { + self.connections = sort_and_prune(&mut self.connections_map); + } } } From 4ae85a2de59955a2e46f41cd23e336817a4c73b0 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 30 Mar 2026 12:15:47 +0300 Subject: [PATCH 2/3] add WidgetAction abstraction and per-pane focus/pause dispatch Introduce display/input.rs with WidgetAction enum as the extensibility hook for future per-pane keybindings. Add handle_widget_action() and toggle_pane_pause() to Ui. Thread focused_pane: Option through Ui::draw() and Layout::render(). Signed-off-by: NotAShelf Change-Id: I2499d5a2b42290898e506efc7cede19c6a6a6964 add WidgetAction abstraction and per-pane focus/pause dispatch Introduce display/input.rs with WidgetAction enum as the extensibility hook for future per-pane keybindings. Add handle_widget_action() and toggle_pane_pause() to Ui. Thread focused_pane: Option through Ui::draw() and Layout::render(). Signed-off-by: NotAShelf Change-Id: I2499d5a2b42290898e506efc7cede19c6a6a6964 --- src/display/input.rs | 8 ++++++++ src/display/mod.rs | 2 ++ src/display/ui.rs | 34 ++++++++++++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 src/display/input.rs diff --git a/src/display/input.rs b/src/display/input.rs new file mode 100644 index 00000000..2e4eb0f6 --- /dev/null +++ b/src/display/input.rs @@ -0,0 +1,8 @@ +/// An action that can be dispatched to a focused pane. +/// +/// New per-widget behaviours should be added as variants here and handled in +/// [`crate::display::Ui::handle_widget_action`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WidgetAction { + TogglePause, +} diff --git a/src/display/mod.rs b/src/display/mod.rs index 127c2bc1..80c6cd50 100644 --- a/src/display/mod.rs +++ b/src/display/mod.rs @@ -1,9 +1,11 @@ mod components; +mod input; mod raw_terminal_backend; mod ui; mod ui_state; pub use components::*; +pub use input::*; pub use raw_terminal_backend::*; pub use ui::*; pub use ui_state::*; diff --git a/src/display/ui.rs b/src/display/ui.rs index 0f94cb67..a540b0d9 100644 --- a/src/display/ui.rs +++ b/src/display/ui.rs @@ -7,6 +7,7 @@ use crate::{ cli::{Opt, RenderOpts}, display::{ components::{HeaderDetails, HelpText, Layout, Table}, + input::WidgetAction, UIState, }, network::{display_connection_string, display_ip_or_host, LocalSocket, Utilization}, @@ -127,7 +128,13 @@ where write_to_stdout(""); } - pub fn draw(&mut self, paused: bool, elapsed_time: Duration, table_cycle_offset: usize) { + pub fn draw( + &mut self, + paused: bool, + elapsed_time: Duration, + table_cycle_offset: usize, + focused_pane: Option, + ) { let layout = Layout { header: HeaderDetails { state: &self.state, @@ -138,10 +145,12 @@ where footer: HelpText { paused, show_dns: self.state.show_dns, + focused_pane, }, + pane_paused: self.state.pane_paused, }; self.terminal - .draw(|frame| layout.render(frame, frame.area(), table_cycle_offset)) + .draw(|frame| layout.render(frame, frame.area(), table_cycle_offset, focused_pane)) .unwrap(); } @@ -177,6 +186,27 @@ where self.get_tables_to_display().len() } + /// Dispatch a [`WidgetAction`] to the pane at `pane_index`. + /// + /// This is the single entry point for per-pane keybinding actions. New + /// `WidgetAction` variants should be handled here. + pub fn handle_widget_action(&mut self, action: WidgetAction, pane_index: usize) { + match action { + WidgetAction::TogglePause => self.toggle_pane_pause(pane_index), + } + } + + /// Toggle the paused state for the pane at `pane_index`. + /// + /// When a pane is paused its display data is frozen at the current snapshot; + /// live network updates continue accumulating in the backing maps but are + /// not written to the display vecs until the pane is unpaused. + fn toggle_pane_pause(&mut self, pane_index: usize) { + if let Some(flag) = self.state.pane_paused.get_mut(pane_index) { + *flag = !*flag; + } + } + pub fn update_state( &mut self, connections_to_procs: HashMap, From 5a6e6b224524039ca44421d89127bc4e2f74ff11 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 30 Mar 2026 12:15:47 +0300 Subject: [PATCH 3/3] add pane focus and per-pane freeze to the TUI cycles focus through panes (None -> 0 -> 1 -> 2 -> None); focused pane gets a thick cyan border. freezes/unfreezes the focused pane, showing [PAUSED] in yellow in its title. Help footer shows contextual hints for both keys, gated on terminal width. Signed-off-by: NotAShelf Change-Id: Id17f0119a115b4026e377a4f0c502c276a6a6964 --- src/display/components/help_text.rs | 17 ++++++++- src/display/components/layout.rs | 20 +++++++---- src/display/components/table.rs | 30 ++++++++++++++-- src/main.rs | 54 +++++++++++++++++++++++++++-- 4 files changed, 108 insertions(+), 13 deletions(-) diff --git a/src/display/components/help_text.rs b/src/display/components/help_text.rs index 213198f7..f89b47f0 100644 --- a/src/display/components/help_text.rs +++ b/src/display/components/help_text.rs @@ -9,6 +9,7 @@ use ratatui::{ pub struct HelpText { pub paused: bool, pub show_dns: bool, + pub focused_pane: Option, } const FIRST_WIDTH_BREAKPOINT: u16 = 76; @@ -19,6 +20,8 @@ const TEXT_WHEN_NOT_PAUSED: &str = " Press to pause."; const TEXT_WHEN_DNS_NOT_SHOWN: &str = " (DNS queries hidden)."; const TEXT_WHEN_DNS_SHOWN: &str = " (DNS queries shown)."; const TEXT_TAB_TIP: &str = " Use to rearrange tables."; +const TEXT_FOCUS_TIP: &str = " Use to cycle focus."; +const TEXT_FREEZE_PANE_TIP: &str = " Press to freeze focused pane."; impl HelpText { pub fn render(&self, frame: &mut Frame, rect: Rect) { @@ -42,8 +45,20 @@ impl HelpText { TEXT_TAB_TIP }; + let focus_tip = if rect.width <= SECOND_WIDTH_BREAKPOINT { + "" + } else { + TEXT_FOCUS_TIP + }; + + let freeze_tip = if self.focused_pane.is_some() && rect.width > SECOND_WIDTH_BREAKPOINT { + TEXT_FREEZE_PANE_TIP + } else { + "" + }; + let text = Span::styled( - [pause_content, tab_text, dns_content].concat(), + [pause_content, tab_text, focus_tip, freeze_tip, dns_content].concat(), Style::default().add_modifier(Modifier::BOLD), ); let paragraph = Paragraph::new(text).alignment(Alignment::Left); diff --git a/src/display/components/layout.rs b/src/display/components/layout.rs index 7fa68013..35a90e01 100644 --- a/src/display/components/layout.rs +++ b/src/display/components/layout.rs @@ -28,6 +28,7 @@ pub struct Layout<'a> { pub header: HeaderDetails<'a>, pub children: Vec, pub footer: HelpText, + pub pane_paused: [bool; 3], } impl Layout<'_> { @@ -99,16 +100,23 @@ impl Layout<'_> { } } - pub fn render(&self, frame: &mut Frame, rect: Rect, table_cycle_offset: usize) { + pub fn render( + &self, + frame: &mut Frame, + rect: Rect, + table_cycle_offset: usize, + focused_pane: Option, + ) { let (top, app, bottom) = top_app_and_bottom_split(rect); let layout_slots = self.build_layout(app); + let num_children = self.children.len(); for i in 0..layout_slots.len() { if let Some(rect) = layout_slots.get(i) { - if let Some(child) = self - .children - .get((i + table_cycle_offset) % self.children.len()) - { - child.render(frame, *rect); + let child_index = (i + table_cycle_offset) % num_children; + if let Some(child) = self.children.get(child_index) { + let focused = focused_pane == Some(child_index); + let paused = self.pane_paused.get(child_index).copied().unwrap_or(false); + child.render(frame, *rect, focused, paused); } } } diff --git a/src/display/components/table.rs b/src/display/components/table.rs index 4c66d3c4..4223fffd 100644 --- a/src/display/components/table.rs +++ b/src/display/components/table.rs @@ -5,7 +5,7 @@ use itertools::Itertools; use ratatui::{ layout::{Constraint, Rect}, style::{Color, Style}, - widgets::{Block, Borders, Row}, + widgets::{Block, BorderType, Borders, Row}, Frame, }; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; @@ -371,7 +371,7 @@ impl Table { } /// See [`Table`] for layout rules. - pub fn render(&self, frame: &mut Frame, rect: Rect) { + pub fn render(&self, frame: &mut Frame, rect: Rect, focused: bool, paused: bool) { let (computed_layout, spacer_width) = { // pick the largest possible layout, constrained by the available width let &(_, layout) = self @@ -411,8 +411,32 @@ impl Table { .map(Constraint::Length) .collect(); + let title = if paused { + format!("{} [PAUSED]", self.title) + } else { + self.title.to_string() + }; + let title_style = if paused { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + let block = if focused { + Block::default() + .title(title) + .title_style(title_style) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .border_type(BorderType::Thick) + } else { + Block::default() + .title(title) + .title_style(title_style) + .borders(Borders::ALL) + }; + let table = ratatui::widgets::Table::new(tui_rows_iter, widths_constraints) - .block(Block::default().title(self.title).borders(Borders::ALL)) + .block(block) .header(Row::new(column_names).style(Style::default().fg(Color::Yellow))) .flex(ratatui::layout::Flex::Legacy) .column_spacing(spacer_width); diff --git a/src/main.rs b/src/main.rs index 400f5480..2a05ab5e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,7 @@ use crossterm::{ event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, terminal, }; -use display::{elapsed_time, RawTerminalBackend, Ui}; +use display::{elapsed_time, RawTerminalBackend, Ui, WidgetAction}; use eyre::bail; use network::{ dns::{self, IpTable}, @@ -100,6 +100,7 @@ where let last_start_time = Arc::new(RwLock::new(Instant::now())); let cumulative_time = Arc::new(RwLock::new(Duration::new(0, 0))); let table_cycle_offset = Arc::new(AtomicUsize::new(0)); + let focused_pane = Arc::new(Mutex::new(None::)); // handle SIGINT properly instead of as a keypress // see https://github.com/imsnif/bandwhich/issues/487 @@ -130,6 +131,7 @@ where let running = running.clone(); let paused = paused.clone(); let table_cycle_offset = table_cycle_offset.clone(); + let focused_pane = focused_pane.clone(); let network_utilization = network_utilization.clone(); let last_start_time = last_start_time.clone(); @@ -156,6 +158,7 @@ where let mut ui = ui.lock().unwrap(); let paused = paused.load(Ordering::SeqCst); let table_cycle_offset = table_cycle_offset.load(Ordering::SeqCst); + let focused_pane = *focused_pane.lock().unwrap(); if !paused { ui.update_state(sockets_to_procs, utilization, ip_to_host); } @@ -168,7 +171,7 @@ where if raw_mode { ui.output_text(&mut write_to_stdout); } else { - ui.draw(paused, elapsed_time, table_cycle_offset); + ui.draw(paused, elapsed_time, table_cycle_offset, focused_pane); } } let render_duration = render_start_time.elapsed(); @@ -189,6 +192,7 @@ where .spawn({ let running = running.clone(); let display_handler = display_handler.thread().clone(); + let focused_pane = focused_pane.clone(); move || { let mut terminal_events = terminal_events; @@ -201,6 +205,7 @@ where match evt { Event::Resize(_x, _y) if !raw_mode => { let paused = paused.load(Ordering::SeqCst); + let focused_pane = *focused_pane.lock().unwrap(); ui.draw( paused, elapsed_time( @@ -209,6 +214,7 @@ where paused, ), table_cycle_offset.load(Ordering::SeqCst), + focused_pane, ); } Event::Key(KeyEvent { @@ -250,6 +256,18 @@ where display_handler.unpark(); } + Event::Key(KeyEvent { + modifiers: KeyModifiers::NONE, + code: KeyCode::Char('f'), + kind: KeyEventKind::Press, + .. + }) => { + let focused = *focused_pane.lock().unwrap(); + if let Some(pane_index) = focused { + ui.handle_widget_action(WidgetAction::TogglePause, pane_index); + display_handler.unpark(); + } + } Event::Key(KeyEvent { modifiers: KeyModifiers::NONE, code: KeyCode::Tab, @@ -265,7 +283,37 @@ where let table_count = ui.get_table_count(); let new = table_cycle_offset.load(Ordering::SeqCst) + 1 % table_count; table_cycle_offset.store(new, Ordering::SeqCst); - ui.draw(paused, elapsed_time, new); + let focused_pane = *focused_pane.lock().unwrap(); + ui.draw(paused, elapsed_time, new, focused_pane); + } + Event::Key(KeyEvent { + modifiers: KeyModifiers::NONE, + code: KeyCode::Char('n'), + kind: KeyEventKind::Press, + .. + }) => { + let paused = paused.load(Ordering::SeqCst); + let elapsed_time = elapsed_time( + *last_start_time.read().unwrap(), + *cumulative_time.read().unwrap(), + paused, + ); + let table_count = ui.get_table_count(); + let new_focused = { + let mut fp = focused_pane.lock().unwrap(); + let next = Some(match *fp { + None => 0, + Some(i) => (i + 1) % table_count, + }); + *fp = next; + next + }; + ui.draw( + paused, + elapsed_time, + table_cycle_offset.load(Ordering::SeqCst), + new_focused, + ); } _ => (), };