commit f65ff75a43ca143c033605f029cc91ea6dc0651c Author: Maksim Totmin Date: Tue Jun 16 22:30:38 2026 +0700 Initial commit: Stream Deck daemon with web UI - Web-based key editor (Alpine.js SPA) - Actions: shell commands, scripts, built-in media/volume/brightness, page switching - Keyboard shortcut action (wtype/xdotool) - On-device display with periodic command output - Auto page switching (Hyprland, Sway, Niri, GNOME, KDE, X11) - Screensaver with idle detection - Config hot-reload - Multi-device support diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e98506 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +streamdeck-lets-go +*.test +*.out +*.exe +/images/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad8a72e --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# streamdeck-lets-go + +> A lightweight daemon for controlling Elgato Stream Deck devices with a built-in web UI. + +--- + +## Features + +- **Web-based editor** — Alpine.js SPA for managing pages, keys, icons, and actions +- **Multi-action support** — assign shell commands, scripts, page switching, media/volume/brightness controls, and **keyboard shortcuts** to any key +- **On-device display** — render icons, text labels, and periodic command output directly on Stream Deck keys +- **Auto page switching** — automatically change pages based on the focused window (Hyprland, Sway, Niri, GNOME, KDE, X11) +- **Screensaver** — dim or blank the deck after a configurable idle timeout +- **Hot-reload** — config changes via the web UI are applied live; manual edits to `config.json` are detected and reloaded automatically +- **No cloud, no Electron** — single Go binary with an embedded web frontend + +--- + +## Quick Start + +### Prerequisites + +- Linux with **CGO** enabled (required by `bearsh/hid` / libusb) +- Stream Deck connected via USB + +### Install from source + +```bash +go build -o streamdeck-lets-go . +``` + +### Run + +```bash +# Full daemon (deck hardware + web UI) +./streamdeck-lets-go daemon + +# Web UI only (config editor without deck) +./streamdeck-lets-go serve -addr :9090 + +# List connected devices +./streamdeck-lets-go discover +``` + +Open `http://localhost:9090` in your browser. + +--- + +## Configuration + +The config file is stored at `~/.config/streamdeck-lets-go/config.json`. You can edit it manually or through the web UI. + +### Key actions + +| Type | Field | Description | +|---|---|---| +| `command` | `command` | Run a shell command | +| `script` | `script` | Run an executable script | +| `builtin` | `builtin` | Built-in media/volume/brightness controls | +| `page` | `page` | Switch to another page | +| `keyboard` | `keys` | Send a keyboard shortcut (e.g. `ctrl+t`, `super+return`) | + +### Keyboard actions + +Sends a key combination to the currently focused window. + +| Platform | Tool required | +|---|---| +| Wayland | `wtype` | +| X11 | `xdotool` | + +The daemon warns on startup if the required tool is missing. + +### Built-in actions + +| Value | Action | +|---|---| +| `volume_up` / `volume_down` / `volume_mute` | PipeWire volume (via `wpctl`) | +| `brightness_up` / `brightness_down` | Display brightness (via `brightnessctl`) | +| `media_play_pause` / `media_next` / `media_prev` / `media_stop` | MPRIS media (via `playerctl`) | + +### Periodic display + +Any key can display the output of a command or script, updated on an interval. Use this for weather, system stats, calendar, etc. + +### Auto-switch rules + +Automatically change pages based on the focused window: + +```json +{ + "wm_class": "firefox", + "page": "browser" +} +``` + +Supported compositors: Hyprland, Sway, Niri, GNOME, KDE, X11. + +### Screensaver + +After a configurable idle period the deck dims or shows a custom image. + +--- + +## Building from source + +```bash +git clone git@git.totmin.ru:en2zmax/streamdeck-lets-go.git +cd streamdeck-lets-go +go build -o streamdeck-lets-go . +``` + +Dependencies: + +| Package | Purpose | +|---|---| +| `libusb-1.0` | HID communication via CGO | +| `librsvg` | SVG icon rendering (optional) | +| `fontconfig` | System font detection | + +--- + +## Supported devices + +| Model | PID | Keys | +|---|---|---| +| Stream Deck Original | `0x006d` | 15 | +| Stream Deck Mini | `0x0063` | 6 | +| Stream Deck XL | `0x006c` | 32 | +| Stream Deck MK.2 | `0x0080` | 15 | +| Stream Deck Pedal | `0x0086` | 3 | + +--- + +## License + +MIT diff --git a/action.go b/action.go new file mode 100644 index 0000000..c9aad41 --- /dev/null +++ b/action.go @@ -0,0 +1,311 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "strings" + "time" + + "streamdeck-lets-go/internal/config" +) + +func ExecuteAction(a *config.Action, deck *Deck, pm *PageManager) error { + if a == nil { + return nil + } + + slog.Debug("execute action", "type", a.Type) + + switch a.Type { + case "command": + return execCommand(a) + case "builtin": + return execBuiltin(a, deck, pm) + case "script": + return execScript(a) + case "page": + if pm != nil { + return pm.ActivatePage(a.Page) + } + return fmt.Errorf("page manager not available") + case "keyboard": + return execKeyboard(a) + default: + return fmt.Errorf("unknown action type: %s", a.Type) + } +} + +func execCommand(a *config.Action) error { + cmd := exec.Command("sh", "-c", a.Command) + + if a.Background { + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Start(); err != nil { + return fmt.Errorf("start command: %w", err) + } + go func() { + if err := cmd.Wait(); err != nil { + slog.Warn("command finished", "cmd", a.Command, "error", err) + } + }() + return nil + } + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("run command: %w", err) + } + return nil +} + +func execScript(a *config.Action) error { + cmd := exec.Command(a.Script) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("run script: %w", err) + } + return nil +} + +func execDisplayCapture(d *config.DisplayCfg, timeout time.Duration) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + var cmd *exec.Cmd + if d.Command != "" { + cmd = exec.CommandContext(ctx, "sh", "-c", d.Command) + } else { + cmd = exec.CommandContext(ctx, d.Script) + } + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + output := stdout.String() + if stderr.Len() > 0 { + if output != "" { + output += "\n" + } + output += stderr.String() + } + return output, err +} + +func execBuiltin(a *config.Action, deck *Deck, pm *PageManager) error { + parts := strings.SplitN(a.Builtin, ":", 2) + if len(parts) < 1 { + return fmt.Errorf("invalid builtin: %s", a.Builtin) + } + + category := parts[0] + action := "" + if len(parts) > 1 { + action = parts[1] + } + + slog.Debug("builtin", "category", category, "action", action) + + switch category { + case "media": + return mediaAction(action) + case "volume": + return volumeAction(action) + case "brightness": + return brightnessAction(action) + case "page": + if pm != nil { + return pageBuiltinAction(action, pm) + } + return fmt.Errorf("page manager not available") + case "deck": + return deckBuiltinAction(action, deck) + default: + return fmt.Errorf("unknown builtin category: %s", category) + } +} + +func mediaAction(action string) error { + var cmd string + switch action { + case "playpause": + cmd = "playerctl play-pause" + case "next": + cmd = "playerctl next" + case "prev": + cmd = "playerctl previous" + case "stop": + cmd = "playerctl stop" + default: + return fmt.Errorf("unknown media action: %s", action) + } + return exec.Command("sh", "-c", cmd).Run() +} + +func volumeAction(action string) error { + var cmd string + switch action { + case "up": + cmd = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+" + case "down": + cmd = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-" + case "mute": + cmd = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle" + default: + return fmt.Errorf("unknown volume action: %s", action) + } + return exec.Command("sh", "-c", cmd).Run() +} + +func brightnessAction(action string) error { + var cmd string + switch action { + case "up": + cmd = "brightnessctl s +10%" + case "down": + cmd = "brightnessctl s 10%-" + default: + return fmt.Errorf("unknown brightness action: %s", action) + } + return exec.Command("sh", "-c", cmd).Run() +} + +func pageBuiltinAction(action string, pm *PageManager) error { + switch action { + case "next": + pageNames := pm.PageNames() + current := pm.ActivePageName() + for i, name := range pageNames { + if name == current { + next := (i + 1) % len(pageNames) + return pm.ActivatePage(pageNames[next]) + } + } + if len(pageNames) > 0 { + return pm.ActivatePage(pageNames[0]) + } + case "prev": + pageNames := pm.PageNames() + current := pm.ActivePageName() + for i, name := range pageNames { + if name == current { + prev := (i - 1 + len(pageNames)) % len(pageNames) + return pm.ActivatePage(pageNames[prev]) + } + } + if len(pageNames) > 0 { + return pm.ActivatePage(pageNames[0]) + } + default: + return fmt.Errorf("unknown page action: %s", action) + } + return nil +} + +func deckBuiltinAction(action string, deck *Deck) error { + switch action { + case "brightness-up": + current := deck.Brightness() + next := current + 25 + if next > 100 { + next = 25 + } + return deck.SetBrightness(next) + case "brightness-down": + current := deck.Brightness() + next := current - 25 + if next < 25 { + next = 100 + } + return deck.SetBrightness(next) + default: + return fmt.Errorf("unknown deck action: %s", action) + } +} + +func keyboardTool() string { + if os.Getenv("WAYLAND_DISPLAY") != "" { + if _, err := exec.LookPath("wtype"); err == nil { + return "wtype" + } + return "" + } + if _, err := exec.LookPath("xdotool"); err == nil { + return "xdotool" + } + return "" +} + +func execKeyboard(a *config.Action) error { + tool := keyboardTool() + if tool == "" { + wayland := os.Getenv("WAYLAND_DISPLAY") != "" + if wayland { + return fmt.Errorf("keyboard action requires wtype — install it (e.g. apk add wtype) and restart") + } + return fmt.Errorf("keyboard action requires xdotool — install it (e.g. apk add xdotool) and restart") + } + + keys := strings.ToLower(strings.TrimSpace(a.Keys)) + if keys == "" { + return fmt.Errorf("keyboard action: keys is empty") + } + + parts := strings.Split(keys, "+") + if len(parts) == 0 { + return fmt.Errorf("keyboard action: invalid keys format %q", a.Keys) + } + + switch tool { + case "wtype": + return execWType(parts) + case "xdotool": + return execXDoTool(keys) + default: + return fmt.Errorf("keyboard action: unsupported tool %q", tool) + } +} + +func execWType(parts []string) error { + mainKey := parts[len(parts)-1] + mods := parts[:len(parts)-1] + + args := make([]string, 0, 2+len(mods)*2) + + for _, m := range mods { + args = append(args, "-M", m) + } + args = append(args, "-P", mainKey, "-p", mainKey) + for i := len(mods) - 1; i >= 0; i-- { + args = append(args, "-m", mods[i]) + } + + cmd := exec.Command("wtype", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func execXDoTool(keys string) error { + cmd := exec.Command("xdotool", "key", keys) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func (pm *PageManager) PageNames() []string { + pm.mu.RLock() + defer pm.mu.RUnlock() + names := make([]string, 0, len(pm.pages)) + for name := range pm.pages { + names = append(names, name) + } + return names +} diff --git a/assets/Font_Awesome_7_BrandsRegular400.otf b/assets/Font_Awesome_7_BrandsRegular400.otf new file mode 100644 index 0000000..08b8ce0 Binary files /dev/null and b/assets/Font_Awesome_7_BrandsRegular400.otf differ diff --git a/assets/Font_Awesome_7_FreeRegular400.otf b/assets/Font_Awesome_7_FreeRegular400.otf new file mode 100644 index 0000000..fb469d2 Binary files /dev/null and b/assets/Font_Awesome_7_FreeRegular400.otf differ diff --git a/assets/Font_Awesome_7_FreeSolid900.otf b/assets/Font_Awesome_7_FreeSolid900.otf new file mode 100644 index 0000000..25133ed Binary files /dev/null and b/assets/Font_Awesome_7_FreeSolid900.otf differ diff --git a/autoswitch.go b/autoswitch.go new file mode 100644 index 0000000..9b3625a --- /dev/null +++ b/autoswitch.go @@ -0,0 +1,451 @@ +package main + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "os/exec" + "regexp" + "strings" + "sync" + "time" + + "streamdeck-lets-go/internal/config" +) + +type WindowDetector interface { + Start(ctx context.Context) (<-chan Window, error) + Close() +} + +type Window struct { + WMClass string + Title string +} + +func NewWindowDetector() WindowDetector { + desktop := os.Getenv("XDG_CURRENT_DESKTOP") + wayland := os.Getenv("WAYLAND_DISPLAY") + + slog.Debug("detecting desktop environment", "XDG_CURRENT_DESKTOP", desktop, "WAYLAND_DISPLAY", wayland) + + switch { + case desktop == "Hyprland": + slog.Info("auto-switch: using Hyprland detector") + d := &hyprlandDetector{} + d.PollInterval = 200 * time.Millisecond + return d + + case desktop == "sway": + slog.Info("auto-switch: using Sway detector") + d := &swayDetector{} + d.PollInterval = 500 * time.Millisecond + return d + + case desktop == "niri": + slog.Info("auto-switch: using Niri detector") + d := &niriDetector{} + d.PollInterval = 200 * time.Millisecond + return d + + case desktop == "GNOME" || strings.Contains(desktop, "GNOME"): + slog.Info("auto-switch: GNOME detector (stub)") + d := &gnomeDetector{} + d.PollInterval = 500 * time.Millisecond + return d + + case strings.Contains(desktop, "KDE") || strings.Contains(desktop, "plasma"): + slog.Info("auto-switch: KDE detector (stub)") + d := &kdeDetector{} + d.PollInterval = 500 * time.Millisecond + return d + + case wayland != "": + slog.Warn("auto-switch: unknown Wayland compositor, using portal fallback") + d := &portalDetector{} + d.PollInterval = 500 * time.Millisecond + return d + + default: + slog.Info("auto-switch: using X11 detector") + d := &x11Detector{} + d.PollInterval = 200 * time.Millisecond + return d + } +} + +type basePoll struct { + PollInterval time.Duration + pollFn func(context.Context) Window + cancel context.CancelFunc + lastRaw string +} + +func (b *basePoll) start(ctx context.Context, ch chan<- Window) { + ticker := time.NewTicker(b.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + win := b.pollFn(ctx) + raw := win.WMClass + "|" + win.Title + if raw != b.lastRaw { + b.lastRaw = raw + select { + case ch <- win: + default: + } + } + case <-ctx.Done(): + return + } + } +} + +func (b *basePoll) stop() { + if b.cancel != nil { + b.cancel() + } +} + +func runCmd(ctx context.Context, cmd string, args ...string) (string, error) { + c := exec.CommandContext(ctx, cmd, args...) + out, err := c.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func runCmdJSON(ctx context.Context, cmd string, args ...string) ([]byte, error) { + c := exec.CommandContext(ctx, cmd, args...) + return c.Output() +} + +type hyprlandDetector struct { + basePoll +} + +func (d *hyprlandDetector) Start(ctx context.Context) (<-chan Window, error) { + d.pollFn = d.getWindow + ctx, d.cancel = context.WithCancel(ctx) + ch := make(chan Window, 4) + go d.basePoll.start(ctx, ch) + return ch, nil +} + +func (d *hyprlandDetector) Close() { d.basePoll.stop() } + +type hyprlandWindow struct { + Class string `json:"class"` + Title string `json:"title"` +} + +func (d *hyprlandDetector) getWindow(ctx context.Context) Window { + data, err := runCmdJSON(ctx, "hyprctl", "activewindow", "-j") + if err != nil { + return Window{} + } + + var win hyprlandWindow + if err := json.Unmarshal(data, &win); err != nil { + return Window{} + } + return Window{WMClass: win.Class, Title: win.Title} +} + +type swayDetector struct { + basePoll +} + +func (d *swayDetector) Start(ctx context.Context) (<-chan Window, error) { + d.pollFn = d.getWindow + ctx, d.cancel = context.WithCancel(ctx) + ch := make(chan Window, 4) + go d.basePoll.start(ctx, ch) + return ch, nil +} + +func (d *swayDetector) Close() { d.basePoll.stop() } + +type swayNode struct { + Type string `json:"type"` + Focused bool `json:"focused"` + AppID *string `json:"app_id"` + Name string `json:"name"` + Nodes []swayNode `json:"nodes"` + Floating []swayNode `json:"floating_nodes"` + Props *swayWindowProps `json:"window_properties"` +} + +type swayWindowProps struct { + Class string `json:"class"` +} + +func (d *swayDetector) getWindow(ctx context.Context) Window { + data, err := runCmdJSON(ctx, "swaymsg", "-t", "get_tree") + if err != nil { + return Window{} + } + + var root swayNode + if err := json.Unmarshal(data, &root); err != nil { + return Window{} + } + + node := findFocusedSwayNode(&root) + if node == nil { + return Window{} + } + + wmClass := "" + if node.AppID != nil { + wmClass = *node.AppID + } else if node.Props != nil { + wmClass = node.Props.Class + } + + return Window{WMClass: wmClass, Title: node.Name} +} + +func findFocusedSwayNode(node *swayNode) *swayNode { + if node.Type == "con" && node.Focused { + return node + } + for i := range node.Nodes { + if found := findFocusedSwayNode(&node.Nodes[i]); found != nil { + return found + } + } + for i := range node.Floating { + if found := findFocusedSwayNode(&node.Floating[i]); found != nil { + return found + } + } + return nil +} + +type niriDetector struct { + basePoll +} + +func (d *niriDetector) Start(ctx context.Context) (<-chan Window, error) { + d.pollFn = d.getWindow + ctx, d.cancel = context.WithCancel(ctx) + ch := make(chan Window, 4) + go d.basePoll.start(ctx, ch) + return ch, nil +} + +func (d *niriDetector) Close() { d.basePoll.stop() } + +type niriWindow struct { + AppID *string `json:"app_id"` + Title string `json:"title"` +} + +func (d *niriDetector) getWindow(ctx context.Context) Window { + data, err := runCmdJSON(ctx, "niri", "msg", "--json", "focused-window") + if err != nil { + return Window{} + } + + var win niriWindow + if err := json.Unmarshal(data, &win); err != nil { + return Window{} + } + if win.AppID == nil { + return Window{} + } + + return Window{WMClass: *win.AppID, Title: win.Title} +} + +type gnomeDetector struct{ basePoll } +func (d *gnomeDetector) Start(ctx context.Context) (<-chan Window, error) { + d.pollFn = d.getWindow + ctx, d.cancel = context.WithCancel(ctx) + ch := make(chan Window, 4) + go d.basePoll.start(ctx, ch) + return ch, nil +} +func (d *gnomeDetector) Close() { d.basePoll.stop() } + +func (d *gnomeDetector) getWindow(ctx context.Context) Window { + rawClass, err := runCmd(ctx, "gdbus", "call", "--session", + "--dest", "org.gnome.Shell", + "--object-path", "/org/gnome/Shell", + "--method", "org.gnome.Shell.Eval", + "global.display.focus_window?.get_wm_class() ?? ''") + if err != nil { + return Window{} + } + rawTitle, err := runCmd(ctx, "gdbus", "call", "--session", + "--dest", "org.gnome.Shell", + "--object-path", "/org/gnome/Shell", + "--method", "org.gnome.Shell.Eval", + "global.display.focus_window?.title ?? ''") + if err != nil { + _ = rawClass + return Window{} + } + + wmClass := parseGnomeEval(rawClass) + title := parseGnomeEval(rawTitle) + return Window{WMClass: wmClass, Title: title} +} + +func parseGnomeEval(out string) string { + out = strings.TrimSpace(out) + out = strings.TrimPrefix(out, "(true, ") + out = strings.TrimSuffix(out, ")") + out = strings.Trim(out, `"'`) + return out +} + +type kdeDetector struct{ basePoll } +func (d *kdeDetector) Start(ctx context.Context) (<-chan Window, error) { + d.pollFn = d.getWindow + ctx, d.cancel = context.WithCancel(ctx) + ch := make(chan Window, 4) + go d.basePoll.start(ctx, ch) + return ch, nil +} +func (d *kdeDetector) Close() { d.basePoll.stop() } + +func (d *kdeDetector) getWindow(ctx context.Context) Window { + wmClass, _ := runCmd(ctx, "kdotool", "getactivewindow", "getclassname") + title, _ := runCmd(ctx, "kdotool", "getactivewindow", "getwindowname") + return Window{WMClass: wmClass, Title: title} +} + +type x11Detector struct{ basePoll } +func (d *x11Detector) Start(ctx context.Context) (<-chan Window, error) { + d.pollFn = d.getWindow + ctx, d.cancel = context.WithCancel(ctx) + ch := make(chan Window, 4) + go d.basePoll.start(ctx, ch) + return ch, nil +} +func (d *x11Detector) Close() { d.basePoll.stop() } + +func (d *x11Detector) getWindow(ctx context.Context) Window { + wmClass, _ := runCmd(ctx, "xdotool", "getactivewindow", "getclassname") + title, _ := runCmd(ctx, "xdotool", "getactivewindow", "getwindowname") + return Window{WMClass: wmClass, Title: title} +} + +type portalDetector struct{ basePoll } +func (d *portalDetector) Start(ctx context.Context) (<-chan Window, error) { + d.pollFn = d.getWindow + ctx, d.cancel = context.WithCancel(ctx) + ch := make(chan Window, 4) + go d.basePoll.start(ctx, ch) + return ch, nil +} +func (d *portalDetector) Close() { d.basePoll.stop() } + +func (d *portalDetector) getWindow(ctx context.Context) Window { + return Window{} +} + +type AutoSwitchManager struct { + mu sync.RWMutex + rules []compiledRule + lastManualPage string + autoPage string + autoStay bool +} + +type compiledRule struct { + wmClass *regexp.Regexp + title *regexp.Regexp + rule config.SwitchRule +} + +func NewAutoSwitchManager(rules []config.SwitchRule, defaultPage string) *AutoSwitchManager { + m := &AutoSwitchManager{ + lastManualPage: defaultPage, + } + m.Reload(rules) + return m +} + +func (m *AutoSwitchManager) Reload(rules []config.SwitchRule) { + m.mu.Lock() + defer m.mu.Unlock() + + m.rules = make([]compiledRule, 0, len(rules)) + for _, r := range rules { + cr := compiledRule{rule: r} + if r.WMClass != "" { + re, err := regexp.Compile(r.WMClass) + if err != nil { + slog.Warn("auto-switch: invalid wm_class regex", "pattern", r.WMClass, "error", err) + continue + } + cr.wmClass = re + } + if r.Title != "" { + re, err := regexp.Compile(r.Title) + if err != nil { + slog.Warn("auto-switch: invalid title regex", "pattern", r.Title, "error", err) + continue + } + cr.title = re + } + m.rules = append(m.rules, cr) + } + + slog.Info("auto-switch: rules loaded", "count", len(m.rules)) +} + +func (m *AutoSwitchManager) NotifyManualPage(name string) { + m.mu.Lock() + defer m.mu.Unlock() + m.lastManualPage = name + m.autoPage = "" + slog.Debug("auto-switch: manual page set", "page", name) +} + +func (m *AutoSwitchManager) Evaluate(win Window, currentPage string) (page string, shouldSwitch bool) { + m.mu.Lock() + defer m.mu.Unlock() + + if win.WMClass == "" && win.Title == "" { + if m.autoPage != "" && m.autoPage == currentPage && !m.autoStay { + m.autoPage = "" + slog.Debug("auto-switch: no focused window, reverting to manual page", "page", m.lastManualPage) + return m.lastManualPage, true + } + return "", false + } + + for _, r := range m.rules { + if r.wmClass == nil && r.title == nil { + continue + } + if r.wmClass != nil && !r.wmClass.MatchString(win.WMClass) { + continue + } + if r.title != nil && !r.title.MatchString(win.Title) { + continue + } + + m.autoPage = r.rule.Page + m.autoStay = r.rule.Stay + slog.Debug("auto-switch: rule matched", + "wm_class", win.WMClass, "page", r.rule.Page, "stay", r.rule.Stay) + return r.rule.Page, true + } + + if m.autoPage != "" && m.autoPage == currentPage && !m.autoStay { + m.autoPage = "" + slog.Debug("auto-switch: reverting to manual page", "page", m.lastManualPage) + return m.lastManualPage, true + } + + return "", false +} diff --git a/autoswitch_test.go b/autoswitch_test.go new file mode 100644 index 0000000..a4e60b3 --- /dev/null +++ b/autoswitch_test.go @@ -0,0 +1,223 @@ +package main + +import ( + "encoding/json" + "testing" + + "streamdeck-lets-go/internal/config" +) + +func TestFindFocusedSwayNode(t *testing.T) { + input := `{ + "id": 0, + "type": "root", + "nodes": [ + { + "type": "output", + "name": "eDP-1", + "nodes": [ + { + "type": "workspace", + "name": "1", + "nodes": [ + { + "type": "con", + "focused": false, + "app_id": "firefox", + "name": "Firefox", + "nodes": [] + } + ] + }, + { + "type": "workspace", + "name": "2", + "nodes": [ + { + "type": "con", + "focused": true, + "app_id": "Alacritty", + "name": "Alacritty", + "nodes": [] + } + ] + } + ], + "floating_nodes": [] + } + ] + }` + + var root swayNode + if err := json.Unmarshal([]byte(input), &root); err != nil { + t.Fatal(err) + } + + node := findFocusedSwayNode(&root) + if node == nil { + t.Fatal("expected focused node, got nil") + } + if node.AppID == nil || *node.AppID != "Alacritty" { + t.Fatalf("expected Alacritty, got %v", node.AppID) + } + if node.Name != "Alacritty" { + t.Fatalf("expected name Alacritty, got %s", node.Name) + } +} + +func TestFindFocusedSwayNode_XWayland(t *testing.T) { + input := `{ + "id": 0, + "type": "root", + "nodes": [ + { + "type": "output", + "nodes": [ + { + "type": "workspace", + "nodes": [ + { + "type": "con", + "focused": true, + "app_id": null, + "name": "Steam", + "window_properties": {"class": "steam"}, + "nodes": [] + } + ] + } + ] + } + ] + }` + + var root swayNode + json.Unmarshal([]byte(input), &root) + + node := findFocusedSwayNode(&root) + if node == nil { + t.Fatal("expected focused node, got nil") + } + if node.Props == nil || node.Props.Class != "steam" { + t.Fatal("expected window_properties.class = steam") + } +} + +func TestFindFocusedSwayNode_NoFocus(t *testing.T) { + input := `{ + "id": 0, + "type": "root", + "nodes": [ + { + "type": "output", + "nodes": [ + { + "type": "workspace", + "nodes": [ + { + "type": "con", + "focused": false, + "app_id": "kitty", + "name": "kitty", + "nodes": [] + } + ] + } + ] + } + ] + }` + + var root swayNode + json.Unmarshal([]byte(input), &root) + node := findFocusedSwayNode(&root) + if node != nil { + t.Fatal("expected nil when no focused node") + } +} + +func TestAutoSwitchManager_Evaluate(t *testing.T) { + rules := []config.SwitchRule{ + {WMClass: "firefox", Page: "browser"}, + {WMClass: "Alacritty|kitty", Title: ".*vim.*", Page: "coding", Stay: true}, + } + + m := NewAutoSwitchManager(rules, "default") + + page, ok := m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "default") + if !ok || page != "browser" { + t.Fatalf("expected browser, got %s/%v", page, ok) + } + + page, ok = m.Evaluate(Window{WMClass: "Alacritty", Title: "nvim main.go"}, "default") + if !ok || page != "coding" { + t.Fatalf("expected coding, got %s/%v", page, ok) + } + + page, ok = m.Evaluate(Window{WMClass: "firefox", Title: "YouTube"}, "browser") + if !ok || page != "browser" { + t.Fatalf("expected browser again, got %s/%v", page, ok) + } +} + +func TestAutoSwitchManager_Stay(t *testing.T) { + rules := []config.SwitchRule{ + {WMClass: "firefox", Page: "browser", Stay: false}, + } + + m := NewAutoSwitchManager(rules, "home") + + m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "home") + + page, ok := m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "browser") + if !ok || page != "home" { + t.Fatalf("expected revert to home, got %s/%v", page, ok) + } +} + +func TestAutoSwitchManager_StayTrue(t *testing.T) { + rules := []config.SwitchRule{ + {WMClass: "firefox", Page: "browser", Stay: true}, + } + + m := NewAutoSwitchManager(rules, "home") + + m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "home") + + page, ok := m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "browser") + if ok { + t.Fatalf("expected no switch (stay=true), got %s", page) + } +} + +func TestAutoSwitchManager_ManualOverride(t *testing.T) { + rules := []config.SwitchRule{ + {WMClass: "firefox", Page: "browser", Stay: false}, + } + + m := NewAutoSwitchManager(rules, "home") + + // 1. Firefox opens → auto-switch to "browser" + m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "home") + + // 2. User manually switches to "settings" + m.NotifyManualPage("settings") + + // 3. Window changes to thunar on "settings" → no match → stay on settings + page, ok := m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "settings") + if ok { + t.Fatalf("expected no switch (already on manual page), got %s", page) + } + + // 4. Firefox comes back → auto-switch to "browser" again + page, ok = m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "settings") + if !ok || page != "browser" { + t.Fatalf("expected browser, got %s/%v", page, ok) + } + + // 5. Thunar again, current page is "browser" (auto) → no match → revert to "settings" + page, ok = m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "browser") + if !ok || page != "settings" { + t.Fatalf("expected revert to settings, got %s/%v", page, ok) + } +} diff --git a/cbdt.go b/cbdt.go new file mode 100644 index 0000000..18bcc56 --- /dev/null +++ b/cbdt.go @@ -0,0 +1,209 @@ +package main + +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 < 1 { + displaySize = targetSize + } + + 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 +} diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..e1ec623 --- /dev/null +++ b/config.example.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "log_level": "info", + "default_page": "main", + "devices": [ + { + "serial": "", + "brightness": 75 + } + ], + "pages": [ + { + "name": "main", + "keys": [ + { + "index": 0, + "icon": "fa:terminal", + "action": { + "type": "command", + "command": "kitty", + "background": true + } + }, + { + "index": 1, + "icon": "fab:firefox", + "label": "Browser", + "font": "medium", + "font_size": 11, + "action": { + "type": "command", + "command": "firefox", + "background": true + } + } + ] + } + ], + "auto_switch": [], + "screensaver": { + "enabled": false, + "idle_seconds": 30, + "image": "", + "brightness": 10 + } +} diff --git a/daemon.go b/daemon.go new file mode 100644 index 0000000..8c39785 --- /dev/null +++ b/daemon.go @@ -0,0 +1,256 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/exec" + "time" + + "streamdeck-lets-go/internal/config" +) + +type RunOptions struct { + ConfigPath string + HTTPAddr string + HTTPEnabled bool + NoDeck bool +} + +func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error { + web := NewWebServer(cfg, opts.ConfigPath) + + if !opts.NoDeck || opts.HTTPEnabled { + checkKeyboardTool() + } + + if opts.HTTPEnabled { + go func() { + if err := web.Serve(ctx, opts.HTTPAddr); err != nil { + slog.Error("web server error", "error", err) + } + }() + } + + if opts.NoDeck { + <-ctx.Done() + return nil + } + + var deck *Deck + var err error + + for { + deck, err = OpenDeck("") + if err == nil { + break + } + slog.Warn("no stream deck found, retrying in 5s", "error", err) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(5 * time.Second): + } + } + defer deck.Close() + + deck.SetBrightness(deviceBrightness(cfg)) + + pm := NewPageManager(deck) + pm.LoadPages(cfg.Pages) + web.SetPageManager(pm) + + if err := pm.ActivatePage(cfg.DefaultPage); err != nil { + slog.Warn("activate default page", "error", err) + } + pm.startPeriodicKeys() + + var windowCh <-chan Window + var detector WindowDetector + + defer func() { + if detector != nil { + detector.Close() + } + }() + + if len(cfg.AutoSwitch) > 0 { + detector = NewWindowDetector() + windowCh, _ = detector.Start(ctx) + } + + asm := NewAutoSwitchManager(cfg.AutoSwitch, cfg.DefaultPage) + + ssCtrl := NewScreensaver(&cfg.Screensaver) + + reconnectTicker := time.NewTicker(5 * time.Second) + defer reconnectTicker.Stop() + + ssTicker := time.NewTicker(5 * time.Second) + defer ssTicker.Stop() + + configTicker := time.NewTicker(3 * time.Second) + defer configTicker.Stop() + var lastConfigMod time.Time + + slog.Info("daemon started") + defer slog.Info("daemon stopped") + + for { + select { + case evt, ok := <-deck.Events(): + if !ok { + slog.Warn("deck event channel closed, attempting reconnect...") + deck.Close() + deck = reconnectDeck(ctx, cfg, &pm, asm) + if deck == nil { + return ctx.Err() + } + deck.SetBrightness(deviceBrightness(cfg)) + continue + } + + wasSsActive := ssCtrl.IsActive() + ssCtrl.NotifyInput() + + if evt.Kind == EventKeyPressed { + if wasSsActive { + ssCtrl.Deactivate(deck) + + savedOutputs := pm.GetDisplayOutputs() + + if page := pm.ActivePage(); page != nil { + if err := pm.ActivatePage(pm.ActivePageName()); err != nil { + slog.Warn("screensaver: re-render page", "error", err) + } + } + + for idx, output := range savedOutputs { + pm.ReRenderDisplayKey(idx, output) + } + } + + page := pm.ActivePage() + if page == nil { + continue + } + for _, k := range page.Keys { + if k.Index == evt.Index && k.Action != nil { + slog.Debug("key pressed", "index", evt.Index, "action", k.Action.Type) + + if k.Action.Type == "page" { + asm.NotifyManualPage(k.Action.Page) + } + + go func(a *config.Action) { + if err := ExecuteAction(a, deck, pm); err != nil { + slog.Error("execute action", "error", err) + } + }(k.Action) + break + } + } + } + + case win := <-windowCh: + if page, ok := asm.Evaluate(win, pm.ActivePageName()); ok { + if err := pm.ActivatePage(page); err != nil { + slog.Warn("auto-switch: activate page", "error", err) + } + pm.startPeriodicKeys() + } + + case <-configTicker.C: + path := config.ConfigPath(opts.ConfigPath) + fi, err := os.Stat(path) + if err != nil { + continue + } + mt := fi.ModTime() + if mt.After(lastConfigMod) && !lastConfigMod.IsZero() { + lastConfigMod = mt + slog.Info("config file changed, reloading") + newCfg, err := config.LoadConfig(opts.ConfigPath) + if err != nil { + slog.Error("reload config", "error", err) + continue + } + cfg = newCfg + web.UpdateConfig(cfg) + ssCtrl = NewScreensaver(&cfg.Screensaver) + deck.SetBrightness(deviceBrightness(cfg)) + pm.stopPeriodicKeys() + pm.LoadPages(cfg.Pages) + asm.Reload(cfg.AutoSwitch) + if len(cfg.AutoSwitch) > 0 && detector == nil { + detector = NewWindowDetector() + windowCh, _ = detector.Start(ctx) + } + if err := pm.ActivatePage(cfg.DefaultPage); err != nil { + slog.Warn("reload: activate default page", "error", err) + } + pm.startPeriodicKeys() + } + if lastConfigMod.IsZero() { + lastConfigMod = mt + } + + case <-reconnectTicker.C: + if err := deck.SetBrightness(deck.Brightness()); err != nil { + slog.Warn("deck connection lost, reconnecting...", "error", err) + deck.Close() + deck = reconnectDeck(ctx, cfg, &pm, asm) + if deck == nil { + return ctx.Err() + } + deck.SetBrightness(deviceBrightness(cfg)) + } + + case <-ssTicker.C: + if ssCtrl.Check() { + ssCtrl.Activate(deck, &cfg.Screensaver) + } + + case <-ctx.Done(): + return nil + } + } +} + +func checkKeyboardTool() { + if os.Getenv("WAYLAND_DISPLAY") != "" { + if _, err := exec.LookPath("wtype"); err != nil { + slog.Warn("keyboard actions require wtype on Wayland — install it (e.g. apk add wtype) and restart") + } + } else { + if _, err := exec.LookPath("xdotool"); err != nil { + slog.Warn("keyboard actions require xdotool on X11 — install it (e.g. apk add xdotool) and restart") + } + } +} + +func deviceBrightness(cfg *config.Config) int { + if len(cfg.Devices) > 0 && cfg.Devices[0].Brightness > 0 { + return cfg.Devices[0].Brightness + } + return 75 +} + +func reconnectDeck(ctx context.Context, cfg *config.Config, pm **PageManager, asm *AutoSwitchManager) *Deck { + for { + newDeck, err := OpenDeck("") + if err == nil { + *pm = NewPageManager(newDeck) + (*pm).LoadPages(cfg.Pages) + (*pm).ActivatePage(cfg.DefaultPage) + (*pm).startPeriodicKeys() + asm.NotifyManualPage(cfg.DefaultPage) + return newDeck + } + select { + case <-ctx.Done(): + return nil + case <-time.After(3 * time.Second): + } + } +} diff --git a/deck.go b/deck.go new file mode 100644 index 0000000..58d6c4d --- /dev/null +++ b/deck.go @@ -0,0 +1,331 @@ +package main + +import ( + "fmt" + "image" + "image/color" + "image/draw" + "log/slog" + "sync" + + "github.com/bearsh/hid" + "github.com/dh1tw/streamdeck" + "github.com/disintegration/gift" +) + +type DeviceInfo struct { + Serial string + Model string + PID uint16 +} + +type DeckConfig struct { + PID uint16 + Name string + KeysX int + KeysY int + KeySize int + ImageFmt string + Rotate bool + Convert bool + HasDials bool + HasTouch bool +} + +var knownDecks = []DeckConfig{ + {PID: 0x60, Name: "Mini", KeysX: 3, KeysY: 2, KeySize: 80, ImageFmt: "bmp", Convert: true}, + {PID: 0x6d, Name: "Original", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "jpg", Rotate: true}, + {PID: 0x63, Name: "OriginalV2",KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "bmp"}, + {PID: 0x80, Name: "MK2", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "jpg", Rotate: true}, + {PID: 0x6c, Name: "XL", KeysX: 8, KeysY: 4, KeySize: 96, ImageFmt: "jpg"}, +} + +func findConfig(pid uint16) (DeckConfig, bool) { + for _, d := range knownDecks { + if d.PID == pid { + return d, true + } + } + return DeckConfig{}, false +} + +func EnumerateDevices() ([]DeviceInfo, error) { + devices := hid.Enumerate(streamdeck.VendorID, 0) + if len(devices) == 0 { + return nil, nil + } + var infos []DeviceInfo + for _, d := range devices { + info := DeviceInfo{Serial: d.Serial, PID: d.ProductID} + if cfg, ok := findConfig(d.ProductID); ok { + info.Model = cfg.Name + } else { + info.Model = fmt.Sprintf("Unknown (0x%04x)", d.ProductID) + } + infos = append(infos, info) + } + return infos, nil +} + +func toStreamDeckConfig(dc DeckConfig) *streamdeck.Config { + return &streamdeck.Config{ + ProductID: dc.PID, + NumButtonColumns: dc.KeysX, + NumButtonRows: dc.KeysY, + Spacer: 19, + ButtonSize: dc.KeySize, + ImageFormat: dc.ImageFmt, + ImageRotate: dc.Rotate, + ConvertKey: dc.Convert, + } +} + +type Event struct { + Kind int + Index int +} + +const ( + EventKeyPressed = 1 + EventKeyReleased = 2 +) + +type Deck struct { + sd *streamdeck.StreamDeck + cfg DeckConfig + serial string + + mu sync.Mutex + events chan Event + closed bool + brightness int +} + +func OpenDeck(serial string) (*Deck, error) { + desiredSerial := serial + if serial == "first" || serial == "" { + desiredSerial = "" + } + + sd, err := streamdeck.NewStreamDeck(desiredSerial) + if err != nil { + return nil, fmt.Errorf("opening streamdeck: %w", err) + } + + cfg, ok := findConfig(sd.Config.ProductID) + if !ok { + sd.Close() + return nil, fmt.Errorf("unsupported device PID: 0x%04x", sd.Config.ProductID) + } + + d := &Deck{ + sd: sd, + cfg: cfg, + serial: sd.Serial(), + events: make(chan Event, 64), + brightness: 75, + } + + sd.SetBtnEventCb(func(s streamdeck.State, e streamdeck.Event) { + kind := 0 + switch e.Kind { + case streamdeck.EventKeyPressed: + kind = EventKeyPressed + case streamdeck.EventKeyReleased: + kind = EventKeyReleased + default: + return + } + select { + case d.events <- Event{Kind: kind, Index: e.Which}: + default: + slog.Warn("event channel full, dropping event", "kind", e.Kind, "index", e.Which) + } + }) + + slog.Info("deck opened", "serial", d.serial, "model", cfg.Name, "keys", cfg.KeysX*cfg.KeysY) + return d, nil +} + +func (d *Deck) Close() { + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return + } + d.closed = true + close(d.events) + d.sd.Close() +} + +func (d *Deck) Serial() string { return d.serial } +func (d *Deck) Events() <-chan Event { return d.events } +func (d *Deck) NumKeys() int { return d.cfg.KeysX * d.cfg.KeysY } +func (d *Deck) KeySize() int { return d.cfg.KeySize } +func (d *Deck) Config() DeckConfig { return d.cfg } + +func (d *Deck) SetBrightness(val int) error { + if val < 0 { + val = 0 + } + if val > 100 { + val = 100 + } + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return fmt.Errorf("deck closed") + } + d.brightness = val + return d.sd.SetBrightness(uint16(val)) +} + +func (d *Deck) Brightness() int { + d.mu.Lock() + defer d.mu.Unlock() + return d.brightness +} + +func (d *Deck) FillColor(keyIndex int, r, g, b uint8) error { + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return fmt.Errorf("deck closed") + } + return d.sd.FillColor(keyIndex, int(r), int(g), int(b)) +} + +func (d *Deck) FillImage(keyIndex int, img image.Image) error { + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return fmt.Errorf("deck closed") + } + return d.sd.FillImage(keyIndex, img) +} + +func (d *Deck) FillPanel(img image.Image) error { + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return fmt.Errorf("deck closed") + } + return d.sd.FillPanel(img) +} + +func (d *Deck) ClearAll() error { + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return fmt.Errorf("deck closed") + } + return d.sd.ClearAllBtns() +} + +func (d *Deck) ClearKey(idx int) error { + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return fmt.Errorf("deck closed") + } + return d.sd.ClearBtn(idx) +} + +func (d *Deck) WriteText(keyIndex int, text string, bg color.Color, fontName string, fontSize float64) error { + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return fmt.Errorf("deck closed") + } + if fontSize <= 0 { + fontSize = 18 + } + font := streamdeck.MonoMedium + if fontName == "regular" { + font = streamdeck.MonoRegular + } + + ks := d.cfg.KeySize + charW := fontSize * 0.55 + textW := int(float64(len(text)) * charW) + posX := (ks - textW) / 2 + if posX < 2 { + posX = 2 + } + posY := ks/2 - 24 + int(fontSize/3) + if posY < 2 { + posY = 2 + } + + return d.sd.WriteText(keyIndex, streamdeck.TextButton{ + Lines: []streamdeck.TextLine{ + { + Text: text, + PosX: posX, + PosY: posY, + Font: font, + FontSize: fontSize, + FontColor: color.White, + }, + }, + BgColor: bg, + }) +} + +func (d *Deck) WriteTextOnImage(keyIndex int, img image.Image, text, fontName string, fontSize float64) error { + ks := d.cfg.KeySize + + g := gift.New( + gift.Resize(ks, ks, gift.LanczosResampling), + ) + rgba := image.NewRGBA(image.Rect(0, 0, ks, ks)) + g.Draw(rgba, img) + + lines := []streamdeck.TextLine{} + if text != "" { + if fontSize <= 0 { + fontSize = 16 + } + font := streamdeck.MonoRegular + if fontName == "medium" { + font = streamdeck.MonoMedium + } + + barHeight := 20 + if ks < 72 { + barHeight = 18 + } + barRect := image.Rect(0, ks-barHeight, ks, ks) + draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over) + + offsetY := 24 + baselineY := ks - 6 + posY := baselineY - offsetY + if posY < 2 { + posY = 2 + } + + charW := fontSize * 0.55 + textW := int(float64(len(text)) * charW) + posX := (ks - textW) / 2 + if posX < 2 { + posX = 2 + } + + lines = append(lines, streamdeck.TextLine{ + Text: text, + PosX: posX, + PosY: posY, + Font: font, + FontSize: fontSize, + FontColor: color.White, + }) + } + + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return fmt.Errorf("deck closed") + } + return d.sd.WriteTextOnImage(keyIndex, rgba, lines) +} diff --git a/emoji.go b/emoji.go new file mode 100644 index 0000000..93d83c6 --- /dev/null +++ b/emoji.go @@ -0,0 +1,209 @@ +package main + +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 +} diff --git a/fontawesome.go b/fontawesome.go new file mode 100644 index 0000000..2c090ca --- /dev/null +++ b/fontawesome.go @@ -0,0 +1,138 @@ +package main + +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)) + + offX := (size - int(fontSize)) / 2 + if offX < 0 { + offX = size / 8 + } + baselineY := size * 7 / 10 + + 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:") +} + diff --git a/fontawesome_data.go b/fontawesome_data.go new file mode 100644 index 0000000..1c81776 --- /dev/null +++ b/fontawesome_data.go @@ -0,0 +1,71 @@ +package main + +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:] +} diff --git a/fontawesome_icons.go b/fontawesome_icons.go new file mode 100644 index 0000000..98d5ea5 --- /dev/null +++ b/fontawesome_icons.go @@ -0,0 +1,1979 @@ +package main + +var fa7Icons = map[string]string{ + "0": "0", // U+0030 + "1": "1", // U+0031 + "2": "2", // U+0032 + "3": "3", // U+0033 + "4": "4", // U+0034 + "5": "5", // U+0035 + "6": "6", // U+0036 + "7": "7", // U+0037 + "8": "8", // U+0038 + "9": "9", // U+0039 + "A": "A", // U+0041 + "AddressBook": "\xef\x8a\xb9", // U+f2b9 + "AddressCard": "\xef\x8a\xbb", // U+f2bb + "AlarmClock": "\xef\x8d\x8e", // U+f34e + "AlignCenter": "\xef\x80\xb7", // U+f037 + "AlignJustify": "\xef\x80\xb9", // U+f039 + "AlignLeft": "\xef\x80\xb6", // U+f036 + "AlignRight": "\xef\x80\xb8", // U+f038 + "Anchor": "\xef\x84\xbd", // U+f13d + "AnchorCircleCheck": "\xee\x92\xaa", // U+e4aa + "AnchorCircleExclamation": "\xee\x92\xab", // U+e4ab + "AnchorCircleXmark": "\xee\x92\xac", // U+e4ac + "AnchorLock": "\xee\x92\xad", // U+e4ad + "AngleDown": "\xef\x84\x87", // U+f107 + "AngleLeft": "\xef\x84\x84", // U+f104 + "AngleRight": "\xef\x84\x85", // U+f105 + "AngleUp": "\xef\x84\x86", // U+f106 + "AnglesDown": "\xef\x84\x83", // U+f103 + "AnglesLeft": "\xef\x84\x80", // U+f100 + "AnglesRight": "\xef\x84\x81", // U+f101 + "AnglesUp": "\xef\x84\x82", // U+f102 + "Ankh": "\xef\x99\x84", // U+f644 + "AppleWhole": "\xef\x97\x91", // U+f5d1 + "Aquarius": "\xee\xa1\x85", // U+e845 + "Archway": "\xef\x95\x97", // U+f557 + "Aries": "\xee\xa1\x86", // U+e846 + "ArrowDown": "\xef\x81\xa3", // U+f063 + "ArrowDown19": "\xef\x85\xa2", // U+f162 + "ArrowDown91": "\xef\xa2\x86", // U+f886 + "ArrowDownAZ": "\xef\x85\x9d", // U+f15d + "ArrowDownLong": "\xef\x85\xb5", // U+f175 + "ArrowDownShortWide": "\xef\xa2\x84", // U+f884 + "ArrowDownUpAcrossLine": "\xee\x92\xaf", // U+e4af + "ArrowDownUpLock": "\xee\x92\xb0", // U+e4b0 + "ArrowDownWideShort": "\xef\x85\xa0", // U+f160 + "ArrowDownZA": "\xef\xa2\x81", // U+f881 + "ArrowLeft": "\xef\x81\xa0", // U+f060 + "ArrowLeftLong": "\xef\x85\xb7", // U+f177 + "ArrowPointer": "\xef\x89\x85", // U+f245 + "ArrowRight": "\xef\x81\xa1", // U+f061 + "ArrowRightArrowLeft": "\xef\x83\xac", // U+f0ec + "ArrowRightFromBracket": "\xef\x82\x8b", // U+f08b + "ArrowRightLong": "\xef\x85\xb8", // U+f178 + "ArrowRightToBracket": "\xef\x82\x90", // U+f090 + "ArrowRightToCity": "\xee\x92\xb3", // U+e4b3 + "ArrowRotateLeft": "\xef\x83\xa2", // U+f0e2 + "ArrowRotateRight": "\xef\x80\x9e", // U+f01e + "ArrowTrendDown": "\xee\x82\x97", // U+e097 + "ArrowTrendUp": "\xee\x82\x98", // U+e098 + "ArrowTurnDown": "\xef\x85\x89", // U+f149 + "ArrowTurnUp": "\xef\x85\x88", // U+f148 + "ArrowUp": "\xef\x81\xa2", // U+f062 + "ArrowUp19": "\xef\x85\xa3", // U+f163 + "ArrowUp91": "\xef\xa2\x87", // U+f887 + "ArrowUpAZ": "\xef\x85\x9e", // U+f15e + "ArrowUpFromBracket": "\xee\x82\x9a", // U+e09a + "ArrowUpFromGroundWater": "\xee\x92\xb5", // U+e4b5 + "ArrowUpFromWaterPump": "\xee\x92\xb6", // U+e4b6 + "ArrowUpLong": "\xef\x85\xb6", // U+f176 + "ArrowUpRightDots": "\xee\x92\xb7", // U+e4b7 + "ArrowUpRightFromSquare": "\xef\x82\x8e", // U+f08e + "ArrowUpShortWide": "\xef\xa2\x85", // U+f885 + "ArrowUpWideShort": "\xef\x85\xa1", // U+f161 + "ArrowUpZA": "\xef\xa2\x82", // U+f882 + "ArrowsDownToLine": "\xee\x92\xb8", // U+e4b8 + "ArrowsDownToPeople": "\xee\x92\xb9", // U+e4b9 + "ArrowsLeftRight": "\xef\x81\xbe", // U+f07e + "ArrowsLeftRightToLine": "\xee\x92\xba", // U+e4ba + "ArrowsRotate": "\xef\x80\xa1", // U+f021 + "ArrowsSpin": "\xee\x92\xbb", // U+e4bb + "ArrowsSplitUpAndLeft": "\xee\x92\xbc", // U+e4bc + "ArrowsToCircle": "\xee\x92\xbd", // U+e4bd + "ArrowsToDot": "\xee\x92\xbe", // U+e4be + "ArrowsToEye": "\xee\x92\xbf", // U+e4bf + "ArrowsTurnRight": "\xee\x93\x80", // U+e4c0 + "ArrowsTurnToDots": "\xee\x93\x81", // U+e4c1 + "ArrowsUpDown": "\xef\x81\xbd", // U+f07d + "ArrowsUpDownLeftRight": "\xef\x81\x87", // U+f047 + "ArrowsUpToLine": "\xee\x93\x82", // U+e4c2 + "Asterisk": "*", // U+002a + "At": "@", // U+0040 + "Atom": "\xef\x97\x92", // U+f5d2 + "AudioDescription": "\xef\x8a\x9e", // U+f29e + "AustralSign": "\xee\x82\xa9", // U+e0a9 + "Award": "\xef\x95\x99", // U+f559 + "B": "B", // U+0042 + "Baby": "\xef\x9d\xbc", // U+f77c + "BabyCarriage": "\xef\x9d\xbd", // U+f77d + "Backward": "\xef\x81\x8a", // U+f04a + "BackwardFast": "\xef\x81\x89", // U+f049 + "BackwardStep": "\xef\x81\x88", // U+f048 + "Bacon": "\xef\x9f\xa5", // U+f7e5 + "Bacteria": "\xee\x81\x99", // U+e059 + "Bacterium": "\xee\x81\x9a", // U+e05a + "BagShopping": "\xef\x8a\x90", // U+f290 + "Bahai": "\xef\x99\xa6", // U+f666 + "BahtSign": "\xee\x82\xac", // U+e0ac + "Ban": "\xef\x81\x9e", // U+f05e + "BanSmoking": "\xef\x95\x8d", // U+f54d + "Bandage": "\xef\x91\xa2", // U+f462 + "BangladeshiTakaSign": "\xee\x8b\xa6", // U+e2e6 + "Barcode": "\xef\x80\xaa", // U+f02a + "Bars": "\xef\x83\x89", // U+f0c9 + "BarsProgress": "\xef\xa0\xa8", // U+f828 + "BarsStaggered": "\xef\x95\x90", // U+f550 + "Baseball": "\xef\x90\xb3", // U+f433 + "BaseballBatBall": "\xef\x90\xb2", // U+f432 + "BasketShopping": "\xef\x8a\x91", // U+f291 + "Basketball": "\xef\x90\xb4", // U+f434 + "Bath": "\xef\x8b\x8d", // U+f2cd + "BatteryEmpty": "\xef\x89\x84", // U+f244 + "BatteryFull": "\xef\x89\x80", // U+f240 + "BatteryHalf": "\xef\x89\x82", // U+f242 + "BatteryQuarter": "\xef\x89\x83", // U+f243 + "BatteryThreeQuarters": "\xef\x89\x81", // U+f241 + "Bed": "\xef\x88\xb6", // U+f236 + "BedPulse": "\xef\x92\x87", // U+f487 + "BeerMugEmpty": "\xef\x83\xbc", // U+f0fc + "Bell": "\xef\x83\xb3", // U+f0f3 + "BellConcierge": "\xef\x95\xa2", // U+f562 + "BellSlash": "\xef\x87\xb6", // U+f1f6 + "BezierCurve": "\xef\x95\x9b", // U+f55b + "Bicycle": "\xef\x88\x86", // U+f206 + "Binoculars": "\xef\x87\xa5", // U+f1e5 + "Biohazard": "\xef\x9e\x80", // U+f780 + "BitcoinSign": "\xee\x82\xb4", // U+e0b4 + "Blender": "\xef\x94\x97", // U+f517 + "BlenderPhone": "\xef\x9a\xb6", // U+f6b6 + "Blog": "\xef\x9e\x81", // U+f781 + "Bold": "\xef\x80\xb2", // U+f032 + "Bolt": "\xef\x83\xa7", // U+f0e7 + "BoltLightning": "\xee\x82\xb7", // U+e0b7 + "Bomb": "\xef\x87\xa2", // U+f1e2 + "Bone": "\xef\x97\x97", // U+f5d7 + "Bong": "\xef\x95\x9c", // U+f55c + "Book": "\xef\x80\xad", // U+f02d + "BookAtlas": "\xef\x95\x98", // U+f558 + "BookBible": "\xef\x99\x87", // U+f647 + "BookBookmark": "\xee\x82\xbb", // U+e0bb + "BookJournalWhills": "\xef\x99\xaa", // U+f66a + "BookMedical": "\xef\x9f\xa6", // U+f7e6 + "BookOpen": "\xef\x94\x98", // U+f518 + "BookOpenReader": "\xef\x97\x9a", // U+f5da + "BookQuran": "\xef\x9a\x87", // U+f687 + "BookSkull": "\xef\x9a\xb7", // U+f6b7 + "BookTanakh": "\xef\xa0\xa7", // U+f827 + "Bookmark": "\xef\x80\xae", // U+f02e + "BorderAll": "\xef\xa1\x8c", // U+f84c + "BorderNone": "\xef\xa1\x90", // U+f850 + "BorderTopLeft": "\xef\xa1\x93", // U+f853 + "BoreHole": "\xee\x93\x83", // U+e4c3 + "BottleDroplet": "\xee\x93\x84", // U+e4c4 + "BottleWater": "\xee\x93\x85", // U+e4c5 + "BowlFood": "\xee\x93\x86", // U+e4c6 + "BowlRice": "\xee\x8b\xab", // U+e2eb + "BowlingBall": "\xef\x90\xb6", // U+f436 + "Box": "\xef\x91\xa6", // U+f466 + "BoxArchive": "\xef\x86\x87", // U+f187 + "BoxOpen": "\xef\x92\x9e", // U+f49e + "BoxTissue": "\xee\x81\x9b", // U+e05b + "BoxesPacking": "\xee\x93\x87", // U+e4c7 + "BoxesStacked": "\xef\x91\xa8", // U+f468 + "Braille": "\xef\x8a\xa1", // U+f2a1 + "Brain": "\xef\x97\x9c", // U+f5dc + "BrazilianRealSign": "\xee\x91\xac", // U+e46c + "BreadSlice": "\xef\x9f\xac", // U+f7ec + "Bridge": "\xee\x93\x88", // U+e4c8 + "BridgeCircleCheck": "\xee\x93\x89", // U+e4c9 + "BridgeCircleExclamation": "\xee\x93\x8a", // U+e4ca + "BridgeCircleXmark": "\xee\x93\x8b", // U+e4cb + "BridgeLock": "\xee\x93\x8c", // U+e4cc + "BridgeWater": "\xee\x93\x8e", // U+e4ce + "Briefcase": "\xef\x82\xb1", // U+f0b1 + "BriefcaseMedical": "\xef\x91\xa9", // U+f469 + "Broom": "\xef\x94\x9a", // U+f51a + "BroomBall": "\xef\x91\x98", // U+f458 + "Brush": "\xef\x95\x9d", // U+f55d + "Bucket": "\xee\x93\x8f", // U+e4cf + "Bug": "\xef\x86\x88", // U+f188 + "BugSlash": "\xee\x92\x90", // U+e490 + "Bugs": "\xee\x93\x90", // U+e4d0 + "Building": "\xef\x86\xad", // U+f1ad + "BuildingCircleArrowRight": "\xee\x93\x91", // U+e4d1 + "BuildingCircleCheck": "\xee\x93\x92", // U+e4d2 + "BuildingCircleExclamation": "\xee\x93\x93", // U+e4d3 + "BuildingCircleXmark": "\xee\x93\x94", // U+e4d4 + "BuildingColumns": "\xef\x86\x9c", // U+f19c + "BuildingFlag": "\xee\x93\x95", // U+e4d5 + "BuildingLock": "\xee\x93\x96", // U+e4d6 + "BuildingNgo": "\xee\x93\x97", // U+e4d7 + "BuildingShield": "\xee\x93\x98", // U+e4d8 + "BuildingUn": "\xee\x93\x99", // U+e4d9 + "BuildingUser": "\xee\x93\x9a", // U+e4da + "BuildingWheat": "\xee\x93\x9b", // U+e4db + "Bullhorn": "\xef\x82\xa1", // U+f0a1 + "Bullseye": "\xef\x85\x80", // U+f140 + "Burger": "\xef\xa0\x85", // U+f805 + "Burst": "\xee\x93\x9c", // U+e4dc + "Bus": "\xef\x88\x87", // U+f207 + "BusSide": "\xee\xa0\x9d", // U+e81d + "BusSimple": "\xef\x95\x9e", // U+f55e + "BusinessTime": "\xef\x99\x8a", // U+f64a + "C": "C", // U+0043 + "CableCar": "\xef\x9f\x9a", // U+f7da + "CakeCandles": "\xef\x87\xbd", // U+f1fd + "Calculator": "\xef\x87\xac", // U+f1ec + "Calendar": "\xef\x84\xb3", // U+f133 + "CalendarCheck": "\xef\x89\xb4", // U+f274 + "CalendarDay": "\xef\x9e\x83", // U+f783 + "CalendarDays": "\xef\x81\xb3", // U+f073 + "CalendarMinus": "\xef\x89\xb2", // U+f272 + "CalendarPlus": "\xef\x89\xb1", // U+f271 + "CalendarWeek": "\xef\x9e\x84", // U+f784 + "CalendarXmark": "\xef\x89\xb3", // U+f273 + "Camera": "\xef\x80\xb0", // U+f030 + "CameraRetro": "\xef\x82\x83", // U+f083 + "CameraRotate": "\xee\x83\x98", // U+e0d8 + "Campground": "\xef\x9a\xbb", // U+f6bb + "Cancer": "\xee\xa1\x87", // U+e847 + "CandyCane": "\xef\x9e\x86", // U+f786 + "Cannabis": "\xef\x95\x9f", // U+f55f + "Capricorn": "\xee\xa1\x88", // U+e848 + "Capsules": "\xef\x91\xab", // U+f46b + "Car": "\xef\x86\xb9", // U+f1b9 + "CarBattery": "\xef\x97\x9f", // U+f5df + "CarBurst": "\xef\x97\xa1", // U+f5e1 + "CarOn": "\xee\x93\x9d", // U+e4dd + "CarRear": "\xef\x97\x9e", // U+f5de + "CarSide": "\xef\x97\xa4", // U+f5e4 + "CarTunnel": "\xee\x93\x9e", // U+e4de + "Caravan": "\xef\xa3\xbf", // U+f8ff + "CaretDown": "\xef\x83\x97", // U+f0d7 + "CaretLeft": "\xef\x83\x99", // U+f0d9 + "CaretRight": "\xef\x83\x9a", // U+f0da + "CaretUp": "\xef\x83\x98", // U+f0d8 + "Carrot": "\xef\x9e\x87", // U+f787 + "CartArrowDown": "\xef\x88\x98", // U+f218 + "CartFlatbed": "\xef\x91\xb4", // U+f474 + "CartFlatbedSuitcase": "\xef\x96\x9d", // U+f59d + "CartPlus": "\xef\x88\x97", // U+f217 + "CartShopping": "\xef\x81\xba", // U+f07a + "CashRegister": "\xef\x9e\x88", // U+f788 + "Cat": "\xef\x9a\xbe", // U+f6be + "CediSign": "\xee\x83\x9f", // U+e0df + "CentSign": "\xee\x8f\xb5", // U+e3f5 + "Certificate": "\xef\x82\xa3", // U+f0a3 + "Chair": "\xef\x9b\x80", // U+f6c0 + "Chalkboard": "\xef\x94\x9b", // U+f51b + "ChalkboardUser": "\xef\x94\x9c", // U+f51c + "ChampagneGlasses": "\xef\x9e\x9f", // U+f79f + "ChargingStation": "\xef\x97\xa7", // U+f5e7 + "ChartArea": "\xef\x87\xbe", // U+f1fe + "ChartBar": "\xef\x82\x80", // U+f080 + "ChartColumn": "\xee\x83\xa3", // U+e0e3 + "ChartDiagram": "\xee\x9a\x95", // U+e695 + "ChartGantt": "\xee\x83\xa4", // U+e0e4 + "ChartLine": "\xef\x88\x81", // U+f201 + "ChartPie": "\xef\x88\x80", // U+f200 + "ChartSimple": "\xee\x91\xb3", // U+e473 + "Check": "\xef\x80\x8c", // U+f00c + "CheckDouble": "\xef\x95\xa0", // U+f560 + "CheckToSlot": "\xef\x9d\xb2", // U+f772 + "Cheese": "\xef\x9f\xaf", // U+f7ef + "Chess": "\xef\x90\xb9", // U+f439 + "ChessBishop": "\xef\x90\xba", // U+f43a + "ChessBoard": "\xef\x90\xbc", // U+f43c + "ChessKing": "\xef\x90\xbf", // U+f43f + "ChessKnight": "\xef\x91\x81", // U+f441 + "ChessPawn": "\xef\x91\x83", // U+f443 + "ChessQueen": "\xef\x91\x85", // U+f445 + "ChessRook": "\xef\x91\x87", // U+f447 + "ChevronDown": "\xef\x81\xb8", // U+f078 + "ChevronLeft": "\xef\x81\x93", // U+f053 + "ChevronRight": "\xef\x81\x94", // U+f054 + "ChevronUp": "\xef\x81\xb7", // U+f077 + "Child": "\xef\x86\xae", // U+f1ae + "ChildCombatant": "\xee\x93\xa0", // U+e4e0 + "ChildDress": "\xee\x96\x9c", // U+e59c + "ChildReaching": "\xee\x96\x9d", // U+e59d + "Children": "\xee\x93\xa1", // U+e4e1 + "Church": "\xef\x94\x9d", // U+f51d + "Circle": "\xef\x84\x91", // U+f111 + "CircleArrowDown": "\xef\x82\xab", // U+f0ab + "CircleArrowLeft": "\xef\x82\xa8", // U+f0a8 + "CircleArrowRight": "\xef\x82\xa9", // U+f0a9 + "CircleArrowUp": "\xef\x82\xaa", // U+f0aa + "CircleCheck": "\xef\x81\x98", // U+f058 + "CircleChevronDown": "\xef\x84\xba", // U+f13a + "CircleChevronLeft": "\xef\x84\xb7", // U+f137 + "CircleChevronRight": "\xef\x84\xb8", // U+f138 + "CircleChevronUp": "\xef\x84\xb9", // U+f139 + "CircleDollarToSlot": "\xef\x92\xb9", // U+f4b9 + "CircleDot": "\xef\x86\x92", // U+f192 + "CircleDown": "\xef\x8d\x98", // U+f358 + "CircleExclamation": "\xef\x81\xaa", // U+f06a + "CircleH": "\xef\x91\xbe", // U+f47e + "CircleHalfStroke": "\xef\x81\x82", // U+f042 + "CircleInfo": "\xef\x81\x9a", // U+f05a + "CircleLeft": "\xef\x8d\x99", // U+f359 + "CircleMinus": "\xef\x81\x96", // U+f056 + "CircleNodes": "\xee\x93\xa2", // U+e4e2 + "CircleNotch": "\xef\x87\x8e", // U+f1ce + "CirclePause": "\xef\x8a\x8b", // U+f28b + "CirclePlay": "\xef\x85\x84", // U+f144 + "CirclePlus": "\xef\x81\x95", // U+f055 + "CircleQuestion": "\xef\x81\x99", // U+f059 + "CircleRadiation": "\xef\x9e\xba", // U+f7ba + "CircleRight": "\xef\x8d\x9a", // U+f35a + "CircleStop": "\xef\x8a\x8d", // U+f28d + "CircleUp": "\xef\x8d\x9b", // U+f35b + "CircleUser": "\xef\x8a\xbd", // U+f2bd + "CircleXmark": "\xef\x81\x97", // U+f057 + "City": "\xef\x99\x8f", // U+f64f + "Clapperboard": "\xee\x84\xb1", // U+e131 + "Clipboard": "\xef\x8c\xa8", // U+f328 + "ClipboardCheck": "\xef\x91\xac", // U+f46c + "ClipboardList": "\xef\x91\xad", // U+f46d + "ClipboardQuestion": "\xee\x93\xa3", // U+e4e3 + "ClipboardUser": "\xef\x9f\xb3", // U+f7f3 + "Clock": "\xef\x80\x97", // U+f017 + "ClockRotateLeft": "\xef\x87\x9a", // U+f1da + "Clone": "\xef\x89\x8d", // U+f24d + "ClosedCaptioning": "\xef\x88\x8a", // U+f20a + "ClosedCaptioningSlash": "\xee\x84\xb5", // U+e135 + "Cloud": "\xef\x83\x82", // U+f0c2 + "CloudArrowDown": "\xef\x83\xad", // U+f0ed + "CloudArrowUp": "\xef\x83\xae", // U+f0ee + "CloudBolt": "\xef\x9d\xac", // U+f76c + "CloudMeatball": "\xef\x9c\xbb", // U+f73b + "CloudMoon": "\xef\x9b\x83", // U+f6c3 + "CloudMoonRain": "\xef\x9c\xbc", // U+f73c + "CloudRain": "\xef\x9c\xbd", // U+f73d + "CloudShowersHeavy": "\xef\x9d\x80", // U+f740 + "CloudShowersWater": "\xee\x93\xa4", // U+e4e4 + "CloudSun": "\xef\x9b\x84", // U+f6c4 + "CloudSunRain": "\xef\x9d\x83", // U+f743 + "Clover": "\xee\x84\xb9", // U+e139 + "Code": "\xef\x84\xa1", // U+f121 + "CodeBranch": "\xef\x84\xa6", // U+f126 + "CodeCommit": "\xef\x8e\x86", // U+f386 + "CodeCompare": "\xee\x84\xba", // U+e13a + "CodeFork": "\xee\x84\xbb", // U+e13b + "CodeMerge": "\xef\x8e\x87", // U+f387 + "CodePullRequest": "\xee\x84\xbc", // U+e13c + "Coins": "\xef\x94\x9e", // U+f51e + "ColonSign": "\xee\x85\x80", // U+e140 + "Comment": "\xef\x81\xb5", // U+f075 + "CommentDollar": "\xef\x99\x91", // U+f651 + "CommentDots": "\xef\x92\xad", // U+f4ad + "CommentMedical": "\xef\x9f\xb5", // U+f7f5 + "CommentNodes": "\xee\x9a\x96", // U+e696 + "CommentSlash": "\xef\x92\xb3", // U+f4b3 + "CommentSms": "\xef\x9f\x8d", // U+f7cd + "Comments": "\xef\x82\x86", // U+f086 + "CommentsDollar": "\xef\x99\x93", // U+f653 + "CompactDisc": "\xef\x94\x9f", // U+f51f + "Compass": "\xef\x85\x8e", // U+f14e + "CompassDrafting": "\xef\x95\xa8", // U+f568 + "Compress": "\xef\x81\xa6", // U+f066 + "Computer": "\xee\x93\xa5", // U+e4e5 + "ComputerMouse": "\xef\xa3\x8c", // U+f8cc + "Cookie": "\xef\x95\xa3", // U+f563 + "CookieBite": "\xef\x95\xa4", // U+f564 + "Copy": "\xef\x83\x85", // U+f0c5 + "Copyright": "\xef\x87\xb9", // U+f1f9 + "Couch": "\xef\x92\xb8", // U+f4b8 + "Cow": "\xef\x9b\x88", // U+f6c8 + "CreditCard": "\xef\x82\x9d", // U+f09d + "Crop": "\xef\x84\xa5", // U+f125 + "CropSimple": "\xef\x95\xa5", // U+f565 + "Cross": "\xef\x99\x94", // U+f654 + "Crosshairs": "\xef\x81\x9b", // U+f05b + "Crow": "\xef\x94\xa0", // U+f520 + "Crown": "\xef\x94\xa1", // U+f521 + "Crutch": "\xef\x9f\xb7", // U+f7f7 + "CruzeiroSign": "\xee\x85\x92", // U+e152 + "Cube": "\xef\x86\xb2", // U+f1b2 + "Cubes": "\xef\x86\xb3", // U+f1b3 + "CubesStacked": "\xee\x93\xa6", // U+e4e6 + "D": "D", // U+0044 + "Database": "\xef\x87\x80", // U+f1c0 + "DeleteLeft": "\xef\x95\x9a", // U+f55a + "Democrat": "\xef\x9d\x87", // U+f747 + "Desktop": "\xef\x8e\x90", // U+f390 + "Dharmachakra": "\xef\x99\x95", // U+f655 + "DiagramNext": "\xee\x91\xb6", // U+e476 + "DiagramPredecessor": "\xee\x91\xb7", // U+e477 + "DiagramProject": "\xef\x95\x82", // U+f542 + "DiagramSuccessor": "\xee\x91\xba", // U+e47a + "Diamond": "\xef\x88\x99", // U+f219 + "DiamondTurnRight": "\xef\x97\xab", // U+f5eb + "Dice": "\xef\x94\xa2", // U+f522 + "DiceD20": "\xef\x9b\x8f", // U+f6cf + "DiceD6": "\xef\x9b\x91", // U+f6d1 + "DiceFive": "\xef\x94\xa3", // U+f523 + "DiceFour": "\xef\x94\xa4", // U+f524 + "DiceOne": "\xef\x94\xa5", // U+f525 + "DiceSix": "\xef\x94\xa6", // U+f526 + "DiceThree": "\xef\x94\xa7", // U+f527 + "DiceTwo": "\xef\x94\xa8", // U+f528 + "Disease": "\xef\x9f\xba", // U+f7fa + "Display": "\xee\x85\xa3", // U+e163 + "Divide": "\xef\x94\xa9", // U+f529 + "Dna": "\xef\x91\xb1", // U+f471 + "Dog": "\xef\x9b\x93", // U+f6d3 + "DollarSign": "$", // U+0024 + "Dolly": "\xef\x91\xb2", // U+f472 + "DongSign": "\xee\x85\xa9", // U+e169 + "DoorClosed": "\xef\x94\xaa", // U+f52a + "DoorOpen": "\xef\x94\xab", // U+f52b + "Dove": "\xef\x92\xba", // U+f4ba + "DownLeftAndUpRightToCenter": "\xef\x90\xa2", // U+f422 + "DownLong": "\xef\x8c\x89", // U+f309 + "Download": "\xef\x80\x99", // U+f019 + "Dragon": "\xef\x9b\x95", // U+f6d5 + "DrawPolygon": "\xef\x97\xae", // U+f5ee + "Droplet": "\xef\x81\x83", // U+f043 + "DropletSlash": "\xef\x97\x87", // U+f5c7 + "Drum": "\xef\x95\xa9", // U+f569 + "DrumSteelpan": "\xef\x95\xaa", // U+f56a + "DrumstickBite": "\xef\x9b\x97", // U+f6d7 + "Dumbbell": "\xef\x91\x8b", // U+f44b + "Dumpster": "\xef\x9e\x93", // U+f793 + "DumpsterFire": "\xef\x9e\x94", // U+f794 + "Dungeon": "\xef\x9b\x99", // U+f6d9 + "E": "E", // U+0045 + "EarDeaf": "\xef\x8a\xa4", // U+f2a4 + "EarListen": "\xef\x8a\xa2", // U+f2a2 + "EarthAfrica": "\xef\x95\xbc", // U+f57c + "EarthAmericas": "\xef\x95\xbd", // U+f57d + "EarthAsia": "\xef\x95\xbe", // U+f57e + "EarthEurope": "\xef\x9e\xa2", // U+f7a2 + "EarthOceania": "\xee\x91\xbb", // U+e47b + "Egg": "\xef\x9f\xbb", // U+f7fb + "Eject": "\xef\x81\x92", // U+f052 + "Elevator": "\xee\x85\xad", // U+e16d + "Ellipsis": "\xef\x85\x81", // U+f141 + "EllipsisVertical": "\xef\x85\x82", // U+f142 + "Envelope": "\xef\x83\xa0", // U+f0e0 + "EnvelopeCircleCheck": "\xee\x93\xa8", // U+e4e8 + "EnvelopeOpen": "\xef\x8a\xb6", // U+f2b6 + "EnvelopeOpenText": "\xef\x99\x98", // U+f658 + "EnvelopesBulk": "\xef\x99\xb4", // U+f674 + "Equals": "=", // U+003d + "Eraser": "\xef\x84\xad", // U+f12d + "Ethernet": "\xef\x9e\x96", // U+f796 + "EuroSign": "\xef\x85\x93", // U+f153 + "Exclamation": "!", // U+0021 + "Expand": "\xef\x81\xa5", // U+f065 + "Explosion": "\xee\x93\xa9", // U+e4e9 + "Eye": "\xef\x81\xae", // U+f06e + "EyeDropper": "\xef\x87\xbb", // U+f1fb + "EyeLowVision": "\xef\x8a\xa8", // U+f2a8 + "EyeSlash": "\xef\x81\xb0", // U+f070 + "F": "F", // U+0046 + "FaceAngry": "\xef\x95\x96", // U+f556 + "FaceDizzy": "\xef\x95\xa7", // U+f567 + "FaceFlushed": "\xef\x95\xb9", // U+f579 + "FaceFrown": "\xef\x84\x99", // U+f119 + "FaceFrownOpen": "\xef\x95\xba", // U+f57a + "FaceGrimace": "\xef\x95\xbf", // U+f57f + "FaceGrin": "\xef\x96\x80", // U+f580 + "FaceGrinBeam": "\xef\x96\x82", // U+f582 + "FaceGrinBeamSweat": "\xef\x96\x83", // U+f583 + "FaceGrinHearts": "\xef\x96\x84", // U+f584 + "FaceGrinSquint": "\xef\x96\x85", // U+f585 + "FaceGrinSquintTears": "\xef\x96\x86", // U+f586 + "FaceGrinStars": "\xef\x96\x87", // U+f587 + "FaceGrinTears": "\xef\x96\x88", // U+f588 + "FaceGrinTongue": "\xef\x96\x89", // U+f589 + "FaceGrinTongueSquint": "\xef\x96\x8a", // U+f58a + "FaceGrinTongueWink": "\xef\x96\x8b", // U+f58b + "FaceGrinWide": "\xef\x96\x81", // U+f581 + "FaceGrinWink": "\xef\x96\x8c", // U+f58c + "FaceKiss": "\xef\x96\x96", // U+f596 + "FaceKissBeam": "\xef\x96\x97", // U+f597 + "FaceKissWinkHeart": "\xef\x96\x98", // U+f598 + "FaceLaugh": "\xef\x96\x99", // U+f599 + "FaceLaughBeam": "\xef\x96\x9a", // U+f59a + "FaceLaughSquint": "\xef\x96\x9b", // U+f59b + "FaceLaughWink": "\xef\x96\x9c", // U+f59c + "FaceMeh": "\xef\x84\x9a", // U+f11a + "FaceMehBlank": "\xef\x96\xa4", // U+f5a4 + "FaceRollingEyes": "\xef\x96\xa5", // U+f5a5 + "FaceSadCry": "\xef\x96\xb3", // U+f5b3 + "FaceSadTear": "\xef\x96\xb4", // U+f5b4 + "FaceSmile": "\xef\x84\x98", // U+f118 + "FaceSmileBeam": "\xef\x96\xb8", // U+f5b8 + "FaceSmileWink": "\xef\x93\x9a", // U+f4da + "FaceSurprise": "\xef\x97\x82", // U+f5c2 + "FaceTired": "\xef\x97\x88", // U+f5c8 + "Fan": "\xef\xa1\xa3", // U+f863 + "Faucet": "\xee\x80\x85", // U+e005 + "FaucetDrip": "\xee\x80\x86", // U+e006 + "Fax": "\xef\x86\xac", // U+f1ac + "Feather": "\xef\x94\xad", // U+f52d + "FeatherPointed": "\xef\x95\xab", // U+f56b + "Ferry": "\xee\x93\xaa", // U+e4ea + "File": "\xef\x85\x9b", // U+f15b + "FileArrowDown": "\xef\x95\xad", // U+f56d + "FileArrowUp": "\xef\x95\xb4", // U+f574 + "FileAudio": "\xef\x87\x87", // U+f1c7 + "FileCircleCheck": "\xee\x96\xa0", // U+e5a0 + "FileCircleExclamation": "\xee\x93\xab", // U+e4eb + "FileCircleMinus": "\xee\x93\xad", // U+e4ed + "FileCirclePlus": "\xee\x92\x94", // U+e494 + "FileCircleQuestion": "\xee\x93\xaf", // U+e4ef + "FileCircleXmark": "\xee\x96\xa1", // U+e5a1 + "FileCode": "\xef\x87\x89", // U+f1c9 + "FileContract": "\xef\x95\xac", // U+f56c + "FileCsv": "\xef\x9b\x9d", // U+f6dd + "FileExcel": "\xef\x87\x83", // U+f1c3 + "FileExport": "\xef\x95\xae", // U+f56e + "FileFragment": "\xee\x9a\x97", // U+e697 + "FileHalfDashed": "\xee\x9a\x98", // U+e698 + "FileImage": "\xef\x87\x85", // U+f1c5 + "FileImport": "\xef\x95\xaf", // U+f56f + "FileInvoice": "\xef\x95\xb0", // U+f570 + "FileInvoiceDollar": "\xef\x95\xb1", // U+f571 + "FileLines": "\xef\x85\x9c", // U+f15c + "FileMedical": "\xef\x91\xb7", // U+f477 + "FilePdf": "\xef\x87\x81", // U+f1c1 + "FilePen": "\xef\x8c\x9c", // U+f31c + "FilePowerpoint": "\xef\x87\x84", // U+f1c4 + "FilePrescription": "\xef\x95\xb2", // U+f572 + "FileShield": "\xee\x93\xb0", // U+e4f0 + "FileSignature": "\xef\x95\xb3", // U+f573 + "FileVideo": "\xef\x87\x88", // U+f1c8 + "FileWaveform": "\xef\x91\xb8", // U+f478 + "FileWord": "\xef\x87\x82", // U+f1c2 + "FileZipper": "\xef\x87\x86", // U+f1c6 + "Fill": "\xef\x95\xb5", // U+f575 + "FillDrip": "\xef\x95\xb6", // U+f576 + "Film": "\xef\x80\x88", // U+f008 + "Filter": "\xef\x82\xb0", // U+f0b0 + "FilterCircleDollar": "\xef\x99\xa2", // U+f662 + "FilterCircleXmark": "\xee\x85\xbb", // U+e17b + "Fingerprint": "\xef\x95\xb7", // U+f577 + "Fire": "\xef\x81\xad", // U+f06d + "FireBurner": "\xee\x93\xb1", // U+e4f1 + "FireExtinguisher": "\xef\x84\xb4", // U+f134 + "FireFlameCurved": "\xef\x9f\xa4", // U+f7e4 + "FireFlameSimple": "\xef\x91\xaa", // U+f46a + "Fish": "\xef\x95\xb8", // U+f578 + "FishFins": "\xee\x93\xb2", // U+e4f2 + "Flag": "\xef\x80\xa4", // U+f024 + "FlagCheckered": "\xef\x84\x9e", // U+f11e + "FlagUsa": "\xef\x9d\x8d", // U+f74d + "Flask": "\xef\x83\x83", // U+f0c3 + "FlaskVial": "\xee\x93\xb3", // U+e4f3 + "FloppyDisk": "\xef\x83\x87", // U+f0c7 + "FlorinSign": "\xee\x86\x84", // U+e184 + "Folder": "\xef\x81\xbb", // U+f07b + "FolderClosed": "\xee\x86\x85", // U+e185 + "FolderMinus": "\xef\x99\x9d", // U+f65d + "FolderOpen": "\xef\x81\xbc", // U+f07c + "FolderPlus": "\xef\x99\x9e", // U+f65e + "FolderTree": "\xef\xa0\x82", // U+f802 + "Font": "\xef\x80\xb1", // U+f031 + "FontAwesome": "\xef\x8a\xb4", // U+f2b4 + "Football": "\xef\x91\x8e", // U+f44e + "Forward": "\xef\x81\x8e", // U+f04e + "ForwardFast": "\xef\x81\x90", // U+f050 + "ForwardStep": "\xef\x81\x91", // U+f051 + "FrancSign": "\xee\x86\x8f", // U+e18f + "Frog": "\xef\x94\xae", // U+f52e + "Futbol": "\xef\x87\xa3", // U+f1e3 + "G": "G", // U+0047 + "Gamepad": "\xef\x84\x9b", // U+f11b + "GasPump": "\xef\x94\xaf", // U+f52f + "Gauge": "\xef\x98\xa4", // U+f624 + "GaugeHigh": "\xef\x98\xa5", // U+f625 + "GaugeSimple": "\xef\x98\xa9", // U+f629 + "GaugeSimpleHigh": "\xef\x98\xaa", // U+f62a + "Gavel": "\xef\x83\xa3", // U+f0e3 + "Gear": "\xef\x80\x93", // U+f013 + "Gears": "\xef\x82\x85", // U+f085 + "Gem": "\xef\x8e\xa5", // U+f3a5 + "Gemini": "\xee\xa1\x89", // U+e849 + "Genderless": "\xef\x88\xad", // U+f22d + "Ghost": "\xef\x9b\xa2", // U+f6e2 + "Gift": "\xef\x81\xab", // U+f06b + "Gifts": "\xef\x9e\x9c", // U+f79c + "GlassWater": "\xee\x93\xb4", // U+e4f4 + "GlassWaterDroplet": "\xee\x93\xb5", // U+e4f5 + "Glasses": "\xef\x94\xb0", // U+f530 + "Globe": "\xef\x82\xac", // U+f0ac + "GolfBallTee": "\xef\x91\x90", // U+f450 + "Gopuram": "\xef\x99\xa4", // U+f664 + "GraduationCap": "\xef\x86\x9d", // U+f19d + "GreaterThan": ">", // U+003e + "GreaterThanEqual": "\xef\x94\xb2", // U+f532 + "Grip": "\xef\x96\x8d", // U+f58d + "GripLines": "\xef\x9e\xa4", // U+f7a4 + "GripLinesVertical": "\xef\x9e\xa5", // U+f7a5 + "GripVertical": "\xef\x96\x8e", // U+f58e + "GroupArrowsRotate": "\xee\x93\xb6", // U+e4f6 + "GuaraniSign": "\xee\x86\x9a", // U+e19a + "Guitar": "\xef\x9e\xa6", // U+f7a6 + "Gun": "\xee\x86\x9b", // U+e19b + "H": "H", // U+0048 + "Hammer": "\xef\x9b\xa3", // U+f6e3 + "Hamsa": "\xef\x99\xa5", // U+f665 + "Hand": "\xef\x89\x96", // U+f256 + "HandBackFist": "\xef\x89\x95", // U+f255 + "HandDots": "\xef\x91\xa1", // U+f461 + "HandFist": "\xef\x9b\x9e", // U+f6de + "HandHolding": "\xef\x92\xbd", // U+f4bd + "HandHoldingDollar": "\xef\x93\x80", // U+f4c0 + "HandHoldingDroplet": "\xef\x93\x81", // U+f4c1 + "HandHoldingHand": "\xee\x93\xb7", // U+e4f7 + "HandHoldingHeart": "\xef\x92\xbe", // U+f4be + "HandHoldingMedical": "\xee\x81\x9c", // U+e05c + "HandLizard": "\xef\x89\x98", // U+f258 + "HandMiddleFinger": "\xef\xa0\x86", // U+f806 + "HandPeace": "\xef\x89\x9b", // U+f25b + "HandPointDown": "\xef\x82\xa7", // U+f0a7 + "HandPointLeft": "\xef\x82\xa5", // U+f0a5 + "HandPointRight": "\xef\x82\xa4", // U+f0a4 + "HandPointUp": "\xef\x82\xa6", // U+f0a6 + "HandPointer": "\xef\x89\x9a", // U+f25a + "HandScissors": "\xef\x89\x97", // U+f257 + "HandSparkles": "\xee\x81\x9d", // U+e05d + "HandSpock": "\xef\x89\x99", // U+f259 + "Handcuffs": "\xee\x93\xb8", // U+e4f8 + "Hands": "\xef\x8a\xa7", // U+f2a7 + "HandsAslInterpreting": "\xef\x8a\xa3", // U+f2a3 + "HandsBound": "\xee\x93\xb9", // U+e4f9 + "HandsBubbles": "\xee\x81\x9e", // U+e05e + "HandsClapping": "\xee\x86\xa8", // U+e1a8 + "HandsHolding": "\xef\x93\x82", // U+f4c2 + "HandsHoldingChild": "\xee\x93\xba", // U+e4fa + "HandsHoldingCircle": "\xee\x93\xbb", // U+e4fb + "HandsPraying": "\xef\x9a\x84", // U+f684 + "Handshake": "\xef\x8a\xb5", // U+f2b5 + "HandshakeAngle": "\xef\x93\x84", // U+f4c4 + "HandshakeSlash": "\xee\x81\xa0", // U+e060 + "Hanukiah": "\xef\x9b\xa6", // U+f6e6 + "HardDrive": "\xef\x82\xa0", // U+f0a0 + "Hashtag": "#", // U+0023 + "HatCowboy": "\xef\xa3\x80", // U+f8c0 + "HatCowboySide": "\xef\xa3\x81", // U+f8c1 + "HatWizard": "\xef\x9b\xa8", // U+f6e8 + "HeadSideCough": "\xee\x81\xa1", // U+e061 + "HeadSideCoughSlash": "\xee\x81\xa2", // U+e062 + "HeadSideMask": "\xee\x81\xa3", // U+e063 + "HeadSideVirus": "\xee\x81\xa4", // U+e064 + "Heading": "\xef\x87\x9c", // U+f1dc + "Headphones": "\xef\x80\xa5", // U+f025 + "Headset": "\xef\x96\x90", // U+f590 + "Heart": "\xef\x80\x84", // U+f004 + "HeartCircleBolt": "\xee\x93\xbc", // U+e4fc + "HeartCircleCheck": "\xee\x93\xbd", // U+e4fd + "HeartCircleExclamation": "\xee\x93\xbe", // U+e4fe + "HeartCircleMinus": "\xee\x93\xbf", // U+e4ff + "HeartCirclePlus": "\xee\x94\x80", // U+e500 + "HeartCircleXmark": "\xee\x94\x81", // U+e501 + "HeartCrack": "\xef\x9e\xa9", // U+f7a9 + "HeartPulse": "\xef\x88\x9e", // U+f21e + "Helicopter": "\xef\x94\xb3", // U+f533 + "HelicopterSymbol": "\xee\x94\x82", // U+e502 + "HelmetSafety": "\xef\xa0\x87", // U+f807 + "HelmetUn": "\xee\x94\x83", // U+e503 + "Hexagon": "\xef\x8c\x92", // U+f312 + "HexagonNodes": "\xee\x9a\x99", // U+e699 + "HexagonNodesBolt": "\xee\x9a\x9a", // U+e69a + "Highlighter": "\xef\x96\x91", // U+f591 + "HillAvalanche": "\xee\x94\x87", // U+e507 + "HillRockslide": "\xee\x94\x88", // U+e508 + "Hippo": "\xef\x9b\xad", // U+f6ed + "HockeyPuck": "\xef\x91\x93", // U+f453 + "HollyBerry": "\xef\x9e\xaa", // U+f7aa + "Horse": "\xef\x9b\xb0", // U+f6f0 + "HorseHead": "\xef\x9e\xab", // U+f7ab + "Hospital": "\xef\x83\xb8", // U+f0f8 + "HospitalUser": "\xef\xa0\x8d", // U+f80d + "HotTubPerson": "\xef\x96\x93", // U+f593 + "Hotdog": "\xef\xa0\x8f", // U+f80f + "Hotel": "\xef\x96\x94", // U+f594 + "Hourglass": "\xef\x89\x94", // U+f254 + "HourglassEnd": "\xef\x89\x93", // U+f253 + "HourglassHalf": "\xef\x89\x92", // U+f252 + "HourglassStart": "\xef\x89\x91", // U+f251 + "House": "\xef\x80\x95", // U+f015 + "HouseChimney": "\xee\x8e\xaf", // U+e3af + "HouseChimneyCrack": "\xef\x9b\xb1", // U+f6f1 + "HouseChimneyMedical": "\xef\x9f\xb2", // U+f7f2 + "HouseChimneyUser": "\xee\x81\xa5", // U+e065 + "HouseChimneyWindow": "\xee\x80\x8d", // U+e00d + "HouseCircleCheck": "\xee\x94\x89", // U+e509 + "HouseCircleExclamation": "\xee\x94\x8a", // U+e50a + "HouseCircleXmark": "\xee\x94\x8b", // U+e50b + "HouseCrack": "\xee\x8e\xb1", // U+e3b1 + "HouseFire": "\xee\x94\x8c", // U+e50c + "HouseFlag": "\xee\x94\x8d", // U+e50d + "HouseFloodWater": "\xee\x94\x8e", // U+e50e + "HouseFloodWaterCircleArrowRight": "\xee\x94\x8f", // U+e50f + "HouseLaptop": "\xee\x81\xa6", // U+e066 + "HouseLock": "\xee\x94\x90", // U+e510 + "HouseMedical": "\xee\x8e\xb2", // U+e3b2 + "HouseMedicalCircleCheck": "\xee\x94\x91", // U+e511 + "HouseMedicalCircleExclamation": "\xee\x94\x92", // U+e512 + "HouseMedicalCircleXmark": "\xee\x94\x93", // U+e513 + "HouseMedicalFlag": "\xee\x94\x94", // U+e514 + "HouseSignal": "\xee\x80\x92", // U+e012 + "HouseTsunami": "\xee\x94\x95", // U+e515 + "HouseUser": "\xee\x86\xb0", // U+e1b0 + "HryvniaSign": "\xef\x9b\xb2", // U+f6f2 + "Hurricane": "\xef\x9d\x91", // U+f751 + "I": "I", // U+0049 + "ICursor": "\xef\x89\x86", // U+f246 + "IceCream": "\xef\xa0\x90", // U+f810 + "Icicles": "\xef\x9e\xad", // U+f7ad + "Icons": "\xef\xa1\xad", // U+f86d + "IdBadge": "\xef\x8b\x81", // U+f2c1 + "IdCard": "\xef\x8b\x82", // U+f2c2 + "IdCardClip": "\xef\x91\xbf", // U+f47f + "Igloo": "\xef\x9e\xae", // U+f7ae + "Image": "\xef\x80\xbe", // U+f03e + "ImagePortrait": "\xef\x8f\xa0", // U+f3e0 + "Images": "\xef\x8c\x82", // U+f302 + "Inbox": "\xef\x80\x9c", // U+f01c + "Indent": "\xef\x80\xbc", // U+f03c + "IndianRupeeSign": "\xee\x86\xbc", // U+e1bc + "Industry": "\xef\x89\xb5", // U+f275 + "Infinity": "\xef\x94\xb4", // U+f534 + "Info": "\xef\x84\xa9", // U+f129 + "Italic": "\xef\x80\xb3", // U+f033 + "J": "J", // U+004a + "Jar": "\xee\x94\x96", // U+e516 + "JarWheat": "\xee\x94\x97", // U+e517 + "Jedi": "\xef\x99\xa9", // U+f669 + "JetFighter": "\xef\x83\xbb", // U+f0fb + "JetFighterUp": "\xee\x94\x98", // U+e518 + "Joint": "\xef\x96\x95", // U+f595 + "JugDetergent": "\xee\x94\x99", // U+e519 + "K": "K", // U+004b + "Kaaba": "\xef\x99\xab", // U+f66b + "Key": "\xef\x82\x84", // U+f084 + "Keyboard": "\xef\x84\x9c", // U+f11c + "Khanda": "\xef\x99\xad", // U+f66d + "KipSign": "\xee\x87\x84", // U+e1c4 + "KitMedical": "\xef\x91\xb9", // U+f479 + "KitchenSet": "\xee\x94\x9a", // U+e51a + "KiwiBird": "\xef\x94\xb5", // U+f535 + "L": "L", // U+004c + "LandMineOn": "\xee\x94\x9b", // U+e51b + "Landmark": "\xef\x99\xaf", // U+f66f + "LandmarkDome": "\xef\x9d\x92", // U+f752 + "LandmarkFlag": "\xee\x94\x9c", // U+e51c + "Language": "\xef\x86\xab", // U+f1ab + "Laptop": "\xef\x84\x89", // U+f109 + "LaptopCode": "\xef\x97\xbc", // U+f5fc + "LaptopFile": "\xee\x94\x9d", // U+e51d + "LaptopMedical": "\xef\xa0\x92", // U+f812 + "LariSign": "\xee\x87\x88", // U+e1c8 + "LayerGroup": "\xef\x97\xbd", // U+f5fd + "Leaf": "\xef\x81\xac", // U+f06c + "LeftLong": "\xef\x8c\x8a", // U+f30a + "LeftRight": "\xef\x8c\xb7", // U+f337 + "Lemon": "\xef\x82\x94", // U+f094 + "Leo": "\xee\xa1\x8a", // U+e84a + "LessThan": "<", // U+003c + "LessThanEqual": "\xef\x94\xb7", // U+f537 + "Libra": "\xee\xa1\x8b", // U+e84b + "LifeRing": "\xef\x87\x8d", // U+f1cd + "Lightbulb": "\xef\x83\xab", // U+f0eb + "LinesLeaning": "\xee\x94\x9e", // U+e51e + "Link": "\xef\x83\x81", // U+f0c1 + "LinkSlash": "\xef\x84\xa7", // U+f127 + "LiraSign": "\xef\x86\x95", // U+f195 + "List": "\xef\x80\xba", // U+f03a + "ListCheck": "\xef\x82\xae", // U+f0ae + "ListOl": "\xef\x83\x8b", // U+f0cb + "ListUl": "\xef\x83\x8a", // U+f0ca + "LitecoinSign": "\xee\x87\x93", // U+e1d3 + "LocationArrow": "\xef\x84\xa4", // U+f124 + "LocationCrosshairs": "\xef\x98\x81", // U+f601 + "LocationDot": "\xef\x8f\x85", // U+f3c5 + "LocationPin": "\xef\x81\x81", // U+f041 + "LocationPinLock": "\xee\x94\x9f", // U+e51f + "Lock": "\xef\x80\xa3", // U+f023 + "LockOpen": "\xef\x8f\x81", // U+f3c1 + "Locust": "\xee\x94\xa0", // U+e520 + "Lungs": "\xef\x98\x84", // U+f604 + "LungsVirus": "\xee\x81\xa7", // U+e067 + "M": "M", // U+004d + "Magnet": "\xef\x81\xb6", // U+f076 + "MagnifyingGlass": "\xef\x80\x82", // U+f002 + "MagnifyingGlassArrowRight": "\xee\x94\xa1", // U+e521 + "MagnifyingGlassChart": "\xee\x94\xa2", // U+e522 + "MagnifyingGlassDollar": "\xef\x9a\x88", // U+f688 + "MagnifyingGlassLocation": "\xef\x9a\x89", // U+f689 + "MagnifyingGlassMinus": "\xef\x80\x90", // U+f010 + "MagnifyingGlassPlus": "\xef\x80\x8e", // U+f00e + "ManatSign": "\xee\x87\x95", // U+e1d5 + "Map": "\xef\x89\xb9", // U+f279 + "MapLocation": "\xef\x96\x9f", // U+f59f + "MapLocationDot": "\xef\x96\xa0", // U+f5a0 + "MapPin": "\xef\x89\xb6", // U+f276 + "Marker": "\xef\x96\xa1", // U+f5a1 + "Mars": "\xef\x88\xa2", // U+f222 + "MarsAndVenus": "\xef\x88\xa4", // U+f224 + "MarsAndVenusBurst": "\xee\x94\xa3", // U+e523 + "MarsDouble": "\xef\x88\xa7", // U+f227 + "MarsStroke": "\xef\x88\xa9", // U+f229 + "MarsStrokeRight": "\xef\x88\xab", // U+f22b + "MarsStrokeUp": "\xef\x88\xaa", // U+f22a + "MartiniGlass": "\xef\x95\xbb", // U+f57b + "MartiniGlassCitrus": "\xef\x95\xa1", // U+f561 + "MartiniGlassEmpty": "\xef\x80\x80", // U+f000 + "Mask": "\xef\x9b\xba", // U+f6fa + "MaskFace": "\xee\x87\x97", // U+e1d7 + "MaskVentilator": "\xee\x94\xa4", // U+e524 + "MasksTheater": "\xef\x98\xb0", // U+f630 + "MattressPillow": "\xee\x94\xa5", // U+e525 + "Maximize": "\xef\x8c\x9e", // U+f31e + "Medal": "\xef\x96\xa2", // U+f5a2 + "Memory": "\xef\x94\xb8", // U+f538 + "Menorah": "\xef\x99\xb6", // U+f676 + "Mercury": "\xef\x88\xa3", // U+f223 + "Message": "\xef\x89\xba", // U+f27a + "Meteor": "\xef\x9d\x93", // U+f753 + "Microchip": "\xef\x8b\x9b", // U+f2db + "Microphone": "\xef\x84\xb0", // U+f130 + "MicrophoneLines": "\xef\x8f\x89", // U+f3c9 + "MicrophoneLinesSlash": "\xef\x94\xb9", // U+f539 + "MicrophoneSlash": "\xef\x84\xb1", // U+f131 + "Microscope": "\xef\x98\x90", // U+f610 + "MillSign": "\xee\x87\xad", // U+e1ed + "Minimize": "\xef\x9e\x8c", // U+f78c + "Minus": "\xef\x81\xa8", // U+f068 + "Mitten": "\xef\x9e\xb5", // U+f7b5 + "Mobile": "\xef\x8f\x8e", // U+f3ce + "MobileButton": "\xef\x84\x8b", // U+f10b + "MobileRetro": "\xee\x94\xa7", // U+e527 + "MobileScreen": "\xef\x8f\x8f", // U+f3cf + "MobileScreenButton": "\xef\x8f\x8d", // U+f3cd + "MobileVibrate": "\xee\xa0\x96", // U+e816 + "MoneyBill": "\xef\x83\x96", // U+f0d6 + "MoneyBill1": "\xef\x8f\x91", // U+f3d1 + "MoneyBill1Wave": "\xef\x94\xbb", // U+f53b + "MoneyBillTransfer": "\xee\x94\xa8", // U+e528 + "MoneyBillTrendUp": "\xee\x94\xa9", // U+e529 + "MoneyBillWave": "\xef\x94\xba", // U+f53a + "MoneyBillWheat": "\xee\x94\xaa", // U+e52a + "MoneyBills": "\xee\x87\xb3", // U+e1f3 + "MoneyCheck": "\xef\x94\xbc", // U+f53c + "MoneyCheckDollar": "\xef\x94\xbd", // U+f53d + "Monument": "\xef\x96\xa6", // U+f5a6 + "Moon": "\xef\x86\x86", // U+f186 + "MortarPestle": "\xef\x96\xa7", // U+f5a7 + "Mosque": "\xef\x99\xb8", // U+f678 + "Mosquito": "\xee\x94\xab", // U+e52b + "MosquitoNet": "\xee\x94\xac", // U+e52c + "Motorcycle": "\xef\x88\x9c", // U+f21c + "Mound": "\xee\x94\xad", // U+e52d + "Mountain": "\xef\x9b\xbc", // U+f6fc + "MountainCity": "\xee\x94\xae", // U+e52e + "MountainSun": "\xee\x94\xaf", // U+e52f + "MugHot": "\xef\x9e\xb6", // U+f7b6 + "MugSaucer": "\xef\x83\xb4", // U+f0f4 + "Music": "\xef\x80\x81", // U+f001 + "N": "N", // U+004e + "NairaSign": "\xee\x87\xb6", // U+e1f6 + "NetworkWired": "\xef\x9b\xbf", // U+f6ff + "Neuter": "\xef\x88\xac", // U+f22c + "Newspaper": "\xef\x87\xaa", // U+f1ea + "NonBinary": "\xee\xa0\x87", // U+e807 + "NotEqual": "\xef\x94\xbe", // U+f53e + "Notdef": "\xee\x87\xbe", // U+e1fe + "NoteSticky": "\xef\x89\x89", // U+f249 + "NotesMedical": "\xef\x92\x81", // U+f481 + "O": "O", // U+004f + "ObjectGroup": "\xef\x89\x87", // U+f247 + "ObjectUngroup": "\xef\x89\x88", // U+f248 + "Octagon": "\xef\x8c\x86", // U+f306 + "OilCan": "\xef\x98\x93", // U+f613 + "OilWell": "\xee\x94\xb2", // U+e532 + "Om": "\xef\x99\xb9", // U+f679 + "Otter": "\xef\x9c\x80", // U+f700 + "Outdent": "\xef\x80\xbb", // U+f03b + "P": "P", // U+0050 + "Pager": "\xef\xa0\x95", // U+f815 + "PaintRoller": "\xef\x96\xaa", // U+f5aa + "Paintbrush": "\xef\x87\xbc", // U+f1fc + "Palette": "\xef\x94\xbf", // U+f53f + "Pallet": "\xef\x92\x82", // U+f482 + "Panorama": "\xee\x88\x89", // U+e209 + "PaperPlane": "\xef\x87\x98", // U+f1d8 + "Paperclip": "\xef\x83\x86", // U+f0c6 + "ParachuteBox": "\xef\x93\x8d", // U+f4cd + "Paragraph": "\xef\x87\x9d", // U+f1dd + "Passport": "\xef\x96\xab", // U+f5ab + "Paste": "\xef\x83\xaa", // U+f0ea + "Pause": "\xef\x81\x8c", // U+f04c + "Paw": "\xef\x86\xb0", // U+f1b0 + "Peace": "\xef\x99\xbc", // U+f67c + "Pen": "\xef\x8c\x84", // U+f304 + "PenClip": "\xef\x8c\x85", // U+f305 + "PenFancy": "\xef\x96\xac", // U+f5ac + "PenNib": "\xef\x96\xad", // U+f5ad + "PenRuler": "\xef\x96\xae", // U+f5ae + "PenToSquare": "\xef\x81\x84", // U+f044 + "Pencil": "\xef\x8c\x83", // U+f303 + "Pentagon": "\xee\x9e\x90", // U+e790 + "PeopleArrows": "\xee\x81\xa8", // U+e068 + "PeopleCarryBox": "\xef\x93\x8e", // U+f4ce + "PeopleGroup": "\xee\x94\xb3", // U+e533 + "PeopleLine": "\xee\x94\xb4", // U+e534 + "PeoplePulling": "\xee\x94\xb5", // U+e535 + "PeopleRobbery": "\xee\x94\xb6", // U+e536 + "PeopleRoof": "\xee\x94\xb7", // U+e537 + "PepperHot": "\xef\xa0\x96", // U+f816 + "Percent": "%", // U+0025 + "Person": "\xef\x86\x83", // U+f183 + "PersonArrowDownToLine": "\xee\x94\xb8", // U+e538 + "PersonArrowUpFromLine": "\xee\x94\xb9", // U+e539 + "PersonBiking": "\xef\xa1\x8a", // U+f84a + "PersonBooth": "\xef\x9d\x96", // U+f756 + "PersonBreastfeeding": "\xee\x94\xba", // U+e53a + "PersonBurst": "\xee\x94\xbb", // U+e53b + "PersonCane": "\xee\x94\xbc", // U+e53c + "PersonChalkboard": "\xee\x94\xbd", // U+e53d + "PersonCircleCheck": "\xee\x94\xbe", // U+e53e + "PersonCircleExclamation": "\xee\x94\xbf", // U+e53f + "PersonCircleMinus": "\xee\x95\x80", // U+e540 + "PersonCirclePlus": "\xee\x95\x81", // U+e541 + "PersonCircleQuestion": "\xee\x95\x82", // U+e542 + "PersonCircleXmark": "\xee\x95\x83", // U+e543 + "PersonDigging": "\xef\xa1\x9e", // U+f85e + "PersonDotsFromLine": "\xef\x91\xb0", // U+f470 + "PersonDress": "\xef\x86\x82", // U+f182 + "PersonDressBurst": "\xee\x95\x84", // U+e544 + "PersonDrowning": "\xee\x95\x85", // U+e545 + "PersonFalling": "\xee\x95\x86", // U+e546 + "PersonFallingBurst": "\xee\x95\x87", // U+e547 + "PersonHalfDress": "\xee\x95\x88", // U+e548 + "PersonHarassing": "\xee\x95\x89", // U+e549 + "PersonHiking": "\xef\x9b\xac", // U+f6ec + "PersonMilitaryPointing": "\xee\x95\x8a", // U+e54a + "PersonMilitaryRifle": "\xee\x95\x8b", // U+e54b + "PersonMilitaryToPerson": "\xee\x95\x8c", // U+e54c + "PersonPraying": "\xef\x9a\x83", // U+f683 + "PersonPregnant": "\xee\x8c\x9e", // U+e31e + "PersonRays": "\xee\x95\x8d", // U+e54d + "PersonRifle": "\xee\x95\x8e", // U+e54e + "PersonRunning": "\xef\x9c\x8c", // U+f70c + "PersonShelter": "\xee\x95\x8f", // U+e54f + "PersonSkating": "\xef\x9f\x85", // U+f7c5 + "PersonSkiing": "\xef\x9f\x89", // U+f7c9 + "PersonSkiingNordic": "\xef\x9f\x8a", // U+f7ca + "PersonSnowboarding": "\xef\x9f\x8e", // U+f7ce + "PersonSwimming": "\xef\x97\x84", // U+f5c4 + "PersonThroughWindow": "\xee\x96\xa9", // U+e5a9 + "PersonWalking": "\xef\x95\x94", // U+f554 + "PersonWalkingArrowLoopLeft": "\xee\x95\x91", // U+e551 + "PersonWalkingArrowRight": "\xee\x95\x92", // U+e552 + "PersonWalkingDashedLineArrowRight": "\xee\x95\x93", // U+e553 + "PersonWalkingLuggage": "\xee\x95\x94", // U+e554 + "PersonWalkingWithCane": "\xef\x8a\x9d", // U+f29d + "PesetaSign": "\xee\x88\xa1", // U+e221 + "PesoSign": "\xee\x88\xa2", // U+e222 + "Phone": "\xef\x82\x95", // U+f095 + "PhoneFlip": "\xef\xa1\xb9", // U+f879 + "PhoneSlash": "\xef\x8f\x9d", // U+f3dd + "PhoneVolume": "\xef\x8a\xa0", // U+f2a0 + "PhotoFilm": "\xef\xa1\xbc", // U+f87c + "PictureInPicture": "\xee\xa0\x8b", // U+e80b + "PiggyBank": "\xef\x93\x93", // U+f4d3 + "Pills": "\xef\x92\x84", // U+f484 + "Pisces": "\xee\xa1\x8c", // U+e84c + "PizzaSlice": "\xef\xa0\x98", // U+f818 + "PlaceOfWorship": "\xef\x99\xbf", // U+f67f + "Plane": "\xef\x81\xb2", // U+f072 + "PlaneArrival": "\xef\x96\xaf", // U+f5af + "PlaneCircleCheck": "\xee\x95\x95", // U+e555 + "PlaneCircleExclamation": "\xee\x95\x96", // U+e556 + "PlaneCircleXmark": "\xee\x95\x97", // U+e557 + "PlaneDeparture": "\xef\x96\xb0", // U+f5b0 + "PlaneLock": "\xee\x95\x98", // U+e558 + "PlaneSlash": "\xee\x81\xa9", // U+e069 + "PlaneUp": "\xee\x88\xad", // U+e22d + "PlantWilt": "\xee\x96\xaa", // U+e5aa + "PlateWheat": "\xee\x95\x9a", // U+e55a + "Play": "\xef\x81\x8b", // U+f04b + "Plug": "\xef\x87\xa6", // U+f1e6 + "PlugCircleBolt": "\xee\x95\x9b", // U+e55b + "PlugCircleCheck": "\xee\x95\x9c", // U+e55c + "PlugCircleExclamation": "\xee\x95\x9d", // U+e55d + "PlugCircleMinus": "\xee\x95\x9e", // U+e55e + "PlugCirclePlus": "\xee\x95\x9f", // U+e55f + "PlugCircleXmark": "\xee\x95\xa0", // U+e560 + "Plus": "+", // U+002b + "PlusMinus": "\xee\x90\xbc", // U+e43c + "Podcast": "\xef\x8b\x8e", // U+f2ce + "Poo": "\xef\x8b\xbe", // U+f2fe + "PooStorm": "\xef\x9d\x9a", // U+f75a + "Poop": "\xef\x98\x99", // U+f619 + "PowerOff": "\xef\x80\x91", // U+f011 + "Prescription": "\xef\x96\xb1", // U+f5b1 + "PrescriptionBottle": "\xef\x92\x85", // U+f485 + "PrescriptionBottleMedical": "\xef\x92\x86", // U+f486 + "Print": "\xef\x80\xaf", // U+f02f + "PumpMedical": "\xee\x81\xaa", // U+e06a + "PumpSoap": "\xee\x81\xab", // U+e06b + "PuzzlePiece": "\xef\x84\xae", // U+f12e + "Q": "Q", // U+0051 + "Qrcode": "\xef\x80\xa9", // U+f029 + "Question": "?", // U+003f + "QuoteLeft": "\xef\x84\x8d", // U+f10d + "QuoteRight": "\xef\x84\x8e", // U+f10e + "R": "R", // U+0052 + "Radiation": "\xef\x9e\xb9", // U+f7b9 + "Radio": "\xef\xa3\x97", // U+f8d7 + "Rainbow": "\xef\x9d\x9b", // U+f75b + "RankingStar": "\xee\x95\xa1", // U+e561 + "Receipt": "\xef\x95\x83", // U+f543 + "RecordVinyl": "\xef\xa3\x99", // U+f8d9 + "RectangleAd": "\xef\x99\x81", // U+f641 + "RectangleList": "\xef\x80\xa2", // U+f022 + "RectangleXmark": "\xef\x90\x90", // U+f410 + "Recycle": "\xef\x86\xb8", // U+f1b8 + "Registered": "\xef\x89\x9d", // U+f25d + "Repeat": "\xef\x8d\xa3", // U+f363 + "Reply": "\xef\x8f\xa5", // U+f3e5 + "ReplyAll": "\xef\x84\xa2", // U+f122 + "Republican": "\xef\x9d\x9e", // U+f75e + "Restroom": "\xef\x9e\xbd", // U+f7bd + "Retweet": "\xef\x81\xb9", // U+f079 + "Ribbon": "\xef\x93\x96", // U+f4d6 + "RightFromBracket": "\xef\x8b\xb5", // U+f2f5 + "RightLeft": "\xef\x8d\xa2", // U+f362 + "RightLong": "\xef\x8c\x8b", // U+f30b + "RightToBracket": "\xef\x8b\xb6", // U+f2f6 + "Ring": "\xef\x9c\x8b", // U+f70b + "Road": "\xef\x80\x98", // U+f018 + "RoadBarrier": "\xee\x95\xa2", // U+e562 + "RoadBridge": "\xee\x95\xa3", // U+e563 + "RoadCircleCheck": "\xee\x95\xa4", // U+e564 + "RoadCircleExclamation": "\xee\x95\xa5", // U+e565 + "RoadCircleXmark": "\xee\x95\xa6", // U+e566 + "RoadLock": "\xee\x95\xa7", // U+e567 + "RoadSpikes": "\xee\x95\xa8", // U+e568 + "Robot": "\xef\x95\x84", // U+f544 + "Rocket": "\xef\x84\xb5", // U+f135 + "Rotate": "\xef\x8b\xb1", // U+f2f1 + "RotateLeft": "\xef\x8b\xaa", // U+f2ea + "RotateRight": "\xef\x8b\xb9", // U+f2f9 + "Route": "\xef\x93\x97", // U+f4d7 + "Rss": "\xef\x82\x9e", // U+f09e + "RubleSign": "\xef\x85\x98", // U+f158 + "Rug": "\xee\x95\xa9", // U+e569 + "Ruler": "\xef\x95\x85", // U+f545 + "RulerCombined": "\xef\x95\x86", // U+f546 + "RulerHorizontal": "\xef\x95\x87", // U+f547 + "RulerVertical": "\xef\x95\x88", // U+f548 + "RupeeSign": "\xef\x85\x96", // U+f156 + "RupiahSign": "\xee\x88\xbd", // U+e23d + "S": "S", // U+0053 + "SackDollar": "\xef\xa0\x9d", // U+f81d + "SackXmark": "\xee\x95\xaa", // U+e56a + "Sagittarius": "\xee\xa1\x8d", // U+e84d + "Sailboat": "\xee\x91\x85", // U+e445 + "Satellite": "\xef\x9e\xbf", // U+f7bf + "SatelliteDish": "\xef\x9f\x80", // U+f7c0 + "ScaleBalanced": "\xef\x89\x8e", // U+f24e + "ScaleUnbalanced": "\xef\x94\x95", // U+f515 + "ScaleUnbalancedFlip": "\xef\x94\x96", // U+f516 + "School": "\xef\x95\x89", // U+f549 + "SchoolCircleCheck": "\xee\x95\xab", // U+e56b + "SchoolCircleExclamation": "\xee\x95\xac", // U+e56c + "SchoolCircleXmark": "\xee\x95\xad", // U+e56d + "SchoolFlag": "\xee\x95\xae", // U+e56e + "SchoolLock": "\xee\x95\xaf", // U+e56f + "Scissors": "\xef\x83\x84", // U+f0c4 + "Scorpio": "\xee\xa1\x8e", // U+e84e + "Screwdriver": "\xef\x95\x8a", // U+f54a + "ScrewdriverWrench": "\xef\x9f\x99", // U+f7d9 + "Scroll": "\xef\x9c\x8e", // U+f70e + "ScrollTorah": "\xef\x9a\xa0", // U+f6a0 + "SdCard": "\xef\x9f\x82", // U+f7c2 + "Section": "\xee\x91\x87", // U+e447 + "Seedling": "\xef\x93\x98", // U+f4d8 + "Septagon": "\xee\xa0\xa0", // U+e820 + "Server": "\xef\x88\xb3", // U+f233 + "Shapes": "\xef\x98\x9f", // U+f61f + "Share": "\xef\x81\xa4", // U+f064 + "ShareFromSquare": "\xef\x85\x8d", // U+f14d + "ShareNodes": "\xef\x87\xa0", // U+f1e0 + "SheetPlastic": "\xee\x95\xb1", // U+e571 + "ShekelSign": "\xef\x88\x8b", // U+f20b + "Shield": "\xef\x84\xb2", // U+f132 + "ShieldCat": "\xee\x95\xb2", // U+e572 + "ShieldDog": "\xee\x95\xb3", // U+e573 + "ShieldHalved": "\xef\x8f\xad", // U+f3ed + "ShieldHeart": "\xee\x95\xb4", // U+e574 + "ShieldVirus": "\xee\x81\xac", // U+e06c + "Ship": "\xef\x88\x9a", // U+f21a + "Shirt": "\xef\x95\x93", // U+f553 + "ShoePrints": "\xef\x95\x8b", // U+f54b + "Shop": "\xef\x95\x8f", // U+f54f + "ShopLock": "\xee\x92\xa5", // U+e4a5 + "ShopSlash": "\xee\x81\xb0", // U+e070 + "Shower": "\xef\x8b\x8c", // U+f2cc + "Shrimp": "\xee\x91\x88", // U+e448 + "Shuffle": "\xef\x81\xb4", // U+f074 + "ShuttleSpace": "\xef\x86\x97", // U+f197 + "SignHanging": "\xef\x93\x99", // U+f4d9 + "Signal": "\xef\x80\x92", // U+f012 + "Signature": "\xef\x96\xb7", // U+f5b7 + "SignsPost": "\xef\x89\xb7", // U+f277 + "SimCard": "\xef\x9f\x84", // U+f7c4 + "SingleQuoteLeft": "\xee\xa0\x9b", // U+e81b + "SingleQuoteRight": "\xee\xa0\x9c", // U+e81c + "Sink": "\xee\x81\xad", // U+e06d + "Sitemap": "\xef\x83\xa8", // U+f0e8 + "Skull": "\xef\x95\x8c", // U+f54c + "SkullCrossbones": "\xef\x9c\x94", // U+f714 + "Slash": "\xef\x9c\x95", // U+f715 + "Sleigh": "\xef\x9f\x8c", // U+f7cc + "Sliders": "\xef\x87\x9e", // U+f1de + "Smog": "\xef\x9d\x9f", // U+f75f + "Smoking": "\xef\x92\x8d", // U+f48d + "Snowflake": "\xef\x8b\x9c", // U+f2dc + "Snowman": "\xef\x9f\x90", // U+f7d0 + "Snowplow": "\xef\x9f\x92", // U+f7d2 + "Soap": "\xee\x81\xae", // U+e06e + "Socks": "\xef\x9a\x96", // U+f696 + "SolarPanel": "\xef\x96\xba", // U+f5ba + "Sort": "\xef\x83\x9c", // U+f0dc + "SortDown": "\xef\x83\x9d", // U+f0dd + "SortUp": "\xef\x83\x9e", // U+f0de + "Spa": "\xef\x96\xbb", // U+f5bb + "SpaghettiMonsterFlying": "\xef\x99\xbb", // U+f67b + "SpellCheck": "\xef\xa2\x91", // U+f891 + "Spider": "\xef\x9c\x97", // U+f717 + "Spinner": "\xef\x84\x90", // U+f110 + "Spiral": "\xee\xa0\x8a", // U+e80a + "Splotch": "\xef\x96\xbc", // U+f5bc + "Spoon": "\xef\x8b\xa5", // U+f2e5 + "SprayCan": "\xef\x96\xbd", // U+f5bd + "SprayCanSparkles": "\xef\x97\x90", // U+f5d0 + "Square": "\xef\x83\x88", // U+f0c8 + "SquareArrowUpRight": "\xef\x85\x8c", // U+f14c + "SquareBinary": "\xee\x9a\x9b", // U+e69b + "SquareCaretDown": "\xef\x85\x90", // U+f150 + "SquareCaretLeft": "\xef\x86\x91", // U+f191 + "SquareCaretRight": "\xef\x85\x92", // U+f152 + "SquareCaretUp": "\xef\x85\x91", // U+f151 + "SquareCheck": "\xef\x85\x8a", // U+f14a + "SquareEnvelope": "\xef\x86\x99", // U+f199 + "SquareFull": "\xef\x91\x9c", // U+f45c + "SquareH": "\xef\x83\xbd", // U+f0fd + "SquareMinus": "\xef\x85\x86", // U+f146 + "SquareNfi": "\xee\x95\xb6", // U+e576 + "SquareParking": "\xef\x95\x80", // U+f540 + "SquarePen": "\xef\x85\x8b", // U+f14b + "SquarePersonConfined": "\xee\x95\xb7", // U+e577 + "SquarePhone": "\xef\x82\x98", // U+f098 + "SquarePhoneFlip": "\xef\xa1\xbb", // U+f87b + "SquarePlus": "\xef\x83\xbe", // U+f0fe + "SquarePollHorizontal": "\xef\x9a\x82", // U+f682 + "SquarePollVertical": "\xef\x9a\x81", // U+f681 + "SquareRootVariable": "\xef\x9a\x98", // U+f698 + "SquareRss": "\xef\x85\x83", // U+f143 + "SquareShareNodes": "\xef\x87\xa1", // U+f1e1 + "SquareUpRight": "\xef\x8d\xa0", // U+f360 + "SquareVirus": "\xee\x95\xb8", // U+e578 + "SquareXmark": "\xef\x8b\x93", // U+f2d3 + "StaffSnake": "\xee\x95\xb9", // U+e579 + "Stairs": "\xee\x8a\x89", // U+e289 + "Stamp": "\xef\x96\xbf", // U+f5bf + "Stapler": "\xee\x96\xaf", // U+e5af + "Star": "\xef\x80\x85", // U+f005 + "StarAndCrescent": "\xef\x9a\x99", // U+f699 + "StarHalf": "\xef\x82\x89", // U+f089 + "StarHalfStroke": "\xef\x97\x80", // U+f5c0 + "StarOfDavid": "\xef\x9a\x9a", // U+f69a + "StarOfLife": "\xef\x98\xa1", // U+f621 + "SterlingSign": "\xef\x85\x94", // U+f154 + "Stethoscope": "\xef\x83\xb1", // U+f0f1 + "Stop": "\xef\x81\x8d", // U+f04d + "Stopwatch": "\xef\x8b\xb2", // U+f2f2 + "Stopwatch20": "\xee\x81\xaf", // U+e06f + "Store": "\xef\x95\x8e", // U+f54e + "StoreSlash": "\xee\x81\xb1", // U+e071 + "StreetView": "\xef\x88\x9d", // U+f21d + "Strikethrough": "\xef\x83\x8c", // U+f0cc + "Stroopwafel": "\xef\x95\x91", // U+f551 + "Subscript": "\xef\x84\xac", // U+f12c + "Suitcase": "\xef\x83\xb2", // U+f0f2 + "SuitcaseMedical": "\xef\x83\xba", // U+f0fa + "SuitcaseRolling": "\xef\x97\x81", // U+f5c1 + "Sun": "\xef\x86\x85", // U+f185 + "SunPlantWilt": "\xee\x95\xba", // U+e57a + "Superscript": "\xef\x84\xab", // U+f12b + "Swatchbook": "\xef\x97\x83", // U+f5c3 + "Synagogue": "\xef\x9a\x9b", // U+f69b + "Syringe": "\xef\x92\x8e", // U+f48e + "T": "T", // U+0054 + "Table": "\xef\x83\x8e", // U+f0ce + "TableCells": "\xef\x80\x8a", // U+f00a + "TableCellsColumnLock": "\xee\x99\xb8", // U+e678 + "TableCellsLarge": "\xef\x80\x89", // U+f009 + "TableCellsRowLock": "\xee\x99\xba", // U+e67a + "TableCellsRowUnlock": "\xee\x9a\x91", // U+e691 + "TableColumns": "\xef\x83\x9b", // U+f0db + "TableList": "\xef\x80\x8b", // U+f00b + "TableTennisPaddleBall": "\xef\x91\x9d", // U+f45d + "Tablet": "\xef\x8f\xbb", // U+f3fb + "TabletButton": "\xef\x84\x8a", // U+f10a + "TabletScreenButton": "\xef\x8f\xba", // U+f3fa + "Tablets": "\xef\x92\x90", // U+f490 + "TachographDigital": "\xef\x95\xa6", // U+f566 + "Tag": "\xef\x80\xab", // U+f02b + "Tags": "\xef\x80\xac", // U+f02c + "Tape": "\xef\x93\x9b", // U+f4db + "Tarp": "\xee\x95\xbb", // U+e57b + "TarpDroplet": "\xee\x95\xbc", // U+e57c + "Taurus": "\xee\xa1\x8f", // U+e84f + "Taxi": "\xef\x86\xba", // U+f1ba + "Teeth": "\xef\x98\xae", // U+f62e + "TeethOpen": "\xef\x98\xaf", // U+f62f + "TemperatureArrowDown": "\xee\x80\xbf", // U+e03f + "TemperatureArrowUp": "\xee\x81\x80", // U+e040 + "TemperatureEmpty": "\xef\x8b\x8b", // U+f2cb + "TemperatureFull": "\xef\x8b\x87", // U+f2c7 + "TemperatureHalf": "\xef\x8b\x89", // U+f2c9 + "TemperatureHigh": "\xef\x9d\xa9", // U+f769 + "TemperatureLow": "\xef\x9d\xab", // U+f76b + "TemperatureQuarter": "\xef\x8b\x8a", // U+f2ca + "TemperatureThreeQuarters": "\xef\x8b\x88", // U+f2c8 + "TengeSign": "\xef\x9f\x97", // U+f7d7 + "Tent": "\xee\x95\xbd", // U+e57d + "TentArrowDownToLine": "\xee\x95\xbe", // U+e57e + "TentArrowLeftRight": "\xee\x95\xbf", // U+e57f + "TentArrowTurnLeft": "\xee\x96\x80", // U+e580 + "TentArrowsDown": "\xee\x96\x81", // U+e581 + "Tents": "\xee\x96\x82", // U+e582 + "Terminal": "\xef\x84\xa0", // U+f120 + "TextHeight": "\xef\x80\xb4", // U+f034 + "TextSlash": "\xef\xa1\xbd", // U+f87d + "TextWidth": "\xef\x80\xb5", // U+f035 + "Thermometer": "\xef\x92\x91", // U+f491 + "ThumbsDown": "\xef\x85\xa5", // U+f165 + "ThumbsUp": "\xef\x85\xa4", // U+f164 + "Thumbtack": "\xef\x82\x8d", // U+f08d + "ThumbtackSlash": "\xee\x9a\x8f", // U+e68f + "Ticket": "\xef\x85\x85", // U+f145 + "TicketSimple": "\xef\x8f\xbf", // U+f3ff + "Timeline": "\xee\x8a\x9c", // U+e29c + "ToggleOff": "\xef\x88\x84", // U+f204 + "ToggleOn": "\xef\x88\x85", // U+f205 + "Toilet": "\xef\x9f\x98", // U+f7d8 + "ToiletPaper": "\xef\x9c\x9e", // U+f71e + "ToiletPaperSlash": "\xee\x81\xb2", // U+e072 + "ToiletPortable": "\xee\x96\x83", // U+e583 + "ToiletsPortable": "\xee\x96\x84", // U+e584 + "Toolbox": "\xef\x95\x92", // U+f552 + "Tooth": "\xef\x97\x89", // U+f5c9 + "ToriiGate": "\xef\x9a\xa1", // U+f6a1 + "Tornado": "\xef\x9d\xaf", // U+f76f + "TowerBroadcast": "\xef\x94\x99", // U+f519 + "TowerCell": "\xee\x96\x85", // U+e585 + "TowerObservation": "\xee\x96\x86", // U+e586 + "Tractor": "\xef\x9c\xa2", // U+f722 + "Trademark": "\xef\x89\x9c", // U+f25c + "TrafficLight": "\xef\x98\xb7", // U+f637 + "Trailer": "\xee\x81\x81", // U+e041 + "Train": "\xef\x88\xb8", // U+f238 + "TrainSubway": "\xef\x88\xb9", // U+f239 + "TrainTram": "\xee\x96\xb4", // U+e5b4 + "Transgender": "\xef\x88\xa5", // U+f225 + "Trash": "\xef\x87\xb8", // U+f1f8 + "TrashArrowUp": "\xef\xa0\xa9", // U+f829 + "TrashCan": "\xef\x8b\xad", // U+f2ed + "TrashCanArrowUp": "\xef\xa0\xaa", // U+f82a + "Tree": "\xef\x86\xbb", // U+f1bb + "TreeCity": "\xee\x96\x87", // U+e587 + "TriangleExclamation": "\xef\x81\xb1", // U+f071 + "Trophy": "\xef\x82\x91", // U+f091 + "Trowel": "\xee\x96\x89", // U+e589 + "TrowelBricks": "\xee\x96\x8a", // U+e58a + "Truck": "\xef\x83\x91", // U+f0d1 + "TruckArrowRight": "\xee\x96\x8b", // U+e58b + "TruckDroplet": "\xee\x96\x8c", // U+e58c + "TruckFast": "\xef\x92\x8b", // U+f48b + "TruckField": "\xee\x96\x8d", // U+e58d + "TruckFieldUn": "\xee\x96\x8e", // U+e58e + "TruckFront": "\xee\x8a\xb7", // U+e2b7 + "TruckMedical": "\xef\x83\xb9", // U+f0f9 + "TruckMonster": "\xef\x98\xbb", // U+f63b + "TruckMoving": "\xef\x93\x9f", // U+f4df + "TruckPickup": "\xef\x98\xbc", // U+f63c + "TruckPlane": "\xee\x96\x8f", // U+e58f + "TruckRampBox": "\xef\x93\x9e", // U+f4de + "Tty": "\xef\x87\xa4", // U+f1e4 + "TurkishLiraSign": "\xee\x8a\xbb", // U+e2bb + "TurnDown": "\xef\x8e\xbe", // U+f3be + "TurnUp": "\xef\x8e\xbf", // U+f3bf + "Tv": "\xef\x89\xac", // U+f26c + "U": "U", // U+0055 + "Umbrella": "\xef\x83\xa9", // U+f0e9 + "UmbrellaBeach": "\xef\x97\x8a", // U+f5ca + "Underline": "\xef\x83\x8d", // U+f0cd + "UniversalAccess": "\xef\x8a\x9a", // U+f29a + "Unlock": "\xef\x82\x9c", // U+f09c + "UnlockKeyhole": "\xef\x84\xbe", // U+f13e + "UpDown": "\xef\x8c\xb8", // U+f338 + "UpDownLeftRight": "\xef\x82\xb2", // U+f0b2 + "UpLong": "\xef\x8c\x8c", // U+f30c + "UpRightAndDownLeftFromCenter": "\xef\x90\xa4", // U+f424 + "UpRightFromSquare": "\xef\x8d\x9d", // U+f35d + "Upload": "\xef\x82\x93", // U+f093 + "User": "\xef\x80\x87", // U+f007 + "UserAstronaut": "\xef\x93\xbb", // U+f4fb + "UserCheck": "\xef\x93\xbc", // U+f4fc + "UserClock": "\xef\x93\xbd", // U+f4fd + "UserDoctor": "\xef\x83\xb0", // U+f0f0 + "UserGear": "\xef\x93\xbe", // U+f4fe + "UserGraduate": "\xef\x94\x81", // U+f501 + "UserGroup": "\xef\x94\x80", // U+f500 + "UserInjured": "\xef\x9c\xa8", // U+f728 + "UserLock": "\xef\x94\x82", // U+f502 + "UserMinus": "\xef\x94\x83", // U+f503 + "UserNinja": "\xef\x94\x84", // U+f504 + "UserNurse": "\xef\xa0\xaf", // U+f82f + "UserPen": "\xef\x93\xbf", // U+f4ff + "UserPlus": "\xef\x88\xb4", // U+f234 + "UserSecret": "\xef\x88\x9b", // U+f21b + "UserShield": "\xef\x94\x85", // U+f505 + "UserSlash": "\xef\x94\x86", // U+f506 + "UserTag": "\xef\x94\x87", // U+f507 + "UserTie": "\xef\x94\x88", // U+f508 + "UserXmark": "\xef\x88\xb5", // U+f235 + "Users": "\xef\x83\x80", // U+f0c0 + "UsersBetweenLines": "\xee\x96\x91", // U+e591 + "UsersGear": "\xef\x94\x89", // U+f509 + "UsersLine": "\xee\x96\x92", // U+e592 + "UsersRays": "\xee\x96\x93", // U+e593 + "UsersRectangle": "\xee\x96\x94", // U+e594 + "UsersSlash": "\xee\x81\xb3", // U+e073 + "UsersViewfinder": "\xee\x96\x95", // U+e595 + "Utensils": "\xef\x8b\xa7", // U+f2e7 + "V": "V", // U+0056 + "VanShuttle": "\xef\x96\xb6", // U+f5b6 + "Vault": "\xee\x8b\x85", // U+e2c5 + "Venus": "\xef\x88\xa1", // U+f221 + "VenusDouble": "\xef\x88\xa6", // U+f226 + "VenusMars": "\xef\x88\xa8", // U+f228 + "Vest": "\xee\x82\x85", // U+e085 + "VestPatches": "\xee\x82\x86", // U+e086 + "Vial": "\xef\x92\x92", // U+f492 + "VialCircleCheck": "\xee\x96\x96", // U+e596 + "VialVirus": "\xee\x96\x97", // U+e597 + "Vials": "\xef\x92\x93", // U+f493 + "Video": "\xef\x80\xbd", // U+f03d + "VideoSlash": "\xef\x93\xa2", // U+f4e2 + "Vihara": "\xef\x9a\xa7", // U+f6a7 + "Virgo": "\xee\xa1\x90", // U+e850 + "Virus": "\xee\x81\xb4", // U+e074 + "VirusCovid": "\xee\x92\xa8", // U+e4a8 + "VirusCovidSlash": "\xee\x92\xa9", // U+e4a9 + "VirusSlash": "\xee\x81\xb5", // U+e075 + "Viruses": "\xee\x81\xb6", // U+e076 + "Voicemail": "\xef\xa2\x97", // U+f897 + "Volcano": "\xef\x9d\xb0", // U+f770 + "Volleyball": "\xef\x91\x9f", // U+f45f + "Volume": "\xef\x9a\xa8", // U+f6a8 + "VolumeHigh": "\xef\x80\xa8", // U+f028 + "VolumeLow": "\xef\x80\xa7", // U+f027 + "VolumeOff": "\xef\x80\xa6", // U+f026 + "VolumeXmark": "\xef\x9a\xa9", // U+f6a9 + "VrCardboard": "\xef\x9c\xa9", // U+f729 + "W": "W", // U+0057 + "WalkieTalkie": "\xef\xa3\xaf", // U+f8ef + "Wallet": "\xef\x95\x95", // U+f555 + "WandMagic": "\xef\x83\x90", // U+f0d0 + "WandMagicSparkles": "\xee\x8b\x8a", // U+e2ca + "WandSparkles": "\xef\x9c\xab", // U+f72b + "Warehouse": "\xef\x92\x94", // U+f494 + "Water": "\xef\x9d\xb3", // U+f773 + "WaterLadder": "\xef\x97\x85", // U+f5c5 + "WaveSquare": "\xef\xa0\xbe", // U+f83e + "WebAwesome": "\xee\x9a\x82", // U+e682 + "WeightHanging": "\xef\x97\x8d", // U+f5cd + "WeightScale": "\xef\x92\x96", // U+f496 + "WheatAwn": "\xee\x8b\x8d", // U+e2cd + "WheatAwnCircleExclamation": "\xee\x96\x98", // U+e598 + "Wheelchair": "\xef\x86\x93", // U+f193 + "WheelchairMove": "\xee\x8b\x8e", // U+e2ce + "WhiskeyGlass": "\xef\x9e\xa0", // U+f7a0 + "Wifi": "\xef\x87\xab", // U+f1eb + "Wind": "\xef\x9c\xae", // U+f72e + "WindowMaximize": "\xef\x8b\x90", // U+f2d0 + "WindowMinimize": "\xef\x8b\x91", // U+f2d1 + "WindowRestore": "\xef\x8b\x92", // U+f2d2 + "WineBottle": "\xef\x9c\xaf", // U+f72f + "WineGlass": "\xef\x93\xa3", // U+f4e3 + "WineGlassEmpty": "\xef\x97\x8e", // U+f5ce + "WonSign": "\xef\x85\x99", // U+f159 + "Worm": "\xee\x96\x99", // U+e599 + "Wrench": "\xef\x82\xad", // U+f0ad + "X": "X", // U+0058 + "XRay": "\xef\x92\x97", // U+f497 + "Xmark": "\xef\x80\x8d", // U+f00d + "XmarksLines": "\xee\x96\x9a", // U+e59a + "Y": "Y", // U+0059 + "YenSign": "\xef\x85\x97", // U+f157 + "YinYang": "\xef\x9a\xad", // U+f6ad + "Z": "Z", // U+005a + } + +var fa7BrandsIcons = map[string]string{ + "42Group": "\xee\x82\x80", // U+e080 + "500px": "\xef\x89\xae", // U+f26e + "AccessibleIcon": "\xef\x8d\xa8", // U+f368 + "Accusoft": "\xef\x8d\xa9", // U+f369 + "Adn": "\xef\x85\xb0", // U+f170 + "Adversal": "\xef\x8d\xaa", // U+f36a + "Affiliatetheme": "\xef\x8d\xab", // U+f36b + "Airbnb": "\xef\xa0\xb4", // U+f834 + "Algolia": "\xef\x8d\xac", // U+f36c + "Alipay": "\xef\x99\x82", // U+f642 + "Amazon": "\xef\x89\xb0", // U+f270 + "AmazonPay": "\xef\x90\xac", // U+f42c + "Amilia": "\xef\x8d\xad", // U+f36d + "Android": "\xef\x85\xbb", // U+f17b + "Angellist": "\xef\x88\x89", // U+f209 + "Angrycreative": "\xef\x8d\xae", // U+f36e + "Angular": "\xef\x90\xa0", // U+f420 + "AppStore": "\xef\x8d\xaf", // U+f36f + "AppStoreIos": "\xef\x8d\xb0", // U+f370 + "Apper": "\xef\x8d\xb1", // U+f371 + "Apple": "\xef\x85\xb9", // U+f179 + "ApplePay": "\xef\x90\x95", // U+f415 + "ArchLinux": "\xee\xa1\xa7", // U+e867 + "Artstation": "\xef\x9d\xba", // U+f77a + "Asymmetrik": "\xef\x8d\xb2", // U+f372 + "Atlassian": "\xef\x9d\xbb", // U+f77b + "Audible": "\xef\x8d\xb3", // U+f373 + "Autoprefixer": "\xef\x90\x9c", // U+f41c + "Avianex": "\xef\x8d\xb4", // U+f374 + "Aviato": "\xef\x90\xa1", // U+f421 + "Aws": "\xef\x8d\xb5", // U+f375 + "Bandcamp": "\xef\x8b\x95", // U+f2d5 + "BattleNet": "\xef\xa0\xb5", // U+f835 + "Behance": "\xef\x86\xb4", // U+f1b4 + "Bilibili": "\xee\x8f\x99", // U+e3d9 + "Bimobject": "\xef\x8d\xb8", // U+f378 + "Bitbucket": "\xef\x85\xb1", // U+f171 + "Bitcoin": "\xef\x8d\xb9", // U+f379 + "Bity": "\xef\x8d\xba", // U+f37a + "BlackTie": "\xef\x89\xbe", // U+f27e + "Blackberry": "\xef\x8d\xbb", // U+f37b + "Blogger": "\xef\x8d\xbc", // U+f37c + "BloggerB": "\xef\x8d\xbd", // U+f37d + "Bluesky": "\xee\x99\xb1", // U+e671 + "Bluetooth": "\xef\x8a\x93", // U+f293 + "BluetoothB": "\xef\x8a\x94", // U+f294 + "BoardGameGeek": "\xee\xa1\x95", // U+e855 + "Bootstrap": "\xef\xa0\xb6", // U+f836 + "Bots": "\xee\x8d\x80", // U+e340 + "Brave": "\xee\x98\xbc", // U+e63c + "BraveReverse": "\xee\x98\xbd", // U+e63d + "Btc": "\xef\x85\x9a", // U+f15a + "Buffer": "\xef\xa0\xb7", // U+f837 + "Buromobelexperte": "\xef\x8d\xbf", // U+f37f + "BuyNLarge": "\xef\xa2\xa6", // U+f8a6 + "Buysellads": "\xef\x88\x8d", // U+f20d + "CanadianMapleLeaf": "\xef\x9e\x85", // U+f785 + "CashApp": "\xee\x9f\x94", // U+e7d4 + "CcAmazonPay": "\xef\x90\xad", // U+f42d + "CcAmex": "\xef\x87\xb3", // U+f1f3 + "CcApplePay": "\xef\x90\x96", // U+f416 + "CcDinersClub": "\xef\x89\x8c", // U+f24c + "CcDiscover": "\xef\x87\xb2", // U+f1f2 + "CcJcb": "\xef\x89\x8b", // U+f24b + "CcMastercard": "\xef\x87\xb1", // U+f1f1 + "CcPaypal": "\xef\x87\xb4", // U+f1f4 + "CcStripe": "\xef\x87\xb5", // U+f1f5 + "CcVisa": "\xef\x87\xb0", // U+f1f0 + "Centercode": "\xef\x8e\x80", // U+f380 + "Centos": "\xef\x9e\x89", // U+f789 + "Chrome": "\xef\x89\xa8", // U+f268 + "Chromecast": "\xef\xa0\xb8", // U+f838 + "CircleZulip": "\xee\xa1\x91", // U+e851 + "Claude": "\xee\xa1\xa1", // U+e861 + "Cloudflare": "\xee\x81\xbd", // U+e07d + "Cloudscale": "\xef\x8e\x83", // U+f383 + "Cloudsmith": "\xef\x8e\x84", // U+f384 + "Cloudversify": "\xef\x8e\x85", // U+f385 + "Cmplid": "\xee\x8d\xa0", // U+e360 + "Codepen": "\xef\x87\x8b", // U+f1cb + "Codiepie": "\xef\x8a\x84", // U+f284 + "Confluence": "\xef\x9e\x8d", // U+f78d + "Connectdevelop": "\xef\x88\x8e", // U+f20e + "Contao": "\xef\x89\xad", // U+f26d + "CottonBureau": "\xef\xa2\x9e", // U+f89e + "Cpanel": "\xef\x8e\x88", // U+f388 + "CreativeCommons": "\xef\x89\x9e", // U+f25e + "CreativeCommonsBy": "\xef\x93\xa7", // U+f4e7 + "CreativeCommonsNc": "\xef\x93\xa8", // U+f4e8 + "CreativeCommonsNcEu": "\xef\x93\xa9", // U+f4e9 + "CreativeCommonsNcJp": "\xef\x93\xaa", // U+f4ea + "CreativeCommonsNd": "\xef\x93\xab", // U+f4eb + "CreativeCommonsPd": "\xef\x93\xac", // U+f4ec + "CreativeCommonsPdAlt": "\xef\x93\xad", // U+f4ed + "CreativeCommonsRemix": "\xef\x93\xae", // U+f4ee + "CreativeCommonsSa": "\xef\x93\xaf", // U+f4ef + "CreativeCommonsSampling": "\xef\x93\xb0", // U+f4f0 + "CreativeCommonsSamplingPlus": "\xef\x93\xb1", // U+f4f1 + "CreativeCommonsShare": "\xef\x93\xb2", // U+f4f2 + "CreativeCommonsZero": "\xef\x93\xb3", // U+f4f3 + "CriticalRole": "\xef\x9b\x89", // U+f6c9 + "Css": "\xee\x9a\xa2", // U+e6a2 + "Css3": "\xef\x84\xbc", // U+f13c + "Css3Alt": "\xef\x8e\x8b", // U+f38b + "Cuttlefish": "\xef\x8e\x8c", // U+f38c + "DAndD": "\xef\x8e\x8d", // U+f38d + "DAndDBeyond": "\xef\x9b\x8a", // U+f6ca + "Dailymotion": "\xee\x81\x92", // U+e052 + "DartLang": "\xee\x9a\x93", // U+e693 + "Dashcube": "\xef\x88\x90", // U+f210 + "Debian": "\xee\x98\x8b", // U+e60b + "Deezer": "\xee\x81\xb7", // U+e077 + "Delicious": "\xef\x86\xa5", // U+f1a5 + "Deploydog": "\xef\x8e\x8e", // U+f38e + "Deskpro": "\xef\x8e\x8f", // U+f38f + "Dev": "\xef\x9b\x8c", // U+f6cc + "Deviantart": "\xef\x86\xbd", // U+f1bd + "Dhl": "\xef\x9e\x90", // U+f790 + "Diaspora": "\xef\x9e\x91", // U+f791 + "Digg": "\xef\x86\xa6", // U+f1a6 + "DigitalOcean": "\xef\x8e\x91", // U+f391 + "Discord": "\xef\x8e\x92", // U+f392 + "Discourse": "\xef\x8e\x93", // U+f393 + "Disqus": "\xee\x9f\x95", // U+e7d5 + "Dochub": "\xef\x8e\x94", // U+f394 + "Docker": "\xef\x8e\x95", // U+f395 + "Draft2digital": "\xef\x8e\x96", // U+f396 + "Dribbble": "\xef\x85\xbd", // U+f17d + "Dropbox": "\xef\x85\xab", // U+f16b + "Drupal": "\xef\x86\xa9", // U+f1a9 + "Duolingo": "\xee\xa0\x92", // U+e812 + "Dyalog": "\xef\x8e\x99", // U+f399 + "Earlybirds": "\xef\x8e\x9a", // U+f39a + "Ebay": "\xef\x93\xb4", // U+f4f4 + "Edge": "\xef\x8a\x82", // U+f282 + "EdgeLegacy": "\xee\x81\xb8", // U+e078 + "Elementor": "\xef\x90\xb0", // U+f430 + "Eleventy": "\xee\x9f\x96", // U+e7d6 + "Ello": "\xef\x97\xb1", // U+f5f1 + "Ember": "\xef\x90\xa3", // U+f423 + "Empire": "\xef\x87\x91", // U+f1d1 + "Envira": "\xef\x8a\x99", // U+f299 + "Erlang": "\xef\x8e\x9d", // U+f39d + "Ethereum": "\xef\x90\xae", // U+f42e + "Etsy": "\xef\x8b\x97", // U+f2d7 + "Evernote": "\xef\xa0\xb9", // U+f839 + "Expeditedssl": "\xef\x88\xbe", // U+f23e + "Facebook": "\xef\x82\x9a", // U+f09a + "FacebookF": "\xef\x8e\x9e", // U+f39e + "FacebookMessenger": "\xef\x8e\x9f", // U+f39f + "FantasyFlightGames": "\xef\x9b\x9c", // U+f6dc + "Fedex": "\xef\x9e\x97", // U+f797 + "Fediverse": "\xee\xa1\xa5", // U+e865 + "Fedora": "\xef\x9e\x98", // U+f798 + "Figma": "\xef\x9e\x99", // U+f799 + "FilesPinwheel": "\xee\x9a\x9f", // U+e69f + "Firefox": "\xef\x89\xa9", // U+f269 + "FirefoxBrowser": "\xee\x80\x87", // U+e007 + "FirstOrder": "\xef\x8a\xb0", // U+f2b0 + "FirstOrderAlt": "\xef\x94\x8a", // U+f50a + "Firstdraft": "\xef\x8e\xa1", // U+f3a1 + "Flickr": "\xef\x85\xae", // U+f16e + "Flipboard": "\xef\x91\x8d", // U+f44d + "Flutter": "\xee\x9a\x94", // U+e694 + "Fly": "\xef\x90\x97", // U+f417 + "FontAwesome": "\xef\x8a\xb4", // U+f2b4 + "Fonticons": "\xef\x8a\x80", // U+f280 + "FonticonsFi": "\xef\x8e\xa2", // U+f3a2 + "Forgejo": "\xee\xa1\xa0", // U+e860 + "FortAwesome": "\xef\x8a\x86", // U+f286 + "FortAwesomeAlt": "\xef\x8e\xa3", // U+f3a3 + "Forumbee": "\xef\x88\x91", // U+f211 + "Foursquare": "\xef\x86\x80", // U+f180 + "FreeCodeCamp": "\xef\x8b\x85", // U+f2c5 + "Freebsd": "\xef\x8e\xa4", // U+f3a4 + "Fulcrum": "\xef\x94\x8b", // U+f50b + "GalacticRepublic": "\xef\x94\x8c", // U+f50c + "GalacticSenate": "\xef\x94\x8d", // U+f50d + "GetPocket": "\xef\x89\xa5", // U+f265 + "Gg": "\xef\x89\xa0", // U+f260 + "GgCircle": "\xef\x89\xa1", // U+f261 + "Git": "\xef\x87\x93", // U+f1d3 + "GitAlt": "\xef\xa1\x81", // U+f841 + "Gitee": "\xee\xa1\xa3", // U+e863 + "Github": "\xef\x82\x9b", // U+f09b + "GithubAlt": "\xef\x84\x93", // U+f113 + "Gitkraken": "\xef\x8e\xa6", // U+f3a6 + "Gitlab": "\xef\x8a\x96", // U+f296 + "Gitter": "\xef\x90\xa6", // U+f426 + "Glide": "\xef\x8a\xa5", // U+f2a5 + "GlideG": "\xef\x8a\xa6", // U+f2a6 + "Globaleaks": "\xee\xa1\x9d", // U+e85d + "Gofore": "\xef\x8e\xa7", // U+f3a7 + "Golang": "\xee\x90\x8f", // U+e40f + "Goodreads": "\xef\x8e\xa8", // U+f3a8 + "GoodreadsG": "\xef\x8e\xa9", // U+f3a9 + "Google": "\xef\x86\xa0", // U+f1a0 + "GoogleDrive": "\xef\x8e\xaa", // U+f3aa + "GooglePay": "\xee\x81\xb9", // U+e079 + "GooglePlay": "\xef\x8e\xab", // U+f3ab + "GooglePlus": "\xef\x8a\xb3", // U+f2b3 + "GooglePlusG": "\xef\x83\x95", // U+f0d5 + "GoogleScholar": "\xee\x98\xbb", // U+e63b + "GoogleWallet": "\xef\x87\xae", // U+f1ee + "Gratipay": "\xef\x86\x84", // U+f184 + "Grav": "\xef\x8b\x96", // U+f2d6 + "Gripfire": "\xef\x8e\xac", // U+f3ac + "Grunt": "\xef\x8e\xad", // U+f3ad + "Guilded": "\xee\x81\xbe", // U+e07e + "Gulp": "\xef\x8e\xae", // U+f3ae + "HackerNews": "\xef\x87\x94", // U+f1d4 + "Hackerrank": "\xef\x97\xb7", // U+f5f7 + "Hashnode": "\xee\x92\x99", // U+e499 + "Hips": "\xef\x91\x92", // U+f452 + "HireAHelper": "\xef\x8e\xb0", // U+f3b0 + "Hive": "\xee\x81\xbf", // U+e07f + "Hooli": "\xef\x90\xa7", // U+f427 + "Hornbill": "\xef\x96\x92", // U+f592 + "Hotjar": "\xef\x8e\xb1", // U+f3b1 + "Houzz": "\xef\x89\xbc", // U+f27c + "Html5": "\xef\x84\xbb", // U+f13b + "Hubspot": "\xef\x8e\xb2", // U+f3b2 + "HuggingFace": "\xee\xa1\xa9", // U+e869 + "Ideal": "\xee\x80\x93", // U+e013 + "Imdb": "\xef\x8b\x98", // U+f2d8 + "Instagram": "\xef\x85\xad", // U+f16d + "Instalod": "\xee\x82\x81", // U+e081 + "Intercom": "\xef\x9e\xaf", // U+f7af + "InternetExplorer": "\xef\x89\xab", // U+f26b + "Invision": "\xef\x9e\xb0", // U+f7b0 + "Ioxhost": "\xef\x88\x88", // U+f208 + "ItchIo": "\xef\xa0\xba", // U+f83a + "Itunes": "\xef\x8e\xb4", // U+f3b4 + "ItunesNote": "\xef\x8e\xb5", // U+f3b5 + "Java": "\xef\x93\xa4", // U+f4e4 + "JediOrder": "\xef\x94\x8e", // U+f50e + "Jenkins": "\xef\x8e\xb6", // U+f3b6 + "Jira": "\xef\x9e\xb1", // U+f7b1 + "Joget": "\xef\x8e\xb7", // U+f3b7 + "Joomla": "\xef\x86\xaa", // U+f1aa + "Js": "\xef\x8e\xb8", // U+f3b8 + "Jsfiddle": "\xef\x87\x8c", // U+f1cc + "Julia": "\xee\xa1\x92", // U+e852 + "Jxl": "\xee\x99\xbb", // U+e67b + "Kaggle": "\xef\x97\xba", // U+f5fa + "KakaoTalk": "\xee\x9f\x97", // U+e7d7 + "Keybase": "\xef\x93\xb5", // U+f4f5 + "Keycdn": "\xef\x8e\xba", // U+f3ba + "Kickstarter": "\xef\x8e\xbb", // U+f3bb + "KickstarterK": "\xef\x8e\xbc", // U+f3bc + "KoFi": "\xee\xa1\x96", // U+e856 + "Korvue": "\xef\x90\xaf", // U+f42f + "Kubernetes": "\xee\xa1\x97", // U+e857 + "Laravel": "\xef\x8e\xbd", // U+f3bd + "Lastfm": "\xef\x88\x82", // U+f202 + "Leanpub": "\xef\x88\x92", // U+f212 + "Leetcode": "\xee\xa1\xaa", // U+e86a + "Less": "\xef\x90\x9d", // U+f41d + "Letterboxd": "\xee\x98\xad", // U+e62d + "Line": "\xef\x8f\x80", // U+f3c0 + "Linkedin": "\xef\x82\x8c", // U+f08c + "LinkedinIn": "\xef\x83\xa1", // U+f0e1 + "Linktree": "\xee\x9f\x98", // U+e7d8 + "Linode": "\xef\x8a\xb8", // U+f2b8 + "Linux": "\xef\x85\xbc", // U+f17c + "Lumon": "\xee\x9f\xa2", // U+e7e2 + "LumonDrop": "\xee\x9f\xa3", // U+e7e3 + "Lyft": "\xef\x8f\x83", // U+f3c3 + "Magento": "\xef\x8f\x84", // U+f3c4 + "Mailchimp": "\xef\x96\x9e", // U+f59e + "Mandalorian": "\xef\x94\x8f", // U+f50f + "Markdown": "\xef\x98\x8f", // U+f60f + "Mastodon": "\xef\x93\xb6", // U+f4f6 + "Maxcdn": "\xef\x84\xb6", // U+f136 + "Mdb": "\xef\xa3\x8a", // U+f8ca + "Medapps": "\xef\x8f\x86", // U+f3c6 + "Medium": "\xef\x88\xba", // U+f23a + "Medrt": "\xef\x8f\x88", // U+f3c8 + "Meetup": "\xef\x8b\xa0", // U+f2e0 + "Megaport": "\xef\x96\xa3", // U+f5a3 + "Mendeley": "\xef\x9e\xb3", // U+f7b3 + "Meta": "\xee\x92\x9b", // U+e49b + "Microblog": "\xee\x80\x9a", // U+e01a + "Microsoft": "\xef\x8f\x8a", // U+f3ca + "Mintbit": "\xee\x98\xaf", // U+e62f + "Mix": "\xef\x8f\x8b", // U+f3cb + "Mixcloud": "\xef\x8a\x89", // U+f289 + "Mixer": "\xee\x81\x96", // U+e056 + "Mizuni": "\xef\x8f\x8c", // U+f3cc + "Modx": "\xef\x8a\x85", // U+f285 + "Monero": "\xef\x8f\x90", // U+f3d0 + "Napster": "\xef\x8f\x92", // U+f3d2 + "Neos": "\xef\x98\x92", // U+f612 + "NfcDirectional": "\xee\x94\xb0", // U+e530 + "NfcSymbol": "\xee\x94\xb1", // U+e531 + "Nimblr": "\xef\x96\xa8", // U+f5a8 + "Node": "\xef\x90\x99", // U+f419 + "NodeJs": "\xef\x8f\x93", // U+f3d3 + "Notion": "\xee\x9f\x99", // U+e7d9 + "Npm": "\xef\x8f\x94", // U+f3d4 + "Ns8": "\xef\x8f\x95", // U+f3d5 + "Nutritionix": "\xef\x8f\x96", // U+f3d6 + "Obsidian": "\xee\xa1\xb9", // U+e879 + "OctopusDeploy": "\xee\x82\x82", // U+e082 + "Odnoklassniki": "\xef\x89\xa3", // U+f263 + "Odysee": "\xee\x97\x86", // U+e5c6 + "OldRepublic": "\xef\x94\x90", // U+f510 + "Openai": "\xee\x9f\x8f", // U+e7cf + "Opencart": "\xef\x88\xbd", // U+f23d + "Openid": "\xef\x86\x9b", // U+f19b + "Openstreetmap": "\xee\xa1\xab", // U+e86b + "Opensuse": "\xee\x98\xab", // U+e62b + "Opera": "\xef\x89\xaa", // U+f26a + "OptinMonster": "\xef\x88\xbc", // U+f23c + "Orcid": "\xef\xa3\x92", // U+f8d2 + "Osi": "\xef\x90\x9a", // U+f41a + "Padlet": "\xee\x92\xa0", // U+e4a0 + "Page4": "\xef\x8f\x97", // U+f3d7 + "Pagelines": "\xef\x86\x8c", // U+f18c + "Palfed": "\xef\x8f\x98", // U+f3d8 + "Pandora": "\xee\x9f\x9a", // U+e7da + "Patreon": "\xef\x8f\x99", // U+f3d9 + "Paypal": "\xef\x87\xad", // U+f1ed + "Perbyte": "\xee\x82\x83", // U+e083 + "Periscope": "\xef\x8f\x9a", // U+f3da + "Phabricator": "\xef\x8f\x9b", // U+f3db + "PhoenixFramework": "\xef\x8f\x9c", // U+f3dc + "PhoenixSquadron": "\xef\x94\x91", // U+f511 + "Php": "\xef\x91\x97", // U+f457 + "PiedPiper": "\xef\x8a\xae", // U+f2ae + "PiedPiperAlt": "\xef\x86\xa8", // U+f1a8 + "PiedPiperHat": "\xef\x93\xa5", // U+f4e5 + "PiedPiperPp": "\xef\x86\xa7", // U+f1a7 + "Pinterest": "\xef\x83\x92", // U+f0d2 + "PinterestP": "\xef\x88\xb1", // U+f231 + "Pix": "\xee\x90\xba", // U+e43a + "Pixelfed": "\xee\x9f\x9b", // U+e7db + "Pixiv": "\xee\x99\x80", // U+e640 + "Playstation": "\xef\x8f\x9f", // U+f3df + "Postgresql": "\xee\xa1\x98", // U+e858 + "ProductHunt": "\xef\x8a\x88", // U+f288 + "Pushed": "\xef\x8f\xa1", // U+f3e1 + "Python": "\xef\x8f\xa2", // U+f3e2 + "Qq": "\xef\x87\x96", // U+f1d6 + "Quinscape": "\xef\x91\x99", // U+f459 + "Quora": "\xef\x8b\x84", // U+f2c4 + "RProject": "\xef\x93\xb7", // U+f4f7 + "RaspberryPi": "\xef\x9e\xbb", // U+f7bb + "Ravelry": "\xef\x8b\x99", // U+f2d9 + "React": "\xef\x90\x9b", // U+f41b + "Reacteurope": "\xef\x9d\x9d", // U+f75d + "Readme": "\xef\x93\x95", // U+f4d5 + "Rebel": "\xef\x87\x90", // U+f1d0 + "RedRiver": "\xef\x8f\xa3", // U+f3e3 + "Reddit": "\xef\x86\xa1", // U+f1a1 + "RedditAlien": "\xef\x8a\x81", // U+f281 + "Redhat": "\xef\x9e\xbc", // U+f7bc + "Renren": "\xef\x86\x8b", // U+f18b + "Replyd": "\xef\x8f\xa6", // U+f3e6 + "Researchgate": "\xef\x93\xb8", // U+f4f8 + "Resolving": "\xef\x8f\xa7", // U+f3e7 + "Rev": "\xef\x96\xb2", // U+f5b2 + "Rocketchat": "\xef\x8f\xa8", // U+f3e8 + "Rockrms": "\xef\x8f\xa9", // U+f3e9 + "Rust": "\xee\x81\xba", // U+e07a + "Safari": "\xef\x89\xa7", // U+f267 + "Salesforce": "\xef\xa0\xbb", // U+f83b + "Sass": "\xef\x90\x9e", // U+f41e + "Scaleway": "\xee\xa1\x99", // U+e859 + "Schlix": "\xef\x8f\xaa", // U+f3ea + "Screenpal": "\xee\x95\xb0", // U+e570 + "Scribd": "\xef\x8a\x8a", // U+f28a + "Searchengin": "\xef\x8f\xab", // U+f3eb + "Sellcast": "\xef\x8b\x9a", // U+f2da + "Sellsy": "\xef\x88\x93", // U+f213 + "Servicestack": "\xef\x8f\xac", // U+f3ec + "Shirtsinbulk": "\xef\x88\x94", // U+f214 + "Shoelace": "\xee\x98\x8c", // U+e60c + "Shopify": "\xee\x81\x97", // U+e057 + "Shopware": "\xef\x96\xb5", // U+f5b5 + "SignalMessenger": "\xee\x99\xa3", // U+e663 + "Simplybuilt": "\xef\x88\x95", // U+f215 + "Sistrix": "\xef\x8f\xae", // U+f3ee + "Sith": "\xef\x94\x92", // U+f512 + "Sitrox": "\xee\x91\x8a", // U+e44a + "Sketch": "\xef\x9f\x86", // U+f7c6 + "Skyatlas": "\xef\x88\x96", // U+f216 + "Skype": "\xef\x85\xbe", // U+f17e + "Slack": "\xef\x86\x98", // U+f198 + "Slideshare": "\xef\x87\xa7", // U+f1e7 + "Snapchat": "\xef\x8a\xab", // U+f2ab + "Solana": "\xee\xa1\x9e", // U+e85e + "Soundcloud": "\xef\x86\xbe", // U+f1be + "Sourcetree": "\xef\x9f\x93", // U+f7d3 + "SpaceAwesome": "\xee\x96\xac", // U+e5ac + "Speakap": "\xef\x8f\xb3", // U+f3f3 + "SpeakerDeck": "\xef\xa0\xbc", // U+f83c + "Spotify": "\xef\x86\xbc", // U+f1bc + "SquareBehance": "\xef\x86\xb5", // U+f1b5 + "SquareBluesky": "\xee\x9a\xa3", // U+e6a3 + "SquareDeskpro": "\xee\xa1\x84", // U+e844 + "SquareDribbble": "\xef\x8e\x97", // U+f397 + "SquareFacebook": "\xef\x82\x82", // U+f082 + "SquareFigma": "\xee\x9f\xa4", // U+e7e4 + "SquareFontAwesome": "\xee\x96\xad", // U+e5ad + "SquareFontAwesomeStroke": "\xef\x8d\x9c", // U+f35c + "SquareGit": "\xef\x87\x92", // U+f1d2 + "SquareGithub": "\xef\x82\x92", // U+f092 + "SquareGitlab": "\xee\x96\xae", // U+e5ae + "SquareGooglePlus": "\xef\x83\x94", // U+f0d4 + "SquareHackerNews": "\xef\x8e\xaf", // U+f3af + "SquareInstagram": "\xee\x81\x95", // U+e055 + "SquareJs": "\xef\x8e\xb9", // U+f3b9 + "SquareLastfm": "\xef\x88\x83", // U+f203 + "SquareLetterboxd": "\xee\x98\xae", // U+e62e + "SquareLinkedin": "\xee\x9f\x90", // U+e7d0 + "SquareOdnoklassniki": "\xef\x89\xa4", // U+f264 + "SquarePiedPiper": "\xee\x80\x9e", // U+e01e + "SquarePinterest": "\xef\x83\x93", // U+f0d3 + "SquareReddit": "\xef\x86\xa2", // U+f1a2 + "SquareSnapchat": "\xef\x8a\xad", // U+f2ad + "SquareSteam": "\xef\x86\xb7", // U+f1b7 + "SquareThreads": "\xee\x98\x99", // U+e619 + "SquareTumblr": "\xef\x85\xb4", // U+f174 + "SquareTwitter": "\xef\x82\x81", // U+f081 + "SquareUpwork": "\xee\x99\xbc", // U+e67c + "SquareViadeo": "\xef\x8a\xaa", // U+f2aa + "SquareVimeo": "\xef\x86\x94", // U+f194 + "SquareWebAwesome": "\xee\x9a\x83", // U+e683 + "SquareWebAwesomeStroke": "\xee\x9a\x84", // U+e684 + "SquareWhatsapp": "\xef\x90\x8c", // U+f40c + "SquareXTwitter": "\xee\x98\x9a", // U+e61a + "SquareXing": "\xef\x85\xa9", // U+f169 + "SquareYoutube": "\xef\x90\xb1", // U+f431 + "Squarespace": "\xef\x96\xbe", // U+f5be + "StackExchange": "\xef\x86\x8d", // U+f18d + "StackOverflow": "\xef\x85\xac", // U+f16c + "Stackpath": "\xef\xa1\x82", // U+f842 + "Staylinked": "\xef\x8f\xb5", // U+f3f5 + "Steam": "\xef\x86\xb6", // U+f1b6 + "SteamSymbol": "\xef\x8f\xb6", // U+f3f6 + "StickerMule": "\xef\x8f\xb7", // U+f3f7 + "Strava": "\xef\x90\xa8", // U+f428 + "Stripe": "\xef\x90\xa9", // U+f429 + "StripeS": "\xef\x90\xaa", // U+f42a + "Stubber": "\xee\x97\x87", // U+e5c7 + "Studiovinari": "\xef\x8f\xb8", // U+f3f8 + "Stumbleupon": "\xef\x86\xa4", // U+f1a4 + "StumbleuponCircle": "\xef\x86\xa3", // U+f1a3 + "Superpowers": "\xef\x8b\x9d", // U+f2dd + "Supple": "\xef\x8f\xb9", // U+f3f9 + "Supportnow": "\xee\xa0\xb3", // U+e833 + "Suse": "\xef\x9f\x96", // U+f7d6 + "Svelte": "\xee\xa1\xa8", // U+e868 + "Swift": "\xef\xa3\xa1", // U+f8e1 + "Symfony": "\xef\xa0\xbd", // U+f83d + "Symfonycasts": "\xee\xa2\xab", // U+e8ab + "TailwindCss": "\xee\xa1\xa6", // U+e866 + "Teamspeak": "\xef\x93\xb9", // U+f4f9 + "Telegram": "\xef\x8b\x86", // U+f2c6 + "TencentWeibo": "\xef\x87\x95", // U+f1d5 + "Tex": "\xee\x9f\xbf", // U+e7ff + "TheRedYeti": "\xef\x9a\x9d", // U+f69d + "Themeco": "\xef\x97\x86", // U+f5c6 + "Themeisle": "\xef\x8a\xb2", // U+f2b2 + "ThinkPeaks": "\xef\x9c\xb1", // U+f731 + "Threads": "\xee\x98\x98", // U+e618 + "Threema": "\xee\xa1\x9f", // U+e85f + "Tidal": "\xee\x9f\x9c", // U+e7dc + "Tiktok": "\xee\x81\xbb", // U+e07b + "TorBrowser": "\xee\xa0\xb8", // U+e838 + "TradeFederation": "\xef\x94\x93", // U+f513 + "Trello": "\xef\x86\x81", // U+f181 + "Tumblr": "\xef\x85\xb3", // U+f173 + "Twitch": "\xef\x87\xa8", // U+f1e8 + "Twitter": "\xef\x82\x99", // U+f099 + "Typescript": "\xee\xa1\x80", // U+e840 + "Typo3": "\xef\x90\xab", // U+f42b + "Uber": "\xef\x90\x82", // U+f402 + "Ubuntu": "\xef\x9f\x9f", // U+f7df + "Uikit": "\xef\x90\x83", // U+f403 + "Ultralytics": "\xee\xa1\xad", // U+e86d + "UltralyticsHub": "\xee\xa1\xae", // U+e86e + "UltralyticsYolo": "\xee\xa1\xaf", // U+e86f + "Umbraco": "\xef\xa3\xa8", // U+f8e8 + "Uncharted": "\xee\x82\x84", // U+e084 + "Uniregistry": "\xef\x90\x84", // U+f404 + "Unison": "\xee\xa1\x94", // U+e854 + "Unity": "\xee\x81\x89", // U+e049 + "UnrealEngine": "\xee\xa1\x9c", // U+e85c + "Unsplash": "\xee\x81\xbc", // U+e07c + "Untappd": "\xef\x90\x85", // U+f405 + "Ups": "\xef\x9f\xa0", // U+f7e0 + "Upwork": "\xee\x99\x81", // U+e641 + "Usb": "\xef\x8a\x87", // U+f287 + "Usps": "\xef\x9f\xa1", // U+f7e1 + "Ussunnah": "\xef\x90\x87", // U+f407 + "Vaadin": "\xef\x90\x88", // U+f408 + "Venmo": "\xee\xa1\x9a", // U+e85a + "VenmoV": "\xee\xa1\x9b", // U+e85b + "Viacoin": "\xef\x88\xb7", // U+f237 + "Viadeo": "\xef\x8a\xa9", // U+f2a9 + "Viber": "\xef\x90\x89", // U+f409 + "Vim": "\xee\xa2\x8a", // U+e88a + "Vimeo": "\xef\x90\x8a", // U+f40a + "VimeoV": "\xef\x89\xbd", // U+f27d + "Vine": "\xef\x87\x8a", // U+f1ca + "Vk": "\xef\x86\x89", // U+f189 + "Vnv": "\xef\x90\x8b", // U+f40b + "Vsco": "\xee\x9f\x9d", // U+e7dd + "Vuejs": "\xef\x90\x9f", // U+f41f + "W3c": "\xee\x9f\x9e", // U+e7de + "WatchmanMonitoring": "\xee\x82\x87", // U+e087 + "Waze": "\xef\xa0\xbf", // U+f83f + "WebAwesome": "\xee\x9a\x82", // U+e682 + "Webflow": "\xee\x99\x9c", // U+e65c + "Weebly": "\xef\x97\x8c", // U+f5cc + "Weibo": "\xef\x86\x8a", // U+f18a + "Weixin": "\xef\x87\x97", // U+f1d7 + "Whatsapp": "\xef\x88\xb2", // U+f232 + "Whmcs": "\xef\x90\x8d", // U+f40d + "WikipediaW": "\xef\x89\xa6", // U+f266 + "Windows": "\xef\x85\xba", // U+f17a + "Wirsindhandwerk": "\xee\x8b\x90", // U+e2d0 + "Wix": "\xef\x97\x8f", // U+f5cf + "WizardsOfTheCoast": "\xef\x9c\xb0", // U+f730 + "Wodu": "\xee\x82\x88", // U+e088 + "WolfPackBattalion": "\xef\x94\x94", // U+f514 + "Wordpress": "\xef\x86\x9a", // U+f19a + "WordpressSimple": "\xef\x90\x91", // U+f411 + "Wpbeginner": "\xef\x8a\x97", // U+f297 + "Wpexplorer": "\xef\x8b\x9e", // U+f2de + "Wpforms": "\xef\x8a\x98", // U+f298 + "Wpressr": "\xef\x8f\xa4", // U+f3e4 + "XTwitter": "\xee\x98\x9b", // U+e61b + "Xbox": "\xef\x90\x92", // U+f412 + "Xing": "\xef\x85\xa8", // U+f168 + "Xmpp": "\xee\xa1\xa4", // U+e864 + "YCombinator": "\xef\x88\xbb", // U+f23b + "Yahoo": "\xef\x86\x9e", // U+f19e + "Yammer": "\xef\xa1\x80", // U+f840 + "Yandex": "\xef\x90\x93", // U+f413 + "YandexInternational": "\xef\x90\x94", // U+f414 + "Yarn": "\xef\x9f\xa3", // U+f7e3 + "Yelp": "\xef\x87\xa9", // U+f1e9 + "Yoast": "\xef\x8a\xb1", // U+f2b1 + "Youtube": "\xef\x85\xa7", // U+f167 + "Zhihu": "\xef\x98\xbf", // U+f63f + "Zoom": "\xee\xa1\xbb", // U+e87b + "Zulip": "\xee\xa1\x93", // U+e853 + } diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..df2eb22 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module streamdeck-lets-go + +go 1.26.4 + +require ( + github.com/bearsh/hid v1.6.0 + github.com/dh1tw/streamdeck v1.0.0 + github.com/disintegration/gift v1.2.1 + golang.org/x/image v0.42.0 +) + +require ( + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.38.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7b9c15c --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +github.com/bearsh/hid v1.6.0 h1:eOBSuF2pg+SCytKGuGjOzZx73xQ72gevJ5IlFvzgfGE= +github.com/bearsh/hid v1.6.0/go.mod h1:7JhM3r/tm4ALu4WWFqshda+Q6aIcnGRpUR08sx/dHdc= +github.com/dgottlieb/smarty-assertions v1.2.6 h1:YAXgSslRBbVtd54iTqM4yGT2k1a2qS6cffNQo0SDxDY= +github.com/dgottlieb/smarty-assertions v1.2.6/go.mod h1:x1wpV/RTxYWtN+vgrcRuCF4hjUmonK5NR59ZzQSym2k= +github.com/dh1tw/streamdeck v1.0.0 h1:YyK0g3BLuPQDXnNypwMBbDjbAUDdY073fYeTG5qPXH4= +github.com/dh1tw/streamdeck v1.0.0/go.mod h1:+EqLcYGV7m9jyRVSLTpQkl0BDmRTJEcOTSfcrf1FRzs= +github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc= +github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +go.viam.com/test v1.2.4 h1:JYgZhsuGAQ8sL9jWkziAXN9VJJiKbjoi9BsO33TW3ug= +go.viam.com/test v1.2.4/go.mod h1:zI2xzosHdqXAJ/kFqcN+OIF78kQuTV2nIhGZ8EzvaJI= +golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY= +golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= diff --git a/icons.go b/icons.go new file mode 100644 index 0000000..cb54fb2 --- /dev/null +++ b/icons.go @@ -0,0 +1,205 @@ +package main + +import ( + "bytes" + "fmt" + "image" + _ "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) (image.Image, error) { + cmd := exec.Command("rsvg-convert", + "-w", strconv.Itoa(targetSize), + "-h", strconv.Itoa(targetSize), + "-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) + } + 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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..953b865 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,266 @@ +package config + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "time" +) + +type Config struct { + Version int `json:"version"` + LogLevel string `json:"log_level"` + DefaultPage string `json:"default_page"` + Devices []DeviceConfig `json:"devices"` + Pages []PageConfig `json:"pages"` + AutoSwitch []SwitchRule `json:"auto_switch"` + Screensaver ScreensaverCfg `json:"screensaver"` +} + +type DeviceConfig struct { + Serial string `json:"serial"` + Brightness int `json:"brightness"` + Rotation int `json:"rotation,omitempty"` +} + +type PageConfig struct { + Name string `json:"name"` + Icon string `json:"icon,omitempty"` + Keys []KeyConfig `json:"keys"` + Background string `json:"background,omitempty"` + Screensaver *ScreensaverCfg `json:"screensaver,omitempty"` +} + +type KeyConfig struct { + Index int `json:"index"` + Icon string `json:"icon,omitempty"` + Background string `json:"background,omitempty"` + Label string `json:"label,omitempty"` + Font string `json:"font,omitempty"` + FontSize *float64 `json:"font_size,omitempty"` + IconScale *float64 `json:"icon_scale,omitempty"` + Action *Action `json:"action,omitempty"` + Display *DisplayCfg `json:"display,omitempty"` +} + +type Action struct { + Type string `json:"type"` + Command string `json:"command,omitempty"` + Builtin string `json:"builtin,omitempty"` + Script string `json:"script,omitempty"` + Page string `json:"page,omitempty"` + Background bool `json:"background,omitempty"` + Keys string `json:"keys,omitempty"` +} + +type DisplayCfg struct { + Command string `json:"command,omitempty"` + Script string `json:"script,omitempty"` + Interval string `json:"interval"` + MaxLen int `json:"max_len,omitempty"` + Timeout string `json:"timeout,omitempty"` +} + +type SwitchRule struct { + WMClass string `json:"wm_class"` + Title string `json:"title,omitempty"` + Page string `json:"page"` + Devices []string `json:"devices,omitempty"` + Stay bool `json:"stay,omitempty"` +} + +type ScreensaverCfg struct { + Enabled bool `json:"enabled"` + IdleSeconds int `json:"idle_seconds,omitempty"` + Image string `json:"image,omitempty"` + Brightness int `json:"brightness,omitempty"` +} + +func DefaultConfig() *Config { + return &Config{ + Version: 1, + LogLevel: "info", + DefaultPage: "default", + Pages: []PageConfig{ + { + Name: "default", + Keys: []KeyConfig{}, + }, + }, + } +} + +func ConfigPath(path string) string { + if path != "" { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return "config.json" + } + return filepath.Join(home, ".config", "streamdeck-lets-go", "config.json") +} + +func LoadConfig(path string) (*Config, error) { + path = ConfigPath(path) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + slog.Info("config not found, using defaults", "path", path) + return DefaultConfig(), nil + } + return nil, fmt.Errorf("reading config: %w", err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + return &cfg, nil +} + +func (c *Config) Save(path string) error { + path = ConfigPath(path) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating config dir: %w", err) + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return fmt.Errorf("marshaling config: %w", err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing config: %w", err) + } + return nil +} + +func (c *Config) Validate() error { + if c.Version < 1 { + return fmt.Errorf("unsupported config version: %d", c.Version) + } + if c.DefaultPage == "" { + return fmt.Errorf("default_page is required") + } + pageNames := make(map[string]bool) + for _, p := range c.Pages { + if p.Name == "" { + return fmt.Errorf("page name is required") + } + if pageNames[p.Name] { + return fmt.Errorf("duplicate page name: %s", p.Name) + } + pageNames[p.Name] = true + + seenKeys := make(map[int]bool) + maxKey := 31 + for _, k := range p.Keys { + if k.Index < 0 { + return fmt.Errorf("page %s: key index must be >= 0", p.Name) + } + if k.Index > maxKey { + return fmt.Errorf("page %s: key index %d exceeds max %d", p.Name, k.Index, maxKey) + } + if seenKeys[k.Index] { + return fmt.Errorf("page %s: duplicate key index %d", p.Name, k.Index) + } + seenKeys[k.Index] = true + + if k.Action != nil { + switch k.Action.Type { + case "command": + if k.Action.Command == "" { + return fmt.Errorf("page %s key %d: command required for command action", p.Name, k.Index) + } + case "builtin": + case "script": + if k.Action.Script == "" { + return fmt.Errorf("page %s key %d: script path required for script action", p.Name, k.Index) + } + case "page": + if k.Action.Page == "" { + return fmt.Errorf("page %s key %d: page name required for page action", p.Name, k.Index) + } + case "keyboard": + if k.Action.Keys == "" { + return fmt.Errorf("page %s key %d: keys required for keyboard action", p.Name, k.Index) + } + case "": + return fmt.Errorf("page %s key %d: action type is required", p.Name, k.Index) + default: + return fmt.Errorf("page %s key %d: unknown action type: %s", p.Name, k.Index, k.Action.Type) + } + } + + if k.Display != nil { + if k.Display.Command == "" && k.Display.Script == "" { + return fmt.Errorf("page %s key %d: display requires command or script", p.Name, k.Index) + } + if k.Display.Command != "" && k.Display.Script != "" { + return fmt.Errorf("page %s key %d: display command and script are mutually exclusive", p.Name, k.Index) + } + if k.Display.Interval == "" { + return fmt.Errorf("page %s key %d: display interval is required", p.Name, k.Index) + } + interval, err := time.ParseDuration(k.Display.Interval) + if err != nil { + return fmt.Errorf("page %s key %d: invalid display interval %q: %w", p.Name, k.Index, k.Display.Interval, err) + } + if interval < time.Second { + return fmt.Errorf("page %s key %d: display interval must be at least 1s", p.Name, k.Index) + } + if interval > 24*time.Hour { + return fmt.Errorf("page %s key %d: display interval must not exceed 24h", p.Name, k.Index) + } + if k.Display.Timeout != "" { + if _, err := time.ParseDuration(k.Display.Timeout); err != nil { + return fmt.Errorf("page %s key %d: invalid display timeout %q: %w", p.Name, k.Index, k.Display.Timeout, err) + } + } + if k.Display.MaxLen < 0 { + return fmt.Errorf("page %s key %d: max_len must be >= 0", p.Name, k.Index) + } + if k.Display.MaxLen > 4096 { + return fmt.Errorf("page %s key %d: max_len must be <= 4096", p.Name, k.Index) + } + } + } + } + + for _, p := range c.Pages { + for _, k := range p.Keys { + if k.Action != nil && k.Action.Type == "page" { + if !pageNames[k.Action.Page] { + return fmt.Errorf("page %s key %d: references unknown page %q", p.Name, k.Index, k.Action.Page) + } + } + } + } + + for _, sr := range c.AutoSwitch { + if sr.WMClass == "" && sr.Title == "" { + return fmt.Errorf("auto_switch rule: wm_class or title is required") + } + if !pageNames[sr.Page] { + return fmt.Errorf("auto_switch rule: references unknown page %q", sr.Page) + } + if _, err := regexp.Compile(sr.WMClass); sr.WMClass != "" && err != nil { + return fmt.Errorf("auto_switch rule: invalid wm_class regex %q: %w", sr.WMClass, err) + } + if _, err := regexp.Compile(sr.Title); sr.Title != "" && err != nil { + return fmt.Errorf("auto_switch rule: invalid title regex %q: %w", sr.Title, err) + } + } + + if !pageNames[c.DefaultPage] { + return fmt.Errorf("default_page %q not found in pages", c.DefaultPage) + } + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..655900d --- /dev/null +++ b/main.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + _ "image/gif" + _ "image/jpeg" + _ "image/png" + + "streamdeck-lets-go/internal/config" +) + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))) + + fs := flag.NewFlagSet("streamdeck-lets-go", flag.ExitOnError) + configPath := fs.String("config", "", "path to config.json (default: ~/.config/streamdeck-lets-go/config.json)") + httpAddr := fs.String("addr", ":9090", "web UI listen address") + noDeck := fs.Bool("no-deck", false, "run without Stream Deck hardware (config editing only)") + verbose := fs.Bool("v", false, "verbose output") + + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "Usage: streamdeck-lets-go [flags] [command]\n\n") + fmt.Fprintf(os.Stderr, "Commands:\n") + fmt.Fprintf(os.Stderr, " daemon run the Stream Deck daemon with web UI (default)\n") + fmt.Fprintf(os.Stderr, " serve run web UI only (no deck hardware)\n") + fmt.Fprintf(os.Stderr, " discover list connected Stream Decks\n") + fmt.Fprintf(os.Stderr, "\nFlags:\n") + fs.PrintDefaults() + os.Exit(0) + } + + cmd := os.Args[1] + args := os.Args[2:] + + switch cmd { + case "daemon": + fs.Parse(args) + case "serve": + fs.Parse(args) + *noDeck = true + case "discover": + discover() + return + default: + fs.Parse(os.Args[1:]) + } + + if *verbose { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))) + } + + cfg, err := config.LoadConfig(*configPath) + if err != nil { + slog.Error("config", "error", err) + os.Exit(1) + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + httpEnabled := true + if err := Run(ctx, cfg, RunOptions{ + ConfigPath: *configPath, + HTTPAddr: *httpAddr, + HTTPEnabled: httpEnabled, + NoDeck: *noDeck, + }); err != nil { + slog.Error("run", "error", err) + os.Exit(1) + } +} + +func discover() { + devices, err := EnumerateDevices() + if err != nil { + slog.Error("discover", "error", err) + os.Exit(1) + } + if len(devices) == 0 { + fmt.Println("No Stream Deck devices found.") + return + } + fmt.Printf("Found %d Stream Deck(s):\n", len(devices)) + for _, d := range devices { + fmt.Printf(" Serial: %s Model: %s PID: 0x%04x\n", d.Serial, d.Model, d.PID) + } +} diff --git a/media.go b/media.go new file mode 100644 index 0000000..e7cb558 --- /dev/null +++ b/media.go @@ -0,0 +1,11 @@ +package main + +import ( + "log/slog" +) + +func init() { + slog.Info("media module loaded") +} + +// mediaAction is handled inline in action.go via playerctl/wpctl diff --git a/page.go b/page.go new file mode 100644 index 0000000..dd9997b --- /dev/null +++ b/page.go @@ -0,0 +1,781 @@ +package main + +import ( + "context" + "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 PageManager struct { + mu sync.RWMutex + pages map[string]*config.PageConfig + active string + deck *Deck + keyStates map[int]*keyState + displayOutputs map[int]string + displayMu sync.RWMutex +} + +func NewPageManager(deck *Deck) *PageManager { + return &PageManager{ + pages: make(map[string]*config.PageConfig), + deck: deck, + keyStates: make(map[int]*keyState), + displayOutputs: make(map[int]string), + } +} + +func (pm *PageManager) LoadPages(pages []config.PageConfig) { + pm.mu.Lock() + defer pm.mu.Unlock() + pm.pages = make(map[string]*config.PageConfig, len(pages)) + for i := range pages { + pm.pages[pages[i].Name] = &pages[i] + } +} + +func (pm *PageManager) ActivatePage(name string) error { + 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]string) + 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) + } + + 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]string { + pm.displayMu.RLock() + defer pm.displayMu.RUnlock() + result := make(map[int]string, 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) { + pm.mu.RLock() + defer pm.mu.RUnlock() + page := pm.pages[pm.active] + if page == nil { + return + } + for _, k := range page.Keys { + if k.Index == keyIndex { + pm.renderKey(keyIndex, &k) + return + } + } +} + +func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) { + pm.mu.RLock() + page := pm.pages[pm.active] + pm.mu.RUnlock() + if page == nil { + return + } + for _, k := range page.Keys { + 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 = "medium" + } + 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 = "regular" + } + + if k.Label != "" { + if err := pm.deck.WriteTextOnImage(idx, img, k.Label, onImgFontName, onImgFontSize); 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); 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) + } + } +} + +func (pm *PageManager) stopPeriodicKeys() { + 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}) + } + } + pm.mu.Unlock() + + for _, dk := range keys { + go pm.runDisplayKey(dk.index, dk.display, dk.ctx) + } +} + +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 + } + } +} + +func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output string, execErr error) { + pm.mu.RLock() + page := pm.pages[pm.active] + pm.mu.RUnlock() + if page == nil { + return + } + + var kc *config.KeyConfig + for _, k := range page.Keys { + 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) + } + + text := sanitizeText(output) + if text == "" { + return + } + + pm.displayMu.Lock() + pm.displayOutputs[idx] = text + pm.displayMu.Unlock() + + maxLen := d.MaxLen + if maxLen <= 0 { + maxLen = 128 + } + if len(text) > maxLen { + text = text[:maxLen] + } + + 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) + textImg := renderUnicodeText(text, fontSize, pm.deck.KeySize(), color.Black) + if err := pm.deck.FillImage(idx, textImg); err != nil { + slog.Warn("fill image", "error", err) + } + return + } + + if kc.Background != "" { + if bg, err := parseHexColor(kc.Background); err == nil { + img = applyBackground(img, bg) + } else { + slog.Warn("invalid background color", "value", kc.Background, "error", err) + } + } + + composite := renderUnicodeTextOnImage(img, text, fontSize, pm.deck.KeySize()) + if err := pm.deck.FillImage(idx, composite); err != nil { + slog.Warn("fill image", "error", err) + } + return + } + + if kc.Background != "" { + bg, err := parseHexColor(kc.Background) + if err == nil { + textImg := renderUnicodeText(text, fontSize, pm.deck.KeySize(), bg) + if err := pm.deck.FillImage(idx, textImg); err != nil { + slog.Warn("fill image", "error", err) + } + return + } + slog.Warn("invalid background color", "value", kc.Background, "error", err) + } + textImg := renderUnicodeText(text, fontSize, pm.deck.KeySize(), color.Black) + if err := pm.deck.FillImage(idx, textImg); err != nil { + slog.Warn("fill image", "error", err) + } +} + +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) + } + 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) + } + 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 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 == "" { + 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{color.White}, + Face: face, + Dot: fixed.P(x, y), + } + d.DrawString(text) + + return rgba +} + +func renderUnicodeTextOnImage(baseImg image.Image, text string, fontSize float64, keySize int) *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 + } + 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 == "" { + 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{color.White}, + 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 loadImage(path string, targetSize int, faScale float64) (image.Image, error) { + if cached, ok := imageCache.Load(path); 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(path, img) + return img, nil + } + + if isEmojiRef(path) { + r, err := parseEmojiRef(path) + if err != nil { + return nil, err + } + img := renderEmojiGlyph(r, targetSize, faScale) + imageCache.Store(path, 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) + if err != nil { + return nil, err + } + imageCache.Store(path, 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) + } + + imageCache.Store(path, 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 +} diff --git a/render.go b/render.go new file mode 100644 index 0000000..472f9a2 --- /dev/null +++ b/render.go @@ -0,0 +1,132 @@ +package main + +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) 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 != "" { + return composeImageWithLabel(img, k.Label, keySize) + } + 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) + } + return blankImage(keySize, bg) + } + } + return renderTextImage(k.Label, keySize) +} + +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) 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) + draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over) + + if text != "" { + face := basicfont.Face7x13 + 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) image.Image { + rgba := blankImage(keySize, color.RGBA{0, 0, 0, 255}).(*image.RGBA) + + if text != "" { + face := basicfont.Face7x13 + 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 +} diff --git a/screensaver.go b/screensaver.go new file mode 100644 index 0000000..ff4a359 --- /dev/null +++ b/screensaver.go @@ -0,0 +1,92 @@ +package main + +import ( + "log/slog" + "time" + + "streamdeck-lets-go/internal/config" +) + +type Screensaver struct { + enabled bool + idleAfter time.Duration + lastInput time.Time + active bool + savedBrightness int +} + +func NewScreensaver(cfg *config.ScreensaverCfg) *Screensaver { + ss := &Screensaver{ + enabled: cfg.Enabled, + idleAfter: time.Duration(cfg.IdleSeconds) * time.Second, + lastInput: time.Now(), + } + if ss.idleAfter <= 0 { + ss.idleAfter = 30 * time.Second + } + return ss +} + +func (ss *Screensaver) NotifyInput() { + ss.lastInput = time.Now() + if ss.active { + ss.active = false + slog.Debug("screensaver deactivated") + } +} + +func (ss *Screensaver) Check() bool { + if !ss.enabled { + return false + } + if time.Since(ss.lastInput) > ss.idleAfter { + if !ss.active { + ss.active = true + slog.Debug("screensaver activated") + return true + } + return false + } + return false +} + +func (ss *Screensaver) Activate(deck *Deck, cfg *config.ScreensaverCfg) { + ss.savedBrightness = deck.Brightness() + + brightness := cfg.Brightness + if brightness <= 0 { + brightness = 10 + } + if err := deck.SetBrightness(brightness); err != nil { + slog.Warn("screensaver: set brightness", "error", err) + } + + if cfg.Image != "" { + img, err := loadImage(cfg.Image, 0, 0) + if err != nil { + slog.Warn("screensaver: load image", "path", cfg.Image, "error", err) + return + } + if err := deck.FillPanel(img); err != nil { + slog.Warn("screensaver: fill panel", "error", err) + } + } + + slog.Info("screensaver activated") +} + +func (ss *Screensaver) Deactivate(deck *Deck) { + brightness := ss.savedBrightness + if brightness <= 0 { + brightness = 75 + } + if err := deck.SetBrightness(brightness); err != nil { + slog.Warn("screensaver: restore brightness", "error", err) + } + ss.savedBrightness = 0 + slog.Info("screensaver deactivated") +} + +func (ss *Screensaver) IsActive() bool { + return ss.active +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..c114d40 --- /dev/null +++ b/static/app.js @@ -0,0 +1,557 @@ +document.addEventListener('alpine:init', () => { + Alpine.data('deckApp', () => ({ + // ── State ── + config: null, + pages: [], + activePage: '', + view: 'grid', + showAdvanced: false, + showDisplay: false, + toast: null, + displayOutputs: {}, + isCapturingKey: false, + + // ── Edit state ── + editing: null, + editForm: {}, + + // ── Sub-views ── + subView: null, // 'backups', 'settings', 'switcher', null + + // ── Settings ── + settingsForm: {}, + autoSwitchRules: [], + + // ── Init ── + async init() { + await this.loadConfig() + setInterval(() => this.pollDisplayOutputs(), 3000) + }, + + async pollDisplayOutputs() { + if (this.view !== 'grid') return + try { + const res = await fetch(`/api/display-outputs`) + this.displayOutputs = await res.json() + } catch (_) {} + }, + + async loadConfig() { + try { + const res = await fetch('/api/config') + this.config = await res.json() + this.pages = this.config.pages + if (this.pages.length > 0) { + this.activePage = this.config.default_page || this.pages[0].name + } + this.syncSettings() + } catch (e) { + this.showToast('Failed to load config', 'error') + } + }, + + syncSettings() { + this.settingsForm = { + brightness: this.config.devices?.[0]?.brightness || 75, + screensaver_enabled: this.config.screensaver?.enabled || false, + screensaver_idle: this.config.screensaver?.idle_seconds || 30, + screensaver_brightness: this.config.screensaver?.brightness || 10, + } + this.autoSwitchRules = [...(this.config.auto_switch || [])] + }, + + // ── Page helpers ── + get currentPage() { + return this.pages.find(p => p.name === this.activePage) + }, + + get pageNames() { + return this.pages.map(p => p.name) + }, + + // ── Grid ── + get gridKeys() { + const page = this.currentPage + if (!page) return [] + const keys = [] + for (let i = 0; i < 15; i++) { + const kc = page.keys.find(k => k.index === i) + keys.push({ + index: i, + ...kc, + configured: !!kc, + hasDisplay: !!(kc?.display), + previewUrl: this.keyPreviewUrl(kc ? kc : {}), + }) + } + return keys + }, + + keyPreviewUrl(kc) { + const k = kc || {} + let url = '/api/render?key_size=72' + if (k.icon) url += `&icon=${encodeURIComponent(k.icon)}` + const label = this.displayOutputs[k.index] || k.label + if (label) url += `&label=${encodeURIComponent(label)}` + if (k.icon_scale != null) url += `&icon_scale=${k.icon_scale}` + if (k.font_size != null) url += `&font_size=${k.font_size}` + if (k.background) url += `&background=${encodeURIComponent(k.background)}` + return url + }, + + isFAIcon(icon) { + if (!icon) return false + return icon.startsWith('fa:') || icon.startsWith('far:') || icon.startsWith('fab:') + }, + + faClass(icon) { + if (!icon) return '' + if (icon.startsWith('fa:')) return 'fa-solid fa-' + icon.slice(3) + if (icon.startsWith('far:')) return 'fa-regular fa-' + icon.slice(4) + if (icon.startsWith('fab:')) return 'fa-brands fa-' + icon.slice(4) + return '' + }, + + get keyChips() { + if (!this.editForm.keys) return [] + const map = { ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift', super: 'Super', meta: 'Super' } + return this.editForm.keys.split('+').map(k => map[k] || k.charAt(0).toUpperCase() + k.slice(1)) + }, + + toggleMod(mod) { + if (mod === 'ctrl') this.editForm.modCtrl = !this.editForm.modCtrl + else if (mod === 'alt') this.editForm.modAlt = !this.editForm.modAlt + else if (mod === 'shift') this.editForm.modShift = !this.editForm.modShift + else if (mod === 'super') this.editForm.modSuper = !this.editForm.modSuper + this.rebuildKeys() + }, + + rebuildKeys() { + const mods = [] + if (this.editForm.modCtrl) mods.push('ctrl') + if (this.editForm.modAlt) mods.push('alt') + if (this.editForm.modShift) mods.push('shift') + if (this.editForm.modSuper) mods.push('super') + if (this.editForm.mainKey) mods.push(this.editForm.mainKey) + this.editForm.keys = mods.join('+') + }, + + startKeyCapture() { + this.isCapturingKey = true + this.$nextTick(() => this.$refs.keyCapture?.focus()) + }, + + onCaptureKey(e) { + if (!this.isCapturingKey) return + e.preventDefault() + e.stopPropagation() + const map = { + 'Control': '', 'Alt': '', 'Shift': '', 'Meta': '', + ' ': 'space', 'Enter': 'return', 'Tab': 'tab', + 'Escape': 'escape', 'Delete': 'delete', 'Backspace': 'backspace', + 'ArrowUp': 'up', 'ArrowDown': 'down', 'ArrowLeft': 'left', 'ArrowRight': 'right', + } + let key = map[e.key] + if (key === undefined) { + key = e.key.length === 1 ? e.key.toLowerCase() : e.key + } + if (!key) return + this.editForm.mainKey = key.toLowerCase() + this.isCapturingKey = false + this.rebuildKeys() + }, + + displayKey(key) { + const map = { return: '\u21B5', escape: 'Esc', tab: 'Tab', space: '\u2423', + delete: 'Del', backspace: '\u232B', up: '\u2191', down: '\u2193', left: '\u2190', right: '\u2192' } + return map[key] || key.toUpperCase() + }, + + // ── Edit Key ── + editKey(idx) { + this.isCapturingKey = false + const page = this.currentPage + if (!page) return + const kc = page.keys.find(k => k.index === idx) + this.editing = idx + this.showAdvanced = false + this.showDisplay = !!(kc?.display) + + const action = kc?.action || {} + + const parts = (action.keys || '').toLowerCase().split('+') + const mainKey = parts.length > 0 ? parts.pop() : '' + + this.editForm = { + icon: kc?.icon || '', + label: kc?.label || '', + icon_scale: kc?.icon_scale ?? 0.55, + font_size: kc?.font_size ?? 16, + bg_color: kc?.background || '', + action_type: action.type || 'command', + command: action.command || '', + builtin: action.builtin || '', + script: action.script || '', + page: action.page || '', + keys: action.keys || '', + modCtrl: parts.includes('ctrl'), + modAlt: parts.includes('alt'), + modShift: parts.includes('shift'), + modSuper: parts.includes('super'), + mainKey: mainKey, + background: action.background ?? true, + display_mode: kc?.display ? (kc.display.command ? 'command' : 'script') : 'none', + display_command: kc?.display?.command || '', + display_script: kc?.display?.script || '', + display_interval: kc?.display?.interval || '30s', + display_max_len: kc?.display?.max_len || 128, + display_timeout: kc?.display?.timeout || '', + } + }, + + get editPreviewUrl() { + const f = this.editForm + let url = '/api/render?key_size=96' + if (f.icon) url += `&icon=${encodeURIComponent(f.icon)}` + if (f.label) url += `&label=${encodeURIComponent(f.label)}` + if (f.icon_scale != null) url += `&icon_scale=${f.icon_scale}` + if (f.font_size != null) url += `&font_size=${f.font_size}` + if (f.bg_color) url += `&background=${encodeURIComponent(f.bg_color)}` + return url + }, + + get editPreviewFaClass() { + return this.faClass(this.editForm.icon) + }, + + async saveKey() { + const page = this.currentPage + if (!page) return + + const dm = this.editForm.display_mode + const hasDisplay = dm !== 'none' + const actionType = this.editForm.action_type + + if (dm === 'command' && !this.editForm.display_command) { + this.showToast('Display command is required', 'error') + return + } + if (dm === 'script' && !this.editForm.display_script) { + this.showToast('Display script path is required', 'error') + return + } + + if (actionType && actionType !== 'none') { + if (actionType === 'command' && !this.editForm.command) { + if (!hasDisplay) { this.showToast('Command is required', 'error'); return } + } + if (actionType === 'script' && !this.editForm.script) { + if (!hasDisplay) { this.showToast('Script path is required', 'error'); return } + } + if (actionType === 'page' && !this.editForm.page) { + this.showToast('Target page is required', 'error') + return + } + if (actionType === 'keyboard' && !this.editForm.keys) { + if (!hasDisplay) { this.showToast('Key combination is required', 'error'); return } + } + } + + const kc = { + index: this.editing, + icon: this.editForm.icon, + label: this.editForm.label, + icon_scale: this.editForm.icon_scale, + font_size: this.editForm.font_size, + background: this.editForm.bg_color || '', + } + + if (actionType && actionType !== 'none') { + if (actionType === 'command' && this.editForm.command) { + kc.action = { type: 'command', command: this.editForm.command, background: this.editForm.background } + } else if (actionType === 'builtin') { + kc.action = { type: 'builtin', builtin: this.editForm.builtin } + } else if (actionType === 'script' && this.editForm.script) { + kc.action = { type: 'script', script: this.editForm.script } + } else if (actionType === 'page' && this.editForm.page) { + kc.action = { type: 'page', page: this.editForm.page } + } else if (actionType === 'keyboard' && this.editForm.keys) { + kc.action = { type: 'keyboard', keys: this.editForm.keys } + } else if (!hasDisplay) { + kc.action = null + } else { + kc.action = null + } + } else { + kc.action = null + } + + if (hasDisplay) { + kc.display = { + command: dm === 'command' ? this.editForm.display_command : '', + script: dm === 'script' ? this.editForm.display_script : '', + interval: this.editForm.display_interval || '30s', + max_len: this.editForm.display_max_len || 0, + } + if (this.editForm.display_timeout) { + kc.display.timeout = this.editForm.display_timeout + } + } else { + kc.display = null + } + + const existingIdx = page.keys.findIndex(k => k.index === this.editing) + if (existingIdx >= 0) { + page.keys[existingIdx] = kc + } else { + page.keys.push(kc) + } + + await this.saveConfig() + this.editing = null + }, + + async deleteKey() { + const page = this.currentPage + if (!page) return + page.keys = page.keys.filter(k => k.index !== this.editing) + await this.saveConfig() + this.editing = null + }, + + // ── Image picker ── + pickImage() { + this.$refs.fileInput.click() + }, + + async onImagePicked(e) { + const file = e.target.files[0] + if (!file) return + const formData = new FormData() + formData.append('file', file) + try { + const res = await fetch('/api/upload', { method: 'POST', body: formData }) + if (!res.ok) { this.showToast('Upload failed', 'error'); return } + const data = await res.json() + this.editForm.icon = data.path + } catch (err) { + this.showToast('Upload failed', 'error') + } + e.target.value = '' + }, + + // ── Page management ── + switchPage(name) { + this.activePage = name + this.displayOutputs = {} + }, + + async addPage() { + const name = prompt('New page name:') + if (!name || name.trim() === '') return + if (this.pages.find(p => p.name === name)) { + this.showToast('Page already exists', 'error') + return + } + this.pages.push({ name: name.trim(), keys: [] }) + this.activePage = name.trim() + this.config.pages = this.pages + if (!this.config.default_page) { + this.config.default_page = name.trim() + } + await this.saveConfig() + }, + + async deletePage() { + if (this.pages.length <= 1) { + this.showToast('Cannot delete the last page', 'error') + return + } + if (!confirm(`Delete page "${this.activePage}"?`)) return + const oldName = this.activePage + this.pages = this.pages.filter(p => p.name !== oldName) + this.activePage = this.pages[0].name + if (this.config.default_page === oldName) { + this.config.default_page = this.activePage + } + this.config.pages = this.pages + await this.saveConfig() + }, + + async renamePage() { + const oldName = this.activePage + const newName = prompt('Rename page:', oldName) + if (!newName || newName.trim() === '' || newName === oldName) return + if (this.pages.find(p => p.name === newName.trim())) { + this.showToast('Page already exists', 'error') + return + } + const page = this.pages.find(p => p.name === oldName) + if (page) page.name = newName.trim() + this.activePage = newName.trim() + if (this.config.default_page === oldName) { + this.config.default_page = newName.trim() + } + + // Update page references + for (const p of this.pages) { + for (const k of p.keys) { + if (k.action?.page === oldName) k.action.page = newName.trim() + } + } + for (const r of (this.config.auto_switch || [])) { + if (r.page === oldName) r.page = newName.trim() + } + + await this.saveConfig() + }, + + // ── Save / Reload ── + async saveConfig() { + this.config.pages = this.pages + try { + const res = await fetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.config), + }) + if (res.ok) { + this.showToast('Saved', 'success') + } else { + const text = await res.text() + this.showToast(`Save failed: ${text}`, 'error') + } + } catch (e) { + this.showToast('Save failed', 'error') + } + }, + + async reloadConfig() { + await this.loadConfig() + this.showToast('Reloaded', 'success') + }, + + // ── Settings ── + async saveSettings() { + if (!this.config.devices || this.config.devices.length === 0) { + this.config.devices = [{ serial: '', brightness: 75 }] + } + this.config.devices[0].brightness = parseInt(this.settingsForm.brightness) + this.config.screensaver = { + enabled: this.settingsForm.screensaver_enabled, + idle_seconds: parseInt(this.settingsForm.screensaver_idle) || 30, + brightness: parseInt(this.settingsForm.screensaver_brightness) || 10, + } + this.config.auto_switch = this.autoSwitchRules + await this.saveConfig() + }, + + addAutoSwitchRule() { + this.autoSwitchRules.push({ wm_class: '', title: '', page: '', stay: false, devices: [] }) + }, + + removeAutoSwitchRule(idx) { + this.autoSwitchRules.splice(idx, 1) + }, + + // ── Backup ── + backups: [], + + async loadBackups() { + try { + const res = await fetch('/api/backups') + this.backups = await res.json() + } catch (e) { + this.backups = [] + } + }, + + downloadConfig() { + window.open('/api/config/download', '_blank') + }, + + async downloadBackup(name) { + window.open(`/api/backups/${encodeURIComponent(name)}`, '_blank') + }, + + async restoreConfig() { + const input = document.createElement('input') + input.type = 'file' + input.accept = '.json' + input.onchange = async () => { + const file = input.files[0] + if (!file) return + try { + const formData = new FormData() + formData.append('file', file) + const res = await fetch('/api/config/restore', { + method: 'POST', + body: formData, + }) + if (res.ok) { + await this.loadConfig() + this.showToast('Config restored', 'success') + } else { + const text = await res.text() + this.showToast(`Restore failed: ${text}`, 'error') + } + } catch (e) { + this.showToast('Restore failed', 'error') + } + } + input.click() + }, + + async restoreBackup(filename) { + if (!confirm(`Restore "${filename}"?`)) return + try { + const res = await fetch(`/api/backups/${encodeURIComponent(filename)}`) + if (!res.ok) { + this.showToast('Failed to download backup', 'error') + return + } + const blob = await res.blob() + const formData = new FormData() + formData.append('file', blob, filename) + const putRes = await fetch('/api/config/restore', { + method: 'POST', + body: formData, + }) + if (putRes.ok) { + await this.loadConfig() + this.showToast('Backup restored', 'success') + } else { + const text = await putRes.text() + this.showToast(`Restore failed: ${text}`, 'error') + } + } catch (e) { + this.showToast('Restore failed', 'error') + } + }, + + // ── Toast ── + showToast(msg, type = 'success') { + this.toast = { msg, type } + setTimeout(() => { this.toast = null }, 2500) + }, + + // ── Formatting ── + formatSize(bytes) { + if (bytes < 1024) return bytes + ' B' + return (bytes / 1024).toFixed(1) + ' KB' + }, + + formatTime(iso) { + if (!iso) return '' + const d = new Date(iso) + return d.toLocaleString() + }, + + backupNameTime(name) { + // Extract date from config.YYYY-MM-DDTHH-MM-SS.json + const m = name.match(/config\.(.+)\.json/) + if (!m) return name + return m[1].replace('T', ' ') + }, + })) +}) diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..81efeb6 --- /dev/null +++ b/static/index.html @@ -0,0 +1,440 @@ + + + + + +StreamDeck + + + + + + +
+ + +
+ + + + + + +
+ + + +
+
+ + +
+
+ +
+ +
+ + + +
+
+ + +
+
+

Pages

+ +
+
+ + +
+
+ + +
+

Device

+
+ +
+ + +
+
+
+ + +
+

Screensaver

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+

Auto-switch rules

+ + +
+ + +
+
+ + +
+
+ +
+

Actions

+
+ + +
+
+ +
+

Auto backups

+ + +
+
+
+ + + + + +
+ +
+ + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..c581c5c --- /dev/null +++ b/static/styles.css @@ -0,0 +1,697 @@ +:root { + --bg: #0a0a10; + --surface: #141420; + --surface-hover: #1a1a2a; + --border: #252540; + --border-light: #333358; + --text: #e4e4f0; + --text-muted: #7a7a90; + --accent: #6366f1; + --accent-glow: rgba(99, 102, 241, 0.3); + --danger: #ef4444; + --success: #22c55e; + --radius: 12px; + --radius-sm: 8px; + --radius-xs: 6px; + --shadow: 0 4px 24px rgba(0, 0, 0, 0.4); + --transition: 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +.app { + display: flex; + flex-direction: column; + height: 100vh; + max-width: 780px; + margin: 0 auto; + padding: 0 16px; +} + +/* ── Header ── */ +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 0; + gap: 12px; + flex-shrink: 0; +} + +header h1 { + font-size: 16px; + font-weight: 600; + letter-spacing: -0.3px; +} + +.page-nav { + display: flex; + align-items: center; + gap: 6px; +} + +.page-nav .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--border); + cursor: pointer; + transition: background var(--transition); +} +.page-nav .dot:hover { background: var(--text-muted); } +.page-nav .dot.active { background: var(--accent); box-shadow: 0 0 8px var(--accent-glow); } + +.header-actions { + display: flex; + gap: 8px; +} + +.icon-btn { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-muted); + width: 32px; + height: 32px; + border-radius: var(--radius-xs); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all var(--transition); + font-size: 14px; +} +.icon-btn:hover { + background: var(--surface-hover); + border-color: var(--border-light); + color: var(--text); +} + +/* ── Grid ── */ +main { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +} + +.deck-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; + padding: 20px; + background: var(--surface); + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.key-btn { + aspect-ratio: 1; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: #0d0d18; + cursor: pointer; + overflow: hidden; + position: relative; + transition: all var(--transition); + padding: 8px; + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.key-btn:hover { + border-color: var(--accent); + box-shadow: 0 0 12px var(--accent-glow); + transform: scale(1.03); +} + +.key-btn:active { transform: scale(0.97); } + +.key-btn img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: inherit; +} + +.key-btn.empty { + opacity: 0.3; +} +.key-btn.empty:hover { + opacity: 0.5; + border-color: var(--border-light); + box-shadow: none; +} + +.key-label { + position: absolute; + bottom: 3px; + left: 4px; + right: 4px; + font-size: 9px; + color: rgba(255, 255, 255, 0.75); + text-align: center; + text-shadow: 0 1px 2px rgba(0,0,0,0.8); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + line-height: 1; +} + +.fa-preview { + font-size: 34px; + color: #fff; +} + +/* ── Modal ── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 150ms ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; + width: 360px; + max-width: calc(100vw - 32px); + box-shadow: var(--shadow); + animation: slideUp 200ms cubic-bezier(0.16, 1, 0.3, 1); + max-height: 85vh; + overflow-y: auto; +} + +@keyframes slideUp { + from { transform: translateY(16px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.modal h3 { + font-size: 15px; + font-weight: 600; + margin-bottom: 16px; + letter-spacing: -0.2px; +} + +.preview-box { + width: 100px; + height: 100px; + margin: 0 auto 16px; + border-radius: var(--radius-xs); + overflow: hidden; + border: 1px solid var(--border); + position: relative; + background: #0d0d18; + cursor: pointer; +} + +.preview-box img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.preview-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + opacity: 0; + transition: opacity var(--transition); + border-radius: var(--radius-xs); +} + +.preview-box:hover .preview-overlay { opacity: 1; } + +.preview-overlay i { + font-size: 24px; + color: #fff; +} + +.form-group { + margin-bottom: 12px; +} + +.form-group label { + display: block; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 8px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-xs); + color: var(--text); + font-size: 13px; + font-family: inherit; + outline: none; + transition: border-color var(--transition); +} + +.form-group input:focus, +.form-group select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +.form-group input::placeholder { + color: var(--text-muted); + opacity: 0.5; +} + +.form-group select { + cursor: pointer; + -webkit-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%237a7a90' stroke-width='1.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 28px; +} + +.form-row { + display: flex; + gap: 8px; +} + +.form-row .form-group { flex: 1; } + +.checkbox-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.checkbox-row input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} + +.checkbox-row label { + font-size: 13px; + color: var(--text); + cursor: pointer; +} + +/* Collapsible */ +.collapse-toggle { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 4px 0; + margin-bottom: 8px; + font-family: inherit; +} + +.collapse-toggle:hover { color: var(--accent); } +.collapse-toggle i { font-size: 10px; transition: transform var(--transition); } +.collapse-toggle.open i { transform: rotate(90deg); } + +.modal-actions { + display: flex; + gap: 8px; + margin-top: 20px; +} + +.btn { + padding: 8px 16px; + border-radius: var(--radius-xs); + font-size: 13px; + font-weight: 600; + cursor: pointer; + font-family: inherit; + border: 1px solid transparent; + transition: all var(--transition); +} + +.btn-primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); + flex: 1; +} + +.btn-primary:hover { + background: #4f46e5; + box-shadow: 0 0 16px var(--accent-glow); +} + +.btn-outline { + background: transparent; + border-color: var(--border); + color: var(--text); +} + +.btn-outline:hover { + background: var(--surface-hover); + border-color: var(--border-light); +} + +.btn-danger { + color: var(--danger); + border-color: transparent; + background: transparent; +} + +.btn-danger:hover { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.3); +} + +/* ── Settings / Backup panels ── */ +.panel { + width: 100%; + padding: 20px; + background: var(--surface); + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.panel h3 { + font-size: 14px; + font-weight: 600; + margin-bottom: 16px; +} + +.panel-section { + margin-bottom: 20px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border); +} + +.panel-section:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.panel-section h4 { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.backup-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid var(--border); + font-size: 12px; +} + +.backup-item:last-child { border-bottom: none; } + +.backup-item .meta { + color: var(--text-muted); + font-size: 11px; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: var(--bg); + border-radius: 20px; + font-size: 12px; + border: 1px solid var(--border); +} + +.status-badge .dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.status-badge .dot.online { background: var(--success); } +.status-badge .dot.offline { background: var(--text-muted); } + +/* ── Toasts ── */ +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: var(--surface); + border: 1px solid var(--border); + padding: 12px 24px; + border-radius: var(--radius); + font-size: 13px; + z-index: 200; + box-shadow: var(--shadow); + animation: slideUp 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.toast.success { border-color: var(--success); } +.toast.error { border-color: var(--danger); } + +.slider-group { + display: flex; + align-items: center; + gap: 10px; +} + +.slider-group input[type="range"] { + flex: 1; + -webkit-appearance: none; + appearance: none; + height: 4px; + border-radius: 2px; + background: var(--border); + outline: none; +} + +.slider-group input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: 2px solid var(--bg); +} + +.slider-group .value { + font-size: 12px; + color: var(--text-muted); + min-width: 36px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* ── Toolbar ── */ +.toolbar { + display: flex; + align-items: center; + gap: 6px; + margin-top: 12px; + flex-shrink: 0; +} + +.toolbar .btn { + font-size: 12px; + padding: 6px 14px; +} + +/* ── Scrollbar ── */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--border-light); } + +/* ── Auto-switch rule item ── */ +.rule-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: var(--bg); + border-radius: var(--radius-xs); + margin-bottom: 6px; + font-size: 12px; +} + +.rule-item code { + background: var(--surface); + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + color: var(--accent); +} + +/* ── Display indicator on grid keys ── */ +.display-indicator { + position: absolute; + bottom: 4px; + right: 4px; + font-size: 7px; + color: var(--accent); + opacity: 0.6; + pointer-events: none; +} + +/* ── Tab buttons ── */ +.tab-row { + display: flex; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + overflow: hidden; +} + +.tab-btn { + flex: 1; + padding: 6px 8px; + background: var(--bg); + border: none; + color: var(--text-muted); + font-size: 11px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: all var(--transition); + border-right: 1px solid var(--border); +} + +.tab-btn:last-child { border-right: none; } +.tab-btn:hover { color: var(--text); } +.tab-btn.active { + background: var(--accent); + color: #fff; +} + +/* ── Modifier buttons ── */ +.mod-row { + display: flex; + gap: 4px; + margin-bottom: 8px; +} +.mod-btn { + flex: 1; + padding: 6px 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-xs); + color: var(--text-muted); + font-size: 11px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: all var(--transition); + text-align: center; +} +.mod-btn:hover { color: var(--text); border-color: var(--border-light); } +.mod-btn.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +/* ── Key capture ── */ +.key-capture { + border: 1px dashed var(--border); + border-radius: var(--radius-xs); + padding: 8px; + min-height: 34px; + cursor: pointer; + text-align: center; + transition: all var(--transition); + outline: none; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; +} +.key-capture:hover { border-color: var(--accent); } +.key-capture:focus-visible { border-color: var(--accent); } +.key-capture.capturing { + border-color: var(--accent); + box-shadow: 0 0 12px var(--accent-glow); + animation: recorderPulse 1s infinite; +} +.captured-key { + font-weight: 600; + font-family: 'SF Mono', 'Fira Code', monospace; + color: var(--accent); + font-size: 13px; +} +.capture-hint { color: var(--accent); font-weight: 600; } +.capture-placeholder { color: var(--text-muted); opacity: 0.6; } + +@keyframes recorderPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} +.key-chips { + display: flex; + align-items: center; + gap: 2px; + flex-wrap: wrap; + justify-content: center; +} +.key-chip { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 2px 8px; + font-size: 12px; + font-weight: 600; + font-family: 'SF Mono', 'Fira Code', monospace; + color: var(--accent); +} +.key-plus { + color: var(--text-muted); + font-size: 11px; + margin: 0 2px; +} diff --git a/web.go b/web.go new file mode 100644 index 0000000..2e643d8 --- /dev/null +++ b/web.go @@ -0,0 +1,529 @@ +package main + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "image/png" + "io" + "io/fs" + "log/slog" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "streamdeck-lets-go/internal/config" +) + +//go:embed static/* +var staticFS embed.FS + +var staticRoot fs.FS + +func init() { + var err error + staticRoot, err = fs.Sub(staticFS, "static") + if err != nil { + panic(fmt.Sprintf("static embed: %v", err)) + } +} + +type WebServer struct { + cfg *config.Config + configPath string + pm *PageManager + mu sync.RWMutex +} + +func NewWebServer(cfg *config.Config, configPath string) *WebServer { + return &WebServer{cfg: cfg, configPath: configPath} +} + +func (s *WebServer) UpdateConfig(cfg *config.Config) { + s.mu.Lock() + defer s.mu.Unlock() + s.cfg = cfg +} + +func (s *WebServer) SetPageManager(pm *PageManager) { + s.mu.Lock() + defer s.mu.Unlock() + s.pm = pm +} + +func (s *WebServer) Serve(ctx context.Context, addr string) error { + mux := http.NewServeMux() + + mux.HandleFunc("GET /api/config", s.handleGetConfig) + mux.HandleFunc("PUT /api/config", s.handlePutConfig) + mux.HandleFunc("GET /api/config/download", s.handleDownloadConfig) + mux.HandleFunc("POST /api/config/restore", s.handleRestoreConfig) + + mux.HandleFunc("GET /api/pages", s.handleGetPages) + + mux.HandleFunc("GET /api/render", s.handleRender) + + mux.HandleFunc("GET /api/display-outputs", s.handleDisplayOutputs) + + mux.HandleFunc("GET /api/backups", s.handleListBackups) + mux.HandleFunc("GET /api/backups/{filename}", s.handleGetBackup) + + mux.HandleFunc("GET /api/models", s.handleGetModels) + + mux.HandleFunc("GET /api/status", s.handleGetStatus) + + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticRoot)))) + + mux.HandleFunc("/", s.handleSPA) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && r.URL.Path == "/api/upload" { + s.handleUpload(w, r) + return + } + mux.ServeHTTP(w, r) + }) + + srv := &http.Server{Addr: addr, Handler: handler} + + go func() { + <-ctx.Done() + slog.Info("shutting down web server") + srv.Close() + }() + + slog.Info("web server listening", "addr", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("web server: %w", err) + } + return nil +} + +func (s *WebServer) handleSPA(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + data, err := fs.ReadFile(staticRoot, "index.html") + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) +} + +func (s *WebServer) handleGetConfig(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + defer s.mu.RUnlock() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(s.cfg) +} + +func (s *WebServer) handlePutConfig(w http.ResponseWriter, r *http.Request) { + var newCfg config.Config + if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil { + http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest) + return + } + if err := newCfg.Validate(); err != nil { + http.Error(w, fmt.Sprintf("invalid config: %v", err), http.StatusBadRequest) + return + } + + path := config.ConfigPath(s.configPath) + + s.mu.Lock() + oldCfg := s.cfg + s.cfg = &newCfg + s.mu.Unlock() + + if err := s.cfg.Save(s.configPath); err != nil { + s.mu.Lock() + s.cfg = oldCfg + s.mu.Unlock() + slog.Error("save config", "error", err) + http.Error(w, fmt.Sprintf("save failed: %v", err), http.StatusInternalServerError) + return + } + + backupOldConfig(path) + gcImages(&newCfg) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "saved"}) +} + +func (s *WebServer) handleDownloadConfig(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + defer s.mu.RUnlock() + + data, err := json.MarshalIndent(s.cfg, "", " ") + if err != nil { + http.Error(w, "serialization error", http.StatusInternalServerError) + return + } + + filename := fmt.Sprintf("streamdeck-config-%s.json", time.Now().Format("2006-01-02")) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + w.Write(data) +} + +func (s *WebServer) handleRestoreConfig(w http.ResponseWriter, r *http.Request) { + ct := r.Header.Get("Content-Type") + var newCfg config.Config + + if strings.HasPrefix(ct, "multipart/form-data") { + if err := r.ParseMultipartForm(10 << 20); err != nil { + http.Error(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest) + return + } + file, _, err := r.FormFile("file") + if err != nil { + http.Error(w, fmt.Sprintf("file field 'file' required: %v", err), http.StatusBadRequest) + return + } + defer file.Close() + if err := json.NewDecoder(file).Decode(&newCfg); err != nil { + http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest) + return + } + } else { + if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil { + http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest) + return + } + } + + if err := newCfg.Validate(); err != nil { + http.Error(w, fmt.Sprintf("invalid config: %v", err), http.StatusBadRequest) + return + } + + path := config.ConfigPath(s.configPath) + backupOldConfig(path) + + s.mu.Lock() + s.cfg = &newCfg + s.mu.Unlock() + + if err := s.cfg.Save(s.configPath); err != nil { + http.Error(w, fmt.Sprintf("save failed: %v", err), http.StatusInternalServerError) + return + } + gcImages(&newCfg) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "restored"}) +} + +func (s *WebServer) handleGetPages(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + defer s.mu.RUnlock() + + type pageInfo struct { + Name string `json:"name"` + Keys []config.KeyConfig `json:"keys"` + Icon string `json:"icon,omitempty"` + } + + pages := make([]pageInfo, 0, len(s.cfg.Pages)) + for _, p := range s.cfg.Pages { + pages = append(pages, pageInfo{ + Name: p.Name, + Keys: p.Keys, + Icon: p.Icon, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(pages) +} + +func (s *WebServer) handleRender(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + keySize := 72 + if s := q.Get("key_size"); s != "" { + fmt.Sscanf(s, "%d", &keySize) + } + + kc := &config.KeyConfig{} + + if page := q.Get("page"); page != "" { + key := q.Get("key") + s.mu.RLock() + for _, p := range s.cfg.Pages { + if p.Name == page { + for _, k := range p.Keys { + if fmt.Sprintf("%d", k.Index) == key { + kc = &k + break + } + } + break + } + } + s.mu.RUnlock() + } + + if icon := q.Get("icon"); icon != "" { + kc.Icon = icon + if s := q.Get("icon_scale"); s != "" { + v := 0.0 + fmt.Sscanf(s, "%f", &v) + kc.IconScale = &v + } + } + if label := q.Get("label"); label != "" { + kc.Label = label + } + if fs := q.Get("font_size"); fs != "" { + v := 0.0 + fmt.Sscanf(fs, "%f", &v) + kc.FontSize = &v + } + if bg := q.Get("background"); bg != "" { + kc.Background = bg + } + + img := RenderKeyToImage(kc, keySize) + + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "no-cache") + png.Encode(w, img) +} + +func (s *WebServer) handleDisplayOutputs(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + pm := s.pm + s.mu.RUnlock() + + if pm == nil { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{}")) + return + } + + outputs := pm.GetDisplayOutputs() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(outputs) +} + +func backupOldConfig(path string) { + backupDir := filepath.Join(filepath.Dir(path), "backups") + if err := os.MkdirAll(backupDir, 0755); err != nil { + slog.Warn("backup: create dir", "error", err) + return + } + + data, err := os.ReadFile(path) + if err != nil { + return + } + + name := fmt.Sprintf("config.%s.json", time.Now().Format("2006-01-02T15-04-05")) + dst := filepath.Join(backupDir, name) + if err := os.WriteFile(dst, data, 0644); err != nil { + slog.Warn("backup: write", "error", err) + return + } + + entries, _ := os.ReadDir(backupDir) + if len(entries) > 10 { + sort.Slice(entries, func(i, j int) bool { + fi, _ := entries[i].Info() + fj, _ := entries[j].Info() + return fi.ModTime().Before(fj.ModTime()) + }) + for _, e := range entries[:len(entries)-10] { + os.Remove(filepath.Join(backupDir, e.Name())) + } + } +} + +func gcImages(cfg *config.Config) { + imgDir := filepath.Join(configDir(), "images") + entries, err := os.ReadDir(imgDir) + if err != nil { + return + } + + used := make(map[string]bool, len(entries)) + for _, p := range cfg.Pages { + for _, k := range p.Keys { + if k.Icon != "" { + used[k.Icon] = true + } + } + } + + for _, e := range entries { + if e.IsDir() { + continue + } + path := filepath.Join(imgDir, e.Name()) + if !used[path] { + if err := os.Remove(path); err != nil { + slog.Warn("gc: remove orphan image", "path", path, "error", err) + } else { + slog.Debug("gc: removed orphan image", "path", path) + } + } + } +} + +func (s *WebServer) handleListBackups(w http.ResponseWriter, r *http.Request) { + path := config.ConfigPath(s.configPath) + backupDir := filepath.Join(filepath.Dir(path), "backups") + + entries, err := os.ReadDir(backupDir) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]map[string]interface{}{}) + return + } + + type backupInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + Time string `json:"time"` + } + + var backups []backupInfo + for _, e := range entries { + if e.IsDir() || !strings.HasPrefix(e.Name(), "config.") || !strings.HasSuffix(e.Name(), ".json") { + continue + } + fi, err := e.Info() + if err != nil { + continue + } + backups = append(backups, backupInfo{ + Name: e.Name(), + Size: fi.Size(), + Time: fi.ModTime().Format(time.RFC3339), + }) + } + + sort.Slice(backups, func(i, j int) bool { + return backups[i].Time > backups[j].Time + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(backups) +} + +func (s *WebServer) handleGetBackup(w http.ResponseWriter, r *http.Request) { + filename := r.PathValue("filename") + if filename == "" || strings.Contains(filename, "/") || strings.Contains(filename, "..") { + http.Error(w, "invalid filename", http.StatusBadRequest) + return + } + + path := config.ConfigPath(s.configPath) + backupDir := filepath.Join(filepath.Dir(path), "backups") + fullPath := filepath.Join(backupDir, filename) + + if !strings.HasPrefix(fullPath, backupDir) { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + + data, err := os.ReadFile(fullPath) + if err != nil { + http.Error(w, "backup not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + w.Write(data) +} + +func (s *WebServer) handleGetModels(w http.ResponseWriter, r *http.Request) { + type modelInfo struct { + PID uint16 `json:"pid"` + Name string `json:"name"` + KeysX int `json:"keys_x"` + KeysY int `json:"keys_y"` + KeySize int `json:"key_size"` + } + + models := make([]modelInfo, 0, len(knownDecks)) + for _, d := range knownDecks { + models = append(models, modelInfo{ + PID: d.PID, + Name: d.Name, + KeysX: d.KeysX, + KeysY: d.KeysY, + KeySize: d.KeySize, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models) +} + +func (s *WebServer) handleGetStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "config_version": s.cfg.Version, + "default_page": s.cfg.DefaultPage, + "page_count": len(s.cfg.Pages), + "device_count": len(s.cfg.Devices), + }) +} + +func (s *WebServer) handleUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := r.ParseMultipartForm(10 << 20); err != nil { + http.Error(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest) + return + } + + file, handler, err := r.FormFile("file") + if err != nil { + http.Error(w, fmt.Sprintf("file field 'file' required: %v", err), http.StatusBadRequest) + return + } + defer file.Close() + + uploadDir := filepath.Join(configDir(), "images") + if err := os.MkdirAll(uploadDir, 0755); err != nil { + http.Error(w, fmt.Sprintf("create upload dir: %v", err), http.StatusInternalServerError) + return + } + + ext := filepath.Ext(handler.Filename) + if ext == "" { + ext = ".png" + } + filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext) + savePath := filepath.Join(uploadDir, filename) + + dst, err := os.Create(savePath) + if err != nil { + http.Error(w, fmt.Sprintf("create file: %v", err), http.StatusInternalServerError) + return + } + defer dst.Close() + + if _, err := io.Copy(dst, file); err != nil { + http.Error(w, fmt.Sprintf("write file: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"path": savePath}) +}