Fruit-Jam-OS/src/code.py
2025-08-05 10:28:06 -05:00

604 lines
19 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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()