diff --git a/go.mod b/go.mod index 6866e7c..b974c51 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 ( @@ -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 ) diff --git a/go.sum b/go.sum index be56ab7..66e70a9 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/color.go b/internal/config/color.go new file mode 100644 index 0000000..91227e1 --- /dev/null +++ b/internal/config/color.go @@ -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} +} diff --git a/internal/config/color_test.go b/internal/config/color_test.go new file mode 100644 index 0000000..0e6a478 --- /dev/null +++ b/internal/config/color_test.go @@ -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) + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 9510962..cebebe4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { diff --git a/internal/config/default.go b/internal/config/default.go index a0a1d7f..6183d22 100644 --- a/internal/config/default.go +++ b/internal/config/default.go @@ -3,6 +3,7 @@ package config import ( "log/slog" "path/filepath" + "runtime" "strings" "time" @@ -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: "↑", @@ -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() diff --git a/internal/config/load.go b/internal/config/load.go index 38b0d2d..a88a875 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -9,6 +9,7 @@ import ( "log/slog" "os" "path/filepath" + "slices" "time" "github.com/knadh/koanf/providers/file" @@ -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 { diff --git a/internal/dynamicicon/Inconsolata_Condensed-Black.ttf b/internal/dynamicicon/Inconsolata_Condensed-Black.ttf new file mode 100644 index 0000000..64a8d75 Binary files /dev/null and b/internal/dynamicicon/Inconsolata_Condensed-Black.ttf differ diff --git a/internal/dynamicicon/dynamicicon.go b/internal/dynamicicon/dynamicicon.go new file mode 100644 index 0000000..f40da2f --- /dev/null +++ b/internal/dynamicicon/dynamicicon.go @@ -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 +} diff --git a/internal/dynamicicon/encode.go b/internal/dynamicicon/encode.go new file mode 100644 index 0000000..8829efd --- /dev/null +++ b/internal/dynamicicon/encode.go @@ -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) +} diff --git a/internal/dynamicicon/encode_windows.go b/internal/dynamicicon/encode_windows.go new file mode 100644 index 0000000..a918b4e --- /dev/null +++ b/internal/dynamicicon/encode_windows.go @@ -0,0 +1,69 @@ +package dynamicicon + +import ( + "bytes" + "encoding/binary" + "image" + "image/png" + "io" +) + +type iconDir struct { + reserved uint16 + imageType uint16 + numImages uint16 +} + +type iconDirEntry struct { + imageWidth uint8 + imageHeight uint8 + numColors uint8 + reserved uint8 + colorPlanes uint16 + bitsPerPixel uint16 + sizeInBytes uint32 + offset uint32 +} + +func newIcondir() iconDir { + return iconDir{ + imageType: 1, + numImages: 1, + } +} + +func newIcondirentry() iconDirEntry { + return iconDirEntry{ + colorPlanes: 1, // windows is supposed to not mind 0 or 1, but other icon files seem to have 1 here + bitsPerPixel: 32, // can be 24 for bitmap or 24/32 for png. Set to 32 for now + offset: 22, // 6 iconDir + 16 iconDirEntry, next image will be this image size + 16 iconDirEntry, etc + } +} + +func encode(w io.Writer, img image.Image) error { + dir := newIcondir() + entry := newIcondirentry() + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return err + } + entry.sizeInBytes = uint32(buf.Len()) + + bounds := img.Bounds() + entry.imageWidth = uint8(bounds.Dx()) + entry.imageHeight = uint8(bounds.Dy()) + + if err := binary.Write(w, binary.LittleEndian, dir); err != nil { + return err + } + if err := binary.Write(w, binary.LittleEndian, entry); err != nil { + return err + } + + if _, err := buf.WriteTo(w); err != nil { + return err + } + + return nil +} diff --git a/internal/tray/items/preferences/dynamic_icon.go b/internal/tray/items/preferences/dynamic_icon.go new file mode 100644 index 0000000..9d3ef04 --- /dev/null +++ b/internal/tray/items/preferences/dynamic_icon.go @@ -0,0 +1,35 @@ +package preferences + +import ( + "fyne.io/systray" + "github.com/gabe565/nightscout-menu-bar/internal/config" +) + +func NewDynamicIcon(conf *config.Config, parent *systray.MenuItem) DynamicIcon { + item := DynamicIcon{config: conf} + item.MenuItem = parent.AddSubMenuItemCheckbox( + "Render dynamic icon", + "", + conf.DynamicIcon.Enabled, + ) + return item +} + +type DynamicIcon struct { + config *config.Config + *systray.MenuItem +} + +func (l DynamicIcon) Toggle() error { + if l.Checked() { + l.Uncheck() + } else { + l.Check() + } + + l.config.DynamicIcon.Enabled = l.Checked() + if err := l.config.Write(); err != nil { + return err + } + return nil +} diff --git a/internal/tray/items/preferences/preferences.go b/internal/tray/items/preferences/preferences.go index fac0f9d..9bd3e22 100644 --- a/internal/tray/items/preferences/preferences.go +++ b/internal/tray/items/preferences/preferences.go @@ -22,6 +22,7 @@ func New(conf *config.Config) Preferences { autostartEnabled, ) + dynamicIcon := NewDynamicIcon(conf, item) localFile := NewLocalFile(conf, item) return Preferences{ @@ -30,6 +31,7 @@ func New(conf *config.Config) Preferences { Token: token, Units: units, StartOnLogin: startOnLogin, + DynamicIcon: dynamicIcon, LocalFile: localFile, } } @@ -40,6 +42,7 @@ type Preferences struct { Token Token Units Units StartOnLogin *systray.MenuItem + DynamicIcon DynamicIcon LocalFile LocalFile } diff --git a/internal/tray/systray.go b/internal/tray/systray.go index 28fd4ed..57c960f 100644 --- a/internal/tray/systray.go +++ b/internal/tray/systray.go @@ -10,6 +10,7 @@ import ( "github.com/gabe565/nightscout-menu-bar/internal/assets" "github.com/gabe565/nightscout-menu-bar/internal/autostart" "github.com/gabe565/nightscout-menu-bar/internal/config" + "github.com/gabe565/nightscout-menu-bar/internal/dynamicicon" "github.com/gabe565/nightscout-menu-bar/internal/fetch" "github.com/gabe565/nightscout-menu-bar/internal/nightscout" "github.com/gabe565/nightscout-menu-bar/internal/ticker" @@ -33,6 +34,10 @@ func New() *Tray { t.ticker = ticker.New(t.config, t.bus) + if t.config.DynamicIcon.Enabled { + t.dynamicIcon = dynamicicon.New(t.config) + } + t.config.AddCallback(func() { t.bus <- ReloadConfigMsg{} }) @@ -40,10 +45,11 @@ func New() *Tray { } type Tray struct { - config *config.Config - ticker *ticker.Ticker - bus chan any - items items.Items + config *config.Config + ticker *ticker.Ticker + dynamicIcon *dynamicicon.DynamicIcon + bus chan any + items items.Items } func (t *Tray) Run(ctx context.Context) { @@ -62,9 +68,11 @@ func (t *Tray) Quit() { systray.Quit() } -func (t *Tray) onReady() { +func (t *Tray) onReady() { //nolint:gocyclo systray.SetTemplateIcon(assets.Nightscout, assets.Nightscout) - systray.SetTitle(t.config.Title) + if !t.config.DynamicIcon.Enabled { + systray.SetTitle(t.config.Title) + } systray.SetTooltip(t.config.Title) t.items = items.New(t.config) @@ -117,6 +125,19 @@ func (t *Tray) onReady() { if err := t.items.Preferences.LocalFile.Toggle(); err != nil { t.onError(err) } + case <-t.items.Preferences.DynamicIcon.ClickedCh: + if err := t.items.Preferences.DynamicIcon.Toggle(); err != nil { + t.onError(err) + } + if t.config.DynamicIcon.Enabled { + t.dynamicIcon = dynamicicon.New(t.config) + } else { + if t.dynamicIcon != nil { + t.dynamicIcon = nil + systray.SetTemplateIcon(assets.Nightscout, assets.Nightscout) + } + t.dynamicIcon = nil + } case <-t.items.Quit.ClickedCh: t.Quit() case msg := <-t.bus: @@ -126,7 +147,17 @@ func (t *Tray) onReady() { value := msg.String(t.config) slog.Debug("Updating reading", "value", value) - systray.SetTitle(value) + if t.dynamicIcon == nil { + systray.SetTitle(value) + } else { + systray.SetTitle("") + if icon, err := t.dynamicIcon.Generate(msg); err == nil { + systray.SetTemplateIcon(icon, icon) + } else { + systray.SetTitle(value) + t.onError(err) + } + } systray.SetTooltip(value) t.items.LastReading.SetTitle(value) diff --git a/test.png b/test.png new file mode 100644 index 0000000..eb713e8 Binary files /dev/null and b/test.png differ