This commit is contained in:
2025-07-26 22:22:55 +00:00
parent 57cd742d34
commit 9588c3df67
7 changed files with 1176 additions and 1 deletions

View File

@@ -10,6 +10,7 @@ group = "net.eksb"
dependencies {
api("com.miglayout:miglayout-swing:11.4.2")
api("org.swinglabs:swingx:1.6.1")
}
java {

View File

@@ -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
}
}
}

View File

@@ -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 <T> JComboBox<T>.model(values:Array<T>) {
model = DefaultComboBoxModel<T>(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 <E> JComboBox<E>.autoComplete( autoCompleteStrings:((E)->Sequence<String>)? =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<String> {
return autoCompleteStrings(item as E).toList().toTypedArray()
}
}
)
} else {
AutoCompleteDecorator.decorate(this)
}
}
typealias ListCellRendererLambda<T> = DefaultListCellRenderer.(
list:JList<T>,
value:T,

View File

@@ -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<Int,Int> = 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)
}

View File

@@ -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<Int,Float>? = null
/**
* Fixed widths for [reszieColumnWidths].
* [colResizeListener] sets these when the user resizes a column,
* so it stays that width.
*/
val fixedWidths:MutableMap<Int,Int> = 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 <T:Any,reified C> GoodTableModel<T>.column(name:String, identifier:Any?=null, op:GoodTableColumn<T, C>.()->Unit ): GoodTableColumn<T,C> {
val tableColumn = GoodTableColumn<T, C>(name, identifier, C::class.java)
op(tableColumn)
addColumn(tableColumn)
return tableColumn
}

View File

@@ -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> = (T)->C
/** Lambda to get a cell value (C) for the row value (T), at the specified index. */
typealias CellValueCalcIndexed<T,C> = (T,Int)->C
/** Lambda to determine if the cell is editable for the row value (R). */
typealias IsEditableCalc<T> = (T)->Boolean
/**
* Callback for when cell's value is changed.
* Arguments are row value, cell value, row index, cell index.
*/
typealias CellValueChangeHandler<T,C> = (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<T,C> = (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<T:Any>:AbstractTableModel() {
val cols:MutableList<GoodTableColumn<T, *>> = 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<GoodTableColumn<T,*>> = cols
.filter { col -> col.tables?.contains(table) ?: true }
/** The rows. If you modify this directly, you are responsible for calling [fire*] */
val rows:MutableList<T> = 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 <T:Any,C> getColumn(columnIndex:Int):GoodTableColumn<T,C> = cols[columnIndex] as GoodTableColumn<T,C>
fun <T:Any,C> getColumn(columnName:String):GoodTableColumn<T,C> = 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<T,*> ) {
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<T,*>
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<T,*>
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 <T:Any> JTable.goodTableModel( hmodel:GoodTableModel<T> ) {
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<T:Any,C>(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<T,C>? = null
/** Calculates if the cell is editable based on the row value. */
var isEditableCalc:IsEditableCalc<T>? = null
/** Callback for when a cell's value changes. */
var changeHandler:CellValueChangeHandler<T,C>? = null
/** Callback for when a cell is clicked. */
var clickHandler:CellClickHandler<T,C>? = 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<C>? = 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<Boolean>? = null
/** If not null, only put in these tables' ColumnModel. */
var tables:Set<JTable>? = null
/** Set the function that calculates a cell's value based on the row value. */
fun cellValue( calc:CellValueCalcIndexed<T,C>) {
this.cellValueCalc = calc
}
/** Set the function that calculates a cell's value based on the row value. */
fun cellValue( calc:CellValueCalc<T,C>) = cellValue { value, index -> calc(value) }
/** Sets the function that determines if a cell is editable based on the row value. */
fun isEditable( calc:IsEditableCalc<T>) {
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<T,C> ) {
this.changeHandler = changeHandler
}
/** Register a callback for when a cell is clicked. */
fun onClick( clickHandler:CellClickHandler<T,C> ) {
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<T>).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<C>, crossinline op:JComboBox<C>.(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<T>).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<C>,
autoComplete:Boolean = false,
autoCompleteStrings:((C)->Sequence<String>)? = 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<C>,
autoComplete:Boolean = false,
autoCompleteStrings:((C)->Sequence<String>)? = 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<T>).getTableRowValue(table,row)!!
onEdit?.invoke(rowValue,value as C)
return super.getTableCellEditorComponent(table, value, isSelected, row, column)
}
}
}
}

View File

@@ -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<Int,Float>? = null,
fixedWidths:Map<Int,Int>? = null
) {
val columnModel = table.columnModel
val font = table.font
val metrics = table.getFontMetrics(font)
val colNames = Array<String>(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<Int> {
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)
}
} )
}