diff --git a/editor.go b/editor.go index e4a552e..af4c8ff 100644 --- a/editor.go +++ b/editor.go @@ -88,8 +88,10 @@ type imeState struct { rng key.Range caret key.Caret } - snippet key.Snippet - start, end int + snippet key.Snippet + start, end int + isComposing bool + composition key.Range } type EditorEvent interface { @@ -279,6 +281,7 @@ func (e *Editor) layout(gtx layout.Context) layout.Dimensions { } e.paintText(gtx, textColor) + e.paintComposition(gtx, textColor) } if gtx.Enabled() { e.paintCaret(gtx, textColor) @@ -316,6 +319,16 @@ func (e *Editor) paintCaret(gtx layout.Context, material color.Color) { e.text.PaintCaret(gtx, material.Op(gtx.Ops)) } +func (e *Editor) paintComposition(gtx layout.Context, material color.Color) { + e.initBuffer() + if !e.ime.isComposing { + return + } + + e.text.PaintComposition(gtx, e.ime.composition, material.Op(gtx.Ops), "dash") + +} + // Len is the length of the editor contents, in runes. func (e *Editor) Len() int { e.initBuffer() diff --git a/event.go b/event.go index 81ea636..837cc17 100644 --- a/event.go +++ b/event.go @@ -250,6 +250,40 @@ func (e *Editor) processEditEvents(gtx layout.Context) EditorEvent { e.updateSnippet(gtx, ke.Start, ke.End) case key.EditEvent: e.onTextInput(ke) + case key.CompositionEvent: + // Since v0.10.1, gio delivers IME composition event. During composition stage, + // range marks the current text range of composition. When composition confirmed/canceled, + // range becomes (-1, -1). + // When there is no IME for text input, no CompositionEvent is delivered. + e.ime.composition = key.Range(ke) + + isStart := false + isFinish := false + + if ke.Start != -1 && ke.Start != ke.End { + if !e.ime.isComposing { + isStart = true + } + e.ime.isComposing = true + } else { + if e.ime.isComposing { + isFinish = true + } + e.ime.isComposing = false + } + + // GroupOp and UnGroupOp must be paired. + if isStart { + // composition started, ensure the undo transaction has started, and all text of a IME composition + // can be undone in a batch. + e.buffer.GroupOp() + } + + if isFinish { + // composition is done, seal the undo transaction. + e.buffer.UnGroupOp() + } + case key.SelectionEvent: e.scrollCaret = true e.scroller.Stop() diff --git a/go.mod b/go.mod index 00221aa..8ccb95c 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module github.com/oligo/gvcode go 1.25 require ( - gioui.org v0.9.0 + gioui.org v0.10.1 github.com/andybalholm/stroke v0.0.0-20230904101225-24ef450bc62c - github.com/go-text/typesetting v0.3.0 + github.com/go-text/typesetting v0.3.4 github.com/rdleal/intervalst v1.4.1 golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 golang.org/x/image v0.26.0 @@ -14,6 +14,7 @@ require ( require ( gioui.org/shader v1.0.8 // indirect golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect ) diff --git a/go.sum b/go.sum index 5765f2e..176a944 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,16 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= -gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc= -gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao= +gioui.org v0.10.1 h1:Dvp6iDk9RKuZk19jxhOmb4p673CLVvb656LyMxQ+uO0= +gioui.org v0.10.1/go.mod h1:MZJZsdEPkTBzChdqeE8CiiQhreUQBj43qusDxQNDf7k= gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= github.com/andybalholm/stroke v0.0.0-20230904101225-24ef450bc62c h1:hHefapU8Zg8roqjYi9V8CNFPD0z6tbDDSqNgBgY1O4U= github.com/andybalholm/stroke v0.0.0-20230904101225-24ef450bc62c/go.mod h1:ccdDYaY5+gO+cbnQdFxEXqfy0RkoV25H3jLXUDNM3wg= -github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4= -github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY= -github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= -github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU= +github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= github.com/rdleal/intervalst v1.4.1 h1:8rCuAea0aj3Z6cDSeN3Q3c+LZjmHmrqZhXrJbqfPw0c= github.com/rdleal/intervalst v1.4.1/go.mod h1:xO89Z6BC+LQDH+IPQQw/OESt5UADgFD41tYMUINGpxQ= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= @@ -19,7 +19,9 @@ golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfa golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= diff --git a/internal/buffer/piecetable.go b/internal/buffer/piecetable.go index e3673af..d71d628 100644 --- a/internal/buffer/piecetable.go +++ b/internal/buffer/piecetable.go @@ -6,6 +6,7 @@ import ( "slices" "strings" "sync" + "unicode" "unicode/utf8" ) @@ -189,12 +190,40 @@ func (pt *PieceTable) insert(runeIndex int, text string) bool { } // Check if this insert action can be optimized by merging the input with previous one. -// multiple characters input won't be merged. +// The following rules are applied when deciding merging or not: +// +// - multiple characters input won't be merged. +// - unicode space characters are not merged. +// - character class transitions: transitions between normal chars and punctuations/symbols. +// +// This prevent different chars from being undo as a unit, and similar chars can be combines +// and undo as a unit. func (pt *PieceTable) tryAppendToLastPiece(runeIndex int, text string) bool { if pt.lastAction != actionInsert || runeIndex != pt.lastActionEndIdx || - pt.lastInsertPiece == nil || - utf8.RuneCountInString(text) > 1 { + pt.lastInsertPiece == nil { + return false + } + + r, size := utf8.DecodeRuneInString(text) + if size != len(text) { + // Either contains multiple runes or invalid UTF-8 + return false + } + + // Prevent unicode space character from merging. + if unicode.IsSpace(r) { + return false + } + + // get last inserted rune + lastRune, _, err := pt.getBuf(pt.lastInsertPiece.source).ReadRuneAt(int64(pt.lastInsertPiece.byteOff + pt.lastInsertPiece.byteLength - 1)) + if err != nil { + // FIXME: return error here? + return false + } + // + if isPunctuationOrSymbol(r) != isPunctuationOrSymbol(lastRune) { return false } @@ -213,6 +242,11 @@ func (pt *PieceTable) tryAppendToLastPiece(runeIndex int, text string) bool { return true } +// Helper to catch brackets, operators, and punctuation +func isPunctuationOrSymbol(r rune) bool { + return unicode.IsPunct(r) || unicode.IsSymbol(r) +} + func (pt *PieceTable) insertAtBoundary(runeIndex int, text string, oldPiece *piece) { textRuneOff, textByteOff, textRunes := pt.addToBuffer(modify, []byte(text)) @@ -223,7 +257,6 @@ func (pt *PieceTable) insertAtBoundary(runeIndex int, text string, oldPiece *pie byteOff: textByteOff, byteLength: len(text), } - pt.lastInsertPiece = newPiece // insertion is at the boundary of 2 pieces. oldPieces := &pieceRange{ @@ -240,6 +273,7 @@ func (pt *PieceTable) insertAtBoundary(runeIndex int, text string, oldPiece *pie pt.seqLength += textRunes pt.seqBytes += len(text) pt.recordAction(actionInsert, runeIndex+textRunes) + pt.lastInsertPiece = newPiece } func (pt *PieceTable) insertInMiddle(runeIndex int, text string, oldPiece *piece, inRuneOff int) { @@ -252,7 +286,6 @@ func (pt *PieceTable) insertInMiddle(runeIndex int, text string, oldPiece *piece byteOff: textByteOff, byteLength: len(text), } - pt.lastInsertPiece = newPiece // preserve the old pieces as a pieceRange, and push to the undo stack. oldPieces := &pieceRange{ @@ -294,6 +327,7 @@ func (pt *PieceTable) insertInMiddle(runeIndex int, text string, oldPiece *piece pt.seqLength += textRunes pt.seqBytes += len(text) pt.recordAction(actionInsert, runeIndex+textRunes) + pt.lastInsertPiece = newPiece } // undoRedo restores operation saved in src to dest. If there is a valid batchId, the src stack @@ -547,9 +581,14 @@ func (pt *PieceTable) UnGroupOp() { } func (pt *PieceTable) unGroupOp() { + if pt.currentBatch == nil { + return + } + *pt.currentBatch-- if *pt.currentBatch <= 0 { pt.currentBatch = nil + } } diff --git a/textview/text_paint.go b/textview/text_paint.go index 73ab126..f936977 100644 --- a/textview/text_paint.go +++ b/textview/text_paint.go @@ -5,6 +5,8 @@ import ( "image/color" "math" + "gioui.org/f32" + "gioui.org/io/key" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" @@ -13,6 +15,7 @@ import ( "gioui.org/unit" lt "github.com/oligo/gvcode/internal/layout" "github.com/oligo/gvcode/internal/painter" + "github.com/oligo/gvcode/internal/stroke" ) // calculateViewSize determines the size of the current visible content, @@ -182,6 +185,63 @@ func (e *TextView) HighlightMatchingBrackets(gtx layout.Context, material op.Cal } } +func (e *TextView) PaintComposition(gtx layout.Context, rng key.Range, material op.CallOp, pattern string) { + if rng == (key.Range{}) || rng.Start < 0 || rng.End < 0 || rng.Start == rng.End { + return + } + + localViewport := image.Rectangle{Max: e.viewSize} + docViewport := image.Rectangle{Max: e.viewSize}.Add(e.scrollOff) + defer clip.Rect(localViewport).Push(gtx.Ops).Pop() + + e.regions = e.layouter.Locate(docViewport, rng.Start, rng.End, e.regions) + if len(e.regions) == 0 { + return + } + + thickness := max(gtx.Dp(unit.Dp(1)), 1) + + for _, region := range e.regions { + y := region.Bounds.Max.Y - max(region.Baseline*2/3, thickness) // 1/3 below the baseline. + underline := image.Rect(region.Bounds.Min.X, y, region.Bounds.Max.X, y+thickness) + underline = underline.Intersect(localViewport) + if underline.Empty() { + continue + } + + switch pattern { + + case "dash": + path := stroke.Path{} + path.Segments = append(path.Segments, stroke.MoveTo(f32.Pt(float32(underline.Min.X), float32(underline.Max.Y)))) + path.Segments = append(path.Segments, stroke.LineTo(f32.Pt(float32(underline.Max.X), float32(underline.Max.Y)))) + + cl := stroke.Stroke{ + Path: path, + Width: float32(thickness), + Cap: stroke.FlatCap, + Join: stroke.BevelJoin, + Dashes: stroke.Dashes{ + Dashes: []float32{4, 2}, + }, + }.Op(gtx.Ops).Push(gtx.Ops) + + material.Add(gtx.Ops) + paint.PaintOp{}.Add(gtx.Ops) + + cl.Pop() + default: + // simple underline + stack := clip.Rect(underline).Push(gtx.Ops) + material.Add(gtx.Ops) + paint.PaintOp{}.Add(gtx.Ops) + stack.Pop() + } + + } + +} + // caretCurrentLine returns the current paragraph that the carent is in. // Only the start position is checked. func (e *TextView) caretCurrentLine() (start lt.CombinedPos, end lt.CombinedPos, lineIndex int) {