-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.zig
More file actions
1095 lines (1008 loc) · 51.2 KB
/
Copy pathapp.zig
File metadata and controls
1095 lines (1008 loc) · 51.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! The zigui runtime: opens an OS window via SDL3, drives an event loop, and
//! presents frames. Rendering prefers the SDL_GPU backend (`src/gpu/gpu.zig`:
//! Metal on macOS, Vulkan on Linux/Windows) and falls back to the pure-Zig
//! software rasterizer (the `Canvas` command list rasterized on the CPU and
//! uploaded to an SDL streaming texture) when no GPU is available — same
//! command list, same output, so apps never notice which backend ran.
//!
//! This file and `gpu/gpu.zig` are the only parts of zigui that link SDL/C.
//! They are compiled into application executables, never into the core library
//! or its test suite — so `zig build test` stays headless and works in Docker.
const std = @import("std");
const zigui = @import("zigui");
pub const c = @cImport({
@cInclude("SDL3/SDL.h");
});
pub const gpu = @import("gpu/gpu.zig");
/// Row-major RGBA8 window icon (`rgba` is `w*h*4` bytes). See `Config.icon`.
pub const WindowIcon = struct { rgba: []const u8, w: c_int, h: c_int };
pub const Config = struct {
title: [:0]const u8 = "zigui",
width: u32 = 900,
height: u32 = 600,
/// Minimum window size the user can resize to (0 = no limit). Applied via
/// SDL_SetWindowMinimumSize after the window is created.
min_width: u32 = 0,
min_height: u32 = 0,
theme: zigui.Theme = zigui.default_theme,
/// When true, the window's close button hides the window (keeping the app
/// alive in the tray) instead of quitting. Use with a `Tray` that offers a
/// way back (e.g. an "Open" entry) and a "Quit". ⌘Q still quits.
hide_on_close: bool = false,
/// Render via the SDL_GPU backend (Metal/Vulkan) when available, falling
/// back to the software rasterizer when it isn't. Set false to force the
/// software path; the `ZIGUI_SOFTWARE` environment variable does the same
/// at runtime.
gpu: bool = true,
/// Optional window/taskbar icon, set via `SDL_SetWindowIcon` after the window
/// is created. `rgba` is row-major RGBA8 (`w*h*4` bytes); the surface is
/// copied by SDL, so the caller may free `rgba` after `run` sets it.
icon: ?WindowIcon = null,
/// Supersampling factor: the UI is rendered into a framebuffer this many
/// times larger per axis, then downscaled on present (SSAA). On a low-DPI
/// (100%) display this is what makes text crisp — gamma adds weight, but
/// only extra device pixels add edge resolution. 1 = off (default). 2 is a
/// good value for 96-dpi monitors; it costs ~4× the fill (negligible on the
/// GPU, heavier on the software fallback). Capped internally so an already
/// HiDPI display isn't oversampled past ~2× effective device pixels.
supersample: f32 = 1,
};
pub const Error = error{ SdlInit, TrayInit };
// The running window + loop flag, exposed so tray callbacks (and the window
// close handler) can show/hide the window or quit. Set for the duration of
// `run`; mirrors the `g_animator` thread-local pattern.
var g_window: ?*c.SDL_Window = null;
var g_running: ?*bool = null;
var g_hide_on_close: bool = false;
/// Show and raise the window (e.g. from a tray "Open" entry).
pub fn showWindow() void {
if (g_window) |w| {
_ = c.SDL_ShowWindow(w);
_ = c.SDL_RaiseWindow(w);
}
}
/// Hide the window without quitting (the app keeps running in the tray).
pub fn hideWindow() void {
if (g_window) |w| _ = c.SDL_HideWindow(w);
}
/// Ask the run loop to exit (e.g. from a tray "Quit" entry). Only flips the
/// flag: a tray/menu callback fires inside macOS's Cocoa menu-tracking loop,
/// which swallows any SDL wakeup event we'd push here, so the loop can't be
/// woken from this context. Instead the loop caps its idle wait (`idle_wake_ms`)
/// and re-checks `running` on the timeout — see the wait sites in `run`.
pub fn quit() void {
if (g_running) |r| r.* = false;
}
/// Whether a window-close request hides to the tray (true) or quits (false).
/// Mirrors `Config.hide_on_close`, but can be toggled at runtime from a tray
/// checkbox.
pub fn hideOnClose() bool {
return g_hide_on_close;
}
pub fn setHideOnClose(v: bool) void {
g_hide_on_close = v;
}
/// The running app's animator, exposed so view callbacks can start animations
/// (e.g. `app.animator().?.animateTo(&state, 1, 0.3, .ease_in_out)`). Set for the
/// duration of `run`. Mirrors the focused-field thread-local pattern in `view`.
var g_animator: ?*zigui.Animator = null;
pub fn animator() ?*zigui.Animator {
return g_animator;
}
/// An optional "busy" predicate: while it returns true the loop wakes on a
/// ~60fps timeout (instead of blocking on input) and rebuilds each frame, so the
/// app's `body` can poll in-flight work (e.g. a streaming socket). Mirrors the
/// animator-active branch. Set by examples via `setBusyCheck`.
var g_busy_fn: ?*const fn () bool = null;
pub fn setBusyCheck(f: ?*const fn () bool) void {
g_busy_fn = f;
}
/// A cadence-returning generalization of `setBusyCheck`: while the provider
/// returns a non-zero interval (ms), the loop wakes on that timeout instead of
/// blocking on input and rebuilds each frame — so a heavy GPU job can repaint a
/// spinner at 10 fps (not fighting the worker for the GPU at 60) while video
/// playback keeps its own smooth cadence. 0 means fully idle (block on input).
/// Takes precedence over the boolean predicate when both are set.
var g_busy_interval_fn: ?*const fn () u32 = null;
pub fn setBusyInterval(f: ?*const fn () u32) void {
g_busy_interval_fn = f;
}
/// Ceiling on the idle wait (ms) when the app has no busy work. A fully
/// blocking `SDL_WaitEvent` only returns on an SDL event, so state changed by a
/// context that posts none — most importantly a tray/menu callback — isn't
/// observed until the next incidental event. When the window is visible that's
/// the user's next mouse-over; when it's hidden-to-tray there's no window to
/// receive one, so `app.quit()` from the tray would hang the process forever.
/// Capping the wait lets the loop re-check `running` (and re-run the frame hook,
/// e.g. tray status) ~every 250 ms while idle. Both idle wait sites skip the
/// redraw on a timeout (nothing changed), so this stays ~free — measured <0.5%
/// CPU idle — while bounding worst-case tray-action latency to this interval
/// instead of "until the next incidental event".
const idle_wake_ms: c_int = 250;
/// The app-requested wake cadence in ms (0 = idle): the interval provider when
/// set, else 16 ms while the boolean busy predicate holds.
fn busyIntervalMs() u32 {
if (g_busy_interval_fn) |f| return f();
if (g_busy_fn) |f| {
if (f()) return 16;
}
return 0;
}
/// An optional theme provider, queried once per frame so the app can switch
/// light/dark (or any theme) live without restarting. When null the loop uses
/// the static `Config.theme`. Set by examples via `setThemeProvider`.
var g_theme_fn: ?*const fn () zigui.Theme = null;
pub fn setThemeProvider(f: ?*const fn () zigui.Theme) void {
g_theme_fn = f;
}
/// An optional per-frame hook, invoked once each main-loop iteration (before the
/// frame is drawn). Use it to push app state into retained OS surfaces that live
/// outside the view tree — e.g. refreshing a system-tray menu's labels/icon.
/// Runs on the main thread, so it is safe to call SDL tray setters from here.
var g_frame_fn: ?*const fn () void = null;
pub fn setFrameHook(f: ?*const fn () void) void {
g_frame_fn = f;
}
/// The OS-level light/dark preference, reported by SDL. Cross-platform: on
/// macOS it reads `AppleInterfaceStyle`, on Windows the `AppsUseLightTheme`
/// registry value, and on Linux the XDG `org.freedesktop.appearance` portal.
/// `.unknown` when the platform can't report one — callers should fall back.
///
/// An OS theme change emits `SDL_EVENT_SYSTEM_THEME_CHANGED`, which wakes the
/// run loop (every event triggers a rebuild on the next iteration), so an app
/// that re-queries this each frame follows the OS live without extra wiring.
pub const SystemTheme = enum { unknown, light, dark };
pub fn systemTheme() SystemTheme {
return switch (c.SDL_GetSystemTheme()) {
c.SDL_SYSTEM_THEME_LIGHT => .light,
c.SDL_SYSTEM_THEME_DARK => .dark,
else => .unknown,
};
}
/// The OS color scheme as a `zigui.ColorScheme`, ready to hand to
/// `zigui.themeForScheme(family, app.colorScheme())`. Falls back to `.light`
/// when the platform can't report a preference. Re-query it each frame (or in a
/// theme provider) to follow the OS live.
pub fn colorScheme() zigui.ColorScheme {
return if (systemTheme() == .dark) .dark else .light;
}
/// An optional application key handler, called first on every key-down with the
/// SDL keycode (`SDLK_*`) and modifier mask (`SDL_KMOD_*`, both reachable via
/// `app.c`). Return true to mark the key consumed — the loop then skips its
/// default text-field editing for that event. Use it for app shortcuts and
/// clipboard (e.g. ⌘S / ⌘O / ⌘C / ⌘V). Mirrors `setBusyCheck`.
var g_key_fn: ?*const fn (key: u32, mods: u16) bool = null;
pub fn setKeyHandler(f: ?*const fn (key: u32, mods: u16) bool) void {
g_key_fn = f;
}
/// When true, the loop prints rolling frame-time stats (last/avg/max ms + the
/// equivalent fps) to stderr ~once per second. Off by default; also enabled at
/// startup if the `ZIGUI_FRAME_LOG` environment variable is set. Useful for
/// profiling the software rasterizer without a GPU profiler.
/// The cursor's last known position in logical points, or null when it has left
/// the window. Fed into `Context.hover_point` each frame for hover highlights.
var g_hover_point: ?zigui.geometry.Point = null;
/// Scale converting SDL mouse coordinates (logical window points) into the
/// design-point space that hit/scroll regions live in. `drawFrame` updates it
/// each frame to `pixel_density / display_scale`: 1.0 on macOS (where the OS UI
/// scale rides on the backing scale), but <1 on Windows/Linux at a non-100% OS
/// display-scale setting, where the two diverge. See `drawFrame`.
var g_event_scale: f32 = 1;
var g_frame_log: bool = false;
pub fn setFrameLog(on: bool) void {
g_frame_log = on;
}
/// Rolling per-second render-time accumulator (see `g_frame_log`).
const FrameStats = struct {
count: u32 = 0,
sum_ns: u64 = 0,
max_ns: u64 = 0,
last_report_ms: u64 = 0,
fn record(self: *FrameStats, ns: u64, now_ms: u64) void {
self.count += 1;
self.sum_ns += ns;
if (ns > self.max_ns) self.max_ns = ns;
if (self.last_report_ms == 0) self.last_report_ms = now_ms;
if (now_ms - self.last_report_ms >= 1000 and self.count > 0) {
const last_ms = @as(f64, @floatFromInt(ns)) / 1.0e6;
const avg_ms = @as(f64, @floatFromInt(self.sum_ns)) / @as(f64, @floatFromInt(self.count)) / 1.0e6;
const max_ms = @as(f64, @floatFromInt(self.max_ns)) / 1.0e6;
std.debug.print("[zigui] render: last {d:.2}ms avg {d:.2}ms max {d:.2}ms ({d} frames, {d:.0} fps-equiv)\n", .{
last_ms, avg_ms, max_ms, self.count, if (avg_ms > 0) 1000.0 / avg_ms else 0,
});
self.count = 0;
self.sum_ns = 0;
self.max_ns = 0;
self.last_report_ms = now_ms;
}
}
};
// ---------------------------------------------------------------------------
// Clipboard (SDL3's cross-platform clipboard: NSPasteboard / Win32 / X11/Wayland).
// ---------------------------------------------------------------------------
/// Put UTF-8 `text` on the system clipboard. `text` need not be NUL-terminated.
pub fn setClipboardText(allocator: std.mem.Allocator, text: []const u8) void {
const z = allocator.dupeZ(u8, text) catch return;
defer allocator.free(z);
_ = c.SDL_SetClipboardText(z.ptr);
}
/// Open a URL (or `file://…` path) in the OS default handler — opens a folder in
/// Finder/Explorer/the file manager, or a web URL in the browser. Cross-platform
/// via SDL3 (`NSWorkspace` / `ShellExecute` / `xdg-open`). Returns false on failure.
pub fn openUrl(url: [*:0]const u8) bool {
return c.SDL_OpenURL(url);
}
/// Read UTF-8 text from the system clipboard. Returns an allocator-owned copy the
/// caller must free, or null when the clipboard is empty. (SDL returns an empty
/// string rather than null on failure; we normalize that to null.)
pub fn getClipboardText(allocator: std.mem.Allocator) ?[]u8 {
const p = c.SDL_GetClipboardText();
defer c.SDL_free(p);
if (p == null) return null;
const s = std.mem.span(p);
if (s.len == 0) return null;
return allocator.dupe(u8, s) catch null;
}
/// Copy the focused field's current selection to the clipboard (no-op if empty).
fn clipboardCopy(f: *zigui.TextFieldState) void {
const sel = f.selectedText();
if (sel.len == 0) return;
setClipboardText(f.allocator, sel);
}
/// Insert clipboard text at the focused field's caret, replacing any selection.
fn clipboardPaste(f: *zigui.TextFieldState) void {
const s = getClipboardText(f.allocator) orelse return;
defer f.allocator.free(s);
f.insert(s) catch {};
}
// ---------------------------------------------------------------------------
// File dialog (SDL3's native open-file dialog: NSOpenPanel / IFileDialog /
// XDG portal). Asynchronous: the OS panel runs alongside our loop and SDL
// invokes the callback later — possibly on another thread — so the callback
// only copies the path into a static buffer and pushes a user event to wake
// `SDL_WaitEvent`; the app polls `takeFileDialogResult` from its per-frame
// hook/body. One dialog at a time.
// ---------------------------------------------------------------------------
/// A dialog file filter, e.g. `.{ .name = "WAV audio", .pattern = "wav" }`.
/// `pattern` is a semicolon-separated extension list without dots ("wav;mp3").
pub const FileFilter = c.SDL_DialogFileFilter;
pub const FileDialogResult = union(enum) {
/// No dialog finished since the last take (still open, or none shown).
none,
/// The user canceled (or the dialog failed).
canceled,
/// The user picked a file; caller owns the path.
picked: []u8,
};
var g_dialog_open: bool = false; // main thread only: a dialog is showing
var g_dialog_done: std.atomic.Value(bool) = .init(false);
var g_dialog_path_len: usize = 0; // 0 = canceled; guarded by g_dialog_done
var g_dialog_path_buf: [4096]u8 = undefined;
fn fileDialogThunk(userdata: ?*anyopaque, filelist: [*c]const [*c]const u8, filter: c_int) callconv(.c) void {
_ = userdata;
_ = filter;
g_dialog_path_len = 0;
if (filelist != null and filelist[0] != null) {
const path = std.mem.span(filelist[0]);
const n = @min(path.len, g_dialog_path_buf.len);
@memcpy(g_dialog_path_buf[0..n], path[0..n]);
g_dialog_path_len = n;
}
g_dialog_done.store(true, .release);
// Wake the (possibly blocked) event loop so the app polls the result.
var ev = std.mem.zeroes(c.SDL_Event);
ev.type = c.SDL_EVENT_USER;
_ = c.SDL_PushEvent(&ev);
}
/// Show the native "Open File" dialog (single file). Returns false if one is
/// already open. `filters` must point at memory that outlives the dialog (a
/// global/comptime slice is the easy way) — SDL holds the pointer until the
/// user dismisses the panel. Poll `takeFileDialogResult` each frame for the
/// outcome. Call from the main thread.
pub fn openFileDialog(filters: []const FileFilter, default_location: ?[*:0]const u8) bool {
if (g_dialog_open) return false;
g_dialog_open = true;
g_dialog_done.store(false, .release);
c.SDL_ShowOpenFileDialog(
fileDialogThunk,
null,
g_window,
if (filters.len > 0) filters.ptr else null,
@intCast(filters.len),
default_location orelse null,
false, // allow_many
);
return true;
}
/// Show the native "Open Folder" dialog (single folder). Returns false if a
/// dialog is already open. Shares the same result plumbing as `openFileDialog`
/// — poll `takeFileDialogResult` each frame for the chosen directory path.
/// Call from the main thread.
pub fn openFolderDialog(default_location: ?[*:0]const u8) bool {
if (g_dialog_open) return false;
g_dialog_open = true;
g_dialog_done.store(false, .release);
c.SDL_ShowOpenFolderDialog(
fileDialogThunk,
null,
g_window,
default_location orelse null,
false, // allow_many
);
return true;
}
/// Whether an open-file dialog is currently showing (result not yet taken).
pub fn fileDialogOpen() bool {
return g_dialog_open;
}
/// Collect the finished dialog's outcome, once. Returns `.none` while the
/// dialog is still up (or when none was shown); `.picked` hands the caller an
/// allocator-owned copy of the chosen path. Call from the main thread.
pub fn takeFileDialogResult(allocator: std.mem.Allocator) FileDialogResult {
if (!g_dialog_open) return .none;
if (!g_dialog_done.load(.acquire)) return .none;
g_dialog_open = false;
if (g_dialog_path_len == 0) return .canceled;
const copy = allocator.dupe(u8, g_dialog_path_buf[0..g_dialog_path_len]) catch return .canceled;
return .{ .picked = copy };
}
// ---------------------------------------------------------------------------
// System tray / menu bar (cross-platform via SDL3's native tray API:
// NSStatusItem on macOS, Shell_NotifyIcon on Windows, StatusNotifierItem on
// Linux). The icon is an `SDL_Surface` — so it can be drawn with zigui's own
// rasterizer and handed over as RGBA pixels. The menu, however, is OS-drawn
// (labels/checkboxes/submenus/separators only) and *retained*: build it once,
// then mutate it imperatively — it is not part of the per-frame view tree.
//
// Note: SDL may invoke entry callbacks from a non-main thread on some
// platforms; on macOS they run on the main thread. Keep callbacks simple
// (flip app-owned state, call show/hide/quit).
// ---------------------------------------------------------------------------
fn trayThunk(userdata: ?*anyopaque, entry: ?*c.SDL_TrayEntry) callconv(.c) void {
_ = entry;
const cb: *zigui.Callback = @ptrCast(@alignCast(userdata orelse return));
cb.call();
}
/// Build an `SDL_Surface` from row-major RGBA8 pixels (the format zigui's
/// `Framebuffer.toRgba8Alloc` produces). Returns null on failure.
fn makeSurface(rgba: []const u8, w: c_int, h: c_int) ?*c.SDL_Surface {
const surf = c.SDL_CreateSurface(w, h, c.SDL_PIXELFORMAT_ABGR8888) orelse return null;
const uw: usize = @intCast(w);
const uh: usize = @intCast(h);
const pitch: usize = @intCast(surf.*.pitch);
const dst: [*]u8 = @ptrCast(surf.*.pixels orelse return surf);
var y: usize = 0;
while (y < uh) : (y += 1) {
@memcpy(dst[y * pitch ..][0 .. uw * 4], rgba[y * uw * 4 ..][0 .. uw * 4]);
}
return surf;
}
/// A handle to a single tray menu entry, returned by the `add*` builders so its
/// label / enabled / checked state can be mutated later (the menu is retained
/// and OS-drawn, not rebuilt per frame). SDL copies the label string, so a
/// transient buffer is fine. Call these on the main thread (e.g. a frame hook).
pub const TrayEntry = struct {
entry: *c.SDL_TrayEntry,
pub fn setLabel(self: TrayEntry, label: [:0]const u8) void {
c.SDL_SetTrayEntryLabel(self.entry, label.ptr);
}
pub fn setEnabled(self: TrayEntry, enabled: bool) void {
c.SDL_SetTrayEntryEnabled(self.entry, enabled);
}
pub fn setChecked(self: TrayEntry, checked: bool) void {
c.SDL_SetTrayEntryChecked(self.entry, checked);
}
};
/// A submenu / menu handle. Entries are appended in order; a button entry fires
/// its `zigui.Callback` when clicked.
pub const TrayMenu = struct {
owner: *Tray,
menu: *c.SDL_TrayMenu,
pub fn addItem(self: TrayMenu, label: [:0]const u8, cb: zigui.Callback) ?TrayEntry {
const e = c.SDL_InsertTrayEntryAt(self.menu, -1, label.ptr, c.SDL_TRAYENTRY_BUTTON) orelse return null;
self.owner.bind(e, cb);
return .{ .entry = e };
}
/// A non-interactive label row (a disabled button), useful as a status line.
pub fn addLabel(self: TrayMenu, label: [:0]const u8) ?TrayEntry {
const e = c.SDL_InsertTrayEntryAt(self.menu, -1, label.ptr, c.SDL_TRAYENTRY_BUTTON) orelse return null;
c.SDL_SetTrayEntryEnabled(e, false);
return .{ .entry = e };
}
/// A checkbox entry; `cb` fires on toggle (read the new state from the entry
/// via your own model, or keep it in sync with `TrayEntry.setChecked`).
pub fn addCheckItem(self: TrayMenu, label: [:0]const u8, checked: bool, cb: zigui.Callback) ?TrayEntry {
var flags: u32 = c.SDL_TRAYENTRY_CHECKBOX;
if (checked) flags |= c.SDL_TRAYENTRY_CHECKED;
const e = c.SDL_InsertTrayEntryAt(self.menu, -1, label.ptr, flags) orelse return null;
self.owner.bind(e, cb);
return .{ .entry = e };
}
pub fn addSeparator(self: TrayMenu) void {
_ = c.SDL_InsertTrayEntryAt(self.menu, -1, null, 0);
}
/// Append a submenu entry and return its menu for nesting.
pub fn addSubmenu(self: TrayMenu, label: [:0]const u8) ?TrayMenu {
const e = c.SDL_InsertTrayEntryAt(self.menu, -1, label.ptr, c.SDL_TRAYENTRY_SUBMENU) orelse return null;
const sub = c.SDL_CreateTraySubmenu(e) orelse return null;
return .{ .owner = self.owner, .menu = sub };
}
};
/// A system-tray icon with a root menu. Create once (after/around `run`), keep
/// it alive for the app's lifetime, and `deinit` on exit.
pub const Tray = struct {
handle: *c.SDL_Tray,
root: *c.SDL_TrayMenu,
icon: ?*c.SDL_Surface,
gpa: std.mem.Allocator,
callbacks: std.ArrayList(*zigui.Callback) = .empty,
/// Create the tray with an RGBA8 icon (`w`×`h`) and a tooltip. SDL is
/// initialized if it isn't already (idempotent), so this may be called
/// before `run`.
pub fn create(gpa: std.mem.Allocator, rgba: []const u8, w: c_int, h: c_int, tooltip: [:0]const u8) !Tray {
_ = c.SDL_Init(c.SDL_INIT_VIDEO);
const surf = makeSurface(rgba, w, h) orelse return Error.TrayInit;
const handle = c.SDL_CreateTray(surf, tooltip.ptr) orelse {
c.SDL_DestroySurface(surf);
return Error.TrayInit;
};
const root = c.SDL_CreateTrayMenu(handle) orelse {
c.SDL_DestroyTray(handle);
c.SDL_DestroySurface(surf);
return Error.TrayInit;
};
return .{ .handle = handle, .root = root, .icon = surf, .gpa = gpa };
}
pub fn deinit(self: *Tray) void {
c.SDL_DestroyTray(self.handle);
if (self.icon) |s| c.SDL_DestroySurface(s);
for (self.callbacks.items) |cb| self.gpa.destroy(cb);
self.callbacks.deinit(self.gpa);
}
/// The root menu, to which entries are added.
pub fn menu(self: *Tray) TrayMenu {
return .{ .owner = self, .menu = self.root };
}
/// Replace the icon (e.g. to reflect a status change). Copies `rgba`, so the
/// caller may free it afterwards.
pub fn setIcon(self: *Tray, rgba: []const u8, w: c_int, h: c_int) void {
const surf = makeSurface(rgba, w, h) orelse return;
c.SDL_SetTrayIcon(self.handle, surf);
if (self.icon) |old| c.SDL_DestroySurface(old);
self.icon = surf;
}
/// Box `cb` so it outlives this call (SDL keeps the userdata pointer), and
/// wire it to `entry` through the C-ABI thunk.
fn bind(self: *Tray, entry: *c.SDL_TrayEntry, cb: zigui.Callback) void {
const boxed = self.gpa.create(zigui.Callback) catch return;
boxed.* = cb;
self.callbacks.append(self.gpa, boxed) catch {
self.gpa.destroy(boxed);
return;
};
c.SDL_SetTrayEntryCallback(entry, trayThunk, boxed);
}
};
/// Run the app. `body` is invoked each frame to (re)build the view tree from the
/// current application state; state mutations triggered by events cause the next
/// frame to reflect the change.
pub fn run(
gpa: std.mem.Allocator,
comptime AppState: type,
st: *AppState,
cfg: Config,
comptime body: fn (*AppState) zigui.View,
) !void {
if (!c.SDL_Init(c.SDL_INIT_VIDEO)) {
std.log.err("SDL_Init failed: {s}", .{c.SDL_GetError()});
return Error.SdlInit;
}
defer c.SDL_Quit();
const window = c.SDL_CreateWindow(
cfg.title.ptr,
@intCast(cfg.width),
@intCast(cfg.height),
c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_HIGH_PIXEL_DENSITY,
) orelse {
std.log.err("SDL_CreateWindow failed: {s}", .{c.SDL_GetError()});
return Error.SdlInit;
};
defer c.SDL_DestroyWindow(window);
if (cfg.min_width > 0 and cfg.min_height > 0)
_ = c.SDL_SetWindowMinimumSize(window, @intCast(cfg.min_width), @intCast(cfg.min_height));
// Window/taskbar icon (SDL copies the surface, so we free it immediately).
if (cfg.icon) |ic| {
if (makeSurface(ic.rgba, ic.w, ic.h)) |surf| {
_ = c.SDL_SetWindowIcon(window, surf);
c.SDL_DestroySurface(surf);
}
}
// Prefer the GPU backend; when device/swapchain creation fails (no Vulkan
// driver, headless CI, ZIGUI_SOFTWARE set) fall back to the software
// rasterizer presented through an SDL streaming texture.
const want_gpu = cfg.gpu and c.SDL_getenv("ZIGUI_SOFTWARE") == null;
var gpu_backend: ?gpu.Gpu = if (want_gpu) gpu.Gpu.init(gpa, window) else null;
defer if (gpu_backend) |*g| g.deinit();
if (gpu_backend) |*g| std.log.info("zigui: rendering via SDL_GPU ({s})", .{g.driverName()});
const renderer: ?*c.SDL_Renderer = if (gpu_backend != null) null else c.SDL_CreateRenderer(window, null) orelse {
std.log.err("SDL_CreateRenderer failed: {s}", .{c.SDL_GetError()});
return Error.SdlInit;
};
defer if (renderer) |r| c.SDL_DestroyRenderer(r);
_ = c.SDL_StartTextInput(window);
var font = zigui.Font.default();
// Bundled monochrome emoji font, wired as a fallback so codepoints Inter
// lacks (emoji, etc.) still render. Lives for the whole loop, so the
// `&emoji_font.face` pointer stays valid.
var emoji_font = zigui.Font.emoji();
font.face.fallback = &emoji_font.face;
var cache = zigui.GlyphCache.init(gpa, &font.face);
defer cache.deinit();
var icon_font = zigui.Font.icons();
var icon_cache = zigui.GlyphCache.init(gpa, &icon_font.face);
defer icon_cache.deinit();
var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
// Animations are driven here: the loop ticks the animator each frame while
// it is active, and view callbacks reach it via `app.animator()`.
var anim = zigui.Animator.init(gpa);
defer anim.deinit();
g_animator = &anim;
defer g_animator = null;
var last_ms: u64 = c.SDL_GetTicks();
var texture: ?*c.SDL_Texture = null;
var tex_w: c_int = 0;
var tex_h: c_int = 0;
defer if (texture) |t| c.SDL_DestroyTexture(t);
// Persistent render buffers (reused across frames; freed at shutdown).
var fb: zigui.Framebuffer = .empty;
defer fb.deinit();
var rgba: []u8 = &.{};
defer if (rgba.len > 0) gpa.free(rgba);
const perf_freq = c.SDL_GetPerformanceFrequency();
var stats: FrameStats = .{};
// Opt-in frame profiling via env var (in addition to `setFrameLog`).
if (c.SDL_getenv("ZIGUI_FRAME_LOG") != null) g_frame_log = true;
// Let the (platform-free) context menu copy/paste via the SDL clipboard.
zigui.setClipboardOps(.{ .copy = clipboardCopy, .paste = clipboardPaste });
// Cache wrapped-text layout across frames so a long, static transcript isn't
// re-wrapped on every redraw (only changing text actually re-wraps).
var wrap_cache = zigui.WrapCache.init(gpa);
defer wrap_cache.deinit();
zigui.setWrapCache(&wrap_cache);
defer zigui.setWrapCache(null);
// A dedicated arena + scratch region lists for redraws issued from the macOS
// live-resize event watch, so a watch-triggered frame never aliases the main
// loop's arena (which is mid-use when the watch fires). The lists are backed
// by `resize_arena` (their items hold arena-allocated callbacks), so they are
// never deinit'd with gpa — `resize_arena.deinit()` reclaims everything.
var resize_arena = std.heap.ArenaAllocator.init(gpa);
defer resize_arena.deinit();
var resize_hits: std.ArrayList(zigui.HitRegion) = .empty;
var resize_scrolls: std.ArrayList(zigui.ScrollRegion) = .empty;
var running = true;
// Expose the window/loop to tray callbacks and the close handler.
g_window = window;
g_running = &running;
g_hide_on_close = cfg.hide_on_close;
defer {
g_window = null;
g_running = null;
}
// Everything one `drawFrame` needs, shared by the main loop and the resize
// watch (which run on the same thread, never concurrently).
const Frame = struct {
gpa: std.mem.Allocator,
window: *c.SDL_Window,
renderer: ?*c.SDL_Renderer,
gpu_backend: ?*gpu.Gpu,
cache: *zigui.GlyphCache,
icon_cache: *zigui.GlyphCache,
cfg: Config,
st: *AppState,
texture: *?*c.SDL_Texture,
tex_w: *c_int,
tex_h: *c_int,
// Persistent pixel buffers reused across frames (realloc only on resize),
// so steady-state frames allocate no per-frame framebuffer/RGBA scratch.
fb: *zigui.Framebuffer,
rgba: *[]u8,
perf_freq: u64,
stats: *FrameStats,
resize_arena: *std.heap.ArenaAllocator,
resize_hits: *std.ArrayList(zigui.HitRegion),
resize_scrolls: *std.ArrayList(zigui.ScrollRegion),
};
const Render = struct {
/// Build, rasterize and present one frame into `arena_state`, recording
/// hit/scroll regions into the given lists. Returns false (no present) if
/// the window is hidden or has a degenerate size.
fn drawFrame(
fr: *Frame,
frame_arena: *std.heap.ArenaAllocator,
hits: *std.ArrayList(zigui.HitRegion),
scrolls: *std.ArrayList(zigui.ScrollRegion),
) bool {
if ((c.SDL_GetWindowFlags(fr.window) & c.SDL_WINDOW_HIDDEN) != 0) return false;
// Logical size (points) drives layout; pixel size drives the
// framebuffer so output is crisp on Retina. Their ratio is the scale.
var lw: c_int = 0;
var lh: c_int = 0;
_ = c.SDL_GetWindowSize(fr.window, &lw, &lh);
var pw: c_int = 0;
var ph: c_int = 0;
_ = c.SDL_GetWindowSizeInPixels(fr.window, &pw, &ph);
if (lw <= 0 or lh <= 0 or pw <= 0 or ph <= 0) return false;
const t0 = c.SDL_GetPerformanceCounter();
const fpw: f32 = @floatFromInt(pw);
const fph: f32 = @floatFromInt(ph);
// `display_scale` is the OS-honored content scale: it folds the HiDPI
// backing density AND the user's display-scale setting (Windows
// 125/150/200%, fractional scaling on Linux). The earlier `pw/lw`
// captured only the backing density, so non-100% OS scaling was
// ignored everywhere except macOS (where the two coincide). We lay the
// UI out in `device_pixels / display_scale` design points and scale
// each ×display_scale, so a control keeps its physical size on any
// display. A 0.0 return means SDL couldn't query it — fall back to 1.
const display_scale = c.SDL_GetWindowDisplayScale(fr.window);
const base_scale: f32 = if (display_scale > 0) display_scale else 1;
// Supersampling (SSAA): render into a framebuffer `ss`× larger per
// axis, then downscale on present. This is the lever that makes text
// crisp on a low-DPI (100%) display. Capped so the *total* device
// scale stays ≤ 2 — a Retina/HiDPI panel already has the pixels, so
// we never render it at 4×.
const ss: f32 = @max(1, @min(@max(1, fr.cfg.supersample), 2 / base_scale));
// `scale` is device-pixels-per-design-point (backing × supersample);
// layout still happens in design points (`base_scale` only), so hit
// regions and event mapping are unaffected by `ss`.
const scale: f32 = base_scale * ss;
const point_w = fpw / base_scale;
const point_h = fph / base_scale;
// SDL mouse events arrive in logical window points; hit regions live
// in design points. Convert by pixel_density / display_scale (== 1 on
// macOS); `ss` cancels out since it doesn't change the design space.
const pixel_density = fpw / @as(f32, @floatFromInt(lw));
g_event_scale = pixel_density / base_scale;
// The framebuffer/scene is the oversampled device size; present
// downscales it to the window's actual pixel size (pw×ph).
const uw: u32 = @intFromFloat(@round(fpw * ss));
const uh: u32 = @intFromFloat(@round(fph * ss));
// Frame clock for time-based UI (auto-hiding scrollbars, etc.).
zigui.setFrameTime(c.SDL_GetTicks());
// Live theme: query the provider (if any) so light/dark switches take
// effect without a restart. Read it after `body` builds the tree so the
// framebuffer clear and Context agree with whatever the body painted.
_ = frame_arena.reset(.retain_capacity);
const arena = frame_arena.allocator();
const theme = if (g_theme_fn) |f| f() else fr.cfg.theme;
// Publish the active theme's selection tints so composed constructors
// (Sidebar/Table/RadioGroup) pick up dark mode and custom accents.
zigui.setThemeTokens(theme);
zigui.beginBuild(arena);
const root = body(fr.st);
zigui.endBuild();
// Reset to empty (not clearRetainingCapacity): the previous buffer was
// arena-allocated and the reset above reclaimed it, so keeping the old
// pointer would alias freshly handed-out arena memory.
hits.* = .empty;
scrolls.* = .empty;
var overlays: std.ArrayList(zigui.OverlayReq) = .empty;
var ctx = zigui.Context.initFull(theme, fr.cache, arena, hits, &overlays, null);
ctx.scroll_regions = scrolls;
ctx.icon_cache = fr.icon_cache;
ctx.hover_point = g_hover_point;
// Coverage gamma for text: a straight sRGB alpha-blend renders the
// anti-aliased edge of light glyphs on a dark background too thin
// (the classic "soft" custom-UI text). Gamma-correcting the coverage
// restores weight. Dark themes lighten the edge (~0.6 ≈ the
// gamma-correct result); light themes are left untouched (1.0) since
// dark-on-light doesn't suffer the same thinning. Both backends apply
// the same exponent, so software/GPU output stays pixel-matched.
const dark_ui = theme.colors.window_background.luminance() < 0.5;
zigui.setTextGamma(if (dark_ui) 0.6 else 1.0);
var canvas = zigui.Canvas.init(arena);
// No background fill command here: the framebuffer's `clear` below paints
// the window background with a fast @memset, so emitting a full-window
// fill_rrect would just re-rasterize every pixel through the SDF path.
zigui.renderScaled(&ctx, root, .{ .x = 0, .y = 0, .width = point_w, .height = point_h }, scale, &canvas) catch {};
if (fr.gpu_backend) |g| {
// GPU backend: translate + replay the command list on the GPU.
// `ss` makes it render the scene oversampled and downscale on the
// final blit (SSAA), mirroring the software texture path below.
if (!g.frame(arena, canvas.commands.items, theme.colors.window_background, ss)) return false;
} else {
// Software backend. Persistent framebuffer + RGBA buffer:
// reallocated only when the pixel size changes, so a
// steady-state frame does zero pixel-buffer allocation.
const rend = fr.renderer.?;
fr.fb.ensureSize(fr.gpa, uw, uh) catch return false;
fr.fb.clear(theme.colors.window_background);
zigui.raster.render(arena, fr.fb, canvas.commands.items) catch return false;
const need: usize = @as(usize, uw) * @as(usize, uh) * 4;
if (fr.rgba.*.len != need) {
if (fr.rgba.*.len > 0) fr.gpa.free(fr.rgba.*);
fr.rgba.* = fr.gpa.alloc(u8, need) catch return false;
}
fr.fb.toRgba8(fr.rgba.*);
// The texture is the oversampled (uw×uh) size; `SDL_RenderTexture`
// with a null dst stretches it to the window's pixel size, and the
// LINEAR scale mode makes that a clean SSAA downsample.
const iuw: c_int = @intCast(uw);
const iuh: c_int = @intCast(uh);
if (fr.texture.* == null or fr.tex_w.* != iuw or fr.tex_h.* != iuh) {
if (fr.texture.*) |t| c.SDL_DestroyTexture(t);
fr.texture.* = c.SDL_CreateTexture(rend, c.SDL_PIXELFORMAT_ABGR8888, c.SDL_TEXTUREACCESS_STREAMING, iuw, iuh);
if (fr.texture.*) |t| _ = c.SDL_SetTextureScaleMode(t, c.SDL_SCALEMODE_LINEAR);
fr.tex_w.* = iuw;
fr.tex_h.* = iuh;
}
_ = c.SDL_UpdateTexture(fr.texture.*, null, fr.rgba.*.ptr, @intCast(uw * 4));
_ = c.SDL_RenderClear(rend);
_ = c.SDL_RenderTexture(rend, fr.texture.*, null, null);
_ = c.SDL_RenderPresent(rend);
}
if (g_frame_log) {
const t1 = c.SDL_GetPerformanceCounter();
const ns = (t1 -% t0) *% 1_000_000_000 / fr.perf_freq;
fr.stats.record(ns, c.SDL_GetTicks());
}
return true;
}
/// SDL event watch: fires synchronously while events are pumped, INCLUDING
/// during the macOS modal live-resize loop (when the main loop is blocked
/// in SDL_WaitEvent). Redraw on resize/expose so content reflows live
/// instead of the old texture being stretched until the mouse is released.
fn resizeWatch(userdata: ?*anyopaque, ev: [*c]c.SDL_Event) callconv(.c) bool {
const fr: *Frame = @ptrCast(@alignCast(userdata.?));
switch (ev.*.type) {
c.SDL_EVENT_WINDOW_RESIZED,
c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED,
c.SDL_EVENT_WINDOW_EXPOSED,
=> _ = drawFrame(fr, fr.resize_arena, fr.resize_hits, fr.resize_scrolls),
else => {},
}
return true;
}
};
var frame = Frame{
.gpa = gpa,
.window = window,
.renderer = renderer,
.gpu_backend = if (gpu_backend) |*g| g else null,
.cache = &cache,
.icon_cache = &icon_cache,
.cfg = cfg,
.st = st,
.texture = &texture,
.tex_w = &tex_w,
.tex_h = &tex_h,
.fb = &fb,
.rgba = &rgba,
.perf_freq = perf_freq,
.stats = &stats,
.resize_arena = &resize_arena,
.resize_hits = &resize_hits,
.resize_scrolls = &resize_scrolls,
};
_ = c.SDL_AddEventWatch(Render.resizeWatch, &frame);
defer c.SDL_RemoveEventWatch(Render.resizeWatch, &frame);
while (running) {
// Per-frame hook (e.g. refresh the system-tray menu). Runs whether or not
// the window is visible, so the tray tracks status even when hidden.
if (g_frame_fn) |f| f();
// When hidden (e.g. closed-to-tray), don't render — just pump events so
// tray entries and a re-open still work, and block efficiently. While a
// busy predicate is set (e.g. a generation in flight), wake on a ~60fps
// timeout so the frame hook keeps the tray status live.
if ((c.SDL_GetWindowFlags(window) & c.SDL_WINDOW_HIDDEN) != 0) {
const no_hits: []const zigui.HitRegion = &.{};
const no_scrolls: []const zigui.ScrollRegion = &.{};
const busy_ms = busyIntervalMs();
var ev: c.SDL_Event = undefined;
// Cap the idle wait (`idle_wake_ms`): a hidden-to-tray window can't
// receive an event to observe a tray callback's `running = false`,
// so a blocking wait here hangs Quit forever.
if (c.SDL_WaitEventTimeout(&ev, if (busy_ms != 0) @intCast(busy_ms) else idle_wake_ms)) handleEvent(&ev, &running, no_hits, no_scrolls);
while (c.SDL_PollEvent(&ev)) handleEvent(&ev, &running, no_hits, no_scrolls);
continue;
}
// Fresh per-frame region lists (arena-backed via drawFrame); discarded at
// the next arena reset, exactly like the pre-refactor loop.
var hits: std.ArrayList(zigui.HitRegion) = .empty;
var scrolls: std.ArrayList(zigui.ScrollRegion) = .empty;
if (!Render.drawFrame(&frame, &arena_state, &hits, &scrolls)) {
// Frame skipped. On the congested-swapchain path, drawFrame has
// already rebuilt the tree and filled `hits`/`scrolls` before the
// GPU acquire failed, so these regions describe the CURRENT frame
// and their `Callback.ctx` pointers target the current frame
// arena — valid until the next drawFrame resets it. Dispatching
// against them is therefore safe. A snapshot of a *previous*
// frame's regions never is: ctx pointers may be frame-arena-owned,
// and that arena was reset (and reused) by the rebuild above —
// dispatching a stale snapshot is a use-after-reset (observed as a
// sidebar-tap segfault during video generation). The pre-build
// early returns (hidden/degenerate window) leave the lists empty,
// which just makes dispatch a harmless no-op.
// While busy, wake on a short timeout so the next present retries
// promptly instead of stalling until the next input event.
var ev: c.SDL_Event = undefined;
const ms = busyIntervalMs();
if (ms != 0) {
if (c.SDL_WaitEventTimeout(&ev, @intCast(@min(ms, 16)))) handleEvent(&ev, &running, hits.items, scrolls.items);
} else {
if (c.SDL_WaitEvent(&ev)) handleEvent(&ev, &running, hits.items, scrolls.items);
}
while (c.SDL_PollEvent(&ev)) handleEvent(&ev, &running, hits.items, scrolls.items);
continue;
}
// Event handling. Hit/scroll regions stay in design points (renderScaled
// leaves them unscaled); SDL reports mouse coordinates in logical window
// points, so `handleEvent` converts via `g_event_scale` before dispatch
// (a no-op on macOS). While animating *or* while a
// busy predicate is set (e.g. a streaming request in flight), wake on a
// ~60fps timeout and rebuild each frame; otherwise block for input.
var ev: c.SDL_Event = undefined;
// UI animations keep their own ~60fps cadence; the app's requested
// interval (e.g. a slow spinner repaint during a heavy GPU job) paces
// the loop only when nothing is animating.
const app_ms = busyIntervalMs();
const anim_busy = anim.active() or zigui.scrollbarsAnimating(c.SDL_GetTicks());
const busy_ms: u32 = if (anim_busy) (if (app_ms == 0) 16 else @min(app_ms, 16)) else app_ms;
if (busy_ms != 0) {
if (c.SDL_WaitEventTimeout(&ev, @intCast(busy_ms))) handleEvent(&ev, &running, hits.items, scrolls.items);
while (c.SDL_PollEvent(&ev)) handleEvent(&ev, &running, hits.items, scrolls.items);
const now = c.SDL_GetTicks();
const dt: f32 = @as(f32, @floatFromInt(now - last_ms)) / 1000.0;
last_ms = now;
anim.tick(dt);
} else {
// Fully idle: wait for input. A blocking SDL_WaitEvent would only
// return on an SDL event, but a tray/menu callback flips `running`
// (tray Quit) or updates tray state while posting none — so a bare
// block leaves the app parked until the user's next mouse-over, and
// hangs forever when the window is hidden. Instead wait in
// `idle_wake_ms` slices: on a timeout re-run the frame hook (keeps
// the tray live) and re-check `running`, WITHOUT redrawing — nothing
// changed, so idle stays ~free (no 4 Hz repaint). Only a real event
// breaks out to the redraw at the top of the outer loop; it
// dispatches against the last frame's hit regions, still valid since
// no drawFrame has reset the arena.
while (running) {
if (c.SDL_WaitEventTimeout(&ev, idle_wake_ms)) {
handleEvent(&ev, &running, hits.items, scrolls.items);
while (c.SDL_PollEvent(&ev)) handleEvent(&ev, &running, hits.items, scrolls.items);
break;
}
if (g_frame_fn) |f| f();
// Background work (a generation, playback) may have flipped the
// busy cadence on: leave idle so the outer loop redraws at it.
if (busyIntervalMs() != 0) break;
}
last_ms = c.SDL_GetTicks();
}
}
}
/// SDL reports mouse coordinates in logical window points; hit/scroll regions
/// live in design points. Bring an event coordinate into region space.
fn eventPoint(x: f32, y: f32) zigui.geometry.Point {
return .{ .x = x * g_event_scale, .y = y * g_event_scale };
}
fn handleEvent(ev: *c.SDL_Event, running: *bool, hits: []const zigui.HitRegion, scrolls: []const zigui.ScrollRegion) void {
// With frame logging on, also log which events wake the loop (each wake
// costs a full rebuild+raster) so redraw storms can be attributed.
if (g_frame_log) std.debug.print("[zigui] event 0x{x}\n", .{ev.type});
switch (ev.type) {
c.SDL_EVENT_QUIT => running.* = false,
// The close button: hide-to-tray when configured, else quit. (⌘Q still
// sends SDL_EVENT_QUIT.)
c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => if (g_hide_on_close) hideWindow() else {
running.* = false;
},
c.SDL_EVENT_MOUSE_BUTTON_DOWN => {
const p = eventPoint(ev.button.x, ev.button.y);
if (ev.button.button == c.SDL_BUTTON_RIGHT) {
// Right-click pops the text context menu when over an editable
// field; elsewhere it just dismisses any open menu.
if (zigui.fieldAt(hits, p)) |field| {
zigui.openContextMenu(field, p);
} else {
zigui.closeContextMenu();
}
} else if (zigui.contextMenuOpen()) {
// A left-click while the menu is open routes to the menu (an item
// or the dismiss region) without disturbing focus.