diff --git a/src/amdgpu_stats/style.css b/src/amdgpu_stats/style.css index 0d9c138..84efbb8 100644 --- a/src/amdgpu_stats/style.css +++ b/src/amdgpu_stats/style.css @@ -1,17 +1,42 @@ -Header { - background: $panel; -} +/* for colors, see: https://textual.textualize.io/guide/design/#theme-reference */ GPUStatsWidget { - box-sizing: content-box; +/* box-sizing: content-box;*/ background: $panel; height: 100%; width: 100%; - min-width: 50; +/* min-width: 50;*/ +} +.logs { + height: 1fr; } -/* for colors, see: https://textual.textualize.io/guide/design/#theme-reference */ - -DataTable { +.stat_table { 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; +} diff --git a/src/amdgpu_stats/tui.py b/src/amdgpu_stats/tui.py index 1d7f592..882d764 100644 --- a/src/amdgpu_stats/tui.py +++ b/src/amdgpu_stats/tui.py @@ -7,11 +7,7 @@ Can be used as a way to monitor GPU(s) in your terminal, or inform other utiliti Classes: - GPUStats: the object for the _Application_, instantiated at runtime - - GPUStatsWidget: the primary container for the three stat widgets: - - MiscDisplay - - ClockDisplay - - PowerDisplay - - LogScreen: Second screen with the logging widget, header, and footer + - GPUStatsWidget: the primary container for the tabbed content; stats table / logs Functions: - start: Creates the 'App' and renders the TUI using the classes above @@ -20,60 +16,55 @@ Functions: # pylint: disable=line-too-long import sys from datetime import datetime -from os import path from rich.text import Text from textual.binding import Binding from textual.app import App, ComposeResult from textual.containers import Container -from textual.screen import Screen -from textual.widgets import Header, Footer, Static, TextLog, DataTable - -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 textual.widgets import ( + 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 +) # rich markup reference: # https://rich.readthedocs.io/en/stable/markup.html -class LogScreen(Screen): - """Creates a screen for the logging widget""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.text_log = TextLog(highlight=True, markup=True, name='log_gpu') - +class Notification(Static): def on_mount(self) -> None: - """Event handler called when widget is first added - On first display in this case.""" + self.set_timer(3, self.remove) - def compose(self) -> ComposeResult: - yield Header(show_clock=True) - yield self.text_log - yield Footer() - -# def on_key(self, event: events.Key) -> None: -# """Log/show key presses when the log window is open""" -# self.text_log.write(event) + def on_click(self) -> None: + self.remove() class GPUStatsWidget(Static): """The main stats widget.""" - columns = ["card", - "core clock", - "memory clock", - "utilization", - "voltage", - "power usage", - "set limit", - "default limit", - "capability", - "fan rpm", - "edge temp", - "junction temp", - "memory temp"] + columns = ["Card", + "Core clock", + "Memory clock", + "Utilization", + "Voltage", + "Power", + "[italic]Limit", + "[italic]Default", + "[italic]Capability", + "Fan RPM", + "Edge temp", + "Junction temp", + "Memory temp"] timer_stats = None + text_log = None + stats_table = None table = None table_needs_init = True data = {} @@ -82,60 +73,73 @@ class GPUStatsWidget(Static): super().__init__(*args, **kwargs) # Instance variables 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: '''Fires when stats widget first shown''' self.table = self.query_one(DataTable) + # construct the table columns for column in self.columns: 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 + # 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) def compose(self) -> ComposeResult: """Create child widgets.""" - stats_table = DataTable(zebra_stripes=True, show_cursor=False, name='stats_table') - yield stats_table - self.update_log('[bold]App:[/] created stats table') + # Add the TabbedContent widget + with TabbedContent("Stats", "Logs"): + 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: """Update the TextLog widget with a new message.""" - log_screen = AMDGPUStats.SCREENS["logs"] - log_screen.text_log.write(message) + self.text_log.write(message) def get_stats(self): '''Function to fetch stats / update the table''' for card in self.cards: 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 = { - "card": card, - "core clock": get_clock('core', card=card, format_freq=True), - "memory clock": get_clock('memory', card=card, format_freq=True), - "utilization": f'{get_gpu_usage(card=card)}%', - "voltage": f'{get_voltage(card=card)}V', - "power usage": f'{power_stats["average"]}W', - "set limit": f'{power_stats["limit"]}W', - "default limit": f'{power_stats["default"]}W', - "capability": f'{power_stats["capability"]}W', - "fan rpm": f'{get_fan_rpm(card=card)}', - "edge temp": f"{get_temp_stat(name='edge', 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"} + "Card": card, + "Core clock": get_clock('core', card=card, format_freq=True), + "Memory clock": get_clock('memory', card=card, format_freq=True), + "Utilization": f'{get_gpu_usage(card=card)}%', + "Voltage": f'{get_voltage(card=card)}V', + "Power": f'{power_stats["average"]}W', + "[italic]Limit": f'{power_stats["limit"]}W', + "[italic]Default": f'{power_stats["default"]}W', + "[italic]Capability": f'{power_stats["capability"]}W', + "Fan RPM": f'{get_fan_rpm(card=card)}', + "Edge temp": f"{get_temp_stat(name='edge', 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"} # handle the table data appopriately # if needs populated anew or updated if self.table_needs_init: # 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 = [ - 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) 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: - # Update existing rows + # Update existing rows, retaining styling/justification 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) if self.table_needs_init: # if this is the first time updating the table, mark it initialized @@ -149,9 +153,6 @@ class AMDGPUStats(App): # apply stylesheet CSS_PATH = 'style.css' - # initialize log screen - SCREENS = {"logs": LogScreen()} - # title the app after the card # TITLE = 'GPUStats - ' + CARD @@ -163,51 +164,55 @@ class AMDGPUStats(App): Binding("s", "custom_screenshot", "Screenshot"), Binding("q", "quit", "Quit") ] + stats_widget = GPUStatsWidget(cards=AMDGPU_CARDS, + name="stats_widget") def compose(self) -> ComposeResult: """Create child widgets for the app.""" 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)}") + yield Footer() + yield Container(self.stats_widget) # 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() - async def action_custom_dark(self) -> None: + def action_custom_dark(self) -> None: """An action to toggle dark mode. Wraps 'action_toggle_dark' with logging and a refresh""" - self.dark = not self.dark - self.update_log(f"[bold]Dark side: [italic]{self.dark}") - self.refresh() + self.app.dark = not self.app.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: """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(":", "_") screen_name = 'amdgpu_stats_' + timestamp + '.svg' - screen_path = path.join(screen_dir, screen_name) - self.action_screenshot(path=screen_dir, filename=screen_name) - self.update_log(f'[bold]Screenshot taken: [italic]{screen_path}') + # take the screenshot, recording the path for logging/notification + outpath = self.save_screenshot(path=screen_dir, filename=screen_name) + # 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: """Toggle between the main screen and the LogScreen.""" - if isinstance(self.screen, LogScreen): - self.pop_screen() + active = self.query_one(TabbedContent).active + # if the second tab (logs), go to first + if active == "tab-2": + self.query_one(TabbedContent).active = 'tab-1' else: - self.push_screen("logs") + # otherwise, go to logs + self.query_one(TabbedContent).active = 'tab-2' def update_log(self, message: str) -> None: """Update the TextLog widget with a new message.""" - log_screen = self.SCREENS["logs"] - log_screen.text_log.write(message) + log = self.stats_widget.text_log + log.write(message) def start() -> None: