fix(ui): menu deboucing (#14)

Fixed menu keep moving/debouncing while move up/down
This commit is contained in:
VERSE
2026-07-02 15:41:44 +07:00
committed by GitHub
parent f37f0681ce
commit c7960aac46
3 changed files with 241 additions and 74 deletions
+140 -27
View File
@@ -3,11 +3,14 @@ package integration
import (
"fmt"
"os"
"strconv"
"strings"
"sync"
"unicode/utf8"
"github.com/charmbracelet/lipgloss"
"github.com/versenilvis/iris/commands/core"
"github.com/versenilvis/iris/logger"
"golang.org/x/term"
)
@@ -16,6 +19,112 @@ const (
maxItems = 6
)
func ComputeCursorCol(data []byte) int {
col := 0
i := 0
n := len(data)
for i < n {
b := data[i]
if b == '\r' {
col = 0
i++
continue
}
if b == '\b' || b == 0x7f {
col--
if col < 0 {
col = 0
}
i++
continue
}
if b == '\t' {
col = (col + 8) &^ 7
i++
continue
}
if b == '\033' {
if i+1 < n && data[i+1] == '[' {
j := i + 2
for j < n && data[j] >= 0x20 && data[j] <= 0x3F {
j++
}
if j < n {
cmd := data[j]
paramsStr := string(data[i+2 : j])
paramsStr = strings.TrimLeft(paramsStr, "?>=")
parts := strings.Split(paramsStr, ";")
getParam := func(idx, def int) int {
if idx < len(parts) && parts[idx] != "" {
if v, err := strconv.Atoi(parts[idx]); err == nil && v > 0 {
return v
}
}
return def
}
switch cmd {
case 'C':
col += getParam(0, 1)
case 'D':
col -= getParam(0, 1)
if col < 0 {
col = 0
}
case 'G':
col = getParam(0, 1) - 1
if col < 0 {
col = 0
}
}
i = j + 1
continue
}
break
} else if i+1 < n && data[i+1] == ']' {
j := i + 2
for j < n {
if data[j] == '\007' {
j++
break
}
if data[j] == '\033' && j+1 < n && data[j+1] == '\\' {
j += 2
break
}
j++
}
i = j
continue
} else if i+1 < n && (data[i+1] == 'P' || data[i+1] == 'X' || data[i+1] == '^' || data[i+1] == '_') {
j := i + 2
for j < n {
if data[j] == '\033' && j+1 < n && data[j+1] == '\\' {
j += 2
break
}
j++
}
i = j
continue
} else if i+1 < n {
i += 2
continue
} else {
break
}
}
if b < 0x20 {
i++
continue
}
r, size := utf8.DecodeRune(data[i:])
w := lipgloss.Width(string(r))
col += w
i += size
}
return col
}
type Overlay struct {
mu sync.Mutex
Visible bool
@@ -25,6 +134,16 @@ type Overlay struct {
LastGhostLen int
TypedQuery string
UserNavigated bool
PromptLen int
}
func (o *Overlay) SetPromptLen(l int) {
o.mu.Lock()
defer o.mu.Unlock()
if o.PromptLen != l {
logger.Debugf("SetPromptLen: %d -> %d", o.PromptLen, l)
o.PromptLen = l
}
}
var (
@@ -117,6 +236,10 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool) string {
}
func (o *Overlay) Render() string {
return o.draw()
}
func (o *Overlay) draw() string {
o.mu.Lock()
defer o.mu.Unlock()
@@ -127,23 +250,20 @@ func (o *Overlay) Render() string {
var s strings.Builder
s.WriteString("\033[?7l")
var offset int
if o.UserNavigated && len(o.Items) > 0 && o.Cursor >= 0 && o.Cursor < len(o.Items) {
currentCmd := o.Items[o.Cursor].Cmd
typedLen := len([]rune(o.TypedQuery))
currentLen := len([]rune(currentCmd))
targetCol := o.PromptLen + typedLen
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || width <= 0 {
width = 120
}
if currentLen+4 < width {
offset = currentLen - typedLen
} else {
curCol := (currentLen + 2) % width
typedCol := (typedLen + 2) % width
offset = curCol - typedCol
if targetCol+boxWidth > width {
targetCol = width - boxWidth
}
if targetCol < 0 {
targetCol = 0
}
logger.Debugf("Overlay draw: pLen=%d, typedLen=%d, targetCol=%d, width=%d", o.PromptLen, typedLen, targetCol, width)
s.WriteString("\0337")
@@ -164,7 +284,6 @@ func (o *Overlay) Render() string {
if o.Cursor >= o.StartIdx+windowSize-scrolloffDown {
o.StartIdx = o.Cursor - windowSize + scrolloffDown + 1
}
if o.StartIdx < 0 {
o.StartIdx = 0
}
@@ -177,7 +296,6 @@ func (o *Overlay) Render() string {
start := o.StartIdx
end := start + windowSize
totalLines := windowSize + 2
for range totalLines {
@@ -187,14 +305,17 @@ func (o *Overlay) Render() string {
s.WriteString("\0337")
moveToTarget := func() {
s.WriteString("\r")
if targetCol > 0 {
fmt.Fprintf(&s, "\033[%dC", targetCol)
}
}
s.WriteString("\0338")
fmt.Fprintf(&s, "\033[%dB", 1)
s.WriteString("\033[2K")
if offset > 0 {
fmt.Fprintf(&s, "\033[%dD", offset)
} else if offset < 0 {
fmt.Fprintf(&s, "\033[%dC", -offset)
}
moveToTarget()
scrollInfo := ""
if len(o.Items) > windowSize {
@@ -213,11 +334,7 @@ func (o *Overlay) Render() string {
s.WriteString("\0338")
fmt.Fprintf(&s, "\033[%dB", (i-start)+2)
s.WriteString("\033[2K")
if offset > 0 {
fmt.Fprintf(&s, "\033[%dD", offset)
} else if offset < 0 {
fmt.Fprintf(&s, "\033[%dC", -offset)
}
moveToTarget()
it := o.Items[i]
rawIcon := fixedWidth(it.Icon, iconW)
@@ -244,11 +361,7 @@ func (o *Overlay) Render() string {
s.WriteString("\0338")
fmt.Fprintf(&s, "\033[%dB", windowSize+2)
s.WriteString("\033[2K")
if offset > 0 {
fmt.Fprintf(&s, "\033[%dD", offset)
} else if offset < 0 {
fmt.Fprintf(&s, "\033[%dC", -offset)
}
moveToTarget()
bottomBorder := "╰" + strings.Repeat("─", boxWidth) + "╯"
s.WriteString(borderStyle.Render(bottomBorder))
+35 -31
View File
@@ -82,6 +82,8 @@ func runWrapper() {
var naiveBuffer string
cursorOffset := 0
var bufferMu sync.Mutex
var userNavigated bool
var renderMenuNow func()
r, w, err := os.Pipe() // pipe for ipc communication from shell to iris
if err != nil {
@@ -225,6 +227,7 @@ func runWrapper() {
os.Exit(2)
}
}()
var lastPromptBuf []byte
buf := make([]byte, 4096)
for {
n, err := ptmx.Read(buf)
@@ -236,12 +239,26 @@ func runWrapper() {
continue
}
writeStdout(buf[:n])
bufferMu.Lock()
nbEmpty := naiveBuffer == ""
bufferMu.Unlock()
if nbEmpty && !userNavigated {
lastPromptBuf = append(lastPromptBuf, buf[:n]...)
if idx := bytes.LastIndexByte(lastPromptBuf, '\n'); idx >= 0 {
lastPromptBuf = append([]byte(nil), lastPromptBuf[idx+1:]...)
}
pLen := integration.ComputeCursorCol(lastPromptBuf)
if pLen >= 0 {
overlay.SetPromptLen(pLen)
}
}
}
}()
var disableGhostText atomic.Bool
disableGhostText.Store(!config.Get().UI.GhostText)
var userNavigated bool
var renderOverlay func()
isExecuting := func() bool {
@@ -344,7 +361,7 @@ func runWrapper() {
var renderTimer *time.Timer
var renderMu sync.Mutex
renderMenuNow := func() {
renderMenuNow = func() {
if isExecuting() {
return
}
@@ -406,8 +423,6 @@ func runWrapper() {
writeStdout([]byte(b.String()))
}
var isNavTimerRunning bool
renderOverlay = func() {
renderMu.Lock()
defer renderMu.Unlock()
@@ -417,34 +432,16 @@ func runWrapper() {
renderTimer.Stop()
renderTimer = nil
}
isNavTimerRunning = false
return
}
navCopy := userNavigated
if navCopy {
if isNavTimerRunning {
if userNavigated {
return
}
if renderTimer != nil {
renderTimer.Stop()
}
isNavTimerRunning = true
// IMPORTANT: please dont change this to above 24ms or under 19ms
// I still can get the reason why it only works stably between 20-23ms
renderTimer = time.AfterFunc(23*time.Millisecond, func() {
renderMu.Lock()
renderTimer = nil
isNavTimerRunning = false
renderMu.Unlock()
renderMenuNow()
})
} else {
if renderTimer != nil {
renderTimer.Stop()
}
isNavTimerRunning = false
renderTimer = time.AfterFunc(25*time.Millisecond, func() {
renderMu.Lock()
renderTimer = nil
@@ -452,7 +449,6 @@ func runWrapper() {
renderMenuNow()
})
}
}
renderOverlay()
@@ -550,11 +546,9 @@ func runWrapper() {
cursorOffset = 0
bufferMu.Unlock()
writeStdout([]byte(overlay.Render()))
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
userNavigated = true
overlay.UserNavigated = true
renderOverlay()
i += 2
continue
} else if !overlay.Visible && naiveBuffer == "" && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { // up/down arrow on empty prompt
@@ -587,6 +581,7 @@ func runWrapper() {
}
}
overlay.TypedQuery = ""
overlay.UpdateItems(historyList)
if inputSlice[i+2] == 'A' {
@@ -601,11 +596,11 @@ func runWrapper() {
cursorOffset = 0
bufferMu.Unlock()
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
userNavigated = true
overlay.UserNavigated = true
renderOverlay()
writeStdout([]byte(overlay.Render()))
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
}
i += 2
continue
@@ -715,6 +710,15 @@ func runWrapper() {
saveMode(activeMode)
activeModeMu.Unlock()
logger.Debugf("Intercepted Ctrl+R, toggled mode to %q", activeMode)
if userNavigated {
bufferMu.Lock()
naiveBuffer = overlay.TypedQuery
cursorOffset = 0
bufferMu.Unlock()
_, _ = ptmx.Write(append([]byte{0x15}, overlay.TypedQuery...))
}
userNavigated = false
overlay.UserNavigated = false
shouldOverlayDraw = true
// enter: enter behavior is a bit different from tab suggestions in code editor
// I want it to execute the command anyway and ignore the suggestions
+50
View File
@@ -0,0 +1,50 @@
package tests
import (
"testing"
"github.com/versenilvis/iris/integration"
)
func TestComputeCursorCol(t *testing.T) {
tests := []struct {
name string
data []byte
want int
}{
{
name: "Simple prompt",
data: []byte("λ "),
want: 2,
},
{
name: "ANSI color prompt",
data: []byte("\033[32mλ \033[0m"),
want: 2,
},
{
name: "Carriage return and move right",
data: []byte("λ \033[140G...22 chars right prompt...\r\033[2C"),
want: 2,
},
{
name: "OSC sequence before prompt",
data: []byte("\033]0;iris on fix/menu-debouncing\007λ "),
want: 2,
},
{
name: "CSI Horizontal Absolute",
data: []byte("abc\033[10Gde"),
want: 11,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := integration.ComputeCursorCol(tt.data)
if got != tt.want {
t.Errorf("ComputeCursorCol(%q) = %d, want %d", tt.data, got, tt.want)
}
})
}
}