[rocprofiler-compute][tui] menu bar lag fix (#1942)
This commit is contained in:
@@ -48,6 +48,10 @@ Full documentation for ROCm Compute Profiler is available at [https://rocm.docs.
|
||||
|
||||
* Removed "VL1 Lat" metric for AMD Instinct MI300 series GPUs, due to MI300 series not supporting TCP_TCP_LATENCY_sum counter.
|
||||
|
||||
### Optimized
|
||||
|
||||
* Improved the responsiveness of menu and dropdown buttons in TUI analyze mode for a smoother user experience.
|
||||
|
||||
## ROCm Compute Profiler 3.4.0 for ROCm 7.2.0
|
||||
|
||||
### Added
|
||||
|
||||
@@ -42,5 +42,4 @@ setuptools python library: MIT
|
||||
tabulate python library: MIT
|
||||
textual python library: MIT
|
||||
textual_plotext python library: MIT
|
||||
textual-fspicker python library: MIT
|
||||
tqdm python library: MIT
|
||||
|
||||
@@ -16,5 +16,4 @@ sqlalchemy>=2.0.42
|
||||
tabulate
|
||||
textual
|
||||
textual_plotext
|
||||
textual-fspicker>=0.4.3
|
||||
tqdm
|
||||
|
||||
@@ -46,12 +46,12 @@ Collapsible {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
Collapsible>.collapsible--title {
|
||||
Collapsible >.collapsible--title {
|
||||
background: $surface-darken-2;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
Collapsible>.collapsible--content {
|
||||
Collapsible >.collapsible--content {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
@@ -197,26 +197,27 @@ RocprofTUIApp {
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
background: $surface;
|
||||
color: $text;
|
||||
min-width: 10;
|
||||
height: 1;
|
||||
margin: 0;
|
||||
padding: 0 1;
|
||||
Button.menu-item {
|
||||
background: $accent;
|
||||
width: 25;
|
||||
height: 2;
|
||||
padding: 0 2;
|
||||
border: none;
|
||||
width: auto;
|
||||
align: center middle;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
Button:hover {
|
||||
background: $primary-background-lighten-1;
|
||||
Button.menu-item:hover {
|
||||
background: $accent-lighten-1;
|
||||
color: $text-accent;
|
||||
}
|
||||
|
||||
Button.selected {
|
||||
background: $primary-background;
|
||||
color: $text-accent;
|
||||
text-style: bold;
|
||||
Button.menu-item:focus {
|
||||
background: $accent-darken-1;
|
||||
}
|
||||
|
||||
Button.menu-item.-active {
|
||||
background: $accent-darken-2;
|
||||
}
|
||||
|
||||
MenuBar {
|
||||
@@ -236,9 +237,12 @@ MenuButton {
|
||||
background: $surface;
|
||||
color: $text;
|
||||
min-width: 10;
|
||||
height: 1;
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
border: none;
|
||||
width: auto;
|
||||
align: center middle;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
MenuButton:hover {
|
||||
@@ -246,11 +250,43 @@ MenuButton:hover {
|
||||
color: $text-accent;
|
||||
}
|
||||
|
||||
MenuButton:focus {
|
||||
background: $primary-background;
|
||||
}
|
||||
|
||||
MenuButton.-active {
|
||||
background: $primary-background-darken-1;
|
||||
}
|
||||
|
||||
MenuButton.selected {
|
||||
background: $primary-background;
|
||||
color: $text-accent;
|
||||
}
|
||||
|
||||
/* InstantButton replacements for menu items */
|
||||
InstantButton.menu-item {
|
||||
background: $accent;
|
||||
width: 25;
|
||||
height: 2;
|
||||
padding: 0 2;
|
||||
border: none;
|
||||
align: center middle;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
InstantButton.menu-item:hover {
|
||||
background: $accent-lighten-1;
|
||||
color: $text-accent;
|
||||
}
|
||||
|
||||
InstantButton.menu-item:focus {
|
||||
background: $accent-darken-1;
|
||||
}
|
||||
|
||||
InstantButton.menu-item.-active {
|
||||
background: $accent-darken-2;
|
||||
}
|
||||
|
||||
#dropdown-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
@@ -260,28 +296,29 @@ DropdownMenu {
|
||||
background: $accent;
|
||||
width: auto;
|
||||
min-width: 20;
|
||||
padding-top: 1;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
layout: grid;
|
||||
grid-size: 1 3;
|
||||
}
|
||||
|
||||
DropdownMenu > * {
|
||||
width: 1fr;
|
||||
height: 2; /* makes the widget claim vertical space */
|
||||
align: center middle; /* makes the widget fill that space */
|
||||
content-align: center middle; /* centers the label inside the widget */
|
||||
}
|
||||
|
||||
#file-dropdown {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
background: $accent;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: $primary-background-lighten-1;
|
||||
color: $text-accent;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Terimnal {
|
||||
Terminal {
|
||||
layout: vertical;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -312,4 +349,4 @@ Terimnal {
|
||||
border-title-color: $foreground;
|
||||
border-title-style: b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
|
||||
##############################################################################
|
||||
"""
|
||||
ROCm Compute Profiler TUI - Main Application with Analysis Methods
|
||||
@@ -33,16 +32,13 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from textual import on, work
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.widgets import Button, Footer, Header
|
||||
from textual_fspicker import SelectDirectory
|
||||
from textual.widgets import Footer, Header
|
||||
|
||||
import config
|
||||
from rocprof_compute_tui.config import APP_TITLE
|
||||
from rocprof_compute_tui.views.main_view import MainView
|
||||
from rocprof_compute_tui.widgets.menu_bar.menu_bar import DropdownMenu
|
||||
from utils.specs import generate_machine_specs
|
||||
from utils.utils import get_version
|
||||
|
||||
@@ -76,6 +72,7 @@ class RocprofTUIApp(App):
|
||||
self.supported_archs = supported_archs or {}
|
||||
self.soc: dict[str, Any] = {}
|
||||
self.mspec: Optional[Any] = None
|
||||
self.mouse = True
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
@@ -84,14 +81,26 @@ class RocprofTUIApp(App):
|
||||
|
||||
def action_refresh(self) -> None:
|
||||
self.main_view.refresh_view()
|
||||
self.notify("View refreshed", severity="information")
|
||||
|
||||
def load_soc_specs(self, sysinfo: Optional[dict] = None) -> None:
|
||||
self.mspec = generate_machine_specs(self.args, sysinfo)
|
||||
arch = self.mspec.gpu_arch
|
||||
soc_module = importlib.import_module(f"rocprof_compute_soc.soc_{arch}")
|
||||
soc_class = getattr(soc_module, f"{arch}_soc")
|
||||
self.soc[arch] = soc_class(self.args, self.mspec)
|
||||
try:
|
||||
self.mspec = generate_machine_specs(self.args, sysinfo)
|
||||
arch = self.mspec.gpu_arch
|
||||
|
||||
soc_module = importlib.import_module(f"rocprof_compute_soc.soc_{arch}")
|
||||
soc_class = getattr(soc_module, f"{arch}_soc")
|
||||
self.soc[arch] = soc_class(self.args, self.mspec)
|
||||
|
||||
self.notify(f"Loaded system specs for {arch}", severity="information")
|
||||
|
||||
except Exception as e:
|
||||
self.notify(f"Failed to load system specs: {e}", severity="error")
|
||||
raise
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Recent directories management
|
||||
# -------------------------------------------------------------------------
|
||||
def _load_recent_dirs(self) -> list[str]:
|
||||
recent_file = Path.home() / ".textual_browser_recent.json"
|
||||
if recent_file.exists():
|
||||
@@ -114,14 +123,16 @@ class RocprofTUIApp(App):
|
||||
self.recent_dirs = self.recent_dirs[:5]
|
||||
self._save_recent_dirs()
|
||||
|
||||
@on(Button.Pressed, "#menu-open-workload")
|
||||
@work
|
||||
async def pick_directory(self) -> None:
|
||||
if opened := await self.push_screen_wait(SelectDirectory()):
|
||||
self.add_recent_dir(str(opened))
|
||||
self.main_view.selected_path = opened
|
||||
self.query_one("#file-dropdown", DropdownMenu).add_class("hidden")
|
||||
self.main_view.run_analysis()
|
||||
def on_recent_selected(self, selected_dir: Optional[str]) -> None:
|
||||
if not selected_dir:
|
||||
self.notify("Directory selection cancelled", severity="information")
|
||||
return
|
||||
|
||||
if Path(selected_dir) != self.main_view.selected_path:
|
||||
self.main_view.selected_path = Path(selected_dir)
|
||||
|
||||
self.notify(f"Selected: {selected_dir}", severity="information")
|
||||
self.main_view.run_analysis()
|
||||
|
||||
|
||||
def run_tui(
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
# THE SOFTWARE.
|
||||
|
||||
##############################################################################
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Hashable
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
@@ -74,16 +76,40 @@ class Logger:
|
||||
}
|
||||
self.logger.log(level_map[log_level], message)
|
||||
|
||||
if update_ui and self.output_area and hasattr(self.output_area, "text"):
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
formatted_msg = f"[{timestamp}] [{log_level}] {message}"
|
||||
self.output_area.text = (
|
||||
f"{self.output_area.text}\n{formatted_msg}"
|
||||
if self.output_area.text
|
||||
else formatted_msg
|
||||
)
|
||||
if (
|
||||
not update_ui
|
||||
or not self.output_area
|
||||
or not hasattr(self.output_area, "text")
|
||||
):
|
||||
return
|
||||
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
formatted_msg = f"[{timestamp}] [{log_level}] {message}"
|
||||
app = getattr(self.output_area, "app", None)
|
||||
|
||||
if app is None or not hasattr(app, "_thread_id"):
|
||||
# app not ready yet — update immediately (safe during compose)
|
||||
if self.output_area.text:
|
||||
self.output_area.text += "\n" + formatted_msg
|
||||
else:
|
||||
self.output_area.text = formatted_msg
|
||||
return
|
||||
|
||||
# Detect if we are on UI thread
|
||||
in_ui_thread = threading.get_ident() == app._thread_id
|
||||
|
||||
def _apply() -> None:
|
||||
if self.output_area.text:
|
||||
self.output_area.text += "\n" + formatted_msg
|
||||
else:
|
||||
self.output_area.text = formatted_msg
|
||||
self.output_area.cursor_location = (999999, 0)
|
||||
|
||||
if in_ui_thread:
|
||||
_apply()
|
||||
else:
|
||||
app.call_from_thread(_apply)
|
||||
|
||||
def info(self, message: str, update_ui: bool = True) -> None:
|
||||
self.log(message, "INFO", update_ui)
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ Main View Module
|
||||
Contains the main view layout and organization for the application.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -112,62 +114,155 @@ class MainView(Horizontal):
|
||||
|
||||
@work(thread=True)
|
||||
def run_analysis(self) -> None:
|
||||
self.kernel_to_df_dict = {}
|
||||
self.top_kernel_to_df_list = []
|
||||
"""
|
||||
Run analysis in a background worker thread.
|
||||
All UI updates are marshalled back onto the main thread.
|
||||
"""
|
||||
|
||||
if not self.selected_path:
|
||||
self._update_kernel_view(
|
||||
"No directory selected for analysis", LogLevel.ERROR
|
||||
)
|
||||
# Capture selected path at the beginning to avoid races
|
||||
selected = self.selected_path
|
||||
|
||||
# -----------------------------
|
||||
# 1. No directory selected
|
||||
# -----------------------------
|
||||
if not selected:
|
||||
|
||||
def ui_no_directory() -> None:
|
||||
self.app.notify(
|
||||
"No directory selected for analysis", severity="warning"
|
||||
)
|
||||
self._update_kernel_view(
|
||||
"No directory selected for analysis", LogLevel.ERROR
|
||||
)
|
||||
|
||||
self.app.call_from_thread(ui_no_directory)
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(f"Starting analysis on: {self.selected_path}")
|
||||
# Reset analysis results on the UI thread before starting
|
||||
def ui_reset_before_analysis() -> None:
|
||||
self.kernel_to_df_dict = {}
|
||||
self.top_kernel_to_df_list = []
|
||||
self.logger.info(f"Starting analysis on: {selected}")
|
||||
self.logger.info("Loading...")
|
||||
|
||||
self.app.notify(f"Running analysis on: {selected}", severity="information")
|
||||
self._update_kernel_view(
|
||||
f"Running analysis on: {self.selected_path}", LogLevel.SUCCESS
|
||||
f"Running analysis on: {selected}", LogLevel.SUCCESS
|
||||
)
|
||||
|
||||
# 1. Create and TUI analyzer
|
||||
self.app.call_from_thread(ui_reset_before_analysis)
|
||||
|
||||
try:
|
||||
# ------------------------------------
|
||||
# 2. Initialize analyzer
|
||||
# ------------------------------------
|
||||
analyzer = tui_analysis(
|
||||
self.app.args, self.app.supported_archs, str(self.selected_path)
|
||||
self.app.args, self.app.supported_archs, str(selected)
|
||||
)
|
||||
analyzer.sanitize()
|
||||
|
||||
# 2. Load and process system info and Configure SoC
|
||||
sysinfo_path = self.selected_path / "sysinfo.csv"
|
||||
# ------------------------------------
|
||||
# 3. Load sysinfo
|
||||
# ------------------------------------
|
||||
sysinfo_path = selected / "sysinfo.csv"
|
||||
if not sysinfo_path.exists():
|
||||
raise FileNotFoundError(f"sysinfo.csv not found at {sysinfo_path}")
|
||||
# Let the UI thread handle the error and reset state
|
||||
error = FileNotFoundError(f"sysinfo.csv not found at {sysinfo_path}")
|
||||
tb = traceback.format_exc()
|
||||
|
||||
def ui_missing_sysinfo() -> None:
|
||||
error_msg = f"Analysis failed: {error}"
|
||||
self.logger.error(f"{error_msg}\n{tb}")
|
||||
self.app.notify(
|
||||
f"sysinfo.csv not found at: {sysinfo_path}", severity="error"
|
||||
)
|
||||
self.kernel_to_df_dict = {}
|
||||
self.top_kernel_to_df_list = []
|
||||
self._update_kernel_view(error_msg, LogLevel.ERROR)
|
||||
|
||||
self.app.call_from_thread(ui_missing_sysinfo)
|
||||
return
|
||||
|
||||
sys_info = file_io.load_sys_info(str(sysinfo_path)).iloc[0].to_dict()
|
||||
self.app.load_soc_specs(sys_info)
|
||||
analyzer.set_soc(self.app.soc)
|
||||
|
||||
# 3. run analysis
|
||||
# ------------------------------------
|
||||
# 4. Run preprocessing
|
||||
# ------------------------------------
|
||||
analyzer.pre_processing()
|
||||
self.kernel_to_df_dict = analyzer.run_kernel_analysis()
|
||||
self.top_kernel_to_df_list = analyzer.run_top_kernel()
|
||||
|
||||
if not self.kernel_to_df_dict or not self.top_kernel_to_df_list:
|
||||
self._update_kernel_view(
|
||||
"Analysis completed but not all data was returned", LogLevel.WARNING
|
||||
)
|
||||
else:
|
||||
self.app.call_from_thread(self.refresh_results)
|
||||
self.logger.info("Kernel Analysis completed successfully")
|
||||
def ui_after_preprocessing() -> None:
|
||||
self.app.notify("Profiling data loaded", severity="information")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.app.call_from_thread(ui_after_preprocessing)
|
||||
|
||||
# ------------------------------------
|
||||
# 5. Kernel analysis (heavy work)
|
||||
# ------------------------------------
|
||||
kernel_to_df_dict = analyzer.run_kernel_analysis()
|
||||
top_kernel_to_df_list = analyzer.run_top_kernel()
|
||||
|
||||
# ------------------------------------
|
||||
# 6. Pass results to UI thread
|
||||
# ------------------------------------
|
||||
self.app.call_from_thread(
|
||||
self._analysis_success,
|
||||
kernel_to_df_dict,
|
||||
top_kernel_to_df_list,
|
||||
)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
tb = traceback.format_exc()
|
||||
error_msg = f"Analysis failed: {str(e)}"
|
||||
self.logger.error(f"{error_msg}\n{traceback.format_exc()}")
|
||||
self._update_kernel_view(error_msg, LogLevel.ERROR)
|
||||
|
||||
def ui_error_handler() -> None:
|
||||
# Clear in-memory results
|
||||
self.kernel_to_df_dict = {}
|
||||
self.top_kernel_to_df_list = []
|
||||
|
||||
# Log, notify, and update kernel view safely
|
||||
self.logger.error(f"{error_msg}\n{tb}")
|
||||
self.app.notify(error_msg, severity="error")
|
||||
self._update_kernel_view(error_msg, LogLevel.ERROR)
|
||||
|
||||
self.app.call_from_thread(ui_error_handler)
|
||||
|
||||
def _analysis_success(
|
||||
self,
|
||||
kernel_to_df_dict: dict[str, dict[str, Any]],
|
||||
top_kernel_to_df_list: list[dict[str, Any]],
|
||||
) -> None:
|
||||
self.kernel_to_df_dict = kernel_to_df_dict or {}
|
||||
self.top_kernel_to_df_list = top_kernel_to_df_list or []
|
||||
|
||||
if not self.kernel_to_df_dict or not self.top_kernel_to_df_list:
|
||||
self.app.notify(
|
||||
"Analysis completed but not all data was produced",
|
||||
severity="warning",
|
||||
)
|
||||
self._update_kernel_view(
|
||||
"Analysis completed but not all data was returned", LogLevel.WARNING
|
||||
)
|
||||
else:
|
||||
self.refresh_results()
|
||||
self.logger.info("Kernel Analysis completed successfully")
|
||||
self.app.notify("Kernel analysis completed", severity="information")
|
||||
|
||||
def _update_kernel_view(self, message: str, log_level: LogLevel) -> None:
|
||||
self.app.call_from_thread(
|
||||
lambda: self.query_one("#kernel-view").update_view(message, log_level)
|
||||
)
|
||||
app = self.app
|
||||
|
||||
# detect thread
|
||||
in_ui_thread = threading.get_ident() == app._thread_id
|
||||
|
||||
def apply() -> None:
|
||||
view = self.query_one("#kernel-view")
|
||||
if view:
|
||||
view.update_view(message, log_level)
|
||||
|
||||
if in_ui_thread:
|
||||
apply()
|
||||
else:
|
||||
app.call_from_thread(apply)
|
||||
|
||||
def refresh_results(self) -> None:
|
||||
kernel_view = self.query_one("#kernel-view")
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
##############################################################################
|
||||
# MIT License
|
||||
#
|
||||
# Copyright (c) 2025 Advanced Micro Devices, Inc. All Rights Reserved.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
##############################################################################
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import DirectoryTree, Label, Static
|
||||
|
||||
from rocprof_compute_tui.widgets.instant_button import InstantButton
|
||||
|
||||
|
||||
class DirectoryPicker(ModalScreen[Optional[Path]]):
|
||||
DEFAULT_CSS = """
|
||||
DirectoryPicker {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#picker-container {
|
||||
width: 90;
|
||||
height: 35;
|
||||
background: $surface;
|
||||
border: thick $primary;
|
||||
}
|
||||
|
||||
#picker-header {
|
||||
dock: top;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: $primary;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
#picker-title {
|
||||
width: 100%;
|
||||
content-align: center middle;
|
||||
text-style: bold;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
#breadcrumb-container {
|
||||
dock: top;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 1;
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
#breadcrumb {
|
||||
width: 100%;
|
||||
color: $text;
|
||||
content-align: left middle;
|
||||
}
|
||||
|
||||
#picker-content {
|
||||
width: 100%;
|
||||
height: 1fr;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
#dir-tree {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: round $primary-darken-2;
|
||||
}
|
||||
|
||||
#picker-footer {
|
||||
dock: bottom;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 1;
|
||||
background: $surface-darken-1;
|
||||
}
|
||||
|
||||
#selection-info {
|
||||
dock: top;
|
||||
width: 100%;
|
||||
padding: 0 1;
|
||||
color: $success;
|
||||
text-style: italic;
|
||||
}
|
||||
|
||||
#picker-buttons {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
align: center middle;
|
||||
padding: 1 0;
|
||||
}
|
||||
|
||||
#picker-buttons InstantButton {
|
||||
margin: 0 1;
|
||||
min-width: 16;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, start_path: Optional[Path] = None) -> None:
|
||||
super().__init__()
|
||||
self.start_path = start_path or Path.cwd()
|
||||
self.selected_path: Optional[Path] = self.start_path
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="picker-container"):
|
||||
with Container(id="picker-header"):
|
||||
yield Label("📁 Select Directory", id="picker-title")
|
||||
|
||||
with Container(id="breadcrumb-container"):
|
||||
yield Static(self._format_breadcrumb(self.start_path), id="breadcrumb")
|
||||
|
||||
with Container(id="picker-content"):
|
||||
yield DirectoryTree(str(self.start_path), id="dir-tree")
|
||||
|
||||
with Container(id="picker-footer"):
|
||||
yield Static("", id="selection-info")
|
||||
with Horizontal(id="picker-buttons"):
|
||||
yield InstantButton("Select", id="select-dir", classes="primary")
|
||||
yield InstantButton("Cancel", id="cancel-dir", classes="error")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
tree = self.query_one("#dir-tree", DirectoryTree)
|
||||
tree.show_root = True
|
||||
tree.show_guides = True
|
||||
tree.focus()
|
||||
self._update_selection_info()
|
||||
|
||||
def _format_breadcrumb(self, path: Path) -> str:
|
||||
parts = list(path.parts)
|
||||
if len(parts) > 5:
|
||||
return f"{parts[0]} / ... / {' / '.join(parts[-4:])}"
|
||||
return str(path)
|
||||
|
||||
def _update_selection_info(self) -> None:
|
||||
info = self.query_one("#selection-info", Static)
|
||||
if self.selected_path:
|
||||
info.update(f"Selected: {self.selected_path.name} ({self.selected_path})")
|
||||
else:
|
||||
info.update("No directory selected")
|
||||
|
||||
@on(DirectoryTree.DirectorySelected)
|
||||
def on_directory_selected(self, event: DirectoryTree.DirectorySelected) -> None:
|
||||
"""Update selection when a directory is selected in the tree."""
|
||||
self.selected_path = event.path
|
||||
breadcrumb = self.query_one("#breadcrumb", Static)
|
||||
breadcrumb.update(self._format_breadcrumb(event.path))
|
||||
self._update_selection_info()
|
||||
|
||||
def on_instant_button_instant_pressed(
|
||||
self, event: InstantButton.InstantPressed
|
||||
) -> None:
|
||||
"""Handle instant button presses in this picker."""
|
||||
bid = event.button.id
|
||||
|
||||
if bid == "select-dir":
|
||||
event.stop()
|
||||
if self.selected_path:
|
||||
self.dismiss(self.selected_path)
|
||||
else:
|
||||
# Nothing selected; keep modal open and notify user.
|
||||
self.notify("No directory selected", severity="warning")
|
||||
|
||||
elif bid == "cancel-dir":
|
||||
event.stop()
|
||||
self.dismiss(None)
|
||||
|
||||
def on_key(self, event) -> None: # noqa: ANN001
|
||||
if event.key == "enter":
|
||||
button = self.query_one("#select-dir", InstantButton)
|
||||
self.on_instant_button_instant_pressed(InstantButton.InstantPressed(button))
|
||||
elif event.key == "escape":
|
||||
button = self.query_one("#cancel-dir", InstantButton)
|
||||
self.on_instant_button_instant_pressed(InstantButton.InstantPressed(button))
|
||||
@@ -0,0 +1,48 @@
|
||||
##############################################################################
|
||||
# MIT License
|
||||
#
|
||||
# Copyright (c) 2025 Advanced Micro Devices, Inc. All Rights Reserved.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
##############################################################################
|
||||
|
||||
from textual.message import Message
|
||||
from textual.widgets import Button
|
||||
|
||||
|
||||
class InstantButton(Button):
|
||||
"""
|
||||
A button that fires exactly once per *click* using Textual's press semantics.
|
||||
"""
|
||||
|
||||
class InstantPressed(Message):
|
||||
"""Custom message fired once for each successful button press."""
|
||||
|
||||
def __init__(self, button: "InstantButton") -> None:
|
||||
super().__init__()
|
||||
self.button = button # the button that was pressed
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Translate Textual's `Button.Pressed` into `InstantPressed`."""
|
||||
if event.button is not self:
|
||||
return
|
||||
|
||||
event.stop()
|
||||
|
||||
self.post_message(self.InstantPressed(self))
|
||||
+197
-32
@@ -22,87 +22,252 @@
|
||||
# THE SOFTWARE.
|
||||
##############################################################################
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from __future__ import annotations
|
||||
|
||||
from textual import on
|
||||
from typing import Any
|
||||
|
||||
from textual import on, work
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, Horizontal
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Button
|
||||
|
||||
from rocprof_compute_tui.widgets.directory_picker import DirectoryPicker
|
||||
from rocprof_compute_tui.widgets.recent_directories import RecentDirectoriesScreen
|
||||
|
||||
|
||||
class DropdownMenu(Container):
|
||||
BINDINGS = [
|
||||
Binding("escape", "close_menu", "Close", show=False),
|
||||
]
|
||||
|
||||
class Closed(Message):
|
||||
"""Posted when dropdown is closed."""
|
||||
|
||||
pass
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the dropdown menu with menu items."""
|
||||
yield Button("Open Workload", id="menu-open-workload", classes="menu-item")
|
||||
yield Button("Open Recent", id="menu-open-recent", classes="menu-item")
|
||||
# TODO:
|
||||
# yield Button("Attach", id="menu-attach", classes="menu-item")
|
||||
yield Button("Exit", id="menu-exit", classes="menu-item")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.add_class("hidden")
|
||||
self.display = False
|
||||
self._apply_hidden_state()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Visibility helpers
|
||||
# -------------------------------------------------------------------------
|
||||
def _apply_visible_state(self) -> None:
|
||||
"""Ensure the menu is visible and hit-testable."""
|
||||
styles = self.styles
|
||||
styles.pointer_events = "auto"
|
||||
styles.visibility = "visible"
|
||||
styles.opacity = 1.0
|
||||
|
||||
def _apply_hidden_state(self) -> None:
|
||||
"""Ensure the menu is completely removed from hit-testing."""
|
||||
styles = self.styles
|
||||
styles.pointer_events = "none"
|
||||
styles.visibility = "hidden"
|
||||
styles.opacity = 0.0
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Public show/hide API
|
||||
# -------------------------------------------------------------------------
|
||||
def show(self) -> None:
|
||||
"""Show the dropdown and make it focusable + hit-testable."""
|
||||
self.display = True
|
||||
self._apply_visible_state()
|
||||
self.refresh(layout=True)
|
||||
self.focus()
|
||||
|
||||
def hide(self) -> None:
|
||||
"""Hide the dropdown and remove it from hit-testing."""
|
||||
self.display = False
|
||||
self._apply_hidden_state()
|
||||
self.refresh(layout=True)
|
||||
self.post_message(self.Closed())
|
||||
|
||||
def action_close_menu(self) -> None:
|
||||
self.hide()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Focus handling: close when focus leaves menu & menu button
|
||||
# -------------------------------------------------------------------------
|
||||
def on_blur(self) -> None:
|
||||
# Check if focus moved to a child or the parent menu button
|
||||
if self.display:
|
||||
# Use call_later to allow focus to settle first
|
||||
self.call_later(self._check_focus_and_close)
|
||||
|
||||
def _check_focus_and_close(self) -> None:
|
||||
focused = self.app.focused
|
||||
# Don't close if focus is on a menu item or the menu button
|
||||
if focused is None:
|
||||
self.hide()
|
||||
return
|
||||
if not (
|
||||
self.is_ancestor_of(focused)
|
||||
or (hasattr(focused, "id") and focused.id == "menu-file")
|
||||
):
|
||||
self.hide()
|
||||
|
||||
def is_ancestor_of(self, widget) -> bool: # noqa: ANN001
|
||||
current = widget
|
||||
while current is not None:
|
||||
if current is self:
|
||||
return True
|
||||
current = current.parent
|
||||
return False
|
||||
|
||||
|
||||
class MenuButton(Button):
|
||||
is_open = reactive(False)
|
||||
"""Menu button with reactive open state and proper sync with DropdownMenu."""
|
||||
|
||||
is_open: reactive[bool] = reactive(False, init=False)
|
||||
|
||||
def __init__(self, label: str, menu_id: str, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(label, *args, **kwargs)
|
||||
self.menu_id = menu_id
|
||||
self._dropdown: DropdownMenu | None = None
|
||||
|
||||
def on_click(self) -> None:
|
||||
self.is_open = not self.is_open
|
||||
dropdown = self.app.query_one(f"#{self.menu_id}", DropdownMenu)
|
||||
def on_mount(self) -> None:
|
||||
# IMPORTANT: delay lookup until after the full DOM is built
|
||||
def late_init() -> None:
|
||||
try:
|
||||
self._dropdown = self.app.query_one(f"#{self.menu_id}", DropdownMenu)
|
||||
except Exception:
|
||||
self._dropdown = None
|
||||
|
||||
if self.is_open:
|
||||
dropdown.remove_class("hidden")
|
||||
self.call_later(late_init)
|
||||
|
||||
def watch_is_open(self, value: bool) -> None:
|
||||
"""React to is_open changes by showing/hiding the dropdown."""
|
||||
if self._dropdown is None:
|
||||
return
|
||||
|
||||
if value:
|
||||
self._dropdown.show()
|
||||
self.add_class("-active")
|
||||
else:
|
||||
dropdown.add_class("hidden")
|
||||
self._dropdown.hide()
|
||||
self.remove_class("-active")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Toggle dropdown on press."""
|
||||
if event.button is not self:
|
||||
return
|
||||
event.stop() # Prevent event bubbling
|
||||
self.is_open = not self.is_open
|
||||
|
||||
@on(DropdownMenu.Closed)
|
||||
def on_dropdown_closed(self, event: DropdownMenu.Closed) -> None: # noqa: ARG002
|
||||
self.is_open = False
|
||||
|
||||
|
||||
class MenuBar(Container):
|
||||
"""A menu bar that spans the width of the app."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "close_all_menus", "Close menus", show=False),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Horizontal(
|
||||
MenuButton("File", "file-dropdown", id="menu-file"), id="menu-buttons"
|
||||
MenuButton("File", "file-dropdown", id="menu-file"),
|
||||
id="menu-buttons",
|
||||
)
|
||||
|
||||
with Container(id="dropdown-container"):
|
||||
# This ID is used everywhere; do not change lightly
|
||||
yield DropdownMenu(id="file-dropdown")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.border_title = "MENU BAR"
|
||||
self.add_class("section")
|
||||
self.parent_main_view = self.screen.query_one("#main-container", Horizontal)
|
||||
|
||||
def action_close_all_menus(self) -> None:
|
||||
"""Close all open menus in the bar."""
|
||||
for menu_btn in self.query(MenuButton):
|
||||
menu_btn.is_open = False
|
||||
|
||||
def close_dropdown(self) -> None:
|
||||
"""Close the File menu dropdown."""
|
||||
menu_button = self.query_one("#menu-file", MenuButton)
|
||||
menu_button.is_open = False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Menu item actions
|
||||
# -------------------------------------------------------------------------
|
||||
@on(Button.Pressed, "#menu-open-workload")
|
||||
def open_workload(self, event: Button.Pressed) -> None:
|
||||
"""Open the directory picker for workload selection."""
|
||||
event.stop()
|
||||
self.close_dropdown()
|
||||
self._start_pick_directory()
|
||||
|
||||
@on(Button.Pressed, "#menu-open-recent")
|
||||
def show_recent(self) -> None:
|
||||
def show_recent(self, event: Button.Pressed) -> None: # noqa: ARG002
|
||||
"""Open the Recent Directories screen."""
|
||||
if not self.app.recent_dirs:
|
||||
self.notify("No recent directories found", severity="warning")
|
||||
return
|
||||
|
||||
def on_recent_selected(selected_dir: Optional[str]) -> None:
|
||||
if selected_dir:
|
||||
path_obj = Path(selected_dir)
|
||||
if path_obj.exists():
|
||||
self.parent_main_view.selected_path = path_obj
|
||||
self.query_one("#file-dropdown", DropdownMenu).add_class("hidden")
|
||||
self.parent_main_view.run_analysis()
|
||||
else:
|
||||
# Remove non-existent path from recent dirs
|
||||
if selected_dir in self.app.recent_dirs:
|
||||
self.app.recent_dirs.remove(selected_dir)
|
||||
self.app._save_recent_dirs()
|
||||
# Close the dropdown when opening a modal
|
||||
self.close_dropdown()
|
||||
|
||||
self.app.push_screen(
|
||||
RecentDirectoriesScreen(self.app.recent_dirs), on_recent_selected
|
||||
RecentDirectoriesScreen(self.app.recent_dirs),
|
||||
self.app.on_recent_selected,
|
||||
)
|
||||
|
||||
@on(Button.Pressed, "#menu-exit")
|
||||
def exit_app(self) -> None:
|
||||
def exit_app(self, event: Button.Pressed) -> None: # noqa: ARG002
|
||||
"""Exit the application."""
|
||||
self.app.exit()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Asynchronous directory picker workflow (moved from App into MenuBar)
|
||||
# -------------------------------------------------------------------------
|
||||
@work
|
||||
async def _start_pick_directory(self) -> None:
|
||||
"""Open directory picker and handle selection."""
|
||||
app = self.app
|
||||
|
||||
try:
|
||||
picker = DirectoryPicker()
|
||||
opened = await app.push_screen_wait(picker)
|
||||
if opened:
|
||||
app.log(f"Directory selected: {opened}")
|
||||
app.notify(f"Selected directory: {opened}", severity="information")
|
||||
|
||||
app.add_recent_dir(str(opened))
|
||||
app.main_view.selected_path = opened
|
||||
|
||||
app.notify("Running analysis…", severity="information")
|
||||
app.main_view.run_analysis()
|
||||
else:
|
||||
app.log("Directory selection cancelled")
|
||||
app.notify("Directory selection cancelled", severity="information")
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
app.log(f"Error in directory picker: {e}")
|
||||
app.notify(f"Error opening directory picker: {e}", severity="error")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Click outside to close menu
|
||||
# -------------------------------------------------------------------------
|
||||
def on_click(self, event) -> None: # noqa: ANN001
|
||||
"""Close menus when clicking outside dropdown area."""
|
||||
menu_btn = self.query_one("#menu-file", MenuButton)
|
||||
dropdown = self.query_one("#file-dropdown", DropdownMenu)
|
||||
|
||||
if menu_btn.is_open and dropdown.display:
|
||||
# Get click coordinates relative to widgets
|
||||
click_in_dropdown = dropdown.region.contains_point(event.screen_offset)
|
||||
click_in_button = menu_btn.region.contains_point(event.screen_offset)
|
||||
|
||||
if not click_in_dropdown and not click_in_button:
|
||||
menu_btn.is_open = False
|
||||
|
||||
+104
-16
@@ -26,38 +26,126 @@
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Label, ListItem, ListView
|
||||
from textual.widgets import Label, ListItem, ListView
|
||||
|
||||
from rocprof_compute_tui.widgets.instant_button import InstantButton
|
||||
|
||||
|
||||
class ClickableListItem(ListItem):
|
||||
"""A ListItem that highlights on single click without selecting."""
|
||||
|
||||
def on_mouse_down(self, event) -> None: # noqa: ANN001
|
||||
event.stop()
|
||||
|
||||
# Find parent ListView
|
||||
parent = self.parent
|
||||
list_view = None
|
||||
while parent is not None:
|
||||
if isinstance(parent, ListView):
|
||||
list_view = parent
|
||||
break
|
||||
parent = parent.parent
|
||||
|
||||
if list_view is None:
|
||||
return
|
||||
|
||||
# Determine index
|
||||
try:
|
||||
index = list_view.children.index(self)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
# Update highlight immediately
|
||||
list_view.index = index
|
||||
list_view.refresh(layout=True)
|
||||
self.refresh(layout=True)
|
||||
|
||||
def on_click(self, event) -> None: # noqa: ANN001
|
||||
event.stop()
|
||||
|
||||
def on_mouse_up(self, event) -> None: # noqa: ANN001
|
||||
event.stop()
|
||||
|
||||
|
||||
class RecentDirectoriesScreen(ModalScreen):
|
||||
"""Modal screen to display recent directories."""
|
||||
"""Modal screen to display recent directories as clickable list items."""
|
||||
|
||||
def __init__(self, recent_dirs: list[str]) -> None:
|
||||
def __init__(self, recent_dirs: list[str], current_dir: str | None = None) -> None:
|
||||
super().__init__()
|
||||
self.recent_dirs = recent_dirs
|
||||
self.current_dir = current_dir
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Compose UI
|
||||
# -------------------------------------------------------------------------
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="recent-modal"):
|
||||
yield Label("Recent Directories", id="recent-title")
|
||||
|
||||
if self.recent_dirs:
|
||||
with ListView(id="recent-list"):
|
||||
for directory in self.recent_dirs:
|
||||
yield ListItem(Label(directory))
|
||||
for i, directory in enumerate(self.recent_dirs):
|
||||
# Normal-looking list item (NO InstantButton inside)
|
||||
yield ClickableListItem(
|
||||
Label(directory),
|
||||
id=f"recent-row-{i}",
|
||||
)
|
||||
else:
|
||||
yield Label("No recent directories found", id="no-recent")
|
||||
|
||||
with Horizontal(id="recent-buttons"):
|
||||
yield Button("Select", variant="primary", id="select-recent")
|
||||
yield Button("Close", variant="default", id="close-recent")
|
||||
yield InstantButton("Select", variant="primary", id="select-recent")
|
||||
yield InstantButton("Close", variant="default", id="close-recent")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "close-recent":
|
||||
self.dismiss()
|
||||
elif event.button.id == "select-recent":
|
||||
# -------------------------------------------------------------------------
|
||||
# Initialize highlight when screen opens
|
||||
# -------------------------------------------------------------------------
|
||||
def on_mount(self) -> None:
|
||||
if not self.recent_dirs:
|
||||
return
|
||||
|
||||
list_view = self.query_one("#recent-list", ListView)
|
||||
|
||||
# Default to first item
|
||||
index = 0
|
||||
|
||||
# If current_dir matches one of the recent dirs
|
||||
if self.current_dir:
|
||||
try:
|
||||
index = self.recent_dirs.index(self.current_dir)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
list_view.index = index
|
||||
list_view.focus()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Buttons
|
||||
# -------------------------------------------------------------------------
|
||||
def on_instant_button_instant_pressed(
|
||||
self, event: InstantButton.InstantPressed
|
||||
) -> None:
|
||||
"""Handle Select / Close / row-selection."""
|
||||
button = event.button
|
||||
bid = button.id
|
||||
|
||||
# Close
|
||||
if bid == "close-recent":
|
||||
event.stop()
|
||||
self.dismiss(None)
|
||||
return
|
||||
|
||||
# Select highlighted row
|
||||
if bid == "select-recent":
|
||||
event.stop()
|
||||
list_view = self.query_one("#recent-list", ListView)
|
||||
if list_view.highlighted_child:
|
||||
selected_dir = self.recent_dirs[list_view.index or 0]
|
||||
self.dismiss(selected_dir)
|
||||
idx = list_view.index or 0
|
||||
self.dismiss(self.recent_dirs[idx])
|
||||
return
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Keyboard activation (Enter or double-click)
|
||||
# -------------------------------------------------------------------------
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
selected_dir = self.recent_dirs[event.list_view.index or 0]
|
||||
self.dismiss(selected_dir)
|
||||
idx = event.list_view.index or 0
|
||||
self.dismiss(self.recent_dirs[idx])
|
||||
|
||||
مرجع در شماره جدید
Block a user