From 338bcea80031053a9c8dbedd9ad10083f383651d Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Sat, 1 Nov 2025 14:13:37 -0700 Subject: [PATCH 01/18] Initial icons --- badge/apps/menu/folder_icon.png | Bin 0 -> 138 bytes badge/apps/menu/folder_up_icon.png | Bin 0 -> 195 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 badge/apps/menu/folder_icon.png create mode 100644 badge/apps/menu/folder_up_icon.png diff --git a/badge/apps/menu/folder_icon.png b/badge/apps/menu/folder_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..25b3123af2d948415925a64aadec46748e7162d4 GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gjfu1goAr*6y6C_v{Hv}E%IAdwp z`_SI*s95BRSDlPHFPNUC-4LB;bJRgBAxXl=(S@slRsAB6a^Ulu#V&Yx1#8QJ3<2I6 jfsFyH&q%U1i!(5=yy4!$9Q-5{XgGtXtDnm{r-UW|Z#OB$ literal 0 HcmV?d00001 diff --git a/badge/apps/menu/folder_up_icon.png b/badge/apps/menu/folder_up_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..53058e3ba658eace42b73c353c30261261dcd9c8 GIT binary patch literal 195 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjU7jwEAr*6y6C_v<%NZP!P+w;F z=lIeSO56+kLJl;YF*H9MQt(NH*|niF(SSv8BSUn+BR-|a|9N=QJNc_(96N109NLsG zH(T<}FmSmLoBL<}DX}@JDIUJA8zenC*mD?ECtY-W#q%h}-nx0B4R_jkwNo{0ZJT+P sL^6~K%K0{HiEa$od`FU1S%iV%;8dBe#O13Lfv#fkboFyt=akR{0Gpvf-T(jq literal 0 HcmV?d00001 From 4dfcd6048e55e0b614d0af1675c07387a0c9ebcf Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Sat, 1 Nov 2025 14:47:48 -0700 Subject: [PATCH 02/18] Initial plan --- subfolder-support-plan.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 subfolder-support-plan.md diff --git a/subfolder-support-plan.md b/subfolder-support-plan.md new file mode 100644 index 0000000..ba2bcf4 --- /dev/null +++ b/subfolder-support-plan.md @@ -0,0 +1,13 @@ +## Plan: Menu Subfolders Rollout + +Add folder-aware discovery, icons, and navigation so the launcher can browse nested directories with a fixed back entry and alphabetical grouping while keeping the firmware testable after each incremental change. + +**Steps 5:** +1. Refactor listing in [`__init__.py`](badge/apps/menu/__init__.py) to scan current path, tag entries, and sort apps then folders alphabetically. +2. Stage new assets `folder_icon.png` and `folder_up_icon.png` under [`menu`](badge/apps/menu/) without altering runtime behavior. +3. Map entry types to sprites in [`icon.py`](badge/apps/menu/icon.py) and adjust `load_page_icons` in [`__init__.py`](badge/apps/menu/__init__.py) to pin the back icon at slot 0 when depth > 0. +4. Extend navigation state in [`__init__.py`](badge/apps/menu/__init__.py) (path stack, selection offsets, `io.BUTTON_HOME` reset) so A enters folders, B launches apps, and HOME returns to root. +5. Validate after each commit on hardware: post-refactor flat menu, folder/back icon rendering, then full navigation with HOME-to-root. + +**Open Questions 1:** +1. None. From ebc0727e0ab5547ef0136a4d2a9154b3eb978c61 Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Sat, 1 Nov 2025 14:53:05 -0700 Subject: [PATCH 03/18] Update instructions --- .github/copilot-instructions.md | 18 ++++++++ subfolder-support-plan.md | 82 +++++++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b7a35a2..e9846c6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,6 +4,8 @@ This project is for developing applications on the GitHub Universe 2025 hackable ## Project Structure +**Default launcher structure:** + ``` / ├── badge/ # Badge firmware and apps (deployed to /system/ on device) @@ -24,6 +26,22 @@ This project is for developing applications on the GitHub Universe 2025 hackable └── README.md ``` +**With menu subfolder support enabled:** + +``` +badge/apps/ +├── games/ # Folder without __init__.py; appears as a submenu +│ ├── flappy/ # App (has __init__.py, icon.png) +│ └── snake/ +├── utilities/ +│ ├── wifi/ +│ └── weather/ +├── menu/ # Launcher logic (hidden from menu list) +└── startup/ # Boot animation (hidden from menu list) +``` + +When this structure is active, the launcher treats any directory lacking an `__init__.py` as a navigable folder, pins a back entry at the first slot inside subfolders, and continues to hide the `menu` and `startup` apps from the user-facing list. + ## Hardware Specifications - **Processor**: RP2350 Dual-core ARM Cortex-M33 @ 200MHz diff --git a/subfolder-support-plan.md b/subfolder-support-plan.md index ba2bcf4..43c2088 100644 --- a/subfolder-support-plan.md +++ b/subfolder-support-plan.md @@ -3,11 +3,85 @@ Add folder-aware discovery, icons, and navigation so the launcher can browse nested directories with a fixed back entry and alphabetical grouping while keeping the firmware testable after each incremental change. **Steps 5:** -1. Refactor listing in [`__init__.py`](badge/apps/menu/__init__.py) to scan current path, tag entries, and sort apps then folders alphabetically. -2. Stage new assets `folder_icon.png` and `folder_up_icon.png` under [`menu`](badge/apps/menu/) without altering runtime behavior. +1. Refactor listing in [`__init__.py`](badge/apps/menu/__init__.py) to scan the active directory, tag each entry, and sort folders before apps alphabetically. Keep to MicroPython-friendly calls (`os.listdir`, `badgeware.is_dir`, `badgeware.file_exists`). + ```python + def scan_entries(root): + entries = [] + try: + for name in os.listdir(root): + if name.startswith("."): + continue + full_path = "{}/{}".format(root, name) + if is_dir(full_path): + has_module = file_exists("{}/__init__.py".format(full_path)) + kind = "app" if has_module else "folder" + entries.append({"name": name, "path": full_path, "kind": kind}) + except OSError as exc: + print("scan_entries failed for {}: {}".format(root, exc)) + folders = [item for item in entries if item["kind"] == "folder"] + apps = [item for item in entries if item["kind"] == "app"] + folders.sort(key=lambda item: item["name"]) + apps.sort(key=lambda item: item["name"]) + return folders + apps + ``` +2. Stage new assets `folder_icon.png` and `folder_up_icon.png` under [`menu`](badge/apps/menu/) without altering runtime behavior. Use 24x24 PNGs so the existing `Image.load` calls continue working on-device. 3. Map entry types to sprites in [`icon.py`](badge/apps/menu/icon.py) and adjust `load_page_icons` in [`__init__.py`](badge/apps/menu/__init__.py) to pin the back icon at slot 0 when depth > 0. -4. Extend navigation state in [`__init__.py`](badge/apps/menu/__init__.py) (path stack, selection offsets, `io.BUTTON_HOME` reset) so A enters folders, B launches apps, and HOME returns to root. -5. Validate after each commit on hardware: post-refactor flat menu, folder/back icon rendering, then full navigation with HOME-to-root. + ```python + ICON_SPRITES = { + "app": "/system/apps/menu/default_icon.png", + "folder": "/system/apps/menu/folder_icon.png", + "back": "/system/apps/menu/folder_up_icon.png", + } + + def sprite_for(entry): + path = ICON_SPRITES.get(entry["kind"], ICON_SPRITES["app"]) + try: + return Image.load(path) + except OSError: + return Image.load(ICON_SPRITES["app"]) + ``` + In `load_page_icons`, insert a synthetic `{"kind": "back", "name": "..", "path": current_path}` entry at index 0 whenever the depth is greater than zero, then pad the grid as usual. +4. Extend navigation state in [`__init__.py`](badge/apps/menu/__init__.py) (path stack, selection offsets, `io.BUTTON_HOME` reset) so A enters folders, B launches apps, and HOME returns to root. Preserve pagination behavior and reuse the scanning helper for each stack change. + ```python + ROOT = "/system/apps" + path_stack = [] + current_entries = scan_entries(ROOT) + cursor_index = 0 + + def enter_folder(entry): + global current_entries, cursor_index + path_stack.append(entry["path"]) + current_entries = [{"name": "..", "path": entry["path"], "kind": "back"}] + current_entries.extend(scan_entries(entry["path"])) + cursor_index = 0 + + def leave_folder(): + global current_entries, cursor_index + if path_stack: + path_stack.pop() + active_path = ROOT if not path_stack else path_stack[-1] + current_entries = scan_entries(active_path) + cursor_index = 0 + + def handle_input(): + global cursor_index + if io.BUTTON_HOME in io.pressed: + while path_stack: + leave_folder() + return None + if io.BUTTON_A in io.pressed: + entry = current_entries[cursor_index] + if entry["kind"] == "folder": + enter_folder(entry) + elif entry["kind"] == "back": + leave_folder() + if io.BUTTON_B in io.pressed: + entry = current_entries[cursor_index] + if entry["kind"] == "app": + return entry["path"] + return None + ``` +5. Validate after each commit on hardware: first ensure the refactor keeps flat menu behavior, then verify folder/back icon rendering with pagination, and finally confirm full navigation (A-to-enter, B-to-launch, HOME-to-root) matches expectations. **Open Questions 1:** 1. None. From 94752928685d49e8f694d3f2ad7c3ac9930e1901 Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Sun, 2 Nov 2025 20:42:59 -0800 Subject: [PATCH 04/18] menu: add folder-aware scan helper --- badge/apps/menu/__init__.py | 48 ++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/badge/apps/menu/__init__.py b/badge/apps/menu/__init__.py index 64bb349..4a5019a 100755 --- a/badge/apps/menu/__init__.py +++ b/badge/apps/menu/__init__.py @@ -13,20 +13,35 @@ screen.font = PixelFont.load("/system/assets/fonts/ark.ppf") # screen.antialias = Image.X2 +ROOT = "/system/apps" + + +def scan_entries(root): + entries = [] + try: + for name in os.listdir(root): + if name.startswith("."): + continue + full_path = "{}/{}".format(root, name) + if is_dir(full_path): + has_module = file_exists("{}/__init__.py".format(full_path)) + kind = "app" if has_module else "folder" + entries.append({"name": name, "path": full_path, "kind": kind}) + except OSError as exc: + print("scan_entries failed for {}: {}".format(root, exc)) + folders = [item for item in entries if item["kind"] == "folder"] + apps_only = [item for item in entries if item["kind"] == "app"] + folders.sort(key=lambda item: item["name"]) + apps_only.sort(key=lambda item: item["name"]) + return folders + apps_only + + # Auto-discover apps with __init__.py -apps = [] -try: - for entry in os.listdir("/system/apps"): - app_path = f"/system/apps/{entry}" - if is_dir(app_path): - has_init = file_exists(f"{app_path}/__init__.py") - if has_init: - # Skip menu and startup apps - if entry not in ("menu", "startup"): - # Use directory name as display name - apps.append((entry, entry)) -except Exception as e: - print(f"Error discovering apps: {e}") +apps = [ + (entry["name"], entry["name"]) + for entry in scan_entries(ROOT) + if entry["kind"] == "app" and entry["name"] not in ("menu", "startup") +] # Pagination constants APPS_PER_PAGE = 6 @@ -43,14 +58,15 @@ def load_page_icons(page): app = apps[i] name, path = app[0], app[1] - if is_dir(f"/system/apps/{path}"): + app_path = "{}/{}".format(ROOT, path) + if is_dir(app_path): icon_idx = i - start_idx x = icon_idx % 3 y = math.floor(icon_idx / 3) pos = (x * 48 + 33, y * 48 + 42) try: # Try to load app-specific icon, fall back to default - icon_path = f"/system/apps/{path}/icon.png" + icon_path = "{}/icon.png".format(app_path) if not file_exists(icon_path): icon_path = "/system/apps/menu/default_icon.png" sprite = Image.load(icon_path) @@ -104,7 +120,7 @@ def update(): if io.BUTTON_B in io.pressed: app_idx = current_page * APPS_PER_PAGE + active if app_idx < len(apps): - app_path = f"/system/apps/{apps[app_idx][1]}" + app_path = "{}/{}".format(ROOT, apps[app_idx][1]) try: # Verify the app still exists before launching if is_dir(app_path) and file_exists(f"{app_path}/__init__.py"): From 3623728453c820813a54435054c6f94f02f4f6d6 Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Sun, 2 Nov 2025 20:58:00 -0800 Subject: [PATCH 05/18] menu: map entry types to sprites --- badge/apps/menu/__init__.py | 70 +++++++++++++++++++++++-------------- badge/apps/menu/icon.py | 23 +++++++++++- 2 files changed, 66 insertions(+), 27 deletions(-) diff --git a/badge/apps/menu/__init__.py b/badge/apps/menu/__init__.py index 4a5019a..8699245 100755 --- a/badge/apps/menu/__init__.py +++ b/badge/apps/menu/__init__.py @@ -6,7 +6,7 @@ import math from badgeware import screen, PixelFont, Image, SpriteSheet, is_dir, file_exists, shapes, brushes, io, run -from icon import Icon +from icon import Icon, sprite_for import ui mona = SpriteSheet("/system/assets/mona-sprites/mona-default.png", 11, 1) @@ -14,6 +14,8 @@ # screen.antialias = Image.X2 ROOT = "/system/apps" +current_path = ROOT +current_depth = 0 def scan_entries(root): @@ -26,7 +28,12 @@ def scan_entries(root): if is_dir(full_path): has_module = file_exists("{}/__init__.py".format(full_path)) kind = "app" if has_module else "folder" - entries.append({"name": name, "path": full_path, "kind": kind}) + entry = {"name": name, "path": full_path, "kind": kind} + if kind == "app": + icon_path = "{}/icon.png".format(full_path) + if file_exists(icon_path): + entry["icon"] = icon_path + entries.append(entry) except OSError as exc: print("scan_entries failed for {}: {}".format(root, exc)) folders = [item for item in entries if item["kind"] == "folder"] @@ -49,33 +56,44 @@ def scan_entries(root): total_pages = max(1, math.ceil(len(apps) / APPS_PER_PAGE)) # find installed apps and create icons for current page -def load_page_icons(page): +def load_page_icons(page, depth=0, active_path=ROOT): icons = [] - start_idx = page * APPS_PER_PAGE - end_idx = min(start_idx + APPS_PER_PAGE, len(apps)) - + page_size = APPS_PER_PAGE - 1 if depth > 0 else APPS_PER_PAGE + if page_size <= 0: + page_size = 1 + + start_idx = page * page_size + end_idx = min(start_idx + page_size, len(apps)) + + entries = [] + + if depth > 0: + entries.append({"name": "..", "path": active_path, "kind": "back"}) + for i in range(start_idx, end_idx): app = apps[i] - name, path = app[0], app[1] - - app_path = "{}/{}".format(ROOT, path) - if is_dir(app_path): - icon_idx = i - start_idx - x = icon_idx % 3 - y = math.floor(icon_idx / 3) - pos = (x * 48 + 33, y * 48 + 42) - try: - # Try to load app-specific icon, fall back to default - icon_path = "{}/icon.png".format(app_path) - if not file_exists(icon_path): - icon_path = "/system/apps/menu/default_icon.png" - sprite = Image.load(icon_path) - icons.append(Icon(pos, name, icon_idx % APPS_PER_PAGE, sprite)) - except Exception as e: - print(f"Error loading icon for {path}: {e}") + entry = { + "name": app[0], + "path": "{}/{}".format(ROOT, app[1]), + "kind": "app", + } + icon_path = "{}/icon.png".format(entry["path"]) + if file_exists(icon_path): + entry["icon"] = icon_path + entries.append(entry) + + for slot, entry in enumerate(entries): + x = slot % 3 + y = slot // 3 + pos = (x * 48 + 33, y * 48 + 42) + try: + sprite = sprite_for(entry) + icons.append(Icon(pos, entry["name"], slot % APPS_PER_PAGE, sprite)) + except Exception as e: + print("Error loading icon for {}: {}".format(entry["path"], e)) return icons -icons = load_page_icons(current_page) +icons = load_page_icons(current_page, current_depth, current_path) active = 0 @@ -101,7 +119,7 @@ def update(): if current_page < total_pages - 1: # Move to next page current_page += 1 - icons = load_page_icons(current_page) + icons = load_page_icons(current_page, current_depth, current_path) active = 0 else: # Wrap to beginning @@ -110,7 +128,7 @@ def update(): if current_page > 0: # Move to previous page current_page -= 1 - icons = load_page_icons(current_page) + icons = load_page_icons(current_page, current_depth, current_path) active = len(icons) - 1 else: # Wrap to end diff --git a/badge/apps/menu/icon.py b/badge/apps/menu/icon.py index 747c94b..693658a 100755 --- a/badge/apps/menu/icon.py +++ b/badge/apps/menu/icon.py @@ -1,5 +1,5 @@ import math -from badgeware import brushes, shapes, io, Matrix, screen +from badgeware import brushes, shapes, io, Matrix, screen, Image # bright icon colours bold = [ @@ -26,6 +26,27 @@ squircle = shapes.squircle(0, 0, 20, 4) shade_brush = brushes.color(0, 0, 0, 30) +ICON_SPRITES = { + "app": "/system/apps/menu/default_icon.png", + "folder": "/system/apps/menu/folder_icon.png", + "back": "/system/apps/menu/folder_up_icon.png", +} + + +def sprite_for(entry): + custom_path = entry.get("icon") + if custom_path: + try: + return Image.load(custom_path) + except OSError: + pass + + path = ICON_SPRITES.get(entry.get("kind"), ICON_SPRITES["app"]) + try: + return Image.load(path) + except OSError: + return Image.load(ICON_SPRITES["app"]) + class Icon: active_icon = None From e3a87e2354272ca0881ba709913599afa55db9a3 Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Sun, 2 Nov 2025 21:23:01 -0800 Subject: [PATCH 06/18] menu: add folder navigation controls --- badge/apps/menu/__init__.py | 210 ++++++++++++++++++++++-------------- 1 file changed, 132 insertions(+), 78 deletions(-) diff --git a/badge/apps/menu/__init__.py b/badge/apps/menu/__init__.py index 8699245..6e32e1a 100755 --- a/badge/apps/menu/__init__.py +++ b/badge/apps/menu/__init__.py @@ -4,8 +4,7 @@ sys.path.insert(0, "/system/apps/menu") os.chdir("/system/apps/menu") -import math -from badgeware import screen, PixelFont, Image, SpriteSheet, is_dir, file_exists, shapes, brushes, io, run +from badgeware import screen, PixelFont, SpriteSheet, is_dir, file_exists, shapes, brushes, io, run from icon import Icon, sprite_for import ui @@ -15,7 +14,12 @@ ROOT = "/system/apps" current_path = ROOT -current_depth = 0 +path_stack = [] +current_entries = [] +current_page = 0 +total_pages = 1 +icons = [] +active = 0 def scan_entries(root): @@ -43,110 +47,160 @@ def scan_entries(root): return folders + apps_only -# Auto-discover apps with __init__.py -apps = [ - (entry["name"], entry["name"]) - for entry in scan_entries(ROOT) - if entry["kind"] == "app" and entry["name"] not in ("menu", "startup") -] - # Pagination constants APPS_PER_PAGE = 6 -current_page = 0 -total_pages = max(1, math.ceil(len(apps) / APPS_PER_PAGE)) -# find installed apps and create icons for current page -def load_page_icons(page, depth=0, active_path=ROOT): - icons = [] - page_size = APPS_PER_PAGE - 1 if depth > 0 else APPS_PER_PAGE - if page_size <= 0: - page_size = 1 - start_idx = page * page_size - end_idx = min(start_idx + page_size, len(apps)) +def current_dir(): + if not path_stack: + return ROOT + return path_stack[-1] - entries = [] - if depth > 0: - entries.append({"name": "..", "path": active_path, "kind": "back"}) - - for i in range(start_idx, end_idx): - app = apps[i] - entry = { - "name": app[0], - "path": "{}/{}".format(ROOT, app[1]), - "kind": "app", - } - icon_path = "{}/icon.png".format(entry["path"]) - if file_exists(icon_path): - entry["icon"] = icon_path - entries.append(entry) - - for slot, entry in enumerate(entries): +def rebuild_entries(reset_page): + global current_entries, current_page, total_pages, icons, active, current_path + current_path = current_dir() + entries = scan_entries(current_path) + if current_path == ROOT: + filtered = [] + for entry in entries: + if entry["name"] in ("menu", "startup"): + continue + filtered.append(entry) + entries = filtered + if path_stack: + entries.insert(0, {"name": "..", "path": current_path, "kind": "back"}) + current_entries = entries + total_pages = (len(current_entries) + APPS_PER_PAGE - 1) // APPS_PER_PAGE + if total_pages < 1: + total_pages = 1 + if reset_page: + current_page = 0 + elif current_page >= total_pages: + current_page = total_pages - 1 + start_idx = current_page * APPS_PER_PAGE + page_len = len(current_entries) - start_idx + if page_len < 0: + page_len = 0 + if page_len > APPS_PER_PAGE: + page_len = APPS_PER_PAGE + if page_len == 0: + active = 0 + elif active >= page_len: + active = page_len - 1 + icons = load_page_icons(current_page) + + +def load_page_icons(page): + result = [] + start_idx = page * APPS_PER_PAGE + end_idx = min(start_idx + APPS_PER_PAGE, len(current_entries)) + for offset in range(start_idx, end_idx): + entry = current_entries[offset] + slot = offset - start_idx x = slot % 3 y = slot // 3 pos = (x * 48 + 33, y * 48 + 42) try: sprite = sprite_for(entry) - icons.append(Icon(pos, entry["name"], slot % APPS_PER_PAGE, sprite)) + result.append(Icon(pos, entry["name"], slot % APPS_PER_PAGE, sprite)) except Exception as e: - print("Error loading icon for {}: {}".format(entry["path"], e)) - return icons + print("Error loading icon for {}: {}".format(entry.get("path", ""), e)) + return result -icons = load_page_icons(current_page, current_depth, current_path) -active = 0 +def selected_entry(): + index = current_page * APPS_PER_PAGE + active + if 0 <= index < len(current_entries): + return current_entries[index] + return None + + +def enter_folder(entry): + if not is_dir(entry["path"]): + return + path_stack.append(entry["path"]) + rebuild_entries(True) + + +def leave_folder(): + if path_stack: + path_stack.pop() + rebuild_entries(True) MAX_ALPHA = 255 alpha = 30 +rebuild_entries(True) + + def update(): global active, icons, alpha, current_page, total_pages # process button inputs to switch between icons + if io.BUTTON_HOME in io.pressed: + if path_stack: + path_stack[:] = [] + rebuild_entries(True) + return None + + move_delta = 0 + if io.BUTTON_C in io.pressed: - active += 1 - if io.BUTTON_A in io.pressed: - active -= 1 + move_delta += 1 if io.BUTTON_UP in io.pressed: - active -= 3 + move_delta -= 3 if io.BUTTON_DOWN in io.pressed: - active += 3 + move_delta += 3 + + if io.BUTTON_A in io.pressed: + move_delta -= 1 + + if move_delta: + active += move_delta # Handle wrapping and page changes - if active >= len(icons): - if current_page < total_pages - 1: - # Move to next page - current_page += 1 - icons = load_page_icons(current_page, current_depth, current_path) - active = 0 - else: - # Wrap to beginning - active = 0 - elif active < 0: - if current_page > 0: - # Move to previous page - current_page -= 1 - icons = load_page_icons(current_page, current_depth, current_path) - active = len(icons) - 1 - else: - # Wrap to end - active = len(icons) - 1 - - # Launch app with error handling + page_len = len(icons) + if page_len == 0: + active = 0 + else: + if active >= page_len: + if current_page < total_pages - 1: + current_page += 1 + icons = load_page_icons(current_page) + active = 0 + page_len = len(icons) + else: + active = 0 + elif active < 0: + if current_page > 0: + current_page -= 1 + icons = load_page_icons(current_page) + page_len = len(icons) + active = page_len - 1 if page_len else 0 + else: + active = page_len - 1 + + entry = selected_entry() + + # Launch app or navigate with B if io.BUTTON_B in io.pressed: - app_idx = current_page * APPS_PER_PAGE + active - if app_idx < len(apps): - app_path = "{}/{}".format(ROOT, apps[app_idx][1]) - try: - # Verify the app still exists before launching - if is_dir(app_path) and file_exists(f"{app_path}/__init__.py"): - return app_path - else: - print(f"Error: App {apps[app_idx][1]} not found or missing __init__.py") - except Exception as e: - print(f"Error launching app {apps[app_idx][1]}: {e}") + if entry: + if entry["kind"] == "folder": + enter_folder(entry) + return None + if entry["kind"] == "back": + leave_folder() + return None + if entry["kind"] == "app": + app_path = entry["path"] + try: + if is_dir(app_path) and file_exists("{}/__init__.py".format(app_path)): + return app_path + print("Error: App {} not found or missing __init__.py".format(entry["name"])) + except Exception as exc: + print("Error launching app {}: {}".format(entry["name"], exc)) ui.draw_background() ui.draw_header() From 26fe0a5b494d6cedc71999b2571fa25080e36756 Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Sun, 2 Nov 2025 21:32:36 -0800 Subject: [PATCH 07/18] menu: hide empty folders --- badge/apps/menu/__init__.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/badge/apps/menu/__init__.py b/badge/apps/menu/__init__.py index 6e32e1a..a4cee04 100755 --- a/badge/apps/menu/__init__.py +++ b/badge/apps/menu/__init__.py @@ -22,6 +22,22 @@ active = 0 +def folder_has_entries(path): + try: + for name in os.listdir(path): + if name.startswith("."): + continue + child_path = "{}/{}".format(path, name) + if is_dir(child_path): + if file_exists("{}/__init__.py".format(child_path)): + return True + if folder_has_entries(child_path): + return True + except OSError as exc: + print("folder_has_entries failed for {}: {}".format(path, exc)) + return False + + def scan_entries(root): entries = [] try: @@ -31,13 +47,14 @@ def scan_entries(root): full_path = "{}/{}".format(root, name) if is_dir(full_path): has_module = file_exists("{}/__init__.py".format(full_path)) - kind = "app" if has_module else "folder" - entry = {"name": name, "path": full_path, "kind": kind} - if kind == "app": + if has_module: + entry = {"name": name, "path": full_path, "kind": "app"} icon_path = "{}/icon.png".format(full_path) if file_exists(icon_path): entry["icon"] = icon_path - entries.append(entry) + entries.append(entry) + elif folder_has_entries(full_path): + entries.append({"name": name, "path": full_path, "kind": "folder"}) except OSError as exc: print("scan_entries failed for {}: {}".format(root, exc)) folders = [item for item in entries if item["kind"] == "folder"] From b7f1459668201ce13d6e3f066a7827185f3c92fb Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Sun, 2 Nov 2025 21:44:47 -0800 Subject: [PATCH 08/18] Removed working files --- subfolder-support-plan.md | 87 --------------------------------------- 1 file changed, 87 deletions(-) delete mode 100644 subfolder-support-plan.md diff --git a/subfolder-support-plan.md b/subfolder-support-plan.md deleted file mode 100644 index 43c2088..0000000 --- a/subfolder-support-plan.md +++ /dev/null @@ -1,87 +0,0 @@ -## Plan: Menu Subfolders Rollout - -Add folder-aware discovery, icons, and navigation so the launcher can browse nested directories with a fixed back entry and alphabetical grouping while keeping the firmware testable after each incremental change. - -**Steps 5:** -1. Refactor listing in [`__init__.py`](badge/apps/menu/__init__.py) to scan the active directory, tag each entry, and sort folders before apps alphabetically. Keep to MicroPython-friendly calls (`os.listdir`, `badgeware.is_dir`, `badgeware.file_exists`). - ```python - def scan_entries(root): - entries = [] - try: - for name in os.listdir(root): - if name.startswith("."): - continue - full_path = "{}/{}".format(root, name) - if is_dir(full_path): - has_module = file_exists("{}/__init__.py".format(full_path)) - kind = "app" if has_module else "folder" - entries.append({"name": name, "path": full_path, "kind": kind}) - except OSError as exc: - print("scan_entries failed for {}: {}".format(root, exc)) - folders = [item for item in entries if item["kind"] == "folder"] - apps = [item for item in entries if item["kind"] == "app"] - folders.sort(key=lambda item: item["name"]) - apps.sort(key=lambda item: item["name"]) - return folders + apps - ``` -2. Stage new assets `folder_icon.png` and `folder_up_icon.png` under [`menu`](badge/apps/menu/) without altering runtime behavior. Use 24x24 PNGs so the existing `Image.load` calls continue working on-device. -3. Map entry types to sprites in [`icon.py`](badge/apps/menu/icon.py) and adjust `load_page_icons` in [`__init__.py`](badge/apps/menu/__init__.py) to pin the back icon at slot 0 when depth > 0. - ```python - ICON_SPRITES = { - "app": "/system/apps/menu/default_icon.png", - "folder": "/system/apps/menu/folder_icon.png", - "back": "/system/apps/menu/folder_up_icon.png", - } - - def sprite_for(entry): - path = ICON_SPRITES.get(entry["kind"], ICON_SPRITES["app"]) - try: - return Image.load(path) - except OSError: - return Image.load(ICON_SPRITES["app"]) - ``` - In `load_page_icons`, insert a synthetic `{"kind": "back", "name": "..", "path": current_path}` entry at index 0 whenever the depth is greater than zero, then pad the grid as usual. -4. Extend navigation state in [`__init__.py`](badge/apps/menu/__init__.py) (path stack, selection offsets, `io.BUTTON_HOME` reset) so A enters folders, B launches apps, and HOME returns to root. Preserve pagination behavior and reuse the scanning helper for each stack change. - ```python - ROOT = "/system/apps" - path_stack = [] - current_entries = scan_entries(ROOT) - cursor_index = 0 - - def enter_folder(entry): - global current_entries, cursor_index - path_stack.append(entry["path"]) - current_entries = [{"name": "..", "path": entry["path"], "kind": "back"}] - current_entries.extend(scan_entries(entry["path"])) - cursor_index = 0 - - def leave_folder(): - global current_entries, cursor_index - if path_stack: - path_stack.pop() - active_path = ROOT if not path_stack else path_stack[-1] - current_entries = scan_entries(active_path) - cursor_index = 0 - - def handle_input(): - global cursor_index - if io.BUTTON_HOME in io.pressed: - while path_stack: - leave_folder() - return None - if io.BUTTON_A in io.pressed: - entry = current_entries[cursor_index] - if entry["kind"] == "folder": - enter_folder(entry) - elif entry["kind"] == "back": - leave_folder() - if io.BUTTON_B in io.pressed: - entry = current_entries[cursor_index] - if entry["kind"] == "app": - return entry["path"] - return None - ``` -5. Validate after each commit on hardware: first ensure the refactor keeps flat menu behavior, then verify folder/back icon rendering with pagination, and finally confirm full navigation (A-to-enter, B-to-launch, HOME-to-root) matches expectations. - -**Open Questions 1:** -1. None. From f0f29bc73f50903a5aa73e26865b05ae97a35539 Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Sun, 2 Nov 2025 21:59:49 -0800 Subject: [PATCH 09/18] menu: guard icon loading and depth scanning --- badge/apps/menu/__init__.py | 9 +++++++-- badge/apps/menu/icon.py | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/badge/apps/menu/__init__.py b/badge/apps/menu/__init__.py index a4cee04..c1578b2 100755 --- a/badge/apps/menu/__init__.py +++ b/badge/apps/menu/__init__.py @@ -22,7 +22,12 @@ active = 0 -def folder_has_entries(path): +FOLDER_SCAN_DEPTH = 4 + + +def folder_has_entries(path, depth=0): + if depth >= FOLDER_SCAN_DEPTH: + return False try: for name in os.listdir(path): if name.startswith("."): @@ -31,7 +36,7 @@ def folder_has_entries(path): if is_dir(child_path): if file_exists("{}/__init__.py".format(child_path)): return True - if folder_has_entries(child_path): + if folder_has_entries(child_path, depth + 1): return True except OSError as exc: print("folder_has_entries failed for {}: {}".format(path, exc)) diff --git a/badge/apps/menu/icon.py b/badge/apps/menu/icon.py index 693658a..263df4e 100755 --- a/badge/apps/menu/icon.py +++ b/badge/apps/menu/icon.py @@ -45,7 +45,10 @@ def sprite_for(entry): try: return Image.load(path) except OSError: - return Image.load(ICON_SPRITES["app"]) + try: + return Image.load(ICON_SPRITES["app"]) + except OSError: + return Image(24, 24) class Icon: From 33acdc070f46ef6c59f636c78eff07515fa0938a Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Sun, 2 Nov 2025 23:25:18 -0800 Subject: [PATCH 10/18] Add shared app runtime bootstrap helper --- badge/apps/stocks/__init__.py | 5 ++-- badge/apps/weather/__init__.py | 5 ++-- badge/apps/wifi/__init__.py | 5 ++-- badge/main.py | 54 ++++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/badge/apps/stocks/__init__.py b/badge/apps/stocks/__init__.py index 022719f..5774091 100644 --- a/badge/apps/stocks/__init__.py +++ b/badge/apps/stocks/__init__.py @@ -1,8 +1,7 @@ import sys -import os +from badge_app_runtime import prepare_app_path, active_path -sys.path.insert(0, "/system/apps/stocks") -os.chdir("/system/apps/stocks") +APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/stocks") from badgeware import io, brushes, shapes, screen, PixelFont, run import network diff --git a/badge/apps/weather/__init__.py b/badge/apps/weather/__init__.py index f1235d0..edcb048 100644 --- a/badge/apps/weather/__init__.py +++ b/badge/apps/weather/__init__.py @@ -1,8 +1,7 @@ import sys -import os +from badge_app_runtime import prepare_app_path, active_path -sys.path.insert(0, "/system/apps/weather") -os.chdir("/system/apps/weather") +APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/weather") from badgeware import io, brushes, shapes, screen, PixelFont, run import network diff --git a/badge/apps/wifi/__init__.py b/badge/apps/wifi/__init__.py index db79456..4a8c3a6 100644 --- a/badge/apps/wifi/__init__.py +++ b/badge/apps/wifi/__init__.py @@ -1,8 +1,7 @@ import sys -import os +from badge_app_runtime import prepare_app_path, active_path -sys.path.insert(0, "/system/apps/wifi") -os.chdir("/system/apps/wifi") +APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/wifi") from badgeware import io, brushes, shapes, screen, PixelFont, run, Matrix import network diff --git a/badge/main.py b/badge/main.py index bed1dd5..60f95b3 100755 --- a/badge/main.py +++ b/badge/main.py @@ -7,6 +7,59 @@ import gc import powman + +def _default_app_fallback(module_globals, fallback): + if fallback: + return fallback + module_name = module_globals.get("__name__", "") + if module_name: + module_name = module_name.rsplit(".", 1)[-1] + if module_name and module_name != "__main__": + return "/system/apps/{}".format(module_name) + return None + + +def _prepare_app_path(module_globals, fallback=None): + app_file = module_globals.get("__file__") + app_dir = None + + if app_file: + separator = "/" if "/" in app_file else ("\\" if "\\" in app_file else None) + if separator: + app_dir = app_file.rsplit(separator, 1)[0] + + if not app_dir: + try: + app_dir = os.getcwd() + except OSError: + app_dir = None + + if not app_dir: + app_dir = _default_app_fallback(module_globals, fallback) + + if app_dir and app_dir not in sys.path: + sys.path.insert(0, app_dir) + + if app_dir: + try: + os.chdir(app_dir) + except OSError: + pass + + return app_dir + + +class _AppRuntimeModule: + """Runtime helper exposed to apps for path bootstrapping.""" + + +badge_app_runtime = _AppRuntimeModule() +badge_app_runtime.__name__ = "badge_app_runtime" +badge_app_runtime.prepare_app_path = _prepare_app_path +badge_app_runtime.active_path = None + +sys.modules["badge_app_runtime"] = badge_app_runtime + SKIP_CINEMATIC = powman.get_wake_reason() == powman.WAKE_WATCHDOG running_app = None @@ -58,6 +111,7 @@ def quit_to_launcher(pin): sys.path.insert(0, app) os.chdir(app) +badge_app_runtime.active_path = app running_app = __import__(app) From 7e9f7b4d787292b66340f1f234c1347d835d772e Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Sun, 2 Nov 2025 23:33:13 -0800 Subject: [PATCH 11/18] Refactor app bootstrapping for submenu deployment --- badge/apps/badge/__init__.py | 4 +-- badge/apps/copilot-loop/__init__.py | 9 +++-- badge/apps/crypto/__init__.py | 9 +++-- badge/apps/flappy/__init__.py | 5 ++- badge/apps/gallery/__init__.py | 4 +-- badge/apps/invaders/__init__.py | 5 ++- badge/apps/monapet/__init__.py | 9 +++-- badge/apps/monapet/mona.py | 9 +++-- badge/apps/quest/__init__.py | 9 +++-- badge/apps/sketch/__init__.py | 9 +++-- badge/badge_app_runtime.py | 46 ++++++++++++++++++++++++ badge/main.py | 54 +---------------------------- 12 files changed, 79 insertions(+), 93 deletions(-) create mode 100644 badge/badge_app_runtime.py diff --git a/badge/apps/badge/__init__.py b/badge/apps/badge/__init__.py index 4079fb0..8df4fe5 100755 --- a/badge/apps/badge/__init__.py +++ b/badge/apps/badge/__init__.py @@ -1,8 +1,8 @@ import sys import os +from badge_app_runtime import prepare_app_path, active_path -sys.path.insert(0, "/system/apps/badge") -os.chdir("/system/apps/badge") +APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/badge") from badgeware import io, brushes, shapes, Image, run, PixelFont, screen, Matrix, file_exists diff --git a/badge/apps/copilot-loop/__init__.py b/badge/apps/copilot-loop/__init__.py index c22783b..b2301f4 100644 --- a/badge/apps/copilot-loop/__init__.py +++ b/badge/apps/copilot-loop/__init__.py @@ -1,11 +1,10 @@ import sys -import os - -sys.path.insert(0, "/system/apps/copilot-loop") -os.chdir("/system/apps/copilot-loop") +import sys +from badge_app_runtime import prepare_app_path, active_path -from badgeware import Image, screen, run, io +APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/copilot-loop") +from badgeware import io, run, screen, Image, PixelFont, SpriteSheet frame_index = 1 diff --git a/badge/apps/crypto/__init__.py b/badge/apps/crypto/__init__.py index 6d93533..9ee3b46 100644 --- a/badge/apps/crypto/__init__.py +++ b/badge/apps/crypto/__init__.py @@ -1,11 +1,10 @@ import sys -import os +import sys +from badge_app_runtime import prepare_app_path, active_path -sys.path.insert(0, "/system/apps/crypto") -os.chdir("/system/apps/crypto") +APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/crypto") -from badgeware import io, brushes, shapes, screen, PixelFont, run -import network +from badgeware import io, brushes, shapes, screen, PixelFont, Image, run from urllib.urequest import urlopen import json import gc diff --git a/badge/apps/flappy/__init__.py b/badge/apps/flappy/__init__.py index 00e92c5..f0cd009 100755 --- a/badge/apps/flappy/__init__.py +++ b/badge/apps/flappy/__init__.py @@ -1,8 +1,7 @@ import sys -import os +from badge_app_runtime import prepare_app_path, active_path -sys.path.insert(0, "/system/apps/flappy") -os.chdir("/system/apps/flappy") +APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/flappy") from badgeware import screen, Image, PixelFont, SpriteSheet, io, brushes, shapes, run, State from mona import Mona diff --git a/badge/apps/gallery/__init__.py b/badge/apps/gallery/__init__.py index edfdf09..e4bdc9a 100755 --- a/badge/apps/gallery/__init__.py +++ b/badge/apps/gallery/__init__.py @@ -1,8 +1,8 @@ import sys import os +from badge_app_runtime import prepare_app_path, active_path -sys.path.insert(0, "/system/apps/gallery") -os.chdir("/system/apps/gallery") +APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/gallery") from badgeware import PixelFont, Image, screen, run, io, brushes, shapes diff --git a/badge/apps/invaders/__init__.py b/badge/apps/invaders/__init__.py index 79038f6..eec707c 100755 --- a/badge/apps/invaders/__init__.py +++ b/badge/apps/invaders/__init__.py @@ -1,9 +1,8 @@ import sys -import os import random +from badge_app_runtime import prepare_app_path, active_path -sys.path.insert(0, "/system/apps/invaders") -os.chdir("/system/apps/invaders") +APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/invaders") from badgeware import screen, PixelFont, io, brushes, shapes, run diff --git a/badge/apps/monapet/__init__.py b/badge/apps/monapet/__init__.py index 4e66762..dfb16a5 100755 --- a/badge/apps/monapet/__init__.py +++ b/badge/apps/monapet/__init__.py @@ -1,11 +1,10 @@ import sys -import os - -sys.path.insert(0, "/system/apps/monapet") -os.chdir("/system/apps/monapet") +import sys +from badge_app_runtime import prepare_app_path, active_path +APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/monapet") -import ui +from badgeware import io, brushes, Image, Matrix, PixelFont, screen, SpriteSheet, run from mona import Mona from badgeware import io, run, State diff --git a/badge/apps/monapet/mona.py b/badge/apps/monapet/mona.py index 0255e09..b268106 100755 --- a/badge/apps/monapet/mona.py +++ b/badge/apps/monapet/mona.py @@ -1,11 +1,10 @@ import sys -import os +import sys +from badge_app_runtime import prepare_app_path, active_path -sys.path.insert(0, "/system/apps/monapet") -os.chdir("/system/apps/monapet") +APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/monapet") -from badgeware import screen, brushes, SpriteSheet, shapes, clamp, io -import random +from badgeware import Image, SpriteSheet, brushes, screen import math # this class defines our little friend, modify it to change their behaviour! diff --git a/badge/apps/quest/__init__.py b/badge/apps/quest/__init__.py index 4b51596..1f9bb98 100755 --- a/badge/apps/quest/__init__.py +++ b/badge/apps/quest/__init__.py @@ -1,11 +1,10 @@ import sys -import os +import sys +from badge_app_runtime import prepare_app_path, active_path -sys.path.insert(0, "/system/apps/quest") -os.chdir("/system/apps/quest") +APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/quest") -import math -import random +from badgeware import io, run, shapes, screen, PixelFont, SpriteSheet, Image, brushes from badgeware import State, PixelFont, Image, brushes, screen, io, shapes, run from beacon import GithubUniverseBeacon from aye_arr.nec import NECReceiver diff --git a/badge/apps/sketch/__init__.py b/badge/apps/sketch/__init__.py index 635f3da..4bc8df1 100755 --- a/badge/apps/sketch/__init__.py +++ b/badge/apps/sketch/__init__.py @@ -1,11 +1,10 @@ import sys -import os +import sys +from badge_app_runtime import prepare_app_path, active_path -sys.path.insert(0, "/system/apps/sketch") -os.chdir("/system/apps/sketch") +APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/sketch") -from badgeware import Image, brushes, shapes, screen, io, run -import ui +from badgeware import io, brushes, shapes, Image, run, PixelFont, screen canvas = Image(0, 0, ui.canvas_area[2], ui.canvas_area[3]) diff --git a/badge/badge_app_runtime.py b/badge/badge_app_runtime.py new file mode 100644 index 0000000..1565d68 --- /dev/null +++ b/badge/badge_app_runtime.py @@ -0,0 +1,46 @@ +import os +import sys + + +def _default_app_fallback(module_globals, fallback): + if fallback: + return fallback + module_name = module_globals.get("__name__", "") + if module_name: + module_name = module_name.rsplit(".", 1)[-1] + if module_name and module_name != "__main__": + return "/system/apps/{}".format(module_name) + return None + + +def prepare_app_path(module_globals, fallback=None): + app_file = module_globals.get("__file__") + app_dir = None + + if app_file: + separator = "/" if "/" in app_file else ("\\" if "\\" in app_file else None) + if separator: + app_dir = app_file.rsplit(separator, 1)[0] + + if not app_dir: + try: + app_dir = os.getcwd() + except OSError: + app_dir = None + + if not app_dir: + app_dir = _default_app_fallback(module_globals, fallback) + + if app_dir and app_dir not in sys.path: + sys.path.insert(0, app_dir) + + if app_dir: + try: + os.chdir(app_dir) + except OSError: + pass + + return app_dir + + +active_path = None diff --git a/badge/main.py b/badge/main.py index 60f95b3..a820342 100755 --- a/badge/main.py +++ b/badge/main.py @@ -6,59 +6,7 @@ import machine import gc import powman - - -def _default_app_fallback(module_globals, fallback): - if fallback: - return fallback - module_name = module_globals.get("__name__", "") - if module_name: - module_name = module_name.rsplit(".", 1)[-1] - if module_name and module_name != "__main__": - return "/system/apps/{}".format(module_name) - return None - - -def _prepare_app_path(module_globals, fallback=None): - app_file = module_globals.get("__file__") - app_dir = None - - if app_file: - separator = "/" if "/" in app_file else ("\\" if "\\" in app_file else None) - if separator: - app_dir = app_file.rsplit(separator, 1)[0] - - if not app_dir: - try: - app_dir = os.getcwd() - except OSError: - app_dir = None - - if not app_dir: - app_dir = _default_app_fallback(module_globals, fallback) - - if app_dir and app_dir not in sys.path: - sys.path.insert(0, app_dir) - - if app_dir: - try: - os.chdir(app_dir) - except OSError: - pass - - return app_dir - - -class _AppRuntimeModule: - """Runtime helper exposed to apps for path bootstrapping.""" - - -badge_app_runtime = _AppRuntimeModule() -badge_app_runtime.__name__ = "badge_app_runtime" -badge_app_runtime.prepare_app_path = _prepare_app_path -badge_app_runtime.active_path = None - -sys.modules["badge_app_runtime"] = badge_app_runtime +import badge_app_runtime SKIP_CINEMATIC = powman.get_wake_reason() == powman.WAKE_WATCHDOG From 495970ad65841afe23b11d53298b7dc0aee603a3 Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Sun, 2 Nov 2025 23:50:16 -0800 Subject: [PATCH 12/18] Fix monapet runtime imports and update simulator runtime loader --- badge/apps/monapet/__init__.py | 3 +-- badge/apps/monapet/mona.py | 4 ++-- simulator/badge_simulator.py | 24 ++++++++++++++++++++++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/badge/apps/monapet/__init__.py b/badge/apps/monapet/__init__.py index dfb16a5..b6a6221 100755 --- a/badge/apps/monapet/__init__.py +++ b/badge/apps/monapet/__init__.py @@ -1,10 +1,9 @@ import sys -import sys from badge_app_runtime import prepare_app_path, active_path APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/monapet") -from badgeware import io, brushes, Image, Matrix, PixelFont, screen, SpriteSheet, run +import ui from mona import Mona from badgeware import io, run, State diff --git a/badge/apps/monapet/mona.py b/badge/apps/monapet/mona.py index b268106..db67ede 100755 --- a/badge/apps/monapet/mona.py +++ b/badge/apps/monapet/mona.py @@ -1,10 +1,10 @@ import sys -import sys from badge_app_runtime import prepare_app_path, active_path APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/monapet") -from badgeware import Image, SpriteSheet, brushes, screen +from badgeware import screen, brushes, SpriteSheet, shapes, clamp, io +import random import math # this class defines our little friend, modify it to change their behaviour! diff --git a/simulator/badge_simulator.py b/simulator/badge_simulator.py index d9fc320..8eb4f88 100644 --- a/simulator/badge_simulator.py +++ b/simulator/badge_simulator.py @@ -1366,10 +1366,30 @@ def load_game_module(module_path: str) -> ModuleType: game_dir = os.path.dirname(game_abs) sim_root = SIM_ROOT if SIM_ROOT is not None else _find_sim_root(game_dir) simulator_dir = os.path.dirname(os.path.abspath(__file__)) - for p in (game_dir, os.path.join(sim_root, "apps"), simulator_dir): - if p not in sys.path: + search_paths = [game_dir, simulator_dir] + if sim_root: + search_paths.extend([ + os.path.join(sim_root, "apps"), + sim_root, + ]) + for p in search_paths: + if p and p not in sys.path: sys.path.insert(0, p) + # Ensure badge_app_runtime helper is available to imported apps + runtime_module = sys.modules.get("badge_app_runtime") + if runtime_module is None and sim_root: + runtime_path = os.path.join(sim_root, "badge_app_runtime.py") + if os.path.isfile(runtime_path): + runtime_spec = importlib.util.spec_from_file_location("badge_app_runtime", runtime_path) + if runtime_spec and runtime_spec.loader: + runtime_module = importlib.util.module_from_spec(runtime_spec) + runtime_spec.loader.exec_module(runtime_module) # type: ignore + sys.modules["badge_app_runtime"] = runtime_module + + if runtime_module is not None: + runtime_module.active_path = os.path.abspath(game_dir) + # Provide `badgeware` badgeware = ModuleType("badgeware") badgeware.screen = screen From afce069f999fc2a6857c71bc6ec2c6cb6763abb2 Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Mon, 3 Nov 2025 00:21:50 -0800 Subject: [PATCH 13/18] Reset main.py to defaults --- badge/main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/badge/main.py b/badge/main.py index a820342..bed1dd5 100755 --- a/badge/main.py +++ b/badge/main.py @@ -6,7 +6,6 @@ import machine import gc import powman -import badge_app_runtime SKIP_CINEMATIC = powman.get_wake_reason() == powman.WAKE_WATCHDOG @@ -59,7 +58,6 @@ def quit_to_launcher(pin): sys.path.insert(0, app) os.chdir(app) -badge_app_runtime.active_path = app running_app = __import__(app) From fb861619ab2552e4c8cf0e3822457c4f5404f93d Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Mon, 3 Nov 2025 01:31:50 -0800 Subject: [PATCH 14/18] Refactor app bootstrapping for shared runtime helper --- badge/apps/badge/__init__.py | 24 +++++-- badge/apps/copilot-loop/__init__.py | 12 +++- badge/apps/crypto/__init__.py | 11 ++- badge/apps/flappy/__init__.py | 11 ++- badge/apps/gallery/__init__.py | 11 ++- badge/apps/invaders/__init__.py | 11 ++- badge/apps/monapet/__init__.py | 11 ++- badge/apps/monapet/mona.py | 11 ++- badge/apps/quest/__init__.py | 12 +++- badge/apps/sketch/__init__.py | 13 +++- badge/apps/stocks/__init__.py | 11 ++- badge/apps/weather/__init__.py | 11 ++- badge/apps/wifi/__init__.py | 11 ++- badge/badge_app_runtime.py | 104 ++++++++++++++++++++++------ 14 files changed, 209 insertions(+), 55 deletions(-) diff --git a/badge/apps/badge/__init__.py b/badge/apps/badge/__init__.py index 8df4fe5..164a747 100755 --- a/badge/apps/badge/__init__.py +++ b/badge/apps/badge/__init__.py @@ -1,8 +1,15 @@ import sys import os -from badge_app_runtime import prepare_app_path, active_path -APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/badge") +if "/system" not in sys.path: + sys.path.insert(0, "/system") + +try: + from badge_app_runtime import ensure_app_path +except ImportError: + from badge_app_runtime import prepare_app_path as ensure_app_path + +APP_DIR = ensure_app_path(globals(), "/system/apps/badge") from badgeware import io, brushes, shapes, Image, run, PixelFont, screen, Matrix, file_exists @@ -11,7 +18,6 @@ import network from urllib.urequest import urlopen import gc -import sys import json @@ -51,21 +57,27 @@ def get_connection_details(user): # `secrets.py` can be imported on the device. Clean up sys.path afterwards. sys.path.insert(0, "/") try: - from secrets import WIFI_PASSWORD, WIFI_SSID, GITHUB_USERNAME, GITHUB_TOKEN + secrets = __import__("secrets") finally: # ensure we remove the path we inserted even if import fails try: sys.path.pop(0) except Exception: pass - except ImportError as e: + + WIFI_PASSWORD = getattr(secrets, "WIFI_PASSWORD", None) + WIFI_SSID = getattr(secrets, "WIFI_SSID", None) + GITHUB_USERNAME = getattr(secrets, "GITHUB_USERNAME", None) + # Allow the token to be omitted entirely – treat missing as None/empty string + GITHUB_TOKEN = getattr(secrets, "GITHUB_TOKEN", None) + except ImportError: # If the user hasn't created a secrets.py file, fall back to None so # the rest of the app can detect missing credentials and show helpful UI. WIFI_PASSWORD = None WIFI_SSID = None GITHUB_USERNAME = None GITHUB_TOKEN = None - except Exception as e: + except Exception: WIFI_PASSWORD = None WIFI_SSID = None GITHUB_USERNAME = None diff --git a/badge/apps/copilot-loop/__init__.py b/badge/apps/copilot-loop/__init__.py index b2301f4..2a21514 100644 --- a/badge/apps/copilot-loop/__init__.py +++ b/badge/apps/copilot-loop/__init__.py @@ -1,8 +1,14 @@ import sys -import sys -from badge_app_runtime import prepare_app_path, active_path -APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/copilot-loop") +if "/system" not in sys.path: + sys.path.insert(0, "/system") + +try: + from badge_app_runtime import ensure_app_path +except ImportError: + from badge_app_runtime import prepare_app_path as ensure_app_path + +APP_DIR = ensure_app_path(globals(), "/system/apps/copilot-loop") from badgeware import io, run, screen, Image, PixelFont, SpriteSheet diff --git a/badge/apps/crypto/__init__.py b/badge/apps/crypto/__init__.py index 9ee3b46..7479cb9 100644 --- a/badge/apps/crypto/__init__.py +++ b/badge/apps/crypto/__init__.py @@ -1,8 +1,15 @@ import sys import sys -from badge_app_runtime import prepare_app_path, active_path -APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/crypto") +if "/system" not in sys.path: + sys.path.insert(0, "/system") + +try: + from badge_app_runtime import ensure_app_path +except ImportError: + from badge_app_runtime import prepare_app_path as ensure_app_path + +APP_DIR = ensure_app_path(globals(), "/system/apps/crypto") from badgeware import io, brushes, shapes, screen, PixelFont, Image, run from urllib.urequest import urlopen diff --git a/badge/apps/flappy/__init__.py b/badge/apps/flappy/__init__.py index f0cd009..8cf20f8 100755 --- a/badge/apps/flappy/__init__.py +++ b/badge/apps/flappy/__init__.py @@ -1,7 +1,14 @@ import sys -from badge_app_runtime import prepare_app_path, active_path -APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/flappy") +if "/system" not in sys.path: + sys.path.insert(0, "/system") + +try: + from badge_app_runtime import ensure_app_path +except ImportError: + from badge_app_runtime import prepare_app_path as ensure_app_path + +APP_DIR = ensure_app_path(globals(), "/system/apps/flappy") from badgeware import screen, Image, PixelFont, SpriteSheet, io, brushes, shapes, run, State from mona import Mona diff --git a/badge/apps/gallery/__init__.py b/badge/apps/gallery/__init__.py index e4bdc9a..692df94 100755 --- a/badge/apps/gallery/__init__.py +++ b/badge/apps/gallery/__init__.py @@ -1,8 +1,15 @@ import sys import os -from badge_app_runtime import prepare_app_path, active_path -APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/gallery") +if "/system" not in sys.path: + sys.path.insert(0, "/system") + +try: + from badge_app_runtime import ensure_app_path +except ImportError: + from badge_app_runtime import prepare_app_path as ensure_app_path + +APP_DIR = ensure_app_path(globals(), "/system/apps/gallery") from badgeware import PixelFont, Image, screen, run, io, brushes, shapes diff --git a/badge/apps/invaders/__init__.py b/badge/apps/invaders/__init__.py index eec707c..c5472d2 100755 --- a/badge/apps/invaders/__init__.py +++ b/badge/apps/invaders/__init__.py @@ -1,8 +1,15 @@ import sys import random -from badge_app_runtime import prepare_app_path, active_path -APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/invaders") +if "/system" not in sys.path: + sys.path.insert(0, "/system") + +try: + from badge_app_runtime import ensure_app_path +except ImportError: + from badge_app_runtime import prepare_app_path as ensure_app_path + +APP_DIR = ensure_app_path(globals(), "/system/apps/invaders") from badgeware import screen, PixelFont, io, brushes, shapes, run diff --git a/badge/apps/monapet/__init__.py b/badge/apps/monapet/__init__.py index b6a6221..62649a0 100755 --- a/badge/apps/monapet/__init__.py +++ b/badge/apps/monapet/__init__.py @@ -1,7 +1,14 @@ import sys -from badge_app_runtime import prepare_app_path, active_path -APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/monapet") +if "/system" not in sys.path: + sys.path.insert(0, "/system") + +try: + from badge_app_runtime import ensure_app_path +except ImportError: + from badge_app_runtime import prepare_app_path as ensure_app_path + +APP_DIR = ensure_app_path(globals(), "/system/apps/monapet") import ui from mona import Mona diff --git a/badge/apps/monapet/mona.py b/badge/apps/monapet/mona.py index db67ede..b952635 100755 --- a/badge/apps/monapet/mona.py +++ b/badge/apps/monapet/mona.py @@ -1,7 +1,14 @@ import sys -from badge_app_runtime import prepare_app_path, active_path -APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/monapet") +if "/system" not in sys.path: + sys.path.insert(0, "/system") + +try: + from badge_app_runtime import ensure_app_path +except ImportError: + from badge_app_runtime import prepare_app_path as ensure_app_path + +APP_DIR = ensure_app_path(globals(), "/system/apps/monapet") from badgeware import screen, brushes, SpriteSheet, shapes, clamp, io import random diff --git a/badge/apps/quest/__init__.py b/badge/apps/quest/__init__.py index 1f9bb98..bd0553e 100755 --- a/badge/apps/quest/__init__.py +++ b/badge/apps/quest/__init__.py @@ -1,8 +1,14 @@ import sys -import sys -from badge_app_runtime import prepare_app_path, active_path -APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/quest") +if "/system" not in sys.path: + sys.path.insert(0, "/system") + +try: + from badge_app_runtime import ensure_app_path +except ImportError: + from badge_app_runtime import prepare_app_path as ensure_app_path + +APP_DIR = ensure_app_path(globals(), "/system/apps/quest") from badgeware import io, run, shapes, screen, PixelFont, SpriteSheet, Image, brushes from badgeware import State, PixelFont, Image, brushes, screen, io, shapes, run diff --git a/badge/apps/sketch/__init__.py b/badge/apps/sketch/__init__.py index 4bc8df1..2ac042f 100755 --- a/badge/apps/sketch/__init__.py +++ b/badge/apps/sketch/__init__.py @@ -1,9 +1,16 @@ import sys -import sys -from badge_app_runtime import prepare_app_path, active_path -APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/sketch") +if "/system" not in sys.path: + sys.path.insert(0, "/system") + +try: + from badge_app_runtime import ensure_app_path +except ImportError: + from badge_app_runtime import prepare_app_path as ensure_app_path + +APP_DIR = ensure_app_path(globals(), "/system/apps/sketch") +from . import ui from badgeware import io, brushes, shapes, Image, run, PixelFont, screen diff --git a/badge/apps/stocks/__init__.py b/badge/apps/stocks/__init__.py index 5774091..820ae50 100644 --- a/badge/apps/stocks/__init__.py +++ b/badge/apps/stocks/__init__.py @@ -1,7 +1,14 @@ import sys -from badge_app_runtime import prepare_app_path, active_path -APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/stocks") +if "/system" not in sys.path: + sys.path.insert(0, "/system") + +try: + from badge_app_runtime import ensure_app_path +except ImportError: + from badge_app_runtime import prepare_app_path as ensure_app_path + +APP_DIR = ensure_app_path(globals(), "/system/apps/stocks") from badgeware import io, brushes, shapes, screen, PixelFont, run import network diff --git a/badge/apps/weather/__init__.py b/badge/apps/weather/__init__.py index edcb048..792c929 100644 --- a/badge/apps/weather/__init__.py +++ b/badge/apps/weather/__init__.py @@ -1,7 +1,14 @@ import sys -from badge_app_runtime import prepare_app_path, active_path -APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/weather") +if "/system" not in sys.path: + sys.path.insert(0, "/system") + +try: + from badge_app_runtime import ensure_app_path +except ImportError: + from badge_app_runtime import prepare_app_path as ensure_app_path + +APP_DIR = ensure_app_path(globals(), "/system/apps/weather") from badgeware import io, brushes, shapes, screen, PixelFont, run import network diff --git a/badge/apps/wifi/__init__.py b/badge/apps/wifi/__init__.py index 4a8c3a6..ff1f9ae 100644 --- a/badge/apps/wifi/__init__.py +++ b/badge/apps/wifi/__init__.py @@ -1,7 +1,14 @@ import sys -from badge_app_runtime import prepare_app_path, active_path -APP_DIR = prepare_app_path(globals(), active_path or "/system/apps/wifi") +if "/system" not in sys.path: + sys.path.insert(0, "/system") + +try: + from badge_app_runtime import ensure_app_path +except ImportError: + from badge_app_runtime import prepare_app_path as ensure_app_path + +APP_DIR = ensure_app_path(globals(), "/system/apps/wifi") from badgeware import io, brushes, shapes, screen, PixelFont, run, Matrix import network diff --git a/badge/badge_app_runtime.py b/badge/badge_app_runtime.py index 1565d68..de78465 100644 --- a/badge/badge_app_runtime.py +++ b/badge/badge_app_runtime.py @@ -2,34 +2,88 @@ import sys -def _default_app_fallback(module_globals, fallback): - if fallback: - return fallback +_CACHE_KEY = "__badge_app_runtime_dir" + + +def _normalize_path(value): + if value is None: + return None + if isinstance(value, bytes): + value = value.decode() + value = str(value) + value = value.replace("\\", "/") + if value.endswith("/") and value != "/": + value = value[:-1] + return value or None + + +def _parent_dir(path): + path = _normalize_path(path) + if not path: + return None + slash = path.rfind("/") + if slash == -1: + return None + if slash == 0: + return "/" + return path[:slash] + + +def _dir_exists(path): + if not path: + return False + try: + os.listdir(path) + return True + except OSError: + return False + + +def _default_app_dir(module_globals, fallback): module_name = module_globals.get("__name__", "") - if module_name: - module_name = module_name.rsplit(".", 1)[-1] - if module_name and module_name != "__main__": - return "/system/apps/{}".format(module_name) - return None + if not module_name or module_name == "__main__": + return _normalize_path(fallback) + tail = module_name.rsplit(".", 1)[-1] + base = _normalize_path(fallback) + if base and base.endswith(tail): + return base + if base: + return base + return "/system/apps/{}".format(tail) -def prepare_app_path(module_globals, fallback=None): - app_file = module_globals.get("__file__") - app_dir = None +def ensure_app_path(module_globals, fallback=None): + cached = module_globals.get(_CACHE_KEY) + if cached: + return cached - if app_file: - separator = "/" if "/" in app_file else ("\\" if "\\" in app_file else None) - if separator: - app_dir = app_file.rsplit(separator, 1)[0] + candidates = [] - if not app_dir: - try: - app_dir = os.getcwd() - except OSError: - app_dir = None + def add_candidate(path): + path = _normalize_path(path) + if path and path not in candidates: + candidates.append(path) + + add_candidate(_parent_dir(module_globals.get("__file__"))) + + module_spec = module_globals.get("__spec__") + if module_spec is not None: + add_candidate(_parent_dir(getattr(module_spec, "origin", None))) + + if sys.path: + add_candidate(sys.path[0]) - if not app_dir: - app_dir = _default_app_fallback(module_globals, fallback) + add_candidate(_default_app_dir(module_globals, fallback)) + + app_dir = None + for candidate in candidates: + if not candidate: + continue + if _dir_exists(candidate): + app_dir = candidate + break + if app_dir is None: + app_dir = candidate if app_dir and app_dir not in sys.path: sys.path.insert(0, app_dir) @@ -40,7 +94,13 @@ def prepare_app_path(module_globals, fallback=None): except OSError: pass + module_globals[_CACHE_KEY] = app_dir return app_dir +def prepare_app_path(module_globals, fallback=None): + """Backward-compatible wrapper for older apps.""" + return ensure_app_path(module_globals, fallback) + + active_path = None From d0bbb21f0389d5f5780915dd3033c8d7c4485ac5 Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Mon, 3 Nov 2025 01:43:57 -0800 Subject: [PATCH 15/18] Update badge/apps/crypto/__init__.py Removing duplicate import. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- badge/apps/crypto/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/badge/apps/crypto/__init__.py b/badge/apps/crypto/__init__.py index 7479cb9..ec9d786 100644 --- a/badge/apps/crypto/__init__.py +++ b/badge/apps/crypto/__init__.py @@ -1,6 +1,4 @@ import sys -import sys - if "/system" not in sys.path: sys.path.insert(0, "/system") From 224759ff6cc3934be114e33fc970dc37a8a3edc5 Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Mon, 3 Nov 2025 01:44:32 -0800 Subject: [PATCH 16/18] Update badge/apps/quest/__init__.py Removing duplicate imports. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- badge/apps/quest/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/badge/apps/quest/__init__.py b/badge/apps/quest/__init__.py index bd0553e..ed5cfdd 100755 --- a/badge/apps/quest/__init__.py +++ b/badge/apps/quest/__init__.py @@ -10,8 +10,7 @@ APP_DIR = ensure_app_path(globals(), "/system/apps/quest") -from badgeware import io, run, shapes, screen, PixelFont, SpriteSheet, Image, brushes -from badgeware import State, PixelFont, Image, brushes, screen, io, shapes, run +from badgeware import io, run, shapes, screen, PixelFont, SpriteSheet, Image, brushes, State from beacon import GithubUniverseBeacon from aye_arr.nec import NECReceiver import ui From dabf0f9112ef77832c22c8a4d3e8b4b68b9917e9 Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Mon, 3 Nov 2025 01:47:43 -0800 Subject: [PATCH 17/18] Update badge/apps/copilot-loop/__init__.py Removing unused imports Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- badge/apps/copilot-loop/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/badge/apps/copilot-loop/__init__.py b/badge/apps/copilot-loop/__init__.py index 2a21514..1bd5212 100644 --- a/badge/apps/copilot-loop/__init__.py +++ b/badge/apps/copilot-loop/__init__.py @@ -10,7 +10,7 @@ APP_DIR = ensure_app_path(globals(), "/system/apps/copilot-loop") -from badgeware import io, run, screen, Image, PixelFont, SpriteSheet +from badgeware import io, run, screen, Image frame_index = 1 From 90f3b052707718ac7c311e12e6d56a2170e81273 Mon Sep 17 00:00:00 2001 From: Samuli Jarvinen Date: Mon, 3 Nov 2025 01:49:16 -0800 Subject: [PATCH 18/18] Update badge/badge_app_runtime.py Adding comments Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- badge/badge_app_runtime.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/badge/badge_app_runtime.py b/badge/badge_app_runtime.py index de78465..b5b4faf 100644 --- a/badge/badge_app_runtime.py +++ b/badge/badge_app_runtime.py @@ -92,6 +92,8 @@ def add_candidate(path): try: os.chdir(app_dir) except OSError: + # It is safe to ignore errors when changing directory; app_dir may not exist or be accessible, + # but we still want to proceed with app path setup for compatibility. pass module_globals[_CACHE_KEY] = app_dir