2023-06-13 18:19:51 +03:00

433 lines
13 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}
import ocelot.desktop.geometry.{Rect2D, Size2D, Vector2D}
import ocelot.desktop.graphics.Graphics
import ocelot.desktop.ui.event.MouseEvent
import ocelot.desktop.ui.event.handlers.HoverHandler
import ocelot.desktop.ui.event.sources.{KeyEvents, MouseEvents, ScrollEvents}
import ocelot.desktop.ui.widget.{RootWidget, Widget}
import ocelot.desktop.util._
import ocelot.desktop.{OcelotDesktop, Settings}
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.SystemUtils
import org.lwjgl.BufferUtils
import org.lwjgl.input.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.{Files, Paths}
import javax.imageio.ImageIO
import scala.collection.mutable
import scala.concurrent.duration.DurationInt
object UiHandler extends Logging {
var root: RootWidget = _
var graphics: Graphics = _
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
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)
)
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(root: RootWidget): Unit = {
this.root = root
root.relayout()
scalingFactor = Settings.get.scaleFactor
fullScreen = Settings.get.windowFullscreen
windowTitle = "Ocelot Desktop v" + BuildInfo.version
loadIcons()
if (!Settings.get.disableVsync) {
logger.info("VSync enabled")
Display.setVSyncEnabled(true)
} else {
logger.info("VSync disabled (via config)")
}
Display.create()
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(scalingFactor)
if (Settings.get.audioDisable) {
logger.warn("Sound disabled (via config)")
} else {
Audio.init()
}
}
private var nativeLibrariesDir: String = _
def loadLibraries(): Unit = {
// we cannot remove DLL files on Windows after they were loaded by Ocelot
// therefore we will create them in local directory and keep for future
nativeLibrariesDir = if (SystemUtils.IS_OS_WINDOWS) {
Paths.get(this.getClass.getProtectionDomain.getCodeSource.getLocation.toURI.resolve("natives")).toString
} else {
val directory = Files.createTempDirectory("ocelot-desktop")
Runtime.getRuntime.addShutdownHook(new Thread(() => FileUtils.deleteDirectory(new File(nativeLibrariesDir))))
directory.toString
}
val arch = System.getProperty("os.arch")
val is64bit = arch.startsWith("amd64")
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)
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("Unpacking native libraries to: " + nativeLibrariesDir)
for (lib <- libs) {
val dest = new File(Paths.get(nativeLibrariesDir, lib).toString)
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", nativeLibrariesDir)
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(", ")}")
}
//noinspection ScalaWeakerAccess
def windowTitle: String = Display.getTitle
//noinspection ScalaWeakerAccess
def windowTitle_=(title: String): Unit = Display.setTitle(title)
private var exitRequested = false
def exit(): Unit = {
exitRequested = true
}
def start(): Unit = {
while (!exitRequested) {
if (Display.isCloseRequested)
OcelotDesktop.exit()
OcelotDesktop.withTickLockAcquired {
updateWindowSizeAndPosition()
Audio.update()
KeyEvents.update()
MouseEvents.update()
Profiler.measure("000_update") {
update()
}
Profiler.measure("001_draw") {
draw()
}
}
Profiler.measure("002_sleep") {
ticker.waitNext()
}
Display.update()
fpsCalculator.tick()
}
}
def terminate(): Unit = {
root.workspaceView.dispose()
KeyEvents.destroy()
MouseEvents.destroy()
graphics.freeResource()
SoundBuffers.freeResource()
Display.destroy()
Audio.destroy()
}
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()
}
for (event <- KeyEvents.events)
hierarchy.reverseIterator.foreach(_.handleEvent(event))
for (event <- MouseEvents.events)
hierarchy.reverseIterator.filter(_.receiveAllMouseEvents).foreach(_.handleEvent(event))
val scrollTarget = hierarchy.reverseIterator
.find(w => w.receiveScrollEvents && w.clippedBounds.contains(mousePos))
val mouseTarget = hierarchy.reverseIterator
.find(w => w.receiveMouseEvents && w.clippedBounds.contains(mousePos))
for (event <- ScrollEvents.events)
scrollTarget.foreach(_.handleEvent(event))
for (event <- MouseEvents.events)
if (event.state == MouseEvent.State.Press) {
mouseTarget.foreach(_.handleEvent(event))
} else
hierarchy.reverseIterator.foreach(_.handleEvent(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()
}
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, height) / scalingFactor
}
// Settings fields should be updated only in non-fullscreen mode
if (fullScreen)
return
Settings.get.windowSize.set(width, height)
Settings.get.windowPosition.set(Display.getX, Display.getY)
}
private def draw(): Unit = {
graphics.startViewport()
graphics.clear()
root.draw(graphics)
graphics.flush()
graphics.update()
}
}