Compare commits

...

6 Commits

Author SHA1 Message Date
9588c3df67 various 2025-07-26 22:22:55 +00:00
57cd742d34 rearranging and docs 2025-07-17 02:04:28 +00:00
1bb524646e mig and other goodies 2025-07-15 02:04:27 +00:00
092986b351 readme note about maven central 2025-02-20 01:17:31 +00:00
a48fd041d1 script to deploy to maven central 2025-02-20 01:07:56 +00:00
4887fa198b add license (apache2) and copyright info 2025-02-20 01:07:37 +00:00
18 changed files with 1720 additions and 14 deletions

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

2
NOTICE Normal file
View File

@@ -0,0 +1,2 @@
kswingutil
Copyright 2025 Stephen Byrne

View File

@@ -1,3 +1,5 @@
# kswingutil # kswingutil
Kotlin Swing Utilities Kotlin Swing Utilities
Deployed to Maven Central as `net.eksb:kswingutil`.

View File

@@ -1,24 +1,28 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
kotlin("jvm") version "2.1.10" kotlin("jvm") version "2.2.0"
`maven-publish` // for publishToMavenLocal `maven-publish` // for publishToMavenLocal
id("org.jetbrains.dokka-javadoc") version "2.0.0" id("org.jetbrains.dokka-javadoc") version "2.0.0"
} }
group = "net.eksb" group = "net.eksb"
dependencies {} dependencies {
api("com.miglayout:miglayout-swing:11.4.2")
api("org.swinglabs:swingx:1.6.1")
}
java { java {
targetCompatibility = JavaVersion.VERSION_17 toolchain {
sourceCompatibility = JavaVersion.VERSION_17 languageVersion.set(JavaLanguageVersion.of(21))
}
withSourcesJar() withSourcesJar()
} }
kotlin { kotlin {
compilerOptions { compilerOptions {
jvmTarget.set(JvmTarget.JVM_17) jvmTarget.set(JvmTarget.JVM_21)
} }
} }
@@ -27,6 +31,9 @@ val dokkaJavadocJar by tasks.registering(Jar::class) {
from(tasks.dokkaGeneratePublicationJavadoc.flatMap { it.outputDirectory }) from(tasks.dokkaGeneratePublicationJavadoc.flatMap { it.outputDirectory })
archiveClassifier.set("javadoc") archiveClassifier.set("javadoc")
} }
tasks.build {
dependsOn(tasks["dokkaJavadocJar"])
}
publishing { publishing {
publications { publications {
@@ -34,6 +41,29 @@ publishing {
from(components["java"]) from(components["java"])
tasks["generateMetadataFileForMavenPublication"].dependsOn(dokkaJavadocJar) tasks["generateMetadataFileForMavenPublication"].dependsOn(dokkaJavadocJar)
artifact(dokkaJavadocJar) artifact(dokkaJavadocJar)
pom {
name = "${project.group}:${project.name}"
description = "Kotlin utilities for Swing application"
url = "https://git.eksb.net/stephen/kswingutil"
licenses {
license {
name = "The Apache License, Version 2.0"
url = "http://www.apache.org/licenses/LICENSE-2.0.txt"
}
}
developers {
developer {
name = "Stephen Byrne"
email = "code@eksb.net"
url = "https://git.eksb.net/stephen"
}
}
scm {
url = "not used"
connection = "not used"
developerConnection = "not used"
}
}
} }
} }
} }

74
scripts/maven-central-deploy.sh Executable file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# This script deploys the built library to Maven Central.
# $GPG_KEY_ID must be set to a key ID
# $TOKEN_USERNAME and $TOKEN_PASSWORD must be set to your Maven Central token credentials
if [ -z $GPG_KEY_ID ]; then
echo "\$GPG_KEY_ID not set" >&2
exit 1
fi
if [ -z $TOKEN_USERNAME ]; then
echo "\$TOKEN_USERNAME not set" >&2
exit 1
fi
if [ -z $TOKEN_PASSWORD ]; then
echo "\$TOKEN_PASSWORD not set" >&2
exit 1
fi
POM=build/publications/maven/pom-default.xml
set -e
if [ ! -e "${POM}" ]; then
echo "No file: '${POM}'." >&2
echo "Maybe run 'gradle -Pversion=0.0.1 build generatePomFileForMavenPublication'?" >&2
exit 1
fi
GROUP_ID=$(xmlstarlet sel -t -m '//_:project' -v '_:groupId' build/publications/maven/pom-default.xml)
ARTIFACT_ID=$(xmlstarlet sel -t -m '//_:project' -v '_:artifactId' build/publications/maven/pom-default.xml)
VERSION=$(xmlstarlet sel -t -m '//_:project' -v '_:version' build/publications/maven/pom-default.xml)
NAME="${GROUP_ID}:${ARTIFACT_ID}:${VERSION}"
echo "Deploy ${NAME}..."
# build the zip directory
ZIP_DIR="build/publications/maven/central-bundle"
rm -rf "${ZIP_DIR}"
SUB_DIR="${GROUP_ID//./\/}/${ARTIFACT_ID//.\//}/${VERSION}"
TARGET_DIR="${ZIP_DIR}/${SUB_DIR}"
mkdir -p "${TARGET_DIR}"
cp $POM "${TARGET_DIR}/${ARTIFACT_ID}-${VERSION}.pom"
cp "build/libs/${ARTIFACT_ID}-${VERSION}.jar" "${TARGET_DIR}"
cp "build/libs/${ARTIFACT_ID}-${VERSION}-javadoc.jar" "${TARGET_DIR}"
cp "build/libs/${ARTIFACT_ID}-${VERSION}-sources.jar" "${TARGET_DIR}"
# sign and digest
pushd $TARGET_DIR
for file in *.jar *.pom; do
gpg --default-key $GPG_KEY_ID --armor --detach-sign "${file}"
sha1sum "${file}" |awk '{print $1}' > "${file}.sha1"
md5sum "${file}" |awk '{print $1}' > "${file}.md5"
done
popd
# build the zip file
ZIP="build/publications/maven/central-bundle.zip"
rm -f "${ZIP}"
pushd $ZIP_DIR
zip -r ../$(basename ${ZIP}) .
popd
# deploy
AUTH_HEADER="Authorization: Bearer $(echo "${TOKEN_USERNAME}:${TOKEN_PASSWORD}"|base64)"
DEPLOYMENT_ID=$(curl --request POST \
--verbose \
--header "${AUTH_HEADER}" \
--form bundle=@${ZIP} \
"https://central.sonatype.com/api/v1/publisher/upload?publishingType=AUTOMATIC&name=${NAME}")
echo "Deployment ID: ${DEPLOYMENT_ID}"

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

@@ -0,0 +1,13 @@
package net.eksb.kswingutil
import javax.swing.UIManager
fun systemLookAndFeel() {
try {
UIManager.setLookAndFeel(
UIManager.getSystemLookAndFeelClassName()
)
} catch ( e:java.lang.Exception) {
System.err.println( "Error setting laf: ${e.message}" )
}
}

View File

@@ -2,7 +2,10 @@ package net.eksb.kswingutil.ext
import net.eksb.kswingutil.property.MutableProperty import net.eksb.kswingutil.property.MutableProperty
import net.eksb.kswingutil.property.Property import net.eksb.kswingutil.property.Property
import java.awt.Color
import java.awt.Component import java.awt.Component
import javax.swing.JComponent
import javax.swing.border.Border
/** /**
* One-way bind a [java.awt.Component] to a [Property]. * One-way bind a [java.awt.Component] to a [Property].

View File

@@ -0,0 +1,39 @@
package net.eksb.kswingutil.ext
import net.eksb.kswingutil.property.Property
import java.awt.Color
import java.awt.Component
import javax.swing.JComponent
import javax.swing.border.Border
/**
* Bind the supplied property to this component's tooltip text.
*/
fun JComponent.bindTooltipText(property:Property<String>) {
bind(property) {
toolTipText = it
}
}
/**
* Bind the supplied property to this component's background color.
*/
fun JComponent.bindBackground(property:Property<Color>) {
bind(property) {
background = it
}
}
/**
* Bind the supplied property to this component's border.
*/
fun JComponent.bindBorder(property:Property<Border>) {
bind(property) {
border = it
}
}
/**
* Bind this component to be enabled when [property] is enabled.
*/
fun Component.enabledWhen(property:Property<Boolean>) = bind(property) { isEnabled = it }

View File

@@ -1,7 +1,5 @@
package net.eksb.kswingutil.ext package net.eksb.kswingutil.ext
import net.eksb.kswingutil.property.Property
import net.eksb.kswingutil.ext.bind
import java.awt.Component import java.awt.Component
import java.awt.Frame import java.awt.Frame
import java.awt.event.ComponentAdapter import java.awt.event.ComponentAdapter
@@ -13,13 +11,14 @@ import javax.swing.SwingUtilities
import kotlin.system.exitProcess import kotlin.system.exitProcess
/** /**
* Bind this component to be enabled when [property] is enabled. * Get the frame that this component is or is in.
*/ */
fun Component.enabledWhen(property:Property<Boolean>) = bind(property) { isEnabled = it }
fun Component.getFrame(): Frame = fun Component.getFrame(): Frame =
this as? Frame ?: SwingUtilities.getAncestorOfClass(Frame::class.java,this) as Frame this as? Frame ?: SwingUtilities.getAncestorOfClass(Frame::class.java,this) as Frame
/**
* Execute the supplied function when this component is disposed.
*/
fun Component.onDispose(block:()->Unit) { fun Component.onDispose(block:()->Unit) {
addComponentListener( addComponentListener(
object: ComponentAdapter() { object: ComponentAdapter() {
@@ -38,6 +37,9 @@ fun Component.onDispose(block:()->Unit) {
}) })
} }
/**
* Exit the JVM when this frame is closed.
*/
fun JFrame.exitOnClose() { fun JFrame.exitOnClose() {
addWindowListener(object: WindowAdapter() { addWindowListener(object: WindowAdapter() {
override fun windowClosed(e: WindowEvent) { override fun windowClosed(e: WindowEvent) {

View File

@@ -4,10 +4,19 @@ import java.awt.Component
import java.awt.event.MouseAdapter import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
/**
* Execute the supplied function whenever mouse button 1 is clicked on this
* component.
*/
fun Component.onClick1(block:(MouseEvent)->Unit) { fun Component.onClick1(block:(MouseEvent)->Unit) {
onClick(MouseEvent.BUTTON1, block) onClick(MouseEvent.BUTTON1, block)
} }
/**
* Execute the supplied function whenever the specified mouse button
* is clicked on this component.
* If [button] is null, execute the function for any mouse button.
*/
fun Component.onClick(button:Int?=null, block:(MouseEvent)->Unit) { fun Component.onClick(button:Int?=null, block:(MouseEvent)->Unit) {
addMouseListener(object: MouseAdapter() { addMouseListener(object: MouseAdapter() {
override fun mouseClicked(event: MouseEvent) { override fun mouseClicked(event: MouseEvent) {

View File

@@ -2,9 +2,13 @@ package net.eksb.kswingutil.ext
import net.eksb.kswingutil.property.MutableProperty import net.eksb.kswingutil.property.MutableProperty
import net.eksb.kswingutil.property.Property 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.Component
import java.awt.Container import java.awt.Container
import java.awt.Font
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.BorderFactory import javax.swing.BorderFactory
import javax.swing.Box import javax.swing.Box
import javax.swing.DefaultComboBoxModel import javax.swing.DefaultComboBoxModel
@@ -15,13 +19,36 @@ import javax.swing.JComponent
import javax.swing.JLabel import javax.swing.JLabel
import javax.swing.JList import javax.swing.JList
import javax.swing.JPanel import javax.swing.JPanel
import javax.swing.JScrollPane
import javax.swing.JSeparator import javax.swing.JSeparator
import javax.swing.JTabbedPane
import javax.swing.JToolBar
fun Container.separator(constraints:Any?=null, alignment:Int, block:JSeparator.()->Unit ={}):JSeparator = fun Container.separator(constraints:Any?=null, alignment:Int, block:JSeparator.()->Unit ={}):JSeparator =
add(constraints, JSeparator(alignment),block) add(constraints, JSeparator(alignment),block)
fun Container.panel(constraints:Any?=null, block:JPanel.()->Unit ={}):JPanel = add(constraints, JPanel(),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 Container.horizontalGlue():Component = add(Box.createHorizontalGlue())
fun JComponent.emptyBorder(size:Int) { fun JComponent.emptyBorder(size:Int) {
@@ -47,6 +74,32 @@ fun <T> JComboBox<T>.model(values:Array<T>) {
model = DefaultComboBoxModel<T>(values) 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.( typealias ListCellRendererLambda<T> = DefaultListCellRenderer.(
list:JList<T>, list:JList<T>,
value:T, value:T,
@@ -87,3 +140,40 @@ fun <T> JComboBox<T>.bind(property:MutableProperty<T>) {
fun Container.button(constraints:Any?=null, text:String?=null, block:JButton.()->Unit = {}):JButton = add(constraints, fun Container.button(constraints:Any?=null, text:String?=null, block:JButton.()->Unit = {}):JButton = add(constraints,
JButton(text),block) JButton(text),block)
/**
* Execute the [enter] function when the mouse enters this component,
* and the [exit] function when the mouse exits this component.
*/
fun JComponent.onHover(
enter:(MouseEvent)->Unit,
exit:(MouseEvent)->Unit
) {
addMouseListener(object: MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
enter.invoke(e)
}
override fun mouseExited(e: MouseEvent) {
exit.invoke(e)
}
})
}
/**
* Create a Property that is true when the mouse is hovered over this component.
*/
fun JComponent.hoverProperty(): Property<Boolean> =
MutableProperty(false).apply {
onHover(
enter = { e -> value = true },
exit = { e -> value = false }
)
}
/**
* Make this component's text bold.
*/
fun JComponent.bold() {
font = font.deriveFont(Font.BOLD)
repaint()
}

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,74 @@
package net.eksb.kswingutil.mig
// Utility and extension functions for using MigLayout. See http://miglayout.com/
import java.awt.Container
import java.util.UUID
import net.miginfocom.swing.*
// Let users import net.eksb.kswingutil.mig.* and not have to also import net.miginfocom.layout.*
typealias LC = net.miginfocom.layout.LC
typealias AC = net.miginfocom.layout.AC
typealias CC = net.miginfocom.layout.CC
fun Container.migLayout(
layoutConstraints:LC?=null,
colConstraints:AC?=null,
rowConstraints:AC?=null
) {
layout = MigLayout(
( layoutConstraints ?: LC() ).debugIfEnv(),
colConstraints ?: AC(),
rowConstraints ?: AC() )
}
fun CC.heightMin():CC = height("min")
/**
* Set a cell to take up the full height of the row
* without impacting the height of the row.
* Or, in other words, to take the full height of the tallest cell
* besides this one.
*/
fun CC.heightGrowMin():CC = growY().heightMin()
fun AC.grow(w:Int) = grow(w.toFloat())
fun AC.grow(w:Double) = grow(w.toFloat())
/**
* Set all columns to be the same width.
*
* This works by setting [AC.sizeGroup] to the same thing
* for all columns.
*
* After this method returns, the end, [AC.curIx] is the last index.
*/
fun AC.allColsSameWidth():AC = apply {
val group = UUID.randomUUID().toString()
(0 until count).forEach { index ->
index(index).sizeGroup(group)
}
}
fun AC.alignRight() = align("right")
fun CC.alignRight() = alignX("right")
fun AC.alignLeft() = align("left")
fun CC.alignLeft() = alignX("left")
fun AC.alignCenter() = align("center")
fun CC.alignCenter() = alignX("center")
private val debug:Boolean = System.getenv("SWINGUTIL_MIG_DEBUG")?.toBoolean() == true
/**
* Show debug decorations if $SWINGUTIL_MIG_DEBUG is set to "true"
* @param repaintMillis The new debug repaint interval.
*/
fun LC.debugIfEnv(repaintMillis:Int=300):LC = apply {
if ( debug ) {
debug(repaintMillis)
}
}
fun LC.noInsets() = insetsAll("0")
fun LC.noGridGap() = gridGap("0","0")

View File

@@ -15,3 +15,46 @@ fun <T,R> Property<T>.map(transform:(T)->R):Property<R> =
* Get a [Property] that is true when this property is not null. * Get a [Property] that is true when this property is not null.
*/ */
fun <T> Property<T>.mapNotNull():Property<Boolean> = map { it != null } fun <T> Property<T>.mapNotNull():Property<Boolean> = map { it != null }
/**
* Create a Property<T> that is updated to the value generated by [op]
* when any of the supplied [props] change.
*
* @param props Update this property when any of these properties change.
* @param op Calculates the value of this property
*/
fun <T> propertyFrom(vararg props:Property<*>, op:()->T): Property<T> =
MutableProperty(op())
.apply {
props.forEach { prop ->
prop.addListener { _, _ ->
value = op()
}
}
}
/**
* Get a `Property<Boolean>` that is `!this.value`.
*/
operator fun Property<Boolean>.not():Property<Boolean> = map { ! it }
/**
* Get a `Property<Boolean>` that is this property's value or the supplied
* property's value.
*/
fun Property<Boolean>.or(other:Property<Boolean>): Property<Boolean> =
propertyFrom(this, other) { this.value || other.value }
/**
* Get a `Property<Boolean>` that is this property's value and the supplied
* property's value.
*/
fun Property<Boolean>.and(other:Property<Boolean>): Property<Boolean> =
propertyFrom(this, other) { this.value && other.value }
/**
* Get a `Property<Boolean>` that is this property's value xor the supplied
* property's value.
*/
fun Property<Boolean>.xor(other:Property<Boolean>): Property<Boolean> =
propertyFrom(this, other) { this.value xor other.value }

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