项目文件夹

文件
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 行
12 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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")
}