fix(ui): menu deboucing (#14)
Fixed menu keep moving/debouncing while move up/down
This commit is contained in:
+140
-27
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user