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: