"""
Main dashboard page for MeshCore GUI.
Thin orchestrator that owns the layout and the 500 ms update timer.
All visual content is delegated to individual panel classes in
:mod:`meshcore_gui.gui.panels`.
"""
import logging
from urllib.parse import urlencode
from nicegui import ui
from meshcore_gui import config
from meshcore_gui.core.protocols import SharedDataReader
from meshcore_gui.gui.panels import (
ActionsPanel,
BbsPanel,
BotPanel,
ChannelBackupPanel,
ChannelPanel,
ContactsPanel,
DevicePanel,
MapPanel,
MessagesPanel,
RoomServerPanel,
RxLogPanel,
)
from meshcore_gui.gui.archive_page import ArchivePage
from meshcore_gui.services.bbs_config_store import BbsConfigStore
from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService
from meshcore_gui.services.bot_config_store import BotConfigStore
from meshcore_gui.services.channel_service import (
CHANNEL_SORT_BY_INDEX,
CHANNEL_SORT_BY_NAME,
sort_channels,
)
from meshcore_gui.services.channel_sort_store import ChannelSortStore
from meshcore_gui.services.pin_store import PinStore
from meshcore_gui.services.room_password_store import RoomPasswordStore
# Suppress the harmless "Client has been deleted" warning that NiceGUI
# emits when a browser tab is refreshed while a ui.timer is active.
class _DeletedClientFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
return 'Client has been deleted' not in record.getMessage()
logging.getLogger('nicegui').addFilter(_DeletedClientFilter())
# ── DOMCA Theme ──────────────────────────────────────────────────────
# Fonts + CSS variables adapted from domca.nl style.css for NiceGUI/Quasar.
# Dark/light variable sets switch via Quasar's body--dark / body--light classes.
_DOMCA_HEAD = '''
'''
# ── Landing SVG loader ────────────────────────────────────────────────
# Reads the SVG from config.LANDING_SVG_PATH and replaces {callsign}
# with config.OPERATOR_CALLSIGN. Falls back to a minimal placeholder
# when the file is missing.
def _load_landing_svg() -> str:
"""Load the landing page SVG from disk.
Returns:
SVG markup string with ``{callsign}`` replaced by the
configured operator callsign.
"""
path = config.LANDING_SVG_PATH
try:
raw = path.read_text(encoding="utf-8")
return raw.replace("{callsign}", config.OPERATOR_CALLSIGN)
except FileNotFoundError:
return (
''
)
# ── Standalone menu items (no submenus) ──────────────────────────────
_STANDALONE_ITEMS = [
('\U0001f465', 'CONTACTS', 'contacts'),
('\U0001f5fa\ufe0f', 'MAP', 'map'),
('\U0001f4e1', 'DEVICE', 'device'),
('\u26a1', 'ACTIONS', 'actions'),
('\U0001f4ca', 'RX LOG', 'rxlog'),
('\U0001f916', 'BOT', 'bot'),
('\U0001f4cb', 'BBS', 'bbs'),
]
_EXT_LINKS = config.EXT_LINKS
# ── Shared button styles ─────────────────────────────────────────────
_SUB_BTN_STYLE = (
"font-family: 'JetBrains Mono', monospace; "
"letter-spacing: 1px; font-size: 0.72rem; "
"padding: 0.2rem 1.2rem 0.2rem 2.4rem"
)
_MENU_BTN_STYLE = (
"font-family: 'JetBrains Mono', monospace; "
"letter-spacing: 2px; font-size: 0.8rem; "
"padding: 0.35rem 1.2rem"
)
class DashboardPage:
"""Main dashboard rendered at ``/``.
Args:
shared: SharedDataReader for data access and command dispatch.
"""
def __init__(self, shared: SharedDataReader, pin_store: PinStore, room_password_store: RoomPasswordStore, bot_config_store: BotConfigStore | None = None, device_id: str = "") -> None:
self._shared = shared
self._pin_store = pin_store
self._room_password_store = room_password_store
self._device_id = device_id
# BBS service and config store (singletons shared with bot routing)
self._bbs_config_store = BbsConfigStore()
self._bbs_service = BbsService()
self._bbs_handler = BbsCommandHandler(
self._bbs_service, self._bbs_config_store
)
# Bot config store — injected from __main__ so dashboard and worker
# share the same device-scoped file. Falls back to a default-scoped
# instance when not provided (e.g. unit tests, legacy callers).
self._bot_config_store = bot_config_store if bot_config_store is not None else BotConfigStore()
# Channel sort preference store — global (not per-device) UI setting
# driving the drawer Messages/Archive submenu order.
self._channel_sort_store = ChannelSortStore()
# Panels (created fresh on each render)
self._device: DevicePanel | None = None
self._contacts: ContactsPanel | None = None
self._map: MapPanel | None = None
self._messages: MessagesPanel | None = None
self._actions: ActionsPanel | None = None
self._rxlog: RxLogPanel | None = None
self._room_server: RoomServerPanel | None = None
self._bbs: BbsPanel | None = None
self._bot: BotPanel | None = None
# Channel add dialog panel
self._channel_panel: ChannelPanel | None = None
# Channel backup / restore dialogs
self._channel_backup_panel: ChannelBackupPanel | None = None
# Channel delete confirmation dialog
self._confirm_delete_dialog = None
self._confirm_delete_label = None
self._pending_delete_cmd: dict | None = None
# Header status label
self._status_label = None
# Local first-render flag
self._initialized: bool = False
# Panel switching state (layout)
self._panel_containers: dict = {}
self._active_panel: str = 'landing'
self._drawer = None
self._menu_buttons: dict = {}
# Submenu containers (for dynamic channel/room items)
self._msg_sub_container = None
self._archive_sub_container = None
self._rooms_sub_container = None
self._last_channel_fingerprint = None
self._last_rooms_fingerprint = None
# Archive page reference (for inline channel switching)
self._archive_page: ArchivePage | None = None
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def render(self) -> None:
"""Build the complete dashboard layout and start the timer."""
self._initialized = False
# Reset fingerprints: render() creates new (empty) NiceGUI
# containers, so _update_submenus must rebuild into them even
# when the channel/room data hasn't changed since last session.
self._last_channel_fingerprint = None
self._last_rooms_fingerprint = None
# Create panel instances (UNCHANGED functional wiring)
put_cmd = self._shared.put_command
self._device = DevicePanel()
self._contacts = ContactsPanel(put_cmd, self._pin_store, self._shared.set_auto_add_enabled, self._on_add_room_server)
self._map = MapPanel()
self._messages = MessagesPanel(put_cmd)
self._actions = ActionsPanel(put_cmd)
self._rxlog = RxLogPanel()
self._room_server = RoomServerPanel(put_cmd, self._room_password_store)
self._bbs = BbsPanel(put_cmd, self._bbs_service, self._bbs_config_store)
self._bot = BotPanel(
put_cmd,
self._shared.set_bot_enabled,
self._bot_config_store,
self._pin_store,
)
self._channel_panel = ChannelPanel(put_cmd)
self._channel_panel.render()
self._channel_backup_panel = ChannelBackupPanel(
self._device_id, put_cmd
)
self._channel_backup_panel.render()
# ── Channel delete confirmation dialog ────────────────────
self._confirm_delete_dialog = ui.dialog()
with self._confirm_delete_dialog:
with ui.card().classes('w-full').style('min-width: 280px; max-width: 380px'):
ui.label('🗑️ Delete Channel').classes('font-bold text-gray-600 text-base')
self._confirm_delete_label = ui.label('').classes('text-sm text-gray-500')
with ui.row().classes('gap-2 justify-end w-full'):
ui.button(
'Cancel',
on_click=lambda: self._confirm_delete_dialog.close(),
).props('flat no-caps')
ui.button(
'Delete',
on_click=self._on_delete_confirmed,
).props('unelevated color=negative no-caps')
# Inject DOMCA theme (fonts + CSS variables)
ui.add_head_html(_DOMCA_HEAD)
# Default to dark mode (DOMCA theme)
dark = ui.dark_mode(True)
dark.on_value_change(lambda e: self._map.set_ui_dark_mode(e.value))
self._map.set_ui_dark_mode(dark.value)
# ── Left Drawer (must be created before header for Quasar) ────
self._drawer = ui.left_drawer(value=False, bordered=True).classes(
'domca-drawer'
).style('padding: 0')
with self._drawer:
# DOMCA branding (clickable → landing page)
with ui.column().style('padding: 0.2rem 1.2rem 0'):
ui.button(
'DOMCA',
on_click=lambda: self._navigate_panel('landing'),
).props('flat no-caps').style(
"font-family: 'Exo 2', sans-serif; font-size: 1.4rem; "
"font-weight: 800; color: var(--title); letter-spacing: 4px; "
"margin-bottom: 0.3rem; padding: 0"
)
self._menu_buttons = {}
# ── 💬 MESSAGES (expandable with channel submenu) ──────
with ui.expansion(
'\U0001f4ac MESSAGES', icon=None, value=False,
).props('dense header-class="q-pa-none"').classes('w-full'):
self._msg_sub_container = ui.column().classes('w-full gap-0')
with self._msg_sub_container:
self._make_sub_btn(
'ALL', lambda: self._navigate_panel('messages', channel=None)
)
self._make_sub_btn(
'DM', lambda: self._navigate_panel('messages', channel='DM')
)
# Dynamic channel items populated by _update_submenus
self._make_sub_btn(
'+ Add Channel',
lambda: self._channel_panel.open() if self._channel_panel else None,
)
self._make_sub_btn(
'💾 Backup Channels',
lambda: (
self._channel_backup_panel.open_backup()
if self._channel_backup_panel else None
),
)
self._make_sub_btn(
'📥 Restore Channels',
lambda: (
self._channel_backup_panel.open_restore()
if self._channel_backup_panel else None
),
)
# ── 🏠 ROOMS (expandable with room submenu) ───────────
with ui.expansion(
'\U0001f3e0 ROOMS', icon=None, value=False,
).props('dense header-class="q-pa-none"').classes('w-full'):
self._rooms_sub_container = ui.column().classes('w-full gap-0')
with self._rooms_sub_container:
self._make_sub_btn(
'ALL', lambda: self._navigate_panel('rooms')
)
# Pre-populate from persisted rooms
for entry in self._room_password_store.get_rooms():
short = entry.name or entry.pubkey[:12]
self._make_sub_btn(
f'\U0001f3e0 {short}',
lambda: self._navigate_panel('rooms'),
)
# ── 📚 ARCHIVE (expandable with channel submenu) ──────
with ui.expansion(
'\U0001f4da ARCHIVE', icon=None, value=False,
).props('dense header-class="q-pa-none"').classes('w-full'):
self._archive_sub_container = ui.column().classes('w-full gap-0')
with self._archive_sub_container:
self._make_sub_btn(
'ALL', lambda: self._navigate_panel('archive', channel=None)
)
self._make_sub_btn(
'DM', lambda: self._navigate_panel('archive', channel='DM')
)
# Dynamic channel items populated by _update_submenus
ui.separator().classes('my-1')
# ── Standalone menu items (MAP, DEVICE, ACTIONS, RX LOG)
for icon, label, panel_id in _STANDALONE_ITEMS:
btn = ui.button(
f'{icon} {label}',
on_click=lambda pid=panel_id: self._navigate_panel(pid),
).props('flat no-caps align=left').classes(
'w-full justify-start domca-menu-btn'
).style(_MENU_BTN_STYLE)
self._menu_buttons[panel_id] = btn
ui.separator().classes('my-2')
# External links (same as domca.nl navigation)
with ui.column().style('padding: 0 1.2rem'):
for label, url in _EXT_LINKS:
ui.link(label, url, new_tab=True).classes(
'domca-ext-link'
).style(
"font-family: 'JetBrains Mono', monospace; "
"letter-spacing: 2px; font-size: 0.72rem; "
"text-decoration: none; opacity: 0.6; "
"display: block; padding: 0.35rem 0"
)
# Footer in drawer
ui.space()
ui.label(f'\u00a9 2026 {config.OPERATOR_CALLSIGN}').classes('domca-footer').style('padding: 0 1.2rem 1rem')
# ── Header ────────────────────────────────────────────────
with ui.header().classes('items-center px-4 py-2 shadow-md'):
menu_btn = ui.button(
icon='menu',
on_click=lambda: self._drawer.toggle(),
).props('flat round dense color=white')
# Swap icon: menu ↔ close
self._drawer.on_value_change(
lambda e: menu_btn.props(f'icon={"close" if e.value else "menu"}')
)
ui.label(f'\U0001f517 MeshCore v{config.VERSION}').classes(
'text-lg font-bold ml-2 domca-header-text'
).style("font-family: 'JetBrains Mono', monospace")
# Transport mode badge
_is_ble = config.TRANSPORT == "ble"
_badge_icon = '🔵' if _is_ble else '🟢'
_badge_label = 'BLE' if _is_ble else 'Serial'
ui.label(f'{_badge_icon} {_badge_label}').classes(
'text-xs ml-2 domca-header-text'
).style(
"font-family: 'JetBrains Mono', monospace; "
"opacity: 0.65; letter-spacing: 1px"
)
ui.space()
_initial_status = self._shared.get_snapshot().get('status', 'Starting...')
self._status_label = ui.label(_initial_status).classes(
'text-sm opacity-70 domca-header-text'
)
ui.button(
icon='brightness_6',
on_click=lambda: dark.toggle(),
).props('flat round dense color=white').tooltip('Toggle dark / light')
# ── Main Content Area ─────────────────────────────────────
self._panel_containers = {}
# Landing page (SVG splash from file — visible by default)
landing = ui.column().classes('domca-landing w-full')
with landing:
ui.html(_load_landing_svg())
self._panel_containers['landing'] = landing
# Panel containers (hidden by default, shown on menu click)
panel_defs = [
('messages', self._messages),
('contacts', self._contacts),
('map', self._map),
('device', self._device),
('actions', self._actions),
('rxlog', self._rxlog),
('rooms', self._room_server),
('bbs', self._bbs),
('bot', self._bot),
]
for panel_id, panel_obj in panel_defs:
container = ui.column().classes('domca-panel')
container.set_visibility(False)
with container:
panel_obj.render()
self._panel_containers[panel_id] = container
# Archive panel (inline — replaces separate /archive page)
archive_container = ui.column().classes('domca-panel')
archive_container.set_visibility(False)
with archive_container:
self._archive_page = ArchivePage(self._shared)
self._archive_page.render()
self._panel_containers['archive'] = archive_container
self._active_panel = 'landing'
# Start update timer
self._apply_url_state()
ui.timer(0.5, self._update_ui)
# ------------------------------------------------------------------
# Submenu button helper (layout only)
# ------------------------------------------------------------------
@staticmethod
def _make_sub_btn(label: str, on_click) -> ui.button:
"""Create a submenu button in the drawer."""
return ui.button(
label,
on_click=on_click,
).props('flat no-caps align=left').classes(
'w-full justify-start domca-sub-btn'
).style(_SUB_BTN_STYLE)
@staticmethod
def _make_channel_sub_item(label: str, on_click, on_delete, on_move) -> None:
"""Create a channel submenu item with inline move and delete buttons.
Renders a full-width row containing a navigation button (flex-1),
a compact move button (↕) and a compact delete button (🗑).
Args:
label: Button label text (e.g. '[1] #localmesh').
on_click: Callback for navigating to the channel.
on_delete: Callback for deleting the channel.
on_move: Callback for opening the move/reindex dialog.
"""
with ui.row().classes('w-full gap-0 items-center').style('padding: 0'):
ui.button(
label,
on_click=on_click,
).props('flat no-caps align=left').classes(
'flex-1 justify-start domca-sub-btn'
).style(_SUB_BTN_STYLE)
ui.button(
'↕',
on_click=on_move,
).props('flat dense no-caps').classes(
'domca-sub-btn'
).style(
"font-size: 0.7rem; opacity: 0.45; min-width: 1.6rem; padding: 0.1rem 0.3rem"
)
ui.button(
'🗑',
on_click=on_delete,
).props('flat dense no-caps').classes(
'domca-sub-btn'
).style(
"font-size: 0.7rem; opacity: 0.45; min-width: 1.6rem; padding: 0.1rem 0.3rem"
)
# ------------------------------------------------------------------
# Channel sort helpers (drawer submenu)
# ------------------------------------------------------------------
@staticmethod
def _sort_btn_label(mode: str) -> str:
"""Return the display label for the sort-toggle sub-button.
The label reflects the CURRENT sort mode, so the user sees at a
glance how the list is ordered; clicking the button switches to
the other mode.
Args:
mode: Current sort mode string.
Returns:
A label suitable for passing to :meth:`_make_sub_btn`.
"""
if mode == CHANNEL_SORT_BY_NAME:
return '↕ Sort: name'
return '↕ Sort: index'
def _toggle_channel_sort(self) -> None:
"""Flip the drawer channel sort mode and refresh both submenus.
Persists the new mode via :class:`ChannelSortStore`, invalidates
the submenu fingerprint to force a rebuild, and triggers an
immediate refresh so the user sees the reordered list without
waiting for the next 500 ms dashboard tick.
"""
self._channel_sort_store.toggle_mode()
self._last_channel_fingerprint = None
data = self._shared.get_snapshot()
self._update_submenus(data)
# ------------------------------------------------------------------
# Dynamic submenu updates (layout — called from _update_ui)
# ------------------------------------------------------------------
def _update_submenus(self, data: dict) -> None:
"""Rebuild channel/room submenu items when data changes.
Only the dynamic items are rebuilt; the container is cleared and
ALL items (static + dynamic) are re-rendered.
"""
# ── Channel submenus (Messages + Archive) ──
channels = data.get('channels', [])
sort_mode = self._channel_sort_store.get_mode()
# Sort mode is part of the fingerprint so a user-initiated
# toggle forces a rebuild even when the channel list itself is
# unchanged.
ch_fingerprint = (
tuple((ch['idx'], ch['name']) for ch in channels),
sort_mode,
)
if ch_fingerprint != self._last_channel_fingerprint and channels:
self._last_channel_fingerprint = ch_fingerprint
sorted_channels = sort_channels(channels, sort_mode)
# Rebuild Messages submenu
if self._msg_sub_container:
self._msg_sub_container.clear()
with self._msg_sub_container:
self._make_sub_btn(
'ALL', lambda: self._navigate_panel('messages', channel=None)
)
self._make_sub_btn(
'DM', lambda: self._navigate_panel('messages', channel='DM')
)
self._make_sub_btn(
self._sort_btn_label(sort_mode),
self._toggle_channel_sort,
)
for ch in sorted_channels:
idx = ch['idx']
name = ch['name']
self._make_channel_sub_item(
f"[{idx}] {name}",
on_click=lambda i=idx: self._navigate_panel('messages', channel=i),
on_delete=lambda i=idx, n=name, chs=channels: (
self._open_delete_confirm(i, n, chs)
),
on_move=lambda i=idx: (
self._channel_panel.open(mode='move', preselect_idx=i)
if self._channel_panel else None
),
)
self._make_sub_btn(
'+ Add Channel',
lambda: self._channel_panel.open() if self._channel_panel else None,
)
self._make_sub_btn(
'💾 Backup Channels',
lambda: (
self._channel_backup_panel.open_backup()
if self._channel_backup_panel else None
),
)
self._make_sub_btn(
'📥 Restore Channels',
lambda: (
self._channel_backup_panel.open_restore()
if self._channel_backup_panel else None
),
)
# Rebuild Archive submenu
if self._archive_sub_container:
self._archive_sub_container.clear()
with self._archive_sub_container:
self._make_sub_btn(
'ALL', lambda: self._navigate_panel('archive', channel=None)
)
self._make_sub_btn(
'DM', lambda: self._navigate_panel('archive', channel='DM')
)
self._make_sub_btn(
self._sort_btn_label(sort_mode),
self._toggle_channel_sort,
)
for ch in sorted_channels:
idx = ch['idx']
name = ch['name']
self._make_channel_sub_item(
f"[{idx}] {name}",
on_click=lambda n=name: self._navigate_panel('archive', channel=n),
on_delete=lambda i=idx, n=name, chs=channels: (
self._open_delete_confirm(i, n, chs)
),
on_move=lambda i=idx: (
self._channel_panel.open(mode='move', preselect_idx=i)
if self._channel_panel else None
),
)
# ── Room submenus ──
rooms = self._room_password_store.get_rooms()
rooms_fingerprint = tuple((r.pubkey, r.name) for r in rooms)
if rooms_fingerprint != self._last_rooms_fingerprint:
self._last_rooms_fingerprint = rooms_fingerprint
if self._rooms_sub_container:
self._rooms_sub_container.clear()
with self._rooms_sub_container:
self._make_sub_btn(
'ALL', lambda: self._navigate_panel('rooms')
)
for entry in rooms:
short = entry.name or entry.pubkey[:12]
self._make_sub_btn(
f'\U0001f3e0 {short}',
lambda: self._navigate_panel('rooms'),
)
# ------------------------------------------------------------------
# Panel switching (layout helper — no functional logic)
# ------------------------------------------------------------------
def _apply_url_state(self) -> None:
"""Apply panel selection from URL query params on first render."""
try:
params = ui.context.client.request.query_params
except Exception:
return
panel = params.get('panel') or 'landing'
channel = params.get('channel')
if panel not in self._panel_containers:
panel = 'landing'
channel = None
if panel == 'messages':
if channel is None or channel.lower() == 'all':
channel = None
elif channel.upper() == 'DM':
channel = 'DM'
else:
channel = int(channel) if channel.isdigit() else None
elif panel == 'archive':
if channel is None or channel.lower() == 'all':
channel = None
elif channel.upper() == 'DM':
channel = 'DM'
else:
channel = None
self._show_panel(panel, channel)
def _build_panel_url(self, panel_id: str, channel=None) -> str:
params = {'panel': panel_id}
if channel is not None:
params['channel'] = str(channel)
return '/?' + urlencode(params)
def _navigate_panel(self, panel_id: str, channel=None) -> None:
"""Navigate with panel id in the URL so browser back restores state."""
ui.navigate.to(self._build_panel_url(panel_id, channel))
def _show_panel(self, panel_id: str, channel=None) -> None:
"""Show the selected panel, hide all others, close the drawer.
Args:
panel_id: Panel to show (e.g. 'messages', 'archive', 'rooms').
channel: Optional channel filter.
For messages: None=all, 'DM'=DM only, int=channel idx.
For archive: None=all, 'DM'=DM only, str=channel name.
"""
for pid, container in self._panel_containers.items():
container.set_visibility(pid == panel_id)
self._active_panel = panel_id
# Apply channel filter to messages panel
if panel_id == 'messages' and self._messages:
self._messages.set_active_channel(channel)
# Apply channel filter to archive panel
if panel_id == 'archive' and self._archive_page:
self._archive_page.set_channel_filter(channel)
self._refresh_active_panel_now(force_map_center=(panel_id == 'map'))
# Update active menu highlight (standalone buttons only)
for pid, btn in self._menu_buttons.items():
if pid == panel_id:
btn.classes('domca-menu-active', remove='')
else:
btn.classes(remove='domca-menu-active')
# Close drawer after selection
if self._drawer:
self._drawer.hide()
def _refresh_active_panel_now(self, force_map_center: bool = False) -> None:
"""Refresh only the currently visible panel.
This is used directly after a panel switch so the user does not
need to wait for the next 500 ms dashboard tick.
"""
data = self._shared.get_snapshot()
if data.get('channels'):
self._messages.update_filters(data)
self._messages.update_channel_options(data['channels'])
self._update_submenus(data)
if self._active_panel == 'device':
self._device.update(data)
elif self._active_panel == 'map':
if force_map_center:
data['force_center'] = True
self._map.update(data)
elif self._active_panel == 'actions':
self._actions.update(data)
elif self._active_panel == 'contacts':
self._contacts.update(data)
elif self._active_panel == 'messages':
self._messages.update(
data,
self._messages.channel_filters,
self._messages.last_channels,
room_pubkeys=(
self._room_server.get_room_pubkeys()
if self._room_server else None
),
)
elif self._active_panel == 'rooms':
self._room_server.update(data)
elif self._active_panel == 'rxlog':
self._rxlog.update(data)
elif self._active_panel == 'bbs':
if self._bbs:
self._bbs.update(data)
elif self._active_panel == 'bot':
if self._bot:
self._bot.update(data)
# ------------------------------------------------------------------
# Room Server callback (from ContactsPanel)
# ------------------------------------------------------------------
def _on_add_room_server(self, pubkey: str, name: str, password: str) -> None:
"""Handle adding a Room Server from the contacts panel.
Delegates to the RoomServerPanel which persists the entry,
creates the UI card and sends the login command.
"""
if self._room_server:
self._room_server.add_room(pubkey, name, password)
# ------------------------------------------------------------------
# Channel delete confirmation (dialog + dispatch)
# ------------------------------------------------------------------
def _open_delete_confirm(self, idx: int, name: str, channels: list) -> None:
"""Open the delete confirmation dialog for a channel.
Stores the pending command so ``_on_delete_confirmed`` can dispatch
it without needing to capture mutable closure state.
Args:
idx: Channel index to delete.
name: Channel name (shown in the dialog label).
channels: Current channel list snapshot passed to the handler.
"""
self._pending_delete_cmd = {
'action': 'del_channel',
'idx': idx,
'channels': list(channels),
}
if self._confirm_delete_label:
self._confirm_delete_label.text = (
f'Remove channel [{idx}] "{name}" from the device? '
'Higher-numbered channels will be re-indexed automatically.'
)
if self._confirm_delete_dialog:
self._confirm_delete_dialog.open()
def _on_delete_confirmed(self) -> None:
"""Dispatch the pending delete command and close the dialog."""
if self._confirm_delete_dialog:
self._confirm_delete_dialog.close()
if self._pending_delete_cmd is not None:
self._shared.put_command(self._pending_delete_cmd)
self._pending_delete_cmd = None
# ------------------------------------------------------------------
# Timer-driven UI update
# ------------------------------------------------------------------
def _update_ui(self) -> None:
try:
if not self._status_label:
return
# Atomic snapshot + flag clear: eliminates race condition
# where worker sets channels_updated between separate
# get_snapshot() and clear_update_flags() calls.
data = self._shared.get_snapshot_and_clear_flags()
is_first = not self._initialized
# Mark initialised immediately — even if a panel update
# crashes below, we must NOT retry the full first-render
# path every 500 ms (that causes the infinite rebuild).
if is_first:
self._initialized = True
# Always update status
self._status_label.text = data['status']
# Channel-dependent drawer/submenu state may stay global.
# The helpers below already contain equality checks, so this
# remains cheap while keeping navigation consistent.
if data['channels']:
self._messages.update_filters(data)
self._messages.update_channel_options(data['channels'])
self._update_submenus(data)
if self._channel_panel:
self._channel_panel.update(data)
if self._channel_backup_panel:
self._channel_backup_panel.update(data)
if self._active_panel == 'device':
if data['device_updated'] or is_first:
self._device.update(data)
elif self._active_panel == 'map':
# Keep sending snapshots while the map panel is active.
# The browser runtime coalesces pending payloads, so only
# the newest snapshot is applied.
self._map.update(data)
elif self._active_panel == 'actions':
if data['channels_updated'] or is_first:
self._actions.update(data)
elif self._active_panel == 'contacts':
if data['contacts_updated'] or is_first:
self._contacts.update(data)
elif self._active_panel == 'messages':
self._messages.update(
data,
self._messages.channel_filters,
self._messages.last_channels,
room_pubkeys=(
self._room_server.get_room_pubkeys()
if self._room_server else None
),
)
elif self._active_panel == 'rooms':
self._room_server.update(data)
elif self._active_panel == 'rxlog':
if data['rxlog_updated'] or is_first:
self._rxlog.update(data)
elif self._active_panel == 'bbs':
if self._bbs:
self._bbs.update(data)
elif self._active_panel == 'bot':
if self._bot:
self._bot.update(data)
# Signal worker that GUI is ready for data
if is_first and data['channels'] and data['contacts']:
self._shared.mark_gui_initialized()
except Exception as e:
err = str(e).lower()
if "deleted" not in err and "client" not in err:
import traceback
print(f"GUI update error: {e}")
traceback.print_exc()