diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py
index 42fb73e0..63b48fbe 100755
--- a/modalapi/modhandler.py
+++ b/modalapi/modhandler.py
@@ -167,6 +167,9 @@ def poll_wifi(self):
if wifi_update is not None:
self.wifi_status = wifi_update
self.lcd.update_wifi(self.wifi_status)
+ wifi_menu = getattr(self.lcd, 'wifi_menu', None)
+ if wifi_menu is not None:
+ wifi_menu.notify_status_change()
def poll_system_info(self):
# Get the system state from the systemd service
@@ -375,6 +378,7 @@ def set_current_pedalboard(self, pedalboard):
self.load_current_presets()
self.lcd.link_data(self.pedalboard_list, self.current, self.hardware.footswitches)
self.lcd.draw_main_panel()
+ self.lcd.update_wifi(self.wifi_status)
def bind_current_pedalboard(self):
# "current" being the pedalboard mod-host says is current
@@ -768,9 +772,6 @@ def system_toggle_hotspot(self, **kwargs):
else:
self.wifi_manager.enable_hotspot()
- def configure_wifi_credentials(self, ssid, password):
- return self.wifi_manager.configure_wifi(ssid, password)
-
def audio_parameter_change(self, direction, name, symbol, value, min, max, commit_callback):
if symbol is not None:
d = self.lcd.draw_audio_parameter_dialog(name, symbol, value, min, max, commit_callback)
diff --git a/modalapi/wifi.py b/modalapi/wifi.py
index eccf15c1..0e3ab0f6 100644
--- a/modalapi/wifi.py
+++ b/modalapi/wifi.py
@@ -18,9 +18,64 @@
# Copyright (C) 2017 Vilniaus Blokas UAB, https://blokas.io/pisound
import os
+import re
import threading
import subprocess
import logging
+from typing import Optional, TypedDict
+
+
+class SavedConnection(TypedDict):
+ name: str
+ ssid: str
+ timestamp: int
+
+
+class ScannedNetwork(TypedDict):
+ ssid: str
+ signal: int
+ security: str
+ in_use: bool
+
+
+class WifiStatus(TypedDict, total=False):
+ wifi_supported: bool
+ wifi_connected: bool
+ hotspot_active: bool
+ state: str
+ connection: str
+ ip4_address: str
+ ssid: str
+
+
+def parse_nmcli_error(stderr: Optional[bytes | str]) -> str:
+ """Map a chunk of nmcli stderr to a short user-facing reason."""
+ if stderr is None:
+ return "unknown error"
+ text = stderr.decode('utf-8', errors='replace') if isinstance(stderr, (bytes, bytearray)) else str(stderr)
+ lower = text.lower()
+ if 'secrets were required' in lower or '802-11-wireless-security.psk' in lower or '(7)' in lower:
+ return "auth failed (wrong password)"
+ if 'no network with ssid' in lower or 'no suitable' in lower or 'ssid not found' in lower:
+ return "network not found"
+ if 'ip-config-unavailable' in lower or 'dhcp' in lower:
+ return "couldn't get an IP (DHCP timeout)"
+ if 'timeout' in lower or 'timed out' in lower:
+ return "timed out"
+ if 'not authorized' in lower or 'permission denied' in lower:
+ return "permission denied"
+ # Fall back to first non-empty line, truncated.
+ for line in text.splitlines():
+ line = line.strip()
+ if line:
+ return line[:80]
+ return "unknown error"
+
+
+def _split_terse(line: str) -> list[str]:
+ """Split an nmcli -t terse line, honouring backslash-escaped colons."""
+ return [p.replace('\\:', ':') for p in re.split(r'(? None:
+ self.iface_name: str = ifname
+ self.ssid: Optional[str] = None
+ self.psk: Optional[str] = None
+ self.lock: threading.Lock = threading.Lock()
+ self.last_status: WifiStatus = {}
+ self.changed: bool = False
+ self.stop: threading.Event = threading.Event()
+ self.wireless_supported: bool = False
+ self.wireless_file: str = os.path.join(os.sep, 'sys', 'class', 'net', self.iface_name, 'wireless')
+ self.operstate_file: str = os.path.join(os.sep, 'sys', 'class', 'net', self.iface_name, 'operstate')
self.thread = threading.Thread(target=self._polling_thread, daemon=True).start()
- def __del__(self):
+ def __del__(self) -> None:
logging.info("Wifi monitor cleanup")
self.stop.set()
- self.thread.join()
+ if self.thread is not None:
+ self.thread.join()
- def _is_wifi_supported(self):
+ def _is_wifi_supported(self) -> bool:
# Once we know it's supported, no need to check the file again
if self.wireless_supported:
return True
self.wireless_supported = os.path.exists(self.wireless_file)
return self.wireless_supported
-
- def _is_wifi_connected(self):
+
+ def _is_wifi_connected(self) -> bool:
try:
with open(self.operstate_file) as f:
line = f.readline()
- f.close()
return line.startswith('up')
- except Exception as e:
+ except Exception:
return False
- def _is_hotspot_active(self):
+ def _is_hotspot_active(self) -> bool:
try:
result = subprocess.run(['systemctl', 'is-active', 'wifi-hotspot'], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
- if result.stdout.strip() == 'active':
- return True
- else:
- return False
- except:
+ return result.stdout.strip() == 'active'
+ except Exception:
return False
- return True
- def _get_wpa_status(self, status):
+ def _get_wpa_status(self, status: WifiStatus) -> None:
try:
result = subprocess.run(
['nmcli', '-t', '-f', 'GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,802-11-WIRELESS.SSID',
@@ -88,32 +139,44 @@ def _get_wpa_status(self, status):
stderr=subprocess.PIPE,
text=True
)
- if result.returncode == 0:
- for line in result.stdout.strip().split('\n'):
- if ':' in line:
- key, value = line.split(':', 1)
- # Map nmcli fields to wpa_cli-like keys for compatibility
- key = key.strip().replace('GENERAL.', '').replace('802-11-WIRELESS.', '').replace('.', '_').lower()
- status[key] = value.strip()
except Exception as e:
logging.error("NetworkManager status fail:" + str(e))
+ return
+ if result.returncode != 0:
+ return
+ for line in result.stdout.strip().split('\n'):
+ if ':' not in line:
+ continue
+ key, value = line.split(':', 1)
+ key = key.strip()
+ value = value.strip()
+ if key == 'GENERAL.STATE':
+ status['state'] = value
+ elif key == 'GENERAL.CONNECTION':
+ status['connection'] = value
+ elif key == 'IP4.ADDRESS' or key.startswith('IP4.ADDRESS['):
+ status['ip4_address'] = value
+ elif key == '802-11-WIRELESS.SSID':
+ status['ssid'] = value
- def _polling_thread(self):
+ def _polling_thread(self) -> None:
while True:
- new_status = {}
- new_status['wifi_supported'] = supported = self._is_wifi_supported()
- new_status['wifi_connected'] = connected = self._is_wifi_connected()
- new_status['hotspot_active'] = hp_active = self._is_hotspot_active()
+ new_status: WifiStatus = {}
+ supported = new_status['wifi_supported'] = self._is_wifi_supported()
+ connected = new_status['wifi_connected'] = self._is_wifi_connected()
+ hp_active = new_status['hotspot_active'] = self._is_hotspot_active()
if supported and (connected or hp_active):
self._get_wpa_status(new_status)
if new_status != self.last_status:
logging.debug("Wifi status changed:" + str(new_status))
- creds=()
+ creds: Optional[list[str]] = None
if supported and connected:
- creds = self._acquire_creds()
+ active_conn = new_status.get('connection')
+ if active_conn:
+ creds = self._acquire_creds(active_conn)
self.lock.acquire()
- if supported and connected and creds and len(creds)==2:
+ if supported and connected and creds is not None and len(creds) == 2:
self.ssid = creds[0]
self.psk = creds[1]
self.last_status = new_status
@@ -125,7 +188,7 @@ def _polling_thread(self):
break
# External API
- def poll(self):
+ def poll(self) -> Optional[WifiStatus]:
if self.changed:
logging.debug("wifi poll changed detect !")
# We don't need to do a deep copy because that dictionary content
@@ -139,30 +202,137 @@ def poll(self):
return update
return None
- def enable_hotspot(self):
+ def enable_hotspot(self) -> None:
try:
subprocess.check_output(['sudo', 'systemctl', 'enable', '--now', 'wifi-hotspot']).strip().decode('utf-8')
- except:
+ except Exception:
logging.debug('Wifi hotspot enabling failed')
- def disable_hotspot(self):
+ def disable_hotspot(self) -> None:
try:
subprocess.check_output(['sudo', 'systemctl', 'disable', '--now', 'wifi-hotspot']).strip().decode('utf-8')
- except:
+ except Exception:
logging.debug('Wifi hotspot disabling failed')
- def configure_wifi(self, ssid, password):
- # This changes updates the config (ssid and psk) in the /etc/NetworkManager/system-connections file
- # Bringing the connection up is expected to be done elsewhere (eg. disable_hotspot)
- #
- # TODO check credentials without connecting - problem is reusing wlan0 for hotspot, would prob need separate dev
- # Can be done by making a test conn with a different profile, try to conn, disconnect if success, error if not.
- # nmcli connection add type wifi ifname wlan0 con-name temp-test ssid "SSID" wifi-sec.key-mgmt wpa-psk wifi-sec.psk "Password"
- # nmcli connection up temp-test
- # nmcli connection delete temp-test
- try:
- result = subprocess.check_output([
- 'sudo', 'nmcli', 'connection', 'modify', self.connection_name,
+ def list_connections(self) -> list[SavedConnection]:
+ """Return all saved wifi profiles, excluding the hotspot.
+
+ timestamp is the unix-seconds of last successful activation (0 if never)."""
+ try:
+ result = subprocess.run(
+ ['nmcli', '-t', '-f', 'NAME,TYPE,TIMESTAMP,802-11-WIRELESS.SSID', 'connection', 'show'],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
+ )
+ connections: list[SavedConnection] = []
+ for line in result.stdout.strip().split('\n'):
+ if not line:
+ continue
+ parts = _split_terse(line)
+ if len(parts) >= 2 and parts[1] == '802-11-wireless':
+ name = parts[0]
+ if name == self.HOTSPOT_PROFILE:
+ continue
+ try:
+ timestamp = int(parts[2]) if len(parts) > 2 and parts[2] else 0
+ except ValueError:
+ timestamp = 0
+ ssid = parts[3] if len(parts) > 3 and parts[3] else name
+ connections.append(SavedConnection(name=name, ssid=ssid, timestamp=timestamp))
+ return connections
+ except Exception as e:
+ logging.error("Failed to list wifi connections: " + str(e))
+ return []
+
+ def scan_networks(self) -> list[ScannedNetwork]:
+ """Return nearby networks, deduplicated by SSID (strongest wins), sorted by signal desc.
+
+ Hidden SSIDs are filtered out."""
+ try:
+ result = subprocess.run(
+ ['nmcli', '-t', '-f', 'IN-USE,SSID,SIGNAL,SECURITY', 'dev', 'wifi', 'list',
+ '--rescan', 'auto', 'ifname', self.iface_name],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=15
+ )
+ except Exception as e:
+ logging.error("wifi scan failed: " + str(e))
+ return []
+
+ best: dict[str, ScannedNetwork] = {}
+ for line in result.stdout.strip().split('\n'):
+ if not line:
+ continue
+ parts = _split_terse(line)
+ if len(parts) < 4:
+ continue
+ in_use = parts[0] == '*'
+ ssid = parts[1]
+ if not ssid:
+ continue # hidden network
+ try:
+ signal = int(parts[2])
+ except ValueError:
+ signal = 0
+ security = parts[3]
+ existing = best.get(ssid)
+ if existing is None or signal > existing['signal']:
+ best[ssid] = ScannedNetwork(ssid=ssid, signal=signal,
+ security=security, in_use=in_use)
+ elif in_use:
+ existing['in_use'] = True
+ return sorted(best.values(), key=lambda n: n['signal'], reverse=True)
+
+ def _resolve_unique_name(self, desired: str, exclude: Optional[str] = None) -> str:
+ """Pick a profile name based on `desired`, suffixing (2)/(3)/... if it collides.
+
+ `exclude` is the existing name of a profile being modified (so it doesn't collide with itself)."""
+ existing = {c['name'] for c in self.list_connections()}
+ if exclude is not None:
+ existing.discard(exclude)
+ name = desired
+ counter = 2
+ while name in existing:
+ name = '%s (%d)' % (desired, counter)
+ counter += 1
+ return name
+
+ def add_connection(self, ssid: str, psk: str) -> Optional[bytes]:
+ """Add a new wifi profile. Profile name is the SSID, suffixed if a duplicate exists."""
+ name = self._resolve_unique_name(ssid)
+ try:
+ subprocess.check_output([
+ 'sudo', 'nmcli', 'connection', 'add',
+ 'type', 'wifi', 'ifname', self.iface_name,
+ 'con-name', name,
+ 'ssid', ssid,
+ 'wifi-sec.key-mgmt', 'wpa-psk',
+ 'wifi-sec.psk', psk,
+ 'connection.autoconnect', 'yes'
+ ], stderr=subprocess.STDOUT)
+ return None
+ except subprocess.CalledProcessError as exc:
+ return exc.output
+
+ def delete_connection(self, name: str) -> Optional[bytes]:
+ """Delete a wifi profile by its NM connection name."""
+ try:
+ subprocess.check_output(
+ ['sudo', 'nmcli', 'connection', 'delete', name],
+ stderr=subprocess.STDOUT
+ )
+ return None
+ except subprocess.CalledProcessError as exc:
+ return exc.output
+
+ def configure_wifi(self, name: str, ssid: str, password: str) -> Optional[bytes]:
+ """Update the SSID and PSK for an existing wifi profile.
+
+ Auto-syncs connection.id to the new SSID (with collision suffix), so the display
+ label can never drift from the SSID."""
+ new_name = self._resolve_unique_name(ssid, exclude=name)
+ try:
+ subprocess.check_output([
+ 'sudo', 'nmcli', 'connection', 'modify', name,
+ 'connection.id', new_name,
'802-11-wireless.ssid', ssid,
'802-11-wireless-security.psk', password
], stderr=subprocess.STDOUT)
@@ -170,24 +340,102 @@ def configure_wifi(self, ssid, password):
except subprocess.CalledProcessError as exc:
return exc.output
- def _acquire_creds(self):
+ def connect_scanned(self, ssid: str, psk: Optional[str] = None) -> Optional[bytes]:
+ """Join a network discovered via scan. Creates a profile and activates it atomically.
+
+ On failure nmcli cleans up the partial profile, so this doubles as a credential test."""
+ cmd = ['sudo', 'nmcli', 'dev', 'wifi', 'connect', ssid, 'ifname', self.iface_name]
+ if psk:
+ cmd += ['password', psk]
+ try:
+ subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=45)
+ return None
+ except subprocess.CalledProcessError as exc:
+ return exc.output
+ except subprocess.TimeoutExpired:
+ return b'connection timed out'
+
+ def disconnect(self, name: str) -> Optional[bytes]:
+ """Bring down a saved profile without forgetting it."""
+ try:
+ subprocess.check_output(
+ ['sudo', 'nmcli', 'connection', 'down', name],
+ stderr=subprocess.STDOUT, timeout=20
+ )
+ return None
+ except subprocess.CalledProcessError as exc:
+ return exc.output
+ except subprocess.TimeoutExpired:
+ return b'disconnect timed out'
+
+ def connect_saved(self, name: str) -> Optional[bytes]:
+ """Activate an existing saved profile."""
+ try:
+ subprocess.check_output(
+ ['sudo', 'nmcli', 'connection', 'up', name],
+ stderr=subprocess.STDOUT, timeout=45
+ )
+ return None
+ except subprocess.CalledProcessError as exc:
+ return exc.output
+ except subprocess.TimeoutExpired:
+ return b'connection timed out'
+
+ def replace_psk(self, name: str, psk: str) -> Optional[bytes]:
+ """Update the PSK on a saved profile and validate by activating it.
+
+ On failure the previous PSK is restored so the saved profile keeps working."""
+ old_psk = self.get_psk_for(name)
+ try:
+ subprocess.check_output(
+ ['sudo', 'nmcli', 'connection', 'modify', name,
+ '802-11-wireless-security.psk', psk],
+ stderr=subprocess.STDOUT
+ )
+ except subprocess.CalledProcessError as exc:
+ return exc.output
+
+ err = self.connect_saved(name)
+ if err is not None and old_psk is not None:
+ try:
+ subprocess.check_output(
+ ['sudo', 'nmcli', 'connection', 'modify', name,
+ '802-11-wireless-security.psk', old_psk],
+ stderr=subprocess.STDOUT
+ )
+ logging.info("rolled back PSK on %s after failed connect" % name)
+ except subprocess.CalledProcessError as rollback_exc:
+ logging.error("PSK rollback failed: " + str(rollback_exc.output))
+ return err
+
+ def get_psk_for(self, name: str) -> Optional[str]:
+ """Fetch the stored PSK for a specific wifi profile."""
+ try:
+ result = subprocess.run(
+ ['sudo', 'nmcli', '-s', '-g', '802-11-wireless-security.psk', 'connection', 'show', name],
+ stdout=subprocess.PIPE, text=True
+ )
+ return result.stdout.strip() or None
+ except Exception:
+ return None
+
+ def _acquire_creds(self, connection_name: str) -> Optional[list[str]]:
try:
- # Run the nmcli command and capture the output
result = subprocess.run(
['sudo', 'nmcli', '-s', '-g', '802-11-wireless.ssid,802-11-wireless-security.psk', 'connection',
- 'show', self.connection_name],
+ 'show', connection_name],
stdout=subprocess.PIPE,
text=True
)
fields = result.stdout.split('\n')
if len(fields) == 3:
return fields[:2]
-
- except:
+ except Exception:
logging.debug('Failure running nmcli to get wifi name')
+ return None
- def get_ssid(self):
+ def get_ssid(self) -> Optional[str]:
return self.ssid
- def get_psk(self):
+ def get_psk(self) -> Optional[str]:
return self.psk
diff --git a/pistomp/handler.py b/pistomp/handler.py
index 695dd325..74728f80 100755
--- a/pistomp/handler.py
+++ b/pistomp/handler.py
@@ -89,6 +89,3 @@ def poll_wifi(self):
def system_toggle_hotspot(self, **kw):
raise NotImplementedError()
-
- def configure_wifi_credentials(self, ssid, password):
- raise NotImplementedError()
diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py
index a5b14bc2..79510f57 100644
--- a/pistomp/lcd320x240.py
+++ b/pistomp/lcd320x240.py
@@ -19,6 +19,7 @@
import os
import common.token as Token
import common.parameter as Parameter
+from ui.wifi_menu import WifiMenu
import pistomp.category as Category
import pistomp.lcd as abstract_lcd
import pistomp.switchstate as switchstate
@@ -91,8 +92,6 @@ def __init__(self, cwd, handler=None, flip=False):
# widgets
self.w_wifi = None
- self.w_wifi_ssid = None
- self.w_wifi_pw = None
self.w_eq = None
self.w_power = None
self.w_wrench = None
@@ -116,6 +115,8 @@ def __init__(self, cwd, handler=None, flip=False):
self.pedalboards = {}
+ self.wifi_menu = WifiMenu(self)
+
if not display.has_system_splash:
self.splash_show(True)
@@ -175,7 +176,7 @@ def draw_tools(self, wifi_type=None, eq_type=None, bypass_type=None, system_type
if self.w_wifi is not None:
return
self.w_wifi = ImageWidget(box=Box.xywh(210, 0, 20, 20), image_path=os.path.join(self.imagedir,
- 'wifi_gray.png'), parent=self.main_panel, action=self.draw_wifi_menu)
+ 'wifi_gray.png'), parent=self.main_panel, action=self.wifi_menu.open)
self.main_panel.add_sel_widget(self.w_wifi)
if self.w_eq is not None:
return
@@ -203,55 +204,6 @@ def draw_bypass_preference(self):
pref == Token.LEFT_RIGHT or pref == None)]
self.draw_selection_menu(items, "Bypass Preference", auto_dismiss=True)
- def toggle_hotspot(self, arg1):
- self.pstack.pop_panel(None)
- self.draw_info_message("connecting...")
- self.main_panel.refresh()
- self.handler.system_toggle_hotspot()
- self.draw_info_message("")
- self.main_panel.refresh()
-
- def configure_wifi(self, event, button):
- result = self.handler.configure_wifi_credentials(self.w_wifi_ssid.text, self.w_wifi_pw.text)
-
- # Show Error dialog if configure was not successful
- if result is not None:
- d = MessageDialog(self.pstack, result.decode("utf-8"), title="Error")
- self.pstack.push_panel(d)
- else:
- self.pstack.pop_panel(button.parent)
-
- def draw_wifi_dialog(self, event):
- ssid = self.handler.wifi_manager.get_ssid()
- ssid = ssid if ssid else "None"
- psk = self.handler.wifi_manager.get_psk()
- psk = psk if psk else "None"
-
- d = Dialog(width=240, height=120, auto_destroy=True, title='Configure WiFi')
-
- self.w_wifi_ssid = TextWidget(box=Box.xywh(0, 0, 190, 0), text=ssid, prompt='SSID :', parent=d,
- outline=1, sel_width=3,
- outline_radius=5,
- align=WidgetAlign.NONE, name='cancel_btn',
- edit_message='WiFi SSID')
- d.add_sel_widget(self.w_wifi_ssid)
- self.w_wifi_pw = TextWidget(box=Box.xywh(0, 30, 169, 0), text=psk, prompt='Passwd :', parent=d,
- outline=1,
- sel_width=3, outline_radius=5,
- align=WidgetAlign.NONE, name='cancel_btn',
- edit_message='Password')
- d.add_sel_widget(self.w_wifi_pw)
-
- b = TextWidget(box=Box.xywh(0, 90, 0, 0), text='Cancel', parent=d, outline=1, sel_width=3, outline_radius=5,
- action=lambda x, y: self.pstack.pop_panel(d), align=WidgetAlign.NONE, name='cancel_btn')
- d.add_sel_widget(b)
- b = TextWidget(box=Box.xywh(80, 90, 0, 0), text='Ok', parent=d, outline=1, sel_width=3, outline_radius=5,
- action=self.configure_wifi, align=WidgetAlign.NONE, name='ok_btn')
- d.add_sel_widget(b)
-
- self.pstack.push_panel(d)
- d.refresh()
-
#
# Title (Pedalboard and Preset)
#
@@ -306,16 +258,23 @@ def draw_preset_menu(self, event, widget):
items.append((name, self.handler.preset_change, i))
self.draw_selection_menu(items, "Snapshots", auto_dismiss=True, dismiss_option=True)
- def draw_selection_menu(self, items, title="", auto_dismiss=False, dismiss_option=False):
- # items is list of touples: (item_label, callback_method, callback_arg)
- # The below assumes that the callback takes the menu item label as an argument
+ def draw_selection_menu(self, items, title="", auto_dismiss=False, dismiss_option=False,
+ font=None):
+ # items is a list of tuples: (label, callback, arg) or (label, callback, arg, is_active)
+ # or (label, callback, arg, is_active, long_callback) where long_callback is called
+ # instead of callback on a long press.
def menu_action(event, params):
+ if event == InputEvent.LONG_CLICK and len(params) >= 5 and params[4] is not None:
+ params[4](params[2])
+ return
callback = params[1]
- if callback is not None:
- callback(params[2])
+ if callback is None:
+ return
+ callback(params[2])
+ extra = {} if font is None else {'font': font}
m = Menu(title=title, items=items, auto_destroy=True, default_item=None, max_width=180, max_height=200,
- auto_dismiss=auto_dismiss, dismiss_option=dismiss_option, action=menu_action)
+ auto_dismiss=auto_dismiss, dismiss_option=dismiss_option, action=menu_action, **extra)
self.pstack.push_panel(m)
return m
@@ -573,12 +532,6 @@ def draw_bank_menu(self, event):
items.append((k, self.handler.set_bank, k, k==current_bank))
self.draw_selection_menu(items, "Bank Select", auto_dismiss=True)
- def draw_wifi_menu(self, event, widget):
- label = "Switch to Wifi" if util.DICT_GET(self.handler.wifi_status, 'hotspot_active') else "Switch to Hotspot"
- items = [("Configure WiFi", self.draw_wifi_dialog, None),
- (label, self.toggle_hotspot, None)]
- self.draw_selection_menu(items, "WiFi Menu", dismiss_option = True)
-
def draw_audio_menu(self, event, widget):
items = [("Output Volume", self.handler.system_menu_headphone_volume, None),
("Input Gain", self.handler.system_menu_input_gain, None),
diff --git a/pyproject.toml b/pyproject.toml
index a870238f..7b31c9eb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -56,7 +56,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
-packages = ["modalapi", "pistomp", "uilib", "common"]
+packages = ["modalapi", "pistomp", "uilib", "ui", "common"]
[tool.ruff]
line-length = 120
diff --git a/ui/__init__.py b/ui/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/ui/wifi_menu.py b/ui/wifi_menu.py
new file mode 100644
index 00000000..0d79e580
--- /dev/null
+++ b/ui/wifi_menu.py
@@ -0,0 +1,518 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+import time
+from typing import TYPE_CHECKING, Callable, NotRequired, Optional, Protocol, TypedDict, cast
+
+from PIL import Image as _Image, ImageDraw as _ImageDraw, ImageFont
+
+import common.util as util
+from modalapi.wifi import (
+ SavedConnection,
+ ScannedNetwork,
+ WifiManager,
+ WifiStatus,
+ parse_nmcli_error,
+)
+from uilib import (
+ Box,
+ Config,
+ Dialog,
+ FontWithGlyphs,
+ InputEvent,
+ LetterSelector,
+ MessageDialog,
+ PillGlyph,
+ RoundedPanel,
+ TextWidget,
+ WidgetAlign,
+)
+from uilib.menu import Menu
+
+if TYPE_CHECKING:
+ from pistomp.lcd320x240 import Lcd
+
+
+class _WifiHost(Protocol):
+ """The handler-side surface WifiMenu needs to do its job.
+
+ The concrete handler (`modalapi.modhandler.ModHandler`) satisfies this structurally.
+ """
+ wifi_manager: WifiManager
+ wifi_status: Optional[WifiStatus]
+
+ def system_toggle_hotspot(self) -> None: ...
+
+
+SIGNAL_FILLED = '\u25ae' # ▮
+SIGNAL_EMPTY = '\u25af' # ▯
+ACTIVE_GLYPH = '\u2714' # ✔
+SAVED_GLYPH = '\u2022' # •
+PUBLIC_GLYPH = '\ue001' # PUA sentinel — rendered as pill badge by FontWithGlyphs
+HOTSPOT_ON = '\u25cf' # ●
+HOTSPOT_OFF = '\u25cb' # ○
+SEP = '\u00b7' # ·
+SPLIT = TextWidget.SPLIT_SEP # left/right alignment marker for menu rows
+
+class Row(TypedDict):
+ """A single network line in the wifi menu — saved profile, in-range network, or both.
+
+ `signal`, `security`, and `profile` are present but may be None (e.g. saved-but-out-of-range
+ has no signal/security; in-range-but-unsaved has no profile)."""
+ ssid: str
+ signal: Optional[int]
+ security: Optional[str]
+ saved: bool
+ profile: Optional[SavedConnection]
+ active: bool
+ disambiguator: NotRequired[str]
+
+
+ConnectFn = Callable[[], Optional[bytes]]
+PasswordCallback = Callable[[str], None]
+MenuItem = tuple # (label, callback, arg) or (label, callback, arg, is_active)
+
+
+class _PassphraseEditor(RoundedPanel):
+ """Single-panel passphrase entry that opens directly with the letter selector.
+
+ Skips the intermediate form dialog: Cancel dismisses cleanly; OK with a
+ non-empty passphrase dismisses and calls on_submit.
+ """
+
+ def __init__(self, ssid: str, pstack, on_submit: PasswordCallback) -> None:
+ self._pstack = pstack
+ self._on_submit = on_submit
+ self._curline = ''
+
+ font = ImageFont.truetype("DejaVuSans.ttf", 18)
+ box = Box(0, 0, 300, 80)
+ box = box.centre(pstack.box)
+ super().__init__(box=box, parent=pstack, auto_destroy=True)
+ self.set_outline(2, (255, 255, 255))
+
+ TextWidget(box=Box.xywh(10, 8, 280, 0), text='Password for ' + ssid,
+ font=font, parent=self)
+ self._edit = TextWidget(box=Box.xywh(10, 30, 280, 20),
+ text='\u2588', font=font, parent=self)
+ self._edit.set_background((64, 64, 64))
+ selector = LetterSelector(box=Box.xywh(10, 52, 280, 22), font=font,
+ parent=self, action=self._on_letter)
+ self.add_sel_widget(selector)
+ pstack.push_panel(self)
+ self.refresh()
+
+ def _on_letter(self, event: InputEvent, data: object) -> None:
+ if event == InputEvent.CANCEL:
+ self._pstack.pop_panel(self)
+ return
+ if event == InputEvent.OK:
+ if self._curline:
+ self._pstack.pop_panel(self)
+ self._on_submit(self._curline)
+ return
+ if event == InputEvent.CLEAR:
+ self._curline = ''
+ elif event == InputEvent.BACKSPACE:
+ self._curline = self._curline[:-1]
+ elif event == InputEvent.LETTER:
+ self._curline += str(data)
+ self._edit.set_text(self._curline + '\u2588')
+
+
+def signal_bars(signal: int) -> str:
+ levels = max(1, min(4, (signal + 12) // 25))
+ return SIGNAL_FILLED * levels + SIGNAL_EMPTY * (4 - levels)
+
+
+def format_age(ts: Optional[int]) -> Optional[str]:
+ if not ts:
+ return None
+ age = max(0, int(time.time()) - int(ts))
+ if age < 60:
+ return "now"
+ if age < 3600:
+ return "%dm ago" % (age // 60)
+ if age < 86400:
+ return "%dh ago" % (age // 3600)
+ return "%dd ago" % (age // 86400)
+
+
+def is_open_network(security: Optional[str]) -> bool:
+ return not security or security == '--'
+
+
+def _make_badge_font() -> FontWithGlyphs:
+ base = Config().get_font('default')
+ assert base is not None, "default font not configured"
+ return FontWithGlyphs(base, {PUBLIC_GLYPH: PillGlyph('P')})
+
+
+class WifiMenu:
+ """The wifi panel: scan, join, edit and forget networks; toggle hotspot.
+
+ Owned by Lcd, which exposes the panel stack and `draw_selection_menu`.
+ The menu is opened via `open()`,
+ typically wired to the toolbar wifi icon.
+ """
+
+ def __init__(self, lcd: 'Lcd') -> None:
+ self.lcd: 'Lcd' = lcd
+ self._root_menu: Optional['Menu'] = None
+ self._cached_scanned: list[ScannedNetwork] = []
+ self._cached_saved_by_ssid: dict[str, list[SavedConnection]] = {}
+
+ @property
+ def _host(self) -> _WifiHost:
+ h = self.lcd.handler
+ assert h is not None, "WifiMenu requires lcd.handler to be set"
+ return cast(_WifiHost, h)
+
+ @property
+ def _wifi_manager(self) -> WifiManager:
+ return self._host.wifi_manager
+
+ @property
+ def _wifi_status(self) -> WifiStatus:
+ return self._host.wifi_status or {}
+
+ @property
+ def _pstack(self):
+ return self.lcd.pstack
+
+ # ----- entry points -----
+
+ def open(self, event: object = None, widget: object = None) -> None:
+ # If wifi_supported is missing the host hasn't seen a poll yet — assume
+ # supported so we don't render a stale "Disconnected" surface during
+ # the cold-start window.
+ supported = util.DICT_GET(self._wifi_status, 'wifi_supported')
+ if supported is None:
+ supported = True
+ hotspot_active = bool(util.DICT_GET(self._wifi_status, 'hotspot_active'))
+
+ saved_by_ssid: dict[str, list[SavedConnection]] = {}
+ for c in self._wifi_manager.list_connections():
+ saved_by_ssid.setdefault(c['ssid'], []).append(c)
+
+ scanned: list[ScannedNetwork] = []
+ if supported and not hotspot_active:
+ scanned = self._wifi_manager.scan_networks()
+
+ self._cached_scanned = scanned
+ self._cached_saved_by_ssid = saved_by_ssid
+ self._render_root_menu()
+
+ def _render_root_menu(self) -> None:
+ wifi_status = self._wifi_status
+ hotspot_active = bool(util.DICT_GET(wifi_status, 'hotspot_active'))
+ active_name = util.DICT_GET(wifi_status, 'connection')
+ scanned = self._cached_scanned
+ saved_by_ssid = self._cached_saved_by_ssid
+ scanned_ssids = {n['ssid'] for n in scanned}
+ rows, nearby = self._build_rows(scanned, saved_by_ssid, scanned_ssids, active_name)
+ title = self._title(wifi_status, active_name, scanned)
+ items = self._build_items(rows, nearby, hotspot_active)
+ self._root_menu = self.lcd.draw_selection_menu(items, title, dismiss_option=True)
+
+ def notify_status_change(self) -> None:
+ """Called by the handler after wifi_status is updated.
+
+ Rebuilds the root menu in place so hotspot/active indicators stay
+ current. No-op if the wifi menu isn't the top panel (don't disrupt
+ password prompts, submenus, or unrelated UI).
+ """
+ if self._root_menu is None or self._pstack.current is not self._root_menu:
+ return
+ old = self._root_menu
+ self._root_menu = None
+ self._pstack.pop_panel(old)
+ self._render_root_menu()
+
+ def toggle_hotspot(self, _: object = None) -> None:
+ self._pstack.pop_panel(None)
+ self._mark_disconnected(also_hotspot=True)
+ self._host.system_toggle_hotspot()
+
+ # ----- list assembly -----
+
+ def _build_rows(self,
+ scanned: list[ScannedNetwork],
+ saved_by_ssid: dict[str, list[SavedConnection]],
+ scanned_ssids: set[str],
+ active_name: Optional[str]) -> tuple[list[Row], list[Row]]:
+ """Returns (visible_rows, nearby_unsaved)."""
+ rows: list[Row] = []
+ nearby: list[Row] = []
+
+ # Split in-range scan results into saved (shown in main list) and
+ # unsaved (shown in "Nearby networks..." submenu).
+ for net in scanned:
+ profiles = saved_by_ssid.get(net['ssid'], [])
+ saved_profile = self._pick_profile(profiles, active_name)
+ row: Row = {
+ 'ssid': net['ssid'],
+ 'signal': net['signal'],
+ 'security': net['security'],
+ 'saved': saved_profile is not None,
+ 'profile': saved_profile,
+ 'active': saved_profile is not None and saved_profile['name'] == active_name,
+ }
+ self._maybe_disambiguate(row, profiles)
+ if saved_profile is not None:
+ rows.append(row)
+ else:
+ nearby.append(row)
+ rows.sort(key=lambda r: (not r['active'], -(r['signal'] or 0)))
+ nearby.sort(key=lambda r: -(r['signal'] or 0))
+
+ # All saved profiles not visible in scan, sorted by recency.
+ out_of_range: list[Row] = []
+ for ssid, profiles in saved_by_ssid.items():
+ if ssid in scanned_ssids:
+ continue
+ for profile in profiles:
+ ooo_row: Row = {
+ 'ssid': ssid,
+ 'signal': None,
+ 'security': None,
+ 'saved': True,
+ 'profile': profile,
+ 'active': profile['name'] == active_name,
+ }
+ self._maybe_disambiguate(ooo_row, profiles)
+ out_of_range.append(ooo_row)
+ out_of_range.sort(key=lambda r: -(r['profile']['timestamp'] if r['profile'] else 0))
+ rows.extend(out_of_range)
+ return rows, nearby
+
+ def _build_items(self, rows: list[Row], nearby: list[Row],
+ hotspot_active: bool) -> list[MenuItem]:
+ items: list[MenuItem] = [(self._row_label(r), self._on_network_tap, r, None, self._on_network_long_tap) for r in rows]
+ if nearby:
+ items.append(("Nearby networks...", self._open_nearby_menu, nearby))
+ items.append(("Join other network...", self._open_join_dialog, None))
+ items.append((
+ "Hotspot Mode" + SPLIT + (HOTSPOT_ON if hotspot_active else HOTSPOT_OFF),
+ self.toggle_hotspot, None))
+ return items
+
+ def _title(self, wifi_status: WifiStatus,
+ active_name: Optional[str],
+ scanned: list[ScannedNetwork]) -> str:
+ if util.DICT_GET(wifi_status, 'hotspot_active'):
+ return "WiFi " + SEP + " Hotspot"
+ if active_name:
+ ssid = util.DICT_GET(wifi_status, 'ssid') or active_name
+ for net in scanned:
+ if net['in_use'] or net['ssid'] == ssid:
+ return "WiFi %s %s %s" % (SEP, ssid, signal_bars(net['signal']))
+ return "WiFi %s %s" % (SEP, ssid)
+ return "WiFi " + SEP + " Disconnected"
+
+ def _row_label(self, row: Row) -> str:
+ ssid = row['ssid']
+ if row.get('active'):
+ prefix = ACTIVE_GLYPH + ' '
+ elif row.get('saved'):
+ prefix = SAVED_GLYPH + ' '
+ else:
+ prefix = ''
+ disambiguator = row.get('disambiguator')
+ badge = (' ' + PUBLIC_GLYPH) if (not row.get('saved') and is_open_network(row.get('security'))) else ''
+ left = prefix + ssid + ((' ' + disambiguator) if disambiguator else '') + badge
+ right = signal_bars(row['signal']) if row.get('signal') is not None else ''
+ return left + SPLIT + right
+
+ @staticmethod
+ def _pick_profile(profiles: list[SavedConnection],
+ active_name: Optional[str]) -> Optional[SavedConnection]:
+ if not profiles:
+ return None
+ for p in profiles:
+ if p['name'] == active_name:
+ return p
+ return max(profiles, key=lambda p: p['timestamp'] or 0)
+
+ @staticmethod
+ def _maybe_disambiguate(row: Row, profiles: list[SavedConnection]) -> None:
+ profile = row.get('profile')
+ if row.get('saved') and len(profiles) > 1 and profile is not None:
+ age = format_age(profile.get('timestamp'))
+ if age:
+ row['disambiguator'] = SEP + ' ' + age
+
+ # ----- per-network actions -----
+
+ def _on_network_tap(self, row: Row) -> None:
+ """Root-menu row tap. Routes by row state:
+
+ tap on saved+active → Disconnect/Forget/Replace pw submenu
+ tap on saved+non-active → connect_saved (no prompt — we have the PSK)
+ tap on unsaved → _connect_scanned_flow (stays in nearby on failure)
+ """
+ saved = row.get('saved')
+ if saved:
+ if row.get('active'):
+ self._open_saved_submenu(row, include_disconnect=True)
+ return
+ self._connect_saved(row)
+ return
+ self._connect_scanned_flow(row)
+
+ def _connect_scanned_flow(self, row: Row) -> None:
+ """Connect to a scanned (unsaved) network. One attempt — if it fails,
+ show the error. The user can re-try via the menu."""
+ ssid = row['ssid']
+
+ def attempt(psk: Optional[str]) -> None:
+ self._mark_disconnected()
+ err = self._wifi_manager.connect_scanned(ssid, psk)
+ if err is None:
+ self._pstack.pop_panel(None) # dismiss nearby submenu on success
+ return
+ self._pstack.push_panel(
+ MessageDialog(self._pstack, parse_nmcli_error(err), title="Couldn't connect"))
+
+ if is_open_network(row.get('security')):
+ attempt(None)
+ else:
+ self._open_password_prompt(ssid, attempt)
+
+ def _on_network_long_tap(self, row: Row) -> None:
+ """Long-press on a network row → saved-network submenu."""
+ if row.get('saved'):
+ self._open_saved_submenu(row, include_disconnect=bool(row.get('active')))
+
+ def _open_saved_submenu(self, row: Row, include_disconnect: bool = False) -> None:
+ items: list[MenuItem] = []
+ if include_disconnect:
+ items.append(("Disconnect", self._disconnect, row))
+ items.append(("Replace password", self._open_replace_psk_dialog, row))
+ items.append(("Forget", self._forget, row))
+ self.lcd.draw_selection_menu(items, row['ssid'], dismiss_option=True)
+
+ def _open_nearby_menu(self, nearby: list[Row]) -> None:
+ items: list[MenuItem] = [(self._row_label(r), self._on_network_tap, r) for r in nearby]
+ self.lcd.draw_selection_menu(items, "Nearby Networks", dismiss_option=True,
+ font=_make_badge_font())
+
+ def _connect_saved(self, row: Row) -> None:
+ profile = row['profile']
+ assert profile is not None
+ self._pstack.pop_panel(None)
+ self._mark_disconnected()
+ err = self._wifi_manager.connect_saved(profile['name'])
+ if err is None:
+ return
+ self._pstack.push_panel(
+ MessageDialog(self._pstack, parse_nmcli_error(err), title="Couldn't connect"))
+
+ def _mark_disconnected(self, also_hotspot: bool = False) -> None:
+ """Immediately dim the WiFi toolbar icon to show we are no longer connected.
+
+ The background polling thread will eventually push the authoritative status,
+ but callers should not leave the icon silver while a blocking operation runs.
+ Pass also_hotspot=True when toggling hotspot mode so the icon goes gray
+ rather than staying orange.
+ """
+ status: WifiStatus = {**self._wifi_status,
+ 'wifi_connected': False, 'connection': None, 'ssid': None}
+ if also_hotspot:
+ status['hotspot_active'] = False
+ self._host.wifi_status = status
+ self.lcd.update_wifi(status)
+
+ def _disconnect(self, row: Row) -> None:
+ profile = row['profile']
+ assert profile is not None
+ self._pstack.pop_panel(None)
+ err = self._wifi_manager.disconnect(profile['name'])
+ if err is not None:
+ self._pstack.push_panel(
+ MessageDialog(self._pstack, parse_nmcli_error(err), title="Couldn't disconnect"))
+ return
+ self._mark_disconnected()
+
+ def _connect_with_feedback(self, connect_fn: ConnectFn, ssid: str) -> None:
+ self._pstack.pop_panel(None)
+ self._mark_disconnected()
+ err = connect_fn()
+ if err is None:
+ return
+ self._pstack.push_panel(
+ MessageDialog(self._pstack, parse_nmcli_error(err), title="Couldn't connect"))
+
+ def _open_replace_psk_dialog(self, row: Row) -> None:
+ profile = row['profile']
+ assert profile is not None
+ self._open_password_prompt(row['ssid'],
+ lambda psk: self._connect_with_feedback(
+ lambda: self._wifi_manager.replace_psk(profile['name'], psk),
+ row['ssid']))
+
+ def _forget(self, row: Row) -> None:
+ profile = row['profile']
+ assert profile is not None
+ result = self._wifi_manager.delete_connection(profile['name'])
+ if result is not None:
+ self._pstack.push_panel(
+ MessageDialog(self._pstack, parse_nmcli_error(result), title="Error"))
+ return
+ self._pstack.pop_panel(None)
+ if self._root_menu is not None:
+ self._pstack.pop_panel(self._root_menu)
+ self.open()
+
+ # ----- dialogs -----
+
+ def _open_password_prompt(self, ssid: str, on_submit: PasswordCallback) -> None:
+ _PassphraseEditor(ssid, self._pstack, on_submit)
+
+ def _open_join_dialog(self, _: object = None) -> None:
+ d = Dialog(width=240, height=120, auto_destroy=True, title='Join other network')
+ ssid_w = TextWidget(box=Box.xywh(0, 0, 190, 0), text='', prompt='SSID :', parent=d,
+ outline=1, sel_width=3, outline_radius=5,
+ align=WidgetAlign.NONE, name='ssid_field',
+ edit_message='WiFi SSID')
+ d.add_sel_widget(ssid_w)
+ pw_w = TextWidget(box=Box.xywh(0, 30, 169, 0), text='', prompt='Passwd :', parent=d,
+ outline=1, sel_width=3, outline_radius=5,
+ align=WidgetAlign.NONE, name='pw_field',
+ edit_message='Password')
+ d.add_sel_widget(pw_w)
+
+ cancel = TextWidget(box=Box.xywh(0, 90, 0, 0), text='Cancel', parent=d,
+ outline=1, sel_width=3, outline_radius=5,
+ action=lambda x, y: self._pstack.pop_panel(d),
+ align=WidgetAlign.NONE, name='cancel_btn')
+ d.add_sel_widget(cancel)
+
+ def submit(_event, _button):
+ ssid = ssid_w.text
+ psk = pw_w.text
+ if not ssid:
+ return
+ self._pstack.pop_panel(d)
+ self._connect_with_feedback(
+ lambda: self._wifi_manager.connect_scanned(ssid, psk or None),
+ ssid)
+
+ ok = TextWidget(box=Box.xywh(80, 90, 0, 0), text='Ok', parent=d,
+ outline=1, sel_width=3, outline_radius=5,
+ action=submit, align=WidgetAlign.NONE, name='ok_btn')
+ d.add_sel_widget(ok)
+ self._pstack.push_panel(d)
+ d.refresh()
diff --git a/uilib/__init__.py b/uilib/__init__.py
index 4b2a8540..ac7f6501 100644
--- a/uilib/__init__.py
+++ b/uilib/__init__.py
@@ -14,6 +14,7 @@
# along with pi-stomp. If not, see .
from uilib.misc import *
+from uilib.font_with_glyphs import *
from uilib.box import *
from uilib.widget import *
from uilib.container import *
diff --git a/uilib/container.py b/uilib/container.py
index 443be4bd..5afaeb66 100644
--- a/uilib/container.py
+++ b/uilib/container.py
@@ -87,7 +87,7 @@ def _unfocus(self, box):
def _compose(self, widget, orig_box, real_box):
assert isinstance(widget, ContainerWidget)
- real_box.deoffset(self.offset)
+ real_box.deoffset(self.offset) # XXX: result is discarded
# Crop real box to this image box. This avoids trying to copy pixels
# that are outside of it
diff --git a/uilib/font_with_glyphs.py b/uilib/font_with_glyphs.py
new file mode 100644
index 00000000..f1cccda7
--- /dev/null
+++ b/uilib/font_with_glyphs.py
@@ -0,0 +1,142 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+from typing import Generator, Protocol, runtime_checkable
+
+from PIL import Image, ImageDraw, ImageFont
+
+
+@runtime_checkable
+class Glyph(Protocol):
+ def width(self, font_height: int) -> int: ...
+ def draw(self, draw: ImageDraw.ImageDraw, x: int, y: int, font_height: int, color) -> None: ...
+
+
+class PillGlyph:
+ """Rounded-rectangle badge with a short label rendered in a small font."""
+
+ def __init__(self, label: str, font_size: int = 9) -> None:
+ self._label = label
+ self._font = ImageFont.truetype("DejaVuSans.ttf", font_size)
+ bb = self._font.getbbox(label)
+ self._text_w = int(bb[2] - bb[0])
+ self._text_h = int(bb[3] - bb[1])
+ self._text_bb = bb
+
+ def width(self, font_height: int) -> int:
+ return self._text_w + 8 + 2 # +2 for 1px breathing on each side
+
+ def draw(self, draw: ImageDraw.ImageDraw, x: int, y: int, font_height: int, color) -> None:
+ bw = self.width(font_height) - 2 # pill body excludes breathing pixels
+ bx = x + 1 # 1px left breathing
+ bh = self._text_h + 4
+ by = y + max(0, (font_height - bh) // 2)
+ draw.rounded_rectangle([bx, by, bx + bw - 1, by + bh - 1],
+ radius=2, fill=color, outline=None)
+ tx = bx + (bw - self._text_w) // 2 - int(self._text_bb[0])
+ ty = by + 2 - int(self._text_bb[1])
+ draw.text((tx, ty), self._label, fill=0, font=self._font)
+
+
+class FontWithGlyphs:
+ """Font wrapper that renders custom glyphs for specified sentinel characters.
+
+ All other rendering delegates transparently to the wrapped base font, so
+ regular text draws exactly as it would with the base font alone.
+
+ Usage::
+
+ font = FontWithGlyphs(base_font, {MY_CHAR: PillGlyph('P')})
+ draw_selection_menu(items, font=font)
+ """
+
+ def __init__(self, base: ImageFont.FreeTypeFont, glyphs: dict[str, Glyph]) -> None:
+ self._base = base
+ self._glyphs = glyphs
+ self._ascent, self._descent = base.getmetrics()
+ self._font_height = self._ascent + self._descent
+
+ # --- PIL font interface ---
+
+ def getmetrics(self) -> tuple[int, int]:
+ return self._base.getmetrics()
+
+ def getbbox(self, text: str, *args, **kwargs) -> tuple[int, int, int, int]:
+ if not any(c in text for c in self._glyphs):
+ bb = self._base.getbbox(text, *args, **kwargs)
+ return (int(bb[0]), int(bb[1]), int(bb[2]), int(bb[3]))
+
+ total_w = 0
+ min_top = 0
+ max_bottom = 0
+ for segment, glyph in self._iter_segments(text):
+ if glyph is not None:
+ total_w += glyph.width(self._font_height)
+ max_bottom = max(max_bottom, self._font_height)
+ elif segment:
+ bb = self._base.getbbox(segment, *args, **kwargs)
+ total_w += int(bb[2]) - int(bb[0])
+ min_top = min(min_top, int(bb[1]))
+ max_bottom = max(max_bottom, int(bb[3]))
+ return (0, min_top, total_w, max_bottom)
+
+ def getmask2(self, text: str, mode: str = '', **kwargs) -> tuple:
+ if not any(c in text for c in self._glyphs):
+ return self._base.getmask2(text, mode, **kwargs)
+
+ img = Image.new('L', (max(self._measure_width(text), 1), self._font_height), 0)
+ draw = ImageDraw.Draw(img)
+ x = 0
+ for segment, glyph in self._iter_segments(text):
+ if glyph is not None:
+ glyph.draw(draw, x, 0, self._font_height, 255)
+ x += glyph.width(self._font_height)
+ elif segment:
+ # draw.text applies the font's internal offset automatically,
+ # so vertical positioning matches normal single-string rendering.
+ draw.text((x, 0), segment, fill=255, font=self._base)
+ bb = self._base.getbbox(segment)
+ x += int(bb[2]) - int(bb[0])
+ return img.im, (0, 0)
+
+ def __getattr__(self, name: str):
+ return getattr(self._base, name)
+
+ # --- helpers ---
+
+ def _iter_segments(self, text: str) -> Generator[tuple[str, 'Glyph | None'], None, None]:
+ """Yield (segment_str, glyph_or_None) pairs for each run in text."""
+ buf = ''
+ for ch in text:
+ glyph = self._glyphs.get(ch)
+ if glyph is not None:
+ if buf:
+ yield buf, None
+ buf = ''
+ yield ch, glyph
+ else:
+ buf += ch
+ if buf:
+ yield buf, None
+
+ def _measure_width(self, text: str) -> int:
+ total = 0
+ for segment, glyph in self._iter_segments(text):
+ if glyph is not None:
+ total += glyph.width(self._font_height)
+ elif segment:
+ bb = self._base.getbbox(segment)
+ total += int(bb[2]) - int(bb[0])
+ return total
diff --git a/uilib/menu.py b/uilib/menu.py
index bd5bb5db..daa57813 100644
--- a/uilib/menu.py
+++ b/uilib/menu.py
@@ -16,6 +16,7 @@
from uilib.dialog import *
from uilib.config import *
+
class Menu(Dialog):
"""A pop-up menu panel with lines of text to select
items : iterable of tuples whose first element is the text to display
@@ -45,7 +46,7 @@ def __init__(self, items, font = None, max_width = None, max_height = None,
for i in items:
# item structure: 0:name, 1:action, 2:object, 3:selected item
t = i[0]
- if len(i) == 4 and i[3]:
+ if len(i) >= 4 and i[3]:
t = '\u2714 ' + t # Add checkmark to selected item
b = Box.xywh(0,h,self.box.width,self.item_h)
w = TextWidget(box = b, text_halign = self.text_halign, font = self.font,
diff --git a/uilib/panel.py b/uilib/panel.py
index ddad8bfc..3ea149df 100644
--- a/uilib/panel.py
+++ b/uilib/panel.py
@@ -204,7 +204,9 @@ def poll_updates(self):
def _compose(self, widget, orig_box, real_box):
# This always called with widget = a Panel which is a direct
# child of the stack, so we can drop orig_box
- self._do_refresh(widget, real_box)
+ real_box = real_box.intersection(self.box.norm())
+ if not real_box.is_empty():
+ self._do_refresh(widget, real_box)
def refresh(self):
self._do_refresh(None, self.box)
diff --git a/uilib/text.py b/uilib/text.py
index 277b4421..a53ab59b 100644
--- a/uilib/text.py
+++ b/uilib/text.py
@@ -220,6 +220,10 @@ def _adjust_box(self):
return
h_margin, v_margin = self._get_margins()
tw, th = self._get_text_size()
+ # For height, always use at least a full line height so empty-text
+ # widgets don't collapse to near-zero.
+ ascent, descent = self.font_metrics
+ th = max(th, ascent + descent)
# Add outline to account for PIL rectangles being "inset"
extra = self.outline
trace(self, "margins=", h_margin, v_margin, "text_size=", tw, th)
@@ -246,6 +250,8 @@ def set_font(self, font):
self.text_size_valid = False
self.refresh()
+ SPLIT_SEP = '\u001F' # if present in text exactly once, render as left + right halves
+
def _draw(self, image, draw, real_box):
# Draw text
#
@@ -254,12 +260,29 @@ def _draw(self, image, draw, real_box):
# ContainerWidget subclass ? For now assume it fits ...
#
h_margin, v_margin = self._get_margins()
- tw, th = self._get_text_size()
extra = self.outline
hroom = real_box.width - h_margin - extra
vroom = real_box.height - v_margin - extra
if hroom < 0 or vroom < 0:
return
+
+ if self.SPLIT_SEP in self.text:
+ parts = self.text.split(self.SPLIT_SEP)
+ if len(parts) != 2:
+ raise ValueError("TextWidget split text must contain exactly one separator")
+ left, right = parts
+ lw, lh = get_text_size(left, self.font, self.font_metrics)
+ rw, rh = get_text_size(right, self.font, self.font_metrics)
+ th = max(lh, rh)
+ if th > vroom:
+ th = vroom
+ y = real_box.y0 + v_margin
+ draw.text((real_box.x0 + h_margin, y), left, fill=self.fgnd_color, font=self.font)
+ draw.text((real_box.x0 + real_box.width - h_margin - extra - rw, y),
+ right, fill=self.fgnd_color, font=self.font)
+ return
+
+ tw, th = self._get_text_size()
if tw > hroom:
tw = hroom
if th > vroom: