From 0a477fd288d064d265395c88dbd0f261b723804e Mon Sep 17 00:00:00 2001 From: spiperac Date: Mon, 15 Jun 2026 06:42:13 +0200 Subject: Initial release --- internal/ui/widgets/textviewentry.go | 1589 ++++++++++++++++++++++++++++++++++ 1 file changed, 1589 insertions(+) create mode 100644 internal/ui/widgets/textviewentry.go (limited to 'internal/ui/widgets/textviewentry.go') diff --git a/internal/ui/widgets/textviewentry.go b/internal/ui/widgets/textviewentry.go new file mode 100644 index 0000000..48f39d8 --- /dev/null +++ b/internal/ui/widgets/textviewentry.go @@ -0,0 +1,1589 @@ +package widgets + +import ( + "image/color" + "strings" + "sync" + "time" + "unicode" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +const tveTabWidth = 4 // spaces per tab + +// TextViewEntry is a syntax-highlighted, virtualised, editable text widget. +// It never calls widget.Entry.SetText so large requests never block the UI. +// +// Thread-safety: all mutable state is guarded by mu. All methods that mutate +// state are called from the Fyne UI goroutine (via TypedKey, TypedRune, etc.) +// so in practice mu only protects against the blink goroutine reading +// cursorVisible concurrently with the renderer. Visual cache reads/writes are +// also coordinated through mu. +type TextViewEntry struct { + widget.BaseWidget + + // OnSend is called when the user presses Ctrl+S. + OnSend func() + + mu sync.RWMutex + rawFull string + + scrollOffset int + scrollFrac float32 + + selAnchorLine int + selAnchorCol int + selLine int + selCol int + hasSelection bool + + cursorLine int + cursorCol int + + undoStack []tveUndoEntry + + shiftSelecting bool + shiftHeld bool + + cursorVisible bool + blinkReset chan struct{} // send to reset blink timer; close to stop goroutine + blinkRunning bool + + dragging bool // true while mouse drag is in progress — guards FocusLost + scrollbarHovered bool // true while mouse is over scrollbar — widens thumb + + // Visual line cache. Invalidated by setting visualCacheRaw = "". + // Protected by mu — both raw snapshot and built result stored together so + // there is no TOCTOU window. + visualCache []tveVisualLine + visualCacheRaw string + visualCacheWidth int + visualCacheValid bool + + win fyne.Window + rend *tveRenderer +} + +type tveUndoEntry struct { + raw string + cursorLine int + cursorCol int +} + +type tveVisualLine struct { + tokens []tvToken + raw string + logicalIdx int + colOffset int +} + +func NewTextViewEntry() *TextViewEntry { + e := &TextViewEntry{} + e.ExtendBaseWidget(e) + return e +} + +func (e *TextViewEntry) SetWindow(w fyne.Window) { e.win = w } +func (e *TextViewEntry) SetPlaceHolder(_ string) {} +func (e *TextViewEntry) AcceptsTab() bool { return true } + +func (e *TextViewEntry) GetText() string { + e.mu.RLock() + defer e.mu.RUnlock() + return e.rawFull +} + +func (e *TextViewEntry) SetText(s string) { + e.mu.Lock() + e.rawFull = s + e.scrollOffset = 0 + e.scrollFrac = 0 + e.hasSelection = false + e.shiftSelecting = false + e.shiftHeld = false + e.cursorLine = 0 + e.cursorCol = 0 + e.undoStack = nil + e.visualCacheValid = false + e.mu.Unlock() + e.Refresh() +} + +// splitRaw normalises line endings and splits into logical lines. +func splitRaw(raw string) []string { + if raw == "" { + return []string{""} + } + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + return strings.Split(raw, "\n") +} + +// logicalLines returns the current logical lines. Caller must not hold mu. +func (e *TextViewEntry) logicalLines() []string { + e.mu.RLock() + raw := e.rawFull + e.mu.RUnlock() + return splitRaw(raw) +} + +// visual returns cached visual lines, rebuilding under mu if stale. +// This eliminates the TOCTOU race: raw snapshot and cache write happen in +// the same critical section. +func (e *TextViewEntry) visual() []tveVisualLine { + cpl := e.charsPerLine() + + e.mu.Lock() + raw := e.rawFull + if e.visualCache != nil && e.visualCacheValid && e.visualCacheRaw == raw && e.visualCacheWidth == cpl { + cached := e.visualCache + e.mu.Unlock() + return cached + } + // Build while holding the lock so raw cannot change under us. + built := buildVisualLines(splitRaw(raw), cpl) + e.visualCache = built + e.visualCacheRaw = raw + e.visualCacheWidth = cpl + e.visualCacheValid = true + + e.mu.Unlock() + return built +} + +// commitLines writes mutated lines back and invalidates the visual cache. +// Must be called from the UI goroutine (same as all edit methods). +func (e *TextViewEntry) commitLines(lines []string) { + e.mu.Lock() + e.rawFull = strings.Join(lines, "\n") + e.visualCacheRaw = "" + e.visualCacheValid = false + e.mu.Unlock() +} + +// charsPerLine computes the number of monospace characters that fit in the +// available width. Safe to call without holding mu. +func (e *TextViewEntry) charsPerLine() int { + if e.rend == nil { + return 80 + } + charWidth := fyne.MeasureText("M", theme.TextSize(), fyne.TextStyle{Monospace: true}).Width + if charWidth <= 0 { + charWidth = theme.TextSize() * 0.6 + } + availWidth := e.rend.size.Width - tvPadX*2 - tvScrollW + if availWidth < 1 { + return 80 + } + n := int(availWidth / charWidth) + if n < 10 { + n = 10 + } + return n +} + +// buildVisualLines converts logical lines into wrapped visual lines. +// Each logical line is tokenised once for correct colours on continuations. +func buildVisualLines(logicalLines []string, charsPerLine int) []tveVisualLine { + if charsPerLine < 10 { + charsPerLine = 10 + } + bodyStartLine := -1 + for i, l := range logicalLines { + if l == "" && i > 0 { + bodyStartLine = i + break + } + } + contentType := "text" + if bodyStartLine >= 0 { + contentType = detectContentType(logicalLines) + } + + var result []tveVisualLine + + for logIdx, rawLine := range logicalLines { + inBody := bodyStartLine >= 0 && logIdx > bodyStartLine + runes := []rune(rawLine) + + if len(runes) == 0 { + var tokens []tvToken + if !inBody { + tokens = tokeniseHTTPMeta(rawLine, logIdx == 0) + } else { + tokens = tokeniseBody(rawLine, contentType) + } + result = append(result, tveVisualLine{ + tokens: tokens, raw: "", logicalIdx: logIdx, colOffset: 0, + }) + continue + } + + colOffset := 0 + for colOffset < len(runes) { + end := colOffset + charsPerLine + if end > len(runes) { + end = len(runes) + } + chunk := string(runes[colOffset:end]) + var chunkTokens []tvToken + if !inBody { + // Only the very first chunk of the first line is the HTTP start line. + chunkTokens = tokeniseHTTPMeta(chunk, logIdx == 0 && colOffset == 0) + } else { + chunkTokens = tokeniseBody(chunk, contentType) + } + result = append(result, tveVisualLine{ + tokens: chunkTokens, + raw: chunk, + logicalIdx: logIdx, + colOffset: colOffset, + }) + colOffset = end + } + } + return result +} + +// logicalToVisual maps a logical (line, col) to the visual line index and +// column within that visual line. Handles end-of-line and wrapped lines correctly. +func logicalToVisual(visual []tveVisualLine, logicalLine, logicalCol int) (int, int) { + lastMatchV := -1 + for v, vl := range visual { + if vl.logicalIdx != logicalLine { + continue + } + chunkLen := len([]rune(vl.raw)) + chunkEnd := vl.colOffset + chunkLen + isLastChunk := v+1 >= len(visual) || visual[v+1].logicalIdx != logicalLine + + if logicalCol >= vl.colOffset && logicalCol < chunkEnd { + return v, logicalCol - vl.colOffset + } + if isLastChunk && logicalCol >= chunkEnd { + return v, chunkLen + } + lastMatchV = v + } + if lastMatchV >= 0 { + vl := visual[lastMatchV] + return lastMatchV, len([]rune(vl.raw)) + } + return 0, 0 +} + +func (e *TextViewEntry) pushUndo(prevRaw string, prevCursorLine, prevCursorCol int) { + if len(e.undoStack) >= 100 { + e.undoStack = e.undoStack[1:] + } + e.undoStack = append(e.undoStack, tveUndoEntry{ + raw: prevRaw, cursorLine: prevCursorLine, cursorCol: prevCursorCol, + }) +} + +func (e *TextViewEntry) deleteSelection(lines []string) []string { + if !e.hasSelection { + return lines + } + startLine, startCol, endLine, endCol := e.normaliseSelection() + if !e.hasSelection { + return lines + } + if startLine >= len(lines) { + startLine = len(lines) - 1 + } + if endLine >= len(lines) { + endLine = len(lines) - 1 + } + if startLine == endLine { + runes := []rune(lines[startLine]) + if startCol > len(runes) { + startCol = len(runes) + } + if endCol > len(runes) { + endCol = len(runes) + } + newRunes := append([]rune{}, runes[:startCol]...) + newRunes = append(newRunes, runes[endCol:]...) + lines[startLine] = string(newRunes) + } else { + startRunes := []rune(lines[startLine]) + endRunes := []rune(lines[endLine]) + if startCol > len(startRunes) { + startCol = len(startRunes) + } + if endCol > len(endRunes) { + endCol = len(endRunes) + } + merged := string(startRunes[:startCol]) + string(endRunes[endCol:]) + newLines := make([]string, 0, len(lines)-(endLine-startLine)) + newLines = append(newLines, lines[:startLine]...) + newLines = append(newLines, merged) + newLines = append(newLines, lines[endLine+1:]...) + lines = newLines + } + e.cursorLine = startLine + e.cursorCol = startCol + e.hasSelection = false + e.shiftSelecting = false + return lines +} + +func (e *TextViewEntry) ensureCursorVisible() { + visible := e.visibleLineCount() + if visible <= 0 { + return + } + vLine, _ := logicalToVisual(e.visual(), e.cursorLine, e.cursorCol) + if vLine < e.scrollOffset { + e.scrollOffset = vLine + } else if vLine >= e.scrollOffset+visible { + e.scrollOffset = vLine - visible + 1 + } +} + +func (e *TextViewEntry) thumbBounds() (thumbY, thumbH float32, ok bool) { + if e.rend == nil { + return 0, 0, false + } + totalLines := len(e.visual()) + bodyHeight := e.rend.size.Height - tvPadY*2 + visibleLines := int(bodyHeight / tvLineH) + scrollable := totalLines - visibleLines + if scrollable <= 0 || bodyHeight <= 0 { + return 0, 0, false + } + ratio := float32(visibleLines) / float32(totalLines) + thumbH = bodyHeight * ratio + if thumbH < tvScrollMinThumb { + thumbH = tvScrollMinThumb + } + trackHeight := bodyHeight - thumbH + thumbY = tvPadY + (float32(e.scrollOffset)/float32(scrollable))*trackHeight + return thumbY, thumbH, true +} + +// -------------------------------------------------------------------------- +// Blink — single long-lived goroutine, reset via channel on each keystroke/click. +// -------------------------------------------------------------------------- + +func (e *TextViewEntry) startBlink() { + e.stopBlink() + resetCh := make(chan struct{}, 1) + e.blinkReset = resetCh + e.blinkRunning = true + e.mu.Lock() + e.cursorVisible = true + e.mu.Unlock() + go func() { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case _, ok := <-resetCh: + if !ok { + // Channel closed — widget destroyed or focus lost. + return + } + // Reset: make cursor visible and restart the ticker. + fyne.Do(func() { + e.mu.Lock() + e.cursorVisible = true + e.mu.Unlock() + }) + ticker.Reset(500 * time.Millisecond) + case <-ticker.C: + fyne.Do(func() { + e.mu.Lock() + e.cursorVisible = !e.cursorVisible + e.mu.Unlock() + e.Refresh() + }) + } + } + }() +} + +func (e *TextViewEntry) stopBlink() { + if e.blinkReset != nil { + close(e.blinkReset) + e.blinkReset = nil + } + e.blinkRunning = false + e.mu.Lock() + e.cursorVisible = false + e.mu.Unlock() +} + +// resetBlink makes the cursor immediately visible and resets the blink timer. +// Non-blocking — uses a buffered channel so rapid keystrokes never block. +func (e *TextViewEntry) resetBlink() { + if !e.blinkRunning { + return + } + select { + case e.blinkReset <- struct{}{}: + default: + // Already a reset pending — fine, cursor will be shown. + } +} + +// -------------------------------------------------------------------------- +// Widget lifecycle +// -------------------------------------------------------------------------- + +func (e *TextViewEntry) CreateRenderer() fyne.WidgetRenderer { + e.stopBlink() + r := newTVERenderer(e) + e.rend = r + return r +} + +func (e *TextViewEntry) Build() fyne.CanvasObject { + return container.NewStack(e, newTVEInputLayer(e)) +} + +// -------------------------------------------------------------------------- +// Input handling +// -------------------------------------------------------------------------- + +func (e *TextViewEntry) Scrolled(ev *fyne.ScrollEvent) { + totalLines := len(e.visual()) + visible := e.visibleLineCount() + maxOffset := totalLines - visible + if maxOffset < 0 { + maxOffset = 0 + } + e.scrollFrac -= ev.Scrolled.DY / tvLineH + rows := int(e.scrollFrac) + e.scrollFrac -= float32(rows) + if rows == 0 { + if ev.Scrolled.DY < 0 { + rows = 1 + } else if ev.Scrolled.DY > 0 { + rows = -1 + } + } + e.scrollOffset += rows + if e.scrollOffset < 0 { + e.scrollOffset = 0 + } + if e.scrollOffset > maxOffset { + e.scrollOffset = maxOffset + } + e.Refresh() +} + +func (e *TextViewEntry) visibleLineCount() int { + if e.rend == nil { + return 0 + } + availHeight := e.rend.size.Height - tvPadY*2 + if availHeight < 0 { + return 0 + } + return int(availHeight/tvLineH) + 1 +} + +func (e *TextViewEntry) posFromPoint(point fyne.Position) (line, col int) { + visual := e.visual() + charWidth := fyne.MeasureText("M", theme.TextSize(), fyne.TextStyle{Monospace: true}).Width + if charWidth <= 0 { + charWidth = theme.TextSize() * 0.6 + } + vLineIdx := e.scrollOffset + int((point.Y-tvPadY)/tvLineH) + if vLineIdx < 0 { + vLineIdx = 0 + } + if vLineIdx >= len(visual) { + vLineIdx = len(visual) - 1 + } + if vLineIdx < 0 { + return 0, 0 + } + vl := visual[vLineIdx] + runes := []rune(vl.raw) + vCol := int((point.X - tvPadX) / charWidth) + if vCol < 0 { + vCol = 0 + } + if vCol > len(runes) { + vCol = len(runes) + } + return vl.logicalIdx, vl.colOffset + vCol +} + +func (e *TextViewEntry) normaliseSelection() (int, int, int, int) { + al, ac := e.selAnchorLine, e.selAnchorCol + cl, cc := e.selLine, e.selCol + if al > cl || (al == cl && ac > cc) { + return cl, cc, al, ac + } + return al, ac, cl, cc +} + +func (e *TextViewEntry) selectedText() string { + if !e.hasSelection { + return "" + } + lines := e.logicalLines() + startLine, startCol, endLine, endCol := e.normaliseSelection() + if startLine == endLine { + runes := []rune(lines[startLine]) + if startCol > len(runes) { + startCol = len(runes) + } + if endCol > len(runes) { + endCol = len(runes) + } + return string(runes[startCol:endCol]) + } + var b strings.Builder + for i := startLine; i <= endLine; i++ { + if i >= len(lines) { + break + } + runes := []rune(lines[i]) + lo, hi := 0, len(runes) + if i == startLine { + lo = startCol + } + if i == endLine { + hi = endCol + if hi > len(runes) { + hi = len(runes) + } + } + if lo > len(runes) { + lo = len(runes) + } + b.WriteString(string(runes[lo:hi])) + if i < endLine { + b.WriteByte('\n') + } + } + return b.String() +} + +// Dragged is implemented directly on TextViewEntry so Fyne's hit-test finds it +// before any parent split container — same pattern as widget.Entry. +func (e *TextViewEntry) Dragged(ev *fyne.DragEvent) { + if e.rend != nil && ev.Position.X >= e.rend.size.Width-tvScrollW { + return + } + line, col := e.posFromPoint(ev.Position) + e.selLine, e.selCol = line, col + e.cursorLine, e.cursorCol = line, col + e.hasSelection = !(e.selAnchorLine == line && e.selAnchorCol == col) + e.Refresh() +} + +func (e *TextViewEntry) DragEnd() {} + +func (e *TextViewEntry) TypedRune(r rune) { + lines := e.logicalLines() + prevRaw := strings.Join(lines, "\n") + prevCL, prevCC := e.cursorLine, e.cursorCol + if e.hasSelection { + lines = e.deleteSelection(lines) + } + cl := e.cursorLine + cc := e.cursorCol + if cl >= len(lines) { + cl = len(lines) - 1 + } + runes := []rune(lines[cl]) + if cc > len(runes) { + cc = len(runes) + } + newRunes := make([]rune, 0, len(runes)+1) + newRunes = append(newRunes, runes[:cc]...) + newRunes = append(newRunes, r) + newRunes = append(newRunes, runes[cc:]...) + lines[cl] = string(newRunes) + e.pushUndo(prevRaw, prevCL, prevCC) + e.commitLines(lines) + e.mu.Lock() + e.cursorLine = cl + e.cursorCol = cc + 1 + e.mu.Unlock() + e.ensureCursorVisible() + e.resetBlink() + e.Refresh() +} + +func (e *TextViewEntry) TypedKey(ev *fyne.KeyEvent) { + lines := e.logicalLines() + cl := e.cursorLine + cc := e.cursorCol + if cl >= len(lines) { + cl = len(lines) - 1 + } + if cl < 0 { + cl = 0 + } + runes := []rune(lines[cl]) + if cc > len(runes) { + cc = len(runes) + } + + isShift := e.shiftHeld + + beginShift := func() { + if !e.shiftSelecting { + e.shiftSelecting = true + e.selAnchorLine = cl + e.selAnchorCol = cc + } + } + updateShift := func() { + e.selLine = e.cursorLine + e.selCol = e.cursorCol + e.hasSelection = !(e.selAnchorLine == e.selLine && e.selAnchorCol == e.selCol) + } + cancelShift := func() { + e.shiftSelecting = false + e.shiftHeld = false + e.hasSelection = false + } + + switch ev.Name { + case fyne.KeyBackspace: + prevRaw := strings.Join(lines, "\n") + prevCL, prevCC := e.cursorLine, e.cursorCol + if e.hasSelection { + lines = e.deleteSelection(lines) + e.pushUndo(prevRaw, prevCL, prevCC) + e.commitLines(lines) + } else if cc > 0 { + newRunes := append([]rune{}, runes[:cc-1]...) + newRunes = append(newRunes, runes[cc:]...) + lines[cl] = string(newRunes) + e.pushUndo(prevRaw, prevCL, prevCC) + e.commitLines(lines) + e.mu.Lock() + e.cursorCol = cc - 1 + e.mu.Unlock() + } else if cl > 0 { + prevRunes := []rune(lines[cl-1]) + merged := string(prevRunes) + string(runes) + newLines := append([]string{}, lines[:cl-1]...) + newLines = append(newLines, merged) + newLines = append(newLines, lines[cl+1:]...) + e.pushUndo(prevRaw, prevCL, prevCC) + e.commitLines(newLines) + e.mu.Lock() + e.cursorLine = cl - 1 + e.cursorCol = len(prevRunes) + e.mu.Unlock() + } + cancelShift() + + case fyne.KeyDelete: + prevRaw := strings.Join(lines, "\n") + prevCL, prevCC := e.cursorLine, e.cursorCol + if e.hasSelection { + lines = e.deleteSelection(lines) + e.pushUndo(prevRaw, prevCL, prevCC) + e.commitLines(lines) + } else if cc < len(runes) { + newRunes := append([]rune{}, runes[:cc]...) + newRunes = append(newRunes, runes[cc+1:]...) + lines[cl] = string(newRunes) + e.pushUndo(prevRaw, prevCL, prevCC) + e.commitLines(lines) + } else if cl < len(lines)-1 { + merged := string(runes) + lines[cl+1] + newLines := append([]string{}, lines[:cl]...) + newLines = append(newLines, merged) + newLines = append(newLines, lines[cl+2:]...) + e.pushUndo(prevRaw, prevCL, prevCC) + e.commitLines(newLines) + } + cancelShift() + + case fyne.KeyReturn, fyne.KeyEnter: + prevRaw := strings.Join(lines, "\n") + prevCL, prevCC := e.cursorLine, e.cursorCol + if e.hasSelection { + lines = e.deleteSelection(lines) + cl = e.cursorLine + cc = e.cursorCol + runes = []rune(lines[cl]) + } + before := string(runes[:cc]) + after := string(runes[cc:]) + newLines := make([]string, 0, len(lines)+1) + newLines = append(newLines, lines[:cl]...) + newLines = append(newLines, before, after) + newLines = append(newLines, lines[cl+1:]...) + e.pushUndo(prevRaw, prevCL, prevCC) + e.commitLines(newLines) + e.mu.Lock() + e.cursorLine = cl + 1 + e.cursorCol = 0 + e.mu.Unlock() + cancelShift() + + case fyne.KeyTab: + prevRaw := strings.Join(lines, "\n") + prevCL, prevCC := e.cursorLine, e.cursorCol + if e.hasSelection { + startLine, selStartCol, endLine, selEndCol := e.normaliseSelection() + indent := strings.Repeat(" ", tveTabWidth) + for i := startLine; i <= endLine; i++ { + lines[i] = indent + lines[i] + } + e.pushUndo(prevRaw, prevCL, prevCC) + e.commitLines(lines) + e.selAnchorLine = startLine + if selStartCol > 0 { + e.selAnchorCol = selStartCol + tveTabWidth + } else { + e.selAnchorCol = 0 + } + e.selLine = endLine + e.selCol = selEndCol + tveTabWidth + // Preserve cursor at whichever end it was before. + if prevCL == startLine { + e.cursorLine = startLine + e.cursorCol = prevCC + tveTabWidth + } else { + e.cursorLine = endLine + e.cursorCol = selEndCol + tveTabWidth + } + } else { + indent := strings.Repeat(" ", tveTabWidth) + newRunes := make([]rune, 0, len(runes)+tveTabWidth) + newRunes = append(newRunes, runes[:cc]...) + newRunes = append(newRunes, []rune(indent)...) + newRunes = append(newRunes, runes[cc:]...) + lines[cl] = string(newRunes) + e.pushUndo(prevRaw, prevCL, prevCC) + e.commitLines(lines) + e.mu.Lock() + e.cursorCol = cc + tveTabWidth + e.mu.Unlock() + } + + case fyne.KeyLeft: + if isShift { + beginShift() + } else if e.hasSelection { + startLine, startCol, _, _ := e.normaliseSelection() + e.mu.Lock() + e.cursorLine = startLine + e.cursorCol = startCol + e.mu.Unlock() + cancelShift() + e.ensureCursorVisible() + e.resetBlink() + e.Refresh() + return + } + if cc > 0 { + e.mu.Lock() + e.cursorCol-- + e.mu.Unlock() + } else if cl > 0 { + e.mu.Lock() + e.cursorLine-- + e.cursorCol = len([]rune(lines[e.cursorLine])) + e.mu.Unlock() + } + if isShift { + updateShift() + } + + case fyne.KeyRight: + if isShift { + beginShift() + } else if e.hasSelection { + _, _, endLine, endCol := e.normaliseSelection() + e.mu.Lock() + e.cursorLine = endLine + e.cursorCol = endCol + e.mu.Unlock() + cancelShift() + e.ensureCursorVisible() + e.resetBlink() + e.Refresh() + return + } + if cc < len(runes) { + e.mu.Lock() + e.cursorCol++ + e.mu.Unlock() + } else if cl < len(lines)-1 { + e.mu.Lock() + e.cursorLine++ + e.cursorCol = 0 + e.mu.Unlock() + } + if isShift { + updateShift() + } + + case fyne.KeyUp: + if isShift { + beginShift() + } else { + cancelShift() + } + if cl > 0 { + e.mu.Lock() + e.cursorLine-- + prevLen := len([]rune(lines[e.cursorLine])) + if e.cursorCol > prevLen { + e.cursorCol = prevLen + } + e.mu.Unlock() + } + if isShift { + updateShift() + } + + case fyne.KeyDown: + if isShift { + beginShift() + } else { + cancelShift() + } + if cl < len(lines)-1 { + e.mu.Lock() + e.cursorLine++ + nextLen := len([]rune(lines[e.cursorLine])) + if e.cursorCol > nextLen { + e.cursorCol = nextLen + } + e.mu.Unlock() + } + if isShift { + updateShift() + } + + case fyne.KeyHome: + if isShift { + beginShift() + } else { + cancelShift() + } + e.mu.Lock() + e.cursorCol = 0 + e.mu.Unlock() + if isShift { + updateShift() + } + + case fyne.KeyEnd: + if isShift { + beginShift() + } else { + cancelShift() + } + e.mu.Lock() + e.cursorCol = len(runes) + e.mu.Unlock() + if isShift { + updateShift() + } + + case fyne.KeyPageUp: + visible := e.visibleLineCount() + visual := e.visual() + curVLine, _ := logicalToVisual(visual, e.cursorLine, e.cursorCol) + targetVLine := curVLine - visible + if targetVLine < 0 { + targetVLine = 0 + } + vl := visual[targetVLine] + e.mu.Lock() + e.cursorLine = vl.logicalIdx + e.cursorCol = vl.colOffset + e.mu.Unlock() + cancelShift() + + case fyne.KeyPageDown: + visible := e.visibleLineCount() + visual := e.visual() + curVLine, _ := logicalToVisual(visual, e.cursorLine, e.cursorCol) + targetVLine := curVLine + visible + if targetVLine >= len(visual) { + targetVLine = len(visual) - 1 + } + vl := visual[targetVLine] + e.mu.Lock() + e.cursorLine = vl.logicalIdx + e.cursorCol = vl.colOffset + e.mu.Unlock() + cancelShift() + } + + e.ensureCursorVisible() + e.resetBlink() + e.Refresh() +} + +func (e *TextViewEntry) TypedShortcut(s fyne.Shortcut) { + if cs, ok := s.(*desktop.CustomShortcut); ok { + if cs.KeyName == fyne.KeyS && cs.Modifier == fyne.KeyModifierControl { + if e.OnSend != nil { + e.OnSend() + } + return + } + } + + switch s.(type) { + case *fyne.ShortcutUndo: + e.mu.Lock() + if len(e.undoStack) > 0 { + undo := e.undoStack[len(e.undoStack)-1] + e.undoStack = e.undoStack[:len(e.undoStack)-1] + e.rawFull = undo.raw + e.visualCacheValid = false + e.cursorLine = undo.cursorLine + e.cursorCol = undo.cursorCol + e.hasSelection = false + e.shiftSelecting = false + e.shiftHeld = false + } + e.mu.Unlock() + e.resetBlink() + e.Refresh() + + case *fyne.ShortcutSelectAll: + lines := e.logicalLines() + totalLines := len(lines) + e.selAnchorLine, e.selAnchorCol = 0, 0 + e.selLine = totalLines - 1 + if e.selLine < 0 { + e.selLine = 0 + } + e.selCol = len([]rune(lines[e.selLine])) + e.hasSelection = true + e.Refresh() + + case *fyne.ShortcutCopy: + if e.win != nil && e.hasSelection { + e.win.Clipboard().SetContent(e.selectedText()) + } + + case *fyne.ShortcutCut: + if e.win == nil || !e.hasSelection { + return + } + e.win.Clipboard().SetContent(e.selectedText()) + lines := e.logicalLines() + prevRaw := strings.Join(lines, "\n") + prevCL, prevCC := e.cursorLine, e.cursorCol + lines = e.deleteSelection(lines) + e.pushUndo(prevRaw, prevCL, prevCC) + e.commitLines(lines) + e.ensureCursorVisible() + e.resetBlink() + e.Refresh() + + case *fyne.ShortcutPaste: + if e.win == nil { + return + } + text := e.win.Clipboard().Content() + if text == "" { + return + } + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + lines := e.logicalLines() + prevRaw := strings.Join(lines, "\n") + prevCL, prevCC := e.cursorLine, e.cursorCol + if e.hasSelection { + lines = e.deleteSelection(lines) + } + cl := e.cursorLine + cc := e.cursorCol + if cl >= len(lines) { + cl = len(lines) - 1 + } + runes := []rune(lines[cl]) + if cc > len(runes) { + cc = len(runes) + } + pasteLines := strings.Split(text, "\n") + if len(pasteLines) == 1 { + newRunes := make([]rune, 0, len(runes)+len([]rune(text))) + newRunes = append(newRunes, runes[:cc]...) + newRunes = append(newRunes, []rune(text)...) + newRunes = append(newRunes, runes[cc:]...) + lines[cl] = string(newRunes) + e.pushUndo(prevRaw, prevCL, prevCC) + e.commitLines(lines) + e.mu.Lock() + e.cursorLine = cl + e.cursorCol = cc + len([]rune(pasteLines[0])) + e.mu.Unlock() + } else { + firstPart := string(runes[:cc]) + pasteLines[0] + lastPart := pasteLines[len(pasteLines)-1] + string(runes[cc:]) + newLines := make([]string, 0, len(lines)+len(pasteLines)-1) + newLines = append(newLines, lines[:cl]...) + newLines = append(newLines, firstPart) + newLines = append(newLines, pasteLines[1:len(pasteLines)-1]...) + newLines = append(newLines, lastPart) + newLines = append(newLines, lines[cl+1:]...) + e.pushUndo(prevRaw, prevCL, prevCC) + e.commitLines(newLines) + e.mu.Lock() + e.cursorLine = cl + len(pasteLines) - 1 + e.cursorCol = len([]rune(pasteLines[len(pasteLines)-1])) + e.mu.Unlock() + } + e.ensureCursorVisible() + e.resetBlink() + e.Refresh() + } +} + +func (e *TextViewEntry) FocusGained() { + e.startBlink() + e.Refresh() +} + +func (e *TextViewEntry) FocusLost() { + e.stopBlink() + e.shiftHeld = false + // Only clear selection state if we are not mid-drag. When dragging outside + // the window Fyne fires FocusLost but the user is still interacting. + if !e.dragging { + e.shiftSelecting = false + } + e.Refresh() +} + +func (e *TextViewEntry) KeyDown(ev *fyne.KeyEvent) { + if ev.Name == desktop.KeyShiftLeft || ev.Name == desktop.KeyShiftRight { + e.shiftHeld = true + } +} + +func (e *TextViewEntry) KeyUp(ev *fyne.KeyEvent) { + if ev.Name == desktop.KeyShiftLeft || ev.Name == desktop.KeyShiftRight { + e.shiftHeld = false + e.shiftSelecting = false + } +} + +// -------------------------------------------------------------------------- +// Renderer +// -------------------------------------------------------------------------- + +type tveRenderer struct { + entry *TextViewEntry + size fyne.Size + + selBgs []*canvas.Rectangle + tokenSlots [][]*canvas.Text + cursorRect *canvas.Rectangle + slots int + + scrollTrack *canvas.Rectangle + scrollThumb *canvas.Rectangle + + lastWidth float32 // tracks width to invalidate visual cache on resize +} + +func newTVERenderer(entry *TextViewEntry) *tveRenderer { + r := &tveRenderer{entry: entry} + r.scrollTrack = canvas.NewRectangle(color.Transparent) + r.scrollThumb = canvas.NewRectangle(color.Transparent) + r.cursorRect = canvas.NewRectangle(color.Transparent) + return r +} + +// Destroy is called when the widget is removed. Stop the blink goroutine. +func (r *tveRenderer) Destroy() { + r.entry.stopBlink() +} + +func (r *tveRenderer) Objects() []fyne.CanvasObject { + total := 3 + len(r.selBgs) + len(r.tokenSlots)*tvMaxTokensPerLine + objs := make([]fyne.CanvasObject, 0, total) + objs = append(objs, r.scrollTrack, r.scrollThumb) + for _, selBg := range r.selBgs { + objs = append(objs, selBg) + } + for _, slot := range r.tokenSlots { + for _, t := range slot { + objs = append(objs, t) + } + } + objs = append(objs, r.cursorRect) + return objs +} + +func (r *tveRenderer) MinSize() fyne.Size { + return fyne.NewSize(200, tvLineH*3+tvPadY*2) +} + +func (r *tveRenderer) Layout(size fyne.Size) { + r.size = size + r.checkWrapWidth() + r.growSlots() + r.layoutContent() +} + +func (r *tveRenderer) Refresh() { + r.checkWrapWidth() + r.growSlots() + r.layoutContent() + canvas.Refresh(r.entry) +} + +func (r *tveRenderer) checkWrapWidth() { + availWidth := r.size.Width - tvPadX*2 - tvScrollW + if availWidth < 1 { + return + } + if r.lastWidth != availWidth { + r.lastWidth = availWidth + // Invalidate visual cache so wrapping is recomputed at new width. + r.entry.mu.Lock() + r.entry.visualCacheRaw = "" + r.entry.mu.Unlock() + } +} + +func (r *tveRenderer) growSlots() { + bodyHeight := r.size.Height - tvPadY*2 + if bodyHeight < 0 { + bodyHeight = 0 + } + needed := int(bodyHeight/tvLineH) + 2 + for r.slots < needed { + selBg := canvas.NewRectangle(color.Transparent) + r.selBgs = append(r.selBgs, selBg) + tokenSlot := make([]*canvas.Text, tvMaxTokensPerLine) + for i := range tokenSlot { + t := canvas.NewText("", color.White) + t.TextStyle = fyne.TextStyle{Monospace: true} + t.TextSize = theme.TextSize() + tokenSlot[i] = t + } + r.tokenSlots = append(r.tokenSlots, tokenSlot) + r.slots++ + } + for slot := needed; slot < r.slots; slot++ { + r.selBgs[slot].Hide() + for _, t := range r.tokenSlots[slot] { + t.Hide() + } + } +} + +func (r *tveRenderer) visibleLines() int { + bodyHeight := r.size.Height - tvPadY*2 + if bodyHeight < 0 { + return 0 + } + return int(bodyHeight/tvLineH) + 1 +} + +func (r *tveRenderer) layoutContent() { + entry := r.entry + visual := entry.visual() + totalLines := len(visual) + + charWidth := fyne.MeasureText("M", theme.TextSize(), fyne.TextStyle{Monospace: true}).Width + if charWidth <= 0 { + charWidth = theme.TextSize() * 0.6 + } + + entry.mu.Lock() + scrollOffset := entry.scrollOffset + hasSelection := entry.hasSelection + cursorLogLine := entry.cursorLine + cursorLogCol := entry.cursorCol + cursorVisible := entry.cursorVisible + var selStartLog, selStartCol, selEndLog, selEndCol int + if hasSelection { + selStartLog, selStartCol, selEndLog, selEndCol = entry.normaliseSelection() + } + entry.mu.Unlock() + + visible := r.visibleLines() + maxOffset := totalLines - visible + if maxOffset < 0 { + maxOffset = 0 + } + if scrollOffset > maxOffset { + scrollOffset = maxOffset + entry.mu.Lock() + entry.scrollOffset = scrollOffset + entry.mu.Unlock() + } + + needed := int((r.size.Height-tvPadY*2)/tvLineH) + 2 + cursorVLine, cursorVCol := logicalToVisual(visual, cursorLogLine, cursorLogCol) + cursorDrawn := false + yPos := tvPadY + + for slot := 0; slot < needed && slot < r.slots; slot++ { + lineIdx := scrollOffset + slot + selBg := r.selBgs[slot] + + if lineIdx >= totalLines { + selBg.FillColor = color.Transparent + selBg.Resize(fyne.NewSize(0, 0)) + selBg.Refresh() + selBg.Hide() + for _, t := range r.tokenSlots[slot] { + t.Hide() + } + yPos += tvLineH + continue + } + + vl := visual[lineIdx] + + // Selection highlight. Fix 5: use strict < chunkEnd for start boundary. + if hasSelection { + vSelStart, vSelEnd := -1, -1 + chunkStart := vl.colOffset + chunkEnd := vl.colOffset + len([]rune(vl.raw)) + isLastChunk := lineIdx+1 >= len(visual) || visual[lineIdx+1].logicalIdx != vl.logicalIdx + + switch { + case vl.logicalIdx > selStartLog && vl.logicalIdx < selEndLog: + vSelStart = 0 + vSelEnd = len([]rune(vl.raw)) + case vl.logicalIdx == selStartLog && vl.logicalIdx == selEndLog: + if selStartCol < chunkEnd && selEndCol > chunkStart { + vSelStart = selStartCol - chunkStart + vSelEnd = selEndCol - chunkStart + } + case vl.logicalIdx == selStartLog: + if selStartCol < chunkEnd { + vSelStart = selStartCol - chunkStart + vSelEnd = len([]rune(vl.raw)) + // Extend one extra char width for visual continuity if not last chunk. + if !isLastChunk { + vSelEnd++ + } + } + case vl.logicalIdx == selEndLog: + if selEndCol > chunkStart { + vSelStart = 0 + vSelEnd = selEndCol - chunkStart + } + } + + if vSelStart < 0 { + vSelStart = 0 + } + lineLen := len([]rune(vl.raw)) + if vSelEnd > lineLen { + vSelEnd = lineLen + } + + if vSelStart >= 0 && vSelEnd > vSelStart { + selBg.Move(fyne.NewPos(tvPadX+float32(vSelStart)*charWidth, yPos)) + selBg.Resize(fyne.NewSize(float32(vSelEnd-vSelStart)*charWidth, tvLineH)) + selBg.FillColor = selectionColor() + selBg.Refresh() + selBg.Show() + } else { + selBg.FillColor = color.Transparent + selBg.Resize(fyne.NewSize(0, 0)) + selBg.Refresh() + selBg.Hide() + } + } else { + selBg.FillColor = color.Transparent + selBg.Resize(fyne.NewSize(0, 0)) + selBg.Refresh() + selBg.Hide() + } + + // Draw tokens. + xPos := tvPadX + tokenIdx := 0 + for _, token := range vl.tokens { + if tokenIdx >= tvMaxTokensPerLine { + break + } + if token.Text == "" { + continue + } + t := r.tokenSlots[slot][tokenIdx] + t.Text = token.Text + t.Color = tvColor(token.Kind) + t.TextStyle = fyne.TextStyle{Monospace: true} + t.TextSize = theme.TextSize() + t.Move(fyne.NewPos(xPos, yPos)) + t.Refresh() + t.Show() + xPos += fyne.MeasureText(token.Text, theme.TextSize(), fyne.TextStyle{Monospace: true}).Width + tokenIdx++ + } + for ; tokenIdx < tvMaxTokensPerLine; tokenIdx++ { + r.tokenSlots[slot][tokenIdx].Hide() + } + + // Draw cursor. + if lineIdx == cursorVLine && cursorVisible { + cursorX := tvPadX + float32(cursorVCol)*charWidth + r.cursorRect.Move(fyne.NewPos(cursorX, yPos+2)) + r.cursorRect.Resize(fyne.NewSize(2, tvLineH-4)) + r.cursorRect.FillColor = theme.Color(theme.ColorNameForeground) + r.cursorRect.Refresh() + r.cursorRect.Show() + cursorDrawn = true + } + + yPos += tvLineH + } + + if !cursorDrawn { + r.cursorRect.FillColor = color.Transparent + r.cursorRect.Resize(fyne.NewSize(0, 0)) + r.cursorRect.Refresh() + r.cursorRect.Hide() + } + + r.layoutScrollbar(totalLines) +} + +func (r *tveRenderer) layoutScrollbar(totalLines int) { + bodyHeight := r.size.Height - tvPadY*2 + trackW := float32(tvScrollW) + trackX := r.size.Width - trackW + if r.entry.scrollbarHovered { + trackW = tvScrollW + 6 + trackX = r.size.Width - trackW + } + r.scrollTrack.Move(fyne.NewPos(trackX, tvPadY)) + r.scrollTrack.Resize(fyne.NewSize(trackW, bodyHeight)) + + visibleLines := int(bodyHeight / tvLineH) + if totalLines <= visibleLines { + r.scrollTrack.FillColor = color.Transparent + r.scrollTrack.Refresh() + r.scrollThumb.FillColor = color.Transparent + r.scrollThumb.Refresh() + return + } + + r.scrollTrack.FillColor = theme.Color(theme.ColorNameScrollBar) + r.scrollTrack.Refresh() + + ratio := float32(visibleLines) / float32(totalLines) + thumbHeight := bodyHeight * ratio + if thumbHeight < tvScrollMinThumb { + thumbHeight = tvScrollMinThumb + } + scrollable := float32(totalLines - visibleLines) + thumbY := tvPadY + (float32(r.entry.scrollOffset)/scrollable)*(bodyHeight-thumbHeight) + + thumbW := float32(tvScrollW - 2) + thumbX := trackX + 1 + if r.entry.scrollbarHovered { + thumbW = trackW - 2 + thumbX = trackX + 1 + } + r.scrollThumb.Move(fyne.NewPos(thumbX, thumbY)) + r.scrollThumb.Resize(fyne.NewSize(thumbW, thumbHeight)) + r.scrollThumb.FillColor = theme.Color(theme.ColorNameForeground) + r.scrollThumb.CornerRadius = thumbW / 2 + r.scrollThumb.Refresh() +} + +// -------------------------------------------------------------------------- +// Input layer +// -------------------------------------------------------------------------- + +type tveInputLayer struct { + widget.BaseWidget + entry *TextViewEntry + draggingThumb bool + draggingText bool +} + +func newTVEInputLayer(entry *TextViewEntry) *tveInputLayer { + layer := &tveInputLayer{entry: entry} + layer.ExtendBaseWidget(layer) + return layer +} + +func (layer *tveInputLayer) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(canvas.NewRectangle(color.Transparent)) +} + +func (layer *tveInputLayer) Scrolled(ev *fyne.ScrollEvent) { layer.entry.Scrolled(ev) } + +func (layer *tveInputLayer) inScrollbarArea(xPos float32) bool { + if layer.entry.rend == nil { + return false + } + return xPos >= layer.entry.rend.size.Width-tvScrollW +} + +func (layer *tveInputLayer) inThumb(xPos, yPos float32) bool { + if !layer.inScrollbarArea(xPos) { + return false + } + thumbY, thumbH, ok := layer.entry.thumbBounds() + if !ok { + return false + } + return yPos >= thumbY && yPos <= thumbY+thumbH +} + +func (layer *tveInputLayer) MouseDown(ev *desktop.MouseEvent) { + if ev.Button != desktop.MouseButtonPrimary { + return + } + layer.draggingThumb = layer.inThumb(ev.Position.X, ev.Position.Y) + if layer.draggingThumb { + return + } + if layer.inScrollbarArea(ev.Position.X) { + return + } + entry := layer.entry + line, col := entry.posFromPoint(ev.Position) + entry.selAnchorLine, entry.selAnchorCol = line, col + entry.selLine, entry.selCol = line, col + entry.cursorLine, entry.cursorCol = line, col + entry.hasSelection = false + entry.shiftSelecting = false + entry.shiftHeld = false + entry.dragging = false + entry.resetBlink() + entry.Refresh() + if entry.win != nil { + entry.win.Canvas().Focus(entry) + } +} + +func (layer *tveInputLayer) MouseUp(_ *desktop.MouseEvent) { + layer.draggingThumb = false + layer.draggingText = false + layer.entry.dragging = false +} + +func (layer *tveInputLayer) Tapped(_ *fyne.PointEvent) {} + +func (layer *tveInputLayer) DoubleTapped(ev *fyne.PointEvent) { + if layer.inScrollbarArea(ev.Position.X) { + return + } + entry := layer.entry + line, col := entry.posFromPoint(ev.Position) + lines := entry.logicalLines() + if line >= len(lines) { + return + } + runes := []rune(lines[line]) + if len(runes) == 0 { + return + } + if col >= len(runes) { + col = len(runes) - 1 + } + wordStart := col + for wordStart > 0 && tveIsWordRune(runes[wordStart-1]) { + wordStart-- + } + wordEnd := col + for wordEnd < len(runes) && tveIsWordRune(runes[wordEnd]) { + wordEnd++ + } + entry.selAnchorLine, entry.selAnchorCol = line, wordStart + entry.selLine, entry.selCol = line, wordEnd + entry.cursorLine, entry.cursorCol = line, wordEnd + entry.hasSelection = wordStart != wordEnd + entry.Refresh() +} + +func (layer *tveInputLayer) dragScrollbar(ev *fyne.DragEvent) { + entry := layer.entry + if entry.rend == nil { + return + } + totalLines := len(entry.visual()) + if totalLines == 0 { + return + } + bodyHeight := entry.rend.size.Height - tvPadY*2 + visibleLines := int(bodyHeight / tvLineH) + scrollable := totalLines - visibleLines + if scrollable <= 0 { + return + } + ratio := float32(visibleLines) / float32(totalLines) + thumbHeight := bodyHeight * ratio + if thumbHeight < tvScrollMinThumb { + thumbHeight = tvScrollMinThumb + } + trackHeight := bodyHeight - thumbHeight + if trackHeight <= 0 { + return + } + newThumbTop := ev.Position.Y - tvPadY - thumbHeight/2 + if newThumbTop < 0 { + newThumbTop = 0 + } + if newThumbTop > trackHeight { + newThumbTop = trackHeight + } + entry.scrollOffset = int((newThumbTop / trackHeight) * float32(scrollable)) + entry.Refresh() +} + +func (layer *tveInputLayer) Dragged(ev *fyne.DragEvent) { + if layer.draggingThumb { + layer.dragScrollbar(ev) + return + } + layer.draggingText = true + layer.entry.dragging = true + layer.entry.Dragged(ev) +} + +func (layer *tveInputLayer) DragEnd() { + wasDraggingText := layer.draggingText + layer.draggingThumb = false + layer.draggingText = false + layer.entry.dragging = false + if wasDraggingText && layer.entry.win != nil { + layer.entry.win.Canvas().Focus(layer.entry) + layer.entry.resetBlink() + } +} + +func (layer *tveInputLayer) MouseIn(_ *desktop.MouseEvent) {} + +func (layer *tveInputLayer) MouseOut() { + if layer.entry.scrollbarHovered { + layer.entry.scrollbarHovered = false + layer.entry.Refresh() + } +} + +func (layer *tveInputLayer) MouseMoved(ev *desktop.MouseEvent) { + inScrollbar := layer.inScrollbarArea(ev.Position.X) + if inScrollbar != layer.entry.scrollbarHovered { + layer.entry.scrollbarHovered = inScrollbar + layer.entry.Refresh() + } +} + +func tveIsWordRune(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' +} -- cgit v1.2.3