├── img └── current.png ├── .gitignore ├── README.md ├── main.go ├── LICENSE ├── go.mod ├── button.go ├── handler.go ├── go.sum └── ui.go /img/current.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unix-streamdeck/streamdeckui/HEAD/img/current.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | .idea 15 | streamdeckui 16 | .vscode/launch.json 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StreamDeck UI 2 | 3 | This repository contains a graphical configuration tool for the StreamDeck 4 | devices that works on Unix, Linux and other OS. 5 | It is a work heavily in progress and we welcome any contributions. 6 | 7 | # Dependencies 8 | 9 | dbus & zenity 10 | 11 | For Debian/Ubuntu and Linux Mint users fallowing depencencies are needed 12 | ``` 13 | sudo apt install libgl1-mesa-dev xorg-dev 14 | ``` 15 | 16 | # Usage 17 | 18 | To use the streamdeck on unix you will need to have a daemon running. 19 | This GUI is built to work with unix-streamdeck/streamdeckd, the install steps 20 | are the following to build from code. You will only need a Go compiler. 21 | 22 | ```bash 23 | $ go get github.com/unix-streamdeck/streamdeckd 24 | $ `go env path`/bin/streamdeckd 25 | ``` 26 | 27 | Once that is running (you should probably plug in your streamdeck device first) 28 | you can install this package 29 | 30 | ```bash 31 | $ go get github.com/unix-streamdeck/streamdeckui 32 | $ `go env path`/bin/streamdeckui 33 | ``` 34 | 35 | # Screenshot 36 | 37 | ![](img/current.png) 38 | 39 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/app" 8 | "fyne.io/fyne/v2/driver/desktop" 9 | "github.com/unix-streamdeck/api" 10 | ) 11 | 12 | var conn *api.Connection 13 | 14 | func main() { 15 | dev, err := api.Connect() 16 | if err != nil { 17 | log.Fatal("Could not connect to device: " + err.Error()) 18 | } 19 | conn = dev 20 | 21 | defer dev.Close() 22 | info, err := dev.GetInfo() 23 | if err != nil { 24 | log.Fatal("Cound not read device info: " + err.Error()) 25 | } 26 | 27 | a := app.New() 28 | w := a.NewWindow("StreamDeck Unix") 29 | 30 | e := newEditor(info, w) 31 | 32 | // CTRL-S : save config 33 | ctrlS := desktop.CustomShortcut{KeyName: fyne.KeyS, Modifier: desktop.ControlModifier} 34 | e.win.Canvas().AddShortcut(&ctrlS, func(shortcut fyne.Shortcut) { 35 | e.saveConfig() 36 | }) 37 | 38 | // CTRL-C : copy current button 39 | e.win.Canvas().AddShortcut(&fyne.ShortcutCopy{}, func(shortcut fyne.Shortcut) { 40 | e.copyButton() 41 | }) 42 | 43 | // CTRL-V : paste current button 44 | e.win.Canvas().AddShortcut(&fyne.ShortcutPaste{}, func(shortcut fyne.Shortcut) { 45 | e.pasteButton() 46 | }) 47 | 48 | w.SetContent(e.loadUI()) 49 | w.ShowAndRun() 50 | } 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, unix-streamdeck 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/unix-streamdeck/streamdeckui 2 | 3 | go 1.23.1 4 | 5 | toolchain go1.24.6 6 | 7 | require ( 8 | fyne.io/fyne/v2 v2.6.3 9 | github.com/ncruces/zenity v0.10.14 10 | github.com/unix-streamdeck/api v1.0.1 11 | ) 12 | 13 | require ( 14 | fyne.io/systray v1.11.0 // indirect 15 | github.com/BurntSushi/toml v1.5.0 // indirect 16 | github.com/akavel/rsrc v0.10.2 // indirect 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/dchest/jsmin v1.0.0 // indirect 19 | github.com/fogleman/gg v1.3.0 // indirect 20 | github.com/fredbi/uri v1.1.1 // indirect 21 | github.com/fsnotify/fsnotify v1.9.0 // indirect 22 | github.com/fyne-io/gl-js v0.2.0 // indirect 23 | github.com/fyne-io/glfw-js v0.3.0 // indirect 24 | github.com/fyne-io/image v0.1.1 // indirect 25 | github.com/fyne-io/oksvg v0.1.0 // indirect 26 | github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect 27 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 // indirect 28 | github.com/go-text/render v0.2.0 // indirect 29 | github.com/go-text/typesetting v0.3.0 // indirect 30 | github.com/godbus/dbus/v5 v5.1.0 // indirect 31 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect 32 | github.com/hack-pad/go-indexeddb v0.3.2 // indirect 33 | github.com/hack-pad/safejs v0.1.1 // indirect 34 | github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect 35 | github.com/josephspurrier/goversioninfo v1.5.0 // indirect 36 | github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect 37 | github.com/kr/text v0.1.0 // indirect 38 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect 39 | github.com/nicksnyder/go-i18n/v2 v2.6.0 // indirect 40 | github.com/pmezard/go-difflib v1.0.0 // indirect 41 | github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect 42 | github.com/rymdport/portal v0.4.2 // indirect 43 | github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect 44 | github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect 45 | github.com/stretchr/testify v1.11.0 // indirect 46 | github.com/yuin/goldmark v1.7.13 // indirect 47 | golang.org/x/image v0.30.0 // indirect 48 | golang.org/x/net v0.43.0 // indirect 49 | golang.org/x/sys v0.35.0 // indirect 50 | golang.org/x/text v0.28.0 // indirect 51 | gopkg.in/yaml.v3 v3.0.1 // indirect 52 | ) 53 | -------------------------------------------------------------------------------- /button.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | 7 | "fyne.io/fyne/v2" 8 | "fyne.io/fyne/v2/canvas" 9 | "fyne.io/fyne/v2/theme" 10 | "fyne.io/fyne/v2/widget" 11 | "github.com/unix-streamdeck/api" 12 | ) 13 | 14 | type button struct { 15 | widget.BaseWidget 16 | editor *editor 17 | 18 | keyID int 19 | key api.Key 20 | } 21 | 22 | func newButton(key api.Key, id int, e *editor) *button { 23 | b := &button{key: key, keyID: id, editor: e} 24 | b.ExtendBaseWidget(b) 25 | return b 26 | } 27 | 28 | func (b *button) CreateRenderer() fyne.WidgetRenderer { 29 | icon := canvas.NewImageFromFile(b.key.Icon) 30 | text := &canvas.Image{} 31 | 32 | border := canvas.NewRectangle(color.Transparent) 33 | border.StrokeWidth = 2 34 | border.SetMinSize(fyne.NewSize(float32(b.editor.currentDevice.IconSize), float32(b.editor.currentDevice.IconSize))) 35 | 36 | bg := canvas.NewRectangle(color.Black) 37 | render := &buttonRenderer{border: border, text: text, icon: icon, bg: bg, 38 | objects: []fyne.CanvasObject{bg, icon, text, border}, b: b} 39 | render.Refresh() 40 | return render 41 | } 42 | 43 | func (b *button) Tapped(ev *fyne.PointEvent) { 44 | b.editor.editButton(b) 45 | } 46 | 47 | func (b *button) updateKey() { 48 | if b.keyID >= len(b.editor.currentDeviceConfig.Pages[b.editor.currentDevice.Page]) { 49 | return 50 | } 51 | b.editor.currentDeviceConfig.Pages[b.editor.currentDevice.Page][b.keyID] = b.key 52 | if b.editor.currentDeviceConfig.Pages[b.editor.currentDevice.Page][b.keyID].IconHandler == "Default" { 53 | b.editor.currentDeviceConfig.Pages[b.editor.currentDevice.Page][b.keyID].IconHandler = "" 54 | } 55 | if b.editor.currentDeviceConfig.Pages[b.editor.currentDevice.Page][b.keyID].KeyHandler == "Default" { 56 | b.editor.currentDeviceConfig.Pages[b.editor.currentDevice.Page][b.keyID].KeyHandler = "" 57 | } 58 | } 59 | 60 | const ( 61 | buttonInset = 2 62 | ) 63 | 64 | type buttonRenderer struct { 65 | border, bg *canvas.Rectangle 66 | icon, text *canvas.Image 67 | 68 | objects []fyne.CanvasObject 69 | 70 | b *button 71 | } 72 | 73 | func (r *buttonRenderer) Layout(s fyne.Size) { 74 | size := s.Subtract(fyne.NewSize(buttonInset*2, buttonInset*2)) 75 | offset := fyne.NewPos(buttonInset, buttonInset) 76 | 77 | for _, obj := range r.objects { 78 | obj.Move(offset) 79 | obj.Resize(size) 80 | } 81 | } 82 | 83 | func (r *buttonRenderer) MinSize() fyne.Size { 84 | iconSize := fyne.NewSize(float32(r.b.editor.currentDevice.IconSize), float32(r.b.editor.currentDevice.IconSize)) 85 | return iconSize.Add(fyne.NewSize(buttonInset*2, buttonInset*2)) 86 | } 87 | 88 | func (r *buttonRenderer) Refresh() { 89 | if r.b.editor.currentButton == r.b { 90 | r.border.StrokeColor = theme.FocusColor() 91 | } else { 92 | r.border.StrokeColor = &color.Gray{128} 93 | } 94 | 95 | r.text.Image = r.textToImage() 96 | r.text.Refresh() 97 | if r.b.key.Icon != r.icon.File { 98 | r.icon.File = r.b.key.Icon 99 | go r.icon.Refresh() 100 | } 101 | 102 | r.border.Refresh() 103 | } 104 | 105 | func (r *buttonRenderer) BackgroundColor() color.Color { 106 | return color.Transparent 107 | } 108 | 109 | func (r *buttonRenderer) Objects() []fyne.CanvasObject { 110 | return r.objects 111 | } 112 | 113 | func (r *buttonRenderer) Destroy() { 114 | // nothing 115 | } 116 | 117 | func (r *buttonRenderer) textToImage() image.Image { 118 | textImg := image.NewNRGBA(image.Rect(0, 0, r.b.editor.currentDevice.IconSize, r.b.editor.currentDevice.IconSize)) 119 | img, err := api.DrawText(textImg, r.b.key.Text, r.b.key.TextSize, r.b.key.TextAlignment) 120 | if err != nil { 121 | fyne.LogError("Failed to draw text to imge", err) 122 | } 123 | return img 124 | } 125 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "strconv" 7 | "strings" 8 | 9 | "fyne.io/fyne/v2" 10 | "fyne.io/fyne/v2/dialog" 11 | "fyne.io/fyne/v2/layout" 12 | "fyne.io/fyne/v2/widget" 13 | "github.com/ncruces/zenity" 14 | "github.com/unix-streamdeck/api" 15 | ) 16 | 17 | var ( 18 | handlers = []*api.Module{ 19 | {Name: "Default", IsIcon: true, IsKey: true}, 20 | } 21 | ) 22 | 23 | func initHandlers(conn *api.Connection) { 24 | modules, err := conn.GetModules() 25 | if err != nil { 26 | fyne.LogError("Unable to get handlers", err) 27 | } 28 | handlers = append(handlers, modules...) 29 | } 30 | 31 | func loadDefaultIconUI(e *editor) fyne.CanvasObject { 32 | 33 | entry := widget.NewMultiLineEntry() 34 | entry.OnChanged = func(text string) { 35 | e.currentButton.key.Text = text 36 | e.currentButton.Refresh() 37 | e.currentButton.updateKey() 38 | } 39 | 40 | icon := widget.NewButton("Select Icon", func() { 41 | file, err := zenity.SelectFile(zenity.FileFilters{zenity.FileFilter{Name: "Files", Patterns: []string{"*.png", "*.jpg", "*.jpeg"}}}) 42 | if err != nil && err.Error() != "dialog canceled" { 43 | dialog.ShowError(err, e.win) 44 | return 45 | } 46 | if file != "" { 47 | e.currentButton.key.Icon = file 48 | e.currentButton.Refresh() 49 | e.currentButton.updateKey() 50 | } 51 | }) 52 | 53 | clearIcon := widget.NewButton("Clear Icon", func() { 54 | e.currentButton.key.Icon = "" 55 | e.currentButton.Refresh() 56 | e.currentButton.updateKey() 57 | }) 58 | iconGroup := fyne.NewContainerWithLayout(layout.NewGridLayout(2), icon, clearIcon) 59 | //iconGroup := widget.NewForm(widget.NewFormItem("", icon), widget.NewFormItem("", clearIcon)) 60 | 61 | textAlignment := widget.NewSelect([]string{"TOP", "MIDDLE", "BOTTOM"}, func(alignment string) { 62 | e.currentButton.key.TextAlignment = alignment 63 | e.currentButton.Refresh() 64 | e.currentButton.updateKey() 65 | }) 66 | 67 | textSize := widget.NewEntry() 68 | textSize.OnChanged = func(size string) { 69 | if size == "" { 70 | e.currentButton.key.TextSize = 0 71 | e.currentButton.Refresh() 72 | e.currentButton.updateKey() 73 | return 74 | } 75 | sizeInt, err := strconv.Atoi(size) 76 | if err != nil { 77 | dialog.ShowError(err, e.win) 78 | return 79 | } 80 | e.currentButton.key.TextSize = sizeInt 81 | e.currentButton.Refresh() 82 | e.currentButton.updateKey() 83 | } 84 | 85 | entry.SetText(e.currentButton.key.Text) 86 | if e.currentButton.key.TextSize != 0 { 87 | textSize.SetText(strconv.Itoa(e.currentButton.key.TextSize)) 88 | } else { 89 | textSize.SetText("") 90 | } 91 | textAlignment.SetSelected(strings.ToUpper(e.currentButton.key.TextAlignment)) 92 | 93 | return widget.NewForm( 94 | widget.NewFormItem("Text", entry), 95 | widget.NewFormItem("Text Alignment", textAlignment), 96 | widget.NewFormItem("Font Size", textSize), 97 | widget.NewFormItem("Icon", iconGroup), 98 | ) 99 | } 100 | 101 | func loadDefaultKeyUI(e *editor) fyne.CanvasObject { 102 | 103 | url := widget.NewEntry() 104 | url.Text = e.currentButton.key.Url 105 | page := widget.NewEntry() 106 | page.Text = strconv.FormatInt(int64(e.currentButton.key.SwitchPage), 10) 107 | keyBind := widget.NewEntry() 108 | keyBind.Text = e.currentButton.key.Keybind 109 | command := widget.NewEntry() 110 | command.Text = e.currentButton.key.Command 111 | brightness := widget.NewEntry() 112 | brightness.Text = strconv.FormatInt(int64(e.currentButton.key.Brightness), 10) 113 | 114 | url.OnChanged = func(text string) { 115 | e.currentButton.key.Url = text 116 | e.currentButton.Refresh() 117 | e.currentButton.updateKey() 118 | } 119 | 120 | page.OnChanged = func(text string) { 121 | pageNum := 0 122 | if text != "" { 123 | num, err := strconv.ParseInt(text, 10, 0) 124 | if err != nil { 125 | dialog.ShowError(err, e.win) 126 | return 127 | } 128 | pageNum = int(num) 129 | } 130 | e.currentButton.key.SwitchPage = pageNum 131 | e.currentButton.Refresh() 132 | e.currentButton.updateKey() 133 | } 134 | 135 | keyBind.OnChanged = func(text string) { 136 | e.currentButton.key.Keybind = text 137 | e.currentButton.Refresh() 138 | e.currentButton.updateKey() 139 | } 140 | 141 | command.OnChanged = func(text string) { 142 | e.currentButton.key.Command = text 143 | e.currentButton.Refresh() 144 | e.currentButton.updateKey() 145 | } 146 | 147 | brightness.OnChanged = func(text string) { 148 | brightness := 0 149 | if text != "" { 150 | num, err := strconv.ParseInt(text, 10, 0) 151 | if err != nil { 152 | dialog.ShowError(err, e.win) 153 | return 154 | } 155 | if int(num) > 100 || int(num) < 0 { 156 | dialog.ShowError(errors.New("Brightness out of range"), e.win) 157 | return 158 | } 159 | brightness = int(num) 160 | } 161 | e.currentButton.key.Brightness = brightness 162 | e.currentButton.Refresh() 163 | e.currentButton.updateKey() 164 | } 165 | return widget.NewForm( 166 | widget.NewFormItem("URL", url), 167 | widget.NewFormItem("Switch Page", page), 168 | widget.NewFormItem("Keybind", keyBind), 169 | widget.NewFormItem("Command", command), 170 | widget.NewFormItem("Brightness", brightness), 171 | ) 172 | } 173 | 174 | func loadUI(fields []api.Field, itemMap map[string]string, e *editor) fyne.CanvasObject { 175 | var items []*widget.FormItem 176 | for _, field := range fields { 177 | item := generateField(field, itemMap, e) 178 | if item != nil { 179 | items = append(items, item) 180 | } 181 | } 182 | return widget.NewForm(items...) 183 | } 184 | 185 | func generateField(field api.Field, itemMap map[string]string, e *editor) *widget.FormItem { 186 | if field.Type == "Text" { 187 | item := widget.NewEntry() 188 | item.Text = itemMap[field.Name] 189 | item.OnChanged = func(text string) { 190 | itemMap[field.Name] = text 191 | log.Println(e.currentButton.key.IconHandlerFields["check_command"]) 192 | e.currentButton.Refresh() 193 | e.currentButton.updateKey() 194 | } 195 | return widget.NewFormItem(field.Title, item) 196 | } else if field.Type == "File" { 197 | file := widget.NewButton("Select File", func() { 198 | var fileTypes []string 199 | for _, fileType := range field.FileTypes { 200 | fileTypes = append(fileTypes, "*"+fileType) 201 | } 202 | file, err := zenity.SelectFile(zenity.FileFilters{zenity.FileFilter{Name: "Files", Patterns: fileTypes}}) 203 | if err != nil && err.Error() != "dialog canceled" { 204 | dialog.ShowError(err, e.win) 205 | return 206 | } 207 | if file != "" { 208 | itemMap[field.Name] = file 209 | e.currentButton.Refresh() 210 | e.currentButton.updateKey() 211 | } 212 | }) 213 | clearFile := widget.NewButton("Clear File", func() { 214 | itemMap[field.Name] = "" 215 | e.currentButton.Refresh() 216 | e.currentButton.updateKey() 217 | }) 218 | item := fyne.NewContainerWithLayout(layout.NewGridLayout(2), file, clearFile) 219 | return widget.NewFormItem(field.Title, item) 220 | } else if field.Type == "TextAlignment" { 221 | item := widget.NewSelect([]string{"TOP", "MIDDLE", "BOTTOM"}, func(alignment string) { 222 | itemMap[field.Name] = alignment 223 | e.currentButton.Refresh() 224 | e.currentButton.updateKey() 225 | }) 226 | alignment, ok := itemMap[field.Name] 227 | if ok { 228 | item.SetSelected(strings.ToUpper(alignment)) 229 | } 230 | return widget.NewFormItem(field.Title, item) 231 | } else if field.Type == "Number" { 232 | item := widget.NewEntry() 233 | item.Text = itemMap[field.Name] 234 | item.OnChanged = func(text string) { 235 | value := 0 236 | if text != "" { 237 | num, err := strconv.ParseInt(text, 10, 0) 238 | if err != nil { 239 | dialog.ShowError(err, e.win) 240 | return 241 | } 242 | value = int(num) 243 | } 244 | itemMap[field.Name] = strconv.Itoa(value) 245 | e.currentButton.Refresh() 246 | e.currentButton.updateKey() 247 | } 248 | return widget.NewFormItem(field.Title, item) 249 | } else if field.Type == "Select" { 250 | item := widget.NewSelect(field.Values, func(value string) { 251 | itemMap[field.Name] = value 252 | e.currentButton.Refresh() 253 | e.currentButton.updateKey() 254 | }) 255 | action, ok := itemMap[field.Name] 256 | if ok { 257 | item.SetSelected(action) 258 | } 259 | return widget.NewFormItem(field.Title, item) 260 | } else if field.Type == "Password" { 261 | item := widget.NewPasswordEntry() 262 | item.Text = itemMap[field.Name] 263 | item.OnChanged = func(text string) { 264 | itemMap[field.Name] = text 265 | log.Println(e.currentButton.key.IconHandlerFields["check_command"]) 266 | e.currentButton.Refresh() 267 | e.currentButton.updateKey() 268 | } 269 | return widget.NewFormItem(field.Title, item) 270 | } 271 | return nil 272 | } 273 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | fyne.io/fyne/v2 v2.6.3 h1:cvtM2KHeRuH+WhtHiA63z5wJVBkQ9+Ay0UMl9PxFHyA= 2 | fyne.io/fyne/v2 v2.6.3/go.mod h1:NGSurpRElVoI1G3h+ab2df3O5KLGh1CGbsMMcX0bPIs= 3 | fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= 4 | fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= 5 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 6 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 7 | github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= 8 | github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/dchest/jsmin v1.0.0 h1:Y2hWXmGZiRxtl+VcTksyucgTlYxnhPzTozCwx9gy9zI= 12 | github.com/dchest/jsmin v1.0.0/go.mod h1:AVBIund7Mr7lKXT70hKT2YgL3XEXUaUk5iw9DZ8b0Uc= 13 | github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= 14 | github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= 15 | github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= 16 | github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= 17 | github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko= 18 | github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o= 19 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 20 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 21 | github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs= 22 | github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= 23 | github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= 24 | github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= 25 | github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= 26 | github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= 27 | github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw= 28 | github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= 29 | github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= 30 | github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= 31 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 h1:RkGhqHxEVAvPM0/R+8g7XRwQnHatO0KAuVcwHo8q9W8= 32 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728/go.mod h1:SyRD8YfuKk+ZXlDqYiqe1qMSqjNgtHzBTG810KUagMc= 33 | github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= 34 | github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= 35 | github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4= 36 | github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY= 37 | github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= 38 | github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= 39 | github.com/godbus/dbus/v5 v5.0.4-0.20200513180336-df5ef3eb7cca/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 40 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 41 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 42 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= 43 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 44 | github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= 45 | github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= 46 | github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= 47 | github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= 48 | github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8= 49 | github.com/hack-pad/safejs v0.1.1/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= 50 | github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= 51 | github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= 52 | github.com/josephspurrier/goversioninfo v1.5.0 h1:9TJtORoyf4YMoWSOo/cXFN9A/lB3PniJ91OxIH6e7Zg= 53 | github.com/josephspurrier/goversioninfo v1.5.0/go.mod h1:6MoTvFZ6GKJkzcdLnU5T/RGYUbHQbKpYeNP0AgQLd2o= 54 | github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= 55 | github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= 56 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 57 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 58 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 59 | github.com/ncruces/zenity v0.10.14 h1:OBFl7qfXcvsdo1NUEGxTlZvAakgWMqz9nG38TuiaGLI= 60 | github.com/ncruces/zenity v0.10.14/go.mod h1:ZBW7uVe/Di3IcRYH0Br8X59pi+O6EPnNIOU66YHpOO4= 61 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 62 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 63 | github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ= 64 | github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE= 65 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 66 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 67 | github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= 68 | github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= 69 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 70 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 71 | github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 h1:GranzK4hv1/pqTIhMTXt2X8MmMOuH3hMeUR0o9SP5yc= 72 | github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844/go.mod h1:T1TLSfyWVBRXVGzWd0o9BI4kfoO9InEgfQe4NV3mLz8= 73 | github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= 74 | github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= 75 | github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= 76 | github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= 77 | github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= 78 | github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= 79 | github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= 80 | github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 81 | github.com/unix-streamdeck/api v1.0.1 h1:B5P5p1d4uqvoy8N2OhiWdy/iuhHRCYI+pP3+lwYzDsU= 82 | github.com/unix-streamdeck/api v1.0.1/go.mod h1:Z8bzDHQnWv/2hx9wQXp0/qw6Fp4ty5pFRsgaBG5WYAI= 83 | github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 84 | github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 85 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 86 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 87 | golang.org/x/image v0.0.0-20200801110659-972c09e46d76/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 88 | golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= 89 | golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= 90 | golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= 91 | golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 92 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 93 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 94 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 95 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 96 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 97 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 98 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 99 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 100 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 101 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 102 | -------------------------------------------------------------------------------- /ui.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "fyne.io/fyne/v2" 8 | "fyne.io/fyne/v2/container" 9 | "fyne.io/fyne/v2/dialog" 10 | "fyne.io/fyne/v2/layout" 11 | "fyne.io/fyne/v2/theme" 12 | "fyne.io/fyne/v2/widget" 13 | "github.com/unix-streamdeck/api" 14 | ) 15 | 16 | type editor struct { 17 | currentButton *button 18 | copiedButton *button 19 | config *api.Config 20 | info []*api.StreamDeckInfo 21 | currentDeviceConfig *api.Deck 22 | currentDevice *api.StreamDeckInfo 23 | deviceButtons map[string][]fyne.CanvasObject 24 | layouts map[string]*fyne.Container 25 | deviceSelector *widget.Select 26 | 27 | iconHandler, keyHandler *widget.Select 28 | pageLabel *toolbarLabel 29 | buttons []fyne.CanvasObject 30 | keyDetailSelector, iconDetailSelector *fyne.Container 31 | 32 | win fyne.Window 33 | } 34 | 35 | func newEditor(info []*api.StreamDeckInfo, w fyne.Window) *editor { 36 | c, err := conn.GetConfig() 37 | if err != nil { 38 | dialog.ShowError(err, w) 39 | c = &api.Config{} 40 | } 41 | currentDevice := info[0] 42 | var config *api.Deck 43 | for i := range c.Decks { 44 | if c.Decks[i].Serial == currentDevice.Serial { 45 | config = &c.Decks[i] 46 | } 47 | } 48 | ed := &editor{config: c, info: info, win: w, currentDevice: currentDevice, currentDeviceConfig: config, 49 | deviceButtons: make(map[string][]fyne.CanvasObject), layouts: make(map[string]*fyne.Container)} 50 | go ed.registerPageListener() // TODO remove "go" once daemon fixed 51 | return ed 52 | } 53 | 54 | func (e *editor) loadEditor() fyne.CanvasObject { 55 | 56 | var keyIds []string 57 | var iconIds []string 58 | 59 | initHandlers(conn) 60 | for _, module := range handlers { 61 | if module.IsKey { 62 | keyIds = append(keyIds, module.Name) 63 | } 64 | if module.IsIcon { 65 | iconIds = append(iconIds, module.Name) 66 | } 67 | } 68 | e.keyDetailSelector = fyne.NewContainerWithLayout(layout.NewMaxLayout()) 69 | e.iconDetailSelector = fyne.NewContainerWithLayout(layout.NewMaxLayout()) 70 | e.iconHandler = widget.NewSelect(iconIds, e.chooseIconHandler) 71 | e.keyHandler = widget.NewSelect(keyIds, e.chooseKeyHandler) 72 | e.refreshEditor() 73 | 74 | iconHandler := widget.NewForm( 75 | widget.NewFormItem("Icon Handler", e.iconHandler), 76 | ) 77 | keyHandler := widget.NewForm( 78 | widget.NewFormItem("Key Handler", e.keyHandler), 79 | ) 80 | iconForm := fyne.NewContainerWithLayout(layout.NewFormLayout(), fyne.NewContainerWithLayout(layout.NewCenterLayout(), iconHandler), e.iconDetailSelector) 81 | keyForm := fyne.NewContainerWithLayout(layout.NewFormLayout(), fyne.NewContainerWithLayout(layout.NewCenterLayout(), keyHandler), e.keyDetailSelector) 82 | tabs := container.NewAppTabs( 83 | container.NewTabItem("Icon Config", iconForm), 84 | container.NewTabItem("Keypress Config", keyForm), 85 | ) 86 | tabs.SetTabLocation(container.TabLocationTop) 87 | return tabs 88 | } 89 | 90 | func (e *editor) chooseKeyHandler(name string) { 91 | e.chooseHandler(name, "Key") 92 | } 93 | 94 | func (e *editor) chooseIconHandler(name string) { 95 | e.chooseHandler(name, "Icon") 96 | } 97 | 98 | func (e *editor) chooseHandler(name string, handlerType string) { 99 | var module *api.Module 100 | for _, mod := range handlers { 101 | if mod.Name == name { 102 | module = mod 103 | break 104 | } 105 | } 106 | if module == nil { 107 | fyne.LogError("Handler not found "+name, nil) 108 | return 109 | } 110 | if (handlerType == "Key" && !module.IsKey) || (handlerType == "Icon" && !module.IsIcon) { 111 | fyne.LogError("Handler not found "+name, nil) 112 | return 113 | } 114 | var ui fyne.CanvasObject 115 | 116 | var fields []api.Field 117 | var itemMap map[string]string 118 | if handlerType == "Key" { 119 | fields = module.KeyFields 120 | if e.currentButton.key.KeyHandlerFields == nil { 121 | e.currentButton.key.KeyHandlerFields = make(map[string]string) 122 | } 123 | itemMap = e.currentButton.key.KeyHandlerFields 124 | 125 | } else { 126 | fields = module.IconFields 127 | if e.currentButton.key.IconHandlerFields == nil { 128 | e.currentButton.key.IconHandlerFields = make(map[string]string) 129 | } 130 | itemMap = e.currentButton.key.IconHandlerFields 131 | } 132 | 133 | if fields != nil { 134 | ui = loadUI(fields, itemMap, e) 135 | } else { 136 | ui = widget.NewForm() 137 | } 138 | 139 | if name == "Default" { 140 | if handlerType == "Key" { 141 | ui = loadDefaultKeyUI(e) 142 | e.currentButton.key.KeyHandler = "Default" 143 | } else { 144 | ui = loadDefaultIconUI(e) 145 | e.currentButton.key.IconHandler = "Default" 146 | } 147 | } else { 148 | if handlerType == "Key" { 149 | e.currentButton.key.KeyHandler = name 150 | } else { 151 | e.currentButton.key.IconHandler = name 152 | } 153 | } 154 | e.currentButton.updateKey() 155 | e.currentButton.Refresh() 156 | 157 | if ui != nil { 158 | if handlerType == "Key" { 159 | e.keyDetailSelector.Objects = []fyne.CanvasObject{ui} 160 | } else { 161 | e.iconDetailSelector.Objects = []fyne.CanvasObject{ui} 162 | } 163 | } 164 | if handlerType == "Key" { 165 | e.keyDetailSelector.Refresh() 166 | } else { 167 | e.iconDetailSelector.Refresh() 168 | } 169 | } 170 | 171 | func (e *editor) editButton(b *button) { 172 | old := e.currentButton 173 | e.currentButton = b 174 | 175 | old.Refresh() 176 | b.Refresh() 177 | 178 | e.refreshEditor() 179 | } 180 | 181 | func (e *editor) emptyPage() api.Page { 182 | var keys api.Page 183 | for i := 0; i < e.currentDevice.Cols*e.currentDevice.Rows; i++ { 184 | keys = append(keys, api.Key{}) 185 | } 186 | 187 | return keys 188 | } 189 | 190 | func (e *editor) pageListener(serial string, page int32) { 191 | if e.currentDevice.Serial != serial { 192 | for i := range e.info { 193 | if e.info[i].Serial == serial { 194 | e.info[i].Page = int(page) 195 | } 196 | } 197 | return 198 | } 199 | 200 | if int(page) == e.currentDevice.Page { 201 | return 202 | } 203 | 204 | e.currentDevice.Page = int(page) 205 | e.setPage(int(page), false) 206 | } 207 | 208 | func (e *editor) refreshEditor() { 209 | if e.currentButton != nil { 210 | handler := e.currentButton.key.KeyHandler 211 | if handler == "" { 212 | handler = "Default" 213 | } 214 | e.keyHandler.SetSelected(handler) 215 | handler = e.currentButton.key.IconHandler 216 | if handler == "" { 217 | handler = "Default" 218 | } 219 | e.iconHandler.SetSelected(handler) 220 | } 221 | } 222 | 223 | func (e *editor) refresh() { 224 | for _, b := range e.buttons { 225 | if e.currentButton == nil { 226 | e.currentButton = b.(*button) 227 | } 228 | if b.(*button).keyID >= len(e.currentDeviceConfig.Pages[e.currentDevice.Page]) { 229 | e.currentDeviceConfig.Pages[e.currentDevice.Page] = append(e.currentDeviceConfig.Pages[e.currentDevice.Page], api.Key{}) 230 | } 231 | b.(*button).key = e.currentDeviceConfig.Pages[e.currentDevice.Page][b.(*button).keyID] 232 | b.Refresh() 233 | } 234 | 235 | e.refreshEditor() 236 | } 237 | 238 | func (e *editor) registerPageListener() { 239 | err := conn.RegisterPageListener(e.pageListener) 240 | if err != nil { 241 | dialog.ShowError(err, e.win) 242 | } 243 | } 244 | 245 | func (e *editor) reset() { 246 | for _, b := range e.buttons { 247 | newKey := api.Key{} 248 | b.(*button).key = newKey 249 | e.currentDeviceConfig.Pages[e.currentDevice.Page][b.(*button).keyID] = newKey 250 | b.Refresh() 251 | } 252 | e.refreshEditor() 253 | 254 | err := conn.SetConfig(e.config) 255 | if err != nil { 256 | dialog.ShowError(err, e.win) 257 | } 258 | } 259 | 260 | func (e *editor) setPage(page int, pushToDbus bool) { 261 | if pushToDbus { 262 | err := conn.SetPage(e.currentDevice.Serial, page) 263 | if err != nil { 264 | dialog.ShowError(err, e.win) 265 | return 266 | } 267 | } 268 | 269 | text := fmt.Sprintf("%d/%d", page+1, len(e.currentDeviceConfig.Pages)) 270 | e.pageLabel.label.SetText(text) 271 | e.currentDevice.Page = page 272 | e.currentButton = nil 273 | e.refresh() 274 | } 275 | 276 | // Save config. Used by both the toolbar action and the keyboard shortcut 277 | func (e *editor) saveConfig() { 278 | err := conn.SetConfig(e.config) 279 | if err != nil { 280 | dialog.ShowError(err, e.win) 281 | return 282 | } 283 | err = conn.CommitConfig() 284 | if err != nil { 285 | dialog.ShowError(err, e.win) 286 | } 287 | } 288 | 289 | // Copy current button. Used by both the toolbar action and the keyboard shortcut 290 | func (e *editor) copyButton() { 291 | e.copiedButton = e.currentButton 292 | } 293 | 294 | // Paste copied button, if any. Used by both the toolbar action and the keyboard shortcut 295 | func (e *editor) pasteButton() { 296 | if e.copiedButton != nil { 297 | e.currentButton.key = e.copiedButton.key 298 | e.refreshEditor() 299 | } 300 | } 301 | 302 | func (e *editor) loadToolbar() *widget.Toolbar { 303 | e.pageLabel = newToolbarLabel("0") 304 | return widget.NewToolbar( 305 | newToolBarActionWithLabel("Preview", theme.UploadIcon(), func() { 306 | err := conn.SetConfig(e.config) 307 | if err != nil { 308 | dialog.ShowError(err, e.win) 309 | } 310 | }), 311 | newToolBarActionWithLabel("Save", theme.DocumentSaveIcon(), e.saveConfig), 312 | newToolBarActionWithLabel("Reload", theme.ContentUndoIcon(), func() { 313 | err := conn.ReloadConfig() 314 | if err != nil { 315 | dialog.ShowError(err, e.win) 316 | } 317 | c, err := conn.GetConfig() 318 | if err != nil { 319 | dialog.ShowError(err, e.win) 320 | } 321 | e.config = c 322 | e.refresh() 323 | }), 324 | newToolBarActionWithLabel("Reset", theme.DeleteIcon(), func() { 325 | dialog.ShowConfirm("Reset config?", "Are you sure you want to reset?", 326 | func(ok bool) { 327 | if ok { 328 | e.reset() 329 | } 330 | }, e.win) 331 | }), 332 | newToolBarActionWithLabel("Run Button", theme.MediaPlayIcon(), func() { 333 | err := conn.PressButton(e.currentDevice.Serial, e.currentButton.keyID) 334 | if err != nil { 335 | fyne.LogError("Failed to run button press", err) 336 | } 337 | }), 338 | newToolBarActionWithLabel("Copy Button", theme.ContentCopyIcon(), e.copyButton), 339 | newToolBarActionWithLabel("Paste Button", theme.ContentPasteIcon(), e.pasteButton), 340 | widget.NewToolbarSpacer(), 341 | widget.NewToolbarAction(theme.MediaSkipPreviousIcon(), func() { 342 | if e.currentDevice.Page == 0 { 343 | return 344 | } 345 | 346 | e.setPage(e.currentDevice.Page-1, true) 347 | }), 348 | e.pageLabel, 349 | widget.NewToolbarAction(theme.MediaSkipNextIcon(), func() { 350 | if e.currentDevice.Page == len(e.currentDeviceConfig.Pages)-1 { 351 | return 352 | } 353 | 354 | e.setPage(e.currentDevice.Page+1, true) 355 | }), 356 | widget.NewToolbarSpacer(), 357 | 358 | widget.NewToolbarAction(theme.ContentAddIcon(), func() { 359 | e.currentDeviceConfig.Pages = append(e.currentDeviceConfig.Pages, e.emptyPage()) 360 | err := conn.SetConfig(e.config) 361 | if err != nil { 362 | dialog.ShowError(err, e.win) 363 | return 364 | } 365 | e.setPage(len(e.currentDeviceConfig.Pages)-1, true) 366 | }), 367 | widget.NewToolbarAction(theme.ContentRemoveIcon(), func() { 368 | if len(e.currentDeviceConfig.Pages) == 1 { 369 | e.reset() 370 | return 371 | } 372 | 373 | for i := len(e.currentDeviceConfig.Pages) - 1; i > e.currentDevice.Page; i-- { 374 | e.currentDeviceConfig.Pages[i-1] = e.currentDeviceConfig.Pages[i] 375 | } 376 | e.currentDeviceConfig.Pages = e.currentDeviceConfig.Pages[:len(e.currentDeviceConfig.Pages)-1] 377 | 378 | e.setPage(e.currentDevice.Page-1, true) 379 | err := conn.SetConfig(e.config) 380 | if err != nil { 381 | dialog.ShowError(err, e.win) 382 | return 383 | } 384 | }), 385 | ) 386 | } 387 | 388 | func (e *editor) loadUI() fyne.CanvasObject { 389 | toolbar := e.loadToolbar() 390 | var page api.Page 391 | if len(e.currentDeviceConfig.Pages) >= 1 { 392 | page = e.currentDeviceConfig.Pages[e.currentDevice.Page] 393 | } 394 | 395 | var layouts []*fyne.Container 396 | for j := range e.info { 397 | var buttons []fyne.CanvasObject 398 | for i := 0; i < e.info[j].Cols*e.info[j].Rows; i++ { 399 | var key api.Key 400 | if i < len(page) { 401 | key = page[i] 402 | } 403 | btn := newButton(key, i, e) 404 | if i == 0 { 405 | e.currentButton = btn 406 | } 407 | buttons = append(buttons, btn) 408 | } 409 | e.deviceButtons[e.info[j].Serial] = buttons 410 | buttonGrid := fyne.NewContainerWithLayout(layout.NewGridLayout(e.info[j].Cols), 411 | buttons...) 412 | e.layouts[e.info[j].Serial] = buttonGrid 413 | layouts = append(layouts, buttonGrid) 414 | } 415 | 416 | editor := e.loadEditor() 417 | e.setPage(e.currentDevice.Page, false) 418 | 419 | var deviceIDs []string 420 | 421 | for i := range e.info { 422 | deviceString := "" 423 | if e.info[i].Cols == 5 { 424 | deviceString = "Elgato Streamdeck Original: " 425 | } else if e.info[i].Cols == 3 { 426 | deviceString = "Elgato Streamdeck Mini: " 427 | } else if e.info[i].Cols == 8 { 428 | deviceString = "Elgato Streamdeck XL: " 429 | } 430 | deviceString += e.info[i].Serial 431 | deviceIDs = append(deviceIDs, deviceString) 432 | } 433 | 434 | e.deviceSelector = widget.NewSelect(deviceIDs, func(selected string) { 435 | serial := strings.Split(selected, ": ")[1] 436 | for i := range e.info { 437 | if e.info[i].Serial == serial { 438 | e.currentDevice = e.info[i] 439 | } 440 | e.layouts[e.info[i].Serial].Hide() 441 | } 442 | for i := range e.config.Decks { 443 | if e.config.Decks[i].Serial == serial { 444 | e.currentDeviceConfig = &e.config.Decks[i] 445 | } 446 | } 447 | e.buttons = e.deviceButtons[serial] 448 | container := e.layouts[serial] 449 | container.Show() 450 | for i := range e.info { 451 | if e.info[i].Serial == serial { 452 | e.setPage(e.info[i].Page, false) 453 | } 454 | } 455 | }) 456 | e.buttons = e.deviceButtons[deviceIDs[0]] 457 | e.deviceSelector.SetSelectedIndex(0) 458 | 459 | form := widget.NewForm(widget.NewFormItem("Device: ", e.deviceSelector)) 460 | 461 | if len(deviceIDs) == 1 { 462 | form.Hide() 463 | } 464 | 465 | topGrid := fyne.NewContainerWithLayout(layout.NewBorderLayout(toolbar, form, nil, nil), toolbar, form) 466 | 467 | layoutsCont := fyne.NewContainerWithLayout(layout.NewCenterLayout()) 468 | 469 | for i := range layouts { 470 | layoutsCont.Add(layouts[i]) 471 | } 472 | 473 | return fyne.NewContainerWithLayout(layout.NewBorderLayout(topGrid, editor, nil, nil), 474 | topGrid, editor, layoutsCont) 475 | } 476 | 477 | type ToolbarActionWithLabel struct { 478 | Icon fyne.Resource 479 | label string 480 | OnActivated func() 481 | editor editor 482 | } 483 | 484 | // ToolbarObject gets a button to render this ToolbarAction 485 | func (t *ToolbarActionWithLabel) ToolbarObject() fyne.CanvasObject { 486 | button := widget.NewButtonWithIcon(t.label, t.Icon, t.OnActivated) 487 | button.Importance = widget.LowImportance 488 | 489 | return button 490 | } 491 | 492 | func newToolBarActionWithLabel(text string, icon fyne.Resource, onActivated func()) *ToolbarActionWithLabel { 493 | return &ToolbarActionWithLabel{label: text, Icon: icon, OnActivated: onActivated} 494 | } 495 | 496 | type toolbarLabel struct { 497 | label *widget.Label 498 | } 499 | 500 | func (t *toolbarLabel) ToolbarObject() fyne.CanvasObject { 501 | return t.label 502 | } 503 | 504 | func newToolbarLabel(text string) *toolbarLabel { 505 | return &toolbarLabel{label: widget.NewLabel(text)} 506 | } 507 | --------------------------------------------------------------------------------