diff --git a/KIVY_PARITY.md b/KIVY_PARITY.md index 3c72d013..ed9007a6 100644 --- a/KIVY_PARITY.md +++ b/KIVY_PARITY.md @@ -151,17 +151,17 @@ mutually exclusive. `allow_no_selection` defaults to `true`; set it to `false` when one member must remain selected. `toggle_button_group_members(id)` returns the mounted IDs belonging to the same group. -## GridLayout +## Grid -![GridLayout example](docs/images/widgets/grid_layout.png) +![Grid example](docs/images/widgets/grid_layout.png) -`GridLayout` assigns children to matrix cells in declaration order. At least +`Grid` assigns children to matrix cells in declaration order. At least one of `columns`/`cols` or `rows` is required. With one constraint, the other dimension grows to fit the children; setting both places a fixed limit on the number of cells. ```vml -GridLayout { +Grid { columns: 3 padding: 8 spacing: 8 @@ -172,7 +172,7 @@ GridLayout { } ``` -The V API is `grid_layout(GridLayoutConfig{...})` and returns an error for an +The V API is `grid(GridConfig{...})` and returns an error for an invalid constraint or capacity. It supports all eight two-axis orientations, per-edge `GridPadding`, horizontal/vertical `GridSpacing`, default or forced row and column sizes, and per-index minimum sizes. VML also accepts diff --git a/README.md b/README.md index 22b935ff..1d66b471 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). + You can also build desktop ui2 apps in C or any language with a C FFI. Build the shared library and the C counter with `make c-example`; the standalone header is [`c/ui2.h`](c/ui2.h). See the [C API guide](c/README.md) for the build diff --git a/benchmarks/flex_measure/main.v b/benchmarks/flex_measure/main.v new file mode 100644 index 00000000..193c581c --- /dev/null +++ b/benchmarks/flex_measure/main.v @@ -0,0 +1,103 @@ +// 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 runs = 7 + +struct Case { + name string + width f64 + depth int + grid bool +} + +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 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 + } + 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 + } + 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 element.children.len +} + +fn median(values []f64) f64 { + mut sorted := values.clone() + sorted.sort(a < b) + return sorted[sorted.len / 2] +} + +fn main() { + cases := [ + 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 } + checksum += build(case)! + mut iterations := 1 + for { + 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 { + 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') + } + println('checksum ${checksum}') +} 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 358be42e..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 new file mode 100644 index 00000000..e58bd678 --- /dev/null +++ b/examples/responsive_layout/main.v @@ -0,0 +1,134 @@ +@[has_globals] +module main + +import math +import os +import ui2 + +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' }, + ] +} + +__global responsive_app = ResponsiveApp{} + +fn main() { + 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_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 ceb8fba1..1929bb26 100644 --- a/ui/accordion.v +++ b/ui/accordion.v @@ -26,7 +26,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 adab78bc..595f2768 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/component_attachments.v b/ui/component_attachments.v index 5cc77331..5a81d72d 100644 --- a/ui/component_attachments.v +++ b/ui/component_attachments.v @@ -567,8 +567,8 @@ pub: // percentage to the description. It is ignored in other statuses. progress ?f64 tooltip string - axis BoxOrientation = .horizontal // Vertical puts the preview above the text. - size ComponentSize = .medium + axis LayoutOrientation = .horizontal // Vertical puts the preview above the text. + size ComponentSize = .medium // hide_media and hide_content leave out the preview or the text; a // vertical attachment without text is a square image tile. hide_media bool diff --git a/ui/component_bars_toolbar.v b/ui/component_bars_toolbar.v index b2cb8dd5..41f7127c 100644 --- a/ui/component_bars_toolbar.v +++ b/ui/component_bars_toolbar.v @@ -11,7 +11,7 @@ pub: id string frame Rect items []BarItem - orientation BoxOrientation = .horizontal + orientation LayoutOrientation = .horizontal // enabled false disables every command and the keyboard helper. enabled bool = true // hovered is the command under the pointer; keep it with toolbar_hover_from_event. diff --git a/ui/component_choice.v b/ui/component_choice.v index 6abc0be3..e3e13142 100644 --- a/ui/component_choice.v +++ b/ui/component_choice.v @@ -548,7 +548,7 @@ pub: id string frame Rect items []ButtonGroupItem - orientation BoxOrientation = .horizontal + orientation LayoutOrientation = .horizontal variant ButtonVariant outline bool fill bool // Stretch the buttons over the frame instead of packing them. diff --git a/ui/component_choice_radio.v b/ui/component_choice_radio.v index 47169aaf..0483c3ab 100644 --- a/ui/component_choice_radio.v +++ b/ui/component_choice_radio.v @@ -33,8 +33,8 @@ pub: frame Rect options []RadioOption selected string // Value of the checked option; empty checks none. - orientation BoxOrientation = .vertical - enabled bool = true + orientation LayoutOrientation = .vertical + enabled bool = true accessibility_label string // Names the group for assistive technology; not drawn. size ComponentSize = .medium theme ComponentTheme diff --git a/ui/component_choice_stepper.v b/ui/component_choice_stepper.v index 8a17e5cc..568d47c7 100644 --- a/ui/component_choice_stepper.v +++ b/ui/component_choice_stepper.v @@ -16,9 +16,9 @@ pub: frame Rect items []StepperItem current int // Index of the current step; items.len marks every step completed. - orientation BoxOrientation = .horizontal - clickable bool = true // False draws the steps without making them buttons. - enabled bool = true + orientation LayoutOrientation = .horizontal + clickable bool = true // False draws the steps without making them buttons. + enabled bool = true text_center bool // Horizontal only: center each step's text under its indicator. size ComponentSize = .medium theme ComponentTheme diff --git a/ui/component_controls.v b/ui/component_controls.v index ad40636a..05de76d9 100644 --- a/ui/component_controls.v +++ b/ui/component_controls.v @@ -384,7 +384,7 @@ pub: frame Rect options []CheckboxOption selected []string // Values of the checked options. - orientation BoxOrientation = .vertical + orientation LayoutOrientation = .vertical // select_all, when not empty, is the label of a parent checkbox shown first: // checked when every enabled option is, indeterminate when only some are, // and toggling every enabled option at once. In a vertical group the diff --git a/ui/component_display.v b/ui/component_display.v index 37f7dd6b..3230d2f9 100644 --- a/ui/component_display.v +++ b/ui/component_display.v @@ -176,8 +176,8 @@ pub struct SeparatorConfig { pub: id string frame Rect - orientation BoxOrientation = .horizontal - thickness f64 = 1 + orientation LayoutOrientation = .horizontal + thickness f64 = 1 theme ComponentTheme } diff --git a/ui/component_form.v b/ui/component_form.v index f4936428..8c84553e 100644 --- a/ui/component_form.v +++ b/ui/component_form.v @@ -105,8 +105,8 @@ pub: actions []FormAction // label_layout puts labels above their controls (.vertical) or beside them // (.horizontal), independently of columns. - label_layout BoxOrientation = .vertical - columns int = 1 + label_layout LayoutOrientation = .vertical + columns int = 1 // collapse_below makes the form one column with labels above their // controls while frame is narrower than this; zero never collapses. collapse_below f64 @@ -1033,8 +1033,8 @@ pub: id string frame Rect field FormField - label_layout BoxOrientation = .vertical - label_width f64 = 140 + label_layout LayoutOrientation = .vertical + label_width f64 = 140 label_align Align label_text_size f64 size ComponentSize = .medium diff --git a/ui/component_form_test.v b/ui/component_form_test.v index 69808954..9b658836 100644 --- a/ui/component_form_test.v +++ b/ui/component_form_test.v @@ -126,7 +126,7 @@ fn test_form_place_leaves_holes_and_gives_sections_their_own_row() { // As GPUI Kit's own test: label orientation and grid columns are independent. fn test_form_label_layout_is_independent_of_field_columns() { for width in [360.0, 800.0] { - for orientation in [BoxOrientation.horizontal, .vertical] { + for orientation in [LayoutOrientation.horizontal, .vertical] { config := FormConfig{ id: 'form' frame: rect(0, 0, width, 0) diff --git a/ui/component_lists_virtual.v b/ui/component_lists_virtual.v index 07aa84dc..25a993cc 100644 --- a/ui/component_lists_virtual.v +++ b/ui/component_lists_virtual.v @@ -355,7 +355,7 @@ pub: // on_scroll reports the id, and rebuild when range() changes. offset f64 overscan int = 3 - orientation BoxOrientation = .vertical + orientation LayoutOrientation = .vertical item VirtualListItemFn = unsafe { nil } transparent bool // Leave the background unpainted. accessibility_label string 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 new file mode 100644 index 00000000..1bb84011 --- /dev/null +++ b/ui/flex_layout.v @@ -0,0 +1,358 @@ +module ui2 + +pub enum FlexJustify { + start + center + end + space_between + space_around + space_evenly +} + +// 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 FlexChild { +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 LayoutAlignment = .auto +} + +pub struct FlexConfig { +pub: + id string + frame Rect + box BoxStyle + orientation LayoutOrientation + padding LayoutPadding + gap f64 + line_gap f64 = -1 + wrap bool + justify FlexJustify + align LayoutAlignment = .stretch + children []FlexChild +} + +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_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 !layout_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 !layout_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 += 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 !layout_finite(total_width) || !layout_finite(total_height) { + return error('flex layout total size exceeds the supported range') + } +} + +fn flex_main_bounds(child FlexChild, orientation LayoutOrientation) (f64, f64) { + return if orientation == .horizontal { + child.minimum_width, child.maximum_width + } else { + child.minimum_height, child.maximum_height + } +} + +fn flex_basis(child FlexChild, orientation LayoutOrientation) 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 layout_bound(preferred, minimum, maximum) +} + +fn flex_cross_size(child FlexChild, orientation LayoutOrientation, stretch f64) f64 { + if orientation == .horizontal { + return layout_bound(if stretch >= 0 { stretch } else { child.element.frame.height }, + child.minimum_height, child.maximum_height) + } + return layout_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 FlexConfig, 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 = layout_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 FlexConfig, 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 = layout_max(max_weight, if growing { child.grow } else { child.shrink }) + max_basis = layout_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] = layout_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 := layout_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_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_frames(config FlexConfig) ![]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 := 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 := 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 + }) + 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, layout_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_preferred_size reports the natural unwrapped size before growing +// or shrinking. The caller may measure text/content into element frames first. +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) + } else { + 0.0 + } + mut cross := f64(0) + for child in config.children { + main += flex_basis(child, config.orientation) + 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, + 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(config FlexConfig) !Element { + frames := flex_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..f49c7a69 --- /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_frames(FlexConfig{ + frame: rect(100, 200, 420, 80) + padding: LayoutPadding{ left: 10, top: 5, right: 10, bottom: 5 } + gap: 10 + children: [ + 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_frames(FlexConfig{ + frame: rect(0, 0, 150, 30) + children: [ + 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_frames(FlexConfig{ + frame: rect(0, 0, 225, 30) + children: [ + FlexChild{ element: flex_test_child(100, 10) }, + FlexChild{ 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_frames(FlexConfig{ + frame: rect(0, 0, 250, 30) + children: [ + 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 + 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_frames(FlexConfig{ + frame: rect(0, 0, 210, 200) + gap: 10 + line_gap: 7 + wrap: true + align: .center + children: [ + 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_frames(FlexConfig{ + frame: rect(0, 0, 100, 100) + gap: 8 + wrap: true + children: [ + 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_frames(FlexConfig{ + frame: rect(20, 20, 200, 130) + orientation: .vertical + padding: LayoutPadding{ left: 5, top: 10, right: 5, bottom: 10 } + gap: 10 + line_gap: 15 + wrap: true + align: .end + children: [ + 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 := FlexConfig{ + frame: rect(0, 0, 200, 80) + gap: 10 + align: .center + children: [ + 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_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_frames(FlexConfig{ + ...base + children: [FlexChild{ + 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_frames(FlexConfig{ + frame: rect(0, 0, 0, 0) + padding: LayoutPadding{ left: 10, top: 10, right: 10, bottom: 10 } + gap: 10 + justify: .center + children: [ + 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 := FlexConfig{ + id: 'flex' + frame: rect(20, 30, 300, 60) + padding: LayoutPadding{ left: 5, top: 6, right: 7, bottom: 8 } + gap: 10 + children: [ + FlexChild{ + element: button('a', 'Accept', rect(4, 8, 60, 20), BoxStyle{}, TextStyle{}) + basis: 80 + grow: 1 + }, + FlexChild{ + element: button('b', 'Cancel', rect(4, 8, 70, 30), BoxStyle{}, TextStyle{}) + grow: 1 + }, + ] + } + 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' + 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_frames(FlexConfig{})! == []Rect{} + assert flex_preferred_size(FlexConfig{})! == rect(0, 0, 0, 0) + for justify in [FlexJustify.space_around, .space_evenly] { + frames := flex_frames(FlexConfig{ + frame: rect(0, 0, 100, 40) + justify: justify + 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_frames(FlexConfig{ + frame: rect(0, 0, 50, 30) + children: [ + 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 := FlexConfig{ frame: rect(0, 0, 100, 40) } + invalid_configs := [ + 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_frames(config) { + assert false, 'invalid config must fail' + } + } + child := FlexChild{ element: flex_test_child(10, 10) } + invalid_children := [ + 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_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 06861698..77de0992 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 } -pub struct GridLayoutConfig { +// 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 GridConfig { 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 GridConfig, 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 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) + } 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 GridConfig, 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 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) + } + 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.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 } @@ -133,45 +394,109 @@ 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 { - if child_count < 0 { - return error('grid child count cannot be negative') - } - columns, rows := grid_dimensions(config.columns, config.rows, child_count)! +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)! 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.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} 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 creates a view whose children are assigned to cells in their +// 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_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_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)! + 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.0, (preferred - current) / f64(count)) +} + +// 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 f15ffea6..a40c77b7 100644 --- a/ui/grid_layout_test.v +++ b/ui/grid_layout_test.v @@ -1,7 +1,9 @@ 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') @@ -9,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') @@ -17,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 } @@ -33,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 @@ -48,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 @@ -78,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 @@ -93,3 +95,232 @@ 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 := GridConfig{ + frame: rect(0, 0, 310, 200) + auto_columns_min_width: 90 + padding: GridPadding{ left: 10, right: 10 } + spacing: GridSpacing{ horizontal: 10 } + } + 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_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_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 := GridConfig{ + frame: rect(0, 0, 800, 200) + auto_columns_min_width: 100 + } + frames := grid_frames(config, 2) or { panic(err) } + assert frames == [rect(0, 0, 400, 200), rect(400, 0, 400, 200)] + 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_frames(config, 0) or { panic(err) } + assert empty == []Rect{} +} + +fn test_grid_explicit_dimensions_take_precedence_over_automatic_columns() { + frames := grid_frames(GridConfig{ + 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_frames(GridConfig{ + 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_frames(GridConfig{ + 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_frames(GridConfig{ + 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_frames(GridConfig{ + 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_frames(GridConfig{ + 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_frames(GridConfig{ + 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 := GridConfig{ + frame: rect(0, 0, 300, 200) + auto_columns_min_width: 100 + child_spans: [GridSpan{ column_span: 2 }] + } + 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_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 [ + 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_frames(config, count) { + assert false, 'invalid fixed grid must fail' + } + } +} + +fn test_grid_rejects_non_finite_dimensions_and_invalid_spans() { + for config in [ + 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_frames(config, 1) { + assert false, 'invalid grid input must fail' + } + } +} + +fn test_grid_rejects_span_area_overflow_before_allocating_occupancy() { + if _ := grid_frames(GridConfig{ + 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 := GridConfig{ + 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_preferred_size(config, sizes) or { panic(err) } + assert wide == rect(0, 0, 310, 100) + 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_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 + } +} + +fn test_grid_preferred_size_respects_forced_tracks_and_minimums() { + preferred := grid_preferred_size(GridConfig{ + 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) +} + +fn test_automatic_grid_preserves_fractional_capacity_and_edges() { + 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_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_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 new file mode 100644 index 00000000..f3d6461b --- /dev/null +++ b/ui/layout_measure.v @@ -0,0 +1,242 @@ +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 LayoutPadding) !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 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()! + 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 := $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 + } + 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) 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 LayoutPadding{ 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 LayoutPadding{ + 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 { + LayoutPadding{ left: 12, right: if element.image_path.len > 0 { 36 } else { 12 }, top: 6, bottom: 6 } + } + .checkbox { LayoutPadding{ left: 26 } } + .dropdown { + LayoutPadding{ left: math.max(0, element.padding_left), right: 32, top: 6, bottom: 6 } + } + .text_field { + LayoutPadding{ left: math.max(0, element.padding_left), right: 8, top: 6, bottom: 6 } + } + .text_area { LayoutPadding{ left: 8, right: 8, top: 8, bottom: 8 } } + else { LayoutPadding{} } + } +} + +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) + }) +} + +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 new file mode 100644 index 00000000..287e7fbb --- /dev/null +++ b/ui/layout_measure_backend.c.v @@ -0,0 +1,35 @@ +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 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)! + $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) +} + +$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 new file mode 100644 index 00000000..08964907 --- /dev/null +++ b/ui/layout_measure_cpu.c.v @@ -0,0 +1,118 @@ +@[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, size 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 { + 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() } + 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)) + 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_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..8b322342 --- /dev/null +++ b/ui/layout_measure_darwin_test.v @@ -0,0 +1,23 @@ +// vtest build: macos && !ui2_custom_rendering? && !ui2_headless? +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..98b2f1ba --- /dev/null +++ b/ui/layout_measure_test.v @@ -0,0 +1,153 @@ +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() { + 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(LayoutPadding{ left: 20, right: 30, top: 50 })! == LayoutConstraints{ + min_width: 50 + max_width: 50 + max_height: 0 + } + if _ := tight.deflate(LayoutPadding{ 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 +} + +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_custom_test.v b/ui/ui_custom_test.v index 02b886b2..3f275740 100644 --- a/ui/ui_custom_test.v +++ b/ui/ui_custom_test.v @@ -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 + } } diff --git a/ui/ui_font.v b/ui/ui_font.v index 161a7031..4932db10 100644 --- a/ui/ui_font.v +++ b/ui/ui_font.v @@ -113,6 +113,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 3f54088f..46eda0f1 100644 --- a/ui/ui_scroll_immediate.c.v +++ b/ui/ui_scroll_immediate.c.v @@ -334,60 +334,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] { diff --git a/ui/vml.v b/ui/vml.v index 4cb4e450..b899e2ae 100644 --- a/ui/vml.v +++ b/ui/vml.v @@ -848,7 +848,7 @@ fn node_to_element_base(node &VNode, frame Rect) !Element { 'FloatLayout', 'RelativeLayout' { return v_float_layout(node, frame)! } - 'GridLayout' { + 'Grid' { return v_grid(node, frame)! } 'AnchorLayout' { @@ -1229,8 +1229,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() @@ -1331,10 +1331,10 @@ 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{ + return GridConfig{ id: node.id frame: frame box: v_box(node) @@ -1366,7 +1366,7 @@ fn v_grid(node &VNode, frame Rect) !Element { } } config := v_grid_config(node, rect(0, 0, frame.width, frame.height))! - frames := grid_layout_frames(config, visible.len)! + frames := grid_frames(config, visible.len)! mut children := []Element{cap: visible.len} for index, child in visible { children << node_to_element(child, frames[index])! @@ -1551,7 +1551,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) 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_model.v b/ui/vml_model.v index c4f1436e..a9fd2ca5 100644 --- a/ui/vml_model.v +++ b/ui/vml_model.v @@ -723,7 +723,7 @@ fn v_child_layout(node &VNode, actual Rect, metrics []VLayoutChildMetrics) !VChi 'Row' { VChildLayoutKind.row } 'BoxLayout' { VChildLayoutKind.box } 'FloatLayout', 'RelativeLayout' { VChildLayoutKind.float } - 'GridLayout' { VChildLayoutKind.grid } + 'Grid' { VChildLayoutKind.grid } 'AnchorLayout' { VChildLayoutKind.anchor } 'StackLayout' { VChildLayoutKind.stack } 'PageLayout' { VChildLayoutKind.page } @@ -777,7 +777,7 @@ 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)! + 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 { diff --git a/ui/vml_model_test.v b/ui/vml_model_test.v index b2a9c2c9..af949baa 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_test.v b/ui/vml_test.v index 7e7fbfaa..dc19e2a2 100644 --- a/ui/vml_test.v +++ b/ui/vml_test.v @@ -774,7 +774,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 @@ -793,7 +793,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')