summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcitrons <citrons@mondecitronne.com>2025-05-31 17:16:38 -0500
committercitrons <citrons@mondecitronne.com>2025-05-31 17:16:57 -0500
commitb11c892158772f508e494b2726a5d4db1bb74d23 (patch)
tree16113353422520259ccc7937e9085a68ac6663a6
parent0a58e68ad438ff43fa5bbecdb8914aa00cab5099 (diff)
text input box
-rw-r--r--client/application.go29
-rw-r--r--client/buffer/buffer.go12
-rw-r--r--client/client/client.go10
-rw-r--r--client/clipboard/clipboard.go34
-rw-r--r--client/main.go7
-rw-r--r--tui/builder.go39
-rw-r--r--tui/draw.go6
-rw-r--r--tui/geometry.go32
-rw-r--r--tui/layout.go131
-rw-r--r--tui/style.go1
-rw-r--r--tui/text_input.go140
11 files changed, 302 insertions, 139 deletions
diff --git a/client/application.go b/client/application.go
index fdb0216..72ec078 100644
--- a/client/application.go
+++ b/client/application.go
@@ -7,6 +7,7 @@ import (
"citrons.xyz/talk/proto"
"citrons.xyz/talk/tui"
"zgo.at/termfo/keys"
+ "os"
)
type application struct {
@@ -23,7 +24,6 @@ func newApplication(serverAddress string) *application {
var app application
app.Client = client.New(serverAddress)
app.cache = object.NewCache(&app)
- app.cmdBuffer.Buffer = buffer.New("buffer")
app.currentBuffer = &app.cmdBuffer.Buffer
app.cmdBuffer.info("connecting to %s", app.Client.Address)
@@ -105,13 +105,18 @@ func (a *application) auth(name string) {
}
func (a *application) onInput(ev tui.Event) {
+ tui.Selected = "input"
+
a.currentBuffer.Scroll(-ev.Mouse.Scroll * 2)
+ scroll := tui.Size().Height - 5
switch ev.Key {
- case keys.Up:
- a.currentBuffer.Scroll(1)
- case keys.Down:
- a.currentBuffer.Scroll(-1)
+ case keys.PageUp:
+ a.currentBuffer.Scroll(scroll)
+ case keys.PageDown:
+ a.currentBuffer.Scroll(-scroll)
}
+
+ a.currentBuffer.TextInput.Update(ev)
}
func (a *application) show() {
@@ -120,8 +125,18 @@ func (a *application) show() {
tui.Push("", tui.Box {
Width: tui.BoxSize(s.Width), Height: tui.BoxSize(s.Height),
})
- a.currentBuffer.Show()
+
+ a.currentBuffer.Show("buffer")
+ tui.Push("status", tui.Box {
+ Width: tui.Fill, Height: tui.BoxSize(1), Dir: tui.Right,
+ Style: &tui.Style {Bg: tui.White, Fg: tui.Black},
+ })
+ tui.Pop()
+ a.currentBuffer.TextInput.Show("input")
+
tui.Pop()
tui.DrawLayout()
- tui.Present()
+ if tui.Present() != nil {
+ os.Exit(-1)
+ }
}
diff --git a/client/buffer/buffer.go b/client/buffer/buffer.go
index bb782fb..230fba9 100644
--- a/client/buffer/buffer.go
+++ b/client/buffer/buffer.go
@@ -5,10 +5,10 @@ import (
)
type Buffer struct {
- id string
top *bufList
bottom *bufList
scroll tui.ScrollState
+ TextInput tui.TextInput
Closed bool
}
@@ -24,10 +24,6 @@ type bufList struct {
next *bufList
}
-func New(id string) Buffer {
- return Buffer {id: id}
-}
-
func (b *Buffer) Add(msg Message) {
l := &bufList {msg: msg}
if b.bottom != nil {
@@ -70,8 +66,8 @@ func (b *Buffer) AtTop() bool {
return b.scroll.AtLast()
}
-func (b *Buffer) Show() (atTop bool) {
- tui.Push(b.id, tui.Box {
+func (b *Buffer) Show(id string) (atTop bool) {
+ tui.Push(id, tui.Box {
Width: tui.Fill, Height: tui.Fill, Dir: tui.Up, Overflow: true,
Scroll: &b.scroll,
})
@@ -90,5 +86,5 @@ func (b *Buffer) Show() (atTop bool) {
tui.Pop()
}
- return atTop
+ return b.scroll.AtLast()
}
diff --git a/client/client/client.go b/client/client/client.go
index afbafbe..e32e02c 100644
--- a/client/client/client.go
+++ b/client/client/client.go
@@ -50,16 +50,12 @@ func (c *Client) RunClient() {
defer conn.Close()
sleep = time.Second
- c.message <- Message {func(mh MessageHandler) {
- mh.OnConnect()
- }}
-
recv, recvErr := proto.ReadLines(bufio.NewReader(conn))
send := make(chan proto.Line, 1)
defer close(send)
sendErr := proto.WriteLines(bufio.NewWriter(conn), send)
- c.send = make(chan proto.Line)
+ c.send = make(chan proto.Line, 1)
go func() {
buf := make([]proto.Line, 0, 8)
for {
@@ -83,6 +79,10 @@ func (c *Client) RunClient() {
}()
defer close(c.send)
+ c.message <- Message {func(mh MessageHandler) {
+ mh.OnConnect()
+ }}
+
run: for {
select {
case line := <-recv:
diff --git a/client/clipboard/clipboard.go b/client/clipboard/clipboard.go
new file mode 100644
index 0000000..b110378
--- /dev/null
+++ b/client/clipboard/clipboard.go
@@ -0,0 +1,34 @@
+package tui
+
+import (
+ "sync"
+)
+
+type Clipboard interface {
+ Copy(text string)
+ Paste() <-chan string
+}
+
+type virtualClipboard string
+
+func (clip *virtualClipboard) Copy(text string) {
+ *clip = text
+}
+
+var ch = make(chan string)
+func (clip *virtualClipboard) Paste() <-chan string {
+ go func() {
+ ch <- *clip
+ }()
+ return ch
+}
+
+var clipboard Clipboard = virtualClipboard {}
+var mut RWMutex
+
+func Get() {
+ mut.RLock()
+ c := clipboard
+ mut.RUnlock()
+ return c
+}
diff --git a/client/main.go b/client/main.go
index 1f46a00..4c65752 100644
--- a/client/main.go
+++ b/client/main.go
@@ -3,10 +3,15 @@ package main
import (
"citrons.xyz/talk/tui"
"time"
+ "fmt"
+ "os"
)
func main() {
- tui.Start()
+ err := tui.Start()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "error initializing terminal: ", err)
+ }
app := newApplication("localhost:27508")
go app.RunClient()
diff --git a/tui/builder.go b/tui/builder.go
new file mode 100644
index 0000000..e969cec
--- /dev/null
+++ b/tui/builder.go
@@ -0,0 +1,39 @@
+package tui
+
+import (
+ "github.com/rivo/uniseg"
+)
+
+type builder struct {
+ runs []textRun
+ width int
+}
+
+func (b *builder) add(s string, style *Style) {
+ b.width += uniseg.StringWidth(s)
+ if len(b.runs) == 0 || style != b.runs[len(b.runs) - 1].style {
+ b.runs = append(b.runs, textRun {s, style, false})
+ } else {
+ run := b.runs[len(b.runs) - 1]
+ run.text += s
+ b.runs[len(b.runs) - 1] = run
+ }
+}
+
+func (b *builder) addRun(run textRun) {
+ b.width += uniseg.StringWidth(run.text)
+ b.runs = append(b.runs, run)
+}
+
+func (b *builder) addRuns(runs []textRun) {
+ for _, run := range runs {
+ b.addRun(run)
+ }
+}
+
+func (b *builder) flush() []textRun {
+ runs := b.runs
+ b.width = 0
+ b.runs = nil
+ return runs
+}
diff --git a/tui/draw.go b/tui/draw.go
index 37a678e..302f876 100644
--- a/tui/draw.go
+++ b/tui/draw.go
@@ -131,8 +131,11 @@ func writeClearCursor() {
scr.writer.WriteString(terminfo.Get(caps.CursorInvisible))
}
-func writeCursor(x int, y int) {
+func writeShowCursor() {
scr.writer.WriteString(terminfo.Get(caps.CursorNormal))
+}
+
+func writeCursor(x int, y int) {
scr.writer.WriteString(terminfo.Get(caps.CursorAddress, y, x))
}
@@ -194,6 +197,7 @@ func Present() error {
}
}
if scr.showCursor {
+ writeShowCursor()
writeCursor(scr.cursor.x, scr.cursor.y)
}
scr.prevSize = s
diff --git a/tui/geometry.go b/tui/geometry.go
new file mode 100644
index 0000000..b20960b
--- /dev/null
+++ b/tui/geometry.go
@@ -0,0 +1,32 @@
+package tui
+
+type Direction int
+const (
+ Down = iota
+ Up
+ Right
+ Left
+)
+
+func (d Direction) axis() int {
+ switch d {
+ case Down, Up:
+ return 1
+ default:
+ return 0
+ }
+}
+
+func (d Direction) reverse() bool {
+ switch d {
+ case Down, Right:
+ return false
+ default:
+ return true
+ }
+}
+
+type rect struct {
+ min [2]int
+ max [2]int
+} \ No newline at end of file
diff --git a/tui/layout.go b/tui/layout.go
index 0681e8e..e2c3e0f 100644
--- a/tui/layout.go
+++ b/tui/layout.go
@@ -2,7 +2,6 @@ package tui
import (
"github.com/rivo/uniseg"
- "strings"
"unicode"
)
@@ -16,9 +15,6 @@ type Box struct {
Scroll *ScrollState
text []textRun
computedLines [][]textRun
- cursorLine int
- cursorCol int
- hasCursor bool
children []*Box
computedPosition int
computedSize [2]int
@@ -36,43 +32,12 @@ func (s BoxSize) isFlexible() bool {
return s == Fill
}
-type Direction int
-const (
- Down = iota
- Up
- Right
- Left
-)
-
-func (d Direction) axis() int {
- switch d {
- case Down, Up:
- return 1
- default:
- return 0
- }
-}
-
-func (d Direction) reverse() bool {
- switch d {
- case Down, Right:
- return false
- default:
- return true
- }
-}
-
type textRun struct {
text string
style *Style
hasCursor bool
}
-type rect struct {
- min [2]int
- max [2]int
-}
-
type ScrollState struct {
at string
offset int
@@ -81,6 +46,8 @@ type ScrollState struct {
atLast bool
}
+var Selected string
+
func (s *ScrollState) ToStart() {
*s = ScrollState {}
}
@@ -237,85 +204,32 @@ func (b *Box) computeText(axis int) {
} else {
var (
limit = b.computedSize[0]
- line []textRun
- text, word strings.Builder
- lineWidth, wordWidth int
- hasCursor bool
- cursorAt int
- run textRun
+ line, word builder
)
- flushText := func() {
- if text.Len() != 0 {
- line = append(line, textRun {text.String(), run.style, false})
- }
- text.Reset()
- }
- flushLine := func() {
- flushText()
- b.computedLines = append(b.computedLines, line)
- lineWidth = 0
- line = nil
- }
- breakWord := func() {
- if lineWidth + wordWidth > limit {
- flushLine()
- }
- g := uniseg.NewGraphemes(word.String())
- pos := 0
- for g.Next() {
- if hasCursor && pos == cursorAt {
- b.cursorLine = len(b.computedLines)
- b.cursorCol = lineWidth
- b.hasCursor = true
- hasCursor = false
- }
-
- if g.Width() > limit {
- continue
- }
- if lineWidth == 0 && g.Str() == " " {
- continue
- }
- text.WriteString(g.Str())
- if g.Width() + lineWidth > limit {
- flushLine()
- }
- lineWidth += g.Width()
-
- pos++
- }
- wordWidth = 0
- word.Reset()
- }
for _, run := range b.text {
if run.hasCursor {
- hasCursor = true
- cursorAt = wordWidth
+ word.addRun(run)
+ word.addRun(textRun {"", nil, false})
}
g := uniseg.NewGraphemes(run.text)
for g.Next() {
- if g.LineBreak() == uniseg.LineCanBreak {
- breakWord()
+ word.add(g.Str(), run.style)
+ if word.width >= limit {
+ line.addRuns(word.flush())
+ } else if line.width + word.width >= limit {
+ b.computedLines = append(b.computedLines, line.flush())
}
- if lineWidth != 0 || !unicode.IsSpace(g.Runes()[0]) {
- word.WriteString(g.Str())
- wordWidth += g.Width()
+ if unicode.IsSpace(g.Runes()[0]) {
+ line.addRuns(word.flush())
}
- _, end := g.Positions()
- if end == len(run.text) {
- breakWord()
- break
- }
- if g.LineBreak() == uniseg.LineMustBreak {
- breakWord()
- flushLine()
+ if g.Str() == "\n" {
+ line.addRuns(word.flush())
+ b.computedLines = append(b.computedLines, line.flush())
}
}
- flushText()
- }
- if len(line) != 0 || text.Len() != 0 {
- flushLine()
}
+ line.addRuns(word.flush())
+ b.computedLines = append(b.computedLines, line.flush())
if b.Height == TextSize {
b.computedSize[axis] = len(b.computedLines) + b.marginsSize(axis)
}
@@ -458,17 +372,12 @@ func (b *Box) drawComputed(parentRect rect, parentStyle Style) {
} else {
s = style
}
+ if t.hasCursor {
+ ShowCursor(x, y)
+ }
x += WriteAt(x, y, t.text, s)
}
}
- if b.hasCursor {
- cx := b.cursorCol + b.computedRect.min[0] + b.Margins[0]
- cy := b.cursorLine + b.computedRect.min[1] + b.Margins[2]
- if cx >= viewRect.min[0] && cx < viewRect.max[0] &&
- cy >= viewRect.min[1] && cy < viewRect.max[1] {
- ShowCursor(cx, cy)
- }
- }
for _, c := range b.children {
c.drawComputed(viewRect, style)
}
diff --git a/tui/style.go b/tui/style.go
index ac67233..aa58448 100644
--- a/tui/style.go
+++ b/tui/style.go
@@ -7,6 +7,7 @@ type Style struct {
Italic bool
Underline bool
Strikethrough bool
+ selected bool
}
const (
diff --git a/tui/text_input.go b/tui/text_input.go
index a8c7d6d..3bceb24 100644
--- a/tui/text_input.go
+++ b/tui/text_input.go
@@ -1,15 +1,143 @@
package tui
import (
-// "github.com/rivo/uniseg"
-// "strings"
+ "github.com/rivo/uniseg"
+ "zgo.at/termfo/keys"
+ "strings"
)
type TextInput struct {
- Contents string
- Cursor int
+ id string
+ linesBefore []string
+ beforeCursor string
+ selection string
+ selectionBefore bool
+ afterCursor string
+ linesAfter []string
}
-func (t *TextInput) Update(ev Event) {
- t.Cursor = max(t.Cursor, 0)
+func (t *TextInput) Text() string {
+ return t.beforeCursor + t.selection + t.afterCursor
+}
+
+func (t *TextInput) SetText(text string) {
+ t.beforeCursor = ""
+ t.afterCursor = text
+}
+
+func toGraphemes(s string) []string {
+ g := uniseg.NewGraphemes(s)
+ var result []string
+ for g.Next() {
+ result = append(result, g.Str())
+ }
+ return result
+}
+
+func splitRight(s string) (string, string) {
+ if s == "" {
+ return "", ""
+ }
+ gs := toGraphemes(s)
+ return strings.Join(gs[:len(gs) - 1], ""), gs[len(gs) - 1]
+}
+
+func splitLeft(s string) (string, string) {
+ if s == "" {
+ return "", ""
+ }
+ cluster, rest, _, _ := uniseg.FirstGraphemeCluster([]byte(s), 0)
+ return string(cluster), string(rest)
+}
+
+func (t *TextInput) Left(selection bool, word bool) {
+ t.Deselect()
+ var right string
+ t.beforeCursor, right = splitRight(t.beforeCursor)
+ t.afterCursor = right + t.afterCursor
+}
+
+func (t *TextInput) Right(selection bool, word bool) {
+ t.Deselect()
+ var left string
+ left, t.afterCursor = splitLeft(t.afterCursor)
+ t.beforeCursor += left
+}
+
+func (t *TextInput) Start(selection bool) {
+ t.afterCursor = t.beforeCursor + t.afterCursor
+ t.beforeCursor = ""
+}
+
+func (t *TextInput) End(selection bool) {
+ t.beforeCursor = t.beforeCursor + t.afterCursor
+ t.afterCursor = ""
+}
+
+func (t *TextInput) Selection() string {
+ return t.selection
+}
+
+func (t *TextInput) Deselect() {
+ if t.selectionBefore {
+ t.beforeCursor += t.selection
+ } else {
+ t.afterCursor = t.selection + t.afterCursor
+ }
+ t.selection = ""
+}
+
+func (t *TextInput) Write(text string) {
+ t.selection = ""
+ t.beforeCursor += text
+}
+
+func (t *TextInput) Update(ev Event) (usedKeybind bool) {
+ if Selected != t.id {
+ return
+ }
+ if ev.TextInput != 0 {
+ t.Write(string(ev.TextInput))
+ }
+
+ selection := ev.Key & keys.Shift != 0
+ word := ev.Key & keys.Ctrl != 0
+ switch ev.Key.WithoutMods() {
+ case keys.Left:
+ t.Left(selection, word)
+ case keys.Right:
+ t.Right(selection, word)
+ case 'a':
+ if ev.Key & keys.Ctrl != 0 {
+ t.Start(selection)
+ }
+ case 'e':
+ if ev.Key & keys.Ctrl != 0 {
+ t.End(selection)
+ }
+ case keys.Backspace:
+ if t.selection != "" {
+ t.selection = ""
+ } else {
+ t.beforeCursor, _ = splitRight(t.beforeCursor)
+ }
+ default:
+ return false
+ }
+ return true
+}
+
+func (t *TextInput) Show(id string) {
+ t.id = id
+ Push(id, Box {Width: Fill, Height: 4})//TextSize})
+ Text(t.beforeCursor, nil)
+ if t.selectionBefore {
+ Text(t.selection, &Style {Bg: Blue, Fg: White, selected: true})
+ }
+ Cursor()
+ if !t.selectionBefore {
+ Text(t.selection, &Style {Bg: Blue, Fg: White, selected: true})
+ }
+ Text(t.afterCursor, nil)
+ Pop()
}