diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..21f0d5f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +on: + pull_request: + workflow_dispatch: + push: + branches: + - main + - master + tags: + - v?[0-9]+.[0-9]+.[0-9]+* + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + DeterminateCI: + uses: DeterminateSystems/ci/.github/workflows/workflow.yml@main + permissions: + id-token: write + contents: read diff --git a/build.zig.zon b/build.zig.zon index 347813f..4b24ccd 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -4,18 +4,18 @@ .version = "0.1.0", .minimum_zig_version = "0.16.0", .dependencies = .{ - .lattice = .{ - .url = "git+https://github.com/Midstall/lattice#dc7ab4610a0b77167234af43e85b82ae99b63c15", - .hash = "lattice-0.1.0-TdtxHONlCQD42nhei2aw6kuqs7vRnA_mu4V_KvMDszjf", - }, .prism = .{ - .url = "git+https://github.com/Midstall/prism#6b14a3e72bf05427e8ff75335f0206d079c508f0", - .hash = "prism-0.1.0-PSE12I20OAC4gr2XF_0RgEyXa0N0NvzYZyUsXI2VRkhR", + .url = "git+https://github.com/Midstall/prism#d50303f2302c161ff3e9fadbb24f990248fe1e0f", + .hash = "prism-0.1.0-PSE12N28OACyQoAXy3yrAXyMxthCSa6ur1TgjVpCL0c_", }, .webidl = .{ .url = "git+https://github.com/Midstall/webidl.zig#4b0d3c61d5f22e6072e9eacf03174e4e8393c6a2", .hash = "webidl_zig-0.1.0-FrIyV_uUBgAGtNwP-OkLegPJMWUnpeKuPqDdVbWyC9Fn", }, + .lattice = .{ + .url = "git+https://github.com/Midstall/lattice#9f0a9b128dcf012e3760f440e4f5cd9562ec8752", + .hash = "lattice-0.1.0-TdtxHEviCQCoi6o8R_6d05KlMo2iVFGryVFpGbDEW81-", + }, }, .paths = .{ "build", "build.zig", "build.zig.zon", "lib" }, } diff --git a/flake.nix b/flake.nix index a9a2683..fae1041 100644 --- a/flake.nix +++ b/flake.nix @@ -80,7 +80,7 @@ zigDeps = pkgs.zig.fetchDeps { inherit (finalAttrs) src pname version; - hash = "sha256-fVebraG0knvhYlTEZHH6ldCeVbB1KC1AR9LnBKD/L9c="; + hash = "sha256-8xkDtB/nAGOqPLghu4toAtQY0zEY8pzxWiwnbz99tfM="; }; nativeBuildInputs = with pkgs; [ diff --git a/lib/phantom/window.zig b/lib/phantom/window.zig index 3f29965..b558b6d 100644 --- a/lib/phantom/window.zig +++ b/lib/phantom/window.zig @@ -48,6 +48,37 @@ pub fn available(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process return false; } +/// Fold a lattice key event into the shared phantom key event. lattice has +/// already run the keymap: the keysym arrives as the X11 number input.Keysym +/// carries, so the conversion is a bit cast and not a table. The enum is +/// non-exhaustive, so any u32 lattice can send is a valid cast. +/// +/// Analyzed only where the compositor path builds: `lattice` is `void` +/// elsewhere, and every reference to this function sits behind that gate. +fn keyEventFromLattice(k: lattice.event.KeyEvent) phantom.input.KeyEvent { + return .{ + .keysym = @enumFromInt(k.keysym), + .text = k.text, + .mods = .{ + .shift = k.mods.shift, + .ctrl = k.mods.ctrl, + .alt = k.mods.alt, + .super = k.mods.super, + }, + .action = switch (k.state) { + .pressed => .press, + .released => .release, + }, + }; +} + +/// Route one lattice key event into the focus manager. The window path has no +/// hard quit key: closing a window is the compositor's `close_requested`, and +/// an application's own shortcuts are what `KeyboardListener` is for. +fn dispatchKey(focus_mgr: *phantom.FocusManager, k: lattice.event.KeyEvent) void { + _ = focus_mgr.dispatch(keyEventFromLattice(k)); +} + /// Where a window session draws, and how much of the process it takes over. /// /// Every default matches what `App.run` did when it was the only way in: a @@ -135,6 +166,11 @@ pub const Session = struct { canvas: phantom.Canvas, dispatcher: phantom.input.Dispatcher, + /// The focus manager owns keyboard traversal for the tree. It must outlive + /// the element tree: tearing the tree down walks every render object and + /// calls back into this to forget its focus handler, which is why `deinit` + /// takes the tree down first. Mirrors `tui.Session`. + focus_mgr: phantom.FocusManager, /// The last pointer position, in physical pixels. A button and a scroll /// report no position of their own, so both land wherever the last motion /// left the pointer. @@ -211,6 +247,14 @@ pub const Session = struct { }); const view = self.owner.activeView().?; + // The focus manager must exist before the tree does: a failed mount + // tears the partial tree down through `errdefer`, and that walk calls + // back into the manager to forget each focus handler. Mirrors + // `tui.Session.init`. + self.focus_mgr = .{}; + errdefer self.focus_mgr.deinit(gpa); + self.owner.focus = &self.focus_mgr; + var bctx = phantom.BuildContext{ .arena = self.arena.allocator(), .owner = &self.owner }; const root_widget = root.call(&bctx); var mq = phantom.MediaQuery{ .data = &view.metrics, .child = root_widget }; @@ -240,6 +284,7 @@ pub const Session = struct { pub fn deinit(self: *Session) void { self.canvas.deinit(); self.el.deinit(self.gpa); + self.focus_mgr.deinit(self.gpa); self.owner.deinit(); self.arena.deinit(); self.ctx.destroySurface(self.surface.id); @@ -269,6 +314,9 @@ pub const Session = struct { const ts = std.Io.Clock.now(.awake, self.io); self.owner.scheduler.tick(ts.nanoseconds); self.owner.flushDirty(&bctx); + // A rebuild can add or remove focusable nodes, so the traversal order is + // rebuilt from the tree rather than kept incrementally. + try self.focus_mgr.collect(self.gpa, self.el); const rt = try self.ctx.renderTarget(self.surface.id); const vp = phantom.PhysicalSize{ @@ -347,6 +395,7 @@ pub const Session = struct { @as(f32, @floatCast(a.horizontal)) * self.scale, @as(f32, @floatCast(a.vertical)) * self.scale, ), + .key => |k| dispatchKey(&self.focus_mgr, k), else => {}, }, else => {}, @@ -354,6 +403,72 @@ pub const Session = struct { } }; +test "a lattice key event becomes a phantom key event" { + if (!compositor_builds) return; + const lk = lattice.event.KeyEvent{ + .keycode = 30, + .state = .pressed, + .keysym = 'a', + .mods = .{ .shift = true }, + .text = "A", + }; + const ev = keyEventFromLattice(lk); + try std.testing.expectEqual(phantom.input.Keysym.fromCodepoint('a'), ev.keysym); + try std.testing.expectEqualStrings("A", ev.text.?); + try std.testing.expect(ev.mods.shift); + try std.testing.expect(!ev.mods.ctrl); + try std.testing.expectEqual(phantom.input.KeyAction.press, ev.action); +} + +test "a released lattice key reports the release action and keeps its keysym" { + if (!compositor_builds) return; + const lk = lattice.event.KeyEvent{ .keycode = 30, .state = .released, .keysym = 'a' }; + const ev = keyEventFromLattice(lk); + try std.testing.expectEqual(phantom.input.KeyAction.release, ev.action); + try std.testing.expectEqual(phantom.input.Keysym.fromCodepoint('a'), ev.keysym); + try std.testing.expect(ev.text == null); + try std.testing.expect(ev.mods.none()); +} + +test "a key event reaches the focused node's handler" { + if (!compositor_builds) return; + const Seen = struct { + var last: ?phantom.input.Keysym = null; + fn onKey(_: *anyopaque, ev: phantom.input.KeyEvent) bool { + last = ev.keysym; + return true; + } + }; + Seen.last = null; + + var dummy: u8 = 0; + var handlers = phantom.FocusHandlers{ .ctx = &dummy, .on_key = Seen.onKey }; + var mgr = phantom.FocusManager{}; + defer mgr.deinit(std.testing.allocator); + try mgr.order.append(std.testing.allocator, &handlers); + mgr.focusNext(); + + dispatchKey(&mgr, .{ .keycode = 30, .state = .pressed, .keysym = 'a' }); + try std.testing.expectEqual(@as(?phantom.input.Keysym, phantom.input.Keysym.fromCodepoint('a')), Seen.last); +} + +test "Tab moves the focus through the window path" { + if (!compositor_builds) return; + var dummy: u8 = 0; + var first = phantom.FocusHandlers{ .ctx = &dummy }; + var second = phantom.FocusHandlers{ .ctx = &dummy }; + var mgr = phantom.FocusManager{}; + defer mgr.deinit(std.testing.allocator); + try mgr.order.append(std.testing.allocator, &first); + try mgr.order.append(std.testing.allocator, &second); + mgr.focusNext(); + + // 0xFF09 is the X11 Tab keysym, which is what lattice reports after its + // keymap resolves the key. + dispatchKey(&mgr, .{ .keycode = 15, .state = .pressed, .keysym = 0xFF09 }); + try std.testing.expect(mgr.current == &second); +} + test "the default options open the window phantom has always opened" { const o = Options{}; try std.testing.expectEqualStrings("phantom", o.title);