2025-02-17 11:29:57 +03:00

527 lines
16 KiB
Scala
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package ocelot.desktop.ui
import buildinfo.BuildInfo
import ocelot.desktop.audio.{Audio, SoundBuffers, SoundSource}
import ocelot.desktop.geometry.{Rect2D, Size2D, Vector2D}
import ocelot.desktop.graphics.Graphics
import ocelot.desktop.ui.event.{Event, MouseEvent}
import ocelot.desktop.ui.event.handlers.HoverHandler
import ocelot.desktop.ui.event.sources.{KeyEvents, MouseEvents, ScrollEvents}
import ocelot.desktop.ui.widget.window.Window
import ocelot.desktop.ui.widget.{RootWidget, Widget}
import ocelot.desktop.util._
import ocelot.desktop.{OcelotDesktop, Settings}
import org.apache.commons.lang3.SystemUtils
import org.lwjgl.BufferUtils
import org.lwjgl.input.{Keyboard, Mouse}
import org.lwjgl.opengl._
import java.awt.Toolkit
import java.awt.datatransfer.{DataFlavor, StringSelection, UnsupportedFlavorException}
import java.io.{File, FileOutputStream}
import java.nio.ByteBuffer
import java.nio.channels.Channels
import java.nio.file.Paths
import javax.imageio.ImageIO
import javax.swing.JFileChooser
import scala.annotation.tailrec
import scala.collection.mutable
import scala.concurrent.duration.DurationInt
import scala.util.Try
object UiHandler extends Logging {
var root: RootWidget = _
var graphics: Graphics = _
private var _terminating = false
def terminating: Boolean = _terminating
private val hierarchy = mutable.ArrayBuffer(root: Widget)
private var shouldUpdateHierarchy = true
private val fpsCalculator = new FPSCalculator
private val ticker = new Ticker
var scalingFactor = 1.0f
ticker.tickInterval = 1.second / 144
val UiThreadTasks = new TaskQueue()
def getHierarchy: Array[Widget] = hierarchy.toArray
def updateHierarchy(): Unit = {
shouldUpdateHierarchy = true
}
private def _updateHierarchy(): Unit = {
val stack = mutable.Stack(root: Widget)
hierarchy.clear()
while (stack.nonEmpty) {
val widget = stack.pop()
hierarchy += widget
for (child <- widget.hierarchy.reverseIterator) {
stack.push(child)
}
}
shouldUpdateHierarchy = false
}
def fps: Float = fpsCalculator.fps
def dt: Float = fpsCalculator.dt
def mousePosition: Vector2D = {
Vector2D(Mouse.getX, Display.getHeight - Mouse.getY) / scalingFactor
}
private val _clipboard = Toolkit.getDefaultToolkit.getSystemClipboard
def clipboard: String = {
try {
_clipboard.getData(DataFlavor.stringFlavor).toString
} catch {
case _: UnsupportedFlavorException =>
logger.debug("Trying to paste non-Unicode content from clipboard! Replaced with empty string.")
""
}
}
def clipboard_=(value: String): Unit = {
val data = new StringSelection(value)
_clipboard.setContents(data, data)
}
def fullScreen: Boolean = Display.isFullscreen
def fullScreen_=(value: Boolean): Unit = {
if (value) {
Display.setDisplayModeAndFullscreen(Display.getDesktopDisplayMode)
} else {
// Updating size from settings
val settingsSize = Settings.get.windowSize
val unscaledSize = sanitizeWindowSize(
if (settingsSize.isSet) Size2D(settingsSize.x, settingsSize.y)
else Size2D(800, 600)
)
if (root != null) {
root.size = unscaledSize / scalingFactor
}
// Setting normal display mode (non-fullscreen by default)
Display.setDisplayMode(new DisplayMode(unscaledSize.width.toInt, unscaledSize.height.toInt))
// Updating position from settings
if (Settings.get.windowPosition.isSet) {
if (Settings.get.windowValidatePosition) {
// the configuration can have incorrect values for coordinates
// and that can cause the window to "disappear" from the screen
// TODO: after upgrading to LWJGL 3 consider the multi-monitor setups
val desktop = Display.getDesktopDisplayMode
if (Settings.get.windowPosition.x < -Settings.get.windowSize.x)
Settings.get.windowPosition.x = 0
if (Settings.get.windowPosition.x > desktop.getWidth)
Settings.get.windowPosition.x = desktop.getWidth - Settings.get.windowSize.x
if (Settings.get.windowPosition.y < -Settings.get.windowSize.y)
Settings.get.windowPosition.y = 0
if (Settings.get.windowPosition.y > desktop.getHeight)
Settings.get.windowPosition.y = desktop.getHeight - Settings.get.windowSize.y
}
Display.setLocation(Settings.get.windowPosition.x, Settings.get.windowPosition.y)
}
// Window should be resizable after exit from fullscreen mode
// But it seems like there's lwjgl bug with internal resizable value caching
// How to reproduce:
// 1) Enter fullscreen mode via setDisplayModeAndFullscreen(...) or setFullScreen(true)
// 2) Set window resizable via setResizable(true)
// 3) Exit from fullscreen mode
// 4) Set window resizable again
// Result:
// Window is not resizable and can't be resized with mouse
// Expected behaviour:
// Window should be resizable
Display.setResizable(false)
Display.setResizable(true)
}
Settings.get.windowFullscreen = value
}
private def formatRect(rect: Rect2D): String = {
val Rect2D(x, y, w, h) = rect
f"$w%.0f×$h%.0f$x%+.0f$y%+.0f"
}
private def windowGeometry: Rect2D = new Rect2D(Display.getX, Display.getY, Display.getWidth, Display.getHeight)
private def sanitizeWindowSize(size: Size2D): Size2D = {
Size2D(
if (size.width < 10) 800 else size.width,
if (size.height < 10) 600 else size.height,
)
}
private def sanitizeWindowGeometry(currentGeometry: Rect2D): Rect2D = {
val Rect2D(x, y, w, h) = currentGeometry
val newSize = sanitizeWindowSize(Size2D(w, h))
Rect2D(
x max 0,
y max 0,
newSize.width,
newSize.height,
)
}
private def fixInsaneInitialWindowGeometry(): Unit = {
// what I mean by insane: ocelot.desktop.ui.UiHandler$ - Created window: 0×0 (at 960, -569)
val currentGeometry = windowGeometry
val geometry = sanitizeWindowGeometry(currentGeometry)
if (geometry != currentGeometry) {
logger.warn(s"Window geometry sanity check failed: ${formatRect(currentGeometry)} is officially insane")
logger.warn(s"Resetting to ${formatRect(geometry)}")
Display.setDisplayMode(new DisplayMode(geometry.w.toInt, geometry.h.toInt))
Display.setLocation(geometry.x.toInt, geometry.y.toInt)
}
}
def init(): Unit = {
scalingFactor = Settings.get.scaleFactor
fullScreen = Settings.get.windowFullscreen
Display.setTitle(windowTitleBase)
loadIcons()
if (!Settings.get.disableVsync) {
logger.info("VSync enabled")
Display.setVSyncEnabled(true)
} else {
logger.info("VSync disabled (via config)")
}
val attrs = new ContextAttribs(
3,
2,
ContextAttribs.CONTEXT_CORE_PROFILE_BIT_ARB,
ContextAttribs.CONTEXT_FORWARD_COMPATIBLE_BIT_ARB,
)
val pf = new PixelFormat()
.withDepthBits(24)
.withStencilBits(8)
.withAlphaBits(8)
.withSRGB(true)
logger.info(s"Creating an OpenGL context: $attrs")
Display.create(pf, attrs)
if (Settings.get.windowValidatePosition)
fixInsaneInitialWindowGeometry()
KeyEvents.init()
MouseEvents.init()
logger.info(s"Created window: ${formatRect(windowGeometry)}")
logger.info(s"OpenGL vendor: ${GL11.glGetString(GL11.GL_VENDOR)}")
logger.info(s"OpenGL renderer: ${GL11.glGetString(GL11.GL_RENDERER)}")
logger.info(s"OpenGL version: ${GL11.glGetString(GL11.GL_VERSION)}")
Spritesheet.load()
graphics = new Graphics(Display.getWidth, Display.getHeight, scalingFactor)
if (Settings.get.audioDisable) {
logger.warn("Sound disabled (via config)")
} else {
Audio.init()
}
}
def setRoot(widget: RootWidget): Unit = {
root = widget
root.relayout()
}
def loadLibraries(): Unit = {
val librariesPath = OcelotPaths.libraries.toString
val arch = System.getProperty("os.arch")
val is64bit = arch.startsWith("amd64")
val isArm64 = arch.equals("aarch64") || arch.startsWith("arm64")
val libs = {
if (SystemUtils.IS_OS_WINDOWS) {
if (is64bit)
Array("jinput-dx8_64.dll", "jinput-raw_64.dll", "jinput-wintab.dll", "lwjgl64.dll", "OpenAL64.dll")
else
Array("jinput-dx8.dll", "jinput-raw.dll", "jinput-wintab.dll", "lwjgl.dll", "OpenAL32.dll")
} else if (SystemUtils.IS_OS_MAC_OSX)
if (isArm64) {
Array("liblwjgl-arm64.dylib")
} else {
Array("liblwjgl.dylib")
}
else if (SystemUtils.IS_OS_LINUX) {
if (is64bit)
Array("libjinput-linux64.so", "liblwjgl64.so", "libopenal64.so")
else
Array("libjinput-linux.so", "liblwjgl.so", "libopenal.so")
} else
throw new Exception("Unsupported OS")
}
logger.debug(s"Unpacking native libraries to: $librariesPath")
for (lib <- libs) {
val destinationFilename = if (SystemUtils.IS_OS_MAC_OSX) "liblwjgl.dylib" else lib
val dest = new File(Paths.get(librariesPath, destinationFilename).toString)
logger.debug(s"Unpacking $lib to $dest")
if (!dest.exists()) {
val source = getClass.getResourceAsStream("/" + lib)
if (!dest.getParentFile.exists())
dest.getParentFile.mkdirs()
val output = new FileOutputStream(dest)
output.getChannel.transferFrom(Channels.newChannel(source), 0, Long.MaxValue)
output.flush()
output.close()
source.close()
}
}
System.setProperty("org.lwjgl.librarypath", librariesPath)
if (Settings.get.debugLwjgl) {
logger.info("Enabling LWJGL debug mode")
System.setProperty("org.lwjgl.util.Debug", true.toString)
}
}
private def loadIcons(): Unit = {
val sizes = Array(256, 128, 64, 32, 16)
val list = new Array[ByteBuffer](sizes.length)
for ((size, i) <- sizes.zipWithIndex) {
val imageURL = getClass.getResource(s"/ocelot/desktop/images/icon$size.png")
val image = ImageIO.read(imageURL)
val pixels = new Array[Int](image.getWidth * image.getHeight)
image.getRGB(0, 0, image.getWidth, image.getHeight, pixels, 0, image.getWidth)
val buf = BufferUtils.createByteBuffer(image.getWidth * image.getHeight * 4)
for (y <- 0 until image.getHeight) {
for (x <- 0 until image.getWidth) {
val pixel = pixels(y * image.getWidth + x)
buf.put(((pixel >> 16) & 0xff).toByte)
buf.put(((pixel >> 8) & 0xff).toByte)
buf.put((pixel & 0xff).toByte)
buf.put(((pixel >> 24) & 0xff).toByte)
}
}
buf.flip()
list(i) = buf
}
Display.setIcon(list)
logger.info(s"Loaded window icons of sizes ${sizes.mkString(", ")}")
}
private def windowTitleBase: String = s"Ocelot Desktop ${BuildInfo.version} (${BuildInfo.commit.take(7)})"
private var _windowTitleSuffix: Option[String] = None
def windowTitleSuffix: Option[String] = _windowTitleSuffix
def windowTitleSuffix_=(value: Option[String]): Unit = {
if (value == _windowTitleSuffix)
return
_windowTitleSuffix = value
UiThreadTasks.add(updateWindowTitle)
}
private def updateWindowTitle(): Unit = {
Display.setTitle(
if (_windowTitleSuffix.isEmpty)
windowTitleBase
else
s"$windowTitleBase - ${_windowTitleSuffix.get}"
)
}
private var exitRequested = false
def exit(): Unit = {
exitRequested = true
}
def start(): Unit = {
while (!exitRequested) {
if (Display.isCloseRequested)
OcelotDesktop.exit()
OcelotDesktop.withTickLockAcquired {
updateWindowSizeAndPosition()
UiThreadTasks.run()
Audio.update()
KeyEvents.update()
MouseEvents.update()
OcelotDesktop.updateAutosave()
Profiler.measure("000_update") {
update()
}
Profiler.measure("001_draw") {
draw()
}
// we are processing screenshots here to make sure that
// the current frame was fully drawn on the screen,
// but the update for the next frame yet not happened (if the window, for example, is to be resized)
if (KeyEvents.isReleased(Keyboard.KEY_F12)) {
SoundSource.InterfaceShutter.play()
val image = graphics.screenshot()
root.flash.bang()
OcelotDesktop.showFileChooserDialog(JFileChooser.SAVE_DIALOG, JFileChooser.DIRECTORIES_ONLY) { dir =>
Try {
for (dir <- dir) {
val file = new File(dir, s"ocelot-${System.currentTimeMillis()}.png")
ImageIO.write(image, "PNG", file)
logger.debug(s"Saved screenshot to: $file")
}
}
}
}
}
Profiler.measure("002_sleep") {
ticker.waitNext()
}
Display.update()
fpsCalculator.tick()
}
}
def terminate(): Unit = {
_terminating = true
root.workspaceView.dispose()
KeyEvents.destroy()
MouseEvents.destroy()
graphics.freeResource()
SoundBuffers.freeResource()
Display.destroy()
Audio.destroy()
}
private def dispatchEvent(iter: => IterableOnce[Widget] = hierarchy.reverseIterator)(event: Event): Unit = {
for (widget <- iter) {
if (event.consumed) {
return
}
widget.handleEvent(event)
}
}
private def update(): Unit = {
if (shouldUpdateHierarchy) {
_updateHierarchy()
}
val mousePos = mousePosition
if (mousePos.x < 0 || mousePos.y < 0 || mousePos.x > root.width || mousePos.y > root.height) {
KeyEvents.releaseKeys()
MouseEvents.releaseButtons()
}
// TODO: dispatch to the focused widget instead of broadcasting to the entire hierarchy.
KeyEvents.events.foreach(dispatchEvent())
MouseEvents.events
.foreach(dispatchEvent(hierarchy.reverseIterator.filter(w => w.enabled && w.receiveAllMouseEvents)))
val scrollTarget = hierarchy.reverseIterator
.find(w => w.receiveScrollEvents && w.clippedBounds.contains(mousePos))
val mouseTarget = hierarchy.reverseIterator
.find(w => w.enabled && w.receiveMouseEvents && w.clippedBounds.contains(mousePos))
ScrollEvents.events.foreach(dispatchEvent(scrollTarget))
for (event <- MouseEvents.events) {
if (event.state == MouseEvent.State.Press) {
dispatchEvent(mouseTarget)(event)
// TODO: this should be done in the event capturing phase in [[Window]] itself.
for (mouseTarget <- mouseTarget if !mouseTarget.isInstanceOf[Window]) {
focusParentWindow(mouseTarget.parent)
}
} else {
dispatchEvent(hierarchy.reverseIterator)(event)
}
}
hierarchy.reverseIterator.foreach {
case handler: HoverHandler if !mouseTarget.contains(handler) => handler.setHovered(false)
case _ =>
}
mouseTarget.foreach {
case handler: HoverHandler => handler.setHovered(true)
case _ =>
}
root.update()
}
@tailrec
private def focusParentWindow(parent: Option[Widget]): Unit = {
parent match {
case Some(window: Window) => window.focus()
case Some(widget) => focusParentWindow(widget.parent)
case None =>
}
}
private def updateWindowSizeAndPosition(): Unit = {
val width = Display.getWidth
val height = Display.getHeight
if (graphics.resize(width, height, scalingFactor)) {
// Checking for window position changes here seems to fail (on linux at least)
// It instead happens on the previous frame
root.size = Size2D(width / scalingFactor, height / scalingFactor)
}
// Settings fields should be updated only in non-fullscreen mode
if (!fullScreen) {
Settings.get.windowSize.set(width, height)
Settings.get.windowPosition.set(Display.getX, Display.getY)
}
}
private def draw(): Unit = {
graphics.begin()
graphics.clear()
root.draw(graphics)
graphics.flush()
graphics.update()
}
}