Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

412 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}