Add dynamic_keys support to PageConfig — a contract-based plugin
system where any executable outputs a JSON array of KeyConfig to stdout.
The daemon runs the generator on a configurable interval and renders
the keys dynamically on the Stream Deck.
Core changes:
- config: DynamicKeyGen struct (command/script, interval, timeout, max_keys)
- config: full validation for dynamic_keys fields
- action: execDynamicKeys() with safe JSON parsing (stderr logged separately,
env vars STREAMDECK_KEY_COUNT / STREAMDECK_CONFIG_DIR)
- page: activeKeys() merges static + dynamic keys (static wins by index)
- page: startDynamicKeys / stopDynamicKeys lifecycle, per-page results cache
- page: rerenderActivePage() with deadlock-safe lock ordering
- daemon: event handler uses activeKeys() for dynamic key actions
Web UI:
- GET /api/pages returns effective_keys (merged static + cached dynamic)
- app.js: gridKeys uses effective_keys when available
- app.js: guard against editing dynamic keys through the UI
- index.html: "⚡ dynamic" badge + effective key count in page switcher
- polls /api/pages every 10s to keep the UI in sync
Image rendering:
- loadImage: gift.ResizeToFill instead of gift.Resize (preserves aspect
ratio, crops to fill — no more stretched cover art)
Example & docs:
- config.example.json: games page with dynamic_keys + back button
- README.md: comprehensive Dynamic Key Generators section (architecture
diagram, config reference, generator contract, merge behavior,
env vars, error handling, Python + Shell examples, tips)
1112 lines
24 KiB
Go
1112 lines
24 KiB
Go
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
|
|
}
|