27b4151881c266383acd5ef4644dbbed20e02e03

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Rewrite to use tview and add a nice TUI

Diff

This diff is truncated to protect this page.

  1diff --git a/AGENTS.md b/AGENTS.md
  2new file mode 100644
  3index 0000000000000000000000000000000000000000..e43cb0d1e3157e0b239453e2920def38abc9ec11
  4--- /dev/null
  5+++ b/AGENTS.md
  6@@ -0,0 +1,27 @@
  7+# RPN Calculator
  8+
  9+## Structure
 10+
 11+- `main.go` composes the calculator and terminal UI.
 12+- `rpn/lexer.go` tokenizes calculator input.
 13+- `rpn/rpn.go` is terminal-free: it evaluates tokens against the stack and exposes calculator events and stack snapshots.
 14+- `rpn/stack.go`, `rpn/token.go`, and `rpn/parser.go` define the stack, token types, and input cursor.
 15+- `rpn/rpn_test.go` covers calculator evaluation, events, and stack snapshots.
 16+- `tui/application.go` renders the tview interface and owns terminal input, layout, status messages, help, and exit behavior.
 17+
 18+## Local Development
 19+
 20+Run the following from the repository root:
 21+
 22+```sh
 23+go test ./...
 24+go vet ./...
 25+golangci-lint run
 26+```
 27+
 28+## Gotchas
 29+
 30+- Do not run `go run .` or the compiled REPL from an agent loop. It waits indefinitely for terminal input. Use the compile, test, vet, and lint commands above for automated verification; test the REPL manually in a real terminal when necessary.
 31+- The terminal UI uses `github.com/rivo/tview`. Do not combine it with readline or direct ANSI cursor handling: tview owns raw terminal input and rendering.
 32+- Keep `rpn` independent of tview and terminal output. Exchange calculator events and copied stack snapshots through its public API instead.
 33+- `golangci-lint` is the project lint command. Error strings must begin with lowercase letters to satisfy its staticcheck configuration.
 34diff --git a/go.mod b/go.mod
 35index 8b46595a7d1f65efbc4aeecb3842f251b19ef4b9..fa7e2600c9265f701286439ce7f9bc4a071064de 100644
 36--- a/go.mod
 37+++ b/go.mod
 38@@ -1,3 +1,18 @@
 39 module gitea.theedgeofrage.com/theedgeofrage/rpn
 40 
 41 go 1.21
 42+
 43+require (
 44+	github.com/gdamore/tcell/v2 v2.6.1-0.20231203215052-2917c3801e73
 45+	github.com/rivo/tview v0.0.0-20231206124440-5f078138442e
 46+)
 47+
 48+require (
 49+	github.com/gdamore/encoding v1.0.0 // indirect
 50+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 51+	github.com/mattn/go-runewidth v0.0.14 // indirect
 52+	github.com/rivo/uniseg v0.4.3 // indirect
 53+	golang.org/x/sys v0.11.0 // indirect
 54+	golang.org/x/term v0.9.0 // indirect
 55+	golang.org/x/text v0.12.0 // indirect
 56+)
 57diff --git a/go.sum b/go.sum
 58new file mode 100644
 59index 0000000000000000000000000000000000000000..fa81d26dbcf858046149f92e5ba66ea43b6c7194
 60--- /dev/null
 61+++ b/go.sum
 62@@ -0,0 +1,50 @@
 63+github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
 64+github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
 65+github.com/gdamore/tcell/v2 v2.6.1-0.20231203215052-2917c3801e73 h1:SeDV6ZUSVlTAUUPdMzPXgMyj96z+whQJRRUff8dIeic=
 66+github.com/gdamore/tcell/v2 v2.6.1-0.20231203215052-2917c3801e73/go.mod h1:pwzJMyH4Hd0AZMJkWQ+/g01dDvYWEvmJuaiRU71Xl8k=
 67+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 68+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 69+github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
 70+github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 71+github.com/rivo/tview v0.0.0-20231206124440-5f078138442e h1:mPy47VW9tkqImnSPgcjnEHJuG3XHDBtXj2hDb1qBrRs=
 72+github.com/rivo/tview v0.0.0-20231206124440-5f078138442e/go.mod h1:c0SPlNPXkM+/Zgjn/0vD3W0Ds1yxstN7lpquqLDpWCg=
 73+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 74+github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
 75+github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 76+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 77+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 78+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 79+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 80+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 81+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 82+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 83+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 84+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 85+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 86+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 87+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 88+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 89+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 90+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 91+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 92+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 93+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 94+golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 95+golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
 96+golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 97+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 98+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 99+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
100+golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28=
101+golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
102+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
103+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
104+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
105+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
106+golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
107+golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
108+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
109+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
110+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
111+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
112+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
113diff --git a/main.go b/main.go
114index eca8fdcf42fd7e407a29d86f9a8dd6adad01f30e..2072d744a4ecbb300f1c42482c59ce9ce79538d0 100644
115--- a/main.go
116+++ b/main.go
117@@ -1,38 +1,15 @@
118 package main
119 
120 import (
121-	"bufio"
122-	"errors"
123 	"fmt"
124-	"io"
125 	"os"
126 
127 	"gitea.theedgeofrage.com/theedgeofrage/rpn/rpn"
128+	"gitea.theedgeofrage.com/theedgeofrage/rpn/tui"
129 )
130 
131 func main() {
132-	rpn := rpn.NewRPN()
133-	reader := bufio.NewReader(os.Stdin)
134-	for {
135-		rpn.PrintStack()
136-		// input := ""
137-		// _, err := fmt.Scanln(&input)
138-
139-		fmt.Print("> ")
140-		input, err := reader.ReadString('\n')
141-		if err != nil {
142-			if errors.Is(err, io.EOF) {
143-				fmt.Println()
144-				return
145-			}
146-			fmt.Println(err)
147-			continue
148-		}
149-
150-		err = rpn.Eval(input)
151-		if err != nil {
152-			fmt.Println(err)
153-			continue
154-		}
155+	if err := tui.New(rpn.NewRPN()).Run(); err != nil {
156+		fmt.Fprintln(os.Stderr, err)
157 	}
158 }
159diff --git a/rpn/lexer.go b/rpn/lexer.go
160index 06f2abac88ac372850d4dc81cdab15faaed7c319..8b0fc7ecad1db41108436ca5932b8268783e03e7 100644
161--- a/rpn/lexer.go
162+++ b/rpn/lexer.go
163@@ -69,6 +69,9 @@ func (l *Lexer) Parse(input string) ([]*Token, error) {
164 	var err error
165 	var token *Token
166 	l.input = NewInput(input)
167+	if l.input == nil {
168+		return nil, nil
169+	}
170 	tokens := []*Token{}
171 	for {
172 		char := l.input.NextChar()
173diff --git a/rpn/rpn.go b/rpn/rpn.go
174index 2cc7bf0207eb348ec0eb4d8c8431d7b3f2f4c5d6..91736dbea84096134d8af0620d19dc9a632db310 100644
175--- a/rpn/rpn.go
176+++ b/rpn/rpn.go
177@@ -10,6 +10,29 @@ type RPN struct {
178 	lexer *Lexer
179 }
180 
181+type Event int
182+
183+const (
184+	EventNone Event = iota
185+	EventHelp
186+	EventExit
187+)
188+
189+const HelpText = `RPN Calculator
190+==============
191+Commands:
192+  +, -, *, /, %, ^, sqrt
193+  pop, swap
194+  help
195+  exit (or Ctrl-D)
196+Examples:
197+  1 2 + == 3
198+  2 3 4 + *
199+  2 3 4 + * 5 /
200+  9 sqrt
201+  2 3 swap -
202+  exit`
203+
204 func NewRPN() *RPN {
205 	return &RPN{
206 		stack: &Stack{},
207@@ -17,29 +40,13 @@ func NewRPN() *RPN {
208 	}
209 }
210 
211-func printHelp() {
212-	println("RPN Calculator")
213-	println("==============")
214-	println("Commands:")
215-	println("  +, -, *, /, %, ^, sqrt")
216-	println("  pop, swap")
217-	println("  help")
218-	println("  exit (or Ctrl-D)")
219-	println("Examples:")
220-	println("  1 2 + == 3")
221-	println("  2 3 4 + *")
222-	println("  2 3 4 + * 5 /")
223-	println("  9 sqrt")
224-	println("  2 3 swap -")
225-	println("  exit")
226-}
227-
228-func (r *RPN) Eval(input string) error {
229+func (r *RPN) Eval(input string) (Event, error) {
230 	tokens, err := r.lexer.Parse(input)
231 	if err != nil {
232-		return err
233+		return EventNone, err
234 	}
235 
236+	event := EventNone
237 	for _, token := range tokens {
238 		switch token.Type {
239 		case number:
240@@ -47,7 +54,7 @@ func (r *RPN) Eval(input string) error {
241 		case binaryOp:
242 			a, b, err := r.stack.Pop2()
243 			if err != nil {
244-				return err
245+				return EventNone, err
246 			}
247 			switch token.Operator {
248 			case plus:
249@@ -60,14 +67,14 @@ func (r *RPN) Eval(input string) error {
250 				if a == 0 {
251 					r.stack.Push(b)
252 					r.stack.Push(a)
253-					return fmt.Errorf("can't divide by zero")
254+					return EventNone, fmt.Errorf("can't divide by zero")
255 				}
256 				r.stack.Push(b / a)
257 			case mod:
258 				if a == 0 {
259 					r.stack.Push(b)
260 					r.stack.Push(a)
261-					return fmt.Errorf("can't divide by zero")
262+					return EventNone, fmt.Errorf("can't divide by zero")
263 				}
264 				r.stack.Push(float64(int64(b) % int64(a)))
265 			case power:
266@@ -76,7 +83,7 @@ func (r *RPN) Eval(input string) error {
267 		case unaryOp:
268 			a, err := r.stack.Pop()
269 			if err != nil {
270-				return err
271+				return EventNone, err
272 			}
273 			switch token.Operator {
274 			case sqrt:
275@@ -85,27 +92,27 @@ func (r *RPN) Eval(input string) error {
276 		case pop:
277diff --git a/rpn/rpn_test.go b/rpn/rpn_test.go
278new file mode 100644
279index 0000000000000000000000000000000000000000..097828ff0d31ab02af054f4d219e220a60244dfb
280--- /dev/null
281+++ b/rpn/rpn_test.go
282@@ -0,0 +1,60 @@
283+package rpn
284+
285+import (
286+	"slices"
287+	"testing"
288+)
289+
290+func TestEvalUpdatesStack(t *testing.T) {
291+	calculator := NewRPN()
292+
293+	event, err := calculator.Eval("2 3 +")
294+	if err != nil {
295+		t.Fatalf("Eval() error = %v", err)
296+	}
297+	if event != EventNone {
298+		t.Fatalf("Eval() event = %v, want %v", event, EventNone)
299+	}
300+
301+	if got, want := calculator.Values(), []float64{5}; !slices.Equal(got, want) {
302+		t.Errorf("Values() = %v, want %v", got, want)
303+	}
304+}
305+
306+func TestEvalReturnsEvents(t *testing.T) {
307+	tests := []struct {
308+		input string
309+		want  Event
310+	}{
311+		{input: "", want: EventNone},
312+		{input: "help", want: EventHelp},
313+		{input: "exit", want: EventExit},
314+	}
315+
316+	for _, test := range tests {
317+		t.Run(test.input, func(t *testing.T) {
318+			event, err := NewRPN().Eval(test.input)
319+			if err != nil {
320+				t.Fatalf("Eval() error = %v", err)
321+			}
322+			if event != test.want {
323+				t.Errorf("Eval() event = %v, want %v", event, test.want)
324+			}
325+		})
326+	}
327+}
328+
329+func TestValuesReturnsCopy(t *testing.T) {
330+	calculator := NewRPN()
331+	_, err := calculator.Eval("5")
332+	if err != nil {
333+		t.Fatalf("Eval() error = %v", err)
334+	}
335+
336+	values := calculator.Values()
337+	values[0] = 10
338+
339+	if got := calculator.Values()[0]; got != 5 {
340+		t.Errorf("Values()[0] = %v, want 5", got)
341+	}
342+}
343diff --git a/rpn/stack.go b/rpn/stack.go
344index c937c14aaf6102229ae91a807d5ae80e69c5adc7..da42405b0011b5ba1868ae9d623c96a1065bf820 100644
345--- a/rpn/stack.go
346+++ b/rpn/stack.go
347@@ -2,7 +2,6 @@ package rpn
348 
349 import (
350 	"fmt"
351-	"math"
352 )
353 
354 var ErrNotEnoughValuesTmpl = "Need at least %d values on the stack to perform this operation"
355@@ -56,12 +55,6 @@ func (s *Stack) Clear() {
356 	s.values = []float64{}
357 }
358 
359-func (s *Stack) Print() {
360-	for _, value := range s.values {
361-		if value == math.Trunc(value) {
362-			fmt.Printf("%d\n", int64(value))
363-		} else {
364-			fmt.Printf("%f\n", value)
365-		}
366-	}
367+func (s *Stack) Values() []float64 {
368+	return append([]float64(nil), s.values...)
369 }
370diff --git a/tui/application.go b/tui/application.go
371new file mode 100644
372index 0000000000000000000000000000000000000000..d12102582fb220b3ba8936ad40ba6b893a1f45be
373--- /dev/null
374+++ b/tui/application.go
375@@ -0,0 +1,158 @@
376+package tui
377+
378+import (
379+	"fmt"
380+	"math"
381+	"strings"
382+
383+	"gitea.theedgeofrage.com/theedgeofrage/rpn/rpn"
384+	"github.com/gdamore/tcell/v2"
385+	"github.com/rivo/tview"
386+)
387+
388+const calculatorPage = "calculator"
389+
390+const helpPage = "help"
391+
392+type Calculator interface {
393+	Eval(input string) (rpn.Event, error)
394+	Values() []float64
395+}
396+
397+type Application struct {
398+	application *tview.Application
399+	calculator  Calculator
400+	pages       *tview.Pages
401+	layout      *tview.Flex
402+	stack       *tview.TextView
403+	status      *tview.TextView
404+	input       *tview.InputField
405+}
406+
407+func New(calculator Calculator) *Application {
408+	stack := tview.NewTextView().SetWrap(false)
409+	stack.SetBorder(true).SetTitle("Stack")
410+
411+	app := &Application{
412+		application: tview.NewApplication(),
413+		calculator:  calculator,
414+		stack:       stack,
415+		status: tview.NewTextView().
416+			SetWrap(false),
417+		input: tview.NewInputField().
418+			SetLabel("> ").
419+			SetFieldBackgroundColor(tcell.ColorBlack).
420+			SetFieldTextColor(tcell.ColorWhite),
421+	}
422+
423+	app.input.SetDoneFunc(app.submit)
424+
425+	app.layout = tview.NewFlex().
426+		SetDirection(tview.FlexRow).
427+		AddItem(nil, 0, 1, false).
428+		AddItem(app.stack, 0, 1, false).
429+		AddItem(app.status, 0, 0, false).
430+		AddItem(app.input, 1, 0, true)
431+
432+	app.pages = tview.NewPages().AddPage(calculatorPage, app.layout, true, true)
433+	app.application.
434+		SetRoot(app.pages, true).
435+		SetFocus(app.input).
436+		SetInputCapture(app.captureInput).
437+		SetBeforeDrawFunc(app.resizeStack)
438+	app.renderStack()
439+
440+	return app
441+}
442+
443+func (a *Application) Run() error {
444+	return a.application.Run()
445+}
446+
447+func (a *Application) captureInput(event *tcell.EventKey) *tcell.EventKey {
448+	if event.Key() == tcell.KeyCtrlD {
449+		a.application.Stop()
450+		return nil
451+	}
452+	return event
453+}
454+
455+func (a *Application) submit(key tcell.Key) {
456+	if key != tcell.KeyEnter {
457+		return
458+	}
459+
460+	input := strings.TrimSpace(a.input.GetText())
461+	a.input.SetText("")
462+	if input == "" {
463+		return
464+	}
465+
466+	event, err := a.calculator.Eval(input)
467+	a.renderStack()
468+	if err != nil {
469+		a.status.SetText(err.Error())
470+		return
471+	}
472+
473+	a.status.SetText("")
474+	switch event {