From 1db778522a675d89f7f8fcc7f118087baa6c2214 Mon Sep 17 00:00:00 2001 From: Mitchell Date: Mon, 20 Apr 2026 08:31:55 -0500 Subject: [PATCH] feat: add app icons and tray functionality - Create new Spacecap logo icon. - Add Linux tray support. Unfortunately we have to pull in GTK for this, because that's just what SDL3 relies on. As of right now it increases the AppImage build from ~18MB to ~26MB. - Update build and test action to also build the AppImage. - Update imguiz. This updates imgui to the latest docking branch. It also bundles SDL3 in with it, so we can remove the SDL3 dependency from Spacecap. I did this with these changes because we rely on some SDL3 APIs for this tray change. closes #87 --- .github/workflows/build_and_test.yml | 3 + README.md | 2 +- build.zig | 18 ++-- build.zig.zon | 8 +- build_app_image.sh | 10 +- flake.nix | 12 +++ packaging/linux/spacecap.desktop | 2 +- packaging/linux/spacecap.svg | 10 -- packaging/logo.aseprite | Bin 0 -> 2076 bytes packaging/logo_blue.png | Bin 0 -> 1209 bytes packaging/logo_green.png | Bin 0 -> 1224 bytes packaging/logo_red.png | Bin 0 -> 1245 bytes packaging/palette.aseprite | Bin 0 -> 309 bytes src/state/actor.zig | 9 +- src/ui/app_icon.zig | 37 +++++++ src/ui/sdl.zig | 3 + src/ui/tray.zig | 139 +++++++++++++++++++++++++++ src/ui/ui.zig | 67 +++++++++++-- 18 files changed, 277 insertions(+), 43 deletions(-) delete mode 100644 packaging/linux/spacecap.svg create mode 100644 packaging/logo.aseprite create mode 100644 packaging/logo_blue.png create mode 100644 packaging/logo_green.png create mode 100644 packaging/logo_red.png create mode 100644 packaging/palette.aseprite create mode 100644 src/ui/app_icon.zig create mode 100644 src/ui/tray.zig diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 57584bd..0251345 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -39,3 +39,6 @@ jobs: - name: Test run: nix develop -c zig build test -Dnix + + - name: Build AppImage + run: nix develop -c zig build -Dappimage -Doptimize=ReleaseFast diff --git a/README.md b/README.md index 36778e5..364366d 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Use `spacecap -h` to see available commands. ### Windows -Windows is not yet supported. This application was architected in such a way +Windows is not yet supported. Spacecap is architected in such a way that it can be cross platform. For Windows support, the audio/video capture interfaces need to be implemented. It's on the roadmap, but is not currently a priority. diff --git a/build.zig b/build.zig index dbef348..654d06c 100644 --- a/build.zig +++ b/build.zig @@ -1,4 +1,3 @@ -const util = @import("src/util.zig"); const std = @import("std"); const ffmpeg_build_util = @import("build/ffmpeg_build.zig"); const version = @import("build/version.zig"); @@ -40,6 +39,12 @@ fn add_shared_dependencies( try compile_shader(allocator, b, exe, "random.vert", "random_vert_shader"); try compile_shader(allocator, b, exe, "bgr-ycbcr-shader-2plane.comp", "bgr-ycbcr-shader-2plane"); + inline for (.{ "logo_blue.png", "logo_red.png", "logo_green.png" }) |logo_file| { + exe.root_module.addAnonymousImport(logo_file, .{ + .root_source_file = b.path("packaging/" ++ logo_file), + }); + } + // vulkan const vulkan_headers = b.dependency("vulkan_headers", .{}); const vulkan = b.dependency( @@ -52,16 +57,11 @@ fn add_shared_dependencies( exe.root_module.addImport("vulkan", vulkan); exe.addIncludePath(vulkan_headers.path("")); - // SDL3 - const sdl = b.dependency("sdl", .{ + // NOTE: SDL3 is statically linked by imguiz. + const imguiz = b.dependency("imguiz", .{ .target = target, .optimize = optimize, - .linkage = .static, - }); - exe.linkLibrary(sdl.artifact("SDL3")); - - // imguiz - const imguiz = b.dependency("imguiz", .{}).module("imguiz"); + }).module("imguiz"); exe.root_module.addImport("imguiz", imguiz); // zigrc diff --git a/build.zig.zon b/build.zig.zon index d67a086..247ef21 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -79,8 +79,8 @@ .hash = "vulkan-0.0.0-r7YtxztIAwBc30xIMx4tzfUcEmc7goWiFJkR13yeLfi8", }, .imguiz = .{ - .url = "git+https://github.com/mgerb/imguiz#7d6e1a4dfb2f31da24c9f76e0c4ba8285e89d289", - .hash = "imguiz-0.0.0-sI63zx-_DwQbtJX5KbwrOK_VnEkxIft96REu9HVBZ3W8", + .url = "git+https://github.com/mgerb/imguiz#3b56163b6f87e681856e019141f034e9966d8aa0", + .hash = "imguiz-0.0.0-sI63z3FwbgSB_xv3TSKRjoMhwSfup8tmCPMq1ZJyDqUs", }, .ffmpeg = .{ .url = "https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n8.0.1.tar.gz", @@ -94,10 +94,6 @@ .url = "git+https://github.com/Aandreba/zigrc#b1e98f1cc506e975bdb27341c27d920021e7b4d8", .hash = "zigrc-1.0.0-lENlWzvQAACulrbkL9PVhWjFsWSkYhi7AmfSbCM-2Xlh", }, - .sdl = .{ - .url = "git+https://github.com/allyourcodebase/SDL3#467a4baad2acdda1bcf717065793fbbdf2569f8d", - .hash = "sdl-0.0.0-i4QD0WN-qQB1RKdRC9-6iZyTco2jIIr3y2DFrAz-D9CH", - }, .pipewire = .{ .url = "https://github.com/mgerb/pipewire/archive/refs/heads/dev.tar.gz", .hash = "pipewire-1.6.2-tKslQ3LyEgC050NwrrKyjJjEXbO-M_CvmyVMu3FLLoJd", diff --git a/build_app_image.sh b/build_app_image.sh index 3a750f0..e490cdc 100755 --- a/build_app_image.sh +++ b/build_app_image.sh @@ -13,8 +13,14 @@ LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" linuxdeploy \ --appdir AppDir \ --executable zig-out/linux/spacecap \ --desktop-file packaging/linux/spacecap.desktop \ - --icon-file packaging/linux/spacecap.svg \ - --exclude-library libvulkan.so.1 + --icon-file packaging/logo_blue.png \ + --exclude-library libvulkan.so.1 \ + --library "$GTK3_LIB" \ + --library "$APPINDICATOR_LIB" + +# NOTE: These extra libs are required for the tray icon functionality. +# Unfortunately we have to pull in gtk, which increases app size by 50% :( + env -u SOURCE_DATE_EPOCH APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 appimagetool AppDir zig-out/linux/spacecap-linux-x86_64.AppImage rm -rf AppDir diff --git a/flake.nix b/flake.nix index d03f174..afed33d 100644 --- a/flake.nix +++ b/flake.nix @@ -105,6 +105,11 @@ libportal zlib glib + + # Required for linux tray icon. + gtk3 + libayatana-appindicator + appimage-run # For configuring ffmpeg headers @@ -118,11 +123,18 @@ VK_LAYER_PATH = "${pkgs.vulkan-validation-layers}/share/vulkan/explicit_layer.d"; VULKAN_SDK_PATH_WINDOWS = "${pkgs.pkgsCross.mingwW64.vulkan-loader}/bin"; + GTK3_LIB = "${pkgs.gtk3}/lib/libgtk-3.so.0"; + APPINDICATOR_LIB = "${pkgs.libayatana-appindicator}/lib/libayatana-appindicator3.so.1"; # Required for Github actions or non-NixOS machines. LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.vulkan-loader pkgs.glib + + # Required for linux tray icon. + pkgs.gtk3 + pkgs.libayatana-appindicator + pkgs.libportal # SDL runtime backends (don't rely on ffmpeg closure for these). pkgs.wayland diff --git a/packaging/linux/spacecap.desktop b/packaging/linux/spacecap.desktop index adc4e00..46a099f 100644 --- a/packaging/linux/spacecap.desktop +++ b/packaging/linux/spacecap.desktop @@ -3,6 +3,6 @@ Type=Application Name=Spacecap Comment=Hardware accelerated replay capture tool Exec=spacecap -Icon=spacecap +Icon=logo_blue Categories=AudioVideo;Video; Terminal=false diff --git a/packaging/linux/spacecap.svg b/packaging/linux/spacecap.svg deleted file mode 100644 index ae85fd8..0000000 --- a/packaging/linux/spacecap.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/packaging/logo.aseprite b/packaging/logo.aseprite new file mode 100644 index 0000000000000000000000000000000000000000..f0a3fdbfaa13e4914aa7da82270a8ff7868e7ef0 GIT binary patch literal 2076 zcmcJPe^iq99>*VJX>+d4-P@X$iM8#xXnDO19m2A1r8_fqZh?Zumh!_Ns1pSRZn@U3 zZMk%k-~_~-Qj>u)hd(x^c2~zTQn+e}GDTC65P>OzOAdOUthP?)?!VpVd%mydd%mCV z^ZmS^&+~mh+cyEgRD&0=A8b&-3jhEj*nhuX0KD!`0?&i(|8WU@0AOXoZ#}CYnBYaQ z`#{%IK9;T&90x#OcsS$#Smek zw0vI=a_rj2An)|wpU)l_f-Jl`3372(4YH)~SCFY)YartjaBIO}vZtVsM=8NO7yx{L zbUKYj$}J384{vxP{4_u#5uuFCf;`&J^#xBp?+c_+>7)lMcKy#So&hK{5-IobO`go+ z3*=FW&l%xL9R&Ajm;|5)!d28t7 zaT0!vbQ-=Mg_Pz6eW>yNPdY>`tDk8o$IRyBR4GWTuMqzFd@gbgT{~ z)f{AB@A?GueyTW;Vbgut6EA{arv1m=YTSE3ZJ?g%eQm<|L*R1J8ujdma*PG{4B#Ye zgoP(@WMVC=Xw5kq*2U`Syo?j)_ljG|tfcFe@=vz55*iBVOVQsXT?q|V@I0r4*5u6X zRbfMS$7DJr>sly_U6pU(irNkJVpv)jYjG$A>9hNTih&DfH0StQlv??c+@nZaF5j}S zc@TU4q=~>2`s?Sv6w0P^vWc_x`r&@;%;&S1nZ9Ga@s0xl6k8C(#tt&eN8hp^2;6mx zl%p%4C+{m!Dla7Rz$3O1G?)+I{n+Vv9W)+zI&L4>{4vz;P*U#f(_L*_hrj)sx%6Gj zk+X|`=`i|lXzo?0W?a&#;tYbt*R(%HKJNz`7Tr0lfOl_l*D-xjE2`04mmo$krf)hK zaV96ox-B2p#jwlB4chA~5V&rjnK7Wd*&MvYRpN$lk*tf;7I07#RKi`NTu1Wg1yH8usQ6zINqo0aS`(lyWoGzwDf-A$;n& zh5lF2NeVU69e-(nns>WL@KeXWhnhMcYKj6iS)(3lB48YPjH5-$++ch*=G22qLH)x{N`>nupj(>Mx8Yt5)QiCb{jbr zorYn2_>A|5r!VfTCE`B!tibZ8UXE*Os75nue}qjlB+<5I_d&_V;ZS4@&n`=BPr&=U z99?!Ce`fEWL0kQA!eguDf4EO~$=Nw%rMEEJu-U4RDHh z0)yte0mTh6pTaCZ!tXrT!oksYdd88dWz{9#3yglAz#sPdoaVcUoMT2Sx%(Y?mKJuZ zXRn*i?PP~62NTxD*c_aKz_iEKT3Di(Iz)o&JxSF<-#b^H6B&X?h6P3>yAGbPl%;LD zQk8dmHxpH3o<4EVt%n}n}fU)ST5J}`0R$B^#m9%7M8+|V}Ot&=&vWQbN+wH!6 zI@N47-Fg%BxImN5P5(%8W4@-0J{i-kWyWF+s1v2y#puI?u<14hipi`?5yB&vi_E4b r^>oD&>b<^~xFa>FmGQ5QkrE8n(8tn`T7TL8c9`^yRp6D}H&6c;dCrPw literal 0 HcmV?d00001 diff --git a/packaging/logo_blue.png b/packaging/logo_blue.png new file mode 100644 index 0000000000000000000000000000000000000000..342c749d70b924193dedc4d7d32a81fbe078c22b GIT binary patch literal 1209 zcmV;q1V;ObP)Px(b4f%&RCt{2oxN=wF%U-2hO3Zr!@1mm3Qz|QRfAoFJ8+=X6(}~;fRr1~?KbSd z7(gP#^Al%{ZbmI3ooaOGGP5=M^000n+H?u#0H&<6ze=T0;>FMc^-__gM z9{}dE?Woo!`{Dl1(zpl2{1r#%nhJTzE#p&nPrI8On?D`^UmspO3BYv#kG6wtjHN?wc5cO3=o{| z8bF);bk3WV{93$>uu3g}0fN(PfL!vu=6Y>y;f>ec#91c50KqX^1z6AZtM(D(eUGwQ zzyQIit^v}y-zxc!kLMHh>Bs5C`>jT=5oXl_7$7*!1`t}4zkA|^i_7;fe*g3P*XeX> zH-eQ3FaW4fuL4FTUsyf)f+Su{AU}6mEntA)sA~W*fOqnDnfr5C)dZtz0Spivvrb@+ zD9IOgs|hebaEb=#o%>51CHX16CXmOkZ*-m3)>;7r1jnrg(9ZowCpAGUrNp-y0Rsdl z+XwJk_sg6w9Bu9|AtosG2YSyf)LxAY0|dvs1~^);-qR(c)=2#(qSt-b%F_WF+k1_+MZDuCqckXoR3 z?r-hbVu0Y786bL(e^)R-aLmsR248a5TWDRSVSwN;02bl;=61?;lNbQOQ4fHJ`#ZP0 zVnpWR@_o~OLGAye^gIj@oMHO`;5=dhsROjO?X@xn2u|_r;B60Kw@#79d!U(De0sueP0%+lwb0CGp%d2+mS{UVs*_Im@bsn4Rzx z@9zr(z^k|&4b)cuwIhAC||@f0KwT}l-AH{jvTurxfKim0000001%3Q Xpyq+QSj2N900000NkvXXu0mjfOK&kb literal 0 HcmV?d00001 diff --git a/packaging/logo_green.png b/packaging/logo_green.png new file mode 100644 index 0000000000000000000000000000000000000000..807ab65584dcf8189f3681d687cbd9a204fadd0b GIT binary patch literal 1224 zcmV;(1ULJMP)Px(f=NU{RCt{2ojq<8K@f!(#3}6DB3}YhAHXpQp%X3uXW#@7I3*xtE>8F>V1i2Sg+T1zpvl)^v)a(00000004koycqogqB$Oq|2&@O;o)JI zzpIy{UjP`(vZGp??7Q0=m+yCZbzSxNRg3`xBxm|j>NSURyv)GSnqn41zyQgaVicZ& zC;#pD53bMu^W7KKs=UnTJ+$Ox-;0~jDV-3+j|`TZYzNjS&~nz)(K#M7ifKNq4sKIFhFwLJHX!7`n})dZ$v4sJW<7R;DYy7JM10=`I07!fvr0XqMkJ4a(URdM za)r;?;q(5tuOFX%Zr$fEeLdb81WtkuFc>-;$fAFlGA0BokB7LvAQ9&=O}t5T>=J3&d|}fMad8n-sZ5LZ5*9!EigcGmKdcqSj{1^ mN|LR>0000000000kc$7h{DpN^$*0Z$0000Px(mq|oHRCt{2oj+0)K@f*m%g_@*PMPBcH~=>=>X8vpfZG2{OW}wE+x}oNfl#Tk<)*Y6EW*zyQe!H^APLFA-$~Zxg@($q6%n zHTl{eeTmtO<(L4DB{hoVSEx-WDsjdO|-0zkA`}^aG|M27a>!{?HXf?v9S^x$}j=2VC zP5$bMCr(b!wr$(CFaN%Odi=F*TfGtJc>4UJ_84+tfaItR!1wV-C7+l*`2;1NOh8|+ zjV9;~43Hf4Du4_So%~hiehD%FVXX7d zCHb6I6O{S_{PVZg3NS!&!fF8R+`o6Y39OV7Z#4o8kQ}iO5Vh{7IiJ|u++TuBQ0fn~ zUR$WW8yO6c9QPVvZ@qqRcul}p1zsIMt+`=<n9{;Mq0LgJP z021E^>3$2=RT>PC91MU-xVXF;a^EBjfaItLz}@Xlw<}gNadLWQ+Apa6dz6lY0g}^g zKL9umS)lg*0IgPr0g_X^I+*VcUgZv-v%}~8@83TD_kHV?KicOH!T`xZ4KNrw8_21> z@<;kPb_9W=R2u-bKpXtZAZxXctAl|Hr~x`*O}>OzR~80H4h#^Cck;F1Uco>`xBCQU zLAp<1?=z_|08oJ%pb;tY#O!-0V1VQ_8z5_EkXQG_=)~ixVW0~0GlQg&zE=~>Qr$~v z&;N5^faI7BK$H@&nh~TOy0gypgw&rw9{?Ps`k6t}NRtvVYv-`E7k{=2R}h}8|DJx9?i=@KwNa)yq+W0VXb z;cX7<*~Zby)&c`0XNgf-gVh`ot0dV93;+NC000000IB#3C?|k7XX!TX00000NkvXX Hu0mjf`Mx?s literal 0 HcmV?d00001 diff --git a/packaging/palette.aseprite b/packaging/palette.aseprite new file mode 100644 index 0000000000000000000000000000000000000000..57463914eed091ac377b6def8c729d0319099faf GIT binary patch literal 309 zcmXqIWMFu(l#zj*fsug&h#44CfEWRQ;vhi=0U)FW*a|fC*GCqxtt>#c7!b27fUQKb z&IQPpRA7gSup!C7n8wD&{}~t#`Pu`?>^pOSWb~f@K=R?4wLtRIv-AIzfJ#{u_+c7= zG*E(pi@_-|IXk^5zcepJ6Ub*(01`m(p9w+(T>}i2iaE&%DM@LGsmu%v_6%YGorNLb literal 0 HcmV?d00001 diff --git a/src/state/actor.zig b/src/state/actor.zig index ef79dbc..45411a5 100644 --- a/src/state/actor.zig +++ b/src/state/actor.zig @@ -587,9 +587,7 @@ pub const Actor = struct { self.ui_mutex.lock(); defer self.ui_mutex.unlock(); - if (!self.state.is_recording_video) { - self.state.is_recording_video = true; - } + self.state.is_recording_video = true; } fn stop_record(self: *Self) !void { @@ -614,11 +612,12 @@ pub const Actor = struct { } } - /// Dispatch an action to the actor. + /// Dispatch an action to the actor. This is thread safe. /// /// WARN: The actor uses a buffered channel, /// and it will block the caller if it fills up. - /// Be careful when using this from the UI thread. + /// Be careful when using this from the UI thread, although + /// if the buffer fills up then something is seriously wrong. pub fn dispatch(self: *Self, action: Actions) !void { log.debug("[dispatch] dispatching action: {}", .{action}); try self.action_chan.send(action); diff --git a/src/ui/app_icon.zig b/src/ui/app_icon.zig new file mode 100644 index 0000000..a0751c9 --- /dev/null +++ b/src/ui/app_icon.zig @@ -0,0 +1,37 @@ +const imguiz = @import("imguiz").imguiz; + +const APP_ICON_BLUE = @embedFile("logo_blue.png"); +const APP_ICON_RED = @embedFile("logo_red.png"); +const APP_ICON_GREEN = @embedFile("logo_green.png"); + +/// A utility for interacting app icons with SDL3. +pub const AppIcon = struct { + const Self = @This(); + + app_icon_surface_blue: *imguiz.SDL_Surface, + app_icon_surface_red: *imguiz.SDL_Surface, + app_icon_surface_green: *imguiz.SDL_Surface, + + pub fn init() Self { + return .{ + .app_icon_surface_blue = imguiz.SDL_LoadPNG_IO( + imguiz.SDL_IOFromConstMem(APP_ICON_BLUE.ptr, APP_ICON_BLUE.len).?, + true, + ).?, + .app_icon_surface_red = imguiz.SDL_LoadPNG_IO( + imguiz.SDL_IOFromConstMem(APP_ICON_RED.ptr, APP_ICON_RED.len).?, + true, + ).?, + .app_icon_surface_green = imguiz.SDL_LoadPNG_IO( + imguiz.SDL_IOFromConstMem(APP_ICON_GREEN.ptr, APP_ICON_GREEN.len).?, + true, + ).?, + }; + } + + pub fn deinit(self: *Self) void { + imguiz.SDL_DestroySurface(self.app_icon_surface_blue); + imguiz.SDL_DestroySurface(self.app_icon_surface_red); + imguiz.SDL_DestroySurface(self.app_icon_surface_green); + } +}; diff --git a/src/ui/sdl.zig b/src/ui/sdl.zig index bcec59c..e6b1ba7 100644 --- a/src/ui/sdl.zig +++ b/src/ui/sdl.zig @@ -46,6 +46,9 @@ pub fn get_sdl_vulkan_extensions(allocator: std.mem.Allocator) !SDLVulkanExtensi /// If Linux, try Wayland, fallback to x11. pub fn init() !void { + _ = imguiz.SDL_SetHint(imguiz.SDL_HINT_APP_NAME, "Spacecap"); + _ = imguiz.SDL_SetHint(imguiz.SDL_HINT_APP_ID, "spacecap"); + if (util.is_linux()) { if (try try_sdl_init_with_hint("wayland")) { log.info("[sdl_init] using wayland", .{}); diff --git a/src/ui/tray.zig b/src/ui/tray.zig new file mode 100644 index 0000000..e71b2e7 --- /dev/null +++ b/src/ui/tray.zig @@ -0,0 +1,139 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const imguiz = @import("imguiz").imguiz; +const Actor = @import("../state/actor.zig").Actor; +const AppIcon = @import("./app_icon.zig").AppIcon; + +const APP_ICON_TOOLTIP = "Spacecap"; +const APP_ICON_RECORDING_TOOLTIP = "Spacecap - Replay Buffer Recording"; + +/// Use the SDL3 API to interact with the system tray. +/// NOTE: Interactions must be on the UI thread. +pub const Tray = struct { + const log = std.log.scoped(.tray); + const Self = @This(); + + pub const State = struct { + is_recording: bool, + is_capturing: bool, + }; + + actor: *Actor, + app_icon: *AppIcon, + tray: *imguiz.SDL_Tray, + start_replay_buffer_entry: *imguiz.SDL_TrayEntry, + stop_replay_buffer_entry: *imguiz.SDL_TrayEntry, + save_replay_entry: *imguiz.SDL_TrayEntry, + state: State = .{ + .is_recording = false, + .is_capturing = false, + }, + + pub fn init(actor: *Actor, app_icon: *AppIcon) !Self { + const tray = imguiz.SDL_CreateTray(app_icon.app_icon_surface_blue, "Spacecap") orelse return error.TrayInitCreateTray; + errdefer { + imguiz.SDL_DestroyTray(tray); + } + const menu = imguiz.SDL_CreateTrayMenu(tray) orelse { + log.warn("[init_sdl_tray] failed to create tray menu: {s}", .{imguiz.SDL_GetError()}); + return error.TrayInitMenu; + }; + + const start_record_entry = try insert_tray_entry(menu, "Start Replay Buffer"); + imguiz.SDL_SetTrayEntryCallback(start_record_entry, start_record_callback, actor); + + const stop_record_entry = try insert_tray_entry(menu, "Stop Replay Buffer"); + imguiz.SDL_SetTrayEntryCallback(stop_record_entry, stop_record_callback, actor); + + const save_replay_entry = try insert_tray_entry(menu, "Save Replay"); + imguiz.SDL_SetTrayEntryCallback(save_replay_entry, save_replay_callback, actor); + + const quit_entry = imguiz.SDL_InsertTrayEntryAt(menu, -1, "Quit", imguiz.SDL_TRAYENTRY_BUTTON) orelse { + log.warn("[init_sdl_tray] failed to create tray quit entry", .{}); + return error.TrayInitInsertQuitEntry; + }; + imguiz.SDL_SetTrayEntryCallback(quit_entry, quit_callback, null); + + return .{ + .actor = actor, + .app_icon = app_icon, + .tray = tray, + .start_replay_buffer_entry = start_record_entry, + .stop_replay_buffer_entry = stop_record_entry, + .save_replay_entry = save_replay_entry, + }; + } + + pub fn deinit(self: *Self) void { + imguiz.SDL_DestroyTray(self.tray); + } + + // Update the state of the tray menu entries. + // WARNING: Not thread safe. Must be called from the UI thread. + pub fn set_state(self: *Self, state: State) void { + if (std.meta.eql(self.state, state)) { + return; + } + + if (self.state.is_recording != state.is_recording) { + imguiz.SDL_SetTrayIcon(self.tray, self.get_icon_surface_for_state(state.is_recording)); + imguiz.SDL_SetTrayTooltip(self.tray, get_tooltip_for_state(state.is_recording)); + } + + imguiz.SDL_SetTrayEntryEnabled(self.start_replay_buffer_entry, !state.is_recording and state.is_capturing); + imguiz.SDL_SetTrayEntryEnabled(self.stop_replay_buffer_entry, state.is_recording); + imguiz.SDL_SetTrayEntryEnabled(self.save_replay_entry, state.is_recording); + + self.state = state; + } + + fn insert_tray_entry(menu: *imguiz.SDL_TrayMenu, comptime name: [:0]const u8) !*imguiz.SDL_TrayEntry { + const tray_entry = imguiz.SDL_InsertTrayEntryAt(menu, -1, name, imguiz.SDL_TRAYENTRY_BUTTON) orelse { + log.warn("[init_sdl_tray] failed to create tray entry: " ++ name, .{}); + return error.TrayInitInsertStartRecordEntry; + }; + imguiz.SDL_SetTrayEntryEnabled(tray_entry, false); + return tray_entry; + } + + fn get_icon_surface_for_state(self: *Self, is_recording: bool) ?*imguiz.SDL_Surface { + return if (is_recording) + self.app_icon.app_icon_surface_red + else + self.app_icon.app_icon_surface_blue; + } + + fn get_tooltip_for_state(is_recording: bool) [*c]const u8 { + return if (is_recording) + APP_ICON_RECORDING_TOOLTIP + else + APP_ICON_TOOLTIP; + } + + fn start_record_callback(userdata: ?*anyopaque, _: ?*imguiz.SDL_TrayEntry) callconv(.c) void { + assert(userdata != null); + const actor: *Actor = @ptrCast(@alignCast(userdata)); + actor.dispatch(.start_record) catch unreachable; + } + + fn stop_record_callback(userdata: ?*anyopaque, _: ?*imguiz.SDL_TrayEntry) callconv(.c) void { + assert(userdata != null); + const actor: *Actor = @ptrCast(@alignCast(userdata)); + actor.dispatch(.stop_record) catch unreachable; + } + + fn save_replay_callback(userdata: ?*anyopaque, _: ?*imguiz.SDL_TrayEntry) callconv(.c) void { + assert(userdata != null); + const actor: *Actor = @ptrCast(@alignCast(userdata)); + actor.dispatch(.save_replay) catch unreachable; + } + + fn quit_callback(_: ?*anyopaque, _: ?*imguiz.SDL_TrayEntry) callconv(.c) void { + var event: imguiz.SDL_Event = std.mem.zeroes(imguiz.SDL_Event); + event.type = imguiz.SDL_EVENT_QUIT; + if (!imguiz.SDL_PushEvent(&event)) { + log.warn("[quit_callback] failed to push quit event: {s}", .{imguiz.SDL_GetError()}); + } + } +}; diff --git a/src/ui/ui.zig b/src/ui/ui.zig index 1b9eb05..445d760 100644 --- a/src/ui/ui.zig +++ b/src/ui/ui.zig @@ -4,6 +4,7 @@ const c = @import("imguiz").imguiz; const vk = @import("vulkan"); const rc = @import("zigrc"); const sdl = @import("./sdl.zig"); +const Tray = @import("./tray.zig").Tray; const VulkanCapturePreviewTexture = @import("../vulkan/vulkan_capture_preview_texture.zig").VulkanCapturePreviewTexture; const Actor = @import("../state/actor.zig").Actor; @@ -13,6 +14,7 @@ const draw_left_column = @import("./draw_left_column.zig").draw_left_column; const draw_video_preview = @import("./draw_video_preview.zig").draw_video_preview; const VulkanImageBuffer = @import("../vulkan/vulkan_image_buffer.zig").VulkanImageBuffer; const WaylandPresentGate = @import("./wayland_present_gate.zig").WaylandPresentGate; +const AppIcon = @import("./app_icon.zig").AppIcon; // TODO: save and restore window size const WIDTH = 1600; @@ -30,12 +32,15 @@ pub const UI = struct { allocator: std.mem.Allocator, window: ?*c.struct_SDL_Window = null, + window_icon_surface: ?*c.SDL_Surface = null, + tray: ?Tray = null, surface: ?c.VkSurfaceKHR = null, descriptor_pool: ?vk.DescriptorPool = null, swapchain_rebuild: bool = false, wayland_present_gate: ?WaylandPresentGate = null, + app_icon: AppIcon, - /// Init SDL and return new UI instance + /// Init SDL and return new UI instance. pub fn init( allocator: std.mem.Allocator, actor: *Actor, @@ -48,6 +53,7 @@ pub const UI = struct { .allocator = allocator, .actor = actor, .vulkan = vulkan, + .app_icon = .init(), }; try sdl.init(); @@ -70,6 +76,7 @@ pub const UI = struct { } } + self.app_icon.deinit(); c.cImGui_ImplVulkan_Shutdown(); c.cImGui_ImplSDL3_Shutdown(); if (self.wayland_present_gate) |*wayland_present_gate| { @@ -90,19 +97,26 @@ pub const UI = struct { self.vulkan.window = null; } - // Seems like destroying the vulkan window destroys the surface? - // if (self.surface) |surface| { - // c.SDL_Vulkan_DestroySurface(self.vkInstance(), surface, null); - // } + if (self.surface) |surface| { + c.SDL_Vulkan_DestroySurface(self.vk_instance(), surface, null); + self.surface = null; + } if (self.window) |window| { c.SDL_DestroyWindow(window); } + if (self.tray) |*tray| { + tray.deinit(); + } + if (self.window_icon_surface) |icon_surface| { + c.SDL_DestroySurface(icon_surface); + } c.SDL_Quit(); self.allocator.destroy(self); } + // TODO: Split off the main loop into its own method. fn init_vulkan(self: *Self) !void { self.window = c.SDL_CreateWindow("Spacecap", WIDTH, HEIGHT, c.SDL_WINDOW_VULKAN | c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_HIGH_PIXEL_DENSITY); if (self.window == null) return error.SDLCreateWindowFailure; @@ -112,6 +126,20 @@ pub const UI = struct { } } + if (!c.SDL_SetWindowIcon(self.window.?, self.app_icon.app_icon_surface_blue)) { + log.warn("[init_vulkan] failed to set window icon: {s}", .{c.SDL_GetError()}); + } + + // Just log the error. Should still run without the tray. + self.tray = Tray.init(self.actor, &self.app_icon) catch |err| blk: { + log.err("[init_vulkan] unable to initialize tray: {}", .{err}); + break :blk null; + }; + errdefer { + if (self.tray) |*tray| tray.deinit(); + self.tray = null; + } + var surface: c.VkSurfaceKHR = undefined; if (!c.SDL_Vulkan_CreateSurface(self.window, self.vk_instance(), null, &surface)) { @@ -196,13 +224,13 @@ pub const UI = struct { init_info.Queue = self.vk_queue(); init_info.PipelineCache = g_PipelineCache; // TODO: maybe need? init_info.DescriptorPool = self.vk_descriptor_pool(); - init_info.RenderPass = self.vulkan.window.?.RenderPass; - init_info.Subpass = 0; init_info.MinImageCount = MIN_IMAGE_COUNT; init_info.ImageCount = self.vulkan.window.?.ImageCount; - init_info.MSAASamples = c.VK_SAMPLE_COUNT_1_BIT; init_info.Allocator = null; init_info.CheckVkResultFn = check_vk_result; + init_info.PipelineInfoMain.RenderPass = self.vulkan.window.?.RenderPass; + init_info.PipelineInfoMain.Subpass = 0; + init_info.PipelineInfoMain.MSAASamples = c.VK_SAMPLE_COUNT_1_BIT; if (!c.cImGui_ImplVulkan_Init(&init_info)) { return error.ImGuiVulkanInitFailure; } @@ -228,6 +256,7 @@ pub const UI = struct { .GlyphMaxAdvanceX = std.math.floatMax(f32), .RasterizerMultiply = 1.0, .RasterizerDensity = 1.0, + .ExtraSizeScale = 1.0, }; const font = c.ImFontAtlas_AddFontFromMemoryTTF( @@ -277,6 +306,15 @@ pub const UI = struct { wayland_present_gate.dispatch_pending(); } + if (self.tray) |*tray| { + self.actor.ui_mutex.lock(); + defer self.actor.ui_mutex.unlock(); + tray.set_state(.{ + .is_recording = self.actor.state.is_recording_video, + .is_capturing = self.actor.state.is_capturing_video, + }); + } + // Resize swap chain? var fb_width: i32 = undefined; var fb_height: i32 = undefined; @@ -311,6 +349,7 @@ pub const UI = struct { fb_width, fb_height, MIN_IMAGE_COUNT, + c.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT, ); self.vulkan.window.?.FrameIndex = 0; self.swapchain_rebuild = false; @@ -548,7 +587,16 @@ pub const UI = struct { fn setup_vulkan_window(self: *Self) !void { self.vulkan.window = .{ .Surface = self.surface.?, - .ClearEnable = true, + .AttachmentDesc = .{ + .format = c.VK_FORMAT_UNDEFINED, + .samples = c.VK_SAMPLE_COUNT_1_BIT, + .loadOp = c.VK_ATTACHMENT_LOAD_OP_CLEAR, + .storeOp = c.VK_ATTACHMENT_STORE_OP_STORE, + .stencilLoadOp = c.VK_ATTACHMENT_LOAD_OP_DONT_CARE, + .stencilStoreOp = c.VK_ATTACHMENT_STORE_OP_DONT_CARE, + .initialLayout = c.VK_IMAGE_LAYOUT_UNDEFINED, + .finalLayout = c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, + }, }; // Check for WSI support @@ -606,6 +654,7 @@ pub const UI = struct { fb_width, fb_height, MIN_IMAGE_COUNT, + c.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT, ); } }