Skip to content

Commit 2d4e327

Browse files
feat(gui): enable plugins to contribute nav items to right sidebar
1 parent f828fd8 commit 2d4e327

3 files changed

Lines changed: 202 additions & 0 deletions

File tree

src/aignostics/gui/_frame.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020

2121
HEALTH_UPDATE_INTERVAL = 30
2222
USERINFO_UPDATE_INTERVAL = 60 * 60
23+
PROPS_CLICKABLE = "clickable"
24+
PROPS_AVATAR = "avatar"
25+
CLASSES_FULL_WIDTH = "w-full"
26+
CLASSES_FULL_HEIGHT = "h-full"
27+
CLASSES_FULL_SIZE = f"{CLASSES_FULL_WIDTH} {CLASSES_FULL_HEIGHT}"
2328

2429

2530
@contextmanager
@@ -47,9 +52,38 @@ def frame( # noqa: C901, PLR0915
4752
from aignostics.platform import Service as PlatformService # noqa: PLC0415
4853
from aignostics.platform import UserInfo, settings # noqa: PLC0415
4954
from aignostics.system import Service as SystemService # noqa: PLC0415
55+
from aignostics.utils import NavItem, gui_get_nav_groups # noqa: PLC0415
5056

5157
theme()
5258

59+
def _nav_item(icon: str, label: str, target: str, marker: str, new_tab: bool = True) -> None:
60+
"""Create a navigation item with icon and link."""
61+
with ui.item().props(PROPS_CLICKABLE).classes(CLASSES_FULL_WIDTH):
62+
with ui.item_section().props(PROPS_AVATAR):
63+
ui.icon(icon, color="primary")
64+
with ui.item_section():
65+
ui.link(label, target, new_tab=new_tab).mark(marker)
66+
67+
def _render_nav_item(item: NavItem) -> None:
68+
"""Render a single NavItem."""
69+
_nav_item(item.icon, item.label, item.target, item.marker or "", item.new_tab)
70+
71+
def _render_nav_groups() -> None:
72+
"""Render all navigation groups from discovered NavBuilders."""
73+
nav_groups = gui_get_nav_groups()
74+
for group in nav_groups:
75+
if group.use_expansion:
76+
with (
77+
ui.expansion(group.name, icon=group.icon, group="nav").classes(CLASSES_FULL_WIDTH),
78+
ui.list().props("dense").classes(CLASSES_FULL_WIDTH),
79+
):
80+
for item in group.items:
81+
_render_nav_item(item)
82+
else:
83+
# Render items flat without expansion
84+
for item in group.items:
85+
_render_nav_item(item)
86+
5387
user_info: UserInfo | None = None
5488
launchpad_healthy: bool | None = None
5589

@@ -247,6 +281,7 @@ def toggle_dark_mode() -> None:
247281
ui.label("Manage Cloud Bucket").classes(
248282
"font-bold" if context.client.page.path == "/bucket" else "font-normal"
249283
)
284+
_render_nav_groups()
250285
with ui.item(on_click=lambda _: ui.navigate.to("/system")).props("clickable"):
251286
with ui.item_section().props("avatar"):
252287
health_icon()

src/aignostics/utils/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from ._fs import get_user_data_directory, open_user_data_directory, sanitize_path, sanitize_path_component
2727
from ._health import Health
2828
from ._log import LogSettings
29+
from ._nav import BaseNavBuilder, NavGroup, NavItem, gui_get_nav_groups
2930
from ._process import SUBPROCESS_CREATION_FLAGS, ProcessInfo, get_process_info
3031
from ._service import BaseService
3132
from ._settings import UNHIDE_SENSITIVE_INFO, OpaqueSettings, load_settings, strip_to_none_before_validator
@@ -35,9 +36,12 @@
3536
__all__ = [
3637
"SUBPROCESS_CREATION_FLAGS",
3738
"UNHIDE_SENSITIVE_INFO",
39+
"BaseNavBuilder",
3840
"BaseService",
3941
"Health",
4042
"LogSettings",
43+
"NavGroup",
44+
"NavItem",
4145
"OpaqueSettings",
4246
"ProcessInfo",
4347
"__author_email__",
@@ -63,6 +67,7 @@
6367
"discover_plugin_packages",
6468
"get_process_info",
6569
"get_user_data_directory",
70+
"gui_get_nav_groups",
6671
"load_modules",
6772
"load_settings",
6873
"locate_implementations",

src/aignostics/utils/_nav.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Navigation infrastructure for NiceGUI sidebar.
2+
3+
This module provides:
4+
- NavItem: Individual navigation item dataclass
5+
- NavGroup: Group of navigation items
6+
- BaseNavBuilder: Abstract base class for module navigation builders
7+
- gui_get_nav_groups: Collect and sort navigation groups from all NavBuilders
8+
"""
9+
10+
from abc import ABC, abstractmethod
11+
from dataclasses import dataclass, field
12+
13+
from ._di import locate_subclasses
14+
15+
16+
@dataclass
17+
class NavItem:
18+
"""Navigation item for sidebar.
19+
20+
Attributes:
21+
icon: Material icon name (e.g., 'waving_hand', 'settings').
22+
label: Display label for the navigation item.
23+
target: URL path or external URL for the link.
24+
marker: Test marker for the item. Auto-generated from label if None.
25+
new_tab: Whether to open the link in a new tab. Defaults to False (same tab).
26+
"""
27+
28+
icon: str
29+
label: str
30+
target: str
31+
marker: str | None = None
32+
new_tab: bool = False
33+
34+
def __post_init__(self) -> None:
35+
"""Auto-generate marker from label if not provided."""
36+
if self.marker is None:
37+
# Convert label to SCREAMING_SNAKE_CASE marker
38+
self.marker = "LINK_" + self.label.upper().replace(" ", "_").replace("(", "").replace(")", "")
39+
40+
41+
@dataclass
42+
class NavGroup:
43+
"""Group of navigation items from a NavBuilder.
44+
45+
Used internally for rendering navigation in the sidebar.
46+
"""
47+
48+
name: str
49+
icon: str = "folder"
50+
items: list[NavItem] = field(default_factory=list)
51+
position: int = 1000
52+
use_expansion: bool = True
53+
54+
55+
class BaseNavBuilder(ABC):
56+
"""Base class for navigation builders.
57+
58+
Each module should have ONE NavBuilder that defines its navigation items.
59+
NavBuilders are auto-discovered and used to populate the sidebar.
60+
61+
Example:
62+
class NavBuilder(BaseNavBuilder):
63+
@staticmethod
64+
def get_nav_name() -> str:
65+
return "My Module"
66+
67+
@staticmethod
68+
def get_nav_items() -> list[NavItem]:
69+
return [
70+
NavItem(icon="home", label="Home", target="/my-module"),
71+
NavItem(icon="settings", label="Settings", target="/my-module/settings"),
72+
]
73+
74+
@staticmethod
75+
def get_nav_position() -> int:
76+
return 200 # Lower = higher in sidebar
77+
"""
78+
79+
@staticmethod
80+
@abstractmethod
81+
def get_nav_name() -> str:
82+
"""Return the display name for this module's navigation group.
83+
84+
Returns:
85+
str: Display name shown in sidebar (e.g., 'Hello World', 'System').
86+
"""
87+
88+
@staticmethod
89+
@abstractmethod
90+
def get_nav_items() -> list[NavItem]:
91+
"""Return navigation items for the sidebar.
92+
93+
Items are rendered in list order within the module's group.
94+
95+
Returns:
96+
list[NavItem]: Navigation items for this module.
97+
"""
98+
99+
@staticmethod
100+
def get_nav_position() -> int:
101+
"""Return position in sidebar (lower = higher).
102+
103+
Convention:
104+
- 100-199: Core demo/example pages
105+
- 200-499: Feature modules
106+
- 500-799: Default (unspecified)
107+
- 800-899: System/admin pages
108+
- 900-999: External links (API docs, status, repo)
109+
110+
Returns:
111+
int: Position value. Defaults to 1000.
112+
"""
113+
return 1000
114+
115+
@staticmethod
116+
def get_nav_icon() -> str:
117+
"""Return the icon for the navigation group expansion panel.
118+
119+
Uses Material Icons names. See: https://fonts.google.com/icons
120+
121+
Returns:
122+
str: Material icon name. Defaults to 'folder'.
123+
"""
124+
return "folder"
125+
126+
@staticmethod
127+
def get_nav_use_expansion() -> bool:
128+
"""Return whether to render items in an expansion panel.
129+
130+
If True, items are grouped in a collapsible expansion with get_nav_name().
131+
If False, items are rendered flat without grouping.
132+
133+
Returns:
134+
bool: Use expansion panel. Defaults to True.
135+
"""
136+
return True
137+
138+
139+
def gui_get_nav_groups() -> list[NavGroup]:
140+
"""Collect navigation groups from all NavBuilders.
141+
142+
Returns:
143+
list[NavGroup]: Navigation groups sorted by position.
144+
"""
145+
nav_builders = locate_subclasses(BaseNavBuilder)
146+
groups: list[NavGroup] = []
147+
148+
for nav_builder in nav_builders:
149+
items = nav_builder.get_nav_items()
150+
if items: # Only include builders with nav items
151+
groups.append(
152+
NavGroup(
153+
name=nav_builder.get_nav_name(),
154+
icon=nav_builder.get_nav_icon(),
155+
items=items,
156+
position=nav_builder.get_nav_position(),
157+
use_expansion=nav_builder.get_nav_use_expansion(),
158+
)
159+
)
160+
161+
# Sort by position (lower = higher in sidebar)
162+
return sorted(groups, key=lambda g: g.position)

0 commit comments

Comments
 (0)