Skip to content

Repository files navigation

caf ☕

A reactive coffee scene for your terminal that keeps your Mac awake.

caf-art wraps caffeinate -d in a procedurally rendered, physically simulated cup of coffee. Python 3.11+, with no third-party Python packages. The optional Swift/Metal renderer uses the macOS GPU and requires Xcode Command Line Tools to build.

Cream mixing — actual Metal output

Diner / 94: graphical mode

On a terminal that positively answers the Kitty graphics capability query, caf uses the optional Metal helper automatically. Otherwise it keeps the ANSI renderer; terminal names alone are not treated as proof of image support.

  • Ivory glazed ceramic, oxblood pinstripes, a resting foot and walnut tabletop.
  • Soft contact shadows, window/neon reflections, warm steam shafts and a free liquid surface.
  • Restrained red/teal diner neon and occasional passing-headlight sweeps; l toggles them, or start with --studio for the simpler lighting.
  • World-space steam follows both tilt axes; the steam-written clock still works.
  • Fine, stable ordered dithering (--smooth disables it), not low-resolution upscaling.
  • Ambient light changes the warm/cool lighting balance. The camera rests by default; press o for auto-orbit.
  • Bounded 30 FPS target, up to 960×720 real pixels. Sensor samples are buffered independently of rendering. At 720×480 on the development Mac, the warm APIC + rendering + readback/IPC + PNG pipeline measured 19.2 ms median / 20.5 ms p95 over 320 warm frames covering cream, stirring and a cup knock-over. Terminal transport, decoding and display are excluded; this is not a guaranteed end-to-end frame rate.
  • Missing helper, unavailable GPU, unsupported terminal or a failed render falls back to the CPU path. --ansi explicitly disables images; --renderer cpu disables Metal.

Teach it your laptop's tilt directions

caf --calibrate
caf

The guided calibration samples three held-still poses: level, right edge down, then far edge down. Tilt the whole laptop gently about 10–20 degrees; don't twist the lid. It saves validated screen axes to ~/.config/caf/imu.json. Cancelling or a bad capture preserves the previous calibration. Calibration does not run caffeinate or change sleep settings.

Start caf in a comfortable level pose. After the initial one-second settling period, that zero stays locked: a side tilt no longer fades away as you hold it. z re-zeros without discarding the directional mapping. --auto-level opts back into the old slow re-level behavior. Without a saved mapping, the existing sensor-axis heuristic remains available. caf --imu-tilt shows both axes; actual physical signs should be checked on your machine after calibration.

Real 3D liquid and spills

Metal mode now defaults to --fluid apic: a persistent 3D APIC particle/grid solver, not a tilted heightfield or canned splash. Pressure projection keeps liquid approximately incompressible; both calibrated tilt axes change gravity. Coffee can rise over the rim, form streams and detached drops, hit the saucer, and collect on the table. Returning to level lets it settle; spilled coffee does not respawn inside the cup. The status shows the fraction remaining and spilled.

  • s: remove a sip; when low, pour a refill instead.
  • f: pour a refill, leaving existing spills alone.
  • r: deliberately reset to a full cup and clean table.
  • space / x: stir / give the liquid and cup a small impulse.
  • m: pour cream into the coffee; stir it into moving ribbons and gradually mix it.
  • k: strongly knock the cup; r stands it back up and clears all liquid/cream.
  • l: toggle cinematic lighting without resetting the simulation.
  • z: re-zero laptop gravity, without deleting spills.
  • --fluid legacy: the previous GPU heightfield; CPU/ANSI also retain their existing simulation. Real 3D spilling requires the Metal helper.

The free surface now uses a finer 224×128×224 covariance-aware reconstruction, with thinner sheet reconstruction, bounded surface-tension forces and short-range rim adhesion. Density-drift correction prevents particle bunching from silently shrinking the visible liquid. The underlying pressure grid remains 56×32×56; this is still a finite-resolution real-time approximation, not film-grade splashes or a scientific CFD instrument. A model unit now represents 10 cm: gravity and control impulses use mug-scale timing, with substeps at most 1/360 second. Floor drag is time-scaled, rather than applied repeatedly during collision projection. Small ripples and thin films below the grid resolution are still approximations.

Fine splashes have actual volume. Sparse fast splash edges and rim drips can break into eight smaller ballistic droplets, preserving the parent's volume and linear momentum. Droplets rejoin the bulk flow on contact; cup and wet-surface impacts produce short-lived ripples rather than permanent animated noise.

Spills leave a wet surface behind. A conservative thin-film layer spreads across the fixed saucer and table, with gravity-driven flow, glossy wetness and edge-enhanced evaporation. Coffee and cream pigment travel with it, then pin into irregular drying edges—not stamped circular decals. Moving the cup reveals the wet patches and residue underneath. They persist for the current run until r or restart; they are not saved to disk. Drying pauses when the terminal loses focus. The 384×384 film covers an 80 cm square with a sealed outer edge and a maximum 3.2 mm depth; deeper pools remain in APIC and do not directly evaporate. This is a reduced-dimensional coating/normal model, not a fully resolved contact-line or film-geometry solver, and drying time is intentionally accelerated.

Splash breakup — actual GPU output Spreading wet patch and drying residue after 60 simulated seconds

Coffee transmits light rather than wearing an opaque brown surface. A bounded refracted ray samples actual liquid thickness, RGB absorption, and ceramic/table behind it: deep coffee is dark; thin spills transmit amber light. Fresnel reflection remains at the surface. There is no procedural cream-colored pattern in APIC mode; only added cream scatters light. This is a real-time single-path approximation, not a full multiple-scattering/path-traced renderer.

Cream is transported, not painted. A small moving pour adds cream-bearing particles. Concentration follows the flow, with gentle buoyancy and diffusion; stirring creates differential rotation instead of spinning a texture. A short absorption/scattering integration reveals cream below the surface. This is a single-fluid concentration model with stylized optics, not a separate multiphase milk solver or a canned latte-art animation.

The cup is a moving rigid body. Its foot, shell, rim and handle contact the saucer/table; the same transformed geometry drives rendering and liquid collision. Moving-wall velocities, pressure traction and collision impulses couple cup and coffee. Depending on knock direction, the handle may catch the saucer instead of letting the cup fall over. Try k rather than shaking the laptop; orbiting changes the screen-relative knock direction. Use --cup locked to retain a fixed cup. There is no timed housekeeping or automatic refill. A rocking cup can recover naturally through contact forces; r deliberately restores the whole scene.

Optional impact foley: caf --sound adds ceramic contacts and liquid splats whose timing/intensity follows measured physics. Playback is rate-limited to three voices, and quitting stops/reaps them. Audio remains off by default.

Focus-aware idle is automatic. In terminals that report focus (xterm mode 1004, including compatible Ghostty configurations), switching away pauses drawing, liquid/cup simulation, sensor polling and incidental foley. The native helper stays loaded but receives no frames. Pomodoro deadlines, timer bells, caffeinate and the lid guard remain active. Refocusing resumes from the same liquid state without fast-forwarding missed physics or replaying buffered laptop motion. q/Ctrl+C still quit while idle, and focus reporting is disabled during cleanup. Terminals or multiplexers that do not forward focus events remain active; there is no unreliable guessed-focus fallback. The existing IMU bridge service is not stopped or reconfigured.

The full cup starts with 15,032 equal-volume particles. Sources/sinks, droplets, deposited film and particles leaving the finite simulation domain are explicitly accounted for. Volume is tracked in eighth-particle units; deposited water remains accounted for as either wet film or evaporation. The bounded source budget is 60,000 original-particle equivalents, with 120,000 internal slots for breakup. Both are append-only until r; exhausted breakup slots simply prevent new splits. A full empty-cup refill takes several seconds rather than instantly packing thousands of particles into a tiny nozzle. The saucer stays fixed. There is no ceramic fracture, replay or lid-angle tracking. The lid guard and sleep configuration remain unchanged.

Cup tipped with its contents spilled — actual GPU output

Pomodoro work removes excess liquid, but never heals a spill; break-time refill adds actual particles. Because you can spill or sip, remaining coffee need not exactly match the timer. Manually reset when the allocation is exhausted.

Actual GPU spill after a synthetic 22-degree tilt

Existing renderers

caf --renderer cpu --ansi keeps the character-based 3D scene. caf --2d keeps the original rigid-body scene. Both can use the new calibration and held-pose lock.

classic

What's inside

  • Half-block renderer — the cup, handle, and saucer are drawn into a pixel buffer at 2× vertical resolution with truecolor lighting (curvature shading, glossy highlight, ambient falloff). 256-color fallback when COLORTERM isn't truecolor.
  • Braille steam — steam renders at 2×4 dots per character cell, driven by a curl-noise particle simulation. Wisps braid and bend as they rise.
  • Legacy shallow-water surface (CPU / --fluid legacy) — the liquid is a height-field wave simulation on an elliptical grid with reflecting walls. Sipping splashes it, stirring drags an orbiting spoon through it, refilling pours into it, and waves reflect off the cup wall. Lighting comes from the simulated surface slope.
  • CPU load is heat — a busy machine makes the coffee boil: more steam, faster wisps, bubbles breaking the surface. An idle machine lets it calm.
  • Steam writes the time — every 10 minutes (or on w), the steam particles converge to spell the clock, hold, and dissolve back into chaos.

steam writes the time

  • Responsive — layout recomputes from the terminal size every frame; resizing rescales the whole scene (and gusts the steam sideways).

  • Window motion is physics — the terminal's position is polled via the xterm CSI 13 t report; dragging the window blows the steam around, shoving it fast sloshes the coffee by inertia, and vertical jerks push the steam toward or away from you in 2.5D (wisps swell and brighten as they approach). In 3D mode the camera picks up a parallax nudge too.

  • Real laptop motion is physics — Apple Silicon MacBooks (M2+) hide a real MEMS IMU in the Sensor Processing Unit. A tiny ctypes bridge streams it (no sudo, no dependencies). Tilting the laptop doesn't move the cup — the terminal is bolted to the machine and so is everything drawn in it — it moves gravity: the coffee stays level with the real world while the cup leans around it, climbing the near wall when you tip the screen toward you. Shoves slosh it, spinning steers the 3D camera.

    Orientation is a complementary filter — the gyro turns the up vector every sample, the accelerometer pulls it back over half a second — so the coffee tracks the screen instead of catching up with it, and a shove is never mistaken for a lean. The sensor is not chassis-aligned (a MacBook flat on a desk reads a ~20° lean), so the pose caf starts in is calibrated as level and everything is measured against that; z re-zeros it, and that reference stays locked until z. With --auto-level, a pose you settle into becomes the new level over about a minute. caf-art --imu-tilt is a live readout — if your machine leans the wrong way, set CAF_IMU_FLIP to any mix of x (mirror left/right), y (near/far) and s (swap).

  • Spill physics (--2d) — in the half-block scene the cup is a rigid body. Tilt far enough and the coffee runs over the rim, streaks down, stains the saucer, and pools on the floor against the terminal walls. Keep going (or knock it with x, a hard window-shove, or a real-world jolt) and it slides off the saucer, tumbles, bounces off the window edges, and dumps everything — the steam spells OOPS, and housekeeping restores order a few seconds later. Metal APIC also has a movable 3D cup; the CPU/legacy 3D cup remains fixed. The older 2D scene retains its automatic housekeeping and screen-edge bounces.

spill

  • Ambient light sensor — the same SPU exposes the ALS. Dim the room and the scene dims to embers (the steam catches what light remains); a bright morning brings it back.
  • Sound (--sound) — procedurally synthesized foley (no assets: the WAVs are generated on first run): a ceramic clink for stirs, a glug for pours, a slurp for sips, a bell for the pomodoro, and a crash when the cup meets the floor.
  • Diff rendering — only changed lines are re-emitted each frame.
  • Kitty graphics mode (--hires) — on Ghostty/kitty, renders the same scene as real images at 4 px per cell.

hires

  • Ray-marched 3D mode (default; --2d for the half-block scene) — the cup becomes a signed-distance-field scene (revolved-profile cup and saucer, torus handle, liquid disc) sphere-traced in pure Python: Blinn-Phong shading, fresnel rim light, one soft penumbra shadow ray per hit (the cup casts a real contact shadow on the saucer), and a single reflection bounce on the liquid that mirrors the inner wall. The steam is volumetric — world-space wisps rise off the liquid surface, orbit with the camera, catch the key light, and disappear behind the cup via a depth buffer. The liquid surface is bump-mapped live from the same shallow-water simulation, so stirring and sipping ripple in 3D, and the surface is a real plane held level with world gravity, so a tilted laptop shows as coffee riding up the inside of the wall until it brims at the rim. Arrow keys orbit, o toggles auto-orbit. ~30 ms/frame at 90×30 — no numpy, no GPU, just sphere tracing in a for loop.

3d

Usage

caf-art [preset] [flags]      # ray-marched 3D cup by default

Presets: diner (default) classic matcha cyber berry dark

cyber

Keys while running

key action
s take a sip — the level drops; refills with a pour when low
f pour a refill without clearing spills
r reset APIC to a full cup and clean table
m pour cream into APIC coffee
k strongly knock the APIC cup; contact direction determines whether it tips
l toggle animated diner lighting
space stir — differential flow stretches and mixes the cream
x small cup/liquid nudge; legacy 3D remains wave-only
w steam writes the current time
t cycle themes
z call the laptop's current pose level (re-zero the tilt)
q quit (Ctrl+C works too)

Flags

flag effect
--fluid apic|legacy genuine GPU liquid (default) or the old heightfield
--renderer auto|metal|cpu automatically select, request, or disable Metal
--ansi force character output
--smooth disable GPU dithering
--cup free|locked movable APIC cup (default) or fixed cup
--studio disable animated diner lighting
--2d original, cheaper half-block rigid-body scene
--pomodoro N the coffee is the timer: drains over N minutes of work, rings and refills over a 5-minute break, repeats — steam spells BREAK and GO
--weather fetch local weather once (wttr.in): rain streaks, drifting snow, or a warm sun halo
--zen screensaver mode: no status line, slow theme crossfades, occasional auto-stirs
--hires kitty graphics protocol output (Ghostty, kitty)
--sound procedural foley through afplay (macOS)

Custom themes

~/.config/caf/themes.toml:

[mocha]
name = "Mocha"
icon = "🍫"
cup = [180, 130, 90]
liquid = [60, 35, 20]
crema = [190, 150, 110]
steam = [235, 230, 225]
saucer = [140, 95, 60]

Install

git clone https://github.com/bookernath/caf.git
cd caf
./install.sh

The installer compiles the Metal helper and installs caf-art, caf_graphics.py, caf_motion.py, caf-metal, caf.metal, caf-fluid.metal, caf-scene.metal and caf-detail.metal together in ~/.local/bin. All four Metal sources are required. Existing files are copied into a .caf-backup-* directory there before replacement. To roll back, copy the desired backup files back into ~/.local/bin (the old single-file renderer ignores the extra helper files).

It does not replace caf-lid-guard or edit your shell configuration. For a fresh installation that also needs the optional lid guard, install that separately:

install -m 755 caf-lid-guard ~/.local/bin/caf-lid-guard

For CPU-only use without a Swift compiler, copy caf-art, caf_graphics.py and caf_motion.py together into ~/.local/bin, then make caf-art executable.

Optionally add a shell alias:

caf() { ~/.local/bin/caf-art "$@" }

The same function is provided in caf-shortcut.sh.

caffeinate is macOS-only; on other platforms the animation runs without the keep-awake (a caffeinate binary on PATH will be used if present).

Closing the lid

caffeinate — with any flag, including -s — only files IOKit power assertions. Lid close is not idle sleep: it's a separate hardware-triggered sleep path that ignores assertions entirely, so a plain caffeinate session still suspends the instant the lid shuts. Amphetamine's "Closed-Display Mode" originally used the private assertion property AppliesOnLidClose, but macOS 10.13 put that behind the restricted com.apple.private.iokit.assertonlidclose entitlement. The only lever left to third-party code is the kernel SleepDisabled flag, i.e. pmset disablesleep 1 — which needs root and, left behind, means the Mac never sleeps again.

caf-lid-guard is that lever with a leash:

  • one sudo prompt at startup unless the optional narrow sudoers rule is installed;
  • a watchdog clears the flag the moment the owning caf-art pid goes away — clean exit, Ctrl+C, kill -9, closed terminal, crash — and it's already root in the interactive-sudo path, so teardown never re-prompts;
  • concurrent caf sessions refcount, restoring only the pre-caf value;
  • a stuck flag from a killed watchdog is reclaimed on the next run;
  • if something else (Amphetamine, a manual pmset) already set the flag, it's left alone;
  • while the lid is shut it parks the internal panel once per close (pmset displaysleepnow), since SleepDisabled otherwise leaves it lit and cooking under a closed lid.

caf --no-lid-guard (or CAF_NO_LID_GUARD=1) skips it; caf-lid-guard status shows the current flag, holder count, and lid state.

caf-lid-guard.sudoers preserves the optional setup template from the original gist. It grants passwordless access only to the listed pmset sleep-toggle commands; the watchdog can then run unprivileged and restore through that rule. Review the template and validate it with visudo before any manual installation. The renderer installer does not install this rule or change your existing privileges or sleep settings.

Development and verification

python3 -B -m unittest discover -s tests -v
# Also exercise actual GPU frames on a Mac with Metal access:
CAF_TEST_METAL=1 python3 -B -m unittest discover -s tests -v
# Legacy GPU material preview: no sensors or power assertions:
python3 -B tools/preview.py --output .build/diner.png
# Reproducible APIC tilt/return snapshots and particle-accounting diagnostics:
python3 -B tools/fluid_probe.py --tilt 22 --frames 270 --render-every 30
python3 -B tools/cinematic_probe.py --action mix --frames 330 --render-every 30
python3 -B tools/cinematic_probe.py --action knock --frames 300 --render-every 30
python3 -B tools/detail_probe.py --seconds 60 --output .build/detail-probe
python3 -B tools/fluid_benchmark.py --output .build/benchmark.json

Build the helper without installing:

mkdir -p .build
xcrun swiftc -O -module-cache-path "$PWD/.build/module-cache" native/caf-metal.swift -o .build/caf-metal

native/caf.metal contains the renderer; native/caf-fluid.metal contains the APIC solver, concentration transport and particle-surface reconstruction. native/caf-detail.metal owns conservative breakup, wet-film transport, evaporation, pigment deposition, impact ripples and fine-droplet intersections. native/caf-scene.metal shares collision geometry, rigid-body state and contact stepping between physics and rendering. caf_graphics.py owns the bounded JSON/RGB helper protocol, color conversion and terminal capability probe; caf_motion.py owns calibration persistence and the sensor queue. The CPU renderer, water simulation, keyboard interactions and awake-session lifetime remain in caf-art. GPU tests need device access; sandboxed/headless runs may only have the CPU fallback. The suite also exercises real keyboard input behind an emulated terminal, with sensors, audio playback and power assertions disabled. It checks cream mass, occupied volume/density, cup rest/tipping/handle catches, zero effective gravity, source/sink accounting, rendering and cleanup. Focus tests send fragmented focus escapes through a PTY, check zero background frames/polls, advance Pomodoro while blurred, and verify no catch-up on resume. A falling-coffee test checks physical gravity timing directly; there is no separate color-test mode. Detail tests exercise production Metal kernels with controlled inputs for exact split volume/momentum, exhausted slot capacity, film/pigment conservation and ripple decay. Real knock/aging tests verify deposition, residue, cream accounting, zero-time rendering, focus-paused wetness and reset cleanup.

The CAF_METAL_5 helper and its shaders must be installed together; the shader layout guard rejects stale helpers rather than permitting mismatched field access.

Technique background: particle-surface reconstruction and position-based rigid-body contacts. Optics background: Beer–Lambert transmittance. Drying background: capillary flow and ring stains. Focus protocol: xterm focus events. This is a compact custom Metal implementation, not those papers' reference code.

License

MIT

About

A reactive coffee scene for your terminal, with Metal fluid simulation and macOS keep-awake.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages