diff --git a/README.md b/README.md index 6ed2f82..9c818f7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

License Python - Textual + Textual Platform black ruff diff --git a/src/rigi/__init__.py b/src/rigi/__init__.py index e6f0d85..971708b 100644 --- a/src/rigi/__init__.py +++ b/src/rigi/__init__.py @@ -62,12 +62,18 @@ 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.notifications import ( + RigiNotificationRack as RigiNotificationRack, +) +from rigi.widgets.notifications import ( + RigiNotificationWidget as RigiNotificationWidget, +) 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 -__version__ = "1.0.0" +__version__ = "1.1.0" __all__ = [ # Textual primitives "Widget", @@ -126,6 +132,8 @@ "extract_help_annotation", "RigiGauge", "RigiSparkline", + "RigiNotificationRack", + "RigiNotificationWidget", # Platform utilities "platform", "console", diff --git a/src/rigi/core/app.py b/src/rigi/core/app.py index 00af8bc..60c3406 100644 --- a/src/rigi/core/app.py +++ b/src/rigi/core/app.py @@ -11,6 +11,7 @@ from textual import on from textual.app import App, ComposeResult from textual.binding import Binding +from textual.notifications import SeverityLevel from textual.widget import Widget from rigi.commands.command import Command @@ -32,6 +33,7 @@ from rigi.widgets.content_area import RigiContentArea 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.widgets.statusbar import ( RigiStatusBar, @@ -119,6 +121,7 @@ def __init__( self._rigi_menu_items: list[tuple[str, str, Callable[[], None]]] = [] self._rigi_settings: list[RigiSettingDef] = [] + self._disable_notifications = True self._register_builtin_commands() def _register_builtin_commands(self) -> None: @@ -260,6 +263,7 @@ def compose(self) -> ComposeResult: registry=self._cmd_registry, history_file=history_file, ) + yield RigiNotificationRack() def on_mount(self) -> None: self.title = f"{self._prog_name} v{self._version}" @@ -296,9 +300,27 @@ def _set_terminal_title(self) -> None: except Exception: pass + def notify( + self, + message: str, + *, + title: str = "", + severity: SeverityLevel = "information", + timeout: float | None = 5.0, + markup: bool = True, + ) -> None: + if not markup: + message = message.replace("[", "\\[") + effective_timeout = timeout if timeout is not None else 5.0 + try: + self.query_one(RigiNotificationRack).add_notification( + title, message, severity, effective_timeout + ) + except Exception: + pass + @property def terminal(self) -> str: - """Name of the running terminal (kitty, iterm2, wezterm, etc.).""" return _console.detect_terminal() @property @@ -455,8 +477,8 @@ async def _run_shell(self, cmd: str) -> None: ) _terminal_log.error(f"Shell command timed out: {cmd}") return - out = (stdout.decode(errors="replace") + stderr.decode(errors="replace")).strip() - display = out[:1200] if out else "(no output)" + raw = (stdout.decode(errors="replace") + stderr.decode(errors="replace")).strip() + 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) @@ -465,10 +487,11 @@ async def _run_shell(self, cmd: str) -> None: except Exception as exc: msg = str(exc) _terminal_log.error(f"Shell command failed: {cmd}", exc_info=True) + safe_msg = msg.replace("[", "\\[") try: - self.query_one(RigiBottomPanel).write_output(f"[red]{msg}[/red]") + self.query_one(RigiBottomPanel).write_output(f"[red]{safe_msg}[/red]") except Exception: - self.notify(msg, severity="error", title=f"$ {cmd[:30]}") + self.notify(safe_msg, severity="error", title=f"$ {cmd[:30]}") @on(_HamburgerButton.Clicked) def on_hamburger_clicked(self, event: _HamburgerButton.Clicked) -> None: @@ -723,8 +746,29 @@ def add_setting( value_fn: Callable[[], str] | None = None, action_fn: Callable[[], None] | None = None, action_label: str = "Change", + write_fn: Callable[[str], None] | None = None, + ) -> RigiSettingDef: + s = RigiSettingDef( + category, label, description, value_fn, action_fn, action_label, write_fn + ) + self._rigi_settings.append(s) + return s + + def add_checkbox_setting( + self, + category: str, + label: str, + description: str = "", + checked_fn: Callable[[], bool] | None = None, + toggle_fn: Callable[[], None] | None = None, ) -> RigiSettingDef: - s = RigiSettingDef(category, label, description, value_fn, action_fn, action_label) + s = RigiSettingDef( + category=category, + label=label, + description=description, + checkbox_fn=checked_fn, + toggle_fn=toggle_fn, + ) self._rigi_settings.append(s) return s diff --git a/src/rigi/css/default.tcss b/src/rigi/css/default.tcss index 9627c49..f2e9511 100644 --- a/src/rigi/css/default.tcss +++ b/src/rigi/css/default.tcss @@ -322,6 +322,14 @@ _SettingItem { _SettingItem ._s-label { color: #c9d1d9; text-style: bold; height: 1; } _SettingItem ._s-desc { color: #6e7681; height: 1; } +_SettingSwitch { + height: 1; + width: auto; + margin-top: 1; + background: transparent; +} +_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; } @@ -336,11 +344,30 @@ RigiSettingsScreen { align: center middle; background: transparent; } } #s-titlebar { height: 2; - padding: 0 2; + padding: 0 1; border-bottom: solid #21262d; background: transparent; + layout: horizontal; content-align: left middle; } +#s-title-lbl { + width: 1fr; + height: 1; + color: #c9d1d9; + content-align: left middle; + padding: 0 1; +} +#s-close-btn { + width: 3; + height: 1; + min-width: 3; + max-width: 3; + border: none; + background: transparent; + color: #6e7681; + padding: 0; +} +#s-close-btn:hover { color: #f85149; background: transparent; } #s-body { layout: horizontal; height: 1fr; background: transparent; } #s-categories { width: 22; @@ -439,3 +466,59 @@ RigiBottomPanel #terminal-input { color: #e6edf3; } RigiBottomPanel #terminal-input:focus { border: none; } + +RigiNotificationRack { + layer: overlay; + width: 1fr; + height: auto; + max-height: 80%; + dock: bottom; + align: right bottom; + margin-bottom: 3; + background: transparent; + layout: vertical; +} + +RigiNotificationWidget { + width: 44; + max-width: 50%; + height: auto; + min-height: 3; + margin: 0 1 1 0; + padding: 0 1 1 1; + background: #161b22; + border-left: thick #58a6ff; +} +RigiNotificationWidget.notif--warning { border-left: thick #e3b341; } +RigiNotificationWidget.notif--error { border-left: thick #f85149; } + +.notif-header { + layout: horizontal; + height: 1; + width: 100%; + background: transparent; +} +.notif-title { + width: 1fr; + height: 1; + content-align: left middle; + background: transparent; +} +.notif-message { + width: 100%; + height: auto; + color: #c9d1d9; + background: transparent; + margin-top: 1; +} +.notif-close { + width: 3; + min-width: 3; + max-width: 3; + height: 1; + border: none; + background: transparent; + color: #6e7681; + padding: 0; +} +.notif-close:hover { color: #f85149; background: transparent; } diff --git a/src/rigi/screens/settings.py b/src/rigi/screens/settings.py index 8dc7ab5..7c24fe2 100644 --- a/src/rigi/screens/settings.py +++ b/src/rigi/screens/settings.py @@ -1,5 +1,3 @@ -"""RigiSettingsScreen — modal settings panel.""" - from __future__ import annotations import logging @@ -11,7 +9,7 @@ from textual.message import Message from textual.screen import ModalScreen from textual.widget import Widget -from textual.widgets import Input, Label +from textual.widgets import Button, Input, Label, Switch _ui_log = logging.getLogger("rigi.ui") @@ -26,6 +24,8 @@ def __init__( action_fn: Callable[[], None] | None = None, action_label: str = "Change", write_fn: Callable[[str], None] | None = None, + checkbox_fn: Callable[[], bool] | None = None, + toggle_fn: Callable[[], None] | None = None, ) -> None: self.category = category self.label = label @@ -34,6 +34,8 @@ def __init__( self.action_fn = action_fn self.action_label = action_label self.write_fn = write_fn + self.checkbox_fn = checkbox_fn + self.toggle_fn = toggle_fn self._current_value: str | None = None def get_value(self) -> str: @@ -55,6 +57,15 @@ def set_value(self, v: str) -> None: except Exception as e: _ui_log.error(f"Error setting value for {self.label}: {e}", exc_info=True) + def get_checked(self) -> bool: + if self.checkbox_fn is None: + return False + try: + return self.checkbox_fn() + except Exception as e: + _ui_log.error(f"Error getting checkbox value for {self.label}: {e}", exc_info=True) + return False + class _CategoryClicked(Message): def __init__(self, name: str) -> None: @@ -130,6 +141,24 @@ def on_submitted(self, event: Input.Submitted) -> None: self.app.set_focus(None) +class _SettingSwitch(Widget): + def __init__(self, setting: RigiSettingDef) -> 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) + + class _SettingItem(Widget): def __init__(self, setting: RigiSettingDef) -> None: super().__init__() @@ -139,11 +168,11 @@ 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.write_fn is not None or ( - self._setting.value_fn is not None and self._setting.action_fn is None - ): + if self._setting.checkbox_fn is not None: + yield _SettingSwitch(self._setting) + elif self._setting.write_fn is not None: yield _SettingInput(self._setting) - elif self._setting.value_fn or self._setting.action_fn: + elif self._setting.value_fn is not None or self._setting.action_fn is not None: yield _ValueRow(self._setting) @@ -171,7 +200,8 @@ def __init__(self, settings: list[RigiSettingDef]) -> None: def compose(self) -> ComposeResult: with Widget(id="s-outer"): with Widget(id="s-titlebar"): - yield Label("[dim]ESC to close[/dim]") + 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: @@ -185,6 +215,11 @@ 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.dismiss(None) + @on(_CategoryClicked) def on_category_clicked(self, event: _CategoryClicked) -> None: event.stop() diff --git a/src/rigi/widgets/bottom_panel.py b/src/rigi/widgets/bottom_panel.py index 9e25446..f220648 100644 --- a/src/rigi/widgets/bottom_panel.py +++ b/src/rigi/widgets/bottom_panel.py @@ -9,6 +9,7 @@ from textual.events import Key, MouseDown, MouseMove, MouseUp from textual.message import Message from textual.reactive import reactive +from textual.timer import Timer from textual.widget import Widget from textual.widgets import Button, ContentSwitcher, Input, Label, RichLog, Select, Tab, Tabs @@ -79,6 +80,8 @@ def __init__(self) -> None: self._logger_filter: str = "all" self._level_filter: str = "all" self._known_loggers: list[str] = [] + self._active: bool = False + self._flush_timer: Timer | None = None def compose(self) -> ComposeResult: yield RichLog(highlight=False, markup=True, id="logs-output", auto_scroll=True) @@ -107,9 +110,24 @@ def compose(self) -> ComposeResult: yield Button("Clear", id="btn-logs-clear", variant="default") def on_mount(self) -> None: - self.set_interval(0.5, self._flush) + pass + + def activate(self) -> None: + self._active = True + self._reset_seen() + self._flush() + if self._flush_timer is None: + self._flush_timer = self.set_interval(0.5, self._flush) + + def deactivate(self) -> None: + self._active = False + if self._flush_timer is not None: + self._flush_timer.stop() + self._flush_timer = None def _flush(self) -> None: + if not self._active: + return self._refresh_logger_select() try: view = self.query_one("#logs-output", RichLog) @@ -128,9 +146,10 @@ def _flush(self) -> None: ms = rec.timestamp.microsecond // 1000 ts = rec.timestamp.strftime("%H:%M:%S") + f".{ms:03d}" safe_message = rec.message.replace("[", "\\[") + safe_logger = rec.logger_name.replace("[", "\\[") view.write( f"[dim]{ts}[/dim] " - f"[bold cyan]{rec.logger_name:<20}[/bold cyan] " + f"[bold cyan]{safe_logger:<20}[/bold cyan] " f"[{color}]{rec.level:<8}[/{color}] " f"{safe_message}" ) @@ -213,7 +232,7 @@ def compose(self) -> ComposeResult: yield Tabs(Tab("Terminal", id="tab-terminal"), Tab("Logs", id="tab-logs")) with ContentSwitcher(initial="bp-terminal", id="bp-switcher"): with Widget(id="bp-terminal"): - yield RichLog(highlight=True, markup=True, id="term-history") + yield RichLog(highlight=False, markup=True, id="term-history") with Widget(id="input-row"): yield Label(self._prompt_label(focused=False), id="terminal-prompt") yield _TerminalInput(placeholder="", id="terminal-input") @@ -231,6 +250,14 @@ def watch_active_tab(self, value: str) -> None: self.query_one("#bp-switcher", ContentSwitcher).current = f"bp-{value}" except Exception: pass + try: + logs_view = self.query_one(_LogsView) + if value == "logs": + logs_view.activate() + else: + logs_view.deactivate() + except Exception: + pass def on_click(self) -> None: if self.active_tab == "terminal": @@ -321,7 +348,8 @@ def on_input_submitted(self, event: Input.Submitted) -> None: self.query_one("#terminal-input", _TerminalInput).value = "" except Exception: pass - self.write_output(f"[bold green]{self._prompt_text}[/bold green] [dim]$[/dim] {text}") + 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)) def action_complete(self) -> None: diff --git a/src/rigi/widgets/notifications.py b/src/rigi/widgets/notifications.py new file mode 100644 index 0000000..a697d85 --- /dev/null +++ b/src/rigi/widgets/notifications.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from uuid import uuid4 + +from textual import on +from textual.app import ComposeResult +from textual.message import Message +from textual.notifications import SeverityLevel +from textual.widget import Widget +from textual.widgets import Button, Label + +_SEVERITY_STYLE: dict[str, str] = { + "information": "bold cyan", + "warning": "bold yellow", + "error": "bold red", + "success": "bold green", +} + + +class _DismissNotification(Message): + def __init__(self, notification_id: str) -> None: + super().__init__() + self.notification_id = notification_id + + +class RigiNotificationWidget(Widget): + def __init__( + self, + notification_id: str, + title: str, + message: str, + severity: SeverityLevel = "information", + timeout: float = 5.0, + ) -> None: + super().__init__() + self._notification_id = notification_id + self._title = title + self._message = message + self._severity = severity + self._timeout = timeout + self.add_class(f"notif--{severity}") + + def compose(self) -> ComposeResult: + style = _SEVERITY_STYLE.get(self._severity, "bold white") + with Widget(classes="notif-header"): + title_text = f"[{style}]{self._title}[/{style}]" if self._title else " " + yield Label(title_text, classes="notif-title", markup=True) + yield Button("×", classes="notif-close") + if self._message: + yield Label(self._message, classes="notif-message", markup=True) + + def on_mount(self) -> None: + if self._timeout > 0: + self.set_timer(self._timeout, self._expire) + + def _expire(self) -> None: + self.post_message(_DismissNotification(self._notification_id)) + + @on(Button.Pressed, ".notif-close") + def on_close_pressed(self, event: Button.Pressed) -> None: + event.stop() + self._expire() + + +class RigiNotificationRack(Widget): + def compose(self) -> ComposeResult: + yield from [] + + def add_notification( + self, + title: str, + message: str, + severity: SeverityLevel = "information", + timeout: float = 5.0, + ) -> str: + notification_id = str(uuid4()) + notif = RigiNotificationWidget(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): + if widget._notification_id == event.notification_id: + widget.remove() + return