diff --git a/app/HistoryTransport.zig b/app/HistoryTransport.zig index 6a41fc69b..a14d7e7d2 100644 --- a/app/HistoryTransport.zig +++ b/app/HistoryTransport.zig @@ -58,6 +58,7 @@ const sdk = @import("fizzy_sdk"); const icons = @import("icons"); const rules = @import("history_transport.zig"); const dome = @import("dome.zig"); +const Liquid = @import("Liquid.zig"); const dome_rules = @import("dome_rules.zig"); shown: rules.Shown = .{}, @@ -79,6 +80,10 @@ presence: dome_rules.Presence = .{}, /// The liquid's swing, and the step the document stood at when it was last drawn: a scrub knocks it. slosh: dome_rules.Slosh = .{}, slosh_at: ?usize = null, +/// The liquid in a corner vial (`DrawOptions.corner`): sloshing as the vial moves, splashing and +/// its level easing up as steps come. It asks for frames only while it moves. +liquid: Liquid = .{}, + /// The bar has its collapse tab (it grew from a grip): its place reaches up over it. tab: bool = false, /// The pointer was on the bar last frame: leaving it, the bar goes soon. @@ -189,6 +194,9 @@ pub const DrawOptions = struct { /// A tape playing into the document (fizzy: in a tape's tab), how far it has played, 0 to 1: /// the grip fills as it plays rather than by `steps`. Null: none. progress: ?f32 = null, + /// The card the document is on, its bottom-right corner (`dome.Corner`): the grip is a vial + /// there rather than a dome on the middle of `edge`, where a split's bar runs. + corner: ?dome.Corner = null, /// That tape's steps are landing now (it is neither played out nor held): play's button pauses /// it. tape_playing: bool = false, @@ -225,8 +233,10 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica self.shown.fade = if (opts.history) dome.growNs() else rules.fade_ns; var reach = area; if (opts.edge) |y| reach.h = @max(reach.h, y - reach.y); - const on_grip = opts.history and reach.contains(mouse) and !core.dialogs.modalOverCurrent() and - if (gripRect(area, dome.radius, opts.edge)) |g| dvui.windowRectScale().rectToPhysical(g.outsetAll(dome.reach)).contains(mouse) else false; + const on_grip = opts.history and !core.dialogs.modalOverCurrent() and if (opts.corner) |c| + c.hit(dvui.windowRectScale().s).contains(mouse) + else + reach.contains(mouse) and if (gripRect(area, dome.radius, opts.edge)) |g| dvui.windowRectScale().rectToPhysical(g.outsetAll(dome.reach)).contains(mouse) else false; self.tab = opts.history; const on_bar = if (self.place(area, now)) |b| b.contains(mouse) else false; if (!on_bar) self.held_off = false; @@ -257,22 +267,32 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica if (self.grip_steps) |was| if (opts.steps > was) { self.stepped_at = now; self.slosh.knock(now, 0.35); + // Into a vial, the new step splashes the surface, as a drop landing would. Only into + // one: a liquid nothing steps would hold its splashes for when a vial came. + if (opts.corner != null) self.liquid.splashIn(); }; self.grip_steps = opts.steps; + self.liquid.setLevel(fill); + if (opts.corner) |c| self.liquid.step(now, c.point()); const glow: f32 = if (self.stepped_at) |t| (if (now - t < step_glow_ns) 1 - @as(f32, @floatCast(@as(f64, @floatFromInt(now - t)) / @as(f64, @floatFromInt(step_glow_ns)))) else 0) else 0; if (glow == 0) self.stepped_at = null; const in_use = on_grip or self.stepped_at != null or !self.slosh.settled(now); const alpha = self.presence.ease(in_use, now); // While the bar grows out of it, the grip is the bar's glass: not drawn here. - if (p <= 0) if (gripRect(area, dome.radius, opts.edge)) |g| if (dome.draw(g, id_extra, .{ + const grip_look: dome.Look = .{ .alpha = alpha, .lit = on_grip, .held = self.dwell.held(now), .fill = fill, .glow = glow, .tilt = self.slosh.tilt(now), - }, "history.grip:{s}", .{doc.owner.documentPath(doc)})) self.stir(now); - if (self.dwell.since != null or self.stepped_at != null or !self.slosh.settled(now) or self.presence.moving(in_use)) dvui.refresh(null, @src(), null); + .liquid = if (opts.corner != null) &self.liquid else null, + }; + if (p <= 0) if (opts.corner) |c| { + if (dome.drawCorner(c, id_extra, grip_look, "history.grip:{s}", .{doc.owner.documentPath(doc)})) self.stir(now); + } else if (gripRect(area, dome.radius, opts.edge)) |g| if (dome.draw(g, id_extra, grip_look, "history.grip:{s}", .{doc.owner.documentPath(doc)})) self.stir(now); + if (self.dwell.since != null or self.stepped_at != null or !self.slosh.settled(now) or self.presence.moving(in_use) or + (opts.corner != null and !self.liquid.settled())) dvui.refresh(null, @src(), null); } if (p <= 0) return pressed; const arena = dvui.currentWindow().arena(); @@ -441,6 +461,8 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica if (self.slosh_at) |was| if (was != at) { const by = (@as(f32, @floatFromInt(at)) - @as(f32, @floatFromInt(was))) / @as(f32, @floatFromInt(@max(1, n))); self.slosh.knock(now, dome_rules.knockFor(by)); + // The vial's liquid too, a scrub's step tipping it as the dome's swing does. + if (opts.corner != null) self.liquid.knock(dome_rules.knockFor(by)); }; self.slosh_at = at; const tilt = self.slosh.tilt(now); @@ -450,10 +472,12 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica // the grip in the same glass and shrinking as they leave it, the glass between them reaching // further while they are near: they run together and part as drops do. What is on them shows // once they are most of the way out. Without a grip, it fades. - const from: ?dvui.Rect.Physical = if (opts.history and p < 1) if (gripRect(area, dome.radius, opts.edge)) |g| wrs.rectToPhysical(g) else null else null; - const out = core.motion.enterFull(p); - const bud: ?dvui.Rect.Physical = if (from) |f| dome.bud(f) else null; - const prev_alpha = dvui.alpha(if (from != null) dome.glassAlpha(self.presence.value, p) else p); + const from: ?dvui.Rect.Physical = if (!opts.history or p >= 1) null else if (opts.corner) |c| c.box(s) else if (gripRect(area, dome.radius, opts.edge)) |g| wrs.rectToPhysical(g) else null; + const out = dome.smooth(p); + // Out of a corner vial, which is whole at rest, the glass is whole throughout: eased toward a + // dome's quiet presence, it was glass at partial alpha — a hole — and the vial at full alpha + // the frame it went back in, a flash. + const prev_alpha = dvui.alpha(if (opts.corner != null and from != null) 1 else if (from != null) dome.glassAlpha(self.presence.value, p) else p); defer dvui.alphaSet(prev_alpha); const pt = dvui.currentWindow().mouse_pt; var bubbles: [6]core.dialogs.Bubble = undefined; @@ -462,9 +486,11 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica // strip grows, so the pictures sit inside them. const line_radius = std.math.lerp(phys[1].h / 2, core.corners.scaled(core.corners.card) * s, @min(1, grown * 1.5)); for (phys, bubbles[0..5], 0..) |r, *b, i| b.* = .{ - .rect = if (bud) |f| dome.lerpRect(f, r, dome.emerge(p, bud_delays[i])) else r, + .rect = if (from) |f| dome.pull(f, r, p, bud_delays[i], bud_arcs[i]) else r, .radius = if (i == 1 and grown > 0) line_radius else null, - .light = if (r.contains(pt) and switch (i) { + // Lit under the pointer only once the bar is out: moving, a lit bubble was a lighter drop + // among the rest, its light running across the necks as they joined. + .light = if (p >= 1 and r.contains(pt) and switch (i) { 0 => trimming or at > 0, 2 => !trimming and at < n, 3, 4 => true, @@ -475,10 +501,12 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica // Play's bubble rides behind export's in the list, so export's may be left out. if (export_g <= 0) bubbles[3] = bubbles[4]; var count: usize = if (export_g > 0) 5 else 4; + var tab_index: ?usize = null; if (self.tab) { + tab_index = count; bubbles[count] = .{ - .rect = if (bud) |f| dome.lerpRect(f, tab_p, dome.emerge(p, bud_delays[5])) else tab_p, - .light = if (tab_p.contains(pt)) core.dialogs.bubble_hover else 0, + .rect = if (from) |f| dome.pull(f, tab_p, p, bud_delays[5], bud_arcs[5]) else tab_p, + .light = if (p >= 1 and tab_p.contains(pt)) core.dialogs.bubble_hover else 0, }; count += 1; } @@ -486,17 +514,19 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica var shapes: [7]core.dialogs.Bubble = undefined; @memcpy(shapes[0..count], bubbles[0..count]); if (from) |f| { - shapes[count] = dome.shrinking(f, p); + // A corner vial shrinks into its corner; a dome into its edge. + shapes[count] = if (opts.corner) |c| .{ .rect = core.widgets.orb.rectOf(c.shape(s, 1 - dome.smooth(p))) } else dome.shrinking(f, p); count += 1; } { - // Not past the pane's edge, which the grip stands on. - const prev_clip = if (from) |f| dvui.clip(dome.aboveEdge(f)) else dvui.clipGet(); + // Not past the pane's edge, which the grip stands on: the card, for a corner vial. + const prev_clip = if (opts.corner) |c| dvui.clip(c.card) else if (from) |f| dvui.clip(dome.aboveEdge(f)) else dvui.clipGet(); defer dvui.clipSet(prev_clip); // No shadow ring: each bubble's own ring lay dark across the necks where they run together // (the tab into the bar). The one soft shadow of the merged shape is the orb's // (`plans/LIQUID_EDGE_PLAN.md`); the rim and lens are what set the glass off meanwhile. - core.dialogs.glassBubbles(fid, shapes[0..count], s, .{ .merge = dome.merge(p, 14), .shadow = 0 }); + // Out of a corner vial, cut by the card's outline, curve and all, as the vial is at rest. + core.dialogs.glassBubbles(fid, shapes[0..count], s, .{ .merge = dome.merge(p, 14), .shadow = 0, .edge = if (opts.corner) |c| c.place() else null }); } // What is on the bubbles comes once they are most of the way out; the liquid comes with them. const shape_alpha = dvui.alpha(1); @@ -506,18 +536,25 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica } const ink = dvui.themeGet().color(.window, .text); + // Each icon on its bubble where the bubble is now, so it rides out of the grip with it and + // grows with it, rather than waiting at the place the bubble is going to. + const back_now = wrs.rectFromPhysical(bubbles[0].rect); + const fwd_now = wrs.rectFromPhysical(bubbles[2].rect); + const share_now = wrs.rectFromPhysical(bubbles[3].rect); if (trimming) { - button(bounds, back, "history_cancel", icons.tvg.lucide.x, ink.opacity(0.85)); - } else button(bounds, back, "history_back", icons.tvg.lucide.undo, ink.opacity(if (at > 0) 0.85 else 0.3)); - button(bounds, fwd, "history_forward", icons.tvg.lucide.redo, ink.opacity(if (!trimming and at < n) 0.85 else 0.3)); + button(bounds, back_now, "history_cancel", icons.tvg.lucide.x, ink.opacity(0.85)); + } else button(bounds, back_now, "history_back", icons.tvg.lucide.undo, ink.opacity(if (at > 0) 0.85 else 0.3)); + button(bounds, fwd_now, "history_forward", icons.tvg.lucide.redo, ink.opacity(if (!trimming and at < n) 0.85 else 0.3)); + // Play's bubble is behind export's, or in its place when there is no export. + const play_now = wrs.rectFromPhysical(bubbles[if (export_g > 0) 4 else 3].rect); if (self.play != null or opts.tape_playing) { - button(bounds, play_r, "history_pause", icons.tvg.lucide.pause, ink.opacity(0.85)); - } else button(bounds, play_r, "history_play", icons.tvg.lucide.play, ink.opacity(if (hi > lo) 0.85 else 0.3)); - if (self.tab) dome.chevron(bounds, tab, ink.opacity(0.85)); + button(bounds, play_now, "history_pause", icons.tvg.lucide.pause, ink.opacity(0.85)); + } else button(bounds, play_now, "history_play", icons.tvg.lucide.play, ink.opacity(if (hi > lo) 0.85 else 0.3)); + if (tab_index) |ti| dome.chevron(bounds, wrs.rectFromPhysical(bubbles[ti].rect), ink.opacity(0.85)); if (export_g > 0.3) { if (trimming) { - button(bounds, share, "history_export_done", icons.tvg.lucide.check, yellow); - } else button(bounds, share, "history_export", icons.tvg.lucide.@"share-2", ink.opacity(0.85 * export_g)); + button(bounds, share_now, "history_export_done", icons.tvg.lucide.check, yellow); + } else button(bounds, share_now, "history_export", icons.tvg.lucide.@"share-2", ink.opacity(0.85 * export_g)); } // What the document is at, over the line: the step's title, and how far along. @@ -560,9 +597,12 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica return pressed; } -/// When each bubble buds out of the grip, as a share of the growing: the line first, undo, play and -/// redo after it, then export, then the tab (as `phys`: undo, line, redo, export, play). -const bud_delays = [6]f32{ 0.12, 0, 0.12, 0.22, 0.06, 0.3 }; +/// When each bubble buds out of the grip, as a share of the growing — the line first, then the +/// nearest the grip outward: export, redo, play, undo, the tab — each on its own moment so none +/// travels stacked on another; and how high each arcs, so their paths fan out. As `phys`: undo, +/// line, redo, export, play; then the tab. +const bud_delays = [6]f32{ 0.3, 0, 0.16, 0.08, 0.23, 0.36 }; +const bud_arcs = [6]f32{ 1.3, 0.6, 1.0, 0.8, 1.15, 1.6 }; /// macOS's trim yellow. const yellow: dvui.Color = .{ .r = 0xF5, .g = 0xC0, .b = 0x2E }; diff --git a/app/Liquid.zig b/app/Liquid.zig new file mode 100644 index 000000000..70c4a9b61 --- /dev/null +++ b/app/Liquid.zig @@ -0,0 +1,207 @@ +//! The liquid in a history vial (`dome.paintCorner`): a surface that sloshes as the vial moves and +//! splashes as steps come, as a liquid does. +//! +//! The surface is a row of columns tied to their neighbours by springs (the usual small model of +//! water waves): pulled toward their neighbours' heights, toward rest, and losing energy as they +//! go, with what the row holds kept the same, so a wave moves liquid about rather than making it. +//! The vial moving tips it: its acceleration across the screen, from where the caller says it is +//! each frame, pushes one side down and the other up, as a cup's liquid does when the cup is +//! moved. A new step splashes it (`splashIn`), an undo or redo knocks it (`knock`), and its level +//! eases to the history's fill (`setLevel`). +//! +//! **It lets the app sleep.** `settled` says when nothing moves — the surface flat and still, the +//! level where it is going — and the caller asks for frames only while it is not. A long gap +//! between frames (the app slept, or its frames come slower than the liquid can follow) starts it +//! afresh, still, at its level: frames that never come quickly enough never keep it moving. +//! +//! Units are physical pixels and seconds; std-only, so it tests without a window +//! (`build/app.zig`). +const std = @import("std"); + +const Liquid = @This(); + +pub const columns = 16; + +/// Each column's height over the level, pixels (up positive), and its speed. +h: [columns]f32 = @splat(0), +v: [columns]f32 = @splat(0), +/// The level, 0 to 1, as it is and where it is going (`setLevel`). +level: f32 = 0, +target: f32 = 0, +last_ns: i128 = 0, +last_pos: [2]f32 = .{ 0, 0 }, +last_vel: [2]f32 = .{ 0, 0 }, +/// Which splash comes next, for where it lands. +count: u32 = 0, + +/// How strongly a column is pulled toward its neighbours (wave speed), toward rest, and how fast +/// it loses energy, per second. +const tension: f32 = 900; +const stiffness: f32 = 30; +const damping: f32 = 2.6; +/// Pixels of tip per pixel-per-second² of acceleration across, at the vessel's edges. +const tip_gain: f32 = 0.00045; +/// How hard a splash pushes the column it lands on down, pixels per second. +const splash: f32 = 90; +/// How fast the level comes to where it is going, per second. +const level_rate: f32 = 6; +/// A frame gap past this is a new start, not motion to follow. +const max_gap_ns: i128 = 100 * std.time.ns_per_ms; +/// The longest step the simulation takes, seconds. +const step_s: f32 = 1.0 / 240.0; + +/// Where the level is going, 0 to 1: the history's fill. +pub fn setLevel(self: *Liquid, fill: f32) void { + self.target = std.math.clamp(fill, 0, 1); + if (self.last_ns == 0) self.level = self.target; +} + +/// A new step: the surface splashes where a drop would land, somewhere across the middle, a little +/// differently each time. +pub fn splashIn(self: *Liquid) void { + const spread = [_]f32{ 0.5, 0.38, 0.62, 0.45, 0.55, 0.32 }; + const c: usize = @intFromFloat(@round(spread[self.count % spread.len] * (columns - 1))); + self.count +%= 1; + self.v[c] -= splash; + if (c > 0) self.v[c - 1] -= splash * 0.4; + if (c < columns - 1) self.v[c + 1] -= splash * 0.4; +} + +/// A knock, -1 to 1: the surface tipped as by a sideways push (an undo, a redo). +pub fn knock(self: *Liquid, amount: f32) void { + for (&self.v, 0..) |*v, i| v.* += amount * 60 * across(i); +} + +/// Advance to `now_ns`, the vial at `pos` (pixels, any fixed point of it on screen): tip the surface +/// by how the vial accelerated, run the waves, and ease the level. +pub fn step(self: *Liquid, now_ns: i128, pos: [2]f32) void { + defer { + self.last_ns = now_ns; + self.last_pos = pos; + } + if (self.last_ns == 0 or now_ns - self.last_ns > max_gap_ns or now_ns <= self.last_ns) { + // A new start: still, at its level. + self.h = @splat(0); + self.v = @splat(0); + self.level = self.target; + self.last_vel = .{ 0, 0 }; + return; + } + const dt: f32 = @floatCast(@as(f64, @floatFromInt(now_ns - self.last_ns)) / std.time.ns_per_s); + const vel: [2]f32 = .{ (pos[0] - self.last_pos[0]) / dt, (pos[1] - self.last_pos[1]) / dt }; + const acc_x = (vel[0] - self.last_vel[0]) / dt; + self.last_vel = vel; + // The vial pushed one way, the liquid lags: higher on the side it was pushed from. + const tip = std.math.clamp(acc_x * tip_gain, -400, 400); + for (&self.v, 0..) |*v, i| v.* += tip * across(i); + + var left = dt; + while (left > 0) { + const h = @min(left, step_s); + left -= h; + self.wave(h); + } + self.level += (self.target - self.level) * @min(1, level_rate * dt); +} + +/// Column `i` across the vessel, -1 at its left to 1 at its right. +fn across(i: usize) f32 { + return @as(f32, @floatFromInt(i)) / @as(f32, columns - 1) * 2 - 1; +} + +fn wave(self: *Liquid, dt: f32) void { + var force: [columns]f32 = undefined; + for (0..columns) |i| { + const l = self.h[if (i == 0) 0 else i - 1]; + const r = self.h[if (i == columns - 1) columns - 1 else i + 1]; + force[i] = tension * (l + r - 2 * self.h[i]) - stiffness * self.h[i]; + } + const keep = @exp(-damping * dt); + var mean: f32 = 0; + for (0..columns) |i| { + self.v[i] = (self.v[i] + force[i] * dt) * keep; + self.h[i] += self.v[i] * dt; + mean += self.h[i]; + } + // What the row holds stays the same: a wave moves liquid, it makes none. + mean /= columns; + for (&self.h) |*h| h.* -= mean; +} + +/// Nothing moves: the surface flat and still, the level where it is going. The caller asks for no +/// frames while this holds. +pub fn settled(self: *const Liquid) bool { + if (@abs(self.level - self.target) > 0.002) return false; + for (self.h, self.v) |h, v| if (@abs(h) > 0.05 or @abs(v) > 0.5) return false; + return true; +} + +test "a still vial settles and stays settled" { + var l: Liquid = .{}; + l.setLevel(0.5); + var t: i128 = 1; + for (0..200) |_| { + l.step(t, .{ 100, 100 }); + t += 16 * std.time.ns_per_ms; + } + try std.testing.expect(l.settled()); +} + +test "moving the vial tips the surface, and it settles again" { + var l: Liquid = .{}; + l.setLevel(0.5); + var t: i128 = 1; + var x: f32 = 100; + for (0..10) |_| { + l.step(t, .{ x, 100 }); + t += 16 * std.time.ns_per_ms; + x += 20; + } + try std.testing.expect(!l.settled()); + for (0..600) |_| { + l.step(t, .{ x, 100 }); + t += 16 * std.time.ns_per_ms; + } + try std.testing.expect(l.settled()); +} + +test "a splash ripples the surface, keeps its volume, and settles" { + var l: Liquid = .{}; + l.setLevel(0.4); + var t: i128 = 1; + l.step(t, .{ 0, 0 }); + l.splashIn(); + t += 16 * std.time.ns_per_ms; + l.step(t, .{ 0, 0 }); + try std.testing.expect(!l.settled()); + for (0..600) |_| { + t += 16 * std.time.ns_per_ms; + l.step(t, .{ 0, 0 }); + var sum: f32 = 0; + for (l.h) |h| sum += h; + try std.testing.expectApproxEqAbs(@as(f32, 0), sum, 0.01); + } + try std.testing.expect(l.settled()); +} + +test "a gap between frames is a new start, not a jolt" { + var l: Liquid = .{}; + l.setLevel(0.5); + var t: i128 = 1; + l.step(t, .{ 0, 0 }); + t += 5 * std.time.ns_per_s; + l.step(t, .{ 500, 0 }); + try std.testing.expect(l.settled()); +} + +test "frames always too slow to follow still settle, so the app can sleep" { + var l: Liquid = .{}; + l.setLevel(0.2); + var t: i128 = 1; + l.step(t, .{ 0, 0 }); + l.splashIn(); + l.setLevel(0.6); + t += 200 * std.time.ns_per_ms; + l.step(t, .{ 0, 0 }); + try std.testing.expect(l.settled()); +} diff --git a/app/dome.zig b/app/dome.zig index 0255416d5..bbb8daf49 100644 --- a/app/dome.zig +++ b/app/dome.zig @@ -27,6 +27,7 @@ const dvui = @import("dvui"); const core = @import("core"); const icons = @import("icons"); const rules = @import("dome_rules.zig"); +const Liquid = @import("Liquid.zig"); /// The dome's radius: a semicircle twice as wide as it is tall. pub const radius: f32 = 13; @@ -43,7 +44,17 @@ pub fn rect(cx: f32, edge_y: f32, r: f32) dvui.Rect { /// How long a bar takes to grow out of its dome, and to go back in: a surface's opening time. pub fn growNs() i128 { - return @as(i128, core.motion.duration(core.motion.open_us)) * std.time.ns_per_us; + return @as(i128, core.motion.duration(grow_us)) * std.time.ns_per_us; +} +/// Microseconds, as written, a bar takes to grow out of its grip: slower than a surface opening, +/// so it reads as one liquid swelling and parting into drops, not controls flung out. +const grow_us: i32 = 620_000; + +/// The one curve the growing runs on, both ways: eased in and out, with no swing past and no +/// drawing back, so nothing about it jumps. +pub fn smooth(t: f32) f32 { + const k = std.math.clamp(t, 0, 1); + return if (k < 0.5) 4 * k * k * k else 1 - std.math.pow(f32, -2 * k + 2, 3) / 2; } /// Where the bar's bubbles bud from in the dome at `from` (physical, the semicircle's box): a @@ -58,6 +69,41 @@ pub fn emerge(p: f32, delay: f32) f32 { return core.motion.enterFull(std.math.clamp((p - delay) / (1 - delay), 0, 1)); } +/// Where a bubble of the bar is, `p` of the way out (0 in its grip `from`, 1 at its place `to`), +/// budding at `delay` (a share of the growing): a drop pulled out of the grip rather than a button +/// sliding. Small while it travels, inflating as it nears its place; along an arc, up and then +/// across, rather than straight, `arc` times the usual height (so bubbles leaving together fan out +/// rather than travel stacked); on one smooth curve (`smooth`) both ways, so going back is coming +/// played backwards — in the reverse order, as the later a bubble buds, the sooner it is in. +pub fn pull(from: dvui.Rect.Physical, to: dvui.Rect.Physical, p: f32, delay: f32, arc: f32) dvui.Rect.Physical { + const k = std.math.clamp((p - delay) / (1 - delay), 0, 1); + const t = smooth(k); + if (t <= 0) return bud(from); + const a = bud(from); + const ac: [2]f32 = .{ a.x + a.w / 2, a.y + a.h / 2 }; + const bc: [2]f32 = .{ to.x + to.w / 2, to.y + to.h / 2 }; + // Up off the straight line, most of the way out at the middle of the way. + const dist = @sqrt((bc[0] - ac[0]) * (bc[0] - ac[0]) + (bc[1] - ac[1]) * (bc[1] - ac[1])); + const lift = dist * pull_arc * arc * @sin(std.math.pi * std.math.clamp(t, 0, 1)); + const c: [2]f32 = .{ std.math.lerp(ac[0], bc[0], t), std.math.lerp(ac[1], bc[1], t) - lift }; + // A drop while it travels, as big as the grip's bud at most; its place's size once near it. + const drop = @min(a.h, @min(to.w, to.h) * pull_drop); + const grow = std.math.clamp((t - pull_inflate) / (1 - pull_inflate), 0, 1); + const g = grow * grow * (3 - 2 * grow); + const w = std.math.lerp(drop, to.w, g); + const h = std.math.lerp(drop, to.h, g); + // Past its place in a swing, past its size a little too. + const swell = if (t > 1) t else 1; + return .{ .x = c[0] - w * swell / 2, .y = c[1] - h * swell / 2, .w = w * swell, .h = h * swell }; +} +/// How far up off the straight way a pulled bubble arcs, as a share of the way; how big it is +/// while it travels, as a share of its place's size; and how far along it starts to inflate. +const pull_arc: f32 = 0.14; +// Not much smaller than its place: the glass's rim is one for the whole field, fitted to its +// smallest shape, and a tiny drop is all rim — a darker drop among the rest. +const pull_drop: f32 = 0.7; +const pull_inflate: f32 = 0.55; + /// The dome at `from` itself in the bar's glass while the bar is `p` of the way out, shrinking into /// its edge as the bubbles leave it. Clip the glass to the edge (`aboveEdge`). pub fn shrinking(from: dvui.Rect.Physical, p: f32) core.dialogs.Bubble { @@ -65,10 +111,11 @@ pub fn shrinking(from: dvui.Rect.Physical, p: f32) core.dialogs.Bubble { return .{ .rect = .{ .x = from.x + from.w / 2 - d / 2, .y = from.y + from.h - d / 2, .w = d, .h = d } }; } -/// How near the bar's bubbles reach for each other `p` of the way out: further while they are -/// near the dome, so they run together, and `settled` once out. +/// How near the bar's bubbles reach for each other `p` of the way out: far through the first +/// half, so they and the grip swell as one blob, then falling to `settled` over the second, so the +/// necks between them thin and let go as each nears its place, and they part as drops do. pub fn merge(p: f32, settled: f32) f32 { - return std.math.lerp(40, settled, core.motion.enterFull(p)); + return std.math.lerp(28, settled, smooth((p - 0.3) / 0.6)); } /// The clip that keeps the glass growing out of the dome at `from` from reaching past the edge it @@ -127,6 +174,9 @@ pub const Look = struct { glow: f32 = 0, /// How tipped its liquid is, -1 to 1 (`rules.Slosh`). tilt: f32 = 0, + /// A corner vial's liquid, its surface and drops (`paintCorner`): drawn from it rather than + /// from `fill` and `tilt`. + liquid: ?*const Liquid = null, }; /// The pool in the dome at `from` (physical, the semicircle's box) the liquid flows out of into @@ -218,10 +268,255 @@ pub fn pourTube(done: dvui.Rect.Physical, tilt: f32, from_pool: ?dvui.Rect.Physi const prev = dvui.alpha(1); dvui.alphaSet(alpha); defer dvui.alphaSet(prev); - const tube = if (from_pool) |f| lerpRect(f, done, out) else done; + const pool_r = from_pool orelse return fillTube(done, tilt, color); + // Poured: a stream from the pool to the knob, the end of the line's liquid nearest the pool, + // then the liquid spreading from there back to the line's start. + const k = std.math.clamp(out, 0, 1); + // The stream's head runs from the pool to the line over the first part; its tail leaves the + // pool once the line is filling, and runs into the line after the head. + const head = smooth(k / pour_stream); + const tail = smooth((k - pour_stream) / (pour_fill - pour_stream)); + const th = done.h; + const lip: [2]f32 = .{ pool_r.x + pool_r.w * 0.3, pool_r.y + @min(pool_r.h, th) / 2 }; + const end: [2]f32 = .{ done.x + done.w - th / 2, done.y + th / 2 }; + if (tail < 1 and head > 0) pourStream(lip, end, tail, head, th, color); + // The line fills from the knob back to its start once the stream reaches it. + const fill = smooth((k - pour_stream * 0.8) / (1 - pour_stream * 0.8)); + const w = @max(th, std.math.lerp(th, done.w, fill)); + if (fill > 0) fillTube(.{ .x = done.x + done.w - w, .y = done.y, .w = w, .h = th }, tilt * fill, color); +} + +/// How far into the pour (0 to 1) the stream reaches the line, and lets go of the pool. +const pour_stream: f32 = 0.4; +const pour_fill: f32 = 0.75; + +/// The line's liquid in `tube` (physical): a capsule, its head tipped by `tilt` (-1 to 1). +fn fillTube(tube: dvui.Rect.Physical, tilt: f32, color: dvui.Color) void { var outline: [4 * (rules.corner_points + 1)]rules.Pt = undefined; var kept: [outline.len + 1]rules.Pt = undefined; const vessel = rules.roundedOutline(tube.x, tube.y, tube.w, tube.h, @min(tube.w, tube.h) / 2, &outline); const a = tilt * 0.6; pour(rules.cut(vessel, .{ tube.x + tube.w, tube.y + tube.h / 2 }, .{ @cos(a), @sin(a) }, &kept), color); } + +// ── The grip as a corner vial ──────────────────────────────────────────────────────────────── +// +// The history's grip in the bottom-right corner of the card its document is on, the way a place's +// arrange button is in its top-right (`layout/Region.zig`, `cornerPiece`): an orb on the card's +// corner point, cut by the card's outline with an edge of its own (`LiquidField.Edge.cut`), one +// radius on every card and reaching out by part of what a rounder corner's curve cuts off. The +// corner is the one place on a card no split's bar is (they run along the middle of its edges), +// and the quarter is room for the liquid to show. A history icon sits in it, over the liquid. + +/// The orb's radius in points, and how much of what the card's curve cuts off it reaches out by: +/// the arrange corner's (`Region.corner_grown_radius`, `corner_curve_reach`). +pub const corner_radius: f32 = 30; +pub const corner_curve_reach: f32 = 0.5; +/// The history icon's size in points, and how far in from the quarter's middle it sits. +const corner_icon: f32 = 13; + +/// A card's bottom-right corner, physical: its rect and its corners (top-left, top-right, +/// bottom-right, bottom-left). +pub const Corner = struct { + card: dvui.Rect.Physical, + radii: [4]f32 = @splat(0), + + fn curveIn(self: Corner) f32 { + return self.radii[2] * (std.math.sqrt2 - 1); + } + + /// The card's corner point. + pub fn point(self: Corner) [2]f32 { + return .{ self.card.x + self.card.w, self.card.y + self.card.h }; + } + + /// The orb's radius, physical. + pub fn radiusPx(self: Corner, s: f32) f32 { + return corner_radius * s + self.curveIn() * corner_curve_reach; + } + + /// The square from the corner the quarter lies in: where the pointer is on it, and what the + /// bar grows out of. + pub fn box(self: Corner, s: f32) dvui.Rect.Physical { + const r = self.radiusPx(s); + const p = self.point(); + return .{ .x = p[0] - r, .y = p[1] - r, .w = r, .h = r }; + } + + /// The orb, `t` of its size (1 whole, 0 nothing), on the corner point. + pub fn shape(self: Corner, s: f32, t: f32) core.widgets.orb.Shape { + return .circle(self.point(), self.radiusPx(s) * @max(0, t)); + } + + /// The card's outline, which cuts it. + pub fn place(self: Corner) core.widgets.LiquidField.Edge { + return .{ .rect = self.card, .radii = self.radii, .cut = true }; + } + + /// Where the pointer is on it: the square from the corner that lies inside the quarter drawn + /// (its far corner on the circle), so nothing past the glass — the document's last columns, + /// a scroll bar's end — is taken for the grip's. + pub fn hit(self: Corner, s: f32) dvui.Rect.Physical { + const d = self.radiusPx(s) * std.math.sqrt1_2; + const p = self.point(); + return .{ .x = p[0] - d, .y = p[1] - d, .w = d, .h = d }; + } + + /// Where the icon is centred: on the diagonal, in the middle of what of the quarter shows. + pub fn iconCenter(self: Corner, s: f32) [2]f32 { + const axis = self.radii[2] * (1 - std.math.sqrt1_2); + const in = axis + @max(0, self.radiusPx(s) - self.curveIn()) / 2 * std.math.sqrt1_2; + const p = self.point(); + return .{ p[0] - in, p[1] - in }; + } +}; + +/// The vial at `c`, as `look` says, in a floating widget of its own that takes its presses, named +/// for a tape or a test to aim at. Returns whether it was pressed. +pub fn drawCorner(c: Corner, id_extra: usize, look: Look, comptime anchor_fmt: []const u8, anchor_args: anytype) bool { + const wrs = dvui.windowRectScale(); + const s = wrs.s; + const pr = c.hit(s); + var float: dvui.FloatingWidget = undefined; + float.init(@src(), .{}, .{ .rect = wrs.rectFromPhysical(pr), .id_extra = id_extra }); + defer float.deinit(); + core.anchor.markRect(float.data().id, pr, true, anchor_fmt, anchor_args); + var pressed = false; + for (dvui.events()) |*e| { + if (!dvui.eventMatch(e, .{ .id = float.data().id, .r = float.data().borderRectScale().r })) continue; + if (e.evt == .mouse and e.evt.mouse.action == .press and e.evt.mouse.button.pointer()) { + e.handle(@src(), float.data()); + pressed = true; + } + } + paintCorner(float.data().id, c, 1, look); + // The history icon over the liquid: grey at rest, the text colour while it is in use. + const theme = dvui.themeGet(); + const grey = theme.color(.window, .fill).lerp(if (theme.dark) dvui.Color.white else dvui.Color.black, 0.3).opacity(1); + const lit: f32 = if (look.lit) 1 else std.math.clamp(@max(look.glow, look.held), 0, 1); + const ic = c.iconCenter(s); + const d = corner_icon * s; + const bounds = float.data().borderRectScale().r; + core.icon.icon(@src(), "history_grip", icons.tvg.lucide.history, .{ + .stroke_color = .white, + .fill_color = .white, + }, .{ + .rect = .{ .x = (ic[0] - d / 2 - bounds.x) / s, .y = (ic[1] - d / 2 - bounds.y) / s, .w = corner_icon, .h = corner_icon }, + .color_text = .{ .color = grey.lerp(theme.color(.content, .text), lit) }, + }); + return pressed; +} + +/// The vial's pixels at `c`, `t` of its size (1 whole; less while the bar grows out of it): its +/// glass, an orb cut by the card, and the liquid in it up to its level, tipped by its slosh. +pub fn paintCorner(id: dvui.Id, c: Corner, t: f32, look: Look) void { + const s = dvui.windowRectScale().s; + const prev = dvui.clip(c.card); + defer dvui.clipSet(prev); + const shape = c.shape(s, t); + if (shape.half[0] < 1) return; + core.widgets.orb.draw(id, shape, s, .{ + .place = c.place(), + // Lit under the pointer, lighter still as the wait before the bar runs. + .light = if (look.lit) std.math.lerp(core.dialogs.bubble_hover, core.dialogs.bubble_press, look.held) else 0, + }); + if (look.fill <= 0) return; + // The vessel: the glass a little in from its rim, cut by the card a little in from its edge. + const inset = liquid_inset * s; + const geometry = core.orb_geometry; + var a: [160]geometry.Pt = undefined; + var b: [160]geometry.Pt = undefined; + var out: [320]geometry.Pt = undefined; + var scratch: [320]geometry.Pt = undefined; + const inner: geometry.Shape = .circle(shape.center, @max(0, shape.half[0] - inset)); + const card = c.card.insetAll(inset); + const card_radii: [4]f32 = .{ @max(0, c.radii[0] - inset), @max(0, c.radii[1] - inset), @max(0, c.radii[2] - inset), @max(0, c.radii[3] - inset) }; + const vessel = geometry.cut(inner.outline(24, &a), geometry.Shape.box(card.x, card.y, card.w, card.h, card_radii).outline(16, &b), &out, &scratch); + if (vessel.len < 3) return; + const p = c.point(); + if (look.liquid) |liq| return pourLiquid(vessel, liq, p[1] - (shape.half[0] - inset), p[1] - inset, s, liquidColor(@max(look.glow, 0.6 * look.held))); + // Never full: at most `fill_top` of the way up what shows, so there is always room to swing. + const top = p[1] - (shape.half[0] - inset); + const level = std.math.lerp(p[1] - inset, top, rules.fill_top * look.fill); + const tip = look.tilt * 0.45; + var kept: [321]rules.Pt = undefined; + pour(rules.cut(vessel, .{ p[0] - shape.half[0] / 2, level }, .{ @sin(tip), -@cos(tip) }, &kept), liquidColor(@max(look.glow, 0.6 * look.held))); +} + +/// How far in from the glass and the card's edge the liquid keeps, points. +const liquid_inset: f32 = 2.5; + +/// The liquid `liq` in `vessel` (physical, convex), its top `top` and its bottom `bottom` (y): the +/// surface drawn column to column in strips, each cut by the vessel, with a lighter line along +/// its top, and the drops in the air, cut by the vessel too, so one coming in emerges from the +/// glass rather than over it. +fn pourLiquid(vessel: []const rules.Pt, liq: *const Liquid, top: f32, bottom: f32, s: f32, color: dvui.Color) void { + const geometry = core.orb_geometry; + var minx: f32 = std.math.floatMax(f32); + var maxx: f32 = -std.math.floatMax(f32); + for (vessel) |q| { + minx = @min(minx, q[0]); + maxx = @max(maxx, q[0]); + } + const level_y = std.math.lerp(bottom, top, rules.fill_top * liq.level); + const dx = (maxx - minx) / @as(f32, Liquid.columns - 1); + const shine = color.lerp(.white, 0.35); + var out: [256]geometry.Pt = undefined; + var scratch: [256]geometry.Pt = undefined; + for (0..Liquid.columns - 1) |i| { + const x0 = minx + dx * @as(f32, @floatFromInt(i)); + // A hair past the next column, so the strips' smoothed edges do not show as seams. + const x1 = x0 + dx + 0.75 * s; + const y0 = level_y - liq.h[i]; + const y1 = level_y - liq.h[i + 1]; + const strip = [_]geometry.Pt{ .{ x0, y0 }, .{ x1, y1 }, .{ x1, bottom + 2 * s }, .{ x0, bottom + 2 * s } }; + pour(geometry.cut(&strip, vessel, &out, &scratch), color); + const line = [_]geometry.Pt{ .{ x0, y0 - 0.5 * s }, .{ x1, y1 - 0.5 * s }, .{ x1, y1 + 0.9 * s }, .{ x0, y0 + 0.9 * s } }; + pour(geometry.cut(&line, vessel, &out, &scratch), shine); + } +} + +/// The stream poured from `lip` (the pool's surface) to `end` (the line's knob), physical, from +/// `tail` to `head` of the way along (0 to 1): up off the pool and over in an arc, thin at the lip +/// and the line's width where it lands, as a stream running out of a vessel thins at its lip. +fn pourStream(lip: [2]f32, end: [2]f32, tail: f32, head: f32, th: f32, color: dvui.Color) void { + // A quadratic arc, its control point up off the middle by a part of the way across. + const span = @abs(end[0] - lip[0]) + @abs(end[1] - lip[1]); + const ctrl: [2]f32 = .{ (lip[0] + end[0]) / 2, @min(lip[1], end[1]) - span * 0.18 }; + const at = struct { + fn f(a: [2]f32, b: [2]f32, c: [2]f32, u: f32) [2]f32 { + const v = 1 - u; + return .{ v * v * a[0] + 2 * v * u * b[0] + u * u * c[0], v * v * a[1] + 2 * v * u * b[1] + u * u * c[1] }; + } + }.f; + const n = 18; + var prev = at(lip, ctrl, end, tail); + var prev_w = std.math.lerp(th * 0.45, th, tail) / 2; + for (1..n + 1) |i| { + const u = std.math.lerp(tail, head, @as(f32, @floatFromInt(i)) / n); + const p = at(lip, ctrl, end, u); + const w = std.math.lerp(th * 0.45, th, u) / 2; + const dx = p[0] - prev[0]; + const dy = p[1] - prev[1]; + const len = @max(0.001, @sqrt(dx * dx + dy * dy)); + const nx = -dy / len; + const ny = dx / len; + // A hair longer than the step, so the segments' smoothed edges do not show as seams. + const ex = dx / len * 0.4; + const ey = dy / len * 0.4; + const quad = [_]rules.Pt{ + .{ prev[0] + nx * prev_w - ex, prev[1] + ny * prev_w - ey }, + .{ p[0] + nx * w + ex, p[1] + ny * w + ey }, + .{ p[0] - nx * w + ex, p[1] - ny * w + ey }, + .{ prev[0] - nx * prev_w - ex, prev[1] - ny * prev_w - ey }, + }; + pour(&quad, color); + prev = p; + prev_w = w; + } + // A round head where it is running. + const h = at(lip, ctrl, end, head); + const hw = std.math.lerp(th * 0.45, th, head) / 2; + const r: dvui.Rect.Physical = .{ .x = h[0] - hw, .y = h[1] - hw, .w = 2 * hw, .h = 2 * hw }; + r.fill(.all(hw), .{ .color = .{ .color = color }, .fade = 1 }); +} diff --git a/build/app.zig b/build/app.zig index 65077490e..c41b5d5a4 100644 --- a/build/app.zig +++ b/build/app.zig @@ -869,6 +869,8 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // An orb's geometry: shapes, how one becomes another, the polygons the no-glass path fills // (plans/LIQUID_EDGE_PLAN.md, "The orb system"). std-only. .{ "fizzy-orb-geometry-tests", "core/orb_geometry.zig" }, + // The liquid in a history vial: its waves, its drops, and when it is still. std-only. + .{ "fizzy-liquid-tests", "app/Liquid.zig" }, // The in-app menu bar's order: fizzy's menus, then plugins', then Help. std-only (see // menu_order.zig); `Menu.draw` walks it. .{ "fizzy-menu-order-tests", "src/editor/menu_order.zig" }, diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 4e110c0bc..6e07d7380 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -3114,12 +3114,19 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { // around it. // The card the document is drawn on, the first box round it reaching lower (its padding): its // bottom edge is where the history's grip stands. + // Its rect and corners too: the history's grip is a vial in its bottom-right corner. + var card_corner: ?@import("app").dome.Corner = null; const card_edge = edge: { var wd = dvui.parentGet().data(); const own = wd.borderRectScale().r; for (0..8) |_| { - const r = wd.borderRectScale().r; - if (r.y + r.h > own.y + own.h + 0.5) break :edge r.y + r.h; + const rs = wd.borderRectScale(); + const r = rs.r; + if (r.y + r.h > own.y + own.h + 0.5) { + const cr = wd.options.cornersGet().scale(rs.s, dvui.CornerRect.Physical); + card_corner = .{ .card = r, .radii = .{ cr.tl.radius(), cr.tr.radius(), cr.br.radius(), cr.bl.radius() } }; + break :edge r.y + r.h; + } if (wd.parent.data() == wd) break; wd = wd.parent.data(); } @@ -3173,6 +3180,11 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { if (claim_pill == null) if (ds.transport.place(area, now)) |bar| { area.h = @min(area.h, bar.y - area.y + 6 * dvui.windowRectScale().s); }; + // And above the history's vial in the card's corner, which the feed's newest would sit on. + if (claim_pill == null and ds.has_history) if (card_corner) |c| { + const s = dvui.windowRectScale().s; + area.h = @max(0, @min(area.h, c.point()[1] - c.radiusPx(s) - area.y + 6 * s)); + }; if (ds.feed.draw(area, @truncate(ds.doc_id), now)) |pressed| editor.actFromFeed(doc, ds, pressed); } @@ -3215,6 +3227,7 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { .steps = keptSteps(ds, doc, recorded), .limit = editor.app.settings.history_limit, .edge = card_edge, + .corner = card_corner, .frames = if (recorded != null) &ds.frames else null, .export_from = if (track) |tr| tr.base_top else null, .window_summary = summary,