pmemstat 2.3.29__tar.gz → 3.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pmemstat-2.3.29/README.md → pmemstat-3.0.2/PKG-INFO +39 -6
- pmemstat-2.3.29/PKG-INFO → pmemstat-3.0.2/README.md +22 -22
- pmemstat-3.0.2/TODO.md +88 -0
- {pmemstat-2.3.29 → pmemstat-3.0.2}/pmemstat/main.py +57 -11
- {pmemstat-2.3.29 → pmemstat-3.0.2}/pyproject.toml +3 -2
- pmemstat-2.3.29/deploy +0 -8
- pmemstat-2.3.29/pmemstat/Window.py +0 -647
- {pmemstat-2.3.29 → pmemstat-3.0.2}/.gitignore +0 -0
- {pmemstat-2.3.29 → pmemstat-3.0.2}/LICENSE.txt +0 -0
- {pmemstat-2.3.29 → pmemstat-3.0.2}/images/help-screen.png +0 -0
- {pmemstat-2.3.29 → pmemstat-3.0.2}/images/pmemstat.png +0 -0
- {pmemstat-2.3.29 → pmemstat-3.0.2}/images/scroll-pos.png +0 -0
- {pmemstat-2.3.29 → pmemstat-3.0.2}/memtest.c +0 -0
- {pmemstat-2.3.29 → pmemstat-3.0.2}/pmemstat/CpuSmooth.py +0 -0
- {pmemstat-2.3.29 → pmemstat-3.0.2}/pmemstat/KillThem.py +0 -0
- {pmemstat-2.3.29 → pmemstat-3.0.2}/pmemstat/__init__.py +0 -0
- {pmemstat-2.3.29 → pmemstat-3.0.2}/run.sh +0 -0
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pmemstat
|
|
3
|
+
Version: 3.0.2
|
|
4
|
+
Summary: Report Proportional Memory on Linux
|
|
5
|
+
Keywords: proportional memory,RAM,htop,top,monitor
|
|
6
|
+
Author-email: Joe Defen <joedef@duck.com>
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
License-File: LICENSE.txt
|
|
13
|
+
Requires-Dist: psutil > 5.9
|
|
14
|
+
Requires-Dist: console-window >= 1.0.0
|
|
15
|
+
Project-URL: Bug Tracker, https://github.com/joedefen/pmemstat/issues
|
|
16
|
+
Project-URL: Homepage, https://github.com/joedefen/pmemstat
|
|
17
|
+
|
|
1
18
|
> **Quick Start**: from the CLI
|
|
2
19
|
> * **If `python3 -V` shows v3.11 or later, install with `pipx`**:
|
|
3
20
|
> * install `pipx` per your distro's guidance
|
|
@@ -88,7 +105,20 @@ Explanation of some options and arguments:
|
|
|
88
105
|
|
|
89
106
|
|
|
90
107
|
## Example Usage with Explanation of Output
|
|
91
|
-
|
|
108
|
+
```
|
|
109
|
+
20:49:12 Tot=7.6G Used=6.2G Avail=1.4G Oth=0 Sh+Tmp=477.7M PIDs=174
|
|
110
|
+
2.4%/ker MajF/s=2 zRAM=813.2M CR=4.3 eTot:16.8G eUsed:8.8G eAvail:8.0G
|
|
111
|
+
cpu_pct pswap other data ptotal key/info (exe by mem)
|
|
112
|
+
60.8 2,535 593 3,988 7,116 T 174x --TOTALS in MB --
|
|
113
|
+
───────────────────────────────────────────────────────────────────────────────
|
|
114
|
+
5.9 1,366 90 2,110 3,567 24x browser
|
|
115
|
+
16.6 89 117 822 1,028 9x code
|
|
116
|
+
5.9 270 32 291 593 1x firefox
|
|
117
|
+
1.6 95 68 335 497 6x exe
|
|
118
|
+
2.4 150 66 94 310 6x brave
|
|
119
|
+
16.0 52 73 152 277 3x VQ6B1EUZqoCU04zoRU
|
|
120
|
+
0.1 39 19 13 70 2x konsole
|
|
121
|
+
```
|
|
92
122
|
|
|
93
123
|
In the default refreshed window loop, we see
|
|
94
124
|
* a **leader line** with:
|
|
@@ -104,14 +134,17 @@ In the default refreshed window loop, we see
|
|
|
104
134
|
* how many PIDs are contributing to the report vs the total number of PIDs excluding kernel threads
|
|
105
135
|
* a **second leader line for zRAM** only if zRAM is active with:
|
|
106
136
|
* percent cpu consumed by kernel processes (not normalized ... if there 4 CPUs, then the total CPU can 400%). This is important because, with zRAM, this often is mostly the swap process.
|
|
137
|
+
* **MajF/s** - the number of "major" page faults per second.
|
|
138
|
+
* 100+/s is high/worrisome. Indicates significant memory pressure and heavy swapping or repeated large file access. System thrashing/slowdown is likely noticeable slow response.
|
|
139
|
+
* 1000+/s is extreme. Indicates severe memory overcommitment, likely with processes constantly fighting for RAM, or an application aggressively accessing disk-mapped memory.
|
|
107
140
|
* **zRAM** - the amount of "Used" RAM that is storing zRAM data
|
|
108
|
-
* **eTot** - the effective Total RAM
|
|
109
|
-
* **eUsed** - the effective Used RAM
|
|
110
|
-
* **eAvail** - the effective Available RAM
|
|
141
|
+
* **eTot** - the effective Total RAM ... the bigger **zRAM**, the more accurate this number
|
|
142
|
+
* **eUsed** - the effective Used RAM
|
|
143
|
+
* **eAvail** - the effective Available RAM
|
|
111
144
|
* a **header line with the reported fields** including:
|
|
112
145
|
* **cpu** - percent CPU (again not normalized to 100%)
|
|
113
146
|
* **pswap** - proportional use of swap (per smaps_rollup)
|
|
114
|
-
* **other** - partly sums these categories (shown by the key, `o`)
|
|
147
|
+
* **other** - partly sums these categories (shown by the key, `o`)
|
|
115
148
|
* **shSYSV** - proportional use of System V shared memory (per smaps)
|
|
116
149
|
* **shOth** - proportional use of other shared memory (per smaps)
|
|
117
150
|
* **stack** - exclusive use of stack memory per smaps
|
|
@@ -182,4 +215,4 @@ If the `pipx` install is not acceptable, choose the best way to install:
|
|
|
182
215
|
```
|
|
183
216
|
PIP_BREAK_SYSTEM_PACKAGES=1 sudo python -m pip install pmemstat
|
|
184
217
|
# to uninstall: sudo python -m pip uninstall pmemstat
|
|
185
|
-
```
|
|
218
|
+
```
|
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: pmemstat
|
|
3
|
-
Version: 2.3.29
|
|
4
|
-
Summary: Report Proportional Memory on Linux
|
|
5
|
-
Keywords: proportional memory,RAM,htop,top,monitor
|
|
6
|
-
Author-email: Joe Defen <joedef@duck.com>
|
|
7
|
-
Requires-Python: >=3.8
|
|
8
|
-
Description-Content-Type: text/markdown
|
|
9
|
-
Classifier: Programming Language :: Python :: 3
|
|
10
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
-
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
-
License-File: LICENSE.txt
|
|
13
|
-
Requires-Dist: psutil>5.9
|
|
14
|
-
Project-URL: Bug Tracker, https://github.com/joedefen/pmemstat/issues
|
|
15
|
-
Project-URL: Homepage, https://github.com/joedefen/pmemstat
|
|
16
|
-
|
|
17
1
|
> **Quick Start**: from the CLI
|
|
18
2
|
> * **If `python3 -V` shows v3.11 or later, install with `pipx`**:
|
|
19
3
|
> * install `pipx` per your distro's guidance
|
|
@@ -104,7 +88,20 @@ Explanation of some options and arguments:
|
|
|
104
88
|
|
|
105
89
|
|
|
106
90
|
## Example Usage with Explanation of Output
|
|
107
|
-
|
|
91
|
+
```
|
|
92
|
+
20:49:12 Tot=7.6G Used=6.2G Avail=1.4G Oth=0 Sh+Tmp=477.7M PIDs=174
|
|
93
|
+
2.4%/ker MajF/s=2 zRAM=813.2M CR=4.3 eTot:16.8G eUsed:8.8G eAvail:8.0G
|
|
94
|
+
cpu_pct pswap other data ptotal key/info (exe by mem)
|
|
95
|
+
60.8 2,535 593 3,988 7,116 T 174x --TOTALS in MB --
|
|
96
|
+
───────────────────────────────────────────────────────────────────────────────
|
|
97
|
+
5.9 1,366 90 2,110 3,567 24x browser
|
|
98
|
+
16.6 89 117 822 1,028 9x code
|
|
99
|
+
5.9 270 32 291 593 1x firefox
|
|
100
|
+
1.6 95 68 335 497 6x exe
|
|
101
|
+
2.4 150 66 94 310 6x brave
|
|
102
|
+
16.0 52 73 152 277 3x VQ6B1EUZqoCU04zoRU
|
|
103
|
+
0.1 39 19 13 70 2x konsole
|
|
104
|
+
```
|
|
108
105
|
|
|
109
106
|
In the default refreshed window loop, we see
|
|
110
107
|
* a **leader line** with:
|
|
@@ -120,14 +117,17 @@ In the default refreshed window loop, we see
|
|
|
120
117
|
* how many PIDs are contributing to the report vs the total number of PIDs excluding kernel threads
|
|
121
118
|
* a **second leader line for zRAM** only if zRAM is active with:
|
|
122
119
|
* percent cpu consumed by kernel processes (not normalized ... if there 4 CPUs, then the total CPU can 400%). This is important because, with zRAM, this often is mostly the swap process.
|
|
120
|
+
* **MajF/s** - the number of "major" page faults per second.
|
|
121
|
+
* 100+/s is high/worrisome. Indicates significant memory pressure and heavy swapping or repeated large file access. System thrashing/slowdown is likely noticeable slow response.
|
|
122
|
+
* 1000+/s is extreme. Indicates severe memory overcommitment, likely with processes constantly fighting for RAM, or an application aggressively accessing disk-mapped memory.
|
|
123
123
|
* **zRAM** - the amount of "Used" RAM that is storing zRAM data
|
|
124
|
-
* **eTot** - the effective Total RAM
|
|
125
|
-
* **eUsed** - the effective Used RAM
|
|
126
|
-
* **eAvail** - the effective Available RAM
|
|
124
|
+
* **eTot** - the effective Total RAM ... the bigger **zRAM**, the more accurate this number
|
|
125
|
+
* **eUsed** - the effective Used RAM
|
|
126
|
+
* **eAvail** - the effective Available RAM
|
|
127
127
|
* a **header line with the reported fields** including:
|
|
128
128
|
* **cpu** - percent CPU (again not normalized to 100%)
|
|
129
129
|
* **pswap** - proportional use of swap (per smaps_rollup)
|
|
130
|
-
* **other** - partly sums these categories (shown by the key, `o`)
|
|
130
|
+
* **other** - partly sums these categories (shown by the key, `o`)
|
|
131
131
|
* **shSYSV** - proportional use of System V shared memory (per smaps)
|
|
132
132
|
* **shOth** - proportional use of other shared memory (per smaps)
|
|
133
133
|
* **stack** - exclusive use of stack memory per smaps
|
|
@@ -198,4 +198,4 @@ If the `pipx` install is not acceptable, choose the best way to install:
|
|
|
198
198
|
```
|
|
199
199
|
PIP_BREAK_SYSTEM_PACKAGES=1 sudo python -m pip install pmemstat
|
|
200
200
|
# to uninstall: sudo python -m pip uninstall pmemstat
|
|
201
|
-
```
|
|
201
|
+
```
|
pmemstat-3.0.2/TODO.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Summary of TODOs:
|
|
2
|
+
* Drop the percents from the zRAM line
|
|
3
|
+
* Add compression ratio (CR)
|
|
4
|
+
* Add Major page faults /s (MajF:200/s)
|
|
5
|
+
* Document the new features (some insight below)
|
|
6
|
+
|
|
7
|
+
----
|
|
8
|
+
|
|
9
|
+
That's an insightful follow-up. You are absolutely correct to be cautious about adding a metric that is difficult to interpret, as it can confuse the user rather than help.
|
|
10
|
+
|
|
11
|
+
1. Ease of Getting Page Faults
|
|
12
|
+
|
|
13
|
+
Yes, page fault counts are relatively easy to get in Linux. They are tracked by the kernel.
|
|
14
|
+
|
|
15
|
+
The most relevant location for a tool like memstat (which is likely capturing data periodically) is often the /proc/vmstat file, which contains system-wide metrics like pgfault (total), pgmajfault (major), and pgminfault (minor).
|
|
16
|
+
|
|
17
|
+
Minor Faults (pgminfault): These are extremely common and usually cheap. They occur when a page is accessed for the first time or is shared/already in RAM but needs its entry updated in the process's page table. They are fast and generally not a primary concern for performance.
|
|
18
|
+
|
|
19
|
+
Major Faults (pgmajfault): These are the ones you should focus on. A major fault happens when the requested page is not in physical RAM and must be read from a backing store, like the disk (or a swap device, which, in your case, is the zRAM device). These incur significant latency due to I/O and are a strong indicator of memory pressure and system slowdown.
|
|
20
|
+
|
|
21
|
+
Recommendation: If you add page faults, you should only report the Major Faults per second (MajFaults/s). This is the key metric that directly correlates with the I/O cost you want to diagnose alongside zRAM activity.
|
|
22
|
+
|
|
23
|
+
2. Guidance: When is "Bigger" Bad?
|
|
24
|
+
|
|
25
|
+
You're looking for absolute thresholds, and while there's no single universally accepted number (it depends on the speed of your I/O and CPU), here is the common guidance for Major Page Faults per second:
|
|
26
|
+
Major Faults/s Range Interpretation System Impact
|
|
27
|
+
* 0 - ~10 per second Normal/Low Expected during application startup or initial file access. No performance concern.
|
|
28
|
+
* ~10 - ~100 per second Moderate Suggests consistent file access or some mild memory pressure. Could cause minor, noticeable latency on disk-bound systems.
|
|
29
|
+
* 100+ per second High/Worry Indicates significant memory pressure and heavy swapping or repeated large file access. This is where system thrashing/slowdown is noticeable. On a busy desktop, a sustained rate here means the user is likely complaining about slow response.
|
|
30
|
+
* 1000+ per second Extreme Indicates severe memory overcommitment, likely with processes constantly fighting for RAM, or an application aggressively accessing disk-mapped memory.
|
|
31
|
+
|
|
32
|
+
Guidance: For your tool, the goal is to show the user they are experiencing memory thrashing (when the system is spending more time swapping than doing work). A sustained MajFaults/s of over 100 is a strong indication of this, especially if it coincides with high kernel CPU usage from zRAM compression/decompression.
|
|
33
|
+
|
|
34
|
+
Final Revised Snippet Example
|
|
35
|
+
|
|
36
|
+
Focusing on the critical Major Page Faults and the simplified CR:
|
|
37
|
+
```
|
|
38
|
+
15:02:03 Tot=7.6G Used=6.2G Avail=1.4G Oth=0 Sh+Tmp=983.8M PIDs=166
|
|
39
|
+
1.9/ker zRAM=612.6M CR:2.7 MajF:12/s eTot:16.5G eUsed:7.9G eAvail:8.6G
|
|
40
|
+
cpu_pct pswap other data ptotal key/info (exe by mem)
|
|
41
|
+
27.1 1,892 680 3,637 6,210 T 166x --TOTALS in MB --
|
|
42
|
+
────────────────────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
... process list ...
|
|
44
|
+
```
|
|
45
|
+
This line is powerful: it shows zRAM is working (CR:2.7), but also shows the direct performance cost (MajF:12/s).
|
|
46
|
+
|
|
47
|
+
Understanding how to measure and interpret page faults is key to diagnosing memory-related performance issues.
|
|
48
|
+
|
|
49
|
+
If you are interested in a deeper technical dive into the Linux kernel's page fault mechanism, you can check out Faster & Fewer Page Faults: Kernel Recipes 2023.
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
Your analysis of the process rollup is spot on. For modern applications like browsers (Chrome, Firefox, Edge, etc.), IDEs (VS Code), and even certain large applications (like Electron or Java apps), reporting memory usage per individual PID is misleading. The user wants to know, "How much memory is **Firefox** using?" not "How much memory are its 25 sub-processes using?"
|
|
56
|
+
|
|
57
|
+
### Advertising Strategy: The Two Pillars
|
|
58
|
+
|
|
59
|
+
When you draft your announcement, you should clearly highlight these two pillars of `memstat`'s superior diagnostic capability:
|
|
60
|
+
|
|
61
|
+
1. **The Process Problem Solved (Rollup/Grouping):**
|
|
62
|
+
|
|
63
|
+
* **The Problem:** Standard tools (`top`, `ps`) make modern apps (browsers, IDEs, etc.) look innocent by splitting their memory across dozens of processes.
|
|
64
|
+
* **The `memstat` Solution:** `memstat` automatically aggregates related processes (based on the executable name, command line, or other heuristics) and presents a single, honest line item for the **total memory, swap, and CPU** used by the entire application group. This instantly reveals the true memory hogs.
|
|
65
|
+
* *Example to highlight:* "See the true cost of your 50 open browser tabs with a single, clear line."
|
|
66
|
+
|
|
67
|
+
2. **The Memory Ceiling Problem Solved (zRAM/Effective Memory):**
|
|
68
|
+
|
|
69
|
+
* **The Problem:** When zRAM is active, system monitoring tools report only physical RAM usage, failing to show the massive, hidden memory capacity provided by compression.
|
|
70
|
+
* **The `memstat` Solution:** The new output line displays the **Estimated Total (`eTot`), Used (`eUsed`), and Available (`eAvail`)** memory by applying the real-time **Compression Ratio (`CR:X.X`)** of your zRAM swap device. You instantly know your system's *effective* memory ceiling and available headroom.
|
|
71
|
+
* *Example to highlight:* "Don't trust `free`\! If you run zRAM, `memstat` is the only tool that shows you your **true $16.5\text{G}$ effective RAM** and how much is left."
|
|
72
|
+
|
|
73
|
+
### Revised Snippet (The Final Polish)
|
|
74
|
+
|
|
75
|
+
This combines all the best suggestions into a powerful, concise output:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
15:02:03 Tot=7.6G Used=6.2G Avail=1.4G Oth=0 Sh+Tmp=983.8M PIDs=166
|
|
79
|
+
1.9/ker zRAM=612.6M CR:2.7 MajF:12/s eTot:16.5G eUsed:7.9G eAvail:8.6G
|
|
80
|
+
cpu_pct pswap other data ptotal key/info (exe by mem)
|
|
81
|
+
27.1 1,892 680 3,637 6,210 T 166x --TOTALS in MB --
|
|
82
|
+
────────────────────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
1.0 967 90 2,244 3,300 21x browser <--- Process Rollup Highlight!
|
|
84
|
+
2.1 220 45 341 605 1x firefox
|
|
85
|
+
...
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This output is information-dense, highly diagnostic, and showcases two unique, valuable features that directly address major pain points in Linux performance monitoring. Good luck with the release\!
|
|
@@ -50,7 +50,7 @@ import curses
|
|
|
50
50
|
from types import SimpleNamespace
|
|
51
51
|
from io import StringIO
|
|
52
52
|
from datetime import datetime, timedelta
|
|
53
|
-
from
|
|
53
|
+
from console_window import ConsoleWindow, OptionSpinner
|
|
54
54
|
from pmemstat.KillThem import KillThem
|
|
55
55
|
from pmemstat.CpuSmooth import CpuSmooth, SysStat
|
|
56
56
|
|
|
@@ -105,14 +105,15 @@ class ZramProjector:
|
|
|
105
105
|
self.e_avail = 0
|
|
106
106
|
self.e_used = 0
|
|
107
107
|
self.e_max_used = 0
|
|
108
|
+
self.ratio = 0.0
|
|
108
109
|
self.limit_pct = 80
|
|
109
110
|
self.devs = {}
|
|
110
111
|
self.DB = False
|
|
111
112
|
|
|
112
|
-
def human_pct(self, number):
|
|
113
|
+
def human_pct(self, number, with_pct=False):
|
|
113
114
|
""" Return the number in human form and as pct total memory. """
|
|
114
115
|
rv = human(number)
|
|
115
|
-
if number > 0 and self.meminfo.MemTotal > 0:
|
|
116
|
+
if with_pct and number > 0 and self.meminfo.MemTotal > 0:
|
|
116
117
|
fraction = number/self.meminfo.MemTotal
|
|
117
118
|
pct = int(round(100*fraction))
|
|
118
119
|
rv += f'/{pct}%'
|
|
@@ -187,10 +188,10 @@ class ZramProjector:
|
|
|
187
188
|
self.meminfo.MemZram = stats.mem_used_total
|
|
188
189
|
|
|
189
190
|
e_used = meminfo.MemUsed - stats.mem_used_total + stats.orig_data_size
|
|
190
|
-
ratio = None
|
|
191
|
+
ratio, self.ratio = None, 0.0
|
|
191
192
|
if e_used <= self.e_used: # zram hurts. assume not enuf data to project
|
|
192
193
|
e_used = self.e_used
|
|
193
|
-
ratio = 3.5 # wild guess for projection
|
|
194
|
+
self.ratio = ratio = 3.5 # wild guess for projection
|
|
194
195
|
# if self.DB: dump_effective(ns, '(unchg)')
|
|
195
196
|
# return ns
|
|
196
197
|
self.e_used = e_used
|
|
@@ -200,7 +201,7 @@ class ZramProjector:
|
|
|
200
201
|
limit_pct = min(1.0, limit_pct, stats_limit_pct)
|
|
201
202
|
# projected amount of orig used memory that fits in limit
|
|
202
203
|
if ratio is None: # hopefully about 4 ;-)
|
|
203
|
-
ratio = stats.orig_data_size / stats.mem_used_total
|
|
204
|
+
self.ratio = ratio = stats.orig_data_size / stats.mem_used_total
|
|
204
205
|
e_max_used = ratio * meminfo.MemTotal * limit_pct/100
|
|
205
206
|
# now limit by disksize
|
|
206
207
|
e_max_used = min(e_max_used, stats.disksize)
|
|
@@ -594,6 +595,7 @@ class PmemStat:
|
|
|
594
595
|
self.kernel_prcs = []
|
|
595
596
|
self.groups = {} # indexed by group key (e.g., cmd)
|
|
596
597
|
self.window = None
|
|
598
|
+
self.vmstat = None
|
|
597
599
|
self.spin = OptionSpinner()
|
|
598
600
|
self.number = 0 # line number for opts.numbers
|
|
599
601
|
self.units, self.divisor, self.fwidth = 0, 0, 0
|
|
@@ -844,6 +846,45 @@ class PmemStat:
|
|
|
844
846
|
assert not keys, f'ALERT: cannot get vitals ({keys}) from {meminfofile}'
|
|
845
847
|
return meminfoKB
|
|
846
848
|
|
|
849
|
+
def get_vmstat(self):
|
|
850
|
+
def make_ns(now):
|
|
851
|
+
ns = SimpleNamespace()
|
|
852
|
+
ns.base_time = now
|
|
853
|
+
ns.base_value = 0
|
|
854
|
+
ns.last_time = now
|
|
855
|
+
ns.last_value = 0
|
|
856
|
+
ns.rate = 0
|
|
857
|
+
return ns
|
|
858
|
+
|
|
859
|
+
"""Get most vital stats from /proc/vmstat'"""
|
|
860
|
+
infofile = '/proc/vmstat'
|
|
861
|
+
now = time.monotonic()
|
|
862
|
+
if not self.vmstat:
|
|
863
|
+
self.vmstat = {'pgmajfault': make_ns(now)}
|
|
864
|
+
info = self.vmstat
|
|
865
|
+
keys = list(info.keys())
|
|
866
|
+
|
|
867
|
+
with open(infofile, encoding='utf-8') as fileh:
|
|
868
|
+
for line in fileh:
|
|
869
|
+
match = re.match(r'^([^\s]+)\s+(\d+)$', line)
|
|
870
|
+
if not match:
|
|
871
|
+
continue
|
|
872
|
+
key, value = match.group(1), int(match.group(2))
|
|
873
|
+
if key not in keys:
|
|
874
|
+
continue
|
|
875
|
+
ns = info[key]
|
|
876
|
+
ns.base_time, ns.base_value = ns.last_time, ns.last_value
|
|
877
|
+
ns.last_time, ns.last_value = now, value
|
|
878
|
+
ns.rate = 0
|
|
879
|
+
if ns.last_time > ns.base_time:
|
|
880
|
+
ns.rate = int(round((ns.last_value - ns.base_value)
|
|
881
|
+
/ (ns.last_time - ns.base_time)))
|
|
882
|
+
keys.remove(key)
|
|
883
|
+
if not keys:
|
|
884
|
+
break
|
|
885
|
+
assert not keys, f'ALERT: cannot get vitals ({keys}) from {infofile}'
|
|
886
|
+
return self.vmstat
|
|
887
|
+
|
|
847
888
|
def loop(self, now, is_first, regroup=False):
|
|
848
889
|
"""one loop thru all pids"""
|
|
849
890
|
# pylint: disable=too-many-branches
|
|
@@ -853,6 +894,7 @@ class PmemStat:
|
|
|
853
894
|
|
|
854
895
|
def pr_top_of_report(appKB):
|
|
855
896
|
nonlocal self, meminfoKB, wanted_prcs, total_user_pids, kernel_cpu
|
|
897
|
+
nonlocal major_fault_rate
|
|
856
898
|
windowed = bool(self.window)
|
|
857
899
|
resume = False
|
|
858
900
|
# print timestamp of report
|
|
@@ -889,13 +931,15 @@ class PmemStat:
|
|
|
889
931
|
resume = False
|
|
890
932
|
proj = self.zram_projector
|
|
891
933
|
if self.opts.cpu:
|
|
892
|
-
leader = f'{kernel_cpu:8.1f}
|
|
934
|
+
leader = f'{kernel_cpu:8.1f}%/ker '
|
|
893
935
|
self.emit(leader, to_head=True, resume=resume)
|
|
894
936
|
resume = True
|
|
937
|
+
self.emit(f'MajF/s={major_fault_rate} ', to_head=True, resume=resume)
|
|
938
|
+
resume = True
|
|
895
939
|
self.emit(f' zRAM={human(self.zram_projector.meminfo.MemZram)}',
|
|
896
940
|
to_head=True, attr=curses.A_BOLD, resume=resume)
|
|
897
|
-
resume = True
|
|
898
941
|
leader = ''
|
|
942
|
+
leader += f' CR={proj.ratio:.1f}'
|
|
899
943
|
leader += f' eTot:{proj.human_pct(proj.e_max_used)}'
|
|
900
944
|
leader += f' eUsed:{proj.human_pct(proj.e_used)}'
|
|
901
945
|
leader += f' eAvail:{proj.human_pct(proj.e_avail)}'
|
|
@@ -903,7 +947,7 @@ class PmemStat:
|
|
|
903
947
|
self.emit(leader, to_head=True, resume=resume)
|
|
904
948
|
elif self.opts.cpu: # second line if reporting cpu
|
|
905
949
|
resume = False
|
|
906
|
-
leader = f'{kernel_cpu:8.1f}
|
|
950
|
+
leader = f'{kernel_cpu:8.1f}%/ker'
|
|
907
951
|
sort_kernel_prcs()
|
|
908
952
|
for prc in self.kernel_prcs[0:2]:
|
|
909
953
|
nickname = prc.cpu.get_nickname()
|
|
@@ -913,6 +957,8 @@ class PmemStat:
|
|
|
913
957
|
# pylint: disable=too-many-branches
|
|
914
958
|
self.loop_num += 1
|
|
915
959
|
meminfoKB = self.get_meminfo()
|
|
960
|
+
vmstat = self.get_vmstat()
|
|
961
|
+
major_fault_rate = vmstat['pgmajfault'].rate
|
|
916
962
|
self.zram_projector.compute_effective(meminfoKB)
|
|
917
963
|
total_user_pids = 0
|
|
918
964
|
total_kernel_pids = 0
|
|
@@ -1154,7 +1200,7 @@ class PmemStat:
|
|
|
1154
1200
|
prompt='Set search string, then Enter', obj=self.opts)
|
|
1155
1201
|
|
|
1156
1202
|
keys_we_handle = [ord('K'), curses.KEY_ENTER, 10] + list(self.spin.keys)
|
|
1157
|
-
self.window =
|
|
1203
|
+
self.window = ConsoleWindow(head_line=True, keys=keys_we_handle)
|
|
1158
1204
|
is_first = True
|
|
1159
1205
|
was_groupby, regroup = self.opts.groupby, True
|
|
1160
1206
|
was_others = self.opts.others
|
|
@@ -1285,7 +1331,7 @@ def run():
|
|
|
1285
1331
|
except KeyboardInterrupt:
|
|
1286
1332
|
pass
|
|
1287
1333
|
except Exception as exce:
|
|
1288
|
-
|
|
1334
|
+
ConsoleWindow.stop_curses()
|
|
1289
1335
|
print("exception:", str(exce))
|
|
1290
1336
|
print(traceback.format_exc())
|
|
1291
1337
|
|
|
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pmemstat"
|
|
7
|
-
version = "
|
|
7
|
+
version = "3.0.2"
|
|
8
8
|
description = "Report Proportional Memory on Linux"
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "Joe Defen", email = "joedef@duck.com"},
|
|
@@ -19,7 +19,8 @@ classifiers = [
|
|
|
19
19
|
"Operating System :: POSIX :: Linux"
|
|
20
20
|
]
|
|
21
21
|
dependencies = [
|
|
22
|
-
'psutil>5.9',
|
|
22
|
+
'psutil > 5.9',
|
|
23
|
+
'console-window >= 1.0.0',
|
|
23
24
|
]
|
|
24
25
|
|
|
25
26
|
[project.urls]
|
pmemstat-2.3.29/deploy
DELETED
|
@@ -1,647 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
"""
|
|
4
|
-
Custom Wrapper for python curses.
|
|
5
|
-
"""
|
|
6
|
-
# pylint: disable=too-many-instance-attributes,too-many-arguments
|
|
7
|
-
# pylint: disable=invalid-name,broad-except,too-many-branches
|
|
8
|
-
|
|
9
|
-
import traceback
|
|
10
|
-
import atexit
|
|
11
|
-
import time
|
|
12
|
-
import curses
|
|
13
|
-
import textwrap
|
|
14
|
-
from types import SimpleNamespace
|
|
15
|
-
from curses.textpad import rectangle, Textbox
|
|
16
|
-
dump_str = None
|
|
17
|
-
|
|
18
|
-
class OptionSpinner:
|
|
19
|
-
"""Manage a bunch of options where the value is rotate thru
|
|
20
|
-
a fixed set of values pressing a key."""
|
|
21
|
-
def __init__(self):
|
|
22
|
-
"""Give the object with the attribute to change its
|
|
23
|
-
value (e.g., options from argparse or "self" from
|
|
24
|
-
the object managing the window).
|
|
25
|
-
|
|
26
|
-
And array of specs like:
|
|
27
|
-
['a - allow auto suggestions', 'allow_auto', True, False],
|
|
28
|
-
['/ - filter pattern', 'filter_str', self.filter_str],
|
|
29
|
-
A spec can have a trailing None + more comments shown after
|
|
30
|
-
the value.
|
|
31
|
-
"""
|
|
32
|
-
self.options, self.keys = [], []
|
|
33
|
-
self.margin = 4 # + actual width (1st column right pos)
|
|
34
|
-
self.align = self.margin # + actual width (1st column right pos)
|
|
35
|
-
self.default_obj = SimpleNamespace() # if not given one
|
|
36
|
-
self.attr_to_option = {} # given an attribute, find its option ns
|
|
37
|
-
self.key_to_option = {} # given key, options namespace
|
|
38
|
-
self.keys = set()
|
|
39
|
-
|
|
40
|
-
@staticmethod
|
|
41
|
-
def _make_option_ns():
|
|
42
|
-
return SimpleNamespace(
|
|
43
|
-
keys=[],
|
|
44
|
-
descr='',
|
|
45
|
-
obj=None,
|
|
46
|
-
attr='',
|
|
47
|
-
vals=None,
|
|
48
|
-
prompt=None,
|
|
49
|
-
comments=[],
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
def get_value(self, attr, coerce=False):
|
|
53
|
-
"""Get the value of the given attribute."""
|
|
54
|
-
ns = self.attr_to_option.get(attr, None)
|
|
55
|
-
obj = ns.obj if ns else None
|
|
56
|
-
value = getattr(obj, attr, None) if obj else None
|
|
57
|
-
if value is None and obj and coerce:
|
|
58
|
-
if ns.vals:
|
|
59
|
-
if value not in ns.vals:
|
|
60
|
-
value = ns.vals[0]
|
|
61
|
-
setattr(obj, attr, value)
|
|
62
|
-
else:
|
|
63
|
-
if value is None:
|
|
64
|
-
value = ''
|
|
65
|
-
setattr(ns.obj, ns.attr, '')
|
|
66
|
-
return value
|
|
67
|
-
|
|
68
|
-
def _register(self, ns):
|
|
69
|
-
""" Create the mappings needed"""
|
|
70
|
-
assert ns.attr not in self.attr_to_option
|
|
71
|
-
self.attr_to_option[ns.attr] = ns
|
|
72
|
-
for key in ns.keys:
|
|
73
|
-
assert key not in self.key_to_option, f'key ({chr(key)}, {key}) already used'
|
|
74
|
-
self.key_to_option[key] = ns
|
|
75
|
-
self.keys.add(key)
|
|
76
|
-
self.options.append(ns)
|
|
77
|
-
self.align = max(self.align, self.margin+len(ns.descr))
|
|
78
|
-
self.get_value(ns.attr, coerce=True)
|
|
79
|
-
|
|
80
|
-
def add(self, obj, specs):
|
|
81
|
-
""" Compatibility Method."""
|
|
82
|
-
for spec in specs:
|
|
83
|
-
ns = self._make_option_ns()
|
|
84
|
-
ns.descr = spec[0]
|
|
85
|
-
ns.obj = obj
|
|
86
|
-
ns.attr = spec[1]
|
|
87
|
-
ns.vals=spec[2:]
|
|
88
|
-
if None in ns.vals:
|
|
89
|
-
idx = ns.vals.index(None)
|
|
90
|
-
ns.vals = ns.vals[:idx]
|
|
91
|
-
ns.comments = ns.vals[idx+1:]
|
|
92
|
-
ns.keys = [ord(ns.descr[0])]
|
|
93
|
-
self._register(ns)
|
|
94
|
-
|
|
95
|
-
def add_key(self, attr, descr, obj=None, vals=None, prompt=None, keys=None, comments=None):
|
|
96
|
-
""" Standard method"""
|
|
97
|
-
ns = self._make_option_ns()
|
|
98
|
-
if keys:
|
|
99
|
-
ns.keys = list(keys) if isinstance(keys, (list, tuple, set)) else [keys]
|
|
100
|
-
else:
|
|
101
|
-
ns.keys = [ord(descr[0])]
|
|
102
|
-
if comments is None:
|
|
103
|
-
ns.comments = []
|
|
104
|
-
else:
|
|
105
|
-
ns.comments = list(comments) if isinstance(keys, (list, tuple)) else [comments]
|
|
106
|
-
ns.descr = descr
|
|
107
|
-
ns.attr = attr
|
|
108
|
-
ns.obj = obj if obj else self.default_obj
|
|
109
|
-
ns.vals, ns.prompt = vals, prompt
|
|
110
|
-
assert bool(ns.vals) ^ bool(ns.prompt)
|
|
111
|
-
self._register(ns)
|
|
112
|
-
|
|
113
|
-
def show_help_nav_keys(self, win):
|
|
114
|
-
""" Get/present standard verbiage for the navigation keys"""
|
|
115
|
-
for line in Window.get_nav_keys_blurb().splitlines():
|
|
116
|
-
if line:
|
|
117
|
-
win.add_header(line)
|
|
118
|
-
|
|
119
|
-
def show_help_body(self, win):
|
|
120
|
-
""" Write the help page section."""
|
|
121
|
-
win.add_body('Type keys to alter choice:', curses.A_UNDERLINE)
|
|
122
|
-
|
|
123
|
-
for ns in self.options:
|
|
124
|
-
# get / coerce the current value
|
|
125
|
-
value = self.get_value(ns.attr)
|
|
126
|
-
assert value is not None, f'cannot get value of {repr(ns.attr)}'
|
|
127
|
-
choices = ns.vals if ns.vals else [value]
|
|
128
|
-
|
|
129
|
-
win.add_body(f'{ns.descr:>{self.align}}: ')
|
|
130
|
-
|
|
131
|
-
for choice in choices:
|
|
132
|
-
shown = f'{choice}'
|
|
133
|
-
if isinstance(choice, bool):
|
|
134
|
-
shown = "ON" if choice else "off"
|
|
135
|
-
win.add_body(' ', resume=True)
|
|
136
|
-
win.add_body(shown, resume=True,
|
|
137
|
-
attr=curses.A_REVERSE if choice == value else None)
|
|
138
|
-
|
|
139
|
-
for comment in ns.comments:
|
|
140
|
-
win.add_body(f'{"":>{self.align}}: {comment}')
|
|
141
|
-
|
|
142
|
-
def do_key(self, key, win):
|
|
143
|
-
"""Do the automated processing of a key."""
|
|
144
|
-
ns = self.key_to_option.get(key, None)
|
|
145
|
-
if ns is None:
|
|
146
|
-
return None
|
|
147
|
-
value = self.get_value(ns.attr)
|
|
148
|
-
if ns.vals:
|
|
149
|
-
idx = ns.vals.index(value) if value in ns.vals else -1
|
|
150
|
-
value = ns.vals[(idx+1) % len(ns.vals)] # choose next
|
|
151
|
-
else:
|
|
152
|
-
value = win.answer(prompt=ns.prompt, seed=value)
|
|
153
|
-
setattr(ns.obj, ns.attr, value)
|
|
154
|
-
return value
|
|
155
|
-
|
|
156
|
-
class Window:
|
|
157
|
-
""" Layer above curses to encapsulate what we need """
|
|
158
|
-
timeout_ms = 200
|
|
159
|
-
static_scr = None
|
|
160
|
-
nav_keys = """
|
|
161
|
-
Navigation: H/M/L: top/middle/end-of-page
|
|
162
|
-
k, UP: up one row 0, HOME: first row
|
|
163
|
-
j, DOWN: down one row $, END: last row
|
|
164
|
-
Ctrl-u: half-page up Ctrl-b, PPAGE: page up
|
|
165
|
-
Ctrl-d: half-page down Ctrl-f, NPAGE: page down
|
|
166
|
-
"""
|
|
167
|
-
def __init__(self, head_line=True, head_rows=50, body_rows=200,
|
|
168
|
-
body_cols=200, keys=None, pick_mode=False, pick_size=1):
|
|
169
|
-
self.scr = self._start_curses()
|
|
170
|
-
|
|
171
|
-
self.head = SimpleNamespace(
|
|
172
|
-
pad=curses.newpad(head_rows, body_cols),
|
|
173
|
-
rows=head_rows,
|
|
174
|
-
cols=body_cols,
|
|
175
|
-
row_cnt=0, # no. head rows added
|
|
176
|
-
texts = [],
|
|
177
|
-
view_cnt=0, # no. head rows viewable (NOT in body)
|
|
178
|
-
)
|
|
179
|
-
self.body = SimpleNamespace(
|
|
180
|
-
pad = curses.newpad(body_rows, body_cols),
|
|
181
|
-
rows= body_rows,
|
|
182
|
-
cols=body_cols,
|
|
183
|
-
row_cnt = 0,
|
|
184
|
-
texts = []
|
|
185
|
-
)
|
|
186
|
-
self.hor_line_cnt = 1 if head_line else 0 # no. h-lines in header
|
|
187
|
-
self.scroll_pos = 0 # how far down into body are we?
|
|
188
|
-
self.max_scroll_pos = 0
|
|
189
|
-
self.pick_pos = 0 # in highlight mode, where are we?
|
|
190
|
-
self.last_pick_pos = -1 # last highlighted position
|
|
191
|
-
self.pick_mode = pick_mode # whether in highlight mode
|
|
192
|
-
self.pick_size = pick_size # whether in highlight mode
|
|
193
|
-
self.rows, self.cols = 0, 0
|
|
194
|
-
self.scroll_view_size = 0 # no. viewable lines of the body
|
|
195
|
-
self.handled_keys = set(keys) if isinstance(keys, (set, list)) else []
|
|
196
|
-
self._set_screen_dims()
|
|
197
|
-
self.calc()
|
|
198
|
-
|
|
199
|
-
@staticmethod
|
|
200
|
-
def get_nav_keys_blurb():
|
|
201
|
-
"""For a help screen, describe the nav keys"""
|
|
202
|
-
return textwrap.dedent(Window.nav_keys)
|
|
203
|
-
|
|
204
|
-
def _set_screen_dims(self):
|
|
205
|
-
"""Recalculate dimensions ... return True if geometry changed."""
|
|
206
|
-
rows, cols = self.scr.getmaxyx()
|
|
207
|
-
same = bool(rows == self.rows and cols == self.cols)
|
|
208
|
-
self.rows, self.cols = rows, cols
|
|
209
|
-
return same
|
|
210
|
-
|
|
211
|
-
@staticmethod
|
|
212
|
-
def _start_curses():
|
|
213
|
-
""" Curses initial setup. Note: not using curses.wrapper because we
|
|
214
|
-
don't wish to change the colors. """
|
|
215
|
-
atexit.register(Window.stop_curses)
|
|
216
|
-
Window.static_scr = scr = curses.initscr()
|
|
217
|
-
curses.noecho()
|
|
218
|
-
curses.cbreak()
|
|
219
|
-
curses.curs_set(0)
|
|
220
|
-
scr.keypad(1)
|
|
221
|
-
scr.timeout(Window.timeout_ms)
|
|
222
|
-
scr.clear()
|
|
223
|
-
return scr
|
|
224
|
-
|
|
225
|
-
def set_pick_mode(self, on=True, pick_size=1):
|
|
226
|
-
"""Set whether in highlight mode."""
|
|
227
|
-
was_on, was_size = self.pick_mode, self.pick_size
|
|
228
|
-
self.pick_mode = bool(on)
|
|
229
|
-
self.pick_size = max(pick_size, 1)
|
|
230
|
-
if self.pick_mode and (not was_on or was_size != self.pick_size):
|
|
231
|
-
self.last_pick_pos = -2 # indicates need to clear them all
|
|
232
|
-
|
|
233
|
-
@staticmethod
|
|
234
|
-
def stop_curses():
|
|
235
|
-
""" Curses shutdown (registered to be called on exit). """
|
|
236
|
-
if Window.static_scr:
|
|
237
|
-
curses.nocbreak()
|
|
238
|
-
curses.echo()
|
|
239
|
-
Window.static_scr.keypad(0)
|
|
240
|
-
curses.endwin()
|
|
241
|
-
Window.static_scr = None
|
|
242
|
-
|
|
243
|
-
def calc(self):
|
|
244
|
-
"""Recalculate dimensions ... return True if geometry changed."""
|
|
245
|
-
same = self._set_screen_dims()
|
|
246
|
-
self.head.view_cnt = min(self.rows - self.hor_line_cnt, self.head.row_cnt)
|
|
247
|
-
self.scroll_view_size = self.rows - self.head.view_cnt - self.hor_line_cnt
|
|
248
|
-
self.max_scroll_pos = max(self.body.row_cnt - self.scroll_view_size, 0)
|
|
249
|
-
self.body_base = self.head.view_cnt + self.hor_line_cnt
|
|
250
|
-
return not same
|
|
251
|
-
|
|
252
|
-
def _add(self, ns, text, attr=None, resume=False):
|
|
253
|
-
""" Add text to head/body pad using its namespace"""
|
|
254
|
-
is_body = bool(id(ns) == id(self.body))
|
|
255
|
-
if ns.row_cnt < ns.rows:
|
|
256
|
-
row = max(ns.row_cnt - (1 if resume else 0), 0)
|
|
257
|
-
if (is_body and self.pick_mode) or attr is None:
|
|
258
|
-
attr = curses.A_NORMAL
|
|
259
|
-
if resume:
|
|
260
|
-
ns.pad.addstr(text, attr)
|
|
261
|
-
ns.texts[row] += text
|
|
262
|
-
else:
|
|
263
|
-
ns.pad.addstr(row, 0, text, attr)
|
|
264
|
-
ns.texts.append(text) # text only history
|
|
265
|
-
ns.row_cnt += 1
|
|
266
|
-
|
|
267
|
-
def add_header(self, text, attr=None, resume=False):
|
|
268
|
-
"""Add text to header"""
|
|
269
|
-
self._add(self.head, text, attr, resume)
|
|
270
|
-
|
|
271
|
-
def add_body(self, text, attr=None, resume=False):
|
|
272
|
-
""" Add text to body (below header and header line)"""
|
|
273
|
-
self._add(self.body, text, attr, resume)
|
|
274
|
-
|
|
275
|
-
def draw(self, y, x, text, text_attr=None, width=None, leftpad=False, header=False):
|
|
276
|
-
"""Draws the given text (as utf-8 or unicode) at position (row=y,col=x)
|
|
277
|
-
with optional text attributes and width.
|
|
278
|
-
This is more compatible with my older, simpler Window class.
|
|
279
|
-
"""
|
|
280
|
-
ns = self.head if header else self.body
|
|
281
|
-
text_attr = text_attr if text_attr else curses.A_NORMAL
|
|
282
|
-
if y < 0 or y >= ns.rows or x < 0 or x >= ns.cols:
|
|
283
|
-
return # nada if out of bounds
|
|
284
|
-
if y+1 >= ns.row_cnt:
|
|
285
|
-
ns.row_cnt = y+1
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
uni = text if isinstance(text, str) else text.decode('utf-8')
|
|
289
|
-
|
|
290
|
-
if width is not None:
|
|
291
|
-
width = min(width, self.cols - x)
|
|
292
|
-
if width <= 0:
|
|
293
|
-
return
|
|
294
|
-
padlen = width - len(uni)
|
|
295
|
-
if padlen > 0:
|
|
296
|
-
if leftpad:
|
|
297
|
-
uni = padlen * ' ' + uni
|
|
298
|
-
else: # rightpad
|
|
299
|
-
uni += padlen * ' '
|
|
300
|
-
text = uni[:width].encode('utf-8')
|
|
301
|
-
else:
|
|
302
|
-
text = uni.encode('utf-8')
|
|
303
|
-
|
|
304
|
-
try:
|
|
305
|
-
while y >= len(ns.texts):
|
|
306
|
-
ns.texts.append('')
|
|
307
|
-
ns.texts[y] = ns.texts[y][:x].ljust(x) + uni + ns.texts[y][x+len(uni):]
|
|
308
|
-
ns.pad.addstr(y, x, text, text_attr)
|
|
309
|
-
except curses.error:
|
|
310
|
-
# this sucks, but curses returns an error if drawing the last character
|
|
311
|
-
# on the screen always. this can happen if resizing screen even if
|
|
312
|
-
# special care is taken. So, we just ignore errors. Anyhow, you cannot
|
|
313
|
-
# get decent error handling.
|
|
314
|
-
pass
|
|
315
|
-
|
|
316
|
-
def highlight_picked(self):
|
|
317
|
-
"""Highlight the current pick and un-highlight the previous pick."""
|
|
318
|
-
if not self.pick_mode:
|
|
319
|
-
return
|
|
320
|
-
pos0, pos1 = self.last_pick_pos, self.pick_pos
|
|
321
|
-
if pos0 == -2: # special flag to clear all formatting
|
|
322
|
-
for row in range(self.body.row_cnt):
|
|
323
|
-
self.body.pad.addstr(row, 0, self.body.texts[row], curses.A_NORMAL)
|
|
324
|
-
if pos0 != pos1:
|
|
325
|
-
if 0 <= pos0 < self.body.row_cnt:
|
|
326
|
-
for i in range(self.pick_size):
|
|
327
|
-
self.body.pad.addstr(pos0+i, 0, self.body.texts[pos0+i], curses.A_NORMAL)
|
|
328
|
-
if 0 <= pos1 < self.body.row_cnt:
|
|
329
|
-
for i in range(self.pick_size):
|
|
330
|
-
string = self.body.texts[pos1+i]
|
|
331
|
-
self.body.pad.addstr(pos1+i, 0, string, curses.A_REVERSE)
|
|
332
|
-
self.last_pick_pos = pos1
|
|
333
|
-
|
|
334
|
-
def _scroll_indicator_row(self):
|
|
335
|
-
""" Compute the absolute scroll indicator row:
|
|
336
|
-
- We want the top to be only when scroll_pos==0
|
|
337
|
-
- We want the bottom to be only when scroll_pos=max_scroll_pos-1
|
|
338
|
-
"""
|
|
339
|
-
if self.max_scroll_pos <= 1:
|
|
340
|
-
return self.body_base
|
|
341
|
-
y2, y1 = self.scroll_view_size-1, 1
|
|
342
|
-
x2, x1 = self.max_scroll_pos, 1
|
|
343
|
-
x = self.scroll_pos
|
|
344
|
-
pos = y1 + (y2-y1)*(x-x1)/(x2-x1)
|
|
345
|
-
return min(self.body_base + int(max(pos, 0)), self.rows-1)
|
|
346
|
-
|
|
347
|
-
def _scroll_indicator_col(self):
|
|
348
|
-
""" Compute the absolute scroll indicator col:
|
|
349
|
-
- We want the left to be only when scroll_pos==0
|
|
350
|
-
- We want the right to be only when scroll_pos=max_scroll_pos-1
|
|
351
|
-
"""
|
|
352
|
-
if self.pick_mode:
|
|
353
|
-
return self._calc_indicator(
|
|
354
|
-
self.pick_pos, 0, self.body.row_cnt-1, 0, self.cols-1)
|
|
355
|
-
return self._calc_indicator(
|
|
356
|
-
self.scroll_pos, 0, self.max_scroll_pos, 0, self.cols-1)
|
|
357
|
-
|
|
358
|
-
def _calc_indicator(self, pos, pos0, pos9, ind0, ind9):
|
|
359
|
-
if self.max_scroll_pos <= 0:
|
|
360
|
-
return -1 # not scrollable
|
|
361
|
-
if pos9 - pos0 <= 0:
|
|
362
|
-
return -1 # not scrollable
|
|
363
|
-
if pos <= pos0:
|
|
364
|
-
return ind0
|
|
365
|
-
if pos >= pos9:
|
|
366
|
-
return ind9
|
|
367
|
-
ind = int(round(ind0 + (ind9-ind0+1)*(pos-pos0)/(pos9-pos0+1)))
|
|
368
|
-
return min(max(ind, ind0+1), ind9-1)
|
|
369
|
-
|
|
370
|
-
def render(self):
|
|
371
|
-
"""Draw everything added. In a loop cuz curses is a
|
|
372
|
-
piece of shit."""
|
|
373
|
-
for _ in range(128):
|
|
374
|
-
try:
|
|
375
|
-
self.render_once()
|
|
376
|
-
return
|
|
377
|
-
except curses.error:
|
|
378
|
-
time.sleep(0.16)
|
|
379
|
-
self._set_screen_dims()
|
|
380
|
-
continue
|
|
381
|
-
try:
|
|
382
|
-
self.render_once()
|
|
383
|
-
except Exception:
|
|
384
|
-
Window.stop_curses()
|
|
385
|
-
print(f"""curses err:
|
|
386
|
-
head.row_cnt={self.head.row_cnt}
|
|
387
|
-
head.view_cnt={self.head.view_cnt}
|
|
388
|
-
hor_line_cnt={self.hor_line_cnt}
|
|
389
|
-
body.row_cnt={self.body.row_cnt}
|
|
390
|
-
scroll_pos={self.scroll_pos}
|
|
391
|
-
max_scroll_pos={self.max_scroll_pos}
|
|
392
|
-
pick_pos={self.pick_pos}
|
|
393
|
-
last_pick_pos={self.last_pick_pos}
|
|
394
|
-
pick_mode={self.pick_mode}
|
|
395
|
-
pick_size={self.pick_size}
|
|
396
|
-
rows={self.rows}
|
|
397
|
-
cols={self.cols}
|
|
398
|
-
""")
|
|
399
|
-
raise
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
def render_once(self):
|
|
403
|
-
"""Draw everything added."""
|
|
404
|
-
self.calc()
|
|
405
|
-
# if self.scroll_view_size <= 0:
|
|
406
|
-
# self.scr.refresh()
|
|
407
|
-
indent = 0
|
|
408
|
-
if self.body_base < self.rows:
|
|
409
|
-
ind_pos = 0 if self.pick_mode else self._scroll_indicator_row()
|
|
410
|
-
if self.pick_mode:
|
|
411
|
-
self.pick_pos = max(self.pick_pos, 0)
|
|
412
|
-
self.pick_pos = min(self.pick_pos, self.body.row_cnt-1)
|
|
413
|
-
if self.pick_pos >= 0:
|
|
414
|
-
self.pick_pos -= (self.pick_pos % self.pick_size)
|
|
415
|
-
if self.pick_pos < 0:
|
|
416
|
-
self.scroll_pos = 0
|
|
417
|
-
elif self.scroll_pos > self.pick_pos:
|
|
418
|
-
# light position is below body bottom
|
|
419
|
-
self.scroll_pos = self.pick_pos
|
|
420
|
-
elif self.scroll_pos < self.pick_pos - (self.scroll_view_size - self.pick_size):
|
|
421
|
-
# light position is above body top
|
|
422
|
-
self.scroll_pos = self.pick_pos - (self.scroll_view_size - self.pick_size)
|
|
423
|
-
self.scroll_pos = max(self.scroll_pos, 0)
|
|
424
|
-
self.scroll_pos = min(self.scroll_pos, self.max_scroll_pos)
|
|
425
|
-
indent = 1
|
|
426
|
-
else:
|
|
427
|
-
self.scroll_pos = max(self.scroll_pos, 0)
|
|
428
|
-
self.scroll_pos = min(self.scroll_pos, self.max_scroll_pos)
|
|
429
|
-
self.pick_pos = self.scroll_pos + ind_pos - self.body_base
|
|
430
|
-
# indent = 1 if self.body.row_cnt > self.scroll_view_size else 0
|
|
431
|
-
|
|
432
|
-
if indent > 0 and self.pick_mode:
|
|
433
|
-
self.scr.vline(self.body_base, 0, ' ', self.scroll_view_size)
|
|
434
|
-
if self.pick_pos >= 0:
|
|
435
|
-
pos = self.pick_pos - self.scroll_pos + self.body_base
|
|
436
|
-
self.scr.addstr(pos, 0, '>', curses.A_REVERSE)
|
|
437
|
-
|
|
438
|
-
if self.head.view_cnt < self.rows:
|
|
439
|
-
self.scr.hline(self.head.view_cnt, 0, curses.ACS_HLINE, self.cols)
|
|
440
|
-
ind_pos = self._scroll_indicator_col()
|
|
441
|
-
if ind_pos >= 0:
|
|
442
|
-
bot, cnt = ind_pos, 1
|
|
443
|
-
if 0 < ind_pos < self.cols-1:
|
|
444
|
-
width = self.scroll_view_size/self.body.row_cnt * self.cols
|
|
445
|
-
bot = max(int(round(ind_pos-width/2)), 1)
|
|
446
|
-
top = min(int(round(ind_pos+width/2)), self.cols-1)
|
|
447
|
-
cnt = top - bot
|
|
448
|
-
# self.scr.addstr(self.head.view_cnt, bot, '-'*cnt, curses.A_REVERSE)
|
|
449
|
-
# self.scr.hline(self.head.view_cnt, bot, curses.ACS_HLINE, curses.A_REVERSE, cnt)
|
|
450
|
-
for idx in range(bot, bot+cnt):
|
|
451
|
-
self.scr.addch(self.head.view_cnt, idx, curses.ACS_HLINE, curses.A_REVERSE)
|
|
452
|
-
|
|
453
|
-
self.scr.refresh()
|
|
454
|
-
|
|
455
|
-
if self.rows > 0:
|
|
456
|
-
last_row = min(self.head.view_cnt, self.rows)-1
|
|
457
|
-
if last_row >= 0:
|
|
458
|
-
self.head.pad.refresh(0, 0, 0, indent, last_row, self.cols-1)
|
|
459
|
-
|
|
460
|
-
if self.body_base < self.rows:
|
|
461
|
-
if self.pick_mode:
|
|
462
|
-
self.highlight_picked()
|
|
463
|
-
self.body.pad.refresh(self.scroll_pos, 0,
|
|
464
|
-
self.body_base, indent, self.rows-1, self.cols-1)
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
def answer(self, prompt='Type string [then Enter]', seed='', width=80):
|
|
468
|
-
"""Popup"""
|
|
469
|
-
def mod_key(key):
|
|
470
|
-
return 7 if key == 10 else key
|
|
471
|
-
|
|
472
|
-
# need 3 extra cols for rectangle (so we don't draw in southeast corner)
|
|
473
|
-
# and 3 rows (top/prompt/bottom)
|
|
474
|
-
# +Prompt--- -----------------+
|
|
475
|
-
# | Seed-for-answer |
|
|
476
|
-
# +---------Press ENTER to submit+
|
|
477
|
-
if self.rows < 3 or self.cols < 30:
|
|
478
|
-
return seed
|
|
479
|
-
width = min(width, self.cols-3) # max text width
|
|
480
|
-
row0, row9 = self.rows//2 - 1, self.rows//2 + 1
|
|
481
|
-
col0 = (self.cols - (width+2)) // 2
|
|
482
|
-
col9 = col0 + width + 2 - 1
|
|
483
|
-
|
|
484
|
-
self.scr.clear()
|
|
485
|
-
win = curses.newwin(1, width, row0+1, col0+1) # input window
|
|
486
|
-
rectangle(self.scr, row0, col0, row9, col9)
|
|
487
|
-
self.scr.addstr(row0, col0+1, prompt[0:width])
|
|
488
|
-
win.addstr(seed[0:width-1])
|
|
489
|
-
ending = 'Press ENTER to submit'[:width]
|
|
490
|
-
self.scr.addstr(row9, col0+1+width-len(ending), ending)
|
|
491
|
-
self.scr.refresh()
|
|
492
|
-
curses.curs_set(2)
|
|
493
|
-
answer = Textbox(win).edit(mod_key).strip()
|
|
494
|
-
curses.curs_set(0)
|
|
495
|
-
return answer
|
|
496
|
-
|
|
497
|
-
def alert(self, title='ALERT', message='', height=1, width=80):
|
|
498
|
-
"""Alert box"""
|
|
499
|
-
def mod_key(key):
|
|
500
|
-
return 7 if key == 10 else key
|
|
501
|
-
|
|
502
|
-
# need 3 extra cols for rectangle (so we don't draw in southeast corner)
|
|
503
|
-
# and 3 rows (top/prompt/bottom)
|
|
504
|
-
# +Prompt--- -----------------+
|
|
505
|
-
# | First line for message... |
|
|
506
|
-
# | Last line for message. |
|
|
507
|
-
# +-----------Press ENTER to ack+
|
|
508
|
-
if self.rows < 2+height or self.cols < 30:
|
|
509
|
-
return
|
|
510
|
-
width = min(width, self.cols-3) # max text width
|
|
511
|
-
row0 = (self.rows+height-1)//2 - 1
|
|
512
|
-
row9 = row0 + height + 1
|
|
513
|
-
col0 = (self.cols - (width+2)) // 2
|
|
514
|
-
col9 = col0 + width + 2 - 1
|
|
515
|
-
|
|
516
|
-
self.scr.clear()
|
|
517
|
-
for row in range(self.rows):
|
|
518
|
-
self.scr.insstr(row, 0, ' '*self.cols, curses.A_REVERSE)
|
|
519
|
-
pad = curses.newpad(20, 200)
|
|
520
|
-
win = curses.newwin(1, 1, row9-1, col9-2) # input window
|
|
521
|
-
rectangle(self.scr, row0, col0, row9, col9)
|
|
522
|
-
self.scr.addstr(row0, col0+1, title[0:width], curses.A_REVERSE)
|
|
523
|
-
pad.addstr(message)
|
|
524
|
-
ending = 'Press ENTER to ack'[:width]
|
|
525
|
-
self.scr.addstr(row9, col0+1+width-len(ending), ending)
|
|
526
|
-
self.scr.refresh()
|
|
527
|
-
pad.refresh(0, 0, row0+1, col0+1, row9-1, col9-1)
|
|
528
|
-
Textbox(win).edit(mod_key).strip()
|
|
529
|
-
return
|
|
530
|
-
|
|
531
|
-
def clear(self):
|
|
532
|
-
"""Clear in prep for new screen"""
|
|
533
|
-
self.scr.clear()
|
|
534
|
-
self.head.pad.clear()
|
|
535
|
-
self.body.pad.clear()
|
|
536
|
-
self.head.texts, self.body.texts, self.last_pick_pos = [], [], -1
|
|
537
|
-
self.head.row_cnt = self.body.row_cnt = 0
|
|
538
|
-
|
|
539
|
-
def prompt(self, seconds=1.0):
|
|
540
|
-
"""Here is where we sleep waiting for commands or timeout"""
|
|
541
|
-
ctl_b, ctl_d, ctl_f, ctl_u = 2, 4, 6, 21
|
|
542
|
-
elapsed = 0.0
|
|
543
|
-
while elapsed < seconds:
|
|
544
|
-
key = self.scr.getch()
|
|
545
|
-
if key == curses.ERR:
|
|
546
|
-
elapsed += self.timeout_ms / 1000
|
|
547
|
-
continue
|
|
548
|
-
if key in (curses.KEY_RESIZE, ) or curses.is_term_resized(self.rows, self.cols):
|
|
549
|
-
# self.scr.erase()
|
|
550
|
-
self._set_screen_dims()
|
|
551
|
-
# self.render()
|
|
552
|
-
break
|
|
553
|
-
|
|
554
|
-
# App keys...
|
|
555
|
-
if key in self.handled_keys:
|
|
556
|
-
return key # return for handling
|
|
557
|
-
|
|
558
|
-
# Navigation Keys...
|
|
559
|
-
pos = self.pick_pos if self.pick_mode else self.scroll_pos
|
|
560
|
-
delta = self.pick_size if self.pick_mode else 1
|
|
561
|
-
was_pos = pos
|
|
562
|
-
if key in (ord('k'), curses.KEY_UP):
|
|
563
|
-
pos -= delta
|
|
564
|
-
elif key in (ord('j'), curses.KEY_DOWN):
|
|
565
|
-
pos += delta
|
|
566
|
-
elif key in (ctl_b, curses.KEY_PPAGE):
|
|
567
|
-
pos -= self.scroll_view_size
|
|
568
|
-
elif key in (ctl_u, ):
|
|
569
|
-
pos -= self.scroll_view_size//2
|
|
570
|
-
elif key in (ctl_f, curses.KEY_NPAGE):
|
|
571
|
-
pos += self.scroll_view_size
|
|
572
|
-
elif key in (ctl_d, ):
|
|
573
|
-
pos += self.scroll_view_size//2
|
|
574
|
-
elif key in (ord('0'), curses.KEY_HOME):
|
|
575
|
-
pos = 0
|
|
576
|
-
elif key in (ord('$'), curses.KEY_END):
|
|
577
|
-
pos = self.body.row_cnt - 1
|
|
578
|
-
elif key in (ord('H'), ):
|
|
579
|
-
pos = self.scroll_pos
|
|
580
|
-
elif key in (ord('M'), ):
|
|
581
|
-
pos = self.scroll_pos + self.scroll_view_size//2
|
|
582
|
-
elif key in (ord('L'), ):
|
|
583
|
-
pos = self.scroll_pos + self.scroll_view_size-1
|
|
584
|
-
|
|
585
|
-
if self.pick_mode:
|
|
586
|
-
self.pick_pos = pos
|
|
587
|
-
else:
|
|
588
|
-
self.scroll_pos = pos
|
|
589
|
-
self.pick_pos = pos
|
|
590
|
-
|
|
591
|
-
if pos != was_pos:
|
|
592
|
-
self.render()
|
|
593
|
-
# ignore unhandled keys
|
|
594
|
-
return None
|
|
595
|
-
|
|
596
|
-
def no_runner():
|
|
597
|
-
"""Appease sbrun"""
|
|
598
|
-
|
|
599
|
-
if __name__ == '__main__':
|
|
600
|
-
def main():
|
|
601
|
-
"""Test program"""
|
|
602
|
-
def do_key(key):
|
|
603
|
-
nonlocal spin, win, opts
|
|
604
|
-
value = spin.do_key(key, win)
|
|
605
|
-
if key in (ord('p'), ord('s')):
|
|
606
|
-
win.set_pick_mode(on=opts.pick_mode, pick_size=opts.pick_size)
|
|
607
|
-
elif key == ord('n'):
|
|
608
|
-
win.alert(title='Info', message=f'got: {value}')
|
|
609
|
-
return value
|
|
610
|
-
|
|
611
|
-
spin = OptionSpinner()
|
|
612
|
-
spin.add_key('help_mode', '? - toggle help screen', vals=[False, True])
|
|
613
|
-
spin.add_key('pick_mode', 'p - toggle pick mode', vals=[False, True])
|
|
614
|
-
spin.add_key('pick_size', 's - #rows in pick', vals=[1, 2, 3])
|
|
615
|
-
spin.add_key('name', 'n - select name', prompt='Provide Your Name:')
|
|
616
|
-
spin.add_key('mult', 'm - row multiplier', vals=[0.5, 0.9, 1.0, 1.1, 2, 4, 16])
|
|
617
|
-
opts = spin.default_obj
|
|
618
|
-
|
|
619
|
-
win = Window(head_line=True, keys=spin.keys)
|
|
620
|
-
opts.name = "[hit 'n' to enter name]"
|
|
621
|
-
for loop in range(100000000000):
|
|
622
|
-
body_size = int(round(win.scroll_view_size*opts.mult))
|
|
623
|
-
if opts.help_mode:
|
|
624
|
-
win.set_pick_mode(False)
|
|
625
|
-
spin.show_help_nav_keys(win)
|
|
626
|
-
spin.show_help_body(win)
|
|
627
|
-
else:
|
|
628
|
-
win.set_pick_mode(opts.pick_mode, opts.pick_size)
|
|
629
|
-
win.add_header(f'Header: {loop} "{opts.name}"')
|
|
630
|
-
for idx, line in enumerate(range(body_size//opts.pick_size)):
|
|
631
|
-
win.add_body(f'Main pick: {loop}.{line}')
|
|
632
|
-
for num in range(1, opts.pick_size):
|
|
633
|
-
win.draw(num+idx*opts.pick_size, 0, f' addon: {loop}.{line}')
|
|
634
|
-
win.render()
|
|
635
|
-
do_key(win.prompt(seconds=5))
|
|
636
|
-
win.clear()
|
|
637
|
-
|
|
638
|
-
try:
|
|
639
|
-
main()
|
|
640
|
-
except KeyboardInterrupt:
|
|
641
|
-
pass
|
|
642
|
-
except Exception as exce:
|
|
643
|
-
Window.stop_curses()
|
|
644
|
-
print("exception:", str(exce))
|
|
645
|
-
print(traceback.format_exc())
|
|
646
|
-
if dump_str:
|
|
647
|
-
print(dump_str)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|