Skip to content

Commit 7d002db

Browse files
author
Your Name
committed
v0.3.1: Fix mouse support for all tabs, per-tab sorting
Mouse support fixes: - Fix tab bar click regions for Net and GPU tabs (off-by-one) - Fix row click selection for Net/GPU tabs (was always targeting Main tab) - Enable column header click sorting for Net and GPU tabs - Add per-tab sort state (net_sort_field, gpu_sort_field) so each tab remembers its own sort column and direction independently - Sort indicator arrows now display correctly per-tab - F6 sort menu, < > cycling, and I (invert) all work per-tab - Collector uses user's sort preference instead of hardcoded order README: Remove outdated GetPerTcpConnectionEStats references, clarify Net tab works without admin.
1 parent ab2acc9 commit 7d002db

9 files changed

Lines changed: 176 additions & 47 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "pstop"
3-
version = "0.3.0"
3+
version = "0.3.1"
44
edition = "2021"
55
description = "htop for Windows — beautiful, fast TUI system monitor with per-core CPU bars, memory/swap/network, tree view, process management, 7 color schemes, mouse support. Drop-in htop replacement for PowerShell & Windows Terminal."
66
license = "MIT"

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ Press `F5` or `t` to toggle process tree view — see parent-child relationships
139139
### 📋 Four Tab Views
140140
- **Main** - Full process table (PID, USER, CPU%, MEM%, TIME+, Command...)
141141
- **I/O** - Disk read/write rates per process
142-
- **Net** - Per-process network bandwidth (live download/upload rates with auto-scaling B/s, KB/s, MB/s, GB/s) plus active connection counts via `GetPerTcpConnectionEStats`. Admin recommended for bandwidth data.
142+
- **Net** - Per-process network bandwidth (live download/upload rates with auto-scaling B/s, KB/s, MB/s, GB/s) plus active connection counts. No admin required.
143143
- **GPU** - Per-process GPU engine utilization and dedicated/shared video memory usage via PDH performance counters
144144

145145
### ⚙️ F2 Setup Menu (Full htop Parity)
@@ -264,7 +264,7 @@ Format: simple `key=value` (htoprc-style). Persisted settings include:
264264
- [x] WinGet (`winget install marlocarlo.pstop`)
265265
- [x] Chocolatey (`choco install pstop`)
266266
- [x] GPU monitoring (per-process GPU engine usage + VRAM, header GPU/VMem bars)
267-
- [x] Network per-process tracking (live bandwidth via GetPerTcpConnectionEStats)
267+
- [x] Network per-process tracking (live bandwidth, no admin required)
268268
- [x] Auto-adjusting CPU column layout (2/4/8/16 columns based on core count)
269269
- [x] Independent htop-style header panel flow (no forced alignment)
270270
- [ ] Scoop bucket

‎choco/pstop.nuspec‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
33
<metadata>
44
<id>pstop</id>
5-
<version>0.3.0</version>
5+
<version>0.3.1</version>
66
<title>pstop - htop for Windows</title>
77
<authors>marlocarlo</authors>
88
<owners>marlocarlo</owners>

‎src/app.rs‎

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,20 @@ pub struct App {
6969
pub scroll_offset: usize,
7070
pub visible_rows: usize,
7171

72-
// Sorting
72+
// Sorting (Main/IO tab)
7373
pub sort_field: ProcessSortField,
7474
pub sort_ascending: bool,
7575
pub sort_menu_index: usize,
7676
pub sort_scroll_offset: usize,
7777

78+
// Sorting (Net tab)
79+
pub net_sort_field: ProcessSortField,
80+
pub net_sort_ascending: bool,
81+
82+
// Sorting (GPU tab)
83+
pub gpu_sort_field: ProcessSortField,
84+
pub gpu_sort_ascending: bool,
85+
7886
// Search (F3) — transient, doesn't filter
7987
pub search_query: String,
8088
pub search_not_found: bool,
@@ -210,6 +218,12 @@ impl App {
210218
sort_menu_index: 9,
211219
sort_scroll_offset: 0,
212220

221+
net_sort_field: ProcessSortField::IoReadRate, // Download by default
222+
net_sort_ascending: false,
223+
224+
gpu_sort_field: ProcessSortField::Cpu, // GPU% by default
225+
gpu_sort_ascending: false,
226+
213227
search_query: String::new(),
214228
search_not_found: false,
215229
filter_query: String::new(),
@@ -635,16 +649,107 @@ impl App {
635649
self.filtered_processes.get(self.selected_index)
636650
}
637651

638-
/// Toggle sort field (cycle through or set specific)
652+
/// Get the active sort field for the current tab
653+
pub fn active_sort_field(&self) -> ProcessSortField {
654+
match self.active_tab {
655+
ProcessTab::Main | ProcessTab::Io => self.sort_field,
656+
ProcessTab::Net => self.net_sort_field,
657+
ProcessTab::Gpu => self.gpu_sort_field,
658+
}
659+
}
660+
661+
/// Get the active sort ascending for the current tab
662+
pub fn active_sort_ascending(&self) -> bool {
663+
match self.active_tab {
664+
ProcessTab::Main | ProcessTab::Io => self.sort_ascending,
665+
ProcessTab::Net => self.net_sort_ascending,
666+
ProcessTab::Gpu => self.gpu_sort_ascending,
667+
}
668+
}
669+
670+
/// Toggle sort field for the current tab (cycle through or set specific)
639671
pub fn set_sort_field(&mut self, field: ProcessSortField) {
640-
if self.sort_field == field {
641-
self.sort_ascending = !self.sort_ascending;
642-
} else {
643-
self.sort_field = field;
644-
self.sort_ascending = false;
672+
match self.active_tab {
673+
ProcessTab::Main | ProcessTab::Io => {
674+
if self.sort_field == field {
675+
self.sort_ascending = !self.sort_ascending;
676+
} else {
677+
self.sort_field = field;
678+
self.sort_ascending = false;
679+
}
680+
}
681+
ProcessTab::Net => {
682+
if self.net_sort_field == field {
683+
self.net_sort_ascending = !self.net_sort_ascending;
684+
} else {
685+
self.net_sort_field = field;
686+
self.net_sort_ascending = false;
687+
}
688+
self.sort_net_processes();
689+
}
690+
ProcessTab::Gpu => {
691+
if self.gpu_sort_field == field {
692+
self.gpu_sort_ascending = !self.gpu_sort_ascending;
693+
} else {
694+
self.gpu_sort_field = field;
695+
self.gpu_sort_ascending = false;
696+
}
697+
self.sort_gpu_processes();
698+
}
645699
}
646700
}
647701

702+
/// Sort Net tab data by current net_sort_field
703+
pub fn sort_net_processes(&mut self) {
704+
let ascending = self.net_sort_ascending;
705+
let field = self.net_sort_field;
706+
707+
self.net_processes.sort_by(|a, b| {
708+
let ord = match field {
709+
ProcessSortField::Pid => a.pid.cmp(&b.pid),
710+
ProcessSortField::Command => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
711+
ProcessSortField::IoReadRate => a.recv_bytes_per_sec.partial_cmp(&b.recv_bytes_per_sec).unwrap_or(std::cmp::Ordering::Equal),
712+
ProcessSortField::IoWriteRate => a.send_bytes_per_sec.partial_cmp(&b.send_bytes_per_sec).unwrap_or(std::cmp::Ordering::Equal),
713+
ProcessSortField::Nice => a.connection_count.cmp(&b.connection_count),
714+
// Default: sort by total bandwidth
715+
_ => {
716+
let a_total = a.recv_bytes_per_sec + a.send_bytes_per_sec;
717+
let b_total = b.recv_bytes_per_sec + b.send_bytes_per_sec;
718+
a_total.partial_cmp(&b_total).unwrap_or(std::cmp::Ordering::Equal)
719+
}
720+
};
721+
if ascending { ord } else { ord.reverse() }
722+
});
723+
}
724+
725+
/// Sort GPU tab data by current gpu_sort_field
726+
pub fn sort_gpu_processes(&mut self) {
727+
let ascending = self.gpu_sort_ascending;
728+
let field = self.gpu_sort_field;
729+
730+
self.gpu_processes.sort_by(|a, b| {
731+
let ord = match field {
732+
ProcessSortField::Pid => a.pid.cmp(&b.pid),
733+
ProcessSortField::Command => {
734+
// Need process names — use pid comparison as fallback
735+
a.pid.cmp(&b.pid)
736+
}
737+
ProcessSortField::Cpu => a.gpu_usage.partial_cmp(&b.gpu_usage).unwrap_or(std::cmp::Ordering::Equal),
738+
ProcessSortField::Status => a.engine_type.cmp(&b.engine_type),
739+
ProcessSortField::ResMem => a.dedicated_mem.cmp(&b.dedicated_mem),
740+
ProcessSortField::SharedMem => a.shared_mem.cmp(&b.shared_mem),
741+
ProcessSortField::VirtMem => {
742+
let a_total = a.dedicated_mem + a.shared_mem;
743+
let b_total = b.dedicated_mem + b.shared_mem;
744+
a_total.cmp(&b_total)
745+
}
746+
// Default: sort by GPU usage
747+
_ => a.gpu_usage.partial_cmp(&b.gpu_usage).unwrap_or(std::cmp::Ordering::Equal),
748+
};
749+
if ascending { ord } else { ord.reverse() }
750+
});
751+
}
752+
648753
/// Toggle tag on selected process
649754
pub fn toggle_tag_selected(&mut self) {
650755
if let Some(proc) = self.selected_process() {

‎src/input.rs‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ fn handle_normal_mode(app: &mut App, key: KeyEvent) {
9494

9595
// ── F6 — sort menu ──
9696
KeyCode::F(6) => {
97-
app.sort_menu_index = app.sort_field.index();
97+
app.sort_menu_index = app.active_sort_field().index();
9898
app.sort_scroll_offset = 0;
9999
// Ensure current selection is visible
100100
if app.sort_menu_index >= 10 {
@@ -110,7 +110,13 @@ fn handle_normal_mode(app: &mut App, key: KeyEvent) {
110110
KeyCode::Char('M') => app.set_sort_field(ProcessSortField::Mem),
111111
KeyCode::Char('T') => app.set_sort_field(ProcessSortField::Time),
112112
KeyCode::Char('N') => app.set_sort_field(ProcessSortField::Pid),
113-
KeyCode::Char('I') => app.sort_ascending = !app.sort_ascending,
113+
KeyCode::Char('I') => {
114+
match app.active_tab {
115+
ProcessTab::Main | ProcessTab::Io => app.sort_ascending = !app.sort_ascending,
116+
ProcessTab::Net => { app.net_sort_ascending = !app.net_sort_ascending; app.sort_net_processes(); }
117+
ProcessTab::Gpu => { app.gpu_sort_ascending = !app.gpu_sort_ascending; app.sort_gpu_processes(); }
118+
}
119+
}
114120

115121
// ── F7 — Nice - (raise priority / lower nice) ──
116122
KeyCode::F(7) => {
@@ -724,14 +730,24 @@ fn kill_process_with_signal(pid: u32, signal_index: usize) {
724730
}
725731
}
726732

727-
/// Cycle through sort fields
733+
/// Cycle through sort fields (tab-aware: uses header fields for current tab)
728734
fn cycle_sort_field(app: &mut App, forward: bool) {
729-
let fields = ProcessSortField::all();
730-
let current_idx = fields.iter().position(|f| *f == app.sort_field).unwrap_or(0);
735+
use crate::ui::process_table::{HEADERS, IO_HEADERS, NET_HEADERS, GPU_HEADERS};
736+
use crate::app::ProcessTab;
737+
738+
let headers: &[(&str, u16, ProcessSortField, u8)] = match app.active_tab {
739+
ProcessTab::Main => HEADERS,
740+
ProcessTab::Io => IO_HEADERS,
741+
ProcessTab::Net => NET_HEADERS,
742+
ProcessTab::Gpu => GPU_HEADERS,
743+
};
744+
let fields: Vec<ProcessSortField> = headers.iter().map(|(_, _, f, _)| *f).collect();
745+
let current = app.active_sort_field();
746+
let current_idx = fields.iter().position(|f| *f == current).unwrap_or(0);
731747
let new_idx = if forward {
732748
(current_idx + 1) % fields.len()
733749
} else {
734750
if current_idx == 0 { fields.len() - 1 } else { current_idx - 1 }
735751
};
736-
app.sort_field = fields[new_idx];
752+
app.set_sort_field(fields[new_idx]);
737753
}

‎src/mouse.rs‎

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use crossterm::event::{MouseEvent, MouseEventKind, MouseButton};
33
use crate::app::{App, AppMode, ProcessTab};
44
use crate::system::process::ProcessSortField;
55
use crate::ui;
6-
use crate::ui::process_table::{HEADERS, IO_HEADERS, compute_display_columns};
6+
use crate::ui::process_table::{HEADERS, IO_HEADERS, NET_HEADERS, GPU_HEADERS, compute_display_columns};
77

88
/// Handle a mouse event.
99
/// Requires the terminal size (columns, rows) to compute layout areas.
@@ -62,33 +62,30 @@ fn handle_tab_bar_click(app: &mut App, x: u16) {
6262
app.active_tab = ProcessTab::Main;
6363
} else if (8..13).contains(&x) {
6464
app.active_tab = ProcessTab::Io;
65-
} else if (14..18).contains(&x) {
65+
} else if (14..19).contains(&x) {
6666
app.active_tab = ProcessTab::Net;
67-
} else if (19..24).contains(&x) {
67+
} else if (20..25).contains(&x) {
6868
app.active_tab = ProcessTab::Gpu;
6969
}
7070
}
7171

7272
// ── Header click (sort by column) ───────────────────────────────────
7373

7474
fn handle_header_click(app: &mut App, x: u16, term_width: u16) {
75-
// Net and GPU tabs don't support column-click sorting (different data model)
76-
if matches!(app.active_tab, ProcessTab::Net | ProcessTab::Gpu) {
77-
return;
78-
}
79-
8075
let headers = match app.active_tab {
8176
ProcessTab::Main => HEADERS,
8277
ProcessTab::Io => IO_HEADERS,
83-
_ => return,
78+
ProcessTab::Net => NET_HEADERS,
79+
ProcessTab::Gpu => GPU_HEADERS,
8480
};
8581

8682
// Compute display columns (same logic as rendering, so clicks match)
8783
let base_visible: std::collections::HashSet<ProcessSortField> = match app.active_tab {
8884
ProcessTab::Main => app.visible_columns.clone(),
8985
_ => headers.iter().map(|(_, _, f, _)| *f).collect(),
9086
};
91-
let display_cols = compute_display_columns(headers, &base_visible, term_width, app.sort_field);
87+
let sort_field = app.active_sort_field();
88+
let display_cols = compute_display_columns(headers, &base_visible, term_width, sort_field);
9289

9390
// Compute column boundaries, respecting auto-hidden columns
9491
let mut cursor: u16 = 0;
@@ -119,10 +116,26 @@ fn handle_header_click(app: &mut App, x: u16, term_width: u16) {
119116

120117
fn handle_row_click(app: &mut App, y: u16, data_start_y: u16) {
121118
let row_offset = (y - data_start_y) as usize;
122-
let target_index = app.scroll_offset + row_offset;
123119

124-
if target_index < app.filtered_processes.len() {
125-
app.selected_index = target_index;
120+
match app.active_tab {
121+
ProcessTab::Main | ProcessTab::Io => {
122+
let target_index = app.scroll_offset + row_offset;
123+
if target_index < app.filtered_processes.len() {
124+
app.selected_index = target_index;
125+
}
126+
}
127+
ProcessTab::Net => {
128+
let target_index = app.net_scroll_offset + row_offset;
129+
if target_index < app.net_processes.len() {
130+
app.net_selected_index = target_index;
131+
}
132+
}
133+
ProcessTab::Gpu => {
134+
let target_index = app.gpu_scroll_offset + row_offset;
135+
if target_index < app.gpu_processes.len() {
136+
app.gpu_selected_index = target_index;
137+
}
138+
}
126139
}
127140
}
128141

@@ -195,7 +208,7 @@ fn execute_fkey_action(app: &mut App, action: FkeyAction) {
195208
}
196209
}
197210
FkeyAction::SortBy => {
198-
app.sort_menu_index = app.sort_field.index();
211+
app.sort_menu_index = app.active_sort_field().index();
199212
app.mode = AppMode::SortSelect;
200213
}
201214
FkeyAction::NiceMinus => {

‎src/system/collector.rs‎

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ impl Collector {
123123
let conn_counts = netstat::count_connections_per_pid();
124124

125125
// Build ProcessNetBandwidth by matching connection PIDs to process I/O rates
126-
let mut net_procs: Vec<netstat::ProcessNetBandwidth> = conn_counts
126+
let net_procs: Vec<netstat::ProcessNetBandwidth> = conn_counts
127127
.into_iter()
128128
.map(|(pid, count)| {
129129
let (name, recv, send) = app.processes.iter()
@@ -143,23 +143,16 @@ impl Collector {
143143
})
144144
.collect();
145145

146-
// Sort: highest total bandwidth first, then by connection count
147-
net_procs.sort_by(|a, b| {
148-
let ar = a.recv_bytes_per_sec + a.send_bytes_per_sec;
149-
let br = b.recv_bytes_per_sec + b.send_bytes_per_sec;
150-
br.partial_cmp(&ar)
151-
.unwrap_or(std::cmp::Ordering::Equal)
152-
.then_with(|| b.connection_count.cmp(&a.connection_count))
153-
});
154-
146+
// Sort by user's selected Net sort field
155147
app.net_processes = net_procs;
148+
app.sort_net_processes();
156149
}
157150

158151
// ── GPU per-process data (GPU tab) ──
159152
if matches!(app.active_tab, crate::app::ProcessTab::Gpu) {
160153
app.gpu_processes = self.gpu_collector.collect();
161-
// Sort by GPU usage descending so busiest processes are at top
162-
app.gpu_processes.sort_by(|a, b| b.gpu_usage.partial_cmp(&a.gpu_usage).unwrap_or(std::cmp::Ordering::Equal));
154+
// Sort by user's selected GPU sort field
155+
app.sort_gpu_processes();
163156

164157
// Populate overall GPU stats for header meters
165158
let info = &self.gpu_collector.adapter_info;

‎src/ui/process_table.rs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,9 @@ pub fn draw_process_table(f: &mut Frame, app: &App, area: Rect) {
9696
ProcessTab::Main => app.visible_columns.clone(),
9797
_ => headers.iter().map(|(_, _, f, _)| *f).collect(),
9898
};
99-
let display_cols = compute_display_columns(headers, &base_visible, area.width, app.sort_field);
99+
let active_sort = app.active_sort_field();
100+
let active_ascending = app.active_sort_ascending();
101+
let display_cols = compute_display_columns(headers, &base_visible, area.width, active_sort);
100102

101103
// Build header spans with sort indicator
102104
let mut header_spans: Vec<Span> = Vec::new();
@@ -106,12 +108,12 @@ pub fn draw_process_table(f: &mut Frame, app: &App, area: Rect) {
106108
continue;
107109
}
108110

109-
let is_sorted = *sort_field == app.sort_field;
111+
let is_sorted = *sort_field == active_sort;
110112
let fixed_w = fixed_cols_width_for(headers, &display_cols);
111113
let w = if *width == 0 { (area.width as usize).saturating_sub(fixed_w) } else { *width as usize };
112114

113115
let display = if is_sorted {
114-
let arrow = if app.sort_ascending { "▲" } else { "▼" };
116+
let arrow = if active_ascending { "▲" } else { "▼" };
115117
format!("{}{}", name, arrow)
116118
} else {
117119
name.to_string()

0 commit comments

Comments
 (0)