mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-30 01:12:17 +00:00
feat: graph pivot UX — breadcrumb trail, back nav, relation grouping
- Add PivotBreadcrumbBar widget to inspector.py showing multi-hop path - Add _pivot_history/pivot_to_entry/action_pivot_back to app.py - LinkedItemsPanel: group linked items by relation type with section headers - LinkedItemsPanel: b key for back navigation, breadcrumb rendered from app history - External entry selection (grid) clears pivot history; pivot chain preserved on linked nav
This commit is contained in:
1 parent
2ab73c50a0
commit
5eedea3762
2 files changed
+154
-25
No files matched your search
@@ -952,6 +952,10 @@ class SeedPassTuiV3(App[None]):
|
||||
# Internal state for sensitive actions
|
||||
_pending_sensitive_confirm: tuple[str, int, float] | None = None
|
||||
|
||||
# Pivot navigation history (entry_ids visited before current via LinkedItemsPanel)
|
||||
_pivot_history: list[int]
|
||||
_pivot_in_progress: bool
|
||||
|
||||
@staticmethod
|
||||
def render_qr_ascii(data: str) -> str:
|
||||
return render_qr_ascii(data)
|
||||
@@ -988,6 +992,9 @@ class SeedPassTuiV3(App[None]):
|
||||
self.services: dict[str, Any] = {}
|
||||
self._initial_fingerprint = fingerprint
|
||||
self._main_screen_initialized = False
|
||||
# Pivot history for multi-hop linked entry navigation
|
||||
self._pivot_history: list[int] = []
|
||||
self._pivot_in_progress: bool = False
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize provided services or start in profile-unlock mode."""
|
||||
@@ -1221,6 +1228,10 @@ class SeedPassTuiV3(App[None]):
|
||||
|
||||
def watch_selected_entry_id(self, old_id: int | None, new_id: int | None) -> None:
|
||||
"""Update inspectors when an entry is selected."""
|
||||
if not self._pivot_in_progress:
|
||||
# External selection (grid, search) — reset pivot chain
|
||||
self._pivot_history.clear()
|
||||
self._pivot_in_progress = False
|
||||
try:
|
||||
inspector = self.screen.query_one("#inspector-pane")
|
||||
if new_id is None:
|
||||
@@ -1232,6 +1243,24 @@ class SeedPassTuiV3(App[None]):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def pivot_to_entry(self, entry_id: int) -> None:
|
||||
"""Navigate to a linked entry, pushing current entry onto the pivot history stack."""
|
||||
if self.selected_entry_id is not None:
|
||||
self._pivot_history.append(self.selected_entry_id)
|
||||
self._pivot_in_progress = True
|
||||
self.selected_entry_id = entry_id
|
||||
self.notify(f"Opened linked entry #{entry_id}")
|
||||
|
||||
def action_pivot_back(self) -> None:
|
||||
"""Return to the previous entry in the pivot chain."""
|
||||
if not self._pivot_history:
|
||||
self.notify("No previous entry in pivot chain", severity="warning")
|
||||
return
|
||||
prev_id = self._pivot_history.pop()
|
||||
self._pivot_in_progress = True
|
||||
self.selected_entry_id = prev_id
|
||||
self.notify(f"Back: entry #{prev_id}")
|
||||
|
||||
def action_close_inspector(self) -> None:
|
||||
"""Collapse the inspector and clear the active selection."""
|
||||
self.selected_entry_id = None
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from __future__ import annotations
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
from textual.app import ComposeResult
|
||||
from textual.events import Key
|
||||
@@ -599,6 +600,41 @@ class UtilityHintsBar(Static):
|
||||
self.update(f"[b]Actions:[/b] {hint_str} [dim](Ctrl+P: palette)[/dim]")
|
||||
|
||||
|
||||
class PivotBreadcrumbBar(Static):
|
||||
"""Shows navigation breadcrumb for multi-hop pivot chain."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
PivotBreadcrumbBar {
|
||||
height: auto;
|
||||
min-height: 1;
|
||||
background: #0a0a1a;
|
||||
color: #6666aa;
|
||||
padding: 0 1;
|
||||
border-bottom: solid #222244;
|
||||
display: none;
|
||||
}
|
||||
PivotBreadcrumbBar.visible {
|
||||
display: block;
|
||||
}
|
||||
"""
|
||||
|
||||
def set_crumbs(self, history: list[dict], current_label: str = "") -> None:
|
||||
"""Render the breadcrumb trail from pivot history + current entry."""
|
||||
if not history:
|
||||
self.update("")
|
||||
self.remove_class("visible")
|
||||
return
|
||||
parts = [f"#{c['entry_id']} {c['label']}" for c in history]
|
||||
if current_label:
|
||||
parts.append(f"[b]{current_label}[/b]")
|
||||
self.update(" > ".join(parts))
|
||||
self.add_class("visible")
|
||||
|
||||
def clear(self) -> None:
|
||||
self.update("")
|
||||
self.remove_class("visible")
|
||||
|
||||
|
||||
class BoardContainer(Vertical):
|
||||
"""
|
||||
Reactive container that swaps specialized boards based on entry kind.
|
||||
@@ -754,6 +790,12 @@ class LinkedItemsPanel(Vertical):
|
||||
color: #555555;
|
||||
margin-top: 1;
|
||||
}
|
||||
.linked-group-header {
|
||||
color: #aaaaff;
|
||||
text-style: bold;
|
||||
margin-top: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
"""
|
||||
|
||||
# Available kind filters — None means "all"
|
||||
@@ -769,6 +811,7 @@ class LinkedItemsPanel(Vertical):
|
||||
self.atlas_source_scope: str | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield PivotBreadcrumbBar(id="pivot-breadcrumb-bar")
|
||||
yield Label("Linked Items", id="linked-items-title")
|
||||
yield Label("No linked items.", id="linked-items-summary", classes="linked-summary")
|
||||
yield Label("", id="linked-filter-bar", classes="linked-filter-bar")
|
||||
@@ -838,31 +881,74 @@ class LinkedItemsPanel(Vertical):
|
||||
)
|
||||
return
|
||||
|
||||
self._nav_items = list(visible)
|
||||
|
||||
# Group by (direction, relation) for a cleaner grouped display
|
||||
groups: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
||||
for item in visible:
|
||||
direction_arrow = "->" if item.get("direction") == "outgoing" else "<-"
|
||||
direction = str(item.get("direction", "outgoing")).strip()
|
||||
relation = str(item.get("relation", "")).strip() or "related_to"
|
||||
label = str(item.get("label", "")).strip() or f"Entry #{item.get('entry_id', '?')}"
|
||||
kind = str(item.get("kind", "")).strip() or "entry"
|
||||
archived_tag = " [archived]" if item.get("archived") else ""
|
||||
tags = item.get("tags", [])
|
||||
tag_str = f" tags:{','.join(tags[:3])}" if tags else ""
|
||||
hop = item.get("hop")
|
||||
hop_str = f" hop:{hop}" if hop and int(hop) > 1 else ""
|
||||
note = str(item.get("note", "")).strip()
|
||||
note_str = f" ({note[:30]})" if note else ""
|
||||
line = (
|
||||
f"{direction_arrow} [{relation}] #{item.get('entry_id')} "
|
||||
f"{label} <{kind}>{archived_tag}{tag_str}{hop_str}{note_str}"
|
||||
)
|
||||
groups[(direction, relation)].append(item)
|
||||
|
||||
# Sort: outgoing first, then incoming; alphabetically within each direction
|
||||
sorted_keys = sorted(
|
||||
groups.keys(),
|
||||
key=lambda k: (0 if k[0] == "outgoing" else 1, k[1]),
|
||||
)
|
||||
|
||||
nav_items: list[dict[str, Any]] = []
|
||||
for direction, relation in sorted_keys:
|
||||
arrow = "->" if direction == "outgoing" else "<-"
|
||||
list_container.mount(
|
||||
Button(
|
||||
f"{line} | Open",
|
||||
id=f"linked-open-{int(item.get('entry_id', 0) or 0)}",
|
||||
classes="linked-open",
|
||||
Label(
|
||||
f"[b]{arrow} [{relation}][/b]",
|
||||
classes="linked-group-header",
|
||||
)
|
||||
)
|
||||
for item in groups[(direction, relation)]:
|
||||
label = str(item.get("label", "")).strip() or f"Entry #{item.get('entry_id', '?')}"
|
||||
kind = str(item.get("kind", "")).strip() or "entry"
|
||||
archived_tag = " [archived]" if item.get("archived") else ""
|
||||
tags = item.get("tags", [])
|
||||
tag_str = f" tags:{','.join(tags[:3])}" if tags else ""
|
||||
hop = item.get("hop")
|
||||
hop_str = f" hop:{hop}" if hop and int(hop) > 1 else ""
|
||||
note = str(item.get("note", "")).strip()
|
||||
note_str = f" ({note[:30]})" if note else ""
|
||||
line = (
|
||||
f" #{item.get('entry_id')} "
|
||||
f"{label} <{kind}>{archived_tag}{tag_str}{hop_str}{note_str}"
|
||||
)
|
||||
nav_items.append(item)
|
||||
list_container.mount(
|
||||
Button(
|
||||
f"{line} | Open",
|
||||
id=f"linked-open-{int(item.get('entry_id', 0) or 0)}",
|
||||
classes="linked-open",
|
||||
)
|
||||
)
|
||||
|
||||
self._nav_items = nav_items
|
||||
|
||||
def _get_entry_label(self, entry_id: int) -> str:
|
||||
"""Fetch label for an entry, falling back to its ID string."""
|
||||
try:
|
||||
entry = self.app.services["entry"].retrieve_entry(entry_id)
|
||||
return str(entry.get("label") or f"#{entry_id}") if entry else f"#{entry_id}"
|
||||
except Exception:
|
||||
return f"#{entry_id}"
|
||||
|
||||
def _refresh_breadcrumb(self, current_entry_id: int | None) -> None:
|
||||
"""Render breadcrumb bar from app._pivot_history."""
|
||||
try:
|
||||
bar = self.query_one("#pivot-breadcrumb-bar", PivotBreadcrumbBar)
|
||||
except Exception:
|
||||
return
|
||||
history: list[int] = getattr(self.app, "_pivot_history", [])
|
||||
if not history or current_entry_id is None:
|
||||
bar.clear()
|
||||
return
|
||||
crumbs = [{"entry_id": eid, "label": self._get_entry_label(eid)} for eid in history]
|
||||
current_label = self._get_entry_label(current_entry_id)
|
||||
bar.set_crumbs(crumbs, current_label)
|
||||
|
||||
def update_entry(self, entry_id: int | None) -> None:
|
||||
summary_label = self.query_one("#linked-items-summary", Label)
|
||||
@@ -870,6 +956,8 @@ class LinkedItemsPanel(Vertical):
|
||||
list_container = self.query_one("#linked-items-list", Vertical)
|
||||
nav_hint = self.query_one("#linked-nav-hint", Label)
|
||||
|
||||
self._refresh_breadcrumb(entry_id)
|
||||
|
||||
if entry_id is None:
|
||||
summary_label.update("No linked items.")
|
||||
filter_bar.update("")
|
||||
@@ -953,11 +1041,17 @@ class LinkedItemsPanel(Vertical):
|
||||
pass
|
||||
|
||||
def on_key(self, event: Key) -> None:
|
||||
app = self.app
|
||||
if event.key == "b":
|
||||
# Back navigation: pop from pivot history
|
||||
if hasattr(app, "action_pivot_back"):
|
||||
app.action_pivot_back()
|
||||
event.stop()
|
||||
return
|
||||
if not self._nav_items:
|
||||
return
|
||||
if event.key == "f":
|
||||
# Cycle kind filter — need to re-fetch and re-render
|
||||
app = self.app
|
||||
entry_id = getattr(app, "selected_entry_id", None)
|
||||
if entry_id is None:
|
||||
return
|
||||
@@ -989,8 +1083,11 @@ class LinkedItemsPanel(Vertical):
|
||||
item = self._nav_items[self._focused_idx]
|
||||
target_id = int(item.get("entry_id", 0) or 0)
|
||||
if target_id > 0:
|
||||
self.app.selected_entry_id = target_id
|
||||
self.app.notify(f"Opened linked entry #{target_id}")
|
||||
if hasattr(app, "pivot_to_entry"):
|
||||
app.pivot_to_entry(target_id)
|
||||
else:
|
||||
app.selected_entry_id = target_id
|
||||
app.notify(f"Opened linked entry #{target_id}")
|
||||
event.stop()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
@@ -1008,5 +1105,8 @@ class LinkedItemsPanel(Vertical):
|
||||
except ValueError:
|
||||
self.app.notify("Invalid linked item target", severity="error")
|
||||
return
|
||||
self.app.selected_entry_id = entry_id
|
||||
self.app.notify(f"Opened linked entry #{entry_id}")
|
||||
if hasattr(self.app, "pivot_to_entry"):
|
||||
self.app.pivot_to_entry(entry_id)
|
||||
else:
|
||||
self.app.selected_entry_id = entry_id
|
||||
self.app.notify(f"Opened linked entry #{entry_id}")
|
||||
Reference in new issue
Block a user