[rocprofiler-compute][tui] menu bar lag fix (#1942)

This commit is contained in:
xuchen-amd
2025-12-16 17:02:27 -05:00
کامیت شده توسط GitHub
والد 3a3738ad98
کامیت c738e73d99
11فایلهای تغییر یافته به همراه803 افزوده شده و 137 حذف شده
@@ -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))
@@ -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
@@ -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])