summaryrefslogtreecommitdiff
path: root/internal/ui/widgets
diff options
context:
space:
mode:
Diffstat (limited to 'internal/ui/widgets')
-rw-r--r--internal/ui/widgets/datatable.go876
-rw-r--r--internal/ui/widgets/datatable_test.go199
-rw-r--r--internal/ui/widgets/textview.go836
-rw-r--r--internal/ui/widgets/textview_test.go189
-rw-r--r--internal/ui/widgets/textviewentry.go1589
-rw-r--r--internal/ui/widgets/textviewentry_test.go381
-rw-r--r--internal/ui/widgets/tokeniser.go362
-rw-r--r--internal/ui/widgets/tokeniser_test.go213
8 files changed, 4645 insertions, 0 deletions
diff --git a/internal/ui/widgets/datatable.go b/internal/ui/widgets/datatable.go
new file mode 100644
index 0000000..2cf476d
--- /dev/null
+++ b/internal/ui/widgets/datatable.go
@@ -0,0 +1,876 @@
+package widgets
+
+import (
+ "image/color"
+
+ "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"
+)
+
+// DataTableColumn describes a single column header and its initial width.
+type DataTableColumn struct {
+ Header string
+ Width float32
+}
+
+// ContextMenuItem describes a single item in a right-click context menu.
+type ContextMenuItem struct {
+ Label string
+ Action func()
+}
+
+// Layout constants for DataTable. All sizes are in logical pixels.
+const (
+ dtRowH float32 = 32 // height of a data row
+ dtHdrH float32 = 34 // height of the header row
+ dtPadX float32 = 8 // horizontal cell padding
+ dtDivW float32 = 6 // width of a column-divider hit area
+ dtScrollW float32 = 8 // width of the scrollbar track
+
+ // dtNearBottomThreshold is how many rows from the end triggers OnNearBottom.
+ dtNearBottomThreshold = 20
+)
+
+// DataTable is a virtualised, row-selectable table widget with resizable
+// columns, right-click context menus, and a draggable scrollbar thumb.
+//
+// Create with NewDataTable, set all callback fields, call SetWindow, then
+// use the object returned by Build in your layout.
+type DataTable struct {
+ widget.BaseWidget
+
+ // Columns defines the header labels and initial widths.
+ Columns []DataTableColumn
+
+ // Data callbacks — may be updated at runtime; call Refresh after changes.
+ RowCount func() int
+ CellValue func(row, col int) string
+ CellStyle func(row, col int) widget.Importance // nil → MediumImportance
+ RowID func(row int) int64 // stable unique ID per row
+
+ // Interaction callbacks.
+ OnSelect func(row int)
+ MenuItems func(row int) []ContextMenuItem // nil → no context menu
+ OnNearBottom func() // fired when scrolled near the last row
+ RowBgColor func(row int) color.Color // nil → default background; non-nil overrides row background
+
+ colWidths []float32
+ selectedID int64
+ selectedRow int // last-selected row index; used for keyboard navigation
+ hasSelected bool
+ scrollOffset int
+ scrollFrac float32 // sub-row accumulator for smooth trackpad scrolling
+
+ nearBottomFired bool // debounce: only fire once per approach to the bottom
+
+ scrollbarHovered bool
+
+ win fyne.Window
+ rend *dtRenderer
+ inputLayer *dtInputLayer
+}
+
+// NewDataTable allocates and extends a DataTable.
+func NewDataTable() *DataTable {
+ t := &DataTable{}
+ t.ExtendBaseWidget(t)
+ return t
+}
+
+// SetWindow provides the window reference required for context menu anchoring.
+// Must be called before the widget is shown.
+func (t *DataTable) SetWindow(w fyne.Window) { t.win = w }
+
+// Build constructs the complete canvas object to place in a layout.
+// Call once after all callbacks are configured.
+func (t *DataTable) Build() fyne.CanvasObject {
+ t.initColWidths()
+ layer := newDTInputLayer(t)
+ t.inputLayer = layer
+ return container.NewStack(t, layer)
+}
+
+// SetSelected highlights the row identified by id. Pass 0 to clear.
+func (t *DataTable) SetSelected(id int64) {
+ t.selectedID = id
+ t.hasSelected = id != 0
+ t.Refresh()
+}
+
+// ClearSelection removes the current row highlight.
+func (t *DataTable) ClearSelection() {
+ t.selectedID = 0
+ t.hasSelected = false
+ t.Refresh()
+}
+
+// ScrollToRow scrolls the viewport so that row is visible.
+func (t *DataTable) ScrollToRow(row int) {
+ visible := t.visibleSlots()
+ if row < t.scrollOffset {
+ t.scrollOffset = row
+ } else if row >= t.scrollOffset+visible {
+ t.scrollOffset = row - visible + 1
+ }
+ if t.scrollOffset < 0 {
+ t.scrollOffset = 0
+ }
+ t.Refresh()
+}
+
+// Scrolled implements fyne.Scrollable, forwarding wheel events from the
+// input layer overlay.
+func (t *DataTable) Scrolled(ev *fyne.ScrollEvent) {
+ totalRows := t.rowCount()
+ visible := t.visibleSlots()
+ maxOffset := totalRows - visible
+ if maxOffset < 0 {
+ maxOffset = 0
+ }
+ t.scrollFrac -= ev.Scrolled.DY / dtRowH
+ rows := int(t.scrollFrac)
+ t.scrollFrac -= float32(rows)
+ if rows == 0 {
+ if ev.Scrolled.DY < 0 {
+ rows = 1
+ } else if ev.Scrolled.DY > 0 {
+ rows = -1
+ }
+ }
+ t.scrollOffset += rows
+ if t.scrollOffset < 0 {
+ t.scrollOffset = 0
+ }
+ if t.scrollOffset > maxOffset {
+ t.scrollOffset = maxOffset
+ }
+ t.Refresh()
+
+ // Fire OnNearBottom when the user scrolls within dtNearBottomThreshold rows
+ // of the end. Debounced: only fires once until the user scrolls back up.
+ if t.OnNearBottom != nil && totalRows > 0 {
+ rowsFromBottom := totalRows - (t.scrollOffset + visible)
+ if rowsFromBottom < dtNearBottomThreshold {
+ if !t.nearBottomFired {
+ t.nearBottomFired = true
+ t.OnNearBottom()
+ }
+ } else {
+ // Reset debounce when user scrolls back up far enough.
+ t.nearBottomFired = false
+ }
+ }
+}
+
+// CreateRenderer implements fyne.Widget.
+func (t *DataTable) CreateRenderer() fyne.WidgetRenderer {
+ t.initColWidths()
+ renderer := newDTRenderer(t)
+ t.rend = renderer
+ return renderer
+}
+
+func (t *DataTable) initColWidths() {
+ if len(t.colWidths) == len(t.Columns) {
+ return
+ }
+ t.colWidths = make([]float32, len(t.Columns))
+ for i, col := range t.Columns {
+ t.colWidths[i] = col.Width
+ }
+}
+
+func (t *DataTable) rowCount() int {
+ if t.RowCount == nil {
+ return 0
+ }
+ return t.RowCount()
+}
+
+func (t *DataTable) visibleSlots() int {
+ if t.rend == nil {
+ return 1
+ }
+ availHeight := t.rend.size.Height - dtHdrH
+ if availHeight < 0 {
+ return 0
+ }
+ return int(availHeight/dtRowH) + 2
+}
+
+// cellColor maps a widget.Importance value to a theme colour.
+func (t *DataTable) cellColor(row, col int) color.Color {
+ importance := widget.MediumImportance
+ if t.CellStyle != nil {
+ importance = t.CellStyle(row, col)
+ }
+ switch importance {
+ case widget.DangerImportance:
+ return theme.Color(theme.ColorNameError)
+ case widget.WarningImportance:
+ return theme.Color(theme.ColorNameWarning)
+ case widget.SuccessImportance:
+ return theme.Color(theme.ColorNameSuccess)
+ case widget.LowImportance:
+ return theme.Color(theme.ColorNamePlaceHolder)
+ case widget.HighImportance:
+ return theme.Color(theme.ColorNamePrimary)
+ default:
+ return theme.Color(theme.ColorNameForeground)
+ }
+}
+
+// minColWidth returns the minimum display width for col, measured from its
+// header text so the header is always fully readable.
+func (t *DataTable) minColWidth(col int) float32 {
+ if col >= len(t.Columns) {
+ return dtPadX * 2
+ }
+ textWidth := fyne.MeasureText(t.Columns[col].Header, theme.TextSize(), fyne.TextStyle{Bold: true}).Width
+ return textWidth + dtPadX*2
+}
+
+// thumbBounds returns the scrollbar thumb position and height in widget-local
+// coordinates. ok is false when all rows fit in the viewport.
+func (t *DataTable) thumbBounds() (thumbY, thumbH float32, ok bool) {
+ if t.rend == nil {
+ return 0, 0, false
+ }
+ totalRows := t.rowCount()
+ bodyHeight := t.rend.size.Height - dtHdrH
+ visibleRows := int(bodyHeight / dtRowH)
+ scrollable := totalRows - visibleRows
+ if scrollable <= 0 || bodyHeight <= 0 {
+ return 0, 0, false
+ }
+ ratio := float32(visibleRows) / float32(totalRows)
+ thumbH = bodyHeight * ratio
+ if thumbH < 20 {
+ thumbH = 20
+ }
+ trackHeight := bodyHeight - thumbH
+ thumbY = dtHdrH + (float32(t.scrollOffset)/float32(scrollable))*trackHeight
+ return thumbY, thumbH, true
+}
+
+func (t *DataTable) tappedRow(row int) {
+ if t.RowID != nil {
+ t.selectedID = t.RowID(row)
+ t.hasSelected = true
+ }
+ t.selectedRow = row
+ t.Refresh()
+ if t.OnSelect != nil {
+ t.OnSelect(row)
+ }
+ if t.win != nil {
+ t.win.Canvas().Focus(t)
+ }
+}
+
+// FocusGained, FocusLost, TypedRune, TypedKey implement fyne.Focusable.
+func (t *DataTable) FocusGained() { t.Refresh() }
+func (t *DataTable) FocusLost() { t.Refresh() }
+func (t *DataTable) TypedRune(_ rune) {}
+func (t *DataTable) TypedKey(ev *fyne.KeyEvent) {
+ if !t.hasSelected || t.RowCount == nil {
+ return
+ }
+ total := t.RowCount()
+ if total == 0 {
+ return
+ }
+ next := t.selectedRow
+ switch ev.Name {
+ case fyne.KeyUp:
+ next--
+ case fyne.KeyDown:
+ next++
+ default:
+ return
+ }
+ if next < 0 {
+ next = 0
+ }
+ if next >= total {
+ next = total - 1
+ }
+ if next == t.selectedRow {
+ return
+ }
+ t.ScrollToRow(next)
+ t.tappedRow(next)
+}
+
+func (t *DataTable) secondaryTappedRow(row int, absPos fyne.Position) {
+ if t.MenuItems == nil || t.win == nil {
+ return
+ }
+ items := t.MenuItems(row)
+ if len(items) == 0 {
+ return
+ }
+ menuItems := make([]*fyne.MenuItem, len(items))
+ for i, item := range items {
+ it := item
+ menuItems[i] = fyne.NewMenuItem(it.Label, it.Action)
+ }
+ widget.ShowPopUpMenuAtPosition(fyne.NewMenu("", menuItems...), t.win.Canvas(), absPos)
+}
+
+// dtRenderer draws the table using canvas primitives. Only visible row slots
+// are allocated; slots grow as the widget height increases but are never freed.
+type dtRenderer struct {
+ table *DataTable
+ size fyne.Size
+
+ headerBg *canvas.Rectangle
+ headerTexts []*canvas.Text
+ dividerLines []*canvas.Line
+
+ slots int
+ rowBgs []*canvas.Rectangle
+ cellTexts [][]*canvas.Text
+
+ scrollTrack *canvas.Rectangle
+ scrollThumb *canvas.Rectangle
+
+ objs []fyne.CanvasObject // cached; rebuilt only when slots grow
+}
+
+func newDTRenderer(table *DataTable) *dtRenderer {
+ nCols := len(table.Columns)
+ r := &dtRenderer{table: table}
+ r.headerBg = canvas.NewRectangle(color.Transparent)
+
+ r.headerTexts = make([]*canvas.Text, nCols)
+ for i, col := range table.Columns {
+ headerText := canvas.NewText(col.Header, color.Transparent)
+ headerText.TextStyle = fyne.TextStyle{Bold: true}
+ headerText.TextSize = theme.TextSize()
+ r.headerTexts[i] = headerText
+ }
+
+ if nCols > 1 {
+ r.dividerLines = make([]*canvas.Line, nCols-1)
+ for i := range r.dividerLines {
+ dividerLine := canvas.NewLine(color.Transparent)
+ dividerLine.StrokeWidth = 1
+ r.dividerLines[i] = dividerLine
+ }
+ }
+
+ r.scrollTrack = canvas.NewRectangle(color.Transparent)
+ r.scrollThumb = canvas.NewRectangle(color.Transparent)
+ r.rebuildObjects()
+ return r
+}
+
+func (r *dtRenderer) Destroy() {}
+
+func (r *dtRenderer) Objects() []fyne.CanvasObject { return r.objs }
+
+func (r *dtRenderer) MinSize() fyne.Size {
+ return fyne.NewSize(100, dtHdrH+dtRowH*3)
+}
+
+func (r *dtRenderer) Layout(size fyne.Size) {
+ r.size = size
+ r.growSlots()
+ r.layoutHeader()
+ r.layoutBody()
+ r.layoutScrollbar()
+}
+
+func (r *dtRenderer) Refresh() {
+ r.table.initColWidths()
+ r.growSlots()
+ r.layoutHeader()
+ r.layoutBody()
+ r.updateCells()
+ r.layoutScrollbar()
+ canvas.Refresh(r.table)
+}
+
+// rebuildObjects reconstructs the cached object slice after slots grow.
+func (r *dtRenderer) rebuildObjects() {
+ nFixed := 1 + len(r.headerTexts) + len(r.dividerLines) + 2
+ total := nFixed + len(r.rowBgs)
+ for _, row := range r.cellTexts {
+ total += len(row)
+ }
+ objs := make([]fyne.CanvasObject, 0, total)
+ objs = append(objs, r.headerBg)
+ for _, headerText := range r.headerTexts {
+ objs = append(objs, headerText)
+ }
+ for _, dividerLine := range r.dividerLines {
+ objs = append(objs, dividerLine)
+ }
+ for _, rowBg := range r.rowBgs {
+ objs = append(objs, rowBg)
+ }
+ for _, row := range r.cellTexts {
+ for _, cellText := range row {
+ objs = append(objs, cellText)
+ }
+ }
+ objs = append(objs, r.scrollTrack, r.scrollThumb)
+ r.objs = objs
+}
+
+func (r *dtRenderer) growSlots() {
+ bodyHeight := r.size.Height - dtHdrH
+ if bodyHeight < 0 {
+ bodyHeight = 0
+ }
+ needed := int(bodyHeight/dtRowH) + 2
+ nCols := len(r.table.Columns)
+
+ grew := false
+ for r.slots < needed {
+ rowBg := canvas.NewRectangle(color.Transparent)
+ r.rowBgs = append(r.rowBgs, rowBg)
+ cellRow := make([]*canvas.Text, nCols)
+ for col := 0; col < nCols; col++ {
+ cellText := canvas.NewText("", color.Transparent)
+ cellText.TextSize = theme.TextSize()
+ cellRow[col] = cellText
+ }
+ r.cellTexts = append(r.cellTexts, cellRow)
+ r.slots++
+ grew = true
+ }
+ if grew {
+ r.rebuildObjects()
+ }
+
+ for slot := needed; slot < r.slots; slot++ {
+ r.rowBgs[slot].Hide()
+ for _, cellText := range r.cellTexts[slot] {
+ cellText.Hide()
+ }
+ }
+}
+
+func (r *dtRenderer) layoutHeader() {
+ r.headerBg.Move(fyne.NewPos(0, 0))
+ r.headerBg.Resize(fyne.NewSize(r.size.Width, dtHdrH))
+ r.headerBg.FillColor = theme.Color(theme.ColorNameHeaderBackground)
+ r.headerBg.Refresh()
+
+ xPos := float32(0)
+ for i, headerText := range r.headerTexts {
+ if i >= len(r.table.colWidths) {
+ headerText.Hide()
+ continue
+ }
+ colWidth := r.table.colWidths[i]
+ headerText.Move(fyne.NewPos(xPos+dtPadX, (dtHdrH-theme.TextSize())/2))
+ headerText.Color = theme.Color(theme.ColorNameForeground)
+ headerText.Text = r.table.Columns[i].Header
+ headerText.Refresh()
+ headerText.Show()
+
+ if i < len(r.dividerLines) {
+ divX := xPos + colWidth - 0.5
+ r.dividerLines[i].Position1 = fyne.NewPos(divX, 4)
+ r.dividerLines[i].Position2 = fyne.NewPos(divX, dtHdrH-4)
+ r.dividerLines[i].StrokeColor = theme.Color(theme.ColorNameSeparator)
+ r.dividerLines[i].Refresh()
+ r.dividerLines[i].Show()
+ }
+ xPos += colWidth
+ }
+}
+
+func (r *dtRenderer) layoutBody() {
+ totalRows := r.table.rowCount()
+ needed := int((r.size.Height-dtHdrH)/dtRowH) + 2
+
+ yPos := dtHdrH
+ for slot := 0; slot < needed && slot < r.slots; slot++ {
+ dataIdx := r.table.scrollOffset + slot
+ rowBg := r.rowBgs[slot]
+ rowBg.Move(fyne.NewPos(0, yPos))
+ rowBg.Resize(fyne.NewSize(r.size.Width, dtRowH))
+
+ if dataIdx >= totalRows {
+ rowBg.FillColor = color.Transparent
+ rowBg.Refresh()
+ rowBg.Show()
+ for _, cellText := range r.cellTexts[slot] {
+ cellText.Hide()
+ }
+ yPos += dtRowH
+ continue
+ }
+
+ switch {
+ case r.table.hasSelected && r.table.RowID != nil && r.table.RowID(dataIdx) == r.table.selectedID:
+ rowBg.FillColor = theme.Color(theme.ColorNameSelection)
+ default:
+ if r.table.RowBgColor != nil {
+ if c := r.table.RowBgColor(dataIdx); c != nil {
+ rowBg.FillColor = c
+ } else {
+ rowBg.FillColor = theme.Color(theme.ColorNameBackground)
+ }
+ } else {
+ rowBg.FillColor = theme.Color(theme.ColorNameBackground)
+ }
+ }
+ rowBg.Refresh()
+ rowBg.Show()
+
+ xPos := float32(0)
+ for col, colWidth := range r.table.colWidths {
+ if col >= len(r.cellTexts[slot]) {
+ break
+ }
+ cellText := r.cellTexts[slot][col]
+ cellText.Move(fyne.NewPos(xPos+dtPadX, yPos+(dtRowH-theme.TextSize())/2))
+ cellText.Show()
+ xPos += colWidth
+ }
+ yPos += dtRowH
+ }
+}
+
+func (r *dtRenderer) layoutScrollbar() {
+ totalRows := r.table.rowCount()
+ bodyHeight := r.size.Height - dtHdrH
+ trackW := dtScrollW
+ trackX := r.size.Width - trackW
+ if r.table.scrollbarHovered {
+ trackW = dtScrollW + 6
+ trackX = r.size.Width - trackW
+ }
+
+ r.scrollTrack.Move(fyne.NewPos(trackX, dtHdrH))
+ r.scrollTrack.Resize(fyne.NewSize(trackW, bodyHeight))
+
+ visibleRows := int(bodyHeight / dtRowH)
+ if totalRows <= visibleRows {
+ 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()
+
+ thumbY, thumbH, ok := r.table.thumbBounds()
+ if !ok {
+ r.scrollThumb.FillColor = color.Transparent
+ r.scrollThumb.Refresh()
+ return
+ }
+
+ thumbW := trackW - 2
+ r.scrollThumb.Move(fyne.NewPos(trackX+1, thumbY))
+ r.scrollThumb.Resize(fyne.NewSize(thumbW, thumbH))
+ r.scrollThumb.FillColor = theme.Color(theme.ColorNameForeground)
+ r.scrollThumb.CornerRadius = thumbW / 2
+ r.scrollThumb.Refresh()
+}
+
+func (r *dtRenderer) updateCells() {
+ totalRows := r.table.rowCount()
+ needed := int((r.size.Height-dtHdrH)/dtRowH) + 2
+
+ for slot := 0; slot < needed && slot < r.slots; slot++ {
+ dataIdx := r.table.scrollOffset + slot
+ if dataIdx >= totalRows {
+ for _, cellText := range r.cellTexts[slot] {
+ cellText.Text = ""
+ cellText.Refresh()
+ }
+ continue
+ }
+ for col, cellText := range r.cellTexts[slot] {
+ if col >= len(r.table.colWidths) {
+ cellText.Text = ""
+ cellText.Refresh()
+ continue
+ }
+ if r.table.CellValue != nil {
+ cellText.Text = truncateText(r.table.CellValue(dataIdx, col), cellText.TextSize, cellText.TextStyle, r.table.colWidths[col]-dtPadX*2)
+ }
+ cellText.Color = r.table.cellColor(dataIdx, col)
+ cellText.Refresh()
+ }
+ }
+}
+
+// truncateText shortens s to fit within maxWidth pixels, appending an ellipsis.
+func truncateText(s string, size float32, style fyne.TextStyle, maxWidth float32) string {
+ if maxWidth <= 0 {
+ return ""
+ }
+ if fyne.MeasureText(s, size, style).Width <= maxWidth {
+ return s
+ }
+ const ellipsis = "..."
+ budget := maxWidth - fyne.MeasureText(ellipsis, size, style).Width
+ if budget <= 0 {
+ return ellipsis
+ }
+ runes := []rune(s)
+ lo, hi := 0, len(runes)
+ for lo < hi {
+ mid := (lo + hi + 1) / 2
+ if fyne.MeasureText(string(runes[:mid]), size, style).Width <= budget {
+ lo = mid
+ } else {
+ hi = mid - 1
+ }
+ }
+ return string(runes[:lo]) + ellipsis
+}
+
+// dtInputLayer is a transparent widget overlaid on DataTable via
+// container.NewStack. It routes all pointer events: row taps, context menus,
+// scrollbar thumb drag, and mouse-wheel scroll.
+type dtInputLayer struct {
+ widget.BaseWidget
+ table *DataTable
+ dividers []*colDivider
+ draggingThumb bool
+}
+
+func newDTInputLayer(table *DataTable) *dtInputLayer {
+ layer := &dtInputLayer{table: table}
+ layer.ExtendBaseWidget(layer)
+ for i := 0; i < len(table.Columns)-1; i++ {
+ layer.dividers = append(layer.dividers, newColDivider(table, i))
+ }
+ return layer
+}
+
+func (layer *dtInputLayer) CreateRenderer() fyne.WidgetRenderer {
+ objs := make([]fyne.CanvasObject, len(layer.dividers))
+ for i, divider := range layer.dividers {
+ objs[i] = divider
+ }
+ return &dtInputRenderer{layer: layer, objs: objs}
+}
+
+func (layer *dtInputLayer) inScrollbarArea(xPos float32) bool {
+ if layer.table.rend == nil {
+ return false
+ }
+ return xPos >= layer.table.rend.size.Width-dtScrollW
+}
+
+func (layer *dtInputLayer) inThumb(xPos, yPos float32) bool {
+ if !layer.inScrollbarArea(xPos) {
+ return false
+ }
+ thumbY, thumbH, ok := layer.table.thumbBounds()
+ if !ok {
+ return false
+ }
+ return yPos >= thumbY && yPos <= thumbY+thumbH
+}
+
+func (layer *dtInputLayer) rowAtY(yPos float32) int {
+ if yPos < dtHdrH {
+ return -1
+ }
+ dataIdx := layer.table.scrollOffset + int((yPos-dtHdrH)/dtRowH)
+ if dataIdx < 0 || dataIdx >= layer.table.rowCount() {
+ return -1
+ }
+ return dataIdx
+}
+
+func (layer *dtInputLayer) Scrolled(ev *fyne.ScrollEvent) { layer.table.Scrolled(ev) }
+
+func (layer *dtInputLayer) Tapped(ev *fyne.PointEvent) {
+ if layer.inScrollbarArea(ev.Position.X) {
+ return
+ }
+ if row := layer.rowAtY(ev.Position.Y); row >= 0 {
+ layer.table.tappedRow(row)
+ }
+}
+
+func (layer *dtInputLayer) TappedSecondary(ev *fyne.PointEvent) {
+ if layer.inScrollbarArea(ev.Position.X) {
+ return
+ }
+ if row := layer.rowAtY(ev.Position.Y); row >= 0 {
+ layer.table.secondaryTappedRow(row, ev.AbsolutePosition)
+ }
+}
+
+func (layer *dtInputLayer) MouseDown(ev *desktop.MouseEvent) {
+ layer.draggingThumb = layer.inThumb(ev.Position.X, ev.Position.Y)
+}
+
+func (layer *dtInputLayer) MouseUp(_ *desktop.MouseEvent) { layer.draggingThumb = false }
+
+func (layer *dtInputLayer) Dragged(ev *fyne.DragEvent) {
+ if !layer.draggingThumb {
+ return
+ }
+ table := layer.table
+ if table.rend == nil {
+ return
+ }
+ widgetPos := fyne.CurrentApp().Driver().AbsolutePositionForObject(table)
+ if ev.AbsolutePosition.Y < widgetPos.Y || ev.AbsolutePosition.Y > widgetPos.Y+table.Size().Height {
+ return
+ }
+ _, thumbH, ok := table.thumbBounds()
+ if !ok {
+ return
+ }
+ bodyHeight := table.rend.size.Height - dtHdrH
+ trackHeight := bodyHeight - thumbH
+ if trackHeight <= 0 {
+ return
+ }
+ scrollable := table.rowCount() - int(bodyHeight/dtRowH)
+ if scrollable <= 0 {
+ return
+ }
+ thumbTop := ev.Position.Y - dtHdrH - thumbH/2
+ if thumbTop < 0 {
+ thumbTop = 0
+ }
+ if thumbTop > trackHeight {
+ thumbTop = trackHeight
+ }
+ table.scrollOffset = int((thumbTop / trackHeight) * float32(scrollable))
+ table.Refresh()
+}
+
+func (layer *dtInputLayer) DragEnd() { layer.draggingThumb = false }
+func (layer *dtInputLayer) MouseIn(_ *desktop.MouseEvent) {}
+func (layer *dtInputLayer) MouseOut() {
+ if layer.table.scrollbarHovered {
+ layer.table.scrollbarHovered = false
+ layer.table.Refresh()
+ }
+}
+func (layer *dtInputLayer) MouseMoved(ev *desktop.MouseEvent) {
+ inScrollbar := layer.inScrollbarArea(ev.Position.X)
+ if inScrollbar != layer.table.scrollbarHovered {
+ layer.table.scrollbarHovered = inScrollbar
+ layer.table.Refresh()
+ }
+}
+
+// dtInputRenderer positions column-divider widgets within the input layer.
+type dtInputRenderer struct {
+ layer *dtInputLayer
+ objs []fyne.CanvasObject
+}
+
+func (r *dtInputRenderer) Destroy() {}
+func (r *dtInputRenderer) MinSize() fyne.Size { return fyne.NewSize(0, 0) }
+func (r *dtInputRenderer) Objects() []fyne.CanvasObject { return r.objs }
+
+func (r *dtInputRenderer) Layout(size fyne.Size) {
+ table := r.layer.table
+ xPos := float32(0)
+ for i, divider := range r.layer.dividers {
+ if i >= len(table.colWidths) {
+ break
+ }
+ xPos += table.colWidths[i]
+ divider.Move(fyne.NewPos(xPos-dtDivW/2, 0))
+ divider.Resize(fyne.NewSize(dtDivW, size.Height))
+ }
+}
+
+func (r *dtInputRenderer) Refresh() {
+ r.Layout(r.layer.Size())
+ canvas.Refresh(r.layer)
+}
+
+// colDivider is a transparent widget positioned at each column boundary.
+// It presents an H-resize cursor and handles column-resize drag events.
+type colDivider struct {
+ widget.BaseWidget
+ table *DataTable
+ col int // index of the column to the left of this divider
+}
+
+func newColDivider(table *DataTable, col int) *colDivider {
+ divider := &colDivider{table: table, col: col}
+ divider.ExtendBaseWidget(divider)
+ return divider
+}
+
+func (d *colDivider) CreateRenderer() fyne.WidgetRenderer {
+ return widget.NewSimpleRenderer(canvas.NewRectangle(color.Transparent))
+}
+
+func (d *colDivider) Cursor() desktop.Cursor { return desktop.HResizeCursor }
+func (d *colDivider) MouseIn(_ *desktop.MouseEvent) {}
+func (d *colDivider) MouseOut() {}
+func (d *colDivider) MouseMoved(_ *desktop.MouseEvent) {}
+
+// Dragged resizes the column to the left of this divider.
+func (d *colDivider) Dragged(ev *fyne.DragEvent) {
+ if d.col >= len(d.table.colWidths) {
+ return
+ }
+
+ delta := ev.Dragged.DX
+ leftWidth := d.table.colWidths[d.col]
+ leftMin := d.table.minColWidth(d.col)
+
+ if leftWidth+delta < leftMin {
+ delta = leftMin - leftWidth
+ }
+ if delta == 0 {
+ return
+ }
+
+ if d.col+1 < len(d.table.colWidths) {
+ rightWidth := d.table.colWidths[d.col+1]
+ rightMin := d.table.minColWidth(d.col + 1)
+
+ if delta > 0 {
+ spareSpace := float32(0)
+ if d.table.rend != nil {
+ totalWidth := float32(0)
+ for _, colWidth := range d.table.colWidths {
+ totalWidth += colWidth
+ }
+ spareSpace = d.table.rend.size.Width - totalWidth - dtScrollW
+ if spareSpace < 0 {
+ spareSpace = 0
+ }
+ }
+ takenFromRight := delta - spareSpace
+ if takenFromRight > 0 {
+ if rightWidth-takenFromRight < rightMin {
+ takenFromRight = rightWidth - rightMin
+ }
+ d.table.colWidths[d.col+1] -= takenFromRight
+ delta = spareSpace + takenFromRight
+ }
+ } else {
+ d.table.colWidths[d.col+1] -= delta
+ }
+ }
+
+ d.table.colWidths[d.col] = leftWidth + delta
+ d.table.Refresh()
+ if d.table.inputLayer != nil {
+ d.table.inputLayer.Refresh()
+ }
+}
+
+func (d *colDivider) DragEnd() {}
diff --git a/internal/ui/widgets/datatable_test.go b/internal/ui/widgets/datatable_test.go
new file mode 100644
index 0000000..c0c7092
--- /dev/null
+++ b/internal/ui/widgets/datatable_test.go
@@ -0,0 +1,199 @@
+package widgets
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestTable(rowCount int) *DataTable {
+ dt := NewDataTable()
+ dt.Columns = []DataTableColumn{
+ {Header: "Col A", Width: 100},
+ {Header: "Col B", Width: 200},
+ }
+ dt.RowCount = func() int { return rowCount }
+ dt.RowID = func(row int) int64 { return int64(row + 1) }
+ dt.CellValue = func(row, col int) string { return "cell" }
+ return dt
+}
+
+// ── rowCount ─────────────────────────────────────────────────────────────────
+
+func TestDataTable_RowCount_Nil(t *testing.T) {
+ dt := NewDataTable()
+ assert.Equal(t, 0, dt.rowCount())
+}
+
+func TestDataTable_RowCount(t *testing.T) {
+ dt := newTestTable(42)
+ assert.Equal(t, 42, dt.rowCount())
+}
+
+// ── initColWidths ────────────────────────────────────────────────────────────
+
+func TestDataTable_InitColWidths(t *testing.T) {
+ dt := newTestTable(0)
+ dt.initColWidths()
+
+ require.Len(t, dt.colWidths, 2)
+ assert.Equal(t, float32(100), dt.colWidths[0])
+ assert.Equal(t, float32(200), dt.colWidths[1])
+}
+
+func TestDataTable_InitColWidths_Idempotent(t *testing.T) {
+ dt := newTestTable(0)
+ dt.initColWidths()
+ dt.colWidths[0] = 999
+ dt.initColWidths()
+
+ assert.Equal(t, float32(999), dt.colWidths[0], "initColWidths must not overwrite existing widths")
+}
+
+// ── SetSelected / ClearSelection ─────────────────────────────────────────────
+
+func TestDataTable_SetSelected(t *testing.T) {
+ dt := newTestTable(5)
+ dt.hasSelected = false
+
+ dt.selectedID = 3
+ dt.hasSelected = true
+
+ assert.True(t, dt.hasSelected)
+ assert.Equal(t, int64(3), dt.selectedID)
+}
+
+func TestDataTable_SetSelected_Zero_Clears(t *testing.T) {
+ dt := newTestTable(5)
+ dt.selectedID = 3
+ dt.hasSelected = true
+
+ dt.selectedID = 0
+ dt.hasSelected = false
+
+ assert.False(t, dt.hasSelected)
+}
+
+func TestDataTable_ClearSelection(t *testing.T) {
+ dt := newTestTable(5)
+ dt.selectedID = 5
+ dt.hasSelected = true
+
+ dt.selectedID = 0
+ dt.hasSelected = false
+
+ assert.False(t, dt.hasSelected)
+ assert.Equal(t, int64(0), dt.selectedID)
+}
+
+// ── tappedRow ────────────────────────────────────────────────────────────────
+
+func TestDataTable_TappedRow_SetsSelectionID(t *testing.T) {
+ dt := newTestTable(5)
+ dt.OnSelect = func(row int) {}
+ dt.rend = &dtRenderer{table: dt}
+
+ // Call internal logic directly without triggering Refresh.
+ if dt.RowID != nil {
+ dt.selectedID = dt.RowID(2)
+ dt.hasSelected = true
+ }
+
+ assert.True(t, dt.hasSelected)
+ assert.Equal(t, int64(3), dt.selectedID) // RowID = row+1
+}
+
+func TestDataTable_TappedRow_FiresOnSelect(t *testing.T) {
+ dt := newTestTable(5)
+ fired := -1
+ dt.OnSelect = func(row int) { fired = row }
+
+ if dt.RowID != nil {
+ dt.selectedID = dt.RowID(2)
+ dt.hasSelected = true
+ }
+ dt.OnSelect(2)
+
+ assert.Equal(t, 2, fired)
+}
+
+func TestDataTable_TappedRow_NoRowID_NoSelection(t *testing.T) {
+ dt := newTestTable(5)
+ dt.RowID = nil
+
+ if dt.RowID != nil {
+ dt.selectedID = dt.RowID(2)
+ dt.hasSelected = true
+ }
+
+ assert.False(t, dt.hasSelected)
+}
+
+// ── ScrollToRow ──────────────────────────────────────────────────────────────
+
+func TestDataTable_ScrollToRow_BelowViewport(t *testing.T) {
+ dt := newTestTable(100)
+ dt.rend = &dtRenderer{table: dt}
+ dt.scrollOffset = 0
+
+ row := 50
+ visible := 5 // simulated
+ if row >= dt.scrollOffset+visible {
+ dt.scrollOffset = row - visible + 1
+ }
+
+ assert.Greater(t, dt.scrollOffset, 0)
+}
+
+func TestDataTable_ScrollToRow_AboveViewport(t *testing.T) {
+ dt := newTestTable(100)
+ dt.scrollOffset = 30
+
+ row := 5
+ if row < dt.scrollOffset {
+ dt.scrollOffset = row
+ }
+
+ assert.Equal(t, 5, dt.scrollOffset)
+}
+
+func TestDataTable_ScrollToRow_NegativeClamp(t *testing.T) {
+ dt := newTestTable(10)
+ dt.scrollOffset = 5
+
+ dt.scrollOffset = 0
+ if dt.scrollOffset < 0 {
+ dt.scrollOffset = 0
+ }
+
+ assert.GreaterOrEqual(t, dt.scrollOffset, 0)
+}
+
+// ── thumbBounds ──────────────────────────────────────────────────────────────
+
+func TestDataTable_ThumbBounds_NoRenderer(t *testing.T) {
+ dt := newTestTable(50)
+ _, _, ok := dt.thumbBounds()
+ assert.False(t, ok)
+}
+
+func TestDataTable_ThumbBounds_AllRowsFit(t *testing.T) {
+ dt := newTestTable(3)
+ // Simulate a large viewport where all rows fit.
+ dt.rend = &dtRenderer{table: dt}
+ // 3 rows × 32px = 96px. Set viewport height > that.
+ dt.rend.size.Height = 500
+ _, _, ok := dt.thumbBounds()
+ assert.False(t, ok, "all rows fit — thumb not needed")
+}
+
+func TestDataTable_ThumbBounds_NeedsScroll(t *testing.T) {
+ dt := newTestTable(100)
+ dt.rend = &dtRenderer{table: dt}
+ dt.rend.size.Height = 200 // small viewport
+ _, thumbH, ok := dt.thumbBounds()
+
+ assert.True(t, ok)
+ assert.Greater(t, thumbH, float32(0))
+}
diff --git a/internal/ui/widgets/textview.go b/internal/ui/widgets/textview.go
new file mode 100644
index 0000000..6ec4b1e
--- /dev/null
+++ b/internal/ui/widgets/textview.go
@@ -0,0 +1,836 @@
+package widgets
+
+// TextView is a virtualised, syntax-highlighted, selectable read-only text
+// widget for Fyne.
+//
+// Architecture
+//
+// Fyne's widget.Entry performs a full layout pass on every SetText call,
+// making it unusable for large HTTP responses (minified JS/CSS etc.).
+// TextView solves this with the same virtualisation principle as DataTable:
+// only the visible lines are rendered as canvas objects.
+//
+// Pipeline
+//
+// SetText(s)
+// └─▶ goroutine: parse + wrap + tokenise (never blocks the UI thread)
+// └─▶ fyne.Do: store []tvLine, Refresh()
+// └─▶ renderer: draw only visible lines
+//
+// Syntax highlighting
+//
+// HTTP-aware tokeniser:
+// - Request line — method (error colour), path (primary), version (dimmed)
+// - Response line — version (dimmed), status code (success/warning/error), status text (dimmed)
+// - Header name — primary
+// - Header value — foreground
+// - Body (JSON) — keys (primary), strings (success), numbers (warning), booleans/null (error)
+// - Body (other) — plain foreground
+//
+// Text selection
+//
+// Selection is tracked as (line, col) pairs in visual (wrapped) line coordinates.
+// Click sets the anchor, drag extends, Ctrl+A selects all, Ctrl+C copies to clipboard.
+//
+// Scrolling
+//
+// Vertical only. Mouse wheel and scrollbar thumb drag both work.
+
+import (
+ "image/color"
+ "math"
+ "strings"
+ "sync"
+ "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"
+)
+
+// Layout constants for TextView. All sizes are in logical pixels.
+const (
+ tvLineH float32 = 20 // height of one text line
+ tvPadX float32 = 8 // left and right margin
+ tvPadY float32 = 4 // top and bottom margin
+ tvScrollW float32 = 8 // scrollbar track width
+ tvScrollMinThumb float32 = 20 // minimum scrollbar thumb height
+ tvMaxTokensPerLine = 64 // canvas text objects allocated per visible line
+)
+
+// TextView is the public widget. Use newTextView to create it.
+// Call SetText to load content; parsing never blocks the UI thread.
+// Use Build to obtain the canvas object to place in a layout.
+type TextView struct {
+ widget.BaseWidget
+
+ mu sync.RWMutex
+ lines []tvLine // processed visual lines, populated by background parser
+ rawFull string // original unprocessed text, used for select-all copy
+
+ scrollOffset int // index of the first visible line
+ scrollFrac float32 // sub-line accumulator for smooth trackpad scrolling
+
+ // Selection is tracked as anchor + cursor (line, col) pairs in visual-line
+ // coordinates. Anchor is set on mouse down; cursor moves during drag.
+ selAnchorLine int
+ selAnchorCol int
+ selLine int
+ selCol int
+ hasSelection bool
+
+ win fyne.Window
+ rend *tvRenderer
+
+ scrollbarHovered bool // true while mouse is over scrollbar track or thumb — widens thumb
+
+ pendingThumb *tvScrollThumb // created in Build before rend exists; wired in CreateRenderer
+ lastWrapWidth float32 // wrap width used for the last parse pass
+ pendingText string // text waiting to be parsed once width is known
+ processing bool // true while the background parse goroutine is running
+ gen int64
+}
+
+func NewTextView() *TextView {
+ view := &TextView{}
+ view.ExtendBaseWidget(view)
+ return view
+}
+
+// SetWindow provides the window reference required for clipboard access.
+// Must be called before the widget is shown.
+func (view *TextView) SetWindow(w fyne.Window) { view.win = w }
+
+// SetText replaces the displayed content. Parsing and line-wrapping run on a
+// background goroutine so the UI thread is never blocked.
+func (view *TextView) SetText(s string) {
+ view.mu.Lock()
+ view.pendingText = s
+ view.rawFull = s
+ view.scrollOffset = 0
+ view.scrollFrac = 0
+ view.hasSelection = false
+ alreadyProcessing := view.processing
+ currentWidth := view.lastWrapWidth
+ view.mu.Unlock()
+
+ if alreadyProcessing || currentWidth < 1 {
+ return
+ }
+ view.startProcessing(s, currentWidth)
+}
+
+// SetPlaceHolder is a no-op retained for API compatibility.
+func (view *TextView) SetPlaceHolder(_ string) {}
+
+// GetText returns the current raw text content.
+func (view *TextView) GetText() string {
+ view.mu.RLock()
+ defer view.mu.RUnlock()
+ return view.rawFull
+}
+
+func (view *TextView) startProcessing(s string, wrapWidth float32) {
+ view.mu.Lock()
+ view.gen++
+ currentGen := view.gen
+ view.processing = true
+ view.mu.Unlock()
+
+ go func(gen int64, text string, width float32) {
+ parsedLines := parseAndWrap(text, width)
+
+ fyne.Do(func() {
+ view.mu.Lock()
+ if gen != view.gen {
+ view.mu.Unlock()
+ return
+ }
+
+ view.lines = parsedLines
+ view.processing = false
+
+ pending := ""
+ if view.pendingText != text {
+ pending = view.pendingText
+ }
+ latestWidth := view.lastWrapWidth
+ view.mu.Unlock()
+
+ if pending != "" && latestWidth > 0 {
+ view.startProcessing(pending, latestWidth)
+ }
+
+ view.Refresh()
+ })
+ }(currentGen, s, wrapWidth)
+}
+
+// CreateRenderer implements fyne.Widget.
+func (view *TextView) CreateRenderer() fyne.WidgetRenderer {
+ renderer := newTVRenderer(view)
+ view.rend = renderer
+ if view.pendingThumb != nil {
+ renderer.thumbWidget = view.pendingThumb
+ view.pendingThumb = nil
+ }
+ return renderer
+}
+
+// Scrolled implements fyne.Scrollable.
+func (view *TextView) Scrolled(ev *fyne.ScrollEvent) {
+ view.mu.Lock()
+
+ totalLines := len(view.lines)
+
+ view.scrollFrac -= ev.Scrolled.DY / tvLineH
+ rows := int(view.scrollFrac)
+ view.scrollFrac -= float32(rows)
+ if rows == 0 {
+ if ev.Scrolled.DY < 0 {
+ rows = 1
+ } else if ev.Scrolled.DY > 0 {
+ rows = -1
+ }
+ }
+ view.scrollOffset += rows
+
+ visible := view.visibleLineCount()
+ maxOffset := max(totalLines-visible, 0)
+ if view.scrollOffset < 0 {
+ view.scrollOffset = 0
+ }
+ if view.scrollOffset > maxOffset {
+ view.scrollOffset = maxOffset
+ }
+
+ view.mu.Unlock()
+ view.Refresh()
+}
+
+func (view *TextView) visibleLineCount() int {
+ if view.rend == nil {
+ return 0
+ }
+ availHeight := view.rend.size.Height - tvPadY*2
+ if availHeight < 0 {
+ return 0
+ }
+ return int(availHeight / tvLineH)
+}
+
+// posFromPoint converts a canvas-local position to a (line, col) pair in
+// visual-line coordinates.
+func (view *TextView) posFromPoint(point fyne.Position) (line, col int) {
+ view.mu.RLock()
+ totalLines := len(view.lines)
+ view.mu.RUnlock()
+
+ lineIdx := max(view.scrollOffset+int((point.Y-tvPadY)/tvLineH), 0)
+ if lineIdx >= totalLines {
+ lineIdx = totalLines - 1
+ }
+ if lineIdx < 0 {
+ return 0, 0
+ }
+
+ view.mu.RLock()
+ rawLine := ""
+ if lineIdx < len(view.lines) {
+ rawLine = view.lines[lineIdx].Raw
+ }
+ view.mu.RUnlock()
+
+ runes := []rune(rawLine)
+ charWidth := fyne.MeasureText("M", theme.TextSize(), fyne.TextStyle{Monospace: true}).Width
+ if charWidth <= 0 {
+ charWidth = theme.TextSize() * 0.6
+ }
+ col = min(max(int((point.X-tvPadX)/charWidth), 0), len(runes))
+ return lineIdx, col
+}
+
+// selectedText returns the currently selected plain text.
+func (view *TextView) selectedText() string {
+ if !view.hasSelection {
+ return ""
+ }
+ view.mu.RLock()
+ defer view.mu.RUnlock()
+
+ startLine, startCol, endLine, endCol := view.normaliseSelection()
+ if startLine == endLine {
+ runes := []rune(view.lines[startLine].Raw)
+ if startCol > len(runes) {
+ startCol = len(runes)
+ }
+ if endCol > len(runes) {
+ endCol = len(runes)
+ }
+ return string(runes[startCol:endCol])
+ }
+
+ var builder strings.Builder
+ for lineIdx := startLine; lineIdx <= endLine; lineIdx++ {
+ if lineIdx >= len(view.lines) {
+ break
+ }
+ runes := []rune(view.lines[lineIdx].Raw)
+ selStart, selEnd := 0, len(runes)
+ if lineIdx == startLine {
+ selStart = startCol
+ }
+ if lineIdx == endLine {
+ selEnd = endCol
+ if selEnd > len(runes) {
+ selEnd = len(runes)
+ }
+ }
+ if selStart > len(runes) {
+ selStart = len(runes)
+ }
+ builder.WriteString(string(runes[selStart:selEnd]))
+ if lineIdx < endLine {
+ builder.WriteByte('\n')
+ }
+ }
+ return builder.String()
+}
+
+// normaliseSelection returns (startLine, startCol, endLine, endCol) in reading order.
+func (view *TextView) normaliseSelection() (int, int, int, int) {
+ anchorLine, anchorCol := view.selAnchorLine, view.selAnchorCol
+ cursorLine, cursorCol := view.selLine, view.selCol
+ if anchorLine > cursorLine || (anchorLine == cursorLine && anchorCol > cursorCol) {
+ return cursorLine, cursorCol, anchorLine, anchorCol
+ }
+ return anchorLine, anchorCol, cursorLine, cursorCol
+}
+
+func (view *TextView) TypedShortcut(s fyne.Shortcut) {
+ switch s.(type) {
+ case *fyne.ShortcutCopy:
+ if view.win != nil && view.hasSelection {
+ fyne.CurrentApp().Clipboard().SetContent(view.selectedText())
+ }
+ case *fyne.ShortcutSelectAll:
+ view.mu.RLock()
+ totalLines := len(view.lines)
+ lastRaw := ""
+ if totalLines > 0 {
+ lastRaw = view.lines[totalLines-1].Raw
+ }
+ view.mu.RUnlock()
+ view.selAnchorLine, view.selAnchorCol = 0, 0
+ view.selLine = max(totalLines-1, 0)
+ view.selCol = len([]rune(lastRaw))
+ view.hasSelection = true
+ view.Refresh()
+ }
+}
+
+func (view *TextView) TypedKey(_ *fyne.KeyEvent) {}
+func (view *TextView) TypedRune(_ rune) {}
+func (view *TextView) FocusGained() { view.Refresh() }
+func (view *TextView) FocusLost() {
+ view.hasSelection = false
+ view.Refresh()
+}
+
+// Build constructs the complete canvas object to place in a layout.
+// Call once after all callbacks are configured.
+func (view *TextView) Build() fyne.CanvasObject {
+ thumb := newTVScrollThumb(view)
+ view.pendingThumb = thumb
+ return container.NewStack(view, newTVInputLayer(view), thumb)
+}
+
+// tvInputLayer is a transparent widget overlaid on TextView via
+// container.NewStack. It handles all pointer input: text selection,
+// scrollbar interaction, and mouse-wheel scroll forwarding.
+type tvInputLayer struct {
+ widget.BaseWidget
+ view *TextView
+}
+
+func newTVInputLayer(view *TextView) *tvInputLayer {
+ layer := &tvInputLayer{view: view}
+ layer.ExtendBaseWidget(layer)
+ return layer
+}
+
+func (layer *tvInputLayer) CreateRenderer() fyne.WidgetRenderer {
+ return widget.NewSimpleRenderer(canvas.NewRectangle(color.Transparent))
+}
+
+func (layer *tvInputLayer) Scrolled(ev *fyne.ScrollEvent) { layer.view.Scrolled(ev) }
+
+func (layer *tvInputLayer) inScrollbarArea(xPos float32) bool {
+ if layer.view.rend == nil {
+ return false
+ }
+ return xPos >= layer.view.rend.size.Width-tvScrollW
+}
+
+// MouseDown sets the selection anchor on button press so drag always starts
+// from the correct position.
+func (layer *tvInputLayer) MouseDown(ev *desktop.MouseEvent) {
+ if layer.inScrollbarArea(ev.Position.X) {
+ return
+ }
+ view := layer.view
+ line, col := view.posFromPoint(ev.Position)
+ view.selAnchorLine, view.selAnchorCol = line, col
+ view.selLine, view.selCol = line, col
+ view.hasSelection = false
+ view.Refresh()
+ if view.win != nil {
+ view.win.Canvas().Focus(view)
+ }
+}
+
+func (layer *tvInputLayer) MouseUp(_ *desktop.MouseEvent) {}
+
+// Tapped satisfies fyne.Tappable; selection state is managed in MouseDown.
+func (layer *tvInputLayer) Tapped(_ *fyne.PointEvent) {}
+
+func (layer *tvInputLayer) DoubleTapped(ev *fyne.PointEvent) {
+ if layer.inScrollbarArea(ev.Position.X) {
+ return
+ }
+ view := layer.view
+ line, col := view.posFromPoint(ev.Position)
+ view.mu.RLock()
+ rawLine := ""
+ if line < len(view.lines) {
+ rawLine = view.lines[line].Raw
+ }
+ view.mu.RUnlock()
+
+ runes := []rune(rawLine)
+ if len(runes) == 0 {
+ return
+ }
+ if col >= len(runes) {
+ col = len(runes) - 1
+ }
+
+ wordStart := col
+ for wordStart > 0 && isWordRune(runes[wordStart-1]) {
+ wordStart--
+ }
+ wordEnd := col
+ for wordEnd < len(runes) && isWordRune(runes[wordEnd]) {
+ wordEnd++
+ }
+ view.selAnchorLine, view.selAnchorCol = line, wordStart
+ view.selLine, view.selCol = line, wordEnd
+ view.hasSelection = wordStart != wordEnd
+ view.Refresh()
+}
+
+func (layer *tvInputLayer) Dragged(ev *fyne.DragEvent) {
+ if layer.inScrollbarArea(ev.Position.X) {
+ return
+ }
+ view := layer.view
+ line, col := view.posFromPoint(ev.Position)
+ view.selLine, view.selCol = line, col
+ view.hasSelection = !(view.selAnchorLine == view.selLine && view.selAnchorCol == view.selCol)
+ view.Refresh()
+}
+
+func (layer *tvInputLayer) DragEnd() {}
+func (layer *tvInputLayer) MouseIn(_ *desktop.MouseEvent) {}
+
+func (layer *tvInputLayer) MouseOut() {
+ if layer.view.scrollbarHovered {
+ layer.view.scrollbarHovered = false
+ layer.view.Refresh()
+ }
+}
+
+func (layer *tvInputLayer) MouseMoved(ev *desktop.MouseEvent) {
+ inScrollbar := layer.inScrollbarArea(ev.Position.X)
+ if inScrollbar != layer.view.scrollbarHovered {
+ layer.view.scrollbarHovered = inScrollbar
+ layer.view.Refresh()
+ }
+}
+
+func isWordRune(r rune) bool {
+ return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-'
+}
+
+// tvScrollThumb is a draggable widget overlaid on the scrollbar thumb visual.
+// It receives drag events and translates them to scroll offset changes.
+type tvScrollThumb struct {
+ widget.BaseWidget
+ view *TextView
+ rect *canvas.Rectangle
+}
+
+func newTVScrollThumb(view *TextView) *tvScrollThumb {
+ thumb := &tvScrollThumb{view: view}
+ thumb.rect = canvas.NewRectangle(color.Transparent)
+ thumb.ExtendBaseWidget(thumb)
+ return thumb
+}
+
+func (thumb *tvScrollThumb) CreateRenderer() fyne.WidgetRenderer {
+ return widget.NewSimpleRenderer(thumb.rect)
+}
+
+func (thumb *tvScrollThumb) Cursor() desktop.Cursor { return desktop.DefaultCursor }
+
+func (thumb *tvScrollThumb) MouseIn(_ *desktop.MouseEvent) {
+ if !thumb.view.scrollbarHovered {
+ thumb.view.scrollbarHovered = true
+ thumb.view.Refresh()
+ }
+}
+
+func (thumb *tvScrollThumb) MouseOut() {
+ if thumb.view.scrollbarHovered {
+ thumb.view.scrollbarHovered = false
+ thumb.view.Refresh()
+ }
+}
+
+func (thumb *tvScrollThumb) MouseMoved(_ *desktop.MouseEvent) {}
+
+func (thumb *tvScrollThumb) Dragged(ev *fyne.DragEvent) {
+ view := thumb.view
+ view.mu.RLock()
+ totalLines := len(view.lines)
+ view.mu.RUnlock()
+
+ if view.rend == nil || totalLines == 0 {
+ return
+ }
+
+ widgetPos := fyne.CurrentApp().Driver().AbsolutePositionForObject(view)
+ widgetBottom := widgetPos.Y + view.Size().Height
+ if ev.AbsolutePosition.Y < widgetPos.Y || ev.AbsolutePosition.Y > widgetBottom {
+ return
+ }
+
+ bodyHeight := view.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.AbsolutePosition.Y - widgetPos.Y - tvPadY - thumbHeight/2
+ if newThumbTop < 0 {
+ newThumbTop = 0
+ }
+ if newThumbTop > trackHeight {
+ newThumbTop = trackHeight
+ }
+
+ view.mu.Lock()
+ view.scrollOffset = int((newThumbTop / trackHeight) * float32(scrollable))
+ view.mu.Unlock()
+ view.Refresh()
+}
+
+func (thumb *tvScrollThumb) DragEnd() {}
+
+// tvRenderer draws the text content using canvas primitives.
+// Only visible line slots are allocated; slots grow as the widget grows taller.
+type tvRenderer struct {
+ view *TextView
+ size fyne.Size
+
+ selBgs []*canvas.Rectangle // one selection-highlight rectangle per visible slot
+ tokenSlots [][]*canvas.Text // [slot][tokenIndex] — pre-allocated token text objects
+ slots int
+
+ scrollTrack *canvas.Rectangle
+ scrollThumb *canvas.Rectangle // visual canvas rect for the scrollbar thumb
+ thumbWidget *tvScrollThumb // draggable overlay widget, positioned to match scrollThumb
+}
+
+func newTVRenderer(view *TextView) *tvRenderer {
+ renderer := &tvRenderer{view: view}
+ renderer.scrollTrack = canvas.NewRectangle(color.Transparent)
+ renderer.scrollThumb = canvas.NewRectangle(color.Transparent)
+ return renderer
+}
+
+func (renderer *tvRenderer) Destroy() {}
+
+func (renderer *tvRenderer) Objects() []fyne.CanvasObject {
+ total := 2 + len(renderer.selBgs) + len(renderer.tokenSlots)*tvMaxTokensPerLine
+ objs := make([]fyne.CanvasObject, 0, total)
+ objs = append(objs, renderer.scrollTrack, renderer.scrollThumb)
+ for _, selBg := range renderer.selBgs {
+ objs = append(objs, selBg)
+ }
+ for _, slot := range renderer.tokenSlots {
+ for _, tokenText := range slot {
+ objs = append(objs, tokenText)
+ }
+ }
+ return objs
+}
+
+func (renderer *tvRenderer) MinSize() fyne.Size {
+ return fyne.NewSize(200, tvLineH*3+tvPadY*2)
+}
+
+func (renderer *tvRenderer) Layout(size fyne.Size) {
+ renderer.size = size
+ renderer.checkWrapWidth()
+ renderer.growSlots()
+ renderer.layoutContent()
+}
+
+func (renderer *tvRenderer) Refresh() {
+ renderer.checkWrapWidth()
+ renderer.growSlots()
+ renderer.layoutContent()
+ canvas.Refresh(renderer.view)
+}
+
+// checkWrapWidth triggers a re-parse when the available text width changes.
+func (renderer *tvRenderer) checkWrapWidth() {
+ availWidth := renderer.size.Width - tvPadX*2 - tvScrollW
+ if availWidth < 1 {
+ return
+ }
+ renderer.view.mu.Lock()
+ prevWidth := renderer.view.lastWrapWidth
+ pendingText := renderer.view.pendingText
+ renderer.view.lastWrapWidth = availWidth
+ renderer.view.mu.Unlock()
+
+ if pendingText == "" {
+ return
+ }
+ if math.Abs(float64(prevWidth-availWidth)) > 4 || prevWidth < 1 {
+ renderer.view.startProcessing(pendingText, availWidth)
+ }
+}
+
+func (renderer *tvRenderer) growSlots() {
+ bodyHeight := renderer.size.Height - tvPadY*2
+ if bodyHeight < 0 {
+ bodyHeight = 0
+ }
+ needed := int(bodyHeight/tvLineH) + 2
+
+ // grow
+ for renderer.slots < needed {
+ selBg := canvas.NewRectangle(color.Transparent)
+ renderer.selBgs = append(renderer.selBgs, selBg)
+
+ slot := make([]*canvas.Text, tvMaxTokensPerLine)
+ for i := range slot {
+ t := canvas.NewText("", color.Transparent)
+ t.TextSize = theme.TextSize()
+ slot[i] = t
+ }
+ renderer.tokenSlots = append(renderer.tokenSlots, slot)
+
+ renderer.slots++
+ }
+
+ // hide unused (no truncation, keeps all content available)
+ for slot := needed; slot < renderer.slots; slot++ {
+ renderer.selBgs[slot].Hide()
+ for _, tokenText := range renderer.tokenSlots[slot] {
+ tokenText.Hide()
+ }
+ }
+}
+
+func (renderer *tvRenderer) layoutContent() {
+ renderer.view.mu.RLock()
+ lines := renderer.view.lines
+ scrollOffset := renderer.view.scrollOffset
+ hasSelection := renderer.view.hasSelection
+ var startLine, startCol, endLine, endCol int
+ if hasSelection {
+ startLine, startCol, endLine, endCol = renderer.view.normaliseSelection()
+ }
+ renderer.view.mu.RUnlock()
+
+ totalLines := len(lines)
+ needed := int((renderer.size.Height-tvPadY*2)/tvLineH) + 2
+ contentWidth := renderer.size.Width - tvScrollW
+ charWidth := fyne.MeasureText("M", theme.TextSize(), fyne.TextStyle{Monospace: true}).Width
+
+ yPos := tvPadY
+ for slot := 0; slot < needed && slot < renderer.slots; slot++ {
+ lineIdx := scrollOffset + slot
+ selBg := renderer.selBgs[slot]
+
+ if lineIdx >= totalLines {
+ selBg.FillColor = color.Transparent
+ selBg.Resize(fyne.NewSize(0, 0))
+ selBg.Refresh()
+ selBg.Hide()
+ for _, tokenText := range renderer.tokenSlots[slot] {
+ tokenText.Hide()
+ }
+ yPos += tvLineH
+ continue
+ }
+
+ // selection highlight background
+ if hasSelection && lineIdx >= startLine && lineIdx <= endLine {
+ line := lines[lineIdx]
+ runes := []rune(line.Raw)
+ lineLen := len(runes)
+
+ selStart := 0
+ selEnd := lineLen
+ if lineIdx == startLine {
+ selStart = startCol
+ }
+ if lineIdx == endLine {
+ selEnd = endCol
+ }
+ if selStart > lineLen {
+ selStart = lineLen
+ }
+ if selEnd > lineLen {
+ selEnd = lineLen
+ }
+
+ selX := tvPadX + float32(selStart)*charWidth
+ selWidth := float32(selEnd-selStart) * charWidth
+ if lineIdx < endLine && selEnd == lineLen {
+ selWidth += charWidth
+ }
+ selBg.Move(fyne.NewPos(selX, yPos))
+ selBg.Resize(fyne.NewSize(selWidth, tvLineH))
+ selBg.FillColor = selectionColor()
+ selBg.Refresh()
+ selBg.Show()
+ } else {
+ selBg.FillColor = color.Transparent
+ selBg.Resize(fyne.NewSize(0, 0))
+ selBg.Refresh()
+ selBg.Hide()
+ }
+
+ // token text objects
+ line := lines[lineIdx]
+ xPos := tvPadX
+ tokenIdx := 0
+ for _, token := range line.Tokens {
+ if tokenIdx >= tvMaxTokensPerLine {
+ break
+ }
+ if token.Text == "" {
+ continue
+ }
+ tokenText := renderer.tokenSlots[slot][tokenIdx]
+ maxWidth := contentWidth - xPos
+ displayText := truncateText(token.Text, theme.TextSize(), fyne.TextStyle{Monospace: true}, maxWidth)
+ if displayText == "" {
+ tokenText.Hide()
+ tokenIdx++
+ continue
+ }
+ tokenText.Text = displayText
+ tokenText.Color = tvColor(token.Kind)
+ tokenText.TextStyle = fyne.TextStyle{Monospace: true}
+ tokenText.TextSize = theme.TextSize()
+ tokenText.Move(fyne.NewPos(xPos, yPos))
+ tokenText.Refresh()
+ tokenText.Show()
+ xPos += fyne.MeasureText(token.Text, theme.TextSize(), fyne.TextStyle{Monospace: true}).Width
+ tokenIdx++
+ }
+ for ; tokenIdx < tvMaxTokensPerLine; tokenIdx++ {
+ renderer.tokenSlots[slot][tokenIdx].Hide()
+ }
+
+ yPos += tvLineH
+ }
+
+ renderer.layoutScrollbar(totalLines)
+}
+
+func (renderer *tvRenderer) layoutScrollbar(totalLines int) {
+ bodyHeight := renderer.size.Height - tvPadY*2
+ trackW := float32(tvScrollW)
+ trackX := renderer.size.Width - trackW
+ if renderer.view.scrollbarHovered {
+ trackW = tvScrollW + 6
+ trackX = renderer.size.Width - trackW
+ }
+
+ renderer.scrollTrack.Move(fyne.NewPos(trackX, tvPadY))
+ renderer.scrollTrack.Resize(fyne.NewSize(trackW, bodyHeight))
+
+ visibleLines := int(bodyHeight / tvLineH)
+ if totalLines <= visibleLines {
+ renderer.scrollTrack.FillColor = color.Transparent
+ renderer.scrollTrack.Refresh()
+ renderer.scrollThumb.FillColor = color.Transparent
+ renderer.scrollThumb.Refresh()
+ if renderer.thumbWidget != nil {
+ renderer.thumbWidget.Hide()
+ }
+ return
+ }
+
+ renderer.scrollTrack.FillColor = theme.Color(theme.ColorNameScrollBar)
+ renderer.scrollTrack.Refresh()
+
+ ratio := float32(visibleLines) / float32(totalLines)
+ thumbHeight := bodyHeight * ratio
+ if thumbHeight < tvScrollMinThumb {
+ thumbHeight = tvScrollMinThumb
+ }
+ scrollable := float32(totalLines - visibleLines)
+ thumbY := tvPadY + (float32(renderer.view.scrollOffset)/scrollable)*(bodyHeight-thumbHeight)
+
+ thumbW := float32(tvScrollW - 2)
+ thumbX := trackX + 1
+ if renderer.view.scrollbarHovered {
+ thumbW = trackW - 2
+ thumbX = trackX + 1
+ }
+
+ renderer.scrollThumb.Move(fyne.NewPos(thumbX, thumbY))
+ renderer.scrollThumb.Resize(fyne.NewSize(thumbW, thumbHeight))
+ renderer.scrollThumb.FillColor = theme.Color(theme.ColorNameForeground)
+ renderer.scrollThumb.CornerRadius = thumbW / 2
+ renderer.scrollThumb.Refresh()
+
+ if renderer.thumbWidget != nil {
+ renderer.thumbWidget.rect.FillColor = color.Transparent
+ renderer.thumbWidget.Move(fyne.NewPos(thumbX, thumbY))
+ renderer.thumbWidget.Resize(fyne.NewSize(thumbW, thumbHeight))
+ renderer.thumbWidget.Show()
+ }
+}
+
+func selectionColor() color.Color {
+ themeColor := theme.Color(theme.ColorNameSelection)
+ if nrgba, ok := themeColor.(color.NRGBA); ok {
+ nrgba.A = 180
+ return nrgba
+ }
+ return themeColor
+}
diff --git a/internal/ui/widgets/textview_test.go b/internal/ui/widgets/textview_test.go
new file mode 100644
index 0000000..b88fbc9
--- /dev/null
+++ b/internal/ui/widgets/textview_test.go
@@ -0,0 +1,189 @@
+package widgets
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// ── NewTextView / GetText / SetText ──────────────────────────────────────────
+
+func TestTextView_GetText_Empty(t *testing.T) {
+ tv := NewTextView()
+ assert.Equal(t, "", tv.GetText())
+}
+
+func TestTextView_SetText_StoresRaw(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.rawFull = "hello world"
+ tv.mu.Unlock()
+
+ assert.Equal(t, "hello world", tv.GetText())
+}
+
+func TestTextView_SetText_ResetsScroll(t *testing.T) {
+ tv := NewTextView()
+ tv.scrollOffset = 10
+
+ tv.mu.Lock()
+ tv.rawFull = "new content"
+ tv.scrollOffset = 0
+ tv.scrollFrac = 0
+ tv.hasSelection = false
+ tv.mu.Unlock()
+
+ assert.Equal(t, 0, tv.scrollOffset)
+}
+
+func TestTextView_SetText_ResetsSelection(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.hasSelection = true
+ tv.selAnchorLine, tv.selAnchorCol = 1, 3
+ tv.rawFull = "new"
+ tv.hasSelection = false
+ tv.mu.Unlock()
+
+ assert.False(t, tv.hasSelection)
+}
+
+func TestTextView_SetText_Empty(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.rawFull = "something"
+ tv.mu.Unlock()
+
+ tv.mu.Lock()
+ tv.rawFull = ""
+ tv.mu.Unlock()
+
+ assert.Equal(t, "", tv.GetText())
+}
+
+// ── normaliseSelection ───────────────────────────────────────────────────────
+
+func TestTextView_NormaliseSelection_Forward(t *testing.T) {
+ tv := NewTextView()
+ tv.selAnchorLine, tv.selAnchorCol = 0, 2
+ tv.selLine, tv.selCol = 1, 5
+
+ sl, sc, el, ec := tv.normaliseSelection()
+ assert.Equal(t, 0, sl)
+ assert.Equal(t, 2, sc)
+ assert.Equal(t, 1, el)
+ assert.Equal(t, 5, ec)
+}
+
+func TestTextView_NormaliseSelection_Reversed(t *testing.T) {
+ tv := NewTextView()
+ tv.selAnchorLine, tv.selAnchorCol = 1, 5
+ tv.selLine, tv.selCol = 0, 2
+
+ sl, sc, el, ec := tv.normaliseSelection()
+ assert.Equal(t, 0, sl)
+ assert.Equal(t, 2, sc)
+ assert.Equal(t, 1, el)
+ assert.Equal(t, 5, ec)
+}
+
+func TestTextView_NormaliseSelection_SamePosition(t *testing.T) {
+ tv := NewTextView()
+ tv.selAnchorLine, tv.selAnchorCol = 0, 3
+ tv.selLine, tv.selCol = 0, 3
+
+ sl, sc, el, ec := tv.normaliseSelection()
+ assert.Equal(t, 0, sl)
+ assert.Equal(t, 3, sc)
+ assert.Equal(t, 0, el)
+ assert.Equal(t, 3, ec)
+}
+
+// ── selectedText ─────────────────────────────────────────────────────────────
+
+func TestTextView_SelectedText_NoSelection(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.lines = []tvLine{{Raw: "hello world"}}
+ tv.mu.Unlock()
+ tv.hasSelection = false
+
+ assert.Equal(t, "", tv.selectedText())
+}
+
+func TestTextView_SelectedText_SameLine(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.lines = []tvLine{{Raw: "hello world"}}
+ tv.mu.Unlock()
+ tv.selAnchorLine, tv.selAnchorCol = 0, 0
+ tv.selLine, tv.selCol = 0, 5
+ tv.hasSelection = true
+
+ assert.Equal(t, "hello", tv.selectedText())
+}
+
+func TestTextView_SelectedText_MultiLine(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.lines = []tvLine{
+ {Raw: "line one"},
+ {Raw: "line two"},
+ }
+ tv.mu.Unlock()
+ tv.selAnchorLine, tv.selAnchorCol = 0, 5
+ tv.selLine, tv.selCol = 1, 4
+ tv.hasSelection = true
+
+ assert.Equal(t, "one\nline", tv.selectedText())
+}
+
+// ── pendingText ──────────────────────────────────────────────────────────────
+
+func TestTextView_SetText_StoresPendingText(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.pendingText = "pending content"
+ tv.rawFull = "pending content"
+ tv.mu.Unlock()
+
+ tv.mu.RLock()
+ pending := tv.pendingText
+ tv.mu.RUnlock()
+
+ assert.Equal(t, "pending content", pending)
+}
+
+// ── scroll offset clamping ───────────────────────────────────────────────────
+
+func TestTextView_ScrollOffset_ClampMin(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.lines = []tvLine{{Raw: "line one"}, {Raw: "line two"}}
+ tv.mu.Unlock()
+ tv.scrollOffset = 0
+
+ // Simulate scroll up past top.
+ tv.scrollOffset -= 5
+ if tv.scrollOffset < 0 {
+ tv.scrollOffset = 0
+ }
+
+ assert.Equal(t, 0, tv.scrollOffset)
+}
+
+func TestTextView_ScrollOffset_ClampMax(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.lines = make([]tvLine, 10)
+ tv.mu.Unlock()
+ tv.scrollOffset = 0
+
+ maxOffset := 10 - 3
+ tv.scrollOffset = 100
+ if tv.scrollOffset > maxOffset {
+ tv.scrollOffset = maxOffset
+ }
+
+ assert.Equal(t, maxOffset, tv.scrollOffset)
+}
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 == '-'
+}
diff --git a/internal/ui/widgets/textviewentry_test.go b/internal/ui/widgets/textviewentry_test.go
new file mode 100644
index 0000000..bbb20ef
--- /dev/null
+++ b/internal/ui/widgets/textviewentry_test.go
@@ -0,0 +1,381 @@
+package widgets
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTVE() *TextViewEntry {
+ e := &TextViewEntry{}
+ e.ExtendBaseWidget(e)
+ return e
+}
+
+// ── splitRaw ─────────────────────────────────────────────────────────────────
+
+func TestSplitRaw(t *testing.T) {
+ tests := []struct {
+ input string
+ want []string
+ }{
+ {"", []string{""}},
+ {"a\nb", []string{"a", "b"}},
+ {"a\r\nb", []string{"a", "b"}},
+ {"a\rb", []string{"a", "b"}},
+ {"a\nb\nc", []string{"a", "b", "c"}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got := splitRaw(tt.input)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+// ── buildVisualLines ─────────────────────────────────────────────────────────
+
+func TestBuildVisualLines_NoWrap(t *testing.T) {
+ lines := buildVisualLines([]string{"GET /foo HTTP/1.1", "Host: example.com"}, 80)
+ assert.Len(t, lines, 2)
+}
+
+func TestBuildVisualLines_Wraps(t *testing.T) {
+ long := strings.Repeat("x", 100)
+ lines := buildVisualLines([]string{long}, 40)
+
+ assert.Greater(t, len(lines), 2, "expected ≥3 visual lines for 100-char line at width 40")
+
+ for _, vl := range lines {
+ assert.Equal(t, 0, vl.logicalIdx, "all chunks should map to logicalIdx 0")
+ }
+
+ expected := 0
+ for _, vl := range lines {
+ assert.Equal(t, expected, vl.colOffset)
+ expected += len([]rune(vl.raw))
+ }
+}
+
+func TestBuildVisualLines_EmptyLine(t *testing.T) {
+ lines := buildVisualLines([]string{"GET /foo HTTP/1.1", "", `{"key":"val"}`}, 80)
+ assert.Len(t, lines, 3)
+}
+
+func TestBuildVisualLines_EmptyInput(t *testing.T) {
+ lines := buildVisualLines([]string{""}, 80)
+ assert.Len(t, lines, 1)
+}
+
+// ── logicalToVisual ──────────────────────────────────────────────────────────
+
+func TestLogicalToVisual_Simple(t *testing.T) {
+ visual := buildVisualLines([]string{"hello", "world"}, 80)
+
+ v, col := logicalToVisual(visual, 0, 0)
+ assert.Equal(t, 0, v)
+ assert.Equal(t, 0, col)
+
+ v, col = logicalToVisual(visual, 1, 3)
+ assert.Equal(t, 1, visual[v].logicalIdx)
+ assert.Equal(t, 3, col)
+}
+
+func TestLogicalToVisual_EndOfLine(t *testing.T) {
+ visual := buildVisualLines([]string{"hello"}, 80)
+
+ v, col := logicalToVisual(visual, 0, 5)
+ assert.Equal(t, 0, v)
+ assert.Equal(t, 5, col)
+}
+
+func TestLogicalToVisual_WrappedLine(t *testing.T) {
+ // "01234567890123456789" wrapped at 10 → chunks [0:10] [10:20]
+ visual := buildVisualLines([]string{"01234567890123456789"}, 10)
+
+ v, col := logicalToVisual(visual, 0, 11)
+ assert.Equal(t, 10, visual[v].colOffset, "col 11 should land in chunk starting at offset 10")
+ assert.Equal(t, 1, col)
+}
+
+func TestLogicalToVisual_EndOfWrappedLine(t *testing.T) {
+ // "01234567890123456789" wrapped at 10 → chunks [0:10] [10:20]
+ visual := buildVisualLines([]string{"01234567890123456789"}, 10)
+
+ v, _ := logicalToVisual(visual, 0, 20)
+ assert.Equal(t, visual[len(visual)-1].colOffset, visual[v].colOffset, "col at end should land in last chunk")
+}
+
+// ── normaliseSelection ───────────────────────────────────────────────────────
+
+func TestNormaliseSelection_Forward(t *testing.T) {
+ e := newTVE()
+ e.selAnchorLine, e.selAnchorCol = 0, 2
+ e.selLine, e.selCol = 1, 5
+
+ sl, sc, el, ec := e.normaliseSelection()
+ assert.Equal(t, 0, sl)
+ assert.Equal(t, 2, sc)
+ assert.Equal(t, 1, el)
+ assert.Equal(t, 5, ec)
+}
+
+func TestNormaliseSelection_Reversed(t *testing.T) {
+ e := newTVE()
+ e.selAnchorLine, e.selAnchorCol = 1, 5
+ e.selLine, e.selCol = 0, 2
+
+ sl, sc, el, ec := e.normaliseSelection()
+ assert.Equal(t, 0, sl)
+ assert.Equal(t, 2, sc)
+ assert.Equal(t, 1, el)
+ assert.Equal(t, 5, ec)
+}
+
+func TestNormaliseSelection_SamePosition(t *testing.T) {
+ e := newTVE()
+ e.selAnchorLine, e.selAnchorCol = 0, 3
+ e.selLine, e.selCol = 0, 3
+
+ sl, sc, el, ec := e.normaliseSelection()
+ assert.Equal(t, 0, sl)
+ assert.Equal(t, 3, sc)
+ assert.Equal(t, 0, el)
+ assert.Equal(t, 3, ec)
+}
+
+// ── selectedText ─────────────────────────────────────────────────────────────
+
+func TestSelectedText_SameLine(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello world"
+ e.mu.Unlock()
+ e.selAnchorLine, e.selAnchorCol = 0, 0
+ e.selLine, e.selCol = 0, 5
+ e.hasSelection = true
+
+ assert.Equal(t, "hello", e.selectedText())
+}
+
+func TestSelectedText_MultiLine(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "line one\nline two"
+ e.mu.Unlock()
+ e.selAnchorLine, e.selAnchorCol = 0, 5
+ e.selLine, e.selCol = 1, 4
+ e.hasSelection = true
+
+ assert.Equal(t, "one\nline", e.selectedText())
+}
+
+func TestSelectedText_NoSelection(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello"
+ e.mu.Unlock()
+ e.hasSelection = false
+
+ assert.Equal(t, "", e.selectedText())
+}
+
+// ── deleteSelection ──────────────────────────────────────────────────────────
+
+func TestDeleteSelection_SameLine(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello world"
+ e.mu.Unlock()
+ e.selAnchorLine, e.selAnchorCol = 0, 6
+ e.selLine, e.selCol = 0, 11
+ e.hasSelection = true
+
+ lines := e.logicalLines()
+ lines = e.deleteSelection(lines)
+ e.commitLines(lines)
+
+ assert.Equal(t, "hello ", e.GetText())
+ assert.Equal(t, 6, e.cursorCol)
+ assert.False(t, e.hasSelection)
+}
+
+func TestDeleteSelection_MultiLine(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "line one\nline two\nline three"
+ e.mu.Unlock()
+ e.selAnchorLine, e.selAnchorCol = 0, 5
+ e.selLine, e.selCol = 2, 4
+ e.hasSelection = true
+
+ lines := e.logicalLines()
+ lines = e.deleteSelection(lines)
+ e.commitLines(lines)
+
+ assert.Equal(t, "line three", e.GetText())
+}
+
+func TestDeleteSelection_ReversedAnchor(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello world"
+ e.mu.Unlock()
+ e.selAnchorLine, e.selAnchorCol = 0, 11
+ e.selLine, e.selCol = 0, 6
+ e.hasSelection = true
+
+ lines := e.logicalLines()
+ lines = e.deleteSelection(lines)
+ e.commitLines(lines)
+
+ assert.Equal(t, "hello ", e.GetText())
+}
+
+func TestDeleteSelection_NoSelection(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello"
+ e.mu.Unlock()
+ e.hasSelection = false
+
+ lines := e.logicalLines()
+ lines = e.deleteSelection(lines)
+ e.commitLines(lines)
+
+ assert.Equal(t, "hello", e.GetText())
+}
+
+// ── pushUndo / undo ──────────────────────────────────────────────────────────
+
+func TestPushUndo_StoresCursorPosition(t *testing.T) {
+ e := newTVE()
+ e.pushUndo("hello", 0, 3)
+
+ require.Len(t, e.undoStack, 1)
+ assert.Equal(t, "hello", e.undoStack[0].raw)
+ assert.Equal(t, 0, e.undoStack[0].cursorLine)
+ assert.Equal(t, 3, e.undoStack[0].cursorCol)
+}
+
+func TestPushUndo_StackLimit(t *testing.T) {
+ e := newTVE()
+ for i := 0; i < 110; i++ {
+ e.pushUndo("x", 0, 0)
+ }
+ assert.LessOrEqual(t, len(e.undoStack), 100)
+}
+
+func TestUndo_RestoresTextAndCursor(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello"
+ e.mu.Unlock()
+ e.cursorLine, e.cursorCol = 0, 5
+
+ e.pushUndo("hello", 0, 5)
+
+ e.mu.Lock()
+ e.rawFull = "hello world"
+ e.visualCacheRaw = ""
+ e.cursorLine = 0
+ e.cursorCol = 11
+ e.mu.Unlock()
+
+ e.mu.Lock()
+ undo := e.undoStack[len(e.undoStack)-1]
+ e.undoStack = e.undoStack[:len(e.undoStack)-1]
+ e.rawFull = undo.raw
+ e.visualCacheRaw = ""
+ e.cursorLine = undo.cursorLine
+ e.cursorCol = undo.cursorCol
+ e.mu.Unlock()
+
+ assert.Equal(t, "hello", e.GetText())
+ assert.Equal(t, 0, e.cursorLine)
+ assert.Equal(t, 5, e.cursorCol)
+}
+
+// ── visual cache ─────────────────────────────────────────────────────────────
+
+func TestVisualCache_InvalidatedOnCommit(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello"
+ e.mu.Unlock()
+
+ e.mu.Lock()
+ e.visualCache = buildVisualLines([]string{"hello"}, 80)
+ e.visualCacheRaw = "hello"
+ e.visualCacheWidth = 80
+ e.mu.Unlock()
+
+ e.commitLines([]string{"world"})
+
+ e.mu.Lock()
+ cacheRaw := e.visualCacheRaw
+ e.mu.Unlock()
+
+ assert.Equal(t, "", cacheRaw, "commitLines should invalidate visual cache")
+}
+
+func TestVisualCache_HitOnSecondCall(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "GET /foo HTTP/1.1\nHost: example.com"
+ e.visualCacheRaw = ""
+ e.mu.Unlock()
+
+ // Prime the cache manually without needing a renderer.
+ cpl := 80
+ e.mu.Lock()
+ built := buildVisualLines(splitRaw(e.rawFull), cpl)
+ e.visualCache = built
+ e.visualCacheRaw = e.rawFull
+ e.visualCacheWidth = cpl
+ e.mu.Unlock()
+
+ e.mu.Lock()
+ c1 := e.visualCache
+ c2 := e.visualCache
+ e.mu.Unlock()
+
+ require.NotEmpty(t, c1)
+ assert.Equal(t, &c1[0], &c2[0], "expected same slice on repeated access")
+}
+
+func TestVisualCache_MissAfterMutation(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello"
+ cpl := 80
+ e.visualCache = buildVisualLines([]string{"hello"}, cpl)
+ e.visualCacheRaw = "hello"
+ e.visualCacheWidth = cpl
+ e.mu.Unlock()
+
+ e.commitLines([]string{"world"})
+
+ e.mu.Lock()
+ cacheRaw := e.visualCacheRaw
+ e.mu.Unlock()
+
+ assert.Equal(t, "", cacheRaw, "cache should be invalidated after mutation")
+}
+
+// ── commitLines ──────────────────────────────────────────────────────────────
+
+func TestCommitLines_UpdatesText(t *testing.T) {
+ e := newTVE()
+ e.commitLines([]string{"new text"})
+ assert.Equal(t, "new text", e.GetText())
+}
+
+func TestCommitLines_MultiLine(t *testing.T) {
+ e := newTVE()
+ e.commitLines([]string{"line one", "line two"})
+ assert.Equal(t, "line one\nline two", e.GetText())
+}
diff --git a/internal/ui/widgets/tokeniser.go b/internal/ui/widgets/tokeniser.go
new file mode 100644
index 0000000..d471bcd
--- /dev/null
+++ b/internal/ui/widgets/tokeniser.go
@@ -0,0 +1,362 @@
+package widgets
+
+import (
+ "image/color"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/theme"
+)
+
+// tvTokenKind identifies the syntax role of a token for colour mapping.
+type tvTokenKind uint8
+
+const (
+ tvKindPlain tvTokenKind = iota
+ tvKindMethod // HTTP request method
+ tvKindPath // HTTP request path
+ tvKindVersion // HTTP version string
+ tvKindStatus2xx // 2xx response status code
+ tvKindStatus3xx // 3xx response status code
+ tvKindStatus4xx // 4xx response status code
+ tvKindStatus5xx // 5xx response status code
+ tvKindHdrName // header field name
+ tvKindHdrColon // colon separator after header name
+ tvKindHdrValue // header field value
+ tvKindJSONKey // JSON object key
+ tvKindJSONStr // JSON string value
+ tvKindJSONNum // JSON number
+ tvKindJSONBool // JSON true, false, null
+ tvKindJSONPunct // JSON structural punctuation: { } [ ] , :
+ tvKindLow // dimmed / de-emphasised text
+)
+
+// tvColor returns the theme colour for a given token kind.
+func tvColor(kind tvTokenKind) color.Color {
+ switch kind {
+ case tvKindMethod:
+ return theme.Color(theme.ColorNameError)
+ case tvKindPath:
+ return theme.Color(theme.ColorNamePrimary)
+ case tvKindVersion, tvKindLow:
+ return theme.Color(theme.ColorNamePlaceHolder)
+ case tvKindStatus2xx:
+ return theme.Color(theme.ColorNameSuccess)
+ case tvKindStatus3xx:
+ return theme.Color(theme.ColorNamePrimary)
+ case tvKindStatus4xx, tvKindStatus5xx:
+ return theme.Color(theme.ColorNameError)
+ case tvKindHdrName:
+ return theme.Color(theme.ColorNamePrimary)
+ case tvKindHdrColon, tvKindJSONPunct:
+ return theme.Color(theme.ColorNamePlaceHolder)
+ case tvKindHdrValue:
+ return theme.Color(theme.ColorNameForeground)
+ case tvKindJSONKey:
+ return theme.Color(theme.ColorNamePrimary)
+ case tvKindJSONStr:
+ return theme.Color(theme.ColorNameSuccess)
+ case tvKindJSONNum:
+ return theme.Color(theme.ColorNameWarning)
+ case tvKindJSONBool:
+ return theme.Color(theme.ColorNameError)
+ default:
+ return theme.Color(theme.ColorNameForeground)
+ }
+}
+
+// tvToken is a run of text sharing a single syntax colour.
+type tvToken struct {
+ Text string
+ Kind tvTokenKind
+}
+
+// tvLine is one visual line (after word-wrap) composed of tokens.
+// Raw holds the plain text for selection and clipboard operations.
+type tvLine struct {
+ Tokens []tvToken
+ Raw string
+}
+
+// parseAndWrap tokenises raw HTTP text and wraps each logical line to fit
+// within wrapWidth pixels. Returns the complete slice of visual lines.
+func parseAndWrap(s string, wrapWidth float32) []tvLine {
+ if s == "" {
+ return nil
+ }
+ s = strings.ReplaceAll(s, "\r\n", "\n")
+ s = strings.ReplaceAll(s, "\r", "\n")
+
+ logicalLines := strings.Split(s, "\n")
+
+ charWidth := fyne.MeasureText("M", theme.TextSize(), fyne.TextStyle{Monospace: true}).Width
+ if charWidth <= 0 {
+ charWidth = theme.TextSize() * 0.6
+ }
+ charsPerLine := max(int(wrapWidth/charWidth), 10)
+
+ bodyContentType := detectContentType(logicalLines)
+
+ var result []tvLine
+ inBody := false
+
+ for i, rawLine := range logicalLines {
+ if !inBody && strings.TrimSpace(rawLine) == "" && i > 0 {
+ inBody = true
+ result = append(result, tvLine{Tokens: []tvToken{{Text: "", Kind: tvKindPlain}}, Raw: ""})
+ continue
+ }
+
+ var tokens []tvToken
+ if !inBody {
+ tokens = tokeniseHTTPMeta(rawLine, i == 0)
+ } else {
+ tokens = tokeniseBody(rawLine, bodyContentType)
+ }
+
+ result = append(result, wrapTokens(tokens, rawLine, charsPerLine)...)
+ }
+ return result
+}
+
+// detectContentType scans the header section to determine the body content type.
+func detectContentType(lines []string) string {
+ for _, line := range lines {
+ lower := strings.ToLower(line)
+ if strings.HasPrefix(lower, "content-type:") {
+ switch {
+ case strings.Contains(lower, "json"):
+ return "json"
+ case strings.Contains(lower, "html"):
+ return "html"
+ default:
+ return "text"
+ }
+ }
+ if line == "" {
+ break
+ }
+ }
+ return "text"
+}
+
+// tokeniseHTTPMeta tokenises one line from the HTTP header section.
+// isFirstLine indicates the request or response line.
+func tokeniseHTTPMeta(line string, isFirstLine bool) []tvToken {
+ if line == "" {
+ return []tvToken{{Text: "", Kind: tvKindPlain}}
+ }
+ if isFirstLine {
+ return tokeniseFirstLine(line)
+ }
+ before, after, ok := strings.Cut(line, ":")
+ if !ok {
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+ }
+ return []tvToken{
+ {Text: before, Kind: tvKindHdrName},
+ {Text: ":", Kind: tvKindHdrColon},
+ {Text: after, Kind: tvKindHdrValue},
+ }
+}
+
+// tokeniseFirstLine tokenises the HTTP request or response line.
+func tokeniseFirstLine(line string) []tvToken {
+ if strings.TrimSpace(line) == "" {
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+ }
+
+ parts := strings.Fields(line)
+ if len(parts) == 0 {
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+ }
+
+ // Response line: HTTP/1.1 200 OK
+ if strings.HasPrefix(parts[0], "HTTP/") {
+ if len(parts) < 2 {
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+ }
+
+ statusKind := tvKindStatus2xx
+ switch parts[1][0] {
+ case '3':
+ statusKind = tvKindStatus3xx
+ case '4':
+ statusKind = tvKindStatus4xx
+ case '5':
+ statusKind = tvKindStatus5xx
+ }
+
+ tokens := []tvToken{
+ {Text: parts[0] + " ", Kind: tvKindVersion},
+ {Text: parts[1], Kind: statusKind},
+ }
+
+ if len(parts) > 2 {
+ tokens = append(tokens, tvToken{
+ Text: " " + strings.Join(parts[2:], " "),
+ Kind: tvKindLow,
+ })
+ }
+
+ return tokens
+ }
+
+ // Request line: GET /path HTTP/1.1
+ if len(parts) >= 3 {
+ return []tvToken{
+ {Text: parts[0] + " ", Kind: tvKindMethod},
+ {Text: parts[1], Kind: tvKindPath},
+ {Text: " " + strings.Join(parts[2:], " "), Kind: tvKindVersion},
+ }
+ }
+
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+}
+
+// tokeniseBody tokenises a body line according to its content type.
+func tokeniseBody(line string, contentType string) []tvToken {
+ if contentType == "json" {
+ return tokeniseJSON(line)
+ }
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+}
+
+// tokeniseJSON performs a single-pass tokenisation of a JSON line.
+func tokeniseJSON(line string) []tvToken {
+ if line == "" {
+ return []tvToken{{Text: "", Kind: tvKindPlain}}
+ }
+ var tokens []tvToken
+ runes := []rune(line)
+ pos := 0
+ for pos < len(runes) {
+ ch := runes[pos]
+ switch {
+ case ch == '"':
+ end := pos + 1
+ for end < len(runes) {
+ if runes[end] == '"' {
+ // Count preceding backslashes — even count means quote is unescaped.
+ numBackslashes := 0
+ for i := end - 1; i >= pos && runes[i] == '\\'; i-- {
+ numBackslashes++
+ }
+ if numBackslashes%2 == 0 {
+ break
+ }
+ }
+ end++
+ }
+ if end < len(runes) {
+ end++
+ }
+ str := string(runes[pos:end])
+ peek := end
+ for peek < len(runes) && runes[peek] == ' ' {
+ peek++
+ }
+ if peek < len(runes) && runes[peek] == ':' {
+ tokens = append(tokens, tvToken{Text: str, Kind: tvKindJSONKey})
+ } else {
+ tokens = append(tokens, tvToken{Text: str, Kind: tvKindJSONStr})
+ }
+ pos = end
+ case ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == ',' || ch == ':':
+ tokens = append(tokens, tvToken{Text: string(ch), Kind: tvKindJSONPunct})
+ pos++
+ case ch == '-' || (ch >= '0' && ch <= '9'):
+ end := pos + 1
+ for end < len(runes) && (runes[end] >= '0' && runes[end] <= '9' || runes[end] == '.' || runes[end] == 'e' || runes[end] == 'E' || runes[end] == '+' || runes[end] == '-') {
+ end++
+ }
+ tokens = append(tokens, tvToken{Text: string(runes[pos:end]), Kind: tvKindJSONNum})
+ pos = end
+ case ch == 't' || ch == 'f' || ch == 'n':
+ matched := false
+ for _, keyword := range []string{"true", "false", "null"} {
+ if strings.HasPrefix(string(runes[pos:]), keyword) {
+ tokens = append(tokens, tvToken{Text: keyword, Kind: tvKindJSONBool})
+ pos += len([]rune(keyword))
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ tokens = append(tokens, tvToken{Text: string(ch), Kind: tvKindPlain})
+ pos++
+ }
+ case ch == ' ' || ch == '\t':
+ end := pos + 1
+ for end < len(runes) && (runes[end] == ' ' || runes[end] == '\t') {
+ end++
+ }
+ tokens = append(tokens, tvToken{Text: string(runes[pos:end]), Kind: tvKindPlain})
+ pos = end
+ default:
+ tokens = append(tokens, tvToken{Text: string(ch), Kind: tvKindPlain})
+ pos++
+ }
+ }
+ return tokens
+}
+
+// wrapTokens splits a logical line into visual lines of at most charsPerLine runes.
+func wrapTokens(tokens []tvToken, rawLine string, charsPerLine int) []tvLine {
+ if charsPerLine <= 0 {
+ charsPerLine = 80
+ }
+
+ var result []tvLine
+ var current []tvToken
+ currentLen := 0
+
+ flush := func() {
+ if len(current) == 0 {
+ return
+ }
+ var raw strings.Builder
+ for _, t := range current {
+ raw.WriteString(t.Text)
+ }
+ result = append(result, tvLine{Tokens: current, Raw: raw.String()})
+ current = nil
+ currentLen = 0
+ }
+
+ for _, token := range tokens {
+ tokenLen := len([]rune(token.Text))
+
+ // never split JSON strings
+ if token.Kind == tvKindJSONStr || token.Kind == tvKindJSONKey {
+ if currentLen+tokenLen > charsPerLine {
+ flush()
+ }
+ current = append(current, token)
+ currentLen += tokenLen
+ continue
+ }
+
+ // split other tokens if needed
+ runes := []rune(token.Text)
+ for len(runes) > 0 {
+ spaceLeft := charsPerLine - currentLen
+ if spaceLeft <= 0 {
+ flush()
+ spaceLeft = charsPerLine
+ }
+
+ n := min(len(runes), spaceLeft)
+
+ current = append(current, tvToken{
+ Text: string(runes[:n]),
+ Kind: token.Kind,
+ })
+ currentLen += n
+ runes = runes[n:]
+ }
+ }
+
+ flush()
+ return result
+}
diff --git a/internal/ui/widgets/tokeniser_test.go b/internal/ui/widgets/tokeniser_test.go
new file mode 100644
index 0000000..11c4d63
--- /dev/null
+++ b/internal/ui/widgets/tokeniser_test.go
@@ -0,0 +1,213 @@
+package widgets
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ── detectContentType ────────────────────────────────────────────────────────
+
+func TestDetectContentType_JSON(t *testing.T) {
+ lines := []string{"POST /foo HTTP/1.1", "Content-Type: application/json", ""}
+ assert.Equal(t, "json", detectContentType(lines))
+}
+
+func TestDetectContentType_HTML(t *testing.T) {
+ lines := []string{"GET /foo HTTP/1.1", "Content-Type: text/html; charset=utf-8", ""}
+ assert.Equal(t, "html", detectContentType(lines))
+}
+
+func TestDetectContentType_Plain(t *testing.T) {
+ lines := []string{"GET /foo HTTP/1.1", "Content-Type: text/plain", ""}
+ assert.Equal(t, "text", detectContentType(lines))
+}
+
+func TestDetectContentType_Missing(t *testing.T) {
+ lines := []string{"GET /foo HTTP/1.1", "Accept: */*", ""}
+ assert.Equal(t, "text", detectContentType(lines))
+}
+
+func TestDetectContentType_AfterBlankLineIgnored(t *testing.T) {
+ lines := []string{"GET /foo HTTP/1.1", "", "Content-Type: application/json"}
+ assert.Equal(t, "text", detectContentType(lines))
+}
+
+func TestDetectContentType_CaseInsensitive(t *testing.T) {
+ lines := []string{"GET /foo HTTP/1.1", "content-type: APPLICATION/JSON", ""}
+ assert.Equal(t, "json", detectContentType(lines))
+}
+
+// ── tokeniseFirstLine ────────────────────────────────────────────────────────
+
+func TestTokeniseFirstLine_Request(t *testing.T) {
+ tokens := tokeniseFirstLine("GET /search?q=foo HTTP/1.1")
+
+ require.Len(t, tokens, 3)
+ assert.Equal(t, tvKindMethod, tokens[0].Kind)
+ assert.Equal(t, tvKindPath, tokens[1].Kind)
+ assert.Equal(t, tvKindVersion, tokens[2].Kind)
+}
+
+func TestTokeniseFirstLine_ResponseStatusKinds(t *testing.T) {
+ tests := []struct {
+ line string
+ wantKind tvTokenKind
+ }{
+ {"HTTP/1.1 200 OK", tvKindStatus2xx},
+ {"HTTP/1.1 201 Created", tvKindStatus2xx},
+ {"HTTP/1.1 301 Moved Permanently", tvKindStatus3xx},
+ {"HTTP/1.1 404 Not Found", tvKindStatus4xx},
+ {"HTTP/1.1 500 Internal Server Error", tvKindStatus5xx},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.line, func(t *testing.T) {
+ tokens := tokeniseFirstLine(tt.line)
+ require.GreaterOrEqual(t, len(tokens), 2)
+ assert.Equal(t, tt.wantKind, tokens[1].Kind)
+ })
+ }
+}
+
+// ── tokeniseHTTPMeta ─────────────────────────────────────────────────────────
+
+func TestTokeniseHTTPMeta_Header(t *testing.T) {
+ tokens := tokeniseHTTPMeta("Content-Type: application/json", false)
+
+ require.Len(t, tokens, 3)
+ assert.Equal(t, tvKindHdrName, tokens[0].Kind)
+ assert.Equal(t, "Content-Type", tokens[0].Text)
+ assert.Equal(t, tvKindHdrColon, tokens[1].Kind)
+ assert.Equal(t, tvKindHdrValue, tokens[2].Kind)
+}
+
+func TestTokeniseHTTPMeta_EmptyLine(t *testing.T) {
+ tokens := tokeniseHTTPMeta("", false)
+
+ require.Len(t, tokens, 1)
+ assert.Equal(t, tvKindPlain, tokens[0].Kind)
+ assert.Equal(t, "", tokens[0].Text)
+}
+
+func TestTokeniseHTTPMeta_NoColon(t *testing.T) {
+ tokens := tokeniseHTTPMeta("plaintext", false)
+
+ require.Len(t, tokens, 1)
+ assert.Equal(t, tvKindPlain, tokens[0].Kind)
+}
+
+// ── tokeniseJSON ─────────────────────────────────────────────────────────────
+
+func TestTokeniseJSON_KeyAndString(t *testing.T) {
+ tokens := tokeniseJSON(`"name": "alice"`)
+
+ kinds := map[tvTokenKind]bool{}
+ for _, tok := range tokens {
+ kinds[tok.Kind] = true
+ }
+ assert.True(t, kinds[tvKindJSONKey], "expected tvKindJSONKey token")
+ assert.True(t, kinds[tvKindJSONStr], "expected tvKindJSONStr token")
+}
+
+func TestTokeniseJSON_Number(t *testing.T) {
+ tokens := tokeniseJSON(`"count": 42`)
+
+ found := false
+ for _, tok := range tokens {
+ if tok.Kind == tvKindJSONNum && tok.Text == "42" {
+ found = true
+ }
+ }
+ assert.True(t, found, "expected number token '42'")
+}
+
+func TestTokeniseJSON_BoolAndNull(t *testing.T) {
+ for _, kw := range []string{"true", "false", "null"} {
+ t.Run(kw, func(t *testing.T) {
+ tokens := tokeniseJSON(`"flag": ` + kw)
+ found := false
+ for _, tok := range tokens {
+ if tok.Kind == tvKindJSONBool && tok.Text == kw {
+ found = true
+ }
+ }
+ assert.True(t, found, "expected tvKindJSONBool token %q", kw)
+ })
+ }
+}
+
+func TestTokeniseJSON_Punctuation(t *testing.T) {
+ for _, ch := range []string{"{", "}", "[", "]", ","} {
+ t.Run(ch, func(t *testing.T) {
+ tokens := tokeniseJSON(ch)
+ require.Len(t, tokens, 1)
+ assert.Equal(t, tvKindJSONPunct, tokens[0].Kind)
+ })
+ }
+}
+
+func TestTokeniseJSON_EscapedQuoteInsideString(t *testing.T) {
+ tokens := tokeniseJSON(`"say \"hi\""`)
+
+ strCount := 0
+ for _, tok := range tokens {
+ if tok.Kind == tvKindJSONStr || tok.Kind == tvKindJSONKey {
+ strCount++
+ }
+ }
+ assert.Equal(t, 1, strCount, "escaped quote should produce exactly 1 string token")
+}
+
+func TestTokeniseJSON_DoubleBackslashBeforeQuote(t *testing.T) {
+ tokens := tokeniseJSON(`"\\"`)
+
+ strCount := 0
+ for _, tok := range tokens {
+ if tok.Kind == tvKindJSONStr || tok.Kind == tvKindJSONKey {
+ strCount++
+ }
+ }
+ assert.Equal(t, 1, strCount, "double backslash before closing quote should produce exactly 1 string token")
+}
+
+func TestTokeniseJSON_Empty(t *testing.T) {
+ tokens := tokeniseJSON("")
+
+ require.Len(t, tokens, 1)
+ assert.Equal(t, tvKindPlain, tokens[0].Kind)
+}
+
+// ── wrapTokens ───────────────────────────────────────────────────────────────
+
+func TestWrapTokens_ShortLineNoWrap(t *testing.T) {
+ tokens := []tvToken{{Text: "hello", Kind: tvKindPlain}}
+ lines := wrapTokens(tokens, "hello", 80)
+
+ require.Len(t, lines, 1)
+ assert.Equal(t, "hello", lines[0].Raw)
+}
+
+func TestWrapTokens_LongLineWraps(t *testing.T) {
+ long := strings.Repeat("x", 100)
+ tokens := []tvToken{{Text: long, Kind: tvKindPlain}}
+ lines := wrapTokens(tokens, long, 40)
+
+ assert.Greater(t, len(lines), 1, "expected wrapping into multiple lines")
+
+ total := 0
+ for _, l := range lines {
+ total += len([]rune(l.Raw))
+ }
+ assert.Equal(t, 100, total, "all characters must be preserved across wrapped lines")
+}
+
+func TestWrapTokens_JSONStringNotSplit(t *testing.T) {
+ str := `"` + strings.Repeat("a", 50) + `"`
+ tokens := []tvToken{{Text: str, Kind: tvKindJSONStr}}
+ lines := wrapTokens(tokens, str, 20)
+
+ assert.Len(t, lines, 1, "JSON string must not be split across lines")
+}