Skip to content

Commit c8ea916

Browse files
feat: implement Rich UI, process aggregation, and robust input handling
- Fix input detection issues by implementing immediate multi-byte sequence (no longer does it spam characaters FINALLY) - Implement persistent UI configuration using Windows Registry to avoid external JSON files in PyInstaller builds. - Fixed artifacts surrounding startup and screen resizing by sending os.system('cls') on resize and startup_ok - Refined some stuff and checked compatability with dogfooding
1 parent 869b2ba commit c8ea916

File tree

13 files changed

+635
-44
lines changed

13 files changed

+635
-44
lines changed

README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,11 @@ A text based user interface (TUI) task manager for Windows, inspired by `htop`.
5353
| `export [file]` | Export process list to file (default: `processes.txt`) |
5454
| `quit` | Exit the application |
5555
| `showdrives` | Toggle display of all drives |
56+
| `procfull` | Toggle full process info (default: complete) |
57+
| `rich` | Toggle the use of the Rich UI version (restart required) |
5658

57-
Do be warned that faster refresh rates (above superfast) cause delta calculations to drift heavily, making the general values less accurate and for the bars to jump unexpectedly.
59+
> [!NOTE]
60+
> Fast refresh rates may cause delta calculation (for CPU cores) to drift, I've done my best to mitigate this but it's not perfect.
5861
5962
### Controls
6063

@@ -71,8 +74,9 @@ Do be warned that faster refresh rates (above superfast) cause delta calculation
7174

7275
Simply go to releases and download the latest installer or use the ps1 files attached, choose your options (ps1 does not support this) and you're ready to use winhtop in the terminal. Uninstalling should be easy aswell thru the installer, or the uninstall ps1 file.
7376

74-
Small disclaimer:
75-
Some security vendors may block the main .exe due using the same headers as malware (this is a known pyinstaller [issue](https://github.com/pyinstaller/pyinstaller/issues/6754), i'll be looking into using a different method), but rest assured it can't do anything malicious.
77+
78+
> [!IMPORTANT]
79+
> Some security vendors may block the main .exe due using the same headers as malware (this is a known pyinstaller [issue](https://github.com/pyinstaller/pyinstaller/issues/6754), i'll be looking into using a different method), but rest assured it can't do anything malicious.
7680
7781
## Requirements
7882

@@ -140,11 +144,11 @@ setx PATH "%PATH:;%LOCALAPPDATA%\Programs\WinHtop=%"
140144

141145
This program (or the installer attached) is unable to:
142146

143-
- Change registry values (apart from uninstall marking)
147+
- Change registry values (It makes an uninstall key from Inno setup and a DWORD for Rich configuration)
144148
- Sniff the network
145149
- Or other malicious activities
146150

147-
It is not my responsibility if you also run `kill csrss.exe`, most important system processes are blacklisted/protected from this command though, killing the wrong process can cause system instability or you to be logged out.
148-
This program is provided as-is, without any warranty. Use at your own risk.
151+
> [!CAUTION]
152+
> It is not my responsibility if you also run `kill csrss.exe`, most important system processes are blacklisted/protected from this command though, killing the wrong process can cause system instability or you to be logged out. This program is provided as-is, without any warranty. Use at your own risk.
149153
150154
Also it may be a bit slow on some lower end systems, but it should work fine for when you need it.

Winhtop-installer.iss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
; =========================================================
44

55
#define MyAppName "WinHtop"
6-
#define MyAppVersion "1.1.0"
6+
#define MyAppVersion "1.2.0"
77
#define MyAppPublisher "Iza Carlos"
88
#define MyAppExeName "winhtop.exe"
99
#define MyAppId "C6E2A3B4-D1F2-4EBA-BD3F-6A7C10B7B7C2"
9.3 MB
Binary file not shown.

dist/winhtop.exe

9.34 MB
Binary file not shown.

modules/config.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,29 @@
2525
# so for about 60 fps:
2626
"party": 0.0167
2727
}
28+
29+
# UI Mode Toggle - stored in registry for persistence across runs
30+
# Uses HKEY_CURRENT_USER\Software\WinHTop\UseRichUI
31+
import winreg
32+
33+
def _get_rich_ui_pref():
34+
"""Load USE_RICH_UI from Windows registry."""
35+
try:
36+
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\WinHTop", 0, winreg.KEY_READ)
37+
value, _ = winreg.QueryValueEx(key, "UseRichUI")
38+
winreg.CloseKey(key)
39+
return bool(value)
40+
except (FileNotFoundError, OSError):
41+
return True # Default to Rich UI
42+
43+
def _set_rich_ui_pref(enabled: bool):
44+
"""Save USE_RICH_UI to Windows registry."""
45+
try:
46+
key = winreg.CreateKey(winreg.HKEY_CURRENT_USER, r"Software\WinHTop")
47+
winreg.SetValueEx(key, "UseRichUI", 0, winreg.REG_DWORD, 1 if enabled else 0)
48+
winreg.CloseKey(key)
49+
except OSError:
50+
pass # Silently fail if can't write
51+
52+
# Load preference on import
53+
USE_RICH_UI = _get_rich_ui_pref()

modules/input.py

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import msvcrt
1010
import psutil
1111
from .state import state
12+
from . import config
1213
from .config import *
1314
from .processes import get_process_tree_info
1415
try:
@@ -111,7 +112,7 @@ def execute_command(cmd_str):
111112
return
112113

113114
if cmd == "help":
114-
state.status_message = "kill|suspend|resume|info, sort, filter, speed, showdrives, export, quit"
115+
state.status_message = "Commands: kill|suspend|resume|info, sort, filter, speed, showdrives, procfull, rich, export, quit"
115116
return
116117

117118
if cmd == "showdrives":
@@ -123,6 +124,26 @@ def execute_command(cmd_str):
123124
state.status_message = "Showing only C: drive"
124125
return
125126

127+
if cmd == "procfull":
128+
if arg.lower() == "true":
129+
state.procfull_mode = True
130+
elif arg.lower() == "false":
131+
state.procfull_mode = False
132+
else:
133+
state.procfull_mode = not state.procfull_mode
134+
state.scroll_offset = 0
135+
if state.procfull_mode:
136+
state.status_message = "Showing parent processes with aggregated children"
137+
else:
138+
state.status_message = "Showing all processes normally"
139+
return
140+
141+
if cmd == "rich":
142+
config.USE_RICH_UI = not config.USE_RICH_UI
143+
config._set_rich_ui_pref(config.USE_RICH_UI) # Persist to registry
144+
state.status_message = f"Rich UI: {'enabled' if config.USE_RICH_UI else 'disabled'} (takes effect on restart)"
145+
return
146+
126147
if cmd == "speed":
127148
speeds = [k for k in REFRESH_RATES.keys() if k != "party"]
128149
key = arg.lower()
@@ -267,10 +288,19 @@ def execute_command(cmd_str):
267288
state.status_message = f"{action_names[cmd]} {success}, Errors: {errors}"
268289

269290
def handle_input():
270-
"""Handle keyboard input (non-blocking)."""
291+
"""Handle keyboard input (non-blocking).
292+
293+
Arrow keys and special keys send a two-byte sequence:
294+
First byte: \x00 or \xe0 (prefix)
295+
Second byte: key code (e.g., 72=Up, 80=Down)
296+
297+
We must read the second byte immediately after detecting the prefix,
298+
not wait for it to appear in a later kbhit() check.
299+
"""
271300
while msvcrt.kbhit():
272301
ch = msvcrt.getwch()
273302

303+
# Handle confirmation prompts
274304
if state.pending_confirmation is not None:
275305
if ch.lower() == 'y':
276306
execute_pending_action()
@@ -282,27 +312,35 @@ def handle_input():
282312
state.input_buffer = ""
283313
return
284314
else:
315+
# Consume but ignore other keys during confirmation
285316
continue
286317

318+
# Handle special keys (arrow keys, function keys, etc.)
319+
# These come as two-character sequences: \x00 or \xe0 followed by the key code
287320
if ch in ('\x00', '\xe0'):
288-
if msvcrt.kbhit():
289-
key2 = msvcrt.getwch()
290-
if key2 == 'H':
291-
state.scroll_offset = max(0, state.scroll_offset - 1)
292-
elif key2 == 'P': # Down
293-
state.scroll_offset += 1
294-
elif key2 == 'I': # PgUp
295-
state.scroll_offset = max(0, state.scroll_offset - 10)
296-
elif key2 == 'Q': # PgDn
297-
state.scroll_offset += 10
298-
elif key2 == 'G': # Home
299-
state.scroll_offset = 0
300-
elif key2 == 'O': # End
301-
state.scroll_offset = max(0, len(state.processes) - 5)
302-
elif key2 == 'S': # Del
303-
state.input_buffer = state.input_buffer[:-1]
321+
# IMMEDIATELY read the second byte - don't wait for kbhit()
322+
# The second byte is always sent right after the prefix
323+
key2 = msvcrt.getwch()
324+
key_code = ord(key2)
325+
326+
if key_code == 72: # Up arrow
327+
state.scroll_offset = max(0, state.scroll_offset - 1)
328+
elif key_code == 80: # Down arrow
329+
state.scroll_offset += 1
330+
elif key_code == 73: # PgUp
331+
state.scroll_offset = max(0, state.scroll_offset - 10)
332+
elif key_code == 81: # PgDn
333+
state.scroll_offset += 10
334+
elif key_code == 71: # Home
335+
state.scroll_offset = 0
336+
elif key_code == 79: # End
337+
state.scroll_offset = max(0, len(state.processes) - 5)
338+
elif key_code == 83: # Del
339+
state.input_buffer = state.input_buffer[:-1]
340+
# All other special keys (F1-F12, Insert, etc.) are silently consumed
304341
continue
305342

343+
# Normal key handling
306344
if ch == '\r':
307345
execute_command(state.input_buffer)
308346
state.input_buffer = ""

modules/processes.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def get_processes():
6969

7070
pinfo = {
7171
'pid': pid,
72+
'ppid': p.get('ppid', 0), # For procfull aggregation
7273
'name': p['name'],
7374
'cpu_percent': p['cpu_percent'],
7475
'memory_percent': mem_pct,
@@ -78,6 +79,10 @@ def get_processes():
7879

7980
procs.append(pinfo)
8081

82+
# Aggregate children into parents if procfull_mode is enabled
83+
if state.procfull_mode:
84+
procs = aggregate_children(procs)
85+
8186
# Sort
8287
try:
8388
if state.sort_key == 'name':
@@ -87,6 +92,64 @@ def get_processes():
8792
except:
8893
state.processes = procs
8994

95+
96+
def aggregate_children(proc_list):
97+
"""When procfull_mode is True, aggregate DIRECT child stats into parent entries.
98+
99+
This groups processes by their parent, sums CPU/MEM of DIRECT children only
100+
into the parent, and marks parents with a child count indicator.
101+
102+
Note: Only direct children are aggregated, not all descendants recursively.
103+
This matches typical task manager behavior where e.g., chrome.exe shows
104+
its immediate child processes, not the entire tree.
105+
"""
106+
# Build a pid -> process info map
107+
pid_map = {p['pid']: p for p in proc_list}
108+
109+
# Build parent -> direct children map
110+
children_map = {} # parent_pid -> [child_pinfo, ...]
111+
child_pids = set() # Track which pids are children (to exclude from result)
112+
113+
for p in proc_list:
114+
ppid = p.get('ppid', 0)
115+
# Only consider as child if parent exists in our list and isn't self
116+
if ppid in pid_map and ppid != p['pid']:
117+
if ppid not in children_map:
118+
children_map[ppid] = []
119+
children_map[ppid].append(p)
120+
child_pids.add(p['pid'])
121+
122+
# Build result: only parent processes (those not appearing as children)
123+
# or processes with no parent in the list
124+
aggregated = []
125+
126+
for p in proc_list:
127+
pid = p['pid']
128+
129+
# Skip processes that are children of another process in the list
130+
if pid in child_pids:
131+
continue
132+
133+
# Get direct children of this process
134+
direct_children = children_map.get(pid, [])
135+
136+
if direct_children:
137+
# Aggregate direct children stats
138+
child_cpu = sum(c.get('cpu_percent', 0) for c in direct_children)
139+
child_mem = sum(c.get('memory_percent', 0) for c in direct_children)
140+
141+
agg_entry = p.copy()
142+
agg_entry['cpu_percent'] = p.get('cpu_percent', 0) + child_cpu
143+
agg_entry['memory_percent'] = p.get('memory_percent', 0) + child_mem
144+
agg_entry['_child_count'] = len(direct_children)
145+
aggregated.append(agg_entry)
146+
else:
147+
# No children, just add as-is
148+
aggregated.append(p)
149+
150+
return aggregated
151+
152+
90153
def get_process_tree_info(targets):
91154
"""Build process tree info showing parent-child relationships."""
92155
tree_lines = []

0 commit comments

Comments
 (0)