From b11c892158772f508e494b2726a5d4db1bb74d23 Mon Sep 17 00:00:00 2001 From: citrons Date: Sat, 31 May 2025 17:16:38 -0500 Subject: text input box --- client/application.go | 29 ++++++--- client/buffer/buffer.go | 12 ++-- client/client/client.go | 10 +-- client/clipboard/clipboard.go | 34 ++++++++++ client/main.go | 7 ++- tui/builder.go | 39 ++++++++++++ tui/draw.go | 6 +- tui/geometry.go | 32 ++++++++++ tui/layout.go | 131 ++++++--------------------------------- tui/style.go | 1 + tui/text_input.go | 140 ++++++++++++++++++++++++++++++++++++++++-- 11 files changed, 302 insertions(+), 139 deletions(-) create mode 100644 client/clipboard/clipboard.go create mode 100644 tui/builder.go create mode 100644 tui/geometry.go 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() } -- cgit v1.2.3