diff --git a/app/HistoryFrames.zig b/app/HistoryFrames.zig index c0afc37a1..fe0fdac7b 100644 --- a/app/HistoryFrames.zig +++ b/app/HistoryFrames.zig @@ -1,19 +1,22 @@ -//! A document's history as pictures: a small copy of its pane at each step it has stood at, for -//! the transport's strip (`HistoryTransport`), as a video's scrubber shows frames. +//! A document's history as pictures: a crop of its pane at each step it has stood at, for the +//! transport's strip (`HistoryTransport`), as a video's scrubber shows frames. //! //! Owner-agnostic: a picture is what the pane drew, read from the frame the app already keeps in -//! a texture (`core.FrameTarget.snapshot`, scaled down on the GPU), so any document has frames -//! without its owner knowing. Taken only after the document's history moved (`moved`): the frame -//! after an edit lands, an undo, a redo, a scrub's jump. Each is the pane above the bar, keyed by -//! the history entry on top then (0 before any). A step the document never stood at while open -//! here has no picture: the strip shows it plain until a scrub passes over it. +//! a texture (`core.FrameTarget.snapshot`), so any document has frames without its owner knowing. +//! Taken only after the document's history moved (`moved`): the frame after an edit lands, an +//! undo, a redo, a scrub's jump. Each is the pane above the bar, keyed by the history entry on top +//! then (0 before any). A step the document never stood at while open here has no picture: the +//! strip shows it plain until a scrub passes over it. //! -//! Each picture also knows where its step changed the document (`Picture.change`): compared with -//! the picture of the step before, column by column (`columnPrints`), once, as it lands. The strip -//! shows each step's slice there, so it reads as the steps themselves. +//! A picture is the strip's size, cut from the frame at its own pixels round where its step changed +//! the document (`Picture.change`), so the strip draws it one pixel for one and it reads as +//! crisply as the pane. Where it changed is found on a small copy of the pane, compared with the +//! step before's column by column and row by row (`prints`), once, as it lands. //! -//! Costs nothing on a frame the history did not move; on one it did, a copy of the pane into a -//! texture the strip's size. At most `cap` pictures a document, the oldest taken going first. +//! Costs nothing on a frame the history did not move; on one it did, two copies of the pane on the +//! GPU (the whole one into a target kept while the history keeps moving, `whole`), and the next +//! frame a read of the small one and a crop of the other. At most `cap` pictures +//! a document, the oldest taken going first. const HistoryFrames = @This(); const std = @import("std"); @@ -26,32 +29,47 @@ frames: std.AutoArrayHashMapUnmanaged(u64, Picture) = .empty, /// The entry on top the frame just drawn shows, to be taken from it at the next (`take`). due: ?Step = null, /// A picture taken last frame, still the target it was drawn into: read the frame after, once -/// its copy has landed, for where its content is (`land`). -unread: ?struct { step: Step, target: dvui.TextureTarget, source: [2]u32 } = null, +/// its copy has landed, for where it changed, and cropped from `whole` (`land`). +unread: ?Unread = null, +/// The pane at its own pixels, as the last picture taken saw it: what its crop is cut from. Kept +/// while the history moves frame after frame (a held undo, a scrub), so each step is a copy into +/// it rather than a new pane-sized target; let go of the first frame no picture is asked for. +whole: ?dvui.TextureTarget = null, + +const Unread = struct { + step: Step, + /// The pane, small (`height` high): read back for where its step changed. + small: dvui.TextureTarget, + /// The size of `whole` when it was taken, in pixels. + source: [2]u32, + /// The crop's size: the strip's, in pixels. + crop: [2]u32, +}; /// A step's picture to take: the entry on top, and the one under it (null before any), whose /// picture it is compared with for what the step changed. const Step = struct { top: u64, below: ?u64 }; -/// A picture of the document, and where in it the content is (`content`): what differs from its -/// background, as fractions of it; null where nothing does (an empty document). +/// A crop of the document's pane at a step, the strip's size at its own pixels, and where the step +/// changed it. pub const Picture = struct { tex: dvui.Texture, - content: ?rules.Frac, - /// Each column's print (`columnPrints`), owned; empty when it could not be read. + /// The small copy's prints (`prints`): each column's, then each row's; owned, empty when it + /// could not be read. prints: []u32 = &.{}, /// The size of the pane's part it was taken from, in physical pixels: two pictures are /// compared only when taken from the same, or the scaling alone would differ everywhere. source: [2]u32 = .{ 0, 0 }, - /// The middle of what this step changed from the one before, as a fraction across; null when - /// nothing visibly did, or the step before has no picture to compare with. - change: ?f32 = null, + /// What this step changed from the one before, as fractions of the pane; null when nothing + /// visibly did, or the step before has no picture to compare with. + change: ?rules.Frac = null, }; -/// At most this many pictures a document: 48 of 384 by 192 is about 14 MB. +/// At most this many pictures a document: each the strip's size, 4 bytes a pixel (a 600 by 104 +/// strip on a Retina screen is 250 KB, so 48 are about 12 MB). pub const cap = 48; -/// A picture's height in pixels; its width keeps the pane's shape, up to `max_w`. Fine enough for -/// the strip to frame a few lines of a document close (`paintStrip`) and still read them. +/// The small copy's height in pixels, only for where a step changed; its width keeps the pane's +/// shape, up to `max_w`. pub const height: u32 = 192; pub const max_w: u32 = 384; @@ -72,60 +90,101 @@ pub fn moved(self: *HistoryFrames, top: u64, below: ?u64) void { /// Before the document draws: take the picture asked for (`moved`) from what the last frame drew of /// `pane` (physical), above `bar_h` (physical) of its bottom, where the transport and the feed /// are drawn over it: the bar's height as it stands, its strip included, so a picture never holds -/// the strip itself. -pub fn take(self: *HistoryFrames, gpa: std.mem.Allocator, pane: dvui.Rect.Physical, bar_h: f32) void { +/// the strip itself. `strip` is the strip's size in physical pixels (`HistoryTransport.stripPixels`): +/// the crop's; null where the pane has no room for the bar, and no picture is taken. +pub fn take(self: *HistoryFrames, gpa: std.mem.Allocator, pane: dvui.Rect.Physical, bar_h: f32, strip: ?[2]u32) void { self.land(gpa); - const step = self.due orelse return; + const step = self.due orelse { + self.dropWhole(); + return; + }; self.due = null; - var r = pane; - r.h -= bar_h; - if (r.w < 8 or r.h < 8) return; + const crop = strip orelse return self.dropWhole(); + // On whole pixels, so the copy at its own size is the pane's pixels, not a blend of two. + var r: dvui.Rect.Physical = .{ .x = @round(pane.x), .y = @round(pane.y), .w = @round(pane.w), .h = @round(pane.h - bar_h) }; + r = r.intersect(dvui.windowRectPixels()); + if (r.w < 8 or r.h < 8 or crop[0] == 0 or crop[1] == 0) return self.dropWhole(); const prof = core.profile.begin("fizzy", "history.frame"); defer prof.end(); + const source: [2]u32 = .{ @intFromFloat(r.w), @intFromFloat(r.h) }; + if (self.whole) |t| if (t.width != source[0] or t.height != source[1]) self.dropWhole(); + const whole = self.whole orelse (dvui.textureCreateTarget(.{ .width = source[0], .height = source[1], .interpolation = .linear }) catch return); + self.whole = whole; const h: f32 = @floatFromInt(height); const w: u32 = @intFromFloat(@min(@as(f32, @floatFromInt(max_w)), @round(h * r.w / r.h))); - const target = core.FrameTarget.snapshotTarget(r, .{ @max(w, 1), height }) orelse return; - self.unread = .{ .step = step, .target = target, .source = .{ @intFromFloat(@round(r.w)), @intFromFloat(@round(r.h)) } }; + if (!core.FrameTarget.snapshotInto(r, whole)) return; + const small = core.FrameTarget.snapshotTarget(r, .{ @max(w, 1), height }) orelse return; + self.unread = .{ .step = step, .small = small, .source = source, .crop = crop }; // The frame that reads it back. dvui.refresh(null, @src(), null); } -/// The picture taken last frame (`unread`), its copy landed: read back for where its content is -/// (`contentOf`) and where it changed from the step before (`columnPrints`), then kept as a -/// texture. A read of one small picture, once per move. +fn dropWhole(self: *HistoryFrames) void { + if (self.whole) |t| t.destroyLater(); + self.whole = null; +} + +/// The picture taken last frame (`unread`), its copies landed: the small one read back for where +/// it changed from the step before (`prints`) and let go of, and the crop round that cut from +/// `whole` at its own pixels (`history_transport.cropAt`). A read of one small picture and a copy +/// the strip's size, once per move. fn land(self: *HistoryFrames, gpa: std.mem.Allocator) void { const u = self.unread orelse return; self.unread = null; - const prof = core.profile.begin("fizzy", "history.frame.content"); + defer u.small.destroyLater(); + const whole_target = self.whole orelse return; + const prof = core.profile.begin("fizzy", "history.frame.crop"); defer prof.end(); - const w = u.target.width; - const h = u.target.height; - var pic: Picture = .{ .tex = undefined, .content = null, .source = u.source }; + const w = u.small.width; + const h = u.small.height; + var pic: Picture = .{ .tex = undefined, .source = u.source }; + var content: ?rules.Frac = null; // Compared with: null when there is nothing to compare with, so a picture taken again keeps // what it knew. var compared = false; - if (dvui.Texture.readTarget(dvui.currentWindow().arena(), u.target)) |px| { - pic.content = contentOf(px, w, h); - if (gpa.alloc(u32, w)) |prints| { - columnPrints(px, w, h, prints); - pic.prints = prints; - if (u.step.below) |b| if (self.frames.get(b)) |before| if (before.prints.len == prints.len and prints.len > 0 and std.mem.eql(u32, &before.source, &pic.source)) { + if (dvui.Texture.readTarget(dvui.currentWindow().arena(), u.small)) |px| { + content = contentOf(px, w, h); + if (gpa.alloc(u32, w + h)) |p| { + prints(px, w, h, p); + pic.prints = p; + if (u.step.below) |b| if (self.frames.get(b)) |before| if (before.prints.len == p.len and std.mem.eql(u32, &before.source, &pic.source)) { compared = true; - if (rules.changedSpan(before.prints, prints)) |c| pic.change = (c.lo + c.hi) / 2; + const cols = rules.changedSpan(before.prints[0..w], p[0..w]); + const rows = rules.changedSpan(before.prints[w..], p[w..]); + if (cols) |c| if (rows) |r| { + pic.change = .{ .x = c.lo, .w = c.hi - c.lo, .y = r.lo, .h = r.hi - r.lo }; + }; }; } else |_| {} } else |_| {} - pic.tex = dvui.Texture.fromTarget(u.target) catch { + const top = u.step.top; + // Taken again, not compared: what it knew, when taken from the same view of it (from another, + // it is somewhere else now). + if (self.frames.get(top)) |old| if (!compared and std.mem.eql(u32, &old.source, &pic.source)) { + pic.change = old.change; + }; + // Round what the step changed; one that changed nothing visible, round the document's content, + // or the pane's middle. + const round = pic.change orelse content orelse rules.Frac{}; + const sw: f32 = @floatFromInt(u.source[0]); + const sh: f32 = @floatFromInt(u.source[1]); + const c = rules.cropAt(.{ (round.x + round.w / 2) * sw, (round.y + round.h / 2) * sh }, u.crop, u.source); + const whole = dvui.Texture.fromTargetTemp(whole_target) catch { gpa.free(pic.prints); return; }; - const top = u.step.top; - if (self.frames.fetchOrderedRemove(top)) |kv| { - // Taken again: the newest, so the last to go. - // Kept only from the same view of it: from another, it is somewhere else now. - if (!compared and std.mem.eql(u32, &kv.value.source, &pic.source)) pic.change = kv.value.change; - release(gpa, kv.value); - } + const rect: dvui.Rect.Physical = .{ .x = @floatFromInt(c.x), .y = @floatFromInt(c.y), .w = @floatFromInt(c.w), .h = @floatFromInt(c.h) }; + const cropped = core.FrameTarget.copyRect(whole, rect, .{ c.w, c.h }, .nearest) orelse { + gpa.free(pic.prints); + return; + }; + pic.tex = dvui.Texture.fromTarget(cropped) catch { + cropped.destroyLater(); + gpa.free(pic.prints); + return; + }; + // Taken again: the newest, so the last to go. + if (self.frames.fetchOrderedRemove(top)) |kv| release(gpa, kv.value); self.frames.put(gpa, top, pic) catch release(gpa, pic); while (self.frames.count() > cap) { release(gpa, self.frames.values()[0]); @@ -138,19 +197,29 @@ fn release(gpa: std.mem.Allocator, pic: Picture) void { gpa.free(pic.prints); } -/// A print of each column of a picture of `w` by `h` pixels into `out` (`w` long): a hash of its -/// pixels, so two pictures of the same size differ in a column exactly where their prints do -/// (`history_transport.changedSpan`). The bands along the bottom and right edges are left out, as -/// for the content (`contentOf`): a scrollbar moving is not the step's change. -pub fn columnPrints(px: []const dvui.Color.PMA, w: u32, h: u32, out: []u32) void { +/// A print of each column of a picture of `w` by `h` pixels, then of each row, into `out` (`w + h` +/// long): a hash of their pixels, so two pictures of the same size differ in a column or a row +/// exactly where their prints do (`history_transport.changedSpan`). The bands along the bottom and +/// right edges are left out, as for the content (`contentOf`): a scrollbar moving is not the step's +/// change. +pub fn prints(px: []const dvui.Color.PMA, w: u32, h: u32, out: []u32) void { @memset(out, 2166136261); - if (w == 0 or h == 0 or px.len < @as(usize, w) * h or out.len < w) return; + if (w == 0 or h == 0 or px.len < @as(usize, w) * h or out.len < w + h) return; const scan_w = w -| @as(u32, @intFromFloat(@ceil(@as(f32, @floatFromInt(w)) * edge_band))); const scan_h = h -| @as(u32, @intFromFloat(@ceil(@as(f32, @floatFromInt(h)) * edge_band))); - // Row by row, each column's print taking its pixel in turn: in memory order. + const cols = out[0..w]; + const rows = out[w..][0..h]; + // Row by row, each column's print taking its pixel in turn, and the row's each of its pixels: + // in memory order. for (0..scan_h) |y| { const row = px[y * w ..][0..scan_w]; - for (row, out[0..scan_w]) |p, *o| o.* = (o.* ^ (@as(u32, p.r) | @as(u32, p.g) << 8 | @as(u32, p.b) << 16 | @as(u32, p.a) << 24)) *% 16777619; + var r = rows[y]; + for (row, cols[0..scan_w]) |p, *o| { + const v = @as(u32, p.r) | @as(u32, p.g) << 8 | @as(u32, p.b) << 16 | @as(u32, p.a) << 24; + o.* = (o.* ^ v) *% 16777619; + r = (r ^ v) *% 16777619; + } + rows[y] = r; } } @@ -201,7 +270,8 @@ pub fn deinit(self: *HistoryFrames, gpa: std.mem.Allocator) void { // no window, and the GPU goes with the process. if (dvui.current_window != null) { for (self.frames.values()) |p| dvui.textureDestroyLater(p.tex); - if (self.unread) |u| u.target.destroyLater(); + if (self.unread) |u| u.small.destroyLater(); + if (self.whole) |t| t.destroyLater(); } for (self.frames.values()) |p| gpa.free(p.prints); self.frames.deinit(gpa); @@ -224,7 +294,7 @@ test "a picture's content is the box round what differs from its background" { try std.testing.expectEqual(c.x, contentOf(&px, 8, 4).?.x); } -test "a step's change is the span of columns where its picture differs from the one before" { +test "a step's change is the box of columns and rows where its picture differs from the one before" { const bg: dvui.Color.PMA = .{ .r = 30, .g = 30, .b = 34, .a = 255 }; const ink: dvui.Color.PMA = .{ .r = 220, .g = 180, .b = 120, .a = 255 }; const w = 40; @@ -233,22 +303,27 @@ test "a step's change is the span of columns where its picture differs from the // A mark already there, at the left. for (2..6) |y| before[y * w + 3] = ink; var after = before; - // The step draws a stroke across columns 20 to 25, on two rows. + // The step draws a stroke across columns 20 to 25, on rows 8 and 9. for (20..26) |x| for (8..10) |y| { after[y * w + x] = ink; }; - var pa: [w]u32 = undefined; - var pb: [w]u32 = undefined; - columnPrints(&before, w, h, &pa); - columnPrints(&after, w, h, &pb); - const span = rules.changedSpan(&pa, &pb).?; - try std.testing.expectEqual(@as(f32, 20.0 / 40.0), span.lo); - try std.testing.expectEqual(@as(f32, 26.0 / 40.0), span.hi); - // Nothing visible changed: no span, and the slice stays where it was. - try std.testing.expectEqual(@as(?rules.Span, null), rules.changedSpan(&pa, &pa)); + var pa: [w + h]u32 = undefined; + var pb: [w + h]u32 = undefined; + prints(&before, w, h, &pa); + prints(&after, w, h, &pb); + const cols = rules.changedSpan(pa[0..w], pb[0..w]).?; + try std.testing.expectEqual(@as(f32, 20.0 / 40.0), cols.lo); + try std.testing.expectEqual(@as(f32, 26.0 / 40.0), cols.hi); + const rows = rules.changedSpan(pa[w..], pb[w..]).?; + try std.testing.expectEqual(@as(f32, 8.0 / 20.0), rows.lo); + try std.testing.expectEqual(@as(f32, 10.0 / 20.0), rows.hi); + // Nothing visible changed: no span either way. + try std.testing.expectEqual(@as(?rules.Span, null), rules.changedSpan(pa[0..w], pa[0..w])); + try std.testing.expectEqual(@as(?rules.Span, null), rules.changedSpan(pa[w..], pa[w..])); // A change only in the right-hand band (a scrollbar) is not the step's. var bar = before; for (0..h) |y| bar[y * w + w - 1] = ink; - columnPrints(&bar, w, h, &pb); - try std.testing.expectEqual(@as(?rules.Span, null), rules.changedSpan(&pa, &pb)); + prints(&bar, w, h, &pb); + try std.testing.expectEqual(@as(?rules.Span, null), rules.changedSpan(pa[0..w], pb[0..w])); + try std.testing.expectEqual(@as(?rules.Span, null), rules.changedSpan(pa[w..], pb[w..])); } diff --git a/app/HistoryTransport.zig b/app/HistoryTransport.zig index 7e3fe58b4..4c209e2ed 100644 --- a/app/HistoryTransport.zig +++ b/app/HistoryTransport.zig @@ -34,9 +34,9 @@ //! set. One per document pane, kept by the app beside the pane. //! //! **The strip.** Given the document's pictures (`DrawOptions.frames`, `HistoryFrames`), scrubbing -//! (a drag on the line, a held undo or redo) grows the bar upward into a strip of them: one picture -//! of the history, each step a slice of it, under the knob that goes there (`paintStrip`). It -//! stays while the pointer is on the bar. +//! (a drag on the line, a held undo or redo) grows the bar upward into a strip of them: each step's +//! picture of where it changed the document, at its own pixels, under the knob that goes there +//! (`paintStrip`). It stays while the pointer is on the bar. //! //! **Export.** The bar has an export button whenever the app can export (`DrawOptions.export_from`). //! Pressed, it grows the strip and opens a window over it, as macOS trims a recording: a yellow @@ -103,6 +103,8 @@ const bar_h: f32 = 40; /// How much taller the bar grows for its strip. const strip_h: f32 = 66; const button_d: f32 = 34; +/// How far in from the track's ends the line, and the strip over it, start. +const line_pad: f32 = 14; const gap: f32 = 10; const max_w: f32 = 460; const tab_d = dome.tab_d; @@ -161,6 +163,23 @@ fn geometry(nat: dvui.Rect.Natural, grown: f32) ?dvui.Rect { return .{ .x = nat.x + nat.w / 2 - w / 2, .y = bottom - h, .w = w, .h = h }; } +/// The strip's size over `pane` (physical) fully grown, in physical pixels, at its widest (no +/// export button): what each step's picture is cut to (`HistoryFrames.take`), so the strip draws +/// it one pixel for one. Null when the pane is too narrow for the bar. +pub fn stripPixels(pane: dvui.Rect.Physical) ?[2]u32 { + const s = dvui.windowRectScale().s; + const whole = geometry(pane.toNatural(), 1) orelse return null; + // As `draw` lays it out: the track between the buttons, less the line's ends; the strip's + // height less its inset at the top and its gap at the bottom. + const w = (whole.w - 3 * (button_d + gap) - 2 * line_pad) * s; + const h = (strip_h - strip_top - 2) * s; + if (w < 1 or h < 1) return null; + // A pixel to spare each side: the strip's columns are rounded to whole pixels where `draw` + // places them, so one can come out a pixel wider than this, and a crop narrower than its + // column would leave glass beside it. + return .{ @intFromFloat(@ceil(w) + 2), @intFromFloat(@ceil(h) + 2) }; +} + /// The id `historyGoTo` takes for stop `i`: 0 before the first step, else the step's. fn stopId(line: sdk.Plugin.HistoryLine, i: usize) u64 { return if (i == 0) 0 else line.entries[i - 1].id; @@ -322,9 +341,8 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica const phys = [5]dvui.Rect.Physical{ wrs.rectToPhysical(back), wrs.rectToPhysical(track), wrs.rectToPhysical(fwd), wrs.rectToPhysical(share), wrs.rectToPhysical(play_r) }; const tab_p = wrs.rectToPhysical(tab); - const pad = 14; - const x0 = phys[1].x + pad * s; - const x1 = phys[1].x + phys[1].w - pad * s; + const x0 = phys[1].x + line_pad * s; + const x1 = phys[1].x + phys[1].w - line_pad * s; // Where a press goes to a step: the line's band, under the title, and the strip above it // while it shows. A press on the title or the capsule's ends is the bar's, and does nothing. const band_y = phys[1].y + phys[1].h - bar_h * s * 0.55; @@ -533,7 +551,7 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica const film_radius = @max(strip_radius * s, line_radius - film_top); if (grown > 0) if (opts.frames) |frames| paintStrip(film, film_radius, frames, line, at, s, grown); dvui.labelNoFmt(@src(), label, .{}, .{ - .rect = .{ .x = track.x + pad - bounds.x, .y = track.y + track.h - bar_h + 4 - bounds.y, .w = track.w - 2 * pad, .h = font.lineHeight() }, + .rect = .{ .x = track.x + line_pad - bounds.x, .y = track.y + track.h - bar_h + 4 - bounds.y, .w = track.w - 2 * line_pad, .h = font.lineHeight() }, .padding = .{}, .margin = .{}, .font = font, @@ -610,32 +628,20 @@ fn paintWindow(vessel: dvui.Rect.Physical, radius: f32, x0: f32, x1: f32, win: r } } -/// The strip `r` (physical), its ends where the line's are: one picture of the history, each stop -/// a slice of it (`history_transport.stripColumns`), under the knob that goes to it. A stop shows -/// its slice's width of the document as it was then, round where that step changed it -/// (`history_transport.sliceAt`), so across the strip the steps read as the actions themselves; a -/// step that changed nothing visible shows the stretch round where its knob sits. The strip -/// frames the document's content, as a recorder's strip frames what changed: the part of the -/// pictures round everything any of them holds (`HistoryFrames.Picture.content`), at the strip's -/// shape (`history_transport.frameContent`), so a few words at the top of a wide pane fill it -/// rather than empty pane. A stop with no picture shows the nearest that has one; none at all, -/// the stretch stays glass. Where the document is, `at`, a line across the strip says, as a -/// recorder's playhead does, over the middle of its stop's slice where the knob below it is. +/// The strip `r` (physical), its ends where the line's are: each stop's picture +/// (`HistoryFrames.Picture`) in its column (`history_transport.stripColumns`), under the knob that +/// goes to it. A picture is a crop of the document round where that step changed it, drawn one +/// pixel for one (`history_transport.oneToOne`), its middle in the column's, so across the strip +/// the steps read as the actions themselves, as crisp as the pane. A stop with no picture shows the +/// nearest that has one; none at all, the stretch stays glass. Where the document is, `at`, a line +/// across the strip says, as a recorder's playhead does, over the middle of its stop's column where +/// the knob below it is. fn paintStrip(r: dvui.Rect.Physical, radius: f32, frames: *const HistoryFrames, line: sdk.Plugin.HistoryLine, at: usize, s: f32, grown: f32) void { if (r.h < 4 * s or r.w < 4 * s) return; const n = line.entries.len; var buf: [256]rules.Column = undefined; const columns = rules.stripColumns(n, r.x, r.x + r.w, 2 * s, &buf); const ink = dvui.themeGet().color(.window, .text); - // Round what the pictures shown hold, in fractions of a picture (they share the pane's shape). - var boxes: [256]rules.Frac = undefined; - var nb: usize = 0; - for (columns) |c| if (nearestPicture(frames, line, c.stop)) |pic| if (pic.content) |pc| { - boxes[nb] = pc; - nb += 1; - }; - const content = rules.contentUnion(boxes[0..nb]); - const want = r.w / r.h; for (columns, 0..) |c, i| { // On whole pixels, so neighbouring slices meet edge to edge: a fractional edge is half // covered on both sides, and the glass shows through the join as a seam. @@ -651,23 +657,19 @@ fn paintStrip(r: dvui.Rect.Physical, radius: f32, frames: *const HistoryFrames, .tr = if (i == columns.len - 1) end else square, .br = if (i == columns.len - 1) end else square, }; + const plain: dvui.Color = ink.opacity(0.06 * grown); const pic = nearestPicture(frames, line, c.stop) orelse { - col.fill(corners, .{ .color = .{ .color = ink.opacity(0.06 * grown) } }); + col.fill(corners, .{ .color = .{ .color = plain } }); continue; }; - const tex = pic.tex; - // The part of the picture the strip frames, round the content; this column's stretch of it. - const ta = @as(f32, @floatFromInt(tex.width)) / @as(f32, @floatFromInt(@max(1, tex.height))); - const cover = rules.frameContent(content, ta, want, content_pad, content_closest); - const fa = (cx0 - r.x) / r.w; - const fb = (cx1 - r.x) / r.w; - // Where the step changed something, when it did (`HistoryFrames.Picture.change`): the - // strip reads as the steps themselves, each slice its own action. - const sw = (fb - fa) * cover.w; - // Only its own picture's change: a stop borrowing a neighbour's shows its stretch of it. - const change = if (frames.get(stopId(line, c.stop))) |_| pic.change else null; - const uv: dvui.Rect = .{ .x = rules.sliceAt(cover.x + fa * cover.w, sw, change), .y = cover.y, .w = sw, .h = cover.h }; - dvui.renderTexture(tex, .{ .r = col, .s = s }, .{ .uv = uv, .corners = corners.scale(1 / s, dvui.CornerRect), .colormod = dvui.Color.white.opacity(grown) }) catch {}; + const fit = rules.oneToOne(.{ .x = col.x, .y = col.y, .w = col.w, .h = col.h }, .{ pic.tex.width, pic.tex.height }) orelse continue; + // A picture smaller than its column (taken from a narrower pane): the rest plain. + if (fit.dest.w < col.w or fit.dest.h < col.h) col.fill(corners, .{ .color = .{ .color = plain } }); + const dest: dvui.Rect.Physical = .{ .x = fit.dest.x, .y = fit.dest.y, .w = fit.dest.w, .h = fit.dest.h }; + // The strip's round ends only where the picture reaches them. + const fills = dest.x == col.x and dest.w == col.w and dest.y == col.y and dest.h == col.h; + const uv: dvui.Rect = .{ .x = fit.uv.x, .y = fit.uv.y, .w = fit.uv.w, .h = fit.uv.h }; + dvui.renderTexture(pic.tex, .{ .r = dest, .s = s }, .{ .uv = uv, .corners = (if (fills) corners else dvui.CornerRect.Physical{ .tl = square, .bl = square, .tr = square, .br = square }).scale(1 / s, dvui.CornerRect), .colormod = dvui.Color.white.opacity(grown) }) catch {}; } // The playhead, standing a little past the strip's top and bottom, with a dark edge so it reads // over any picture. @@ -678,11 +680,6 @@ fn paintStrip(r: dvui.Rect.Physical, radius: f32, frames: *const HistoryFrames, head.fill(.round(1 * s), .{ .color = .{ .color = dvui.themeGet().color(.highlight, .fill).opacity(grown) } }); } -/// How much room the strip leaves round the content, as a share of it each side; and how close it -/// comes, as the least share of a picture's height it shows (a single word is not blown up). -const content_pad: f32 = 0.12; -const content_closest: f32 = 0.15; - /// The trim bracket's gap to what it holds, the width of its yellow and of its handles, in points; /// the strip's own corner radius; and how far past a handle, either way, a press still takes it /// (`Trim.grab`): inside that, a press on the strip is a seek. diff --git a/app/history_transport.zig b/app/history_transport.zig index bb4a5f22a..996d2fc19 100644 --- a/app/history_transport.zig +++ b/app/history_transport.zig @@ -226,9 +226,9 @@ pub fn stripColumns(n: usize, x0: f32, x1: f32, min_w: f32, out: []Column) []Col /// A stretch across a picture, as fractions of its width: from `lo` to `hi`. pub const Span = struct { lo: f32, hi: f32 }; -/// Where two pictures of the same width differ, given each column's print (`HistoryFrames.columnPrints`): -/// from the first column whose prints differ to past the last. Null when none do, or they are not -/// the same width. +/// Where two pictures of the same size differ, given each column's print, or each row's +/// (`HistoryFrames.prints`): from the first whose prints differ to past the last. Null when none +/// do, or they are not the same size. pub fn changedSpan(before: []const u32, after: []const u32) ?Span { if (before.len != after.len or after.len == 0) return null; var lo: usize = after.len; @@ -242,69 +242,42 @@ pub fn changedSpan(before: []const u32, after: []const u32) ?Span { return .{ .lo = @as(f32, @floatFromInt(lo)) / n, .hi = @as(f32, @floatFromInt(hi)) / n }; } -/// Where a stop's slice of its picture starts, as a fraction across it, the slice `w` of it wide: -/// at `x`, its place in the strip, for a step with no `change` (`HistoryFrames.Picture.change`); -/// else round the middle of what the step changed, so the strip shows each step's own action. -/// Kept inside the picture either way. -pub fn sliceAt(x: f32, w: f32, change: ?f32) f32 { - const c = change orelse return x; - return std.math.clamp(c - w / 2, 0, @max(0, 1 - w)); -} - /// A part of a picture, as fractions of it: 0 to 1 across and down. pub const Frac = struct { x: f32 = 0, y: f32 = 0, w: f32 = 1, h: f32 = 1 }; -/// The part of a picture `pic_aspect` wide for each high that a strip `want` wide for each high -/// shows (`paintStrip`): round `content` (the whole picture without one), padded by `pad` of its -/// size each side, grown to the strip's shape, and never closer in than `min` of the picture's -/// height. Kept inside the picture, its middle where the content's is as far as that allows. A -/// picture of another shape than the strip, its content all of it, is covered round its middle. -pub fn frameContent(content: ?Frac, pic_aspect: f32, want: f32, pad: f32, min: f32) Frac { - const a = @max(pic_aspect, 1e-3); - const c = content orelse Frac{}; - // In the picture's own units: `a` across, 1 down. - const cw = c.w * a * (1 + 2 * pad); - const ch = c.h * (1 + 2 * pad); - var h = @max(@max(ch, cw / want), min); - var w = h * want; - if (w > a) { - w = a; - h = w / want; - } - if (h > 1) { - h = 1; - w = want; +/// A rect of whole pixels. +pub const Px = struct { x: u32 = 0, y: u32 = 0, w: u32 = 0, h: u32 = 0 }; + +/// The part of a picture `source` pixels big kept for a step's slice of the strip +/// (`HistoryFrames`): `size` pixels, the strip's own, its middle at `centre` (pixels) as far as the +/// picture allows. A picture smaller than `size` either way is kept whole that way. +pub fn cropAt(centre: [2]f32, size: [2]u32, source: [2]u32) Px { + var out: Px = .{ .w = @min(size[0], source[0]), .h = @min(size[1], source[1]) }; + inline for (.{ "x", "y" }, .{ "w", "h" }, 0..) |p, d, i| { + const room: f32 = @floatFromInt(source[i] - @field(out, d)); + const at = @round(centre[i] - @as(f32, @floatFromInt(@field(out, d))) / 2); + @field(out, p) = @intFromFloat(std.math.clamp(at, 0, room)); } - const mx = (c.x + c.w / 2) * a; - const my = c.y + c.h / 2; - const x = std.math.clamp(mx - w / 2, 0, a - w); - const y = std.math.clamp(my - h / 2, 0, 1 - h); - return .{ .x = x / a, .y = y, .w = w / a, .h = h }; + return out; } -/// What the strip frames of `boxes`, each one picture's content: the box round them all, leaving -/// out any far bigger than most (four times the middle one's area), which is something passing -/// over the pane when that picture was taken (the activity feed, a menu), not the document. Null -/// for none. -pub fn contentUnion(boxes: []Frac) ?Frac { - if (boxes.len == 0) return null; - std.mem.sort(Frac, boxes, {}, struct { - fn smaller(_: void, a: Frac, b: Frac) bool { - return a.w * a.h < b.w * b.h; - } - }.smaller); - const median = boxes[boxes.len / 2]; - const most = 4 * median.w * median.h; - var u: ?Frac = null; - for (boxes) |b| { - if (b.w * b.h > most) break; - u = if (u) |v| blk: { - const x0 = @min(v.x, b.x); - const y0 = @min(v.y, b.y); - break :blk Frac{ .x = x0, .y = y0, .w = @max(v.x + v.w, b.x + b.w) - x0, .h = @max(v.y + v.h, b.y + b.h) - y0 }; - } else b; - } - return u; +/// A rect in pixels, as `oneToOne` takes and gives it. +pub const Rect = struct { x: f32, y: f32, w: f32, h: f32 }; + +/// Where a picture `pic` pixels big is drawn in `cell` (pixels) at one pixel for one, no closer +/// and no further: its middle on the cell's, cut to the cell (`dest`, on whole pixels), and the +/// part of the picture that shows there (`uv`, fractions of it). Null for an empty picture or cell. +pub fn oneToOne(cell: Rect, pic: [2]u32) ?struct { dest: Rect, uv: Frac } { + if (pic[0] == 0 or pic[1] == 0 or cell.w < 1 or cell.h < 1) return null; + const pw: f32 = @floatFromInt(pic[0]); + const ph: f32 = @floatFromInt(pic[1]); + const w = @min(@floor(cell.w), pw); + const h = @min(@floor(cell.h), ph); + const x = @round(cell.x + (cell.w - w) / 2); + const y = @round(cell.y + (cell.h - h) / 2); + const u = @round((pw - w) / 2); + const v = @round((ph - h) / 2); + return .{ .dest = .{ .x = x, .y = y, .w = w, .h = h }, .uv = .{ .x = u / pw, .y = v / ph, .w = w / pw, .h = h / ph } }; } const testing = std.testing; @@ -414,60 +387,43 @@ test "each stop takes the stretch of the strip round its knob, or columns share try testing.expectEqual(@as(f32, 50), none[0].x1); } -test "the strip frames the content, at the strip's shape, inside the picture" { - const eq = struct { - fn eq(want: Frac, got: Frac) !void { - try testing.expectApproxEqAbs(want.x, got.x, 1e-4); - try testing.expectApproxEqAbs(want.y, got.y, 1e-4); - try testing.expectApproxEqAbs(want.w, got.w, 1e-4); - try testing.expectApproxEqAbs(want.h, got.h, 1e-4); - } - }.eq; - // No content known: the picture covers the strip round its middle. - try eq(.{ .x = 0, .y = 0.25, .w = 1, .h = 0.5 }, frameContent(null, 2, 4, 0.1, 0.3)); - // A few words at the top left: framed close, as close as `min` allows, held to the corner. - try eq(.{ .x = 0, .y = 0, .w = 0.6, .h = 0.3 }, frameContent(.{ .x = 0, .y = 0, .w = 0.2, .h = 0.1 }, 2, 4, 0.1, 0.3)); - // Content in the middle stays in the middle. - const mid = frameContent(.{ .x = 0.4, .y = 0.4, .w = 0.2, .h = 0.2 }, 2, 4, 0, 0.1); - try testing.expectApproxEqAbs(@as(f32, 0.5), mid.x + mid.w / 2, 1e-4); - try testing.expectApproxEqAbs(@as(f32, 0.5), mid.y + mid.h / 2, 1e-4); - // Always the strip's shape, in the picture's units. - for ([_]?Frac{ null, .{ .x = 0.1, .y = 0.7, .w = 0.05, .h = 0.05 }, .{ .x = 0, .y = 0, .w = 1, .h = 1 } }) |c| { - const f = frameContent(c, 2, 4, 0.1, 0.2); - try testing.expectApproxEqAbs(@as(f32, 4), f.w * 2 / f.h, 1e-3); - try testing.expect(f.x >= 0 and f.y >= 0 and f.x + f.w <= 1 + 1e-4 and f.y + f.h <= 1 + 1e-4); - } -} - -test "the strip's content leaves out a picture of something passing over the pane" { - var boxes = [_]Frac{ - .{ .x = 0, .y = 0, .w = 0.1, .h = 0.03 }, .{ .x = 0, .y = 0, .w = 0.2, .h = 0.03 }, - .{ .x = 0, .y = 0, .w = 0.9, .h = 0.9 }, .{ .x = 0, .y = 0, .w = 0.25, .h = 0.03 }, - }; - const u = contentUnion(&boxes).?; - try testing.expectApproxEqAbs(@as(f32, 0.25), u.w, 1e-5); - try testing.expectApproxEqAbs(@as(f32, 0.03), u.h, 1e-5); - // All of them big: the document fills the pane, and the strip frames it all. - var big = [_]Frac{ .{ .w = 0.8, .h = 0.9 }, .{ .w = 0.9, .h = 0.9 } }; - try testing.expectApproxEqAbs(@as(f32, 0.9), contentUnion(&big).?.w, 1e-5); - try testing.expectEqual(@as(?Frac, null), contentUnion(&.{})); -} - -test "a step's slice stands round what it changed, inside the picture" { - // No change: where the strip puts it. - try testing.expectEqual(@as(f32, 0.3), sliceAt(0.3, 0.1, null)); - // Round the change's middle. - try testing.expectApproxEqAbs(@as(f32, 0.65), sliceAt(0.3, 0.1, 0.7), 1e-6); - // Near an edge: held inside the picture. - try testing.expectEqual(@as(f32, 0), sliceAt(0.3, 0.1, 0.02)); - try testing.expectApproxEqAbs(@as(f32, 0.9), sliceAt(0.3, 0.1, 0.99), 1e-6); - // A change wider than a slice: its middle (from `changedSpan`). +test "a step's change is where its prints differ" { const before = [_]u32{ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }; const after = [_]u32{ 1, 2, 2, 2, 2, 2, 2, 1, 1, 1 }; const span = changedSpan(&before, &after).?; try testing.expectApproxEqAbs(@as(f32, 0.1), span.lo, 1e-6); try testing.expectApproxEqAbs(@as(f32, 0.7), span.hi, 1e-6); - try testing.expectApproxEqAbs(@as(f32, 0.35), sliceAt(0.8, 0.1, (span.lo + span.hi) / 2), 1e-6); - // Pictures of different widths are not compared. + try testing.expectEqual(@as(?Span, null), changedSpan(&before, &before)); + // Pictures of different sizes are not compared. try testing.expectEqual(@as(?Span, null), changedSpan(&before, after[0..9])); } + +test "a step's crop is the strip's size round its change, inside the picture" { + // Round the change. + try testing.expectEqual(Px{ .x = 450, .y = 248, .w = 600, .h = 104 }, cropAt(.{ 750, 300 }, .{ 600, 104 }, .{ 2000, 1200 })); + // Near an edge: held inside the picture. + try testing.expectEqual(Px{ .x = 0, .y = 0, .w = 600, .h = 104 }, cropAt(.{ 10, 5 }, .{ 600, 104 }, .{ 2000, 1200 })); + try testing.expectEqual(Px{ .x = 1400, .y = 1096, .w = 600, .h = 104 }, cropAt(.{ 1990, 1199 }, .{ 600, 104 }, .{ 2000, 1200 })); + // A picture smaller than the strip: all of it that way. + try testing.expectEqual(Px{ .x = 0, .y = 48, .w = 400, .h = 104 }, cropAt(.{ 100, 100 }, .{ 600, 104 }, .{ 400, 300 })); +} + +test "a crop is drawn one pixel for one, centred in its cell and cut to it" { + // A narrow cell: the middle of the picture, as wide as the cell, on whole pixels. + const a = oneToOne(.{ .x = 100.4, .y = 20, .w = 30, .h = 104 }, .{ 600, 104 }).?; + try testing.expectEqual(@as(f32, 30), a.dest.w); + try testing.expectEqual(@as(f32, 104), a.dest.h); + try testing.expectEqual(@as(f32, 100), a.dest.x); + try testing.expectApproxEqAbs(@as(f32, 285.0 / 600.0), a.uv.x, 1e-6); + // The pixels it shows are as many as it is drawn over: never scaled. + try testing.expectApproxEqAbs(a.dest.w, a.uv.w * 600, 1e-4); + try testing.expectApproxEqAbs(a.dest.h, a.uv.h * 104, 1e-4); + // A cell bigger than the picture (a wider pane than it was taken from) or shorter (the strip + // still growing): the picture at its own size in the middle, cut to the cell. + const b = oneToOne(.{ .x = 0, .y = 0, .w = 800, .h = 50 }, .{ 600, 104 }).?; + try testing.expectEqual(@as(f32, 100), b.dest.x); + try testing.expectEqual(@as(f32, 600), b.dest.w); + try testing.expectEqual(@as(f32, 50), b.dest.h); + try testing.expectApproxEqAbs(@as(f32, 27.0 / 104.0), b.uv.y, 1e-6); + try testing.expect(oneToOne(.{ .x = 0, .y = 0, .w = 0, .h = 50 }, .{ 600, 104 }) == null); +} diff --git a/backend/src/glass/FrameTarget.zig b/backend/src/glass/FrameTarget.zig index a8a2360e0..f5ec84d56 100644 --- a/backend/src/glass/FrameTarget.zig +++ b/backend/src/glass/FrameTarget.zig @@ -264,6 +264,22 @@ pub fn snapshot(rect: dvui.Rect.Physical, size: ?[2]u32) ?dvui.Texture { /// or `destroyLater`): for a caller that reads its pixels back (`dvui.Texture.readTarget`), which /// it can do from the next frame, once the copy has landed. pub fn snapshotTarget(rect: dvui.Rect.Physical, size: ?[2]u32) ?dvui.TextureTarget { + const src, const r = lastFrame(rect) orelse return null; + const w: u32, const h: u32 = size orelse .{ @intFromFloat(@round(r.w)), @intFromFloat(@round(r.h)) }; + return copyRect(src, r, .{ w, h }, .linear); +} + +/// `snapshotTarget` into `out`, a target the caller keeps and draws into again (`HistoryFrames`' +/// copy of a pane, taken at each step of a held undo): `rect` scaled to `out`'s size. False when +/// there is no last frame to copy (see `snapshot`). +pub fn snapshotInto(rect: dvui.Rect.Physical, out: dvui.TextureTarget) bool { + const src, const r = lastFrame(rect) orelse return false; + copyInto(src, r, out); + return true; +} + +/// The last frame shown, and `rect` (window pixels) cut to it; null as `snapshot` says. +fn lastFrame(rect: dvui.Rect.Physical) ?struct { dvui.Texture, dvui.Rect.Physical } { const self = current orelse return null; // The last frame shown: the other target while one is bound; in a frame nobody will see, the // one bound last (`begin` leaves the targets as they were). @@ -272,11 +288,26 @@ pub fn snapshotTarget(rect: dvui.Rect.Physical, size: ?[2]u32) ?dvui.TextureTarg const prev = self.targets[last] orelse return null; const r = rect.intersect(dvui.windowRectPixels()); if (r.w < 1 or r.h < 1) return null; - const w: u32, const h: u32 = size orelse .{ @intFromFloat(@round(r.w)), @intFromFloat(@round(r.h)) }; - if (w == 0 or h == 0) return null; const src = dvui.Texture.fromTargetTemp(prev) catch return null; - const out = dvui.textureCreateTarget(.{ .width = w, .height = h, .interpolation = .linear }) catch return null; + return .{ src, r }; +} + +/// `r` (`src`'s pixels) of `src`, copied into a new target `size` pixels (the caller's, as +/// `snapshotTarget`'s), which draws with `interpolation`. The copy itself samples `src` as `src` +/// does: at `r`'s own size on whole pixels it is `src`'s pixels exactly (a crop of a pane taken +/// whole, `HistoryFrames`). Null when `size` is empty or the backend has no targets. +pub fn copyRect(src: dvui.Texture, r: dvui.Rect.Physical, size: [2]u32, interpolation: dvui.enums.TextureInterpolation) ?dvui.TextureTarget { + const w, const h = size; + if (w == 0 or h == 0) return null; + const out = dvui.textureCreateTarget(.{ .width = w, .height = h, .interpolation = interpolation }) catch return null; + copyInto(src, r, out); + return out; +} +/// Draw `r` (`src`'s pixels) of `src` over the whole of `out`, replacing what it held. +fn copyInto(src: dvui.Texture, r: dvui.Rect.Physical, out: dvui.TextureTarget) void { + const w = out.width; + const h = out.height; const prev_rendering = dvui.renderingSet(true); defer _ = dvui.renderingSet(prev_rendering); const prev_alpha = dvui.currentWindow().alpha; @@ -293,13 +324,12 @@ pub fn snapshotTarget(rect: dvui.Rect.Physical, size: ?[2]u32) ?dvui.TextureTarg dvui.clipSet(dest); const sw: f32 = @floatFromInt(src.width); const sh: f32 = @floatFromInt(src.height); - const copy = if (dvui.Backend.support_texture_blend) blk: { + const blended = if (dvui.Backend.support_texture_blend) blk: { dvui.currentWindow().backend.textureBlend(src, .copy) catch break :blk false; break :blk true; } else false; - defer if (copy) dvui.currentWindow().backend.textureBlend(src, .over) catch {}; + defer if (blended) dvui.currentWindow().backend.textureBlend(src, .over) catch {}; dvui.renderTexture(src, .{ .r = dest, .s = 1 }, .{ .uv = .{ .x = r.x / sw, .y = r.y / sh, .w = r.w / sw, .h = r.h / sh } }) catch {}; } _ = dvui.renderTarget(was); - return out; } diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 6c35aabd4..1266dc109 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -3148,7 +3148,7 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { const pane = canvas.data().borderRectScale().r; // Above the bar as it stands, its strip and the feed over it included. const under = if (ds.transport.place(pane, dvui.frameTimeNS())) |bar| pane.y + pane.h - bar.y + 40 * dvui.windowRectScale().s else 0; - ds.frames.take(editor.app.gpa, pane, @max(76 * dvui.windowRectScale().s, under)); + ds.frames.take(editor.app.gpa, pane, @max(76 * dvui.windowRectScale().s, under), @import("app").HistoryTransport.stripPixels(pane)); } const focus_before = dvui.lastFocusedIdInFrame();