From 737bdac7fd0fb8be516d421076df0cc0502f6aec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:12:56 +0000 Subject: [PATCH 1/2] tape: captions show per word, speed is a clamped sequencer value A caption stays up 0.3 s a word of title and text at 1x, never under 2 s (Script.reading_ms_per_word, reading_min_ms), so long captions no longer vanish quickly. The player's rate becomes Sequencer.speed, a plain f32 in 0.5..4 (default 1) stepped through 0.5, 0.75, 1, 1.5, 2, 3, 4; it divides demo time on the wall clock (playUntil, wallMs). Seek and rewind ask advance for a demo time, so they stay exact at any speed. Part of fizzyedit/work#16 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Lc78nzw65EqAttcHSvg2XF --- app/automation/overlay.zig | 6 +-- docs/AUTOMATION.md | 9 +++- sdk/replay/Player.zig | 25 ++-------- sdk/tape/Script.zig | 40 ++++++++++++++-- sdk/tape/Sequencer.zig | 95 ++++++++++++++++++++++++++++++++++++++ src/editor/Editor.zig | 2 +- src/editor/Keybinds.zig | 8 ++-- 7 files changed, 149 insertions(+), 36 deletions(-) diff --git a/app/automation/overlay.zig b/app/automation/overlay.zig index 749af907e..8221060f2 100644 --- a/app/automation/overlay.zig +++ b/app/automation/overlay.zig @@ -1004,9 +1004,9 @@ fn barTrack(player: *Player, tape: *const Tape, bounds: dvui.Rect, r: dvui.Rect, const x1 = r.x + r.w - pad; const small_font = dvui.Font.theme(.body).larger(-2); - // The speed, top right of the capsule: a press steps it (`Player.stepRate`). + // The speed, top right of the capsule: a press steps it (`Sequencer.stepSpeed`). var speed_buf: [8]u8 = undefined; - const speed = std.mem.print(&speed_buf, "{d}\u{00D7}", .{player.rate}) catch ""; + const speed = std.mem.print(&speed_buf, "{d}\u{00D7}", .{player.seq.speed}) catch ""; const speed_w = small_font.textSize(speed).w; const speed_r: dvui.Rect = .{ .x = x1 - speed_w, .y = r.y + 5, .w = speed_w, .h = small_font.lineHeight() }; dvui.labelNoFmt(@src(), speed, .{}, .{ @@ -1014,7 +1014,7 @@ fn barTrack(player: *Player, tape: *const Tape, bounds: dvui.Rect, r: dvui.Rect, .padding = .{}, .margin = .{}, .font = small_font, - .color_text = .{ .color = ink.opacity(if (player.rate == 1) 0.65 else 0.95) }, + .color_text = .{ .color = ink.opacity(if (player.seq.speed == 1) 0.65 else 0.95) }, }); player.transport.speed = wrs.rectToPhysical(speed_r.outsetAll(4)); const chapter = if (tape.chapterAt(now)) |i| tape.chapters[i].title else tape.title; diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 9bf8a4c75..749b47aa8 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -243,7 +243,14 @@ pub fn build(s: *Script) !void { `Script` keeps a pen (`s.t`) and moves it at an unhurried person's pace (`Script.Pace`: an 800 ms glide, a hover before the press, a beat after each action, 12 characters a second). `pause`, `caption(.., .{ .hold = true })` and every option's `ms` adjust it. A caption shows for as long as -it takes to read (`Script.readingMs`) unless given `ms`. +it takes to read unless given `ms`: `Script.readingMs`, 0.3 s a word of title and text at 1x +(`reading_ms_per_word`) and never under 2 s (`reading_min_ms`). + +**Playback speed** is `Sequencer.speed`, a plain `f32` in 0.5…4 (default 1; the transport steps +`Sequencer.speeds`: 0.5, 0.75, 1, 1.5, 2, 3, 4). It divides every demo time on the wall clock — +`playUntil` turns a frame's wall time into the demo time to advance to, `wallMs` a caption's `ms` +into how long it is up — so the captions and the demo around them speed up together. A seek asks +`advance` for a demo time, which no speed enters, so it lands exactly at any speed. A caption that narrates what the pointer does is a **callout beside the action**, where the viewer is looking: next to what the pointer is aimed at while it shows (or `.near`, an anchor), to its diff --git a/sdk/replay/Player.zig b/sdk/replay/Player.zig index 1dc7dc5eb..837b65908 100644 --- a/sdk/replay/Player.zig +++ b/sdk/replay/Player.zig @@ -74,9 +74,6 @@ after_seek: After = .pause, seek_target: f64 = 0, /// Where the seek replays from, for the overlay's progress. seek_from: f64 = 0, -/// Demo time per wall time while playing: one of `speeds`, from the transport or `setRate`. -rate: f32 = 1, - /// Real input reached the app since the tape last put it in a known state. diverged: bool = false, /// Wall time one displayed frame may spend on silent frames while a seek catches up (`frames`). @@ -221,7 +218,7 @@ pub const Transport = struct { /// Sent into where it rests (its tab): the bar stays there, playing or not, until that brings it /// back (a press here, the pointer held on it in the overlay). tucked: bool = false, - /// The speed on the track (`rate`), which a press steps through `speeds`. + /// The speed on the track (`Sequencer.speed`), which a press steps through `Sequencer.speeds`. speed: dvui.Rect.Physical = .{}, /// The demo time the scrubber is held at while a viewer drags it; the player seeks there as /// it moves (`frame`), and once more when it is let go. @@ -283,22 +280,6 @@ pub fn wallNs(self: *const Player) i128 { } /// The demo time to show: the scrubber's while it is held, else the sequencer's. -/// The speeds the transport steps through. -pub const speeds = [_]f32{ 0.25, 0.5, 1, 2, 4 }; - -/// Play at `rate` times the tape's own pace, clamped to the slowest and fastest of `speeds`. -pub fn setRate(self: *Player, rate: f32) void { - self.rate = std.math.clamp(rate, speeds[0], speeds[speeds.len - 1]); -} - -/// The next of `speeds` after the current one, round to the slowest. -pub fn stepRate(self: *Player) void { - for (speeds) |sp| { - if (sp > self.rate + 0.001) return self.setRate(sp); - } - self.setRate(speeds[0]); -} - pub fn now(self: *const Player) f64 { if (self.transport.scrub) |t| return t; return if (self.state == .seeking) self.seek_target else self.seq.now; @@ -723,7 +704,7 @@ pub fn frame(self: *Player) void { .playing => { Input.holdPointer(self.seq.pointer); self.seq.replaying = false; - _ = self.seq.advance(self.seq.now + wall_ms * self.rate, wall_ms, self.sink()); + _ = self.seq.advance(self.seq.playUntil(wall_ms), wall_ms, self.sink()); if (self.seq.done() and self.seq.now >= @as(f64, @floatFromInt(self.duration()))) self.state = .ended; }, .seeking => { @@ -941,7 +922,7 @@ fn transportTakes(self: *Player, e: *dvui.Event, me: dvui.Event.Mouse) bool { } else if (tr.play.contains(me.p)) { self.toggle(); } else if (tr.speed.contains(me.p)) { - self.stepRate(); + self.seq.stepSpeed(); } else if (tr.track.contains(me.p)) { tr.scrub = tr.timeAt(me.p.x, self.duration()); self.scrub_after = if (self.state == .playing or (self.state == .seeking and self.after_seek == .play)) .play else .pause; diff --git a/sdk/tape/Script.zig b/sdk/tape/Script.zig index e03493e06..1bf52ee65 100644 --- a/sdk/tape/Script.zig +++ b/sdk/tape/Script.zig @@ -167,7 +167,7 @@ pub const CaptionOptions = struct { /// Words over the app, starting now. pub fn caption(self: *Script, text: []const u8, opts: CaptionOptions) !void { - const ms = opts.ms orelse readingMs(opts.title.len + text.len); + const ms = opts.ms orelse readingMs(opts.title, text); try self.captions.append(self.arena.allocator(), .{ .at = self.t, .ms = ms, @@ -181,10 +181,25 @@ pub fn caption(self: *Script, text: []const u8, opts: CaptionOptions) !void { if (opts.hold) self.t += ms; } -/// Long enough to read at an unhurried pace and still look at the app, never shorter than a -/// glance: about fourteen characters a second, after a couple of seconds to find it. -pub fn readingMs(chars: usize) u32 { - return std.math.clamp(@as(u32, @intCast(chars)) * 70 + 1800, 3200, 12_000); +/// Demo time a caption shows for per word at 1x (`readingMs`). +pub const reading_ms_per_word: u32 = 300; +/// The least a caption shows for at 1x, however short: long enough to find and take in. +pub const reading_min_ms: u32 = 2000; + +/// The words in `text`: runs of non-whitespace. +pub fn wordCount(text: []const u8) usize { + var n: usize = 0; + var it = std.mem.tokenizeAny(u8, text, " \t\r\n"); + while (it.next()) |_| n += 1; + return n; +} + +/// How long a caption shows for at 1x: `reading_ms_per_word` for each word of its title and text, +/// never under `reading_min_ms`. Playback speed divides it (`Sequencer.wallMs`), so the caption +/// and the demo around it speed up together. +pub fn readingMs(title: []const u8, text: []const u8) u32 { + const words: u32 = @intCast(wordCount(title) + wordCount(text)); + return @max(reading_min_ms, words * reading_ms_per_word); } /// Nothing for a while. @@ -470,3 +485,18 @@ test "a stroke presses once, traces each point, and releases" { try testing.expectEqual(2, traces); try testing.expect(released_last); } + +test "a caption shows for a time per word, never under the floor" { + try testing.expectEqual(@as(usize, 0), wordCount("")); + try testing.expectEqual(@as(usize, 0), wordCount(" \n\t ")); + try testing.expectEqual(@as(usize, 1), wordCount("Hi")); + try testing.expectEqual(@as(usize, 4), wordCount(" one two\tthree\nfour ")); + // Under the floor: a glance is still 2 s. + try testing.expectEqual(reading_min_ms, readingMs("", "Hi")); + try testing.expectEqual(reading_min_ms, readingMs("", "")); + // Six words is exactly the floor; the seventh is past it. + try testing.expectEqual(@as(u32, 2000), readingMs("", "a b c d e f")); + try testing.expectEqual(@as(u32, 2100), readingMs("", "a b c d e f g")); + // Title and text both count, and a long caption is not cut short. + try testing.expectEqual(@as(u32, 300 * 30), readingMs("one two three four five", "a b c d e f g h i j k l m n o p q r s t u v w x y")); +} diff --git a/sdk/tape/Sequencer.zig b/sdk/tape/Sequencer.zig index 10969eadb..a179128d7 100644 --- a/sdk/tape/Sequencer.zig +++ b/sdk/tape/Sequencer.zig @@ -48,6 +48,16 @@ pressed: u8 = 0, /// Set by whoever plays the tape while it replays to a moment rather than plays live (a seek): /// a carried glide then takes a frame per step of its path (`path_step_ms`). replaying: bool = false, +/// Demo time per wall time while a tape plays live: a plain value in `min_speed`...`max_speed` +/// (`setSpeed`). Only `playUntil` and `wallMs` read it, so a seek, which replays to a demo time, +/// lands exactly at any speed. +speed: f32 = default_speed, + +pub const min_speed: f32 = 0.5; +pub const max_speed: f32 = 4.0; +pub const default_speed: f32 = 1.0; +/// The speeds a transport steps through. +pub const speeds = [_]f32{ 0.5, 0.75, 1, 1.5, 2, 3, 4 }; pub const Point = struct { x: f32 = 0, @@ -156,6 +166,29 @@ pub fn restoreTo(self: *Sequencer, cursor: usize, now: f64, pointer: Point) void self.pressed = 0; } +/// Play at `speed` times the tape's own pace, kept within `min_speed`...`max_speed`. +pub fn setSpeed(self: *Sequencer, speed: f32) void { + self.speed = std.math.clamp(speed, min_speed, max_speed); +} + +/// The next of `speeds` above the current one, round to the slowest. +pub fn stepSpeed(self: *Sequencer) void { + for (speeds) |sp| { + if (sp > self.speed + 0.001) return self.setSpeed(sp); + } + self.setSpeed(speeds[0]); +} + +/// The demo time `wall_ms` of live playing reaches: what to ask `advance` for. +pub fn playUntil(self: Sequencer, wall_ms: f64) f64 { + return self.now + wall_ms * self.speed; +} + +/// How long `demo_ms` of demo time (a caption's `ms`, say) lasts on the wall clock. +pub fn wallMs(self: Sequencer, demo_ms: f64) f64 { + return demo_ms / self.speed; +} + /// Every op has been applied and nothing is in flight. pub fn done(self: Sequencer) bool { return self.cursor >= self.tape.ops.len and self.glide == null and self.typing == null and !self.holding; @@ -913,3 +946,65 @@ test "a trace runs ruled: on the line between, evenly" { try testing.expectEqual(@as(f32, 0), pathPoint(op, a, b, 0.5).y); try testing.expectEqual(@as(f32, 100), pathPoint(op, a, b, 0.25).x); } + +test "speed is clamped, steps through its notches and divides demo time" { + const tape: Tape = .{ .name = "t", .ops = &.{} }; + var seq = Sequencer.init(&tape); + try testing.expectEqual(@as(f32, 1), seq.speed); + seq.setSpeed(0.1); + try testing.expectEqual(min_speed, seq.speed); + seq.setSpeed(40); + try testing.expectEqual(max_speed, seq.speed); + + // Every notch: a frame of wall time reaches `speed` times as much demo time, and a caption + // of 2 s is up for 2 s / speed of the wall clock. + for (speeds) |sp| { + seq.setSpeed(sp); + try testing.expectEqual(sp, seq.speed); + seq.now = 100; + try testing.expectEqual(100 + 16 * @as(f64, sp), seq.playUntil(16)); + try testing.expectEqual(2000 / @as(f64, sp), seq.wallMs(2000)); + } + + // The transport steps through them in order and wraps to the slowest. + seq.setSpeed(min_speed); + for (speeds[1..]) |sp| { + seq.stepSpeed(); + try testing.expectEqual(sp, seq.speed); + } + seq.stepSpeed(); + try testing.expectEqual(speeds[0], seq.speed); +} + +test "a rewind and seek land on the same moment at every speed" { + const ops = [_]Tape.Op{ + .{ .at = 0, .do = .{ .keyframe = 0 } }, + .{ .at = 0, .ms = 100, .do = .{ .move = .{ .tag = "ok" } } }, + .{ .at = 100, .do = .{ .press = .left } }, + .{ .at = 150, .do = .{ .release = .left } }, + }; + const tape: Tape = .{ .name = "t", .ops = &ops, .keyframes = &test_keyframes }; + + var expected: ?[]u8 = null; + defer if (expected) |e| testing.allocator.free(e); + for (speeds) |sp| { + var log: Log = .{}; + defer log.deinit(); + try log.tags.put(testing.allocator, "ok", .{ .x = 100, .y = 0 }); + + var seq: Sequencer = .init(&tape); + seq.setSpeed(sp); + // Play on a way, then go back and replay to a moment, as a seek does. + _ = seq.advance(seq.playUntil(40), 40, log.sink()); + seq.rewind(0); + log.out.clearRetainingCapacity(); + seq.replaying = true; + while (seq.advance(120, 0, log.sink()) != .reached) {} + try testing.expectEqual(@as(f64, 120), seq.now); + const landed = try landings(log.out.items); + if (expected) |e| { + defer testing.allocator.free(landed); + try testing.expectEqualStrings(e, landed); + } else expected = landed; + } +} diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 87666555d..19dd33c40 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -4379,7 +4379,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // How things move this frame, for every animation here and in every plugin (`core.motion`). // A demo replaying to a seek wants every frame to land where it is going: no motion — but the // glass keeps its edge, the transport's among it, scrubbed or not. - const playback: f32 = if (editor.demo.player.state == .playing) editor.demo.player.rate else 1; + const playback: f32 = if (editor.demo.player.state == .playing) editor.demo.player.seq.speed else 1; fizzy.core.motion.publish(editor.app.settings.motion, editor.app.settings.motion_speed, dvui.currentWindow().backend.prefersReducedMotion(), editor.demo.fast, playback); fizzy.core.programs.publishHost(); // Where the main window's title bar takes presses, not the app (macOS): floating windows, diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index 539bba959..af3c21046 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -300,18 +300,18 @@ fn setWindowMaterial(editor: *Editor, kind: fizzy.core.material.Kind) void { } const DemoSpeed = sdk.Command.Params(struct { - rate: sdk.Command.Arg(f32, .{ .description = "How many times the tape's own pace: 0.25, 0.5, 1, 2 or 4. 0 steps to the next.", .min = 0, .max = 4 }) = .init(0), + rate: sdk.Command.Arg(f32, .{ .description = "How many times the tape's own pace: 0.5 to 4 (0.5, 0.75, 1, 1.5, 2, 3, 4 on the transport). 0 steps to the next.", .min = 0, .max = 4 }) = .init(0), }); fn cmdDemoSpeedStep(state: *anyopaque) anyerror!void { - editorFromState(state).demo.player.stepRate(); + editorFromState(state).demo.player.seq.stepSpeed(); } /// How fast the loaded tape plays, as the transport's speed does. fn cmdDemoSpeed(state: *anyopaque, args: DemoSpeed.Args, call: *sdk.Command.Call) anyerror!void { const player = &editorFromState(state).demo.player; - if (args.rate == 0) player.stepRate() else player.setRate(args.rate); - try call.returns(.{ .rate = player.rate }); + if (args.rate == 0) player.seq.stepSpeed() else player.seq.setSpeed(args.rate); + try call.returns(.{ .rate = player.seq.speed }); } fn demoCatalogCommands() [Editor.Demo.catalog.entries.len]FizzyCommand { From bf72b84dc9767deaaa21c17611619f148bfaef97 Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 21 Oct 2026 12:49:01 -0500 Subject: [PATCH 2/2] tape: cap caption reading time, speed notches agree with rate arg --- docs/AUTOMATION.md | 9 +++++---- sdk/tape/Script.zig | 10 +++++++--- sdk/tape/Sequencer.zig | 11 ++--------- src/editor/Keybinds.zig | 2 +- 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 749b47aa8..2db8e3374 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -244,13 +244,14 @@ pub fn build(s: *Script) !void { glide, a hover before the press, a beat after each action, 12 characters a second). `pause`, `caption(.., .{ .hold = true })` and every option's `ms` adjust it. A caption shows for as long as it takes to read unless given `ms`: `Script.readingMs`, 0.3 s a word of title and text at 1x -(`reading_ms_per_word`) and never under 2 s (`reading_min_ms`). +(`reading_ms_per_word`) and between 2 s (`reading_min_ms`) and 12 s (`reading_max_ms`). **Playback speed** is `Sequencer.speed`, a plain `f32` in 0.5…4 (default 1; the transport steps `Sequencer.speeds`: 0.5, 0.75, 1, 1.5, 2, 3, 4). It divides every demo time on the wall clock — -`playUntil` turns a frame's wall time into the demo time to advance to, `wallMs` a caption's `ms` -into how long it is up — so the captions and the demo around them speed up together. A seek asks -`advance` for a demo time, which no speed enters, so it lands exactly at any speed. +`playUntil` turns a frame's wall time into the demo time to advance to. A caption's `ms` is demo +time too, so it is up for `ms / speed` of the wall clock with no conversion of its own — the +captions and the demo around them speed up together. A seek asks `advance` for a demo time, which +no speed enters, so it lands exactly at any speed. A caption that narrates what the pointer does is a **callout beside the action**, where the viewer is looking: next to what the pointer is aimed at while it shows (or `.near`, an anchor), to its diff --git a/sdk/tape/Script.zig b/sdk/tape/Script.zig index 1bf52ee65..68d727098 100644 --- a/sdk/tape/Script.zig +++ b/sdk/tape/Script.zig @@ -185,6 +185,8 @@ pub fn caption(self: *Script, text: []const u8, opts: CaptionOptions) !void { pub const reading_ms_per_word: u32 = 300; /// The least a caption shows for at 1x, however short: long enough to find and take in. pub const reading_min_ms: u32 = 2000; +/// The most a caption shows for at 1x, however long: past this a caption should be split. +pub const reading_max_ms: u32 = 12_000; /// The words in `text`: runs of non-whitespace. pub fn wordCount(text: []const u8) usize { @@ -195,11 +197,11 @@ pub fn wordCount(text: []const u8) usize { } /// How long a caption shows for at 1x: `reading_ms_per_word` for each word of its title and text, -/// never under `reading_min_ms`. Playback speed divides it (`Sequencer.wallMs`), so the caption -/// and the demo around it speed up together. +/// between `reading_min_ms` and `reading_max_ms`. It is demo time, so playback speed (which +/// advances demo time faster) shortens the caption and the demo around it together. pub fn readingMs(title: []const u8, text: []const u8) u32 { const words: u32 = @intCast(wordCount(title) + wordCount(text)); - return @max(reading_min_ms, words * reading_ms_per_word); + return std.math.clamp(words * reading_ms_per_word, reading_min_ms, reading_max_ms); } /// Nothing for a while. @@ -499,4 +501,6 @@ test "a caption shows for a time per word, never under the floor" { try testing.expectEqual(@as(u32, 2100), readingMs("", "a b c d e f g")); // Title and text both count, and a long caption is not cut short. try testing.expectEqual(@as(u32, 300 * 30), readingMs("one two three four five", "a b c d e f g h i j k l m n o p q r s t u v w x y")); + // Past the ceiling a caption stops growing. + try testing.expectEqual(reading_max_ms, readingMs("", "w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w")); } diff --git a/sdk/tape/Sequencer.zig b/sdk/tape/Sequencer.zig index a179128d7..4e598f20a 100644 --- a/sdk/tape/Sequencer.zig +++ b/sdk/tape/Sequencer.zig @@ -49,7 +49,7 @@ pressed: u8 = 0, /// a carried glide then takes a frame per step of its path (`path_step_ms`). replaying: bool = false, /// Demo time per wall time while a tape plays live: a plain value in `min_speed`...`max_speed` -/// (`setSpeed`). Only `playUntil` and `wallMs` read it, so a seek, which replays to a demo time, +/// (`setSpeed`). Only `playUntil` reads it, so a seek, which replays to a demo time, /// lands exactly at any speed. speed: f32 = default_speed, @@ -184,11 +184,6 @@ pub fn playUntil(self: Sequencer, wall_ms: f64) f64 { return self.now + wall_ms * self.speed; } -/// How long `demo_ms` of demo time (a caption's `ms`, say) lasts on the wall clock. -pub fn wallMs(self: Sequencer, demo_ms: f64) f64 { - return demo_ms / self.speed; -} - /// Every op has been applied and nothing is in flight. pub fn done(self: Sequencer) bool { return self.cursor >= self.tape.ops.len and self.glide == null and self.typing == null and !self.holding; @@ -956,14 +951,12 @@ test "speed is clamped, steps through its notches and divides demo time" { seq.setSpeed(40); try testing.expectEqual(max_speed, seq.speed); - // Every notch: a frame of wall time reaches `speed` times as much demo time, and a caption - // of 2 s is up for 2 s / speed of the wall clock. + // Every notch: a frame of wall time reaches `speed` times as much demo time. for (speeds) |sp| { seq.setSpeed(sp); try testing.expectEqual(sp, seq.speed); seq.now = 100; try testing.expectEqual(100 + 16 * @as(f64, sp), seq.playUntil(16)); - try testing.expectEqual(2000 / @as(f64, sp), seq.wallMs(2000)); } // The transport steps through them in order and wraps to the slowest. diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index af3c21046..27f372d46 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -300,7 +300,7 @@ fn setWindowMaterial(editor: *Editor, kind: fizzy.core.material.Kind) void { } const DemoSpeed = sdk.Command.Params(struct { - rate: sdk.Command.Arg(f32, .{ .description = "How many times the tape's own pace: 0.5 to 4 (0.5, 0.75, 1, 1.5, 2, 3, 4 on the transport). 0 steps to the next.", .min = 0, .max = 4 }) = .init(0), + rate: sdk.Command.Arg(f32, .{ .description = "How many times the tape's own pace: 0.5 to 4 (0.5, 0.75, 1, 1.5, 2, 3, 4 on the transport); anything between 0 and 0.5 plays at 0.5. 0 steps to the next.", .min = 0, .max = 4 }) = .init(0), }); fn cmdDemoSpeedStep(state: *anyopaque) anyerror!void {