From 67efda1187c3b023c1329eac1cbf453cd2723e69 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 05:44:03 +0000 Subject: [PATCH 1/3] feat: implement material3 table foundation in pkg/x/m3table Introduced a basic Table composable in `pkg/x/m3table` allowing row/column configuration. Added `Column` layout parameters for dynamic (weight) and fixed (width) sizing. Supports optional headers and Material 3 Horizontal Dividers. Co-authored-by: zodimo <5030404+zodimo@users.noreply.github.com> --- pkg/x/m3table/options.go | 36 ++++++++++++++++ pkg/x/m3table/table.go | 88 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 pkg/x/m3table/options.go create mode 100644 pkg/x/m3table/table.go diff --git a/pkg/x/m3table/options.go b/pkg/x/m3table/options.go new file mode 100644 index 0000000..8e8b9e5 --- /dev/null +++ b/pkg/x/m3table/options.go @@ -0,0 +1,36 @@ +package m3table + +import ( + "github.com/zodimo/go-compose/compose/ui" + "github.com/zodimo/go-compose/compose/ui/unit" + "github.com/zodimo/go-compose/pkg/api" +) + +// Column defines the configuration for a single column in the table. +type Column struct { + // Header is an optional composable that defines the column header. + Header api.Composable + // Weight is the flex weight of the column. If greater than 0, the column + // scales proportionally based on its weight. + Weight int + // Width is the fixed width of the column. This is used if Weight is 0. + Width unit.Dp +} + +type TableOptions struct { + Modifier ui.Modifier +} + +type TableOption func(*TableOptions) + +func DefaultTableOptions() TableOptions { + return TableOptions{ + Modifier: ui.EmptyModifier, + } +} + +func WithModifier(modifier ui.Modifier) TableOption { + return func(o *TableOptions) { + o.Modifier = modifier + } +} diff --git a/pkg/x/m3table/table.go b/pkg/x/m3table/table.go new file mode 100644 index 0000000..c231fdb --- /dev/null +++ b/pkg/x/m3table/table.go @@ -0,0 +1,88 @@ +package m3table + +import ( + "github.com/zodimo/go-compose/compose/foundation/layout/box" + "github.com/zodimo/go-compose/compose/foundation/layout/column" + "github.com/zodimo/go-compose/compose/foundation/layout/row" + "github.com/zodimo/go-compose/compose/material3/divider" + "github.com/zodimo/go-compose/compose/ui" + "github.com/zodimo/go-compose/modifiers/size" + "github.com/zodimo/go-compose/modifiers/weight" + "github.com/zodimo/go-compose/pkg/api" +) + +// Table constructs a basic material3 table layout given a list of columns, +// the number of data rows, and a factory function to create each cell's content. +func Table( + columns []Column, + rowCount int, + cellContent func(row, col int) api.Composable, + options ...TableOption, +) api.Composable { + + opts := DefaultTableOptions() + for _, option := range options { + if option != nil { + option(&opts) + } + } + + return func(c api.Composer) api.Composer { + c.StartBlock("Table") + + hasHeaders := false + for _, col := range columns { + if col.Header != nil { + hasHeaders = true + break + } + } + + c.WithComposable(column.Column( + c.Sequence( + c.When(hasHeaders, func(c api.Composer) api.Composer { + return c.Sequence( + row.Row( + func(c api.Composer) api.Composer { + for i, col := range columns { + c.Key(i, wrapCell(col, col.Header)) + } + return c + }, + ), + divider.Divider(), + )(c) + }), + c.Range(rowCount, func(r int) api.Composable { + return row.Row( + func(c api.Composer) api.Composer { + for cIdx, col := range columns { + c.Key(cIdx, wrapCell(col, cellContent(r, cIdx))) + } + return c + }, + ) + }), + ), + column.WithModifier(opts.Modifier), + )) + + return c.EndBlock() + } +} + +// wrapCell wraps the provided content into a box with proper width modifiers. +func wrapCell(col Column, content api.Composable) api.Composable { + var mod ui.Modifier = ui.EmptyModifier + if col.Weight > 0 { + mod = mod.Then(weight.Weight(col.Weight)) + } else if col.Width > 0 { + mod = mod.Then(size.Width(int(col.Width))) + } + + if content == nil { + content = func(c api.Composer) api.Composer { return c } + } + + return box.Box(content, box.WithModifier(mod)) +} From 8da470448a1d8152b4e08c9d64b2b6d6b5e771fb Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:49:03 +0000 Subject: [PATCH 2/3] fix(table): apply Material 3 minimum row heights Added `MinHeaderRowHeight` and `MinRowHeight` to `TableOptions` using standard Material 3 sizes (56dp and 52dp). Constraints are applied using `size.MinHeight` on row creation. Co-authored-by: zodimo <5030404+zodimo@users.noreply.github.com> --- pkg/x/m3table/options.go | 22 ++++++++++++++++++++-- pkg/x/m3table/table.go | 2 ++ test-plan.txt | 15 +++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 test-plan.txt diff --git a/pkg/x/m3table/options.go b/pkg/x/m3table/options.go index 8e8b9e5..4bf6d7e 100644 --- a/pkg/x/m3table/options.go +++ b/pkg/x/m3table/options.go @@ -18,14 +18,18 @@ type Column struct { } type TableOptions struct { - Modifier ui.Modifier + Modifier ui.Modifier + MinHeaderRowHeight unit.Dp + MinRowHeight unit.Dp } type TableOption func(*TableOptions) func DefaultTableOptions() TableOptions { return TableOptions{ - Modifier: ui.EmptyModifier, + Modifier: ui.EmptyModifier, + MinHeaderRowHeight: 56, // Material 3 standard data table header row height + MinRowHeight: 52, // Material 3 standard data table row height } } @@ -34,3 +38,17 @@ func WithModifier(modifier ui.Modifier) TableOption { o.Modifier = modifier } } + +// WithMinHeaderRowHeight sets the minimum height for the header row. +func WithMinHeaderRowHeight(height unit.Dp) TableOption { + return func(o *TableOptions) { + o.MinHeaderRowHeight = height + } +} + +// WithMinRowHeight sets the minimum height for data rows. +func WithMinRowHeight(height unit.Dp) TableOption { + return func(o *TableOptions) { + o.MinRowHeight = height + } +} diff --git a/pkg/x/m3table/table.go b/pkg/x/m3table/table.go index c231fdb..53b2f77 100644 --- a/pkg/x/m3table/table.go +++ b/pkg/x/m3table/table.go @@ -49,6 +49,7 @@ func Table( } return c }, + row.WithModifier(size.MinHeight(int(opts.MinHeaderRowHeight))), ), divider.Divider(), )(c) @@ -61,6 +62,7 @@ func Table( } return c }, + row.WithModifier(size.MinHeight(int(opts.MinRowHeight))), ) }), ), diff --git a/test-plan.txt b/test-plan.txt new file mode 100644 index 0000000..ed5adb1 --- /dev/null +++ b/test-plan.txt @@ -0,0 +1,15 @@ +Ah, the user asked "What about row height constraints ?" +In `Table`, we currently don't constrain the row height. +Usually a table row in Material 3 has a standard minimum height, or we can add `MinHeight` to `TableOptions` or `RowOptions`. +But `pkg/x/m3table/table.go` uses `row.Row`. We can apply a `MinHeight` modifier to the `row.Row` for both headers and cells. +Let's see if there's a standard Material 3 table row height. The Material 3 spec for Data tables says: +Row height: 52dp +Header row height: 56dp + +We can add `MinHeight` modifier to the row. +Wait, let's look at `modifiers/size/constructor.go` to see if we have `MinHeight`. +Yes, `MinHeight(minHeight int) ui.Modifier`. + +Let's modify `pkg/x/m3table/table.go` to add a default `MinHeight` to rows, or maybe pass it via `TableOptions`. +Since it's a basic table, maybe we can just hardcode or provide options. +Let's provide `MinRowHeight unit.Dp` and `MinHeaderRowHeight unit.Dp` in `TableOptions`. From 8ab876e6c5ac4410f5873b94517f26b7671c657d Mon Sep 17 00:00:00 2001 From: Jaco Labuschagne Date: Sat, 28 Mar 2026 16:45:52 +0200 Subject: [PATCH 3/3] improvements and demo --- pkg/x/m3table/cmd/demo/main.go | 61 +++++++++++++++++++++++++++ pkg/x/m3table/cmd/demo/ui.go | 75 ++++++++++++++++++++++++++++++++++ pkg/x/m3table/table.go | 30 ++++++-------- 3 files changed, 148 insertions(+), 18 deletions(-) create mode 100644 pkg/x/m3table/cmd/demo/main.go create mode 100644 pkg/x/m3table/cmd/demo/ui.go diff --git a/pkg/x/m3table/cmd/demo/main.go b/pkg/x/m3table/cmd/demo/main.go new file mode 100644 index 0000000..9a2da59 --- /dev/null +++ b/pkg/x/m3table/cmd/demo/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "log" + "os" + + "github.com/zodimo/go-compose/compose" + "github.com/zodimo/go-compose/pkg/api" + "github.com/zodimo/go-compose/runtime" + "github.com/zodimo/go-compose/store" + "github.com/zodimo/go-compose/theme" + + "gioui.org/app" + "gioui.org/io/system" + "gioui.org/op" + "gioui.org/unit" +) + +func main() { + go func() { + w := new(app.Window) + w.Option(app.Title("Table Demo")) + w.Option(app.Size(unit.Dp(600), unit.Dp(800))) + + if err := Run(w); err != nil { + log.Fatal(err) + } + os.Exit(0) + }() + app.Main() +} + +func Run(window *app.Window) error { + enLocale := system.Locale{Language: "en", Direction: system.LTR} + var ops op.Ops + + store := store.NewPersistentState() + store.Subscribe(func() { + window.Invalidate() + }) + + runtime := runtime.NewRuntime() + themeManager := theme.GetThemeManager() + + for { + switch frameEvent := window.Event().(type) { + case app.DestroyEvent: + return frameEvent.Err + case app.FrameEvent: + gtx := app.NewContext(&ops, frameEvent) + gtx.Locale = enLocale + gtx = themeManager.Material3ThemeInit(gtx) + + composer := compose.NewComposer(api.ComposerWithStore(store)) + + callOp := runtime.Run(gtx, composer, UI()) + callOp.Add(gtx.Ops) + frameEvent.Frame(gtx.Ops) + } + } +} diff --git a/pkg/x/m3table/cmd/demo/ui.go b/pkg/x/m3table/cmd/demo/ui.go new file mode 100644 index 0000000..e472c40 --- /dev/null +++ b/pkg/x/m3table/cmd/demo/ui.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "image/color" + + "github.com/zodimo/go-compose/compose/foundation/layout/box" + "github.com/zodimo/go-compose/compose/foundation/layout/column" + ftext "github.com/zodimo/go-compose/compose/foundation/text" + "github.com/zodimo/go-compose/compose/material3/text" + "github.com/zodimo/go-compose/compose/ui/graphics" + "github.com/zodimo/go-compose/modifiers/background" + "github.com/zodimo/go-compose/modifiers/padding" + "github.com/zodimo/go-compose/modifiers/size" + "github.com/zodimo/go-compose/pkg/api" + "github.com/zodimo/go-compose/pkg/x/m3table" +) + +func UI() api.Composable { + return func(c api.Composer) api.Composer { + + return column.Column( + c.Sequence( + // Title for Fixed Grid + text.HeadlineMedium("Table", + ftext.WithModifier(padding.All(16)), + ), + + m3table.Table( + []m3table.Column{ + { + Header: text.BodyLarge("Header 1"), + Width: 100, + }, + { + Header: text.BodyLarge("Header 2"), + Width: 200, + }, + { + Header: text.BodyLarge("Header 3"), + Width: 300, + }, + }, + 10, + func(row, col int) api.Composable { + return text.BodyLarge(fmt.Sprintf("Cell %d,%d", row, col)) + }, + ), + ), + column.WithModifier(size.FillMax()), + )(c) + } +} + +// GridItem creates a single grid item with colored background +func GridItem(index int) api.Composable { + // Simple alternating colors for visual distinction + colors := []color.NRGBA{ + {R: 234, G: 221, B: 255, A: 255}, // Primary container + {R: 232, G: 222, B: 248, A: 255}, // Secondary container + {R: 255, G: 216, B: 228, A: 255}, // Tertiary container + } + bgColor := colors[index%len(colors)] + + return box.Box( + text.TitleLarge(fmt.Sprintf("%d", index)), + box.WithModifier( + size.Height(80). + Then(size.FillMaxWidth()). + Then(background.Background(graphics.FromNRGBA(bgColor))). + Then(padding.All(8)), + ), + box.WithAlignment(box.Center), + ) +} diff --git a/pkg/x/m3table/table.go b/pkg/x/m3table/table.go index 53b2f77..ecab3bb 100644 --- a/pkg/x/m3table/table.go +++ b/pkg/x/m3table/table.go @@ -1,6 +1,7 @@ package m3table import ( + "github.com/zodimo/go-compose/compose" "github.com/zodimo/go-compose/compose/foundation/layout/box" "github.com/zodimo/go-compose/compose/foundation/layout/column" "github.com/zodimo/go-compose/compose/foundation/layout/row" @@ -28,7 +29,6 @@ func Table( } return func(c api.Composer) api.Composer { - c.StartBlock("Table") hasHeaders := false for _, col := range columns { @@ -38,17 +38,15 @@ func Table( } } - c.WithComposable(column.Column( + return column.Column( c.Sequence( c.When(hasHeaders, func(c api.Composer) api.Composer { return c.Sequence( row.Row( - func(c api.Composer) api.Composer { - for i, col := range columns { - c.Key(i, wrapCell(col, col.Header)) - } - return c - }, + c.Range(len(columns), func(i int) api.Composable { + return wrapCell(columns[i], columns[i].Header) + }), + row.WithAlignment(row.Middle), row.WithModifier(size.MinHeight(int(opts.MinHeaderRowHeight))), ), divider.Divider(), @@ -56,20 +54,16 @@ func Table( }), c.Range(rowCount, func(r int) api.Composable { return row.Row( - func(c api.Composer) api.Composer { - for cIdx, col := range columns { - c.Key(cIdx, wrapCell(col, cellContent(r, cIdx))) - } - return c - }, + c.Range(len(columns), func(cIdx int) api.Composable { + return wrapCell(columns[cIdx], cellContent(r, cIdx)) + }), + row.WithAlignment(row.Middle), row.WithModifier(size.MinHeight(int(opts.MinRowHeight))), ) }), ), column.WithModifier(opts.Modifier), - )) - - return c.EndBlock() + )(c) } } @@ -83,7 +77,7 @@ func wrapCell(col Column, content api.Composable) api.Composable { } if content == nil { - content = func(c api.Composer) api.Composer { return c } + content = compose.Id() } return box.Box(content, box.WithModifier(mod))