From 4ab1ad084a38ba1d6e6ee3a8ed5e96177dcb59fd Mon Sep 17 00:00:00 2001 From: phall Date: Wed, 2 Sep 2026 21:59:55 -0400 Subject: [PATCH] feat(canvas): support vertical split axes --- packages/native-sdk/native-sdk.d.ts | 2 +- src/platform/macos/appkit_host.h | 1 + src/platform/macos/appkit_host.m | 1 + src/platform/macos/root.zig | 1 + src/platform/types.zig | 1 + src/primitives/canvas/events.zig | 35 +++-- src/primitives/canvas/root.zig | 2 + src/primitives/canvas/ui.zig | 21 ++- src/primitives/canvas/widget_access.zig | 14 +- src/primitives/canvas/widget_invalidation.zig | 1 + src/primitives/canvas/widget_layout.zig | 142 +++++++++++++----- src/primitives/canvas/widget_render.zig | 28 ++-- src/primitives/canvas/widget_routing.zig | 1 + src/primitives/canvas/widget_runtime.zig | 4 + src/primitives/canvas/widgets.zig | 38 +++-- .../canvas_widget_split_tree_tests.zig | 75 +++++++++ src/runtime/view.zig | 1 + src/runtime/view_widget_control.zig | 132 +++++++++++----- src/runtime/view_widget_tree.zig | 12 +- src/runtime/widget_bridge.zig | 1 + 20 files changed, 395 insertions(+), 118 deletions(-) diff --git a/packages/native-sdk/native-sdk.d.ts b/packages/native-sdk/native-sdk.d.ts index 210dcd09f..1d5845341 100644 --- a/packages/native-sdk/native-sdk.d.ts +++ b/packages/native-sdk/native-sdk.d.ts @@ -187,7 +187,7 @@ export type NativeSdkGpuSurfacePresentMode = "none" | "timer"; export type NativeSdkGpuSurfaceAlphaMode = "none" | "opaque" | "premultiplied"; export type NativeSdkGpuSurfaceColorSpace = "none" | "srgb" | "display_p3"; export type NativeSdkGpuSurfaceStatus = "unavailable" | "initializing" | "ready" | "lost"; -export type NativeSdkCursor = "arrow" | "pointing_hand" | "text" | "resize_horizontal"; +export type NativeSdkCursor = "arrow" | "pointing_hand" | "text" | "resize_horizontal" | "resize_vertical"; export type NativeSdkCanvasFrameProfileRisk = "idle" | "low" | "moderate" | "high"; export interface NativeSdkViewInfo { diff --git a/src/platform/macos/appkit_host.h b/src/platform/macos/appkit_host.h index 4b4ddac9b..2cb2c112d 100644 --- a/src/platform/macos/appkit_host.h +++ b/src/platform/macos/appkit_host.h @@ -115,6 +115,7 @@ typedef enum { NATIVE_SDK_APPKIT_CURSOR_POINTING_HAND = 1, NATIVE_SDK_APPKIT_CURSOR_TEXT = 2, NATIVE_SDK_APPKIT_CURSOR_RESIZE_HORIZONTAL = 3, + NATIVE_SDK_APPKIT_CURSOR_RESIZE_VERTICAL = 4, } native_sdk_appkit_cursor_t; typedef enum { diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index 1f7ac6afb..a82c84292 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -306,6 +306,7 @@ static NSAccessibilityRole NativeSdkAccessibilityRoleForWidgetRole(NSInteger rol case NATIVE_SDK_APPKIT_CURSOR_POINTING_HAND: return [NSCursor pointingHandCursor]; case NATIVE_SDK_APPKIT_CURSOR_TEXT: return [NSCursor IBeamCursor]; case NATIVE_SDK_APPKIT_CURSOR_RESIZE_HORIZONTAL: return [NSCursor resizeLeftRightCursor]; + case NATIVE_SDK_APPKIT_CURSOR_RESIZE_VERTICAL: return [NSCursor resizeUpDownCursor]; case NATIVE_SDK_APPKIT_CURSOR_ARROW: default: return [NSCursor arrowCursor]; diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 112fc8003..be52fe1e6 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -2231,6 +2231,7 @@ fn appKitCursor(cursor: platform_mod.Cursor) c_int { .pointing_hand => 1, .text => 2, .resize_horizontal => 3, + .resize_vertical => 4, }; } diff --git a/src/platform/types.zig b/src/platform/types.zig index 45007790c..f4c12b4f7 100644 --- a/src/platform/types.zig +++ b/src/platform/types.zig @@ -1025,6 +1025,7 @@ pub const Cursor = enum { pointing_hand, text, resize_horizontal, + resize_vertical, }; pub const ViewInfo = struct { diff --git a/src/primitives/canvas/events.zig b/src/primitives/canvas/events.zig index c8591c09f..17c5ab15d 100644 --- a/src/primitives/canvas/events.zig +++ b/src/primitives/canvas/events.zig @@ -28,6 +28,8 @@ pub const WidgetHit = struct { depth: usize, index: usize, state: WidgetState, + /// Relevant for divider cursor/input semantics; horizontal elsewhere. + split_axis: canvas.SplitAxis = .horizontal, /// Semantic role of the hit widget (kind alone cannot distinguish a /// link hotspot from plain text, and links want a pointer cursor). role: WidgetRole = .none, @@ -671,11 +673,10 @@ pub fn widgetKeyboardControlIntent(widget: Widget, keyboard: WidgetKeyboardEvent } else null, - // The split divider is the ARIA separator: horizontal arrows - // adjust the parent split's fraction, Home/End jump to the - // clamp edges (the runtime clamps against the panes' min - // widths when it applies the value). - .split_divider => if (widgetSplitDividerKeyboardValue(widget.value, keyboard)) |next_value| + // The split divider is the ARIA separator: arrows along its axis + // adjust the parent split's fraction, Home/End jump to the clamp + // edges (the runtime clamps against the panes' main-axis minimums). + .split_divider => if (widgetSplitDividerKeyboardValueForAxis(widget.value, widget.runtime_flags.split_axis, keyboard)) |next_value| .{ .kind = .set_value, .actions = .{ @@ -825,19 +826,31 @@ pub fn widgetSliderKeyboardValue(current: f32, keyboard: WidgetKeyboardEvent) ?f return null; } -/// Fraction steps for the split divider: the slider's step sizes, on the -/// horizontal axis only (the vertical arrows stay free for tree/list -/// focus travel around the divider). -pub fn widgetSplitDividerKeyboardValue(current: f32, keyboard: WidgetKeyboardEvent) ?f32 { +/// Fraction steps for a split divider: the slider's step sizes along the +/// split's axis. Cross-axis arrows stay free for surrounding focus travel. +pub fn widgetSplitDividerKeyboardValueForAxis(current: f32, axis: canvas.SplitAxis, keyboard: WidgetKeyboardEvent) ?f32 { if (keyboard.phase != .key_down or keyboard.modifiers.hasNavigationModifier()) return null; const step: f32 = if (keyboard.modifiers.shift) 0.1 else 0.05; - if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowleft")) return current - step; - if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowright")) return current + step; + switch (axis) { + .horizontal => { + if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowleft")) return current - step; + if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowright")) return current + step; + }, + .vertical => { + if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowup")) return current - step; + if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowdown")) return current + step; + }, + } if (std.ascii.eqlIgnoreCase(keyboard.key, "home")) return 0; if (std.ascii.eqlIgnoreCase(keyboard.key, "end")) return 1; return null; } +/// Original horizontal helper retained for source compatibility. +pub fn widgetSplitDividerKeyboardValue(current: f32, keyboard: WidgetKeyboardEvent) ?f32 { + return widgetSplitDividerKeyboardValueForAxis(current, .horizontal, keyboard); +} + /// The ARIA tree-row keymap, resolved on the routed keyboard target: /// - Enter/Space activate (select, plus press when a command is bound). /// - A key that MOVED focus onto this row (`focus_moved`) selects it — diff --git a/src/primitives/canvas/root.zig b/src/primitives/canvas/root.zig index b594b1a32..eef6114d7 100644 --- a/src/primitives/canvas/root.zig +++ b/src/primitives/canvas/root.zig @@ -440,6 +440,7 @@ pub const DesignTokens = token_model.DesignTokens; // Canvas widget model and built-in factories live in `widgets.zig`; root keeps the public API stable. pub const WidgetKind = widget_model.WidgetKind; +pub const SplitAxis = widget_model.SplitAxis; pub const WidgetCursor = widget_model.WidgetCursor; pub const WidgetState = widget_model.WidgetState; pub const WidgetRuntimeFlags = widget_model.WidgetRuntimeFlags; @@ -754,6 +755,7 @@ pub const textInputCaretVisibleScrollOffsetForWidget = widget_runtime.textInputC pub const intrinsicWidgetSize = widget_runtime.intrinsicWidgetSize; pub const cursorForWidgetHit = widget_runtime.cursorForWidgetHit; pub const cursorForWidgetTarget = widget_runtime.cursorForWidgetTarget; +pub const cursorForWidgetTargetOnAxis = widget_runtime.cursorForWidgetTargetOnAxis; /// Whether the engine hit-tests widgets of this kind (widget_access.zig — /// the single source of truth the runtime, both markup engines, and the /// markup validator's element list all derive from). Kind-level only: the diff --git a/src/primitives/canvas/ui.zig b/src/primitives/canvas/ui.zig index 819293424..3fa3d86bc 100644 --- a/src/primitives/canvas/ui.zig +++ b/src/primitives/canvas/ui.zig @@ -535,6 +535,10 @@ pub fn Ui(comptime Msg: type) type { text: []const u8 = "", placeholder: []const u8 = "", value: f32 = 0, + /// Axis for a `split`: `.horizontal` preserves the original + /// left/right behavior; `.vertical` stacks top/bottom. The + /// synthesized divider inherits it for input and semantics. + split_axis: canvas.SplitAxis = .horizontal, /// HORIZONTAL scroll offset for a horizontal-capable /// `scroll` container (markup `value-x`) — the sideways /// counterpart of `value`. Follows the same source-wins @@ -629,10 +633,14 @@ pub fn Ui(comptime Msg: type) type { width: f32 = 0, /// Definite height; same contract as `width`. height: f32 = 0, + /// Height floor WITHOUT the definite-max side of `height`. + /// Vertical split panes use it to constrain divider travel. + min_height: f32 = 0, + /// Height ceiling WITHOUT the definite-min side of `height`. + max_height: f32 = 0, /// Width floor WITHOUT the definite-max side of `width`: /// the widget may grow past it but never shrink below. - /// Split panes use it to constrain the divider drag (the - /// clamp band derives from both panes' floors). + /// Horizontal split panes use it to constrain divider travel. min_width: f32 = 0, /// Width ceiling WITHOUT the definite-min side of `width`: /// the widget fills or hugs normally until this bound, then @@ -3776,6 +3784,7 @@ pub fn Ui(comptime Msg: type) type { .image_id = options.image, .image_src = options.image_src, .value = options.value, + .runtime_flags = .{ .split_axis = options.split_axis }, .value_x = options.value_x, .tree_level = options.tree_level, .terminal = .{ .pty = options.pty, .scrollback = options.scrollback }, @@ -3807,14 +3816,17 @@ pub fn Ui(comptime Msg: type) type { .virtual_anchor_index = options.virtual_anchor_index, .virtual_anchor_extent = options.virtual_anchor_extent, .virtual_total_extent = options.virtual_total_extent, - .min_size = .{ .width = @max(options.width, options.min_width), .height = options.height }, + .min_size = .{ + .width = @max(options.width, options.min_width), + .height = @max(options.height, options.min_height), + }, // Explicit sizes are definite (min AND max). Resizable // is the exception: width documents the initial width // and the engine's drag handle keeps writing larger // frames past it. .max_size = if (kind == .resizable) .{} else .{ .width = if (options.width > 0) options.width else options.max_width, - .height = options.height, + .height = if (options.height > 0) options.height else options.max_height, }, }, .style = options.style, @@ -3844,6 +3856,7 @@ pub fn Ui(comptime Msg: type) type { .kind = .split_divider, .id = structuralId(split_widget.id, .split_divider, UiKey{ .str = "divider" }), .value = split_widget.value, + .runtime_flags = .{ .split_axis = split_widget.runtime_flags.split_axis }, .state = .{ .disabled = split_widget.state.disabled }, .semantics = .{ .label = "Split divider" }, }; diff --git a/src/primitives/canvas/widget_access.zig b/src/primitives/canvas/widget_access.zig index 2020c4673..c356fa041 100644 --- a/src/primitives/canvas/widget_access.zig +++ b/src/primitives/canvas/widget_access.zig @@ -22,7 +22,7 @@ const snapTextRange = text_model.snapTextRange; pub fn cursorForWidgetHit(hit: ?WidgetHit) WidgetCursor { const target = hit orelse return .arrow; if (target.role == .link and !target.state.disabled) return .pointing_hand; - return cursorForWidgetTarget(target.kind, target.state); + return cursorForWidgetTargetOnAxis(target.kind, target.state, target.split_axis); } /// The kind-level half of the register: I-beam over editable text (a @@ -31,14 +31,24 @@ pub fn cursorForWidgetHit(hit: ?WidgetHit) WidgetCursor { /// arrow over everything else — including sliders, which keep the arrow /// at rest AND during a drag on every native platform. The pointing hand /// never comes from a kind; it is role-driven (`cursorForWidgetHit`). +/// +/// This original entry point preserves horizontal divider behavior. pub fn cursorForWidgetTarget(kind: WidgetKind, state: WidgetState) WidgetCursor { + return cursorForWidgetTargetOnAxis(kind, state, .horizontal); +} + +pub fn cursorForWidgetTargetOnAxis(kind: WidgetKind, state: WidgetState, split_axis: widget_model.SplitAxis) WidgetCursor { if (state.disabled) return .arrow; return switch (kind) { // The terminal joins the editable-text register: a click // focuses the session's input, and the I-beam advertises it — // the platform terminals' own convention. .input, .text_field, .search_field, .combobox, .textarea, .terminal => .text, - .resizable, .split_divider => .resize_horizontal, + .resizable => .resize_horizontal, + .split_divider => switch (split_axis) { + .horizontal => .resize_horizontal, + .vertical => .resize_vertical, + }, else => .arrow, }; } diff --git a/src/primitives/canvas/widget_invalidation.zig b/src/primitives/canvas/widget_invalidation.zig index 24f1104df..6ce7a0120 100644 --- a/src/primitives/canvas/widget_invalidation.zig +++ b/src/primitives/canvas/widget_invalidation.zig @@ -292,6 +292,7 @@ fn widgetChange( const next_diff_lines = next.widget.codeDiffLines(); const layout_dirty = previous.widget.kind != next.widget.kind or + previous.widget.runtime_flags.split_axis != next.widget.runtime_flags.split_axis or previous.depth != next.depth or previous.parent_index != next.parent_index or root_bounds_dirty or diff --git a/src/primitives/canvas/widget_layout.zig b/src/primitives/canvas/widget_layout.zig index dd506e522..71816dccb 100644 --- a/src/primitives/canvas/widget_layout.zig +++ b/src/primitives/canvas/widget_layout.zig @@ -14,6 +14,7 @@ const widget_render = @import("widget_render.zig"); const Error = canvas.Error; const Widget = widget_model.Widget; +const SplitAxis = widget_model.SplitAxis; const WidgetMainAlignment = widget_model.WidgetMainAlignment; const WidgetCrossAlignment = widget_model.WidgetCrossAlignment; const WidgetLayoutStyle = widget_model.WidgetLayoutStyle; @@ -1699,13 +1700,62 @@ pub fn splitEffectiveFraction(value: f32, available: f32, first_min: f32, second const bounds = splitFractionBounds(available, first_min, second_min); return std.math.clamp(base, bounds.low, bounds.high); } +fn splitMainExtent(rect: geometry.RectF, axis: SplitAxis) f32 { + return switch (axis) { + .horizontal => rect.width, + .vertical => rect.height, + }; +} + +fn splitMainMax(rect: geometry.RectF, axis: SplitAxis) f32 { + return switch (axis) { + .horizontal => rect.maxX(), + .vertical => rect.maxY(), + }; +} + +fn splitPaneMin(widget: Widget, axis: SplitAxis) f32 { + return nonNegative(switch (axis) { + .horizontal => widget.layout.min_size.width, + .vertical => widget.layout.min_size.height, + }); +} + +fn splitChildFrame(content: geometry.RectF, axis: SplitAxis, origin: f32, extent: f32) geometry.RectF { + return switch (axis) { + .horizontal => geometry.RectF.init(origin, content.y, extent, content.height), + .vertical => geometry.RectF.init(content.x, origin, content.width, extent), + }; +} + +fn setSplitFrameOrigin(frame: *geometry.RectF, axis: SplitAxis, origin: f32) void { + switch (axis) { + .horizontal => frame.x = origin, + .vertical => frame.y = origin, + } +} + +fn setSplitFrameExtent(frame: *geometry.RectF, axis: SplitAxis, extent: f32) void { + switch (axis) { + .horizontal => frame.width = extent, + .vertical => frame.height = extent, + } +} + +fn translateSplitFrame(frame: *geometry.RectF, axis: SplitAxis, delta: f32) void { + switch (axis) { + .horizontal => frame.x += delta, + .vertical => frame.y += delta, + } +} + -/// Split layout: [pane 1][divider][pane 2] along the horizontal axis. +/// Split layout: pane 1, divider, pane 2 along `widget.split_axis`. /// The divider is the builder-synthesized `.split_divider` child; panes -/// are the remaining flow children (exactly two by the validator's -/// rule — extras degrade to zero-width frames rather than failing). The -/// first pane takes `splitEffectiveFraction` of the width left after -/// the divider band; both panes stretch the full height. +/// are the remaining flow children (exactly two by the validator's rule — +/// extras degrade to zero-extent frames rather than failing). The first +/// pane takes `splitEffectiveFraction` of the main-axis extent left after +/// the divider band; both panes stretch across the other axis. fn layoutSplitChildren( widget: Widget, content: geometry.RectF, @@ -1733,29 +1783,34 @@ fn layoutSplitChildren( } } + const axis = widget.runtime_flags.split_axis; const divider_extent = if (divider != null) splitDividerExtent(widget) else 0; - const available = @max(0, content.width - divider_extent); - const first_min = if (panes[0]) |pane| nonNegative(pane.layout.min_size.width) else 0; - const second_min = if (panes[1]) |pane| nonNegative(pane.layout.min_size.width) else 0; + const available = @max(0, splitMainExtent(content, axis) - divider_extent); + const first_min = if (panes[0]) |pane| splitPaneMin(pane, axis) else 0; + const second_min = if (panes[1]) |pane| splitPaneMin(pane, axis) else 0; const fraction = splitEffectiveFraction(widget.value, available, first_min, second_min); - const first_width = if (panes[1] == null) available else available * fraction; + const first_extent = if (panes[1] == null) available else available * fraction; - var cursor = content.x; + var cursor = switch (axis) { + .horizontal => content.x, + .vertical => content.y, + }; if (panes[0]) |pane| { - _ = try layoutWidgetDepth(pane, geometry.RectF.init(cursor, content.y, first_width, content.height), parent_index, depth + 1, output, len, tokens); - cursor += first_width; + _ = try layoutWidgetDepth(pane, splitChildFrame(content, axis, cursor, first_extent), parent_index, depth + 1, output, len, tokens); + cursor += first_extent; } if (divider) |handle| { - // The handle mirrors the EFFECTIVE fraction so keyboard steps and - // separator semantics read the position layout actually used. + // The handle mirrors the EFFECTIVE fraction and axis so keyboard, + // cursor, and separator semantics describe the geometry in use. var handle_copy = handle; handle_copy.value = fraction; - _ = try layoutWidgetDepth(handle_copy, geometry.RectF.init(cursor, content.y, divider_extent, content.height), parent_index, depth + 1, output, len, tokens); + handle_copy.runtime_flags.split_axis = axis; + _ = try layoutWidgetDepth(handle_copy, splitChildFrame(content, axis, cursor, divider_extent), parent_index, depth + 1, output, len, tokens); cursor += divider_extent; } if (panes[1]) |pane| { - const second_width = @max(0, content.maxX() - cursor); - _ = try layoutWidgetDepth(pane, geometry.RectF.init(cursor, content.y, second_width, content.height), parent_index, depth + 1, output, len, tokens); + const second_extent = @max(0, splitMainMax(content, axis) - cursor); + _ = try layoutWidgetDepth(pane, splitChildFrame(content, axis, cursor, second_extent), parent_index, depth + 1, output, len, tokens); } // Panes past the first two never happen through the builder/markup // (the validator enforces exactly two); raw trees degrade to empty @@ -1763,7 +1818,7 @@ fn layoutSplitChildren( if (extra_start) |start| { for (widget.children[start..]) |child| { if (!widgetTakesFlowSlot(child) or child.kind == .split_divider) continue; - _ = try layoutWidgetDepth(child, geometry.RectF.init(content.maxX(), content.y, 0, 0), parent_index, depth + 1, output, len, tokens); + _ = try layoutWidgetDepth(child, splitChildFrame(content, axis, splitMainMax(content, axis), 0), parent_index, depth + 1, output, len, tokens); } } } @@ -1832,37 +1887,47 @@ pub fn slideSplitChildren( const second_index = pane_indices[1] orelse return; const handle_index = divider_index orelse return; - const content = frame.inset(nodes[node_index].widget.layout.padding).normalized(); - const divider_extent = nodes[handle_index].frame.width; - const available = @max(0, content.width - divider_extent); - const first_min = @max(0, nodes[first_index].widget.layout.min_size.width); - const second_min = @max(0, nodes[second_index].widget.layout.min_size.width); + const split = nodes[node_index].widget; + const axis = split.runtime_flags.split_axis; + const content = frame.inset(split.layout.padding).normalized(); + const divider_extent = splitMainExtent(nodes[handle_index].frame, axis); + const available = @max(0, splitMainExtent(content, axis) - divider_extent); + const first_min = splitPaneMin(nodes[first_index].widget, axis); + const second_min = splitPaneMin(nodes[second_index].widget, axis); // The same clamp family the runtime's drag echo applies: a // sub-epsilon fraction stays a sliver instead of falling into the - // `<= 0` unset sentinel, and pane min widths bound the boundary. + // `<= 0` unset sentinel, and pane minimums bound the boundary. const effective = splitEffectiveFraction(@max(fraction, 0.0001), available, first_min, second_min); - const first_width = available * effective; - const divider_x = content.x + first_width; - const dx = divider_x - nodes[handle_index].frame.x; + const first_extent = available * effective; + const content_origin = switch (axis) { + .horizontal => content.x, + .vertical => content.y, + }; + const divider_origin = content_origin + first_extent; + const previous_divider_origin = switch (axis) { + .horizontal => nodes[handle_index].frame.x, + .vertical => nodes[handle_index].frame.y, + }; + const delta = divider_origin - previous_divider_origin; nodes[node_index].widget.value = effective; nodes[handle_index].widget.value = effective; - nodes[first_index].frame.width = first_width; + setSplitFrameExtent(&nodes[first_index].frame, axis, first_extent); nodes[first_index].widget.frame = nodes[first_index].frame; - nodes[handle_index].frame.x = divider_x; + setSplitFrameOrigin(&nodes[handle_index].frame, axis, divider_origin); nodes[handle_index].widget.frame = nodes[handle_index].frame; - const second_x = divider_x + divider_extent; - nodes[second_index].frame.x = second_x; - nodes[second_index].frame.width = @max(0, content.maxX() - second_x); + const second_origin = divider_origin + divider_extent; + setSplitFrameOrigin(&nodes[second_index].frame, axis, second_origin); + setSplitFrameExtent(&nodes[second_index].frame, axis, @max(0, splitMainMax(content, axis) - second_origin)); nodes[second_index].widget.frame = nodes[second_index].frame; - if (dx == 0) return; + if (delta == 0) return; // The second pane's content rides its leading edge: translate the // whole subtree (frames only — wraps and sizes stand). const second_depth = nodes[second_index].depth; var index = second_index + 1; while (index < nodes.len and nodes[index].depth > second_depth) : (index += 1) { - nodes[index].frame.x += dx; + translateSplitFrame(&nodes[index].frame, axis, delta); nodes[index].widget.frame = nodes[index].frame; } } @@ -2076,9 +2141,12 @@ fn intrinsicWidgetSizeDepth(widget: Widget, tokens: DesignTokens, depth: usize) intrinsicGridChildrenSize(widget, tokens, depth), .stack, .bubble, .resizable, .panel, .popover => intrinsicOverlayChildrenSize(widget, tokens, depth), .tree => intrinsicAxisChildrenSize(widget, tokens, .vertical, depth), - // The divider band is thin along the row and cross-sized by the - // panes it divides (like a bare separator in a row). - .split_divider => geometry.SizeF.init(splitDividerExtent(widget), 0), + // The divider band is thin on the split axis and cross-sized by + // the panes it divides (like a bare separator in a row/column). + .split_divider => switch (widget.runtime_flags.split_axis) { + .horizontal => geometry.SizeF.init(splitDividerExtent(widget), 0), + .vertical => geometry.SizeF.init(0, splitDividerExtent(widget)), + }, // A split fills the space it is given (panes partition it); // like scroll viewports it reports no intrinsic size of its own. // Media surfaces measure like images: the texture is external diff --git a/src/primitives/canvas/widget_render.zig b/src/primitives/canvas/widget_render.zig index 1648fb212..e751485d9 100644 --- a/src/primitives/canvas/widget_render.zig +++ b/src/primitives/canvas/widget_render.zig @@ -3120,10 +3120,10 @@ fn emitSeparatorWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) }); } -/// The split's drag handle: a centered vertical hairline in the divider -/// band. Hover/press tint the line with the accent color (the band is -/// the hit target, so the affordance appears as the pointer reaches -/// it); keyboard focus draws the standard focus ring around the band. +/// The split's drag handle: a centered hairline across the divider band. +/// Hover/press tint the line with the accent color (the band is the hit +/// target, so the affordance appears as the pointer reaches it); keyboard +/// focus draws the standard focus ring around the band. fn emitSplitDividerWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void { const visual = componentControlVisualTokens(widget, tokens); const normalized = widget.frame.normalized(); @@ -3133,12 +3133,20 @@ fn emitSplitDividerWidget(builder: *Builder, widget: Widget, tokens: DesignToken @max(2, controlStrokeWidth(widget, visual, tokens.stroke.hairline)) else controlStrokeWidth(widget, visual, tokens.stroke.hairline); - const line_rect = geometry.RectF.init( - normalized.x + (normalized.width - thickness) * 0.5, - normalized.y, - thickness, - normalized.height, - ); + const line_rect = switch (widget.runtime_flags.split_axis) { + .horizontal => geometry.RectF.init( + normalized.x + (normalized.width - thickness) * 0.5, + normalized.y, + thickness, + normalized.height, + ), + .vertical => geometry.RectF.init( + normalized.x, + normalized.y + (normalized.height - thickness) * 0.5, + normalized.width, + thickness, + ), + }; const line_color = if (active) widgetAccentColor(widget, tokens.colors.accent) else diff --git a/src/primitives/canvas/widget_routing.zig b/src/primitives/canvas/widget_routing.zig index b1ec9920d..a014a7802 100644 --- a/src/primitives/canvas/widget_routing.zig +++ b/src/primitives/canvas/widget_routing.zig @@ -123,6 +123,7 @@ fn widgetHitFromNode(node: WidgetLayoutNode, index: usize) WidgetHit { .depth = node.depth, .index = index, .state = node.widget.state, + .split_axis = node.widget.runtime_flags.split_axis, .role = node.widget.semantics.role, }; } diff --git a/src/primitives/canvas/widget_runtime.zig b/src/primitives/canvas/widget_runtime.zig index 52ea8d683..0997f09ef 100644 --- a/src/primitives/canvas/widget_runtime.zig +++ b/src/primitives/canvas/widget_runtime.zig @@ -360,6 +360,10 @@ pub fn cursorForWidgetHit(hit: ?WidgetHit) WidgetCursor { pub fn cursorForWidgetTarget(kind: WidgetKind, state: WidgetState) WidgetCursor { return widget_access.cursorForWidgetTarget(kind, state); } +pub fn cursorForWidgetTargetOnAxis(kind: WidgetKind, state: WidgetState, split_axis: widget_model.SplitAxis) WidgetCursor { + return widget_access.cursorForWidgetTargetOnAxis(kind, state, split_axis); +} + fn collectWidgetSemantics(layout: WidgetLayoutTree, output: []WidgetSemanticsNode) Error![]const WidgetSemanticsNode { return widget_semantics.collectWidgetSemantics(layout, output, widgetScrollSemantics); diff --git a/src/primitives/canvas/widgets.zig b/src/primitives/canvas/widgets.zig index 2759f53af..3a82854f8 100644 --- a/src/primitives/canvas/widgets.zig +++ b/src/primitives/canvas/widgets.zig @@ -102,21 +102,22 @@ pub const WidgetKind = enum { /// hash `widgetKindCode`, not declaration order, so placement here /// carries no meaning.) chart, - /// Two-pane horizontal splitter: exactly two flow children (the - /// panes) separated by a builder-synthesized `.split_divider` handle. - /// `value` is the MODEL-OWNED fraction of the content width the - /// first pane takes (0 means "unset" and lays out at 0.5); dragging - /// the divider dispatches a `canvas_widget_resize` event so the - /// model can own the fraction (`on_resize`), and the runtime keeps - /// an uncontrolled divider position across rebuilds with the same - /// source-wins reconcile rule as scroll offsets. + /// Two-pane splitter: exactly two flow children (the panes) separated + /// by a builder-synthesized `.split_divider` handle. `split_axis` + /// chooses left/right or top/bottom while preserving horizontal as the + /// default. `value` is the MODEL-OWNED fraction of the split axis the + /// first pane takes (0 means "unset" and lays out at 0.5); dragging the + /// divider dispatches a `canvas_widget_resize` event so the model can + /// own the fraction (`on_resize`), and the runtime keeps an uncontrolled + /// divider position across rebuilds with the same source-wins reconcile + /// rule as scroll offsets. split, /// The draggable divider handle between a `.split`'s panes. Never /// authored directly: `Ui.finalizeNode` synthesizes it between the /// two panes (both markup engines build through the same builder). - /// Focusable, `resize_horizontal` cursor, ARIA separator semantics; - /// arrow keys adjust the parent split's fraction when it holds - /// focus. `value` mirrors the parent split's fraction. + /// Focusable, axis-appropriate resize cursor, ARIA separator semantics; + /// matching arrow keys adjust the parent split's fraction when it holds + /// focus. `value` and `split_axis` mirror the parent split. split_divider, /// Disclosure-tree container (vertical flow like `list`): descendant /// widgets carrying `role = .treeitem` form ONE roving keyboard @@ -245,11 +246,17 @@ pub fn widgetKindCode(kind: WidgetKind) u16 { }; } +pub const SplitAxis = enum(u1) { + horizontal, + vertical, +}; + pub const WidgetCursor = enum { arrow, pointing_hand, text, resize_horizontal, + resize_vertical, }; pub const WidgetState = struct { @@ -264,16 +271,17 @@ pub const WidgetState = struct { invalid: bool = false, }; -/// Engine-owned widget markers share one byte so adding runtime policy does -/// not expand every retained `Widget`. Builder-facing behavior remains on -/// ordinary named fields; these bits are stamped only by engine code. +/// Compact widget policy shares one byte so adding runtime or builder-facing +/// behavior does not expand every retained `Widget`. pub const WidgetRuntimeFlags = packed struct(u8) { /// `Ui.code` stamped this textarea as the editor surface. code_editor: bool = false, /// The runtime installed an OS-native scroll driver; engine-drawn /// scrollbar and kinetic physics stand down for this scroll view. native_scroll: bool = false, - _reserved: u6 = 0, + /// The axis declared by a split and mirrored onto its divider. + split_axis: SplitAxis = .horizontal, + _reserved: u5 = 0, }; /// Two 128-line masks for code-only diff presentation. `Widget` packs them diff --git a/src/runtime/canvas_widget_split_tree_tests.zig b/src/runtime/canvas_widget_split_tree_tests.zig index ae540239f..b568eb640 100644 --- a/src/runtime/canvas_widget_split_tree_tests.zig +++ b/src/runtime/canvas_widget_split_tree_tests.zig @@ -64,6 +64,17 @@ fn buildSplitTree(ui: *TestUi) TestUi.Node { ui.column(.{ .min_width = 60 }, .{}), }); } +fn buildVerticalSplitTree(ui: *TestUi) TestUi.Node { + return ui.split(.{ + .split_axis = .vertical, + .value = 0.5, + .on_resize = TestUi.valueMsg(.resized), + }, .{ + ui.column(.{ .min_height = 60 }, .{}), + ui.column(.{ .min_height = 60 }, .{}), + }); +} + fn findNodeByKind(layout: canvas.WidgetLayoutTree, kind: canvas.WidgetKind) ?canvas.WidgetLayoutNode { for (layout.nodes) |node| { @@ -251,6 +262,70 @@ test "keyboard adjusts the focused split divider through the resize event" { try std.testing.expectApproxEqAbs(@as(f32, 240.0 / 300.0), app_state.last_resize_fraction, 0.0001); try std.testing.expectEqual(@as(u32, 4), app_state.resize_count); } +test "vertical split divider lays out, drags, keys, and adjusts accessibly on its axis" { + const harness = try TestHarness().create(std.testing.allocator, .{}); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + var app_state: ObservingApp = .{}; + const app = app_state.app(); + try harness.start(app); + + _ = try harness.runtime.createView(.{ + .window_id = 1, + .label = "canvas", + .kind = .gpu_surface, + .frame = geometry.RectF.init(0, 0, 100, 309), + }); + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var ui = TestUi.init(arena.allocator()); + const tree = try ui.finalize(buildVerticalSplitTree(&ui)); + var nodes: [8]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(tree.root, geometry.RectF.init(0, 0, 100, 309), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); + + const installed = try harness.runtime.canvasWidgetLayout(1, "canvas"); + const divider = findNodeByKind(installed, .split_divider) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(canvas.SplitAxis.vertical, divider.widget.runtime_flags.split_axis); + try std.testing.expectEqualDeep(geometry.RectF.init(0, 150, 100, 9), divider.frame); + + // Pointer hover exposes the native north/south resize cursor, then a + // captured downward drag changes only the vertical fraction. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .pointer_move, .x = 50, .y = 154 } }); + try std.testing.expectEqual(platform.Cursor.resize_vertical, harness.null_platform.view_cursor); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .pointer_down, .x = 50, .y = 154, .button = 0 } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .pointer_drag, .x = 50, .y = 214 } }); + const dragged = try harness.runtime.canvasWidgetLayout(1, "canvas"); + const dragged_split = dragged.findById(tree.root.id) orelse return error.TestUnexpectedResult; + const expected_fraction: f32 = (214.0 - 4.5) / 300.0; + try std.testing.expectApproxEqAbs(expected_fraction, dragged_split.widget.value, 0.0001); + const dragged_divider = findNodeByKind(dragged, .split_divider) orelse return error.TestUnexpectedResult; + try std.testing.expectApproxEqAbs(expected_fraction * 300.0, dragged_divider.frame.y, 0.001); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .pointer_up, .x = 50, .y = 214, .button = 0 } }); + + // The press focused the separator. Cross-axis arrows do nothing; + // ArrowDown follows the divider down and emits the resize value. + try std.testing.expectEqual(divider.widget.id, harness.runtime.views[0].canvas_widget_focused_id); + const before_key = dragged_split.widget.value; + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "arrowright" } }); + try std.testing.expectApproxEqAbs(before_key, (try harness.runtime.canvasWidgetLayout(1, "canvas")).findById(tree.root.id).?.widget.value, 0.0001); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "arrowdown" } }); + const after_key = (try harness.runtime.canvasWidgetLayout(1, "canvas")).findById(tree.root.id).?.widget.value; + try std.testing.expect(after_key > before_key); + + // The separator's adjustable accessibility action travels through the + // same control intent and on_resize channel. + const resize_count_before_accessibility = app_state.resize_count; + _ = try harness.runtime.dispatchCanvasWidgetAccessibilityAction(app, 1, "canvas", .{ + .id = divider.widget.id, + .action = .decrement, + }); + const adjusted = try harness.runtime.canvasWidgetLayout(1, "canvas"); + try std.testing.expect(adjusted.findById(tree.root.id).?.widget.value < after_key); + try std.testing.expectEqual(resize_count_before_accessibility + 1, app_state.resize_count); +} + fn treeRowPanel(id: canvas.ObjectId, y: f32, height: f32, expanded: ?bool, children: []const canvas.Widget) canvas.Widget { return .{ diff --git a/src/runtime/view.zig b/src/runtime/view.zig index b8fea0aeb..70b3b4354 100644 --- a/src/runtime/view.zig +++ b/src/runtime/view.zig @@ -925,6 +925,7 @@ pub const RuntimeView = struct { pub const applyCanvasWidgetSplitFractionMoved = CanvasWidgetControlMethods.applyCanvasWidgetSplitFractionMoved; pub const noteCanvasWidgetResizeEvent = CanvasWidgetControlMethods.noteCanvasWidgetResizeEvent; pub const noteCanvasWidgetChangeEvent = CanvasWidgetControlMethods.noteCanvasWidgetChangeEvent; + pub const translateCanvasWidgetDescendants = CanvasWidgetControlMethods.translateCanvasWidgetDescendants; pub const translateCanvasWidgetDescendantsX = CanvasWidgetControlMethods.translateCanvasWidgetDescendantsX; pub const toggleCanvasWidgetTreeItemExpanded = CanvasWidgetControlMethods.toggleCanvasWidgetTreeItemExpanded; pub const applyCanvasWidgetControlKeyboard = CanvasWidgetControlMethods.applyCanvasWidgetControlKeyboard; diff --git a/src/runtime/view_widget_control.zig b/src/runtime/view_widget_control.zig index 1da3d9c58..5d11bc9c1 100644 --- a/src/runtime/view_widget_control.zig +++ b/src/runtime/view_widget_control.zig @@ -18,9 +18,55 @@ pub const CanvasWidgetToggleAnimation = struct { dirty_bounds: ?geometry.RectF, }; -fn setCanvasWidgetNodeWidth(node: *canvas.WidgetLayoutNode, width: f32) void { - node.frame.width = width; - node.widget.frame.width = width; +fn splitMainExtent(rect: geometry.RectF, axis: canvas.SplitAxis) f32 { + return switch (axis) { + .horizontal => rect.width, + .vertical => rect.height, + }; +} + +fn splitMainMax(rect: geometry.RectF, axis: canvas.SplitAxis) f32 { + return switch (axis) { + .horizontal => rect.maxX(), + .vertical => rect.maxY(), + }; +} + +fn splitMainPoint(point: geometry.PointF, axis: canvas.SplitAxis) f32 { + return switch (axis) { + .horizontal => point.x, + .vertical => point.y, + }; +} + +fn splitMainOrigin(rect: geometry.RectF, axis: canvas.SplitAxis) f32 { + return switch (axis) { + .horizontal => rect.x, + .vertical => rect.y, + }; +} + +fn splitPaneMin(widget: canvas.Widget, axis: canvas.SplitAxis) f32 { + return @max(0, switch (axis) { + .horizontal => widget.layout.min_size.width, + .vertical => widget.layout.min_size.height, + }); +} + +fn setCanvasWidgetNodeMainExtent(node: *canvas.WidgetLayoutNode, axis: canvas.SplitAxis, extent: f32) void { + switch (axis) { + .horizontal => node.frame.width = extent, + .vertical => node.frame.height = extent, + } + node.widget.frame = node.frame; +} + +fn setCanvasWidgetNodeMainOrigin(node: *canvas.WidgetLayoutNode, axis: canvas.SplitAxis, origin: f32) void { + switch (axis) { + .horizontal => node.frame.x = origin, + .vertical => node.frame.y = origin, + } + node.widget.frame = node.frame; } pub fn RuntimeViewCanvasWidgetControl(comptime RuntimeView: type) type { @@ -79,12 +125,12 @@ pub fn RuntimeViewCanvasWidgetControl(comptime RuntimeView: type) type { }; } - /// Divider drag: the captured `.split_divider` follows the - /// pointer's absolute x within the parent split's content box. - /// The runtime applies the fraction as the optimistic echo - /// (frames move geometrically; the model's rebuild is the exact - /// layout) and notes a resize event so the split's `on_resize` - /// Msg dispatches with the applied fraction. + /// Divider drag: the captured `.split_divider` follows the pointer + /// within the parent split's main-axis content box. The runtime + /// applies the fraction as the optimistic echo (frames move + /// geometrically; the model's rebuild is the exact layout) and notes + /// a resize event so the split's `on_resize` Msg dispatches with the + /// applied fraction. pub fn applyCanvasWidgetSplitPointer(self: *RuntimeView, id: canvas.ObjectId, point: geometry.PointF) anyerror!?geometry.RectF { const divider_index = self.canvasWidgetNodeIndexById(id) orelse return null; const divider = self.widget_layout_nodes[divider_index].widget; @@ -94,11 +140,12 @@ pub fn RuntimeViewCanvasWidgetControl(comptime RuntimeView: type) type { const split_node = self.widget_layout_nodes[split_index]; if (split_node.widget.kind != .split) return null; + const axis = split_node.widget.runtime_flags.split_axis; const content = split_node.frame.inset(split_node.widget.layout.padding).normalized(); - const divider_extent = self.widget_layout_nodes[divider_index].frame.width; - const available = content.width - divider_extent; + const divider_extent = splitMainExtent(self.widget_layout_nodes[divider_index].frame, axis); + const available = splitMainExtent(content, axis) - divider_extent; if (!(available > 0)) return null; - const fraction = (point.x - content.x - divider_extent * 0.5) / available; + const fraction = (splitMainPoint(point, axis) - splitMainOrigin(content, axis) - divider_extent * 0.5) / available; // A live drag owns the fraction: an armed layout tween on // this split retires here, so the pointer's per-step echoes // (live re-wrap) never fight the tween's slide — the @@ -108,10 +155,10 @@ pub fn RuntimeViewCanvasWidgetControl(comptime RuntimeView: type) type { return self.applyCanvasWidgetSplitFraction(split_index, fraction); } - /// Apply a first-pane fraction to a split: clamp against the - /// panes' min widths, move the divider and pane frames (pane - /// content translates with its pane; internal reflow waits for - /// the model's rebuild), and note the resize event. + /// Apply a first-pane fraction to a split: clamp against the panes' + /// main-axis minimums, move the divider and pane frames (pane content + /// translates with its pane; internal reflow waits for the model's + /// rebuild), and note the resize event. pub fn applyCanvasWidgetSplitFraction(self: *RuntimeView, split_index: usize, requested_fraction: f32) anyerror!?geometry.RectF { return self.applyCanvasWidgetSplitFractionMoved(split_index, requested_fraction, true); } @@ -153,31 +200,33 @@ pub fn RuntimeViewCanvasWidgetControl(comptime RuntimeView: type) type { const second_index = pane_indices[1] orelse return null; const handle_index = divider_index orelse return null; + const axis = split_node.widget.runtime_flags.split_axis; const content = split_node.frame.inset(split_node.widget.layout.padding).normalized(); - const divider_extent = self.widget_layout_nodes[handle_index].frame.width; - const available = @max(0, content.width - divider_extent); - const first_min = @max(0, self.widget_layout_nodes[first_index].widget.layout.min_size.width); - const second_min = @max(0, self.widget_layout_nodes[second_index].widget.layout.min_size.width); + const divider_extent = splitMainExtent(self.widget_layout_nodes[handle_index].frame, axis); + const available = @max(0, splitMainExtent(content, axis) - divider_extent); + const first_min = splitPaneMin(self.widget_layout_nodes[first_index].widget, axis); + const second_min = splitPaneMin(self.widget_layout_nodes[second_index].widget, axis); const fraction = canvas.splitEffectiveFraction(@max(requested_fraction, 0.0001), available, first_min, second_min); const previous_fraction = canvas.splitEffectiveFraction(self.widget_layout_nodes[handle_index].widget.value, available, first_min, second_min); if (fraction == previous_fraction) return null; - const first_width = available * fraction; - const divider_x = content.x + first_width; - const dx = divider_x - self.widget_layout_nodes[handle_index].frame.x; - if (dx == 0) return null; + const first_extent = available * fraction; + const divider_origin = splitMainOrigin(content, axis) + first_extent; + const delta = divider_origin - splitMainOrigin(self.widget_layout_nodes[handle_index].frame, axis); + if (delta == 0) return null; self.widget_layout_nodes[split_index].widget.value = fraction; self.widget_layout_nodes[handle_index].widget.value = fraction; - setCanvasWidgetNodeWidth(&self.widget_layout_nodes[first_index], first_width); - self.widget_layout_nodes[handle_index].frame.x = divider_x; - self.widget_layout_nodes[handle_index].widget.frame.x = divider_x; - const second_x = divider_x + divider_extent; - const second_width = @max(0, content.maxX() - second_x); - self.widget_layout_nodes[second_index].frame.x = second_x; - self.widget_layout_nodes[second_index].widget.frame.x = second_x; - setCanvasWidgetNodeWidth(&self.widget_layout_nodes[second_index], second_width); - self.translateCanvasWidgetDescendantsX(second_index, dx); + setCanvasWidgetNodeMainExtent(&self.widget_layout_nodes[first_index], axis, first_extent); + setCanvasWidgetNodeMainOrigin(&self.widget_layout_nodes[handle_index], axis, divider_origin); + const second_origin = divider_origin + divider_extent; + setCanvasWidgetNodeMainOrigin(&self.widget_layout_nodes[second_index], axis, second_origin); + setCanvasWidgetNodeMainExtent( + &self.widget_layout_nodes[second_index], + axis, + @max(0, splitMainMax(content, axis) - second_origin), + ); + self.translateCanvasWidgetDescendants(second_index, axis, delta); if (note_resize) self.noteCanvasWidgetResizeEvent(split_node.widget.id); try self.refreshCanvasWidgetSemantics(); @@ -213,18 +262,27 @@ pub fn RuntimeViewCanvasWidgetControl(comptime RuntimeView: type) type { self.widget_change_event_count += 1; } - /// Horizontal twin of `translateCanvasWidgetScrollDescendants`: - /// shift a pane subtree sideways when its pane edge moves. - pub fn translateCanvasWidgetDescendantsX(self: *RuntimeView, node_index: usize, dx: f32) void { + /// Shift a pane subtree along its split axis when the leading edge + /// moves. Frames and widget mirrors stay synchronized. + pub fn translateCanvasWidgetDescendants(self: *RuntimeView, node_index: usize, axis: canvas.SplitAxis, delta: f32) void { const depth = self.widget_layout_nodes[node_index].depth; + const offset = switch (axis) { + .horizontal => geometry.OffsetF.init(delta, 0), + .vertical => geometry.OffsetF.init(0, delta), + }; var index = node_index + 1; while (index < self.widget_layout_node_count and self.widget_layout_nodes[index].depth > depth) : (index += 1) { - const translated = self.widget_layout_nodes[index].frame.translate(.{ .dx = dx, .dy = 0 }); + const translated = self.widget_layout_nodes[index].frame.translate(offset); self.widget_layout_nodes[index].frame = translated; self.widget_layout_nodes[index].widget.frame = translated; } } + /// Compatibility entry point for existing horizontal callers. + pub fn translateCanvasWidgetDescendantsX(self: *RuntimeView, node_index: usize, dx: f32) void { + self.translateCanvasWidgetDescendants(node_index, .horizontal, dx); + } + pub fn applyCanvasWidgetResizableDelta(self: *RuntimeView, id: canvas.ObjectId, delta_x: f32) anyerror!?geometry.RectF { if (!std.math.isFinite(delta_x) or delta_x == 0) return null; const index = self.canvasWidgetNodeIndexById(id) orelse return null; diff --git a/src/runtime/view_widget_tree.zig b/src/runtime/view_widget_tree.zig index 2e0957d0b..ba88f8084 100644 --- a/src/runtime/view_widget_tree.zig +++ b/src/runtime/view_widget_tree.zig @@ -380,7 +380,7 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type { if (node.widget.semantics.role == .link and !node.widget.state.disabled) { return platformCursorFromCanvas(.pointing_hand); } - return platformCursorFromCanvas(canvas.cursorForWidgetTarget(node.widget.kind, node.widget.state)); + return platformCursorFromCanvas(canvas.cursorForWidgetTargetOnAxis(node.widget.kind, node.widget.state, node.widget.runtime_flags.split_axis)); } pub fn canvasWidgetRenderState(self: *const RuntimeView) canvas.WidgetRenderState { @@ -1171,6 +1171,16 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type { .decrement => "arrowleft", }; return switch (self.widget_layout_nodes[index].widget.kind) { + .split_divider => switch (self.widget_layout_nodes[index].widget.runtime_flags.split_axis) { + .horizontal => switch (direction) { + .increment => "arrowright", + .decrement => "arrowleft", + }, + .vertical => switch (direction) { + .increment => "arrowdown", + .decrement => "arrowup", + }, + }, .grid, .scroll_view, .list, .data_grid, .table => switch (direction) { // Page keys step the vertical axis on every keymap // except the horizontal-only one (which mirrors the diff --git a/src/runtime/widget_bridge.zig b/src/runtime/widget_bridge.zig index 82bcc5df0..0822cc5ae 100644 --- a/src/runtime/widget_bridge.zig +++ b/src/runtime/widget_bridge.zig @@ -33,6 +33,7 @@ pub fn platformCursorFromCanvas(cursor: canvas.WidgetCursor) platform.Cursor { .pointing_hand => .pointing_hand, .text => .text, .resize_horizontal => .resize_horizontal, + .resize_vertical => .resize_vertical, }; }