mirror of
https://gitlab.com/cc-ru/ocelot/ocelot-desktop.git
synced 2025-12-20 11:09:20 +01:00
433 lines
13 KiB
Scala
433 lines
13 KiB
Scala
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()
|
||
}
|
||
}
|