From 36134cdfc9f873e1916a55d58dbcc54830b5c84c Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 17:09:11 +0000 Subject: [PATCH 01/23] fix: prevent log output from overlapping other widgets --- src/rigi/css/default.tcss | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/rigi/css/default.tcss b/src/rigi/css/default.tcss index f2e9511..4140f58 100644 --- a/src/rigi/css/default.tcss +++ b/src/rigi/css/default.tcss @@ -413,8 +413,8 @@ _ResizeHandle { } _ResizeHandle:hover { color: #58a6ff; } -_LogsView { layout: horizontal; height: 1fr; background: #0d1117; } -_LogsView #logs-output { width: 1fr; height: 1fr; background: #0d1117; } +_LogsView { layout: horizontal; height: 1fr; background: #0d1117; overflow: hidden; } +_LogsView #logs-output { width: 1fr; height: 1fr; background: #0d1117; overflow-y: auto; overflow-x: hidden; } _LogsView #logs-controls { width: 18; height: 1fr; @@ -441,7 +441,9 @@ _LogsView #logs-controls Button { padding: 0 1; } -RigiBottomPanel { height: 12; layout: vertical; background: #0d1117; } +RigiBottomPanel { height: 12; layout: vertical; background: #0d1117; overflow: hidden; } +#bp-logs { height: 1fr; layout: vertical; overflow: hidden; } +#bp-terminal { height: 1fr; layout: vertical; overflow: hidden; } RigiBottomPanel Tabs { height: 3; background: #161b22; padding: 0; dock: none; } RigiBottomPanel Tab { color: #8b949e; min-width: 12; } RigiBottomPanel Tab:hover { color: #e6edf3; } From ddd09762c09290f8f4edef40b3fca0f78a71f293 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 17:10:13 +0000 Subject: [PATCH 02/23] feat: add action menu widget with numbered items and color support --- src/rigi/core/app.py | 11 +++++ src/rigi/css/default.tcss | 15 +++++++ src/rigi/screens/__init__.py | 2 + src/rigi/screens/action_menu.py | 80 +++++++++++++++++++++++++++++++++ src/rigi/widgets/__init__.py | 8 ++++ src/rigi/widgets/action_menu.py | 72 +++++++++++++++++++++++++++++ 6 files changed, 188 insertions(+) create mode 100644 src/rigi/screens/action_menu.py create mode 100644 src/rigi/widgets/action_menu.py diff --git a/src/rigi/core/app.py b/src/rigi/core/app.py index 3618598..652f310 100644 --- a/src/rigi/core/app.py +++ b/src/rigi/core/app.py @@ -25,6 +25,7 @@ from rigi.core.dev_commands import register_dev_commands from rigi.core.settings_manager import SettingsManager from rigi.core.types import HandlerFn, HelpEntry, StatusItem, SubtabDef, TabDef +from rigi.screens.action_menu import RigiActionMenuScreen from rigi.screens.hamburger import RigiHamburgerScreen from rigi.screens.help import RigiHelpScreen from rigi.screens.settings import RigiSettingDef, RigiSettingsScreen @@ -33,6 +34,7 @@ from rigi.widgets.border_frame import RigiBorderFrame from rigi.widgets.bottom_panel import RigiBottomPanel from rigi.widgets.content_area import RigiContentArea +from rigi.widgets.action_menu import RigiActionMenuItemData from rigi.widgets.hamburger_menu import RigiMenuItemData from rigi.widgets.help_panel import RigiShortcutsBar, extract_help_annotation from rigi.widgets.notifications import RigiNotificationRack @@ -726,6 +728,15 @@ def open_url(self, url: str, *, new_tab: bool = True) -> None: def open_path(self, path: str | Path) -> bool: return _platform_utils.open_path(path) + def show_action_menu( + self, + items: list[RigiActionMenuItemData], + title: str = "", + x: int | None = None, + y: int | None = None, + ) -> None: + self.push_screen(RigiActionMenuScreen(items, title=title, anchor_x=x, anchor_y=y)) + def notify_desktop(self, title: str, body: str = "", urgency: str = "normal") -> bool: return _platform_utils.notify_desktop(title, body, urgency) diff --git a/src/rigi/css/default.tcss b/src/rigi/css/default.tcss index 4140f58..b6fa2f1 100644 --- a/src/rigi/css/default.tcss +++ b/src/rigi/css/default.tcss @@ -404,6 +404,21 @@ RigiMenuPanel { overflow-y: auto; } +RigiActionMenuPanel { + width: 30; + height: auto; + max-height: 20; + border: round #30363d; + border-title-color: #c9d1d9; + background: #0d1117; + padding: 0; + overflow-y: auto; +} +RigiActionMenuItem { height: 1; width: 100%; padding: 0 1; background: transparent; } +RigiActionMenuItem:hover { background: #1c2128; } +RigiActionMenuItem.--disabled { color: #3d444d; } +RigiActionMenuScreen { background: transparent; } + _ResizeHandle { height: 1; width: 100%; diff --git a/src/rigi/screens/__init__.py b/src/rigi/screens/__init__.py index f2e285a..078fa3c 100644 --- a/src/rigi/screens/__init__.py +++ b/src/rigi/screens/__init__.py @@ -1,5 +1,6 @@ """Rigi screen classes.""" +from rigi.screens.action_menu import RigiActionMenuScreen from rigi.screens.hamburger import RigiHamburgerScreen from rigi.screens.help import BUILTIN_SHORTCUTS, RigiHelpScreen from rigi.screens.settings import RigiSettingDef, RigiSettingsScreen @@ -10,4 +11,5 @@ "RigiSettingDef", "RigiSettingsScreen", "RigiHamburgerScreen", + "RigiActionMenuScreen", ] diff --git a/src/rigi/screens/action_menu.py b/src/rigi/screens/action_menu.py new file mode 100644 index 0000000..9c202dc --- /dev/null +++ b/src/rigi/screens/action_menu.py @@ -0,0 +1,80 @@ +"""RigiActionMenuScreen — modal popup action menu.""" + +from __future__ import annotations + +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.events import Click +from textual.screen import ModalScreen + +from rigi.widgets.action_menu import ( + RigiActionMenuItemData, + RigiActionMenuPanel, + _ActionItemClicked, +) + + +class RigiActionMenuScreen(ModalScreen[None]): + BINDINGS = [Binding("escape", "dismiss", show=False)] + + def __init__( + self, + items: list[RigiActionMenuItemData], + title: str = "", + anchor_x: int | None = None, + anchor_y: int | None = None, + ) -> None: + super().__init__() + self._items = items + self._title = title + self._anchor_x = anchor_x + self._anchor_y = anchor_y + + def compose(self) -> ComposeResult: + yield RigiActionMenuPanel(self._items, title=self._title, id="rigi-action-menu") + + def on_mount(self) -> None: + panel = self.query_one("#rigi-action-menu", RigiActionMenuPanel) + panel_w = 30 + panel_h = min(2 + len(self._items), 20) + app_w = self.app.size.width + app_h = self.app.size.height + + if self._anchor_x is not None and self._anchor_y is not None: + x = min(self._anchor_x, max(0, app_w - panel_w - 1)) + y = min(self._anchor_y, max(0, app_h - panel_h - 1)) + else: + x = max(0, (app_w - panel_w) // 2) + y = max(0, (app_h - panel_h) // 2) + + panel.styles.offset = (x, y) + panel.styles.width = panel_w + panel.styles.height = panel_h + + @on(_ActionItemClicked) + def on_item_clicked(self, event: _ActionItemClicked) -> None: + event.stop() + item = event.item + if item.callback is not None: + callback = item.callback + self.dismiss(None) + self.app.call_after_refresh(callback) + + def on_click(self, event: Click) -> None: + panel = self.query_one("#rigi-action-menu", RigiActionMenuPanel) + if not panel.region.contains(event.x, event.y): + self.dismiss(None) + + def action_dismiss(self) -> None: + self.dismiss(None) + + def on_key(self, event) -> None: + if hasattr(event, "key") and event.key.isdigit(): + idx = int(event.key) - 1 + if 0 <= idx < len(self._items): + item = self._items[idx] + if not item.disabled and item.callback is not None: + callback = item.callback + self.dismiss(None) + self.app.call_after_refresh(callback) diff --git a/src/rigi/widgets/__init__.py b/src/rigi/widgets/__init__.py index 6180730..bf5d8fb 100644 --- a/src/rigi/widgets/__init__.py +++ b/src/rigi/widgets/__init__.py @@ -35,6 +35,11 @@ Tree, ) +from rigi.widgets.action_menu import ( + RigiActionMenuItem, + RigiActionMenuItemData, + RigiActionMenuPanel, +) from rigi.widgets.border_frame import RigiBorderFrame from rigi.widgets.bottom_panel import RigiBottomPanel from rigi.widgets.content_area import RigiContentArea @@ -72,6 +77,9 @@ "RigiMenuPanel", "RigiHamburgerPanel", "RigiMenuItemData", + "RigiActionMenuItem", + "RigiActionMenuPanel", + "RigiActionMenuItemData", "RigiSettingsScreen", "RigiSettingDef", "RigiImage", diff --git a/src/rigi/widgets/action_menu.py b/src/rigi/widgets/action_menu.py new file mode 100644 index 0000000..8656ac6 --- /dev/null +++ b/src/rigi/widgets/action_menu.py @@ -0,0 +1,72 @@ +"""Action menu widget — vertical popup with numbered items and color support.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from textual.app import ComposeResult +from textual.events import Click +from textual.message import Message +from textual.widget import Widget +from textual.widgets import Label + + +@dataclass +class RigiActionMenuItemData: + label: str + callback: Callable[[], Any] | None = None + color: str | None = None + disabled: bool = False + + +class _ActionItemClicked(Message): + def __init__(self, item: RigiActionMenuItemData) -> None: + super().__init__() + self.item = item + + +class RigiActionMenuItem(Widget): + can_focus = False + + def __init__(self, item: RigiActionMenuItemData, number: int) -> None: + super().__init__() + self._item = item + self._number = number + + def compose(self) -> ComposeResult: + num_str = f"[dim]{self._number}.[/dim] " if self._number > 0 else "" + color_prefix = f"[{self._item.color}]" if self._item.color else "" + color_suffix = f"[/{self._item.color}]" if self._item.color else "" + label = self._item.label + if self._item.disabled: + label = f"[dim]{label}[/dim]" + yield Label(f"{num_str}{color_prefix}{label}{color_suffix}") + + def on_click(self, event: Click) -> None: + event.stop() + if not self._item.disabled and self._item.callback is not None: + self.post_message(_ActionItemClicked(self._item)) + + +class RigiActionMenuPanel(Widget): + def __init__( + self, + items: list[RigiActionMenuItemData], + title: str = "", + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._items = items + if title: + self.border_title = title + + def compose(self) -> ComposeResult: + for i, item in enumerate(self._items, start=1): + yield RigiActionMenuItem(item, number=i) + + def replace_items(self, items: list[RigiActionMenuItemData]) -> None: + self._items = items + self.remove_children() + for i, item in enumerate(items, start=1): + self.mount(RigiActionMenuItem(item, number=i)) From 152be91a7fccbb01c6e870d0a690a793cfe9883a Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 17:10:46 +0000 Subject: [PATCH 03/23] feat: add vertical tab groups for in-page navigation --- src/rigi/css/default.tcss | 41 ++++++++++++++++ src/rigi/widgets/__init__.py | 2 + src/rigi/widgets/vertical_tabs.py | 80 +++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 src/rigi/widgets/vertical_tabs.py diff --git a/src/rigi/css/default.tcss b/src/rigi/css/default.tcss index b6fa2f1..b241232 100644 --- a/src/rigi/css/default.tcss +++ b/src/rigi/css/default.tcss @@ -419,6 +419,47 @@ RigiActionMenuItem:hover { background: #1c2128; } RigiActionMenuItem.--disabled { color: #3d444d; } RigiActionMenuScreen { background: transparent; } +RigiVerticalTabs { + height: 100%; + width: 100%; + layout: horizontal; + background: transparent; +} +RigiVerticalTabs #vt-nav { + width: 18; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + background: transparent; + border-right: solid #21262d; + padding: 1 0; +} +_VerticalTabItem { + height: 1; + width: 100%; + padding: 0 1; + color: #6e7681; + background: transparent; +} +_VerticalTabItem:hover { color: #c9d1d9; background: #161b22; } +_VerticalTabItem.--active { + color: #58a6ff; + text-style: bold; + border-left: thick #58a6ff; +} +RigiVerticalTabs #vt-switcher { + width: 1fr; + height: 100%; + background: transparent; +} +RigiVerticalTabs #vt-switcher > Widget { + height: 100%; + width: 100%; + background: transparent; + overflow-y: auto; + overflow-x: hidden; +} + _ResizeHandle { height: 1; width: 100%; diff --git a/src/rigi/widgets/__init__.py b/src/rigi/widgets/__init__.py index bf5d8fb..876908a 100644 --- a/src/rigi/widgets/__init__.py +++ b/src/rigi/widgets/__init__.py @@ -57,6 +57,7 @@ from rigi.widgets.sidebar import RigiSidebar from rigi.widgets.statusbar import RigiStatusBar, RigiStatusItem from rigi.widgets.terminal_bar import RigiTerminalBar +from rigi.widgets.vertical_tabs import RigiVerticalTabs __all__ = [ # Textual primitives @@ -80,6 +81,7 @@ "RigiActionMenuItem", "RigiActionMenuPanel", "RigiActionMenuItemData", + "RigiVerticalTabs", "RigiSettingsScreen", "RigiSettingDef", "RigiImage", diff --git a/src/rigi/widgets/vertical_tabs.py b/src/rigi/widgets/vertical_tabs.py new file mode 100644 index 0000000..8b91f98 --- /dev/null +++ b/src/rigi/widgets/vertical_tabs.py @@ -0,0 +1,80 @@ +"""Vertical tab groups for in-page navigation.""" + +from __future__ import annotations + +from typing import Any, Callable + +from textual.app import ComposeResult +from textual.message import Message +from textual.widget import Widget +from textual.widgets import ContentSwitcher, Label + + +class _VerticalTabItem(Widget): + can_focus = False + + def __init__(self, label: str, idx: int) -> None: + super().__init__() + self._label = label + self._idx = idx + + def compose(self) -> ComposeResult: + yield Label(self._label) + + def set_active(self, active: bool) -> None: + self.set_class(active, "--active") + + def on_click(self) -> None: + self.post_message(_VerticalTabClicked(self._idx)) + self.app.set_focus(None) + + +class _VerticalTabClicked(Message): + def __init__(self, idx: int) -> None: + super().__init__() + self.idx = idx + + +class RigiVerticalTabs(Widget): + def __init__( + self, + tabs: list[tuple[str, Callable[[], Widget]]], + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._tab_defs = tabs + self._active_idx: int = 0 + + def compose(self) -> ComposeResult: + with Widget(id="vt-nav"): + for i, (name, _) in enumerate(self._tab_defs): + item = _VerticalTabItem(name, i) + item.set_active(i == self._active_idx) + yield item + with ContentSwitcher(initial="vt-content-0", id="vt-switcher"): + for i, _ in enumerate(self._tab_defs): + yield Widget(id=f"vt-content-{i}") + + def on_mount(self) -> None: + for i, (_, factory) in enumerate(self._tab_defs): + try: + container = self.query_one(f"#vt-content-{i}", Widget) + container.mount(factory()) + except Exception: + pass + + def set_active(self, idx: int) -> None: + if idx < 0 or idx >= len(self._tab_defs): + return + self._active_idx = idx + for item in self.query(_VerticalTabItem): + item.set_active(item._idx == idx) + try: + switcher = self.query_one("#vt-switcher", ContentSwitcher) + switcher.current = f"vt-content-{idx}" + except Exception: + pass + + def on__vertical_tab_clicked(self, event: _VerticalTabClicked) -> None: + event.stop() + self.set_active(event.idx) From ed77d250f97e7d291de831cc528924a58e141481 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 17:38:10 +0000 Subject: [PATCH 04/23] fix: double vertical lines on group borders and theme backgrounds --- src/rigi/themes/base.py | 43 ++++++++++++++++++++++++++++++-- src/rigi/themes/dark.py | 17 ++++++++++++- src/rigi/themes/light.py | 2 ++ src/rigi/widgets/content_area.py | 1 - tests/test_resize.py | 17 +------------ 5 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/rigi/themes/base.py b/src/rigi/themes/base.py index c67b47c..b4c2e82 100644 --- a/src/rigi/themes/base.py +++ b/src/rigi/themes/base.py @@ -15,6 +15,10 @@ class RigiTheme: name: str + # ── backgrounds ──────────────────────────────────────────────────────── + bg_color: str = "#000000" + fg_color: str = "#ffffff" + # ── borders / separators ─────────────────────────────────────────────── border: str = "#30363d" border_dim: str = "#21262d" @@ -36,17 +40,25 @@ class RigiTheme: def to_css(self) -> str: return f"""/* rigi-theme: {self.name} */ +App, Screen {{ + background: {self.bg_color}; + color: {self.fg_color}; +}} RigiBorderFrame {{ border: round {self.border}; + background: {self.bg_color}; + color: {self.fg_color}; }} RigiStatusBar {{ border-bottom: solid {self.border_dim}; + background: {self.bg_color}; }} _RigiMainNav {{ - border-right: solid {self.border_dim}; + background: {self.bg_color}; }} _MainNavItem {{ color: {self.text_dim}; + background: {self.bg_color}; }} _MainNavItem:hover {{ color: {self.text}; @@ -56,10 +68,11 @@ def to_css(self) -> str: border-left: thick {self.text_highlight}; }} _RigiSubNav {{ - border-right: solid {self.border_dim}; + background: {self.bg_color}; }} _SubNavItem {{ color: {self.text_dim}; + background: {self.bg_color}; }} _SubNavItem:hover {{ color: {self.text}; @@ -69,10 +82,14 @@ def to_css(self) -> str: }} RigiShortcutsBar {{ border-top: solid {self.border_dim}; + background: {self.bg_color}; }} RigiShortcutsBar Label {{ color: {self.text_dim}; }} +RigiTerminalBar {{ + background: {self.bg_color}; +}} RigiTerminalBar Label {{ color: {self.terminal_color}; }} @@ -81,6 +98,7 @@ def to_css(self) -> str: }} RigiCard {{ border: round {self.border_dim}; + background: {self.bg_color}; }} RigiCompletionList {{ border: solid {self.border}; @@ -113,4 +131,25 @@ def to_css(self) -> str: #help-dismiss {{ color: {self.text_dim}; }} +_RigiBody {{ + background: {self.bg_color}; +}} +RigiSidebar {{ + background: {self.bg_color}; +}} +RigiContentArea {{ + background: {self.bg_color}; +}} +RigiBottomPanel {{ + background: {self.bg_color}; +}} +#content-main {{ + background: {self.bg_color}; +}} +_VerticalResizeHandle {{ + background: {self.bg_color}; +}} +_ContentResizeHandle {{ + background: {self.bg_color}; +}} """ diff --git a/src/rigi/themes/dark.py b/src/rigi/themes/dark.py index a9c9195..4278b2c 100644 --- a/src/rigi/themes/dark.py +++ b/src/rigi/themes/dark.py @@ -1,3 +1,18 @@ from rigi.themes.base import RigiTheme -DARK = RigiTheme(name="dark") +DARK = RigiTheme( + name="dark", + bg_color="#000000", + fg_color="#ffffff", + border="#30363d", + border_dim="#21262d", + text="#c9d1d9", + text_dim="#6e7681", + text_highlight="#58a6ff", + text_highlight2="#79c0ff", + terminal_color="#3fb950", + key_color="#e3b341", + desc_color="#8b949e", + popup_bg="#0d1117", + completion_bg="#1c2128", +) diff --git a/src/rigi/themes/light.py b/src/rigi/themes/light.py index 04bc79d..2f6e980 100644 --- a/src/rigi/themes/light.py +++ b/src/rigi/themes/light.py @@ -2,6 +2,8 @@ LIGHT = RigiTheme( name="light", + bg_color="#ffffff", + fg_color="#000000", border="#d0d7de", border_dim="#d8dee4", text="#24292f", diff --git a/src/rigi/widgets/content_area.py b/src/rigi/widgets/content_area.py index a7c9d1a..8f8556e 100644 --- a/src/rigi/widgets/content_area.py +++ b/src/rigi/widgets/content_area.py @@ -67,7 +67,6 @@ def __init__(self) -> None: self._current: Widget | None = None def compose(self) -> ComposeResult: - yield _ContentResizeHandle() with Widget(id="content-main"): yield _RigiEmptyState(id="rigi-empty-state") diff --git a/tests/test_resize.py b/tests/test_resize.py index eb06551..ce876a7 100644 --- a/tests/test_resize.py +++ b/tests/test_resize.py @@ -6,7 +6,7 @@ from rigi.commands.registry import CommandRegistry from rigi.widgets.bottom_panel import RigiBottomPanel, _ResizeHandle -from rigi.widgets.content_area import RigiContentArea, _ContentResizeHandle +from rigi.widgets.content_area import RigiContentArea from rigi.widgets.sidebar import _VerticalResizeHandle @@ -25,21 +25,6 @@ def compose(self): assert "│" in rendered -@pytest.mark.asyncio -async def test_content_resize_handle_render(): - """Test content resize handle rendering.""" - - class TestApp(App[None]): - def compose(self): - yield RigiContentArea() - - app = TestApp() - async with app.run_test() as _: - handle = app.query_one(_ContentResizeHandle) - rendered = handle.render() - assert "│" in rendered - - @pytest.mark.asyncio async def test_horizontal_resize_handle_render(): """Test horizontal resize handle rendering.""" From b582b61777042806d1a6772c7e2da87bda62b8c5 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 17:38:16 +0000 Subject: [PATCH 05/23] fix: output help and terminal info to terminal panel --- src/rigi/core/_cmd_handlers.py | 40 +++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/rigi/core/_cmd_handlers.py b/src/rigi/core/_cmd_handlers.py index 254bd7a..a073a00 100644 --- a/src/rigi/core/_cmd_handlers.py +++ b/src/rigi/core/_cmd_handlers.py @@ -12,30 +12,44 @@ async def cmd_terminal(app: RigiApp, **_: Any) -> None: + from rigi.widgets.bottom_panel import RigiBottomPanel + nfo = _console.info() lines = [ - f"[bold]Terminal:[/bold] {nfo['terminal']}", - f"[bold]True color:[/bold] {'yes' if nfo['true_color'] else 'no'}" - f" [dim](depth {nfo['color_depth']})[/dim]", - f"[bold]Hyperlinks:[/bold] {'yes' if nfo['hyperlinks'] else 'no'}", - f"[bold]Unicode:[/bold] {'yes' if nfo['unicode'] else 'no'}", - f"[bold]Mouse:[/bold] {'yes' if nfo['mouse'] else 'no'}", - f"[bold]Kitty gfx:[/bold] {'yes' if nfo['kitty_graphics'] else 'no'}", - f"[bold]Multiplexer:[/bold] " + "[bold]Terminal Info[/bold]", + f" Terminal: {nfo['terminal']}", + f" True color: {'yes' if nfo['true_color'] else 'no'}" + f" (depth {nfo['color_depth']})", + f" Hyperlinks: {'yes' if nfo['hyperlinks'] else 'no'}", + f" Unicode: {'yes' if nfo['unicode'] else 'no'}", + f" Mouse: {'yes' if nfo['mouse'] else 'no'}", + f" Kitty gfx: {'yes' if nfo['kitty_graphics'] else 'no'}", + f" Multiplexer: " f"{'tmux' if nfo['tmux'] else 'screen' if nfo['screen'] else 'none'}", - f"[bold]Size:[/bold] {nfo['columns']}×{nfo['lines']}", + f" Size: {nfo['columns']}×{nfo['lines']}", ] - app.notify("\n".join(lines), title="Terminal Info", timeout=8) + try: + app.query_one(RigiBottomPanel).write_output("\n".join(lines)) + except Exception: + app.notify("\n".join(lines), title="Terminal Info", timeout=8) async def cmd_help(app: RigiApp, **kwargs: Any) -> None: + from rigi.widgets.bottom_panel import RigiBottomPanel + cmd_name = kwargs.get("command") registry = app.cmd_registry + def _output(text: str) -> None: + try: + app.query_one(RigiBottomPanel).write_output(text) + except Exception: + app.notify(text, title="Help", timeout=12) + if cmd_name: cmd = registry.get(cmd_name) if cmd is None: - app.notify(f"Unknown command: {cmd_name}", severity="error", title="help") + _output(f"[red]Unknown command: {cmd_name}[/red]") return lines = [f"[bold cyan]{cmd.name}[/bold cyan]"] @@ -60,7 +74,7 @@ async def cmd_help(app: RigiApp, **kwargs: Any) -> None: if not sub.hidden: lines.append(f" [cyan]{sub.name}[/cyan] - {sub.help}") - app.notify("\n".join(lines), title=f"Help: {cmd.name}", timeout=15) + _output("\n".join(lines)) else: lines = ["[bold]Available commands:[/bold]\n"] for cmd in registry.all(): @@ -69,7 +83,7 @@ async def cmd_help(app: RigiApp, **kwargs: Any) -> None: lines.append(f" [cyan]{cmd.name}[/cyan]{aliases} - {cmd.help}") lines.append("\n[dim]Type 'help ' for detailed information[/dim]") lines.append("[dim]Type '!' to run shell commands[/dim]") - app.notify("\n".join(lines), title="Terminal Help", timeout=12) + _output("\n".join(lines)) async def cmd_quit(app: RigiApp, **_: Any) -> None: From e473ce17f936e251c3cd8b2490ef39c07400b8a0 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 17:38:21 +0000 Subject: [PATCH 06/23] feat: add RigiCheckbox widget --- src/rigi/__init__.py | 2 ++ src/rigi/widgets/checkbox.py | 56 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/rigi/widgets/checkbox.py diff --git a/src/rigi/__init__.py b/src/rigi/__init__.py index 86f44e7..b19a0af 100644 --- a/src/rigi/__init__.py +++ b/src/rigi/__init__.py @@ -51,6 +51,7 @@ from rigi.themes import RigiTheme from rigi.widgets.border_frame import RigiBorderFrame from rigi.widgets.bottom_panel import RigiBottomPanel +from rigi.widgets.checkbox import RigiCheckbox from rigi.widgets.content_area import RigiContentArea from rigi.widgets.gauge import RigiGauge, RigiSparkline from rigi.widgets.hamburger_menu import ( @@ -113,6 +114,7 @@ "RigiSidebar", "RigiTerminalBar", "RigiBottomPanel", + "RigiCheckbox", "RigiContentArea", "RigiBorderFrame", "RigiHamburgerScreen", diff --git a/src/rigi/widgets/checkbox.py b/src/rigi/widgets/checkbox.py new file mode 100644 index 0000000..421f321 --- /dev/null +++ b/src/rigi/widgets/checkbox.py @@ -0,0 +1,56 @@ +"""Simple clickable checkbox widget for Rigi.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.message import Message +from textual.widget import Widget +from textual.widgets import Label + + +class RigiCheckbox(Widget): + """A simple checkbox with a clickable label. + + Posts ``RigiCheckbox.Changed`` when toggled. + """ + + class Changed(Message): + def __init__(self, value: bool) -> None: + super().__init__() + self.value = value + + def __init__(self, label: str = "", value: bool = False) -> None: + super().__init__() + self._label = label + self._value = value + + def compose(self) -> ComposeResult: + yield Label(self._render_text(), id="rigi-checkbox-label") + + def _render_text(self) -> str: + box = "[green]✓[/green]" if self._value else " " + return f"[{box}] {self._label}" + + def on_click(self) -> None: + self.toggle() + + def toggle(self) -> None: + self._value = not self._value + try: + self.query_one("#rigi-checkbox-label", Label).update(self._render_text()) + except Exception: + pass + self.post_message(self.Changed(self._value)) + + @property + def value(self) -> bool: + return self._value + + @value.setter + def value(self, v: bool) -> None: + if v != self._value: + self._value = v + try: + self.query_one("#rigi-checkbox-label", Label).update(self._render_text()) + except Exception: + pass From 111528bda24c719c0f9ca7baf30a03010aceb044 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 17:38:26 +0000 Subject: [PATCH 07/23] feat: add transparent background setting with opacity control --- src/rigi/core/app.py | 71 ++++++++++++++++++++++++++++++++++++ src/rigi/screens/settings.py | 16 ++++++-- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/rigi/core/app.py b/src/rigi/core/app.py index 652f310..b52e3d5 100644 --- a/src/rigi/core/app.py +++ b/src/rigi/core/app.py @@ -118,6 +118,8 @@ def __init__( self._rigi_help_entries: list[HelpEntry] = [] self._rigi_menu_items: list[tuple[str, str, Callable[[], None]]] = [] self._settings_manager = SettingsManager() + self._transparent_enabled: bool = False + self._transparent_percent: int = 50 self._disable_notifications = True self._register_builtin_commands() @@ -305,11 +307,72 @@ def set_theme(self, theme: RigiTheme) -> None: tie_breaker=self._theme_tie_breaker, ) self.refresh_css(animate=False) + self._apply_transparency() _ui_log.info(f"Theme changed to: {theme.name}") except Exception as exc: _ui_log.error(f"Theme error: {exc}", exc_info=True) self.notify(f"Theme error: {exc}", severity="error") + def _toggle_transparency(self) -> None: + self._transparent_enabled = not self._transparent_enabled + self._apply_transparency() + + def _set_transparency_percent(self, value: str) -> None: + try: + self._transparent_percent = max(0, min(100, int(value))) + except ValueError: + pass + self._apply_transparency() + + def _apply_transparency(self) -> None: + if not self.is_running: + return + try: + if self._transparent_enabled: + alpha = max(0.0, min(1.0, 1.0 - (self._transparent_percent / 100.0))) + bg = self._theme.bg_color + if bg.startswith("#") and len(bg) == 7: + r = int(bg[1:3], 16) + g = int(bg[3:5], 16) + b = int(bg[5:7], 16) + rgba = f"rgb({r} {g} {b} / {alpha})" + else: + rgba = bg + css = f""" +App, Screen {{ + background: transparent; +}} +RigiBorderFrame, _RigiBody, RigiSidebar, RigiContentArea, #content-main, +_RigiMainNav, _RigiSubNav, RigiBottomPanel, RigiTerminalBar, +RigiStatusBar, RigiShortcutsBar, _VerticalResizeHandle, _ContentResizeHandle {{ + background: {rgba}; +}} +""" + else: + css = f""" +App, Screen {{ + background: {self._theme.bg_color}; +}} +RigiBorderFrame, _RigiBody, RigiSidebar, RigiContentArea, #content-main, +_RigiMainNav, _RigiSubNav, RigiBottomPanel, RigiTerminalBar, +RigiStatusBar, RigiShortcutsBar, _VerticalResizeHandle, _ContentResizeHandle {{ + background: {self._theme.bg_color}; +}} +""" + self._theme_tie_breaker += 1 + self.stylesheet.add_source( + css, + read_from=( + f"__rigi_transparency_{self._theme_tie_breaker}__", + f"__rigi_transparency_{self._theme_tie_breaker}__", + ), + is_default_css=False, + tie_breaker=self._theme_tie_breaker, + ) + self.refresh_css(animate=False) + except Exception as exc: + _ui_log.error(f"Transparency error: {exc}", exc_info=True) + def _cycle_theme(self) -> None: from rigi.themes import DARK, LIGHT, MONOKAI, NORD @@ -588,6 +651,14 @@ def _open_settings(self) -> None: description="UTF-8 output encoding", value_fn=lambda: "yes" if _console.supports_unicode() else "no", ), + RigiSettingDef( + category="Appearance", + label="Transparent", + description="Enable transparent background with adjustable opacity", + checkbox_fn=lambda: self._transparent_enabled, + toggle_fn=self._toggle_transparency, + write_fn=self._set_transparency_percent, + ), ] self.push_screen(RigiSettingsScreen(builtin + self._settings_manager.all_defs())) diff --git a/src/rigi/screens/settings.py b/src/rigi/screens/settings.py index 7c24fe2..0f2b409 100644 --- a/src/rigi/screens/settings.py +++ b/src/rigi/screens/settings.py @@ -157,6 +157,12 @@ def on_changed(self, event: Switch.Changed) -> None: self._setting.toggle_fn() except Exception as e: _ui_log.error(f"Error toggling setting {self._setting.label}: {e}", exc_info=True) + # Show/hide sibling input when present + for sibling in self.siblings: + if isinstance(sibling, _SettingInput): + sibling.display = event.value + if event.value: + sibling.focus() class _SettingItem(Widget): @@ -170,10 +176,14 @@ def compose(self) -> ComposeResult: yield Label(self._setting.description, classes="_s-desc") if self._setting.checkbox_fn is not None: yield _SettingSwitch(self._setting) - elif self._setting.write_fn is not None: - yield _SettingInput(self._setting) + if self._setting.write_fn is not None: + inp = _SettingInput(self._setting) + if self._setting.checkbox_fn is not None: + inp.display = self._setting.get_checked() + yield inp elif self._setting.value_fn is not None or self._setting.action_fn is not None: - yield _ValueRow(self._setting) + if self._setting.checkbox_fn is None: + yield _ValueRow(self._setting) class _SettingsContent(Widget): From 14377e433ca16fabea561d5dd5076de3c5108513 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 17:38:31 +0000 Subject: [PATCH 08/23] feat: add vertical tabs and action menu examples --- examples/09_vertical_tabs.py | 57 ++++++++++++++++++++++++++++++++++++ examples/10_action_menu.py | 47 +++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 examples/09_vertical_tabs.py create mode 100644 examples/10_action_menu.py diff --git a/examples/09_vertical_tabs.py b/examples/09_vertical_tabs.py new file mode 100644 index 0000000..5bf1465 --- /dev/null +++ b/examples/09_vertical_tabs.py @@ -0,0 +1,57 @@ +"""Vertical tabs example — in-page tab switcher.""" + +from __future__ import annotations + +from rigi import RigiApp, TabDef +from rigi.layout.pane import RigiCard, RigiPane +from rigi.widgets import Label +from rigi.widgets.vertical_tabs import RigiVerticalTabs + +app = RigiApp( + name="vertical-tabs", + version="1.0.0", + description="Demo of RigiVerticalTabs", + home_tab="Demo", +) + + +def make_overview(): + return RigiPane( + Label("[bold]RigiVerticalTabs[/bold] — switch between panels vertically."), + Label(""), + RigiVerticalTabs( + tabs=[ + ( + "Overview", + lambda: RigiCard( + Label("This is the overview panel."), + Label("Vertical tabs are great for settings or multi-step forms."), + title=" Overview", + ), + ), + ( + "Settings", + lambda: RigiCard( + Label("[dim]Option 1:[/dim] enabled"), + Label("[dim]Option 2:[/dim] disabled"), + title=" Settings", + ), + ), + ( + "About", + lambda: RigiCard( + Label("Version: 1.0.0"), + Label("Built with Rigi + Textual"), + title=" About", + ), + ), + ] + ), + ) + + +demo_tab = TabDef(name="Demo", key="1", icon="", widget_factory=make_overview) +app.add_tab(demo_tab) + +if __name__ == "__main__": + RigiApp.run_cli(app) diff --git a/examples/10_action_menu.py b/examples/10_action_menu.py new file mode 100644 index 0000000..c1d1e15 --- /dev/null +++ b/examples/10_action_menu.py @@ -0,0 +1,47 @@ +"""Action menu example — popup menu with numbered actions.""" + +from __future__ import annotations + +from rigi import RigiApp, TabDef +from rigi.layout.pane import RigiCard, RigiPane +from rigi.widgets import Label +from rigi.widgets.action_menu import RigiActionMenuItemData + +app = RigiApp( + name="action-menu", + version="1.0.0", + description="Demo of RigiActionMenu", + home_tab="Demo", +) + + +def make_demo(): + return RigiPane( + Label("[bold]RigiActionMenu[/bold] — press [cyan]Ctrl+M[/cyan] or use the button below."), + Label(""), + RigiCard( + Label("Action menus show numbered items with color support."), + Label("Click an item or press its number key to activate."), + title=" Info", + ), + ) + + +demo_tab = TabDef(name="Demo", key="1", icon="", widget_factory=make_demo) +app.add_tab(demo_tab) + + +@app.command("menu", help="Show the action menu") +async def cmd_menu(app: RigiApp, **_: object) -> None: + items = [ + RigiActionMenuItemData("Copy", color="cyan", callback=lambda: app.notify("Copied!", timeout=2)), + RigiActionMenuItemData("Paste", color="green", callback=lambda: app.notify("Pasted!", timeout=2)), + RigiActionMenuItemData("Delete", color="red", callback=lambda: app.notify("Deleted!", timeout=2)), + RigiActionMenuItemData("Rename", callback=lambda: app.notify("Renamed!", timeout=2)), + RigiActionMenuItemData("Cancel", disabled=True), + ] + app.show_action_menu(items, title="Actions") + + +if __name__ == "__main__": + RigiApp.run_cli(app) From 314ee6f383867690001077db42368f0c2021167c Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 17:39:57 +0000 Subject: [PATCH 09/23] fix: show current transparency percent value in settings input --- src/rigi/core/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/rigi/core/app.py b/src/rigi/core/app.py index b52e3d5..113a1d2 100644 --- a/src/rigi/core/app.py +++ b/src/rigi/core/app.py @@ -655,6 +655,7 @@ def _open_settings(self) -> None: category="Appearance", label="Transparent", description="Enable transparent background with adjustable opacity", + value_fn=lambda: str(self._transparent_percent), checkbox_fn=lambda: self._transparent_enabled, toggle_fn=self._toggle_transparency, write_fn=self._set_transparency_percent, From 532240e2ab7728587ef2f3e5a8fc204f41c6ede1 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 17:42:48 +0000 Subject: [PATCH 10/23] fix: export RigiVerticalTabs and RigiActionMenuItemData from main module --- src/rigi/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/rigi/__init__.py b/src/rigi/__init__.py index b19a0af..947cf1d 100644 --- a/src/rigi/__init__.py +++ b/src/rigi/__init__.py @@ -51,8 +51,10 @@ from rigi.themes import RigiTheme from rigi.widgets.border_frame import RigiBorderFrame from rigi.widgets.bottom_panel import RigiBottomPanel +from rigi.widgets.action_menu import RigiActionMenuItemData from rigi.widgets.checkbox import RigiCheckbox from rigi.widgets.content_area import RigiContentArea +from rigi.widgets.vertical_tabs import RigiVerticalTabs from rigi.widgets.gauge import RigiGauge, RigiSparkline from rigi.widgets.hamburger_menu import ( RigiHamburgerPanel, @@ -140,6 +142,8 @@ "RigiSparkline", "RigiNotificationRack", "RigiNotificationWidget", + "RigiVerticalTabs", + "RigiActionMenuItemData", # Platform utilities "platform", "console", From ad481cc76c548161296b7c6cb140d7f0b72334a9 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 17:43:34 +0000 Subject: [PATCH 11/23] refactor: use direct main module imports in examples --- examples/09_vertical_tabs.py | 3 +-- examples/10_action_menu.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/09_vertical_tabs.py b/examples/09_vertical_tabs.py index 5bf1465..4f173ce 100644 --- a/examples/09_vertical_tabs.py +++ b/examples/09_vertical_tabs.py @@ -2,10 +2,9 @@ from __future__ import annotations -from rigi import RigiApp, TabDef +from rigi import RigiApp, RigiVerticalTabs, TabDef from rigi.layout.pane import RigiCard, RigiPane from rigi.widgets import Label -from rigi.widgets.vertical_tabs import RigiVerticalTabs app = RigiApp( name="vertical-tabs", diff --git a/examples/10_action_menu.py b/examples/10_action_menu.py index c1d1e15..210d536 100644 --- a/examples/10_action_menu.py +++ b/examples/10_action_menu.py @@ -2,10 +2,9 @@ from __future__ import annotations -from rigi import RigiApp, TabDef +from rigi import RigiActionMenuItemData, RigiApp, TabDef from rigi.layout.pane import RigiCard, RigiPane from rigi.widgets import Label -from rigi.widgets.action_menu import RigiActionMenuItemData app = RigiApp( name="action-menu", From 6949558111c05e3e1dce946e32890d4383921f5e Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 17:45:10 +0000 Subject: [PATCH 12/23] feat: add keyboard support and focus to RigiCheckbox --- src/rigi/widgets/checkbox.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/rigi/widgets/checkbox.py b/src/rigi/widgets/checkbox.py index 421f321..65b9996 100644 --- a/src/rigi/widgets/checkbox.py +++ b/src/rigi/widgets/checkbox.py @@ -3,6 +3,7 @@ from __future__ import annotations from textual.app import ComposeResult +from textual.events import Click, Key from textual.message import Message from textual.widget import Widget from textual.widgets import Label @@ -14,6 +15,8 @@ class RigiCheckbox(Widget): Posts ``RigiCheckbox.Changed`` when toggled. """ + can_focus = True + class Changed(Message): def __init__(self, value: bool) -> None: super().__init__() @@ -31,9 +34,15 @@ def _render_text(self) -> str: box = "[green]✓[/green]" if self._value else " " return f"[{box}] {self._label}" - def on_click(self) -> None: + def on_click(self, event: Click) -> None: + event.stop() self.toggle() + def on_key(self, event: Key) -> None: + if event.key in ("enter", "space"): + event.stop() + self.toggle() + def toggle(self) -> None: self._value = not self._value try: From c367baabe3b6470b12576b544adc4959b79f1f88 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 17:48:24 +0000 Subject: [PATCH 13/23] fix: use unicode symbols in RigiCheckbox to avoid markup parsing issues --- src/rigi/widgets/checkbox.py | 4 ++-- tests/test_checkbox.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 tests/test_checkbox.py diff --git a/src/rigi/widgets/checkbox.py b/src/rigi/widgets/checkbox.py index 65b9996..942856c 100644 --- a/src/rigi/widgets/checkbox.py +++ b/src/rigi/widgets/checkbox.py @@ -31,8 +31,8 @@ def compose(self) -> ComposeResult: yield Label(self._render_text(), id="rigi-checkbox-label") def _render_text(self) -> str: - box = "[green]✓[/green]" if self._value else " " - return f"[{box}] {self._label}" + box = "[green]☑[/green]" if self._value else "☐" + return f"{box} {self._label}" def on_click(self, event: Click) -> None: event.stop() diff --git a/tests/test_checkbox.py b/tests/test_checkbox.py new file mode 100644 index 0000000..9ccfa45 --- /dev/null +++ b/tests/test_checkbox.py @@ -0,0 +1,32 @@ +"""Tests for RigiCheckbox widget.""" + +import pytest +from textual.app import App + +from rigi.widgets.checkbox import RigiCheckbox + + +@pytest.mark.asyncio +async def test_checkbox_initial_value(): + class TestApp(App[None]): + def compose(self): + yield RigiCheckbox("Test", value=True) + + app = TestApp() + async with app.run_test() as _: + cb = app.query_one(RigiCheckbox) + assert cb.value is True + + +@pytest.mark.asyncio +async def test_checkbox_toggle(): + class TestApp(App[None]): + def compose(self): + yield RigiCheckbox("Test", value=False) + + app = TestApp() + async with app.run_test() as _: + cb = app.query_one(RigiCheckbox) + assert cb.value is False + cb.toggle() + assert cb.value is True From cea6b40660f4dc52ae9b58e11c2855db08b6714c Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 18:17:06 +0000 Subject: [PATCH 14/23] feat: rename all public classes removing Rigi prefix --- examples/01_minimal.py | 12 +- examples/02_dashboard.py | 26 +-- examples/03_todo.py | 24 +-- examples/04_file_browser.py | 18 +- examples/05_system_monitor.py | 22 +-- examples/06_notes.py | 34 ++-- examples/07_multi_theme.py | 28 +-- examples/08_platform_features.py | 66 +++---- .../{09_vertical_tabs.py => 09_tab_group.py} | 28 +-- examples/10_action_menu.py | 24 +-- src/rigi/__init__.py | 120 +++++------ src/rigi/commands/provider.py | 8 +- src/rigi/core/__init__.py | 4 +- src/rigi/core/_cmd_handlers.py | 24 +-- src/rigi/core/app.py | 187 ++++++++++-------- src/rigi/core/dev_commands.py | 60 +++--- src/rigi/core/settings_manager.py | 14 +- src/rigi/css/default.tcss | 171 ++++++++-------- src/rigi/layout/__init__.py | 14 +- src/rigi/layout/pane.py | 12 +- src/rigi/screens/__init__.py | 18 +- src/rigi/screens/action_menu.py | 16 +- src/rigi/screens/hamburger.py | 26 +-- src/rigi/screens/help.py | 4 +- src/rigi/screens/settings.py | 16 +- src/rigi/themes/__init__.py | 6 +- src/rigi/themes/base.py | 43 ++-- src/rigi/themes/dark.py | 4 +- src/rigi/themes/light.py | 4 +- src/rigi/themes/monokai.py | 4 +- src/rigi/themes/nord.py | 4 +- src/rigi/widgets/__init__.py | 86 ++++---- src/rigi/widgets/action_menu.py | 18 +- src/rigi/widgets/border_frame.py | 2 +- src/rigi/widgets/bottom_panel.py | 12 +- src/rigi/widgets/checkbox.py | 4 +- src/rigi/widgets/content_area.py | 14 +- src/rigi/widgets/gauge.py | 4 +- src/rigi/widgets/hamburger_menu.py | 22 +-- src/rigi/widgets/hamburger_overlay.py | 75 +++++++ src/rigi/widgets/help_panel.py | 4 +- src/rigi/widgets/image.py | 2 +- src/rigi/widgets/mouse.py | 12 +- src/rigi/widgets/notifications.py | 8 +- src/rigi/widgets/palette.py | 16 +- src/rigi/widgets/settings_screen.py | 6 +- src/rigi/widgets/sidebar.py | 55 +++--- src/rigi/widgets/statusbar.py | 8 +- .../{vertical_tabs.py => tab_group.py} | 49 +++-- src/rigi/widgets/terminal_bar.py | 12 +- tests/test_basic.py | 28 +-- tests/test_checkbox.py | 12 +- tests/test_resize.py | 10 +- tests/test_terminal.py | 24 +-- tests/test_widgets.py | 20 +- 55 files changed, 833 insertions(+), 711 deletions(-) rename examples/{09_vertical_tabs.py => 09_tab_group.py} (63%) create mode 100644 src/rigi/widgets/hamburger_overlay.py rename src/rigi/widgets/{vertical_tabs.py => tab_group.py} (50%) diff --git a/examples/01_minimal.py b/examples/01_minimal.py index 90b3074..0dbcdfd 100644 --- a/examples/01_minimal.py +++ b/examples/01_minimal.py @@ -2,16 +2,16 @@ from __future__ import annotations -from rigi import RigiApp, TabDef -from rigi.layout.pane import RigiCard, RigiPane +from rigi import App, TabDef +from rigi.layout.pane import Card, Pane from rigi.widgets import Label -app = RigiApp(name="minimal", version="1.0.0", description="Simplest possible Rigi app") +app = App(name="minimal", version="1.0.0", description="Simplest possible Rigi app") def home(): - return RigiPane( - RigiCard( + return Pane( + Card( Label("Welcome to [bold cyan]Rigi[/bold cyan]!"), Label(""), Label(" [dim]Ctrl+H[/dim] Help"), @@ -25,4 +25,4 @@ def home(): app.add_tab(TabDef(name="Home", key="1", icon="⌂", widget_factory=home)) if __name__ == "__main__": - RigiApp.run_cli(app) + App.run_cli(app) diff --git a/examples/02_dashboard.py b/examples/02_dashboard.py index 02f0e74..65a88bf 100644 --- a/examples/02_dashboard.py +++ b/examples/02_dashboard.py @@ -6,11 +6,11 @@ import os import random -from rigi import RigiApp, TabDef -from rigi.layout.pane import RigiCard, RigiHPane, RigiPane +from rigi import App, TabDef +from rigi.layout.pane import Card, HPane, Pane from rigi.widgets import DataTable, Label -app = RigiApp( +app = App( name="dashboard", version="2.0.0", description="Live metrics dashboard", @@ -25,15 +25,15 @@ def make_overview(): - return RigiPane( - RigiHPane( - RigiCard( + return Pane( + HPane( + Card( Label(f"[bold green]{random.randint(100, 999)}[/bold green] requests/s"), Label(f"[bold yellow]{random.randint(1, 30)}ms[/bold yellow] avg latency"), Label(f"[bold cyan]{random.randint(5, 50)}[/bold cyan] active users"), title=" Overview", ), - RigiCard( + Card( Label("[green]●[/green] API [dim]healthy[/dim]"), Label("[green]●[/green] Database [dim]healthy[/dim]"), Label("[yellow]●[/yellow] Cache [dim]degraded[/dim]"), @@ -42,7 +42,7 @@ def make_overview(): title=" Services", ), ), - RigiCard( + Card( Label(f"Uptime: [cyan]{random.randint(1, 99)}d {random.randint(0,23)}h[/cyan]"), Label( f"Version: [dim]v{random.randint(1,5)}.{random.randint(0,9)}.{random.randint(0,9)}[/dim]" @@ -69,7 +69,7 @@ def make_metrics_table(): ] for row in metrics: table.add_row(*row) - return RigiPane(table) + return Pane(table) def make_logs(): @@ -85,8 +85,8 @@ def make_logs(): f"[{color}]{lvl:5}[/{color}] [dim]{t.strftime('%H:%M:%S')}[/dim] [bold]{svc}[/bold] request handled" ) - return RigiPane( - RigiCard(*[Label(line) for line in lines], title=" Recent Logs"), + return Pane( + Card(*[Label(line) for line in lines], title=" Recent Logs"), ) @@ -102,10 +102,10 @@ def make_logs(): @app.command("refresh", help="Refresh widget cache", aliases=["r"]) -async def cmd_refresh(app: RigiApp, **_: object) -> None: +async def cmd_refresh(app: App, **_: object) -> None: app.invalidate_tab_cache() app.notify("Refreshed", timeout=2) if __name__ == "__main__": - RigiApp.run_cli(app) + App.run_cli(app) diff --git a/examples/03_todo.py b/examples/03_todo.py index f49c409..081db4e 100644 --- a/examples/03_todo.py +++ b/examples/03_todo.py @@ -2,11 +2,11 @@ from __future__ import annotations -from rigi import RigiApp, TabDef -from rigi.layout.pane import RigiCard, RigiPane +from rigi import App, TabDef +from rigi.layout.pane import Card, Pane from rigi.widgets import DataTable, Label -app = RigiApp(name="todo", version="1.0.0", description="Terminal todo manager", home_tab="Tasks") +app = App(name="todo", version="1.0.0", description="Terminal todo manager", home_tab="Tasks") _tasks: list[dict[str, object]] = [ {"id": 1, "text": "Set up project structure", "done": True, "priority": "high"}, @@ -28,9 +28,9 @@ def make_tasks(): col = priority_color.get(pri, "white") task_text = f"[dim]{t['text']}[/dim]" if t["done"] else str(t["text"]) table.add_row(str(t["id"]), done_mark, f"[{col}]{pri}[/{col}]", task_text, "today") - return RigiPane( + return Pane( table, - RigiCard( + Card( Label("[dim]add [/dim] Add new task"), Label("[dim]done [/dim] Mark complete"), Label("[dim]delete [/dim] Delete task"), @@ -43,8 +43,8 @@ def make_tasks(): def make_done(): done = [t for t in _tasks if t["done"]] - return RigiPane( - RigiCard( + return Pane( + Card( *[Label(f"[green]✓[/green] {t['text']}") for t in done] or [Label("[dim]No completed tasks yet[/dim]")], title=f" Completed ({len(done)})", @@ -67,7 +67,7 @@ def make_done(): @app.command("add", help="Add a new task") -async def cmd_add(app: RigiApp, **kwargs: object) -> None: +async def cmd_add(app: App, **kwargs: object) -> None: global _next_id text = " ".join(str(v) for v in kwargs.values() if v) if not text: @@ -80,7 +80,7 @@ async def cmd_add(app: RigiApp, **kwargs: object) -> None: @app.command("done", help="Mark task as complete") -async def cmd_done(app: RigiApp, **kwargs: object) -> None: +async def cmd_done(app: App, **kwargs: object) -> None: try: tid = int(next(iter(kwargs.values()))) # type: ignore[arg-type] task = next(t for t in _tasks if t["id"] == tid) @@ -92,7 +92,7 @@ async def cmd_done(app: RigiApp, **kwargs: object) -> None: @app.command("delete", help="Delete a task", aliases=["del", "rm"]) -async def cmd_delete(app: RigiApp, **kwargs: object) -> None: +async def cmd_delete(app: App, **kwargs: object) -> None: try: tid = int(next(iter(kwargs.values()))) # type: ignore[arg-type] task = next(t for t in _tasks if t["id"] == tid) @@ -104,7 +104,7 @@ async def cmd_delete(app: RigiApp, **kwargs: object) -> None: @app.command("clear", help="Remove all completed tasks") -async def cmd_clear(app: RigiApp, **_: object) -> None: +async def cmd_clear(app: App, **_: object) -> None: removed = sum(1 for t in _tasks if t["done"]) _tasks[:] = [t for t in _tasks if not t["done"]] app.invalidate_tab_cache() @@ -112,4 +112,4 @@ async def cmd_clear(app: RigiApp, **_: object) -> None: if __name__ == "__main__": - RigiApp.run_cli(app) + App.run_cli(app) diff --git a/examples/04_file_browser.py b/examples/04_file_browser.py index 4eba94c..fdc78e3 100644 --- a/examples/04_file_browser.py +++ b/examples/04_file_browser.py @@ -6,11 +6,11 @@ import stat from pathlib import Path -from rigi import RigiApp, TabDef -from rigi.layout.pane import RigiCard, RigiPane +from rigi import App, TabDef +from rigi.layout.pane import Card, Pane from rigi.widgets import DataTable, Label -app = RigiApp(name="files", version="1.0.0", description="Local filesystem browser") +app = App(name="files", version="1.0.0", description="Local filesystem browser") _cwd: Path = Path.cwd() @@ -54,7 +54,7 @@ def make_browser(): except OSError: table.add_row(f"[dim]{entry.name}[/dim]", "?", "?", "?") - info = RigiCard( + info = Card( Label(f"[bold]Path:[/bold] {_cwd}"), Label(f"[bold]Items:[/bold] {len(entries)}"), Label( @@ -63,7 +63,7 @@ def make_browser(): title=" Current Directory", ) - return RigiPane(info, table) + return Pane(info, table) def _file_type(p: Path) -> str: @@ -91,7 +91,7 @@ def _file_type(p: Path) -> str: @app.command("cd", help="Change directory") -async def cmd_cd(app: RigiApp, **kwargs: object) -> None: +async def cmd_cd(app: App, **kwargs: object) -> None: global _cwd target = str(next(iter(kwargs.values()), "~")) try: @@ -106,13 +106,13 @@ async def cmd_cd(app: RigiApp, **kwargs: object) -> None: @app.command("ls", help="List current directory", aliases=["dir"]) -async def cmd_ls(app: RigiApp, **_: object) -> None: +async def cmd_ls(app: App, **_: object) -> None: app.invalidate_tab_cache() app.navigate_to_tab("Browser") @app.command("home", help="Go to home directory") -async def cmd_home(app: RigiApp, **_: object) -> None: +async def cmd_home(app: App, **_: object) -> None: global _cwd _cwd = Path.home() app.invalidate_tab_cache() @@ -120,4 +120,4 @@ async def cmd_home(app: RigiApp, **_: object) -> None: if __name__ == "__main__": - RigiApp.run_cli(app) + App.run_cli(app) diff --git a/examples/05_system_monitor.py b/examples/05_system_monitor.py index 922dd08..8f3f1b0 100644 --- a/examples/05_system_monitor.py +++ b/examples/05_system_monitor.py @@ -7,11 +7,11 @@ import subprocess import time -from rigi import RigiApp, TabDef -from rigi.layout.pane import RigiCard, RigiHPane, RigiPane +from rigi import App, TabDef +from rigi.layout.pane import Card, HPane, Pane from rigi.widgets import DataTable, Label -app = RigiApp( +app = App( name="sysmon", version="1.0.0", description="System resource monitor", home_tab="System" ) @@ -81,9 +81,9 @@ def make_system(): for row in procs_data: procs_table.add_row(*row) - return RigiPane( - RigiHPane( - RigiCard( + return Pane( + HPane( + Card( Label(f"[bold]OS:[/bold] {platform.system()} {platform.release()}"), Label(f"[bold]Arch:[/bold] {platform.machine()}"), Label(f"[bold]Python:[/bold] {platform.python_version()}"), @@ -91,7 +91,7 @@ def make_system(): Label(f"[bold]Uptime:[/bold] {uptime}"), title=" Host", ), - RigiCard( + Card( Label( f"[bold]CPU:[/bold] [{'green' if int(cpu.rstrip('%') or 0) < 70 else 'red'}]{cpu}[/{'green' if int(cpu.rstrip('%') or 0) < 70 else 'red'}]" ), @@ -102,7 +102,7 @@ def make_system(): title=" Resources", ), ), - RigiCard(procs_table, title=" Top Processes (by CPU)"), + Card(procs_table, title=" Top Processes (by CPU)"), ) @@ -126,7 +126,7 @@ def make_env(): if len(val) > 60: val = val[:57] + "..." table.add_row(f"[bold]{key}[/bold]", val) - return RigiPane(table) + return Pane(table) system_tab = TabDef(name="System", key="1", icon="", widget_factory=make_system) @@ -140,10 +140,10 @@ def make_env(): @app.command("refresh", help="Refresh all tabs", aliases=["r"]) -async def cmd_refresh(app: RigiApp, **_: object) -> None: +async def cmd_refresh(app: App, **_: object) -> None: app.invalidate_tab_cache() app.notify("Refreshed", timeout=2) if __name__ == "__main__": - RigiApp.run_cli(app) + App.run_cli(app) diff --git a/examples/06_notes.py b/examples/06_notes.py index 0da20ad..1aaa1d7 100644 --- a/examples/06_notes.py +++ b/examples/06_notes.py @@ -2,11 +2,11 @@ from __future__ import annotations -from rigi import RigiApp, TabDef -from rigi.layout.pane import RigiPane +from rigi import App, TabDef +from rigi.layout.pane import Pane from rigi.widgets import Markdown -app = RigiApp(name="notes", version="1.0.0", description="Markdown notes viewer") +app = App(name="notes", version="1.0.0", description="Markdown notes viewer") _notes: dict[str, str] = { "Getting Started": """# Getting Started @@ -98,16 +98,16 @@ async def greet(app, name="world", **_): """, "API Reference": """# API Reference -## RigiApp +## App ```python -RigiApp( +App( name: str, version: str = "0.1.0", description: str = "", username: str | None = None, home_tab: str | None = None, - theme: RigiTheme | None = None, + theme: Theme | None = None, ) ``` @@ -119,7 +119,7 @@ async def greet(app, name="world", **_): - `add_menu_item(label, callback, section)` - `navigate_to_tab(name: str) → bool` - `invalidate_tab_cache(tab_name=None)` -- `set_theme(theme: RigiTheme)` +- `set_theme(theme: Theme)` - `register_css(path)` ## TabDef @@ -131,11 +131,11 @@ async def greet(app, name="world", **_): ## Layout helpers -- `RigiPane(*children)` — vertical stack -- `RigiHPane(*children)` — horizontal row -- `RigiVPane(*children)` — vertical column -- `RigiCard(*children, title="")` — bordered card -- `RigiSplit(*children, sizes=None)` — horizontal split +- `Pane(*children)` — vertical stack +- `HPane(*children)` — horizontal row +- `VPane(*children)` — vertical column +- `Card(*children, title="")` — bordered card +- `Split(*children, sizes=None)` — horizontal split """, } @@ -143,7 +143,7 @@ async def greet(app, name="world", **_): def _make_note(name: str): def _factory(): content = _notes.get(name, f"# {name}\n\n*Empty note.*") - return RigiPane(Markdown(content)) + return Pane(Markdown(content)) return _factory @@ -157,13 +157,13 @@ def _factory(): @app.command("list", help="List all notes", aliases=["ls"]) -async def cmd_list(app: RigiApp, **_: object) -> None: +async def cmd_list(app: App, **_: object) -> None: names = "\n".join(f" • {n}" for n in _notes) app.notify(f"Notes:\n{names}", title="All notes") @app.command("new", help="Create a blank note") -async def cmd_new(app: RigiApp, **kwargs: object) -> None: +async def cmd_new(app: App, **kwargs: object) -> None: name = " ".join(str(v) for v in kwargs.values() if v).strip() if not name: app.notify("Usage: new ", severity="warning") @@ -173,7 +173,7 @@ async def cmd_new(app: RigiApp, **kwargs: object) -> None: @app.command("search", help="Search note contents") -async def cmd_search(app: RigiApp, **kwargs: object) -> None: +async def cmd_search(app: App, **kwargs: object) -> None: query = " ".join(str(v) for v in kwargs.values() if v).lower() if not query: app.notify("Usage: search ", severity="warning") @@ -186,4 +186,4 @@ async def cmd_search(app: RigiApp, **kwargs: object) -> None: if __name__ == "__main__": - RigiApp.run_cli(app) + App.run_cli(app) diff --git a/examples/07_multi_theme.py b/examples/07_multi_theme.py index 452804b..3bd448c 100644 --- a/examples/07_multi_theme.py +++ b/examples/07_multi_theme.py @@ -4,12 +4,12 @@ import datetime -from rigi import RigiApp, TabDef -from rigi.layout.pane import RigiCard, RigiHPane, RigiPane, RigiSplit +from rigi import App, TabDef +from rigi.layout.pane import Card, HPane, Pane, Split from rigi.themes import DARK, LIGHT, MONOKAI, NORD from rigi.widgets import Label, Markdown -app = RigiApp( +app = App( name="themes", version="1.0.0", description="Theme & styling showcase", @@ -24,9 +24,9 @@ def make_showcase(): - return RigiPane( - RigiHPane( - RigiCard( + return Pane( + HPane( + Card( Label("[bold red]Error[/bold red] critical failure"), Label("[bold yellow]Warning[/bold yellow] disk space low"), Label("[bold green]Success[/bold green] deployment complete"), @@ -35,7 +35,7 @@ def make_showcase(): Label("[bold magenta]Trace[/bold magenta] entering fn foo"), title=" Log Levels", ), - RigiCard( + Card( Label("[dim]Disabled / secondary text[/dim]"), Label("[bold]Bold / primary text[/bold]"), Label("[italic]Italic annotation[/italic]"), @@ -45,7 +45,7 @@ def make_showcase(): title=" Text Styles", ), ), - RigiCard( + Card( Markdown(""" ## Color swatches @@ -65,9 +65,9 @@ def make_showcase(): def make_widgets(): - return RigiPane( - RigiSplit( - RigiCard( + return Pane( + Split( + Card( Label(" ● Active item"), Label(" ○ Inactive item"), Label(" ▶ Collapsed group"), @@ -75,7 +75,7 @@ def make_widgets(): Label(" ─ Separator"), title=" Sidebar Icons", ), - RigiCard( + Card( Label(" ⌂ Home button (active: [cyan]blue[/cyan])"), Label(" ☰ Hamburger menu"), Label(" ● Terminal focused"), @@ -94,7 +94,7 @@ def make_widgets(): @app.command("theme", help="Switch theme: dark | light | monokai | nord") -async def cmd_theme(app: RigiApp, **kwargs: object) -> None: +async def cmd_theme(app: App, **kwargs: object) -> None: name = str(next(iter(kwargs.values()), "")).lower() themes = {"dark": DARK, "light": LIGHT, "monokai": MONOKAI, "nord": NORD} t = themes.get(name) @@ -106,4 +106,4 @@ async def cmd_theme(app: RigiApp, **kwargs: object) -> None: if __name__ == "__main__": - RigiApp.run_cli(app) + App.run_cli(app) diff --git a/examples/08_platform_features.py b/examples/08_platform_features.py index 4590855..f5e7600 100644 --- a/examples/08_platform_features.py +++ b/examples/08_platform_features.py @@ -5,11 +5,11 @@ import asyncio import random -from rigi import RigiApp, TabDef, platform -from rigi.layout.pane import RigiCard, RigiPane -from rigi.widgets import Label, Markdown, RigiGauge, RigiSparkline +from rigi import App, TabDef, platform +from rigi.layout.pane import Card, Pane +from rigi.widgets import Label, Markdown, Gauge, Sparkline -app = RigiApp( +app = App( name="platform-demo", version="1.0.0", description="Platform features & new widgets showcase", @@ -21,11 +21,11 @@ _mem_history: list[float] = [] _net_history: list[float] = [] -_gauge_cpu: RigiGauge | None = None -_gauge_mem: RigiGauge | None = None -_spark_cpu: RigiSparkline | None = None -_spark_mem: RigiSparkline | None = None -_spark_net: RigiSparkline | None = None +_gauge_cpu: Gauge | None = None +_gauge_mem: Gauge | None = None +_spark_cpu: Sparkline | None = None +_spark_mem: Sparkline | None = None +_spark_net: Sparkline | None = None def _read_cpu_pct() -> float: @@ -53,26 +53,26 @@ def _read_mem_pct() -> float: return random.uniform(30, 70) -def make_overview() -> RigiPane: +def make_overview() -> Pane: global _gauge_cpu, _gauge_mem, _spark_cpu, _spark_mem, _spark_net - _gauge_cpu = RigiGauge(label="CPU", value=_read_cpu_pct(), color="green") - _gauge_mem = RigiGauge(label="MEM", value=_read_mem_pct(), color="cyan") - _spark_cpu = RigiSparkline(color="green") - _spark_mem = RigiSparkline(color="cyan") - _spark_net = RigiSparkline(color="yellow") + _gauge_cpu = Gauge(label="CPU", value=_read_cpu_pct(), color="green") + _gauge_mem = Gauge(label="MEM", value=_read_mem_pct(), color="cyan") + _spark_cpu = Sparkline(color="green") + _spark_mem = Sparkline(color="cyan") + _spark_net = Sparkline(color="yellow") cols, lines = platform.terminal_size() - return RigiPane( - RigiCard( + return Pane( + Card( Label(f"[bold]Platform:[/bold] {platform.PLATFORM_NAME}"), Label(f"[bold]Arch:[/bold] {platform.ARCH}"), Label(f"[bold]Wayland:[/bold] {'yes' if platform.IS_WAYLAND else 'no'}"), Label(f"[bold]Terminal:[/bold] {cols}×{lines}"), title=" System", ), - RigiCard( + Card( Label("CPU usage"), _gauge_cpu, _spark_cpu, @@ -88,11 +88,11 @@ def make_overview() -> RigiPane: ) -def make_platform() -> RigiPane: +def make_platform() -> Pane: import sys - return RigiPane( - RigiCard( + return Pane( + Card( Markdown(f""" ## Platform Details @@ -108,7 +108,7 @@ def make_platform() -> RigiPane: """), title=" Platform", ), - RigiCard( + Card( Markdown(f""" ## Config Directories @@ -125,15 +125,15 @@ def make_platform() -> RigiPane: ) -def make_features() -> RigiPane: - return RigiPane( - RigiCard( +def make_features() -> Pane: + return Pane( + Card( Markdown(""" ## New Features in this Build ### Widgets -- **RigiGauge** — horizontal progress bar with label and % -- **RigiSparkline** — rolling mini-chart, push values with `.push(v)` +- **Gauge** — horizontal progress bar with label and % +- **Sparkline** — rolling mini-chart, push values with `.push(v)` ### App Methods - `app.open_url(url)` — open browser cross-platform @@ -174,7 +174,7 @@ def make_features() -> RigiPane: @app.on_startup -async def _start_metrics(a: RigiApp) -> None: # pyright: ignore[reportUnusedFunction] +async def _start_metrics(a: App) -> None: # pyright: ignore[reportUnusedFunction] async def _loop() -> None: while True: cpu = _read_cpu_pct() @@ -198,7 +198,7 @@ async def _loop() -> None: @app.command("open", help="Open a URL or path (e.g. open https://example.com)") -async def cmd_open(app: RigiApp, **kwargs: object) -> None: +async def cmd_open(app: App, **kwargs: object) -> None: target = " ".join(str(v) for v in kwargs.values() if v).strip() if not target: app.notify("Usage: open ", severity="warning") @@ -215,7 +215,7 @@ async def cmd_open(app: RigiApp, **kwargs: object) -> None: @app.command("copy", help="Copy text to clipboard") -async def cmd_copy(app: RigiApp, **kwargs: object) -> None: +async def cmd_copy(app: App, **kwargs: object) -> None: text = " ".join(str(v) for v in kwargs.values() if v).strip() if not text: app.notify("Usage: copy ", severity="warning") @@ -229,7 +229,7 @@ async def cmd_copy(app: RigiApp, **kwargs: object) -> None: @app.command("desktop-notify", help="Send OS desktop notification", aliases=["dn"]) -async def cmd_dn(app: RigiApp, **kwargs: object) -> None: +async def cmd_dn(app: App, **kwargs: object) -> None: text = " ".join(str(v) for v in kwargs.values() if v).strip() or "Hello from Rigi!" ok = app.notify_desktop("Rigi", text) app.notify( @@ -239,7 +239,7 @@ async def cmd_dn(app: RigiApp, **kwargs: object) -> None: @app.command("gauge", help="Demo: set CPU gauge value (e.g. gauge 75)") -async def cmd_gauge(app: RigiApp, **kwargs: object) -> None: +async def cmd_gauge(app: App, **kwargs: object) -> None: try: val = float(str(next(iter(kwargs.values())))) if _gauge_cpu is not None: @@ -250,4 +250,4 @@ async def cmd_gauge(app: RigiApp, **kwargs: object) -> None: if __name__ == "__main__": - RigiApp.run_cli(app) + App.run_cli(app) diff --git a/examples/09_vertical_tabs.py b/examples/09_tab_group.py similarity index 63% rename from examples/09_vertical_tabs.py rename to examples/09_tab_group.py index 4f173ce..a76a862 100644 --- a/examples/09_vertical_tabs.py +++ b/examples/09_tab_group.py @@ -1,36 +1,36 @@ -"""Vertical tabs example — in-page tab switcher.""" +"""TabGroup example — horizontal in-page tabs with optional wrapping.""" from __future__ import annotations -from rigi import RigiApp, RigiVerticalTabs, TabDef -from rigi.layout.pane import RigiCard, RigiPane +from rigi import App, TabDef, TabGroup +from rigi.layout.pane import Card, Pane from rigi.widgets import Label -app = RigiApp( - name="vertical-tabs", +app = App( + name="tab-group", version="1.0.0", - description="Demo of RigiVerticalTabs", + description="Demo of TabGroup", home_tab="Demo", ) def make_overview(): - return RigiPane( - Label("[bold]RigiVerticalTabs[/bold] — switch between panels vertically."), + return Pane( + Label("[bold]TabGroup[/bold] — switch between panels horizontally."), Label(""), - RigiVerticalTabs( + TabGroup( tabs=[ ( "Overview", - lambda: RigiCard( + lambda: Card( Label("This is the overview panel."), - Label("Vertical tabs are great for settings or multi-step forms."), + Label("Tab groups are great for settings or multi-step forms."), title=" Overview", ), ), ( "Settings", - lambda: RigiCard( + lambda: Card( Label("[dim]Option 1:[/dim] enabled"), Label("[dim]Option 2:[/dim] disabled"), title=" Settings", @@ -38,7 +38,7 @@ def make_overview(): ), ( "About", - lambda: RigiCard( + lambda: Card( Label("Version: 1.0.0"), Label("Built with Rigi + Textual"), title=" About", @@ -53,4 +53,4 @@ def make_overview(): app.add_tab(demo_tab) if __name__ == "__main__": - RigiApp.run_cli(app) + App.run_cli(app) diff --git a/examples/10_action_menu.py b/examples/10_action_menu.py index 210d536..2544b4a 100644 --- a/examples/10_action_menu.py +++ b/examples/10_action_menu.py @@ -2,11 +2,11 @@ from __future__ import annotations -from rigi import RigiActionMenuItemData, RigiApp, TabDef -from rigi.layout.pane import RigiCard, RigiPane +from rigi import ActionMenuItemData, App, TabDef +from rigi.layout.pane import Card, Pane from rigi.widgets import Label -app = RigiApp( +app = App( name="action-menu", version="1.0.0", description="Demo of RigiActionMenu", @@ -15,10 +15,10 @@ def make_demo(): - return RigiPane( + return Pane( Label("[bold]RigiActionMenu[/bold] — press [cyan]Ctrl+M[/cyan] or use the button below."), Label(""), - RigiCard( + Card( Label("Action menus show numbered items with color support."), Label("Click an item or press its number key to activate."), title=" Info", @@ -31,16 +31,16 @@ def make_demo(): @app.command("menu", help="Show the action menu") -async def cmd_menu(app: RigiApp, **_: object) -> None: +async def cmd_menu(app: App, **_: object) -> None: items = [ - RigiActionMenuItemData("Copy", color="cyan", callback=lambda: app.notify("Copied!", timeout=2)), - RigiActionMenuItemData("Paste", color="green", callback=lambda: app.notify("Pasted!", timeout=2)), - RigiActionMenuItemData("Delete", color="red", callback=lambda: app.notify("Deleted!", timeout=2)), - RigiActionMenuItemData("Rename", callback=lambda: app.notify("Renamed!", timeout=2)), - RigiActionMenuItemData("Cancel", disabled=True), + ActionMenuItemData("Copy", color="cyan", callback=lambda: app.notify("Copied!", timeout=2)), + ActionMenuItemData("Paste", color="green", callback=lambda: app.notify("Pasted!", timeout=2)), + ActionMenuItemData("Delete", color="red", callback=lambda: app.notify("Deleted!", timeout=2)), + ActionMenuItemData("Rename", callback=lambda: app.notify("Renamed!", timeout=2)), + ActionMenuItemData("Cancel", disabled=True), ] app.show_action_menu(items, title="Actions") if __name__ == "__main__": - RigiApp.run_cli(app) + App.run_cli(app) diff --git a/src/rigi/__init__.py b/src/rigi/__init__.py index 947cf1d..936d052 100644 --- a/src/rigi/__init__.py +++ b/src/rigi/__init__.py @@ -8,7 +8,7 @@ from rigi.commands.command import Command from rigi.commands.parser import build_cli_parser, parse_inline from rigi.commands.registry import CommandRegistry -from rigi.core.app import RigiApp +from rigi.core.app import App from rigi.core.platform import ( CAPS, IS_ISH, @@ -41,41 +41,41 @@ tmux_passthrough, ) from rigi.core.types import CommandArg, HelpEntry, StatusItem, SubtabDef, TabDef -from rigi.layout.pane import RigiCard, RigiHPane, RigiPane, RigiScrollPane, RigiSplit, RigiVPane -from rigi.screens.hamburger import RigiHamburgerScreen -from rigi.screens.help import RigiHelpScreen +from rigi.layout.pane import Card, HPane, Pane, ScrollPane, Split, VPane +from rigi.screens.hamburger import HamburgerScreen +from rigi.screens.help import HelpScreen from rigi.themes import DARK as ThemeDark from rigi.themes import LIGHT as ThemeLight from rigi.themes import MONOKAI as ThemeMonokai from rigi.themes import NORD as ThemeNord -from rigi.themes import RigiTheme -from rigi.widgets.border_frame import RigiBorderFrame -from rigi.widgets.bottom_panel import RigiBottomPanel -from rigi.widgets.action_menu import RigiActionMenuItemData -from rigi.widgets.checkbox import RigiCheckbox -from rigi.widgets.content_area import RigiContentArea -from rigi.widgets.vertical_tabs import RigiVerticalTabs -from rigi.widgets.gauge import RigiGauge, RigiSparkline +from rigi.themes import Theme +from rigi.widgets.border_frame import BorderFrame +from rigi.widgets.bottom_panel import BottomPanel +from rigi.widgets.action_menu import ActionMenuItemData +from rigi.widgets.checkbox import Checkbox +from rigi.widgets.content_area import ContentArea +from rigi.widgets.tab_group import TabGroup +from rigi.widgets.gauge import Gauge, Sparkline from rigi.widgets.hamburger_menu import ( - RigiHamburgerPanel, - RigiMenuItem, - RigiMenuItemData, - RigiMenuPanel, + HamburgerPanel, + MenuItem, + MenuItemData, + MenuPanel, ) -from rigi.widgets.help_panel import RigiShortcutsBar, extract_help_annotation -from rigi.widgets.image import RigiImage, TerminalImageProtocol, detect_image_protocol -from rigi.widgets.mouse import RigiClickable, RigiDraggable, RigiMouseMixin +from rigi.widgets.help_panel import ShortcutsBar, extract_help_annotation +from rigi.widgets.image import Image, TerminalImageProtocol, detect_image_protocol +from rigi.widgets.mouse import Clickable, Draggable, MouseMixin from rigi.widgets.notifications import ( - RigiNotificationRack as RigiNotificationRack, + NotificationRack as NotificationRack, ) from rigi.widgets.notifications import ( - RigiNotificationWidget as RigiNotificationWidget, + NotificationWidget as NotificationWidget, ) from rigi.core.settings_manager import Setting, SettingsManager, SettingsPage -from rigi.widgets.settings_screen import RigiSettingDef, RigiSettingsScreen -from rigi.widgets.sidebar import RigiSidebar -from rigi.widgets.statusbar import RigiStatusBar, RigiStatusItem -from rigi.widgets.terminal_bar import RigiTerminalBar +from rigi.widgets.settings_screen import SettingDef, SettingsScreen +from rigi.widgets.sidebar import Sidebar +from rigi.widgets.statusbar import StatusBar, StatusBarItem +from rigi.widgets.terminal_bar import TerminalBar __version__ = "1.2.0" __all__ = [ @@ -86,8 +86,8 @@ "ModalScreen", "reactive", # Core app - "RigiApp", - "RigiTheme", + "App", + "Theme", "ThemeDark", "ThemeLight", "ThemeMonokai", @@ -104,46 +104,46 @@ "SubtabDef", "HelpEntry", # Layout - "RigiPane", - "RigiHPane", - "RigiVPane", - "RigiScrollPane", - "RigiCard", - "RigiSplit", + "Pane", + "HPane", + "VPane", + "ScrollPane", + "Card", + "Split", # Widgets - "RigiStatusBar", - "RigiStatusItem", - "RigiSidebar", - "RigiTerminalBar", - "RigiBottomPanel", - "RigiCheckbox", - "RigiContentArea", - "RigiBorderFrame", - "RigiHamburgerScreen", - "RigiMenuItem", - "RigiMenuPanel", - "RigiHamburgerPanel", - "RigiMenuItemData", - "RigiSettingsScreen", - "RigiSettingDef", + "StatusBar", + "StatusItem", + "Sidebar", + "TerminalBar", + "BottomPanel", + "Checkbox", + "ContentArea", + "BorderFrame", + "HamburgerScreen", + "MenuItem", + "MenuPanel", + "HamburgerPanel", + "MenuItemData", + "SettingsScreen", + "SettingDef", "Setting", "SettingsPage", "SettingsManager", - "RigiImage", + "Image", "TerminalImageProtocol", "detect_image_protocol", - "RigiMouseMixin", - "RigiClickable", - "RigiDraggable", - "RigiHelpScreen", - "RigiShortcutsBar", + "MouseMixin", + "Clickable", + "Draggable", + "HelpScreen", + "ShortcutsBar", "extract_help_annotation", - "RigiGauge", - "RigiSparkline", - "RigiNotificationRack", - "RigiNotificationWidget", - "RigiVerticalTabs", - "RigiActionMenuItemData", + "Gauge", + "Sparkline", + "NotificationRack", + "NotificationWidget", + "TabGroup", + "ActionMenuItemData", # Platform utilities "platform", "console", diff --git a/src/rigi/commands/provider.py b/src/rigi/commands/provider.py index efb9a93..e1677bd 100644 --- a/src/rigi/commands/provider.py +++ b/src/rigi/commands/provider.py @@ -9,7 +9,7 @@ from rigi.commands.command import Command if TYPE_CHECKING: - from rigi.core.app import RigiApp + from rigi.core.app import App def _fuzzy_score(query: str, candidate: str) -> float | None: @@ -32,18 +32,18 @@ def _fuzzy_score(query: str, candidate: str) -> float | None: return raw / max(len(candidate), 1) -class RigiCommandProvider(Provider): +class CommandProvider(Provider): """Bridges Rigi's CommandRegistry into Textual's built-in command palette.""" _commands: list[Command] async def startup(self) -> None: - app: RigiApp = self.app # type: ignore[assignment] + app: App = self.app # type: ignore[assignment] self._commands = list(app._cmd_registry.visible()) async def search(self, query: str) -> Hits: def _run(name: str) -> None: - app: RigiApp = self.app # type: ignore[assignment] + app: App = self.app # type: ignore[assignment] self.app.call_later(app._handle_command, name) scored: list[tuple[float, Command]] = [] diff --git a/src/rigi/core/__init__.py b/src/rigi/core/__init__.py index 336a44e..ce61499 100644 --- a/src/rigi/core/__init__.py +++ b/src/rigi/core/__init__.py @@ -1,11 +1,11 @@ """Rigi core modules — app, types, platform, console.""" from rigi.core import console, platform -from rigi.core.app import RigiApp +from rigi.core.app import App from rigi.core.types import CommandArg, HandlerFn, HelpEntry, StatusItem, SubtabDef, TabDef __all__ = [ - "RigiApp", + "App", "platform", "console", "CommandArg", diff --git a/src/rigi/core/_cmd_handlers.py b/src/rigi/core/_cmd_handlers.py index a073a00..473c985 100644 --- a/src/rigi/core/_cmd_handlers.py +++ b/src/rigi/core/_cmd_handlers.py @@ -1,18 +1,18 @@ -"""Built-in terminal command handlers for RigiApp.""" +"""Built-in terminal command handlers for App.""" from __future__ import annotations from typing import TYPE_CHECKING, Any import rigi.core.console as _console -from rigi.widgets.bottom_panel import RigiBottomPanel +from rigi.widgets.bottom_panel import BottomPanel if TYPE_CHECKING: - from rigi.core.app import RigiApp + from rigi.core.app import App -async def cmd_terminal(app: RigiApp, **_: Any) -> None: - from rigi.widgets.bottom_panel import RigiBottomPanel +async def cmd_terminal(app: App, **_: Any) -> None: + from rigi.widgets.bottom_panel import BottomPanel nfo = _console.info() lines = [ @@ -29,20 +29,20 @@ async def cmd_terminal(app: RigiApp, **_: Any) -> None: f" Size: {nfo['columns']}×{nfo['lines']}", ] try: - app.query_one(RigiBottomPanel).write_output("\n".join(lines)) + app.query_one(BottomPanel).write_output("\n".join(lines)) except Exception: app.notify("\n".join(lines), title="Terminal Info", timeout=8) -async def cmd_help(app: RigiApp, **kwargs: Any) -> None: - from rigi.widgets.bottom_panel import RigiBottomPanel +async def cmd_help(app: App, **kwargs: Any) -> None: + from rigi.widgets.bottom_panel import BottomPanel cmd_name = kwargs.get("command") registry = app.cmd_registry def _output(text: str) -> None: try: - app.query_one(RigiBottomPanel).write_output(text) + app.query_one(BottomPanel).write_output(text) except Exception: app.notify(text, title="Help", timeout=12) @@ -86,12 +86,12 @@ def _output(text: str) -> None: _output("\n".join(lines)) -async def cmd_quit(app: RigiApp, **_: Any) -> None: +async def cmd_quit(app: App, **_: Any) -> None: app.exit() -async def cmd_clear(app: RigiApp, **_: Any) -> None: +async def cmd_clear(app: App, **_: Any) -> None: try: - app.query_one(RigiBottomPanel).clear_history_view() + app.query_one(BottomPanel).clear_history_view() except Exception: pass diff --git a/src/rigi/core/app.py b/src/rigi/core/app.py index 113a1d2..f55487e 100644 --- a/src/rigi/core/app.py +++ b/src/rigi/core/app.py @@ -9,14 +9,14 @@ from typing import Any, Awaitable, Callable from textual import on -from textual.app import App, ComposeResult +from textual.app import App as _TextualApp, ComposeResult from textual.binding import Binding from textual.notifications import SeverityLevel from textual.widget import Widget from rigi.commands.command import Command from rigi.commands.parser import build_cli_parser, parse_inline -from rigi.commands.provider import RigiCommandProvider +from rigi.commands.provider import CommandProvider from rigi.commands.registry import CommandRegistry from rigi.core import console as _console from rigi.core import log_store @@ -25,22 +25,22 @@ from rigi.core.dev_commands import register_dev_commands from rigi.core.settings_manager import SettingsManager from rigi.core.types import HandlerFn, HelpEntry, StatusItem, SubtabDef, TabDef -from rigi.screens.action_menu import RigiActionMenuScreen -from rigi.screens.hamburger import RigiHamburgerScreen -from rigi.screens.help import RigiHelpScreen -from rigi.screens.settings import RigiSettingDef, RigiSettingsScreen +from rigi.screens.action_menu import ActionMenuScreen +from rigi.widgets.hamburger_overlay import HamburgerOverlay +from rigi.screens.help import HelpScreen +from rigi.screens.settings import SettingDef, SettingsScreen from rigi.themes import DARK as _DEFAULT_THEME -from rigi.themes import RigiTheme -from rigi.widgets.border_frame import RigiBorderFrame -from rigi.widgets.bottom_panel import RigiBottomPanel -from rigi.widgets.content_area import RigiContentArea -from rigi.widgets.action_menu import RigiActionMenuItemData -from rigi.widgets.hamburger_menu import RigiMenuItemData -from rigi.widgets.help_panel import RigiShortcutsBar, extract_help_annotation -from rigi.widgets.notifications import RigiNotificationRack -from rigi.widgets.sidebar import RigiSidebar +from rigi.themes import Theme +from rigi.widgets.border_frame import BorderFrame +from rigi.widgets.bottom_panel import BottomPanel +from rigi.widgets.content_area import ContentArea +from rigi.widgets.action_menu import ActionMenuItemData +from rigi.widgets.hamburger_menu import MenuItemData +from rigi.widgets.help_panel import ShortcutsBar, extract_help_annotation +from rigi.widgets.notifications import NotificationRack +from rigi.widgets.sidebar import Sidebar from rigi.widgets.statusbar import ( - RigiStatusBar, + StatusBar, _HamburgerButton, _HomeButton, ) @@ -51,15 +51,15 @@ _CSS_PATH = Path(__file__).parent.parent / "css" / "default.tcss" -class _RigiBody(Widget): +class _Body(Widget): def compose(self) -> ComposeResult: yield from [] -class RigiApp(App[None]): +class App(_TextualApp[None]): CSS_PATH = str(_CSS_PATH) - COMMANDS = {RigiCommandProvider} + COMMANDS = {CommandProvider} BINDINGS = [ Binding("ctrl+q", "quit", "Quit", priority=True), @@ -80,7 +80,7 @@ def __init__( username: str | None = None, sidebar_width: int = 20, terminal_label: str | None = None, - theme: RigiTheme | None = None, + theme: Theme | None = None, home_tab: str | None = None, persist_history: bool = True, ) -> None: @@ -105,14 +105,14 @@ def __init__( resolved_theme = {"dark": DARK, "light": LIGHT, "monokai": MONOKAI, "nord": NORD}.get( env_theme ) - self._theme: RigiTheme = resolved_theme if resolved_theme is not None else _DEFAULT_THEME + self._theme: Theme = resolved_theme if resolved_theme is not None else _DEFAULT_THEME self._theme_tie_breaker: int = 200 self._home_tab_name: str | None = home_tab self._cmd_registry = CommandRegistry() self._rigi_tabs: list[TabDef] = [] self._rigi_status_items: list[StatusItem] = [] - self._rigi_startup_hooks: list[Callable[[RigiApp], Awaitable[None] | None]] = [] + self._rigi_startup_hooks: list[Callable[[App], Awaitable[None] | None]] = [] self._rigi_widget_cache: dict[tuple[int, ...], Widget] = {} self._rigi_extra_css: list[Path] = [] self._rigi_help_entries: list[HelpEntry] = [] @@ -174,18 +174,18 @@ def _register_builtin_commands(self) -> None: # ------------------------------------------------------------------ # def compose(self) -> ComposeResult: - status_bar = RigiStatusBar() + status_bar = StatusBar() for item in self._rigi_status_items: status_bar._items.append(item) - with RigiBorderFrame(self._prog_name, self._version): + with BorderFrame(self._prog_name, self._version): yield status_bar - with _RigiBody(): - yield RigiSidebar() - yield RigiContentArea() + with _Body(): + yield Sidebar() + yield ContentArea() - yield RigiShortcutsBar() + yield ShortcutsBar() prompt = self._terminal_label or f"{self._username}@{self._prog_name}" history_file: Path | None = None if self._persist_history: @@ -193,12 +193,12 @@ def compose(self) -> ComposeResult: history_file = _platform_utils.config_dir(self._prog_name) / "terminal_history" except Exception: pass - yield RigiBottomPanel( + yield BottomPanel( prompt_text=prompt, registry=self._cmd_registry, history_file=history_file, ) - yield RigiNotificationRack() + yield NotificationRack() def on_mount(self) -> None: self.title = f"{self._prog_name} v{self._version}" @@ -209,7 +209,7 @@ def on_mount(self) -> None: for css_path in self._rigi_extra_css: self._apply_css_file(css_path) - sidebar = self.query_one(RigiSidebar) + sidebar = self.query_one(Sidebar) sidebar.set_tabs(self._rigi_tabs) if self._rigi_tabs: @@ -251,7 +251,7 @@ def notify( message = message.replace("[", "\\[") effective_timeout = timeout if timeout is not None else 5.0 try: - self.query_one(RigiNotificationRack).add_notification( + self.query_one(NotificationRack).add_notification( title, message, severity, effective_timeout ) except Exception: @@ -293,7 +293,7 @@ def register_css(self, path: str | Path) -> None: if self.is_running: self._apply_css_file(p) - def set_theme(self, theme: RigiTheme) -> None: + def set_theme(self, theme: Theme) -> None: try: self._theme = theme self._theme_tie_breaker += 1 @@ -342,9 +342,9 @@ def _apply_transparency(self) -> None: App, Screen {{ background: transparent; }} -RigiBorderFrame, _RigiBody, RigiSidebar, RigiContentArea, #content-main, -_RigiMainNav, _RigiSubNav, RigiBottomPanel, RigiTerminalBar, -RigiStatusBar, RigiShortcutsBar, _VerticalResizeHandle, _ContentResizeHandle {{ +BorderFrame, _Body, Sidebar, ContentArea, #content-main, +_MainNav, _SubNav, BottomPanel, TerminalBar, +StatusBar, ShortcutsBar, _VerticalResizeHandle, _ContentResizeHandle {{ background: {rgba}; }} """ @@ -353,9 +353,9 @@ def _apply_transparency(self) -> None: App, Screen {{ background: {self._theme.bg_color}; }} -RigiBorderFrame, _RigiBody, RigiSidebar, RigiContentArea, #content-main, -_RigiMainNav, _RigiSubNav, RigiBottomPanel, RigiTerminalBar, -RigiStatusBar, RigiShortcutsBar, _VerticalResizeHandle, _ContentResizeHandle {{ +BorderFrame, _Body, Sidebar, ContentArea, #content-main, +_MainNav, _SubNav, BottomPanel, TerminalBar, +StatusBar, ShortcutsBar, _VerticalResizeHandle, _ContentResizeHandle {{ background: {self._theme.bg_color}; }} """ @@ -388,8 +388,8 @@ def _cycle_theme(self) -> None: # Navigation # # ------------------------------------------------------------------ # - @on(RigiSidebar.NavigationChanged) - def on_sidebar_nav(self, event: RigiSidebar.NavigationChanged) -> None: + @on(Sidebar.NavigationChanged) + def on_sidebar_nav(self, event: Sidebar.NavigationChanged) -> None: self._navigate_to(event.tab_idx, event.subtab_path) self._update_home_button() @@ -402,9 +402,9 @@ def _home_tab_idx(self) -> int: def _update_home_button(self) -> None: try: - sidebar = self.query_one(RigiSidebar) + sidebar = self.query_one(Sidebar) on_home = sidebar._active_tab == self._home_tab_idx() and sidebar._active_path == [] - self.query_one(RigiStatusBar).set_home_active(on_home) + self.query_one(StatusBar).set_home_active(on_home) except Exception: pass @@ -432,7 +432,7 @@ def _navigate_to(self, tab_idx: int, subtab_path: list[int]) -> None: return self._rigi_widget_cache[cache_key] = factory() - self.query_one(RigiContentArea).show_widget(self._rigi_widget_cache[cache_key]) + self.query_one(ContentArea).show_widget(self._rigi_widget_cache[cache_key]) except Exception as e: _ui_log.error(f"Error navigating to tab {tab_idx}: {e}", exc_info=True) self.notify("Navigation error - check logs", severity="error") @@ -451,13 +451,13 @@ def _resolve_factory(self, tab: TabDef, path: list[int]) -> Callable[[], Widget] def navigate_to_tab(self, name: str) -> bool: for idx, tab in enumerate(self._rigi_tabs): if tab.name.lower() == name.lower(): - self.query_one(RigiSidebar).jump_to_tab_by_key(tab.key or "") + self.query_one(Sidebar).jump_to_tab_by_key(tab.key or "") self._navigate_to(idx, []) return True return False def invalidate_tab_cache(self, tab_name: str | None = None) -> None: - content = self.query_one(RigiContentArea) if self.is_running else None + content = self.query_one(ContentArea) if self.is_running else None def _evict(widget: Widget) -> None: if content and widget is content._current: @@ -481,8 +481,8 @@ def _evict(widget: Widget) -> None: # Terminal command processing # # ------------------------------------------------------------------ # - @on(RigiBottomPanel.CommandSubmitted) - def on_command_submitted(self, event: RigiBottomPanel.CommandSubmitted) -> None: + @on(BottomPanel.CommandSubmitted) + def on_command_submitted(self, event: BottomPanel.CommandSubmitted) -> None: self.run_worker(self._handle_command(event.text), name="rigi-cmd", exclusive=False) async def _handle_command(self, text: str) -> None: @@ -550,14 +550,14 @@ async def _run_shell(self, cmd: str) -> None: display = (raw[:1200] if raw else "(no output)").replace("[", "\\[") _terminal_log.info(f"Shell command completed: {cmd}") try: - self.query_one(RigiBottomPanel).write_output(display) + self.query_one(BottomPanel).write_output(display) except Exception: self.notify(display, title=f"$ {cmd[:40]}", timeout=12) except Exception as exc: msg = str(exc).replace("[", "\\[") _terminal_log.error(f"Shell command failed: {cmd}", exc_info=True) try: - self.query_one(RigiBottomPanel).write_output(f"[red]{msg}[/red]") + self.query_one(BottomPanel).write_output(f"[red]{msg}[/red]") except Exception: self.notify(msg, severity="error", title=f"$ {cmd[:30]}") @@ -568,14 +568,23 @@ async def _run_shell(self, cmd: str) -> None: @on(_HamburgerButton.Clicked) def on_hamburger_clicked(self, event: _HamburgerButton.Clicked) -> None: event.stop() - self.push_screen(RigiHamburgerScreen(self._build_hamburger_sections())) + self._open_hamburger() - def _build_hamburger_sections(self) -> list[tuple[str, list[RigiMenuItemData]]]: + def _open_hamburger(self) -> None: + try: + existing = self.query_one(HamburgerOverlay) + existing._close() + return + except Exception: + pass + self.mount(HamburgerOverlay(self._build_hamburger_sections())) + + def _build_hamburger_sections(self) -> list[tuple[str, list[MenuItemData]]]: from rigi.themes import DARK, LIGHT, MONOKAI, NORD builtin_themes = [DARK, LIGHT, MONOKAI, NORD] theme_submenu = [ - RigiMenuItemData( + MenuItemData( label=t.name.capitalize(), callback=lambda _t=t: self.set_theme(_t), checked=(t.name == self._theme.name), @@ -583,20 +592,20 @@ def _build_hamburger_sections(self) -> list[tuple[str, list[RigiMenuItemData]]]: for t in builtin_themes ] - main_items: list[RigiMenuItemData] = [ - RigiMenuItemData("Theme", submenu=theme_submenu), - RigiMenuItemData("Settings", callback=self._open_settings), - RigiMenuItemData( + main_items: list[MenuItemData] = [ + MenuItemData("Theme", submenu=theme_submenu), + MenuItemData("Settings", callback=self._open_settings), + MenuItemData( "Help", callback=lambda: self.run_worker(self.action_show_help(), name="rigi-help"), ), ] - by_section: dict[str, list[RigiMenuItemData]] = {} + by_section: dict[str, list[MenuItemData]] = {} for sec, lbl, cb in self._rigi_menu_items: - by_section.setdefault(sec, []).append(RigiMenuItemData(lbl, cb)) + by_section.setdefault(sec, []).append(MenuItemData(lbl, cb)) - sections: list[tuple[str, list[RigiMenuItemData]]] = [("", main_items)] + sections: list[tuple[str, list[MenuItemData]]] = [("", main_items)] for sec_name, items in by_section.items(): sections.append((sec_name, items)) return sections @@ -610,8 +619,8 @@ def settings(self) -> SettingsManager: return self._settings_manager def _open_settings(self) -> None: - builtin: list[RigiSettingDef] = [ - RigiSettingDef( + builtin: list[SettingDef] = [ + SettingDef( category="Appearance", label="Theme", description="Color theme for the interface", @@ -619,25 +628,25 @@ def _open_settings(self) -> None: action_fn=self._cycle_theme, action_label="Cycle", ), - RigiSettingDef( + SettingDef( category="Terminal", label="Emulator", description="Detected terminal application", value_fn=lambda: _console.detect_terminal(), ), - RigiSettingDef( + SettingDef( category="Terminal", label="True color", description="24-bit color support", value_fn=lambda: "yes" if _console.supports_true_color() else "no", ), - RigiSettingDef( + SettingDef( category="Terminal", label="Hyperlinks", description="OSC 8 clickable link support", value_fn=lambda: "yes" if _console.supports_hyperlinks() else "no", ), - RigiSettingDef( + SettingDef( category="Terminal", label="Multiplexer", description="Running inside tmux or screen", @@ -645,13 +654,13 @@ def _open_settings(self) -> None: "tmux" if _console.IS_TMUX else ("screen" if _console.IS_SCREEN else "none") ), ), - RigiSettingDef( + SettingDef( category="Terminal", label="Unicode", description="UTF-8 output encoding", value_fn=lambda: "yes" if _console.supports_unicode() else "no", ), - RigiSettingDef( + SettingDef( category="Appearance", label="Transparent", description="Enable transparent background with adjustable opacity", @@ -661,7 +670,7 @@ def _open_settings(self) -> None: write_fn=self._set_transparency_percent, ), ] - self.push_screen(RigiSettingsScreen(builtin + self._settings_manager.all_defs())) + self.push_screen(SettingsScreen(builtin + self._settings_manager.all_defs())) # ------------------------------------------------------------------ # # Keyboard actions # @@ -675,25 +684,25 @@ def _terminal_input_focused(self) -> bool: def action_nav_up(self) -> None: if not self._terminal_input_focused(): - self.query_one(RigiSidebar).navigate(-1) + self.query_one(Sidebar).navigate(-1) def action_nav_down(self) -> None: if not self._terminal_input_focused(): - self.query_one(RigiSidebar).navigate(1) + self.query_one(Sidebar).navigate(1) def action_nav_right(self) -> None: if not self._terminal_input_focused(): - self.query_one(RigiSidebar).navigate_right() + self.query_one(Sidebar).navigate_right() def action_nav_left(self) -> None: if not self._terminal_input_focused(): - self.query_one(RigiSidebar).navigate_left() + self.query_one(Sidebar).navigate_left() def action_focus_terminal(self) -> None: - self.query_one(RigiBottomPanel).focus_input() + self.query_one(BottomPanel).focus_input() async def action_show_help(self) -> None: - await self.push_screen(RigiHelpScreen(self._rigi_help_entries)) + await self.push_screen(HelpScreen(self._rigi_help_entries)) def action_copy_focused(self) -> None: text = self._extract_focused_text() @@ -749,7 +758,7 @@ def _extract_focused_text(self) -> str: async def action_quit(self) -> None: try: - self.query_one(RigiBottomPanel).save_history() + self.query_one(BottomPanel).save_history() except Exception: pass self.exit() @@ -775,7 +784,7 @@ def add_status( ) self._rigi_status_items.append(item) if self.is_running: - self.query_one(RigiStatusBar).add_item(item) + self.query_one(StatusBar).add_item(item) return item def add_menu_item( @@ -789,7 +798,7 @@ def add_menu_item( def set_terminal_label(self, label: str) -> None: self._terminal_label = label try: - self.query_one(RigiBottomPanel).prompt_text = label + self.query_one(BottomPanel).prompt_text = label except Exception: pass @@ -802,12 +811,24 @@ def open_path(self, path: str | Path) -> bool: def show_action_menu( self, - items: list[RigiActionMenuItemData], + items: list[ActionMenuItemData], title: str = "", x: int | None = None, y: int | None = None, ) -> None: - self.push_screen(RigiActionMenuScreen(items, title=title, anchor_x=x, anchor_y=y)) + self.push_screen(ActionMenuScreen(items, title=title, anchor_x=x, anchor_y=y)) + + def on_click(self, event: Any) -> None: + if hasattr(event, "button") and event.button == 3: + items = self._context_menu_items() + if items: + self.show_action_menu(items, x=event.x, y=event.y) + + def _context_menu_items(self) -> list[ActionMenuItemData]: + return [] + + def set_context_menu(self, items: list[ActionMenuItemData]) -> None: + self._context_menu_items = lambda: items def notify_desktop(self, title: str, body: str = "", urgency: str = "normal") -> bool: return _platform_utils.notify_desktop(title, body, urgency) @@ -857,8 +878,8 @@ def register_command(self, cmd: Command) -> Command: return cmd def on_startup( - self, fn: Callable[[RigiApp], Awaitable[None] | None] - ) -> Callable[[RigiApp], Awaitable[None] | None]: + self, fn: Callable[[App], Awaitable[None] | None] + ) -> Callable[[App], Awaitable[None] | None]: self._rigi_startup_hooks.append(fn) return fn @@ -871,7 +892,7 @@ def cmd_registry(self) -> CommandRegistry: # ------------------------------------------------------------------ # @classmethod - def run_cli(cls, app_instance: RigiApp) -> None: + def run_cli(cls, app_instance: App) -> None: parser = build_cli_parser( prog_name=app_instance._prog_name, version=app_instance._version, diff --git a/src/rigi/core/dev_commands.py b/src/rigi/core/dev_commands.py index 9321aff..5a4e5a4 100644 --- a/src/rigi/core/dev_commands.py +++ b/src/rigi/core/dev_commands.py @@ -20,7 +20,7 @@ from rigi.commands.command import Command if TYPE_CHECKING: - from rigi.core.app import RigiApp + from rigi.core.app import App # Создаем специализированные логгеры _log = logging.getLogger("rigi.dev") @@ -70,7 +70,7 @@ def _tree_lines(widget: Any, depth: int = 0, max_depth: int = 4) -> list[str]: # ── Command handlers ─────────────────────────────────────────────────────────── -async def _cmd_help(app: RigiApp, **_: Any) -> None: +async def _cmd_help(app: App, **_: Any) -> None: lines = ["[bold]sudo commands[/bold] (hidden from normal autocomplete):\n"] for sub in _SUBCOMMANDS: aliases = f" [dim]({', '.join(sub.aliases)})[/dim]" if sub.aliases else "" @@ -80,19 +80,19 @@ async def _cmd_help(app: RigiApp, **_: Any) -> None: app.notify("\n".join(lines), title="Dev Commands", timeout=15) -async def _cmd_clear_cache(app: RigiApp, **_: Any) -> None: +async def _cmd_clear_cache(app: App, **_: Any) -> None: n = len(app._rigi_widget_cache) app.invalidate_tab_cache() app.notify(f"Cleared {n} cached widget(s)", title="sudo cc", timeout=3) -async def _cmd_reload(app: RigiApp, **_: Any) -> None: +async def _cmd_reload(app: App, **_: Any) -> None: app.invalidate_tab_cache() app.refresh(layout=True) app.notify("All caches cleared + layout refreshed", title="sudo reload", timeout=3) -async def _cmd_reload_css(app: RigiApp, **_: Any) -> None: +async def _cmd_reload_css(app: App, **_: Any) -> None: try: app.refresh_css(animate=False) app.notify("CSS reloaded", title="sudo rcss", timeout=3) @@ -100,11 +100,11 @@ async def _cmd_reload_css(app: RigiApp, **_: Any) -> None: app.notify(str(e), severity="error", title="sudo rcss") -async def _cmd_css(app: RigiApp, **kwargs: Any) -> None: +async def _cmd_css(app: App, **kwargs: Any) -> None: rule = " ".join(str(v) for v in kwargs.values() if v).strip() if not rule: app.notify( - "Usage: sudo css (e.g. sudo css 'RigiCard { opacity: 0.8; }')", + "Usage: sudo css (e.g. sudo css 'Card { opacity: 0.8; }')", severity="warning", ) return @@ -121,14 +121,14 @@ async def _cmd_css(app: RigiApp, **kwargs: Any) -> None: app.notify(str(e), severity="error", title="sudo css") -async def _cmd_dump_theme(app: RigiApp, **_: Any) -> None: +async def _cmd_dump_theme(app: App, **_: Any) -> None: css = app._theme.to_css() path = Path(f"/tmp/rigi_theme_{app._prog_name}.css") path.write_text(css, encoding="utf-8") app.notify(f"Written to {path}\n\n{css[:400]}…", title="sudo dump_theme", timeout=10) -async def _cmd_inspect(app: RigiApp, **_: Any) -> None: +async def _cmd_inspect(app: App, **_: Any) -> None: focused = app.focused if focused is None: app.notify("No focused widget", severity="warning", title="sudo inspect") @@ -148,7 +148,7 @@ async def _cmd_inspect(app: RigiApp, **_: Any) -> None: app.notify("\n".join(lines), title="sudo inspect", timeout=10) -async def _cmd_tree(app: RigiApp, **_: Any) -> None: +async def _cmd_tree(app: App, **_: Any) -> None: try: screen = app.screen lines = _tree_lines(screen, max_depth=4) @@ -160,7 +160,7 @@ async def _cmd_tree(app: RigiApp, **_: Any) -> None: app.notify(str(e), severity="error", title="sudo tree") -async def _cmd_focus(app: RigiApp, **_: Any) -> None: +async def _cmd_focus(app: App, **_: Any) -> None: focused = app.focused if focused is None: app.notify("No widget focused", title="sudo focus", timeout=4) @@ -168,7 +168,7 @@ async def _cmd_focus(app: RigiApp, **_: Any) -> None: app.notify(_widget_path(focused), title="sudo focus", timeout=6) -async def _cmd_mem(app: RigiApp, **_: Any) -> None: +async def _cmd_mem(app: App, **_: Any) -> None: lines: list[str] = [] try: import resource @@ -197,14 +197,14 @@ async def _cmd_mem(app: RigiApp, **_: Any) -> None: app.notify("\n".join(lines) or "Memory info unavailable", title="sudo mem", timeout=8) -async def _cmd_gc(app: RigiApp, **_: Any) -> None: +async def _cmd_gc(app: App, **_: Any) -> None: before = sum(_gc.get_count()) n = _gc.collect() after = sum(_gc.get_count()) app.notify(f"Collected {n} objects (count {before}→{after})", title="sudo gc", timeout=4) -async def _cmd_tracemalloc(app: RigiApp, **_: Any) -> None: +async def _cmd_tracemalloc(app: App, **_: Any) -> None: import tracemalloc if tracemalloc.is_tracing(): @@ -219,7 +219,7 @@ async def _cmd_tracemalloc(app: RigiApp, **_: Any) -> None: ) -async def _cmd_screenshot(app: RigiApp, **_: Any) -> None: +async def _cmd_screenshot(app: App, **_: Any) -> None: try: svg = app.export_screenshot() path = Path(f"/tmp/rigi_{app._prog_name}_{os.getpid()}.svg") @@ -229,7 +229,7 @@ async def _cmd_screenshot(app: RigiApp, **_: Any) -> None: app.notify(str(e), severity="error", title="sudo screenshot") -async def _cmd_env(app: RigiApp, **kwargs: Any) -> None: +async def _cmd_env(app: App, **kwargs: Any) -> None: query = " ".join(str(v) for v in kwargs.values() if v).strip().lower() items = sorted(os.environ.items()) if query: @@ -243,7 +243,7 @@ async def _cmd_env(app: RigiApp, **kwargs: Any) -> None: app.notify("\n".join(lines), title=f"sudo env{' [' + query + ']' if query else ''}", timeout=12) -async def _cmd_tabs(app: RigiApp, **_: Any) -> None: +async def _cmd_tabs(app: App, **_: Any) -> None: lines: list[str] = [] for i, tab in enumerate(app._rigi_tabs): cached_keys = [k for k in app._rigi_widget_cache if k[0] == i] @@ -258,7 +258,7 @@ async def _cmd_tabs(app: RigiApp, **_: Any) -> None: app.notify("\n".join(lines) or "(no tabs)", title="sudo tabs", timeout=10) -async def _cmd_cmds(app: RigiApp, **_: Any) -> None: +async def _cmd_cmds(app: App, **_: Any) -> None: all_cmds = app._cmd_registry.all() lines = ["[bold]All registered commands (including hidden):[/bold]\n"] for cmd in sorted(all_cmds, key=lambda c: c.name): @@ -269,14 +269,14 @@ async def _cmd_cmds(app: RigiApp, **_: Any) -> None: app.notify("\n".join(lines), title="sudo cmds", timeout=12) -async def _cmd_bell(app: RigiApp, **_: Any) -> None: +async def _cmd_bell(app: App, **_: Any) -> None: from rigi.core import console console.write_escape(console.bell()) app.notify("🔔", title="sudo bell", timeout=2) -async def _cmd_dn_test(app: RigiApp, **_: Any) -> None: +async def _cmd_dn_test(app: App, **_: Any) -> None: from rigi.core import platform ok = platform.notify_desktop("Rigi Dev", f"Test from {app._prog_name}") @@ -287,12 +287,12 @@ async def _cmd_dn_test(app: RigiApp, **_: Any) -> None: ) -async def _cmd_crash(app: RigiApp, **kwargs: Any) -> None: +async def _cmd_crash(app: App, **kwargs: Any) -> None: msg = " ".join(str(v) for v in kwargs.values() if v).strip() or "Test crash from sudo crash" raise RuntimeError(msg) -async def _cmd_python(app: RigiApp, **kwargs: Any) -> None: +async def _cmd_python(app: App, **kwargs: Any) -> None: expr = " ".join(str(v) for v in kwargs.values() if v).strip() if not expr: app.notify("Usage: sudo python ", severity="warning") @@ -305,7 +305,7 @@ async def _cmd_python(app: RigiApp, **kwargs: Any) -> None: app.notify(tb[:800], severity="error", title="sudo python") -async def _cmd_log(app: RigiApp, **kwargs: Any) -> None: +async def _cmd_log(app: App, **kwargs: Any) -> None: msg = " ".join(str(v) for v in kwargs.values() if v).strip() if not msg: app.notify("Usage: sudo log ", severity="warning") @@ -314,7 +314,7 @@ async def _cmd_log(app: RigiApp, **kwargs: Any) -> None: app.notify(f"Logged: {msg}", title="sudo log", timeout=3) -async def _cmd_log_level(app: RigiApp, **kwargs: Any) -> None: +async def _cmd_log_level(app: App, **kwargs: Any) -> None: level_str = " ".join(str(v) for v in kwargs.values() if v).strip().upper() level = getattr(logging, level_str, None) if not isinstance(level, int): @@ -328,7 +328,7 @@ async def _cmd_log_level(app: RigiApp, **kwargs: Any) -> None: app.notify(f"rigi logger → {level_str}", title="sudo log_level", timeout=3) -async def _cmd_perf(app: RigiApp, **_: Any) -> None: +async def _cmd_perf(app: App, **_: Any) -> None: try: import time @@ -347,7 +347,7 @@ async def _cmd_perf(app: RigiApp, **_: Any) -> None: app.notify(str(e), severity="error", title="sudo perf") -async def _cmd_set_theme(app: RigiApp, **kwargs: Any) -> None: +async def _cmd_set_theme(app: App, **kwargs: Any) -> None: from rigi.themes import DARK, LIGHT, MONOKAI, NORD name = " ".join(str(v) for v in kwargs.values() if v).strip().lower() @@ -360,7 +360,7 @@ async def _cmd_set_theme(app: RigiApp, **kwargs: Any) -> None: app.notify(f"Theme → {name}", title="sudo set_theme", timeout=2) -async def _cmd_console_info(app: RigiApp, **_: Any) -> None: +async def _cmd_console_info(app: App, **_: Any) -> None: from rigi.core import console nfo = console.info() @@ -379,7 +379,7 @@ async def _cmd_console_info(app: RigiApp, **_: Any) -> None: app.notify("\n".join(lines), title="sudo console", timeout=10) -async def _cmd_widget_styles(app: RigiApp, **_: Any) -> None: +async def _cmd_widget_styles(app: App, **_: Any) -> None: focused = app.focused if focused is None: app.notify("No focused widget", severity="warning", title="sudo styles") @@ -399,7 +399,7 @@ async def _cmd_widget_styles(app: RigiApp, **_: Any) -> None: app.notify(str(e), severity="error", title="sudo styles") -async def _cmd_hotkeys(app: RigiApp, **_: Any) -> None: +async def _cmd_hotkeys(app: App, **_: Any) -> None: lines = ["[bold]Active bindings:[/bold]\n"] try: from textual.binding import Binding as _Binding @@ -412,7 +412,7 @@ async def _cmd_hotkeys(app: RigiApp, **_: Any) -> None: app.notify("\n".join(lines), title="sudo hotkeys", timeout=8) -async def _cmd_reload_module(app: RigiApp, **kwargs: Any) -> None: +async def _cmd_reload_module(app: App, **kwargs: Any) -> None: mod_name = " ".join(str(v) for v in kwargs.values() if v).strip() if not mod_name: app.notify("Usage: sudo reload_module ", severity="warning") diff --git a/src/rigi/core/settings_manager.py b/src/rigi/core/settings_manager.py index 7580421..d3ad6db 100644 --- a/src/rigi/core/settings_manager.py +++ b/src/rigi/core/settings_manager.py @@ -3,7 +3,7 @@ from collections.abc import Iterable from typing import Callable -from rigi.screens.settings import RigiSettingDef +from rigi.screens.settings import SettingDef class Setting: @@ -29,8 +29,8 @@ def __init__( self.checkbox_fn = checkbox_fn self.toggle_fn = toggle_fn - def _to_def(self, category: str) -> RigiSettingDef: - return RigiSettingDef( + def _to_def(self, category: str) -> SettingDef: + return SettingDef( category=category, label=self.label, description=self.description, @@ -48,10 +48,10 @@ class SettingsPage: def __init__(self, name: str) -> None: self.name = name - self._defs: list[RigiSettingDef] = [] + self._defs: list[SettingDef] = [] @property - def settings(self) -> list[RigiSettingDef]: + def settings(self) -> list[SettingDef]: return self._defs @settings.setter @@ -79,8 +79,8 @@ def add_page(self, name: str) -> SettingsPage: self._pages.append(page) return page - def all_defs(self) -> list[RigiSettingDef]: - defs: list[RigiSettingDef] = [] + def all_defs(self) -> list[SettingDef]: + defs: list[SettingDef] = [] for page in self._pages: defs.extend(page._defs) return defs diff --git a/src/rigi/css/default.tcss b/src/rigi/css/default.tcss index b241232..c01c41a 100644 --- a/src/rigi/css/default.tcss +++ b/src/rigi/css/default.tcss @@ -8,7 +8,7 @@ Screen { background: transparent; } -RigiBorderFrame { +BorderFrame { width: 100%; height: 100%; border: round #30363d; @@ -51,7 +51,7 @@ _HomeButton { _HomeButton:hover { color: #c9d1d9; } _HomeButton.--active { color: #58a6ff; } -RigiStatusBar { +StatusBar { height: 2; layout: horizontal; padding: 0 1; @@ -59,7 +59,7 @@ RigiStatusBar { background: transparent; } -RigiStatusItem { +StatusItem { height: 1; padding: 0 1; width: auto; @@ -67,21 +67,21 @@ RigiStatusItem { background: transparent; } -_RigiBody { +_Body { layout: horizontal; height: 1fr; width: 100%; background: transparent; } -RigiSidebar { +Sidebar { layout: horizontal; height: 100%; width: auto; background: transparent; } -_RigiMainNav { +_MainNav { width: 20; height: 100%; overflow-y: auto; @@ -103,7 +103,7 @@ _MainNavItem.--active { text-style: bold; } -_RigiSubNav { +_SubNav { width: 18; height: 100%; overflow-y: auto; @@ -134,14 +134,14 @@ _SubNavItem { _SubNavItem:hover { color: #c9d1d9; } _SubNavItem.--active { color: #79c0ff; text-style: bold; } -_RigiEmptyState { +_EmptyState { height: 100%; width: 100%; content-align: center middle; } -_RigiEmptyState Label { color: #3d444d; width: auto; } +_EmptyState Label { color: #3d444d; width: auto; } -RigiContentArea { +ContentArea { height: 1fr; width: 1fr; layout: horizontal; @@ -151,22 +151,22 @@ RigiContentArea { } #content-main { width: 1fr; height: 100%; } -RigiShortcutsBar { +ShortcutsBar { height: 1; layout: horizontal; padding: 0 1; border-top: solid #21262d; background: transparent; } -RigiShortcutsBar Label { color: #6e7681; padding: 0 1; } +ShortcutsBar Label { color: #6e7681; padding: 0 1; } -RigiTerminalBar { +TerminalBar { height: 2; layout: vertical; background: #0d1117; } -RigiTerminalBar #input-row { +TerminalBar #input-row { height: 1; background: transparent; } @@ -179,7 +179,7 @@ _TerminalResizeHandle { } _TerminalResizeHandle:hover { color: #58a6ff; } -RigiTerminalBar Label { +TerminalBar Label { height: 1; color: #3fb950; padding: 0 0 0 1; @@ -187,7 +187,7 @@ RigiTerminalBar Label { content-align: left middle; } -RigiTerminalBar Input { +TerminalBar Input { height: 1; width: 1fr; border: none; @@ -195,35 +195,35 @@ RigiTerminalBar Input { background: transparent; color: #e6edf3; } -RigiTerminalBar Input:focus { border: none; } +TerminalBar Input:focus { border: none; } -RigiPane { +Pane { height: 100%; width: 100%; padding: 1 2; layout: vertical; } -RigiHPane { +HPane { layout: horizontal; height: auto; width: 100%; } -RigiVPane { +VPane { layout: vertical; height: 100%; width: 100%; } -RigiScrollPane { +ScrollPane { height: 100%; width: 100%; overflow-y: auto; overflow-x: hidden; } -RigiCard { +Card { border: round #21262d; border-title-color: #c9d1d9; border-title-align: left; @@ -233,17 +233,22 @@ RigiCard { width: 1fr; } -RigiSplit { +Split { layout: horizontal; height: 100%; width: 100%; } -RigiSplit > Widget { width: 1fr; height: 100%; } +Split > Widget { width: 1fr; height: 100%; } -RigiHamburgerScreen { background: transparent; } +HamburgerOverlay { + layer: overlay; + width: 100%; + height: 100%; + background: transparent; +} -RigiHelpScreen { align: center middle; } -RigiHelpScreen > #help-container { +HelpScreen { align: center middle; } +HelpScreen > #help-container { width: 60; height: auto; max-height: 80%; @@ -252,7 +257,7 @@ RigiHelpScreen > #help-container { padding: 1 2; overflow-y: auto; } -RigiHelpScreen #help-title { +HelpScreen #help-title { text-style: bold; color: #58a6ff; width: 100%; @@ -260,11 +265,11 @@ RigiHelpScreen #help-title { margin-bottom: 1; height: 1; } -RigiHelpScreen .help-category { color: #30363d; text-style: bold; margin-top: 1; height: 1; } -RigiHelpScreen .help-row { layout: horizontal; height: 1; width: 100%; } -RigiHelpScreen .help-key { width: 16; color: #e3b341; text-style: bold; } -RigiHelpScreen .help-desc { width: 1fr; color: #8b949e; } -RigiHelpScreen #help-dismiss { +HelpScreen .help-category { color: #30363d; text-style: bold; margin-top: 1; height: 1; } +HelpScreen .help-row { layout: horizontal; height: 1; width: 100%; } +HelpScreen .help-key { width: 16; color: #e3b341; text-style: bold; } +HelpScreen .help-desc { width: 1fr; color: #8b949e; } +HelpScreen #help-dismiss { margin-top: 1; color: #6e7681; content-align: center middle; @@ -272,9 +277,9 @@ RigiHelpScreen #help-dismiss { height: 1; } -RigiGauge { height: 1; width: 100%; } -RigiSparkline { height: 1; width: 100%; } -RigiImage { height: auto; width: auto; } +Gauge { height: 1; width: 100%; } +Sparkline { height: 1; width: 100%; } +Image { height: auto; width: auto; } _CategoryRow { height: 2; @@ -333,7 +338,7 @@ _SettingSwitch Switch { height: 1; width: auto; } _SettingsContent { width: 1fr; height: 100%; padding: 1 2; overflow-y: auto; background: transparent; } _SettingsContent ._cat-title { color: #58a6ff; text-style: bold; height: 1; margin-bottom: 1; } -RigiSettingsScreen { align: center middle; background: transparent; } +SettingsScreen { align: center middle; background: transparent; } #s-outer { width: 90%; height: 85%; @@ -381,8 +386,8 @@ RigiSettingsScreen { align: center middle; background: transparent; } _ContentResizeHandle { width: 1; height: 100%; background: transparent; color: #30363d; } _ContentResizeHandle:hover { color: #58a6ff; } -RigiMenuItem { height: 1; width: 100%; padding: 0 1; background: transparent; } -RigiMenuItem:hover { background: #1c2128; } +MenuItem { height: 1; width: 100%; padding: 0 1; background: transparent; } +MenuItem:hover { background: #1c2128; } _MenuSectionLabel { height: 1; @@ -393,7 +398,7 @@ _MenuSectionLabel { background: transparent; } -RigiMenuPanel { +MenuPanel { width: 26; height: auto; max-height: 24; @@ -404,7 +409,7 @@ RigiMenuPanel { overflow-y: auto; } -RigiActionMenuPanel { +ActionMenuPanel { width: 30; height: auto; max-height: 20; @@ -414,45 +419,51 @@ RigiActionMenuPanel { padding: 0; overflow-y: auto; } -RigiActionMenuItem { height: 1; width: 100%; padding: 0 1; background: transparent; } -RigiActionMenuItem:hover { background: #1c2128; } -RigiActionMenuItem.--disabled { color: #3d444d; } -RigiActionMenuScreen { background: transparent; } +ActionMenuItem { height: 1; width: 100%; padding: 0 1; background: transparent; } +ActionMenuItem:hover { background: #1c2128; } +ActionMenuItem.--disabled { color: #3d444d; } +ActionMenuScreen { background: transparent; } -RigiVerticalTabs { +TabGroup { height: 100%; width: 100%; - layout: horizontal; + layout: vertical; background: transparent; } -RigiVerticalTabs #vt-nav { - width: 18; - height: 100%; - overflow-y: auto; - overflow-x: hidden; +TabGroup #tabgroup-nav { + width: 100%; + height: auto; + overflow-x: auto; + overflow-y: hidden; background: transparent; - border-right: solid #21262d; - padding: 1 0; + border-bottom: solid #21262d; + padding: 0 1; } -_VerticalTabItem { - height: 1; +TabGroup #tabgroup-nav .tab-row { + layout: horizontal; + height: auto; width: 100%; - padding: 0 1; + background: transparent; +} +_TabItem { + height: 1; + width: auto; + padding: 0 2; color: #6e7681; background: transparent; } -_VerticalTabItem:hover { color: #c9d1d9; background: #161b22; } -_VerticalTabItem.--active { +_TabItem:hover { color: #c9d1d9; background: #161b22; } +_TabItem.--active { color: #58a6ff; text-style: bold; - border-left: thick #58a6ff; + border-bottom: thick #58a6ff; } -RigiVerticalTabs #vt-switcher { - width: 1fr; - height: 100%; +TabGroup #tabgroup-switcher { + width: 100%; + height: 1fr; background: transparent; } -RigiVerticalTabs #vt-switcher > Widget { +TabGroup #tabgroup-switcher > Widget { height: 100%; width: 100%; background: transparent; @@ -497,25 +508,25 @@ _LogsView #logs-controls Button { padding: 0 1; } -RigiBottomPanel { height: 12; layout: vertical; background: #0d1117; overflow: hidden; } +BottomPanel { height: 12; layout: vertical; background: #0d1117; overflow: hidden; } #bp-logs { height: 1fr; layout: vertical; overflow: hidden; } #bp-terminal { height: 1fr; layout: vertical; overflow: hidden; } -RigiBottomPanel Tabs { height: 3; background: #161b22; padding: 0; dock: none; } -RigiBottomPanel Tab { color: #8b949e; min-width: 12; } -RigiBottomPanel Tab:hover { color: #e6edf3; } -RigiBottomPanel Tab.-active { color: #58a6ff; } -RigiBottomPanel #bp-switcher { height: 1fr; } -RigiBottomPanel #bp-terminal { layout: vertical; height: 1fr; } -RigiBottomPanel #term-history { height: 1fr; background: transparent; } -RigiBottomPanel #input-row { height: 1; layout: horizontal; background: transparent; } -RigiBottomPanel #terminal-prompt { +BottomPanel Tabs { height: 3; background: #161b22; padding: 0; dock: none; } +BottomPanel Tab { color: #8b949e; min-width: 12; } +BottomPanel Tab:hover { color: #e6edf3; } +BottomPanel Tab.-active { color: #58a6ff; } +BottomPanel #bp-switcher { height: 1fr; } +BottomPanel #bp-terminal { layout: vertical; height: 1fr; } +BottomPanel #term-history { height: 1fr; background: transparent; } +BottomPanel #input-row { height: 1; layout: horizontal; background: transparent; } +BottomPanel #terminal-prompt { height: 1; color: #3fb950; padding: 0 0 0 1; width: auto; content-align: left middle; } -RigiBottomPanel #terminal-input { +BottomPanel #terminal-input { height: 1; width: 1fr; border: none; @@ -523,9 +534,9 @@ RigiBottomPanel #terminal-input { background: transparent; color: #e6edf3; } -RigiBottomPanel #terminal-input:focus { border: none; } +BottomPanel #terminal-input:focus { border: none; } -RigiNotificationRack { +NotificationRack { layer: overlay; width: 1fr; height: auto; @@ -537,7 +548,7 @@ RigiNotificationRack { layout: vertical; } -RigiNotificationWidget { +NotificationWidget { width: 44; max-width: 50%; height: auto; @@ -547,8 +558,8 @@ RigiNotificationWidget { background: #161b22; border-left: thick #58a6ff; } -RigiNotificationWidget.notif--warning { border-left: thick #e3b341; } -RigiNotificationWidget.notif--error { border-left: thick #f85149; } +NotificationWidget.notif--warning { border-left: thick #e3b341; } +NotificationWidget.notif--error { border-left: thick #f85149; } .notif-header { layout: horizontal; diff --git a/src/rigi/layout/__init__.py b/src/rigi/layout/__init__.py index a0571f6..d0b272d 100644 --- a/src/rigi/layout/__init__.py +++ b/src/rigi/layout/__init__.py @@ -1,10 +1,10 @@ -from rigi.layout.pane import RigiCard, RigiHPane, RigiPane, RigiScrollPane, RigiSplit, RigiVPane +from rigi.layout.pane import Card, HPane, Pane, ScrollPane, Split, VPane __all__ = [ - "RigiPane", - "RigiHPane", - "RigiVPane", - "RigiScrollPane", - "RigiCard", - "RigiSplit", + "Pane", + "HPane", + "VPane", + "ScrollPane", + "Card", + "Split", ] diff --git a/src/rigi/layout/pane.py b/src/rigi/layout/pane.py index 9b03c7d..fa727e1 100644 --- a/src/rigi/layout/pane.py +++ b/src/rigi/layout/pane.py @@ -5,27 +5,27 @@ from textual.widget import Widget -class RigiPane(Widget): +class Pane(Widget): def __init__(self, *children: Widget, **kwargs: Any) -> None: super().__init__(*children, **kwargs) -class RigiHPane(Widget): +class HPane(Widget): def __init__(self, *children: Widget, **kwargs: Any) -> None: super().__init__(*children, **kwargs) -class RigiVPane(Widget): +class VPane(Widget): def __init__(self, *children: Widget, **kwargs: Any) -> None: super().__init__(*children, **kwargs) -class RigiScrollPane(Widget): +class ScrollPane(Widget): def __init__(self, *children: Widget, **kwargs: Any) -> None: super().__init__(*children, **kwargs) -class RigiCard(Widget): +class Card(Widget): def __init__(self, *children: Widget, title: str = "", **kwargs: Any) -> None: super().__init__(*children, **kwargs) self._title = title @@ -33,7 +33,7 @@ def __init__(self, *children: Widget, title: str = "", **kwargs: Any) -> None: self.border_title = title -class RigiSplit(Widget): +class Split(Widget): def __init__(self, *children: Widget, sizes: list[str] | None = None, **kwargs: Any) -> None: super().__init__(*children, **kwargs) self._sizes = sizes diff --git a/src/rigi/screens/__init__.py b/src/rigi/screens/__init__.py index 078fa3c..d18be52 100644 --- a/src/rigi/screens/__init__.py +++ b/src/rigi/screens/__init__.py @@ -1,15 +1,15 @@ """Rigi screen classes.""" -from rigi.screens.action_menu import RigiActionMenuScreen -from rigi.screens.hamburger import RigiHamburgerScreen -from rigi.screens.help import BUILTIN_SHORTCUTS, RigiHelpScreen -from rigi.screens.settings import RigiSettingDef, RigiSettingsScreen +from rigi.screens.action_menu import ActionMenuScreen +from rigi.screens.hamburger import HamburgerScreen +from rigi.screens.help import BUILTIN_SHORTCUTS, HelpScreen +from rigi.screens.settings import SettingDef, SettingsScreen __all__ = [ "BUILTIN_SHORTCUTS", - "RigiHelpScreen", - "RigiSettingDef", - "RigiSettingsScreen", - "RigiHamburgerScreen", - "RigiActionMenuScreen", + "HelpScreen", + "SettingDef", + "SettingsScreen", + "HamburgerScreen", + "ActionMenuScreen", ] diff --git a/src/rigi/screens/action_menu.py b/src/rigi/screens/action_menu.py index 9c202dc..cc43b9a 100644 --- a/src/rigi/screens/action_menu.py +++ b/src/rigi/screens/action_menu.py @@ -1,4 +1,4 @@ -"""RigiActionMenuScreen — modal popup action menu.""" +"""ActionMenuScreen — modal popup action menu.""" from __future__ import annotations @@ -9,18 +9,18 @@ from textual.screen import ModalScreen from rigi.widgets.action_menu import ( - RigiActionMenuItemData, - RigiActionMenuPanel, + ActionMenuItemData, + ActionMenuPanel, _ActionItemClicked, ) -class RigiActionMenuScreen(ModalScreen[None]): +class ActionMenuScreen(ModalScreen[None]): BINDINGS = [Binding("escape", "dismiss", show=False)] def __init__( self, - items: list[RigiActionMenuItemData], + items: list[ActionMenuItemData], title: str = "", anchor_x: int | None = None, anchor_y: int | None = None, @@ -32,10 +32,10 @@ def __init__( self._anchor_y = anchor_y def compose(self) -> ComposeResult: - yield RigiActionMenuPanel(self._items, title=self._title, id="rigi-action-menu") + yield ActionMenuPanel(self._items, title=self._title, id="rigi-action-menu") def on_mount(self) -> None: - panel = self.query_one("#rigi-action-menu", RigiActionMenuPanel) + panel = self.query_one("#rigi-action-menu", ActionMenuPanel) panel_w = 30 panel_h = min(2 + len(self._items), 20) app_w = self.app.size.width @@ -62,7 +62,7 @@ def on_item_clicked(self, event: _ActionItemClicked) -> None: self.app.call_after_refresh(callback) def on_click(self, event: Click) -> None: - panel = self.query_one("#rigi-action-menu", RigiActionMenuPanel) + panel = self.query_one("#rigi-action-menu", ActionMenuPanel) if not panel.region.contains(event.x, event.y): self.dismiss(None) diff --git a/src/rigi/screens/hamburger.py b/src/rigi/screens/hamburger.py index b2feabd..1f33424 100644 --- a/src/rigi/screens/hamburger.py +++ b/src/rigi/screens/hamburger.py @@ -1,4 +1,4 @@ -"""RigiHamburgerScreen — slide-in hamburger menu modal.""" +"""HamburgerScreen — slide-in hamburger menu modal.""" from __future__ import annotations @@ -9,25 +9,25 @@ from textual.screen import ModalScreen from rigi.widgets.hamburger_menu import ( - RigiMenuItemData, - RigiMenuPanel, + MenuItemData, + MenuPanel, _ItemClicked, ) -class RigiHamburgerScreen(ModalScreen[None]): +class HamburgerScreen(ModalScreen[None]): BINDINGS = [Binding("escape", "action_close_or_dismiss", show=False)] - def __init__(self, sections: list[tuple[str, list[RigiMenuItemData]]]) -> None: + def __init__(self, sections: list[tuple[str, list[MenuItemData]]]) -> None: super().__init__() self._current_sections = sections - self._sections_stack: list[list[tuple[str, list[RigiMenuItemData]]]] = [] + self._sections_stack: list[list[tuple[str, list[MenuItemData]]]] = [] def compose(self) -> ComposeResult: - yield RigiMenuPanel(self._current_sections, id="rigi-main-menu") + yield MenuPanel(self._current_sections, id="rigi-main-menu") def on_mount(self) -> None: - panel = self.query_one("#rigi-main-menu", RigiMenuPanel) + panel = self.query_one("#rigi-main-menu", MenuPanel) panel_w = 26 x = max(0, self.app.size.width - panel_w - 1) panel.styles.offset = (x, 3) @@ -45,25 +45,25 @@ def on_item_clicked(self, event: _ItemClicked) -> None: self.dismiss(None) self.app.call_after_refresh(callback) - def _enter_submenu(self, item: RigiMenuItemData) -> None: + def _enter_submenu(self, item: MenuItemData) -> None: self._sections_stack.append(self._current_sections) - back_item = RigiMenuItemData("Back", is_back=True) + back_item = MenuItemData("Back", is_back=True) self._current_sections = [("", [back_item] + list(item.submenu or []))] - panel = self.query_one("#rigi-main-menu", RigiMenuPanel) + panel = self.query_one("#rigi-main-menu", MenuPanel) panel.border_title = item.label panel.replace_sections(self._current_sections) def _go_back(self) -> None: if self._sections_stack: self._current_sections = self._sections_stack.pop() - panel = self.query_one("#rigi-main-menu", RigiMenuPanel) + panel = self.query_one("#rigi-main-menu", MenuPanel) panel.border_title = "" panel.replace_sections(self._current_sections) else: self.dismiss(None) def on_click(self, event: Click) -> None: - main = self.query_one("#rigi-main-menu", RigiMenuPanel) + main = self.query_one("#rigi-main-menu", MenuPanel) if not main.region.contains(event.x, event.y): self.dismiss(None) diff --git a/src/rigi/screens/help.py b/src/rigi/screens/help.py index 461cccc..cce46b7 100644 --- a/src/rigi/screens/help.py +++ b/src/rigi/screens/help.py @@ -1,4 +1,4 @@ -"""RigiHelpScreen — full-screen keyboard-shortcut reference.""" +"""HelpScreen — full-screen keyboard-shortcut reference.""" from __future__ import annotations @@ -24,7 +24,7 @@ ] -class RigiHelpScreen(ModalScreen[None]): +class HelpScreen(ModalScreen[None]): BINDINGS = [ Binding("escape", "dismiss", "Close", show=False), Binding("ctrl+h", "dismiss", "Close", show=False), diff --git a/src/rigi/screens/settings.py b/src/rigi/screens/settings.py index 0f2b409..b4e332b 100644 --- a/src/rigi/screens/settings.py +++ b/src/rigi/screens/settings.py @@ -14,7 +14,7 @@ _ui_log = logging.getLogger("rigi.ui") -class RigiSettingDef: +class SettingDef: def __init__( self, category: str, @@ -105,14 +105,14 @@ def on_click(self) -> None: self._callback() try: screen = self.app.screen - if isinstance(screen, RigiSettingsScreen): + if isinstance(screen, SettingsScreen): screen._refresh_content() except Exception as e: _ui_log.error(f"Error in action button click: {e}", exc_info=True) class _ValueRow(Widget): - def __init__(self, setting: RigiSettingDef) -> None: + def __init__(self, setting: SettingDef) -> None: super().__init__() self._setting = setting @@ -123,7 +123,7 @@ def compose(self) -> ComposeResult: class _SettingInput(Input): - def __init__(self, setting: RigiSettingDef) -> None: + def __init__(self, setting: SettingDef) -> None: super().__init__(value=setting.get_value()) self._setting = setting self.restrict = None @@ -142,7 +142,7 @@ def on_submitted(self, event: Input.Submitted) -> None: class _SettingSwitch(Widget): - def __init__(self, setting: RigiSettingDef) -> None: + def __init__(self, setting: SettingDef) -> None: super().__init__() self._setting = setting @@ -166,7 +166,7 @@ def on_changed(self, event: Switch.Changed) -> None: class _SettingItem(Widget): - def __init__(self, setting: RigiSettingDef) -> None: + def __init__(self, setting: SettingDef) -> None: super().__init__() self._setting = setting @@ -191,10 +191,10 @@ def compose(self) -> ComposeResult: yield from [] -class RigiSettingsScreen(ModalScreen[None]): +class SettingsScreen(ModalScreen[None]): BINDINGS = [Binding("escape", "dismiss", show=False)] - def __init__(self, settings: list[RigiSettingDef]) -> None: + def __init__(self, settings: list[SettingDef]) -> None: super().__init__() self._settings = settings self._active_category = "" diff --git a/src/rigi/themes/__init__.py b/src/rigi/themes/__init__.py index db8c3d9..8473968 100644 --- a/src/rigi/themes/__init__.py +++ b/src/rigi/themes/__init__.py @@ -2,13 +2,13 @@ Usage:: - from rigi.themes import DARK, LIGHT, MONOKAI, NORD, RigiTheme + from rigi.themes import DARK, LIGHT, MONOKAI, NORD, Theme """ -from rigi.themes.base import RigiTheme +from rigi.themes.base import Theme from rigi.themes.dark import DARK from rigi.themes.light import LIGHT from rigi.themes.monokai import MONOKAI from rigi.themes.nord import NORD -__all__ = ["RigiTheme", "DARK", "LIGHT", "MONOKAI", "NORD"] +__all__ = ["Theme", "DARK", "LIGHT", "MONOKAI", "NORD"] diff --git a/src/rigi/themes/base.py b/src/rigi/themes/base.py index b4c2e82..68a4bd6 100644 --- a/src/rigi/themes/base.py +++ b/src/rigi/themes/base.py @@ -1,4 +1,4 @@ -"""RigiTheme data class — color palette for a Rigi application.""" +"""Theme data class — color palette for a Rigi application.""" from __future__ import annotations @@ -6,8 +6,8 @@ @dataclass -class RigiTheme: - """Color theme for a RigiApp. +class Theme: + """Color theme for a App. All color values are CSS color strings (hex, named, rgb(), etc.). Call ``to_css()`` to get the complete override stylesheet. @@ -44,16 +44,16 @@ def to_css(self) -> str: background: {self.bg_color}; color: {self.fg_color}; }} -RigiBorderFrame {{ +BorderFrame {{ border: round {self.border}; background: {self.bg_color}; color: {self.fg_color}; }} -RigiStatusBar {{ +StatusBar {{ border-bottom: solid {self.border_dim}; background: {self.bg_color}; }} -_RigiMainNav {{ +_MainNav {{ background: {self.bg_color}; }} _MainNavItem {{ @@ -67,8 +67,9 @@ def to_css(self) -> str: color: {self.text_highlight}; border-left: thick {self.text_highlight}; }} -_RigiSubNav {{ +_SubNav {{ background: {self.bg_color}; + border-right: solid {self.border_dim}; }} _SubNavItem {{ color: {self.text_dim}; @@ -80,39 +81,39 @@ def to_css(self) -> str: _SubNavItem.--active {{ color: {self.text_highlight2}; }} -RigiShortcutsBar {{ +ShortcutsBar {{ border-top: solid {self.border_dim}; background: {self.bg_color}; }} -RigiShortcutsBar Label {{ +ShortcutsBar Label {{ color: {self.text_dim}; }} -RigiTerminalBar {{ +TerminalBar {{ background: {self.bg_color}; }} -RigiTerminalBar Label {{ +TerminalBar Label {{ color: {self.terminal_color}; }} -RigiTerminalBar Input {{ +TerminalBar Input {{ color: {self.text}; }} -RigiCard {{ +Card {{ border: round {self.border_dim}; background: {self.bg_color}; }} -RigiCompletionList {{ +CompletionList {{ border: solid {self.border}; background: {self.completion_bg}; }} -RigiHelpScreen > #help-container {{ +HelpScreen > #help-container {{ border: round {self.border}; background: {self.popup_bg}; }} -RigiHamburgerPanel {{ +HamburgerPanel {{ border: round {self.border}; background: {self.popup_bg}; }} -RigiPaletteScreen > #palette-container {{ +PaletteScreen > #palette-container {{ border: round {self.border}; background: {self.popup_bg}; }} @@ -131,16 +132,16 @@ def to_css(self) -> str: #help-dismiss {{ color: {self.text_dim}; }} -_RigiBody {{ +_Body {{ background: {self.bg_color}; }} -RigiSidebar {{ +Sidebar {{ background: {self.bg_color}; }} -RigiContentArea {{ +ContentArea {{ background: {self.bg_color}; }} -RigiBottomPanel {{ +BottomPanel {{ background: {self.bg_color}; }} #content-main {{ diff --git a/src/rigi/themes/dark.py b/src/rigi/themes/dark.py index 4278b2c..eb8957f 100644 --- a/src/rigi/themes/dark.py +++ b/src/rigi/themes/dark.py @@ -1,6 +1,6 @@ -from rigi.themes.base import RigiTheme +from rigi.themes.base import Theme -DARK = RigiTheme( +DARK = Theme( name="dark", bg_color="#000000", fg_color="#ffffff", diff --git a/src/rigi/themes/light.py b/src/rigi/themes/light.py index 2f6e980..86e80d9 100644 --- a/src/rigi/themes/light.py +++ b/src/rigi/themes/light.py @@ -1,6 +1,6 @@ -from rigi.themes.base import RigiTheme +from rigi.themes.base import Theme -LIGHT = RigiTheme( +LIGHT = Theme( name="light", bg_color="#ffffff", fg_color="#000000", diff --git a/src/rigi/themes/monokai.py b/src/rigi/themes/monokai.py index 6f96ae6..653c04a 100644 --- a/src/rigi/themes/monokai.py +++ b/src/rigi/themes/monokai.py @@ -1,6 +1,6 @@ -from rigi.themes.base import RigiTheme +from rigi.themes.base import Theme -MONOKAI = RigiTheme( +MONOKAI = Theme( name="monokai", border="#75715e", border_dim="#3e3d32", diff --git a/src/rigi/themes/nord.py b/src/rigi/themes/nord.py index 4ed4372..a5bb3cf 100644 --- a/src/rigi/themes/nord.py +++ b/src/rigi/themes/nord.py @@ -1,6 +1,6 @@ -from rigi.themes.base import RigiTheme +from rigi.themes.base import Theme -NORD = RigiTheme( +NORD = Theme( name="nord", border="#4c566a", border_dim="#3b4252", diff --git a/src/rigi/widgets/__init__.py b/src/rigi/widgets/__init__.py index 876908a..8d559aa 100644 --- a/src/rigi/widgets/__init__.py +++ b/src/rigi/widgets/__init__.py @@ -36,28 +36,28 @@ ) from rigi.widgets.action_menu import ( - RigiActionMenuItem, - RigiActionMenuItemData, - RigiActionMenuPanel, + ActionMenuItem, + ActionMenuItemData, + ActionMenuPanel, ) -from rigi.widgets.border_frame import RigiBorderFrame -from rigi.widgets.bottom_panel import RigiBottomPanel -from rigi.widgets.content_area import RigiContentArea -from rigi.widgets.gauge import RigiGauge, RigiSparkline +from rigi.widgets.border_frame import BorderFrame +from rigi.widgets.bottom_panel import BottomPanel +from rigi.widgets.content_area import ContentArea +from rigi.widgets.gauge import Gauge, Sparkline from rigi.widgets.hamburger_menu import ( - RigiHamburgerPanel, - RigiMenuItem, - RigiMenuItemData, - RigiMenuPanel, + HamburgerPanel, + MenuItem, + MenuItemData, + MenuPanel, ) -from rigi.widgets.help_panel import RigiShortcutsBar, extract_help_annotation -from rigi.widgets.image import RigiImage, TerminalImageProtocol, detect_image_protocol -from rigi.widgets.mouse import RigiClickable, RigiDraggable, RigiMouseMixin -from rigi.widgets.settings_screen import RigiSettingDef, RigiSettingsScreen -from rigi.widgets.sidebar import RigiSidebar -from rigi.widgets.statusbar import RigiStatusBar, RigiStatusItem -from rigi.widgets.terminal_bar import RigiTerminalBar -from rigi.widgets.vertical_tabs import RigiVerticalTabs +from rigi.widgets.help_panel import ShortcutsBar, extract_help_annotation +from rigi.widgets.image import Image, TerminalImageProtocol, detect_image_protocol +from rigi.widgets.mouse import Clickable, Draggable, MouseMixin +from rigi.widgets.settings_screen import SettingDef, SettingsScreen +from rigi.widgets.sidebar import Sidebar +from rigi.widgets.statusbar import StatusBar, StatusItem +from rigi.widgets.terminal_bar import TerminalBar +from rigi.widgets.tab_group import TabGroup __all__ = [ # Textual primitives @@ -67,33 +67,33 @@ "ModalScreen", "reactive", # Rigi widgets - "RigiStatusBar", - "RigiStatusItem", - "RigiSidebar", - "RigiTerminalBar", - "RigiBottomPanel", - "RigiBorderFrame", - "RigiContentArea", - "RigiMenuItem", - "RigiMenuPanel", - "RigiHamburgerPanel", - "RigiMenuItemData", - "RigiActionMenuItem", - "RigiActionMenuPanel", - "RigiActionMenuItemData", - "RigiVerticalTabs", - "RigiSettingsScreen", - "RigiSettingDef", - "RigiImage", + "StatusBar", + "StatusItem", + "Sidebar", + "TerminalBar", + "BottomPanel", + "BorderFrame", + "ContentArea", + "MenuItem", + "MenuPanel", + "HamburgerPanel", + "MenuItemData", + "ActionMenuItem", + "ActionMenuPanel", + "ActionMenuItemData", + "TabGroup", + "SettingsScreen", + "SettingDef", + "Image", "TerminalImageProtocol", "detect_image_protocol", - "RigiMouseMixin", - "RigiClickable", - "RigiDraggable", - "RigiShortcutsBar", + "MouseMixin", + "Clickable", + "Draggable", + "ShortcutsBar", "extract_help_annotation", - "RigiGauge", - "RigiSparkline", + "Gauge", + "Sparkline", # Textual widgets "Label", "Static", diff --git a/src/rigi/widgets/action_menu.py b/src/rigi/widgets/action_menu.py index 8656ac6..417f14b 100644 --- a/src/rigi/widgets/action_menu.py +++ b/src/rigi/widgets/action_menu.py @@ -13,7 +13,7 @@ @dataclass -class RigiActionMenuItemData: +class ActionMenuItemData: label: str callback: Callable[[], Any] | None = None color: str | None = None @@ -21,15 +21,15 @@ class RigiActionMenuItemData: class _ActionItemClicked(Message): - def __init__(self, item: RigiActionMenuItemData) -> None: + def __init__(self, item: ActionMenuItemData) -> None: super().__init__() self.item = item -class RigiActionMenuItem(Widget): +class ActionMenuItem(Widget): can_focus = False - def __init__(self, item: RigiActionMenuItemData, number: int) -> None: + def __init__(self, item: ActionMenuItemData, number: int) -> None: super().__init__() self._item = item self._number = number @@ -49,10 +49,10 @@ def on_click(self, event: Click) -> None: self.post_message(_ActionItemClicked(self._item)) -class RigiActionMenuPanel(Widget): +class ActionMenuPanel(Widget): def __init__( self, - items: list[RigiActionMenuItemData], + items: list[ActionMenuItemData], title: str = "", **kwargs: Any, ) -> None: @@ -63,10 +63,10 @@ def __init__( def compose(self) -> ComposeResult: for i, item in enumerate(self._items, start=1): - yield RigiActionMenuItem(item, number=i) + yield ActionMenuItem(item, number=i) - def replace_items(self, items: list[RigiActionMenuItemData]) -> None: + def replace_items(self, items: list[ActionMenuItemData]) -> None: self._items = items self.remove_children() for i, item in enumerate(items, start=1): - self.mount(RigiActionMenuItem(item, number=i)) + self.mount(ActionMenuItem(item, number=i)) diff --git a/src/rigi/widgets/border_frame.py b/src/rigi/widgets/border_frame.py index 7e90538..00cd31f 100644 --- a/src/rigi/widgets/border_frame.py +++ b/src/rigi/widgets/border_frame.py @@ -3,7 +3,7 @@ from textual.widget import Widget -class RigiBorderFrame(Widget): +class BorderFrame(Widget): def __init__(self, prog_name: str, version: str) -> None: super().__init__() self.border_title = f" {prog_name} v{version} " diff --git a/src/rigi/widgets/bottom_panel.py b/src/rigi/widgets/bottom_panel.py index f220648..2c16d3b 100644 --- a/src/rigi/widgets/bottom_panel.py +++ b/src/rigi/widgets/bottom_panel.py @@ -42,7 +42,7 @@ def render(self) -> str: def on_mouse_down(self, event: MouseDown) -> None: self.capture_mouse() self._drag_y = event.screen_y - panel = next((w for w in self.ancestors if isinstance(w, RigiBottomPanel)), None) + panel = next((w for w in self.ancestors if isinstance(w, BottomPanel)), None) if panel is not None: self._drag_h = panel.size.height @@ -51,7 +51,7 @@ def on_mouse_move(self, event: MouseMove) -> None: return delta = self._drag_y - event.screen_y new_h = max(4, self._drag_h + delta) - panel = next((w for w in self.ancestors if isinstance(w, RigiBottomPanel)), None) + panel = next((w for w in self.ancestors if isinstance(w, BottomPanel)), None) if panel is not None: panel.styles.height = new_h @@ -63,12 +63,12 @@ def on_mouse_up(self, _: MouseUp) -> None: class _TerminalInput(Input): def on_focus(self) -> None: - panel = next((w for w in self.ancestors if isinstance(w, RigiBottomPanel)), None) + panel = next((w for w in self.ancestors if isinstance(w, BottomPanel)), None) if panel is not None: panel._on_focus_changed(True) def on_blur(self) -> None: - panel = next((w for w in self.ancestors if isinstance(w, RigiBottomPanel)), None) + panel = next((w for w in self.ancestors if isinstance(w, BottomPanel)), None) if panel is not None: panel._on_focus_changed(False) @@ -198,7 +198,7 @@ def _reset_seen(self) -> None: pass -class RigiBottomPanel(Widget): +class BottomPanel(Widget): BINDINGS = [ Binding("tab", "complete", "Complete", show=False), ] @@ -350,7 +350,7 @@ def on_input_submitted(self, event: Input.Submitted) -> None: pass safe_text = text.replace("[", "\\[") self.write_output(f"[bold green]{self._prompt_text}[/bold green] [dim]$[/dim] {safe_text}") - self.post_message(RigiBottomPanel.CommandSubmitted(text)) + self.post_message(BottomPanel.CommandSubmitted(text)) def action_complete(self) -> None: if not self._completions: diff --git a/src/rigi/widgets/checkbox.py b/src/rigi/widgets/checkbox.py index 942856c..0020be6 100644 --- a/src/rigi/widgets/checkbox.py +++ b/src/rigi/widgets/checkbox.py @@ -9,10 +9,10 @@ from textual.widgets import Label -class RigiCheckbox(Widget): +class Checkbox(Widget): """A simple checkbox with a clickable label. - Posts ``RigiCheckbox.Changed`` when toggled. + Posts ``Checkbox.Changed`` when toggled. """ can_focus = True diff --git a/src/rigi/widgets/content_area.py b/src/rigi/widgets/content_area.py index 8f8556e..479e090 100644 --- a/src/rigi/widgets/content_area.py +++ b/src/rigi/widgets/content_area.py @@ -1,4 +1,4 @@ -"""RigiContentArea with resizable support.""" +"""ContentArea with resizable support.""" from __future__ import annotations @@ -27,7 +27,7 @@ def on_mouse_down(self, event: MouseDown) -> None: try: self.capture_mouse() self._drag_x = event.screen_x - content = next((w for w in self.ancestors if isinstance(w, RigiContentArea)), None) + content = next((w for w in self.ancestors if isinstance(w, ContentArea)), None) if content is not None: self._drag_w = content.size.width _ui_log.debug("Started resizing content area") @@ -40,7 +40,7 @@ def on_mouse_move(self, event: MouseMove) -> None: try: delta = event.screen_x - self._drag_x new_w = max(20, self._drag_w + delta) - content = next((w for w in self.ancestors if isinstance(w, RigiContentArea)), None) + content = next((w for w in self.ancestors if isinstance(w, ContentArea)), None) if content is not None: content.styles.width = new_w except Exception as e: @@ -56,19 +56,19 @@ def on_mouse_up(self, _event: MouseUp) -> None: _ui_log.error(f"Error in content resize mouse_up: {e}", exc_info=True) -class _RigiEmptyState(Widget): +class _EmptyState(Widget): def compose(self) -> ComposeResult: yield Label("Select a section from the sidebar") -class RigiContentArea(Widget): +class ContentArea(Widget): def __init__(self) -> None: super().__init__() self._current: Widget | None = None def compose(self) -> ComposeResult: with Widget(id="content-main"): - yield _RigiEmptyState(id="rigi-empty-state") + yield _EmptyState(id="rigi-empty-state") def show_widget(self, widget: Widget) -> None: try: @@ -100,7 +100,7 @@ def _show_empty_state(self) -> None: except Exception: try: content_main = self.query_one("#content-main") - content_main.mount(_RigiEmptyState(id="rigi-empty-state")) + content_main.mount(_EmptyState(id="rigi-empty-state")) except Exception as e: _ui_log.error(f"Error showing empty state: {e}", exc_info=True) diff --git a/src/rigi/widgets/gauge.py b/src/rigi/widgets/gauge.py index 3cfc030..6c05a82 100644 --- a/src/rigi/widgets/gauge.py +++ b/src/rigi/widgets/gauge.py @@ -8,7 +8,7 @@ from textual.widget import Widget -class RigiGauge(Widget): +class Gauge(Widget): """Horizontal progress bar — set .value to update.""" def __init__( @@ -58,7 +58,7 @@ def render(self) -> Text: return t -class RigiSparkline(Widget): +class Sparkline(Widget): """Inline sparkline chart. Call .push(value) to add data points.""" _BARS = " ▁▂▃▄▅▆▇█" diff --git a/src/rigi/widgets/hamburger_menu.py b/src/rigi/widgets/hamburger_menu.py index eaa1845..64bba05 100644 --- a/src/rigi/widgets/hamburger_menu.py +++ b/src/rigi/widgets/hamburger_menu.py @@ -13,22 +13,22 @@ @dataclass -class RigiMenuItemData: +class MenuItemData: label: str callback: Callable[[], Any] | None = None checked: bool = False - submenu: list[RigiMenuItemData] | None = None + submenu: list[MenuItemData] | None = None is_back: bool = False class _ItemClicked(Message): - def __init__(self, item: RigiMenuItemData) -> None: + def __init__(self, item: MenuItemData) -> None: super().__init__() self.item = item -class RigiMenuItem(Widget): - def __init__(self, item: RigiMenuItemData) -> None: +class MenuItem(Widget): + def __init__(self, item: MenuItemData) -> None: super().__init__() self._item = item @@ -54,10 +54,10 @@ def compose(self) -> ComposeResult: yield Label(f"── {self._title}") -class RigiMenuPanel(Widget): +class MenuPanel(Widget): def __init__( self, - sections: list[tuple[str, list[RigiMenuItemData]]], + sections: list[tuple[str, list[MenuItemData]]], title: str = "", **kwargs: Any, ) -> None: @@ -71,16 +71,16 @@ def compose(self) -> ComposeResult: if title: yield _MenuSectionLabel(title) for item in items: - yield RigiMenuItem(item) + yield MenuItem(item) - def replace_sections(self, sections: list[tuple[str, list[RigiMenuItemData]]]) -> None: + def replace_sections(self, sections: list[tuple[str, list[MenuItemData]]]) -> None: self._sections = sections self.remove_children() for title, items in sections: if title: self.mount(_MenuSectionLabel(title)) for item in items: - self.mount(RigiMenuItem(item)) + self.mount(MenuItem(item)) -RigiHamburgerPanel = RigiMenuPanel +HamburgerPanel = MenuPanel diff --git a/src/rigi/widgets/hamburger_overlay.py b/src/rigi/widgets/hamburger_overlay.py new file mode 100644 index 0000000..648e864 --- /dev/null +++ b/src/rigi/widgets/hamburger_overlay.py @@ -0,0 +1,75 @@ +"""Hamburger menu overlay widget — mounts directly on app, non-blocking.""" + +from __future__ import annotations + +from textual import on +from textual.app import ComposeResult +from textual.events import Click +from textual.widget import Widget + +from rigi.widgets.hamburger_menu import MenuItemData, MenuPanel, _ItemClicked + + +class HamburgerOverlay(Widget): + """Non-blocking overlay that hosts the hamburger menu panel.""" + + def __init__(self, sections: list[tuple[str, list[MenuItemData]]]) -> None: + super().__init__() + self._current_sections = sections + self._sections_stack: list[list[tuple[str, list[MenuItemData]]]] = [] + + def compose(self) -> ComposeResult: + yield MenuPanel(self._current_sections, id="rigi-main-menu") + + def on_mount(self) -> None: + panel = self.query_one("#rigi-main-menu", MenuPanel) + panel_w = 26 + x = max(0, self.app.size.width - panel_w - 1) + panel.styles.offset = (x, 3) + + @on(_ItemClicked) + def on_item_clicked(self, event: _ItemClicked) -> None: + event.stop() + item = event.item + if item.is_back: + self._go_back() + elif item.submenu is not None: + self._enter_submenu(item) + elif item.callback is not None: + callback = item.callback + self._close() + self.app.call_after_refresh(callback) + + def _enter_submenu(self, item: MenuItemData) -> None: + self._sections_stack.append(self._current_sections) + back_item = MenuItemData("Back", is_back=True) + self._current_sections = [("", [back_item] + list(item.submenu or []))] + panel = self.query_one("#rigi-main-menu", MenuPanel) + panel.border_title = item.label + panel.replace_sections(self._current_sections) + + def _go_back(self) -> None: + if self._sections_stack: + self._current_sections = self._sections_stack.pop() + panel = self.query_one("#rigi-main-menu", MenuPanel) + panel.border_title = "" + panel.replace_sections(self._current_sections) + else: + self._close() + + def on_click(self, event: Click) -> None: + main = self.query_one("#rigi-main-menu", MenuPanel) + if not main.region.contains(event.x, event.y): + self._close() + + def action_close_or_dismiss(self) -> None: + if self._sections_stack: + self._go_back() + else: + self._close() + + def _close(self) -> None: + try: + self.remove() + except Exception: + pass diff --git a/src/rigi/widgets/help_panel.py b/src/rigi/widgets/help_panel.py index ac01974..82cf445 100644 --- a/src/rigi/widgets/help_panel.py +++ b/src/rigi/widgets/help_panel.py @@ -1,4 +1,4 @@ -"""RigiShortcutsBar and help annotation utilities.""" +"""ShortcutsBar and help annotation utilities.""" from __future__ import annotations @@ -34,7 +34,7 @@ def extract_help_annotation(fn: Callable[..., object] | None) -> str | None: return m.group(1).strip() if m else None -class RigiShortcutsBar(Widget): +class ShortcutsBar(Widget): HINTS: list[tuple[str, str]] = [ ("Ctrl+H", "Help"), ("Ctrl+P", "Commands"), diff --git a/src/rigi/widgets/image.py b/src/rigi/widgets/image.py index d731af2..5688546 100644 --- a/src/rigi/widgets/image.py +++ b/src/rigi/widgets/image.py @@ -189,7 +189,7 @@ def center_line(text: str, style: str) -> list[Segment]: return strips -class RigiImage(Widget): +class Image(Widget): def __init__( self, source: str | Path | bytes | None = None, diff --git a/src/rigi/widgets/mouse.py b/src/rigi/widgets/mouse.py index 404509f..b40129f 100644 --- a/src/rigi/widgets/mouse.py +++ b/src/rigi/widgets/mouse.py @@ -15,12 +15,12 @@ from textual.widget import Widget -class RigiMouseMixin: +class MouseMixin: """ Mixin for any Widget subclass to get convenient mouse event hooks. Usage: - class MyWidget(RigiMouseMixin, Widget): + class MyWidget(MouseMixin, Widget): def on_rigi_click(self, x, y, button): ... """ @@ -81,12 +81,12 @@ async def _rigi_dispatch_scroll( await result -class RigiClickable(RigiMouseMixin, Widget): +class Clickable(MouseMixin, Widget): """ Widget that fires a Clicked message and calls on_rigi_click. Usage: - btn = RigiClickable("Click me") + btn = Clickable("Click me") btn.on_rigi_click = lambda x, y, btn: print("clicked") """ @@ -105,11 +105,11 @@ def render(self) -> str: return self._label def on_click(self, event: Click) -> None: - self.post_message(RigiClickable.Clicked(event.x, event.y, event.button)) + self.post_message(Clickable.Clicked(event.x, event.y, event.button)) super().on_click(event) -class RigiDraggable(RigiMouseMixin, Widget): +class Draggable(MouseMixin, Widget): """ Widget that supports drag operations. """ diff --git a/src/rigi/widgets/notifications.py b/src/rigi/widgets/notifications.py index a697d85..b5307b5 100644 --- a/src/rigi/widgets/notifications.py +++ b/src/rigi/widgets/notifications.py @@ -23,7 +23,7 @@ def __init__(self, notification_id: str) -> None: self.notification_id = notification_id -class RigiNotificationWidget(Widget): +class NotificationWidget(Widget): def __init__( self, notification_id: str, @@ -62,7 +62,7 @@ def on_close_pressed(self, event: Button.Pressed) -> None: self._expire() -class RigiNotificationRack(Widget): +class NotificationRack(Widget): def compose(self) -> ComposeResult: yield from [] @@ -74,14 +74,14 @@ def add_notification( timeout: float = 5.0, ) -> str: notification_id = str(uuid4()) - notif = RigiNotificationWidget(notification_id, title, message, severity, timeout) + notif = NotificationWidget(notification_id, title, message, severity, timeout) self.mount(notif) return notification_id @on(_DismissNotification) def on_dismiss(self, event: _DismissNotification) -> None: event.stop() - for widget in self.query(RigiNotificationWidget): + for widget in self.query(NotificationWidget): if widget._notification_id == event.notification_id: widget.remove() return diff --git a/src/rigi/widgets/palette.py b/src/rigi/widgets/palette.py index 7078968..5f2e937 100644 --- a/src/rigi/widgets/palette.py +++ b/src/rigi/widgets/palette.py @@ -35,16 +35,16 @@ def _fuzzy_score(query: str, candidate: str) -> int | None: return score if i == len(q) else None -class RigiPaletteScreen(ModalScreen[str | None]): +class PaletteScreen(ModalScreen[str | None]): """Ctrl+P command palette with fuzzy search.""" DEFAULT_CSS = """ - RigiPaletteScreen { + PaletteScreen { align: center top; layer: overlay; background: rgba(0,0,0,0.5); } - RigiPaletteScreen > #palette-container { + PaletteScreen > #palette-container { width: 64; height: auto; max-height: 30; @@ -53,7 +53,7 @@ class RigiPaletteScreen(ModalScreen[str | None]): border: round #30363d; background: #0d1117; } - RigiPaletteScreen Input { + PaletteScreen Input { border: none; background: transparent; width: 100%; @@ -61,20 +61,20 @@ class RigiPaletteScreen(ModalScreen[str | None]): padding: 0; color: #e6edf3; } - RigiPaletteScreen Input:focus { border: none; } - RigiPaletteScreen #palette-divider { + PaletteScreen Input:focus { border: none; } + PaletteScreen #palette-divider { height: 1; width: 100%; color: #30363d; } - RigiPaletteScreen OptionList { + PaletteScreen OptionList { border: none; padding: 0; background: transparent; height: auto; max-height: 22; } - RigiPaletteScreen #palette-hint { + PaletteScreen #palette-hint { height: 1; color: #6e7681; width: 100%; diff --git a/src/rigi/widgets/settings_screen.py b/src/rigi/widgets/settings_screen.py index 7831e12..aa6c342 100644 --- a/src/rigi/widgets/settings_screen.py +++ b/src/rigi/widgets/settings_screen.py @@ -1,12 +1,12 @@ """Settings widget helpers. -RigiSettingDef and RigiSettingsScreen have moved to rigi.screens.settings +SettingDef and SettingsScreen have moved to rigi.screens.settings — re-exported here for backward compat. """ from rigi.screens.settings import ( # noqa: F401 - RigiSettingDef as RigiSettingDef, + SettingDef as SettingDef, ) from rigi.screens.settings import ( - RigiSettingsScreen as RigiSettingsScreen, + SettingsScreen as SettingsScreen, ) diff --git a/src/rigi/widgets/sidebar.py b/src/rigi/widgets/sidebar.py index 1c16a65..ea9b6e8 100644 --- a/src/rigi/widgets/sidebar.py +++ b/src/rigi/widgets/sidebar.py @@ -85,7 +85,7 @@ def on_click(self) -> None: self.app.set_focus(None) -class _RigiMainNav(Widget): +class _MainNav(Widget): def __init__(self) -> None: super().__init__(id="main-nav") self._tabs: list[TabDef] = [] @@ -167,7 +167,7 @@ def set_active(self, active: bool) -> None: self.set_class(active, "--active") -class _RigiSubNav(Widget): +class _SubNav(Widget): def __init__(self) -> None: super().__init__(id="sub-nav") self._tab: TabDef | None = None @@ -258,7 +258,7 @@ def on_mount(self) -> None: self._rebuild() -class RigiSidebar(Widget): +class Sidebar(Widget): class NavigationChanged(Message): def __init__(self, tab_idx: int, subtab_path: list[int]) -> None: super().__init__() @@ -273,25 +273,24 @@ def __init__(self) -> None: self._nav_level: str = "tab" def compose(self) -> ComposeResult: - yield _RigiMainNav() + yield _MainNav() yield _VerticalResizeHandle("main-nav") - yield _RigiSubNav() - yield _VerticalResizeHandle("sub-nav") + yield _SubNav() def on_mount(self) -> None: - main = self.query_one(_RigiMainNav) + main = self.query_one(_MainNav) main.set_tabs(self._tabs) if self._tabs: - self.query_one(_RigiSubNav).set_tab(self._tabs[0], 0) + self.query_one(_SubNav).set_tab(self._tabs[0], 0) def set_tabs(self, tabs: list[TabDef]) -> None: self._tabs = tabs if not self.is_mounted: return - main = self.query_one(_RigiMainNav) + main = self.query_one(_MainNav) main.set_tabs(tabs) tab = tabs[0] if tabs else None - self.query_one(_RigiSubNav).set_tab(tab, 0) + self.query_one(_SubNav).set_tab(tab, 0) def on__main_tab_clicked(self, event: _MainTabClicked) -> None: event.stop() @@ -299,24 +298,24 @@ def on__main_tab_clicked(self, event: _MainTabClicked) -> None: self._active_tab = idx self._active_path = [] self._nav_level = "tab" - self.query_one(_RigiMainNav).set_active(idx) + self.query_one(_MainNav).set_active(idx) tab = self._tabs[idx] if idx < len(self._tabs) else None - self.query_one(_RigiSubNav).set_tab(tab, idx, []) - self.post_message(RigiSidebar.NavigationChanged(idx, [])) + self.query_one(_SubNav).set_tab(tab, idx, []) + self.post_message(Sidebar.NavigationChanged(idx, [])) def on__sub_item_clicked(self, event: _SubItemClicked) -> None: event.stop() path = event.path - sub_nav = self.query_one(_RigiSubNav) + sub_nav = self.query_one(_SubNav) sub_nav.set_active_path(path) self._active_path = list(path) self._nav_level = "subtab" - self.post_message(RigiSidebar.NavigationChanged(self._active_tab, path)) + self.post_message(Sidebar.NavigationChanged(self._active_tab, path)) def navigate(self, direction: int) -> None: if not self._tabs: return - sub_nav = self.query_one(_RigiSubNav) + sub_nav = self.query_one(_SubNav) if self._nav_level == "tab": new_tab = max(0, min(len(self._tabs) - 1, self._active_tab + direction)) @@ -324,10 +323,10 @@ def navigate(self, direction: int) -> None: return self._active_tab = new_tab self._active_path = [] - self.query_one(_RigiMainNav).set_active(new_tab) + self.query_one(_MainNav).set_active(new_tab) tab_or_none: TabDef | None = self._tabs[new_tab] if new_tab < len(self._tabs) else None sub_nav.set_tab(tab_or_none, new_tab, []) - self.post_message(RigiSidebar.NavigationChanged(new_tab, [])) + self.post_message(Sidebar.NavigationChanged(new_tab, [])) else: if not self._active_path: return @@ -347,12 +346,12 @@ def navigate(self, direction: int) -> None: return self._active_path = self._active_path[:-1] + [new_idx] sub_nav.set_active_path(self._active_path) - self.post_message(RigiSidebar.NavigationChanged(self._active_tab, self._active_path)) + self.post_message(Sidebar.NavigationChanged(self._active_tab, self._active_path)) def navigate_right(self) -> None: if not self._tabs: return - sub_nav = self.query_one(_RigiSubNav) + sub_nav = self.query_one(_SubNav) if self._nav_level == "tab": tab = self._tabs[self._active_tab] if self._active_tab < len(self._tabs) else None @@ -360,7 +359,7 @@ def navigate_right(self) -> None: self._nav_level = "subtab" self._active_path = [0] sub_nav.set_tab(tab, self._active_tab, [0]) - self.post_message(RigiSidebar.NavigationChanged(self._active_tab, [0])) + self.post_message(Sidebar.NavigationChanged(self._active_tab, [0])) else: current = sub_nav.resolve(self._active_path) if current and current.children: @@ -369,23 +368,23 @@ def navigate_right(self) -> None: sub_nav._active_path = list(child_path) sub_nav._rebuild() self._active_path = child_path - self.post_message(RigiSidebar.NavigationChanged(self._active_tab, child_path)) + self.post_message(Sidebar.NavigationChanged(self._active_tab, child_path)) def navigate_left(self) -> None: if self._nav_level != "subtab": return - sub_nav = self.query_one(_RigiSubNav) + sub_nav = self.query_one(_SubNav) if len(self._active_path) > 1: parent = self._active_path[:-1] self._active_path = parent sub_nav.set_active_path(parent) - self.post_message(RigiSidebar.NavigationChanged(self._active_tab, parent)) + self.post_message(Sidebar.NavigationChanged(self._active_tab, parent)) else: self._nav_level = "tab" self._active_path = [] sub_nav.set_active_path([]) - self.post_message(RigiSidebar.NavigationChanged(self._active_tab, [])) + self.post_message(Sidebar.NavigationChanged(self._active_tab, [])) def jump_to_tab_by_key(self, key: str) -> bool: for t_idx, tab in enumerate(self._tabs): @@ -393,8 +392,8 @@ def jump_to_tab_by_key(self, key: str) -> bool: self._active_tab = t_idx self._active_path = [] self._nav_level = "tab" - self.query_one(_RigiMainNav).set_active(t_idx) - self.query_one(_RigiSubNav).set_tab(tab, t_idx, []) - self.post_message(RigiSidebar.NavigationChanged(t_idx, [])) + self.query_one(_MainNav).set_active(t_idx) + self.query_one(_SubNav).set_tab(tab, t_idx, []) + self.post_message(Sidebar.NavigationChanged(t_idx, [])) return True return False diff --git a/src/rigi/widgets/statusbar.py b/src/rigi/widgets/statusbar.py index 1418a90..9fc1696 100644 --- a/src/rigi/widgets/statusbar.py +++ b/src/rigi/widgets/statusbar.py @@ -8,7 +8,7 @@ from rigi.core.types import StatusItem -class RigiStatusItem(Widget): +class StatusBarItem(Widget): def __init__(self, item: StatusItem) -> None: super().__init__(id=f"status-{item.key}") self._item = item @@ -79,7 +79,7 @@ def set_active(self, active: bool) -> None: self.set_class(active, "--active") -class RigiStatusBar(Widget): +class StatusBar(Widget): def __init__(self) -> None: super().__init__() self._items: list[StatusItem] = [] @@ -88,7 +88,7 @@ def add_item(self, item: StatusItem) -> None: self._items.append(item) if self.is_mounted: spacer = self.query_one(_StatusSpacer) - self.mount(RigiStatusItem(item), before=spacer) + self.mount(StatusItem(item), before=spacer) def set_home_active(self, active: bool) -> None: try: @@ -99,6 +99,6 @@ def set_home_active(self, active: bool) -> None: def compose(self) -> ComposeResult: yield _HomeButton() for item in self._items: - yield RigiStatusItem(item) + yield StatusItem(item) yield _StatusSpacer() yield _HamburgerButton() diff --git a/src/rigi/widgets/vertical_tabs.py b/src/rigi/widgets/tab_group.py similarity index 50% rename from src/rigi/widgets/vertical_tabs.py rename to src/rigi/widgets/tab_group.py index 8b91f98..60ec1c0 100644 --- a/src/rigi/widgets/vertical_tabs.py +++ b/src/rigi/widgets/tab_group.py @@ -1,4 +1,4 @@ -"""Vertical tab groups for in-page navigation.""" +"""Horizontal tab groups for in-page navigation with optional wrapping.""" from __future__ import annotations @@ -10,7 +10,7 @@ from textual.widgets import ContentSwitcher, Label -class _VerticalTabItem(Widget): +class _TabItem(Widget): can_focus = False def __init__(self, label: str, idx: int) -> None: @@ -25,40 +25,55 @@ def set_active(self, active: bool) -> None: self.set_class(active, "--active") def on_click(self) -> None: - self.post_message(_VerticalTabClicked(self._idx)) + self.post_message(_TabClicked(self._idx)) self.app.set_focus(None) -class _VerticalTabClicked(Message): +class _TabClicked(Message): def __init__(self, idx: int) -> None: super().__init__() self.idx = idx -class RigiVerticalTabs(Widget): +class TabGroup(Widget): def __init__( self, tabs: list[tuple[str, Callable[[], Widget]]], + wrap: int = 0, **kwargs: Any, ) -> None: super().__init__(**kwargs) self._tab_defs = tabs self._active_idx: int = 0 + self._wrap = wrap def compose(self) -> ComposeResult: - with Widget(id="vt-nav"): - for i, (name, _) in enumerate(self._tab_defs): - item = _VerticalTabItem(name, i) - item.set_active(i == self._active_idx) - yield item - with ContentSwitcher(initial="vt-content-0", id="vt-switcher"): + with Widget(id="tabgroup-nav"): + if self._wrap > 0: + for row_start in range(0, len(self._tab_defs), self._wrap): + with Widget(classes="tab-row"): + for i in range( + row_start, min(row_start + self._wrap, len(self._tab_defs)) + ): + name, _ = self._tab_defs[i] + item = _TabItem(name, i) + item.set_active(i == self._active_idx) + yield item + else: + for i, (name, _) in enumerate(self._tab_defs): + item = _TabItem(name, i) + item.set_active(i == self._active_idx) + yield item + with ContentSwitcher( + initial="tab-content-0", id="tabgroup-switcher" + ): for i, _ in enumerate(self._tab_defs): - yield Widget(id=f"vt-content-{i}") + yield Widget(id=f"tab-content-{i}") def on_mount(self) -> None: for i, (_, factory) in enumerate(self._tab_defs): try: - container = self.query_one(f"#vt-content-{i}", Widget) + container = self.query_one(f"#tab-content-{i}", Widget) container.mount(factory()) except Exception: pass @@ -67,14 +82,14 @@ def set_active(self, idx: int) -> None: if idx < 0 or idx >= len(self._tab_defs): return self._active_idx = idx - for item in self.query(_VerticalTabItem): + for item in self.query(_TabItem): item.set_active(item._idx == idx) try: - switcher = self.query_one("#vt-switcher", ContentSwitcher) - switcher.current = f"vt-content-{idx}" + switcher = self.query_one("#tabgroup-switcher", ContentSwitcher) + switcher.current = f"tab-content-{idx}" except Exception: pass - def on__vertical_tab_clicked(self, event: _VerticalTabClicked) -> None: + def on__tab_clicked(self, event: _TabClicked) -> None: event.stop() self.set_active(event.idx) diff --git a/src/rigi/widgets/terminal_bar.py b/src/rigi/widgets/terminal_bar.py index ae980ee..d005324 100644 --- a/src/rigi/widgets/terminal_bar.py +++ b/src/rigi/widgets/terminal_bar.py @@ -17,12 +17,12 @@ class _TerminalInput(Input): def on_focus(self) -> None: - bar = next((w for w in self.ancestors if isinstance(w, RigiTerminalBar)), None) + bar = next((w for w in self.ancestors if isinstance(w, TerminalBar)), None) if bar is not None: bar._on_focus_changed(True) def on_blur(self) -> None: - bar = next((w for w in self.ancestors if isinstance(w, RigiTerminalBar)), None) + bar = next((w for w in self.ancestors if isinstance(w, TerminalBar)), None) if bar is not None: bar._on_focus_changed(False) @@ -39,7 +39,7 @@ def render(self) -> str: def on_mouse_down(self, event: MouseDown) -> None: self.capture_mouse() self._drag_y = event.screen_y - bar = next((w for w in self.ancestors if isinstance(w, RigiTerminalBar)), None) + bar = next((w for w in self.ancestors if isinstance(w, TerminalBar)), None) if bar is not None: self._drag_h = bar.size.height @@ -48,7 +48,7 @@ def on_mouse_move(self, event: MouseMove) -> None: return delta = self._drag_y - event.screen_y new_h = max(2, self._drag_h + delta) - bar = next((w for w in self.ancestors if isinstance(w, RigiTerminalBar)), None) + bar = next((w for w in self.ancestors if isinstance(w, TerminalBar)), None) if bar is not None: bar.styles.height = new_h @@ -58,7 +58,7 @@ def on_mouse_up(self, _: MouseUp) -> None: self._drag_h = None -class RigiTerminalBar(Widget): +class TerminalBar(Widget): BINDINGS = [ Binding("tab", "complete", "Complete", show=False), ] @@ -156,7 +156,7 @@ def on_input_submitted(self, event: Input.Submitted) -> None: self._history_pos = -1 self._input.value = "" self._completions = [] - self.post_message(RigiTerminalBar.CommandSubmitted(text)) + self.post_message(TerminalBar.CommandSubmitted(text)) def action_complete(self) -> None: if not self._completions: diff --git a/tests/test_basic.py b/tests/test_basic.py index 700d84d..862ca0e 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -3,8 +3,8 @@ from rigi import ( Command, CommandArg, - RigiApp, - RigiTheme, + App, + Theme, StatusItem, SubtabDef, TabDef, @@ -14,17 +14,17 @@ ThemeNord, ) from rigi.commands.registry import CommandRegistry -from rigi.widgets.hamburger_menu import RigiMenuItemData -from rigi.widgets.settings_screen import RigiSettingDef +from rigi.widgets.hamburger_menu import MenuItemData +from rigi.widgets.settings_screen import SettingDef def test_import() -> None: - assert RigiApp is not None + assert App is not None def test_theme_to_css() -> None: css = ThemeDark.to_css() - assert "RigiBorderFrame" in css + assert "BorderFrame" in css assert ThemeDark.name == "dark" @@ -80,7 +80,7 @@ def test_command_completions() -> None: def test_setting_def_get_set() -> None: - s = RigiSettingDef( + s = SettingDef( category="Test", label="Key", value_fn=lambda: "default", @@ -92,7 +92,7 @@ def test_setting_def_get_set() -> None: def test_setting_def_write_fn() -> None: written: list[str] = [] - s = RigiSettingDef( + s = SettingDef( category="Test", label="Key", write_fn=written.append, @@ -102,7 +102,7 @@ def test_setting_def_write_fn() -> None: def test_menu_item_data() -> None: - item = RigiMenuItemData(label="Option", checked=True) + item = MenuItemData(label="Option", checked=True) assert item.label == "Option" assert item.checked is True assert item.submenu is None @@ -110,23 +110,23 @@ def test_menu_item_data() -> None: def test_rigi_app_init() -> None: - app = RigiApp(name="testapp", version="0.0.1", description="test") + app = App(name="testapp", version="0.0.1", description="test") assert app._prog_name == "testapp" assert app._version == "0.0.1" def test_rigi_app_add_tab() -> None: - app = RigiApp(name="testapp") + app = App(name="testapp") tab = app.add_tab(TabDef(name="Dashboard", key="1")) assert tab in app._rigi_tabs def test_rigi_app_command_decorator() -> None: - app = RigiApp(name="testapp") + app = App(name="testapp") calls: list[str] = [] @app.command("greet", help="Say hi") - async def greet(_app: RigiApp, **_: object) -> None: # pyright: ignore[reportUnusedFunction] + async def greet(_app: App, **_: object) -> None: # pyright: ignore[reportUnusedFunction] calls.append("hi") assert app._cmd_registry.get("greet") is not None @@ -143,7 +143,7 @@ def test_status_item() -> None: def test_custom_theme() -> None: - theme = RigiTheme( + theme = Theme( name="custom", border="#ff0000", text="#ffffff", diff --git a/tests/test_checkbox.py b/tests/test_checkbox.py index 9ccfa45..edf3434 100644 --- a/tests/test_checkbox.py +++ b/tests/test_checkbox.py @@ -1,20 +1,20 @@ -"""Tests for RigiCheckbox widget.""" +"""Tests for Checkbox widget.""" import pytest from textual.app import App -from rigi.widgets.checkbox import RigiCheckbox +from rigi.widgets.checkbox import Checkbox @pytest.mark.asyncio async def test_checkbox_initial_value(): class TestApp(App[None]): def compose(self): - yield RigiCheckbox("Test", value=True) + yield Checkbox("Test", value=True) app = TestApp() async with app.run_test() as _: - cb = app.query_one(RigiCheckbox) + cb = app.query_one(Checkbox) assert cb.value is True @@ -22,11 +22,11 @@ def compose(self): async def test_checkbox_toggle(): class TestApp(App[None]): def compose(self): - yield RigiCheckbox("Test", value=False) + yield Checkbox("Test", value=False) app = TestApp() async with app.run_test() as _: - cb = app.query_one(RigiCheckbox) + cb = app.query_one(Checkbox) assert cb.value is False cb.toggle() assert cb.value is True diff --git a/tests/test_resize.py b/tests/test_resize.py index ce876a7..2ac9675 100644 --- a/tests/test_resize.py +++ b/tests/test_resize.py @@ -5,8 +5,8 @@ from textual.events import MouseDown, MouseMove, MouseUp from rigi.commands.registry import CommandRegistry -from rigi.widgets.bottom_panel import RigiBottomPanel, _ResizeHandle -from rigi.widgets.content_area import RigiContentArea +from rigi.widgets.bottom_panel import BottomPanel, _ResizeHandle +from rigi.widgets.content_area import ContentArea from rigi.widgets.sidebar import _VerticalResizeHandle @@ -31,7 +31,7 @@ async def test_horizontal_resize_handle_render(): class TestApp(App[None]): def compose(self): - yield RigiBottomPanel(prompt_text="test", registry=CommandRegistry(), history_file=None) + yield BottomPanel(prompt_text="test", registry=CommandRegistry(), history_file=None) app = TestApp() async with app.run_test() as _: @@ -69,12 +69,12 @@ async def test_resize_minimum_size(): class TestApp(App[None]): def compose(self): - yield RigiBottomPanel(prompt_text="test", registry=CommandRegistry(), history_file=None) + yield BottomPanel(prompt_text="test", registry=CommandRegistry(), history_file=None) app = TestApp() async with app.run_test() as _: handle = app.query_one(_ResizeHandle) - panel = app.query_one(RigiBottomPanel) + panel = app.query_one(BottomPanel) # Simulate drag to very small size handle._drag_y = 100 diff --git a/tests/test_terminal.py b/tests/test_terminal.py index a75b8e5..a3ac476 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -6,7 +6,7 @@ from rigi.commands.command import Command from rigi.commands.registry import CommandRegistry -from rigi.widgets.bottom_panel import RigiBottomPanel, _TerminalInput +from rigi.widgets.bottom_panel import BottomPanel, _TerminalInput @pytest.mark.asyncio @@ -15,7 +15,7 @@ async def test_terminal_input_focus(): class TestApp(App[None]): def compose(self): - yield RigiBottomPanel(prompt_text="test", registry=CommandRegistry(), history_file=None) + yield BottomPanel(prompt_text="test", registry=CommandRegistry(), history_file=None) app = TestApp() async with app.run_test() as pilot: @@ -36,11 +36,11 @@ def compose(self): registry = CommandRegistry() cmd = Command(name="test", help="Test command") registry.register(cmd) - yield RigiBottomPanel(prompt_text="test", registry=registry, history_file=None) + yield BottomPanel(prompt_text="test", registry=registry, history_file=None) app = TestApp() async with app.run_test() as pilot: - panel = app.query_one(RigiBottomPanel) + panel = app.query_one(BottomPanel) terminal_input = app.query_one("#terminal-input", _TerminalInput) # Type command @@ -61,11 +61,11 @@ async def test_terminal_history_navigation(): class TestApp(App[None]): def compose(self): - yield RigiBottomPanel(prompt_text="test", registry=CommandRegistry(), history_file=None) + yield BottomPanel(prompt_text="test", registry=CommandRegistry(), history_file=None) app = TestApp() async with app.run_test() as pilot: - panel = app.query_one(RigiBottomPanel) + panel = app.query_one(BottomPanel) terminal_input = app.query_one("#terminal-input", _TerminalInput) # Add some history @@ -98,12 +98,12 @@ def compose(self): cmd2 = Command(name="terminal", help="Terminal") registry.register(cmd1) registry.register(cmd2) - yield RigiBottomPanel(prompt_text="test", registry=registry, history_file=None) + yield BottomPanel(prompt_text="test", registry=registry, history_file=None) app = TestApp() async with app.run_test() as _: terminal_input = app.query_one("#terminal-input", _TerminalInput) - panel = app.query_one(RigiBottomPanel) + panel = app.query_one(BottomPanel) terminal_input.focus() terminal_input.value = "te" @@ -120,11 +120,11 @@ async def test_terminal_clear(): class TestApp(App[None]): def compose(self): - yield RigiBottomPanel(prompt_text="test", registry=CommandRegistry(), history_file=None) + yield BottomPanel(prompt_text="test", registry=CommandRegistry(), history_file=None) app = TestApp() async with app.run_test() as pilot: - panel = app.query_one(RigiBottomPanel) + panel = app.query_one(BottomPanel) # Write some output panel.write_output("Test line 1") @@ -147,11 +147,11 @@ async def test_terminal_tab_switching(): class TestApp(App[None]): def compose(self): - yield RigiBottomPanel(prompt_text="test", registry=CommandRegistry(), history_file=None) + yield BottomPanel(prompt_text="test", registry=CommandRegistry(), history_file=None) app = TestApp() async with app.run_test() as pilot: - panel = app.query_one(RigiBottomPanel) + panel = app.query_one(BottomPanel) # Should start on terminal assert panel.active_tab == "terminal" diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 78a6cee..92a6c52 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -4,8 +4,8 @@ from textual.app import App from textual.widgets import Label -from rigi.widgets.content_area import RigiContentArea -from rigi.widgets.settings_screen import RigiSettingDef +from rigi.widgets.content_area import ContentArea +from rigi.widgets.settings_screen import SettingDef @pytest.mark.asyncio @@ -14,11 +14,11 @@ async def test_content_area_show_widget(): class TestApp(App[None]): def compose(self): - yield RigiContentArea() + yield ContentArea() app = TestApp() async with app.run_test() as _: - content = app.query_one(RigiContentArea) + content = app.query_one(ContentArea) test_widget = Label("Test") content.show_widget(test_widget) @@ -32,11 +32,11 @@ async def test_content_area_clear(): class TestApp(App[None]): def compose(self): - yield RigiContentArea() + yield ContentArea() app = TestApp() async with app.run_test() as _: - content = app.query_one(RigiContentArea) + content = app.query_one(ContentArea) test_widget = Label("Test") content.show_widget(test_widget) @@ -48,7 +48,7 @@ def compose(self): def test_setting_def_get_value(): """Test getting setting value.""" - setting = RigiSettingDef(category="Test", label="Test Setting", value_fn=lambda: "test_value") + setting = SettingDef(category="Test", label="Test Setting", value_fn=lambda: "test_value") assert setting.get_value() == "test_value" @@ -60,7 +60,7 @@ def test_setting_def_set_value(): def write_fn(value: str) -> None: stored.append(value) - setting = RigiSettingDef(category="Test", label="Test Setting", write_fn=write_fn) + setting = SettingDef(category="Test", label="Test Setting", write_fn=write_fn) setting.set_value("new_value") assert stored == ["new_value"] @@ -72,7 +72,7 @@ def test_setting_def_with_error(): def error_fn(): raise ValueError("Test error") - setting = RigiSettingDef(category="Test", label="Test Setting", value_fn=error_fn) + setting = SettingDef(category="Test", label="Test Setting", value_fn=error_fn) # Should not raise, should return empty string assert setting.get_value() == "" @@ -86,7 +86,7 @@ def value_fn(): call_count.append(1) return "value" - setting = RigiSettingDef(category="Test", label="Test Setting", value_fn=value_fn) + setting = SettingDef(category="Test", label="Test Setting", value_fn=value_fn) # First call val1 = setting.get_value() From c4293ce2af468307ffbfb9c035e6d510b7991527 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 18:37:53 +0000 Subject: [PATCH 15/23] fix: single-row horizontal tabs with underline, transparent modal backgrounds, direct hamburger panel mount --- src/rigi/core/app.py | 20 ++++++++++++++++---- src/rigi/css/default.tcss | 19 ++++--------------- src/rigi/themes/base.py | 9 +++++++++ src/rigi/widgets/tab_group.py | 29 ++++++++--------------------- 4 files changed, 37 insertions(+), 40 deletions(-) diff --git a/src/rigi/core/app.py b/src/rigi/core/app.py index f55487e..7894037 100644 --- a/src/rigi/core/app.py +++ b/src/rigi/core/app.py @@ -26,7 +26,7 @@ from rigi.core.settings_manager import SettingsManager from rigi.core.types import HandlerFn, HelpEntry, StatusItem, SubtabDef, TabDef from rigi.screens.action_menu import ActionMenuScreen -from rigi.widgets.hamburger_overlay import HamburgerOverlay +from rigi.widgets.hamburger_menu import MenuPanel from rigi.screens.help import HelpScreen from rigi.screens.settings import SettingDef, SettingsScreen from rigi.themes import DARK as _DEFAULT_THEME @@ -572,12 +572,17 @@ def on_hamburger_clicked(self, event: _HamburgerButton.Clicked) -> None: def _open_hamburger(self) -> None: try: - existing = self.query_one(HamburgerOverlay) - existing._close() + existing = self.query_one("#rigi-main-menu", MenuPanel) + existing.remove() return except Exception: pass - self.mount(HamburgerOverlay(self._build_hamburger_sections())) + panel = MenuPanel(self._build_hamburger_sections(), id="rigi-main-menu") + panel.styles.layer = "overlay" + panel_w = 26 + x = max(0, self.size.width - panel_w - 1) + panel.styles.offset = (x, 3) + self.mount(panel) def _build_hamburger_sections(self) -> list[tuple[str, list[MenuItemData]]]: from rigi.themes import DARK, LIGHT, MONOKAI, NORD @@ -823,6 +828,13 @@ def on_click(self, event: Any) -> None: items = self._context_menu_items() if items: self.show_action_menu(items, x=event.x, y=event.y) + return + try: + panel = self.query_one("#rigi-main-menu", MenuPanel) + if panel not in event.chain: + panel.remove() + except Exception: + pass def _context_menu_items(self) -> list[ActionMenuItemData]: return [] diff --git a/src/rigi/css/default.tcss b/src/rigi/css/default.tcss index c01c41a..4e7915d 100644 --- a/src/rigi/css/default.tcss +++ b/src/rigi/css/default.tcss @@ -240,14 +240,7 @@ Split { } Split > Widget { width: 1fr; height: 100%; } -HamburgerOverlay { - layer: overlay; - width: 100%; - height: 100%; - background: transparent; -} - -HelpScreen { align: center middle; } +HelpScreen { align: center middle; background: transparent; } HelpScreen > #help-container { width: 60; height: auto; @@ -431,6 +424,7 @@ TabGroup { background: transparent; } TabGroup #tabgroup-nav { + layout: horizontal; width: 100%; height: auto; overflow-x: auto; @@ -439,18 +433,13 @@ TabGroup #tabgroup-nav { border-bottom: solid #21262d; padding: 0 1; } -TabGroup #tabgroup-nav .tab-row { - layout: horizontal; - height: auto; - width: 100%; - background: transparent; -} _TabItem { - height: 1; + height: 3; width: auto; padding: 0 2; color: #6e7681; background: transparent; + content-align: center middle; } _TabItem:hover { color: #c9d1d9; background: #161b22; } _TabItem.--active { diff --git a/src/rigi/themes/base.py b/src/rigi/themes/base.py index 68a4bd6..5bb29dc 100644 --- a/src/rigi/themes/base.py +++ b/src/rigi/themes/base.py @@ -105,10 +105,19 @@ def to_css(self) -> str: border: solid {self.border}; background: {self.completion_bg}; }} +HelpScreen {{ + background: transparent; +}} HelpScreen > #help-container {{ border: round {self.border}; background: {self.popup_bg}; }} +ActionMenuScreen {{ + background: transparent; +}} +SettingsScreen {{ + background: transparent; +}} HamburgerPanel {{ border: round {self.border}; background: {self.popup_bg}; diff --git a/src/rigi/widgets/tab_group.py b/src/rigi/widgets/tab_group.py index 60ec1c0..def6751 100644 --- a/src/rigi/widgets/tab_group.py +++ b/src/rigi/widgets/tab_group.py @@ -1,4 +1,4 @@ -"""Horizontal tab groups for in-page navigation with optional wrapping.""" +"""Horizontal tab groups for in-page navigation.""" from __future__ import annotations @@ -7,7 +7,7 @@ from textual.app import ComposeResult from textual.message import Message from textual.widget import Widget -from textual.widgets import ContentSwitcher, Label +from textual.widgets import ContentSwitcher class _TabItem(Widget): @@ -18,8 +18,8 @@ def __init__(self, label: str, idx: int) -> None: self._label = label self._idx = idx - def compose(self) -> ComposeResult: - yield Label(self._label) + def render(self) -> str: + return self._label def set_active(self, active: bool) -> None: self.set_class(active, "--active") @@ -39,31 +39,18 @@ class TabGroup(Widget): def __init__( self, tabs: list[tuple[str, Callable[[], Widget]]], - wrap: int = 0, **kwargs: Any, ) -> None: super().__init__(**kwargs) self._tab_defs = tabs self._active_idx: int = 0 - self._wrap = wrap def compose(self) -> ComposeResult: with Widget(id="tabgroup-nav"): - if self._wrap > 0: - for row_start in range(0, len(self._tab_defs), self._wrap): - with Widget(classes="tab-row"): - for i in range( - row_start, min(row_start + self._wrap, len(self._tab_defs)) - ): - name, _ = self._tab_defs[i] - item = _TabItem(name, i) - item.set_active(i == self._active_idx) - yield item - else: - for i, (name, _) in enumerate(self._tab_defs): - item = _TabItem(name, i) - item.set_active(i == self._active_idx) - yield item + for i, (name, _) in enumerate(self._tab_defs): + item = _TabItem(name, i) + item.set_active(i == self._active_idx) + yield item with ContentSwitcher( initial="tab-content-0", id="tabgroup-switcher" ): From d8c9f7609983f0e563ab4bdf4c0528a250a08572 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 18:46:28 +0000 Subject: [PATCH 16/23] fix: small 1px tab underline, unified separator line, menu panel self-contained navigation, narrow scrollbars --- src/rigi/css/default.tcss | 9 +++++---- src/rigi/widgets/hamburger_menu.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/rigi/css/default.tcss b/src/rigi/css/default.tcss index 4e7915d..cb4beb9 100644 --- a/src/rigi/css/default.tcss +++ b/src/rigi/css/default.tcss @@ -1,5 +1,7 @@ App { background: transparent; + scrollbar-size-vertical: 1; + scrollbar-size-horizontal: 1; } Screen { @@ -430,22 +432,21 @@ TabGroup #tabgroup-nav { overflow-x: auto; overflow-y: hidden; background: transparent; - border-bottom: solid #21262d; padding: 0 1; } _TabItem { - height: 3; + height: 1; width: auto; padding: 0 2; color: #6e7681; background: transparent; - content-align: center middle; + border-bottom: solid #21262d; } _TabItem:hover { color: #c9d1d9; background: #161b22; } _TabItem.--active { color: #58a6ff; text-style: bold; - border-bottom: thick #58a6ff; + border-bottom: solid #58a6ff; } TabGroup #tabgroup-switcher { width: 100%; diff --git a/src/rigi/widgets/hamburger_menu.py b/src/rigi/widgets/hamburger_menu.py index 64bba05..36170c6 100644 --- a/src/rigi/widgets/hamburger_menu.py +++ b/src/rigi/widgets/hamburger_menu.py @@ -63,6 +63,7 @@ def __init__( ) -> None: super().__init__(**kwargs) self._sections = sections + self._sections_stack: list[list[tuple[str, list[MenuItemData]]]] = [] if title: self.border_title = title @@ -82,5 +83,32 @@ def replace_sections(self, sections: list[tuple[str, list[MenuItemData]]]) -> No for item in items: self.mount(MenuItem(item)) + def on__item_clicked(self, event: _ItemClicked) -> None: + event.stop() + item = event.item + if item.is_back: + self._go_back() + elif item.submenu is not None: + self._enter_submenu(item) + elif item.callback is not None: + callback = item.callback + self.remove() + self.app.call_after_refresh(callback) + + def _enter_submenu(self, item: MenuItemData) -> None: + self._sections_stack.append(self._sections) + back_item = MenuItemData("Back", is_back=True) + self._sections = [("", [back_item] + list(item.submenu or []))] + self.border_title = item.label + self.replace_sections(self._sections) + + def _go_back(self) -> None: + if self._sections_stack: + self._sections = self._sections_stack.pop() + self.border_title = "" + self.replace_sections(self._sections) + else: + self.remove() + HamburgerPanel = MenuPanel From 0a294787c7f649c5c8ff0ea49fa12e2318a65af8 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 19:55:43 +0000 Subject: [PATCH 17/23] feat: narrow hamburger menu to fit longest item, add editable table to example 10 --- examples/10_action_menu.py | 120 +++++++++++++++- src/rigi/__init__.py | 4 + src/rigi/core/app.py | 55 ++++++- src/rigi/css/default.tcss | 40 ++++-- src/rigi/themes/base.py | 6 +- src/rigi/widgets/__init__.py | 4 + src/rigi/widgets/help_overlay.py | 57 ++++++++ src/rigi/widgets/settings_overlay.py | 207 +++++++++++++++++++++++++++ 8 files changed, 463 insertions(+), 30 deletions(-) create mode 100644 src/rigi/widgets/help_overlay.py create mode 100644 src/rigi/widgets/settings_overlay.py diff --git a/examples/10_action_menu.py b/examples/10_action_menu.py index 2544b4a..3eb87d0 100644 --- a/examples/10_action_menu.py +++ b/examples/10_action_menu.py @@ -1,22 +1,117 @@ -"""Action menu example — popup menu with numbered actions.""" +"""Action menu + editable table example.""" from __future__ import annotations +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.events import Key +from textual.widget import Widget +from textual.widgets import DataTable, Input, Label + from rigi import ActionMenuItemData, App, TabDef from rigi.layout.pane import Card, Pane -from rigi.widgets import Label + + +_COLUMNS = ("Name", "Age", "City", "Role") +_ROWS = [ + ("Alice", "30", "New York", "Engineer"), + ("Bob", "25", "London", "Designer"), + ("Carol", "35", "Tokyo", "Manager"), + ("Dave", "28", "Berlin", "Developer"), + ("Eve", "32", "Paris", "Analyst"), +] app = App( name="action-menu", version="1.0.0", - description="Demo of RigiActionMenu", + description="Demo of ActionMenu and EditableTable", home_tab="Demo", ) -def make_demo(): +class EditableTable(Widget): + DEFAULT_CSS = """ + EditableTable { + layout: vertical; + height: 1fr; + width: 100%; + } + EditableTable #cell-input { + margin-bottom: 1; + } + EditableTable DataTable { + height: 1fr; + width: 100%; + } + """ + + BINDINGS = [ + Binding("e", "edit_cell", "Edit"), + Binding("enter", "edit_cell", "Edit"), + ] + + def __init__(self) -> None: + super().__init__() + self._data: list[list[str]] = [list(r) for r in _ROWS] + self._col_keys: list[object] = [] + self._row_keys: list[object] = [] + self._editing: tuple[int, int] | None = None + + def compose(self) -> ComposeResult: + yield Input(placeholder="Edit — Enter to save, Esc to cancel", id="cell-input") + yield DataTable() + + def on_mount(self) -> None: + self.query_one(Input).display = False + table = self.query_one(DataTable) + table.cursor_type = "cell" + self._col_keys = list(table.add_columns(*_COLUMNS)) + for row in self._data: + self._row_keys.append(table.add_row(*row)) + + def action_edit_cell(self) -> None: + inp = self.query_one(Input) + if inp.display: + return + table = self.query_one(DataTable) + coord = table.cursor_coordinate + row_idx, col_idx = coord.row, coord.column + inp.value = self._data[row_idx][col_idx] + inp.display = True + inp.focus() + self._editing = (row_idx, col_idx) + + @on(Input.Submitted, "#cell-input") + def _on_submitted(self, event: Input.Submitted) -> None: + event.stop() + self._save(event.value) + + def on_key(self, event: Key) -> None: + if event.key == "escape" and self.query_one(Input).display: + event.stop() + self._cancel() + + def _save(self, value: str) -> None: + if self._editing is None: + return + row_idx, col_idx = self._editing + self._data[row_idx][col_idx] = value + table = self.query_one(DataTable) + table.update_cell(self._row_keys[row_idx], self._col_keys[col_idx], value) + self._editing = None + self.query_one(Input).display = False + table.focus() + + def _cancel(self) -> None: + self._editing = None + self.query_one(Input).display = False + self.query_one(DataTable).focus() + + +def make_demo() -> Widget: return Pane( - Label("[bold]RigiActionMenu[/bold] — press [cyan]Ctrl+M[/cyan] or use the button below."), + Label("[bold]ActionMenu[/bold] — press [cyan]Ctrl+M[/cyan] or use the button below."), Label(""), Card( Label("Action menus show numbered items with color support."), @@ -26,8 +121,19 @@ def make_demo(): ) -demo_tab = TabDef(name="Demo", key="1", icon="", widget_factory=make_demo) -app.add_tab(demo_tab) +def make_table() -> Widget: + return Pane( + Label( + "[bold]Editable Table[/bold] — arrow keys to navigate, " + "[cyan]E[/cyan] or [cyan]Enter[/cyan] to edit a cell, [cyan]Esc[/cyan] to cancel." + ), + Label(""), + EditableTable(), + ) + + +app.add_tab(TabDef(name="Demo", key="1", icon="", widget_factory=make_demo)) +app.add_tab(TabDef(name="Table", key="2", icon="", widget_factory=make_table)) @app.command("menu", help="Show the action menu") diff --git a/src/rigi/__init__.py b/src/rigi/__init__.py index 936d052..54c09a7 100644 --- a/src/rigi/__init__.py +++ b/src/rigi/__init__.py @@ -44,6 +44,7 @@ from rigi.layout.pane import Card, HPane, Pane, ScrollPane, Split, VPane from rigi.screens.hamburger import HamburgerScreen from rigi.screens.help import HelpScreen +from rigi.widgets.help_overlay import HelpOverlay from rigi.themes import DARK as ThemeDark from rigi.themes import LIGHT as ThemeLight from rigi.themes import MONOKAI as ThemeMonokai @@ -72,6 +73,7 @@ NotificationWidget as NotificationWidget, ) from rigi.core.settings_manager import Setting, SettingsManager, SettingsPage +from rigi.widgets.settings_overlay import SettingsOverlay from rigi.widgets.settings_screen import SettingDef, SettingsScreen from rigi.widgets.sidebar import Sidebar from rigi.widgets.statusbar import StatusBar, StatusBarItem @@ -125,6 +127,7 @@ "HamburgerPanel", "MenuItemData", "SettingsScreen", + "SettingsOverlay", "SettingDef", "Setting", "SettingsPage", @@ -136,6 +139,7 @@ "Clickable", "Draggable", "HelpScreen", + "HelpOverlay", "ShortcutsBar", "extract_help_annotation", "Gauge", diff --git a/src/rigi/core/app.py b/src/rigi/core/app.py index 7894037..239a53c 100644 --- a/src/rigi/core/app.py +++ b/src/rigi/core/app.py @@ -12,6 +12,7 @@ from textual.app import App as _TextualApp, ComposeResult from textual.binding import Binding from textual.notifications import SeverityLevel +from textual.events import Key from textual.widget import Widget from rigi.commands.command import Command @@ -27,8 +28,9 @@ from rigi.core.types import HandlerFn, HelpEntry, StatusItem, SubtabDef, TabDef from rigi.screens.action_menu import ActionMenuScreen from rigi.widgets.hamburger_menu import MenuPanel -from rigi.screens.help import HelpScreen -from rigi.screens.settings import SettingDef, SettingsScreen +from rigi.widgets.help_overlay import HelpOverlay +from rigi.screens.settings import SettingDef +from rigi.widgets.settings_overlay import SettingsOverlay from rigi.themes import DARK as _DEFAULT_THEME from rigi.themes import Theme from rigi.widgets.border_frame import BorderFrame @@ -579,7 +581,7 @@ def _open_hamburger(self) -> None: pass panel = MenuPanel(self._build_hamburger_sections(), id="rigi-main-menu") panel.styles.layer = "overlay" - panel_w = 26 + panel_w = 16 x = max(0, self.size.width - panel_w - 1) panel.styles.offset = (x, 3) self.mount(panel) @@ -675,7 +677,15 @@ def _open_settings(self) -> None: write_fn=self._set_transparency_percent, ), ] - self.push_screen(SettingsScreen(builtin + self._settings_manager.all_defs())) + try: + existing = self.query_one("#rigi-settings-overlay", SettingsOverlay) + existing.remove() + return + except Exception: + pass + overlay = SettingsOverlay(builtin + self._settings_manager.all_defs()) + overlay.styles.layer = "overlay" + self.mount(overlay) # ------------------------------------------------------------------ # # Keyboard actions # @@ -707,7 +717,15 @@ def action_focus_terminal(self) -> None: self.query_one(BottomPanel).focus_input() async def action_show_help(self) -> None: - await self.push_screen(HelpScreen(self._rigi_help_entries)) + try: + existing = self.query_one("#rigi-help-overlay", HelpOverlay) + existing.remove() + return + except Exception: + pass + overlay = HelpOverlay(self._rigi_help_entries) + overlay.styles.layer = "overlay" + self.mount(overlay) def action_copy_focused(self) -> None: text = self._extract_focused_text() @@ -835,6 +853,33 @@ def on_click(self, event: Any) -> None: panel.remove() except Exception: pass + try: + overlay = self.query_one("#rigi-help-overlay", HelpOverlay) + if overlay not in event.chain: + overlay.remove() + except Exception: + pass + try: + overlay = self.query_one("#rigi-settings-overlay", SettingsOverlay) + if overlay not in event.chain: + overlay.remove() + except Exception: + pass + + def on_key(self, event: Key) -> None: + if event.key == "escape": + for selector, cls in ( + ("#rigi-main-menu", MenuPanel), + ("#rigi-settings-overlay", SettingsOverlay), + ("#rigi-help-overlay", HelpOverlay), + ): + try: + widget = self.query_one(selector, cls) + widget.remove() + event.stop() + return + except Exception: + pass def _context_menu_items(self) -> list[ActionMenuItemData]: return [] diff --git a/src/rigi/css/default.tcss b/src/rigi/css/default.tcss index cb4beb9..e3290d9 100644 --- a/src/rigi/css/default.tcss +++ b/src/rigi/css/default.tcss @@ -242,8 +242,13 @@ Split { } Split > Widget { width: 1fr; height: 100%; } -HelpScreen { align: center middle; background: transparent; } -HelpScreen > #help-container { +HelpOverlay { + width: 100%; + height: 100%; + background: transparent; + align: center middle; +} +HelpOverlay > #help-container { width: 60; height: auto; max-height: 80%; @@ -252,7 +257,7 @@ HelpScreen > #help-container { padding: 1 2; overflow-y: auto; } -HelpScreen #help-title { +HelpOverlay #help-title { text-style: bold; color: #58a6ff; width: 100%; @@ -260,11 +265,11 @@ HelpScreen #help-title { margin-bottom: 1; height: 1; } -HelpScreen .help-category { color: #30363d; text-style: bold; margin-top: 1; height: 1; } -HelpScreen .help-row { layout: horizontal; height: 1; width: 100%; } -HelpScreen .help-key { width: 16; color: #e3b341; text-style: bold; } -HelpScreen .help-desc { width: 1fr; color: #8b949e; } -HelpScreen #help-dismiss { +HelpOverlay .help-category { color: #30363d; text-style: bold; margin-top: 1; height: 1; } +HelpOverlay .help-row { layout: horizontal; height: 1; width: 100%; } +HelpOverlay .help-key { width: 16; color: #e3b341; text-style: bold; } +HelpOverlay .help-desc { width: 1fr; color: #8b949e; } +HelpOverlay #help-dismiss { margin-top: 1; color: #6e7681; content-align: center middle; @@ -333,8 +338,13 @@ _SettingSwitch Switch { height: 1; width: auto; } _SettingsContent { width: 1fr; height: 100%; padding: 1 2; overflow-y: auto; background: transparent; } _SettingsContent ._cat-title { color: #58a6ff; text-style: bold; height: 1; margin-bottom: 1; } -SettingsScreen { align: center middle; background: transparent; } -#s-outer { +SettingsOverlay { + width: 100%; + height: 100%; + background: transparent; + align: center middle; +} +SettingsOverlay > #s-outer { width: 90%; height: 85%; border: round #30363d; @@ -394,14 +404,14 @@ _MenuSectionLabel { } MenuPanel { - width: 26; + width: 16; height: auto; - max-height: 24; + max-height: 14; border: round #30363d; border-title-color: #c9d1d9; background: #0d1117; padding: 0; - overflow-y: auto; + overflow-y: hidden; } ActionMenuPanel { @@ -432,15 +442,15 @@ TabGroup #tabgroup-nav { overflow-x: auto; overflow-y: hidden; background: transparent; - padding: 0 1; + border-bottom: solid #21262d; } _TabItem { height: 1; + box-sizing: content-box; width: auto; padding: 0 2; color: #6e7681; background: transparent; - border-bottom: solid #21262d; } _TabItem:hover { color: #c9d1d9; background: #161b22; } _TabItem.--active { diff --git a/src/rigi/themes/base.py b/src/rigi/themes/base.py index 5bb29dc..0653f69 100644 --- a/src/rigi/themes/base.py +++ b/src/rigi/themes/base.py @@ -105,17 +105,17 @@ def to_css(self) -> str: border: solid {self.border}; background: {self.completion_bg}; }} -HelpScreen {{ +HelpOverlay {{ background: transparent; }} -HelpScreen > #help-container {{ +HelpOverlay > #help-container {{ border: round {self.border}; background: {self.popup_bg}; }} ActionMenuScreen {{ background: transparent; }} -SettingsScreen {{ +SettingsOverlay {{ background: transparent; }} HamburgerPanel {{ diff --git a/src/rigi/widgets/__init__.py b/src/rigi/widgets/__init__.py index 8d559aa..029f073 100644 --- a/src/rigi/widgets/__init__.py +++ b/src/rigi/widgets/__init__.py @@ -53,6 +53,8 @@ from rigi.widgets.help_panel import ShortcutsBar, extract_help_annotation from rigi.widgets.image import Image, TerminalImageProtocol, detect_image_protocol from rigi.widgets.mouse import Clickable, Draggable, MouseMixin +from rigi.widgets.help_overlay import HelpOverlay +from rigi.widgets.settings_overlay import SettingsOverlay from rigi.widgets.settings_screen import SettingDef, SettingsScreen from rigi.widgets.sidebar import Sidebar from rigi.widgets.statusbar import StatusBar, StatusItem @@ -83,7 +85,9 @@ "ActionMenuItemData", "TabGroup", "SettingsScreen", + "SettingsOverlay", "SettingDef", + "HelpOverlay", "Image", "TerminalImageProtocol", "detect_image_protocol", diff --git a/src/rigi/widgets/help_overlay.py b/src/rigi/widgets/help_overlay.py new file mode 100644 index 0000000..ab6a35c --- /dev/null +++ b/src/rigi/widgets/help_overlay.py @@ -0,0 +1,57 @@ +"""HelpOverlay — transparent overlay widget with help content.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.events import Click +from textual.widget import Widget +from textual.widgets import Label + +from rigi.core.types import HelpEntry + +BUILTIN_SHORTCUTS: list[HelpEntry] = [ + HelpEntry("Ctrl+Q", "Quit the application", "Navigation"), + HelpEntry("Ctrl+T", "Focus the terminal input", "Navigation"), + HelpEntry("Ctrl+H", "Open / close this help panel", "Navigation"), + HelpEntry("Ctrl+P", "Open command palette (fuzzy search)", "Navigation"), + HelpEntry("↑ / ↓", "Move through sidebar items", "Navigation"), + HelpEntry("→ / ←", "Enter / leave subtab group", "Navigation"), + HelpEntry("Ctrl+C", "Copy focused cell / label to clipboard", "Navigation"), + HelpEntry("Tab", "Auto-complete command in terminal", "Terminal"), + HelpEntry("↑ / ↓", "Browse command history", "Terminal"), + HelpEntry("Enter", "Submit command", "Terminal"), +] + + +class HelpOverlay(Widget): + can_focus = True + + def __init__(self, entries: list[HelpEntry]) -> None: + super().__init__() + self._entries = entries + + def compose(self) -> ComposeResult: + with Widget(id="help-container"): + yield Label(" Help", id="help-title") + + all_entries = BUILTIN_SHORTCUTS + self._entries + categories: dict[str, list[HelpEntry]] = {} + for e in all_entries: + categories.setdefault(e.category, []).append(e) + + for cat, items in categories.items(): + yield Label(f"── {cat} ──", classes="help-category") + for item in items: + yield Widget( + Label(item.key, classes="help-key"), + Label(item.description, classes="help-desc"), + classes="help-row", + ) + + yield Label("Esc → close", id="help-dismiss") + + def on_click(self, event: Click) -> None: + container = self.query_one("#help-container") + if not container.region.contains(event.x, event.y): + self.remove() + event.stop() diff --git a/src/rigi/widgets/settings_overlay.py b/src/rigi/widgets/settings_overlay.py new file mode 100644 index 0000000..1e19066 --- /dev/null +++ b/src/rigi/widgets/settings_overlay.py @@ -0,0 +1,207 @@ +"""SettingsOverlay — transparent overlay widget with settings content.""" + +from __future__ import annotations + +import logging +from typing import Callable + +from textual import on +from textual.app import ComposeResult +from textual.events import Click +from textual.message import Message +from textual.widget import Widget +from textual.widgets import Button, Input, Label, Switch + +from rigi.screens.settings import SettingDef + +_ui_log = logging.getLogger("rigi.ui") + + +class _CategoryClicked(Message): + def __init__(self, name: str) -> None: + super().__init__() + self.name = name + + +class _CategoryRow(Widget): + can_focus = False + + def __init__(self, name: str) -> None: + super().__init__() + self._cat_name = name + + def compose(self) -> ComposeResult: + yield Label(self._cat_name) + + def set_active(self, v: bool) -> None: + self.set_class(v, "--active") + + def on_click(self) -> None: + self.post_message(_CategoryClicked(self._cat_name)) + + +class _ActionButton(Widget): + can_focus = False + + def __init__(self, label: str, callback: Callable[[], None]) -> None: + super().__init__() + self._label = label + self._callback = callback + + def compose(self) -> ComposeResult: + yield Label(self._label) + + def on_click(self) -> None: + self._callback() + try: + overlay = self.app.query_one("#rigi-settings-overlay") + if isinstance(overlay, SettingsOverlay): + overlay._refresh_content() + except Exception as e: + _ui_log.error(f"Error in action button click: {e}", exc_info=True) + + +class _ValueRow(Widget): + def __init__(self, setting: SettingDef) -> None: + super().__init__() + self._setting = setting + + def compose(self) -> ComposeResult: + yield Label(self._setting.get_value(), classes="_val-lbl") + if self._setting.action_fn: + yield _ActionButton(self._setting.action_label, self._setting.action_fn) + + +class _SettingInput(Input): + def __init__(self, setting: SettingDef) -> None: + super().__init__(value=setting.get_value()) + self._setting = setting + self.restrict = None + + def _commit(self) -> None: + self._setting.set_value(self.value) + + def on_blur(self) -> None: + self._commit() + + @on(Input.Submitted) + def on_submitted(self, event: Input.Submitted) -> None: + event.stop() + self._commit() + self.app.set_focus(None) + + +class _SettingSwitch(Widget): + def __init__(self, setting: SettingDef) -> None: + super().__init__() + self._setting = setting + + def compose(self) -> ComposeResult: + yield Switch(value=self._setting.get_checked(), classes="_s-switch") + + @on(Switch.Changed) + def on_changed(self, event: Switch.Changed) -> None: + event.stop() + if self._setting.toggle_fn: + try: + self._setting.toggle_fn() + except Exception as e: + _ui_log.error(f"Error toggling setting {self._setting.label}: {e}", exc_info=True) + for sibling in self.siblings: + if isinstance(sibling, _SettingInput): + sibling.display = event.value + if event.value: + sibling.focus() + + +class _SettingItem(Widget): + def __init__(self, setting: SettingDef) -> None: + super().__init__() + self._setting = setting + + def compose(self) -> ComposeResult: + yield Label(self._setting.label, classes="_s-label") + if self._setting.description: + yield Label(self._setting.description, classes="_s-desc") + if self._setting.checkbox_fn is not None: + yield _SettingSwitch(self._setting) + if self._setting.write_fn is not None: + inp = _SettingInput(self._setting) + if self._setting.checkbox_fn is not None: + inp.display = self._setting.get_checked() + yield inp + elif self._setting.value_fn is not None or self._setting.action_fn is not None: + if self._setting.checkbox_fn is None: + yield _ValueRow(self._setting) + + +class _SettingsContent(Widget): + def compose(self) -> ComposeResult: + yield from [] + + +class SettingsOverlay(Widget): + can_focus = True + + def __init__(self, settings: list[SettingDef]) -> None: + super().__init__() + self._settings = settings + self._active_category = "" + self._categories: list[str] = [] + seen: set[str] = set() + for s in settings: + if s.category not in seen: + seen.add(s.category) + self._categories.append(s.category) + if self._categories: + self._active_category = self._categories[0] + + def compose(self) -> ComposeResult: + with Widget(id="s-outer"): + with Widget(id="s-titlebar"): + yield Label("Settings", id="s-title-lbl") + yield Button("×", id="s-close-btn") + with Widget(id="s-body"): + with Widget(id="s-categories"): + for cat in self._categories: + row = _CategoryRow(cat) + if cat == self._active_category: + row.add_class("--active") + yield row + yield _SettingsContent(id="s-content") + + def on_mount(self) -> None: + self.query_one("#s-outer").border_title = "⚙ Settings" + self._render_category(self._active_category) + + @on(Button.Pressed, "#s-close-btn") + def on_close_pressed(self, event: Button.Pressed) -> None: + event.stop() + self.remove() + + @on(_CategoryClicked) + def on_category_clicked(self, event: _CategoryClicked) -> None: + event.stop() + if event.name == self._active_category: + return + self._active_category = event.name + for row in self.query(_CategoryRow): + row.set_active(row._cat_name == event.name) + self._render_category(event.name) + + def _render_category(self, category: str) -> None: + content = self.query_one("#s-content", _SettingsContent) + content.remove_children() + content.mount(Label(category, classes="_cat-title")) + for s in self._settings: + if s.category == category: + content.mount(_SettingItem(s)) + + def _refresh_content(self) -> None: + self._render_category(self._active_category) + + def on_click(self, event: Click) -> None: + container = self.query_one("#s-outer") + if not container.region.contains(event.x, event.y): + self.remove() + event.stop() From 399afe5c08a7b5c583b6a967de8d40dbfc484508 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 19:55:53 +0000 Subject: [PATCH 18/23] fix: use Any for DataTable key lists in example 10 --- examples/10_action_menu.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/10_action_menu.py b/examples/10_action_menu.py index 3eb87d0..3a27eba 100644 --- a/examples/10_action_menu.py +++ b/examples/10_action_menu.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from textual import on from textual.app import ComposeResult from textual.binding import Binding @@ -54,8 +56,8 @@ class EditableTable(Widget): def __init__(self) -> None: super().__init__() self._data: list[list[str]] = [list(r) for r in _ROWS] - self._col_keys: list[object] = [] - self._row_keys: list[object] = [] + self._col_keys: list[Any] = [] + self._row_keys: list[Any] = [] self._editing: tuple[int, int] | None = None def compose(self) -> ComposeResult: From 62b02c2e86e1cdd38a6074113238f3cdc4f0be17 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 20:23:47 +0000 Subject: [PATCH 19/23] feat: row cursor, right-click/E action menu with edit modal and delete in example 10 --- examples/10_action_menu.py | 166 +++++++++++++++++++++++++++---------- 1 file changed, 121 insertions(+), 45 deletions(-) diff --git a/examples/10_action_menu.py b/examples/10_action_menu.py index 3a27eba..04ae488 100644 --- a/examples/10_action_menu.py +++ b/examples/10_action_menu.py @@ -7,7 +7,8 @@ from textual import on from textual.app import ComposeResult from textual.binding import Binding -from textual.events import Key +from textual.events import Click +from textual.screen import ModalScreen from textual.widget import Widget from textual.widgets import DataTable, Input, Label @@ -32,6 +33,85 @@ ) +class _EditRowScreen(ModalScreen[list[str] | None]): + DEFAULT_CSS = """ + _EditRowScreen { + align: center middle; + } + _EditRowScreen > #er-box { + width: 48; + height: auto; + border: round #30363d; + background: #0d1117; + padding: 1 2; + } + _EditRowScreen #er-title { + color: #58a6ff; + text-style: bold; + height: 1; + margin-bottom: 1; + } + _EditRowScreen .er-label { + color: #6e7681; + height: 1; + margin-top: 1; + } + _EditRowScreen .er-input { + height: 1; + border: solid #30363d; + background: #161b22; + color: #e6edf3; + padding: 0 1; + } + _EditRowScreen .er-input:focus { + border: solid #58a6ff; + } + _EditRowScreen #er-hint { + color: #3d444d; + height: 1; + margin-top: 1; + content-align: center middle; + width: 100%; + } + """ + + BINDINGS = [Binding("escape", "dismiss_none", show=False)] + + def __init__(self, columns: tuple[str, ...], values: list[str]) -> None: + super().__init__() + self._columns = columns + self._values = values + + def compose(self) -> ComposeResult: + with Widget(id="er-box"): + yield Label("Edit Row", id="er-title") + for col, val in zip(self._columns, self._values): + yield Label(col, classes="er-label") + yield Input(value=val, classes="er-input") + yield Label("Enter — save · Esc — cancel", id="er-hint") + + def on_mount(self) -> None: + inputs = list(self.query(Input)) + if inputs: + inputs[0].focus() + + def action_dismiss_none(self) -> None: + self.dismiss(None) + + @on(Input.Submitted) + def _on_submitted(self, event: Input.Submitted) -> None: + event.stop() + inputs = list(self.query(Input)) + try: + idx = inputs.index(event.input) + except ValueError: + idx = -1 + if idx == len(inputs) - 1: + self.dismiss([inp.value for inp in inputs]) + elif idx >= 0: + inputs[idx + 1].focus() + + class EditableTable(Widget): DEFAULT_CSS = """ EditableTable { @@ -39,76 +119,72 @@ class EditableTable(Widget): height: 1fr; width: 100%; } - EditableTable #cell-input { - margin-bottom: 1; - } EditableTable DataTable { height: 1fr; width: 100%; } """ - BINDINGS = [ - Binding("e", "edit_cell", "Edit"), - Binding("enter", "edit_cell", "Edit"), - ] + BINDINGS = [Binding("e", "row_menu", "Actions")] def __init__(self) -> None: super().__init__() self._data: list[list[str]] = [list(r) for r in _ROWS] self._col_keys: list[Any] = [] self._row_keys: list[Any] = [] - self._editing: tuple[int, int] | None = None def compose(self) -> ComposeResult: - yield Input(placeholder="Edit — Enter to save, Esc to cancel", id="cell-input") yield DataTable() def on_mount(self) -> None: - self.query_one(Input).display = False table = self.query_one(DataTable) - table.cursor_type = "cell" + table.cursor_type = "row" self._col_keys = list(table.add_columns(*_COLUMNS)) for row in self._data: self._row_keys.append(table.add_row(*row)) - def action_edit_cell(self) -> None: - inp = self.query_one(Input) - if inp.display: - return + def action_row_menu(self) -> None: table = self.query_one(DataTable) - coord = table.cursor_coordinate - row_idx, col_idx = coord.row, coord.column - inp.value = self._data[row_idx][col_idx] - inp.display = True - inp.focus() - self._editing = (row_idx, col_idx) - - @on(Input.Submitted, "#cell-input") - def _on_submitted(self, event: Input.Submitted) -> None: - event.stop() - self._save(event.value) + row_idx = table.cursor_coordinate.row + x = table.region.x + 2 + y = table.region.y + row_idx + 2 + self._show_row_menu(row_idx, x, y) - def on_key(self, event: Key) -> None: - if event.key == "escape" and self.query_one(Input).display: + def on_click(self, event: Click) -> None: + if event.button == 3: event.stop() - self._cancel() + table = self.query_one(DataTable) + row_idx = table.cursor_coordinate.row + self._show_row_menu(row_idx, event.screen_x, event.screen_y) - def _save(self, value: str) -> None: - if self._editing is None: + def _show_row_menu(self, row_idx: int, x: int, y: int) -> None: + if row_idx < 0 or row_idx >= len(self._data): return - row_idx, col_idx = self._editing - self._data[row_idx][col_idx] = value + items = [ + ActionMenuItemData("Edit", callback=lambda: self._edit_row(row_idx)), + ActionMenuItemData("Delete", color="red", callback=lambda: self._delete_row(row_idx)), + ] + self.app.show_action_menu(items, x=x, y=y) + + def _edit_row(self, row_idx: int) -> None: + def _apply(values: list[str] | None) -> None: + if values is None: + return + self._data[row_idx] = values + table = self.query_one(DataTable) + for col_idx, val in enumerate(values): + table.update_cell(self._row_keys[row_idx], self._col_keys[col_idx], val) + + self.app.push_screen( + _EditRowScreen(_COLUMNS, list(self._data[row_idx])), + _apply, + ) + + def _delete_row(self, row_idx: int) -> None: table = self.query_one(DataTable) - table.update_cell(self._row_keys[row_idx], self._col_keys[col_idx], value) - self._editing = None - self.query_one(Input).display = False - table.focus() - - def _cancel(self) -> None: - self._editing = None - self.query_one(Input).display = False - self.query_one(DataTable).focus() + table.remove_row(self._row_keys[row_idx]) + self._data.pop(row_idx) + self._row_keys.pop(row_idx) def make_demo() -> Widget: @@ -126,8 +202,8 @@ def make_demo() -> Widget: def make_table() -> Widget: return Pane( Label( - "[bold]Editable Table[/bold] — arrow keys to navigate, " - "[cyan]E[/cyan] or [cyan]Enter[/cyan] to edit a cell, [cyan]Esc[/cyan] to cancel." + "[bold]Editable Table[/bold] — arrows to navigate, " + "[cyan]E[/cyan] or [cyan]RMB[/cyan] for actions." ), Label(""), EditableTable(), From cbbcec65047110bfec31c63c7a49177ea926a0e7 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Wed, 13 May 2026 20:42:34 +0000 Subject: [PATCH 20/23] fix: action menu as overlay, right-click via mouse_down, pyright/ruff/black clean, bump 1.3.0 --- examples/05_system_monitor.py | 4 +- examples/08_platform_features.py | 2 +- examples/10_action_menu.py | 29 +++++---- pyproject.toml | 2 +- src/rigi/__init__.py | 10 +-- src/rigi/core/_cmd_handlers.py | 6 +- src/rigi/core/app.py | 107 ++++++++++++------------------- src/rigi/screens/action_menu.py | 12 ++-- src/rigi/widgets/__init__.py | 4 +- src/rigi/widgets/action_menu.py | 29 ++++++++- src/rigi/widgets/content_area.py | 4 +- src/rigi/widgets/statusbar.py | 4 +- src/rigi/widgets/tab_group.py | 4 +- tests/conftest.py | 6 ++ tests/test_basic.py | 4 +- tests/test_resize.py | 1 - 16 files changed, 119 insertions(+), 109 deletions(-) create mode 100644 tests/conftest.py diff --git a/examples/05_system_monitor.py b/examples/05_system_monitor.py index 8f3f1b0..8487c40 100644 --- a/examples/05_system_monitor.py +++ b/examples/05_system_monitor.py @@ -11,9 +11,7 @@ from rigi.layout.pane import Card, HPane, Pane from rigi.widgets import DataTable, Label -app = App( - name="sysmon", version="1.0.0", description="System resource monitor", home_tab="System" -) +app = App(name="sysmon", version="1.0.0", description="System resource monitor", home_tab="System") _start = time.time() diff --git a/examples/08_platform_features.py b/examples/08_platform_features.py index f5e7600..8ae0963 100644 --- a/examples/08_platform_features.py +++ b/examples/08_platform_features.py @@ -7,7 +7,7 @@ from rigi import App, TabDef, platform from rigi.layout.pane import Card, Pane -from rigi.widgets import Label, Markdown, Gauge, Sparkline +from rigi.widgets import Gauge, Label, Markdown, Sparkline app = App( name="platform-demo", diff --git a/examples/10_action_menu.py b/examples/10_action_menu.py index 04ae488..ec02821 100644 --- a/examples/10_action_menu.py +++ b/examples/10_action_menu.py @@ -2,12 +2,12 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from textual import on from textual.app import ComposeResult from textual.binding import Binding -from textual.events import Click +from textual.events import MouseDown from textual.screen import ModalScreen from textual.widget import Widget from textual.widgets import DataTable, Input, Label @@ -15,7 +15,6 @@ from rigi import ActionMenuItemData, App, TabDef from rigi.layout.pane import Card, Pane - _COLUMNS = ("Name", "Age", "City", "Role") _ROWS = [ ("Alice", "30", "New York", "Engineer"), @@ -85,7 +84,7 @@ def __init__(self, columns: tuple[str, ...], values: list[str]) -> None: def compose(self) -> ComposeResult: with Widget(id="er-box"): yield Label("Edit Row", id="er-title") - for col, val in zip(self._columns, self._values): + for col, val in zip(self._columns, self._values, strict=True): yield Label(col, classes="er-label") yield Input(value=val, classes="er-input") yield Label("Enter — save · Esc — cancel", id="er-hint") @@ -150,12 +149,16 @@ def action_row_menu(self) -> None: y = table.region.y + row_idx + 2 self._show_row_menu(row_idx, x, y) - def on_click(self, event: Click) -> None: + def on_mouse_down(self, event: MouseDown) -> None: if event.button == 3: event.stop() table = self.query_one(DataTable) - row_idx = table.cursor_coordinate.row - self._show_row_menu(row_idx, event.screen_x, event.screen_y) + table_region = table.region + row_in_view = event.screen_y - table_region.y - 1 + scroll_y = int(table.scroll_offset.y) + row_idx = max(0, min(row_in_view + scroll_y, len(self._data) - 1)) + if row_in_view >= 0: + self._show_row_menu(row_idx, event.screen_x, event.screen_y) def _show_row_menu(self, row_idx: int, x: int, y: int) -> None: if row_idx < 0 or row_idx >= len(self._data): @@ -164,7 +167,7 @@ def _show_row_menu(self, row_idx: int, x: int, y: int) -> None: ActionMenuItemData("Edit", callback=lambda: self._edit_row(row_idx)), ActionMenuItemData("Delete", color="red", callback=lambda: self._delete_row(row_idx)), ] - self.app.show_action_menu(items, x=x, y=y) + cast(App, self.app).show_action_menu(items, x=x, y=y) def _edit_row(self, row_idx: int) -> None: def _apply(values: list[str] | None) -> None: @@ -175,7 +178,7 @@ def _apply(values: list[str] | None) -> None: for col_idx, val in enumerate(values): table.update_cell(self._row_keys[row_idx], self._col_keys[col_idx], val) - self.app.push_screen( + cast(App, self.app).push_screen( _EditRowScreen(_COLUMNS, list(self._data[row_idx])), _apply, ) @@ -218,8 +221,12 @@ def make_table() -> Widget: async def cmd_menu(app: App, **_: object) -> None: items = [ ActionMenuItemData("Copy", color="cyan", callback=lambda: app.notify("Copied!", timeout=2)), - ActionMenuItemData("Paste", color="green", callback=lambda: app.notify("Pasted!", timeout=2)), - ActionMenuItemData("Delete", color="red", callback=lambda: app.notify("Deleted!", timeout=2)), + ActionMenuItemData( + "Paste", color="green", callback=lambda: app.notify("Pasted!", timeout=2) + ), + ActionMenuItemData( + "Delete", color="red", callback=lambda: app.notify("Deleted!", timeout=2) + ), ActionMenuItemData("Rename", callback=lambda: app.notify("Renamed!", timeout=2)), ActionMenuItemData("Cancel", disabled=True), ] diff --git a/pyproject.toml b/pyproject.toml index f46c62b..76d0778 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "rigi" -version = "1.2.0" +version = "1.3.0" description = "Rigi isn't a graphics interface, it's terminal. A high-level TUI framework built on Textual." readme = "README.md" requires-python = ">=3.10" diff --git a/src/rigi/__init__.py b/src/rigi/__init__.py index 54c09a7..87e97ea 100644 --- a/src/rigi/__init__.py +++ b/src/rigi/__init__.py @@ -40,22 +40,21 @@ terminal_size, tmux_passthrough, ) +from rigi.core.settings_manager import Setting, SettingsManager, SettingsPage from rigi.core.types import CommandArg, HelpEntry, StatusItem, SubtabDef, TabDef from rigi.layout.pane import Card, HPane, Pane, ScrollPane, Split, VPane from rigi.screens.hamburger import HamburgerScreen from rigi.screens.help import HelpScreen -from rigi.widgets.help_overlay import HelpOverlay from rigi.themes import DARK as ThemeDark from rigi.themes import LIGHT as ThemeLight from rigi.themes import MONOKAI as ThemeMonokai from rigi.themes import NORD as ThemeNord from rigi.themes import Theme +from rigi.widgets.action_menu import ActionMenuItemData from rigi.widgets.border_frame import BorderFrame from rigi.widgets.bottom_panel import BottomPanel -from rigi.widgets.action_menu import ActionMenuItemData from rigi.widgets.checkbox import Checkbox from rigi.widgets.content_area import ContentArea -from rigi.widgets.tab_group import TabGroup from rigi.widgets.gauge import Gauge, Sparkline from rigi.widgets.hamburger_menu import ( HamburgerPanel, @@ -63,6 +62,7 @@ MenuItemData, MenuPanel, ) +from rigi.widgets.help_overlay import HelpOverlay from rigi.widgets.help_panel import ShortcutsBar, extract_help_annotation from rigi.widgets.image import Image, TerminalImageProtocol, detect_image_protocol from rigi.widgets.mouse import Clickable, Draggable, MouseMixin @@ -72,14 +72,14 @@ from rigi.widgets.notifications import ( NotificationWidget as NotificationWidget, ) -from rigi.core.settings_manager import Setting, SettingsManager, SettingsPage from rigi.widgets.settings_overlay import SettingsOverlay from rigi.widgets.settings_screen import SettingDef, SettingsScreen from rigi.widgets.sidebar import Sidebar from rigi.widgets.statusbar import StatusBar, StatusBarItem +from rigi.widgets.tab_group import TabGroup from rigi.widgets.terminal_bar import TerminalBar -__version__ = "1.2.0" +__version__ = "1.3.0" __all__ = [ # Textual primitives "Widget", diff --git a/src/rigi/core/_cmd_handlers.py b/src/rigi/core/_cmd_handlers.py index 473c985..af97e65 100644 --- a/src/rigi/core/_cmd_handlers.py +++ b/src/rigi/core/_cmd_handlers.py @@ -18,14 +18,12 @@ async def cmd_terminal(app: App, **_: Any) -> None: lines = [ "[bold]Terminal Info[/bold]", f" Terminal: {nfo['terminal']}", - f" True color: {'yes' if nfo['true_color'] else 'no'}" - f" (depth {nfo['color_depth']})", + f" True color: {'yes' if nfo['true_color'] else 'no'}" f" (depth {nfo['color_depth']})", f" Hyperlinks: {'yes' if nfo['hyperlinks'] else 'no'}", f" Unicode: {'yes' if nfo['unicode'] else 'no'}", f" Mouse: {'yes' if nfo['mouse'] else 'no'}", f" Kitty gfx: {'yes' if nfo['kitty_graphics'] else 'no'}", - f" Multiplexer: " - f"{'tmux' if nfo['tmux'] else 'screen' if nfo['screen'] else 'none'}", + f" Multiplexer: " f"{'tmux' if nfo['tmux'] else 'screen' if nfo['screen'] else 'none'}", f" Size: {nfo['columns']}×{nfo['lines']}", ] try: diff --git a/src/rigi/core/app.py b/src/rigi/core/app.py index 239a53c..73d77eb 100644 --- a/src/rigi/core/app.py +++ b/src/rigi/core/app.py @@ -9,10 +9,11 @@ from typing import Any, Awaitable, Callable from textual import on -from textual.app import App as _TextualApp, ComposeResult +from textual.app import App as _TextualApp +from textual.app import ComposeResult from textual.binding import Binding -from textual.notifications import SeverityLevel from textual.events import Key +from textual.notifications import SeverityLevel from textual.widget import Widget from rigi.commands.command import Command @@ -26,20 +27,18 @@ from rigi.core.dev_commands import register_dev_commands from rigi.core.settings_manager import SettingsManager from rigi.core.types import HandlerFn, HelpEntry, StatusItem, SubtabDef, TabDef -from rigi.screens.action_menu import ActionMenuScreen -from rigi.widgets.hamburger_menu import MenuPanel -from rigi.widgets.help_overlay import HelpOverlay from rigi.screens.settings import SettingDef -from rigi.widgets.settings_overlay import SettingsOverlay from rigi.themes import DARK as _DEFAULT_THEME from rigi.themes import Theme +from rigi.widgets.action_menu import ActionMenuItemData, ActionMenuPanel from rigi.widgets.border_frame import BorderFrame from rigi.widgets.bottom_panel import BottomPanel from rigi.widgets.content_area import ContentArea -from rigi.widgets.action_menu import ActionMenuItemData -from rigi.widgets.hamburger_menu import MenuItemData +from rigi.widgets.hamburger_menu import MenuItemData, MenuPanel +from rigi.widgets.help_overlay import HelpOverlay from rigi.widgets.help_panel import ShortcutsBar, extract_help_annotation from rigi.widgets.notifications import NotificationRack +from rigi.widgets.settings_overlay import SettingsOverlay from rigi.widgets.sidebar import Sidebar from rigi.widgets.statusbar import ( StatusBar, @@ -104,9 +103,12 @@ def __init__( if env_theme: from rigi.themes import DARK, LIGHT, MONOKAI, NORD - resolved_theme = {"dark": DARK, "light": LIGHT, "monokai": MONOKAI, "nord": NORD}.get( - env_theme - ) + resolved_theme = { + "dark": DARK, + "light": LIGHT, + "monokai": MONOKAI, + "nord": NORD, + }.get(env_theme) self._theme: Theme = resolved_theme if resolved_theme is not None else _DEFAULT_THEME self._theme_tie_breaker: int = 200 self._home_tab_name: str | None = home_tab @@ -126,10 +128,6 @@ def __init__( self._disable_notifications = True self._register_builtin_commands() - # ------------------------------------------------------------------ # - # Built-in commands # - # ------------------------------------------------------------------ # - def _register_builtin_commands(self) -> None: help_cmd = Command( name="help", help="Show available commands or detailed help for a command" @@ -171,10 +169,6 @@ def _register_builtin_commands(self) -> None: register_dev_commands(self._cmd_registry) - # ------------------------------------------------------------------ # - # Composition & mount # - # ------------------------------------------------------------------ # - def compose(self) -> ComposeResult: status_bar = StatusBar() for item in self._rigi_status_items: @@ -236,10 +230,6 @@ def _set_terminal_title(self) -> None: except Exception: pass - # ------------------------------------------------------------------ # - # Notifications & terminal info # - # ------------------------------------------------------------------ # - def notify( self, message: str, @@ -270,10 +260,6 @@ def terminal_info(self) -> dict[str, object]: def hyperlink(self, url: str, text: str) -> str: return _console.hyperlink(url, text) - # ------------------------------------------------------------------ # - # CSS & theme # - # ------------------------------------------------------------------ # - def _apply_css_file(self, path: Path) -> None: try: css_text = path.read_text(encoding="utf-8") @@ -386,10 +372,6 @@ def _cycle_theme(self) -> None: idx = 0 self.set_theme(_themes[idx]) - # ------------------------------------------------------------------ # - # Navigation # - # ------------------------------------------------------------------ # - @on(Sidebar.NavigationChanged) def on_sidebar_nav(self, event: Sidebar.NavigationChanged) -> None: self._navigate_to(event.tab_idx, event.subtab_path) @@ -479,10 +461,6 @@ def _evict(widget: Widget) -> None: if key[0] == idx: _evict(self._rigi_widget_cache.pop(key)) - # ------------------------------------------------------------------ # - # Terminal command processing # - # ------------------------------------------------------------------ # - @on(BottomPanel.CommandSubmitted) def on_command_submitted(self, event: BottomPanel.CommandSubmitted) -> None: self.run_worker(self._handle_command(event.text), name="rigi-cmd", exclusive=False) @@ -514,9 +492,7 @@ async def _handle_command(self, text: str) -> None: if cmd is None: return - nav_tab = next( - (t for t in self._rigi_tabs if t.name.lower() == cmd.name.lower()), None - ) + nav_tab = next((t for t in self._rigi_tabs if t.name.lower() == cmd.name.lower()), None) if nav_tab is not None and cmd.handler is None: self.navigate_to_tab(nav_tab.name) _terminal_log.info(f"Navigated to tab: {nav_tab.name}") @@ -563,10 +539,6 @@ async def _run_shell(self, cmd: str) -> None: except Exception: self.notify(msg, severity="error", title=f"$ {cmd[:30]}") - # ------------------------------------------------------------------ # - # Hamburger menu # - # ------------------------------------------------------------------ # - @on(_HamburgerButton.Clicked) def on_hamburger_clicked(self, event: _HamburgerButton.Clicked) -> None: event.stop() @@ -617,10 +589,6 @@ def _build_hamburger_sections(self) -> list[tuple[str, list[MenuItemData]]]: sections.append((sec_name, items)) return sections - # ------------------------------------------------------------------ # - # Settings screen # - # ------------------------------------------------------------------ # - @property def settings(self) -> SettingsManager: return self._settings_manager @@ -687,10 +655,6 @@ def _open_settings(self) -> None: overlay.styles.layer = "overlay" self.mount(overlay) - # ------------------------------------------------------------------ # - # Keyboard actions # - # ------------------------------------------------------------------ # - def _terminal_input_focused(self) -> bool: try: return self.query_one("#terminal-input").has_focus @@ -786,10 +750,6 @@ async def action_quit(self) -> None: pass self.exit() - # ------------------------------------------------------------------ # - # Public API # - # ------------------------------------------------------------------ # - def add_tab(self, tab: TabDef) -> TabDef: self._rigi_tabs.append(tab) return tab @@ -839,7 +799,25 @@ def show_action_menu( x: int | None = None, y: int | None = None, ) -> None: - self.push_screen(ActionMenuScreen(items, title=title, anchor_x=x, anchor_y=y)) + try: + self.query_one("#rigi-action-panel", ActionMenuPanel).remove() + except Exception: + pass + panel = ActionMenuPanel(items, title=title, id="rigi-action-panel") + panel.styles.layer = "overlay" + panel_w = max((len(item.label) + 6 for item in items), default=22) + panel_h = min(2 + len(items), 20) + app_w, app_h = self.size.width, self.size.height + if x is not None and y is not None: + px = min(x, max(0, app_w - panel_w - 1)) + py = min(y, max(0, app_h - panel_h - 1)) + else: + px = max(0, (app_w - panel_w) // 2) + py = max(0, (app_h - panel_h) // 2) + panel.styles.offset = (px, py) + panel.styles.width = panel_w + panel.styles.height = panel_h + self.mount(panel) def on_click(self, event: Any) -> None: if hasattr(event, "button") and event.button == 3: @@ -865,10 +843,17 @@ def on_click(self, event: Any) -> None: overlay.remove() except Exception: pass + try: + panel = self.query_one("#rigi-action-panel", ActionMenuPanel) + if panel not in event.chain: + panel.remove() + except Exception: + pass def on_key(self, event: Key) -> None: if event.key == "escape": for selector, cls in ( + ("#rigi-action-panel", ActionMenuPanel), ("#rigi-main-menu", MenuPanel), ("#rigi-settings-overlay", SettingsOverlay), ("#rigi-help-overlay", HelpOverlay), @@ -905,10 +890,6 @@ async def _wrapped() -> Any: return asyncio.create_task(_wrapped(), name=name) - # ------------------------------------------------------------------ # - # Commands & hooks # - # ------------------------------------------------------------------ # - def command( self, name: str, @@ -944,10 +925,6 @@ def on_startup( def cmd_registry(self) -> CommandRegistry: return self._cmd_registry - # ------------------------------------------------------------------ # - # CLI entry point # - # ------------------------------------------------------------------ # - @classmethod def run_cli(cls, app_instance: App) -> None: parser = build_cli_parser( @@ -975,9 +952,7 @@ def run_cli(cls, app_instance: App) -> None: parser.print_help() sys.exit(1) - tab = next( - (t for t in app_instance._rigi_tabs if t.name.lower() == cmd_name.lower()), None - ) + tab = next((t for t in app_instance._rigi_tabs if t.name.lower() == cmd_name.lower()), None) if tab and cmd.handler is None: async def _nav() -> None: diff --git a/src/rigi/screens/action_menu.py b/src/rigi/screens/action_menu.py index cc43b9a..7b1f356 100644 --- a/src/rigi/screens/action_menu.py +++ b/src/rigi/screens/action_menu.py @@ -1,11 +1,11 @@ -"""ActionMenuScreen — modal popup action menu.""" +"""ActionMenuScreen — modal popup action menu (kept for API compatibility).""" from __future__ import annotations from textual import on from textual.app import ComposeResult from textual.binding import Binding -from textual.events import Click +from textual.events import Click, Key from textual.screen import ModalScreen from rigi.widgets.action_menu import ( @@ -16,7 +16,7 @@ class ActionMenuScreen(ModalScreen[None]): - BINDINGS = [Binding("escape", "dismiss", show=False)] + BINDINGS = [Binding("escape", "close_menu", show=False)] def __init__( self, @@ -66,11 +66,11 @@ def on_click(self, event: Click) -> None: if not panel.region.contains(event.x, event.y): self.dismiss(None) - def action_dismiss(self) -> None: + def action_close_menu(self) -> None: self.dismiss(None) - def on_key(self, event) -> None: - if hasattr(event, "key") and event.key.isdigit(): + def on_key(self, event: Key) -> None: + if event.key.isdigit(): idx = int(event.key) - 1 if 0 <= idx < len(self._items): item = self._items[idx] diff --git a/src/rigi/widgets/__init__.py b/src/rigi/widgets/__init__.py index 029f073..074e7c8 100644 --- a/src/rigi/widgets/__init__.py +++ b/src/rigi/widgets/__init__.py @@ -50,16 +50,16 @@ MenuItemData, MenuPanel, ) +from rigi.widgets.help_overlay import HelpOverlay from rigi.widgets.help_panel import ShortcutsBar, extract_help_annotation from rigi.widgets.image import Image, TerminalImageProtocol, detect_image_protocol from rigi.widgets.mouse import Clickable, Draggable, MouseMixin -from rigi.widgets.help_overlay import HelpOverlay from rigi.widgets.settings_overlay import SettingsOverlay from rigi.widgets.settings_screen import SettingDef, SettingsScreen from rigi.widgets.sidebar import Sidebar from rigi.widgets.statusbar import StatusBar, StatusItem -from rigi.widgets.terminal_bar import TerminalBar from rigi.widgets.tab_group import TabGroup +from rigi.widgets.terminal_bar import TerminalBar __all__ = [ # Textual primitives diff --git a/src/rigi/widgets/action_menu.py b/src/rigi/widgets/action_menu.py index 417f14b..b350431 100644 --- a/src/rigi/widgets/action_menu.py +++ b/src/rigi/widgets/action_menu.py @@ -6,7 +6,7 @@ from typing import Any, Callable from textual.app import ComposeResult -from textual.events import Click +from textual.events import Click, Key from textual.message import Message from textual.widget import Widget from textual.widgets import Label @@ -50,6 +50,8 @@ def on_click(self, event: Click) -> None: class ActionMenuPanel(Widget): + can_focus = True + def __init__( self, items: list[ActionMenuItemData], @@ -65,8 +67,33 @@ def compose(self) -> ComposeResult: for i, item in enumerate(self._items, start=1): yield ActionMenuItem(item, number=i) + def on_mount(self) -> None: + self.focus() + def replace_items(self, items: list[ActionMenuItemData]) -> None: self._items = items self.remove_children() for i, item in enumerate(items, start=1): self.mount(ActionMenuItem(item, number=i)) + + def on__action_item_clicked(self, event: _ActionItemClicked) -> None: + event.stop() + item = event.item + if item.callback is not None: + callback = item.callback + self.remove() + self.app.call_after_refresh(callback) + + def on_key(self, event: Key) -> None: + if event.key == "escape": + self.remove() + event.stop() + elif event.key.isdigit(): + idx = int(event.key) - 1 + if 0 <= idx < len(self._items): + item = self._items[idx] + if not item.disabled and item.callback is not None: + callback = item.callback + self.remove() + self.app.call_after_refresh(callback) + event.stop() diff --git a/src/rigi/widgets/content_area.py b/src/rigi/widgets/content_area.py index 479e090..33b27a8 100644 --- a/src/rigi/widgets/content_area.py +++ b/src/rigi/widgets/content_area.py @@ -46,7 +46,8 @@ def on_mouse_move(self, event: MouseMove) -> None: except Exception as e: _ui_log.error(f"Error in content resize mouse_move: {e}", exc_info=True) - def on_mouse_up(self, _event: MouseUp) -> None: + def on_mouse_up(self, event: MouseUp) -> None: + event.stop() try: self.release_mouse() self._drag_x = None @@ -67,6 +68,7 @@ def __init__(self) -> None: self._current: Widget | None = None def compose(self) -> ComposeResult: + yield _ContentResizeHandle() with Widget(id="content-main"): yield _EmptyState(id="rigi-empty-state") diff --git a/src/rigi/widgets/statusbar.py b/src/rigi/widgets/statusbar.py index 9fc1696..4231db0 100644 --- a/src/rigi/widgets/statusbar.py +++ b/src/rigi/widgets/statusbar.py @@ -88,7 +88,7 @@ def add_item(self, item: StatusItem) -> None: self._items.append(item) if self.is_mounted: spacer = self.query_one(_StatusSpacer) - self.mount(StatusItem(item), before=spacer) + self.mount(StatusBarItem(item), before=spacer) def set_home_active(self, active: bool) -> None: try: @@ -99,6 +99,6 @@ def set_home_active(self, active: bool) -> None: def compose(self) -> ComposeResult: yield _HomeButton() for item in self._items: - yield StatusItem(item) + yield StatusBarItem(item) yield _StatusSpacer() yield _HamburgerButton() diff --git a/src/rigi/widgets/tab_group.py b/src/rigi/widgets/tab_group.py index def6751..dd5df8c 100644 --- a/src/rigi/widgets/tab_group.py +++ b/src/rigi/widgets/tab_group.py @@ -51,9 +51,7 @@ def compose(self) -> ComposeResult: item = _TabItem(name, i) item.set_active(i == self._active_idx) yield item - with ContentSwitcher( - initial="tab-content-0", id="tabgroup-switcher" - ): + with ContentSwitcher(initial="tab-content-0", id="tabgroup-switcher"): for i, _ in enumerate(self._tab_defs): yield Widget(id=f"tab-content-{i}") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1a7c80a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) diff --git a/tests/test_basic.py b/tests/test_basic.py index 862ca0e..93060f1 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,13 +1,13 @@ from __future__ import annotations from rigi import ( + App, Command, CommandArg, - App, - Theme, StatusItem, SubtabDef, TabDef, + Theme, ThemeDark, ThemeLight, ThemeMonokai, diff --git a/tests/test_resize.py b/tests/test_resize.py index 2ac9675..ca7c47d 100644 --- a/tests/test_resize.py +++ b/tests/test_resize.py @@ -6,7 +6,6 @@ from rigi.commands.registry import CommandRegistry from rigi.widgets.bottom_panel import BottomPanel, _ResizeHandle -from rigi.widgets.content_area import ContentArea from rigi.widgets.sidebar import _VerticalResizeHandle From d26597657d8cabce9d0fee4b2d60d4e811889a99 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Thu, 14 May 2026 12:09:50 +0000 Subject: [PATCH 21/23] fix: update action panel in place to avoid DuplicateIds on rapid re-trigger --- src/rigi/core/app.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/rigi/core/app.py b/src/rigi/core/app.py index 73d77eb..6df747d 100644 --- a/src/rigi/core/app.py +++ b/src/rigi/core/app.py @@ -799,12 +799,6 @@ def show_action_menu( x: int | None = None, y: int | None = None, ) -> None: - try: - self.query_one("#rigi-action-panel", ActionMenuPanel).remove() - except Exception: - pass - panel = ActionMenuPanel(items, title=title, id="rigi-action-panel") - panel.styles.layer = "overlay" panel_w = max((len(item.label) + 6 for item in items), default=22) panel_h = min(2 + len(items), 20) app_w, app_h = self.size.width, self.size.height @@ -814,6 +808,18 @@ def show_action_menu( else: px = max(0, (app_w - panel_w) // 2) py = max(0, (app_h - panel_h) // 2) + try: + existing = self.query_one("#rigi-action-panel", ActionMenuPanel) + existing.replace_items(items) + existing.styles.offset = (px, py) + existing.styles.width = panel_w + existing.styles.height = panel_h + existing.focus() + return + except Exception: + pass + panel = ActionMenuPanel(items, title=title, id="rigi-action-panel") + panel.styles.layer = "overlay" panel.styles.offset = (px, py) panel.styles.width = panel_w panel.styles.height = panel_h From 64fd1cd9e483653a21c1041c145c1e5517df6cd9 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Thu, 14 May 2026 12:09:57 +0000 Subject: [PATCH 22/23] chore: bump 1.3.1 --- pyproject.toml | 2 +- src/rigi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 76d0778..190c2e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "rigi" -version = "1.3.0" +version = "1.3.1" description = "Rigi isn't a graphics interface, it's terminal. A high-level TUI framework built on Textual." readme = "README.md" requires-python = ">=3.10" diff --git a/src/rigi/__init__.py b/src/rigi/__init__.py index 87e97ea..99145aa 100644 --- a/src/rigi/__init__.py +++ b/src/rigi/__init__.py @@ -79,7 +79,7 @@ from rigi.widgets.tab_group import TabGroup from rigi.widgets.terminal_bar import TerminalBar -__version__ = "1.3.0" +__version__ = "1.3.1" __all__ = [ # Textual primitives "Widget", From 83bf7d947854566bff531df9fc6fa0cec448b47c Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Thu, 14 May 2026 12:13:10 +0000 Subject: [PATCH 23/23] feat: add PR summary Co-authored-by: xelthorV <251467136+xelthorV@users.noreply.github.com> --- .github/workflows/pr-summary.yml | 346 +++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 .github/workflows/pr-summary.yml diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml new file mode 100644 index 0000000..6644a8d --- /dev/null +++ b/.github/workflows/pr-summary.yml @@ -0,0 +1,346 @@ +name: PR Summary + +on: + pull_request: + types: + - opened + - synchronize + - reopened + +permissions: + pull-requests: write + contents: read + +jobs: + pr-summary: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate PR summary + id: summary + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + + const commits = await github.paginate( + github.rest.pulls.listCommits, + { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100 + } + ); + + const files = await github.paginate( + github.rest.pulls.listFiles, + { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100 + } + ); + + const groups = { + feat: [], + fix: [], + refactor: [], + perf: [], + docs: [], + test: [], + chore: [], + ci: [], + style: [], + build: [], + revert: [], + other: [] + }; + + const titles = { + feat: "✨ Features", + fix: "🐛 Fixes", + refactor: "♻️ Refactoring", + perf: "⚡ Performance", + docs: "📝 Documentation", + test: "🧪 Tests", + chore: "🔧 Chores", + ci: "🚀 CI", + style: "🎨 Style", + build: "📦 Build", + revert: "⏪ Reverts", + other: "📌 Other" + }; + + let additions = 0; + let deletions = 0; + + const contributors = new Map(); + const scopeStats = new Map(); + const dirStats = new Map(); + + for (const file of files) { + additions += file.additions; + deletions += file.deletions; + + const dir = + file.filename.includes("/") + ? file.filename.split("/")[0] + : "root"; + + dirStats.set( + dir, + (dirStats.get(dir) || 0) + 1 + ); + } + + for (const commit of commits) { + const sha = commit.sha.substring(0, 7); + const url = commit.html_url; + + const author = + commit.author?.login || + commit.commit.author.name; + + contributors.set( + author, + (contributors.get(author) || 0) + 1 + ); + + const message = + commit.commit.message.split("\n")[0]; + + const match = message.match( + /^(\w+)(\((.*?)\))?:\s(.+)$/ + ); + + let type = "other"; + let scope = ""; + let description = message; + + if (match) { + type = match[1]; + scope = match[3] || ""; + description = match[4]; + } + + if (!groups[type]) { + type = "other"; + } + + if (scope) { + scopeStats.set( + scope, + (scopeStats.get(scope) || 0) + 1 + ); + } + + groups[type].push({ + sha, + url, + scope, + description, + author + }); + } + + const topFiles = [...files] + .sort((a, b) => b.changes - a.changes) + .slice(0, 10); + + const topScopes = [...scopeStats.entries()] + .sort((a, b) => b[1] - a[1]); + + const topDirs = [...dirStats.entries()] + .sort((a, b) => b[1] - a[1]); + + function progress(value, total) { + const width = 20; + const filled = Math.round((value / total) * width); + + return ( + "█".repeat(filled) + + "░".repeat(width - filled) + ); + } + + const totalTypedCommits = Object.values(groups) + .reduce((acc, arr) => acc + arr.length, 0); + + let body = ""; + + body += `\n`; + + body += `# 📋 PR Summary\n\n`; + + body += `### ${pr.title}\n\n`; + + body += `> ${pr.user.login} opened a pull request from \`${pr.head.ref}\` → \`${pr.base.ref}\`\n\n`; + + body += `---\n\n`; + + body += `## 📊 Overview\n\n`; + + body += `| Metric | Value |\n`; + body += `|---|---|\n`; + body += `| Commits | \`${commits.length}\` |\n`; + body += `| Changed Files | \`${files.length}\` |\n`; + body += `| Additions | \`+${additions}\` |\n`; + body += `| Deletions | \`-${deletions}\` |\n`; + body += `| Contributors | \`${contributors.size}\` |\n\n`; + + body += `---\n\n`; + + body += `## 📈 Change Distribution\n\n`; + + for (const [type, items] of Object.entries(groups)) { + if (!items.length) continue; + + const bar = progress( + items.length, + totalTypedCommits + ); + + body += `- ${titles[type]} \`${bar}\` ${items.length}\n`; + } + + body += `\n---\n\n`; + + for (const [type, items] of Object.entries(groups)) { + if (!items.length) continue; + + body += `## ${titles[type]}\n\n`; + + body += `
\n`; + body += `${items.length} commits\n\n`; + + for (const item of items) { + const scope = item.scope + ? `\`${item.scope}\` ` + : ""; + + body += `- [\`${item.sha}\`](${item.url}) ${scope}${item.description} — @${item.author}\n`; + } + + body += `\n
\n\n`; + } + + body += `---\n\n`; + + body += `## 🎯 Main Impact Areas\n\n`; + + for (const [scope, count] of topScopes.slice(0, 8)) { + body += `- \`${scope}\` — ${count} commits\n`; + } + + body += `\n---\n\n`; + + body += `## 📂 Most Changed Files\n\n`; + + body += `\`\`\`diff\n`; + + for (const file of topFiles) { + body += `+ ${String(file.additions).padEnd(4)} `; + body += `- ${String(file.deletions).padEnd(4)} `; + body += `${file.filename}\n`; + } + + body += `\`\`\`\n\n`; + + body += `---\n\n`; + + body += `## 🧩 Changed Directories\n\n`; + + for (const [dir, count] of topDirs.slice(0, 10)) { + body += `- \`${dir}/\` — ${count} files\n`; + } + + body += `\n---\n\n`; + + body += `## ⚠️ High Impact Files\n\n`; + + const risky = files + .filter(f => f.changes > 200) + .sort((a, b) => b.changes - a.changes); + + if (risky.length) { + for (const file of risky) { + body += `- \`${file.filename}\` `; + body += `(+${file.additions} / -${file.deletions})\n`; + } + } else { + body += `No high impact files detected.\n`; + } + + body += `\n---\n\n`; + + body += `## 👥 Contributors\n\n`; + + for (const [user, count] of contributors.entries()) { + body += `- @${user} — ${count} commits\n`; + } + + body += `\n---\n\n`; + + body += `## 🔎 Raw Commit Messages\n\n`; + + body += `
\n`; + body += `Show raw commits\n\n`; + + body += `\`\`\`text\n`; + + for (const commit of commits) { + body += `${commit.commit.message}\n\n`; + } + + body += `\`\`\`\n`; + body += `
\n\n`; + + body += `---\n\n`; + + body += `Generated automatically from conventional commits and PR metadata.`; + + core.setOutput("body", body); + + - name: Create or update comment + uses: actions/github-script@v7 + env: + BODY: ${{ steps.summary.outputs.body }} + with: + script: | + const marker = ''; + + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number + } + ); + + const existing = comments.find(comment => + comment.body.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: process.env.BODY + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: process.env.BODY + }); + } \ No newline at end of file