-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add dynamic icon generation (#79)
* 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
Showing
15 changed files
with
453 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Binary file not shown.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
Oops, something went wrong.