Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
267 changes: 186 additions & 81 deletions badge/apps/menu/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,115 +4,220 @@
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 icon import Icon
from badgeware import screen, PixelFont, SpriteSheet, is_dir, file_exists, shapes, brushes, io, run
from icon import Icon, sprite_for
import ui

mona = SpriteSheet("/system/assets/mona-sprites/mona-default.png", 11, 1)
screen.font = PixelFont.load("/system/assets/fonts/ark.ppf")
# screen.antialias = Image.X2

# 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}")
ROOT = "/system/apps"
current_path = ROOT
path_stack = []
current_entries = []
current_page = 0
total_pages = 1
icons = []
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:
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))
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)
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"]
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


# 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 current_dir():
if not path_stack:
return ROOT
return path_stack[-1]


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):
icons = []
result = []
start_idx = page * APPS_PER_PAGE
end_idx = min(start_idx + APPS_PER_PAGE, len(apps))

for i in range(start_idx, end_idx):
app = apps[i]
name, path = app[0], app[1]

if is_dir(f"/system/apps/{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"
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}")
return icons

icons = load_page_icons(current_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)
result.append(Icon(pos, entry["name"], slot % APPS_PER_PAGE, sprite))
except Exception as e:
print("Error loading icon for {}: {}".format(entry.get("path", ""), e))
return result


def selected_entry():
index = current_page * APPS_PER_PAGE + active
if 0 <= index < len(current_entries):
return current_entries[index]
return None

active = 0

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)
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)
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 = f"/system/apps/{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()
Expand Down
Binary file added badge/apps/menu/folder_icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added badge/apps/menu/folder_up_icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 22 additions & 1 deletion badge/apps/menu/icon.py
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -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
Expand Down