All Downloads are FREE. Search and download functionalities are using the official Maven repository.

dorkbox.notify.DesktopNotify.kt Maven / Gradle / Ivy

Go to download

Linux, MacOS, or Windows (notification/growl/toast/) popups for the desktop for Java 8+

The newest version!
/*
 * Copyright 2023 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.notify.LAFUtil.tweenEngine
import dorkbox.notify.Notify.Companion.MOVE_DURATION
import dorkbox.tweenEngine.Tween
import dorkbox.tweenEngine.TweenEquations
import dorkbox.tweenEngine.TweenEvents
import dorkbox.util.ScreenUtil
import java.awt.*
import java.awt.image.BufferedImage
import javax.swing.JWindow



// 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
internal class DesktopNotify(override val notification: Notify) : JWindow(), NotifyType {
    companion object {
        private val tweenAccessor = DesktopAccessor()

        private val windowCloseListener = DesktopWindowCloseAdapter()
        private val mouseListener = DesktopMouseAdapter()


        @Suppress("DuplicatedCode")
        private fun getAnchorX(position: Position, bounds: Rectangle): Int {
            // we use the screen that the mouse is currently on.
            val startX = bounds.getX().toInt()
            val screenWidth = bounds.getWidth().toInt()

            return when (position) {
                // LEFT ALIGN
                Position.TOP_LEFT, Position.BOTTOM_LEFT -> Notify.MARGIN + startX
                // CENTER ALIGN
                Position.TOP, Position.CENTER, Position.BOTTOM -> startX + screenWidth / 2 - Notify.WIDTH / 2 - Notify.MARGIN / 2
                // RIGHT ALIGN
                Position.TOP_RIGHT, Position.BOTTOM_RIGHT -> startX + screenWidth - Notify.WIDTH - Notify.MARGIN
            }
        }

        private fun getAnchorY(position: Position, bounds: Rectangle): Int {
            val startY = bounds.getY().toInt()
            val screenHeight = bounds.getHeight().toInt()

            return when (position) {
                // TOP ALIGN
                Position.TOP_LEFT, Position.TOP, Position.TOP_RIGHT -> startY + Notify.MARGIN
                // CENTER ALIGN
                Position.CENTER -> startY + screenHeight / 2 - Notify.HEIGHT / 2 - Notify.MARGIN / 2
                // BOTTOM ALIGN
                Position.BOTTOM_LEFT, Position.BOTTOM, Position.BOTTOM_RIGHT -> startY + screenHeight - Notify.HEIGHT - Notify.MARGIN
            }
        }
    }

    private lateinit var cachedImage: BufferedImage
    private lateinit var cachedClose: BufferedImage
    private lateinit var cachedCloseEnabled: BufferedImage

    // for the progress bar. we directly draw this onscreen
    // non-volatile because it's always accessed in the active render thread
    override var progress = 0

    // The button is "hittable" from the entire corner
    private val closeButton: Rectangle

    override var shakeTween: Tween? = null
    override var moveTween: Tween? = null
    override var hideTween: Tween? = null

    // this is used in combination with position, so that we can track which screen and what position a popup is in
    override var idAndPosition = ""
    override var popupIndex = 0

    override var anchorX = 0
    override var anchorY = 0

    // the ONLY reason "shake" works, is because we configure the target X/Y location for moving based on where we WANT the popup to go
    // and completely ignoring its current position
    var shakeX = 0
    var shakeY = 0


    // this is on the swing EDT
    init {
        addWindowListener(windowCloseListener)
        addMouseListener(mouseListener)
        addMouseMotionListener(mouseListener)

        isAlwaysOnTop = true

        preferredSize = Dimension(Notify.WIDTH, Notify.HEIGHT)
        maximumSize = Dimension(Notify.WIDTH, Notify.HEIGHT)
        minimumSize = Dimension(Notify.WIDTH, Notify.HEIGHT)
        size = Dimension(Notify.WIDTH, Notify.HEIGHT)

        setLocation(Short.MIN_VALUE.toInt(), Short.MIN_VALUE.toInt())

        closeButton = Rectangle(NotifyType.closeX, NotifyType.closeY,
                                18, 18)

        background = notification.theme.panel_BG

        // now we setup the rendering of the image
        refresh()
    }

    override fun refresh() {
        cachedImage = renderBackgroundInfo(notification.title, notification.text, notification.theme, notification.image)
        cachedClose = renderCloseButton(notification.theme, false)
        cachedCloseEnabled = renderCloseButton(notification.theme, true)

        val bounds = LAFUtil.getGraphics(notification.screen)
        val point = Point(bounds.getX().toInt(), bounds.getY().toInt())
        idAndPosition = ScreenUtil.getMonitorNumberAtLocation(point).toString() + ":" + notification.position

        val growDown = LAFUtil.growDown(this)

        anchorX = getAnchorX(notification.position, bounds)
        anchorY = getAnchorY(notification.position, bounds) + calculateOffset(growDown, point)
    }

    /**
     * have to adjust for offsets when the window-manager has a toolbar that consumes space and prevents overlap.
     */
    private fun calculateOffset(showFromTop: Boolean, point: Point): Int {
        val gc = ScreenUtil.getMonitorAtLocation(point)?.defaultConfiguration ?: return 0
        val screenInsets = Toolkit.getDefaultToolkit().getScreenInsets(gc)

        return if (showFromTop) {
            screenInsets.top
        } else {
            -(screenInsets.bottom)
        }
    }



    override fun paint(gIgnore: Graphics) {
        // Get the graphics context from the buffer strategy
        val bufferStrategy = bufferStrategy

        val g = bufferStrategy.drawGraphics

        g.clearRect(0, 0, width, height) // Clear the screen

        // we cache the text + image (to an image), the two states of the close "button" and then always render the progressbar
        try {
            draw(g)
        } catch (ignored: Exception) {
            // have also seen (happened after screen/PC was "woken up", in Xubuntu 16.04):
            // java.lang.ClassCastException:sun.awt.image.BufImgSurfaceData cannot be cast to sun.java2d.xr.XRSurfaceData at sun.java2d.xr.XRPMBlitLoops.cacheToTmpSurface(XRPMBlitLoops.java:148)
            // at sun.java2d.xr.XrSwToPMBlit.Blit(XRPMBlitLoops.java:356)
            // at sun.java2d.SurfaceDataProxy.updateSurfaceData(SurfaceDataProxy.java:498)
            // at sun.java2d.SurfaceDataProxy.replaceData(SurfaceDataProxy.java:455)
            // at sun.java2d.SurfaceData.getSourceSurfaceData(SurfaceData.java:233)
            // at sun.java2d.pipe.DrawImage.renderImageCopy(DrawImage.java:566)
            // at sun.java2d.pipe.DrawImage.copyImage(DrawImage.java:67)
            // at sun.java2d.pipe.DrawImage.copyImage(DrawImage.java:1014)
            // at sun.java2d.pipe.ValidatePipe.copyImage(ValidatePipe.java:186)
            // at sun.java2d.SunGraphics2D.drawImage(SunGraphics2D.java:3318)
            // at sun.java2d.SunGraphics2D.drawImage(SunGraphics2D.java:3296)
            // at dorkbox.notify.NotifyCanvas.paint(NotifyCanvas.java:92)

            // redo the cache
            refresh()

            // try to draw again
            try {
                draw(g)
            } catch (ignored2: Exception) {
            }
        }

        // Dispose the graphics context and show the buffer
        g.dispose()
        bufferStrategy.show()
    }

    private fun draw(g: Graphics) {
        g.drawImage(cachedImage, 0, 0, null)

        if (!notification.hideCloseButton) {
            if (closeButton.contains(MouseInfo.getPointerInfo().location)) {
                g.drawImage(cachedCloseEnabled, 0, 0, null)
            } else {
                g.drawImage(cachedClose, 0, 0, null)
            }
        }

        // the progress bar can change (only getting bigger!), so we always draw it when it grows
        if (progress > 0) {
            // draw the progress bar along the bottom
            g.color = notification.theme.progress_FG
            g.fillRect(0, Notify.HEIGHT - 2, progress, 2)
        }
    }

    fun onClick() {
        // Check - we were over the 'X' (and thus no notify), or was it in the general area
        val isClickOnCloseButton = !notification.hideCloseButton && closeButton.contains(MouseInfo.getPointerInfo().location)

        if (isClickOnCloseButton) {
            // we always close the notification popup
            notification.onClose()
        } else {
            // only call the general click handler IF we click in the general area!
            notification.onClickAction()
        }
    }

    override fun setupHide() {
        if (hideTween != null) {
            hideTween!!.value(Notify.WIDTH.toFloat())
        } else if (notification.hideAfterDurationInMillis > 0) {
            // begin a timeline to get rid of the popup (default is 5 seconds)
            val tween = tweenEngine
                .to(this, DesktopAccessor.PROGRESS, tweenAccessor, notification.hideAfterDurationInMillis / 1000.0f)
                .value(Notify.WIDTH.toFloat())
                .ease(TweenEquations.Linear)
                .addCallback(TweenEvents.COMPLETE) {
                    notification.onClose()
                }

            hideTween = tween
            tween.start()
        }
    }

    override fun setupMove(y: Float) {
        if (moveTween != null) {
            moveTween!!.value(y)
        } else {
            val tween = tweenEngine
                .to(this, DesktopAccessor.Y_POS, tweenAccessor, MOVE_DURATION)
                .value(y)
                .ease(TweenEquations.Linear)
                .addCallback(TweenEvents.COMPLETE) {
                    // make sure to remove the tween once it's done, otherwise .cancel can do weird things.
                    moveTween = null
                }

            moveTween = tween
            tween.start()
        }
    }

    override fun doShake(count: Int, targetX: Float, targetY: Float) {
        if (shakeTween != null) {
            shakeTween!!.value(targetX, targetY)
                        .repeatAutoReverse(count, 0f)
        } else {
            val tween = tweenEngine
                .to(this, DesktopAccessor.SHAKE, tweenAccessor, 0.05f)
                .value(targetX, targetY)
                .repeatAutoReverse(count, 0f)
                .ease(TweenEquations.Linear)

            shakeTween = tween
            tween.start()
        }
    }

    fun setLocationShake(x: Int, y: Int) {
        val x1 = getX() - shakeX
        val y1 = getY() - shakeY
        shakeX = x
        shakeY = y
        setLocationInternal(x1 + x, y1 + y)
    }

    override fun setLocationInternal(x: Int, y: Int) {
        setLocation(x, y)

        // update the location where we detect the close button. The button is "hittable" from the entire corner
        closeButton.setLocation(x + NotifyType.X_1, y)
    }

    // this is called during parent initialization (before we are initialized), so we cannot access objects here properly!
//    override fun setLocation(x: Int, y: Int) {
//        super.setLocation(x, y)
//    }

    override fun setVisible(visible: Boolean) {
        // this is because the order of operations are different based upon visibility.
        updatePositionsPre(this, this, visible)

        super.setVisible(visible)

        // this is because the order of operations are different based upon visibility.
        updatePositionsPost(this, this, visible)

        if (visible) {
            toFront()
        }
    }

    // called on the Swing EDT
    override fun close() {
        cancelMove()
        cancelHide()
        cancelShake()

        removeWindowListener(windowCloseListener)
        removeMouseMotionListener(mouseListener)
        removeMouseListener(mouseListener)

        updatePositionsPre(component = this, notify = this, visible = false)

        super.setVisible(false)

        removeAll()
        dispose()
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy