diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 328c3d322..6bcd15d22 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "vpk": { - "version": "0.0.1298", + "version": "1.2.161", "commands": [ "vpk" ], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75297f3a3..a8b2ae8d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ concurrency: cancel-in-progress: true env: - ZIG_VERSION: "0.16.0" + ZIG_VERSION: "0.17.0" jobs: changes: diff --git a/.github/workflows/plugin-loop.yml b/.github/workflows/plugin-loop.yml index 52c992559..8fd20fe89 100644 --- a/.github/workflows/plugin-loop.yml +++ b/.github/workflows/plugin-loop.yml @@ -16,7 +16,7 @@ on: inputs: zig_version: description: "Zig to measure with (a version, or master)" - default: "0.16.0" + default: "0.17.0" backends: description: "Comma-separated: default, llvm, self-hosted" default: "default,llvm,self-hosted" @@ -34,7 +34,7 @@ concurrency: cancel-in-progress: true env: - ZIG_VERSION: ${{ inputs.zig_version || '0.16.0' }} + ZIG_VERSION: ${{ inputs.zig_version || '0.17.0' }} jobs: measure: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3f116108..16f427006 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ concurrency: cancel-in-progress: false env: - ZIG_VERSION: "0.16.0" + ZIG_VERSION: "0.17.0" jobs: # Linux + Windows: no fizzy_release environment (no signing secrets needed). @@ -114,7 +114,7 @@ jobs: - uses: actions/setup-dotnet@v4 with: - dotnet-version: "8.0" + dotnet-version: "10.0" # vpk 1.2 targets .NET 10 # Prefetch before msvcup/package so lazy deps (e.g. zigwin32 from GitHub) get retry # backoff; zig build msvcup-setup alone can hit HttpConnectionClosing with no retries. @@ -278,7 +278,7 @@ jobs: - uses: actions/setup-dotnet@v4 with: - dotnet-version: "8.0" + dotnet-version: "10.0" # vpk 1.2 targets .NET 10 - name: Fetch deps with retries shell: bash @@ -349,7 +349,13 @@ jobs: log="$RUNNER_TEMP/package-${{ matrix.channel }}.log" n=0 until [ "$n" -ge 3 ]; do - if zig build package -Doptimize=ReleaseFast "-Dtarget=${{ matrix.target }}" 2>&1 | tee "$log"; then + # Signing as build options: zig build caches its configuration without regard to the + # environment, so build.zig does not read the FIZZY_MACOS_* variables itself. + sign=() + [ -n "${FIZZY_MACOS_SIGN_APP:-}" ] && sign+=("-Dmacos-sign-app=$FIZZY_MACOS_SIGN_APP") + [ -n "${FIZZY_MACOS_SIGN_INSTALLER:-}" ] && sign+=("-Dmacos-sign-installer=$FIZZY_MACOS_SIGN_INSTALLER") + [ -n "${FIZZY_MACOS_NOTARY_PROFILE:-}" ] && sign+=("-Dmacos-notary-profile=$FIZZY_MACOS_NOTARY_PROFILE") + if zig build package -Doptimize=ReleaseFast "-Dtarget=${{ matrix.target }}" ${sign[@]+"${sign[@]}"} 2>&1 | tee "$log"; then exit 0 fi if ! grep -Eq 'HttpConnectionClosing|invalid HTTP response|TlsInitializationFailed|ConnectionResetByPeer|TemporaryNameServerFailure|unable to fetch|connection reset' "$log"; then diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 4db3c81b1..a74d33b3f 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -59,7 +59,7 @@ on: default: "" env: - ZIG_VERSION: "0.16.0" + ZIG_VERSION: "0.17.0" WEBSITE: fizzyedit/website jobs: diff --git a/app/App.zig b/app/App.zig index 078b051bf..8c4155d4d 100644 --- a/app/App.zig +++ b/app/App.zig @@ -844,11 +844,11 @@ pub fn readPluginReservedField(gpa: std.mem.Allocator, settings_data: ?[:0]const const data = settings_data orelse return null; const plugins = SettingsPluginsZon.extractField(gpa, data, "plugins") orelse return null; defer gpa.free(plugins); - const plugins_z = gpa.dupeZ(u8, plugins) catch return null; + const plugins_z = gpa.dupeSentinel(u8, plugins, 0) catch return null; defer gpa.free(plugins_z); const id_block = SettingsPluginsZon.extractField(gpa, plugins_z, id) orelse return null; defer gpa.free(id_block); - const id_z = gpa.dupeZ(u8, id_block) catch return null; + const id_z = gpa.dupeSentinel(u8, id_block, 0) catch return null; defer gpa.free(id_z); return SettingsPluginsZon.extractField(gpa, id_z, field); } @@ -858,11 +858,11 @@ pub fn readPluginSettingsText(gpa: std.mem.Allocator, settings_data: ?[:0]const const data = settings_data orelse return null; const plugins = SettingsPluginsZon.extractField(gpa, data, "plugins") orelse return null; defer gpa.free(plugins); - const plugins_z = gpa.dupeZ(u8, plugins) catch return null; + const plugins_z = gpa.dupeSentinel(u8, plugins, 0) catch return null; defer gpa.free(plugins_z); const id_block = SettingsPluginsZon.extractField(gpa, plugins_z, id) orelse return null; defer gpa.free(id_block); - const id_z = gpa.dupeZ(u8, id_block) catch return null; + const id_z = gpa.dupeSentinel(u8, id_block, 0) catch return null; defer gpa.free(id_z); return SettingsPluginsZon.extractField(gpa, id_z, "settings"); } diff --git a/app/Recents.zig b/app/Recents.zig index 8a535f74f..4b38e6b12 100644 --- a/app/Recents.zig +++ b/app/Recents.zig @@ -49,9 +49,10 @@ pub fn load(allocator: std.mem.Allocator, path: []const u8) !Recents { if (core.fs.readZ(allocator, dvui.io, path) catch null) |data| { defer allocator.free(data); - if (std.zon.parse.fromSliceAlloc(Disk, allocator, data, null, .{ .ignore_unknown_fields = true }) catch null) |disk| { - defer std.zon.parse.free(allocator, disk); - + var disk_arena: std.heap.ArenaAllocator = .init(allocator); + defer disk_arena.deinit(); + var disk_diag: std.zon.parse.Diagnostics = undefined; + if (std.zon.parse.fromSlice(Disk, .{ .gpa = allocator, .arena = disk_arena.allocator(), .source = data, .diagnostics = &disk_diag, .ignore_unknown_fields = true }) catch null) |disk| { for (disk.folders) |folder| { // A folder on a mount (`gdrive://…`) is not the disk's to check: it exists // whenever its plugin is signed in, which is decided at open time. diff --git a/app/Secrets.zig b/app/Secrets.zig index b7f4e91ae..f4102611b 100644 --- a/app/Secrets.zig +++ b/app/Secrets.zig @@ -138,7 +138,7 @@ fn save(self: *Secrets) !void { const tmp = try std.fmt.allocPrint(self.gpa, "{s}.tmp", .{self.path}); defer self.gpa.free(tmp); { - var file = try std.Io.Dir.createFileAbsolute(self.io, tmp, .{ .permissions = @enumFromInt(0o600), .truncate = true }); + var file = try std.Io.Dir.createFileAbsolute(self.io, tmp, .{ .permissions = @fromBackingInt(@intCast(0o600)), .truncate = true }); defer file.close(self.io); try file.writeStreamingAll(self.io, out.written()); } diff --git a/app/automation/Service.zig b/app/automation/Service.zig index 77c2eaf1d..b8d7b7a4f 100644 --- a/app/automation/Service.zig +++ b/app/automation/Service.zig @@ -112,7 +112,7 @@ fn playFor(self: *Service, bytes: []const u8, author: ?sdk.Author) Api.Play { else => .{ .invalid = @errorName(err) }, }; if (self.current) |t| self.remember(.{ .ticket = t, .outcome = last }); - const ticket: Api.Ticket = @enumFromInt(self.next); + const ticket: Api.Ticket = @fromBackingInt(@intCast(self.next)); self.next += 1; self.current = ticket; return .{ .started = ticket }; @@ -170,22 +170,22 @@ pub fn frameEnded(self: *Service, end_micros: ?u32) bool { } fn snapshot(ctx: *anyopaque) Api.SnapshotTicket { - const s = from(ctx).snapshots orelse return @enumFromInt(0); - return @enumFromInt(s.request()); + const s = from(ctx).snapshots orelse return @fromBackingInt(@intCast(0)); + return @fromBackingInt(@intCast(s.request())); } fn snapshotText(ctx: *anyopaque, ticket: Api.SnapshotTicket) ?[]const u8 { const s = from(ctx).snapshots orelse return null; - return s.get(@intFromEnum(ticket)); + return s.get(@backingInt(ticket)); } fn capture(ctx: *anyopaque, window: []const u8) Api.CaptureTicket { - const c = from(ctx).captures orelse return @enumFromInt(0); - return @enumFromInt(c.request(window)); + const c = from(ctx).captures orelse return @fromBackingInt(@intCast(0)); + return @fromBackingInt(@intCast(c.request(window))); } fn captureImage(ctx: *anyopaque, ticket: Api.CaptureTicket) ?Api.Image { const c = from(ctx).captures orelse return null; - const image = c.get(@intFromEnum(ticket)) orelse return null; + const image = c.get(@backingInt(ticket)) orelse return null; return .{ .pixels = image.pixels, .width = image.width, .height = image.height }; } diff --git a/app/automation/overlay.zig b/app/automation/overlay.zig index f3eb017fe..74c569eb9 100644 --- a/app/automation/overlay.zig +++ b/app/automation/overlay.zig @@ -634,12 +634,12 @@ fn drawTransport(player: *Player, tape: *const Tape, win: dvui.Rect) f32 { var phys: [5]dvui.Rect.Physical = undefined; for (pieces, &phys) |r, *out| out.* = wrs.rectToPhysical(r); - tr.prev = phys[@intFromEnum(Piece.prev)]; - tr.play = phys[@intFromEnum(Piece.play)]; - tr.next = phys[@intFromEnum(Piece.next)]; - tr.close = phys[@intFromEnum(Piece.close)]; + tr.prev = phys[@backingInt(Piece.prev)]; + tr.play = phys[@backingInt(Piece.play)]; + tr.next = phys[@backingInt(Piece.next)]; + tr.close = phys[@backingInt(Piece.close)]; const hovered: ?Piece = if (tr.pointer) |pt| for (phys, 0..) |r, i| { - if (i != @intFromEnum(Piece.track) and r.contains(pt)) break @enumFromInt(i); + if (i != @backingInt(Piece.track) and r.contains(pt)) break @fromBackingInt(@intCast(i)); } else null else null; barGlass(fw.data().id, &phys, s, hovered, apart); @@ -650,11 +650,11 @@ fn drawTransport(player: *Player, tape: *const Tape, win: dvui.Rect) f32 { const ink = theme().color(.content, .text); const playing = player.state == .playing or (player.state == .seeking and player.after_seek == .play); const play_glyph: Glyph = if (playing) .pause else if (player.state == .ended) .replay else .play; - barIcon(bounds, pieces[@intFromEnum(Piece.prev)], .prev, ink.opacity(0.8)); - barIcon(bounds, pieces[@intFromEnum(Piece.play)], play_glyph, ink); - barIcon(bounds, pieces[@intFromEnum(Piece.next)], .next, ink.opacity(0.8)); - barIcon(bounds, pieces[@intFromEnum(Piece.close)], .close, ink.opacity(0.65)); - tr.track = barTrack(player, tape, bounds, pieces[@intFromEnum(Piece.track)], ink); + barIcon(bounds, pieces[@backingInt(Piece.prev)], .prev, ink.opacity(0.8)); + barIcon(bounds, pieces[@backingInt(Piece.play)], play_glyph, ink); + barIcon(bounds, pieces[@backingInt(Piece.next)], .next, ink.opacity(0.8)); + barIcon(bounds, pieces[@backingInt(Piece.close)], .close, ink.opacity(0.65)); + tr.track = barTrack(player, tape, bounds, pieces[@backingInt(Piece.track)], ink); return std.math.clamp(p * 1.6, 0, 1); } @@ -671,9 +671,9 @@ fn barGeometry(center: dvui.Point, full_w: f32, p: f32, out: *[5]dvui.Rect) f32 var widths = piece_d; var round_w: f32 = 0; for (piece_d, 0..) |d, i| { - if (i != @intFromEnum(Piece.track)) round_w += d; + if (i != @backingInt(Piece.track)) round_w += d; } - widths[@intFromEnum(Piece.track)] = @max(80, full_w - round_w - 4 * bar_gap); + widths[@backingInt(Piece.track)] = @max(80, full_w - round_w - 4 * bar_gap); const gap = bar_gap * apart; var total: f32 = 4 * gap; for (widths) |w| total += w; @@ -694,7 +694,7 @@ fn barGlass(id: dvui.Id, pieces: []const dvui.Rect.Physical, s: f32, hovered: ?P var bubbles: [5]core.dialogs.Bubble = undefined; for (pieces, bubbles[0..pieces.len], 0..) |r, *b, i| b.* = .{ .rect = r, - .light = if (hovered != null and @intFromEnum(hovered.?) == i) core.dialogs.bubble_hover else 0, + .light = if (hovered != null and @backingInt(hovered.?) == i) core.dialogs.bubble_hover else 0, }; core.dialogs.glassBubbles(id, bubbles[0..pieces.len], s, .{ .merge = bar_merge, .shadow = apart }); } @@ -715,7 +715,7 @@ fn barIcon(bounds: dvui.Rect, r: dvui.Rect, glyph: Glyph, color: dvui.Color) voi .stroke_color = .{ .color = color }, .fill_color = .{ .color = color }, }, .{ - .id_extra = @intFromEnum(glyph), + .id_extra = @backingInt(glyph), .rect = .{ .x = r.x + (r.w - d) / 2 - bounds.x, .y = r.y + (r.h - d) / 2 - bounds.y, .w = d, .h = d }, }); } diff --git a/app/control/Server.zig b/app/control/Server.zig index 8bcd151d2..2fe5ce823 100644 --- a/app/control/Server.zig +++ b/app/control/Server.zig @@ -32,7 +32,7 @@ pub const keep_alive_ms = 15_000; /// The longest socket path the OS takes, without its terminating zero. std's /// `UnixAddress.max_len` says 108 everywhere but Windows, where Darwin's `sockaddr_un` holds 104: /// a path between them passes `UnixAddress.init` and then overruns the copy in `listen`. -pub const max_path = if (builtin.os.tag == .windows) 107 else @sizeOf(std.posix.sockaddr.un) - @offsetOf(std.posix.sockaddr.un, "path") - 1; +pub const max_path = if (builtin.target.os.tag == .windows) 107 else @sizeOf(std.posix.sockaddr.un) - @offsetOf(std.posix.sockaddr.un, "path") - 1; /// Where an app whose config folder is `config_folder` listens: `control/control.sock` in it, a folder /// of its own only this user can open (`start` makes it). When that is longer than the OS takes @@ -188,7 +188,7 @@ pub fn start(self: *Server) !void { // target may be a folder shared with others, which the mode would then be set on. if (std.fs.path.dirname(self.socket_path)) |dir| { try Io.Dir.cwd().createDirPath(self.io, dir); - if (comptime builtin.os.tag != .windows) { + if (comptime builtin.target.os.tag != .windows) { const st = try Io.Dir.cwd().statFile(self.io, dir, .{ .follow_symlinks = false }); if (st.kind != .directory) return error.NotAFolder; try Io.Dir.cwd().setFilePermissions(self.io, dir, .fromMode(0o700), .{ .follow_symlinks = false }); @@ -203,7 +203,7 @@ pub fn start(self: *Server) !void { self.listener = null; Io.Dir.deleteFileAbsolute(self.io, self.socket_path) catch {}; } - if (comptime builtin.os.tag != .windows) { + if (comptime builtin.target.os.tag != .windows) { // This user only. The umask usually says so already; a shared machine's may not. try Io.Dir.cwd().setFilePermissions(self.io, self.socket_path, .fromMode(0o600), .{}); // Not on Windows, where the socket is a reparse point `statFile` cannot open: there this @@ -545,7 +545,7 @@ const TestClient = struct { } const head = self.reply[0..self.reply_len]; const key = "content-length: "; - if (std.ascii.indexOfIgnoreCase(head, key)) |i| { + if (std.ascii.findIgnoreCase(head, key)) |i| { const end = std.mem.indexOfPos(u8, head, i, "\r\n") orelse return; const len = std.fmt.parseInt(usize, head[i + key.len .. end], 10) catch return; const want = @min(len, self.reply.len - self.reply_len); @@ -604,7 +604,7 @@ test "a request reaches the UI thread and its answer goes back" { try std.testing.expect(std.mem.endsWith(u8, reply, "\r\n\r\n{\"ok\":true}")); // Only this user may open it. - if (builtin.os.tag == .windows) return; + if (builtin.target.os.tag == .windows) return; const st = try Io.Dir.cwd().statFile(io, path, .{}); try std.testing.expectEqual(@as(std.posix.mode_t, 0o600), st.permissions.toMode() & 0o777); // And its folder: nobody else reaches it, even before the socket's own mode is set. @@ -688,7 +688,7 @@ test "a config folder too deep for a socket path listens in a runtime folder ins defer gpa.free(want); try std.testing.expectEqualStrings(want, short); // Between Darwin's 104 and std's 108, the length that crashed `listen`: not used as it is. - const deep = "/" ++ "d" ** (max_path - "/control/control.sock".len + 2); + const deep = "/" ++ @as([max_path - "/control/control.sock".len + 2]u8, @splat('d')); const fallback = try socketPath(gpa, deep, "/tmp/me"); defer gpa.free(fallback); try std.testing.expect(fallback.len <= max_path); @@ -704,7 +704,7 @@ test "a config folder too deep for a socket path listens in a runtime folder ins test "a socket path the OS cannot hold fails at start, not later" { if (!Io.net.has_unix_sockets) return error.SkipZigTest; // Longer than the OS takes but within std's limit: refused here, not overrun in `listen`. - const long = "/" ++ "d" ** max_path; + const long = "/" ++ @as([max_path]u8, @splat('d')); var server: Server = .{ .gpa = std.testing.allocator, .io = std.testing.io, .socket_path = long, .wake = noWake }; try std.testing.expectError(error.NameTooLong, server.start()); } @@ -854,7 +854,7 @@ test "a client that stops reading neither holds up the quit nor buffers without try answerStreams(&server, 1); // Until its task blocks writing and the backlog passes the cap; how much the socket takes first // is the OS's (Windows grows its buffers), so publish until then, up to far more than any. - const chunk = "\"" ++ "x" ** (64 * 1024) ++ "\""; + const chunk = "\"" ++ @as([64 * 1024]u8, @splat('x')) ++ "\""; var published: usize = 0; while (!overrun(&server) and published < 256 * 1024 * 1024) : (published += chunk.len) server.publish("e", chunk); try std.testing.expect(overrun(&server)); @@ -895,7 +895,7 @@ test "a stopped server ends a stream whose client stopped reading" { } try answerStreams(&server, 1); // Enough to block its write, not enough to overrun. - const chunk = "\"" ++ "x" ** (64 * 1024) ++ "\""; + const chunk = "\"" ++ @as([64 * 1024]u8, @splat('x')) ++ "\""; for (0..16) |_| server.publish("e", chunk); io.sleep(.fromMilliseconds(50), .awake) catch {}; // Returns: the stream's socket is shut down under its blocked write. diff --git a/app/control/json.zig b/app/control/json.zig index c0a58d974..86cd8746d 100644 --- a/app/control/json.zig +++ b/app/control/json.zig @@ -71,13 +71,13 @@ fn writeZon(w: *std.Io.Writer, value: std.json.Value) std.Io.Writer.Error!void { pub fn jsonFromZon(arena: std.mem.Allocator, zon: ?[]const u8) Error![]u8 { const text = zon orelse return arena.dupe(u8, "null"); if (std.mem.trim(u8, text, " \t\r\n").len == 0) return arena.dupe(u8, "null"); - const source = try arena.dupeZ(u8, text); - const ast = try std.zig.Ast.parse(arena, source, .zon); + const source = try arena.dupeSentinel(u8, text, 0); + const ast = try std.zig.Ast.parse(arena, source, .{ .mode = .zon }); if (ast.errors.len > 0) return error.NotZon; const zoir = try std.zig.ZonGen.generate(arena, ast, .{ .parse_str_lits = true }); if (zoir.hasCompileErrors()) return error.NotZon; var out: std.Io.Writer.Allocating = .init(arena); - writeJson(&out.writer, zoir, .root) catch |err| return switch (err) { + writeJson(&out.writer, &zoir, .root) catch |err| return switch (err) { error.NotUtf8 => error.NotUtf8, error.WriteFailed => error.OutOfMemory, }; @@ -91,7 +91,7 @@ fn writeString(w: *std.Io.Writer, s: []const u8) (std.Io.Writer.Error || error{N try std.json.Stringify.encodeJsonString(s, .{}, w); } -fn writeJson(w: *std.Io.Writer, zoir: std.zig.Zoir, index: std.zig.Zoir.Node.Index) (std.Io.Writer.Error || error{NotUtf8})!void { +fn writeJson(w: *std.Io.Writer, zoir: *const std.zig.Zoir, index: std.zig.Zoir.Node.Index) (std.Io.Writer.Error || error{NotUtf8})!void { switch (index.get(zoir)) { .true => try w.writeAll("true"), .false => try w.writeAll("false"), @@ -152,7 +152,8 @@ test "arguments: JSON object to a ZON struct literal" { // Negative zero as ZON reads it. const neg = try zonFromJson(a, "{\"x\":-0.0}", &.{}); try std.testing.expectEqualStrings(".{.x=-0.0}", neg); - _ = try std.zon.parse.fromSliceAlloc(struct { x: f64 }, a, try a.dupeZ(u8, neg), null, .{}); + var diag: std.zon.parse.Diagnostics = undefined; + _ = try std.zon.parse.fromSlice(struct { x: f64 }, .{ .gpa = a, .arena = a, .source = try a.dupeSentinel(u8, neg, 0), .diagnostics = &diag }); try std.testing.expectError(error.NotAnObject, zonFromJson(a, "[1]", &.{})); } diff --git a/app/crash/crash.zig b/app/crash/crash.zig index a5f4848ee..4f55f4c69 100644 --- a/app/crash/crash.zig +++ b/app/crash/crash.zig @@ -284,7 +284,7 @@ pub fn writeTo(io: std.Io, config_folder: []const u8) void { const pid = if (builtin.target.os.tag == .windows) win.GetCurrentProcessId() else std.c.getpid(); const launched = std.Io.Timestamp.now(io, .real).toMilliseconds(); const sep = std.fs.path.sep; - const path = std.fmt.bufPrintZ(&path_buf, "{s}{c}crashes{c}{d}-{d}.txt", .{ config_folder, sep, sep, launched, pid }) catch return; + const path = std.fmt.bufPrintSentinel(&path_buf, "{s}{c}crashes{c}{d}-{d}.txt", .{ config_folder, sep, sep, launched, pid }, 0) catch return; const dir = std.fs.path.dirname(path).?; std.Io.Dir.cwd().createDirPath(io, dir) catch |err| { std.log.warn("crash reports: can't create {s}: {t}", .{ dir, err }); diff --git a/app/crash/image.zig b/app/crash/image.zig index c4d85820d..b76524741 100644 --- a/app/crash/image.zig +++ b/app/crash/image.zig @@ -176,7 +176,7 @@ test "a GNU build-id note is found among others" { return std.mem.toBytes(v); } }.u; - const other = w(4) ++ w(8) ++ w(1) ++ "GNU\x00".* ++ [_]u8{0} ** 8; + const other = w(4) ++ w(8) ++ w(1) ++ "GNU\x00".* ++ @as([8]u8, @splat(0)); const build_id = w(4) ++ w(5) ++ w(std.elf.NT_GNU_BUILD_ID) ++ "GNU\x00".* ++ "\xde\xad\xbe\xef\x01\x00\x00\x00".*; try std.testing.expectEqualSlices(u8, "\xde\xad\xbe\xef\x01", gnuBuildId(&(other ++ build_id)).?); try std.testing.expectEqual(null, gnuBuildId(&other)); diff --git a/app/keymap/Keymap.zig b/app/keymap/Keymap.zig index 360d62d3c..1f83374f0 100644 --- a/app/keymap/Keymap.zig +++ b/app/keymap/Keymap.zig @@ -60,8 +60,8 @@ pub const When = packed struct { /// context-specific binding always wins over a global one on the same chord. pub fn weight(self: When) u8 { var n: u8 = 0; - inline for (@typeInfo(When).@"struct".fields) |f| { - if (@field(self, f.name)) n += 1; + inline for (@typeInfo(When).@"struct".field_names, @typeInfo(When).@"struct".field_types, @typeInfo(When).@"struct".field_attrs) |f_name, _, _| { + if (@field(self, f_name)) n += 1; } return n; } @@ -79,9 +79,9 @@ pub const When = packed struct { const tok = std.mem.trim(u8, raw, " \t"); if (tok.len == 0) continue; var matched = false; - inline for (@typeInfo(When).@"struct".fields) |f| { - if (eqlCamelOrSnake(tok, f.name)) { - @field(out, f.name) = true; + inline for (@typeInfo(When).@"struct".field_names, @typeInfo(When).@"struct".field_types, @typeInfo(When).@"struct".field_attrs) |f_name, _, _| { + if (eqlCamelOrSnake(tok, f_name)) { + @field(out, f_name) = true; matched = true; } } @@ -205,8 +205,8 @@ fn best(self: Keymap, stroke: Stroke, ctx: When, active_owner: ?[]const u8) ?Bin winner = b; continue; }; - const b_rank = (@as(u16, @intFromEnum(b.source)) << 8) | b.when.weight(); - const w_rank = (@as(u16, @intFromEnum(w.source)) << 8) | w.when.weight(); + const b_rank = (@as(u16, @backingInt(b.source)) << 8) | b.when.weight(); + const w_rank = (@as(u16, @backingInt(w.source)) << 8) | w.when.weight(); // Owner-scoped bindings beat otherwise equal-rank global ones — that's the whole // point of active-owner conflict resolution (pixi Export vs fizzy Quick Open). const b_owner_boost: u16 = if (b.owner_id != null) 1 else 0; @@ -322,7 +322,7 @@ pub fn conflicts(self: Keymap, gpa: Allocator) ![]Conflict { for (self.bindings.items, 0..) |b, i| { const cmd = b.command orelse continue; - const rank: u16 = (@as(u16, @intFromEnum(b.source)) << 8) | b.when.weight(); + const rank: u16 = (@as(u16, @backingInt(b.source)) << 8) | b.when.weight(); for (claims.items) |*c| { if (!c.stroke.eql(b.stroke) or !c.when.eql(b.when)) continue; if (!std.mem.eql(u8, c.command, cmd) or !std.mem.eql(u8, c.args, b.args)) continue; diff --git a/app/keymap/zon.zig b/app/keymap/zon.zig index 5fa7bc3a4..203e9869f 100644 --- a/app/keymap/zon.zig +++ b/app/keymap/zon.zig @@ -111,7 +111,7 @@ pub const File = struct { const Parsed = struct { ast: std.zig.Ast, zoir: std.zig.Zoir }; fn parseZon(gpa: Allocator, source: [:0]const u8) ?Parsed { - var ast = std.zig.Ast.parse(gpa, source, .zon) catch return null; + var ast = std.zig.Ast.parse(gpa, source, .{ .mode = .zon }) catch return null; errdefer ast.deinit(gpa); var zoir = std.zig.ZonGen.generate(gpa, ast, .{ .parse_str_lits = true }) catch { ast.deinit(gpa); @@ -130,12 +130,12 @@ fn findField( container: std.zig.Zoir.Node.Index, name: []const u8, ) ?std.zig.Zoir.Node.Index { - const s = switch (container.get(zoir)) { + const s = switch (container.get(&zoir)) { .struct_literal => |sl| sl, else => return null, }; for (s.names, 0..) |field_name, i| { - if (std.mem.eql(u8, field_name.get(zoir), name)) return s.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name.get(&zoir), name)) return s.vals.at(@intCast(i)); } return null; } @@ -146,7 +146,7 @@ fn stringField( name: []const u8, ) ?[]const u8 { const node = findField(zoir, container, name) orelse return null; - return switch (node.get(zoir)) { + return switch (node.get(&zoir)) { .string_literal => |s| s, else => null, }; @@ -154,7 +154,7 @@ fn stringField( /// 1-based line/column of a node's first token. fn locationOf(ast: std.zig.Ast, zoir: std.zig.Zoir, node: std.zig.Zoir.Node.Index) struct { line: u32, column: u32 } { - const tok = ast.firstToken(node.getAstNode(zoir)); + const tok = ast.firstToken(node.getAstNode(&zoir)); const start = ast.tokenStart(tok); var line: u32 = 1; var col: u32 = 1; @@ -188,7 +188,7 @@ fn collectBlock( owner_id: ?[]const u8, platform: Platform, ) Allocator.Error!void { - const range = switch (block.get(zoir)) { + const range = switch (block.get(&zoir)) { .array_literal => |r| r, .empty_literal => return, else => { @@ -203,7 +203,7 @@ fn collectBlock( const entry = range.at(i); const loc = locationOf(ast, zoir, entry); - switch (entry.get(zoir)) { + switch (entry.get(&zoir)) { .struct_literal => {}, else => { c.diag(loc.line, loc.column, "binding must be a struct, e.g. .{{ .keys = \"ctrl+s\", .command = \"fizzy.save\" }}", .{}); @@ -226,7 +226,7 @@ fn collectBlock( c.diag(loc.line, loc.column, "binding is missing `.command` (use `.command = null` to unbind)", .{}); continue; }; - const command: ?[]const u8 = switch (command_node.get(zoir)) { + const command: ?[]const u8 = switch (command_node.get(&zoir)) { .string_literal => |s| s, .null => null, else => { @@ -244,8 +244,8 @@ fn collectBlock( // `.args`, when given, is a struct literal of the command's arguments, kept as written. var args_text: ?[]const u8 = null; if (findField(zoir, entry, "args")) |args_node| { - switch (args_node.get(zoir)) { - .struct_literal => args_text = ast.getNodeSource(args_node.getAstNode(zoir)), + switch (args_node.get(&zoir)) { + .struct_literal => args_text = ast.getNodeSource(args_node.getAstNode(&zoir)), .empty_literal => {}, else => { c.diag(loc.line, loc.column, "`.args` must be a struct of the command's arguments, e.g. .{{ .line = 1 }}", .{}); @@ -262,7 +262,7 @@ fn collectBlock( var when_text: ?[]u8 = null; errdefer if (when_text) |w| c.gpa.free(w); if (findField(zoir, entry, "when")) |when_node| { - switch (when_node.get(zoir)) { + switch (when_node.get(&zoir)) { .string_literal => |s| { var unknown: std.ArrayList([]const u8) = .empty; defer unknown.deinit(c.gpa); @@ -332,10 +332,10 @@ pub fn parse(gpa: Allocator, source: [:0]const u8, platform: Platform) Allocator } if (findField(parsed.zoir, .root, "plugins")) |plugins_node| { - switch (plugins_node.get(parsed.zoir)) { + switch (plugins_node.get(&parsed.zoir)) { .struct_literal => |sl| { for (sl.names, 0..) |id_str, i| { - const id = id_str.get(parsed.zoir); + const id = id_str.get(&parsed.zoir); try collectBlock(&c, parsed.ast, parsed.zoir, sl.vals.at(@intCast(i)), id, platform); } }, @@ -577,7 +577,7 @@ test "format round-trips through parse" { const written = try format(a, first.bindings); defer a.free(written); - const written_z = try a.dupeZ(u8, written); + const written_z = try a.dupeSentinel(u8, written, 0); defer a.free(written_z); var second = try parse(a, written_z, .other); @@ -631,7 +631,7 @@ test "backslash keys survive a write/read cycle" { const written = try format(a, f.bindings); defer a.free(written); - const written_z = try a.dupeZ(u8, written); + const written_z = try a.dupeSentinel(u8, written, 0); defer a.free(written_z); var again = try parse(a, written_z, .other); defer again.deinit(a); diff --git a/app/layout/Chooser.zig b/app/layout/Chooser.zig index 26f4b9fca..fe8927480 100644 --- a/app/layout/Chooser.zig +++ b/app/layout/Chooser.zig @@ -303,7 +303,8 @@ fn carryLifted(self: *Chooser) void { } else { f.beginViewDrag(lift.id, lift.rect); // From the motion that lifted it on: a release in the same frame is the drag's too. - if (dvui.currentWindow().capture) |cm| dvui.captureMouseCustom(cm, lift.event); + if (dvui.captureMouseGet()) |cm| + dvui.captureMouseCustom(cm, lift.event); } self.carried = lift.id; dvui.refresh(null, @src(), null); diff --git a/app/layout/Floats.zig b/app/layout/Floats.zig index 0b48b5e52..19ea90fa4 100644 --- a/app/layout/Floats.zig +++ b/app/layout/Floats.zig @@ -472,10 +472,10 @@ fn syncStack(l: *Layout) void { const floats = &l.state.floats; const n = @min(floats.items.items.len, max); var ids: [max]u64 = undefined; - for (floats.items.items[0..n], 0..) |f, i| ids[i] = @intFromEnum(f.win_id); + for (floats.items.items[0..n], 0..) |f, i| ids[i] = @backingInt(f.win_id); const stack = dvui.currentWindow().subwindows.stack.items; const stack_ids = l.arena.alloc(u64, stack.len) catch return; - for (stack, 0..) |sw, i| stack_ids[i] = @intFromEnum(sw.id); + for (stack, 0..) |sw, i| stack_ids[i] = @backingInt(sw.id); var order: [max]usize = undefined; rules.stackOrder(order[0..n], ids[0..n], stack_ids); floats.reorder(order[0..n]); diff --git a/app/layout/Layout.zig b/app/layout/Layout.zig index a202af997..63319f616 100644 --- a/app/layout/Layout.zig +++ b/app/layout/Layout.zig @@ -517,7 +517,7 @@ fn claimedElsewhere( if (sdk.keywords.groupKey(r.keywords) == want) continue; if (self.state.assignment(r.name) != null) continue; const theirs = sdk.keywords.strength(r.keywords, s.keywords); - if (@intFromEnum(theirs) > @intFromEnum(mine)) return true; + if (@backingInt(theirs) > @backingInt(mine)) return true; if (theirs == mine and mine_at != null and i < mine_at.?) return true; } return false; @@ -890,7 +890,7 @@ fn resetPack(b: *dvui.BoxWidget) void { b.packed_children = 0; b.total_weight = 0; b.min_space_taken = 0; - if (builtin.mode == .Debug) b.child_id = .zero; + if (builtin.mode == .debug) b.child_id = .zero; } // ── The base layer: regions and keywords ──────────────────────────────────────────────────── @@ -1030,7 +1030,7 @@ pub fn beginPluginRegion(self: *Layout, spec: sdk.RegionSpec) ?sdk.RegionSpec.To self.plugin_regions[self.plugin_depth] = r; self.plugin_depth += 1; - return @enumFromInt(self.plugin_depth); + return @fromBackingInt(@intCast(self.plugin_depth)); } pub fn drawPluginRegionContents(self: *Layout, token: sdk.RegionSpec.Token) !dvui.App.Result { @@ -1109,7 +1109,7 @@ pub fn endPluginRegion(self: *Layout, token: sdk.RegionSpec.Token) void { // Strictly nested, like the boxes they are. Closing out of order would deinit the wrong box // and dvui would report it as a stack mismatch two widgets later, naming neither the plugin // nor the region — so say it here while the token still means something. - const depth = @intFromEnum(token); + const depth = @backingInt(token); if (depth != self.plugin_depth) { dvui.log.err("plugin region closed out of order ({d} of {d} open)", .{ depth, self.plugin_depth }); return; @@ -1119,7 +1119,7 @@ pub fn endPluginRegion(self: *Layout, token: sdk.RegionSpec.Token) void { } fn pluginRegion(self: *Layout, token: sdk.RegionSpec.Token) ?*Region { - const depth = @intFromEnum(token); + const depth = @backingInt(token); if (depth == 0 or depth > self.plugin_depth) return null; return &self.plugin_regions[depth - 1]; } diff --git a/app/layout/Picker.zig b/app/layout/Picker.zig index 52c562d15..c159d52c0 100644 --- a/app/layout/Picker.zig +++ b/app/layout/Picker.zig @@ -417,7 +417,7 @@ fn sectionOf(places: []const Place, id: []const u8, keywords: []const []const u8 var best_fit: sdk.keywords.Fit = .none; for (places, 0..) |p, i| { const fit = sdk.keywords.strength(p.keywords, keywords); - if (@intFromEnum(fit) > @intFromEnum(best_fit)) { + if (@backingInt(fit) > @backingInt(best_fit)) { best = i; best_fit = fit; } diff --git a/app/layout/Region.zig b/app/layout/Region.zig index 360ca4e1a..673762ef0 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -1104,7 +1104,7 @@ fn nameExtra(name: []const u8) usize { } fn extraFor(name: []const u8, side: SplitTree.Side) usize { - return @truncate(nameExtra(name) ^ @as(u64, @intFromEnum(side)) << 8); + return @truncate(nameExtra(name) ^ @as(u64, @backingInt(side)) << 8); } fn idOf(state: *Layout.State, name: []const u8) ?dvui.Id { diff --git a/app/root.zig b/app/root.zig index 8e7e7dfdf..5b9767466 100644 --- a/app/root.zig +++ b/app/root.zig @@ -117,7 +117,7 @@ pub const control = struct { /// (`Server.socketPath`), with this user's runtime folder for a config folder too deep. pub fn socketPath(gpa: @import("std").mem.Allocator, config_folder: []const u8) ![]u8 { const std = @import("std"); - const runtime: ?[]const u8 = if (@import("builtin").os.tag == .windows) null else for ([_][*:0]const u8{ "TMPDIR", "XDG_RUNTIME_DIR" }) |name| { + const runtime: ?[]const u8 = if (@import("builtin").target.os.tag == .windows) null else for ([_][*:0]const u8{ "TMPDIR", "XDG_RUNTIME_DIR" }) |name| { if (std.c.getenv(name)) |v| if (std.mem.len(v) > 0) break std.mem.span(v); } else null; return Server.socketPath(gpa, config_folder, runtime); diff --git a/app/settings/Settings.zig b/app/settings/Settings.zig index a32d07b5a..3019d1f5b 100644 --- a/app/settings/Settings.zig +++ b/app/settings/Settings.zig @@ -186,42 +186,37 @@ pub fn load(allocator: std.mem.Allocator, path: []const u8, plugins_dir: ?[]cons const data = core.fs.readZ(allocator, dvui.io, path) catch return default(allocator); defer allocator.free(data); - const parsed = parseOnly(allocator, data) catch |err| { + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const parsed = parseOnly(allocator, arena.allocator(), data) catch |err| { dvui.log.warn("Could not parse settings.zon ({s}); using defaults.", .{@errorName(err)}); return default(allocator); }; - defer freeParsed(allocator, parsed); - var result = parsed; // Own `theme` for the process lifetime (freed in `deinit`); every other field is scalar. result.theme = try allocator.dupe(u8, parsed.theme); return result; } -/// Frees a `parseOnly`/`load` result. Not `std.zon.parse.free` directly: since R12 the file only -/// records non-default fields, so a missing `theme` is filled by `std.zon.parse` with *this -/// struct's declared default* — a comptime string literal, not an allocation — and handing that -/// pointer to the allocator would be an invalid free. Zeroing it first makes the whole-struct -/// free a no-op for that field (`Allocator.free` returns early on an empty slice) while still -/// covering any other allocated field. -pub fn freeParsed(allocator: std.mem.Allocator, parsed: Settings) void { - var value = parsed; - if (value.theme.ptr == default_theme.ptr) value.theme = ""; - std.zon.parse.free(allocator, value); -} - /// Parses `data` (already-read `settings.zon` bytes) into a `Settings` value, ignoring unknown /// fields (forward-compat + `.plugins` is handled separately — see `load`'s doc comment above). /// Unlike `load`, this returns the raw parse error on failure instead of falling back to /// defaults — defaulting is only correct when there's no existing live state to fall back *to* /// (startup); `Editor.reconcileExternalSettingsChange` (external hand-edit reconciliation, R11) /// has existing state and must not let a transient torn-write read reset every fizzy field and -/// every plugin's settings. Caller frees the result with `freeParsed` (not `Settings.deinit`, -/// which owns the live value's long-lived `theme`). -pub fn parseOnly(allocator: std.mem.Allocator, data: [:0]const u8) !Settings { +/// every plugin's settings. The result's strings live in `arena` (`allocator` is the parse's +/// scratch); `load` copies out the one it keeps. +pub fn parseOnly(allocator: std.mem.Allocator, arena: std.mem.Allocator, data: [:0]const u8) !Settings { @setEvalBranchQuota(10_000); - var parsed = try std.zon.parse.fromSliceAlloc(Settings, allocator, data, null, .{ .ignore_unknown_fields = true }); + var diag: std.zon.parse.Diagnostics = undefined; + var parsed = try std.zon.parse.fromSlice(Settings, .{ + .gpa = allocator, + .arena = arena, + .source = data, + .diagnostics = &diag, + .ignore_unknown_fields = true, + }); SettingsMigration.windowGlass(allocator, data, &parsed); return parsed; } @@ -243,7 +238,7 @@ fn fieldEqual(comptime FT: type, a: FT, b: FT) bool { /// halves of `settings.zon` record just what the user actually changed. An untouched fizzy /// serializes to `.{}`; a field hand-written back at its default drops out on the next write. /// Reading back is unaffected: `load`/`parseOnly` fill every missing field from these same -/// defaults (see `freeParsed` for the one ownership wrinkle that creates). +/// defaults (they are the declared defaults themselves: `load` copies `theme` before keeping it). /// /// This is *not* the whole `settings.zon` file — plugin settings are a separate /// `.plugins = .{ . = .{...}, ... }` field spliced on afterward by @@ -257,12 +252,12 @@ pub fn serialize(settings: *const Settings, allocator: std.mem.Allocator) ![]u8 defer aw.deinit(); var any = false; - inline for (std.meta.fields(Settings)) |f| { - if (!fieldEqual(f.type, @field(settings, f.name), @field(default_value, f.name))) { + inline for (@typeInfo(Settings).@"struct".field_names, @typeInfo(Settings).@"struct".field_types, @typeInfo(Settings).@"struct".field_attrs) |f_name, f_type, _| { + if (!fieldEqual(f_type, @field(settings, f_name), @field(default_value, f_name))) { if (!any) try aw.writer.writeAll(".{\n"); any = true; - try aw.writer.print(" .{f} = ", .{std.zig.fmtId(f.name)}); - try std.zon.stringify.serialize(@field(settings, f.name), .{}, &aw.writer); + try aw.writer.print(" .{f} = ", .{std.zig.fmtId(f_name)}); + try std.zon.stringify.serialize(@field(settings, f_name), .{}, &aw.writer); try aw.writer.writeAll(",\n"); } } diff --git a/app/settings/SettingsMigration.zig b/app/settings/SettingsMigration.zig index 475941ec3..ae99eaa8f 100644 --- a/app/settings/SettingsMigration.zig +++ b/app/settings/SettingsMigration.zig @@ -75,13 +75,13 @@ pub fn migrateToPerPluginEnabled(allocator: std.mem.Allocator, settings_zon_path defer allocator.free(data); @setEvalBranchQuota(10_000); - const legacy = std.zon.parse.fromSliceAlloc(LegacyDisk, allocator, data, null, .{ - .ignore_unknown_fields = true, - }) catch |err| { + var legacy_arena: std.heap.ArenaAllocator = .init(allocator); + defer legacy_arena.deinit(); + var legacy_diag: std.zon.parse.Diagnostics = undefined; + const legacy = std.zon.parse.fromSlice(LegacyDisk, .{ .gpa = allocator, .arena = legacy_arena.allocator(), .source = data, .diagnostics = &legacy_diag, .ignore_unknown_fields = true }) catch |err| { dvui.log.warn("settings: could not parse settings.zon for R12 enabled migration ({s}); skipping", .{@errorName(err)}); return; }; - defer std.zon.parse.free(allocator, legacy); const blocks = SettingsPluginsZon.listPluginBlocks(allocator, data) catch return; defer SettingsPluginsZon.freeEntries(allocator, blocks); @@ -157,11 +157,12 @@ pub fn migrateToPerPluginEnabled(allocator: std.mem.Allocator, settings_zon_path // Regenerate fizzy's own fields from a Settings parse (unknown fields — including the legacy // `disabled_plugins` list — are ignored), so the rewritten file drops that list for free. - const parsed = Settings.parseOnly(allocator, data) catch |err| { + var parsed_arena: std.heap.ArenaAllocator = .init(allocator); + defer parsed_arena.deinit(); + const parsed = Settings.parseOnly(allocator, parsed_arena.allocator(), data) catch |err| { dvui.log.warn("settings: could not re-serialize fizzy's own fields for R12 migration ({s}); skipping", .{@errorName(err)}); return; }; - defer Settings.freeParsed(allocator, parsed); const fizzy_text = Settings.serialize(&parsed, allocator) catch |err| { dvui.log.warn("settings: could not serialize fizzy's own fields for R12 migration ({s}); skipping", .{@errorName(err)}); @@ -195,15 +196,15 @@ pub fn migrateToPerPluginEnabled(allocator: std.mem.Allocator, settings_zon_path /// `enabled` was listed, so an `.auto_update = false`-only block — which the app itself can /// write for a disabled plugin — was being swallowed too.) fn isAlreadyNested(gpa: std.mem.Allocator, text: []const u8) bool { - const z = gpa.dupeZ(u8, text) catch return false; + const z = gpa.dupeSentinel(u8, text, 0) catch return false; defer gpa.free(z); if (SettingsPluginsZon.extractField(gpa, z, "settings")) |s| { gpa.free(s); return true; } - inline for (@typeInfo(SettingsPluginsZon.Reserved).@"struct".fields) |field| { - if (SettingsPluginsZon.extractField(gpa, z, field.name)) |s| { + inline for (@typeInfo(SettingsPluginsZon.Reserved).@"struct".field_names, @typeInfo(SettingsPluginsZon.Reserved).@"struct".field_types, @typeInfo(SettingsPluginsZon.Reserved).@"struct".field_attrs) |field_name, _, _| { + if (SettingsPluginsZon.extractField(gpa, z, field_name)) |s| { gpa.free(s); return true; } @@ -254,8 +255,10 @@ const LegacyGlass = struct { /// dialogs' blur goes with them, as the roughness that followed it does. pub fn windowGlass(allocator: std.mem.Allocator, data: [:0]const u8, settings: *Settings) void { @setEvalBranchQuota(10_000); - const legacy = std.zon.parse.fromSliceAlloc(LegacyGlass, allocator, data, null, .{ .ignore_unknown_fields = true }) catch return; - defer std.zon.parse.free(allocator, legacy); + var legacy_arena: std.heap.ArenaAllocator = .init(allocator); + defer legacy_arena.deinit(); + var legacy_diag: std.zon.parse.Diagnostics = undefined; + const legacy = std.zon.parse.fromSlice(LegacyGlass, .{ .gpa = allocator, .arena = legacy_arena.allocator(), .source = data, .diagnostics = &legacy_diag, .ignore_unknown_fields = true }) catch return; if (legacy.dialog_opacity == null and legacy.dialog_blur == null and legacy.dialog_lift == null and legacy.dialog_detail == null and legacy.dialog_refraction == null) return; const opaque_window = comptime builtin.target.cpu.arch == .wasm32 or (builtin.target.os.tag != .macos and builtin.target.os.tag != .windows); diff --git a/app/settings/SettingsPluginsZon.zig b/app/settings/SettingsPluginsZon.zig index bb787c75c..e112e2bf5 100644 --- a/app/settings/SettingsPluginsZon.zig +++ b/app/settings/SettingsPluginsZon.zig @@ -43,13 +43,13 @@ pub fn freeEntries(gpa: Allocator, entries: []const Entry) void { /// restriction on `name`). Returns the field's value node, or null if `container` isn't a struct /// literal or has no such field. fn findField(zoir: std.zig.Zoir, container: std.zig.Zoir.Node.Index, name: []const u8) ?std.zig.Zoir.Node.Index { - const node = container.get(zoir); + const node = container.get(&zoir); const s = switch (node) { .struct_literal => |sl| sl, else => return null, }; for (s.names, 0..) |field_name, i| { - if (std.mem.eql(u8, field_name.get(zoir), name)) return s.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name.get(&zoir), name)) return s.vals.at(@intCast(i)); } return null; } @@ -57,7 +57,7 @@ fn findField(zoir: std.zig.Zoir, container: std.zig.Zoir.Node.Index, name: []con /// Owned copy of `node`'s exact source text (first token's start through last token's end), /// preserving whitespace/comments inside the span verbatim. fn nodeSourceText(gpa: Allocator, ast: std.zig.Ast, zoir: std.zig.Zoir, node: std.zig.Zoir.Node.Index) Allocator.Error![]u8 { - const ast_node = node.getAstNode(zoir); + const ast_node = node.getAstNode(&zoir); const first_tok = ast.firstToken(ast_node); const last_tok = ast.lastToken(ast_node); const start = ast.tokenStart(first_tok); @@ -70,7 +70,7 @@ fn nodeSourceText(gpa: Allocator, ast: std.zig.Ast, zoir: std.zig.Zoir, node: st const Parsed = struct { ast: std.zig.Ast, zoir: std.zig.Zoir }; fn parseZon(gpa: Allocator, source: [:0]const u8) ?Parsed { - var ast = std.zig.Ast.parse(gpa, source, .zon) catch return null; + var ast = std.zig.Ast.parse(gpa, source, .{ .mode = .zon }) catch return null; errdefer ast.deinit(gpa); var zoir = std.zig.ZonGen.generate(gpa, ast, .{ .parse_str_lits = false }) catch { ast.deinit(gpa); @@ -102,7 +102,7 @@ pub fn extractField(gpa: Allocator, source: [:0]const u8, field_name: []const u8 pub fn extractPluginBlob(gpa: Allocator, source: [:0]const u8, id: []const u8) ?[]u8 { const plugins = extractField(gpa, source, "plugins") orelse return null; defer gpa.free(plugins); - const plugins_z = gpa.dupeZ(u8, plugins) catch return null; + const plugins_z = gpa.dupeSentinel(u8, plugins, 0) catch return null; defer gpa.free(plugins_z); return extractField(gpa, plugins_z, id); } @@ -122,7 +122,7 @@ pub fn listPluginBlocks(gpa: Allocator, source: ?[:0]const u8) Allocator.Error![ } const plugins_node = findField(parsed.zoir, .root, "plugins") orelse return &.{}; - const node = plugins_node.get(parsed.zoir); + const node = plugins_node.get(&parsed.zoir); const s = switch (node) { .struct_literal => |sl| sl, else => return &.{}, @@ -137,7 +137,7 @@ pub fn listPluginBlocks(gpa: Allocator, source: ?[:0]const u8) Allocator.Error![ out.deinit(gpa); } for (s.names, 0..) |field_name, i| { - const id = try gpa.dupe(u8, field_name.get(parsed.zoir)); + const id = try gpa.dupe(u8, field_name.get(&parsed.zoir)); errdefer gpa.free(id); const text = try nodeSourceText(gpa, parsed.ast, parsed.zoir, s.vals.at(@intCast(i))); try out.append(gpa, .{ .id = id, .text = text }); @@ -295,7 +295,7 @@ pub fn upsertOne(gpa: Allocator, existing_full_source: ?[:0]const u8, entry: Ent return gpa.dupe(u8, src); } - const ast_node = plugins_node.getAstNode(parsed.zoir); + const ast_node = plugins_node.getAstNode(&parsed.zoir); const last_tok = parsed.ast.lastToken(ast_node); const insert_at = parsed.ast.tokenStart(last_tok); @@ -435,11 +435,11 @@ test "extractPluginBlob returns null when the id is missing from an existing .pl test "nested extractField reaches .plugins..settings" { const plugins = extractField(testing.allocator, sample_source, "plugins").?; defer testing.allocator.free(plugins); - const plugins_z = try testing.allocator.dupeZ(u8, plugins); + const plugins_z = try testing.allocator.dupeSentinel(u8, plugins, 0); defer testing.allocator.free(plugins_z); const id_block = extractField(testing.allocator, plugins_z, "text").?; defer testing.allocator.free(id_block); - const id_z = try testing.allocator.dupeZ(u8, id_block); + const id_z = try testing.allocator.dupeSentinel(u8, id_block, 0); defer testing.allocator.free(id_z); const settings = extractField(testing.allocator, id_z, "settings").?; defer testing.allocator.free(settings); @@ -453,7 +453,7 @@ test "composeMergedText round-trips: overlay replaces one id, others survive unt const composed = try composeMergedText(testing.allocator, fizzy_text, sample_source, &overlay); defer testing.allocator.free(composed); - const composed_z = try testing.allocator.dupeZ(u8, composed); + const composed_z = try testing.allocator.dupeSentinel(u8, composed, 0); defer testing.allocator.free(composed_z); const pixi = extractPluginBlob(testing.allocator, composed_z, "pixi").?; @@ -472,7 +472,7 @@ test "composeMergedText removes an id when overlay text is null" { const composed = try composeMergedText(testing.allocator, fizzy_text, sample_source, &overlay); defer testing.allocator.free(composed); - const composed_z = try testing.allocator.dupeZ(u8, composed); + const composed_z = try testing.allocator.dupeSentinel(u8, composed, 0); defer testing.allocator.free(composed_z); try testing.expect(extractPluginBlob(testing.allocator, composed_z, "pixi") == null); @@ -489,7 +489,7 @@ test "composeMergedText adds a brand-new id when there is no existing .plugins b const composed = try composeMergedText(testing.allocator, fizzy_text, null, &overlay); defer testing.allocator.free(composed); - const composed_z = try testing.allocator.dupeZ(u8, composed); + const composed_z = try testing.allocator.dupeSentinel(u8, composed, 0); defer testing.allocator.free(composed_z); const pixi = extractPluginBlob(testing.allocator, composed_z, "pixi").?; @@ -512,7 +512,7 @@ test "composeMergedText accepts an empty fizzy struct (every fizzy field at its const composed = try composeMergedText(testing.allocator, ".{}", null, &overlay); defer testing.allocator.free(composed); - const composed_z = try testing.allocator.dupeZ(u8, composed); + const composed_z = try testing.allocator.dupeSentinel(u8, composed, 0); defer testing.allocator.free(composed_z); const pixi = extractPluginBlob(testing.allocator, composed_z, "pixi").?; @@ -523,7 +523,7 @@ test "composeMergedText accepts an empty fizzy struct (every fizzy field at its test "upsertOne inserts a new id into an existing .plugins block, leaving the rest untouched" { const composed = try upsertOne(testing.allocator, sample_source, .{ .id = "markdown", .text = ".{ .enabled = true }" }); defer testing.allocator.free(composed); - const composed_z = try testing.allocator.dupeZ(u8, composed); + const composed_z = try testing.allocator.dupeSentinel(u8, composed, 0); defer testing.allocator.free(composed_z); const markdown = extractPluginBlob(testing.allocator, composed_z, "markdown").?; @@ -546,7 +546,7 @@ test "upsertOne adds a .plugins block when none exists yet" { const source: [:0]const u8 = ".{ .theme = 0.35 }"; const composed = try upsertOne(testing.allocator, source, .{ .id = "pixi", .text = ".{ .enabled = true }" }); defer testing.allocator.free(composed); - const composed_z = try testing.allocator.dupeZ(u8, composed); + const composed_z = try testing.allocator.dupeSentinel(u8, composed, 0); defer testing.allocator.free(composed_z); const pixi = extractPluginBlob(testing.allocator, composed_z, "pixi").?; @@ -557,7 +557,7 @@ test "upsertOne adds a .plugins block when none exists yet" { test "upsertOne handles a missing settings.zon (null source)" { const composed = try upsertOne(testing.allocator, null, .{ .id = "pixi", .text = ".{ .enabled = true }" }); defer testing.allocator.free(composed); - const composed_z = try testing.allocator.dupeZ(u8, composed); + const composed_z = try testing.allocator.dupeSentinel(u8, composed, 0); defer testing.allocator.free(composed_z); const pixi = extractPluginBlob(testing.allocator, composed_z, "pixi").?; @@ -601,7 +601,7 @@ test "composed extensions survive extractField + parseExtensions" { ); defer testing.allocator.free(block); - const block_z = try testing.allocator.dupeZ(u8, block); + const block_z = try testing.allocator.dupeSentinel(u8, block, 0); defer testing.allocator.free(block_z); const exts_text = extractField(testing.allocator, block_z, "extensions").?; @@ -642,24 +642,24 @@ test "every Reserved field is discoverable by extractField" { // reserved field that composes to something `extractField` cannot find back is silently // destructive: it would be reinterpreted as a plugin-author setting. This asserts the two // sides stay in step for every field, including ones added later. - inline for (@typeInfo(Reserved).@"struct".fields) |field| { + inline for (@typeInfo(Reserved).@"struct".field_names, @typeInfo(Reserved).@"struct".field_types, @typeInfo(Reserved).@"struct".field_attrs) |field_name, field_type, _| { // Compose a block in which this field is guaranteed non-default, so it is actually // emitted (fields are written only when they differ from their default). var reserved: Reserved = .{}; - switch (field.type) { - bool => @field(reserved, field.name) = !@field(reserved, field.name), + switch (field_type) { + bool => @field(reserved, field_name) = !@field(reserved, field_name), // Tri-state: absent is the default, so either concrete value is "non-default" and // must be emitted. `false` is the interesting one — it used to be omitted. - ?bool => @field(reserved, field.name) = false, - []const []const u8 => @field(reserved, field.name) = &.{".probe"}, - else => @compileError("extend this test for Reserved field type " ++ @typeName(field.type)), + ?bool => @field(reserved, field_name) = false, + []const []const u8 => @field(reserved, field_name) = &.{".probe"}, + else => @compileError("extend this test for Reserved field type " ++ @typeName(field_type)), } const block = try composePluginIdBlock(testing.allocator, reserved, null); defer testing.allocator.free(block); - const block_z = try testing.allocator.dupeZ(u8, block); + const block_z = try testing.allocator.dupeSentinel(u8, block, 0); defer testing.allocator.free(block_z); - const found = extractField(testing.allocator, block_z, field.name); + const found = extractField(testing.allocator, block_z, field_name); try testing.expect(found != null); testing.allocator.free(found.?); } diff --git a/app/state_delta.zig b/app/state_delta.zig index 5caa4deb5..592af11b9 100644 --- a/app/state_delta.zig +++ b/app/state_delta.zig @@ -43,11 +43,11 @@ pub fn encode(gpa: std.mem.Allocator, ref: []const u8, new: []const u8) std.mem. const w = &out.writer; write(w, new.len, prefix, suffix) catch return error.OutOfMemory; if (a.len != b.len) { - w.writeByte(@intFromEnum(Mode.raw)) catch return error.OutOfMemory; + w.writeByte(@backingInt(Mode.raw)) catch return error.OutOfMemory; w.writeAll(b) catch return error.OutOfMemory; return out.toOwnedSlice(); } - w.writeByte(@intFromEnum(Mode.runs)) catch return error.OutOfMemory; + w.writeByte(@backingInt(Mode.runs)) catch return error.OutOfMemory; // Runs of differing blocks, found in one pass and kept so their count can lead them. var runs: std.ArrayList(Run) = .empty; defer runs.deinit(gpa); @@ -159,7 +159,13 @@ test "an edit in one place is a delta of about its size" { } test "text inserted shifts what follows, and the delta is still the part between the edits" { - const ref = "fn main() void {\n std.debug.print(\"hello\", .{});\n}\n" ** 100; + const line = "fn main() void {\n std.debug.print(\"hello\", .{});\n}\n"; + const ref: *const [line.len * 100]u8 = comptime blk: { + var buf: [line.len * 100]u8 = undefined; + for (0..100) |i| buf[i * line.len ..][0..line.len].* = line.*; + const out = buf; + break :blk &out; + }; const new = ref[0..2000] ++ "// inserted\n" ++ ref[2000..]; const size = try roundTrip(ref, new); try testing.expect(size < 32); diff --git a/app/store/PluginLoader.zig b/app/store/PluginLoader.zig index 333f793ba..b74bef099 100644 --- a/app/store/PluginLoader.zig +++ b/app/store/PluginLoader.zig @@ -492,7 +492,7 @@ pub fn register(host: *Host, opened: Opened, pre: ?PreRegister) LoadError!Loaded crash.addModule(o.plugin_id, plugin_version, @intFromPtr(o.register_fn)); errdefer crash.removeModule(@intFromPtr(o.register_fn)); - const status: dylib_api.RegisterStatus = @enumFromInt(o.register_fn(host)); + const status: dylib_api.RegisterStatus = @fromBackingInt(@intCast(o.register_fn(host))); switch (status) { .ok => {}, .err_abi_mismatch => return error.AbiMismatch, @@ -514,7 +514,7 @@ pub fn register(host: *Host, opened: Opened, pre: ?PreRegister) LoadError!Loaded } fn allowAbiWarn() bool { - if (builtin.mode != .Debug) return false; + if (builtin.mode != .debug) return false; if (std.c.getenv("FIZZY_PLUGIN_ABI_WARN")) |v| { return std.mem.eql(u8, std.mem.span(v), "1"); } @@ -533,8 +533,9 @@ pub fn probeName(allocator: std.mem.Allocator, path: []const u8) ?[]u8 { if (lib.lookup(dylib_api.GetManifestZonFn, dylib_api.symbol_manifest_zon)) |get_zon| { const zon = std.mem.span(get_zon()); if (zon.len > 0) { - if (sdk.Manifest.parse(allocator, zon)) |parsed| { - defer sdk.Manifest.free(allocator, parsed); + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + if (sdk.Manifest.parse(allocator, arena.allocator(), zon)) |parsed| { if (parsed.name.len > 0) return allocator.dupe(u8, parsed.name) catch null; } else |_| {} } @@ -571,8 +572,9 @@ pub fn probeManifestInfo(allocator: std.mem.Allocator, path: []const u8) ?Probed const get_zon = lib.lookup(dylib_api.GetManifestZonFn, dylib_api.symbol_manifest_zon) orelse return null; const zon = std.mem.span(get_zon()); if (zon.len == 0) return null; - const parsed = sdk.Manifest.parse(allocator, zon) catch return null; - defer sdk.Manifest.free(allocator, parsed); + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const parsed = sdk.Manifest.parse(allocator, arena.allocator(), zon) catch return null; var out: ProbedManifest = .{}; errdefer freeProbedManifest(allocator, out); @@ -615,10 +617,10 @@ pub fn probeVersionInfo(path: []const u8) ?PluginVersionInfo { if (lib.lookup(dylib_api.GetManifestZonFn, dylib_api.symbol_manifest_zon)) |get_zon| { const zon = std.mem.span(get_zon()); if (zon.len > 0) { - // Stack/arena-free parse: use page allocator for a short-lived Manifest. - const gpa = std.heap.page_allocator; - if (sdk.Manifest.parse(gpa, zon)) |parsed| { - defer sdk.Manifest.free(gpa, parsed); + // A short-lived Manifest, in an arena over the page allocator. + var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena.deinit(); + if (sdk.Manifest.parse(std.heap.page_allocator, arena.allocator(), zon)) |parsed| { if (std.SemanticVersion.parse(parsed.version)) |sv| { plugin_version = .{ .major = @intCast(sv.major), .minor = @intCast(sv.minor), .patch = @intCast(sv.patch) }; } else |_| {} diff --git a/app/store/PluginLoader_web.zig b/app/store/PluginLoader_web.zig index 45c8ef0e4..d6e906f72 100644 --- a/app/store/PluginLoader_web.zig +++ b/app/store/PluginLoader_web.zig @@ -264,7 +264,7 @@ pub const Prepared = struct { if (pre) |inject| { self.set_globals(if (inject.gpa) |gpa| @ptrCast(gpa) else null, inject.arg_b, inject.arg_c); } - const status: dylib_api.RegisterStatus = @enumFromInt(self.reg_fn(host)); + const status: dylib_api.RegisterStatus = @fromBackingInt(@intCast(self.reg_fn(host))); switch (status) { .ok => {}, .err_abi_mismatch => return error.AbiMismatch, diff --git a/app/store/PluginStore.zig b/app/store/PluginStore.zig index 4d205aec3..3cf6560bb 100644 --- a/app/store/PluginStore.zig +++ b/app/store/PluginStore.zig @@ -262,7 +262,7 @@ pub fn queueInstall(id: []const u8) void { pub fn isInstalling(id: []const u8) bool { if (web_in_flight.contains(id)) return true; const job = jobs.get(id) orelse return false; - const status: JobStatus = @enumFromInt(job.status.load(.acquire)); + const status: JobStatus = @fromBackingInt(@intCast(job.status.load(.acquire))); return status == .downloading or status == .downloaded; } @@ -1062,7 +1062,7 @@ pub fn applyPendingUpdate(id: []const u8) void { /// here too. pub fn updateStatus(id: []const u8) ?[]const u8 { if (web_in_flight.contains(id)) return "Updating\u{2026}"; - if (jobs.get(id)) |job| return switch (@as(JobStatus, @enumFromInt(job.status.load(.acquire)))) { + if (jobs.get(id)) |job| return switch (@as(JobStatus, @fromBackingInt(@intCast(job.status.load(.acquire))))) { .downloading => "Updating\u{2026}", .downloaded => "Installing\u{2026}", .failed => "Failed", @@ -1388,7 +1388,7 @@ pub fn tick() void { var i: usize = 0; while (i < jobs.count()) { const job = jobs.values()[i]; - switch (@as(JobStatus, @enumFromInt(job.status.load(.acquire)))) { + switch (@as(JobStatus, @fromBackingInt(@intCast(job.status.load(.acquire))))) { .downloading => i += 1, .failed => { // Nobody asked for a quiet job, so it leaves no failed card to retry from. Log it, @@ -1437,7 +1437,7 @@ pub fn tick() void { const n = @min(@errorName(err).len, job.err_buf.len); @memcpy(job.err_buf[0..n], @errorName(err)[0..n]); job.err_len = n; - job.status.store(@intFromEnum(JobStatus.failed), .release); + job.status.store(@backingInt(JobStatus.failed), .release); i += 1; continue; }; @@ -1670,10 +1670,10 @@ fn worker(job: *Job, io: std.Io) void { const n = @min(@errorName(err).len, job.err_buf.len); @memcpy(job.err_buf[0..n], @errorName(err)[0..n]); job.err_len = n; - job.status.store(@intFromEnum(JobStatus.failed), .release); + job.status.store(@backingInt(JobStatus.failed), .release); return; }; - job.status.store(@intFromEnum(JobStatus.downloaded), .release); + job.status.store(@backingInt(JobStatus.downloaded), .release); } fn removeJob(id: []const u8) void { @@ -1750,7 +1750,7 @@ fn buildJob(id: []const u8, url: []const u8, sha256: []const u8, opts: DownloadO errdefer a.free(dest); job.* = .{ - .status = .init(@intFromEnum(JobStatus.downloading)), + .status = .init(@backingInt(JobStatus.downloading)), .id = id_dup, .url = url_dup, .sha256 = sha_dup, @@ -3105,7 +3105,7 @@ fn drawCardControls(entry: StoreEntry) void { defer ctl.deinit(); // An in-flight / failed install job preempts the normal controls. - if (jobs.get(entry.id)) |job| switch (@as(JobStatus, @enumFromInt(job.status.load(.acquire)))) { + if (jobs.get(entry.id)) |job| switch (@as(JobStatus, @fromBackingInt(@intCast(job.status.load(.acquire))))) { .downloading => { drawInstalling(if (job.is_update) "Updating…" else "Installing…", muted); return; @@ -3250,7 +3250,7 @@ fn drawStoreCardControls(entry: StoreEntry) void { defer ctl.deinit(); // An in-flight / failed install job preempts the normal controls. - if (jobs.get(entry.id)) |job| switch (@as(JobStatus, @enumFromInt(job.status.load(.acquire)))) { + if (jobs.get(entry.id)) |job| switch (@as(JobStatus, @fromBackingInt(@intCast(job.status.load(.acquire))))) { .downloading => { drawInstalling(if (job.is_update) "Updating…" else "Installing…", muted); return; diff --git a/app/store/plugin_repo_asset.zig b/app/store/plugin_repo_asset.zig index f593406ae..c1b25299c 100644 --- a/app/store/plugin_repo_asset.zig +++ b/app/store/plugin_repo_asset.zig @@ -29,7 +29,7 @@ pub fn fetchOk(io: std.Io, url: []const u8, limit: std.Io.Limit) ?[]u8 { if (result.status != .ok) return null; const written = body.written(); - if (limit != .unlimited and limit != .nothing and written.len > @intFromEnum(limit)) return null; + if (limit != .unlimited and limit != .nothing and written.len > @backingInt(limit)) return null; return gpa().dupe(u8, written) catch null; } diff --git a/app/store/readme.zig b/app/store/readme.zig index a0f6e5d17..449fbd3bf 100644 --- a/app/store/readme.zig +++ b/app/store/readme.zig @@ -30,7 +30,7 @@ pub const Readme = struct { /// Captured at select time so the worker can wake the blocked event loop when the fetch /// finishes (otherwise the UI stays on "Loading…" until an unrelated input event). window: *dvui.Window, - status: std.atomic.Value(u8) = .init(@intFromEnum(Status.idle)), + status: std.atomic.Value(u8) = .init(@backingInt(Status.idle)), /// Fetched README bytes (app-allocator owned). Written once by the worker before it flips /// `status` to `ready` with release ordering; read on the UI thread only after an acquire /// load sees `ready`, so no lock is needed for the bytes themselves. @@ -48,7 +48,7 @@ pub const Readme = struct { preview: markdown.Preview = .{}, fn statusValue(self: *Readme) Status { - return @enumFromInt(self.status.load(.acquire)); + return @fromBackingInt(@intCast(self.status.load(.acquire))); } /// Start fetching `id`'s README (from its `repo` URL, optionally scoped to `subpath` within @@ -72,7 +72,7 @@ pub const Readme = struct { .io = dvui.io, .window = dvui.currentWindow(), }; - self.status.store(@intFromEnum(Status.fetching), .release); + self.status.store(@backingInt(Status.fetching), .release); return self; } @@ -85,7 +85,7 @@ pub const Readme = struct { return; } self.thread = std.Thread.spawn(.{}, worker, .{self}) catch { - self.status.store(@intFromEnum(Status.failed), .release); + self.status.store(@backingInt(Status.failed), .release); return; }; } @@ -113,7 +113,7 @@ pub const Readme = struct { if (comptime !is_wasm) return; if (self.statusValue() != .fetching) return; if (self.wasm_url_i >= self.wasm_url_len) { - self.status.store(@intFromEnum(Status.not_found), .release); + self.status.store(@backingInt(Status.not_found), .release); return; } switch (web_fetch.request(repo_asset.gpa(), self.wasm_urls[self.wasm_url_i])) { @@ -121,16 +121,16 @@ pub const Readme = struct { .failed => { self.wasm_url_i += 1; if (self.wasm_url_i >= self.wasm_url_len) - self.status.store(@intFromEnum(Status.not_found), .release); + self.status.store(@backingInt(Status.not_found), .release); }, .ready => |body| { const gpa = repo_asset.gpa(); self.bytes = gpa.dupe(u8, body) catch { - self.status.store(@intFromEnum(Status.failed), .release); + self.status.store(@backingInt(Status.failed), .release); return; }; self.image_base = gpa.dupe(u8, self.wasm_urls[self.wasm_url_i]) catch null; - self.status.store(@intFromEnum(Status.ready), .release); + self.status.store(@backingInt(Status.ready), .release); }, } } @@ -184,7 +184,7 @@ pub fn deinit() void { fn fillWasmCandidates(self: *Readme) void { var url_buf: [3][256]u8 = undefined; const candidates = repo_asset.rawGithubUrls(&url_buf, self.repo, self.subpath, readme_filename) orelse { - self.status.store(@intFromEnum(Status.not_found), .release); + self.status.store(@backingInt(Status.not_found), .release); return; }; const gpa = repo_asset.gpa(); @@ -194,7 +194,7 @@ fn fillWasmCandidates(self: *Readme) void { n += 1; } self.wasm_url_len = n; - if (n == 0) self.status.store(@intFromEnum(Status.not_found), .release); + if (n == 0) self.status.store(@backingInt(Status.not_found), .release); } fn worker(self: *Readme) void { @@ -206,14 +206,14 @@ fn worker(self: *Readme) void { if (repo_asset.readLocalAsset(self.io, self.subpath, readme_filename, limit)) |asset| { self.bytes = asset.bytes; self.image_base = asset.dir; - self.status.store(@intFromEnum(Status.ready), .release); + self.status.store(@backingInt(Status.ready), .release); return; } } var url_buf: [3][256]u8 = undefined; const candidates = repo_asset.rawGithubUrls(&url_buf, self.repo, self.subpath, readme_filename) orelse { - self.status.store(@intFromEnum(Status.not_found), .release); + self.status.store(@backingInt(Status.not_found), .release); return; }; @@ -222,9 +222,9 @@ fn worker(self: *Readme) void { self.bytes = body; // `url` lives in the worker's stack buffer — the preview needs it every frame. self.image_base = repo_asset.gpa().dupe(u8, url) catch null; - self.status.store(@intFromEnum(Status.ready), .release); + self.status.store(@backingInt(Status.ready), .release); return; } } - self.status.store(@intFromEnum(Status.not_found), .release); + self.status.store(@backingInt(Status.not_found), .release); } diff --git a/app/store/registry/store.zig b/app/store/registry/store.zig index 74d816491..7d4f7a247 100644 --- a/app/store/registry/store.zig +++ b/app/store/registry/store.zig @@ -46,7 +46,7 @@ pub const Catalog = struct { io: std.Io, summary_url: []const u8, shard_url: []const u8, - status_value: std.atomic.Value(u8) = .init(@intFromEnum(Status.idle)), + status_value: std.atomic.Value(u8) = .init(@backingInt(Status.idle)), mutex: std.Io.Mutex = .init, summary: ?ParsedSummary = null, shard: ?ParsedShard = null, @@ -92,7 +92,7 @@ pub const Catalog = struct { } pub fn status(self: *Catalog) Status { - return @enumFromInt(self.status_value.load(.acquire)); + return @fromBackingInt(@intCast(self.status_value.load(.acquire))); } /// Kick off a background refresh. No-op while one is already in flight. Must be called from @@ -106,10 +106,10 @@ pub const Catalog = struct { // Release the previous worker's group resources. `status() != .fetching` means it has // already stored its final status, so this returns as soon as that task unwinds. self.tasks.await(self.io) catch {}; - self.status_value.store(@intFromEnum(Status.fetching), .release); + self.status_value.store(@backingInt(Status.fetching), .release); // `concurrent`, not `async`: this must run on its own thread, never inline on the caller's. self.tasks.concurrent(self.io, worker, .{self}) catch { - self.status_value.store(@intFromEnum(Status.failed), .release); + self.status_value.store(@backingInt(Status.failed), .release); return; }; } @@ -118,12 +118,12 @@ pub const Catalog = struct { if (self.wasm_summary_url) |u| self.allocator.free(u); if (self.wasm_shard_url) |u| self.allocator.free(u); self.wasm_summary_url = cacheBust(self.allocator, self.io, self.summary_url) orelse { - self.status_value.store(@intFromEnum(Status.failed), .release); + self.status_value.store(@backingInt(Status.failed), .release); return; }; self.wasm_shard_url = cacheBust(self.allocator, self.io, self.shard_url); self.wasm_phase = .summary; - self.status_value.store(@intFromEnum(Status.fetching), .release); + self.status_value.store(@backingInt(Status.fetching), .release); } /// Advance in-flight browser GETs. UI thread, wasm only — a no-op natively. `fetcher` is the @@ -163,7 +163,7 @@ pub const Catalog = struct { .shard => { const url = self.wasm_shard_url orelse { self.wasm_phase = .idle; - self.status_value.store(@intFromEnum(Status.ready), .release); + self.status_value.store(@backingInt(Status.ready), .release); return; }; switch (fetcher.request(self.allocator, url)) { @@ -172,7 +172,7 @@ pub const Catalog = struct { // every card reads "no compatible build". .failed => { self.wasm_phase = .idle; - self.status_value.store(@intFromEnum(Status.ready), .release); + self.status_value.store(@backingInt(Status.ready), .release); }, .ready => |bytes| { if (std.json.parseFromSlice(registry.ReleaseShard, self.allocator, bytes, .{ @@ -183,7 +183,7 @@ pub const Catalog = struct { self.shard = parsed; } else |_| {} self.wasm_phase = .idle; - self.status_value.store(@intFromEnum(Status.ready), .release); + self.status_value.store(@backingInt(Status.ready), .release); }, } }, @@ -192,7 +192,7 @@ pub const Catalog = struct { fn failWasm(self: *Catalog) void { self.wasm_phase = .idle; - self.status_value.store(@intFromEnum(Status.failed), .release); + self.status_value.store(@backingInt(Status.failed), .release); } /// Runs in the task group. Every failure path — including `error.Canceled` from a shutdown @@ -200,7 +200,7 @@ pub const Catalog = struct { /// refresh: the app is on its way down, and a later `refresh` would overwrite it anyway. fn worker(self: *Catalog) void { const fresh_summary = registry.fetchSummary(self.allocator, self.io, self.summary_url) catch { - self.status_value.store(@intFromEnum(Status.failed), .release); + self.status_value.store(@backingInt(Status.failed), .release); return; }; // Best-effort: any failure (network, 404 for a brand-new fingerprint nobody has @@ -216,7 +216,7 @@ pub const Catalog = struct { self.shard = s; } self.mutex.unlock(self.io); - self.status_value.store(@intFromEnum(Status.ready), .release); + self.status_value.store(@backingInt(Status.ready), .release); } /// A joined view of the latest summary + this host's release shard, for the duration the diff --git a/app/store/store_icon.zig b/app/store/store_icon.zig index 55dcfccc7..60b68ea43 100644 --- a/app/store/store_icon.zig +++ b/app/store/store_icon.zig @@ -17,7 +17,7 @@ const icon_filename = "ICON.png"; const Status = enum(u8) { idle, fetching, ready, not_found, failed }; const IconEntry = struct { - status: std.atomic.Value(u8) = .init(@intFromEnum(Status.idle)), + status: std.atomic.Value(u8) = .init(@backingInt(Status.idle)), /// Fetched PNG bytes (app-allocator owned). Written by the worker; decoded on the UI thread. bytes: ?[]u8 = null, /// Decoded once on the UI thread after `bytes` is ready. @@ -28,7 +28,7 @@ const IconEntry = struct { wasm_url_i: usize = 0, fn statusValue(self: *IconEntry) Status { - return @enumFromInt(self.status.load(.acquire)); + return @fromBackingInt(@intCast(self.status.load(.acquire))); } fn deinit(self: *IconEntry) void { @@ -92,7 +92,7 @@ pub fn request(id: []const u8, repo: []const u8, subpath: []const u8) void { return; }; - entry.status.store(@intFromEnum(Status.fetching), .release); + entry.status.store(@backingInt(Status.fetching), .release); if (comptime is_wasm) { fillWasmCandidates(entry, repo_owned, subpath_owned); gpa.free(repo_owned); @@ -102,7 +102,7 @@ pub fn request(id: []const u8, repo: []const u8, subpath: []const u8) void { entry.thread = std.Thread.spawn(.{}, worker, .{ repo_owned, subpath_owned, entry }) catch { gpa.free(repo_owned); gpa.free(subpath_owned); - entry.status.store(@intFromEnum(Status.failed), .release); + entry.status.store(@backingInt(Status.failed), .release); return; }; } @@ -117,7 +117,7 @@ pub fn pump() void { fn pumpEntry(entry: *IconEntry) void { if (entry.statusValue() != .fetching) return; if (entry.wasm_url_i >= entry.wasm_url_len) { - entry.status.store(@intFromEnum(Status.not_found), .release); + entry.status.store(@backingInt(Status.not_found), .release); return; } switch (web_fetch.request(repo_asset.gpa(), entry.wasm_urls[entry.wasm_url_i])) { @@ -125,14 +125,14 @@ fn pumpEntry(entry: *IconEntry) void { .failed => { entry.wasm_url_i += 1; if (entry.wasm_url_i >= entry.wasm_url_len) - entry.status.store(@intFromEnum(Status.not_found), .release); + entry.status.store(@backingInt(Status.not_found), .release); }, .ready => |body| { entry.bytes = repo_asset.gpa().dupe(u8, body) catch { - entry.status.store(@intFromEnum(Status.failed), .release); + entry.status.store(@backingInt(Status.failed), .release); return; }; - entry.status.store(@intFromEnum(Status.ready), .release); + entry.status.store(@backingInt(Status.ready), .release); }, } } @@ -140,7 +140,7 @@ fn pumpEntry(entry: *IconEntry) void { fn fillWasmCandidates(entry: *IconEntry, repo: []const u8, subpath: []const u8) void { var url_buf: [3][256]u8 = undefined; const candidates = repo_asset.rawGithubUrls(&url_buf, repo, subpath, icon_filename) orelse { - entry.status.store(@intFromEnum(Status.not_found), .release); + entry.status.store(@backingInt(Status.not_found), .release); return; }; const gpa = repo_asset.gpa(); @@ -150,7 +150,7 @@ fn fillWasmCandidates(entry: *IconEntry, repo: []const u8, subpath: []const u8) n += 1; } entry.wasm_url_len = n; - if (n == 0) entry.status.store(@intFromEnum(Status.not_found), .release); + if (n == 0) entry.status.store(@backingInt(Status.not_found), .release); } /// Draw the cached icon for `id` into the current dvui parent, at `size` square. Returns true @@ -200,23 +200,23 @@ fn worker(repo_owned: []u8, subpath_owned: []u8, entry: *IconEntry) void { if (repo_asset.readLocalAsset(io, subpath_owned, icon_filename, limit)) |asset| { repo_asset.gpa().free(asset.dir); // an icon has nothing relative to resolve entry.bytes = asset.bytes; - entry.status.store(@intFromEnum(Status.ready), .release); + entry.status.store(@backingInt(Status.ready), .release); return; } } var url_buf: [3][256]u8 = undefined; const candidates = repo_asset.rawGithubUrls(&url_buf, repo_owned, subpath_owned, icon_filename) orelse { - entry.status.store(@intFromEnum(Status.not_found), .release); + entry.status.store(@backingInt(Status.not_found), .release); return; }; for (candidates.slice()) |url| { if (repo_asset.fetchOk(io, url, limit)) |body| { entry.bytes = body; - entry.status.store(@intFromEnum(Status.ready), .release); + entry.status.store(@backingInt(Status.ready), .release); return; } } - entry.status.store(@intFromEnum(Status.not_found), .release); + entry.status.store(@backingInt(Status.not_found), .release); } diff --git a/app/update/auto_update.zig b/app/update/auto_update.zig index 2cc28af01..fe2f7420c 100644 --- a/app/update/auto_update.zig +++ b/app/update/auto_update.zig @@ -18,12 +18,7 @@ pub var hooks: Hooks = .{}; pub const impl: bool = build_opts.velopack_enabled and builtin.target.cpu.arch != .wasm32; -const Vpk = if (impl) - @cImport({ - @cInclude("Velopack.h"); - }) -else - struct {}; +const Vpk = if (impl) @import("velopack_c") else struct {}; pub fn logVpkError(prefix: []const u8) void { if (!impl) return; @@ -93,7 +88,7 @@ fn openGithubManager(allocator: std.mem.Allocator, url: []const u8) error{OutOfM if (!impl) return null; if (url.len == 0) return null; - const repo_url_z = try allocator.dupeZ(u8, url); + const repo_url_z = try allocator.dupeSentinel(u8, url, 0); defer allocator.free(repo_url_z); const source: ?*Vpk.vpkc_update_source_t = Vpk.vpkc_new_source_github(repo_url_z.ptr, null, false); @@ -125,7 +120,7 @@ pub fn openUpdateManager(io: std.Io, allocator: std.mem.Allocator) error{OutOfMe if (std.c.getenv("FIZZY_AUTOUPDATE_URL")) |raw| { const update_url = std.mem.span(raw); if (update_url.len == 0) return null; - const update_url_z = try allocator.dupeZ(u8, update_url); + const update_url_z = try allocator.dupeSentinel(u8, update_url, 0); defer allocator.free(update_url_z); var manager: ?*Vpk.vpkc_update_manager_t = null; if (!Vpk.vpkc_new_update_manager(update_url_z.ptr, null, null, &manager)) { diff --git a/app/update/update_install.zig b/app/update/update_install.zig index 24a1adb94..235ed8da0 100644 --- a/app/update/update_install.zig +++ b/app/update/update_install.zig @@ -19,12 +19,7 @@ const dvui = @import("dvui"); const auto_update = @import("auto_update.zig"); const build_opts = @import("build_opts"); -const Vpk = if (auto_update.impl) - @cImport({ - @cInclude("Velopack.h"); - }) -else - struct {}; +const Vpk = if (auto_update.impl) @import("velopack_c") else struct {}; const UpdateJob = @This(); @@ -41,7 +36,7 @@ allocator: std.mem.Allocator, window: *dvui.Window, io: std.Io, -phase: std.atomic.Value(u8) = .init(@intFromEnum(Phase.queued)), +phase: std.atomic.Value(u8) = .init(@backingInt(Phase.queued)), /// 0–100 from Velopack's download callback. Stays at 0 outside `.downloading`. progress: std.atomic.Value(u8) = .init(0), /// Worker → main publish flag. @@ -141,7 +136,7 @@ pub fn applyAtExit() bool { pub fn currentPhase(job: *const UpdateJob) Phase { const raw = job.phase.load(.acquire); - return @enumFromInt(raw); + return @fromBackingInt(@intCast(raw)); } pub fn progressPercent(job: *const UpdateJob) u8 { @@ -184,7 +179,7 @@ fn setFailed(job: *UpdateJob, e: auto_update.UpdateInstallError) void { } else { job.err_msg_len = 0; } - job.phase.store(@intFromEnum(Phase.failed), .release); + job.phase.store(@backingInt(Phase.failed), .release); } fn workerMain(job: *UpdateJob) void { @@ -202,7 +197,7 @@ fn workerMain(job: *UpdateJob) void { return; } - job.phase.store(@intFromEnum(Phase.downloading), .release); + job.phase.store(@backingInt(Phase.downloading), .release); dvui.refresh(job.window, @src(), null); const mgr_opaque = auto_update.openUpdateManager(job.io, job.allocator) catch { @@ -223,7 +218,7 @@ fn workerMain(job: *UpdateJob) void { switch (result) { Vpk.UPDATE_AVAILABLE => {}, Vpk.NO_UPDATE_AVAILABLE, Vpk.REMOTE_IS_EMPTY => { - job.phase.store(@intFromEnum(Phase.no_update), .release); + job.phase.store(@backingInt(Phase.no_update), .release); return; }, Vpk.UPDATE_ERROR => { @@ -250,6 +245,6 @@ fn workerMain(job: *UpdateJob) void { job.manager = mgr_opaque; job.update_info = u; job.progress.store(100, .monotonic); - job.phase.store(@intFromEnum(Phase.downloaded), .release); + job.phase.store(@backingInt(Phase.downloaded), .release); job.downloaded_unseen.store(true, .release); } diff --git a/app/update/update_notify.zig b/app/update/update_notify.zig index 195ffc128..c6657ada2 100644 --- a/app/update/update_notify.zig +++ b/app/update/update_notify.zig @@ -16,7 +16,7 @@ const Phase = enum(u8) { }; var launched: bool = false; -var phase: std.atomic.Value(u8) = .init(@intFromEnum(Phase.pending)); +var phase: std.atomic.Value(u8) = .init(@backingInt(Phase.pending)); /// Set by `startLaunchCheck` when the "update available" state is faked for UI work /// (`Constants.debug_simulate_update_available`). Nothing may act on it beyond drawing the @@ -44,7 +44,7 @@ const PROGRESS_TOAST_TIMEOUT_US: i32 = std.math.maxInt(i32); /// Stable, non-null subwindow id for this toast. Using a non-null id makes DVUI's /// default `toastsShow(null, …)` (run in `Window.end`) skip it, so we can render /// it ourselves in a custom rect via `dvui.toastsFor(SUBWINDOW_ID)`. -const SUBWINDOW_ID: dvui.Id = @enumFromInt(0xF122_DA70_AD0E_71D0); +const SUBWINDOW_ID: dvui.Id = @fromBackingInt(@intCast(0xF122_DA70_AD0E_71D0)); // Estimated toast size — used for the FloatingWidget bounding rect and for // computing the bottom-left anchor relative to the infobar. The real toast @@ -63,22 +63,22 @@ pub fn startLaunchCheck(io: std.Io, simulate_out_of_date: bool) void { const fake = "9.9.9 (simulated)"; @memcpy(remote_ver[0..fake.len], fake); remote_ver_len = fake.len; - phase.store(@intFromEnum(Phase.done_yes), .release); + phase.store(@backingInt(Phase.done_yes), .release); return; } if (!auto_update.impl) { - phase.store(@intFromEnum(Phase.done_no), .release); + phase.store(@backingInt(Phase.done_no), .release); return; } if (!auto_update.installLayoutSupported(io)) { - phase.store(@intFromEnum(Phase.done_no), .release); + phase.store(@backingInt(Phase.done_no), .release); return; } const thread = std.Thread.spawn(.{}, worker, .{io}) catch { - phase.store(@intFromEnum(Phase.done_no), .release); + phase.store(@backingInt(Phase.done_no), .release); return; }; thread.detach(); @@ -87,7 +87,7 @@ pub fn startLaunchCheck(io: std.Io, simulate_out_of_date: bool) void { fn worker(io: std.Io) void { var ver_buf: [128]u8 = undefined; const summary = auto_update.checkRemoteVersionSummary(io, std.heap.page_allocator, &ver_buf) catch { - phase.store(@intFromEnum(Phase.done_no), .release); + phase.store(@backingInt(Phase.done_no), .release); return; }; switch (summary) { @@ -95,19 +95,19 @@ fn worker(io: std.Io) void { const n = @min(v.len, remote_ver.len); @memcpy(remote_ver[0..n], v[0..n]); remote_ver_len = n; - phase.store(@intFromEnum(Phase.done_yes), .release); + phase.store(@backingInt(Phase.done_yes), .release); }, .no_update, .remote_empty => { - phase.store(@intFromEnum(Phase.done_no), .release); + phase.store(@backingInt(Phase.done_no), .release); }, else => { - phase.store(@intFromEnum(Phase.done_no), .release); + phase.store(@backingInt(Phase.done_no), .release); }, } } pub fn badgeVisible() bool { - return phase.load(.acquire) == @intFromEnum(Phase.done_yes); + return phase.load(.acquire) == @backingInt(Phase.done_yes); } /// Where the launch update check has got to, for anything that must sequence itself *behind* a @@ -138,7 +138,7 @@ pub fn appUpdateState() AppUpdateState { else => return .installing, }; } - return switch (@as(Phase, @enumFromInt(phase.load(.acquire)))) { + return switch (@as(Phase, @fromBackingInt(@intCast(phase.load(.acquire))))) { .pending => .checking, .done_no => .none, .done_yes => .available, diff --git a/app/watch/folder_events.zig b/app/watch/folder_events.zig index 736fe2334..12a6f4bed 100644 --- a/app/watch/folder_events.zig +++ b/app/watch/folder_events.zig @@ -372,7 +372,7 @@ pub const Watches = struct { /// for, wherever it was left out before. fn done(self: *Watches, path: []const u8, owed: Owed) void { inline for (.{ &self.frontier, &self.todo }) |map| { - if (map.getIndex(path)) |i| if (@intFromEnum(map.values()[i]) <= @intFromEnum(owed)) { + if (map.getIndex(path)) |i| if (@backingInt(map.values()[i]) <= @backingInt(owed)) { const k = map.keys()[i]; map.swapRemoveAt(i); self.gpa.free(k); @@ -480,7 +480,7 @@ pub const Watches = struct { fn leftOut(self: *Watches, path: []const u8, owed: Owed, ctx: anytype) void { const gop = self.frontier.getOrPut(self.gpa, path) catch return self.degrade(ctx); if (gop.found_existing) { - if (@intFromEnum(owed) > @intFromEnum(gop.value_ptr.*)) gop.value_ptr.* = owed; + if (@backingInt(owed) > @backingInt(gop.value_ptr.*)) gop.value_ptr.* = owed; return; } gop.key_ptr.* = self.gpa.dupe(u8, path) catch { @@ -632,7 +632,7 @@ pub const Watches = struct { /// Where `Doorbell` builds: POSIX only. The walker that uses it exists only for a backend that /// watches folder by folder (inotify, kqueue), and none of those is on Windows. -pub const has_doorbell = builtin.os.tag != .windows and !builtin.cpu.arch.isWasm(); +pub const has_doorbell = builtin.target.os.tag != .windows and !builtin.target.cpu.arch.isWasm(); /// Wakes a thread from another that may not wait, allocate or touch an `Io`: nightwatch's /// reader. A pipe with a flag in front of it, so a burst of rings is one byte. @@ -1068,7 +1068,7 @@ test "a folder refused for its own reasons is left out, said once, and the walk } test "a folder that can't be read is left out and said; the rest is watched" { - if (builtin.os.tag == .windows) return error.SkipZigTest; + if (builtin.target.os.tag == .windows) return error.SkipZigTest; var t: TestTree = .{ .tmp = undefined }; try t.init(); defer t.tmp.cleanup(); diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 988a5ab8a..4657436f0 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -31,6 +31,13 @@ The `.m` files in `src/platform/macos/` are built **without ARC**, under a frame - `orderedIndex` is stale right after `setLevel:`: order after a level change, don't ask first. - A child window (`addChildWindow:`) drops to its parent's level. Set the level after attaching, and attach only once it is shown. + - No macOS window both stays above another and has a full screen of its own. A child window + moves with its parent and goes into full screen with it. A window above the normal level is + taken into the app's full-screen Space as a palette, on the way there and when the person + swipes to it, before the app hears the Space changed. A normal window comes forward when + clicked. So a float in the OS's frame (`ViewportFrame.native`) is an ordinary window: don't + attach it or raise its level to keep it above the main one. Three rounds of that each failed + at a new moment (fizzyedit/fizzy#483, #492). - Borderless windows join the Window menu only through `changeWindowsItem:`. - **A press reaches SDL3View but not SDL:** SDL3View inherits NSView's `-mouseDown:`, which keeps the press in a full-screen window. `visual_effect_view.m` adds a forwarding one. Add the @@ -41,6 +48,8 @@ The `.m` files in `src/platform/macos/` are built **without ARC**, under a frame - AppKit pictures the after-state synchronously *inside* `toggleFullScreen:`. - Whoever moves a window's frame sets its drawable size, because SDL hears of the new size late. - Take frame jumps only inside the app's frame transaction. + - Step a still window's way (`WindowMonitor.still`) only in the app's frames. Stepped by the + pump between them, its backdrop runs ahead of a late picture and shows as a band round it. - Read the pixel density from `backingScaleFactor`. - A sandbox fizzy launched from a shell isn't the active app, and AppKit stalls or refuses its transitions. diff --git a/backend/build.zig b/backend/build.zig index 86ad7433e..30817b4a4 100644 --- a/backend/build.zig +++ b/backend/build.zig @@ -23,7 +23,7 @@ pub fn build(b: *std.Build) void { // harness, against the patched library. Arguments after `--` reach the runner: // `--filter `, `--require-gpu `. const test_sdl_step = b.step("test-sdl", "Build and run the suites for fizzy's SDL patches"); - const sdl_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseFast else optimize; + const sdl_optimize: std.builtin.OptimizeMode = if (optimize == .debug) .fast else optimize; if (sdlDependency(b, target, sdl_optimize, null)) |sdl| { const source = patchedSdl(b).?; const sdl_test = b.addLibrary(.{ @@ -54,7 +54,7 @@ pub fn build(b: *std.Build) void { suites.root_module.linkLibrary(sdl_test); suites.root_module.linkLibrary(sdl.artifact("SDL3")); const run = b.addRunArtifact(suites); - if (b.args) |args| run.addArgs(args); + run.addPassthruArgs(); test_sdl_step.dependOn(&run.step); } } @@ -109,7 +109,7 @@ pub fn backendModule(dep: *std.Build.Dependency, dvui_mod: *std.Build.Module, op // device, every window's present. A Debug frame spent much of itself there (`sample`), and a // window's way into full screen ran at half the display's rate in Debug alone. The app's own // code stays Debug; stepping into SDL's C is what a Debug app gives up. - const sdl_optimize: std.builtin.OptimizeMode = if (opts.optimize == .Debug) .ReleaseFast else opts.optimize; + const sdl_optimize: std.builtin.OptimizeMode = if (opts.optimize == .debug) .fast else opts.optimize; const sdl_dep = sdlDependency(b, opts.target, sdl_optimize, opts.macos_sdk); const sdl_translate_c = b.addTranslateC(.{ @@ -147,7 +147,8 @@ pub fn backendModule(dep: *std.Build.Dependency, dvui_mod: *std.Build.Module, op fn patchedSdl(b: *std.Build) ?std.Build.LazyPath { const io = b.graph.io; var names: std.ArrayList([]const u8) = .empty; - var dir = b.build_root.handle.openDir(io, "patches/sdl", .{ .iterate = true }) catch |err| + b.dependOnDirectoryContents(b.path("patches/sdl")); + var dir = b.root.openDir(io, "patches/sdl", .{ .iterate = true }) catch |err| std.debug.panic("backend: open patches/sdl: {t}", .{err}); defer dir.close(io); var it = dir.iterate(); diff --git a/backend/build.zig.zon b/backend/build.zig.zon index 715385de8..5674be36a 100644 --- a/backend/build.zig.zon +++ b/backend/build.zig.zon @@ -2,15 +2,15 @@ .name = .fizzy_backend, .version = "0.1.0", .fingerprint = 0x2bf08855a368b9a8, - .minimum_zig_version = "0.16.0", + .minimum_zig_version = "0.17.0", .dependencies = .{ // SDL3 for the window, its events and the GPU: upstream SDL, patched at build time with // `patches/sdl` (marler8997/zig-patch), built by fizzyedit/sdl_zig's `fizzy` branch, which // takes the patched tree (`-Dsdl_source`). How the patches are made and how SDL is updated: // fizzy's docs/DEPENDENCIES.md. Pinned here, by the package that uses them, and nowhere else. .sdl = .{ - .url = "https://github.com/fizzyedit/sdl_zig/archive/a99807da9b6217422449a824cb24de4d97f482a3.tar.gz", - .hash = "sdl-1.0.3+3.4.16-i4QD0f8KqgC0YeoFofQA7ZnPQWkyEVe2MJ-jhVnFqYk9", + .url = "https://github.com/fizzyedit/sdl_zig/archive/ef7eb36b0b28aeac933d1bcdc152c56654891780.tar.gz", + .hash = "sdl-1.0.3+3.4.16-i4QD0fEMqgARU41cFUViN07xvtpE9OeVQ6bDHadi1OfP", .lazy = true, }, // The release sdl_zig's own `.sdl` pins: the same tarball, fetched once. @@ -26,8 +26,8 @@ }, // AppKit from Zig, for the macOS platform pieces (and an app's own, `objcModule`). .zig_objc = .{ - .url = "https://github.com/foxnne/zig-objc/archive/c8de82ff80281215ad92900866dab7103a8efa8b.tar.gz", - .hash = "zig_objc-0.0.0-Ir_Sp9gsAQCPAJc0oF5xoWePHWP6Y6tCphDeyNUThJoi", + .url = "https://github.com/foxnne/zig-objc/archive/f90394bce753153e387117cff6bf5536cd08f0c5.tar.gz", + .hash = "zig_objc-0.0.0-Ir_Sp1orAQDJQI7i3urWjRRHXVlB7ZG4pbKY3H8hX1AU", .lazy = true, }, // Win32 from Zig, for the Windows platform pieces (and an app's own, `win32Module`). diff --git a/backend/src/GpuRenderer.zig b/backend/src/GpuRenderer.zig index 50d5b9f41..671bd620f 100644 --- a/backend/src/GpuRenderer.zig +++ b/backend/src/GpuRenderer.zig @@ -243,6 +243,10 @@ verts: std.ArrayList(Vertex) = .empty, indices: std.ArrayList(Index) = .empty, draws: std.ArrayList(Draw) = .empty, uniforms: std.ArrayList([4]f32) = .empty, +/// Where the frame is laid down in the window, physical pixels from its top left: nothing but while +/// the main window stands still on its way into or out of full screen, its picture growing or +/// shrinking in it (`SDLBackend.places_main_picture`). +window_offset: [2]f32 = .{ 0, 0 }, active: ?Active = null, blend_override: ?Blend = null, @@ -272,7 +276,7 @@ pub fn primary() ?*GpuRenderer { /// D3D12 the window takes whatever SDL picks, and is opaque. fn createDevice(window: *c.SDL_Window) ?*c.SDL_GPUDevice { const formats = c.SDL_GPU_SHADERFORMAT_SPIRV | c.SDL_GPU_SHADERFORMAT_DXIL | c.SDL_GPU_SHADERFORMAT_MSL; - const debug = builtin.mode == .Debug; + const debug = builtin.mode == .debug; if (builtin.target.os.tag == .windows and c.SDL_GetWindowFlags(window) & c.SDL_WINDOW_TRANSPARENT != 0) { if (c.SDL_CreateGPUDevice(formats, debug, "direct3d12")) |device| return device; log.warn("no D3D12 device ({s}); the transparent window will be opaque", .{c.SDL_GetError()}); @@ -881,6 +885,8 @@ fn clearPass(self: *GpuRenderer, tex: *Tex) !void { } fn encodePass(self: *GpuRenderer, dest: *c.SDL_GPUTexture, dw: u32, dh: u32, format: c.SDL_GPUTextureFormat, load: c.SDL_GPULoadOp) !void { + // Into the window: moved by `window_offset`, physical pixels. + const off: [2]f32 = if (self.swapchain == dest) self.window_offset else .{ 0, 0 }; if (self.target != null) try self.clearSampled(); // Before the rings are asked for room: they cycle per command buffer (`epoch`). const cmd = try self.ensureCmd(); @@ -898,7 +904,7 @@ fn encodePass(self: *GpuRenderer, dest: *c.SDL_GPUTexture, dw: u32, dh: u32, for const sx = 2.0 / @as(f32, @floatFromInt(dw)); const sy = 2.0 / @as(f32, @floatFromInt(dh)); for (self.verts.items, 0..) |v, i| { - out[i] = .{ .x = v.x * sx - 1.0, .y = 1.0 - v.y * sy, .col = v.col, .u = v.u, .v = v.v }; + out[i] = .{ .x = (v.x + off[0]) * sx - 1.0, .y = 1.0 - (v.y + off[1]) * sy, .col = v.col, .u = v.u, .v = v.v }; } @memcpy(staged.ptr[ioff..total], std.mem.sliceAsBytes(self.indices.items)); self.stream_ring.unmap(self.device); @@ -939,7 +945,10 @@ fn encodePass(self: *GpuRenderer, dest: *c.SDL_GPUTexture, dw: u32, dh: u32, for var last_n: u32 = 0; for (self.draws.items) |d| { var r = full; - if (d.clip) |cr| { + if (d.clip) |clip| { + var cr = clip; + cr.x += @intFromFloat(off[0]); + cr.y += @intFromFloat(off[1]); const x0 = @max(cr.x, 0); const y0 = @max(cr.y, 0); const x1 = @min(cr.x + cr.w, full.w); @@ -1310,7 +1319,7 @@ pub fn programBegin(self: *GpuRenderer, id: u32, textures: []const ?*anyopaque, } pub fn programBlend(self: *GpuRenderer, mode: u8) void { - self.blend_override = @enumFromInt(@as(u2, @truncate(mode))); + self.blend_override = @fromBackingInt(@intCast(@as(u2, @truncate(mode)))); } pub fn programEnd(self: *GpuRenderer) void { diff --git a/backend/src/Health.zig b/backend/src/Health.zig index 57c0c8c02..eeed51b86 100644 --- a/backend/src/Health.zig +++ b/backend/src/Health.zig @@ -77,11 +77,11 @@ pub const Counts = struct { destroyed: u32 = 0, fn add(a: *Counts, b: Counts) void { - inline for (std.meta.fields(Counts)) |f| @field(a, f.name) +|= @field(b, f.name); + inline for (@typeInfo(Counts).@"struct".field_names, @typeInfo(Counts).@"struct".field_types, @typeInfo(Counts).@"struct".field_attrs) |f_name, _, _| @field(a, f_name) +|= @field(b, f_name); } fn max(a: *Counts, b: Counts) void { - inline for (std.meta.fields(Counts)) |f| @field(a, f.name) = @max(@field(a, f.name), @field(b, f.name)); + inline for (@typeInfo(Counts).@"struct".field_names, @typeInfo(Counts).@"struct".field_types, @typeInfo(Counts).@"struct".field_attrs) |f_name, _, _| @field(a, f_name) = @max(@field(a, f_name), @field(b, f_name)); } }; @@ -284,7 +284,7 @@ test "SDL's log is counted from any thread, and the first error kept" { for (&pool) |*th| th.* = try std.Thread.spawn(.{}, struct { fn run(hp: *Health) void { for (0..each) |_| { - hp.noteLog(.@"error", "x" ** (first_error_cap + 10)); + hp.noteLog(.@"error", &@as([first_error_cap + 10]u8, @splat('x'))); hp.noteLog(.warning, "w"); } } @@ -295,7 +295,7 @@ test "SDL's log is counted from any thread, and the first error kept" { try std.testing.expectEqual(@as(u32, threads * each), s.sdl_errors); try std.testing.expectEqual(@as(u32, threads * each + 1), s.sdl_warnings); // Cut to the cap, written once. - try std.testing.expectEqualStrings("x" ** first_error_cap, s.first_sdl_error); + try std.testing.expectEqualStrings(&@as([first_error_cap]u8, @splat('x')), s.first_sdl_error); } test "a snapshot round-trips through ZON" { @@ -312,10 +312,12 @@ test "a snapshot round-trips through ZON" { var out: std.Io.Writer.Allocating = .init(std.testing.allocator); defer out.deinit(); try std.zon.stringify.serialize(s, .{}, &out.writer); - const text = try std.testing.allocator.dupeZ(u8, out.written()); + const text = try std.testing.allocator.dupeSentinel(u8, out.written(), 0); defer std.testing.allocator.free(text); - const back = try std.zon.parse.fromSliceAlloc(Snapshot, std.testing.allocator, text, null, .{}); - defer std.zon.parse.free(std.testing.allocator, back); + var back_arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer back_arena.deinit(); + var back_diag: std.zon.parse.Diagnostics = undefined; + const back = try std.zon.parse.fromSlice(Snapshot, .{ .gpa = std.testing.allocator, .arena = back_arena.allocator(), .source = text, .diagnostics = &back_diag }); try std.testing.expectEqual(s.presents, back.presents); try std.testing.expectEqual(s.os_windows, back.os_windows); try std.testing.expectEqual(s.ns_windows, back.ns_windows); diff --git a/backend/src/SDLBackend.zig b/backend/src/SDLBackend.zig index 87b57fd29..3f188f979 100644 --- a/backend/src/SDLBackend.zig +++ b/backend/src/SDLBackend.zig @@ -19,7 +19,7 @@ pub const viewport_map = @import("viewport_map.zig"); const TouchSlots = @import("touch_slots.zig"); /// The OS windows besides the main one an app draws with (`Viewport`). pub const viewports = @import("viewports.zig"); -/// `dvui.osWindow` as a window of its own from the one frame (`osWindowFloating`). +/// dvui's floating windows as OS windows of their own from the one frame (`floatingWindowOs`). pub const os_windows = @import("os_windows.zig"); /// What this backend keeps count of as it runs (`health`). pub const Health = @import("Health.zig"); @@ -96,6 +96,19 @@ sdl_quit: bool = true, clear_window_on_begin: bool = false, /// See `mainPicture`. main_picture: ?viewport_map.Rect = null, +/// The backend lays the main window's frame down where its picture is (`mainPicture`), the app +/// drawing straight to the window: a plain dvui app's, whose picture is also below the title bar +/// (`platform.macos_monitor.titlebarStrip`). Fizzy draws to a target and lays it down itself +/// (`core.gfx.FrameTarget`), and keeps its content clear of the traffic lights itself. +places_main_picture: bool = false, +/// A plain dvui app's main window's size its shadow was last taken at, and the frames it is taken +/// again for: clear over its backdrop (`platform.window.nativeTitleBar`), AppKit shapes the shadow +/// — and the outline that comes with it — from what is drawn, as a float's window has it +/// (`Viewport.shadow_size`). +main_shadow_size: [2]i32 = .{ 0, 0 }, +main_shadow_frames: u8 = 0, +/// Whether a viewport's window, itself seen, was presented into this frame (`unseen`). +viewport_seen: bool = false, /// Added to the wall clock by `nanoTime`: how far a demo's silent frames have moved the app's /// clock on (`app.automation.Player.frames`). Only grows. clock_ahead_ns: i128 = 0, @@ -125,7 +138,7 @@ stack_stale: bool = true, /// is skinned so as it opens (`viewportGlass`). window_skin: ?dvui.Color = null, -const cursor_enum_count = @typeInfo(dvui.enums.Cursor).@"enum".fields.len; +const cursor_enum_count = @typeInfo(dvui.enums.Cursor).@"enum".field_names.len; /// The most viewports open at once. pub const max_viewports = 8; @@ -222,6 +235,9 @@ pub const Viewport = struct { menu: bool = false, /// Framed by the app, not the OS (`ViewportFrame.app`): borderless everywhere, macOS too. app_frame: bool = false, + /// Framed by the OS as any app's window is (`ViewportFrame.native`): its title bar, frame and + /// hit-testing are the OS's own, and nothing of fizzy's dresses it. + native: bool = false, /// macOS, framed by the app: the size its shadow was last taken at, and how many frames more /// to take it again. AppKit shapes a clear window's shadow from what is drawn in it, so it is /// taken again once the window shows and after it changes size, a frame late, once the @@ -353,6 +369,12 @@ pub var window_create_hook: ?*const fn (props: c.SDL_PropertiesID) void = null; /// sees the answer change without asking for frames itself. pub var frame_ended_hook: ?*const fn (end_micros: ?u32) bool = null; +/// Which of dvui's floating windows are shown in OS windows of their own (`floatingWindowOs`, +/// `os_windows`): those that ask (`os_window`, as `dvui.osWindow`'s does), every one (not modal), +/// or none, for an app that takes floats out into windows itself, as fizzy does (`Popout`). Set +/// before the first frame. +pub var floating_os_windows: enum { asked, all, none } = .asked; + /// The backend's health counters now (`Health`): frames, presents, swapchain resizes, the OS /// windows alive, what SDL logged, frame-time percentiles and each frame's GPU work. Kept current /// whether or not anyone reads them; reading them sorts the frame times and asks SDL (and AppKit) @@ -476,7 +498,7 @@ const quit_signals = struct { const n = std.c.read(fd, &buf, buf.len); if (n == 0) return; if (n < 0) { - if (std.c._errno().* == @intFromEnum(posix.E.INTR)) continue; + if (std.c._errno().* == @backingInt(posix.E.INTR)) continue; return; } const pool = if (comptime has_objc) objc_autoreleasePoolPush() else null; @@ -656,16 +678,16 @@ pub const WindowGeometry = struct { fn filePath(buf: []u8, options: InitOptions) ?[:0]const u8 { if (options.pref_path) |dir| { if (std.mem.endsWith(u8, dir, std.fs.path.sep_str)) { - return std.fmt.bufPrintZ(buf, "{s}{s}", .{ dir, zon_file_name }) catch null; + return std.fmt.bufPrintSentinel(buf, "{s}{s}", .{ dir, zon_file_name }, 0) catch null; } - return std.fmt.bufPrintZ(buf, "{s}{c}{s}", .{ dir, std.fs.path.sep, zon_file_name }) catch null; + return std.fmt.bufPrintSentinel(buf, "{s}{c}{s}", .{ dir, std.fs.path.sep, zon_file_name }, 0) catch null; } const pref = c.SDL_GetPrefPath(options.org.ptr, options.title.ptr) orelse { logErr("SDL_GetPrefPath in WindowGeometry") catch {}; return null; }; defer c.SDL_free(pref); - return std.fmt.bufPrintZ(buf, "{s}" ++ zon_file_name, .{std.mem.span(@as([*:0]const u8, @ptrCast(pref)))}) catch null; + return std.fmt.bufPrintSentinel(buf, "{s}" ++ zon_file_name, .{std.mem.span(@as([*:0]const u8, @ptrCast(pref)))}, 0) catch null; } fn fromSaved(saved: Saved) ?WindowGeometry { @@ -703,13 +725,10 @@ pub const WindowGeometry = struct { if (data.len >= nul_buf.len) return null; @memcpy(nul_buf[0..data.len], data); nul_buf[data.len] = 0; - const saved = std.zon.parse.fromSlice( - Saved, - std.heap.page_allocator, - nul_buf[0..data.len :0], - null, - .{ .ignore_unknown_fields = true }, - ) catch return null; + var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena.deinit(); + var diag: std.zon.parse.Diagnostics = undefined; + const saved = std.zon.parse.fromSliceNoAlloc(Saved, .{ .gpa = std.heap.page_allocator, .arena = arena.allocator(), .source = nul_buf[0..data.len :0], .diagnostics = &diag, .ignore_unknown_fields = true }) catch return null; return fromSaved(saved); } @@ -1056,12 +1075,108 @@ fn addEventWinRecursive(self: *SDLBackend, event: *c.SDL_Event, win: *dvui.Windo // --------------------------------------------------------------------------------------------- // dvui's OS windows -/// A `dvui.osWindow`, which dvui draws as a floating window here: this backend makes no -/// `dvui.Window` of its own for one (no `initWindowSecondary`), so it is a floating window in the -/// one frame, and `os_windows` shows it in an OS window of its own. dvui calls this each frame it is -/// drawn, with the header it is moved by (physical). -pub fn osWindowFloating(_: *SDLBackend, id: dvui.Id, header: dvui.Rect.Physical, opts: dvui.OsWindowWidget.InitOptions) void { - os_windows.named(id, header, opts.title); +/// The theme's window colours `dressPlainWindows` last gave the windows. +var plain_dressed: ?[2]dvui.Color = null; + +/// The OS's own title bar on a plain dvui app's windows — the main one and each of the OS's own +/// frame (`Viewport.native`) — in the theme's colour: set again only when the theme's colours +/// change. A window opening is dressed as it opens (`viewportOpen`). +fn dressPlainWindows(self: *SDLBackend) void { + const theme = dvui.themeGet(); + const colors: [2]dvui.Color = .{ theme.color(.window, .fill), theme.color(.window, .text) }; + if (plain_dressed) |d| if (std.meta.eql(d, colors)) return; + plain_dressed = colors; + platform.window.nativeTitleBar(self.window, colors[0], colors[1], theme.dark); + for (&self.viewport_slots) |*slot| if (slot.*) |*v| if (v.native) { + platform.window.nativeTitleBar(v.window, colors[0], colors[1], theme.dark); + }; +} + +/// A floating window of dvui's (not modal) shown in an OS window of its own, its frame the OS's +/// (`os_windows`), as `floating_os_windows` says: dvui asks as it begins (`asked`, the window asked +/// for one), true while it has one or one can open for it. `dvui.osWindow`'s asks, which this +/// backend draws as a floating window: it makes no `dvui.Window` of its own for one (no +/// `initWindowSecondary`). +pub fn floatingWindowOs(_: *SDLBackend, id: dvui.Id, asked: bool, open_flag: ?*bool, min: dvui.Size, max: dvui.Size) bool { + const shown = switch (floating_os_windows) { + // And one torn off the main window by a drop over no window (`os_windows.torn`). + .asked => asked or os_windows.torn(id) or os_windows.heldFrameless(id), + .all => true, + .none => false, + }; + return shown and os_windows.named(id, open_flag, min, max); +} + +/// `windowHeader` in floating window `id`: its title on that window's title bar, where it has one +/// of its own (`floatingWindowOs`). +pub fn floatingWindowTitle(_: *SDLBackend, id: dvui.Id, header: []const u8) bool { + return os_windows.titled(id, header); +} + +/// The kinds of window material this backend can show (fizzy's `core.material`, field for field): +/// only a kind every surface it puts glass on can show. A clear window everywhere (`transparent`); +/// on macOS vibrancy (`frost`) and a plain blur, and Liquid Glass from macOS 26; on Windows +/// Acrylic (`frost`); on Linux the compositor's blur behind a window, where it has one +/// (`wayland_blur`). +pub const MaterialKinds = struct { + none: bool = true, + transparent: bool = false, + blur: bool = false, + frost: bool = false, + liquid: bool = false, +}; + +/// Whether the pointer is over no window of the app's: the bare desktop, or another app's window. +/// macOS (`os_windows`' tear-off); false elsewhere, where it is not asked. +pub fn pointerOverNoWindow(self: *SDLBackend) bool { + if (comptime builtin.target.os.tag != .macos) return false; + return self.windowAtPointer() == null; +} + +/// The app's window the pointer is over, as an opaque handle (macOS: its `NSWindow`), or null. +pub fn windowAtPointer(_: *SDLBackend) ?*anyopaque { + if (comptime builtin.target.os.tag != .macos) return null; + return fizzy_macos_app_window_at_pointer(); +} + +/// `window` (`windowAtPointer`'s) in front of the app's other windows, not made key. +pub fn bringForward(_: *SDLBackend, window: *anyopaque) void { + if (comptime builtin.target.os.tag != .macos) return; + fizzy_macos_window_bring_forward(window); +} + +/// The OS's title bar over a window of its own frame (`ViewportFrame.native`), physical pixels: as +/// the main window's, a plain dvui app's being one too, or macOS's own height where it shows none +/// (full screen). +pub fn titleBand(self: *SDLBackend) f32 { + const points = platform.macos_monitor.titlebarStrip(self.window, 0, 0); + return (if (points > 0) points else 28) * self.density(); +} + +/// Where the pointer is on the desktop, in the main window's frame (physical pixels from its top +/// left), as a held pointer over the main window is read (`heldPoint`): not in the band of the +/// window a press began in. +pub fn pointerInMain(self: *SDLBackend) dvui.Point.Physical { + var gx: f32 = 0; + var gy: f32 = 0; + _ = c.SDL_GetGlobalMouseState(&gx, &gy); + const origin = self.mainOnScreen(); + const d = self.density(); + return .{ .x = (gx - origin.x) * d, .y = (gy - origin.y) * d }; +} + +/// Whether a pointer button is held, wherever the pointer is. +pub fn pointerHeld(_: *SDLBackend) bool { + return c.SDL_GetGlobalMouseState(null, null) != 0; +} + +pub fn materialKinds(self: *SDLBackend) MaterialKinds { + return switch (comptime builtin.target.os.tag) { + .macos => .{ .transparent = true, .blur = true, .frost = true, .liquid = liquidGlassAvailable() }, + .windows => .{ .transparent = true, .frost = true }, + .linux => .{ .transparent = true, .blur = platform.linux_titlebar.blurOffered(self.window) }, + else => .{}, + }; } // --------------------------------------------------------------------------------------------- @@ -1087,6 +1202,11 @@ pub const ViewportFrame = enum { /// on macOS. Moved and resized only where the app says (`viewportHints`). A dvui app's /// floating windows, drawn as dvui draws them. app, + /// The OS's own window, as any app's: its title bar (the window's title, its buttons, moving, + /// full screen), its frame and resizing, the title bar in the window's colour + /// (`platform.window.nativeTitleBar`), and what the app draws below it — a dvui floating + /// window's content and nothing of its frame. `dvui.osWindow`'s windows (`os_windows`). + native, }; const ViewportKind = enum { window, carry, overlay, menu }; @@ -1303,7 +1423,8 @@ fn openViewport(self: *SDLBackend, at: viewport_map.Rect, title_text: [:0]const // A menu's window is a carry window that takes the pointer, never focused: the window it opened // from keeps the keyboard, as an OS menu leaves it. // One framed by the app (`ViewportFrame.app`) is borderless on macOS as well. - const titled = builtin.target.os.tag == .macos and !carry and frame == .os; + // One framed natively (`ViewportFrame.native`) is titled everywhere: the OS's ordinary window. + const titled = !carry and (frame == .native or (builtin.target.os.tag == .macos and frame == .os)); const frame_flag: c.SDL_WindowFlags = if (titled) 0 else c.SDL_WINDOW_BORDERLESS; const size_flag: c.SDL_WindowFlags = if (carry) 0 else c.SDL_WINDOW_RESIZABLE; const focus_flag: c.SDL_WindowFlags = if (menu) c.SDL_WINDOW_NOT_FOCUSABLE else 0; @@ -1328,7 +1449,7 @@ fn openViewport(self: *SDLBackend, at: viewport_map.Rect, title_text: [:0]const // where SDL makes it a child window that moves with its parent, and the windows that left // the main one stay where they are when it moves (`plans/POPOUT_WINDOWS_PLAN.md`, decision 2). if (comptime builtin.target.os.tag != .macos) _ = c.SDL_SetWindowParent(window, self.window); - self.viewport_slots[slot] = .{ .window = window, .band = b, .anchor = anchor, .density = d, .screen = placed.screen, .frame = placed.frame, .passive = carry and !menu, .overlay = role == .overlay, .menu = menu, .app_frame = frame == .app }; + self.viewport_slots[slot] = .{ .window = window, .band = b, .anchor = anchor, .density = d, .screen = placed.screen, .frame = placed.frame, .passive = carry and !menu, .overlay = role == .overlay, .menu = menu, .app_frame = frame == .app, .native = frame == .native }; const vp = &self.viewport_slots[slot].?; // Dressed by `viewportOpenMenu`, and no hit test: nothing in it moves or resizes it. if (menu) return vp; @@ -1340,6 +1461,26 @@ fn openViewport(self: *SDLBackend, at: viewport_map.Rect, title_text: [:0]const if (comptime builtin.target.os.tag == .macos) fizzy_macos_viewport_carry(cocoaWindow(window), cocoaWindow(self.window), platform.window.ns_visual_effect_material); return vp; } + // The OS's own window: its title bar and frame are the OS's to hit-test and move. No show or + // close animation, as no viewport has one (`viewportDress`): AppKit's never finishes under + // this loop and leaves the window behind as a ghost. + if (frame == .native) { + if (comptime builtin.target.os.tag == .macos) fizzy_macos_viewport_still(cocoaWindow(window)); + const theme = dvui.themeGet(); + platform.window.nativeTitleBar(window, theme.color(.window, .fill), theme.color(.window, .text), theme.dark); + // Through Spaces, zooms and live resizes as the main window goes. + if (comptime builtin.target.os.tag == .macos) platform.macos_monitor.watch(window); + // Its part of the frame below the title bar's band (`platform.macos_monitor.titlebarStrip`): + // the window that much taller, and as far up, for that part to be where it was asked. + const band = platform.macos_monitor.titlebarStrip(window, 0, 0); + if (band > 0) { + const up: c_int = @intFromFloat(@round(band)); + _ = c.SDL_SetWindowPosition(window, vp.screen.x, vp.screen.y - up); + _ = c.SDL_SetWindowSize(window, vp.screen.w, vp.screen.h + up); + vp.picture_at = .{ 0, @intFromFloat(@round(band * vp.density)) }; + } + return vp; + } // The slot holds it for the window's life, so SDL may keep the pointer. _ = c.SDL_SetWindowHitTest(window, viewportHitTest, vp); if (comptime builtin.target.os.tag == .macos) if (frame == .app) { @@ -1641,11 +1782,41 @@ pub fn viewportDragMove(_: *SDLBackend, vp: *Viewport) bool { /// (macOS), the window switcher. pub fn viewportTitle(_: *SDLBackend, vp: *Viewport, text: []const u8) void { var buf: [128]u8 = undefined; - const z = std.fmt.bufPrintZ(&buf, "{s}", .{text[0..@min(text.len, buf.len - 1)]}) catch return; + const z = std.fmt.bufPrintSentinel(&buf, "{s}", .{text[0..@min(text.len, buf.len - 1)]}, 0) catch return; _ = c.SDL_SetWindowTitle(vp.window, z.ptr); if (comptime builtin.target.os.tag == .macos) fizzy_macos_viewport_windows_item(cocoaWindow(vp.window), z.ptr); } +/// Whether `vp`'s window has the keyboard: it is the OS's key window. +pub fn viewportHasKeyboard(vp: *const Viewport) bool { + return c.SDL_GetWindowFlags(vp.window) & c.SDL_WINDOW_INPUT_FOCUS != 0; +} + +/// dvui's screens besides the main window (`dvui.screensSet`): each window's part of the frame that +/// shows a floating window — not a menu's, a carried view's or an overlay's. +pub fn viewportScreens(self: *SDLBackend, extra: ?dvui.Rect.Natural) void { + var rects: [max_viewports + 1]dvui.Rect.Natural = undefined; + var n: usize = 0; + const s = dvui.windowNaturalScale(); + for (&self.viewport_slots) |*slot| { + const vp = if (slot.*) |*v| v else continue; + if (vp.passive or vp.menu or vp.overlay) continue; + rects[n] = .{ .x = vp.frame.x / s, .y = vp.frame.y / s, .w = vp.frame.w / s, .h = vp.frame.h / s }; + n += 1; + } + // Last, so a window's band holding a floating window wins over it (`dvui.screenFor`). + if (extra) |r| { + rects[n] = r; + n += 1; + } + dvui.screensSet(rects[0..n]); +} + +pub fn viewportMaxSize(_: *SDLBackend, vp: *Viewport, w: f32, h: f32) void { + _ = c.SDL_SetWindowMaximumSize(vp.window, @intFromFloat(@floor(w / vp.density)), @intFromFloat(@floor(h / vp.density))); + platform.win32_titlebar.allowMaximize(vp.window, w == 0 and h == 0); +} + /// The least the OS may resize `vp`'s window to: `size` physical pixels of the frame, its float's /// least. pub fn viewportMinSize(_: *SDLBackend, vp: *Viewport, w: f32, h: f32) void { @@ -1656,6 +1827,11 @@ extern fn fizzy_macos_viewport_glass(nswindow: ?*anyopaque, main: ?*anyopaque, i extern fn fizzy_macos_viewport_dress(nswindow: ?*anyopaque, main: ?*anyopaque) void; extern fn fizzy_macos_viewport_plain(nswindow: ?*anyopaque) void; extern fn fizzy_macos_viewport_reshadow(nswindow: ?*anyopaque) void; +extern fn fizzy_macos_window_off_active_space(nswindow: ?*anyopaque) c_int; +extern fn fizzy_macos_window_at_pointer() ?*anyopaque; +extern fn fizzy_macos_app_window_at_pointer() ?*anyopaque; +extern fn fizzy_macos_window_bring_forward(nswindow: ?*anyopaque) void; +extern fn fizzy_macos_viewport_still(nswindow: ?*anyopaque) void; extern fn fizzy_macos_viewport_menu(nswindow: ?*anyopaque, main: ?*anyopaque, material: c_long, radius: f64, dialog: c_int) c_int; extern fn fizzy_macos_viewport_menu_attached(nswindow: ?*anyopaque) void; extern fn fizzy_macos_viewport_order_above(nswindow: ?*anyopaque, other: ?*anyopaque) void; @@ -1724,6 +1900,25 @@ pub fn viewportLiquidGlass(_: *SDLBackend, vp: *Viewport, look: platform.window. /// Where `vp`'s window is now, in the main window's part of the frame: physical pixels from the /// main window's top left, wherever either window has been moved since it opened. +/// Whether `vp`'s window — its title bar's band too — lies wholly inside the main window, with the +/// main window on the Space being shown (`viewports.mainHolds`). +pub fn viewportMainHolds(self: *SDLBackend, vp: *const Viewport) bool { + if (comptime builtin.target.os.tag == .macos) if (fizzy_macos_window_off_active_space(cocoaWindow(self.window)) != 0) return false; + var x: c_int = 0; + var y: c_int = 0; + var w: c_int = 0; + var h: c_int = 0; + _ = c.SDL_GetWindowPosition(vp.window, &x, &y); + _ = c.SDL_GetWindowSize(vp.window, &w, &h); + var mx: c_int = 0; + var my: c_int = 0; + var mw: c_int = 0; + var mh: c_int = 0; + _ = c.SDL_GetWindowPosition(self.window, &mx, &my); + _ = c.SDL_GetWindowSize(self.window, &mw, &mh); + return x >= mx and y >= my and x + w <= mx + mw and y + h <= my + mh; +} + pub fn viewportInMain(self: *SDLBackend, vp: *const Viewport) viewport_map.Rect { // Where it was last put, not SDL's idea of it: on Windows it may have been moved in one step // behind SDL's back (`viewportPlace`), and only the app moves it. @@ -1775,6 +1970,11 @@ fn density(self: *SDLBackend) f32 { /// OS keeps sending a held pointer to the window that took the press, so a view carried out of a /// float that is out went on landing in the float's part of the frame over the main window, and /// one carried from the main window never reached the float's window. +/// +/// macOS: a pointer's motion with no button held too, when a viewport's window is in front of the +/// one AppKit sent it to. AppKit sends mouse-moved events to the key window wherever the pointer +/// is, and SDL reports them as that window's: hovering a float's window in front of the main one +/// lit up the main window's widgets under it (the user). fn heldAcrossWindows(self: *SDLBackend, event: c.SDL_Event) bool { switch (event.type) { c.SDL_EVENT_MOUSE_MOTION => if (event.motion.which == c.SDL_TOUCH_MOUSEID) return false, @@ -1786,7 +1986,14 @@ fn heldAcrossWindows(self: *SDLBackend, event: c.SDL_Event) bool { } else false; if (!any) return false; if (event.type == c.SDL_EVENT_MOUSE_BUTTON_UP) return true; - return c.SDL_GetGlobalMouseState(null, null) != 0; + if (c.SDL_GetGlobalMouseState(null, null) != 0) return true; + if (comptime builtin.target.os.tag == .macos) if (event.type == c.SDL_EVENT_MOUSE_MOTION) { + const from = c.SDL_GetWindowFromID(event.motion.windowID) orelse return false; + const front = fizzy_macos_window_at_pointer() orelse return false; + if (front == cocoaWindow(from)) return false; + for (&self.viewport_slots) |*slot| if (slot.*) |*vp| if (cocoaWindow(vp.window) == front) return true; + }; + return false; } /// Where in the frame the pointer is, by the window of fizzy's it is over on the desktop: a @@ -1811,7 +2018,7 @@ fn heldPoint(self: *SDLBackend) dvui.Point.Physical { // Over the viewport the press landed on, that one: where two viewports' windows overlap, the // OS gave the press to the one on top, which the order of the slots below does not know — a // press on the second of two overlapping floats' windows was read in the first's band. - if (self.press_window) |pw| if (self.viewportOf(pw)) |vp| if (!vp.passive and !vp.see_through) { + if (self.press_window) |pw| if (self.viewportOf(pw)) |vp| if (!vp.passive and !vp.see_through and !platform.window.windowUnseen(vp.window)) { const full = c.SDL_GetWindowFlags(vp.window) & c.SDL_WINDOW_FULLSCREEN != 0; const s = if (full) liveScreen(vp) else vp.screen; if (gx >= @as(f32, @floatFromInt(s.x)) and gy >= @as(f32, @floatFromInt(s.y)) and @@ -1830,6 +2037,10 @@ fn heldPoint(self: *SDLBackend) dvui.Point.Physical { for (&self.viewport_slots) |*slot| { const vp = if (slot.*) |*v| v else continue; if (vp.passive or vp.see_through) continue; + // Nobody sees it, on another Space: the pointer is not over it, wherever it lies there. + // Read as over it, a float dragged in the main window's full screen Space vanished into the + // band of a window left on the desktop as it crossed where that window lies (the user). + if (platform.window.windowUnseen(vp.window)) continue; // In a fullscreen Space of its own, a window is where the OS put it, and under nothing: // read as under the main window, or where it was before it went, every press and release // in it landed over the main window — hovering worked, clicking did nothing (the user). @@ -1925,17 +2136,19 @@ fn viewportFollowWindow(vp: *Viewport) void { var h: c_int = 0; _ = c.SDL_GetWindowPosition(vp.window, &x, &y); _ = c.SDL_GetWindowSize(vp.window, &w, &h); + // A window of the OS's own frame: its part of the frame below the title bar's band. + const band: f32 = if (vp.native) platform.macos_monitor.titlebarStrip(vp.window, 0, 0) else 0; // Standing still on its way into or out of full screen: its float goes where the picture is. if (platform.window.windowSpacePicture(vp.window)) |p| { const at: viewport_map.ScreenRect = .{ .x = x + @as(c_int, @intFromFloat(@round(p.x))), - .y = y + @as(c_int, @intFromFloat(@round(p.y))), + .y = y + @as(c_int, @intFromFloat(@round(p.y + band))), .w = @intFromFloat(@round(p.w)), - .h = @intFromFloat(@round(p.h)), + .h = @intFromFloat(@round(@max(1, p.h - band))), }; vp.picture_at = .{ @intFromFloat(@max(0, @round(p.x * vp.density))), - @intFromFloat(@max(0, @round(p.y * vp.density))), + @intFromFloat(@max(0, @round((p.y + band) * vp.density))), }; if (std.meta.eql(at, vp.screen)) return; vp.screen = at; @@ -1943,8 +2156,9 @@ fn viewportFollowWindow(vp: *Viewport) void { vp.os_placed = true; return; } - vp.picture_at = .{ 0, 0 }; - const now: viewport_map.ScreenRect = .{ .x = x, .y = y, .w = w, .h = h }; + const b: c_int = @intFromFloat(@round(band)); + vp.picture_at = .{ 0, @intFromFloat(@round(band * vp.density)) }; + const now: viewport_map.ScreenRect = .{ .x = x, .y = y + b, .w = w, .h = @max(1, h - b) }; // Where the app put it: its own doing, reported back. if (std.meta.eql(now, vp.screen)) return; // Placing a window is asynchronous on X11: just after the app put it somewhere, what it says @@ -2000,7 +2214,7 @@ pub fn setCursor(self: *SDLBackend, cursor: dvui.enums.Cursor) void { if (new_state == false) return; } - const enum_int = @intFromEnum(cursor); + const enum_int = @backingInt(cursor); const tried = self.cursor_backing_tried[enum_int]; if (!tried) { self.cursor_backing_tried[enum_int] = true; @@ -2051,11 +2265,17 @@ pub fn textInputRect(self: *SDLBackend, rect: ?dvui.Rect.Natural) void { if (rect) |rect_frame| { var r = rect_frame; if (vp) |v| { - // From the frame into the viewport's window: its band offset, in points. + // From the frame into the viewport's window: its band offset, in points, and where its + // picture lies in the window (below a title band, or on its way into full screen). const d = c.SDL_GetWindowPixelDensity(v.window); - r.x -= v.frame.x / (if (d > 0) d else 1); - r.y -= v.frame.y / (if (d > 0) d else 1); - } + const dd = if (d > 0) d else 1; + r.x += (@as(f32, @floatFromInt(v.picture_at[0])) - v.frame.x) / dd; + r.y += (@as(f32, @floatFromInt(v.picture_at[1])) - v.frame.y) / dd; + } else if (self.places_main_picture) if (self.mainPicture()) |p| { + // The main window's frame lies at its picture (`main_picture`): below the title band. + r.x += p.x; + r.y += p.y; + }; // This is the offset from r.x in window coords, supposed to be the // location of the cursor I think so that the IME window can be put // at the cursor location. We will use 0 for now, might need to @@ -2095,6 +2315,9 @@ pub fn deinit(self: *SDLBackend) void { if (slot.*) |*v| self.viewportClose(v); } self.gpu.destroy(); + // Not followed through Spaces from here: the monitor's pump, and a block queued as a way + // out of full screen ended, read a window SDL had destroyed while it quit (two crashes). + if (comptime builtin.target.os.tag == .macos) platform.macos_monitor.unwatch(self.window); c.SDL_DestroyWindow(self.window); if (self.sdl_quit) { quit_signals.uninstall(); @@ -2106,13 +2329,19 @@ pub fn deinit(self: *SDLBackend) void { /// The main window shows nothing: hidden, minimized, occluded (macOS: on another Space, or wholly /// covered; `platform.window.windowUnseen`), or its last frame reached no drawable. +/// Whether nobody sees this frame: the main window hidden, occluded, minimized or held, and no +/// window of a viewport's shown it either (`viewport_seen`). A float's window in a full screen +/// Space of its own leaves the main one on another Space, unseen: counted by it alone, every frame +/// of the float's way slept out to a sixtieth of a second, and it ran at 56 frames a second where +/// the main window's ran at the display's 120 (the user, measured). pub fn unseen(self: *SDLBackend) bool { - return platform.window.windowUnseen(self.window) or !self.gpu.presented; + return (platform.window.windowUnseen(self.window) or !self.gpu.presented) and !self.viewport_seen; } pub fn renderPresent(self: *SDLBackend) void { // First, so it runs last: after the viewports' own transaction below is committed into it. defer if (self.present_hook) |hook| hook(self); + self.viewport_seen = false; // macOS: a window put somewhere new this frame goes there in the transaction its picture is // presented in, so the two change together (`placeWindow`). Before the pictures are copied // into the windows, so a resized window's drawable is already its new size. A window's first @@ -2122,11 +2351,31 @@ pub fn renderPresent(self: *SDLBackend) void { var transacted: [max_viewports]bool = @splat(false); var any = false; if (comptime builtin.target.os.tag == .macos) { + if (self.places_main_picture) { + var w: c_int = 0; + var h: c_int = 0; + _ = c.SDL_GetWindowSize(self.window, &w, &h); + if (self.main_shadow_size[0] != w or self.main_shadow_size[1] != h) { + self.main_shadow_size = .{ w, h }; + self.main_shadow_frames = 2; + } + if (self.main_shadow_frames > 0) { + self.main_shadow_frames -= 1; + fizzy_macos_viewport_reshadow(cocoaWindow(self.window)); + } + } for (&self.viewport_slots, 0..) |*slot, i| { const vp = if (slot.*) |*v| v else continue; - if (vp.app_frame and vp.shown) { - if (vp.shadow_size[0] != vp.screen.w or vp.shadow_size[1] != vp.screen.h) { - vp.shadow_size = .{ vp.screen.w, vp.screen.h }; + if ((vp.app_frame or vp.native) and vp.shown) { + // By the window's size, as the main window's: on its way into or out of full screen + // it stands still at that size while its picture grows (`vp.screen`), and asked to + // each frame of it, the window server worked the shadow of a full-screen window out + // again from its pixels every frame. + var ww: c_int = 0; + var wh: c_int = 0; + _ = c.SDL_GetWindowSize(vp.window, &ww, &wh); + if (vp.shadow_size[0] != ww or vp.shadow_size[1] != wh) { + vp.shadow_size = .{ ww, wh }; vp.shadow_frames = 2; } if (vp.shadow_frames > 0) { @@ -2176,7 +2425,14 @@ pub fn renderPresent(self: *SDLBackend) void { // a float's or a dialog's window keeps its picture until it is over. AppKit hands their layers // drawables back slowly while the main window goes through a Space transition, and waiting on // them every frame halved the move's rate with one open. + // Or another float's window moving itself: one left on the Space it leaves is held as the main + // window is (`mainHeld`). Presented into while a sibling's way ran, every frame waited on its + // drawable, and the moving window's way ran at 55 frames a second, the main window's at 120. const main_moving = platform.window.windowMovingItself(self.window); + var moving_vp: ?*Viewport = null; + for (&self.viewport_slots) |*slot| if (slot.*) |*v| if (v.shown and platform.window.windowMovingItself(v.window)) { + moving_vp = v; + }; for (&self.viewport_slots, 0..) |*slot, i| { const vp = if (slot.*) |*v| v else continue; const target = vp.pending orelse continue; @@ -2185,8 +2441,9 @@ pub fn renderPresent(self: *SDLBackend) void { // for up to a second: skipped, once it has been shown (it is created hidden, and covered // then by its own account). if (vp.shown and c.SDL_GetWindowFlags(vp.window) & (c.SDL_WINDOW_MINIMIZED | c.SDL_WINDOW_OCCLUDED) != 0) continue; - if (vp.shown and main_moving and !platform.window.windowMovingItself(vp.window)) continue; + if (vp.shown and (main_moving or moving_vp != null) and vp != moving_vp and !platform.window.windowMovingItself(vp.window)) continue; const presented = self.gpu.presentInto(vp.window, target, vp.picture_at); + if (presented and !platform.window.windowUnseen(vp.window)) self.viewport_seen = true; if (vp.shown) continue; if (presented) { first_frame[i] = true; @@ -2200,13 +2457,15 @@ pub fn renderPresent(self: *SDLBackend) void { // others reaches the screen a composite or more after the size it was drawn for, as an edge // with nothing on it or a frame cut off (docs/WINDOWS_LIVE_RESIZE.md). self.gpu.present(self.clear_window_on_begin, builtin.target.os.tag == .windows and self.inLiveResize()); - // Shown once there is a frame in it (or empty, above). Not made key: the window it came out of - // keeps the keyboard until the viewport is clicked. + // Shown once there is a frame in it (or empty, above). Not made key — the window it came out of + // keeps the keyboard until the viewport is clicked — but for a window of the OS's own frame + // (`.native`), which opens key, as a window an app opens does. for (&self.viewport_slots, first_frame, map_empty) |*slot, show, empty| { if (!show and !empty) continue; const vp = if (slot.*) |*v| v else continue; if (!vp.mapped) { - _ = c.SDL_SetHint(c.SDL_HINT_WINDOW_ACTIVATE_WHEN_SHOWN, "0"); + // Only while the app is active: one opened behind another app's window took its keyboard. + _ = c.SDL_SetHint(c.SDL_HINT_WINDOW_ACTIVATE_WHEN_SHOWN, if (vp.native and appActive()) "1" else "0"); _ = c.SDL_ShowWindow(vp.window); _ = c.SDL_ResetHint(c.SDL_HINT_WINDOW_ACTIVATE_WHEN_SHOWN); vp.mapped = true; @@ -2234,6 +2493,9 @@ pub fn renderPresent(self: *SDLBackend) void { if (!vp.over_main) continue; vp.over_main = false; if (!vp.mapped or vp.passive or vp.menu) continue; + // An ordinary window of the app's (`ViewportFrame.native`): in front where it opened, and + // ordered from there as the person orders it. + if (vp.native) continue; fizzy_macos_viewport_over_main(cocoaWindow(vp.window), main_ns); self.stack_stale = true; } @@ -2317,6 +2579,23 @@ pub fn begin(self: *SDLBackend, arena: std.mem.Allocator) !void { if (self.begin_hook) |hook| hook(self); // Its step for this frame taken (`begin_hook`): where the main window's picture is now. self.main_picture = if (platform.window.windowSpacePicture(self.window)) |p| .{ .x = p.x, .y = p.y, .w = p.w, .h = p.h } else null; + // A plain dvui app's: below the title bar, all the time (`platform.macos_monitor.titlebarStrip`). + if (comptime builtin.target.os.tag == .macos) if (self.places_main_picture) { + var p: viewport_map.Rect = self.main_picture orelse whole: { + var w: c_int = 0; + var h: c_int = 0; + _ = c.SDL_GetWindowSize(self.window, &w, &h); + break :whole .{ .w = @floatFromInt(w), .h = @floatFromInt(h) }; + }; + const band = platform.macos_monitor.titlebarStrip(self.window, 0, 0); + p.y += band; + p.h = @max(1, p.h - band); + self.main_picture = p; + }; + self.gpu.window_offset = .{ 0, 0 }; + if (self.places_main_picture) if (self.pictureInWindow()) |p| { + self.gpu.window_offset = .{ p.at.x, p.at.y }; + }; // A float's window standing still on its way into or out of full screen: its picture has // taken this frame's step (`begin_hook`), and the float follows it there — as it did the // window's moves — and back to the window as the way ends. @@ -2335,14 +2614,15 @@ pub fn begin(self: *SDLBackend, arena: std.mem.Allocator) !void { /// full-screen size, the app drawn growing or shrinking in it. dvui is told the window is the /// picture's size (`windowSize`, `pixelSize`), the frame is laid down where the picture is /// (`pictureInWindow`, `core.gfx.FrameTarget`) and the pointer is read against it (`mainOnScreen`, -/// the main window's motion). Null on no such way. +/// the main window's motion). Null on no such way — but for a plain dvui app's window +/// (`places_main_picture`), whose picture is always set: the window below its title band. fn mainPicture(self: *const SDLBackend) ?viewport_map.Rect { return self.main_picture; } /// Where the main window's frame is laid down in it this frame, physical, and all of it: its -/// picture, while it stands still on its way into or out of full screen (`main_picture`). Null: -/// the frame is the window. +/// picture, while it stands still on its way into or out of full screen (`main_picture`), and +/// always for a plain dvui app's window, below its title band. Null: the frame is the window. pub fn pictureInWindow(self: *SDLBackend) ?struct { at: dvui.Rect.Physical, all: dvui.Rect.Physical } { if (comptime builtin.target.os.tag != .macos) return null; const p = self.mainPicture() orelse return null; @@ -2371,6 +2651,11 @@ fn mainHeld(self: *SDLBackend) bool { // Its own way into or out of full screen is drawn whatever AppKit says of it on the way. if (platform.window.windowMovingItself(self.window)) return false; if (c.SDL_GetWindowFlags(self.window) & c.SDL_WINDOW_OCCLUDED != 0) return true; + // On a Space nobody is looking at, as while a float's window has a full screen Space of its + // own: waited on there, its drawable came a second late, Metal's timeout, and the frame with it + // (the user, traced). Only that, not every way of being unseen: a window shown undrawn for its + // first frame (`showUndrawn`) is unseen too, and held, it would never draw one. + if (fizzy_macos_window_off_active_space(cocoaWindow(self.window)) != 0) return true; for (&self.viewport_slots) |*slot| { const vp = if (slot.*) |*v| v else continue; if (vp.shown and platform.window.windowMovingItself(vp.window)) return true; @@ -2659,7 +2944,7 @@ comptime { } fn touchButton(slot: u4) dvui.enums.Button { - return @enumFromInt(@intFromEnum(dvui.enums.Button.touch0) + @as(u8, slot)); + return @fromBackingInt(@intCast(@backingInt(dvui.enums.Button.touch0) + @as(u8, slot))); } /// Lift every finger the window will not hear lift: it lost focus, is going to the background or @@ -2985,16 +3270,16 @@ pub fn SDL_keymod_to_dvui(keymod: u16) dvui.enums.Mod { if (keymod == if (sdl3) c.SDL_KMOD_NONE else c.KMOD_NONE) return dvui.enums.Mod.none; var m: u16 = 0; - if (keymod & (if (sdl3) c.SDL_KMOD_LSHIFT else c.KMOD_LSHIFT) > 0) m |= @intFromEnum(dvui.enums.Mod.lshift); - if (keymod & (if (sdl3) c.SDL_KMOD_RSHIFT else c.KMOD_RSHIFT) > 0) m |= @intFromEnum(dvui.enums.Mod.rshift); - if (keymod & (if (sdl3) c.SDL_KMOD_LCTRL else c.KMOD_LCTRL) > 0) m |= @intFromEnum(dvui.enums.Mod.lcontrol); - if (keymod & (if (sdl3) c.SDL_KMOD_RCTRL else c.KMOD_RCTRL) > 0) m |= @intFromEnum(dvui.enums.Mod.rcontrol); - if (keymod & (if (sdl3) c.SDL_KMOD_LALT else c.KMOD_LALT) > 0) m |= @intFromEnum(dvui.enums.Mod.lalt); - if (keymod & (if (sdl3) c.SDL_KMOD_RALT else c.KMOD_RALT) > 0) m |= @intFromEnum(dvui.enums.Mod.ralt); - if (keymod & (if (sdl3) c.SDL_KMOD_LGUI else c.KMOD_LGUI) > 0) m |= @intFromEnum(dvui.enums.Mod.lcommand); - if (keymod & (if (sdl3) c.SDL_KMOD_RGUI else c.KMOD_RGUI) > 0) m |= @intFromEnum(dvui.enums.Mod.rcommand); + if (keymod & (if (sdl3) c.SDL_KMOD_LSHIFT else c.KMOD_LSHIFT) > 0) m |= @backingInt(dvui.enums.Mod.lshift); + if (keymod & (if (sdl3) c.SDL_KMOD_RSHIFT else c.KMOD_RSHIFT) > 0) m |= @backingInt(dvui.enums.Mod.rshift); + if (keymod & (if (sdl3) c.SDL_KMOD_LCTRL else c.KMOD_LCTRL) > 0) m |= @backingInt(dvui.enums.Mod.lcontrol); + if (keymod & (if (sdl3) c.SDL_KMOD_RCTRL else c.KMOD_RCTRL) > 0) m |= @backingInt(dvui.enums.Mod.rcontrol); + if (keymod & (if (sdl3) c.SDL_KMOD_LALT else c.KMOD_LALT) > 0) m |= @backingInt(dvui.enums.Mod.lalt); + if (keymod & (if (sdl3) c.SDL_KMOD_RALT else c.KMOD_RALT) > 0) m |= @backingInt(dvui.enums.Mod.ralt); + if (keymod & (if (sdl3) c.SDL_KMOD_LGUI else c.KMOD_LGUI) > 0) m |= @backingInt(dvui.enums.Mod.lcommand); + if (keymod & (if (sdl3) c.SDL_KMOD_RGUI else c.KMOD_RGUI) > 0) m |= @backingInt(dvui.enums.Mod.rcommand); - return @as(dvui.enums.Mod, @enumFromInt(m)); + return @as(dvui.enums.Mod, @fromBackingInt(@intCast(m))); } pub fn SDL_keysym_to_dvui(keysym: i32) dvui.enums.Key { @@ -3182,7 +3467,7 @@ const SdlLogPriorityType = blk: { const Opt = @typeInfo(c.SDL_LogOutputFunction); const FnPtr = if (Opt == .optional) @typeInfo(Opt.optional.child) else Opt; const FnT = @typeInfo(FnPtr.pointer.child).@"fn"; - break :blk FnT.params[2].type.?; + break :blk FnT.param_types[2].?; }; fn sdlLogCallback(userdata: ?*anyopaque, category: c_int, priority: SdlLogPriorityType, message: [*c]const u8) callconv(.c) void { @@ -3332,7 +3617,9 @@ pub fn main(main_init: std.process.Init) !u8 { .org = init_opts.org, .icon = init_opts.icon, .hidden = init_opts.hidden, - .transparent = init_opts.transparent, + // Clear on macOS, over the backdrop a plain dvui app's window is dressed with + // (`platform.window.nativeTitleBar`): SDL fills an opaque window's view with black. + .transparent = init_opts.transparent or builtin.target.os.tag == .macos, .persist_window_geometry = init_opts.persist_window_geometry, .pref_path = init_opts.pref_path, }); @@ -3373,7 +3660,12 @@ pub fn main(main_init: std.process.Init) !u8 { // send all SDL events to dvui for processing try back.addAllEvents(&win); + // As `appIterate`: the floating windows in OS windows of their own, and a plain dvui app's + // main window dressed. + os_windows.beforeFrame(); const res = try app.frameFn(); + os_windows.afterFrame(); + if (!platform.window.app_dresses_windows) back.dressPlainWindows(); var end_micros = try win.end(.{}); Health.current.frameEnd(c.SDL_GetTicksNS()); @@ -3479,7 +3771,9 @@ fn appInit(appstate: ?*?*anyopaque, argc: c_int, argv: ?[*:null]?[*:0]u8) callco .org = init_opts.org, .icon = init_opts.icon, .hidden = init_opts.hidden, - .transparent = init_opts.transparent, + // Clear on macOS, over the backdrop a plain dvui app's window is dressed with + // (`platform.window.nativeTitleBar`): SDL fills an opaque window's view with black. + .transparent = init_opts.transparent or builtin.target.os.tag == .macos, .persist_window_geometry = init_opts.persist_window_geometry, .pref_path = init_opts.pref_path, }) catch |err| { @@ -3521,6 +3815,13 @@ fn appInit(appstate: ?*?*anyopaque, argc: c_int, argv: ?[*:null]?[*:0]u8) callco return c.SDL_APP_FAILURE; }; } + // Through Spaces, zooms and live resizes, each step drawn in time with the window: for an app + // that has not installed it itself, with its own chrome, as fizzy does (`restoreWindowState`). + if (comptime builtin.target.os.tag == .macos) if (appState.back.begin_hook == null) { + appState.back.places_main_picture = true; + platform.macos_monitor.install(&appState.win, true); + platform.macos_monitor.launchComplete(); + }; return c.SDL_APP_CONTINUE; } @@ -3620,6 +3921,9 @@ fn appIterate(_: ?*anyopaque) callconv(.c) c.SDL_AppResult { return c.SDL_APP_FAILURE; }; os_windows.afterFrame(); + // A plain dvui app's main window: the OS's own title bar, in the theme's colour. Fizzy's own + // windows are dressed by fizzy (`platform.window.setStyle`), which says so. + if (!platform.window.app_dresses_windows) appState.back.dressPlainWindows(); live_resize_trace.overlay(); diff --git a/backend/src/os_windows.zig b/backend/src/os_windows.zig index 7a2549d55..28011923e 100644 --- a/backend/src/os_windows.zig +++ b/backend/src/os_windows.zig @@ -1,25 +1,29 @@ -//! `dvui.osWindow` on this backend: each is a floating window in the one frame (dvui's fallback, -//! as this backend makes no `dvui.Window` of its own for one), shown in an OS window of its own -//! (`viewports`) that looks just as it does inside the main window — dvui's header and close -//! button, no OS title bar (`Frame.app`). The app writes plain dvui; on a backend without OS -//! windows the same floating window stays in the main window. +//! dvui's floating windows on this backend — `dvui.osWindow`'s too, as this backend makes no +//! `dvui.Window` of its own for one — each in the one frame, shown in an OS window of its own +//! (`viewports`): the OS's own title bar — the window's title, its buttons, moving, resizing, full +//! screen — in the theme's colour, and the floating window's content below it (`Frame.native`). +//! The app writes plain dvui; on a backend without OS windows, or past `max_outs`, the same +//! floating window stays in the main window, with dvui's own header and close button. Modal +//! dialogs stay in the window they are over. //! -//! dvui says which floating windows are OS windows as they draw (`named`, from -//! `SDLBackend.osWindowFloating`). Around the app's frame (`SDLBackend.appIterate`, or an app's -//! own loop): +//! dvui asks as each begins whether it is in an OS window (`named`, from +//! `SDLBackend.floatingWindowOs`), and its `windowHeader` hands that window its title (`titled`). +//! The window the OS makes key is the one dvui's keyboard goes to (`beforeFrame`). Around the +//! app's frame (`SDLBackend.appIterate`, or an app's own loop): //! -//! - `beforeFrame`: a window the OS moved takes its floating window with it (`osPlaced`), and each -//! window's band is a screen of dvui's (`dvui.screensSet`), so dvui keeps the floating window in -//! it; one being resized holds the pointer in its band (`pinPointer`); +//! - `beforeFrame`: each floating window takes its window's place and size, the OS's to change — +//! moved, resized, zoomed, full screen — never the other way, and each window's band is a screen +//! of dvui's (`dvui.screensSet`), so dvui keeps what opens in it there; //! - `afterFrame`: dvui's dialogs and toasts made (`Window.drawRetained`); a window whose floating //! window was not drawn — closed — goes; each one named and not yet in a window gets one over //! where it is (`viewports.open`), drawn in that window's band from the next frame; and each -//! window is put where its floating window is (`viewports.place`), told its header is where the -//! OS moves it from (`hints`), and handed its picture: every subwindow in its band, taken out of -//! the frame (`viewports.Picture`). +//! window is handed its picture: every subwindow in its band, taken out of the frame +//! (`viewports.Picture`). The OS's close clears the app's `open_flag`, as dvui's close button +//! would. //! -//! dvui keeps where each floating window is (`_rect` in its data, by its id): moved into the -//! window's band, and read back after the frame. +//! dvui keeps where each floating window is (`_rect` in its data, by its id): set each frame to its +//! window's part of the frame, in that window's band. +const std = @import("std"); const dvui = @import("dvui"); const viewports = @import("viewports.zig"); @@ -33,27 +37,174 @@ const Out = struct { target: ?dvui.Texture.Target = null, /// Opened this frame: drawn in the main window still, in its band from the next. fresh: bool = true, + /// Its window's title, and least and most size (natural), as last given it. + title: [64:0]u8 = @splat(0), + min: dvui.Size = .{}, + max: dvui.Size = .{}, }; var outs: [max_outs]?Out = @splat(null); -/// The floating windows dvui named as OS windows this frame (`named`), with their headers -/// (physical) and titles. -const Named = struct { id: dvui.Id, header: dvui.Rect.Physical, title: [64:0]u8 }; +/// The floating windows dvui named as OS windows this frame (`named`), with their titles +/// (`titled`), the flags their windows' close clears, and their least and most sizes. +const Named = struct { + id: dvui.Id, + title: [64:0]u8 = @splat(0), + open_flag: ?*bool, + min: dvui.Size, + max: dvui.Size, + shown: bool = false, + /// Drawn as an OS window's, without dvui's frame or header, but no window opened for it yet: + /// one being torn off (`frameless`). + hold: bool = false, +}; var named_list: [max_outs]Named = undefined; var named_n: usize = 0; -/// The pointer is pinned to a window's band by `beforeFrame`, to be let go once nothing holds it. -var pinned = false; - -/// dvui drew floating window `id` for an OS window this frame (`dvui.osWindow`), moved by `header`. -pub fn named(id: dvui.Id, header: dvui.Rect.Physical, title: ?[:0]const u8) void { - if (named_n == named_list.len) return; - var n: Named = .{ .id = id, .header = header, .title = @splat(0) }; - const t = title orelse ""; - const len = @min(t.len, n.title.len); - @memcpy(n.title[0..len], t[0..len]); - named_list[named_n] = n; +/// The floating window that has the keyboard, as last read from the OS's key window: null for the +/// main window's own subwindow. +var keyed: ?dvui.Id = null; + +/// A floating window dragged by its header past every window of the app's (`tearFrame`) becomes a +/// small card of itself at the pointer, in a carry window (`viewports.openCarry`): its header gone, +/// its content live. Let go over the desktop, the card grows to its window's size where it opens, +/// a title bar's place opening at its top (`Grow`), and it is in an OS window of its own there +/// (`torn`), its content under the OS's title bar where the card's was. Moved by the OS wholly back +/// into the main window, the window shrinks into its place there, dvui's header for the OS's title +/// bar (`Merge`). A card, not the floating window at its size made up as an OS window: it never +/// could look like one, and became one with a jump; a card that changes into the window is a change +/// it shows (the user). dvui says nothing of a drag, so it is read from what dvui shows: the +/// floating window that has the mouse, its rect moving while its size stays — moved by its header, +/// not resized from an edge. Over another of the app's windows the held pointer already takes it +/// into that window (`SDLBackend.heldPoint`). +const Tear = struct { + id: dvui.Id, + /// Where on it the pointer took it, from its top left (physical, at its size). + grab: dvui.Point.Physical, + /// Its rect last frame (physical): moved at the same size, it is being moved. + last: dvui.Rect.Physical, + moving: bool = false, + /// When it went past the app's windows: drawn without its header from the frame after + /// (`frameless`), and shrinking into the card from then (`shrink_ns`). + out_ns: ?i128 = null, + /// Its picture this frame, at its size, without its header: what the card shows. + live: ?dvui.Texture.Target = null, + card: ?*viewports.Viewport = null, + card_target: ?dvui.Texture.Target = null, + /// The card this frame, in the main window's frame (physical); null over a window of the app's. + place: ?dvui.Rect.Physical = null, + /// The app's window it was last over, brought forward as it came over it. + over: ?*anyopaque = null, +}; +var tear: ?Tear = null; + +/// The floating window drawn without its header — as an OS window's is — though it has no window +/// yet (`named`, `titled`): the one being torn off, past the app's windows or growing into its own. +var frameless: ?dvui.Id = null; + +/// Whether floating window `id` is drawn without its header though it has no window yet +/// (`frameless`): `SDLBackend.floatingWindowOs` answers for it as for an OS window's. +pub fn heldFrameless(id: dvui.Id) bool { + return frameless == id; +} + +/// The card growing into the window let go over the desktop (`tearFrame`): from where it was to the +/// window's frame, a title bar's place opening at its top, the window asked for as it gets there, +/// and the card gone once that window shows. +const Grow = struct { + id: dvui.Id, + card: *viewports.Viewport, + card_target: ?dvui.Texture.Target, + live: ?dvui.Texture.Target, + from: dvui.Rect.Physical, + /// The window's frame (physical): its title bar's band over its content. + to: dvui.Rect.Physical, + band: f32, + start_ns: i128, + /// The window asked for (`tearOff`), once the card has grown. + asked: bool = false, +}; +var grow: ?Grow = null; + +/// A torn-off window moved wholly back into the main window (`beforeFrame`): its last picture +/// shrinking from the window's frame to the floating window's place, the OS's title bar's band +/// becoming dvui's header's, then the floating window there. +const Merge = struct { + id: dvui.Id, + card: *viewports.Viewport, + card_target: ?dvui.Texture.Target = null, + snap: dvui.Texture.Target, + from: dvui.Rect.Physical, + band: f32, + to: dvui.Rect.Physical, + header: f32, + start_ns: i128, +}; +var merge: ?Merge = null; + +/// How long the card takes to grow into its window, or a window to shrink back into the main one, +/// and the floating window to shrink into the card as it goes past the app's windows. +const grow_ns: i128 = 200 * std.time.ns_per_ms; +const shrink_ns: i128 = 150 * std.time.ns_per_ms; +/// The card: half its window's size, and no wider than this (natural). +const card_scale: f32 = 0.5; +const card_max_w: f32 = 360; + +/// The height of dvui's header (`dvui.windowHeader`: the heading font's line and its padding), +/// physical: what the OS's title bar takes the place of. +fn headerHeight() f32 { + return (dvui.Font.theme(.heading).lineHeight() + 10) * dvui.windowNaturalScale(); +} + +/// Torn off the window it was in by a drop over no window (`tearFrame`): shown in an OS window +/// though it did not ask (`SDLBackend.floatingWindowOs`), opened where it was let go (`at`, once). +/// Its window closed, it is an ordinary floating window again. +const Torn = struct { id: dvui.Id, at: ?dvui.Rect.Physical }; +var torn_list: [max_outs]?Torn = @splat(null); + +/// Whether floating window `id` was torn off into an OS window of its own (`tearFrame`). +pub fn torn(id: dvui.Id) bool { + for (torn_list) |slot| if (slot) |t| if (t.id == id) return true; + return false; +} + +/// A window failed to open (no viewport left, or the OS refused one): none is promised (`named`) +/// until one closes. Promised and never opened, a floating window stayed frameless in the main +/// window, unmovable and unclosable. +var open_failed = false; + +/// Floating window `id` begins: whether it is shown in an OS window of its own — it has one, or one +/// can open for it — so dvui draws it without a frame of its own. Its window sized by the OS no +/// smaller than `min` and no larger than `max` (natural). +pub fn named(id: dvui.Id, open_flag: ?*bool, min: dvui.Size, max: dvui.Size) bool { + if (named_n == named_list.len) return false; + // An OS window has a close button, and only `open_flag` closes a floating window: one without + // stays in the main window, where dvui gives it no close button either. + if (open_flag == null) return false; + // One that will open: room in `outs` past those already asked for this frame, and viewports. + var free: usize = 0; + for (outs) |slot| free += @intFromBool(slot == null); + var waiting: usize = 0; + for (named_list[0..named_n]) |w| waiting += @intFromBool(w.shown and outOf(w.id) == null); + // Being torn off: as an OS window's, though none opens for it until it is let go (`frameless`). + const hold = frameless == id and outOf(id) == null and !torn(id); + const shown = hold or outOf(id) != null or (viewports.available() and !open_failed and waiting < free); + named_list[named_n] = .{ .id = id, .open_flag = open_flag, .min = min, .max = max, .shown = shown, .hold = hold }; named_n += 1; + return shown; +} + +/// `windowHeader` in floating window `id`: its title, for its OS window's title bar. Whether it has +/// one (`named`), and the header draws nothing. +pub fn titled(id: dvui.Id, header: []const u8) bool { + for (named_list[0..named_n]) |*w| if (w.id == id) { + // Cut at a character, never inside one: the OS takes the title as UTF-8. + var len = @min(header.len, w.title.len - 1); + while (len > 0 and len < header.len and header[len] & 0xC0 == 0x80) len -= 1; + @memcpy(w.title[0..len], header[0..len]); + w.title[len] = 0; + return w.shown; + }; + return false; } fn natural(r: viewports.Rect, s: f32) dvui.Rect { @@ -68,33 +219,335 @@ fn physical(r: dvui.Rect, s: f32) viewports.Rect { pub fn beforeFrame() void { named_n = 0; const s = dvui.windowNaturalScale(); - var screens: [max_outs]dvui.Rect.Natural = undefined; - var n: usize = 0; - var pin: ?*viewports.Viewport = null; for (&outs) |*slot| { const o = if (slot.*) |*o| o else continue; - if (viewports.osPlaced(o.viewport)) |fr| dvui.dataSet(null, o.id, "_rect", natural(fr, s)); - _ = viewports.osMoveEnded(o.viewport); - const r = dvui.dataGet(null, o.id, "_rect", dvui.Rect) orelse continue; - screens[n] = .{ .x = r.x, .y = r.y, .w = r.w, .h = r.h }; - n += 1; - // Resized from its edges, it holds the pointer: read in its band till it lets go, even - // past its window's edge. - if (dvui.captured(o.id)) pin = o.viewport; + // Where its window is, and its size: the OS's — moved, resized, zoomed, full screen — and + // never the other way. Not grown to fit what it holds, as dvui sizes a floating window + // while its content changes (`_auto_size`). + _ = viewports.osPlaced(o.viewport); + // Torn off, and moved by the OS wholly into the main window: back in it, where it was let + // go, as a floating window of its own again (`torn`). Wholly, so a window only overlapping + // the main one stays a window. + if (viewports.osMoveEnded(o.viewport)) |end| if (!end.resized and torn(o.id) and viewports.mainHolds(o.viewport)) { + const content = viewports.inMain(o.viewport); + dvui.dataSet(null, o.id, "_rect", natural(content, s)); + for (&torn_list) |*ts| if (ts.*) |tt| if (tt.id == o.id) { + ts.* = null; + }; + // Shrinking into its place from its window, its last picture in a card over it + // (`Merge`); or there at once where no card can be made. + const band = viewports.titleBand(); + const c: dvui.Rect.Physical = .{ .x = content.x, .y = content.y, .w = content.w, .h = content.h }; + const from: dvui.Rect.Physical = .{ .x = c.x, .y = c.y - band, .w = c.w, .h = c.h + band }; + if (merge == null and o.target != null and viewports.tears()) if (viewports.openCarry(viewRect(from))) |card| { + merge = .{ .id = o.id, .card = card, .snap = o.target.?, .from = from, .band = band, .to = c, .header = headerHeight(), .start_ns = dvui.currentWindow().frame_time_ns }; + o.target = null; + }; + viewports.close(o.viewport); + if (o.target) |t| t.destroyLater(); + slot.* = null; + open_failed = false; + dvui.refresh(null, @src(), null); + continue; + }; + dvui.dataSet(null, o.id, "_rect", natural(viewports.frameOf(o.viewport), s)); + dvui.dataSet(null, o.id, "_auto_size", false); + } + viewports.publishScreens(park(s)); + // The OS made another window key — clicked, or opened — so dvui's keyboard goes there: to its + // floating window, or the main window's own subwindow. Only as the key window changes, so a + // click in the main window that focuses one of its own floating windows stands. + if (viewports.appActive()) { + var key: ?dvui.Id = null; + for (outs) |slot| if (slot) |o| if (viewports.hasKeyboard(o.viewport)) { + key = o.id; + }; + if (key != keyed) { + keyed = key; + dvui.focusSubwindow(key orelse dvui.currentWindow().data().id, null); + if (key) |id| dvui.raiseSubwindow(id); + } + } +} + +/// A floating window shown only as a card (`Tear` past the app's windows, `Grow`) parked where +/// nothing else is in the frame — left of the main window, as far as the bands are right of it — on +/// a screen of dvui's of its own: dvui clips it to that screen, which is itself, and keeps it there. +/// Where it was, dvui clipped it to the main window wherever its middle was over it — just past the +/// window's edge, and growing into its window (the user). The card is placed by the pointer, not by +/// where the floating window is. Set before the app draws; the screen, for `publishScreens`. +fn park(s: f32) ?dvui.Rect.Natural { + const id = if (grow) |g| g.id else if (tear) |t| if (t.out_ns != null) t.id else return null else return null; + // Its window opened: in that window's band from here (`beforeFrame`), its first picture there. + if (outOf(id) != null) return null; + const r = dvui.dataGet(null, id, "_rect", dvui.Rect) orelse return null; + const at: dvui.Rect.Natural = .{ .x = -viewports.band_stride / s, .y = 0, .w = r.w, .h = r.h }; + dvui.dataSet(null, id, "_rect", dvui.Rect{ .x = at.x, .y = at.y, .w = at.w, .h = at.h }); + return at; +} + +fn viewRect(r: dvui.Rect.Physical) viewports.Rect { + return .{ .x = r.x, .y = r.y, .w = r.w, .h = r.h }; +} + +/// The floating window being dragged this frame, if any, as a card past the app's windows (`Tear`), +/// and a card growing into its window (`Grow`) or a window shrinking back (`Merge`). After the app +/// has drawn, so its rect and drawing are this frame's. +fn tearFrame() void { + if (!viewports.tears()) return; + const cw = dvui.currentWindow(); + growFrame(cw); + mergeFrame(cw); + const held = viewports.pointerHeld(); + var dragged: ?*dvui.Subwindows.Subwindow = null; + if (held) if (dvui.captureMouseGet()) |cm| for (cw.subwindows.stack.items) |*sw| { + if (sw.id != cm.id or !sw.used or sw.modal or sw.id == cw.data().id or outOf(sw.id) != null) continue; + dragged = sw; + break; + }; + const sw = dragged orelse { + if (tear) |*t| { + if (!held and t.place != null and t.live != null and t.card != null and grow == null) { + // Let go over the desktop: the card grows into the window, its content where the + // floating window's would be under the pointer, its title bar's band over it. + const r = t.last; + const mouse = viewports.pointerInMain(); + const band = viewports.titleBand(); + const content: dvui.Rect.Physical = .{ .x = mouse.x - t.grab.x, .y = mouse.y - t.grab.y, .w = r.w, .h = r.h }; + grow = .{ + .id = t.id, + .card = t.card.?, + .card_target = t.card_target, + .live = t.live.?, + .from = t.place.?, + .to = .{ .x = content.x, .y = content.y - band, .w = content.w, .h = content.h + band }, + .band = band, + .start_ns = cw.frame_time_ns, + }; + t.card = null; + t.card_target = null; + t.live = null; + dvui.refresh(null, @src(), null); + } else if (frameless == t.id) frameless = null; + endTear(t); + tear = null; + } + return; + }; + const r = sw.rect_pixels; + const mouse = cw.mouse_pt; + if (tear == null or tear.?.id != sw.id) { + if (tear) |*t| endTear(t); + tear = .{ .id = sw.id, .grab = .{ .x = mouse.x - r.x, .y = mouse.y - r.y }, .last = r }; + return; + } + const t = &tear.?; + if (r.w != t.last.w or r.h != t.last.h) { + t.moving = false; + } else if (r.x != t.last.x or r.y != t.last.y) { + t.moving = true; + } + t.last = r; + // The app's window it is over in front, to show where it would land (the user): once, as it + // comes over each. + const over = viewports.windowAtPointer(); + if (t.moving and over != t.over) if (over) |w| viewports.bringForward(w); + t.over = over; + if (!t.moving or over != null) { + // In a window of the app's: drawn there, as dvui draws it, its header back — from where the + // pointer took it, back over a window: dvui kept it at the edge it left by (`screenFor`) + // while the pointer went on, and it came back that far from the pointer (the user). + if (t.out_ns != null) { + const s = dvui.windowNaturalScale(); + dvui.dataSet(null, t.id, "_rect", dvui.Rect{ .x = (mouse.x - t.grab.x) / s, .y = (mouse.y - t.grab.y) / s, .w = r.w / s, .h = r.h / s }); + dvui.refresh(null, @src(), null); + } + t.out_ns = null; + if (frameless == t.id) frameless = null; + if (t.card) |c| viewports.carryShape(c, null, 0); + t.place = null; + return; + } + if (t.out_ns == null) { + // Past them: drawn without its header from the next frame, and the card from then. This + // frame as dvui drew it, header and all. + t.out_ns = cw.frame_time_ns; + frameless = t.id; + dvui.refresh(null, @src(), null); + return; + } + // Its picture without its header, at its size, taken out of the window dvui keeps it at the + // edge of (`screenFor`): there only at the pointer, as the card. + if (!livePicture(&t.live, sw)) return; + const ns = dvui.windowNaturalScale(); + const small = @min(card_scale, card_max_w * ns / @max(1, r.w)); + // From its size into the card, round the pointer, its header's place closing up as it goes. + const k = ease(cw.frame_time_ns - t.out_ns.?, shrink_ns); + const scale = 1 + (small - 1) * k; + if (k < 1) dvui.refresh(null, @src(), null); + // Where the pointer is on the desktop, in the main window's frame: a floating window pressed in + // another OS window's band was read in that band (`SDLBackend.heldPoint`), and its card and its + // window went a band's width off — 32,000 points — and back on screen as macOS put them (the user, + // traced). + const at = viewports.pointerInMain(); + const card = whole(.{ .x = at.x - t.grab.x * scale, .y = at.y - t.grab.y * scale, .w = r.w * scale, .h = r.h * scale }); + if (t.card == null) t.card = viewports.openCarry(viewRect(card)) orelse return; + drawCard(t.card.?, &t.card_target, t.live.?, card, headerHeight() * (1 - k) * scale, scale); + t.place = card; +} + +/// `sw`'s drawing this frame into `slot`'s target at its size, taken out of the frame: smoothly +/// sampled, for a card that shrinks it (sampled by the nearest pixel, its text crawled as the card +/// moved, the user). Whether there is a picture. +fn livePicture(slot: *?dvui.Texture.Target, sw: *dvui.Subwindows.Subwindow) bool { + const r = sw.rect_pixels; + const w: u32 = @intFromFloat(@max(1, @round(r.w))); + const h: u32 = @intFromFloat(@max(1, @round(r.h))); + if (slot.*) |target| if (target.width != w or target.height != h) { + target.destroyLater(); + slot.* = null; + }; + if (slot.* == null) slot.* = dvui.textureCreateTarget(.{ .width = w, .height = h, .interpolation = .linear }) catch return false; + const picture: viewports.Picture = .begin(slot.*.?, viewRect(r)); + dvui.currentWindow().renderSubwindow(sw, .{ .take = true }); + picture.end(); + return true; +} + +/// A card in carry window `card` at `at` (the main window's frame, physical): the window's colour, +/// a band of it `strip` high at its top — a title bar's or a header's place — and `content` under +/// it at the card's width; its corners the window's at `scale`. +fn drawCard(card: *viewports.Viewport, card_target: *?dvui.Texture.Target, content: dvui.Texture.Target, at: dvui.Rect.Physical, strip: f32, scale: f32) void { + const placed = viewports.placeMain(card, viewRect(at)); + const shown: dvui.Rect.Physical = .{ .x = placed.x, .y = placed.y, .w = placed.w, .h = placed.h }; + viewports.carryShape(card, viewports.windowRadius() * dvui.windowNaturalScale() * scale, 1); + const w: u32 = @intFromFloat(@max(1, @round(shown.w))); + const h: u32 = @intFromFloat(@max(1, @round(shown.h))); + const target = viewports.sizedTarget(card_target, w, h) orelse return; + const picture: viewports.Picture = .begin(target, viewRect(shown)); + { + const prev_clip = dvui.clipGet(); + defer dvui.clipSet(prev_clip); + dvui.clipSet(shown); + shown.fill(.all(0), .{ .color = .{ .color = dvui.themeGet().color(.window, .fill) } }); + if (dvui.Texture.fromTargetTemp(content)) |tex| { + const k = at.w / @max(1, @as(f32, @floatFromInt(content.width))); + const r: dvui.Rect.Physical = .{ .x = at.x, .y = @round(at.y + strip), .w = at.w, .h = @as(f32, @floatFromInt(content.height)) * k }; + dvui.renderTexture(tex, .{ .r = r, .s = 1 }, .{}) catch {}; + } else |_| {} + } + picture.end(); + viewports.present(card, target); +} + +/// The card growing into the window let go over the desktop (`Grow`): eased from where it was to +/// the window's frame, the title bar's band opening at its top, the window asked for as it gets +/// there, and the card gone once that window shows. The floating window, drawn without its header +/// meanwhile (`frameless`), nowhere but in the card. +fn growFrame(cw: *dvui.Window) void { + const g = if (grow) |*g| g else return; + var drawn = false; + for (cw.subwindows.stack.items) |*sw| if (sw.id == g.id) { + drawn = true; + if (outOf(sw.id)) |o| { + if (o.fresh) viewports.Picture.drop(sw); + } else _ = livePicture(&g.live, sw); + }; + // Given up: the floating window closed on its way, or no window opened for it in a second past + // the card's growing (none to spare, or one it cannot have: no `open_flag` to close it by). The + // card goes, and the floating window is an ordinary one again; kept, the card stayed and every + // frame asked for the next. + if (!drawn or (g.asked and outOf(g.id) == null and cw.frame_time_ns - g.start_ns > grow_ns + std.time.ns_per_s)) { + endGrow(g); + return; } - // Only with windows of its own: an app keeping screens of its own (fizzy's floats) sets them. - if (n > 0) dvui.screensSet(screens[0..n]); - if (pin) |vp| { - viewports.pinPointer(.{ .viewport = vp }); - pinned = true; - } else if (pinned) { - viewports.pinPointer(.none); - pinned = false; + const live = g.live orelse return; + const k = ease(cw.frame_time_ns - g.start_ns, grow_ns); + const at = whole(lerp(g.from, g.to, k)); + const scale = at.w / @max(1, g.to.w); + drawCard(g.card, &g.card_target, live, at, g.band * k * scale, scale); + if (k >= 1 and !g.asked) { + g.asked = true; + tearOff(g.id, .{ .x = g.to.x, .y = g.to.y + g.band, .w = g.to.w, .h = g.to.h - g.band }); } + // Its window on screen: the card goes, and the window is where it was. + if (outOf(g.id)) |o| if (!o.fresh and viewports.shown(o.viewport)) { + endGrow(g); + return; + }; + dvui.refresh(null, @src(), null); +} + +fn endGrow(g: *Grow) void { + viewports.close(g.card); + if (g.card_target) |ct| ct.destroyLater(); + if (g.live) |live| live.destroyLater(); + if (frameless == g.id) frameless = null; + // Asked for and never opened: not torn off after all. + if (outOf(g.id) == null) for (&torn_list) |*ts| if (ts.*) |tt| if (tt.id == g.id) { + ts.* = null; + }; + grow = null; + dvui.refresh(null, @src(), null); +} + +/// A torn-off window shrinking back into the main window (`Merge`): its last picture eased from the +/// window's frame to the floating window's place, the OS's title bar's band becoming dvui's +/// header's; the floating window drawn nowhere meanwhile, and there as the card goes. +fn mergeFrame(cw: *dvui.Window) void { + const m = if (merge) |*m| m else return; + const k = ease(cw.frame_time_ns - m.start_ns, grow_ns); + if (k >= 1) { + viewports.close(m.card); + if (m.card_target) |ct| ct.destroyLater(); + m.snap.destroyLater(); + merge = null; + return; + } + for (cw.subwindows.stack.items) |*sw| if (sw.id == m.id) viewports.Picture.drop(sw); + const at = whole(lerp(m.from, m.to, k)); + drawCard(m.card, &m.card_target, m.snap, at, m.band + (m.header - m.band) * k, 1); + dvui.refresh(null, @src(), null); +} + +fn lerp(a: dvui.Rect.Physical, b: dvui.Rect.Physical, k: f32) dvui.Rect.Physical { + return .{ .x = a.x + (b.x - a.x) * k, .y = a.y + (b.y - a.y) * k, .w = a.w + (b.w - a.w) * k, .h = a.h + (b.h - a.h) * k }; +} + +/// How far `elapsed` is through `total`, eased out (cubic), 0 to 1. +fn ease(elapsed: i128, total: i128) f32 { + const t: f32 = @floatCast(std.math.clamp(@as(f64, @floatFromInt(elapsed)) / @as(f64, @floatFromInt(total)), 0, 1)); + return 1 - (1 - t) * (1 - t) * (1 - t); +} + +/// `r` on whole pixels: a card at a fraction of one was drawn a little differently each frame, +/// and shimmered as it moved. +fn whole(r: dvui.Rect.Physical) dvui.Rect.Physical { + return .{ .x = @round(r.x), .y = @round(r.y), .w = @round(r.w), .h = @round(r.h) }; +} + +fn endTear(t: *Tear) void { + if (t.card) |c| viewports.close(c); + if (t.card_target) |target| target.destroyLater(); + if (t.live) |live| live.destroyLater(); + t.card = null; + t.card_target = null; + t.live = null; +} + +/// Floating window `id` in an OS window of its own from the next frame, opened at `at` (the main +/// window's frame, physical) rather than where dvui keeps it, at the window's edge. +fn tearOff(id: dvui.Id, at: dvui.Rect.Physical) void { + for (&torn_list) |*slot| if (slot.* == null) { + slot.* = .{ .id = id, .at = at }; + dvui.refresh(null, @src(), null); + return; + }; } /// After the app's frame, before `dvui.Window.end`. pub fn afterFrame() void { + tearFrame(); if (named_n == 0 and for (outs) |slot| { if (slot != null) break false; } else true) return; @@ -116,11 +569,16 @@ pub fn afterFrame() void { } viewports.close(o.viewport); if (o.target) |t| t.destroyLater(); + // Closed: an ordinary floating window again, next time it shows. + for (&torn_list) |*ts| if (ts.*) |tt| if (tt.id == o.id) { + ts.* = null; + }; slot.* = null; + open_failed = false; } for (named_list[0..named_n]) |*w| { - if (outOf(w.id) != null) continue; + if (!w.shown or w.hold or outOf(w.id) != null) continue; if (!viewports.available()) break; const slot = for (&outs) |*slot| { if (slot.* == null) break slot; @@ -131,8 +589,17 @@ pub fn afterFrame() void { // which would take it out of its window's band again. if (r.w < 1 or r.h < 1) continue; if (dvui.dataGet(null, w.id, "_auto_pos", bool) orelse true) continue; - const vp = viewports.open(physical(r, s), &w.title, .app) orelse break; - slot.* = .{ .id = w.id, .viewport = vp }; + // Torn off: where it was let go. + var at = physical(r, s); + for (&torn_list) |*ts| if (ts.*) |*tt| if (tt.id == w.id) if (tt.at) |a| { + at = viewRect(a); + tt.at = null; + }; + const vp = viewports.open(at, &w.title, .native) orelse { + open_failed = true; + break; + }; + slot.* = .{ .id = w.id, .viewport = vp, .title = w.title }; dvui.dataSet(null, w.id, "_rect", natural(viewports.frameOf(vp), s)); dvui.refresh(null, @src(), null); } @@ -143,31 +610,44 @@ pub fn afterFrame() void { o.fresh = false; continue; } - const r = dvui.dataGet(null, o.id, "_rect", dvui.Rect) orelse continue; - const shown = viewports.place(o.viewport, physical(r, s)); - // The OS moves its window by its header, but for the close button — square at the - // header's height, at its right end where buttons go OK then Cancel, else its left - // (`dvui.windowHeader`) — and not from the floating window's own resize zones round its - // edge (`app_side`). - if (namedOf(o.id)) |w| { - const hd = w.header; - const keep_x = if (cw.button_order == .ok_cancel) hd.x + hd.w - hd.h else hd.x; - viewports.hints(o.viewport, .{ - .drag = .{ .x = hd.x, .y = hd.y, .w = hd.w, .h = hd.h }, - .keep = .{ .x = keep_x, .y = hd.y, .w = hd.h, .h = hd.h }, - .glass = shown, - .edge = 0, - .app_side = 6 * s, - .app_corner = 15 * s, - }); + // Its window's part of the frame: the floating window was given it (`beforeFrame`), and + // anything it did to its own size this frame is not handed back to the window. + const shown = viewports.frameOf(o.viewport); + // Its window's close: as the floating window's close button would, and gone next frame. + if (viewports.closeRequested(o.viewport)) if (namedOf(o.id)) |w| if (w.open_flag) |of| { + of.* = false; + dvui.refresh(null, @src(), null); + }; + if (namedOf(o.id)) |n| { + if (!std.mem.eql(u8, &n.title, &o.title)) { + o.title = n.title; + viewports.setTitle(o.viewport, std.mem.sliceTo(&o.title, 0)); + } + // The OS resizes it, within what the floating window allows — dvui's demo is no wider + // than 600, and its window maximizes no wider. Not what its content needs, which + // changes as it is resized (a pane collapsing) and fought the person. + if (!std.meta.eql(n.min, o.min)) { + o.min = n.min; + viewports.minSize(o.viewport, n.min.w * s, n.min.h * s); + } + if (!std.meta.eql(n.max, o.max)) { + o.max = n.max; + // None each way it has none (`dvui.max_float_safe`). + const none = dvui.max_float_safe; + viewports.maxSize(o.viewport, if (n.max.w >= none) 0 else n.max.w * s, if (n.max.h >= none) 0 else n.max.h * s); + } } const w: u32 = @intFromFloat(@max(1, @round(shown.w))); const h: u32 = @intFromFloat(@max(1, @round(shown.h))); - const target = viewports.sizedTarget(&o.target, w, h) orelse continue; + const target = viewports.keptTarget(o.viewport, &o.target, w, h) orelse continue; const area: dvui.Rect.Physical = .{ .x = shown.x, .y = shown.y, .w = shown.w, .h = shown.h }; const picture: viewports.Picture = .begin(target, shown); + // Every subwindow over any of its part of the frame: the parts are a band apart, so one + // overlaps one at most. By its middle, a floating window larger than the window it was + // dragged into — dvui keeps it on that window's screen from its top left, the rest past the + // edge — had its middle past the part, was in no window's picture, and vanished (the user). for (cw.subwindows.stack.items) |*sw| { - if (area.contains(sw.rect_pixels.center())) dvui.currentWindow().renderSubwindow(sw, .{ .take = true }); + if (!area.intersect(sw.rect_pixels).empty()) dvui.currentWindow().renderSubwindow(sw, .{ .take = true }); } picture.end(); viewports.present(o.viewport, target); diff --git a/backend/src/platform/dialogs.zig b/backend/src/platform/dialogs.zig index c800f95c9..fbb9ab3c1 100644 --- a/backend/src/platform/dialogs.zig +++ b/backend/src/platform/dialogs.zig @@ -182,7 +182,7 @@ fn GenericDialogCallback(cb: ?*anyopaque, files: [*c]const [*c]const u8, mode: D }; var allocated: usize = 0; for (0..path_count) |i| { - zig_files[i] = alloc().dupeZ(u8, std.mem.span(files[i])) catch { + zig_files[i] = alloc().dupeSentinel(u8, std.mem.span(files[i]), 0) catch { dvui.log.err("Failed to dupe dialog result path", .{}); for (zig_files[0..allocated]) |f| alloc().free(f); alloc().free(zig_files); diff --git a/backend/src/platform/geometry.zig b/backend/src/platform/geometry.zig index ab5276119..e40da2221 100644 --- a/backend/src/platform/geometry.zig +++ b/backend/src/platform/geometry.zig @@ -73,9 +73,12 @@ fn fileLoad(_: ?*anyopaque) ?Geometry { const gpa = std.heap.page_allocator; const data = std.Io.Dir.cwd().readFileAlloc(file_io, path, gpa, .limited(4096)) catch return null; defer gpa.free(data); - const z = gpa.dupeZ(u8, data) catch return null; + const z = gpa.dupeSentinel(u8, data, 0) catch return null; defer gpa.free(z); - const g = std.zon.parse.fromSlice(Geometry, gpa, z, null, .{ .ignore_unknown_fields = true }) catch return null; + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var diag: std.zon.parse.Diagnostics = undefined; + const g = std.zon.parse.fromSliceNoAlloc(Geometry, .{ .gpa = gpa, .arena = arena.allocator(), .source = z, .diagnostics = &diag, .ignore_unknown_fields = true }) catch return null; return if (g.valid()) g else null; } @@ -131,7 +134,7 @@ pub fn capture(win: *dvui.Window) ?Geometry { switch (builtin.target.os.tag) { .macos => { const cocoa = cocoaWindowOf(win.backend.impl.window) orelse return null; - var out4: [4]f64 = .{0} ** 4; + var out4: [4]f64 = @splat(0); // The windowed frame — the one it had before a fullscreen Space, while it is in one. fizzy_macos_window_current_windowed_frame(cocoa, &out4); const g: Geometry = .{ .x = out4[0], .y = out4[1], .w = out4[2], .h = out4[3] }; diff --git a/backend/src/platform/linux_titlebar.zig b/backend/src/platform/linux_titlebar.zig index 2b45246f3..5a7a61f91 100644 --- a/backend/src/platform/linux_titlebar.zig +++ b/backend/src/platform/linux_titlebar.zig @@ -64,6 +64,15 @@ pub fn frameInsets(window: *c.SDL_Window) Insets { return .{}; } +/// Whether the compositor blurs behind `window` at all (`blurBehind`), asking nothing of it: for +/// the window materials the backend offers (`SDLBackend.materialKinds`). +pub fn blurOffered(window: *c.SDL_Window) bool { + if (comptime builtin.target.os.tag != .linux) return false; + const props = c.SDL_GetWindowProperties(window); + const display = c.SDL_GetPointerProperty(props, c.SDL_PROP_WINDOW_WAYLAND_DISPLAY_POINTER, null) orelse return false; + return wayland_blur.offered(display); +} + /// The desktop's blur behind the window's frame (`wayland_blur`), inside the margin its shadow is /// drawn in (`frameInsets`), its corners rounded by `radius` points — or none, null. Whether the /// compositor blurs behind windows at all: Wayland with `ext-background-effect-v1` (GNOME 51, diff --git a/backend/src/platform/macos/visual_effect_view.m b/backend/src/platform/macos/visual_effect_view.m index 13a8a5a2b..ebd3ba7c4 100644 --- a/backend/src/platform/macos/visual_effect_view.m +++ b/backend/src/platform/macos/visual_effect_view.m @@ -1,5 +1,6 @@ #import #import +#import #import #import @@ -107,6 +108,21 @@ - (NSView *)hitTest:(NSPoint)point { } @end +/* A plain dvui app's window's backdrop (`fizzy_macos_window_plain_backdrop`): the window's colour + * under SDL's view, with the window's corners, in a window otherwise clear — the part of the window + * that follows its picture on its way into or out of full screen, as glass does + * (`fizzy_macos_window_glass_follow`). */ +@interface FizzyPlainBackdropView : NSView +@property double radius; +@end + +@implementation FizzyPlainBackdropView +- (NSView *)hitTest:(NSPoint)point { + (void)point; + return nil; +} +@end + /* * A float popped out of the main window into a window of its own (fizzy's viewports): its glass * shows the desktop behind it through vibrancy, as the main window's chrome does, so the float's @@ -175,6 +191,49 @@ void fizzy_macos_viewport_plain(void *nswindow) { } } +/* No show or close animation for `nswindow` (`viewportDress`): a window of the OS's own frame + * (`SDLBackend.ViewportFrame.native`), dressed by nothing else of fizzy's. */ +void fizzy_macos_viewport_still(void *nswindow) { + NSWindow *window = (__bridge NSWindow *)nswindow; + if (window != nil) [window setAnimationBehavior:NSWindowAnimationBehaviorNone]; +} + +/* Whether `nswindow` is shown on a Space other than the one the person is looking at + * (`SDLBackend.mainHeld`). */ +int fizzy_macos_window_off_active_space(void *nswindow) { + NSWindow *window = (__bridge NSWindow *)nswindow; + return window != nil && [window isVisible] && ![window isOnActiveSpace] ? 1 : 0; +} + +/* The app's window the pointer is over, the front one where several are, of those that take the + * pointer: shown, on the Space being shown, not click-through (`SDLBackend.windowAtPointer`); null + * over none. Not the window in front under the pointer (`fizzy_macos_window_at_pointer`): a + * tear-off's card follows the pointer, and asked that, the card — or the glass the OS puts with it — + * answered, none of the app's ordinary windows, and the pointer read as over the desktop over a + * window of the app's (the user, traced). */ +void *fizzy_macos_app_window_at_pointer(void) { + const NSPoint p = [NSEvent mouseLocation]; + for (NSWindow *w in [NSApp orderedWindows]) { + if (![w isVisible] || ![w isOnActiveSpace] || [w ignoresMouseEvents]) continue; + if (NSPointInRect(p, [w frame])) return (__bridge void *)w; + } + return NULL; +} + +/* `nswindow` in front of the app's other windows, not made key: a window a drag is over + * (`os_windows`' tear-off), brought forward to show where it would land. */ +void fizzy_macos_window_bring_forward(void *nswindow) { + NSWindow *w = (__bridge NSWindow *)nswindow; + if (w != nil) [w orderFront:nil]; +} + +/* The app's window in front under the pointer, or nil where another app's is + * (`SDLBackend.heldAcrossWindows`). */ +void *fizzy_macos_window_at_pointer(void) { + const NSInteger n = [NSWindow windowNumberAtPoint:[NSEvent mouseLocation] belowWindowWithWindowNumber:0]; + return (__bridge void *)[NSApp windowWithWindowNumber:n]; +} + void fizzy_macos_viewport_reshadow(void *nswindow) { @autoreleasepool { NSWindow *window = (__bridge NSWindow *)nswindow; @@ -1452,6 +1511,111 @@ static BOOL windowGlassPicture(NSWindow *window, NSRect *in_frame_view, double * /* The glass behind a float's window — its vibrancy, or its Liquid Glass — where the float is drawn: * only where its picture is while the window stands still on its way into or out of full screen, * all of the window otherwise. Each step of the way, and as it ends. */ +/* `nswindow` made a plain dvui app's (`SDLBackend.titleBand`): clear, SDL's Metal layer too, over a + * backdrop of `r`,`g`,`b` with corners of `radius` points — square in full screen. Again with a new + * colour as the theme changes. */ +static void make_layers_clear(NSView *view) { + if ([view.layer isKindOfClass:[CAMetalLayer class]]) view.layer.opaque = NO; + for (NSView *sub in view.subviews) make_layers_clear(sub); +} + +/* Whether `nswindow` has the backdrop in its colour (`fizzy_macos_window_plain_backdrop`). */ +int fizzy_macos_window_has_plain_backdrop(void *nswindow) { + NSWindow *window = (__bridge NSWindow *)nswindow; + NSView *frame = [[window contentView] superview]; + for (NSView *v in [frame subviews]) { + if ([v isKindOfClass:[FizzyPlainBackdropView class]]) return 1; + } + return 0; +} + +void fizzy_macos_window_plain_backdrop(void *nswindow, double r, double g, double b, double radius) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + NSView *content = [window contentView]; + NSView *frame = [content superview]; + if (window == nil || content == nil || frame == nil) return; + if ([window isOpaque]) [window setOpaque:NO]; + [window setBackgroundColor:[NSColor clearColor]]; + /* The OS's shadow, and the 1-point outline that comes with it round a titled window: SDL + * makes a transparent window without one (`viewportDress`). */ + if (![window hasShadow]) [window setHasShadow:YES]; + make_layers_clear(content); + FizzyPlainBackdropView *backdrop = nil; + for (NSView *v in [frame subviews]) { + if ([v isKindOfClass:[FizzyPlainBackdropView class]]) backdrop = (FizzyPlainBackdropView *)v; + } + if (backdrop == nil) { + backdrop = [[FizzyPlainBackdropView alloc] initWithFrame:[frame bounds]]; + /* Layer-hosting: its layer its own, which AppKit does not redraw over. */ + [backdrop setLayer:[CALayer layer]]; + [backdrop setWantsLayer:YES]; + [backdrop setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; + [frame addSubview:backdrop positioned:NSWindowBelow relativeTo:content]; +#if !__has_feature(objc_arc) + /* Held by the frame view now; this file is built without ARC. */ + [backdrop release]; +#endif + } + backdrop.radius = radius; + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + CGColorRef color = CGColorCreateSRGB(r, g, b, 1); + backdrop.layer.backgroundColor = color; + CGColorRelease(color); + const BOOL full = ([window styleMask] & NSWindowStyleMaskFullScreen) != 0; + backdrop.layer.cornerRadius = full ? 0 : radius; + backdrop.layer.masksToBounds = NO; + [CATransaction commit]; + [window invalidateShadow]; + } +} + +/* The OS's shadow and 1-point outline round a titled window, drawn by the backdrop's own layer + * while `on` (`fizzy_macos_window_glass_follow`): a soft shadow below it, and an outline light on a + * dark window and dark on a light one. */ +static void backdropFrame(CALayer *layer, BOOL on, double radius, NSWindow *window) { + if (!on) { + if (layer.shadowOpacity != 0) layer.shadowOpacity = 0; + if (layer.borderWidth != 0) layer.borderWidth = 0; + return; + } + const BOOL dark = [[[window effectiveAppearance] bestMatchFromAppearancesWithNames:@[ NSAppearanceNameAqua, NSAppearanceNameDarkAqua ]] isEqualToString:NSAppearanceNameDarkAqua]; + layer.masksToBounds = NO; + layer.shadowColor = CGColorGetConstantColor(kCGColorBlack); + layer.shadowOpacity = dark ? 0.6f : 0.35f; + layer.shadowRadius = 20; + layer.shadowOffset = CGSizeMake(0, -10); + CGPathRef path = CGPathCreateWithRoundedRect(layer.bounds, radius, radius, NULL); + layer.shadowPath = path; + CGPathRelease(path); + CGColorRef edge = CGColorCreateSRGB(dark ? 1 : 0, dark ? 1 : 0, dark ? 1 : 0, dark ? 0.18 : 0.22); + layer.borderColor = edge; + CGColorRelease(edge); + layer.borderWidth = 1; +} + +/* SDL's view clipped to `picture` (in the frame view), rounded by `radius`, on a still window's way + * into or out of full screen; unclipped otherwise (`picture` empty). The OS rounds a window's + * content to its corners, but on that way the window is the screen's size and the picture only part + * of it: the picture's square corners stood past the backdrop's rounded ones (the user). */ +static void clip_content(NSView *content, NSView *frame, NSRect picture, double radius) { + if (NSIsEmptyRect(picture)) { + if (content.layer.mask != nil) content.layer.mask = nil; + return; + } + if (![content wantsLayer]) [content setWantsLayer:YES]; + CAShapeLayer *mask = [content.layer.mask isKindOfClass:[CAShapeLayer class]] ? (CAShapeLayer *)content.layer.mask : nil; + if (mask == nil) { + mask = [CAShapeLayer layer]; + content.layer.mask = mask; + } + mask.frame = content.layer.bounds; + CGPathRef path = CGPathCreateWithRoundedRect(NSRectToCGRect([frame convertRect:picture toView:content]), radius, radius, NULL); + mask.path = path; + CGPathRelease(path); +} + void fizzy_macos_window_glass_follow(void *nswindow) { @autoreleasepool { NSWindow *window = (__bridge NSWindow *)nswindow; @@ -1459,10 +1623,35 @@ void fizzy_macos_window_glass_follow(void *nswindow) { NSView *frame = [content superview]; if (content == nil || frame == nil) return; NSRect picture; - const BOOL still = windowGlassPicture(window, &picture, NULL); + double fullness = 0; + const BOOL still = windowGlassPicture(window, &picture, &fullness); [CATransaction begin]; [CATransaction setDisableActions:YES]; + const BOOL full = ([window styleMask] & NSWindowStyleMaskFullScreen) != 0; for (NSView *v in [frame subviews]) { + if ([v isKindOfClass:[FizzyPlainBackdropView class]]) { + /* The window's shape where the window is, through its way: its corners until it + * stands at full screen. */ + FizzyPlainBackdropView *backdrop = (FizzyPlainBackdropView *)v; + const NSRect want = still ? picture : [frame bounds]; + if (!NSEqualRects([v frame], want)) [v setFrame:want]; + /* Its corners square as it goes into full screen and round as it comes out, with its + * picture (`fullness`), not at either end. */ + const double radius = still ? backdrop.radius * (1.0 - fmin(fmax(fullness, 0.0), 1.0)) : (full ? 0 : backdrop.radius); + if (v.layer.cornerRadius != radius) v.layer.cornerRadius = radius; + clip_content(content, frame, still ? picture : NSZeroRect, radius); + /* The OS's shadow and outline again once it is out: AppKit's style changes on the + * way turned them off (the user). */ + if (!full && !still) { + if (![window hasShadow]) [window setHasShadow:YES]; + [window invalidateShadow]; + } + /* On its way into or out of full screen the window is the screen's size, and the OS + * draws no shadow or outline round a full-screen window: the backdrop draws them + * round itself, where the window seems to be, until it lands and the OS's are back. */ + backdropFrame(v.layer, still, radius, window); + continue; + } if (![v isKindOfClass:[FizzyViewportGlassView class]]) continue; const NSRect want = still ? picture : [content frame]; if (!NSEqualRects([v frame], want)) [v setFrame:want]; diff --git a/backend/src/platform/macos/window_monitor.m b/backend/src/platform/macos/window_monitor.m index 48abcc21d..c5ea1db64 100644 --- a/backend/src/platform/macos/window_monitor.m +++ b/backend/src/platform/macos/window_monitor.m @@ -308,7 +308,11 @@ static void pump_tick_inner(void) { if (m->exit_origin_until > CACurrentMediaTime()) { restore_pre_fullscreen_origin_if_nudged((__bridge NSWindow *)m->window); } - if (m->anim_active) step_space_animation(m); + /* Not a still window's: its picture moves only in the app's frames + * (`fizzy_macos_window_space_step`), and stepped here between them too, its backdrop ran + * ahead of a picture drawn late — a frame of its own round the float, a band (the user, + * while recording the screen slowed the frames). */ + if (m->anim_active && !m->still) step_space_animation(m); hold_landing(m); fizzy_macos_window_sync_content_views(m->window); fizzy_macos_window_pump_sync(m->window); @@ -876,6 +880,9 @@ void fizzy_macos_window_space_stage(int stage, void *nswindow) { break; case 1: // didEnter finish_space_animation(m); + /* Its backdrop square, its corners the screen's: however it got here — AppKit's own way + * in, Split View — and not only the monitor's own animation. */ + fizzy_macos_window_glass_follow(win); /* Full screen in AppKit's eyes from here: its frame view square now, not at whatever * next asks for a layout (`still_frame_square`). */ if (m->still) relayout_frame((__bridge NSWindow *)win); @@ -896,6 +903,8 @@ void fizzy_macos_window_space_stage(int stage, void *nswindow) { break; case 3: // didExit finish_space_animation(m); + /* Its corners back, however it came out: AppKit's own way out of Split View too. */ + fizzy_macos_window_glass_follow(win); m->space_transition = NO; m->space_entering = NO; m->unzoom_animating = NO; diff --git a/backend/src/platform/macos_monitor.zig b/backend/src/platform/macos_monitor.zig index 9bf665b28..2cb2d59c4 100644 --- a/backend/src/platform/macos_monitor.zig +++ b/backend/src/platform/macos_monitor.zig @@ -378,7 +378,12 @@ export fn fizzy_macos_origin_nudged(cap_x: f64, cap_y: f64, cur_x: f64, cur_y: f /// here answers from AppKit's own state, and the window only keeps its content under the title bar /// through SDL's styles, for fizzy's chrome (`styleTitled`): without that it came back from each /// round trip a title bar shorter. -pub fn install(win: *dvui.Window) void { +/// +/// `still`: the window stands still at its full-screen size on its way into or out of full screen, +/// its picture growing or shrinking in it (as a float's window, `watch`) — for an app whose frame +/// the backend lays down where the picture is (`SDLBackend.places_main_picture`). Otherwise only a +/// window of Liquid Glass does. +pub fn install(win: *dvui.Window, still: bool) void { if (comptime builtin.target.os.tag != .macos) return; const back = win.backend.impl; const cocoa = cocoaWindowOf(back.window) orelse return; @@ -388,6 +393,7 @@ pub fn install(win: *dvui.Window) void { back.present_hook = macosAppPresented; addWatched(.{ .window = back.window, .cocoa = cocoa, .follow_position = false }); fizzy_macos_window_install_resize_observer(cocoa); + if (still) fizzy_macos_window_space_still(cocoa); // Draw each step of a live resize from inside it, presented with the Core Animation // transaction that resizes the window, so the new size and the frame drawn for it reach the // screen together (fizzy's SDL patches 0003-0004, `docs/MACOS_LIVE_RESIZE.md`). By name, @@ -429,6 +435,13 @@ pub fn unwatch(window: *c.SDL_Window) void { if (comptime builtin.target.os.tag != .macos) return; const cocoa = cocoaWindowOf(window) orelse return; fizzy_macos_window_uninstall_monitor(cocoa); + // The main window, as the app quits: the pump's tick reads it without asking for its monitor + // (`fizzy_macos_window_pump_render`), and one more tick while SDL quits would draw into a + // window it has destroyed. + if (macos_monitor_window == window) { + macos_monitor_window = null; + macos_pump_ready = false; + } for (&watched) |*slot| { if (slot.*) |w| if (w.cocoa == cocoa) { slot.* = null; @@ -461,8 +474,33 @@ pub const TitlebarState = struct { }; pub fn titlebarState(win: *dvui.Window) TitlebarState { + return titlebarStateOf(win.backend.impl.window); +} + +/// The band at the top of `window` its content keeps clear of the traffic lights, points: no less +/// than `top_buffer` and `titlebar_height` — an app's own header (fizzy's) — and AppKit's title +/// bar; `top_buffer` alone in a fullscreen Space; closing up and opening again with the window's +/// way into and out of one. 0 and 0 for a plain dvui app's window, the band under the OS's title +/// bar (`SDLBackend.places_main_picture`). `titlebar_height` off macOS. +pub fn titlebarStrip(window: *c.SDL_Window, titlebar_height: f32, top_buffer: f32) f32 { + if (comptime builtin.target.os.tag != .macos) return titlebar_height; + const t = titlebarStateOf(window); + const in: window_layout.StripInputs = .{ + .collapsed = t.collapsed, + .restoring_chrome = t.restoring_chrome, + .live_inset = t.live_inset, + .saved_inset = t.saved_inset, + .titlebar_height = titlebar_height, + .titlebar_top_buffer = top_buffer, + }; + if (@import("window.zig").windowSpaceFullness(window)) |f| return window_layout.titlebarStripAtFullness(in, f); + return window_layout.chooseTitlebarStrip(in); +} + +/// `titlebarState` of any watched window: the main one, or one of the OS's own frame. +pub fn titlebarStateOf(window: *c.SDL_Window) TitlebarState { if (comptime builtin.target.os.tag != .macos) return .{}; - const raw_ptr = cocoaWindowOf(win.backend.impl.window); + const raw_ptr = cocoaWindowOf(window); const inset = if (raw_ptr != null) fizzy_macos_window_titlebar_inset(raw_ptr) else 0; const saved = fizzy_macos_window_saved_titlebar_inset(raw_ptr); return .{ diff --git a/backend/src/platform/menu.zig b/backend/src/platform/menu.zig index df41db01a..a75be5918 100644 --- a/backend/src/platform/menu.zig +++ b/backend/src/platform/menu.zig @@ -115,13 +115,13 @@ pub export fn FizzyMenuEnabled(section: c_int, tag: c_int) callconv(.c) bool { const h = hooks orelse return true; if (h.input_blocked(h.ctx)) return false; if (section < 0 or section > 2 or tag < 0) return true; - return h.enabled(h.ctx, .{ .section = @enumFromInt(section), .tag = @intCast(tag) }); + return h.enabled(h.ctx, .{ .section = @fromBackingInt(@intCast(section)), .tag = @intCast(tag) }); } pub export fn FizzyMenuTitle(section: c_int, tag: c_int) callconv(.c) ?[*:0]const u8 { const h = hooks orelse return null; if (section < 0 or section > 2 or tag < 0) return null; - return h.title(h.ctx, .{ .section = @enumFromInt(section), .tag = @intCast(tag) }); + return h.title(h.ctx, .{ .section = @fromBackingInt(@intCast(section)), .tag = @intCast(tag) }); } pub export fn FizzyMenuInputBlocked() callconv(.c) bool { @@ -142,7 +142,7 @@ fn nsString(text: [*:0]const u8) objc.Object { /// A sentinel copy of `text` for AppKit, from the platform allocator; free with `freeZ`. fn dupeZ(text: []const u8) ?[:0]u8 { - return dialogs.allocator().dupeZ(u8, text) catch null; + return dialogs.allocator().dupeSentinel(u8, text, 0) catch null; } fn setImage(item: objc.Object, symbol: [*:0]const u8, description: [*:0]const u8) void { @@ -398,7 +398,7 @@ fn applyKey(item: objc.Object, key: ?[]const u8, modifiers: c_ulong) void { /// The item chosen in `section` since the last call, if one was. Call once a frame, inside it. pub fn pollActivation(section: Section) ?Activation { - const tag = pending_tag[@intFromEnum(section)].swap(-1, .acq_rel); + const tag = pending_tag[@backingInt(section)].swap(-1, .acq_rel); if (tag < 0) return null; return .{ .ref = .{ .section = section, .tag = @intCast(tag) }, diff --git a/backend/src/platform/wayland_blur.zig b/backend/src/platform/wayland_blur.zig index ad95efbcc..a6e07a5e2 100644 --- a/backend/src/platform/wayland_blur.zig +++ b/backend/src/platform/wayland_blur.zig @@ -24,6 +24,14 @@ pub const Want = struct { radius: f32, }; +/// Whether the compositor blurs behind windows at all, as `behind` would say, setting nothing. +pub fn offered(display: *anyopaque) bool { + if (comptime builtin.target.os.tag != .linux) return false; + const s = ready(display) orelse return false; + _ = s.lib.display_dispatch_queue_pending(s.display, s.queue); + return (s.effect_manager != null and s.effect_blur) or s.kde_manager != null; +} + /// Blur behind `surface` (a `wl_surface` of `display`, SDL's) as `want` says, or none: whether /// the compositor blurs behind windows at all — it has one of the protocols, and its blur is on. /// Cheap to call every frame: the region is sent only when it changes. The first call binds the @@ -118,9 +126,9 @@ fn load() ?Lib { // SDL's own copy, already loaded for the window: never a second one. const handle = std.c.dlopen("libwayland-client.so.0", .{ .LAZY = true, .NOLOAD = true }) orelse return null; var lib: Lib = undefined; - inline for (@typeInfo(Lib).@"struct".fields) |f| { - const sym = std.c.dlsym(handle, "wl_" ++ f.name) orelse return null; - @field(lib, f.name) = @ptrCast(@alignCast(sym)); + inline for (@typeInfo(Lib).@"struct".field_names, @typeInfo(Lib).@"struct".field_types, @typeInfo(Lib).@"struct".field_attrs) |f_name, _, _| { + const sym = std.c.dlsym(handle, "wl_" ++ f_name) orelse return null; + @field(lib, f_name) = @ptrCast(@alignCast(sym)); } return lib; } diff --git a/backend/src/platform/win32_titlebar.zig b/backend/src/platform/win32_titlebar.zig index cf018c495..95c6ef737 100644 --- a/backend/src/platform/win32_titlebar.zig +++ b/backend/src/platform/win32_titlebar.zig @@ -53,7 +53,7 @@ pub fn applyChrome(win: *dvui.Window) void { _ = win32.graphics.dwm.DwmSetWindowAttribute( hwnd_h, - @as(win32.graphics.dwm.DWMWINDOWATTRIBUTE, @enumFromInt(DWMWA_WINDOW_CORNER_PREFERENCE)), + @as(win32.graphics.dwm.DWMWINDOWATTRIBUTE, @fromBackingInt(@intCast(DWMWA_WINDOW_CORNER_PREFERENCE))), &DWMWCP_ROUND, @sizeOf(u32), ); @@ -75,7 +75,7 @@ pub fn applyChrome(win: *dvui.Window) void { const backdrop_type: u32 = DWMSBT_TRANSIENTWINDOW; _ = win32.graphics.dwm.DwmSetWindowAttribute( hwnd_h, - @as(win32.graphics.dwm.DWMWINDOWATTRIBUTE, @enumFromInt(DWMWA_SYSTEMBACKDROP_TYPE)), + @as(win32.graphics.dwm.DWMWINDOWATTRIBUTE, @fromBackingInt(@intCast(DWMWA_SYSTEMBACKDROP_TYPE))), &backdrop_type, @sizeOf(u32), ); @@ -108,12 +108,50 @@ pub fn setDarkMode(win: *dvui.Window, dark: bool) void { const value: u32 = @intFromBool(dark); _ = win32.graphics.dwm.DwmSetWindowAttribute( @as(win32.foundation.HWND, @ptrCast(hwnd)), - @as(win32.graphics.dwm.DWMWINDOWATTRIBUTE, @enumFromInt(DWMWA_USE_IMMERSIVE_DARK_MODE)), + @as(win32.graphics.dwm.DWMWINDOWATTRIBUTE, @fromBackingInt(@intCast(DWMWA_USE_IMMERSIVE_DARK_MODE))), &value, @sizeOf(u32), ); } +/// The OS's caption on `window`, in `color` with its title in `text`, light or dark as the theme +/// is (`window.nativeTitleBar`). Caption and text colours are Windows 11's; earlier, DWM's own. +/// A window of the OS's own frame with a most size (`viewports.maxSize`) has no maximize button, as a +/// Windows app with one has none: `SW_MAXIMIZE`, the button and a double-click on the caption fill +/// the screen past the most SDL keeps a drag to. Given back once it has no most. +pub fn allowMaximize(window: *c.SDL_Window, allow: bool) void { + if (comptime builtin.target.os.tag != .windows) return; + const raw = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_WIN32_HWND_POINTER, null) orelse return; + const h: win32.foundation.HWND = @ptrCast(raw); + const wm = win32.ui.windows_and_messaging; + const ws_maximizebox: isize = 0x00010000; + const style = wm.GetWindowLongPtrW(h, wm.GWL_STYLE); + const want = if (allow) style | ws_maximizebox else style & ~ws_maximizebox; + if (want == style) return; + _ = wm.SetWindowLongPtrW(h, wm.GWL_STYLE, want); + // SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED: the caption + // drawn again without the button. + _ = wm.SetWindowPos(h, null, 0, 0, 0, 0, @bitCast(@as(u32, 0x0001 | 0x0002 | 0x0004 | 0x0010 | 0x0020))); +} + +pub fn nativeCaption(window: *c.SDL_Window, color: dvui.Color, text: dvui.Color, dark: bool) void { + if (builtin.target.os.tag != .windows) return; + const hwnd = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_WIN32_HWND_POINTER, null) orelse return; + const hwnd_h = @as(win32.foundation.HWND, @ptrCast(hwnd)); + const dark_value: u32 = @intFromBool(dark); + _ = win32.graphics.dwm.DwmSetWindowAttribute( + hwnd_h, + @as(win32.graphics.dwm.DWMWINDOWATTRIBUTE, @fromBackingInt(@intCast(DWMWA_USE_IMMERSIVE_DARK_MODE))), + &dark_value, + @sizeOf(u32), + ); + // COLORREF: 0x00BBGGRR. + const caption: u32 = @as(u32, color.b) << 16 | @as(u32, color.g) << 8 | color.r; + const title: u32 = @as(u32, text.b) << 16 | @as(u32, text.g) << 8 | text.r; + _ = win32.graphics.dwm.DwmSetWindowAttribute(hwnd_h, win32.graphics.dwm.DWMWA_CAPTION_COLOR, &caption, @sizeOf(u32)); + _ = win32.graphics.dwm.DwmSetWindowAttribute(hwnd_h, win32.graphics.dwm.DWMWA_TEXT_COLOR, &title, @sizeOf(u32)); +} + /// No caption or border tint: the app draws its own title bar in the client area. pub fn clearCaptionColors(win: *dvui.Window) void { if (builtin.target.os.tag != .windows) return; @@ -238,7 +276,7 @@ fn win32MicaSubclassProc( const backdrop_type: u32 = DWMSBT_TRANSIENTWINDOW; _ = win32.graphics.dwm.DwmSetWindowAttribute( hWnd, - @as(win32.graphics.dwm.DWMWINDOWATTRIBUTE, @enumFromInt(DWMWA_SYSTEMBACKDROP_TYPE)), + @as(win32.graphics.dwm.DWMWINDOWATTRIBUTE, @fromBackingInt(@intCast(DWMWA_SYSTEMBACKDROP_TYPE))), &backdrop_type, @sizeOf(u32), ); @@ -277,8 +315,8 @@ fn win32MicaSubclassProc( // Resize edges/corners only while not maximized. const frame: titlebar.Frame = if (win32.ui.windows_and_messaging.IsZoomed(hWnd) != 0) .{} else .{ - .w = @max(win32.ui.windows_and_messaging.GetSystemMetrics(@as(win32.ui.windows_and_messaging.SYSTEM_METRICS_INDEX, @enumFromInt(SM_CXSIZEFRAME))), 4), - .h = @max(win32.ui.windows_and_messaging.GetSystemMetrics(@as(win32.ui.windows_and_messaging.SYSTEM_METRICS_INDEX, @enumFromInt(SM_CYSIZEFRAME))), 4), + .w = @max(win32.ui.windows_and_messaging.GetSystemMetrics(@as(win32.ui.windows_and_messaging.SYSTEM_METRICS_INDEX, @fromBackingInt(@intCast(SM_CXSIZEFRAME)))), 4), + .h = @max(win32.ui.windows_and_messaging.GetSystemMetrics(@as(win32.ui.windows_and_messaging.SYSTEM_METRICS_INDEX, @fromBackingInt(@intCast(SM_CYSIZEFRAME)))), 4), }; // The caption-button codes are also what make the Win11 snap-layouts flyout appear on // the maximize button. @@ -384,7 +422,7 @@ pub fn viewportChrome(hwnd: *anyopaque, main_hwnd: ?*anyopaque, dark: bool, radi const h: win32.foundation.HWND = @ptrCast(hwnd); const dwm = win32.graphics.dwm; const corner: u32 = if (radius_pt < 1) DWMWCP_DONOTROUND else if (radius_pt < 6) DWMWCP_ROUNDSMALL else DWMWCP_ROUND; - _ = dwm.DwmSetWindowAttribute(h, @enumFromInt(DWMWA_WINDOW_CORNER_PREFERENCE), &corner, @sizeOf(u32)); + _ = dwm.DwmSetWindowAttribute(h, @fromBackingInt(@intCast(DWMWA_WINDOW_CORNER_PREFERENCE)), &corner, @sizeOf(u32)); // No border and no caption colour, as the main window has none (`clearCaptionColors`): DWM // treats the window as framed (its caption taken away, `WM_NCCALCSIZE`) and tinted the strip // where a title bar would be — the accent colour, where Windows shows it on title bars — so @@ -393,11 +431,11 @@ pub fn viewportChrome(hwnd: *anyopaque, main_hwnd: ?*anyopaque, dark: bool, radi _ = dwm.DwmSetWindowAttribute(h, dwm.DWMWA_BORDER_COLOR, &none, @sizeOf(u32)); _ = dwm.DwmSetWindowAttribute(h, dwm.DWMWA_CAPTION_COLOR, &none, @sizeOf(u32)); const dark_value: u32 = @intFromBool(dark); - _ = dwm.DwmSetWindowAttribute(h, @enumFromInt(DWMWA_USE_IMMERSIVE_DARK_MODE), &dark_value, @sizeOf(u32)); + _ = dwm.DwmSetWindowAttribute(h, @fromBackingInt(@intCast(DWMWA_USE_IMMERSIVE_DARK_MODE)), &dark_value, @sizeOf(u32)); // No DWM animation as it shows or closes: it appears and goes exactly where its float is drawn, // in the frame it changes in (macOS: `NSWindowAnimationBehaviorNone`). const no_transitions: u32 = 1; - _ = dwm.DwmSetWindowAttribute(h, @enumFromInt(DWMWA_TRANSITIONS_FORCEDISABLED), &no_transitions, @sizeOf(u32)); + _ = dwm.DwmSetWindowAttribute(h, @fromBackingInt(@intCast(DWMWA_TRANSITIONS_FORCEDISABLED)), &no_transitions, @sizeOf(u32)); _ = win32.ui.shell.SetWindowSubclass(h, viewportSubclassProc, viewport_subclass_id, @intFromPtr(loop)); // No system menu: with the frame extended over the window, DWM draws the caption buttons // `WS_SYSMENU` brings, over the float's header (as `applyChrome` strips it from the main @@ -445,7 +483,7 @@ pub fn viewportMenuChrome(hwnd: *anyopaque, dark: bool, radius_pt: f32) bool { const dwm = win32.graphics.dwm; const wm = win32.ui.windows_and_messaging; const corner: u32 = if (radius_pt < 1) DWMWCP_DONOTROUND else if (radius_pt < 6) DWMWCP_ROUNDSMALL else DWMWCP_ROUND; - _ = dwm.DwmSetWindowAttribute(h, @enumFromInt(DWMWA_WINDOW_CORNER_PREFERENCE), &corner, @sizeOf(u32)); + _ = dwm.DwmSetWindowAttribute(h, @fromBackingInt(@intCast(DWMWA_WINDOW_CORNER_PREFERENCE)), &corner, @sizeOf(u32)); // No border and no caption colour, as the main window has none (`clearCaptionColors`): DWM // treats the window as framed (its caption taken away, `WM_NCCALCSIZE`) and tinted the strip // where a title bar would be — the accent colour, where Windows shows it on title bars — so @@ -454,9 +492,9 @@ pub fn viewportMenuChrome(hwnd: *anyopaque, dark: bool, radius_pt: f32) bool { _ = dwm.DwmSetWindowAttribute(h, dwm.DWMWA_BORDER_COLOR, &none, @sizeOf(u32)); _ = dwm.DwmSetWindowAttribute(h, dwm.DWMWA_CAPTION_COLOR, &none, @sizeOf(u32)); const dark_value: u32 = @intFromBool(dark); - _ = dwm.DwmSetWindowAttribute(h, @enumFromInt(DWMWA_USE_IMMERSIVE_DARK_MODE), &dark_value, @sizeOf(u32)); + _ = dwm.DwmSetWindowAttribute(h, @fromBackingInt(@intCast(DWMWA_USE_IMMERSIVE_DARK_MODE)), &dark_value, @sizeOf(u32)); const no_transitions: u32 = 1; - _ = dwm.DwmSetWindowAttribute(h, @enumFromInt(DWMWA_TRANSITIONS_FORCEDISABLED), &no_transitions, @sizeOf(u32)); + _ = dwm.DwmSetWindowAttribute(h, @fromBackingInt(@intCast(DWMWA_TRANSITIONS_FORCEDISABLED)), &no_transitions, @sizeOf(u32)); _ = win32.ui.shell.SetWindowSubclass(h, menuSubclassProc, menu_subclass_id, 0); // No system menu, whose caption buttons DWM would draw over the menu with the frame extended // (as for a float's window, `viewportChrome`); SDL's caption style stays, collapsed by its @@ -491,7 +529,7 @@ fn menuSubclassProc( // Always active to DWM, so its backdrop stays Acrylic rather than the inactive fallback. if (uMsg == wm.WM_NCACTIVATE) return win32.ui.shell.DefSubclassProc(hWnd, uMsg, 1, lParam); // SDL sets the window's style again as it likes: never the system menu. - if (uMsg == wm.WM_STYLECHANGING and @as(isize, @bitCast(wParam)) == @intFromEnum(wm.GWL_STYLE)) { + if (uMsg == wm.WM_STYLECHANGING and @as(isize, @bitCast(wParam)) == @backingInt(wm.GWL_STYLE)) { const ss: *wm.STYLESTRUCT = @ptrFromInt(@as(usize, @bitCast(lParam))); ss.styleNew &= ~@as(u32, @intCast(viewport_ws_sysmenu)); } @@ -506,7 +544,7 @@ fn menuSubclassProc( /// every activation, as DWM wants (and as `win32MicaSubclassProc` does for the main window). fn applyViewportBackdrop(h: win32.foundation.HWND) bool { const backdrop_type: u32 = DWMSBT_TRANSIENTWINDOW; - const hr = win32.graphics.dwm.DwmSetWindowAttribute(h, @enumFromInt(DWMWA_SYSTEMBACKDROP_TYPE), &backdrop_type, @sizeOf(u32)); + const hr = win32.graphics.dwm.DwmSetWindowAttribute(h, @fromBackingInt(@intCast(DWMWA_SYSTEMBACKDROP_TYPE)), &backdrop_type, @sizeOf(u32)); _ = win32.graphics.dwm.DwmExtendFrameIntoClientArea(h, &win32_mica_margins); return hr >= 0; } @@ -574,12 +612,12 @@ fn viewportSubclassProc( if (hWnd) |h| _ = applyViewportBackdrop(h); } // SDL sets the window's style again as it likes (`viewportChrome`): never the system menu. - if (uMsg == wm.WM_STYLECHANGING and @as(isize, @bitCast(wParam)) == @intFromEnum(wm.GWL_STYLE)) { + if (uMsg == wm.WM_STYLECHANGING and @as(isize, @bitCast(wParam)) == @backingInt(wm.GWL_STYLE)) { const ss: *wm.STYLESTRUCT = @ptrFromInt(@as(usize, @bitCast(lParam))); ss.styleNew &= ~@as(u32, @intCast(viewport_ws_sysmenu)); } // Nor its taskbar button. - if (uMsg == wm.WM_STYLECHANGING and @as(isize, @bitCast(wParam)) == @intFromEnum(wm.GWL_EXSTYLE)) { + if (uMsg == wm.WM_STYLECHANGING and @as(isize, @bitCast(wParam)) == @backingInt(wm.GWL_EXSTYLE)) { const ss: *wm.STYLESTRUCT = @ptrFromInt(@as(usize, @bitCast(lParam))); ss.styleNew |= @as(u32, @intCast(viewport_ws_ex_appwindow)); } diff --git a/backend/src/platform/window.zig b/backend/src/platform/window.zig index 63279fbe3..e95ed016e 100644 --- a/backend/src/platform/window.zig +++ b/backend/src/platform/window.zig @@ -190,7 +190,7 @@ fn setCloaked(win: *dvui.Window, cloaked: bool) void { const win32 = @import("win32"); const value: u32 = @intFromBool(cloaked); // DWMWA_CLOAK. - _ = win32.graphics.dwm.DwmSetWindowAttribute(@ptrCast(hwnd), @as(win32.graphics.dwm.DWMWINDOWATTRIBUTE, @enumFromInt(13)), &value, @sizeOf(u32)); + _ = win32.graphics.dwm.DwmSetWindowAttribute(@ptrCast(hwnd), @as(win32.graphics.dwm.DWMWINDOWATTRIBUTE, @fromBackingInt(@intCast(13))), &value, @sizeOf(u32)); } pub fn isMaximized(win: *dvui.Window) bool { @@ -321,7 +321,12 @@ pub fn isFullscreenChromeHidden(win: *dvui.Window) bool { /// returns `WS_SYSMENU` and its caption buttons, and the backdrop's frame with it). Applying it all /// unconditionally every frame was a `SetWindowPos(SWP_FRAMECHANGED)` — a `WM_NCCALCSIZE` — a /// library load and a class-brush write each frame on Windows, and two style-mask writes on macOS. +/// Whether the app dresses its windows itself (`setStyle`, `setBackground`), as fizzy does: then the +/// backend leaves a plain dvui app's look (`nativeTitleBar`) off its main window. +pub var app_dresses_windows = false; + pub fn setStyle(win: *dvui.Window) void { + app_dresses_windows = true; if (builtin.target.os.tag == .macos) { const raw_ptr = c.SDL_GetPointerProperty( c.SDL_GetWindowProperties(win.backend.impl.window), @@ -344,7 +349,7 @@ pub fn setStyle(win: *dvui.Window) void { // borderless, it was never titled. if (comptime own_backend) for (&win.backend.impl.viewport_slots) |*slot| { const vp = if (slot.*) |*v| v else continue; - if (vp.passive or vp.menu or vp.app_frame) continue; + if (vp.passive or vp.menu or vp.app_frame or vp.native) continue; const ns = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(vp.window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse continue; styleTitled(ns); }; @@ -408,6 +413,38 @@ fn styleTitled(raw_ptr: *anyopaque) void { } } +extern fn fizzy_macos_window_has_plain_backdrop(nswindow: ?*anyopaque) c_int; +extern fn fizzy_macos_window_plain_backdrop(nswindow: ?*anyopaque, r: f64, g: f64, b: f64, radius: f64) void; +extern fn fizzy_macos_window_corner_radius() f64; + +/// The OS's own title bar on `window` — its title, its buttons — coloured as the window is +/// (`color`, the theme's window fill), the app's content below it: how a plain dvui app's windows +/// look on this backend (`SDLBackend.floatingWindowOs`, `SDLBackend.dressPlainWindows`). Fizzy's +/// own windows draw their title bar themselves (`setStyle`). Cheap to call again: each setter +/// is skipped when the window already has it. +/// - macOS: fizzy's own shape (`styleTitled`), its content under a transparent title bar, but its +/// title showing: the backend keeps that band clear and in the window's colour +/// (`platform.macos_monitor.titlebarStrip`), and the window goes into and out of full screen as fizzy's does. +/// - Windows: the caption in that colour (DWM, Windows 11), its text the theme's. +/// - Linux: the window manager's own, as it draws it. +pub fn nativeTitleBar(window: *c.SDL_Window, color: dvui.Color, text: dvui.Color, dark: bool) void { + switch (builtin.target.os.tag) { + .macos => { + const raw_ptr = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return; + styleTitled(raw_ptr); + const ns = objc.Object.fromId(raw_ptr); + if (ns.msgSend(c_long, "titleVisibility", .{}) != 0) ns.msgSend(void, "setTitleVisibility:", .{@as(c_long, 0)}); + skin(raw_ptr, color, dark); + // Clear, over a backdrop in the window's colour that follows its picture on its way + // into or out of full screen, as fizzy's glass does. + const f = color.toRGBA(); + fizzy_macos_window_plain_backdrop(raw_ptr, @as(f64, @floatFromInt(f[0])) / 255, @as(f64, @floatFromInt(f[1])) / 255, @as(f64, @floatFromInt(f[2])) / 255, fizzy_macos_window_corner_radius()); + }, + .windows => win32_titlebar.nativeCaption(window, color, text, dark), + else => {}, + } +} + /// A window of fizzy's skinned as the app is: its background — what AppKit draws its title bar /// with, the one a fullscreen window reveals at the top among them — `color`, and its appearance /// light or dark by the app's theme, not the system's, so its title bar, traffic lights and @@ -424,9 +461,17 @@ pub fn skin(raw_ptr: *anyopaque, color: dvui.Color, dark: bool) void { @as(f64, @floatFromInt(color.a)) / 255.0, }); // This sets both the titlebar and the window background color — clear on a window of Liquid - // Glass (`liquidGlass`), whose colour is under its glass. + // Glass (`liquidGlass`), whose colour is under its glass, and on one with a backdrop in its + // colour (`nativeTitleBar`), whose colour is the backdrop's: on its way into or out of full + // screen the window stands at the screen's size while the backdrop follows its picture, and a + // background in the window's colour filled all of the screen round the picture (the user). const glass = fizzy_macos_window_has_liquid_glass(raw_ptr) != 0; - window.msgSend(void, "setBackgroundColor:", .{if (glass) NSColor.msgSend(objc.Object, "clearColor", .{}).value else new_color.value}); + if (fizzy_macos_window_has_plain_backdrop(raw_ptr) != 0) { + const f = color.toRGBA(); + fizzy_macos_window_plain_backdrop(raw_ptr, @as(f64, @floatFromInt(f[0])) / 255, @as(f64, @floatFromInt(f[1])) / 255, @as(f64, @floatFromInt(f[2])) / 255, fizzy_macos_window_corner_radius()); + } else { + window.msgSend(void, "setBackgroundColor:", .{if (glass) NSColor.msgSend(objc.Object, "clearColor", .{}).value else new_color.value}); + } // Set window NSAppearance so the app (title bar, traffic lights, vibrancy) matches dvui theme. if (objc.getClass("NSAppearance")) |NSAppearance| { @@ -447,6 +492,7 @@ pub fn skin(raw_ptr: *anyopaque, color: dvui.Color, dark: bool) void { } pub fn setBackground(win: *dvui.Window, color: dvui.Color) void { + app_dresses_windows = true; if (builtin.target.os.tag == .macos) { const raw_ptr = c.SDL_GetPointerProperty( c.SDL_GetWindowProperties(win.backend.impl.window), diff --git a/backend/src/viewports.zig b/backend/src/viewports.zig index 63822e158..7bb23f0e4 100644 --- a/backend/src/viewports.zig +++ b/backend/src/viewports.zig @@ -43,6 +43,55 @@ pub fn openCarry(at: Rect) ?*Viewport { return dvui.currentWindow().backend.impl.viewportOpenCarry(at); } +/// Whether a floating window dragged past the app's windows can tear off into one of its own +/// (`os_windows`): macOS, where the window under the pointer can be asked, and only in an app that +/// leaves its floating windows to the backend (`SDLBackend.floating_os_windows`): fizzy carries +/// its own out (`Popout`). Windows and Linux need the same question of the window under the pointer +/// (`pointerOverNoWindow`). +pub fn tears() bool { + return builtin.target.os.tag == .macos and SDLBackend.floating_os_windows != .none; +} + +/// Whether `vp`'s window lies wholly inside the main window, on the Space being shown +/// (`os_windows`: a torn-off window let go there goes back in). +pub fn mainHolds(vp: *const Viewport) bool { + return dvui.currentWindow().backend.impl.viewportMainHolds(vp); +} + +/// Physical pixels from the frame's origin to the first viewport's band, and between bands. +pub const band_stride = @import("viewport_map.zig").band_stride; + +/// Where the pointer is on the desktop, in the main window's frame (physical): whichever window a +/// press began in (`SDLBackend.pointerInMain`). +pub fn pointerInMain() dvui.Point.Physical { + return dvui.currentWindow().backend.impl.pointerInMain(); +} + +/// The OS's title bar over a window of its own frame, physical (`SDLBackend.titleBand`). +pub fn titleBand() f32 { + return dvui.currentWindow().backend.impl.titleBand(); +} + +/// Whether the pointer is over no window of the app's (`SDLBackend.pointerOverNoWindow`). +pub fn pointerOverNoWindow() bool { + return dvui.currentWindow().backend.impl.pointerOverNoWindow(); +} + +/// The app's window the pointer is over (`SDLBackend.windowAtPointer`), or null. +pub fn windowAtPointer() ?*anyopaque { + return dvui.currentWindow().backend.impl.windowAtPointer(); +} + +/// `window` (`windowAtPointer`'s) in front of the app's other windows, not made key. +pub fn bringForward(window: *anyopaque) void { + dvui.currentWindow().backend.impl.bringForward(window); +} + +/// Whether a pointer button is held, wherever the pointer is. +pub fn pointerHeld() bool { + return dvui.currentWindow().backend.impl.pointerHeld(); +} + /// Whether one of the app's windows has the keyboard: the app is the active one. pub fn appActive() bool { return SDLBackend.appActive(); @@ -324,6 +373,15 @@ pub const Picture = struct { _ = dvui.renderTarget(self.rt); } + /// Subwindow `sw`'s drawing this frame out of the frame, drawn nowhere: a floating window shown + /// only as a picture taken of it before (`os_windows`' tear-off card). Its queues emptied as + /// `dvui.Window.renderSubwindow`'s `take` empties them. + pub fn drop(sw: *dvui.Subwindows.Subwindow) void { + sw.render_cmds = .empty; + sw.render_cmds_after = .empty; + sw.render_cmds_front = .{}; + } + /// Done: drawing goes to the frame's own target again. pub fn end(self: Picture) void { _ = dvui.renderTarget(self.prev); @@ -341,6 +399,44 @@ pub fn sizedTarget(slot: *?dvui.Texture.Target, w: u32, h: u32) ?dvui.Texture.Ta return slot.*.?; } +/// `sizedTarget` for `vp`'s picture: the same, but on its window's way into or out of full screen, +/// its picture a new size every frame, the one in `slot` kept while `w` by `h` fits in it — made +/// the display's size as the way begins, not anew each frame. The picture is drawn from its top +/// left; `present` copies what the window holds. +pub fn keptTarget(vp: *const Viewport, slot: *?dvui.Texture.Target, w: u32, h: u32) ?dvui.Texture.Target { + const moving = spaceFullness(vp) != null; + if (slot.*) |t| { + const fits = t.width >= w and t.height >= h; + const exact = t.width == w and t.height == h; + if (!fits or (!moving and !exact)) { + t.destroyLater(); + slot.* = null; + } + } + if (slot.* == null) { + var tw = w; + var th = h; + if (moving) { + const d = displayInMain(); + tw = @max(w, @as(u32, @intFromFloat(@max(0, @round(d.w))))); + th = @max(h, @as(u32, @intFromFloat(@max(0, @round(d.h))))); + } + slot.* = dvui.textureCreateTarget(.{ .width = tw, .height = th, .interpolation = .nearest }) catch return null; + } + return slot.*.?; +} + +/// dvui's screens besides the main window's (`dvui.screensSet`): each window's part of the frame +/// that shows a floating window, so what opens in it — a menu, a dialog — stays in that window. +pub fn publishScreens(extra: ?dvui.Rect.Natural) void { + dvui.currentWindow().backend.impl.viewportScreens(extra); +} + +/// The most the OS may resize `vp`'s window to, physical pixels: 0 each way for no most. +pub fn maxSize(vp: *Viewport, w: f32, h: f32) void { + dvui.currentWindow().backend.impl.viewportMaxSize(vp, w, h); +} + /// Where `vp`'s window is now, in the main window's part of the frame (physical pixels from /// its top left): where its float goes when it comes back. pub fn inMain(vp: *const Viewport) Rect { @@ -389,6 +485,11 @@ pub fn glass(vp: *Viewport, inset: f32, radius: f32, dark: bool) bool { return dvui.currentWindow().backend.impl.viewportGlass(vp, inset, radius, dark); } +/// Whether `vp`'s window has the keyboard: it is the OS's key window. +pub fn hasKeyboard(vp: *const Viewport) bool { + return SDLBackend.viewportHasKeyboard(vp); +} + /// The OS asked to close `vp`'s window. pub fn closeRequested(vp: *const Viewport) bool { return vp.close_requested; diff --git a/backend/src/viewports_none.zig b/backend/src/viewports_none.zig index 39fd24099..87a205714 100644 --- a/backend/src/viewports_none.zig +++ b/backend/src/viewports_none.zig @@ -60,6 +60,13 @@ pub const Picture = struct { pub fn sizedTarget(_: *?dvui.Texture.Target, _: u32, _: u32) ?dvui.Texture.Target { return null; } +pub fn keptTarget(_: *const Viewport, _: *?dvui.Texture.Target, _: u32, _: u32) ?dvui.Texture.Target { + return null; +} +/// No windows besides the main one: dvui's own screen only. +pub fn publishScreens(_: ?dvui.Rect.Natural) void { + dvui.screensSet(&.{}); +} pub fn inMain(_: *const Viewport) Rect { return .{}; } @@ -156,6 +163,7 @@ pub fn dragMove(_: *Viewport) bool { return false; } pub fn minSize(_: *Viewport, _: f32, _: f32) void {} +pub fn maxSize(_: *Viewport, _: f32, _: f32) void {} pub fn setTitle(_: *Viewport, _: []const u8) void {} pub fn glass(_: *Viewport, _: f32, _: f32, _: bool) bool { return false; diff --git a/build.zig b/build.zig index b83c77d78..e9666ca7d 100644 --- a/build.zig +++ b/build.zig @@ -12,12 +12,13 @@ pub fn build(b: *std.Build) !void { const windows_msvc_libc_opt = b.option([]const u8, "windows-msvc-libc", "zig libc manifest for *-windows-msvc when cross-compiling; forwarded by packageall for Windows children") orelse null; const fetch_msvc_opt = b.option(bool, "fetch-msvc", "If *-windows-msvc libc is missing under .velopack-msvc/, run msvcup-setup first (downloads MSVC+SDK; requires network). Defaults to true on Windows hosts targeting *-windows-msvc.") orelse null; - const macos_sign_app_identity = b.option([]const u8, "macos-sign-app", "macOS codesign identity for the app bundle (e.g. 'Developer ID Application: NAME (TEAMID)')") orelse - b.graph.environ_map.get("FIZZY_MACOS_SIGN_APP"); - const macos_sign_install_identity = b.option([]const u8, "macos-sign-installer", "macOS codesign identity for the installer pkg (e.g. 'Developer ID Installer: NAME (TEAMID)')") orelse - b.graph.environ_map.get("FIZZY_MACOS_SIGN_INSTALLER"); - const macos_notary_profile = b.option([]const u8, "macos-notary-profile", "notarytool keychain profile name (run `xcrun notarytool store-credentials ` first)") orelse - b.graph.environ_map.get("FIZZY_MACOS_NOTARY_PROFILE"); + // Options only, never the environment: Zig 0.17 caches the build's configuration without + // regard to it, so a variable read here would be seen once and then ignored. + // `scripts/release.sh` and `release.yml` turn the FIZZY_MACOS_* variables into these. + const macos_sign_app_identity = b.option([]const u8, "macos-sign-app", "macOS codesign identity for the app bundle (e.g. 'Developer ID Application: NAME (TEAMID)')"); + const macos_sign_install_identity = b.option([]const u8, "macos-sign-installer", "macOS codesign identity for the installer pkg (e.g. 'Developer ID Installer: NAME (TEAMID)')"); + const macos_notary_profile = b.option([]const u8, "macos-notary-profile", "notarytool keychain profile name (run `xcrun notarytool store-credentials ` first)"); + const macos_sdk = b.option([]const u8, "macos-sdk", "macOS SDK path for an explicit -Dtarget=*-macos (default: `xcrun --sdk macosx --show-sdk-path`)"); // Windows 10 1803 at least: AF_UNIX arrived there, and std offers it only to a target whose // minimum is that (`std.Io.net.has_unix_sockets`) — the control server's socket @@ -39,6 +40,7 @@ pub fn build(b: *std.Build) !void { .macos_sign_app_identity = macos_sign_app_identity, .macos_sign_install_identity = macos_sign_install_identity, .macos_notary_profile = macos_notary_profile, + .macos_sdk = macos_sdk, // What fizzy-the-app ships in its web build beyond the built-ins. The browser cannot // load a plugin at runtime, so this is the one place a third-party plugin is named // in this repo — as build data, the way an app built on fizzy lists its own. By @@ -200,8 +202,8 @@ pub fn addDvui(fizzy_dep: *std.Build.Dependency, root_module: *std.Build.Module, const b = fizzy_dep.builder; const exe = @import("build/exe.zig"); const target = root_module.resolved_target orelse @panic("fizzy.addDvui: give the root module its target first"); - const optimize = root_module.optimize orelse .Debug; - const macos_sdl_paths = try @import("build/common.zig").macosSdlPathsForExplicitTarget(b, target); + const optimize = root_module.optimize orelse .debug; + const macos_sdl_paths = try @import("build/common.zig").macosSdlPathsForExplicitTarget(b, target, null); const native = exe.nativeDvui(b, backend, target, optimize, .off, macos_sdl_paths); root_module.addImport("dvui", native.dvui); root_module.addImport("backend", native.backend); diff --git a/build.zig.zon b/build.zig.zon index 06b0c79a4..77e8a680f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -8,7 +8,6 @@ "build", "build.zig", "build.zig.zon", - "update.zig", "sdk", "backend", "assets", @@ -18,7 +17,7 @@ .fingerprint = 0x3311781b569a8e73, .name = .fizzy, .version = "0.2.0", - .minimum_zig_version = "0.16.0", + .minimum_zig_version = "0.17.0", .dependencies = .{ .icons = .{ .url = "https://github.com/fizzyedit/icons/archive/41e7d2162b121e3798438a7da25ff4648b204dc7.tar.gz", @@ -34,31 +33,31 @@ .path = "backend", }, .assetpack = .{ - .url = "https://github.com/foxnne/assetpack/archive/ac7592f3f5988857840d0df4610e1e1fad690e2e.tar.gz", - .hash = "assetpack-0.2.0-5DA2d1ZkAADJanNVdWrUBOGMhOzUENhrUiqXcHADxY2x", + .url = "https://github.com/foxnne/assetpack/archive/91eb632487d38e488a342e5755f55a9955c57807.tar.gz", + .hash = "assetpack-0.2.0-5DA2d2NkAADexrzqfFqkTks3i1fR9GKSfd5X5hI7Z9vn", }, // App-only. Plugins depend on `sdk/` (not this root package) so they never see // Velopack — see AGENTS.md. .velopack_zig = .{ - .url = "https://github.com/graphl-tech/velopack-zig/archive/0c2f20635a97fde38cc8000e9fb1a75f891cf37d.tar.gz", - .hash = "velopack_zig-0.0.1-LzeGcengAACU1f33uDAuLKlWXtek0KCC6i_b3XWeJMjd", + .url = "https://github.com/graphl-tech/velopack-zig/archive/3d553b7efb3b9443ac4ad2bffdf85d5ae328177e.tar.gz", + .hash = "velopack_zig-0.1.0-bbBwERkwAQBxx5LimM-B5UQrwoqltUUvrthOlyuDbscm", .lazy = true, }, .dvui_singleton_app = .{ .path = "libs/dvui-singleton-app", }, .md4zig = .{ - .url = "git+https://github.com/fizzyedit/md4zig#2d0a687d28be9849b9abe3453bfb7c9f22ffcda1", - .hash = "md4zig-0.1.0-iiatj7oOBgCcrw8ee3UISfYe2LsKVEf7O3osC85HthPX", + .url = "git+https://github.com/fizzyedit/md4zig#eb5241c9e46cd0a507c0c7a85b01afe2b51e58a8", + .hash = "md4zig-0.1.0-iiatj6AQBgBP6PiK2c4MRhjNK2ZXjVg94GvbIEv5WEl7", }, .nightwatch = .{ - .url = "git+https://github.com/neurocyte/nightwatch#cac3b9edb3a59f5b3a290cf3b76044ad9b09ef28", - .hash = "nightwatch-1.0.0-uXzeHyOgAgCvaBhschfx3LA48n01bLYOVBQUXYjvrREM", + .url = "git+https://github.com/neurocyte/nightwatch#023e79f52bde36ac8fa4c967bd8333b14efb93cc", + .hash = "nightwatch-1.0.0-uXzeH1ALAwAr6LLM0H1Z0aBO_a5gpxQ1YsXpdlh3Xv6U", .lazy = true, }, .zf = .{ - .url = "git+https://github.com/natecraddock/zf#c35c421f84895193246db06c40683c1a30e616ef", - .hash = "zf-0.11.0-OIRy8X-RAAAwaRXHMYpj2uvBnuGTZWEE_3V7acqHQNtW", + .url = "git+https://github.com/natecraddock/zf#e00f9e978b6a7126d1b35fa8ffd49b62042ad6f3", + .hash = "zf-0.11.0-OIRy8XCRAAB6FiMwIrtD6FqlagnFwpI6YyOu7bXeYH5J", }, }, } diff --git a/build/app.zig b/build/app.zig index 4698b9b79..10429f692 100644 --- a/build/app.zig +++ b/build/app.zig @@ -48,6 +48,8 @@ pub const Options = struct { macos_sign_app_identity: ?[]const u8 = null, macos_sign_install_identity: ?[]const u8 = null, macos_notary_profile: ?[]const u8 = null, + /// `-Dmacos-sdk`: the macOS SDK for an explicit `-Dtarget=*-macos`; null asks `xcrun`. + macos_sdk: ?[]const u8 = null, }; pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, opts: Options) !void { @@ -264,7 +266,7 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options // package dependency to resolve here. Their canonical `build.zig` is only for the // standalone (`cd plugins/ && zig build`) third-party-shape build. - const macos_sdl_paths = try common.macosSdlPathsForExplicitTarget(b, target); + const macos_sdl_paths = try common.macosSdlPathsForExplicitTarget(b, target, opts.macos_sdk); const zig_out_subdir = common.zigOutSubdirForTarget(b, target); const zig_out_install_dir: std.Build.InstallDir = .{ .custom = zig_out_subdir }; @@ -286,10 +288,8 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options .fetch_if_missing = fetch_msvc, }); - var effective_win_libc: ?[]const u8 = win_libc.libc_path; - if (effective_win_libc == null) { - if (cross_win_msvc) effective_win_libc = b.libc_file; - } + // Only `-Dwindows-msvc-libc` or the managed `.velopack-msvc/` tree: Zig 0.17 has no global `--libc`. + const effective_win_libc: ?[]const u8 = win_libc.libc_path; // Velopack in the dev/install exe is opt-in (`-Dvelopack=true`). Release // packaging (`zig build package`) still links Velopack when the ABI supports @@ -352,7 +352,8 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options // An app's version is its own: it passes `.app_version` (its build root is not this // package's, so its VERSION file is out of reach here), else 0.0.0. const app_version: []const u8 = if (app_version_opt) |v| v else if (opts.own) |own| blk: { - const raw = b.build_root.handle.readFileAlloc(b.graph.io, own.version_file, b.allocator, std.Io.Limit.limited(256)) catch |e| std.debug.panic("read VERSION: {}", .{e}); + b.dependOnFileContents(b.path(own.version_file)); + const raw = std.Io.Dir.cwd().readFileAlloc(b.graph.io, b.root.joinString(b.allocator, own.version_file) catch @panic("OOM"), b.allocator, std.Io.Limit.limited(256)) catch |e| std.debug.panic("read VERSION: {}", .{e}); version_owned = raw; break :blk std.mem.trimEnd(u8, raw, "\r\n"); } else "0.0.0"; @@ -429,8 +430,6 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options const workbench_opts = b.addOptions(); workbench_opts.addOption(bool, "file_tree", workbench_file_tree); - common.addUpdateStep(b); - const msvcup_before_compile: ?*std.Build.Step.Run = if (vz) |v| velopack.addMsvcupSetupStep(b, v, ".velopack-msvc") else null; const msvcup_setup_step = b.step("msvcup-setup", "Download MSVC SDK into .velopack-msvc/ via velopack-zig (writes zig-libc-*.ini)"); if (msvcup_before_compile) |m| msvcup_setup_step.dependOn(&m.step); @@ -508,7 +507,6 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. const zig_out_subdir = cfg.zig_out_subdir; const zig_out_install_dir = cfg.zig_out_install_dir; const target_is_windows_msvc = cfg.target_is_windows_msvc; - const cross_win_msvc = cfg.cross_win_msvc; const effective_win_libc = cfg.effective_win_libc; const velopack_supported_for_target = cfg.velopack_supported_for_target; const velopack_enabled = cfg.velopack_enabled; @@ -615,7 +613,19 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. }); // A running fizzy holds its executable on Windows: set it aside first, or the install fails. if (b.graph.host.result.os.tag == .windows and target.result.os.tag == .windows) { - const aside = SetAsideRunningExe.create(b, b.getInstallPath(zig_out_install_dir, exe.out_filename)); + const aside_exe = b.addExecutable(.{ + .name = "set-aside", + .root_module = b.createModule(.{ + .root_source_file = b.path("build/set_aside.zig"), + .target = b.graph.host, + .optimize = .fast, + }), + }); + const aside = b.addRunArtifact(aside_exe); + aside.setName("set aside a running executable"); + aside.addDirectoryArg(common.installDirPath(zig_out_install_dir)); + aside.addArg(exe.out_filename); + aside.has_side_effects = true; aside.step.dependOn(&exe.step); install_artifact.step.dependOn(&aside.step); } @@ -840,6 +850,9 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // the OS's form and the app's. std-only (see glass_look.zig); `LiquidField` and `Popout` // apply it. .{ "fizzy-glass-look-tests", "core/gfx/glass_look.zig" }, + // What the app's windows are made of, and the fallback to what the backend can show. + // std-only but for `publish`/`get`, which reach dvui and are not under test here. + .{ "fizzy-material-tests", "core/material.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" }, @@ -1242,7 +1255,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // off for the msvc ABI regardless of host (cross or native Windows). integration_tests.root_module.link_libcpp = !target_is_windows_msvc; if (velopack_enabled) if (vz) |v| { - try velopack.linkVelopack(b, v, integration_tests, .{ .target = target, .optimize = optimize }); + try velopack.linkVelopack(b, v, integration_tests, .{ .target = target, .optimize = optimize, .c_modules = &.{app_module_test} }); }; test_integration_step.dependOn(&b.addRunArtifact(integration_tests).step); @@ -1270,7 +1283,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. if (target.result.os.tag == .windows) demo_tests.root_module.linkSystemLibrary("comctl32", .{}); demo_tests.root_module.link_libcpp = !target_is_windows_msvc; if (velopack_enabled) if (vz) |v| { - try velopack.linkVelopack(b, v, demo_tests, .{ .target = target, .optimize = optimize }); + try velopack.linkVelopack(b, v, demo_tests, .{ .target = target, .optimize = optimize, .c_modules = &.{app_module_test} }); }; test_integration_step.dependOn(&b.addRunArtifact(demo_tests).step); check_integration_step.dependOn(&demo_tests.step); @@ -1422,7 +1435,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. bench_replay.root_module.linkSystemLibrary("comctl32", .{}); } if (velopack_enabled) if (vz) |v| { - try velopack.linkVelopack(b, v, bench_replay, .{ .target = target, .optimize = optimize }); + try velopack.linkVelopack(b, v, bench_replay, .{ .target = target, .optimize = optimize, .c_modules = &.{app_module_test} }); }; const check_bench_replay_step = b.step("check-bench-replay", "Compile fizzy bench-replay without running it"); @@ -1628,12 +1641,13 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. }; if (effective_win_libc) |ini| { - if (cross_win_msvc) b.libc_file = null; const libc_lp: std.Build.LazyPath = .{ .cwd_relative = ini }; velopack.applyWindowsMsvcLibcRecursive(b, roots[0..n], libc_lp); + // Absolute, and already declared to the configure cache where it was resolved + // (`velopack.resolveWindowsMsvcLibc`). const ini_exists = blk: { - b.build_root.handle.access(b.graph.io, ini, .{}) catch break :blk false; + std.Io.Dir.cwd().access(b.graph.io, ini, .{}) catch break :blk false; break :blk true; }; if (ini_exists) { @@ -1681,45 +1695,3 @@ fn addTestBinaryArgs( } else try addTestBinaryArgs(b, run, seen, dep); } } - -/// Windows will not let a file a running process was started from be written or deleted, but it -/// will let it be renamed. So before the install, an installed executable that is running (it -/// cannot be opened for writing) is renamed aside, to `.