412 lines
12 KiB
Go
412 lines
12 KiB
Go
package model
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
|
||
tea "charm.land/bubbletea/v2"
|
||
)
|
||
|
||
// keymapEntry is a row in the Ctrl+K overlay. Rows with `divider = true` are
|
||
// unselectable section headers (e.g. "— plugins —").
|
||
type keymapEntry struct {
|
||
key, action string
|
||
divider bool
|
||
}
|
||
|
||
// keymapEntries is the full list of keybindings shown in the keymap overlay.
|
||
var keymapEntries = []keymapEntry{
|
||
{key: "Space", action: "Play / Pause"},
|
||
{key: "s", action: "Stop"},
|
||
{key: "> .", action: "Next track"},
|
||
{key: "< ,", action: "Previous track"},
|
||
{key: "← →", action: "Seek ±5s"},
|
||
{key: "Shift+← →", action: "Seek ±large step"},
|
||
{key: "Nj", action: "Seek to N×10% of track (e.g. 7j = 70%)"},
|
||
{key: "+ -", action: "Volume up/down"},
|
||
{key: "] [", action: "Speed up/down (±0.25x)"},
|
||
{key: "z", action: "Toggle shuffle"},
|
||
{key: "r", action: "Cycle repeat"},
|
||
{key: "m", action: "Toggle mono"},
|
||
{key: "e", action: "Cycle EQ preset"},
|
||
{key: "t", action: "Choose theme"},
|
||
{key: "v", action: "Cycle visualizer"},
|
||
{key: "Ctrl+V", action: "Choose visualizer"},
|
||
{key: "V", action: "Full-screen visualizer"},
|
||
{key: "↑ ↓", action: "Playlist scroll / EQ adjust (wraps around)"},
|
||
{key: "PgUp PgDn / Ctrl+U D", action: "Scroll playlist/browser by page"},
|
||
{key: "Home End / g G", action: "Go to top/end of playlist/browser"},
|
||
{key: "Shift+↑ ↓", action: "Move track up/down"},
|
||
{key: "h l", action: "EQ cursor left/right"},
|
||
{key: "Enter", action: "Play selected track"},
|
||
{key: "a", action: "Toggle queue (play next)"},
|
||
{key: "A", action: "Queue manager"},
|
||
{key: "x", action: "Remove selected track from playlist"},
|
||
{key: "w", action: "Write selected track/selection to playlist"},
|
||
{key: "o", action: "Open file browser"},
|
||
{key: "N", action: "Navidrome browser"},
|
||
{key: "L", action: "Browse local playlists"},
|
||
{key: "R", action: "Open radio provider"},
|
||
{key: "S", action: "Open Spotify provider"},
|
||
{key: "P", action: "Open Plex provider"},
|
||
{key: "Y", action: "Open YouTube provider"},
|
||
{key: "C", action: "Open SoundCloud provider"},
|
||
{key: "M", action: "Open NetEase provider"},
|
||
{key: "J", action: "Open Jellyfin provider"},
|
||
{key: "E", action: "Open Emby provider"},
|
||
{key: "Q", action: "Open Qobuz provider"},
|
||
{key: "Ctrl+J", action: "Jump to time"},
|
||
{key: "p", action: "Playlist manager"},
|
||
{key: "Ctrl+H", action: "Toggle album headers"},
|
||
{key: "i", action: "Track info / metadata"},
|
||
{key: "Ctrl+S", action: "Save/download track to ~/Music"},
|
||
{key: "Ctrl+X", action: "Expand/collapse view"},
|
||
{key: "/", action: "Filter/search list"},
|
||
{key: "f", action: "Toggle bookmark ★ (or favorite station in radio)"},
|
||
{key: "Ctrl+F", action: "Search (active provider or YouTube)"},
|
||
{key: "u", action: "Load URL (stream/playlist)"},
|
||
{key: "d", action: "Audio device picker"},
|
||
{key: "y", action: "Show lyrics"},
|
||
{key: "Tab", action: "Toggle focus"},
|
||
{key: "Esc", action: "Back to provider"},
|
||
{key: "? Ctrl+K", action: "This keymap"},
|
||
{key: "q", action: "Quit"},
|
||
}
|
||
|
||
// coreReservedKeys is the set of keys owned by cliamp's global UI handler.
|
||
// Plugins are refused registration for any key in this set. Kept as a plain
|
||
// slice so it's obvious at a glance which strings belong here; every entry
|
||
// is in Bubbletea's `msg.String()` form (lowercase, ctrl+ prefix, etc.).
|
||
//
|
||
// This must be kept in sync with handleKey() in keys.go. If you add or remove
|
||
// a case there, update this list — the TestReservedKeys test in keymap_test.go
|
||
// guards against drift.
|
||
var coreReservedKeys = []string{
|
||
// Global quit / escape.
|
||
"q", "ctrl+c", "esc", "backspace", "b",
|
||
|
||
// Playback.
|
||
"space", "s", ">", ".", "<", ",",
|
||
"left", "right", "shift+left", "shift+right",
|
||
"+", "=", "-", "]", "[",
|
||
"f",
|
||
|
||
// Percentage seek primes on digits 0-9 and consumes the following `j`.
|
||
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "j",
|
||
|
||
// Navigation and focus.
|
||
"up", "k", "down", "ctrl+n", "ctrl+p",
|
||
"shift+up", "shift+down",
|
||
"pgup", "pgdown", "ctrl+u", "ctrl+d",
|
||
"g", "G", "home", "end",
|
||
"enter", "tab", "h", "l",
|
||
|
||
// Features.
|
||
"r", "z", "m", "e", "a", "A", "ctrl+h",
|
||
"ctrl+s", "S", "/", "ctrl+f", "w",
|
||
"ctrl+j", "J", "E", "p", "t", "i", "y", "o", "u",
|
||
"N", "L", "R", "P", "Y", "C", "M", "Q",
|
||
"v", "V", "ctrl+v", "ctrl+x", "x", "d", "ctrl+k", "?",
|
||
"ctrl+r",
|
||
}
|
||
|
||
// ReservedKeys returns a fresh copy of the core-reserved key set. Handed to
|
||
// the Lua plugin manager at startup so it can reject conflicting plugin binds.
|
||
func ReservedKeys() map[string]bool {
|
||
out := make(map[string]bool, len(coreReservedKeys))
|
||
for _, k := range coreReservedKeys {
|
||
out[k] = true
|
||
}
|
||
return out
|
||
}
|
||
|
||
// buildKeymapEntries returns the core keybindings plus any plugin-registered
|
||
// binds that supplied a description. Plugins appear under a divider row.
|
||
// Only called when the overlay is opened; the result is cached on keymap.entries
|
||
// so navigation (which calls keymapCount many times per frame) is allocation-free.
|
||
func (m Model) buildKeymapEntries() []keymapEntry {
|
||
out := make([]keymapEntry, 0, len(keymapEntries)+4)
|
||
out = append(out, keymapEntries...)
|
||
if m.luaMgr == nil {
|
||
return out
|
||
}
|
||
binds := m.luaMgr.KeyBindings()
|
||
if len(binds) == 0 {
|
||
return out
|
||
}
|
||
out = append(out, keymapEntry{action: "— plugins —", divider: true})
|
||
for _, b := range binds {
|
||
label := b.Description
|
||
if b.Plugin != "" {
|
||
label += " (" + b.Plugin + ")"
|
||
}
|
||
out = append(out, keymapEntry{key: b.Key, action: label})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (m *Model) keymapCount() int {
|
||
if m.keymap.searching || m.keymap.search != "" {
|
||
return len(m.keymap.filtered)
|
||
}
|
||
return len(m.keymap.entries)
|
||
}
|
||
|
||
func (m *Model) keymapHelpLine() string {
|
||
if m.keymap.searching {
|
||
return helpKey("Enter", "Confirm ") + helpKey("Esc", "Cancel ") + helpKey("Type", "Filter")
|
||
}
|
||
return helpKey("↓↑", "Scroll ") + helpKey("PgUp/Dn", "Page ") +
|
||
helpKey("Home/End", "Jump ") + helpKey("/", "Filter ") + helpKey("Esc", "Close")
|
||
}
|
||
|
||
// keymapHeaderLine renders the keymap's single-line header for the playlist
|
||
// region: the filter prompt while searching/filtered, otherwise a labeled
|
||
// separator with the match count.
|
||
func (m Model) keymapHeaderLine() string {
|
||
if m.keymap.searching || m.keymap.search != "" {
|
||
return filterCountHeader(m.keymap.search, fmt.Sprintf("%d/%d", m.keymapCount(), len(m.keymap.entries)))
|
||
}
|
||
return sepHeaderN("Keymap", m.keymap.cursor+1, len(m.keymap.entries))
|
||
}
|
||
|
||
func (m *Model) keymapVisible() int {
|
||
return m.effectivePlaylistVisible()
|
||
}
|
||
|
||
// keymapMaybeAdjustScroll keeps the cursor visible in the current keymap window.
|
||
func (m *Model) keymapMaybeAdjustScroll(visible int) {
|
||
clampScroll(&m.keymap.cursor, &m.keymap.scroll, m.keymapCount(), visible)
|
||
}
|
||
|
||
// openKeymap resets the keymap state and shows it. Snapshots plugin bindings
|
||
// once so the render/navigation code doesn't re-query the plugin manager.
|
||
func (m *Model) openKeymap() {
|
||
m.keymap.searching = false
|
||
m.keymap.search = ""
|
||
m.keymap.filtered = nil
|
||
m.keymap.cursor = 0
|
||
m.keymap.scroll = 0
|
||
m.keymap.entries = m.buildKeymapEntries()
|
||
m.keymap.visible = true
|
||
// The keymap now renders in the playlist region; recompute chrome so its
|
||
// header/help are reflected in the visible-row budget, then fit the cursor.
|
||
m.refreshChrome()
|
||
m.applyHeightMode()
|
||
m.keymapMaybeAdjustScroll(m.keymapVisible())
|
||
}
|
||
|
||
// closeKeymap hides the keymap, clears its filter state, and restores playlist
|
||
// sizing after the inline header and help line are dismissed.
|
||
func (m *Model) closeKeymap() {
|
||
m.keymap.visible = false
|
||
m.keymap.searching = false
|
||
m.keymap.search = ""
|
||
m.keymap.filtered = nil
|
||
m.refreshChrome()
|
||
m.applyHeightMode()
|
||
m.adjustScroll()
|
||
}
|
||
|
||
func (m *Model) handleKeymapSearchKey(msg tea.KeyPressMsg) tea.Cmd {
|
||
switch msg.String() {
|
||
case "ctrl+c":
|
||
m.keymap.visible = false
|
||
return m.quit()
|
||
case "esc":
|
||
m.keymap.searching = false
|
||
m.keymap.search = ""
|
||
m.keymap.filtered = nil
|
||
m.keymap.cursor = m.keymap.savedCursor
|
||
m.keymap.scroll = m.keymap.savedScroll
|
||
return nil
|
||
case "enter":
|
||
m.keymap.searching = false
|
||
if m.keymap.search == "" {
|
||
m.keymap.cursor = m.keymap.savedCursor
|
||
m.keymap.scroll = m.keymap.savedScroll
|
||
}
|
||
return nil
|
||
case "down":
|
||
m.keymap.searching = false
|
||
if m.keymapCount() > 0 {
|
||
m.keymap.cursor = 0
|
||
m.keymapMaybeAdjustScroll(m.keymapVisible())
|
||
}
|
||
return nil
|
||
case "backspace":
|
||
if m.keymap.search != "" {
|
||
m.keymap.search = removeLastRune(m.keymap.search)
|
||
m.updateKeymapFilter()
|
||
} else {
|
||
m.keymap.searching = false
|
||
m.keymap.cursor = m.keymap.savedCursor
|
||
m.keymap.scroll = m.keymap.savedScroll
|
||
}
|
||
return nil
|
||
case "space":
|
||
m.keymap.search += " "
|
||
m.updateKeymapFilter()
|
||
return nil
|
||
}
|
||
|
||
if len(msg.Text) > 0 {
|
||
m.keymap.search += msg.Text
|
||
m.updateKeymapFilter()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// handleKeymapKey processes key presses while the keymap overlay is open.
|
||
func (m *Model) handleKeymapKey(msg tea.KeyPressMsg) tea.Cmd {
|
||
if m.keymap.searching {
|
||
return m.handleKeymapSearchKey(msg)
|
||
}
|
||
|
||
switch msg.String() {
|
||
case "ctrl+c":
|
||
m.keymap.visible = false
|
||
return m.quit()
|
||
|
||
case "esc", "ctrl+k", "?", "q":
|
||
m.closeKeymap()
|
||
|
||
case "/":
|
||
m.keymap.savedCursor = m.keymap.cursor
|
||
m.keymap.savedScroll = m.keymap.scroll
|
||
m.keymap.searching = true
|
||
m.keymap.search = ""
|
||
m.updateKeymapFilter()
|
||
return nil
|
||
|
||
case "up", "k":
|
||
if m.keymap.search != "" && m.keymap.cursor == 0 {
|
||
m.keymap.searching = true
|
||
return nil
|
||
}
|
||
count := m.keymapCount()
|
||
if m.keymap.cursor > 0 {
|
||
m.keymap.cursor--
|
||
} else if count > 0 {
|
||
m.keymap.cursor = count - 1
|
||
}
|
||
m.keymapMaybeAdjustScroll(m.keymapVisible())
|
||
|
||
case "down", "j":
|
||
count := m.keymapCount()
|
||
if m.keymap.cursor < count-1 {
|
||
m.keymap.cursor++
|
||
} else if count > 0 {
|
||
m.keymap.cursor = 0
|
||
}
|
||
m.keymapMaybeAdjustScroll(m.keymapVisible())
|
||
|
||
case "ctrl+x":
|
||
m.toggleExpandedView()
|
||
m.keymapMaybeAdjustScroll(m.keymapVisible())
|
||
|
||
case "pgup", "ctrl+u":
|
||
if m.keymap.cursor > 0 {
|
||
visible := m.keymapVisible()
|
||
m.keymap.cursor -= min(m.keymap.cursor, visible)
|
||
m.keymapMaybeAdjustScroll(visible)
|
||
}
|
||
|
||
case "pgdown", "ctrl+d":
|
||
count := m.keymapCount()
|
||
if m.keymap.cursor < count-1 {
|
||
visible := m.keymapVisible()
|
||
m.keymap.cursor = min(count-1, m.keymap.cursor+visible)
|
||
m.keymapMaybeAdjustScroll(visible)
|
||
}
|
||
|
||
case "home", "g":
|
||
m.keymap.cursor = 0
|
||
m.keymapMaybeAdjustScroll(m.keymapVisible())
|
||
|
||
case "end", "G":
|
||
count := m.keymapCount()
|
||
if count > 0 {
|
||
m.keymap.cursor = count - 1
|
||
}
|
||
m.keymapMaybeAdjustScroll(m.keymapVisible())
|
||
|
||
case "backspace", "h":
|
||
if m.keymap.search != "" {
|
||
m.keymap.search = ""
|
||
m.updateKeymapFilter()
|
||
} else {
|
||
m.closeKeymap()
|
||
}
|
||
|
||
case "enter", "l":
|
||
m.closeKeymap()
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// updateKeymapFilter rebuilds the filtered indices and clamps the cursor.
|
||
func (m *Model) updateKeymapFilter() {
|
||
m.keymap.filtered = nil
|
||
m.keymap.cursor = 0
|
||
m.keymap.scroll = 0
|
||
if m.keymap.search == "" {
|
||
return
|
||
}
|
||
query := strings.ToLower(m.keymap.search)
|
||
for i, e := range m.keymap.entries {
|
||
if e.divider {
|
||
continue
|
||
}
|
||
if strings.Contains(strings.ToLower(e.key), query) ||
|
||
strings.Contains(strings.ToLower(e.action), query) {
|
||
m.keymap.filtered = append(m.keymap.filtered, i)
|
||
}
|
||
}
|
||
}
|
||
|
||
// renderKeymapList renders the keymap entries for the playlist region while the
|
||
// keymap is open. The header and help line are supplied by the main layout
|
||
// (renderPlaylistHeader / renderHelp), mirroring renderVisPickerList.
|
||
func (m Model) renderKeymapList() string {
|
||
budget := m.effectivePlaylistVisible()
|
||
if budget <= 0 {
|
||
return ""
|
||
}
|
||
|
||
entries := m.keymap.entries
|
||
var visible []keymapEntry
|
||
if m.keymap.search != "" {
|
||
for _, i := range m.keymap.filtered {
|
||
visible = append(visible, entries[i])
|
||
}
|
||
} else {
|
||
visible = entries
|
||
}
|
||
|
||
if len(visible) == 0 {
|
||
msg := "(empty)"
|
||
if m.keymap.search != "" {
|
||
msg = "No matches"
|
||
}
|
||
return strings.Join(fitLines([]string{dimStyle.Render(" " + msg)}, budget), "\n")
|
||
}
|
||
|
||
lines := make([]string, 0, budget)
|
||
for i := m.keymap.scroll; i < len(visible) && len(lines) < budget; i++ {
|
||
entry := visible[i]
|
||
if entry.divider {
|
||
lines = append(lines, dimStyle.Render(" "+entry.action))
|
||
continue
|
||
}
|
||
line := fmt.Sprintf("%-10s %s", entry.key, entry.action)
|
||
if m.keymap.searching {
|
||
lines = append(lines, dimStyle.Render(" "+line))
|
||
} else {
|
||
lines = append(lines, cursorLine(line, i == m.keymap.cursor))
|
||
}
|
||
}
|
||
return strings.Join(padLines(lines, budget, len(lines)), "\n")
|
||
}
|