Files
2026-07-13 13:00:08 +08:00

513 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 cli
import (
"errors"
"fmt"
"os"
"strings"
"golang.org/x/term"
"reasonix/internal/i18n"
)
// errCancelled is returned by selectOne when the user aborts (q or Ctrl-C).
var errCancelled = errors.New("selection cancelled")
type menuItem struct {
name string
desc string
}
// termHeight returns the terminal's row count, falling back to 24 on error.
func termHeight(fd int) int {
_, h, err := term.GetSize(fd)
if err != nil || h <= 0 {
return 24
}
return h
}
// fixedLines returns the number of non-item lines rendered each frame:
// header label, blank separator, scroll-up indicator, scroll-down indicator.
// When searching is true the search bar adds one more line.
func fixedLines(searching bool) int {
n := 4 // header + blank + scroll-up + scroll-down
if searching {
n++ // search bar
}
return n
}
// maxViewport calculates how many menu item rows fit after subtracting the
// fixed lines from the available terminal rows, leaving at least 1 row.
func maxViewport(totalItems, termRows int, searching bool) int {
avail := termRows - fixedLines(searching)
if avail < 1 {
avail = 1
}
if totalItems < avail {
return totalItems
}
return avail
}
// renderSearchBar draws the search input line when searching is active.
func renderSearchBar(w *os.File, query string) {
fmt.Fprintf(w, "\r\033[K%s %s\n", accent("🔍"), query+"_")
}
// filterMenuItems returns items whose name or desc contain the query (case-insensitive).
func filterMenuItems(items []menuItem, query string) []menuItem {
if query == "" {
return items
}
lq := strings.ToLower(query)
var out []menuItem
for _, it := range items {
if strings.Contains(strings.ToLower(it.name), lq) || strings.Contains(strings.ToLower(it.desc), lq) {
out = append(out, it)
}
}
return out
}
// selectOne renders an interactive single-choice menu navigated with the arrow
// keys (or j/k), confirmed with Enter, aborted with q or Ctrl-C. It puts the
// terminal in raw mode, so it requires a TTY (callers gate on isInteractive).
// When the item list exceeds the terminal height, only a viewport-sized window
// is shown, with scroll indicators. Pressing '/' enters search mode to filter
// items by keyword.
func selectOne(label string, items []menuItem) (int, error) {
fd := int(os.Stdin.Fd())
old, err := term.MakeRaw(fd)
if err != nil {
return 0, err
}
defer term.Restore(fd, old)
w := os.Stdout
th := termHeight(fd)
// search state
searching := false
searchQuery := ""
filtered := items
filterIdx := make([]int, len(items))
for i := range items {
filterIdx[i] = i
}
sel := 0
scroll := 0
prevLines := 0 // lines printed in the previous frame; 0 = first frame
render := func() {
n := len(filtered)
vp := maxViewport(n, th, searching)
// adjust scroll to keep sel visible
if sel < scroll {
scroll = sel
}
if sel >= scroll+vp {
scroll = sel - vp + 1
}
if scroll < 0 {
scroll = 0
}
// scroll-up indicator (always 1 line)
if n > 0 && scroll > 0 {
fmt.Fprintf(w, "\r\033[K%s\n", dim(fmt.Sprintf(i18n.M.SelectMoreAboveFmt, scroll)))
} else {
fmt.Fprintf(w, "\r\033[K\r\n")
}
// menu rows
end := scroll + vp
if end > n {
end = n
}
for i := scroll; i < end; i++ {
it := filtered[i]
name := fmt.Sprintf("%-10s", it.name)
if i == sel {
fmt.Fprintf(w, "\r\033[K%s\r\n", reverse(fmt.Sprintf(" %s %s ", name, it.desc)))
} else {
fmt.Fprintf(w, "\r\033[K %s %s\r\n", name, dim(it.desc))
}
}
// if fewer items than viewport, pad with blank lines so the frame
// height stays constant
for i := end - scroll; i < vp; i++ {
fmt.Fprintf(w, "\r\033[K\r\n")
}
// scroll-down indicator (always 1 line)
if n > 0 && end < n {
fmt.Fprintf(w, "\r\033[K%s\n", dim(fmt.Sprintf(i18n.M.SelectMoreBelowFmt, n-end)))
} else {
fmt.Fprintf(w, "\r\033[K\r\n")
}
}
drawHeader := func() {
if searching {
fmt.Fprintf(w, "\r\033[K%s %s %s\r\n\r\n", accent("▌"), bold(label), dim(i18n.M.SelectSearchHint))
renderSearchBar(w, searchQuery)
} else {
fmt.Fprintf(w, "\r\033[K%s %s %s\r\n\r\n", accent("▌"), bold(label), dim(i18n.M.SelectOneHint))
}
}
redraw := func() {
if prevLines > 0 {
fmt.Fprintf(w, "\033[%dA", prevLines)
}
drawHeader()
render()
// Clear everything below the current frame so stale rows from a taller
// previous frame don't linger.
fmt.Fprint(w, "\033[J")
prevLines = fixedLines(searching) + maxViewport(len(filtered), th, searching)
}
redraw() // initial draw
buf := make([]byte, 8)
for {
n, err := os.Stdin.Read(buf)
if err != nil {
return 0, err
}
k := buf[:n]
if searching {
switch {
case k[0] == 27: // Esc — exit search
searching = false
searchQuery = ""
filtered = items
filterIdx = make([]int, len(items))
for i := range items {
filterIdx[i] = i
}
sel = 0
scroll = 0
redraw()
case k[0] == '\r' || k[0] == '\n':
if len(filtered) > 0 {
fmt.Fprint(w, "\r\n")
return filterIdx[sel], nil
}
case k[0] == 127 || k[0] == 8: // backspace
if len(searchQuery) > 0 {
searchQuery = searchQuery[:len(searchQuery)-1]
filtered = filterMenuItems(items, searchQuery)
filterIdx = filterIndices(items, searchQuery)
sel = 0
scroll = 0
redraw()
}
case k[0] == 3: // Ctrl-C
fmt.Fprint(w, "\r\n")
return 0, errCancelled
case k[0] >= 32 && k[0] < 127: // printable
searchQuery += string(k[0])
filtered = filterMenuItems(items, searchQuery)
filterIdx = filterIndices(items, searchQuery)
sel = 0
scroll = 0
redraw()
default:
continue
}
continue
}
switch {
case k[0] == '\r' || k[0] == '\n':
fmt.Fprint(w, "\r\n")
return filterIdx[sel], nil
case k[0] == 3 || k[0] == 'q': // Ctrl-C or q
fmt.Fprint(w, "\r\n")
return 0, errCancelled
case k[0] == '/': // enter search mode
searching = true
searchQuery = ""
redraw()
case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'A': // up
if sel > 0 {
sel--
}
case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'B': // down
if sel < len(filtered)-1 {
sel++
}
case k[0] == 'k':
if sel > 0 {
sel--
}
case k[0] == 'j':
if sel < len(filtered)-1 {
sel++
}
default:
continue // ignore other keys, no redraw
}
redraw()
}
}
// selectMany renders an interactive multi-choice menu: arrow keys (or j/k) move,
// Space toggles, Enter confirms (at least one required), q/Ctrl-C aborts. It
// returns the checked indices in order and requires a TTY. When the item list
// exceeds the terminal height, only a viewport-sized window is shown. Pressing
// '/' enters search mode to filter items by keyword.
func selectMany(label string, items []menuItem) ([]int, error) {
fd := int(os.Stdin.Fd())
old, err := term.MakeRaw(fd)
if err != nil {
return nil, err
}
defer term.Restore(fd, old)
w := os.Stdout
th := termHeight(fd)
// search state
searching := false
searchQuery := ""
filtered := items
filterIdx := make([]int, len(items))
for i := range items {
filterIdx[i] = i
}
cur := 0
checked := make([]bool, len(items))
scroll := 0
prevLines := 0
render := func() {
n := len(filtered)
vp := maxViewport(n, th, searching)
if cur < scroll {
scroll = cur
}
if cur >= scroll+vp {
scroll = cur - vp + 1
}
if scroll < 0 {
scroll = 0
}
if n > 0 && scroll > 0 {
fmt.Fprintf(w, "\r\033[K%s\n", dim(fmt.Sprintf(i18n.M.SelectMoreAboveFmt, scroll)))
} else {
fmt.Fprintf(w, "\r\033[K\r\n")
}
end := scroll + vp
if end > n {
end = n
}
for i := scroll; i < end; i++ {
it := filtered[i]
origIdx := filterIdx[i]
box := "[ ]"
if checked[origIdx] {
box = "[x]"
}
name := fmt.Sprintf("%-14s", it.name)
if i == cur {
fmt.Fprintf(w, "\r\033[K%s\r\n", reverse(fmt.Sprintf(" %s %s %s ", box, name, it.desc)))
} else {
fmt.Fprintf(w, "\r\033[K %s %s %s\r\n", box, name, dim(it.desc))
}
}
for i := end - scroll; i < vp; i++ {
fmt.Fprintf(w, "\r\033[K\r\n")
}
if n > 0 && end < n {
fmt.Fprintf(w, "\r\033[K%s\n", dim(fmt.Sprintf(i18n.M.SelectMoreBelowFmt, n-end)))
} else {
fmt.Fprintf(w, "\r\033[K\r\n")
}
}
drawHeader := func() {
if searching {
fmt.Fprintf(w, "\r\033[K%s %s %s\r\n\r\n", accent("▌"), bold(label), dim(i18n.M.SelectSearchHint))
renderSearchBar(w, searchQuery)
} else {
fmt.Fprintf(w, "\r\033[K%s %s %s\r\n\r\n", accent("▌"), bold(label), dim(i18n.M.SelectManyHint))
}
}
redraw := func() {
if prevLines > 0 {
fmt.Fprintf(w, "\033[%dA", prevLines)
}
drawHeader()
render()
fmt.Fprint(w, "\033[J")
prevLines = fixedLines(searching) + maxViewport(len(filtered), th, searching)
}
redraw()
buf := make([]byte, 8)
for {
n, err := os.Stdin.Read(buf)
if err != nil {
return nil, err
}
k := buf[:n]
if searching {
switch {
case k[0] == 27: // Esc — exit search
searching = false
searchQuery = ""
filtered = items
filterIdx = make([]int, len(items))
for i := range items {
filterIdx[i] = i
}
cur = 0
scroll = 0
redraw()
case k[0] == '\r' || k[0] == '\n':
var out []int
for i, c := range checked {
if c {
out = append(out, i)
}
}
if len(out) == 0 {
continue
}
fmt.Fprint(w, "\r\n")
return out, nil
case k[0] == ' ':
if len(filtered) > 0 {
origIdx := filterIdx[cur]
checked[origIdx] = !checked[origIdx]
}
case k[0] == 127 || k[0] == 8: // backspace
if len(searchQuery) > 0 {
searchQuery = searchQuery[:len(searchQuery)-1]
filtered = filterMenuItems(items, searchQuery)
filterIdx = filterIndices(items, searchQuery)
cur = 0
scroll = 0
redraw()
}
case k[0] == 3: // Ctrl-C
fmt.Fprint(w, "\r\n")
return nil, errCancelled
case k[0] >= 32 && k[0] < 127:
searchQuery += string(k[0])
filtered = filterMenuItems(items, searchQuery)
filterIdx = filterIndices(items, searchQuery)
cur = 0
scroll = 0
redraw()
case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'A':
if cur > 0 {
cur--
}
case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'B':
if cur < len(filtered)-1 {
cur++
}
case k[0] == 'k':
if cur > 0 {
cur--
}
case k[0] == 'j':
if cur < len(filtered)-1 {
cur++
}
default:
continue
}
redraw()
continue
}
switch {
case k[0] == '\r' || k[0] == '\n':
var out []int
for i, c := range checked {
if c {
out = append(out, i)
}
}
if len(out) == 0 {
continue // need at least one selection
}
fmt.Fprint(w, "\r\n")
return out, nil
case k[0] == 3 || k[0] == 'q':
fmt.Fprint(w, "\r\n")
return nil, errCancelled
case k[0] == '/': // enter search mode
searching = true
searchQuery = ""
redraw()
case k[0] == ' ':
if len(filtered) > 0 {
origIdx := filterIdx[cur]
checked[origIdx] = !checked[origIdx]
}
case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'A':
if cur > 0 {
cur--
}
case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'B':
if cur < len(filtered)-1 {
cur++
}
case k[0] == 'k':
if cur > 0 {
cur--
}
case k[0] == 'j':
if cur < len(filtered)-1 {
cur++
}
default:
continue
}
redraw()
}
}
// filterIndices returns the original indices of items matching query.
func filterIndices(items []menuItem, query string) []int {
if query == "" {
out := make([]int, len(items))
for i := range items {
out[i] = i
}
return out
}
lq := strings.ToLower(query)
var out []int
for i, it := range items {
if strings.Contains(strings.ToLower(it.name), lq) || strings.Contains(strings.ToLower(it.desc), lq) {
out = append(out, i)
}
}
return out
}
// FrameLines is exported for testing. It returns the total number of terminal
// lines that selectOne/selectMany will print for the given state.
func FrameLines(filteredLen, termRows int, searching bool) int {
return fixedLines(searching) + maxViewport(filteredLen, termRows, searching)
}