abandon screens, move to tabbed content
Improve dark mode consistency / UI presentation, TabbedContent over Screens
This commit is contained in:
commit
ce1f7bb4e4
4 changed files with 130 additions and 106 deletions
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "amdgpu-stats"
|
name = "amdgpu-stats"
|
||||||
version = "0.1.12"
|
version = "0.1.13"
|
||||||
description = "A module/TUI for AMD GPU statistics"
|
description = "A module/TUI for AMD GPU statistics"
|
||||||
authors = ["Josh Lay <pypi@jlay.io>"]
|
authors = ["Josh Lay <pypi@jlay.io>"]
|
||||||
repository = "https://github.com/joshlay/amdgpu_stats"
|
repository = "https://github.com/joshlay/amdgpu_stats"
|
||||||
|
@ -10,7 +10,7 @@ documentation = "https://amdgpu-stats.readthedocs.io/en/latest/"
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.9"
|
python = "^3.9"
|
||||||
textual = ">=0.11"
|
textual = ">=0.16.0"
|
||||||
humanfriendly = ">=10.0"
|
humanfriendly = ">=10.0"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
textual==0.10.*
|
textual>=0.16.0
|
||||||
humanfriendly==10.0
|
humanfriendly==10.0
|
||||||
|
|
|
@ -1,17 +1,42 @@
|
||||||
Header {
|
/* for colors, see: https://textual.textualize.io/guide/design/#theme-reference */
|
||||||
background: $panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
GPUStatsWidget {
|
GPUStatsWidget {
|
||||||
box-sizing: content-box;
|
/* box-sizing: content-box;*/
|
||||||
background: $panel;
|
background: $panel;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 50;
|
/* min-width: 50;*/
|
||||||
|
}
|
||||||
|
.logs {
|
||||||
|
height: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* for colors, see: https://textual.textualize.io/guide/design/#theme-reference */
|
.stat_table {
|
||||||
|
|
||||||
DataTable {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Container {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
TabbedContent {
|
||||||
|
/* box-sizing: content-box;*/
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification {
|
||||||
|
dock: bottom;
|
||||||
|
layer: notification;
|
||||||
|
width: auto;
|
||||||
|
margin: 2 4;
|
||||||
|
padding: 1 2;
|
||||||
|
background: $background;
|
||||||
|
color: $text;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
Header {
|
||||||
|
dock: top;
|
||||||
|
content-align: center middle;
|
||||||
|
background: $panel;
|
||||||
|
}
|
||||||
|
|
|
@ -7,11 +7,7 @@ Can be used as a way to monitor GPU(s) in your terminal, or inform other utiliti
|
||||||
|
|
||||||
Classes:
|
Classes:
|
||||||
- GPUStats: the object for the _Application_, instantiated at runtime
|
- GPUStats: the object for the _Application_, instantiated at runtime
|
||||||
- GPUStatsWidget: the primary container for the three stat widgets:
|
- GPUStatsWidget: the primary container for the tabbed content; stats table / logs
|
||||||
- MiscDisplay
|
|
||||||
- ClockDisplay
|
|
||||||
- PowerDisplay
|
|
||||||
- LogScreen: Second screen with the logging widget, header, and footer
|
|
||||||
|
|
||||||
Functions:
|
Functions:
|
||||||
- start: Creates the 'App' and renders the TUI using the classes above
|
- start: Creates the 'App' and renders the TUI using the classes above
|
||||||
|
@ -20,122 +16,135 @@ Functions:
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from os import path
|
|
||||||
|
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.containers import Container
|
from textual.containers import Container
|
||||||
from textual.screen import Screen
|
from textual.widgets import (
|
||||||
from textual.widgets import Header, Footer, Static, TextLog, DataTable
|
Header, Footer, Static, TextLog, DataTable, TabbedContent
|
||||||
|
)
|
||||||
from .utils import AMDGPU_CARDS, get_fan_rpm, get_power_stats, get_temp_stat, get_clock, get_gpu_usage, get_voltage
|
|
||||||
# pylint: disable=line-too-long
|
|
||||||
|
|
||||||
|
from .utils import (
|
||||||
|
AMDGPU_CARDS,
|
||||||
|
get_fan_rpm,
|
||||||
|
get_power_stats,
|
||||||
|
get_temp_stat,
|
||||||
|
get_clock,
|
||||||
|
get_gpu_usage,
|
||||||
|
get_voltage
|
||||||
|
)
|
||||||
# rich markup reference:
|
# rich markup reference:
|
||||||
# https://rich.readthedocs.io/en/stable/markup.html
|
# https://rich.readthedocs.io/en/stable/markup.html
|
||||||
|
|
||||||
|
|
||||||
class LogScreen(Screen):
|
class Notification(Static):
|
||||||
"""Creates a screen for the logging widget"""
|
'''Self-removing notification widget'''
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.text_log = TextLog(highlight=True, markup=True, name='log_gpu')
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Event handler called when widget is first added
|
'''On the creation/display of the notification...
|
||||||
On first display in this case."""
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
Creates a timer to remove itself in 3 seconds'''
|
||||||
yield Header(show_clock=True)
|
self.set_timer(3, self.remove)
|
||||||
yield self.text_log
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
# def on_key(self, event: events.Key) -> None:
|
def on_click(self) -> None:
|
||||||
# """Log/show key presses when the log window is open"""
|
'''Fires when notification is clicked, removes the widget'''
|
||||||
# self.text_log.write(event)
|
self.remove()
|
||||||
|
|
||||||
|
|
||||||
class GPUStatsWidget(Static):
|
class GPUStatsWidget(Static):
|
||||||
"""The main stats widget."""
|
"""The main stats widget."""
|
||||||
|
|
||||||
columns = ["card",
|
columns = ["Card",
|
||||||
"core clock",
|
"Core clock",
|
||||||
"memory clock",
|
"Memory clock",
|
||||||
"utilization",
|
"Utilization",
|
||||||
"voltage",
|
"Voltage",
|
||||||
"power usage",
|
"Power",
|
||||||
"set limit",
|
"[italic]Limit",
|
||||||
"default limit",
|
"[italic]Default",
|
||||||
"capability",
|
"[italic]Capability",
|
||||||
"fan rpm",
|
"Fan RPM",
|
||||||
"edge temp",
|
"Edge temp",
|
||||||
"junction temp",
|
"Junction temp",
|
||||||
"memory temp"]
|
"Memory temp"]
|
||||||
timer_stats = None
|
timer_stats = None
|
||||||
|
text_log = None
|
||||||
|
stats_table = None
|
||||||
table = None
|
table = None
|
||||||
table_needs_init = True
|
table_needs_init = True
|
||||||
data = {}
|
data = {}
|
||||||
|
|
||||||
def __init__(self, *args, cards=None, **kwargs):
|
def __init__(self, *args, cards=None, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Instance variables
|
|
||||||
self.cards = cards
|
self.cards = cards
|
||||||
|
self.text_log = TextLog(highlight=True, markup=True, name='log_gpu', classes='logs')
|
||||||
|
self.stats_table = DataTable(zebra_stripes=True, show_cursor=False, name='stats_table', classes='stat_table')
|
||||||
|
|
||||||
async def on_mount(self) -> None:
|
async def on_mount(self) -> None:
|
||||||
'''Fires when stats widget first shown'''
|
'''Fires when stats widget first shown'''
|
||||||
self.table = self.query_one(DataTable)
|
self.table = self.query_one(DataTable)
|
||||||
|
# construct the table columns
|
||||||
for column in self.columns:
|
for column in self.columns:
|
||||||
self.table.add_column(label=column, key=column)
|
self.table.add_column(label=column, key=column)
|
||||||
# self.table.add_columns(*self.columns)
|
# mark the table as needing initialization (with rows)
|
||||||
self.table_needs_init = True
|
self.table_needs_init = True
|
||||||
|
# do a one-off stat collection, populate table before the interval
|
||||||
|
if self.table_needs_init:
|
||||||
|
self.get_stats()
|
||||||
|
# stand up the stat-collecting interval, once per second
|
||||||
self.timer_stats = self.set_interval(1, self.get_stats)
|
self.timer_stats = self.set_interval(1, self.get_stats)
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Create child widgets."""
|
"""Create child widgets."""
|
||||||
stats_table = DataTable(zebra_stripes=True, show_cursor=False, name='stats_table')
|
# Add the TabbedContent widget
|
||||||
yield stats_table
|
with TabbedContent("Stats", "Logs"):
|
||||||
self.update_log('[bold]App:[/] created stats table')
|
yield self.stats_table
|
||||||
|
yield self.text_log
|
||||||
|
self.update_log("[bold green]App started, logging begin!")
|
||||||
|
self.update_log(f"[bold]Discovered AMD GPUs: [/]{list(AMDGPU_CARDS)}")
|
||||||
|
self.update_log('[bold]App: [/]created stats table')
|
||||||
|
|
||||||
def update_log(self, message: str) -> None:
|
def update_log(self, message: str) -> None:
|
||||||
"""Update the TextLog widget with a new message."""
|
"""Update the TextLog widget with a new message."""
|
||||||
log_screen = AMDGPUStats.SCREENS["logs"]
|
self.text_log.write(message)
|
||||||
log_screen.text_log.write(message)
|
|
||||||
|
|
||||||
def get_stats(self):
|
def get_stats(self):
|
||||||
'''Function to fetch stats / update the table'''
|
'''Function to fetch stats / update the table'''
|
||||||
for card in self.cards:
|
for card in self.cards:
|
||||||
power_stats = get_power_stats(card=card)
|
power_stats = get_power_stats(card=card)
|
||||||
|
# annoyingly, must retain the styling used w/ the cols above
|
||||||
|
# otherwise stats won't update
|
||||||
|
# noticed when fiddling 'style' below between new/update 'Text'
|
||||||
self.data = {
|
self.data = {
|
||||||
"card": card,
|
"Card": card,
|
||||||
"core clock": get_clock('core', card=card, format_freq=True),
|
"Core clock": get_clock('core', card=card, format_freq=True),
|
||||||
"memory clock": get_clock('memory', card=card, format_freq=True),
|
"Memory clock": get_clock('memory', card=card, format_freq=True),
|
||||||
"utilization": f'{get_gpu_usage(card=card)}%',
|
"Utilization": f'{get_gpu_usage(card=card)}%',
|
||||||
"voltage": f'{get_voltage(card=card)}V',
|
"Voltage": f'{get_voltage(card=card)}V',
|
||||||
"power usage": f'{power_stats["average"]}W',
|
"Power": f'{power_stats["average"]}W',
|
||||||
"set limit": f'{power_stats["limit"]}W',
|
"[italic]Limit": f'{power_stats["limit"]}W',
|
||||||
"default limit": f'{power_stats["default"]}W',
|
"[italic]Default": f'{power_stats["default"]}W',
|
||||||
"capability": f'{power_stats["capability"]}W',
|
"[italic]Capability": f'{power_stats["capability"]}W',
|
||||||
"fan rpm": f'{get_fan_rpm(card=card)}',
|
"Fan RPM": f'{get_fan_rpm(card=card)}',
|
||||||
"edge temp": f"{get_temp_stat(name='edge', card=card)}C",
|
"Edge temp": f"{get_temp_stat(name='edge', card=card)}C",
|
||||||
"junction temp": f"{get_temp_stat(name='junction', card=card)}C",
|
"Junction temp": f"{get_temp_stat(name='junction', card=card)}C",
|
||||||
"memory temp": f"{get_temp_stat(name='mem', card=card)}C"}
|
"Memory temp": f"{get_temp_stat(name='mem', card=card)}C"}
|
||||||
# handle the table data appopriately
|
# handle the table data appopriately
|
||||||
# if needs populated anew or updated
|
# if needs populated anew or updated
|
||||||
if self.table_needs_init:
|
if self.table_needs_init:
|
||||||
# Add rows for the first time
|
# Add rows for the first time
|
||||||
# Adding styled and justified `Text` objects instead of plain strings.
|
# Adding right-justified `Text` objects instead of plain strings
|
||||||
styled_row = [
|
styled_row = [
|
||||||
Text(str(cell), style="italic", justify="right") for cell in self.data.values()
|
Text(str(cell), style="normal", justify="right") for cell in self.data.values()
|
||||||
]
|
]
|
||||||
self.table.add_row(*styled_row, key=card)
|
self.table.add_row(*styled_row, key=card)
|
||||||
hwmon_dir = AMDGPU_CARDS[card]
|
hwmon_dir = AMDGPU_CARDS[card]
|
||||||
self.update_log(f'[bold]stats:[/] added row for [bold green]{card}[/], info dir: {hwmon_dir}')
|
self.update_log(f"[bold]Table: [/]added row for '{card}', info dir: '{hwmon_dir}'")
|
||||||
else:
|
else:
|
||||||
# Update existing rows
|
# Update existing rows, retaining styling/justification
|
||||||
for column, value in self.data.items():
|
for column, value in self.data.items():
|
||||||
styled_cell = Text(str(value), style="italic", justify="right")
|
styled_cell = Text(str(value), style="normal", justify="right")
|
||||||
self.table.update_cell(card, column, styled_cell)
|
self.table.update_cell(card, column, styled_cell)
|
||||||
if self.table_needs_init:
|
if self.table_needs_init:
|
||||||
# if this is the first time updating the table, mark it initialized
|
# if this is the first time updating the table, mark it initialized
|
||||||
|
@ -149,65 +158,55 @@ class AMDGPUStats(App):
|
||||||
# apply stylesheet
|
# apply stylesheet
|
||||||
CSS_PATH = 'style.css'
|
CSS_PATH = 'style.css'
|
||||||
|
|
||||||
# initialize log screen
|
|
||||||
SCREENS = {"logs": LogScreen()}
|
|
||||||
|
|
||||||
# title the app after the card
|
|
||||||
# TITLE = 'GPUStats - ' + CARD
|
|
||||||
|
|
||||||
# setup keybinds
|
# setup keybinds
|
||||||
# Binding("l", "push_screen('logs')", "Toggle logs", priority=True),
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("c", "custom_dark", "Colors"),
|
Binding("c", "custom_dark", "Colors"),
|
||||||
Binding("l", "custom_log", "Logs"),
|
Binding("l", "custom_log", "Logs"),
|
||||||
Binding("s", "custom_screenshot", "Screenshot"),
|
Binding("s", "custom_screenshot", "Screenshot"),
|
||||||
Binding("q", "quit", "Quit")
|
Binding("q", "quit", "Quit")
|
||||||
]
|
]
|
||||||
|
stats_widget = GPUStatsWidget(cards=AMDGPU_CARDS,
|
||||||
|
name="stats_widget")
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Create child widgets for the app."""
|
"""Create child widgets for the app."""
|
||||||
yield Header(show_clock=True)
|
yield Header(show_clock=True)
|
||||||
stats_widget = Container(GPUStatsWidget(cards=AMDGPU_CARDS,
|
|
||||||
name="stats_widget"))
|
|
||||||
yield stats_widget
|
|
||||||
self.update_log("[bold green]App started, logging begin!")
|
|
||||||
self.update_log(f"[bold]Discovered AMD GPUs:[/] {list(AMDGPU_CARDS)}")
|
|
||||||
# nice-to-have: account for not storing these in dicts, but resolved in funcs
|
|
||||||
# for metric, source in SRC_FILES.items():
|
|
||||||
# self.update_log(f'[bold] {metric}:[/] {source}')
|
|
||||||
# for metric, source in TEMP_FILES.items():
|
|
||||||
# self.update_log(f'[bold] {metric} temperature:[/] {source}')
|
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
yield Container(self.stats_widget)
|
||||||
|
|
||||||
async def action_custom_dark(self) -> None:
|
def action_custom_dark(self) -> None:
|
||||||
"""An action to toggle dark mode.
|
"""An action to toggle dark mode.
|
||||||
|
|
||||||
Wraps 'action_toggle_dark' with logging and a refresh"""
|
Wraps 'action_toggle_dark' with logging and a refresh"""
|
||||||
self.dark = not self.dark
|
self.app.dark = not self.app.dark
|
||||||
self.update_log(f"[bold]Dark side: [italic]{self.dark}")
|
self.update_log(f"[bold]Dark side: [italic]{self.app.dark}")
|
||||||
self.refresh()
|
|
||||||
# self.dark = not self.dark
|
|
||||||
|
|
||||||
def action_custom_screenshot(self, screen_dir: str = '/tmp') -> None:
|
def action_custom_screenshot(self, screen_dir: str = '/tmp') -> None:
|
||||||
"""Action that fires when the user presses 's' for a screenshot"""
|
"""Action that fires when the user presses 's' for a screenshot"""
|
||||||
# construct the screenshot elements + path
|
# construct the screenshot elements: name (w/ ISO timestamp) + path
|
||||||
timestamp = datetime.now().isoformat().replace(":", "_")
|
timestamp = datetime.now().isoformat().replace(":", "_")
|
||||||
screen_name = 'amdgpu_stats_' + timestamp + '.svg'
|
screen_name = 'amdgpu_stats_' + timestamp + '.svg'
|
||||||
screen_path = path.join(screen_dir, screen_name)
|
# take the screenshot, recording the path for logging/notification
|
||||||
self.action_screenshot(path=screen_dir, filename=screen_name)
|
outpath = self.save_screenshot(path=screen_dir, filename=screen_name)
|
||||||
self.update_log(f'[bold]Screenshot taken: [italic]{screen_path}')
|
# construct the log/notification message, then show it
|
||||||
|
message = Text.assemble("Screenshot saved to ", (f"'{outpath}'", "bold"))
|
||||||
|
self.screen.mount(Notification(message))
|
||||||
|
self.update_log(message)
|
||||||
|
|
||||||
def action_custom_log(self) -> None:
|
def action_custom_log(self) -> None:
|
||||||
"""Toggle between the main screen and the LogScreen."""
|
"""Toggle between the main screen and the LogScreen."""
|
||||||
if isinstance(self.screen, LogScreen):
|
active = self.query_one(TabbedContent).active
|
||||||
self.pop_screen()
|
# if the second tab (logs), go to first
|
||||||
|
if active == "tab-2":
|
||||||
|
self.query_one(TabbedContent).active = 'tab-1'
|
||||||
else:
|
else:
|
||||||
self.push_screen("logs")
|
# otherwise, go to logs
|
||||||
|
self.query_one(TabbedContent).active = 'tab-2'
|
||||||
|
|
||||||
def update_log(self, message: str) -> None:
|
def update_log(self, message: str) -> None:
|
||||||
"""Update the TextLog widget with a new message."""
|
"""Update the TextLog widget with a new message."""
|
||||||
log_screen = self.SCREENS["logs"]
|
log = self.stats_widget.text_log
|
||||||
log_screen.text_log.write(message)
|
log.write(message)
|
||||||
|
|
||||||
|
|
||||||
def start() -> None:
|
def start() -> None:
|
||||||
|
|
Reference in a new issue