From c7960aac46f6b7b4b985f4c01cf3282dcb37fb4c Mon Sep 17 00:00:00 2001 From: VERSE Date: Thu, 2 Jul 2026 15:41:44 +0700 Subject: [PATCH] fix(ui): menu deboucing (#14) Fixed menu keep moving/debouncing while move up/down --- integration/overlay.go | 179 +++++++++++++++++++++++++++++++++-------- root/wrapper.go | 86 ++++++++++---------- tests/cursor_test.go | 50 ++++++++++++ 3 files changed, 241 insertions(+), 74 deletions(-) create mode 100644 tests/cursor_test.go diff --git a/integration/overlay.go b/integration/overlay.go index fc2f0c4..f21b39a 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -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)) - 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 - } + typedLen := len([]rune(o.TypedQuery)) + targetCol := o.PromptLen + typedLen + + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 { + width = 120 } + 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)) diff --git a/root/wrapper.go b/root/wrapper.go index 890b04f..8034eda 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -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,41 +432,22 @@ func runWrapper() { renderTimer.Stop() renderTimer = nil } - isNavTimerRunning = false return } - navCopy := userNavigated - - if navCopy { - if isNavTimerRunning { - 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 - renderMu.Unlock() - renderMenuNow() - }) + if userNavigated { + return } + + if renderTimer != nil { + renderTimer.Stop() + } + renderTimer = time.AfterFunc(25*time.Millisecond, func() { + renderMu.Lock() + renderTimer = nil + renderMu.Unlock() + 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 diff --git a/tests/cursor_test.go b/tests/cursor_test.go new file mode 100644 index 0000000..562c443 --- /dev/null +++ b/tests/cursor_test.go @@ -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) + } + }) + } +}