Archived
1
1
Fork 0

abandon screens, move to tabbed content

Improve dark mode consistency / UI presentation, TabbedContent over Screens
This commit is contained in:
Josh Lay 2023-05-02 19:43:01 -05:00 committed by GitHub
commit ce1f7bb4e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 130 additions and 106 deletions

View file

@ -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]

View file

@ -1,2 +1,2 @@
textual==0.10.* textual>=0.16.0
humanfriendly==10.0 humanfriendly==10.0

View file

@ -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;
}

View file

@ -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: