Overview
This Fruit Jam app lets you design printable greeting cards. The text and image on the front of the card, as well as the text inside the card are all customizable.
If your card is in celebration of one of the winter holidays, you can use the include snowflake designer to make a geometric snowflake for the front of your card. If snowflakes not your style? No problem, the image on the front can be a custom SVG or PNG image file instead. It can also easily adapt to cards for any occasion by omitting or changing the emoji that appear on the front of the card.
Once all of the text and image details are set, the card is generated and put on the CPSAVES drive, where it can be accessed by your PC. Open the card HTML file in a browser and it's ready to print, fold up, and then give to a friend or family member.
Page last edited December 22, 2025
Text editor powered by tinymce.
Install CircuitPython
CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.
CircuitPython Quickstart
Follow this step-by-step to quickly get CircuitPython running on your board.
Click the link above to download the latest CircuitPython UF2 file.
Save it wherever is convenient for you.
To enter the bootloader, hold down the BOOT/BOOTSEL button (highlighted in red above), and while continuing to hold it (don't let go!), press and release the reset button (highlighted in red or blue above). Continue to hold the BOOT/BOOTSEL button until the RP2350 drive appears!
If the drive does not appear, release all the buttons, and then repeat the process above.
You can also start with your board unplugged from USB, press and hold the BOOTSEL button (highlighted in red above), continue to hold it while plugging it into USB, and wait for the drive to appear before releasing the button.
A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.
You will see a new disk drive appear called RP2350.
Drag the adafruit-circuitpython-boardname-language-version.uf2 file to RP2350.
The RP2350 drive will disappear and a new disk drive called CIRCUITPY will appear.
That's it, you're done! :)
Safe Mode
You want to edit your code.py or modify the files on your CIRCUITPY drive, but find that you can't. Perhaps your board has gotten into a state where CIRCUITPY is read-only. You may have turned off the CIRCUITPY drive altogether. Whatever the reason, safe mode can help.
Safe mode in CircuitPython does not run any user code on startup, and disables auto-reload. This means a few things. First, safe mode bypasses any code in boot.py (where you can set CIRCUITPY read-only or turn it off completely). Second, it does not run the code in code.py. And finally, it does not automatically soft-reload when data is written to the CIRCUITPY drive.
Therefore, whatever you may have done to put your board in a non-interactive state, safe mode gives you the opportunity to correct it without losing all of the data on the CIRCUITPY drive.
To enter safe mode when using CircuitPython, plug in your board or hit reset (highlighted in red above). Immediately after the board starts up or resets, it waits 1000ms. On some boards, the onboard status LED (highlighted in green above) will blink yellow during that time. If you press reset during that 1000ms, the board will start up in safe mode. It can be difficult to react to the yellow LED, so you may want to think of it simply as a slow double click of the reset button. (Remember, a fast double click of reset enters the bootloader.)
In Safe Mode
If you successfully enter safe mode on CircuitPython, the LED will intermittently blink yellow three times.
If you connect to the serial console, you'll find the following message.
Auto-reload is off. Running in safe mode! Not running saved code. CircuitPython is in safe mode because you pressed the reset button during boot. Press again to exit safe mode. Press any key to enter the REPL. Use CTRL-D to reload.
You can now edit the contents of the CIRCUITPY drive. Remember, your code will not run until you press the reset button, or unplug and plug in your board, to get out of safe mode.
Flash Resetting UF2
If your board ever gets into a really weird state and CIRCUITPY doesn't show up as a disk drive after installing CircuitPython, try loading this 'nuke' UF2 to RP2350. which will do a 'deep clean' on your Flash Memory. You will lose all the files on the board, but at least you'll be able to revive it! After loading this UF2, follow the steps above to re-install CircuitPython.
Page last edited December 22, 2025
Text editor powered by tinymce.
Code
Getting the Program's Files
To use the application, you need to obtain code.py with the program, and the other project files to place on the Fruit Jam CIRCUITPY drive.
Thankfully, this can be done in one go. In the example below, click the Download Project Bundle button below to download the necessary libraries, the code.py file, and other project files in a zip file.
Connect your board to your computer via a known good data+power USB cable. The board should show up in your File Explorer/Finder (depending on your operating system) as a flash drive named CIRCUITPY.
Extract the contents of the zip file, copy the lib directory files to CIRCUITPY/lib. Copy the code.py, card_maker_helpers.py, card.template.html, snowflake.template.svg, and checkbox.bmp files to your CIRCUITPY drive. The program should self start.
# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries
# SPDX-License-Identifier: MIT
"""
Greeting card maker for Fruit Jam. Input messages for the front
and inside of the card and select a custom image for the front,
or use a snowflake image designed right inside the app.
"""
import json
import os
import sys
import time
import displayio
import storage
import supervisor
import terminalio
from adafruit_usb_host_mouse import find_and_init_boot_mouse
from adafruit_fruitjam.peripherals import request_display_config
from adafruit_templateengine import render_template
from adafruit_displayio_layout.layouts.page_layout import PageLayout
from adafruit_display_text.bitmap_label import Label
from adafruit_display_text.text_box import TextBox
from adafruit_checkbox import CheckBox
from adafruit_button import Button
from card_maker_helpers import (
PointHighlighterCache,
svg_points,
fill_polygon,
draw_snowflake,
random_polygon,
distance,
)
# change emoji here if you want to use different ones
emoji = "🎄⛄❄️🌟"
CLICK_COOLDOWN = 0.5 # in seconds, how long to wait before along another mouse click
last_click_time = -1 # when the last mouse click occurred
focused_input = None # var to hold the input box that is currently focused
def update_color():
"""
Update the color of the snowflake palette based on the
value from the color input box
"""
hex_color_str = snowflake_color_input.text
try:
hex_color_int = int(hex_color_str, 16)
except ValueError:
return
palette[0] = hex_color_int
# set the display to 320x240 if it isn't already
request_display_config(320, 240)
# display and main group setup
display = supervisor.runtime.display
main_group = displayio.Group()
display.root_group = main_group
# page layout will hold different pages and allow us to switch between them
page_layout = PageLayout(0, 0)
main_group.append(page_layout)
# create a Group for the snowflake maker screen
snowflake_maker_group = displayio.Group()
# setup snowflake palette
palette = displayio.Palette(3)
palette[0] = 0x3388FF
palette[1] = 0xFFFFFF
palette[2] = 0xFF0000
# setup Bitmap to hold the top left quadrant of the snowflake.
# full snowflake is 240x240, so each quadrant is 120x120
snowflake_bmp = displayio.Bitmap(120, 120, 3)
snowflake_bmp.fill(1)
# TileGrid for the top left quadrant
top_left_tg = displayio.TileGrid(snowflake_bmp, pixel_shader=palette)
snowflake_maker_group.append(top_left_tg)
# TileGrid for the top right quadrant
top_right_tg = displayio.TileGrid(snowflake_bmp, pixel_shader=palette)
# mirror horizontally
top_right_tg.flip_x = True
# place it to the right of the top left quadrant
top_right_tg.x = top_left_tg.x + top_left_tg.tile_width
snowflake_maker_group.append(top_right_tg)
# TileGrid for the bottom left quadrant
bottom_left_tg = displayio.TileGrid(snowflake_bmp, pixel_shader=palette)
# mirror vertically
bottom_left_tg.flip_y = True
# place it below the top left quadrant
bottom_left_tg.y = top_left_tg.y + top_left_tg.tile_height
snowflake_maker_group.append(bottom_left_tg)
# TileGrid for the bottom right quadrant
bottom_right_tg = displayio.TileGrid(snowflake_bmp, pixel_shader=palette)
# mirror both horizontally and vertically
bottom_right_tg.flip_x = True
bottom_right_tg.flip_y = True
# place it below top quadrants, and to the right of bottom left quadrant
bottom_right_tg.x = top_left_tg.x + top_left_tg.tile_width
bottom_right_tg.y = top_left_tg.y + top_left_tg.tile_height
snowflake_maker_group.append(bottom_right_tg)
# setup help text for the right side of the screen
snowflake_maker_info_text = TextBox(terminalio.FONT, 80, 240)
snowflake_maker_info_text.anchor_point = (0, 0)
snowflake_maker_info_text.anchored_position = (242, 0)
snowflake_maker_info_text.text = "Click points, then click back on the first one to cut out.\n\n[del]ete all\n\n[r]andom\n\n[ctrl+z] undo \n\n[esc] back" # pylint: disable=line-too-long
snowflake_maker_group.append(snowflake_maker_info_text)
# setup a cache for PointHighlighters, used for showing which point
# the user clicked on. The Cache allows them to be re-used.
point_highlighter_cache = PointHighlighterCache(snowflake_maker_group)
# setup mouse
mouse = find_and_init_boot_mouse()
if mouse is None:
raise RuntimeError("No mouse found connected to USB Host")
# add the mouse TileGrid to the main group
main_group.append(mouse.tilegrid)
# list to hold the points that have been clicked
clicked_points = []
# data object that will get stored as JSON in CPSAVES
# to let the user resume where they left off
card_json_data = {"polygons": []}
# list of polygon points in string format ready to use in a SVG polygon
svg_polygons = []
# check if there is any existing card data json file
if "card_data.json" in os.listdir("/saves/"):
print("loading card_data.json")
# read and load the data from the existing file
with open("/saves/card_data.json", "r") as f:
card_json_data = json.load(f)
# Draw each polygon from the data, and build up the list of SVG polygons
for points in card_json_data["polygons"]:
svg_polygons.append(svg_points(points))
fill_polygon(snowflake_bmp, points, 0)
# Group for the card setup screen
card_setup_group = displayio.Group()
# background palette to hold white
background_palette = displayio.Palette(1)
background_palette[0] = 0xFFFFFF
# background Bitmap to put white underneath all UI elements
background_bmp = displayio.Bitmap(32, 24, 1)
background_tg = displayio.TileGrid(
bitmap=background_bmp, pixel_shader=background_palette
)
background_group = displayio.Group(
scale=10
) # scale 10 to save RAM with a smaller Bitmap
background_group.append(background_tg)
card_setup_group.append(background_group)
# label above the front message input box
front_msg_lbl = Label(terminalio.FONT, text="Front Message", color=0x000000)
front_msg_lbl.anchor_point = (0, 0)
front_msg_lbl.anchored_position = (2, 2)
card_setup_group.append(front_msg_lbl)
# front message input box, for the user to type in a message for the front of the card
front_msg_input = TextBox(
terminalio.FONT,
120,
14,
text="Happy Holidays",
color=0x000000,
background_color=0xDDDDDD,
)
front_msg_input.anchor_point = (0, 0)
front_msg_input.anchored_position = (2, 2 + 12 + 6)
# check if there is an outside message in the saved card data
if "outside_message" in card_json_data and card_json_data["outside_message"]:
# check if the emoji were enabled in the saved outside message by presence of <br> tag
# set the text on the input box from the saved outside message
if "<br>" in card_json_data["outside_message"]:
# show the portion without the emoji in the front message input box
front_msg_input.text = card_json_data["outside_message"].split("<br>")[1]
else:
# show the whole front message in the input box
front_msg_input.text = card_json_data["outside_message"]
card_setup_group.append(front_msg_input)
# custom Bitmap used for all CheckBoxes
checkbox_bmp = displayio.OnDiskBitmap("checkbox.bmp")
checkbox_bmp.pixel_shader.make_transparent(0)
# CheckBox to the right of front message input
# allows enable/disable of emoji on front of card
front_emoji_checkbox = CheckBox(
terminalio.FONT, spritesheet=checkbox_bmp, text="Include Emoji", text_color=0x000000
)
front_emoji_checkbox.anchor_point = (0, 0)
front_emoji_checkbox.anchored_position = (
front_msg_input.x + front_msg_input.width + 18,
2,
)
# check if the saved data had emoji in it
if "outside_message" in card_json_data and "<br>" in card_json_data["outside_message"]:
# check the box to start if saved data had emoji
front_emoji_checkbox.checked = True
card_setup_group.append(front_emoji_checkbox)
# Label to identify the inside message box
inside_msg_lbl = Label(terminalio.FONT, text="Inside Message", color=0x000000)
inside_msg_lbl.anchor_point = (0, 0)
inside_msg_lbl.anchored_position = (2, front_msg_input.anchored_position[1] + 18)
card_setup_group.append(inside_msg_lbl)
# inside message input box, wider and taller than front message
# because it can fit more text in the inside of the card
inside_msg_input = TextBox(
terminalio.FONT, 300, 60, text="", color=0x000000, background_color=0xDDDDDD
)
inside_msg_input.anchor_point = (0, 0)
inside_msg_input.anchored_position = (2, inside_msg_lbl.anchored_position[1] + 18)
# load the inside message from saved data if there was one
if "inside_message" in card_json_data and card_json_data["inside_message"]:
inside_msg_input.text = card_json_data["inside_message"]
card_setup_group.append(inside_msg_input)
# Label to identify the front picture configuration controls
front_img_lbl = Label(terminalio.FONT, text="Front Picture", color=0x000000)
front_img_lbl.anchor_point = (0, 0)
front_img_lbl.anchored_position = (2, inside_msg_input.anchored_position[1] + 66)
card_setup_group.append(front_img_lbl)
# CheckBox to use the snowflake image for the front of the card
front_snowflake_checkbox = CheckBox(
terminalio.FONT, checkbox_bmp, text="Snowflake", text_color=0x000000, checked=True
)
front_snowflake_checkbox.anchor_point = (0, 0)
front_snowflake_checkbox.anchored_position = (
2,
front_img_lbl.anchored_position[1] + 18,
)
# Set the checked state based on saved data
if "front_img" in card_json_data:
front_snowflake_checkbox.checked = card_json_data["front_img"] == "snowflake.svg"
card_setup_group.append(front_snowflake_checkbox)
# Button for launching the snowflake maker screen
btn_x = (
front_snowflake_checkbox.anchored_position[0] + front_snowflake_checkbox.width + 10
)
btn_y = front_snowflake_checkbox.anchored_position[1] - (26 // 2 - 16 // 2)
make_snowflake_btn = Button(
x=btn_x,
y=btn_y,
width=96,
height=26,
style=Button.ROUNDRECT,
fill_color=0xBBFFFF,
outline_color=0x88BBBB,
label="Make Snowflake",
label_font=terminalio.FONT,
label_color=0x000000,
)
card_setup_group.append(make_snowflake_btn)
# input box for hex color code. Selected color is applied to the snowflake cutouts
snowflake_color_input = TextBox(
terminalio.FONT, 50, 14, text="0x3388ff", color=0x000000, background_color=0xDDDDDD
)
snowflake_color_input.anchor_point = (0, 0.5)
snowflake_color_input.anchored_position = (
make_snowflake_btn.x + make_snowflake_btn.width + 10,
front_snowflake_checkbox.anchored_position[1] + 16 // 2,
)
card_setup_group.append(snowflake_color_input)
# preview swatch for the snowflake color from the input box
snowflake_color_preview_bmp = displayio.Bitmap(14, 14, 1)
snowflake_color_preview_tg = displayio.TileGrid(
snowflake_color_preview_bmp, pixel_shader=palette
)
snowflake_color_preview_tg.x = snowflake_color_input.x + snowflake_color_input.width + 4
snowflake_color_preview_tg.y = (
front_snowflake_checkbox.anchored_position[1] + 16 // 2 - 14 // 2
)
card_setup_group.append(snowflake_color_preview_tg)
# CheckBox to use custom_front.svg file from CIRCUITPY
# instead of the snowflake for the front of the card
front_custom_svg_checkbox = CheckBox(
terminalio.FONT, checkbox_bmp, text="custom_front.svg", text_color=0x000000
)
front_custom_svg_checkbox.anchor_point = (0, 0)
front_custom_svg_checkbox.anchored_position = (
2,
front_snowflake_checkbox.anchored_position[1] + 26,
)
# set the checked state based on saved data
if (
"front_img" in card_json_data
and card_json_data["front_img"] != "snowflake.svg"
and card_json_data["front_img"].endswith(".svg")
):
front_custom_svg_checkbox.checked = True
card_setup_group.append(front_custom_svg_checkbox)
# CheckBox to use custom_front.png file from CIRCUITPY
# instead of the snowflake for the front of the card
front_custom_png_checkbox = CheckBox(
terminalio.FONT, checkbox_bmp, text="custom_front.png", text_color=0x000000
)
front_custom_png_checkbox.anchor_point = (0, 0)
front_custom_png_checkbox.anchored_position = (
2,
front_custom_svg_checkbox.anchored_position[1] + 26,
)
if "front_img" in card_json_data and card_json_data["front_img"].endswith(".bmp"):
front_custom_svg_checkbox.checked = True
card_setup_group.append(front_custom_png_checkbox)
# Remount CPSAVES button in bottom right
btn_x = display.width - 96 - 4
btn_y = display.height - 26 - 4
remount_btn = Button(
x=btn_x,
y=btn_y,
width=96,
height=26,
style=Button.ROUNDRECT,
fill_color=0xD5A0E2,
outline_color=0x861EBC,
label="Remount CPSAVES",
label_font=terminalio.FONT,
label_color=0x000000,
)
card_setup_group.append(remount_btn)
# Generate Card button above remount button.
btn_x = display.width - 96 - 4
btn_y = remount_btn.y - 26 - 4
generate_card_btn = Button(
x=btn_x,
y=btn_y,
width=96,
height=26,
style=Button.ROUNDRECT,
fill_color=0xD5A0E2,
outline_color=0x861EBC,
label="Generate Card",
label_font=terminalio.FONT,
label_color=0x000000,
)
card_setup_group.append(generate_card_btn)
# Label to show status to the user when they click generate or remount
status_lbl = Label(terminalio.FONT, text="", color=0x000000)
status_lbl.anchor_point = (1.0, 1.0)
status_lbl.anchored_position = (display.width - 4, generate_card_btn.y - 6)
card_setup_group.append(status_lbl)
# add the Groups for card_setup and snowflake_maker screens to the page_layout
page_layout.add_content(card_setup_group, page_name="card_setup")
page_layout.add_content(snowflake_maker_group, page_name="snowflake_maker")
# show the card setup screen to start
page_layout.show_page("card_setup")
while True:
# update mouse
pressed_btns = mouse.update()
# store current timestamp to check for cooldown
now = time.monotonic()
# check if any keyboard events have happened
kbd_event_available = supervisor.runtime.serial_bytes_available
# on the snowflake maker screen restrict the mouse to top left quadrant
if page_layout.showing_page_name == "snowflake_maker":
mouse.x = min(mouse.x, top_left_tg.x + top_left_tg.tile_width - 1)
mouse.y = min(mouse.y, top_left_tg.y + top_left_tg.tile_height)
# if there are keyboard events
if kbd_event_available:
# read data from the keyboard input
inc_kbd_data = sys.stdin.read(kbd_event_available)
# convert to bytes for when we need to test for non ASCII things
kbd_event_bytes = inc_kbd_data.encode("utf-8")
# if user is on the card_setup screen
if page_layout.showing_page_name == "card_setup":
# if an input box has focus
if focused_input is not None:
# if there is a single key press, and it's in the
# basic visible ASCII chars range
if len(inc_kbd_data) == 1 and " " <= inc_kbd_data <= "~":
# add the entered character to the focused input box
focused_input.text += inc_kbd_data
# if the color input box is the focused one, update the snowflake color
if focused_input == snowflake_color_input:
update_color()
# if enter was pressed
elif inc_kbd_data == "\n":
# if the color input box is the focused one, update the snowflake color
if focused_input == snowflake_color_input:
update_color()
# set the background color of the input box back to normal
focused_input.background_color = 0xDDDDDD
# clear out the focused input var
focused_input = None
# if there are multiple keys in the data
elif len(inc_kbd_data) > 1:
# loop over all characters
for c in inc_kbd_data:
# if current character is in the basic visible ASCII chars range
if " " <= c <= "~":
# add it to the focused input
focused_input.text += c
# if backspace was pressed
elif kbd_event_bytes == b"\x08":
# remove the last character from the input box
focused_input.text = focused_input.text[:-1]
# unhandled key event
else:
# just print the bytes for easy debugging
print(kbd_event_bytes)
# no input is focused currently
else:
# just print the bytes for easy debugging
print(kbd_event_bytes)
# if user is on the snowflake maker screen
if page_layout.showing_page_name == "snowflake_maker":
# if esc key was pressed
if kbd_event_bytes == b"\x1b":
# go back to the card setup screen
page_layout.show_page("card_setup")
# if the delete key was pressed
elif kbd_event_bytes == b"\x1b[3~":
# clear the lists of polygons
svg_polygons.clear()
card_json_data["polygons"].clear()
# fill the snowflake bitmap with white
snowflake_bmp.fill(1)
# if ctrl-z was pressed
elif kbd_event_bytes == b"\x1a":
# remove the last polygon from the lists
svg_polygons.pop()
removed_polygon = card_json_data["polygons"].pop()
# blank the snowflake Bitmap to white
snowflake_bmp.fill(1)
# re-draw the snowflake sans removed polygon
draw_snowflake(snowflake_bmp, card_json_data["polygons"], 0)
# if r key was pressed
elif inc_kbd_data == "r":
# generate random points for a polygon
random_points = random_polygon()
# add the polygon to both lists
svg_polygons.append(svg_points(random_points))
card_json_data["polygons"].append(list(random_points))
# draw the new polygon in the snowflake Bitmap
fill_polygon(snowflake_bmp, random_points, 0)
# -- END Keyboard Handling --
# if any mouse buttons were pressed
if pressed_btns:
# if user is on the snowflake maker screen
if page_layout.showing_page_name == "snowflake_maker":
# if left button was pressed, and it's not within the cooldown time
if "left" in pressed_btns and now >= last_click_time + CLICK_COOLDOWN:
# update the last clicked timestamp
last_click_time = now
# var for the point that was clicked
cur_clicked_point = (mouse.x, mouse.y)
# if it's the first point in a polygon
if len(clicked_points) == 0:
# add it to the list of clicked points
clicked_points.append(cur_clicked_point)
# put a point highlighter at the clicked location
highlighter = point_highlighter_cache.get_highlighter(
cur_clicked_point
)
# set the color to green to signify it's the first point
highlighter[0] = 1
# skip the rest of the mouse handling, goto next iteration of main loop
continue
# if user clicked on or very near the first point again
if distance(cur_clicked_point, clicked_points[0]) < 10:
# add the new polygon to the lists
svg_polygons.append(svg_points(clicked_points))
card_json_data["polygons"].append(list(clicked_points))
# draw the new polygon on the snowflake Bitmap
fill_polygon(snowflake_bmp, clicked_points, 0)
# clear the list of clicked points
clicked_points.clear()
# release all point highlighters back to the pool to be re-used
point_highlighter_cache.release_all()
# the clicked point is not on or near the first point
else:
# add the new point to the list of clicked points
clicked_points.append(cur_clicked_point)
# put a point highlighter on the clicked point
highlighter = point_highlighter_cache.get_highlighter(
cur_clicked_point
)
# set the highlighter color to black to signify it is not the first point
highlighter[0] = 0
# if user is on the card setup screen
elif page_layout.showing_page_name == "card_setup":
# if left mouse button was pressed and it's not within the cooldown time
if "left" in pressed_btns and now >= last_click_time + CLICK_COOLDOWN:
# check if the front message input box was clicked
front_input_x1, front_input_y1 = front_msg_input.anchored_position
front_input_x2 = front_input_x1 + front_msg_input.tilegrid.tile_width
front_input_y2 = front_input_y1 + front_msg_input.tilegrid.tile_height
if (
front_input_x1 <= mouse.x <= front_input_x2
and front_input_y1 <= mouse.y <= front_input_y2
):
# update the background colors and focused input var
front_msg_input.background_color = 0xFFDDFF
inside_msg_input.background_color = 0xDDDDDD
snowflake_color_input.background_color = 0xDDDDDD
focused_input = front_msg_input
# check if the inside message input box was clicked
inside_input_x1, inside_input_y1 = inside_msg_input.anchored_position
inside_input_x2 = inside_input_x1 + inside_msg_input.width
inside_input_y2 = inside_input_y1 + inside_msg_input.height
if (
inside_input_x1 <= mouse.x <= inside_input_x2
and inside_input_y1 <= mouse.y <= inside_input_y2
):
# update the background colors and focused input var
inside_msg_input.background_color = 0xFFDDFF
front_msg_input.background_color = 0xDDDDDD
snowflake_color_input.background_color = 0xDDDDDD
focused_input = inside_msg_input
# check if the color input box was clicked
color_input_x1, color_input_y1 = snowflake_color_input.anchored_position
color_input_y1 -= 14 // 2 # account for y anchor point 0.5
color_input_x2 = color_input_x1 + snowflake_color_input.width
color_input_y2 = color_input_y1 + snowflake_color_input.height
if (
color_input_x1 <= mouse.x <= color_input_x2
and color_input_y1 <= mouse.y <= color_input_y2
):
# update the background colors and focused input var
snowflake_color_input.background_color = 0xFFDDFF
inside_msg_input.background_color = 0xDDDDDD
front_msg_input.background_color = 0xDDDDDD
focused_input = snowflake_color_input
# if the front emoji CheckBox was clicked
if front_emoji_checkbox.contains((mouse.x, mouse.y)):
# toggle its checked state
front_emoji_checkbox.checked = not front_emoji_checkbox.checked
# if the front snowflake image CheckBox was clicked
if front_snowflake_checkbox.contains((mouse.x, mouse.y)):
# toggle its checked state
front_snowflake_checkbox.checked = (
not front_snowflake_checkbox.checked
)
# uncheck the other front image CheckBoxes
if front_snowflake_checkbox.checked:
front_custom_svg_checkbox.checked = False
front_custom_png_checkbox.checked = False
# if the custom SVG front image CheckBox was clicked
if front_custom_svg_checkbox.contains((mouse.x, mouse.y)):
# toggle its checked state
front_custom_svg_checkbox.checked = (
not front_custom_svg_checkbox.checked
)
# uncheck the other front image CheckBoxes
if front_custom_svg_checkbox.checked:
front_snowflake_checkbox.checked = False
front_custom_png_checkbox.checked = False
# if the custom PNG front image CheckBox was clicked
if front_custom_png_checkbox.contains((mouse.x, mouse.y)):
# toggle its checked state
front_custom_png_checkbox.checked = (
not front_custom_png_checkbox.checked
)
# uncheck the other front image CheckBoxes
if front_custom_png_checkbox.checked:
front_snowflake_checkbox.checked = False
front_custom_svg_checkbox.checked = False
# if the make snowflake button was clicked
if make_snowflake_btn.contains((mouse.x, mouse.y)):
# show the snowflake maker screen
page_layout.show_page("snowflake_maker")
front_img = "" # var to hold the name of the front image file
# if the generate card button was clicked
if generate_card_btn.contains((mouse.x, mouse.y)):
# if no front image CheckBoxes are checked
if (
not front_snowflake_checkbox.checked
and not front_custom_png_checkbox.checked
and not front_custom_svg_checkbox.checked
):
# show an error to the user and skip to the next main loop iteration
status_lbl.text = "Must select front image"
continue
# if the snowflake front image CheckBox is checked
if front_snowflake_checkbox.checked:
# if there is no saved snowflake, and the polygons list is empty
if (
"snowflake.svg" not in os.listdir("/saves/")
and len(svg_polygons) == 0
):
# show an error that there is no snowflake and
# skip to next main loop iteration
status_lbl.text = "No snowflake image"
continue
# if there are at least one polygons
if len(svg_polygons) > 0:
# render snowflake SVG template to string
status_lbl.text = "Saving Snowflake"
out_svg = render_template(
"snowflake.template.svg",
context={
"polygons": svg_polygons,
"color": hex(palette[0])[2:],
},
)
# save snowflake SVG file in CPSAVES
with open("/saves/snowflake.svg", "w") as f:
f.write(out_svg)
# set the front image var to the snowflake SVG filename
front_img = "snowflake.svg"
# if the custom SVG front image CheckBox is checked
elif front_custom_svg_checkbox.checked:
status_lbl.text = "Saving Custom SVG"
# copy custom_front.svg from CIRCUITPY to CPSAVES
with open("custom_front.svg", "r") as fin:
with open("/saves/custom_front.svg", "w") as fout:
fout.write(fin.read())
# set the front image var to custom SVG file
front_img = "custom_front.svg"
# if the custom PNG front image CheckBox is checked
elif front_custom_png_checkbox.checked:
status_lbl.text = "Saving Custom PNG"
# copy custom_front.png from CIRCUITPY to CPSAVES
with open("custom_front.png", "rb") as fin:
with open("/saves/custom_front.png", "wb") as fout:
fout.write(fin.read())
# set the front image var to custom PNG file
front_img = "custom_front.png"
status_lbl.text = "Saving Card"
# if the front emoji CheckBox is checked
if front_emoji_checkbox.checked:
# include emoji in the front message text
outside_message = f"{emoji}<br>{front_msg_input.text}"
else:
# only use the front message from the input box
outside_message = f"{front_msg_input.text}"
# render the card HTML template to a string
out_html = render_template(
"card.template.html",
context={
"outside_message": outside_message,
"inside_message": inside_msg_input.text,
"color": hex(palette[0]),
"timestamp": int(time.monotonic()),
"front_img": front_img,
},
)
# save the card HTML file to CPSAVES
with open("/saves/card.html", "w") as f:
f.write(out_html)
# store the card configuration in an object to save
card_json_data["outside_message"] = outside_message
card_json_data["inside_message"] = inside_msg_input.text
card_json_data["color"] = hex(palette[0])
card_json_data["front_img"] = front_img
# save the card configuration object in a JSON file to CPSAVES
with open("/saves/card_data.json", "w") as f:
print(f"saving json: {card_json_data}")
f.write(json.dumps(card_json_data))
status_lbl.text = "Card Saved"
# if the remount CPSAVES button was clicked
if remount_btn.contains((mouse.x, mouse.y)):
status_lbl.text = "Remounting CPSAVES"
# remount the CPSAVES drive partition
storage.remount("/saves", readonly=False)
status_lbl.text = "Done"
Drive Structure
After copying the files, your drive should look like the listing below. It can contain other files as well, but must contain these at a minimum.
Page last edited December 22, 2025
Text editor powered by tinymce.
Code Explanation
The code for this project consists of two python files code.py and card_maker_helpers.py, as well as two template files card.template.html and snowflake.template.svg.
card_maker_helpers.py
This file contains several helper functions as well as a class which are all imported and used by the code.py file. Helper functions:
-
svg_points()Converts points from alistoftuplesinto a string that is in SVG polygon comma separated syntax. -
distance()A simple distance calculation to determine how far apart two points are. -
random_polygon()Generates and returns the points for a random polygon. Special care is taken inside this function to not generate any line segments that intersect with existing segments chosen for the random polygon. This ensures the shape doesn't cross back over itself. -
fill_polygon()Draws and fills in the given polygon in the providedBitmapusing the specified color. -
draw_snowflake()Draws all of the polygons that make up a snowflake in the providedBitmap.
There is also a class PointHighlighterCache which provides a pool of small crosshair images to be placed in the snowflake maker screen at the points where the user clicks the mouse.
This class ensures that only as many highlighters as are needed get created. It re-uses any existing ones before creating new ones.
The first point gets colored green and the rest are black. Once the first point is clicked again it will close the shape and draw the polygon.
Template Files
The two template files are meant to be used with the adafruit_templateengine library. The library allows code to fill in specified parts of the templates using data that was generated dynamically, in our case by the user drawing their snowflake, and entering the card messages and other options.
snowflake.template.svg is an SVG image file. SVG files are actually just plain text XML files that contain geometry data about what shapes to draw. This template contains a for loop that draws 4 copies of each polygon from a list. The main copy in the top left is drawn in its normal orientation, the other copies are mirrored vertically and/or horizontally to create the snowflake patterns. CircuitPython doesn't support viewing SVG files directly, but web browsers do.
card.template.html is an HTML web page greeting card template. The page uses CSS to be laid out in 4 quadrants that add up to the size a plain sheet of paper. Two quadrants are mirrored vertically to account for the folds that will be made to the card after printing. The front image file, front message, and inside message all get filled in with values that are entered or selected on the card setup page.
code.py
The main project code file is responsible for the high level functionality and behavior of the app. It sets up all of the visual elements, adding them into two separate Groups. One Group for the card setup screen, and another for the snowflake maker screen. A PageLayout is used to easily change between the different screens.
Card Setup Screen
In the main loop, the code checks the keyboard and mouse for input events and reacts to them according to which screen is showing and whether any of the input boxes have focus. CheckBoxes are toggled when they get clicked. Input boxes will get focus when clicked, and will add typed characters to the end of their input if they have focus.
When the generate button is clicked, the following tasks occur to prepare the card file:
- Validation to ensure a front image has been selected.
- If the user opted to use a snowflake image for the front, the snowflake.svg file is rendered from the polygons drawn in the snowflake maker screen. The file gets written to the CPSAVES drive.
- If one of the custom image files is selected, that file gets copied from CIRCUITPY to CPSAVES.
- If the front emoji checkbox is checked the front message is appended together with the emoji characters.
- The card HTML template is rendered using data entered by the user. The card.html file gets written to the CPSAVES drive.
- All of the card details are also stored as JSON in the file card_data.json in CPSAVES. When the app launches it will check for this file and try to load the data from it to let the user resume from the last card they made.
The last button is for remounting the CPSAVES drive to the computer. This is used after the card has been generated to refresh the files visible to the PC. CPSAVES uses the FAT filesystem which does not contain support for notifying the host of changes to files, so this manual unmount/eject and then remount process ensures that the PC can see the latest version of the generated files.
When the snowflake maker screen is showing, the mouse will be restricted to the top left corner of the snowflake. Clicking the mouse will drop a point highlight at the location clicked. After clicking a few more points and then clicking back on the first point again, the polygon will be closed and then drawn and filled in. The polygon shapes automatically get duplicated and mirrored using 3 more TileGrids with their flip_x and flip_y properties to handle the mirroring.
A Label on the right acts as an info pane showing a few keyboard keys shortcuts and basic instructions for drawing a snowflake.
Page last edited December 22, 2025
Text editor powered by tinymce.
Use
Connect Hardware
Plug in a USB keyboard and mouse to the two USB host ports on the Fruit Jam. Use an HDMI cable to connect to a DVI compatible display. Connect a USB C cable in to the Fruit Jam for power.
Click the input boxes and type in a short message for the front of the card, and a longer one for the inside of the card. Check the "Include Emoji" CheckBox if you want the front of the card to include some festive emoji characters. Check one of the boxes on the bottom left to choose whether to use a snowflake, custom SVG, or custom PNG file for the front image.
To use a custom image save the image, copy the file to CIRCUITPY using the name "custom_front.svg" or "custom_front.png". The name of the file must match one of these exactly, and the file must be directly in the root of the CIRCUITPY drive.
If you're using a snowflake image for the front and want a color other than the default blue, type in a custom color to the snowflake color input box. In the image to the left, the custom pink color of 0xee88ff has been typed into the color input. Click the "Make Snowflake" button to open up the snowflake designer.
Inside the snowflake designer, click points in the upper left corner of the snowflake to make polygon shapes to cut out of the white background. The snowflake designer screen also supports a few keyboard hotkeys.
- Del - Press the delete key to erase the current snowflake and start over with a blank white canvas.
- r - The r key will add a random polygon shape cutout to the canvas. You can press it multiple times to build up more cutouts until you are satisfied with the look of the snowflake.
- Ctrl+z - The Ctrl+z combo has the typical undo behavior. It will remove the most recently added shape from the snowflake whether it was added randomly or manually. Use this if you make a mistake or don't like the looks of a randomly generated shape.
- Esc - Once you like the snowflake and are ready to get it printed on a card, press the Esc key to exit the snowflake designer and go back to the card setup screen.
Once everything is all set, press the "Generate Card" button. A status message will appear above the button indicating progress and showing a "Card Saved" message when the process is complete. Typically it takes only a few seconds, but can go longer for large custom image files.
If your Fruit Jam is not plugged into a PC with the USB cable, then now is the time to un-plug the USB C cable and plug in the Fruit Jam to your PC instead of a power adapter or other power source. When you connect the Fruit Jam to a PC, it should mount both the CIRCUITPY and CPSAVES drive on your computer. The generated card is stored as card.html on the CPSAVES drive.
If your Fruit Jam has been plugged into the PC the whole time, you need to do one more step to manually unmount/remount CPSAVES to get the PC to see the card file that was just generated. On your computer, unmount or eject the CPSAVES drive using the standard procedure for your OS. Then back inside of the app click on the "Remount CPSAVES" button. After a few seconds the CPSAVES drive will reappear on the PC and contain the latest version of the card file.
In the browser, open the menu and click Print.
In the print settings, ensure that the page is set to the portrait orientation, and set the following settings:
- Enable "Fit to page width"
- Set the margins to "None" or the smallest option available.
- Disable "Print headers and footers"
Take a last look at the preview on the left and make sure it looks good. If it doesn't, now is the time to go back and change anything that needs it.
When it's looking good, click the Print button.
Once the card is printed, fold it in half on the short side or "hamburger style", then fold the result in half again to make the greeting card with the image on the front and the longer message on the inside.
Page last edited December 22, 2025
Text editor powered by tinymce.