From 02102927c3027f336efe09bd7673b917dacd330e Mon Sep 17 00:00:00 2001 From: tomaszbk Date: Tue, 29 Sep 2026 23:46:21 -0300 Subject: [PATCH 1/7] feat: add responsive flex and grid layout with intrinsic sizing Add FlexLayout and extend GridLayout with spans, automatic columns and content measurement, shared by native backends and the custom renderer, in the V API and runtime VML. Co-Authored-By: Claude Opus 5.5 --- examples/responsive_layout/main.v | 36 ++ .../responsive_layout/responsive_layout.vml | 96 ++++ ui/flex_layout.v | 383 ++++++++++++++++ ui/flex_layout_test.v | 238 ++++++++++ ui/grid_layout.v | 387 ++++++++++++++-- ui/grid_layout_test.v | 251 ++++++++++- ui/layout_measure.v | 202 +++++++++ ui/layout_measure_backend.c.v | 21 + ui/layout_measure_cpu.c.v | 108 +++++ ui/layout_measure_darwin.c.v | 23 + ui/layout_measure_darwin_test.v | 22 + ui/layout_measure_test.v | 92 ++++ ui/vml.v | 48 +- ui/vml_flex.v | 181 ++++++++ ui/vml_model.v | 127 ++++-- ui/vml_model_layout.v | 162 +++++++ ui/vml_modern_layout_test.v | 424 ++++++++++++++++++ 17 files changed, 2696 insertions(+), 105 deletions(-) create mode 100644 examples/responsive_layout/main.v create mode 100644 examples/responsive_layout/responsive_layout.vml create mode 100644 ui/flex_layout.v create mode 100644 ui/flex_layout_test.v create mode 100644 ui/layout_measure.v create mode 100644 ui/layout_measure_backend.c.v create mode 100644 ui/layout_measure_cpu.c.v create mode 100644 ui/layout_measure_darwin.c.v create mode 100644 ui/layout_measure_darwin_test.v create mode 100644 ui/layout_measure_test.v create mode 100644 ui/vml_flex.v create mode 100644 ui/vml_model_layout.v create mode 100644 ui/vml_modern_layout_test.v diff --git a/examples/responsive_layout/main.v b/examples/responsive_layout/main.v new file mode 100644 index 00000000..1487c3d0 --- /dev/null +++ b/examples/responsive_layout/main.v @@ -0,0 +1,36 @@ +module main + +import os +import ui2 + +const responsive_source = $embed_file('responsive_layout.vml').to_string() + +pub struct Project { +pub: + id int + title string + description string +} + +pub struct ResponsiveApp { +pub mut: + created int + projects []Project = [ + Project{ id: 1, title: 'Design system', description: 'Shared controls and typography' }, + Project{ id: 2, title: 'Desktop app', description: 'One layout across window sizes' }, + ] +} + +pub fn (mut app ResponsiveApp) create_project() { + app.created++ +} + +fn main() { + ui2.run_vml[ResponsiveApp]( + source: responsive_source + model: ResponsiveApp{} + title: 'UI2 ยท Responsive layout' + width: if '--compact' in os.args { 390 } else { 1000 } + height: 780 + ) or { panic(err) } +} diff --git a/examples/responsive_layout/responsive_layout.vml b/examples/responsive_layout/responsive_layout.vml new file mode 100644 index 00000000..7dd94c9c --- /dev/null +++ b/examples/responsive_layout/responsive_layout.vml @@ -0,0 +1,96 @@ +Screen { + id: root + background: #F2F5FA + + ScrollView { + id: viewport + width: root.width + height: root.height + background: #F2F5FA + + FlexLayout { + id: page + width: root.width + height: root.height < 740 ? 740 : root.height + orientation: vertical + padding: 24 + gap: 20 + background: #F2F5FA + + FlexLayout { + id: toolbar + gap: 12 + line_gap: 12 + wrap: true + align_items: center + transparent: true + + Label { text: "UI2 Studio" font_size: 26 bold: true color: #17243D flex_grow: 1 min_width: 170 } + Button { id: create text: "New project" on_tap: app.create_project() background: #315EEB color: #FFFFFF corner_radius: 8 } + Button { id: settings text: "Settings" background: #FFFFFF color: #17243D corner_radius: 8 } + } + + FlexLayout { + id: searchbar + gap: 12 + wrap: true + align_items: center + transparent: true + + TextField { id: search placeholder: "Search projects" width: 220 height: 38 flex_grow: 1 min_width: 150 background: #FFFFFF corner_radius: 8 } + Label { id: count text: "Created: " + app.created color: #52627D } + } + + GridLayout { + id: projects + flex_grow: 1 + auto_columns_min_width: 240 + max_columns: 3 + spacing: 16 + transparent: true + + FlexLayout { + id: featured + column_span: 2 + orientation: vertical + padding: 20 + gap: 10 + background: #17243D + corner_radius: 14 + + Label { text: "A layout that adapts" font_size: 23 bold: true color: #FFFFFF height: 30 } + Label { text: "Flex, grid and content sizing." color: #C8D7F3 height: 24 } + View { flex_grow: 1 transparent: true } + FlexLayout { + justify: end + transparent: true + Button { text: "Create project" on_tap: app.create_project() background: #FFFFFF color: #17243D corner_radius: 8 } + } + } + + Repeater { + model: app.projects + key: item.id + FlexLayout { + orientation: vertical + padding: 20 + gap: 10 + background: #FFFFFF + corner_radius: 14 + + Label { text: item.title font_size: 20 bold: true color: #17243D height: 26 } + Label { text: item.description color: #52627D height: 24 } + View { flex_grow: 1 transparent: true } + FlexLayout { + justify: end + transparent: true + Button { text: "Open project" background: #E9EFFD color: #244ECA corner_radius: 8 } + } + } + } + } + + Label { text: "Resize the window. Your search stays in place." color: #52627D font_size: 12 height: 20 } + } + } +} diff --git a/ui/flex_layout.v b/ui/flex_layout.v new file mode 100644 index 00000000..18107832 --- /dev/null +++ b/ui/flex_layout.v @@ -0,0 +1,383 @@ +module ui2 + +import math + +pub enum FlexAlignment { + auto + start + center + end + stretch +} + +pub enum FlexJustify { + start + center + end + space_between + space_around + space_evenly +} + +// FlexLayoutChild supplies preferred main-axis size and its allowed bounds. +// A basis of -1 uses the element's frame size. Grow shares extra space; shrink +// shares a deficit in proportion to shrink * basis. Explicit minima may overflow. +pub struct FlexLayoutChild { +pub: + element Element + basis f64 = -1 + grow f64 + shrink f64 = 1 + minimum_width f64 + minimum_height f64 + maximum_width f64 = -1 + maximum_height f64 = -1 + align_self FlexAlignment = .auto +} + +pub struct FlexLayoutConfig { +pub: + id string + frame Rect + box BoxStyle + orientation BoxOrientation + padding BoxPadding + gap f64 + line_gap f64 = -1 + wrap bool + justify FlexJustify + align FlexAlignment = .stretch + children []FlexLayoutChild +} + +pub fn flex_alignment(value string) !FlexAlignment { + return match value { + '', 'auto' { .auto } + 'start' { .start } + 'center' { .center } + 'end' { .end } + 'stretch' { .stretch } + else { return error('unknown flex alignment `${value}`') } + } +} + +pub fn flex_justify(value string) !FlexJustify { + return match value { + '', 'start' { .start } + 'center' { .center } + 'end' { .end } + 'space_between' { .space_between } + 'space_around' { .space_around } + 'space_evenly' { .space_evenly } + else { return error('unknown flex justification `${value}`') } + } +} + +fn flex_finite(value f64) bool { + return !math.is_nan(value) && !math.is_inf(value, 0) +} + +fn flex_validate(config FlexLayoutConfig) ! { + if config.align == .auto { + return error('flex layout alignment must be start, center, end, or stretch') + } + for value in [config.frame.x, config.frame.y, config.frame.width, config.frame.height, + config.padding.left, config.padding.top, config.padding.right, config.padding.bottom, + config.gap, config.line_gap] { + if !flex_finite(value) { + return error('flex layout dimensions and spacing must be finite') + } + } + if config.frame.width < 0 || config.frame.height < 0 { + return error('flex layout dimensions cannot be negative') + } + if config.gap < 0 || (config.line_gap < 0 && config.line_gap != -1) { + return error('flex layout gaps cannot be negative (line_gap accepts -1 for gap)') + } + if config.padding.left < 0 || config.padding.top < 0 || config.padding.right < 0 + || config.padding.bottom < 0 { + return error('flex layout padding cannot be negative') + } + mut total_width := config.padding.left + config.padding.right + mut total_height := config.padding.top + config.padding.bottom + for child in config.children { + for value in [child.element.frame.x, child.element.frame.y, child.element.frame.width, + child.element.frame.height, child.basis, child.grow, child.shrink, child.minimum_width, + child.minimum_height, child.maximum_width, child.maximum_height] { + if !flex_finite(value) { + return error('flex child sizes, bounds, and weights must be finite') + } + } + if child.element.frame.width < 0 || child.element.frame.height < 0 + || (child.basis < 0 && child.basis != -1) { + return error('flex child dimensions and basis cannot be negative (basis accepts -1)') + } + if child.grow < 0 || child.shrink < 0 { + return error('flex child grow and shrink cannot be negative') + } + if child.minimum_width < 0 || child.minimum_height < 0 + || (child.maximum_width < 0 && child.maximum_width != -1) + || (child.maximum_height < 0 && child.maximum_height != -1) { + return error('flex child bounds cannot be negative (maximum accepts -1)') + } + if child.maximum_width >= 0 && child.minimum_width > child.maximum_width { + return error('flex child minimum width cannot exceed its maximum width') + } + if child.maximum_height >= 0 && child.minimum_height > child.maximum_height { + return error('flex child minimum height cannot exceed its maximum height') + } + // Reject arithmetic overflow as well as explicitly non-finite inputs. + total_width += box_max(child.element.frame.width, box_max(child.minimum_width, + box_max(child.maximum_width, child.basis))) + config.gap + total_height += box_max(child.element.frame.height, box_max(child.minimum_height, + box_max(child.maximum_height, child.basis))) + config.gap + } + if !flex_finite(total_width) || !flex_finite(total_height) { + return error('flex layout total size exceeds the supported range') + } +} + +fn flex_main_bounds(child FlexLayoutChild, orientation BoxOrientation) (f64, f64) { + return if orientation == .horizontal { + child.minimum_width, child.maximum_width + } else { + child.minimum_height, child.maximum_height + } +} + +fn flex_basis(child FlexLayoutChild, orientation BoxOrientation) f64 { + preferred := if child.basis >= 0 { + child.basis + } else if orientation == .horizontal { + child.element.frame.width + } else { + child.element.frame.height + } + minimum, maximum := flex_main_bounds(child, orientation) + return box_bound(preferred, minimum, maximum) +} + +fn flex_cross_size(child FlexLayoutChild, orientation BoxOrientation, stretch f64) f64 { + if orientation == .horizontal { + return box_bound(if stretch >= 0 { stretch } else { child.element.frame.height }, + child.minimum_height, child.maximum_height) + } + return box_bound(if stretch >= 0 { stretch } else { child.element.frame.width }, + child.minimum_width, child.maximum_width) +} + +struct FlexLine { + start int + end int + cross f64 +} + +fn flex_lines(config FlexLayoutConfig, available f64) []FlexLine { + mut lines := []FlexLine{} + mut start := 0 + mut used := f64(0) + mut cross := f64(0) + for index, child in config.children { + basis := flex_basis(child, config.orientation) + if config.wrap && index > start && used + config.gap + basis > available { + lines << FlexLine{start, index, cross} + start = index + used = 0 + cross = 0 + } + if index > start { + used += config.gap + } + used += basis + cross = box_max(cross, flex_cross_size(child, config.orientation, -1)) + } + if start < config.children.len { + lines << FlexLine{start, config.children.len, cross} + } + return lines +} + +fn flex_line_sizes(config FlexLayoutConfig, line FlexLine, available f64) []f64 { + count := line.end - line.start + mut bases := []f64{cap: count} + mut sizes := []f64{cap: count} + for index in line.start .. line.end { + basis := flex_basis(config.children[index], config.orientation) + bases << basis + sizes << basis + } + // Every redistribution either consumes all remaining space or locks at least + // one item at a bound. One final pass is enough after all possible locks. + for _ in 0 .. count + 1 { + mut used := f64(0) + for size in sizes { + used += size + } + remaining := available - used + if remaining == 0 { + break + } + growing := remaining > 0 + mut max_weight := f64(0) + mut max_basis := f64(0) + mut active := []bool{len: count} + for offset in 0 .. count { + child := config.children[line.start + offset] + minimum, maximum := flex_main_bounds(child, config.orientation) + if (growing && child.grow > 0 && (maximum < 0 || sizes[offset] < maximum)) + || (!growing && child.shrink > 0 && bases[offset] > 0 && sizes[offset] > minimum) { + active[offset] = true + max_weight = box_max(max_weight, if growing { child.grow } else { child.shrink }) + max_basis = box_max(max_basis, bases[offset]) + } + } + if max_weight == 0 { + break + } + // Normalize before multiplying: valid large weights must not overflow. + mut weights := []f64{len: count} + mut total_weight := f64(0) + for offset, pending in active { + if pending { + child := config.children[line.start + offset] + weights[offset] = if growing { + child.grow / max_weight + } else { + (child.shrink / max_weight) * (bases[offset] / max_basis) + } + total_weight += weights[offset] + } + } + if total_weight == 0 { + break + } + mut limited := false + for offset, pending in active { + if pending { + minimum, maximum := flex_main_bounds(config.children[line.start + offset], + config.orientation) + candidate := sizes[offset] + remaining * (weights[offset] / total_weight) + sizes[offset] = box_bound(candidate, minimum, maximum) + limited = limited || sizes[offset] != candidate + } + } + if !limited { + break + } + } + return sizes +} + +fn flex_justify_space(justify FlexJustify, free_space f64, count int, gap f64) (f64, f64) { + remaining := box_max(0, free_space) + return match justify { + .start { 0.0, gap } + .center { remaining / 2, gap } + .end { remaining, gap } + .space_between { 0.0, if count > 1 { gap + remaining / f64(count - 1) } else { gap } } + .space_around { remaining / f64(count * 2), gap + remaining / f64(count) } + .space_evenly { remaining / f64(count + 1), gap + remaining / f64(count + 1) } + } +} + +// flex_layout_frames returns local child frames in declaration order. Wrapping +// uses bounded preferred bases before distributing each line's extra space. +// Wrapped lines keep their preferred cross size and are separated by line_gap. +// An unwrapped line occupies the available cross size. Small viewports clamp +// available size to zero, while explicit minima and non-shrinking items overflow. +pub fn flex_layout_frames(config FlexLayoutConfig) ![]Rect { + flex_validate(config)! + main_start := if config.orientation == .horizontal { + config.padding.left + } else { + config.padding.top + } + cross_start := if config.orientation == .horizontal { + config.padding.top + } else { + config.padding.left + } + main_available := box_max(0, if config.orientation == .horizontal { + config.frame.width - config.padding.left - config.padding.right + } else { + config.frame.height - config.padding.top - config.padding.bottom + }) + cross_available := box_max(0, if config.orientation == .horizontal { + config.frame.height - config.padding.top - config.padding.bottom + } else { + config.frame.width - config.padding.left - config.padding.right + }) + line_gap := if config.line_gap >= 0 { config.line_gap } else { config.gap } + mut frames := []Rect{cap: config.children.len} + mut cross_cursor := cross_start + for line in flex_lines(config, main_available) { + count := line.end - line.start + gaps := config.gap * f64(count - 1) + sizes := flex_line_sizes(config, line, box_max(0, main_available - gaps)) + mut used := gaps + for size in sizes { + used += size + } + offset, gap := flex_justify_space(config.justify, main_available - used, count, config.gap) + mut cursor := main_start + offset + line_cross := if config.wrap { line.cross } else { cross_available } + for index in line.start .. line.end { + child := config.children[index] + align := if child.align_self == .auto { config.align } else { child.align_self } + cross_size := flex_cross_size(child, config.orientation, if align == .stretch { + line_cross + } else { + -1.0 + }) + cross_offset := match align { + .center { (line_cross - cross_size) / 2 } + .end { line_cross - cross_size } + else { 0.0 } + } + main_size := sizes[index - line.start] + frames << if config.orientation == .horizontal { + rect(cursor, cross_cursor + cross_offset, main_size, cross_size) + } else { + rect(cross_cursor + cross_offset, cursor, cross_size, main_size) + } + cursor += main_size + gap + } + cross_cursor += line_cross + line_gap + } + return frames +} + +// flex_layout_preferred_size reports the natural unwrapped size before growing +// or shrinking. The caller may measure text/content into element frames first. +pub fn flex_layout_preferred_size(config FlexLayoutConfig) !Rect { + flex_validate(config)! + mut main := if config.children.len > 0 { + config.gap * f64(config.children.len - 1) + } else { + 0.0 + } + mut cross := f64(0) + for child in config.children { + main += flex_basis(child, config.orientation) + cross = box_max(cross, flex_cross_size(child, config.orientation, -1)) + } + return if config.orientation == .horizontal { + rect(0, 0, main + config.padding.left + config.padding.right, + cross + config.padding.top + config.padding.bottom) + } else { + rect(0, 0, cross + config.padding.left + config.padding.right, + main + config.padding.top + config.padding.bottom) + } +} + +pub fn flex_layout(config FlexLayoutConfig) !Element { + frames := flex_layout_frames(config)! + mut children := []Element{cap: config.children.len} + for index, child in config.children { + children << Element{ + ...child.element + frame: frames[index] + } + } + return view(config.id, config.frame, config.box, children) +} diff --git a/ui/flex_layout_test.v b/ui/flex_layout_test.v new file mode 100644 index 00000000..0535f88b --- /dev/null +++ b/ui/flex_layout_test.v @@ -0,0 +1,238 @@ +module ui2 + +import math + +fn flex_test_child(width f64, height f64) Element { + return view('child', rect(17, 23, width, height), BoxStyle{}, []) +} + +fn flex_test_near(actual f64, expected f64) { + assert math.abs(actual - expected) < 0.000001, 'expected ${expected}, got ${actual}' +} + +fn test_flex_grow_preserves_bases_and_redistributes_at_multiple_bounds() { + frames := flex_layout_frames(FlexLayoutConfig{ + frame: rect(100, 200, 420, 80) + padding: BoxPadding{ left: 10, top: 5, right: 10, bottom: 5 } + gap: 10 + children: [ + FlexLayoutChild{ element: flex_test_child(40, 20), grow: 1, maximum_width: 60 }, + FlexLayoutChild{ element: flex_test_child(40, 20), grow: 1, maximum_width: 100 }, + FlexLayoutChild{ element: flex_test_child(40, 20), grow: 2 }, + ] + }) or { panic(err) } + assert frames == [rect(10, 5, 60, 70), rect(80, 5, 100, 70), rect(190, 5, 220, 70)] +} + +fn test_flex_shrink_is_basis_weighted_and_redistributes_at_minimum() { + frames := flex_layout_frames(FlexLayoutConfig{ + frame: rect(0, 0, 150, 30) + children: [ + FlexLayoutChild{ element: flex_test_child(100, 10), minimum_width: 80 }, + FlexLayoutChild{ element: flex_test_child(200, 10) }, + ] + }) or { panic(err) } + assert frames == [rect(0, 0, 80, 30), rect(80, 0, 70, 30)] + weighted := flex_layout_frames(FlexLayoutConfig{ + frame: rect(0, 0, 225, 30) + children: [ + FlexLayoutChild{ element: flex_test_child(100, 10) }, + FlexLayoutChild{ element: flex_test_child(200, 10) }, + ] + }) or { panic(err) } + assert weighted[0].width == 75 + assert weighted[1].width == 150 +} + +fn test_flex_shrink_respects_weights_disabled_shrink_and_explicit_basis() { + frames := flex_layout_frames(FlexLayoutConfig{ + frame: rect(0, 0, 250, 30) + children: [ + FlexLayoutChild{ element: flex_test_child(20, 10), basis: 100, shrink: 0 }, + FlexLayoutChild{ element: flex_test_child(100, 10), shrink: 1 }, + FlexLayoutChild{ element: flex_test_child(100, 10), shrink: 3 }, + ] + }) or { panic(err) } + assert frames[0].width == 100 + assert frames[1].width == 87.5 + assert frames[2].width == 62.5 +} + +fn test_flex_wrap_uses_bounded_bases_and_grows_each_line_independently() { + frames := flex_layout_frames(FlexLayoutConfig{ + frame: rect(0, 0, 210, 200) + gap: 10 + line_gap: 7 + wrap: true + align: .center + children: [ + FlexLayoutChild{ element: flex_test_child(100, 20), grow: 1 }, + FlexLayoutChild{ element: flex_test_child(100, 40), grow: 1 }, + FlexLayoutChild{ element: flex_test_child(130, 30), maximum_width: 100, grow: 1 }, + ] + }) or { panic(err) } + assert frames == [rect(0, 10, 100, 20), rect(110, 0, 100, 40), rect(0, 47, 100, 30)] +} + +fn test_flex_wrap_shrinks_oversized_item_without_empty_line() { + frames := flex_layout_frames(FlexLayoutConfig{ + frame: rect(0, 0, 100, 100) + gap: 8 + wrap: true + children: [ + FlexLayoutChild{ element: flex_test_child(200, 20) }, + FlexLayoutChild{ element: flex_test_child(40, 30), grow: 1 }, + ] + }) or { panic(err) } + assert frames == [rect(0, 0, 100, 20), rect(0, 28, 100, 30)] +} + +fn test_vertical_flex_wrap_and_alignment_are_axis_independent() { + frames := flex_layout_frames(FlexLayoutConfig{ + frame: rect(20, 20, 200, 130) + orientation: .vertical + padding: BoxPadding{ left: 5, top: 10, right: 5, bottom: 10 } + gap: 10 + line_gap: 15 + wrap: true + align: .end + children: [ + FlexLayoutChild{ element: flex_test_child(20, 50) }, + FlexLayoutChild{ element: flex_test_child(40, 50) }, + FlexLayoutChild{ element: flex_test_child(30, 50), grow: 1 }, + ] + }) or { panic(err) } + assert frames == [rect(25, 10, 20, 50), rect(5, 70, 40, 50), rect(60, 10, 30, 110)] +} + +fn test_flex_justification_and_child_alignment() { + base := FlexLayoutConfig{ + frame: rect(0, 0, 200, 80) + gap: 10 + align: .center + children: [ + FlexLayoutChild{ element: flex_test_child(40, 20) }, + FlexLayoutChild{ element: flex_test_child(40, 20), align_self: .end }, + ] + } + expected_x := [[0.0, 50.0], [55.0, 105.0], [110.0, 160.0], [0.0, 160.0], [27.5, 132.5], + [110.0 / 3, 50.0 + 220.0 / 3]] + for index, justify in [FlexJustify.start, .center, .end, .space_between, .space_around, + .space_evenly] { + frames := flex_layout_frames(FlexLayoutConfig{ ...base, justify: justify }) or { panic(err) } + flex_test_near(frames[0].x, expected_x[index][0]) + flex_test_near(frames[1].x, expected_x[index][1]) + assert frames[0].y == 30 + assert frames[1].y == 60 + } + stretched := flex_layout_frames(FlexLayoutConfig{ + ...base + children: [FlexLayoutChild{ + element: flex_test_child(40, 20) + align_self: .stretch + maximum_height: 50 + }] + }) or { panic(err) } + assert stretched == [rect(0, 0, 40, 50)] +} + +fn test_flex_small_viewports_keep_nonnegative_sizes_and_explicit_minimum_overflow() { + frames := flex_layout_frames(FlexLayoutConfig{ + frame: rect(0, 0, 0, 0) + padding: BoxPadding{ left: 10, top: 10, right: 10, bottom: 10 } + gap: 10 + justify: .center + children: [ + FlexLayoutChild{ element: flex_test_child(100, 20), minimum_width: 20, minimum_height: 5 }, + FlexLayoutChild{ element: flex_test_child(100, 20) }, + ] + }) or { panic(err) } + assert frames == [rect(10, 10, 20, 5), rect(40, 10, 0, 0)] +} + +fn test_flex_preferred_size_and_element_preserve_declared_content() { + config := FlexLayoutConfig{ + id: 'flex' + frame: rect(20, 30, 300, 60) + padding: BoxPadding{ left: 5, top: 6, right: 7, bottom: 8 } + gap: 10 + children: [ + FlexLayoutChild{ + element: button('a', 'Accept', rect(4, 8, 60, 20), BoxStyle{}, TextStyle{}) + basis: 80 + grow: 1 + }, + FlexLayoutChild{ + element: button('b', 'Cancel', rect(4, 8, 70, 30), BoxStyle{}, TextStyle{}) + grow: 1 + }, + ] + } + assert flex_layout_preferred_size(config)! == rect(0, 0, 172, 44) + layout := flex_layout(config) or { panic(err) } + assert layout.id == 'flex' + assert layout.frame == config.frame + assert layout.children[0].id == 'a' + assert layout.children[0].text == 'Accept' + assert layout.children[1].text == 'Cancel' + assert layout.children[0].frame == rect(5, 6, 144, 46) + assert layout.children[1].frame == rect(159, 6, 134, 46) +} + +fn test_flex_empty_and_single_item_justification() { + assert flex_layout_frames(FlexLayoutConfig{})! == []Rect{} + assert flex_layout_preferred_size(FlexLayoutConfig{})! == rect(0, 0, 0, 0) + for justify in [FlexJustify.space_around, .space_evenly] { + frames := flex_layout_frames(FlexLayoutConfig{ + frame: rect(0, 0, 100, 40) + justify: justify + children: [FlexLayoutChild{ element: flex_test_child(20, 10) }] + }) or { panic(err) } + assert frames == [rect(40, 0, 20, 40)] + } +} + +fn test_flex_large_weights_stay_finite() { + frames := flex_layout_frames(FlexLayoutConfig{ + frame: rect(0, 0, 50, 30) + children: [ + FlexLayoutChild{ element: flex_test_child(100, 10), shrink: 1.0e308 }, + FlexLayoutChild{ element: flex_test_child(100, 10), shrink: 1.0e308 }, + ] + }) or { panic(err) } + assert frames == [rect(0, 0, 25, 30), rect(25, 0, 25, 30)] +} + +fn test_flex_rejects_invalid_numeric_inputs_and_bounds() { + base := FlexLayoutConfig{ frame: rect(0, 0, 100, 40) } + invalid_configs := [ + FlexLayoutConfig{ ...base, frame: rect(0, 0, -1, 40) }, + FlexLayoutConfig{ ...base, frame: rect(0, 0, math.inf(1), 40) }, + FlexLayoutConfig{ ...base, padding: BoxPadding{ left: -1 } }, + FlexLayoutConfig{ ...base, padding: BoxPadding{ top: math.nan() } }, + FlexLayoutConfig{ ...base, gap: -1 }, + FlexLayoutConfig{ ...base, line_gap: -2 }, + FlexLayoutConfig{ ...base, align: .auto }, + ] + for config in invalid_configs { + if _ := flex_layout_frames(config) { + assert false, 'invalid config must fail' + } + } + child := FlexLayoutChild{ element: flex_test_child(10, 10) } + invalid_children := [ + FlexLayoutChild{ ...child, basis: -2 }, + FlexLayoutChild{ ...child, basis: math.nan() }, + FlexLayoutChild{ ...child, grow: -1 }, + FlexLayoutChild{ ...child, shrink: math.inf(1) }, + FlexLayoutChild{ ...child, minimum_width: -1 }, + FlexLayoutChild{ ...child, maximum_height: -2 }, + FlexLayoutChild{ ...child, minimum_width: 20, maximum_width: 10 }, + FlexLayoutChild{ ...child, minimum_height: 20, maximum_height: 10 }, + ] + for invalid in invalid_children { + if _ := flex_layout_frames(FlexLayoutConfig{ ...base, children: [invalid] }) { + assert false, 'invalid child must fail' + } + } +} diff --git a/ui/grid_layout.v b/ui/grid_layout.v index 06861698..32997be2 100644 --- a/ui/grid_layout.v +++ b/ui/grid_layout.v @@ -1,5 +1,7 @@ module ui2 +import math + pub enum GridOrientation { left_to_right_top_to_bottom top_to_bottom_left_to_right @@ -25,13 +27,26 @@ pub: vertical f64 } +// GridSpan reserves adjacent cells for one child. Spans follow the configured +// fill direction. In an automatic grid, column spans shrink with the viewport. +pub struct GridSpan { +pub: + column_span int = 1 + row_span int = 1 +} + pub struct GridLayoutConfig { pub: - id string - frame Rect - box BoxStyle - columns int - rows int + id string + frame Rect + box BoxStyle + columns int + rows int + // Automatic columns are used only when neither columns nor rows is set. + auto_columns_min_width f64 + max_columns int + // Entries correspond to children; missing trailing entries mean 1 x 1. + child_spans []GridSpan orientation GridOrientation padding GridPadding spacing GridSpacing @@ -44,6 +59,196 @@ pub: children []Element } +struct GridPlacement { + column int + row int + column_span int = 1 + row_span int = 1 +} + +fn grid_validate_config(config GridLayoutConfig, child_count int) ! { + if child_count < 0 { + return error('grid child count cannot be negative') + } + if config.columns < 0 || config.rows < 0 || config.max_columns < 0 { + return error('grid rows, columns and max_columns cannot be negative') + } + for value in [config.frame.width, config.frame.height, config.padding.left, config.padding.top, + config.padding.right, config.padding.bottom, config.spacing.horizontal, + config.spacing.vertical, config.column_default_width, config.row_default_height, + config.auto_columns_min_width] { + if math.is_nan(value) || math.is_inf(value, 0) || value < 0 { + return error('grid dimensions, spacing and padding must be finite and non-negative') + } + } + for minimum in [config.columns_minimum, config.rows_minimum] { + for index, value in minimum { + if index < 0 { + return error('grid minimum indices cannot be negative') + } + if math.is_nan(value) || math.is_inf(value, 0) || value < 0 { + return error('grid minimum sizes must be finite and non-negative') + } + } + } + if config.child_spans.len > child_count { + return error('grid has more child spans than children') + } + for span in config.child_spans { + if span.column_span < 1 || span.row_span < 1 { + return error('grid child spans must be positive') + } + } +} + +fn grid_automatic_columns(config GridLayoutConfig, child_count int) int { + inner := math.max(0, config.frame.width - config.padding.left - config.padding.right) + fit := if inner < config.auto_columns_min_width { + f64(1) + } else { + 1 + math.floor((inner - config.auto_columns_min_width) / (config.auto_columns_min_width + config.spacing.horizontal)) + } + mut columns := int(math.min(f64(math.max(1, child_count)), fit)) + if config.max_columns > 0 { + columns = math.min(columns, config.max_columns) + } + return columns +} + +fn grid_column_major(orientation GridOrientation) bool { + return orientation in [.top_to_bottom_left_to_right, .top_to_bottom_right_to_left, + .bottom_to_top_left_to_right, .bottom_to_top_right_to_left] +} + +fn grid_reverse_columns(orientation GridOrientation) bool { + return orientation in [.right_to_left_top_to_bottom, .top_to_bottom_right_to_left, + .right_to_left_bottom_to_top, .bottom_to_top_right_to_left] +} + +fn grid_reverse_rows(orientation GridOrientation) bool { + return orientation in [.left_to_right_bottom_to_top, .bottom_to_top_left_to_right, + .right_to_left_bottom_to_top, .bottom_to_top_right_to_left] +} + +fn grid_pack_spans(spans []GridSpan, columns int, rows int, orientation GridOrientation) ?[]GridPlacement { + mut occupied := []bool{len: columns * rows} + mut cells := []GridPlacement{cap: spans.len} + for span in spans { + mut found := false + for index in 0 .. occupied.len { + column := if grid_column_major(orientation) { index / rows } else { index % columns } + row := if grid_column_major(orientation) { index % rows } else { index / columns } + if column + span.column_span > columns || row + span.row_span > rows { + continue + } + mut available := true + for y in row .. row + span.row_span { + for x in column .. column + span.column_span { + if occupied[y * columns + x] { + available = false + } + } + } + if !available { + continue + } + for y in row .. row + span.row_span { + for x in column .. column + span.column_span { + occupied[y * columns + x] = true + } + } + cells << GridPlacement{ + column: if grid_reverse_columns(orientation) { + columns - column - span.column_span + } else { + column + } + row: if grid_reverse_rows(orientation) { + rows - row - span.row_span + } else { + row + } + column_span: span.column_span + row_span: span.row_span + } + found = true + break + } + if !found { + return none + } + } + return cells +} + +fn grid_spanned_dimensions(config GridLayoutConfig, child_count int, automatic bool) !(int, int, []GridPlacement) { + mut columns := if automatic { + grid_automatic_columns(config, child_count) + } else { + config.columns + } + mut rows := config.rows + if columns == 0 && rows == 0 { + return error('grid layout requires columns or rows') + } + mut spans := []GridSpan{len: child_count} + mut area := i64(0) + mut max_column_span := 1 + mut max_row_span := 1 + for index in 0 .. child_count { + span := if index < config.child_spans.len { config.child_spans[index] } else { GridSpan{} } + spans[index] = GridSpan{ + column_span: if automatic { + math.min(span.column_span, columns) + } else { + span.column_span + } + row_span: span.row_span + } + max_column_span = math.max(max_column_span, spans[index].column_span) + max_row_span = math.max(max_row_span, spans[index].row_span) + area += i64(spans[index].column_span) * i64(spans[index].row_span) + if area > 2_147_483_647 { + return error('grid child spans are too large') + } + } + if columns > 0 && max_column_span > columns { + return error('grid child column span exceeds its ${columns} columns') + } + if rows > 0 && max_row_span > rows { + return error('grid child row span exceeds its ${rows} rows') + } + if columns > 0 && rows > 0 { + if i64(columns) * i64(rows) > 2_147_483_647 { + return error('grid span capacity is too large') + } + cells := grid_pack_spans(spans, columns, rows, config.orientation) or { + return error('grid child spans do not fit within ${columns} columns and ${rows} rows') + } + return columns, rows, cells + } + grow_rows := rows == 0 + if grow_rows { + rows = math.max(max_row_span, int((area + columns - 1) / columns)) + } else { + columns = math.max(max_column_span, int((area + rows - 1) / rows)) + } + for { + if i64(columns) * i64(rows) > 2_147_483_647 { + return error('grid span capacity is too large') + } + if cells := grid_pack_spans(spans, columns, rows, config.orientation) { + return columns, rows, cells + } + if grow_rows { + rows++ + } else { + columns++ + } + } + return error('unable to place grid child spans') +} + // grid_orientation converts the compact VML orientation names to the typed V // API. The two letter pairs describe horizontal and vertical traversal. pub fn grid_orientation(value string) !GridOrientation { @@ -70,28 +275,33 @@ fn grid_dimensions(columns int, rows int, child_count int) !(int, int) { return error('grid layout requires columns or rows') } if columns > 0 && rows > 0 { - if child_count > columns * rows { - return error('grid layout has ${child_count} children but only ${columns * rows} cells') + capacity := i64(columns) * i64(rows) + if i64(child_count) > capacity { + return error('grid layout has ${child_count} children but only ${capacity} cells') } return columns, rows } if columns > 0 { - computed_rows := if child_count == 0 { 0 } else { (child_count + columns - 1) / columns } + computed_rows := if child_count == 0 { + 0 + } else { + int((i64(child_count) + columns - 1) / columns) + } return columns, computed_rows } - computed_columns := if child_count == 0 { 0 } else { (child_count + rows - 1) / rows } + computed_columns := if child_count == 0 { 0 } else { int((i64(child_count) + rows - 1) / rows) } return computed_columns, rows } -fn grid_axis_sizes(available f64, count int, default_size f64, force_default bool, minimum map[int]f64) ![]f64 { +fn grid_track_defaults(count int, default_size f64, minimum map[int]f64, automatic bool) ![]f64 { if count == 0 { return [] } - if available < 0 || default_size < 0 { - return error('grid dimensions cannot be negative') - } mut sizes := []f64{len: count, init: default_size} for index, value in minimum { + if automatic && index >= count { + continue + } if index < 0 || index >= count { return error('grid minimum index ${index} is outside 0..${count - 1}') } @@ -100,14 +310,27 @@ fn grid_axis_sizes(available f64, count int, default_size f64, force_default boo } sizes[index] = value } - if force_default { - return sizes + return sizes +} + +fn grid_axis_sizes(available f64, count int, default_size f64, force_default bool, minimum map[int]f64, automatic bool) ![]f64 { + if available < 0 || math.is_inf(available, 0) || math.is_nan(available) || default_size < 0 { + return error('grid dimensions must be finite and non-negative') } + mut sizes := grid_track_defaults(count, default_size, minimum, automatic)! + if count == 0 { return sizes } mut used := f64(0) for size in sizes { used += size } - if used < available { + if math.is_inf(used, 0) { + return error('grid track sizes exceed the finite layout range') + } + if automatic && used > available { + for index in 0 .. count { + sizes[index] *= available / used + } + } else if !force_default && used < available { extra := (available - used) / f64(count) for index in 0 .. count { sizes[index] += extra @@ -116,6 +339,44 @@ fn grid_axis_sizes(available f64, count int, default_size f64, force_default boo return sizes } +fn grid_resolved_cells(config GridLayoutConfig, child_count int, automatic bool) !(int, int, []GridPlacement) { + if config.child_spans.any(it.column_span > 1 || it.row_span > 1) && child_count > 0 { + return grid_spanned_dimensions(config, child_count, automatic) + } + configured_columns := if automatic { + grid_automatic_columns(config, child_count) + } else { + config.columns + } + columns, rows := grid_dimensions(configured_columns, config.rows, child_count)! + mut cells := []GridPlacement{cap: child_count} + for index in 0 .. child_count { + column, row := grid_cell_coordinates(index, columns, rows, config.orientation) + cells << GridPlacement{ column: column, row: row } + } + return columns, rows, cells +} + +fn grid_fit_padding(available f64, leading f64, trailing f64, automatic bool) (f64, f64) { + if automatic && leading + trailing > available { + // Division before multiplication avoids overflowing the padding sum. + ratio := if leading > trailing { + 1 / (1 + trailing / leading) + } else { + (leading / trailing) / (1 + leading / trailing) + } + return available * ratio, available * (1 - ratio) + } + return leading, trailing +} + +fn grid_fit_spacing(available f64, count int, spacing f64, automatic bool) f64 { + if automatic && count > 1 { + return math.min(spacing, math.max(0, available) / f64(count - 1)) + } + return spacing +} + fn grid_cell_coordinates(index int, columns int, rows int, orientation GridOrientation) (int, int) { return match orientation { .left_to_right_top_to_bottom { index % columns, index / columns } @@ -137,37 +398,101 @@ fn grid_cell_coordinates(index int, columns int, rows int, orientation GridOrien // is forced to its defaults, remaining space is distributed equally after its // configured minimums have been reserved. pub fn grid_layout_frames(config GridLayoutConfig, child_count int) ![]Rect { - if child_count < 0 { - return error('grid child count cannot be negative') - } - columns, rows := grid_dimensions(config.columns, config.rows, child_count)! + grid_validate_config(config, child_count)! + automatic := config.columns == 0 && config.rows == 0 && config.auto_columns_min_width > 0 + columns, rows, cells := grid_resolved_cells(config, child_count, automatic)! if child_count == 0 { return [] } - inner_width := config.frame.width - config.padding.left - config.padding.right - config.spacing.horizontal * f64(columns - 1) - inner_height := config.frame.height - config.padding.top - config.padding.bottom - config.spacing.vertical * f64(rows - 1) - widths := grid_axis_sizes(inner_width, columns, config.column_default_width, config.force_column_width, config.columns_minimum)! - heights := grid_axis_sizes(inner_height, rows, config.row_default_height, config.force_row_height, config.rows_minimum)! + left, right := grid_fit_padding(config.frame.width, config.padding.left, config.padding.right, automatic) + top, bottom := grid_fit_padding(config.frame.height, config.padding.top, config.padding.bottom, automatic) + horizontal_spacing := grid_fit_spacing(config.frame.width - left - right, columns, config.spacing.horizontal, automatic) + vertical_spacing := grid_fit_spacing(config.frame.height - top - bottom, rows, config.spacing.vertical, automatic) + computed_width := config.frame.width - left - right - horizontal_spacing * f64(columns - 1) + computed_height := config.frame.height - top - bottom - vertical_spacing * f64(rows - 1) + inner_width := if automatic { math.max(0, computed_width) } else { computed_width } + inner_height := if automatic { math.max(0, computed_height) } else { computed_height } + widths := grid_axis_sizes(inner_width, columns, config.column_default_width, config.force_column_width, config.columns_minimum, automatic)! + heights := grid_axis_sizes(inner_height, rows, config.row_default_height, config.force_row_height, config.rows_minimum, automatic)! mut x_positions := []f64{len: columns} mut y_positions := []f64{len: rows} - mut x := config.padding.left + mut x := left for column, width in widths { x_positions[column] = x - x += width + config.spacing.horizontal + x += width + horizontal_spacing } - mut y := config.padding.top + mut y := top for row, height in heights { y_positions[row] = y - y += height + config.spacing.vertical + y += height + vertical_spacing } mut frames := []Rect{cap: child_count} - for index in 0 .. child_count { - column, row := grid_cell_coordinates(index, columns, rows, config.orientation) - frames << rect(x_positions[column], y_positions[row], widths[column], heights[row]) + for cell in cells { + last_column := cell.column + cell.column_span - 1 + last_row := cell.row + cell.row_span - 1 + frames << rect(x_positions[cell.column], y_positions[cell.row], + if cell.column_span == 1 { + widths[cell.column] + } else { + x_positions[last_column] + widths[last_column] - x_positions[cell.column] + }, + if cell.row_span == 1 { + heights[cell.row] + } else { + y_positions[last_row] + heights[last_row] - y_positions[cell.row] + }) } return frames } +// grid_layout_preferred_size measures the natural grid size, including padding +// and gaps. Automatic columns are resolved from config.frame.width; child sizes +// should therefore have been measured at the corresponding available width. +// Measurement reserves enough equally distributed extra space that every child +// fits its tracks, matching grid_layout_frames. Forced defaults ignore child +// measurements on their axis. This does not mutate +// or construct Elements and can be used by a containing layout during measurement. +pub fn grid_layout_preferred_size(config GridLayoutConfig, child_sizes []Rect) !Rect { + grid_validate_config(config, child_sizes.len)! + automatic := config.columns == 0 && config.rows == 0 && config.auto_columns_min_width > 0 + columns, rows, cells := grid_resolved_cells(config, child_sizes.len, automatic)! + widths := grid_track_defaults(columns, config.column_default_width, config.columns_minimum, automatic)! + heights := grid_track_defaults(rows, config.row_default_height, config.rows_minimum, automatic)! + mut width_extra := f64(0) + mut height_extra := f64(0) + for index, child in child_sizes { + if math.is_nan(child.width) || math.is_inf(child.width, 0) || child.width < 0 + || math.is_nan(child.height) || math.is_inf(child.height, 0) || child.height < 0 { + return error('grid child preferred sizes must be finite and non-negative') + } + cell := cells[index] + if !config.force_column_width { + width_extra = math.max(width_extra, grid_preferred_track_extra(widths, cell.column, cell.column_span, + child.width, config.spacing.horizontal)) + } + if !config.force_row_height { + height_extra = math.max(height_extra, grid_preferred_track_extra(heights, cell.row, cell.row_span, + child.height, config.spacing.vertical)) + } + } + mut width := config.padding.left + config.padding.right + config.spacing.horizontal * f64(math.max(0, columns - 1)) + width_extra * f64(columns) + mut height := config.padding.top + config.padding.bottom + config.spacing.vertical * f64(math.max(0, rows - 1)) + height_extra * f64(rows) + for track in widths { width += track } + for track in heights { height += track } + if math.is_inf(width, 0) || math.is_inf(height, 0) { + return error('grid preferred sizes exceed the finite layout range') + } + return rect(0, 0, width, height) +} + +fn grid_preferred_track_extra(tracks []f64, start int, count int, preferred f64, spacing f64) f64 { + mut current := spacing * f64(count - 1) + for index in start .. start + count { + current += tracks[index] + } + return math.max(0, (preferred - current) / f64(count)) +} + // grid_layout creates a view whose children are assigned to cells in their // declaration order. Child frames are replaced by the computed cell frames. pub fn grid_layout(config GridLayoutConfig) !Element { diff --git a/ui/grid_layout_test.v b/ui/grid_layout_test.v index 14968a18..662c028a 100644 --- a/ui/grid_layout_test.v +++ b/ui/grid_layout_test.v @@ -1,5 +1,7 @@ module ui2 +import math + fn test_grid_layout_requires_a_row_or_column_constraint() { if _ := grid_layout_frames(GridLayoutConfig{}, 1) { assert false, 'an unconstrained grid must fail' @@ -18,11 +20,11 @@ fn test_grid_layout_rejects_children_beyond_fixed_capacity() { fn test_grid_layout_distributes_space_with_padding_spacing_and_minimums() { frames := grid_layout_frames(GridLayoutConfig{ - frame: rect(0, 0, 230, 110) - columns: 2 - padding: GridPadding{ left: 10, top: 8, right: 10, bottom: 8 } - spacing: GridSpacing{ horizontal: 10, vertical: 6 } - columns_minimum: { + frame: rect(0, 0, 230, 110) + columns: 2 + padding: GridPadding{ left: 10, top: 8, right: 10, bottom: 8 } + spacing: GridSpacing{ horizontal: 10, vertical: 6 } + columns_minimum: { 0: 60.0 1: 80.0 } @@ -34,13 +36,13 @@ fn test_grid_layout_distributes_space_with_padding_spacing_and_minimums() { fn test_grid_layout_can_force_default_axis_sizes() { frames := grid_layout_frames(GridLayoutConfig{ - frame: rect(0, 0, 300, 200) - columns: 2 + frame: rect(0, 0, 300, 200) + columns: 2 column_default_width: 60 - row_default_height: 40 - force_column_width: true - force_row_height: true - columns_minimum: { + row_default_height: 40 + force_column_width: true + force_row_height: true + columns_minimum: { 1: 80.0 } }, 2) or { panic(err) } @@ -49,8 +51,8 @@ fn test_grid_layout_can_force_default_axis_sizes() { fn test_grid_layout_supports_rows_and_reverse_column_major_flow() { frames := grid_layout_frames(GridLayoutConfig{ - frame: rect(0, 0, 200, 100) - rows: 2 + frame: rect(0, 0, 200, 100) + rows: 2 orientation: .bottom_to_top_right_to_left }, 4) or { panic(err) } assert frames == [rect(100, 50, 100, 50), rect(100, 0, 100, 50), rect(0, 50, 100, 50), @@ -79,9 +81,9 @@ fn test_grid_layout_supports_all_fill_orientations() { fn test_grid_layout_replaces_child_frames_and_preserves_content() { grid := grid_layout( - id: 'actions' - frame: rect(20, 30, 200, 80) - columns: 2 + id: 'actions' + frame: rect(20, 30, 200, 80) + columns: 2 children: [ button('one', 'One', Rect{}, BoxStyle{}, TextStyle{}), button('two', 'Two', Rect{}, BoxStyle{}, TextStyle{}), @@ -93,3 +95,220 @@ fn test_grid_layout_replaces_child_frames_and_preserves_content() { assert grid.children[0].frame == rect(0, 0, 100, 80) assert grid.children[1].frame == rect(100, 0, 100, 80) } + +fn test_grid_automatic_columns_follow_width_thresholds_and_shrink_to_one_column() { + config := GridLayoutConfig{ + frame: rect(0, 0, 310, 200) + auto_columns_min_width: 90 + padding: GridPadding{ left: 10, right: 10 } + spacing: GridSpacing{ horizontal: 10 } + } + wide := grid_layout_frames(config, 6) or { panic(err) } + assert wide[0] == rect(10, 0, 90, 100) + assert wide[2] == rect(210, 0, 90, 100) + assert wide[3] == rect(10, 100, 90, 100) + medium := grid_layout_frames(GridLayoutConfig{ ...config, frame: rect(0, 0, 309, 210) }, 6) or { panic(err) } + assert medium[0] == rect(10, 0, 139.5, 70) + assert medium[2] == rect(10, 70, 139.5, 70) + narrow := grid_layout_frames(GridLayoutConfig{ ...config, frame: rect(0, 0, 100, 240) }, 6) or { panic(err) } + assert narrow[0] == rect(10, 0, 80, 40) + assert narrow[5] == rect(10, 200, 80, 40) +} + +fn test_grid_automatic_columns_are_limited_to_children_and_maximum() { + config := GridLayoutConfig{ + frame: rect(0, 0, 800, 200) + auto_columns_min_width: 100 + } + frames := grid_layout_frames(config, 2) or { panic(err) } + assert frames == [rect(0, 0, 400, 200), rect(400, 0, 400, 200)] + capped := grid_layout_frames(GridLayoutConfig{ ...config, max_columns: 2 }, 5) or { panic(err) } + assert capped[0].width == 400 + assert capped[2].x == 0 + assert capped[4].y > capped[2].y + empty := grid_layout_frames(config, 0) or { panic(err) } + assert empty == []Rect{} +} + +fn test_grid_explicit_dimensions_take_precedence_over_automatic_columns() { + frames := grid_layout_frames(GridLayoutConfig{ + frame: rect(0, 0, 600, 100) + columns: 2 + auto_columns_min_width: 50 + max_columns: 1 + }, 2) or { panic(err) } + assert frames == [rect(0, 0, 300, 100), rect(300, 0, 300, 100)] +} + +fn test_grid_automatic_layout_fits_even_below_padding_and_gap_sizes() { + frames := grid_layout_frames(GridLayoutConfig{ + frame: rect(0, 0, 6, 10) + auto_columns_min_width: 100 + padding: GridPadding{ left: 10, right: 10 } + spacing: GridSpacing{ horizontal: 20, vertical: 40 } + row_default_height: 50 + column_default_width: 100 + columns_minimum: { + 1: 80.0 + } + }, 3) or { panic(err) } + assert frames == [rect(3, 0, 0, 0), rect(3, 5, 0, 0), rect(3, 10, 0, 0)] + zero := grid_layout_frames(GridLayoutConfig{ + frame: Rect{} + auto_columns_min_width: 100 + padding: GridPadding{ left: 10, top: 8, right: 10, bottom: 8 } + spacing: GridSpacing{ horizontal: 20, vertical: 40 } + }, 3) or { panic(err) } + assert zero == [Rect{}, Rect{}, Rect{}] +} + +fn test_grid_spans_include_gaps_and_fill_available_holes() { + frames := grid_layout_frames(GridLayoutConfig{ + frame: rect(0, 0, 320, 230) + columns: 3 + spacing: GridSpacing{ horizontal: 10, vertical: 10 } + child_spans: [GridSpan{ column_span: 2, row_span: 2 }] + }, 5) or { panic(err) } + assert frames == [rect(0, 0, 210, 150), rect(220, 0, 100, 70), rect(220, 80, 100, 70), + rect(0, 160, 100, 70), rect(110, 160, 100, 70)] +} + +fn test_grid_spans_grow_rows_until_rectangular_children_fit() { + frames := grid_layout_frames(GridLayoutConfig{ + frame: rect(0, 0, 300, 200) + columns: 3 + child_spans: [GridSpan{ column_span: 2 }, GridSpan{ column_span: 2 }, + GridSpan{ column_span: 2 }] + }, 3) or { panic(err) } + assert frames[0].y == 0 + assert math.abs(frames[1].y - 200.0 / 3) < 0.00001 + assert math.abs(frames[2].y - 400.0 / 3) < 0.00001 + assert frames[2].width == 200 +} + +fn test_grid_spans_can_grow_columns_and_reverse_column_major_order() { + frames := grid_layout_frames(GridLayoutConfig{ + frame: rect(0, 0, 300, 200) + rows: 2 + orientation: .bottom_to_top_right_to_left + child_spans: [GridSpan{ row_span: 2 }, GridSpan{ column_span: 2 }] + }, 3) or { panic(err) } + assert frames == [rect(200, 0, 100, 200), rect(0, 100, 200, 100), rect(100, 0, 100, 100)] +} + +fn test_grid_spans_honor_every_orientation() { + orientations := [GridOrientation.left_to_right_top_to_bottom, .top_to_bottom_left_to_right, + .right_to_left_top_to_bottom, .top_to_bottom_right_to_left, .left_to_right_bottom_to_top, + .bottom_to_top_left_to_right, .right_to_left_bottom_to_top, .bottom_to_top_right_to_left] + first := [rect(0, 0, 200, 100), rect(0, 0, 200, 100), rect(100, 0, 200, 100), + rect(100, 0, 200, 100), rect(0, 100, 200, 100), rect(0, 100, 200, 100), + rect(100, 100, 200, 100), rect(100, 100, 200, 100)] + second := [rect(200, 0, 100, 100), rect(0, 100, 100, 100), rect(0, 0, 100, 100), + rect(200, 100, 100, 100), rect(200, 100, 100, 100), rect(0, 0, 100, 100), + rect(0, 100, 100, 100), rect(200, 0, 100, 100)] + for index, orientation in orientations { + frames := grid_layout_frames(GridLayoutConfig{ + frame: rect(0, 0, 300, 200) + columns: 3 + rows: 2 + orientation: orientation + child_spans: [GridSpan{ column_span: 2 }] + }, 2) or { panic(err) } + assert frames == [first[index], second[index]] + } +} + +fn test_grid_automatic_column_spans_contract_on_narrow_windows() { + config := GridLayoutConfig{ + frame: rect(0, 0, 300, 200) + auto_columns_min_width: 100 + child_spans: [GridSpan{ column_span: 2 }] + } + wide := grid_layout_frames(config, 3) or { panic(err) } + assert wide == [rect(0, 0, 200, 100), rect(200, 0, 100, 100), rect(0, 100, 100, 100)] + narrow := grid_layout_frames(GridLayoutConfig{ ...config, frame: rect(0, 0, 80, 300) }, 3) or { panic(err) } + assert narrow == [rect(0, 0, 80, 100), rect(0, 100, 80, 100), rect(0, 200, 80, 100)] +} + +fn test_grid_rejects_spans_that_cannot_fit_explicit_dimensions() { + for config in [ + GridLayoutConfig{ columns: 2, child_spans: [GridSpan{ column_span: 3 }] }, + GridLayoutConfig{ rows: 2, child_spans: [GridSpan{ row_span: 3 }] }, + GridLayoutConfig{ + columns: 3 + rows: 2 + child_spans: [GridSpan{ column_span: 2, row_span: 2 }, GridSpan{ column_span: 2 }] + }, + ] { + count := config.child_spans.len + if _ := grid_layout_frames(config, count) { + assert false, 'invalid fixed grid must fail' + } + } +} + +fn test_grid_rejects_non_finite_dimensions_and_invalid_spans() { + for config in [ + GridLayoutConfig{ columns: 1, frame: rect(0, 0, math.inf(1), 100) }, + GridLayoutConfig{ columns: 1, padding: GridPadding{ left: math.nan() } }, + GridLayoutConfig{ columns: 1, spacing: GridSpacing{ horizontal: -1 } }, + GridLayoutConfig{ columns: 1, auto_columns_min_width: math.nan() }, + GridLayoutConfig{ columns: 1, max_columns: -1 }, + GridLayoutConfig{ columns: 1, child_spans: [GridSpan{ column_span: 0 }] }, + GridLayoutConfig{ columns: 1, child_spans: [GridSpan{ row_span: -1 }] }, + GridLayoutConfig{ columns: 1, child_spans: [GridSpan{}, GridSpan{}] }, + ] { + if _ := grid_layout_frames(config, 1) { + assert false, 'invalid grid input must fail' + } + } +} + +fn test_grid_rejects_span_area_overflow_before_allocating_occupancy() { + if _ := grid_layout_frames(GridLayoutConfig{ + columns: 50000 + child_spans: [GridSpan{ column_span: 50000, row_span: 50000 }] + }, 1) { + assert false, 'span area beyond the array index range must fail' + } else { + assert err.msg().contains('too large') + } +} + +fn test_grid_preferred_size_measures_children_and_spans_at_responsive_width() { + config := GridLayoutConfig{ + frame: rect(0, 0, 310, 0) + auto_columns_min_width: 90 + padding: GridPadding{ left: 10, top: 5, right: 10, bottom: 5 } + spacing: GridSpacing{ horizontal: 10, vertical: 10 } + child_spans: [GridSpan{ column_span: 2 }] + } + sizes := [rect(0, 0, 190, 40), rect(0, 0, 90, 20), rect(0, 0, 90, 30)] + wide := grid_layout_preferred_size(config, sizes) or { panic(err) } + assert wide == rect(0, 0, 310, 100) + narrow := grid_layout_preferred_size(GridLayoutConfig{ ...config, frame: rect(0, 0, 100, 0) }, sizes) or { panic(err) } + assert narrow == rect(0, 0, 210, 150) + placed := grid_layout_frames(GridLayoutConfig{ ...config, frame: wide }, sizes.len) or { panic(err) } + for index, frame in placed { + assert frame.width >= sizes[index].width + assert frame.height >= sizes[index].height + } +} + +fn test_grid_preferred_size_respects_forced_tracks_and_minimums() { + preferred := grid_layout_preferred_size(GridLayoutConfig{ + columns: 2 + column_default_width: 50 + row_default_height: 20 + force_column_width: true + force_row_height: true + columns_minimum: { + 1: 80.0 + } + rows_minimum: { + 1: 30.0 + } + spacing: GridSpacing{ horizontal: 10, vertical: 5 } + }, [rect(0, 0, 200, 100), rect(0, 0, 200, 100), rect(0, 0, 200, 100)]) or { panic(err) } + assert preferred == rect(0, 0, 140, 55) +} diff --git a/ui/layout_measure.v b/ui/layout_measure.v new file mode 100644 index 00000000..beceb624 --- /dev/null +++ b/ui/layout_measure.v @@ -0,0 +1,202 @@ +module ui2 + +import math + +// LayoutSize is a measured size in the same logical units as Element.frame. +pub struct LayoutSize { +pub: + width f64 + height f64 +} + +// LayoutConstraints describes the sizes a parent accepts. A maximum of -1 is +// unbounded; zero is an actual bound. Minimums win only after validation, so +// contradictory constraints produce an error instead of overflowing silently. +pub struct LayoutConstraints { +pub: + min_width f64 + min_height f64 + max_width f64 = -1 + max_height f64 = -1 +} + +pub fn (constraints LayoutConstraints) validate() ! { + for minimum in [constraints.min_width, constraints.min_height] { + if !math.is_finite(minimum) || minimum < 0 { + return error('layout minimums must be finite and nonnegative') + } + } + for maximum in [constraints.max_width, constraints.max_height] { + if !math.is_finite(maximum) || (maximum < 0 && maximum != -1) { + return error('layout maximums must be nonnegative or -1 (unbounded)') + } + } + if (constraints.max_width >= 0 && constraints.min_width > constraints.max_width) + || (constraints.max_height >= 0 && constraints.min_height > constraints.max_height) { + return error('layout minimum cannot exceed its maximum') + } +} + +pub fn (constraints LayoutConstraints) constrain(size LayoutSize) !LayoutSize { + constraints.validate()! + if !math.is_finite(size.width) || !math.is_finite(size.height) { + return error('measured layout size must be finite') + } + return LayoutSize{ + width: layout_constrain_axis(size.width, constraints.min_width, constraints.max_width) + height: layout_constrain_axis(size.height, constraints.min_height, constraints.max_height) + } +} + +pub fn (constraints LayoutConstraints) loosen() LayoutConstraints { + return LayoutConstraints{ max_width: constraints.max_width, max_height: constraints.max_height } +} + +// deflate gives content the space left inside a container's padding. Padding +// larger than a bounded axis leaves a tight zero, never a negative maximum. +pub fn (constraints LayoutConstraints) deflate(padding BoxPadding) !LayoutConstraints { + constraints.validate()! + for inset in [padding.left, padding.top, padding.right, padding.bottom] { + if !math.is_finite(inset) || inset < 0 { + return error('layout padding must be finite and nonnegative') + } + } + horizontal := padding.left + padding.right + vertical := padding.top + padding.bottom + return LayoutConstraints{ + min_width: math.max(0, constraints.min_width - horizontal) + min_height: math.max(0, constraints.min_height - vertical) + max_width: if constraints.max_width < 0 { + -1 + } else { + math.max(0, constraints.max_width - horizontal) + } + max_height: if constraints.max_height < 0 { + -1 + } else { + math.max(0, constraints.max_height - vertical) + } + } +} + +fn layout_constrain_axis(value f64, minimum f64, maximum f64) f64 { + bounded := math.max(minimum, value) + return if maximum < 0 { bounded } else { math.min(maximum, bounded) } +} + +// LayoutTextMeasureFn measures rendered text under a maximum logical width. +// -1 means unbounded. Supplying a callback makes layout independent of a window +// or GPU and lets an embedder use its own font engine without changing layout. +pub type LayoutTextMeasureFn = fn (string, TextStyle, f64) !LayoutSize + +// measure_layout_element keeps positive declared dimensions and measures zero +// axes from text or children. Existing controls opt in by calling this API; +// constructing an Element does not change its legacy geometry. Containers with +// a layout algorithm measure/place their children before this extent fallback. +pub fn measure_layout_element(element Element, constraints LayoutConstraints, measure LayoutTextMeasureFn) !LayoutSize { + constraints.validate()! + if element.hidden { + return constraints.constrain(LayoutSize{}) + } + if element.frame.width > 0 && element.frame.height > 0 { + return constraints.constrain(LayoutSize{ width: element.frame.width, height: element.frame.height }) + } + mut preferred := LayoutSize{} + if element.kind in [.label, .button, .toggle_button, .checkbox, .dropdown, .text_field, .text_area] { + if voidptr(measure) == unsafe { nil } { + return error('intrinsic text sizing requires a text measurer') + } + insets := layout_measure_control_insets(element) + outer_width := if element.frame.width > 0 { + layout_constrain_axis(element.frame.width, constraints.min_width, constraints.max_width) + } else { + constraints.max_width + } + text_width := if outer_width < 0 { + -1.0 + } else { + math.max(0, outer_width - insets.left - insets.right) + } + content := if element.text.len == 0 && element.kind in [.text_field, .text_area] { + element.placeholder + } else { + element.text + } + measured := measure(content, element.text_style, text_width)! + preferred = LayoutSize{ + width: measured.width + insets.left + insets.right + height: measured.height + insets.top + insets.bottom + } + if element.kind == .checkbox { + preferred = LayoutSize{ ...preferred, height: math.max(18, preferred.height) } + } + } else { + mut width := 0.0 + mut height := 0.0 + for child in element.children { + if child.hidden { continue } + child_size := measure_layout_element(child, LayoutConstraints{}, measure)! + width = math.max(width, child.frame.x + child_size.width) + height = math.max(height, child.frame.y + child_size.height) + } + preferred = LayoutSize{ width: width, height: height } + } + return constraints.constrain(LayoutSize{ + width: if element.frame.width > 0 { element.frame.width } else { preferred.width } + height: if element.frame.height > 0 { element.frame.height } else { preferred.height } + }) +} + +// These content insets affect only opted-in intrinsic sizes. Existing explicit +// frames and backend drawing stay unchanged. +fn layout_measure_control_insets(element Element) BoxPadding { + $if macos && !ui2_custom_rendering ?&& !ui2_headless ? { + if element.kind == .label { + // Borderless NSTextField still reserves two points at each horizontal + // edge. NSAttributedString measures glyphs only; include the cell's + // existing margins so an intrinsic label does not truncate itself. + return BoxPadding{ left: 2, right: 2 } + } + } + return match element.kind { + .button, .toggle_button { + BoxPadding{ left: 12, right: if element.image_path.len > 0 { 36 } else { 12 }, top: 6, bottom: 6 } + } + .checkbox { BoxPadding{ left: 26 } } + .dropdown { + BoxPadding{ left: math.max(0, element.padding_left), right: 32, top: 6, bottom: 6 } + } + .text_field { + BoxPadding{ left: math.max(0, element.padding_left), right: 8, top: 6, bottom: 6 } + } + .text_area { BoxPadding{ left: 8, right: 8, top: 8, bottom: 8 } } + else { BoxPadding{} } + } +} + +fn layout_validate_text_measurement(style TextStyle, max_width f64) ! { + if !math.is_finite(style.size) || style.size <= 0 { + return error('intrinsic text size must be finite and positive') + } + if !math.is_finite(max_width) || (max_width < 0 && max_width != -1) { + return error('text measurement width must be nonnegative or -1') + } +} + +fn layout_measure_text_lines(text string, style TextStyle, max_width f64, line_height f64, width_of fn (string) f64) !LayoutSize { + layout_validate_text_measurement(style, max_width)! + if !math.is_finite(line_height) || line_height <= 0 { + return error('text measurement needs a positive finite line height') + } + lines := if style.lines > 1 { + wrap_text_lines_measured(text, max_width, style.lines, width_of) + } else { + [text] + } + mut width := 0.0 + for line in lines { width = math.max(width, width_of(line)) } + return LayoutConstraints{ max_width: max_width }.constrain(LayoutSize{ + width: width + height: line_height * f64(lines.len) + }) +} diff --git a/ui/layout_measure_backend.c.v b/ui/layout_measure_backend.c.v new file mode 100644 index 00000000..57c4d405 --- /dev/null +++ b/ui/layout_measure_backend.c.v @@ -0,0 +1,21 @@ +module ui2 + +// measure_layout_text uses the active backend's metrics when available, with +// CPU fontstash measurement before a custom window opens (and for embedders +// without a native measurement adapter). Font sizes retain their point units. +// Like layout/build, call this on the UI thread while a window is mounted. +pub fn measure_layout_text(text string, style TextStyle, max_width f64) !LayoutSize { + layout_validate_text_measurement(style, max_width)! + $if macos && !ui2_custom_rendering ?&& !ui2_headless ? { + return layout_measure_appkit_text(text, style, max_width) + } $else $if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && !ui2_headless ? { + ctx := g_gg_app.ctx + if ctx != unsafe { nil } && ctx.font_inited { + ensure_symbol_fallbacks(ctx) + return layout_measure_text_lines(text, style, max_width, font_line_height(style.size), fn [ctx, style] (line string) f64 { + return menu_text_width(ctx, line, style) + }) + } + } + return layout_measure_cpu_text(text, style, max_width) +} diff --git a/ui/layout_measure_cpu.c.v b/ui/layout_measure_cpu.c.v new file mode 100644 index 00000000..f58e7758 --- /dev/null +++ b/ui/layout_measure_cpu.c.v @@ -0,0 +1,108 @@ +@[has_globals] +module ui2 + +import fontstash +import os +import os.font +import sync + +// A CPU-only fontstash context supplies real glyph advances before a window +// exists. It shares UI2's font discovery, point conversion and fallback chain; +// no GPU, gg context, font parser or extra dependency is involved. +@[heap] +struct LayoutMeasureFonts { + mutex &sync.Mutex = sync.new_mutex() +mut: + stash &fontstash.Context = unsafe { nil } + regular string + bold string + index map[string]string + indexed bool + font_ids map[string]int + metrics map[string]FontMetrics + fallbacks []int +} + +__global g_layout_measure_fonts = &LayoutMeasureFonts{} + +fn (mut fonts LayoutMeasureFonts) initialize() ! { + if fonts.stash != unsafe { nil } { return } + regular, bold := font_paths() + if regular.len == 0 { + return error('intrinsic text sizing needs an installed or bundled font; supply LayoutTextMeasureFn') + } + fonts.stash = fontstash.create_internal(&C.FONSparams{ width: 256, height: 256 }) + if fonts.stash == unsafe { nil } { + return error('could not create CPU font measurement context') + } + fonts.regular = regular + fonts.bold = if bold.len > 0 { bold } else { regular } + for path in font_symbol_paths() { + data := os.read_bytes(path) or { continue } + id := fonts.stash.add_font_mem(path, data, true) + if id != fontstash.invalid { + fonts.fallbacks << id + fonts.font_ids[path] = id + } + } +} + +fn (mut fonts LayoutMeasureFonts) path(style TextStyle) string { + if style.font_family.len > 0 { + if os.is_file(style.font_family) { return style.font_family } + if !fonts.indexed { + mut dirs := font_bundle_dirs() + dirs << font_system_dirs() + fonts.index = font_index(dirs) + fonts.indexed = true + } + mut path := font_lookup(fonts.index, style.font_family, style.bold, style.italic) + if path.len == 0 && (style.bold || style.italic) { + path = font_lookup(fonts.index, style.font_family, false, false) + } + if path.len == 0 && font_is_mono_family(style.font_family) { + path = font_mono_path(fonts.index, style.font_family, style.bold, style.italic) + } + if path.len > 0 { return path } + } + if style.bold { return fonts.bold } + if style.italic { + italic := font.get_path_variant(fonts.regular, .italic) + if os.is_file(italic) { return italic } + } + return fonts.regular +} + +fn (mut fonts LayoutMeasureFonts) load(path string) !int { + if id := fonts.font_ids[path] { return id } + data := os.read_bytes(path)! + id := fonts.stash.add_font_mem(path, data, true) + if id == fontstash.invalid { return error('cannot measure font `${path}`') } + for fallback in fonts.fallbacks { + if fallback != id { fonts.stash.add_fallback_font(id, fallback) } + } + fonts.font_ids[path] = id + return id +} + +fn layout_measure_cpu_text(text string, style TextStyle, max_width f64) !LayoutSize { + mut fonts := g_layout_measure_fonts + fonts.mutex.lock() + defer { fonts.mutex.unlock() } + fonts.initialize()! + path := fonts.path(style) + id := fonts.load(path)! + metrics := fonts.metrics[path] or { + loaded := font_file_metrics(path) or { FontMetrics{} } + fonts.metrics[path] = loaded + loaded + } + stash := fonts.stash + stash.set_font(id) + stash.set_size(f32(int(font_render_size(style.size, metrics) + 0.5))) + stash.set_align(int(fontstash.Align.left) | int(fontstash.Align.baseline)) + return layout_measure_text_lines(text, style, max_width, font_line_height(style.size), fn [stash] (line string) f64 { + mut glyph_bounds := [4]f32{} + return f64(stash.text_bounds(0, 0, line, &glyph_bounds[0])) + }) +} diff --git a/ui/layout_measure_darwin.c.v b/ui/layout_measure_darwin.c.v new file mode 100644 index 00000000..9e796547 --- /dev/null +++ b/ui/layout_measure_darwin.c.v @@ -0,0 +1,23 @@ +// Keep AppKit imports behind the native backend switch so custom builds do not +// mix AppKit's MRC bridge with Sokol's ARC sources. +module ui2 + +$if macos && !ui2_custom_rendering ?&& !ui2_headless ? { + import macos + + fn layout_measure_appkit_text(text string, style TextStyle, max_width f64) !LayoutSize { + pool := macos.autorelease_pool_new() + defer { macos.release(pool) } + font := native_text_style_font(style) + line_height := macos.msg_f64(font, 'ascender') - macos.msg_f64(font, 'descender') + + macos.msg_f64(font, 'leading') + attrs := native_text_attributes(style.color, 0, style.size, style.font_family, + style.bold, style.italic, style.underline, style.strikethrough, style.vertical_align) + return layout_measure_text_lines(text, style, max_width, line_height, fn [attrs] (line string) f64 { + attributed := macos.msg_id2(macos.alloc('NSAttributedString'), 'initWithString:attributes:', macos.nsstring(line), attrs) + size := macos.msg_point(attributed, 'size') + macos.release(attributed) + return size.x + }) + } +} diff --git a/ui/layout_measure_darwin_test.v b/ui/layout_measure_darwin_test.v new file mode 100644 index 00000000..e83fc6da --- /dev/null +++ b/ui/layout_measure_darwin_test.v @@ -0,0 +1,22 @@ +module ui2 + +$if macos && !ui2_custom_rendering ?&& !ui2_headless ? { + import macos + + fn test_layout_native_content_sized_label_fits_its_appkit_cell() { + pool := macos.autorelease_pool_new() + defer { macos.release(pool) } + for caption in ['Created: 1', 'iii', 'WWW'] { + style := TextStyle{ size: 14 } + element := label('caption', caption, Rect{}, style) + measured := measure_layout_element(element, LayoutConstraints{}, measure_layout_text)! + frame := NativeRect{ width: measured.width, height: measured.height } + native := native_new_label(frame, caption, style.color, style.size, false, + false, false, align_value(style.align), 1, .top, false) + cell := macos.msg_id(native, 'cell') + cell_size := macos.msg_point(cell, 'cellSize') + macos.release(native) + assert measured.width >= cell_size.x, 'content-sized `${caption}` must include the NSTextField cell insets: ${measured.width} < ${cell_size.x}' + } + } +} diff --git a/ui/layout_measure_test.v b/ui/layout_measure_test.v new file mode 100644 index 00000000..89fec98d --- /dev/null +++ b/ui/layout_measure_test.v @@ -0,0 +1,92 @@ +module ui2 + +fn layout_measure_fixture_width(text string) f64 { + mut width := 0.0 + for character in text.runes() { + width += match character { + `W` { 12.0 } + `i` { 3.0 } + ` ` { 4.0 } + else { 8.0 } + } + } + return width +} + +fn layout_measure_fixture_text(text string, style TextStyle, width f64) !LayoutSize { + return layout_measure_text_lines(text, style, width, 20, layout_measure_fixture_width) +} + +fn layout_measure_unexpected_text(_text string, _style TextStyle, _width f64) !LayoutSize { + return error('fixed and hidden elements must not request text measurement') +} + +fn test_layout_constraints_distinguish_zero_from_unbounded_and_validate() { + unbounded := LayoutConstraints{ min_width: 10 } + assert unbounded.constrain(LayoutSize{ width: 200, height: 50 })! == LayoutSize{ width: 200, height: 50 } + assert unbounded.constrain(LayoutSize{ width: 2, height: -3 })! == LayoutSize{ width: 10 } + zero := LayoutConstraints{ max_width: 0, max_height: 0 } + assert zero.constrain(LayoutSize{ width: 200, height: 50 })! == LayoutSize{} + if _ := (LayoutConstraints{ min_width: 20, max_width: 10 }).constrain(LayoutSize{}) { + assert false, 'contradictory constraints must be rejected' + } + if _ := (LayoutConstraints{ max_height: -2 }).constrain(LayoutSize{}) { + assert false, 'only -1 represents an unbounded maximum' + } + tight := LayoutConstraints{ min_width: 100, max_width: 100, min_height: 40, max_height: 40 } + assert tight.loosen() == LayoutConstraints{ max_width: 100, max_height: 40 } + assert tight.deflate(BoxPadding{ left: 20, right: 30, top: 50 })! == LayoutConstraints{ + min_width: 50 + max_width: 50 + max_height: 0 + } + if _ := tight.deflate(BoxPadding{ left: -1 }) { + assert false, 'negative padding must be rejected' + } +} + +fn test_layout_measure_text_uses_available_content_width_and_preserves_point_style() { + label_inset := $if macos && !ui2_custom_rendering ?&& !ui2_headless ? { 4.0 } $else { 0.0 } + style := TextStyle{ size: 14, lines: 3 } + label := label('description', 'WW WW', Rect{}, style) + unbounded := measure_layout_element(label, LayoutConstraints{}, layout_measure_fixture_text)! + assert unbounded == LayoutSize{ width: 52 + label_inset, height: 20 } + narrow := measure_layout_element(label, LayoutConstraints{ max_width: 30 }, layout_measure_fixture_text)! + assert narrow == LayoutSize{ width: 24 + label_inset, height: 40 } + assert label.text_style.size == 14 + assert label.frame == Rect{} + fixed_width := Element{ ...label, frame: rect(0, 0, 30, 0) } + assert measure_layout_element(fixed_width, LayoutConstraints{}, layout_measure_fixture_text)! == LayoutSize{ width: 30, height: 40 } + button := button('action', 'WW WW', Rect{}, BoxStyle{}, style) + // Control insets are removed before wrapping, then added to its answer. + assert measure_layout_element(button, LayoutConstraints{ max_width: 54 }, layout_measure_fixture_text)! == LayoutSize{ width: 48, height: 52 } +} + +fn test_layout_measure_declared_sizes_hidden_nodes_and_nested_extents() { + label_inset := $if macos && !ui2_custom_rendering ?&& !ui2_headless ? { 4.0 } $else { 0.0 } + fixed := label('fixed', 'text', rect(7, 3, 40, 30), TextStyle{}) + assert measure_layout_element(fixed, LayoutConstraints{ max_width: 20 }, layout_measure_unexpected_text)! == LayoutSize{ width: 20, height: 30 } + hidden := Element{ ...fixed, hidden: true } + assert measure_layout_element(hidden, LayoutConstraints{}, layout_measure_unexpected_text)! == LayoutSize{} + child := label('intrinsic', 'WWW', rect(7, 3, 0, 0), TextStyle{}) + container := view('root', Rect{}, BoxStyle{}, [ + view('nested', rect(10, 5, 0, 0), BoxStyle{}, [child]), + Element{ ...fixed, frame: rect(1_000, 1_000, 400, 400), hidden: true }, + ]) + assert measure_layout_element(container, LayoutConstraints{}, layout_measure_fixture_text)! == LayoutSize{ width: 53 + label_inset, height: 28 } +} + +fn test_layout_default_text_measurement_uses_real_fonts_before_window_creation() { + style := TextStyle{ size: 16 } + wide := measure_layout_text('WWW', style, -1)! + narrow := measure_layout_text('iii', style, -1)! + assert wide.width > narrow.width * 1.5 + assert narrow.width > 0 + assert wide.height > 0 + larger := measure_layout_text('WWW', TextStyle{ size: 32 }, -1)! + assert larger.width > wide.width * 1.7 + assert larger.height > wide.height * 1.7 + wrapped := measure_layout_text('WWW WWW', TextStyle{ ...style, lines: 3 }, wide.width + 1)! + assert wrapped.width <= wide.width + 1 + assert wrapped.height > wide.height +} diff --git a/ui/vml.v b/ui/vml.v index 3974df25..0ab15774 100644 --- a/ui/vml.v +++ b/ui/vml.v @@ -776,7 +776,11 @@ pub fn element_from_vnode(node &VNode, frame Rect) !Element { } fn node_to_element(node &VNode, frame Rect) !Element { - resolved := if node.tag == 'Screen' && node.prop_bool('adaptive') { frame } else { v_frame(node, frame) } + resolved := if node.tag == 'Screen' && node.prop_bool('adaptive') { + frame + } else { + v_frame(node, frame) + } el := node_to_element_base(node, resolved)! key := node.prop('key') menu := v_menu(node) @@ -837,6 +841,9 @@ fn node_to_element_base(node &VNode, frame Rect) !Element { 'BoxLayout' { return v_box_layout(node, frame)! } + 'FlexLayout' { + return v_flex(node, frame)! + } 'FloatLayout', 'RelativeLayout' { return v_float_layout(node, frame)! } @@ -1128,7 +1135,7 @@ fn v_is_layout_metadata(node &VNode) bool { } fn v_children(node &VNode, frame Rect) ![]Element { - if node.tag == 'Screen' && node.prop_bool('adaptive') { + if node.tag == 'Screen' && node.prop_bool('adaptive') && !node.prop_bool('__adaptive_layout_resolved') { return v_adaptive_children(node, frame)! } mut out := []Element{} @@ -1324,41 +1331,48 @@ fn v_grid_config(node &VNode, frame Rect) !GridLayoutConfig { padding := node.prop_or('padding', '0').f64() spacing := node.prop_or('spacing', '0').f64() return GridLayoutConfig{ - id: node.id - frame: frame - box: v_box(node) - columns: node.prop_or('columns', node.prop_or('cols', '0')).int() - rows: node.prop_or('rows', '0').int() - orientation: grid_orientation(node.prop_or('orientation', 'lr-tb'))! - padding: GridPadding{ + id: node.id + frame: frame + box: v_box(node) + columns: node.prop_or('columns', node.prop_or('cols', '0')).int() + rows: node.prop_or('rows', '0').int() + auto_columns_min_width: node.prop_or('auto_columns_min_width', '0').f64() + max_columns: node.prop_or('max_columns', '0').int() + orientation: grid_orientation(node.prop_or('orientation', 'lr-tb'))! + padding: GridPadding{ left: node.prop_or('padding_left', padding.str()).f64() top: node.prop_or('padding_top', padding.str()).f64() right: node.prop_or('padding_right', padding.str()).f64() bottom: node.prop_or('padding_bottom', padding.str()).f64() } - spacing: GridSpacing{ + spacing: GridSpacing{ horizontal: node.prop_or('spacing_x', spacing.str()).f64() vertical: node.prop_or('spacing_y', spacing.str()).f64() } - column_default_width: node.prop_or('col_default_width', '0').f64() - row_default_height: node.prop_or('row_default_height', '0').f64() - force_column_width: node.prop_bool('col_force_default') - force_row_height: node.prop_bool('row_force_default') + column_default_width: node.prop_or('col_default_width', '0').f64() + row_default_height: node.prop_or('row_default_height', '0').f64() + force_column_width: node.prop_bool('col_force_default') + force_row_height: node.prop_bool('row_force_default') } } fn v_grid(node &VNode, frame Rect) !Element { + if node.prop_bool('__layout_resolved') { + return view(node.id, frame, v_box(node), v_children(node, rect(0, 0, frame.width, frame.height))!) + } mut visible := []&VNode{} + mut spans := []GridSpan{} for child in node.children { if !v_is_layout_metadata(child) { visible << child + spans << v_grid_span(child) } } config := v_grid_config(node, rect(0, 0, frame.width, frame.height))! - frames := grid_layout_frames(config, visible.len)! + frames := grid_layout_frames(GridLayoutConfig{ ...config, child_spans: spans }, visible.len)! mut children := []Element{cap: visible.len} for index, child in visible { - children << node_to_element(child, frames[index])! + children << node_to_element(v_layout_node_at(child, frames[index]), frames[index])! } return view(node.id, frame, v_box(node), children) } @@ -1817,7 +1831,7 @@ fn v_frame(node &VNode, fallback Rect) Rect { return rect(node.prop_or('x', fallback.x.str()).f64(), node.prop_or('y', fallback.y.str()).f64(), node.prop_or('width', fallback.width.str()).f64(), node.prop_or('height', - fallback.height.str()).f64()) + fallback.height.str()).f64()) } fn v_dimension(node &VNode, key string, fallback f64) f64 { diff --git a/ui/vml_flex.v b/ui/vml_flex.v new file mode 100644 index 00000000..771b6b40 --- /dev/null +++ b/ui/vml_flex.v @@ -0,0 +1,181 @@ +module ui2 + +// Flex is an opt-in layout. Declared dimensions are preferred sizes; its parent +// owns the final frame. Existing Row/Column/BoxLayout documents keep their rules. +fn v_flex_config(node &VNode, frame Rect, children []FlexLayoutChild) !FlexLayoutConfig { + padding := node.prop_or('padding', '0').f64() + return FlexLayoutConfig{ + id: node.id + frame: frame + box: v_box(node) + orientation: box_orientation(node.prop_or('orientation', 'horizontal'))! + padding: BoxPadding{ + left: node.prop_or('padding_left', padding.str()).f64() + top: node.prop_or('padding_top', padding.str()).f64() + right: node.prop_or('padding_right', padding.str()).f64() + bottom: node.prop_or('padding_bottom', padding.str()).f64() + } + gap: node.prop_or('gap', '0').f64() + line_gap: node.prop_or('line_gap', '-1').f64() + wrap: node.prop_bool('wrap') + justify: flex_justify(node.prop_or('justify', 'start'))! + align: flex_alignment(node.prop_or('align_items', 'stretch'))! + children: children + } +} + +fn v_flex_child(node &VNode, preferred Rect) !FlexLayoutChild { + return FlexLayoutChild{ + element: Element{ frame: preferred } + basis: node.prop_or('flex_basis', '-1').f64() + grow: node.prop_or('flex_grow', '0').f64() + shrink: node.prop_or('flex_shrink', '1').f64() + minimum_width: node.prop_or('min_width', '0').f64() + minimum_height: node.prop_or('min_height', '0').f64() + maximum_width: node.prop_or('max_width', '-1').f64() + maximum_height: node.prop_or('max_height', '-1').f64() + align_self: flex_alignment(node.prop_or('align_self', 'auto'))! + } +} + +fn v_flex_items(node &VNode, available Rect) ![]FlexLayoutChild { + mut items := []FlexLayoutChild{} + for child in node.children { + if !v_is_layout_metadata(child) { + items << v_flex_child(child, v_layout_preferred(child, available)!)! + } + } + return items +} + +fn v_layout_preferred(node &VNode, available Rect) !Rect { + width := v_dimension(node, 'width', -1) + height := v_dimension(node, 'height', -1) + probe := rect(0, 0, if width >= 0 { width } else { available.width }, if height >= 0 { + height + } else { + available.height + }) + mut preferred := Rect{} + if width >= 0 && height >= 0 { + preferred = probe + } else if node.tag == 'FlexLayout' { + config := v_flex_config(node, probe, []FlexLayoutChild{})! + inner := rect(0, 0, box_max(0, probe.width - config.padding.left - config.padding.right), + box_max(0, probe.height - config.padding.top - config.padding.bottom)) + initial := v_flex_items(node, inner)! + frames_before_measure := flex_layout_frames(FlexLayoutConfig{ ...config, children: initial })! + items := v_flex_remeasure_plain(node, config, initial, frames_before_measure)! + preferred = flex_layout_preferred_size(FlexLayoutConfig{ ...config, children: items })! + if config.wrap && config.orientation == .horizontal { + frames := flex_layout_frames(FlexLayoutConfig{ ...config, frame: rect(0, 0, probe.width, 0), children: items })! + mut bottom := config.padding.top + for frame in frames { + bottom = box_max(bottom, frame.y + frame.height) + } + preferred = rect(0, 0, preferred.width, bottom + config.padding.bottom) + } + } else if node.tag == 'GridLayout' { + mut sizes := []Rect{} + mut spans := []GridSpan{} + mut visible := []&VNode{} + for child in node.children { + if !v_is_layout_metadata(child) { + sizes << v_layout_preferred(child, probe)! + spans << v_grid_span(child) + visible << child + } + } + config := v_grid_config(node, probe)! + spanned := GridLayoutConfig{ ...config, child_spans: spans } + initial := grid_layout_preferred_size(spanned, sizes)! + // Grid row heights also depend on the width of each assigned cell. Use + // its natural height for this width-only probe so padding/gaps fit even + // when the parent's available height is zero during measurement. + cells := grid_layout_frames(GridLayoutConfig{ ...spanned, frame: rect(0, 0, probe.width, initial.height) }, sizes.len)! + for index, child in visible { + if v_dimension(child, 'height', -1) >= 0 { continue } + measured := v_layout_preferred(v_layout_node_width(child, cells[index].width), + rect(0, 0, cells[index].width, probe.height))! + sizes[index] = rect(0, 0, sizes[index].width, measured.height) + } + preferred = grid_layout_preferred_size(spanned, sizes)! + } else { + // Leaf measurement uses the renderer's font metrics, keeping text in its + // existing point units. Other container types keep declared geometry. + el := node_to_element(node, rect(0, 0, box_max(0, width), box_max(0, height)))! + measured := measure_layout_element(el, LayoutConstraints{}, measure_layout_text)! + preferred = rect(0, 0, measured.width, measured.height) + } + return rect(0, 0, if width >= 0 { width } else { preferred.width }, if height >= 0 { + height + } else { + preferred.height + }) +} + +fn v_layout_node_at(node &VNode, frame Rect) &VNode { + mut placed := &VNode{ ...node, props: node.props.clone() } + placed.props['x'] = frame.x.str() + placed.props['y'] = frame.y.str() + placed.props['width'] = frame.width.str() + placed.props['height'] = frame.height.str() + return placed +} + +fn v_layout_node_width(node &VNode, width f64) &VNode { + mut assigned := &VNode{ ...node, props: node.props.clone(), expressions: node.expressions.clone() } + assigned.props['width'] = width.str() + assigned.expressions['width'] = &VExpression{ kind: .literal, value: width.str(), line: node.line } + return assigned +} + +fn v_flex_remeasure_plain(node &VNode, config FlexLayoutConfig, initial []FlexLayoutChild, frames []Rect) ![]FlexLayoutChild { + mut children := initial.clone() + mut index := 0 + for node_child in node.children { + if v_is_layout_metadata(node_child) { continue } + if v_dimension(node_child, 'height', -1) < 0 { + available := rect(0, 0, frames[index].width, config.frame.height) + preferred := v_layout_preferred(v_layout_node_width(node_child, frames[index].width), available)! + child := children[index] + children[index] = FlexLayoutChild{ + ...child + element: Element{ + ...child.element + frame: rect(0, 0, child.element.frame.width, preferred.height) + } + } + } + index++ + } + return children +} + +fn v_flex(node &VNode, frame Rect) !Element { + if node.prop_bool('__layout_resolved') { + return view(node.id, frame, v_box(node), v_children(node, rect(0, 0, frame.width, frame.height))!) + } + config := v_flex_config(node, rect(0, 0, frame.width, frame.height), []FlexLayoutChild{})! + inner := rect(0, 0, box_max(0, frame.width - config.padding.left - config.padding.right), + box_max(0, frame.height - config.padding.top - config.padding.bottom)) + initial := v_flex_items(node, inner)! + first := flex_layout_frames(FlexLayoutConfig{ ...config, children: initial })! + children_at_width := v_flex_remeasure_plain(node, config, initial, first)! + frames := flex_layout_frames(FlexLayoutConfig{ ...config, children: children_at_width })! + mut children := []Element{} + mut index := 0 + for child in node.children { + if v_is_layout_metadata(child) { continue } + children << node_to_element(v_layout_node_at(child, frames[index]), frames[index])! + index++ + } + return view(node.id, frame, v_box(node), children) +} + +fn v_grid_span(node &VNode) GridSpan { + return GridSpan{ + column_span: node.prop_or('column_span', '1').int() + row_span: node.prop_or('row_span', '1').int() + } +} diff --git a/ui/vml_model.v b/ui/vml_model.v index 66df9ddc..644d23b3 100644 --- a/ui/vml_model.v +++ b/ui/vml_model.v @@ -673,6 +673,7 @@ enum VChildLayoutKind { column row box + flex float grid anchor @@ -684,6 +685,10 @@ enum VChildLayoutKind { struct VLayoutChildMetrics { frame Rect + flex FlexLayoutChild + span GridSpan + source &VNode = unsafe { nil } + scope map[string]VValue size_hint_x f64 = 1.0 size_hint_y f64 = 1.0 minimum_width f64 = -1.0 @@ -697,12 +702,13 @@ struct VLayoutChildMetrics { } struct VChildLayout { - kind VChildLayoutKind - frame Rect - padding f64 - spacing f64 - cells []Rect - anchor AnchorLayoutConfig + kind VChildLayoutKind + frame Rect + padding f64 + spacing f64 + cells []Rect + anchor AnchorLayoutConfig + adaptive_parent &VNode = unsafe { nil } mut: cursor f64 index int @@ -713,6 +719,7 @@ fn v_child_layout(node &VNode, actual Rect, metrics []VLayoutChildMetrics) !VChi 'Column' { VChildLayoutKind.column } 'Row' { VChildLayoutKind.row } 'BoxLayout' { VChildLayoutKind.box } + 'FlexLayout' { VChildLayoutKind.flex } 'FloatLayout', 'RelativeLayout' { VChildLayoutKind.float } 'GridLayout' { VChildLayoutKind.grid } 'AnchorLayout' { VChildLayoutKind.anchor } @@ -727,11 +734,15 @@ fn v_child_layout(node &VNode, actual Rect, metrics []VLayoutChildMetrics) !VChi local := rect(0, 0, actual.width, actual.height) mut child_sizes := []Rect{cap: metrics.len} mut box_children := []BoxLayoutChild{cap: metrics.len} + mut flex_children := []FlexLayoutChild{cap: metrics.len} + mut spans := []GridSpan{cap: metrics.len} mut float_children := []FloatLayoutChild{cap: metrics.len} mut panel_tabs := []TabbedPanelTab{cap: metrics.len} mut accordion_items := []AccordionItem{cap: metrics.len} for metric in metrics { child_sizes << metric.frame + flex_children << metric.flex + spans << metric.span if kind == .box { box_children << BoxLayoutChild{ element: Element{ @@ -768,7 +779,13 @@ fn v_child_layout(node &VNode, actual Rect, metrics []VLayoutChildMetrics) !VChi } mut cells := []Rect{} if kind == .grid { - cells = grid_layout_frames(v_grid_config(node, local)!, child_sizes.len)! + config := v_grid_config(node, local)! + cells = grid_layout_frames(GridLayoutConfig{ ...config, child_spans: spans }, child_sizes.len)! + } else if kind == .flex { + config := v_flex_config(node, local, flex_children)! + first := flex_layout_frames(config)! + children := v_flex_remeasure_metrics(config, metrics, first)! + cells = flex_layout_frames(FlexLayoutConfig{ ...config, children: children })! } else if kind == .box { cells = box_layout_frames(v_box_layout_config(node, local, box_children)!)! } else if kind == .float { @@ -785,13 +802,18 @@ fn v_child_layout(node &VNode, actual Rect, metrics []VLayoutChildMetrics) !VChi cells = []Rect{len: accordion_items.len, init: geometry.content} } return VChildLayout{ - kind: kind - frame: local - padding: padding - spacing: node.prop_or('spacing', '0').f64() - cursor: padding - cells: cells - anchor: if kind == .anchor { + kind: kind + frame: local + padding: padding + spacing: node.prop_or('spacing', '0').f64() + cursor: padding + cells: cells + adaptive_parent: if node.tag == 'Screen' && node.prop_bool('adaptive') { + &VNode{ ...node } + } else { + unsafe { nil } + } + anchor: if kind == .anchor { v_anchor_config(node, local)! } else { AnchorLayoutConfig{} @@ -817,7 +839,7 @@ fn (layout &VChildLayout) fallback(child &VNode, scope map[string]VValue) !Rect .row { rect(layout.cursor, layout.padding, 80, layout.frame.height - layout.padding * 2) } - .box { + .box, .flex { if layout.index < layout.cells.len { layout.cells[layout.index] } else { @@ -874,7 +896,7 @@ fn (layout &VChildLayout) fallback(child &VNode, scope map[string]VValue) !Rect } fn (mut layout VChildLayout) advance(child &VNode) { - if child.tag in ['MenuItem', 'Option'] { + if v_is_layout_metadata(child) || child.tag == 'Option' { return } match layout.kind { @@ -884,7 +906,7 @@ fn (mut layout VChildLayout) advance(child &VNode) { .row { layout.cursor += v_dimension(child, 'width', 80) + layout.spacing } - .box { + .box, .flex { layout.index++ } .float { @@ -978,12 +1000,12 @@ fn v_layout_child_metric(node &VNode, scope map[string]VValue, box bool, floatin } } -fn v_layout_child_metrics(node &VNode, scope map[string]VValue) ![]VLayoutChildMetrics { +fn v_layout_child_metrics(node &VNode, scope map[string]VValue, actual Rect) ![]VLayoutChildMetrics { mut metrics := []VLayoutChildMetrics{} box := node.tag == 'BoxLayout' floating := node.tag in ['FloatLayout', 'RelativeLayout'] for child in node.children { - if child.tag in ['MenuItem', 'Option'] { + if v_is_layout_metadata(child) || child.tag == 'Option' { continue } if node.tag == 'TabbedPanel' && child.tag != 'Tab' { @@ -993,7 +1015,7 @@ fn v_layout_child_metrics(node &VNode, scope map[string]VValue) ![]VLayoutChildM continue } if child.tag != 'Repeater' { - metrics << v_layout_child_metric(child, scope, box, floating)! + metrics << v_modern_child_metric(node, child, scope, actual, box, floating)! continue } model_expr := child.expressions['model'] or { @@ -1008,10 +1030,10 @@ fn v_layout_child_metrics(node &VNode, scope map[string]VValue) ![]VLayoutChildM item_scope['item'] = item item_scope['index'] = v_number(f64(index), index.str()) for repeated in child.children { - if repeated.tag in ['MenuItem', 'Option'] { + if v_is_layout_metadata(repeated) || repeated.tag == 'Option' { continue } - metrics << v_layout_child_metric(repeated, item_scope, box, floating)! + metrics << v_modern_child_metric(node, repeated, item_scope, actual, box, floating)! } } } @@ -1055,7 +1077,8 @@ fn v_eval_node(node &VNode, incoming_scope map[string]VValue, frame Rect, mut ev mut binding := ?VmlBinding(none) for key, expr in node.expressions { if key == 'id' || key in node.property_types || key.starts_with('bind.') - || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { + || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', + 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { continue } resolved.props[key] = v_eval(expr, scope)!.string_value() @@ -1095,7 +1118,11 @@ fn v_eval_node(node &VNode, incoming_scope map[string]VValue, frame Rect, mut ev evaluation.toggle_group_bindings[resolved_binding.group] = group_bindings } } - actual := v_frame(resolved, frame) + actual := if node.tag == 'Screen' && resolved.prop_bool('adaptive') { + frame + } else { + v_frame(resolved, frame) + } if node.id.len > 0 { mut object := scope[node.id] or { return error('internal VML scope error for `${node.id}` at line ${node.line}') @@ -1162,18 +1189,31 @@ fn v_eval_node(node &VNode, incoming_scope map[string]VValue, frame Rect, mut ev } } - child_metrics := v_layout_child_metrics(node, scope)! + if node.tag == 'Screen' && resolved.prop_bool('adaptive') { + v_validate_adaptive_screen(resolved)! + } + child_metrics := v_layout_child_metrics(node, scope, actual)! mut layout := v_child_layout(resolved, actual, child_metrics)! for child in node.children { if child.tag == 'Repeater' { v_expand_repeater(child, scope, mut resolved.children, mut evaluation, mut layout)! } else { - resolved_child := v_eval_node(child, scope, layout.fallback(child, scope)!, mut - evaluation)! + resolved_child := if node.tag == 'Screen' && resolved.prop_bool('adaptive') { + v_eval_adaptive_child(resolved, child, scope, actual, mut evaluation)! + } else { + v_eval_layout_child(child, scope, layout.fallback(child, scope)!, + layout.kind, mut evaluation)! + } resolved.children << resolved_child layout.advance(resolved_child) } } + if layout.kind in [.flex, .grid] { + resolved.props['__layout_resolved'] = 'true' + } + if node.tag == 'Screen' && resolved.prop_bool('adaptive') { + resolved.props['__adaptive_layout_resolved'] = 'true' + } return resolved } @@ -1204,8 +1244,12 @@ fn v_expand_repeater(node &VNode, scope map[string]VValue, mut output []&VNode, parent_key := (scope['__repeat_key'] or { v_string('') }).string_value() item_scope['__repeat_key'] = v_string(v_repeat_identity(parent_key, key)) for child_index, child in node.children { - mut repeated := v_eval_node(child, item_scope, layout.fallback(child, item_scope)!, mut - evaluation)! + mut repeated := if layout.adaptive_parent != unsafe { nil } { + v_eval_adaptive_child(layout.adaptive_parent, child, item_scope, layout.frame, mut evaluation)! + } else { + v_eval_layout_child(child, item_scope, layout.fallback(child, item_scope)!, + layout.kind, mut evaluation)! + } if repeated.props['key'].len == 0 { repeated.props['key'] = if node.children.len == 1 { key @@ -1326,7 +1370,8 @@ fn v_validate_node_schema[T](node &VNode, incoming_scope map[string]VSchema) ! { } for key, expr in node.expressions { if key == 'id' || key in node.property_types || key.starts_with('bind.') - || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { + || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', + 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { continue } v_schema_expression(expr, scope)! @@ -1457,17 +1502,17 @@ fn type_check_action[T](name string, args []VSchema, line int) ! { if method.name == name { $if !method.is_pub { return error('app action `${name}` is not public') - } $else $if method.typ is fn () { + } $else $if method.typ is fn ( ) { if args.len != 0 { return error('app action `${name}` expects no arguments at line ${line}') } return - } $else $if method.typ is fn (int) { + } $else $if method.typ is fn ( int ) { if args.len != 1 || args[0].kind != .number { return error('app action `${name}` expects one int argument at line ${line}') } return - } $else $if method.typ is fn (string) { + } $else $if method.typ is fn ( string ) { if args.len != 1 || args[0].kind != .string_ { return error('app action `${name}` expects one string argument at line ${line}') } @@ -1495,13 +1540,13 @@ fn vml_dispatch[T](mut model T, invocation VmlInvocation) ! { } $for method in T.methods { if method.name == invocation.name { - $if method.is_pub && method.typ is fn () { + $if method.is_pub && method.typ is fn ( ) { model.$method() return - } $else $if method.is_pub && method.typ is fn (int) { + } $else $if method.is_pub && method.typ is fn ( int ) { model.$method(int(args[0].numeric(invocation.line)!)) return - } $else $if method.is_pub && method.typ is fn (string) { + } $else $if method.is_pub && method.typ is fn ( string ) { model.$method(args[0].string_value()) return } @@ -1518,11 +1563,11 @@ fn vml_apply_assignment[T](mut model T, assignment VmlAssignment) ! { pub struct VmlRunConfig[T] { pub: - source string - model T - title string = 'App' - width int = 400 - height int = 800 + source string + model T + title string = 'App' + width int = 400 + height int = 800 min_width int min_height int } diff --git a/ui/vml_model_layout.v b/ui/vml_model_layout.v new file mode 100644 index 00000000..45a7c9d5 --- /dev/null +++ b/ui/vml_model_layout.v @@ -0,0 +1,162 @@ +module ui2 + +fn v_modern_child_metric(parent &VNode, child &VNode, scope map[string]VValue, actual Rect, box bool, floating bool) !VLayoutChildMetrics { + if parent.tag == 'FlexLayout' { + // Evaluate sizing and content without registering events. The actual pass + // below resolves actions/bindings exactly once, with the allocated frame. + parent_props := v_measurement_node(parent, scope, actual, false)! + config := v_flex_config(parent_props, actual, []FlexLayoutChild{})! + available := rect(0, 0, box_max(0, actual.width - config.padding.left - config.padding.right), + box_max(0, actual.height - config.padding.top - config.padding.bottom)) + measured := v_measurement_node(child, scope, available, true)! + preferred := v_layout_preferred(measured, available)! + return VLayoutChildMetrics{ + frame: preferred + flex: v_flex_child(measured, preferred)! + source: &VNode{ ...child } + scope: scope.clone() + } + } + metric := v_layout_child_metric(child, scope, box, floating)! + if parent.tag == 'GridLayout' { + return VLayoutChildMetrics{ + ...metric + span: GridSpan{ + column_span: int(v_layout_dimension(child, 'column_span', scope, 1)!) + row_span: int(v_layout_dimension(child, 'row_span', scope, 1)!) + } + } + } + return metric +} + +// Main-axis distribution can change the width available to wrapping content. +// Measure auto heights once at that assigned width, keeping horizontal bases +// intact. Vertical layouts then distribute their updated intrinsic heights. +fn v_flex_remeasure_metrics(config FlexLayoutConfig, metrics []VLayoutChildMetrics, frames []Rect) ![]FlexLayoutChild { + mut children := config.children.clone() + for index, metric in metrics { + if metric.source == unsafe { nil } { continue } + available := rect(0, 0, frames[index].width, config.frame.height) + assigned := v_layout_node_width(metric.source, frames[index].width) + measured := v_measurement_node(assigned, metric.scope, available, true)! + if v_dimension(measured, 'height', -1) >= 0 { continue } + preferred := v_layout_preferred(measured, available)! + child := children[index] + children[index] = FlexLayoutChild{ + ...child + element: Element{ + ...child.element + frame: rect(0, 0, child.element.frame.width, preferred.height) + } + } + } + return children +} + +fn v_eval_layout_child(node &VNode, scope map[string]VValue, frame Rect, kind VChildLayoutKind, mut evaluation VmlEvaluation) !&VNode { + if kind !in [.flex, .grid] || v_is_layout_metadata(node) || node.tag == 'Option' { + return v_eval_node(node, scope, frame, mut evaluation)! + } + mut assigned := &VNode{ ...node, expressions: node.expressions.clone() } + for key, value in { + 'x': frame.x + 'y': frame.y + 'width': frame.width + 'height': frame.height + } { + assigned.expressions[key] = &VExpression{ kind: .literal, value: value.str(), line: node.line } + } + return v_eval_node(assigned, scope, frame, mut evaluation)! +} + +fn v_eval_adaptive_child(parent &VNode, child &VNode, scope map[string]VValue, available Rect, mut evaluation VmlEvaluation) !&VNode { + if child.tag in ['MenuItem', 'Option'] { + return v_eval_node(child, scope, available, mut evaluation)! + } + if child.tag == 'LayoutVariation' { + return error('LayoutVariation belongs to a control, not Screen') + } + rw := v_adaptive_number(parent, 'width', 0)! + rh := v_adaptive_number(parent, 'height', 0)! + bw := v_adaptive_number(parent, 'layout_breakpoint_width', 600)! + bh := v_adaptive_number(parent, 'layout_breakpoint_height', 600)! + v_validate_adaptive_screen(parent)! + measured := v_measurement_node(child, scope, available, true)! + adapted := v_adaptive_child(measured, rw, rh, available, + adaptive_size_class(available.width, bw), adaptive_size_class(available.height, bh))! + mut assigned := &VNode{ ...child, expressions: child.expressions.clone() } + assigned.expressions['hidden'] = &VExpression{ kind: .literal, value: adapted.prop('hidden'), line: child.line } + // Adapt before evaluation so descendants and actions see the actual frame. + return v_eval_layout_child(assigned, scope, v_frame(adapted, available), .flex, mut evaluation)! +} + +fn v_validate_adaptive_screen(parent &VNode) ! { + for key in ['width', 'height', 'layout_breakpoint_width', 'layout_breakpoint_height'] { + fallback := if key.starts_with('layout_') { 600.0 } else { 0.0 } + if v_adaptive_number(parent, key, fallback)! <= 0 { + return error('adaptive Screen needs positive design dimensions and size-class breakpoints') + } + } +} + +// Resolve only the declarative properties needed for measurement. In particular +// this pass must not register bindings, assign identities, or invoke actions. +fn v_measurement_node(node &VNode, incoming_scope map[string]VValue, available Rect, descend bool) !&VNode { + mut scope := map[string]VValue{} + for name, value in incoming_scope { + scope[name] = value + } + mut resolved := &VNode{ ...node, props: node.props.clone(), children: []&VNode{} } + if node.id.len > 0 { + scope[node.id] = v_object({ + 'x': v_number(0, '0') + 'y': v_number(0, '0') + 'width': v_number(available.width, available.width.str()) + 'height': v_number(available.height, available.height.str()) + }) + } + for key in node.property_order { + expr := node.expressions[key] or { continue } + value := v_eval(expr, scope)! + resolved.props[key] = value.string_value() + if node.id.len > 0 { + mut object := scope[node.id] or { return error('missing measurement scope for `${node.id}`') } + object.fields[key] = value + scope[node.id] = object + } + } + for key, expr in node.expressions { + if key == 'id' || key in node.property_types || key.starts_with('on_') { continue } + property := if key.starts_with('bind.') { key.all_after('bind.') } else { key } + resolved.props[property] = v_eval(expr, scope)!.string_value() + } + actual := v_frame(resolved, available) + if node.id.len > 0 { + mut object := scope[node.id] or { return error('missing measurement scope for `${node.id}`') } + object.fields['width'] = v_number(actual.width, actual.width.str()) + object.fields['height'] = v_number(actual.height, actual.height.str()) + scope[node.id] = object + } + if !descend { return resolved } + for child in node.children { + if child.tag != 'Repeater' { + resolved.children << v_measurement_node(child, scope, actual, true)! + continue + } + expr := child.expressions['model'] or { return error('Repeater requires `model` at line ${child.line}') } + items := v_eval(expr, scope)! + if items.kind != .list { + return error('Repeater model must be a collection at line ${expr.line}') + } + for index, item in items.items { + mut item_scope := scope.clone() + item_scope['item'] = item + item_scope['index'] = v_number(index, index.str()) + for repeated in child.children { + resolved.children << v_measurement_node(repeated, item_scope, actual, true)! + } + } + } + return resolved +} diff --git a/ui/vml_modern_layout_test.v b/ui/vml_modern_layout_test.v new file mode 100644 index 00000000..5423b990 --- /dev/null +++ b/ui/vml_modern_layout_test.v @@ -0,0 +1,424 @@ +module ui2 + +import math + +pub struct ModernLayoutItem { +pub: + id int + title string + basis f64 + grow f64 +} + +pub struct ModernLayoutProject { +pub: + id int + title string + description string +} + +pub struct ModernLayoutApp { +pub mut: + caption string = 'Save' + query string = 'initial' + calls int + selected int + created int + items []ModernLayoutItem + projects []ModernLayoutProject +} + +pub fn (mut app ModernLayoutApp) select(id int) { + app.selected = id + app.calls++ +} + +pub fn (mut app ModernLayoutApp) expand_caption() { + app.caption = 'Save every pending change' + app.calls++ +} + +pub fn (mut app ModernLayoutApp) create_project() { + app.created++ +} + +fn modern_layout_find(element Element, id string) ?Element { + if element.id == id { return element } + for child in element.children { + if found := modern_layout_find(child, id) { return found } + } + return none +} + +fn modern_layout_near(actual f64, expected f64) { + assert math.abs(actual - expected) < 0.00001, '${actual} != ${expected}' +} + +fn modern_layout_assert_siblings_fit(element Element) { + for index, child in element.children { + assert child.frame.width >= 0 && child.frame.height >= 0, child.id + assert child.frame.x >= -0.00001 && child.frame.y >= -0.00001, child.id + assert child.frame.x + child.frame.width <= element.frame.width + 0.00001, '${child.id}: ${child.frame} outside ${element.id}: ${element.frame}' + assert child.frame.y + child.frame.height <= element.frame.height + 0.00001, '${child.id}: ${child.frame} outside ${element.id}: ${element.frame}' + for other in element.children[index + 1..] { + if child.frame.width == 0 || child.frame.height == 0 || other.frame.width == 0 + || other.frame.height == 0 { + continue + } + assert child.frame.x + child.frame.width <= other.frame.x + 0.00001 + || other.frame.x + other.frame.width <= child.frame.x + 0.00001 + || child.frame.y + child.frame.height <= other.frame.y + 0.00001 + || other.frame.y + other.frame.height <= child.frame.y + 0.00001, 'overlap: ${child.id} ${child.frame} and ${other.id} ${other.frame}' + } + } +} + +fn test_vml_flex_parent_owns_final_frame_with_explicit_preferred_dimensions() { + source := 'FlexLayout { + gap: 10 + Button { id: first text: "First" width: 100 height: 20 flex_grow: 1 min_width: 80 } + Button { id: second text: "Second" width: 200 height: 20 flex_grow: 1 } + }' + for width in [150.0, 410.0] { + direct := element_from_vml(source, rect(20, 30, width, 50)) or { panic(err) } + modeled := element_from_vml_model(source, ModernLayoutApp{}, rect(20, 30, width, 50)) or { panic(err) } + assert direct.children.map(it.frame) == modeled.children.map(it.frame) + assert direct.children[0].frame == if width == 150 { + rect(0, 0, 80, 50) + } else { + rect(0, 0, 150, 50) + } + assert direct.children[1].frame == if width == 150 { + rect(90, 0, 60, 50) + } else { + rect(160, 0, 250, 50) + } + modern_layout_assert_siblings_fit(direct) + } +} + +fn test_vml_nested_flex_expressions_see_assigned_parent_size() { + source := 'FlexLayout { + gap: 20 + FlexLayout { + id: content + width: 100 + flex_grow: 1 + orientation: vertical + Label { id: probe text: "width=" + content.width width: content.width height: 20 } + } + View { width: 80 flex_shrink: 0 } + }' + for width in [300.0, 500.0] { + root := element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, width, 100)) or { panic(err) } + content := modern_layout_find(root, 'content') or { panic('missing content') } + probe := modern_layout_find(root, 'probe') or { panic('missing probe') } + assert content.frame.width == width - 100 + assert probe.frame.width == content.frame.width + assert probe.text.starts_with('width=') + assert probe.text.all_after('=').f64() == content.frame.width + modern_layout_assert_siblings_fit(root) + modern_layout_assert_siblings_fit(content) + } +} + +fn test_vml_nested_wrapping_flex_measures_height_at_assigned_width() { + source := 'FlexLayout { + gap: 10 align_items: start + FlexLayout { + id: wrapped flex_basis: 0 flex_grow: 1 wrap: true gap: 10 + Button { text: "First" width: 80 height: 30 } + Button { text: "Second" width: 80 height: 30 } + } + View { flex_basis: 0 flex_grow: 1 } + }' + for width in [300.0, 500.0] { + root := element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, width, 200)) or { panic(err) } + wrapped := root.children[0] + assert wrapped.frame.width == (width - 10) / 2 + assert wrapped.frame.height == if width == 300 { 70 } else { 30 } + modern_layout_assert_siblings_fit(wrapped) + } +} + +fn test_vml_flex_remeasures_multiline_and_width_dependent_text() { + source := 'FlexLayout { + gap: 10 align_items: start + Label { id: caption flex_basis: 0 flex_grow: 1 lines: 10 + text: caption.width < 200 ? "A longer caption that needs several lines when the allocated width becomes narrow" : "Short" + } + View { flex_basis: 0 flex_grow: 1 } + }' + root := element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, 300, 300)) or { panic(err) } + caption := root.children[0] + assert caption.frame.width == 145 + assert caption.text.starts_with('A longer') + measured := measure_layout_text(caption.text, caption.text_style, caption.frame.width) or { panic(err) } + assert measured.height > measure_layout_text('Short', caption.text_style, 145)!.height + modern_layout_near(caption.frame.height, measured.height) +} + +fn test_vml_vertical_flex_measures_height_after_cross_axis_maximum() { + source := 'FlexLayout { + orientation: vertical align_items: start + FlexLayout { wrap: true gap: 10 max_width: 150 + Button { text: "First" width: 80 height: 30 } + Button { text: "Second" width: 80 height: 30 } + } + }' + for modeled in [false, true] { + root := if modeled { + element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, 500, 300))! + } else { + element_from_vml(source, rect(0, 0, 500, 300))! + } + assert root.children[0].frame == rect(0, 0, 150, 70) + modern_layout_assert_siblings_fit(root.children[0]) + } +} + +fn test_vml_intrinsic_grid_measures_rows_at_assigned_cell_width() { + source := 'FlexLayout { orientation: vertical + GridLayout { columns: 2 spacing: 10 + Label { text: "A caption long enough to wrap into multiple lines inside its narrow grid cell" lines: 10 } + Label { text: "Another caption long enough to wrap into multiple lines inside its grid cell" lines: 10 } + } + }' + for modeled in [false, true] { + root := if modeled { + element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, 300, 300))! + } else { + element_from_vml(source, rect(0, 0, 300, 300))! + } + grid := root.children[0] + mut expected_height := 0.0 + for child in grid.children { + assert child.frame.width == 145 + measured := measure_layout_text(child.text, child.text_style, child.frame.width)! + expected_height = math.max(expected_height, measured.height) + } + modern_layout_near(grid.frame.height, expected_height) + modern_layout_assert_siblings_fit(grid) + } +} + +fn test_vml_adaptive_screen_reflows_modern_children_after_resizing() { + for tag in ['FlexLayout', 'GridLayout'] { + source := 'Screen { adaptive: true width: 760 height: 520 + ${tag} { id: body x: 24 y: 20 width: 712 height: 100 layout_x: stretch + columns: 2 spacing: 10 gap: 10 + Button { text: "First" flex_basis: 0 flex_grow: 1 } + Button { text: "Second" flex_basis: 0 flex_grow: 1 } + } + }' + for width in [390.0, 1000.0] { + direct := element_from_vml(source, rect(0, 0, width, 780)) or { panic(err) } + modeled := element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, width, 780)) or { panic(err) } + body := modeled.children[0] + assert body.frame.width == width - 48 + assert body.children.map(it.frame) == direct.children[0].children.map(it.frame), tag + modern_layout_assert_siblings_fit(body) + } + } +} + +fn test_vml_adaptive_repeater_variations_preserve_hidden_state_and_actions() { + source := 'Screen { id: root adaptive: true width: 760 height: 520 + Repeater { model: app.items key: item.id + FlexLayout { x: 24 y: index * 120 width: 712 height: 100 layout_x: stretch + LayoutVariation { width_class: compact reference_width: 390 reference_height: 780 + x: 10 y: index * 110 width: 370 height: 100 layout_x: stretch hidden: index == 1 } + Button { text: item.title flex_basis: 0 flex_grow: 1 on_tap: app.select(item.id) } + Label { text: root.width flex_basis: 0 flex_grow: 1 } + } + } + }' + mut app := new_vml_app(source, ModernLayoutApp{ + items: [ + ModernLayoutItem{ id: 7, title: 'First' }, + ModernLayoutItem{ id: 9, title: 'Second' }, + ] + }) or { panic(err) } + root := app.build(rect(0, 0, 390, 780)) or { panic(err) } + assert root.children.len == 2 + assert root.children[0].frame == rect(10, 0, 370, 100) + assert root.children[1].frame == rect(10, 110, 370, 100) + assert !root.children[0].hidden + assert root.children[1].hidden + assert root.children[0].children[0].frame.width == 185 + assert root.children[0].children[1].text.f64() == 390 + assert app.events.len == 2 + app.handle(root.children[0].children[0].action_id) or { panic(err) } + assert app.state().selected == 7 + assert app.state().calls == 1 + if _ := element_from_vml_model('Screen { adaptive: true width: 0 height: 520 }', + ModernLayoutApp{}, rect(0, 0, 390, 780)) { + assert false, 'invalid empty adaptive screens must still fail' + } +} + +fn test_vml_flex_wrap_uses_intrinsic_text_sizes_without_window() { + source := 'FlexLayout { + wrap: true gap: 9 align_items: start + Button { id: first text: "Publish changes" } + Button { id: second text: "Publish changes" } + }' + wide := element_from_vml(source, rect(0, 0, 1000, 200)) or { panic(err) } + first := wide.children[0] + assert first.frame.width > 30 + assert first.frame.height > 12 + assert wide.children[1].frame.y == 0 + modern_layout_near(wide.children[1].frame.x, first.frame.width + 9) + narrow_width := first.frame.width + 1 + narrow := element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, narrow_width, 200)) or { panic(err) } + modern_layout_near(narrow.children[0].frame.width, first.frame.width) + assert narrow.children[1].frame.x == 0 + modern_layout_near(narrow.children[1].frame.y, first.frame.height + 9) + modern_layout_assert_siblings_fit(narrow) +} + +fn test_vml_flex_repeater_resolves_weights_and_preserves_keyed_actions() { + source := 'FlexLayout { + gap: 10 + Repeater { + model: app.items + key: item.id + Button { text: item.title flex_basis: item.basis flex_grow: item.grow on_tap: app.select(item.id) } + } + }' + model := ModernLayoutApp{ + items: [ + ModernLayoutItem{ id: 7, title: 'First', basis: 40, grow: 1 }, + ModernLayoutItem{ id: 9, title: 'Second', basis: 80, grow: 3 }, + ] + } + root := element_from_vml_model(source, model, rect(0, 0, 300, 50)) or { panic(err) } + assert root.children[0].frame == rect(0, 0, 82.5, 50) + assert root.children[1].frame == rect(92.5, 0, 207.5, 50) + assert root.children.map(it.key) == ['7', '9'] + assert root.children[0].action_id != root.children[1].action_id + reordered := element_from_vml_model(source, ModernLayoutApp{ items: model.items.reverse() }, + rect(0, 0, 300, 50)) or { panic(err) } + assert reordered.children[0].key == '9' + assert reordered.children[0].action_id == root.children[1].action_id + assert reordered.children[1].action_id == root.children[0].action_id + mut app := new_vml_app(source, model) or { panic(err) } + for width in [300.0, 500.0, 150.0] { + built := app.build(rect(0, 0, width, 50)) or { panic(err) } + assert built.children.map(it.action_id) == root.children.map(it.action_id) + assert app.state().calls == 0 + assert app.events.len == 2 + modern_layout_assert_siblings_fit(built) + } + app.handle(root.children[1].action_id) or { panic(err) } + assert app.state().selected == 9 + assert app.state().calls == 1 +} + +fn modern_layout_control_text(_ string) string { + return 'edited' +} + +fn test_vml_flex_measurement_preserves_binding_and_never_invokes_action() { + source := 'FlexLayout { + gap: 12 align_items: center + Button { id: save text: app.caption on_tap: app.expand_caption() } + TextField { id: query bind.text: app.query flex_grow: 1 } + }' + mut app := new_vml_app(source, ModernLayoutApp{}) or { panic(err) } + app.control_text = modern_layout_control_text + first := app.build(rect(0, 0, 700, 80)) or { panic(err) } + assert app.state().calls == 0 + assert app.state().query == 'initial' + assert app.events.len == 2 + assert first.children[0].frame.width > 24 + assert first.children[1].text == 'initial' + app.handle(first.children[1].action_id) or { panic(err) } + assert app.state().query == 'edited' + app.handle(first.children[0].action_id) or { panic(err) } + second := app.build(rect(0, 0, 700, 80)) or { panic(err) } + assert app.state().calls == 1 + assert second.children[0].frame.width > first.children[0].frame.width + assert second.children[1].frame.width < first.children[1].frame.width + assert second.children[0].action_id == first.children[0].action_id + assert second.children[1].action_id == first.children[1].action_id + assert second.children[1].text == 'edited' + modern_layout_assert_siblings_fit(second) +} + +fn test_vml_grid_auto_columns_spans_nested_flex_and_metadata_resize_together() { + source := 'GridLayout { + auto_columns_min_width: 180 max_columns: 3 spacing: 10 + LayoutVariation { width_class: compact width: 20 height: 20 } + FlexLayout { + id: featured column_span: 2 row_span: 2 gap: 10 width: 1 height: 1 + Button { id: left text: "Left" width: 60 flex_grow: 1 } + LayoutVariation { width_class: compact width: 20 height: 20 } + Button { id: right text: "Right" width: 60 flex_grow: 1 } + } + Button { id: second text: "Second" width: 1 height: 1 } + Button { id: third text: "Third" width: 1 height: 1 } + }' + for width in [620.0, 250.0] { + direct := element_from_vml(source, rect(0, 0, width, 300)) or { panic(err) } + modeled := element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, width, 300)) or { panic(err) } + assert direct.children.len == 3 + assert modeled.children.len == 3 + assert direct.children.map(it.frame) == modeled.children.map(it.frame) + featured := direct.children[0] + assert featured.children.len == 2 + assert featured.frame.width == if width == 620 { 410 } else { 250 } + assert direct.children[1].frame.x == if width == 620 { 420 } else { 0 } + assert direct.children[1].frame.width == if width == 620 { 200 } else { 250 } + modern_layout_near(featured.children[0].frame.width, (featured.frame.width - 10) / 2) + modern_layout_assert_siblings_fit(direct) + modern_layout_assert_siblings_fit(featured) + modern_layout_assert_siblings_fit(modeled.children[0]) + } +} + +fn test_responsive_example_fits_wide_and_compact_windows_and_keeps_actions() { + source := $embed_file('../examples/responsive_layout/responsive_layout.vml').to_string() + model := ModernLayoutApp{ + projects: [ + ModernLayoutProject{ id: 1, title: 'Design system', description: 'Shared controls and typography' }, + ModernLayoutProject{ id: 2, title: 'Desktop app', description: 'One layout across window sizes' }, + ] + } + mut app := new_vml_app(source, model) or { panic(err) } + mut action_id := '' + for width in [1000.0, 390.0, 1000.0] { + root := app.build(rect(0, 0, width, 780)) or { panic(err) } + validate_element_tree(root) or { panic(err) } + page := modern_layout_find(root, 'page') or { panic('missing page') } + projects := modern_layout_find(root, 'projects') or { panic('missing projects') } + toolbar := modern_layout_find(root, 'toolbar') or { panic('missing toolbar') } + searchbar := modern_layout_find(root, 'searchbar') or { panic('missing searchbar') } + create := modern_layout_find(root, 'create') or { panic('missing action') } + assert page.frame.width == width + assert projects.frame.width == width - 48 + assert projects.children.len == 3 + assert projects.children[1].key == '1' + assert projects.children[2].key == '2' + if width == 390 { + assert projects.children.all(it.frame.x == 0) + assert projects.children.all(it.frame.width == projects.frame.width) + assert projects.children[1].frame.y > projects.children[0].frame.y + } else { + assert projects.children[0].frame.width > projects.children[1].frame.width + } + for container in [page, toolbar, searchbar, projects] { + modern_layout_assert_siblings_fit(container) + } + for card in projects.children { + modern_layout_assert_siblings_fit(card) + } + if action_id.len > 0 { + assert create.action_id == action_id + } + action_id = create.action_id + assert app.state().created == 0 + } + app.handle(action_id) or { panic(err) } + assert app.state().created == 1 +} From 7d93bb5959500f01950b10ada84f2b5b726d23f2 Mon Sep 17 00:00:00 2001 From: tomaszbk Date: Thu, 1 Oct 2026 21:12:37 -0300 Subject: [PATCH 2/7] perf: reuse nested Flex/Grid measurements within one VML build Each Flex level measured its subtree twice (before and after assigning widths), and nested levels repeated that, so intrinsic measurement grew exponentially with nesting depth. A per-build VLayoutMeasureCache now memoizes Flex/Grid preferred sizes by structural content and probe size, plus leaf sizes measured inside another container's measurement. Keys intern tag, id, resolved props and children rather than node paths, so Repeater items and scope-dependent expressions stay distinct. The model path keeps the cache in VmlEvaluation; static documents use one per FlexLayout. Nothing persists between builds. The flex/grid solver is unchanged. Verified identical element trees (frames, text, hidden) against the previous code for the responsive example, nested Flex and a Repeater/Grid document at four window sizes, native and custom. Benchmark (-prod, AppKit text): nested 10 levels 83 ms -> 1.6-2.5 ms, responsive example 6.2 -> 3.0 ms, flat 200-label wrap unchanged. Co-Authored-By: Claude Opus 5.5 --- benchmarks/flex_measure/main.v | 133 +++++++++++++++++++++++++++++++++ ui/vml_flex.v | 74 +++++++++++++----- ui/vml_measure_cache.v | 66 ++++++++++++++++ ui/vml_measure_cache_test.v | 84 +++++++++++++++++++++ ui/vml_model.v | 17 +++-- ui/vml_model_layout.v | 8 +- 6 files changed, 351 insertions(+), 31 deletions(-) create mode 100644 benchmarks/flex_measure/main.v create mode 100644 ui/vml_measure_cache.v create mode 100644 ui/vml_measure_cache_test.v diff --git a/benchmarks/flex_measure/main.v b/benchmarks/flex_measure/main.v new file mode 100644 index 00000000..c4b2bdb4 --- /dev/null +++ b/benchmarks/flex_measure/main.v @@ -0,0 +1,133 @@ +// Times runtime VML constructions that measure intrinsic Flex/Grid sizes: +// the responsive example, a flat wrapping Flex, and nested Flex containers. +// From the repository root: +// v -prod -path "$(dirname "$PWD")|@vlib|@vmodules" -o /tmp/ui2-flex-bench benchmarks/flex_measure/main.v +// /tmp/ui2-flex-bench [name-filter] +module main + +import os +import time +import ui2 + +const responsive_vml = $embed_file('../../examples/responsive_layout/responsive_layout.vml').to_string() +const runs = 7 + +pub struct Project { +pub: + id int + title string + description string +} + +pub struct BenchApp { +pub mut: + created int + projects []Project +} + +pub fn (mut app BenchApp) create_project() { + app.created++ +} + +struct Case { + name string + source string + frame ui2.Rect + model bool +} + +// nested alternates orientations so every level changes the width available +// to its subtree, the shape that made intrinsic measurement repeat work. +fn nested(depth int) string { + mut source := 'Label { text: "content that can wrap at a narrow width" lines: 3 }' + for level in 0 .. depth { + orientation := if level % 2 == 0 { 'horizontal' } else { 'vertical' } + source = 'FlexLayout { orientation: ${orientation} gap: 4\n Label { text: "level ${level}" }\n ${source} }' + } + return 'Screen {\n ${source} }' +} + +fn flat(count int) string { + mut labels := []string{cap: count} + for index in 0 .. count { + labels << 'Label { text: "item ${index}" }' + } + return 'Screen {\n FlexLayout { wrap: true gap: 6\n ${labels.join('\n')} } }' +} + +fn model() BenchApp { + mut projects := []Project{cap: 12} + for id in 1 .. 13 { + projects << Project{ + id: id + title: 'Project ${id}' + description: 'A description that is long enough to wrap in narrow cards' + } + } + return BenchApp{ + projects: projects + } +} + +fn build(case Case, mut app ui2.VmlApp[BenchApp]) !int { + root := if case.model { + app.build(case.frame)! + } else { + ui2.element_from_vml(case.source, + case.frame)! + } + return root.children.len +} + +fn median(values []f64) f64 { + mut sorted := values.clone() + sorted.sort(a < b) + return sorted[sorted.len / 2] +} + +fn main() { + wide := ui2.rect(0, 0, 1000, 780) + compact := ui2.rect(0, 0, 390, 780) + cases := [ + Case{'responsive wide (model)', responsive_vml, wide, true}, + Case{'responsive compact (model)', responsive_vml, compact, true}, + Case{'flat wrap 200 (static)', flat(200), wide, false}, + Case{'flat wrap 200 (model)', flat(200), wide, true}, + Case{'nested 4 (static)', nested(4), wide, false}, + Case{'nested 4 (model)', nested(4), wide, true}, + Case{'nested 8 (static)', nested(8), wide, false}, + Case{'nested 8 (model)', nested(8), wide, true}, + Case{'nested 10 (static)', nested(10), wide, false}, + Case{'nested 10 (model)', nested(10), wide, true}, + ] + filter := if os.args.len > 1 { os.args[1] } else { '' } + mut checksum := 0 + for case in cases { + if filter.len > 0 && !case.name.contains(filter) { + continue + } + mut app := ui2.new_vml_app(case.source, model())! + checksum += build(case, mut app)! // warm fonts and caches outside timing + mut iterations := 1 + for { + mut watch := time.new_stopwatch() + for _ in 0 .. iterations { + checksum += build(case, mut app)! + } + if watch.elapsed().milliseconds() >= 100 || iterations >= 100_000 { + break + } + iterations *= 2 + } + mut samples := []f64{cap: runs} + for _ in 0 .. runs { + mut watch := time.new_stopwatch() + for _ in 0 .. iterations { + checksum += build(case, mut app)! + } + samples << f64(watch.elapsed().microseconds()) / f64(iterations) + } + println('${case.name:-28} ${median(samples):12.1f} us/build') + } + println('checksum ${checksum}') +} diff --git a/ui/vml_flex.v b/ui/vml_flex.v index 771b6b40..f28168f2 100644 --- a/ui/vml_flex.v +++ b/ui/vml_flex.v @@ -38,17 +38,17 @@ fn v_flex_child(node &VNode, preferred Rect) !FlexLayoutChild { } } -fn v_flex_items(node &VNode, available Rect) ![]FlexLayoutChild { +fn v_flex_items(node &VNode, available Rect, mut cache VLayoutMeasureCache) ![]FlexLayoutChild { mut items := []FlexLayoutChild{} for child in node.children { if !v_is_layout_metadata(child) { - items << v_flex_child(child, v_layout_preferred(child, available)!)! + items << v_flex_child(child, v_layout_preferred(child, available, mut cache)!)! } } return items } -fn v_layout_preferred(node &VNode, available Rect) !Rect { +fn v_layout_preferred(node &VNode, available Rect, mut cache VLayoutMeasureCache) !Rect { width := v_dimension(node, 'width', -1) height := v_dimension(node, 'height', -1) probe := rect(0, 0, if width >= 0 { width } else { available.width }, if height >= 0 { @@ -56,16 +56,38 @@ fn v_layout_preferred(node &VNode, available Rect) !Rect { } else { available.height }) - mut preferred := Rect{} if width >= 0 && height >= 0 { - preferred = probe - } else if node.tag == 'FlexLayout' { + return probe + } + container := node.tag in ['FlexLayout', 'GridLayout'] + // Only measurements inside another container's measurement repeat. Keying + // the direct children of a single Flex would cost more than it could save. + if !container && cache.nesting == 0 { + return v_layout_leaf_preferred(node, width, height)! + } + // A leaf reads only its declared dimensions, so the probe is not part of it. + key := cache.preferred_key(node, probe, container) + if cached := cache.preferred[key] { + return cached + } + cache.computed++ + if !container { + result := v_layout_leaf_preferred(node, width, height)! + cache.preferred[key] = result + return result + } + cache.nesting++ + defer { + cache.nesting-- + } + mut preferred := Rect{} + if node.tag == 'FlexLayout' { config := v_flex_config(node, probe, []FlexLayoutChild{})! inner := rect(0, 0, box_max(0, probe.width - config.padding.left - config.padding.right), box_max(0, probe.height - config.padding.top - config.padding.bottom)) - initial := v_flex_items(node, inner)! + initial := v_flex_items(node, inner, mut cache)! frames_before_measure := flex_layout_frames(FlexLayoutConfig{ ...config, children: initial })! - items := v_flex_remeasure_plain(node, config, initial, frames_before_measure)! + items := v_flex_remeasure_plain(node, config, initial, frames_before_measure, mut cache)! preferred = flex_layout_preferred_size(FlexLayoutConfig{ ...config, children: items })! if config.wrap && config.orientation == .horizontal { frames := flex_layout_frames(FlexLayoutConfig{ ...config, frame: rect(0, 0, probe.width, 0), children: items })! @@ -81,7 +103,7 @@ fn v_layout_preferred(node &VNode, available Rect) !Rect { mut visible := []&VNode{} for child in node.children { if !v_is_layout_metadata(child) { - sizes << v_layout_preferred(child, probe)! + sizes << v_layout_preferred(child, probe, mut cache)! spans << v_grid_span(child) visible << child } @@ -96,17 +118,25 @@ fn v_layout_preferred(node &VNode, available Rect) !Rect { for index, child in visible { if v_dimension(child, 'height', -1) >= 0 { continue } measured := v_layout_preferred(v_layout_node_width(child, cells[index].width), - rect(0, 0, cells[index].width, probe.height))! + rect(0, 0, cells[index].width, probe.height), mut cache)! sizes[index] = rect(0, 0, sizes[index].width, measured.height) } preferred = grid_layout_preferred_size(spanned, sizes)! - } else { - // Leaf measurement uses the renderer's font metrics, keeping text in its - // existing point units. Other container types keep declared geometry. - el := node_to_element(node, rect(0, 0, box_max(0, width), box_max(0, height)))! - measured := measure_layout_element(el, LayoutConstraints{}, measure_layout_text)! - preferred = rect(0, 0, measured.width, measured.height) } + result := v_layout_declared_or(width, height, preferred) + cache.preferred[key] = result + return result +} + +// Leaf measurement uses the renderer's font metrics, keeping text in its +// existing point units. Other container types keep declared geometry. +fn v_layout_leaf_preferred(node &VNode, width f64, height f64) !Rect { + el := node_to_element(node, rect(0, 0, box_max(0, width), box_max(0, height)))! + measured := measure_layout_element(el, LayoutConstraints{}, measure_layout_text)! + return v_layout_declared_or(width, height, rect(0, 0, measured.width, measured.height)) +} + +fn v_layout_declared_or(width f64, height f64, preferred Rect) Rect { return rect(0, 0, if width >= 0 { width } else { preferred.width }, if height >= 0 { height } else { @@ -130,14 +160,15 @@ fn v_layout_node_width(node &VNode, width f64) &VNode { return assigned } -fn v_flex_remeasure_plain(node &VNode, config FlexLayoutConfig, initial []FlexLayoutChild, frames []Rect) ![]FlexLayoutChild { +fn v_flex_remeasure_plain(node &VNode, config FlexLayoutConfig, initial []FlexLayoutChild, frames []Rect, mut cache VLayoutMeasureCache) ![]FlexLayoutChild { mut children := initial.clone() mut index := 0 for node_child in node.children { if v_is_layout_metadata(node_child) { continue } if v_dimension(node_child, 'height', -1) < 0 { available := rect(0, 0, frames[index].width, config.frame.height) - preferred := v_layout_preferred(v_layout_node_width(node_child, frames[index].width), available)! + preferred := v_layout_preferred(v_layout_node_width(node_child, frames[index].width), available, mut + cache)! child := children[index] children[index] = FlexLayoutChild{ ...child @@ -159,9 +190,12 @@ fn v_flex(node &VNode, frame Rect) !Element { config := v_flex_config(node, rect(0, 0, frame.width, frame.height), []FlexLayoutChild{})! inner := rect(0, 0, box_max(0, frame.width - config.padding.left - config.padding.right), box_max(0, frame.height - config.padding.top - config.padding.bottom)) - initial := v_flex_items(node, inner)! + // Static documents convert each nested Flex separately; one cache per Flex + // still shares the repeated subtree measurements below it. + mut cache := VLayoutMeasureCache{} + initial := v_flex_items(node, inner, mut cache)! first := flex_layout_frames(FlexLayoutConfig{ ...config, children: initial })! - children_at_width := v_flex_remeasure_plain(node, config, initial, first)! + children_at_width := v_flex_remeasure_plain(node, config, initial, first, mut cache)! frames := flex_layout_frames(FlexLayoutConfig{ ...config, children: children_at_width })! mut children := []Element{} mut index := 0 diff --git a/ui/vml_measure_cache.v b/ui/vml_measure_cache.v new file mode 100644 index 00000000..698b5e29 --- /dev/null +++ b/ui/vml_measure_cache.v @@ -0,0 +1,66 @@ +module ui2 + +import math + +// VLayoutMeasureCache reuses intrinsic sizes within one VML construction. +// Each Flex level measures a subtree before and after assigning its width, and +// nested levels repeat that, so identical requests multiply with depth. A cache +// lives for one build and is then dropped: nothing needs invalidating, and any +// change of content or constraints inside the build is a different key. +// +// Keys are structural rather than node paths. Repeater items share a path, and +// expressions that read an ancestor's size resolve the same source node to +// different content during measurement and evaluation. +@[heap] +struct VLayoutMeasureCache { +mut: + shapes map[voidptr]VMeasureShape + shape_ids map[string]int + preferred map[string]Rect + // computed counts preferred-size measurements that missed the cache. + computed int + // nesting counts container measurements in progress. + nesting int +} + +struct VMeasureShape { + // Holding the node keeps its address from being reused during the build. + node &VNode + id int +} + +// shape interns a node's measurement inputs: tag, id, resolved properties and +// children. Children are interned first, so each node costs only its own data. +fn (mut cache VLayoutMeasureCache) shape(node &VNode) int { + if known := cache.shapes[voidptr(node)] { + return known.id + } + mut key := []string{cap: node.props.len * 2 + node.children.len + 2} + key << '${node.tag.len}:${node.tag}${node.id.len}:${node.id}' + for name, value in node.props { + key << '${name.len}:${name}${value.len}:${value}' + } + key << '|' + for child in node.children { + key << cache.shape(child).str() + } + canonical := key.join(',') + id := cache.shape_ids[canonical] or { + next := cache.shape_ids.len + cache.shape_ids[canonical] = next + next + } + cache.shapes[voidptr(node)] = VMeasureShape{ + node: unsafe { node } + id: id + } + return id +} + +fn (mut cache VLayoutMeasureCache) preferred_key(node &VNode, probe Rect, depends_on_probe bool) string { + shape := cache.shape(node) + if !depends_on_probe { + return shape.str() + } + return '${shape}:${math.f64_bits(probe.width)}:${math.f64_bits(probe.height)}' +} diff --git a/ui/vml_measure_cache_test.v b/ui/vml_measure_cache_test.v new file mode 100644 index 00000000..f76dadb4 --- /dev/null +++ b/ui/vml_measure_cache_test.v @@ -0,0 +1,84 @@ +module ui2 + +pub struct MeasureCacheItem { +pub: + id int + text string +} + +pub struct MeasureCacheApp { +pub: + items []MeasureCacheItem +} + +// Alternating orientations change the width each level offers its subtree. +fn measure_cache_nested(depth int) string { + mut source := 'Label { text: "content that wraps at a narrow width" lines: 3 }' + for level in 0 .. depth { + orientation := if level % 2 == 0 { 'horizontal' } else { 'vertical' } + source = 'FlexLayout { orientation: ${orientation} gap: 4\n Label { text: "level ${level}" }\n ${source} }' + } + return source +} + +fn measure_cache_model_computations(depth int) int { + root := parse_vml('Screen {\n ${measure_cache_nested(depth)} }') or { panic(err) } + mut evaluation := VmlEvaluation{} + v_eval_node(root, { + 'app': v_value_from(MeasureCacheApp{}) + }, rect(0, 0, 800, 600), mut evaluation) or { panic(err) } + return evaluation.measure.computed +} + +fn measure_cache_static_computations(depth int) int { + root := parse_vml(measure_cache_nested(depth)) or { panic(err) } + mut cache := VLayoutMeasureCache{} + v_layout_preferred(root, rect(0, 0, 800, 600), mut cache) or { panic(err) } + return cache.computed +} + +fn test_nested_flex_measurement_grows_polynomially_with_depth() { + for count in [measure_cache_static_computations, measure_cache_model_computations] { + shallow := count(6) + deep := count(12) + // Measuring every subtree twice per level made six more levels cost about + // 2^6 times as much. With reuse the growth stays near quadratic (~4x). + assert deep < shallow * 8, '${shallow} -> ${deep}' + } +} + +// Repeated items share a source node and path but resolve to different text. +// Each must be measured as its own content inside an outer measurement. +fn test_nested_measurement_keeps_repeated_items_distinct() { + source := 'FlexLayout { + orientation: vertical align_items: start + FlexLayout { + id: row gap: 4 align_items: start width: 300 + Repeater { + model: app.items + key: item.id + FlexLayout { + orientation: vertical flex_basis: 0 flex_grow: 1 + Label { text: item.text lines: 8 } + } + } + } + }' + long_text := 'A description long enough to wrap onto several lines in a narrow column' + model := MeasureCacheApp{ + items: [MeasureCacheItem{1, 'Short'}, MeasureCacheItem{2, long_text}, + MeasureCacheItem{3, 'Short'}] + } + root := element_from_vml_model(source, model, rect(0, 0, 600, 400)) or { panic(err) } + row := root.children[0] + items := row.children + assert items.len == 3 + label := items[1].children[0] + expected := measure_layout_text(long_text, label.text_style, items[1].frame.width) or { + panic(err) + } + assert items[0].frame.height < items[1].frame.height + assert items[0].frame.height == items[2].frame.height + assert items[1].frame.height == expected.height + assert row.frame.height == expected.height +} diff --git a/ui/vml_model.v b/ui/vml_model.v index 644d23b3..4ad2a911 100644 --- a/ui/vml_model.v +++ b/ui/vml_model.v @@ -616,6 +616,7 @@ struct VmlEvaluation { mut: events map[string]VmlEvent toggle_group_bindings map[string][]VmlBinding + measure VLayoutMeasureCache } fn v_action(expr &VExpression, scope map[string]VValue) !VmlInvocation { @@ -714,7 +715,7 @@ mut: index int } -fn v_child_layout(node &VNode, actual Rect, metrics []VLayoutChildMetrics) !VChildLayout { +fn v_child_layout(node &VNode, actual Rect, metrics []VLayoutChildMetrics, mut cache VLayoutMeasureCache) !VChildLayout { kind := match node.tag { 'Column' { VChildLayoutKind.column } 'Row' { VChildLayoutKind.row } @@ -784,7 +785,7 @@ fn v_child_layout(node &VNode, actual Rect, metrics []VLayoutChildMetrics) !VChi } else if kind == .flex { config := v_flex_config(node, local, flex_children)! first := flex_layout_frames(config)! - children := v_flex_remeasure_metrics(config, metrics, first)! + children := v_flex_remeasure_metrics(config, metrics, first, mut cache)! cells = flex_layout_frames(FlexLayoutConfig{ ...config, children: children })! } else if kind == .box { cells = box_layout_frames(v_box_layout_config(node, local, box_children)!)! @@ -1000,7 +1001,7 @@ fn v_layout_child_metric(node &VNode, scope map[string]VValue, box bool, floatin } } -fn v_layout_child_metrics(node &VNode, scope map[string]VValue, actual Rect) ![]VLayoutChildMetrics { +fn v_layout_child_metrics(node &VNode, scope map[string]VValue, actual Rect, mut cache VLayoutMeasureCache) ![]VLayoutChildMetrics { mut metrics := []VLayoutChildMetrics{} box := node.tag == 'BoxLayout' floating := node.tag in ['FloatLayout', 'RelativeLayout'] @@ -1015,7 +1016,8 @@ fn v_layout_child_metrics(node &VNode, scope map[string]VValue, actual Rect) ![] continue } if child.tag != 'Repeater' { - metrics << v_modern_child_metric(node, child, scope, actual, box, floating)! + metrics << v_modern_child_metric(node, child, scope, actual, box, floating, mut + cache)! continue } model_expr := child.expressions['model'] or { @@ -1033,7 +1035,8 @@ fn v_layout_child_metrics(node &VNode, scope map[string]VValue, actual Rect) ![] if v_is_layout_metadata(repeated) || repeated.tag == 'Option' { continue } - metrics << v_modern_child_metric(node, repeated, item_scope, actual, box, floating)! + metrics << v_modern_child_metric(node, repeated, item_scope, actual, box, floating, mut + cache)! } } } @@ -1192,8 +1195,8 @@ fn v_eval_node(node &VNode, incoming_scope map[string]VValue, frame Rect, mut ev if node.tag == 'Screen' && resolved.prop_bool('adaptive') { v_validate_adaptive_screen(resolved)! } - child_metrics := v_layout_child_metrics(node, scope, actual)! - mut layout := v_child_layout(resolved, actual, child_metrics)! + child_metrics := v_layout_child_metrics(node, scope, actual, mut evaluation.measure)! + mut layout := v_child_layout(resolved, actual, child_metrics, mut evaluation.measure)! for child in node.children { if child.tag == 'Repeater' { v_expand_repeater(child, scope, mut resolved.children, mut evaluation, mut layout)! diff --git a/ui/vml_model_layout.v b/ui/vml_model_layout.v index 45a7c9d5..6b48edb8 100644 --- a/ui/vml_model_layout.v +++ b/ui/vml_model_layout.v @@ -1,6 +1,6 @@ module ui2 -fn v_modern_child_metric(parent &VNode, child &VNode, scope map[string]VValue, actual Rect, box bool, floating bool) !VLayoutChildMetrics { +fn v_modern_child_metric(parent &VNode, child &VNode, scope map[string]VValue, actual Rect, box bool, floating bool, mut cache VLayoutMeasureCache) !VLayoutChildMetrics { if parent.tag == 'FlexLayout' { // Evaluate sizing and content without registering events. The actual pass // below resolves actions/bindings exactly once, with the allocated frame. @@ -9,7 +9,7 @@ fn v_modern_child_metric(parent &VNode, child &VNode, scope map[string]VValue, a available := rect(0, 0, box_max(0, actual.width - config.padding.left - config.padding.right), box_max(0, actual.height - config.padding.top - config.padding.bottom)) measured := v_measurement_node(child, scope, available, true)! - preferred := v_layout_preferred(measured, available)! + preferred := v_layout_preferred(measured, available, mut cache)! return VLayoutChildMetrics{ frame: preferred flex: v_flex_child(measured, preferred)! @@ -33,7 +33,7 @@ fn v_modern_child_metric(parent &VNode, child &VNode, scope map[string]VValue, a // Main-axis distribution can change the width available to wrapping content. // Measure auto heights once at that assigned width, keeping horizontal bases // intact. Vertical layouts then distribute their updated intrinsic heights. -fn v_flex_remeasure_metrics(config FlexLayoutConfig, metrics []VLayoutChildMetrics, frames []Rect) ![]FlexLayoutChild { +fn v_flex_remeasure_metrics(config FlexLayoutConfig, metrics []VLayoutChildMetrics, frames []Rect, mut cache VLayoutMeasureCache) ![]FlexLayoutChild { mut children := config.children.clone() for index, metric in metrics { if metric.source == unsafe { nil } { continue } @@ -41,7 +41,7 @@ fn v_flex_remeasure_metrics(config FlexLayoutConfig, metrics []VLayoutChildMetri assigned := v_layout_node_width(metric.source, frames[index].width) measured := v_measurement_node(assigned, metric.scope, available, true)! if v_dimension(measured, 'height', -1) >= 0 { continue } - preferred := v_layout_preferred(measured, available)! + preferred := v_layout_preferred(measured, available, mut cache)! child := children[index] children[index] = FlexLayoutChild{ ...child From d6175401a5923da68b4c137579752d319dac3e57 Mon Sep 17 00:00:00 2001 From: tomaszbk Date: Sat, 3 Oct 2026 10:57:07 -0300 Subject: [PATCH 3/7] fix: measure custom text areas at their drawn content width --- ui/layout_measure.v | 42 +++++++++++++++++++++++- ui/layout_measure_backend.c.v | 14 ++++++++ ui/layout_measure_cpu.c.v | 10 ++++++ ui/layout_measure_test.v | 61 +++++++++++++++++++++++++++++++++++ ui/ui_font.v | 54 +++++++++++++++++++++++++++++++ ui/ui_scroll_immediate.c.v | 54 ------------------------------- 6 files changed, 180 insertions(+), 55 deletions(-) diff --git a/ui/layout_measure.v b/ui/layout_measure.v index beceb624..d6ee0166 100644 --- a/ui/layout_measure.v +++ b/ui/layout_measure.v @@ -122,7 +122,17 @@ pub fn measure_layout_element(element Element, constraints LayoutConstraints, me } else { element.text } - measured := measure(content, element.text_style, text_width)! + measured := $if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && !ui2_headless ? { + // Editors wrap every row, unlike labels with a declared line limit. + // Keep external callbacks' original TextStyle contract unchanged. + if element.kind == .text_area && voidptr(measure) == voidptr(measure_layout_text) { + layout_measure_custom_text_area(content, element.text_style, text_width)! + } else { + measure(content, element.text_style, text_width)! + } + } $else { + measure(content, element.text_style, text_width)! + } preferred = LayoutSize{ width: measured.width + insets.left + insets.right height: measured.height + insets.top + insets.bottom @@ -158,6 +168,18 @@ fn layout_measure_control_insets(element Element) BoxPadding { return BoxPadding{ left: 2, right: 2 } } } + $if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && !ui2_headless ? { + if element.kind == .text_area { + // Include the gutter reserved by text_area_content_rect even when no + // scrollbar is visible, so measurement and drawing wrap identically. + return BoxPadding{ + left: math.max(2, element.padding_left) + right: if element.disable_scroll { 8 } else { 12 } + top: 8 + bottom: 8 + } + } + } return match element.kind { .button, .toggle_button { BoxPadding{ left: 12, right: if element.image_path.len > 0 { 36 } else { 12 }, top: 6, bottom: 6 } @@ -200,3 +222,21 @@ fn layout_measure_text_lines(text string, style TextStyle, max_width f64, line_h height: line_height * f64(lines.len) }) } + +fn layout_measure_text_area_lines(text string, style TextStyle, max_width f64, line_height f64, width_of fn (string) f64) !LayoutSize { + layout_validate_text_measurement(style, max_width)! + if !math.is_finite(line_height) || line_height <= 0 { + return error('text measurement needs a positive finite line height') + } + lines := if max_width < 0 { + text.replace('\r\n', '\n').replace('\r', '\n').split('\n') + } else { + wrap_text_area_lines(text, max_width, width_of) + } + mut width := 0.0 + for line in lines { width = math.max(width, width_of(line)) } + return LayoutConstraints{ max_width: max_width }.constrain(LayoutSize{ + width: width + height: line_height * f64(lines.len) + }) +} diff --git a/ui/layout_measure_backend.c.v b/ui/layout_measure_backend.c.v index 57c4d405..128c320b 100644 --- a/ui/layout_measure_backend.c.v +++ b/ui/layout_measure_backend.c.v @@ -19,3 +19,17 @@ pub fn measure_layout_text(text string, style TextStyle, max_width f64) !LayoutS } return layout_measure_cpu_text(text, style, max_width) } + +$if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && !ui2_headless ? { + fn layout_measure_custom_text_area(text string, style TextStyle, max_width f64) !LayoutSize { + layout_validate_text_measurement(style, max_width)! + ctx := g_gg_app.ctx + if ctx != unsafe { nil } && ctx.font_inited { + ensure_symbol_fallbacks(ctx) + return layout_measure_text_area_lines(text, style, max_width, font_line_height(style.size), fn [ctx, style] (line string) f64 { + return menu_text_width(ctx, line, style) + }) + } + return layout_measure_cpu_fontstash(text, style, max_width, true) + } +} diff --git a/ui/layout_measure_cpu.c.v b/ui/layout_measure_cpu.c.v index f58e7758..5280a3b3 100644 --- a/ui/layout_measure_cpu.c.v +++ b/ui/layout_measure_cpu.c.v @@ -86,6 +86,10 @@ fn (mut fonts LayoutMeasureFonts) load(path string) !int { } fn layout_measure_cpu_text(text string, style TextStyle, max_width f64) !LayoutSize { + return layout_measure_cpu_fontstash(text, style, max_width, false) +} + +fn layout_measure_cpu_fontstash(text string, style TextStyle, max_width f64, text_area bool) !LayoutSize { mut fonts := g_layout_measure_fonts fonts.mutex.lock() defer { fonts.mutex.unlock() } @@ -101,6 +105,12 @@ fn layout_measure_cpu_text(text string, style TextStyle, max_width f64) !LayoutS stash.set_font(id) stash.set_size(f32(int(font_render_size(style.size, metrics) + 0.5))) stash.set_align(int(fontstash.Align.left) | int(fontstash.Align.baseline)) + if text_area { + return layout_measure_text_area_lines(text, style, max_width, font_line_height(style.size), fn [stash] (line string) f64 { + mut glyph_bounds := [4]f32{} + return f64(stash.text_bounds(0, 0, line, &glyph_bounds[0])) + }) + } return layout_measure_text_lines(text, style, max_width, font_line_height(style.size), fn [stash] (line string) f64 { mut glyph_bounds := [4]f32{} return f64(stash.text_bounds(0, 0, line, &glyph_bounds[0])) diff --git a/ui/layout_measure_test.v b/ui/layout_measure_test.v index 89fec98d..61538dbb 100644 --- a/ui/layout_measure_test.v +++ b/ui/layout_measure_test.v @@ -1,5 +1,9 @@ module ui2 +$if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && !ui2_headless ? { + import math +} + fn layout_measure_fixture_width(text string) f64 { mut width := 0.0 for character in text.runes() { @@ -90,3 +94,60 @@ fn test_layout_default_text_measurement_uses_real_fonts_before_window_creation() assert wrapped.width <= wide.width + 1 assert wrapped.height > wide.height } + +fn test_layout_editor_measurement_preserves_rows_and_splits_overlong_words() { + // Independent advances: W=12, i=3. Two Ws fill the first 24-unit row; + // three i glyphs fill the next, followed by an empty paragraph and one W. + style := TextStyle{ lines: 1 } + assert layout_measure_text_area_lines('WWiii\r\n\r\nW', style, 24, 20, + layout_measure_fixture_width)! == LayoutSize{ width: 24, height: 80 } + assert layout_measure_text_area_lines('WWiii\r\n\r\nW', style, -1, 20, + layout_measure_fixture_width)! == LayoutSize{ width: 33, height: 60 } + assert layout_measure_text_area_lines('ab cd\tef\n', style, 24, 20, + layout_measure_fixture_width)! == LayoutSize{ width: 16, height: 80 } + assert layout_measure_text_area_lines('WW', style, 0, 20, + layout_measure_fixture_width)! == LayoutSize{} + assert style.lines == 1 +} + +fn layout_measure_require_declared_editor_style(_text string, style TextStyle, _width f64) !LayoutSize { + if style.lines != 1 || style.size != 16 || style.font_family != 'Roboto Mono' { + return error('external callbacks must receive the declared editor style') + } + return LayoutSize{ width: 16, height: 20 } +} + +fn test_custom_intrinsic_editor_matches_wrapping_content_width_before_window_creation() { + $if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && !ui2_headless ? { + style := TextStyle{ size: 16, font_family: 'Roboto Mono', lines: 1 } + advance := measure_layout_text('M', style, -1)!.width + // With two monospaced glyphs per row, five Ms occupy three rows. + // CRLF blank, final M and trailing newline contribute three more. + content_width := advance * 2 + 0.05 + for padding_left in [0.0, 12.0] { + for scrolling in [true, false] { + area := Element{ + ...text_area('notes', 'MMMMM\r\n\r\nM\n', Rect{}, BoxStyle{}, style) + padding_left: padding_left + disable_scroll: !scrolling + } + gutter := if scrolling { 12.0 } else { 8.0 } + left := if padding_left == 0 { 2.0 } else { 12.0 } + available := content_width + left + gutter + measured := measure_layout_element(area, LayoutConstraints{ max_width: available }, + measure_layout_text)! + assert math.abs(measured.height - (font_line_height(style.size) * 6 + 16)) < 0.01 + assert math.abs(measured.width - (advance * 2 + left + gutter)) < 0.01 + assert area.text_style == style + custom := measure_layout_element(area, LayoutConstraints{}, + layout_measure_require_declared_editor_style)! + assert custom == LayoutSize{ width: 16 + left + gutter, height: 36 } + fixed := Element{ ...area, frame: rect(0, 0, 90, 30) } + assert measure_layout_element(fixed, LayoutConstraints{}, layout_measure_unexpected_text)! == LayoutSize{ width: 90, height: 30 } + zero_content := measure_layout_element(area, + LayoutConstraints{ max_width: left + gutter }, measure_layout_text)! + assert zero_content == LayoutSize{ width: left + gutter, height: 16 } + } + } + } +} diff --git a/ui/ui_font.v b/ui/ui_font.v index 6bbbd4a7..43b1395a 100644 --- a/ui/ui_font.v +++ b/ui/ui_font.v @@ -114,6 +114,60 @@ fn wrap_text_lines_measured(text string, width f64, limit int, measure fn (strin return lines } +// Wrap using the same font measurement as drawing. Explicit blank lines +// survive, and an unbroken word is split only at UTF-8 rune boundaries. +fn wrap_text_area_lines(value string, width f64, measure fn (string) f64) []string { + if width <= 0 { + return []string{} + } + mut lines := []string{} + for paragraph in value.replace('\r\n', '\n').replace('\r', '\n').split('\n') { + if paragraph.len == 0 { + lines << '' + continue + } + runes := paragraph.runes() + mut start := 0 + for start < runes.len { + rest := runes[start..].string() + if measure(rest) <= width { + lines << rest + break + } + mut low := 0 + mut high := runes.len - start + for low < high { + mid := (low + high + 1) / 2 + if measure(runes[start..start + mid].string()) <= width { + low = mid + } else { + high = mid - 1 + } + } + // A glyph wider than the pane is clipped, but must still advance. + kept := if low > 0 { low } else { 1 } + mut end := start + kept + mut next := end + if end < runes.len { + mut space := end + for space > start && runes[space] != ` ` && runes[space] != `\t` { + space-- + } + if space > start { + end = space + next = space + 1 + for next < runes.len && (runes[next] == ` ` || runes[next] == `\t`) { + next++ + } + } + } + lines << runes[start..end].string() + start = next + } + } + return lines +} + // text_with_overflow puts everything a label has no room left for onto its last line: // the words after `head` on that line, then the paragraphs after it. They are joined // with spaces because what comes back is one line, and drawing truncates it, which is diff --git a/ui/ui_scroll_immediate.c.v b/ui/ui_scroll_immediate.c.v index d647998c..d5000393 100644 --- a/ui/ui_scroll_immediate.c.v +++ b/ui/ui_scroll_immediate.c.v @@ -215,60 +215,6 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 return '' } - // Wrap using the same font measurement as drawing. Explicit blank lines - // survive, and an unbroken word is split only at UTF-8 rune boundaries. - fn wrap_text_area_lines(value string, width f64, measure fn (string) f64) []string { - if width <= 0 { - return []string{} - } - mut lines := []string{} - for paragraph in value.replace('\r\n', '\n').replace('\r', '\n').split('\n') { - if paragraph.len == 0 { - lines << '' - continue - } - runes := paragraph.runes() - mut start := 0 - for start < runes.len { - rest := runes[start..].string() - if measure(rest) <= width { - lines << rest - break - } - mut low := 0 - mut high := runes.len - start - for low < high { - mid := (low + high + 1) / 2 - if measure(runes[start..start + mid].string()) <= width { - low = mid - } else { - high = mid - 1 - } - } - // A glyph wider than the pane is clipped, but must still advance. - kept := if low > 0 { low } else { 1 } - mut end := start + kept - mut next := end - if end < runes.len { - mut space := end - for space > start && runes[space] != ` ` && runes[space] != `\t` { - space-- - } - if space > start { - end = space - next = space + 1 - for next < runes.len && (runes[next] == ` ` || runes[next] == `\t`) { - next++ - } - } - } - lines << runes[start..end].string() - start = next - } - } - return lines - } - fn text_area_lines(id string, value string, width f64, style TextStyle, rendered_size int, measure fn (string) f64) []string { if id.len > 0 { if cached := g_text_area_layouts[id] { From 1b204d485ecba2b7249c28c86241341d0aa8461e Mon Sep 17 00:00:00 2001 From: tomaszbk Date: Sat, 3 Oct 2026 12:55:46 -0300 Subject: [PATCH 4/7] Keep fractional dimensions in automatic and intrinsic Grid layout --- ui/grid_layout.v | 10 +++++----- ui/grid_layout_test.v | 12 ++++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/ui/grid_layout.v b/ui/grid_layout.v index 32997be2..61b9a304 100644 --- a/ui/grid_layout.v +++ b/ui/grid_layout.v @@ -102,7 +102,7 @@ fn grid_validate_config(config GridLayoutConfig, child_count int) ! { } fn grid_automatic_columns(config GridLayoutConfig, child_count int) int { - inner := math.max(0, config.frame.width - config.padding.left - config.padding.right) + inner := math.max(0.0, config.frame.width - config.padding.left - config.padding.right) fit := if inner < config.auto_columns_min_width { f64(1) } else { @@ -372,7 +372,7 @@ fn grid_fit_padding(available f64, leading f64, trailing f64, automatic bool) (f fn grid_fit_spacing(available f64, count int, spacing f64, automatic bool) f64 { if automatic && count > 1 { - return math.min(spacing, math.max(0, available) / f64(count - 1)) + return math.min(spacing, math.max(0.0, available) / f64(count - 1)) } return spacing } @@ -410,8 +410,8 @@ pub fn grid_layout_frames(config GridLayoutConfig, child_count int) ![]Rect { vertical_spacing := grid_fit_spacing(config.frame.height - top - bottom, rows, config.spacing.vertical, automatic) computed_width := config.frame.width - left - right - horizontal_spacing * f64(columns - 1) computed_height := config.frame.height - top - bottom - vertical_spacing * f64(rows - 1) - inner_width := if automatic { math.max(0, computed_width) } else { computed_width } - inner_height := if automatic { math.max(0, computed_height) } else { computed_height } + inner_width := if automatic { math.max(0.0, computed_width) } else { computed_width } + inner_height := if automatic { math.max(0.0, computed_height) } else { computed_height } widths := grid_axis_sizes(inner_width, columns, config.column_default_width, config.force_column_width, config.columns_minimum, automatic)! heights := grid_axis_sizes(inner_height, rows, config.row_default_height, config.force_row_height, config.rows_minimum, automatic)! mut x_positions := []f64{len: columns} @@ -490,7 +490,7 @@ fn grid_preferred_track_extra(tracks []f64, start int, count int, preferred f64, for index in start .. start + count { current += tracks[index] } - return math.max(0, (preferred - current) / f64(count)) + return math.max(0.0, (preferred - current) / f64(count)) } // grid_layout creates a view whose children are assigned to cells in their diff --git a/ui/grid_layout_test.v b/ui/grid_layout_test.v index 662c028a..a95bd6d9 100644 --- a/ui/grid_layout_test.v +++ b/ui/grid_layout_test.v @@ -312,3 +312,15 @@ fn test_grid_preferred_size_respects_forced_tracks_and_minimums() { }, [rect(0, 0, 200, 100), rect(0, 0, 200, 100), rect(0, 0, 200, 100)]) or { panic(err) } assert preferred == rect(0, 0, 140, 55) } + +fn test_automatic_grid_preserves_fractional_capacity_and_edges() { + frames := grid_layout_frames(GridLayoutConfig{ frame: rect(0, 0, 100.5, 20.5), auto_columns_min_width: 50.25 }, 2)! + assert frames == [rect(0, 0, 50.25, 20.5), rect(50.25, 0, 50.25, 20.5)] + tiny := grid_layout_frames(GridLayoutConfig{ frame: rect(0, 0, 0.75, 0.5), auto_columns_min_width: 0.1, spacing: GridSpacing{ horizontal: 0.5 } }, 2)! + assert tiny == [rect(0, 0, 0.125, 0.5), rect(0.625, 0, 0.125, 0.5)] +} + +fn test_grid_intrinsic_spans_keep_fractional_content_size() { + preferred := grid_layout_preferred_size(GridLayoutConfig{ columns: 2, child_spans: [GridSpan{ column_span: 2 }] }, [rect(0, 0, 3.5, 2.25)])! + assert preferred == rect(0, 0, 3.5, 2.25) +} From 61b1e0fbca0c407e7e22f9052c160b6db0e079b2 Mon Sep 17 00:00:00 2001 From: tomaszbk Date: Sat, 3 Oct 2026 13:07:41 -0300 Subject: [PATCH 5/7] Keep custom backend tests aligned with key event arguments --- ui/ui_custom_test.v | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/ui/ui_custom_test.v b/ui/ui_custom_test.v index 8abce54c..1ef162ad 100644 --- a/ui/ui_custom_test.v +++ b/ui/ui_custom_test.v @@ -47,8 +47,8 @@ $if ui2_custom_rendering ? { g_focused_field = 'field' replace_text_value('field', 'abc') replace_text_editor('field', text_editor('abc'.clone())) - handle_key_down(.left) - handle_key_down(.backspace) + handle_key_down(.left, 0) + handle_key_down(.backspace, 0) handle_char_input(`x`) assert text('field') == 'axc' forget_text_state('field') @@ -573,16 +573,20 @@ $if ui2_custom_rendering ? { } fn test_text_field_selection_text_uses_rune_offsets() { - before, selected := text_field_selection_text('a๐Ÿ™‚bc', TextSelection{ - anchor: 4 - caret: 1 - }) - assert before == 'a' - assert selected == '๐Ÿ™‚bc' + $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { + before, selected := text_field_selection_text('a๐Ÿ™‚bc', TextSelection{ + anchor: 4 + caret: 1 + }) + assert before == 'a' + assert selected == '๐Ÿ™‚bc' + } } fn test_text_field_selection_origin_respects_text_alignment() { - assert text_field_aligned_text_origin(10, 100, 40, .left) == 10 - assert text_field_aligned_text_origin(10, 100, 40, .center) == 40 - assert text_field_aligned_text_origin(10, 100, 40, .right) == 70 + $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { + assert text_field_aligned_text_origin(10, 100, 40, .left) == 10 + assert text_field_aligned_text_origin(10, 100, 40, .center) == 40 + assert text_field_aligned_text_origin(10, 100, 40, .right) == 70 + } } From 10e1bb9b397439c902e37f6ecc5f619c71072b4d Mon Sep 17 00:00:00 2001 From: tomaszbk Date: Sat, 10 Oct 2026 00:14:01 -0300 Subject: [PATCH 6/7] Use shared Flex and Grid APIs with V layout examples --- README.md | 4 + benchmarks/flex_measure/main.v | 150 +++---- docs/modern-layout.md | 64 +++ examples/grid_layout/grid_layout.vml | 33 -- examples/grid_layout/grid_layout_test.v | 3 +- examples/grid_layout/main.v | 39 +- examples/responsive_layout/main.v | 122 ++++- .../responsive_layout/responsive_layout.vml | 96 ---- .../responsive_layout_test.v | 51 +++ ui/accordion.v | 2 +- ui/box_layout.v | 62 +-- ui/box_layout_test.v | 6 +- ui/flex_intrinsic_test.v | 37 ++ ui/flex_layout.v | 107 ++--- ui/flex_layout_test.v | 138 +++--- ui/float_layout.v | 4 +- ui/grid_layout.v | 26 +- ui/grid_layout_test.v | 92 ++-- ui/layout.v | 58 +++ ui/layout_measure.v | 26 +- ui/layout_measure_test.v | 8 +- ui/vml.v | 60 +-- ui/vml_adaptive_test.v | 4 +- ui/vml_flex.v | 215 --------- ui/vml_measure_cache.v | 66 --- ui/vml_measure_cache_test.v | 84 ---- ui/vml_model.v | 136 ++---- ui/vml_model_layout.v | 162 ------- ui/vml_model_test.v | 2 +- ui/vml_modern_layout_test.v | 424 ------------------ ui/vml_test.v | 4 +- 31 files changed, 691 insertions(+), 1594 deletions(-) create mode 100644 docs/modern-layout.md delete mode 100644 examples/grid_layout/grid_layout.vml delete mode 100644 examples/responsive_layout/responsive_layout.vml create mode 100644 examples/responsive_layout/responsive_layout_test.v create mode 100644 ui/flex_intrinsic_test.v create mode 100644 ui/layout.v delete mode 100644 ui/vml_flex.v delete mode 100644 ui/vml_measure_cache.v delete mode 100644 ui/vml_measure_cache_test.v delete mode 100644 ui/vml_model_layout.v delete mode 100644 ui/vml_modern_layout_test.v diff --git a/README.md b/README.md index 38f9220d..b0a81d93 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ and Android. The Linux backend draws and handles widgets directly through `gg`/Sokol; it has no GTK dependency. +The V API includes responsive Flex and Grid placement with intrinsic text +measurement. See the [layout guide](docs/modern-layout.md) and +[responsive example](examples/responsive_layout/main.v). + macOS and Windows use native widgets by default. Pass the compile-time define `-d ui2_custom_rendering` to use the same custom `gg` renderer there instead: diff --git a/benchmarks/flex_measure/main.v b/benchmarks/flex_measure/main.v index c4b2bdb4..193c581c 100644 --- a/benchmarks/flex_measure/main.v +++ b/benchmarks/flex_measure/main.v @@ -1,82 +1,67 @@ -// Times runtime VML constructions that measure intrinsic Flex/Grid sizes: -// the responsive example, a flat wrapping Flex, and nested Flex containers. -// From the repository root: -// v -prod -path "$(dirname "$PWD")|@vlib|@vmodules" -o /tmp/ui2-flex-bench benchmarks/flex_measure/main.v -// /tmp/ui2-flex-bench [name-filter] +// Measures intrinsic text and Flex/Grid placement through the V API. +// Run with the compiler and module path documented in README.md. module main import os import time import ui2 -const responsive_vml = $embed_file('../../examples/responsive_layout/responsive_layout.vml').to_string() const runs = 7 -pub struct Project { -pub: - id int - title string - description string -} - -pub struct BenchApp { -pub mut: - created int - projects []Project -} - -pub fn (mut app BenchApp) create_project() { - app.created++ -} - struct Case { - name string - source string - frame ui2.Rect - model bool + name string + width f64 + depth int + grid bool } -// nested alternates orientations so every level changes the width available -// to its subtree, the shape that made intrinsic measurement repeat work. -fn nested(depth int) string { - mut source := 'Label { text: "content that can wrap at a narrow width" lines: 3 }' - for level in 0 .. depth { - orientation := if level % 2 == 0 { 'horizontal' } else { 'vertical' } - source = 'FlexLayout { orientation: ${orientation} gap: 4\n Label { text: "level ${level}" }\n ${source} }' - } - return 'Screen {\n ${source} }' -} - -fn flat(count int) string { - mut labels := []string{cap: count} - for index in 0 .. count { - labels << 'Label { text: "item ${index}" }' - } - return 'Screen {\n FlexLayout { wrap: true gap: 6\n ${labels.join('\n')} } }' +fn measured_label(text string, width f64) !ui2.Element { + element := ui2.label('', text, ui2.Rect{}, ui2.TextStyle{ lines: 3 }) + size := ui2.measure_layout_element(element, ui2.LayoutConstraints{ max_width: width }, ui2.measure_layout_text)! + return ui2.Element{ ...element, frame: ui2.rect(0, 0, size.width, size.height) } } -fn model() BenchApp { - mut projects := []Project{cap: 12} - for id in 1 .. 13 { - projects << Project{ - id: id - title: 'Project ${id}' - description: 'A description that is long enough to wrap in narrow cards' +fn build(case Case) !int { + if case.grid { + mut children := []ui2.Element{} + for index in 0 .. 12 { + children << measured_label('Project ${index}: a description that wraps in narrow cards', case.width / 3)! } + config := ui2.GridConfig{ + frame: ui2.rect(0, 0, case.width, 780) + auto_columns_min_width: 240 + max_columns: 3 + spacing: ui2.GridSpacing{ horizontal: 16, vertical: 16 } + child_spans: [ui2.GridSpan{ column_span: 2 }] + children: children + } + return ui2.grid(config)!.children.len } - return BenchApp{ - projects: projects + if case.depth == 0 { + mut children := []ui2.FlexChild{} + for index in 0 .. 200 { + children << ui2.FlexChild{ element: measured_label('Item ${index}', case.width)! } + } + return ui2.flex(ui2.FlexConfig{ frame: ui2.rect(0, 0, case.width, 780), gap: 6, wrap: true, children: children })!.children.len } -} - -fn build(case Case, mut app ui2.VmlApp[BenchApp]) !int { - root := if case.model { - app.build(case.frame)! - } else { - ui2.element_from_vml(case.source, - case.frame)! + mut element := measured_label('Content that can wrap at a narrow width', case.width)! + for level in 0 .. case.depth { + config := ui2.FlexConfig{ + orientation: if level % 2 == 0 { + ui2.LayoutOrientation.horizontal + } else { + ui2.LayoutOrientation.vertical + } + gap: 4 + children: [ + ui2.FlexChild{ element: measured_label('Level ${level}', case.width)! }, + ui2.FlexChild{ element: element }, + ] + } + preferred := ui2.flex_preferred_size(config)! + element = ui2.flex(ui2.FlexConfig{ ...config, frame: preferred })! } - return root.children.len + return element.children.len } fn median(values []f64) f64 { @@ -86,45 +71,30 @@ fn median(values []f64) f64 { } fn main() { - wide := ui2.rect(0, 0, 1000, 780) - compact := ui2.rect(0, 0, 390, 780) cases := [ - Case{'responsive wide (model)', responsive_vml, wide, true}, - Case{'responsive compact (model)', responsive_vml, compact, true}, - Case{'flat wrap 200 (static)', flat(200), wide, false}, - Case{'flat wrap 200 (model)', flat(200), wide, true}, - Case{'nested 4 (static)', nested(4), wide, false}, - Case{'nested 4 (model)', nested(4), wide, true}, - Case{'nested 8 (static)', nested(8), wide, false}, - Case{'nested 8 (model)', nested(8), wide, true}, - Case{'nested 10 (static)', nested(10), wide, false}, - Case{'nested 10 (model)', nested(10), wide, true}, + Case{ name: 'grid wide', width: 1000, grid: true }, + Case{ name: 'grid compact', width: 390, grid: true }, + Case{ name: 'flat wrap 200', width: 1000 }, + Case{ name: 'nested 4', width: 1000, depth: 4 }, + Case{ name: 'nested 8', width: 1000, depth: 8 }, + Case{ name: 'nested 10', width: 1000, depth: 10 }, ] filter := if os.args.len > 1 { os.args[1] } else { '' } mut checksum := 0 for case in cases { - if filter.len > 0 && !case.name.contains(filter) { - continue - } - mut app := ui2.new_vml_app(case.source, model())! - checksum += build(case, mut app)! // warm fonts and caches outside timing + if filter.len > 0 && !case.name.contains(filter) { continue } + checksum += build(case)! mut iterations := 1 for { - mut watch := time.new_stopwatch() - for _ in 0 .. iterations { - checksum += build(case, mut app)! - } - if watch.elapsed().milliseconds() >= 100 || iterations >= 100_000 { - break - } + watch := time.new_stopwatch() + for _ in 0 .. iterations { checksum += build(case)! } + if watch.elapsed().milliseconds() >= 100 || iterations >= 100_000 { break } iterations *= 2 } mut samples := []f64{cap: runs} for _ in 0 .. runs { - mut watch := time.new_stopwatch() - for _ in 0 .. iterations { - checksum += build(case, mut app)! - } + watch := time.new_stopwatch() + for _ in 0 .. iterations { checksum += build(case)! } samples << f64(watch.elapsed().microseconds()) / f64(iterations) } println('${case.name:-28} ${median(samples):12.1f} us/build') diff --git a/docs/modern-layout.md b/docs/modern-layout.md new file mode 100644 index 00000000..4085fdf5 --- /dev/null +++ b/docs/modern-layout.md @@ -0,0 +1,64 @@ +# Flex and Grid + +The V layout API separates measurement from placement. `measure_layout_element` +measures text and content under `LayoutConstraints`; `flex_frames` and +`grid_frames` assign child frames. `flex` and `grid` return an `Element` with +those frames applied. Dimensions and spacing stay fractional through these steps. + +## Flex + +`FlexConfig` arranges `FlexChild` values horizontally or vertically. A child's +`basis` supplies its preferred main-axis size, `grow` shares extra space, and +`shrink` shares a deficit in proportion to its basis. Minima and maxima constrain +the result. Wrapping creates separate lines with `line_gap` between them. + +```v +children := [ + ui2.FlexChild{ element: heading, grow: 1, minimum_width: 170 }, + ui2.FlexChild{ element: action, shrink: 0 }, +] +toolbar := ui2.flex(ui2.FlexConfig{ + frame: ui2.rect(0, 0, available_width, 40) + gap: 12 + align: .center + children: children +})! +``` + +`LayoutOrientation`, `LayoutAlignment`, and `LayoutPadding` provide shared +orientation, alignment, and padding values. `FlexChild.align_self` overrides the +container's alignment for one child; `.auto` inherits the container's alignment. + +Measure a width-dependent child again after calculating its assigned width. +For example, a wrapped label can need more height when its Flex sibling grows. +Use `measure_layout_element` with that width and then place the measured child. +The measurement callback receives the declared `TextStyle`. + +## Grid + +`GridConfig` supports fixed rows or columns, track minima, spacing and padding. +With `auto_columns_min_width`, it selects the number of columns that fit the +available width. `max_columns` caps that count. A `GridSpan` can reserve adjacent +rows or columns for a child; automatic grids clamp column spans when the viewport +becomes narrower. + +```v +cards := ui2.grid(ui2.GridConfig{ + frame: ui2.rect(0, 0, available_width, available_height) + auto_columns_min_width: 240 + max_columns: 3 + spacing: ui2.GridSpacing{ horizontal: 16, vertical: 16 } + child_spans: [ui2.GridSpan{ column_span: 2 }] + children: project_cards +})! +``` + +`flex_preferred_size` and `grid_preferred_size` report natural sizes from measured +children. They calculate geometry without constructing a view or invoking actions. +Explicit child frames provide preferences; the parent assigns the final frames. + +The [responsive example](../examples/responsive_layout/main.v) combines wrapped +toolbar rows, a search control, and a spanning project grid. The +[grid example](../examples/grid_layout/main.v) demonstrates equal cells. The +[measurement benchmark](../benchmarks/flex_measure/main.v) exercises text +measurement, wrapping, nested Flex containers and responsive Grid placement. diff --git a/examples/grid_layout/grid_layout.vml b/examples/grid_layout/grid_layout.vml deleted file mode 100644 index bb948519..00000000 --- a/examples/grid_layout/grid_layout.vml +++ /dev/null @@ -1,33 +0,0 @@ -Screen { - id: root - background: #F1F5F9 - - Label { - text: "Dashboard" - x: 20 - y: 16 - width: root.width - 40 - height: 32 - color: #0F172A - font_size: 22 - bold: true - } - - GridLayout { - id: dashboard - x: 20 - y: 60 - width: root.width - 40 - height: root.height - 80 - columns: 3 - padding: 8 - spacing: 8 - - Button { text: "Overview" background: #DBEAFE corner_radius: 8 } - Button { text: "Activity" background: #DCFCE7 corner_radius: 8 } - Button { text: "Messages" background: #FCE7F3 corner_radius: 8 } - Button { text: "Files" background: #FEF3C7 corner_radius: 8 } - Button { text: "Calendar" background: #EDE9FE corner_radius: 8 } - Button { text: "Settings" background: #E2E8F0 corner_radius: 8 } - } -} diff --git a/examples/grid_layout/grid_layout_test.v b/examples/grid_layout/grid_layout_test.v index bc906ad6..d2122c09 100644 --- a/examples/grid_layout/grid_layout_test.v +++ b/examples/grid_layout/grid_layout_test.v @@ -15,8 +15,7 @@ fn find_grid_element(element ui2.Element, id string) ?ui2.Element { } fn test_grid_layout_demo_uses_responsive_equal_cells() { - root := ui2.element_from_vml_model(grid_vml_source, GridLayoutDemo{}, ui2.rect(0, 0, - grid_width, grid_height)) or { panic(err) } + root := grid_tree(ui2.rect(0, 0, grid_width, grid_height)) or { panic(err) } grid := find_grid_element(root, 'dashboard') or { panic('missing dashboard grid') } assert grid.children.len == 6 assert grid.children[0].frame == ui2.rect(8, 8, 96, 78) diff --git a/examples/grid_layout/main.v b/examples/grid_layout/main.v index 23aa5d08..ecfd3a0f 100644 --- a/examples/grid_layout/main.v +++ b/examples/grid_layout/main.v @@ -4,16 +4,37 @@ import ui2 const grid_width = 360 const grid_height = 260 -const grid_vml_source = $embed_file('grid_layout.vml').to_string() -pub struct GridLayoutDemo {} +fn grid_tree(frame ui2.Rect) !ui2.Element { + captions := ['Overview', 'Activity', 'Messages', 'Files', 'Calendar', 'Settings'] + colors := [u32(0xdbeafe), u32(0xdcfce7), u32(0xfce7f3), u32(0xfef3c7), u32(0xede9fe), + u32(0xe2e8f0)] + mut children := []ui2.Element{} + for index, caption in captions { + children << ui2.button('dashboard-${index}', caption, ui2.Rect{}, + ui2.BoxStyle{ bg: colors[index], radius: 8 }, ui2.TextStyle{}) + } + dashboard := ui2.grid(ui2.GridConfig{ + id: 'dashboard' + frame: ui2.rect(20, 60, frame.width - 40, frame.height - 80) + columns: 3 + padding: ui2.GridPadding{ left: 8, right: 8, top: 8, bottom: 8 } + spacing: ui2.GridSpacing{ horizontal: 8, vertical: 8 } + children: children + })! + return ui2.screen(0xf1f5f9, [ + ui2.label('heading', 'Dashboard', ui2.rect(20, 16, frame.width - 40, 32), + ui2.TextStyle{ color: 0x0f172a, size: 22, bold: true }), + dashboard, + ]) +} + +fn build() ui2.Element { + return grid_tree(ui2.bounds()) or { panic(err) } +} + +fn event(_id string) {} fn main() { - ui2.run_vml[GridLayoutDemo]( - source: grid_vml_source - model: GridLayoutDemo{} - title: 'Grid Layout' - width: grid_width - height: grid_height - ) or { panic(err) } + ui2.run_window('Grid layout', grid_width, grid_height, build, event) } diff --git a/examples/responsive_layout/main.v b/examples/responsive_layout/main.v index 1487c3d0..e58bd678 100644 --- a/examples/responsive_layout/main.v +++ b/examples/responsive_layout/main.v @@ -1,10 +1,10 @@ +@[has_globals] module main +import math import os import ui2 -const responsive_source = $embed_file('responsive_layout.vml').to_string() - pub struct Project { pub: id int @@ -21,16 +21,114 @@ pub mut: ] } -pub fn (mut app ResponsiveApp) create_project() { - app.created++ -} +__global responsive_app = ResponsiveApp{} fn main() { - ui2.run_vml[ResponsiveApp]( - source: responsive_source - model: ResponsiveApp{} - title: 'UI2 ยท Responsive layout' - width: if '--compact' in os.args { 390 } else { 1000 } - height: 780 - ) or { panic(err) } + width := if '--compact' in os.args { 390 } else { 1000 } + ui2.run_window('UI2 ยท Responsive layout', width, 780, build, event) +} + +fn build() ui2.Element { + return responsive_tree(responsive_app, ui2.bounds()) or { panic(err) } +} + +fn event(id string) { + if id in ['create', 'create-featured'] { + responsive_app.created++ + ui2.refresh() + } +} + +fn measured_label(id string, text string, width f64, style ui2.TextStyle) !ui2.Element { + element := ui2.label(id, text, ui2.Rect{}, style) + size := ui2.measure_layout_element(element, ui2.LayoutConstraints{ max_width: width }, ui2.measure_layout_text)! + return ui2.Element{ ...element, frame: ui2.rect(0, 0, size.width, size.height) } +} + +// Wrapping assigns each line its natural height before the container is placed. +fn row(id string, width f64, children []ui2.FlexChild) !ui2.Element { + config := ui2.FlexConfig{ + id: id + frame: ui2.rect(0, 0, width, 0) + gap: 12 + line_gap: 12 + wrap: true + align: .center + children: children + } + frames := ui2.flex_frames(config)! + mut height := 0.0 + for frame in frames { + height = math.max(height, frame.y + frame.height) + } + return ui2.flex(ui2.FlexConfig{ ...config, frame: ui2.rect(0, 0, width, height) })! +} + +fn project_card(id string, title string, description string, size ui2.Rect, featured bool) !ui2.Element { + color := if featured { u32(0xffffff) } else { u32(0x17243d) } + title_label := measured_label(id + '-title', title, math.max(0.0, size.width - 40), + ui2.TextStyle{ size: 20, color: color, lines: 2 })! + description_label := measured_label(id + '-description', description, + math.max(0.0, size.width - 40), ui2.TextStyle{ size: 14, color: color, lines: 3 })! + return ui2.flex(ui2.FlexConfig{ + id: id + frame: ui2.rect(0, 0, size.width, size.height) + box: ui2.BoxStyle{ bg: if featured { u32(0x17243d) } else { u32(0xffffff) }, radius: 14 } + orientation: .vertical + padding: ui2.LayoutPadding{ left: 20, right: 20, top: 20, bottom: 20 } + gap: 10 + children: [ + ui2.FlexChild{ element: title_label, shrink: 0 }, + ui2.FlexChild{ element: description_label, shrink: 0 }, + ui2.FlexChild{ element: ui2.view(id + '-space', ui2.Rect{}, ui2.BoxStyle{ transparent: true }, []ui2.Element{}), grow: 1 }, + ui2.FlexChild{ + element: ui2.button(if featured { 'create-featured' } else { id + '-open' }, + if featured { 'Create project' } else { 'Open project' }, ui2.rect(0, 0, 120, 36), + ui2.BoxStyle{ bg: 0xe9effd, radius: 8 }, ui2.TextStyle{ color: 0x244eca }) + shrink: 0 + }, + ] + })! +} + +fn responsive_tree(app ResponsiveApp, frame ui2.Rect) !ui2.Element { + width := math.max(0.0, frame.width - 48) + height := math.max(740.0, frame.height) + toolbar := row('toolbar', width, [ + ui2.FlexChild{ element: ui2.label('heading', 'UI2 Studio', ui2.rect(0, 0, 170, 40), ui2.TextStyle{ size: 26, bold: true, color: 0x17243d }), grow: 1, minimum_width: 170 }, + ui2.FlexChild{ element: ui2.button('create', 'New project', ui2.rect(0, 0, 112, 40), ui2.BoxStyle{ bg: 0x315eeb, radius: 8 }, ui2.TextStyle{ color: 0xffffff }) }, + ui2.FlexChild{ element: ui2.button('settings', 'Settings', ui2.rect(0, 0, 98, 40), ui2.BoxStyle{ bg: 0xffffff, radius: 8 }, ui2.TextStyle{ color: 0x17243d }) }, + ])! + searchbar := row('searchbar', width, [ + ui2.FlexChild{ element: ui2.text_field('search', '', 'Search projects', ui2.rect(0, 0, 220, 38), ui2.BoxStyle{ bg: 0xffffff, radius: 8 }, ui2.TextStyle{}, 0), grow: 1, minimum_width: 150 }, + ui2.FlexChild{ element: measured_label('count', 'Created: ${app.created}', width, ui2.TextStyle{ color: 0x52627d })! }, + ])! + config := ui2.GridConfig{ + id: 'projects' + frame: ui2.rect(0, 0, width, math.max(0.0, height - 48 - toolbar.frame.height - searchbar.frame.height - 80)) + auto_columns_min_width: 240 + max_columns: 3 + spacing: ui2.GridSpacing{ horizontal: 16, vertical: 16 } + child_spans: [ui2.GridSpan{ column_span: 2 }] + } + cells := ui2.grid_frames(config, app.projects.len + 1)! + mut cards := [project_card('featured', 'A layout that adapts', 'Flex, grid and content sizing.', cells[0], true)!] + for index, project in app.projects { + cards << project_card('project-${project.id}', project.title, project.description, cells[index + 1], false)! + } + projects := ui2.grid(ui2.GridConfig{ ...config, children: cards })! + page := ui2.flex(ui2.FlexConfig{ + id: 'page' + frame: ui2.rect(0, 0, frame.width, height) + orientation: .vertical + padding: ui2.LayoutPadding{ left: 24, right: 24, top: 24, bottom: 24 } + gap: 20 + children: [ + ui2.FlexChild{ element: toolbar, shrink: 0 }, + ui2.FlexChild{ element: searchbar, shrink: 0 }, + ui2.FlexChild{ element: projects, grow: 1 }, + ui2.FlexChild{ element: ui2.label('instructions', 'Resize the window. Your search stays in place.', ui2.rect(0, 0, width, 20), ui2.TextStyle{ size: 12, color: 0x52627d }), shrink: 0 }, + ] + })! + return ui2.screen(0xf2f5fa, [ui2.scroll('viewport', frame, 0xf2f5fa, [page])]) } diff --git a/examples/responsive_layout/responsive_layout.vml b/examples/responsive_layout/responsive_layout.vml deleted file mode 100644 index 7dd94c9c..00000000 --- a/examples/responsive_layout/responsive_layout.vml +++ /dev/null @@ -1,96 +0,0 @@ -Screen { - id: root - background: #F2F5FA - - ScrollView { - id: viewport - width: root.width - height: root.height - background: #F2F5FA - - FlexLayout { - id: page - width: root.width - height: root.height < 740 ? 740 : root.height - orientation: vertical - padding: 24 - gap: 20 - background: #F2F5FA - - FlexLayout { - id: toolbar - gap: 12 - line_gap: 12 - wrap: true - align_items: center - transparent: true - - Label { text: "UI2 Studio" font_size: 26 bold: true color: #17243D flex_grow: 1 min_width: 170 } - Button { id: create text: "New project" on_tap: app.create_project() background: #315EEB color: #FFFFFF corner_radius: 8 } - Button { id: settings text: "Settings" background: #FFFFFF color: #17243D corner_radius: 8 } - } - - FlexLayout { - id: searchbar - gap: 12 - wrap: true - align_items: center - transparent: true - - TextField { id: search placeholder: "Search projects" width: 220 height: 38 flex_grow: 1 min_width: 150 background: #FFFFFF corner_radius: 8 } - Label { id: count text: "Created: " + app.created color: #52627D } - } - - GridLayout { - id: projects - flex_grow: 1 - auto_columns_min_width: 240 - max_columns: 3 - spacing: 16 - transparent: true - - FlexLayout { - id: featured - column_span: 2 - orientation: vertical - padding: 20 - gap: 10 - background: #17243D - corner_radius: 14 - - Label { text: "A layout that adapts" font_size: 23 bold: true color: #FFFFFF height: 30 } - Label { text: "Flex, grid and content sizing." color: #C8D7F3 height: 24 } - View { flex_grow: 1 transparent: true } - FlexLayout { - justify: end - transparent: true - Button { text: "Create project" on_tap: app.create_project() background: #FFFFFF color: #17243D corner_radius: 8 } - } - } - - Repeater { - model: app.projects - key: item.id - FlexLayout { - orientation: vertical - padding: 20 - gap: 10 - background: #FFFFFF - corner_radius: 14 - - Label { text: item.title font_size: 20 bold: true color: #17243D height: 26 } - Label { text: item.description color: #52627D height: 24 } - View { flex_grow: 1 transparent: true } - FlexLayout { - justify: end - transparent: true - Button { text: "Open project" background: #E9EFFD color: #244ECA corner_radius: 8 } - } - } - } - } - - Label { text: "Resize the window. Your search stays in place." color: #52627D font_size: 12 height: 20 } - } - } -} diff --git a/examples/responsive_layout/responsive_layout_test.v b/examples/responsive_layout/responsive_layout_test.v new file mode 100644 index 00000000..18936083 --- /dev/null +++ b/examples/responsive_layout/responsive_layout_test.v @@ -0,0 +1,51 @@ +module main + +import math +import ui2 + +fn find_responsive(element ui2.Element, id string) ?ui2.Element { + if element.id == id { return element } + for child in element.children { + if found := find_responsive(child, id) { return found } + } + return none +} + +fn test_responsive_grid_changes_column_count_and_clamps_featured_span() { + app := ResponsiveApp{} + wide := responsive_tree(app, ui2.rect(0, 0, 1000, 780))! + compact := responsive_tree(app, ui2.rect(0, 0, 390, 780))! + ui2.validate_element_tree(wide)! + ui2.validate_element_tree(compact)! + wide_grid := find_responsive(wide, 'projects') or { panic('missing grid') } + compact_grid := find_responsive(compact, 'projects') or { panic('missing compact grid') } + assert wide_grid.frame.width == 952 + assert compact_grid.frame.width == 342 + assert wide_grid.children.len == 3 && compact_grid.children.len == 3 + // Three 306 2/3-unit columns with 16-unit gaps: the featured card spans two. + assert math.abs(wide_grid.children[0].frame.width - 629.3333333333334) < 0.0001 + assert math.abs(wide_grid.children[1].frame.x - 645.3333333333334) < 0.0001 + assert compact_grid.children[0].frame.width == 342 + assert compact_grid.children[1].frame.x == 0 + assert compact_grid.children[1].frame.y > compact_grid.children[0].frame.y + wide_toolbar := find_responsive(wide, 'toolbar') or { panic('missing toolbar') } + compact_toolbar := find_responsive(compact, 'toolbar') or { panic('missing toolbar') } + assert wide_toolbar.frame.height == 40 + assert compact_toolbar.frame.height == 92 + assert app.created == 0 +} + +fn test_responsive_build_keeps_control_identity_and_reads_updated_model() { + mut app := ResponsiveApp{} + first := responsive_tree(app, ui2.rect(0, 0, 1000.5, 780))! + app.created = 2 + second := responsive_tree(app, ui2.rect(0, 0, 390.25, 780))! + for id in ['search', 'create', 'create-featured', 'project-1', 'project-2'] { + assert (find_responsive(first, id) or { panic('missing ${id}') }).id == id + assert (find_responsive(second, id) or { panic('missing ${id}') }).id == id + } + count := find_responsive(second, 'count') or { panic('missing count') } + assert count.text == 'Created: 2' + grid := find_responsive(second, 'projects') or { panic('missing grid') } + assert grid.frame.width == 342.25 +} diff --git a/ui/accordion.v b/ui/accordion.v index d7539875..0b05468d 100644 --- a/ui/accordion.v +++ b/ui/accordion.v @@ -15,7 +15,7 @@ pub: frame Rect box BoxStyle current int - orientation BoxOrientation + orientation LayoutOrientation min_space f64 = 44.0 header_box BoxStyle active_header_box BoxStyle diff --git a/ui/box_layout.v b/ui/box_layout.v index 096c49ab..6377273b 100644 --- a/ui/box_layout.v +++ b/ui/box_layout.v @@ -1,24 +1,11 @@ module ui2 -pub enum BoxOrientation { - horizontal - vertical -} - pub enum BoxAlignment { start center end } -pub struct BoxPadding { -pub: - left f64 - top f64 - right f64 - bottom f64 -} - // BoxLayoutChild combines an element with the sizing hints used by its parent. // A negative size hint preserves the element's declared size on that axis. pub struct BoxLayoutChild { @@ -39,22 +26,12 @@ pub: id string frame Rect box BoxStyle - orientation BoxOrientation - padding BoxPadding + orientation LayoutOrientation + padding LayoutPadding spacing f64 children []BoxLayoutChild } -pub fn box_orientation(value string) !BoxOrientation { - return match value { - '', 'horizontal' { .horizontal } - 'vertical' { .vertical } - else { - return error('unknown box orientation `${value}`') - } - } -} - pub fn box_alignment(value string) !BoxAlignment { return match value { '', 'start', 'left', 'top' { .start } @@ -66,21 +43,6 @@ pub fn box_alignment(value string) !BoxAlignment { } } -fn box_bound(value f64, minimum f64, maximum f64) f64 { - mut bounded := if value < 0 { 0.0 } else { value } - if minimum >= 0 && bounded < minimum { - bounded = minimum - } - if maximum >= 0 && bounded > maximum { - bounded = maximum - } - return bounded -} - -fn box_max(left f64, right f64) f64 { - return if left > right { left } else { right } -} - fn box_validate(config BoxLayoutConfig) ! { if config.frame.width < 0 || config.frame.height < 0 { return error('box layout dimensions cannot be negative') @@ -116,7 +78,7 @@ fn box_axis_sizes(available f64, declared []f64, hints []f64, minimums []f64, ma mut remaining := available for index, declared_size in declared { if hints[index] < 0 { - sizes[index] = box_bound(declared_size, minimums[index], maximums[index]) + sizes[index] = layout_bound(declared_size, minimums[index], maximums[index]) remaining -= sizes[index] } else { flexible[index] = true @@ -143,7 +105,7 @@ fn box_axis_sizes(available f64, declared []f64, hints []f64, minimums []f64, ma continue } candidate := if weight > 0 { remaining * hints[index] / weight } else { 0.0 } - bounded := box_bound(candidate, minimums[index], maximums[index]) + bounded := layout_bound(candidate, minimums[index], maximums[index]) if bounded != candidate { sizes[index] = bounded remaining -= bounded @@ -217,7 +179,7 @@ pub fn box_layout_frames(config BoxLayoutConfig) ![]Rect { for index, child in config.children { if config.orientation == .horizontal { cross_available := config.frame.height - config.padding.top - config.padding.bottom - cross_size := box_bound(if child.size_hint_y < 0 { + cross_size := layout_bound(if child.size_hint_y < 0 { child.element.frame.height } else { cross_available * child.size_hint_y @@ -226,7 +188,7 @@ pub fn box_layout_frames(config BoxLayoutConfig) ![]Rect { frames << rect(cursor, y, main_sizes[index], cross_size) } else { cross_available := config.frame.width - config.padding.left - config.padding.right - cross_size := box_bound(if child.size_hint_x < 0 { + cross_size := layout_bound(if child.size_hint_x < 0 { child.element.frame.width } else { cross_available * child.size_hint_x @@ -253,14 +215,14 @@ pub fn box_layout_minimum_size(config BoxLayoutConfig) !Rect { for child in config.children { if config.orientation == .horizontal { main += if child.size_hint_x < 0 { - box_bound(child.element.frame.width, child.minimum_width, child.maximum_width) + layout_bound(child.element.frame.width, child.minimum_width, child.maximum_width) } else if child.minimum_width >= 0 { child.minimum_width } else { 0 } - cross = box_max(cross, if child.size_hint_y < 0 { - box_bound(child.element.frame.height, child.minimum_height, child.maximum_height) + cross = layout_max(cross, if child.size_hint_y < 0 { + layout_bound(child.element.frame.height, child.minimum_height, child.maximum_height) } else if child.minimum_height >= 0 { child.minimum_height } else { @@ -268,14 +230,14 @@ pub fn box_layout_minimum_size(config BoxLayoutConfig) !Rect { }) } else { main += if child.size_hint_y < 0 { - box_bound(child.element.frame.height, child.minimum_height, child.maximum_height) + layout_bound(child.element.frame.height, child.minimum_height, child.maximum_height) } else if child.minimum_height >= 0 { child.minimum_height } else { 0 } - cross = box_max(cross, if child.size_hint_x < 0 { - box_bound(child.element.frame.width, child.minimum_width, child.maximum_width) + cross = layout_max(cross, if child.size_hint_x < 0 { + layout_bound(child.element.frame.width, child.minimum_width, child.maximum_width) } else if child.minimum_width >= 0 { child.minimum_width } else { diff --git a/ui/box_layout_test.v b/ui/box_layout_test.v index a7673bf8..cd490a1d 100644 --- a/ui/box_layout_test.v +++ b/ui/box_layout_test.v @@ -7,7 +7,7 @@ fn box_test_child(id string, width f64, height f64) Element { fn test_box_layout_distributes_space_after_fixed_children() { config := BoxLayoutConfig{ frame: rect(0, 0, 330, 80) - padding: BoxPadding{ left: 10, top: 5, right: 10, bottom: 5 } + padding: LayoutPadding{ left: 10, top: 5, right: 10, bottom: 5 } spacing: 10 children: [ BoxLayoutChild{ element: box_test_child('fixed', 80, 20), size_hint_x: -1 }, @@ -36,7 +36,7 @@ fn test_vertical_box_layout_supports_fixed_cross_size_and_alignment() { config := BoxLayoutConfig{ frame: rect(0, 0, 200, 120) orientation: .vertical - padding: BoxPadding{ left: 10, top: 10, right: 10, bottom: 10 } + padding: LayoutPadding{ left: 10, top: 10, right: 10, bottom: 10 } spacing: 4 children: [ BoxLayoutChild{ @@ -55,7 +55,7 @@ fn test_vertical_box_layout_supports_fixed_cross_size_and_alignment() { fn test_box_layout_reports_minimum_size_and_preserves_content() { config := BoxLayoutConfig{ frame: rect(0, 0, 200, 80) - padding: BoxPadding{ left: 5, top: 6, right: 7, bottom: 8 } + padding: LayoutPadding{ left: 5, top: 6, right: 7, bottom: 8 } spacing: 4 children: [ BoxLayoutChild{ diff --git a/ui/flex_intrinsic_test.v b/ui/flex_intrinsic_test.v new file mode 100644 index 00000000..c37820db --- /dev/null +++ b/ui/flex_intrinsic_test.v @@ -0,0 +1,37 @@ +module ui2 + +fn flex_intrinsic_fixture_measure(_text string, _style TextStyle, width f64) !LayoutSize { + return if width < 0 { + LayoutSize{ width: 120, height: 20 } + } else { + LayoutSize{ width: width, height: if width < 60 { 40.0 } else { 20.0 } } + } +} + +fn test_flex_intrinsic_children_are_remeasured_at_assigned_width() { + label := label('description', 'wraps at narrow widths', Rect{}, TextStyle{ lines: 3 }) + preferred := measure_layout_element(label, LayoutConstraints{}, flex_intrinsic_fixture_measure)! + config := FlexConfig{ + frame: rect(0, 0, 100, 50) + gap: 10 + align: .start + children: [ + FlexChild{ element: Element{ ...label, frame: rect(0, 0, preferred.width, preferred.height) }, basis: 0, grow: 1 }, + FlexChild{ element: view('peer', rect(0, 0, 100, 20), BoxStyle{}, []Element{}), basis: 0, grow: 1 }, + ] + } + assigned := flex_frames(config)! + assert assigned[0].width == 45 && assigned[1].x == 55 + measured := measure_layout_element(label, LayoutConstraints{ max_width: assigned[0].width }, flex_intrinsic_fixture_measure)! + assert measured.width == 45 && measured.height == 40 + root := flex(FlexConfig{ + ...config + children: [ + FlexChild{ ...config.children[0], element: Element{ ...label, frame: rect(0, 0, measured.width, measured.height) } }, + config.children[1], + ] + })! + assert root.children[0].frame == rect(0, 0, 45, 40) + assert root.children[1].frame == rect(55, 0, 45, 20) + assert root.children[0].id == 'description' && root.children[0].text == label.text +} diff --git a/ui/flex_layout.v b/ui/flex_layout.v index 18107832..1bb84011 100644 --- a/ui/flex_layout.v +++ b/ui/flex_layout.v @@ -1,15 +1,5 @@ module ui2 -import math - -pub enum FlexAlignment { - auto - start - center - end - stretch -} - pub enum FlexJustify { start center @@ -19,10 +9,10 @@ pub enum FlexJustify { space_evenly } -// FlexLayoutChild supplies preferred main-axis size and its allowed bounds. +// FlexChild supplies preferred main-axis size and its allowed bounds. // A basis of -1 uses the element's frame size. Grow shares extra space; shrink // shares a deficit in proportion to shrink * basis. Explicit minima may overflow. -pub struct FlexLayoutChild { +pub struct FlexChild { pub: element Element basis f64 = -1 @@ -30,35 +20,24 @@ pub: shrink f64 = 1 minimum_width f64 minimum_height f64 - maximum_width f64 = -1 - maximum_height f64 = -1 - align_self FlexAlignment = .auto + maximum_width f64 = -1 + maximum_height f64 = -1 + align_self LayoutAlignment = .auto } -pub struct FlexLayoutConfig { +pub struct FlexConfig { pub: id string frame Rect box BoxStyle - orientation BoxOrientation - padding BoxPadding + orientation LayoutOrientation + padding LayoutPadding gap f64 line_gap f64 = -1 wrap bool justify FlexJustify - align FlexAlignment = .stretch - children []FlexLayoutChild -} - -pub fn flex_alignment(value string) !FlexAlignment { - return match value { - '', 'auto' { .auto } - 'start' { .start } - 'center' { .center } - 'end' { .end } - 'stretch' { .stretch } - else { return error('unknown flex alignment `${value}`') } - } + align LayoutAlignment = .stretch + children []FlexChild } pub fn flex_justify(value string) !FlexJustify { @@ -73,18 +52,14 @@ pub fn flex_justify(value string) !FlexJustify { } } -fn flex_finite(value f64) bool { - return !math.is_nan(value) && !math.is_inf(value, 0) -} - -fn flex_validate(config FlexLayoutConfig) ! { +fn flex_validate(config FlexConfig) ! { if config.align == .auto { return error('flex layout alignment must be start, center, end, or stretch') } for value in [config.frame.x, config.frame.y, config.frame.width, config.frame.height, config.padding.left, config.padding.top, config.padding.right, config.padding.bottom, config.gap, config.line_gap] { - if !flex_finite(value) { + if !layout_finite(value) { return error('flex layout dimensions and spacing must be finite') } } @@ -104,7 +79,7 @@ fn flex_validate(config FlexLayoutConfig) ! { for value in [child.element.frame.x, child.element.frame.y, child.element.frame.width, child.element.frame.height, child.basis, child.grow, child.shrink, child.minimum_width, child.minimum_height, child.maximum_width, child.maximum_height] { - if !flex_finite(value) { + if !layout_finite(value) { return error('flex child sizes, bounds, and weights must be finite') } } @@ -127,17 +102,17 @@ fn flex_validate(config FlexLayoutConfig) ! { return error('flex child minimum height cannot exceed its maximum height') } // Reject arithmetic overflow as well as explicitly non-finite inputs. - total_width += box_max(child.element.frame.width, box_max(child.minimum_width, - box_max(child.maximum_width, child.basis))) + config.gap - total_height += box_max(child.element.frame.height, box_max(child.minimum_height, - box_max(child.maximum_height, child.basis))) + config.gap + total_width += layout_max(child.element.frame.width, layout_max(child.minimum_width, + layout_max(child.maximum_width, child.basis))) + config.gap + total_height += layout_max(child.element.frame.height, layout_max(child.minimum_height, + layout_max(child.maximum_height, child.basis))) + config.gap } - if !flex_finite(total_width) || !flex_finite(total_height) { + if !layout_finite(total_width) || !layout_finite(total_height) { return error('flex layout total size exceeds the supported range') } } -fn flex_main_bounds(child FlexLayoutChild, orientation BoxOrientation) (f64, f64) { +fn flex_main_bounds(child FlexChild, orientation LayoutOrientation) (f64, f64) { return if orientation == .horizontal { child.minimum_width, child.maximum_width } else { @@ -145,7 +120,7 @@ fn flex_main_bounds(child FlexLayoutChild, orientation BoxOrientation) (f64, f64 } } -fn flex_basis(child FlexLayoutChild, orientation BoxOrientation) f64 { +fn flex_basis(child FlexChild, orientation LayoutOrientation) f64 { preferred := if child.basis >= 0 { child.basis } else if orientation == .horizontal { @@ -154,15 +129,15 @@ fn flex_basis(child FlexLayoutChild, orientation BoxOrientation) f64 { child.element.frame.height } minimum, maximum := flex_main_bounds(child, orientation) - return box_bound(preferred, minimum, maximum) + return layout_bound(preferred, minimum, maximum) } -fn flex_cross_size(child FlexLayoutChild, orientation BoxOrientation, stretch f64) f64 { +fn flex_cross_size(child FlexChild, orientation LayoutOrientation, stretch f64) f64 { if orientation == .horizontal { - return box_bound(if stretch >= 0 { stretch } else { child.element.frame.height }, + return layout_bound(if stretch >= 0 { stretch } else { child.element.frame.height }, child.minimum_height, child.maximum_height) } - return box_bound(if stretch >= 0 { stretch } else { child.element.frame.width }, + return layout_bound(if stretch >= 0 { stretch } else { child.element.frame.width }, child.minimum_width, child.maximum_width) } @@ -172,7 +147,7 @@ struct FlexLine { cross f64 } -fn flex_lines(config FlexLayoutConfig, available f64) []FlexLine { +fn flex_lines(config FlexConfig, available f64) []FlexLine { mut lines := []FlexLine{} mut start := 0 mut used := f64(0) @@ -189,7 +164,7 @@ fn flex_lines(config FlexLayoutConfig, available f64) []FlexLine { used += config.gap } used += basis - cross = box_max(cross, flex_cross_size(child, config.orientation, -1)) + cross = layout_max(cross, flex_cross_size(child, config.orientation, -1)) } if start < config.children.len { lines << FlexLine{start, config.children.len, cross} @@ -197,7 +172,7 @@ fn flex_lines(config FlexLayoutConfig, available f64) []FlexLine { return lines } -fn flex_line_sizes(config FlexLayoutConfig, line FlexLine, available f64) []f64 { +fn flex_line_sizes(config FlexConfig, line FlexLine, available f64) []f64 { count := line.end - line.start mut bases := []f64{cap: count} mut sizes := []f64{cap: count} @@ -227,8 +202,8 @@ fn flex_line_sizes(config FlexLayoutConfig, line FlexLine, available f64) []f64 if (growing && child.grow > 0 && (maximum < 0 || sizes[offset] < maximum)) || (!growing && child.shrink > 0 && bases[offset] > 0 && sizes[offset] > minimum) { active[offset] = true - max_weight = box_max(max_weight, if growing { child.grow } else { child.shrink }) - max_basis = box_max(max_basis, bases[offset]) + max_weight = layout_max(max_weight, if growing { child.grow } else { child.shrink }) + max_basis = layout_max(max_basis, bases[offset]) } } if max_weight == 0 { @@ -257,7 +232,7 @@ fn flex_line_sizes(config FlexLayoutConfig, line FlexLine, available f64) []f64 minimum, maximum := flex_main_bounds(config.children[line.start + offset], config.orientation) candidate := sizes[offset] + remaining * (weights[offset] / total_weight) - sizes[offset] = box_bound(candidate, minimum, maximum) + sizes[offset] = layout_bound(candidate, minimum, maximum) limited = limited || sizes[offset] != candidate } } @@ -269,7 +244,7 @@ fn flex_line_sizes(config FlexLayoutConfig, line FlexLine, available f64) []f64 } fn flex_justify_space(justify FlexJustify, free_space f64, count int, gap f64) (f64, f64) { - remaining := box_max(0, free_space) + remaining := layout_max(0, free_space) return match justify { .start { 0.0, gap } .center { remaining / 2, gap } @@ -280,12 +255,12 @@ fn flex_justify_space(justify FlexJustify, free_space f64, count int, gap f64) ( } } -// flex_layout_frames returns local child frames in declaration order. Wrapping +// flex_frames returns local child frames in declaration order. Wrapping // uses bounded preferred bases before distributing each line's extra space. // Wrapped lines keep their preferred cross size and are separated by line_gap. // An unwrapped line occupies the available cross size. Small viewports clamp // available size to zero, while explicit minima and non-shrinking items overflow. -pub fn flex_layout_frames(config FlexLayoutConfig) ![]Rect { +pub fn flex_frames(config FlexConfig) ![]Rect { flex_validate(config)! main_start := if config.orientation == .horizontal { config.padding.left @@ -297,12 +272,12 @@ pub fn flex_layout_frames(config FlexLayoutConfig) ![]Rect { } else { config.padding.left } - main_available := box_max(0, if config.orientation == .horizontal { + main_available := layout_max(0, if config.orientation == .horizontal { config.frame.width - config.padding.left - config.padding.right } else { config.frame.height - config.padding.top - config.padding.bottom }) - cross_available := box_max(0, if config.orientation == .horizontal { + cross_available := layout_max(0, if config.orientation == .horizontal { config.frame.height - config.padding.top - config.padding.bottom } else { config.frame.width - config.padding.left - config.padding.right @@ -313,7 +288,7 @@ pub fn flex_layout_frames(config FlexLayoutConfig) ![]Rect { for line in flex_lines(config, main_available) { count := line.end - line.start gaps := config.gap * f64(count - 1) - sizes := flex_line_sizes(config, line, box_max(0, main_available - gaps)) + sizes := flex_line_sizes(config, line, layout_max(0, main_available - gaps)) mut used := gaps for size in sizes { used += size @@ -347,9 +322,9 @@ pub fn flex_layout_frames(config FlexLayoutConfig) ![]Rect { return frames } -// flex_layout_preferred_size reports the natural unwrapped size before growing +// flex_preferred_size reports the natural unwrapped size before growing // or shrinking. The caller may measure text/content into element frames first. -pub fn flex_layout_preferred_size(config FlexLayoutConfig) !Rect { +pub fn flex_preferred_size(config FlexConfig) !Rect { flex_validate(config)! mut main := if config.children.len > 0 { config.gap * f64(config.children.len - 1) @@ -359,7 +334,7 @@ pub fn flex_layout_preferred_size(config FlexLayoutConfig) !Rect { mut cross := f64(0) for child in config.children { main += flex_basis(child, config.orientation) - cross = box_max(cross, flex_cross_size(child, config.orientation, -1)) + cross = layout_max(cross, flex_cross_size(child, config.orientation, -1)) } return if config.orientation == .horizontal { rect(0, 0, main + config.padding.left + config.padding.right, @@ -370,8 +345,8 @@ pub fn flex_layout_preferred_size(config FlexLayoutConfig) !Rect { } } -pub fn flex_layout(config FlexLayoutConfig) !Element { - frames := flex_layout_frames(config)! +pub fn flex(config FlexConfig) !Element { + frames := flex_frames(config)! mut children := []Element{cap: config.children.len} for index, child in config.children { children << Element{ diff --git a/ui/flex_layout_test.v b/ui/flex_layout_test.v index 0535f88b..f49c7a69 100644 --- a/ui/flex_layout_test.v +++ b/ui/flex_layout_test.v @@ -11,33 +11,33 @@ fn flex_test_near(actual f64, expected f64) { } fn test_flex_grow_preserves_bases_and_redistributes_at_multiple_bounds() { - frames := flex_layout_frames(FlexLayoutConfig{ + frames := flex_frames(FlexConfig{ frame: rect(100, 200, 420, 80) - padding: BoxPadding{ left: 10, top: 5, right: 10, bottom: 5 } + padding: LayoutPadding{ left: 10, top: 5, right: 10, bottom: 5 } gap: 10 children: [ - FlexLayoutChild{ element: flex_test_child(40, 20), grow: 1, maximum_width: 60 }, - FlexLayoutChild{ element: flex_test_child(40, 20), grow: 1, maximum_width: 100 }, - FlexLayoutChild{ element: flex_test_child(40, 20), grow: 2 }, + FlexChild{ element: flex_test_child(40, 20), grow: 1, maximum_width: 60 }, + FlexChild{ element: flex_test_child(40, 20), grow: 1, maximum_width: 100 }, + FlexChild{ element: flex_test_child(40, 20), grow: 2 }, ] }) or { panic(err) } assert frames == [rect(10, 5, 60, 70), rect(80, 5, 100, 70), rect(190, 5, 220, 70)] } fn test_flex_shrink_is_basis_weighted_and_redistributes_at_minimum() { - frames := flex_layout_frames(FlexLayoutConfig{ + frames := flex_frames(FlexConfig{ frame: rect(0, 0, 150, 30) children: [ - FlexLayoutChild{ element: flex_test_child(100, 10), minimum_width: 80 }, - FlexLayoutChild{ element: flex_test_child(200, 10) }, + FlexChild{ element: flex_test_child(100, 10), minimum_width: 80 }, + FlexChild{ element: flex_test_child(200, 10) }, ] }) or { panic(err) } assert frames == [rect(0, 0, 80, 30), rect(80, 0, 70, 30)] - weighted := flex_layout_frames(FlexLayoutConfig{ + weighted := flex_frames(FlexConfig{ frame: rect(0, 0, 225, 30) children: [ - FlexLayoutChild{ element: flex_test_child(100, 10) }, - FlexLayoutChild{ element: flex_test_child(200, 10) }, + FlexChild{ element: flex_test_child(100, 10) }, + FlexChild{ element: flex_test_child(200, 10) }, ] }) or { panic(err) } assert weighted[0].width == 75 @@ -45,12 +45,12 @@ fn test_flex_shrink_is_basis_weighted_and_redistributes_at_minimum() { } fn test_flex_shrink_respects_weights_disabled_shrink_and_explicit_basis() { - frames := flex_layout_frames(FlexLayoutConfig{ + frames := flex_frames(FlexConfig{ frame: rect(0, 0, 250, 30) children: [ - FlexLayoutChild{ element: flex_test_child(20, 10), basis: 100, shrink: 0 }, - FlexLayoutChild{ element: flex_test_child(100, 10), shrink: 1 }, - FlexLayoutChild{ element: flex_test_child(100, 10), shrink: 3 }, + FlexChild{ element: flex_test_child(20, 10), basis: 100, shrink: 0 }, + FlexChild{ element: flex_test_child(100, 10), shrink: 1 }, + FlexChild{ element: flex_test_child(100, 10), shrink: 3 }, ] }) or { panic(err) } assert frames[0].width == 100 @@ -59,75 +59,75 @@ fn test_flex_shrink_respects_weights_disabled_shrink_and_explicit_basis() { } fn test_flex_wrap_uses_bounded_bases_and_grows_each_line_independently() { - frames := flex_layout_frames(FlexLayoutConfig{ + frames := flex_frames(FlexConfig{ frame: rect(0, 0, 210, 200) gap: 10 line_gap: 7 wrap: true align: .center children: [ - FlexLayoutChild{ element: flex_test_child(100, 20), grow: 1 }, - FlexLayoutChild{ element: flex_test_child(100, 40), grow: 1 }, - FlexLayoutChild{ element: flex_test_child(130, 30), maximum_width: 100, grow: 1 }, + FlexChild{ element: flex_test_child(100, 20), grow: 1 }, + FlexChild{ element: flex_test_child(100, 40), grow: 1 }, + FlexChild{ element: flex_test_child(130, 30), maximum_width: 100, grow: 1 }, ] }) or { panic(err) } assert frames == [rect(0, 10, 100, 20), rect(110, 0, 100, 40), rect(0, 47, 100, 30)] } fn test_flex_wrap_shrinks_oversized_item_without_empty_line() { - frames := flex_layout_frames(FlexLayoutConfig{ + frames := flex_frames(FlexConfig{ frame: rect(0, 0, 100, 100) gap: 8 wrap: true children: [ - FlexLayoutChild{ element: flex_test_child(200, 20) }, - FlexLayoutChild{ element: flex_test_child(40, 30), grow: 1 }, + FlexChild{ element: flex_test_child(200, 20) }, + FlexChild{ element: flex_test_child(40, 30), grow: 1 }, ] }) or { panic(err) } assert frames == [rect(0, 0, 100, 20), rect(0, 28, 100, 30)] } fn test_vertical_flex_wrap_and_alignment_are_axis_independent() { - frames := flex_layout_frames(FlexLayoutConfig{ + frames := flex_frames(FlexConfig{ frame: rect(20, 20, 200, 130) orientation: .vertical - padding: BoxPadding{ left: 5, top: 10, right: 5, bottom: 10 } + padding: LayoutPadding{ left: 5, top: 10, right: 5, bottom: 10 } gap: 10 line_gap: 15 wrap: true align: .end children: [ - FlexLayoutChild{ element: flex_test_child(20, 50) }, - FlexLayoutChild{ element: flex_test_child(40, 50) }, - FlexLayoutChild{ element: flex_test_child(30, 50), grow: 1 }, + FlexChild{ element: flex_test_child(20, 50) }, + FlexChild{ element: flex_test_child(40, 50) }, + FlexChild{ element: flex_test_child(30, 50), grow: 1 }, ] }) or { panic(err) } assert frames == [rect(25, 10, 20, 50), rect(5, 70, 40, 50), rect(60, 10, 30, 110)] } fn test_flex_justification_and_child_alignment() { - base := FlexLayoutConfig{ + base := FlexConfig{ frame: rect(0, 0, 200, 80) gap: 10 align: .center children: [ - FlexLayoutChild{ element: flex_test_child(40, 20) }, - FlexLayoutChild{ element: flex_test_child(40, 20), align_self: .end }, + FlexChild{ element: flex_test_child(40, 20) }, + FlexChild{ element: flex_test_child(40, 20), align_self: .end }, ] } expected_x := [[0.0, 50.0], [55.0, 105.0], [110.0, 160.0], [0.0, 160.0], [27.5, 132.5], [110.0 / 3, 50.0 + 220.0 / 3]] for index, justify in [FlexJustify.start, .center, .end, .space_between, .space_around, .space_evenly] { - frames := flex_layout_frames(FlexLayoutConfig{ ...base, justify: justify }) or { panic(err) } + frames := flex_frames(FlexConfig{ ...base, justify: justify }) or { panic(err) } flex_test_near(frames[0].x, expected_x[index][0]) flex_test_near(frames[1].x, expected_x[index][1]) assert frames[0].y == 30 assert frames[1].y == 60 } - stretched := flex_layout_frames(FlexLayoutConfig{ + stretched := flex_frames(FlexConfig{ ...base - children: [FlexLayoutChild{ + children: [FlexChild{ element: flex_test_child(40, 20) align_self: .stretch maximum_height: 50 @@ -137,39 +137,39 @@ fn test_flex_justification_and_child_alignment() { } fn test_flex_small_viewports_keep_nonnegative_sizes_and_explicit_minimum_overflow() { - frames := flex_layout_frames(FlexLayoutConfig{ + frames := flex_frames(FlexConfig{ frame: rect(0, 0, 0, 0) - padding: BoxPadding{ left: 10, top: 10, right: 10, bottom: 10 } + padding: LayoutPadding{ left: 10, top: 10, right: 10, bottom: 10 } gap: 10 justify: .center children: [ - FlexLayoutChild{ element: flex_test_child(100, 20), minimum_width: 20, minimum_height: 5 }, - FlexLayoutChild{ element: flex_test_child(100, 20) }, + FlexChild{ element: flex_test_child(100, 20), minimum_width: 20, minimum_height: 5 }, + FlexChild{ element: flex_test_child(100, 20) }, ] }) or { panic(err) } assert frames == [rect(10, 10, 20, 5), rect(40, 10, 0, 0)] } fn test_flex_preferred_size_and_element_preserve_declared_content() { - config := FlexLayoutConfig{ + config := FlexConfig{ id: 'flex' frame: rect(20, 30, 300, 60) - padding: BoxPadding{ left: 5, top: 6, right: 7, bottom: 8 } + padding: LayoutPadding{ left: 5, top: 6, right: 7, bottom: 8 } gap: 10 children: [ - FlexLayoutChild{ + FlexChild{ element: button('a', 'Accept', rect(4, 8, 60, 20), BoxStyle{}, TextStyle{}) basis: 80 grow: 1 }, - FlexLayoutChild{ + FlexChild{ element: button('b', 'Cancel', rect(4, 8, 70, 30), BoxStyle{}, TextStyle{}) grow: 1 }, ] } - assert flex_layout_preferred_size(config)! == rect(0, 0, 172, 44) - layout := flex_layout(config) or { panic(err) } + assert flex_preferred_size(config)! == rect(0, 0, 172, 44) + layout := flex(config) or { panic(err) } assert layout.id == 'flex' assert layout.frame == config.frame assert layout.children[0].id == 'a' @@ -180,58 +180,58 @@ fn test_flex_preferred_size_and_element_preserve_declared_content() { } fn test_flex_empty_and_single_item_justification() { - assert flex_layout_frames(FlexLayoutConfig{})! == []Rect{} - assert flex_layout_preferred_size(FlexLayoutConfig{})! == rect(0, 0, 0, 0) + assert flex_frames(FlexConfig{})! == []Rect{} + assert flex_preferred_size(FlexConfig{})! == rect(0, 0, 0, 0) for justify in [FlexJustify.space_around, .space_evenly] { - frames := flex_layout_frames(FlexLayoutConfig{ + frames := flex_frames(FlexConfig{ frame: rect(0, 0, 100, 40) justify: justify - children: [FlexLayoutChild{ element: flex_test_child(20, 10) }] + children: [FlexChild{ element: flex_test_child(20, 10) }] }) or { panic(err) } assert frames == [rect(40, 0, 20, 40)] } } fn test_flex_large_weights_stay_finite() { - frames := flex_layout_frames(FlexLayoutConfig{ + frames := flex_frames(FlexConfig{ frame: rect(0, 0, 50, 30) children: [ - FlexLayoutChild{ element: flex_test_child(100, 10), shrink: 1.0e308 }, - FlexLayoutChild{ element: flex_test_child(100, 10), shrink: 1.0e308 }, + FlexChild{ element: flex_test_child(100, 10), shrink: 1.0e308 }, + FlexChild{ element: flex_test_child(100, 10), shrink: 1.0e308 }, ] }) or { panic(err) } assert frames == [rect(0, 0, 25, 30), rect(25, 0, 25, 30)] } fn test_flex_rejects_invalid_numeric_inputs_and_bounds() { - base := FlexLayoutConfig{ frame: rect(0, 0, 100, 40) } + base := FlexConfig{ frame: rect(0, 0, 100, 40) } invalid_configs := [ - FlexLayoutConfig{ ...base, frame: rect(0, 0, -1, 40) }, - FlexLayoutConfig{ ...base, frame: rect(0, 0, math.inf(1), 40) }, - FlexLayoutConfig{ ...base, padding: BoxPadding{ left: -1 } }, - FlexLayoutConfig{ ...base, padding: BoxPadding{ top: math.nan() } }, - FlexLayoutConfig{ ...base, gap: -1 }, - FlexLayoutConfig{ ...base, line_gap: -2 }, - FlexLayoutConfig{ ...base, align: .auto }, + FlexConfig{ ...base, frame: rect(0, 0, -1, 40) }, + FlexConfig{ ...base, frame: rect(0, 0, math.inf(1), 40) }, + FlexConfig{ ...base, padding: LayoutPadding{ left: -1 } }, + FlexConfig{ ...base, padding: LayoutPadding{ top: math.nan() } }, + FlexConfig{ ...base, gap: -1 }, + FlexConfig{ ...base, line_gap: -2 }, + FlexConfig{ ...base, align: .auto }, ] for config in invalid_configs { - if _ := flex_layout_frames(config) { + if _ := flex_frames(config) { assert false, 'invalid config must fail' } } - child := FlexLayoutChild{ element: flex_test_child(10, 10) } + child := FlexChild{ element: flex_test_child(10, 10) } invalid_children := [ - FlexLayoutChild{ ...child, basis: -2 }, - FlexLayoutChild{ ...child, basis: math.nan() }, - FlexLayoutChild{ ...child, grow: -1 }, - FlexLayoutChild{ ...child, shrink: math.inf(1) }, - FlexLayoutChild{ ...child, minimum_width: -1 }, - FlexLayoutChild{ ...child, maximum_height: -2 }, - FlexLayoutChild{ ...child, minimum_width: 20, maximum_width: 10 }, - FlexLayoutChild{ ...child, minimum_height: 20, maximum_height: 10 }, + FlexChild{ ...child, basis: -2 }, + FlexChild{ ...child, basis: math.nan() }, + FlexChild{ ...child, grow: -1 }, + FlexChild{ ...child, shrink: math.inf(1) }, + FlexChild{ ...child, minimum_width: -1 }, + FlexChild{ ...child, maximum_height: -2 }, + FlexChild{ ...child, minimum_width: 20, maximum_width: 10 }, + FlexChild{ ...child, minimum_height: 20, maximum_height: 10 }, ] for invalid in invalid_children { - if _ := flex_layout_frames(FlexLayoutConfig{ ...base, children: [invalid] }) { + if _ := flex_frames(FlexConfig{ ...base, children: [invalid] }) { assert false, 'invalid child must fail' } } diff --git a/ui/float_layout.v b/ui/float_layout.v index cbee60b5..cda82b4a 100644 --- a/ui/float_layout.v +++ b/ui/float_layout.v @@ -69,12 +69,12 @@ pub fn float_layout_frames(config FloatLayoutConfig) ![]Rect { float_validate(config)! mut frames := []Rect{cap: config.children.len} for child in config.children { - width := box_bound(if child.size_hint_x < 0 { + width := layout_bound(if child.size_hint_x < 0 { child.element.frame.width } else { config.frame.width * child.size_hint_x }, child.minimum_width, child.maximum_width) - height := box_bound(if child.size_hint_y < 0 { + height := layout_bound(if child.size_hint_y < 0 { child.element.frame.height } else { config.frame.height * child.size_hint_y diff --git a/ui/grid_layout.v b/ui/grid_layout.v index 61b9a304..77de0992 100644 --- a/ui/grid_layout.v +++ b/ui/grid_layout.v @@ -35,7 +35,7 @@ pub: row_span int = 1 } -pub struct GridLayoutConfig { +pub struct GridConfig { pub: id string frame Rect @@ -66,7 +66,7 @@ struct GridPlacement { row_span int = 1 } -fn grid_validate_config(config GridLayoutConfig, child_count int) ! { +fn grid_validate_config(config GridConfig, child_count int) ! { if child_count < 0 { return error('grid child count cannot be negative') } @@ -101,7 +101,7 @@ fn grid_validate_config(config GridLayoutConfig, child_count int) ! { } } -fn grid_automatic_columns(config GridLayoutConfig, child_count int) int { +fn grid_automatic_columns(config GridConfig, child_count int) int { inner := math.max(0.0, config.frame.width - config.padding.left - config.padding.right) fit := if inner < config.auto_columns_min_width { f64(1) @@ -181,7 +181,7 @@ fn grid_pack_spans(spans []GridSpan, columns int, rows int, orientation GridOrie return cells } -fn grid_spanned_dimensions(config GridLayoutConfig, child_count int, automatic bool) !(int, int, []GridPlacement) { +fn grid_spanned_dimensions(config GridConfig, child_count int, automatic bool) !(int, int, []GridPlacement) { mut columns := if automatic { grid_automatic_columns(config, child_count) } else { @@ -339,7 +339,7 @@ fn grid_axis_sizes(available f64, count int, default_size f64, force_default boo return sizes } -fn grid_resolved_cells(config GridLayoutConfig, child_count int, automatic bool) !(int, int, []GridPlacement) { +fn grid_resolved_cells(config GridConfig, child_count int, automatic bool) !(int, int, []GridPlacement) { if config.child_spans.any(it.column_span > 1 || it.row_span > 1) && child_count > 0 { return grid_spanned_dimensions(config, child_count, automatic) } @@ -394,10 +394,10 @@ fn grid_cell_coordinates(index int, columns int, rows int, orientation GridOrien } } -// grid_layout_frames returns child frames in declaration order. Unless an axis +// grid_frames returns child frames in declaration order. Unless an axis // is forced to its defaults, remaining space is distributed equally after its // configured minimums have been reserved. -pub fn grid_layout_frames(config GridLayoutConfig, child_count int) ![]Rect { +pub fn grid_frames(config GridConfig, child_count int) ![]Rect { grid_validate_config(config, child_count)! automatic := config.columns == 0 && config.rows == 0 && config.auto_columns_min_width > 0 columns, rows, cells := grid_resolved_cells(config, child_count, automatic)! @@ -445,14 +445,14 @@ pub fn grid_layout_frames(config GridLayoutConfig, child_count int) ![]Rect { return frames } -// grid_layout_preferred_size measures the natural grid size, including padding +// grid_preferred_size measures the natural grid size, including padding // and gaps. Automatic columns are resolved from config.frame.width; child sizes // should therefore have been measured at the corresponding available width. // Measurement reserves enough equally distributed extra space that every child -// fits its tracks, matching grid_layout_frames. Forced defaults ignore child +// fits its tracks, matching grid_frames. Forced defaults ignore child // measurements on their axis. This does not mutate // or construct Elements and can be used by a containing layout during measurement. -pub fn grid_layout_preferred_size(config GridLayoutConfig, child_sizes []Rect) !Rect { +pub fn grid_preferred_size(config GridConfig, child_sizes []Rect) !Rect { grid_validate_config(config, child_sizes.len)! automatic := config.columns == 0 && config.rows == 0 && config.auto_columns_min_width > 0 columns, rows, cells := grid_resolved_cells(config, child_sizes.len, automatic)! @@ -493,10 +493,10 @@ fn grid_preferred_track_extra(tracks []f64, start int, count int, preferred f64, return math.max(0.0, (preferred - current) / f64(count)) } -// grid_layout creates a view whose children are assigned to cells in their +// grid creates a view whose children are assigned to cells in their // declaration order. Child frames are replaced by the computed cell frames. -pub fn grid_layout(config GridLayoutConfig) !Element { - frames := grid_layout_frames(config, config.children.len)! +pub fn grid(config GridConfig) !Element { + frames := grid_frames(config, config.children.len)! mut children := []Element{cap: config.children.len} for index, child in config.children { children << Element{ diff --git a/ui/grid_layout_test.v b/ui/grid_layout_test.v index a95bd6d9..a40c77b7 100644 --- a/ui/grid_layout_test.v +++ b/ui/grid_layout_test.v @@ -3,7 +3,7 @@ module ui2 import math fn test_grid_layout_requires_a_row_or_column_constraint() { - if _ := grid_layout_frames(GridLayoutConfig{}, 1) { + if _ := grid_frames(GridConfig{}, 1) { assert false, 'an unconstrained grid must fail' } else { assert err.msg().contains('requires columns or rows') @@ -11,7 +11,7 @@ fn test_grid_layout_requires_a_row_or_column_constraint() { } fn test_grid_layout_rejects_children_beyond_fixed_capacity() { - if _ := grid_layout_frames(GridLayoutConfig{ columns: 2, rows: 2 }, 5) { + if _ := grid_frames(GridConfig{ columns: 2, rows: 2 }, 5) { assert false, 'a fixed grid must reject excess children' } else { assert err.msg().contains('only 4 cells') @@ -19,7 +19,7 @@ fn test_grid_layout_rejects_children_beyond_fixed_capacity() { } fn test_grid_layout_distributes_space_with_padding_spacing_and_minimums() { - frames := grid_layout_frames(GridLayoutConfig{ + frames := grid_frames(GridConfig{ frame: rect(0, 0, 230, 110) columns: 2 padding: GridPadding{ left: 10, top: 8, right: 10, bottom: 8 } @@ -35,7 +35,7 @@ fn test_grid_layout_distributes_space_with_padding_spacing_and_minimums() { } fn test_grid_layout_can_force_default_axis_sizes() { - frames := grid_layout_frames(GridLayoutConfig{ + frames := grid_frames(GridConfig{ frame: rect(0, 0, 300, 200) columns: 2 column_default_width: 60 @@ -50,7 +50,7 @@ fn test_grid_layout_can_force_default_axis_sizes() { } fn test_grid_layout_supports_rows_and_reverse_column_major_flow() { - frames := grid_layout_frames(GridLayoutConfig{ + frames := grid_frames(GridConfig{ frame: rect(0, 0, 200, 100) rows: 2 orientation: .bottom_to_top_right_to_left @@ -80,7 +80,7 @@ fn test_grid_layout_supports_all_fill_orientations() { } fn test_grid_layout_replaces_child_frames_and_preserves_content() { - grid := grid_layout( + grid := grid( id: 'actions' frame: rect(20, 30, 200, 80) columns: 2 @@ -97,41 +97,41 @@ fn test_grid_layout_replaces_child_frames_and_preserves_content() { } fn test_grid_automatic_columns_follow_width_thresholds_and_shrink_to_one_column() { - config := GridLayoutConfig{ + config := GridConfig{ frame: rect(0, 0, 310, 200) auto_columns_min_width: 90 padding: GridPadding{ left: 10, right: 10 } spacing: GridSpacing{ horizontal: 10 } } - wide := grid_layout_frames(config, 6) or { panic(err) } + wide := grid_frames(config, 6) or { panic(err) } assert wide[0] == rect(10, 0, 90, 100) assert wide[2] == rect(210, 0, 90, 100) assert wide[3] == rect(10, 100, 90, 100) - medium := grid_layout_frames(GridLayoutConfig{ ...config, frame: rect(0, 0, 309, 210) }, 6) or { panic(err) } + medium := grid_frames(GridConfig{ ...config, frame: rect(0, 0, 309, 210) }, 6) or { panic(err) } assert medium[0] == rect(10, 0, 139.5, 70) assert medium[2] == rect(10, 70, 139.5, 70) - narrow := grid_layout_frames(GridLayoutConfig{ ...config, frame: rect(0, 0, 100, 240) }, 6) or { panic(err) } + narrow := grid_frames(GridConfig{ ...config, frame: rect(0, 0, 100, 240) }, 6) or { panic(err) } assert narrow[0] == rect(10, 0, 80, 40) assert narrow[5] == rect(10, 200, 80, 40) } fn test_grid_automatic_columns_are_limited_to_children_and_maximum() { - config := GridLayoutConfig{ + config := GridConfig{ frame: rect(0, 0, 800, 200) auto_columns_min_width: 100 } - frames := grid_layout_frames(config, 2) or { panic(err) } + frames := grid_frames(config, 2) or { panic(err) } assert frames == [rect(0, 0, 400, 200), rect(400, 0, 400, 200)] - capped := grid_layout_frames(GridLayoutConfig{ ...config, max_columns: 2 }, 5) or { panic(err) } + capped := grid_frames(GridConfig{ ...config, max_columns: 2 }, 5) or { panic(err) } assert capped[0].width == 400 assert capped[2].x == 0 assert capped[4].y > capped[2].y - empty := grid_layout_frames(config, 0) or { panic(err) } + empty := grid_frames(config, 0) or { panic(err) } assert empty == []Rect{} } fn test_grid_explicit_dimensions_take_precedence_over_automatic_columns() { - frames := grid_layout_frames(GridLayoutConfig{ + frames := grid_frames(GridConfig{ frame: rect(0, 0, 600, 100) columns: 2 auto_columns_min_width: 50 @@ -141,7 +141,7 @@ fn test_grid_explicit_dimensions_take_precedence_over_automatic_columns() { } fn test_grid_automatic_layout_fits_even_below_padding_and_gap_sizes() { - frames := grid_layout_frames(GridLayoutConfig{ + frames := grid_frames(GridConfig{ frame: rect(0, 0, 6, 10) auto_columns_min_width: 100 padding: GridPadding{ left: 10, right: 10 } @@ -153,7 +153,7 @@ fn test_grid_automatic_layout_fits_even_below_padding_and_gap_sizes() { } }, 3) or { panic(err) } assert frames == [rect(3, 0, 0, 0), rect(3, 5, 0, 0), rect(3, 10, 0, 0)] - zero := grid_layout_frames(GridLayoutConfig{ + zero := grid_frames(GridConfig{ frame: Rect{} auto_columns_min_width: 100 padding: GridPadding{ left: 10, top: 8, right: 10, bottom: 8 } @@ -163,7 +163,7 @@ fn test_grid_automatic_layout_fits_even_below_padding_and_gap_sizes() { } fn test_grid_spans_include_gaps_and_fill_available_holes() { - frames := grid_layout_frames(GridLayoutConfig{ + frames := grid_frames(GridConfig{ frame: rect(0, 0, 320, 230) columns: 3 spacing: GridSpacing{ horizontal: 10, vertical: 10 } @@ -174,7 +174,7 @@ fn test_grid_spans_include_gaps_and_fill_available_holes() { } fn test_grid_spans_grow_rows_until_rectangular_children_fit() { - frames := grid_layout_frames(GridLayoutConfig{ + frames := grid_frames(GridConfig{ frame: rect(0, 0, 300, 200) columns: 3 child_spans: [GridSpan{ column_span: 2 }, GridSpan{ column_span: 2 }, @@ -187,7 +187,7 @@ fn test_grid_spans_grow_rows_until_rectangular_children_fit() { } fn test_grid_spans_can_grow_columns_and_reverse_column_major_order() { - frames := grid_layout_frames(GridLayoutConfig{ + frames := grid_frames(GridConfig{ frame: rect(0, 0, 300, 200) rows: 2 orientation: .bottom_to_top_right_to_left @@ -207,7 +207,7 @@ fn test_grid_spans_honor_every_orientation() { rect(200, 100, 100, 100), rect(200, 100, 100, 100), rect(0, 0, 100, 100), rect(0, 100, 100, 100), rect(200, 0, 100, 100)] for index, orientation in orientations { - frames := grid_layout_frames(GridLayoutConfig{ + frames := grid_frames(GridConfig{ frame: rect(0, 0, 300, 200) columns: 3 rows: 2 @@ -219,29 +219,29 @@ fn test_grid_spans_honor_every_orientation() { } fn test_grid_automatic_column_spans_contract_on_narrow_windows() { - config := GridLayoutConfig{ + config := GridConfig{ frame: rect(0, 0, 300, 200) auto_columns_min_width: 100 child_spans: [GridSpan{ column_span: 2 }] } - wide := grid_layout_frames(config, 3) or { panic(err) } + wide := grid_frames(config, 3) or { panic(err) } assert wide == [rect(0, 0, 200, 100), rect(200, 0, 100, 100), rect(0, 100, 100, 100)] - narrow := grid_layout_frames(GridLayoutConfig{ ...config, frame: rect(0, 0, 80, 300) }, 3) or { panic(err) } + narrow := grid_frames(GridConfig{ ...config, frame: rect(0, 0, 80, 300) }, 3) or { panic(err) } assert narrow == [rect(0, 0, 80, 100), rect(0, 100, 80, 100), rect(0, 200, 80, 100)] } fn test_grid_rejects_spans_that_cannot_fit_explicit_dimensions() { for config in [ - GridLayoutConfig{ columns: 2, child_spans: [GridSpan{ column_span: 3 }] }, - GridLayoutConfig{ rows: 2, child_spans: [GridSpan{ row_span: 3 }] }, - GridLayoutConfig{ + GridConfig{ columns: 2, child_spans: [GridSpan{ column_span: 3 }] }, + GridConfig{ rows: 2, child_spans: [GridSpan{ row_span: 3 }] }, + GridConfig{ columns: 3 rows: 2 child_spans: [GridSpan{ column_span: 2, row_span: 2 }, GridSpan{ column_span: 2 }] }, ] { count := config.child_spans.len - if _ := grid_layout_frames(config, count) { + if _ := grid_frames(config, count) { assert false, 'invalid fixed grid must fail' } } @@ -249,23 +249,23 @@ fn test_grid_rejects_spans_that_cannot_fit_explicit_dimensions() { fn test_grid_rejects_non_finite_dimensions_and_invalid_spans() { for config in [ - GridLayoutConfig{ columns: 1, frame: rect(0, 0, math.inf(1), 100) }, - GridLayoutConfig{ columns: 1, padding: GridPadding{ left: math.nan() } }, - GridLayoutConfig{ columns: 1, spacing: GridSpacing{ horizontal: -1 } }, - GridLayoutConfig{ columns: 1, auto_columns_min_width: math.nan() }, - GridLayoutConfig{ columns: 1, max_columns: -1 }, - GridLayoutConfig{ columns: 1, child_spans: [GridSpan{ column_span: 0 }] }, - GridLayoutConfig{ columns: 1, child_spans: [GridSpan{ row_span: -1 }] }, - GridLayoutConfig{ columns: 1, child_spans: [GridSpan{}, GridSpan{}] }, + GridConfig{ columns: 1, frame: rect(0, 0, math.inf(1), 100) }, + GridConfig{ columns: 1, padding: GridPadding{ left: math.nan() } }, + GridConfig{ columns: 1, spacing: GridSpacing{ horizontal: -1 } }, + GridConfig{ columns: 1, auto_columns_min_width: math.nan() }, + GridConfig{ columns: 1, max_columns: -1 }, + GridConfig{ columns: 1, child_spans: [GridSpan{ column_span: 0 }] }, + GridConfig{ columns: 1, child_spans: [GridSpan{ row_span: -1 }] }, + GridConfig{ columns: 1, child_spans: [GridSpan{}, GridSpan{}] }, ] { - if _ := grid_layout_frames(config, 1) { + if _ := grid_frames(config, 1) { assert false, 'invalid grid input must fail' } } } fn test_grid_rejects_span_area_overflow_before_allocating_occupancy() { - if _ := grid_layout_frames(GridLayoutConfig{ + if _ := grid_frames(GridConfig{ columns: 50000 child_spans: [GridSpan{ column_span: 50000, row_span: 50000 }] }, 1) { @@ -276,7 +276,7 @@ fn test_grid_rejects_span_area_overflow_before_allocating_occupancy() { } fn test_grid_preferred_size_measures_children_and_spans_at_responsive_width() { - config := GridLayoutConfig{ + config := GridConfig{ frame: rect(0, 0, 310, 0) auto_columns_min_width: 90 padding: GridPadding{ left: 10, top: 5, right: 10, bottom: 5 } @@ -284,11 +284,11 @@ fn test_grid_preferred_size_measures_children_and_spans_at_responsive_width() { child_spans: [GridSpan{ column_span: 2 }] } sizes := [rect(0, 0, 190, 40), rect(0, 0, 90, 20), rect(0, 0, 90, 30)] - wide := grid_layout_preferred_size(config, sizes) or { panic(err) } + wide := grid_preferred_size(config, sizes) or { panic(err) } assert wide == rect(0, 0, 310, 100) - narrow := grid_layout_preferred_size(GridLayoutConfig{ ...config, frame: rect(0, 0, 100, 0) }, sizes) or { panic(err) } + narrow := grid_preferred_size(GridConfig{ ...config, frame: rect(0, 0, 100, 0) }, sizes) or { panic(err) } assert narrow == rect(0, 0, 210, 150) - placed := grid_layout_frames(GridLayoutConfig{ ...config, frame: wide }, sizes.len) or { panic(err) } + placed := grid_frames(GridConfig{ ...config, frame: wide }, sizes.len) or { panic(err) } for index, frame in placed { assert frame.width >= sizes[index].width assert frame.height >= sizes[index].height @@ -296,7 +296,7 @@ fn test_grid_preferred_size_measures_children_and_spans_at_responsive_width() { } fn test_grid_preferred_size_respects_forced_tracks_and_minimums() { - preferred := grid_layout_preferred_size(GridLayoutConfig{ + preferred := grid_preferred_size(GridConfig{ columns: 2 column_default_width: 50 row_default_height: 20 @@ -314,13 +314,13 @@ fn test_grid_preferred_size_respects_forced_tracks_and_minimums() { } fn test_automatic_grid_preserves_fractional_capacity_and_edges() { - frames := grid_layout_frames(GridLayoutConfig{ frame: rect(0, 0, 100.5, 20.5), auto_columns_min_width: 50.25 }, 2)! + frames := grid_frames(GridConfig{ frame: rect(0, 0, 100.5, 20.5), auto_columns_min_width: 50.25 }, 2)! assert frames == [rect(0, 0, 50.25, 20.5), rect(50.25, 0, 50.25, 20.5)] - tiny := grid_layout_frames(GridLayoutConfig{ frame: rect(0, 0, 0.75, 0.5), auto_columns_min_width: 0.1, spacing: GridSpacing{ horizontal: 0.5 } }, 2)! + tiny := grid_frames(GridConfig{ frame: rect(0, 0, 0.75, 0.5), auto_columns_min_width: 0.1, spacing: GridSpacing{ horizontal: 0.5 } }, 2)! assert tiny == [rect(0, 0, 0.125, 0.5), rect(0.625, 0, 0.125, 0.5)] } fn test_grid_intrinsic_spans_keep_fractional_content_size() { - preferred := grid_layout_preferred_size(GridLayoutConfig{ columns: 2, child_spans: [GridSpan{ column_span: 2 }] }, [rect(0, 0, 3.5, 2.25)])! + preferred := grid_preferred_size(GridConfig{ columns: 2, child_spans: [GridSpan{ column_span: 2 }] }, [rect(0, 0, 3.5, 2.25)])! assert preferred == rect(0, 0, 3.5, 2.25) } diff --git a/ui/layout.v b/ui/layout.v new file mode 100644 index 00000000..8fb4dfe8 --- /dev/null +++ b/ui/layout.v @@ -0,0 +1,58 @@ +module ui2 + +import math + +pub enum LayoutOrientation { + horizontal + vertical +} + +pub enum LayoutAlignment { + auto + start + center + end + stretch +} + +pub struct LayoutPadding { +pub: + left f64 + top f64 + right f64 + bottom f64 +} + +pub fn layout_orientation(value string) !LayoutOrientation { + return match value { + '', 'horizontal' { .horizontal } + 'vertical' { .vertical } + else { return error('unknown layout orientation `${value}`') } + } +} + +pub fn layout_alignment(value string) !LayoutAlignment { + return match value { + '', 'auto' { .auto } + 'start' { .start } + 'center' { .center } + 'end' { .end } + 'stretch' { .stretch } + else { return error('unknown layout alignment `${value}`') } + } +} + +fn layout_finite(value f64) bool { + return !math.is_nan(value) && !math.is_inf(value, 0) +} + +fn layout_bound(value f64, minimum f64, maximum f64) f64 { + mut bounded := if value < 0 { 0.0 } else { value } + if minimum >= 0 && bounded < minimum { bounded = minimum } + if maximum >= 0 && bounded > maximum { bounded = maximum } + return bounded +} + +fn layout_max(left f64, right f64) f64 { + return if left > right { left } else { right } +} diff --git a/ui/layout_measure.v b/ui/layout_measure.v index d6ee0166..f3d6461b 100644 --- a/ui/layout_measure.v +++ b/ui/layout_measure.v @@ -54,7 +54,7 @@ pub fn (constraints LayoutConstraints) loosen() LayoutConstraints { // deflate gives content the space left inside a container's padding. Padding // larger than a bounded axis leaves a tight zero, never a negative maximum. -pub fn (constraints LayoutConstraints) deflate(padding BoxPadding) !LayoutConstraints { +pub fn (constraints LayoutConstraints) deflate(padding LayoutPadding) !LayoutConstraints { constraints.validate()! for inset in [padding.left, padding.top, padding.right, padding.bottom] { if !math.is_finite(inset) || inset < 0 { @@ -91,7 +91,7 @@ pub type LayoutTextMeasureFn = fn (string, TextStyle, f64) !LayoutSize // measure_layout_element keeps positive declared dimensions and measures zero // axes from text or children. Existing controls opt in by calling this API; -// constructing an Element does not change its legacy geometry. Containers with +// constructing an Element retains its declared geometry. Containers with // a layout algorithm measure/place their children before this extent fallback. pub fn measure_layout_element(element Element, constraints LayoutConstraints, measure LayoutTextMeasureFn) !LayoutSize { constraints.validate()! @@ -122,7 +122,7 @@ pub fn measure_layout_element(element Element, constraints LayoutConstraints, me } else { element.text } - measured := $if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && !ui2_headless ? { + measured := $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { // Editors wrap every row, unlike labels with a declared line limit. // Keep external callbacks' original TextStyle contract unchanged. if element.kind == .text_area && voidptr(measure) == voidptr(measure_layout_text) { @@ -159,20 +159,20 @@ pub fn measure_layout_element(element Element, constraints LayoutConstraints, me // These content insets affect only opted-in intrinsic sizes. Existing explicit // frames and backend drawing stay unchanged. -fn layout_measure_control_insets(element Element) BoxPadding { +fn layout_measure_control_insets(element Element) LayoutPadding { $if macos && !ui2_custom_rendering ?&& !ui2_headless ? { if element.kind == .label { // Borderless NSTextField still reserves two points at each horizontal // edge. NSAttributedString measures glyphs only; include the cell's // existing margins so an intrinsic label does not truncate itself. - return BoxPadding{ left: 2, right: 2 } + return LayoutPadding{ left: 2, right: 2 } } } - $if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && !ui2_headless ? { + $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { if element.kind == .text_area { // Include the gutter reserved by text_area_content_rect even when no // scrollbar is visible, so measurement and drawing wrap identically. - return BoxPadding{ + return LayoutPadding{ left: math.max(2, element.padding_left) right: if element.disable_scroll { 8 } else { 12 } top: 8 @@ -182,17 +182,17 @@ fn layout_measure_control_insets(element Element) BoxPadding { } return match element.kind { .button, .toggle_button { - BoxPadding{ left: 12, right: if element.image_path.len > 0 { 36 } else { 12 }, top: 6, bottom: 6 } + LayoutPadding{ left: 12, right: if element.image_path.len > 0 { 36 } else { 12 }, top: 6, bottom: 6 } } - .checkbox { BoxPadding{ left: 26 } } + .checkbox { LayoutPadding{ left: 26 } } .dropdown { - BoxPadding{ left: math.max(0, element.padding_left), right: 32, top: 6, bottom: 6 } + LayoutPadding{ left: math.max(0, element.padding_left), right: 32, top: 6, bottom: 6 } } .text_field { - BoxPadding{ left: math.max(0, element.padding_left), right: 8, top: 6, bottom: 6 } + LayoutPadding{ left: math.max(0, element.padding_left), right: 8, top: 6, bottom: 6 } } - .text_area { BoxPadding{ left: 8, right: 8, top: 8, bottom: 8 } } - else { BoxPadding{} } + .text_area { LayoutPadding{ left: 8, right: 8, top: 8, bottom: 8 } } + else { LayoutPadding{} } } } diff --git a/ui/layout_measure_test.v b/ui/layout_measure_test.v index 61538dbb..98b2f1ba 100644 --- a/ui/layout_measure_test.v +++ b/ui/layout_measure_test.v @@ -1,6 +1,6 @@ module ui2 -$if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && !ui2_headless ? { +$if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { import math } @@ -39,12 +39,12 @@ fn test_layout_constraints_distinguish_zero_from_unbounded_and_validate() { } tight := LayoutConstraints{ min_width: 100, max_width: 100, min_height: 40, max_height: 40 } assert tight.loosen() == LayoutConstraints{ max_width: 100, max_height: 40 } - assert tight.deflate(BoxPadding{ left: 20, right: 30, top: 50 })! == LayoutConstraints{ + assert tight.deflate(LayoutPadding{ left: 20, right: 30, top: 50 })! == LayoutConstraints{ min_width: 50 max_width: 50 max_height: 0 } - if _ := tight.deflate(BoxPadding{ left: -1 }) { + if _ := tight.deflate(LayoutPadding{ left: -1 }) { assert false, 'negative padding must be rejected' } } @@ -118,7 +118,7 @@ fn layout_measure_require_declared_editor_style(_text string, style TextStyle, _ } fn test_custom_intrinsic_editor_matches_wrapping_content_width_before_window_creation() { - $if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && !ui2_headless ? { + $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { style := TextStyle{ size: 16, font_family: 'Roboto Mono', lines: 1 } advance := measure_layout_text('M', style, -1)!.width // With two monospaced glyphs per row, five Ms occupy three rows. diff --git a/ui/vml.v b/ui/vml.v index bde52f35..a1969869 100644 --- a/ui/vml.v +++ b/ui/vml.v @@ -776,11 +776,7 @@ pub fn element_from_vnode(node &VNode, frame Rect) !Element { } fn node_to_element(node &VNode, frame Rect) !Element { - resolved := if node.tag == 'Screen' && node.prop_bool('adaptive') { - frame - } else { - v_frame(node, frame) - } + resolved := if node.tag == 'Screen' && node.prop_bool('adaptive') { frame } else { v_frame(node, frame) } el := node_to_element_base(node, resolved)! key := node.prop('key') menu := v_menu(node) @@ -844,13 +840,10 @@ fn node_to_element_base(node &VNode, frame Rect) !Element { 'BoxLayout' { return v_box_layout(node, frame)! } - 'FlexLayout' { - return v_flex(node, frame)! - } 'FloatLayout', 'RelativeLayout' { return v_float_layout(node, frame)! } - 'GridLayout' { + 'Grid' { return v_grid(node, frame)! } 'AnchorLayout' { @@ -1138,7 +1131,7 @@ fn v_is_layout_metadata(node &VNode) bool { } fn v_children(node &VNode, frame Rect) ![]Element { - if node.tag == 'Screen' && node.prop_bool('adaptive') && !node.prop_bool('__adaptive_layout_resolved') { + if node.tag == 'Screen' && node.prop_bool('adaptive') { return v_adaptive_children(node, frame)! } mut out := []Element{} @@ -1228,8 +1221,8 @@ fn v_box_layout_config(node &VNode, frame Rect, children []BoxLayoutChild) !BoxL id: node.id frame: frame box: v_box(node) - orientation: box_orientation(node.prop_or('orientation', 'horizontal'))! - padding: BoxPadding{ + orientation: layout_orientation(node.prop_or('orientation', 'horizontal'))! + padding: LayoutPadding{ left: node.prop_or('padding_left', padding.str()).f64() top: node.prop_or('padding_top', padding.str()).f64() right: node.prop_or('padding_right', padding.str()).f64() @@ -1330,52 +1323,45 @@ fn v_float_layout(node &VNode, frame Rect) !Element { return view(node.id, frame, v_box(node), children) } -fn v_grid_config(node &VNode, frame Rect) !GridLayoutConfig { +fn v_grid_config(node &VNode, frame Rect) !GridConfig { padding := node.prop_or('padding', '0').f64() spacing := node.prop_or('spacing', '0').f64() - return GridLayoutConfig{ - id: node.id - frame: frame - box: v_box(node) - columns: node.prop_or('columns', node.prop_or('cols', '0')).int() - rows: node.prop_or('rows', '0').int() - auto_columns_min_width: node.prop_or('auto_columns_min_width', '0').f64() - max_columns: node.prop_or('max_columns', '0').int() - orientation: grid_orientation(node.prop_or('orientation', 'lr-tb'))! - padding: GridPadding{ + return GridConfig{ + id: node.id + frame: frame + box: v_box(node) + columns: node.prop_or('columns', node.prop_or('cols', '0')).int() + rows: node.prop_or('rows', '0').int() + orientation: grid_orientation(node.prop_or('orientation', 'lr-tb'))! + padding: GridPadding{ left: node.prop_or('padding_left', padding.str()).f64() top: node.prop_or('padding_top', padding.str()).f64() right: node.prop_or('padding_right', padding.str()).f64() bottom: node.prop_or('padding_bottom', padding.str()).f64() } - spacing: GridSpacing{ + spacing: GridSpacing{ horizontal: node.prop_or('spacing_x', spacing.str()).f64() vertical: node.prop_or('spacing_y', spacing.str()).f64() } - column_default_width: node.prop_or('col_default_width', '0').f64() - row_default_height: node.prop_or('row_default_height', '0').f64() - force_column_width: node.prop_bool('col_force_default') - force_row_height: node.prop_bool('row_force_default') + column_default_width: node.prop_or('col_default_width', '0').f64() + row_default_height: node.prop_or('row_default_height', '0').f64() + force_column_width: node.prop_bool('col_force_default') + force_row_height: node.prop_bool('row_force_default') } } fn v_grid(node &VNode, frame Rect) !Element { - if node.prop_bool('__layout_resolved') { - return view(node.id, frame, v_box(node), v_children(node, rect(0, 0, frame.width, frame.height))!) - } mut visible := []&VNode{} - mut spans := []GridSpan{} for child in node.children { if !v_is_layout_metadata(child) { visible << child - spans << v_grid_span(child) } } config := v_grid_config(node, rect(0, 0, frame.width, frame.height))! - frames := grid_layout_frames(GridLayoutConfig{ ...config, child_spans: spans }, visible.len)! + frames := grid_frames(config, visible.len)! mut children := []Element{cap: visible.len} for index, child in visible { - children << node_to_element(v_layout_node_at(child, frames[index]), frames[index])! + children << node_to_element(child, frames[index])! } return view(node.id, frame, v_box(node), children) } @@ -1557,7 +1543,7 @@ fn v_accordion_config(node &VNode, frame Rect, items []AccordionItem) !Accordion frame: frame box: v_box(node) current: node.prop_or('current', '0').int() - orientation: box_orientation(node.prop_or('orientation', 'horizontal'))! + orientation: layout_orientation(node.prop_or('orientation', 'horizontal'))! min_space: node.prop_or('min_space', '44').f64() header_box: BoxStyle{ bg: v_color(node, 'title_background', 0xe2e8f0) @@ -1834,7 +1820,7 @@ fn v_frame(node &VNode, fallback Rect) Rect { return rect(node.prop_or('x', fallback.x.str()).f64(), node.prop_or('y', fallback.y.str()).f64(), node.prop_or('width', fallback.width.str()).f64(), node.prop_or('height', - fallback.height.str()).f64()) + fallback.height.str()).f64()) } fn v_dimension(node &VNode, key string, fallback f64) f64 { diff --git a/ui/vml_adaptive_test.v b/ui/vml_adaptive_test.v index 47dc3f5e..73a114a3 100644 --- a/ui/vml_adaptive_test.v +++ b/ui/vml_adaptive_test.v @@ -235,7 +235,7 @@ fn assert_adaptive_container_metadata_is_nonvisual(tag string, first string, sec } fn test_adaptive_vml_metadata_never_occupies_container_layout_slots() { - for tag in ['Column', 'Row', 'BoxLayout', 'FloatLayout', 'RelativeLayout', 'GridLayout', + for tag in ['Column', 'Row', 'BoxLayout', 'FloatLayout', 'RelativeLayout', 'Grid', 'AnchorLayout', 'StackLayout', 'PageLayout', 'Carousel', 'View', 'Rectangle', 'Scroll'] { assert_adaptive_container_metadata_is_nonvisual(tag, 'Label { id: first text: "First" width: 40 height: 24 }', @@ -244,7 +244,7 @@ fn test_adaptive_vml_metadata_never_occupies_container_layout_slots() { } fn test_adaptive_vml_metadata_is_inert_in_empty_layouts() { - for tag in ['Column', 'Row', 'BoxLayout', 'FloatLayout', 'RelativeLayout', 'GridLayout', + for tag in ['Column', 'Row', 'BoxLayout', 'FloatLayout', 'RelativeLayout', 'Grid', 'AnchorLayout', 'StackLayout', 'PageLayout', 'Carousel', 'View', 'Rectangle', 'Scroll'] { assert_adaptive_container_metadata_is_nonvisual(tag, '', '')! } diff --git a/ui/vml_flex.v b/ui/vml_flex.v deleted file mode 100644 index f28168f2..00000000 --- a/ui/vml_flex.v +++ /dev/null @@ -1,215 +0,0 @@ -module ui2 - -// Flex is an opt-in layout. Declared dimensions are preferred sizes; its parent -// owns the final frame. Existing Row/Column/BoxLayout documents keep their rules. -fn v_flex_config(node &VNode, frame Rect, children []FlexLayoutChild) !FlexLayoutConfig { - padding := node.prop_or('padding', '0').f64() - return FlexLayoutConfig{ - id: node.id - frame: frame - box: v_box(node) - orientation: box_orientation(node.prop_or('orientation', 'horizontal'))! - padding: BoxPadding{ - left: node.prop_or('padding_left', padding.str()).f64() - top: node.prop_or('padding_top', padding.str()).f64() - right: node.prop_or('padding_right', padding.str()).f64() - bottom: node.prop_or('padding_bottom', padding.str()).f64() - } - gap: node.prop_or('gap', '0').f64() - line_gap: node.prop_or('line_gap', '-1').f64() - wrap: node.prop_bool('wrap') - justify: flex_justify(node.prop_or('justify', 'start'))! - align: flex_alignment(node.prop_or('align_items', 'stretch'))! - children: children - } -} - -fn v_flex_child(node &VNode, preferred Rect) !FlexLayoutChild { - return FlexLayoutChild{ - element: Element{ frame: preferred } - basis: node.prop_or('flex_basis', '-1').f64() - grow: node.prop_or('flex_grow', '0').f64() - shrink: node.prop_or('flex_shrink', '1').f64() - minimum_width: node.prop_or('min_width', '0').f64() - minimum_height: node.prop_or('min_height', '0').f64() - maximum_width: node.prop_or('max_width', '-1').f64() - maximum_height: node.prop_or('max_height', '-1').f64() - align_self: flex_alignment(node.prop_or('align_self', 'auto'))! - } -} - -fn v_flex_items(node &VNode, available Rect, mut cache VLayoutMeasureCache) ![]FlexLayoutChild { - mut items := []FlexLayoutChild{} - for child in node.children { - if !v_is_layout_metadata(child) { - items << v_flex_child(child, v_layout_preferred(child, available, mut cache)!)! - } - } - return items -} - -fn v_layout_preferred(node &VNode, available Rect, mut cache VLayoutMeasureCache) !Rect { - width := v_dimension(node, 'width', -1) - height := v_dimension(node, 'height', -1) - probe := rect(0, 0, if width >= 0 { width } else { available.width }, if height >= 0 { - height - } else { - available.height - }) - if width >= 0 && height >= 0 { - return probe - } - container := node.tag in ['FlexLayout', 'GridLayout'] - // Only measurements inside another container's measurement repeat. Keying - // the direct children of a single Flex would cost more than it could save. - if !container && cache.nesting == 0 { - return v_layout_leaf_preferred(node, width, height)! - } - // A leaf reads only its declared dimensions, so the probe is not part of it. - key := cache.preferred_key(node, probe, container) - if cached := cache.preferred[key] { - return cached - } - cache.computed++ - if !container { - result := v_layout_leaf_preferred(node, width, height)! - cache.preferred[key] = result - return result - } - cache.nesting++ - defer { - cache.nesting-- - } - mut preferred := Rect{} - if node.tag == 'FlexLayout' { - config := v_flex_config(node, probe, []FlexLayoutChild{})! - inner := rect(0, 0, box_max(0, probe.width - config.padding.left - config.padding.right), - box_max(0, probe.height - config.padding.top - config.padding.bottom)) - initial := v_flex_items(node, inner, mut cache)! - frames_before_measure := flex_layout_frames(FlexLayoutConfig{ ...config, children: initial })! - items := v_flex_remeasure_plain(node, config, initial, frames_before_measure, mut cache)! - preferred = flex_layout_preferred_size(FlexLayoutConfig{ ...config, children: items })! - if config.wrap && config.orientation == .horizontal { - frames := flex_layout_frames(FlexLayoutConfig{ ...config, frame: rect(0, 0, probe.width, 0), children: items })! - mut bottom := config.padding.top - for frame in frames { - bottom = box_max(bottom, frame.y + frame.height) - } - preferred = rect(0, 0, preferred.width, bottom + config.padding.bottom) - } - } else if node.tag == 'GridLayout' { - mut sizes := []Rect{} - mut spans := []GridSpan{} - mut visible := []&VNode{} - for child in node.children { - if !v_is_layout_metadata(child) { - sizes << v_layout_preferred(child, probe, mut cache)! - spans << v_grid_span(child) - visible << child - } - } - config := v_grid_config(node, probe)! - spanned := GridLayoutConfig{ ...config, child_spans: spans } - initial := grid_layout_preferred_size(spanned, sizes)! - // Grid row heights also depend on the width of each assigned cell. Use - // its natural height for this width-only probe so padding/gaps fit even - // when the parent's available height is zero during measurement. - cells := grid_layout_frames(GridLayoutConfig{ ...spanned, frame: rect(0, 0, probe.width, initial.height) }, sizes.len)! - for index, child in visible { - if v_dimension(child, 'height', -1) >= 0 { continue } - measured := v_layout_preferred(v_layout_node_width(child, cells[index].width), - rect(0, 0, cells[index].width, probe.height), mut cache)! - sizes[index] = rect(0, 0, sizes[index].width, measured.height) - } - preferred = grid_layout_preferred_size(spanned, sizes)! - } - result := v_layout_declared_or(width, height, preferred) - cache.preferred[key] = result - return result -} - -// Leaf measurement uses the renderer's font metrics, keeping text in its -// existing point units. Other container types keep declared geometry. -fn v_layout_leaf_preferred(node &VNode, width f64, height f64) !Rect { - el := node_to_element(node, rect(0, 0, box_max(0, width), box_max(0, height)))! - measured := measure_layout_element(el, LayoutConstraints{}, measure_layout_text)! - return v_layout_declared_or(width, height, rect(0, 0, measured.width, measured.height)) -} - -fn v_layout_declared_or(width f64, height f64, preferred Rect) Rect { - return rect(0, 0, if width >= 0 { width } else { preferred.width }, if height >= 0 { - height - } else { - preferred.height - }) -} - -fn v_layout_node_at(node &VNode, frame Rect) &VNode { - mut placed := &VNode{ ...node, props: node.props.clone() } - placed.props['x'] = frame.x.str() - placed.props['y'] = frame.y.str() - placed.props['width'] = frame.width.str() - placed.props['height'] = frame.height.str() - return placed -} - -fn v_layout_node_width(node &VNode, width f64) &VNode { - mut assigned := &VNode{ ...node, props: node.props.clone(), expressions: node.expressions.clone() } - assigned.props['width'] = width.str() - assigned.expressions['width'] = &VExpression{ kind: .literal, value: width.str(), line: node.line } - return assigned -} - -fn v_flex_remeasure_plain(node &VNode, config FlexLayoutConfig, initial []FlexLayoutChild, frames []Rect, mut cache VLayoutMeasureCache) ![]FlexLayoutChild { - mut children := initial.clone() - mut index := 0 - for node_child in node.children { - if v_is_layout_metadata(node_child) { continue } - if v_dimension(node_child, 'height', -1) < 0 { - available := rect(0, 0, frames[index].width, config.frame.height) - preferred := v_layout_preferred(v_layout_node_width(node_child, frames[index].width), available, mut - cache)! - child := children[index] - children[index] = FlexLayoutChild{ - ...child - element: Element{ - ...child.element - frame: rect(0, 0, child.element.frame.width, preferred.height) - } - } - } - index++ - } - return children -} - -fn v_flex(node &VNode, frame Rect) !Element { - if node.prop_bool('__layout_resolved') { - return view(node.id, frame, v_box(node), v_children(node, rect(0, 0, frame.width, frame.height))!) - } - config := v_flex_config(node, rect(0, 0, frame.width, frame.height), []FlexLayoutChild{})! - inner := rect(0, 0, box_max(0, frame.width - config.padding.left - config.padding.right), - box_max(0, frame.height - config.padding.top - config.padding.bottom)) - // Static documents convert each nested Flex separately; one cache per Flex - // still shares the repeated subtree measurements below it. - mut cache := VLayoutMeasureCache{} - initial := v_flex_items(node, inner, mut cache)! - first := flex_layout_frames(FlexLayoutConfig{ ...config, children: initial })! - children_at_width := v_flex_remeasure_plain(node, config, initial, first, mut cache)! - frames := flex_layout_frames(FlexLayoutConfig{ ...config, children: children_at_width })! - mut children := []Element{} - mut index := 0 - for child in node.children { - if v_is_layout_metadata(child) { continue } - children << node_to_element(v_layout_node_at(child, frames[index]), frames[index])! - index++ - } - return view(node.id, frame, v_box(node), children) -} - -fn v_grid_span(node &VNode) GridSpan { - return GridSpan{ - column_span: node.prop_or('column_span', '1').int() - row_span: node.prop_or('row_span', '1').int() - } -} diff --git a/ui/vml_measure_cache.v b/ui/vml_measure_cache.v deleted file mode 100644 index 698b5e29..00000000 --- a/ui/vml_measure_cache.v +++ /dev/null @@ -1,66 +0,0 @@ -module ui2 - -import math - -// VLayoutMeasureCache reuses intrinsic sizes within one VML construction. -// Each Flex level measures a subtree before and after assigning its width, and -// nested levels repeat that, so identical requests multiply with depth. A cache -// lives for one build and is then dropped: nothing needs invalidating, and any -// change of content or constraints inside the build is a different key. -// -// Keys are structural rather than node paths. Repeater items share a path, and -// expressions that read an ancestor's size resolve the same source node to -// different content during measurement and evaluation. -@[heap] -struct VLayoutMeasureCache { -mut: - shapes map[voidptr]VMeasureShape - shape_ids map[string]int - preferred map[string]Rect - // computed counts preferred-size measurements that missed the cache. - computed int - // nesting counts container measurements in progress. - nesting int -} - -struct VMeasureShape { - // Holding the node keeps its address from being reused during the build. - node &VNode - id int -} - -// shape interns a node's measurement inputs: tag, id, resolved properties and -// children. Children are interned first, so each node costs only its own data. -fn (mut cache VLayoutMeasureCache) shape(node &VNode) int { - if known := cache.shapes[voidptr(node)] { - return known.id - } - mut key := []string{cap: node.props.len * 2 + node.children.len + 2} - key << '${node.tag.len}:${node.tag}${node.id.len}:${node.id}' - for name, value in node.props { - key << '${name.len}:${name}${value.len}:${value}' - } - key << '|' - for child in node.children { - key << cache.shape(child).str() - } - canonical := key.join(',') - id := cache.shape_ids[canonical] or { - next := cache.shape_ids.len - cache.shape_ids[canonical] = next - next - } - cache.shapes[voidptr(node)] = VMeasureShape{ - node: unsafe { node } - id: id - } - return id -} - -fn (mut cache VLayoutMeasureCache) preferred_key(node &VNode, probe Rect, depends_on_probe bool) string { - shape := cache.shape(node) - if !depends_on_probe { - return shape.str() - } - return '${shape}:${math.f64_bits(probe.width)}:${math.f64_bits(probe.height)}' -} diff --git a/ui/vml_measure_cache_test.v b/ui/vml_measure_cache_test.v deleted file mode 100644 index f76dadb4..00000000 --- a/ui/vml_measure_cache_test.v +++ /dev/null @@ -1,84 +0,0 @@ -module ui2 - -pub struct MeasureCacheItem { -pub: - id int - text string -} - -pub struct MeasureCacheApp { -pub: - items []MeasureCacheItem -} - -// Alternating orientations change the width each level offers its subtree. -fn measure_cache_nested(depth int) string { - mut source := 'Label { text: "content that wraps at a narrow width" lines: 3 }' - for level in 0 .. depth { - orientation := if level % 2 == 0 { 'horizontal' } else { 'vertical' } - source = 'FlexLayout { orientation: ${orientation} gap: 4\n Label { text: "level ${level}" }\n ${source} }' - } - return source -} - -fn measure_cache_model_computations(depth int) int { - root := parse_vml('Screen {\n ${measure_cache_nested(depth)} }') or { panic(err) } - mut evaluation := VmlEvaluation{} - v_eval_node(root, { - 'app': v_value_from(MeasureCacheApp{}) - }, rect(0, 0, 800, 600), mut evaluation) or { panic(err) } - return evaluation.measure.computed -} - -fn measure_cache_static_computations(depth int) int { - root := parse_vml(measure_cache_nested(depth)) or { panic(err) } - mut cache := VLayoutMeasureCache{} - v_layout_preferred(root, rect(0, 0, 800, 600), mut cache) or { panic(err) } - return cache.computed -} - -fn test_nested_flex_measurement_grows_polynomially_with_depth() { - for count in [measure_cache_static_computations, measure_cache_model_computations] { - shallow := count(6) - deep := count(12) - // Measuring every subtree twice per level made six more levels cost about - // 2^6 times as much. With reuse the growth stays near quadratic (~4x). - assert deep < shallow * 8, '${shallow} -> ${deep}' - } -} - -// Repeated items share a source node and path but resolve to different text. -// Each must be measured as its own content inside an outer measurement. -fn test_nested_measurement_keeps_repeated_items_distinct() { - source := 'FlexLayout { - orientation: vertical align_items: start - FlexLayout { - id: row gap: 4 align_items: start width: 300 - Repeater { - model: app.items - key: item.id - FlexLayout { - orientation: vertical flex_basis: 0 flex_grow: 1 - Label { text: item.text lines: 8 } - } - } - } - }' - long_text := 'A description long enough to wrap onto several lines in a narrow column' - model := MeasureCacheApp{ - items: [MeasureCacheItem{1, 'Short'}, MeasureCacheItem{2, long_text}, - MeasureCacheItem{3, 'Short'}] - } - root := element_from_vml_model(source, model, rect(0, 0, 600, 400)) or { panic(err) } - row := root.children[0] - items := row.children - assert items.len == 3 - label := items[1].children[0] - expected := measure_layout_text(long_text, label.text_style, items[1].frame.width) or { - panic(err) - } - assert items[0].frame.height < items[1].frame.height - assert items[0].frame.height == items[2].frame.height - assert items[1].frame.height == expected.height - assert row.frame.height == expected.height -} diff --git a/ui/vml_model.v b/ui/vml_model.v index 4ad2a911..462198b0 100644 --- a/ui/vml_model.v +++ b/ui/vml_model.v @@ -616,7 +616,6 @@ struct VmlEvaluation { mut: events map[string]VmlEvent toggle_group_bindings map[string][]VmlBinding - measure VLayoutMeasureCache } fn v_action(expr &VExpression, scope map[string]VValue) !VmlInvocation { @@ -674,7 +673,6 @@ enum VChildLayoutKind { column row box - flex float grid anchor @@ -686,10 +684,6 @@ enum VChildLayoutKind { struct VLayoutChildMetrics { frame Rect - flex FlexLayoutChild - span GridSpan - source &VNode = unsafe { nil } - scope map[string]VValue size_hint_x f64 = 1.0 size_hint_y f64 = 1.0 minimum_width f64 = -1.0 @@ -703,26 +697,24 @@ struct VLayoutChildMetrics { } struct VChildLayout { - kind VChildLayoutKind - frame Rect - padding f64 - spacing f64 - cells []Rect - anchor AnchorLayoutConfig - adaptive_parent &VNode = unsafe { nil } + kind VChildLayoutKind + frame Rect + padding f64 + spacing f64 + cells []Rect + anchor AnchorLayoutConfig mut: cursor f64 index int } -fn v_child_layout(node &VNode, actual Rect, metrics []VLayoutChildMetrics, mut cache VLayoutMeasureCache) !VChildLayout { +fn v_child_layout(node &VNode, actual Rect, metrics []VLayoutChildMetrics) !VChildLayout { kind := match node.tag { 'Column' { VChildLayoutKind.column } 'Row' { VChildLayoutKind.row } 'BoxLayout' { VChildLayoutKind.box } - 'FlexLayout' { VChildLayoutKind.flex } 'FloatLayout', 'RelativeLayout' { VChildLayoutKind.float } - 'GridLayout' { VChildLayoutKind.grid } + 'Grid' { VChildLayoutKind.grid } 'AnchorLayout' { VChildLayoutKind.anchor } 'StackLayout' { VChildLayoutKind.stack } 'PageLayout' { VChildLayoutKind.page } @@ -735,15 +727,11 @@ fn v_child_layout(node &VNode, actual Rect, metrics []VLayoutChildMetrics, mut c local := rect(0, 0, actual.width, actual.height) mut child_sizes := []Rect{cap: metrics.len} mut box_children := []BoxLayoutChild{cap: metrics.len} - mut flex_children := []FlexLayoutChild{cap: metrics.len} - mut spans := []GridSpan{cap: metrics.len} mut float_children := []FloatLayoutChild{cap: metrics.len} mut panel_tabs := []TabbedPanelTab{cap: metrics.len} mut accordion_items := []AccordionItem{cap: metrics.len} for metric in metrics { child_sizes << metric.frame - flex_children << metric.flex - spans << metric.span if kind == .box { box_children << BoxLayoutChild{ element: Element{ @@ -780,13 +768,7 @@ fn v_child_layout(node &VNode, actual Rect, metrics []VLayoutChildMetrics, mut c } mut cells := []Rect{} if kind == .grid { - config := v_grid_config(node, local)! - cells = grid_layout_frames(GridLayoutConfig{ ...config, child_spans: spans }, child_sizes.len)! - } else if kind == .flex { - config := v_flex_config(node, local, flex_children)! - first := flex_layout_frames(config)! - children := v_flex_remeasure_metrics(config, metrics, first, mut cache)! - cells = flex_layout_frames(FlexLayoutConfig{ ...config, children: children })! + cells = grid_frames(v_grid_config(node, local)!, child_sizes.len)! } else if kind == .box { cells = box_layout_frames(v_box_layout_config(node, local, box_children)!)! } else if kind == .float { @@ -803,18 +785,13 @@ fn v_child_layout(node &VNode, actual Rect, metrics []VLayoutChildMetrics, mut c cells = []Rect{len: accordion_items.len, init: geometry.content} } return VChildLayout{ - kind: kind - frame: local - padding: padding - spacing: node.prop_or('spacing', '0').f64() - cursor: padding - cells: cells - adaptive_parent: if node.tag == 'Screen' && node.prop_bool('adaptive') { - &VNode{ ...node } - } else { - unsafe { nil } - } - anchor: if kind == .anchor { + kind: kind + frame: local + padding: padding + spacing: node.prop_or('spacing', '0').f64() + cursor: padding + cells: cells + anchor: if kind == .anchor { v_anchor_config(node, local)! } else { AnchorLayoutConfig{} @@ -840,7 +817,7 @@ fn (layout &VChildLayout) fallback(child &VNode, scope map[string]VValue) !Rect .row { rect(layout.cursor, layout.padding, 80, layout.frame.height - layout.padding * 2) } - .box, .flex { + .box { if layout.index < layout.cells.len { layout.cells[layout.index] } else { @@ -897,7 +874,7 @@ fn (layout &VChildLayout) fallback(child &VNode, scope map[string]VValue) !Rect } fn (mut layout VChildLayout) advance(child &VNode) { - if v_is_layout_metadata(child) || child.tag == 'Option' { + if child.tag in ['MenuItem', 'Option'] { return } match layout.kind { @@ -907,7 +884,7 @@ fn (mut layout VChildLayout) advance(child &VNode) { .row { layout.cursor += v_dimension(child, 'width', 80) + layout.spacing } - .box, .flex { + .box { layout.index++ } .float { @@ -1001,12 +978,12 @@ fn v_layout_child_metric(node &VNode, scope map[string]VValue, box bool, floatin } } -fn v_layout_child_metrics(node &VNode, scope map[string]VValue, actual Rect, mut cache VLayoutMeasureCache) ![]VLayoutChildMetrics { +fn v_layout_child_metrics(node &VNode, scope map[string]VValue) ![]VLayoutChildMetrics { mut metrics := []VLayoutChildMetrics{} box := node.tag == 'BoxLayout' floating := node.tag in ['FloatLayout', 'RelativeLayout'] for child in node.children { - if v_is_layout_metadata(child) || child.tag == 'Option' { + if child.tag in ['MenuItem', 'Option'] { continue } if node.tag == 'TabbedPanel' && child.tag != 'Tab' { @@ -1016,8 +993,7 @@ fn v_layout_child_metrics(node &VNode, scope map[string]VValue, actual Rect, mut continue } if child.tag != 'Repeater' { - metrics << v_modern_child_metric(node, child, scope, actual, box, floating, mut - cache)! + metrics << v_layout_child_metric(child, scope, box, floating)! continue } model_expr := child.expressions['model'] or { @@ -1032,11 +1008,10 @@ fn v_layout_child_metrics(node &VNode, scope map[string]VValue, actual Rect, mut item_scope['item'] = item item_scope['index'] = v_number(f64(index), index.str()) for repeated in child.children { - if v_is_layout_metadata(repeated) || repeated.tag == 'Option' { + if repeated.tag in ['MenuItem', 'Option'] { continue } - metrics << v_modern_child_metric(node, repeated, item_scope, actual, box, floating, mut - cache)! + metrics << v_layout_child_metric(repeated, item_scope, box, floating)! } } } @@ -1080,8 +1055,7 @@ fn v_eval_node(node &VNode, incoming_scope map[string]VValue, frame Rect, mut ev mut binding := ?VmlBinding(none) for key, expr in node.expressions { if key == 'id' || key in node.property_types || key.starts_with('bind.') - || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', - 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { + || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { continue } resolved.props[key] = v_eval(expr, scope)!.string_value() @@ -1121,11 +1095,7 @@ fn v_eval_node(node &VNode, incoming_scope map[string]VValue, frame Rect, mut ev evaluation.toggle_group_bindings[resolved_binding.group] = group_bindings } } - actual := if node.tag == 'Screen' && resolved.prop_bool('adaptive') { - frame - } else { - v_frame(resolved, frame) - } + actual := v_frame(resolved, frame) if node.id.len > 0 { mut object := scope[node.id] or { return error('internal VML scope error for `${node.id}` at line ${node.line}') @@ -1192,31 +1162,18 @@ fn v_eval_node(node &VNode, incoming_scope map[string]VValue, frame Rect, mut ev } } - if node.tag == 'Screen' && resolved.prop_bool('adaptive') { - v_validate_adaptive_screen(resolved)! - } - child_metrics := v_layout_child_metrics(node, scope, actual, mut evaluation.measure)! - mut layout := v_child_layout(resolved, actual, child_metrics, mut evaluation.measure)! + child_metrics := v_layout_child_metrics(node, scope)! + mut layout := v_child_layout(resolved, actual, child_metrics)! for child in node.children { if child.tag == 'Repeater' { v_expand_repeater(child, scope, mut resolved.children, mut evaluation, mut layout)! } else { - resolved_child := if node.tag == 'Screen' && resolved.prop_bool('adaptive') { - v_eval_adaptive_child(resolved, child, scope, actual, mut evaluation)! - } else { - v_eval_layout_child(child, scope, layout.fallback(child, scope)!, - layout.kind, mut evaluation)! - } + resolved_child := v_eval_node(child, scope, layout.fallback(child, scope)!, mut + evaluation)! resolved.children << resolved_child layout.advance(resolved_child) } } - if layout.kind in [.flex, .grid] { - resolved.props['__layout_resolved'] = 'true' - } - if node.tag == 'Screen' && resolved.prop_bool('adaptive') { - resolved.props['__adaptive_layout_resolved'] = 'true' - } return resolved } @@ -1247,12 +1204,8 @@ fn v_expand_repeater(node &VNode, scope map[string]VValue, mut output []&VNode, parent_key := (scope['__repeat_key'] or { v_string('') }).string_value() item_scope['__repeat_key'] = v_string(v_repeat_identity(parent_key, key)) for child_index, child in node.children { - mut repeated := if layout.adaptive_parent != unsafe { nil } { - v_eval_adaptive_child(layout.adaptive_parent, child, item_scope, layout.frame, mut evaluation)! - } else { - v_eval_layout_child(child, item_scope, layout.fallback(child, item_scope)!, - layout.kind, mut evaluation)! - } + mut repeated := v_eval_node(child, item_scope, layout.fallback(child, item_scope)!, mut + evaluation)! if repeated.props['key'].len == 0 { repeated.props['key'] = if node.children.len == 1 { key @@ -1373,8 +1326,7 @@ fn v_validate_node_schema[T](node &VNode, incoming_scope map[string]VSchema) ! { } for key, expr in node.expressions { if key == 'id' || key in node.property_types || key.starts_with('bind.') - || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', - 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { + || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { continue } v_schema_expression(expr, scope)! @@ -1505,17 +1457,17 @@ fn type_check_action[T](name string, args []VSchema, line int) ! { if method.name == name { $if !method.is_pub { return error('app action `${name}` is not public') - } $else $if method.typ is fn ( ) { + } $else $if method.typ is fn () { if args.len != 0 { return error('app action `${name}` expects no arguments at line ${line}') } return - } $else $if method.typ is fn ( int ) { + } $else $if method.typ is fn (int) { if args.len != 1 || args[0].kind != .number { return error('app action `${name}` expects one int argument at line ${line}') } return - } $else $if method.typ is fn ( string ) { + } $else $if method.typ is fn (string) { if args.len != 1 || args[0].kind != .string_ { return error('app action `${name}` expects one string argument at line ${line}') } @@ -1543,13 +1495,13 @@ fn vml_dispatch[T](mut model T, invocation VmlInvocation) ! { } $for method in T.methods { if method.name == invocation.name { - $if method.is_pub && method.typ is fn ( ) { + $if method.is_pub && method.typ is fn () { model.$method() return - } $else $if method.is_pub && method.typ is fn ( int ) { + } $else $if method.is_pub && method.typ is fn (int) { model.$method(int(args[0].numeric(invocation.line)!)) return - } $else $if method.is_pub && method.typ is fn ( string ) { + } $else $if method.is_pub && method.typ is fn (string) { model.$method(args[0].string_value()) return } @@ -1566,11 +1518,11 @@ fn vml_apply_assignment[T](mut model T, assignment VmlAssignment) ! { pub struct VmlRunConfig[T] { pub: - source string - model T - title string = 'App' - width int = 400 - height int = 800 + source string + model T + title string = 'App' + width int = 400 + height int = 800 min_width int min_height int } diff --git a/ui/vml_model_layout.v b/ui/vml_model_layout.v deleted file mode 100644 index 6b48edb8..00000000 --- a/ui/vml_model_layout.v +++ /dev/null @@ -1,162 +0,0 @@ -module ui2 - -fn v_modern_child_metric(parent &VNode, child &VNode, scope map[string]VValue, actual Rect, box bool, floating bool, mut cache VLayoutMeasureCache) !VLayoutChildMetrics { - if parent.tag == 'FlexLayout' { - // Evaluate sizing and content without registering events. The actual pass - // below resolves actions/bindings exactly once, with the allocated frame. - parent_props := v_measurement_node(parent, scope, actual, false)! - config := v_flex_config(parent_props, actual, []FlexLayoutChild{})! - available := rect(0, 0, box_max(0, actual.width - config.padding.left - config.padding.right), - box_max(0, actual.height - config.padding.top - config.padding.bottom)) - measured := v_measurement_node(child, scope, available, true)! - preferred := v_layout_preferred(measured, available, mut cache)! - return VLayoutChildMetrics{ - frame: preferred - flex: v_flex_child(measured, preferred)! - source: &VNode{ ...child } - scope: scope.clone() - } - } - metric := v_layout_child_metric(child, scope, box, floating)! - if parent.tag == 'GridLayout' { - return VLayoutChildMetrics{ - ...metric - span: GridSpan{ - column_span: int(v_layout_dimension(child, 'column_span', scope, 1)!) - row_span: int(v_layout_dimension(child, 'row_span', scope, 1)!) - } - } - } - return metric -} - -// Main-axis distribution can change the width available to wrapping content. -// Measure auto heights once at that assigned width, keeping horizontal bases -// intact. Vertical layouts then distribute their updated intrinsic heights. -fn v_flex_remeasure_metrics(config FlexLayoutConfig, metrics []VLayoutChildMetrics, frames []Rect, mut cache VLayoutMeasureCache) ![]FlexLayoutChild { - mut children := config.children.clone() - for index, metric in metrics { - if metric.source == unsafe { nil } { continue } - available := rect(0, 0, frames[index].width, config.frame.height) - assigned := v_layout_node_width(metric.source, frames[index].width) - measured := v_measurement_node(assigned, metric.scope, available, true)! - if v_dimension(measured, 'height', -1) >= 0 { continue } - preferred := v_layout_preferred(measured, available, mut cache)! - child := children[index] - children[index] = FlexLayoutChild{ - ...child - element: Element{ - ...child.element - frame: rect(0, 0, child.element.frame.width, preferred.height) - } - } - } - return children -} - -fn v_eval_layout_child(node &VNode, scope map[string]VValue, frame Rect, kind VChildLayoutKind, mut evaluation VmlEvaluation) !&VNode { - if kind !in [.flex, .grid] || v_is_layout_metadata(node) || node.tag == 'Option' { - return v_eval_node(node, scope, frame, mut evaluation)! - } - mut assigned := &VNode{ ...node, expressions: node.expressions.clone() } - for key, value in { - 'x': frame.x - 'y': frame.y - 'width': frame.width - 'height': frame.height - } { - assigned.expressions[key] = &VExpression{ kind: .literal, value: value.str(), line: node.line } - } - return v_eval_node(assigned, scope, frame, mut evaluation)! -} - -fn v_eval_adaptive_child(parent &VNode, child &VNode, scope map[string]VValue, available Rect, mut evaluation VmlEvaluation) !&VNode { - if child.tag in ['MenuItem', 'Option'] { - return v_eval_node(child, scope, available, mut evaluation)! - } - if child.tag == 'LayoutVariation' { - return error('LayoutVariation belongs to a control, not Screen') - } - rw := v_adaptive_number(parent, 'width', 0)! - rh := v_adaptive_number(parent, 'height', 0)! - bw := v_adaptive_number(parent, 'layout_breakpoint_width', 600)! - bh := v_adaptive_number(parent, 'layout_breakpoint_height', 600)! - v_validate_adaptive_screen(parent)! - measured := v_measurement_node(child, scope, available, true)! - adapted := v_adaptive_child(measured, rw, rh, available, - adaptive_size_class(available.width, bw), adaptive_size_class(available.height, bh))! - mut assigned := &VNode{ ...child, expressions: child.expressions.clone() } - assigned.expressions['hidden'] = &VExpression{ kind: .literal, value: adapted.prop('hidden'), line: child.line } - // Adapt before evaluation so descendants and actions see the actual frame. - return v_eval_layout_child(assigned, scope, v_frame(adapted, available), .flex, mut evaluation)! -} - -fn v_validate_adaptive_screen(parent &VNode) ! { - for key in ['width', 'height', 'layout_breakpoint_width', 'layout_breakpoint_height'] { - fallback := if key.starts_with('layout_') { 600.0 } else { 0.0 } - if v_adaptive_number(parent, key, fallback)! <= 0 { - return error('adaptive Screen needs positive design dimensions and size-class breakpoints') - } - } -} - -// Resolve only the declarative properties needed for measurement. In particular -// this pass must not register bindings, assign identities, or invoke actions. -fn v_measurement_node(node &VNode, incoming_scope map[string]VValue, available Rect, descend bool) !&VNode { - mut scope := map[string]VValue{} - for name, value in incoming_scope { - scope[name] = value - } - mut resolved := &VNode{ ...node, props: node.props.clone(), children: []&VNode{} } - if node.id.len > 0 { - scope[node.id] = v_object({ - 'x': v_number(0, '0') - 'y': v_number(0, '0') - 'width': v_number(available.width, available.width.str()) - 'height': v_number(available.height, available.height.str()) - }) - } - for key in node.property_order { - expr := node.expressions[key] or { continue } - value := v_eval(expr, scope)! - resolved.props[key] = value.string_value() - if node.id.len > 0 { - mut object := scope[node.id] or { return error('missing measurement scope for `${node.id}`') } - object.fields[key] = value - scope[node.id] = object - } - } - for key, expr in node.expressions { - if key == 'id' || key in node.property_types || key.starts_with('on_') { continue } - property := if key.starts_with('bind.') { key.all_after('bind.') } else { key } - resolved.props[property] = v_eval(expr, scope)!.string_value() - } - actual := v_frame(resolved, available) - if node.id.len > 0 { - mut object := scope[node.id] or { return error('missing measurement scope for `${node.id}`') } - object.fields['width'] = v_number(actual.width, actual.width.str()) - object.fields['height'] = v_number(actual.height, actual.height.str()) - scope[node.id] = object - } - if !descend { return resolved } - for child in node.children { - if child.tag != 'Repeater' { - resolved.children << v_measurement_node(child, scope, actual, true)! - continue - } - expr := child.expressions['model'] or { return error('Repeater requires `model` at line ${child.line}') } - items := v_eval(expr, scope)! - if items.kind != .list { - return error('Repeater model must be a collection at line ${expr.line}') - } - for index, item in items.items { - mut item_scope := scope.clone() - item_scope['item'] = item - item_scope['index'] = v_number(index, index.str()) - for repeated in child.children { - resolved.children << v_measurement_node(repeated, item_scope, actual, true)! - } - } - } - return resolved -} diff --git a/ui/vml_model_test.v b/ui/vml_model_test.v index 40962125..89760fba 100644 --- a/ui/vml_model_test.v +++ b/ui/vml_model_test.v @@ -279,7 +279,7 @@ fn test_vml_model_toggle_button_groups_update_all_bound_fields() { } fn test_vml_model_grid_layout_counts_repeater_children() { - source := 'GridLayout { + source := 'Grid { columns: 2 padding: 10 spacing: 10 diff --git a/ui/vml_modern_layout_test.v b/ui/vml_modern_layout_test.v deleted file mode 100644 index 5423b990..00000000 --- a/ui/vml_modern_layout_test.v +++ /dev/null @@ -1,424 +0,0 @@ -module ui2 - -import math - -pub struct ModernLayoutItem { -pub: - id int - title string - basis f64 - grow f64 -} - -pub struct ModernLayoutProject { -pub: - id int - title string - description string -} - -pub struct ModernLayoutApp { -pub mut: - caption string = 'Save' - query string = 'initial' - calls int - selected int - created int - items []ModernLayoutItem - projects []ModernLayoutProject -} - -pub fn (mut app ModernLayoutApp) select(id int) { - app.selected = id - app.calls++ -} - -pub fn (mut app ModernLayoutApp) expand_caption() { - app.caption = 'Save every pending change' - app.calls++ -} - -pub fn (mut app ModernLayoutApp) create_project() { - app.created++ -} - -fn modern_layout_find(element Element, id string) ?Element { - if element.id == id { return element } - for child in element.children { - if found := modern_layout_find(child, id) { return found } - } - return none -} - -fn modern_layout_near(actual f64, expected f64) { - assert math.abs(actual - expected) < 0.00001, '${actual} != ${expected}' -} - -fn modern_layout_assert_siblings_fit(element Element) { - for index, child in element.children { - assert child.frame.width >= 0 && child.frame.height >= 0, child.id - assert child.frame.x >= -0.00001 && child.frame.y >= -0.00001, child.id - assert child.frame.x + child.frame.width <= element.frame.width + 0.00001, '${child.id}: ${child.frame} outside ${element.id}: ${element.frame}' - assert child.frame.y + child.frame.height <= element.frame.height + 0.00001, '${child.id}: ${child.frame} outside ${element.id}: ${element.frame}' - for other in element.children[index + 1..] { - if child.frame.width == 0 || child.frame.height == 0 || other.frame.width == 0 - || other.frame.height == 0 { - continue - } - assert child.frame.x + child.frame.width <= other.frame.x + 0.00001 - || other.frame.x + other.frame.width <= child.frame.x + 0.00001 - || child.frame.y + child.frame.height <= other.frame.y + 0.00001 - || other.frame.y + other.frame.height <= child.frame.y + 0.00001, 'overlap: ${child.id} ${child.frame} and ${other.id} ${other.frame}' - } - } -} - -fn test_vml_flex_parent_owns_final_frame_with_explicit_preferred_dimensions() { - source := 'FlexLayout { - gap: 10 - Button { id: first text: "First" width: 100 height: 20 flex_grow: 1 min_width: 80 } - Button { id: second text: "Second" width: 200 height: 20 flex_grow: 1 } - }' - for width in [150.0, 410.0] { - direct := element_from_vml(source, rect(20, 30, width, 50)) or { panic(err) } - modeled := element_from_vml_model(source, ModernLayoutApp{}, rect(20, 30, width, 50)) or { panic(err) } - assert direct.children.map(it.frame) == modeled.children.map(it.frame) - assert direct.children[0].frame == if width == 150 { - rect(0, 0, 80, 50) - } else { - rect(0, 0, 150, 50) - } - assert direct.children[1].frame == if width == 150 { - rect(90, 0, 60, 50) - } else { - rect(160, 0, 250, 50) - } - modern_layout_assert_siblings_fit(direct) - } -} - -fn test_vml_nested_flex_expressions_see_assigned_parent_size() { - source := 'FlexLayout { - gap: 20 - FlexLayout { - id: content - width: 100 - flex_grow: 1 - orientation: vertical - Label { id: probe text: "width=" + content.width width: content.width height: 20 } - } - View { width: 80 flex_shrink: 0 } - }' - for width in [300.0, 500.0] { - root := element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, width, 100)) or { panic(err) } - content := modern_layout_find(root, 'content') or { panic('missing content') } - probe := modern_layout_find(root, 'probe') or { panic('missing probe') } - assert content.frame.width == width - 100 - assert probe.frame.width == content.frame.width - assert probe.text.starts_with('width=') - assert probe.text.all_after('=').f64() == content.frame.width - modern_layout_assert_siblings_fit(root) - modern_layout_assert_siblings_fit(content) - } -} - -fn test_vml_nested_wrapping_flex_measures_height_at_assigned_width() { - source := 'FlexLayout { - gap: 10 align_items: start - FlexLayout { - id: wrapped flex_basis: 0 flex_grow: 1 wrap: true gap: 10 - Button { text: "First" width: 80 height: 30 } - Button { text: "Second" width: 80 height: 30 } - } - View { flex_basis: 0 flex_grow: 1 } - }' - for width in [300.0, 500.0] { - root := element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, width, 200)) or { panic(err) } - wrapped := root.children[0] - assert wrapped.frame.width == (width - 10) / 2 - assert wrapped.frame.height == if width == 300 { 70 } else { 30 } - modern_layout_assert_siblings_fit(wrapped) - } -} - -fn test_vml_flex_remeasures_multiline_and_width_dependent_text() { - source := 'FlexLayout { - gap: 10 align_items: start - Label { id: caption flex_basis: 0 flex_grow: 1 lines: 10 - text: caption.width < 200 ? "A longer caption that needs several lines when the allocated width becomes narrow" : "Short" - } - View { flex_basis: 0 flex_grow: 1 } - }' - root := element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, 300, 300)) or { panic(err) } - caption := root.children[0] - assert caption.frame.width == 145 - assert caption.text.starts_with('A longer') - measured := measure_layout_text(caption.text, caption.text_style, caption.frame.width) or { panic(err) } - assert measured.height > measure_layout_text('Short', caption.text_style, 145)!.height - modern_layout_near(caption.frame.height, measured.height) -} - -fn test_vml_vertical_flex_measures_height_after_cross_axis_maximum() { - source := 'FlexLayout { - orientation: vertical align_items: start - FlexLayout { wrap: true gap: 10 max_width: 150 - Button { text: "First" width: 80 height: 30 } - Button { text: "Second" width: 80 height: 30 } - } - }' - for modeled in [false, true] { - root := if modeled { - element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, 500, 300))! - } else { - element_from_vml(source, rect(0, 0, 500, 300))! - } - assert root.children[0].frame == rect(0, 0, 150, 70) - modern_layout_assert_siblings_fit(root.children[0]) - } -} - -fn test_vml_intrinsic_grid_measures_rows_at_assigned_cell_width() { - source := 'FlexLayout { orientation: vertical - GridLayout { columns: 2 spacing: 10 - Label { text: "A caption long enough to wrap into multiple lines inside its narrow grid cell" lines: 10 } - Label { text: "Another caption long enough to wrap into multiple lines inside its grid cell" lines: 10 } - } - }' - for modeled in [false, true] { - root := if modeled { - element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, 300, 300))! - } else { - element_from_vml(source, rect(0, 0, 300, 300))! - } - grid := root.children[0] - mut expected_height := 0.0 - for child in grid.children { - assert child.frame.width == 145 - measured := measure_layout_text(child.text, child.text_style, child.frame.width)! - expected_height = math.max(expected_height, measured.height) - } - modern_layout_near(grid.frame.height, expected_height) - modern_layout_assert_siblings_fit(grid) - } -} - -fn test_vml_adaptive_screen_reflows_modern_children_after_resizing() { - for tag in ['FlexLayout', 'GridLayout'] { - source := 'Screen { adaptive: true width: 760 height: 520 - ${tag} { id: body x: 24 y: 20 width: 712 height: 100 layout_x: stretch - columns: 2 spacing: 10 gap: 10 - Button { text: "First" flex_basis: 0 flex_grow: 1 } - Button { text: "Second" flex_basis: 0 flex_grow: 1 } - } - }' - for width in [390.0, 1000.0] { - direct := element_from_vml(source, rect(0, 0, width, 780)) or { panic(err) } - modeled := element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, width, 780)) or { panic(err) } - body := modeled.children[0] - assert body.frame.width == width - 48 - assert body.children.map(it.frame) == direct.children[0].children.map(it.frame), tag - modern_layout_assert_siblings_fit(body) - } - } -} - -fn test_vml_adaptive_repeater_variations_preserve_hidden_state_and_actions() { - source := 'Screen { id: root adaptive: true width: 760 height: 520 - Repeater { model: app.items key: item.id - FlexLayout { x: 24 y: index * 120 width: 712 height: 100 layout_x: stretch - LayoutVariation { width_class: compact reference_width: 390 reference_height: 780 - x: 10 y: index * 110 width: 370 height: 100 layout_x: stretch hidden: index == 1 } - Button { text: item.title flex_basis: 0 flex_grow: 1 on_tap: app.select(item.id) } - Label { text: root.width flex_basis: 0 flex_grow: 1 } - } - } - }' - mut app := new_vml_app(source, ModernLayoutApp{ - items: [ - ModernLayoutItem{ id: 7, title: 'First' }, - ModernLayoutItem{ id: 9, title: 'Second' }, - ] - }) or { panic(err) } - root := app.build(rect(0, 0, 390, 780)) or { panic(err) } - assert root.children.len == 2 - assert root.children[0].frame == rect(10, 0, 370, 100) - assert root.children[1].frame == rect(10, 110, 370, 100) - assert !root.children[0].hidden - assert root.children[1].hidden - assert root.children[0].children[0].frame.width == 185 - assert root.children[0].children[1].text.f64() == 390 - assert app.events.len == 2 - app.handle(root.children[0].children[0].action_id) or { panic(err) } - assert app.state().selected == 7 - assert app.state().calls == 1 - if _ := element_from_vml_model('Screen { adaptive: true width: 0 height: 520 }', - ModernLayoutApp{}, rect(0, 0, 390, 780)) { - assert false, 'invalid empty adaptive screens must still fail' - } -} - -fn test_vml_flex_wrap_uses_intrinsic_text_sizes_without_window() { - source := 'FlexLayout { - wrap: true gap: 9 align_items: start - Button { id: first text: "Publish changes" } - Button { id: second text: "Publish changes" } - }' - wide := element_from_vml(source, rect(0, 0, 1000, 200)) or { panic(err) } - first := wide.children[0] - assert first.frame.width > 30 - assert first.frame.height > 12 - assert wide.children[1].frame.y == 0 - modern_layout_near(wide.children[1].frame.x, first.frame.width + 9) - narrow_width := first.frame.width + 1 - narrow := element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, narrow_width, 200)) or { panic(err) } - modern_layout_near(narrow.children[0].frame.width, first.frame.width) - assert narrow.children[1].frame.x == 0 - modern_layout_near(narrow.children[1].frame.y, first.frame.height + 9) - modern_layout_assert_siblings_fit(narrow) -} - -fn test_vml_flex_repeater_resolves_weights_and_preserves_keyed_actions() { - source := 'FlexLayout { - gap: 10 - Repeater { - model: app.items - key: item.id - Button { text: item.title flex_basis: item.basis flex_grow: item.grow on_tap: app.select(item.id) } - } - }' - model := ModernLayoutApp{ - items: [ - ModernLayoutItem{ id: 7, title: 'First', basis: 40, grow: 1 }, - ModernLayoutItem{ id: 9, title: 'Second', basis: 80, grow: 3 }, - ] - } - root := element_from_vml_model(source, model, rect(0, 0, 300, 50)) or { panic(err) } - assert root.children[0].frame == rect(0, 0, 82.5, 50) - assert root.children[1].frame == rect(92.5, 0, 207.5, 50) - assert root.children.map(it.key) == ['7', '9'] - assert root.children[0].action_id != root.children[1].action_id - reordered := element_from_vml_model(source, ModernLayoutApp{ items: model.items.reverse() }, - rect(0, 0, 300, 50)) or { panic(err) } - assert reordered.children[0].key == '9' - assert reordered.children[0].action_id == root.children[1].action_id - assert reordered.children[1].action_id == root.children[0].action_id - mut app := new_vml_app(source, model) or { panic(err) } - for width in [300.0, 500.0, 150.0] { - built := app.build(rect(0, 0, width, 50)) or { panic(err) } - assert built.children.map(it.action_id) == root.children.map(it.action_id) - assert app.state().calls == 0 - assert app.events.len == 2 - modern_layout_assert_siblings_fit(built) - } - app.handle(root.children[1].action_id) or { panic(err) } - assert app.state().selected == 9 - assert app.state().calls == 1 -} - -fn modern_layout_control_text(_ string) string { - return 'edited' -} - -fn test_vml_flex_measurement_preserves_binding_and_never_invokes_action() { - source := 'FlexLayout { - gap: 12 align_items: center - Button { id: save text: app.caption on_tap: app.expand_caption() } - TextField { id: query bind.text: app.query flex_grow: 1 } - }' - mut app := new_vml_app(source, ModernLayoutApp{}) or { panic(err) } - app.control_text = modern_layout_control_text - first := app.build(rect(0, 0, 700, 80)) or { panic(err) } - assert app.state().calls == 0 - assert app.state().query == 'initial' - assert app.events.len == 2 - assert first.children[0].frame.width > 24 - assert first.children[1].text == 'initial' - app.handle(first.children[1].action_id) or { panic(err) } - assert app.state().query == 'edited' - app.handle(first.children[0].action_id) or { panic(err) } - second := app.build(rect(0, 0, 700, 80)) or { panic(err) } - assert app.state().calls == 1 - assert second.children[0].frame.width > first.children[0].frame.width - assert second.children[1].frame.width < first.children[1].frame.width - assert second.children[0].action_id == first.children[0].action_id - assert second.children[1].action_id == first.children[1].action_id - assert second.children[1].text == 'edited' - modern_layout_assert_siblings_fit(second) -} - -fn test_vml_grid_auto_columns_spans_nested_flex_and_metadata_resize_together() { - source := 'GridLayout { - auto_columns_min_width: 180 max_columns: 3 spacing: 10 - LayoutVariation { width_class: compact width: 20 height: 20 } - FlexLayout { - id: featured column_span: 2 row_span: 2 gap: 10 width: 1 height: 1 - Button { id: left text: "Left" width: 60 flex_grow: 1 } - LayoutVariation { width_class: compact width: 20 height: 20 } - Button { id: right text: "Right" width: 60 flex_grow: 1 } - } - Button { id: second text: "Second" width: 1 height: 1 } - Button { id: third text: "Third" width: 1 height: 1 } - }' - for width in [620.0, 250.0] { - direct := element_from_vml(source, rect(0, 0, width, 300)) or { panic(err) } - modeled := element_from_vml_model(source, ModernLayoutApp{}, rect(0, 0, width, 300)) or { panic(err) } - assert direct.children.len == 3 - assert modeled.children.len == 3 - assert direct.children.map(it.frame) == modeled.children.map(it.frame) - featured := direct.children[0] - assert featured.children.len == 2 - assert featured.frame.width == if width == 620 { 410 } else { 250 } - assert direct.children[1].frame.x == if width == 620 { 420 } else { 0 } - assert direct.children[1].frame.width == if width == 620 { 200 } else { 250 } - modern_layout_near(featured.children[0].frame.width, (featured.frame.width - 10) / 2) - modern_layout_assert_siblings_fit(direct) - modern_layout_assert_siblings_fit(featured) - modern_layout_assert_siblings_fit(modeled.children[0]) - } -} - -fn test_responsive_example_fits_wide_and_compact_windows_and_keeps_actions() { - source := $embed_file('../examples/responsive_layout/responsive_layout.vml').to_string() - model := ModernLayoutApp{ - projects: [ - ModernLayoutProject{ id: 1, title: 'Design system', description: 'Shared controls and typography' }, - ModernLayoutProject{ id: 2, title: 'Desktop app', description: 'One layout across window sizes' }, - ] - } - mut app := new_vml_app(source, model) or { panic(err) } - mut action_id := '' - for width in [1000.0, 390.0, 1000.0] { - root := app.build(rect(0, 0, width, 780)) or { panic(err) } - validate_element_tree(root) or { panic(err) } - page := modern_layout_find(root, 'page') or { panic('missing page') } - projects := modern_layout_find(root, 'projects') or { panic('missing projects') } - toolbar := modern_layout_find(root, 'toolbar') or { panic('missing toolbar') } - searchbar := modern_layout_find(root, 'searchbar') or { panic('missing searchbar') } - create := modern_layout_find(root, 'create') or { panic('missing action') } - assert page.frame.width == width - assert projects.frame.width == width - 48 - assert projects.children.len == 3 - assert projects.children[1].key == '1' - assert projects.children[2].key == '2' - if width == 390 { - assert projects.children.all(it.frame.x == 0) - assert projects.children.all(it.frame.width == projects.frame.width) - assert projects.children[1].frame.y > projects.children[0].frame.y - } else { - assert projects.children[0].frame.width > projects.children[1].frame.width - } - for container in [page, toolbar, searchbar, projects] { - modern_layout_assert_siblings_fit(container) - } - for card in projects.children { - modern_layout_assert_siblings_fit(card) - } - if action_id.len > 0 { - assert create.action_id == action_id - } - action_id = create.action_id - assert app.state().created == 0 - } - app.handle(action_id) or { panic(err) } - assert app.state().created == 1 -} diff --git a/ui/vml_test.v b/ui/vml_test.v index ff3d53aa..b7fa9ce9 100644 --- a/ui/vml_test.v +++ b/ui/vml_test.v @@ -757,7 +757,7 @@ fn test_vml_relative_layout_keeps_child_frames_local() { } fn test_vml_grid_layout_assigns_cells_in_the_requested_orientation() { - el := element_from_vml('GridLayout { + el := element_from_vml('Grid { id: tools columns: 2 orientation: rl-bt @@ -776,7 +776,7 @@ fn test_vml_grid_layout_assigns_cells_in_the_requested_orientation() { } fn test_vml_grid_layout_requires_a_constraint() { - if _ := element_from_vml('GridLayout { Button { text: "Missing constraint" } }', rect(0, 0, 200, 100)) { + if _ := element_from_vml('Grid { Button { text: "Missing constraint" } }', rect(0, 0, 200, 100)) { assert false, 'an unconstrained VML grid must fail' } else { assert err.msg().contains('requires columns or rows') From e0a07a80866a48927dd1b5d4596547c26c4b912f Mon Sep 17 00:00:00 2001 From: tomaszbk Date: Sat, 10 Oct 2026 00:15:04 -0300 Subject: [PATCH 7/7] Mark AppKit measurement test for its native profile --- ui/layout_measure_backend.c.v | 2 +- ui/layout_measure_cpu.c.v | 2 +- ui/layout_measure_darwin_test.v | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/layout_measure_backend.c.v b/ui/layout_measure_backend.c.v index 128c320b..287e7fbb 100644 --- a/ui/layout_measure_backend.c.v +++ b/ui/layout_measure_backend.c.v @@ -2,7 +2,7 @@ module ui2 // measure_layout_text uses the active backend's metrics when available, with // CPU fontstash measurement before a custom window opens (and for embedders -// without a native measurement adapter). Font sizes retain their point units. +// without a native measurement adapter). Font sizes follow backend drawing metrics. // Like layout/build, call this on the UI thread while a window is mounted. pub fn measure_layout_text(text string, style TextStyle, max_width f64) !LayoutSize { layout_validate_text_measurement(style, max_width)! diff --git a/ui/layout_measure_cpu.c.v b/ui/layout_measure_cpu.c.v index 5280a3b3..08964907 100644 --- a/ui/layout_measure_cpu.c.v +++ b/ui/layout_measure_cpu.c.v @@ -7,7 +7,7 @@ import os.font import sync // A CPU-only fontstash context supplies real glyph advances before a window -// exists. It shares UI2's font discovery, point conversion and fallback chain; +// exists. It shares UI2's font discovery, size conversion and fallback chain; // no GPU, gg context, font parser or extra dependency is involved. @[heap] struct LayoutMeasureFonts { diff --git a/ui/layout_measure_darwin_test.v b/ui/layout_measure_darwin_test.v index e83fc6da..8b322342 100644 --- a/ui/layout_measure_darwin_test.v +++ b/ui/layout_measure_darwin_test.v @@ -1,3 +1,4 @@ +// vtest build: macos && !ui2_custom_rendering? && !ui2_headless? module ui2 $if macos && !ui2_custom_rendering ?&& !ui2_headless ? {