678 lines
19 KiB
Go
678 lines
19 KiB
Go
// streamdeck-init — Interactive TUI for configuring Stream Deck keys.
|
|
//
|
|
// This is a config builder, not an installer. It reads and appends to
|
|
// ~/.config/streamdeck-go/config.yaml (or a custom path via -config).
|
|
// It does NOT touch services, binaries, udev rules, or dependencies.
|
|
//
|
|
// ## How it works
|
|
//
|
|
// 1. Bootstrap: ensures config.yaml and modules.yaml exist (creates from
|
|
// defaults/embedded example if missing).
|
|
// 2. Loads both files using the same internal/config and internal/modules
|
|
// packages the daemon uses.
|
|
// 3. Renders an 8x4 key grid showing which slots are free vs occupied.
|
|
// 4. Walks the user through a series of huh forms:
|
|
// - Pick a key slot (0-31)
|
|
// - Choose action type: module function, shell command, or toggle key (with poll)
|
|
// - If module: pick module → function → customize params (with defaults)
|
|
// - If command: type a shell command
|
|
// - Pick an icon from icons_dir (or type a custom filename)
|
|
// - Confirm and append to config.yaml
|
|
// 5. Loops: asks "Add another key?" after each addition.
|
|
//
|
|
// ## Expanding this tool
|
|
//
|
|
// To add new TUI steps (e.g. a "delete key" flow), add a new function
|
|
// following the pattern of configureModuleKey/configureCommandKey/
|
|
// configureToggleKey: build huh forms, collect values into a keyEntry,
|
|
// return it for the confirm/append step.
|
|
//
|
|
// To support new key types in the YAML output, update keyEntry and
|
|
// renderKeySnippet() in yaml.go.
|
|
//
|
|
// The grid rendering is in grid.go — it's hardcoded to 8x4 (XL). To
|
|
// support other models, make gridCols/gridRows dynamic based on the
|
|
// device config's product_id.
|
|
//
|
|
// ## Dependencies
|
|
//
|
|
// - github.com/charmbracelet/huh — form/prompt library (select, input, confirm)
|
|
// - github.com/charmbracelet/lipgloss — terminal styling (grid rendering, summary output)
|
|
// - internal/config — shared config loader and DefaultConfigPath()
|
|
// - internal/modules — shared module registry loader
|
|
// - internal/defaults — embedded modules.example.yaml for bootstrapping
|
|
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.i0t.app/lwoodard/streamdeck-go/internal/config"
|
|
"git.i0t.app/lwoodard/streamdeck-go/internal/defaults"
|
|
"git.i0t.app/lwoodard/streamdeck-go/internal/modules"
|
|
"github.com/charmbracelet/huh"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
func main() {
|
|
cfgPath := flag.String("config", config.DefaultConfigPath(), "path to config file")
|
|
flag.Parse()
|
|
|
|
// Bootstrap: ensure config dir, config.yaml, and modules.yaml exist.
|
|
// On a fresh install this creates everything from scratch so the user
|
|
// doesn't need to manually create files before running the TUI.
|
|
if err := bootstrap(*cfgPath); err != nil {
|
|
fmt.Fprintf(os.Stderr, "setup error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Main loop — each iteration configures one key, then asks to continue.
|
|
for {
|
|
if err := runOnce(*cfgPath); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
var again bool
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewConfirm().
|
|
Title("Add another key?").
|
|
Value(&again),
|
|
),
|
|
)
|
|
if err := form.Run(); err != nil {
|
|
break
|
|
}
|
|
if !again {
|
|
break
|
|
}
|
|
}
|
|
|
|
fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true).Render("✓") +
|
|
" Done — the daemon will auto-reload your config.")
|
|
}
|
|
|
|
// bootstrap ensures the config directory, config.yaml, and modules.yaml exist.
|
|
//
|
|
// Creates:
|
|
// - ~/.config/streamdeck-go/ (config dir)
|
|
// - ~/.config/streamdeck-go/icons/ (icon storage)
|
|
// - ~/.config/streamdeck-go/config.yaml (from inline default if missing)
|
|
// - ~/.config/streamdeck-go/modules.yaml (from embedded example if missing)
|
|
//
|
|
// The embedded modules.example.yaml comes from internal/defaults, which uses
|
|
// //go:embed so the init binary works standalone without the repo present.
|
|
func bootstrap(cfgPath string) error {
|
|
dir := filepath.Dir(cfgPath)
|
|
iconsDir := filepath.Join(dir, "icons")
|
|
if err := os.MkdirAll(iconsDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create a minimal config.yaml if one doesn't exist yet.
|
|
// Uses "keys: {}" (empty map) which appendKeyToConfig handles specially —
|
|
// it replaces the {} with actual key entries on first append.
|
|
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
|
defaultCfg := fmt.Sprintf(`# streamdeck-go configuration
|
|
icons_dir: %s
|
|
brightness: 70
|
|
|
|
device:
|
|
vendor_id: 0x0fd9
|
|
product_id: 0x00ba
|
|
|
|
keys: {}
|
|
`, iconsDir)
|
|
if err := os.WriteFile(cfgPath, []byte(defaultCfg), 0644); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("Created %s\n", cfgPath)
|
|
}
|
|
|
|
// Copy the embedded modules.example.yaml so module functions are available
|
|
// immediately. Users can edit this file to add custom modules later.
|
|
modPath := config.ModulesPath(cfgPath)
|
|
if _, err := os.Stat(modPath); os.IsNotExist(err) {
|
|
if err := os.WriteFile(modPath, defaults.ModulesExampleYAML, 0644); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("Created %s\n", modPath)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// runOnce performs one complete key configuration cycle:
|
|
//
|
|
// load config → show grid → pick slot → pick action → pick icon → confirm → write
|
|
//
|
|
// Config is re-loaded at the start of each cycle so that keys added in the
|
|
// previous iteration show up as occupied in the grid.
|
|
func runOnce(cfgPath string) error {
|
|
// Re-read config each iteration to pick up keys added in previous cycles.
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
return fmt.Errorf("load config: %w", err)
|
|
}
|
|
|
|
// Load the module registry to enumerate available modules/functions.
|
|
reg, err := modules.LoadRegistry(config.ModulesPath(cfgPath))
|
|
if err != nil {
|
|
return fmt.Errorf("load modules: %w", err)
|
|
}
|
|
|
|
// Show the 8x4 key grid so the user can see which slots are taken.
|
|
fmt.Print(renderGrid(cfg.Keys))
|
|
|
|
// Step 1: Pick a key slot (0-31).
|
|
keyIdx, err := pickKeySlot(cfg.Keys)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Step 2: Choose between module function or raw shell command.
|
|
actionType, err := pickActionType()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Step 3: Configure the key based on action type.
|
|
var entry keyEntry
|
|
entry.Index = keyIdx
|
|
|
|
switch actionType {
|
|
case "module":
|
|
entry, err = configureModuleKey(keyIdx, reg)
|
|
case "toggle":
|
|
entry, err = configureToggleKey(keyIdx, reg)
|
|
default:
|
|
entry, err = configureCommandKey(keyIdx)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Step 4: Pick icon(s).
|
|
if entry.IsToggle {
|
|
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Icon for ON state:"))
|
|
iconTrue, err := pickIcon(cfg.IconsDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entry.IconTrue = iconTrue
|
|
|
|
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Icon for OFF state:"))
|
|
iconFalse, err := pickIcon(cfg.IconsDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entry.IconFalse = iconFalse
|
|
} else {
|
|
icon, err := pickIcon(cfg.IconsDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entry.Icon = icon
|
|
}
|
|
|
|
// Step 5: Show summary and confirm before writing.
|
|
fmt.Println()
|
|
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Summary:"))
|
|
fmt.Printf(" Key: %d\n", entry.Index)
|
|
if entry.IsToggle {
|
|
fmt.Printf(" Icon ON: %s\n", entry.IconTrue)
|
|
fmt.Printf(" Icon OFF: %s\n", entry.IconFalse)
|
|
} else {
|
|
fmt.Printf(" Icon: %s\n", entry.Icon)
|
|
}
|
|
if entry.Module != "" {
|
|
fmt.Printf(" Module: %s\n", entry.Module)
|
|
fmt.Printf(" Function: %s\n", entry.Function)
|
|
if len(entry.Params) > 0 {
|
|
fmt.Println(" Params:")
|
|
for k, v := range entry.Params {
|
|
fmt.Printf(" %s: %s\n", k, v)
|
|
}
|
|
}
|
|
} else {
|
|
fmt.Printf(" Command: %s\n", entry.Command)
|
|
}
|
|
if entry.IsToggle {
|
|
fmt.Println(" Poll:")
|
|
if entry.PollModule != "" {
|
|
fmt.Printf(" Module: %s\n", entry.PollModule)
|
|
fmt.Printf(" Function: %s\n", entry.PollFunction)
|
|
if len(entry.PollParams) > 0 {
|
|
fmt.Println(" Params:")
|
|
for k, v := range entry.PollParams {
|
|
fmt.Printf(" %s: %s\n", k, v)
|
|
}
|
|
}
|
|
} else {
|
|
fmt.Printf(" Command: %s\n", entry.PollCommand)
|
|
}
|
|
if entry.PollMatch != "" {
|
|
fmt.Printf(" Match: %s\n", entry.PollMatch)
|
|
}
|
|
if entry.PollInterval != "" {
|
|
fmt.Printf(" Interval: %s\n", entry.PollInterval)
|
|
}
|
|
}
|
|
fmt.Println()
|
|
|
|
var confirm bool
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewConfirm().
|
|
Title("Write this key to config?").
|
|
Value(&confirm),
|
|
),
|
|
)
|
|
if err := form.Run(); err != nil {
|
|
return err
|
|
}
|
|
if !confirm {
|
|
fmt.Println(" Skipped.")
|
|
return nil
|
|
}
|
|
|
|
// Step 6: Append the YAML snippet to config.yaml.
|
|
// This uses raw text append (not marshal/unmarshal) to preserve comments.
|
|
if err := appendKeyToConfig(cfgPath, entry); err != nil {
|
|
return fmt.Errorf("write config: %w", err)
|
|
}
|
|
|
|
fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true).Render(" ✓") +
|
|
fmt.Sprintf(" Key %d added to config.", entry.Index))
|
|
return nil
|
|
}
|
|
|
|
// pickKeySlot presents a select list of all 32 key slots, showing which are
|
|
// occupied and what they're mapped to. Returns the selected key index.
|
|
//
|
|
// FUTURE: filter to show only free keys, or add a "overwrite?" confirmation
|
|
// when an occupied slot is picked.
|
|
func pickKeySlot(keys map[int]config.KeyConfig) (int, error) {
|
|
options := make([]huh.Option[int], gridKeys)
|
|
for i := 0; i < gridKeys; i++ {
|
|
label := fmt.Sprintf("%-3d", i)
|
|
if k, ok := keys[i]; ok {
|
|
label += fmt.Sprintf(" (occupied — %s)", keyLabel(k))
|
|
} else {
|
|
label += " (free)"
|
|
}
|
|
options[i] = huh.NewOption(label, i)
|
|
}
|
|
|
|
var keyIdx int
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewSelect[int]().
|
|
Title("Pick a key slot").
|
|
Options(options...).
|
|
Value(&keyIdx),
|
|
),
|
|
)
|
|
if err := form.Run(); err != nil {
|
|
return 0, err
|
|
}
|
|
return keyIdx, nil
|
|
}
|
|
|
|
// pickActionType asks whether to configure a module function, a raw shell command,
|
|
// or a toggle key with poll-based state checking.
|
|
func pickActionType() (string, error) {
|
|
var actionType string
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewSelect[string]().
|
|
Title("What should this key do?").
|
|
Options(
|
|
huh.NewOption("Module function (Slack, OBS, etc.)", "module"),
|
|
huh.NewOption("Shell command", "command"),
|
|
huh.NewOption("Toggle key (with poll)", "toggle"),
|
|
).
|
|
Value(&actionType),
|
|
),
|
|
)
|
|
if err := form.Run(); err != nil {
|
|
return "", err
|
|
}
|
|
return actionType, nil
|
|
}
|
|
|
|
// configureModuleKey walks the user through: module → function → params.
|
|
//
|
|
// Modules and functions are enumerated dynamically from the loaded registry,
|
|
// so any module added to modules.yaml automatically appears in the TUI.
|
|
//
|
|
// For each param defined in the function's FunctionDef.Params, an input field
|
|
// is shown pre-filled with the default value. The user can accept defaults or
|
|
// override per-key. Functions with no params (e.g. clear_status) skip this step.
|
|
//
|
|
// FUTURE: show a description/help text for each function (would require adding
|
|
// a "description" field to FunctionDef in internal/modules/modules.go).
|
|
func configureModuleKey(keyIdx int, reg *modules.Registry) (keyEntry, error) {
|
|
entry := keyEntry{Index: keyIdx}
|
|
|
|
if reg == nil || len(reg.Modules) == 0 {
|
|
return entry, fmt.Errorf("no modules defined in modules.yaml")
|
|
}
|
|
|
|
// Pick module — enumerate all top-level keys from modules.yaml.
|
|
modNames := make([]string, 0, len(reg.Modules))
|
|
for name := range reg.Modules {
|
|
modNames = append(modNames, name)
|
|
}
|
|
sort.Strings(modNames)
|
|
|
|
var modName string
|
|
modOpts := make([]huh.Option[string], len(modNames))
|
|
for i, name := range modNames {
|
|
modOpts[i] = huh.NewOption(name, name)
|
|
}
|
|
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewSelect[string]().
|
|
Title("Pick a module").
|
|
Options(modOpts...).
|
|
Value(&modName),
|
|
),
|
|
)
|
|
if err := form.Run(); err != nil {
|
|
return entry, err
|
|
}
|
|
entry.Module = modName
|
|
|
|
// Pick function — enumerate all functions within the chosen module.
|
|
mod := reg.Modules[modName]
|
|
fnNames := make([]string, 0, len(mod))
|
|
for name := range mod {
|
|
fnNames = append(fnNames, name)
|
|
}
|
|
sort.Strings(fnNames)
|
|
|
|
var fnName string
|
|
fnOpts := make([]huh.Option[string], len(fnNames))
|
|
for i, name := range fnNames {
|
|
fnOpts[i] = huh.NewOption(name, name)
|
|
}
|
|
|
|
form = huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewSelect[string]().
|
|
Title(fmt.Sprintf("Pick a function from %s", modName)).
|
|
Options(fnOpts...).
|
|
Value(&fnName),
|
|
),
|
|
)
|
|
if err := form.Run(); err != nil {
|
|
return entry, err
|
|
}
|
|
entry.Function = fnName
|
|
|
|
// Customize params — one input per param, pre-filled with the module default.
|
|
// paramPtrs is a []string (not []*string) so that ¶mPtrs[i] gives a stable
|
|
// pointer to each slice element — huh.NewInput().Value() needs a *string.
|
|
fn := mod[fnName]
|
|
if len(fn.Params) > 0 {
|
|
paramKeys := make([]string, 0, len(fn.Params))
|
|
for k := range fn.Params {
|
|
paramKeys = append(paramKeys, k)
|
|
}
|
|
sort.Strings(paramKeys)
|
|
|
|
paramPtrs := make([]string, len(paramKeys))
|
|
paramFields := make([]huh.Field, len(paramKeys))
|
|
for i, k := range paramKeys {
|
|
paramPtrs[i] = fn.Params[k] // pre-fill with default
|
|
paramFields[i] = huh.NewInput().
|
|
Title(k).
|
|
Value(¶mPtrs[i]).
|
|
Placeholder(fn.Params[k])
|
|
}
|
|
|
|
form = huh.NewForm(
|
|
huh.NewGroup(paramFields...),
|
|
)
|
|
if err := form.Run(); err != nil {
|
|
return entry, err
|
|
}
|
|
|
|
// Collect the (possibly edited) values back into the entry.
|
|
entry.Params = make(map[string]string, len(paramKeys))
|
|
for i, k := range paramKeys {
|
|
entry.Params[k] = paramPtrs[i]
|
|
}
|
|
}
|
|
|
|
return entry, nil
|
|
}
|
|
|
|
// configureCommandKey asks for a raw shell command string.
|
|
// This is the non-module path — the command is written directly to config.yaml.
|
|
func configureCommandKey(keyIdx int) (keyEntry, error) {
|
|
entry := keyEntry{Index: keyIdx}
|
|
|
|
var cmd string
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewInput().
|
|
Title("Shell command").
|
|
Value(&cmd).
|
|
Placeholder("e.g. open -a Firefox"),
|
|
),
|
|
)
|
|
if err := form.Run(); err != nil {
|
|
return entry, err
|
|
}
|
|
entry.Command = cmd
|
|
return entry, nil
|
|
}
|
|
|
|
// configureToggleKey walks the user through setting up a toggle key:
|
|
// press action (module or command) + poll configuration (module or command,
|
|
// match string, interval). Icons are handled separately in runOnce().
|
|
func configureToggleKey(keyIdx int, reg *modules.Registry) (keyEntry, error) {
|
|
entry := keyEntry{Index: keyIdx, IsToggle: true}
|
|
|
|
// Ask what happens when the key is pressed.
|
|
var pressType string
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewSelect[string]().
|
|
Title("What should pressing this key do?").
|
|
Options(
|
|
huh.NewOption("Module function", "module"),
|
|
huh.NewOption("Shell command", "command"),
|
|
).
|
|
Value(&pressType),
|
|
),
|
|
)
|
|
if err := form.Run(); err != nil {
|
|
return entry, err
|
|
}
|
|
|
|
if pressType == "module" {
|
|
modEntry, err := configureModuleKey(keyIdx, reg)
|
|
if err != nil {
|
|
return entry, err
|
|
}
|
|
entry.Module = modEntry.Module
|
|
entry.Function = modEntry.Function
|
|
entry.Params = modEntry.Params
|
|
} else {
|
|
cmdEntry, err := configureCommandKey(keyIdx)
|
|
if err != nil {
|
|
return entry, err
|
|
}
|
|
entry.Command = cmdEntry.Command
|
|
}
|
|
|
|
// Configure the poll block.
|
|
if err := configurePoll(&entry, reg); err != nil {
|
|
return entry, err
|
|
}
|
|
|
|
return entry, nil
|
|
}
|
|
|
|
// configurePoll walks through poll configuration for a toggle key: how to check
|
|
// state (module function or shell command), optional match string, and interval.
|
|
func configurePoll(entry *keyEntry, reg *modules.Registry) error {
|
|
var pollType string
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewSelect[string]().
|
|
Title("How should the key check its state?").
|
|
Options(
|
|
huh.NewOption("Module function", "module"),
|
|
huh.NewOption("Shell command", "command"),
|
|
).
|
|
Value(&pollType),
|
|
),
|
|
)
|
|
if err := form.Run(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if pollType == "module" {
|
|
modEntry, err := configureModuleKey(entry.Index, reg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entry.PollModule = modEntry.Module
|
|
entry.PollFunction = modEntry.Function
|
|
entry.PollParams = modEntry.Params
|
|
} else {
|
|
var cmd string
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewInput().
|
|
Title("Poll command").
|
|
Description("Shell command to check key state").
|
|
Value(&cmd).
|
|
Placeholder("e.g. pgrep -x myapp"),
|
|
),
|
|
)
|
|
if err := form.Run(); err != nil {
|
|
return err
|
|
}
|
|
entry.PollCommand = cmd
|
|
}
|
|
|
|
// Match string and interval.
|
|
var match, interval string
|
|
interval = "2s"
|
|
form = huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewInput().
|
|
Title("Match string").
|
|
Description("Substring in output that means ON (leave empty to use exit code)").
|
|
Value(&match).
|
|
Placeholder("optional"),
|
|
huh.NewInput().
|
|
Title("Poll interval").
|
|
Description("How often to check state").
|
|
Value(&interval).
|
|
Placeholder("2s"),
|
|
),
|
|
)
|
|
if err := form.Run(); err != nil {
|
|
return err
|
|
}
|
|
entry.PollMatch = match
|
|
entry.PollInterval = interval
|
|
|
|
return nil
|
|
}
|
|
|
|
// pickIcon lets the user select an icon file.
|
|
//
|
|
// If icons_dir has image files, shows a select list of them plus a "type custom"
|
|
// option. If the directory is empty, falls back to a plain text input.
|
|
//
|
|
// Supported extensions: .png, .jpg, .jpeg, .gif, .svg
|
|
//
|
|
// FUTURE: show icon previews (would require a terminal image protocol like
|
|
// sixel or kitty graphics). For now, filenames are enough.
|
|
func pickIcon(iconsDir string) (string, error) {
|
|
// Scan icons_dir for image files.
|
|
entries, err := os.ReadDir(iconsDir)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return "", err
|
|
}
|
|
|
|
var iconFiles []string
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
|
switch ext {
|
|
case ".png", ".jpg", ".jpeg", ".gif", ".svg":
|
|
iconFiles = append(iconFiles, e.Name())
|
|
}
|
|
}
|
|
|
|
// No icons in directory — just ask for a filename.
|
|
if len(iconFiles) == 0 {
|
|
var icon string
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewInput().
|
|
Title("Icon filename").
|
|
Value(&icon).
|
|
Placeholder("e.g. myicon.png"),
|
|
),
|
|
)
|
|
if err := form.Run(); err != nil {
|
|
return "", err
|
|
}
|
|
return icon, nil
|
|
}
|
|
|
|
// Show available icons as a select list with a "custom" escape hatch.
|
|
sort.Strings(iconFiles)
|
|
options := make([]huh.Option[string], 0, len(iconFiles)+1)
|
|
options = append(options, huh.NewOption("[ Type a custom filename ]", "__custom__"))
|
|
for _, f := range iconFiles {
|
|
options = append(options, huh.NewOption(f, f))
|
|
}
|
|
|
|
var icon string
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewSelect[string]().
|
|
Title(fmt.Sprintf("Pick an icon from %s", iconsDir)).
|
|
Options(options...).
|
|
Value(&icon),
|
|
),
|
|
)
|
|
if err := form.Run(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if icon == "__custom__" {
|
|
form = huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewInput().
|
|
Title("Icon filename").
|
|
Value(&icon).
|
|
Placeholder("e.g. myicon.png"),
|
|
),
|
|
)
|
|
if err := form.Run(); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
return icon, nil
|
|
}
|