907 lines
25 KiB
Go
907 lines
25 KiB
Go
package gui
|
|
|
|
import (
|
|
"image"
|
|
"image/color"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"gioui.org/f32"
|
|
"gioui.org/font"
|
|
"gioui.org/layout"
|
|
"gioui.org/op"
|
|
"gioui.org/op/clip"
|
|
"gioui.org/op/paint"
|
|
"gioui.org/text"
|
|
"gioui.org/unit"
|
|
"gioui.org/widget"
|
|
"gioui.org/widget/material"
|
|
)
|
|
|
|
// Short aliases, the conventional Gio shorthand.
|
|
type (
|
|
C = layout.Context
|
|
D = layout.Dimensions
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Text
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Text returns a label in the app's type scale.
|
|
func (t *Theme) Text(size unit.Sp, col color.NRGBA, txt string) material.LabelStyle {
|
|
l := material.Label(t.Theme, size, txt)
|
|
l.Color = col
|
|
return l
|
|
}
|
|
|
|
// Mono returns a monospaced label, used wherever columns of addresses, ports
|
|
// or timings need to line up.
|
|
func (t *Theme) MonoLabel(size unit.Sp, col color.NRGBA, txt string) material.LabelStyle {
|
|
l := t.Text(size, col, txt)
|
|
l.Font.Typeface = t.Mono
|
|
return l
|
|
}
|
|
|
|
// Title is the heading of a page.
|
|
func (t *Theme) Title(txt string) material.LabelStyle {
|
|
l := t.Text(SizeTitle, t.P.TextPri, txt)
|
|
l.Font.Weight = font.SemiBold
|
|
return l
|
|
}
|
|
|
|
// Display is the single largest text on a page, used for headline numbers.
|
|
func (t *Theme) Display(txt string) material.LabelStyle {
|
|
l := t.Text(SizeDisplay, t.P.TextPri, txt)
|
|
l.Font.Weight = font.SemiBold
|
|
return l
|
|
}
|
|
|
|
// Body is normal running text.
|
|
func (t *Theme) Body(txt string) material.LabelStyle {
|
|
return t.Text(SizeBody, t.P.TextPri, txt)
|
|
}
|
|
|
|
// Secondary is a de-emphasised label, typically the left column of a key/value
|
|
// row.
|
|
func (t *Theme) Secondary(txt string) material.LabelStyle {
|
|
return t.Text(SizeBody, t.P.TextSec, txt)
|
|
}
|
|
|
|
// Caption is metadata: timestamps, hints, units.
|
|
func (t *Theme) Caption(txt string) material.LabelStyle {
|
|
return t.Text(SizeCaption, t.P.TextDim, txt)
|
|
}
|
|
|
|
// OneLine constrains a label to a single truncated line, which keeps table
|
|
// rows from reflowing when a peer has a long name.
|
|
func OneLine(l material.LabelStyle) material.LabelStyle {
|
|
l.MaxLines = 1
|
|
l.WrapPolicy = text.WrapGraphemes
|
|
return l
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Primitive drawing helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// FillRRect paints a rounded rectangle of the given size.
|
|
func FillRRect(gtx C, size image.Point, radius unit.Dp, col color.NRGBA) {
|
|
r := gtx.Dp(radius)
|
|
if max := min(size.X, size.Y) / 2; r > max {
|
|
r = max
|
|
}
|
|
paint.FillShape(gtx.Ops, col, clip.UniformRRect(image.Rectangle{Max: size}, r).Op(gtx.Ops))
|
|
}
|
|
|
|
// StrokeRRect outlines a rounded rectangle.
|
|
func StrokeRRect(gtx C, size image.Point, radius unit.Dp, width unit.Dp, col color.NRGBA) {
|
|
r := gtx.Dp(radius)
|
|
if max := min(size.X, size.Y) / 2; r > max {
|
|
r = max
|
|
}
|
|
w := float32(gtx.Dp(width))
|
|
// Inset by half the stroke width so the outline lands inside the bounds.
|
|
inset := int(w / 2)
|
|
rect := image.Rectangle{Min: image.Pt(inset, inset), Max: size.Sub(image.Pt(inset, inset))}
|
|
if rect.Dx() <= 0 || rect.Dy() <= 0 {
|
|
return
|
|
}
|
|
spec := clip.UniformRRect(rect, r).Path(gtx.Ops)
|
|
paint.FillShape(gtx.Ops, col, clip.Stroke{Path: spec, Width: w}.Op())
|
|
}
|
|
|
|
// Circle paints a filled circle of the given diameter.
|
|
func Circle(gtx C, diameter int, col color.NRGBA) D {
|
|
if diameter <= 0 {
|
|
return D{}
|
|
}
|
|
r := diameter / 2
|
|
paint.FillShape(gtx.Ops, col,
|
|
clip.UniformRRect(image.Rectangle{Max: image.Pt(diameter, diameter)}, r).Op(gtx.Ops))
|
|
return D{Size: image.Pt(diameter, diameter)}
|
|
}
|
|
|
|
// animFrame is the minimum gap between animation frames, i.e. a ~25fps cap.
|
|
//
|
|
// This matters more than it looks. op.InvalidateCmd with a zero At means
|
|
// "redraw immediately", so a widget that issues one every frame makes Gio
|
|
// render as fast as the machine can manage — several hundred percent CPU under
|
|
// software rendering, for a spinner nobody is watching. Scheduling the next
|
|
// frame at a fixed time bounds the loop, and concurrent animations coalesce
|
|
// onto the same wakeup.
|
|
//
|
|
// The cap alone is not enough, because a frame is not cheap: profiling this UI
|
|
// under llvmpipe put 73% of the time in Gio's path stenciler, which every
|
|
// rounded rectangle, border and icon goes through. So animation is also
|
|
// reserved for genuinely transient states — see [Theme.StatusDot]. An idle
|
|
// window must settle at zero frames per second, not a slow trickle.
|
|
const animFrame = 40 * time.Millisecond
|
|
|
|
// animSlowFrame is the cadence for ambient motion with a multi-second cycle,
|
|
// where 12fps is indistinguishable from 25 but costs half as much.
|
|
const animSlowFrame = 80 * time.Millisecond
|
|
|
|
// animate requests the next animation frame at the capped rate. Every animated
|
|
// widget in this package goes through it.
|
|
func animate(gtx C) {
|
|
gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(animFrame)})
|
|
}
|
|
|
|
// animateSlow is [animate] for slow, decorative motion.
|
|
func animateSlow(gtx C) {
|
|
gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(animSlowFrame)})
|
|
}
|
|
|
|
// Spacer returns a fixed-size gap.
|
|
func Spacer(v unit.Dp) layout.Spacer { return layout.Spacer{Height: v, Width: v} }
|
|
|
|
// VGap is a vertical gap.
|
|
func VGap(v unit.Dp) layout.FlexChild {
|
|
return layout.Rigid(layout.Spacer{Height: v}.Layout)
|
|
}
|
|
|
|
// HGap is a horizontal gap.
|
|
func HGap(v unit.Dp) layout.FlexChild {
|
|
return layout.Rigid(layout.Spacer{Width: v}.Layout)
|
|
}
|
|
|
|
// Divider draws a hairline separator.
|
|
func (t *Theme) Divider(gtx C) D {
|
|
h := max(gtx.Dp(1), 1)
|
|
w := gtx.Constraints.Min.X
|
|
if w == 0 {
|
|
w = gtx.Constraints.Max.X
|
|
}
|
|
paint.FillShape(gtx.Ops, t.P.Border, clip.Rect{Max: image.Pt(w, h)}.Op())
|
|
return D{Size: image.Pt(w, h)}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Card
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// CardStyle is the standard container: a slightly raised surface with a
|
|
// hairline border. Cards are the only container in the UI, which is what keeps
|
|
// dense pages from turning into noise.
|
|
type CardStyle struct {
|
|
Title string
|
|
Subtitle string
|
|
// Accent tints the left edge, used to flag a section's severity without
|
|
// adding another coloured chip.
|
|
Accent *color.NRGBA
|
|
// Trailing renders at the top-right of the header, for actions.
|
|
Trailing layout.Widget
|
|
Pad unit.Dp
|
|
Radius unit.Dp
|
|
Bg *color.NRGBA
|
|
}
|
|
|
|
// Card returns a default card.
|
|
func (t *Theme) Card() CardStyle {
|
|
return CardStyle{Pad: SpaceLG, Radius: RadiusMD}
|
|
}
|
|
|
|
// Layout draws the card around w.
|
|
func (c CardStyle) Layout(t *Theme, gtx C, w layout.Widget) D {
|
|
bg := t.P.Surface
|
|
if c.Bg != nil {
|
|
bg = *c.Bg
|
|
}
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Expanded(func(gtx C) D {
|
|
size := gtx.Constraints.Min
|
|
FillRRect(gtx, size, c.Radius, bg)
|
|
StrokeRRect(gtx, size, c.Radius, 1, t.P.Border)
|
|
if c.Accent != nil {
|
|
// A 3dp bar hugging the left edge, clipped to the card radius.
|
|
r := gtx.Dp(c.Radius)
|
|
defer clip.UniformRRect(image.Rectangle{Max: size}, r).Push(gtx.Ops).Pop()
|
|
paint.FillShape(gtx.Ops, *c.Accent,
|
|
clip.Rect{Max: image.Pt(gtx.Dp(3), size.Y)}.Op())
|
|
}
|
|
return D{Size: size}
|
|
}),
|
|
layout.Stacked(func(gtx C) D {
|
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
|
return layout.UniformInset(c.Pad).Layout(gtx, func(gtx C) D {
|
|
if c.Title == "" {
|
|
return w(gtx)
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
return c.header(t, gtx)
|
|
}),
|
|
VGap(SpaceMD),
|
|
layout.Rigid(w),
|
|
)
|
|
})
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (c CardStyle) header(t *Theme, gtx C) D {
|
|
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Flexed(1, func(gtx C) D {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
l := t.Text(SizeSubtitle, t.P.TextPri, c.Title)
|
|
l.Font.Weight = font.SemiBold
|
|
return l.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx C) D {
|
|
if c.Subtitle == "" {
|
|
return D{}
|
|
}
|
|
return layout.Inset{Top: 2}.Layout(gtx, t.Caption(c.Subtitle).Layout)
|
|
}),
|
|
)
|
|
}),
|
|
layout.Rigid(func(gtx C) D {
|
|
if c.Trailing == nil {
|
|
return D{}
|
|
}
|
|
return c.Trailing(gtx)
|
|
}),
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Chips, dots, badges
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ChipStyle is a small pill carrying one piece of status.
|
|
type ChipStyle struct {
|
|
Text string
|
|
Level StatusLevel
|
|
// Solid fills the chip with the level colour instead of tinting it.
|
|
Solid bool
|
|
// Dot prefixes the label with a status dot.
|
|
Dot bool
|
|
}
|
|
|
|
// Chip renders a status pill.
|
|
func (t *Theme) Chip(gtx C, s ChipStyle) D {
|
|
fg := t.StatusColor(s.Level)
|
|
bg := WithAlpha(fg, 0.14)
|
|
if s.Solid {
|
|
bg = fg
|
|
fg = t.P.AccentFg
|
|
}
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Expanded(func(gtx C) D {
|
|
FillRRect(gtx, gtx.Constraints.Min, RadiusPill, bg)
|
|
return D{Size: gtx.Constraints.Min}
|
|
}),
|
|
layout.Stacked(func(gtx C) D {
|
|
return layout.Inset{
|
|
Top: 3, Bottom: 3, Left: SpaceSM, Right: SpaceSM,
|
|
}.Layout(gtx, func(gtx C) D {
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
if !s.Dot {
|
|
return D{}
|
|
}
|
|
return layout.Inset{Right: 5}.Layout(gtx, func(gtx C) D {
|
|
return Circle(gtx, gtx.Dp(6), fg)
|
|
})
|
|
}),
|
|
layout.Rigid(OneLine(t.Text(SizeCaption, fg, s.Text)).Layout),
|
|
)
|
|
})
|
|
}),
|
|
)
|
|
}
|
|
|
|
// StatusDot draws a coloured dot; when pulse is true it breathes.
|
|
//
|
|
// Pass pulse only for states that are actually transient — connecting,
|
|
// retrying, a probe in flight. A dot that breathes forever costs a full
|
|
// redraw of the window several times a second for as long as the app is open,
|
|
// which is not a price worth paying to say "still here".
|
|
func (t *Theme) StatusDot(gtx C, level StatusLevel, pulse bool) D {
|
|
col := t.StatusColor(level)
|
|
d := gtx.Dp(8)
|
|
if pulse {
|
|
// One breath per 1.6s, derived from frame time so it stays smooth.
|
|
phase := float64(gtx.Now.UnixNano()%int64(1600*time.Millisecond)) / float64(1600*time.Millisecond)
|
|
a := 0.35 + 0.65*(0.5+0.5*math.Sin(phase*2*math.Pi))
|
|
halo := WithAlpha(col, float32(a)*0.35)
|
|
hd := gtx.Dp(16)
|
|
off := op.Offset(image.Pt(-(hd-d)/2, -(hd-d)/2)).Push(gtx.Ops)
|
|
Circle(gtx, hd, halo)
|
|
off.Pop()
|
|
animate(gtx)
|
|
}
|
|
return Circle(gtx, d, col)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Key/value rows
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// KV renders a label on the left and a value on the right. This is the primary
|
|
// way facts are shown; keeping every panel on the same row grammar is what
|
|
// makes a dense diagnostics page scannable.
|
|
type KV struct {
|
|
Key string
|
|
Value string
|
|
// Level colours the value. LevelNeutral leaves it primary-coloured.
|
|
Level StatusLevel
|
|
// Mono renders the value monospaced.
|
|
Mono bool
|
|
// Hint appears under the key in caption style.
|
|
Hint string
|
|
// KeyWidth fixes the label column so consecutive rows align. Zero uses a
|
|
// flexible 40% split.
|
|
KeyWidth unit.Dp
|
|
}
|
|
|
|
// Layout draws one key/value row.
|
|
func (t *Theme) KV(gtx C, kv KV) D {
|
|
valCol := t.P.TextPri
|
|
if kv.Level != LevelNeutral {
|
|
valCol = t.StatusColor(kv.Level)
|
|
}
|
|
value := func(gtx C) D {
|
|
var l material.LabelStyle
|
|
if kv.Mono {
|
|
l = t.MonoLabel(SizeBody, valCol, kv.Value)
|
|
} else {
|
|
l = t.Text(SizeBody, valCol, kv.Value)
|
|
}
|
|
l.Alignment = text.End
|
|
return l.Layout(gtx)
|
|
}
|
|
key := func(gtx C) D {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(OneLine(t.Secondary(kv.Key)).Layout),
|
|
layout.Rigid(func(gtx C) D {
|
|
if kv.Hint == "" {
|
|
return D{}
|
|
}
|
|
return t.Caption(kv.Hint).Layout(gtx)
|
|
}),
|
|
)
|
|
}
|
|
return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, func(gtx C) D {
|
|
if kv.KeyWidth > 0 {
|
|
w := gtx.Dp(kv.KeyWidth)
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
gtx.Constraints.Max.X = w
|
|
gtx.Constraints.Min.X = w
|
|
return key(gtx)
|
|
}),
|
|
HGap(SpaceMD),
|
|
layout.Flexed(1, value),
|
|
)
|
|
}
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Flexed(0.42, key),
|
|
HGap(SpaceMD),
|
|
layout.Flexed(0.58, value),
|
|
)
|
|
})
|
|
}
|
|
|
|
// KVList lays out consecutive rows with hairlines between them.
|
|
func (t *Theme) KVList(gtx C, rows []KV) D {
|
|
children := make([]layout.FlexChild, 0, len(rows)*2)
|
|
for i, row := range rows {
|
|
if i > 0 {
|
|
children = append(children, layout.Rigid(t.Divider))
|
|
}
|
|
children = append(children, layout.Rigid(func(gtx C) D {
|
|
return t.KV(gtx, row)
|
|
}))
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Buttons
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ButtonKind selects a button's visual weight. A screen should have at most
|
|
// one Primary.
|
|
type ButtonKind int
|
|
|
|
const (
|
|
ButtonPrimary ButtonKind = iota
|
|
ButtonSubtle
|
|
ButtonGhost
|
|
ButtonDanger
|
|
)
|
|
|
|
// ButtonStyle is this app's button, replacing material.Button so hover, radius
|
|
// and typography match the rest of the design.
|
|
type ButtonStyle struct {
|
|
Kind ButtonKind
|
|
Text string
|
|
Icon IconFunc
|
|
Disabled bool
|
|
// Width, when non-zero, fixes the button width for aligned button rows.
|
|
Width unit.Dp
|
|
}
|
|
|
|
// Button renders a clickable button.
|
|
func (t *Theme) Button(gtx C, click *widget.Clickable, s ButtonStyle) D {
|
|
var bg, fg, border color.NRGBA
|
|
switch s.Kind {
|
|
case ButtonPrimary:
|
|
bg, fg = t.P.Accent, t.P.AccentFg
|
|
case ButtonDanger:
|
|
bg, fg = t.P.Fail, t.P.AccentFg
|
|
case ButtonSubtle:
|
|
bg, fg, border = t.P.SurfaceHi, t.P.TextPri, t.P.Border
|
|
default: // ghost
|
|
bg, fg = color.NRGBA{}, t.P.TextSec
|
|
}
|
|
if s.Disabled {
|
|
bg = WithAlpha(bg, 0.4)
|
|
fg = WithAlpha(fg, 0.45)
|
|
gtx = gtx.Disabled()
|
|
} else if click.Hovered() {
|
|
switch s.Kind {
|
|
case ButtonGhost:
|
|
bg = t.P.SurfaceHi
|
|
fg = t.P.TextPri
|
|
default:
|
|
bg = Mix(bg, t.P.TextPri, 0.12)
|
|
}
|
|
}
|
|
if click.Pressed() {
|
|
bg = Mix(bg, t.P.Bg, 0.18)
|
|
}
|
|
|
|
return click.Layout(gtx, func(gtx C) D {
|
|
if s.Width > 0 {
|
|
gtx.Constraints.Min.X = gtx.Dp(s.Width)
|
|
}
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Expanded(func(gtx C) D {
|
|
if bg.A > 0 {
|
|
FillRRect(gtx, gtx.Constraints.Min, RadiusSM, bg)
|
|
}
|
|
if border.A > 0 {
|
|
StrokeRRect(gtx, gtx.Constraints.Min, RadiusSM, 1, border)
|
|
}
|
|
return D{Size: gtx.Constraints.Min}
|
|
}),
|
|
layout.Stacked(func(gtx C) D {
|
|
return layout.Inset{
|
|
Top: 7, Bottom: 7, Left: SpaceMD, Right: SpaceMD,
|
|
}.Layout(gtx, func(gtx C) D {
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
if s.Icon == nil {
|
|
return D{}
|
|
}
|
|
return layout.Inset{Right: 6}.Layout(gtx, func(gtx C) D {
|
|
return s.Icon(gtx, gtx.Dp(14), fg)
|
|
})
|
|
}),
|
|
layout.Rigid(func(gtx C) D {
|
|
if s.Text == "" {
|
|
return D{}
|
|
}
|
|
l := t.Text(SizeBody, fg, s.Text)
|
|
l.Font.Weight = font.Medium
|
|
l.Alignment = text.Middle
|
|
return l.Layout(gtx)
|
|
}),
|
|
)
|
|
})
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
|
|
// IconButton is a square icon-only button, used in card headers.
|
|
func (t *Theme) IconButton(gtx C, click *widget.Clickable, icon IconFunc, level StatusLevel) D {
|
|
fg := t.P.TextSec
|
|
if level != LevelNeutral {
|
|
fg = t.StatusColor(level)
|
|
}
|
|
bg := color.NRGBA{}
|
|
if click.Hovered() {
|
|
bg = t.P.SurfaceHi
|
|
if level == LevelNeutral {
|
|
fg = t.P.TextPri
|
|
}
|
|
}
|
|
return click.Layout(gtx, func(gtx C) D {
|
|
sz := gtx.Dp(28)
|
|
if bg.A > 0 {
|
|
FillRRect(gtx, image.Pt(sz, sz), RadiusSM, bg)
|
|
}
|
|
icoSize := gtx.Dp(16)
|
|
off := op.Offset(image.Pt((sz-icoSize)/2, (sz-icoSize)/2)).Push(gtx.Ops)
|
|
icon(gtx, icoSize, fg)
|
|
off.Pop()
|
|
return D{Size: image.Pt(sz, sz)}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Toggle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Toggle renders a compact switch with a label.
|
|
func (t *Theme) Toggle(gtx C, b *widget.Bool, label string) D {
|
|
return b.Layout(gtx, func(gtx C) D {
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
w, h := gtx.Dp(32), gtx.Dp(18)
|
|
track := t.P.SurfaceHi
|
|
knobCol := t.P.TextDim
|
|
if b.Value {
|
|
track = t.P.Accent
|
|
knobCol = t.P.AccentFg
|
|
}
|
|
FillRRect(gtx, image.Pt(w, h), RadiusPill, track)
|
|
kd := h - gtx.Dp(4)
|
|
kx := gtx.Dp(2)
|
|
if b.Value {
|
|
kx = w - kd - gtx.Dp(2)
|
|
}
|
|
off := op.Offset(image.Pt(kx, gtx.Dp(2))).Push(gtx.Ops)
|
|
Circle(gtx, kd, knobCol)
|
|
off.Pop()
|
|
return D{Size: image.Pt(w, h)}
|
|
}),
|
|
layout.Rigid(func(gtx C) D {
|
|
if label == "" {
|
|
return D{}
|
|
}
|
|
return layout.Inset{Left: SpaceSM}.Layout(gtx, t.Secondary(label).Layout)
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Segmented control (used for level/language/theme pickers)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// SegmentOption is one choice in a segmented control.
|
|
type SegmentOption struct {
|
|
Key string
|
|
Label string
|
|
// Count, when non-negative, is shown as a trailing tally.
|
|
Count int
|
|
Level StatusLevel
|
|
}
|
|
|
|
// Segmented renders a row of mutually exclusive options backed by a
|
|
// widget.Enum.
|
|
func (t *Theme) Segmented(gtx C, e *widget.Enum, opts []SegmentOption) D {
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Expanded(func(gtx C) D {
|
|
FillRRect(gtx, gtx.Constraints.Min, RadiusSM, t.P.BgElevated)
|
|
return D{Size: gtx.Constraints.Min}
|
|
}),
|
|
layout.Stacked(func(gtx C) D {
|
|
return layout.UniformInset(3).Layout(gtx, func(gtx C) D {
|
|
children := make([]layout.FlexChild, 0, len(opts))
|
|
for _, o := range opts {
|
|
children = append(children, layout.Rigid(func(gtx C) D {
|
|
return t.segment(gtx, e, o)
|
|
}))
|
|
}
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx, children...)
|
|
})
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (t *Theme) segment(gtx C, e *widget.Enum, o SegmentOption) D {
|
|
selected := e.Value == o.Key
|
|
fg := t.P.TextSec
|
|
if selected {
|
|
fg = t.P.TextPri
|
|
}
|
|
if o.Level != LevelNeutral && selected {
|
|
fg = t.StatusColor(o.Level)
|
|
}
|
|
return e.Layout(gtx, o.Key, func(gtx C) D {
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Expanded(func(gtx C) D {
|
|
if selected {
|
|
FillRRect(gtx, gtx.Constraints.Min, RadiusSM-2, t.P.SurfaceHi)
|
|
}
|
|
return D{Size: gtx.Constraints.Min}
|
|
}),
|
|
layout.Stacked(func(gtx C) D {
|
|
return layout.Inset{Top: 4, Bottom: 4, Left: SpaceMD, Right: SpaceMD}.Layout(gtx, func(gtx C) D {
|
|
label := o.Label
|
|
if o.Count >= 0 {
|
|
label = o.Label + " " + itoa(o.Count)
|
|
}
|
|
l := t.Text(SizeCaption, fg, label)
|
|
if selected {
|
|
l.Font.Weight = font.Medium
|
|
}
|
|
return l.Layout(gtx)
|
|
})
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Empty state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// EmptyState is what a panel shows instead of a blank area. It always says why
|
|
// the area is empty, never just "no data".
|
|
func (t *Theme) EmptyState(gtx C, icon IconFunc, title, hint string) D {
|
|
return layout.Center.Layout(gtx, func(gtx C) D {
|
|
return layout.Inset{Top: Space2XL, Bottom: Space2XL}.Layout(gtx, func(gtx C) D {
|
|
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
if icon == nil {
|
|
return D{}
|
|
}
|
|
return icon(gtx, gtx.Dp(28), WithAlpha(t.P.TextDim, 0.7))
|
|
}),
|
|
VGap(SpaceMD),
|
|
layout.Rigid(func(gtx C) D {
|
|
l := t.Text(SizeBody, t.P.TextSec, title)
|
|
l.Alignment = text.Middle
|
|
return l.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx C) D {
|
|
if hint == "" {
|
|
return D{}
|
|
}
|
|
return layout.Inset{Top: SpaceXS}.Layout(gtx, func(gtx C) D {
|
|
l := t.Caption(hint)
|
|
l.Alignment = text.Middle
|
|
return l.Layout(gtx)
|
|
})
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spinner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Spinner draws an indeterminate arc. It requests the next frame itself, so
|
|
// callers just place it.
|
|
func (t *Theme) Spinner(gtx C, size int, col color.NRGBA) D {
|
|
if size <= 0 {
|
|
size = gtx.Dp(20)
|
|
}
|
|
const period = 1100 * time.Millisecond
|
|
phase := float32(gtx.Now.UnixNano()%int64(period)) / float32(period)
|
|
|
|
stroke := float32(gtx.Dp(2))
|
|
r := float32(size)/2 - stroke/2
|
|
center := f32.Pt(float32(size)/2, float32(size)/2)
|
|
|
|
// Track.
|
|
drawArc(gtx, center, r, stroke, 0, 2*math.Pi, WithAlpha(col, 0.15))
|
|
// Sweep: the arc length breathes so the motion reads as progress rather
|
|
// than a rotating stick.
|
|
sweep := float32(0.25*math.Pi) + float32(1.2*math.Pi)*(0.5+0.5*float32(math.Sin(float64(phase)*2*math.Pi)))
|
|
start := phase * 2 * math.Pi * 2
|
|
drawArc(gtx, center, r, stroke, start, sweep, col)
|
|
|
|
animate(gtx)
|
|
return D{Size: image.Pt(size, size)}
|
|
}
|
|
|
|
// drawArc strokes an arc of `sweep` radians starting at `start`.
|
|
func drawArc(gtx C, center f32.Point, radius, width, start, sweep float32, col color.NRGBA) {
|
|
if radius <= 0 || sweep <= 0 {
|
|
return
|
|
}
|
|
var p clip.Path
|
|
p.Begin(gtx.Ops)
|
|
begin := f32.Pt(
|
|
center.X+radius*float32(math.Cos(float64(start))),
|
|
center.Y+radius*float32(math.Sin(float64(start))),
|
|
)
|
|
p.MoveTo(begin)
|
|
// clip.Path.Arc rotates the pen around the focus points; for a circle both
|
|
// foci are the centre.
|
|
p.Arc(center.Sub(begin), center.Sub(begin), sweep)
|
|
paint.FillShape(gtx.Ops, col, clip.Stroke{Path: p.End(), Width: width}.Op())
|
|
}
|
|
|
|
// ProgressBar draws a determinate bar in [0,1].
|
|
func (t *Theme) ProgressBar(gtx C, progress float32, col color.NRGBA) D {
|
|
if progress < 0 {
|
|
progress = 0
|
|
}
|
|
if progress > 1 {
|
|
progress = 1
|
|
}
|
|
w := gtx.Constraints.Max.X
|
|
h := gtx.Dp(4)
|
|
FillRRect(gtx, image.Pt(w, h), RadiusPill, WithAlpha(col, 0.16))
|
|
fw := int(float32(w) * progress)
|
|
if fw > 0 {
|
|
FillRRect(gtx, image.Pt(fw, h), RadiusPill, col)
|
|
}
|
|
return D{Size: image.Pt(w, h)}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Formatting helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
neg := n < 0
|
|
if neg {
|
|
n = -n
|
|
}
|
|
var buf [20]byte
|
|
i := len(buf)
|
|
for n > 0 {
|
|
i--
|
|
buf[i] = byte('0' + n%10)
|
|
n /= 10
|
|
}
|
|
if neg {
|
|
i--
|
|
buf[i] = '-'
|
|
}
|
|
return string(buf[i:])
|
|
}
|
|
|
|
// FormatLatency renders a duration the way a network tool should: sub-10ms
|
|
// gets one decimal, everything else is a whole number of milliseconds.
|
|
func FormatLatency(d time.Duration) string {
|
|
if d <= 0 {
|
|
return "—"
|
|
}
|
|
ms := float64(d) / float64(time.Millisecond)
|
|
switch {
|
|
case ms < 10:
|
|
return trimZero(ms, 1) + " ms"
|
|
case ms < 1000:
|
|
return itoa(int(ms+0.5)) + " ms"
|
|
default:
|
|
return trimZero(ms/1000, 2) + " s"
|
|
}
|
|
}
|
|
|
|
func trimZero(v float64, prec int) string {
|
|
mult := math.Pow(10, float64(prec))
|
|
v = math.Round(v*mult) / mult
|
|
s := strconvFormat(v, prec)
|
|
if strings.Contains(s, ".") {
|
|
s = strings.TrimRight(s, "0")
|
|
s = strings.TrimSuffix(s, ".")
|
|
}
|
|
return s
|
|
}
|
|
|
|
// strconvFormat avoids importing strconv just for one call site pattern; it
|
|
// formats with a fixed number of decimals.
|
|
func strconvFormat(v float64, prec int) string {
|
|
neg := v < 0
|
|
if neg {
|
|
v = -v
|
|
}
|
|
mult := math.Pow(10, float64(prec))
|
|
scaled := int64(math.Round(v * mult))
|
|
intPart := scaled / int64(mult)
|
|
frac := scaled % int64(mult)
|
|
s := itoa(int(intPart))
|
|
if prec > 0 {
|
|
fs := itoa(int(frac))
|
|
for len(fs) < prec {
|
|
fs = "0" + fs
|
|
}
|
|
s += "." + fs
|
|
}
|
|
if neg {
|
|
s = "-" + s
|
|
}
|
|
return s
|
|
}
|
|
|
|
// FormatBytes renders a byte count with binary units.
|
|
func FormatBytes(n int64) string {
|
|
if n < 0 {
|
|
return "—"
|
|
}
|
|
const unit = 1024
|
|
if n < unit {
|
|
return itoa(int(n)) + " B"
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for v := n / unit; v >= unit && exp < 4; v /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
suffixes := []string{"KiB", "MiB", "GiB", "TiB", "PiB"}
|
|
return trimZero(float64(n)/float64(div), 1) + " " + suffixes[exp]
|
|
}
|
|
|
|
// FormatDuration renders an uptime-style duration.
|
|
func FormatDuration(d time.Duration) string {
|
|
if d <= 0 {
|
|
return "—"
|
|
}
|
|
d = d.Round(time.Second)
|
|
h := int(d.Hours())
|
|
m := int(d.Minutes()) % 60
|
|
s := int(d.Seconds()) % 60
|
|
switch {
|
|
case h >= 24:
|
|
return itoa(h/24) + "d " + itoa(h%24) + "h"
|
|
case h > 0:
|
|
return itoa(h) + "h " + itoa(m) + "m"
|
|
case m > 0:
|
|
return itoa(m) + "m " + itoa(s) + "s"
|
|
default:
|
|
return itoa(s) + "s"
|
|
}
|
|
}
|
|
|
|
// RelTime renders how long ago t was, localised.
|
|
func RelTime(th *Theme, t time.Time, now time.Time) string {
|
|
if t.IsZero() {
|
|
return th.T(KNever)
|
|
}
|
|
d := now.Sub(t)
|
|
switch {
|
|
case d < 0:
|
|
return th.T(KJustNow)
|
|
case d < 5*time.Second:
|
|
return th.T(KJustNow)
|
|
case d < time.Minute:
|
|
return itoa(int(d.Seconds())) + th.T(KSecondsAgo)
|
|
case d < time.Hour:
|
|
return itoa(int(d.Minutes())) + th.T(KMinutesAgo)
|
|
case d < 24*time.Hour:
|
|
return itoa(int(d.Hours())) + th.T(KHoursAgo)
|
|
default:
|
|
return t.Format("01-02 15:04")
|
|
}
|
|
}
|
|
|
|
// Truncate shortens s to at most n runes, appending an ellipsis.
|
|
func Truncate(s string, n int) string {
|
|
r := []rune(s)
|
|
if len(r) <= n {
|
|
return s
|
|
}
|
|
if n <= 1 {
|
|
return "…"
|
|
}
|
|
return string(r[:n-1]) + "…"
|
|
}
|