mirror of
https://github.com/pe1hvh/meshcore-gui.git
synced 2026-05-05 13:02:27 +02:00
feature: Drawer channel-list sort toggle(#v1.22.0)
This commit is contained in:
58
CHANGELOG.md
58
CHANGELOG.md
@@ -11,7 +11,63 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver
|
||||
---
|
||||
|
||||
|
||||
## [1.21.0] - 2026-04-20
|
||||
## [1.22.0] - 2026-04-21
|
||||
|
||||
### Added
|
||||
- **Drawer channel-list sort toggle** (`services/channel_sort_store.py`,
|
||||
`services/channel_service.py`, `gui/dashboard.py`):
|
||||
The drawer Messages and Archive submenus can now be sorted either by
|
||||
channel index (the native MeshCore order, default) or alphabetically
|
||||
by channel name. The choice is exposed as a single sub-button per
|
||||
submenu labelled `↕ Sort: index` / `↕ Sort: name` and is shared
|
||||
between both submenus so a single click reorders both lists at once.
|
||||
|
||||
- New store `ChannelSortStore` persists the preference to
|
||||
`~/.meshcore-gui/channel_sort.json` so it survives application
|
||||
restarts. The store is global (not per-device) because the sort
|
||||
mode is a pure UI concern.
|
||||
- New pure helper `sort_channels(channels, mode)` in
|
||||
`services/channel_service.py`. The Public channel (`idx == 0`) is
|
||||
always pinned to the top regardless of mode; moving it during an
|
||||
alphabetical sort would be confusing as it is the default broadcast
|
||||
slot. Sort-by-name is case-insensitive (`str.casefold`).
|
||||
- The dashboard submenu fingerprint now includes the sort mode so a
|
||||
user-initiated toggle forces a rebuild even when the channel list
|
||||
itself has not changed. After toggling, the dashboard calls
|
||||
`_update_submenus` immediately with a fresh SharedData snapshot so
|
||||
the reorder is visible without waiting for the next 500 ms tick.
|
||||
|
||||
### Changed
|
||||
- `CHANNEL_SORT_MODE_DEFAULT` added to `config.py` as the factory
|
||||
default for the sort mode (``"index"``). Used by `ChannelSortStore`
|
||||
on first run and when the stored file is missing or contains an
|
||||
invalid value.
|
||||
- `VERSION` bumped `1.21.0` → `1.22.0` (MINOR: new backwards-compatible
|
||||
feature).
|
||||
|
||||
### Impact
|
||||
- No BLE/worker changes; SharedData and the BLE command pipeline are
|
||||
untouched.
|
||||
- The `ChannelPanel` Move/Reindex dropdown is intentionally NOT
|
||||
affected — that list is an admin tool for operators who know which
|
||||
slot they want, not a navigation aid.
|
||||
- The (unused but still present) `FilterPanel` is not touched.
|
||||
- Existing drawer submenu functionality — delete/move per-channel
|
||||
buttons, Add/Backup/Restore buttons, DM and ALL entries — is
|
||||
preserved unchanged.
|
||||
|
||||
### Rationale
|
||||
Operators with a large number of channels reported needing a way to
|
||||
find a specific channel quickly. Index order is fine for small
|
||||
deployments but becomes unwieldy above ~10 channels. Alphabetical
|
||||
order provides a predictable scan path; keeping Public pinned at the
|
||||
top preserves the mental model of "slot 0 is the broadcast channel".
|
||||
Persistence across restarts avoids forcing the user to re-select the
|
||||
preferred order every session.
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
### Added
|
||||
- **Local channel backup & restore** (`services/channel_backup_store.py`,
|
||||
|
||||
@@ -25,7 +25,7 @@ from typing import Any, Dict, List
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
VERSION: str = "1.21.0"
|
||||
VERSION: str = "1.22.0"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -279,7 +279,7 @@ def debug_data(label: str, obj: Any) -> None:
|
||||
|
||||
# Maximum number of channel slots to probe on the device.
|
||||
# MeshCore supports up to 8 channels (indices 0-7).
|
||||
MAX_CHANNELS: int = 100
|
||||
MAX_CHANNELS: int = 255
|
||||
|
||||
# Enable or disable caching of the channel list to disk.
|
||||
# When False (default), channels are always fetched fresh from the
|
||||
@@ -409,6 +409,25 @@ RXLOG_RETENTION_DAYS: int = 7
|
||||
CONTACT_RETENTION_DAYS: int = 90
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# CHANNEL LIST SORT
|
||||
# ==============================================================================
|
||||
|
||||
# Default sort mode for the drawer channel submenus (Messages and Archive).
|
||||
# This value is applied the first time the application is run or whenever
|
||||
# the stored preference file is missing/corrupt; once the user toggles
|
||||
# the sort control the chosen mode is persisted to
|
||||
# ``~/.meshcore-gui/channel_sort.json`` by :class:`ChannelSortStore`.
|
||||
#
|
||||
# Accepted values:
|
||||
# "index" — ascending channel index (native MeshCore order, default)
|
||||
# "name" — case-insensitive alphabetical by channel name
|
||||
#
|
||||
# The Public channel (idx 0) is always rendered at the top regardless
|
||||
# of this setting; see :func:`sort_channels` in services/channel_service.py.
|
||||
CHANNEL_SORT_MODE_DEFAULT: str = "index"
|
||||
|
||||
|
||||
# BBS channel configuration is managed at runtime via BbsConfigStore.
|
||||
# Settings are persisted to ~/.meshcore-gui/bbs/bbs_config.json
|
||||
# and edited through the BBS Settings panel in the GUI.
|
||||
|
||||
@@ -31,6 +31,12 @@ 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
|
||||
|
||||
@@ -317,6 +323,10 @@ class DashboardPage:
|
||||
# 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
|
||||
@@ -668,6 +678,41 @@ class DashboardPage:
|
||||
"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)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -680,10 +725,18 @@ class DashboardPage:
|
||||
"""
|
||||
# ── Channel submenus (Messages + Archive) ──
|
||||
channels = data.get('channels', [])
|
||||
ch_fingerprint = tuple((ch['idx'], ch['name']) for ch in 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:
|
||||
@@ -695,7 +748,11 @@ class DashboardPage:
|
||||
self._make_sub_btn(
|
||||
'DM', lambda: self._navigate_panel('messages', channel='DM')
|
||||
)
|
||||
for ch in channels:
|
||||
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(
|
||||
@@ -738,7 +795,11 @@ class DashboardPage:
|
||||
self._make_sub_btn(
|
||||
'DM', lambda: self._navigate_panel('archive', channel='DM')
|
||||
)
|
||||
for ch in channels:
|
||||
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(
|
||||
|
||||
@@ -14,6 +14,7 @@ import base64
|
||||
import io
|
||||
import os
|
||||
from hashlib import sha256
|
||||
from typing import Dict, List
|
||||
from urllib.parse import urlencode
|
||||
|
||||
|
||||
@@ -135,3 +136,58 @@ def generate_qr_base64(name: str, secret: bytes) -> str:
|
||||
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sorting helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Sort-mode string constants. Exposed here so both the GUI layer and the
|
||||
# :class:`ChannelSortStore` can share the same vocabulary without one
|
||||
# having to import the other.
|
||||
CHANNEL_SORT_BY_INDEX: str = "index"
|
||||
CHANNEL_SORT_BY_NAME: str = "name"
|
||||
|
||||
|
||||
def sort_channels(channels: List[Dict], mode: str) -> List[Dict]:
|
||||
"""Return a new channel list sorted according to ``mode``.
|
||||
|
||||
The Public channel (``idx == 0``) is always pinned to the top of
|
||||
the returned list regardless of the requested mode. Public is the
|
||||
default broadcast slot and moving it would be confusing when the
|
||||
list is used for quick navigation.
|
||||
|
||||
The input list is not mutated; each dict reference is copied across
|
||||
unchanged so the ``idx`` and ``name`` fields — and any other
|
||||
channel metadata — remain coupled.
|
||||
|
||||
Args:
|
||||
channels: Channel dicts as produced by SharedData. Each dict is
|
||||
expected to contain at least an ``idx`` (int) and a
|
||||
``name`` (str).
|
||||
mode: Either :data:`CHANNEL_SORT_BY_INDEX` to keep the
|
||||
ascending-index order (the native MeshCore ordering) or
|
||||
:data:`CHANNEL_SORT_BY_NAME` for case-insensitive
|
||||
alphabetical order. Unknown values fall back to index mode.
|
||||
|
||||
Returns:
|
||||
A new list. The Public channel (if present) is first, followed
|
||||
by the remaining channels in the order dictated by ``mode``.
|
||||
"""
|
||||
if not channels:
|
||||
return []
|
||||
|
||||
public = [ch for ch in channels if ch.get("idx") == 0]
|
||||
rest = [ch for ch in channels if ch.get("idx") != 0]
|
||||
|
||||
if mode == CHANNEL_SORT_BY_NAME:
|
||||
rest_sorted = sorted(
|
||||
rest, key=lambda ch: (ch.get("name") or "").casefold()
|
||||
)
|
||||
else:
|
||||
# SORT_BY_INDEX (default). An explicit sort guarantees a
|
||||
# deterministic order even if the upstream list is not already
|
||||
# ordered by index.
|
||||
rest_sorted = sorted(rest, key=lambda ch: ch.get("idx", 0))
|
||||
|
||||
return public + rest_sorted
|
||||
|
||||
134
meshcore_gui/services/channel_sort_store.py
Normal file
134
meshcore_gui/services/channel_sort_store.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Channel sort preference store for MeshCore GUI.
|
||||
|
||||
Persists the user's choice of drawer channel-list sort order so that it
|
||||
survives application restarts. The preference is a GUI-only concern
|
||||
and is intentionally not stored on the device.
|
||||
|
||||
The sort mode is a single global setting (not per-device): the drawer
|
||||
submenus for Messages and Archive share the same preference, matching
|
||||
the user's mental model of "how do I want to see my channel list".
|
||||
|
||||
Storage location
|
||||
~~~~~~~~~~~~~~~~
|
||||
``~/.meshcore-gui/channel_sort.json``
|
||||
|
||||
Thread safety
|
||||
~~~~~~~~~~~~~
|
||||
All methods use an internal lock for thread-safe operation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from meshcore_gui.config import CHANNEL_SORT_MODE_DEFAULT, debug_print
|
||||
|
||||
# Valid sort-mode values. Kept as module-level constants so callers do
|
||||
# not have to rely on string literals when comparing or dispatching.
|
||||
SORT_BY_INDEX: str = "index"
|
||||
SORT_BY_NAME: str = "name"
|
||||
|
||||
_VALID_MODES = frozenset({SORT_BY_INDEX, SORT_BY_NAME})
|
||||
|
||||
_STORE_DIR = Path.home() / ".meshcore-gui"
|
||||
_STORE_PATH = _STORE_DIR / "channel_sort.json"
|
||||
|
||||
|
||||
class ChannelSortStore:
|
||||
"""Persistent storage for the drawer channel-list sort mode.
|
||||
|
||||
A single sort mode (``"index"`` or ``"name"``) is shared by every
|
||||
drawer channel submenu. The preference is reloaded on instantiation
|
||||
and written to disk on every successful mutation.
|
||||
|
||||
Args:
|
||||
default_mode: Sort mode to use when no stored file exists or the
|
||||
stored value is invalid. Defaults to
|
||||
:data:`~meshcore_gui.config.CHANNEL_SORT_MODE_DEFAULT`.
|
||||
"""
|
||||
|
||||
def __init__(self, default_mode: str = CHANNEL_SORT_MODE_DEFAULT) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._mode: str = (
|
||||
default_mode if default_mode in _VALID_MODES else SORT_BY_INDEX
|
||||
)
|
||||
self._load()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_mode(self) -> str:
|
||||
"""Return the current sort mode.
|
||||
|
||||
Returns:
|
||||
Either :data:`SORT_BY_INDEX` or :data:`SORT_BY_NAME`.
|
||||
"""
|
||||
with self._lock:
|
||||
return self._mode
|
||||
|
||||
def set_mode(self, mode: str) -> None:
|
||||
"""Set and persist the sort mode.
|
||||
|
||||
Invalid values are silently ignored to keep the stored file in a
|
||||
known-good state.
|
||||
|
||||
Args:
|
||||
mode: Must be :data:`SORT_BY_INDEX` or :data:`SORT_BY_NAME`.
|
||||
"""
|
||||
if mode not in _VALID_MODES:
|
||||
debug_print(f"ChannelSortStore: rejecting invalid mode '{mode}'")
|
||||
return
|
||||
with self._lock:
|
||||
if self._mode == mode:
|
||||
return
|
||||
self._mode = mode
|
||||
self._save()
|
||||
debug_print(f"ChannelSortStore: set mode -> {mode}")
|
||||
|
||||
def toggle_mode(self) -> str:
|
||||
"""Flip between the two sort modes and persist the new value.
|
||||
|
||||
Returns:
|
||||
The new sort mode after toggling.
|
||||
"""
|
||||
with self._lock:
|
||||
new_mode = SORT_BY_NAME if self._mode == SORT_BY_INDEX else SORT_BY_INDEX
|
||||
self._mode = new_mode
|
||||
self._save()
|
||||
debug_print(f"ChannelSortStore: toggled mode -> {new_mode}")
|
||||
return new_mode
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Persistence
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load the stored sort mode from disk, if present."""
|
||||
if not _STORE_PATH.exists():
|
||||
debug_print(f"ChannelSortStore: no file at {_STORE_PATH}")
|
||||
return
|
||||
try:
|
||||
data = json.loads(_STORE_PATH.read_text(encoding="utf-8"))
|
||||
stored = data.get("mode")
|
||||
if stored in _VALID_MODES:
|
||||
self._mode = stored
|
||||
debug_print(f"ChannelSortStore: loaded mode '{stored}'")
|
||||
else:
|
||||
debug_print(
|
||||
f"ChannelSortStore: ignoring invalid stored mode '{stored}'"
|
||||
)
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
debug_print(f"ChannelSortStore: load error: {exc}")
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Write the current sort mode to disk."""
|
||||
try:
|
||||
_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_STORE_PATH.write_text(
|
||||
json.dumps({"mode": self._mode}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError as exc:
|
||||
debug_print(f"ChannelSortStore: save error: {exc}")
|
||||
Reference in New Issue
Block a user