Skip to content

Fix image loaders and redesign window shadows - #165

Merged
jserv merged 13 commits into
mainfrom
fix
Oct 21, 2026
Merged

jserv merged 13 commits into
mainfrom
fix

Conversation

@jserv

@jserv jserv commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Two commits.

Fix image loader corruption and decode GIF frames on demand

  • PNG: on macOS colors were wrong (orange showed as magenta) because an Apple-only pass swizzled pixels a second time. libpng now writes native ARGB, and only images with alpha are premultiplied. 16-bit and sub-byte PNGs now load, and an A8 + tRNS row overflow is fixed.
  • JPEG: pix is volatile across setjmp(), the A8 path copies pixels instead of row pointers, and a dead branch is removed.
  • TinyVG: color indices are now bounds-checked (out-of-bounds heap reads, reproduced under ASan), allocation sizes are overflow-checked (heap overflow on wasm32, reproduced), and varuints are capped.
  • GIF: frames decode on demand into the returned pixmap, so nyancat.gif drops from about 880 KB to 64 KB. Transparency is real alpha (it used to be a fake opaque checkerboard), disposal follows browser behavior, interlaced frames 2 to 4 rows tall are fixed, an LZW clear at a code-size bump no longer desyncs, off-canvas frames are clipped, and GIF87a is accepted.

Cast window shadows from the frame and mask them by window alpha

  • Shadows are cast by the window's shape (client plus title tab) instead of its bounding box, and masked by the window's own alpha so they only show outside the window. This fixes the stray shadow beside the title tab and see-through windows showing their own shadow.
  • New look, lit from above: no horizontal offset, 4 down, blur 14. Inactive windows keep a lighter shadow.
  • Geometry Kconfig options now depend on the desktop WM, and the golden fingerprint counts them only when they exist.
  • The animation demo window is see-through.

Validation

  • make check passes: core 337, wm 74, lifetime 21, demos 34. Commit 1 was also built and tested on its own.
  • Sanitized (ASan + UBSan + pointer-overflow) runs of core, wm, lifetime and demos are clean.
  • GIF matches Pillow frame for frame on 26 animations, plus 63 LZW clear patterns and 16 clipping cases. PNG matches on 20 color types. Fuzzing is clean.
  • Shadow output is bit-identical to a plain per-pixel reference, and the core golden scene is unchanged. All 56 cellphone frames are identical to before.
  • Cost per shadow pixel is at parity with the old code. The larger new look adds about 95 µs per full 480x320 repaint (Apple M-series host).

Note

tests/golden/cellphone.txt needs make check-update on the reference platform. Its stored fingerprint no longer matches, so cellphone golden comparisons are currently skipped. On this macOS host, 11 of its frames already differed at aa36aec.


Summary by cubic

Fixes corrupted rendering in the PNG, JPEG, and TinyVG loaders, decodes GIF frames on demand, and reworks window shadows to cast from the window's shape and mask by its alpha. The window manager and every application are now selected individually in Kconfig under top-level menus.

Image loaders

  • PNG colors are correct on macOS: libpng writes native ARGB with one transform set, and only images with alpha are premultiplied. 16-bit and sub-byte PNGs now load, and an A8 row overflow is fixed.
  • JPEG keeps its pixmap volatile across setjmp(), and the A8 path copies pixels instead of row pointers.
  • TinyVG bounds-checks color indices, overflow-checks allocation sizes, and caps varuints, closing heap over-reads and overflows in crafted files. Its paths fill by a new even-odd rule (twin_path_set_fill_rule()), so nested outlines in comic.tvg, tiger.tvg, and flowchart.tvg no longer paint black wedges or stray discs.
  • GIF frames decode into the returned pixmap on demand, so nyancat.gif drops from about 880 KB to 88 KB. Transparency is real alpha, disposal follows browser behavior, interlaced and off-canvas frames are fixed, and GIF87a is accepted.
  • The Vectors demo renders each drawing to fit its window and centers it; twin_widget_set_stretch() keeps the button at its preferred height.

Windows and demos

  • Shadows are cast by the client plus title tab instead of the bounding box and masked by the window's alpha, so they only show outside the window. The new look is lit from above, inactive windows keep a lighter shadow, and the animation demo window is see-through.
  • One calculator serves both desktop and phone with a shared keypad and arithmetic, and gains an erase key; the multi-window demo is desktop-only.
  • Snake, Pong, and Tetris run under any window manager as their own demos and stay themed launcher apps on the phone.
  • Each application is now picked on its own in Kconfig (DEMO_* became APP_*), so configs written before the rename fall back to defaults until their apps are chosen again. Golden frames record the launcher list so comparisons are skipped for other selections, and deterministic edge sorting makes the refreshed frames match on Linux and macOS alike.
  • The headless determinism check runs with MADO_FROZEN_TIME=1, which stops twin's clock so every animation keeps its first frame, and now requires identical renders.

Written for commit 875a4a6. Summary will update on new commits.

View guided diff Turn on auto-fix

Comment thread src/animation.c Outdated
Comment thread src/image-gif.c Outdated
Comment thread src/image-gif.c Outdated
cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

@jserv
jserv force-pushed the fix branch 2 times, most recently from 063160d to ceb5489 Compare October 19, 2026 21:25
cubic-dev-ai[bot]

This comment was marked as resolved.

@jserv
jserv force-pushed the fix branch 2 times, most recently from d2e228a to af50b25 Compare October 19, 2026 22:42
cubic-dev-ai[bot]

This comment was marked as resolved.

jserv added 5 commits October 20, 2026 06:51
On macOS PNG colors came out wrong because an Apple-only pass swizzled
pixels twice; libpng now writes native ARGB, and one row-size check
replaces depth checks that read the IHDR from before the transforms,
so 16-bit and sub-byte PNGs load.  JPEG keeps its pixmap valid across
longjmp() and fixes its A8 path.  TinyVG checks color indices,
allocation sizes and varuint widths, which crafted files overran.

GIF frames decode into the pixmap the loader returns instead of one
pixmap per frame, cutting nyancat.gif from about 880 KB to 88 KB, with
real transparency and browser rules for disposal, loop counts and
short delays.  Interlaced frames two to four rows tall, clear codes at
a code-size bump and frames past the canvas no longer corrupt the
picture.  An animation damages its pixmap when it draws a frame.
A shadow was a blurred copy of the pixmap's bounding box, so the empty
corner beside a title tab got one and see-through windows showed their
own.  Up to two rectangles now cast it: a window's client while it is
opaque, and the desktop title tab.  The casting pixmap's alpha masks
the shadow, so it shows only outside the window.

The desktop lights windows from above, inactive ones keep a lighter
shadow, and the geometry options depend on the desktop policy.
Scan-out stays procedural; per shadow pixel the old geometry costs as
before.  The animation demo becomes see-through.
comic.tvg drew wedges of black, and tiger.tvg and flowchart.tvg stray
discs, because their nested outlines cut holes by the even-odd rule
while twin always filled nonzero.  A path now carries a fill rule, set
with twin_path_set_fill_rule() as part of its saved state, and the
TinyVG loader selects even-odd.  Strokes and every other path keep the
nonzero default.
The window manager choice decides which other options apply, yet it
sat in a submenu under Features.  Only the menu order changes: defconfig
and every CI configuration generate the same config.h.
The demo rendered every drawing at 400x400 and drew it at the corner,
so under the mobile policy, which gives the window the 240x258 work
area, only a corner of each picture showed; its widget also claimed
three quarters of the screen height, which stretched the button and
broke the layout on tall desktops.  The image now takes the room the
button leaves, renders to fit it when that size changes, and is
centered; the button keeps its label's height.

twin_widget_set_stretch() lets a widget keep its preferred size in a
box, and twin_tvg_to_pixmap() returns a pixmap of the drawing's aspect
within the size asked, so no margin is stored beside it.
The demo shows overlapping windows: under the mobile policy each one
covers the last, and the phone listed a launcher icon that opened six
stacked screens.  It now depends on the desktop window manager, and its
comments no longer describe the other policies.
cubic-dev-ai[bot]

This comment was marked as resolved.

Two calculators did the same job: the desktop one stacked integers in
ellipse buttons whose rectangular bevels left wedges between rows, and
the phone kept its own decimal engine and keypad.  apps/calc.c is now
the only one.  Both draw it into a twin window, so the keypad, the
arithmetic, painting and input are shared; a front end supplies only
the window and a palette.  The desktop gets flat round keys colored by
role, a fitted display and keyboard input; the phone keeps its theme.

The keypad gains an erase key, also on Backspace and Delete, which
drops the last character typed.  The phone lists the shared app once:
its launcher row names the desktop entry it replaces.
@jserv
jserv force-pushed the fix branch 2 times, most recently from c213654 to 044dc49 Compare October 20, 2026 06:35
cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

The phone built every app it has.  A "Phone apps" menu now selects
them one by one; call log needs the dialer, and contacts needs the
dialer and messages, which it opens.  Settings stays, as it owns the
theme and wallpaper.

The tests follow the selection: each suite has an empty stand-in when
its app is not built, the calculator is tapped wherever its icon lands,
and the golden file records the launcher list, so frames recorded for
one selection are not compared against another.
jserv added 5 commits October 21, 2026 08:23
Snake, Pong and Tetris were phone apps only, although nothing in them
needs the phone shell.  They move to apps/ as their own demos, each a
kit view in a window of its own on the desktop or under the plain
policies, and still the themed launcher apps on the phone.  Without the
shell every app starts at boot, so these windows open paused until
tapped rather than playing unasked.

The UI kit no longer reaches into the shell: views open on a screen
the shell attaches or on one given to ui_view_new_at(), the theme table
lives in the kit, and a covered view is judged by the window manager's
policy.  A view wider than the phone layout, as a maximizing policy
makes it, shows the theme background beside it.  Each game owns its
ops table, opener and launcher icon, so games.c no longer knows them.
Paths of more than 48 edges are ordered with qsort, compared by top
row alone.  qsort need not be stable, and the order of edges starting
on one row decides the coverage of coincident spans, so glibc and the
BSD libc of macOS rasterized a few glyphs and icons differently.  Ties
now keep their place in the edge array, the order the insertion sort
for short paths already gives, so a frame is the same on every host.
The recorded frames carried a build fingerprint that no longer matched
any configuration, so every cellphone comparison was skipped.  Record
them again for the default mobile build, with the shared calculator
and the launcher list the phone now shows.  With the edge sort fixed
they hold on Linux and macOS alike.
CI compares two fresh demo-headless runs captured about 3 s in and
allowed 100 differing pixels, yet the Animation demo's GIF showed
through a gap between windows and its frame depended on when each run
started, so the check failed about one run in three.

MADO_FROZEN_TIME=1 now stops twin's clock at startup in the headless
backend: no timeout comes due, so every animation keeps its first
frame, while the control tool is still served by the poll loop.  The
Clock reads wall time on its own and shows a fixed time under the same
variable.  The CI step sets it and requires identical frames.
"Build demo applications" gated every application behind one switch,
and the menu still called them demos although they are the
applications Mado ships.  The menu is now "Applications": each one is
chosen on its own, and APPS, true when any is picked, builds the
application library, the launcher and its tests.  The phone shell is a
menuconfig holding its own options and apps, the games have their own
group, and every help text says what the application does.

The DEMO_* symbols become APP_*.  A configuration written before this
change no longer sets them, so its applications fall back to the
defaults until they are picked again.
@jserv
jserv merged commit 2c6992d into main Oct 21, 2026
33 checks passed
@jserv
jserv deleted the fix branch October 21, 2026 01:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant