Skip to content

Commit 93bad7b

Browse files
authored
feat: handling stdin ansi sexies for better ux
* feat: working on handling stdin ansi sexies * feat: add ANSI escape code handling and history * feat: handle scroll * fix: small cleanup
1 parent bf04dd0 commit 93bad7b

File tree

7 files changed

+213
-20
lines changed

7 files changed

+213
-20
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ tcprcon-cli --profile="my_server" --port=27015
161161

162162
## Protocol Compliance
163163

164-
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.
164+
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.
165165

166166
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).
167167

internal/ansi/constants.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package ansi
22

33
const (
4+
ArrowKeyUp = "\033[A"
5+
ArrowKeyDown = "\033[B"
6+
PageUpKey = "\033[5~"
7+
PageDownKey = "\033[6~"
48
ClearScreen = "\033[2J"
59
CursorHome = "\033[H"
610
CursorToPos = "\033[%d;%dH" // use with fmt.Sprintf, the two ds are for the row and column coordinates

internal/fullterm/app.go

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"os"
88
"os/signal"
9+
"strings"
910
"sync"
1011
"syscall"
1112

@@ -20,10 +21,13 @@ type app struct {
2021
stdinChannel chan byte
2122
fd int
2223
prevState *term.State
23-
cmdLine []byte
2424
content []string
2525
commandSignature string
2626
once sync.Once
27+
ansiMachine stdinAnsi
28+
history [][]byte
29+
historyCursor int
30+
scrollOffset int
2731
}
2832

2933
func (src *app) Write(bytes []byte) (int, error) {
@@ -47,15 +51,29 @@ func (src *app) ListenStdin(context context.Context) {
4751
}
4852
}
4953
}
54+
55+
func (src *app) setHistoryTail(b []byte) {
56+
src.history[len(src.history)-1] = b
57+
}
58+
59+
func (src *app) historyTail() []byte {
60+
return src.history[len(src.history)-1]
61+
}
62+
63+
func (src *app) currentCmd() []byte {
64+
return src.history[src.historyCursor]
65+
}
66+
5067
func (src *app) Submissions() <-chan string {
5168
return src.submissionChan
5269
}
5370

54-
func visibleContent(content []string, height int) []string {
71+
func visibleContent(content []string, height int, scrollOffset int) []string {
5572
currentRows := len(content)
73+
endRow := max(currentRows-scrollOffset, 0)
5674
// ngl i forgot why we adding plus 1.. oh well
57-
startRow := max(currentRows-(height+1), 0)
58-
return content[startRow:]
75+
startRow := max(endRow-(height+1), 0)
76+
return content[startRow:endRow]
5977
}
6078

6179
func formatCommandEcho(cmd string) string {
@@ -70,7 +88,7 @@ func (src *app) DrawContent(finalDraw bool) error {
7088
if !finalDraw {
7189
fmt.Print(ansi.ClearScreen + ansi.CursorHome)
7290
}
73-
drawableRows := visibleContent(src.content, height)
91+
drawableRows := visibleContent(src.content, height, src.scrollOffset)
7492
for i := range drawableRows {
7593
fmt.Print(drawableRows[i])
7694
}
@@ -79,11 +97,18 @@ func (src *app) DrawContent(finalDraw bool) error {
7997
return nil
8098
}
8199
ansi.MoveCursorTo(height, 0)
100+
if src.scrollOffset > 0 {
101+
fmt.Print(ansi.Format(fmt.Sprintf("[↑ %d] ", src.scrollOffset), ansi.Yellow, ansi.Bold))
102+
}
82103
fmt.Printf(ansi.Format("%v> ", ansi.Blue), src.commandSignature)
83-
fmt.Print(string(src.cmdLine))
104+
fmt.Print(string(src.currentCmd()))
84105
return nil
85106
}
86107

108+
func (src *app) traverseHistory(delta int) {
109+
src.historyCursor = clamp(0, src.historyCursor+delta, len(src.history)-1)
110+
}
111+
87112
func (src *app) Run(context context.Context) error {
88113

89114
// this could be an argument but i aint feeling yet
@@ -122,19 +147,57 @@ func (src *app) Run(context context.Context) error {
122147
case <-context.Done():
123148
return nil
124149
case newStdinInput := <-src.stdinChannel:
125-
newCmd, isSubmission := constructCmdLine(newStdinInput, src.cmdLine)
126-
if isSubmission {
127-
src.content = append(src.content, formatCommandEcho(string(newCmd)))
128-
src.cmdLine = []byte{}
129-
src.submissionChan <- string(newCmd)
130-
} else {
131-
src.cmdLine = newCmd
150+
ansiSeq, ansiState := src.ansiMachine.handle(newStdinInput)
151+
// src.content = append(src.content, fmt.Sprintf("ansi machine handling %v, at state %v\n", ansiSeq, ansiState))
152+
switch ansiState {
153+
case ansiStateIdle:
154+
// no ansi sequence ongoing so its just presentation bytes
155+
newCmd, isSubmission := constructCmdLine(newStdinInput, src.currentCmd())
156+
if isSubmission {
157+
src.content = append(src.content, formatCommandEcho(string(newCmd)))
158+
if len(src.historyTail()) > 0 {
159+
src.history = append(src.history, []byte{})
160+
}
161+
src.scrollOffset = 0
162+
src.submissionChan <- string(newCmd)
163+
} else {
164+
src.setHistoryTail(newCmd)
165+
}
166+
// feel a bit awkward doing this every input stroke, we can get back to it later
167+
src.historyCursor = len(src.history) - 1
168+
case ansiStateCSITerm:
169+
switch string(ansiSeq) {
170+
case ansi.ArrowKeyUp:
171+
src.traverseHistory(-1)
172+
case ansi.ArrowKeyDown:
173+
src.traverseHistory(1)
174+
case ansi.PageUpKey:
175+
if _, h, err := term.GetSize(src.fd); err == nil {
176+
maxOffset := max(len(src.content)-(h+1), 0)
177+
// substract one cuz we need to account for persistent command line
178+
src.scrollOffset = min(src.scrollOffset+h-1, maxOffset)
179+
} else {
180+
// TODO: do something here idk
181+
}
182+
case ansi.PageDownKey:
183+
if _, h, err := term.GetSize(src.fd); err == nil {
184+
// ditto subtraction
185+
src.scrollOffset = max(src.scrollOffset-(h-1), 0)
186+
}
187+
default:
188+
// src.content = append(src.content, fmt.Sprintf("unhandled csi %v, %v\n", strconv.Itoa(int(ansiState)), ansiSeq))
189+
}
190+
default:
191+
// src.content = append(src.content, fmt.Sprintf("unhandled state %v, %v\n", strconv.Itoa(int(ansiState)), ansiSeq))
132192
}
193+
133194
if err := src.DrawContent(false); err != nil {
134195
return err
135196
}
136197
case newDisplayInput := <-src.DisplayChannel:
137-
src.content = append(src.content, newDisplayInput)
198+
for line := range strings.Lines(newDisplayInput) {
199+
src.content = append(src.content, line)
200+
}
138201
if err := src.DrawContent(false); err != nil {
139202
return err
140203
}
@@ -164,5 +227,8 @@ func CreateApp(commandSignature string) *app {
164227
submissionChan: submissionChan,
165228
content: make([]string, 0),
166229
commandSignature: commandSignature,
230+
ansiMachine: newStdinAnsi(),
231+
history: [][]byte{[]byte{}},
232+
historyCursor: 0,
167233
}
168234
}

internal/fullterm/app_test.go

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66

77
func TestVisibleContentShorterThanWindow(t *testing.T) {
88
content := []string{"line1\n", "line2\n", "line3\n"}
9-
result := visibleContent(content, 10)
9+
result := visibleContent(content, 10, 0)
1010
if len(result) != len(content) {
1111
t.Fatalf("expected %d rows, got %d", len(content), len(result))
1212
}
@@ -20,7 +20,7 @@ func TestVisibleContentShorterThanWindow(t *testing.T) {
2020
func TestVisibleContentExactlyFitsWindow(t *testing.T) {
2121
content := []string{"line1\n", "line2\n", "line3\n"}
2222
// height+1 == len(content), so startRow == 0
23-
result := visibleContent(content, len(content)-1)
23+
result := visibleContent(content, len(content)-1, 0)
2424
if len(result) != len(content) {
2525
t.Fatalf("expected %d rows, got %d", len(content), len(result))
2626
}
@@ -29,7 +29,7 @@ func TestVisibleContentExactlyFitsWindow(t *testing.T) {
2929
func TestVisibleContentOverflowsWindow(t *testing.T) {
3030
content := []string{"line1\n", "line2\n", "line3\n", "line4\n", "line5\n"}
3131
height := 2
32-
result := visibleContent(content, height)
32+
result := visibleContent(content, height, 0)
3333
// startRow = max(5 - 3, 0) = 2, so rows 2,3,4
3434
expectedLen := height + 1
3535
if len(result) != expectedLen {
@@ -44,15 +44,15 @@ func TestVisibleContentOverflowsWindow(t *testing.T) {
4444
}
4545

4646
func TestVisibleContentEmpty(t *testing.T) {
47-
result := visibleContent([]string{}, 10)
47+
result := visibleContent([]string{}, 10, 0)
4848
if len(result) != 0 {
4949
t.Fatalf("expected empty result, got %d rows", len(result))
5050
}
5151
}
5252

5353
func TestVisibleContentZeroHeight(t *testing.T) {
5454
content := []string{"line1\n", "line2\n", "line3\n"}
55-
result := visibleContent(content, 0)
55+
result := visibleContent(content, 0, 0)
5656
// startRow = max(3 - 1, 0) = 2, so only last row
5757
if len(result) != 1 {
5858
t.Fatalf("expected 1 row, got %d", len(result))
@@ -61,3 +61,42 @@ func TestVisibleContentZeroHeight(t *testing.T) {
6161
t.Errorf("expected 'line3\\n', got %q", result[0])
6262
}
6363
}
64+
65+
func TestVisibleContentScrolled(t *testing.T) {
66+
content := []string{"line1\n", "line2\n", "line3\n", "line4\n", "line5\n"}
67+
height := 2
68+
// scrollOffset=2: endRow = 5-2 = 3, startRow = max(3-3, 0) = 0, so rows 0,1,2
69+
result := visibleContent(content, height, 2)
70+
expectedLen := height + 1
71+
if len(result) != expectedLen {
72+
t.Fatalf("expected %d rows, got %d", expectedLen, len(result))
73+
}
74+
if result[0] != "line1\n" {
75+
t.Errorf("expected first visible row to be 'line1\\n', got %q", result[0])
76+
}
77+
if result[len(result)-1] != "line3\n" {
78+
t.Errorf("expected last visible row to be 'line3\\n', got %q", result[len(result)-1])
79+
}
80+
}
81+
82+
func TestVisibleContentScrollOffsetPastTop(t *testing.T) {
83+
content := []string{"line1\n", "line2\n", "line3\n"}
84+
// scrollOffset larger than content — should return empty, not panic
85+
result := visibleContent(content, 2, 100)
86+
if len(result) != 0 {
87+
t.Fatalf("expected empty result when scrolled past top, got %d rows", len(result))
88+
}
89+
}
90+
91+
func TestVisibleContentScrolledPartial(t *testing.T) {
92+
content := []string{"line1\n", "line2\n", "line3\n", "line4\n", "line5\n"}
93+
height := 10
94+
// scrollOffset=1: endRow = 4, startRow = max(4-11, 0) = 0, all 4 rows visible
95+
result := visibleContent(content, height, 1)
96+
if len(result) != 4 {
97+
t.Fatalf("expected 4 rows, got %d", len(result))
98+
}
99+
if result[len(result)-1] != "line4\n" {
100+
t.Errorf("expected last row to be 'line4\\n', got %q", result[len(result)-1])
101+
}
102+
}

internal/fullterm/stdin_ansi.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package fullterm
2+
3+
type ansiState int
4+
5+
const (
6+
ansiStateIdle ansiState = iota
7+
ansiStateEscape
8+
ansiStateCSI
9+
ansiStateCSITerm
10+
)
11+
12+
type stdinAnsi struct {
13+
buf []byte
14+
state ansiState
15+
}
16+
17+
func newStdinAnsi() stdinAnsi {
18+
return stdinAnsi{
19+
buf: make([]byte, 0, 8),
20+
state: ansiStateIdle,
21+
}
22+
}
23+
24+
func (src *stdinAnsi) reset() {
25+
src.buf = make([]byte, 0, 8)
26+
src.state = ansiStateIdle
27+
}
28+
29+
func (src *stdinAnsi) handle(b byte) ([]byte, ansiState) {
30+
switch {
31+
case b == 27:
32+
src.reset()
33+
src.buf = append(src.buf, b)
34+
src.state = ansiStateEscape
35+
case b == 91 && src.state == ansiStateEscape:
36+
src.buf = append(src.buf, b)
37+
src.state = ansiStateCSI
38+
case src.state == ansiStateCSI:
39+
src.buf = append(src.buf, b)
40+
if b > 63 && b < 127 {
41+
defer func() {
42+
src.state = ansiStateIdle
43+
}()
44+
src.state = ansiStateCSITerm
45+
}
46+
default:
47+
src.reset()
48+
}
49+
return src.buf, src.state
50+
}

internal/fullterm/util.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@ func constructCmdLine(newByte byte, cmdLine []byte) ([]byte, bool) {
1515
}
1616
return cmdLine, isSubmission
1717
}
18+
19+
func clamp(minBound int, n int, maxBound int) int {
20+
return max(minBound, min(maxBound, n))
21+
}

internal/fullterm/util_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,33 @@ func TestConstructCmdLineSubmissionLF(t *testing.T) {
7373
t.Errorf("expected 'test', got '%s'", newLine)
7474
}
7575
}
76+
77+
func TestClamp(t *testing.T) {
78+
minBound := 4
79+
maxBound := 23
80+
n := 11
81+
r := clamp(minBound, n, maxBound)
82+
if r != n {
83+
t.Fatalf("expect r to equal %v but instead it is %v", n, r)
84+
}
85+
}
86+
87+
func TestClampGreaterThanMax(t *testing.T) {
88+
minBound := 2
89+
maxBound := 5
90+
n := 12
91+
r := clamp(minBound, n, maxBound)
92+
if r != maxBound {
93+
t.Fatalf("expect r to equal %v but instead it is %v", maxBound, r)
94+
}
95+
}
96+
97+
func TestClampLesserThanMin(t *testing.T) {
98+
minBound := 202
99+
maxBound := 582
100+
n := 105
101+
r := clamp(minBound, n, maxBound)
102+
if r != minBound {
103+
t.Fatalf("expect r to equal %v but instead it is %v", minBound, r)
104+
}
105+
}

0 commit comments

Comments
 (0)