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()