Skip to content

Add mobile and desktop window managers with phone UI - #163

Merged
jserv merged 48 commits into
mainfrom
cellphone
Oct 19, 2026
Merged

jserv merged 48 commits into
mainfrom
cellphone

Conversation

@jserv

@jserv jserv commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

This turns window management into a policy layer and builds a phone UI on top of it. A build selects one window manager in Kconfig, and every screen runs it for its whole life, with no run-time switching or checks: desktop (the default: decorated overlapping windows with maximize, edge snapping, corner resize and focus cycling), mobile (an application stack over the work area between system bars, with push/pop/dismiss transitions, an edge-swipe back gesture and eviction of covered apps' pixels), or none (kiosk). The compositor stays policy-free. apps/cellphone ports the cellphone prototype onto the mobile policy: status and navigation bars, a lock screen, home and a dozen apps on a small immediate-mode view kit.

apps/apps.c registers every demo app. Which window managers an app runs under is settled at build time too: the demos run under all three, and the phone shell depends on WM_MOBILE and is compiled in only there. The demo launcher starts the phone shell when it is built and otherwise every app (only the first under none); the phone's home screen lists the other apps, whose windows Back and Home close like phone screens. include/twin.h now carries only the public interface; object layouts and the backend interface moved to src/twin_private.h and applications use accessors. New calls: twin_window_back() with TwinEventBack, twin_window_activate(), twin_screen_focus_next(), twin_screen_set_inset() for an on-screen keyboard, twin_toplevel_destroy() and twin_box_widget(). A toplevel now paints an opaque background (white by default) and its window is opaque unless the application gives its box a translucent background; widgets fill over their ancestors' backgrounds instead of clearing to their own, so the demos no longer show the wallpaper, their own shadow or, in the phone, the previous app through their gaps. docs/phone.md describes the design and the trade-offs.

Verification: make check runs the in-process headless suites with a virtual clock; the core and phone suites compare bit-exact golden frames. The desktop build runs core (300 checks), wm (73), lifetime (21) and demos (122); the mobile build runs wm (103), demos (112) and cellphone (911); the none build runs the none-only wm cases (28). test-demos runs every registered app under the built policy, closing it window by window, checking that revealed windows are repainted, that a start which runs out of memory leaves nothing behind, and that nothing leaks. All suites pass in 22 configurations (desktop, mobile and none builds, plus no coverage cache, no eviction, no memory stats, no widgets, RGB565 surfaces, a TLSF heap, Pixman, no shadows, a size-optimized build without a scratch arena, and the mobile policy without the phone, across window managers where they apply), under ASan/UBSan with -fno-sanitize-recover=undefined and under Valgrind for all three window managers. Every commit in the series builds. The rendering arithmetic that replaced 64-bit divides and float is checked against the old forms (exhaustively where feasible, e.g. 1.7e7 twin_fixed_div pairs), and the rv32im objects link no soft-float or 64-bit divide helpers in the per-pixel paths. The fbdev and DRM backends share one core now; they were built and checked for clean failure on a missing device, but no framebuffer or DRM device was available to test on hardware.

Performance: the hotspots were found with uftrace on the headless suites and checked with perf. The window a transition dims is blended straight onto the background through a 256-entry product table; large paths are rasterized in bands from one edge list instead of through a full-size heap mask; arcs are flattened to the 1/16 px the rasterizer resolves; the active edge table is an array; the shadow fringe keeps its alphas between rows; scaled images read each texel once; paint work runs before redisplay and windows revealed while a stack of apps closes are painted only if they stay visible; toplevel windows are opaque, so nothing under them is composited. On an x86-64 host, minimum of five runs of perf stat, before this round (7c5118e) and after: the phone suite (test-cellphone, mobile build) runs 14.32G to 7.57G instructions (-47%) and 5.26G to 2.67G cycles (-49%); the desktop demo suite (test-demos) runs 1.98G to 1.25G instructions (-37%) and 0.89G to 0.59G cycles (-33%). The phone's peak heap over all apps falls from 725,372 to 664,000 bytes, mostly from the transient masks banding removed and from photo thumbnails kept as RGB565. The rv32im library text grows about 4 KB. Output stays bit-identical except arcs, whose antialiased edges move by a few pixels per screen (the goldens and screenshots below are regenerated), and the photo grid, whose thumbnails are now RGB565.

Out-of-memory handling is checked where the apps own it (window and widget creation); the core text and path code still dereferences NULL when an allocation fails while painting, which predates this change. Not included: a recents switcher and a notification layer for the phone shell. The WM hooks they need (twin_window_activate, twin_screen_focus_next, the Back event) are in place, but they would change the ported prototype's navigation model. The WASM and VNC backends were not built locally.

Screenshots

Cellphone screens at their native 240x320, unscaled, rendered by make check (MADO_SCREENSHOTS) from this branch in the mobile build. They use the default Sage theme: warm white pages, sage green grouping and deep olive bars, with header titles centered, launcher tiles as touch targets, single-color settings symbols and the current navigation tab filled in the accent. docs/phone.md describes the design language.

Lock, home and the second home page with the shared demo apps, contacts:

lock home home-demos contacts-list

Contact card, dialer, an active call, messages:

contacts-detail dialer-number dialer-incall sms-list

Conversation, now playing, photos, call log:

sms-chat music-now-playing photos-grid calllog

Settings, theme choice, a game, home in the Dark theme:

settings settings-display games-tetris settings-home-dark

Closes #39
Closes #160


Summary by cubic

Turns window management into a build-time policy and ports the cellphone UI to the demo. A build picks one window manager in Kconfig — desktop (decorated overlapping windows), mobile (an app stack over the work area between system bars, with push/pop/dismiss transitions and edge-swipe back), or none (kiosk) — and a screen enters the built policy on creation, calling it through compiled hooks with no run-time registry. Fields only one policy uses compile out of the others, and a destroyed screen tears its windows down without the policy; static archives are rebuilt from scratch so a member from a previous configuration never lingers. The compositor gains multi-rectangle damage, occlusion culling, a coverage cache, synthesized drop shadows and retained-pixel scrolling, with embedded Montserrat and Font Awesome fonts. Rendering avoids per-pixel 64-bit divides and soft-float so the rv32im build links no floating-point support and the TinyVG loader shrinks 3.3 KB, with bit-identical output; the Pixman renderer now passes the whole source transform, and the built-in renderer and compositor share one premultiplied over helper. Resident and static memory shrink as well: masks too big for the scratch arena come from the heap, the arena size is a Kconfig option, an RGB16 mask composites as none so 24 compositing functions are gone, the closure tracker grows past its inline table, and OPTIMIZE_SIZE builds with -Os.

Cellphone app and public API

  • apps/cellphone ports the phone prototype in the new default Sage theme — three named surfaces, deep olive bars, warm white pages — with status and navigation bars, a lock screen, home and a dozen apps on a small immediate-mode UI kit. apps/apps.c registers every demo, the phone shell compiles in only under WM_MOBILE, and launchers start apps in registry order, multi-window demo first, with phone Back, Home and nav bar acting on the topmost application window whether or not it is a phone view.
  • Toplevels start with an opaque white background, so their windows are opaque and the compositor skips everything under them; widgets fill composited over their ancestors' backgrounds, so demo windows no longer show the wallpaper or their own shadow through their gaps, and a repaint erases without punching a hole. The image demo composites its drawing over its background instead of replacing it.
  • Demo apps tear down and report failure when a widget or callback cannot be created; phone shutdown closes every app window, a shown toplevel paints its widgets once instead of twice, and bars are re-hidden on every relayout because a bar's pixmap can still be shown directly through the pixmap API.
  • include/twin.h now holds only the public interface; object layouts and the backend interface moved to src/twin_private.h and applications use accessors. Calls lose parameters every caller passed the same way: twin_toplevel_create() always makes an ARGB32 app window, twin_pixmap_from_file() always decodes to ARGB32, twin_label_set() takes text only, and twin_run() runs the dispatch loop itself and hands its callback the screen. twin_create() installs the arrow cursor, and twin_tvg_to_pixmap() replaces the two TinyVG entry points. New calls add phone navigation plus twin_window_activate(), twin_screen_focus_next(), twin_screen_set_inset() and twin_toplevel_destroy(). Negative text origins no longer trip UBSan, tweens always deliver their final frame, TrueType metrics report real bearings, and malformed GIFs fail cleanly instead of looping or writing past their LZW stream.

Verification

  • make check runs four headless in-process suites (core 614, wm 252, lifetime 24, cellphone 839 checks) with a virtual clock; core and phone compare bit-exact golden frames, missing or malformed goldens fail instead of being skipped, and the wm suite checks that covered windows cost no pixels only when eviction is built. tox-core also covers Pixman-transformed RGB16 reads and the TinyVG hairline pen.
  • fbdev and DRM read evdev on the caller's thread via twin_linux_input_poll() instead of an unsynchronized second thread, and backends sleep until input or the next timeout instead of spinning; VNC no longer leaks or reads uninitialized damage state, the headless backend records its owning PID, headless-ctl validates UTF-8 before typing, and the DRM backend forgets its framebuffer mapping on unmap. fbdev and DRM now share one core in backend/linux_fb.h.
  • All pass in 16 configurations and under ASan/UBSan (including the WM_NONE build) and Valgrind, plus CI lanes without shadows and with OPTIMIZE_SIZE and no scratch arena; test-demos runs every app under the built policy, also with the first allocation failing, and the phone suite re-tests demos with the work area shrunk by the keyboard inset.

Written for commit 122acdc. Summary will update on new commits.

View guided diff Turn on auto-fix

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.

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.

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.

cubic-dev-ai[bot]

This comment was marked as resolved.

jserv added 20 commits October 19, 2026 00:41
Window management becomes a policy chosen at run time: 'none' (kiosk,
always built), 'desktop' (decorated overlapping windows) and 'mobile'
(an application stack over the work area between system bars, with
push/pop/dismiss transitions and an edge-swipe back gesture).  The
compositor stays policy-free; policies only place windows, pick focus
and filter pointer gestures, and switching between them re-lays out
every existing window.

The desktop policy gains double-click maximize, edge snapping, corner
resizing, focus cycling (Alt+Tab in SDL) and keeps title bars on
screen.  The mobile policy holds an edge press until the finger moves,
so taps and long presses at the edge still reach the app, sends
TwinEventBack before closing a window, and evicts the pixels of covered
apps.  Screen resizes, bar changes and an on-screen keyboard inset
(twin_screen_set_inset) re-lay out the windows.  A window destroyed
from its own callbacks stays readable until the screen is idle.

apps/cellphone ports the cellphone prototype onto Mado: a status and
navigation bar, a lock screen, home and a dozen apps built on a small
immediate-mode view kit.

include/twin.h now holds only the public interface: object layouts and
the backend interface live in src/twin_private.h, and applications use
accessors.  The compositor gains multi-rectangle damage, occlusion
culling, a coverage cache, compositor-synthesized drop shadows and
retained-pixel scrolling.

Four headless suites (make check) run in-process with a virtual clock
and bit-exact golden frames: core, wm, lifetime and the phone's whole
user journey.  They pass under every CI configuration, ASan/UBSan and
Valgrind.  docs/phone.md describes the design.
twin_tween_finish() called from a step callback now always delivers the
final frame, even when the callback also returns false. TrueType glyph
metrics report bearings from the outline instead of zero and the
advance, and only stroke fonts claim U+0000. The Pixman renderer passes
the whole source transform, translation included; it used to keep only
the scale terms. The injected allocation failure now covers realloc.

The harness no longer overshoots th_settle's budget, frees the screen
when th_create fails, fails on a missing or malformed golden file
instead of skipping it, and its CHECK macros use temporaries no test can
shadow. The timeout-cancels-tween test now cancels from a timeout due in
the same pass, as its comment promised.
Home and lock screen drags that interrupt a settle animation continue
from where the page or knob is, and a tween that cannot start snaps to
its end.  A press that stops a fling inside a list no longer becomes a
tap.  phone_boot() fails cleanly when the mobile policy, a bar or home
cannot be created.

The calculator divides without overflowing on large operands and lets
+/- start a negative entry.  Text truncation breaks overlong words at a
character boundary, icon detection only matches the private-use range,
music position math survives clock wrap, and the page dots are centered
(the home and dark-theme home goldens move by 2 px).
The headless backend records its pid in the shared memory header and
refuses to start while another live backend owns it, instead of
unlinking it; a stale object from a crashed run is still replaced.
headless-ctl validates UTF-8 before typing anything and waits for the
event queue to drain, so long strings work.  The DRM backend forgets
its mapping when it unmaps the framebuffer.

twin-ttf validates the font name and range syntax and refuses a range
set with no glyphs.  mado-perf restores the cache budget it changes and
uses the shared debug flags.  make clean removes the test binaries,
check-update reports failures, and check explains that the headless
tests do not run under Emscripten.
Document the SDL scale range and its 100 ms idle wake-up, the
headless-ctl type command, that eviction is optional, that Photos uses
JPEG fixtures, and that full-recomposite checks run at test checkpoints.
twin_custom_widget_width() and twin_custom_widget_height() were
declared to return twin_fixed_t but return the widget extents, which
are whole pixels.  The clock already treated the value as pixels; a
caller that converted it with twin_fixed_to_int() got zero.  Declare
both as twin_coord_t and say so.
A toplevel's draw callback sent Paint to its box, which repaints only
the widgets queued since the last paint.  When the mobile window
manager restored an evicted toplevel, nothing was queued, so the window
came back blank.  Flag the whole widget tree before painting: the
window asked for its damaged pixels and they may be all of them.
The desktop and mobile window managers are now a Kconfig choice: a
build has one of them, or only 'none' (WM_NONE), and
twin_screen_set_wm() switches between that one and 'none'.  The
default is the desktop, as on main; building both is a compile error.
The phone no longer offers a window manager page in Settings, nor the
header Back button that only the other scenes needed: it always runs
under the mobile policy.

apps/apps.c registers every demo app that is built together with the
window managers it runs under.  Ordinary apps are marked for all
three; the phone shell, which owns the screen, for the mobile policy
only.  The demo launcher starts the scene's shell, or every app marked
for the scene ('none' starts the first), and the phone's home screen
lists every other app marked for mobile.  Phone navigation (Back,
Home, the nav bar) acts on the topmost application window whether or
not it is a phone view, so these apps go back and home like phone
apps, and a revealed view refreshes the nav bar.

The apps now work at any size a policy gives them.  The multi demo's
windows paint from draw callbacks scaled to the client area, so they
survive resizing and eviction; the animation centres its image; start
functions report failure instead of using a NULL window.

test-demos runs every registered app under each policy the build has
that it is marked for, checking placement, consistency, that revealed
windows are painted, closing window by window, and leaks.  The phone
suite opens each app from the launcher.  test-wm switches between the
built policy and 'none'.  CI builds and tests both the desktop and the
mobile configuration, under Valgrind and the sanitizers too.
The animation demo advanced a frame every time it painted, so any
extra repaint (an exposure, a restore after eviction, the toplevel now
repainting every widget on a window draw) skipped a frame and made the
frame on screen depend on paint timing.  The timer advances the frame
and queues a paint; painting draws the current frame.
The registry listed the multi-window demo last, which put its windows
under the others but left the real-time clock on top, so the headless
determinism check saw the second hand move between runs.  List it first
and start the apps in registry order, which restores the earlier
desktop stacking exactly.  The 'none' scene still starts the first app,
now the multi-window demo, and the phone launcher lists it first.
twin_sfixed_to_fixed() shifted a possibly negative value left, which is
undefined; text painted at a negative origin, as when a phone window
shrinks under an on-screen keyboard, tripped UBSan.  Multiply instead,
as twin_fixed_to_xfixed() already does.
A demo whose custom widget, label, box or button failed to allocate
still showed an empty window and reported success.  Each start now
tears the toplevel down and returns false, with the new
twin_toplevel_destroy().  twin_widget_set_callback() reports whether
the callback was installed, so the calculator knows whether its display
took ownership of its state and frees it exactly once.

test-demos also starts every app with its first allocation failing and
checks that nothing is left behind, and on a desktop requires each
window to lie fully on screen, not only its origin.
phone_shutdown() destroyed only phone views, so a demo opened from the
launcher outlived the shell.  Destroy every application window too.
The phone suite now shrinks the work area under each demo opened from
the launcher and checks that its window follows and repaints.  Changes
to the workflows themselves now run the build lanes.  The README notes
that Photos shows JPEG fixtures, and the test list says test-demos
leaves the phone shell to test-cellphone.
test-demos started every app out of memory under 'none' only, whatever
the app was marked for.  Run it alongside the normal pass, under each
policy the app is marked for that the build has, which is also what the
test list in docs/phone.md describes.
The Kconfig choice already builds one window manager, so nothing needs
to switch or check it at run time.  A screen enters the built policy
when it is created, and creation fails if the policy cannot start;
twin_screen_set_wm(), twin_screen_get_wm(), twin_wm_mode_t and the
queued-switch machinery are gone.  The 'none' policy stays in every
build as what a screen falls back to while it is torn down.

The demo launcher no longer picks a scene: MADO_SCENE and the "Demo
scene at startup" choice are gone, and it starts the phone shell when
one is built, otherwise every app, or only the first under WM_NONE.
Which window managers an app runs under is settled at build time as
well: apps run under all three, and the phone shell depends on
WM_MOBILE in Kconfig and is compiled into the registry only there, so
the registry needs no run-time markers.

The tests follow: the harness creates screens under the built policy,
test-wm drops the switching cases and runs the 'none' cases in WM_NONE
builds, test-demos runs each app under the built policy, and the
lifetime test that switched policy from a destroy callback now
re-lays out every window from it instead.  CI runs the sanitizers on
the WM_NONE build too.
A toplevel is shown by drawing through its window, so its widgets are
painted once and the window manager's first draw finds no damage left;
before, every new toplevel was painted twice on a desktop.  The
animation's black margin is its widget background instead of a second
fill per frame, the multi demo paints text straight into its windows
instead of through a full-size mask per draw, and its blur window
reuses start_window().  The calculator installs the callback that frees
it last, so every failure frees it the same way.  Bars are hidden once
when registered rather than on every relayout.

The phone shell has one home-window check and lists the registry apps
at boot.  Settings drops the leftovers of its window-manager page: a
dead return, a one-member enum and a copy of ui_header().  The tests
share th_top_app(), th_painted() and th_crc_rect() through the harness,
the edge swipe uses ph_drag(), and home.c exports the icon rectangle so
the phone test no longer copies the home grid geometry.
A bar's pixmap can still be shown directly through the pixmap API, so
hiding registered bars only when they are registered let one stay
visible.  Re-hide them on every relayout, as before the cleanup.
Counting frames treated gif_get_frame()'s error return as a frame, and
unchecked reads left the separator, block sizes and palettes stale or
uninitialized at end of file, so a truncated GIF could loop forever.
Reads now go through gif_read(), which zero-fills what a short file
lacks, and a zero size or separator ends decoding.  Frames decoded
before a truncation are kept and a file with none is rejected.  Sizes
that do not fit a pixmap are rejected, size products are computed in
size_t, an LZW string running past its frame ends the stream, and the
LZW table allocation is checked.

test-core loads three malformed files and every prefix of a small
image; before the fix the run never finished.
The fbdev and DRM backends read evdev on a second thread that called
twin_screen_dispatch() while the main thread ran timeouts and
composited, with no locking.  Destroying it freed the state the thread
was still using and closed descriptor 0, and rescanning devices leaked
a descriptor per device each time.  Input is now polled on the caller's
thread: twin_linux_input_poll() sleeps until input or the next timeout
and dispatches what arrived, so fbdev and DRM no longer spin.

linux_vt.h passed a pointer to the saved-mode pointer to VT_GETMODE, so
the mode put back on exit was zeros (and overran a 4-byte slot on
32-bit targets); it also leaked the VT0 descriptor and could not detect
open failures.  VNC re-initialised its damage region on every update
without freeing it, read an uninitialised event when the button and
position were unchanged, dropped a press that came with a move, and
polled without sleeping.

Backends sleep with _twin_idle_timeout().  twin_run() runs the dispatch
loop itself unless the backend owns its loop (WebAssembly), so the five
identical start() functions are gone, as is the configure hook nothing
called.  dispatch.c is folded into api.c.
jserv added 8 commits October 19, 2026 00:51
The registry wrapped each app's start function to supply its title, the
animation's asset path, or to drop a size an app ignores.  The start
functions now take (screen, name, x, y, w, h) themselves, so the
registry points at them directly; a shell such as the phone has boot
and stop functions of its own instead.  The declarations and ASSET_PATH
move into apps.h, replacing six one-function headers and three copies
of the macro.

Also drop twin_sfixed_div(), twin_dfixed_mul() and
_twin_matrix_determinant(), which nothing called.
fbdev.c and drm.c each carried the same span writers, VT handling,
redisplay work and idle wait.  backend/linux_fb.h now holds them once:
a backend embeds twin_linux_fb_t first and supplies how to map its
framebuffer again after a VT switch, plus an optional flush.

Fixed on the way:
- Both polls spun while the VT was switched away, or after mapping the
  framebuffer again failed: damage that cannot be shown kept the idle
  timeout at zero.  The wait now runs to the next timer, with a bounded
  retry for a failed mapping.  The VT signals stay blocked outside the
  input wait, which unblocks them atomically with ppoll(), so a switch
  back signalled just before the wait still ends it.
- On every return to its VT, DRM ran its whole setup again, leaking the
  connector, CRTC and a new dumb buffer each time.  It now maps the
  buffer it owns and sets the CRTC again.  It also restores the
  console's CRTC on exit, and no longer refuses to start when the CRTC
  shows nothing (it then uses XRGB8888).
- A machine without input devices or udev failed to start; the display
  now runs without input.  A udev monitor that reports an error or
  hangup is dropped instead of waking every poll.

Built and checked for clean failure on a missing device; no framebuffer
or DRM device was available to test on hardware.
The none policy raised a mapped window's pixmap but left the window
list as it was, so focus moves that walk the list picked the wrong
window after an older one was shown again.  Mapping now raises the list
entry too.  twin_screen_set_bars() also ignores another screen's
windows in every build, not only the mobile one.
The transformed reader indexed RGB16 rows through a 32-bit pointer, so
it fetched rows twice as far apart and could run past the pixmap, and
it turned samples outside the pixmap into opaque black instead of
transparent.  test-core compares a translated RGB16 source with its
ARGB32 copy.

An RGB16 mask is opaque everywhere, so twin_composite() drops it before
choosing the transform path, and neither path samples it.
twin_create() installs the arrow cursor, but nothing freed it, and
replacing a cursor dropped the old one.  The screen now owns its cursor.
SDL hides its own pointer when the screen draws one.
twin_custom_widget_create() added the requested data size to the
widget's own without a check; a huge size wrapped and returned an
undersized block.  The handler documentation now says that
TwinEventDestroy reaches it before the toolkit's own handling.
test_screen_teardown() showed its plain sibling before the gesture,
which finished the push on a phone and took the press on a desktop, so
the teardown never ran mid-transition.  The sibling stays unmapped.  The
GIF fixtures also fail the run when the temporary file cannot be
written.
A zero line width is drawn with a hairline pen (0.01 units, 0.1 for
outlined fill paths).  When the loader moved to fixed point those
defaults truncated to zero and the lines vanished; no bundled asset uses
a zero width, so the identical-render checks missed it.  test-core loads
a one-line document and counts the inked pixels.  The GIF and TVG cases
now share one temporary-file writer that fails the run when the fixture
cannot be written.
jserv added a commit that referenced this pull request Oct 18, 2026
Rendered by make check with MADO_SCREENSHOTS from the mobile build.
cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

jserv added 13 commits October 19, 2026 06:59
A phone transition draws the window behind at partial opacity every
frame, and that window was blended over a freshly filled background:
a fill, then four multiplies and two saturations a pixel, the largest
cost in the phone suite.  Where such a window's own pixels are opaque
and nothing lies below but the solid background, each channel is
mul(c, alpha) plus mul(background, 255 - alpha).  Both terms are
bounded by their factors, so the sum needs no saturation and the
background term is one packed constant; the opacity holds for a whole
frame, so the products come from a 256-byte table rebuilt only when it
changes, and the background fill is skipped.

Output is bit-identical (the goldens do not change).
The shadow beside a sliding window is a narrow strip whose alphas are
the same on every row away from its ends, yet each pixel looked its
coverage up in two ramps again.  A piece up to 64 pixels wide now
keeps its alphas while the row coverage and geometry stay the same.

The blend also drops its alpha clamp: it scales the destination by
k = 255 - a and adds a, and the scaled alpha is at most k, so the sum
never exceeds 255.  Output is bit-identical.
twin_pixmap_pointer() starts every span loop and was a function call,
millions of times in a run of the phone suite.  It is a multiply and an
add, now inline in twin_private.h.
A filled path whose coverage mask did not fit the scratch arena took a
heap mask of its full size on every paint, 62 KB for a full-screen
rounded rectangle.  Such a shape is now rasterized in bands of rows
that fit half the arena: the edges are built and sorted once, and the
scan hands each band to the compositor as it leaves it, so the
coverage matches one full-height mask.

The scan itself keeps the active edges in an array ordered by x,
re-sorted by one stable insertion pass per row, instead of a linked
list rescanned from its head after every crossing; edges are sorted
by pointer (insertion sort for small paths) rather than moving whole
edges through qsort.  Output is bit-identical.
An arc took eight sides per pixel of radius, rounded up to a power of
two: a radius-8 corner got 32 points per quarter, its chords within
1/400 of a pixel of the curve.  A chord of an n-gon strays about
r pi^2 / (2 n^2), so 1/16 pixel, a quarter of the 4x4 sample spacing,
needs n = pi sqrt(8 r) sides: four times fewer points for the corners
and circles the phone draws, with every shape built, hulled and
scanned faster.  The radius is bounded by the largest row or column
sum of the path matrix, so an uneven ellipse gets the sides its long
axis needs.

Antialiased edges move slightly (a few hundred pixels per screen, by
up to 47 of 255 levels); the cellphone goldens are regenerated.
The work queue ran the backend's redisplay before the paints, so
whatever a pass painted reached the screen only on the next pass,
after a second scan-out.  Layout now runs first, then painting, then
redisplay.  The headless backend scanned out from its poll, right
after injecting input and before that input's paints; it now scans
out from redisplay work like the other backends.

That also makes it safe to defer painting: a window the mobile policy
reveals again is repainted from the work queue rather than at once, so
closing a stack of apps never repaints the ones closed next, and the
Multi demo's windows, which on a phone each cover the one before,
paint only if still shown.
A transformed image read fetched (and, for RGB16, converted) four
texels for every output pixel, though scaling up revisits the same
four for several pixels in a row.  They depend only on the integer
source position, so they are fetched again only when it changes.
The Photos gallery keeps its six decoded thumbnails for as long as the
grid or the viewer is open, 131 KB at 32 bits a pixel.  Photos are
opaque, so they are stored as RGB16: the peak heap over all phone apps
drops from 719 KB to 664 KB, and the grid looks the same (the
photos-grid golden is regenerated).
The blur window's box filter summed the whole window for every pixel
and divided four times; it now keeps running sums and, for windows up
to 16 pixels wide, multiplies by a reciprocal that is exact for every
sum (checked exhaustively), with identical output.  The Circletext,
Smiley, ASCII and Flower windows fill white before anything that can
fail, so every pixel is opaque, and now say so, which lets the
compositor skip what lies under them.
Adding an interval to a row's covered set rebuilt the set in a
temporary array and copied it back through memcpy on every opaque
layer of every row.  Merge the touching intervals in place instead.
The result is the same set, including the overflow case that keeps
the old set; a randomized comparison against the old routine over two
million sequences found no difference.
The built-in renderer and the screen compositor each kept a private
copy of the same over operator.  Keep one in twin_private.h, so the
widget code can use it too.  Output is bit-identical.
The image demo copied each vector drawing into its window with
SOURCE, so the drawing's transparent pixels replaced the widget's
background and the desktop showed through around the tiger.
Composite it with OVER onto the background the widget already filled.
A toplevel's box had a transparent background and child widgets cleared
their rectangle to their own, mostly transparent, color.  The demo
windows (Calculator, Clock, Spline, Vectors) were therefore see-through:
the wallpaper and even the window's own shadow showed in their gaps, and
the compositor had to blend everything underneath.  In the phone, the
app sliding away showed through a demo sliding in.

A toplevel now starts with an opaque white background, and its window is
opaque whenever the root's background is opaque and rectangular.  A
widget fills its rectangle with its background composited over its
ancestors' backgrounds, so a repaint erases the old content without
punching a hole, and a translucent shape no longer darkens with each
repaint.  A box whose children all repaint whole fills itself first, so
space no child covers shows its background, and changing a box's
background or shape repaints its descendants.

The new twin_box_widget gives applications a box's widget; the
Calculator uses it to keep the black panel its translucent white
display and keys were drawn for.

The phone and desktop demo suites run 11% fewer instructions.
@jserv
jserv merged commit 47b47a4 into main Oct 19, 2026
33 checks passed
@jserv
jserv deleted the cellphone branch October 19, 2026 06:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant