diff --git a/README.md b/README.md index 6ed2f82..9c818f7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@
-
+
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