package tui import ( "errors" "fmt" "strings" "charm.land/bubbles/v2/list" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "gitgud.io/mike/mpv-manager/pkg/hotkeys" ) // bindingItem implements list.Item for a single input.conf binding type bindingItem struct { key string command string comment string } func (b bindingItem) Title() string { return sanitizeTerminalText(b.key) } func (b bindingItem) Description() string { desc := b.command if b.comment != "" { desc += " #" + b.comment } return sanitizeTerminalText(desc) } func (b bindingItem) FilterValue() string { return sanitizeTerminalText(b.key + " " + b.command) } // setupHotkeysBindingsList loads input.conf and builds the current bindings list func (m *Model) setupHotkeysBindingsList() { m.inputConfPath = hotkeys.GetInputConfPath() cfg, err := hotkeys.ReadInputConf(m.inputConfPath) if err != nil { m.bindingStatusMsg = fmt.Sprintf("✗ Failed to read input.conf: %v", err) cfg = &hotkeys.InputConfig{Bindings: []hotkeys.Binding{}} } else { m.bindingStatusMsg = "" } m.inputConf = cfg m.bindingConfirm = "" m.bindingEditKey = "" m.bindingNewKey = "" m.bindingNewCmd = "" m.rebuildBindingsList() } // rebuildBindingsList refreshes the bindings list items from the loaded input.conf func (m *Model) rebuildBindingsList() { idx := m.hotkeysBindingsList.Index() items := []list.Item{} if m.inputConf != nil { for _, b := range m.inputConf.Bindings { if b.IsComment || b.IsEmpty || b.IsSection || b.Key == "" || b.Command == "" { continue } items = append(items, bindingItem{key: b.Key, command: b.Command, comment: b.Comment}) } } m.hotkeysBindingsList = m.createStyledList("Current Bindings", items) if idx >= len(items) { idx = len(items) - 1 } if idx >= 0 { m.hotkeysBindingsList.Select(idx) } } // saveBinding validates conflicts and writes a binding. If the key is already // bound to a different command it prompts for overwrite confirmation instead. func (m *Model) saveBinding(key, command string) { if m.inputConf == nil { m.bindingStatusMsg = "✗ No input.conf loaded" m.state = StateHotkeysBindings return } if existing := hotkeys.FindBinding(m.inputConf, key); existing != nil { if existing.Command != command { m.bindingEditKey = key m.bindingNewCmd = command m.bindingConfirm = "overwrite" m.state = StateHotkeysBindings return } m.applySetBinding(key, command, true) return } m.applySetBinding(key, command, false) } // applySetBinding applies a binding to a fresh on-disk snapshot so changes // made by another UI between screen load and confirmation are not lost. func (m *Model) applySetBinding(key, command string, overwrite bool) { existing, err := hotkeys.SetBindingFile(m.inputConfPath, key, command, "", overwrite) if errors.Is(err, hotkeys.ErrBindingConflict) { m.bindingEditKey = key m.bindingNewCmd = command m.bindingConfirm = "overwrite" if existing != nil { m.bindingStatusMsg = fmt.Sprintf("Binding %s currently runs %q; overwrite? (y/n)", key, existing.Command) } m.state = StateHotkeysBindings return } if err != nil { m.bindingStatusMsg = fmt.Sprintf("✗ %v", err) m.state = StateHotkeysBindings return } refreshed, err := hotkeys.ReadInputConf(m.inputConfPath) if err != nil { m.bindingStatusMsg = fmt.Sprintf("✗ Saved binding but failed to reload input.conf: %v", err) m.state = StateHotkeysBindings return } m.inputConf = refreshed m.bindingStatusMsg = fmt.Sprintf("✓ Saved binding: %s → %s", key, command) m.state = StateHotkeysBindings m.rebuildBindingsList() } // confirmBindingAction executes the pending y/n confirmation (overwrite or delete) func (m *Model) confirmBindingAction() (*Model, tea.Cmd) { action := m.bindingConfirm m.bindingConfirm = "" if m.inputConf == nil { m.bindingStatusMsg = "✗ No input.conf loaded" return m, nil } switch action { case "delete": removed, err := hotkeys.RemoveBindingFile(m.inputConfPath, m.bindingEditKey) if err != nil { m.bindingStatusMsg = fmt.Sprintf("✗ Failed to write input.conf: %v", err) return m, nil } if removed { refreshed, err := hotkeys.ReadInputConf(m.inputConfPath) if err != nil { m.bindingStatusMsg = fmt.Sprintf("✗ Deleted binding but failed to reload input.conf: %v", err) return m, nil } m.inputConf = refreshed m.bindingStatusMsg = fmt.Sprintf("✓ Deleted binding: %s", m.bindingEditKey) m.rebuildBindingsList() } case "overwrite": m.applySetBinding(m.bindingEditKey, m.bindingNewCmd, true) } return m, nil } // handleHotkeysBindingsUpdate handles the current bindings list state func (m *Model) handleHotkeysBindingsUpdate(msg tea.Msg) (*Model, tea.Cmd) { var cmd tea.Cmd // A pending y/n confirmation (overwrite or delete) takes over key handling if m.bindingConfirm != "" { if keyMsg, ok := msg.(tea.KeyPressMsg); ok { switch keyCode(keyMsg) { case tea.KeyExtended: switch string(keyTextRunes(keyMsg)) { case "y", "Y": return m.confirmBindingAction() case "n", "N": m.bindingConfirm = "" m.bindingStatusMsg = "Cancelled" return m, nil } case tea.KeyEsc: m.bindingConfirm = "" m.bindingStatusMsg = "Cancelled" return m, nil } } return m, nil } switch msg := msg.(type) { case tea.KeyPressMsg: // While filtering, let the list handle all keys if m.hotkeysBindingsList.SettingFilter() { break } switch keyCode(msg) { case tea.KeyEsc: if m.hotkeysBindingsList.IsFiltered() { m.hotkeysBindingsList.ResetFilter() return m, nil } m.setupHotkeysPresetList() m.state = StateHotkeysPreset m.statusMessage = "Keybinding Presets" return m, nil case tea.KeyExtended: switch string(keyTextRunes(msg)) { case "e": selected := m.hotkeysBindingsList.SelectedItem() if selected != nil { item := selected.(bindingItem) m.bindingEditKey = item.key ti := textinput.New() ti.Placeholder = "Enter mpv command..." ti.SetValue(item.command) ti.CharLimit = 256 ti.SetWidth(50) ti.Focus() m.bindingTextInput = ti m.bindingStatusMsg = "" m.state = StateHotkeysBindingEdit return m, nil } case "d": selected := m.hotkeysBindingsList.SelectedItem() if selected != nil { item := selected.(bindingItem) m.bindingEditKey = item.key m.bindingConfirm = "delete" return m, nil } case "a": ti := textinput.New() ti.Placeholder = "Enter key name (e.g. SPACE, Ctrl+RIGHT)..." ti.CharLimit = 64 ti.SetWidth(50) ti.Focus() m.bindingTextInput = ti m.bindingStatusMsg = "" m.state = StateHotkeysBindingAddKey return m, nil } } } m.hotkeysBindingsList, cmd = m.hotkeysBindingsList.Update(msg) return m, cmd } // handleBindingTextInputUpdate handles the key/command text input states of the bindings editor func (m *Model) handleBindingTextInputUpdate(msg tea.Msg) (*Model, tea.Cmd) { var tiCmd tea.Cmd m.bindingTextInput, tiCmd = m.bindingTextInput.Update(msg) switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEsc: m.bindingStatusMsg = "" m.state = StateHotkeysBindings return m, nil case tea.KeyEnter: value := strings.TrimSpace(m.bindingTextInput.Value()) switch m.state { case StateHotkeysBindingEdit: if err := hotkeys.ValidateBindingCommand(value); err != nil { m.bindingStatusMsg = fmt.Sprintf("✗ %v", err) return m, nil } m.saveBinding(m.bindingEditKey, value) return m, nil case StateHotkeysBindingAddKey: if err := hotkeys.ValidateKeyName(value); err != nil { m.bindingStatusMsg = fmt.Sprintf("✗ %v", err) return m, nil } m.bindingNewKey = value ti := textinput.New() ti.Placeholder = "Enter mpv command..." ti.CharLimit = 256 ti.SetWidth(50) ti.Focus() m.bindingTextInput = ti m.bindingStatusMsg = "" m.state = StateHotkeysBindingAddCmd return m, nil case StateHotkeysBindingAddCmd: if err := hotkeys.ValidateBindingCommand(value); err != nil { m.bindingStatusMsg = fmt.Sprintf("✗ %v", err) return m, nil } m.saveBinding(m.bindingNewKey, value) return m, nil } } } return m, tiCmd } // hotkeysBindingsListView renders the current bindings editor list func (m Model) hotkeysBindingsListView() string { var content strings.Builder content.WriteString(titleStyle.Render("⌨️ Current Bindings")) content.WriteString("\n") content.WriteString(subtitleStyle.Render("══════════════════════")) content.WriteString("\n\n") if m.inputConfPath != "" { content.WriteString(infoStyle.Render(sanitizeTerminalText("File: " + m.inputConfPath))) content.WriteString("\n\n") } if m.bindingConfirm == "delete" { content.WriteString(warningStyle.Render(fmt.Sprintf("Delete binding for %s? Press y to confirm, n to cancel", sanitizeTerminalText(m.bindingEditKey)))) content.WriteString("\n\n") } else if m.bindingConfirm == "overwrite" { existing := "" if m.inputConf != nil { if b := hotkeys.FindBinding(m.inputConf, m.bindingEditKey); b != nil { existing = b.Command } } content.WriteString(warningStyle.Render(fmt.Sprintf("%s is already bound to %q. Overwrite with %q? Press y to confirm, n to cancel", sanitizeTerminalText(m.bindingEditKey), sanitizeTerminalText(existing), sanitizeTerminalText(m.bindingNewCmd)))) content.WriteString("\n\n") } if m.bindingStatusMsg != "" { if strings.HasPrefix(m.bindingStatusMsg, "✓") { content.WriteString(successStyle.Render(sanitizeTerminalText(m.bindingStatusMsg))) } else { content.WriteString(errorStyle.Render(sanitizeTerminalText(m.bindingStatusMsg))) } content.WriteString("\n\n") } if len(m.hotkeysBindingsList.Items()) == 0 { content.WriteString(infoStyle.Render("No bindings found. mpv will use its built-in defaults.")) content.WriteString("\n\n") } content.WriteString(m.hotkeysBindingsList.View()) content.WriteString("\n\n") content.WriteString(infoStyle.Render("Press ")) content.WriteString(subtitleStyle.Render("e")) content.WriteString(infoStyle.Render(" to edit, ")) content.WriteString(subtitleStyle.Render("d")) content.WriteString(infoStyle.Render(" to delete, ")) content.WriteString(subtitleStyle.Render("a")) content.WriteString(infoStyle.Render(" to add, ")) content.WriteString(subtitleStyle.Render("Esc")) content.WriteString(infoStyle.Render(" to go back")) return content.String() } // hotkeyBindingInputView renders the text input for editing/adding a binding func (m Model) hotkeyBindingInputView() string { var content strings.Builder var title string switch m.state { case StateHotkeysBindingEdit: title = fmt.Sprintf("Edit Command for %s", m.bindingEditKey) case StateHotkeysBindingAddKey: title = "Add Binding - Key Name" case StateHotkeysBindingAddCmd: title = fmt.Sprintf("Add Binding - Command for %s", m.bindingNewKey) } content.WriteString(titleStyle.Render(sanitizeTerminalText(title))) content.WriteString("\n") content.WriteString(subtitleStyle.Render("══════════════════════")) content.WriteString("\n\n") content.WriteString(m.bindingTextInput.View()) content.WriteString("\n\n") if m.bindingStatusMsg != "" { content.WriteString(errorStyle.Render(sanitizeTerminalText(m.bindingStatusMsg))) content.WriteString("\n\n") } content.WriteString(infoStyle.Render("Press ")) content.WriteString(subtitleStyle.Render("Enter")) content.WriteString(infoStyle.Render(" to save, ")) content.WriteString(subtitleStyle.Render("Esc")) content.WriteString(infoStyle.Render(" to cancel")) return content.String() }