Skip to content

Commit

Permalink
feat: Add dynamic icon generation (#79)
Browse files Browse the repository at this point in the history
* feat: Initial dynamic icon implementation

* chore: Better dynamic icon

* chore(config): Clean up OS-specific font default code

* fix(tray): Render title if dynamic icon fails to be generated

* chore(dynamicicon): Rename `robotoBold` to `defaultFont`

* chore(config): Tweak dynamic icon font size comment

* chore(config): Increase default dynamic icon font size

* fix(dynamicicon): Fix memory leak when reloading config

* chore(dynamicicon): Add consts for fixed width and height

* test(config): Fix failing tests

* fix(dynamicicon): Fix vertical height

* feat(dynamicicon): Automatically determine the max font size

* chore(dynamicicon): Add log when generating icon
  • Loading branch information
gabe565 authored Aug 18, 2024
1 parent fbd3a89 commit 796283f
Show file tree
Hide file tree
Showing 15 changed files with 453 additions and 17 deletions.
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
fyne.io/systray v1.11.0
github.com/Masterminds/sprig/v3 v3.2.3
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346
github.com/knadh/koanf/providers/file v1.1.0
github.com/knadh/koanf/providers/rawbytes v0.1.0
Expand All @@ -19,6 +20,7 @@ require (
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.9.0
golang.org/x/image v0.18.0
)

require (
Expand All @@ -44,7 +46,6 @@ require (
github.com/spf13/cast v1.3.1 // indirect
github.com/tj/assert v0.0.3 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/sys v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsM
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346 h1:Odeq5rB6OZSkib5gqTG+EM1iF0bUVjYYd33XB1ULv00=
Expand Down
67 changes: 67 additions & 0 deletions internal/config/color.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package config

import (
"bytes"
"errors"
"fmt"
"image/color"
"strconv"
)

var (
ErrMissingPrefix = errors.New(`hex code missing "#" prefix`)
ErrInvalidLength = errors.New("hex code should be 4 or 7 characters")
)

type HexColor color.RGBA

func (h HexColor) MarshalText() ([]byte, error) {
shorthand := h.R>>4 == h.R&0xF && h.G>>4 == h.G&0xF && h.B>>4 == h.B&0xF
if shorthand {
return []byte(fmt.Sprintf("#%x%x%x", h.R&0xF, h.G&0xF, h.B&0xF)), nil
}
return []byte(fmt.Sprintf("#%02x%02x%02x", h.R, h.G, h.B)), nil
}

func (h *HexColor) UnmarshalText(text []byte) error {
if !bytes.HasPrefix(text, []byte("#")) {
return ErrMissingPrefix
}
switch len(text) {
case 4, 7:
default:
return ErrInvalidLength
}

parsed, err := strconv.ParseUint(string(text[1:]), 16, 32)
if err != nil {
return err
}

if parsed > 0xFFF {
h.R = uint8(parsed >> 16 & 0xFF)
h.G = uint8(parsed >> 8 & 0xFF)
h.B = uint8(parsed & 0xFF)
} else {
h.R = uint8(parsed >> 8 & 0xF)
h.R |= h.R << 4
h.G = uint8(parsed >> 4 & 0xF)
h.G |= h.G << 4
h.B = uint8(parsed & 0xF)
h.B |= h.B << 4
}
h.A = 0xFF
return nil
}

func (h HexColor) RGBA() color.RGBA {
return color.RGBA(h)
}

func White() HexColor {
return HexColor{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}
}

func Black() HexColor {
return HexColor{A: 0xFF}
}
69 changes: 69 additions & 0 deletions internal/config/color_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package config

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestHexColor_MarshalText(t *testing.T) {
t.Parallel()
type fields struct {
HexColor HexColor
}
tests := []struct {
name string
fields fields
want []byte
wantErr require.ErrorAssertionFunc
}{
{"white", fields{HexColor{R: 0xFF, G: 0xFF, B: 0xFF}}, []byte("#fff"), require.NoError},
{"black", fields{HexColor{}}, []byte("#000"), require.NoError},
{"red", fields{HexColor{R: 0xFF}}, []byte("#f00"), require.NoError},
{"green", fields{HexColor{G: 0xFF}}, []byte("#0f0"), require.NoError},
{"blue", fields{HexColor{B: 0xFF}}, []byte("#00f"), require.NoError},
{"blue-gray", fields{HexColor{R: 0x60, G: 0x7D, B: 0x8B}}, []byte("#607d8b"), require.NoError},
{"increment", fields{HexColor{R: 1, G: 2, B: 3}}, []byte("#010203"), require.NoError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
h := tt.fields.HexColor
got, err := h.MarshalText()
tt.wantErr(t, err)
assert.Equal(t, tt.want, got)
})
}
}

func TestHexColor_UnmarshalText(t *testing.T) {
t.Parallel()
type args struct {
text []byte
}
tests := []struct {
name string
args args
want HexColor
wantErr require.ErrorAssertionFunc
}{
{"white", args{[]byte("#fff")}, HexColor{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}, require.NoError},
{"black", args{[]byte("#000")}, HexColor{A: 0xFF}, require.NoError},
{"red", args{[]byte("#f00")}, HexColor{R: 0xFF, A: 0xFF}, require.NoError},
{"green", args{[]byte("#0f0")}, HexColor{G: 0xFF, A: 0xFF}, require.NoError},
{"blue", args{[]byte("#00f")}, HexColor{B: 0xFF, A: 0xFF}, require.NoError},
{"blue-gray", args{[]byte("#607d8b")}, HexColor{R: 0x60, G: 0x7D, B: 0x8B, A: 0xFF}, require.NoError},
{"missing-prefix", args{[]byte("fff")}, HexColor{}, require.Error},
{"too-long", args{[]byte("#fffffff")}, HexColor{}, require.Error},
{"too-short", args{[]byte("#fffff")}, HexColor{}, require.Error},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
h := HexColor{}
tt.wantErr(t, h.UnmarshalText(tt.args.text))
assert.Equal(t, tt.want, h)
})
}
}
24 changes: 16 additions & 8 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,22 @@ type Config struct {
Flags *pflag.FlagSet `toml:"-"`
callbacks []func() `toml:"-"`

Title string `toml:"title" comment:"Tray title."`
URL string `toml:"url" comment:"Nightscout URL. (required)"`
Token string `toml:"token" comment:"Nightscout token. Using an access token is recommended instead of the API secret."`
Units string `toml:"units" comment:"Blood sugar unit. (one of: mg/dL, mmol/L)"`
Arrows Arrows `toml:"arrows" comment:"Customize the arrows."`
LocalFile LocalFile `toml:"local-file" comment:"Enables writing the latest blood sugar to a local temporary file."`
Log Log `toml:"log" comment:"Log configuration"`
Advanced Advanced `toml:"advanced" comment:"Advanced settings."`
Title string `toml:"title" comment:"Tray title."`
URL string `toml:"url" comment:"Nightscout URL. (required)"`
Token string `toml:"token" comment:"Nightscout token. Using an access token is recommended instead of the API secret."`
Units string `toml:"units" comment:"Blood sugar unit. (one of: mg/dL, mmol/L)"`
DynamicIcon DynamicIcon `toml:"dynamic_icon" comment:"Makes the tray icon show the current blood sugar reading."`
Arrows Arrows `toml:"arrows" comment:"Customize the arrows."`
LocalFile LocalFile `toml:"local-file" comment:"Enables writing the latest blood sugar to a local temporary file."`
Log Log `toml:"log" comment:"Log configuration"`
Advanced Advanced `toml:"advanced" comment:"Advanced settings."`
}

type DynamicIcon struct {
Enabled bool `toml:"enabled"`
FontColor HexColor `toml:"font_color" comment:"Hex code used to render text."`
FontFile string `toml:"font_file" comment:"If left blank, an embedded font will be used."`
MaxFontSize float64 `toml:"max_font_size" comment:"Maximum font size in points."`
}

type Arrows struct {
Expand Down
13 changes: 13 additions & 0 deletions internal/config/default.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"log/slog"
"path/filepath"
"runtime"
"strings"
"time"

Expand All @@ -15,6 +16,11 @@ func New() *Config {
conf := &Config{
Title: "Nightscout",
Units: UnitsMgdl,
DynamicIcon: DynamicIcon{
Enabled: true,
FontColor: White(),
MaxFontSize: 40,
},
Arrows: Arrows{
DoubleUp: "⇈",
SingleUp: "↑",
Expand All @@ -40,6 +46,13 @@ func New() *Config {
},
}

switch runtime.GOOS {
case "darwin":
conf.DynamicIcon.Enabled = false
case "windows":
conf.DynamicIcon.FontColor = Black()
}

conf.Flags = flag.NewFlagSet("", flag.ContinueOnError)
conf.RegisterFlags()

Expand Down
8 changes: 7 additions & 1 deletion internal/config/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"log/slog"
"os"
"path/filepath"
"slices"
"time"

"github.com/knadh/koanf/providers/file"
Expand Down Expand Up @@ -145,8 +146,13 @@ func (conf *Config) Watch(ctx context.Context) error {
})
}

func (conf *Config) AddCallback(fn func()) {
func (conf *Config) AddCallback(fn func()) int {
conf.callbacks = append(conf.callbacks, fn)
return len(conf.callbacks) - 1
}

func (conf *Config) RemoveCallback(idx int) {
conf.callbacks = slices.Delete(conf.callbacks, idx, idx+1)
}

func migrateConfig(k *koanf.Koanf) error {
Expand Down
Binary file not shown.
119 changes: 119 additions & 0 deletions internal/dynamicicon/dynamicicon.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package dynamicicon

import (
"bytes"
_ "embed"
"errors"
"image"
"image/draw"
"log/slog"
"os"
"sync"

"github.com/gabe565/nightscout-menu-bar/internal/config"
"github.com/gabe565/nightscout-menu-bar/internal/nightscout"
"github.com/golang/freetype/truetype"
"golang.org/x/image/font"
"golang.org/x/image/math/fixed"
)

const (
width, height = 32, 32
widthF, heightF = fixed.Int26_6(width << 6), fixed.Int26_6(height << 6)
)

//go:embed Inconsolata_Condensed-Black.ttf
var defaultFont []byte

type DynamicIcon struct {
config *config.Config
mu sync.Mutex

font *truetype.Font
img *image.RGBA
}

func New(conf *config.Config) *DynamicIcon {
d := &DynamicIcon{
config: conf,
img: image.NewRGBA(image.Rectangle{Max: image.Point{X: width, Y: height}}),
}
return d
}

var ErrFontSize = errors.New("unable to determine the correct font size")

func (d *DynamicIcon) Generate(p *nightscout.Properties) ([]byte, error) {
d.mu.Lock()
defer d.mu.Unlock()

if d.font == nil {
var b []byte
if d.config.DynamicIcon.FontFile == "" {
b = defaultFont
} else {
var err error
if b, err = os.ReadFile(d.config.DynamicIcon.FontFile); err != nil {
return nil, err
}
}

f, err := truetype.Parse(b)
if err != nil {
return nil, err
}

d.font = f
}

bgnow := p.Bgnow.DisplayBg(d.config.Units)

var face font.Face
defer func() {
if face != nil {
_ = face.Close()
}
}()

drawer := &font.Drawer{
Dst: d.img,
Src: image.NewUniform(d.config.DynamicIcon.FontColor.RGBA()),
}

fontSize := d.config.DynamicIcon.MaxFontSize
for {
face = truetype.NewFace(d.font, &truetype.Options{
Size: fontSize,
})
drawer.Face = face

if textWidth := drawer.MeasureString(bgnow); textWidth <= widthF {
break
}

_ = face.Close()
if fontSize <= 1 {
return nil, ErrFontSize
}
fontSize -= 0.5
}

metrics := face.Metrics()

draw.Draw(d.img, d.img.Bounds(), image.Transparent, image.Point{}, draw.Src)
drawer.Dot.X = (widthF - drawer.MeasureString(bgnow)) / 2
drawer.Dot.Y = (heightF + metrics.Ascent - metrics.Descent) / 2
slog.Debug("Generating dynamic icon",
"font_size", fontSize,
"dot", drawer.Dot,
"value", bgnow,
)
drawer.DrawString(bgnow)

var buf bytes.Buffer
if err := encode(&buf, d.img); err != nil {
return nil, err
}

return buf.Bytes(), nil
}
13 changes: 13 additions & 0 deletions internal/dynamicicon/encode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build !windows

package dynamicicon

import (
"image"
"image/png"
"io"
)

func encode(w io.Writer, img image.Image) error {
return png.Encode(w, img)
}
Loading

0 comments on commit 796283f

Please sign in to comment.