Created
November 7, 2025 23:08
-
-
Save FoamyGuy/41f078548b496e31b41f42fe94d02eb7 to your computer and use it in GitHub Desktop.
Revisions
-
FoamyGuy created this gist
Nov 7, 2025 .There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,572 @@ # 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() This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,228 @@ # 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()