main.go
// Command capabilities is a live inspector for the platform-capability layer
// (shell/*.go via ctx.<Cap>()): every capability the running platform exposes,
// shown working. A capability the platform cannot provide renders greyed-out
// "unsupported", so the same screen honestly reports what each target actually
// has.
//
// gophics run # desktop
// gophics run -p web # browser
// gophics run -p android # a phone or tablet
package main
import (
"log"
"github.com/doug/gophics/app"
"github.com/doug/gophics/examples/capabilities/ui"
)
func main() {
if err := app.Run(ui.Root(), ui.Config()); err != nil {
log.Fatal(err)
}
}
ui/ui.go
// Package ui is the capability inspector's widget tree: every capability the
// running platform exposes, shown working, and the ones it does not expose
// shown greyed out. The same screen on desktop, web and a phone is how the
// per-platform matrix stops being a claim in a document.
package ui
import (
"bytes"
"fmt"
"image"
"image/png"
"strings"
"time"
"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/shell"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// Root is the inspector. Config sits beside it so the desktop entry point and
// the generated mobile bind surface build the same app.
func Root() widget.Widget { return App{} }
func Config() app.Config {
return app.Config{
Title: "gophics · capabilities",
AppID: "com.gophics.capabilities",
Size: geom.Size{W: 480, H: 860},
Font: goregular.TTF,
FontFamilies: map[string][]byte{theme.FontBold: gobold.TTF},
}
}
type App struct{}
func (App) CreateState() widget.State { return &state{} }
type state struct {
widget.StateBase[App]
subscribed bool
gp shell.Gamepads
gpSummary string
lastAction string
typed string
storedNote string
geo string
wv shell.WebViewHandle
}
// subscribe wires up live-update sources the first time capabilities are
// available. Capabilities are nil at mount (the tree builds before the shell
// exists) and arrive on the first frame; the runtime rebuilds when they do, so
// this runs from Build, not Init, guarded to run exactly once.
func (s *state) subscribe(ctx widget.Ctx) {
if s.subscribed {
return
}
// Wait until the window has been wired (any capability present).
if ctx.Connectivity() == nil && ctx.Gamepads() == nil && ctx.FilePicker() == nil {
return
}
s.subscribed = true
rebuild := func() { s.SetState(func() {}) }
if c := ctx.Connectivity(); c != nil {
c.OnChange(func(bool) { rebuild() })
}
if b := ctx.Battery(); b != nil {
b.OnChange(rebuild)
}
if l := ctx.Lifecycle(); l != nil {
l.OnChange(func(shell.AppState) { rebuild() })
}
if g := ctx.Gamepads(); g != nil {
s.gp = g
ctx.AddTicker(poller{s})
}
if st := ctx.SecureStorage(); st != nil {
if v, ok := st.Get("note"); ok {
s.storedNote = v
}
}
}
func (s *state) Dispose() {
if s.wv != nil {
s.wv.Close()
}
}
// poller re-reads gamepad state each frame (input is inherently live), rebuilding
// only when the snapshot changes so a still controller doesn't churn.
type poller struct{ s *state }
func (p poller) Tick(float64) bool {
sum := gamepadSummary(p.s.gp.Poll())
if sum != p.s.gpSummary {
p.s.SetState(func() { p.s.gpSummary = sum })
}
return true
}
func gamepadSummary(gs []shell.Gamepad) string {
n := 0
for _, g := range gs {
if g.Connected {
n++
}
}
if n == 0 {
return "no controller connected"
}
g := gs[0]
return fmt.Sprintf("%d connected — %q: %d buttons, %d axes", n, g.ID, len(g.Buttons), len(g.Axes))
}
func (s *state) Build(ctx widget.Ctx) widget.Widget {
th := theme.Auto(ctx)
s.subscribe(ctx)
rows := []widget.Widget{
widget.Padding{Insets: geom.Insets{Top: 40, Bottom: 4}, Child: theme.Display("Capabilities")},
theme.Label("What this platform exposes through ctx.<Cap>() — live."),
widget.Sized{H: 16},
sectionLabel(th, "Live"),
liveCard(th, "Connectivity", ctx.Connectivity() != nil, connStatus(ctx)),
liveCard(th, "Battery", ctx.Battery() != nil, battStatus(ctx)),
liveCard(th, "Lifecycle", ctx.Lifecycle() != nil, lifeStatus(ctx)),
liveCard(th, "Launch link", ctx.Links() != nil, linkStatus(ctx)),
liveCard(th, "Gamepad", ctx.Gamepads() != nil, s.gpSummary),
liveCard(th, "Biometric", ctx.Biometric() != nil, biometricStatus(ctx)),
widget.Sized{H: 12},
sectionLabel(th, "Actions"),
}
rows = append(rows,
action(th, "File picker", ctx.FilePicker() != nil, "Open a file", func() {
ctx.FilePicker().Open(shell.OpenOptions{}, func(f []shell.PickedFile, err error) {
s.setAction(pickResult(f, err))
})
}, s.lastActionFor("File picker")),
action(th, "Share sheet", ctx.Share() != nil, "Share a link", func() {
ctx.Share().Share(shell.ShareItem{Title: "gophics", Text: "Drawn in pure Go.", URL: "https://github.com/doug/gophics"},
func(error) { s.setAction("Share: share sheet opened") })
}, s.lastActionFor("Share")),
action(th, "Notifications", ctx.Notifier() != nil, "Notify me", func() {
n := ctx.Notifier()
n.Authorize(func(p shell.Permission) {
if p == shell.PermissionGranted {
n.Notify(shell.Notification{Title: "gophics", Body: "A local notification."})
s.setAction("Notifications: posted")
} else {
s.setAction("Notifications: permission denied")
}
})
}, s.lastActionFor("Notifications")),
action(th, "Preferences", ctx.Preferences() != nil, "Count launches", func() {
p := ctx.Preferences()
n := 0
if v, ok := p.Get("launches"); ok {
fmt.Sscanf(v, "%d", &n)
}
n++
if err := p.Set("launches", fmt.Sprint(n)); err != nil {
s.setAction("Preferences: " + err.Error())
return
}
// Read it back rather than trusting the write: the point of the
// card is that the value survives, not that Set returned nil.
got, _ := p.Get("launches")
s.setAction("Preferences: stored and read back " + got)
}, s.lastActionFor("Preferences")),
action(th, "Wake lock", ctx.WakeLock() != nil, "Hold for 10s", func() {
w := ctx.WakeLock()
release := w.Acquire("capability inspector")
s.setAction("Wake lock: held")
post := ctx.Post()
time.AfterFunc(10*time.Second, func() {
// Both on the UI goroutine. release() reaches the platform
// through the shell, and every platform call has to be made
// from the thread the shell owns — releasing from the timer's
// goroutine leaves the screen awake with the lease apparently
// dropped, which is the hardest kind of leak to notice.
post(func() {
release()
s.setAction("Wake lock: released")
})
})
}, s.lastActionFor("Wake lock")),
action(th, "Photo library", ctx.Photos() != nil, "Save a swatch", func() {
ph := ctx.Photos()
ph.Authorize(func(p shell.Permission) {
if p != shell.PermissionGranted {
s.setAction("Photo library: permission denied")
return
}
ph.Save(swatchPNG(), "", func(err error) {
if err != nil {
s.setAction("Photo library: " + err.Error())
return
}
s.setAction("Photo library: saved")
})
})
}, s.lastActionFor("Photo library")),
action(th, "Biometric", ctx.Biometric() != nil, "Authenticate", func() {
ctx.Biometric().Authenticate("Unlock the inspector", true, func(ok bool, err error) {
if ok {
s.setAction("Biometric: authenticated")
return
}
s.setAction("Biometric: " + err.Error())
})
}, s.lastActionFor("Biometric")),
action(th, "Geolocation", ctx.Geolocation() != nil, "Locate me", func() {
ctx.Geolocation().Current(func(lat, lon, acc float64, err error) {
if err != nil {
s.SetState(func() { s.geo = "denied / unavailable" })
return
}
s.SetState(func() { s.geo = fmt.Sprintf("%.4f, %.4f (±%.0fm)", lat, lon, acc) })
})
}, s.geo),
action(th, "Secure storage", ctx.SecureStorage() != nil, "Save a note", func() {
note := "saved " + time.Now().Format("15:04:05")
_ = ctx.SecureStorage().Set("note", note)
s.SetState(func() { s.storedNote = note })
}, s.storedNote),
action(th, "Clipboard", ctx.Clipboard() != nil, "Copy a phrase", func() {
_ = ctx.Clipboard().ClipboardWrite("gophics: one binary, everywhere")
s.setAction("Clipboard: wrote a phrase — paste to check")
}, s.lastActionFor("Clipboard")),
action(th, "Window control", ctx.WindowControl() != nil, "Toggle fullscreen", func() {
wc := ctx.WindowControl()
wc.SetTitle("gophics ✦ capabilities")
wc.SetFullscreen(!wc.Fullscreen())
s.setAction("Window: title set, fullscreen toggled")
}, s.lastActionFor("Window")),
action(th, "Text input (IME)", ctx.TextInput() != nil, "Raise keyboard", func() {
ctx.TextInput().Show(shell.TextInputOptions{Autocorrect: true}, shell.TextInputHandler{
OnText: func(t string) { s.SetState(func() { s.typed += t }) },
OnEditKey: func(k shell.EditKey) {
if k == shell.EditBackspace && s.typed != "" {
s.SetState(func() { s.typed = s.typed[:len(s.typed)-1] })
}
},
})
}, "typed: "+s.typed),
action(th, "Accessibility", ctx.Accessibility() != nil, "Announce", func() {
ctx.Accessibility().Announce("Hello from gophics — this went to your screen reader.", false)
s.setAction("Accessibility: announced (assistive tech only)")
}, s.lastActionFor("Accessibility")),
webviewCard(th, s, ctx),
action(th, "Open URL", true, "Open the repo", func() {
_ = ctx.OpenURL("https://github.com/doug/gophics")
s.setAction("OpenURL: opened in system browser")
}, s.lastActionFor("OpenURL")),
)
col := widget.Column(rows...)
col.CrossAlign = layout.CrossStretch
body := widget.Scroll{Child: widget.Padding{Insets: geom.InsetsSymmetric(16, 0), Child: capWidth(col)}}
return widget.Provide[theme.Theme]{Value: th, Child: widget.Fill{Color: th.Bg, Child: body}}
}
// --- live status readers (synchronous getters, read fresh each Build) --------
func connStatus(ctx widget.Ctx) string {
if c := ctx.Connectivity(); c != nil {
return boolStr(c.Online(), "online", "offline")
}
return ""
}
func battStatus(ctx widget.Ctx) string {
if b := ctx.Battery(); b != nil {
return fmt.Sprintf("%d%%%s", int(b.Level()*100+0.5), chargingStr(b.Charging()))
}
return ""
}
func lifeStatus(ctx widget.Ctx) string {
if l := ctx.Lifecycle(); l != nil {
return "state: " + l.State().String() + " (switch tabs)"
}
return ""
}
func linkStatus(ctx widget.Ctx) string {
if l := ctx.Links(); l != nil {
return l.Initial()
}
return ""
}
func biometricStatus(ctx widget.Ctx) string {
b := ctx.Biometric()
if b == nil {
return ""
}
switch b.Available() {
case shell.BiometricFace:
return "face"
case shell.BiometricFingerprint:
return "fingerprint"
case shell.BiometricOther:
return "available"
default:
return "none enrolled"
}
}
// swatchPNG is a small solid image, encoded here so the photo-library card has
// something real to write without shipping an asset.
func swatchPNG() []byte {
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
for i := range img.Pix {
switch i % 4 {
case 0:
img.Pix[i] = 0xE8
case 1:
img.Pix[i] = 0x7A
case 2:
img.Pix[i] = 0x5A
case 3:
img.Pix[i] = 0xFF
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil
}
return buf.Bytes()
}
// --- cards -------------------------------------------------------------------
func liveCard(th theme.Theme, title string, have bool, value string) widget.Widget {
return theme.Card{Child: widget.Row(
widget.Sized{W: 150, Child: leftText(theme.Body(title))},
widget.Expand(statusText(th, have, value)),
)}
}
func action(th theme.Theme, title string, have bool, btn string, onTap func(), result string) widget.Widget {
right := widget.Widget(theme.Body("— unsupported here"))
if have {
right = theme.Button{Label: btn, OnTap: onTap}
}
kids := []widget.Widget{widget.Row(
widget.Sized{W: 150, Child: leftText(theme.Body(title))},
widget.Expand(leftText(right)),
)}
if have && result != "" {
kids = append(kids, widget.Sized{H: 6},
widget.Text{Value: result, Size: th.Type.Label, Color: th.Muted, Wrap: true})
}
c := widget.Column(kids...)
c.CrossAlign = layout.CrossStretch
return theme.Card{Child: c}
}
func webviewCard(th theme.Theme, s *state, ctx widget.Ctx) widget.Widget {
have := ctx.WebView() != nil
label := "Open web view"
onTap := func() {
if s.wv != nil {
s.wv.Close()
s.SetState(func() { s.wv = nil })
return
}
h := ctx.WebView().Open("https://example.com", geom.RectXYWH(480, 120, 460, 320))
s.SetState(func() { s.wv = h })
}
if s.wv != nil {
label = "Close web view"
}
return action(th, "Web view (iframe)", have, label,
onTap, boolStr(s.wv != nil, "open — a real iframe over the canvas", ""))
}
// --- small helpers -----------------------------------------------------------
func sectionLabel(th theme.Theme, s string) widget.Widget {
return widget.Padding{Insets: geom.Insets{Top: 6, Bottom: 8}, Child: theme.Heading(s)}
}
func leftText(w widget.Widget) widget.Widget {
c := widget.Column(w)
c.CrossAlign = layout.CrossStart
return c
}
func statusText(th theme.Theme, have bool, value string) widget.Widget {
if !have {
return leftText(widget.Text{Value: "— unsupported here", Size: th.Type.Body, Color: th.Muted})
}
return leftText(widget.Text{Value: value, Size: th.Type.Body, Color: th.Text, Wrap: true})
}
func (s *state) setAction(msg string) { s.SetState(func() { s.lastAction = msg }) }
func (s *state) lastActionFor(title string) string {
if strings.HasPrefix(s.lastAction, title) {
return s.lastAction
}
return ""
}
func boolStr(b bool, t, f string) string {
if b {
return t
}
return f
}
func chargingStr(c bool) string {
if c {
return " (charging)"
}
return ""
}
func pickResult(f []shell.PickedFile, err error) string {
if err != nil {
return "File picker: error"
}
if len(f) == 0 {
return "File picker: cancelled"
}
return fmt.Sprintf("File picker: %s (%d bytes)", f[0].Name, len(f[0].Data))
}
const contentMaxW = 760
func capWidth(child widget.Widget) widget.Widget {
return widget.LayoutBuilder{Build: func(cs layout.Constraints) widget.Widget {
pad := (cs.Max.W - contentMaxW) / 2
if pad <= 0 {
return child
}
return widget.Padding{Insets: geom.Insets{Left: pad, Right: pad}, Child: child}
}}
}