From f9ae8a35cc242c7bd7d3f369f9f6c07f4ab7c539 Mon Sep 17 00:00:00 2001 From: GGRei Date: Sun, 12 Jul 2026 11:15:35 +0200 Subject: [PATCH] layout: add weighted child sizing --- _gc_lint_test.v | 2 + _layout_weighted_test.v | 981 +++++++++++++++++++++++++++++++++++++ docs/CONTAINERS.md | 56 +++ docs/LAYOUT_ALGORITHM.md | 162 ++++-- examples/weighted_layout.v | 38 ++ layout_sizing.v | 258 +++++++++- shape.v | 3 + view_weighted.v | 71 +++ 8 files changed, 1509 insertions(+), 62 deletions(-) create mode 100644 _layout_weighted_test.v create mode 100644 examples/weighted_layout.v create mode 100644 view_weighted.v diff --git a/_gc_lint_test.v b/_gc_lint_test.v index 67960439..c5c893b5 100644 --- a/_gc_lint_test.v +++ b/_gc_lint_test.v @@ -22,6 +22,8 @@ const clear_allowlist = [ ClearAllowEntry{'scratch_pools.v', 'out.clear()'}, ClearAllowEntry{'render_svg.v', 'out.clear()'}, ClearAllowEntry{'svg/animation.v', 'out.clear()'}, + // []f64 (no pointers) + ClearAllowEntry{'layout_sizing.v', 'scratch.weights.clear()'}, // []int ClearAllowEntry{'layout_sizing.v', 'fill_indices.clear()'}, ClearAllowEntry{'layout_sizing.v', 'fixed_indices.clear()'}, diff --git a/_layout_weighted_test.v b/_layout_weighted_test.v new file mode 100644 index 00000000..e9e86215 --- /dev/null +++ b/_layout_weighted_test.v @@ -0,0 +1,981 @@ +module gui + +import os +import time + +fn weighted_test_row(width f32, padding Padding, spacing f32, children []Layout) Layout { + return Layout{ + shape: &Shape{ + shape_type: .rectangle + axis: .left_to_right + sizing: fixed_fixed + width: width + height: 100 + padding: padding + spacing: spacing + } + children: children + } +} + +fn weighted_test_fill(weight f32) Layout { + return Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + main_axis_weight: weight + } + } +} + +fn test_weighted_row_and_column_distribution() { + mut row := weighted_test_row(400, padding_none, 0, [ + weighted_test_fill(1), + weighted_test_fill(1), + weighted_test_fill(2), + ]) + layout_parents(mut row, unsafe { nil }) + layout_fill_widths(mut row) + layout_fill_heights(mut row) + assert f32_are_close(row.children[0].shape.width, 100) + assert f32_are_close(row.children[1].shape.width, 100) + assert f32_are_close(row.children[2].shape.width, 200) + for child in row.children { + assert f32_are_close(child.shape.height, 100) + } + + mut column := Layout{ + shape: &Shape{ + shape_type: .rectangle + axis: .top_to_bottom + sizing: fixed_fixed + width: 100 + height: 400 + } + children: [weighted_test_fill(1), weighted_test_fill(1), + weighted_test_fill(2)] + } + layout_parents(mut column, unsafe { nil }) + layout_fill_widths(mut column) + layout_fill_heights(mut column) + for child in column.children { + assert f32_are_close(child.shape.width, 100) + } + assert f32_are_close(column.children[0].shape.height, 100) + assert f32_are_close(column.children[1].shape.height, 100) + assert f32_are_close(column.children[2].shape.height, 200) +} + +fn test_weighted_padding_spacing_implicit_fill_and_non_weighted_children() { + mut root := weighted_test_row(400, Padding{ + left: 10 + right: 20 + }, 10, [ + weighted_test_fill(2), + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + } + }, + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fit_fill + width: 40 + } + }, + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fixed_fill + width: 30 + } + }, + ]) + layout_parents(mut root, unsafe { nil }) + layout_fill_widths(mut root) + // 400 - padding(30) - spacing(30) - fit/fixed(70) leaves 270 for 2:1. + assert f32_are_close(root.children[0].shape.width, 180) + assert f32_are_close(root.children[1].shape.width, 90) + assert f32_are_close(root.children[2].shape.width, 40) + assert f32_are_close(root.children[3].shape.width, 30) +} + +fn test_weighted_min_max_clamp_and_zero_max_is_unbounded() { + mut clamped := weighted_test_row(400, padding_none, 0, [ + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + main_axis_weight: 1 + max_width: 80 + } + }, + weighted_test_fill(1), + weighted_test_fill(2), + ]) + layout_parents(mut clamped, unsafe { nil }) + layout_fill_widths(mut clamped) + assert f32_are_close(clamped.children[0].shape.width, 80) + assert f32_are_close(clamped.children[1].shape.width, f32(320) / 3) + assert f32_are_close(clamped.children[2].shape.width, f32(640) / 3) + + mut unbounded := weighted_test_row(100, padding_none, 0, [ + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + main_axis_weight: 1 + max_width: 0 + } + }, + weighted_test_fill(1), + ]) + layout_parents(mut unbounded, unsafe { nil }) + layout_fill_widths(mut unbounded) + assert f32_are_close(unbounded.children[0].shape.width, 50) + assert f32_are_close(unbounded.children[1].shape.width, 50) +} + +fn test_weighted_extreme_weight_recomputes_remaining_active_weight() { + mut root := weighted_test_row(101, padding_none, 0, [ + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + main_axis_weight: f32(1e20) + max_width: 1 + } + }, + weighted_test_fill(1), + weighted_test_fill(1), + ]) + layout_parents(mut root, unsafe { nil }) + layout_fill_widths(mut root) + + // The dominant candidate freezes at 1. The remaining budget is 100 and the + // two active weights are equal, regardless of precision lost in 1e20 + 1 + 1. + assert f32_are_close(root.children[0].shape.width, 1) + assert f32_are_close(root.children[1].shape.width, 50) + assert f32_are_close(root.children[2].shape.width, 50) +} + +fn test_weighted_unrealizable_residue_does_not_change_frozen_maximum() { + budget := f32(1_000_000_000) + maximum := f32(0.04) + mut root := weighted_test_row(budget, padding_none, 0, [ + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + main_axis_weight: 1 + max_width: maximum + } + }, + weighted_test_fill(1), + ]) + layout_parents(mut root, unsafe { nil }) + layout_fill_widths(mut root) + + // The free candidate's ULP exceeds the residue, so exact conservation is + // impossible without incorrectly changing the candidate frozen at max. + assert test_f32_close_with_tolerance(root.children[0].shape.width, maximum, f32(0.000001)) + assert root.children[1].shape.width == budget +} + +fn test_weighted_cascades_maximum_then_minimum_clamps() { + mut root := weighted_test_row(300, padding_none, 0, [ + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + main_axis_weight: 1 + min_width: 140 + } + }, + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + main_axis_weight: 1 + max_width: 40 + } + }, + weighted_test_fill(1), + ]) + layout_parents(mut root, unsafe { nil }) + layout_fill_widths(mut root) + + // lambda starts at 100: the maximum freezes at 40. Recomputing with 260 + // freezes the first minimum at 140, leaving 120 for the final candidate. + assert f32_are_close(root.children[0].shape.width, 140) + assert f32_are_close(root.children[1].shape.width, 40) + assert f32_are_close(root.children[2].shape.width, 120) +} + +fn test_weighted_budget_residue_and_rtl_leave_sizes_unchanged() { + mut ltr := weighted_test_row(401, padding_none, 0, [ + weighted_test_fill(1), + weighted_test_fill(1), + weighted_test_fill(2), + ]) + mut rtl := weighted_test_row(401, padding_none, 0, [ + weighted_test_fill(1), + weighted_test_fill(1), + weighted_test_fill(2), + ]) + rtl.shape.text_dir = .rtl + layout_parents(mut ltr, unsafe { nil }) + layout_parents(mut rtl, unsafe { nil }) + layout_fill_widths(mut ltr) + layout_fill_widths(mut rtl) + + assert f32_abs(ltr.children[0].shape.width + ltr.children[1].shape.width + + ltr.children[2].shape.width - 401) <= f32_tolerance + for i in 0 .. ltr.children.len { + assert f32_are_close(ltr.children[i].shape.width, rtl.children[i].shape.width) + } + + mut ltr_window := Window{} + mut rtl_window := Window{} + layout_positions(mut ltr, 0, 0, mut ltr_window) + layout_positions(mut rtl, 0, 0, mut rtl_window) + assert f32_are_close(ltr.children[0].shape.x, 0) + assert f32_are_close(ltr.children[1].shape.x, ltr.children[0].shape.width) + assert f32_are_close(rtl.children[0].shape.x, rtl.shape.width - rtl.children[0].shape.width) + assert f32_are_close(rtl.children[2].shape.x, 0) +} + +fn test_weighted_fractional_budget_assigns_residue_to_last_eligible_candidate() { + budget := f32(100.1) + mut root := weighted_test_row(budget, padding_none, 0, [ + weighted_test_fill(1), + weighted_test_fill(1), + weighted_test_fill(1), + ]) + layout_parents(mut root, unsafe { nil }) + layout_fill_widths(mut root) + + base := f32(f64(budget) / f64(3)) + expected_last := f32(f64(budget) - f64(2) * f64(base)) + assert root.children[0].shape.width == base + assert root.children[1].shape.width == base + assert expected_last != base + assert root.children[2].shape.width == expected_last + assert f32_abs(root.children[0].shape.width + root.children[1].shape.width + + root.children[2].shape.width - budget) <= f32_tolerance +} + +fn test_weighted_path_preserves_legacy_fill_distribution_without_weights() { + mut root := weighted_test_row(100, padding_none, 5, [ + Layout{ shape: &Shape{ shape_type: .rectangle, sizing: fixed_fill, width: 20 } }, + Layout{ shape: &Shape{ shape_type: .rectangle, sizing: fill_fill } }, + Layout{ shape: &Shape{ shape_type: .rectangle, sizing: fill_fill } }, + ]) + layout_parents(mut root, unsafe { nil }) + layout_fill_widths(mut root) + assert f32_are_close(root.children[1].shape.width, 35) + assert f32_are_close(root.children[2].shape.width, 35) +} + +fn test_weighted_feature_preserves_legacy_shrink_min_max_scroll_and_over_draw() { + mut shrink := weighted_test_row(100, padding_none, 0, [ + Layout{ shape: &Shape{ shape_type: .rectangle, sizing: fill_fill, width: 80 } }, + Layout{ shape: &Shape{ shape_type: .rectangle, sizing: fill_fill, width: 80 } }, + ]) + layout_parents(mut shrink, unsafe { nil }) + layout_fill_widths(mut shrink) + assert f32_are_close(shrink.children[0].shape.width, 50) + assert f32_are_close(shrink.children[1].shape.width, 50) + + mut maximum := weighted_test_row(200, padding_none, 0, [ + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + width: 20 + max_width: 40 + } + }, + Layout{ shape: &Shape{ shape_type: .rectangle, sizing: fill_fill, width: 20 } }, + ]) + layout_parents(mut maximum, unsafe { nil }) + layout_fill_widths(mut maximum) + assert f32_are_close(maximum.children[0].shape.width, 40) + assert f32_are_close(maximum.children[1].shape.width, 160) + + mut minimum := weighted_test_row(80, padding_none, 0, [ + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + width: 100 + min_width: 70 + } + }, + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + width: 100 + min_width: 10 + } + }, + ]) + layout_parents(mut minimum, unsafe { nil }) + layout_fill_widths(mut minimum) + assert f32_are_close(minimum.children[0].shape.width, 70) + assert f32_are_close(minimum.children[1].shape.width, 10) + + mut scroll := weighted_test_row(300, padding_none, 0, [ + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fixed_fill + width: 100 + } + }, + Layout{ + shape: &Shape{ + shape_type: .rectangle + axis: .top_to_bottom + sizing: fill_fill + width: 50 + id_scroll: 91 + } + }, + ]) + layout_parents(mut scroll, unsafe { nil }) + layout_fill_widths(mut scroll) + assert f32_are_close(scroll.children[1].shape.width, 200) + + mut over_draw := weighted_test_row(200, padding_none, 0, [ + Layout{ shape: &Shape{ shape_type: .rectangle, sizing: fill_fill } }, + Layout{ shape: &Shape{ shape_type: .rectangle, sizing: fill_fill } }, + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fixed_fill + width: 80 + over_draw: true + } + }, + ]) + layout_parents(mut over_draw, unsafe { nil }) + layout_fill_widths(mut over_draw) + // Preserve the historical legacy-path accounting of over_draw widths. + assert f32_are_close(over_draw.children[0].shape.width, 60) + assert f32_are_close(over_draw.children[1].shape.width, 60) +} + +@[heap] +struct WeightedTestView implements View { +mut: + content []View +} + +fn (mut view WeightedTestView) generate_layout(mut window Window) Layout { + return Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + } + } +} + +@[heap] +struct WeightedCustomParentView implements View { + width f32 + height f32 +mut: + content []View +} + +fn (mut view WeightedCustomParentView) generate_layout(mut _ Window) Layout { + return Layout{ + shape: &Shape{ + id: 'weighted-custom-parent' + shape_type: .rectangle + axis: .left_to_right + sizing: fixed_fixed + width: view.width + height: view.height + } + } +} + +@[heap] +struct WeightedPayloadView implements View { + id string + payload string +mut: + content []View +} + +fn (mut view WeightedPayloadView) generate_layout(mut _ Window) Layout { + return Layout{ + shape: &Shape{ + id: view.id + resource: view.payload + shape_type: .rectangle + sizing: fill_fill + } + } +} + +@[heap] +struct WeightedMutatingContentView implements View { + root_id string + injected_id string + generated_id string + payload string +mut: + content []View +} + +const weighted_gc_rebuild_iterations = 128 +const weighted_gc_payload_bytes = 64 * 1024 +const weighted_gc_max_growth = usize(3 * 1024 * 1024) + +fn weighted_collect_and_churn() { + gc_collect() + for _ in 0 .. 512 { + unsafe { + p := malloc(32) + vmemset(p, 0x5a, 32) + } + } + gc_collect() +} + +fn (mut view WeightedMutatingContentView) generate_layout(mut _ Window) Layout { + array_clear(mut view.content) + view.content = [ + View(WeightedPayloadView{ + id: view.generated_id + payload: view.payload + }), + ] + return Layout{ + shape: &Shape{ + id: view.root_id + shape_type: .rectangle + sizing: fill_fill + } + children: [ + Layout{ + shape: &Shape{ + id: view.injected_id + shape_type: .rectangle + sizing: fill_fill + } + }, + ] + } +} + +fn test_weighted_wrapper_delegates_custom_view_without_extra_layout_node_and_clears() { + mut view := weighted(WeightedCfg{ + weight: 2 + view: WeightedTestView{ + content: [rectangle(sizing: fill_fill)] + } + }) + mut window := Window{} + mut layout := generate_layout(mut view, mut window) + assert f32_are_close(layout.shape.main_axis_weight, 2) + assert layout.children.len == 1 + assert layout.children[0].shape.shape_type == .rectangle + view_clear(mut view) + assert view.content.len == 0 + layout_clear(mut layout) +} + +fn test_weighted_custom_parent_uses_declared_main_axis() { + mut view := View(WeightedCustomParentView{ + width: 300 + height: 80 + content: [ + weighted(weight: 1, view: rectangle(sizing: fill_fill)), + weighted(weight: 2, view: rectangle(sizing: fill_fill)), + ] + }) + mut window := Window{} + mut layout := generate_layout(mut view, mut window) + layout_parents(mut layout, unsafe { nil }) + layout_fill_widths(mut layout) + assert layout.shape.id == 'weighted-custom-parent' + assert f32_are_close(layout.children[0].shape.width, 100) + assert f32_are_close(layout.children[1].shape.width, 200) + view_clear(mut view) + layout_clear(mut layout) +} + +fn test_weighted_wrapper_transfers_mutated_content_and_preserves_injected_layout() { + mut view := weighted( + weight: 2 + view: WeightedMutatingContentView{ + root_id: 'delegated-root' + injected_id: 'pre-injected-layout' + generated_id: 'generated-content' + payload: 'lifecycle-payload' + content: [rectangle(id: 'stale-content', sizing: fill_fill)] + } + ) + mut window := Window{} + mut layout := generate_layout(mut view, mut window) + assert layout.shape.id == 'delegated-root' + assert f32_are_close(layout.shape.main_axis_weight, 2) + assert layout.children.len == 2 + assert layout.children[0].shape.id == 'pre-injected-layout' + assert layout.children[1].shape.id == 'generated-content' + assert layout.children[1].shape.resource == 'lifecycle-payload' + assert view.content.len == 1 + assert view.content[0] is WeightedPayloadView + view_clear(mut view) + assert view.content.len == 0 + layout_clear(mut layout) +} + +fn test_weighted_rebuilds_do_not_retain_decorated_views_or_content() { + mut window := Window{} + weighted_collect_and_churn() + baseline := gc_memory_use() + + for i in 0 .. weighted_gc_rebuild_iterations { + mut view := row( + width: 100 + height: 40 + sizing: fixed_fixed + content: [ + weighted( + weight: 1 + view: WeightedMutatingContentView{ + root_id: 'gc-root' + injected_id: 'gc-injected' + generated_id: 'gc-generated' + payload: 'x'.repeat(weighted_gc_payload_bytes) + i.str() + content: [rectangle(id: 'gc-stale', sizing: fill_fill)] + } + ), + ] + ) + mut layout := generate_layout(mut view, mut window) + layout_parents(mut layout, unsafe { nil }) + layout_fill_widths(mut layout) + assert layout.children.len == 1 + assert layout.children[0].shape.id == 'gc-root' + assert layout.children[0].children.len == 2 + assert layout.children[0].children[1].shape.resource.len >= weighted_gc_payload_bytes + assert view.content.len == 1 + assert view.content[0] is WeightedView + assert view.content[0].content.len == 1 + assert view.content[0].content[0] is WeightedPayloadView + // Reading the old zeroed []View backing as an interface would be invalid. + // view_weighted.v's array_clear call covers that byte-level invariant + // statically; the typed transfer above and bounded live memory cover the + // observable ownership and retention contract without UB. + view_clear(mut view) + assert view.content.len == 0 + layout_clear(mut layout) + if i % 16 == 0 { + weighted_collect_and_churn() + } + } + + weighted_collect_and_churn() + after := gc_memory_use() + growth := if after > baseline { after - baseline } else { usize(0) } + assert growth < weighted_gc_max_growth +} + +fn test_weighted_public_text_view_keeps_ratio() { + mut window := Window{} + mut view := row( + width: 400 + height: 100 + sizing: fixed_fixed + spacing: 0 + content: [ + weighted(weight: 1, view: text(text: 'one', min_width: 20)), + weighted(weight: 1, view: text(text: 'two', min_width: 80)), + weighted(weight: 2, view: text(text: 'four', min_width: 140)), + ] + ) + mut layout := generate_layout(mut view, mut window) + assert layout.children[0].shape.width >= 20 + assert layout.children[1].shape.width >= 80 + assert layout.children[2].shape.width >= 140 + assert layout.children[0].shape.width < layout.children[1].shape.width + assert layout.children[1].shape.width < layout.children[2].shape.width + layout_parents(mut layout, unsafe { nil }) + layout_fill_widths(mut layout) + assert f32_are_close(layout.children[0].shape.width, 100), 'widths: ${layout.children[0].shape.width}, ${layout.children[1].shape.width}, ${layout.children[2].shape.width}' + assert f32_are_close(layout.children[1].shape.width, 100), 'widths: ${layout.children[0].shape.width}, ${layout.children[1].shape.width}, ${layout.children[2].shape.width}' + assert f32_are_close(layout.children[2].shape.width, 200), 'widths: ${layout.children[0].shape.width}, ${layout.children[1].shape.width}, ${layout.children[2].shape.width}' +} + +fn test_weighted_headless_synthetic_children_do_not_participate() { + mut layout := weighted_test_row(300, padding_none, 0, [ + // Headless stand-in for the floating title/tooltip layouts. + Layout{ + shape: &Shape{ + id: 'title-tooltip-overlay' + shape_type: .rectangle + float: true + width: 60 + } + }, + weighted_test_fill(1), + weighted_test_fill(2), + // Headless stand-ins for horizontal and vertical scrollbars. + Layout{ + shape: &Shape{ + id: 'horizontal-scrollbar' + shape_type: .rectangle + scrollbar_orientation: .horizontal + over_draw: true + width: 12 + } + }, + Layout{ + shape: &Shape{ + id: 'vertical-scrollbar' + shape_type: .rectangle + scrollbar_orientation: .vertical + over_draw: true + width: 12 + } + }, + ]) + layout_parents(mut layout, unsafe { nil }) + layout_fill_widths(mut layout) + + mut float_count := 0 + mut over_draw_count := 0 + mut weighted_indices := []int{} + for i, child in layout.children { + if child.shape.float { + float_count++ + } + if child.shape.over_draw { + over_draw_count++ + } + if child.shape.main_axis_weight > 0 { + weighted_indices << i + } + } + assert float_count == 1 + assert over_draw_count == 2 + assert weighted_indices.len == 2 + assert f32_are_close(layout.children[weighted_indices[0]].shape.width, 100) + assert f32_are_close(layout.children[weighted_indices[1]].shape.width, 200) + assert f32_are_close(layout.children[0].shape.width, 60) + assert f32_are_close(layout.children[3].shape.width, 12) + assert f32_are_close(layout.children[4].shape.width, 12) + layout_clear(mut layout) +} + +fn test_weighted_constraints_and_vertical_max_zero() { + mut root := weighted_test_row(100, padding_none, 0, [ + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + main_axis_weight: 1 + min_width: 60 + } + }, + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + main_axis_weight: 1 + max_width: 20 + } + }, + ]) + layout_parents(mut root, unsafe { nil }) + layout_fill_widths(mut root) + assert f32_are_close(root.children[0].shape.width, 80) + assert f32_are_close(root.children[1].shape.width, 20) + mut column := Layout{ + shape: &Shape{ + shape_type: .rectangle + axis: .top_to_bottom + sizing: fixed_fixed + width: 100 + height: 80 + } + children: [ + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + main_axis_weight: 1 + max_height: 0 + } + }, + ] + } + layout_parents(mut column, unsafe { nil }) + layout_fill_heights(mut column) + assert f32_are_close(column.children[0].shape.height, 80) +} + +fn test_weighted_resize_nested_circle_recalculates_ratio() { + mut root := weighted_test_row(300, padding_none, 0, [ + weighted_test_fill(1), + Layout{ + shape: &Shape{ + shape_type: .circle + axis: .top_to_bottom + sizing: fill_fill + } + children: [ + weighted_test_fill(1), + weighted_test_fill(2), + ] + }, + ]) + layout_parents(mut root, unsafe { nil }) + layout_fill_widths(mut root) + layout_fill_heights(mut root) + assert f32_are_close(root.children[0].shape.width, 150) + assert f32_are_close(root.children[1].children[0].shape.height, f32(100) / 3) + assert f32_are_close(root.children[1].children[1].shape.height, f32(200) / 3) + root.shape.width = 600 + layout_fill_widths(mut root) + assert f32_are_close(root.children[0].shape.width, 300) +} + +fn test_weighted_legacy_wrap_and_overflow_do_not_enter_weighted_path() { + mut root := weighted_test_row(100, padding_none, 5, [ + Layout{ shape: &Shape{ shape_type: .rectangle, sizing: fill_fill } }, + Layout{ shape: &Shape{ shape_type: .rectangle, sizing: fill_fill } }, + ]) + root.shape.wrap = true + root.shape.overflow = true + layout_parents(mut root, unsafe { nil }) + layout_fill_widths(mut root) + assert f32_are_close(root.children[0].shape.width, 47.5) + assert f32_are_close(root.children[1].shape.width, 47.5) +} + +fn test_weighted_infeasible_minima_are_preserved() { + mut root := weighted_test_row(100, padding_none, 0, [ + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + main_axis_weight: 1 + min_width: 80 + } + }, + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + main_axis_weight: 1 + min_width: 80 + } + }, + ]) + layout_parents(mut root, unsafe { nil }) + layout_fill_widths(mut root) + assert f32_are_close(root.children[0].shape.width, 80) + assert f32_are_close(root.children[1].shape.width, 80) +} + +fn test_weighted_all_maxima_saturated_leave_surplus_space() { + mut root := weighted_test_row(400, padding_none, 0, [ + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + main_axis_weight: 1 + max_width: 50 + } + }, + Layout{ + shape: &Shape{ + shape_type: .rectangle + sizing: fill_fill + main_axis_weight: 1 + max_width: 70 + } + }, + ]) + layout_parents(mut root, unsafe { nil }) + layout_fill_widths(mut root) + assert f32_are_close(root.children[0].shape.width, 50) + assert f32_are_close(root.children[1].shape.width, 70) +} + +fn test_weighted_contract_panics_in_subprocess() { + token := '${os.getpid()}_${time.now().unix_micro()}' + probe_source := os.join_path(os.dir(@FILE), '_weighted_contract_${token}_test.v') + mut probe_binary := os.join_path(os.dir(@FILE), '_weighted_contract_${token}') + $if windows { + probe_binary += '.exe' + } + defer { + os.rm(probe_source) or {} + os.rm(probe_binary) or {} + } + os.write_file(probe_source, weighted_contract_probe) or { + assert false, err.msg() + return + } + mut prod_flag := '' + $if prod { + prod_flag = '-prod' + } + mut subsystem_flag := '' + $if windows { + subsystem_flag = '-subsystem console' + } + compile_result := + os.execute('${os.quoted_path(@VEXE)} ${prod_flag} ${subsystem_flag} -cc ${os.quoted_path(@CCOMPILER)} -no-parallel -no-retry-compilation -skip-running -run-only test_weighted_contract_dispatch -o ${os.quoted_path(probe_binary)} ${os.quoted_path(probe_source)}') + assert compile_result.exit_code == 0, 'probe compile exit_code=${compile_result.exit_code} output=${compile_result.output}' + cases := [ + ['test_weighted_zero', 'gui.weighted: weight must be finite and greater than zero'], + ['test_weighted_negative', 'gui.weighted: weight must be finite and greater than zero'], + ['test_weighted_nan', 'gui.weighted: weight must be finite and greater than zero'], + ['test_weighted_inf', 'gui.weighted: weight must be finite and greater than zero'], + ['test_weighted_negative_inf', 'gui.weighted: weight must be finite and greater than zero'], + ['test_weighted_double', 'gui.weighted: a view cannot be weighted more than once'], + ['test_weighted_root', 'gui.weighted: weighted view requires a parent with a main axis'], + ['test_weighted_float', 'gui.weighted: decorated view must participate in normal layout flow'], + ['test_weighted_none', 'gui.weighted: decorated view must participate in normal layout flow'], + ['test_weighted_over_draw', + 'gui.weighted: decorated view must participate in normal layout flow'], + ['test_weighted_fixed', + 'gui.weighted: weighted child cannot use fixed sizing on its parent main axis'], + ['test_weighted_parent_none', + 'gui.weighted: weighted children require a parent with a main axis'], + ['test_weighted_wrap', + 'gui.weighted: weighted children are not supported in wrap or overflow containers'], + ['test_weighted_overflow', + 'gui.weighted: weighted children are not supported in wrap or overflow containers'], + ] + for i, case in cases { + result := os.execute('${os.quoted_path(probe_binary)} ${i}') + assert result.exit_code != 0, '${case[0]} exit_code=${result.exit_code} output=${result.output}' + assert result.output.contains(case[1]), '${case[0]} exit_code=${result.exit_code} output=${result.output}' + } +} + +const weighted_contract_probe = r'module gui + +import math +import os + +struct ContractProbeView implements View { +mut: + content []View + is_float bool + shape_type ShapeType + over_draw bool + sizing Sizing +} + +fn (mut view ContractProbeView) generate_layout(mut _ Window) Layout { + return Layout{shape: &Shape{float: view.is_float, shape_type: view.shape_type, over_draw: view.over_draw, sizing: view.sizing}} +} + +fn contract_layout(mut layout Layout) { + layout_parents(mut layout, unsafe { nil }) + layout_fill_widths(mut layout) +} + +fn weighted_probe(weight f32) View { + return weighted(WeightedCfg{weight: weight, view: rectangle(sizing: fill_fill)}) +} + +fn weighted_probe_row(child View) Layout { + mut window := Window{} + mut view := row(width: 100, height: 100, sizing: fixed_fixed, content: [child]) + return generate_layout(mut view, mut window) +} + +fn probe_weighted_zero() { _ = weighted_probe(0) } +fn probe_weighted_negative() { _ = weighted_probe(-1) } +fn probe_weighted_nan() { _ = weighted_probe(f32(math.nan())) } +fn probe_weighted_inf() { _ = weighted_probe(f32(math.inf(1))) } +fn probe_weighted_negative_inf() { _ = weighted_probe(f32(math.inf(-1))) } + +fn probe_weighted_double() { + _ = weighted(weight: 1, view: weighted(weight: 1, view: rectangle(sizing: fill_fill))) +} + +fn probe_weighted_root() { + mut window := Window{} + mut view := weighted_probe(1) + mut layout := generate_layout(mut view, mut window) + contract_layout(mut layout) +} + +fn probe_weighted_float() { + mut layout := weighted_probe_row(weighted(WeightedCfg{weight: 1, view: ContractProbeView{is_float: true, shape_type: .rectangle, sizing: fill_fill}})) + contract_layout(mut layout) +} + +fn probe_weighted_none() { + mut layout := weighted_probe_row(weighted(WeightedCfg{weight: 1, view: ContractProbeView{shape_type: .none, sizing: fill_fill}})) + contract_layout(mut layout) +} + +fn probe_weighted_over_draw() { + mut layout := weighted_probe_row(weighted(WeightedCfg{weight: 1, view: ContractProbeView{over_draw: true, shape_type: .rectangle, sizing: fill_fill}})) + contract_layout(mut layout) +} + +fn probe_weighted_fixed() { + mut layout := weighted_probe_row(weighted_probe(1)) + layout.children[0].shape.sizing = fixed_fill + contract_layout(mut layout) +} + +fn probe_weighted_parent_none() { + mut layout := weighted_probe_row(weighted_probe(1)) + layout.shape.axis = .none + contract_layout(mut layout) +} + +fn probe_weighted_wrap() { + mut layout := weighted_probe_row(weighted_probe(1)) + layout.shape.wrap = true + contract_layout(mut layout) +} + +fn probe_weighted_overflow() { + mut layout := weighted_probe_row(weighted_probe(1)) + layout.shape.overflow = true + contract_layout(mut layout) +} + +fn test_weighted_contract_dispatch() { + if os.args.len < 2 { + exit(90) + } + match os.args[1].int() { + 0 { probe_weighted_zero() } + 1 { probe_weighted_negative() } + 2 { probe_weighted_nan() } + 3 { probe_weighted_inf() } + 4 { probe_weighted_negative_inf() } + 5 { probe_weighted_double() } + 6 { probe_weighted_root() } + 7 { probe_weighted_float() } + 8 { probe_weighted_none() } + 9 { probe_weighted_over_draw() } + 10 { probe_weighted_fixed() } + 11 { probe_weighted_parent_none() } + 12 { probe_weighted_wrap() } + 13 { probe_weighted_overflow() } + else { exit(91) } + } +} +' diff --git a/docs/CONTAINERS.md b/docs/CONTAINERS.md index 65cf83c7..c249f3f1 100644 --- a/docs/CONTAINERS.md +++ b/docs/CONTAINERS.md @@ -112,6 +112,60 @@ gui.column( ) ``` +## Weighted Children + +Use `gui.weighted` to assign a direct child a proportional share of its +parent's main-axis space. The main axis is horizontal for `row` and vertical +for `column` and `circle`. + +```v ignore +gui.row( + sizing: gui.fill_fill + spacing: 8 + content: [ + gui.weighted(weight: 1, view: gui.text(text: 'One quarter')), + gui.weighted(weight: 1, view: gui.text(text: 'One quarter')), + gui.weighted(weight: 2, view: gui.text(text: 'One half')), + ] +) +``` + +The three text views use their default `.fit` sizing, but receive `1:1:2` of +the distributable width: 25%, 25%, and 50%. Padding, spacing, and the final +sizes of non-participating children are reserved before that space is divided. +The proportions are recalculated whenever the parent is resized. + +An explicit weight activates weighted distribution for that parent. Decorated +`.fit` and `.fill` children participate with their configured weights, and an +undecorated `.fill` sibling participates with an implicit weight of `1`. +Undecorated `.fit` and `.fixed` siblings keep their constrained sizes and do +not participate. Without an explicit weight, the existing fill sizing path is +unchanged. + +Weighted sizes are final main-axis sizes, not growth deltas. Each participating +child receives `clamp(lambda * weight, min, max)`, with constrained space +redistributed among the remaining participants. A zero maximum means no +maximum. If minima cannot fit, the children keep their minima and overflow; +use the existing `clip` or scrolling options when containment is required. If +all maxima are reached, normal parent alignment handles the surplus. Cross-axis +sizing, alignment, RTL positioning, nesting, clipping, and scrolling keep their +existing behavior. + +Weights must be finite and greater than zero. The following configurations +panic instead of silently ignoring or changing a weight: + +- zero, negative, NaN, or infinite weights; +- decorating a view more than once; +- using `gui.weighted(...)` as the root view rather than as a child; +- a decorated root that is floating, `.none`, or `over_draw`; +- a decorated child whose main-axis sizing is `.fixed`; +- a weighted child of a parent without a main axis, such as `canvas`; +- a weighted group directly inside a `wrap` or overflow parent. + +A `wrap` or overflow container may itself be weighted when it is a child of a +normal row or column. The restriction applies only to a weighted group directly +managed by that container. + ## Alignment `h_align` controls alignment **along** the horizontal axis. @@ -434,6 +488,7 @@ gui.column(id: 'panel', hero: true, content: [...]) - `view_container.v` — `ContainerCfg`, `column`, `row`, `wrap`, `canvas`, `circle` +- `view_weighted.v` — `WeightedCfg`, `weighted` - `sizing.v` — `Sizing`, `SizingType`, preset constants - `alignment.v` — `Axis`, `HorizontalAlign`, `VerticalAlign` - `padding.v` — `Padding`, `padding()`, `pad_all()`, `pad_tblr()` @@ -451,3 +506,4 @@ gui.column(id: 'panel', hero: true, content: [...]) - `examples/column_scroll.v` — scrollable column with 10,000-item list - `examples/overflow_panel_demo.v` — responsive toolbar with overflow trigger menu +- `examples/weighted_layout.v` — resizable `1:1:2` weighted button layout diff --git a/docs/LAYOUT_ALGORITHM.md b/docs/LAYOUT_ALGORITHM.md index f675d50d..0fa9b411 100644 --- a/docs/LAYOUT_ALGORITHM.md +++ b/docs/LAYOUT_ALGORITHM.md @@ -109,22 +109,29 @@ on each extracted floating layout independently. ### Pipeline Steps -| # | Function | Purpose | -|------|--------------------------------|------------------------------| -| 1 | `layout_widths` | Intrinsic widths | -| 2 | `layout_fill_widths` | Fill-distribute widths | -| 3 | `layout_wrap_text` | Text wrapping | -| 4 | `layout_heights` | Intrinsic heights | -| 5 | `layout_fill_heights` | Fill-distribute heights | -| 6 | `layout_adjust_scroll_offsets` | Clamp scroll offsets | -| 7 | `layout_positions` | X, Y positioning | -| 8 | `layout_disables` | Propagate disabled state | -| 9 | `layout_scroll_containers` | Tag text scroll parents | -| 10 | `layout_amend` | Post-layout callbacks | -| 11a | `apply_layout_transition` | Animate layout changes | -| 11b | `apply_hero_transition` | Animate hero elements | -| 12 | `layout_set_shape_clips` | Compute clipping rectangles | -| 13 | `layout_hover` | Update hover states | +| # | Function | Purpose | +|------|--------------------------------|---------------------------------| +| 1 | `layout_widths` | Intrinsic widths | +| 2 | `layout_fill_widths` | Fill/weighted width distribution| +| 3 | `layout_wrap_containers` | Wrap container line breaking | +| 4 | `layout_overflow` | Overflow item visibility | +| 5 | `layout_wrap_text` | Text wrapping | +| 6 | `layout_heights` | Intrinsic heights | +| 7 | `layout_fill_heights` | Fill/weighted height distribution| +| 8 | `layout_adjust_scroll_offsets` | Clamp scroll offsets | +| 9 | `layout_positions` | X, Y positioning | +| 10 | `layout_disables` | Propagate disabled state | +| 11 | `layout_scroll_containers` | Tag text scroll parents | +| 12 | `layout_amend` | Post-layout callbacks | +| 13a | `apply_layout_transition` | Animate layout changes | +| 13b | `apply_hero_transition` | Animate hero elements | +| 14 | `layout_set_shape_clips` | Compute clipping rectangles | + +`layout_hover` is not part of the numbered `layout_pipeline` steps. After +`layout_pipeline` has completed for the main tree and every floating layer, +`layout_arrange` calls `layout_hover` in reverse layer order. The topmost layer +therefore receives hover first, and a floating layer under the pointer blocks +hover processing for layers beneath it. ### Why Multiple Passes? @@ -155,23 +162,35 @@ Walk the tree bottom-up. For each container: child plus padding. - Clamp to min/max constraints. -**Step 2 — Fill-Distribute Widths** (`layout_fill_widths`). +**Step 2 — Fill/Weighted Width Distribution** (`layout_fill_widths`). Walk top-down. For each container whose axis is `.left_to_right`: -1. Compute remaining width = container width − padding − spacing - − sum of children widths. -2. If remaining > 0, grow `.fill` children. If remaining < 0, - shrink them. (See "Fill Distribution" below.) +1. If at least one child has an explicit weight, distribute final widths + proportionally among weighted children and implicit-weight `.fill` + children. (See "Weighted Main-Axis Distribution" below.) +2. Otherwise, compute remaining width = container width − padding − spacing + − sum of children widths. Grow or shrink `.fill` children using the legacy + path. (See "Legacy Fill Distribution" below.) For `.top_to_bottom` containers: each `.fill` child gets the container's content width (minus padding), clamped to min/max. -**Step 3 — Text Wrapping** (`layout_wrap_text`). +**Step 3 — Wrap Containers** (`layout_wrap_containers`). +Wrap-enabled rows are split into lines after their widths are known. A weighted +group directly managed by a wrap container is rejected before distribution; +a wrap container may still be weighted as a child of another parent. + +**Step 4 — Overflow Visibility** (`layout_overflow`). +Overflow panels determine which items fit after width sizing. A weighted group +directly managed by an overflow parent is rejected before distribution; the +overflow container itself may be weighted by an outer compatible parent. + +**Step 5 — Text Wrapping** (`layout_wrap_text`). Walk the tree. For each text shape, wrap its content to fit the now-known width. Wrapping changes the shape's minimum height, which is why this runs between width and height passes. -**Step 4 — Intrinsic Heights** (`layout_heights`). +**Step 6 — Intrinsic Heights** (`layout_heights`). Same logic as Step 1 but on the vertical axis: - `.top_to_bottom` (along-axis): sum children heights + spacing + @@ -180,15 +199,16 @@ Same logic as Step 1 but on the vertical axis: - Special case: a `.fill`-height scroll container gets a small minimum height so it can shrink freely. -**Step 5 — Fill-Distribute Heights** (`layout_fill_heights`). -Same logic as Step 2 but on the vertical axis. +**Step 7 — Fill/Weighted Height Distribution** (`layout_fill_heights`). +Same logic as Step 2 but on the vertical axis. Weighted distribution applies +to `.top_to_bottom` parents; the cross axis continues to use existing sizing. -**Step 6 — Adjust Scroll Offsets** (`layout_adjust_scroll_offsets`). +**Step 8 — Adjust Scroll Offsets** (`layout_adjust_scroll_offsets`). For each scroll container, clamp scroll offsets so they stay within the valid range (0 to content overflow). This handles cases where a window resize makes the current offset invalid. -**Step 7 — Positions** (`layout_positions`). +**Step 9 — Positions** (`layout_positions`). Walk top-down. For each child: 1. Start at parent position + padding. @@ -203,45 +223,47 @@ Floating layouts get their starting position from `float_attach_layout`, which computes coordinates from the parent's anchor point and the float's tie-off point, plus any offset. -**Step 8 — Disable Propagation** (`layout_disables`). +**Step 10 — Disable Propagation** (`layout_disables`). Walk the tree. If a parent is disabled, mark all descendants disabled. -**Step 9 — Scroll Container Tags** (`layout_scroll_containers`). +**Step 11 — Scroll Container Tags** (`layout_scroll_containers`). Walk the tree. For each text shape, record the nearest ancestor scroll container's `id_scroll`. This allows text selection to auto-scroll the correct parent. -**Step 10 — Layout Amendments** (`layout_amend`). +**Step 12 — Layout Amendments** (`layout_amend`). Walk bottom-up. Call each shape's `amend_layout` callback if set. These callbacks can adjust appearance after final positions are known (e.g., showing hover highlights). They should not change sizes. -**Step 11a — Layout Transitions** (`apply_layout_transition`). +**Step 13a — Layout Transitions** (`apply_layout_transition`). If a layout transition animation is active, interpolate each shape's position/size between its previous and current values. -**Step 11b — Hero Transitions** (`apply_hero_transition`). +**Step 13b — Hero Transitions** (`apply_hero_transition`). If a hero transition animation is active, interpolate matching hero-tagged shapes between their old and new positions. -**Step 12 — Clipping Rectangles** (`layout_set_shape_clips`). +**Step 14 — Clipping Rectangles** (`layout_set_shape_clips`). Walk top-down. Each shape's clip rectangle is the intersection of its own bounds with its parent's clip. This produces the visible region used for hit testing and draw culling. -**Step 13 — Hover States** (`layout_hover`). -Walk children first (front-to-back priority). For each shape with -an `on_hover` handler: if the mouse is inside the shape's clip -rectangle, call the handler. Stop after the first shape handles -the event. +**Post-pipeline hover phase** (`layout_hover`). +As part of `layout_arrange`, walk layers from topmost to bottommost and children +first within each layer. For each shape with an `on_hover` handler, call the +handler when the mouse is inside its clip rectangle. Stop within a tree after +the first shape handles the event, and do not visit lower layers when a floating +layout contains the pointer. -### Fill Distribution Strategy +### Legacy Fill Distribution Strategy The `distribute_space` function handles both growing and shrinking of `.fill`-sized children. The approach equalizes children -incrementally: +incrementally. This path is unchanged and is used when the parent has no +explicitly weighted child: **Growing** (remaining space > 0): @@ -265,6 +287,59 @@ incrementally: This strategy prevents any single child from becoming much larger or smaller than its siblings, producing visually balanced layouts. +### Weighted Main-Axis Distribution + +`gui.weighted(weight:, view:)` transparently annotates the root `Shape` +generated by a child view. It does not add a `Layout` node. During view +generation, the wrapper delegates root generation to the decorated view, +transfers that view's possibly updated `content`, zeros its temporary interface +slot with `array_clear`, and lets the outer generic traversal generate those +children exactly once. Existing children already injected into the delegated +root are preserved. + +A parent enters the weighted path only when at least one direct child has an +explicit main-axis weight. Candidates are explicitly weighted `.fit` or `.fill` +children plus undecorated `.fill` children with an implicit weight of `1`. +Undecorated `.fit` and `.fixed` children are non-participants whose constrained +sizes are reserved in the budget. + +For each candidate, the final main-axis size is: + +```text +size_i = clamp(lambda * weight_i, min_i, max_i) +``` + +A maximum of zero means unbounded. The solver computes the parent content +budget after padding, spacing, and non-participant sizes, then repeatedly: + +1. computes `lambda` from the remaining budget and active weight sum; +2. sums the signed violations `clamp(target_i) - target_i`; +3. freezes minimum violators when that sum is positive, or maximum violators + when it is negative; a near-zero sum means the clamped targets already + preserve the budget within tolerance; +4. removes frozen candidates, recomputes the active weight sum, and repeats + until stable. + +If the minima exceed the budget, candidates retain their minima and overflow +geometrically; clipping or scrolling is never enabled implicitly. If every +maximum is reached, unused space is left to parent alignment. Calculations use +`f64` and final dimensions use `f32`. Conversion residue is corrected in reverse +declaration order among candidates that remain active and unclamped; candidates +frozen at a minimum or maximum are never moved. If the residue is smaller than +the ULP of every active candidate, it remains as representation error rather +than breaking a bound or a clamped solution. RTL changes positions, not that +order or the resulting sizes. Candidate and constraint buffers live in +`DistributeScratch`, so distribution allocates no per-frame memory after scratch +growth. + +Validation occurs before distribution. An explicit weight must be positive and +finite and cannot be applied twice. A weighted root, a weighted child under an +`.none`-axis parent, and a weighted group directly under wrap or overflow are +invalid. A decorated root cannot be floating, `.none`, or `over_draw`, and a +participant cannot be `.fixed` on the parent's main axis. These cases panic +instead of ignoring the weight. In the absence of explicit weights, all legacy +sizing behavior and ordering remain unchanged. + ## Floating Layouts Floating layouts (tooltips, dropdowns, dialogs) are removed from @@ -293,14 +368,14 @@ value. Scroll state is stored in the window's `ViewState`: - `scroll_x[id_scroll]` — horizontal offset - `scroll_y[id_scroll]` — vertical offset -The scroll offset shifts child positions (Step 7) but does not -change the container's own size. Step 6 clamps offsets to prevent +The scroll offset shifts child positions (Step 9) but does not +change the container's own size. Step 8 clamps offsets to prevent scrolling past content bounds. Scroll containers automatically enable clipping so children outside the viewport are not drawn. ## Layout Amendments -The `amend_layout` callback on a shape runs in Step 10, after all +The `amend_layout` callback on a shape runs in Step 12, after all positions and sizes are final. It receives the layout and window, and can modify appearance properties (color, visibility, decorations). It should not change sizes, as the size passes have @@ -315,6 +390,7 @@ tooltip placement adjustments. parent/float extraction - `layout_sizing.v` — `layout_widths`, `layout_heights`, `layout_fill_widths`, `layout_fill_heights`, `distribute_space` +- `view_weighted.v` — transparent child weighting decorator - `layout_position.v` — `layout_positions`, `layout_disables`, `layout_scroll_containers`, `layout_amend`, `layout_hover`, `layout_set_shape_clips`, `layout_wrap_text`, diff --git a/examples/weighted_layout.v b/examples/weighted_layout.v new file mode 100644 index 00000000..a8bea71b --- /dev/null +++ b/examples/weighted_layout.v @@ -0,0 +1,38 @@ +module main + +import gui + +fn main_view(window &gui.Window) gui.View { + width, height := window.window_size() + return gui.row( + width: width + height: height + sizing: gui.fixed_fixed + content: [ + gui.weighted( + weight: 1 + view: gui.button(content: [gui.text(text: '25% - first share')]) + ), + gui.weighted( + weight: 1 + view: gui.button(content: [gui.text(text: '25% - second share')]) + ), + gui.weighted( + weight: 2 + view: gui.button(content: [gui.text(text: '50% - double share')]) + ), + ] + ) +} + +fn main() { + mut window := gui.window( + width: 900 + height: 280 + title: 'Resizable weighted layout 1:1:2' + on_init: fn (mut w gui.Window) { + w.update_view(main_view) + } + ) + window.run() +} diff --git a/layout_sizing.v b/layout_sizing.v index ae50a5ff..479ce39b 100644 --- a/layout_sizing.v +++ b/layout_sizing.v @@ -22,6 +22,7 @@ struct DistributeScratch { mut: candidates []int fixed_indices []int + weights []f64 parent_total_child_widths map[u64]f32 parent_total_child_heights map[u64]f32 } @@ -40,6 +41,15 @@ fn (mut scratch DistributeScratch) ensure_cap(size int) { } } +@[inline] +fn (mut scratch DistributeScratch) prepare_weights(size int) { + if scratch.weights.cap < size { + scratch.weights = []f64{cap: size} + } else { + scratch.weights.clear() + } +} + // Dimension accessor functions abstract over the horizontal/vertical axis // to enable a single unified algorithm for both directions. @@ -83,6 +93,14 @@ fn get_sizing(shape &Shape, axis DistributeAxis) SizingType { } } +@[inline] +fn get_padding_size(shape &Shape, axis DistributeAxis) f32 { + return match axis { + .horizontal { shape.padding_width() } + .vertical { shape.padding_height() } + } +} + // distribute_space distributes remaining space among fill-sized children. // For grow mode: smallest children grow first until they match the next-smallest. // For shrink mode: largest children shrink first until they match the next-largest. @@ -321,6 +339,182 @@ fn distribute_space(mut layout Layout, return state.remaining } +const weighted_root_error = 'gui.weighted: weighted view requires a parent with a main axis' +const weighted_parent_axis_error = 'gui.weighted: weighted children require a parent with a main axis' +const weighted_parent_flow_error = 'gui.weighted: weighted children are not supported in wrap or overflow containers' +const weighted_fixed_error = 'gui.weighted: weighted child cannot use fixed sizing on its parent main axis' + +@[inline] +fn validate_weighted_root(layout &Layout) { + if layout.parent == unsafe { nil } && layout.shape.main_axis_weight > 0 { + panic(weighted_root_error) + } +} + +fn is_weighted_flow_child(child &Layout) bool { + return !child.shape.float && child.shape.shape_type != .none && !child.shape.over_draw +} + +// distribute_weighted_space assigns final main-axis sizes using constrained +// proportional water-filling. candidates and weights stay paired by index. +fn distribute_weighted_space(mut layout Layout, axis DistributeAxis, mut scratch DistributeScratch) { + if layout.shape.axis != .left_to_right && layout.shape.axis != .top_to_bottom { + panic(weighted_parent_axis_error) + } + if layout.shape.wrap || layout.shape.overflow { + panic(weighted_parent_flow_error) + } + scratch.prepare_weights(layout.children.len) + + mut budget := f64(get_size(layout.shape, axis)) + budget -= f64(get_padding_size(layout.shape, axis)) + budget -= f64(layout.spacing()) + for i, child in layout.children { + if !is_weighted_flow_child(child) { + continue + } + if child.shape.main_axis_weight > 0 && get_sizing(child.shape, axis) == .fixed { + panic(weighted_fixed_error) + } + weight := if child.shape.main_axis_weight > 0 { + f64(child.shape.main_axis_weight) + } else if get_sizing(child.shape, axis) == .fill { + f64(1) + } else { + f64(0) + } + if weight > 0 { + scratch.candidates << i + scratch.weights << weight + } else { + budget -= f64(get_size(child.shape, axis)) + } + } + if scratch.candidates.len == 0 { + return + } + + mut minimum_total := f64(0) + for candidate in scratch.candidates { + minimum_total += f64(get_min_size(layout.children[candidate].shape, axis)) + } + if budget < minimum_total { + for candidate in scratch.candidates { + mut child := &layout.children[candidate] + set_size(mut child.shape, axis, get_min_size(child.shape, axis)) + } + return + } + + mut remaining_budget := budget + mut remaining_weight := f64(0) + for weight in scratch.weights { + remaining_weight += weight + } + for i in 0 .. scratch.candidates.len { + scratch.fixed_indices << i + } + for scratch.fixed_indices.len > 0 && remaining_weight > 0 { + lambda := remaining_budget / remaining_weight + mut violation_total := f64(0) + for active_pos in scratch.fixed_indices { + child_idx := scratch.candidates[active_pos] + child := layout.children[child_idx] + target := lambda * scratch.weights[active_pos] + min_size := f64(get_min_size(child.shape, axis)) + max_size := f64(get_max_size(child.shape, axis)) + if target < min_size { + violation_total += min_size - target + } else if max_size > 0 && target > max_size { + violation_total += max_size - target + } + } + + tolerance := f64(f32_tolerance) + if violation_total >= -tolerance && violation_total <= tolerance { + mut next_active := 0 + for active_pos in scratch.fixed_indices { + child_idx := scratch.candidates[active_pos] + mut child := &layout.children[child_idx] + mut target := lambda * scratch.weights[active_pos] + min_size := f64(get_min_size(child.shape, axis)) + max_size := f64(get_max_size(child.shape, axis)) + mut clamped := false + if target < min_size { + target = min_size + clamped = true + } else if max_size > 0 && target > max_size { + target = max_size + clamped = true + } + set_size(mut child.shape, axis, f32(target)) + if !clamped { + scratch.fixed_indices[next_active] = active_pos + next_active++ + } + } + scratch.fixed_indices.trim(next_active) + break + } + + freeze_minimums := violation_total > tolerance + mut next_active := 0 + for active_pos in scratch.fixed_indices { + child_idx := scratch.candidates[active_pos] + mut child := &layout.children[child_idx] + weight := scratch.weights[active_pos] + min_size := f64(get_min_size(child.shape, axis)) + max_size := f64(get_max_size(child.shape, axis)) + target := lambda * weight + if freeze_minimums && target < min_size { + set_size(mut child.shape, axis, f32(min_size)) + remaining_budget -= min_size + } else if !freeze_minimums && max_size > 0 && target > max_size { + set_size(mut child.shape, axis, f32(max_size)) + remaining_budget -= max_size + } else { + scratch.fixed_indices[next_active] = active_pos + next_active++ + } + } + scratch.fixed_indices.trim(next_active) + remaining_weight = f64(0) + for active_pos in scratch.fixed_indices { + remaining_weight += scratch.weights[active_pos] + } + } + + // Restore f32 conversion residue on the last declarative candidate with room. + mut assigned := f64(0) + for candidate in scratch.candidates { + assigned += f64(get_size(layout.children[candidate].shape, axis)) + } + mut residue := budget - assigned + if residue != 0 { + for reverse_i in 0 .. scratch.fixed_indices.len { + i := scratch.fixed_indices.len - 1 - reverse_i + active_pos := scratch.fixed_indices[i] + child_idx := scratch.candidates[active_pos] + mut child := &layout.children[child_idx] + current := f64(get_size(child.shape, axis)) + min_size := f64(get_min_size(child.shape, axis)) + max_size := f64(get_max_size(child.shape, axis)) + mut target := current + residue + if target < min_size { + target = min_size + } + if max_size > 0 && target > max_size { + target = max_size + } + set_size(mut child.shape, axis, f32(target)) + residue -= f64(get_size(child.shape, axis)) - current + if residue == 0 { + break + } + } + } +} + // layout_widths arranges children horizontally. Only containers with an axis // are processed. fn layout_widths(mut layout Layout) { @@ -454,29 +648,38 @@ fn layout_fill_widths(mut layout Layout) { fn layout_fill_widths_with_scratch(mut layout Layout, mut scratch DistributeScratch) { if layout.parent == unsafe { nil } { scratch.parent_total_child_widths.clear() + validate_weighted_root(layout) } mut remaining_width := layout.shape.width - layout.shape.padding_width() scratch.ensure_cap(layout.children.len) if layout.shape.axis == .left_to_right { + mut has_explicit_weight := false for mut child in layout.children { remaining_width -= child.shape.width + if child.shape.main_axis_weight > 0 { + has_explicit_weight = true + } } // fence post spacing remaining_width -= layout.spacing() - // Grow if needed - if remaining_width > f32_tolerance { - remaining_width = distribute_space(mut layout, remaining_width, .grow, .horizontal, mut - scratch.candidates, mut scratch.fixed_indices) - } + if has_explicit_weight { + distribute_weighted_space(mut layout, .horizontal, mut scratch) + } else { + // Grow if needed + if remaining_width > f32_tolerance { + remaining_width = distribute_space(mut layout, remaining_width, .grow, .horizontal, mut + scratch.candidates, mut scratch.fixed_indices) + } - // Shrink if needed — skip for wrap/overflow containers; - // layout_wrap/layout_overflow handle excess children. - if remaining_width < -f32_tolerance && !layout.shape.wrap && !layout.shape.overflow { - remaining_width = distribute_space(mut layout, remaining_width, .shrink, .horizontal, mut - scratch.candidates, mut scratch.fixed_indices) + // Shrink if needed — skip for wrap/overflow containers; + // layout_wrap/layout_overflow handle excess children. + if remaining_width < -f32_tolerance && !layout.shape.wrap && !layout.shape.overflow { + remaining_width = distribute_space(mut layout, remaining_width, .shrink, + .horizontal, mut scratch.candidates, mut scratch.fixed_indices) + } } } else if layout.shape.axis == .top_to_bottom { if layout.shape.id_scroll > 0 && layout.shape.sizing.width == .fill @@ -516,6 +719,10 @@ fn layout_fill_widths_with_scratch(mut layout Layout, mut scratch DistributeScra } for mut child in layout.children { + if child.shape.main_axis_weight > 0 && layout.shape.axis != .left_to_right + && layout.shape.axis != .top_to_bottom { + panic(weighted_parent_axis_error) + } layout_fill_widths_with_scratch(mut child, mut scratch) } } @@ -530,28 +737,37 @@ fn layout_fill_heights(mut layout Layout) { fn layout_fill_heights_with_scratch(mut layout Layout, mut scratch DistributeScratch) { if layout.parent == unsafe { nil } { scratch.parent_total_child_heights.clear() + validate_weighted_root(layout) } mut remaining_height := layout.shape.height - layout.shape.padding_height() scratch.ensure_cap(layout.children.len) if layout.shape.axis == .top_to_bottom { + mut has_explicit_weight := false for mut child in layout.children { remaining_height -= child.shape.height + if child.shape.main_axis_weight > 0 { + has_explicit_weight = true + } } // fence post spacing remaining_height -= layout.spacing() - // Grow if needed - if remaining_height > f32_tolerance { - remaining_height = distribute_space(mut layout, remaining_height, .grow, .vertical, mut - scratch.candidates, mut scratch.fixed_indices) - } + if has_explicit_weight { + distribute_weighted_space(mut layout, .vertical, mut scratch) + } else { + // Grow if needed + if remaining_height > f32_tolerance { + remaining_height = distribute_space(mut layout, remaining_height, .grow, .vertical, mut + scratch.candidates, mut scratch.fixed_indices) + } - // Shrink if needed - if remaining_height < -f32_tolerance { - remaining_height = distribute_space(mut layout, remaining_height, .shrink, .vertical, mut - scratch.candidates, mut scratch.fixed_indices) + // Shrink if needed + if remaining_height < -f32_tolerance { + remaining_height = distribute_space(mut layout, remaining_height, .shrink, + .vertical, mut scratch.candidates, mut scratch.fixed_indices) + } } } else if layout.shape.axis == .left_to_right { if layout.shape.id_scroll > 0 && layout.shape.sizing.height == .fill @@ -591,6 +807,10 @@ fn layout_fill_heights_with_scratch(mut layout Layout, mut scratch DistributeScr } for mut child in layout.children { + if child.shape.main_axis_weight > 0 && layout.shape.axis != .left_to_right + && layout.shape.axis != .top_to_bottom { + panic(weighted_parent_axis_error) + } layout_fill_heights_with_scratch(mut child, mut scratch) } } diff --git a/shape.v b/shape.v index c612b6a1..b373d513 100644 --- a/shape.v +++ b/shape.v @@ -67,6 +67,9 @@ pub mut: wrap bool // If true, children wrap to next line when exceeding width overflow bool // If true, overflow panel hides non-fitting children opacity f32 = 1.0 // Opacity multiplier (0.0 = transparent, 1.0 = opaque) +mut: + // Internal child metadata. Zero means that no explicit weight was assigned. + main_axis_weight f32 } // ShapeTextConfig holds text/RTF-specific fields for a Shape. diff --git a/view_weighted.v b/view_weighted.v new file mode 100644 index 00000000..caef3cd9 --- /dev/null +++ b/view_weighted.v @@ -0,0 +1,71 @@ +module gui + +import math + +const weighted_invalid_weight_error = 'gui.weighted: weight must be finite and greater than zero' +const weighted_duplicate_error = 'gui.weighted: a view cannot be weighted more than once' +const weighted_invalid_state_error = 'gui.weighted: internal wrapper must contain exactly one view' +const weighted_missing_shape_error = 'gui.weighted: decorated view generated a layout without a shape' +const weighted_non_flow_error = 'gui.weighted: decorated view must participate in normal layout flow' + +// WeightedCfg decorates one view with its share of a parent's main-axis space. +pub struct WeightedCfg { +pub: + weight f32 + view View @[required] +} + +@[heap; minify] +struct WeightedView implements View { + weight f32 +mut: + // Before generation this owns the decorated view at index 0. Afterwards it + // owns that view's generated content for the generic View traversal. + content []View +} + +// weighted assigns a positive, finite main-axis weight to a view. +pub fn weighted(cfg WeightedCfg) View { + if cfg.weight <= 0 || math.is_nan(cfg.weight) || math.is_inf(cfg.weight, 0) { + panic(weighted_invalid_weight_error) + } + if cfg.view is WeightedView { + panic(weighted_duplicate_error) + } + return WeightedView{ + weight: cfg.weight + content: [cfg.view] + } +} + +fn (mut wv WeightedView) generate_layout(mut window Window) Layout { + if wv.content.len != 1 { + panic(weighted_invalid_state_error) + } + + mut decorated := wv.content[0] + mut layout := decorated.generate_layout(mut window) + + // Transfer the generated content without cloning it. The decorated view + // relinquishes the array header; its backing is now owned by this wrapper. + decorated_content := decorated.content + decorated.content = []View{} + + // Zero the temporary interface slot before exposing the delegated content + // to the outer generic generate_layout traversal. + array_clear(mut wv.content) + wv.content = decorated_content + + if isnil(layout.shape) { + panic(weighted_missing_shape_error) + } + if layout.shape.float || layout.shape.shape_type == .none || layout.shape.over_draw { + panic(weighted_non_flow_error) + } + if layout.shape.main_axis_weight != 0 { + panic(weighted_duplicate_error) + } + + layout.shape.main_axis_weight = wv.weight + return layout +}