Add folder support: keys that open sub-pages with a back key
A key with `folder: NAME` swaps the deck to the keys defined under `folders.NAME`. Every folder shows a back key (slot 0 by default, drawn with a built-in chevron) that pops back to the parent; folders nest. The daemon now runs one page at a time: per-key setup moved into setupKey(), openPage() draws a page under its own context, and page.stop() tears it down before the next page is drawn (and on exit, so device death no longer waits on uncancelled goroutines). streamdeck-ctl gains `key set -folder`, reports kind "folder", lists every folder in status, and refuses to `key press` a navigation-only key. The Omarchy grid shows a folder glyph. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ARLa4moGb154fzc9TevgV
This commit is contained in:
30
cmd/streamdeck/folder_test.go
Normal file
30
cmd/streamdeck/folder_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The built-in back arrow must leave the label band (bottom third) untouched
|
||||
// and actually draw something above it.
|
||||
func TestBackArrowLayout(t *testing.T) {
|
||||
img := backArrow(96, 96, color.White)
|
||||
lit := 0
|
||||
for y := 0; y < 64; y++ {
|
||||
for x := 0; x < 96; x++ {
|
||||
if r, _, _, _ := img.At(x, y).RGBA(); r > 0 {
|
||||
lit++
|
||||
}
|
||||
}
|
||||
}
|
||||
if lit < 200 {
|
||||
t.Errorf("arrow region has only %d lit pixels", lit)
|
||||
}
|
||||
for y := 72; y < 96; y++ {
|
||||
for x := 0; x < 96; x++ {
|
||||
if r, _, _, _ := img.At(x, y).RGBA(); r > 0 {
|
||||
t.Fatalf("label band pixel (%d,%d) is lit", x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -158,6 +159,12 @@ func main() {
|
||||
|
||||
// run initialises the deck and handles button presses until ctx is cancelled
|
||||
// or the device dies. Returns true if the device died, false if ctx was cancelled.
|
||||
//
|
||||
// The deck shows one page at a time: the root page (cfg.Keys) or a folder
|
||||
// (cfg.Folders[name]). Pressing a key with a `folder:` field pushes that folder;
|
||||
// pressing a folder's back key pops it. Each page owns its own goroutines
|
||||
// (animations, polls, live text) and is torn down completely before the next
|
||||
// page is drawn, so a folder's keys never fight the root page's for a slot.
|
||||
func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *modules.Registry) (deviceDied bool) {
|
||||
// Recover from any panic inside this goroutine tree.
|
||||
defer func() {
|
||||
@@ -173,141 +180,15 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
||||
if err := sd.SetBrightness(cfg.Brightness); err != nil {
|
||||
log.Printf("warn: brightness: %v", err)
|
||||
}
|
||||
for i := 0; i < sd.KeyCount(); i++ {
|
||||
if err := sd.ClearKey(i); err != nil {
|
||||
log.Printf("warn: clear key %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Track animation/poll goroutines so we can wait for them on exit.
|
||||
var wg sync.WaitGroup
|
||||
// Navigation stack of folder names; empty means the root page is showing.
|
||||
var stack []string
|
||||
current := openPage(ctx, sd, cfg, reg, "")
|
||||
defer func() { current.stop() }()
|
||||
|
||||
// triggers lets button presses immediately re-poll toggle keys.
|
||||
triggers := make(map[int]chan struct{})
|
||||
|
||||
for keyIdx, keyCfg := range cfg.Keys {
|
||||
// Resolve module-based commands to shell strings before any other logic.
|
||||
// keyCfg is a copy from the map, so we must write it back after modification.
|
||||
if keyCfg.Module != "" {
|
||||
resolved, err := reg.Resolve(keyCfg.Module, keyCfg.Function, keyCfg.Params)
|
||||
if err != nil {
|
||||
log.Printf("key %d: module: %v", keyIdx, err)
|
||||
continue
|
||||
}
|
||||
keyCfg.Command = resolved
|
||||
cfg.Keys[keyIdx] = keyCfg
|
||||
}
|
||||
if keyCfg.Poll != nil && keyCfg.Poll.Module != "" {
|
||||
resolved, err := reg.Resolve(keyCfg.Poll.Module, keyCfg.Poll.Function, keyCfg.Poll.Params)
|
||||
if err != nil {
|
||||
log.Printf("key %d: poll module: %v", keyIdx, err)
|
||||
continue
|
||||
}
|
||||
keyCfg.Poll.Command = resolved
|
||||
cfg.Keys[keyIdx] = keyCfg
|
||||
}
|
||||
|
||||
// Optional $(...) sugar → text_command.
|
||||
if keyCfg.TextCommand == "" {
|
||||
if inner, ok := unwrapCmdSubst(keyCfg.Text); ok {
|
||||
keyCfg.TextCommand = inner
|
||||
keyCfg.Text = ""
|
||||
cfg.Keys[keyIdx] = keyCfg
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic text key: a refresh goroutine owns this key's image. It also
|
||||
// handles an optional poll block (choosing icon_true/icon_false per tick),
|
||||
// so it must take precedence over the plain toggle path below.
|
||||
if keyCfg.TextCommand != "" {
|
||||
baseIcon := keyCfg.Icon
|
||||
if keyCfg.Poll != nil {
|
||||
baseIcon = keyCfg.IconTrue
|
||||
}
|
||||
if strings.ToLower(filepath.Ext(baseIcon)) == ".gif" ||
|
||||
strings.ToLower(filepath.Ext(keyCfg.IconFalse)) == ".gif" {
|
||||
log.Printf("key %d: text_command not supported with GIF icon — skipping refresh", keyIdx)
|
||||
continue
|
||||
}
|
||||
trigger := make(chan struct{}, 1)
|
||||
triggers[keyIdx] = trigger
|
||||
wg.Add(1)
|
||||
go func(idx int, kCfg config.KeyConfig, trig chan struct{}) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("panic in refreshTextKey %d: %v", idx, r)
|
||||
}
|
||||
}()
|
||||
refreshTextKey(ctx, sd, idx, kCfg, cfg.IconsDir, trig)
|
||||
}(keyIdx, keyCfg, trigger)
|
||||
continue
|
||||
}
|
||||
|
||||
// Toggle/status key: managed by a polling goroutine.
|
||||
if keyCfg.Poll != nil {
|
||||
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {
|
||||
log.Printf("key %d: toggle key requires icon_on and icon_off", keyIdx)
|
||||
continue
|
||||
}
|
||||
trigger := make(chan struct{}, 1)
|
||||
triggers[keyIdx] = trigger
|
||||
wg.Add(1)
|
||||
go func(idx int, kCfg config.KeyConfig, trig chan struct{}) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("panic in pollKey %d: %v", idx, r)
|
||||
}
|
||||
}()
|
||||
pollKey(ctx, sd, idx, kCfg, cfg.IconsDir, trig)
|
||||
}(keyIdx, keyCfg, trigger)
|
||||
continue
|
||||
}
|
||||
|
||||
// Regular key: load icon once.
|
||||
if keyCfg.Icon == "" {
|
||||
// No icon — render text-only key on a black background.
|
||||
if keyCfg.Text != "" {
|
||||
bg := image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight()))
|
||||
img := overlayText(bg, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
|
||||
if err := sd.SetKeyImage(keyIdx, img); err != nil {
|
||||
log.Printf("key %d: set image: %v", keyIdx, err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
iconPath := filepath.Join(cfg.IconsDir, keyCfg.Icon)
|
||||
|
||||
if strings.ToLower(filepath.Ext(keyCfg.Icon)) == ".gif" {
|
||||
frames, delays, err := loadGIF(sd, iconPath)
|
||||
if err != nil {
|
||||
log.Printf("key %d: load gif %q: %v", keyIdx, keyCfg.Icon, err)
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(idx int, f [][]byte, d []time.Duration) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("panic in animateKey %d: %v", idx, r)
|
||||
}
|
||||
}()
|
||||
animateKey(ctx, sd, idx, f, d)
|
||||
}(keyIdx, frames, delays)
|
||||
} else {
|
||||
img, err := loadImage(iconPath)
|
||||
if err != nil {
|
||||
log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err)
|
||||
continue
|
||||
}
|
||||
if keyCfg.Text != "" {
|
||||
img = overlayText(img, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
|
||||
}
|
||||
if err := sd.SetKeyImage(keyIdx, img); err != nil {
|
||||
log.Printf("key %d: set image: %v", keyIdx, err)
|
||||
}
|
||||
}
|
||||
showPage := func(name string) {
|
||||
current.stop()
|
||||
current = openPage(ctx, sd, cfg, reg, name)
|
||||
}
|
||||
|
||||
log.Printf("stream deck ready (%d keys)", sd.KeyCount())
|
||||
@@ -318,7 +199,6 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
wg.Wait()
|
||||
return false
|
||||
default:
|
||||
}
|
||||
@@ -328,8 +208,6 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
||||
consecutiveErrors++
|
||||
if consecutiveErrors >= 3 {
|
||||
log.Printf("device error (gave up after %d attempts): %v", consecutiveErrors, err)
|
||||
// Cancel animations, wait for them to stop, then signal device death.
|
||||
wg.Wait()
|
||||
return true
|
||||
}
|
||||
log.Printf("read error (%d/3): %v", consecutiveErrors, err)
|
||||
@@ -343,11 +221,26 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
||||
}
|
||||
|
||||
for i, pressed := range buttons {
|
||||
if pressed && !prev[i] {
|
||||
keyCfg, ok := cfg.Keys[i]
|
||||
if !ok || keyCfg.Command == "" {
|
||||
continue
|
||||
if !pressed || prev[i] {
|
||||
continue
|
||||
}
|
||||
|
||||
if i == current.backKey {
|
||||
stack = stack[:len(stack)-1]
|
||||
parent := ""
|
||||
if len(stack) > 0 {
|
||||
parent = stack[len(stack)-1]
|
||||
}
|
||||
log.Printf("key %d pressed → back to %s", i, pageName(parent))
|
||||
showPage(parent)
|
||||
break // this report belongs to the old page
|
||||
}
|
||||
|
||||
keyCfg, ok := current.keys[i]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if keyCfg.Command != "" {
|
||||
log.Printf("key %d pressed → %s", i, keyCfg.Command)
|
||||
go func(cmd string) {
|
||||
defer func() {
|
||||
@@ -357,21 +250,289 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
||||
}()
|
||||
runCommand(cmd)
|
||||
}(keyCfg.Command)
|
||||
}
|
||||
|
||||
// For toggle keys, signal the poll goroutine to re-check state
|
||||
// shortly after the command runs (gives the system time to update).
|
||||
if ch, ok := triggers[i]; ok {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
// For toggle keys, signal the poll goroutine to re-check state
|
||||
// shortly after the command runs (gives the system time to update).
|
||||
if ch, ok := current.triggers[i]; ok {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
if keyCfg.Folder != "" {
|
||||
if _, defined := cfg.Folders[keyCfg.Folder]; !defined {
|
||||
log.Printf("key %d: folder %q is not defined under folders:", i, keyCfg.Folder)
|
||||
continue
|
||||
}
|
||||
log.Printf("key %d pressed → open folder %q", i, keyCfg.Folder)
|
||||
stack = append(stack, keyCfg.Folder)
|
||||
showPage(keyCfg.Folder)
|
||||
break // this report belongs to the old page
|
||||
}
|
||||
}
|
||||
// Keys still held across a page swap stay "pressed" here, so the new
|
||||
// page sees no edge for them until they are physically released.
|
||||
prev = buttons
|
||||
}
|
||||
}
|
||||
|
||||
// pageName names a page for log lines.
|
||||
func pageName(folder string) string {
|
||||
if folder == "" {
|
||||
return "root page"
|
||||
}
|
||||
return fmt.Sprintf("folder %q", folder)
|
||||
}
|
||||
|
||||
// page is one screenful of keys and the goroutines keeping them drawn.
|
||||
type page struct {
|
||||
keys map[int]config.KeyConfig // module templates already resolved
|
||||
triggers map[int]chan struct{} // per-key re-poll channels
|
||||
backKey int // slot that navigates up, -1 on the root page
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// stop tears the page down: cancels its goroutines, waits for them, and blanks
|
||||
// every key so the next page starts from an empty deck.
|
||||
func (p *page) stop() {
|
||||
p.cancel()
|
||||
p.wg.Wait()
|
||||
}
|
||||
|
||||
// openPage clears the deck and draws the root page (folder == "") or the named
|
||||
// folder, starting whatever goroutines its keys need. Keys that fail to set up
|
||||
// are logged and skipped — one bad icon must not blank the whole page.
|
||||
func openPage(parent context.Context, sd *device.StreamDeck, cfg *config.Config, reg *modules.Registry, folder string) *page {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
p := &page{
|
||||
keys: map[int]config.KeyConfig{},
|
||||
triggers: map[int]chan struct{}{},
|
||||
backKey: -1,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
for i := 0; i < sd.KeyCount(); i++ {
|
||||
if err := sd.ClearKey(i); err != nil {
|
||||
log.Printf("warn: clear key %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
source := cfg.Keys
|
||||
if folder != "" {
|
||||
f := cfg.Folders[folder]
|
||||
source = f.Keys
|
||||
p.backKey = f.BackKey()
|
||||
if _, shadowed := source[p.backKey]; shadowed {
|
||||
log.Printf("folder %q: key %d is the back key — its own definition is ignored", folder, p.backKey)
|
||||
}
|
||||
drawBackKey(sd, p.backKey, f.Back, cfg.IconsDir)
|
||||
}
|
||||
|
||||
for keyIdx, keyCfg := range source {
|
||||
if keyIdx == p.backKey {
|
||||
continue
|
||||
}
|
||||
if keyCfg.Folder != "" {
|
||||
if _, defined := cfg.Folders[keyCfg.Folder]; !defined {
|
||||
log.Printf("key %d: folder %q is not defined under folders:", keyIdx, keyCfg.Folder)
|
||||
}
|
||||
}
|
||||
resolved, ok := setupKey(ctx, sd, p, keyIdx, keyCfg, cfg.IconsDir, reg)
|
||||
if ok {
|
||||
p.keys[keyIdx] = resolved
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// setupKey resolves module templates, draws the key's initial image, and starts
|
||||
// any goroutine the key needs (animation, poll, live text). It returns the key
|
||||
// config with commands resolved — the button loop dispatches from that copy.
|
||||
// ok is false when the key could not be set up at all.
|
||||
func setupKey(ctx context.Context, sd *device.StreamDeck, p *page, keyIdx int, keyCfg config.KeyConfig, iconsDir string, reg *modules.Registry) (resolved config.KeyConfig, ok bool) {
|
||||
// Resolve module-based commands to shell strings before any other logic.
|
||||
if keyCfg.Module != "" {
|
||||
cmd, err := reg.Resolve(keyCfg.Module, keyCfg.Function, keyCfg.Params)
|
||||
if err != nil {
|
||||
log.Printf("key %d: module: %v", keyIdx, err)
|
||||
return keyCfg, false
|
||||
}
|
||||
keyCfg.Command = cmd
|
||||
}
|
||||
if keyCfg.Poll != nil && keyCfg.Poll.Module != "" {
|
||||
cmd, err := reg.Resolve(keyCfg.Poll.Module, keyCfg.Poll.Function, keyCfg.Poll.Params)
|
||||
if err != nil {
|
||||
log.Printf("key %d: poll module: %v", keyIdx, err)
|
||||
return keyCfg, false
|
||||
}
|
||||
poll := *keyCfg.Poll // don't mutate the shared config
|
||||
poll.Command = cmd
|
||||
keyCfg.Poll = &poll
|
||||
}
|
||||
|
||||
// Optional $(...) sugar → text_command.
|
||||
if keyCfg.TextCommand == "" {
|
||||
if inner, ok := unwrapCmdSubst(keyCfg.Text); ok {
|
||||
keyCfg.TextCommand = inner
|
||||
keyCfg.Text = ""
|
||||
}
|
||||
}
|
||||
|
||||
spawn := func(name string, fn func()) {
|
||||
p.wg.Add(1)
|
||||
go func() {
|
||||
defer p.wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("panic in %s %d: %v", name, keyIdx, r)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
|
||||
// Dynamic text key: a refresh goroutine owns this key's image. It also
|
||||
// handles an optional poll block (choosing icon_true/icon_false per tick),
|
||||
// so it must take precedence over the plain toggle path below.
|
||||
if keyCfg.TextCommand != "" {
|
||||
baseIcon := keyCfg.Icon
|
||||
if keyCfg.Poll != nil {
|
||||
baseIcon = keyCfg.IconTrue
|
||||
}
|
||||
if strings.ToLower(filepath.Ext(baseIcon)) == ".gif" ||
|
||||
strings.ToLower(filepath.Ext(keyCfg.IconFalse)) == ".gif" {
|
||||
log.Printf("key %d: text_command not supported with GIF icon — skipping refresh", keyIdx)
|
||||
return keyCfg, true
|
||||
}
|
||||
trigger := make(chan struct{}, 1)
|
||||
p.triggers[keyIdx] = trigger
|
||||
kCfg := keyCfg
|
||||
spawn("refreshTextKey", func() { refreshTextKey(ctx, sd, keyIdx, kCfg, iconsDir, trigger) })
|
||||
return keyCfg, true
|
||||
}
|
||||
|
||||
// Toggle/status key: managed by a polling goroutine.
|
||||
if keyCfg.Poll != nil {
|
||||
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {
|
||||
log.Printf("key %d: toggle key requires icon_true and icon_false", keyIdx)
|
||||
return keyCfg, true
|
||||
}
|
||||
trigger := make(chan struct{}, 1)
|
||||
p.triggers[keyIdx] = trigger
|
||||
kCfg := keyCfg
|
||||
spawn("pollKey", func() { pollKey(ctx, sd, keyIdx, kCfg, iconsDir, trigger) })
|
||||
return keyCfg, true
|
||||
}
|
||||
|
||||
// Regular key: load icon once.
|
||||
if keyCfg.Icon == "" {
|
||||
// No icon — render text-only key on a black background.
|
||||
if keyCfg.Text != "" {
|
||||
bg := image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight()))
|
||||
img := overlayText(bg, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
|
||||
if err := sd.SetKeyImage(keyIdx, img); err != nil {
|
||||
log.Printf("key %d: set image: %v", keyIdx, err)
|
||||
}
|
||||
}
|
||||
return keyCfg, true
|
||||
}
|
||||
iconPath := filepath.Join(iconsDir, keyCfg.Icon)
|
||||
|
||||
if strings.ToLower(filepath.Ext(keyCfg.Icon)) == ".gif" {
|
||||
frames, delays, err := loadGIF(sd, iconPath)
|
||||
if err != nil {
|
||||
log.Printf("key %d: load gif %q: %v", keyIdx, keyCfg.Icon, err)
|
||||
return keyCfg, true
|
||||
}
|
||||
spawn("animateKey", func() { animateKey(ctx, sd, keyIdx, frames, delays) })
|
||||
return keyCfg, true
|
||||
}
|
||||
|
||||
img, err := loadImage(iconPath)
|
||||
if err != nil {
|
||||
log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err)
|
||||
return keyCfg, true
|
||||
}
|
||||
if keyCfg.Text != "" {
|
||||
img = overlayText(img, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
|
||||
}
|
||||
if err := sd.SetKeyImage(keyIdx, img); err != nil {
|
||||
log.Printf("key %d: set image: %v", keyIdx, err)
|
||||
}
|
||||
return keyCfg, true
|
||||
}
|
||||
|
||||
// drawBackKey renders a folder's back key: the configured icon if there is
|
||||
// one, otherwise a built-in chevron on black, with a "Back" label by default.
|
||||
func drawBackKey(sd *device.StreamDeck, keyIdx int, back config.BackConfig, iconsDir string) {
|
||||
if keyIdx < 0 || keyIdx >= sd.KeyCount() {
|
||||
log.Printf("back key %d is outside the deck (%d keys)", keyIdx, sd.KeyCount())
|
||||
return
|
||||
}
|
||||
var img image.Image
|
||||
if back.Icon != "" {
|
||||
loaded, err := loadImage(filepath.Join(iconsDir, back.Icon))
|
||||
if err != nil {
|
||||
log.Printf("back key %d: load icon %q: %v — using built-in arrow", keyIdx, back.Icon, err)
|
||||
} else {
|
||||
img = loaded
|
||||
}
|
||||
}
|
||||
if img == nil {
|
||||
img = backArrow(sd.ImageWidth(), sd.ImageHeight(), parseTextColor(back.TextColor))
|
||||
}
|
||||
text := back.Text
|
||||
if text == "" && back.Icon == "" {
|
||||
text = "Back"
|
||||
}
|
||||
if text != "" {
|
||||
img = overlayText(img, text, back.TextColor, sd.ImageWidth())
|
||||
}
|
||||
if err := sd.SetKeyImage(keyIdx, img); err != nil {
|
||||
log.Printf("back key %d: set image: %v", keyIdx, err)
|
||||
}
|
||||
}
|
||||
|
||||
// backArrow draws a left-pointing chevron in the upper part of a black key,
|
||||
// leaving the bottom clear for the label.
|
||||
func backArrow(w, h int, c color.Color) image.Image {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
draw.Draw(img, img.Bounds(), image.Black, image.Point{}, draw.Src)
|
||||
|
||||
fw, fh := float64(w), float64(h)
|
||||
tip := [2]float64{fw * 0.36, fh * 0.40}
|
||||
top := [2]float64{fw * 0.62, fh * 0.16}
|
||||
bottom := [2]float64{fw * 0.62, fh * 0.64}
|
||||
thickness := fw * 0.075
|
||||
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
px, py := float64(x)+0.5, float64(y)+0.5
|
||||
d := math.Min(distToSegment(px, py, top, tip), distToSegment(px, py, tip, bottom))
|
||||
if d <= thickness {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// distToSegment is the distance from point p to the segment a→b.
|
||||
func distToSegment(px, py float64, a, b [2]float64) float64 {
|
||||
dx, dy := b[0]-a[0], b[1]-a[1]
|
||||
lenSq := dx*dx + dy*dy
|
||||
t := 0.0
|
||||
if lenSq > 0 {
|
||||
t = ((px-a[0])*dx + (py-a[1])*dy) / lenSq
|
||||
t = math.Max(0, math.Min(1, t))
|
||||
}
|
||||
cx, cy := a[0]+t*dx, a[1]+t*dy
|
||||
return math.Hypot(px-cx, py-cy)
|
||||
}
|
||||
|
||||
// pollKey watches the state of a toggle key and keeps its icon up to date.
|
||||
// It polls on an interval and also re-polls when triggered (e.g. after a button press).
|
||||
func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg config.KeyConfig, iconsDir string, trigger <-chan struct{}) {
|
||||
|
||||
Reference in New Issue
Block a user