604 lines
19 KiB
Python
604 lines
19 KiB
Python
# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries
|
||
# SPDX-License-Identifier: MIT
|
||
|
||
"""
|
||
Fruit Jam OS Launcher
|
||
"""
|
||
import array
|
||
import atexit
|
||
import json
|
||
import math
|
||
import displayio
|
||
import os
|
||
import supervisor
|
||
import sys
|
||
import terminalio
|
||
import usb
|
||
|
||
import adafruit_pathlib as pathlib
|
||
from adafruit_bitmap_font import bitmap_font
|
||
from adafruit_display_text.text_box import TextBox
|
||
from adafruit_display_text.bitmap_label import Label
|
||
from adafruit_displayio_layout.layouts.grid_layout import GridLayout
|
||
from adafruit_anchored_tilegrid import AnchoredTileGrid
|
||
import adafruit_imageload
|
||
import adafruit_usb_host_descriptors
|
||
from adafruit_anchored_group import AnchoredGroup
|
||
from adafruit_fruitjam.peripherals import request_display_config, VALID_DISPLAY_SIZES
|
||
from adafruit_argv_file import read_argv, write_argv
|
||
|
||
"""
|
||
desktop launcher code.py arguments
|
||
|
||
0: next code files
|
||
1-N: args to pass to next code file
|
||
|
||
"""
|
||
|
||
args = read_argv(__file__)
|
||
if args is not None and len(args) > 0:
|
||
next_code_file = None
|
||
remaining_args = None
|
||
if len(args) > 0:
|
||
next_code_file = args[0]
|
||
if len(args) > 1:
|
||
remaining_args = args[1:]
|
||
|
||
if remaining_args is not None:
|
||
write_argv(next_code_file, remaining_args)
|
||
|
||
next_code_file = next_code_file
|
||
supervisor.set_next_code_file(next_code_file, sticky_on_reload=False, reload_on_error=True,
|
||
working_directory="/".join(next_code_file.split("/")[:-1]))
|
||
print(f"launching: {next_code_file}")
|
||
supervisor.reload()
|
||
|
||
# read environment variables
|
||
color_palette = {
|
||
"bg": os.getenv("FRUIT_JAM_OS_BG", 0x222222),
|
||
"fg": os.getenv("FRUIT_JAM_OS_FG", 0xffffff),
|
||
"accent": os.getenv("FRUIT_JAM_OS_ACCENT", 0x008800),
|
||
"arrow": os.getenv("FRUIT_JAM_OS_ARROW"),
|
||
}
|
||
|
||
display = supervisor.runtime.display
|
||
if (width_config := os.getenv("CIRCUITPY_DISPLAY_WIDTH")) is not None:
|
||
if width_config not in [x[0] for x in VALID_DISPLAY_SIZES]:
|
||
raise ValueError(f"Invalid display size. Must be one of: {VALID_DISPLAY_SIZES}")
|
||
for display_size in VALID_DISPLAY_SIZES:
|
||
if display_size[0] == width_config:
|
||
break
|
||
else:
|
||
display_size = (720, 400)
|
||
request_display_config(*display_size)
|
||
|
||
scale = 1
|
||
if display.width > 360:
|
||
scale = 2
|
||
|
||
font_file = "/fonts/terminal.lvfontbin"
|
||
font = bitmap_font.load_font(font_file)
|
||
scaled_group = displayio.Group(scale=scale)
|
||
|
||
main_group = displayio.Group()
|
||
main_group.append(scaled_group)
|
||
|
||
display.root_group = main_group
|
||
|
||
background_bmp = displayio.Bitmap(display.width, display.height, 1)
|
||
bg_palette = displayio.Palette(1)
|
||
bg_palette[0] = color_palette["bg"]
|
||
bg_tg = displayio.TileGrid(bitmap=background_bmp, pixel_shader=bg_palette)
|
||
scaled_group.append(bg_tg)
|
||
|
||
# load the mouse cursor bitmap
|
||
mouse_bmp = displayio.OnDiskBitmap("launcher_assets/mouse_cursor.bmp")
|
||
|
||
# make the background pink pixels transparent
|
||
mouse_bmp.pixel_shader.make_transparent(0)
|
||
|
||
# create a TileGrid for the mouse, using its bitmap and pixel_shader
|
||
mouse_tg = displayio.TileGrid(mouse_bmp, pixel_shader=mouse_bmp.pixel_shader)
|
||
|
||
# move it to the center of the display
|
||
mouse_tg.x = display.width // (2 * scale)
|
||
mouse_tg.y = display.height // (2 * scale)
|
||
# 046d:c52f
|
||
|
||
launcher_config = {}
|
||
if pathlib.Path("launcher.conf.json").exists():
|
||
with open("launcher.conf.json", "r") as f:
|
||
launcher_config = json.load(f)
|
||
|
||
# mouse = usb.core.find(idVendor=0x046d, idProduct=0xc52f)
|
||
|
||
DIR_IN = 0x80
|
||
mouse_interface_index, mouse_endpoint_address = None, None
|
||
mouse = None
|
||
mouse_was_attached = None
|
||
|
||
if "use_mouse" in launcher_config and launcher_config["use_mouse"]:
|
||
|
||
# scan for connected USB device and loop over any found
|
||
print("scanning usb")
|
||
for device in usb.core.find(find_all=True):
|
||
# print device info
|
||
print(f"{device.idVendor:04x}:{device.idProduct:04x}")
|
||
print(device.manufacturer, device.product)
|
||
print()
|
||
config_descriptor = adafruit_usb_host_descriptors.get_configuration_descriptor(
|
||
device, 0
|
||
)
|
||
print(config_descriptor)
|
||
|
||
_possible_interface_index, _possible_endpoint_address = adafruit_usb_host_descriptors.find_boot_mouse_endpoint(device)
|
||
if _possible_interface_index is not None and _possible_endpoint_address is not None:
|
||
mouse = device
|
||
mouse_interface_index = _possible_interface_index
|
||
mouse_endpoint_address = _possible_endpoint_address
|
||
print(f"mouse interface: {mouse_interface_index} endpoint_address: {hex(mouse_endpoint_address)}")
|
||
|
||
mouse_was_attached = None
|
||
if mouse is not None:
|
||
# detach the kernel driver if needed
|
||
if mouse.is_kernel_driver_active(0):
|
||
mouse_was_attached = True
|
||
mouse.detach_kernel_driver(0)
|
||
else:
|
||
mouse_was_attached = False
|
||
|
||
# set configuration on the mouse so we can use it
|
||
mouse.set_configuration()
|
||
|
||
mouse_buf = array.array("b", [0] * 8)
|
||
|
||
WIDTH = int(280 / 360 * display.width // scale)
|
||
HEIGHT = int(182 / 200 * display.height // scale)
|
||
|
||
config = {
|
||
"menu_title": "Launcher Menu",
|
||
"width": 3,
|
||
"height": 2,
|
||
"apps": [
|
||
{
|
||
"title": "🐍Snake🐍",
|
||
"icon": "icon_snake.bmp",
|
||
"file": "code_snake_game.py"
|
||
},
|
||
{
|
||
"title": "Nyan😺Flap",
|
||
"icon": "icon_flappynyan.bmp",
|
||
"file": "code_flappy_nyan.py"
|
||
},
|
||
{
|
||
"title": "Memory🧠",
|
||
"icon": "icon_memory.bmp",
|
||
"file": "code_memory.py"
|
||
},
|
||
{
|
||
"title": "Matrix",
|
||
"icon": "/apps/matrix/icon.bmp",
|
||
"file": "/apps/matrix/code.py"
|
||
},
|
||
{
|
||
"title": "Breakout",
|
||
"icon": "icon_breakout.bmp",
|
||
"file": "code_breakout.py"
|
||
},
|
||
{
|
||
"title": "Paint🖌️",
|
||
"icon": "icon_paint.bmp",
|
||
}
|
||
|
||
]
|
||
}
|
||
|
||
cell_width = WIDTH // config["width"]
|
||
cell_height = HEIGHT // config["height"]
|
||
|
||
default_icon_bmp, default_icon_palette = adafruit_imageload.load("launcher_assets/default_icon.bmp")
|
||
default_icon_palette.make_transparent(0)
|
||
menu_grid = GridLayout(x=(display.width // scale - WIDTH) // 2,
|
||
y=(display.height // scale - HEIGHT) // 2,
|
||
width=WIDTH, height=HEIGHT, grid_size=(config["width"], config["height"]),
|
||
divider_lines=False)
|
||
scaled_group.append(menu_grid)
|
||
|
||
menu_title_txt = Label(font, text="Fruit Jam OS", color=color_palette["fg"])
|
||
menu_title_txt.anchor_point = (0.5, 0.5)
|
||
menu_title_txt.anchored_position = (display.width // (2 * scale), 2)
|
||
scaled_group.append(menu_title_txt)
|
||
|
||
app_titles = []
|
||
apps = []
|
||
app_path = pathlib.Path("/apps")
|
||
i = 0
|
||
|
||
pages = [{}]
|
||
|
||
cur_file_index = 0
|
||
|
||
for path in app_path.iterdir():
|
||
print(path)
|
||
|
||
code_file = path / "code.py"
|
||
if not code_file.exists():
|
||
continue
|
||
|
||
metadata_file = path / "metadata.json"
|
||
if not metadata_file.exists():
|
||
metadata_file = None
|
||
metadata = None
|
||
if metadata_file is not None:
|
||
with open(metadata_file.absolute(), "r") as f:
|
||
metadata = json.load(f)
|
||
|
||
if metadata is not None and "icon" in metadata:
|
||
icon_file = path / metadata["icon"]
|
||
else:
|
||
icon_file = path / "icon.bmp"
|
||
|
||
if not icon_file.exists():
|
||
icon_file = None
|
||
|
||
if metadata is not None and "title" in metadata:
|
||
title = metadata["title"]
|
||
else:
|
||
title = path.name
|
||
|
||
apps.append({
|
||
"title": title,
|
||
"icon": str(icon_file.absolute()) if icon_file is not None else None,
|
||
"file": str(code_file.absolute()),
|
||
"dir": path
|
||
})
|
||
|
||
i += 1
|
||
|
||
print("launcher config", launcher_config)
|
||
if "favorites" in launcher_config:
|
||
|
||
for favorite_app in reversed(launcher_config["favorites"]):
|
||
print("checking favorite", favorite_app)
|
||
for app in apps:
|
||
print(f"checking app: {app["dir"]}")
|
||
if app["dir"] == f"/apps/{favorite_app}":
|
||
apps.remove(app)
|
||
apps.insert(0, app)
|
||
|
||
|
||
def reuse_cell(grid_coords):
|
||
try:
|
||
cell_group = menu_grid.get_content(grid_coords)
|
||
return cell_group
|
||
except KeyError:
|
||
return None
|
||
|
||
|
||
def _create_cell_group(app):
|
||
cell_group = AnchoredGroup()
|
||
|
||
if app["icon"] is None:
|
||
icon_tg = displayio.TileGrid(bitmap=default_icon_bmp, pixel_shader=default_icon_palette)
|
||
cell_group.append(icon_tg)
|
||
else:
|
||
icon_bmp, icon_palette = adafruit_imageload.load(app["icon"])
|
||
icon_tg = displayio.TileGrid(bitmap=icon_bmp, pixel_shader=icon_palette)
|
||
cell_group.append(icon_tg)
|
||
|
||
icon_tg.x = cell_width // 2 - icon_tg.tile_width // 2
|
||
title_txt = TextBox(font, text=app["title"], width=cell_width, height=18,
|
||
align=TextBox.ALIGN_CENTER, color=color_palette["fg"])
|
||
icon_tg.y = (cell_height - icon_tg.tile_height - title_txt.height) // 2
|
||
cell_group.append(title_txt)
|
||
title_txt.anchor_point = (0, 0)
|
||
title_txt.anchored_position = (0, icon_tg.y + icon_tg.tile_height)
|
||
return cell_group
|
||
|
||
|
||
def _reuse_cell_group(app, cell_group):
|
||
_unhide_cell_group(cell_group)
|
||
if app["icon"] is None:
|
||
icon_tg = cell_group[0]
|
||
icon_tg.bitmap = default_icon_bmp
|
||
icon_tg.pixel_shader = default_icon_palette
|
||
else:
|
||
icon_bmp, icon_palette = adafruit_imageload.load(app["icon"])
|
||
icon_tg = cell_group[0]
|
||
icon_tg.bitmap = icon_bmp
|
||
icon_tg.pixel_shader = icon_palette
|
||
|
||
icon_tg.x = cell_width // 2 - icon_tg.tile_width // 2
|
||
# title_txt = TextBox(font, text=app["title"], width=cell_width, height=18,
|
||
# align=TextBox.ALIGN_CENTER, color=color_palette["fg"])
|
||
# cell_group.append(title_txt)
|
||
title_txt = cell_group[1]
|
||
title_txt.text = app["title"]
|
||
# title_txt.anchor_point = (0, 0)
|
||
# title_txt.anchored_position = (0, icon_tg.y + icon_tg.tile_height)
|
||
|
||
|
||
def _hide_cell_group(cell_group):
|
||
# hide the tilegrid
|
||
cell_group[0].hidden = True
|
||
# set the title to blank space
|
||
cell_group[1].text = " "
|
||
|
||
|
||
def _unhide_cell_group(cell_group):
|
||
# show tilegrid
|
||
cell_group[0].hidden = False
|
||
|
||
|
||
def display_page(page_index):
|
||
max_pages = math.ceil(len(apps) / 6)
|
||
page_txt.text = f"{page_index + 1}/{max_pages}"
|
||
|
||
for grid_index in range(6):
|
||
grid_pos = (grid_index % config["width"], grid_index // config["width"])
|
||
try:
|
||
cur_app = apps[grid_index + (page_index * 6)]
|
||
except IndexError:
|
||
try:
|
||
cell_group = menu_grid.get_content(grid_pos)
|
||
_hide_cell_group(cell_group)
|
||
except KeyError:
|
||
pass
|
||
|
||
# skip to the next for loop iteration
|
||
continue
|
||
|
||
try:
|
||
cell_group = menu_grid.get_content(grid_pos)
|
||
_reuse_cell_group(cur_app, cell_group)
|
||
except KeyError:
|
||
cell_group = _create_cell_group(cur_app)
|
||
menu_grid.add_content(cell_group, grid_position=grid_pos, cell_size=(1, 1))
|
||
|
||
# app_titles.append(title_txt)
|
||
print(f"{grid_index} | {grid_index % config["width"], grid_index // config["width"]}")
|
||
|
||
|
||
page_txt = Label(terminalio.FONT, text="", scale=2, color=color_palette["fg"])
|
||
page_txt.anchor_point = (1.0, 1.0)
|
||
page_txt.anchored_position = (display.width - 2, display.height - 2)
|
||
main_group.append(page_txt)
|
||
|
||
cur_page = 0
|
||
display_page(cur_page)
|
||
|
||
left_bmp, left_palette = adafruit_imageload.load("launcher_assets/arrow_left.bmp")
|
||
left_palette.make_transparent(0)
|
||
right_bmp, right_palette = adafruit_imageload.load("launcher_assets/arrow_right.bmp")
|
||
right_palette.make_transparent(0)
|
||
if color_palette["arrow"] is not None:
|
||
left_palette[2] = right_palette[2] = color_palette["arrow"]
|
||
|
||
left_tg = AnchoredTileGrid(bitmap=left_bmp, pixel_shader=left_palette)
|
||
left_tg.anchor_point = (0, 0.5)
|
||
left_tg.anchored_position = (4, (display.height // 2 // scale) - 2)
|
||
|
||
right_tg = AnchoredTileGrid(bitmap=right_bmp, pixel_shader=right_palette)
|
||
right_tg.anchor_point = (1.0, 0.5)
|
||
right_tg.anchored_position = ((display.width // scale) - 4, (display.height // 2 // scale) - 2)
|
||
original_arrow_btn_color = left_palette[2]
|
||
|
||
scaled_group.append(left_tg)
|
||
scaled_group.append(right_tg)
|
||
|
||
if len(apps) <= 6:
|
||
right_tg.hidden = True
|
||
left_tg.hidden = True
|
||
|
||
if mouse:
|
||
scaled_group.append(mouse_tg)
|
||
|
||
|
||
help_txt = Label(terminalio.FONT, text="[Arrow]: Move\n[E]: Edit\n[Enter]: Run\n[1-9]: Page",
|
||
color=color_palette["fg"])
|
||
# help_txt = TextBox(terminalio.FONT, width=88, height=30, align=TextBox.ALIGN_RIGHT, background_color=color_palette["accent"], text="[E]: Edit\n[Enter]: Run")
|
||
help_txt.anchor_point = (0, 0)
|
||
|
||
help_txt.anchored_position = (2, 2)
|
||
# help_txt.anchored_position = (display.width - 89, 1)
|
||
|
||
print(help_txt.bounding_box)
|
||
main_group.append(help_txt)
|
||
|
||
|
||
def atexit_callback():
|
||
"""
|
||
re-attach USB devices to kernel if needed.
|
||
:return:
|
||
"""
|
||
print("inside atexit callback")
|
||
if mouse_was_attached and not mouse.is_kernel_driver_active(0):
|
||
mouse.attach_kernel_driver(0)
|
||
|
||
|
||
atexit.register(atexit_callback)
|
||
|
||
selected = None
|
||
|
||
|
||
def change_selected(new_selected):
|
||
global selected
|
||
# tuple means an item in the grid is selected
|
||
if isinstance(selected, tuple):
|
||
menu_grid.get_content(selected)[1].background_color = None
|
||
|
||
# TileGrid means arrow is selected
|
||
elif isinstance(selected, AnchoredTileGrid):
|
||
selected.pixel_shader[2] = original_arrow_btn_color
|
||
|
||
# tuple means an item in the grid is selected
|
||
if isinstance(new_selected, tuple):
|
||
menu_grid.get_content(new_selected)[1].background_color = color_palette["accent"]
|
||
# TileGrid means arrow is selected
|
||
elif isinstance(new_selected, AnchoredTileGrid):
|
||
new_selected.pixel_shader[2] = color_palette["accent"]
|
||
selected = new_selected
|
||
|
||
|
||
change_selected((0, 0))
|
||
|
||
def page_right():
|
||
global cur_page
|
||
if cur_page < math.ceil(len(apps) / 6) - 1:
|
||
cur_page += 1
|
||
display_page(cur_page)
|
||
|
||
def page_left():
|
||
global cur_page
|
||
if cur_page > 0:
|
||
cur_page -= 1
|
||
display_page(cur_page)
|
||
|
||
|
||
def handle_key_press(key):
|
||
global index, editor_index, cur_page
|
||
# print(key)
|
||
# up key
|
||
if key == "\x1b[A":
|
||
if isinstance(selected, tuple):
|
||
change_selected((selected[0], (selected[1] - 1) % 2))
|
||
elif selected is left_tg:
|
||
change_selected((0, 0))
|
||
elif selected is right_tg:
|
||
change_selected((2, 0))
|
||
|
||
|
||
# down key
|
||
elif key == "\x1b[B":
|
||
if isinstance(selected, tuple):
|
||
change_selected((selected[0], (selected[1] + 1) % 2))
|
||
elif selected is left_tg:
|
||
change_selected((0, 1))
|
||
elif selected is right_tg:
|
||
change_selected((2, 1))
|
||
# selected = min(len(config["apps"]) - 1, selected + 1)
|
||
|
||
# left key
|
||
elif key == "\x1b[D":
|
||
if isinstance(selected, tuple):
|
||
if selected[0] >= 1:
|
||
change_selected((selected[0] - 1, selected[1]))
|
||
elif not left_tg.hidden:
|
||
change_selected(left_tg)
|
||
else:
|
||
change_selected(((selected[0] - 1) % 3, selected[1]))
|
||
elif selected is left_tg:
|
||
change_selected(right_tg)
|
||
elif selected is right_tg:
|
||
change_selected((2, 0))
|
||
|
||
# right key
|
||
elif key == "\x1b[C":
|
||
if isinstance(selected, tuple):
|
||
if selected[0] <= 1:
|
||
change_selected((selected[0] + 1, selected[1]))
|
||
elif not right_tg.hidden:
|
||
change_selected(right_tg)
|
||
else:
|
||
change_selected(((selected[0] + 1) % 3, selected[1]))
|
||
elif selected is left_tg:
|
||
change_selected((0, 0))
|
||
elif selected is right_tg:
|
||
change_selected(left_tg)
|
||
|
||
elif key == "\n":
|
||
if isinstance(selected, tuple):
|
||
index = (selected[1] * 3 + selected[0]) + (cur_page * 6)
|
||
if index >= len(apps):
|
||
index = None
|
||
print("go!")
|
||
elif selected is left_tg:
|
||
page_left()
|
||
elif selected is right_tg:
|
||
page_right()
|
||
elif key == "e":
|
||
if isinstance(selected, tuple):
|
||
editor_index = (selected[1] * 3 + selected[0]) + (cur_page * 6)
|
||
if editor_index >= len(apps):
|
||
editor_index = None
|
||
|
||
print("go!")
|
||
elif key in "123456789":
|
||
if key != "9":
|
||
requested_page = int(key)
|
||
max_page = math.ceil(len(apps) / 6)
|
||
if requested_page <= max_page:
|
||
cur_page = requested_page - 1
|
||
display_page(requested_page-1)
|
||
else: # key == 9
|
||
max_page = math.ceil(len(apps) / 6)
|
||
cur_page = max_page - 1
|
||
display_page(max_page - 1)
|
||
else:
|
||
print(f"unhandled key: {repr(key)}")
|
||
|
||
|
||
print(f"apps: {apps}")
|
||
print(mouse_interface_index, mouse_endpoint_address)
|
||
while True:
|
||
index = None
|
||
editor_index = None
|
||
|
||
available = supervisor.runtime.serial_bytes_available
|
||
if available:
|
||
c = sys.stdin.read(available)
|
||
print(repr(c))
|
||
# app_titles[selected].background_color = None
|
||
|
||
handle_key_press(c)
|
||
print("selected", selected)
|
||
# app_titles[selected].background_color = color_palette["accent"]
|
||
|
||
if mouse:
|
||
try:
|
||
# attempt to read data from the mouse
|
||
# 10ms timeout, so we don't block long if there
|
||
# is no data
|
||
count = mouse.read(mouse_endpoint_address, mouse_buf, timeout=20)
|
||
except usb.core.USBTimeoutError:
|
||
# skip the rest of the loop if there is no data
|
||
count = 0
|
||
|
||
# update the mouse tilegrid x and y coordinates
|
||
# based on the delta values read from the mouse
|
||
if count > 0:
|
||
mouse_tg.x = max(0, min((display.width // scale) - 1, mouse_tg.x + mouse_buf[1]))
|
||
mouse_tg.y = max(0, min((display.height // scale) - 1, mouse_tg.y + mouse_buf[2]))
|
||
|
||
if mouse_buf[0] & (1 << 0) != 0:
|
||
print("left click")
|
||
clicked_cell = menu_grid.which_cell_contains((mouse_tg.x, mouse_tg.y))
|
||
if clicked_cell is not None:
|
||
index = clicked_cell[1] * config["width"] + clicked_cell[0]
|
||
|
||
if right_tg.contains((mouse_tg.x, mouse_tg.y, 0)):
|
||
page_right()
|
||
if left_tg.contains((mouse_tg.x, mouse_tg.y, 0)):
|
||
page_left()
|
||
|
||
|
||
if index is not None:
|
||
print("index", index)
|
||
print(f"selected: {apps[index]}")
|
||
launch_file = apps[index]["file"]
|
||
supervisor.set_next_code_file(launch_file, sticky_on_reload=False, reload_on_error=True,
|
||
working_directory="/".join(launch_file.split("/")[:-1]))
|
||
supervisor.reload()
|
||
if editor_index is not None:
|
||
print("editor_index", editor_index)
|
||
print(f"editor selected: {apps[editor_index]}")
|
||
edit_file = apps[editor_index]["file"]
|
||
|
||
editor_launch_file = "apps/editor/code.py"
|
||
write_argv(editor_launch_file, [apps[editor_index]["file"]])
|
||
# with open(argv_filename(launch_file), "w") as f:
|
||
# f.write(json.dumps([apps[editor_index]["file"]]))
|
||
|
||
supervisor.set_next_code_file(editor_launch_file, sticky_on_reload=False, reload_on_error=True,
|
||
working_directory="/".join(editor_launch_file.split("/")[:-1]))
|
||
supervisor.reload()
|