Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/connections/connectiondetails.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,15 @@ 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
}

// Reset the client input to essentially "No current input"
func (ci *ClientInput) Reset() {
ci.DataIn = ci.DataIn[:0]
ci.Buffer = ci.Buffer[:0]
ci.Cursor = 0
ci.EnterPressed = false
}

Expand Down
1 change: 1 addition & 0 deletions internal/inputhandlers/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
137 changes: 137 additions & 0 deletions internal/inputhandlers/capture_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
102 changes: 56 additions & 46 deletions internal/inputhandlers/cleanser.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package inputhandlers

import (
"slices"
"strings"
"unicode"
"unicode/utf8"
Expand All @@ -10,84 +11,93 @@ 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
}
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
}
2 changes: 2 additions & 0 deletions internal/inputhandlers/cleanser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading