diff options
Diffstat (limited to 'internal/ui/widgets')
| -rw-r--r-- | internal/ui/widgets/datatable.go | 876 | ||||
| -rw-r--r-- | internal/ui/widgets/datatable_test.go | 199 | ||||
| -rw-r--r-- | internal/ui/widgets/textview.go | 836 | ||||
| -rw-r--r-- | internal/ui/widgets/textview_test.go | 189 | ||||
| -rw-r--r-- | internal/ui/widgets/textviewentry.go | 1589 | ||||
| -rw-r--r-- | internal/ui/widgets/textviewentry_test.go | 381 | ||||
| -rw-r--r-- | internal/ui/widgets/tokeniser.go | 362 | ||||
| -rw-r--r-- | internal/ui/widgets/tokeniser_test.go | 213 |
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") +} |
