summaryrefslogtreecommitdiff
path: root/internal/ui/widgets/datatable.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/ui/widgets/datatable.go')
-rw-r--r--internal/ui/widgets/datatable.go876
1 files changed, 876 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() {}