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.

Revisions

  1. FoamyGuy created this gist Nov 7, 2025.
    572 changes: 572 additions & 0 deletions code.py
    Original 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()
    228 changes: 228 additions & 0 deletions matrix_screensaver.py
    Original 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()