mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-02 10:52:05 +00:00
feat(tui-v3): implement modular TUI architecture and CLI selector
Scaffolded TUI v3 from scratch with modular directory structure. Created RibbonHeader, ProfileTree, EntryDataTable, and BoardContainer widgets. Implemented full-screen SettingsScreen and MaximizedInspectorScreen. Updated CLI with unified '--tui [legacy|v2|v3]' option and 'tui3' command. Reused existing service hooks for zero-logic migration.
This commit is contained in:
1 parent
ac4b3b468b
commit
18a637d3a2
9 files changed
+864
-3
No files matched your search
@@ -13,6 +13,7 @@ from .common import _get_entry_service, _get_services
|
||||
from seedpass.core.errors import SeedPassError
|
||||
from constants import GUI_BACKEND_CONFIG
|
||||
from seedpass.tui_v2.app import check_tui2_runtime, launch_tui2
|
||||
from seedpass.tui_v3 import launch_tui3
|
||||
|
||||
app = typer.Typer(
|
||||
help=(
|
||||
@@ -50,6 +51,12 @@ legacy_tui_option = typer.Option(
|
||||
help="Use legacy interactive TUI (temporary fallback during TUI v2 cutover)",
|
||||
)
|
||||
|
||||
tui_version_option = typer.Option(
|
||||
"v2",
|
||||
"--tui",
|
||||
help="Select TUI version: legacy, v2, v3",
|
||||
)
|
||||
|
||||
# Sub command groups
|
||||
from . import entry, vault, nostr, config, fingerprint, util, api, agent, semantic
|
||||
from .capabilities import register_capabilities_command
|
||||
@@ -131,6 +138,7 @@ def main(
|
||||
no_clipboard: bool = no_clipboard_option,
|
||||
deterministic_totp: bool = deterministic_totp_option,
|
||||
legacy_tui: bool = legacy_tui_option,
|
||||
tui_version: str = tui_version_option,
|
||||
) -> None:
|
||||
"""SeedPass CLI entry point.
|
||||
|
||||
@@ -143,19 +151,34 @@ def main(
|
||||
"no_clipboard": no_clipboard,
|
||||
"deterministic_totp": deterministic_totp,
|
||||
"legacy_tui": legacy_tui,
|
||||
"tui_version": tui_version,
|
||||
}
|
||||
if ctx.invoked_subcommand is None:
|
||||
if legacy_tui:
|
||||
if legacy_tui or tui_version == "legacy":
|
||||
raise _launch_legacy_tui(fingerprint=fingerprint)
|
||||
|
||||
services, preflight_error = _prime_tui2_service(ctx)
|
||||
if preflight_error is not None:
|
||||
typer.echo(
|
||||
"TUI v2 preflight failed; falling back to legacy TUI. "
|
||||
f"TUI {tui_version} preflight failed; falling back to legacy TUI. "
|
||||
f"Details: {preflight_error}"
|
||||
)
|
||||
raise _launch_legacy_tui(fingerprint=fingerprint)
|
||||
|
||||
if tui_version == "v3":
|
||||
launch_tui3(
|
||||
fingerprint=fingerprint,
|
||||
entry_service_factory=lambda: services["entry"],
|
||||
profile_service_factory=lambda: services["profile"],
|
||||
config_service_factory=lambda: services["config"],
|
||||
nostr_service_factory=lambda: services["nostr"],
|
||||
sync_service_factory=lambda: services["sync"],
|
||||
utility_service_factory=lambda: services["utility"],
|
||||
vault_service_factory=lambda: services["vault"],
|
||||
semantic_service_factory=lambda: services["semantic"],
|
||||
)
|
||||
return
|
||||
|
||||
launched = launch_tui2(
|
||||
fingerprint=fingerprint,
|
||||
entry_service_factory=lambda: services["entry"],
|
||||
@@ -171,7 +194,7 @@ def main(
|
||||
return
|
||||
|
||||
typer.echo(
|
||||
"TUI v2 runtime unavailable; falling back to legacy TUI. "
|
||||
f"TUI {tui_version} runtime unavailable; falling back to legacy TUI. "
|
||||
"Run `seedpass tui2 --check` for diagnostics."
|
||||
)
|
||||
raise _launch_legacy_tui(fingerprint=fingerprint)
|
||||
@@ -247,6 +270,28 @@ def tui2(
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("tui3")
|
||||
def tui3(ctx: typer.Context) -> None:
|
||||
"""Launch the experimental TUI v3 scratch architecture."""
|
||||
fingerprint = (ctx.obj or {}).get("fingerprint")
|
||||
services, preflight_error = _prime_tui2_service(ctx)
|
||||
if preflight_error is not None:
|
||||
typer.echo(f"TUI v3 preflight failed: {preflight_error}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
launch_tui3(
|
||||
fingerprint=fingerprint,
|
||||
entry_service_factory=lambda: services["entry"],
|
||||
profile_service_factory=lambda: services["profile"],
|
||||
config_service_factory=lambda: services["config"],
|
||||
nostr_service_factory=lambda: services["nostr"],
|
||||
sync_service_factory=lambda: services["sync"],
|
||||
utility_service_factory=lambda: services["utility"],
|
||||
vault_service_factory=lambda: services["vault"],
|
||||
semantic_service_factory=lambda: services["semantic"],
|
||||
)
|
||||
|
||||
|
||||
@app.command("legacy")
|
||||
def legacy_tui(ctx: typer.Context) -> None:
|
||||
"""Launch the legacy interactive TUI explicitly."""
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
import importlib.util
|
||||
from typing import Any
|
||||
|
||||
def check_tui3_runtime() -> dict[str, Any]:
|
||||
"""Return runtime capability diagnostics for TUI v3."""
|
||||
textual_available = importlib.util.find_spec("textual") is not None
|
||||
return {
|
||||
"status": "ok" if textual_available else "unavailable",
|
||||
"backend": "textual-v3",
|
||||
"textual_available": textual_available,
|
||||
"message": (
|
||||
"Textual runtime available."
|
||||
if textual_available
|
||||
else "Textual is not installed. Install `textual` to run tui3."
|
||||
),
|
||||
}
|
||||
|
||||
def launch_tui3(
|
||||
*,
|
||||
fingerprint: str | None = None,
|
||||
entry_service_factory: Any | None = None,
|
||||
profile_service_factory: Any | None = None,
|
||||
config_service_factory: Any | None = None,
|
||||
nostr_service_factory: Any | None = None,
|
||||
sync_service_factory: Any | None = None,
|
||||
utility_service_factory: Any | None = None,
|
||||
vault_service_factory: Any | None = None,
|
||||
semantic_service_factory: Any | None = None,
|
||||
app_hook: Any | None = None,
|
||||
) -> bool:
|
||||
"""Launch TUI v3 from scratch architecture."""
|
||||
runtime = check_tui3_runtime()
|
||||
if not runtime["textual_available"]:
|
||||
return False
|
||||
|
||||
from .app import SeedPassTuiV3
|
||||
|
||||
app = SeedPassTuiV3(
|
||||
fingerprint=fingerprint,
|
||||
entry_service_factory=entry_service_factory,
|
||||
profile_service_factory=profile_service_factory,
|
||||
config_service_factory=config_service_factory,
|
||||
nostr_service_factory=nostr_service_factory,
|
||||
sync_service_factory=sync_service_factory,
|
||||
utility_service_factory=utility_service_factory,
|
||||
vault_service_factory=vault_service_factory,
|
||||
semantic_service_factory=semantic_service_factory,
|
||||
)
|
||||
|
||||
if callable(app_hook):
|
||||
app_hook(app)
|
||||
return True
|
||||
|
||||
app.run()
|
||||
return True
|
||||
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.reactive import reactive
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Static
|
||||
from textual.containers import Horizontal, Vertical
|
||||
|
||||
from .widgets.header import RibbonHeader
|
||||
from .widgets.sidebar import SidebarContainer
|
||||
from .widgets.grid import GridContainer
|
||||
from .widgets.inspector import BoardContainer
|
||||
from .screens.settings import SettingsScreen
|
||||
from .screens.inspector import MaximizedInspectorScreen
|
||||
|
||||
class MainScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("SeedPass ◈ UI v3", id="brand-strip")
|
||||
yield RibbonHeader(id="top-ribbon")
|
||||
with Vertical(id="body"):
|
||||
with Horizontal(id="top-work"):
|
||||
yield SidebarContainer(id="left")
|
||||
with Vertical(id="center"):
|
||||
yield GridContainer(id="grid-work")
|
||||
with Vertical(id="right"):
|
||||
yield Static("Inspector Board", id="inspector-heading")
|
||||
yield BoardContainer(id="board-container")
|
||||
yield Static("Ready", id="status")
|
||||
yield Footer()
|
||||
|
||||
class SeedPassTuiV3(App[None]):
|
||||
"""
|
||||
SeedPass TUI v3 - Rebuilt from scratch for modularity and mockup fidelity.
|
||||
"""
|
||||
CSS = """
|
||||
#brand-strip {
|
||||
background: #0b0f13;
|
||||
color: #58f29d;
|
||||
text-style: bold;
|
||||
border: solid #2abf75;
|
||||
margin: 0 1;
|
||||
padding: 0 1;
|
||||
height: 3;
|
||||
content-align: center middle;
|
||||
}
|
||||
#body { height: 1fr; margin: 0 1; }
|
||||
#top-work { height: 6fr; }
|
||||
#left { width: 31; border: solid #1a3024; background: #0d1114; }
|
||||
#center { width: 1fr; border: solid #1a3024; background: #0d1114; margin-left: 1; }
|
||||
#right { height: 5fr; border: heavy #3ce79c; background: #0d1114; margin-top: 1; }
|
||||
#status {
|
||||
height: 3;
|
||||
padding: 0 1;
|
||||
border: heavy #58f29d;
|
||||
background: #11191f;
|
||||
color: #e4fff2;
|
||||
margin: 0 1;
|
||||
}
|
||||
#sidebar-placeholder, #grid-placeholder, #inspector-placeholder {
|
||||
height: 1fr;
|
||||
content-align: center middle;
|
||||
color: #3ce79c;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("q", "quit", "Quit", show=True),
|
||||
Binding("ctrl+p", "open_palette", "Palette", show=True),
|
||||
Binding("shift+s", "toggle_settings", "Settings", show=True),
|
||||
Binding("z", "maximize_inspector", "Maximize", show=True),
|
||||
]
|
||||
|
||||
# Shared Reactive State
|
||||
active_fingerprint = reactive("")
|
||||
selected_entry_id = reactive[int | None](None)
|
||||
session_locked = reactive(True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fingerprint: str | None = None,
|
||||
entry_service_factory: Callable | None = None,
|
||||
profile_service_factory: Callable | None = None,
|
||||
config_service_factory: Callable | None = None,
|
||||
nostr_service_factory: Callable | None = None,
|
||||
sync_service_factory: Callable | None = None,
|
||||
utility_service_factory: Callable | None = None,
|
||||
vault_service_factory: Callable | None = None,
|
||||
semantic_service_factory: Callable | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
# Store factories
|
||||
self.factories = {
|
||||
"entry": entry_service_factory,
|
||||
"profile": profile_service_factory,
|
||||
"config": config_service_factory,
|
||||
"nostr": nostr_service_factory,
|
||||
"sync": sync_service_factory,
|
||||
"utility": utility_service_factory,
|
||||
"vault": vault_service_factory,
|
||||
"semantic": semantic_service_factory,
|
||||
}
|
||||
# Initialized services
|
||||
self.services: dict[str, Any] = {}
|
||||
self._initial_fingerprint = fingerprint
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize services and push the main screen."""
|
||||
for name, factory in self.factories.items():
|
||||
if factory:
|
||||
try:
|
||||
self.services[name] = factory()
|
||||
except Exception as e:
|
||||
self.log(f"Failed to init service {name}: {e}")
|
||||
|
||||
self.active_fingerprint = self._initial_fingerprint or ""
|
||||
self.push_screen(MainScreen())
|
||||
|
||||
def watch_active_fingerprint(self, old_fp: str, new_fp: str) -> None:
|
||||
"""Refresh components when the profile changes."""
|
||||
if not new_fp:
|
||||
return
|
||||
# Notify sidebar and grid to refresh
|
||||
try:
|
||||
self.query_one("#profile-tree")._refresh_tree()
|
||||
self.query_one("#entry-data-table")._refresh_data()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def watch_selected_entry_id(self, old_id: int | None, new_id: int | None) -> None:
|
||||
"""Update inspectors when an entry is selected."""
|
||||
try:
|
||||
self.query_one("#board-container").update_entry(new_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def action_refresh(self) -> None:
|
||||
"""Force a global UI refresh."""
|
||||
self.query_one("#profile-tree")._refresh_tree()
|
||||
self.query_one("#entry-data-table")._refresh_data()
|
||||
self.notify("UI Refreshed")
|
||||
|
||||
def action_open_palette(self) -> None:
|
||||
# Placeholder for unified command palette
|
||||
self.notify("Palette coming soon in v3")
|
||||
|
||||
def action_toggle_settings(self) -> None:
|
||||
"""Push the full-screen settings screen."""
|
||||
self.push_screen(SettingsScreen())
|
||||
|
||||
def action_maximize_inspector(self) -> None:
|
||||
"""Push the full-screen maximized entry detail screen."""
|
||||
if self.selected_entry_id is None:
|
||||
self.notify("Select an entry to maximize", severity="warning")
|
||||
return
|
||||
self.push_screen(MaximizedInspectorScreen())
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from textual.app import ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Static, Footer
|
||||
from textual.containers import Vertical, Horizontal
|
||||
|
||||
class MaximizedInspectorScreen(Screen):
|
||||
"""
|
||||
Full-screen detailed view for a single entry.
|
||||
Optimized for long documents, SSH keys, or PGP blocks.
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "app.pop_screen", "Back to Vault"),
|
||||
("v", "reveal", "Reveal Secret"),
|
||||
("g", "qr", "Show QR"),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
MaximizedInspectorScreen {
|
||||
background: #080a0c;
|
||||
}
|
||||
#max-inspector-title {
|
||||
background: #0b0f13;
|
||||
color: #58f29d;
|
||||
text-style: bold;
|
||||
text-align: center;
|
||||
height: 3;
|
||||
border: double #2abf75;
|
||||
padding: 0 1;
|
||||
margin: 1 2;
|
||||
}
|
||||
#max-inspector-container {
|
||||
height: 1fr;
|
||||
margin: 0 2;
|
||||
border: solid #1a3024;
|
||||
padding: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
#max-inspector-footer {
|
||||
height: 3;
|
||||
background: #11191f;
|
||||
color: #daf2e5;
|
||||
text-align: center;
|
||||
border: double #2abf75;
|
||||
padding: 0 1;
|
||||
margin: 1 2;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("SeedPass ◈ Detailed Inspection", id="max-inspector-title")
|
||||
with Vertical(id="max-inspector-container"):
|
||||
yield Static("Loading details...", id="max-inspector-content")
|
||||
yield Static("ESC: Back | v: Reveal | g: QR", id="max-inspector-footer")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._refresh_detail()
|
||||
|
||||
def _refresh_detail(self) -> None:
|
||||
app = self.app
|
||||
eid = app.selected_entry_id
|
||||
if eid is None:
|
||||
self.query_one("#max-inspector-content", Static).update("No entry selected.")
|
||||
return
|
||||
|
||||
if "entry" not in app.services:
|
||||
return
|
||||
|
||||
entry = app.services["entry"].retrieve_entry(eid)
|
||||
if not entry:
|
||||
return
|
||||
|
||||
# Simple render for the full screen view
|
||||
label = entry.get('label', 'Unknown')
|
||||
kind = entry.get('kind', 'Unknown')
|
||||
|
||||
# Build a large detail view
|
||||
header = f"[b]ENTRY #{eid} - {label.upper()} ({kind})[/b]
|
||||
"
|
||||
divider = "=" * 60 + "
|
||||
"
|
||||
|
||||
# Meta info
|
||||
meta_lines = [
|
||||
f"Username : {entry.get('username', '(none)')}",
|
||||
f"URL : {entry.get('url', '(none)')}",
|
||||
f"Created : {entry.get('created_at', '(unknown)')}",
|
||||
f"Modified : {entry.get('modified_at', '(unknown)')}",
|
||||
]
|
||||
|
||||
# Content (if note/doc)
|
||||
content = entry.get('content', '')
|
||||
content_block = f"
|
||||
[b]CONTENT / NOTES:[/b]
|
||||
{divider}{content}
|
||||
" if content else ""
|
||||
|
||||
final_text = header + divider + "
|
||||
".join(meta_lines) + content_block
|
||||
self.query_one("#max-inspector-content", Static).update(final_text)
|
||||
|
||||
def action_reveal(self) -> None:
|
||||
self.app.notify("Reveal triggered (Coming Soon in v3)")
|
||||
|
||||
def action_qr(self) -> None:
|
||||
self.app.notify("QR triggered (Coming Soon in v3)")
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from textual.app import ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Static, Footer
|
||||
from textual.containers import Vertical
|
||||
|
||||
class SettingsScreen(Screen):
|
||||
"""
|
||||
A dedicated, full-screen settings management interface.
|
||||
Categorizes configuration into Security, Storage, and Connectivity.
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "app.pop_screen", "Back to Vault"),
|
||||
("r", "refresh", "Refresh Settings"),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
SettingsScreen {
|
||||
background: #080a0c;
|
||||
}
|
||||
#settings-title {
|
||||
background: #0b0f13;
|
||||
color: #58f29d;
|
||||
text-style: bold;
|
||||
text-align: center;
|
||||
height: 3;
|
||||
border: double #2abf75;
|
||||
padding: 0 1;
|
||||
margin: 1 2;
|
||||
}
|
||||
#settings-container {
|
||||
height: 1fr;
|
||||
margin: 0 2;
|
||||
border: solid #1a3024;
|
||||
padding: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
#settings-footer {
|
||||
height: 3;
|
||||
background: #11191f;
|
||||
color: #daf2e5;
|
||||
text-align: center;
|
||||
border: double #2abf75;
|
||||
padding: 0 1;
|
||||
margin: 1 2;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("SeedPass ◈ System Configuration", id="settings-title")
|
||||
with Vertical(id="settings-container"):
|
||||
yield Static("Loading configuration...", id="settings-content")
|
||||
yield Static("ESC: Exit Settings | R: Reload | Use Palette (Ctrl+P) to change values", id="settings-footer")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.action_refresh()
|
||||
|
||||
def action_refresh(self) -> None:
|
||||
"""Fetch live settings and render them into the content panel."""
|
||||
app = self.app
|
||||
if "config" not in app.services:
|
||||
self.query_one("#settings-content", Static).update("[red]Config Service Offline[/red]")
|
||||
return
|
||||
|
||||
service = app.services["config"]
|
||||
|
||||
def get_val(key, default=""):
|
||||
try:
|
||||
val = service.get(key)
|
||||
return val if val is not None else default
|
||||
except Exception:
|
||||
return "[red](error)[/red]"
|
||||
|
||||
# Categorized Rows
|
||||
security_rows = [
|
||||
f"Secret Mode : {get_val('secret_mode_enabled', False)} [dim](setting-secret on|off)[/dim]",
|
||||
f"Quick Unlock : {get_val('quick_unlock', False)} [dim](setting-quick-unlock on|off)[/dim]",
|
||||
f"KDF Iterations : {get_val('kdf_iterations', 100000)} [dim](setting-kdf-iterations <n>)[/dim]",
|
||||
f"KDF Mode : {get_val('kdf_mode', 'argon2id')} [dim](setting-kdf-mode <mode>)[/dim]",
|
||||
f"Lock Timeout : {get_val('inactivity_timeout', 300)}s [dim](setting-timeout <s>)[/dim]",
|
||||
]
|
||||
|
||||
backup_rows = [
|
||||
f"Backup Path : {get_val('additional_backup_path', '(none)')} [dim](db-export <path>)[/dim]",
|
||||
f"Backup Interval: {get_val('backup_interval', 3600)}s",
|
||||
]
|
||||
|
||||
nostr_rows = [
|
||||
f"Sync Mode : {get_val('semantic_search_mode', 'keyword')} [dim](search-mode ...)[/dim]",
|
||||
f"Relays : {len(get_val('relays', []))} connected [dim](relay-list)[/dim]",
|
||||
]
|
||||
|
||||
# Use the app's card rendering utility if available, or fallback
|
||||
# In v3, we keep card logic in a central place or widget.
|
||||
# For now, we'll implement a clean local version.
|
||||
def render_card(title: str, rows: list[str]) -> str:
|
||||
width = 60
|
||||
top = f"┌─ {title} " + "─" * (width - len(title) - 4) + "┐"
|
||||
lines = [top]
|
||||
for row in rows:
|
||||
lines.append(f"│ {row:<{width-4}} │")
|
||||
lines.append("└" + "─" * (width - 2) + "┘")
|
||||
return "
|
||||
".join(lines)
|
||||
|
||||
rendered_lines = [
|
||||
render_card("SECURITY & AUTHENTICATION", security_rows),
|
||||
"",
|
||||
render_card("STORAGE & BACKUP POLICY", backup_rows),
|
||||
"",
|
||||
render_card("CONNECTIVITY & NOSTR SYNC", nostr_rows),
|
||||
]
|
||||
|
||||
self.query_one("#settings-content", Static).update("
|
||||
".join(rendered_lines))
|
||||
self.app.notify("Settings reloaded")
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from textual.app import ComposeResult
|
||||
from textual.widgets import Static, DataTable
|
||||
from textual.reactive import reactive
|
||||
|
||||
class GridMetrics(Static):
|
||||
"""
|
||||
Displays pagination, row counts, and search mode state.
|
||||
Matches the 'Entry Grid | Pg X/X ...' line in the mockups.
|
||||
"""
|
||||
DEFAULT_CSS = """
|
||||
GridMetrics {
|
||||
height: 1;
|
||||
background: #0b0f13;
|
||||
color: #daf2e5;
|
||||
padding: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def render(self) -> str:
|
||||
# These would eventually come from App reactives
|
||||
pg = "1/1"
|
||||
rows = "0/0"
|
||||
density = "compact"
|
||||
# Search mode highlighting
|
||||
search = "[b](KEYWORD)[/b] hybrid semantic"
|
||||
|
||||
return f"Entry Grid | Pg {pg} Rows {rows} Density {density} Search {search}"
|
||||
|
||||
class EntryDataTable(DataTable):
|
||||
"""
|
||||
High-fidelity data table for entry browsing.
|
||||
"""
|
||||
def on_mount(self) -> None:
|
||||
self.cursor_type = "row"
|
||||
self.add_columns(
|
||||
"Sel", "Id", "Entry#", "Label", "Kind", "Meta", "Arch"
|
||||
)
|
||||
self._refresh_data()
|
||||
|
||||
def _refresh_data(self) -> None:
|
||||
self.clear()
|
||||
app = self.app
|
||||
if "entry" not in app.services:
|
||||
return
|
||||
|
||||
# Fetch real data from service
|
||||
entries = app.services["entry"].search_entries("")
|
||||
for i, (eid, label, user, url, arch, kind) in enumerate(entries):
|
||||
marker = "▶" if eid == app.selected_entry_id else " "
|
||||
arch_status = "🔒" if arch else " "
|
||||
# 'Meta' column logic from mockup
|
||||
meta = user or url or ""
|
||||
|
||||
self.add_row(
|
||||
marker,
|
||||
str(i+1),
|
||||
f"#{eid}",
|
||||
label,
|
||||
kind.value,
|
||||
meta,
|
||||
arch_status,
|
||||
key=str(eid)
|
||||
)
|
||||
|
||||
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||
"""Handle entry selection."""
|
||||
if event.row_key.value:
|
||||
self.app.selected_entry_id = int(event.row_key.value)
|
||||
self.app.notify(f"Selected Entry #{event.row_key.value}")
|
||||
|
||||
class GridContainer(Static):
|
||||
"""
|
||||
The central workspace container.
|
||||
"""
|
||||
def compose(self) -> ComposeResult:
|
||||
yield GridMetrics()
|
||||
# Header divider
|
||||
yield Static("-" * 120, id="grid-divider")
|
||||
yield EntryDataTable(id="entry-data-table")
|
||||
|
||||
DEFAULT_CSS = """
|
||||
GridContainer {
|
||||
height: 1fr;
|
||||
background: #0d1114;
|
||||
}
|
||||
#grid-divider {
|
||||
color: #1a3024;
|
||||
height: 1;
|
||||
padding: 0 1;
|
||||
}
|
||||
#entry-data-table {
|
||||
background: transparent;
|
||||
color: #97b8a6;
|
||||
border: none;
|
||||
height: 1fr;
|
||||
}
|
||||
#entry-data-table > .datatable--header {
|
||||
background: #0b0f13;
|
||||
color: #58f29d;
|
||||
text-style: bold;
|
||||
}
|
||||
#entry-data-table > .datatable--cursor {
|
||||
background: #122019;
|
||||
color: #58f29d;
|
||||
}
|
||||
"""
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
from textual.app import ComposeResult
|
||||
from textual.widgets import Static
|
||||
from textual.reactive import reactive
|
||||
|
||||
class RibbonHeader(Static):
|
||||
"""
|
||||
A high-density status ribbon that displays active profile,
|
||||
session state, and vault metrics.
|
||||
|
||||
Matches the 'UI Board' mockup top ribbon layout.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
RibbonHeader {
|
||||
height: 3;
|
||||
background: #10181f;
|
||||
color: #daf2e5;
|
||||
border: heavy #2abf75;
|
||||
padding: 0 1;
|
||||
margin: 0 1;
|
||||
text-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
def render(self) -> str:
|
||||
app = self.app
|
||||
fp = app.active_fingerprint or "No Profile"
|
||||
lock_icon = "🔒" if app.session_locked else "🔓"
|
||||
status = "LOCKED" if app.session_locked else "UNLOCKED"
|
||||
|
||||
# In a real app, we'd fetch stats from the service.
|
||||
# For the shell, we'll use placeholders that look like the mockup.
|
||||
stats = "Entries: 0 | PWD: 0 | 2FA: 0 | Keys: 0"
|
||||
sync = "Sync: Never"
|
||||
|
||||
# Layout: [Icon] Status | Fingerprint | Stats | Sync
|
||||
left = f"{lock_icon} {status} | Seed: {fp}"
|
||||
right = f"{stats} | {sync}"
|
||||
|
||||
# Calculate spacing to push sync to the right
|
||||
available_width = self.size.width - len(left) - len(right) - 4
|
||||
spacer = " " * max(1, available_width)
|
||||
|
||||
return f"{left}{spacer}{right}"
|
||||
|
||||
def on_mount(self) -> None:
|
||||
# Refresh the header whenever important app state changes
|
||||
self.watch(self.app, "active_fingerprint", self.refresh)
|
||||
self.watch(self.app, "session_locked", self.refresh)
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from textual.app import ComposeResult
|
||||
from textual.widgets import Static, Label
|
||||
from textual.containers import Vertical, Horizontal
|
||||
from textual.reactive import reactive
|
||||
|
||||
class BaseBoard(Static):
|
||||
"""Base class for all specialized entry boards."""
|
||||
entry_data = reactive[dict[str, Any]]({})
|
||||
|
||||
def _render_card(self, title: str, rows: list[str]) -> str:
|
||||
"""Utility to render ASCII cards matching mockup style."""
|
||||
width = 40
|
||||
top = f"┌─ {title} " + "─" * (width - len(title) - 4) + "┐"
|
||||
bottom = "└" + "─" * (width - 2) + "┘"
|
||||
lines = [top]
|
||||
for row in rows:
|
||||
lines.append(f"│ {row:<{width-4}} │")
|
||||
lines.append(bottom)
|
||||
return "
|
||||
".join(lines)
|
||||
|
||||
class IdleBoard(Static):
|
||||
"""Shown when no entry is selected."""
|
||||
def render(self) -> str:
|
||||
return "
|
||||
|
||||
|
||||
[b]Inspector Idle[/b]
|
||||
|
||||
Select an entry from the grid
|
||||
to view details."
|
||||
|
||||
class PasswordBoard(BaseBoard):
|
||||
"""Matches 'Password Board.png' mockup."""
|
||||
def render(self) -> str:
|
||||
d = self.entry_data
|
||||
cred_rows = [
|
||||
f"Label : {d.get('label', '')}",
|
||||
f"Username : {d.get('username', '')}",
|
||||
f"URL : {d.get('url', '')}",
|
||||
"Password : [HIDDEN]",
|
||||
]
|
||||
action_rows = [
|
||||
"▣ Reveal (v) ▣ QR (g)",
|
||||
"▣ Edit (e) ▣ Archive (a)",
|
||||
]
|
||||
|
||||
return "
|
||||
".join([
|
||||
self._render_card("Credentials", cred_rows),
|
||||
"",
|
||||
self._render_card("Quick Actions", action_rows)
|
||||
])
|
||||
|
||||
class NoteBoard(BaseBoard):
|
||||
"""Matches 'Note Board.png' mockup."""
|
||||
def render(self) -> str:
|
||||
d = self.entry_data
|
||||
content = d.get('content', '')
|
||||
# Truncate for preview
|
||||
preview = (content[:200] + '...') if len(content) > 200 else content
|
||||
|
||||
content_rows = preview.splitlines()[:10]
|
||||
action_rows = [
|
||||
"▣ Edit Doc (e) ▣ Export",
|
||||
"▣ Archive (a)",
|
||||
]
|
||||
|
||||
return "
|
||||
".join([
|
||||
self._render_card("Content", content_rows),
|
||||
"",
|
||||
self._render_card("Quick Actions", action_rows)
|
||||
])
|
||||
|
||||
class BoardContainer(Vertical):
|
||||
"""
|
||||
Reactive container that swaps specialized boards based on entry kind.
|
||||
"""
|
||||
DEFAULT_CSS = """
|
||||
BoardContainer {
|
||||
height: 1fr;
|
||||
padding: 1;
|
||||
content-align: center top;
|
||||
}
|
||||
"""
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._show_board("idle")
|
||||
|
||||
def update_entry(self, entry_id: int | None) -> None:
|
||||
if entry_id is None:
|
||||
self._show_board("idle")
|
||||
return
|
||||
|
||||
app = self.app
|
||||
if "entry" not in app.services:
|
||||
return
|
||||
|
||||
entry = app.services["entry"].retrieve_entry(entry_id)
|
||||
if not entry:
|
||||
self._show_board("idle")
|
||||
return
|
||||
|
||||
kind = str(entry.get("kind") or entry.get("type") or "").lower()
|
||||
|
||||
# Select appropriate board
|
||||
if kind in {"password", "stored_password"}:
|
||||
board = self._show_board("password")
|
||||
board.entry_data = entry
|
||||
elif kind in {"document", "note"}:
|
||||
board = self._show_board("note")
|
||||
board.entry_data = entry
|
||||
else:
|
||||
# Fallback for now
|
||||
board = self._show_board("password")
|
||||
board.entry_data = entry
|
||||
|
||||
def _show_board(self, board_type: str) -> Any:
|
||||
self.remove_children()
|
||||
if board_type == "idle":
|
||||
new_board = IdleBoard()
|
||||
elif board_type == "password":
|
||||
new_board = PasswordBoard()
|
||||
elif board_type == "note":
|
||||
new_board = NoteBoard()
|
||||
else:
|
||||
new_board = IdleBoard()
|
||||
|
||||
self.mount(new_board)
|
||||
return new_board
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from textual.app import ComposeResult
|
||||
from textual.widgets import Tree, Static
|
||||
from textual.widgets.tree import TreeNode
|
||||
|
||||
class ProfileTree(Tree):
|
||||
"""
|
||||
A hierarchical sidebar that manages Seed Profiles, Managed Accounts, and Agents.
|
||||
|
||||
Structure:
|
||||
Root (Hidden)
|
||||
├── Fingerprint (Parent Seed)
|
||||
│ ├── Managed Account 1
|
||||
│ │ └── Agent 1
|
||||
│ └── Managed Account 2
|
||||
└── External Fingerprint
|
||||
"""
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.root.expand()
|
||||
self._refresh_tree()
|
||||
|
||||
def _refresh_tree(self) -> None:
|
||||
self.clear()
|
||||
app = self.app
|
||||
if "profile" not in app.services:
|
||||
self.root.add_leaf("Profile Service Offline")
|
||||
return
|
||||
|
||||
service = app.services["profile"]
|
||||
profiles = service.list_profiles()
|
||||
|
||||
# In v3, we will properly fetch nested accounts.
|
||||
# For this initial scaffold, we populate the top-level fingerprints.
|
||||
for fp in profiles:
|
||||
icon = "■ " if fp == app.active_fingerprint else "□ "
|
||||
label = f"{icon}{fp[:12]}..." if len(fp) > 12 else f"{icon}{fp}"
|
||||
node = self.root.add(label, data=fp, expand=True)
|
||||
|
||||
# Placeholder for child accounts - in a real run,
|
||||
# we would query the service for sub-entries of this FP.
|
||||
# node.add_leaf(" ├─ Managed Account 1")
|
||||
|
||||
def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
||||
"""Handle profile selection."""
|
||||
fp = event.node.data
|
||||
if fp and isinstance(fp, str):
|
||||
self.app.active_fingerprint = fp
|
||||
self.app.notify(f"Switched to profile: {fp[:8]}")
|
||||
|
||||
class SidebarContainer(Static):
|
||||
"""Container for the sidebar tree and toggle."""
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("PROFILES", id="sidebar-title")
|
||||
yield ProfileTree("Profiles", id="profile-tree")
|
||||
|
||||
DEFAULT_CSS = """
|
||||
SidebarContainer {
|
||||
width: 31;
|
||||
background: #0d1114;
|
||||
border-right: solid #1a3024;
|
||||
}
|
||||
#sidebar-title {
|
||||
background: #1a3024;
|
||||
color: #58f29d;
|
||||
text-style: bold;
|
||||
padding: 0 1;
|
||||
height: 1;
|
||||
}
|
||||
#profile-tree {
|
||||
background: transparent;
|
||||
color: #97b8a6;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
#profile-tree > .tree--guides {
|
||||
color: #1a3024;
|
||||
}
|
||||
#profile-tree > .tree--cursor {
|
||||
background: #122019;
|
||||
color: #58f29d;
|
||||
text-style: bold;
|
||||
}
|
||||
"""
|
||||
Reference in new issue
Block a user