From 9588c3df679a0070cc02dbfeada1c84315b8e814 Mon Sep 17 00:00:00 2001 From: Stephen Byrne Date: Sat, 26 Jul 2025 22:22:55 +0000 Subject: [PATCH] various --- build.gradle.kts | 1 + .../kswingutil/BoundsPopupMenuListener.kt | 196 +++++++ .../kotlin/net/eksb/kswingutil/ext/Ext.kt | 52 +- .../net/eksb/kswingutil/ext/WindowExt.kt | 82 +++ .../net/eksb/kswingutil/table/GoodTable.kt | 154 ++++++ .../eksb/kswingutil/table/GoodTableModel.kt | 498 ++++++++++++++++++ .../net/eksb/kswingutil/table/TableUtil.kt | 194 +++++++ 7 files changed, 1176 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/net/eksb/kswingutil/BoundsPopupMenuListener.kt create mode 100644 src/main/kotlin/net/eksb/kswingutil/ext/WindowExt.kt create mode 100644 src/main/kotlin/net/eksb/kswingutil/table/GoodTable.kt create mode 100644 src/main/kotlin/net/eksb/kswingutil/table/GoodTableModel.kt create mode 100644 src/main/kotlin/net/eksb/kswingutil/table/TableUtil.kt diff --git a/build.gradle.kts b/build.gradle.kts index b60d737..0877328 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,6 +10,7 @@ group = "net.eksb" dependencies { api("com.miglayout:miglayout-swing:11.4.2") + api("org.swinglabs:swingx:1.6.1") } java { diff --git a/src/main/kotlin/net/eksb/kswingutil/BoundsPopupMenuListener.kt b/src/main/kotlin/net/eksb/kswingutil/BoundsPopupMenuListener.kt new file mode 100644 index 0000000..a52b393 --- /dev/null +++ b/src/main/kotlin/net/eksb/kswingutil/BoundsPopupMenuListener.kt @@ -0,0 +1,196 @@ +package net.eksb.kswingutil + +/* +This is based on https://tips4java.wordpress.com/2010/11/28/combo-box-popup/, which says: +We assume no responsibility for the code. You are free to use and/or modify and/or distribute any or all code posted on +the Java Tips Weblog without restriction. A credit in the code comments would be nice, but not in any way mandatory. +*/ + +import javax.swing.event.PopupMenuListener +import javax.swing.JScrollPane +import javax.swing.event.PopupMenuEvent +import javax.swing.JComboBox +import javax.swing.plaf.basic.BasicComboPopup +import javax.swing.SwingUtilities +import javax.swing.JList +import javax.swing.JScrollBar + +/** + * This class will change the bounds of the JComboBox popup menu to support + * different functionality. It will support the following features: + * - a horizontal scrollbar can be displayed when necessary + * - the popup can be wider than the combo box + * - the popup can be displayed above the combo box + * + * Class will only work for a JComboBox that uses a BasicComboPop. + */ +class BoundsPopupMenuListener( + /** + * True add horizontal scrollBar to scrollPane + * false remove the horizontal scrollBar + * + * For some reason the default implementation of the popup removes the + * horizontal scrollBar from the popup scroll pane which can result in + * the truncation of the rendered items in the popop. Adding a scrollBar + * back to the scrollPane will allow horizontal scrolling if necessary. + */ + var isScrollBarRequired:Boolean = true, + /** + * True adjust the width as required. + * + * Change the width of the popup to be the greater of the width of the + * combo box or the preferred width of the popup. Normally the popup width + * is always the same size as the combo box width. + */ + var isPopupWider:Boolean = false, + /** + * Limit the popup width to the value specified + * (minimum size will be the width of the combo box) + */ + var maximumWidth:Int = -1, + /** + * true display popup above the combo box, + * + * false display popup below the combo box. + * Change the location of the popup relative to the combo box. + */ + var isPopupAbove:Boolean = false +):PopupMenuListener { + private var scrollPane:JScrollPane? = null + + /** + * Alter the bounds of the popup just before it is made visible. + */ + override fun popupMenuWillBecomeVisible(e:PopupMenuEvent) { + val comboBox = e.source as JComboBox<*> + if (comboBox.itemCount == 0) return + val child:Any = comboBox.accessibleContext.getAccessibleChild(0) + if (child is BasicComboPopup) { + SwingUtilities.invokeLater { customizePopup(child) } + } + } + + protected fun customizePopup(popup:BasicComboPopup) { + scrollPane = getScrollPane(popup) + if (isPopupWider) popupWider(popup) + checkHorizontalScrollBar(popup) + + // For some reason in JDK7 the popup will not display at its preferred + // width unless its location has been changed from its default + // (ie. for normal "pop down" shift the popup and reset) + val comboBox = popup.invoker + if ( comboBox.isVisible ) { + val location = comboBox.locationOnScreen + if (isPopupAbove) { + val height = popup.preferredSize.height + popup.setLocation(location.x, location.y - height) + } else { + val height = comboBox.preferredSize.height + popup.setLocation(location.x, location.y + height - 1) + popup.setLocation(location.x, location.y + height) + } + } + } + + /* + * Adjust the width of the scrollpane used by the popup + */ + protected fun popupWider(popup:BasicComboPopup) { + val list:JList<*> = popup.list + + // Determine the maximimum width to use: + // a) determine the popup preferred width + // b) limit width to the maximum if specified + // c) ensure width is not less than the scroll pane width + var popupWidth = (list.preferredSize.width + 5 // make sure horizontal scrollbar doesn't appear + + getScrollBarWidth(popup, scrollPane)) + if (maximumWidth != -1) { + popupWidth = Math.min(popupWidth, maximumWidth) + } + val scrollPaneSize = scrollPane!!.preferredSize + popupWidth = Math.max(popupWidth, scrollPaneSize.width) + + // Adjust the width + scrollPaneSize.width = popupWidth + scrollPane!!.preferredSize = scrollPaneSize + scrollPane!!.maximumSize = scrollPaneSize + } + + /* + * This method is called every time: + * - to make sure the viewport is returned to its default position + * - to remove the horizontal scrollbar when it is not wanted + */ + private fun checkHorizontalScrollBar(popup:BasicComboPopup) { // Reset the viewport to the left + val viewport = scrollPane!!.viewport + val p = viewport.viewPosition + p.x = 0 + viewport.viewPosition = p + + // Remove the scrollbar so it is never painted + if (!isScrollBarRequired) { + scrollPane!!.horizontalScrollBar = null + return + } + + // Make sure a horizontal scrollbar exists in the scrollpane + var horizontal = scrollPane!!.horizontalScrollBar + if (horizontal == null) { + horizontal = JScrollBar(JScrollBar.HORIZONTAL) + scrollPane!!.horizontalScrollBar = horizontal + scrollPane!!.horizontalScrollBarPolicy = JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED + } + + // Potentially increase height of scroll pane to display the scrollbar + if (horizontalScrollBarWillBeVisible(popup, scrollPane)) { + val scrollPaneSize = scrollPane!!.preferredSize + scrollPaneSize.height += horizontal.preferredSize.height + scrollPane!!.preferredSize = scrollPaneSize + scrollPane!!.maximumSize = scrollPaneSize + scrollPane!!.revalidate() + } + } + + /* + * Get the scroll pane used by the popup so its bounds can be adjusted + */ + protected fun getScrollPane(popup:BasicComboPopup):JScrollPane { + val list:JList<*> = popup.list + val c = SwingUtilities.getAncestorOfClass(JScrollPane::class.java, list) + return c as JScrollPane + } + + /* + * I can't find any property on the scrollBar to determine if it will be + * displayed or not so use brute force to determine this. + */ + protected fun getScrollBarWidth(popup:BasicComboPopup, scrollPane:JScrollPane?):Int { + var scrollBarWidth = 0 + val comboBox = popup.invoker as JComboBox<*> + if (comboBox.itemCount > comboBox.maximumRowCount) { + val vertical = scrollPane!!.verticalScrollBar + scrollBarWidth = vertical.preferredSize.width + } + return scrollBarWidth + } + + /* + * I can't find any property on the scrollBar to determine if it will be + * displayed or not so use brute force to determine this. + */ + protected fun horizontalScrollBarWillBeVisible(popup:BasicComboPopup, scrollPane:JScrollPane?):Boolean { + val list:JList<*> = popup.list + val scrollBarWidth = getScrollBarWidth(popup, scrollPane) + val popupWidth = list.preferredSize.width + scrollBarWidth + return popupWidth > scrollPane!!.preferredSize.width + } + + override fun popupMenuCanceled(e:PopupMenuEvent) {} + override fun popupMenuWillBecomeInvisible( + e:PopupMenuEvent) { // In its normal state the scrollpane does not have a scrollbar + if (scrollPane != null) { + scrollPane!!.horizontalScrollBar = null + } + } + +} diff --git a/src/main/kotlin/net/eksb/kswingutil/ext/Ext.kt b/src/main/kotlin/net/eksb/kswingutil/ext/Ext.kt index 610e22f..c02fd41 100644 --- a/src/main/kotlin/net/eksb/kswingutil/ext/Ext.kt +++ b/src/main/kotlin/net/eksb/kswingutil/ext/Ext.kt @@ -2,7 +2,8 @@ package net.eksb.kswingutil.ext import net.eksb.kswingutil.property.MutableProperty import net.eksb.kswingutil.property.Property -import net.eksb.kswingutil.ext.bind +import org.jdesktop.swingx.autocomplete.AutoCompleteDecorator +import org.jdesktop.swingx.autocomplete.ObjectToStringConverter import java.awt.Component import java.awt.Container import java.awt.Font @@ -18,13 +19,36 @@ import javax.swing.JComponent import javax.swing.JLabel import javax.swing.JList import javax.swing.JPanel +import javax.swing.JScrollPane import javax.swing.JSeparator +import javax.swing.JTabbedPane +import javax.swing.JToolBar fun Container.separator(constraints:Any?=null, alignment:Int, block:JSeparator.()->Unit ={}):JSeparator = add(constraints, JSeparator(alignment),block) fun Container.panel(constraints:Any?=null, block:JPanel.()->Unit ={}):JPanel = add(constraints, JPanel(),block) +fun Container.scrollPane(constraints:Any?=null, block:JScrollPane.()->Component):JScrollPane { + val scrollPane = JScrollPane() + scrollPane.viewport.view = block(scrollPane) + scrollPane.fastScroll() + add(scrollPane,constraints) + return scrollPane +} + +fun JScrollPane.fastScroll() { + verticalScrollBar.unitIncrement = 12 + horizontalScrollBar.unitIncrement = 12 +} + +fun Container.tabbedPane(constraints:Any?=null, block:JTabbedPane.()->Unit = {}):JTabbedPane = add(constraints,JTabbedPane(),block) +fun jtabbedPane(block:JTabbedPane.()->Unit = {}):JTabbedPane = JTabbedPane().also{block(it)} + +fun JTabbedPane.tab( title:String, op:()->Component ) { addTab(title,op()) } + +fun Container.toolbar(constraints:Any?=null, block:JToolBar.()->Unit = {}):JToolBar = add(constraints,JToolBar(),block) + fun Container.horizontalGlue():Component = add(Box.createHorizontalGlue()) fun JComponent.emptyBorder(size:Int) { @@ -50,6 +74,32 @@ fun JComboBox.model(values:Array) { model = DefaultComboBoxModel(values) } +/** + * Add an autocomplete decorator to this [JComboBox]. + * + * If [autoCompleteStrings] is not null, it is used to determine which rows match. + * The first item in the sequence is the preferred string, subsequent items are also allowed matches. + * + * If [autoCompleteStrings] is null the string representation ([Any.toString])] + * of the combobox item is used. + */ +fun JComboBox.autoComplete( autoCompleteStrings:((E)->Sequence)? =null ) { + if ( autoCompleteStrings != null ) { + AutoCompleteDecorator.decorate(this, + object:ObjectToStringConverter() { + override fun getPreferredStringForItem(item:Any?):String { + return autoCompleteStrings(item as E).firstOrNull() ?: "" + } + override fun getPossibleStringsForItem(item:Any?):Array { + return autoCompleteStrings(item as E).toList().toTypedArray() + } + } + ) + } else { + AutoCompleteDecorator.decorate(this) + } +} + typealias ListCellRendererLambda = DefaultListCellRenderer.( list:JList, value:T, diff --git a/src/main/kotlin/net/eksb/kswingutil/ext/WindowExt.kt b/src/main/kotlin/net/eksb/kswingutil/ext/WindowExt.kt new file mode 100644 index 0000000..59a0950 --- /dev/null +++ b/src/main/kotlin/net/eksb/kswingutil/ext/WindowExt.kt @@ -0,0 +1,82 @@ +package net.eksb.kswingutil.ext + +import java.awt.Frame +import java.awt.GraphicsConfiguration +import java.awt.GraphicsDevice +import java.awt.GraphicsEnvironment +import java.awt.MouseInfo +import java.awt.Rectangle +import java.awt.Window +import javax.swing.SwingConstants +import javax.swing.SwingUtilities + +/** Get the bounds of the screen that the mouse is currently on. */ +fun getMouseScreenBounds():Rectangle = getMouseWindowGraphicsConfiguration().bounds +/** Get the location of the mouse. */ +fun getMouseLocation():Pair = MouseInfo.getPointerInfo().location.run { x to y } + +/** Get the GraphicsConfiguration of the screen the mouse is currently on. */ +fun getMouseWindowGraphicsConfiguration():GraphicsConfiguration { + val (mouseX:Int,mouseY:Int) = getMouseLocation() + return GraphicsEnvironment + .getLocalGraphicsEnvironment() + .screenDevices + .map(GraphicsDevice::getDefaultConfiguration) // correct? + .find { graphicsConfiguration -> + graphicsConfiguration.bounds.contains( mouseX, mouseY ) + } + ?: throw Exception("Could not find screen with mouse.") +} + +/** + * Place this window at the mouse + * + * @param direction Direction of the window from the mouse. + * SwingConstants.NORTH_EAST, NORTH_WEST, SOUTH_EAST, or SOUTH_WEST. + * This is where the window is relative to the mouse, not which corner of the window the mouse is. + */ +fun Window.placeWindowAtMouse( direction:Int ) { + + // find the bounds of the screen the mouse is on + val (mouseX:Int,mouseY:Int) = getMouseLocation() + val mouseScreenBounds = getMouseScreenBounds() + + // get target position based on mouse position, size, and direction + var (x:Int,y:Int) = + when(direction) { + SwingConstants.NORTH_EAST -> mouseX to mouseY - height + SwingConstants.NORTH_WEST -> mouseX - width to mouseY - height + SwingConstants.SOUTH_EAST -> mouseX to mouseY + SwingConstants.SOUTH_WEST -> mouseX - width to mouseY + else -> throw Exception("direction must be NORTH_EAST, NORTH_WEST, SOUTH_EAST, or SOUTH_WEST") + } + + // adjust to fit on screen + if ( x < mouseScreenBounds.x ) { + x = mouseScreenBounds.x + } + if ( y < mouseScreenBounds.y ) { + y = mouseScreenBounds.y + } + + SwingUtilities.invokeLater { + setLocation(x,y) + requestFocus() + } +} + +/** + * Center this window over the specified frame. + * + * [Window.setLocationRelativeTo] does *NOT* work on multi-monitor setups. + */ +fun Window.centerOver(owner:Frame) { + val ownerBounds = owner.bounds + val centerX = ownerBounds.x + ownerBounds.width/2 + val centerY = ownerBounds.y + ownerBounds.height/2 + + val x = centerX - width/2 + val y = centerY - height/2 + + setLocation(x,y) +} diff --git a/src/main/kotlin/net/eksb/kswingutil/table/GoodTable.kt b/src/main/kotlin/net/eksb/kswingutil/table/GoodTable.kt new file mode 100644 index 0000000..e52e117 --- /dev/null +++ b/src/main/kotlin/net/eksb/kswingutil/table/GoodTable.kt @@ -0,0 +1,154 @@ +package net.eksb.kswingutil.table + +import net.eksb.kswingutil.ext.emptyBorder +import net.eksb.kswingutil.ext.add +import java.awt.Color +import java.awt.Component +import java.awt.Container +import java.awt.Font +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import javax.swing.JComponent +import javax.swing.JTable +import javax.swing.SwingUtilities +import javax.swing.event.ChangeEvent +import javax.swing.event.ListSelectionEvent +import javax.swing.event.TableColumnModelEvent +import javax.swing.event.TableColumnModelListener +import javax.swing.event.TableModelEvent +import javax.swing.table.TableCellRenderer +import javax.swing.table.TableColumnModel +import javax.swing.table.TableModel + +/** + * A [JTable] that supports auto-resized columns + * and custom row and cell rendering. + */ +class GoodTable: JTable() { + init { + tableHeader.font = tableHeader.font.deriveFont(Font.BOLD) + rowHeight = tableRowHeight + } + + /** + * Set to false to stop GoodTable from setting row background colors. + * Do this if you plan on setting them in your renders. + */ + var setBackgroundColor:Boolean = true + + /** Fill weights for [resizeColumnWidths]. */ + var fillWeights:Map? = null + + /** + * Fixed widths for [reszieColumnWidths]. + * [colResizeListener] sets these when the user resizes a column, + * so it stays that width. + */ + val fixedWidths:MutableMap = mutableMapOf() + + /** + * Listener to update [fixedWidths]. + */ + private val colResizeListener = object:TableColumnModelListener { + override fun columnAdded(e:TableColumnModelEvent) {} + override fun columnMarginChanged(e:ChangeEvent) { + val resizingCol = this@GoodTable.tableHeader.resizingColumn + if ( resizingCol != null ) { + val ci = columnModel.columns.asSequence().indexOf(resizingCol) + fixedWidths[ci] = resizingCol.width + } + } + override fun columnMoved(e:TableColumnModelEvent) {} + override fun columnRemoved(e:TableColumnModelEvent) {} + override fun columnSelectionChanged(e:ListSelectionEvent) {} + } + + /** + * When setting a model, register [colResizeListener]. + */ + override fun setColumnModel(columnModel:TableColumnModel) { + this.columnModel?.removeColumnModelListener(colResizeListener) + super.setColumnModel(columnModel) + columnModel.addColumnModelListener(colResizeListener) + } + + override fun setModel(dataModel:TableModel) { + super.setModel(dataModel) + + // resize columns to fit content width + autoResizeMode = AUTO_RESIZE_OFF // because in a scroll pane + model.addTableModelListener { event -> + // resize if rows or cols are added or removed + // do later so table can re-render and re-sort first, otherwise row indexes are wrong + if ( event.type in listOf(TableModelEvent.INSERT,TableModelEvent.DELETE) + || event.column == TableModelEvent.ALL_COLUMNS // this happens on fireTableDataChanged + ) { + SwingUtilities.invokeLater { + resizeColumnWidths(this,true,fillWeights,fixedWidths) + } + } + } + SwingUtilities.invokeLater { + if ( fillWeights != null ) { + // resize when parent size changes + parent?.addComponentListener( object:ComponentAdapter() { + override fun componentResized(e:ComponentEvent?) { + SwingUtilities.invokeLater { + resizeColumnWidths(this@GoodTable,true,fillWeights,fixedWidths) + } + } + }) + } + resizeColumnWidths(this,true,fillWeights,fixedWidths) + } + } + + override fun prepareRenderer(renderer:TableCellRenderer, row:Int, column:Int):Component { + super.prepareRenderer(renderer, row, column) + val component = super.prepareRenderer(renderer, row, column) + val selected = selectionModel.isSelectedIndex(row) + + if ( setBackgroundColor ) { + component.background = + when { + selected -> tableColorSelected + row % 2 == 0 -> tableColorEven + else -> tableColorOdd + } + // If we set the background we have to set the foreground in case somebody's theme + // defaulted to white text on black background and we just made the background white. + if ( component.foreground == null ) component.foreground = Color.BLACK + } + + val jcomp = ( component as? JComponent) + if ( jcomp != null ) { + if ( jcomp.border == null ) { + jcomp.emptyBorder(tableCellPadding) + } + } + + preparer?.invoke( component , row, column, selected ) + return component + } + + var preparer:((Component,Int,Int,Boolean)->Unit)? = null + + companion object { + val tableColorEven = Color.WHITE + val tableColorOdd = Color(240,240,240) + val tableColorSelected = Color(218,230,242) + val tableRowHeight = 22 + val tableCellPadding = 3 + } + +} + +fun Container.goodTable(constraints:Any?=null, block:GoodTable.()->Unit ={}):GoodTable = add(constraints, GoodTable(), block) + +/** Builder to add a [GoodTableColumn] to a [GoodTableModel]. */ +inline fun GoodTableModel.column(name:String, identifier:Any?=null, op:GoodTableColumn.()->Unit ): GoodTableColumn { + val tableColumn = GoodTableColumn(name, identifier, C::class.java) + op(tableColumn) + addColumn(tableColumn) + return tableColumn +} \ No newline at end of file diff --git a/src/main/kotlin/net/eksb/kswingutil/table/GoodTableModel.kt b/src/main/kotlin/net/eksb/kswingutil/table/GoodTableModel.kt new file mode 100644 index 0000000..8361a9e --- /dev/null +++ b/src/main/kotlin/net/eksb/kswingutil/table/GoodTableModel.kt @@ -0,0 +1,498 @@ +package net.eksb.kswingutil.table + +import net.eksb.kswingutil.BoundsPopupMenuListener +import net.eksb.kswingutil.ext.autoComplete +import net.eksb.kswingutil.ext.renderer +import net.eksb.kswingutil.property.Property +import java.awt.Component +import java.awt.event.MouseEvent +import javax.swing.ComboBoxModel +import javax.swing.DefaultCellEditor +import javax.swing.DefaultRowSorter +import javax.swing.JComboBox +import javax.swing.JTable +import javax.swing.table.AbstractTableModel + +import org.jdesktop.swingx.autocomplete.ComboBoxCellEditor +import javax.swing.table.DefaultTableCellRenderer +import javax.swing.table.DefaultTableColumnModel +import javax.swing.table.TableColumn +import javax.swing.table.TableColumnModel + +/** Lambda to get a cell value (C) for the row value (T). */ +typealias CellValueCalc = (T)->C + +/** Lambda to get a cell value (C) for the row value (T), at the specified index. */ +typealias CellValueCalcIndexed = (T,Int)->C + +/** Lambda to determine if the cell is editable for the row value (R). */ +typealias IsEditableCalc = (T)->Boolean + +/** + * Callback for when cell's value is changed. + * Arguments are row value, cell value, row index, cell index. + */ +typealias CellValueChangeHandler = (T,C,Int,Int)->Unit + +/** + * Callback when a cell is clicked. + * Arguments are mouse event, row index, column index, row value, cell value. + */ +typealias CellClickHandler = (MouseEvent,Int,Int,T,C)->Unit + +/** + * Callback when a header cell is clicked. + * Arguments are mouse event and column index. + */ +typealias HeaderClickHandler = (MouseEvent,Int)->Unit + +/** + * A table model that lets you add columns that know how to get their values + * based on the row's value. + * + * @property T row value type. + */ +class GoodTableModel:AbstractTableModel() { + val cols:MutableList> = mutableListOf() + + /** + * Generate a [TableColumnModel] for the specified [JTable]. + */ + fun columnModel(table:JTable):TableColumnModel { + return DefaultTableColumnModel() + .apply { + colsInTable(table).forEach { col -> addColumn(col) } + } + } + + /** + * Get the columns that are in the specified table, according to [GoodTableColumn.onlyInTable]. + */ + private fun colsInTable(table:JTable):List> = cols + .filter { col -> col.tables?.contains(table) ?: true } + + /** The rows. If you modify this directly, you are responsible for calling [fire*] */ + val rows:MutableList = mutableListOf() + + override fun isCellEditable(rowIndex:Int, columnIndex:Int):Boolean { + // determine if the cell is editable by calling the [GoodTableColumn.isEditableCalc]. + val isEditableClac = cols[columnIndex].isEditableCalc + return if ( isEditableClac != null ) { + val rowValue = getRowValue(rowIndex) + if ( rowValue != null ) { + isEditableClac.invoke(rowValue) + } else { + false + } + } else { + false + } + } + + fun getColumn(columnIndex:Int):GoodTableColumn = cols[columnIndex] as GoodTableColumn + fun getColumn(columnName:String):GoodTableColumn = getColumn( findColumn(columnName) ) + + override fun getColumnCount():Int = cols.size + + override fun getColumnClass(columnIndex:Int):Class<*> = cols[columnIndex].cls + + override fun findColumn(columnName:String):Int = cols.indexOfFirst { col -> col.name == columnName } + + override fun getColumnName(column:Int):String = cols[column].name + + override fun getRowCount():Int = rows.size + + /** + * Get the value for the specified model row index, + * which is NOT necessarily the table row index. + * If you have a table row index, use [getTableRowValue]. + */ + fun getRowValue(rowIndex:Int):T? = if ( rowIndex < rows.size ) rows[rowIndex] else null + + /** + * Get the value for the specified table row index, + * which is NOT necessarily the model row index. + * If you have a model row index, use [getRowValue]. + */ + fun getTableRowValue(table:JTable,rowIndex:Int):T? = getRowValue( + table.convertRowIndexToModel(rowIndex) + ) + + override fun getValueAt(rowIndex:Int, columnIndex:Int):Any? { + val rowValue = getRowValue(rowIndex) ?: throw Exception("Null value for row ${rowIndex}") + val cellValueCalc = cols[columnIndex].cellValueCalc + ?: throw Exception("No selector for column ${columnIndex}" ) + return cellValueCalc.invoke(rowValue,rowIndex) + } + + override fun setValueAt(aValue:Any?, rowIndex:Int, columnIndex:Int) { + cols[columnIndex].setValueAt( getRowValue(rowIndex)!!, aValue, rowIndex, columnIndex ) + } + + /** + * Add a column. + */ + fun addColumn( tableColumn:GoodTableColumn ) { + val columnIndex = cols.size + + cols.add(tableColumn) + + tableColumn.modelIndex = columnIndex + tableColumn.headerValue = tableColumn.name + } + + /** + * Setup the table for this model. + * + * @param table the table. + */ + fun setup( table:JTable ) { + // Propagate table clicks to [GoodTableColumn.clickHandler]s. + table.onCellClick { mouseEvent, tableRowIndex, tableColIndex -> + val colIndex = table.convertColumnIndexToModel(tableColIndex) + val rowIndex = table.convertRowIndexToModel(tableRowIndex) + val col = table.columnModel.getColumn(tableColIndex) as GoodTableColumn + val rowValue = getRowValue(rowIndex)!! + col.click( mouseEvent, rowIndex, colIndex, rowValue ) + } + table.onHeaderClick { mouseEvent, tableColIndex -> + val colIndex = table.convertColumnIndexToModel(tableColIndex) + val col = table.columnModel.getColumn(tableColIndex) as GoodTableColumn + col.headerClick( mouseEvent, colIndex ) + } + + val colsInTable = colsInTable(table) + + // Set [GoodTableColumn.sorter]s if the table has a RowSorter + val rowSorter = ( table.rowSorter as? DefaultRowSorter<*,*> ) + if ( rowSorter != null ) { + colsInTable.forEachIndexed { i, col -> + rowSorter.setSortable( i, col.sortable ) + if ( col.sorter != null ) { + rowSorter.setComparator( i, col.sorter ) + } + } + } else { + if ( colsInTable.any { it.sorter != null } ) { + // At least one column has a sorter set, but the table does not have a DefaultRowSorter. + // this is fine if you are providing your own RowSorter that looks at [GoodTableColumn.sorter] + } + } + + // show/hide columns + colsInTable.forEach { col -> + col.visible?.addListener { _, _ -> propagateColumnVisibility(table) } + } + propagateColumnVisibility(table) + + if ( table is GoodTable ) { + val fillWeights = colsInTable + .asSequence() + .mapIndexedNotNull { i, col -> + col.fillWeight?.let { i to it } + } + .toMap() + val fixedWidths = colsInTable + .asSequence() + .mapIndexedNotNull { i, col -> + col.fixedWidth?.let { i to it } + } + .toMap() + if ( fillWeights.isNotEmpty() || fixedWidths.isNotEmpty() ) { + table.fillWeights = fillWeights + table.fixedWidths.putAll( fixedWidths ) + } + } + } + + /** + * Add/Remove columns from the JTable (they stay in the model) + * according to [GoodTableColumn.visible]. + */ + private fun propagateColumnVisibility(table:JTable) { + // index of next column + var i = 0 + + colsInTable(table).forEach { col -> + val show = col.visible?.value ?: true + + if ( show ) { + val tableCol = table.columnModel.columns.asSequence().firstOrNull { it == col } + if ( tableCol != null ) { + // already there + } else { + // add - have to add to the end and then move + table.addColumn(col) + table.moveColumn( table.columnCount-1 ,i ) + } + i++ + } else { + // remove if it is there, noop if it is not + table.removeColumn(col) + } + } + } + + fun fireAllTableRowsUpdated() { + fireTableRowsUpdated(0,rowCount-1) + } +} + +/** + * Set a GoodTableModel to a JTable. + * + * I thought about creating a method + * + * JTable.goodTableModel(op:GoodTableModel.()->Unit) + * + * to build the model inline in the table, but that lets you access the + * table before the model is completely build, which could lead to bugs. + * + * @see [GoodTableModel.setup] + */ +fun JTable.goodTableModel( hmodel:GoodTableModel ) { + model = hmodel + columnModel = hmodel.columnModel(this) + hmodel.setup(this) + hmodel.fireTableDataChanged() +} + +/** + * Column for [GoodTableModel]. + * + * @param name Column name. Used in the header by default. + * @param identifier Value for [TableColumn.identifier]. + * @param cls The column type, to be returned by [TableModel.getColumnClass] + */ +class GoodTableColumn(val name:String, identifier:Any? =null, val cls:Class<*>):TableColumn() { + + init { + setIdentifier(identifier) + } + + /** Calculates a cell's value based on the row value. */ + var cellValueCalc:CellValueCalcIndexed? = null + + /** Calculates if the cell is editable based on the row value. */ + var isEditableCalc:IsEditableCalc? = null + + /** Callback for when a cell's value changes. */ + var changeHandler:CellValueChangeHandler? = null + + /** Callback for when a cell is clicked. */ + var clickHandler:CellClickHandler? = null + + /** Callback for when a header cell is clicked. */ + var headerClickHandler:HeaderClickHandler? = null + + /** + * Fill weights for [resizeColumnWidths]. + * Automatically set by [GoodTableModel.setup] if table is a [GoodTable]. + */ + var fillWeight:Float? = null + + /** + * Fixed widths for [resizeColumnWidths]. + * Automatically set by [GoodTableModel.setup] if table is a [GoodTable]. + */ + var fixedWidth:Int? = null + + /** + * Is the column sortable. + * + * If [JTable.rowSorter] is not null, + * [GoodTableModel.setup] will set the column sortable according to [sortable]. + * So if you want to use this, set [JTable.autoCreateRowSorter] to true + * or create a sorter by some other method before calling [GoodTableModel.setup]. + */ + var sortable:Boolean = true + + /** Sorter to use if the table has a row sorter. + * + * If [sorter] and [JTable.rowSorter] are not null, + * [GoodTableModel.setup] will set this comparator. + * So if you want to use this, set [JTable.autoCreateRowSorter] to true + * or create a sorter by some other method before calling [GoodTableModel.setup]. + */ + var sorter:Comparator? = null + + /** + * Observable indicating if the column is visible. Always visible if null. + * + * [GoodTableModel.setup] sets up listeners to this property to show/hide the column. + */ + var visible:Property? = null + + /** If not null, only put in these tables' ColumnModel. */ + var tables:Set? = null + + /** Set the function that calculates a cell's value based on the row value. */ + fun cellValue( calc:CellValueCalcIndexed) { + this.cellValueCalc = calc + } + + /** Set the function that calculates a cell's value based on the row value. */ + fun cellValue( calc:CellValueCalc) = cellValue { value, index -> calc(value) } + + /** Sets the function that determines if a cell is editable based on the row value. */ + fun isEditable( calc:IsEditableCalc) { + this.isEditableCalc = calc + } + + /** Call [changeHandler] for the new value. */ + internal fun setValueAt( rowValue:T, newValue:Any?, rowIndex:Int, columnIndex:Int ) { + changeHandler?.invoke( rowValue, newValue as C, rowIndex, columnIndex ) + } + + /** Register a callback for when a cell's value changes. */ + fun onChange( changeHandler:CellValueChangeHandler ) { + this.changeHandler = changeHandler + } + + /** Register a callback for when a cell is clicked. */ + fun onClick( clickHandler:CellClickHandler ) { + this.clickHandler = clickHandler + } + + internal fun click( mouseEvent:MouseEvent, row:Int, col:Int, rowValue:T ) { + clickHandler?.let { clickHandler -> + cellValueCalc?.let { cellValueCalc -> + val cellValue = cellValueCalc.invoke(rowValue,row) + clickHandler.invoke( mouseEvent, row, col, rowValue, cellValue ) + } + } + } + + /** Register a callback for when a header cell is clicked. */ + fun onHeaderClick( handler:HeaderClickHandler ) { + this.headerClickHandler = handler + } + + internal fun headerClick( mouseEvent:MouseEvent, col:Int ) { + headerClickHandler?.invoke( mouseEvent, col ) + } + + /** + * Specify a renderer for this cell. + * + * The row index passed to [op] is the table row index, not the model index. + */ + inline fun cellRenderer( crossinline op:DefaultTableCellRenderer.(table:JTable, rowValue:T, value:C, isSelected:Boolean, hasFocus:Boolean, row:Int, column:Int)->Component) { + setCellRenderer( object:DefaultTableCellRenderer() { + override fun getTableCellRendererComponent(table:JTable, value:Any?, isSelected:Boolean, hasFocus:Boolean, row:Int, column:Int):Component { + super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) + val rowValue:T = ( table.model as GoodTableModel).getTableRowValue(table,row)!! + return op(table,rowValue,value as C,isSelected,hasFocus,row,column) + } + } ) + } + + /** + * Use the supplied JComboBox for the editor. The callback is called each time a cell edit starts. + * + * The row index passed to [op] is the table row index, not the model index. + */ + inline fun comboboxEditor( combobox:JComboBox, crossinline op:JComboBox.(table:JTable,rowValue:T,value:C,isSelected:Boolean,row:Int,column:Int)->Unit ) { + setCellEditor( object:DefaultCellEditor(combobox) { + override fun getTableCellEditorComponent(table:JTable, value:Any?, isSelected:Boolean, row:Int, column:Int):Component { + super.getTableCellEditorComponent(table, value, isSelected, row, column) + val rowValue:T = ( table.model as GoodTableModel).getTableRowValue(table,row)!! + op(combobox,table,rowValue,value as C,isSelected,row,column) + return combobox + } + }) + } + + /** + * Use a JComboBox with the supplied values for the editor. + * + * @param values Combobox options + * @param autoComplete If true, use auto-completing input. Without this, a value will be selected + * based on a single keystroke. This allows you to type until you get the desired match, then hit enter. + * @param autoSizePopup If true, make the popup big enough to fit the widest value; + * the popup may be larger than the combobox. + * @param renderText Function to generate the text to show in the option for the given value + */ + fun comboboxEditor( values:Array, + autoComplete:Boolean = false, + autoCompleteStrings:((C)->Sequence)? = null, + autoSizePopup:Boolean = true, + renderText:(value:C)->String? ) { + val combobox = JComboBox(values) + + combobox.prototypeDisplayValue = values + .asSequence() + .map { value -> + val text = renderText(value) + val length = text?.length ?: 0 + Triple(value,text,length) + } + .maxByOrNull { (value,text,length) -> length } + ?.first + + if( autoSizePopup ) { + combobox.addPopupMenuListener(BoundsPopupMenuListener(isPopupWider = true)) + } + combobox.renderer { list, value, index, isSelected, cellHasFocus -> + text = renderText.invoke(value) ?: " " + this + } + if ( autoComplete ) { + combobox.autoComplete(autoCompleteStrings) + cellEditor = ComboBoxCellEditor(combobox) + } else { + cellEditor = DefaultCellEditor(combobox) + } + } + + /** + * Use a JComboBox with the supplied [model]. + * + * @param values Combobox options + * @param autoComplete If true, use auto-completing input. Without this, a value will be selected + * based on a single keystroke. This allows you to type until you get the desired match, then hit enter. + * @param autoSizePopup If true, make the popup big enough to fit the widest value; + * the popup may be larger than the combobox. + * @param renderText Function to generate the text to show in the option for the given value + * @param onEdit Callback before the combobox is shown with the row value and cell value. + * This allows making changes to [model] first. + */ + fun comboboxEditor( model:ComboBoxModel, + autoComplete:Boolean = false, + autoCompleteStrings:((C)->Sequence)? = null, + autoSizePopup:Boolean = true, + renderText:(value:C)->String?, + onEdit:((T,C)->Unit)? = null, + ) { + val combobox = JComboBox(model) + + combobox.prototypeDisplayValue = (0 until model.size ) + .asSequence() + .map { index -> model.getElementAt(index) } + .map { value -> + val text = renderText(value) + val length = text?.length ?: 0 + Triple(value,text,length) + } + .maxByOrNull { (value,text,length) -> length } + ?.first + + if( autoSizePopup ) { + combobox.addPopupMenuListener( BoundsPopupMenuListener(isPopupWider=true) ) + } + combobox.renderer { list, value, index, isSelected, cellHasFocus -> + text = renderText.invoke(value) ?: " " + this + } + if ( autoComplete ) { + combobox.autoComplete(autoCompleteStrings) + } + cellEditor = object:ComboBoxCellEditor(combobox) { + override fun getTableCellEditorComponent(table:JTable, value:Any?, isSelected:Boolean, row:Int, column:Int):Component { + val rowValue:T = (table.model as GoodTableModel).getTableRowValue(table,row)!! + onEdit?.invoke(rowValue,value as C) + return super.getTableCellEditorComponent(table, value, isSelected, row, column) + } + } + } +} diff --git a/src/main/kotlin/net/eksb/kswingutil/table/TableUtil.kt b/src/main/kotlin/net/eksb/kswingutil/table/TableUtil.kt new file mode 100644 index 0000000..ba71841 --- /dev/null +++ b/src/main/kotlin/net/eksb/kswingutil/table/TableUtil.kt @@ -0,0 +1,194 @@ +package net.eksb.kswingutil.table + +import net.eksb.kswingutil.ext.onClick +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JTable +import javax.swing.ListSelectionModel +import kotlin.math.max +import kotlin.math.min + +fun setupTableSizes(table: JTable) { + val font = table.font + val metrics = table.getFontMetrics(font) + val desiredHeight = (metrics.height.toFloat() * 0.4f).toInt() + metrics.height + if (desiredHeight > table.rowHeight) { + table.rowHeight = desiredHeight + } +} + +/** + * Resize the table's columns to fit the cell values (looking at up to the first 10000 rows). + * + * @param table JTable whose columns to resize + * @param considerHeaderWidth If true, also resize to fit the header content. + * @param fillWeights Map of model column index to weight; if not null and not empty, + * after resizing, if there is space left, increase the size of the columns in the map + * according to their weights. Columns are not sized proportional to their weights, + * extra space is allocated to columns according to their weights. + * @param fixedWidths Map of model column index to fixed width for the column. Do not resize these columns. + */ +fun resizeColumnWidths(table: JTable, considerHeaderWidth:Boolean = false, + fillWeights:Map? = null, + fixedWidths:Map? = null +) { + val columnModel = table.columnModel + val font = table.font + val metrics = table.getFontMetrics(font) + val colNames = Array(table.columnCount) { table.getColumnName(it) } + val widths = IntArray(table.columnCount) { ci -> + val mci = table.convertColumnIndexToModel(ci) + fixedWidths?.get(mci) ?: max(metrics.stringWidth(" ${colNames[ci]} W"), 15) + } + val ww = metrics.stringWidth("W") + val rowCnt = min(table.rowCount, 10000) + if ( considerHeaderWidth ) { + for (ci in 0 until table.columnCount) { + val mci = table.convertColumnIndexToModel(ci) + if ( fixedWidths == null || ! fixedWidths.containsKey(mci) ) { + val column = table.tableHeader.columnModel.getColumn(ci) + val renderer = column.headerRenderer ?: table.tableHeader.defaultRenderer + val comp = renderer.getTableCellRendererComponent(table,column.headerValue,true,true,0,ci) + val width = min(300, comp.preferredSize.width + ww) + if (width > widths[ci]) widths[ci] = width + } + } + } + for (row in 0 until rowCnt) { + for (ci in 0 until table.columnCount) { + val mci = table.convertColumnIndexToModel(ci) + if ( fixedWidths == null || ! fixedWidths.containsKey(mci) ) { + //val colName = colNames[ci] + val renderer = table.getCellRenderer(row, ci) + val comp = table.prepareRenderer(renderer, row, ci) + val width = min(300, comp.preferredSize.width + ww) + if (width > widths[ci]) widths[ci] = width + } + } + } + + if ( fillWeights != null && fillWeights.isNotEmpty() ) { + val left = table.parent.width - widths.sum() + // fill weights for columns in [fixedWidths] are not applicable + val applicableFillWeights = + if ( fixedWidths == null ) { + fillWeights + } else { + fillWeights + .filter { (mci,prio) -> + ! fixedWidths.containsKey(mci) + } + } + if ( left > 0 ) { + val prioTotal = applicableFillWeights.values.sum() + if ( prioTotal > 0 ) { + applicableFillWeights.asSequence() + .drop(1) // because later we use the first to fill available space + .forEach { (mci,prio) -> + val ci = table.convertColumnIndexToView(mci) + if ( ci >= 0 ) { + widths[ci] = widths[ci] + ( (prio/prioTotal) * left ).toInt() + } + } + + val firstMci = applicableFillWeights.keys.first() + val firstCi = table.convertColumnIndexToView(firstMci) + if ( firstCi >= 0 ) { // it + widths[firstCi] = widths[firstCi] + table.parent.width - widths.sum() + } + } + } + } + + //table.preferredSize = Dimension(widths.sum(), table.preferredSize.height) + for (ci in 0 until table.columnCount) { + columnModel.getColumn(ci).preferredWidth = widths[ci] + if ( fixedWidths != null && fixedWidths.containsKey(table.convertColumnIndexToModel(ci)) ) { + columnModel.getColumn(ci).minWidth = widths[ci] + columnModel.getColumn(ci).maxWidth = widths[ci] + } + } +} + +/** + * Get selected indicies. + * Because [ListSelectionModel.getSelectedIndices] is not in Java 8. + */ +fun ListSelectionModel.getSelectedIndexes():Set { + val min = minSelectionIndex + val max = maxSelectionIndex + return if ( min >= 0 && max >= 0 ) { + (min..max) + .asSequence() + .filter { index -> isSelectedIndex(index) } + .toSet() + } else { + emptySet() + } +} + +/** + * Synchronize two tables' sorting and selection. + * + * Sharing [RowSorter] and [ListSelectionModel] between tables does not work, + * for reasons that are not clear to me. This method detects changes to one + * and copies the state to the other. + */ +fun syncSelectionModelAndRowSorter( table1:JTable, table2:JTable ) { + var changing = false + fun copySelection(a:JTable,b:JTable) { + b.selectionModel.clearSelection() + a.selectionModel.getSelectedIndexes().forEach { index -> + b.selectionModel.addSelectionInterval(index,index) + } + } + fun copySort(a:JTable,b:JTable) { + b.rowSorter.sortKeys = a.rowSorter.sortKeys + } + fun setup(a:JTable,b:JTable) { + a.selectionModel.addListSelectionListener { event -> + if ( ! event.valueIsAdjusting ) { + if ( ! changing ) { + try { + changing = true + copySelection(a,b) + } finally { + changing = false + } + } + } + } + a.rowSorter.addRowSorterListener { event -> + if ( ! changing ) { + try { + changing = true + copySort(a,b) + } finally { + changing = false + } + } + } + } + setup(table1,table2) + setup(table2,table1) +} + +fun JTable.onCellClick( op:(MouseEvent,Int,Int)->Unit ) { + onClick { event -> + val point = event.point + val rowIndex = rowAtPoint(point) + val colIndex = columnAtPoint(point) + if ( rowIndex >= 0 && colIndex >= 0 ) { + op(event,rowIndex,colIndex) + } + } +} + +fun JTable.onHeaderClick( op:(MouseEvent,Int)->Unit ) { + tableHeader.addMouseListener( object:MouseAdapter() { + override fun mouseClicked(mouseEvent:MouseEvent) { + val colIndex = columnAtPoint( mouseEvent.point ) + op(mouseEvent,colIndex) + } + } ) +}