diff --git a/internal/connections/connectiondetails.go b/internal/connections/connectiondetails.go index 1753b8682..e6d250b0a 100644 --- a/internal/connections/connectiondetails.go +++ b/internal/connections/connectiondetails.go @@ -117,6 +117,7 @@ type ClientInput struct { EnterPressed bool // Did they hit enter? It's stripped from the buffer/input FYI BSPressed bool // Did they hit backspace? TabPressed bool // Did they hit tab? + Cursor int // Byte offset into Buffer where the next edit happens (0..len(Buffer)) History InputHistory // A list of the last 10 things they typed } @@ -124,6 +125,7 @@ type ClientInput struct { func (ci *ClientInput) Reset() { ci.DataIn = ci.DataIn[:0] ci.Buffer = ci.Buffer[:0] + ci.Cursor = 0 ci.EnterPressed = false } diff --git a/internal/inputhandlers/AGENTS.md b/internal/inputhandlers/AGENTS.md index 0fbb45915..84dbf9069 100644 --- a/internal/inputhandlers/AGENTS.md +++ b/internal/inputhandlers/AGENTS.md @@ -11,6 +11,7 @@ - Preserve the distinction between system commands, prompt handling, protocol sanitization, and normal command processing. - If a change touches telnet or ANSI parsing, inspect the matching terminal/connection behavior together rather than patching only one side. - Avoid adding gameplay-specific command policy here when it belongs in `internal/usercommands`. +- `ClientInput.Cursor` is a byte offset into `Buffer` and is the editing position. `CleanserInputHandler` owns buffer mutation: it inserts at the cursor and deletes the rune before it; `AnsiHandler` moves it with the arrow/Home/End/Delete keys. Any code that resizes `Buffer` (history recall in `term_ansi.go`, signal shortcuts in `signals.go`, login submit, prompt redraws in `main.go`) must reset/keep `Cursor` consistent or `clampCursor` will paper over it. Display (echo, erase, cursor moves) is width-aware via `runewidth` so CJK/wide characters occupy 2 columns; it is only emitted for server-side-echo, non-masked input — local-echo clients (Mudlet/web) and masked (password) fields manage their own line and stay linear. ## Verification diff --git a/internal/inputhandlers/capture_test.go b/internal/inputhandlers/capture_test.go new file mode 100644 index 000000000..debc66cc3 --- /dev/null +++ b/internal/inputhandlers/capture_test.go @@ -0,0 +1,137 @@ +package inputhandlers + +import ( + "bytes" + "net" + "testing" + "time" + + "github.com/GoMudEngine/GoMud/internal/connections" + "github.com/GoMudEngine/GoMud/internal/term" +) + +// captureConn is a minimal net.Conn that records every byte written to it, so +// tests can assert on exactly what the terminal would receive. +type captureConn struct { + buf bytes.Buffer +} + +func (c *captureConn) Read(p []byte) (int, error) { return 0, net.ErrClosed } +func (c *captureConn) Write(p []byte) (int, error) { return c.buf.Write(p) } +func (c *captureConn) Close() error { return nil } +func (c *captureConn) LocalAddr() net.Addr { return dummyAddr{} } +func (c *captureConn) RemoteAddr() net.Addr { return dummyAddr{} } +func (c *captureConn) SetDeadline(time.Time) error { return nil } +func (c *captureConn) SetReadDeadline(time.Time) error { return nil } +func (c *captureConn) SetWriteDeadline(time.Time) error { return nil } + +type dummyAddr struct{} + +func (dummyAddr) Network() string { return "test" } +func (dummyAddr) String() string { return "test" } + +// TestCleanserEmitsWidthAwareBackspace is the integration test for the user's +// core complaint: backspacing a wide (CJK) character must erase the full number +// of columns it occupies, not just one. +func TestCleanserEmitsWidthAwareBackspace(t *testing.T) { + cc := &captureConn{} + cd := connections.Add(cc, nil, connections.ConnHuman) + t.Cleanup(func() { connections.Remove(cd.ConnectionId()) }) + + ci := &connections.ClientInput{ConnectionId: cd.ConnectionId(), Buffer: []byte{}, Cursor: 0} + state := map[string]any{} + + // Type a CJK character (2 display columns). + ci.DataIn = []byte("你") + CleanserInputHandler(ci, state) + + if string(ci.Buffer) != "你" { + t.Fatalf("buffer=%q want 你", string(ci.Buffer)) + } + if ci.Cursor != 3 { + t.Fatalf("cursor=%d want 3", ci.Cursor) + } + if !bytes.Contains(cc.buf.Bytes(), []byte("你")) { + t.Errorf("echo should contain 你, got %q", cc.buf.String()) + } + + // Backspace it. + cc.buf.Reset() + ci.DataIn = []byte{term.ASCII_BACKSPACE} + ci.BSPressed = false + CleanserInputHandler(ci, state) + + if string(ci.Buffer) != "" { + t.Errorf("buffer=%q want empty after backspace", string(ci.Buffer)) + } + if ci.Cursor != 0 { + t.Errorf("cursor=%d want 0 after backspace", ci.Cursor) + } + + out := cc.buf.Bytes() + // The glyph is 2 columns wide, so the erase must move back 2 columns... + if !bytes.Contains(out, cursorBackwardN(2)) { + t.Errorf("expected a 2-column cursor-back (ESC[2D) in %q", cc.buf.String()) + } + // ...and clear to the end of the line so no half-glyph remains. + if !bytes.Contains(out, []byte(term.AnsiEraseLineForward.String())) { + t.Errorf("expected erase-to-end-of-line (ESC[0K) in %q", cc.buf.String()) + } + // The old single-column "\b \b" sequence must NOT be used for wide chars. + if bytes.Contains(out, term.BACKSPACE_SEQUENCE) { + t.Errorf("wide-char backspace must not use the 1-col BACKSPACE_SEQUENCE, got %q", cc.buf.String()) + } +} + +// TestCleanserMidLineInsertEcho checks that inserting a character in the middle +// of the line redraws the tail and repositions the cursor (not just overwrites). +func TestCleanserMidLineInsertEcho(t *testing.T) { + cc := &captureConn{} + cd := connections.Add(cc, nil, connections.ConnHuman) + t.Cleanup(func() { connections.Remove(cd.ConnectionId()) }) + + // Existing buffer "llo" with cursor at the front (as if moved left from end). + ci := &connections.ClientInput{ + ConnectionId: cd.ConnectionId(), + Buffer: []byte("llo"), + Cursor: 0, + } + ci.DataIn = []byte("X") + CleanserInputHandler(ci, map[string]any{}) + + if string(ci.Buffer) != "Xllo" { + t.Errorf("buffer=%q want Xllo", string(ci.Buffer)) + } + if ci.Cursor != 1 { + t.Errorf("cursor=%d want 1", ci.Cursor) + } + // The redraw must emit the inserted char + the tail so "llo" is preserved. + if !bytes.Contains(cc.buf.Bytes(), []byte("Xllo")) { + t.Errorf("echo should contain the redrawn tail Xllo, got %q", cc.buf.String()) + } +} + +// TestAnsiHandlerLeftArrowEmitsWidthAwareMove checks that the Left arrow moves +// the terminal cursor back by the display width of the character it crosses. +func TestAnsiHandlerLeftArrowEmitsWidthAwareMove(t *testing.T) { + cc := &captureConn{} + cd := connections.Add(cc, nil, connections.ConnHuman) + t.Cleanup(func() { connections.Remove(cd.ConnectionId()) }) + + // Buffer "x你" with cursor at the end (3 bytes). + ci := &connections.ClientInput{ + ConnectionId: cd.ConnectionId(), + Buffer: []byte("x你"), + Cursor: 4, // len("x你") + DataIn: []byte{term.ANSI_ESC, '[', 'D'}, // Left arrow + } + AnsiHandler(ci, map[string]any{}) + + if ci.Cursor != 1 { // crossed back over 你 (3 bytes) -> lands after 'x' + t.Errorf("cursor=%d want 1", ci.Cursor) + } + // 你 is 2 columns wide, so the terminal cursor must move back 2 columns. + if !bytes.Contains(cc.buf.Bytes(), cursorBackwardN(2)) { + t.Errorf("expected ESC[2D for crossing a wide char, got %q", cc.buf.String()) + } +} diff --git a/internal/inputhandlers/cleanser.go b/internal/inputhandlers/cleanser.go index 8b89261f1..933789ed6 100644 --- a/internal/inputhandlers/cleanser.go +++ b/internal/inputhandlers/cleanser.go @@ -1,6 +1,7 @@ package inputhandlers import ( + "slices" "strings" "unicode" "unicode/utf8" @@ -10,54 +11,67 @@ import ( ) // CleanserInputHandler's job is to remove any bad characters from the input stream -// before passing it down the chain. -// For this reason, it's important it happen before other text processing handlers +// before passing it down the chain, and to maintain the editing buffer + cursor. +// For this reason, it's important it happen before other text processing handlers. func CleanserInputHandler(clientInput *connections.ClientInput, sharedState map[string]any) (nextHandler bool) { if len(clientInput.DataIn) < 1 { return true } - // backspace + // Other handlers (history recall, ctrl-shortcuts) can resize the buffer, so + // keep the cursor valid before we touch anything relative to it. + clampCursor(clientInput) + + serverEcho := !isLocalEchoConn(clientInput.ConnectionId) + masked := isMaskedPrompt(sharedState) + + // Examine the final byte for control keys (backspace / tab / enter). A single + // read can deliver several keystrokes at once; we only react to the last one + // for control-key detection, matching the historical behavior. dIn := clientInput.DataIn[len(clientInput.DataIn)-1] + // Backspace / Delete (the ASCII DEL key, value 127): remove the rune + // immediately before the cursor and erase the columns it occupied. if dIn == term.ASCII_DELETE || dIn == term.ASCII_BACKSPACE { clientInput.BSPressed = true - //connections.SendTo([]byte(term.AnsiMoveCursorBackward.String()+" "+term.AnsiMoveCursorBackward.String()), connDetails.UniqueId()) - // send backspace, space, backspace - if len(clientInput.Buffer) > 0 { - connections.SendTo([]byte{term.ASCII_BACKSPACE, term.ASCII_SPACE, term.ASCII_BACKSPACE}, clientInput.ConnectionId) - - // Handle UTF-8 properly by removing the last complete character (rune) - bufferStr := string(clientInput.Buffer) - if len(bufferStr) > 0 { - // Find the start of the last rune - _, size := utf8.DecodeLastRune(clientInput.Buffer) - if size > 0 { - clientInput.Buffer = clientInput.Buffer[:len(clientInput.Buffer)-size] + // Strip the control byte itself from the input we forward downstream. + clientInput.DataIn = clientInput.DataIn[:len(clientInput.DataIn)-1] + + if clientInput.Cursor > 0 { + r, size := utf8.DecodeLastRune(clientInput.Buffer[:clientInput.Cursor]) + if size > 0 { + // Remove the whole rune (not a single byte) so multibyte UTF-8 + // characters such as CJK are deleted atomically. + clientInput.Buffer = append(clientInput.Buffer[:clientInput.Cursor-size], clientInput.Buffer[clientInput.Cursor:]...) + clientInput.Cursor -= size + + if serverEcho { + w := runeDisplayWidth(r) + if masked { + // Masked fields render one column per rune, regardless of width. + w = 1 + } + redrawEraseAtCursor(clientInput, w) } } } - clientInput.DataIn = clientInput.DataIn[:len(clientInput.DataIn)-1] + return true } if dIn == term.ASCII_TAB { clientInput.TabPressed = true - } else { - // Check if the last byte is a CR or LF or NULL - if dIn <= term.ASCII_CR { - if clientInput.DataIn[len(clientInput.DataIn)-1] == term.ASCII_NULL || clientInput.DataIn[len(clientInput.DataIn)-1] == term.ASCII_LF || clientInput.DataIn[len(clientInput.DataIn)-1] == term.ASCII_CR { - clientInput.EnterPressed = true - } + } else if dIn <= term.ASCII_CR { + // Check if the last byte is a CR or LF or NULL -> treat as Enter. + if last := clientInput.DataIn[len(clientInput.DataIn)-1]; last == term.ASCII_NULL || last == term.ASCII_LF || last == term.ASCII_CR { + clientInput.EnterPressed = true } } - // Remove non printing chars - //clientInput.DataIn = trimNonPrintingBytes(clientInput.DataIn) - + // Strip non-printable bytes while preserving full UTF-8 runes (e.g. CJK). clientInput.DataIn = []byte(strings.Map(func(r rune) rune { if unicode.IsPrint(r) { return r @@ -65,29 +79,25 @@ func CleanserInputHandler(clientInput *connections.ClientInput, sharedState map[ return -1 }, string(clientInput.DataIn))) - // Add all input to the currentBuffer - clientInput.Buffer = append(clientInput.Buffer, clientInput.DataIn...) - - return true -} - -// Trims non printing bytes and SPACE from front/back of a byte slice -func trimNonPrintingBytes(b []byte) []byte { - start := 0 - for ; start < len(b); start++ { - c := b[start] - if c > 31 && c < 127 { - break - } + if len(clientInput.DataIn) == 0 { + return true } - stop := len(b) - for ; stop > start; stop-- { - c := b[stop-1] - if c > 31 && c < 127 { - break - } + // Insert the typed text at the cursor (rather than always appending), so the + // user can edit anywhere in the line after moving the cursor with the arrows. + clientInput.Buffer = slices.Insert(clientInput.Buffer, clientInput.Cursor, clientInput.DataIn...) + oldCursor := clientInput.Cursor + clientInput.Cursor += len(clientInput.DataIn) + + // For server-side-echo clients we render the edit ourselves so that mid-line + // inserts and wide characters display correctly, then clear DataIn so the + // downstream echo handlers (EchoInputHandler / login prompt) don't double up. + // Masked steps leave DataIn intact so the login handler can emit the mask, + // and local-echo clients manage their own display. + if serverEcho && !masked { + redrawInsertAtCursor(clientInput, oldCursor) + clientInput.DataIn = clientInput.DataIn[:0] } - return b[start:stop] + return true } diff --git a/internal/inputhandlers/cleanser_test.go b/internal/inputhandlers/cleanser_test.go index 70e6d695a..55dbe62e3 100644 --- a/internal/inputhandlers/cleanser_test.go +++ b/internal/inputhandlers/cleanser_test.go @@ -60,6 +60,7 @@ func TestCleanserInputHandler_UTF8Backspace(t *testing.T) { ConnectionId: 1, DataIn: []byte{term.ASCII_BACKSPACE}, // Simulate backspace input Buffer: []byte(tt.initialBuffer), + Cursor: len(tt.initialBuffer), // cursor at end, as it would be after typing EnterPressed: false, } sharedState := make(map[string]any) @@ -97,6 +98,7 @@ func TestCleanserInputHandler_NoBackspace(t *testing.T) { ConnectionId: 1, DataIn: []byte("hello🚀"), // Multi-byte UTF-8 input Buffer: []byte("existing"), + Cursor: len("existing"), // cursor at end EnterPressed: false, } sharedState := make(map[string]any) diff --git a/internal/inputhandlers/lineedit.go b/internal/inputhandlers/lineedit.go new file mode 100644 index 000000000..ba1bbfae5 --- /dev/null +++ b/internal/inputhandlers/lineedit.go @@ -0,0 +1,159 @@ +package inputhandlers + +import ( + "fmt" + "unicode/utf8" + + "github.com/GoMudEngine/GoMud/internal/connections" + "github.com/GoMudEngine/GoMud/internal/term" + "github.com/mattn/go-runewidth" +) + +// runeDisplayWidth returns the number of terminal columns a rune occupies when +// rendered. East-Asian wide/fullwidth characters (e.g. CJK) occupy 2 columns; +// everything else occupies at least 1 so erasure never under-shoots. +func runeDisplayWidth(r rune) int { + if w := runewidth.RuneWidth(r); w > 0 { + return w + } + return 1 +} + +// bufferDisplayWidth returns the total terminal-column width of a UTF-8 byte +// slice. Invalid byte sequences are skipped (counted as 0 columns). +func bufferDisplayWidth(b []byte) int { + w := 0 + for len(b) > 0 { + r, size := utf8.DecodeRune(b) + if r == utf8.RuneError && size == 1 { + // Skip the invalid byte rather than treating it as a printable column. + b = b[1:] + continue + } + w += runeDisplayWidth(r) + b = b[size:] + } + return w +} + +// isMaskedPrompt reports whether the connection is currently on a masked +// (password-style) prompt step. When true, line editing stays linear and the +// server must not echo the real characters or move the cursor within the line. +func isMaskedPrompt(sharedState map[string]any) bool { + if sharedState == nil { + return false + } + v, ok := sharedState[promptHandlerStateKey] + if !ok { + return false + } + s, ok := v.(*PromptHandlerState) + if !ok || s == nil { + return false + } + if s.CurrentStepIndex < 0 || s.CurrentStepIndex >= len(s.Steps) { + return false + } + return s.Steps[s.CurrentStepIndex].MaskInput +} + +// isLocalEchoConn reports whether the client echoes input itself (Mudlet and the +// websocket/web client). For such clients the server must not emit per-keystroke +// echo or cursor-move sequences — they manage their own input line. +func isLocalEchoConn(connectionId connections.ConnectionId) bool { + cs := connections.GetClientSettings(connectionId) + return cs.IsMudlet || connections.IsWebsocket(connectionId) +} + +// clampCursor keeps Cursor within [0, len(Buffer)]. It must be called by any +// handler that is about to read/mutate the buffer relative to the cursor, since +// other handlers (history recall, signal shortcuts) can resize the buffer. +func clampCursor(clientInput *connections.ClientInput) { + if clientInput.Cursor < 0 { + clientInput.Cursor = 0 + } else if clientInput.Cursor > len(clientInput.Buffer) { + clientInput.Cursor = len(clientInput.Buffer) + } +} + +// cursorBackwardN returns the ANSI sequence to move the terminal cursor left n +// columns. Returns nil for n <= 0 so callers can pass it straight to SendTo. +func cursorBackwardN(n int) []byte { + if n <= 0 { + return nil + } + return []byte(term.AnsiMoveCursorBackward.StringWithPayload(fmt.Sprintf("%d", n))) +} + +// cursorForwardN returns the ANSI sequence to move the terminal cursor right n +// columns. Returns nil for n <= 0. +func cursorForwardN(n int) []byte { + if n <= 0 { + return nil + } + return []byte(term.AnsiMoveCursorForward.StringWithPayload(fmt.Sprintf("%d", n))) +} + +// matchesKey is a bool-only wrapper around term.Matches for use in compound +// conditions (term.Matches returns two values, which can't appear in ||). +func matchesKey(data []byte, cmd term.TerminalCommand) bool { + ok, _ := term.Matches(data, cmd) + return ok +} + +// isHomeKey reports whether data is any of the common Home-key sequences. +func isHomeKey(data []byte) bool { + return matchesKey(data, term.AnsiKeyHomeCSI) || + matchesKey(data, term.AnsiKeyHomeTilde) || + matchesKey(data, term.AnsiKeyHomeApp) +} + +// isEndKey reports whether data is any of the common End-key sequences. +func isEndKey(data []byte) bool { + return matchesKey(data, term.AnsiKeyEndCSI) || + matchesKey(data, term.AnsiKeyEndTilde) || + matchesKey(data, term.AnsiKeyEndApp) +} + +// clearInputLineDisplay erases the current input from the terminal screen for +// server-side-echo clients and homes the logical cursor, in preparation for +// replacing the buffer contents (e.g. history recall). It does not touch the +// buffer itself; callers reset Buffer/Cursor/DataIn afterwards. +func clearInputLineDisplay(clientInput *connections.ClientInput) { + if isLocalEchoConn(clientInput.ConnectionId) { + return + } + // Move the terminal cursor back to the start of the input field (just after + // the prompt), then erase to the end of the line. Input is contiguous from + // the field start to its end, so this clears the whole field. + connections.SendTo(cursorBackwardN(bufferDisplayWidth(clientInput.Buffer[:clientInput.Cursor])), clientInput.ConnectionId) + connections.SendTo([]byte(term.AnsiEraseLineForward.String()), clientInput.ConnectionId) +} + +// redrawEraseAtCursor is called after a rune has been removed from the buffer +// just before the cursor (backspace). The terminal cursor is still at the old +// (pre-removal) position; this moves it back onto the gap, redraws the tail so +// remaining characters slide left, clears the now-stale trailing cell, and +// leaves the terminal cursor aligned with the logical cursor. +func redrawEraseAtCursor(clientInput *connections.ClientInput, erasedWidth int) { + connections.SendTo(cursorBackwardN(erasedWidth), clientInput.ConnectionId) + + tail := clientInput.Buffer[clientInput.Cursor:] + connections.SendTo(tail, clientInput.ConnectionId) + + // The buffer shrank by one rune, so erase any leftover at the end of the line. + connections.SendTo([]byte(term.AnsiEraseLineForward.String()), clientInput.ConnectionId) + + // Writing the tail advanced the cursor; move it back to the logical cursor. + connections.SendTo(cursorBackwardN(bufferDisplayWidth(tail)), clientInput.ConnectionId) +} + +// redrawInsertAtCursor is called after DataIn has been inserted into the buffer +// at oldCursor. It re-renders from oldCursor to the end (inserted text + old +// tail) and then moves the terminal cursor back to the logical cursor, which +// sits just after the inserted text. For an append-at-end edit the tail is empty +// so this reduces to a plain echo. +func redrawInsertAtCursor(clientInput *connections.ClientInput, oldCursor int) { + connections.SendTo(clientInput.Buffer[oldCursor:], clientInput.ConnectionId) + connections.SendTo(cursorBackwardN(bufferDisplayWidth(clientInput.Buffer[clientInput.Cursor:])), clientInput.ConnectionId) +} diff --git a/internal/inputhandlers/lineedit_test.go b/internal/inputhandlers/lineedit_test.go new file mode 100644 index 000000000..a37d63f6e --- /dev/null +++ b/internal/inputhandlers/lineedit_test.go @@ -0,0 +1,172 @@ +package inputhandlers + +import ( + "testing" + "unicode/utf8" + + "github.com/GoMudEngine/GoMud/internal/connections" + "github.com/GoMudEngine/GoMud/internal/term" +) + +func TestBufferDisplayWidth(t *testing.T) { + cases := []struct { + in string + want int + }{ + {"", 0}, + {"abc", 3}, + {"你好", 4}, // two CJK ideographs, 2 columns each + {"a你b", 4}, // 1 + 2 + 1 + {"你好世界", 8}, // four CJK ideographs + {"🚀", 2}, // emoji, wide + } + for _, c := range cases { + if got := bufferDisplayWidth([]byte(c.in)); got != c.want { + t.Errorf("bufferDisplayWidth(%q) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestBufferDisplayWidthSkipsInvalidUTF8(t *testing.T) { + // A lone continuation byte is invalid UTF-8; it must be skipped, not counted. + bad := []byte{'a', 0xFF, 'b'} + if got := bufferDisplayWidth(bad); got != 2 { + t.Errorf("bufferDisplayWidth(invalid) = %d, want 2", got) + } +} + +func TestRuneDisplayWidth(t *testing.T) { + cases := []struct { + r rune + want int + }{ + {'a', 1}, + {'你', 2}, + {'€', 2}, // East-Asian wide per runewidth + {0x00, 1}, + } + for _, c := range cases { + if got := runeDisplayWidth(c.r); got != c.want { + t.Errorf("runeDisplayWidth(%q) = %d, want %d", c.r, got, c.want) + } + } +} + +func TestKeyMatchers(t *testing.T) { + // Left / Right (CSI and application-mode variants) + if !matchesKey([]byte{term.ANSI_ESC, '[', 'D'}, term.AnsiMoveCursorBackward) { + t.Error("ESC[D should match left arrow") + } + if !matchesKey([]byte{term.ANSI_ESC, 'O', 'D'}, term.AnsiKeyLeftApp) { + t.Error("ESC O D should match app-mode left arrow") + } + if !matchesKey([]byte{term.ANSI_ESC, '[', 'C'}, term.AnsiMoveCursorForward) { + t.Error("ESC[C should match right arrow") + } + + // Home / End variants across terminals + homeSeqs := [][]byte{ + {term.ANSI_ESC, '[', 'H'}, // ESC[H + {term.ANSI_ESC, '[', '1', '~'}, // ESC[1~ + {term.ANSI_ESC, 'O', 'H'}, // ESC OH + } + for _, s := range homeSeqs { + if !isHomeKey(s) { + t.Errorf("expected Home key match for %v", s) + } + } + endSeqs := [][]byte{ + {term.ANSI_ESC, '[', 'F'}, + {term.ANSI_ESC, '[', '4', '~'}, + {term.ANSI_ESC, 'O', 'F'}, + } + for _, s := range endSeqs { + if !isEndKey(s) { + t.Errorf("expected End key match for %v", s) + } + } + + // Delete + if !matchesKey([]byte{term.ANSI_ESC, '[', '3', '~'}, term.AnsiKeyDelete) { + t.Error("ESC[3~ should match Delete") + } +} + +func TestClampCursor(t *testing.T) { + ci := &connections.ClientInput{Buffer: []byte("hello")} + ci.Cursor = 99 + clampCursor(ci) + if ci.Cursor != 5 { + t.Errorf("expected cursor clamped to 5, got %d", ci.Cursor) + } + ci.Cursor = -3 + clampCursor(ci) + if ci.Cursor != 0 { + t.Errorf("expected cursor clamped to 0, got %d", ci.Cursor) + } +} + +// TestCleanserInsertAtCursor verifies that typed text is inserted at the logical +// cursor rather than always appended, enabling mid-line edits. +func TestCleanserInsertAtCursor(t *testing.T) { + // Buffer "hello", cursor in the middle (after "he"). Typing "X" should + // produce "heXllo" with the cursor after the X. + buf := []byte("hello") + clientInput := &connections.ClientInput{ + ConnectionId: 1, + DataIn: []byte("X"), + Buffer: buf, + Cursor: 2, + } + CleanserInputHandler(clientInput, make(map[string]any)) + + if got := string(clientInput.Buffer); got != "heXllo" { + t.Errorf("buffer = %q, want %q", got, "heXllo") + } + if clientInput.Cursor != 3 { + t.Errorf("cursor = %d, want 3", clientInput.Cursor) + } +} + +// TestCleanserBackspaceRemovesFullCJKRune checks that a single backspace +// deletes an entire multi-byte CJK character (3 bytes) from the buffer. +func TestCleanserBackspaceRemovesFullCJKRune(t *testing.T) { + buf := []byte("你好") + clientInput := &connections.ClientInput{ + ConnectionId: 1, + DataIn: []byte{term.ASCII_BACKSPACE}, + Buffer: buf, + Cursor: len(buf), + } + CleanserInputHandler(clientInput, make(map[string]any)) + + if got := string(clientInput.Buffer); got != "你" { + t.Errorf("buffer = %q, want %q", got, "你") + } + if clientInput.Cursor != 3 { // "你" is 3 bytes + t.Errorf("cursor = %d, want 3", clientInput.Cursor) + } + if !utf8.Valid(clientInput.Buffer) { + t.Errorf("buffer is not valid UTF-8: %v", clientInput.Buffer) + } +} + +// TestCleanserBackspaceMidBuffer checks that backspace works when the cursor is +// not at the end of the buffer (removes the rune before the cursor). +func TestCleanserBackspaceMidBuffer(t *testing.T) { + // "heXllo", cursor at index 3 (after X). Backspace removes X -> "hello". + clientInput := &connections.ClientInput{ + ConnectionId: 1, + DataIn: []byte{term.ASCII_BACKSPACE}, + Buffer: []byte("heXllo"), + Cursor: 3, + } + CleanserInputHandler(clientInput, make(map[string]any)) + + if got := string(clientInput.Buffer); got != "hello" { + t.Errorf("buffer = %q, want %q", got, "hello") + } + if clientInput.Cursor != 2 { + t.Errorf("cursor = %d, want 2", clientInput.Cursor) + } +} diff --git a/internal/inputhandlers/login_prompt_handler.go b/internal/inputhandlers/login_prompt_handler.go index 4b287e8aa..716014892 100644 --- a/internal/inputhandlers/login_prompt_handler.go +++ b/internal/inputhandlers/login_prompt_handler.go @@ -276,6 +276,7 @@ func CreatePromptHandler(steps []*PromptStep, onComplete CompletionFunc) connect } submittedInput := strings.TrimSpace(string(clientInput.Buffer)) clientInput.Buffer = clientInput.Buffer[:0] // Clear buffer for next input + clientInput.Cursor = 0 // Reset cursor for next input state.maskTemplate = "" // Clear cached mask template // Validation diff --git a/internal/inputhandlers/signals.go b/internal/inputhandlers/signals.go index 03d1aab4a..2d0c48070 100644 --- a/internal/inputhandlers/signals.go +++ b/internal/inputhandlers/signals.go @@ -42,6 +42,7 @@ func SignalHandler(clientInput *connections.ClientInput, sharedState map[string] if clientInput.DataIn[len(clientInput.DataIn)-1] == CtrlQ { clientInput.DataIn = []byte("/quit") clientInput.Buffer = []byte{} + clientInput.Cursor = 0 clientInput.EnterPressed = true return true } @@ -49,6 +50,7 @@ func SignalHandler(clientInput *connections.ClientInput, sharedState map[string] if clientInput.DataIn[len(clientInput.DataIn)-1] == CtrlW { clientInput.DataIn = []byte("/who") clientInput.Buffer = []byte{} + clientInput.Cursor = 0 clientInput.EnterPressed = true return true } @@ -56,6 +58,7 @@ func SignalHandler(clientInput *connections.ClientInput, sharedState map[string] if clientInput.DataIn[len(clientInput.DataIn)-1] == CtrlX { clientInput.DataIn = []byte("/shutdown 0") clientInput.Buffer = []byte{} + clientInput.Cursor = 0 clientInput.EnterPressed = true return true } @@ -66,6 +69,7 @@ func SignalHandler(clientInput *connections.ClientInput, sharedState map[string] copy(clientInput.DataIn, clientInput.Clipboard) clientInput.Buffer = []byte{} + clientInput.Cursor = 0 clientInput.EnterPressed = false return true } diff --git a/internal/inputhandlers/systemcommands.go b/internal/inputhandlers/systemcommands.go index 095ed8e4a..a482eb98f 100644 --- a/internal/inputhandlers/systemcommands.go +++ b/internal/inputhandlers/systemcommands.go @@ -38,6 +38,7 @@ func SystemCommandInputHandler(clientInput *connections.ClientInput, sharedState if trySystemCommand(message, clientInput.ConnectionId) { // zero out the current buffer clientInput.Buffer = clientInput.Buffer[:0] + clientInput.Cursor = 0 return false } diff --git a/internal/inputhandlers/term_ansi.go b/internal/inputhandlers/term_ansi.go index 8f7b711b1..ae5656fa4 100644 --- a/internal/inputhandlers/term_ansi.go +++ b/internal/inputhandlers/term_ansi.go @@ -1,6 +1,8 @@ package inputhandlers import ( + "unicode/utf8" + "github.com/GoMudEngine/GoMud/internal/connections" "github.com/GoMudEngine/GoMud/internal/mudlog" "github.com/GoMudEngine/GoMud/internal/term" @@ -13,6 +15,12 @@ func AnsiHandler(clientInput *connections.ClientInput, sharedState map[string]an return true } + serverEcho := !isLocalEchoConn(clientInput.ConnectionId) + masked := isMaskedPrompt(sharedState) + // Cursor editing (arrows / home / end / delete) is only meaningful for + // server-side-echo, non-masked input. Masked (password) fields stay linear. + cursorEditAllowed := serverEcho && !masked + // Multiple Ansi Commands's can be stacked into one send, so useful to split them out ansiCmds := [][]byte{} @@ -95,32 +103,21 @@ func AnsiHandler(clientInput *connections.ClientInput, sharedState map[string]an if ok, _ := term.Matches(ansiCmds, term.AnsiMoveCursorUp); ok { mudlog.Debug("Received", "type", "ANSI (MoveCursorUp)", "currentInput", string(clientInput.Buffer), "LastSubmitted", string(clientInput.LastSubmitted)) - // For each character in the buffer, backspace it out - // Then add whatever was last submitted + // Replace the current input line with the previous history entry. + // Clear by display width (not byte count) so wide characters erase + // correctly, then feed the recalled entry back through CleanserInputHandler + // so it is re-inserted and echoed. clientInput.DataIn = []byte{} - bsSequence := []byte{} - spaceSequence := []byte{} - for i := 0; i < len(clientInput.Buffer); i++ { - bsSequence = append(bsSequence, term.ASCII_BACKSPACE) - spaceSequence = append(spaceSequence, term.ASCII_SPACE) - } - - mudlog.Debug("Received", "type", "ANSI (MoveCursorUp)", "bsSequence", len(bsSequence), "spaceSequence", len(spaceSequence)) - - connections.SendTo(bsSequence, clientInput.ConnectionId) - connections.SendTo(spaceSequence, clientInput.ConnectionId) - connections.SendTo(bsSequence, clientInput.ConnectionId) + clearInputLineDisplay(clientInput) clientInput.History.Previous() historicInput := clientInput.History.Get() clientInput.DataIn = make([]byte, len(historicInput)) copy(clientInput.DataIn, historicInput) - //clientInput.DataIn = make([]byte, len(clientInput.LastSubmitted)) - //copy(clientInput.DataIn, clientInput.LastSubmitted) - clientInput.Buffer = []byte{} + clientInput.Cursor = 0 clientInput.EnterPressed = false nextHandler = true continue @@ -129,37 +126,90 @@ func AnsiHandler(clientInput *connections.ClientInput, sharedState map[string]an if ok, _ := term.Matches(ansiCmds, term.AnsiMoveCursorDown); ok { mudlog.Debug("Received", "type", "ANSI (MoveCursorDown)", "currentInput", string(clientInput.Buffer), "LastSubmitted", string(clientInput.LastSubmitted)) - // For each character in the buffer, backspace it out - // Then add whatever was last submitted + // Replace the current input line with the next history entry. clientInput.DataIn = []byte{} - bsSequence := []byte{} - spaceSequence := []byte{} - for i := 0; i < len(clientInput.Buffer); i++ { - bsSequence = append(bsSequence, term.ASCII_BACKSPACE) - spaceSequence = append(spaceSequence, term.ASCII_SPACE) - } - - mudlog.Debug("Received", "type", "ANSI (MoveCursorUp)", "bsSequence", len(bsSequence), "spaceSequence", len(spaceSequence)) - - connections.SendTo(bsSequence, clientInput.ConnectionId) - connections.SendTo(spaceSequence, clientInput.ConnectionId) - connections.SendTo(bsSequence, clientInput.ConnectionId) + clearInputLineDisplay(clientInput) clientInput.History.Next() historicInput := clientInput.History.Get() clientInput.DataIn = make([]byte, len(historicInput)) copy(clientInput.DataIn, historicInput) - //clientInput.DataIn = make([]byte, len(clientInput.LastSubmitted)) - //copy(clientInput.DataIn, clientInput.LastSubmitted) - clientInput.Buffer = []byte{} + clientInput.Cursor = 0 clientInput.EnterPressed = false nextHandler = true continue } + // --- Cursor-editing keys (Left / Right / Home / End / Delete) --- + // These move or edit at the logical cursor within the current input line. + // They are no-ops for masked prompts and for local-echo clients. + if cursorEditAllowed { + + // Left arrow (ESC [ D, or ESC O D in application cursor mode) + if matchesKey(ansiCmds, term.AnsiMoveCursorBackward) || matchesKey(ansiCmds, term.AnsiKeyLeftApp) { + clientInput.DataIn = []byte{} + if clientInput.Cursor > 0 { + if r, size := utf8.DecodeLastRune(clientInput.Buffer[:clientInput.Cursor]); size > 0 { + clientInput.Cursor -= size + connections.SendTo(cursorBackwardN(runeDisplayWidth(r)), clientInput.ConnectionId) + } + } + nextHandler = true + continue + } + + // Right arrow (ESC [ C, or ESC O C) + if matchesKey(ansiCmds, term.AnsiMoveCursorForward) || matchesKey(ansiCmds, term.AnsiKeyRightApp) { + clientInput.DataIn = []byte{} + if clientInput.Cursor < len(clientInput.Buffer) { + if r, size := utf8.DecodeRune(clientInput.Buffer[clientInput.Cursor:]); size > 0 { + clientInput.Cursor += size + connections.SendTo(cursorForwardN(runeDisplayWidth(r)), clientInput.ConnectionId) + } + } + nextHandler = true + continue + } + + // Home: move cursor to the start of the input. + if isHomeKey(ansiCmds) { + clientInput.DataIn = []byte{} + connections.SendTo(cursorBackwardN(bufferDisplayWidth(clientInput.Buffer[:clientInput.Cursor])), clientInput.ConnectionId) + clientInput.Cursor = 0 + nextHandler = true + continue + } + + // End: move cursor to the end of the input. + if isEndKey(ansiCmds) { + clientInput.DataIn = []byte{} + connections.SendTo(cursorForwardN(bufferDisplayWidth(clientInput.Buffer[clientInput.Cursor:])), clientInput.ConnectionId) + clientInput.Cursor = len(clientInput.Buffer) + nextHandler = true + continue + } + + // Delete (forward): remove the rune at the cursor, redraw the tail. + if ok, _ := term.Matches(ansiCmds, term.AnsiKeyDelete); ok { + clientInput.DataIn = []byte{} + if clientInput.Cursor < len(clientInput.Buffer) { + if _, size := utf8.DecodeRune(clientInput.Buffer[clientInput.Cursor:]); size > 0 { + clientInput.Buffer = append(clientInput.Buffer[:clientInput.Cursor], clientInput.Buffer[clientInput.Cursor+size:]...) + // Cursor position is unchanged; redraw from it to the end. + tail := clientInput.Buffer[clientInput.Cursor:] + connections.SendTo(tail, clientInput.ConnectionId) + connections.SendTo([]byte(term.AnsiEraseLineForward.String()), clientInput.ConnectionId) + connections.SendTo(cursorBackwardN(bufferDisplayWidth(tail)), clientInput.ConnectionId) + } + } + nextHandler = true + continue + } + } + isF1, _ := term.Matches(ansiCmds, term.AnsiF1) if !isF1 { // check for Alternate F1 isF1, _ = term.Matches(ansiCmds, term.AnsiF1b) diff --git a/internal/term/term.go b/internal/term/term.go index 50b304f79..5d378d8fd 100644 --- a/internal/term/term.go +++ b/internal/term/term.go @@ -199,6 +199,21 @@ var ( AnsiF3b = TerminalCommand{[]byte{ANSI_ESC, 'O'}, []byte{'R'}} // macos terminal telnet AnsiF4b = TerminalCommand{[]byte{ANSI_ESC, 'O'}, []byte{'S'}} // macos terminal telnet + /////////////////////////// + // EDITING KEYS + /////////////////////////// + // These arrive from the client when the user presses cursor-editing keys. + // Left/Right reuse the cursor-motion sequences (ESC [ C / ESC [ D). + AnsiKeyLeftApp = TerminalCommand{[]byte{ANSI_ESC, 'O'}, []byte{'D'}} // ESC O D (application cursor mode) + AnsiKeyRightApp = TerminalCommand{[]byte{ANSI_ESC, 'O'}, []byte{'C'}} // ESC O C (application cursor mode) + AnsiKeyHomeCSI = TerminalCommand{[]byte{ANSI_ESC, '['}, []byte{'H'}} // ESC [ H (same bytes as cursor-home) + AnsiKeyEndCSI = TerminalCommand{[]byte{ANSI_ESC, '['}, []byte{'F'}} // ESC [ F + AnsiKeyHomeTilde = TerminalCommand{[]byte{ANSI_ESC, '['}, []byte{'1', '~'}} // ESC [ 1 ~ + AnsiKeyEndTilde = TerminalCommand{[]byte{ANSI_ESC, '['}, []byte{'4', '~'}} // ESC [ 4 ~ + AnsiKeyDelete = TerminalCommand{[]byte{ANSI_ESC, '['}, []byte{'3', '~'}} // ESC [ 3 ~ + AnsiKeyHomeApp = TerminalCommand{[]byte{ANSI_ESC, 'O'}, []byte{'H'}} // ESC O H (xterm app mode) + AnsiKeyEndApp = TerminalCommand{[]byte{ANSI_ESC, 'O'}, []byte{'F'}} // ESC O F (xterm app mode) + // Payload is the window title to set it to AnsiSetWindowTitle = TerminalCommand{[]byte{ANSI_ESC, ']', '2', ';'}, []byte{'S', 'T'}} diff --git a/main.go b/main.go index 7e35190af..5721bad40 100644 --- a/main.go +++ b/main.go @@ -567,6 +567,9 @@ func resumeRestoredConnection(connDetails *connections.ConnectionDetails, userOb } if redrawPrompt { + // The prompt redraw below repositions the terminal cursor at the + // end of the input, so resync the logical cursor to match. + clientInput.Cursor = len(clientInput.Buffer) pTxt := userObject.GetCommandPrompt() connections.SendTo([]byte(templates.AnsiParse(pTxt)), clientInput.ConnectionId) } @@ -947,6 +950,9 @@ func handleTelnetConnection(connDetails *connections.ConnectionDetails, wg *sync } if redrawPrompt { + // The prompt redraw below repositions the terminal cursor at the + // end of the input, so resync the logical cursor to match. + clientInput.Cursor = len(clientInput.Buffer) pTxt := userObject.GetCommandPrompt() if connections.IsWebsocket(clientInput.ConnectionId) { connections.SendTo([]byte(pTxt), clientInput.ConnectionId) @@ -1629,6 +1635,9 @@ func handleSSHConnection(connDetails *connections.ConnectionDetails, reqs <-chan } if redrawPrompt { + // The prompt redraw below repositions the terminal cursor at the + // end of the input, so resync the logical cursor to match. + clientInput.Cursor = len(clientInput.Buffer) pTxt := userObject.GetCommandPrompt() connections.SendTo([]byte(templates.AnsiParse(pTxt)), clientInput.ConnectionId) }