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{})},
{"Icons", "Every theme.Icon glyph, named",
sp("Icons", "The built-in glyph set at three sizes", iconsSection{})},
{"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{})},
{"Text selection", "Select, copy and paste the way the platform does",
sp("Text selection", "One SelectionArea over three blocks of prose", textSelectionSection{})},
{"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{})},
{"Custom painting", "Gradients, blend modes, measured text",
sp("Custom painting", "Drawing straight onto a paint.Canvas", paintSection{})},
{"Colour emoji", "Bitmap glyphs, and an honest answer without them",
sp("Colour emoji", "Asking the canvas before drawing an emoji", emojiSection{})},
{"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,
// The root seeds its mode from the platform scheme, so the window
// ground has to follow it too, or a dark first frame and every resize
// gap paint light behind dark-theme text.
BackgroundDark: theme.Dark().Bg,
Font: goregular.TTF,
FontFamilies: map[string][]byte{"bold": gobold.TTF},
// For the colour-emoji section, which is the one thing in the catalog
// the bundled Go faces cannot draw at all: they are Latin outlines and
// an emoji needs the machine's bitmap emoji face. A no-op on the web
// and mobile builds, where the section says so instead.
SystemFonts: true,
}
}
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(
// theme.Icon, not a 🔔: the bundled Go faces are Latin and
// have no glyph for U+1F514, so the emoji drew as a box on
// every platform. The stroked glyph needs no font at all.
theme.Icon{Glyph: theme.IconBell, Size: 24, Color: th.Primary},
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
secret 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("Obscured"),
theme.Field{
Value: s.secret,
Placeholder: "Password",
Obscure: true,
OnChange: func(v string) { s.SetState(func() { s.secret = v }) },
},
widget.Sized{H: 4},
// Length only: the point of the field is that the value stays in it.
widget.Text{Value: fmt.Sprintf("%d characters, none of them copyable", len([]rune(s.secret))), 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_emoji.go
package ui
import (
"runtime"
"strings"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// --- Colour emoji ------------------------------------------------------------
// emojiRow is the sample sheet. Every glyph here is emoji-presentation by
// default, so none of them can quietly render as a monochrome outline and make
// the sheet look like a font bug when it is really a Unicode default.
var emojiRow = []string{"🚀", "🎉", "🌍", "🐙", "🍜", "🎸", "🧭", "🐢"}
const (
emojiBig = 38
emojiInline = 18
// emojiStrike is drawn at every size in emojiStrikeSizes. One glyph rather
// than four different ones, because the row is about the font's bitmap
// strikes and not about the glyphs: the same picture at four sizes shows
// the painter picking a strike, where four pictures would just be a row.
emojiStrike = "🐙"
)
var emojiStrikeSizes = []float32{14, 20, 28, 40}
// emojiSheetHeight is fixed because a Canvas needs its height before Draw runs,
// and the fallback has to occupy the same box as the sheet it replaces — a
// section that changed height depending on the machine's fonts would reflow the
// whole page under the reader.
const emojiSheetHeight = 200
// emojiSection draws colour emoji if this build can, and says why not if it
// cannot.
//
// It exists because "nil is a real answer" is the framework's central idea and
// this is the one place a reader can watch it decide something visible. Emoji
// support is not a property of gophics but of the font chain underneath it:
// present on macOS and iOS (sbix) and Android (CBDT), absent on a stock Windows
// build whose system emoji font is COLR, and absent in a browser, which will
// not hand a WebAssembly page the bytes of an installed font. So the section
// asks paint.Emoji before it draws, and shows the honest answer either way
// rather than a row of tofu boxes.
type emojiSection struct{}
func (emojiSection) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
return sectionColumn(
groupLabel("Bitmap colour glyphs"),
theme.Body("A colour emoji is a picture, not an outline, so it is blitted "+
"from the font's bitmap table instead of filled as a path. gophics "+
"draws the two bitmap formats — sbix, which Apple Color Emoji uses, "+
"and CBDT, which Android's Noto Color Emoji uses."),
widget.Sized{H: 8},
theme.Body("It does not draw COLR, which is a paint graph of layers and "+
"gradients rather than pixels. That is the whole of the difference "+
"between a platform where this section shows emoji and one where it "+
"explains itself: Windows' Segoe UI Emoji and recent Noto are COLR."),
widget.Sized{H: 10},
widget.Canvas{H: emojiSheetHeight, Clip: true, Draw: func(c paint.Canvas, size geom.Size) {
drawEmojiSheet(c, size, th)
}},
groupLabel("Asking before drawing"),
theme.Body("paint.Emoji(c) is an opt-in capability, like paint.Blends: it "+
"reports whether a sample will reach a colour glyph on this canvas's "+
"font chain. Measuring cannot answer that — a missing glyph has ink "+
"too, which is exactly what a tofu box is — so a widget that wants to "+
"fall back to words has to ask."),
widget.Sized{H: 8},
theme.Body("This section is built on that answer. The app sets "+
"Config.SystemFonts so the chain can reach the machine's emoji face; "+
"on the web the equivalent is Config.WebFonts, which fetches a face "+
"per script on demand."),
widget.Sized{H: 4},
)
}
// drawEmojiSheet draws three rows — a sheet, an inline run, and one glyph at
// four sizes — or the fallback, in the same box.
func drawEmojiSheet(c paint.Canvas, size geom.Size, th theme.Theme) {
sheet := strings.Join(emojiRow, " ")
e := paint.Emoji(c)
if e == nil || !e.HasColorEmoji("", sheet, emojiBig) {
drawNoEmojiFace(c, size, th)
return
}
// One TextIn for the whole row rather than eight: the row is a single
// shaped run, so it also shows the emoji advancing like glyphs and the
// spaces between them advancing like spaces.
centeredRun(c, sheet, emojiBig, 48, size.W, th.Text)
centeredLabel(c, "one run, eight glyphs", geom.Pt{X: size.W / 2, Y: 66}, size.W, th)
centeredRun(c, "an emoji 🐙 sits on the baseline", emojiInline, 104, size.W, th.Text)
centeredLabel(c, "mixed run: emoji and outlines, one baseline",
geom.Pt{X: size.W / 2, Y: 122}, size.W, th)
// Sizes share a baseline so the strike changes are comparable: the font
// stores several bitmap strikes and the painter decodes the one nearest the
// device pixel size, which is why these stay sharp instead of blurring up
// from one.
const strikeGap = 8
w := -float32(strikeGap) // no gap after the last one
for _, s := range emojiStrikeSizes {
w += c.MeasureWidthIn("", emojiStrike, s) + strikeGap
}
x := (size.W - w) / 2
if x < 0 {
x = 0
}
for _, s := range emojiStrikeSizes {
c.TextIn("", emojiStrike, geom.Pt{X: x, Y: 172}, s, th.Text)
x += c.MeasureWidthIn("", emojiStrike, s) + strikeGap
}
centeredLabel(c, "one glyph, four sizes, four strikes",
geom.Pt{X: size.W / 2, Y: 190}, size.W, th)
}
// drawNoEmojiFace fills the sheet's box with the reason there is nothing to
// show. The reason is specific to the platform because "no emoji" has three
// different causes and only one of them is something the reader could fix.
func drawNoEmojiFace(c paint.Canvas, size geom.Size, th theme.Theme) {
reason := "No colour-bitmap emoji face in the fallback chain."
fix := "Register one with Config.Fallbacks, or set Config.SystemFonts."
switch runtime.GOOS {
case "js":
reason = "A browser will not give a WebAssembly page an installed font."
fix = "Fetch a face per script with Config.WebFonts."
case "windows":
reason = "Segoe UI Emoji is a COLR font, which this build does not rasterize."
fix = "Register a bitmap or monochrome emoji face with Config.Fallbacks."
}
y := size.H / 2
centeredRun(c, reason, 13, y-6, size.W, th.Text)
centeredRun(c, fix, 13, y+14, size.W, th.Muted)
centeredLabel(c, "paint.Emoji reported no colour face — so this is words, not tofu",
geom.Pt{X: size.W / 2, Y: y + 42}, size.W, th)
}
// centeredRun draws s centred horizontally with its baseline at y. It differs
// from centeredLabel in taking a size and colour rather than being the small
// muted caption, and in not left-aligning when it overflows: these rows are
// drawn inside a clip, so a long line is better cropped evenly on both sides
// than pushed off one edge.
func centeredRun(c paint.Canvas, s string, size, y, maxW float32, col paint.Color) {
w := c.MeasureWidthIn("", s, size)
c.TextIn("", s, geom.Pt{X: (maxW - w) / 2, Y: y}, size, col)
}
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_icons.go
package ui
import (
"github.com/doug/gophics/geom"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// --- Icons -------------------------------------------------------------------
// iconGlyphs is every theme.Icon glyph with its name, in constant order. It is
// a list rather than a loop to the last constant because the sheet exists to
// be read: a glyph that nobody named is a glyph nobody will find.
var iconGlyphs = []struct {
glyph theme.IconGlyph
name string
}{
{theme.IconHome, "Home"}, {theme.IconList, "List"}, {theme.IconSearch, "Search"},
{theme.IconSliders, "Sliders"}, {theme.IconChevronLeft, "ChevronLeft"},
{theme.IconChevronRight, "ChevronRight"}, {theme.IconChevronUp, "ChevronUp"},
{theme.IconChevronDown, "ChevronDown"}, {theme.IconPlus, "Plus"}, {theme.IconClose, "Close"},
{theme.IconCheck, "Check"}, {theme.IconMenu, "Menu"}, {theme.IconCalendar, "Calendar"},
{theme.IconChart, "Chart"}, {theme.IconBell, "Bell"}, {theme.IconPeople, "People"},
{theme.IconPerson, "Person"}, {theme.IconPuzzle, "Puzzle"}, {theme.IconGrip, "Grip"},
{theme.IconReply, "Reply"}, {theme.IconCheckCircle, "CheckCircle"}, {theme.IconTrash, "Trash"},
{theme.IconShare, "Share"}, {theme.IconCopy, "Copy"},
}
// iconsSection is the glyph sheet: every Icon at three sizes, named, so a
// shape that lost weight at small sizes or drifted off its grid is seen here
// before it is seen in a nav bar.
type iconsSection struct{}
func (iconsSection) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
tiles := make([]widget.Widget, 0, len(iconGlyphs))
for _, g := range iconGlyphs {
col := widget.Column(
theme.Icon{Glyph: g.glyph, Size: 28},
widget.Sized{H: 6},
widget.Text{Value: g.name, Size: th.Type.Caption, Color: th.Muted},
)
col.CrossAlign = layout.CrossCenter
tiles = append(tiles, widget.Sized{W: 88, Child: col})
}
// The same glyphs at text size and in the accent colour: an icon spends
// most of its life beside a label, so that is the pairing to judge.
inline := make([]widget.Widget, 0, len(iconGlyphs))
for _, g := range iconGlyphs {
inline = append(inline, theme.Icon{Glyph: g.glyph, Color: th.Primary})
}
// And twice the grid: a stroke that drifted off it, or a corner that is
// not quite where its neighbour's is, shows here before anywhere else.
large := make([]widget.Widget, 0, len(iconGlyphs))
for _, g := range iconGlyphs {
large = append(large, theme.Icon{Glyph: g.glyph, Size: 48, Color: th.Muted})
}
return sectionColumn(
groupLabel("Every glyph"),
theme.Body("Stroked paths on a 24×24 grid — no icon font to go missing."),
widget.Sized{H: 8},
widget.Wrap{Spacing: 4, RunSpacing: 16, Children: tiles},
groupLabel("At body size"),
widget.Padding{Insets: geom.InsetsSymmetric(0, 4), Child: widget.Wrap{Spacing: 10, RunSpacing: 10, Children: inline}},
groupLabel("At 48px"),
widget.Padding{Insets: geom.InsetsSymmetric(0, 4), Child: widget.Wrap{Spacing: 8, RunSpacing: 8, Children: large}},
)
}
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{} }
// feedState holds the rows only. Pull-to-refresh and swipe-to-dismiss have
// their own sections (sections_gestures.go); this page is about navigation.
type feedState struct {
widget.StateBase[feedPage]
cards []card
}
// 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) 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
// menuOrigin is the "Show menu" button's top-left in window coordinates,
// captured on press. There is no widget-origin API, and none is needed: a
// press reports its position in the button's own coordinates and Input
// reports the same point globally, so the difference is the origin. It
// is what lets the menu hang from the button rather than from a window
// point that was only near the button at one size and scroll position.
menuOrigin geom.Pt
}
// menuButtonH fixes the trigger's height so the menu can open just under it,
// the way theme.Dropdown opens under its own fixed-height control.
const menuButtonH = 40
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) {
anchor := geom.Pt{X: s.menuOrigin.X, Y: s.menuOrigin.Y + menuButtonH}
theme.ShowMenu(ctx, anchor, []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") }},
})
}
// menuButton is the menu's trigger: a Button inside an Interactive that only
// listens for the press, to learn where the button is. OnPress reaches every
// handler under the pointer, so the wrapper sees it without taking the tap
// from the Button.
func (s *dialogsState) menuButton(ctx widget.Ctx) widget.Widget {
return widget.Interactive{
Gestures: widget.Gestures{OnPress: func(local geom.Pt) {
p := ctx.Input().Pointer()
s.menuOrigin = geom.Pt{X: p.X - local.X, Y: p.Y - local.Y}
}},
Child: widget.Sized{H: menuButtonH, Child: widget.Align{X: 0, Y: 0.5,
Child: theme.Button{Label: "Show menu", OnTap: func() { s.showMenu(ctx) }}}},
}
}
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. The menu hangs from the button that raised it."),
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) }},
s.menuButton(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_paint.go
package ui
import (
"github.com/doug/gophics/geom"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// --- Paint: gradients, blend modes, measured text ----------------------------
// blendShown is the subset of paint.BlendMode the sheet draws, in the order the
// enum groups them: separable, non-separable, then Porter-Duff. It is a list
// rather than a loop to numBlendModes for the reason the icon sheet is one —
// the sheet exists to be compared, and thirty tiles is a wall, not a
// comparison. These are the modes an app actually reaches for.
var blendShown = []paint.BlendMode{
paint.BlendNormal,
paint.BlendMultiply,
paint.BlendScreen,
paint.BlendOverlay,
paint.BlendDarken,
paint.BlendLighten,
paint.BlendColorDodge,
paint.BlendSoftLight,
paint.BlendDifference,
paint.BlendExclusion,
paint.BlendHue,
paint.BlendColor,
paint.BlendLuminosity,
paint.BlendPlus,
paint.BlendDstOut,
paint.BlendSrcAtop,
}
// paintSection is the custom-drawing sheet: the three things a widget can do
// on a paint.Canvas that no higher-level widget wraps for it.
//
// It is one section rather than three because they are one question — "what can
// I draw in here" — and because a blend mode needs something underneath it to
// blend against, which the gradient above it supplies.
type paintSection struct{}
func (paintSection) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
return sectionColumn(
groupLabel("Gradients"),
theme.Body("Multi-stop linear, radial and sweep ramps. Drawn through "+
"paint.FillGradient, which uses the canvas's own gradient support "+
"where it has any and approximates the ramp in bands where it does "+
"not — so the same call works on every backend."),
widget.Sized{H: 10},
widget.Canvas{H: 132, Clip: true, Draw: func(c paint.Canvas, size geom.Size) {
drawGradientTriptych(c, size, th)
}},
groupLabel("Blend modes"),
theme.Body("Each tile paints the same two swatches over the same ramp, "+
"changing only the mode. Blending is an opt-in capability: it needs a "+
"destination to read, so unlike drawing it cannot be approximated, and "+
"paint.Blends returns nil on a canvas that cannot do it."),
widget.Sized{H: 10},
widget.Canvas{H: blendSheetHeight, Clip: true, Draw: func(c paint.Canvas, size geom.Size) {
drawBlendSheet(c, size, th)
}},
groupLabel("Measured text"),
theme.Body("A canvas measures the text it is about to draw, from the same "+
"Painter that will shape it — so the box always matches the glyphs. "+
"The outer rule is the advance box (ascent, descent and advance width); "+
"the inner one is where the run actually puts ink."),
widget.Sized{H: 10},
widget.Canvas{H: 112, Clip: true, Draw: func(c paint.Canvas, size geom.Size) {
drawMeasuredText(c, size, th)
}},
widget.Sized{H: 4},
)
}
// drawGradientTriptych draws the three gradient kinds side by side, each
// labelled. The panels are sized from the surface rather than fixed, so the
// sheet works at phone width and in a wide window alike.
func drawGradientTriptych(c paint.Canvas, size geom.Size, th theme.Theme) {
const gap, labelH = 8, 18
panelW := (size.W - 2*gap) / 3
panelH := size.H - labelH
if panelW <= 0 || panelH <= 0 {
return
}
// Stops come from the theme's chart palette, so a re-theme restyles the
// ramps along with everything else on the page.
stops := paint.EvenStops(th.Chart[0], th.Chart[2], th.Chart[4])
for i, name := range [...]string{"Linear", "Radial", "Sweep"} {
x := float32(i) * (panelW + gap)
r := geom.Rect{Min: geom.Pt{X: x}, Max: geom.Pt{X: x + panelW, Y: panelH}}
mid := geom.Pt{X: x + panelW/2, Y: panelH / 2}
var g *paint.Gradient
switch i {
case 0:
g = paint.LinearGradient(r.Min, geom.Pt{X: r.Max.X, Y: r.Max.Y}, stops...)
case 1:
g = paint.RadialGradient(mid, panelW/2, stops...)
default:
// A full turn, so the seam where the last stop meets the first is
// visible — it is the thing to get right in a sweep.
g = paint.SweepGradient(mid, 0, 2*3.14159265, stops...)
}
paint.FillGradient(c, r, 10, g)
centeredLabel(c, name, geom.Pt{X: x + panelW/2, Y: panelH + 13}, panelW, th)
}
}
// blendSheetHeight is four rows of tiles plus their labels. It is a constant
// because the Canvas needs a height before Draw runs, and a sheet that sized
// itself from the tile count would have to be laid out twice.
const blendSheetHeight = 4 * (blendTileH + blendLabelH)
const (
blendTileH = 56
blendLabelH = 16
blendTileGap = 6
)
// drawBlendSheet draws one tile per mode in blendShown: a gradient backdrop,
// then two overlapping swatches painted inside a PushBlend/PopBlend pair.
//
// The swatches overlap each other as well as the ramp, which is deliberate —
// the mode applies per shape rather than to the group, so the overlap is where
// that shows.
func drawBlendSheet(c paint.Canvas, size geom.Size, th theme.Theme) {
const cols = 4
tileW := (size.W - (cols-1)*blendTileGap) / cols
if tileW <= 0 {
return
}
b := paint.Blends(c)
backdrop := paint.EvenStops(th.Chart[1], th.Chart[3])
for i, m := range blendShown {
col, row := i%cols, i/cols
x := float32(col) * (tileW + blendTileGap)
y := float32(row) * (blendTileH + blendLabelH)
tile := geom.Rect{Min: geom.Pt{X: x, Y: y}, Max: geom.Pt{X: x + tileW, Y: y + blendTileH}}
paint.FillGradient(c, tile, 8, paint.LinearGradient(
tile.Min, geom.Pt{X: tile.Max.X, Y: tile.Max.Y}, backdrop...))
// Clipped to the tile so a mode that spreads (Plus, DstOut) cannot
// paint over its neighbour and make the sheet unreadable.
c.PushClipRRect(tile, 8)
if b != nil {
b.PushBlend(m)
}
var d float32 = blendTileH * 0.46
c.FillRRect(geom.Rect{
Min: geom.Pt{X: tile.Min.X + tileW*0.5 - d, Y: tile.Min.Y + blendTileH*0.5 - d*0.82},
Max: geom.Pt{X: tile.Min.X + tileW*0.5 + d*0.1, Y: tile.Min.Y + blendTileH*0.5 + d*0.82},
}, d, th.Chart[5])
c.FillRRect(geom.Rect{
Min: geom.Pt{X: tile.Min.X + tileW*0.5 - d*0.1, Y: tile.Min.Y + blendTileH*0.5 - d*0.82},
Max: geom.Pt{X: tile.Min.X + tileW*0.5 + d, Y: tile.Min.Y + blendTileH*0.5 + d*0.82},
}, d, th.OnPrimary)
if b != nil {
b.PopBlend()
}
c.PopClip()
// String() names the mode, so the sheet cannot drift out of step with
// the enum the way a parallel table of names would.
centeredLabel(c, m.String(), geom.Pt{X: x + tileW/2, Y: tile.Max.Y + 12}, tileW, th)
}
}
// drawMeasuredText draws a run with its advance box and its ink box, which is
// the whole point of canvas measurement: the two are different, and a custom
// painter that assumes otherwise clips its own descenders.
func drawMeasuredText(c paint.Canvas, size geom.Size, th theme.Theme) {
const (
sample = "Hamburgefonstiv jpq"
fsize = 30
)
m := c.MetricsIn("", fsize)
w := c.MeasureWidthIn("", sample, fsize)
// Centred using the measurement, which is also the simplest demonstration
// of why a painter wants it: there is no other way to place a run you have
// not drawn yet.
origin := geom.Pt{X: (size.W - w) / 2, Y: m.Ascent + 26}
if origin.X < 0 {
origin.X = 0
}
advance := geom.Rect{
Min: geom.Pt{X: origin.X, Y: origin.Y - m.Ascent},
Max: geom.Pt{X: origin.X + w, Y: origin.Y + m.Descent},
}
c.StrokeRRect(advance, 2, 1, th.Border)
if ink, ok := c.InkBoundsIn("", sample, fsize); ok {
c.StrokeRRect(geom.Rect{
Min: geom.Pt{X: origin.X + ink.Min.X, Y: origin.Y + ink.Min.Y},
Max: geom.Pt{X: origin.X + ink.Max.X, Y: origin.Y + ink.Max.Y},
}, 0, 1, th.Primary)
}
// The baseline, so the two boxes can be read against the thing they are
// both measured from.
c.Line(geom.Pt{X: advance.Min.X, Y: origin.Y}, geom.Pt{X: advance.Max.X, Y: origin.Y}, 1, th.Muted)
c.TextIn("", sample, origin, fsize, th.Text)
centeredLabel(c, "advance box · ink box · baseline",
geom.Pt{X: size.W / 2, Y: advance.Max.Y + 22}, size.W, th)
}
// centeredLabel draws s centred on x, measuring it to find the left edge. It
// exists because every sheet above needs it, and because centring text on a
// canvas is exactly the case Measurer is for.
func centeredLabel(c paint.Canvas, s string, at geom.Pt, maxW float32, th theme.Theme) {
const fsize = 10
w := c.MeasureWidthIn("", s, fsize)
if w > maxW {
// Too long to centre honestly: left-align rather than bleed into the
// neighbouring tile.
c.TextIn("", s, geom.Pt{X: at.X - maxW/2, Y: at.Y}, fsize, th.Muted)
return
}
c.TextIn("", s, geom.Pt{X: at.X - w/2, Y: at.Y}, fsize, th.Muted)
}
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}},
)
}
ui/sections_textselection.go
package ui
import (
"fmt"
"strings"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// textSelectionSection is the one place in the catalog that answers "can I
// select this text and copy it?" — the question a reader asks of any app,
// usually by trying it rather than by reading about it.
//
// So the demo is the text itself: a real passage worth lifting a quote out of,
// three separate widgets (heading, body, attribution) inside one SelectionArea
// so a drag that crosses them selects one continuous range the way a browser
// does. Everything else on the page exists to make the result visible — a live
// readout of what is selected, and a field to paste into, because a copy you
// cannot paste is not a demonstration of copying.
type textSelectionSection struct{}
func (textSelectionSection) CreateState() widget.State { return &textSelectionDemo{} }
type textSelectionDemo struct {
widget.StateBase[textSelectionSection]
selected string
pasted string
}
// The passage is deliberately a few lines of ordinary prose with punctuation
// and a proper noun in it: the things word selection gets wrong are hyphens,
// full stops and capitals, and a demo made of "lorem ipsum" would hide them.
const (
selTitle = "On the pleasure of selecting text"
selBody = "Selection is the quietest part of an interface, and the first " +
"thing a reader reaches for. It should take a word on a double click, " +
"a paragraph on a third, and give the whole thing up to Select All — " +
"without ever leaving half a word behind while the pointer moves."
selAttrib = "— the gophics gallery, 2026"
)
func (s *textSelectionDemo) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
cmd := "Ctrl"
if ctx.MacKeys() {
cmd = "Cmd"
}
passage := widget.Column(
widget.Text{Value: selTitle, Font: theme.FontBold, Size: th.Type.Title, Color: th.Text, Wrap: true},
widget.Sized{H: 8},
widget.Text{Value: selBody, Size: th.Type.Body, Color: th.Text, Wrap: true},
widget.Sized{H: 8},
widget.Text{Value: selAttrib, Size: th.Type.Caption, Color: th.Muted, Wrap: true},
)
passage.CrossAlign = layout.CrossStart
return sectionColumn(
groupLabel("Drag across any of it — the three blocks select as one"),
// The SelectionArea wraps the padding, not the other way round, and the
// card contributes none of its own (Pad: 0): a drag that starts in the
// margin should select the nearest line, as it does in a browser, and
// an area drawn tightly around the glyphs would never see that press.
theme.Card{Pad: 0, Child: widget.SelectionArea{
Child: widget.Padding{
Insets: geom.Insets{Top: 14, Bottom: 14, Left: 14, Right: 14},
Child: passage,
},
OnSelect: func(t string) { s.SetState(func() { s.selected = t }) },
}},
groupLabel("What is selected, as you select it"),
widget.Text{Value: selectionReadout(s.selected), Size: th.Type.Caption, Color: th.Muted, Wrap: true},
groupLabel("Copy it, then paste it back"),
theme.Field{
Value: s.pasted,
Placeholder: "Paste here (" + cmd + "+V, or long-press → Paste)",
OnChange: func(v string) { s.SetState(func() { s.pasted = v }) },
},
groupLabel("What to try"),
hintList(th, []string{
"Double-click a word, then drag — it keeps selecting whole words.",
"Triple-click takes the whole paragraph.",
"Click once, then shift-click further along to extend.",
cmd + "+A selects all three blocks; " + cmd + "+C copies.",
"Right-click without a selection takes the word under the pointer.",
"On a phone: long-press a word, drag the grips to adjust, then Copy.",
}),
)
}
// selectionReadout describes the selection in the terms a reader cares about —
// how much, and the text itself, elided in the middle so both ends stay
// visible. The ends are what tell you whether the selection stopped where you
// meant it to.
func selectionReadout(sel string) string {
if sel == "" {
return "Nothing selected yet."
}
n := len([]rune(sel))
shown := strings.ReplaceAll(sel, "\n", " ¶ ")
if r := []rune(shown); len(r) > 72 {
shown = string(r[:34]) + " … " + string(r[len(r)-34:])
}
unit := "characters"
if n == 1 {
unit = "character"
}
return fmt.Sprintf("%d %s selected: %q", n, unit, shown)
}
// hintList renders the per-platform notes as a plain bulleted column. They are
// inside no SelectionArea on purpose: the one above should be the only text on
// the page that highlights, so it is obvious which part is the demo.
func hintList(th theme.Theme, lines []string) widget.Widget {
kids := make([]widget.Widget, 0, len(lines)*2)
for i, l := range lines {
if i > 0 {
kids = append(kids, widget.Sized{H: 4})
}
row := widget.Row(
widget.Text{Value: "· ", Size: th.Type.Caption, Color: th.Muted},
widget.Flexible{Child: widget.Text{Value: l, Size: th.Type.Caption, Color: th.Muted, Wrap: true}},
)
row.CrossAlign = layout.CrossStart
kids = append(kids, row)
}
col := widget.Column(kids...)
col.CrossAlign = layout.CrossStart
return col
}