From 1e55c457d959495df4d1737de212c4adbcc03541 Mon Sep 17 00:00:00 2001 From: Tristan Ross Date: Mon, 24 Aug 2026 23:18:32 -0700 Subject: [PATCH] feat: working on widgets --- build.zig.zon | 8 +- lib/phantom.zig | 8 + lib/phantom/focus.zig | 543 ++++++++++++++++- lib/phantom/icon/builtin.zig | 236 +++++++ lib/phantom/testing.zig | 142 ++++- lib/phantom/text.zig | 4 + lib/phantom/text/Font.zig | 77 +++ lib/phantom/text/layout.zig | 277 ++++++++- lib/phantom/tui.zig | 105 ++++ lib/phantom/widgets/button.zig | 15 + lib/phantom/widgets/flex.zig | 712 +++++++++++++++++++++- lib/phantom/widgets/focus.zig | 123 +++- lib/phantom/widgets/grid_view.zig | 114 +++- lib/phantom/widgets/keyboard_listener.zig | 1 + lib/phantom/widgets/scroll_view.zig | 465 +++++++++++++- lib/phantom/widgets/text.zig | 140 ++++- lib/phantom/widgets/text_field.zig | 42 ++ 17 files changed, 2954 insertions(+), 58 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 00aab11..fb7ffa8 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -17,5 +17,11 @@ .hash = "prism-0.1.0-PSE12AbIOAAMnhpTrIYz4xOe1wphrH0hdEBig6v50waz", }, }, - .paths = .{ "build", "build.zig", "build.zig.zon", "lib" }, + .paths = .{ + "build", + "build.zig", + "build.zig.zon", + "lib", + "web", + }, } diff --git a/lib/phantom.zig b/lib/phantom.zig index aa344bf..2482c4b 100644 --- a/lib/phantom.zig +++ b/lib/phantom.zig @@ -45,9 +45,12 @@ pub const widgets = struct { pub const Axis = flex.Axis; pub const MainAxisAlignment = flex.MainAxisAlignment; pub const CrossAxisAlignment = flex.CrossAxisAlignment; + pub const FlexFit = flex.FlexFit; pub const Flex = flex.Flex; pub const Column = flex.Column; pub const Row = flex.Row; + pub const Flexible = flex.Flexible; + pub const Expanded = flex.Expanded; pub const stack = @import("phantom/widgets/stack.zig"); pub const Stack = stack.Stack; pub const Positioned = stack.Positioned; @@ -71,6 +74,7 @@ pub const widgets = struct { pub const DecoratedBox = decorated_box.DecoratedBox; pub const scroll_view = @import("phantom/widgets/scroll_view.zig"); pub const ScrollView = scroll_view.ScrollView; + pub const ScrollController = scroll_view.ScrollController; pub const image_widget = @import("phantom/widgets/image.zig"); pub const Image = image_widget.Image; pub const icon_widget = @import("phantom/widgets/icon.zig"); @@ -87,8 +91,11 @@ pub const Padding = widgets.Padding; pub const ErrorBox = widgets.ErrorBox; pub const Text = widgets.Text; pub const Flex = widgets.Flex; +pub const FlexFit = widgets.FlexFit; pub const Column = widgets.Column; pub const Row = widgets.Row; +pub const Flexible = widgets.Flexible; +pub const Expanded = widgets.Expanded; pub const Axis = widgets.Axis; pub const MainAxisAlignment = widgets.MainAxisAlignment; pub const CrossAxisAlignment = widgets.CrossAxisAlignment; @@ -105,6 +112,7 @@ pub const GestureDetector = widgets.GestureDetector; pub const Button = widgets.Button; pub const DecoratedBox = widgets.DecoratedBox; pub const ScrollView = widgets.ScrollView; +pub const ScrollController = widgets.ScrollController; pub const Image = widgets.Image; pub const Icon = widgets.Icon; pub const TextField = widgets.TextField; diff --git a/lib/phantom/focus.zig b/lib/phantom/focus.zig index 45fe3d1..07b28cf 100644 --- a/lib/phantom/focus.zig +++ b/lib/phantom/focus.zig @@ -249,6 +249,379 @@ test "forget drops a listener so a later key does not reach freed memory" { try std.testing.expect(!m.dispatch(.{ .keysym = input.Keysym.fromCodepoint('q') })); } +test "focusNode jumps to a chosen node rather than the next one in the order" { + const gpa = std.testing.allocator; + var h1 = FocusHandlers{ .ctx = undefined }; + var h2 = FocusHandlers{ .ctx = undefined }; + var h3 = FocusHandlers{ .ctx = undefined }; + var m = FocusManager{}; + defer m.deinit(gpa); + try m.order.append(gpa, &h1); + try m.order.append(gpa, &h2); + try m.order.append(gpa, &h3); + m.focusNext(); // current is h1 + + try std.testing.expect(m.focusNode(&h3)); + try std.testing.expect(m.current == &h3); + // The jump must leave the traversal consistent: Tab from h3 wraps to h1. + m.focusNext(); + try std.testing.expect(m.current == &h1); +} + +test "focusNode tells the caller a node outside the order was not focused" { + const gpa = std.testing.allocator; + var h1 = FocusHandlers{ .ctx = undefined }; + var stranger = FocusHandlers{ .ctx = undefined }; + var m = FocusManager{}; + defer m.deinit(gpa); + try m.order.append(gpa, &h1); + m.focusNext(); + + try std.testing.expect(!m.focusNode(&stranger)); + // An unmounted or unavailable target must not take the focus away from h1. + try std.testing.expect(m.current == &h1); +} + +test "focusNode announces the arrival to the node it moved the focus to" { + const gpa = std.testing.allocator; + const Watch = struct { + var gained: bool = false; + fn onChange(_: *anyopaque, focused: bool) void { + if (focused) gained = true; + } + }; + Watch.gained = false; + var dummy: u8 = 0; + var h1 = FocusHandlers{ .ctx = &dummy }; + var h2 = FocusHandlers{ .ctx = &dummy, .on_focus_change = Watch.onChange }; + var m = FocusManager{}; + defer m.deinit(gpa); + try m.order.append(gpa, &h1); + try m.order.append(gpa, &h2); + m.focusNext(); // current is h1 + + try std.testing.expect(m.focusNode(&h2)); + // A button that draws a focus ring only learns it is focused from this call. + try std.testing.expect(Watch.gained); +} + +test "focusById reaches a node by name and reports the name back" { + const gpa = std.testing.allocator; + var h1 = FocusHandlers{ .ctx = undefined, .id = "prompt" }; + var h2 = FocusHandlers{ .ctx = undefined, .id = "results" }; + var m = FocusManager{}; + defer m.deinit(gpa); + try m.order.append(gpa, &h1); + try m.order.append(gpa, &h2); + + try std.testing.expect(m.focusById("results")); + try std.testing.expect(m.current == &h2); + try std.testing.expectEqualStrings("results", m.focusedId().?); +} + +test "focusById matches on the id text and not on the slice address" { + const gpa = std.testing.allocator; + var h1 = FocusHandlers{ .ctx = undefined, .id = "prompt" }; + var m = FocusManager{}; + defer m.deinit(gpa); + try m.order.append(gpa, &h1); + + // A caller builds the id at runtime, so it is a different slice with the same + // bytes. Comparing addresses would silently never match. + var built: [6]u8 = "prompt".*; + try std.testing.expect(m.focusById(&built)); + try std.testing.expect(m.current == &h1); +} + +test "focusById leaves the focus where it is when no node carries the id" { + const gpa = std.testing.allocator; + var h1 = FocusHandlers{ .ctx = undefined, .id = "prompt" }; + var h2 = FocusHandlers{ .ctx = undefined }; + var m = FocusManager{}; + defer m.deinit(gpa); + try m.order.append(gpa, &h1); + try m.order.append(gpa, &h2); + m.focusNext(); // current is h1 + + try std.testing.expect(!m.focusById("missing")); + try std.testing.expect(m.current == &h1); + // A node with no id must not answer to an empty name either. + try std.testing.expect(!m.focusById("")); + try std.testing.expect(m.current == &h1); +} + +test "focusById takes the first of two nodes that share an id" { + const gpa = std.testing.allocator; + var h1 = FocusHandlers{ .ctx = undefined, .id = "row" }; + var h2 = FocusHandlers{ .ctx = undefined, .id = "row" }; + var m = FocusManager{}; + defer m.deinit(gpa); + try m.order.append(gpa, &h1); + try m.order.append(gpa, &h2); + + try std.testing.expect(m.focusById("row")); + try std.testing.expect(m.current == &h1); +} + +test "currentNode hands back the render object under the focused node" { + const gpa = std.testing.allocator; + var ro = RenderObject{ .layoutFn = noLayout, .paintFn = noPaint }; + var h1 = FocusHandlers{ .ctx = undefined, .node = &ro }; + var h2 = FocusHandlers{ .ctx = undefined }; + var m = FocusManager{}; + defer m.deinit(gpa); + try m.order.append(gpa, &h1); + try m.order.append(gpa, &h2); + + try std.testing.expect(m.currentNode() == null); // nothing focused yet + m.focusNext(); + try std.testing.expect(m.currentNode() == &ro); + m.focusNext(); // h2 supplied no render object + try std.testing.expect(m.currentNode() == null); +} + +test "collect names the nearest focusable ancestor of every node" { + const gpa = std.testing.allocator; + var outer = FocusHandlers{ .ctx = undefined }; + var inner = FocusHandlers{ .ctx = undefined }; + var m = FocusManager{}; + defer m.deinit(gpa); + + // testTree chains the elements, so outer encloses inner. + var tree = try testTree(gpa, &.{ &outer, &inner }); + defer tree.deinit(gpa); + try m.collect(gpa, tree.root); + + try std.testing.expect(outer.parent == null); + try std.testing.expect(inner.parent == &outer); +} + +test "collect skips an unavailable node when it names an ancestor" { + const gpa = std.testing.allocator; + const Gone = struct { + fn no(_: *anyopaque) bool { + return false; + } + }; + var dummy: u8 = 0; + var outer = FocusHandlers{ .ctx = &dummy }; + var middle = FocusHandlers{ .ctx = &dummy, .available = Gone.no }; + var inner = FocusHandlers{ .ctx = &dummy }; + var m = FocusManager{}; + defer m.deinit(gpa); + + var tree = try testTree(gpa, &.{ &outer, &middle, &inner }); + defer tree.deinit(gpa); + try m.collect(gpa, tree.root); + + // A disabled node is not in the order, so a key must not bubble into it. The + // link has to skip past it to the nearest node the manager still holds. + try std.testing.expectEqual(@as(usize, 2), m.order.items.len); + try std.testing.expect(inner.parent == &outer); +} + +test "a key the focused node refuses reaches its focusable ancestor" { + const gpa = std.testing.allocator; + const Ancestor = struct { + var saw: ?input.Keysym = null; + fn onKey(_: *anyopaque, ev: input.KeyEvent) bool { + saw = ev.keysym; + return true; + } + }; + const Picky = struct { + fn onKey(_: *anyopaque, _: input.KeyEvent) bool { + return false; + } + }; + Ancestor.saw = null; + var dummy: u8 = 0; + var outer = FocusHandlers{ .ctx = &dummy, .on_key = Ancestor.onKey }; + var inner = FocusHandlers{ .ctx = &dummy, .on_key = Picky.onKey }; + var m = FocusManager{}; + defer m.deinit(gpa); + + var tree = try testTree(gpa, &.{ &outer, &inner }); + defer tree.deinit(gpa); + try m.collect(gpa, tree.root); + try std.testing.expect(m.focusNode(&inner)); + + // This is a text field inside a scroll view: the field ignores Page Down and + // the view around it scrolls. + try std.testing.expect(m.dispatch(.{ .keysym = .page_down })); + try std.testing.expectEqual(@as(?input.Keysym, .page_down), Ancestor.saw); + // Bubbling must not move the focus. + try std.testing.expect(m.current == &inner); +} + +test "a key the focused node uses never reaches its ancestor" { + const gpa = std.testing.allocator; + const Ancestor = struct { + var fired = false; + fn onKey(_: *anyopaque, _: input.KeyEvent) bool { + fired = true; + return true; + } + }; + const Greedy = struct { + fn onKey(_: *anyopaque, _: input.KeyEvent) bool { + return true; + } + }; + Ancestor.fired = false; + var dummy: u8 = 0; + var outer = FocusHandlers{ .ctx = &dummy, .on_key = Ancestor.onKey }; + var inner = FocusHandlers{ .ctx = &dummy, .on_key = Greedy.onKey }; + var m = FocusManager{}; + defer m.deinit(gpa); + + var tree = try testTree(gpa, &.{ &outer, &inner }); + defer tree.deinit(gpa); + try m.collect(gpa, tree.root); + try std.testing.expect(m.focusNode(&inner)); + + try std.testing.expect(m.dispatch(.{ .keysym = .page_down })); + try std.testing.expect(!Ancestor.fired); +} + +test "Tab traverses instead of bubbling, so an ancestor cannot trap the user" { + const gpa = std.testing.allocator; + const Ancestor = struct { + var fired = false; + fn onKey(_: *anyopaque, _: input.KeyEvent) bool { + fired = true; + return true; // would swallow every key it is offered + } + }; + Ancestor.fired = false; + var dummy: u8 = 0; + var outer = FocusHandlers{ .ctx = &dummy, .on_key = Ancestor.onKey }; + var inner = FocusHandlers{ .ctx = &dummy }; + var m = FocusManager{}; + defer m.deinit(gpa); + + var tree = try testTree(gpa, &.{ &outer, &inner }); + defer tree.deinit(gpa); + try m.collect(gpa, tree.root); + try std.testing.expect(m.focusNode(&inner)); + + try std.testing.expect(m.dispatch(.{ .keysym = .tab })); + try std.testing.expect(!Ancestor.fired); + // Tab wrapped from the last node back to the first. + try std.testing.expect(m.current == &outer); +} + +test "bubbling stops at the first ancestor that uses the key" { + const gpa = std.testing.allocator; + const Log = struct { + var order: [3]u8 = .{ 0, 0, 0 }; + var next: usize = 0; + fn record(tag: u8, use: bool) bool { + order[next] = tag; + next += 1; + return use; + } + fn top(_: *anyopaque, _: input.KeyEvent) bool { + return record('t', true); + } + fn middle(_: *anyopaque, _: input.KeyEvent) bool { + return record('m', true); + } + fn leaf(_: *anyopaque, _: input.KeyEvent) bool { + return record('l', false); + } + }; + Log.next = 0; + Log.order = .{ 0, 0, 0 }; + var dummy: u8 = 0; + var top = FocusHandlers{ .ctx = &dummy, .on_key = Log.top }; + var middle = FocusHandlers{ .ctx = &dummy, .on_key = Log.middle }; + var leaf = FocusHandlers{ .ctx = &dummy, .on_key = Log.leaf }; + var m = FocusManager{}; + defer m.deinit(gpa); + + var tree = try testTree(gpa, &.{ &top, &middle, &leaf }); + defer tree.deinit(gpa); + try m.collect(gpa, tree.root); + try std.testing.expect(m.focusNode(&leaf)); + + try std.testing.expect(m.dispatch(.{ .keysym = .page_down })); + // Innermost first, and the outermost ancestor never runs. + try std.testing.expectEqual(@as(usize, 2), Log.next); + try std.testing.expectEqual(@as(u8, 'l'), Log.order[0]); + try std.testing.expectEqual(@as(u8, 'm'), Log.order[1]); +} + +test "forget cuts the parent link so a key cannot bubble into a freed ancestor" { + const gpa = std.testing.allocator; + const Ancestor = struct { + var fired = false; + fn onKey(_: *anyopaque, _: input.KeyEvent) bool { + fired = true; + return true; + } + }; + Ancestor.fired = false; + var dummy: u8 = 0; + var outer = FocusHandlers{ .ctx = &dummy, .on_key = Ancestor.onKey }; + var inner = FocusHandlers{ .ctx = &dummy }; + var m = FocusManager{}; + defer m.deinit(gpa); + + var tree = try testTree(gpa, &.{ &outer, &inner }); + defer tree.deinit(gpa); + try m.collect(gpa, tree.root); + try std.testing.expect(m.focusNode(&inner)); + + // Element.deinit calls this just before the ancestor's render object is freed. + m.forget(&outer); + try std.testing.expect(inner.parent == null); + try std.testing.expect(!m.dispatch(.{ .keysym = .page_down })); + try std.testing.expect(!Ancestor.fired); +} + +test "OwnedId keeps its own copy, so the caller's buffer can be reused" { + const gpa = std.testing.allocator; + var id = OwnedId{}; + defer id.deinit(gpa); + + var scratch: [6]u8 = "prompt".*; + try id.set(gpa, &scratch); + // A frame loop resets the arena a widget config was built in. Overwriting the + // source stands in for that. + @memset(&scratch, 'z'); + try std.testing.expectEqualStrings("prompt", id.text.?); +} + +test "OwnedId reuses the copy when the id text has not changed" { + const gpa = std.testing.allocator; + var id = OwnedId{}; + defer id.deinit(gpa); + + try id.set(gpa, "prompt"); + const first = id.text.?.ptr; + var same: [6]u8 = "prompt".*; + try id.set(gpa, &same); + // A settled tree rebuilds every frame, so an allocation per frame per node is + // the difference between quiet and churning. + try std.testing.expect(id.text.?.ptr == first); + + try id.set(gpa, "results"); + try std.testing.expect(id.text.?.ptr != first); + try std.testing.expectEqualStrings("results", id.text.?); +} + +test "OwnedId releases the copy when the id is taken away" { + const gpa = std.testing.allocator; + var id = OwnedId{}; + defer id.deinit(gpa); + + try id.set(gpa, "prompt"); + try id.set(gpa, null); + // The allocator in this test reports a leak if the copy survived. + try std.testing.expect(id.text == null); +} + // Keyboard focus. The pointer path installs `PointerHandlers` on a render object and // hit tests the tree to find them. Focus is the same shape with a list instead of a // hit test: the order is the pre-order walk of the element tree, which is the order @@ -265,8 +638,9 @@ const RenderObject = render_object.RenderObject; /// `PointerHandlers` carries one. pub const FocusHandlers = struct { ctx: *anyopaque, - /// Returns true when the key was used. An unused key travels no further, because - /// there is no bubbling in this slice. + /// Returns true when the key was used. A key this handler refuses continues + /// through the rest of `dispatch`, which ends at the focusable ancestors and the + /// shortcut listeners. on_key: ?*const fn (ctx: *anyopaque, ev: input.KeyEvent) bool = null, on_focus_change: ?*const fn (ctx: *anyopaque, focused: bool) void = null, /// Returns false when this handler is temporarily unavailable, for example a @@ -276,6 +650,50 @@ pub const FocusHandlers = struct { /// bookkeeping: the next collect just sees the new answer. Shared by `order` and /// `listeners`, so a future disabled `TextField` uses the same field. available: ?*const fn (ctx: *anyopaque) bool = null, + /// The name an application moves the focus to this node by. Null leaves the node + /// reachable through Tab only. The slice must stay valid for as long as the + /// handlers do, so a widget that takes an id from its config keeps a copy of it + /// in `OwnedId`: a config is rebuilt from a scratch arena that the frame loop + /// resets before the next key arrives. + id: ?[]const u8 = null, + /// The render object these handlers sit on. It turns the focused node into a + /// rectangle, which is what `ScrollController.showChild` needs to bring the node + /// into view. Null when the installing widget did not supply it. + node: ?*RenderObject = null, + /// The nearest focusable ancestor. Filled in by `collect` from the element tree, + /// so a widget must never set it: an install that did would be overwritten by + /// the next collect anyway. `dispatch` walks this chain to offer a refused key + /// to what encloses the focused node. + parent: ?*FocusHandlers = null, +}; + +/// A focus id that a render object owns. A widget config is rebuilt every frame, +/// usually into a scratch arena that the frame loop resets, so handlers that +/// borrowed the config's slice would read freed memory on the next key. The copy is +/// replaced only when the id text changes, which leaves a settled tree allocating +/// nothing per frame. +pub const OwnedId = struct { + text: ?[]const u8 = null, + + pub fn set(self: *OwnedId, gpa: std.mem.Allocator, id: ?[]const u8) !void { + const want = id orelse { + self.deinit(gpa); + return; + }; + if (self.text) |have| { + if (std.mem.eql(u8, have, want)) return; + } + // Copy before releasing the old text, so a failed allocation leaves the + // node with the id it already answered to instead of no id at all. + const copy = try gpa.dupe(u8, want); + self.deinit(gpa); + self.text = copy; + } + + pub fn deinit(self: *OwnedId, gpa: std.mem.Allocator) void { + if (self.text) |t| gpa.free(t); + self.text = null; + } }; pub const FocusManager = struct { @@ -298,7 +716,7 @@ pub const FocusManager = struct { pub fn collect(self: *FocusManager, gpa: std.mem.Allocator, root: *Element) !void { self.order.clearRetainingCapacity(); self.listeners.clearRetainingCapacity(); - try walk(gpa, root, &self.order, &self.listeners); + try walk(gpa, root, &self.order, &self.listeners, null); // The focused node may have been removed from the tree, or turned itself // unavailable, by the rebuild that preceded this. Either way it is gone from // `order` now. The render object is still alive here (an unmount instead goes @@ -315,13 +733,30 @@ pub const FocusManager = struct { } } - fn walk(gpa: std.mem.Allocator, el: *Element, out: *std.ArrayList(*FocusHandlers), listeners: *std.ArrayList(*FocusHandlers)) !void { + /// `enclosing` is the nearest focusable ancestor found so far. Only a node that + /// reaches `out` becomes an ancestor for the subtree below it, so every parent + /// link points at a node the manager still holds, and `forget` has one list to + /// clear a freed node out of. + fn walk( + gpa: std.mem.Allocator, + el: *Element, + out: *std.ArrayList(*FocusHandlers), + listeners: *std.ArrayList(*FocusHandlers), + enclosing: ?*FocusHandlers, + ) !void { + var inner = enclosing; if (el.render_object) |ro| { - if (ro.focus) |h| if (isAvailable(h)) try out.append(gpa, h); + if (ro.focus) |h| { + if (isAvailable(h)) { + h.parent = enclosing; + try out.append(gpa, h); + inner = h; + } + } if (ro.key_listener) |h| if (isAvailable(h)) try listeners.append(gpa, h); } - if (el.child) |c| try walk(gpa, c, out, listeners); - for (el.children.items) |c| try walk(gpa, c, out, listeners); + if (el.child) |c| try walk(gpa, c, out, listeners, inner); + for (el.children.items) |c| try walk(gpa, c, out, listeners, inner); } fn isAvailable(h: *FocusHandlers) bool { @@ -368,12 +803,86 @@ pub const FocusManager = struct { self.setCurrent(null); } - /// Route one key. The focused node sees it first, because an application must be - /// able to take a key the manager would otherwise spend: a text field needs Tab - /// to insert a tab, and a dialog needs Escape to close itself. A key the focused - /// node does not use reaches the traversal rules (Tab, Escape), and a key the - /// traversal rules decline reaches the shortcut listeners last: a `KeyboardListener` - /// never steals a key the focused node or a traversal rule wanted first. + /// Move the focus to one chosen node. Returns false when the node is not in the + /// traversal order, which is how an unmounted or unavailable node looks from + /// here, and leaves the focus where it was. Tab order position is the wrong way + /// to name a target, so this is what a click on a text field and a "focus the + /// search box" command both go through. + pub fn focusNode(self: *FocusManager, h: *FocusHandlers) bool { + if (self.indexOf(h) == null) return false; + self.setCurrent(h); + return true; + } + + /// Move the focus to the node that carries `id`. Returns false when no available + /// node carries it. An id an application repeats is a programmer error the + /// manager cannot see, so the first match in tree order wins rather than the + /// call failing: focusing the first of two search boxes is still better than + /// focusing neither. + pub fn focusById(self: *FocusManager, id: []const u8) bool { + for (self.order.items) |h| { + const have = h.id orelse continue; + if (std.mem.eql(u8, have, id)) { + self.setCurrent(h); + return true; + } + } + return false; + } + + /// The id of the focused node. Null when nothing holds the focus or the node + /// that does was given no id. + pub fn focusedId(self: *const FocusManager) ?[]const u8 { + const c = self.current orelse return null; + return c.id; + } + + /// The render object under the focused node, for a caller that needs its + /// rectangle. Null when nothing holds the focus or the installing widget + /// supplied no render object. + pub fn currentNode(self: *const FocusManager) ?*RenderObject { + const c = self.current orelse return null; + return c.node; + } + + /// Offer `ev` to each focusable ancestor of the focused node, innermost first. + /// Returns true when one of them used it. + /// + /// The chain is bounded by the length of the traversal order, because `collect` + /// only ever names an ancestor that is in that order and no node is its own + /// ancestor. The bound costs one comparison and removes any chance that a + /// corrupted link spins the key loop forever. + fn bubble(self: *FocusManager, ev: input.KeyEvent) bool { + const c = self.current orelse return false; + var next = c.parent; + var hops: usize = 0; + while (next) |anc| : (next = anc.parent) { + if (hops >= self.order.items.len) return false; + hops += 1; + if (anc.on_key) |f| { + if (f(anc.ctx, ev)) return true; + } + } + return false; + } + + /// Route one key, in four stages, and return true when a stage used it. + /// + /// 1. The focused node, because an application must be able to take a key the + /// manager would otherwise spend: a text field needs Tab to insert a tab, + /// and a dialog needs Escape to close itself. + /// 2. The traversal rules, Tab and Escape. + /// 3. The focusable ancestors of the focused node, innermost first. A key the + /// focused node refused usually belongs to what encloses it: the page keys + /// inside a text field belong to the scroll view around it, which is + /// otherwise unreachable while the field holds the focus. + /// 4. The shortcut listeners, in tree order. + /// + /// The ancestors come after the traversal rules and not before, so that no + /// ancestor can take Tab away from a user who is trying to leave. The cost is + /// that an ancestor cannot define its own meaning for Tab or Escape. Escape is + /// still swallowed by rule 2 whenever a node holds the focus, so an ancestor and + /// a listener only see Escape when nothing is focused. /// /// Returns true when the key was used. pub fn dispatch(self: *FocusManager, ev: input.KeyEvent) bool { @@ -404,6 +913,8 @@ pub const FocusManager = struct { else => {}, } + if (self.bubble(ev)) return true; + // Last: the shortcut listeners, in tree order. The first one that uses the // key ends the walk. for (self.listeners.items) |l| { @@ -423,6 +934,12 @@ pub const FocusManager = struct { if (self.current == h) self.current = null; removeAll(&self.order, h); removeAll(&self.listeners, h); + // A child of `h` outlives it whenever a subtree is rebuilt from the middle, + // so the parent links have to be cut here as well. Without this the next key + // would bubble from the surviving child into the freed ancestor. + for (self.order.items) |item| { + if (item.parent == h) item.parent = null; + } } /// Remove every occurrence of `h` from `list`. In practice `h` lives in only one diff --git a/lib/phantom/icon/builtin.zig b/lib/phantom/icon/builtin.zig index 4d9211c..99ee5ce 100644 --- a/lib/phantom/icon/builtin.zig +++ b/lib/phantom/icon/builtin.zig @@ -18,12 +18,45 @@ pub const grid_units: u16 = 24; /// one mark under another one's name. pub const Id = enum(u32) { torii = 0, + + // The interface set. Each is a centreline on the same 24 grid the torii + // uses, drawn inside a 2 unit margin so a mark never touches its own box, + // except the two rules, which are deliberately full bleed (see below). + // + // These exist because the bundled fonts do not have them. Mesmerize and + // Neuropol are display faces: every non-ASCII codepoint probed, U+2713 and + // U+2502 among them, resolves to glyph 0. In cell mode that costs nothing, + // since the terminal draws text with its own font, but pixel mode + // rasterises with the bundled faces and a tick came out as a replacement + // box. A mark phantom draws itself works in both. + check = 1, + cross = 2, + chevron_left = 3, + chevron_right = 4, + chevron_up = 5, + chevron_down = 6, + arrow_right = 7, + plus = 8, + minus = 9, + rule_vertical = 10, + rule_horizontal = 11, }; /// The centreline of `id`. pub fn pathFor(id: Id) path.Path { return switch (id) { .torii => torii, + .check => check, + .cross => cross, + .chevron_left => chevron_left, + .chevron_right => chevron_right, + .chevron_up => chevron_up, + .chevron_down => chevron_down, + .arrow_right => arrow_right, + .plus => plus, + .minus => minus, + .rule_vertical => rule_vertical, + .rule_horizontal => rule_horizontal, }; } @@ -225,3 +258,206 @@ test "the torii rasterises with solid pillars and a clear gap between them" { try std.testing.expect(cov.left >= 0 and right <= 24); try std.testing.expect(bottom >= 0 and top <= 24); } + +// --------------------------------------------------------------------------- +// The interface set +// +// Authored directly on the 24 grid rather than transcribed from a generator, so +// the numbers below ARE the geometry. Two rules hold across all of them: +// +// * y grows UPWARDS here, as it does for the torii above, because +// `text/raster.zig` flips on its way to a top-down bitmap. +// * a mark keeps a 2 unit margin, so the default 1.7 stroke has room for its +// round cap without touching the edge of the box. +// +// The exception is the two rules, which run the full height or width. A rail is +// drawn once per row and has to JOIN the one above it: a 2 unit margin would +// leave a visible gap at every row boundary, so they run 0 to 24 and take butt +// caps, which stop exactly at the boundary instead of rounding past it. + +/// A tick. Down from the left, then up to the right, with the vertex low and +/// off centre, which is what makes it read as a tick rather than a V. +const check = path.Path{ .verbs = &.{ + .{ .move = .{ .x = 5, .y = 13 } }, + .{ .line = .{ .x = 10, .y = 8 } }, + .{ .line = .{ .x = 19, .y = 18 } }, +} }; + +/// Two diagonals through the centre. +const cross = path.Path{ .verbs = &.{ + .{ .move = .{ .x = 6.5, .y = 6.5 } }, + .{ .line = .{ .x = 17.5, .y = 17.5 } }, + .{ .move = .{ .x = 17.5, .y = 6.5 } }, + .{ .line = .{ .x = 6.5, .y = 17.5 } }, +} }; + +const chevron_left = path.Path{ .verbs = &.{ + .{ .move = .{ .x = 14.5, .y = 19 } }, + .{ .line = .{ .x = 8, .y = 12 } }, + .{ .line = .{ .x = 14.5, .y = 5 } }, +} }; + +const chevron_right = path.Path{ .verbs = &.{ + .{ .move = .{ .x = 9.5, .y = 19 } }, + .{ .line = .{ .x = 16, .y = 12 } }, + .{ .line = .{ .x = 9.5, .y = 5 } }, +} }; + +const chevron_up = path.Path{ .verbs = &.{ + .{ .move = .{ .x = 5, .y = 9.5 } }, + .{ .line = .{ .x = 12, .y = 16 } }, + .{ .line = .{ .x = 19, .y = 9.5 } }, +} }; + +const chevron_down = path.Path{ .verbs = &.{ + .{ .move = .{ .x = 5, .y = 14.5 } }, + .{ .line = .{ .x = 12, .y = 8 } }, + .{ .line = .{ .x = 19, .y = 14.5 } }, +} }; + +/// Shaft and head. The head meets the shaft at its tip rather than crossing it, +/// so the join stays clean at a small size. +const arrow_right = path.Path{ .verbs = &.{ + .{ .move = .{ .x = 4, .y = 12 } }, + .{ .line = .{ .x = 19.5, .y = 12 } }, + .{ .move = .{ .x = 13.5, .y = 18 } }, + .{ .line = .{ .x = 19.5, .y = 12 } }, + .{ .line = .{ .x = 13.5, .y = 6 } }, +} }; + +const plus = path.Path{ .verbs = &.{ + .{ .move = .{ .x = 12, .y = 5 } }, + .{ .line = .{ .x = 12, .y = 19 } }, + .{ .move = .{ .x = 5, .y = 12 } }, + .{ .line = .{ .x = 19, .y = 12 } }, +} }; + +const minus = path.Path{ .verbs = &.{ + .{ .move = .{ .x = 5, .y = 12 } }, + .{ .line = .{ .x = 19, .y = 12 } }, +} }; + +/// Full bleed and butt capped, so stacking one per row draws a continuous rail +/// with no seam at the row boundaries. This is U+2502's job in a terminal, and +/// the reason it is here is that no bundled font has that glyph. +const rule_vertical = path.Path{ + .verbs = &.{ + .{ .move = .{ .x = 12, .y = 0 } }, + .{ .line = .{ .x = 12, .y = 24 } }, + }, + .stroke = .{ .cap = .butt }, +}; + +/// The same, along the other axis, for a separator that meets its neighbours. +const rule_horizontal = path.Path{ + .verbs = &.{ + .{ .move = .{ .x = 0, .y = 12 } }, + .{ .line = .{ .x = 24, .y = 12 } }, + }, + .stroke = .{ .cap = .butt }, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// The margin every mark keeps, except the two rules. See the section comment. +const margin: f32 = 2; + +const Bounds = struct { min_x: f32, min_y: f32, max_x: f32, max_y: f32 }; + +fn boundsOf(p: path.Path) Bounds { + var b = Bounds{ .min_x = grid, .min_y = grid, .max_x = 0, .max_y = 0 }; + for (p.verbs) |v| { + const pts: []const path.Point = switch (v) { + .move => |pt| &.{pt}, + .line => |pt| &.{pt}, + .quad => |q| &.{ q.ctrl, q.end }, + .cubic => |c| &.{ c.c1, c.c2, c.end }, + .close => &.{}, + }; + for (pts) |pt| { + b.min_x = @min(b.min_x, pt.x); + b.min_y = @min(b.min_y, pt.y); + b.max_x = @max(b.max_x, pt.x); + b.max_y = @max(b.max_y, pt.y); + } + } + return b; +} + +test "every built-in id has a path, and none of them is empty" { + // Exhaustive over the enum, so adding a member without a centreline fails + // here rather than drawing nothing at a call site. + inline for (@typeInfo(Id).@"enum".fields) |f| { + const id: Id = @enumFromInt(f.value); + try std.testing.expect(pathFor(id).verbs.len > 0); + } +} + +test "every built-in mark stays inside its own grid" { + inline for (@typeInfo(Id).@"enum".fields) |f| { + const b = boundsOf(pathFor(@enumFromInt(f.value))); + try std.testing.expect(b.min_x >= 0 and b.min_y >= 0); + try std.testing.expect(b.max_x <= grid and b.max_y <= grid); + } +} + +test "the interface marks keep their margin, so a round cap never touches the edge" { + // The rules are excluded on purpose: they are full bleed so that stacking + // them draws a continuous rail, which is the whole reason they exist. + inline for (@typeInfo(Id).@"enum".fields) |f| { + const id: Id = @enumFromInt(f.value); + if (id == .rule_vertical or id == .rule_horizontal or id == .torii) continue; + const b = boundsOf(pathFor(id)); + try std.testing.expect(b.min_x >= margin and b.min_y >= margin); + try std.testing.expect(b.max_x <= grid - margin and b.max_y <= grid - margin); + } +} + +test "a rule runs the full length and stops square, so stacked rules meet with no seam" { + const v = pathFor(.rule_vertical); + const vb = boundsOf(v); + try std.testing.expectEqual(@as(f32, 0), vb.min_y); + try std.testing.expectEqual(grid, vb.max_y); + // A round cap would bulge past the boundary and a gap would still show + // between rows wherever the bulge did not reach. + try std.testing.expectEqual(path.Cap.butt, v.stroke.cap); + + const h = pathFor(.rule_horizontal); + const hb = boundsOf(h); + try std.testing.expectEqual(@as(f32, 0), hb.min_x); + try std.testing.expectEqual(grid, hb.max_x); + try std.testing.expectEqual(path.Cap.butt, h.stroke.cap); +} + +test "every built-in mark rasterises to real ink, which is what a missing glyph did not" { + const gpa = std.testing.allocator; + inline for (@typeInfo(Id).@"enum".fields) |f| { + const id: Id = @enumFromInt(f.value); + var out = try stroke.expand(gpa, pathFor(id)); + defer out.deinit(gpa); + // The same call the GPU backend makes in `ensureIcon`. + var cov = try raster.rasterize(gpa, out, grid_units, 24, grid_units); + defer cov.deinit(gpa); + + var lit: usize = 0; + for (cov.pixels) |px| { + if (px > 0) lit += 1; + } + // The point of the whole set: a tick drawn by phantom puts ink on the + // surface, where U+2713 in a bundled font resolved to glyph 0. + try std.testing.expect(lit > 0); + } +} + +test "the check mark is a tick and not a V: its vertex sits left of centre" { + const b = boundsOf(pathFor(.check)); + const verbs = pathFor(.check).verbs; + try std.testing.expectEqual(@as(usize, 3), verbs.len); + const vertex = verbs[1].line; + // Lowest point of the three, and left of the middle, which is what + // distinguishes a tick from a symmetric V. + try std.testing.expectEqual(b.min_y, vertex.y); + try std.testing.expect(vertex.x < grid / 2); +} diff --git a/lib/phantom/testing.zig b/lib/phantom/testing.zig index 47c9392..a271336 100644 --- a/lib/phantom/testing.zig +++ b/lib/phantom/testing.zig @@ -15,9 +15,26 @@ pub const find = struct { } }; +/// The first element of `f`'s type, in pre-order. +/// +/// Both child links are followed. An `Element` holds a single `child` for a +/// widget that wraps one other, and a `children` list for one that holds many, +/// and this used to walk only the first: everything under a `Flex`, and so under +/// every `Column` and `Row`, was invisible to `find.byType`. A test looking +/// there got `error.FinderMatchedNothing` and read as a widget that was not +/// built, rather than as a finder that could not see it. +/// +/// A branch that finds nothing must not end the search either. The single-child +/// walk returned its own result outright, so a miss down `child` reported a miss +/// overall even when a later branch held the element. fn search(el: *Element, f: Finder) ?*Element { if (std.mem.eql(u8, el.type_name, f.type_name)) return el; - if (el.child) |c| return search(c, f); + if (el.child) |c| { + if (search(c, f)) |found| return found; + } + for (el.children.items) |c| { + if (search(c, f)) |found| return found; + } return null; } @@ -104,19 +121,41 @@ pub const Harness = struct { sink: *phantom.FaultSink, root: *Element, canvas: phantom.Canvas, + /// Owned by the harness and already wired to `owner.focus`. + /// + /// The harness owns it because the teardown ORDER matters and getting it + /// wrong aborts with no diagnostic: unmounting the tree calls `forget` on + /// the manager, so a manager that a test declared after the harness is torn + /// down first and `forget` then reads freed storage. Owning it here makes + /// the order right by construction. A test that needs the focus manager + /// uses this one rather than declaring its own. + focus: *phantom.FocusManager, dispatcher: phantom.input.Dispatcher = .{}, viewport: phantom.LogicalSize = .{ .width = 800, .height = 600 }, dpr: f32 = 1.0, strict: bool = false, pub fn deinit(self: *Harness) void { + // The tree first: unmounting it calls back into the focus manager to + // forget each render object, so the manager has to still be alive. self.root.deinit(self.gpa); + self.focus.deinit(self.gpa); self.owner.deinit(); self.canvas.deinit(); self.arena.deinit(); self.gpa.destroy(self.arena); self.gpa.destroy(self.owner); self.gpa.destroy(self.sink); + self.gpa.destroy(self.focus); + } + + /// Rebuild the focus traversal order from the current tree. + /// + /// The order is derived from the tree rather than kept incrementally, so it + /// has to be rebuilt after anything that adds or removes a focusable node. + /// `tui.Session` does this once per frame. + pub fn collectFocus(self: *Harness) !void { + try self.focus.collect(self.gpa, self.root); } pub fn pump(self: *Harness) !void { @@ -250,9 +289,12 @@ pub fn mount(gpa: std.mem.Allocator, root_widget: phantom.Widget) !Harness { sink.* = .{}; const owner = try gpa.create(phantom.BuildOwner); owner.* = .{ .gpa = gpa, .sink = sink }; + const focus = try gpa.create(phantom.FocusManager); + focus.* = .{}; + owner.focus = focus; var bctx = phantom.BuildContext{ .arena = arena.allocator(), .owner = owner }; const root = try root_widget.mount(&bctx, null); - return .{ .gpa = gpa, .arena = arena, .owner = owner, .sink = sink, .root = root, .canvas = phantom.Canvas.init(gpa) }; + return .{ .gpa = gpa, .arena = arena, .owner = owner, .sink = sink, .focus = focus, .root = root, .canvas = phantom.Canvas.init(gpa) }; } test "mount + pump the padded blue box, assert tree/layout/html" { @@ -1269,3 +1311,99 @@ test "Tier 2 terminal: a ColoredBox fills the cells it covers" { try r.expectBg(0, 0, phantom.Color.rgb(1, 0, 0)); try r.expectBg(9, 2, phantom.Color.rgb(1, 0, 0)); } + +test "find reaches a widget inside a Column, which the single-child walk could not see" { + const gpa = std.testing.allocator; + var a = phantom.ColoredBox{ .color = phantom.Color.rgb(1, 0, 0) }; + var b = phantom.Text{ .text = "inside", .size = 16 }; + const kids = [_]phantom.Widget{ a.widget(), b.widget() }; + var col = phantom.Column(.{ .children = &kids }); + + var h = try mount(gpa, col.widget()); + defer h.deinit(); + try h.pump(); + + // Anything under a Flex used to report as absent, so a test asserting on a + // widget in a Column was asserting on the finder, not on the tree. + try h.expect(find.byType(phantom.Text), .found); + try h.expect(find.byType(phantom.ColoredBox), .found); +} + +test "find keeps looking down later branches after an earlier one misses" { + const gpa = std.testing.allocator; + // The Text sits in the SECOND child. A search that returned the first + // branch's answer outright would report it missing. + var first = phantom.ColoredBox{ .color = phantom.Color.rgb(0, 1, 0) }; + var inner = phantom.Text{ .text = "second branch", .size = 16 }; + var wrapped = phantom.Padding{ .insets = .{}, .child = inner.widget() }; + const kids = [_]phantom.Widget{ first.widget(), wrapped.widget() }; + var row = phantom.Row(.{ .children = &kids }); + + var h = try mount(gpa, row.widget()); + defer h.deinit(); + try h.pump(); + try h.expect(find.byType(phantom.Text), .found); +} + +test "a widget that is genuinely absent is still reported absent" { + const gpa = std.testing.allocator; + var only = phantom.ColoredBox{ .color = phantom.Color.rgb(0, 0, 1) }; + const kids = [_]phantom.Widget{only.widget()}; + var col = phantom.Column(.{ .children = &kids }); + + var h = try mount(gpa, col.widget()); + defer h.deinit(); + try h.pump(); + // Walking more of the tree must not turn a real miss into a false hit. + try h.expect(find.byType(phantom.Text), .not_found); +} + +test "the harness tears its focus manager down after the tree, so unmounting can still forget nodes" { + const gpa = std.testing.allocator; + var inner = phantom.ColoredBox{ .color = phantom.Color.rgb(1, 1, 1) }; + var f = phantom.Focus{ .id = "only", .child = inner.widget() }; + + var h = try mount(gpa, f.widget()); + try h.pump(); + try h.collectFocus(); + // Focused, so the manager holds a pointer into the tree that is about to go. + try std.testing.expect(h.focus.focusById("only")); + + // The order is the assertion: a manager torn down first would be read by + // `forget` during the unmount below, which aborts with no diagnostic. This + // test passing IS the ordering being right. + h.deinit(); +} + +test "a Button can be focused by the name it was given" { + const gpa = std.testing.allocator; + var label = phantom.Text{ .text = "Go", .size = 16 }; + var b = phantom.Button{ .id = "go", .child = label.widget() }; + + var h = try mount(gpa, b.widget()); + defer h.deinit(); + try h.pump(); + try h.collectFocus(); + + try std.testing.expect(h.focus.focusById("go")); + try std.testing.expectEqualStrings("go", h.focus.focusedId().?); + // A name nothing answers to must not move the focus. + try std.testing.expect(!h.focus.focusById("nope")); +} + +test "a Button with no name is still focusable by traversal, just not by name" { + const gpa = std.testing.allocator; + var label = phantom.Text{ .text = "Go", .size = 16 }; + var b = phantom.Button{ .child = label.widget() }; + + var h = try mount(gpa, b.widget()); + defer h.deinit(); + try h.pump(); + try h.collectFocus(); + + try std.testing.expect(!h.focus.focusById("go")); + h.focus.focusNext(); + // Reached by traversal, and answering to no name. + try std.testing.expect(h.focus.currentNode() != null); + try std.testing.expect(h.focus.focusedId() == null); +} diff --git a/lib/phantom/text.zig b/lib/phantom/text.zig index 78b05a1..8ae8ae9 100644 --- a/lib/phantom/text.zig +++ b/lib/phantom/text.zig @@ -5,6 +5,10 @@ pub const builtin = @import("text/builtin.zig"); pub const Font = @import("text/Font.zig"); pub const Glyph = @import("text/raster.zig").Coverage; pub const mono = @import("text/mono.zig"); +/// Line layout: `layoutLine` measures and positions one run. Exported because a +/// caller that needs to break text itself, or to measure a run before drawing +/// it, otherwise cannot reach the function phantom uses for its own text. +pub const layout = @import("text/layout.zig"); test { @import("std").testing.refAllDecls(@This()); diff --git a/lib/phantom/text/Font.zig b/lib/phantom/text/Font.zig index 8b4c287..bf80e9c 100644 --- a/lib/phantom/text/Font.zig +++ b/lib/phantom/text/Font.zig @@ -87,6 +87,45 @@ pub fn advance(self: *const Font, cp: u21, px_size: f32) f32 { return adv_units * px_size / @as(f32, @floatFromInt(self.metrics.units_per_em)); } +/// The height of one line of this font at `px_size`, in those same pixels. +/// +/// Ascent above the baseline plus descent below it. This is the LINE BOX, the +/// thing a caller needs to know how many rows fit in a height, and it is not the +/// point size: a 24px font does not occupy 24px of vertical space. +/// +/// `line_gap` is deliberately left out. The gap is leading BETWEEN lines rather +/// than part of a line's own box, and `layout.layoutLine` has always measured a +/// run as ascent minus descent, so including it here would make this disagree +/// with the only other place phantom computes the same quantity. There is one +/// definition, and `layoutLine` calls this one. +/// +/// Public because a caller that fits text to a row of a FIXED height, a terminal +/// cell being the case that forced this, otherwise has to recompute +/// `(ascent - descent) * size / units_per_em` from the raw fields, which is +/// phantom's own internal sum written out a second time in somebody else's code. +pub fn lineHeight(self: *const Font, px_size: f32) f32 { + const units: f32 = @floatFromInt(@as(i32, self.metrics.ascent) - @as(i32, self.metrics.descent)); + return units * px_size / @as(f32, @floatFromInt(self.metrics.units_per_em)); +} + +/// The `px_size` whose line box is exactly `line_px` tall: the inverse of +/// `lineHeight`. +/// +/// For fitting text to a row whose height is already decided. The terminal's +/// pixel mode sizes its default text with this so one line of text occupies one +/// terminal cell, which is what makes an 80x24 terminal show 24 rows of text +/// rather than the 13 a fixed point size gave. +/// +/// Returns 0 for a font whose ascent and descent are equal, which is a font with +/// no vertical extent at all: there is no size that makes a zero-height line box +/// reach `line_px`, and 0 is the answer that draws nothing rather than one that +/// divides by zero. +pub fn sizeForLineHeight(self: *const Font, line_px: f32) f32 { + const units: f32 = @floatFromInt(@as(i32, self.metrics.ascent) - @as(i32, self.metrics.descent)); + if (units <= 0) return 0; + return line_px * @as(f32, @floatFromInt(self.metrics.units_per_em)) / units; +} + /// Return a rasterized glyph Coverage for codepoint `cp` at `px_size` pixels. /// Results are cached; subsequent calls with the same (cp, px_size) return the /// same pointer without re-rasterizing. @@ -229,3 +268,41 @@ test "a bundled font's weight always falls in the valid OS/2 usWeightClass range defer font.deinit(gpa); try std.testing.expect(font.weight() >= 100 and font.weight() <= 1000); } + +test "lineHeight is the ascent-to-descent box, and scales with the size" { + const gpa = std.testing.allocator; + var f = try @import("builtin.zig").mesmerize_rg(gpa); + defer f.deinit(gpa); + + const at16 = f.lineHeight(16); + const at32 = f.lineHeight(32); + try std.testing.expect(at16 > 0); + // Linear in the size, so twice the size is twice the box. + try std.testing.expectApproxEqRel(at16 * 2, at32, 0.0001); + // And it is NOT the point size: a line box is taller than the em it names, + // which is the whole reason a caller cannot use the size as a row height. + try std.testing.expect(at16 > 16); +} + +test "sizeForLineHeight inverts lineHeight, so text can be fitted to a fixed row" { + const gpa = std.testing.allocator; + var f = try @import("builtin.zig").mesmerize_rg(gpa); + defer f.deinit(gpa); + + for ([_]f32{ 8, 16, 18, 37, 100 }) |row| { + const size = f.sizeForLineHeight(row); + try std.testing.expectApproxEqRel(row, f.lineHeight(size), 0.0001); + } +} + +test "a run's measured height is the same number lineHeight reports" { + const gpa = std.testing.allocator; + var f = try @import("builtin.zig").mesmerize_rg(gpa); + defer f.deinit(gpa); + + // The two must agree or fitting text to a row would be arithmetic about a + // box that layout then ignores. + var line = try @import("layout.zig").layoutLine(gpa, &f, "Taps: 0", 24, .proportional); + defer line.deinit(gpa); + try std.testing.expectApproxEqRel(f.lineHeight(24), line.height, 0.0001); +} diff --git a/lib/phantom/text/layout.zig b/lib/phantom/text/layout.zig index 3d23d0c..143f173 100644 --- a/lib/phantom/text/layout.zig +++ b/lib/phantom/text/layout.zig @@ -32,7 +32,8 @@ pub fn layoutLine( ) !Line { const scale = size / @as(f32, @floatFromInt(font.metrics.units_per_em)); const font_ascent = @as(f32, @floatFromInt(font.ascent())) * scale; - const font_descent = @as(f32, @floatFromInt(font.descent())) * scale; // negative + // Only the ascent is needed on its own now: the line box comes from + // `Font.lineHeight`, so the two cannot disagree. var glyphs: std.ArrayList(dl.PositionedGlyph) = .empty; errdefer glyphs.deinit(gpa); @@ -50,7 +51,10 @@ pub fn layoutLine( .glyphs = try glyphs.toOwnedSlice(gpa), .width = pen_x, .height = switch (metrics) { - .proportional => font_ascent - font_descent, + // Through `Font.lineHeight` rather than repeating the subtraction, + // so the line box a caller can ASK for and the one a run actually + // gets are the same number by construction. + .proportional => font.lineHeight(size), .mono => |m| m.line, }, .ascent = switch (metrics) { @@ -126,3 +130,272 @@ test "proportional metrics keep the font advances unchanged" { defer line.deinit(gpa); try std.testing.expectApproxEqAbs(font.advance('A', 48), line.glyphs[1].x, 0.01); } + +/// A run of text broken into lines that each fit a width. +pub const Paragraph = struct { + lines: []Line, + /// The widest line, which is what the paragraph occupies. + width: f32, + /// The sum of the line heights, stacked with no extra leading. + height: f32, + + pub fn deinit(self: *Paragraph, gpa: std.mem.Allocator) void { + for (self.lines) |*l| l.deinit(gpa); + gpa.free(self.lines); + self.* = undefined; + } +}; + +/// The advance one codepoint contributes under `metrics`, which is the same +/// question `layoutLine` asks per glyph. Breaking has to measure with the model +/// that will draw, or a line that was measured as fitting would not. +fn advanceOf(font: *Font, cp: u21, size: f32, metrics: mono.TextMetrics) f32 { + return switch (metrics) { + .proportional => font.advance(cp, size), + .mono => |m| m.advance * @as(f32, @floatFromInt(mono.wcwidth(cp))), + }; +} + +/// Where the line starting at `from` ends, and where the next one starts. +/// +/// The two differ when the break falls on a space: the space ends the line and +/// is not carried onto the next one, so a wrapped paragraph does not begin +/// lines with a blank. +const Break = struct { end: usize, next: usize }; + +fn nextBreak( + font: *Font, + text: []const u8, + from: usize, + size: f32, + metrics: mono.TextMetrics, + max_width: f32, +) Break { + var width: f32 = 0; + var last_space: ?usize = null; + var i = from; + while (i < text.len) { + const len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; + const cp = std.unicode.utf8Decode(text[i..][0..@min(len, text.len - i)]) catch { + // Malformed input is a runtime fault, not a reason to stop laying + // out. Treat the byte as one character and keep going, which is + // what `layoutLine` does with the same input. + i += 1; + continue; + }; + if (cp == '\n') return .{ .end = i, .next = i + len }; + + const w = advanceOf(font, cp, size, metrics); + // `width > 0` keeps the line making progress: a single codepoint wider + // than the whole line still gets a line of its own, rather than looping + // for ever on a break that cannot be taken. + if (max_width > 0 and width + w > max_width and width > 0) { + if (last_space) |sp| { + const sp_len = std.unicode.utf8ByteSequenceLength(text[sp]) catch 1; + return .{ .end = sp, .next = sp + sp_len }; + } + // No space to break at, so the word is longer than the line and is + // broken between characters instead. Overflowing the box would hide + // the text under whatever is drawn next to it. + return .{ .end = i, .next = i }; + } + width += w; + if (cp == ' ') last_space = i; + i += len; + } + return .{ .end = text.len, .next = text.len }; +} + +/// Lay out `text` as lines that each fit `max_width`, breaking at spaces where +/// there is one and between characters where there is not. +/// +/// A `max_width` of zero or less does not wrap: only the line feeds in `text` +/// break it. That is the honest reading of "no width to fit into", and it is +/// what an unbounded constraint gives. +/// +/// Line feeds always break, wrapped or not. Nothing else in the text is treated +/// as markup. +/// +/// This lives here, beside `layoutLine`, because breaking has to agree with +/// measuring: it asks `advanceOf` the same question `layoutLine` asks per glyph, +/// including the wide-character rule under `.mono`. A caller that broke text +/// itself would own a second copy of that agreement. +pub fn layoutParagraph( + gpa: std.mem.Allocator, + font: *Font, + text: []const u8, + size: f32, + metrics: mono.TextMetrics, + max_width: f32, +) !Paragraph { + var lines: std.ArrayList(Line) = .empty; + errdefer { + for (lines.items) |*l| l.deinit(gpa); + lines.deinit(gpa); + } + + var pos: usize = 0; + while (true) { + const b = nextBreak(font, text, pos, size, metrics, max_width); + var line = try layoutLine(gpa, font, text[pos..b.end], size, metrics); + errdefer line.deinit(gpa); + try lines.append(gpa, line); + if (b.next >= text.len) break; + pos = b.next; + } + + var width: f32 = 0; + var height: f32 = 0; + for (lines.items) |l| { + width = @max(width, l.width); + height += l.height; + } + return .{ + .lines = try lines.toOwnedSlice(gpa), + .width = width, + .height = height, + }; +} + +test "text that fits stays on one line" { + const gpa = std.testing.allocator; + var font = try Font.load(gpa, builtin.neuropol_bytes); + defer font.deinit(gpa); + const m = mono.TextMetrics{ .mono = mono.Mono.fromCell(10, 20) }; + var p = try layoutParagraph(gpa, &font, "abc", 14, m, 100); + defer p.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), p.lines.len); + try std.testing.expectEqual(@as(f32, 30), p.width); + try std.testing.expectEqual(@as(f32, 20), p.height); +} + +test "a wrap breaks at a space, and the space does not begin the next line" { + const gpa = std.testing.allocator; + var font = try Font.load(gpa, builtin.neuropol_bytes); + defer font.deinit(gpa); + // Ten pixel columns and a fifty pixel line: five columns fit. + const m = mono.TextMetrics{ .mono = mono.Mono.fromCell(10, 20) }; + var p = try layoutParagraph(gpa, &font, "ab cd", 14, m, 50); + defer p.deinit(gpa); + // "ab cd" is exactly five columns, so it fits on one line. + try std.testing.expectEqual(@as(usize, 1), p.lines.len); + + var q = try layoutParagraph(gpa, &font, "abc def", 14, m, 50); + defer q.deinit(gpa); + try std.testing.expectEqual(@as(usize, 2), q.lines.len); + // Three glyphs on each line: the space between them belongs to neither, or + // the second line would start with a blank column. + try std.testing.expectEqual(@as(usize, 3), q.lines[0].glyphs.len); + try std.testing.expectEqual(@as(usize, 3), q.lines[1].glyphs.len); + try std.testing.expectEqual(@as(u21, 'd'), q.lines[1].glyphs[0].cp); +} + +test "a word wider than the line breaks between characters instead of overflowing" { + const gpa = std.testing.allocator; + var font = try Font.load(gpa, builtin.neuropol_bytes); + defer font.deinit(gpa); + const m = mono.TextMetrics{ .mono = mono.Mono.fromCell(10, 20) }; + var p = try layoutParagraph(gpa, &font, "abcdefgh", 14, m, 30); + defer p.deinit(gpa); + // Three columns to a line, so eight characters need three lines. Letting it + // overflow instead would hide the text under whatever is drawn beside it. + try std.testing.expectEqual(@as(usize, 3), p.lines.len); + for (p.lines) |l| try std.testing.expect(l.width <= 30); +} + +test "no line is ever wider than the width it was given" { + const gpa = std.testing.allocator; + var font = try Font.load(gpa, builtin.neuropol_bytes); + defer font.deinit(gpa); + const m = mono.TextMetrics{ .mono = mono.Mono.fromCell(9, 18) }; + const prose = "the quick brown fox jumps over the lazy dog and keeps going"; + for ([_]f32{ 27, 45, 90, 180 }) |w| { + var p = try layoutParagraph(gpa, &font, prose, 14, m, w); + defer p.deinit(gpa); + for (p.lines) |l| try std.testing.expect(l.width <= w); + try std.testing.expect(p.width <= w); + } +} + +test "a line feed breaks even where the text would have fitted" { + const gpa = std.testing.allocator; + var font = try Font.load(gpa, builtin.neuropol_bytes); + defer font.deinit(gpa); + const m = mono.TextMetrics{ .mono = mono.Mono.fromCell(10, 20) }; + var p = try layoutParagraph(gpa, &font, "a\nb", 14, m, 1000); + defer p.deinit(gpa); + try std.testing.expectEqual(@as(usize, 2), p.lines.len); + try std.testing.expectEqual(@as(u21, 'a'), p.lines[0].glyphs[0].cp); + try std.testing.expectEqual(@as(u21, 'b'), p.lines[1].glyphs[0].cp); +} + +test "a width of zero does not wrap, and only the line feeds break the text" { + const gpa = std.testing.allocator; + var font = try Font.load(gpa, builtin.neuropol_bytes); + defer font.deinit(gpa); + const m = mono.TextMetrics{ .mono = mono.Mono.fromCell(10, 20) }; + var p = try layoutParagraph(gpa, &font, "a long line of words", 14, m, 0); + defer p.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), p.lines.len); + + var q = try layoutParagraph(gpa, &font, "one\ntwo", 14, m, 0); + defer q.deinit(gpa); + try std.testing.expectEqual(@as(usize, 2), q.lines.len); +} + +test "breaking counts a wide character as the two columns it will be drawn in" { + const gpa = std.testing.allocator; + var font = try Font.load(gpa, builtin.neuropol_bytes); + defer font.deinit(gpa); + const m = mono.TextMetrics{ .mono = mono.Mono.fromCell(10, 20) }; + // Three wide glyphs are six columns. A sixty pixel line holds all three; a + // fifty pixel line holds two. Counting them as one column each would put + // all three on the short line and draw past its edge. + var wide = try layoutParagraph(gpa, &font, "\u{4E00}\u{4E00}\u{4E00}", 14, m, 60); + defer wide.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), wide.lines.len); + + var narrow = try layoutParagraph(gpa, &font, "\u{4E00}\u{4E00}\u{4E00}", 14, m, 50); + defer narrow.deinit(gpa); + try std.testing.expectEqual(@as(usize, 2), narrow.lines.len); + try std.testing.expectEqual(@as(usize, 2), narrow.lines[0].glyphs.len); +} + +test "a paragraph is as tall as its lines together and as wide as its widest" { + const gpa = std.testing.allocator; + var font = try Font.load(gpa, builtin.neuropol_bytes); + defer font.deinit(gpa); + const m = mono.TextMetrics{ .mono = mono.Mono.fromCell(10, 20) }; + var p = try layoutParagraph(gpa, &font, "abc de", 14, m, 30); + defer p.deinit(gpa); + try std.testing.expectEqual(@as(usize, 2), p.lines.len); + try std.testing.expectEqual(@as(f32, 40), p.height); + // The widest line, not the sum and not the last one. + var widest: f32 = 0; + for (p.lines) |l| widest = @max(widest, l.width); + try std.testing.expectEqual(widest, p.width); +} + +test "empty text is one empty line, so it occupies a row like any other" { + const gpa = std.testing.allocator; + var font = try Font.load(gpa, builtin.neuropol_bytes); + defer font.deinit(gpa); + const m = mono.TextMetrics{ .mono = mono.Mono.fromCell(10, 20) }; + var p = try layoutParagraph(gpa, &font, "", 14, m, 100); + defer p.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), p.lines.len); + try std.testing.expectEqual(@as(usize, 0), p.lines[0].glyphs.len); + try std.testing.expectEqual(@as(f32, 20), p.height); +} + +test "proportional wrapping measures with the font, not with a fixed column" { + const gpa = std.testing.allocator; + var font = try Font.load(gpa, builtin.neuropol_bytes); + defer font.deinit(gpa); + const prose = "wrapping measured against the real advances of the face"; + const width: f32 = 200; + var p = try layoutParagraph(gpa, &font, prose, 16, .proportional, width); + defer p.deinit(gpa); + try std.testing.expect(p.lines.len > 1); + for (p.lines) |l| try std.testing.expect(l.width <= width); +} diff --git a/lib/phantom/tui.zig b/lib/phantom/tui.zig index e79892e..07b7edc 100644 --- a/lib/phantom/tui.zig +++ b/lib/phantom/tui.zig @@ -136,6 +136,31 @@ const FrameState = struct { forced: bool, }; +/// The logical text size that makes one line of the default body font occupy +/// exactly one terminal cell, or null when the mode does not want one. +/// +/// `.cells` returns null: there the mono metrics give every line the cell height +/// and ignore the size outright, so setting one would be a number nothing reads. +/// +/// `.pixels` is the case this exists for. It measures text with the font's own +/// proportional metrics, so a line is as tall as the FONT says, and the theme's +/// default of 24 logical pixels made each line about one and a half cells: an +/// 80x24 terminal held about 13 rows instead of 24. Sizing the default from the +/// cell instead makes a terminal's row count mean what it says, and it does so +/// on every terminal rather than at one assumed cell size. +/// +/// Logical, not physical, because `Text` scales by the layout scale (the dpr) +/// before measuring. Divide the physical answer back out here and the two +/// multiplications cancel at exactly one cell. +fn cellTextSize(owner: *phantom.BuildOwner, m: Mode, cell_h: f32, dpr: f32) ?f32 { + if (m == .cells) return null; + if (dpr <= 0) return null; + const body = phantom.theme.defaultTheme(owner).body_font; + const physical = body.sizeForLineHeight(cell_h); + if (physical <= 0) return null; + return physical / dpr; +} + /// Whether the next frame must be a full-screen base rather than an overlay. /// /// Split out from `renderPixels` and made pure because it is a POLICY, and the @@ -623,6 +648,13 @@ pub const Session = struct { // The mono metrics are in physical pixels, the same space the cell grid // uses. self.owner.text_metrics = textMetricsFor(self.mode, self.cell_w, self.cell_h); + // Sized from the terminal's own cell, so a row of text is a row of the + // terminal. See `cellTextSize`. Set on the owner's default theme, which + // is what a `Text` with no size of its own resolves to, so an + // application that does state a size still gets exactly that. + if (cellTextSize(&self.owner, self.mode, self.cell_h, self.dpr)) |ts| { + self.owner.default_theme.?.text_size = ts; + } // MediaQuery reports the LOGICAL size, so a widget that reads it sees // the same numbers on both machines: a fixed nominal cell height, not @@ -781,6 +813,14 @@ pub const Session = struct { // one. `mode` cannot change after startup, so this is not a fresh // decision every resize: it is the startup choice honored again. self.owner.text_metrics = textMetricsFor(self.mode, self.cell_w, self.cell_h); + // Sized from the terminal's own cell, so a row of text is a row of the + // terminal. See `cellTextSize`. Set on the owner's default theme, which + // is what a `Text` with no size of its own resolves to, so an + // application that does state a size still gets exactly that. + if (cellTextSize(&self.owner, self.mode, self.cell_h, self.dpr)) |ts| { + self.owner.default_theme.?.text_size = ts; + } + // 4. The physical viewport drives the layout constraints. self.viewport = new_size.viewport(); // 4b. Mode A's offscreen surface has to move with everything above, or @@ -2021,3 +2061,68 @@ test "the amortised cost never exceeds twice that of sending a full frame every } try std.testing.expect(sent < 2 * test_screen * frames); } + +test "cells mode wants no text size, because its metrics ignore one" { + const gpa = std.testing.allocator; + var sink = phantom.FaultSink{}; + var owner = phantom.BuildOwner{ .gpa = gpa, .sink = &sink }; + defer owner.deinit(); + try std.testing.expect(cellTextSize(&owner, .cells, 18, 1.0) == null); +} + +test "pixels mode sizes text so one line is exactly one terminal cell" { + const gpa = std.testing.allocator; + var sink = phantom.FaultSink{}; + var owner = phantom.BuildOwner{ .gpa = gpa, .sink = &sink }; + defer owner.deinit(); + + // A terminal reporting no pixel size: dpr is 1, so logical and physical are + // the same and the answer can be checked against the font directly. + const cell_h: f32 = 18; + const size = cellTextSize(&owner, .pixels, cell_h, 1.0).?; + const body = phantom.theme.defaultTheme(&owner).body_font; + try std.testing.expectApproxEqRel(cell_h, body.lineHeight(size), 0.0001); +} + +test "an 80x24 terminal holds 24 rows of default text, not 13" { + const gpa = std.testing.allocator; + var sink = phantom.FaultSink{}; + var owner = phantom.BuildOwner{ .gpa = gpa, .sink = &sink }; + defer owner.deinit(); + + // The reported geometry of a plain 80x24 terminal with an 8x18 cell. + const term_size = term_mod.Size{ .cols = 80, .rows = 24, .xpixel = 640, .ypixel = 432 }; + const cell_h = term_size.cellHeight(); + const dpr = term_size.dpr(); + const viewport = term_size.viewport(); + + const logical_size = cellTextSize(&owner, .pixels, cell_h, dpr).?; + const body = phantom.theme.defaultTheme(&owner).body_font; + // What a `Text` actually measures with: the logical size times the scale. + const rows = viewport.height / body.lineHeight(logical_size * dpr); + try std.testing.expectApproxEqRel(@as(f32, 24), rows, 0.0001); + + // And what the old fixed 24 logical pixels gave, which is the bug: a little + // over half the rows the terminal actually has. + const old_rows = viewport.height / body.lineHeight(24 * dpr); + try std.testing.expect(old_rows < 15); +} + +test "the row count comes out the same on a HiDPI terminal showing the same grid" { + const gpa = std.testing.allocator; + var sink = phantom.FaultSink{}; + var owner = phantom.BuildOwner{ .gpa = gpa, .sink = &sink }; + defer owner.deinit(); + const body = phantom.theme.defaultTheme(&owner).body_font; + + // Same 80x24 grid, one ordinary display and one HiDPI, which report very + // different cell pixels. Uniformity across terminals is the point: a layout + // that fits on one has to fit on the other. + const ordinary = term_mod.Size{ .cols = 80, .rows = 24, .xpixel = 640, .ypixel = 432 }; + const hidpi = term_mod.Size{ .cols = 80, .rows = 24, .xpixel = 1520, .ypixel = 888 }; + inline for (.{ ordinary, hidpi }) |s| { + const ts = cellTextSize(&owner, .pixels, s.cellHeight(), s.dpr()).?; + const rows = s.viewport().height / body.lineHeight(ts * s.dpr()); + try std.testing.expectApproxEqRel(@as(f32, 24), rows, 0.0001); + } +} diff --git a/lib/phantom/widgets/button.zig b/lib/phantom/widgets/button.zig index 7206440..759ad1b 100644 --- a/lib/phantom/widgets/button.zig +++ b/lib/phantom/widgets/button.zig @@ -33,6 +33,9 @@ const RenderButton = struct { hovered: bool = false, pressed: bool = false, focus_handlers: phantom.FocusHandlers = undefined, + /// The copy of the config id that `focus_handlers.id` points at. Owned, + /// because the config it comes from lives in the per-frame build arena. + id: phantom.focus.OwnedId = .{}, focused: bool = false, fn layoutFn(base: *RenderObject, c: layout.BoxConstraints) geom.PhysicalSize { @@ -74,6 +77,7 @@ const RenderButton = struct { fn destroyFn(base: *RenderObject, gpa: std.mem.Allocator) void { const self: *RenderButton = @fieldParentPtr("base", base); + self.id.deinit(gpa); gpa.destroy(self); } @@ -158,6 +162,8 @@ const RenderButton = struct { .on_key = onKey, .on_focus_change = onFocusChange, .available = isAvailable, + .node = &self.base, + .id = self.id.text, }; self.base.focus = &self.focus_handlers; } @@ -170,6 +176,8 @@ pub const Button = struct { enabled: bool = true, on_tap: ?*const fn (*anyopaque) void = null, ctx: *anyopaque = undefined, + /// A name the application can move the focus to. See `FocusManager.focusById`. + id: ?[]const u8 = null, child: Widget, const vtable = Widget.VTable{ .mount = mount, .update = update }; @@ -230,8 +238,14 @@ pub const Button = struct { .radius = 4, .border_width = 0, }; + // Before `colorsFor`, which ends by installing the handlers that read it. + ro.id.set(gpa, self.id) catch |e| { + gpa.destroy(ro); + return e; + }; self.colorsFor(bctx, parent, ro); const el = gpa.create(Element) catch |e| { + ro.id.deinit(gpa); gpa.destroy(ro); return e; }; @@ -256,6 +270,7 @@ pub const Button = struct { fn update(ptr: *const anyopaque, el: *Element, bctx: *BuildContext) anyerror!void { const self: *const Button = @ptrCast(@alignCast(ptr)); const ro: *RenderButton = @fieldParentPtr("base", el.render_object.?); + try ro.id.set(ro.gpa, self.id); self.colorsFor(bctx, el.parent, ro); const pad = bctx.new(phantom.Padding{ .insets = phantom.LogicalEdgeInsets.symmetric(24, 12), diff --git a/lib/phantom/widgets/flex.zig b/lib/phantom/widgets/flex.zig index a5dcf8e..8c9088e 100644 --- a/lib/phantom/widgets/flex.zig +++ b/lib/phantom/widgets/flex.zig @@ -1,3 +1,8 @@ +//! Children placed one after another along an axis. A child that carries a flex +//! factor (`Flexible`, `Expanded`) shares out the main-axis space the other +//! children did not take, and the main axis alignment decides where any space +//! that stays free goes. Without those two things a label and a right-pinned +//! value cannot be a Row, because both children sit at the start edge. const std = @import("std"); const phantom = @import("../../phantom.zig"); const geom = phantom.geometry; @@ -6,9 +11,26 @@ const RenderObject = phantom.RenderObject; const Canvas = phantom.Canvas; pub const Axis = enum { vertical, horizontal }; -pub const MainAxisAlignment = enum { start, center, end }; +pub const MainAxisAlignment = enum { + start, + center, + end, + /// No space before the first child or after the last one. All of it goes + /// into the gaps between children. + space_between, + /// Every child gets an equal share of space, half of it on each side, so + /// the outer edges are half as wide as the inner gaps. + space_around, + /// Every gap is the same width, the two outer ones included. + space_evenly, +}; pub const CrossAxisAlignment = enum { start, center, end }; +/// How much of its share a flexible child must take. `Expanded` uses `.tight`, +/// so the child fills the share exactly; `Flexible` uses `.loose`, so the child +/// may report a smaller size and give the rest back to the alignment. +pub const FlexFit = enum { loose, tight }; + fn mainExtent(axis: Axis, s: geom.PhysicalSize) f32 { return switch (axis) { .vertical => s.height, @@ -28,6 +50,98 @@ fn offsetFor(axis: Axis, main_pos: f32, cross_pos: f32) geom.PhysicalOffset { }; } +/// Constraints for a flexible child that was given `share` of the main axis. +fn shareConstraints(axis: Axis, share: f32, fit: FlexFit, cross_max: f32, scale: f32) layout.BoxConstraints { + const main_min: f32 = switch (fit) { + .loose => 0, + .tight => share, + }; + return switch (axis) { + .vertical => .{ .min_width = 0, .max_width = cross_max, .min_height = main_min, .max_height = share, .scale = scale }, + .horizontal => .{ .min_width = main_min, .max_width = share, .min_height = 0, .max_height = cross_max, .scale = scale }, + }; +} + +/// Where the free main-axis space goes: `leading` before the first child and +/// `between` in every gap. +pub const Spacing = struct { + leading: f32, + between: f32, +}; + +/// Split `free` main-axis space between the leading edge and the gaps, for +/// `count` children. +pub fn spacingFor(alignment: MainAxisAlignment, free: f32, count: usize) Spacing { + if (count == 0) return .{ .leading = 0, .between = 0 }; + const n: f32 = @floatFromInt(count); + // A negative `free` means the children overflowed. Sharing that out would + // make them overlap, so the space-distributing modes pack from the start + // instead. start, center and end keep the raw slack, which is the + // long-standing behaviour and pushes an overflow off the leading edge. + const gap = @max(0.0, free); + return switch (alignment) { + .start => .{ .leading = 0, .between = 0 }, + .center => .{ .leading = free / 2.0, .between = 0 }, + .end => .{ .leading = free, .between = 0 }, + // One child has no gap to sit between, so this degrades to start rather + // than dividing by zero. + .space_between => if (count == 1) + .{ .leading = 0, .between = 0 } + else + .{ .leading = 0, .between = gap / (n - 1.0) }, + .space_around => .{ .leading = gap / n / 2.0, .between = gap / n }, + .space_evenly => .{ .leading = gap / (n + 1.0), .between = gap / (n + 1.0) }, + }; +} + +/// A pass-through box that tells its parent flex how much of the leftover main +/// axis it wants. Only a `RenderFlex` reads it; anywhere else it is a plain +/// wrapper that hands its constraints to its child unchanged. +pub const RenderFlexible = struct { + base: RenderObject, + child: ?*RenderObject = null, + flex: u16, + fit: FlexFit, + + pub fn layoutFn(base: *RenderObject, c: layout.BoxConstraints) geom.PhysicalSize { + const self: *RenderFlexible = @fieldParentPtr("base", base); + const child_size = if (self.child) |ch| ch.layout(c) else geom.PhysicalSize.zero; + // constrain, not the raw child size: under a tight fit the minimum is + // the whole share, so a smaller child still reserves the space the flex + // handed out and the children after it are not pulled backwards. + return c.constrain(child_size); + } + + pub fn paintFn(base: *RenderObject, cv: *Canvas, offset: geom.PhysicalOffset) anyerror!void { + const self: *RenderFlexible = @fieldParentPtr("base", base); + if (self.child) |ch| try ch.paint(cv, offset); + } + + pub fn adopt(base: *RenderObject, child: ?*RenderObject) void { + const self: *RenderFlexible = @fieldParentPtr("base", base); + self.child = child; + } + + pub fn destroyFn(base: *RenderObject, gpa: std.mem.Allocator) void { + const self: *RenderFlexible = @fieldParentPtr("base", base); + gpa.destroy(self); + } +}; + +/// Recover a flexible child from a type-erased render object. The `type_id` tag +/// proves the concrete type, so @fieldParentPtr below is sound. A layoutFn +/// comparison would not be: release builds merge identical function bodies. +fn asFlexible(ro: *RenderObject) ?*RenderFlexible { + if (!ro.isType(RenderFlexible)) return null; + return @fieldParentPtr("base", ro); +} + +/// The flex factor of a child, or 0 when it is not flexible and therefore takes +/// its natural main-axis extent. +fn flexOf(ro: *RenderObject) u16 { + return if (asFlexible(ro)) |f| f.flex else 0; +} + pub const RenderFlex = struct { base: RenderObject, gpa: std.mem.Allocator, @@ -46,6 +160,16 @@ pub const RenderFlex = struct { if (self.sink) |s| s.report(.oom, msg); } + /// An unbounded main axis has no leftover to share out, so a flex factor + /// there cannot be honoured. The child is laid out at its natural size and + /// the mistake is named instead of quietly collapsing the child to nothing. + fn reportUnboundedFlex(self: *RenderFlex) void { + if (self.sink) |s| s.report( + .layout_overflow, + "a Flex child has a flex factor but the main axis is unbounded, it kept its natural size", + ); + } + pub fn layoutFn(base: *RenderObject, c: layout.BoxConstraints) geom.PhysicalSize { const self: *RenderFlex = @fieldParentPtr("base", base); const size = c.biggest(); @@ -77,22 +201,47 @@ pub const RenderFlex = struct { self.offsets.ensureTotalCapacity(self.gpa, self.children.items.len) catch { self.reportOom("out of memory reserving Flex child offsets"); }; - var total_main: f32 = 0; + // First pass: only the children that size themselves. A flexible child is + // held back because its share depends on what this pass leaves over. + var inflexible_main: f32 = 0; var total_cross: f32 = 0; + var total_flex: u32 = 0; for (self.children.items) |ch| { + const f = flexOf(ch); + if (f > 0 and !main_unbounded) { + total_flex += f; + continue; + } + if (f > 0) self.reportUnboundedFlex(); const cs = ch.layout(child_c); - total_main += mainExtent(self.direction, cs); + inflexible_main += mainExtent(self.direction, cs); if (crossExtent(self.direction, cs) > total_cross) total_cross = crossExtent(self.direction, cs); } // When main axis is unbounded, the flex shrinks to fit its children; otherwise // it fills the available space as before. - const main_ext = if (main_unbounded) total_main else mainExtent(self.direction, size); + const main_ext = if (main_unbounded) inflexible_main else mainExtent(self.direction, size); + + // Second pass: share what is left in proportion to the flex factors. + var total_main = inflexible_main; + if (total_flex > 0) { + // If the children that size themselves already overflowed the axis + // there is nothing left to share. A negative share would become a + // constraint no child can satisfy, so the flexible ones get zero. + const spare = @max(0.0, main_ext - inflexible_main); + const denominator: f32 = @floatFromInt(total_flex); + for (self.children.items) |ch| { + const fl = asFlexible(ch) orelse continue; + if (fl.flex == 0) continue; + const share = spare * @as(f32, @floatFromInt(fl.flex)) / denominator; + const cs = ch.layout(shareConstraints(self.direction, share, fl.fit, cross_ext_for_children, c.scale)); + total_main += mainExtent(self.direction, cs); + if (crossExtent(self.direction, cs) > total_cross) total_cross = crossExtent(self.direction, cs); + } + } + const cross_ext = if (main_unbounded) total_cross else crossExtent(self.direction, size); - var main_pos: f32 = switch (self.main) { - .start => 0, - .center => (main_ext - total_main) / 2.0, - .end => main_ext - total_main, - }; + const spacing = spacingFor(self.main, main_ext - total_main, self.children.items.len); + var main_pos: f32 = spacing.leading; for (self.children.items) |ch| { const cm = mainExtent(self.direction, ch.size); const cc = crossExtent(self.direction, ch.size); @@ -106,7 +255,7 @@ pub const RenderFlex = struct { self.offsets.append(self.gpa, offsetFor(self.direction, main_pos, cross_pos)) catch { self.reportOom("out of memory recording a Flex child offset, child not painted"); }; - main_pos += cm; + main_pos += cm + spacing.between; } return if (main_unbounded) switch (self.direction) { .vertical => .{ .width = size.width, .height = total_main }, @@ -202,6 +351,74 @@ pub const Flex = struct { } }; +/// Claims a share of the leftover main-axis space of the enclosing Flex. The +/// share is `flex` divided by the sum of every sibling's flex factor. A +/// `Flexible` inside anything other than a Flex is a transparent wrapper. +/// +/// This is a wrapper widget rather than a field on the Flex child list because +/// a child arrives as a type-erased `Widget`. A parallel array of factors would +/// have to stay in step with the children by hand, and `Stack`/`Positioned` +/// already set the precedent for a wrapper the parent downcasts. +pub const Flexible = struct { + flex: u16 = 1, + fit: FlexFit = .loose, + child: Widget, + + const vtable = Widget.VTable{ .mount = mount, .update = update }; + + pub fn widget(self: *const Flexible) Widget { + return .{ .ptr = self, .vtable = &vtable }; + } + + fn mount(ptr: *const anyopaque, bctx: *BuildContext, parent: ?*Element) anyerror!*Element { + const self: *const Flexible = @ptrCast(@alignCast(ptr)); + const gpa = bctx.owner.gpa; + const ro = try gpa.create(RenderFlexible); + ro.* = .{ + .base = .{ + .layoutFn = RenderFlexible.layoutFn, + .paintFn = RenderFlexible.paintFn, + .destroyFn = RenderFlexible.destroyFn, + .adoptChildFn = RenderFlexible.adopt, + .type_id = phantom.render_object.typeId(RenderFlexible), + }, + .flex = self.flex, + .fit = self.fit, + }; + const el = gpa.create(Element) catch |e| { + gpa.destroy(ro); + return e; + }; + el.* = .{ + .owner = bctx.owner, + .parent = parent, + .vtable = &vtable, + .type_name = @typeName(Flexible), + .render_object = &ro.base, + .depth = phantom.widget.depthOf(parent), + }; + errdefer el.deinit(gpa); + el.child = try el.updateChild(null, self.child, bctx); + ro.base.adoptChild(if (el.child) |ch| ch.renderObject() else null); + return el; + } + + fn update(ptr: *const anyopaque, el: *Element, bctx: *BuildContext) anyerror!void { + const self: *const Flexible = @ptrCast(@alignCast(ptr)); + const ro: *RenderFlexible = @fieldParentPtr("base", el.render_object.?); + ro.flex = self.flex; + ro.fit = self.fit; + el.child = try el.updateChild(el.child, self.child, bctx); + ro.base.adoptChild(if (el.child) |ch| ch.renderObject() else null); + } +}; + +/// A `Flexible` that must fill its whole share. Use it for the one region that +/// should soak up whatever the fixed-size regions did not take. +pub fn Expanded(opts: struct { flex: u16 = 1, child: Widget }) Flexible { + return .{ .flex = opts.flex, .fit = .tight, .child = opts.child }; +} + pub fn Column(opts: struct { main: MainAxisAlignment = .start, cross: CrossAxisAlignment = .start, children: []const Widget }) Flex { return .{ .direction = .vertical, .main = opts.main, .cross = opts.cross, .children = opts.children }; } @@ -227,6 +444,60 @@ const FixedBox = struct { } }; +// Test-only render object: takes the biggest size its constraints allow, the +// way ColoredBox does. Only a child that reads its constraints can show what +// share of the main axis a flex factor actually handed it. +const GreedyBox = struct { + base: RenderObject, + fn lf(_: *RenderObject, c: layout.BoxConstraints) geom.PhysicalSize { + return c.biggest(); + } + fn pf(_: *RenderObject, _: *Canvas, _: geom.PhysicalOffset) anyerror!void {} + fn make() GreedyBox { + return .{ .base = .{ .layoutFn = lf, .paintFn = pf } }; + } +}; + +// Test-only render object: wants a fixed size but obeys its constraints, so a +// tight fit can force it larger and a loose fit cannot. +const NaturalBox = struct { + base: RenderObject, + w: f32, + h: f32, + fn lf(base: *RenderObject, c: layout.BoxConstraints) geom.PhysicalSize { + const self: *NaturalBox = @fieldParentPtr("base", base); + return c.constrain(.{ .width = self.w, .height = self.h }); + } + fn pf(_: *RenderObject, _: *Canvas, _: geom.PhysicalOffset) anyerror!void {} + fn make(w: f32, h: f32) NaturalBox { + return .{ .base = .{ .layoutFn = lf, .paintFn = pf }, .w = w, .h = h }; + } +}; + +fn makeFlexible(child: *RenderObject, flex: u16, fit: FlexFit) RenderFlexible { + return .{ + .base = .{ + .layoutFn = RenderFlexible.layoutFn, + .paintFn = RenderFlexible.paintFn, + .adoptChildFn = RenderFlexible.adopt, + .type_id = phantom.render_object.typeId(RenderFlexible), + }, + .child = child, + .flex = flex, + .fit = fit, + }; +} + +fn makeFlex(gpa: std.mem.Allocator, direction: Axis, main: MainAxisAlignment) RenderFlex { + return .{ + .base = .{ .layoutFn = RenderFlex.layoutFn, .paintFn = RenderFlex.paintFn, .destroyFn = RenderFlex.destroyFn }, + .gpa = gpa, + .direction = direction, + .main = main, + .cross = .start, + }; +} + test "RenderFlex vertical start stacks children; center leads by half slack; scale flows" { const gpa = std.testing.allocator; var a = FixedBox.make(20, 30); @@ -396,3 +667,424 @@ test "Flex widget update reconciles child count (grow then shrink), leak-clean" try std.testing.expectEqual(@as(usize, 1), el.children.items.len); try std.testing.expectEqual(@as(usize, 1), rf.children.items.len); } + +test "a child that fills its constraints takes the whole main extent, so two of them do not share the Row" { + // This is the gap flex factors close. The main-axis constraint a Flex hands + // a child is loose, so a child that sizes itself keeps its natural extent, + // but a child that fills (ColoredBox and everything built on it) swallows + // the whole axis and pushes the next child clean off the end. + const gpa = std.testing.allocator; + var a = GreedyBox.make(); + var b = GreedyBox.make(); + var rf = makeFlex(gpa, .horizontal, .start); + defer { + rf.children.deinit(gpa); + rf.offsets.deinit(gpa); + } + try rf.children.append(gpa, &a.base); + try rf.children.append(gpa, &b.base); + + _ = rf.base.layout(layout.BoxConstraints.tight(.{ .width = 400, .height = 100 })); + + try std.testing.expectEqual(@as(f32, 400), a.base.size.width); + try std.testing.expectEqual(@as(f32, 400), rf.offsets.items[1].x); +} + +test "spacingFor puts every gap between the children for space_between and none at the edges" { + const s = spacingFor(.space_between, 300, 4); + try std.testing.expectEqual(@as(f32, 0), s.leading); + try std.testing.expectEqual(@as(f32, 100), s.between); +} + +test "spacingFor gives space_around a leading edge that is half of an inner gap" { + const s = spacingFor(.space_around, 240, 3); + try std.testing.expectEqual(@as(f32, 80), s.between); + try std.testing.expectEqual(@as(f32, 40), s.leading); +} + +test "spacingFor makes every space_evenly gap equal, outer ones included" { + const s = spacingFor(.space_evenly, 240, 3); + try std.testing.expectEqual(@as(f32, 60), s.between); + try std.testing.expectEqual(@as(f32, 60), s.leading); +} + +test "spacingFor degrades space_between to start for a single child instead of dividing by zero" { + const s = spacingFor(.space_between, 300, 1); + try std.testing.expectEqual(@as(f32, 0), s.leading); + try std.testing.expectEqual(@as(f32, 0), s.between); + // A finite gap is the point: n - 1 is zero here, so a plain division would + // have produced inf and thrown the child out of the box. + try std.testing.expect(std.math.isFinite(s.between)); +} + +test "spacingFor refuses to hand out negative space when the children overflowed" { + // Overflow makes `free` negative. Dividing that between the children would + // stack them on top of each other, which reads as a rendering bug rather + // than as an overflow. + const s = spacingFor(.space_between, -120, 3); + try std.testing.expectEqual(@as(f32, 0), s.between); + const around = spacingFor(.space_around, -120, 3); + try std.testing.expectEqual(@as(f32, 0), around.leading); + // start, center and end keep the raw slack, which is the old behaviour. + try std.testing.expectEqual(@as(f32, -120), spacingFor(.end, -120, 3).leading); +} + +test "space_between pins the last child of a Row to the trailing edge" { + // Use case: a label on the left and its value hard against the right edge. + const gpa = std.testing.allocator; + var label = FixedBox.make(60, 20); + var value = FixedBox.make(50, 20); + var rf = makeFlex(gpa, .horizontal, .space_between); + defer { + rf.children.deinit(gpa); + rf.offsets.deinit(gpa); + } + try rf.children.append(gpa, &label.base); + try rf.children.append(gpa, &value.base); + + _ = rf.base.layout(layout.BoxConstraints.tight(.{ .width = 400, .height = 20 })); + + try std.testing.expectEqual(@as(f32, 0), rf.offsets.items[0].x); + try std.testing.expectEqual(@as(f32, 350), rf.offsets.items[1].x); + // The right edge of the value meets the right edge of the row. + try std.testing.expectEqual(@as(f32, 400), rf.offsets.items[1].x + value.w); +} + +test "space_evenly leaves the same gap before, between and after three children" { + const gpa = std.testing.allocator; + var a = FixedBox.make(40, 20); + var b = FixedBox.make(40, 20); + var c = FixedBox.make(40, 20); + var rf = makeFlex(gpa, .horizontal, .space_evenly); + defer { + rf.children.deinit(gpa); + rf.offsets.deinit(gpa); + } + try rf.children.append(gpa, &a.base); + try rf.children.append(gpa, &b.base); + try rf.children.append(gpa, &c.base); + + _ = rf.base.layout(layout.BoxConstraints.tight(.{ .width = 400, .height = 20 })); + + // 400 - 3*40 = 280 of free space over four equal gaps of 70. + try std.testing.expectEqual(@as(f32, 70), rf.offsets.items[0].x); + try std.testing.expectEqual(@as(f32, 180), rf.offsets.items[1].x); + try std.testing.expectEqual(@as(f32, 290), rf.offsets.items[2].x); + try std.testing.expectEqual(@as(f32, 70), 400 - (rf.offsets.items[2].x + c.w)); +} + +test "space_around gives the outer edges half a gap and the inner ones a whole gap" { + const gpa = std.testing.allocator; + var a = FixedBox.make(40, 20); + var b = FixedBox.make(40, 20); + var rf = makeFlex(gpa, .horizontal, .space_around); + defer { + rf.children.deinit(gpa); + rf.offsets.deinit(gpa); + } + try rf.children.append(gpa, &a.base); + try rf.children.append(gpa, &b.base); + + _ = rf.base.layout(layout.BoxConstraints.tight(.{ .width = 400, .height = 20 })); + + // 400 - 80 = 320 free over two children: 160 each, half of it per side. + try std.testing.expectEqual(@as(f32, 80), rf.offsets.items[0].x); + try std.testing.expectEqual(@as(f32, 280), rf.offsets.items[1].x); + // Symmetric: the trailing margin equals the leading one, and the inner gap + // is twice either of them. This is what separates space_around from + // space_evenly, which would put 133.33 at every gap. + try std.testing.expectEqual(@as(f32, 80), 400 - (rf.offsets.items[1].x + b.w)); + try std.testing.expectEqual(@as(f32, 160), rf.offsets.items[1].x - (rf.offsets.items[0].x + a.w)); +} + +test "an Expanded child takes exactly the height the fixed children left over" { + // Use case: four regions in a Column where the transcript grows and the + // other three keep their natural height. + const gpa = std.testing.allocator; + var header = FixedBox.make(20, 30); + var transcript = GreedyBox.make(); + var flexible = makeFlexible(&transcript.base, 1, .tight); + var footer = FixedBox.make(20, 50); + var rf = makeFlex(gpa, .vertical, .start); + defer { + rf.children.deinit(gpa); + rf.offsets.deinit(gpa); + } + try rf.children.append(gpa, &header.base); + try rf.children.append(gpa, &flexible.base); + try rf.children.append(gpa, &footer.base); + + _ = rf.base.layout(layout.BoxConstraints.tight(.{ .width = 200, .height = 200 })); + + try std.testing.expectEqual(@as(f32, 120), flexible.base.size.height); // 200 - 30 - 50 + try std.testing.expectEqual(@as(f32, 0), rf.offsets.items[0].y); + try std.testing.expectEqual(@as(f32, 30), rf.offsets.items[1].y); + try std.testing.expectEqual(@as(f32, 150), rf.offsets.items[2].y); + // The three regions cover the column with nothing left over. + try std.testing.expectEqual(@as(f32, 200), rf.offsets.items[2].y + footer.h); +} + +test "two Expanded children split the leftover in proportion to their flex factors" { + const gpa = std.testing.allocator; + var big_child = GreedyBox.make(); + var small_child = GreedyBox.make(); + var big = makeFlexible(&big_child.base, 2, .tight); + var small = makeFlexible(&small_child.base, 1, .tight); + var rf = makeFlex(gpa, .vertical, .start); + defer { + rf.children.deinit(gpa); + rf.offsets.deinit(gpa); + } + try rf.children.append(gpa, &big.base); + try rf.children.append(gpa, &small.base); + + _ = rf.base.layout(layout.BoxConstraints.tight(.{ .width = 100, .height = 300 })); + + try std.testing.expectEqual(@as(f32, 200), big.base.size.height); + try std.testing.expectEqual(@as(f32, 100), small.base.size.height); + try std.testing.expectEqual(@as(f32, 200), rf.offsets.items[1].y); +} + +test "three equal Expanded children each take a third of an axis that does not divide evenly" { + // A third of 100 has no exact binary form, so this is where a distribution + // that rounds the wrong way, or that divides by the child count instead of + // the flex total, shows up. + const gpa = std.testing.allocator; + var c0 = GreedyBox.make(); + var c1 = GreedyBox.make(); + var c2 = GreedyBox.make(); + var f0 = makeFlexible(&c0.base, 1, .tight); + var f1 = makeFlexible(&c1.base, 1, .tight); + var f2 = makeFlexible(&c2.base, 1, .tight); + var rf = makeFlex(gpa, .vertical, .start); + defer { + rf.children.deinit(gpa); + rf.offsets.deinit(gpa); + } + try rf.children.append(gpa, &f0.base); + try rf.children.append(gpa, &f1.base); + try rf.children.append(gpa, &f2.base); + + _ = rf.base.layout(layout.BoxConstraints.tight(.{ .width = 10, .height = 100 })); + + const third = 100.0 / 3.0; + try std.testing.expectApproxEqAbs(third, f0.base.size.height, 1e-3); + try std.testing.expectApproxEqAbs(third, f1.base.size.height, 1e-3); + try std.testing.expectApproxEqAbs(third, f2.base.size.height, 1e-3); + // The three of them together still reach the far edge, so no visible sliver + // of the column is left unclaimed. + try std.testing.expectApproxEqAbs(@as(f32, 100), rf.offsets.items[2].y + f2.base.size.height, 1e-3); +} + +test "a tight fit forces a child up to its share while a loose fit leaves it at its natural size" { + const gpa = std.testing.allocator; + var tight_child = NaturalBox.make(20, 30); + var loose_child = NaturalBox.make(20, 30); + var tight = makeFlexible(&tight_child.base, 1, .tight); + var loose = makeFlexible(&loose_child.base, 1, .loose); + var rf = makeFlex(gpa, .vertical, .start); + defer { + rf.children.deinit(gpa); + rf.offsets.deinit(gpa); + } + try rf.children.append(gpa, &tight.base); + try rf.children.append(gpa, &loose.base); + + _ = rf.base.layout(layout.BoxConstraints.tight(.{ .width = 100, .height = 200 })); + + // Both were offered 100, but only the tight one has to take it. + try std.testing.expectEqual(@as(f32, 100), tight.base.size.height); + try std.testing.expectEqual(@as(f32, 30), loose.base.size.height); + try std.testing.expectEqual(@as(f32, 100), rf.offsets.items[1].y); +} + +test "an Expanded holds its whole share open even when the child inside ignores the constraint" { + // A child is free to report any size it likes. If the Expanded passed that + // size on, the share would shrink to fit the child and every later sibling + // would slide up the axis, which is the opposite of what Expanded promises. + const gpa = std.testing.allocator; + var stubborn = FixedBox.make(20, 30); + var flexible = makeFlexible(&stubborn.base, 1, .tight); + var after = FixedBox.make(20, 50); + var rf = makeFlex(gpa, .vertical, .start); + defer { + rf.children.deinit(gpa); + rf.offsets.deinit(gpa); + } + try rf.children.append(gpa, &flexible.base); + try rf.children.append(gpa, &after.base); + + _ = rf.base.layout(layout.BoxConstraints.tight(.{ .width = 100, .height = 200 })); + + try std.testing.expectEqual(@as(f32, 150), flexible.base.size.height); + try std.testing.expectEqual(@as(f32, 150), rf.offsets.items[1].y); +} + +test "a Flexible with a flex factor of zero keeps its natural size and claims none of the leftover" { + const gpa = std.testing.allocator; + var inner = NaturalBox.make(20, 30); + var fixed = makeFlexible(&inner.base, 0, .tight); + var after = FixedBox.make(20, 50); + var rf = makeFlex(gpa, .vertical, .start); + defer { + rf.children.deinit(gpa); + rf.offsets.deinit(gpa); + } + try rf.children.append(gpa, &fixed.base); + try rf.children.append(gpa, &after.base); + + _ = rf.base.layout(layout.BoxConstraints.tight(.{ .width = 100, .height = 200 })); + + try std.testing.expectEqual(@as(f32, 30), fixed.base.size.height); + try std.testing.expectEqual(@as(f32, 30), rf.offsets.items[1].y); +} + +test "a flex factor on an unbounded main axis reports a fault and keeps the child's natural size" { + // An unbounded axis has no leftover to divide. Silently collapsing the child + // to nothing would lose it from the frame with no reason recorded. + const gpa = std.testing.allocator; + var sink = phantom.FaultSink{}; + var inner = NaturalBox.make(20, 30); + var flexible = makeFlexible(&inner.base, 1, .tight); + var rf = makeFlex(gpa, .vertical, .start); + rf.sink = &sink; + defer { + rf.children.deinit(gpa); + rf.offsets.deinit(gpa); + } + try rf.children.append(gpa, &flexible.base); + + const size = rf.base.layout(.{ .min_width = 0, .max_width = 100, .min_height = 0, .max_height = std.math.inf(f32) }); + + try std.testing.expect(!sink.ok()); + try std.testing.expectEqual(phantom.FaultCode.layout_overflow, sink.first.?.code); + try std.testing.expectEqual(@as(f32, 30), flexible.base.size.height); + try std.testing.expectEqual(@as(f32, 30), size.height); +} + +test "a Row of a label and an Expanded value paints the value against the right edge" { + const gpa = std.testing.allocator; + var sink = phantom.FaultSink{}; + var owner = phantom.BuildOwner{ .gpa = gpa, .sink = &sink }; + defer owner.deinit(); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + var bctx = phantom.BuildContext{ .arena = arena.allocator(), .owner = &owner }; + + var label_fill = phantom.ColoredBox{ .color = phantom.Color.rgb(1, 0, 0) }; + var label = phantom.SizedBox{ .width = 60, .child = label_fill.widget() }; + var value_fill = phantom.ColoredBox{ .color = phantom.Color.rgb(0, 1, 0) }; + var value = phantom.SizedBox{ .width = 50, .child = value_fill.widget() }; + var pinned = phantom.Align{ .alignment = .center_right, .child = value.widget() }; + var grown = Expanded(.{ .child = pinned.widget() }); + const kids = [_]Widget{ label.widget(), grown.widget() }; + var row = Row(.{ .children = &kids }); + + const el = try row.widget().mount(&bctx, null); + defer el.deinit(gpa); + _ = el.renderObject().?.layout(layout.BoxConstraints.tight(.{ .width = 400, .height = 20 })); + + var canvas = Canvas.init(gpa); + defer canvas.deinit(); + try el.renderObject().?.paint(&canvas, geom.PhysicalOffset.zero); + + const label_rect = canvas.list.primitives.items[0].rrect.rect; + const value_rect = canvas.list.primitives.items[1].rrect.rect; + try std.testing.expectEqual(@as(f32, 0), label_rect.x); + try std.testing.expectEqual(@as(f32, 60), label_rect.width); + // The value is 50 wide and its right edge meets the right edge of the row. + try std.testing.expectEqual(@as(f32, 50), value_rect.width); + try std.testing.expectEqual(@as(f32, 350), value_rect.x); + try std.testing.expectEqual(@as(f32, 400), value_rect.x + value_rect.width); +} + +test "a Column of four regions gives the Expanded transcript every row the other three left" { + const gpa = std.testing.allocator; + var sink = phantom.FaultSink{}; + var owner = phantom.BuildOwner{ .gpa = gpa, .sink = &sink }; + defer owner.deinit(); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + var bctx = phantom.BuildContext{ .arena = arena.allocator(), .owner = &owner }; + + var header_fill = phantom.ColoredBox{ .color = phantom.Color.rgb(1, 0, 0) }; + var header = phantom.SizedBox{ .height = 20, .child = header_fill.widget() }; + var transcript_fill = phantom.ColoredBox{ .color = phantom.Color.rgb(0, 1, 0) }; + var transcript = Expanded(.{ .child = transcript_fill.widget() }); + var status_fill = phantom.ColoredBox{ .color = phantom.Color.rgb(0, 0, 1) }; + var status = phantom.SizedBox{ .height = 10, .child = status_fill.widget() }; + var input_fill = phantom.ColoredBox{ .color = phantom.Color.rgb(1, 1, 0) }; + var input = phantom.SizedBox{ .height = 30, .child = input_fill.widget() }; + const kids = [_]Widget{ header.widget(), transcript.widget(), status.widget(), input.widget() }; + var col = Column(.{ .children = &kids }); + + const el = try col.widget().mount(&bctx, null); + defer el.deinit(gpa); + _ = el.renderObject().?.layout(layout.BoxConstraints.tight(.{ .width = 400, .height = 200 })); + + var canvas = Canvas.init(gpa); + defer canvas.deinit(); + try el.renderObject().?.paint(&canvas, geom.PhysicalOffset.zero); + + const rects = canvas.list.primitives.items; + try std.testing.expectEqual(@as(usize, 4), rects.len); + try std.testing.expectEqual(@as(f32, 0), rects[0].rrect.rect.y); + try std.testing.expectEqual(@as(f32, 20), rects[0].rrect.rect.height); + // 200 - 20 - 10 - 30 = 140 for the transcript, and the three fixed regions + // keep the heights they asked for. + try std.testing.expectEqual(@as(f32, 20), rects[1].rrect.rect.y); + try std.testing.expectEqual(@as(f32, 140), rects[1].rrect.rect.height); + try std.testing.expectEqual(@as(f32, 160), rects[2].rrect.rect.y); + try std.testing.expectEqual(@as(f32, 10), rects[2].rrect.rect.height); + try std.testing.expectEqual(@as(f32, 170), rects[3].rrect.rect.y); + try std.testing.expectEqual(@as(f32, 30), rects[3].rrect.rect.height); + try std.testing.expectEqual(@as(f32, 200), rects[3].rrect.rect.y + rects[3].rrect.rect.height); +} + +test "changing a Flexible's flex factor redistributes the axis on the next layout" { + const gpa = std.testing.allocator; + var sink = phantom.FaultSink{}; + var owner = phantom.BuildOwner{ .gpa = gpa, .sink = &sink }; + defer owner.deinit(); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + var bctx = phantom.BuildContext{ .arena = arena.allocator(), .owner = &owner }; + + var fill = phantom.ColoredBox{ .color = phantom.Color.rgb(1, 0, 0) }; + var even = Flexible{ .flex = 1, .fit = .tight, .child = fill.widget() }; + const el = try even.widget().mount(&bctx, null); + defer el.deinit(gpa); + const ro: *RenderFlexible = @fieldParentPtr("base", el.render_object.?); + try std.testing.expectEqual(@as(u16, 1), ro.flex); + + var heavier = Flexible{ .flex = 3, .fit = .loose, .child = fill.widget() }; + try heavier.widget().update(el, &bctx); + try std.testing.expectEqual(@as(u16, 3), ro.flex); + try std.testing.expectEqual(FlexFit.loose, ro.fit); + // Same render object, so the flex never rebuilds the subtree for a factor change. + try std.testing.expect(el.render_object.? == &ro.base); +} + +test "a Flexible render object is tagged so only a real one is read as flexible" { + const gpa = std.testing.allocator; + var sink = phantom.FaultSink{}; + var owner = phantom.BuildOwner{ .gpa = gpa, .sink = &sink }; + defer owner.deinit(); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + var bctx = phantom.BuildContext{ .arena = arena.allocator(), .owner = &owner }; + + var fill = phantom.ColoredBox{ .color = phantom.Color.rgb(1, 0, 0) }; + var flexible = Expanded(.{ .flex = 4, .child = fill.widget() }); + const flex_el = try flexible.widget().mount(&bctx, null); + defer flex_el.deinit(gpa); + var plain = phantom.ColoredBox{ .color = phantom.Color.rgb(0, 0, 1) }; + const plain_el = try plain.widget().mount(&bctx, null); + defer plain_el.deinit(gpa); + + try std.testing.expectEqual(@as(u16, 4), flexOf(flex_el.renderObject().?)); + try std.testing.expectEqual(FlexFit.tight, asFlexible(flex_el.renderObject().?).?.fit); + // An untagged render object must never be downcast to RenderFlexible. + try std.testing.expect(asFlexible(plain_el.renderObject().?) == null); + try std.testing.expectEqual(@as(u16, 0), flexOf(plain_el.renderObject().?)); +} diff --git a/lib/phantom/widgets/focus.zig b/lib/phantom/widgets/focus.zig index 1c56c58..d39662e 100644 --- a/lib/phantom/widgets/focus.zig +++ b/lib/phantom/widgets/focus.zig @@ -22,6 +22,8 @@ const RenderFocus = struct { gpa: std.mem.Allocator, child: ?*RenderObject = null, handlers: FocusHandlers, + /// The copy of the config's id that `handlers.id` points at. + id: phantom.focus.OwnedId = .{}, // User callbacks + ctx captured from the widget config. on_key: ?*const fn (*anyopaque, input.KeyEvent) bool, on_focus_change: ?*const fn (*anyopaque, bool) void, @@ -38,6 +40,7 @@ const RenderFocus = struct { } fn destroyFn(base: *RenderObject, gpa: std.mem.Allocator) void { const self: *RenderFocus = @fieldParentPtr("base", base); + self.id.deinit(gpa); gpa.destroy(self); } fn adopt(base: *RenderObject, child: ?*RenderObject) void { @@ -63,20 +66,28 @@ pub const Focus = struct { on_key: ?*const fn (ctx: *anyopaque, ev: input.KeyEvent) bool = null, on_focus_change: ?*const fn (ctx: *anyopaque, focused: bool) void = null, ctx: *anyopaque = undefined, + /// The name `FocusManager.focusById` moves the focus here by. Null leaves the + /// node reachable through Tab only. The text is copied on mount, so a caller may + /// build it in the frame arena. Keep it unique inside one tree: the first node + /// in tree order answers to a name two nodes share. + id: ?[]const u8 = null, const vtable = Widget.VTable{ .mount = mount, .update = update }; pub fn widget(self: *const Focus) Widget { return .{ .ptr = self, .vtable = &vtable }; } - fn install(ro: *RenderFocus, self: *const Focus) void { + fn install(ro: *RenderFocus, self: *const Focus) !void { ro.on_key = self.on_key; ro.on_focus_change = self.on_focus_change; ro.user_ctx = self.ctx; + try ro.id.set(ro.gpa, self.id); ro.handlers = .{ .ctx = ro, .on_key = if (self.on_key != null) RenderFocus.keyThunk else null, .on_focus_change = if (self.on_focus_change != null) RenderFocus.focusChangeThunk else null, + .id = ro.id.text, + .node = &ro.base, }; ro.base.focus = &ro.handlers; } @@ -86,8 +97,12 @@ pub const Focus = struct { const gpa = bctx.owner.gpa; const ro = try gpa.create(RenderFocus); ro.* = .{ .base = .{ .layoutFn = RenderFocus.layoutFn, .paintFn = RenderFocus.paintFn, .destroyFn = RenderFocus.destroyFn, .adoptChildFn = RenderFocus.adopt }, .gpa = gpa, .handlers = .{ .ctx = undefined }, .on_key = null, .on_focus_change = null, .user_ctx = undefined }; - install(ro, self); + install(ro, self) catch |e| { + gpa.destroy(ro); + return e; + }; const el = gpa.create(Element) catch |e| { + ro.id.deinit(gpa); gpa.destroy(ro); return e; }; @@ -100,7 +115,7 @@ pub const Focus = struct { fn update(ptr: *const anyopaque, el: *Element, bctx: *BuildContext) anyerror!void { const self: *const Focus = @ptrCast(@alignCast(ptr)); const ro: *RenderFocus = @fieldParentPtr("base", el.render_object.?); - install(ro, self); + try install(ro, self); el.child = try el.updateChild(el.child, self.child, bctx); ro.base.adoptChild(if (el.child) |c| c.renderObject() else null); } @@ -171,6 +186,108 @@ test "two Focus widgets collect in the order they appear in the tree" { try std.testing.expect(mgr.current != one); } +test "an id reaches one named Focus widget and its keys, whatever the Tab order says" { + const gpa = std.testing.allocator; + const Seen = struct { + var first: u32 = 0; + var second: u32 = 0; + fn onFirst(_: *anyopaque, _: phantom.input.KeyEvent) bool { + first += 1; + return true; + } + fn onSecond(_: *anyopaque, _: phantom.input.KeyEvent) bool { + second += 1; + return true; + } + }; + Seen.first = 0; + Seen.second = 0; + + var leaf_a = phantom.ColoredBox{ .color = phantom.Color.rgb(1, 0, 0) }; + var leaf_b = phantom.ColoredBox{ .color = phantom.Color.rgb(0, 1, 0) }; + var prompt = Focus{ .id = "prompt", .child = leaf_a.widget(), .on_key = Seen.onFirst }; + var results = Focus{ .id = "results", .child = leaf_b.widget(), .on_key = Seen.onSecond }; + var children = [_]phantom.Widget{ prompt.widget(), results.widget() }; + var column = phantom.Column(.{ .children = &children }); + var mgr = phantom.FocusManager{}; + defer mgr.deinit(gpa); + var h = try testing.mount(gpa, column.widget()); + defer h.deinit(); + h.owner.focus = &mgr; + try mgr.collect(gpa, h.root); + + // "results" is second in the Tab order, so a click on it cannot be expressed as + // a number of Tab presses without the application counting the tree itself. + try std.testing.expect(mgr.focusById("results")); + _ = mgr.dispatch(.{ .keysym = phantom.input.Keysym.fromCodepoint('x') }); + try std.testing.expectEqual(@as(u32, 0), Seen.first); + try std.testing.expectEqual(@as(u32, 1), Seen.second); + + try std.testing.expect(mgr.focusById("prompt")); + _ = mgr.dispatch(.{ .keysym = phantom.input.Keysym.fromCodepoint('x') }); + try std.testing.expectEqual(@as(u32, 1), Seen.first); + try std.testing.expectEqual(@as(u32, 1), Seen.second); +} + +test "a Focus widget copies its id, so an id built for one frame still answers later" { + const gpa = std.testing.allocator; + var scratch: [16]u8 = undefined; + const built = try std.fmt.bufPrint(&scratch, "row-{d}", .{3}); + + var leaf = phantom.ColoredBox{ .color = phantom.Color.rgb(0, 0, 1) }; + var f = Focus{ .id = built, .child = leaf.widget() }; + var mgr = phantom.FocusManager{}; + defer mgr.deinit(gpa); + var h = try testing.mount(gpa, f.widget()); + defer h.deinit(); + h.owner.focus = &mgr; + try mgr.collect(gpa, h.root); + + // The frame loop resets the arena a config was built in before the next key + // arrives. Overwriting the source stands in for that. + @memset(&scratch, 'z'); + try std.testing.expect(mgr.focusById("row-3")); + try std.testing.expectEqualStrings("row-3", mgr.focusedId().?); +} + +test "currentNode on a focused Focus widget is that widget's own render object" { + const gpa = std.testing.allocator; + var leaf = phantom.ColoredBox{ .color = phantom.Color.rgb(0, 0, 1) }; + var f = Focus{ .id = "only", .child = leaf.widget() }; + var mgr = phantom.FocusManager{}; + defer mgr.deinit(gpa); + var h = try testing.mount(gpa, f.widget()); + defer h.deinit(); + h.owner.focus = &mgr; + try mgr.collect(gpa, h.root); + + try std.testing.expect(mgr.focusById("only")); + // This is what `ScrollController.showChild` is handed to bring the focused node + // into view, so it has to be the node's own render object and not its child's. + try std.testing.expect(mgr.currentNode() == h.root.render_object.?); +} + +test "a rebuilt Focus widget answers to its new id and no longer to the old one" { + const gpa = std.testing.allocator; + var leaf = phantom.ColoredBox{ .color = phantom.Color.rgb(0, 0, 1) }; + var before = Focus{ .id = "old", .child = leaf.widget() }; + var mgr = phantom.FocusManager{}; + defer mgr.deinit(gpa); + var h = try testing.mount(gpa, before.widget()); + defer h.deinit(); + h.owner.focus = &mgr; + try mgr.collect(gpa, h.root); + try std.testing.expect(mgr.focusById("old")); + + var bctx = phantom.BuildContext{ .arena = h.arena.allocator(), .owner = h.owner }; + var after = Focus{ .id = "new", .child = leaf.widget() }; + try after.widget().update(h.root, &bctx); + try mgr.collect(gpa, h.root); + + try std.testing.expect(mgr.focusById("new")); + try std.testing.expect(!mgr.focusById("old")); +} + test "unmounting a focused Focus widget removes it from the manager" { // Element.deinit must call forgetFocus, or the manager keeps a pointer into // freed memory and the next key dereferences it. diff --git a/lib/phantom/widgets/grid_view.zig b/lib/phantom/widgets/grid_view.zig index 20e0737..16a995a 100644 --- a/lib/phantom/widgets/grid_view.zig +++ b/lib/phantom/widgets/grid_view.zig @@ -79,6 +79,11 @@ const RenderGridView = struct { viewport: geom.PhysicalSize = geom.PhysicalSize.zero, handlers: pointer.PointerHandlers, focus_handlers: phantom.FocusHandlers = undefined, + /// The copy of the config id that `focus_handlers.id` points at. Owned, + /// because the config it comes from lives in the per-frame build arena. + id: phantom.focus.OwnedId = .{}, + /// The controller this grid is attached to, kept so `destroyFn` can detach. + controller: ?*scroll_view.ScrollController = null, fn reportOom(self: *RenderGridView, msg: []const u8) void { if (self.sink) |s| s.report(.oom, msg); @@ -151,8 +156,36 @@ const RenderGridView = struct { try cv.popScroll(); } + /// Point the controller at this grid. The same reasoning as `ScrollView`: a + /// controller that already drives another view is taken over, because the + /// widget that named it here is the live one. + fn attach(self: *RenderGridView, controller: ?*scroll_view.ScrollController) void { + if (self.controller == controller) return; + self.detach(); + self.controller = controller; + if (controller) |c| c.view = .{ + .node = &self.base, + .offset = &self.offset, + .content = &self.content, + .viewport = &self.viewport, + }; + } + + /// Clear the controller, but only while it still points here. A rebuild that + /// moved the controller to another view must not have its new target erased + /// by this grid being freed afterwards. + fn detach(self: *RenderGridView) void { + const c = self.controller orelse return; + if (c.view) |v| { + if (v.node == &self.base) c.view = null; + } + self.controller = null; + } + fn destroyFn(base: *RenderObject, gpa: std.mem.Allocator) void { const self: *RenderGridView = @fieldParentPtr("base", base); + self.detach(); + self.id.deinit(gpa); // Frees only the lists (pointers), NOT the child render objects: those are // owned by the child Elements and freed by their deinit. self.children.deinit(gpa); @@ -180,7 +213,7 @@ const RenderGridView = struct { fn installHandlers(self: *RenderGridView) void { self.handlers = .{ .ctx = self, .on_scroll = scrollThunk }; self.base.pointer = &self.handlers; - self.focus_handlers = .{ .ctx = self, .on_key = onKey }; + self.focus_handlers = .{ .ctx = self, .on_key = onKey, .node = &self.base, .id = self.id.text }; self.base.focus = &self.focus_handlers; } }; @@ -195,6 +228,11 @@ pub const GridView = struct { /// the space offered, so this is what fixes the height. Zero or less is read /// as one, which gives square tiles. aspect_ratio: f32 = 1, + /// A name the application can move the focus to. See `FocusManager.focusById`. + id: ?[]const u8 = null, + /// The handle an application scrolls this grid through. `ScrollController` + /// drives a `ScrollView` exactly the same way. + controller: ?*scroll_view.ScrollController = null, children: []const Widget, const vtable = Widget.VTable{ .mount = mount, .update = update }; @@ -232,8 +270,16 @@ pub const GridView = struct { .aspect_ratio = self.aspect_ratio, .handlers = .{ .ctx = rg }, }; + // Before `installHandlers`, which reads the id into the focus handlers. + rg.id.set(gpa, self.id) catch |e| { + gpa.destroy(rg); + return e; + }; rg.installHandlers(); + rg.attach(self.controller); const el = gpa.create(Element) catch |e| { + rg.detach(); + rg.id.deinit(gpa); gpa.destroy(rg); return e; }; @@ -257,6 +303,11 @@ pub const GridView = struct { rg.columns = self.columns; rg.spacing = self.spacing; rg.aspect_ratio = self.aspect_ratio; + // A rebuild can rename the grid or move the controller to another + // widget, so both are re-read and the handlers re-installed. + try rg.id.set(bctx.owner.gpa, self.id); + rg.installHandlers(); + rg.attach(self.controller); try el.updateChildren(self.children, bctx); syncChildren(rg, el, bctx.owner.gpa); } @@ -684,3 +735,64 @@ test "metricsFor divides the width between the columns and the gaps" { try std.testing.expectEqual(@as(f32, 100), guarded.item_height); try std.testing.expect(std.math.isFinite(metricsFor(100, 1, 0, -3).item_height)); } + +test "a GridView scrolls through the same controller a ScrollView uses" { + const gpa = std.testing.allocator; + var ctl = scroll_view.ScrollController{}; + var kids: [12]Widget = undefined; + var boxes: [12]phantom.ColoredBox = undefined; + for (&boxes, 0..) |*b, i| { + b.* = .{ .color = phantom.Color.rgb(@floatFromInt(i % 2), 0, 1) }; + kids[i] = b.widget(); + } + var g = GridView{ .columns = 2, .controller = &ctl, .children = &kids }; + + var h = try testing.mount(gpa, g.widget()); + defer h.deinit(); + h.viewport = .{ .width = 200, .height = 100 }; + try h.pump(); + + // Attached by mounting, so the application never reaches into the tree. + try std.testing.expect(ctl.attached()); + const max = ctl.maxOffset().?; + try std.testing.expect(max.y > 0); + + try std.testing.expect(ctl.scrollBy(0, 30)); + try std.testing.expectEqual(@as(f32, 30), ctl.offset().?.y); + // Clamped to what the content allows, exactly as a wheel event is. + try std.testing.expect(ctl.jumpTo(.{ .x = 0, .y = max.y + 1000 })); + try std.testing.expectEqual(max.y, ctl.offset().?.y); +} + +test "unmounting a GridView detaches its controller, so the app cannot scroll freed storage" { + const gpa = std.testing.allocator; + var ctl = scroll_view.ScrollController{}; + var box = phantom.ColoredBox{ .color = phantom.Color.rgb(1, 1, 1) }; + const kids = [_]Widget{box.widget()}; + var g = GridView{ .columns = 1, .controller = &ctl, .children = &kids }; + + var h = try testing.mount(gpa, g.widget()); + try h.pump(); + try std.testing.expect(ctl.attached()); + + h.deinit(); + try std.testing.expect(!ctl.attached()); + // Every method reports the miss rather than writing through a dead pointer. + try std.testing.expect(!ctl.scrollBy(0, 10)); + try std.testing.expect(ctl.offset() == null); +} + +test "a named GridView takes the focus by that name" { + const gpa = std.testing.allocator; + var box = phantom.ColoredBox{ .color = phantom.Color.rgb(1, 1, 1) }; + const kids = [_]Widget{box.widget()}; + var g = GridView{ .columns = 1, .id = "launcher", .children = &kids }; + + var h = try testing.mount(gpa, g.widget()); + defer h.deinit(); + try h.pump(); + try h.collectFocus(); + + try std.testing.expect(h.focus.focusById("launcher")); + try std.testing.expectEqualStrings("launcher", h.focus.focusedId().?); +} diff --git a/lib/phantom/widgets/keyboard_listener.zig b/lib/phantom/widgets/keyboard_listener.zig index 1cf04d1..8087d4b 100644 --- a/lib/phantom/widgets/keyboard_listener.zig +++ b/lib/phantom/widgets/keyboard_listener.zig @@ -71,6 +71,7 @@ pub const KeyboardListener = struct { ro.handlers = .{ .ctx = ro, .on_key = if (self.on_key != null) RenderKeyboardListener.keyThunk else null, + .node = &ro.base, }; ro.base.key_listener = &ro.handlers; } diff --git a/lib/phantom/widgets/scroll_view.zig b/lib/phantom/widgets/scroll_view.zig index b1cadec..0b4d758 100644 --- a/lib/phantom/widgets/scroll_view.zig +++ b/lib/phantom/widgets/scroll_view.zig @@ -49,6 +49,135 @@ pub fn keyScrollDelta( }; } +/// The least offset that shows all of `rect`. Both are in content coordinates, so +/// the caller has already taken the viewport's own position out. A rect larger than +/// the viewport lines up with its start edge, because the start of an item is what a +/// reader needs first. +pub fn offsetToShow( + offset: geom.PhysicalOffset, + rect: geom.PhysicalRect, + viewport: geom.PhysicalSize, +) geom.PhysicalOffset { + return .{ + .x = axisOffsetToShow(offset.x, rect.x, rect.width, viewport.width), + .y = axisOffsetToShow(offset.y, rect.y, rect.height, viewport.height), + }; +} + +fn axisOffsetToShow(offset: f32, start: f32, extent: f32, viewport: f32) f32 { + if (start < offset) return start; + // Showing the end of a rect this long would push its start out of sight, and a + // reader needs the start first. + if (extent > viewport) return start; + const end = start + extent; + if (end > offset + viewport) return end - viewport; + // Already in view, so the reader's place is kept. + return offset; +} + +/// The parts of a scrolling render object a `ScrollController` drives, without +/// naming which widget owns them. +/// +/// `ScrollView` and `GridView` scroll identically: both clamp `offset + delta` +/// against the same content and viewport, through the same `clampOffset`. +/// Pointing the controller at the fields rather than at one render object type +/// is what lets one controller serve both, instead of `GridView` growing a +/// second copy of the same methods. +/// +/// The pointers reach into the render object, so a handle is valid only while +/// that object is mounted. `detach` is what keeps that true. +pub const Scrollable = struct { + node: *RenderObject, + offset: *geom.PhysicalOffset, + content: *const geom.PhysicalSize, + viewport: *const geom.PhysicalSize, +}; + +/// The handle an application holds to reach a mounted scrolling widget. The scroll +/// offset lives on the render object, which the widget tree owns and rebuilds, so a +/// controller is the only stable thing a caller can keep across frames. It does not +/// own the view: the view attaches itself while it is mounted and detaches before it +/// is freed, which is why every method reports whether it reached anything. +/// +/// One controller drives one view. Giving the same controller to two mounted +/// scrolling widgets leaves it pointing at whichever mounted last. +pub const ScrollController = struct { + view: ?Scrollable = null, + + /// True while a mounted `ScrollView` uses this controller. + pub fn attached(self: *const ScrollController) bool { + return self.view != null; + } + + /// Where the content is scrolled to, or null while detached. + pub fn offset(self: *const ScrollController) ?geom.PhysicalOffset { + const v = self.view orelse return null; + return v.offset.*; + } + + /// The furthest the content can scroll, or null while detached. Zero on both + /// axes until the first layout, because nothing is known about the content yet. + pub fn maxOffset(self: *const ScrollController) ?geom.PhysicalOffset { + const v = self.view orelse return null; + return .{ + .x = @max(@as(f32, 0), v.content.width - v.viewport.width), + .y = @max(@as(f32, 0), v.content.height - v.viewport.height), + }; + } + + /// Scroll to an absolute offset, clamped to what the content allows. Returns + /// false while detached. + pub fn jumpTo(self: *ScrollController, to: geom.PhysicalOffset) bool { + const v = self.view orelse return false; + v.offset.* = clampOffset(to, v.content.*, v.viewport.*); + return true; + } + + /// Add to the offset, clamped the same way a wheel event is. Returns false while + /// detached. + pub fn scrollBy(self: *ScrollController, dx: f32, dy: f32) bool { + const v = self.view orelse return false; + // The same clamp both scrolling render objects apply to a wheel event. + v.offset.* = clampOffset( + .{ .x = v.offset.x + dx, .y = v.offset.y + dy }, + v.content.*, + v.viewport.*, + ); + return true; + } + + /// Scroll the least amount that puts `rect` in view. The rect is in content + /// coordinates, where the top left of the content is the origin. Returns false + /// while detached. + pub fn scrollIntoView(self: *ScrollController, rect: geom.PhysicalRect) bool { + const v = self.view orelse return false; + v.offset.* = clampOffset(offsetToShow(v.offset.*, rect, v.viewport.*), v.content.*, v.viewport.*); + return true; + } + + /// Scroll the least amount that puts one render object of the content in view. + /// Returns false while detached, or when the view and the child have not both + /// been painted yet: a render object learns where it sits from the paint pass, + /// so there is nothing to aim at before the first frame. + /// + /// `child` must be inside this view. A render object from elsewhere in the tree + /// gives a meaningless rectangle rather than an error, because the render tree + /// holds no upward link to check it against. + pub fn showChild(self: *ScrollController, child: *RenderObject) bool { + const v = self.view orelse return false; + // The child is painted at the view's own paint origin plus its position in + // the content. The scroll offset is applied by the backend from the pushed + // scroll region, not by the paint offset, so the difference of the two + // origins is the content coordinate with no offset to undo. + return self.scrollIntoView(.{ + .x = child.origin.x - v.node.origin.x, + .y = child.origin.y - v.node.origin.y, + .width = child.size.width, + .height = child.size.height, + }); + } +}; + const RenderScrollView = struct { base: RenderObject, gpa: std.mem.Allocator, @@ -58,6 +187,10 @@ const RenderScrollView = struct { viewport: geom.PhysicalSize = geom.PhysicalSize.zero, handlers: pointer.PointerHandlers, focus_handlers: phantom.FocusHandlers = undefined, + /// The copy of the config's id that `focus_handlers.id` points at. + id: phantom.focus.OwnedId = .{}, + /// The controller this view is attached to, kept so `destroyFn` can detach. + controller: ?*ScrollController = null, axis: ScrollView.Axis = .vertical, fn layoutFn(base: *RenderObject, c: layout.BoxConstraints) geom.PhysicalSize { @@ -110,9 +243,36 @@ const RenderScrollView = struct { fn destroyFn(base: *RenderObject, gpa: std.mem.Allocator) void { const self: *RenderScrollView = @fieldParentPtr("base", base); + self.detach(); + self.id.deinit(gpa); gpa.destroy(self); } + /// Point the controller at this view. A controller that already drives another + /// view is taken over, because the widget that named it here is the live one. + fn attach(self: *RenderScrollView, controller: ?*ScrollController) void { + if (self.controller == controller) return; + self.detach(); + self.controller = controller; + if (controller) |c| c.view = .{ + .node = &self.base, + .offset = &self.offset, + .content = &self.content, + .viewport = &self.viewport, + }; + } + + /// Clear the controller, but only while it still points here. A rebuild that + /// moved the controller to another view must not have its new target erased by + /// this view being freed afterwards. + fn detach(self: *RenderScrollView) void { + const c = self.controller orelse return; + if (c.view) |v| { + if (v.node == &self.base) c.view = null; + } + self.controller = null; + } + fn adopt(base: *RenderObject, child: ?*RenderObject) void { const self: *RenderScrollView = @fieldParentPtr("base", base); self.child = child; @@ -141,7 +301,12 @@ const RenderScrollView = struct { fn installHandlers(self: *RenderScrollView) void { self.handlers = .{ .ctx = self, .on_scroll = scrollThunk }; self.base.pointer = &self.handlers; - self.focus_handlers = .{ .ctx = self, .on_key = onKey }; + self.focus_handlers = .{ + .ctx = self, + .on_key = onKey, + .id = self.id.text, + .node = &self.base, + }; self.base.focus = &self.focus_handlers; } }; @@ -149,6 +314,14 @@ const RenderScrollView = struct { pub const ScrollView = struct { child: Widget, axis: Axis = .vertical, + /// The handle an application scrolls this view through. The view attaches itself + /// to it on mount and detaches on unmount, so the caller only has to keep the + /// controller alive for as long as the widget is mounted. + controller: ?*ScrollController = null, + /// The name `FocusManager.focusById` moves the keyboard focus here by. A view + /// with no focusable children needs this to be reached by the arrow keys. The + /// text is copied on mount, so a caller may build it in the frame arena. + id: ?[]const u8 = null, pub const Axis = enum { vertical, horizontal, both }; @@ -172,8 +345,15 @@ pub const ScrollView = struct { .handlers = .{ .ctx = ro }, .axis = self.axis, }; + ro.id.set(gpa, self.id) catch |e| { + gpa.destroy(ro); + return e; + }; ro.installHandlers(); + ro.attach(self.controller); const el = gpa.create(Element) catch |e| { + ro.detach(); + ro.id.deinit(gpa); gpa.destroy(ro); return e; }; @@ -195,6 +375,9 @@ pub const ScrollView = struct { const self: *const ScrollView = @ptrCast(@alignCast(ptr)); const ro: *RenderScrollView = @fieldParentPtr("base", el.render_object.?); ro.axis = self.axis; + try ro.id.set(ro.gpa, self.id); + ro.installHandlers(); + ro.attach(self.controller); el.child = try el.updateChild(el.child, self.child, bctx); ro.base.adoptChild(if (el.child) |c| c.renderObject() else null); } @@ -430,6 +613,286 @@ test "End jumps to the bottom and clamps there, Home returns to the top" { try std.testing.expectEqual(@as(f32, 0), scrollOffsetOf(ro)); } +test "offsetToShow leaves the offset alone when the rect is already in view" { + const vp = geom.PhysicalSize{ .width = 100, .height = 100 }; + const got = offsetToShow(.{ .x = 0, .y = 500 }, .{ .x = 0, .y = 520, .width = 100, .height = 40 }, vp); + try std.testing.expectEqual(@as(f32, 500), got.y); +} + +test "offsetToShow moves down by the least amount that shows the end of the rect" { + const vp = geom.PhysicalSize{ .width = 100, .height = 100 }; + // The rect runs 500..600 and the view shows 0..100, so the bottom edge decides. + const got = offsetToShow(.zero, .{ .x = 0, .y = 500, .width = 100, .height = 100 }, vp); + try std.testing.expectEqual(@as(f32, 500), got.y); +} + +test "offsetToShow moves up to the start of a rect above the view" { + const vp = geom.PhysicalSize{ .width = 100, .height = 100 }; + const got = offsetToShow(.{ .x = 0, .y = 500 }, .{ .x = 0, .y = 220, .width = 100, .height = 40 }, vp); + try std.testing.expectEqual(@as(f32, 220), got.y); +} + +test "offsetToShow lines a rect taller than the viewport up with its start" { + const vp = geom.PhysicalSize{ .width = 100, .height = 100 }; + // Neither edge fits, so showing the end would hide the start. The start wins. + const got = offsetToShow(.zero, .{ .x = 0, .y = 200, .width = 100, .height = 400 }, vp); + try std.testing.expectEqual(@as(f32, 200), got.y); +} + +test "offsetToShow scrolls each axis on its own" { + const vp = geom.PhysicalSize{ .width = 100, .height = 100 }; + // The rect is right of the view and already inside it vertically. + const got = offsetToShow(.{ .x = 0, .y = 50 }, .{ .x = 300, .y = 60, .width = 20, .height = 20 }, vp); + try std.testing.expectEqual(@as(f32, 220), got.x); + try std.testing.expectEqual(@as(f32, 50), got.y); +} + +// --------------------------------------------------------------------------- +// Reaching a mounted view through a ScrollController +// --------------------------------------------------------------------------- + +/// Ten stacked rows of 100 physical units each, so the content is 1000 tall inside +/// a 100 tall viewport and every row has a known content coordinate. +const RowList = struct { + leaf: phantom.ColoredBox = .{ .color = geom.Color.rgb(0, 1, 0) }, + rows: [10]phantom.SizedBox = undefined, + widgets: [10]phantom.Widget = undefined, + + fn column(self: *RowList) phantom.Flex { + for (&self.rows, 0..) |*row, i| { + row.* = .{ .width = 100, .height = 100, .child = self.leaf.widget() }; + self.widgets[i] = row.widget(); + } + return phantom.Column(.{ .main = .start, .cross = .start, .children = &self.widgets }); + } +}; + +fn pushScrollOffset(prims: []const phantom.Primitive) ?geom.PhysicalOffset { + for (prims) |p| { + switch (p) { + .push_scroll => |sr| return sr.offset, + else => {}, + } + } + return null; +} + +test "a ScrollController moves the offset of the view it is attached to, and the paint follows" { + const gpa = std.testing.allocator; + var list = RowList{}; + var col = list.column(); + var ctl = ScrollController{}; + var sv = ScrollView{ .controller = &ctl, .child = col.widget() }; + var h = try testing.mount(gpa, sv.widget()); + defer h.deinit(); + h.viewport = .{ .width = 100, .height = 100 }; + try h.pump(); + + try std.testing.expect(ctl.attached()); + try std.testing.expectEqual(@as(f32, 0), ctl.offset().?.y); + + try std.testing.expect(ctl.scrollBy(0, 250)); + try std.testing.expectEqual(@as(f32, 250), ctl.offset().?.y); + try std.testing.expect(ctl.jumpTo(.{ .x = 0, .y = 40 })); + try std.testing.expectEqual(@as(f32, 40), ctl.offset().?.y); + + // The offset is only worth anything if the next frame draws at it. + try h.pump(); + try std.testing.expectEqual(@as(f32, 40), pushScrollOffset(h.canvas.list.primitives.items).?.y); +} + +test "a ScrollController reports the last offset the content allows and clamps to it" { + const gpa = std.testing.allocator; + var list = RowList{}; + var col = list.column(); + var ctl = ScrollController{}; + var sv = ScrollView{ .controller = &ctl, .child = col.widget() }; + var h = try testing.mount(gpa, sv.widget()); + defer h.deinit(); + h.viewport = .{ .width = 100, .height = 100 }; + try h.pump(); + + // Ten rows of 100 in a viewport of 100 leaves 900 to scroll through. + try std.testing.expectEqual(@as(f32, 900), ctl.maxOffset().?.y); + try std.testing.expect(ctl.jumpTo(.{ .x = 0, .y = 99999 })); + try std.testing.expectEqual(@as(f32, 900), ctl.offset().?.y); + try std.testing.expect(ctl.scrollBy(0, -99999)); + try std.testing.expectEqual(@as(f32, 0), ctl.offset().?.y); +} + +test "a ScrollController that no view uses reports nothing and refuses every move" { + var ctl = ScrollController{}; + try std.testing.expect(!ctl.attached()); + try std.testing.expect(ctl.offset() == null); + try std.testing.expect(ctl.maxOffset() == null); + // An application that built its controller before the first build must be told + // the call did nothing, rather than believing it scrolled. + try std.testing.expect(!ctl.jumpTo(.{ .x = 0, .y = 10 })); + try std.testing.expect(!ctl.scrollBy(0, 10)); + try std.testing.expect(!ctl.scrollIntoView(.{ .x = 0, .y = 0, .width = 10, .height = 10 })); +} + +test "unmounting a ScrollView detaches its controller" { + const gpa = std.testing.allocator; + var list = RowList{}; + var col = list.column(); + var ctl = ScrollController{}; + var sv = ScrollView{ .controller = &ctl, .child = col.widget() }; + var h = try testing.mount(gpa, sv.widget()); + h.viewport = .{ .width = 100, .height = 100 }; + try h.pump(); + try std.testing.expect(ctl.scrollBy(0, 100)); + + h.deinit(); + // The render object is freed now, so a controller that still pointed at it + // would write through a dangling pointer on the next scroll. + try std.testing.expect(!ctl.attached()); + try std.testing.expect(!ctl.scrollBy(0, 100)); +} + +test "scrollIntoView brings a row below the viewport just inside the bottom edge" { + const gpa = std.testing.allocator; + var list = RowList{}; + var col = list.column(); + var ctl = ScrollController{}; + var sv = ScrollView{ .controller = &ctl, .child = col.widget() }; + var h = try testing.mount(gpa, sv.widget()); + defer h.deinit(); + h.viewport = .{ .width = 100, .height = 100 }; + try h.pump(); + + // Row 5 covers content y 500..600. + try std.testing.expect(ctl.scrollIntoView(.{ .x = 0, .y = 500, .width = 100, .height = 100 })); + try std.testing.expectEqual(@as(f32, 500), ctl.offset().?.y); + // Asking again for a row that is now in view must not move anything. + try std.testing.expect(ctl.scrollIntoView(.{ .x = 0, .y = 500, .width = 100, .height = 100 })); + try std.testing.expectEqual(@as(f32, 500), ctl.offset().?.y); + // Row 0 is above the view, so it comes back to the top. + try std.testing.expect(ctl.scrollIntoView(.{ .x = 0, .y = 0, .width = 100, .height = 100 })); + try std.testing.expectEqual(@as(f32, 0), ctl.offset().?.y); +} + +test "showChild scrolls a child render object into view and lands on the same offset twice" { + const gpa = std.testing.allocator; + var list = RowList{}; + var col = list.column(); + var ctl = ScrollController{}; + var sv = ScrollView{ .controller = &ctl, .child = col.widget() }; + var h = try testing.mount(gpa, sv.widget()); + defer h.deinit(); + h.viewport = .{ .width = 100, .height = 100 }; + try h.pump(); + + const column_el = h.root.child orelse return error.NoColumnElement; + const row7 = column_el.children.items[7].renderObject() orelse return error.NoRowRenderObject; + + try std.testing.expect(ctl.showChild(row7)); + // Row 7 covers content y 700..800, so its bottom edge decides the offset. + try std.testing.expectEqual(@as(f32, 700), ctl.offset().?.y); + + // A second frame repaints at the new offset. The child's recorded origin must + // still be a content coordinate, or the view would creep further on each call. + try h.pump(); + try std.testing.expect(ctl.showChild(row7)); + try std.testing.expectEqual(@as(f32, 700), ctl.offset().?.y); +} + +// --------------------------------------------------------------------------- +// Reaching a mounted view through the keyboard +// --------------------------------------------------------------------------- + +test "a ScrollView with an id takes the focus by name and then scrolls on the arrow keys" { + const gpa = std.testing.allocator; + var leaf = phantom.ColoredBox{ .color = geom.Color.rgb(0, 1, 0) }; + var child = tallContent(&leaf); + var sv = ScrollView{ .id = "log", .child = child.widget() }; + // The manager is torn down after the tree, because unmounting the tree calls + // back into it to forget each freed focus node. + var mgr = phantom.FocusManager{}; + defer mgr.deinit(gpa); + var h = try testing.mount(gpa, sv.widget()); + defer h.deinit(); + try h.pump(); + + h.owner.focus = &mgr; + try mgr.collect(gpa, h.root); + + try std.testing.expect(mgr.focusById("log")); + try std.testing.expect(mgr.dispatch(.{ .keysym = .down })); + try std.testing.expect(scrollOffsetOf(h.root.renderObject().?) > 0); +} + +test "a rebuilt ScrollView answers to its new id and no longer to the old one" { + const gpa = std.testing.allocator; + var leaf = phantom.ColoredBox{ .color = geom.Color.rgb(0, 1, 0) }; + var child = tallContent(&leaf); + var mgr = phantom.FocusManager{}; + defer mgr.deinit(gpa); + var before = ScrollView{ .id = "old", .child = child.widget() }; + var h = try testing.mount(gpa, before.widget()); + defer h.deinit(); + try h.pump(); + h.owner.focus = &mgr; + try mgr.collect(gpa, h.root); + try std.testing.expect(mgr.focusById("old")); + + var bctx = phantom.BuildContext{ .arena = h.arena.allocator(), .owner = h.owner }; + var after = ScrollView{ .id = "new", .child = child.widget() }; + try after.widget().update(h.root, &bctx); + try mgr.collect(gpa, h.root); + + try std.testing.expect(mgr.focusById("new")); + try std.testing.expect(!mgr.focusById("old")); +} + +test "a rebuilt ScrollView moves its controller to the widget that names it" { + const gpa = std.testing.allocator; + var leaf = phantom.ColoredBox{ .color = geom.Color.rgb(0, 1, 0) }; + var child = tallContent(&leaf); + var ctl = ScrollController{}; + var before = ScrollView{ .child = child.widget() }; + var h = try testing.mount(gpa, before.widget()); + defer h.deinit(); + try h.pump(); + // A view built with no controller must not be reachable through one. + try std.testing.expect(!ctl.attached()); + + var bctx = phantom.BuildContext{ .arena = h.arena.allocator(), .owner = h.owner }; + var after = ScrollView{ .controller = &ctl, .child = child.widget() }; + try after.widget().update(h.root, &bctx); + try h.pump(); + + // A rebuild that adds a controller has to attach it, or an application that + // wires one up after the first frame never reaches the view. + try std.testing.expect(ctl.attached()); + try std.testing.expect(ctl.scrollBy(0, 40)); + try std.testing.expectEqual(@as(f32, 40), ctl.offset().?.y); +} + +test "a key the focused child refuses scrolls the ScrollView that encloses it" { + const gpa = std.testing.allocator; + var leaf = phantom.ColoredBox{ .color = geom.Color.rgb(0, 1, 0) }; + var child = tallContent(&leaf); + // The row takes the focus and handles no keys of its own, which is the shape of + // a selectable list item. + var row = phantom.Focus{ .id = "row", .child = child.widget() }; + var sv = ScrollView{ .child = row.widget() }; + var mgr = phantom.FocusManager{}; + defer mgr.deinit(gpa); + var h = try testing.mount(gpa, sv.widget()); + defer h.deinit(); + try h.pump(); + + h.owner.focus = &mgr; + try mgr.collect(gpa, h.root); + + try std.testing.expect(mgr.focusById("row")); + try std.testing.expect(mgr.dispatch(.{ .keysym = .page_down })); + try std.testing.expect(scrollOffsetOf(h.root.renderObject().?) > 0); + // The key scrolled the view, and the row kept the focus. + try std.testing.expectEqualStrings("row", mgr.focusedId().?); +} + test "ScrollView vertical axis: Column child lays out to natural stacked height, not 0" { const gpa = std.testing.allocator; var sink = phantom.FaultSink{}; diff --git a/lib/phantom/widgets/text.zig b/lib/phantom/widgets/text.zig index c9825bf..9c0d94c 100644 --- a/lib/phantom/widgets/text.zig +++ b/lib/phantom/widgets/text.zig @@ -22,7 +22,14 @@ const RenderText = struct { size: f32, physical_size: f32 = 0, color: geom.Color, - line: ?text_layout.Line = null, + /// Break the text to the width the constraints allow. False keeps the old + /// single-line behaviour, where a long string simply runs past its box. + /// + /// Off by default on purpose: turning it on for everyone would silently + /// change the height of every existing `Text` that happens to sit in a + /// narrow box, and a layout that was correct would quietly grow a row. + wrap: bool = false, + para: ?text_layout.Paragraph = null, /// Points at `BuildOwner.text_metrics`, which outlives every element in the tree. /// A pointer and not a copy, so a resize that changes the cell size reaches the /// next layout with no remount. @@ -30,37 +37,54 @@ const RenderText = struct { fn layoutFn(base: *RenderObject, c: layout_mod.BoxConstraints) geom.PhysicalSize { const self: *RenderText = @fieldParentPtr("base", base); - if (self.line) |*l| { - l.deinit(self.gpa); - self.line = null; + if (self.para) |*p| { + p.deinit(self.gpa); + self.para = null; } self.physical_size = self.size * c.scale; - const l = text_layout.layoutLine(self.gpa, self.font, self.text, self.physical_size, self.text_metrics.*) catch { + // A zero width tells `layoutParagraph` not to wrap, which is exactly + // what an unwrapped Text wants and what an unbounded constraint means. + const wrap_width: f32 = if (self.wrap) c.max_width else 0; + const p = text_layout.layoutParagraph( + self.gpa, + self.font, + self.text, + self.physical_size, + self.text_metrics.*, + wrap_width, + ) catch { return c.constrain(.{ .width = 0, .height = 0 }); }; - self.line = l; - return c.constrain(.{ .width = l.width, .height = l.height }); + self.para = p; + return c.constrain(.{ .width = p.width, .height = p.height }); } fn paintFn(base: *RenderObject, cv: *Canvas, offset: geom.PhysicalOffset) anyerror!void { const self: *RenderText = @fieldParentPtr("base", base); - const l = self.line orelse return; - cv.drawText(.{ - .glyphs = l.glyphs, - .text = self.text, - .font = @ptrCast(self.font), - .size = self.physical_size, - .color = self.color, - .origin = offset, - .ascent = l.ascent, - }) catch |e| { - if (cv.sink) |s| s.report(.render_failed, @errorName(e)); - }; + const p = self.para orelse return; + // One run per line, stacked by the height of the lines above it. Each + // run carries its own ascent, so a backend that places a baseline does + // not have to know the paragraph exists. + var y = offset.y; + for (p.lines) |l| { + cv.drawText(.{ + .glyphs = l.glyphs, + .text = self.text, + .font = @ptrCast(self.font), + .size = self.physical_size, + .color = self.color, + .origin = .{ .x = offset.x, .y = y }, + .ascent = l.ascent, + }) catch |e| { + if (cv.sink) |s| s.report(.render_failed, @errorName(e)); + }; + y += l.height; + } } fn destroyFn(base: *RenderObject, gpa: std.mem.Allocator) void { const self: *RenderText = @fieldParentPtr("base", base); - if (self.line) |*l| l.deinit(self.gpa); + if (self.para) |*p| p.deinit(self.gpa); // RenderText owns a copy of the string (the widget config it came from lives // in the per-frame build arena, which is reset after each frame). gpa.free(self.text); @@ -73,6 +97,11 @@ pub const Text = struct { font: ?*Font = null, size: ?f32 = null, color: ?geom.Color = null, + /// Break the text to the width the box allows instead of running past it. + /// + /// Off by default, so an existing layout keeps the height it had. A caller + /// that wants a paragraph asks for one. + wrap: bool = false, const vtable = Widget.VTable{ .mount = mount, .update = update }; @@ -115,6 +144,7 @@ pub const Text = struct { .text = text_copy, .size = r.size, .color = r.color, + .wrap = self.wrap, .text_metrics = &bctx.owner.text_metrics, }; const el = try gpa.create(Element); @@ -141,9 +171,12 @@ pub const Text = struct { ro.font = r.font; ro.size = r.size; ro.color = r.color; - if (ro.line) |*l| { - l.deinit(ro.gpa); - ro.line = null; + ro.wrap = self.wrap; + // Dropped rather than re-laid out here: the next layout rebuilds it, + // and it cannot be rebuilt now because the constraints are not known. + if (ro.para) |*p| { + p.deinit(ro.gpa); + ro.para = null; } } }; @@ -225,11 +258,11 @@ test "Text.update invalidates cached line" { _ = el.render_object.?.layout(layout_mod.BoxConstraints.tight(.{ .width = 400, .height = 100 })); const ro: *RenderText = @fieldParentPtr("base", el.render_object.?); - try std.testing.expect(ro.line != null); + try std.testing.expect(ro.para != null); var t2 = Text{ .text = "Bye", .font = &font, .size = 16, .color = geom.Color.rgb(0, 1, 0) }; try t2.widget().update(el, &bctx); - try std.testing.expect(ro.line == null); + try std.testing.expect(ro.para == null); } test "Text with null font/color resolves from the theme; explicit wins" { @@ -336,3 +369,60 @@ test "Text explicit font/color wins over theme" { try std.testing.expectEqual(explicit_color, ro.color); try std.testing.expectEqual(explicit_size, ro.size); } + +test "a Text that does not ask to wrap keeps running past its box, as it always has" { + const gpa = std.testing.allocator; + var h = try phantom.testing.mount(gpa, blk: { + const t = Text{ .text = "a string far longer than the box it is given", .size = 16 }; + break :blk t.widget(); + }); + defer h.deinit(); + h.viewport = .{ .width = 60, .height = 200 }; + try h.pump(); + + const el = h.root; + const ro: *RenderText = @fieldParentPtr("base", el.render_object.?); + // One line, however narrow the box: turning wrapping on for everyone would + // change the height of every existing layout that sits in a narrow box. + try std.testing.expectEqual(@as(usize, 1), ro.para.?.lines.len); +} + +test "a wrapping Text breaks to the width it was given and grows taller instead of wider" { + const gpa = std.testing.allocator; + var h = try phantom.testing.mount(gpa, blk: { + const t = Text{ .text = "a string far longer than the box it is given", .size = 16, .wrap = true }; + break :blk t.widget(); + }); + defer h.deinit(); + h.viewport = .{ .width = 120, .height = 400 }; + try h.pump(); + + const ro: *RenderText = @fieldParentPtr("base", h.root.render_object.?); + const p = ro.para.?; + try std.testing.expect(p.lines.len > 1); + for (p.lines) |l| try std.testing.expect(l.width <= 120); + // Taller than one line, which is the whole point of wrapping. + try std.testing.expect(p.height > p.lines[0].height); +} + +test "a wrapping Text stacks its lines down the box rather than drawing them on top of each other" { + const gpa = std.testing.allocator; + var h = try phantom.testing.mount(gpa, blk: { + const t = Text{ .text = "one two three four five six seven", .size = 16, .wrap = true }; + break :blk t.widget(); + }); + defer h.deinit(); + h.viewport = .{ .width = 100, .height = 400 }; + try h.pump(); + + // Every line reaches the display list, each at its own origin. Painting them + // all at the same y would look like one unreadable line. + var ys: std.ArrayList(f32) = .empty; + defer ys.deinit(gpa); + for (h.canvas.list.primitives.items) |prim| { + if (prim == .text) try ys.append(gpa, prim.text.origin.y); + } + const ro: *RenderText = @fieldParentPtr("base", h.root.render_object.?); + try std.testing.expectEqual(ro.para.?.lines.len, ys.items.len); + for (ys.items[1..], 0..) |y, i| try std.testing.expect(y > ys.items[i]); +} diff --git a/lib/phantom/widgets/text_field.zig b/lib/phantom/widgets/text_field.zig index b441a02..7fb82ea 100644 --- a/lib/phantom/widgets/text_field.zig +++ b/lib/phantom/widgets/text_field.zig @@ -289,6 +289,11 @@ pub const TextField = struct { /// valid for the call only. on_change: ?*const fn (ctx: *anyopaque, text: []const u8) void = null, ctx: *anyopaque = undefined, + /// The name `FocusManager.focusById` moves the focus here by. This is what a + /// click on the field routes through, because a pointer hit gives a render + /// object and the focus is held by name. Null leaves the field reachable + /// through Tab only. + id: ?[]const u8 = null, pub fn widget(self: *const TextField) Widget { return phantom.StatefulWidget(TextField, self); @@ -314,6 +319,9 @@ pub const TextField = struct { color: ?geom.Color = null, caret_color: ?geom.Color = null, caret_width: f32 = 2, + /// Borrowed from the config. The `Focus` widget below copies it, so the + /// slice only has to survive until the next build. + id: ?[]const u8 = null, // The built configs live here so their addresses stay stable across the // build, which is what a Widget borrows. view: CaretText = undefined, @@ -351,6 +359,7 @@ pub const TextField = struct { s.caret_color = config.caret_color; s.caret_width = config.caret_width; s.blink_period = config.blink_period; + s.id = config.id; } /// The text as it stands. Borrowed and valid until the next edit. @@ -375,6 +384,7 @@ pub const TextField = struct { .on_key = State.onKey, .on_focus_change = State.onFocusChange, .ctx = s, + .id = s.id, }; return s.focus_config.widget(); } @@ -566,6 +576,35 @@ const Fixture = struct { } }; +test "a named TextField takes the focus by id, which is what a click on it does" { + const gpa = std.testing.allocator; + var field = TextField{ .id = "prompt", .text = "" }; + var f = try Fixture.init(gpa, &field); + defer f.deinit(); + try f.refresh(); + + // A pointer hit gives the application a place on screen, not a Tab count, so + // this is the only way a click can reach one field out of several. + try std.testing.expect(f.manager.focusById("prompt")); + f.typeText("hi"); + const s = try f.state(); + try std.testing.expectEqualStrings("hi", s.value()); +} + +test "an unnamed TextField answers to no id, so a stray name cannot focus it" { + const gpa = std.testing.allocator; + var field = TextField{ .text = "" }; + var f = try Fixture.init(gpa, &field); + defer f.deinit(); + try f.refresh(); + + try std.testing.expect(!f.manager.focusById("prompt")); + f.typeText("hi"); + const s = try f.state(); + // Nothing holds the focus, so the key went nowhere. + try std.testing.expectEqualStrings("", s.value()); +} + test "typing inserts at the caret and not at the end of the text" { const gpa = std.testing.allocator; var field = TextField{ .text = "ac" }; @@ -903,12 +942,15 @@ test "unmounting a focused field cancels its blink registration" { try std.testing.expectEqual(@as(usize, 0), owner.scheduler.entries.items.len); // Tear down the rest by hand: the harness root was already freed above. + // This mirrors `Harness.deinit` and has to be kept in step with it. + f.harness.focus.deinit(gpa); owner.deinit(); f.harness.canvas.deinit(); f.harness.arena.deinit(); gpa.destroy(f.harness.arena); gpa.destroy(f.harness.owner); gpa.destroy(f.harness.sink); + gpa.destroy(f.harness.focus); f.manager.deinit(gpa); }