From c738e73d9950bf696e0d2a3b96a102d824471d27 Mon Sep 17 00:00:00 2001 From: xuchen-amd Date: Tue, 16 Dec 2025 17:02:27 -0500 Subject: [PATCH] [rocprofiler-compute][tui] menu bar lag fix (#1942) --- projects/rocprofiler-compute/CHANGELOG.md | 4 + projects/rocprofiler-compute/LICENSE.md | 1 - projects/rocprofiler-compute/requirements.txt | 1 - .../src/rocprof_compute_tui/assets/style.css | 95 +++++--- .../src/rocprof_compute_tui/tui_app.py | 47 ++-- .../rocprof_compute_tui/utils/tui_utils.py | 42 +++- .../rocprof_compute_tui/views/main_view.py | 159 +++++++++--- .../widgets/directory_picker.py | 194 +++++++++++++++ .../widgets/instant_button.py | 48 ++++ .../widgets/menu_bar/menu_bar.py | 229 +++++++++++++++--- .../widgets/recent_directories.py | 120 +++++++-- 11 files changed, 803 insertions(+), 137 deletions(-) create mode 100644 projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/directory_picker.py create mode 100644 projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/instant_button.py diff --git a/projects/rocprofiler-compute/CHANGELOG.md b/projects/rocprofiler-compute/CHANGELOG.md index 01447028ad..e6aa2428f4 100644 --- a/projects/rocprofiler-compute/CHANGELOG.md +++ b/projects/rocprofiler-compute/CHANGELOG.md @@ -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 diff --git a/projects/rocprofiler-compute/LICENSE.md b/projects/rocprofiler-compute/LICENSE.md index 0039180502..d795a2150a 100644 --- a/projects/rocprofiler-compute/LICENSE.md +++ b/projects/rocprofiler-compute/LICENSE.md @@ -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 diff --git a/projects/rocprofiler-compute/requirements.txt b/projects/rocprofiler-compute/requirements.txt index fa5ea570c3..87946f2b40 100644 --- a/projects/rocprofiler-compute/requirements.txt +++ b/projects/rocprofiler-compute/requirements.txt @@ -16,5 +16,4 @@ sqlalchemy>=2.0.42 tabulate textual textual_plotext -textual-fspicker>=0.4.3 tqdm diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/assets/style.css b/projects/rocprofiler-compute/src/rocprof_compute_tui/assets/style.css index 373a45516c..48776542bf 100644 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/assets/style.css +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/assets/style.css @@ -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; } -} \ No newline at end of file +} diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/tui_app.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/tui_app.py index c1088b47c3..05f1bc1f4d 100644 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/tui_app.py +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/tui_app.py @@ -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( diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/utils/tui_utils.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/utils/tui_utils.py index 9cc1f0e1e6..9536a77122 100644 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/utils/tui_utils.py +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/utils/tui_utils.py @@ -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) diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/views/main_view.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/views/main_view.py index d36c4624b4..07a4c9db4c 100644 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/views/main_view.py +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/views/main_view.py @@ -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") diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/directory_picker.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/directory_picker.py new file mode 100644 index 0000000000..d45e981522 --- /dev/null +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/directory_picker.py @@ -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)) diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/instant_button.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/instant_button.py new file mode 100644 index 0000000000..f7252398a7 --- /dev/null +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/instant_button.py @@ -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)) diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/menu_bar/menu_bar.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/menu_bar/menu_bar.py index ca2411bde6..3e40bb2b5d 100644 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/menu_bar/menu_bar.py +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/menu_bar/menu_bar.py @@ -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 diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/recent_directories.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/recent_directories.py index 5198fbcf51..2b1d6bae18 100644 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/recent_directories.py +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/recent_directories.py @@ -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])