diff --git a/README.md b/README.md index 66158cb..5b368bf 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ tcprcon-cli --profile="my_server" --port=27015 ## Protocol Compliance -While `tcprcon-cli` follows the standard Source RCON Protocol, some game servers (like Rust) have non-standard implementations that might cause unexpected behavior, such as duplicated responses or incorrect packet IDs, the cli should still work, you might just have to deal with an overly chatty server. +While `tcprcon-cli` follows the standard Source RCON Protocol, some game servers (like Rust) have non-standard implementations that might introduce unexpected behaviors, such as duplicated responses or incorrect packet IDs, the cli should still work, you might just have to deal with an overly chatty server. For a detailed breakdown of known server quirks and how they are handled, see the [Caveats section in the core library documentation](https://github.com/UltimateForm/tcprcon#caveats). diff --git a/internal/ansi/constants.go b/internal/ansi/constants.go index 3cc812a..05f5528 100644 --- a/internal/ansi/constants.go +++ b/internal/ansi/constants.go @@ -1,6 +1,10 @@ package ansi const ( + ArrowKeyUp = "\033[A" + ArrowKeyDown = "\033[B" + PageUpKey = "\033[5~" + PageDownKey = "\033[6~" ClearScreen = "\033[2J" CursorHome = "\033[H" CursorToPos = "\033[%d;%dH" // use with fmt.Sprintf, the two ds are for the row and column coordinates diff --git a/internal/fullterm/app.go b/internal/fullterm/app.go index 97b7424..dea5c6c 100644 --- a/internal/fullterm/app.go +++ b/internal/fullterm/app.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/signal" + "strings" "sync" "syscall" @@ -20,10 +21,13 @@ type app struct { stdinChannel chan byte fd int prevState *term.State - cmdLine []byte content []string commandSignature string once sync.Once + ansiMachine stdinAnsi + history [][]byte + historyCursor int + scrollOffset int } func (src *app) Write(bytes []byte) (int, error) { @@ -47,15 +51,29 @@ func (src *app) ListenStdin(context context.Context) { } } } + +func (src *app) setHistoryTail(b []byte) { + src.history[len(src.history)-1] = b +} + +func (src *app) historyTail() []byte { + return src.history[len(src.history)-1] +} + +func (src *app) currentCmd() []byte { + return src.history[src.historyCursor] +} + func (src *app) Submissions() <-chan string { return src.submissionChan } -func visibleContent(content []string, height int) []string { +func visibleContent(content []string, height int, scrollOffset int) []string { currentRows := len(content) + endRow := max(currentRows-scrollOffset, 0) // ngl i forgot why we adding plus 1.. oh well - startRow := max(currentRows-(height+1), 0) - return content[startRow:] + startRow := max(endRow-(height+1), 0) + return content[startRow:endRow] } func formatCommandEcho(cmd string) string { @@ -70,7 +88,7 @@ func (src *app) DrawContent(finalDraw bool) error { if !finalDraw { fmt.Print(ansi.ClearScreen + ansi.CursorHome) } - drawableRows := visibleContent(src.content, height) + drawableRows := visibleContent(src.content, height, src.scrollOffset) for i := range drawableRows { fmt.Print(drawableRows[i]) } @@ -79,11 +97,18 @@ func (src *app) DrawContent(finalDraw bool) error { return nil } ansi.MoveCursorTo(height, 0) + if src.scrollOffset > 0 { + fmt.Print(ansi.Format(fmt.Sprintf("[↑ %d] ", src.scrollOffset), ansi.Yellow, ansi.Bold)) + } fmt.Printf(ansi.Format("%v> ", ansi.Blue), src.commandSignature) - fmt.Print(string(src.cmdLine)) + fmt.Print(string(src.currentCmd())) return nil } +func (src *app) traverseHistory(delta int) { + src.historyCursor = clamp(0, src.historyCursor+delta, len(src.history)-1) +} + func (src *app) Run(context context.Context) error { // this could be an argument but i aint feeling yet @@ -122,19 +147,57 @@ func (src *app) Run(context context.Context) error { case <-context.Done(): return nil case newStdinInput := <-src.stdinChannel: - newCmd, isSubmission := constructCmdLine(newStdinInput, src.cmdLine) - if isSubmission { - src.content = append(src.content, formatCommandEcho(string(newCmd))) - src.cmdLine = []byte{} - src.submissionChan <- string(newCmd) - } else { - src.cmdLine = newCmd + ansiSeq, ansiState := src.ansiMachine.handle(newStdinInput) + // src.content = append(src.content, fmt.Sprintf("ansi machine handling %v, at state %v\n", ansiSeq, ansiState)) + switch ansiState { + case ansiStateIdle: + // no ansi sequence ongoing so its just presentation bytes + newCmd, isSubmission := constructCmdLine(newStdinInput, src.currentCmd()) + if isSubmission { + src.content = append(src.content, formatCommandEcho(string(newCmd))) + if len(src.historyTail()) > 0 { + src.history = append(src.history, []byte{}) + } + src.scrollOffset = 0 + src.submissionChan <- string(newCmd) + } else { + src.setHistoryTail(newCmd) + } + // feel a bit awkward doing this every input stroke, we can get back to it later + src.historyCursor = len(src.history) - 1 + case ansiStateCSITerm: + switch string(ansiSeq) { + case ansi.ArrowKeyUp: + src.traverseHistory(-1) + case ansi.ArrowKeyDown: + src.traverseHistory(1) + case ansi.PageUpKey: + if _, h, err := term.GetSize(src.fd); err == nil { + maxOffset := max(len(src.content)-(h+1), 0) + // substract one cuz we need to account for persistent command line + src.scrollOffset = min(src.scrollOffset+h-1, maxOffset) + } else { + // TODO: do something here idk + } + case ansi.PageDownKey: + if _, h, err := term.GetSize(src.fd); err == nil { + // ditto subtraction + src.scrollOffset = max(src.scrollOffset-(h-1), 0) + } + default: + // src.content = append(src.content, fmt.Sprintf("unhandled csi %v, %v\n", strconv.Itoa(int(ansiState)), ansiSeq)) + } + default: + // src.content = append(src.content, fmt.Sprintf("unhandled state %v, %v\n", strconv.Itoa(int(ansiState)), ansiSeq)) } + if err := src.DrawContent(false); err != nil { return err } case newDisplayInput := <-src.DisplayChannel: - src.content = append(src.content, newDisplayInput) + for line := range strings.Lines(newDisplayInput) { + src.content = append(src.content, line) + } if err := src.DrawContent(false); err != nil { return err } @@ -164,5 +227,8 @@ func CreateApp(commandSignature string) *app { submissionChan: submissionChan, content: make([]string, 0), commandSignature: commandSignature, + ansiMachine: newStdinAnsi(), + history: [][]byte{[]byte{}}, + historyCursor: 0, } } diff --git a/internal/fullterm/app_test.go b/internal/fullterm/app_test.go index cbbc101..114a2e0 100644 --- a/internal/fullterm/app_test.go +++ b/internal/fullterm/app_test.go @@ -6,7 +6,7 @@ import ( func TestVisibleContentShorterThanWindow(t *testing.T) { content := []string{"line1\n", "line2\n", "line3\n"} - result := visibleContent(content, 10) + result := visibleContent(content, 10, 0) if len(result) != len(content) { t.Fatalf("expected %d rows, got %d", len(content), len(result)) } @@ -20,7 +20,7 @@ func TestVisibleContentShorterThanWindow(t *testing.T) { func TestVisibleContentExactlyFitsWindow(t *testing.T) { content := []string{"line1\n", "line2\n", "line3\n"} // height+1 == len(content), so startRow == 0 - result := visibleContent(content, len(content)-1) + result := visibleContent(content, len(content)-1, 0) if len(result) != len(content) { t.Fatalf("expected %d rows, got %d", len(content), len(result)) } @@ -29,7 +29,7 @@ func TestVisibleContentExactlyFitsWindow(t *testing.T) { func TestVisibleContentOverflowsWindow(t *testing.T) { content := []string{"line1\n", "line2\n", "line3\n", "line4\n", "line5\n"} height := 2 - result := visibleContent(content, height) + result := visibleContent(content, height, 0) // startRow = max(5 - 3, 0) = 2, so rows 2,3,4 expectedLen := height + 1 if len(result) != expectedLen { @@ -44,7 +44,7 @@ func TestVisibleContentOverflowsWindow(t *testing.T) { } func TestVisibleContentEmpty(t *testing.T) { - result := visibleContent([]string{}, 10) + result := visibleContent([]string{}, 10, 0) if len(result) != 0 { t.Fatalf("expected empty result, got %d rows", len(result)) } @@ -52,7 +52,7 @@ func TestVisibleContentEmpty(t *testing.T) { func TestVisibleContentZeroHeight(t *testing.T) { content := []string{"line1\n", "line2\n", "line3\n"} - result := visibleContent(content, 0) + result := visibleContent(content, 0, 0) // startRow = max(3 - 1, 0) = 2, so only last row if len(result) != 1 { t.Fatalf("expected 1 row, got %d", len(result)) @@ -61,3 +61,42 @@ func TestVisibleContentZeroHeight(t *testing.T) { t.Errorf("expected 'line3\\n', got %q", result[0]) } } + +func TestVisibleContentScrolled(t *testing.T) { + content := []string{"line1\n", "line2\n", "line3\n", "line4\n", "line5\n"} + height := 2 + // scrollOffset=2: endRow = 5-2 = 3, startRow = max(3-3, 0) = 0, so rows 0,1,2 + result := visibleContent(content, height, 2) + expectedLen := height + 1 + if len(result) != expectedLen { + t.Fatalf("expected %d rows, got %d", expectedLen, len(result)) + } + if result[0] != "line1\n" { + t.Errorf("expected first visible row to be 'line1\\n', got %q", result[0]) + } + if result[len(result)-1] != "line3\n" { + t.Errorf("expected last visible row to be 'line3\\n', got %q", result[len(result)-1]) + } +} + +func TestVisibleContentScrollOffsetPastTop(t *testing.T) { + content := []string{"line1\n", "line2\n", "line3\n"} + // scrollOffset larger than content — should return empty, not panic + result := visibleContent(content, 2, 100) + if len(result) != 0 { + t.Fatalf("expected empty result when scrolled past top, got %d rows", len(result)) + } +} + +func TestVisibleContentScrolledPartial(t *testing.T) { + content := []string{"line1\n", "line2\n", "line3\n", "line4\n", "line5\n"} + height := 10 + // scrollOffset=1: endRow = 4, startRow = max(4-11, 0) = 0, all 4 rows visible + result := visibleContent(content, height, 1) + if len(result) != 4 { + t.Fatalf("expected 4 rows, got %d", len(result)) + } + if result[len(result)-1] != "line4\n" { + t.Errorf("expected last row to be 'line4\\n', got %q", result[len(result)-1]) + } +} diff --git a/internal/fullterm/stdin_ansi.go b/internal/fullterm/stdin_ansi.go new file mode 100644 index 0000000..b2ffee1 --- /dev/null +++ b/internal/fullterm/stdin_ansi.go @@ -0,0 +1,50 @@ +package fullterm + +type ansiState int + +const ( + ansiStateIdle ansiState = iota + ansiStateEscape + ansiStateCSI + ansiStateCSITerm +) + +type stdinAnsi struct { + buf []byte + state ansiState +} + +func newStdinAnsi() stdinAnsi { + return stdinAnsi{ + buf: make([]byte, 0, 8), + state: ansiStateIdle, + } +} + +func (src *stdinAnsi) reset() { + src.buf = make([]byte, 0, 8) + src.state = ansiStateIdle +} + +func (src *stdinAnsi) handle(b byte) ([]byte, ansiState) { + switch { + case b == 27: + src.reset() + src.buf = append(src.buf, b) + src.state = ansiStateEscape + case b == 91 && src.state == ansiStateEscape: + src.buf = append(src.buf, b) + src.state = ansiStateCSI + case src.state == ansiStateCSI: + src.buf = append(src.buf, b) + if b > 63 && b < 127 { + defer func() { + src.state = ansiStateIdle + }() + src.state = ansiStateCSITerm + } + default: + src.reset() + } + return src.buf, src.state +} diff --git a/internal/fullterm/util.go b/internal/fullterm/util.go index c00e432..3589619 100644 --- a/internal/fullterm/util.go +++ b/internal/fullterm/util.go @@ -15,3 +15,7 @@ func constructCmdLine(newByte byte, cmdLine []byte) ([]byte, bool) { } return cmdLine, isSubmission } + +func clamp(minBound int, n int, maxBound int) int { + return max(minBound, min(maxBound, n)) +} diff --git a/internal/fullterm/util_test.go b/internal/fullterm/util_test.go index 57fdb89..394ea62 100644 --- a/internal/fullterm/util_test.go +++ b/internal/fullterm/util_test.go @@ -73,3 +73,33 @@ func TestConstructCmdLineSubmissionLF(t *testing.T) { t.Errorf("expected 'test', got '%s'", newLine) } } + +func TestClamp(t *testing.T) { + minBound := 4 + maxBound := 23 + n := 11 + r := clamp(minBound, n, maxBound) + if r != n { + t.Fatalf("expect r to equal %v but instead it is %v", n, r) + } +} + +func TestClampGreaterThanMax(t *testing.T) { + minBound := 2 + maxBound := 5 + n := 12 + r := clamp(minBound, n, maxBound) + if r != maxBound { + t.Fatalf("expect r to equal %v but instead it is %v", maxBound, r) + } +} + +func TestClampLesserThanMin(t *testing.T) { + minBound := 202 + maxBound := 582 + n := 105 + r := clamp(minBound, n, maxBound) + if r != minBound { + t.Fatalf("expect r to equal %v but instead it is %v", minBound, r) + } +}