From 0a477fd288d064d265395c88dbd0f261b723804e Mon Sep 17 00:00:00 2001 From: spiperac Date: Mon, 15 Jun 2026 06:42:13 +0200 Subject: Initial release --- internal/ui/widgets/datatable.go | 876 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 876 insertions(+) create mode 100644 internal/ui/widgets/datatable.go (limited to 'internal/ui/widgets/datatable.go') 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() {} -- cgit v1.2.3