Skip to content

Instantly share code, notes, and snippets.

@FoamyGuy
Created November 7, 2025 23:08
Show Gist options
  • Select an option

  • Save FoamyGuy/41f078548b496e31b41f42fe94d02eb7 to your computer and use it in GitHub Desktop.

Select an option

Save FoamyGuy/41f078548b496e31b41f42fe94d02eb7 to your computer and use it in GitHub Desktop.
# 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 time
import displayio
import os
import supervisor
import sys
import terminalio
from adafruit_usb_host_mouse import find_and_init_boot_mouse
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
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
from launcher_config import LauncherConfig
"""
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()
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)
display = supervisor.runtime.display
SCREENSAVER_TIMEOUT = 3 # seconds
last_interaction_time = time.monotonic()
screensaver = None
previous_mouse_location = [0, 0]
scale = 1
if display.width > 360:
scale = 2
launcher_config = LauncherConfig()
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] = launcher_config.palette_bg
bg_tg = displayio.TileGrid(bitmap=background_bmp, pixel_shader=bg_palette)
scaled_group.append(bg_tg)
WIDTH = int(298 / 360 * display.width // scale)
HEIGHT = int(182 / 200 * display.height // scale)
mouse = None
last_left_button_state = 0
left_button_pressed = False
if launcher_config.use_mouse:
mouse = find_and_init_boot_mouse()
if mouse:
mouse.scale = scale
mouse_tg = mouse.tilegrid
mouse_tg.x = display.width // (2 * scale)
mouse_tg.y = display.height // (2 * scale)
config = {
"menu_title": "Launcher Menu",
"width": 3,
"height": 2,
}
cell_width = WIDTH // config["width"]
cell_height = HEIGHT // config["height"]
page_size = config["width"] * 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=launcher_config.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_paths = (
pathlib.Path("/apps"),
pathlib.Path("/sd/apps")
)
pages = [{}]
cur_file_index = 0
for app_path in app_paths:
if not app_path.exists():
continue
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
})
apps = sorted(apps, key=lambda app: app["title"].lower())
print("launcher config", launcher_config)
if len(launcher_config.favorites):
for favorite_app in reversed(launcher_config.favorites):
print("checking favorite", favorite_app)
for app in apps:
app_name = str(app["dir"].absolute()).split("/")[-1]
print(f"checking app: {app_name}")
if app_name == 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=launcher_config.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=launcher_config.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) / page_size)
page_txt.text = f"{page_index + 1}/{max_pages}"
for grid_index in range(page_size):
grid_pos = (grid_index % config["width"], grid_index // config["width"])
try:
cur_app = apps[grid_index + (page_index * page_size)]
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=scale, color=launcher_config.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)
left_palette[2] = right_palette[2] = launcher_config.palette_arrow
left_tg = AnchoredTileGrid(bitmap=left_bmp, pixel_shader=left_palette)
left_tg.anchor_point = (0, 0.5)
left_tg.anchored_position = (0, (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), (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) <= page_size:
right_tg.hidden = True
left_tg.hidden = True
if mouse:
scaled_group.append(mouse_tg)
help_txt = Label(terminalio.FONT, text="[Arrow]: Move [E]: Edit [Enter]: Run [1-9]: Page",
color=launcher_config.palette_fg)
help_txt.anchor_point = (0.0, 1.0)
help_txt.anchored_position = (2, display.height - 2)
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 and mouse.was_attached and not mouse.device.is_kernel_driver_active(0):
mouse.device.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 = launcher_config.palette_accent
# TileGrid means arrow is selected
elif isinstance(new_selected, AnchoredTileGrid):
new_selected.pixel_shader[2] = launcher_config.palette_accent
selected = new_selected
change_selected((0, 0))
def page_right():
global cur_page
if cur_page < math.ceil(len(apps) / page_size) - 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) % config["height"]))
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) % config["height"]))
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) % config["width"], 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) % config["width"], 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] * config["width"] + selected[0]) + (cur_page * page_size)
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] * config["width"] + selected[0]) + (cur_page * page_size)
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) / page_size)
if requested_page <= max_page:
cur_page = requested_page - 1
display_page(requested_page - 1)
else: # key == 9
max_page = math.ceil(len(apps) / page_size)
cur_page = max_page - 1
display_page(max_page - 1)
else:
print(f"unhandled key: {repr(key)}")
print(f"apps: {apps}")
while True:
index = None
editor_index = None
now = time.monotonic()
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)
last_interaction_time = now
# app_titles[selected].background_color = launcher_config.palette_accent
if mouse:
buttons = mouse.update()
if [mouse.x, mouse.y] != previous_mouse_location:
last_interaction_time = now
previous_mouse_location[0] = mouse.x
previous_mouse_location[1] = mouse.y
# Extract button states
if buttons is None:
current_left_button_state = 0
else:
current_left_button_state = 1 if 'left' in buttons else 0
# Detect button presses
if current_left_button_state == 1 and last_left_button_state == 0:
left_button_pressed = True
elif current_left_button_state == 0 and last_left_button_state == 1:
left_button_pressed = False
# Update button states
last_left_button_state = current_left_button_state
if left_button_pressed:
print("left click")
last_interaction_time = now
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]) + (cur_page * page_size)
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 last_interaction_time + SCREENSAVER_TIMEOUT < now:
if display.auto_refresh:
display.auto_refresh = False
# show the screensaver
if screensaver is None:
m = __import__(launcher_config.data["screensaver"]["module"])
cls = getattr(m, launcher_config.data["screensaver"]["class"])
screensaver = cls()
request_display_config(screensaver.display_size[0], screensaver.display_size[1])
display = supervisor.runtime.display
if display.root_group != main_group:
display.root_group = main_group
if screensaver not in main_group:
main_group.append(screensaver)
needs_refresh = screensaver.tick()
if needs_refresh:
display.refresh()
else:
if not display.auto_refresh:
display.auto_refresh = True
if screensaver in main_group:
main_group.remove(screensaver)
request_display_config(*display_size)
display = supervisor.runtime.display
if display.root_group != main_group:
display.root_group = main_group
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()
# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries
# SPDX-License-Identifier: MIT
"""
Matrix rain visual effect
Largely ported from Arduino version in Metro_HSTX_Matrix to
CircuitPython by claude with some additional tweaking to the
colors and refresh functionality.
"""
import sys
import random
import time
import displayio
import supervisor
from displayio import Group, TileGrid
from tilepalettemapper import TilePaletteMapper
from adafruit_fruitjam.peripherals import request_display_config
import adafruit_imageload
# Define structures for character streams
class CharStream:
def __init__(self):
self.x = 0 # X position
self.y = 0 # Y position (head of the stream)
self.length = 0 # Length of the stream
self.speed = 0 # How many frames to wait before moving
self.countdown = 0 # Counter for movement
self.active = False # Whether this stream is currently active
self.chars = [" "] * 30 # Characters in the stream
class MatrixScreenSaver(Group):
display_size = (320, 240)
# screen size in tiles, tiles are 16x16
SCREEN_WIDTH = display_size[0] // 16
SCREEN_HEIGHT = display_size[1] // 16
# Color gradient list from white to dark green
COLORS = [
0xFFFFFF,
0x88FF88,
0x00FF00,
0x00DD00,
0x00BB00,
0x009900,
0x007700,
0x006600,
0x005500,
0x005500,
0x003300,
0x003300,
0x002200,
0x002200,
0x001100,
0x001100,
]
def __init__(self):
super().__init__()
self.init_graphics()
def init_graphics(self):
# Palette to use with the mapper. Has 1 extra color
# so it can have black at index 0
shader_palette = displayio.Palette(len(self.COLORS) + 1)
# set black at index 0
shader_palette[0] = 0x000000
# set the colors from the gradient above in the
# remaining indexes
for i in range(0, len(self.COLORS)):
shader_palette[i + 1] = self.COLORS[i]
# mapper to change colors of tiles within the grid
if sys.implementation.version[0] == 9:
self.grid_color_shader = TilePaletteMapper(
shader_palette, 2, self.SCREEN_WIDTH, self.SCREEN_HEIGHT
)
elif sys.implementation.version[0] >= 10:
self.grid_color_shader = TilePaletteMapper(shader_palette, 2)
# load the spritesheet
self.katakana_bmp, self.katakana_pixelshader = adafruit_imageload.load("matrix_characters.bmp")
# how many characters are in the sprite sheet
self.char_count = self.katakana_bmp.width // 16
# grid to display characters within
self.display_text_grid = TileGrid(
bitmap=self.katakana_bmp,
width=self.SCREEN_WIDTH,
height=self.SCREEN_HEIGHT,
tile_height=16,
tile_width=16,
pixel_shader=self.grid_color_shader,
)
# flip x to get backwards characters
self.display_text_grid.flip_x = True
# add the text grid to main_group, so it will be visible on the display
self.append(self.display_text_grid)
# Array of character streams
self.streams = [CharStream() for _ in range(250)]
# Stream creation rate (higher = more frequent new streams)
self.STREAM_CREATION_CHANCE = 65 # % chance per frame to create new stream
# Initial streams to create at startup
self.INITIAL_STREAMS = 30
self.setup()
def setup(self):
"""Initialize the system"""
# Seed the random number generator
random.seed(int(time.monotonic() * 1000))
# Initialize all streams
self.init_streams()
def loop(self):
"""Main program loop"""
# Update and draw all streams
self.update_streams()
# Randomly create new streams at a higher rate
if random.randint(0, 99) < self.STREAM_CREATION_CHANCE:
self.create_new_stream()
return True
def tick(self):
return self.loop()
def init_streams(self):
"""Initialize all streams as inactive"""
for _ in range(len(self.streams)):
self.streams[_].active = False
# Create initial streams for immediate visual impact
for _ in range(self.INITIAL_STREAMS):
self.create_new_stream()
def create_new_stream(self):
"""Create a new active stream"""
# Find an inactive stream
for _ in range(len(self.streams)):
if not self.streams[_].active:
# Initialize the stream
self.streams[_].x = random.randint(0, self.SCREEN_WIDTH - 1)
self.streams[_].y = random.randint(-5, -1) # Start above the screen
self.streams[_].length = random.randint(5, 20)
self.streams[_].speed = random.randint(0, 3)
self.streams[_].countdown = self.streams[_].speed
self.streams[_].active = True
# Fill with random characters
for j in range(self.streams[_].length):
# streams[i].chars[j] = get_random_char()
self.streams[_].chars[j] = random.randrange(0, self.char_count)
return
def update_streams(self):
"""Update and draw all streams"""
# Clear the display (we'll implement this by looping through display grid)
for x in range(self.SCREEN_WIDTH):
for y in range(self.SCREEN_HEIGHT):
self.display_text_grid[x, y] = 0 # Clear character
# Count active streams (for debugging if needed)
active_count = 0
for _ in range(len(self.streams)):
if self.streams[_].active:
active_count += 1
self.streams[_].countdown -= 1
# Time to move the stream down
if self.streams[_].countdown <= 0:
self.streams[_].y += 1
self.streams[_].countdown = self.streams[_].speed
# Change a random character in the stream
random_index = random.randint(0, self.streams[_].length - 1)
# streams[i].chars[random_index] = get_random_char()
self.streams[_].chars[random_index] = random.randrange(0, self.char_count)
# Draw the stream
self.draw_stream(self.streams[_])
# Check if the stream has moved completely off the screen
if self.streams[_].y - self.streams[_].length > self.SCREEN_HEIGHT:
self.streams[_].active = False
def draw_stream(self, stream):
"""Draw a single character stream"""
for _ in range(stream.length):
y = stream.y - _
# Only draw if the character is on screen
if 0 <= y < self.SCREEN_HEIGHT and 0 <= stream.x < self.SCREEN_WIDTH:
# Set the character
self.display_text_grid[stream.x, y] = stream.chars[_]
if _ + 1 < len(self.COLORS):
self.grid_color_shader[stream.x, y] = [0, _ + 1]
else:
self.grid_color_shader[stream.x, y] = [0, len(self.COLORS) - 1]
# Occasionally change a character in the stream
if random.randint(0, 99) < 25: # 25% chance
idx = random.randint(0, stream.length - 1)
stream.chars[idx] = random.randrange(0, 112)
# # Main program
# setup()
# while True:
# loop()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment