add: gui and tsdiag
This commit is contained in:
+906
@@ -0,0 +1,906 @@
|
||||
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]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user