refactor: restructure to standard Go project layout (cmd/ + internal/)
- Move entry point to cmd/streamdeck-lets-go/ - Split package main into internal packages: - internal/deck: hardware control (Deck, events, brightness) - internal/render: key rendering, icons, fonts, PageManager - internal/web: HTTP API + embedded SPA - internal/daemon: event loop, autoswitch, screensaver, actions - Add ConfigDir() to internal/config - Export cross-package API (Deck, PageManager, WebServer, etc.) - Move dist/arch/ → packaging/ with updated PKGBUILD/.SRCINFO - Add Makefile (build, test, vet, fmt, install) - Update README with new build commands and project structure - Delete unused media.go stub
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,212 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
_ "image/jpeg"
|
||||
|
||||
"github.com/disintegration/gift"
|
||||
)
|
||||
|
||||
type cbdtIndex struct {
|
||||
once sync.Once
|
||||
imgs []image.Image
|
||||
scale int
|
||||
}
|
||||
|
||||
var emojiCBDT cbdtIndex
|
||||
|
||||
func loadCBDT(data []byte) ([]byte, []byte, error) {
|
||||
if len(data) < 12 {
|
||||
return nil, nil, fmt.Errorf("font too small")
|
||||
}
|
||||
numTables := int(binary.BigEndian.Uint16(data[4:6]))
|
||||
off := 12
|
||||
var cmapData, cbdtData []byte
|
||||
for i := 0; i < numTables; i++ {
|
||||
if off+16 > len(data) {
|
||||
break
|
||||
}
|
||||
tag := string(data[off : off+4])
|
||||
tblOff := int(binary.BigEndian.Uint32(data[off+8 : off+12]))
|
||||
tblLen := int(binary.BigEndian.Uint32(data[off+12 : off+16]))
|
||||
switch tag {
|
||||
case "cmap":
|
||||
if tblOff+tblLen <= len(data) {
|
||||
cmapData = data[tblOff : tblOff+tblLen]
|
||||
}
|
||||
case "CBDT":
|
||||
if tblOff+tblLen <= len(data) {
|
||||
cbdtData = data[tblOff : tblOff+tblLen]
|
||||
}
|
||||
}
|
||||
off += 16
|
||||
}
|
||||
if cmapData == nil {
|
||||
return nil, nil, fmt.Errorf("cmap table not found")
|
||||
}
|
||||
if cbdtData == nil {
|
||||
return nil, nil, fmt.Errorf("CBDT table not found")
|
||||
}
|
||||
return cmapData, cbdtData, nil
|
||||
}
|
||||
|
||||
func cmapGlyphIndex(cmap []byte, r rune) (int, bool) {
|
||||
if len(cmap) < 4 {
|
||||
return 0, false
|
||||
}
|
||||
numTables := int(binary.BigEndian.Uint16(cmap[2:4]))
|
||||
for i := 0; i < numTables; i++ {
|
||||
boff := 4 + i*8
|
||||
if boff+8 > len(cmap) {
|
||||
break
|
||||
}
|
||||
platform := binary.BigEndian.Uint16(cmap[boff : boff+2])
|
||||
encoding := binary.BigEndian.Uint16(cmap[boff+2 : boff+4])
|
||||
if platform != 3 || encoding != 10 {
|
||||
continue
|
||||
}
|
||||
subOff := int(binary.BigEndian.Uint32(cmap[boff+4 : boff+8]))
|
||||
if subOff+2 > len(cmap) {
|
||||
continue
|
||||
}
|
||||
fmtRaw := binary.BigEndian.Uint16(cmap[subOff:])
|
||||
if fmtRaw != 12 {
|
||||
continue
|
||||
}
|
||||
return cmapFormat12(cmap[subOff:], r)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func cmapFormat12(data []byte, r rune) (int, bool) {
|
||||
if len(data) < 16 {
|
||||
return 0, false
|
||||
}
|
||||
numGroups := int(binary.BigEndian.Uint32(data[12:16]))
|
||||
cp := uint32(r)
|
||||
lo, hi := 0, numGroups-1
|
||||
for lo <= hi {
|
||||
mid := (lo + hi) / 2
|
||||
goff := 16 + mid*12
|
||||
if goff+12 > len(data) {
|
||||
break
|
||||
}
|
||||
start := binary.BigEndian.Uint32(data[goff:])
|
||||
end := binary.BigEndian.Uint32(data[goff+4:])
|
||||
startGlyph := binary.BigEndian.Uint32(data[goff+8:])
|
||||
if cp < start {
|
||||
hi = mid - 1
|
||||
} else if cp > end {
|
||||
lo = mid + 1
|
||||
} else {
|
||||
return int(startGlyph + (cp - start)), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func scanCBDTImages(cbdt []byte, firstGlyph int) ([]image.Image, error) {
|
||||
if len(cbdt) < 4 {
|
||||
return nil, fmt.Errorf("CBDT too small")
|
||||
}
|
||||
pos := 4
|
||||
var imgs []image.Image
|
||||
for pos < len(cbdt) {
|
||||
if pos+9 > len(cbdt) {
|
||||
break
|
||||
}
|
||||
dataSize := int(binary.BigEndian.Uint16(cbdt[pos+7 : pos+9]))
|
||||
pngOff := pos + 9
|
||||
if pngOff+dataSize > len(cbdt) {
|
||||
break
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(cbdt[pngOff : pngOff+dataSize]))
|
||||
if err != nil {
|
||||
pos += 9 + dataSize
|
||||
continue
|
||||
}
|
||||
imgs = append(imgs, img)
|
||||
pos += 9 + dataSize
|
||||
}
|
||||
if len(imgs) == 0 {
|
||||
return nil, fmt.Errorf("no valid PNG images found in CBDT")
|
||||
}
|
||||
slog.Debug("CBDT scan", "images", len(imgs), "firstGlyph", firstGlyph)
|
||||
return imgs, nil
|
||||
}
|
||||
|
||||
func renderCBDTGlyph(r rune, targetSize int, scale float64) (image.Image, bool) {
|
||||
fontData := loadColorEmojiFont()
|
||||
if fontData == nil {
|
||||
return nil, false
|
||||
}
|
||||
cmap, cbdt, err := loadCBDT(fontData)
|
||||
if err != nil {
|
||||
slog.Debug("CBDT load", "error", err)
|
||||
return nil, false
|
||||
}
|
||||
gid, ok := cmapGlyphIndex(cmap, r)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
emojiCBDT.once.Do(func() {
|
||||
imgs, err := scanCBDTImages(cbdt, 4)
|
||||
if err != nil {
|
||||
slog.Warn("CBDT scan", "error", err)
|
||||
return
|
||||
}
|
||||
emojiCBDT.imgs = imgs
|
||||
})
|
||||
if emojiCBDT.imgs == nil {
|
||||
return nil, false
|
||||
}
|
||||
idx := gid - 5
|
||||
if idx < 0 || idx >= len(emojiCBDT.imgs) {
|
||||
return nil, false
|
||||
}
|
||||
raw := emojiCBDT.imgs[idx]
|
||||
|
||||
displaySize := int(float64(targetSize) * scale)
|
||||
if displaySize > targetSize {
|
||||
displaySize = targetSize
|
||||
}
|
||||
if displaySize < 1 {
|
||||
displaySize = 1
|
||||
}
|
||||
|
||||
scaled := raw
|
||||
if raw.Bounds().Dx() != displaySize || raw.Bounds().Dy() != displaySize {
|
||||
g := gift.New(gift.Resize(displaySize, displaySize, gift.LanczosResampling))
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, displaySize, displaySize))
|
||||
g.Draw(rgba, raw)
|
||||
scaled = rgba
|
||||
}
|
||||
|
||||
out := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize))
|
||||
|
||||
offX := (targetSize - scaled.Bounds().Dx()) / 2
|
||||
offY := (targetSize - scaled.Bounds().Dy()) / 2
|
||||
rect := image.Rect(offX, offY, offX+scaled.Bounds().Dx(), offY+scaled.Bounds().Dy())
|
||||
draw.Draw(out, rect, scaled, image.Point{}, draw.Over)
|
||||
|
||||
return out, true
|
||||
}
|
||||
|
||||
func scaleToTarget(img image.Image, targetSize int) image.Image {
|
||||
b := img.Bounds()
|
||||
if b.Dx() == targetSize && b.Dy() == targetSize {
|
||||
return img
|
||||
}
|
||||
g := gift.New(gift.Resize(targetSize, targetSize, gift.LanczosResampling))
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize))
|
||||
g.Draw(rgba, img)
|
||||
return rgba
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package render
|
||||
|
||||
import "sync"
|
||||
|
||||
var emojiShortcodes = map[string]rune{
|
||||
// media
|
||||
"play_pause": 0x25B6,
|
||||
"stop": 0x23F9,
|
||||
"record": 0x23FA,
|
||||
"eject": 0x23CF,
|
||||
"track_previous": 0x23EE,
|
||||
"track_next": 0x23ED,
|
||||
"fast_forward": 0x23E9,
|
||||
"rewind": 0x23EA,
|
||||
"shuffle": 0x1F500,
|
||||
"repeat": 0x1F501,
|
||||
"repeat_one": 0x1F502,
|
||||
|
||||
// volume
|
||||
"speaker": 0x1F50A,
|
||||
"mute": 0x1F507,
|
||||
"sound": 0x1F509,
|
||||
|
||||
// navigation
|
||||
"arrow_up": 0x2B06,
|
||||
"arrow_down": 0x2B07,
|
||||
"arrow_left": 0x2B05,
|
||||
"arrow_right": 0x27A1,
|
||||
"arrows_clockwise": 0x1F503,
|
||||
"arrows_counterclockwise": 0x1F504,
|
||||
|
||||
// status
|
||||
"check": 0x2705,
|
||||
"heavy_check_mark": 0x2714,
|
||||
"x": 0x274C,
|
||||
"heavy_multiplication_x": 0x2716,
|
||||
"warning": 0x26A0,
|
||||
"information_source": 0x2139,
|
||||
"question": 0x2753,
|
||||
"exclamation": 0x2757,
|
||||
"white_check_mark": 0x2705,
|
||||
"heavy_plus_sign": 0x2795,
|
||||
"heavy_minus_sign": 0x2796,
|
||||
"heavy_division_sign": 0x2797,
|
||||
|
||||
// actions
|
||||
"gear": 0x2699,
|
||||
"hammer": 0x1F528,
|
||||
"wrench": 0x1F527,
|
||||
"key": 0x1F511,
|
||||
"lock": 0x1F512,
|
||||
"unlocked": 0x1F513,
|
||||
"magnifying_glass": 0x1F50D,
|
||||
"home": 0x1F3E0,
|
||||
"bookmark": 0x1F516,
|
||||
"bell": 0x1F514,
|
||||
"clock": 0x1F550,
|
||||
"alarm_clock": 0x23F0,
|
||||
"hourglass": 0x231B,
|
||||
"calendar": 0x1F4C5,
|
||||
"envelope": 0x2709,
|
||||
"camera": 0x1F4F7,
|
||||
"video_camera": 0x1F4F9,
|
||||
"microphone": 0x1F3A4,
|
||||
"telephone": 0x260E,
|
||||
"phone": 0x1F4DE,
|
||||
"computer": 0x1F4BB,
|
||||
"laptop": 0x1F4BB,
|
||||
"folder": 0x1F4C1,
|
||||
"open_file_folder": 0x1F4C2,
|
||||
"clipboard": 0x1F4CB,
|
||||
"memo": 0x1F4DD,
|
||||
"pencil": 0x270F,
|
||||
"scissors": 0x2702,
|
||||
"link": 0x1F517,
|
||||
"paperclip": 0x1F4CE,
|
||||
"pushpin": 0x1F4CC,
|
||||
"trash": 0x1F5D1,
|
||||
"star": 0x2B50,
|
||||
"trophy": 0x1F3C6,
|
||||
"medal": 0x1F3C5,
|
||||
"target": 0x1F3AF,
|
||||
"dart": 0x1F3AF,
|
||||
|
||||
// objects
|
||||
"lightbulb": 0x1F4A1,
|
||||
"bulb": 0x1F4A1,
|
||||
"battery": 0x1F50B,
|
||||
"electric_plug": 0x1F50C,
|
||||
"rocket": 0x1F680,
|
||||
"airplane": 0x2708,
|
||||
"car": 0x1F697,
|
||||
"bicycle": 0x1F6B2,
|
||||
"headphones": 0x1F3A7,
|
||||
"gamepad": 0x1F3AE,
|
||||
"joystick": 0x1F579,
|
||||
"musical_note": 0x1F3B5,
|
||||
"notes": 0x1F3B6,
|
||||
"printer": 0x1F5A8,
|
||||
"keyboard": 0x2328,
|
||||
|
||||
// weather
|
||||
"sun": 0x2600,
|
||||
"sunny": 0x2600,
|
||||
"moon": 0x1F319,
|
||||
"cloud": 0x2601,
|
||||
"rainbow": 0x1F308,
|
||||
"fire": 0x1F525,
|
||||
"flame": 0x1F525,
|
||||
"zap": 0x26A1,
|
||||
"lightning": 0x26A1,
|
||||
"snowflake": 0x2744,
|
||||
"umbrella": 0x2602,
|
||||
|
||||
// hearts
|
||||
"heart": 0x2764,
|
||||
"yellow_heart": 0x1F49B,
|
||||
"green_heart": 0x1F49A,
|
||||
"blue_heart": 0x1F499,
|
||||
"purple_heart": 0x1F49C,
|
||||
"black_heart": 0x1F5A4,
|
||||
"broken_heart": 0x1F494,
|
||||
"two_hearts": 0x1F495,
|
||||
"sparkling_heart": 0x1F496,
|
||||
"heartpulse": 0x1F497,
|
||||
"heart_beat": 0x1F493,
|
||||
"revolving_hearts": 0x1F49E,
|
||||
"cupid": 0x1F498,
|
||||
"gift_heart": 0x1F49D,
|
||||
|
||||
// faces
|
||||
"smile": 0x1F600,
|
||||
"smiley": 0x1F603,
|
||||
"grinning": 0x1F604,
|
||||
"blush": 0x1F60A,
|
||||
"wink": 0x1F609,
|
||||
"heart_eyes": 0x1F60D,
|
||||
"kissing_heart": 0x1F618,
|
||||
"kissing": 0x1F617,
|
||||
"smirk": 0x1F60F,
|
||||
"stuck_out_tongue": 0x1F61B,
|
||||
"stuck_out_tongue_winking_eye": 0x1F61C,
|
||||
"sunglasses": 0x1F60E,
|
||||
"innocent": 0x1F607,
|
||||
"neutral_face": 0x1F610,
|
||||
"expressionless": 0x1F611,
|
||||
"thinking": 0x1F914,
|
||||
"confused": 0x1F615,
|
||||
"worried": 0x1F61F,
|
||||
"frown": 0x1F641,
|
||||
"persevere": 0x1F623,
|
||||
"tired": 0x1F62B,
|
||||
"weary": 0x1F629,
|
||||
"cry": 0x1F622,
|
||||
"sob": 0x1F62D,
|
||||
"sweat_smile": 0x1F605,
|
||||
"joy": 0x1F602,
|
||||
"relaxed": 0x263A,
|
||||
"angry": 0x1F620,
|
||||
"rage": 0x1F621,
|
||||
"skull": 0x1F480,
|
||||
"ghost": 0x1F47B,
|
||||
"robot": 0x1F916,
|
||||
"sleeping": 0x1F634,
|
||||
"sleep": 0x1F634,
|
||||
"zzz": 0x1F4A4,
|
||||
"dizzy": 0x1F4AB,
|
||||
"boom": 0x1F4A5,
|
||||
"collision": 0x1F4A5,
|
||||
"sweat_drops": 0x1F4A6,
|
||||
"dash": 0x1F4A8,
|
||||
"alien": 0x1F47D,
|
||||
"poop": 0x1F4A9,
|
||||
|
||||
// hands & gestures
|
||||
"thumbsup": 0x1F44D,
|
||||
"thumbsdown": 0x1F44E,
|
||||
"ok_hand": 0x1F44C,
|
||||
"wave": 0x1F44B,
|
||||
"clap": 0x1F44F,
|
||||
"open_hands": 0x1F450,
|
||||
"raised_hands": 0x1F64C,
|
||||
"pray": 0x1F64F,
|
||||
"muscle": 0x1F4AA,
|
||||
"point_up": 0x261D,
|
||||
"point_down": 0x1F447,
|
||||
"point_left": 0x1F448,
|
||||
"point_right": 0x1F449,
|
||||
"fist": 0x270A,
|
||||
"raised_hand": 0x270B,
|
||||
"v": 0x270C,
|
||||
"victory": 0x270C,
|
||||
"crossed_fingers": 0x1F91E,
|
||||
"writing_hand": 0x270D,
|
||||
"call_me": 0x1F919,
|
||||
"hand": 0x270B,
|
||||
}
|
||||
|
||||
var (
|
||||
emojiColorFontOnce sync.Once
|
||||
emojiColorFontData []byte
|
||||
)
|
||||
|
||||
func loadColorEmojiFont() []byte {
|
||||
emojiColorFontOnce.Do(func() {
|
||||
emojiColorFontData = fcRead("emoji")
|
||||
})
|
||||
return emojiColorFontData
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/opentype"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
func parseFAIcon(ref string) (faStyle, string, error) {
|
||||
if !strings.HasPrefix(ref, "fa") {
|
||||
return 0, "", fmt.Errorf("not a font awesome ref")
|
||||
}
|
||||
|
||||
var style faStyle
|
||||
var name string
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(ref, "fab:"):
|
||||
style = faBrands
|
||||
name = strings.TrimPrefix(ref, "fab:")
|
||||
case strings.HasPrefix(ref, "far:"):
|
||||
style = faRegular
|
||||
name = strings.TrimPrefix(ref, "far:")
|
||||
case strings.HasPrefix(ref, "fa:"):
|
||||
style = faSolid
|
||||
name = strings.TrimPrefix(ref, "fa:")
|
||||
default:
|
||||
return 0, "", fmt.Errorf("invalid font awesome ref: %s", ref)
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return 0, "", fmt.Errorf("empty icon name")
|
||||
}
|
||||
|
||||
return style, name, nil
|
||||
}
|
||||
|
||||
func faCodepoint(style faStyle, name string) (rune, error) {
|
||||
m, ok := faCodepoints[style]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown style")
|
||||
}
|
||||
|
||||
if cp, ok := m[name]; ok {
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
if cp, ok := m[normalizeFAName(name)]; ok {
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("icon %q not found", name)
|
||||
}
|
||||
|
||||
func normalizeFAName(name string) string {
|
||||
if len(name) == 0 {
|
||||
return name
|
||||
}
|
||||
parts := strings.Split(name, "-")
|
||||
for i, p := range parts {
|
||||
if len(p) > 0 {
|
||||
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||
}
|
||||
}
|
||||
camel := strings.Join(parts, "")
|
||||
return strings.ToLower(camel[:1]) + camel[1:]
|
||||
}
|
||||
|
||||
func faFontBytes(style faStyle) ([]byte, error) {
|
||||
return faFonts.ReadFile(style.otfPath())
|
||||
}
|
||||
|
||||
func loadFAFace(style faStyle, pointSize float64) (font.Face, error) {
|
||||
data, err := faFontBytes(style)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read font: %w", err)
|
||||
}
|
||||
|
||||
fnt, err := opentype.Parse(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse font: %w", err)
|
||||
}
|
||||
|
||||
face, err := opentype.NewFace(fnt, &opentype.FaceOptions{
|
||||
Size: pointSize,
|
||||
DPI: 72,
|
||||
Hinting: font.HintingFull,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new face: %w", err)
|
||||
}
|
||||
return face, nil
|
||||
}
|
||||
|
||||
func renderFAGlyph(style faStyle, name string, size int, scale float64) (image.Image, error) {
|
||||
cp, err := faCodepoint(style, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if scale <= 0 {
|
||||
scale = 0.55
|
||||
}
|
||||
fontSize := float64(size) * scale
|
||||
face, err := loadFAFace(style, fontSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer face.Close()
|
||||
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
|
||||
adv := font.MeasureString(face, string(cp)).Ceil()
|
||||
offX := (size - adv) / 2
|
||||
if offX < 0 {
|
||||
offX = 0
|
||||
}
|
||||
|
||||
metrics := face.Metrics()
|
||||
baselineY := (size + metrics.Ascent.Ceil() - metrics.Descent.Ceil()) / 2
|
||||
|
||||
d := font.Drawer{
|
||||
Dst: rgba,
|
||||
Src: image.NewUniform(color.White),
|
||||
Face: face,
|
||||
Dot: fixed.P(offX, baselineY),
|
||||
}
|
||||
d.DrawString(string(cp))
|
||||
|
||||
return rgba, nil
|
||||
}
|
||||
|
||||
func isFAIconRef(path string) bool {
|
||||
return strings.HasPrefix(path, "fa:") || strings.HasPrefix(path, "far:") || strings.HasPrefix(path, "fab:")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
//go:embed assets/Font_Awesome_7_BrandsRegular400.otf
|
||||
//go:embed assets/Font_Awesome_7_FreeRegular400.otf
|
||||
//go:embed assets/Font_Awesome_7_FreeSolid900.otf
|
||||
var faFonts embed.FS
|
||||
|
||||
type faStyle int
|
||||
|
||||
const (
|
||||
faSolid faStyle = iota
|
||||
faRegular
|
||||
faBrands
|
||||
)
|
||||
|
||||
func (s faStyle) otfPath() string {
|
||||
switch s {
|
||||
case faSolid:
|
||||
return "assets/Font_Awesome_7_FreeSolid900.otf"
|
||||
case faRegular:
|
||||
return "assets/Font_Awesome_7_FreeRegular400.otf"
|
||||
case faBrands:
|
||||
return "assets/Font_Awesome_7_BrandsRegular400.otf"
|
||||
default:
|
||||
return "assets/Font_Awesome_7_FreeSolid900.otf"
|
||||
}
|
||||
}
|
||||
|
||||
var faCodepoints map[faStyle]map[string]rune
|
||||
|
||||
func init() {
|
||||
faCodepoints = make(map[faStyle]map[string]rune)
|
||||
faCodepoints[faSolid] = buildFAMap(fa7Icons)
|
||||
faCodepoints[faRegular] = buildFAMap(fa7Icons)
|
||||
faCodepoints[faBrands] = buildFAMap(fa7BrandsIcons)
|
||||
}
|
||||
|
||||
func buildFAMap(src map[string]string) map[string]rune {
|
||||
m := make(map[string]rune, len(src))
|
||||
for name, cp := range src {
|
||||
r, _ := utf8.DecodeRuneInString(cp)
|
||||
camel := camelToKebab(name)
|
||||
m[name] = r
|
||||
m[camel] = r
|
||||
if lower := toLower(name); lower != name {
|
||||
m[lower] = r
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func camelToKebab(s string) string {
|
||||
var out []byte
|
||||
for i, r := range s {
|
||||
if unicode.IsUpper(r) && i > 0 {
|
||||
out = append(out, '-')
|
||||
}
|
||||
out = append(out, byte(unicode.ToLower(r)))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func toLower(s string) string {
|
||||
return string(unicode.ToLower(rune(s[0]))) + s[1:]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func iconThemeDirs() []string {
|
||||
dirs := []string{}
|
||||
|
||||
if home := os.Getenv("HOME"); home != "" {
|
||||
dirs = append(dirs, filepath.Join(home, ".local/share/icons"))
|
||||
}
|
||||
|
||||
xdgDirs := os.Getenv("XDG_DATA_DIRS")
|
||||
if xdgDirs == "" {
|
||||
xdgDirs = "/usr/local/share:/usr/share"
|
||||
}
|
||||
for _, d := range filepath.SplitList(xdgDirs) {
|
||||
dirs = append(dirs, filepath.Join(d, "icons"))
|
||||
}
|
||||
|
||||
return uniquePaths(dirs)
|
||||
}
|
||||
|
||||
func uniquePaths(paths []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
res := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
if !seen[p] {
|
||||
seen[p] = true
|
||||
res = append(res, p)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func preferredThemes() []string {
|
||||
theme := detectGtkTheme()
|
||||
if theme != "" {
|
||||
return []string{theme, "hicolor", "Adwaita", "Papirus", "Humanity", "breeze", "gnome"}
|
||||
}
|
||||
return []string{"hicolor", "Adwaita", "Papirus", "Humanity", "breeze", "gnome"}
|
||||
}
|
||||
|
||||
func detectGtkTheme() string {
|
||||
data, err := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".config/gtk-4.0/settings.ini"))
|
||||
if err != nil {
|
||||
data, err = os.ReadFile(filepath.Join(os.Getenv("HOME"), ".config/gtk-3.0/settings.ini"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "gtk-icon-theme-name=") {
|
||||
return strings.TrimSpace(line[len("gtk-icon-theme-name="):])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sizeDirs(target int) []string {
|
||||
sizes := []int{}
|
||||
|
||||
for _, base := range []int{16, 22, 24, 32, 48, 64, 72, 96, 128, 192, 256} {
|
||||
sizes = append(sizes, base)
|
||||
}
|
||||
|
||||
sort.Slice(sizes, func(i, j int) bool {
|
||||
di := abs(sizes[i] - target)
|
||||
dj := abs(sizes[j] - target)
|
||||
if di != dj {
|
||||
return di < dj
|
||||
}
|
||||
return sizes[i] > sizes[j]
|
||||
})
|
||||
|
||||
seen := make(map[int]bool)
|
||||
res := make([]string, 0, len(sizes))
|
||||
for _, s := range sizes {
|
||||
if seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
res = append(res, fmt.Sprintf("%dx%d", s, s))
|
||||
}
|
||||
res = append(res, "scalable")
|
||||
return res
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func iconCategories() []string {
|
||||
return []string{"actions", "apps", "categories", "devices", "emblems", "mimetypes", "places", "status"}
|
||||
}
|
||||
|
||||
func findSystemIcon(name string, targetSize int) (string, error) {
|
||||
themes := preferredThemes()
|
||||
dirs := iconThemeDirs()
|
||||
sDirs := sizeDirs(targetSize)
|
||||
cats := iconCategories()
|
||||
|
||||
for _, base := range dirs {
|
||||
for _, theme := range themes {
|
||||
themeDir := filepath.Join(base, theme)
|
||||
if _, err := os.Stat(themeDir); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, sd := range sDirs {
|
||||
for _, cat := range cats {
|
||||
for _, ext := range []string{"png", "xpm"} {
|
||||
p := filepath.Join(themeDir, sd, cat, name+"."+ext)
|
||||
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, sd := range sDirs {
|
||||
for _, cat := range cats {
|
||||
p := filepath.Join(themeDir, sd, cat, name+".svg")
|
||||
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("system icon %q not found", name)
|
||||
}
|
||||
|
||||
func svgToPNG(svgPath string, targetSize int, scale float64) (image.Image, error) {
|
||||
if scale <= 0 {
|
||||
scale = 0.55
|
||||
}
|
||||
|
||||
renderSize := int(float64(targetSize) * scale)
|
||||
if renderSize > targetSize {
|
||||
renderSize = targetSize
|
||||
}
|
||||
if renderSize < 1 {
|
||||
renderSize = 1
|
||||
}
|
||||
|
||||
cmd := exec.Command("rsvg-convert",
|
||||
"-w", strconv.Itoa(renderSize),
|
||||
"-h", strconv.Itoa(renderSize),
|
||||
"-f", "png",
|
||||
svgPath,
|
||||
)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rsvg-convert: %w", err)
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode converted svg: %w", err)
|
||||
}
|
||||
|
||||
if renderSize < targetSize {
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize))
|
||||
offX := (targetSize - renderSize) / 2
|
||||
offY := (targetSize - renderSize) / 2
|
||||
draw.Draw(canvas, image.Rect(offX, offY, offX+renderSize, offY+renderSize), img, image.Point{}, draw.Over)
|
||||
img = canvas
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
var iconSizeCache sync.Map
|
||||
|
||||
func loadSystemIcon(name string, targetSize int) (string, error) {
|
||||
type cacheKey struct {
|
||||
name string
|
||||
size int
|
||||
}
|
||||
key := cacheKey{name, targetSize}
|
||||
if cached, ok := iconSizeCache.Load(key); ok {
|
||||
return cached.(string), nil
|
||||
}
|
||||
path, err := findSystemIcon(name, targetSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iconSizeCache.Store(key, path)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isSystemIconRef(path string) bool {
|
||||
return strings.HasPrefix(path, "@")
|
||||
}
|
||||
|
||||
func systemIconName(path string) string {
|
||||
return strings.TrimPrefix(path, "@")
|
||||
}
|
||||
|
||||
func parseSizeDir(dirName string) (int, bool) {
|
||||
parts := strings.SplitN(dirName, "x", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, false
|
||||
}
|
||||
s, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
|
||||
"github.com/disintegration/gift"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/math/fixed"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
)
|
||||
|
||||
func RenderKeyToImage(k *config.KeyConfig, keySize int, showLabelBackground bool) image.Image {
|
||||
if k == nil {
|
||||
return blankImage(keySize, color.RGBA{0, 0, 0, 255})
|
||||
}
|
||||
if k.Icon == "" && k.Label == "" {
|
||||
if k.Background != "" {
|
||||
if bg, err := parseHexColor(k.Background); err == nil {
|
||||
return blankImage(keySize, bg)
|
||||
}
|
||||
}
|
||||
return blankImage(keySize, color.RGBA{64, 64, 64, 255})
|
||||
}
|
||||
|
||||
faScale := 0.55
|
||||
if k.IconScale != nil {
|
||||
faScale = *k.IconScale
|
||||
}
|
||||
|
||||
if k.Icon != "" {
|
||||
img, err := LoadImage(k.Icon, keySize, faScale)
|
||||
if err != nil {
|
||||
img = blankImage(keySize, color.RGBA{64, 64, 64, 255})
|
||||
}
|
||||
if k.Background != "" {
|
||||
if bg, err := parseHexColor(k.Background); err == nil {
|
||||
img = applyBackground(img, bg)
|
||||
}
|
||||
}
|
||||
if k.Label != "" {
|
||||
fontSize := 10.0
|
||||
if k.FontSize != nil {
|
||||
fontSize = *k.FontSize
|
||||
}
|
||||
return composeImageWithLabel(img, k.Label, keySize, fontSize, showLabelBackground)
|
||||
}
|
||||
g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling))
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
|
||||
g.Draw(rgba, img)
|
||||
return rgba
|
||||
}
|
||||
|
||||
if k.Background != "" {
|
||||
if bg, err := parseHexColor(k.Background); err == nil {
|
||||
if k.Label != "" {
|
||||
fontSize := 12.0
|
||||
if k.FontSize != nil {
|
||||
fontSize = *k.FontSize
|
||||
}
|
||||
return renderUnicodeText(k.Label, fontSize, keySize, bg, color.White)
|
||||
}
|
||||
return blankImage(keySize, bg)
|
||||
}
|
||||
}
|
||||
fontSize := 12.0
|
||||
if k.FontSize != nil {
|
||||
fontSize = *k.FontSize
|
||||
}
|
||||
return renderTextImage(k.Label, keySize, fontSize)
|
||||
}
|
||||
|
||||
func blankImage(size int, c color.Color) image.Image {
|
||||
img := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{c}, image.Point{}, draw.Src)
|
||||
return img
|
||||
}
|
||||
|
||||
func composeImageWithLabel(src image.Image, text string, keySize int, fontSize float64, showLabelBackground bool) image.Image {
|
||||
g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling))
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
|
||||
g.Draw(rgba, src)
|
||||
|
||||
barHeight := 20
|
||||
if keySize < 72 {
|
||||
barHeight = 18
|
||||
}
|
||||
barRect := image.Rect(0, keySize-barHeight, keySize, keySize)
|
||||
if showLabelBackground {
|
||||
draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over)
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
face, err := parseDisplayFace(fontSize)
|
||||
if err != nil {
|
||||
face = basicfont.Face7x13
|
||||
} else {
|
||||
defer face.Close()
|
||||
}
|
||||
textW := font.MeasureString(face, text).Ceil()
|
||||
posX := (keySize - textW) / 2
|
||||
if posX < 2 {
|
||||
posX = 2
|
||||
}
|
||||
posY := keySize - barHeight/2 + face.Metrics().Height.Ceil()/2
|
||||
if posY >= keySize {
|
||||
posY = keySize - 2
|
||||
}
|
||||
|
||||
d := &font.Drawer{
|
||||
Dst: rgba,
|
||||
Src: image.NewUniform(color.White),
|
||||
Face: face,
|
||||
Dot: fixed.P(posX, posY),
|
||||
}
|
||||
d.DrawString(text)
|
||||
}
|
||||
|
||||
return rgba
|
||||
}
|
||||
|
||||
func renderTextImage(text string, keySize int, fontSize float64) image.Image {
|
||||
rgba := blankImage(keySize, color.RGBA{0, 0, 0, 0}).(*image.RGBA)
|
||||
|
||||
if text != "" {
|
||||
face, err := parseDisplayFace(fontSize)
|
||||
if err != nil {
|
||||
face = basicfont.Face7x13
|
||||
} else {
|
||||
defer face.Close()
|
||||
}
|
||||
textW := font.MeasureString(face, text).Ceil()
|
||||
posX := (keySize - textW) / 2
|
||||
if posX < 2 {
|
||||
posX = 2
|
||||
}
|
||||
posY := keySize/2 + face.Metrics().Height.Ceil()/2
|
||||
|
||||
d := &font.Drawer{
|
||||
Dst: rgba,
|
||||
Src: image.NewUniform(color.White),
|
||||
Face: face,
|
||||
Dot: fixed.P(posX, posY),
|
||||
}
|
||||
d.DrawString(text)
|
||||
}
|
||||
|
||||
return rgba
|
||||
}
|
||||
Reference in New Issue
Block a user