Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
34 changes: 34 additions & 0 deletions event.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 5 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
22 changes: 12 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
49 changes: 44 additions & 5 deletions internal/buffer/piecetable.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"slices"
"strings"
"sync"
"unicode"
"unicode/utf8"
)

Expand Down Expand Up @@ -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
}

Expand All @@ -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))

Expand All @@ -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{
Expand All @@ -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) {
Expand All @@ -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{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

}
}

Expand Down
60 changes: 60 additions & 0 deletions textview/text_paint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
Loading