main.go
// Command gallery is the gophics widget catalog: a sectioned, interactive
// showcase of the framework's higher-level components.
//
// The catalog itself is package ui, so the same tree runs from this desktop
// binary, from a web build, and from the mobile bind package next door —
// gomobile cannot bind package main, which is the whole reason for the split.
package main
import (
"log"
"github.com/doug/gophics/app"
"github.com/doug/gophics/examples/gallery/ui"
)
func main() {
if err := app.Run(ui.Gallery{}, ui.Config()); err != nil {
log.Fatal(err)
}
}
mobile/gallerymobile.go
// Package gallerymobile is the gomobile-bind surface for the widget catalog.
//
// It holds only what is the gallery's own, which is building the tree.
// Everything generic — the frame loop, input, lifecycle, accessibility — is on
// shell/mobile.Bridge, which the CLI binds alongside this package, so a host
// calls those methods on the Bridge that Start returns.
package gallerymobile
import (
"os"
"strings"
"github.com/doug/gophics/app"
"github.com/doug/gophics/examples/gallery/ui"
gtext "github.com/doug/gophics/internal/gfx/gg/text"
"github.com/doug/gophics/shell/mobile"
)
// Start builds the app and returns the bridge the host drives it through.
//
// Call it once, before anything else. On failure it returns a nil bridge and
// the error to show — two results because the second is an error, which is the
// one shape gomobile allows.
func Start() (*mobile.Bridge, error) {
h, err := app.NewHandler(ui.Gallery{}, ui.Config())
if err != nil {
return nil, err
}
return mobile.NewBridge(h), nil
}
// CaptureGPU writes one GPU-rendered frame to path as a PNG, and the glyph
// atlas page beside it.
//
// For diagnosing a rendering fault that only a device shows. The host calls it
// with a path inside the app container and devicectl copies the files off.
// Rendering rather than screenshotting is the point: the frame comes off the
// same device, canvas and glyph atlas the screen does, so a fault in that
// atlas is in the file — and the atlas dump beside it says whether the atlas
// or the sampling is at fault. That pair is what found the page-index bug.
func CaptureGPU(bridge *mobile.Bridge, path string) string {
if bridge == nil {
return "no bridge"
}
data := bridge.CaptureGPU(1.0 / 60)
if len(data) == 0 {
return "capture produced nothing (no GPU surface?)"
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return err.Error()
}
// The atlas beside the frame. If the atlas is right and the frame is not,
// the GPU texture is stale; if the atlas is wrong, the fault is upstream of
// the GPU altogether. Nothing else separates those two.
if page := gtext.DumpAtlasPage(0); len(page) > 0 {
_ = os.WriteFile(strings.TrimSuffix(path, ".png")+"_atlas.png", page, 0o644)
}
return ""
}
ui/gallery.go
// Package ui is the gophics widget catalog: a sectioned, interactive
// showcase of the framework's higher-level components — controls, typography,
// charts, dialogs, layout primitives, implicit animations, and the
// Navigator/Hero/gesture stack — all built on procedural (network-free)
// content so it runs anywhere and stays testable headless.
//
// Structure: a Navigator whose home screen lists the catalog sections as
// tappable cards; tapping one pushes that section's page. A theme switcher in
// the home top bar cycles Light / Dark / Glass / GlassDark, held in root state
// and re-provided via widget.Provide[theme.Theme] so every section — which
// reads its colors through theme.Of(ctx) — follows the switch live.
//
// The sections live in sections_*.go; this file holds the root, the theme
// switcher, the home list, and the shared page chrome (scaffold + helpers).
package ui
import (
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/goregular"
"github.com/doug/gophics/app"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/shell"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// --- Theme mode & root -------------------------------------------------------
// themeMode selects which built-in theme the whole catalog runs under; the
// switcher in the home top bar cycles it and root state re-provides the theme.
type themeMode int
const (
modeLight themeMode = iota
modeDark
modeGlass
modeGlassDark
)
func (m themeMode) theme() theme.Theme {
switch m {
case modeDark:
return theme.Dark()
case modeGlass:
return theme.Glass()
case modeGlassDark:
return theme.GlassDark()
default:
return theme.Light()
}
}
func (m themeMode) label() string {
switch m {
case modeDark:
return "Dark"
case modeGlass:
return "Glass"
case modeGlassDark:
return "Glass Dark"
default:
return "Light"
}
}
var allModes = []themeMode{modeLight, modeDark, modeGlass, modeGlassDark}
// themeControl is provided alongside the theme so the switcher (and anything
// else) can read the active mode and request a new one, without threading a
// callback through every page.
type themeControl struct {
mode themeMode
set func(themeMode)
}
// Gallery is the root widget: stateful so it can hold the selected theme mode.
type Gallery struct{}
func (Gallery) CreateState() widget.State { return &galleryState{} }
type galleryState struct {
widget.StateBase[Gallery]
mode themeMode
}
// rootHook lets tests observe (and drive) the root state.
var rootHook func(*galleryState)
func (s *galleryState) Init(ctx widget.Ctx) {
// Seed from the platform scheme so first paint matches the OS; the switcher
// takes over from there.
if ctx.DarkMode() {
s.mode = modeDark
}
if rootHook != nil {
rootHook(s)
}
s.publishMenus(ctx)
}
// Menu item IDs. These cross into the platform's menu, which outlives the build
// that described it, so they are constants rather than indices into anything.
const (
menuThemeLight = iota + 1
menuThemeDark
menuThemeGlass
menuThemeGlassDark
)
// publishMenus installs the native menu bar, where the platform has one.
//
// The theme items are the interesting half: choosing one travels out to the OS
// menu, back through the capability's invoke, onto the UI goroutine, and into
// SetState — the same round trip any real app's menu makes. The roles are the
// other half: About and Quit are placed and performed by the platform, which on
// macOS is the difference between a menu bar that looks right and one that
// looks almost right.
func (s *galleryState) publishMenus(ctx widget.Ctx) {
menus := ctx.Menus()
if menus == nil {
return // web, mobile, terminal: nothing to publish into
}
s.publishBar(menus)
}
// publishBar describes the bar and installs it. Split from publishMenus so the
// description can be tested without a platform menu to publish into.
func (s *galleryState) publishBar(menus shell.Menus) {
menus.SetBar([]shell.Menu{
{Title: "Gallery", Items: []shell.MenuItem{
{Title: "About Gallery", Role: shell.RoleAbout},
{Separator: true},
{Title: "Hide Gallery", Role: shell.RoleHide},
{Title: "Quit Gallery", Role: shell.RoleQuit},
}},
{Title: "View", Items: []shell.MenuItem{
{ID: menuThemeLight, Title: "Light"},
{ID: menuThemeDark, Title: "Dark"},
{Separator: true},
{ID: menuThemeGlass, Title: "Glass"},
{ID: menuThemeGlassDark, Title: "Glass Dark"},
}},
{Title: "Window", Items: []shell.MenuItem{
{Title: "Minimize", Role: shell.RoleMinimize},
{Title: "Zoom", Role: shell.RoleZoom},
{Separator: true},
{Title: "Close Window", Role: shell.RoleClose},
}},
}, s.onMenu)
}
// onMenu handles an item the user chose. The capability delivers this on the UI
// goroutine, so SetState is the right call rather than PostState.
func (s *galleryState) onMenu(id int) {
mode, ok := menuThemeMode(id)
if !ok {
return
}
s.SetState(func() { s.mode = mode })
}
// menuThemeMode maps a menu ID to the theme it selects. Separate from onMenu so
// the mapping can be tested without a platform menu to click.
func menuThemeMode(id int) (themeMode, bool) {
switch id {
case menuThemeLight:
return modeLight, true
case menuThemeDark:
return modeDark, true
case menuThemeGlass:
return modeGlass, true
case menuThemeGlassDark:
return modeGlassDark, true
}
return 0, false
}
func (s *galleryState) Build(ctx widget.Ctx) widget.Widget {
th := s.mode.theme()
ctl := themeControl{mode: s.mode, set: func(m themeMode) {
s.SetState(func() { s.mode = m })
}}
// Provide the theme and the switcher control to the whole tree, then run the
// Navigator over the catalog home. appSurface paints the page background
// (a flat fill, or a gradient backdrop under the glass themes so their
// translucent surfaces have something to frost).
return widget.Provide[theme.Theme]{
Value: th,
Child: widget.Provide[themeControl]{
Value: ctl,
Child: appSurface(th, widget.Navigator{Home: homePage{}}),
},
}
}
// --- Home: section catalog + theme switcher ----------------------------------
// section is one catalog entry: a title, a one-line summary, and a factory for
// the page tapping it pushes.
type section struct {
title, subtitle string
page func() widget.Widget
}
// sections returns the catalog in display order. Each page factory returns a
// self-contained widget; most wrap their demo in sectionPage (adds the scaffold
// chrome + a scrolling body), while Navigator & Hero pushes its own full-page
// feed — a push transition cannot be demonstrated inside a scrolling list.
func sections() []section {
sp := func(title, subtitle string, body widget.Widget) func() widget.Widget {
return func() widget.Widget { return sectionPage{title: title, subtitle: subtitle, body: body} }
}
return []section{
{"Buttons & tappables", "Button, Primary, press-feedback rows",
sp("Buttons & tappables", "Button, Primary, and Tappable rows", buttonsSection{})},
{"Form controls", "Switch, Checkbox, Radio, Slider — all live",
sp("Form controls", "Switch, Checkbox, Radio group, Slider", formSection{})},
{"Selection", "Dropdown, Segmented, Tabs — bound live",
sp("Selection", "Dropdown, Segmented, and Tabs", selectionSection{})},
{"Pickers", "Date & time pickers in a dialog",
sp("Pickers", "Date and time pickers, echoing your pick", pickersSection{})},
{"Text input", "Field with live caret, selection & echo",
sp("Text input", "Field / TextField, echoing what you type", textInputSection{})},
{"Typography", "The Display → Caption type scale",
sp("Typography", "The theme's TypeScale, role by role", typographySection{})},
{"Cards & surfaces", "Card, Decorated, Opacity",
sp("Cards & surfaces", "Surfaces, borders, and group opacity", cardsSection{})},
{"Charts", "Line, area, bar, pie & heatmap marks",
sp("Charts", "Declarative marks over shared scales", chartsSection{})},
{"Dialogs & menus", "ShowDialog and ShowMenu overlays",
sp("Dialogs & menus", "Modal dialog and anchored menu", dialogsSection{})},
{"Layout", "Grid, Wrap, Stack, AspectRatio",
sp("Layout", "The core layout primitives, live", layoutSection{})},
{"Animations", "AnimateColor, AnimateFloat, Scale, Rotation",
sp("Animations", "Implicit animations you can trigger", animationsSection{})},
{"Navigator & Hero", "Push a page; the swatch flies with it",
func() widget.Widget { return feedPage{} }},
{"Pull to refresh", "Drag a list down to reload it",
sp("Pull to refresh", "LazyList with Refreshing / OnRefresh", refreshSection{})},
{"Swipe to dismiss", "Swipe a row aside to remove it",
sp("Swipe to dismiss", "Dismissible rows, keyed so the right one goes", dismissSection{})},
{"Tree", "Fold a hierarchy; rows announce their state",
sp("Tree", "Expandable rows, indented and announced as a tree", treeSection{})},
{"Autocomplete", "Type-ahead over an in-memory list",
sp("Autocomplete", "Suggestions filtered as you type", autocompleteSection{})},
{"Reorderable list", "Drag rows into a new order",
sp("Reorderable list", "Uniform rows, reordered by dragging", reorderSection{})},
{"Drag & drop", "Carry chips between two bins",
sp("Drag & drop", "Draggable payloads and targets that accept them", dragDropSection{})},
{"Rich text & selection", "Styled spans, a link, and drag-to-select",
sp("Rich text & selection", "Rich spans inside a SelectionArea", richTextSection{})},
{"Transform", "Rotate and scale a still-live widget",
sp("Transform", "A transformed subtree that still takes taps", transformSection{})},
{"Right to left", "Mirror a layout, not its glyphs",
sp("Right to left", "Directionality flipping a whole subtree", rtlSection{})},
}
}
type homePage struct{}
// homeHook lets tests grab the Navigator handle and push pages directly.
var homeHook func(widget.Nav)
func (homePage) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
nav := ctx.MustOf[widget.Nav]()
if homeHook != nil {
homeHook(nav)
}
rows := make([]widget.Widget, 0, len(sections())*2)
for _, sec := range sections() {
if len(rows) > 0 {
rows = append(rows, widget.Sized{H: 10})
}
rows = append(rows, sectionCard(th, sec, func() { nav.Push(sec.page()) }))
}
list := widget.Scroll{Child: widget.Padding{
Insets: geom.Insets{Left: 16, Right: 16, Top: 4, Bottom: 28},
Child: sectionColumn(rows...),
}}
head := widget.Column(
theme.Display("Catalog"),
widget.Sized{H: 4},
theme.Label("A tour of the gophics component set"),
widget.Sized{H: 14},
themeSwitcher(ctx),
)
head.CrossAlign = layout.CrossStart
col := widget.Column(
widget.Padding{Insets: geom.Insets{Left: 16, Right: 16, Top: 22, Bottom: 12}, Child: head},
widget.Expand(list),
)
col.CrossAlign = layout.CrossStretch
return appSurface(th, col)
}
// themeSwitcher is a row of buttons cycling the four built-in themes; the active
// one shows the filled Primary style.
func themeSwitcher(ctx widget.Ctx) widget.Widget {
ctl := ctx.MustOf[themeControl]()
btns := make([]widget.Widget, len(allModes))
for i, m := range allModes {
btns[i] = theme.Button{
Label: m.label(),
Primary: m == ctl.mode,
OnTap: func() { ctl.set(m) },
}
}
return widget.Wrap{Spacing: 8, RunSpacing: 8, Children: btns}
}
// sectionCard is one tappable catalog entry.
func sectionCard(th theme.Theme, sec section, onTap func()) widget.Widget {
info := widget.Column(
widget.Text{Value: sec.title, Font: theme.FontBold, Size: th.Type.Heading, Color: th.Text},
widget.Sized{H: 3},
widget.Text{Value: sec.subtitle, Size: th.Type.Label, Color: th.Muted, MaxLines: 1, Ellipsis: true},
)
info.CrossAlign = layout.CrossStart
row := widget.Row(
widget.Expand(info),
widget.Sized{W: 8},
widget.Text{Value: "›", Size: th.Type.Title, Color: th.Muted},
)
return theme.Tappable{
Background: th.Surface,
Radius: th.Radius,
Pad: geom.InsetsSymmetric(14, 14),
Haptic: true,
OnTap: onTap,
Child: row,
}
}
// --- Section page chrome -----------------------------------------------------
// sectionPage wraps a demo body in the shared scaffold and a scrolling area.
type sectionPage struct {
title, subtitle string
body widget.Widget
}
func (p sectionPage) Build(ctx widget.Ctx) widget.Widget {
scroll := widget.Scroll{Child: widget.Padding{
Insets: geom.Insets{Left: 16, Right: 16, Top: 4, Bottom: 32},
Child: p.body,
}}
return scaffold(ctx, p.title, p.subtitle, widget.Expand(scroll))
}
// scaffold is the shared page frame: a title/subtitle header (with a Back button
// once there's something to pop) over the page body, on the app surface.
func scaffold(ctx widget.Ctx, title, subtitle string, body widget.Widget) widget.Widget {
th := theme.Of(ctx)
titleCol := widget.Column(
theme.Title(title),
widget.Sized{H: 2},
theme.Label(subtitle),
)
titleCol.CrossAlign = layout.CrossStart
var head widget.Widget = titleCol
if nav, ok := ctx.Of[widget.Nav](); ok && nav.Depth() > 1 {
back := theme.Button{Label: "← Back", OnTap: func() { nav.Pop() }}
row := widget.Row(back, widget.Sized{W: 12}, widget.Expand(titleCol))
head = row
}
col := widget.Column(
widget.Padding{Insets: geom.Insets{Left: 16, Right: 16, Top: 20, Bottom: 8}, Child: head},
body,
)
col.CrossAlign = layout.CrossStretch
return appSurface(th, col)
}
// --- Shared helpers ----------------------------------------------------------
// appSurface paints the page background behind child. Opaque themes get a flat
// fill; the glass themes (Blur > 0) get a soft gradient backdrop so their
// translucent surfaces have real content to frost over. The tree shape is kept
// constant (a Stack over a background Canvas) across every theme, so switching
// themes never remounts the Navigator underneath it — page and scroll state
// survive the switch.
func appSurface(th theme.Theme, child widget.Widget) widget.Widget {
bg := widget.Canvas{Draw: func(c paint.Canvas, size geom.Size) {
if th.Blur > 0 {
drawBackdrop(c, size, th.Dark)
} else {
c.FillRect(geom.Rect{Max: size.Pt()}, th.Bg)
}
}}
return widget.Stack{Children: []widget.Widget{bg, capWidth(child)}}
}
// contentMaxW keeps the catalog content in a single readable column, centered
// over the full-bleed background, instead of sprawling across a wide window.
const contentMaxW = 760
// capWidth centers child and caps it at contentMaxW, filling narrower screens.
// It centers with symmetric padding (rather than a MainCenter flex) so it works
// under the loose constraints the Stack hands it — the pad fills the surplus and
// the child gets exactly contentMaxW.
//
// The Padding is always emitted, with zero insets when there is no surplus,
// because the shape of this subtree must not depend on the window width.
// Returning a bare child below the threshold and a wrapped one above it changed
// the tree's shape as the window crossed contentMaxW, and reconciliation
// matches by position: child got a new element, and the Navigator underneath it
// remounted and dropped its page stack. Resizing across 760px threw the reader
// back to the section list. Same reason appSurface keeps a constant shape
// across themes, one caller up.
func capWidth(child widget.Widget) widget.Widget {
return widget.LayoutBuilder{Build: func(cs layout.Constraints) widget.Widget {
pad := max((cs.Max.W-contentMaxW)/2, 0)
return widget.Padding{Insets: geom.Insets{Left: pad, Right: pad}, Child: child}
}}
}
// drawBackdrop fills the surface with a diagonal two-tone gradient plus a
// couple of soft color blooms — a photo-ish backdrop for the glass material.
func drawBackdrop(c paint.Canvas, size geom.Size, dark bool) {
r := geom.Rect{Max: size.Pt()}
var a, b paint.Color
if dark {
a, b = paint.RGB(0.10, 0.12, 0.20), paint.RGB(0.18, 0.10, 0.16)
} else {
a, b = paint.RGB(0.78, 0.84, 0.93), paint.RGB(0.93, 0.82, 0.78)
}
c.FillRRectGradient(r, 0, a, b, false)
// Two blooms of the accent hues, painted translucent for a lit-glass feel.
bloom := func(cx, cy, rad float32, col paint.Color) {
c.FillRRect(geom.RectXYWH(cx-rad, cy-rad, rad*2, rad*2), rad, col)
}
if dark {
bloom(size.W*0.2, size.H*0.18, size.W*0.5, paint.Color{R: 0.30, G: 0.20, B: 0.45, A: 0.35})
bloom(size.W*0.85, size.H*0.7, size.W*0.5, paint.Color{R: 0.45, G: 0.22, B: 0.20, A: 0.30})
} else {
bloom(size.W*0.2, size.H*0.18, size.W*0.5, paint.Color{R: 0.55, G: 0.62, B: 0.95, A: 0.30})
bloom(size.W*0.85, size.H*0.7, size.W*0.5, paint.Color{R: 0.95, G: 0.62, B: 0.45, A: 0.28})
}
}
// sectionColumn is a cross-stretched vertical stack — the default body layout,
// so full-width controls (Field, Slider, dividers) fill the page width.
func sectionColumn(children ...widget.Widget) widget.Widget {
col := widget.Column(children...)
col.CrossAlign = layout.CrossStretch
return col
}
// leftColumn stacks children left-aligned (for control groups like a checkbox or
// radio list that should sit at the start of the column, not centered).
func leftColumn(children ...widget.Widget) widget.Widget {
col := widget.Column(children...)
col.CrossAlign = layout.CrossStart
return col
}
// groupLabel titles a group of demos within a section.
func groupLabel(s string) widget.Widget {
return widget.Padding{Insets: geom.Insets{Top: 12, Bottom: 6}, Child: theme.Heading(s)}
}
// divider is a hairline rule in the theme's border color.
func divider(th theme.Theme) widget.Widget {
return widget.Sized{H: 1, Child: widget.Fill{Color: th.Border}}
}
// Config is the app's window configuration, shared by every shell that runs
// it: the desktop binary, the web build, and the mobile bind package.
func Config() app.Config {
return app.Config{
Title: "Gophics Catalog",
AppID: "com.gophics.gallery",
Size: geom.Size{W: 420, H: 760},
Background: theme.Light().Bg,
Font: goregular.TTF,
FontFamilies: map[string][]byte{"bold": gobold.TTF},
}
}
ui/sections_charts.go
package ui
import (
"github.com/doug/gophics/chart"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// chartsSection shows several chart marks over realistic small datasets, each
// themed to the active palette so a re-theme restyles the charts too.
type chartsSection struct{}
func (chartsSection) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
// Shared chrome: axis/label/grid colors and the series palette come from the
// theme, so every chart matches the surrounding app in light and dark.
chrome := func(c chart.Chart) chart.Chart {
c.LabelColor = th.Text
c.AxisColor = th.Muted
c.GridColor = th.Border
c.Palette = th.Chart[:]
return c
}
revenue := chart.XY(0, 12, 1, 18, 2, 15, 3, 22, 4, 28, 5, 26, 6, 34)
lineArea := chrome(chart.Chart{
Legend: true,
Marks: []chart.Mark{
chart.AreaMark{Data: revenue, Name: "Revenue", Alpha: 0.18},
chart.LineMark{Data: revenue, Name: "Revenue", Width: 2.5, Smooth: true, Points: true},
},
})
// chart.Pairs is the typed constructor (chart.Values is the same as
// variadic prototyping sugar).
bars := chrome(chart.Chart{
Marks: []chart.Mark{
chart.BarMark{Data: chart.Pairs([]chart.Pair{
{Label: "Mon", Value: 5}, {Label: "Tue", Value: 8}, {Label: "Wed", Value: 6},
{Label: "Thu", Value: 9}, {Label: "Fri", Value: 12}, {Label: "Sat", Value: 4},
{Label: "Sun", Value: 3},
})},
},
})
pie := chrome(chart.Chart{
Legend: true,
XAxis: chart.Axis{Hide: true},
YAxis: chart.Axis{Hide: true},
Marks: []chart.Mark{
chart.SectorMark{Inner: 0.55, Data: chart.Values("Direct", 42, "Search", 28, "Social", 18, "Email", 12)},
},
})
heatmap := chrome(chart.Chart{
XAxis: chart.Axis{Hide: true},
YAxis: chart.Axis{Hide: true},
Marks: []chart.Mark{
chart.RectMark{Cells: activityCells(), Cols: 10, Rows: 5,
Scale: chart.ColorScale{Lo: 0, Hi: 9,
From: th.Surface, To: th.Primary}},
},
})
return sectionColumn(
chartCard(th, "Line + area", "Smoothed revenue with a filled area and a legend", 190, lineArea),
widget.Sized{H: 14},
chartCard(th, "Bar", "Weekly counts over a categorical band scale", 190, bars),
widget.Sized{H: 14},
chartCard(th, "Donut", "Traffic sources as a Sector mark with a legend", 210, pie),
widget.Sized{H: 14},
chartCard(th, "Heatmap", "A contribution grid of Rect cells over a color scale", 170, heatmap),
)
}
// chartCard frames one chart with a heading and a fixed-height plot area.
func chartCard(th theme.Theme, title, subtitle string, height float32, c chart.Chart) widget.Widget {
head := widget.Column(
widget.Text{Value: title, Font: theme.FontBold, Size: th.Type.Heading, Color: th.Text},
widget.Sized{H: 2},
widget.Text{Value: subtitle, Size: th.Type.Caption, Color: th.Muted},
)
head.CrossAlign = layout.CrossStart
body := widget.Column(
head,
widget.Sized{H: 10},
widget.Sized{H: height, Child: c},
)
body.CrossAlign = layout.CrossStretch
// Solid: a frosted card over a chart costs the whole chart drawn again,
// every frame, and a chart reads better over a steady background anyway.
return theme.Card{Solid: true, Child: body}
}
// activityCells builds a deterministic 10×5 grid of activity values (0..9) for
// the heatmap — a plausible little contribution graph.
func activityCells() []chart.Cell {
cells := make([]chart.Cell, 0, 50)
for x := range 10 {
for y := range 5 {
v := (x*7 + y*3) % 10 // deterministic spread across the ramp
cells = append(cells, chart.Cell{X: x, Y: y, V: float64(v)})
}
}
return cells
}
ui/sections_controls.go
package ui
import (
"fmt"
"time"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// --- Buttons & tappables -----------------------------------------------------
// buttonsSection shows the themed Button (default + Primary) and Tappable rows,
// with a live counter proving each tap fires.
type buttonsSection struct{}
func (buttonsSection) CreateState() widget.State { return &buttonsState{} }
type buttonsState struct {
widget.StateBase[buttonsSection]
taps int
last string
}
func (s *buttonsState) bump(what string) {
s.SetState(func() { s.taps++; s.last = what })
}
func (s *buttonsState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
return sectionColumn(
groupLabel("Buttons"),
widget.Wrap{Spacing: 10, RunSpacing: 10, Children: []widget.Widget{
theme.Button{Label: "Default", OnTap: func() { s.bump("Default") }},
theme.Button{Label: "Primary", Primary: true, OnTap: func() { s.bump("Primary") }},
}},
groupLabel("Tappable rows"),
theme.Body("Row-level press feedback for list items — the highlight flashes on press and eases out on release."),
widget.Sized{H: 8},
widget.Decorated{Color: th.Surface, Radius: th.Radius, Child: widget.Column(
tapRow(th, "Archive", "swipe-free tap target", func() { s.bump("Archive") }),
divider(th),
tapRow(th, "Mute", "with a haptic tick", func() { s.bump("Mute") }),
divider(th),
tapRow(th, "Delete", "", func() { s.bump("Delete") }),
)},
widget.Sized{H: 16},
theme.Card{Child: widget.Text{
Value: fmt.Sprintf("Taps: %d last: %s", s.taps, orDash(s.last)),
Size: th.Type.Body,
Color: th.Text,
}},
)
}
func tapRow(th theme.Theme, title, sub string, onTap func()) widget.Widget {
label := widget.Column(
widget.Text{Value: title, Font: theme.FontBold, Size: th.Type.Body, Color: th.Text},
)
label.CrossAlign = layout.CrossStart
if sub != "" {
label.Children = append(label.Children,
widget.Sized{H: 2},
widget.Text{Value: sub, Size: th.Type.Caption, Color: th.Muted},
)
}
row := widget.Row(widget.Expand(label), widget.Text{Value: "›", Size: th.Type.Title, Color: th.Muted})
return theme.Tappable{
Background: th.Surface,
Pad: geom.InsetsSymmetric(14, 12),
Haptic: true,
OnTap: onTap,
Child: row,
}
}
func orDash(s string) string {
if s == "" {
return "—"
}
return s
}
// --- Form controls -----------------------------------------------------------
// formSection binds Switch, Checkbox, a Radio group, and a Slider to live state
// — the biggest gap in the old gallery, so it gets the most attention.
type formSection struct{}
func (formSection) CreateState() widget.State { return &formState{} }
type formState struct {
widget.StateBase[formSection]
notify bool
toppings [3]bool
plan int
volume float32
}
func (s *formState) Init(widget.Ctx) {
s.toppings = [3]bool{true, false, false}
s.volume = 0.4
}
var toppingNames = []string{"Mushroom", "Olive", "Basil"}
var planNames = []string{"Free", "Pro", "Team"}
func (s *formState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
// Switch: toggles a visible confirmation surface.
var switchState widget.Widget = theme.Body("Notifications are off.")
if s.notify {
switchState = theme.Card{Child: widget.Row(
widget.Text{Value: "🔔", Size: th.Type.Heading},
widget.Sized{W: 8},
widget.Text{Value: "You'll be notified.", Size: th.Type.Body, Color: th.Text},
)}
}
// Checkboxes: three bound toppings, echoed in a summary line.
checks := make([]widget.Widget, 0, 5)
for i, name := range toppingNames {
if i > 0 {
checks = append(checks, widget.Sized{H: 8})
}
checks = append(checks, theme.Checkbox{
Checked: s.toppings[i],
Label: name,
OnChange: func(v bool) { s.SetState(func() { s.toppings[i] = v }) },
})
}
// Radio group: single-select plan.
radios := make([]widget.Widget, 0, 5)
for i, name := range planNames {
if i > 0 {
radios = append(radios, widget.Sized{H: 8})
}
radios = append(radios, theme.Radio{
Selected: s.plan == i,
Label: name,
OnSelect: func() { s.SetState(func() { s.plan = i }) },
})
}
return sectionColumn(
groupLabel("Switch"),
widget.Row(
widget.Expand(theme.Body("Enable notifications")),
theme.Switch{
On: s.notify,
Label: "Enable notifications",
OnChange: func(v bool) { s.SetState(func() { s.notify = v }) },
},
),
widget.Sized{H: 10},
switchState,
groupLabel("Checkbox"),
leftColumn(checks...),
widget.Sized{H: 8},
widget.Text{Value: "Chosen: " + orDash(chosen(s.toppings[:], toppingNames)), Size: th.Type.Label, Color: th.Muted},
groupLabel("Radio group"),
leftColumn(radios...),
widget.Sized{H: 8},
widget.Text{Value: "Plan: " + planNames[s.plan], Size: th.Type.Label, Color: th.Muted},
groupLabel("Slider"),
widget.Row(
widget.Expand(theme.Body("Volume")),
widget.Text{Value: fmt.Sprintf("%d%%", int(s.volume*100+0.5)), Font: theme.FontBold, Size: th.Type.Body, Color: th.Primary},
),
widget.Sized{H: 4},
theme.Slider{Value: s.volume, OnChange: func(v float32) { s.SetState(func() { s.volume = v }) }},
)
}
func chosen(flags []bool, names []string) string {
out := ""
for i, on := range flags {
if on {
if out != "" {
out += ", "
}
out += names[i]
}
}
return out
}
// --- Text input --------------------------------------------------------------
// textInputSection echoes what you type through a themed Field, and keeps a
// multiline note — exercising the caret, selection, and controlled value.
type textInputSection struct{}
func (textInputSection) CreateState() widget.State { return &textInputState{} }
type textInputState struct {
widget.StateBase[textInputSection]
name string
note string
}
func (s *textInputState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
echo := orDash(s.name)
return sectionColumn(
groupLabel("Single line"),
theme.Field{
Value: s.name,
Placeholder: "Your name…",
OnChange: func(v string) { s.SetState(func() { s.name = v }) },
},
widget.Sized{H: 10},
widget.Row(
widget.Text{Value: "Hello, ", Size: th.Type.Body, Color: th.Muted},
widget.Text{Value: echo, Font: theme.FontBold, Size: th.Type.Body, Color: th.Text},
),
widget.Sized{H: 4},
widget.Text{Value: fmt.Sprintf("%d characters", len([]rune(s.name))), Size: th.Type.Caption, Color: th.Muted},
groupLabel("Multiline"),
theme.Field{
Value: s.note,
Placeholder: "A short note (Enter for a new line)…",
Multiline: true,
OnChange: func(v string) { s.SetState(func() { s.note = v }) },
},
)
}
// --- Typography --------------------------------------------------------------
// typographySection is a specimen sheet: each type role at its scale size,
// labeled, so the ramp reads at a glance and re-themes with the switcher.
type typographySection struct{}
func (typographySection) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
row := func(name string, px float32, sample widget.Widget) widget.Widget {
meta := widget.Column(
widget.Text{Value: name, Font: theme.FontBold, Size: th.Type.Label, Color: th.Text},
widget.Text{Value: fmt.Sprintf("%gpx", px), Size: th.Type.Caption, Color: th.Muted},
)
meta.CrossAlign = layout.CrossStart
r := widget.Row(widget.Sized{W: 92, Child: meta}, widget.Expand(sample))
r.CrossAlign = layout.CrossCenter
return r
}
sep := func() widget.Widget {
return widget.Padding{Insets: geom.Insets{Top: 12, Bottom: 12}, Child: divider(th)}
}
return sectionColumn(
row("Display", th.Type.Display, theme.Display("Considered")),
sep(),
row("Title", th.Type.Title, theme.Title("Page title")),
sep(),
row("Heading", th.Type.Heading, theme.Heading("Section heading")),
sep(),
row("Body", th.Type.Body, theme.Body("Body copy is the default reading size for paragraphs of text.")),
sep(),
row("Label", th.Type.Label, theme.Label("Control label")),
sep(),
row("Caption", th.Type.Caption, theme.Caption("Fine print · timestamps")),
)
}
// --- Cards & surfaces --------------------------------------------------------
// cardsSection shows the Card surface, raw Decorated (fill + border), and a
// live Opacity group that fades on tap.
type cardsSection struct{}
func (cardsSection) CreateState() widget.State { return &cardsState{} }
type cardsState struct {
widget.StateBase[cardsSection]
faded bool
}
func (s *cardsState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
alpha := float32(1)
if s.faded {
alpha = 0.25
}
// Deliberately a filled, high-contrast panel rather than a plain Card. In a
// light theme a Card is a white surface on an off-white page — about six
// levels of contrast at full opacity, and none at all once it is faded to a
// quarter. The demo then looks broken rather than dimmed, which is exactly
// how it was reported. Fading has to be visible for a fade to be the point.
fadeTarget := widget.AnimateFloat(alpha, 200*time.Millisecond, func(a float32) widget.Widget {
return widget.Opacity{Alpha: a, Child: widget.Decorated{
Color: th.Primary,
Radius: th.Radius,
Child: widget.Padding{All: 16, Child: widget.Column(
widget.Text{Value: "Grouped opacity", Font: theme.FontBold, Size: th.Type.Heading, Color: th.OnPrimary},
widget.Sized{H: 6},
widget.Text{
Value: "The whole panel fades as one group, not shape by shape.",
Size: th.Type.Body,
Color: th.OnPrimary,
},
)},
}}
})
return sectionColumn(
groupLabel("Card"),
theme.Card{Child: widget.Column(
widget.Text{Value: "A themed surface", Font: theme.FontBold, Size: th.Type.Heading, Color: th.Text},
widget.Sized{H: 6},
theme.Body("Card supplies the surface fill, corner radius, and — under a glass theme — the backdrop blur."),
)},
groupLabel("Decorated"),
widget.Row(
widget.Expand(widget.Decorated{Color: th.Surface, Radius: th.Radius,
Child: widget.Padding{All: 16, Child: widget.Center(widget.Text{Value: "Filled", Size: th.Type.Body, Color: th.Text})}}),
widget.Sized{W: 12},
widget.Expand(widget.Decorated{Radius: th.Radius, BorderColor: th.Border, BorderWidth: 1.5,
Child: widget.Padding{All: 16, Child: widget.Center(widget.Text{Value: "Bordered", Size: th.Type.Body, Color: th.Text})}}),
),
groupLabel("Opacity"),
widget.Interactive{
Gestures: widget.Gestures{OnTap: func() { s.SetState(func() { s.faded = !s.faded }) }},
Child: fadeTarget,
},
widget.Sized{H: 8},
theme.Label("Tap the card to fade it"),
)
}
ui/sections_data.go
package ui
import (
"fmt"
"slices"
"strings"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// --- Tree ---------------------------------------------------------------------
// treeSection folds a small source tree. What is on show is the folding and
// the indentation: row content is ordinary widgets, which is the point of the
// widget owning expansion and nothing else.
//
// It also demonstrates what the tree publishes to assistive technology — each
// row is a treeitem carrying its expanded state, and the disclosure glyph is
// hidden so a screen reader reads the row rather than the triangle.
type treeSection struct{}
func (treeSection) CreateState() widget.State { return &treeState2{} }
type treeState2 struct {
widget.StateBase[treeSection]
lastToggled string
open bool
}
func (s *treeState2) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
leaf := func(id, name string) widget.TreeNode {
return widget.TreeNode{ID: id, Child: widget.Text{
Value: name, Size: th.Type.Body, Color: th.Text,
}}
}
branch := func(id, name string, kids ...widget.TreeNode) widget.TreeNode {
return widget.TreeNode{ID: id, Children: kids, Child: widget.Text{
Value: name, Font: theme.FontBold, Size: th.Type.Body, Color: th.Text,
}}
}
status := "tap a folder to fold it"
if s.lastToggled != "" {
verb := "collapsed"
if s.open {
verb = "expanded"
}
status = fmt.Sprintf("%s %s", s.lastToggled, verb)
}
return sectionColumn(
groupLabel("A source tree"),
theme.Card{Child: widget.Padding{
Insets: geom.Insets{Top: 8, Bottom: 8, Left: 4, Right: 4},
Child: widget.Tree{
InitiallyExpanded: []string{"widget"},
OnToggle: func(id string, expanded bool) {
s.SetState(func() { s.lastToggled, s.open = id, expanded })
},
Nodes: []widget.TreeNode{
branch("widget", "widget",
leaf("w-tree", "tree.go"),
leaf("w-list", "lazylist.go"),
branch("w-internal", "internal",
leaf("w-recon", "reconcile.go"),
),
),
branch("theme", "theme",
leaf("t-controls", "controls.go"),
),
leaf("readme", "README.md"),
},
},
}},
widget.Sized{H: 8},
widget.Text{Value: status, Size: th.Type.Caption, Color: th.Muted},
)
}
// --- Autocomplete --------------------------------------------------------------
// autocompleteSection filters an in-memory list as you type. Suggest runs
// during build, so the demo keeps it to a substring match over a small slice —
// which is exactly the advice in the widget's own documentation.
type autocompleteSection struct{}
func (autocompleteSection) CreateState() widget.State { return &autocompleteDemo{} }
type autocompleteDemo struct {
widget.StateBase[autocompleteSection]
value string
picked string
}
var timezones = []string{
"Africa/Cairo", "America/Chicago", "America/New_York", "America/Sao_Paulo",
"Asia/Kolkata", "Asia/Singapore", "Asia/Tokyo", "Australia/Sydney",
"Europe/Berlin", "Europe/Lisbon", "Europe/London", "Pacific/Auckland",
}
func (s *autocompleteDemo) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
field := widget.Autocomplete{
Value: s.value,
Placeholder: "Start typing — try \"eu\" or \"asia\"…",
MaxVisible: 6,
Suggest: func(in string) []string {
if in == "" {
return nil
}
var out []string
for _, z := range timezones {
if strings.Contains(strings.ToLower(z), strings.ToLower(in)) {
out = append(out, z)
}
}
return out
},
OnChange: func(v string) { s.SetState(func() { s.value = v }) },
OnPick: func(v string) { s.SetState(func() { s.picked = v }) },
Row: func(item string, highlighted bool) widget.Widget {
bg := paint.Color{}
if highlighted {
bg = th.Primary.WithAlpha(0.14)
}
return widget.Decorated{
Color: bg,
Child: widget.Padding{
Insets: geom.Insets{Top: 7, Bottom: 7, Left: 10, Right: 10},
Child: widget.Text{Value: item, Size: th.Type.Body, Color: th.Text},
},
}
},
}
return sectionColumn(
groupLabel("Type a timezone"),
// Autocomplete is a widget-layer control and carries no styling of its
// own, exactly like the raw TextField that theme.Field wraps. The field
// chrome is the app's to supply.
widget.Decorated{
BorderColor: th.Outline,
BorderWidth: 1,
Radius: 8,
Child: widget.Padding{
Insets: geom.Insets{Top: 10, Bottom: 10, Left: 12, Right: 12},
Child: field,
},
},
widget.Sized{H: 10},
widget.Text{
Value: "Picked: " + orDash(s.picked),
Size: th.Type.Caption,
Color: th.Muted,
},
)
}
// --- Reorderable ----------------------------------------------------------------
// reorderSection drags rows into a new order. Rows are a uniform height
// because Reorderable requires it — with variable extents the drop index
// depends on the sizes being crossed, which are not known mid-drag.
type reorderSection struct{}
func (reorderSection) CreateState() widget.State { return &reorderDemo{} }
type reorderDemo struct {
widget.StateBase[reorderSection]
items []string
moves int
}
func (s *reorderDemo) Init(widget.Ctx) {
s.items = []string{"Mercury", "Venus", "Earth", "Mars", "Jupiter"}
}
func (s *reorderDemo) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
const rowH = 44
return sectionColumn(
groupLabel("Drag a row by any part of it"),
widget.Sized{H: rowH * 5, Child: widget.Reorderable{
Count: len(s.items),
ItemExtent: rowH,
// The built row has to be exactly ItemExtent tall. The list maps
// finger position to index by multiplying that number, so a row
// that renders taller overflows its own list and drops in the
// wrong place.
Build: func(i int) widget.Widget {
// The row's height is fixed at ItemExtent, so its padding has
// to fit inside it. Card already pads by 12, and adding
// another 8 on top left the content with less height than
// nothing (44 - 6 - 24 - 16 = -2): the handle and the label
// spilled out of the bottom of the card instead of sitting in
// the middle of it. One padding, and Align centres what is
// left over.
return widget.Sized{H: rowH, Child: widget.Padding{
Insets: geom.Insets{Bottom: 6},
Child: theme.Card{Pad: 8, Child: widget.Align{
X: 0, Y: 0.5, Directional: true,
Child: widget.Row(
widget.Text{Value: "=", Font: theme.FontBold, Size: th.Type.Body, Color: th.Muted},
widget.Sized{W: 10},
widget.Text{Value: s.items[i], Size: th.Type.Body, Color: th.Text},
),
}},
}}
},
OnReorder: func(from, to int) {
s.SetState(func() {
item := s.items[from]
rest := append(s.items[:from:from], s.items[from+1:]...)
s.items = append(rest[:to:to], append([]string{item}, rest[to:]...)...)
s.moves++
})
},
}},
widget.Sized{H: 6},
widget.Text{
Value: fmt.Sprintf("%d reorders · %s", s.moves, strings.Join(s.items, " → ")),
Size: th.Type.Caption,
Color: th.Muted,
Wrap: true,
},
)
}
// --- Drag and drop ---------------------------------------------------------------
// dragDropSection moves chips between two bins. DragHost is what paints the
// in-flight preview above everything else, so the demo wraps itself in one
// rather than relying on an ancestor to have done it.
type dragDropSection struct{}
func (dragDropSection) CreateState() widget.State { return &dragDropDemo{} }
type dragDropDemo struct {
widget.StateBase[dragDropSection]
todo, done []string
}
func (s *dragDropDemo) Init(widget.Ctx) {
s.todo = []string{"Sketch", "Draft", "Review"}
s.done = []string{"Outline"}
}
func (s *dragDropDemo) move(item string, toDone bool) {
s.SetState(func() {
drop := func(from []string) []string {
out := from[:0:0]
for _, v := range from {
if v != item {
out = append(out, v)
}
}
return out
}
s.todo, s.done = drop(s.todo), drop(s.done)
if toDone {
s.done = append(s.done, item)
} else {
s.todo = append(s.todo, item)
}
})
}
func (s *dragDropDemo) chip(th theme.Theme, item string) widget.Widget {
face := widget.Decorated{
Color: th.Primary.WithAlpha(0.16),
Radius: 8,
Child: widget.Padding{
Insets: geom.Insets{Top: 6, Bottom: 6, Left: 10, Right: 10},
Child: widget.Text{Value: item, Size: th.Type.Body, Color: th.Text},
},
}
return widget.Padding{
Insets: geom.Insets{Right: 6, Bottom: 6},
Child: widget.Draggable{
Payload: item,
// Inside a scrollable page a plain drag means scroll, so the
// gesture has to be claimed deliberately.
LongPressToStart: true,
Child: face,
},
}
}
func (s *dragDropDemo) bin(th theme.Theme, title string, items []string, toDone bool) widget.Widget {
chips := make([]widget.Widget, 0, len(items))
for _, it := range items {
chips = append(chips, s.chip(th, it))
}
var body widget.Widget = widget.Wrap{Spacing: 6, RunSpacing: 6, Children: chips}
if len(items) == 0 {
body = widget.Text{Value: "empty", Size: th.Type.Caption, Color: th.Muted}
}
return widget.DropTarget{
Accept: func(p any) bool {
item, ok := p.(string)
if !ok {
return false
}
// A bin does not accept what it already holds: dropping a chip back
// where it started should read as a no-op, not a move.
return !slices.Contains(items, item)
},
OnDrop: func(p any, _ geom.Pt) {
if item, ok := p.(string); ok {
s.move(item, toDone)
}
},
Builder: func(hovering bool) widget.Widget {
border := th.Border
fill := paint.Color{}
if hovering {
border, fill = th.Primary, th.Primary.WithAlpha(0.08)
}
return widget.Decorated{
Color: fill, BorderColor: border, BorderWidth: 1.5, Radius: 10,
Child: widget.Padding{
Insets: geom.Insets{Top: 10, Bottom: 10, Left: 10, Right: 10},
Child: widget.Column(
widget.Text{Value: title, Font: theme.FontBold, Size: th.Type.Label, Color: th.Muted},
widget.Sized{H: 8},
body,
),
},
}
},
}
}
func (s *dragDropDemo) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
return sectionColumn(
groupLabel("Drag a chip across — long-press first on touch"),
widget.DragHost{Child: widget.Column(
s.bin(th, "TO DO", s.todo, false),
widget.Sized{H: 10},
s.bin(th, "DONE", s.done, true),
)},
widget.Sized{H: 8},
widget.Text{
Value: "A bin refuses a chip it already holds — the border only lights for a drop it will take.",
Size: th.Type.Caption,
Color: th.Muted,
Wrap: true,
},
)
}
// --- Rich text and selection -------------------------------------------------------
// richTextSection shows styled spans with a tappable link, wrapped in a
// SelectionArea so the text can be dragged over and copied — the two text
// capabilities that are not part of a plain Text.
type richTextSection struct{}
func (richTextSection) CreateState() widget.State { return &richTextDemo{} }
type richTextDemo struct {
widget.StateBase[richTextSection]
tapped string
}
func (s *richTextDemo) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
return sectionColumn(
groupLabel("Styled spans, one of them a link"),
theme.Card{Child: widget.Padding{
Insets: geom.Insets{Top: 12, Bottom: 12, Left: 12, Right: 12},
Child: widget.SelectionArea{Child: widget.Rich{
Size: th.Type.Body,
Spans: []layout.RichSpan{
{Text: "Rich text runs ", Color: th.Text},
{Text: "bold", Font: theme.FontBold, Color: th.Text},
{Text: " and ", Color: th.Text},
{Text: "coloured", Color: th.Primary},
{Text: " spans in one paragraph, wraps them together, and can carry a ", Color: th.Text},
{Text: "link", Color: th.Primary, Underline: true, Link: "https://gophics.com"},
{Text: ". Drag across any of it to select.", Color: th.Text},
},
OnLink: func(url string) { s.SetState(func() { s.tapped = url }) },
}},
}},
widget.Sized{H: 10},
widget.Text{
Value: "Link tapped: " + orDash(s.tapped),
Size: th.Type.Caption,
Color: th.Muted,
},
)
}
// --- Transform ---------------------------------------------------------------------
// transformSection applies a 2D transform to a live widget, not a picture:
// the button underneath stays tappable through the rotation and scale, which
// is the part worth showing.
type transformSection struct{}
func (transformSection) CreateState() widget.State { return &transformDemo{} }
type transformDemo struct {
widget.StateBase[transformSection]
angle float32
scale float32
taps int
}
func (s *transformDemo) Init(widget.Ctx) { s.scale = 1 }
func (s *transformDemo) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
if s.scale == 0 {
s.scale = 1
}
return sectionColumn(
groupLabel("A transformed, still-live widget"),
widget.Center(widget.Sized{H: 120, Child: widget.Center(
widget.Transform{
T: paint.Transform{Rotation: s.angle, SX: s.scale, SY: s.scale},
Center: true,
Child: theme.Button{
Label: fmt.Sprintf("Tapped %d×", s.taps),
OnTap: func() { s.SetState(func() { s.taps++ }) },
},
},
)}),
widget.Sized{H: 8},
widget.Row(
theme.Button{Label: "Rotate", OnTap: func() {
s.SetState(func() { s.angle += 0.20 })
}},
widget.Sized{W: 8},
theme.Button{Label: "Grow", OnTap: func() {
s.SetState(func() { s.scale = clampF(s.scale+0.15, 0.5, 1.8) })
}},
widget.Sized{W: 8},
// Reset returns the demo to how it was found, tap count included:
// leaving "Tapped 7×" under a reset transform reads as the button
// having missed the reset rather than as a deliberate exclusion.
theme.Button{Label: "Reset", OnTap: func() {
s.SetState(func() { s.angle, s.scale, s.taps = 0, 1, 0 })
}},
),
widget.Sized{H: 8},
widget.Text{
Value: "Hit testing follows the transform — the button still takes taps where it is drawn.",
Size: th.Type.Caption,
Color: th.Muted,
Wrap: true,
},
)
}
func clampF(v, lo, hi float32) float32 {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// --- Directionality ------------------------------------------------------------------
// rtlSection mirrors a layout rather than a string. Everything inside flips —
// row order, alignment, padding — which is what makes right-to-left a layout
// property rather than a text one.
type rtlSection struct{}
func (rtlSection) CreateState() widget.State { return &rtlDemo{} }
type rtlDemo struct {
widget.StateBase[rtlSection]
rtl bool
}
func (s *rtlDemo) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
dir := widget.DirLTR
if s.rtl {
dir = widget.DirRTL
}
sample := theme.Card{Child: widget.Padding{
Insets: geom.Insets{Top: 12, Bottom: 12, Left: 12, Right: 12},
Child: widget.Column(
widget.Row(
widget.Text{Value: "<", Font: theme.FontBold, Size: th.Type.Body, Color: th.Primary},
widget.Sized{W: 8},
widget.Text{Value: "Leading", Font: theme.FontBold, Size: th.Type.Body, Color: th.Text},
widget.Spacer(),
widget.Text{Value: "Trailing", Size: th.Type.Body, Color: th.Muted},
),
widget.Sized{H: 8},
widget.Text{
Value: "Padding, row order and alignment all mirror; the glyphs do not.",
Size: th.Type.Caption,
Color: th.Muted,
Wrap: true,
},
),
}}
return sectionColumn(
groupLabel("Layout mirroring"),
widget.Directionality{Dir: dir, Child: sample},
widget.Sized{H: 10},
widget.Row(
theme.Switch{
On: s.rtl,
Label: "Right to left",
OnChange: func(v bool) { s.SetState(func() { s.rtl = v }) },
},
widget.Sized{W: 10},
widget.Text{Value: "Right to left", Size: th.Type.Body, Color: th.Text},
),
)
}
ui/sections_gestures.go
package ui
// Pull-to-refresh and swipe-to-dismiss, one demo each.
//
// Both used to live inside the Navigation & gestures feed, alongside a
// Navigator, Hero transitions, selectable text and a like button. Five things
// in one page meant none of them was the subject: a reader who wanted to see
// how swipe-to-dismiss is wired had to find it inside a list that was also
// demonstrating four other ideas. They are separate sections now, each showing
// one widget doing one thing, which is how the rest of the catalog reads.
import (
"fmt"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// --- Pull to refresh ---------------------------------------------------------
// refreshSection is a LazyList wired to pull-to-refresh: drag down from the top
// and the list regenerates.
type refreshSection struct{}
func (refreshSection) CreateState() widget.State { return &refreshState{} }
type refreshState struct {
widget.StateBase[refreshSection]
cards []card
seed int
refreshing bool
count int // how many times it has refreshed, so the effect is legible
}
func (s *refreshState) Init(widget.Ctx) { s.cards = makeCards(6, 0) }
func (s *refreshState) refresh(ctx widget.Ctx) {
s.SetState(func() { s.refreshing = true })
// A real app fetches here and clears Refreshing when the work finishes.
// This regenerates on the next frame so the spinner is visible for one.
post := ctx.Post()
post(func() {
s.SetState(func() {
s.seed += 6
s.count++
s.cards = makeCards(6, s.seed)
s.refreshing = false
})
})
}
// refreshedLabel reads as a sentence at every count, rather than "0 time(s)".
func refreshedLabel(n int) string {
if n == 1 {
return "Refreshed once"
}
return fmt.Sprintf("Refreshed %d times", n)
}
func (s *refreshState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
list := widget.LazyList{
Count: len(s.cards),
EstimatedExtent: 96,
Refreshing: s.refreshing,
OnRefresh: func() { s.refresh(ctx) },
Build: func(i int) widget.Widget { return cardTile(th, s.cards[i]) },
}
return sectionColumn(
theme.Body("Drag down from the top of the list to refresh it."),
widget.Sized{H: 4},
widget.Text{
Value: refreshedLabel(s.count),
Size: th.Type.Label,
Color: th.Muted,
},
widget.Sized{H: 10},
// A scrolling list needs a bounded height inside a scrolling page.
// Tall enough that the cut-off row reads as "more below" rather than
// as the demo running out.
widget.Sized{H: 430, Child: list},
)
}
// --- Swipe to dismiss --------------------------------------------------------
// dismissSection is a short list of Dismissible rows: swipe one aside and it is
// removed, with a panel showing behind it as it slides.
type dismissSection struct{}
func (dismissSection) CreateState() widget.State { return &dismissState{} }
type dismissState struct {
widget.StateBase[dismissSection]
cards []card
removed int
}
func (s *dismissState) Init(widget.Ctx) { s.cards = makeCards(5, 40) }
func (s *dismissState) remove(id int) {
s.SetState(func() {
for i, c := range s.cards {
if c.id == id {
s.cards = append(s.cards[:i], s.cards[i+1:]...)
s.removed++
return
}
}
})
}
func (s *dismissState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
rows := make([]widget.Widget, 0, len(s.cards)+1)
for _, c := range s.cards {
// WithKey is what makes removal animate the right row: without a stable
// key the reconciler matches rows by position, so removing the middle
// one looks like the last one vanishing.
rows = append(rows, widget.WithKey{Key: c.id, Child: widget.Dismissible{
OnDismissed: func() { s.remove(c.id) },
Background: dismissPanel(th),
Child: cardTile(th, c),
}})
}
if len(s.cards) == 0 {
rows = append(rows, widget.Padding{All: 16,
Child: theme.Body("All gone. Reopen this section to start over.")})
}
return sectionColumn(
theme.Body("Swipe a row sideways to remove it."),
widget.Sized{H: 4},
widget.Text{
Value: fmt.Sprintf("Removed %d of 5", s.removed),
Size: th.Type.Label,
Color: th.Muted,
},
widget.Sized{H: 10},
sectionColumn(rows...),
)
}
ui/sections_nav.go
package ui
// Navigator with Hero shared-element transitions.
//
// This is the one section that is a full page of its own rather than a
// scrolling demo list, and it earns that: a push transition cannot be shown
// inside a list, because the transition *is* the page changing. The Hero swatch
// flies from the row into the detail header, which is the whole point.
//
// Pull-to-refresh and swipe-to-dismiss used to be tangled into this same feed;
// they are their own sections now (sections_gestures.go), so each demo has one
// subject. The procedural-image helpers stay here as content, not chrome — the
// gesture sections share them.
import (
"fmt"
"image"
"image/color"
"math"
"time"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// --- Data (procedural, deterministic) ---------------------------------------
type card struct {
id int
title, author string
img image.Image // a real decoded image (procedurally generated)
likes int
}
var titles = []string{
"Aurora over the fjords", "Concrete and light", "Tidepools at dawn",
"The last analog radio", "Rooftop gardens of Kyoto", "Salt flats, no horizon",
"Neon in the rain", "A quiet cartography", "Machines that dream",
"Paper airplanes to Mars", "The color of patience", "Signal and moss",
}
var authors = []string{"mira", "koji", "petra", "sol", "wren", "arturo", "ines", "dax"}
// swatchHues returns two gradient colors derived from an index, so the feed is
// colorful and deterministic (no RNG — regenerating is reproducible).
func swatchHues(i int) (paint.Color, paint.Color) {
h := float32(i*47%360) / 360
return paint.HSV(h*360, 0.55, 0.95), paint.HSV(mod01(h+0.12)*360, 0.65, 0.75)
}
func makeCards(n, seed int) []card {
cards := make([]card, n)
for i := range cards {
k := i + seed
cards[i] = card{
id: k*100 + 7, // stable id independent of position
title: titles[k%len(titles)],
author: authors[k%len(authors)],
img: genImage(k),
likes: (k*37)%90 + 3,
}
}
return cards
}
// genImage builds a deterministic, photographic-ish image (layered plasma over
// a two-tone gradient, with a vignette and a little grain) so the feed shows
// real decoded images — exercising the image decode/blit/scale path.
func genImage(seed int) image.Image {
const n = 220 // large enough to stay crisp scaled up into the detail header
img := image.NewRGBA(image.Rect(0, 0, n, n))
a, b := swatchHues(seed)
fs := float64(seed)
for y := range n {
fy := float64(y) / n
for x := range n {
fx := float64(x) / n
v := 0.5 + 0.25*math.Sin((fx*3.7+fs)*math.Pi) +
0.22*math.Cos((fy*2.9-fs*0.7)*math.Pi) +
0.18*math.Sin((fx+fy)*5*math.Pi+fs)
v = clamp01f(v)
t := float32(v)*0.7 + float32(fy)*0.3
col := paint.Lerp(a, b, t)
dx, dy := fx-0.5, fy-0.5
vig := float32(clamp01f(1 - (dx*dx+dy*dy)*0.9))
if vig < 0.4 {
vig = 0.4
}
grain := float32((x*131+y*197+seed*17)%19)/19*0.06 - 0.03
img.SetRGBA(x, y, color.RGBA{
R: to8(col.R*vig + grain),
G: to8(col.G*vig + grain),
B: to8(col.B*vig + grain),
A: 255,
})
}
}
return img
}
func clamp01f(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func to8(v float32) uint8 {
if v < 0 {
v = 0
}
if v > 1 {
v = 1
}
return uint8(v * 255)
}
func mod01(v float32) float32 {
for v >= 1 {
v -= 1
}
return v
}
func bodyFor(c card) string {
return fmt.Sprintf("%s is a study in restraint by @%s — long exposures, "+
"muted palettes, and a stubborn belief that the frame should breathe. "+
"Try selecting this text: press and drag, then Cmd/Ctrl+C to copy.", c.title, c.author)
}
func commentsFor(c card) []string {
return []string{
"the gradient in the corner is doing a lot of work here",
"@" + c.author + " what lens was this?",
"saved to my board immediately",
"that second color choice is inspired",
"reminds me of early Saul Leiter",
"how long was the exposure?",
"the restraint is the whole point",
"instant favorite, no notes",
}
}
// --- Feed page ---------------------------------------------------------------
type feedPage struct{}
func (feedPage) CreateState() widget.State { return &feedState{} }
type feedState struct {
widget.StateBase[feedPage]
cards []card
seed int
refreshing bool
}
// feedHook lets tests observe the mounted feed.
var feedHook func(*feedState)
func (s *feedState) Init(widget.Ctx) {
if feedHook != nil {
feedHook(s)
}
s.cards = makeCards(12, 0)
}
func (s *feedState) refresh(ctx widget.Ctx) {
s.SetState(func() { s.refreshing = true })
// Regenerate from a new seed on the next frame (no network to await; a real
// app would fetch here, then clear refreshing when done).
post := ctx.Post()
post(func() {
s.SetState(func() {
s.seed += 12
s.cards = makeCards(12, s.seed)
s.refreshing = false
})
})
}
func (s *feedState) remove(id int) {
s.SetState(func() {
for i, c := range s.cards {
if c.id == id {
s.cards = append(s.cards[:i], s.cards[i+1:]...)
return
}
}
})
}
func (s *feedState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
nav := ctx.MustOf[widget.Nav]()
list := widget.LazyList{
Count: len(s.cards),
EstimatedExtent: 96,
Build: func(i int) widget.Widget {
c := s.cards[i]
return widget.Interactive{
Gestures: widget.Gestures{OnTap: func() { nav.Push(detailPage{card: c}) }},
Child: cardTile(th, c),
}
},
}
return scaffold(ctx, "Navigator & Hero", "tap a row — the swatch flies into the detail page", widget.Expand(list))
}
func cardTile(th theme.Theme, c card) widget.Widget {
info := widget.Column(
widget.Text{Value: c.title, Font: theme.FontBold, Size: th.Type.Heading, Color: th.Text, MaxLines: 1, Ellipsis: true},
widget.Sized{H: 4},
widget.Text{Value: "@" + c.author + " · " + fmt.Sprintf("%d likes", c.likes), Size: th.Type.Label, Color: th.Muted},
)
info.CrossAlign = layout.CrossStart
row := widget.Row(
widget.Hero{Tag: heroTag(c.id), Child: swatch(c.img, 60, 60, 14)},
widget.Sized{W: 14},
widget.Expand(info),
)
return widget.Padding{All: 8, Child: theme.Card{Pad: 12, Child: row}}
}
func dismissPanel(th theme.Theme) widget.Widget {
label := widget.Text{Value: "remove", Font: theme.FontBold, Size: th.Type.Label, Color: th.OnPrimary}
return widget.Padding{All: 8, Child: widget.Decorated{Color: th.Danger, Radius: th.Radius,
Child: widget.Padding{Insets: geom.Insets{Left: 24, Right: 24},
Child: widget.Row(label, widget.Spacer(), label)}}}
}
// --- Detail page -------------------------------------------------------------
type detailPage struct{ card card }
func (d detailPage) CreateState() widget.State { return &detailState{card: d.card} }
type detailState struct {
widget.StateBase[detailPage]
card card
liked bool
}
func (s *detailState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
nav := ctx.MustOf[widget.Nav]()
c := s.card
// Full-bleed hero header — tapping it (or the back chip) pops.
header := widget.Interactive{
Gestures: widget.Gestures{OnTap: func() { nav.Pop() }},
Child: widget.Hero{Tag: heroTag(c.id), Child: swatch(c.img, 0, 200, 0)},
}
heartSize := float32(22)
heartColor := th.Muted
if s.liked {
heartSize, heartColor = 30, th.Danger
}
likeCount := c.likes
if s.liked {
likeCount++
}
// Animate the glyph's font size (re-rasterized each frame, so it stays crisp)
// rather than scaling a cached glyph bitmap, which would soften it.
like := widget.Interactive{
Gestures: widget.Gestures{OnTap: func() { s.SetState(func() { s.liked = !s.liked }) }},
Child: widget.Row(
widget.Sized{W: 30, Child: widget.Center(
widget.AnimateFloat(heartSize, 140*time.Millisecond, func(sz float32) widget.Widget {
return widget.Text{Value: "♥", Size: sz, Color: heartColor}
}))},
widget.Sized{W: 8},
widget.Text{Value: fmt.Sprintf("%d", likeCount), Size: th.Type.Body, Color: th.Text},
),
}
body := widget.Column(
theme.Button{Label: "← back", OnTap: func() { nav.Pop() }},
widget.Sized{H: 10},
widget.Text{Value: c.title, Font: theme.FontBold, Size: th.Type.Title, Color: th.Text, Wrap: true},
widget.Sized{H: 4},
widget.Text{Value: "by @" + c.author, Size: th.Type.Label, Color: th.Muted},
widget.Sized{H: 14},
widget.SelectableText{S: bodyFor(c), Size: th.Type.Body, Color: th.Text, Wrap: true, SelectionColor: th.Selection},
widget.Sized{H: 16},
like,
widget.Sized{H: 18},
widget.Text{Value: "COMMENTS", Font: theme.FontBold, Size: th.Type.Caption, Color: th.Muted},
)
body.CrossAlign = layout.CrossStart
page := widget.Column(
header,
widget.Padding{All: 16, Child: body},
widget.Expand(commentList(th, c)),
)
page.CrossAlign = layout.CrossStretch
return widget.Fill{Color: th.Bg, Child: page}
}
// commentList is a reverse (bottom-anchored) list: newest comment rests at the
// bottom, scroll up for older — the chat-log layout.
func commentList(th theme.Theme, c card) widget.Widget {
comments := commentsFor(c)
return widget.LazyList{
Count: len(comments),
EstimatedExtent: 52,
Reverse: true,
Build: func(i int) widget.Widget {
return widget.Padding{Insets: geom.Insets{Left: 16, Right: 16, Top: 5, Bottom: 5},
Child: widget.Decorated{Color: th.Surface, Radius: th.Radius, Child: widget.Padding{All: 12,
Child: widget.Text{Value: comments[i], Size: th.Type.Body, Color: th.Text, Wrap: true}}}}
},
}
}
func heroTag(id int) string { return fmt.Sprintf("swatch-%d", id) }
// swatch draws a real image clipped to a rounded rectangle — the card
// thumbnail and detail header, and the Hero that flies between them. W or H of 0
// fills the available space.
func swatch(img image.Image, w, h, radius float32) widget.Widget {
return widget.Canvas{W: w, H: h, Draw: func(c paint.Canvas, size geom.Size) {
r := geom.Rect{Max: size.Pt()}
if radius > 0 {
c.PushClipRRect(r, radius)
c.Image(img, r)
c.PopClip()
} else {
c.Image(img, r)
}
}}
}
ui/sections_overlays.go
package ui
import (
"math"
"time"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// --- Dialogs & menus ---------------------------------------------------------
// dialogsSection triggers the overlay helpers — a modal dialog and an anchored
// menu — and echoes the action they returned.
type dialogsSection struct{}
func (dialogsSection) CreateState() widget.State { return &dialogsState{} }
type dialogsState struct {
widget.StateBase[dialogsSection]
result string
}
func (s *dialogsState) set(r string) { s.SetState(func() { s.result = r }) }
func (s *dialogsState) showDialog(ctx widget.Ctx) {
th := theme.Of(ctx)
var dismiss func()
content := widget.Column(
widget.Text{Value: "Delete file?", Font: theme.FontBold, Size: th.Type.Heading, Color: th.Text},
widget.Sized{H: 8},
widget.Text{Value: "This can't be undone.", Size: th.Type.Body, Color: th.Muted, Wrap: true},
widget.Sized{H: 18},
widget.Row(
widget.Spacer(),
theme.Button{Label: "Cancel", OnTap: func() { dismiss(); s.set("Cancelled") }},
widget.Sized{W: 10},
theme.Button{Label: "Delete", Primary: true, OnTap: func() { dismiss(); s.set("Deleted") }},
),
)
content.CrossAlign = layout.CrossStart
dismiss = theme.ShowDialog(ctx, widget.Sized{W: 260, Child: content})
}
func (s *dialogsState) showMenu(ctx widget.Ctx) {
theme.ShowMenu(ctx, geom.Pt{X: 40, Y: 320}, []theme.MenuItem{
{Label: "Rename", OnTap: func() { s.set("Rename") }},
{Label: "Duplicate", OnTap: func() { s.set("Duplicate") }},
{Label: "Move to trash", OnTap: func() { s.set("Move to trash") }},
})
}
func (s *dialogsState) showSheet(ctx widget.Ctx) {
th := theme.Of(ctx)
var dismiss func()
content := widget.Column(
widget.Text{Value: "Share to…", Font: theme.FontBold, Size: th.Type.Heading, Color: th.Text},
widget.Sized{H: 8},
widget.Text{Value: "A rounded surface that slides up from the bottom edge — drag it down or tap the scrim to dismiss.",
Size: th.Type.Body, Color: th.Muted, Wrap: true},
widget.Sized{H: 18},
theme.Button{Label: "Done", Primary: true, OnTap: func() { dismiss(); s.set("Sheet closed") }},
)
content.CrossAlign = layout.CrossStart
dismiss = theme.ShowBottomSheet(ctx, content)
}
func (s *dialogsState) showSnackbar(ctx widget.Ctx) {
theme.ShowSnackbar(ctx, "Saved")
}
func (s *dialogsState) showSnackbarAction(ctx widget.Ctx) {
theme.ShowSnackbar(ctx, "Message archived",
theme.WithAction("Undo", func() { s.set("Undo archive") }))
}
func (s *dialogsState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
return sectionColumn(
groupLabel("Dialog & menu"),
theme.Body("A centered modal over a dimming scrim; tap the scrim or press Escape to dismiss."),
widget.Sized{H: 10},
widget.Wrap{Spacing: 10, RunSpacing: 10, Children: []widget.Widget{
theme.Button{Label: "Show dialog", Primary: true, OnTap: func() { s.showDialog(ctx) }},
theme.Button{Label: "Show menu", OnTap: func() { s.showMenu(ctx) }},
}},
groupLabel("Bottom sheet"),
theme.Body("A full-width surface that slides up from the bottom edge over a scrim."),
widget.Sized{H: 10},
widget.Wrap{Spacing: 10, RunSpacing: 10, Children: []widget.Widget{
theme.Button{Label: "Show sheet", OnTap: func() { s.showSheet(ctx) }},
}},
groupLabel("Snackbar"),
theme.Body("A transient, non-modal toast near the bottom — optionally with an action."),
widget.Sized{H: 10},
widget.Wrap{Spacing: 10, RunSpacing: 10, Children: []widget.Widget{
theme.Button{Label: "Show snackbar", OnTap: func() { s.showSnackbar(ctx) }},
theme.Button{Label: "Snackbar + Undo", OnTap: func() { s.showSnackbarAction(ctx) }},
}},
widget.Sized{H: 16},
theme.Card{Child: widget.Text{
Value: "Last action: " + orDash(s.result),
Size: th.Type.Body,
Color: th.Text,
}},
)
}
// --- Layout ------------------------------------------------------------------
// layoutSection demonstrates the core layout primitives with small live views.
type layoutSection struct{}
func (layoutSection) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
// Grid: nine equal-width color swatches in three columns.
gridCells := make([]widget.Widget, 9)
for i := range gridCells {
gridCells[i] = widget.Sized{H: 54, Child: widget.Decorated{
Color: th.ChartAt(i), Radius: th.Radius,
Child: widget.Center(widget.Text{Value: string(rune('1' + i)), Font: theme.FontBold, Size: th.Type.Body, Color: th.OnPrimary}),
}}
}
grid := widget.Grid{Columns: 3, Spacing: 8, Children: gridCells}
// Wrap: chips of varying widths that flow onto new runs.
chipWords := []string{"design", "tokens", "themeable", "warm", "behavior-native", "no CSS", "one system"}
chips := make([]widget.Widget, len(chipWords))
for i, w := range chipWords {
chips[i] = chip(th, w)
}
wrap := widget.Wrap{Spacing: 8, RunSpacing: 8, Children: chips}
// Stack: layered content — a surface, a centered badge, a corner tag.
stack := widget.Stack{Children: []widget.Widget{
widget.Sized{H: 110, Child: widget.Decorated{Color: th.SurfaceHover, Radius: th.Radius, Child: widget.Fill{}}},
widget.Sized{H: 110, Child: widget.Center(widget.Decorated{Color: th.Primary, Radius: 24,
Child: widget.Padding{Insets: geom.InsetsSymmetric(16, 10),
Child: widget.Text{Value: "centered", Font: theme.FontBold, Size: th.Type.Body, Color: th.OnPrimary}}})},
widget.Sized{H: 110, Child: widget.Align{X: 1, Y: 0, Child: widget.Padding{All: 8,
Child: chip(th, "top-right")}}},
}}
// AspectRatio: a 16:9 box that keeps its ratio at any width.
aspect := widget.AspectRatio{Ratio: 16.0 / 9.0, Child: widget.Decorated{Radius: th.Radius,
Child: widget.Canvas{Draw: func(c paint.Canvas, size geom.Size) {
c.FillRRectGradient(geom.Rect{Max: size.Pt()}, th.Radius, th.ChartAt(2), th.ChartAt(3), true)
}},
}}
return sectionColumn(
groupLabel("Grid · 3 columns"),
grid,
groupLabel("Wrap"),
wrap,
groupLabel("Stack"),
stack,
groupLabel("AspectRatio · 16:9"),
aspect,
)
}
func chip(th theme.Theme, s string) widget.Widget {
return widget.Decorated{Color: th.Surface, Radius: 20, BorderColor: th.Border, BorderWidth: 1,
Child: widget.Padding{Insets: geom.InsetsSymmetric(12, 7),
Child: widget.Text{Value: s, Size: th.Type.Label, Color: th.Text}}}
}
// --- Animations --------------------------------------------------------------
// animationsSection triggers the implicit-animation family on tap: a tweened
// color, a growing bar, a scale pop, and a rotation.
type animationsSection struct{}
func (animationsSection) CreateState() widget.State { return &animationsState{} }
type animationsState struct {
widget.StateBase[animationsSection]
colorOn bool
wide bool
big bool
angle float32
}
func (s *animationsState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
// AnimateColor: cross-fade a surface between two palette colors.
col := th.ChartAt(2)
if s.colorOn {
col = th.ChartAt(0)
}
colorTile := widget.AnimateColor(col, 250*time.Millisecond, func(c paint.Color) widget.Widget {
return animTile(th, c, "AnimateColor")
})
// AnimateFloat: grow a bar's width between two fractions within a track.
const track = 280
frac := float32(0.3)
if s.wide {
frac = 1.0
}
bar := widget.AnimateFloat(frac, 300*time.Millisecond, func(f float32) widget.Widget {
return widget.Row(widget.Sized{W: f * track, H: 18, Child: widget.Decorated{Color: th.Primary, Radius: 9, Child: widget.Fill{}}})
})
barTrack := widget.Decorated{Color: th.Surface, Radius: 9, BorderColor: th.Border, BorderWidth: 1,
Child: widget.Sized{W: track, H: 18, Child: widget.Align{X: 0, Y: 0.5, Child: bar}}}
// AnimatedScale: pop a badge on tap.
scale := float32(1)
if s.big {
scale = 1.4
}
pop := widget.AnimatedScale(scale, 200*time.Millisecond, animTile(th, th.ChartAt(4), "Scale"))
// AnimatedRotation: spin a square by a quarter turn each tap.
spin := widget.AnimatedRotation(s.angle, 300*time.Millisecond, animTile(th, th.ChartAt(5), "Rotation"))
tap := func(child widget.Widget, onTap func()) widget.Widget {
return widget.Interactive{Gestures: widget.Gestures{OnTap: onTap}, Child: child}
}
return sectionColumn(
theme.Body("Set a new value and it tweens from wherever it is — no controller to manage. Tap each tile."),
groupLabel("AnimateColor"),
tap(colorTile, func() { s.SetState(func() { s.colorOn = !s.colorOn }) }),
groupLabel("AnimateFloat"),
tap(barTrack, func() { s.SetState(func() { s.wide = !s.wide }) }),
groupLabel("AnimatedScale"),
widget.Row(tap(pop, func() { s.SetState(func() { s.big = !s.big }) })),
groupLabel("AnimatedRotation"),
widget.Row(tap(spin, func() { s.SetState(func() { s.angle += math.Pi / 2 }) })),
)
}
// animTile is a small labeled square used by the animation demos.
func animTile(th theme.Theme, col paint.Color, label string) widget.Widget {
return widget.Decorated{Color: col, Radius: th.Radius, Child: widget.Sized{W: 96, H: 96,
Child: widget.Center(widget.Text{Value: label, Font: theme.FontBold, Size: th.Type.Label, Color: th.OnPrimary})}}
}
ui/sections_selection.go
package ui
import (
"fmt"
"time"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// --- Selection ---------------------------------------------------------------
// selectionSection binds the single-choice controls — Dropdown, Segmented, and
// Tabs — to live state, echoing the current choice (and, for Tabs, swapping the
// panel content) so each one visibly drives something.
type selectionSection struct{}
func (selectionSection) CreateState() widget.State { return &selectionState{} }
type selectionState struct {
widget.StateBase[selectionSection]
fruit int // Dropdown selection; -1 shows the placeholder
density int // Segmented selection
tab int // Tabs selection
}
var (
fruitNames = []string{"Apple", "Blueberry", "Clementine", "Date", "Elderberry"}
densityNames = []string{"Compact", "Cozy", "Roomy"}
tabNames = []string{"Overview", "Specs", "Reviews"}
)
func (s *selectionState) Init(widget.Ctx) {
s.fruit = -1 // start on the placeholder
s.density = 1
}
func (s *selectionState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
fruitEcho := "—"
if s.fruit >= 0 && s.fruit < len(fruitNames) {
fruitEcho = fruitNames[s.fruit]
}
// Tabs drive a small piece of swapped content.
var panel widget.Widget
switch s.tab {
case 0:
panel = theme.Body("Overview — the shape of the thing at a glance.")
case 1:
panel = theme.Body("Specs — the numbers and the fine detail.")
default:
panel = theme.Body("Reviews — what people had to say about it.")
}
return sectionColumn(
groupLabel("Dropdown"),
theme.Dropdown{
Options: fruitNames,
Selected: s.fruit,
Placeholder: "Pick a fruit…",
OnChange: func(i int) { s.SetState(func() { s.fruit = i }) },
},
widget.Sized{H: 8},
widget.Text{Value: "Selected: " + fruitEcho, Size: th.Type.Label, Color: th.Muted},
groupLabel("Segmented"),
theme.Segmented{
Options: densityNames,
Selected: s.density,
OnChange: func(i int) { s.SetState(func() { s.density = i }) },
},
widget.Sized{H: 8},
widget.Text{Value: "Density: " + densityNames[s.density], Size: th.Type.Label, Color: th.Muted},
groupLabel("Tabs"),
theme.Tabs{
Tabs: tabNames,
Selected: s.tab,
OnChange: func(i int) { s.SetState(func() { s.tab = i }) },
},
widget.Sized{H: 12},
theme.Card{Child: panel},
)
}
// --- Pickers -----------------------------------------------------------------
// pickersSection triggers the date and time picker dialogs and echoes the
// picked value.
type pickersSection struct{}
func (pickersSection) CreateState() widget.State { return &pickersState{} }
type pickersState struct {
widget.StateBase[pickersSection]
date time.Time
hasDate bool
hour, min int
hasTime bool
}
func (s *pickersState) Init(widget.Ctx) {
s.hour, s.min = 9, 30
}
func (s *pickersState) showDate(ctx widget.Ctx) {
initial := s.date
if !s.hasDate {
initial = time.Now()
}
theme.ShowDatePicker(ctx, initial, func(t time.Time) {
s.SetState(func() { s.date, s.hasDate = t, true })
})
}
func (s *pickersState) showTime(ctx widget.Ctx) {
theme.ShowTimePicker(ctx, s.hour, s.min, func(hour, min int) {
s.SetState(func() { s.hour, s.min, s.hasTime = hour, min, true })
})
}
func (s *pickersState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
dateEcho := "—"
if s.hasDate {
dateEcho = s.date.Format("Mon, Jan 2 2006")
}
timeEcho := "—"
if s.hasTime {
timeEcho = fmt.Sprintf("%02d:%02d", s.hour, s.min)
}
return sectionColumn(
groupLabel("Date"),
theme.Body("Opens a month calendar in a dialog; pick a day and it echoes below."),
widget.Sized{H: 10},
widget.Wrap{Spacing: 10, RunSpacing: 10, Children: []widget.Widget{
theme.Button{Label: "Pick a date", Primary: true, OnTap: func() { s.showDate(ctx) }},
}},
widget.Sized{H: 12},
theme.Card{Child: widget.Text{Value: "Date: " + dateEcho, Size: th.Type.Body, Color: th.Text}},
groupLabel("Time"),
theme.Body("Opens an hour/minute stepper in a dialog; every step reports the new time."),
widget.Sized{H: 10},
widget.Wrap{Spacing: 10, RunSpacing: 10, Children: []widget.Widget{
theme.Button{Label: "Pick a time", OnTap: func() { s.showTime(ctx) }},
}},
widget.Sized{H: 12},
theme.Card{Child: widget.Text{Value: "Time: " + timeEcho, Size: th.Type.Body, Color: th.Text}},
)
}