package main import ( "context" "encoding/json" "fmt" "image" "image/color" "image/draw" _ "image/gif" _ "image/jpeg" _ "image/png" "log/slog" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" "time" "unicode" "unicode/utf8" "github.com/disintegration/gift" "golang.org/x/image/font" "golang.org/x/image/font/gofont/goregular" "golang.org/x/image/font/opentype" "golang.org/x/image/math/fixed" "streamdeck-lets-go/internal/config" ) type keyState struct { cancel context.CancelFunc } type DisplayOutput struct { Text string `json:"text"` Background string `json:"background,omitempty"` TextColor string `json:"text_color,omitempty"` } type PageManager struct { mu sync.RWMutex pages map[string]*config.PageConfig active string deck *Deck keyStates map[int]*keyState displayOutputs map[int]*DisplayOutput displayMu sync.RWMutex defaultFont string showLabelBackground bool dynamicCancel context.CancelFunc dynamicKeys []config.KeyConfig dynamicKeysMu sync.RWMutex dynamicResults map[string][]config.KeyConfig } func NewPageManager(deck *Deck) *PageManager { return &PageManager{ pages: make(map[string]*config.PageConfig), deck: deck, keyStates: make(map[int]*keyState), displayOutputs: make(map[int]*DisplayOutput), defaultFont: "medium", dynamicResults: make(map[string][]config.KeyConfig), } } func (pm *PageManager) activeKeys() []config.KeyConfig { pm.mu.RLock() page := pm.pages[pm.active] pm.mu.RUnlock() if page == nil { return nil } pm.dynamicKeysMu.RLock() dyn := pm.dynamicKeys pm.dynamicKeysMu.RUnlock() if dyn == nil { return page.Keys } used := make(map[int]bool, len(page.Keys)) result := make([]config.KeyConfig, 0, len(page.Keys)+len(dyn)) for _, k := range page.Keys { used[k.Index] = true result = append(result, k) } for _, k := range dyn { if !used[k.Index] { result = append(result, k) } } return result } // GetEffectiveKeys returns merged (static + cached dynamic) keys for a page. // Used by the web UI to preview generated keys. func (pm *PageManager) GetEffectiveKeys(name string) []config.KeyConfig { pm.mu.RLock() page := pm.pages[name] pm.mu.RUnlock() if page == nil { return nil } pm.dynamicKeysMu.RLock() cached := pm.dynamicResults[name] pm.dynamicKeysMu.RUnlock() if cached == nil { return page.Keys } used := make(map[int]bool, len(page.Keys)) result := make([]config.KeyConfig, 0, len(page.Keys)+len(cached)) for _, k := range page.Keys { used[k.Index] = true result = append(result, k) } for _, k := range cached { if !used[k.Index] { result = append(result, k) } } return result } func (pm *PageManager) LoadPages(pages []config.PageConfig) { pm.mu.Lock() pm.pages = make(map[string]*config.PageConfig, len(pages)) for i := range pages { pm.pages[pages[i].Name] = &pages[i] } pm.mu.Unlock() pm.dynamicKeysMu.Lock() pm.dynamicResults = make(map[string][]config.KeyConfig) pm.dynamicKeysMu.Unlock() } func (pm *PageManager) ActivatePage(name string) error { pm.stopDynamicKeys() pm.mu.Lock() defer pm.mu.Unlock() page, ok := pm.pages[name] if !ok { return fmt.Errorf("page %q not found", name) } slog.Info("activating page", "name", name) pm.active = name pm.displayMu.Lock() pm.displayOutputs = make(map[int]*DisplayOutput) pm.displayMu.Unlock() if err := pm.deck.ClearAll(); err != nil { slog.Warn("clear all failed", "error", err) } if page.Background != "" { if img, err := loadImage(page.Background, 0, 0); err == nil { if err := pm.deck.FillPanel(img); err != nil { slog.Warn("fill panel failed", "error", err) } } else { slog.Warn("load background image", "path", page.Background, "error", err) } } keyByIndex := make(map[int]config.KeyConfig, len(page.Keys)) for _, k := range page.Keys { keyByIndex[k.Index] = k } for i := 0; i < pm.deck.NumKeys(); i++ { k, ok := keyByIndex[i] if !ok { if page.Background == "" { pm.deck.ClearKey(i) } continue } pm.renderKey(i, &k) } if page.DynamicKeys != nil { go func() { pm.startDynamicKeys(page.DynamicKeys) }() } return nil } func (pm *PageManager) ActivePageName() string { pm.mu.RLock() defer pm.mu.RUnlock() return pm.active } func (pm *PageManager) ActivePage() *config.PageConfig { pm.mu.RLock() defer pm.mu.RUnlock() return pm.pages[pm.active] } func (pm *PageManager) GetDisplayOutputs() map[int]*DisplayOutput { pm.displayMu.RLock() defer pm.displayMu.RUnlock() result := make(map[int]*DisplayOutput, len(pm.displayOutputs)) for k, v := range pm.displayOutputs { result[k] = v } return result } func (pm *PageManager) GetPage(name string) *config.PageConfig { pm.mu.RLock() defer pm.mu.RUnlock() return pm.pages[name] } func (pm *PageManager) RenderKey(keyIndex int) { for _, k := range pm.activeKeys() { if k.Index == keyIndex { pm.renderKey(keyIndex, &k) return } } } func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) { for _, k := range pm.activeKeys() { if k.Index == keyIndex && k.Display != nil { pm.renderKeyOutput(keyIndex, k.Display, output, nil) return } } } func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) { fontName := k.Font if fontName == "" { fontName = pm.defaultFont } fontSize := 18.0 if k.FontSize != nil { fontSize = *k.FontSize } faScale := 0.55 if k.IconScale != nil { faScale = *k.IconScale } if k.Icon != "" { img, err := loadImage(k.Icon, pm.deck.KeySize(), faScale) if err != nil { slog.Warn("load icon", "path", k.Icon, "error", err) pm.deck.FillColor(idx, 64, 64, 64) if k.Label != "" { pm.deck.WriteText(idx, k.Label, image.Black, fontName, fontSize) } return } if k.Background != "" { if bg, err := parseHexColor(k.Background); err == nil { img = applyBackground(img, bg) } else { slog.Warn("invalid background color", "value", k.Background, "error", err) } } onImgFontSize := fontSize if k.FontSize == nil { onImgFontSize = 16 } onImgFontName := fontName if k.Font == "" { onImgFontName = pm.defaultFont } if k.Label != "" { if err := pm.deck.WriteTextOnImage(idx, img, k.Label, onImgFontName, onImgFontSize, pm.showLabelBackground); err != nil { slog.Warn("write text on image", "error", err) pm.deck.FillImage(idx, img) } } else { if err := pm.deck.FillImage(idx, img); err != nil { slog.Warn("fill image", "error", err) } } } else if k.Label != "" { if k.Background != "" { bg, err := parseHexColor(k.Background) if err == nil { ks := pm.deck.KeySize() img := image.NewRGBA(image.Rect(0, 0, ks, ks)) draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src) if err := pm.deck.WriteTextOnImage(idx, img, k.Label, fontName, fontSize, pm.showLabelBackground); err != nil { slog.Warn("write text on image", "error", err) pm.deck.FillImage(idx, img) } return } slog.Warn("invalid background color", "value", k.Background, "error", err) } if err := pm.deck.WriteText(idx, k.Label, image.Black, fontName, fontSize); err != nil { slog.Warn("write text", "error", err) } } else if k.Background != "" { bg, err := parseHexColor(k.Background) if err == nil { pm.deck.FillColor(idx, bg.R, bg.G, bg.B) } else { slog.Warn("invalid background color", "value", k.Background, "error", err) } } } func (pm *PageManager) stopPeriodicKeys() { pm.stopDynamicKeys() pm.mu.Lock() defer pm.mu.Unlock() for _, ks := range pm.keyStates { ks.cancel() } pm.keyStates = make(map[int]*keyState) } func (pm *PageManager) startPeriodicKeys() { pm.mu.Lock() page := pm.pages[pm.active] if page == nil { pm.mu.Unlock() return } type displayKey struct { index int display *config.DisplayCfg ctx context.Context } var keys []displayKey for _, k := range page.Keys { if k.Display != nil && k.Display.Interval != "" { interval, err := time.ParseDuration(k.Display.Interval) if err != nil || interval < time.Second { continue } if _, exists := pm.keyStates[k.Index]; exists { continue } ctx, cancel := context.WithCancel(context.Background()) pm.keyStates[k.Index] = &keyState{cancel: cancel} keys = append(keys, displayKey{index: k.Index, display: k.Display, ctx: ctx}) } } dg := page.DynamicKeys pm.mu.Unlock() for _, dk := range keys { go pm.runDisplayKey(dk.index, dk.display, dk.ctx) } if dg != nil { pm.startDynamicKeys(dg) } } func (pm *PageManager) stopDynamicKeys() { pm.dynamicKeysMu.Lock() if pm.dynamicCancel != nil { pm.dynamicCancel() pm.dynamicCancel = nil } pm.dynamicKeys = nil pm.dynamicKeysMu.Unlock() } func (pm *PageManager) startDynamicKeys(dg *config.DynamicKeyGen) { ctx, cancel := context.WithCancel(context.Background()) pm.dynamicKeysMu.Lock() if pm.dynamicCancel != nil { pm.dynamicCancel() } pm.dynamicCancel = cancel pm.dynamicKeysMu.Unlock() numKeys := dg.MaxKeys if numKeys <= 0 { numKeys = pm.deck.NumKeys() } var interval time.Duration if dg.Interval != "" { interval, _ = time.ParseDuration(dg.Interval) } if interval == 0 { interval = 60 * time.Second } go pm.runDynamicKeyGenerator(ctx, dg, numKeys, interval) } func (pm *PageManager) runDynamicKeyGenerator(ctx context.Context, dg *config.DynamicKeyGen, numKeys int, interval time.Duration) { timeout := 15 * time.Second if dg.Timeout != "" { if t, err := time.ParseDuration(dg.Timeout); err == nil && t > 0 { timeout = t } } pm.updateDynamicKeys(dg, timeout, numKeys) ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ticker.C: pm.updateDynamicKeys(dg, timeout, numKeys) case <-ctx.Done(): return } } } func (pm *PageManager) updateDynamicKeys(dg *config.DynamicKeyGen, timeout time.Duration, numKeys int) { keys, err := execDynamicKeys(dg, timeout, numKeys) if err != nil { slog.Warn("dynamic_keys generator failed", "error", err) return } if len(keys) > numKeys { keys = keys[:numKeys] } pm.mu.RLock() pageName := pm.active pm.mu.RUnlock() pm.dynamicKeysMu.Lock() if pm.dynamicCancel == nil { pm.dynamicKeysMu.Unlock() return } pm.dynamicKeys = keys pm.dynamicResults[pageName] = keys pm.dynamicKeysMu.Unlock() slog.Info("dynamic_keys updated", "count", len(keys), "page", pageName) pm.rerenderActivePage() } func (pm *PageManager) rerenderActivePage() { pm.mu.RLock() bg := "" if page := pm.pages[pm.active]; page != nil { bg = page.Background } pm.mu.RUnlock() if bg != "" { if img, err := loadImage(bg, 0, 0); err == nil { pm.deck.FillPanel(img) } } keys := pm.activeKeys() keyByIndex := make(map[int]config.KeyConfig, len(keys)) for _, k := range keys { keyByIndex[k.Index] = k } for i := 0; i < pm.deck.NumKeys(); i++ { k, ok := keyByIndex[i] if !ok { if bg == "" { pm.deck.ClearKey(i) } continue } pm.renderKey(i, &k) } } func (pm *PageManager) runDisplayKey(idx int, d *config.DisplayCfg, ctx context.Context) { timeout := 30 * time.Second if d.Timeout != "" { if t, err := time.ParseDuration(d.Timeout); err == nil && t > 0 { timeout = t } } interval, _ := time.ParseDuration(d.Interval) output, execErr := execDisplayCapture(d, timeout) pm.renderKeyOutput(idx, d, output, execErr) ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ticker.C: output, execErr := execDisplayCapture(d, timeout) pm.renderKeyOutput(idx, d, output, execErr) case <-ctx.Done(): return } } } type displayFormat struct { Text string `json:"text"` Background string `json:"background,omitempty"` TextColor string `json:"text_color,omitempty"` } func looksLikeHex(s string) bool { if len(s) != 7 && len(s) != 9 { return false } if s[0] != '#' { return false } for _, c := range s[1:] { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { return false } } return true } func parseDisplayOutput(output string) (*DisplayOutput, color.Color, color.Color) { trimmed := strings.TrimSpace(output) if trimmed == "" { return &DisplayOutput{}, nil, nil } var do DisplayOutput var bgColor, fgColor color.Color if err := json.Unmarshal([]byte(trimmed), &do); err == nil && (do.Text != "" || do.Background != "") { if do.Background != "" { if c, err := parseHexColor(do.Background); err == nil { bgColor = c } } if do.TextColor != "" { if c, err := parseHexColor(do.TextColor); err == nil { fgColor = c } } do.Text = sanitizeText(do.Text) return &do, bgColor, fgColor } lines := strings.SplitN(trimmed, "\n", 2) if len(lines) > 0 { first := strings.TrimSpace(lines[0]) if looksLikeHex(first) { do.Background = first if c, err := parseHexColor(first); err == nil { bgColor = c } if len(lines) > 1 { do.Text = sanitizeText(lines[1]) } return &do, bgColor, fgColor } } do.Text = sanitizeText(trimmed) return &do, bgColor, fgColor } func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output string, execErr error) { var kc *config.KeyConfig for _, k := range pm.activeKeys() { if k.Index == idx { kCopy := k kc = &kCopy break } } if kc == nil { return } if execErr != nil { slog.Warn("display command failed", "key", idx, "error", execErr) } do, bgOverride, fgOverride := parseDisplayOutput(output) fg := fgOverride if fg == nil { fg = color.White } if do.Text == "" && bgOverride == nil { do = &DisplayOutput{} } pm.displayMu.Lock() pm.displayOutputs[idx] = do pm.displayMu.Unlock() maxLen := d.MaxLen if maxLen <= 0 { maxLen = 128 } if len(do.Text) > maxLen { do.Text = do.Text[:maxLen] } text := do.Text faScale := 0.55 if kc.IconScale != nil { faScale = *kc.IconScale } fontSize := 12.0 if kc.FontSize != nil { fontSize = *kc.FontSize } if kc.Icon != "" { img, err := loadImage(kc.Icon, pm.deck.KeySize(), faScale) if err != nil { slog.Warn("load icon for display", "path", kc.Icon, "error", err) var bg color.Color = color.Black if bgOverride != nil { bg = bgOverride } textImg := renderUnicodeText(text, fontSize, pm.deck.KeySize(), bg, fg) if err := pm.deck.FillImage(idx, textImg); err != nil { slog.Warn("fill image", "error", err) } return } if kc.Background != "" && bgOverride == nil { if bg, err := parseHexColor(kc.Background); err == nil { img = applyBackground(img, bg) } else { slog.Warn("invalid background color", "value", kc.Background, "error", err) } } else if bgOverride != nil { if rgba, ok := bgOverride.(color.RGBA); ok { img = applyBackground(img, rgba) } } composite := renderUnicodeTextOnImage(img, text, fontSize, pm.deck.KeySize(), fg, pm.showLabelBackground) if err := pm.deck.FillImage(idx, composite); err != nil { slog.Warn("fill image", "error", err) } return } var bg color.Color = color.Black if bgOverride != nil { bg = bgOverride } else if kc.Background != "" { if c, err := parseHexColor(kc.Background); err == nil { bg = c } } textImg := renderUnicodeText(text, fontSize, pm.deck.KeySize(), bg, fg) if err := pm.deck.FillImage(idx, textImg); err != nil { slog.Warn("fill image", "error", err) } } func (pm *PageManager) RefreshDisplayKey(idx int) { for _, k := range pm.activeKeys() { if k.Index == idx && k.Display != nil { timeout := 30 * time.Second if k.Display.Timeout != "" { if t, err := time.ParseDuration(k.Display.Timeout); err == nil && t > 0 { timeout = t } } output, err := execDisplayCapture(k.Display, timeout) pm.renderKeyOutput(idx, k.Display, output, err) return } } } var ( unicodeFontOnce sync.Once unicodeFontData []byte ) func loadUnicodeFontBytes() []byte { unicodeFontOnce.Do(func() { if data := fcRead(":charset=20BD"); data != nil { unicodeFontData = data return } if data := fcRead("mono"); data != nil { unicodeFontData = data return } if data := fcRead("sans"); data != nil { unicodeFontData = data return } unicodeFontData = goregular.TTF }) return unicodeFontData } var ( emojiFontOnce sync.Once emojiFontData []byte ) func loadEmojiFontBytes() []byte { emojiFontOnce.Do(func() { if data := fcRead("emoji"); data != nil { if _, err := opentype.Parse(data); err == nil { emojiFontData = data return } } emojiFontData = loadUnicodeFontBytes() }) return emojiFontData } func renderEmojiGlyph(r rune, size int, scale float64) image.Image { if img, ok := renderCBDTGlyph(r, size, scale); ok { return img } fontSize := float64(size) * scale data := loadEmojiFontBytes() fnt, err := opentype.Parse(data) if err != nil { return renderUnicodeText(string(r), fontSize, size, color.Black, color.White) } face, err := opentype.NewFace(fnt, &opentype.FaceOptions{ Size: fontSize, DPI: 72, Hinting: font.HintingFull, }) if err != nil { return renderUnicodeText(string(r), fontSize, size, color.Black, color.White) } defer face.Close() rgba := image.NewRGBA(image.Rect(0, 0, size, size)) adv := font.MeasureString(face, string(r)).Ceil() metrics := face.Metrics() height := metrics.Height.Ceil() x := (size - adv) / 2 if x < 1 { x = 1 } y := (size + height) / 2 if y < 1 { y = 1 } d := &font.Drawer{ Dst: rgba, Src: image.NewUniform(color.White), Face: face, Dot: fixed.P(x, y), } d.DrawString(string(r)) return rgba } func fcRead(pattern string) []byte { cmd := exec.Command("fc-match", "-f", "%{file}", pattern) out, err := cmd.Output() if err != nil { return nil } path := strings.TrimSpace(string(out)) if path == "" { return nil } data, err := os.ReadFile(path) if err != nil { return nil } return data } func parseDisplayFace(size float64) (font.Face, error) { f, err := opentype.Parse(loadUnicodeFontBytes()) if err != nil { return nil, err } return opentype.NewFace(f, &opentype.FaceOptions{ Size: size, DPI: 72, Hinting: font.HintingFull, }) } func renderUnicodeText(text string, fontSize float64, keySize int, bg, fg color.Color) *image.RGBA { rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize)) draw.Draw(rgba, rgba.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src) if text == "" || fg == nil { return rgba } face, err := parseDisplayFace(fontSize) if err != nil { return rgba } defer face.Close() textAdvance := font.MeasureString(face, text).Ceil() metrics := face.Metrics() height := metrics.Height.Ceil() x := (keySize - textAdvance) / 2 if x < 1 { x = 1 } y := (keySize + height) / 2 if y < 1 { y = 1 } d := &font.Drawer{ Dst: rgba, Src: &image.Uniform{fg}, Face: face, Dot: fixed.P(x, y), } d.DrawString(text) return rgba } func renderUnicodeTextOnImage(baseImg image.Image, text string, fontSize float64, keySize int, fg color.Color, showLabelBackground bool) *image.RGBA { g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling)) rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize)) g.Draw(rgba, baseImg) barHeight := 20 if keySize < 72 { barHeight = 16 } if showLabelBackground { barRect := image.Rect(0, keySize-barHeight, keySize, keySize) draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over) } if text == "" || fg == nil { return rgba } face, err := parseDisplayFace(fontSize) if err != nil { return rgba } defer face.Close() textAdvance := font.MeasureString(face, text).Ceil() metrics := face.Metrics() ascent := metrics.Ascent.Ceil() x := (keySize - textAdvance) / 2 if x < 1 { x = 1 } y := keySize - barHeight/2 + ascent/2 if y > keySize-2 { y = keySize - 2 } d := &font.Drawer{ Dst: rgba, Src: &image.Uniform{fg}, Face: face, Dot: fixed.P(x, y), } d.DrawString(text) return rgba } func sanitizeText(s string) string { text := strings.TrimSpace(s) if text == "" { return text } var b strings.Builder b.Grow(len(text)) inEscape := false for _, r := range text { if inEscape { if unicode.IsLetter(r) { inEscape = false } continue } if r == 0x1b { inEscape = true continue } if unicode.IsPrint(r) || r == '\n' { b.WriteRune(r) } } return strings.TrimSpace(b.String()) } func parseHexColor(s string) (color.RGBA, error) { if len(s) == 0 || s[0] != '#' { return color.RGBA{}, fmt.Errorf("invalid color format: %q", s) } raw := strings.TrimPrefix(s, "#") if len(raw) != 3 && len(raw) != 6 && len(raw) != 8 { return color.RGBA{}, fmt.Errorf("invalid color length: %q", s) } if len(raw) == 3 { raw = string([]byte{raw[0], raw[0], raw[1], raw[1], raw[2], raw[2]}) } n, err := strconv.ParseUint(raw, 16, 64) if err != nil { return color.RGBA{}, fmt.Errorf("invalid hex color: %q: %w", s, err) } switch len(raw) { case 6: return color.RGBA{ R: uint8(n >> 16), G: uint8(n >> 8), B: uint8(n), A: 255, }, nil case 8: return color.RGBA{ R: uint8(n >> 24), G: uint8(n >> 16), B: uint8(n >> 8), A: uint8(n), }, nil } return color.RGBA{}, fmt.Errorf("unexpected hex length: %d", len(raw)) } func applyBackground(img image.Image, bg color.RGBA) *image.RGBA { b := img.Bounds() rgba := image.NewRGBA(b) draw.Draw(rgba, b, &image.Uniform{bg}, image.Point{}, draw.Src) draw.Draw(rgba, b, img, image.Point{}, draw.Over) return rgba } var imageCache sync.Map func isEmojiRef(path string) bool { return strings.HasPrefix(path, "emoji:") } func parseEmojiRef(path string) (rune, error) { raw := strings.TrimPrefix(path, "emoji:") if raw == "" { return 0, fmt.Errorf("empty emoji ref") } if strings.HasPrefix(raw, ":") && strings.HasSuffix(raw, ":") && len(raw) > 2 { name := strings.ToLower(raw[1 : len(raw)-1]) if r, ok := emojiShortcodes[name]; ok { return r, nil } return 0, fmt.Errorf("unknown emoji shortcode: %s", name) } r, size := utf8.DecodeRuneInString(raw) if r == utf8.RuneError || size == 0 { return 0, fmt.Errorf("invalid emoji character") } return r, nil } func cacheKey(path string, targetSize int, faScale float64) string { return fmt.Sprintf("%s|%d|%.6f", path, targetSize, faScale) } func loadImage(path string, targetSize int, faScale float64) (image.Image, error) { key := cacheKey(path, targetSize, faScale) if cached, ok := imageCache.Load(key); ok { return cached.(image.Image), nil } if isFAIconRef(path) { style, name, err := parseFAIcon(path) if err != nil { return nil, err } img, err := renderFAGlyph(style, name, targetSize, faScale) if err != nil { return nil, err } imageCache.Store(key, img) return img, nil } if isEmojiRef(path) { r, err := parseEmojiRef(path) if err != nil { return nil, err } img := renderEmojiGlyph(r, targetSize, faScale) imageCache.Store(key, img) return img, nil } resolved := path if isSystemIconRef(path) { p, err := findSystemIcon(systemIconName(path), targetSize) if err != nil { return nil, err } resolved = p } else if !strings.HasPrefix(path, "/") { resolved = filepath.Join(configDir(), path) } if strings.HasSuffix(resolved, ".svg") { img, err := svgToPNG(resolved, targetSize, faScale) if err != nil { return nil, err } imageCache.Store(key, img) return img, nil } f, err := os.Open(resolved) if err != nil { return nil, fmt.Errorf("open: %w", err) } defer f.Close() img, _, err := image.Decode(f) if err != nil { return nil, fmt.Errorf("decode: %w", err) } // For raster images, apply scaling and centering when targetSize > 0 if targetSize > 0 && faScale > 0 { displaySize := int(float64(targetSize) * faScale) if displaySize > targetSize { displaySize = targetSize } if displaySize < 1 { displaySize = 1 } // Resize to displaySize preserving aspect ratio (crop to fill) if img.Bounds().Dx() != displaySize || img.Bounds().Dy() != displaySize { g := gift.New(gift.ResizeToFill(displaySize, displaySize, gift.LanczosResampling, gift.CenterAnchor)) scaled := image.NewRGBA(image.Rect(0, 0, displaySize, displaySize)) g.Draw(scaled, img) img = scaled } // Center on targetSize canvas if displaySize < targetSize { canvas := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize)) offX := (targetSize - displaySize) / 2 offY := (targetSize - displaySize) / 2 draw.Draw(canvas, image.Rect(offX, offY, offX+displaySize, offY+displaySize), img, image.Point{}, draw.Over) img = canvas } } imageCache.Store(key, img) return img, nil } var cachedConfigDir string func configDir() string { if cachedConfigDir != "" { return cachedConfigDir } home, err := os.UserHomeDir() if err != nil { cachedConfigDir = "." return cachedConfigDir } cachedConfigDir = filepath.Join(home, ".config", "streamdeck-lets-go") return cachedConfigDir }