Attached sketches

A sketch with no window of its own: its canvas is another app's window. It follows that window around the screen, takes its size, hides when it's minimized, and can feel how you move it, what you type in it, where you click and whether your last command failed. One sketch per window if you like, slowing down by itself while nobody's looking, and exported as a tray app. Windows only for now, and in beta: the API and the export may still change. Samples: Rain on the terminal, Water in the terminal, Keyboard bubbles, Flow field in the terminal, Cloth over the whole terminal, Blobs in the terminal, Wobbly functions, CSS blob in the terminal and Audio-reactive terminal (system audio through NAudio).

Beta

On this page · 16 entries
Beta. Attached sketches are new and still changing: names, the export options and how the sketch is placed may change between versions. On Windows Terminal a sketch is always drawn behind the terminal, under its text, never on top of it; it shows only through a see-through profile (opacity below 100 in the profile's settings, or the profile the export can install). With an opaque profile you won't see it.

AttachedSketch · Attach

  • AttachedSketch
  • Attach(WindowTarget target, AttachLayer layer, topInset, AttachMode mode)

Inherit from AttachedSketch instead of Sketch and everything else stays the same: Setup(), Draw(), Run, ⚡ Hot Reload, ■ Stop. Attach() picks the window to draw on; without it the sketch follows Windows Terminal. Width/Height are the window's (minus topInset, the tab bar by default), change when it's resized, and WindowResized() tells you. Size() has no effect. The sketch draws in logical pixels: on a 150 % monitor things keep their size. In the IDE the canvas shows a card saying where the sketch is and what it's doing, with badges for what it uses: N VENTANAS, TECLADO and RATÓN (input hooks, in orange), SHELL, ECO, PAUSA.

Call Attach() in Settings(). Settings() runs before the window is looked for, so Width/Height are already the target's inside Setup(). Calling Attach() later (a key press, Draw()) moves the sketch to the new target on the next frame; mode is only read at the start.
Clear() every frame. The canvas starts transparent. Clear() at the start of Draw() keeps it see-through where you don't draw (the desktop behind a terminal, or the window under an Above sketch); Background() would cover it.

WindowTarget.Terminal · WindowTarget.Class · WindowTarget.Process · AttachLayer

  • WindowTarget.Terminal
  • WindowTarget.Class(className, topInset)
  • WindowTarget.Process(processName, topInset)
  • AttachLayer

Which window to follow. WindowTarget.Terminal is Windows Terminal (and leaves its 40 px tab bar uncovered). WindowTarget.Class("Notepad") matches a Win32 window class (Spy++ shows it); WindowTarget.Process("Code") the main window of a process, by name, with or without .exe. When several windows match, the one in front wins; when none is open the sketch waits and looks again twice a second. AttachLayer.Behind draws right under the window, which only shows where the window is see-through — the text then stays on top. Windows Terminal always uses Behind, whatever Attach() asks for: give the profile an opacity below 100 to see the sketch. AttachLayer.Above draws on top of the window and lets every click through; it's the default for other windows (Class, Process) and is ignored on the terminal. WindowTarget.IsTerminal tells which is which.

AttachMode.Front · AttachMode.EachWindow · InstanceNumber · MaxInstances

  • AttachMode.Front
  • AttachMode.EachWindow
  • InstanceNumber → int
  • MaxInstances

AttachMode.Front (default) runs one sketch, on the matching window in front. AttachMode.EachWindow runs one per matching window — every open terminal gets its own, as it opens, up to MaxInstances (8) — and drops it when the window closes. Each window's sketch is a separate instance of your class: fields aren't shared, InstanceNumber is 0, 1, 2…, and each is seeded with Seed + InstanceNumber so Random() and Noise() differ per window. Hot Reload reaches all of them, each keeping its own state; only the first one plays sound.

C#
public override void Settings() =>
    Attach(WindowTarget.Terminal, mode: AttachMode.EachWindow);

TargetX · TargetY · TargetScale · IsAttached · IsTargetFocused

  • TargetX
  • TargetY → int
  • TargetScale → float
  • IsAttached
  • IsTargetFocused → bool

Where the sketch's area is on screen (logical pixels), the monitor's scale (1.5 at 150 %), whether a target is found and showing, and whether it's the window you're typing in. MouseX/MouseY follow the cursor even though clicks go through, and MouseMoved() fires when it moves over the area. Focused is the same as IsTargetFocused.

TargetVelocity · TargetAcceleration · IsTargetMoving

  • TargetVelocity
  • TargetAcceleration → PVector
  • IsTargetMoving → bool

How the window is moving, in logical px/s and px/s², smoothed so one jittery frame doesn't spike them; acceleration is clamped to ±TargetAccelerationLimit (30000) so a window that jumps across the screen doesn't launch everything into orbit. Things inside a moving box feel the opposite of its acceleration — a glass of water in a car that brakes — so -TargetAcceleration.X is a sideways force and Gravity - TargetAcceleration.Y is gravity: yank the window down and it gets lighter, and things jump. Both are zero while the window is still or hidden. IsTargetMoving is true above TargetMovingSpeed (5 px/s).

TargetMoved · TargetFocused · TargetBlurred · TargetShown · TargetHidden

  • TargetMoved()
  • TargetFocused()
  • TargetBlurred()
  • TargetShown()
  • TargetHidden()

Override them to react to the window. They run between frames on the same thread as Draw(), never in the middle of one, and only for changes after the sketch started (read IsTargetFocused/IsAttached in Setup() for the starting state). TargetMoved() fires every frame the window's position changes. TargetHidden() fires when it's minimized, hidden, moved to another virtual desktop, made too small or closed; Draw() stops until TargetShown().

ListenToTargetKeys · ListenToTargetMouse

  • ListenToTargetKeys(listen)
  • ListenToTargetMouse(listen)

Opt in to the window's input. After ListenToTargetKeys(), keys typed into the target reach KeyPressed(), KeyTyped() and KeyReleased() with Key/KeyCode set as usual (letters follow Shift and the keyboard layout; arrows and modifiers are CODED). After ListenToTargetMouse(), clicks and the wheel over the sketch's area reach MousePressed(), MouseReleased(), MouseClicked() and MouseWheel(). The target still gets every key and click — nothing is swallowed. Both can be turned off again with false.

Privacy. Keys are read only while the target is the foreground window, and clicks only inside the sketch's area when the target itself is under the cursor; nothing typed in any other app ever reaches the sketch. Reading another app's input needs a low-level system hook, which some antivirus tools flag, so it only starts when a sketch asks for it, stops with the sketch, and the IDE's card shows it (TECLADO, RATÓN).

ListenToShell · CommandFinished · OnShellSignal · LastCommandOk · LastExitCode · CommandCount

  • ListenToShell(listen)
  • CommandFinished()
  • OnShellSignal(name)
  • LastCommandOk → bool
  • LastExitCode
  • CommandCount → int

React to the commands you run in the terminal. After ListenToShell(), CommandFinished() runs every time a command ends, with LastCommandOk and LastExitCode telling how (CommandCount counts them). An empty Enter doesn't count. dana-signal reset typed in the shell sends a signal of your own: OnShellSignal("reset"). With several terminals, the signal goes to the sketch on the one in front (where you just pressed Enter).

One-time setup. The shell has to say how each command ended. Tools → Install PowerShell signals in the IDE (or ShellSignals.InstallForPowerShell(), or the same item in an exported app's tray menu) adds a few lines at the end of your $PROFILE — Windows PowerShell's, and PowerShell 7's if you have it, asked to the shells themselves so a moved Documents folder doesn't matter. Open a new terminal afterwards.
oh-my-posh, starship, posh-git. Prompt themes replace the prompt when they load, so the lines wrap whatever prompt is active and wrap it again whenever the shell is idle and something replaced it (the same approach as Abyss Tide's hook), handing the theme the same $? and $LASTEXITCODE it would have seen: the theme still shows the error, and the sketch gets it too. Each wrapper keeps the prompt it wraps in a closure, so the chain of prompts can never loop back on itself (an earlier version could, and hung the console after an error), names it with a # __PromptWrapper:name comment, and looks for itself down that chain before wrapping, so two hooks that both re-wrap never wrap each other forever. If it's ever entered twice at once it returns a plain prompt instead of recursing.
Abyss Tide. If Abyss Tide's prompt hook is installed, sketches also read its signal file (%LOCALAPPDATA%\AbyssTide\signal.txt: "ok"/"error", no exit code) — nothing else to install. With both hooks, each command counts once, with Dana's exit code.
Not reacting? Tools → Test: failed command (or the tray menu's) sends the signal a failed command would: if the sketch reacts, the sketch side works. Then open a new terminal (open ones don't reload the profile), check that Get-EventSubscriber lists PowerShell.OnIdle, run the install again (it updates an older copy), and look at Get-Content $env:LOCALAPPDATA\DanaProcessing\shell-signal.txt after a command.
Privacy. The lines wrap your existing prompt and write only "ok"/"error", the exit code and the shell's process id to %LOCALAPPDATA%\DanaProcessing\shell-signal.txt — never the command itself — and ShellSignals.Uninstall(profilePath) removes them. The IDE's card warns when a sketch listens but the lines aren't installed, or are an older copy.

ShellSignals.InstallForPowerShell · Install · Uninstall · IsInstalled · PowerShellProfiles · PowerShellSnippet · SignalFile

  • ShellSignals.InstallForPowerShell()
  • Install(profilePath)
  • Uninstall(profilePath)
  • IsInstalled(profilePath)
  • PowerShellProfiles()
  • PowerShellSnippet
  • SignalFile

The profile lines, and helpers to add or remove them. Install is safe to run again (it replaces its own block, marked by comments, and keeps the rest of the file and its line endings). The block, to paste by hand into $PROFILE if you prefer:

C#
# >>> DanaProcessing shell signals >>>
# Lets DanaProcessing sketches attached to this terminal react to commands.
# Works with oh-my-posh, starship, Abyss Tide and custom prompts: it wraps whatever
# prompt is active, wraps it again whenever something replaces it, and keeps $? and
# $LASTEXITCODE for the prompt underneath. Check: Get-EventSubscriber shows PowerShell.OnIdle.
if (-not (Test-Path variable:global:__danaWrap)) {
    $global:__danaSignalFile = Join-Path $env:LOCALAPPDATA 'DanaProcessing\shell-signal.txt'
    New-Item -ItemType Directory -Force (Split-Path $global:__danaSignalFile) | Out-Null
    $global:__danaLastHistoryId = (Get-History -Count 1).Id
    $global:__danaInPrompt = $false
    function global:dana-signal([string]$name) {
        [IO.File]::WriteAllText($global:__danaSignalFile, "signal $name $PID")
    }
    $global:__danaWrap = {
        # Already in the prompt chain? Walk down through the wrappers (ours, Abyss Tide's, others
        # that follow the '# __PromptWrapper:<variable>' convention) looking for this one.
        $p = $function:prompt
        for ($i = 0; $p -and $i -lt 30; $i++) {
            $t = "$p"
            if ($t -match '__DanaShellSignals|__danaSignalFile') { return }   # this hook (or an older copy)
            if ($t -match '# __PromptWrapper:(\w+)') { $name = $Matches[1] }
            elseif ($t -match '__AbyssTideMarker') { $name = '__AbyssTidePrompt' }
            else { break }
            $inner = $null
            if ($p.Module) { $inner = $p.Module.SessionState.PSVariable.GetValue($name) }
            if (-not $inner) { $inner = Get-Variable -Scope Global -Name $name -ValueOnly -ErrorAction Ignore }
            $p = $inner
        }
        $inner = $function:prompt
        $function:global:prompt = {
            # __PromptWrapper:inner
            # __DanaShellSignals
            $ok = $?; $code = $global:LASTEXITCODE
            if ($global:__danaInPrompt) { return "PS $($executionContext.SessionState.Path.CurrentLocation)> " }
            $global:__danaInPrompt = $true
            try {
                try {
                    $last = Get-History -Count 1
                    if ($last -and $last.Id -ne $global:__danaLastHistoryId) {
                        $global:__danaLastHistoryId = $last.Id
                        $line = if ($ok) { "ok 0 $PID" } elseif ($code) { "error $code $PID" } else { "error 1 $PID" }
                        [IO.File]::WriteAllText($global:__danaSignalFile, $line)
                    }
                } catch { }
                # The prompt underneath reads $? and $LASTEXITCODE too: hand them back.
                $global:LASTEXITCODE = $code
                if (-not $ok) { Write-Error '' -ErrorAction Ignore }
                & $inner
            } finally {
                $global:__danaInPrompt = $false
            }
        }.GetNewClosure()
    }
    & $global:__danaWrap
    $null = Register-EngineEvent -SourceIdentifier PowerShell.OnIdle -Action { & $global:__danaWrap }
}
# <<< DanaProcessing shell signals <<<

DeltaTime · EcoMode · NoEco · IsEco · IsEcoEnabled · EcoFrameRate

  • DeltaTime → float
  • EcoMode(on, fps, whenUnfocused)
  • NoEco()
  • IsEco
  • IsEcoEnabled → bool
  • EcoFrameRate → int

An attached sketch lives on the desktop all day, so it saves power by itself: while nobody can see it — another window covers the whole target, it isn't moving, and nothing has happened (input, a shell signal, a focus change) for 2 seconds — and whenever a laptop is on battery below 25 %. A terminal that's merely not in front (beside the window you're using, on another monitor) keeps its full frame rate; EcoMode(whenUnfocused: true) slows it down as soon as it isn't in front, which saves more, it drops to EcoFrameRate (15 by default). Anything happening brings it back to FrameRate() at once. IsEco says when it's slowed down. Use DeltaTime (seconds since the previous frame, at most 0.1) for anything that moves, so motion keeps its speed at any frame rate. EcoMode(fps: 5) changes the slow rate; NoEco() keeps the sketch at full speed always.

Export as a tray app

File → Export on an AttachedSketch makes an app with no window of its own: it draws on the window its Attach() picks and lives in the system tray, next to the clock. The icon's menu shows what it's doing (the same text as the IDE's card) and has Pause/Resume (a click on the icon does the same), Install PowerShell signals and Quit. The export dialog says so instead of offering a title bar option.

Install it like Abyss Tide: install.ps1

The export dialog of an AttachedSketch has two more options, both on by default. One sketch per terminal runs a copy of the sketch on every Windows Terminal window (as if its Attach() said AttachMode.EachWindow, whatever it says). PowerShell installer puts an install.ps1 and a LEEME.txt next to the exe. Run it once (right click → Run with PowerShell, or powershell -ExecutionPolicy Bypass -File .\install.ps1) and, for the current user and without administrator rights, it copies the app to %LOCALAPPDATA%\DanaProcessing\Sketches\<name>, unblocks its files, adds the PowerShell prompt hook (so the sketch knows when a command fails), creates a shortcut in the Startup folder and starts it.

.\install.ps1                      # install, start with Windows, start now
.\install.ps1 -NoAutostart         # without starting with Windows
.\install.ps1 -NoShellHook         # without touching your PowerShell profile
.\install.ps1 -Uninstall           # stop it and remove it
.\install.ps1 -Uninstall -RemoveShellHook   # also remove the hook (shared by every Dana sketch)

In its own Windows Terminal profile (a third option, on by default, needs the installer) does what Abyss Tide does: install.ps1 adds a PowerShell · <name> profile through a Windows Terminal fragment, and lists it in your settings.json with a one-line entry like the ones Terminal writes itself (Terminal hides a fragment profile it has seen before but can't find there; a backup goes to settings.json.bak-dana), see-through at 25 % opacity with a dark scheme of its own, and the sketch draws behind the terminal over a dark backdrop. It shows only in that profile: the other, opaque profiles cover it. Restart Windows Terminal after installing to see the new profile; -Uninstall removes it. Without this option the sketch still draws behind every terminal, never over the text, and only shows in terminals whose profile is already see-through.

The exported app runs once per exe: starting it again (autostart plus a double-click, or reinstalling) doesn't put a second overlay on your terminals. The prompt hook is installed by the exe itself (--install-shell-signals), the same one the IDE's Tools menu installs.

How it works

Each sketch runs on a thread of its own with a borderless, click-through window placed exactly over its target, and that thread sleeps until something happens: the next frame is due, or Windows, the input hooks or the shell wake it. Every frame it reads where the target is (DWM's visible bounds, its monitor's DPI, whether it's minimized or cloaked), resizes the surface if needed, runs the events and Draw(), and puts the pixels on screen: Skia draws straight into the bitmap the window shows, with no copy. Today: Windows only, drawn on the CPU.

Event-driven, not polled. Windows reports when the target moves, resizes, minimizes, hides, closes or loses the foreground (WinEvent hooks): a move takes the overlay along at once, between frames, so it doesn't trail a drag (Windows reports hundreds of moves a second while dragging, so they're merged: the overlay moves at most every 4 ms and the sketch keeps drawing frames throughout the drag); the rest wakes the sketch for a frame right away. A key, a click or a shell signal wakes it too — in eco mode as well, so the first event after a quiet spell isn't late. Shell signals are noticed the moment the file is written (a file watcher; a once-a-second poll is only the fallback), and with AttachMode.EachWindow a terminal you open gets its sketch as soon as it shows. Keyboard and mouse hooks live on another thread, so a slow Draw() never delays your typing.
How fast is it? The IDE's card shows it while the sketch runs: frames per second, what Draw() and putting the frame on screen cost, and how long the last shell signal, key and window move took to arrive (for example 60 fps · dibujo 3.1 ms · presentar 0.9 ms · señal 4 ms · tecla 2 ms · mover 1 ms). For thousands of short lines, draw them in batches (BeginShape(ShapeKind.Lines)) with StrokeWeight(0) — a one-pixel hairline, Skia's fastest — as the flow field sample does: it went from 16 to 6 ms a frame.

Runnable example: water in the terminal

Water that sloshes when you drag Windows Terminal, splashes when you type in it and dims when you switch away. Run in Dana opens it in the IDE; open Windows Terminal before pressing Run. It is also in Sketch → Examples.

C#
// Water in the terminal: drag Windows Terminal around and the water inside
// sloshes, hits the walls and splashes -- the same trick as a glass of
// water in a moving car: what's inside feels the opposite of
// TargetAcceleration.
//
// 1. Open Windows Terminal and press Run.
// 2. Drag the window and let go suddenly. Shake it. Yank it down: the water jumps.
// 3. Type in the terminal: every key drops a drop, Enter makes a splash.
//    Click the water to drop one where you click.
// 4. Switch to another window: the water dims until you come back.
//
// ListenToTargetKeys()/ListenToTargetMouse() read keys typed into the terminal
// and clicks over it (the terminal still gets all of them). Windows only.
public class TerminalWater : AttachedSketch
{
    const float Cell = 8;          // width of one water column, in pixels
    const float Gravity = 900;     // px/s²
    const float Inertia = 0.28f;   // how strongly the window's motion pushes the water
    const float FillLevel = 0.3f;  // how full the window is

    float[] _h = new float[0];     // water height per column
    float[] _u = new float[0];     // flow between columns
    float[] _flux = new float[0];
    int _cols;
    float _dim = 1;
    readonly List<PVector> _drops = new();
    readonly List<PVector> _dropSpeeds = new();

    public override void Settings() => Attach(WindowTarget.Terminal);

    public override void Setup()
    {
        ListenToTargetKeys();
        ListenToTargetMouse();
        Rebuild();
    }

    public override void WindowResized() => Rebuild();

    void Rebuild()
    {
        _cols = Math.Max(8, (int)(Width / Cell));
        _h = new float[_cols];
        _u = new float[_cols + 1];
        _flux = new float[_cols + 1];
        for (int i = 0; i < _cols; i++)
            _h[i] = Height * FillLevel;
    }

    // --- input from the terminal -------------------------------------------

    public override void KeyTyped() => Drop(Random(Width), 1);

    public override void KeyPressed()
    {
        if (Key == ENTER)
            for (int i = 0; i < 6; i++)
                Drop(Random(Width), 2);
    }

    public override void MousePressed() => Drop(MouseX, 1.5f);

    public override void TargetShown() => Splash(_cols / 2, 60); // welcome back

    void Drop(float x, float size)
    {
        _drops.Add(new PVector(x, -10, size));
        _dropSpeeds.Add(new PVector(Random(-20, 20), Random(0, 80)));
    }

    void Splash(int col, float amount)
    {
        if (col < 1 || col >= _cols - 1) return;
        float take = Math.Min(amount, _h[col] * 0.5f);
        _h[col] -= take;               // a dent in the surface...
        _h[col - 1] += take / 2;       // ...pushes water to both sides: a wave
        _h[col + 1] += take / 2;
    }

    // --- simulation -----------------------------------------------------------

    public override void Draw()
    {
        Clear();
        float dt = DeltaTime;   // real seconds: same speed at 60 fps or in eco mode
        var a = TargetAcceleration;
        float fx = -a.X * Inertia;                                        // sideways push
        float g = Constrain(Gravity - a.Y * Inertia, Gravity * 0.25f, Gravity * 4);  // moving down = lighter

        Step(dt, fx, g);
        UpdateDrops(dt, g);

        _dim = Lerp(_dim, IsTargetFocused ? 1 : 0.45f, 0.08f);
        DrawWater();
        DrawDrops();
    }

    void Step(float dt, float fx, float g)
    {
        int sub = 8;
        float sdt = dt / sub;
        float damp = Exp(-0.8f * sdt);
        float maxU = 0.45f * Cell / sdt;
        for (int s = 0; s < sub; s++)
        {
            for (int i = 1; i < _cols; i++)
            {
                float v = _u[i] + sdt * (-g * (_h[i] - _h[i - 1]) / Cell + fx);
                if (_h[i] < 0.5f && _h[i - 1] < 0.5f) v = 0;
                _u[i] = Constrain(v * damp, -maxU, maxU);
            }
            _u[0] = _u[_cols] = 0;   // the window's walls
            for (int i = 1; i < _cols; i++)
                _flux[i] = _u[i] * (_u[i] > 0 ? _h[i - 1] : _h[i]);
            for (int i = 0; i < _cols; i++)
                _h[i] = Constrain(_h[i] - sdt * (_flux[i + 1] - _flux[i]) / Cell, 0, Height);
        }

        // Keep the volume constant (rounding slowly leaks it).
        float target = FillLevel * Height * _cols, vol = 0;
        foreach (var h in _h) vol += h;
        if (vol > 1)
            for (int i = 0; i < _cols; i++) _h[i] *= 1 + (target / vol - 1) * 0.1f;
    }

    void UpdateDrops(float dt, float g)
    {
        for (int i = _drops.Count - 1; i >= 0; i--)
        {
            var p = _drops[i];
            var v = _dropSpeeds[i];
            v.Y += g * dt;
            p.X += v.X * dt;
            p.Y += v.Y * dt;
            int col = Constrain((int)(p.X / Cell), 0, _cols - 1);
            if (p.Y >= Height - _h[col])
            {
                Splash(col, 6 * p.Z);
                _drops.RemoveAt(i);
                _dropSpeeds.RemoveAt(i);
            }
        }
    }

    void DrawWater()
    {
        // The body: one polygon through the middle of every column's top.
        NoStroke();
        Fill(20, 90, 170, 110 * _dim);
        BeginShape();
        Vertex(0, Height);
        Vertex(0, Height - _h[0]);
        for (int i = 0; i < _cols; i++)
            Vertex(i * Cell + Cell / 2, Height - _h[i]);
        Vertex(Width, Height - _h[_cols - 1]);
        Vertex(Width, Height);
        EndShape(close: true);

        // The surface line, brighter (foam) where it's steep.
        StrokeWeight(2);
        for (int i = 1; i < _cols; i++)
        {
            float steep = Abs(_h[i] - _h[i - 1]);
            Stroke(150, 230, 255, (110 + Math.Min(steep * 25, 145)) * _dim);
            Line((i - 1) * Cell + Cell / 2, Height - _h[i - 1], i * Cell + Cell / 2, Height - _h[i]);
        }
    }

    void DrawDrops()
    {
        NoStroke();
        Fill(170, 230, 255, 220 * _dim);
        foreach (var p in _drops)
            Ellipse(p.X, p.Y, 4 * p.Z, 6 * p.Z);
    }
}

Runnable example: a flow field per terminal

Particles following a vector field, a different one on each Windows Terminal window. Dragging a terminal throws them back (inertia) and twists the field; a command that fails turns it into a red storm with the exit code, one that works calms it with a cyan pulse. Idea from "Vector field with particles" by jcponcemath on OpenProcessing, written from scratch for Dana.

C#
// Flow field in the terminal: particles drift along a vector field drawn
// behind Windows Terminal's text -- and every terminal window gets a field of its own.
//
// Idea from "Vector field with particles" by jcponcemath on OpenProcessing
// (openprocessing.org/sketch/3017881); this is a fresh take written for Dana.
//
// 1. Open one or more Windows Terminal windows and press Run.
// 2. Drag a terminal: the particles lag behind (inertia, -TargetAcceleration)
//    and the field twists with the speed (TargetVelocity). Let go: it untwists.
// 3. Run a command that fails (try:  cmd /c exit 3): the field becomes a red
//    storm and shows the exit code. A command that works calms it with a cyan
//    pulse. dana-signal reset draws a new field.
//    Commands need the PowerShell snippet once: run ShellSignals.InstallForPowerShell()
//    (or use the tray menu of the exported app), then open a new terminal.
public class TerminalFlowField : AttachedSketch
{
    const int Tail = 10;  // positions kept per particle for its trail

    sealed class Particle
    {
        public float X, Y, VX, VY, Age, Life;
        public readonly float[] TX = new float[Tail], TY = new float[Tail];
        public int Head;
    }

    readonly List<Particle> _particles = new();

    // Trails are drawn in batches: every segment of the same color and alpha goes
    // into one shape (BeginShape(ShapeKind.Lines) = one Skia call) instead of a
    // Stroke() + Line() per segment, thousands of calls a frame otherwise.
    const int HueSteps = 24, AlphaSteps = 8;   // 15° hue steps, 8 alpha levels
    readonly List<float>[] _batches = Enumerable.Range(0, HueSteps * (AlphaSteps + 1)).Select(_ => new List<float>()).ToArray();
    float _a, _b, _swirl, _phase;   // the field's shape: different per window (each instance has its own seed)
    float _storm;                   // 1 right after a failed command, fades out
    float _calm;                    // 1 right after a command that worked
    float _twist;                   // follows how fast the window is dragged
    float _ringRadius = -1, _ringHue;

    public override void Settings() =>
        Attach(WindowTarget.Terminal, mode: AttachMode.EachWindow);

    public override void Setup()
    {
        ListenToShell();
        ColorMode(ColorSpaceMode.HSB, 360, 100, 100, 255);
        TextAlign(TextAlignH.Right, TextAlignV.Bottom);
        NewField();
        FitParticles();
    }

    public override void WindowResized() => FitParticles();

    void NewField()
    {
        _a = Random(1.5f, 3.5f);
        _b = Random(1.5f, 3.5f);
        _swirl = Random(-0.8f, 0.8f);
        _phase = Random(TWO_PI);
    }

    void FitParticles()
    {
        int wanted = Constrain(Width * Height / 900, 150, 1000);
        while (_particles.Count < wanted)
            _particles.Add(Respawn(new Particle()));
        if (_particles.Count > wanted)
            _particles.RemoveRange(wanted, _particles.Count - wanted);
    }

    Particle Respawn(Particle p)
    {
        p.X = Random(Width);
        p.Y = Random(Height);
        p.VX = p.VY = 0;
        p.Age = 0;
        p.Life = Random(2, 6);
        for (int i = 0; i < Tail; i++) { p.TX[i] = p.X; p.TY[i] = p.Y; }
        return p;
    }

    // --- the vector field -------------------------------------------------------

    (float X, float Y) Field(float x, float y, float t)
    {
        // Smooth swirls in coordinates centred on the window, scaled to it.
        float s = Math.Max(1, Math.Min(Width, Height));
        float u = (x - Width / 2f) / s * 2, v = (y - Height / 2f) / s * 2;
        float fx = Sin(_a * v + _phase + t * 0.2f) - _swirl * v;
        float fy = Cos(_b * u - _phase + t * 0.15f) + _swirl * u;

        // After an error: noisy turbulence on top.
        if (_storm > 0)
        {
            float n = Noise(x * 0.008f + t * 0.4f, y * 0.008f) * TWO_PI * 3;
            fx += Cos(n) * 2.5f * _storm;
            fy += Sin(n) * 2.5f * _storm;
        }

        // While the window is dragged: the whole field turns.
        float c = Cos(_twist), sn = Sin(_twist);
        return (fx * c - fy * sn, fx * sn + fy * c);
    }

    // --- the shell ----------------------------------------------------------------

    public override void CommandFinished()
    {
        if (LastCommandOk)
        {
            _calm = 1;
            Ring(170);
        }
        else
        {
            _storm = 1;
            Ring(5);
            foreach (var p in _particles)   // a kick in every direction
            {
                float a = Random(TWO_PI);
                p.VX += Cos(a) * 250;
                p.VY += Sin(a) * 250;
            }
        }
    }

    public override void OnShellSignal(string name)
    {
        if (name == "reset")
        {
            NewField();
            foreach (var p in _particles) Respawn(p);
            Ring(270);
        }
    }

    void Ring(float hue)
    {
        _ringRadius = 0;
        _ringHue = hue;
    }

    // --- every frame ----------------------------------------------------------------

    void DrawBatches()
    {
        // StrokeWeight(0) is a hairline: exactly one physical pixel at any DPI,
        // and Skia's fastest antialiased line -- about 10x cheaper than 1.6 px
        // with round ends (the trails are thousands of tiny segments).
        StrokeWeight(0);
        NoFill();
        for (int b = 0; b < _batches.Length; b++)
        {
            var batch = _batches[b];
            if (batch.Count == 0) continue;
            int hueStep = b / (AlphaSteps + 1), alphaStep = b % (AlphaSteps + 1);
            Stroke(hueStep * (360f / HueSteps), 65 + 35 * _storm, 100, 255f * alphaStep / AlphaSteps);
            BeginShape(ShapeKind.Lines);
            for (int i = 0; i < batch.Count; i += 2)
                Vertex(batch[i], batch[i + 1]);
            EndShape();
            batch.Clear();
        }
    }

    public override void Draw()
    {
        Clear(); // transparent: the terminal stays visible underneath
        float dt = DeltaTime;   // real seconds: same speed at 60 fps or in eco mode
        float t = Millis() / 1000f;

        _storm = Math.Max(0, _storm - dt / 2.5f);
        _calm = Math.Max(0, _calm - dt / 1.5f);
        _twist = Lerp(_twist, Constrain(TargetVelocity.X / 1500f, -1.3f, 1.3f), Math.Min(1, dt * 4));
        var push = TargetAcceleration;
        float speed = 110 * (1 + 1.5f * _storm) * (1 - 0.5f * _calm);
        float follow = Math.Min(1, dt * 3);

        foreach (var p in _particles)
        {
            var (fx, fy) = Field(p.X, p.Y, t);
            // Follow the field, and feel the window's motion the other way round.
            p.VX += (fx * speed - p.VX) * follow - push.X * 0.15f * dt;
            p.VY += (fy * speed - p.VY) * follow - push.Y * 0.15f * dt;
            p.X += p.VX * dt;
            p.Y += p.VY * dt;
            p.Age += dt;
            p.Head = (p.Head + 1) % Tail;
            p.TX[p.Head] = p.X;
            p.TY[p.Head] = p.Y;

            if (p.Age > p.Life || p.X < -20 || p.Y < -20 || p.X > Width + 20 || p.Y > Height + 20)
            {
                Respawn(p);
                continue;
            }

            // Blue-violet by direction; red in a storm; cyan when calm.
            float hue = 190 + 50 * Sin(Atan2(p.VY, p.VX));
            hue = Lerp(hue, 5, _storm);
            hue = Lerp(hue, 175, _calm * (1 - _storm));
            float fade = Math.Min(1, Math.Min(p.Age * 2, (p.Life - p.Age) * 2));
            int hueStep = ((int)Math.Round(hue / (360f / HueSteps)) % HueSteps + HueSteps) % HueSteps;
            for (int k = 1; k < Tail; k++)   // oldest segment first, fainter
            {
                int alphaStep = (int)Math.Round(fade * k / Tail * AlphaSteps);
                if (alphaStep == 0) continue;
                int i0 = (p.Head + k) % Tail, i1 = (p.Head + k + 1) % Tail;
                var batch = _batches[hueStep * (AlphaSteps + 1) + alphaStep];
                batch.Add(p.TX[i0]); batch.Add(p.TY[i0]); batch.Add(p.TX[i1]); batch.Add(p.TY[i1]);
            }
        }
        DrawBatches();

        // The pulse after a command, from the centre outwards.
        if (_ringRadius >= 0)
        {
            float max = Dist(0, 0, Width, Height) / 2;
            _ringRadius += 700 * dt;
            Stroke(_ringHue, 80, 100, 220 * (1 - _ringRadius / max));
            StrokeWeight(3);
            Ellipse(Width / 2f, Height / 2f, _ringRadius * 2, _ringRadius * 2);
            if (_ringRadius > max) _ringRadius = -1;
        }

        if (_storm > 0)
        {
            NoStroke();
            Fill(5, 80, 100, 255 * _storm);
            TextSize(28);
            Text($"exit {LastExitCode}", Width - 16, Height - 12);
        }
    }
}

Runnable example: cloth over the terminal

A see-through cloth (points and sticks, Verlet integration) covers the whole Windows Terminal window, hanging from its top edge; it swings and folds while you drag the window, because it feels -TargetAcceleration. The cursor pushes it, a click cuts a hole, a failed command rips one and a command that works sends a gust through it. The cloth is filled as a few big shapes without antialiasing (fast, and neighbouring quads meet with no seams), with a weave of hairlines on top. Idea from "Cloth!!" by Millie on OpenProcessing, written from scratch for Dana.

C#
// Cloth on the terminal: the whole Windows Terminal window is covered by a
// sheet of see-through cloth hanging from its top edge. Move the window and
// the cloth swings, ripples and folds.
//
// Idea from "Cloth!!" by Millie on OpenProcessing (openprocessing.org/sketch/2999200);
// its code is hidden, so this is written from scratch for Dana: a grid of
// points joined by sticks (Verlet integration), pinned along the top, drawn
// as one shaded mesh plus a fine weave.
//
// 1. Open Windows Terminal and press Run.
// 2. Drag the terminal: the cloth lags behind and swings (-TargetAcceleration).
//    Shake it hard and it flaps.
// 3. Move the mouse through it to push it; click to cut a hole (the click
//    still reaches the terminal). Too much stretch tears it too.
// 4. A command that fails rips a hole and flashes red; one that works sends a
//    gust of wind through it. dana-signal reset hangs a new cloth.
public class TerminalCloth : AttachedSketch
{
    sealed class Point
    {
        public float X, Y, OldX, OldY, RestX;
        public bool Pinned;
    }

    sealed class Stick
    {
        public Point A, B;
        public float Length;
        public bool Broken;
    }

    const float Gravity = 500;       // px/s²
    const float Inertia = 0.7f;      // how much the window's motion moves the cloth
    const float TearStretch = 3f;    // a stick breaks past this many times its length

    readonly List<Point> _points = new();
    readonly List<Stick> _sticks = new();
    Stick[] _right = new Stick[0], _down = new Stick[0];   // the stick to the right of / below each point
    int _cols, _rows;
    float _redness;                  // 1 right after a failed command
    float _gust;                     // 1 right after a command that worked

    public override void Settings() => Attach(WindowTarget.Terminal);

    public override void Setup()
    {
        ListenToShell();
        ListenToTargetMouse();
        Hang();
    }

    public override void WindowResized() => Hang();

    // A cloth exactly the size of the window, its top row pinned to the top edge.
    void Hang()
    {
        _points.Clear();
        _sticks.Clear();
        _cols = Constrain(Width / 28, 12, 60) + 1;
        _rows = Constrain(Height / 28, 8, 40) + 1;
        float sx = Width / (float)(_cols - 1), sy = Height / (float)(_rows - 1);
        _right = new Stick[_cols * _rows];
        _down = new Stick[_cols * _rows];
        for (int r = 0; r < _rows; r++)
            for (int c = 0; c < _cols; c++)
            {
                float x = c * sx, y = r * sy;
                _points.Add(new Point { X = x, Y = y, OldX = x, OldY = y, RestX = x, Pinned = r == 0 });
            }
        for (int r = 0; r < _rows; r++)
            for (int c = 0; c < _cols; c++)
            {
                int i = r * _cols + c;
                if (c + 1 < _cols) _right[i] = Join(_points[i], _points[i + 1], sx);
                if (r + 1 < _rows) _down[i] = Join(_points[i], _points[i + _cols], sy);
            }
    }

    Stick Join(Point a, Point b, float length)
    {
        var s = new Stick { A = a, B = b, Length = length };
        _sticks.Add(s);
        return s;
    }

    // --- input ------------------------------------------------------------------

    public override void MousePressed() => Cut(MouseX, MouseY, 22);

    public override void CommandFinished()
    {
        if (LastCommandOk) { _gust = 1; return; }
        _redness = 1;
        Cut(Random(Width * 0.2f, Width * 0.8f), Random(Height * 0.3f, Height * 0.7f), Math.Min(Width, Height) * 0.12f);
    }

    public override void OnShellSignal(string name)
    {
        if (name == "reset") Hang();
    }

    void Cut(float x, float y, float radius)
    {
        foreach (var s in _sticks)
        {
            float mx = (s.A.X + s.B.X) / 2, my = (s.A.Y + s.B.Y) / 2;
            if (Dist(mx, my, x, y) < radius) s.Broken = true;
        }
    }

    // --- every frame ----------------------------------------------------------------

    public override void Draw()
    {
        Clear();
        float dt = Math.Min(DeltaTime, 1 / 30f);
        float t = Millis() / 1000f;
        var a = TargetAcceleration;
        _redness = Math.Max(0, _redness - dt / 2);
        _gust = Math.Max(0, _gust - dt / 1.5f);

        // A jerk of the window is felt, but capped: a real drag shouldn't throw the cloth off.
        float ax = Constrain(-a.X * Inertia, -5000, 5000), ay = Constrain(-a.Y * Inertia, -5000, 5000);
        Simulate(dt, t, ax, Gravity + ay);
        DrawMesh();
        DrawWeave();
    }

    void Simulate(float dt, float t, float ax, float ay)
    {
        bool mouseInside = MouseX >= 0 && MouseX < Width && MouseY >= 0 && MouseY < Height;
        float gustX = _gust * 2500;

        // Verlet: the velocity is just "where it was" vs "where it is".
        foreach (var p in _points)
        {
            if (p.Pinned) continue;
            float vx = (p.X - p.OldX) * 0.985f, vy = (p.Y - p.OldY) * 0.985f;
            // A light breeze so the cloth is never perfectly still, plus the gust.
            float breeze = (Noise(p.Y * 0.004f, t * 0.4f) - 0.5f) * 90 + gustX * Noise(p.Y * 0.01f, t * 3);
            p.OldX = p.X;
            p.OldY = p.Y;
            p.X += vx + (ax + breeze) * dt * dt;
            p.Y += vy + ay * dt * dt;

            // The mouse pushes the cloth away (no click needed: MouseX/MouseY follow the cursor).
            if (mouseInside)
            {
                float d = Dist(p.X, p.Y, MouseX, MouseY);
                if (d < 45 && d > 0.01f)
                {
                    p.X += (p.X - MouseX) / d * (45 - d) * 0.25f;
                    p.Y += (p.Y - MouseY) / d * (45 - d) * 0.25f;
                }
            }
        }

        // Sticks pull their two points back to their length, a few times per frame.
        for (int iteration = 0; iteration < 6; iteration++)
            foreach (var s in _sticks)
            {
                if (s.Broken) continue;
                float dx = s.B.X - s.A.X, dy = s.B.Y - s.A.Y;
                float d = Math.Max(0.001f, Sqrt(dx * dx + dy * dy));
                if (d > s.Length * TearStretch) { s.Broken = true; continue; }
                // Cloth resists stretching fully and squeezing only a little, so it can fold.
                float stiffness = d > s.Length ? 1 : 0.3f;
                float push = (d - s.Length) / d * (s.A.Pinned || s.B.Pinned ? 1 : 0.5f) * stiffness;
                if (!s.A.Pinned) { s.A.X += dx * push; s.A.Y += dy * push; }
                if (!s.B.Pinned) { s.B.X -= dx * push; s.B.Y -= dy * push; }
            }

        // Torn-off pieces that fell away stop below the window.
        foreach (var p in _points)
            if (p.Y > Height + 300) { p.Y = p.OldY = Height + 300; p.OldX = p.X; }
    }

    // --- drawing --------------------------------------------------------------------

    // The cloth itself: every quad whose four sides hold, filled with a shade for
    // how the cloth bends there -- folds darker, stretched or swung parts lighter.
    // Quads of the same shade go in one shape (one contour each): a handful of
    // draw calls for the whole window instead of one per quad.
    const int Shades = 6;
    readonly List<int>[] _byShade = Enumerable.Range(0, Shades).Select(_ => new List<int>()).ToArray();

    void DrawMesh()
    {
        foreach (var list in _byShade) list.Clear();
        for (int r = 0; r < _rows - 1; r++)
            for (int c = 0; c < _cols - 1; c++)
            {
                int i = r * _cols + c;
                if (_right[i].Broken || _down[i].Broken || _right[i + _cols].Broken || _down[i + 1].Broken) continue;
                _byShade[(int)(Light(i) * (Shades - 0.01f))].Add(i);
            }

        // Without antialiasing: much faster for shapes this big, and neighbouring
        // quads meet exactly (no faint seams). The weave drawn on top hides the edges.
        NoSmooth();
        NoStroke();
        for (int k = 0; k < Shades; k++)
        {
            if (_byShade[k].Count == 0) continue;
            float shade = k / (float)(Shades - 1);
            Fill(Lerp(60, 150, shade) + 150 * _redness,
                 Lerp(80, 190, shade) * (1 - 0.6f * _redness),
                 Lerp(150, 255, shade) * (1 - 0.5f * _redness),
                 Lerp(26, 62, Math.Abs(shade - 0.5f) * 2) + 45 * _redness);
            BeginShape();
            foreach (int i in _byShade[k])
            {
                BeginContour();
                Vertex(_points[i].X, _points[i].Y);
                Vertex(_points[i + 1].X, _points[i + 1].Y);
                Vertex(_points[i + _cols + 1].X, _points[i + _cols + 1].Y);
                Vertex(_points[i + _cols].X, _points[i + _cols].Y);
                EndContour();
            }
            EndShape();
        }
        Smooth();
    }

    // 0 (a fold) to 1 (stretched or swung out) for the quad at point i.
    float Light(int i)
    {
        Point a = _points[i], b = _points[i + 1], d = _points[i + _cols];
        float span = Dist(a.X, a.Y, b.X, b.Y) / _right[i].Length;              // < 1 where it folds
        float slant = ((d.X - a.X) + (a.X - a.RestX) * 0.15f) / _down[i].Length;  // swinging sideways
        return Constrain(0.5f + (span - 1) * 2.5f + slant * 0.6f, 0, 1);
    }

    // The weave: every stick as a hairline (Skia's fastest line), one shape per
    // color; the stretched ones, warm, go in a second shape on top.
    readonly List<Stick> _stretched = new();

    void DrawWeave()
    {
        StrokeWeight(0);
        Stroke(170 + 85 * _redness, 200 - 120 * _redness, 255 - 150 * _redness, 55);
        _stretched.Clear();
        BeginShape(ShapeKind.Lines);
        foreach (var s in _sticks)
        {
            if (s.Broken) continue;
            float dx = s.B.X - s.A.X, dy = s.B.Y - s.A.Y;
            if (dx * dx + dy * dy > s.Length * s.Length * 1.69f) { _stretched.Add(s); continue; }   // over 1.3x its length
            Vertex(s.A.X, s.A.Y);
            Vertex(s.B.X, s.B.Y);
        }
        EndShape();

        Stroke(255, 160 - 100 * _redness, 90 - 60 * _redness, 200);
        BeginShape(ShapeKind.Lines);
        foreach (var s in _stretched)
        {
            Vertex(s.A.X, s.A.Y);
            Vertex(s.B.X, s.B.Y);
        }
        EndShape();

        // The top hem the cloth hangs from.
        Stroke(220, 230, 255, 140);
        StrokeWeight(2);
        Line(0, 1, Width, 1);
    }
}