diff --git a/ltml/SYNTAX.md b/ltml/SYNTAX.md index c3e6362..96b0a3d 100644 --- a/ltml/SYNTAX.md +++ b/ltml/SYNTAX.md @@ -1169,6 +1169,15 @@ not automatically change paragraph shaping or bidi behavior inside text widgets. - Use `align-self="start"`, `align-self="center"`, or `align-self="end"` to control horizontal placement within the vbox row. - In `dir="rtl"`, `align-self="start"` means right and `align-self="end"` means left. - In `dir="rtl"`, children are flush against the right edge plus padding instead of the left. +- When at least one child uses `height="auto"` and a height-constrained vbox + fragment has true surplus height beyond the specified, percent, and preferred + heights of the children on that fragment plus `layout.vpadding`, omitted + heights keep their preferred heights and the `auto` children split the + leftover height evenly. +- In natural-height vboxes, and in constrained vboxes without surplus, + `height="auto"` behaves the same as an omitted height. +- When a vbox splits across pages, `height="auto"` is evaluated separately for + each fragment page based only on the children present on that fragment. ### HBox Details @@ -1178,6 +1187,12 @@ not automatically change paragraph shaping or bidi behavior inside text widgets. - Use `align-self="start"`, `align-self="center"`, or `align-self="end"` to control vertical placement within the hbox track. - In `hbox`, `align-self="start"` means top and `align-self="end"` means bottom. - Unaligned children share remaining width equally unless `width` is specified. +- When at least one child uses `width="auto"` and the hbox has true surplus + width beyond the preferred widths of the remaining unsized children plus + hpadding, omitted widths keep their preferred widths and the `auto` children + split the leftover space evenly. +- In constrained hboxes, and in layout managers without their own `auto` + sizing policy, `width="auto"` behaves the same as an omitted width. - In `dir="rtl"`, stacking order reverses: children flow right to left, `align="left"` pins to the right side, and `align="right"` pins to the left side. ### Table Details @@ -1186,7 +1201,25 @@ not automatically change paragraph shaping or bidi behavior inside text widgets. - Set `rows` for column-major order (`order="cols"`). - Use `colspan` and `rowspan` attributes on cells to span multiple slots. - Column widths can be fixed (`width="120pt"`), percentage (`width="40%"`), or - automatic (equal share of remaining space). + omitted. Omitted columns keep the historical table behavior: they share the + remaining width equally. +- When at least one single-column cell uses `width="auto"` and the table has + true surplus width beyond the preferred widths of omitted and auto columns, + omitted columns keep their preferred widths and auto columns split the + remaining width evenly. In constrained tables where omitted preferred widths + can still fit, omitted columns keep those preferred widths and auto columns + split the remaining width. Only when omitted preferred widths cannot fit does + the table fall back to equal sharing. +- Cells with `colspan > 1` receive the resolved width of their spanned columns + but do not drive auto column sizing. +- When at least one row contains a `height="auto"` cell and a height-constrained + table has true surplus height beyond fixed and preferred row heights, omitted + rows keep their preferred heights and auto rows split the remaining height + evenly. +- In natural-height tables, and in constrained tables without surplus, + `height="auto"` behaves the same as an omitted height. +- When a direct page-child table splits across pages, auto row height is + evaluated separately for each fragment page. - In `dir="rtl"`, columns are placed right to left (column 0 at the right edge). ### Flow Details @@ -1353,6 +1386,7 @@ Measurements can be expressed in several forms: | With unit | `1in`, `2.5cm`, `14pt` | Explicit unit overrides the current `units`. | | Percentage | `50%` | Percentage of the container's content width or height. | | Relative | `+10`, `-5` | Offset from the container's content dimension. | +| Auto | `auto` | Automatic layout-managed size. Supported by `hbox` width, `vbox` height, table column width, and table row height; elsewhere it behaves like omitting the dimension. | **Supported units:** `pt` (points), `in` (inches, 72pt), `cm` (centimeters, 28.35pt). diff --git a/ltml/canvas_capture.go b/ltml/canvas_capture.go index d97c25b..43b3d4d 100644 --- a/ltml/canvas_capture.go +++ b/ltml/canvas_capture.go @@ -51,6 +51,8 @@ func resetCanvasWidgetRenderState(root Widget) { widget.SetPrinted(false) widget.SetVisible(true) widget.SetDisabled(false) + widget.ClearResolvedWidth() + widget.ClearResolvedHeight() switch value := widget.(type) { case *StdContainer: value.activeChildren = nil diff --git a/ltml/dimensions.go b/ltml/dimensions.go index aaed8de..93cb874 100644 --- a/ltml/dimensions.go +++ b/ltml/dimensions.go @@ -7,21 +7,44 @@ import ( "fmt" "regexp" "strconv" + "strings" +) + +type DimensionMode int8 + +const ( + DimUnspecified DimensionMode = iota + DimLiteral + DimPct + DimRel + DimAuto ) type Dimensions struct { - sides Sides - margin Sides - padding Sides - corners Corners - width float32 - widthPct float32 - widthRel float32 - height float32 - heightPct float32 - heightRel float32 - widthSet bool - heightSet bool + sides Sides + margin Sides + padding Sides + corners Corners + width float32 + height float32 + widthValue float32 + heightValue float32 + widthMode DimensionMode + heightMode DimensionMode + widthValid bool + heightValid bool +} + +type dimensionState struct { + resolved float32 + value float32 + mode DimensionMode + valid bool +} + +type dimensionsState struct { + width dimensionState + height dimensionState } var ( @@ -83,7 +106,10 @@ func (d *Dimensions) SetAttrs(attrs map[string]string, units Units) { } if width, ok := attrs["width"]; ok { - if rePct.MatchString(width) { + width = strings.TrimSpace(width) + if width == "auto" { + d.SetWidthAuto() + } else if rePct.MatchString(width) { widthPct, _ := strconv.ParseFloat(width[:len(width)-1], 64) d.SetWidthPct(widthPct) } else if reRel.MatchString(width) { @@ -95,7 +121,10 @@ func (d *Dimensions) SetAttrs(attrs map[string]string, units Units) { } } if height, ok := attrs["height"]; ok { - if rePct.MatchString(height) { + height = strings.TrimSpace(height) + if height == "auto" { + d.SetHeightAuto() + } else if rePct.MatchString(height) { heightPct, _ := strconv.ParseFloat(height[:len(height)-1], 64) d.SetHeightPct(heightPct) } else if reRel.MatchString(height) { @@ -109,19 +138,64 @@ func (d *Dimensions) SetAttrs(attrs map[string]string, units Units) { } func (d *Dimensions) SetHeight(value float64) { - d.height, d.heightPct, d.heightRel, d.heightSet = float32(value), 0, 0, true + d.height = float32(value) + d.heightValue = float32(value) + d.heightMode = DimLiteral + d.heightValid = true +} + +func (d *Dimensions) SetHeightAuto() { + d.height = 0 + d.heightValue = 0 + d.heightMode = DimAuto + d.heightValid = false +} + +func (d *Dimensions) ClearHeight() { + d.height = 0 + d.heightValue = 0 + d.heightMode = DimUnspecified + d.heightValid = false } func (d *Dimensions) SetHeightPct(value float64) { - d.heightPct, d.height, d.heightRel, d.heightSet = float32(value), 0, 0, true + d.height = 0 + d.heightValue = float32(value) + d.heightMode = DimPct + d.heightValid = false } func (d *Dimensions) SetHeightRel(value float64) { - d.heightRel, d.height, d.heightPct, d.heightSet = float32(value), 0, 0, true + d.height = 0 + d.heightValue = float32(value) + d.heightMode = DimRel + d.heightValid = false +} + +func (d *Dimensions) ResolveHeight(value float64) { + d.height = float32(value) + d.heightValid = true +} + +func (d *Dimensions) ClearResolvedHeight() { + if d.heightMode == DimLiteral { + d.height = d.heightValue + } else { + d.height = 0 + } + d.heightValid = false } func (d *Dimensions) HeightIsSet() bool { - return d.heightSet + if d.heightValid { + return true + } + switch d.heightMode { + case DimLiteral, DimPct, DimRel: + return true + default: + return false + } } func (d *Dimensions) SetTop(value float64) { @@ -141,15 +215,52 @@ func (d *Dimensions) SetLeft(value float64) { } func (d *Dimensions) SetWidth(value float64) { - d.width, d.widthPct, d.widthRel, d.widthSet = float32(value), 0, 0, true + d.width = float32(value) + d.widthValue = float32(value) + d.widthMode = DimLiteral + d.widthValid = true +} + +func (d *Dimensions) SetWidthAuto() { + d.width = 0 + d.widthValue = 0 + d.widthMode = DimAuto + d.widthValid = false +} + +func (d *Dimensions) ClearWidth() { + d.width = 0 + d.widthValue = 0 + d.widthMode = DimUnspecified + d.widthValid = false } func (d *Dimensions) SetWidthPct(value float64) { - d.widthPct, d.widthRel, d.width, d.widthSet = float32(value), 0, 0, true + d.width = 0 + d.widthValue = float32(value) + d.widthMode = DimPct + d.widthValid = false } func (d *Dimensions) SetWidthRel(value float64) { - d.widthRel, d.widthPct, d.width, d.widthSet = float32(value), 0, 0, true + d.width = 0 + d.widthValue = float32(value) + d.widthMode = DimRel + d.widthValid = false +} + +func (d *Dimensions) ResolveWidth(value float64) { + d.width = float32(value) + d.widthValid = true +} + +func (d *Dimensions) ClearResolvedWidth() { + if d.widthMode == DimLiteral { + d.width = d.widthValue + } else { + d.width = 0 + } + d.widthValid = false } func (d *Dimensions) String() string { @@ -158,13 +269,57 @@ func (d *Dimensions) String() string { } func (d *Dimensions) WidthPctIsSet() bool { - return d.widthPct > 0 + return d.widthMode == DimPct } func (d *Dimensions) WidthRelIsSet() bool { - return d.widthRel != 0 + return d.widthMode == DimRel } func (d *Dimensions) WidthIsSet() bool { - return d.widthSet + if d.widthValid { + return true + } + switch d.widthMode { + case DimLiteral, DimPct, DimRel: + return true + default: + return false + } +} + +func (d *Dimensions) WidthMode() DimensionMode { + return d.widthMode +} + +func (d *Dimensions) HeightMode() DimensionMode { + return d.heightMode +} + +func (d *Dimensions) SaveState() dimensionsState { + return dimensionsState{ + width: dimensionState{ + resolved: d.width, + value: d.widthValue, + mode: d.widthMode, + valid: d.widthValid, + }, + height: dimensionState{ + resolved: d.height, + value: d.heightValue, + mode: d.heightMode, + valid: d.heightValid, + }, + } +} + +func (d *Dimensions) RestoreState(state dimensionsState) { + d.width = state.width.resolved + d.widthValue = state.width.value + d.widthMode = state.width.mode + d.widthValid = state.width.valid + d.height = state.height.resolved + d.heightValue = state.height.value + d.heightMode = state.height.mode + d.heightValid = state.height.valid } diff --git a/ltml/dimensions_test.go b/ltml/dimensions_test.go index 939f3ad..1186b32 100644 --- a/ltml/dimensions_test.go +++ b/ltml/dimensions_test.go @@ -9,25 +9,27 @@ import ( func TestDimensions_SetAttrs(t *testing.T) { tests := []struct { - name string - attrs map[string]string - wantWidth float64 - wantWidthPct float64 - wantWidthRel float64 - wantHeight float64 - wantHeightPct float64 - wantHeightRel float64 - wantWidthSet bool - wantHeightSet bool + name string + attrs map[string]string + wantWidth float64 + wantWidthValue float64 + wantWidthMode DimensionMode + wantHeight float64 + wantHeightValue float64 + wantHeightMode DimensionMode + wantWidthSet bool + wantHeightSet bool }{ - {name: "Width", attrs: map[string]string{"width": "30"}, wantWidth: 30, wantWidthSet: true}, - {name: "WidthPct", attrs: map[string]string{"width": "40%"}, wantWidthPct: 40, wantWidthSet: true}, - {name: "WidthRelPlus", attrs: map[string]string{"width": "+50"}, wantWidthRel: 50, wantWidthSet: true}, - {name: "WidthRelMinus", attrs: map[string]string{"width": "-60"}, wantWidthRel: -60, wantWidthSet: true}, - {name: "Height", attrs: map[string]string{"height": "30"}, wantHeight: 30, wantHeightSet: true}, - {name: "HeightPct", attrs: map[string]string{"height": "40%"}, wantHeightPct: 40, wantHeightSet: true}, - {name: "HeightRelPlus", attrs: map[string]string{"height": "+50"}, wantHeightRel: 50, wantHeightSet: true}, - {name: "HeightRelMinus", attrs: map[string]string{"height": "-60"}, wantHeightRel: -60, wantHeightSet: true}, + {name: "Width", attrs: map[string]string{"width": "30"}, wantWidth: 30, wantWidthValue: 30, wantWidthMode: DimLiteral, wantWidthSet: true}, + {name: "WidthPct", attrs: map[string]string{"width": "40%"}, wantWidthValue: 40, wantWidthMode: DimPct, wantWidthSet: true}, + {name: "WidthRelPlus", attrs: map[string]string{"width": "+50"}, wantWidthValue: 50, wantWidthMode: DimRel, wantWidthSet: true}, + {name: "WidthRelMinus", attrs: map[string]string{"width": "-60"}, wantWidthValue: -60, wantWidthMode: DimRel, wantWidthSet: true}, + {name: "WidthAuto", attrs: map[string]string{"width": "auto"}, wantWidthMode: DimAuto}, + {name: "Height", attrs: map[string]string{"height": "30"}, wantHeight: 30, wantHeightValue: 30, wantHeightMode: DimLiteral, wantHeightSet: true}, + {name: "HeightPct", attrs: map[string]string{"height": "40%"}, wantHeightValue: 40, wantHeightMode: DimPct, wantHeightSet: true}, + {name: "HeightRelPlus", attrs: map[string]string{"height": "+50"}, wantHeightValue: 50, wantHeightMode: DimRel, wantHeightSet: true}, + {name: "HeightRelMinus", attrs: map[string]string{"height": "-60"}, wantHeightValue: -60, wantHeightMode: DimRel, wantHeightSet: true}, + {name: "HeightAuto", attrs: map[string]string{"height": "auto"}, wantHeightMode: DimAuto}, } for _, tc := range tests { @@ -39,27 +41,250 @@ func TestDimensions_SetAttrs(t *testing.T) { if got := float64(d.width); got != tc.wantWidth { t.Errorf("width: expected %v, got %v", tc.wantWidth, got) } - if got := float64(d.widthPct); got != tc.wantWidthPct { - t.Errorf("widthPct: expected %v, got %v", tc.wantWidthPct, got) + if got := float64(d.widthValue); got != tc.wantWidthValue { + t.Errorf("widthValue: expected %v, got %v", tc.wantWidthValue, got) } - if got := float64(d.widthRel); got != tc.wantWidthRel { - t.Errorf("widthRel: expected %v, got %v", tc.wantWidthRel, got) + if got := d.WidthMode(); got != tc.wantWidthMode { + t.Errorf("WidthMode: expected %v, got %v", tc.wantWidthMode, got) } if got := float64(d.height); got != tc.wantHeight { t.Errorf("height: expected %v, got %v", tc.wantHeight, got) } - if got := float64(d.heightPct); got != tc.wantHeightPct { - t.Errorf("heightPct: expected %v, got %v", tc.wantHeightPct, got) + if got := float64(d.heightValue); got != tc.wantHeightValue { + t.Errorf("heightValue: expected %v, got %v", tc.wantHeightValue, got) } - if got := float64(d.heightRel); got != tc.wantHeightRel { - t.Errorf("heightRel: expected %v, got %v", tc.wantHeightRel, got) + if got := d.HeightMode(); got != tc.wantHeightMode { + t.Errorf("HeightMode: expected %v, got %v", tc.wantHeightMode, got) } - if d.widthSet != tc.wantWidthSet { - t.Errorf("widthSet: expected %v, got %v", tc.wantWidthSet, d.widthSet) + if got := d.WidthIsSet(); got != tc.wantWidthSet { + t.Errorf("WidthIsSet: expected %v, got %v", tc.wantWidthSet, got) } - if d.heightSet != tc.wantHeightSet { - t.Errorf("heightSet: expected %v, got %v", tc.wantHeightSet, d.heightSet) + if got := d.HeightIsSet(); got != tc.wantHeightSet { + t.Errorf("HeightIsSet: expected %v, got %v", tc.wantHeightSet, got) } }) } } + +func TestDimensions_SetAttrs_AutoIsCaseSensitive(t *testing.T) { + var d Dimensions + d.SetAttrs(map[string]string{"width": "AUTO", "height": "Auto"}, "") + + if got := d.WidthMode(); got == DimAuto { + t.Fatalf("WidthMode() = %v, want non-auto for uppercase AUTO", got) + } + if got := d.HeightMode(); got == DimAuto { + t.Fatalf("HeightMode() = %v, want non-auto for mixed-case Auto", got) + } +} + +func TestStdWidget_DimensionResolution(t *testing.T) { + page := &StdPage{pageStyle: &PageStyle{width: 200, height: 120}} + + pct := &StdWidget{} + _ = pct.SetContainer(page) + pct.SetWidthPct(25) + pct.SetHeightPct(50) + if got := pct.Width(); got != 50 { + t.Fatalf("pct.Width() = %v, want 50", got) + } + if got := pct.Height(); got != 60 { + t.Fatalf("pct.Height() = %v, want 60", got) + } + + rel := &StdWidget{} + _ = rel.SetContainer(page) + rel.SetWidthRel(-20) + rel.SetHeightRel(15) + if got := rel.Width(); got != 180 { + t.Fatalf("rel.Width() = %v, want 180", got) + } + if got := rel.Height(); got != 135 { + t.Fatalf("rel.Height() = %v, want 135", got) + } + + auto := &StdWidget{} + _ = auto.SetContainer(page) + auto.SetWidthAuto() + auto.SetHeightAuto() + auto.SetLeft(10) + auto.SetRight(-10) + auto.SetTop(5) + auto.SetBottom(-5) + if got := auto.Width(); got != 180 { + t.Fatalf("auto.Width() = %v, want 180", got) + } + if got := auto.Height(); got != 110 { + t.Fatalf("auto.Height() = %v, want 110", got) + } +} + +func TestDetectTableColumnTracks_PreservesPercentClassification(t *testing.T) { + page := &StdPage{pageStyle: &PageStyle{width: 200, height: 120}} + grid := NewWidgetGrid(2, 1) + + pct := &StdWidget{} + _ = pct.SetContainer(page) + pct.SetWidthPct(40) + grid.SetCell(0, 0, pct) + + specified := &StdWidget{} + _ = specified.SetContainer(page) + specified.SetWidth(80) + grid.SetCell(1, 0, specified) + + tracks := detectTableColumnTracks(grid, nil) + if got := tracks[0].kind; got != tableTrackPercent { + t.Fatalf("tracks[0].kind = %v, want %v", got, tableTrackPercent) + } + if got := tracks[0].size; got != 80 { + t.Fatalf("tracks[0].size = %v, want 80", got) + } + if got := tracks[1].kind; got != tableTrackSpecified { + t.Fatalf("tracks[1].kind = %v, want %v", got, tableTrackSpecified) + } + if got := tracks[1].size; got != 80 { + t.Fatalf("tracks[1].size = %v, want 80", got) + } +} + +func TestDetectTableColumnTracks_ClassifiesAuto(t *testing.T) { + page := &StdPage{pageStyle: &PageStyle{width: 200, height: 120}} + grid := NewWidgetGrid(1, 1) + + auto := &positionedTestWidget{preferredWidth: 35} + _ = auto.SetContainer(page) + auto.SetWidthAuto() + grid.SetCell(0, 0, auto) + + tracks := detectTableColumnTracks(grid, nil) + if got := tracks[0].kind; got != tableTrackAuto { + t.Fatalf("tracks[0].kind = %v, want %v", got, tableTrackAuto) + } + if got := tracks[0].preferred; got != 35 { + t.Fatalf("tracks[0].preferred = %v, want 35", got) + } +} + +func TestStdIndex_ClearMeasuredGeometry_ClearsOnlyImplicitDimensions(t *testing.T) { + index := &StdIndex{} + index.ResolveWidth(140) + index.ResolveHeight(90) + index.clearMeasuredGeometry() + + if index.width != 0 || index.widthValue != 0 || index.widthMode != DimUnspecified || index.widthValid { + t.Fatalf("implicit width not cleared: width=%v value=%v mode=%v valid=%v", index.width, index.widthValue, index.widthMode, index.widthValid) + } + if index.height != 0 || index.heightValue != 0 || index.heightMode != DimUnspecified || index.heightValid { + t.Fatalf("implicit height not cleared: height=%v value=%v mode=%v valid=%v", index.height, index.heightValue, index.heightMode, index.heightValid) + } + + index.SetAttrs(map[string]string{"width": "40%", "height": "30", "units": "pt"}) + index.ResolveWidth(160) + index.clearMeasuredGeometry() + + if index.width != 0 || index.widthValue != 40 || index.widthMode != DimPct || index.widthValid { + t.Fatalf("explicit width was not preserved: width=%v value=%v mode=%v valid=%v", index.width, index.widthValue, index.widthMode, index.widthValid) + } + if index.height != 30 || index.heightValue != 30 || index.heightMode != DimLiteral || index.heightValid { + t.Fatalf("explicit height was not preserved: height=%v value=%v mode=%v valid=%v", index.height, index.heightValue, index.heightMode, index.heightValid) + } +} + +func TestDimensions_SaveStateAndClearHelpers(t *testing.T) { + var d Dimensions + d.SetWidthPct(40) + d.SetHeight(24) + + saved := d.SaveState() + d.ClearWidth() + d.ClearHeight() + + if d.WidthIsSet() || d.HeightIsSet() { + t.Fatalf("dimensions should be cleared, got widthMode=%v heightMode=%v", d.widthMode, d.heightMode) + } + + d.RestoreState(saved) + + if d.widthMode != DimPct || d.widthValue != 40 || d.width != 0 { + t.Fatalf("width state restore failed: mode=%v value=%v width=%v", d.widthMode, d.widthValue, d.width) + } + if d.heightMode != DimLiteral || d.heightValue != 24 || d.height != 24 { + t.Fatalf("height state restore failed: mode=%v value=%v height=%v", d.heightMode, d.heightValue, d.height) + } +} + +func TestStdWidget_ResolveWidthPreservesSpecifiedModeAndOverridesUntilCleared(t *testing.T) { + page := &StdPage{pageStyle: &PageStyle{width: 200, height: 120}} + widget := &StdWidget{} + _ = widget.SetContainer(page) + widget.SetWidthPct(25) + + if got := widget.Width(); got != 50 { + t.Fatalf("Width() before resolve = %v, want 50", got) + } + + widget.ResolveWidth(80) + page.pageStyle.width = 400 + if got := widget.Width(); got != 80 { + t.Fatalf("Width() after resolve = %v, want 80", got) + } + if got := widget.WidthMode(); got != DimPct { + t.Fatalf("WidthMode() after resolve = %v, want %v", got, DimPct) + } + if got := widget.widthValue; got != 25 { + t.Fatalf("widthValue after resolve = %v, want 25", got) + } + + widget.ClearResolvedWidth() + if got := widget.Width(); got != 100 { + t.Fatalf("Width() after clear = %v, want 100", got) + } +} + +func TestStdWidget_ResolveAutoWidthOverridesSideResolutionUntilCleared(t *testing.T) { + page := &StdPage{pageStyle: &PageStyle{width: 200, height: 120}} + widget := &StdWidget{} + _ = widget.SetContainer(page) + widget.SetWidthAuto() + widget.SetLeft(10) + widget.SetRight(-10) + + if got := widget.Width(); got != 180 { + t.Fatalf("Width() before resolve = %v, want 180", got) + } + + widget.ResolveWidth(90) + widget.SetRight(-40) + if got := widget.Width(); got != 90 { + t.Fatalf("Width() after resolve = %v, want 90", got) + } + if got := widget.WidthIsSet(); !got { + t.Fatalf("WidthIsSet() after resolve = %v, want true", got) + } + + widget.ClearResolvedWidth() + if got := widget.Width(); got != 150 { + t.Fatalf("Width() after clear = %v, want 150", got) + } + if got := widget.WidthIsSet(); !got { + t.Fatalf("WidthIsSet() after clear = %v, want true from left/right anchors", got) + } +} + +func TestDimensions_ClearResolvedHeightPreservesSpecifiedHeight(t *testing.T) { + var d Dimensions + d.SetHeight(24) + d.ResolveHeight(36) + d.ClearResolvedHeight() + + if got := d.HeightMode(); got != DimLiteral { + t.Fatalf("HeightMode() = %v, want %v", got, DimLiteral) + } + if got := d.height; got != 24 { + t.Fatalf("height = %v, want 24", got) + } + if got := d.HeightIsSet(); !got { + t.Fatalf("HeightIsSet() = %v, want true", got) + } +} diff --git a/ltml/dir_test.go b/ltml/dir_test.go index 846b458..ad12667 100644 --- a/ltml/dir_test.go +++ b/ltml/dir_test.go @@ -195,26 +195,6 @@ func TestLayoutVBox_RTL(t *testing.T) { } } -func TestLayoutVBox_AlignSelfCenterCentersTopChildHorizontally(t *testing.T) { - c := positionedContainer(0, 0, 300, 200) - style := &LayoutStyle{} - - w := &positionedTestWidget{preferredWidth: 100, preferredHeight: 30} - w.SetWidth(100) - w.SetAttrs(map[string]string{"align": "top", "align-self": "center"}) - w.SetContainer(c) - c.AddChild(w) - - LayoutVBox(c, style, nil) - - if got := w.Left(); got != 100 { - t.Errorf("vbox top child left = %v, want 100", got) - } - if got := w.Top(); got != 0 { - t.Errorf("vbox top child top = %v, want 0", got) - } -} - func TestLayoutVBox_AlignSelfEndMirrorsInRTL(t *testing.T) { c := rtlContainer(0, 0, 300, 200) style := &LayoutStyle{} @@ -232,27 +212,6 @@ func TestLayoutVBox_AlignSelfEndMirrorsInRTL(t *testing.T) { } } -func TestLayoutVBox_ParagraphDefaultsToFullWidth(t *testing.T) { - c := positionedContainer(0, 0, 300, 200) - p := &StdParagraph{} - if err := p.SetContainer(c); err != nil { - t.Fatal(err) - } - p.paragraphStyle = &ParagraphStyle{} - p.font = &FontStyle{id: "body", entries: []fontEntry{{name: "Helvetica"}}, size: 12} - p.AddText("Short heading") - c.AddChild(p) - - LayoutVBox(c, &LayoutStyle{}, &labelTestWriter{t: t}) - - if got := p.Width(); got != 300 { - t.Fatalf("paragraph width = %v, want 300", got) - } - if got := p.Left(); got != 0 { - t.Fatalf("paragraph left = %v, want 0", got) - } -} - func TestLayoutHBox_RTL(t *testing.T) { c := rtlContainer(0, 0, 300, 100) style := &LayoutStyle{} @@ -278,60 +237,6 @@ func TestLayoutHBox_RTL(t *testing.T) { } } -func TestLayoutHBox_AlignSelfCenterCentersLeftChildVertically(t *testing.T) { - c := positionedContainer(0, 0, 300, 100) - style := &LayoutStyle{} - - w := &positionedTestWidget{preferredWidth: 80, preferredHeight: 20} - w.SetWidth(80) - w.SetAttrs(map[string]string{"align": "left", "align-self": "center"}) - w.SetContainer(c) - c.AddChild(w) - - LayoutHBox(c, style, nil) - - if got := w.Top(); got != 40 { - t.Errorf("hbox left child top = %v, want 40", got) - } - if got := w.Left(); got != 0 { - t.Errorf("hbox left child left = %v, want 0", got) - } -} - -func TestLayoutHBox_ContainerAlignBottomStillBottomAlignsChildrenByDefault(t *testing.T) { - c := positionedContainer(0, 0, 300, 100) - c.align = AlignBottom - style := &LayoutStyle{} - - w := &positionedTestWidget{preferredWidth: 80, preferredHeight: 20} - w.SetWidth(80) - w.SetContainer(c) - c.AddChild(w) - - LayoutHBox(c, style, nil) - - if got := w.Top(); got != 80 { - t.Errorf("default hbox child top with container align bottom = %v, want 80", got) - } -} - -func TestLayoutHBox_AlignSelfEndBottomAlignsChild(t *testing.T) { - c := positionedContainer(0, 0, 300, 100) - style := &LayoutStyle{} - - w := &positionedTestWidget{preferredWidth: 80, preferredHeight: 20} - w.SetWidth(80) - w.SetAttrs(map[string]string{"align-self": "end"}) - w.SetContainer(c) - c.AddChild(w) - - LayoutHBox(c, style, nil) - - if got := w.Top(); got != 80 { - t.Errorf("hbox end-aligned child top = %v, want 80", got) - } -} - func TestLayoutHBox_RTL_AlignedPanels(t *testing.T) { c := rtlContainer(0, 0, 300, 100) style := &LayoutStyle{} diff --git a/ltml/layout_absolute.go b/ltml/layout_absolute.go index 33bc942..e0c7fc5 100644 --- a/ltml/layout_absolute.go +++ b/ltml/layout_absolute.go @@ -39,11 +39,11 @@ func layoutWidgetsWithPosition(writer Writer, widgets []Widget, position Positio widget.SetTop(0) } if !widget.WidthIsSet() { - widget.SetWidth(widget.PreferredWidth(writer)) + widget.ResolveWidth(widget.PreferredWidth(writer)) } widget.LayoutWidget(writer) if !widget.HeightIsSet() { - widget.SetHeight(widget.PreferredHeight(writer)) + widget.ResolveHeight(widget.PreferredHeight(writer)) } } } diff --git a/ltml/layout_box.go b/ltml/layout_box.go index bda5e85..f486170 100644 --- a/ltml/layout_box.go +++ b/ltml/layout_box.go @@ -2,14 +2,37 @@ package ltml const layoutFitEpsilon = 0.001 +// LayoutHBox performs a single-pass horizontal box layout with a few important +// twists: +// +// - child widths are resolved in priority order so fixed commitments consume +// space before flexible ones +// - percent widths are scaled down proportionally when they collectively ask +// for more than the remaining width +// - omitted widths and width="auto" are similar in tight layouts, but in a +// roomy hbox only auto children absorb surplus while omitted children stay +// at preferred width +// - left/right-aligned panels are placed at the edges first, then the +// remaining unaligned children fill the center run +// +// The algorithm deliberately separates "resolve size" from "place children". +// Later layout managers and split logic depend on being able to understand +// exactly when a child became fixed to a concrete width or height. func LayoutHBox(container Container, style *LayoutStyle, writer Writer) { containerFull := false + // Static children participate in the box algorithm. Everything else is + // handled later by layoutPositionedChildren and should be hidden here so the + // static run is easy to reason about. static, remaining := printableWidgets(container, Static) for _, widget := range remaining { widget.SetVisible(false) } + // Alignment affects placement order, not sizing. We first split the static + // widgets into the three horizontal runs the hbox knows how to place: + // edge-pinned left panels, edge-pinned right panels, and the ordinary + // unaligned run between them. var lpanels, rpanels, unaligned []Widget for _, widget := range static { switch widget.Align() { @@ -22,19 +45,40 @@ func LayoutHBox(container Container, style *LayoutStyle, writer Writer) { } } - var percents, specified, others []Widget + // Width modes drive the allocation algorithm. We intentionally classify + // children by "how width was authored" rather than by current resolved width, + // because prior probe/layout passes may already have stamped temporary + // resolved geometry onto the widget. + // + // The allocation priority is: + // 1. explicitly specified widths, including relative widths and widths + // implied by opposing horizontal sides + // 2. percent widths + // 3. omitted widths and auto widths + // + // The last two are separated because width="auto" has special roomy-layout + // behavior in hbox. + var percents, specified, omitted, auto []Widget for _, widget := range static { if widget.WidthPctIsSet() { percents = append(percents, widget) - } else if widget.WidthIsSet() { + } else if widgetAutoWidth(widget) { + auto = append(auto, widget) + } else if widgetWidthSpecified(widget) { specified = append(specified, widget) } else { - others = append(others, widget) + omitted = append(omitted, widget) } } + // widthAvail tracks the content width that remains for the still-unresolved + // width groups. We subtract both child widths and the inter-child padding + // that must exist between committed children. widthAvail := ContentWidth(container) + // Specified widths are hard commitments: they consume space first and are not + // renegotiated by hbox. If they alone overfill the row, later widgets are + // disabled so they do not participate in placement or rendering. for _, widget := range specified { widthAvail -= widget.Width() containerFull = widthAvail < 0 @@ -42,6 +86,10 @@ func LayoutHBox(container Container, style *LayoutStyle, writer Writer) { widthAvail -= style.HPadding() } + // Percent widths are resolved against whatever width remains after the hard + // commitments above. If the requested percents over-allocate, they are scaled + // down proportionally instead of being treated as independent hard failures. + // If even the padding gaps cannot fit, the whole percent group is disabled. if widthAvail-float64(len(percents)-1)*style.HPadding() >= float64(len(percents)) { widthAvail -= float64(len(percents)-1) * style.HPadding() totalPercents := 0.0 @@ -51,7 +99,7 @@ func LayoutHBox(container Container, style *LayoutStyle, writer Writer) { ratio := widthAvail / totalPercents for _, widget := range percents { if ratio < 1.0 { - widget.SetWidth(widget.Width() * ratio) + widget.ResolveWidth(widget.Width() * ratio) } widthAvail -= widget.Width() } @@ -63,29 +111,79 @@ func LayoutHBox(container Container, style *LayoutStyle, writer Writer) { } widthAvail -= style.HPadding() - if widthAvail-float64(len(others)-1)*style.HPadding() >= float64(len(others)) { - widthAvail -= float64(len(others)-1) * style.HPadding() - othersWidth := widthAvail / float64(len(others)) - for _, widget := range others { - widget.SetWidth(othersWidth) + // The final allocation step handles children whose width was omitted or set + // to auto. + // + // In a traditional hbox, omitted widths simply split the leftover width + // evenly. The new auto mode only changes the roomy case: when there is more + // than enough space for everyone's preferred widths, omitted children keep + // their preferred width and only auto children absorb the slack. + if len(auto) > 0 { + remaining := make([]Widget, 0, len(omitted)+len(auto)) + remaining = append(remaining, omitted...) + remaining = append(remaining, auto...) + paddingCost := float64(len(remaining)-1) * style.HPadding() + preferredTotal := 0.0 + for _, widget := range remaining { + preferredTotal += widget.PreferredWidth(writer) } - } else { + if widthAvail > preferredTotal+paddingCost { + widthAvail -= paddingCost + for _, widget := range omitted { + pw := widget.PreferredWidth(writer) + widget.ResolveWidth(pw) + widthAvail -= pw + } + autoWidth := widthAvail / float64(len(auto)) + for _, widget := range auto { + widget.ResolveWidth(autoWidth) + } + } else if widthAvail-float64(len(remaining)-1)*style.HPadding() >= float64(len(remaining)) { + widthAvail -= float64(len(remaining)-1) * style.HPadding() + remainingWidth := widthAvail / float64(len(remaining)) + for _, widget := range remaining { + widget.ResolveWidth(remainingWidth) + } + } else { + containerFull = true + for _, widget := range remaining { + widget.SetDisabled(true) + } + } + // With no auto widths present, omitted children keep the historical hbox + // behavior: split whatever width remains evenly. + } else if len(omitted) > 0 && widthAvail-float64(len(omitted)-1)*style.HPadding() >= float64(len(omitted)) { + widthAvail -= float64(len(omitted)-1) * style.HPadding() + omittedWidth := widthAvail / float64(len(omitted)) + for _, widget := range omitted { + widget.ResolveWidth(omittedWidth) + } + } else if len(omitted) > 0 { containerFull = true - for _, widget := range others { + for _, widget := range omitted { widget.SetDisabled(true) } } + // HBox does not negotiate heights. Unspecified or auto heights simply fall + // back to preferred height, then each child is vertically aligned within the + // container's cross axis. for _, widget := range static { - if !widget.HeightIsSet() { - widget.SetHeight(widget.PreferredHeight(writer)) + if widgetAutoHeight(widget) || !widgetHeightSpecified(widget) { + widget.ResolveHeight(widget.PreferredHeight(writer)) } widget.SetTop(hboxCrossAxisTop(container, widget)) } + // Placement is performed after every width is known. We maintain two moving + // edges so left/right-aligned panels can claim the outer slots first and the + // unaligned run naturally fills the space between them. left := ContentLeft(container) right := ContentRight(container) + // RTL reverses the interpretation of the logical left/right runs while + // keeping the same sizing decisions. The groups are still processed in a + // stable order so authored child order remains meaningful. if IsRTL(container) { for _, widget := range lpanels { if widget.Disabled() { @@ -134,6 +232,9 @@ func LayoutHBox(container Container, style *LayoutStyle, writer Writer) { } } + // An unsized hbox takes the tallest participating child as its content + // height. Child layout runs after this so nested widgets see the final box + // dimensions chosen above. if !container.HeightIsSet() { contentHeight := 0.0 for _, widget := range static { @@ -141,7 +242,7 @@ func LayoutHBox(container Container, style *LayoutStyle, writer Writer) { contentHeight = widget.Height() } } - container.SetHeight(contentHeight + NonContentHeight(container)) + container.ResolveHeight(contentHeight + NonContentHeight(container)) } for _, widget := range static { if widget.Visible() && !widget.Disabled() { @@ -151,12 +252,33 @@ func LayoutHBox(container Container, style *LayoutStyle, writer Writer) { layoutPositionedChildren(container, writer) } +// LayoutVBox performs vertical stacking with separate treatment for headers, +// body children, and footers. +// +// The vertical axis is more subtle than hbox because vbox must reconcile three +// concerns at once: +// +// - natural-height containers need to discover their own height from children +// - constrained containers must stop or split when they run out of room +// - height="auto" can absorb only true surplus height, and only after the +// baseline stack of specified, percent, and preferred heights has been +// computed +// +// As in hbox, the function first resolves the child sizes it needs, then +// performs placement with explicit overflow checks. func LayoutVBox(container Container, style *LayoutStyle, writer Writer) { containerFull := false + + // Only static children are part of the vertical flow. Positioned children are + // laid out afterward in their own coordinate system. static, remaining := printableWidgets(container, Static) for _, widget := range remaining { widget.SetVisible(false) } + + // Top-aligned children are treated as headers, bottom-aligned children as + // footers, and everything else as the body run. This lets vbox reserve footer + // space from the bottom while still laying out the main body top-to-bottom. var headers, footers, unaligned []Widget for _, widget := range static { switch widget.Align() { @@ -169,8 +291,14 @@ func LayoutVBox(container Container, style *LayoutStyle, writer Writer) { } } rtl := IsRTL(container) + + // Width resolution in vbox is simpler than hbox: children are stacked, so + // each child can independently take its preferred width up to the content + // width. Paragraphs are a deliberate special case because their natural width + // is effectively the full available measure rather than the unwrapped line + // width of their text. for _, widget := range static { - if !widget.WidthIsSet() { + if widgetAutoWidth(widget) || !widgetWidthSpecified(widget) { cw := ContentWidth(container) pw := 0.0 if _, ok := widget.(*StdParagraph); ok { @@ -182,41 +310,90 @@ func LayoutVBox(container Container, style *LayoutStyle, writer Writer) { pw = cw } w := min(pw, cw) - widget.SetWidth(w) + widget.ResolveWidth(w) } widget.SetLeft(vboxCrossAxisLeft(container, widget, rtl)) } + + // Before we place any child, compute the baseline vertical stack that this + // fragment would occupy with no surplus distribution. We keep the per-widget + // resolved height in a side map so we can later add auto-height surplus + // without losing track of the original preferred/specifed result. + resolvedHeights := make(map[Widget]float64, len(static)) + autoWidgets := make([]Widget, 0, len(static)) + baselineHeight := 0.0 + seen := 0 + for _, group := range [][]Widget{headers, unaligned, footers} { + for _, widget := range group { + height := widget.Height() + if !widgetHeightSpecified(widget) { + height = widget.PreferredHeight(writer) + if widgetAutoHeight(widget) && !widgetZeroFootprint(widget) { + autoWidgets = append(autoWidgets, widget) + } + } + resolvedHeights[widget] = height + if widgetZeroFootprint(widget) { + continue + } + if seen > 0 { + baselineHeight += style.VPadding() + } + baselineHeight += height + seen++ + } + } + + // For a natural-height vbox, the baseline stack determines the container's + // own resolved height. + // + // For a constrained vbox, the baseline is instead used to decide whether + // there is true surplus height that auto children should absorb. Omitted + // heights never absorb this slack; they stay at preferred height. if !container.HeightIsSet() { - container.SetHeight(vboxNaturalContentHeight(style, writer, headers, unaligned, footers) + NonContentHeight(container)) + container.ResolveHeight(baselineHeight + NonContentHeight(container)) + } else if len(autoWidgets) > 0 { + if surplus := ContentHeight(container) - baselineHeight; surplus > 0 { + extra := surplus / float64(len(autoWidgets)) + for _, widget := range autoWidgets { + resolvedHeights[widget] += extra + } + } + } + + // Commit the resolved heights for any child that was not already height- + // specified by the author. This keeps the later placement and nested layout + // passes working from a stable page-local height. + for _, widget := range static { + if !widgetHeightSpecified(widget) { + widget.ResolveHeight(resolvedHeights[widget]) + } } - top, dy := ContentTop(container), 0.0 + + // top advances downward through the content band. bottom is the maximum + // usable bottom edge for this fragment, which matters both for overflow and + // for footer placement. + top := ContentTop(container) bottom := ContentTop(container) + MaxContentHeight(container) - for i, widget := range headers { + // Headers consume space from the top in source order. + for _, widget := range headers { widget.SetTop(top) widget.LayoutWidget(writer) - if !widget.HeightIsSet() { - widget.SetHeight(widget.PreferredHeight(writer)) - } if widgetZeroFootprint(widget) { widget.SetVisible(widget.Top() <= bottom) continue } top += widget.Height() + style.VPadding() - dy += widget.Height() - if i > 0 { - dy += style.VPadding() - } widget.SetVisible(widget.Bottom() <= bottom) } + // Footers are placed from the bottom upward so they reserve their space + // before the body run is checked for overflow. if len(footers) > 0 { footerBottom := bottom for i := len(footers) - 1; i >= 0; i-- { widget := footers[i] - if !widget.HeightIsSet() { - widget.SetHeight(widget.PreferredHeight(writer)) - } widget.SetBottom(footerBottom) widget.LayoutWidget(writer) if widgetZeroFootprint(widget) { @@ -228,54 +405,31 @@ func LayoutVBox(container Container, style *LayoutStyle, writer Writer) { } } - for i, widget := range unaligned { + // The unaligned body run consumes whatever vertical band remains between the + // headers and footers. Once a non-zero-footprint child would cross the bottom + // edge, that child is hidden and later siblings are skipped for this fragment. + for _, widget := range unaligned { widget.SetVisible(!containerFull) if containerFull { continue } widget.SetTop(top) widget.LayoutWidget(writer) - if !widget.HeightIsSet() { - widget.SetHeight(widget.PreferredHeight(writer)) - } if widgetZeroFootprint(widget) { widget.SetVisible(widget.Top() <= bottom) continue } top += widget.Height() - dy += widget.Height() - if i > 0 { - dy += style.VPadding() - } if top > bottom+layoutFitEpsilon { containerFull = true widget.SetVisible(false) } top += style.VPadding() } - layoutPositionedChildren(container, writer) -} -func vboxNaturalContentHeight(style *LayoutStyle, writer Writer, groups ...[]Widget) float64 { - height := 0.0 - seen := 0 - for _, group := range groups { - for _, widget := range group { - if widgetZeroFootprint(widget) { - continue - } - if seen > 0 { - height += style.VPadding() - } - widgetHeight := widget.Height() - if !widget.HeightIsSet() { - widgetHeight = widget.PreferredHeight(writer) - } - height += widgetHeight - seen++ - } - } - return height + // Positioned children are intentionally outside the static stacking + // algorithm, so they are laid out after the vbox flow has settled. + layoutPositionedChildren(container, writer) } func hboxCrossAxisTop(container Container, widget Widget) float64 { @@ -292,6 +446,38 @@ func hboxCrossAxisTop(container Container, widget Widget) float64 { } } +func widgetWidthSpecified(widget Widget) bool { + if widget.LeftIsSet() && widget.RightIsSet() { + return true + } + switch widget.WidthMode() { + case DimLiteral, DimPct, DimRel: + return true + default: + return false + } +} + +func widgetHeightSpecified(widget Widget) bool { + if widget.TopIsSet() && widget.BottomIsSet() { + return true + } + switch widget.HeightMode() { + case DimLiteral, DimPct, DimRel: + return true + default: + return false + } +} + +func widgetAutoWidth(widget Widget) bool { + return widget.WidthMode() == DimAuto && !(widget.LeftIsSet() && widget.RightIsSet()) +} + +func widgetAutoHeight(widget Widget) bool { + return widget.HeightMode() == DimAuto && !(widget.TopIsSet() && widget.BottomIsSet()) +} + func vboxCrossAxisLeft(container Container, widget Widget, rtl bool) float64 { switch widget.SelfAlign() { case SelfAlignCenter: diff --git a/ltml/layout_box_test.go b/ltml/layout_box_test.go new file mode 100644 index 0000000..6aeaf3b --- /dev/null +++ b/ltml/layout_box_test.go @@ -0,0 +1,455 @@ +package ltml + +import ( + "math" + "testing" +) + +func testHBoxLabel(t *testing.T, text string) *StdLabel { + t.Helper() + label := &StdLabel{} + label.font = &FontStyle{id: "body", entries: []fontEntry{{name: "Helvetica"}}, size: 11} + label.SetAttrs(map[string]string{"padding": "6pt"}) + label.AddText(text) + return label +} + +func TestLayoutVBox_AlignSelfCenterCentersTopChildHorizontally(t *testing.T) { + c := positionedContainer(0, 0, 300, 200) + style := &LayoutStyle{} + + w := &positionedTestWidget{preferredWidth: 100, preferredHeight: 30} + w.SetWidth(100) + w.SetAttrs(map[string]string{"align": "top", "align-self": "center"}) + w.SetContainer(c) + c.AddChild(w) + + LayoutVBox(c, style, nil) + + if got := w.Left(); got != 100 { + t.Errorf("vbox top child left = %v, want 100", got) + } + if got := w.Top(); got != 0 { + t.Errorf("vbox top child top = %v, want 0", got) + } +} + +func TestLayoutVBox_ParagraphDefaultsToFullWidth(t *testing.T) { + c := positionedContainer(0, 0, 300, 200) + p := &StdParagraph{} + if err := p.SetContainer(c); err != nil { + t.Fatal(err) + } + p.paragraphStyle = &ParagraphStyle{} + p.font = &FontStyle{id: "body", entries: []fontEntry{{name: "Helvetica"}}, size: 12} + p.AddText("Short heading") + c.AddChild(p) + + LayoutVBox(c, &LayoutStyle{}, &labelTestWriter{t: t}) + + if got := p.Width(); got != 300 { + t.Fatalf("paragraph width = %v, want 300", got) + } + if got := p.Left(); got != 0 { + t.Fatalf("paragraph left = %v, want 0", got) + } +} + +func TestLayoutHBox_AutoWidthAbsorbsOnlySurplusSpace(t *testing.T) { + c := positionedContainer(0, 0, 400, 100) + style := &LayoutStyle{hpadding: 10} + + fixed := &positionedTestWidget{preferredWidth: 40, preferredHeight: 20} + fixed.SetWidth(40) + if err := fixed.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(fixed) + + pct := &positionedTestWidget{preferredWidth: 30, preferredHeight: 20} + pct.SetWidthPct(25) + if err := pct.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(pct) + + omitted := &positionedTestWidget{preferredWidth: 80, preferredHeight: 20} + if err := omitted.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(omitted) + + auto1 := &positionedTestWidget{preferredWidth: 60, preferredHeight: 20} + auto1.SetWidthAuto() + if err := auto1.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(auto1) + + auto2 := &positionedTestWidget{preferredWidth: 40, preferredHeight: 20} + auto2.SetWidthAuto() + if err := auto2.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(auto2) + + LayoutHBox(c, style, nil) + + if got := fixed.Width(); got != 40 { + t.Fatalf("fixed.Width() = %v, want 40", got) + } + if got := pct.Width(); got != 100 { + t.Fatalf("pct.Width() = %v, want 100", got) + } + if got := omitted.Width(); got != 80 { + t.Fatalf("omitted.Width() = %v, want 80", got) + } + if got := auto1.Width(); got != 70 { + t.Fatalf("auto1.Width() = %v, want 70", got) + } + if got := auto2.Width(); got != 70 { + t.Fatalf("auto2.Width() = %v, want 70", got) + } +} + +func TestLayoutHBox_AutoWidthHasNoEffectWhenConstrained(t *testing.T) { + c := positionedContainer(0, 0, 340, 100) + style := &LayoutStyle{hpadding: 10} + + fixed := &positionedTestWidget{preferredWidth: 40, preferredHeight: 20} + fixed.SetWidth(40) + if err := fixed.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(fixed) + + pct := &positionedTestWidget{preferredWidth: 30, preferredHeight: 20} + pct.SetWidthPct(25) + if err := pct.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(pct) + + omitted := &positionedTestWidget{preferredWidth: 80, preferredHeight: 20} + if err := omitted.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(omitted) + + auto1 := &positionedTestWidget{preferredWidth: 60, preferredHeight: 20} + auto1.SetWidthAuto() + if err := auto1.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(auto1) + + auto2 := &positionedTestWidget{preferredWidth: 40, preferredHeight: 20} + auto2.SetWidthAuto() + if err := auto2.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(auto2) + + LayoutHBox(c, style, nil) + + for _, widget := range []*positionedTestWidget{omitted, auto1, auto2} { + if got := widget.Width(); math.Abs(got-(175.0/3.0)) > 0.001 { + t.Fatalf("widget.Width() = %v, want %v", got, 175.0/3.0) + } + } +} + +func TestLayoutHBox_AutoWidthMatchesDirectLayoutAfterVBoxProbe(t *testing.T) { + writer := &labelTestWriter{t: t} + hboxStyle := &LayoutStyle{manager: "hbox", hpadding: 10} + + buildBox := func() (*StdContainer, []*StdLabel) { + box := &StdContainer{} + box.layout = hboxStyle + box.SetWidth(3.9 * 72) + box.SetAttrs(map[string]string{"padding": "8pt"}) + + fixed := testHBoxLabel(t, "fixed") + fixed.SetWidth(0.75 * 72) + if err := fixed.SetContainer(box); err != nil { + t.Fatal(err) + } + box.AddChild(fixed) + + preferred := testHBoxLabel(t, "preferred width") + if err := preferred.SetContainer(box); err != nil { + t.Fatal(err) + } + box.AddChild(preferred) + + auto1 := testHBoxLabel(t, "auto A") + auto1.SetWidthAuto() + if err := auto1.SetContainer(box); err != nil { + t.Fatal(err) + } + box.AddChild(auto1) + + auto2 := testHBoxLabel(t, "auto B") + auto2.SetWidthAuto() + if err := auto2.SetContainer(box); err != nil { + t.Fatal(err) + } + box.AddChild(auto2) + + return box, []*StdLabel{fixed, preferred, auto1, auto2} + } + + directBox, directLabels := buildBox() + LayoutHBox(directBox, hboxStyle, writer) + directWidths := make([]float64, len(directLabels)) + for i, label := range directLabels { + directWidths[i] = label.Width() + } + + probedBox, probedLabels := buildBox() + outer := positionedContainer(0, 0, 400, 300) + outer.layout = &LayoutStyle{manager: "vbox"} + if err := probedBox.SetContainer(outer); err != nil { + t.Fatal(err) + } + outer.AddChild(probedBox) + + LayoutVBox(outer, outer.layout, writer) + + for i, label := range probedLabels { + if got := label.Width(); math.Abs(got-directWidths[i]) > 0.001 { + t.Fatalf("label %d width after vbox probe = %v, want %v", i, got, directWidths[i]) + } + } +} + +func TestLayoutHBox_WithoutAutoKeepsExistingEqualShareBehavior(t *testing.T) { + c := positionedContainer(0, 0, 300, 100) + style := &LayoutStyle{hpadding: 10} + + fixed := &positionedTestWidget{preferredWidth: 40, preferredHeight: 20} + fixed.SetWidth(40) + if err := fixed.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(fixed) + + pct := &positionedTestWidget{preferredWidth: 30, preferredHeight: 20} + pct.SetWidthPct(100.0 / 3.0) + if err := pct.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(pct) + + omitted1 := &positionedTestWidget{preferredWidth: 80, preferredHeight: 20} + if err := omitted1.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(omitted1) + + omitted2 := &positionedTestWidget{preferredWidth: 50, preferredHeight: 20} + if err := omitted2.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(omitted2) + + LayoutHBox(c, style, nil) + + if got := omitted1.Width(); math.Abs(got-65) > 0.001 { + t.Fatalf("omitted1.Width() = %v, want 65", got) + } + if got := omitted2.Width(); math.Abs(got-65) > 0.001 { + t.Fatalf("omitted2.Width() = %v, want 65", got) + } +} + +func TestLayoutHBox_AlignSelfCenterCentersLeftChildVertically(t *testing.T) { + c := positionedContainer(0, 0, 300, 100) + style := &LayoutStyle{} + + w := &positionedTestWidget{preferredWidth: 80, preferredHeight: 20} + w.SetWidth(80) + w.SetAttrs(map[string]string{"align": "left", "align-self": "center"}) + w.SetContainer(c) + c.AddChild(w) + + LayoutHBox(c, style, nil) + + if got := w.Top(); got != 40 { + t.Errorf("hbox left child top = %v, want 40", got) + } + if got := w.Left(); got != 0 { + t.Errorf("hbox left child left = %v, want 0", got) + } +} + +func TestLayoutVBox_AutoWidthMatchesOmittedWidth(t *testing.T) { + c := positionedContainer(0, 0, 200, 100) + style := &LayoutStyle{} + + omitted := &positionedTestWidget{preferredWidth: 80, preferredHeight: 20} + if err := omitted.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(omitted) + + auto := &positionedTestWidget{preferredWidth: 80, preferredHeight: 20} + auto.SetWidthAuto() + if err := auto.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(auto) + + LayoutVBox(c, style, nil) + + if got := omitted.Width(); got != 80 { + t.Fatalf("omitted.Width() = %v, want 80", got) + } + if got := auto.Width(); got != 80 { + t.Fatalf("auto.Width() = %v, want 80", got) + } +} + +func TestLayoutVBox_AutoHeightAbsorbsOnlySurplusSpace(t *testing.T) { + c := positionedContainer(0, 0, 200, 200) + style := &LayoutStyle{vpadding: 10} + + fixed := &positionedTestWidget{preferredWidth: 80, preferredHeight: 40} + fixed.SetHeight(40) + _ = fixed.SetContainer(c) + c.AddChild(fixed) + + pct := &positionedTestWidget{preferredWidth: 80, preferredHeight: 15} + pct.SetHeightPct(25) + _ = pct.SetContainer(c) + c.AddChild(pct) + + omitted := &positionedTestWidget{preferredWidth: 80, preferredHeight: 30} + _ = omitted.SetContainer(c) + c.AddChild(omitted) + + auto1 := &positionedTestWidget{preferredWidth: 80, preferredHeight: 20} + auto1.SetHeightAuto() + _ = auto1.SetContainer(c) + c.AddChild(auto1) + + auto2 := &positionedTestWidget{preferredWidth: 80, preferredHeight: 10} + auto2.SetHeightAuto() + _ = auto2.SetContainer(c) + c.AddChild(auto2) + + LayoutVBox(c, style, nil) + + if got := fixed.Height(); got != 40 { + t.Fatalf("fixed.Height() = %v, want 40", got) + } + if got := pct.Height(); got != 50 { + t.Fatalf("pct.Height() = %v, want 50", got) + } + if got := omitted.Height(); got != 30 { + t.Fatalf("omitted.Height() = %v, want 30", got) + } + if got := auto1.Height(); got != 25 { + t.Fatalf("auto1.Height() = %v, want 25", got) + } + if got := auto2.Height(); got != 15 { + t.Fatalf("auto2.Height() = %v, want 15", got) + } +} + +func TestLayoutVBox_AutoHeightHasNoEffectWhenNotRoomy(t *testing.T) { + c := positionedContainer(0, 0, 200, 130) + style := &LayoutStyle{vpadding: 10} + + fixed := &positionedTestWidget{preferredWidth: 80, preferredHeight: 40} + fixed.SetHeight(40) + _ = fixed.SetContainer(c) + c.AddChild(fixed) + + omitted := &positionedTestWidget{preferredWidth: 80, preferredHeight: 30} + _ = omitted.SetContainer(c) + c.AddChild(omitted) + + auto1 := &positionedTestWidget{preferredWidth: 80, preferredHeight: 20} + auto1.SetHeightAuto() + _ = auto1.SetContainer(c) + c.AddChild(auto1) + + auto2 := &positionedTestWidget{preferredWidth: 80, preferredHeight: 10} + auto2.SetHeightAuto() + _ = auto2.SetContainer(c) + c.AddChild(auto2) + + LayoutVBox(c, style, nil) + + if got := omitted.Height(); got != 30 { + t.Fatalf("omitted.Height() = %v, want 30", got) + } + if got := auto1.Height(); got != 20 { + t.Fatalf("auto1.Height() = %v, want 20", got) + } + if got := auto2.Height(); got != 10 { + t.Fatalf("auto2.Height() = %v, want 10", got) + } +} + +func TestLayoutVBox_AutoHeightMatchesOmittedHeightWhenContainerIsNatural(t *testing.T) { + c := positionedContainer(0, 0, 200, 0) + c.ClearHeight() + style := &LayoutStyle{vpadding: 10} + + omitted := &positionedTestWidget{preferredWidth: 80, preferredHeight: 30} + _ = omitted.SetContainer(c) + c.AddChild(omitted) + + auto := &positionedTestWidget{preferredWidth: 80, preferredHeight: 20} + auto.SetHeightAuto() + _ = auto.SetContainer(c) + c.AddChild(auto) + + LayoutVBox(c, style, nil) + + if got := omitted.Height(); got != 30 { + t.Fatalf("omitted.Height() = %v, want 30", got) + } + if got := auto.Height(); got != 20 { + t.Fatalf("auto.Height() = %v, want 20", got) + } + if got := c.Height(); got != 60 { + t.Fatalf("container.Height() = %v, want 60", got) + } +} + +func TestLayoutHBox_ContainerAlignBottomStillBottomAlignsChildrenByDefault(t *testing.T) { + c := positionedContainer(0, 0, 300, 100) + c.align = AlignBottom + style := &LayoutStyle{} + + w := &positionedTestWidget{preferredWidth: 80, preferredHeight: 20} + w.SetWidth(80) + w.SetContainer(c) + c.AddChild(w) + + LayoutHBox(c, style, nil) + + if got := w.Top(); got != 80 { + t.Errorf("default hbox child top with container align bottom = %v, want 80", got) + } +} + +func TestLayoutHBox_AlignSelfEndBottomAlignsChild(t *testing.T) { + c := positionedContainer(0, 0, 300, 100) + style := &LayoutStyle{} + + w := &positionedTestWidget{preferredWidth: 80, preferredHeight: 20} + w.SetWidth(80) + w.SetAttrs(map[string]string{"align-self": "end"}) + w.SetContainer(c) + c.AddChild(w) + + LayoutHBox(c, style, nil) + + if got := w.Top(); got != 80 { + t.Errorf("hbox end-aligned child top = %v, want 80", got) + } +} diff --git a/ltml/layout_flow.go b/ltml/layout_flow.go index a8e8228..9c65039 100644 --- a/ltml/layout_flow.go +++ b/ltml/layout_flow.go @@ -1,10 +1,15 @@ package ltml +import "math" + func LayoutFlow(container Container, style *LayoutStyle, writer Writer) { var cx, cy, maxY float64 rtl := IsRTL(container) containerFull := false - bottom := ContentTop(container) + MaxContentHeight(container) + bottom := math.Inf(1) + if container.Height() != 0 { + bottom = ContentTop(container) + MaxContentHeight(container) + } widgets, remaining := printableWidgets(container, Static) for _, widget := range remaining { widget.SetVisible(false) @@ -15,8 +20,8 @@ func LayoutFlow(container Container, style *LayoutStyle, writer Writer) { continue } if widgetZeroFootprint(widget) { - widget.SetWidth(0) - widget.SetHeight(0) + widget.ResolveWidth(0) + widget.ResolveHeight(0) if rtl { widget.SetLeft(ContentRight(container)) } else { @@ -27,14 +32,14 @@ func LayoutFlow(container Container, style *LayoutStyle, writer Writer) { widget.SetVisible(widget.Top() <= bottom) continue } - if w := widget.Width(); w == 0 { + if widgetAutoWidth(widget) || !widgetWidthSpecified(widget) { pw := widget.PreferredWidth(writer) cw := ContentWidth(container) if pw == 0 { pw = cw } - w = min(pw, cw) - widget.SetWidth(w) + w := min(pw, cw) + widget.ResolveWidth(w) } if cx != 0 && (cx+widget.Width()) > ContentWidth(container) { cy += maxY + style.VPadding() @@ -46,8 +51,8 @@ func LayoutFlow(container Container, style *LayoutStyle, writer Writer) { widget.SetLeft(ContentLeft(container) + cx) } widget.SetTop(ContentTop(container) + cy) - if h := widget.Height(); h == 0 { - widget.SetHeight(widget.PreferredHeight(writer)) + if widgetAutoHeight(widget) || !widgetHeightSpecified(widget) { + widget.ResolveHeight(widget.PreferredHeight(writer)) } widget.LayoutWidget(writer) if widget.Bottom() > bottom { @@ -59,7 +64,7 @@ func LayoutFlow(container Container, style *LayoutStyle, writer Writer) { maxY = max(maxY, widget.Height()) } if container.Height() == 0 && maxY > 0 { - container.SetHeight(cy + maxY + NonContentHeight(container)) + container.ResolveHeight(cy + maxY + NonContentHeight(container)) } layoutPositionedChildren(container, writer) } diff --git a/ltml/layout_flow_test.go b/ltml/layout_flow_test.go new file mode 100644 index 0000000..8ca0f47 --- /dev/null +++ b/ltml/layout_flow_test.go @@ -0,0 +1,128 @@ +package ltml + +import ( + "math" + "testing" + + "github.com/rowland/leadtype/ltml/ltpdf" + "github.com/rowland/leadtype/pdf" +) + +func TestLayoutFlow_RecomputesUnspecifiedCardHeightsAfterVBoxProbe(t *testing.T) { + writer := &labelTestWriter{t: t} + flowStyle := &LayoutStyle{manager: "flow", hpadding: 12, vpadding: 12} + + buildFlow := func() (*StdContainer, []*StdContainer) { + flow := &StdContainer{} + flow.layout = flowStyle + flow.SetLeft(0) + flow.SetTop(0) + flow.SetWidth(550) + flow.SetHeight(300) + + cards := make([]*StdContainer, 0, 5) + for _, contentWidth := range []float64{76, 68, 60, 52, 44} { + card := &StdContainer{} + card.layout = &LayoutStyle{manager: "vbox", vpadding: 8} + card.SetWidth(92) + card.SetAttrs(map[string]string{"padding": "8pt"}) + if err := card.SetContainer(flow); err != nil { + t.Fatal(err) + } + flow.AddChild(card) + + draw := &positionedTestWidget{preferredWidth: contentWidth, preferredHeight: contentWidth} + draw.SetWidth(contentWidth) + if err := draw.SetContainer(card); err != nil { + t.Fatal(err) + } + card.AddChild(draw) + + caption := &positionedTestWidget{preferredWidth: 76, preferredHeight: 12} + caption.SetWidth(76) + if err := caption.SetContainer(card); err != nil { + t.Fatal(err) + } + card.AddChild(caption) + + cards = append(cards, card) + } + + return flow, cards + } + + directFlow, directCards := buildFlow() + LayoutFlow(directFlow, flowStyle, writer) + directHeights := make([]float64, len(directCards)) + directTops := make([]float64, len(directCards)) + directVisible := make([]bool, len(directCards)) + for i, card := range directCards { + directHeights[i] = card.Height() + directTops[i] = card.Top() + directVisible[i] = card.Visible() + } + + probedFlow, probedCards := buildFlow() + outer := positionedContainer(0, 0, 550, 300) + outer.layout = &LayoutStyle{manager: "vbox"} + if err := probedFlow.SetContainer(outer); err != nil { + t.Fatal(err) + } + outer.AddChild(probedFlow) + + LayoutVBox(outer, outer.layout, writer) + + for i, card := range probedCards { + if got := card.Visible(); got != directVisible[i] { + t.Fatalf("card %d visible = %v, want %v", i, got, directVisible[i]) + } + if got := card.Height(); math.Abs(got-directHeights[i]) > 0.001 { + t.Fatalf("card %d height = %v, want %v", i, got, directHeights[i]) + } + if got := card.Top(); math.Abs(got-directTops[i]) > 0.001 { + t.Fatalf("card %d top = %v, want %v", i, got, directTops[i]) + } + } +} + +func TestCanvasSample_PostageStampFlowCardsRemainVisible(t *testing.T) { + doc, err := ParseFile(sampleFile("test_054_canvas_draw.ltml")) + if err != nil { + t.Fatal(err) + } + ttFonts, afmFonts, err := loadSampleFontSources() + if err != nil { + t.Fatal(err) + } + writer := <pdf.DocWriter{DocWriter: pdf.NewDocWriter()} + writer.AddFontSource(ttFonts) + writer.AddFontSource(afmFonts) + if err := doc.Print(writer); err != nil { + t.Fatal(err) + } + + var flow *StdContainer + walkWidgets(doc.Root(), func(widget Widget) bool { + container, ok := widget.(*StdContainer) + if !ok || container.layout == nil || container.layout.manager != "flow" { + return true + } + flow = container + return false + }) + if flow == nil { + t.Fatal("flow container not found in canvas sample") + } + if len(flow.children) != 5 { + t.Fatalf("flow child count = %d, want 5", len(flow.children)) + } + for i, child := range flow.children { + card, ok := child.(*StdContainer) + if !ok { + t.Fatalf("flow child %d type = %T, want *StdContainer", i, child) + } + if !card.Visible() { + t.Fatalf("flow child %d visible = false (top=%v height=%v bottom=%v flowBottom=%v)", i, card.Top(), card.Height(), card.Bottom(), flow.Bottom()) + } + } +} diff --git a/ltml/layout_overflow_test.go b/ltml/layout_overflow_test.go index d915479..b26f174 100644 --- a/ltml/layout_overflow_test.go +++ b/ltml/layout_overflow_test.go @@ -14,6 +14,7 @@ type flowTestWidget struct { preferredWidth float64 preferredHeight float64 printedOn *[]int + printedHeights *[]float64 layoutCalls int } @@ -39,6 +40,9 @@ func (w *flowTestWidget) DrawContent(Writer) error { *w.printedOn = append(*w.printedOn, doc.CurrentPhysicalPageNo()) } } + if w.printedHeights != nil { + *w.printedHeights = append(*w.printedHeights, w.Height()) + } return nil } @@ -142,7 +146,7 @@ func TestLayoutVBox_DirectChildSplitTableUsesOuterOverflowInsteadOfSelfClipping( table.layout = defaultLayouts["table"].Clone() table.order = TableOrderRows table.cols = 2 - table.widthPct = 100 + table.SetWidthPct(100) table.splitEnabled = true table.splitExplicit = true table.headerRows = 1 @@ -316,6 +320,162 @@ func TestStdPage_VBoxOverflowDefaultsToTrue(t *testing.T) { } } +func TestStdPage_FixedHeightVBoxSplitDistributesAutoHeightPerPage(t *testing.T) { + page := &StdPage{pageStyle: &PageStyle{width: 200, height: 100}} + page.layout = defaultLayouts["vbox"].Clone() + page.overflow = true + doc := newFlowPageDoc(page) + + box := &StdContainer{} + _ = box.SetContainer(page) + box.layout = defaultLayouts["vbox"].Clone() + box.SetWidth(180) + box.SetHeight(140) + page.AddChild(box) + + row1 := &flowTestWidget{name: "row1", preferredHeight: 30} + row1.SetHeight(30) + _ = row1.SetContainer(box) + box.AddChild(row1) + + var auto1Pages, auto2Pages, auto3Pages []int + var auto1Heights, auto2Heights, auto3Heights []float64 + + auto1 := &flowTestWidget{name: "auto1", preferredHeight: 10, printedOn: &auto1Pages, printedHeights: &auto1Heights} + auto1.SetHeightAuto() + _ = auto1.SetContainer(box) + box.AddChild(auto1) + + row3 := &flowTestWidget{name: "row3", preferredHeight: 20} + _ = row3.SetContainer(box) + box.AddChild(row3) + + auto2 := &flowTestWidget{name: "auto2", preferredHeight: 10, printedOn: &auto2Pages, printedHeights: &auto2Heights} + auto2.SetHeightAuto() + _ = auto2.SetContainer(box) + box.AddChild(auto2) + + row5 := &flowTestWidget{name: "row5", preferredHeight: 20} + _ = row5.SetContainer(box) + box.AddChild(row5) + + row6 := &flowTestWidget{name: "row6", preferredHeight: 20} + _ = row6.SetContainer(box) + box.AddChild(row6) + + auto3 := &flowTestWidget{name: "auto3", preferredHeight: 10, printedOn: &auto3Pages, printedHeights: &auto3Heights} + auto3.SetHeightAuto() + _ = auto3.SetContainer(box) + box.AddChild(auto3) + + w := &labelTestWriter{t: t} + if err := doc.Print(w); err != nil { + t.Fatal(err) + } + if w.pageCount != 2 { + t.Fatalf("page count = %d, want 2", w.pageCount) + } + if !slices.Equal(auto1Pages, []int{1}) || len(auto1Heights) != 1 || auto1Heights[0] != 15 { + t.Fatalf("auto1 = pages:%v heights:%v, want page 1 height 15", auto1Pages, auto1Heights) + } + if !slices.Equal(auto2Pages, []int{1}) || len(auto2Heights) != 1 || auto2Heights[0] != 15 { + t.Fatalf("auto2 = pages:%v heights:%v, want page 1 height 15", auto2Pages, auto2Heights) + } + if !slices.Equal(auto3Pages, []int{2}) || len(auto3Heights) != 1 || auto3Heights[0] != 80 { + t.Fatalf("auto3 = pages:%v heights:%v, want page 2 height 80", auto3Pages, auto3Heights) + } +} + +func TestStdPage_FixedHeightVBoxSplitOnlySharesOnPagesWithAutoChildren(t *testing.T) { + page := &StdPage{pageStyle: &PageStyle{width: 200, height: 100}} + page.layout = defaultLayouts["vbox"].Clone() + page.overflow = true + doc := newFlowPageDoc(page) + + box := &StdContainer{} + _ = box.SetContainer(page) + box.layout = defaultLayouts["vbox"].Clone() + box.SetWidth(180) + box.SetHeight(140) + page.AddChild(box) + + var row1Heights, row2Heights, autoHeights []float64 + var row1Pages, row2Pages, autoPages []int + + row1 := &flowTestWidget{name: "row1", preferredHeight: 50, printedOn: &row1Pages, printedHeights: &row1Heights} + _ = row1.SetContainer(box) + box.AddChild(row1) + + row2 := &flowTestWidget{name: "row2", preferredHeight: 50, printedOn: &row2Pages, printedHeights: &row2Heights} + _ = row2.SetContainer(box) + box.AddChild(row2) + + auto := &flowTestWidget{name: "auto", preferredHeight: 10, printedOn: &autoPages, printedHeights: &autoHeights} + auto.SetHeightAuto() + _ = auto.SetContainer(box) + box.AddChild(auto) + + row4 := &flowTestWidget{name: "row4", preferredHeight: 20} + _ = row4.SetContainer(box) + box.AddChild(row4) + + w := &labelTestWriter{t: t} + if err := doc.Print(w); err != nil { + t.Fatal(err) + } + if w.pageCount != 2 { + t.Fatalf("page count = %d, want 2", w.pageCount) + } + if !slices.Equal(row1Pages, []int{1}) || len(row1Heights) != 1 || row1Heights[0] != 50 { + t.Fatalf("row1 = pages:%v heights:%v, want page 1 height 50", row1Pages, row1Heights) + } + if !slices.Equal(row2Pages, []int{1}) || len(row2Heights) != 1 || row2Heights[0] != 50 { + t.Fatalf("row2 = pages:%v heights:%v, want page 1 height 50", row2Pages, row2Heights) + } + if !slices.Equal(autoPages, []int{2}) || len(autoHeights) != 1 || autoHeights[0] != 80 { + t.Fatalf("auto = pages:%v heights:%v, want page 2 height 80", autoPages, autoHeights) + } +} + +func TestStdPage_NaturalVBoxSplitLeavesAutoHeightAtPreferredSize(t *testing.T) { + page := &StdPage{pageStyle: &PageStyle{width: 200, height: 100}} + page.layout = defaultLayouts["vbox"].Clone() + page.overflow = true + doc := newFlowPageDoc(page) + + box := &StdContainer{} + _ = box.SetContainer(page) + box.layout = defaultLayouts["vbox"].Clone() + box.SetWidth(180) + page.AddChild(box) + + row1 := &flowTestWidget{name: "row1", preferredHeight: 60} + _ = row1.SetContainer(box) + box.AddChild(row1) + + var autoPages []int + var autoHeights []float64 + auto := &flowTestWidget{name: "auto", preferredHeight: 10, printedOn: &autoPages, printedHeights: &autoHeights} + auto.SetHeightAuto() + _ = auto.SetContainer(box) + box.AddChild(auto) + + row3 := &flowTestWidget{name: "row3", preferredHeight: 60} + _ = row3.SetContainer(box) + box.AddChild(row3) + + w := &labelTestWriter{t: t} + if err := doc.Print(w); err != nil { + t.Fatal(err) + } + if w.pageCount != 2 { + t.Fatalf("page count = %d, want 2", w.pageCount) + } + if !slices.Equal(autoPages, []int{1}) || len(autoHeights) != 1 || autoHeights[0] != 10 { + t.Fatalf("auto = pages:%v heights:%v, want page 1 height 10", autoPages, autoHeights) + } +} + func TestStdPage_FlowOverflowDefaultsToTrue(t *testing.T) { page := &StdPage{pageStyle: &PageStyle{width: 100, height: 45}} page.layout = defaultLayouts["flow"].Clone() @@ -597,8 +757,7 @@ func TestStdContainer_SplitForHeight_VBoxRepeatsHeadersAndFooters(t *testing.T) box := &StdContainer{} _ = box.SetContainer(page) box.layout = defaultLayouts["vbox"].Clone() - box.width = 180 - box.widthSet = true + box.SetWidth(180) page.AddChild(box) add := func(name string, height float64, align string) { @@ -649,8 +808,7 @@ func TestStdContainer_SplitForHeight_VBoxSplitsBodyParagraph(t *testing.T) { box := &StdContainer{} _ = box.SetContainer(page) box.layout = defaultLayouts["vbox"].Clone() - box.width = 120 - box.widthSet = true + box.SetWidth(120) page.AddChild(box) header := &flowTestWidget{name: "header", preferredHeight: 10} @@ -708,8 +866,7 @@ func TestStdContainer_SplitForHeight_VBoxReturnsNilWhenRepeatedChromeConsumesPag box := &StdContainer{} _ = box.SetContainer(page) box.layout = defaultLayouts["vbox"].Clone() - box.width = 180 - box.widthSet = true + box.SetWidth(180) page.AddChild(box) header := &flowTestWidget{name: "header", preferredHeight: 20} @@ -896,8 +1053,7 @@ func TestStdContainer_SplitForHeight_TableRepeatsHeaderAndFooterRows(t *testing. table.layout = defaultLayouts["table"].Clone() table.order = TableOrderRows table.cols = 2 - table.width = 180 - table.widthSet = true + table.SetWidth(180) table.splitEnabled = true table.splitExplicit = true table.headerRows = 1 @@ -1276,6 +1432,33 @@ func TestSample_TableSplitHeadersFooters_RepeatsTableFooterRows(t *testing.T) { } } +func TestSample_TableAutoSplit_RendersMultiplePages(t *testing.T) { + doc, err := ParseFile(sampleFile("test_061_table_auto_split.ltml")) + if err != nil { + t.Fatal(err) + } + w := &labelTestWriter{t: t} + if err := doc.Print(w); err != nil { + t.Fatal(err) + } + if w.pageCount < 2 { + t.Fatalf("page count = %d, want at least 2 (printed=%q plain=%q)", w.pageCount, joinRichTexts(w.printed), strings.Join(w.plainPrinted, "\n")) + } + pageTexts := map[int][]string{} + for i, rt := range w.printed { + pageTexts[w.printedPages[i]] = append(pageTexts[w.printedPages[i]], rt.String()) + } + page1Text := strings.Join(pageTexts[1], "\n") + page2Text := strings.Join(pageTexts[2], "\n") + if !strings.Contains(page1Text, "Repeating table header") || !strings.Contains(page2Text, "Repeating table header") { + t.Fatalf("expected repeating table header on first two pages, got page1=%q page2=%q", page1Text, page2Text) + } + allText := strings.Join(w.plainPrinted, "\n") + "\n" + joinRichTexts(w.printed) + if !strings.Contains(allText, "row 10 A") || !strings.Contains(allText, "row 10 B") { + t.Fatalf("expected final table row to render, got %q", allText) + } +} + func joinRichTexts(texts []*rich_text.RichText) string { parts := make([]string, 0, len(texts)) for _, text := range texts { diff --git a/ltml/layout_radial.go b/ltml/layout_radial.go index 88ef005..36be2da 100644 --- a/ltml/layout_radial.go +++ b/ltml/layout_radial.go @@ -75,10 +75,10 @@ func LayoutRadialTable(container Container, style *LayoutStyle, writer Writer) { } } if !container.HeightIsSet() { - container.SetHeight((outerRadius * 2) + NonContentHeight(container)) + container.ResolveHeight((outerRadius * 2) + NonContentHeight(container)) } if !container.WidthIsSet() { - container.SetWidth((outerRadius * 2) + NonContentWidth(container)) + container.ResolveWidth((outerRadius * 2) + NonContentWidth(container)) } } @@ -89,12 +89,12 @@ func inferRadialContainerDimensions(container Container) { } if !base.WidthIsSet() { if width, ok := base.radialInferredWidth(); ok { - base.SetWidth(width) + base.ResolveWidth(width) } } if !base.HeightIsSet() { if height, ok := base.radialInferredHeight(); ok { - base.SetHeight(height) + base.ResolveHeight(height) } } } diff --git a/ltml/layout_table.go b/ltml/layout_table.go index 93e7353..d69fb7b 100644 --- a/ltml/layout_table.go +++ b/ltml/layout_table.go @@ -5,6 +5,23 @@ import ( "math" ) +type tableTrackKind int8 + +const ( + tableTrackOmitted tableTrackKind = iota + tableTrackSpecified + tableTrackPercent + tableTrackAuto +) + +type tableTrackSize struct { + kind tableTrackKind + size float64 + preferred float64 +} + +type tableTrackPlan []tableTrackSize + func markGrid(grid *BoolGrid, a, b, c, d int, value bool) { for aa := 0; aa < c; aa++ { for bb := 0; bb < d; bb++ { @@ -75,136 +92,186 @@ func colGrid(container Container) (*WidgetGrid, error) { return grid, nil } -func detectWidths(grid *WidgetGrid, writer Writer) SpecifiedSizes { - widths := make(SpecifiedSizes, grid.Cols()) +func detectTableColumnTracks(grid *WidgetGrid, writer Writer) tableTrackPlan { + tracks := make(tableTrackPlan, grid.Cols()) for c := 0; c < grid.Cols(); c++ { - var widget Widget + var specifiedWidget Widget + auto := false + preferred := 0.0 for r := 0; r < grid.Rows(); r++ { if w := grid.Cell(c, r); w != nil && w.ColSpan() == 1 { - widget = w - break + if specifiedWidget == nil && (w.WidthPctIsSet() || widgetWidthSpecified(w)) { + specifiedWidget = w + } + if !widgetWidthSpecified(w) && !w.WidthPctIsSet() { + preferred = max(preferred, w.PreferredWidth(writer)) + } + if widgetAutoWidth(w) { + auto = true + } } } - if widget == nil { - widths[c] = &SpecifiedSize{How: Unspecified, Size: 0} - } else if widget.WidthPctIsSet() { - widths[c] = &SpecifiedSize{How: Percent, Size: widget.Width()} - } else if widget.WidthIsSet() { - widths[c] = &SpecifiedSize{How: Specified, Size: widget.Width()} + if specifiedWidget != nil && specifiedWidget.WidthPctIsSet() { + tracks[c] = tableTrackSize{kind: tableTrackPercent, size: specifiedWidget.Width()} + } else if specifiedWidget != nil { + tracks[c] = tableTrackSize{kind: tableTrackSpecified, size: specifiedWidget.Width()} + } else if auto { + tracks[c] = tableTrackSize{kind: tableTrackAuto, preferred: preferred} } else { - max := 0.0 - for r := 0; r < grid.Rows(); r++ { - if w := grid.Cell(c, r); w != nil { - pw := w.PreferredWidth(writer) - if pw > max { - max = pw - } - } - } - widths[c] = &SpecifiedSize{How: Unspecified, Size: max} + tracks[c] = tableTrackSize{kind: tableTrackOmitted, preferred: preferred} } } - return widths + return tracks } -func allocateSpecifiedWidths(widthAvail float64, specified SpecifiedSizes, style *LayoutStyle) float64 { - for _, w := range specified { - if widthAvail >= w.Size { - widthAvail -= (w.Size + style.HPadding()) - } +func (tracks tableTrackPlan) resolvedSizes() []float64 { + sizes := make([]float64, len(tracks)) + for i, track := range tracks { + sizes[i] = track.size } - return widthAvail + return sizes } -func allocatePercentWidths(widthAvail float64, percents SpecifiedSizes, style *LayoutStyle) float64 { - if widthAvail-(float64(len(percents)-1))*style.HPadding() >= float64(len(percents)) { - widthAvail -= float64((len(percents) - 1)) * style.HPadding() +func allocateTableColumnTracks(widthAvail float64, tracks tableTrackPlan, style *LayoutStyle) { + for i := range tracks { + if tracks[i].kind == tableTrackSpecified && widthAvail >= tracks[i].size { + widthAvail -= tracks[i].size + style.HPadding() + } + } + + var percentIndexes []int + for i, track := range tracks { + if track.kind == tableTrackPercent { + percentIndexes = append(percentIndexes, i) + } + } + if len(percentIndexes) > 0 && widthAvail-(float64(len(percentIndexes)-1))*style.HPadding() >= float64(len(percentIndexes)) { + widthAvail -= float64(len(percentIndexes)-1) * style.HPadding() totalPercents := 0.0 - for i := range percents { - totalPercents += percents[i].Size + for _, i := range percentIndexes { + totalPercents += tracks[i].size } ratio := widthAvail / totalPercents - for i := range percents { + for _, i := range percentIndexes { if ratio < 1.0 { - percents[i].Size *= ratio + tracks[i].size *= ratio } - widthAvail -= percents[i].Size + widthAvail -= tracks[i].size } - } else { - for i := range percents { - percents[i].Size = 0 + widthAvail -= style.HPadding() + } else if len(percentIndexes) > 0 { + for _, i := range percentIndexes { + tracks[i].size = 0 } + widthAvail -= style.HPadding() } - widthAvail -= style.HPadding() - return widthAvail -} -func allocateOtherWidths(widthAvail float64, others SpecifiedSizes, style *LayoutStyle) float64 { - if widthAvail-(float64(len(others)-1))*style.HPadding() >= float64(len(others)) { - widthAvail -= float64(len(others)-1) * style.HPadding() - othersWidth := widthAvail / float64(len(others)) - for i := range others { - others[i].Size = othersWidth + var omittedIndexes, autoIndexes []int + for i, track := range tracks { + switch track.kind { + case tableTrackOmitted: + omittedIndexes = append(omittedIndexes, i) + case tableTrackAuto: + autoIndexes = append(autoIndexes, i) + } + } + otherCount := len(omittedIndexes) + len(autoIndexes) + if otherCount == 0 { + return + } + paddingCost := float64(otherCount-1) * style.HPadding() + if len(autoIndexes) > 0 { + preferredTotal := 0.0 + omittedPreferredTotal := 0.0 + for _, i := range omittedIndexes { + preferredTotal += tracks[i].preferred + omittedPreferredTotal += tracks[i].preferred + } + for _, i := range autoIndexes { + preferredTotal += tracks[i].preferred + } + if widthAvail > preferredTotal+paddingCost { + widthAvail -= paddingCost + for _, i := range omittedIndexes { + tracks[i].size = tracks[i].preferred + widthAvail -= tracks[i].size + } + autoWidth := widthAvail / float64(len(autoIndexes)) + for _, i := range autoIndexes { + tracks[i].size = autoWidth + } + return + } + if widthAvail-paddingCost-omittedPreferredTotal >= float64(len(autoIndexes)) { + widthAvail -= paddingCost + for _, i := range omittedIndexes { + tracks[i].size = tracks[i].preferred + widthAvail -= tracks[i].size + } + autoWidth := widthAvail / float64(len(autoIndexes)) + for _, i := range autoIndexes { + tracks[i].size = autoWidth + } + return + } + } + if widthAvail-paddingCost >= float64(otherCount) { + widthAvail -= paddingCost + otherWidth := widthAvail / float64(otherCount) + for _, i := range omittedIndexes { + tracks[i].size = otherWidth + } + for _, i := range autoIndexes { + tracks[i].size = otherWidth } } else { - for i := range others { - others[i].Size = 0 + for _, i := range omittedIndexes { + tracks[i].size = 0 + } + for _, i := range autoIndexes { + tracks[i].size = 0 } } - return widthAvail } -func LayoutTable(container Container, style *LayoutStyle, writer Writer) { - var grid *WidgetGrid - var err error - - if container.Order() == TableOrderRows { - grid, err = rowGrid(container) - } else if container.Order() == TableOrderCols { - grid, err = colGrid(container) - } else { - panic("invalid order") - } - if err != nil { - panic(err) - } +func planTableColumnWidths(grid *WidgetGrid, container Container, style *LayoutStyle, writer Writer) tableTrackPlan { + tracks := detectTableColumnTracks(grid, writer) + allocateTableColumnTracks(ContentWidth(container), tracks, style) + return tracks +} - containerFull := false - widths := detectWidths(grid, writer) - if container.Width() <= 0 { - panic("container width not set") +func tableCellWidth(widths []float64, startCol, colSpan int, hpadding float64) float64 { + width := 0.0 + for i := 0; i < colSpan; i++ { + width += widths[startCol+i] } - percents, others := widths.Partition(func(w *SpecifiedSize) bool { return w.How == Percent }) - specified, others := others.Partition(func(w *SpecifiedSize) bool { return w.How == Specified }) - - widthAvail := ContentWidth(container) - widthAvail = allocateSpecifiedWidths(widthAvail, specified, style) - widthAvail = allocatePercentWidths(widthAvail, percents, style) - widthAvail = allocateOtherWidths(widthAvail, others, style) + return width + float64(colSpan-1)*hpadding +} +func tableBaseHeights(grid *WidgetGrid, widths []float64, style *LayoutStyle, writer Writer) (*SpanSizeGrid, []bool) { heights := NewSpanSizeGrid(grid.Cols(), grid.Rows()) + autoRows := make([]bool, grid.Rows()) for c := 0; c < grid.Cols(); c++ { for r := 0; r < grid.Rows(); r++ { widget := grid.Cell(c, r) if widget == nil { continue } - if widths[c].Size > 0 { - width := 0.0 - for i := 0; i < widget.ColSpan(); i++ { - width += widths[c+i].Size - } - widget.SetWidth(width + float64(widget.ColSpan()-1)*style.HPadding()) - var height float64 - if widget.HeightIsSet() { - height = widget.Height() - } else { - height = widget.PreferredHeight(writer) - } - heights.SetCell(c, r, SpanSize{Span: widget.RowSpan(), Size: height}) - } else { + if widths[c] <= 0 { widget.SetDisabled(true) + continue + } + widget.ResolveWidth(tableCellWidth(widths, c, widget.ColSpan(), style.HPadding())) + var height float64 + if widget.HeightIsSet() { + height = widget.Height() + } else { + height = widget.PreferredHeight(writer) + if widgetAutoHeight(widget) && widget.RowSpan() == 1 { + autoRows[r] = true + } } + heights.SetCell(c, r, SpanSize{Span: widget.RowSpan(), Size: height}) } } @@ -230,6 +297,66 @@ func LayoutTable(container Container, style *LayoutStyle, writer Writer) { heights.SetCell(c, r, ss) } } + return heights, autoRows +} + +func applyTableAutoRowHeights(container Container, style *LayoutStyle, heights *SpanSizeGrid, autoRows []bool) { + if !container.HeightIsSet() { + return + } + autoCount := 0 + baselineHeight := 0.0 + for r := 0; r < heights.Rows(); r++ { + if r > 0 { + baselineHeight += style.VPadding() + } + baselineHeight += heights.Cell(0, r).Size + if autoRows[r] { + autoCount++ + } + } + if autoCount == 0 { + return + } + surplus := ContentHeight(container) - baselineHeight + if surplus <= 0 { + return + } + extra := surplus / float64(autoCount) + for r := 0; r < heights.Rows(); r++ { + if !autoRows[r] { + continue + } + for c := 0; c < heights.Cols(); c++ { + ss := heights.Cell(c, r) + ss.Size += extra + heights.SetCell(c, r, ss) + } + } +} + +func LayoutTable(container Container, style *LayoutStyle, writer Writer) { + var grid *WidgetGrid + var err error + + if container.Order() == TableOrderRows { + grid, err = rowGrid(container) + } else if container.Order() == TableOrderCols { + grid, err = colGrid(container) + } else { + panic("invalid order") + } + if err != nil { + panic(err) + } + + containerFull := false + if container.Width() <= 0 { + panic("container width not set") + } + widths := planTableColumnWidths(grid, container, style, writer).resolvedSizes() + heights, autoRows := tableBaseHeights(grid, widths, style, writer) + applyTableAutoRowHeights(container, style, heights, autoRows) top := ContentTop(container) bottom := top + MaxContentHeight(container) @@ -253,11 +380,7 @@ func LayoutTable(container Container, style *LayoutStyle, writer Writer) { ss := heights.Cell(c, r) widget.SetTop(top) if rtl { - colWidth := widths[c].Size - for i := 1; i < widget.ColSpan(); i++ { - colWidth += widths[c+i].Size + style.HPadding() - } - widget.SetLeft(right - colWidth) + widget.SetLeft(right - tableCellWidth(widths, c, widget.ColSpan(), style.HPadding())) } else { widget.SetLeft(left) } @@ -265,13 +388,13 @@ func LayoutTable(container Container, style *LayoutStyle, writer Writer) { for rowOffset := 0; rowOffset < ss.Span; rowOffset++ { height += heights.Cell(c, r+rowOffset).Size } - widget.SetHeight(height) + widget.ResolveHeight(height) if ss.Span == 1 && ss.Size > maxHeight { maxHeight = ss.Size } } - left += widths[c].Size + style.HPadding() - right -= widths[c].Size + style.HPadding() + left += widths[c] + style.HPadding() + right -= widths[c] + style.HPadding() } if containerFull { continue @@ -289,7 +412,7 @@ func LayoutTable(container Container, style *LayoutStyle, writer Writer) { } } if !container.HeightIsSet() { - container.SetHeight(top - ContentTop(container) + NonContentHeight(container) - style.VPadding()) + container.ResolveHeight(top - ContentTop(container) + NonContentHeight(container) - style.VPadding()) } static, remaining := printableWidgets(container, Static) for _, widget := range remaining { diff --git a/ltml/layout_table_test.go b/ltml/layout_table_test.go new file mode 100644 index 0000000..7677c19 --- /dev/null +++ b/ltml/layout_table_test.go @@ -0,0 +1,276 @@ +package ltml + +import "testing" + +func testTableContainer(width, height float64, cols int) *StdContainer { + page := &StdPage{pageStyle: &PageStyle{width: 400, height: 400}} + c := &StdContainer{} + _ = c.SetContainer(page) + c.SetWidth(width) + if height > 0 { + c.SetHeight(height) + } + c.cols = cols + c.order = TableOrderRows + c.layout = defaultLayouts["table"].Clone() + return c +} + +func addTableTestWidget(t *testing.T, c *StdContainer, preferredWidth, preferredHeight float64) *positionedTestWidget { + t.Helper() + w := &positionedTestWidget{preferredWidth: preferredWidth, preferredHeight: preferredHeight} + if err := w.SetContainer(c); err != nil { + t.Fatal(err) + } + c.AddChild(w) + return w +} + +func TestLayoutTable_AutoWidthUsesSurplusWithoutChangingOmittedColumns(t *testing.T) { + c := testTableContainer(300, 80, 4) + + fixed := addTableTestWidget(t, c, 40, 20) + fixed.SetWidth(60) + + omitted := addTableTestWidget(t, c, 35, 20) + autoA := addTableTestWidget(t, c, 40, 20) + autoA.SetWidthAuto() + autoB := addTableTestWidget(t, c, 30, 20) + autoB.SetWidthAuto() + + LayoutTable(c, c.LayoutStyle(), &labelTestWriter{t: t}) + + if got := fixed.Width(); got != 60 { + t.Fatalf("fixed.Width() = %v, want 60", got) + } + if got := omitted.Width(); got != 35 { + t.Fatalf("omitted.Width() = %v, want preferred width 35", got) + } + if got := autoA.Width(); got != 102.5 { + t.Fatalf("autoA.Width() = %v, want 102.5", got) + } + if got := autoB.Width(); got != 102.5 { + t.Fatalf("autoB.Width() = %v, want 102.5", got) + } +} + +func TestLayoutTable_AutoWidthPreservesPercentColumns(t *testing.T) { + c := testTableContainer(400, 100, 4) + + percent := addTableTestWidget(t, c, 30, 20) + percent.SetWidthPct(25) + fixed := addTableTestWidget(t, c, 30, 20) + fixed.SetWidth(50) + omitted := addTableTestWidget(t, c, 40, 20) + auto := addTableTestWidget(t, c, 50, 20) + auto.SetWidthAuto() + + LayoutTable(c, c.LayoutStyle(), &labelTestWriter{t: t}) + + if got := percent.Width(); got != 100 { + t.Fatalf("percent.Width() = %v, want 100", got) + } + if got := fixed.Width(); got != 50 { + t.Fatalf("fixed.Width() = %v, want 50", got) + } + if got := omitted.Width(); got != 40 { + t.Fatalf("omitted.Width() = %v, want preferred width 40", got) + } + if got := auto.Width(); got != 210 { + t.Fatalf("auto.Width() = %v, want remaining width 210", got) + } +} + +func TestLayoutTable_LaterSingleColumnAutoCellMarksColumnAuto(t *testing.T) { + c := testTableContainer(220, 100, 2) + + omittedA := addTableTestWidget(t, c, 40, 20) + omittedB := addTableTestWidget(t, c, 30, 20) + laterAutoA := addTableTestWidget(t, c, 45, 20) + laterAutoA.SetWidthAuto() + laterAutoB := addTableTestWidget(t, c, 35, 20) + + LayoutTable(c, c.LayoutStyle(), &labelTestWriter{t: t}) + + if got := omittedB.Width(); got != 35 { + t.Fatalf("omittedB.Width() = %v, want preferred width 35", got) + } + if got := omittedA.Width(); got != 185 { + t.Fatalf("omittedA.Width() = %v, want auto column surplus width 185", got) + } + if got := laterAutoA.Width(); got != 185 { + t.Fatalf("laterAutoA.Width() = %v, want auto column surplus width 185", got) + } + if got := laterAutoB.Width(); got != 35 { + t.Fatalf("laterAutoB.Width() = %v, want omitted column preferred width 35", got) + } +} + +func TestLayoutTable_OmittedWidthsKeepLegacyEqualShareWithoutAutoColumns(t *testing.T) { + c := testTableContainer(300, 80, 3) + + a := addTableTestWidget(t, c, 20, 20) + b := addTableTestWidget(t, c, 60, 20) + d := addTableTestWidget(t, c, 100, 20) + + LayoutTable(c, c.LayoutStyle(), &labelTestWriter{t: t}) + + for i, w := range []*positionedTestWidget{a, b, d} { + if got := w.Width(); got != 100 { + t.Fatalf("widget %d Width() = %v, want legacy equal share 100", i, got) + } + } +} + +func TestLayoutTable_AutoWidthPreservesOmittedPreferredWidthWhenAutosCanShrink(t *testing.T) { + c := testTableContainer(120, 80, 3) + + omitted := addTableTestWidget(t, c, 100, 20) + autoA := addTableTestWidget(t, c, 80, 20) + autoA.SetWidthAuto() + autoB := addTableTestWidget(t, c, 80, 20) + autoB.SetWidthAuto() + + LayoutTable(c, c.LayoutStyle(), &labelTestWriter{t: t}) + + if got := omitted.Width(); got != 100 { + t.Fatalf("omitted.Width() = %v, want preferred width 100", got) + } + if got := autoA.Width(); got != 10 { + t.Fatalf("autoA.Width() = %v, want remaining auto share 10", got) + } + if got := autoB.Width(); got != 10 { + t.Fatalf("autoB.Width() = %v, want remaining auto share 10", got) + } +} + +func TestLayoutTable_AutoWidthFallsBackToEqualShareWhenOmittedPreferredCannotFit(t *testing.T) { + c := testTableContainer(90, 80, 3) + + omitted := addTableTestWidget(t, c, 100, 20) + autoA := addTableTestWidget(t, c, 80, 20) + autoA.SetWidthAuto() + autoB := addTableTestWidget(t, c, 80, 20) + autoB.SetWidthAuto() + + LayoutTable(c, c.LayoutStyle(), &labelTestWriter{t: t}) + + for i, w := range []*positionedTestWidget{omitted, autoA, autoB} { + if got := w.Width(); got != 30 { + t.Fatalf("widget %d Width() = %v, want impossible-case equal share 30", i, got) + } + } +} + +func TestLayoutTable_ColspanDoesNotCreateAutoColumnConstraint(t *testing.T) { + c := testTableContainer(240, 100, 2) + + spanning := addTableTestWidget(t, c, 200, 20) + spanning.SetWidthAuto() + spanning.SetAttrs(map[string]string{"colspan": "2"}) + + a := addTableTestWidget(t, c, 30, 20) + b := addTableTestWidget(t, c, 50, 20) + + LayoutTable(c, c.LayoutStyle(), &labelTestWriter{t: t}) + + if got := a.Width(); got != 120 { + t.Fatalf("a.Width() = %v, want legacy equal share 120", got) + } + if got := b.Width(); got != 120 { + t.Fatalf("b.Width() = %v, want legacy equal share 120", got) + } + if got := spanning.Width(); got != 240 { + t.Fatalf("spanning.Width() = %v, want full two-column span 240", got) + } +} + +func TestLayoutTable_AutoHeightRowsShareSurplus(t *testing.T) { + c := testTableContainer(200, 150, 2) + + fixedA := addTableTestWidget(t, c, 30, 20) + fixedA.SetHeight(20) + fixedB := addTableTestWidget(t, c, 30, 20) + fixedB.SetHeight(20) + + omittedA := addTableTestWidget(t, c, 30, 30) + omittedB := addTableTestWidget(t, c, 30, 30) + + autoA := addTableTestWidget(t, c, 30, 10) + autoA.SetHeightAuto() + autoB := addTableTestWidget(t, c, 30, 10) + autoB.SetHeightAuto() + + LayoutTable(c, c.LayoutStyle(), &labelTestWriter{t: t}) + + if got := omittedA.Height(); got != 30 { + t.Fatalf("omittedA.Height() = %v, want preferred height 30", got) + } + if got := omittedB.Height(); got != 30 { + t.Fatalf("omittedB.Height() = %v, want preferred height 30", got) + } + if got := autoA.Height(); got != 100 { + t.Fatalf("autoA.Height() = %v, want 100", got) + } + if got := autoB.Height(); got != 100 { + t.Fatalf("autoB.Height() = %v, want 100", got) + } +} + +func TestLayoutTable_AutoHeightRowsPreservePercentRows(t *testing.T) { + c := testTableContainer(200, 200, 2) + + percentA := addTableTestWidget(t, c, 30, 20) + percentA.SetHeightPct(25) + percentB := addTableTestWidget(t, c, 30, 20) + percentB.SetHeightPct(25) + + omittedA := addTableTestWidget(t, c, 30, 30) + omittedB := addTableTestWidget(t, c, 30, 30) + + autoA := addTableTestWidget(t, c, 30, 10) + autoA.SetHeightAuto() + autoB := addTableTestWidget(t, c, 30, 10) + autoB.SetHeightAuto() + + LayoutTable(c, c.LayoutStyle(), &labelTestWriter{t: t}) + + if got := percentA.Height(); got != 50 { + t.Fatalf("percentA.Height() = %v, want 50", got) + } + if got := percentB.Height(); got != 50 { + t.Fatalf("percentB.Height() = %v, want 50", got) + } + if got := omittedA.Height(); got != 30 { + t.Fatalf("omittedA.Height() = %v, want preferred height 30", got) + } + if got := omittedB.Height(); got != 30 { + t.Fatalf("omittedB.Height() = %v, want preferred height 30", got) + } + if got := autoA.Height(); got != 120 { + t.Fatalf("autoA.Height() = %v, want remaining auto height 120", got) + } + if got := autoB.Height(); got != 120 { + t.Fatalf("autoB.Height() = %v, want remaining auto height 120", got) + } +} + +func TestLayoutTable_NaturalHeightLeavesAutoRowsAtPreferredHeight(t *testing.T) { + c := testTableContainer(200, 0, 1) + + auto := addTableTestWidget(t, c, 30, 25) + auto.SetHeightAuto() + omitted := addTableTestWidget(t, c, 30, 35) + + LayoutTable(c, c.LayoutStyle(), &labelTestWriter{t: t}) + + if got := auto.Height(); got != 25 { + t.Fatalf("auto.Height() = %v, want preferred height 25", got) + } + if got := omitted.Height(); got != 35 { + t.Fatalf("omitted.Height() = %v, want preferred height 35", got) + } + if got := c.Height(); got != 60 { + t.Fatalf("container.Height() = %v, want natural height 60", got) + } +} diff --git a/ltml/linking.go b/ltml/linking.go index 7ea99da..5fc5eab 100644 --- a/ltml/linking.go +++ b/ltml/linking.go @@ -294,6 +294,8 @@ func (d *StdDocument) resetRenderState() { widget.SetPrinted(false) widget.SetVisible(true) widget.SetDisabled(false) + widget.ClearResolvedWidth() + widget.ClearResolvedHeight() switch value := widget.(type) { case *StdPage: value.flowPageIndex = 0 diff --git a/ltml/ltml_test.go b/ltml/ltml_test.go index d63cc1c..0a960e4 100644 --- a/ltml/ltml_test.go +++ b/ltml/ltml_test.go @@ -247,6 +247,12 @@ func TestSamples(t *testing.T) { "test_053_avery_labels", "test_054_canvas_draw", "test_055_arabic_index", + "test_056_hbox_auto_width", + "test_057_vbox_auto_height", + "test_058_vbox_auto_height_overflow", + "test_059_table_auto_width", + "test_060_table_auto_height", + "test_061_table_auto_split", } for _, sample := range samples { diff --git a/ltml/radial_layout_test.go b/ltml/radial_layout_test.go index f81bb82..71a2215 100644 --- a/ltml/radial_layout_test.go +++ b/ltml/radial_layout_test.go @@ -1006,3 +1006,42 @@ func TestStdSector_WithParagraphChild_DoesNotDefaultToTangentRotation(t *testing t.Fatalf("content rotation = %v, want 0 for paragraph-bearing sector", got) } } + +func TestRadialSample_ImplicitParagraphsKeepLegacyPlacement(t *testing.T) { + doc, err := ParseFile(sampleFile("test_038_radial_layout.ltml")) + if err != nil { + t.Fatal(err) + } + + w := &labelTestWriter{t: t} + if err := doc.Print(w); err != nil { + t.Fatal(err) + } + + var direct *StdParagraph + var other *StdParagraph + walkWidgets(doc.Root(), func(widget Widget) bool { + paragraph, ok := widget.(*StdParagraph) + if !ok { + return true + } + text := paragraph.AccessibilityText() + if strings.HasPrefix(text, "This paragraph is a direct child") { + direct = paragraph + } + if strings.HasPrefix(text, "Another implicit sector paragraph") { + other = paragraph + } + return true + }) + + if direct == nil || other == nil { + t.Fatalf("missing implicit paragraphs: direct=%v other=%v", direct != nil, other != nil) + } + if got, want := direct.Top(), 175.03; math.Abs(got-want) > 0.5 { + t.Fatalf("direct implicit paragraph top = %v, want near %v", got, want) + } + if got, want := other.Top(), 386.59; math.Abs(got-want) > 0.5 { + t.Fatalf("secondary implicit paragraph top = %v, want near %v", got, want) + } +} diff --git a/ltml/samples/test_051_paper_sizes.pdf b/ltml/samples/test_051_paper_sizes.pdf index e05a274..5d79c01 100644 Binary files a/ltml/samples/test_051_paper_sizes.pdf and b/ltml/samples/test_051_paper_sizes.pdf differ diff --git a/ltml/samples/test_056_hbox_auto_width.ltml b/ltml/samples/test_056_hbox_auto_width.ltml new file mode 100644 index 0000000..5233e3c --- /dev/null +++ b/ltml/samples/test_056_hbox_auto_width.ltml @@ -0,0 +1,76 @@ + + + + + + + + + + + + +

+ When an hbox has surplus room, omitted widths keep their preferred size + and only width="auto" children absorb the slack. In a constrained hbox, + auto behaves the same as an omitted width. +

+ +
Roomy container
+
+ + + + +
+ +
Constrained container
+
+ + + + +
+
+
diff --git a/ltml/samples/test_056_hbox_auto_width.pdf b/ltml/samples/test_056_hbox_auto_width.pdf new file mode 100644 index 0000000..5936fcf Binary files /dev/null and b/ltml/samples/test_056_hbox_auto_width.pdf differ diff --git a/ltml/samples/test_057_vbox_auto_height.ltml b/ltml/samples/test_057_vbox_auto_height.ltml new file mode 100644 index 0000000..d91be08 --- /dev/null +++ b/ltml/samples/test_057_vbox_auto_height.ltml @@ -0,0 +1,75 @@ + + + + + + + + + + + + +

+ When a fixed-height vbox has surplus room, omitted heights stay at their + preferred size and only height="auto" children grow. In a constrained + fixed-height vbox, auto behaves like an omitted height. +

+ +
Roomy fixed-height container
+
+ + + + +
+ +
Constrained fixed-height container
+
+ + + +
+
+
diff --git a/ltml/samples/test_057_vbox_auto_height.pdf b/ltml/samples/test_057_vbox_auto_height.pdf new file mode 100644 index 0000000..53c085e Binary files /dev/null and b/ltml/samples/test_057_vbox_auto_height.pdf differ diff --git a/ltml/samples/test_058_vbox_auto_height_overflow.ltml b/ltml/samples/test_058_vbox_auto_height_overflow.ltml new file mode 100644 index 0000000..64832e8 --- /dev/null +++ b/ltml/samples/test_058_vbox_auto_height_overflow.ltml @@ -0,0 +1,55 @@ + + + + + + + + + + + +

+ This fixed-height vbox splits across pages. The first fragment contains + only fixed-height children. The continuation fragment contains an omitted + height and a height="auto" child, so only that page shares surplus height + with the auto child. +

+ +
+ + + + + +
+
+
diff --git a/ltml/samples/test_058_vbox_auto_height_overflow.pdf b/ltml/samples/test_058_vbox_auto_height_overflow.pdf new file mode 100644 index 0000000..a6a70ac Binary files /dev/null and b/ltml/samples/test_058_vbox_auto_height_overflow.pdf differ diff --git a/ltml/samples/test_059_table_auto_width.ltml b/ltml/samples/test_059_table_auto_width.ltml new file mode 100644 index 0000000..ac95414 --- /dev/null +++ b/ltml/samples/test_059_table_auto_width.ltml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + +

+ Fixed and percent columns keep their authored sizes. In a roomy table, + omitted columns keep preferred width while width="auto" columns split + surplus. In a tight table, auto columns shrink first so omitted columns + can keep preferred width. +

+ +
Roomy table
+ + + + + + +
+ +
Tight table fallback
+ + + + + + +
+
+
diff --git a/ltml/samples/test_059_table_auto_width.pdf b/ltml/samples/test_059_table_auto_width.pdf new file mode 100644 index 0000000..9981403 Binary files /dev/null and b/ltml/samples/test_059_table_auto_width.pdf differ diff --git a/ltml/samples/test_060_table_auto_height.ltml b/ltml/samples/test_060_table_auto_height.ltml new file mode 100644 index 0000000..c01f146 --- /dev/null +++ b/ltml/samples/test_060_table_auto_height.ltml @@ -0,0 +1,78 @@ + + + + + + + + + + + + +

+ Fixed-height tables preserve fixed and percent rows, then share true + surplus height only with rows containing height="auto" cells. + Natural-height tables leave auto rows at preferred height. +

+ +
Roomy fixed-height table
+ + + + + + + + + +
+ +
Natural-height table
+ + + + + +
+
+
diff --git a/ltml/samples/test_060_table_auto_height.pdf b/ltml/samples/test_060_table_auto_height.pdf new file mode 100644 index 0000000..3a8cc9c Binary files /dev/null and b/ltml/samples/test_060_table_auto_height.pdf differ diff --git a/ltml/samples/test_061_table_auto_split.ltml b/ltml/samples/test_061_table_auto_split.ltml new file mode 100644 index 0000000..2895fe1 --- /dev/null +++ b/ltml/samples/test_061_table_auto_split.ltml @@ -0,0 +1,76 @@ + + + + + + + + + + + +

+ This direct page-child table is a height="auto" child of the page vbox. + Each split fragment receives the remaining page height, then shares + surplus only with the height="auto" table rows present on that fragment. +

+ + +

Repeating table header

+

row 1 A

+

row 1 B

+

row 2 auto A

+

row 2 auto B

+

row 3 A

+

row 3 B

+

row 4 A

+

row 4 B

+

row 5 auto A

+

row 5 auto B

+

row 6 A

+

row 6 B

+

row 7 A

+

row 7 B

+

row 8 auto A

+

row 8 auto B

+

row 9 A

+

row 9 B

+

row 10 A

+

row 10 B

+
+
+
diff --git a/ltml/samples/test_061_table_auto_split.pdf b/ltml/samples/test_061_table_auto_split.pdf new file mode 100644 index 0000000..b00754f Binary files /dev/null and b/ltml/samples/test_061_table_auto_split.pdf differ diff --git a/ltml/std_container.go b/ltml/std_container.go index 735e5d5..d0df6e0 100644 --- a/ltml/std_container.go +++ b/ltml/std_container.go @@ -106,8 +106,8 @@ func (c *StdContainer) Widgets() []Widget { } func (c *StdContainer) PreferredHeight(w Writer) float64 { - if c.height != 0 { - return float64(c.height) + if c.HeightIsSet() { + return c.Height() } c.prepareForLayout(w) if isRadialLayoutStyle(c.layout) { @@ -115,18 +115,23 @@ func (c *StdContainer) PreferredHeight(w Writer) float64 { return height } } - savedHeight, savedHeightPct, savedHeightRel, savedHeightSet := - c.height, c.heightPct, c.heightRel, c.heightSet + saved := c.SaveState() LayoutContainer(c, newLayoutProbeWriter(w)) height := c.Height() - c.height, c.heightPct, c.heightRel, c.heightSet = - savedHeight, savedHeightPct, savedHeightRel, savedHeightSet + walkWidgets(c, func(widget Widget) bool { + if widget != c { + widget.ClearResolvedWidth() + widget.ClearResolvedHeight() + } + return true + }) + c.RestoreState(saved) return height } func (c *StdContainer) PreferredWidth(w Writer) float64 { - if c.width != 0 { - return float64(c.width) + if c.WidthIsSet() { + return c.Width() } if isRadialLayoutStyle(c.layout) { if width, ok := c.radialInferredWidth(); ok { @@ -333,6 +338,14 @@ func (c *StdContainer) SplitForHeight(avail float64, w Writer) (*SplitResult, er return nil, err } bodyCount := metrics.bodyEnd - metrics.bodyStart + if c.tableFragmentHeight(metrics, metrics.bodyStart, metrics.bodyEnd) <= avail { + rows := append([]int{}, metrics.headerRows...) + for r := metrics.bodyStart; r < metrics.bodyEnd; r++ { + rows = append(rows, r) + } + rows = append(rows, metrics.footerRows...) + return &SplitResult{Head: c.cloneTableFragment(metrics, rows), Tail: nil}, nil + } if bodyCount < 2 { return nil, nil } @@ -425,13 +438,7 @@ func (c *StdContainer) tableSplitMetrics(w Writer) (*tableSplitMetrics, error) { return nil, errTableSplitUnsupportedRowSpan } } - widths := detectWidths(grid, w) - percents, others := widths.Partition(func(w *SpecifiedSize) bool { return w.How == Percent }) - specified, others := others.Partition(func(w *SpecifiedSize) bool { return w.How == Specified }) - widthAvail := ContentWidth(c) - widthAvail = allocateSpecifiedWidths(widthAvail, specified, c.LayoutStyle()) - widthAvail = allocatePercentWidths(widthAvail, percents, c.LayoutStyle()) - _ = allocateOtherWidths(widthAvail, others, c.LayoutStyle()) + widths := planTableColumnWidths(grid, c, c.LayoutStyle(), w).resolvedSizes() rowHeights := make([]float64, grid.Rows()) for r := 0; r < grid.Rows(); r++ { @@ -441,16 +448,12 @@ func (c *StdContainer) tableSplitMetrics(w Writer) (*tableSplitMetrics, error) { if widget == nil { continue } - if widths[col].Size <= 0 { + if widths[col] <= 0 { continue } - width := 0.0 - for i := 0; i < widget.ColSpan(); i++ { - width += widths[col+i].Size - } - widget.SetWidth(width + float64(widget.ColSpan()-1)*c.LayoutStyle().HPadding()) + widget.ResolveWidth(tableCellWidth(widths, col, widget.ColSpan(), c.LayoutStyle().HPadding())) height := widget.Height() - if !widget.HeightIsSet() { + if !widgetHeightSpecified(widget) { height = widget.PreferredHeight(w) } if height > maxHeight { @@ -501,10 +504,8 @@ func (c *StdContainer) tableFragmentHeight(metrics *tableSplitMetrics, bodyStart func (c *StdContainer) cloneTableFragment(metrics *tableSplitMetrics, rows []int) *StdContainer { clone := *c clone.activeChildren = c.cloneTableWidgetsForRows(metrics.grid, rows, &clone) - clone.height = 0 - clone.heightPct = 0 - clone.heightRel = 0 - clone.heightSet = false + clone.ClearResolvedWidth() + clone.ClearResolvedHeight() clone.printed = false clone.invisible = false clone.disabled = false @@ -758,7 +759,7 @@ func (c *StdContainer) vboxSplitMetrics(w Writer) *vboxSplitMetrics { } rtl := IsRTL(c) for _, child := range static { - if !child.WidthIsSet() { + if widgetAutoWidth(child) || !widgetWidthSpecified(child) { cw := ContentWidth(c) pw := 0.0 if _, ok := child.(*StdParagraph); ok { @@ -769,11 +770,11 @@ func (c *StdContainer) vboxSplitMetrics(w Writer) *vboxSplitMetrics { if pw == 0 { pw = cw } - child.SetWidth(min(pw, cw)) + child.ResolveWidth(min(pw, cw)) } child.SetLeft(vboxCrossAxisLeft(c, child, rtl)) height := child.Height() - if !child.HeightIsSet() { + if !widgetHeightSpecified(child) { height = child.PreferredHeight(w) } metrics.heights[child] = height @@ -853,10 +854,8 @@ func (c *StdContainer) vboxHasVisibleWidget(groups ...[]Widget) bool { func (c *StdContainer) cloneVBoxFragment(included map[Widget]bool, replacements map[Widget]Widget) *StdContainer { clone := *c clone.activeChildren = make([]Widget, 0, len(included)+len(replacements)) - clone.height = 0 - clone.heightPct = 0 - clone.heightRel = 0 - clone.heightSet = false + clone.ClearResolvedWidth() + clone.ClearResolvedHeight() clone.printed = false clone.invisible = false clone.disabled = false @@ -898,6 +897,8 @@ func cloneWidgetShallow(widget Widget) Widget { if !ok { panic("cloneWidgetShallow produced non-widget clone") } + w.ClearResolvedWidth() + w.ClearResolvedHeight() w.SetPrinted(false) w.SetVisible(true) w.SetDisabled(false) diff --git a/ltml/std_index.go b/ltml/std_index.go index fe8b2be..d2847c2 100644 --- a/ltml/std_index.go +++ b/ltml/std_index.go @@ -5,8 +5,6 @@ import "fmt" type StdIndex struct { StdContainer expandedTargets []string - explicitWidth bool - explicitHeight bool expandErr error } @@ -95,12 +93,6 @@ func (i *StdIndex) SplitForHeight(avail float64, w Writer) (*SplitResult, error) return &SplitResult{Head: head, Tail: tail}, nil } -func (i *StdIndex) SetAttrs(attrs map[string]string) { - i.StdContainer.SetAttrs(attrs) - i.explicitWidth = MapHasAnyKey(attrs, "width") || (i.sides[leftSide].IsSet && i.sides[rightSide].IsSet) - i.explicitHeight = MapHasAnyKey(attrs, "height") || (i.sides[topSide].IsSet && i.sides[bottomSide].IsSet) -} - func (i *StdIndex) clearExpandedState() { i.activeChildren = nil i.expandedTargets = nil @@ -108,18 +100,8 @@ func (i *StdIndex) clearExpandedState() { } func (i *StdIndex) clearMeasuredGeometry() { - if !i.explicitWidth { - i.width = 0 - i.widthPct = 0 - i.widthRel = 0 - i.widthSet = false - } - if !i.explicitHeight { - i.height = 0 - i.heightPct = 0 - i.heightRel = 0 - i.heightSet = false - } + i.ClearResolvedWidth() + i.ClearResolvedHeight() } func (i *StdIndex) currentEntries() []resolvedIndexEntry { diff --git a/ltml/std_page.go b/ltml/std_page.go index 74a06db..0e2dfc3 100644 --- a/ltml/std_page.go +++ b/ltml/std_page.go @@ -47,13 +47,6 @@ func (p *StdPage) BottomIsSet() bool { return true } -func (p *StdPage) root() *StdPage { - if p.container == nil { - return p - } - return p.container.(*StdPage) -} - func (p *StdPage) document() *StdDocument { if p.container != nil { return p.container.(*StdDocument) @@ -201,7 +194,7 @@ func (p *StdPage) SetAttrs(attrs map[string]string) { p.overflowSet = true p.overflow = overflow == "true" } - for k, _ := range attrs { + for k := range attrs { if reMargin.MatchString(k) { p.marginChanged = true break @@ -517,6 +510,8 @@ func (p *StdPage) resetWidgetRenderState(widget Widget) { widget.SetPrinted(false) widget.SetVisible(true) widget.SetDisabled(false) + widget.ClearResolvedWidth() + widget.ClearResolvedHeight() container, ok := widget.(Container) if !ok { return @@ -585,10 +580,14 @@ func (p *StdPage) trySplitChild(item *pageItem, child Widget, w Writer) (bool, e if wc, ok := result.Head.(WantsContainer); ok { _ = wc.SetContainer(p) } + p.resetWidgetRenderState(result.Head) p.copySplitGeometry(result.Head, child) + if widgetAutoHeight(child) || widgetHeightSpecified(child) { + result.Head.ResolveHeight(p.availableHeightForChild(child)) + } result.Head.LayoutWidget(w) if !result.Head.HeightIsSet() { - result.Head.SetHeight(result.Head.PreferredHeight(w)) + result.Head.ResolveHeight(result.Head.PreferredHeight(w)) } if result.Tail != nil { if wc, ok := result.Tail.(WantsContainer); ok { @@ -608,7 +607,7 @@ func (p *StdPage) trySplitChild(item *pageItem, child Widget, w Writer) (bool, e func (p *StdPage) copySplitGeometry(dst, src Widget) { dst.SetLeft(src.Left()) dst.SetTop(src.Top()) - dst.SetWidth(src.Width()) + dst.ResolveWidth(src.Width()) dst.SetPosition(src.Position()) dst.SetVisible(true) dst.SetDisabled(false) diff --git a/ltml/std_paragraph.go b/ltml/std_paragraph.go index af6264d..a7b6f79 100644 --- a/ltml/std_paragraph.go +++ b/ltml/std_paragraph.go @@ -272,10 +272,8 @@ func (p *StdParagraph) cloneForSplit(lines []*rich_text.RichText, suppressBullet clone.splitLines = append([]*rich_text.RichText(nil), lines...) clone.suppressBullet = suppressBullet clone.continuationIndent = continuationIndent - clone.height = 0 - clone.heightPct = 0 - clone.heightRel = 0 - clone.heightSet = false + clone.ClearResolvedWidth() + clone.ClearResolvedHeight() clone.richText = nil clone.printed = false clone.invisible = false diff --git a/ltml/std_widget.go b/ltml/std_widget.go index 04dfd91..7214fff 100644 --- a/ltml/std_widget.go +++ b/ltml/std_widget.go @@ -762,43 +762,47 @@ func (widget *StdWidget) Units() Units { } func (widget *StdWidget) Width() float64 { - if widget.widthPct > 0 { - return float64(widget.widthPct) / 100.0 * ContentWidth(widget.container) - } - if widget.widthRel != 0 { - return ContentWidth(widget.container) + float64(widget.widthRel) - } - if widget.widthSet { + if widget.widthValid { return float64(widget.width) } + switch widget.widthMode { + case DimPct: + return float64(widget.widthValue) / 100.0 * ContentWidth(widget.container) + case DimRel: + return ContentWidth(widget.container) + float64(widget.widthValue) + case DimLiteral: + return float64(widget.widthValue) + } if widget.sides[leftSide].IsSet && widget.sides[rightSide].IsSet { return widget.resolveRight(widget.sides[rightSide].Float64()) - widget.resolveLeft(widget.sides[leftSide].Float64()) } - return float64(widget.width) + return 0 } func (widget *StdWidget) Height() float64 { - if widget.heightPct > 0 { - return float64(widget.heightPct) / 100.0 * ContentHeight(widget.container) - } - if widget.heightRel != 0 { - return ContentHeight(widget.container) + float64(widget.heightRel) - } - if widget.heightSet { + if widget.heightValid { return float64(widget.height) } + switch widget.heightMode { + case DimPct: + return float64(widget.heightValue) / 100.0 * ContentHeight(widget.container) + case DimRel: + return ContentHeight(widget.container) + float64(widget.heightValue) + case DimLiteral: + return float64(widget.heightValue) + } if widget.sides[topSide].IsSet && widget.sides[bottomSide].IsSet { return widget.resolveBottom(widget.sides[bottomSide].Float64()) - widget.resolveTop(widget.sides[topSide].Float64()) } - return float64(widget.height) + return 0 } func (widget *StdWidget) HeightIsSet() bool { - return widget.heightSet || (widget.sides[topSide].IsSet && widget.sides[bottomSide].IsSet) + return widget.Dimensions.HeightIsSet() || (widget.sides[topSide].IsSet && widget.sides[bottomSide].IsSet) } func (widget *StdWidget) WidthIsSet() bool { - return widget.widthSet || (widget.sides[leftSide].IsSet && widget.sides[rightSide].IsSet) + return widget.Dimensions.WidthIsSet() || (widget.sides[leftSide].IsSet && widget.sides[rightSide].IsSet) } func (widget *StdWidget) Visible() bool { diff --git a/ltml/widget.go b/ltml/widget.go index 32c1668..84fc5cc 100644 --- a/ltml/widget.go +++ b/ltml/widget.go @@ -35,15 +35,23 @@ type Widget interface { LeftIsSet() bool SetHeight(value float64) + SetHeightAuto() SetHeightPct(value float64) SetHeightRel(value float64) + ResolveHeight(value float64) + ClearResolvedHeight() SetWidth(value float64) + SetWidthAuto() SetWidthPct(value float64) SetWidthRel(value float64) + ResolveWidth(value float64) + ClearResolvedWidth() Height() float64 HeightIsSet() bool + HeightMode() DimensionMode Width() float64 + WidthMode() DimensionMode WidthPctIsSet() bool WidthRelIsSet() bool WidthIsSet() bool