diff --git a/cmd/systray/logo.go b/cmd/systray/logo.go index ef8caca66..13fd4c564 100644 --- a/cmd/systray/logo.go +++ b/cmd/systray/logo.go @@ -8,6 +8,7 @@ package main import ( "bytes" "context" + "image" "image/color" "image/png" "sync" @@ -17,113 +18,190 @@ import ( "github.com/fogleman/gg" ) -// tsLogo represents the state of the 3x3 dot grid in the Tailscale logo. -// A 0 represents a gray dot, any other value is a white dot. -type tsLogo [9]byte +// tsLogo represents the Tailscale logo displayed as the systray icon. +type tsLogo struct { + // dots represents the state of the 3x3 dot grid in the logo. + // A 0 represents a gray dot, any other value is a white dot. + dots [9]byte + + // dotMask returns an image mask to be used when rendering the logo dots. + dotMask func(dc *gg.Context, borderUnits int, radius int) *image.Alpha + + // overlay is called after the dots are rendered to draw an additional overlay. + overlay func(dc *gg.Context, borderUnits int, radius int) +} var ( // disconnected is all gray dots - disconnected = tsLogo{ + disconnected = tsLogo{dots: [9]byte{ 0, 0, 0, 0, 0, 0, 0, 0, 0, - } + }} // connected is the normal Tailscale logo - connected = tsLogo{ + connected = tsLogo{dots: [9]byte{ 0, 0, 0, 1, 1, 1, 0, 1, 0, - } + }} // loading is a special tsLogo value that is not meant to be rendered directly, // but indicates that the loading animation should be shown. - loading = tsLogo{'l', 'o', 'a', 'd', 'i', 'n', 'g'} + loading = tsLogo{dots: [9]byte{'l', 'o', 'a', 'd', 'i', 'n', 'g'}} // loadingIcons are shown in sequence as an animated loading icon. loadingLogos = []tsLogo{ - { + {dots: [9]byte{ 0, 1, 1, 1, 0, 1, 0, 0, 1, - }, - { + }}, + {dots: [9]byte{ 0, 1, 1, 0, 0, 1, 0, 1, 0, - }, - { + }}, + {dots: [9]byte{ 0, 1, 1, 0, 0, 0, 0, 0, 1, - }, - { + }}, + {dots: [9]byte{ 0, 0, 1, 0, 1, 0, 0, 0, 0, - }, - { + }}, + {dots: [9]byte{ 0, 1, 0, 0, 0, 0, 0, 0, 0, - }, - { + }}, + {dots: [9]byte{ 0, 0, 0, 0, 0, 1, 0, 0, 0, - }, - { + }}, + {dots: [9]byte{ 0, 0, 0, 0, 0, 0, 0, 0, 0, - }, - { + }}, + {dots: [9]byte{ 0, 0, 1, 0, 0, 0, 0, 0, 0, - }, - { + }}, + {dots: [9]byte{ 0, 0, 0, 0, 0, 0, 1, 0, 0, - }, - { + }}, + {dots: [9]byte{ 0, 0, 0, 0, 0, 0, 1, 1, 0, - }, - { + }}, + {dots: [9]byte{ 0, 0, 0, 1, 0, 0, 1, 1, 0, - }, - { + }}, + {dots: [9]byte{ 0, 0, 0, 1, 1, 0, 0, 1, 0, - }, - { + }}, + {dots: [9]byte{ 0, 0, 0, 1, 1, 0, 0, 1, 1, - }, - { + }}, + {dots: [9]byte{ 0, 0, 0, 1, 1, 1, 0, 0, 1, - }, - { + }}, + {dots: [9]byte{ 0, 1, 0, 0, 1, 1, 1, 0, 1, + }}, + } + + // exitNodeOnline is the Tailscale logo with an additional arrow overlay in the corner. + exitNodeOnline = tsLogo{ + dots: [9]byte{ + 0, 0, 0, + 1, 1, 1, + 0, 1, 0, + }, + dotMask: func(dc *gg.Context, borderUnits int, radius int) *image.Alpha { + bu, r := float64(borderUnits), float64(radius) + + x1 := r * (bu + 3.5) + y := r * (bu + 7) + x2 := x1 + (r * 5) + + mc := gg.NewContext(dc.Width(), dc.Height()) + mc.DrawLine(x1, y, x2, y) + mc.DrawLine(x2-(1.5*r), y-(1.5*r), x2, y) + mc.DrawLine(x2-(1.5*r), y+(1.5*r), x2, y) + mc.SetLineWidth(r * 3) + mc.Stroke() + return mc.AsMask() + }, + overlay: func(dc *gg.Context, borderUnits int, radius int) { + bu, r := float64(borderUnits), float64(radius) + + x1 := r * (bu + 3.5) + y := r * (bu + 7) + x2 := x1 + (r * 5) + + dc.DrawLine(x1, y, x2, y) + dc.DrawLine(x2-(1.5*r), y-(1.5*r), x2, y) + dc.DrawLine(x2-(1.5*r), y+(1.5*r), x2, y) + dc.SetColor(fg) + dc.SetLineWidth(r) + dc.Stroke() + }, + } + + // exitNodeOffline is the Tailscale logo with a red "x" in the corner. + exitNodeOffline = tsLogo{ + dots: [9]byte{ + 0, 0, 0, + 1, 1, 1, + 0, 1, 0, + }, + dotMask: func(dc *gg.Context, borderUnits int, radius int) *image.Alpha { + bu, r := float64(borderUnits), float64(radius) + x := r * (bu + 3) + + mc := gg.NewContext(dc.Width(), dc.Height()) + mc.DrawRectangle(x, x, r*6, r*6) + mc.Fill() + return mc.AsMask() + }, + overlay: func(dc *gg.Context, borderUnits int, radius int) { + bu, r := float64(borderUnits), float64(radius) + + x1 := r * (bu + 4) + x2 := x1 + (r * 3.5) + dc.DrawLine(x1, x1, x2, x2) + dc.DrawLine(x1, x2, x2, x1) + dc.SetColor(red) + dc.SetLineWidth(r) + dc.Stroke() }, } ) var ( - black = color.NRGBA{0, 0, 0, 255} - white = color.NRGBA{255, 255, 255, 255} - gray = color.NRGBA{255, 255, 255, 102} + bg = color.NRGBA{0, 0, 0, 255} + fg = color.NRGBA{255, 255, 255, 255} + gray = color.NRGBA{255, 255, 255, 102} + red = color.NRGBA{229, 111, 74, 255} ) // render returns a PNG image of the logo. @@ -140,15 +218,21 @@ func (logo tsLogo) renderWithBorder(borderUnits int) *bytes.Buffer { dc := gg.NewContext(dim, dim) dc.DrawRectangle(0, 0, float64(dim), float64(dim)) - dc.SetColor(black) + dc.SetColor(bg) dc.Fill() + if logo.dotMask != nil { + mask := logo.dotMask(dc, borderUnits, radius) + dc.SetMask(mask) + dc.InvertMask() + } + for y := 0; y < 3; y++ { for x := 0; x < 3; x++ { px := (borderUnits + 1 + 3*x) * radius py := (borderUnits + 1 + 3*y) * radius - col := white - if logo[y*3+x] == 0 { + col := fg + if logo.dots[y*3+x] == 0 { col = gray } dc.DrawCircle(float64(px), float64(py), radius) @@ -157,6 +241,11 @@ func (logo tsLogo) renderWithBorder(borderUnits int) *bytes.Buffer { } } + if logo.overlay != nil { + dc.ResetClip() + logo.overlay(dc, borderUnits, radius) + } + b := bytes.NewBuffer(nil) png.Encode(b, dc.Image()) return b @@ -164,7 +253,7 @@ func (logo tsLogo) renderWithBorder(borderUnits int) *bytes.Buffer { // setAppIcon renders logo and sets it as the systray icon. func setAppIcon(icon tsLogo) { - if icon == loading { + if icon.dots == loading.dots { startLoadingAnimation() } else { stopLoadingAnimation()