phase 2 kotlin WIP

This commit is contained in:
Robinson 2023-01-27 21:32:25 +01:00
parent a218bfb8db
commit b286ee9cf1
No known key found for this signature in database
GPG Key ID: 8E7DB78588BD6F5C
14 changed files with 511 additions and 450 deletions

View File

@ -1,20 +0,0 @@
/*
* Copyright 2021 dorkbox, llc
*
* 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.
*/
package dorkbox.notify
interface ActionHandler<T> {
fun handle(value: T)
}

View File

@ -15,42 +15,39 @@
*/ */
package dorkbox.notify package dorkbox.notify
import dorkbox.util.SwingUtil
import java.awt.Frame import java.awt.Frame
import java.awt.event.ComponentEvent import java.awt.event.ComponentEvent
import java.awt.event.ComponentListener import java.awt.event.ComponentListener
import java.awt.event.WindowStateListener import java.awt.event.WindowStateListener
import javax.swing.ImageIcon
import javax.swing.JFrame
import javax.swing.JPanel import javax.swing.JPanel
// this is a child to a Jframe/window (instead of globally to the screen). // this is a child to a Jframe/window (instead of globally to the screen).
class AsApplication internal constructor(private val notification: Notify, image: ImageIcon?, private val appWindow: JFrame, theme: Theme) : INotify { internal class AsApplication internal constructor(
private val notification: Notify,
private val notifyCanvas: NotifyCanvas,) : NotifyType {
companion object { companion object {
private const val glassPanePrefix = "dorkbox.notify" private const val glassPanePrefix = "dorkbox.notify"
} }
private val look: LookAndFeel private val window = notification.attachedFrame!!
private val notifyCanvas: NotifyCanvas
private val parentListener: ComponentListener private val parentListener: ComponentListener
private val windowStateListener: WindowStateListener private val windowStateListener: WindowStateListener
private var glassPane: JPanel? = null private var glassPane: JPanel
// NOTE: this is on the swing EDT // NOTE: this is on the swing EDT
init { init {
notifyCanvas = NotifyCanvas(this, notification, image, theme)
look = LookAndFeel(this, appWindow, notifyCanvas, notification, appWindow.bounds, false)
// this makes sure that our notify canvas stay anchored to the parent window (if it's hidden/shown/moved/etc) // this makes sure that our notify canvas stay anchored to the parent window (if it's hidden/shown/moved/etc)
parentListener = object : ComponentListener { parentListener = object : ComponentListener {
override fun componentShown(e: ComponentEvent) { override fun componentShown(e: ComponentEvent) {
look.reLayout(appWindow.bounds) notification.notifyLook?.reLayout(window.bounds)
} }
override fun componentHidden(e: ComponentEvent) {} override fun componentHidden(e: ComponentEvent) {}
override fun componentResized(e: ComponentEvent) { override fun componentResized(e: ComponentEvent) {
look.reLayout(appWindow.bounds) notification.notifyLook?.reLayout(window.bounds)
} }
override fun componentMoved(e: ComponentEvent) {} override fun componentMoved(e: ComponentEvent) {}
@ -59,81 +56,61 @@ class AsApplication internal constructor(private val notification: Notify, image
windowStateListener = WindowStateListener { e -> windowStateListener = WindowStateListener { e ->
val state = e.newState val state = e.newState
if (state and Frame.ICONIFIED == 0) { if (state and Frame.ICONIFIED == 0) {
look.reLayout(appWindow.bounds) notification.notifyLook?.reLayout(window.bounds)
} }
} }
appWindow.addWindowStateListener(windowStateListener) window.addWindowStateListener(windowStateListener)
appWindow.addComponentListener(parentListener) window.addComponentListener(parentListener)
val pane = window.glassPane
val glassPane_ = appWindow.glassPane if (pane is JPanel) {
if (glassPane_ is JPanel) { glassPane = pane
glassPane = glassPane_ val name = glassPane.name
val name = glassPane_.name
if (name != glassPanePrefix) { if (name != glassPanePrefix) {
// We just tweak the already existing glassPane, instead of replacing it with our own // We just tweak the already existing glassPane, instead of replacing it with our own
// glassPane = new JPanel(); // glassPane = new JPanel();
glassPane_.layout = null glassPane.layout = null
glassPane_.name = glassPanePrefix glassPane.name = glassPanePrefix
// glassPane.setSize(appWindow.getSize()); // glassPane.setSize(appWindow.getSize());
// glassPane.setOpaque(false); // glassPane.setOpaque(false);
// appWindow.setGlassPane(glassPane); // appWindow.setGlassPane(glassPane);
} }
glassPane_.add(notifyCanvas) glassPane.add(notifyCanvas)
if (!glassPane_.isVisible) { if (!glassPane.isVisible) {
glassPane_.isVisible = true glassPane.isVisible = true
} }
} else { } else {
System.err.println("Not able to add notification to custom glassPane") throw RuntimeException("Not able to add the notification to the window glassPane")
} }
} }
override fun onClick(x: Int, y: Int) { override fun setVisible(visible: Boolean, look: LookAndFeel) {
look.onClick(x, y)
}
/**
* Shakes the popup
*
* @param durationInMillis now long it will shake
* @param amplitude a measure of how much it needs to shake. 4 is a small amount of shaking, 10 is a lot.
*/
override fun shake(durationInMillis: Int, amplitude: Int) {
look.shake(durationInMillis, amplitude)
}
override fun setVisible(visible: Boolean) {
// this is because the order of operations are different based upon visibility. // this is because the order of operations are different based upon visibility.
look.updatePositionsPre(visible) look.updatePositionsPre(visible)
look.updatePositionsPost(visible) look.updatePositionsPost(visible)
} }
// called on the Swing EDT.
override fun close() { override fun close() {
// this must happen in the Swing EDT. This is usually called by the active renderer glassPane.remove(notifyCanvas)
SwingUtil.invokeLater { window.removeWindowStateListener(windowStateListener)
look.close() window.removeComponentListener(parentListener)
glassPane!!.remove(notifyCanvas)
appWindow.removeWindowStateListener(windowStateListener)
appWindow.removeComponentListener(parentListener)
var found = false var found = false
val components = glassPane!!.components val components = glassPane.components
for (component in components) { for (component in components) {
if (component is NotifyCanvas) { if (component is NotifyCanvas) {
found = true found = true
break break
}
} }
}
if (!found) { if (!found) {
// hide the glass pane if there are no more notifications on it. // hide the glass pane if there are no more notifications on it.
glassPane!!.isVisible = false glassPane.isVisible = false
}
notification.onClose()
} }
} }
} }

View File

@ -15,23 +15,16 @@
*/ */
package dorkbox.notify package dorkbox.notify
import dorkbox.util.ScreenUtil
import dorkbox.util.SwingUtil
import java.awt.Dimension import java.awt.Dimension
import java.awt.GraphicsEnvironment
import java.awt.MouseInfo
import javax.swing.ImageIcon
import javax.swing.JWindow import javax.swing.JWindow
// we can't use regular popup, because if we have no owner, it won't work! // we can't use regular popup, because if we have no owner, it won't work!
// instead, we just create a JWindow and use it to hold our content // instead, we just create a JWindow and use it to hold our content
class AsDesktop internal constructor(private val notification: Notify, image: ImageIcon?, theme: Theme) : JWindow(), INotify { internal class AsDesktop internal constructor(val notification: Notify, notifyCanvas: NotifyCanvas) : JWindow(), NotifyType {
companion object { companion object {
private const val serialVersionUID = 1L private const val serialVersionUID = 1L
} }
private val look: LookAndFeel
// this is on the swing EDT // this is on the swing EDT
init { init {
isAlwaysOnTop = true isAlwaysOnTop = true
@ -43,49 +36,10 @@ class AsDesktop internal constructor(private val notification: Notify, image: Im
setSize(NotifyCanvas.WIDTH, NotifyCanvas.HEIGHT) setSize(NotifyCanvas.WIDTH, NotifyCanvas.HEIGHT)
setLocation(Short.MIN_VALUE.toInt(), Short.MIN_VALUE.toInt()) setLocation(Short.MIN_VALUE.toInt(), Short.MIN_VALUE.toInt())
val device = if (notification.screenNumber == Short.MIN_VALUE.toInt()) {
// set screen position based on mouse
val mouseLocation = MouseInfo.getPointerInfo().location
ScreenUtil.getMonitorAtLocation(mouseLocation)
} else {
// set screen position based on specified screen
var screenNumber = notification.screenNumber
val ge = GraphicsEnvironment.getLocalGraphicsEnvironment()
val screenDevices = ge.screenDevices
if (screenNumber < 0) {
screenNumber = 0
} else if (screenNumber > screenDevices.size - 1) {
screenNumber = screenDevices.size - 1
}
screenDevices[screenNumber]
}
val bounds = device.defaultConfiguration.bounds
val notifyCanvas = NotifyCanvas(this, notification, image, theme)
contentPane.add(notifyCanvas) contentPane.add(notifyCanvas)
look = LookAndFeel(this, this, notifyCanvas, notification, bounds, true)
} }
override fun onClick(x: Int, y: Int) { override fun setVisible(visible: Boolean, look: LookAndFeel) {
look.onClick(x, y)
}
/**
* Shakes the popup
*
* @param durationInMillis now long it will shake
* @param amplitude a measure of how much it needs to shake. 4 is a small amount of shaking, 10 is a lot.
*/
override fun shake(durationInMillis: Int, amplitude: Int) {
look.shake(durationInMillis, amplitude)
}
override fun setVisible(visible: Boolean) {
// was it already visible? // was it already visible?
if (visible == isVisible) { if (visible == isVisible) {
// prevent "double setting" visible state // prevent "double setting" visible state
@ -103,19 +57,10 @@ class AsDesktop internal constructor(private val notification: Notify, image: Im
} }
} }
// setVisible(false) with any extra logic // called on the Swing EDT
fun doHide() {
super.setVisible(false)
}
override fun close() { override fun close() {
// this must happen in the Swing EDT. This is usually called by the active renderer super.setVisible(false)
SwingUtil.invokeLater { removeAll()
doHide() dispose()
look.close()
removeAll()
dispose()
notification.onClose()
}
} }
} }

View File

@ -20,7 +20,7 @@ import java.awt.event.MouseEvent
internal class ClickAdapter : MouseAdapter() { internal class ClickAdapter : MouseAdapter() {
override fun mouseReleased(e: MouseEvent) { override fun mouseReleased(e: MouseEvent) {
val parent = (e.source as NotifyCanvas).parent val notifyCanvas = e.source as NotifyCanvas
parent.onClick(e.x, e.y) notifyCanvas.onClick(e.x, e.y)
} }
} }

222
src/dorkbox/notify/LAFUtil.kt Executable file
View File

@ -0,0 +1,222 @@
/*
* Copyright 2015 dorkbox, llc
*
* 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.
*/
package dorkbox.notify
import dorkbox.swingActiveRender.ActionHandlerLong
import dorkbox.tweenEngine.TweenCallback.Events.COMPLETE
import dorkbox.tweenEngine.TweenEngine.Companion.create
import dorkbox.tweenEngine.TweenEquations
import dorkbox.util.ScreenUtil
import java.awt.GraphicsEnvironment
import java.awt.MouseInfo
import java.awt.Rectangle
import java.awt.event.MouseAdapter
import java.util.*
internal object LAFUtil{
val popups: MutableMap<String, PopupList> = HashMap()
// access is only from a single thread ever, so unsafe is preferred.
val animation = create().unsafe().build()
val accessor = NotifyAccessor()
// this is for updating the tween engine during active-rendering
val frameStartHandler = ActionHandlerLong { deltaInNanos -> animation.update(deltaInNanos) }
const val SPACER = 10
const val MARGIN = 20
val windowListener: java.awt.event.WindowAdapter = WindowAdapter()
val mouseListener: MouseAdapter = ClickAdapter()
val RANDOM = Random()
private val MOVE_DURATION = Notify.MOVE_DURATION
fun getGraphics(screen: Int): Rectangle {
val device = if (screen == Short.MIN_VALUE.toInt()) {
// set screen position based on mouse
val mouseLocation = MouseInfo.getPointerInfo().location
ScreenUtil.getMonitorAtLocation(mouseLocation)
} else {
// set screen position based on specified screen
var screenNumber = screen
val ge = GraphicsEnvironment.getLocalGraphicsEnvironment()
val screenDevices = ge.screenDevices
if (screenNumber < 0) {
screenNumber = 0
} else if (screenNumber > screenDevices.size - 1) {
screenNumber = screenDevices.size - 1
}
screenDevices[screenNumber]
}
return device.defaultConfiguration.bounds
}
fun getAnchorX(position: Position, bounds: Rectangle, isDesktop: Boolean): Int {
// we use the screen that the mouse is currently on.
val startX = if (isDesktop) {
bounds.getX().toInt()
} else {
0
}
val screenWidth = bounds.getWidth().toInt()
return when (position) {
Position.TOP_LEFT, Position.BOTTOM_LEFT -> MARGIN + startX
Position.CENTER -> startX + screenWidth / 2 - NotifyCanvas.WIDTH / 2 - MARGIN / 2
Position.TOP_RIGHT, Position.BOTTOM_RIGHT -> startX + screenWidth - NotifyCanvas.WIDTH - MARGIN
}
}
fun getAnchorY(position: Position, bounds: Rectangle, isDesktop: Boolean): Int {
val startY = if (isDesktop) {
bounds.getY().toInt()
} else {
0
}
val screenHeight = bounds.getHeight().toInt()
return when (position) {
Position.TOP_LEFT, Position.TOP_RIGHT -> startY + MARGIN
Position.CENTER -> startY + screenHeight / 2 - NotifyCanvas.HEIGHT / 2 - MARGIN / 2 - SPACER
Position.BOTTOM_LEFT, Position.BOTTOM_RIGHT -> if (isDesktop) {
startY + screenHeight - NotifyCanvas.HEIGHT - MARGIN
} else {
screenHeight - NotifyCanvas.HEIGHT - MARGIN - SPACER * 2
}
}
}
// only called on the swing EDT thread
fun addPopupToMap(sourceLook: LookAndFeel) {
synchronized(popups) {
val id = sourceLook.idAndPosition
var looks = popups[id]
if (looks == null) {
looks = PopupList()
popups[id] = looks
}
val index = looks.size()
sourceLook.popupIndex = index
// the popups are ALL the same size!
// popups at TOP grow down, popups at BOTTOM grow up
val anchorX = sourceLook.anchorX
val anchorY = sourceLook.anchorY
val targetY = if (index == 0) {
anchorY
} else {
val growDown = growDown(sourceLook)
if (sourceLook.isDesktopNotification && index == 1) {
// have to adjust for offsets when the window-manager has a toolbar that consumes space and prevents overlap.
// this is only done when the 2nd popup is added to the list
looks.calculateOffset(growDown, anchorX, anchorY)
}
if (growDown) {
anchorY + index * (NotifyCanvas.HEIGHT + SPACER) + looks.offsetY
} else {
anchorY - index * (NotifyCanvas.HEIGHT + SPACER) + looks.offsetY
}
}
looks.add(sourceLook)
sourceLook.setLocation(anchorX, targetY)
if (sourceLook.hideAfterDurationInSeconds > 0 && sourceLook.hideTween == null) {
// begin a timeline to get rid of the popup (default is 5 seconds)
animation.to(sourceLook, NotifyAccessor.PROGRESS, accessor, sourceLook.hideAfterDurationInSeconds)
.target(NotifyCanvas.WIDTH.toFloat())
.ease(TweenEquations.Linear)
.addCallback(COMPLETE) { sourceLook.notification.onClose() }
.start()
}
}
}
// only called on the swing app or SwingActiveRender thread
fun removePopupFromMap(sourceLook: LookAndFeel): Boolean {
val growDown = growDown(sourceLook)
var popupsAreEmpty: Boolean
synchronized(popups) {
popupsAreEmpty = popups.isEmpty()
val allLooks = popups[sourceLook.idAndPosition]
// there are two loops because it is necessary to cancel + remove all tweens BEFORE adding new ones.
var adjustPopupPosition = false
val iterator = allLooks!!.iterator()
while (iterator.hasNext()) {
val look = iterator.next()
if (look.tween != null) {
look.tween!!.cancel() // cancel does its thing on the next tick of animation cycle
look.tween = null
}
if (look === sourceLook) {
if (look.hideTween != null) {
look.hideTween!!.cancel()
look.hideTween = null
}
adjustPopupPosition = true
iterator.remove()
}
if (adjustPopupPosition) {
look.popupIndex--
}
}
// have to adjust for offsets when the window-manager has a toolbar that consumes space and prevents overlap.
val offsetY = allLooks.offsetY
for (index in 0 until allLooks.size()) {
val look = allLooks[index]
// the popups are ALL the same size!
// popups at TOP grow down, popups at BOTTOM grow up
val changedY = if (growDown) {
look.anchorY + (look.popupIndex * (NotifyCanvas.HEIGHT + SPACER) + offsetY)
} else {
look.anchorY - (look.popupIndex * (NotifyCanvas.HEIGHT + SPACER) + offsetY)
}
// now animate that popup to its new location
look.tween = animation
.to(look, NotifyAccessor.Y_POS, accessor, MOVE_DURATION)
.target(changedY.toFloat())
.ease(TweenEquations.Linear)
.addCallback(COMPLETE) {
// make sure to remove the tween once it's done, otherwise .kill can do weird things.
look.tween = null
}
.start()
}
}
return popupsAreEmpty
}
fun growDown(look: LookAndFeel): Boolean {
return when (look.position) {
Position.TOP_LEFT, Position.TOP_RIGHT, Position.CENTER -> true
else -> false
}
}
}

View File

@ -15,218 +15,52 @@
*/ */
package dorkbox.notify package dorkbox.notify
import dorkbox.swingActiveRender.ActionHandlerLong import dorkbox.notify.LAFUtil.RANDOM
import dorkbox.notify.LAFUtil.SPACER
import dorkbox.notify.LAFUtil.accessor
import dorkbox.notify.LAFUtil.addPopupToMap
import dorkbox.notify.LAFUtil.animation
import dorkbox.notify.LAFUtil.frameStartHandler
import dorkbox.notify.LAFUtil.getAnchorX
import dorkbox.notify.LAFUtil.getAnchorY
import dorkbox.notify.LAFUtil.growDown
import dorkbox.notify.LAFUtil.mouseListener
import dorkbox.notify.LAFUtil.popups
import dorkbox.notify.LAFUtil.removePopupFromMap
import dorkbox.notify.LAFUtil.windowListener
import dorkbox.swingActiveRender.SwingActiveRender import dorkbox.swingActiveRender.SwingActiveRender
import dorkbox.tweenEngine.Tween import dorkbox.tweenEngine.Tween
import dorkbox.tweenEngine.TweenCallback.Events.COMPLETE
import dorkbox.tweenEngine.TweenEngine.Companion.create
import dorkbox.tweenEngine.TweenEquations import dorkbox.tweenEngine.TweenEquations
import dorkbox.util.ScreenUtil import dorkbox.util.ScreenUtil
import java.awt.Point import java.awt.Point
import java.awt.Rectangle import java.awt.Rectangle
import java.awt.Window import java.awt.Window
import java.awt.event.MouseAdapter
import java.util.*
internal class LookAndFeel( internal class LookAndFeel(
private val notify: INotify,
private val parent: Window, private val parent: Window,
private val notifyCanvas: NotifyCanvas, private val notifyCanvas: NotifyCanvas,
private val notification: Notify, val notification: Notify,
parentBounds: Rectangle, parentBounds: Rectangle,
private val isDesktopNotification: Boolean val isDesktopNotification: Boolean
) { ) {
companion object { @Volatile
private val popups: MutableMap<String?, PopupList> = HashMap() var anchorX: Int
// access is only from a single thread ever, so unsafe is preferred.
val animation = create().unsafe().build()
val accessor = NotifyAccessor()
// this is for updating the tween engine during active-rendering
private val frameStartHandler = ActionHandlerLong { deltaInNanos -> animation.update(deltaInNanos) }
const val SPACER = 10
const val MARGIN = 20
private val windowListener: java.awt.event.WindowAdapter = WindowAdapter()
private val mouseListener: MouseAdapter = ClickAdapter()
private val RANDOM = Random()
private val MOVE_DURATION = Notify.MOVE_DURATION
private fun getAnchorX(position: Pos, bounds: Rectangle, isDesktop: Boolean): Int {
// we use the screen that the mouse is currently on.
val startX = if (isDesktop) {
bounds.getX().toInt()
} else {
0
}
val screenWidth = bounds.getWidth().toInt()
return when (position) {
Pos.TOP_LEFT, Pos.BOTTOM_LEFT -> MARGIN + startX
Pos.CENTER -> startX + screenWidth / 2 - NotifyCanvas.WIDTH / 2 - MARGIN / 2
Pos.TOP_RIGHT, Pos.BOTTOM_RIGHT -> startX + screenWidth - NotifyCanvas.WIDTH - MARGIN
}
}
private fun getAnchorY(position: Pos, bounds: Rectangle, isDesktop: Boolean): Int {
val startY = if (isDesktop) {
bounds.getY().toInt()
} else {
0
}
val screenHeight = bounds.getHeight().toInt()
return when (position) {
Pos.TOP_LEFT, Pos.TOP_RIGHT -> startY + MARGIN
Pos.CENTER -> startY + screenHeight / 2 - NotifyCanvas.HEIGHT / 2 - MARGIN / 2 - SPACER
Pos.BOTTOM_LEFT, Pos.BOTTOM_RIGHT -> if (isDesktop) {
startY + screenHeight - NotifyCanvas.HEIGHT - MARGIN
} else {
screenHeight - NotifyCanvas.HEIGHT - MARGIN - SPACER * 2
}
}
}
// only called on the swing EDT thread
private fun addPopupToMap(sourceLook: LookAndFeel) {
synchronized(popups) {
val id = sourceLook.idAndPosition
var looks = popups[id]
if (looks == null) {
looks = PopupList()
popups[id] = looks
}
val index = looks.size()
sourceLook.popupIndex = index
// the popups are ALL the same size!
// popups at TOP grow down, popups at BOTTOM grow up
val anchorX = sourceLook.anchorX
val anchorY = sourceLook.anchorY
val targetY = if (index == 0) {
anchorY
} else {
val growDown = growDown(sourceLook)
if (sourceLook.isDesktopNotification && index == 1) {
// have to adjust for offsets when the window-manager has a toolbar that consumes space and prevents overlap.
// this is only done when the 2nd popup is added to the list
looks.calculateOffset(growDown, anchorX, anchorY)
}
if (growDown) {
anchorY + index * (NotifyCanvas.HEIGHT + SPACER) + looks.offsetY
} else {
anchorY - index * (NotifyCanvas.HEIGHT + SPACER) + looks.offsetY
}
}
looks.add(sourceLook)
sourceLook.setLocation(anchorX, targetY)
if (sourceLook.hideAfterDurationInSeconds > 0 && sourceLook.hideTween == null) {
// begin a timeline to get rid of the popup (default is 5 seconds)
animation.to(sourceLook, NotifyAccessor.PROGRESS, accessor, sourceLook.hideAfterDurationInSeconds)
.target(NotifyCanvas.WIDTH.toFloat())
.ease(TweenEquations.Linear)
.addCallback(COMPLETE) { sourceLook.notify.close() }
.start()
}
}
}
// only called on the swing app or SwingActiveRender thread
private fun removePopupFromMap(sourceLook: LookAndFeel): Boolean {
val growDown = growDown(sourceLook)
var popupsAreEmpty: Boolean
synchronized(popups) {
popupsAreEmpty = popups.isEmpty()
val allLooks = popups[sourceLook.idAndPosition]
// there are two loops because it is necessary to cancel + remove all tweens BEFORE adding new ones.
var adjustPopupPosition = false
val iterator = allLooks!!.iterator()
while (iterator.hasNext()) {
val look = iterator.next()
if (look.tween != null) {
look.tween!!.cancel() // cancel does its thing on the next tick of animation cycle
look.tween = null
}
if (look === sourceLook) {
if (look.hideTween != null) {
look.hideTween!!.cancel()
look.hideTween = null
}
adjustPopupPosition = true
iterator.remove()
}
if (adjustPopupPosition) {
look.popupIndex--
}
}
// have to adjust for offsets when the window-manager has a toolbar that consumes space and prevents overlap.
val offsetY = allLooks.offsetY
for (index in 0 until allLooks.size()) {
val look = allLooks[index]
// the popups are ALL the same size!
// popups at TOP grow down, popups at BOTTOM grow up
val changedY = if (growDown) {
look.anchorY + (look.popupIndex * (NotifyCanvas.HEIGHT + SPACER) + offsetY)
} else {
look.anchorY - (look.popupIndex * (NotifyCanvas.HEIGHT + SPACER) + offsetY)
}
// now animate that popup to its new location
look.tween = animation
.to(look, NotifyAccessor.Y_POS, accessor, MOVE_DURATION)
.target(changedY.toFloat())
.ease(TweenEquations.Linear)
.addCallback(COMPLETE) {
// make sure to remove the tween once it's done, otherwise .kill can do weird things.
look.tween = null
}
.start()
}
}
return popupsAreEmpty
}
private fun growDown(look: LookAndFeel): Boolean {
return when (look.position) {
Pos.TOP_LEFT, Pos.TOP_RIGHT, Pos.CENTER -> true
else -> false
}
}
}
@Volatile @Volatile
private var anchorX: Int var anchorY: Int
val hideAfterDurationInSeconds: Float
@Volatile val position: Position
private var anchorY: Int
private val hideAfterDurationInSeconds: Float
private val position: Pos
// this is used in combination with position, so that we can track which screen and what position a popup is in // this is used in combination with position, so that we can track which screen and what position a popup is in
private var idAndPosition: String? = null var idAndPosition: String
private var popupIndex = 0 var popupIndex = 0
@Volatile @Volatile
private var tween: Tween<*>? = null var tween: Tween<*>? = null
@Volatile @Volatile
private var hideTween: Tween<*>? = null var hideTween: Tween<*>? = null
private val onGeneralAreaClickAction = notification.onGeneralAreaClickAction // explicitly make a copy
init { init {
if (isDesktopNotification) { if (isDesktopNotification) {
@ -248,19 +82,6 @@ internal class LookAndFeel(
anchorY = getAnchorY(position, parentBounds, isDesktopNotification) anchorY = getAnchorY(position, parentBounds, isDesktopNotification)
} }
fun onClick(x: Int, y: Int) {
// Check - we were over the 'X' (and thus no notify), or was it in the general area?
// reasonable position for detecting mouse over
if (!notifyCanvas.isCloseButton(x, y)) {
// only call the general click handler IF we click in the general area!
onGeneralAreaClickAction.invoke(notification)
}
// we always close the notification popup
notify.close()
}
// only called from an application // only called from an application
fun reLayout(bounds: Rectangle) { fun reLayout(bounds: Rectangle) {
// when the parent window moves, we stop all animation and snap the popup into place. This simplifies logic greatly // when the parent window moves, we stop all animation and snap the popup into place. This simplifies logic greatly

View File

@ -36,16 +36,15 @@ import javax.swing.JFrame
* *
* These notifications are for a single screen only, and cannot be anchored to an application. * These notifications are for a single screen only, and cannot be anchored to an application.
* *
* <pre> * ```
* `Notify.create() * `Notify()
* .title("Title Text") * .title("Title Text")
* .text("Hello World!") * .text("Hello World!")
* .useDarkStyle() * .darkStyle()
* .showWarning(); * .showWarning()
` * * ```
</pre> *
*/ */
@Suppress("unused") @Suppress("unused", "MemberVisibilityCanBePrivate")
class Notify private constructor() { class Notify private constructor() {
companion object { companion object {
const val DIALOG_CONFIRM = "dialog-confirm.png" const val DIALOG_CONFIRM = "dialog-confirm.png"
@ -130,7 +129,7 @@ class Notify private constructor() {
imageCache[imageName] = SoftReference(ImageIcon(bufferedImage)) imageCache[imageName] = SoftReference(ImageIcon(bufferedImage))
} }
private fun getImage(imageName: String): ImageIcon? { private fun getImage(imageName: String): ImageIcon {
var resourceAsStream: InputStream? = null var resourceAsStream: InputStream? = null
var image = imageCache[imageName]?.get() var image = imageCache[imageName]?.get()
@ -148,28 +147,65 @@ class Notify private constructor() {
resourceAsStream?.close() resourceAsStream?.close()
} }
return image return image!!
} }
} }
@Volatile
internal var notifyPopup: NotifyType? = null
@Volatile
internal var notifyLook: LookAndFeel? = null
internal var title = "Notification" @Volatile
internal var text = "Lorem ipsum" var title = "Notification"
private var theme: Theme? = null
internal var position = Pos.BOTTOM_RIGHT
internal var hideAfterDurationInMillis = 0
internal var hideCloseButton = false
private var isDark = false
internal var screenNumber = Short.MIN_VALUE.toInt()
private var icon: ImageIcon? = null @Volatile
internal var onGeneralAreaClickAction: Notify.()->Unit = {} var text = "Lorem ipsum"
private var notifyPopup: INotify? = null @Volatile
private var name: String? = null var theme = Theme.defaultLight
private var shakeDurationInMillis = 0
private var shakeAmplitude = 0 @Volatile
private var appWindow: JFrame? = null var position = Position.BOTTOM_RIGHT
@Volatile
var hideAfterDurationInMillis = 0
/**
* Is the close button in the top-right corner of the notification visible
*/
@Volatile
var hideCloseButton = false
@Volatile
var screen = Short.MIN_VALUE.toInt()
@Volatile
var image: ImageIcon? = null
/**
* Called when the notification is closed, either via close button or via close()
*/
@Volatile
var onCloseAction: Notify.()->Unit = {}
/**
* Called when the "general area" (but specifically not the "close button") is clicked.
*/
@Volatile
var onClickAction: Notify.()->Unit = {}
@Volatile
var name = DIALOG_ERROR
@Volatile
var shakeDurationInMillis = 0
@Volatile
var shakeAmplitude = 0
@Volatile
var attachedFrame: JFrame? = null
/** /**
* Specifies the main text * Specifies the main text
@ -208,14 +244,16 @@ class Notify private constructor() {
// now we want to center the image // now we want to center the image
bufferedImage = ImageUtil.getSquareBufferedImage(bufferedImage) bufferedImage = ImageUtil.getSquareBufferedImage(bufferedImage)
icon = ImageIcon(bufferedImage)
this.image = ImageIcon(bufferedImage)
return this return this
} }
/** /**
* Specifies the position of the notification on screen, by default it is [bottom-right][Pos.BOTTOM_RIGHT]. * Specifies the position of the notification on screen, by default it is [bottom-right][Position.BOTTOM_RIGHT].
*/ */
fun position(position: Pos): Notify { fun position(position: Position): Notify {
this.position = position this.position = position
return this return this
} }
@ -235,26 +273,25 @@ class Notify private constructor() {
} }
/** /**
* Specifies what to do when the user clicks on the notification (in addition o the notification hiding, which happens whenever the * Called when the notification is closed, either via close button or via close()
* notification is clicked on). This does not apply when clicking on the "close" button
*/ */
fun onAction(onAction: Notify.()->Unit): Notify { fun onCloseAction(onAction: Notify.()->Unit): Notify {
onGeneralAreaClickAction = onAction onCloseAction = onAction
return this return this
} }
/** /**
* Specifies that the notification should use the built-in dark styling, rather than the default, light-gray notification style. * Called when the "general area" (but specifically not the "close button") is clicked.
*/ */
fun darkStyle(): Notify { fun onClickAction(onAction: Notify.()->Unit): Notify {
isDark = true onClickAction = onAction
return this return this
} }
/** /**
* Specifies what the theme should be, if other than the default. This will always take precedence over the defaults. * Specifies what the theme should be, if other than the default. This will always take precedence over the defaults.
*/ */
fun text(theme: Theme?): Notify { fun theme(theme: Theme): Notify {
this.theme = theme this.theme = theme
return this return this
} }
@ -272,7 +309,7 @@ class Notify private constructor() {
*/ */
fun showWarning() { fun showWarning() {
name = DIALOG_WARNING name = DIALOG_WARNING
icon = getImage(DIALOG_WARNING) image = getImage(DIALOG_WARNING)
show() show()
} }
@ -281,7 +318,7 @@ class Notify private constructor() {
*/ */
fun showInformation() { fun showInformation() {
name = DIALOG_INFORMATION name = DIALOG_INFORMATION
icon = getImage(DIALOG_INFORMATION) image = getImage(DIALOG_INFORMATION)
show() show()
} }
@ -290,7 +327,7 @@ class Notify private constructor() {
*/ */
fun showError() { fun showError() {
name = DIALOG_ERROR name = DIALOG_ERROR
icon = getImage(DIALOG_ERROR) image = getImage(DIALOG_ERROR)
show() show()
} }
@ -299,7 +336,7 @@ class Notify private constructor() {
*/ */
fun showConfirm() { fun showConfirm() {
name = DIALOG_CONFIRM name = DIALOG_CONFIRM
icon = getImage(DIALOG_CONFIRM) image = getImage(DIALOG_CONFIRM)
show() show()
} }
@ -308,39 +345,40 @@ class Notify private constructor() {
* ignored. * ignored.
*/ */
fun show() { fun show() {
val notify = this@Notify
// must be done in the swing EDT // must be done in the swing EDT
SwingUtil.invokeAndWaitQuietly { SwingUtil.invokeAndWaitQuietly {
val notify = this@Notify val window = notify.attachedFrame
val image = notify.icon val shakeDuration = notify.shakeDurationInMillis
val theme = if (notify.theme != null) { val shakeAmp = notify.shakeAmplitude
// use custom provided theme
notify.theme!!
} else {
Theme(TITLE_TEXT_FONT, MAIN_TEXT_FONT, notify.isDark)
}
val window = appWindow
val notifyPopup = if (window == null) { val notifyCanvas = NotifyCanvas(notify, notify.image, theme)
AsDesktop(notify, image, theme)
val notifyPopup: NotifyType
val look: LookAndFeel
if (window == null) {
notifyPopup = AsDesktop(notify, notifyCanvas)
look = LookAndFeel(notifyPopup, notifyCanvas, notify, LAFUtil.getGraphics(notify.screen), true)
} else { } else {
AsApplication(notify, image, window, theme) notifyPopup = AsApplication(notify, notifyCanvas)
look = LookAndFeel(window, notifyCanvas, notify, window.bounds, false)
} }
notifyPopup.setVisible(true) notifyPopup.setVisible(true, look)
if (shakeDurationInMillis > 0) { if (shakeDuration > 0) {
notifyPopup.shake(notify.shakeDurationInMillis, notify.shakeAmplitude) look.shake(shakeDuration, shakeAmp)
} }
notify.notifyPopup = notifyPopup notify.notifyPopup = notifyPopup
notify.notifyLook = look
} }
// don't need to hang onto these.
icon = null
} }
/** /**
* "shakes" the notification, to bring user attention to it. * "Shakes" the notification, to bring user attention to it.
* *
* @param durationInMillis now long it will shake * @param durationInMillis now long it will shake
* @param amplitude a measure of how much it needs to shake. 4 is a small amount of shaking, 10 is a lot. * @param amplitude a measure of how much it needs to shake. 4 is a small amount of shaking, 10 is a lot.
@ -348,9 +386,11 @@ class Notify private constructor() {
fun shake(durationInMillis: Int, amplitude: Int): Notify { fun shake(durationInMillis: Int, amplitude: Int): Notify {
shakeDurationInMillis = durationInMillis shakeDurationInMillis = durationInMillis
shakeAmplitude = amplitude shakeAmplitude = amplitude
if (notifyPopup != null) {
val popupLook = notifyLook
if (popupLook != null) {
// must be done in the swing EDT // must be done in the swing EDT
SwingUtil.invokeLater { notifyPopup!!.shake(durationInMillis, amplitude) } SwingUtil.invokeLater { popupLook.shake(durationInMillis, amplitude) }
} }
return this return this
} }
@ -359,19 +399,22 @@ class Notify private constructor() {
* Closes the notification. Particularly useful if it's an "infinite" duration notification. * Closes the notification. Particularly useful if it's an "infinite" duration notification.
*/ */
fun close() { fun close() {
if (notifyPopup == null) { val popup = notifyPopup
throw NullPointerException("NotifyPopup") val look = notifyLook
if (popup !== null && look != null) {
// must be done in the swing EDT
SwingUtil.invokeLater {
look.close()
popup.close()
}
} }
// must be done in the swing EDT
SwingUtil.invokeLater { notifyPopup!!.close() }
} }
/** /**
* Specifies which screen to display on. If <0, it will show on screen 0. If > max-screens, it will show on the last screen. * Specifies which screen to display on. If <0, it will show on screen 0. If > max-screens, it will show on the last screen.
*/ */
fun setScreen(screenNumber: Int): Notify { fun setScreen(screenNumber: Int): Notify {
this.screenNumber = screenNumber this.screen = screenNumber
return this return this
} }
@ -379,14 +422,21 @@ class Notify private constructor() {
* Attaches this notification to a specific JFrame, instead of having a global notification * Attaches this notification to a specific JFrame, instead of having a global notification
*/ */
fun attach(frame: JFrame?): Notify { fun attach(frame: JFrame?): Notify {
appWindow = frame attachedFrame = frame
return this return this
} }
// called when this notification is closed.
fun onClose() {
// called when this notification is closed. called in the swing EDT!
internal fun onClose() {
this.notifyPopup!!.close()
this.onCloseAction.invoke(this)
notifyPopup = null notifyPopup = null
notifyLook = null
} }
internal fun onClickAction() {
this.onClickAction.invoke(this)
}
} }

View File

@ -15,6 +15,7 @@
*/ */
package dorkbox.notify package dorkbox.notify
import dorkbox.util.SwingUtil
import java.awt.BasicStroke import java.awt.BasicStroke
import java.awt.Canvas import java.awt.Canvas
import java.awt.Color import java.awt.Color
@ -28,13 +29,9 @@ import javax.swing.ImageIcon
import javax.swing.JLabel import javax.swing.JLabel
internal class NotifyCanvas( internal class NotifyCanvas(
val parent: INotify, private val notification: Notify, private val imageIcon: ImageIcon?, private val theme: Theme
private val notification: Notify,
private val imageIcon: ImageIcon?,
private val theme: Theme
) : Canvas() { ) : Canvas() {
private val showCloseButton: Boolean
private var cachedImage: BufferedImage private var cachedImage: BufferedImage
// for the progress bar. we directly draw this onscreen // for the progress bar. we directly draw this onscreen
@ -50,7 +47,7 @@ internal class NotifyCanvas(
isFocusable = false isFocusable = false
background = theme.panel_BG background = theme.panel_BG
showCloseButton = !notification.hideCloseButton
// now we setup the rendering of the image // now we setup the rendering of the image
cachedImage = renderBackgroundInfo(notification.title, notification.text, theme, imageIcon) cachedImage = renderBackgroundInfo(notification.title, notification.text, theme, imageIcon)
@ -89,8 +86,9 @@ internal class NotifyCanvas(
// the progress bar and close button are the only things that can change, so we always draw them every time // the progress bar and close button are the only things that can change, so we always draw them every time
val g2 = g.create() as Graphics2D val g2 = g.create() as Graphics2D
try { try {
if (showCloseButton) { if (!notification.hideCloseButton) {
// manually draw the close button // manually draw the close button
val g3 = g.create() as Graphics2D val g3 = g.create() as Graphics2D
g3.color = theme.panel_BG g3.color = theme.panel_BG
@ -118,11 +116,22 @@ internal class NotifyCanvas(
} }
} }
/** fun onClick(x: Int, y: Int) {
* @return TRUE if we were over the 'X' or FALSE if the click was in the general area (and not over the 'X'). // this must happen in the Swing EDT. This is usually called by the active renderer
*/ SwingUtil.invokeLater {
fun isCloseButton(x: Int, y: Int): Boolean { // Check - we were over the 'X' (and thus no notify), or was it in the general area?
return showCloseButton && x >= 280 && y <= 20
val isClickOnCloseButton = !notification.hideCloseButton && x >= 280 && y <= 20
// reasonable position for detecting mouse over
if (!isClickOnCloseButton) {
// only call the general click handler IF we click in the general area!
notification.onClickAction()
} else {
// we always close the notification popup
notification.onClose()
}
}
} }
companion object { companion object {

View File

@ -15,9 +15,7 @@
*/ */
package dorkbox.notify package dorkbox.notify
interface INotify { internal interface NotifyType {
fun close() fun close()
fun shake(durationInMillis: Int, amplitude: Int) fun setVisible(visible: Boolean, look: LookAndFeel)
fun setVisible(visible: Boolean)
fun onClick(x: Int, y: Int)
} }

View File

@ -40,11 +40,11 @@ internal class PopupList {
val screenInsets = Toolkit.getDefaultToolkit().getScreenInsets(gc) val screenInsets = Toolkit.getDefaultToolkit().getScreenInsets(gc)
if (showFromTop) { if (showFromTop) {
if (screenInsets.top > 0) { if (screenInsets.top > 0) {
offsetY = screenInsets.top - LookAndFeel.MARGIN offsetY = screenInsets.top - LAFUtil.MARGIN
} }
} else { } else {
if (screenInsets.bottom > 0) { if (screenInsets.bottom > 0) {
offsetY = screenInsets.bottom + LookAndFeel.MARGIN offsetY = screenInsets.bottom + LAFUtil.MARGIN
} }
} }
} }

View File

@ -15,7 +15,7 @@
*/ */
package dorkbox.notify package dorkbox.notify
enum class Pos { enum class Position {
/** /**
* top vertically, left horizontally * top vertically, left horizontally
*/ */

View File

@ -23,6 +23,16 @@ import java.awt.Font
* Settings available to change the theme * Settings available to change the theme
*/ */
class Theme { class Theme {
companion object {
val defaultLight: Theme by lazy {
Theme(Notify.TITLE_TEXT_FONT, Notify.MAIN_TEXT_FONT, false)
}
val defaultDark: Theme by lazy {
Theme(Notify.TITLE_TEXT_FONT, Notify.MAIN_TEXT_FONT, true)
}
}
val panel_BG: Color val panel_BG: Color
val titleText_FG: Color val titleText_FG: Color
val mainText_FG: Color val mainText_FG: Color
@ -67,4 +77,25 @@ class Theme {
this.closeX_FG = closeX_FG this.closeX_FG = closeX_FG
this.progress_FG = progress_FG this.progress_FG = progress_FG
} }
/**
* True if we are the default "light" theme
*/
fun isLight(): Boolean {
return this === defaultLight
}
/**
* True if we are the default "dark" theme
*/
fun isDark(): Boolean {
return this === defaultDark
}
/**
* True if we are a custom theme
*/
fun isCustom(): Boolean {
return !isLight() && !isDark()
}
} }

View File

@ -22,7 +22,7 @@ internal class WindowAdapter : WindowAdapter() {
override fun windowClosing(e: WindowEvent) { override fun windowClosing(e: WindowEvent) {
if (e.newState != WindowEvent.WINDOW_CLOSED) { if (e.newState != WindowEvent.WINDOW_CLOSED) {
val source = e.source as AsDesktop val source = e.source as AsDesktop
source.close() source.notification.close()
} }
} }

View File

@ -1,3 +1,5 @@
@file:Suppress("UNUSED_VALUE")
package dorkbox.notify package dorkbox.notify
import dorkbox.util.ImageUtil import dorkbox.util.ImageUtil
@ -49,11 +51,36 @@ object NotifyTest {
// bottomRightInFrame(3, frame) // bottomRightInFrame(3, frame)
// topLeftInFrame(3, frame) // topLeftInFrame(3, frame)
topRightMonitor(3) react()
// topRightMonitor(3)
// bottomLeftScaled(3, frame, image) // bottomLeftScaled(3, frame, image)
// bottomLeftStacking(3, frame, image) // bottomLeftStacking(3, frame, image)
} }
fun react() {
val notify = Notify.create()
notify.title("Notify title modify")
.text("This is a notification popup message This is a notification popup message This is a " +
"notification popup message")
.hideAfter(13000)
.position(Position.TOP_RIGHT)
// .setScreen(0)
.theme(Theme.defaultDark)
// .shake(1300, 4)
.shake(4300, 10)
// .hideCloseButton() // if the hideButton is visible, then it's possible to change things when clicked
.onClickAction {
notify.text = "HOWDY"
System.err.println("Notification clicked on!")
}
notify.show()
try {
Thread.sleep(3000)
} catch (e: InterruptedException) {
e.printStackTrace()
}
}
fun topRightMonitor(count: Int) { fun topRightMonitor(count: Int) {
var notify: Notify var notify: Notify
@ -63,13 +90,13 @@ object NotifyTest {
.text("This is a notification " + i + " popup message This is a notification popup message This is a " + .text("This is a notification " + i + " popup message This is a notification popup message This is a " +
"notification popup message") "notification popup message")
.hideAfter(13000) .hideAfter(13000)
.position(Pos.TOP_RIGHT) .position(Position.TOP_RIGHT)
// .setScreen(0) // .setScreen(0)
.darkStyle() .theme(Theme.defaultDark)
// .shake(1300, 4) // .shake(1300, 4)
.shake(4300, 10) .shake(4300, 10)
.hideCloseButton() .hideCloseButton()
.onAction { System.err.println("Notification $i clicked on!") } .onClickAction { System.err.println("Notification $i clicked on!") }
notify.show() notify.show()
try { try {
Thread.sleep(3000) Thread.sleep(3000)
@ -84,12 +111,12 @@ object NotifyTest {
.title("Notify scaled") .title("Notify scaled")
.text("This is a notification popup message scaled This is a notification popup message This is a " + .text("This is a notification popup message scaled This is a notification popup message This is a " +
"notification popup message scaled ") // .hideAfter(13000) "notification popup message scaled ") // .hideAfter(13000)
.position(Pos.BOTTOM_LEFT) // .setScreen(0) .position(Position.BOTTOM_LEFT) // .setScreen(0)
// .darkStyle() // .darkStyle()
// .shake(1300, 4) // .shake(1300, 4)
// .shake(1300, 10) // .shake(1300, 10)
// .hideCloseButton() // .hideCloseButton()
.onAction { System.err.println("Notification scaled clicked on!") } .onClickAction { System.err.println("Notification scaled clicked on!") }
notify.image(image) notify.image(image)
notify.show() notify.show()
} }
@ -103,13 +130,13 @@ object NotifyTest {
.text("This is a notification " + i + " popup message This is a notification popup message This is a " + .text("This is a notification " + i + " popup message This is a notification popup message This is a " +
"notification popup message") "notification popup message")
// .hideAfter(13000) // .hideAfter(13000)
.position(Pos.BOTTOM_LEFT) .position(Position.BOTTOM_LEFT)
// .setScreen(0) // .setScreen(0)
// .darkStyle() // .darkStyle()
// .shake(1300, 4) // .shake(1300, 4)
// .shake(1300, 10) // .shake(1300, 10)
// .hideCloseButton() // .hideCloseButton()
.onAction { System.err.println("Notification $i clicked on!") } .onClickAction { System.err.println("Notification $i clicked on!") }
if (i == 0) { if (i == 0) {
notify.image(image) notify.image(image)
notify.show() notify.show()
@ -132,13 +159,13 @@ object NotifyTest {
.text("This is a notification " + i + " popup message This is a notification popup message This is a " + .text("This is a notification " + i + " popup message This is a notification popup message This is a " +
"notification popup message") "notification popup message")
.hideAfter(13000) .hideAfter(13000)
.position(Pos.TOP_LEFT) // .position(Pos.CENTER) .position(Position.TOP_LEFT) // .position(Pos.CENTER)
// .setScreen(0) // .setScreen(0)
// .darkStyle() // .darkStyle()
// .shake(1300, 4) // .shake(1300, 4)
// .shake(1300, 10) // .shake(1300, 10)
.attach(frame) // .hideCloseButton() .attach(frame) // .hideCloseButton()
.onAction { System.err.println("Notification $i clicked on!") } .onClickAction { System.err.println("Notification $i clicked on!") }
notify.showWarning() notify.showWarning()
try { try {
@ -157,13 +184,14 @@ object NotifyTest {
.text("This is a notification " + i + " popup message This is a notification popup message This is a " + .text("This is a notification " + i + " popup message This is a notification popup message This is a " +
"notification popup message") "notification popup message")
.hideAfter(13000) .hideAfter(13000)
.position(Pos.BOTTOM_RIGHT) // .position(Pos.CENTER) .position(Position.BOTTOM_RIGHT) // .position(Pos.CENTER)
// .setScreen(0) // .setScreen(0)
.darkStyle() // .shake(1300, 4) .theme(Theme.defaultDark)
// .shake(1300, 4)
.shake(1300, 10) .shake(1300, 10)
.attach(frame) .attach(frame)
.hideCloseButton() .hideCloseButton()
.onAction { System.err.println("Notification $i clicked on!") } .onClickAction { System.err.println("Notification $i clicked on!") }
notify.showWarning() notify.showWarning()
try { try {