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
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..8487c40 100644
--- a/examples/05_system_monitor.py
+++ b/examples/05_system_monitor.py
@@ -7,13 +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(
- 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()
@@ -81,9 +79,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 +89,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 +100,7 @@ def make_system():
title=" Resources",
),
),
- RigiCard(procs_table, title=" Top Processes (by CPU)"),
+ Card(procs_table, title=" Top Processes (by CPU)"),
)
@@ -126,7 +124,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 +138,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..8ae0963 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 Gauge, Label, Markdown, 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_tab_group.py b/examples/09_tab_group.py
new file mode 100644
index 0000000..a76a862
--- /dev/null
+++ b/examples/09_tab_group.py
@@ -0,0 +1,56 @@
+"""TabGroup example โ horizontal in-page tabs with optional wrapping."""
+
+from __future__ import annotations
+
+from rigi import App, TabDef, TabGroup
+from rigi.layout.pane import Card, Pane
+from rigi.widgets import Label
+
+app = App(
+ name="tab-group",
+ version="1.0.0",
+ description="Demo of TabGroup",
+ home_tab="Demo",
+)
+
+
+def make_overview():
+ return Pane(
+ Label("[bold]TabGroup[/bold] โ switch between panels horizontally."),
+ Label(""),
+ TabGroup(
+ tabs=[
+ (
+ "Overview",
+ lambda: Card(
+ Label("This is the overview panel."),
+ Label("Tab groups are great for settings or multi-step forms."),
+ title=" Overview",
+ ),
+ ),
+ (
+ "Settings",
+ lambda: Card(
+ Label("[dim]Option 1:[/dim] enabled"),
+ Label("[dim]Option 2:[/dim] disabled"),
+ title=" Settings",
+ ),
+ ),
+ (
+ "About",
+ lambda: Card(
+ 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__":
+ App.run_cli(app)
diff --git a/examples/10_action_menu.py b/examples/10_action_menu.py
new file mode 100644
index 0000000..ec02821
--- /dev/null
+++ b/examples/10_action_menu.py
@@ -0,0 +1,237 @@
+"""Action menu + editable table example."""
+
+from __future__ import annotations
+
+from typing import Any, cast
+
+from textual import on
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.events import MouseDown
+from textual.screen import ModalScreen
+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
+
+_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 ActionMenu and EditableTable",
+ home_tab="Demo",
+)
+
+
+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, strict=True):
+ 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 {
+ layout: vertical;
+ height: 1fr;
+ width: 100%;
+ }
+ EditableTable DataTable {
+ height: 1fr;
+ width: 100%;
+ }
+ """
+
+ 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] = []
+
+ def compose(self) -> ComposeResult:
+ yield DataTable()
+
+ def on_mount(self) -> None:
+ table = self.query_one(DataTable)
+ 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_row_menu(self) -> None:
+ table = self.query_one(DataTable)
+ 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_mouse_down(self, event: MouseDown) -> None:
+ if event.button == 3:
+ event.stop()
+ table = self.query_one(DataTable)
+ 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):
+ return
+ items = [
+ ActionMenuItemData("Edit", callback=lambda: self._edit_row(row_idx)),
+ ActionMenuItemData("Delete", color="red", callback=lambda: self._delete_row(row_idx)),
+ ]
+ 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:
+ 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)
+
+ cast(App, 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.remove_row(self._row_keys[row_idx])
+ self._data.pop(row_idx)
+ self._row_keys.pop(row_idx)
+
+
+def make_demo() -> Widget:
+ return Pane(
+ 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."),
+ Label("Click an item or press its number key to activate."),
+ title=" Info",
+ ),
+ )
+
+
+def make_table() -> Widget:
+ return Pane(
+ Label(
+ "[bold]Editable Table[/bold] โ arrows to navigate, "
+ "[cyan]E[/cyan] or [cyan]RMB[/cyan] for actions."
+ ),
+ 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")
+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("Rename", callback=lambda: app.notify("Renamed!", timeout=2)),
+ ActionMenuItemData("Cancel", disabled=True),
+ ]
+ app.show_action_menu(items, title="Actions")
+
+
+if __name__ == "__main__":
+ App.run_cli(app)
diff --git a/pyproject.toml b/pyproject.toml
index f46c62b..190c2e3 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.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 86f44e7..99145aa 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,
@@ -40,41 +40,46 @@
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 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.content_area import RigiContentArea
-from rigi.widgets.gauge import RigiGauge, RigiSparkline
+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.checkbox import Checkbox
+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.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.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_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.1"
__all__ = [
# Textual primitives
"Widget",
@@ -83,8 +88,8 @@
"ModalScreen",
"reactive",
# Core app
- "RigiApp",
- "RigiTheme",
+ "App",
+ "Theme",
"ThemeDark",
"ThemeLight",
"ThemeMonokai",
@@ -101,43 +106,48 @@
"SubtabDef",
"HelpEntry",
# Layout
- "RigiPane",
- "RigiHPane",
- "RigiVPane",
- "RigiScrollPane",
- "RigiCard",
- "RigiSplit",
+ "Pane",
+ "HPane",
+ "VPane",
+ "ScrollPane",
+ "Card",
+ "Split",
# Widgets
- "RigiStatusBar",
- "RigiStatusItem",
- "RigiSidebar",
- "RigiTerminalBar",
- "RigiBottomPanel",
- "RigiContentArea",
- "RigiBorderFrame",
- "RigiHamburgerScreen",
- "RigiMenuItem",
- "RigiMenuPanel",
- "RigiHamburgerPanel",
- "RigiMenuItemData",
- "RigiSettingsScreen",
- "RigiSettingDef",
+ "StatusBar",
+ "StatusItem",
+ "Sidebar",
+ "TerminalBar",
+ "BottomPanel",
+ "Checkbox",
+ "ContentArea",
+ "BorderFrame",
+ "HamburgerScreen",
+ "MenuItem",
+ "MenuPanel",
+ "HamburgerPanel",
+ "MenuItemData",
+ "SettingsScreen",
+ "SettingsOverlay",
+ "SettingDef",
"Setting",
"SettingsPage",
"SettingsManager",
- "RigiImage",
+ "Image",
"TerminalImageProtocol",
"detect_image_protocol",
- "RigiMouseMixin",
- "RigiClickable",
- "RigiDraggable",
- "RigiHelpScreen",
- "RigiShortcutsBar",
+ "MouseMixin",
+ "Clickable",
+ "Draggable",
+ "HelpScreen",
+ "HelpOverlay",
+ "ShortcutsBar",
"extract_help_annotation",
- "RigiGauge",
- "RigiSparkline",
- "RigiNotificationRack",
- "RigiNotificationWidget",
+ "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 254bd7a..af97e65 100644
--- a/src/rigi/core/_cmd_handlers.py
+++ b/src/rigi/core/_cmd_handlers.py
@@ -1,41 +1,53 @@
-"""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:
+async def cmd_terminal(app: App, **_: Any) -> None:
+ from rigi.widgets.bottom_panel import BottomPanel
+
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] "
- f"{'tmux' if nfo['tmux'] else 'screen' if nfo['screen'] else 'none'}",
- f"[bold]Size:[/bold] {nfo['columns']}ร{nfo['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" 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" Size: {nfo['columns']}ร{nfo['lines']}",
]
- app.notify("\n".join(lines), title="Terminal Info", timeout=8)
+ try:
+ 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:
+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(BottomPanel).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 +72,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,15 +81,15 @@ 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:
+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 3618598..6df747d 100644
--- a/src/rigi/core/app.py
+++ b/src/rigi/core/app.py
@@ -9,14 +9,16 @@
from typing import Any, Awaitable, Callable
from textual import on
-from textual.app import App, ComposeResult
+from textual.app import App as _TextualApp
+from textual.app import ComposeResult
from textual.binding import Binding
+from textual.events import Key
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,20 +27,21 @@
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.hamburger import RigiHamburgerScreen
-from rigi.screens.help import RigiHelpScreen
-from rigi.screens.settings import RigiSettingDef, RigiSettingsScreen
+from rigi.screens.settings import SettingDef
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.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.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.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 (
- RigiStatusBar,
+ StatusBar,
_HamburgerButton,
_HomeButton,
)
@@ -49,15 +52,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),
@@ -78,7 +81,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:
@@ -100,30 +103,31 @@ 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
- )
- self._theme: RigiTheme = resolved_theme if resolved_theme is not None else _DEFAULT_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
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] = []
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()
- # ------------------------------------------------------------------ #
- # Built-in commands #
- # ------------------------------------------------------------------ #
-
def _register_builtin_commands(self) -> None:
help_cmd = Command(
name="help", help="Show available commands or detailed help for a command"
@@ -165,23 +169,19 @@ def _register_builtin_commands(self) -> None:
register_dev_commands(self._cmd_registry)
- # ------------------------------------------------------------------ #
- # Composition & mount #
- # ------------------------------------------------------------------ #
-
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:
@@ -189,12 +189,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}"
@@ -205,7 +205,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:
@@ -230,10 +230,6 @@ def _set_terminal_title(self) -> None:
except Exception:
pass
- # ------------------------------------------------------------------ #
- # Notifications & terminal info #
- # ------------------------------------------------------------------ #
-
def notify(
self,
message: str,
@@ -247,7 +243,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:
@@ -264,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")
@@ -289,7 +281,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
@@ -303,11 +295,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;
+}}
+BorderFrame, _Body, Sidebar, ContentArea, #content-main,
+_MainNav, _SubNav, BottomPanel, TerminalBar,
+StatusBar, ShortcutsBar, _VerticalResizeHandle, _ContentResizeHandle {{
+ background: {rgba};
+}}
+"""
+ else:
+ css = f"""
+App, Screen {{
+ background: {self._theme.bg_color};
+}}
+BorderFrame, _Body, Sidebar, ContentArea, #content-main,
+_MainNav, _SubNav, BottomPanel, TerminalBar,
+StatusBar, ShortcutsBar, _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
@@ -319,12 +372,8 @@ def _cycle_theme(self) -> None:
idx = 0
self.set_theme(_themes[idx])
- # ------------------------------------------------------------------ #
- # 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()
@@ -337,9 +386,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
@@ -367,7 +416,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")
@@ -386,13 +435,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:
@@ -412,12 +461,8 @@ def _evict(widget: Widget) -> None:
if key[0] == idx:
_evict(self._rigi_widget_cache.pop(key))
- # ------------------------------------------------------------------ #
- # 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:
@@ -447,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}")
@@ -485,32 +528,42 @@ 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]}")
- # ------------------------------------------------------------------ #
- # Hamburger menu #
- # ------------------------------------------------------------------ #
-
@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("#rigi-main-menu", MenuPanel)
+ existing.remove()
+ return
+ except Exception:
+ pass
+ panel = MenuPanel(self._build_hamburger_sections(), id="rigi-main-menu")
+ panel.styles.layer = "overlay"
+ panel_w = 16
+ 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
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),
@@ -518,35 +571,31 @@ 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
- # ------------------------------------------------------------------ #
- # Settings screen #
- # ------------------------------------------------------------------ #
-
@property
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",
@@ -554,25 +603,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",
@@ -580,18 +629,31 @@ 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",
),
+ SettingDef(
+ 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,
+ ),
]
- self.push_screen(RigiSettingsScreen(builtin + self._settings_manager.all_defs()))
-
- # ------------------------------------------------------------------ #
- # Keyboard actions #
- # ------------------------------------------------------------------ #
+ 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)
def _terminal_input_focused(self) -> bool:
try:
@@ -601,25 +663,33 @@ 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))
+ 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()
@@ -675,15 +745,11 @@ 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()
- # ------------------------------------------------------------------ #
- # Public API #
- # ------------------------------------------------------------------ #
-
def add_tab(self, tab: TabDef) -> TabDef:
self._rigi_tabs.append(tab)
return tab
@@ -701,7 +767,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(
@@ -715,7 +781,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
@@ -726,6 +792,92 @@ 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[ActionMenuItemData],
+ title: str = "",
+ x: int | None = None,
+ y: int | None = None,
+ ) -> None:
+ 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)
+ 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
+ self.mount(panel)
+
+ 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)
+ return
+ try:
+ panel = self.query_one("#rigi-main-menu", MenuPanel)
+ if panel not in event.chain:
+ 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
+ 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),
+ ):
+ try:
+ widget = self.query_one(selector, cls)
+ widget.remove()
+ event.stop()
+ return
+ except Exception:
+ pass
+
+ 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)
@@ -744,10 +896,6 @@ async def _wrapped() -> Any:
return asyncio.create_task(_wrapped(), name=name)
- # ------------------------------------------------------------------ #
- # Commands & hooks #
- # ------------------------------------------------------------------ #
-
def command(
self,
name: str,
@@ -774,8 +922,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
@@ -783,12 +931,8 @@ def on_startup(
def cmd_registry(self) -> CommandRegistry:
return self._cmd_registry
- # ------------------------------------------------------------------ #
- # CLI entry point #
- # ------------------------------------------------------------------ #
-
@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,
@@ -814,9 +958,7 @@ def run_cli(cls, app_instance: RigiApp) -> 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/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 f2e9511..e3290d9 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 {
@@ -8,7 +10,7 @@ Screen {
background: transparent;
}
-RigiBorderFrame {
+BorderFrame {
width: 100%;
height: 100%;
border: round #30363d;
@@ -51,7 +53,7 @@ _HomeButton {
_HomeButton:hover { color: #c9d1d9; }
_HomeButton.--active { color: #58a6ff; }
-RigiStatusBar {
+StatusBar {
height: 2;
layout: horizontal;
padding: 0 1;
@@ -59,7 +61,7 @@ RigiStatusBar {
background: transparent;
}
-RigiStatusItem {
+StatusItem {
height: 1;
padding: 0 1;
width: auto;
@@ -67,21 +69,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 +105,7 @@ _MainNavItem.--active {
text-style: bold;
}
-_RigiSubNav {
+_SubNav {
width: 18;
height: 100%;
overflow-y: auto;
@@ -134,14 +136,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 +153,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 +181,7 @@ _TerminalResizeHandle {
}
_TerminalResizeHandle:hover { color: #58a6ff; }
-RigiTerminalBar Label {
+TerminalBar Label {
height: 1;
color: #3fb950;
padding: 0 0 0 1;
@@ -187,7 +189,7 @@ RigiTerminalBar Label {
content-align: left middle;
}
-RigiTerminalBar Input {
+TerminalBar Input {
height: 1;
width: 1fr;
border: none;
@@ -195,35 +197,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 +235,20 @@ 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; }
-
-RigiHelpScreen { align: center middle; }
-RigiHelpScreen > #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 @@ RigiHelpScreen > #help-container {
padding: 1 2;
overflow-y: auto;
}
-RigiHelpScreen #help-title {
+HelpOverlay #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 {
+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;
@@ -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,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; }
-RigiSettingsScreen { 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;
@@ -381,8 +391,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,16 +403,73 @@ _MenuSectionLabel {
background: transparent;
}
-RigiMenuPanel {
- width: 26;
+MenuPanel {
+ width: 16;
+ height: auto;
+ max-height: 14;
+ border: round #30363d;
+ border-title-color: #c9d1d9;
+ background: #0d1117;
+ padding: 0;
+ overflow-y: hidden;
+}
+
+ActionMenuPanel {
+ width: 30;
height: auto;
- max-height: 24;
+ max-height: 20;
border: round #30363d;
border-title-color: #c9d1d9;
background: #0d1117;
padding: 0;
overflow-y: auto;
}
+ActionMenuItem { height: 1; width: 100%; padding: 0 1; background: transparent; }
+ActionMenuItem:hover { background: #1c2128; }
+ActionMenuItem.--disabled { color: #3d444d; }
+ActionMenuScreen { background: transparent; }
+
+TabGroup {
+ height: 100%;
+ width: 100%;
+ layout: vertical;
+ background: transparent;
+}
+TabGroup #tabgroup-nav {
+ layout: horizontal;
+ width: 100%;
+ height: auto;
+ overflow-x: auto;
+ overflow-y: hidden;
+ background: transparent;
+ border-bottom: solid #21262d;
+}
+_TabItem {
+ height: 1;
+ box-sizing: content-box;
+ width: auto;
+ padding: 0 2;
+ color: #6e7681;
+ background: transparent;
+}
+_TabItem:hover { color: #c9d1d9; background: #161b22; }
+_TabItem.--active {
+ color: #58a6ff;
+ text-style: bold;
+ border-bottom: solid #58a6ff;
+}
+TabGroup #tabgroup-switcher {
+ width: 100%;
+ height: 1fr;
+ background: transparent;
+}
+TabGroup #tabgroup-switcher > Widget {
+ height: 100%;
+ width: 100%;
+ background: transparent;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
_ResizeHandle {
height: 1;
@@ -413,8 +480,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,23 +508,25 @@ _LogsView #logs-controls Button {
padding: 0 1;
}
-RigiBottomPanel { height: 12; layout: vertical; background: #0d1117; }
-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 { height: 12; layout: vertical; background: #0d1117; overflow: hidden; }
+#bp-logs { height: 1fr; layout: vertical; overflow: hidden; }
+#bp-terminal { height: 1fr; layout: vertical; overflow: hidden; }
+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;
@@ -465,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;
@@ -479,7 +548,7 @@ RigiNotificationRack {
layout: vertical;
}
-RigiNotificationWidget {
+NotificationWidget {
width: 44;
max-width: 50%;
height: auto;
@@ -489,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 f2e285a..d18be52 100644
--- a/src/rigi/screens/__init__.py
+++ b/src/rigi/screens/__init__.py
@@ -1,13 +1,15 @@
"""Rigi screen classes."""
-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",
+ "HelpScreen",
+ "SettingDef",
+ "SettingsScreen",
+ "HamburgerScreen",
+ "ActionMenuScreen",
]
diff --git a/src/rigi/screens/action_menu.py b/src/rigi/screens/action_menu.py
new file mode 100644
index 0000000..7b1f356
--- /dev/null
+++ b/src/rigi/screens/action_menu.py
@@ -0,0 +1,80 @@
+"""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, Key
+from textual.screen import ModalScreen
+
+from rigi.widgets.action_menu import (
+ ActionMenuItemData,
+ ActionMenuPanel,
+ _ActionItemClicked,
+)
+
+
+class ActionMenuScreen(ModalScreen[None]):
+ BINDINGS = [Binding("escape", "close_menu", show=False)]
+
+ def __init__(
+ self,
+ items: list[ActionMenuItemData],
+ 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 ActionMenuPanel(self._items, title=self._title, id="rigi-action-menu")
+
+ def on_mount(self) -> None:
+ 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
+ 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", ActionMenuPanel)
+ if not panel.region.contains(event.x, event.y):
+ self.dismiss(None)
+
+ def action_close_menu(self) -> None:
+ self.dismiss(None)
+
+ 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]
+ 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/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 7c24fe2..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
@@ -157,10 +157,16 @@ 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):
- def __init__(self, setting: RigiSettingDef) -> None:
+ def __init__(self, setting: SettingDef) -> None:
super().__init__()
self._setting = setting
@@ -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):
@@ -181,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 c67b47c..0653f69 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.
@@ -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} */
-RigiBorderFrame {{
+App, Screen {{
+ background: {self.bg_color};
+ color: {self.fg_color};
+}}
+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 {{
- border-right: solid {self.border_dim};
+_MainNav {{
+ background: {self.bg_color};
}}
_MainNavItem {{
color: {self.text_dim};
+ background: {self.bg_color};
}}
_MainNavItem:hover {{
color: {self.text};
@@ -55,11 +67,13 @@ 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};
+ background: {self.bg_color};
}}
_SubNavItem:hover {{
color: {self.text};
@@ -67,34 +81,48 @@ 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 Label {{
+TerminalBar {{
+ background: {self.bg_color};
+}}
+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 {{
+HelpOverlay {{
+ background: transparent;
+}}
+HelpOverlay > #help-container {{
border: round {self.border};
background: {self.popup_bg};
}}
-RigiHamburgerPanel {{
+ActionMenuScreen {{
+ background: transparent;
+}}
+SettingsOverlay {{
+ background: transparent;
+}}
+HamburgerPanel {{
border: round {self.border};
background: {self.popup_bg};
}}
-RigiPaletteScreen > #palette-container {{
+PaletteScreen > #palette-container {{
border: round {self.border};
background: {self.popup_bg};
}}
@@ -113,4 +141,25 @@ def to_css(self) -> str:
#help-dismiss {{
color: {self.text_dim};
}}
+_Body {{
+ background: {self.bg_color};
+}}
+Sidebar {{
+ background: {self.bg_color};
+}}
+ContentArea {{
+ background: {self.bg_color};
+}}
+BottomPanel {{
+ 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..eb8957f 100644
--- a/src/rigi/themes/dark.py
+++ b/src/rigi/themes/dark.py
@@ -1,3 +1,18 @@
-from rigi.themes.base import RigiTheme
+from rigi.themes.base import Theme
-DARK = RigiTheme(name="dark")
+DARK = Theme(
+ 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..86e80d9 100644
--- a/src/rigi/themes/light.py
+++ b/src/rigi/themes/light.py
@@ -1,7 +1,9 @@
-from rigi.themes.base import RigiTheme
+from rigi.themes.base import Theme
-LIGHT = RigiTheme(
+LIGHT = Theme(
name="light",
+ bg_color="#ffffff",
+ fg_color="#000000",
border="#d0d7de",
border_dim="#d8dee4",
text="#24292f",
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 6180730..074e7c8 100644
--- a/src/rigi/widgets/__init__.py
+++ b/src/rigi/widgets/__init__.py
@@ -35,23 +35,31 @@
Tree,
)
-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.action_menu import (
+ ActionMenuItem,
+ 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.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.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.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.tab_group import TabGroup
+from rigi.widgets.terminal_bar import TerminalBar
__all__ = [
# Textual primitives
@@ -61,29 +69,35 @@
"ModalScreen",
"reactive",
# Rigi widgets
- "RigiStatusBar",
- "RigiStatusItem",
- "RigiSidebar",
- "RigiTerminalBar",
- "RigiBottomPanel",
- "RigiBorderFrame",
- "RigiContentArea",
- "RigiMenuItem",
- "RigiMenuPanel",
- "RigiHamburgerPanel",
- "RigiMenuItemData",
- "RigiSettingsScreen",
- "RigiSettingDef",
- "RigiImage",
+ "StatusBar",
+ "StatusItem",
+ "Sidebar",
+ "TerminalBar",
+ "BottomPanel",
+ "BorderFrame",
+ "ContentArea",
+ "MenuItem",
+ "MenuPanel",
+ "HamburgerPanel",
+ "MenuItemData",
+ "ActionMenuItem",
+ "ActionMenuPanel",
+ "ActionMenuItemData",
+ "TabGroup",
+ "SettingsScreen",
+ "SettingsOverlay",
+ "SettingDef",
+ "HelpOverlay",
+ "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
new file mode 100644
index 0000000..b350431
--- /dev/null
+++ b/src/rigi/widgets/action_menu.py
@@ -0,0 +1,99 @@
+"""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, Key
+from textual.message import Message
+from textual.widget import Widget
+from textual.widgets import Label
+
+
+@dataclass
+class ActionMenuItemData:
+ label: str
+ callback: Callable[[], Any] | None = None
+ color: str | None = None
+ disabled: bool = False
+
+
+class _ActionItemClicked(Message):
+ def __init__(self, item: ActionMenuItemData) -> None:
+ super().__init__()
+ self.item = item
+
+
+class ActionMenuItem(Widget):
+ can_focus = False
+
+ def __init__(self, item: ActionMenuItemData, 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 ActionMenuPanel(Widget):
+ can_focus = True
+
+ def __init__(
+ self,
+ items: list[ActionMenuItemData],
+ 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 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/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
new file mode 100644
index 0000000..0020be6
--- /dev/null
+++ b/src/rigi/widgets/checkbox.py
@@ -0,0 +1,65 @@
+"""Simple clickable checkbox widget for Rigi."""
+
+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
+
+
+class Checkbox(Widget):
+ """A simple checkbox with a clickable label.
+
+ Posts ``Checkbox.Changed`` when toggled.
+ """
+
+ can_focus = True
+
+ 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, 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:
+ 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
diff --git a/src/rigi/widgets/content_area.py b/src/rigi/widgets/content_area.py
index a7c9d1a..33b27a8 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,13 +40,14 @@ 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:
_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
@@ -56,12 +57,12 @@ 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
@@ -69,7 +70,7 @@ def __init__(self) -> None:
def compose(self) -> ComposeResult:
yield _ContentResizeHandle()
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:
@@ -101,7 +102,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..36170c6 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,15 +54,16 @@ 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:
super().__init__(**kwargs)
self._sections = sections
+ self._sections_stack: list[list[tuple[str, list[MenuItemData]]]] = []
if title:
self.border_title = title
@@ -71,16 +72,43 @@ 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
+ 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
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_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/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_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()
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..4231db0 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(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 RigiStatusItem(item)
+ yield StatusBarItem(item)
yield _StatusSpacer()
yield _HamburgerButton()
diff --git a/src/rigi/widgets/tab_group.py b/src/rigi/widgets/tab_group.py
new file mode 100644
index 0000000..dd5df8c
--- /dev/null
+++ b/src/rigi/widgets/tab_group.py
@@ -0,0 +1,80 @@
+"""Horizontal 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
+
+
+class _TabItem(Widget):
+ can_focus = False
+
+ def __init__(self, label: str, idx: int) -> None:
+ super().__init__()
+ self._label = label
+ self._idx = idx
+
+ def render(self) -> str:
+ return self._label
+
+ def set_active(self, active: bool) -> None:
+ self.set_class(active, "--active")
+
+ def on_click(self) -> None:
+ self.post_message(_TabClicked(self._idx))
+ self.app.set_focus(None)
+
+
+class _TabClicked(Message):
+ def __init__(self, idx: int) -> None:
+ super().__init__()
+ self.idx = idx
+
+
+class TabGroup(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="tabgroup-nav"):
+ 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"tab-content-{i}")
+
+ def on_mount(self) -> None:
+ for i, (_, factory) in enumerate(self._tab_defs):
+ try:
+ container = self.query_one(f"#tab-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(_TabItem):
+ item.set_active(item._idx == idx)
+ try:
+ switcher = self.query_one("#tabgroup-switcher", ContentSwitcher)
+ switcher.current = f"tab-content-{idx}"
+ except Exception:
+ pass
+
+ 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/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 700d84d..93060f1 100644
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -1,30 +1,30 @@
from __future__ import annotations
from rigi import (
+ App,
Command,
CommandArg,
- RigiApp,
- RigiTheme,
StatusItem,
SubtabDef,
TabDef,
+ Theme,
ThemeDark,
ThemeLight,
ThemeMonokai,
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
new file mode 100644
index 0000000..edf3434
--- /dev/null
+++ b/tests/test_checkbox.py
@@ -0,0 +1,32 @@
+"""Tests for Checkbox widget."""
+
+import pytest
+from textual.app import App
+
+from rigi.widgets.checkbox import Checkbox
+
+
+@pytest.mark.asyncio
+async def test_checkbox_initial_value():
+ class TestApp(App[None]):
+ def compose(self):
+ yield Checkbox("Test", value=True)
+
+ app = TestApp()
+ async with app.run_test() as _:
+ cb = app.query_one(Checkbox)
+ assert cb.value is True
+
+
+@pytest.mark.asyncio
+async def test_checkbox_toggle():
+ class TestApp(App[None]):
+ def compose(self):
+ yield Checkbox("Test", value=False)
+
+ app = TestApp()
+ async with app.run_test() as _:
+ 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 eb06551..ca7c47d 100644
--- a/tests/test_resize.py
+++ b/tests/test_resize.py
@@ -5,8 +5,7 @@
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, _ContentResizeHandle
+from rigi.widgets.bottom_panel import BottomPanel, _ResizeHandle
from rigi.widgets.sidebar import _VerticalResizeHandle
@@ -25,28 +24,13 @@ 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."""
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 _:
@@ -84,12 +68,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()