Files
2026-07-26 09:39:17 +00:00

269 lines
6.1 KiB
Go

package gui
import (
"image/color"
"gioui.org/font"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget/material"
)
// Spacing scale. Every gap in the UI is one of these; ad-hoc values are what
// makes an interface feel noisy.
const (
SpaceXS unit.Dp = 4
SpaceSM unit.Dp = 8
SpaceMD unit.Dp = 12
SpaceLG unit.Dp = 16
SpaceXL unit.Dp = 24
Space2XL unit.Dp = 32
)
// Corner radii.
const (
RadiusSM unit.Dp = 6
RadiusMD unit.Dp = 10
RadiusLG unit.Dp = 14
RadiusPill unit.Dp = 999
)
// Type scale. Only five sizes exist so hierarchy stays legible when a panel is
// dense with numbers.
const (
SizeDisplay unit.Sp = 23
SizeTitle unit.Sp = 17
SizeSubtitle unit.Sp = 14
SizeBody unit.Sp = 13
SizeCaption unit.Sp = 11.5
SizeMono unit.Sp = 12
)
// Palette holds every colour the UI is allowed to use.
type Palette struct {
// Surfaces, from furthest back to nearest front.
Bg color.NRGBA
BgElevated color.NRGBA
Surface color.NRGBA
SurfaceHi color.NRGBA
Border color.NRGBA
BorderHi color.NRGBA
// Three text weights carry the whole information hierarchy: primary for
// values, secondary for labels, dim for metadata.
TextPri color.NRGBA
TextSec color.NRGBA
TextDim color.NRGBA
Accent color.NRGBA
AccentDim color.NRGBA
AccentFg color.NRGBA
OK color.NRGBA
Warn color.NRGBA
Fail color.NRGBA
Info color.NRGBA
// Series colours for the latency chart, in assignment order. They are
// distinguishable at 2px stroke width and stay distinct in both themes.
Series []color.NRGBA
// Scrim dims the app behind the loading overlay.
Scrim color.NRGBA
}
func rgb(v uint32) color.NRGBA {
return color.NRGBA{R: uint8(v >> 16), G: uint8(v >> 8), B: uint8(v), A: 0xFF}
}
// DarkPalette is the default. The app is a diagnostic tool that people leave
// open in the background, so it defaults to the low-glare theme.
func DarkPalette() Palette {
return Palette{
Bg: rgb(0x0E1116),
BgElevated: rgb(0x141922),
Surface: rgb(0x1A202B),
SurfaceHi: rgb(0x222A38),
Border: rgb(0x252E3B),
BorderHi: rgb(0x364153),
TextPri: rgb(0xE7EBF3),
TextSec: rgb(0x9AA4B8),
TextDim: rgb(0x69738A),
Accent: rgb(0x4C8DFF),
AccentDim: rgb(0x27447A),
AccentFg: rgb(0xFFFFFF),
OK: rgb(0x3DCE87),
Warn: rgb(0xF0A93B),
Fail: rgb(0xFF6B6B),
Info: rgb(0x8B9BFF),
Series: []color.NRGBA{
rgb(0x4C8DFF), rgb(0x2DD4BF), rgb(0xA78BFA), rgb(0xFBBF24),
rgb(0xF472B6), rgb(0xA3E635), rgb(0x38BDF8), rgb(0xFB923C),
},
Scrim: color.NRGBA{R: 0x08, G: 0x0A, B: 0x0E, A: 0xC4},
}
}
// LightPalette mirrors the dark one for people working in bright rooms.
func LightPalette() Palette {
return Palette{
Bg: rgb(0xF6F7F9),
BgElevated: rgb(0xFFFFFF),
Surface: rgb(0xFFFFFF),
SurfaceHi: rgb(0xF0F2F6),
Border: rgb(0xE3E7ED),
BorderHi: rgb(0xCFD5DE),
TextPri: rgb(0x111826),
TextSec: rgb(0x4A5568),
TextDim: rgb(0x818C9E),
Accent: rgb(0x2563EB),
AccentDim: rgb(0xBFD3FA),
AccentFg: rgb(0xFFFFFF),
OK: rgb(0x0F9D58),
Warn: rgb(0xC77700),
Fail: rgb(0xD93636),
Info: rgb(0x4F5DD1),
Series: []color.NRGBA{
rgb(0x2563EB), rgb(0x0D9488), rgb(0x7C3AED), rgb(0xD97706),
rgb(0xDB2777), rgb(0x65A30D), rgb(0x0284C7), rgb(0xEA580C),
},
Scrim: color.NRGBA{R: 0x1A, G: 0x1F, B: 0x28, A: 0xB8},
}
}
// Theme bundles the Gio material theme with this app's design tokens.
type Theme struct {
*material.Theme
P Palette
Dark bool
// Mono is the typeface used for addresses, ports and log lines, where
// column alignment matters more than typographic polish.
Mono font.Typeface
// HasCJK reports whether a font with Chinese coverage was found. When it
// is false the UI falls back to English labels rather than rendering
// tofu boxes.
HasCJK bool
// Lang selects the label set.
Lang Lang
}
// NewTheme builds a theme from a shaper and font collection produced by
// [LoadFonts].
func NewTheme(fonts *FontSet, dark bool) *Theme {
mt := material.NewTheme()
mt.Shaper = text.NewShaper(text.WithCollection(fonts.Collection))
mt.TextSize = SizeBody
mt.Face = fonts.UI
mt.FingerSize = 26
th := &Theme{
Theme: mt,
Dark: dark,
Mono: fonts.Mono,
HasCJK: fonts.HasCJK,
Lang: LangEN,
}
if fonts.HasCJK {
th.Lang = LangZH
}
th.SetDark(dark)
return th
}
// SetDark switches palettes and keeps the embedded material palette in sync so
// stock Gio widgets pick up the right colours too.
func (t *Theme) SetDark(dark bool) {
t.Dark = dark
if dark {
t.P = DarkPalette()
} else {
t.P = LightPalette()
}
t.Theme.Palette = material.Palette{
Bg: t.P.Bg,
Fg: t.P.TextPri,
ContrastBg: t.P.Accent,
ContrastFg: t.P.AccentFg,
}
}
// T looks up a localised string. It is a method on Theme so call sites stay
// short: th.T(K.Peers).
func (t *Theme) T(k Key) string { return Tr(t.Lang, k) }
// StatusColor maps a traffic-light verdict onto the palette.
func (t *Theme) StatusColor(s StatusLevel) color.NRGBA {
switch s {
case LevelOK:
return t.P.OK
case LevelWarn:
return t.P.Warn
case LevelFail:
return t.P.Fail
case LevelInfo:
return t.P.Info
default:
return t.P.TextDim
}
}
// StatusLevel is the UI-side severity, deliberately decoupled from
// netdiag.Status so widgets do not depend on the diagnostics package.
type StatusLevel int
const (
LevelNeutral StatusLevel = iota
LevelOK
LevelWarn
LevelFail
LevelInfo
)
// SeriesColor returns a stable chart colour for index i.
func (t *Theme) SeriesColor(i int) color.NRGBA {
if len(t.P.Series) == 0 {
return t.P.Accent
}
return t.P.Series[i%len(t.P.Series)]
}
// WithAlpha returns c with its alpha scaled by a (0..1).
func WithAlpha(c color.NRGBA, a float32) color.NRGBA {
if a < 0 {
a = 0
}
if a > 1 {
a = 1
}
c.A = uint8(float32(c.A) * a)
return c
}
// Mix blends a into b by t (0 returns a, 1 returns b).
func Mix(a, b color.NRGBA, t float32) color.NRGBA {
if t < 0 {
t = 0
}
if t > 1 {
t = 1
}
lerp := func(x, y uint8) uint8 { return uint8(float32(x) + (float32(y)-float32(x))*t) }
return color.NRGBA{R: lerp(a.R, b.R), G: lerp(a.G, b.G), B: lerp(a.B, b.B), A: lerp(a.A, b.A)}
}