From aee50991c3998467ea52b1a796b655fdb7203544 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Fri, 20 Mar 2026 11:13:15 +0200 Subject: [PATCH 1/9] update README --- README.md | 118 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 84 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 60c2808..001f170 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# sqwatch +# sqwatch - SLURM Queue Watcher A lightweight terminal UI for watching and managing SLURM job queues in real time. @@ -6,11 +6,14 @@ A lightweight terminal UI for watching and managing SLURM job queues in real tim ## Features -- **Live queue view** — Auto-refreshing job table with color-coded states (pending, running, failed, completed, suspended, out of memory, etc.). Job selection and cursor position are preserved across refresh cycles. -- **Flexible filtering** — Filter by user (regex), job name (regex), state, partition, QoS, or node. Partitions, QoS, and nodes are populated from the cluster automatically. Filter settings are persisted to disk and restored on launch. +- **Live queue view** — Auto-refreshing job table with color-coded states (pending, running, failed, completed, suspended, out of memory, etc.). Job selection and cursor position are preserved across refresh cycles. Job fetching runs in a background thread so the UI never stalls. +- **Flexible filtering** — Persistent sidebar for filtering by user (regex), job name (regex), state, partition, QoS, or node. Partitions, QoS, and nodes are populated from the cluster automatically. Filter settings are persisted to disk and restored on launch. - **Column configuration** — Choose which `squeue` fields to display, reorder them, and define multi-level sort priorities. Column settings are also persisted. -- **Script inspector** — Read the submission script of any job, with syntax highlighting via [`bat`](https://github.com/sharkdp/bat) if available. Falls back to plain text with line numbers. +- **Script inspector** — View the submission script of any job, with syntax highlighting via [`bat`](https://github.com/sharkdp/bat) if available. Falls back to plain text with line numbers. Script content loads in a background thread. - **Log viewer** — Tail stdout/stderr logs in real time with automatic file watching via `notify`. +- **Custom output widgets** — Define additional file-watching panels for arbitrary job output files, with automatic JSON pretty-printing. +- **Widget layout** — Toggle visibility of individual panels (filters, script, stdout, stderr, custom widgets) and persist your preferred layout. +- **Clipboard support** — Copy widget contents to the system clipboard via OSC 52, which works over SSH and inside tmux without X11/Wayland. - **Bulk actions** — Select one or many jobs and cancel them in batch with confirmation. Errors from `scancel` are reported through the flash notification bar. ## Requirements @@ -51,32 +54,72 @@ Settings are stored in `~/.config/sqwatch/` (or `$XDG_CONFIG_HOME/sqwatch/`): |------|----------| | `filters.json` | Saved filter presets (user, states, partitions, QoS, nodes, name pattern) | | `columns.json` | Visible columns and sort order | +| `layout.json` | Widget visibility and custom widget definitions | -Press `Ctrl+S` inside the filter or column dialog to persist the current configuration. +Press `Ctrl+S` inside the filter sidebar, column dialog, or widget selector to persist the current configuration. ## Keybindings -### Main View +### Global + +| Key | Action | +|-----|--------| +| `Tab` | Cycle focus to next visible widget | +| `Shift+Tab` | Cycle focus to previous visible widget | +| `w` | Open widget selector (toggle panel visibility) | +| `c` | Open column / sort configuration (when table is focused) | +| `Esc` | Return focus to table, or quit if already on table | +| `Ctrl+C` | Copy focused widget contents to clipboard, or quit if on table | + +### Job Table | Key | Action | |-----|--------| | `Up` / `Down` | Navigate job list | | `Space` | Toggle selection on focused job | | `a` | Select / deselect all | -| `s` | View job script | -| `v` | View job log (stdout/stderr) | -| `f` | Open filter dialog | -| `c` | Open column / sort configuration | | `x` | Cancel selected jobs (with confirmation) | -| `Esc` / `Ctrl+C` | Close overlay or quit | -### Script / Log Viewer +### Script / Log / Custom Widgets | Key | Action | |-----|--------| | `Up` / `Down` | Scroll content | -| `Shift+Up` / `Shift+Down` | Switch to previous/next job | -| `Esc` | Close viewer | +| `PageUp` / `PageDown` | Scroll one page | +| `Ctrl+U` / `Ctrl+D` | Scroll one page (vim-style) | +| `Shift+Up` / `Shift+Down` | Switch to previous/next job in the table | + +### Filter Sidebar + +| Key | Action | +|-----|--------| +| `Up` / `Down` | Navigate between fields and filter sections | +| `Enter` | Edit text field or toggle checkbox | +| `Space` | Toggle checkbox item | +| `Ctrl+S` | Save filter settings to disk | + +### Column / Sort Configuration + +| Key | Action | +|-----|--------| +| `Up` / `Down` | Navigate within a list | +| `Left` / `Right` | Switch between pool, active, and sort lists | +| `Enter` | Add field to selected / sort, or toggle sort order | +| `Del` | Remove field from list | +| `Shift+Up` / `Shift+Down` | Reorder items | +| `Tab` | Cycle between lists | +| `r` | Reset to defaults | +| `Ctrl+S` | Save column settings to disk | +| `Esc` | Close | + +### Widget Selector + +| Key | Action | +|-----|--------| +| `Up` / `Down` | Navigate widget list | +| `Enter` / `Space` | Toggle widget visibility | +| `Ctrl+S` | Save layout to disk | +| `Esc` | Close | ## Architecture @@ -84,32 +127,39 @@ The project is organized into four modules: ``` src/ -├── main.rs # Entry point — terminal setup and teardown -├── dashboard.rs # Central orchestrator — event loop, state, rendering +├── main.rs # Entry point — terminal setup and teardown +├── dashboard.rs # Central orchestrator — event loop, state, rendering ├── backend/ -│ ├── mod.rs # Job and JobState data types -│ ├── commands.rs # Async wrappers around SLURM CLI tools -│ └── query.rs # squeue invocation and output parsing +│ ├── mod.rs # Job and JobState data types +│ ├── commands.rs # Async wrappers around SLURM CLI tools +│ └── query.rs # squeue invocation and output parsing ├── core/ -│ ├── input.rs # Keyboard/mouse/timer event loop (crossbeam channels) -│ ├── config.rs # Filter and column persistence (JSON, XDG paths) -│ └── live_file.rs # File watcher for live log tailing (notify crate) +│ ├── mod.rs +│ ├── input.rs # Keyboard/mouse/timer event loop (crossbeam channels) +│ ├── config.rs # Filter, column, and layout persistence (JSON, XDG paths) +│ ├── job_fetcher.rs # Background thread for periodic squeue refreshes +│ ├── job_detail.rs # Background scontrol cache (LRU, max 64 entries) +│ └── live_file.rs # File watcher for live log tailing (notify crate) └── views/ - ├── chrome.rs # Titlebar, statusbar, and layout framing - ├── job_table.rs # Job list table with selection and sorting - ├── search.rs # Filter dialog (multi-tab, selectable lists) - ├── fields.rs # Column and sort configuration dialog - ├── script_pane.rs # Job script viewer with optional bat highlighting - └── output_pane.rs # Live log viewer (stdout/stderr) + ├── mod.rs + ├── chrome.rs # Titlebar, statusbar, and layout framing + ├── job_table.rs # Job list table with selection and sorting + ├── filter_tree.rs # Persistent filter sidebar with regex text fields and checkbox lists + ├── fields.rs # Column and sort configuration dialog + ├── script_widget.rs # Job script viewer with optional bat highlighting + ├── output_widget.rs # Live log viewer (stdout/stderr) + ├── custom_widget.rs # User-defined file-watching panels + ├── widget_selector.rs # Panel visibility toggle popup + └── theme.rs # Centralized color constants ``` **Dashboard** is the central hub. It owns all view components, the query parameters, the tokio runtime for async SLURM commands, and the input event channel. The main loop is: receive input signal → dispatch to the appropriate handler → redraw. **Backend** wraps all SLURM interactions. Commands are executed asynchronously via `async-process` and dispatched through a shared tokio runtime. The query module builds `squeue` invocations with dynamic format strings and parses the pipe-delimited output. -**Core** handles cross-cutting concerns: the input loop runs on a dedicated thread, multiplexing keyboard, mouse, resize, and timer events into a single `crossbeam` channel. The config module manages JSON persistence for filters and columns. The live file watcher uses `notify` to detect log file changes for real-time tailing. +**Core** handles cross-cutting concerns: the input loop runs on a dedicated thread, multiplexing keyboard, mouse, resize, and timer events into a single `crossbeam` channel. Background workers (`job_fetcher` and `job_detail`) run SLURM queries off the main thread, communicating results back via crossbeam channels polled on timer ticks. The config module manages JSON persistence for filters, columns, and layout. The live file watcher uses `notify` to detect log file changes for real-time tailing. -**Views** are pure rendering components. Each one receives a `Frame` and `Rect` from ratatui and draws itself. Overlay panes (script, log, filter, columns) are rendered on top of the main job table via popup regions. +**Views** are pure rendering components. Each one receives a `Frame` and `Rect` from ratatui and draws itself. The filter sidebar is a persistent side panel, while overlays (column config, widget selector) are rendered on top of the main layout via popup regions. ## Developers @@ -127,17 +177,17 @@ cargo build ### Running checks ```sh -cargo fmt --all --check # Formatting (requires nightly rustfmt) +cargo fmt --all --check # Formatting (requires nightly rustfmt) cargo clippy --all-targets -- -D warnings # Linting -cargo test # Tests -cargo deny check # License and advisory audit +cargo test # Tests +cargo deny check # License and advisory audit ``` ### Project conventions - **Edition 2024** — uses let-chains and other modern Rust features. - **No `unsafe`** — the codebase is entirely safe Rust. -- **Async for SLURM commands only** — the TUI event loop is synchronous; async is used solely for non-blocking SLURM CLI calls via `async-process` + tokio. +- **Async for SLURM commands only** — the TUI event loop is synchronous; async is used solely for non-blocking SLURM CLI calls via `async-process` + tokio. Background workers use crossbeam channels, not async, to communicate with the dashboard. - **`color-eyre`** for error handling — `Result<()>` flows from `main()` through the dashboard. ## Contributing From b4cda6470867e166f1ae1ca429b173f3c9b4f5e6 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Fri, 20 Mar 2026 11:36:53 +0200 Subject: [PATCH 2/9] update theme --- src/views/custom_widget.rs | 21 ++++++++------------- src/views/filter_tree.rs | 12 ++++++++---- src/views/job_table.rs | 20 ++++++++++++++------ src/views/output_widget.rs | 22 +++++++++++----------- src/views/script_widget.rs | 18 +++++++++--------- src/views/theme.rs | 16 +++++++++++++--- 6 files changed, 63 insertions(+), 46 deletions(-) diff --git a/src/views/custom_widget.rs b/src/views/custom_widget.rs index 2a94192..4bfab88 100644 --- a/src/views/custom_widget.rs +++ b/src/views/custom_widget.rs @@ -11,9 +11,9 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use crate::core::live_file::{LiveFileMonitor, MonitorError}; +use crate::views::theme::{ACCENT_CUSTOM, DIM_BORDER}; const POLL_INTERVAL: Duration = Duration::from_secs(1); -const CUSTOM_ACCENT: Color = Color::Rgb(180, 130, 255); #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FileState { @@ -155,21 +155,16 @@ impl CustomOutputWidget { } pub fn render_inline(&mut self, frame: &mut Frame, area: Rect, focused: bool) { - let border_color = if focused { - CUSTOM_ACCENT - } else { - Color::Rgb(80, 80, 110) - }; - - let title = match &self.job_id { - Some(id) => format!(" {} [{}] ", self.title, id), - None => format!(" {} ", self.title), - }; + let border_color = if focused { ACCENT_CUSTOM } else { DIM_BORDER }; let block = Block::default() - .title(title) + .title(format!(" {} ", self.title)) .borders(Borders::ALL) - .border_type(BorderType::Rounded) + .border_type(if focused { + BorderType::Double + } else { + BorderType::Rounded + }) .border_style(Style::default().fg(border_color)); if self.job_id.is_none() { diff --git a/src/views/filter_tree.rs b/src/views/filter_tree.rs index 23f929a..8348cf4 100644 --- a/src/views/filter_tree.rs +++ b/src/views/filter_tree.rs @@ -10,7 +10,7 @@ use regex::Regex; use crate::backend::{JobState, query::QueryParams}; -use super::theme::{ACCENT, CHECKED_COLOR, DIM_BORDER, UNCHECKED_COLOR}; +use super::theme::{ACCENT_SIDEBAR, CHECKED_COLOR, DIM_BORDER, UNCHECKED_COLOR}; const HEADER_COLOR: Color = Color::Rgb(200, 170, 240); const INPUT_COLOR: Color = Color::Rgb(220, 200, 130); @@ -375,11 +375,15 @@ impl FilterTree { known_qos: &[String], known_nodes: &[String], ) { - let border_color = if focused { ACCENT } else { DIM_BORDER }; + let border_color = if focused { ACCENT_SIDEBAR } else { DIM_BORDER }; let block = Block::default() .title(" Filters ") .borders(Borders::ALL) - .border_type(BorderType::Rounded) + .border_type(if focused { + BorderType::Double + } else { + BorderType::Rounded + }) .border_style(Style::default().fg(border_color)); let inner = block.inner(area); @@ -523,7 +527,7 @@ impl FilterTree { if valid == Some(false) { INVALID_COLOR } else { - ACCENT + ACCENT_SIDEBAR } } else if valid == Some(false) { INVALID_COLOR diff --git a/src/views/job_table.rs b/src/views/job_table.rs index e7ca6f9..09317b0 100644 --- a/src/views/job_table.rs +++ b/src/views/job_table.rs @@ -7,6 +7,7 @@ use ratatui::{ use crate::backend::{Job, JobState}; use crate::views::fields::{JobField, OrderedField, SortDirection}; +use crate::views::theme::{DIM_BORDER, ROW_HIGHLIGHT_BG}; pub struct JobTable { pub tbl_state: TableState, @@ -170,7 +171,7 @@ impl JobTable { }; let sty = Style::default() - .fg(Color::Magenta) + .fg(Color::Rgb(200, 120, 255)) .add_modifier(Modifier::BOLD) .add_modifier(Modifier::UNDERLINED); @@ -186,7 +187,10 @@ impl JobTable { let tint = state_color(job.state); let row_style = if is_marked { - Style::default().fg(tint).add_modifier(Modifier::UNDERLINED) + Style::default() + .fg(Color::Rgb(15, 12, 25)) + .bg(tint) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(tint) }; @@ -251,20 +255,24 @@ impl JobTable { .block( Block::default() .borders(Borders::ALL) - .border_type(BorderType::Rounded) + .border_type(if focused { + BorderType::Double + } else { + BorderType::Rounded + }) .title(caption) .border_style(Style::default().fg(if focused { Color::Magenta } else { - Color::Rgb(80, 80, 110) + DIM_BORDER })), ) .row_highlight_style( Style::default() .add_modifier(Modifier::BOLD) - .bg(Color::Rgb(35, 25, 55)), + .bg(ROW_HIGHLIGHT_BG), ) - .highlight_symbol(" \u{25cf} "); + .highlight_symbol(" \u{25b8} "); frame.render_stateful_widget(table, area, &mut self.tbl_state); } diff --git a/src/views/output_widget.rs b/src/views/output_widget.rs index 22386aa..c452f6f 100644 --- a/src/views/output_widget.rs +++ b/src/views/output_widget.rs @@ -10,6 +10,7 @@ use std::{path::PathBuf, time::Duration}; use crate::backend::commands::JobDetail; use crate::core::live_file::{LiveFileMonitor, MonitorError}; +use crate::views::theme::{ACCENT_STDERR, ACCENT_STDOUT, DIM_BORDER}; const POLL_INTERVAL: Duration = Duration::from_secs(1); @@ -186,21 +187,20 @@ impl OutputWidget { } pub fn render_inline(&mut self, frame: &mut Frame, area: Rect, focused: bool) { - let border_color = if focused { - Color::Magenta - } else { - Color::Rgb(80, 80, 110) - }; - - let title = match &self.job_id { - Some(id) => format!(" {} [{}] ", self.stream.label(), id), - None => format!(" {} ", self.stream.label()), + let focused_color = match self.stream { + StreamKind::Stdout => ACCENT_STDOUT, + StreamKind::Stderr => ACCENT_STDERR, }; + let border_color = if focused { focused_color } else { DIM_BORDER }; let block = Block::default() - .title(title) + .title(format!(" {} ", self.stream.label())) .borders(Borders::ALL) - .border_type(BorderType::Rounded) + .border_type(if focused { + BorderType::Double + } else { + BorderType::Rounded + }) .border_style(Style::default().fg(border_color)); if self.job_id.is_none() { diff --git a/src/views/script_widget.rs b/src/views/script_widget.rs index 8f2b568..166f676 100644 --- a/src/views/script_widget.rs +++ b/src/views/script_widget.rs @@ -13,6 +13,7 @@ use std::sync::LazyLock; use std::thread; use crate::backend::commands::JobDetail; +use crate::views::theme::{ACCENT_SCRIPT, DIM_BORDER}; static ANSI_ESCAPE_RE: LazyLock = LazyLock::new(|| Regex::new(r"\x1B\[([0-9;]*)m").unwrap()); @@ -135,20 +136,19 @@ impl ScriptWidget { pub fn render_inline(&self, frame: &mut Frame, area: Rect, focused: bool) { let border_color = if focused { - Color::Magenta + ACCENT_SCRIPT } else { - Color::Rgb(80, 80, 110) - }; - - let title = match (&self.job_id, &self.job_name) { - (Some(id), Some(name)) => format!(" Script: {}/{} ", name, id), - _ => " Script ".to_string(), + DIM_BORDER }; let block = Block::default() - .title(title) + .title(" Script ") .borders(Borders::ALL) - .border_type(BorderType::Rounded) + .border_type(if focused { + BorderType::Double + } else { + BorderType::Rounded + }) .border_style(Style::default().fg(border_color)); if self.job_id.is_none() { diff --git a/src/views/theme.rs b/src/views/theme.rs index bd3da6e..092fc10 100644 --- a/src/views/theme.rs +++ b/src/views/theme.rs @@ -1,9 +1,19 @@ use ratatui::style::Color; -pub const ACCENT: Color = Color::Magenta; -pub const BAR_BG: Color = Color::Rgb(30, 30, 50); +pub const ACCENT: Color = Color::Rgb(200, 120, 255); +pub const BAR_BG: Color = Color::Rgb(22, 22, 40); pub const POPUP_BG: Color = Color::Rgb(15, 15, 30); -pub const DIM_BORDER: Color = Color::Rgb(80, 80, 110); +pub const DIM_BORDER: Color = Color::Rgb(60, 60, 85); pub const FLASH_COLOR: Color = Color::Rgb(255, 200, 80); pub const CHECKED_COLOR: Color = Color::Rgb(80, 200, 255); pub const UNCHECKED_COLOR: Color = Color::Rgb(140, 140, 140); + +// Per-widget accent colors for focused borders +pub const ACCENT_SCRIPT: Color = Color::Rgb(80, 200, 220); +pub const ACCENT_STDOUT: Color = Color::Rgb(80, 210, 150); +pub const ACCENT_STDERR: Color = Color::Rgb(230, 100, 100); +pub const ACCENT_CUSTOM: Color = Color::Rgb(180, 130, 255); +pub const ACCENT_SIDEBAR: Color = Color::Rgb(255, 180, 80); + +// Table row highlighting +pub const ROW_HIGHLIGHT_BG: Color = Color::Rgb(40, 30, 65); From c038f97927c924060bf6ddfbaa427ab3f168acbf Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Fri, 20 Mar 2026 11:38:38 +0200 Subject: [PATCH 3/9] typo --- src/views/widget_selector.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/widget_selector.rs b/src/views/widget_selector.rs index ed56cd5..d57d096 100644 --- a/src/views/widget_selector.rs +++ b/src/views/widget_selector.rs @@ -27,7 +27,7 @@ impl WidgetKind { pub fn label<'a>(&self, custom_defs: &'a [CustomWidgetDef]) -> &'a str { match self { WidgetKind::Filters => "Filters", - WidgetKind::Script => "Execution Script", + WidgetKind::Script => "Script", WidgetKind::Stdout => "stdout", WidgetKind::Stderr => "stderr", WidgetKind::Custom(i) => custom_defs From ea78112700a711c4ac8a55e87c19aab451feb7b7 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Fri, 20 Mar 2026 12:27:58 +0200 Subject: [PATCH 4/9] update key bindings --- src/dashboard.rs | 23 +++++++++-------------- src/views/chrome.rs | 11 +++-------- src/views/fields.rs | 4 ++-- src/views/filter_tree.rs | 4 ++-- src/views/widget_selector.rs | 18 +++++++++++++++--- 5 files changed, 31 insertions(+), 29 deletions(-) diff --git a/src/dashboard.rs b/src/dashboard.rs index d47d7b5..b7075cf 100644 --- a/src/dashboard.rs +++ b/src/dashboard.rs @@ -626,11 +626,12 @@ impl Dashboard { self.flash(format!("{} contents copied", title), 3); } } - FocusWidget::Sidebar => { - self.focus = FocusWidget::Table; - } - FocusWidget::Table => { - self.alive = false; + FocusWidget::Sidebar | FocusWidget::Table => { + self.field_sel = FieldSelector::new( + self.visible_fields.clone(), + self.sort_fields.clone(), + ); + self.field_sel.visible = true; } } return; @@ -643,16 +644,10 @@ impl Dashboard { self.cycle_focus_reverse(); return; } - (_, KeyCode::Char('w')) => { + (KeyModifiers::CONTROL, KeyCode::Char('w')) => { self.widget_sel.visible = true; return; } - (_, KeyCode::Char('c')) if self.focus == FocusWidget::Table => { - self.field_sel = - FieldSelector::new(self.visible_fields.clone(), self.sort_fields.clone()); - self.field_sel.visible = true; - return; - } _ => {} } @@ -679,14 +674,14 @@ impl Dashboard { self.table.advance(); } (_, KeyCode::Char(' ')) => self.table.flip_selection(), - (_, KeyCode::Char('a')) => { + (KeyModifiers::CONTROL, KeyCode::Char('a')) => { if self.table.everything_marked() { self.table.unmark_all(); } else { self.table.mark_all(); } } - (_, KeyCode::Char('x')) => { + (KeyModifiers::CONTROL, KeyCode::Char('x')) => { self.confirming_cancel = true; } _ => {} diff --git a/src/views/chrome.rs b/src/views/chrome.rs index a1b1b02..a122506 100644 --- a/src/views/chrome.rs +++ b/src/views/chrome.rs @@ -215,20 +215,15 @@ pub fn render_statusbar( ("Esc", "Quit"), ("Tab", "Focus"), ("\u{2191}\u{2193}", "Navigation"), - ("w", "Widgets"), + ("Ctrl+W", "Widgets"), + ("Ctrl+C", "Columns"), ]; // Context-specific bindings per focused widget match focus { - FocusWidget::Table => { - bindings.push(("c", "Columns")); - // bindings.push(("Space", "Mark")); - // bindings.push(("a", "Mark All")); - // bindings.push(("x", "Cancel")); - } + FocusWidget::Table => {} FocusWidget::Sidebar => { bindings.push(("Enter", "Edit/Toggle")); - bindings.push(("r", "Reset")); bindings.push(("Ctrl+S", "Save")); } FocusWidget::Script diff --git a/src/views/fields.rs b/src/views/fields.rs index 70d1b5a..b7df8b4 100644 --- a/src/views/fields.rs +++ b/src/views/fields.rs @@ -345,7 +345,7 @@ impl FieldSelector { } }; - let full = format!("{} | r: Reset | Ctrl+S: Save | Esc: Close", hint); + let full = format!("{} | Ctrl+R: Reset | Ctrl+S: Save | Esc: Close", hint); let widget = Paragraph::new(full) .style(Style::default().fg(Color::DarkGray)) .block( @@ -369,7 +369,7 @@ impl FieldSelector { KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { return FieldAction::Save; } - KeyCode::Char('r') => { + KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.reset_to_defaults(); return FieldAction::Confirm; } diff --git a/src/views/filter_tree.rs b/src/views/filter_tree.rs index 8348cf4..544e707 100644 --- a/src/views/filter_tree.rs +++ b/src/views/filter_tree.rs @@ -1,4 +1,4 @@ -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ Frame, layout::{Position, Rect}, @@ -226,7 +226,7 @@ impl FilterTree { FilterTreeAction::Noop } } - KeyCode::Char('r') => { + KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { params.statuses.clear(); params.partitions.clear(); params.qos.clear(); diff --git a/src/views/widget_selector.rs b/src/views/widget_selector.rs index d57d096..2e42c4f 100644 --- a/src/views/widget_selector.rs +++ b/src/views/widget_selector.rs @@ -228,14 +228,26 @@ impl WidgetSelector { WidgetSelectorAction::Noop } } - KeyCode::Char('a') => { + KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.adding = true; self.add_phase = AddPhase::Title; self.add_title_buf.clear(); self.add_filename_buf.clear(); WidgetSelectorAction::Noop } - KeyCode::Char('d') | KeyCode::Delete => { + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if self.cursor < item_count + && let WidgetKind::Custom(i) = &all_kinds[self.cursor] + { + widgets.remove_custom(*i); + if self.cursor > 0 && self.cursor >= item_count - 1 { + self.cursor -= 1; + } + return WidgetSelectorAction::Changed; + } + WidgetSelectorAction::Noop + } + KeyCode::Delete => { if self.cursor < item_count && let WidgetKind::Custom(i) = &all_kinds[self.cursor] { @@ -378,7 +390,7 @@ impl WidgetSelector { lines.push(Line::raw("")); - let hint = " \u{2191}\u{2193}: Navigate | Enter: Toggle | a: Add | d: Delete | Ctrl+S: Save | Esc: Close"; + let hint = " \u{2191}\u{2193}: Navigate | Enter: Toggle | Ctrl+A: Add | Ctrl+D/Del: Delete | Ctrl+S: Save | Esc: Close"; lines.push(Line::from(Span::styled( hint, Style::default().fg(Color::DarkGray), From 75f7b6c644c9d0d006cbf51b4c8b04dfe28ef1e4 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Fri, 20 Mar 2026 12:40:55 +0200 Subject: [PATCH 5/9] fix typo --- src/views/chrome.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/views/chrome.rs b/src/views/chrome.rs index a122506..9403a8c 100644 --- a/src/views/chrome.rs +++ b/src/views/chrome.rs @@ -215,13 +215,14 @@ pub fn render_statusbar( ("Esc", "Quit"), ("Tab", "Focus"), ("\u{2191}\u{2193}", "Navigation"), - ("Ctrl+W", "Widgets"), - ("Ctrl+C", "Columns"), + ("Ctrl+W", "Widgets") ]; // Context-specific bindings per focused widget match focus { - FocusWidget::Table => {} + FocusWidget::Table => { + bindings.push(("Ctrl+C", "Columns")); + } FocusWidget::Sidebar => { bindings.push(("Enter", "Edit/Toggle")); bindings.push(("Ctrl+S", "Save")); From 1695ee4f8be9cae04012ba6b9fe6c3f095055e57 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Fri, 20 Mar 2026 13:00:38 +0200 Subject: [PATCH 6/9] update layout --- src/views/chrome.rs | 82 +++++++++++++++++++++--------------- src/views/script_widget.rs | 6 +-- src/views/widget_selector.rs | 35 +++++++++------ 3 files changed, 70 insertions(+), 53 deletions(-) diff --git a/src/views/chrome.rs b/src/views/chrome.rs index 9403a8c..1c2864c 100644 --- a/src/views/chrome.rs +++ b/src/views/chrome.rs @@ -45,10 +45,10 @@ pub fn build_frame(frame: &mut Frame, widgets: &VisibleWidgets) -> FrameLayout { (None, content) }; - let visible_right = widgets.visible_right_widgets(); - let total_right = visible_right.len(); + let all_panels = widgets.visible_panel_widgets(); - if total_right == 0 { + // No panel widgets: table uses all remaining space + if all_panels.is_empty() { return FrameLayout { titlebar, sidebar, @@ -59,38 +59,38 @@ pub fn build_frame(frame: &mut Frame, widgets: &VisibleWidgets) -> FrameLayout { }; } - // Split into panel widgets (right side, max 4) and overflow (under table) - let (panel_kinds, overflow_kinds) = if total_right <= 4 { - (visible_right.clone(), Vec::new()) - } else { - (visible_right[..4].to_vec(), visible_right[4..].to_vec()) - }; + let total = all_panels.len(); - let panel_count = panel_kinds.len(); + // Right column holds the first N widgets (up to 3), overflow goes to left column. + let right_count = total.min(3); + let overflow_count = total - right_count; - // Split remaining 50/50 into left column (table) and right column (widget panel) + // Grid has `right_count` rows across both columns. + // Split remaining 50/50 into left column (table + overflow) and right column. let cols = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(remaining); let (left_col, right_col) = (cols[0], cols[1]); - // Split right column equally among panel widgets - let right_constraints: Vec = (0..panel_count) - .map(|_| Constraint::Ratio(1, panel_count as u32)) + // Split right column equally among its widgets + let right_constraints: Vec = (0..right_count) + .map(|_| Constraint::Ratio(1, right_count as u32)) .collect(); let right_parts = Layout::default() .direction(Direction::Vertical) .constraints(right_constraints) .split(right_col); - let right_widgets: Vec<(WidgetKind, Rect)> = panel_kinds - .into_iter() + // Right column: positions A, B, C (top to bottom) + let right_widgets: Vec<(WidgetKind, Rect)> = all_panels[..right_count] + .iter() + .cloned() .zip(right_parts.iter().copied()) .collect(); // No overflow: table uses the whole left column - if overflow_kinds.is_empty() { + if overflow_count == 0 { return FrameLayout { titlebar, sidebar, @@ -101,28 +101,42 @@ pub fn build_frame(frame: &mut Frame, widgets: &VisibleWidgets) -> FrameLayout { }; } - // Overflow: split left column into table (top) + bottom zone - let bottom_height = right_parts.iter().map(|r| r.height).min().unwrap_or(3); - let left_split = Layout::default() + // Split left column into the same grid as the right column. + // Table spans the top rows, overflow widgets fill bottom rows. + let table_rows = right_count - overflow_count; + let left_constraints: Vec = (0..right_count) + .map(|_| Constraint::Ratio(1, right_count as u32)) + .collect(); + let left_parts = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(5), Constraint::Length(bottom_height)]) + .constraints(left_constraints) .split(left_col); - let table_rect = left_split[0]; - let bottom_zone = left_split[1]; - let bottom_widgets = if overflow_kinds.len() == 1 { - vec![(overflow_kinds[0].clone(), bottom_zone)] + // Table spans the top rows + let table_rect = if table_rows == 1 { + left_parts[0] } else { - let halves = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(bottom_zone); - overflow_kinds - .into_iter() - .zip(halves.iter().copied()) - .collect() + let first = left_parts[0]; + let last = left_parts[table_rows - 1]; + Rect { + x: first.x, + y: first.y, + width: first.width, + height: last.y + last.height - first.y, + } }; + // Overflow widgets fill bottom rows of left column. + // Positions D, E (bottom to top): the last overflow widget sits at the + // bottom row, the second-to-last above it, etc. + let overflow = &all_panels[right_count..]; + let bottom_widgets: Vec<(WidgetKind, Rect)> = overflow + .iter() + .rev() + .zip(left_parts[table_rows..].iter().copied()) + .map(|(kind, rect)| (kind.clone(), rect)) + .collect(); + FrameLayout { titlebar, sidebar, @@ -215,7 +229,7 @@ pub fn render_statusbar( ("Esc", "Quit"), ("Tab", "Focus"), ("\u{2191}\u{2193}", "Navigation"), - ("Ctrl+W", "Widgets") + ("Ctrl+W", "Widgets"), ]; // Context-specific bindings per focused widget diff --git a/src/views/script_widget.rs b/src/views/script_widget.rs index 166f676..f9c98e8 100644 --- a/src/views/script_widget.rs +++ b/src/views/script_widget.rs @@ -135,11 +135,7 @@ impl ScriptWidget { } pub fn render_inline(&self, frame: &mut Frame, area: Rect, focused: bool) { - let border_color = if focused { - ACCENT_SCRIPT - } else { - DIM_BORDER - }; + let border_color = if focused { ACCENT_SCRIPT } else { DIM_BORDER }; let block = Block::default() .title(" Script ") diff --git a/src/views/widget_selector.rs b/src/views/widget_selector.rs index 2e42c4f..5f1e4f8 100644 --- a/src/views/widget_selector.rs +++ b/src/views/widget_selector.rs @@ -12,7 +12,7 @@ use super::theme::{ACCENT, CHECKED_COLOR, DIM_BORDER, POPUP_BG, UNCHECKED_COLOR} const CUSTOM_COLOR: Color = Color::Rgb(180, 130, 255); const CUSTOM_DIM: Color = Color::Rgb(120, 100, 160); -const MAX_RIGHT_WIDGETS: usize = 6; +const MAX_RIGHT_SLOTS: usize = 3; #[derive(Debug, Clone, PartialEq, Eq)] pub enum WidgetKind { @@ -98,18 +98,9 @@ impl VisibleWidgets { } } - /// Count of visible right-panel widgets (excludes the sidebar). - pub fn right_widget_count(&self) -> usize { - let builtin = [self.script, self.stdout, self.stderr] - .iter() - .filter(|&&v| v) - .count(); - let custom = self.custom.iter().filter(|c| c.visible).count(); - builtin + custom - } - - /// Ordered list of visible right-panel widget kinds. - pub fn visible_right_widgets(&self) -> Vec { + /// Ordered list of all visible panel widget kinds + /// (script, stdout, stderr, then custom — in priority order). + pub fn visible_panel_widgets(&self) -> Vec { let mut out = Vec::new(); if self.script { out.push(WidgetKind::Script); @@ -128,6 +119,22 @@ impl VisibleWidgets { out } + /// Whether another panel widget can be toggled on. + /// The grid has N rows; the right column takes min(N, total) widgets + /// and overflow fills the left column below the table. The table must + /// keep at least one row, so total visible panels are capped at 2*N - 1 + /// where N = right-column count = min(total, MAX_RIGHT_SLOTS). + pub fn can_add_panel(&self) -> bool { + let total = self.visible_panel_widgets().len(); + // With 0 panels the split doesn't exist yet, always allow + if total == 0 { + return true; + } + let right_slots = total.min(MAX_RIGHT_SLOTS); + // Left column can hold at most (right_slots - 1) overflow widgets + total < right_slots + right_slots.saturating_sub(1) + } + /// Full ordered list for the widget selector (all items including hidden). fn all_widget_kinds(&self) -> Vec { let mut out: Vec = BUILTIN_ORDER.to_vec(); @@ -218,7 +225,7 @@ impl WidgetSelector { // Enforce cap: only block toggling ON, not OFF if !widgets.is_visible(kind) && *kind != WidgetKind::Filters - && widgets.right_widget_count() >= MAX_RIGHT_WIDGETS + && !widgets.can_add_panel() { return WidgetSelectorAction::Noop; } From 091cc2338dcdac1f1579d6dd4eb01b2202878420 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Sun, 22 Mar 2026 17:20:32 +0200 Subject: [PATCH 7/9] fix more than 6 widgets bug --- src/views/chrome.rs | 5 ++++- src/views/widget_selector.rs | 15 ++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/views/chrome.rs b/src/views/chrome.rs index 1c2864c..c7eb0e8 100644 --- a/src/views/chrome.rs +++ b/src/views/chrome.rs @@ -101,6 +101,9 @@ pub fn build_frame(frame: &mut Frame, widgets: &VisibleWidgets) -> FrameLayout { }; } + // Cap overflow so the table always keeps at least one row. + let overflow_count = overflow_count.min(right_count.saturating_sub(1)); + // Split left column into the same grid as the right column. // Table spans the top rows, overflow widgets fill bottom rows. let table_rows = right_count - overflow_count; @@ -129,7 +132,7 @@ pub fn build_frame(frame: &mut Frame, widgets: &VisibleWidgets) -> FrameLayout { // Overflow widgets fill bottom rows of left column. // Positions D, E (bottom to top): the last overflow widget sits at the // bottom row, the second-to-last above it, etc. - let overflow = &all_panels[right_count..]; + let overflow = &all_panels[right_count..right_count + overflow_count]; let bottom_widgets: Vec<(WidgetKind, Rect)> = overflow .iter() .rev() diff --git a/src/views/widget_selector.rs b/src/views/widget_selector.rs index 5f1e4f8..a55c71a 100644 --- a/src/views/widget_selector.rs +++ b/src/views/widget_selector.rs @@ -120,19 +120,12 @@ impl VisibleWidgets { } /// Whether another panel widget can be toggled on. - /// The grid has N rows; the right column takes min(N, total) widgets - /// and overflow fills the left column below the table. The table must - /// keep at least one row, so total visible panels are capped at 2*N - 1 - /// where N = right-column count = min(total, MAX_RIGHT_SLOTS). + /// Right column holds up to MAX_RIGHT_SLOTS widgets, overflow fills the + /// left column below the table. The table must keep at least one row, + /// so total visible panels are capped at 2 * MAX_RIGHT_SLOTS - 1. pub fn can_add_panel(&self) -> bool { let total = self.visible_panel_widgets().len(); - // With 0 panels the split doesn't exist yet, always allow - if total == 0 { - return true; - } - let right_slots = total.min(MAX_RIGHT_SLOTS); - // Left column can hold at most (right_slots - 1) overflow widgets - total < right_slots + right_slots.saturating_sub(1) + total < 2 * MAX_RIGHT_SLOTS - 1 } /// Full ordered list for the widget selector (all items including hidden). From f644fcc8dce6651cf18081e0e78c8742606a9c65 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Sun, 22 Mar 2026 17:28:08 +0200 Subject: [PATCH 8/9] fix minor bug when adding widgets --- src/views/widget_selector.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/widget_selector.rs b/src/views/widget_selector.rs index a55c71a..de9c412 100644 --- a/src/views/widget_selector.rs +++ b/src/views/widget_selector.rs @@ -285,7 +285,7 @@ impl WidgetSelector { widgets.custom.push(CustomWidgetDef { title: self.add_title_buf.trim().to_string(), filename: self.add_filename_buf.trim().to_string(), - visible: true, + visible: widgets.can_add_panel(), }); self.adding = false; self.cursor = BUILTIN_ORDER.len() + widgets.custom.len() - 1; From 3e3b854d70c94658102b442c17a4e2274cbeb96c Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Thu, 26 Mar 2026 18:13:01 +0200 Subject: [PATCH 9/9] add changelog entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 187097b..f90fd95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Refreshed the visual theme and tightened keybindings ahead of the first release. Every widget now has its own accent color when focused — cyan for the script viewer, green for stdout, red for stderr, purple for custom panels, and orange for the filter sidebar — and focused borders switched from rounded to double so the active panel is immediately obvious. Marked job rows use an inverted style (dark text on a colored background) instead of the old underline, the cursor symbol changed from a filled circle to a triangle, and the base palette was nudged darker overall. Several single-key shortcuts that conflicted with text input were moved behind `Ctrl`: widget selector is now `Ctrl+W`, column config is `Ctrl+C`, select-all is `Ctrl+A`, cancel is `Ctrl+X`, and reset is `Ctrl+R` in both the field and filter dialogs; the same treatment was applied to the add (`Ctrl+A`) and delete (`Ctrl+D`) actions in the widget selector. The layout engine was reworked so the right column holds up to three widgets and any overflow spills into the left column below the table on an aligned grid, replacing the earlier scheme that squeezed up to four panels on the right and tacked extras onto a half-height bottom strip; the total visible panel cap is now five (2×3−1) to guarantee the table always keeps at least one grid row. Widget titles were simplified by dropping the job ID suffix, and the "Execution Script" label was shortened to "Script". The README was rewritten with a full keybinding reference split by context, an updated architecture tree, and expanded feature descriptions. [PR #6](https://github.com/fedonman/sqwatch/pull/6) + ### Added - Three operations that previously blocked the main thread and froze the UI — `scontrol show job` lookups, periodic `squeue` refreshes, and script file loading with optional `bat` highlighting — were moved into dedicated background threads. A new `JobDetailResolver` runs a single `scontrol` call per job and caches up to 64 results, replacing the duplicate per-widget calls that each blocked for 100–500 ms; widgets now show a "Loading…" placeholder until the detail arrives, and the resolver deduplicates rapid requests by draining the channel and keeping only the latest job ID. A new `JobFetcher` runs `squeue` in its own lightweight tokio runtime so the 1-second auto-refresh and filter-apply no longer stall rendering; the old synchronous `reload_jobs` was split into `reload_jobs_sync` (used once at startup) and a non-blocking `submit_reload` path whose results are picked up on the next timer tick. The script widget's `load_content` was similarly offloaded to a background thread so that file reads and `bat` invocations never touch the render path, with a new `poll_updates` method that mirrors the pattern already used by the output and custom widgets. The input processing loop now drains all pending signals on each iteration and collapses consecutive `Timer` events into a single tick, which eliminates the multi-second freeze that occurred when switching back to the terminal after the window had been unfocused and hundreds of stale timers had piled up in the channel. `Ctrl+C` while any content widget (script, stdout, stderr, or custom) is focused now copies the widget's content to the system clipboard via the OSC 52 escape sequence and flashes a confirmation in the titlebar; the binding works over SSH and inside tmux without requiring X11 or Wayland, and `Esc` remains the key for returning focus to the table. The script widget gained `PageUp`/`PageDown` and `Ctrl+U`/`Ctrl+D` scrolling to match the other widgets, and all four content widget types now show `PgUp/Dn Scroll` and `Ctrl+C Copy` hints in the statusbar. [PR #5](https://github.com/fedonman/sqwatch/pull/5)