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).
On this page · 16 entries
AttachedSketch · AttachWindowTarget.Terminal · WindowTarget.Class · WindowTarget.Process · AttachLayerAttachMode.Front · AttachMode.EachWindow · InstanceNumber · MaxInstancesTargetX · TargetY · TargetScale · IsAttached · IsTargetFocusedTargetVelocity · TargetAcceleration · IsTargetMovingTargetMoved · TargetFocused · TargetBlurred · TargetShown · TargetHiddenListenToTargetKeys · ListenToTargetMouseListenToShell · CommandFinished · OnShellSignal · LastCommandOk · LastExitCode · CommandCountShellSignals.InstallForPowerShell · Install · Uninstall · IsInstalled · PowerShellProfiles · PowerShellSnippet · SignalFileDeltaTime · EcoMode · NoEco · IsEco · IsEcoEnabled · EcoFrameRate- Export as a tray app
- Install it like Abyss Tide: install.ps1
- How it works
- Runnable example: water in the terminal
- Runnable example: a flow field per terminal
- Runnable example: cloth over the terminal
AttachedSketch · Attach
AttachedSketchAttach(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.
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() 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.TerminalWindowTarget.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.FrontAttachMode.EachWindowInstanceNumber → intMaxInstances
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.
public override void Settings() => Attach(WindowTarget.Terminal, mode: AttachMode.EachWindow);
TargetX · TargetY · TargetScale · IsAttached · IsTargetFocused
TargetXTargetY → intTargetScale → floatIsAttachedIsTargetFocused → 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
TargetVelocityTargetAcceleration → PVectorIsTargetMoving → 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.
ListenToShell · CommandFinished · OnShellSignal · LastCommandOk · LastExitCode · CommandCount
ListenToShell(listen)CommandFinished()OnShellSignal(name)LastCommandOk → boolLastExitCodeCommandCount → 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).
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.$? 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.%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.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.%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()PowerShellSnippetSignalFile
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:
# >>> 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 → floatEcoMode(on, fps, whenUnfocused)NoEco()IsEcoIsEcoEnabled → boolEcoFrameRate → 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.
--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.
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.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.
// 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.
// 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.
// 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); } }