Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 100 additions & 27 deletions app/layout/Layout.zig
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ extents_changed: bool = false,
/// Every surface `draw` ran this frame, so `captureUnplaced` knows which ones genuinely drew
/// nowhere — as opposed to drew before the picker asked. Arena-backed, per frame.
drawn: std.ArrayListUnmanaged(*Surface) = .empty,
/// Surfaces drawn this frame in a place with no room, so not captured (`drawCaptured`): no frame
/// is asked for them until they have some. Arena-backed, per frame.
unsized: std.ArrayListUnmanaged(*Surface) = .empty,

/// Open regions, innermost last. Every region pushes; its `deinit` pops. This is what lets
/// `split` know which axis it divides and which neighbour it resizes, without a shape having to
Expand Down Expand Up @@ -667,7 +670,8 @@ fn pick(items: []const *Surface, current: ?[]const u8) ?*Surface {
///
/// While the picker is collecting (`State.snapshots_wanted`), a surface without a snapshot is
/// drawn through a texture target and the result both kept and put on screen — the pixels are
/// the same ones, so nothing blinks, and the surface still ran exactly once.
/// the same ones, so nothing blinks, and the surface still ran exactly once. Not once capture has
/// failed for it, or here at all (`State.captureFailed`, `State.capture_unavailable`).
pub fn draw(self: *Layout, s: *Surface) !dvui.App.Result {
var hasher = std.hash.Wyhash.init(0);
hasher.update(s.id);
Expand All @@ -683,32 +687,46 @@ pub fn draw(self: *Layout, s: *Surface) !dvui.App.Result {
/// `draw` without the fade-in: for a swap, whose overlay already carries the change.
fn drawSolid(self: *Layout, s: *Surface) !dvui.App.Result {
self.drawn.append(self.arena, s) catch {};
if (self.state.snapshots_wanted and self.state.snapshot(s.id) == null) {
if (self.state.snapshots_wanted and !self.state.capture_unavailable and self.state.snapshot(s.id) == null and !self.state.captureFailed(s.id)) {
if (try self.drawCaptured(s)) |r| return r;
}
return surfaceDraw(s);
}

/// `s.draw` into a texture the size of the current parent's content, then that texture onto the
/// screen where the surface would have drawn. Null when there is nothing to capture into — a
/// zero-sized parent, or a backend without render targets — and the caller draws normally.
/// zero-sized parent, or a backend without render targets — and the caller draws normally. A
/// failure is remembered for the picker's session (`State.recordCaptureFailed`), so it is not
/// tried every frame.
///
/// Through `dvui.Picture` rather than a bare `renderTarget` switch: dvui defers part of its
/// drawing (strokes after fills, subwindow content) to queues flushed later, and a bare switch
/// hands those to the screen after the target is gone. `Picture` installs its own queues and
/// flushes them inside `stop`, which is the difference between a snapshot of a scroll area and
/// a snapshot of its background.
fn drawCaptured(self: *Layout, s: *Surface) !?dvui.App.Result {
const prof = core.profile.begin("fizzy", "picker: capture");
defer prof.end();
const rs = dvui.parentGet().data().contentRectScale();
var pic = dvui.Picture.start(rs.r) orelse return null;
if (rs.r.empty()) {
self.unsized.append(self.arena, s) catch {};
return null;
}
var pic = dvui.Picture.start(rs.r) orelse {
self.state.recordCaptureFailed(self.gpa, s.id);
return null;
};
// Some backends leave a fresh target uninitialised; the cross-fade learned this the hard way.
pic.texture.clear();
const old_clip = dvui.clipGet();
dvui.clipSet(pic.r);
const result = surfaceDraw(s);
dvui.clipSet(old_clip);
pic.stop();
const texture = dvui.textureFromTarget(pic.texture) catch return try result;
const texture = dvui.textureFromTarget(pic.texture) catch {
self.state.recordCaptureFailed(self.gpa, s.id);
return try result;
};
dvui.renderTexture(texture, .{ .r = pic.r, .s = rs.s }, .{}) catch {};
self.state.takeSnapshot(self.gpa, s.id, .{ .texture = texture, .natural = .{ .w = pic.r.w / rs.s, .h = pic.r.h / rs.s } });
return try result;
Expand Down Expand Up @@ -737,26 +755,58 @@ pub fn closeFloat(self: *Layout, name: []const u8) void {
Floats.close(self, name, .home);
}

/// Snapshot every surface that drew nowhere this frame, by drawing each once offscreen at a
/// fixed size. Only while the picker is collecting and only for surfaces still missing a
/// snapshot, so a frame with nothing to do costs a lookup. The application calls this after
/// its shape has run, from the base window.
/// Draw the view the picker chose for a picture (`State.offscreen`, picked from the cards it
/// shows) offscreen at a fixed size, one draw a frame: a few frames of warm-up, then its capture.
/// Only while the picker is collecting, so a frame with nothing to do costs a check. The
/// application calls this after its shape has run and before the picker draws, from the base
/// window: what the view opens (a popup, a float) stacks under the picker, and a picture taken
/// here shows on its card the same frame.
///
/// Only a view that drew nowhere this frame (`drawn`) is drawn here: one on screen is captured
/// where it lives (`draw`), and an offscreen picture of the workspace is a picture of an empty box.
///
/// "Drew nowhere" is decided by `drawn`, not by the missing snapshot: the picker opens
/// mid-frame, after some regions have already run, and those surfaces must be captured where
/// they live on the *next* frame rather than photographed offscreen now — an offscreen
/// picture of the workspace is a picture of an empty box.
/// Offscreen, a view costs what it draws: its first draw lays out a whole subtree from nothing,
/// and its capture is the one draw that renders any of it (the warm-up draws are clipped away).
/// The picker opening used to pay both for every view, each in one frame. One at a time, a view
/// scrolled or filtered out of the list is never drawn for it, and a card waiting for its picture
/// shows an empty tile.
pub fn captureUnplaced(self: *Layout) void {
if (!self.state.snapshots_wanted) return;
const state = self.state;
if (!state.snapshots_wanted or state.capture_unavailable) return;
const prof = core.profile.begin("fizzy", "picker: capture unplaced");
defer prof.end();
// A view on screen without its picture (it drew before the picker opened, this frame) is
// captured where it lives on the next. Not one whose capture failed, or that has no room.
var pending = false;
outer: for (self.host.surfaces.items, 0..) |*s, i| {
if (s.hidden or self.state.snapshot(s.id) != null) continue;
for (self.drawn.items) |d| if (d == s) {
pending = true; // drew this frame before the request: in-place capture next frame
continue :outer;
};
for (self.drawn.items) |d| {
if (state.snapshot(d.id) != null or state.captureFailed(d.id)) continue;
for (self.unsized.items) |u| {
if (u == d) break;
} else pending = true;
}

if (state.offscreen) |*o| step: {
const surfaces = self.host.surfaces.items;
// Where the picker found it, unless the registry changed since: then wherever it is now.
if (o.index >= surfaces.len or std.hash.Wyhash.hash(0, surfaces[o.index].id) != o.id) {
o.index = for (surfaces, 0..) |c, i| {
if (std.hash.Wyhash.hash(0, c.id) == o.id) break i;
} else {
state.offscreen = null;
break :step;
};
}
const s = &surfaces[o.index];
// Its card left the list before it got far (filtered out, scrolled away): the list drew
// since without it. Dropped, to be chosen again if the card comes back; further in, it is
// finished.
const out_of_view = o.card_ns < state.picker.list_ns and o.warm < offscreen_warmup_frames / 2;
if (out_of_view or !self.mayDrawOffscreen(s)) {
state.offscreen = null;
break :step;
}
var box = dvui.box(@src(), .{ .dir = .vertical }, .{
.id_extra = i,
.id_extra = @truncate(o.id),
.rect = .{ .x = -20_000, .y = -20_000, .w = offscreen_capture.w, .h = offscreen_capture.h },
.background = true,
.color_fill = .{ .color = dvui.themeGet().color(.content, .fill) },
Expand All @@ -765,15 +815,21 @@ pub fn captureUnplaced(self: *Layout) void {
// A subtree drawn for the first time is not yet what it will look like: dvui lays it
// out from last frame's sizes, which it has none of, and anything inside that reveals
// itself starts hidden. So it is drawn unphotographed for a few frames first — the same
// settle-then-show the reveal does on screen, just without an audience.
const warm = dvui.dataGet(null, box.data().id, "_warm", u8) orelse 0;
if (warm < offscreen_warmup_frames) {
// settle-then-show the reveal does on screen, just without an audience: a few frames, and
// the reveal's whole fade, however fast the frames come.
const now = dvui.frameTimeNS();
if (o.warm == 0) o.since_ns = now;
if (o.warm < offscreen_warmup_frames or now - o.since_ns < offscreenWarmupNs()) {
_ = surfaceDraw(s) catch {};
dvui.dataSet(null, box.data().id, "_warm", warm + 1);
o.warm +|= 1;
pending = true;
continue;
break :step;
}
_ = self.drawCaptured(s) catch null;
// Taken or failed (`drawCaptured` remembers which), it is done: the picker, drawing next,
// shows the picture and chooses the next view.
const started = if (self.drawCaptured(s)) |r| r != null else |_| true;
state.noteOffscreenStart(started);
state.offscreen = null;
}
if (pending) dvui.refresh(null, @src(), null);
}
Expand All @@ -784,6 +840,23 @@ const offscreen_capture: dvui.Size = .{ .w = 360, .h = 480 };
/// Long enough for a 120 ms reveal to finish at 60 fps, with a little to spare.
const offscreen_warmup_frames: u8 = 10;

/// Whether surface `s` may be drawn offscreen for its picture (`captureUnplaced`, chosen by
/// `Picker.draw`): capture works here, it has no picture and no failed capture, it is not hidden,
/// and it drew nowhere this frame — one on screen is captured where it lives, never drawn twice.
pub fn mayDrawOffscreen(self: *const Layout, s: *const Surface) bool {
const state = self.state;
if (state.capture_unavailable or s.hidden) return false;
if (state.snapshot(s.id) != null or state.captureFailed(s.id)) return false;
for (self.drawn.items) |d| if (d == s) return false;
return true;
}

/// How long the warm-up lasts at the least: a reveal's fade (`core.anim.reveal`) at the
/// interface's speed (`core.motion`), so a picture is never taken mid-fade on a fast display.
fn offscreenWarmupNs() i128 {
return @as(i128, core.motion.duration((core.anim.RevealOptions{}).duration_micros)) * std.time.ns_per_us;
}

/// Draw whichever surface is selected for these keywords, into the current parent. Everything
/// it does is reachable through `matching` / `selected` / `draw`; `region` uses it to fill a
/// declared region's space.
Expand Down
26 changes: 26 additions & 0 deletions app/layout/Picker.zig
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ anchor: ?dvui.Point.Natural = null,
/// `ViewDrag`. The popup is closed; `draw` drives the drag instead until the
/// button comes up.
lifted: bool = false,
/// When the list last laid its cards out (`dvui.frameTimeNS`): a view being drawn for its picture
/// whose card was not in view then has left the list (`Layout.captureUnplaced`). A draw that
/// returned before the list leaves it alone.
list_ns: i128 = 0,

/// Card geometry, in points. The preview keeps the snapshot's aspect inside this box.
const preview: dvui.Size = .{ .w = 150, .h = 100 };
Expand Down Expand Up @@ -77,6 +81,8 @@ pub fn close(self: *Picker, gpa: std.mem.Allocator) void {
pub fn draw(self: *Picker, f: *Layout) void {
if (self.lifted) return self.drawLifted(f);
if (!self.is_open) return;
const prof = core.profile.begin("fizzy", "picker");
defer prof.end();
const state = f.state;
const gpa = f.gpa;

Expand Down Expand Up @@ -273,6 +279,7 @@ pub fn draw(self: *Picker, f: *Layout) void {
defer core.widgets.scrollShadows(scroll);

var grid = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal });
self.list_ns = dvui.frameTimeNS();
defer grid.deinit();

var row: ?*dvui.BoxWidget = null;
Expand Down Expand Up @@ -525,6 +532,24 @@ const Hit = union(enum) {
lifted: struct { tile: dvui.Rect.Physical, event_num: u16 },
};

/// Choose surface `s` (at `index` in the registry) to be drawn offscreen for its picture
/// (`State.offscreen`, `Layout.captureUnplaced`) when none is: its card in view, as the list laid
/// it out, and the view one that may be (`Layout.mayDrawOffscreen`). Not on the card's first
/// frame, before it has a size: every card then packs into the top of the list. The card of the
/// view already chosen stamps that it is still in view (`card_ns`).
fn nextOffscreen(f: *Layout, s: *const sdk.Surface, index: usize, card_data: *dvui.WidgetData) void {
const state = f.state;
if (dvui.firstFrame(card_data.id) or card_data.borderRectScale().r.intersect(dvui.clipGet()).empty()) return;
const now = dvui.frameTimeNS();
if (state.offscreen) |*o| {
if (o.index == index) o.card_ns = now;
return;
}
if (!f.mayDrawOffscreen(s)) return;
state.offscreen = .{ .index = index, .id = std.hash.Wyhash.hash(0, s.id), .card_ns = now };
dvui.refresh(null, @src(), null);
}

/// One surface: its snapshot scaled to fit, its title, its owner, and a highlight when it is in
/// the region. A press that moves far enough lifts the card into a drag instead of clicking.
fn card(f: *Layout, s: *const sdk.Surface, on: bool, id_extra: usize) Hit {
Expand All @@ -542,6 +567,7 @@ fn card(f: *Layout, s: *const sdk.Surface, on: bool, id_extra: usize) Hit {
});
defer bw.deinit();
const id = bw.data().id;
nextOffscreen(f, s, id_extra, bw.data());
// For demo tapes (`docs/AUTOMATION.md`): the card, to lift a view out of the picker by.
core.anchor.mark(bw.data(), "picker:{s}", .{s.id});

Expand Down
45 changes: 44 additions & 1 deletion app/layout/State.zig
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,24 @@ shows: std.StringHashMapUnmanaged(Region.Shows) = .empty,
/// unloaded) while the picker is open.
snapshots: std.StringHashMapUnmanaged(Snapshot) = .empty,
/// While true, `Layout.draw` captures any surface it draws that has no snapshot yet, and
/// `Layout.captureUnplaced` draws the rest offscreen once. Set by `openPicker`.
/// `Layout.captureUnplaced` draws the ones whose cards the picker shows offscreen, one at a time
/// (`offscreen`). Set by `openPicker`.
snapshots_wanted: bool = false,
/// The one view `Layout.captureUnplaced` is drawing offscreen for its picture, chosen by the picker
/// from the cards it shows (`Picker.draw`): where it is in the registry, checked against its id's
/// hash (`std.hash.Wyhash`, seed 0) before each use; the frames of warm-up drawn, and when the
/// first was; when the list last had its card in view (`dvui.frameTimeNS`, as `Picker.list_ns`).
offscreen: ?struct { index: usize, id: u64, warm: u8 = 0, since_ns: i128 = 0, card_ns: i128 } = null,
/// No picture can be taken here: nothing more is captured, on screen or off, and no frame asked
/// for one, until the picker reopens. Set once `offscreen_unstarted_limit` offscreen captures in a
/// row could not even start (`noteOffscreenStart`): their target is small and fixed, so that is
/// the backend (no texture targets), not the view.
capture_unavailable: bool = false,
/// Offscreen captures in a row, each of a different view, that could not start.
offscreen_unstarted: u8 = 0,
/// The views (id hashes) whose capture failed since the picker opened, gpa-owned: not tried again
/// until it reopens (`captureFailed`, `recordCaptureFailed`).
capture_failed: std.AutoHashMapUnmanaged(u64, void) = .empty,
/// The one surface picker, if open. On the state rather than in whichever pane opened it so the
/// settings table and a region's corner button open the same thing, and the app draws it in
/// one place above everything else (`Picker.draw`).
Expand Down Expand Up @@ -665,6 +681,33 @@ pub fn discardSnapshots(self: *State, gpa: std.mem.Allocator) void {
}
self.snapshots.clearRetainingCapacity();
self.snapshots_wanted = false;
self.offscreen = null;
self.capture_unavailable = false;
self.offscreen_unstarted = 0;
self.capture_failed.clearAndFree(gpa);
}

/// Whether surface `id`'s capture failed since the picker opened (`capture_failed`).
pub fn captureFailed(self: *const State, id: []const u8) bool {
return self.capture_failed.contains(std.hash.Wyhash.hash(0, id));
}

/// Surface `id`'s capture failed (its picture could not start, or gave no texture): it is not
/// tried again until the picker reopens. Nothing else is affected.
pub fn recordCaptureFailed(self: *State, gpa: std.mem.Allocator, id: []const u8) void {
self.capture_failed.put(gpa, std.hash.Wyhash.hash(0, id), {}) catch {};
}

/// How many offscreen captures in a row may fail to start, each a different view, before capture
/// counts as unavailable here.
const offscreen_unstarted_limit = 3;

/// An offscreen capture began (`started`), or could not: its target is small and fixed, so several
/// in a row that could not mean the backend has no texture targets, and capture is given up for
/// the picker's session (`capture_unavailable`).
pub fn noteOffscreenStart(self: *State, started: bool) void {
self.offscreen_unstarted = if (started) 0 else self.offscreen_unstarted + 1;
if (self.offscreen_unstarted >= offscreen_unstarted_limit) self.capture_unavailable = true;
}

/// Open the picker for region `name`, anchored at `anchor` (or centred when null), and start
Expand Down
Loading
Loading