""" 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 ( '' 'Landing SVG not found: {path.name}' '' ) # ── 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()