msiltop 0.2.0__py3-none-any.whl

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.
msiltop/msiltop.py ADDED
@@ -0,0 +1,932 @@
1
+ import time
2
+ import click
3
+ import asyncio
4
+ from collections import deque
5
+ from typing import Optional
6
+
7
+ from textual.app import App, ComposeResult
8
+ from textual.containers import Horizontal, Vertical
9
+ from textual.widgets import ProgressBar, Static, Label
10
+ from textual_plotext import PlotextPlot
11
+ import plotext as plt
12
+ from datetime import datetime
13
+ from .utils import run_powermetrics_process, parse_powermetrics, get_soc_info, get_ram_metrics_dict
14
+
15
+
16
+ class MetricGauge(Static):
17
+ """Custom gauge widget to display metrics with progress bar and text"""
18
+
19
+ def __init__(self, title: str = "", max_value: int = 100, **kwargs):
20
+ super().__init__(**kwargs)
21
+ self.title = title
22
+ self.max_value = max_value
23
+ self._value = 0
24
+
25
+ def compose(self) -> ComposeResult:
26
+ yield Label(self.title, id="gauge-title")
27
+ yield ProgressBar(total=self.max_value, show_percentage=True, id="gauge-progress")
28
+
29
+ def update_value(self, value: int, title: Optional[str] = None):
30
+ self._value = value
31
+ if title:
32
+ self.title = title
33
+ self.query_one("#gauge-title", Label).update(self.title)
34
+ self.query_one("#gauge-progress", ProgressBar).update(progress=value)
35
+
36
+
37
+ class PowerChart(PlotextPlot):
38
+ """Custom chart widget for power consumption data"""
39
+
40
+ def __init__(self, title: str = "", interval: int = 1, color: str = "cyan", max_points: int = 1200, **kwargs):
41
+ super().__init__(**kwargs)
42
+ self.title = title
43
+ self.interval = interval
44
+ self.plot_color = color
45
+ # Store up to max_points data points
46
+ self.data_points = deque(maxlen=max_points)
47
+ self.timestamps = deque(maxlen=max_points)
48
+ self.start_time = time.time()
49
+ # Track min/max values seen across all time
50
+ self.min_value_seen = None
51
+ self.max_value_seen = None
52
+ self._last_value = None
53
+ self._last_range = None
54
+ self._last_ticks = None
55
+ self._last_labels = None
56
+
57
+ def on_mount(self):
58
+ self.plt.title(self.title)
59
+ self.plt.xlabel("Time (minutes ago)")
60
+ self.plt.ylabel("Power (%)")
61
+ # Apply custom colors before setting auto_theme
62
+ # Set auto_theme to False to prevent overriding custom colors
63
+ self.auto_theme = False
64
+ self.plt.plotsize(None, None) # Auto-size
65
+ # Set Y-axis decimal precision
66
+ self.plt.yfrequency(0) # This will auto-determine the frequency
67
+
68
+ def add_data(self, value: float, render: bool = True):
69
+ current_time = time.time()
70
+ self.data_points.append(value)
71
+ self.timestamps.append(current_time)
72
+ if not render:
73
+ return
74
+ if self._last_value is not None and abs(value - self._last_value) < 0.01:
75
+ return
76
+ self._last_value = value
77
+
78
+ # Update min/max values seen
79
+ if self.min_value_seen is None or value < self.min_value_seen:
80
+ self.min_value_seen = value
81
+ if self.max_value_seen is None or value > self.max_value_seen:
82
+ self.max_value_seen = value
83
+
84
+ self.plt.clear_data()
85
+
86
+ # Always set up axes, even with no data
87
+ # Use tracked min/max values for scaling
88
+ if self.min_value_seen is not None and self.max_value_seen is not None:
89
+ # Ensure a minimum range to avoid flat lines
90
+ range_size = self.max_value_seen - self.min_value_seen
91
+ if range_size < 0.5: # Minimum 0.5% range
92
+ # Center the range around the midpoint
93
+ midpoint = (self.min_value_seen + self.max_value_seen) / 2
94
+ y_min = max(0, midpoint - 0.25)
95
+ y_max = midpoint + 0.25
96
+ else:
97
+ # Add 10% padding on both sides
98
+ padding = range_size * 0.1
99
+ y_min = max(0, self.min_value_seen - padding) # Don't go below 0
100
+ y_max = self.max_value_seen + padding
101
+ else:
102
+ # Default when no data
103
+ y_min = 0
104
+ y_max = 1.0
105
+
106
+ # Create 5 evenly spaced y-ticks only when range changes
107
+ current_range = (round(y_min, 2), round(y_max, 2))
108
+ if current_range != self._last_range:
109
+ y_ticks = []
110
+ y_labels = []
111
+ for i in range(5):
112
+ val = y_min + (y_max - y_min) * i / 4
113
+ y_ticks.append(val)
114
+ y_labels.append(f"{val:.1f}")
115
+ self._last_ticks = y_ticks
116
+ self._last_labels = y_labels
117
+ self._last_range = current_range
118
+ self.plt.yticks(self._last_ticks, self._last_labels)
119
+ # Set y-axis limits
120
+ self.plt.ylim(y_min, y_max)
121
+
122
+ # Set x-axis to show 0.0 to 0.6 minutes ago by default
123
+ default_x_ticks = [0.0, -0.1, -0.2, -0.3, -0.4, -0.5, -0.6]
124
+ default_x_labels = ["0.0", "0.1", "0.2", "0.3", "0.4", "0.5", "0.6"]
125
+
126
+ if len(self.data_points) > 1:
127
+ # Calculate time differences from now in minutes
128
+ time_diffs = [(current_time - t) / 60 for t in self.timestamps]
129
+ # Reverse so most recent is on the right
130
+ time_diffs = [-td for td in time_diffs]
131
+
132
+ # Use RGB color for plotting
133
+ self.plt.plot(time_diffs, list(self.data_points), marker="braille", color=self.plot_color)
134
+
135
+ # Set x-axis labels - show actual time values
136
+ if len(time_diffs) >= 5:
137
+ # Show 5 evenly spaced labels
138
+ indices = [0, len(time_diffs)//4, len(time_diffs)//2, 3*len(time_diffs)//4, len(time_diffs)-1]
139
+ ticks = [time_diffs[i] for i in indices]
140
+ labels = [f"{abs(t):.1f}" for t in ticks]
141
+ self.plt.xticks(ticks, labels)
142
+ else:
143
+ # For fewer points, show all
144
+ labels = [f"{abs(t):.1f}" for t in time_diffs]
145
+ self.plt.xticks(time_diffs, labels)
146
+ else:
147
+ # No data yet, show default x-axis
148
+ self.plt.xticks(default_x_ticks, default_x_labels)
149
+ self.plt.xlim(-0.6, 0)
150
+
151
+ self.refresh()
152
+
153
+ def update_title(self, title: str):
154
+ self.title = title
155
+ self.plt.title(title)
156
+ self.refresh()
157
+
158
+
159
+ class UsageChart(PlotextPlot):
160
+ """Custom chart widget for usage percentage data"""
161
+
162
+ def __init__(self, title: str = "", ylabel: str = "Usage (%)", interval: int = 1, color: str = "cyan", max_points: int = 1200, **kwargs):
163
+ super().__init__(**kwargs)
164
+ self.title = title
165
+ self.ylabel = ylabel
166
+ self.interval = interval
167
+ self.plot_color = color
168
+ # Store up to max_points data points
169
+ self.data_points = deque(maxlen=max_points)
170
+ self.timestamps = deque(maxlen=max_points)
171
+ self.start_time = time.time()
172
+ # Track min/max values seen across all time
173
+ self.min_value_seen = None
174
+ self.max_value_seen = None
175
+ self._last_value = None
176
+ self._last_range = None
177
+ self._last_ticks = None
178
+ self._last_labels = None
179
+
180
+ def on_mount(self):
181
+ self.plt.title(self.title)
182
+ self.plt.xlabel("Time (minutes ago)")
183
+ self.plt.ylabel(self.ylabel)
184
+ self.plt.ylim(0, 100)
185
+ # Apply custom colors before setting auto_theme
186
+ # Set auto_theme to False to prevent overriding custom colors
187
+ self.auto_theme = False
188
+ self.plt.plotsize(None, None) # Auto-size
189
+ # Set Y-axis decimal precision
190
+ self.plt.yfrequency(0) # This will auto-determine the frequency
191
+
192
+ def add_data(self, value: float, render: bool = True):
193
+ current_time = time.time()
194
+ self.data_points.append(value)
195
+ self.timestamps.append(current_time)
196
+ if not render:
197
+ return
198
+ if self._last_value is not None and abs(value - self._last_value) < 0.1:
199
+ return
200
+ self._last_value = value
201
+
202
+ # Update min/max values seen
203
+ if self.min_value_seen is None or value < self.min_value_seen:
204
+ self.min_value_seen = value
205
+ if self.max_value_seen is None or value > self.max_value_seen:
206
+ self.max_value_seen = value
207
+
208
+ self.plt.clear_data()
209
+
210
+ # Always set up axes, even with no data
211
+ # For usage charts, we'll use dynamic scaling but ensure we can see the full 0-100 range if needed
212
+ if self.min_value_seen is not None and self.max_value_seen is not None:
213
+ # If values are spread across a wide range, show full 0-100
214
+ if self.max_value_seen > 80 or (self.max_value_seen - self.min_value_seen) > 50:
215
+ # Use traditional 0-100 scale
216
+ y_min = 0
217
+ y_max = 100
218
+ y_ticks = [0, 25, 50, 75, 100]
219
+ else:
220
+ # Use dynamic scaling for better visibility of small variations
221
+ range_size = self.max_value_seen - self.min_value_seen
222
+ if range_size < 5: # Minimum 5% range
223
+ # Center the range around the midpoint
224
+ midpoint = (self.min_value_seen + self.max_value_seen) / 2
225
+ y_min = max(0, midpoint - 2.5)
226
+ y_max = min(100, midpoint + 2.5)
227
+ else:
228
+ # Add 10% padding on both sides
229
+ padding = range_size * 0.1
230
+ y_min = max(0, self.min_value_seen - padding)
231
+ y_max = min(100, self.max_value_seen + padding)
232
+
233
+ # Create 5 evenly spaced y-ticks
234
+ y_ticks = []
235
+ for i in range(5):
236
+ val = y_min + (y_max - y_min) * i / 4
237
+ y_ticks.append(val)
238
+ else:
239
+ # Default when no data
240
+ y_min = 0
241
+ y_max = 100
242
+ y_ticks = [0, 25, 50, 75, 100]
243
+
244
+ current_range = (round(y_min, 2), round(y_max, 2))
245
+ if current_range != self._last_range:
246
+ y_labels = [f"{val:.1f}" for val in y_ticks]
247
+ self._last_ticks = y_ticks
248
+ self._last_labels = y_labels
249
+ self._last_range = current_range
250
+ self.plt.yticks(self._last_ticks, self._last_labels)
251
+ self.plt.ylim(y_min, y_max)
252
+
253
+ # Set x-axis to show 0.0 to 0.6 minutes ago by default
254
+ default_x_ticks = [0.0, -0.1, -0.2, -0.3, -0.4, -0.5, -0.6]
255
+ default_x_labels = ["0.0", "0.1", "0.2", "0.3", "0.4", "0.5", "0.6"]
256
+
257
+ if len(self.data_points) > 1:
258
+ # Calculate time differences from now in minutes
259
+ time_diffs = [(current_time - t) / 60 for t in self.timestamps]
260
+ # Reverse so most recent is on the right
261
+ time_diffs = [-td for td in time_diffs]
262
+
263
+ # Use RGB color for plotting
264
+ self.plt.plot(time_diffs, list(self.data_points), marker="braille", color=self.plot_color)
265
+
266
+ # Set x-axis labels - show actual time values
267
+ if len(time_diffs) >= 5:
268
+ # Show 5 evenly spaced labels
269
+ indices = [0, len(time_diffs)//4, len(time_diffs)//2, 3*len(time_diffs)//4, len(time_diffs)-1]
270
+ ticks = [time_diffs[i] for i in indices]
271
+ labels = [f"{abs(t):.1f}" for t in ticks]
272
+ self.plt.xticks(ticks, labels)
273
+ else:
274
+ # For fewer points, show all
275
+ labels = [f"{abs(t):.1f}" for t in time_diffs]
276
+ self.plt.xticks(time_diffs, labels)
277
+ else:
278
+ # No data yet, show default x-axis
279
+ self.plt.xticks(default_x_ticks, default_x_labels)
280
+ self.plt.xlim(-0.6, 0)
281
+
282
+ self.refresh()
283
+
284
+ def update_title(self, title: str):
285
+ self.title = title
286
+ self.plt.title(title)
287
+ self.refresh()
288
+
289
+
290
+ class MultiLineChart(PlotextPlot):
291
+ """Custom chart widget for displaying multiple data series on the same chart"""
292
+
293
+ def __init__(self, title: str = "", ylabel: str = "Value", interval: int = 1, color: str = "cyan", max_points: int = 1200, **kwargs):
294
+ super().__init__(**kwargs)
295
+ self.title = title
296
+ self.ylabel = ylabel
297
+ self.interval = interval
298
+ self.plot_color = color
299
+ self.max_points = max_points
300
+ # Store up to max_points data points for each series
301
+ self.data_series = {} # Will store {'series_name': {'data': deque, 'timestamps': deque}}
302
+ self.start_time = time.time()
303
+ self._last_values = {}
304
+
305
+ def on_mount(self):
306
+ self.plt.title(self.title)
307
+ self.plt.xlabel("Time (minutes ago)")
308
+ self.plt.ylabel(self.ylabel)
309
+ # Apply custom colors before setting auto_theme
310
+ # Set auto_theme to False to prevent overriding custom colors
311
+ self.auto_theme = False
312
+ self.plt.plotsize(None, None) # Auto-size
313
+ # Set Y-axis decimal precision
314
+ self.plt.yfrequency(0) # This will auto-determine the frequency
315
+
316
+ def add_data(self, series_name: str, value: float, y_axis: str = "left", color: str | None = None, render: bool = True):
317
+ """Add data point to a specific series"""
318
+ current_time = time.time()
319
+
320
+ # Initialize series if it doesn't exist
321
+ if series_name not in self.data_series:
322
+ # Use provided color or default to the chart's color
323
+ series_color = color if color else self.plot_color
324
+ self.data_series[series_name] = {
325
+ 'data': deque(maxlen=self.max_points),
326
+ 'timestamps': deque(maxlen=self.max_points),
327
+ 'y_axis': y_axis,
328
+ 'color': series_color
329
+ }
330
+
331
+ self.data_series[series_name]['data'].append(value)
332
+ self.data_series[series_name]['timestamps'].append(current_time)
333
+ if not render:
334
+ return
335
+ last_value = self._last_values.get(series_name)
336
+ if last_value is not None and abs(value - last_value) < 0.1:
337
+ return
338
+ self._last_values[series_name] = value
339
+
340
+ self.plt.clear_data()
341
+
342
+ # Determine y-axis range based on all data
343
+ all_data_left = []
344
+ all_data_right = []
345
+
346
+ for name, series in self.data_series.items():
347
+ if series['y_axis'] == 'left':
348
+ all_data_left.extend(list(series['data']))
349
+ else:
350
+ all_data_right.extend(list(series['data']))
351
+
352
+ # Set up left y-axis (usage percentage)
353
+ if all_data_left:
354
+ y_min_left = 0
355
+ y_max_left = max(max(all_data_left), 100) # At least 100 for percentage
356
+ y_ticks_left = [0, 25, 50, 75, 100] if y_max_left <= 100 else [0, y_max_left/4, y_max_left/2, 3*y_max_left/4, y_max_left]
357
+ y_labels_left = [f"{val:.1f}" for val in y_ticks_left]
358
+ self.plt.yticks(y_ticks_left, y_labels_left)
359
+ self.plt.ylim(0, y_max_left * 1.1)
360
+
361
+ # Set x-axis to show 0.0 to 0.6 minutes ago by default
362
+ default_x_ticks = [0.0, -0.1, -0.2, -0.3, -0.4, -0.5, -0.6]
363
+ default_x_labels = ["0.0", "0.1", "0.2", "0.3", "0.4", "0.5", "0.6"]
364
+
365
+ # Plot all series
366
+ has_data = False
367
+ for name, series in self.data_series.items():
368
+ if len(series['data']) > 1:
369
+ has_data = True
370
+ # Calculate time differences from now in minutes
371
+ time_diffs = [(current_time - t) / 60 for t in series['timestamps']]
372
+ # Reverse so most recent is on the right
373
+ time_diffs = [-td for td in time_diffs]
374
+
375
+ # Use series-specific color
376
+ self.plt.plot(time_diffs, list(series['data']), marker="braille", color=series['color'], label=name)
377
+
378
+ if has_data:
379
+ # Use the most recent series' timestamps for x-axis
380
+ most_recent_series = max(self.data_series.values(), key=lambda s: len(s['data']))
381
+ time_diffs = [(current_time - t) / 60 for t in most_recent_series['timestamps']]
382
+ time_diffs = [-td for td in time_diffs]
383
+
384
+ # Set x-axis labels - show actual time values
385
+ if len(time_diffs) >= 5:
386
+ # Show 5 evenly spaced labels
387
+ indices = [0, len(time_diffs)//4, len(time_diffs)//2, 3*len(time_diffs)//4, len(time_diffs)-1]
388
+ ticks = [time_diffs[i] for i in indices]
389
+ labels = [f"{abs(t):.1f}" for t in ticks]
390
+ self.plt.xticks(ticks, labels)
391
+ else:
392
+ # For fewer points, show all
393
+ labels = [f"{abs(t):.1f}" for t in time_diffs]
394
+ self.plt.xticks(time_diffs, labels)
395
+ else:
396
+ # No data yet, show default x-axis
397
+ self.plt.xticks(default_x_ticks, default_x_labels)
398
+ self.plt.xlim(-0.6, 0)
399
+
400
+ self.refresh()
401
+
402
+ def update_title(self, title: str):
403
+ self.title = title
404
+ self.plt.title(title)
405
+ self.refresh()
406
+
407
+
408
+ class FluidTopApp(App):
409
+ """Main FluidTop application using Textual"""
410
+
411
+ # CSS is set dynamically in _apply_theme method
412
+
413
+ def __init__(self, interval: int, theme: str, avg: int, max_count: int, show_cores: bool = False):
414
+ self.interval = interval
415
+ # Store theme temporarily, don't assign to self.theme yet
416
+ theme_value = theme
417
+ self.theme_colors = self._get_theme_colors(theme_value)
418
+ # Apply theme BEFORE calling super().__init__()
419
+ self._apply_theme(theme_value)
420
+
421
+ super().__init__()
422
+
423
+ # Store theme value in a regular instance variable (not reactive)
424
+ self._theme_name = theme_value
425
+ self.avg = avg
426
+ self.max_count = max_count
427
+ self.show_cores = show_cores
428
+
429
+ # Initialize metrics storage
430
+ # No longer tracking averages or peaks
431
+
432
+ # Total energy consumption tracking (in watt-seconds) for each component
433
+ self.total_energy_consumed = 0
434
+ self.cpu_energy_consumed = 0
435
+ self.gpu_energy_consumed = 0
436
+ self.ane_energy_consumed = 0
437
+
438
+ # Powermetrics process
439
+ self.powermetrics_process = None
440
+ self.timecode = None
441
+ self.last_timestamp = 0
442
+ self.count = 0
443
+
444
+ # SoC info
445
+ self.soc_info_dict = get_soc_info()
446
+
447
+ # Smoothing buffers for CPU usage
448
+ self.cpu_usage_buffer_size = 5 # Average over last 5 samples
449
+ self.e_cpu_usage_buffer = []
450
+ self.p_cpu_usage_buffer = []
451
+ self._render_every_n = 2
452
+ self._render_tick = 0
453
+ self._core_usage_buffer = {}
454
+
455
+ def _get_theme_colors(self, theme: str) -> str:
456
+ """Get the color mapping for the theme using plotext-compatible color names"""
457
+ # Using plotext-compatible color names instead of hex colors
458
+ theme_chart_colors = {
459
+ 'default': 'gray',
460
+ 'dark': 'white',
461
+ 'blue': 'blue',
462
+ 'green': 'green',
463
+ 'red': 'red',
464
+ 'purple': 'magenta',
465
+ 'orange': 'yellow',
466
+ 'cyan': 'cyan',
467
+ 'magenta': 'magenta'
468
+ }
469
+ return theme_chart_colors.get(theme, 'cyan')
470
+
471
+ def _apply_theme(self, theme: str):
472
+ """Apply color theme to the application"""
473
+ # Using shadcn-inspired hex colors for better design consistency
474
+ themes = {
475
+ 'default': {'primary': '#18181b', 'accent': '#71717a'}, # zinc-900, zinc-500
476
+ 'dark': {'primary': '#fafafa', 'accent': '#a1a1aa'}, # zinc-50, zinc-400
477
+ 'blue': {'primary': '#1e40af', 'accent': '#3b82f6'}, # blue-800, blue-500
478
+ 'green': {'primary': '#166534', 'accent': '#22c55e'}, # green-800, green-500
479
+ 'red': {'primary': '#dc2626', 'accent': '#ef4444'}, # red-600, red-500
480
+ 'purple': {'primary': '#7c3aed', 'accent': '#a855f7'}, # violet-600, purple-500
481
+ 'orange': {'primary': '#FD8161', 'accent': '#f97316'}, # orange-600, orange-500
482
+ 'cyan': {'primary': '#5DAF8D', 'accent': '#06b6d4'}, # cyan-600, cyan-500
483
+ 'magenta': {'primary': '#db2777', 'accent': '#ec4899'} # pink-600, pink-500
484
+ }
485
+
486
+ if theme in themes:
487
+ colors = themes[theme]
488
+ # Update CSS with theme colors and reduced padding
489
+ self.CSS = f"""
490
+ Screen {{
491
+ layers: base;
492
+ overflow: hidden hidden;
493
+ }}
494
+
495
+ Screen > Container {{
496
+ width: 100%;
497
+ height: 100%;
498
+ overflow: hidden hidden;
499
+ }}
500
+
501
+ MetricGauge {{
502
+ height: 3;
503
+ margin: 0;
504
+ border: solid {colors['primary']};
505
+ }}
506
+
507
+ PowerChart {{
508
+ height: 1fr;
509
+ margin: 0;
510
+ border: none;
511
+ background: $surface;
512
+ }}
513
+
514
+ PowerChart PlotextPlot {{
515
+ background: $surface;
516
+ }}
517
+
518
+ UsageChart {{
519
+ height: 1fr;
520
+ margin: 0;
521
+ border: none;
522
+ background: $surface;
523
+ }}
524
+
525
+ UsageChart PlotextPlot {{
526
+ background: $surface;
527
+ }}
528
+
529
+ MultiLineChart {{
530
+ height: 1fr;
531
+ margin: 0;
532
+ border: none;
533
+ background: $surface;
534
+ }}
535
+
536
+ MultiLineChart PlotextPlot {{
537
+ background: $surface;
538
+ }}
539
+
540
+ #usage-section {{
541
+ border: solid {colors['primary']};
542
+ padding: 0;
543
+ height: 1fr;
544
+ background: $surface;
545
+ }}
546
+
547
+ #power-section {{
548
+ border: solid {colors['primary']};
549
+ padding: 0;
550
+ height: 1fr;
551
+ background: $surface;
552
+ }}
553
+
554
+ #power-section Horizontal {{
555
+ margin-bottom: 1;
556
+ }}
557
+
558
+ #power-section Horizontal:last-child {{
559
+ margin-bottom: 0;
560
+ }}
561
+
562
+ #controls-section {{
563
+ border: solid {colors['accent']};
564
+ padding: 0 1;
565
+ height: 3;
566
+ background: $surface;
567
+ }}
568
+
569
+ #controls-content {{
570
+ layout: horizontal;
571
+ align: center middle;
572
+ width: 100%;
573
+ height: 100%;
574
+ }}
575
+
576
+ #system-info-label {{
577
+ width: 2fr;
578
+ text-align: left;
579
+ color: $text;
580
+ padding: 0 1;
581
+ }}
582
+
583
+ #controls-buttons {{
584
+ width: 1fr;
585
+ layout: horizontal;
586
+ align: right middle;
587
+ height: 100%;
588
+ }}
589
+
590
+ #timestamp-label {{
591
+ width: auto;
592
+ text-align: right;
593
+ color: $text;
594
+ padding: 0 1;
595
+ }}
596
+
597
+ Label {{
598
+ color: $text;
599
+ margin: 0;
600
+ padding: 0;
601
+ }}
602
+
603
+ #controls-title {{
604
+ text-style: bold;
605
+ margin: 0;
606
+ padding: 0;
607
+ }}
608
+ """
609
+
610
+ def compose(self) -> ComposeResult:
611
+ """Compose the UI layout"""
612
+
613
+ # Controls section with power and system info at the top
614
+ with Vertical(id="controls-section"):
615
+ with Horizontal(id="controls-content"):
616
+ # System info on the left
617
+ yield Label("Initializing...", id="system-info-label")
618
+ # Timestamp on the right
619
+ with Horizontal(id="controls-buttons"):
620
+ yield Label("", id="timestamp-label")
621
+
622
+ # Usage Charts section
623
+ with Vertical(id="usage-section"):
624
+ with Horizontal():
625
+ yield MultiLineChart("CPU Usage (E-CPU & P-CPU)", ylabel="Usage (%)", interval=self.interval, color=self.theme_colors, id="cpu-combined-chart")
626
+ yield UsageChart("GPU", interval=self.interval, color=self.theme_colors, id="gpu-usage-chart")
627
+ yield UsageChart("RAM Usage", ylabel="RAM (%)", interval=self.interval, color=self.theme_colors, id="ram-usage-chart")
628
+ if self.show_cores:
629
+ with Vertical(id="core-usage-section"):
630
+ with Horizontal():
631
+ yield MultiLineChart("E-Core Usage", ylabel="Usage (%)", interval=self.interval, color="blue", id="e-core-usage-chart")
632
+ yield MultiLineChart("P-Core Usage", ylabel="Usage (%)", interval=self.interval, color="red", id="p-core-usage-chart")
633
+
634
+ # Power section
635
+ with Vertical(id="power-section"):
636
+ with Horizontal():
637
+ yield PowerChart("CPU Power", interval=self.interval, color=self.theme_colors, id="cpu-power-chart")
638
+ yield PowerChart("GPU Power", interval=self.interval, color=self.theme_colors, id="gpu-power-chart")
639
+ yield PowerChart("ANE Power", interval=self.interval, color=self.theme_colors, id="ane-power-chart")
640
+
641
+ async def on_mount(self):
642
+ """Initialize the application on mount"""
643
+ # Start powermetrics process
644
+ self.timecode = str(int(time.time()))
645
+ self.powermetrics_process = run_powermetrics_process(
646
+ self.timecode, interval=int(self.interval * 1000)
647
+ )
648
+
649
+ # Wait for first reading
650
+ await self.wait_for_first_reading()
651
+
652
+ # Start update timer
653
+ self.set_interval(self.interval, self.update_metrics)
654
+
655
+ # Update system info label
656
+ system_info = f"{self.soc_info_dict['name']} ({self.soc_info_dict['e_core_count']}E+{self.soc_info_dict['p_core_count']}P+{self.soc_info_dict['gpu_core_count']}GPU)"
657
+ self.query_one("#system-info-label", Label).update(system_info)
658
+
659
+ # Initialize timestamp
660
+ await self.update_timestamp()
661
+
662
+ async def wait_for_first_reading(self):
663
+ """Wait for the first powermetrics reading"""
664
+ while True:
665
+ ready = parse_powermetrics(timecode=self.timecode)
666
+ if ready:
667
+ self.last_timestamp = ready[-1]
668
+ break
669
+ await asyncio.sleep(0.1)
670
+
671
+ async def update_metrics(self):
672
+ """Update all metrics - called by timer"""
673
+ try:
674
+ # Handle max_count restart
675
+ if self.max_count > 0 and self.count >= self.max_count:
676
+ self.count = 0
677
+ self.powermetrics_process.terminate()
678
+ self.timecode = str(int(time.time()))
679
+ self.powermetrics_process = run_powermetrics_process(
680
+ self.timecode, interval=int(self.interval * 1000)
681
+ )
682
+ self.count += 1
683
+
684
+ # Parse powermetrics data
685
+ ready = parse_powermetrics(timecode=self.timecode)
686
+ if not ready:
687
+ return
688
+
689
+ cpu_metrics_dict, gpu_metrics_dict, thermal_pressure, _, timestamp = ready
690
+
691
+ if timestamp <= self.last_timestamp:
692
+ return
693
+
694
+ self.last_timestamp = timestamp
695
+
696
+ # CPU, GPU, and ANE gauge widgets have been removed
697
+
698
+ self._render_tick += 1
699
+ should_render = (self._render_tick % self._render_every_n) == 0
700
+ # Update usage charts
701
+ await self.update_usage_charts(cpu_metrics_dict, gpu_metrics_dict, should_render)
702
+
703
+ # Update power charts
704
+ await self.update_power_charts(cpu_metrics_dict, thermal_pressure, should_render)
705
+
706
+ # Update timestamp
707
+ await self.update_timestamp()
708
+
709
+ except Exception as e:
710
+ # Handle errors gracefully
711
+ pass
712
+
713
+ async def update_usage_charts(self, cpu_metrics_dict, gpu_metrics_dict, should_render: bool):
714
+ """Update usage chart metrics"""
715
+ # Update combined CPU chart (E-CPU and P-CPU)
716
+ cpu_combined_chart = self.query_one("#cpu-combined-chart", MultiLineChart)
717
+
718
+ # Get E-CPU and P-CPU usage data
719
+ e_cpu_usage_raw = cpu_metrics_dict['E-Cluster_active']
720
+ p_cpu_usage_raw = cpu_metrics_dict['P-Cluster_active']
721
+
722
+ # Add to smoothing buffers
723
+ self.e_cpu_usage_buffer.append(e_cpu_usage_raw)
724
+ self.p_cpu_usage_buffer.append(p_cpu_usage_raw)
725
+
726
+ # Keep buffer size limited
727
+ if len(self.e_cpu_usage_buffer) > self.cpu_usage_buffer_size:
728
+ self.e_cpu_usage_buffer.pop(0)
729
+ if len(self.p_cpu_usage_buffer) > self.cpu_usage_buffer_size:
730
+ self.p_cpu_usage_buffer.pop(0)
731
+
732
+ # Calculate smoothed values (average of buffer)
733
+ e_cpu_usage = int(sum(self.e_cpu_usage_buffer) / len(self.e_cpu_usage_buffer))
734
+ p_cpu_usage = int(sum(self.p_cpu_usage_buffer) / len(self.p_cpu_usage_buffer))
735
+
736
+ # Add both CPU types to the same chart with different colors
737
+ cpu_combined_chart.add_data(
738
+ f"E-CPU ({self.soc_info_dict['e_core_count']} cores)",
739
+ e_cpu_usage,
740
+ y_axis="left",
741
+ color="blue",
742
+ render=should_render,
743
+ )
744
+ cpu_combined_chart.add_data(
745
+ f"P-CPU ({self.soc_info_dict['p_core_count']} cores)",
746
+ p_cpu_usage,
747
+ y_axis="left",
748
+ color="red",
749
+ render=should_render,
750
+ )
751
+
752
+ # Update title to show both CPU types (smoothed values)
753
+ combined_title = f"E-CPU: {e_cpu_usage}% | P-CPU: {p_cpu_usage}%"
754
+ cpu_combined_chart.update_title(combined_title)
755
+
756
+ # Update GPU usage chart
757
+ gpu_chart = self.query_one("#gpu-usage-chart", UsageChart)
758
+ gpu_usage = gpu_metrics_dict['active']
759
+ gpu_title = f"GPU ({self.soc_info_dict['gpu_core_count']} cores): {gpu_usage}%"
760
+ gpu_chart.update_title(gpu_title)
761
+ gpu_chart.add_data(gpu_usage, render=should_render)
762
+
763
+ # Update RAM usage chart with swap information
764
+ ram_metrics_dict = get_ram_metrics_dict()
765
+ ram_chart = self.query_one("#ram-usage-chart", UsageChart)
766
+ ram_usage_percent = 100 - ram_metrics_dict["free_percent"] # Convert from free to used percentage
767
+
768
+ # Include swap information in the title
769
+ if ram_metrics_dict["swap_total_GB"] < 0.1:
770
+ ram_title = f"RAM: {ram_usage_percent:.1f}% ({ram_metrics_dict['used_GB']:.1f}/{ram_metrics_dict['total_GB']:.1f}GB) - swap inactive"
771
+ else:
772
+ ram_title = f"RAM: {ram_usage_percent:.1f}% ({ram_metrics_dict['used_GB']:.1f}/{ram_metrics_dict['total_GB']:.1f}GB) - swap: {ram_metrics_dict['swap_used_GB']:.1f}/{ram_metrics_dict['swap_total_GB']:.1f}GB"
773
+
774
+ ram_chart.update_title(ram_title)
775
+ ram_chart.add_data(ram_usage_percent, render=should_render)
776
+
777
+ if self.show_cores:
778
+ await self.update_core_usage_charts(cpu_metrics_dict, should_render)
779
+
780
+ async def update_core_usage_charts(self, cpu_metrics_dict, should_render: bool):
781
+ """Update per-core usage charts (optional)"""
782
+ e_core_chart = self.query_one("#e-core-usage-chart", MultiLineChart)
783
+ p_core_chart = self.query_one("#p-core-usage-chart", MultiLineChart)
784
+
785
+ for core_id in cpu_metrics_dict.get("e_core", []):
786
+ key = f"E{core_id}"
787
+ metric_key = f"E-Cluster{core_id}_active"
788
+ core_value = cpu_metrics_dict.get(metric_key)
789
+ if core_value is None:
790
+ continue
791
+ self._core_usage_buffer.setdefault(key, []).append(core_value)
792
+ if len(self._core_usage_buffer[key]) > self.cpu_usage_buffer_size:
793
+ self._core_usage_buffer[key].pop(0)
794
+ smoothed = int(sum(self._core_usage_buffer[key]) / len(self._core_usage_buffer[key]))
795
+ e_core_chart.add_data(key, smoothed, y_axis="left", color="blue", render=should_render)
796
+
797
+ for core_id in cpu_metrics_dict.get("p_core", []):
798
+ key = f"P{core_id}"
799
+ metric_key = f"P-Cluster{core_id}_active"
800
+ core_value = cpu_metrics_dict.get(metric_key)
801
+ if core_value is None:
802
+ continue
803
+ self._core_usage_buffer.setdefault(key, []).append(core_value)
804
+ if len(self._core_usage_buffer[key]) > self.cpu_usage_buffer_size:
805
+ self._core_usage_buffer[key].pop(0)
806
+ smoothed = int(sum(self._core_usage_buffer[key]) / len(self._core_usage_buffer[key]))
807
+ p_core_chart.add_data(key, smoothed, y_axis="left", color="red", render=should_render)
808
+
809
+ async def update_power_charts(self, cpu_metrics_dict, thermal_pressure, should_render: bool):
810
+ """Update power chart metrics"""
811
+ cpu_max_power = self.soc_info_dict["cpu_max_power"]
812
+ gpu_max_power = self.soc_info_dict["gpu_max_power"]
813
+ ane_max_power = 8.0
814
+
815
+ # Calculate power values (already in watts from powermetrics)
816
+ package_power_W = cpu_metrics_dict["package_W"]
817
+ cpu_power_W = cpu_metrics_dict["cpu_W"]
818
+ gpu_power_W = cpu_metrics_dict["gpu_W"]
819
+ ane_power_W = cpu_metrics_dict["ane_W"]
820
+
821
+ # Update energy consumption for each component (watts * seconds = watt-seconds)
822
+ self.total_energy_consumed += package_power_W * self.interval
823
+ self.cpu_energy_consumed += cpu_power_W * self.interval
824
+ self.gpu_energy_consumed += gpu_power_W * self.interval
825
+ self.ane_energy_consumed += ane_power_W * self.interval
826
+
827
+ # Helper function to format energy display
828
+ def format_energy(energy_ws):
829
+ energy_wh = energy_ws / 3600 # Convert watt-seconds to watt-hours
830
+ energy_mwh = energy_wh * 1000 # Convert to milliwatt-hours
831
+
832
+ if energy_mwh < 0.01:
833
+ # For very small values, show in scientific notation or as <0.01mWh
834
+ return "<0.01mWh" if energy_mwh > 0 else "0.00mWh"
835
+ elif energy_mwh < 10:
836
+ # For small values, use 2 decimal points
837
+ return f"{energy_mwh:.2f}mWh"
838
+ elif energy_wh < 1.0:
839
+ # For values under 1Wh, use 1 decimal point
840
+ return f"{energy_mwh:.1f}mWh"
841
+ elif energy_wh < 1000:
842
+ return f"{energy_wh:.2f}Wh"
843
+ else:
844
+ return f"{energy_wh / 1000:.3f}kWh"
845
+
846
+ # Update charts
847
+ cpu_power_chart = self.query_one("#cpu-power-chart", PowerChart)
848
+ cpu_power_percent = cpu_power_W / cpu_max_power * 100 # Keep as float
849
+ cpu_energy_display = format_energy(self.cpu_energy_consumed)
850
+ cpu_title = f"CPU: {cpu_power_W:.2f}W (total: {cpu_energy_display})"
851
+ cpu_power_chart.update_title(cpu_title)
852
+ cpu_power_chart.add_data(cpu_power_percent, render=should_render)
853
+
854
+ gpu_power_chart = self.query_one("#gpu-power-chart", PowerChart)
855
+ gpu_power_percent = gpu_power_W / gpu_max_power * 100 # Keep as float
856
+ gpu_energy_display = format_energy(self.gpu_energy_consumed)
857
+ gpu_title = f"GPU: {gpu_power_W:.2f}W (total: {gpu_energy_display})"
858
+ gpu_power_chart.update_title(gpu_title)
859
+ gpu_power_chart.add_data(gpu_power_percent, render=should_render)
860
+
861
+ ane_power_chart = self.query_one("#ane-power-chart", PowerChart)
862
+ ane_power_percent = ane_power_W / ane_max_power * 100 # Keep as float
863
+ ane_energy_display = format_energy(self.ane_energy_consumed)
864
+ ane_title = f"ANE: {ane_power_W:.2f}W (total: {ane_energy_display})"
865
+ ane_power_chart.update_title(ane_title)
866
+ ane_power_chart.add_data(ane_power_percent, render=should_render)
867
+
868
+ # Update system info label with total power and thermal info
869
+ thermal_throttle = "no" if thermal_pressure == "Nominal" else "yes"
870
+ total_energy_display = format_energy(self.total_energy_consumed)
871
+ system_info = f"{self.soc_info_dict['name']} ({self.soc_info_dict['e_core_count']}E+{self.soc_info_dict['p_core_count']}P+{self.soc_info_dict['gpu_core_count']}GPU) | Total: {package_power_W:.1f}W ({total_energy_display}) | Throttle: {thermal_throttle}"
872
+ self.query_one("#system-info-label", Label).update(system_info)
873
+
874
+ async def update_timestamp(self):
875
+ """Update the timestamp display"""
876
+ current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
877
+ timestamp_label = self.query_one("#timestamp-label", Label)
878
+ timestamp_label.update(f"📅 {current_time}")
879
+
880
+
881
+ def on_unmount(self):
882
+ """Clean up when app is closed"""
883
+ if self.powermetrics_process:
884
+ try:
885
+ self.powermetrics_process.terminate()
886
+ except:
887
+ pass
888
+
889
+ @click.command()
890
+ @click.option('--interval', type=float, default=1.0,
891
+ help='Display interval and sampling interval for powermetrics (seconds)')
892
+ @click.option('--theme', type=click.Choice(['default', 'dark', 'blue', 'green', 'red', 'purple', 'orange', 'cyan', 'magenta']), default='cyan',
893
+ help='Choose color theme')
894
+ @click.option('--avg', type=int, default=30,
895
+ help='Interval for averaged values (seconds)')
896
+ @click.option('--max_count', type=int, default=0,
897
+ help='Max show count to restart powermetrics')
898
+ @click.option('--show_cores', is_flag=True, default=False,
899
+ help='Show per-core CPU usage charts')
900
+ def main(interval, theme, avg, max_count, show_cores):
901
+ """msiltop: Performance monitoring CLI tool for Apple Silicon"""
902
+ return _main_logic(interval, theme, avg, max_count, show_cores=show_cores)
903
+
904
+
905
+ def _main_logic(interval, theme, avg, max_count, show_cores=False):
906
+ """Main logic using Textual app"""
907
+ print("\nMSILTOP - Performance monitoring CLI tool for Apple Silicon")
908
+ print("Get help at `https://github.com/pratikdevnani/msiltop`")
909
+ print("P.S. You are recommended to run MSILTOP with `sudo msiltop`\n")
910
+
911
+ # Create and run the Textual app
912
+ app = FluidTopApp(interval, theme, avg, max_count, show_cores=show_cores)
913
+ try:
914
+ app.run()
915
+ except KeyboardInterrupt:
916
+ print("Stopping...")
917
+ finally:
918
+ # Cleanup is handled in app.on_unmount()
919
+ pass
920
+
921
+ return app.powermetrics_process
922
+
923
+
924
+ if __name__ == "__main__":
925
+ powermetrics_process = main()
926
+ try:
927
+ powermetrics_process.terminate()
928
+ print("Successfully terminated powermetrics process")
929
+ except Exception as e:
930
+ print(e)
931
+ powermetrics_process.terminate()
932
+ print("Successfully terminated powermetrics process")