ocelot-desktop/src/main/scala/ocelot/desktop/OcelotDesktop.scala
2025-01-23 02:08:01 +03:00

551 lines
17 KiB
Scala

package ocelot.desktop
import buildinfo.BuildInfo
import ocelot.desktop.geometry.Size2D
import ocelot.desktop.inventory.Items
import ocelot.desktop.ui.UiHandler
import ocelot.desktop.ui.swing.SplashScreen
import ocelot.desktop.ui.widget._
import ocelot.desktop.ui.widget.modal.notification.{NotificationDialog, NotificationType}
import ocelot.desktop.util.CommandLine.Argument
import ocelot.desktop.util._
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.SystemUtils
import org.lwjgl.opengl.Display
import totoro.ocelot.brain.Ocelot
import totoro.ocelot.brain.nbt.ExtendedNBT.{extendNBTTagCompound, extendNBTTagList}
import totoro.ocelot.brain.nbt.{CompressedStreamTools, NBT, NBTTagCompound, NBTTagString}
import totoro.ocelot.brain.user.User
import totoro.ocelot.brain.workspace.Workspace
import java.awt.Component
import java.io._
import java.nio.file._
import java.util.concurrent.locks.{Lock, ReentrantLock}
import javax.swing.{JDialog, JFileChooser, UIManager}
import scala.collection.mutable
import scala.collection.mutable.ArrayBuffer
import scala.concurrent.duration.Duration
import scala.io.Source
import scala.jdk.CollectionConverters._
import scala.util.{Failure, Success, Try, Using}
object OcelotDesktop
// This configures Log4j appenders & loggers, it should come before Logging in inheritance hierarchy
extends LoggingConfiguration
with Logging
{
System.setProperty("awt.useSystemAAFontSettings", "on")
System.setProperty("swing.aatext", "true")
System.setProperty("LWJGL_WM_CLASS", "Ocelot Desktop")
// Required to make all subsequent swing components look "native"
try UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName)
catch {
case ex: Exception => logger.error("Unable to perform setLookAndFeel()", ex)
}
private val splashScreen = new SplashScreen()
var root: RootWidget = _
val players: ArrayBuffer[User] = ArrayBuffer(User("myself"))
val tpsCounter = new FPSCalculator
val ticker = new Ticker
private val TickerIntervalHistorySize = 5
val tickerIntervalHistory = new mutable.Queue[Duration](TickerIntervalHistorySize)
def pushToTickerIntervalHistory(interval: Duration): Unit = {
if (tickerIntervalHistory.size >= TickerIntervalHistorySize) tickerIntervalHistory.dequeue()
tickerIntervalHistory.enqueue(interval)
}
private val tickLock: Lock = new ReentrantLock()
def withTickLockAcquired(f: => Unit): Unit = withLockAcquired(tickLock)(f)
private def mainInner(args: mutable.HashMap[Argument, Option[String]]): Unit = {
logger.info("Starting up Ocelot Desktop")
logger.info(s"Version: ${BuildInfo.version} (${BuildInfo.commit.take(7)})")
splashScreen.setStatus("Loading configuration...", 0.10f)
val customConfigPath = args.get(CommandLine.ConfigPath).flatten
val desktopConfigPath: Path =
if (customConfigPath.isDefined) {
Paths.get(customConfigPath.get)
}
else {
// TODO: migration for old locations of ocelot.conf, can be safely removed later
// TODO: uncomment this line and delete everything below it when you're ready!
// OcelotPaths.desktopConfig
val newConfigPath = OcelotPaths.desktopConfig
try {
if (!Files.exists(newConfigPath)) {
val oldConfigPath =
if (SystemUtils.IS_OS_WINDOWS)
Paths.get(OcelotPaths.windowsAppDataDirectoryName, "Ocelot", "ocelot.conf")
else
Paths.get(OcelotPaths.linuxHomeDirectoryName, ".config", "ocelot", "ocelot.conf")
if (Files.exists(oldConfigPath))
Files.move(oldConfigPath, newConfigPath)
}
}
catch {
case _: Throwable =>
}
// TODO: end of upper todo <3
newConfigPath
}
Settings.load(desktopConfigPath)
Messages.load(Source.fromURL(getClass.getResource("/ocelot/desktop/messages.txt")))
ColorScheme.load(Source.fromURL(getClass.getResource("/ocelot/desktop/colorscheme.txt")))
splashScreen.setStatus("Initializing brain...", 0.20f)
Ocelot.configPath = Settings.get.brainCustomConfigPath.map(Paths.get(_))
Ocelot.librariesPath = Some(OcelotPaths.libraries)
Ocelot.isPlayerOnlinePredicate = Some(player => players.exists(_.nickname == player))
Ocelot.initialize(logger)
Items.init()
splashScreen.setStatus("Initializing GUI...", 0.30f)
createWorkspace()
UiHandler.loadLibraries()
splashScreen.setStatus("Loading resources...", 0.60f)
UiHandler.init()
val loadRecentWorkspace = Settings.get.recentWorkspace.isDefined && Settings.get.openLastWorkspace
root = new RootWidget(!loadRecentWorkspace)
root.size = Size2D(Display.getWidth / Settings.get.scaleFactor, Display.getHeight / Settings.get.scaleFactor)
UiHandler.setRoot(root)
splashScreen.setStatus("Loading workspace...", 0.90f)
val cmdLineWorkspaceArgument = args.get(CommandLine.WorkspacePath).flatten
if (loadRecentWorkspace || cmdLineWorkspaceArgument.isDefined) {
val result = cmdLineWorkspaceArgument
.orElse(Settings.get.recentWorkspace)
.map(new File(_))
.toRight(new IllegalArgumentException("Received None as a directory path"))
.toTry
.map(load)
result match {
case Failure(exception) =>
val errorMessage = if (cmdLineWorkspaceArgument.isDefined)
"Could not open the specified workspace..."
else
"Could not open the recent workspace..."
logger.error(errorMessage, exception)
new NotificationDialog(s"$errorMessage\n($exception)\nI will create a default one", NotificationType.Info)
.addCloseButton()
.show()
root.workspaceView.createDefaultWorkspace()
Settings.get.recentWorkspace = None
case Success(_) =>
}
}
val updateThread = new Thread(() => try {
val currentThread = Thread.currentThread()
while (!currentThread.isInterrupted) {
Profiler.measure("tick") {
withTickLockAcquired {
workspace.update()
updateThreadTasks.run()
tpsCounter.tick()
}
}
ticker.waitNext()
}
} catch {
case _: InterruptedException => // ignore
}, "update-thread")
updateThread.start()
splashScreen.dispose()
logger.info("Ocelot Desktop is up and ready!")
UiHandler.start()
logger.info("Cleaning up")
updateThread.interrupt()
try updateThread.join() catch {
case _: InterruptedException =>
}
WebcamCapture.cleanup()
Settings.save(desktopConfigPath)
UiHandler.terminate()
ResourceManager.checkEmpty()
Ocelot.shutdown()
logger.info("Thanks for using Ocelot Desktop")
System.exit(0)
}
def main(rawArgs: Array[String]): Unit = {
val args = CommandLine.parse(rawArgs)
if (args.contains(CommandLine.Help)) {
println(CommandLine.doc)
} else {
try mainInner(args)
catch {
case e: Exception =>
val sw = new StringWriter
val pw = new PrintWriter(sw)
e.printStackTrace(pw)
logger.error(s"${sw.toString}")
System.exit(1)
}
}
}
private def saveWorld(nbt: NBTTagCompound): Unit = withTickLockAcquired {
val backendNBT = new NBTTagCompound
val frontendNBT = new NBTTagCompound
workspace.save(backendNBT)
root.workspaceView.save(frontendNBT)
frontendNBT.setNewTagList("players", players.map(player => new NBTTagString(player.nickname)))
nbt.setTag("back", backendNBT)
nbt.setTag("front", frontendNBT)
}
private def loadWorld(nbt: NBTTagCompound): Unit = withTickLockAcquired {
val backendNBT = nbt.getCompoundTag("back")
val frontendNBT = nbt.getCompoundTag("front")
workspace.load(backendNBT)
root.workspaceView.load(frontendNBT)
if (frontendNBT.hasKey("players")) {
players.clear()
players.addAll(frontendNBT.getTagList("players", NBT.TAG_STRING).map((player: NBTTagString) => User(player.getString)))
}
}
private var lastAutosave: Long = -1
def resetAutosave(): Unit = {
if (Settings.get.autosave) {
lastAutosave = System.currentTimeMillis()
logger.debug("Autosave interval was reset.")
}
}
def updateAutosave(): Unit = {
if (Settings.get.autosave) {
if (lastAutosave == -1 || System.currentTimeMillis() - lastAutosave > Settings.get.autosavePeriod * 1000) {
val path = savePath.getOrElse(tmpPath)
logger.debug(s"Autosave to $path...")
saveTo(path)
lastAutosave = System.currentTimeMillis()
}
}
}
private var _savePath: Option[Path] = None
private def savePath: Option[Path] = _savePath
private def savePath_=(path: Option[Path]): Unit = {
_savePath = path
UiHandler.windowTitleSuffix = path.map(_.toString)
}
private val tmpPath = Files.createTempDirectory("ocelot-save")
Runtime.getRuntime.addShutdownHook(new Thread(() => {
// if autosave is turned on and the project is not saved properly, we would like to keep the temporary project
if (!Settings.get.autosave || savePath.isEmpty) {
FileUtils.deleteDirectory(tmpPath.toFile)
}
}))
def newWorkspace(): Unit = showCloseConfirmationDialog("Save workspace before opening a new one?") {
root.workspaceView.newWorkspace()
savePath = None
Settings.get.recentWorkspace = None
resetAutosave()
}
private def saveTo(outputPath: Path): Unit = {
val oldPath = workspace.path
if (oldPath != outputPath) {
val (oldFiles, newFiles) =
Using.resources(Files.list(oldPath), Files.list(outputPath)) { (oldDirStream, newDirStream) =>
val oldFiles = oldDirStream.iterator.asScala.toArray
val newFiles = newDirStream.iterator.asScala.toArray
(oldFiles, newFiles)
}
val toRemove = newFiles.intersect(oldFiles)
for (path <- toRemove) {
if (Files.isDirectory(path)) {
FileUtils.deleteDirectory(path.toFile)
} else {
Files.delete(path)
}
}
for (path <- oldFiles) {
val oldFile = oldPath.resolve(path.getFileName).toFile
val newFile = outputPath.resolve(path.getFileName).toFile
if (Files.isDirectory(path)) {
FileUtils.copyDirectory(oldFile, newFile)
}
else {
FileUtils.copyFile(oldFile, newFile)
}
}
workspace.path = outputPath
}
val path = outputPath.resolve("workspace.nbt")
try {
Files.move(path, path.resolveSibling("workspace.nbt.bak"), StandardCopyOption.REPLACE_EXISTING)
} catch {
case _: FileNotFoundException | _: NoSuchFileException => // no workspace file to back up -- that's okay
}
Using.resource(new DataOutputStream(new FileOutputStream(path.toFile))) { writer =>
val nbt = new NBTTagCompound
saveWorld(nbt)
CompressedStreamTools.writeCompressed(nbt, writer)
}
logger.info(s"Saved workspace to: $outputPath")
}
def save(continuation: => Unit): Unit = savePath match {
case Some(savePath) =>
Try {
saveTo(savePath)
continuation
} match {
case f @ Failure(_) =>
this.savePath = None
showFailureMessage(f)
case Success(_) =>
}
case None => showSaveDialog(continuation)
}
def saveAs(): Unit = showSaveDialog()
def showOpenDialog(): Unit =
showFileChooserDialog(JFileChooser.OPEN_DIALOG, JFileChooser.DIRECTORIES_ONLY) {
case Some(dir) => load(dir)
case None => Success(())
}
def load(dir: File): Try[Unit] = {
val path = Paths.get(dir.getCanonicalPath, "workspace.nbt")
if (Files.exists(path)) {
Using(new DataInputStream(Files.newInputStream(path))) { reader =>
val nbt = CompressedStreamTools.readCompressed(reader)
Transaction.run { tx =>
val oldSavePath = savePath
savePath = Some(dir.toPath)
tx.onFailure { savePath = oldSavePath }
Settings.get.recentWorkspace = Some(dir.getCanonicalPath)
workspace.path = dir.toPath
loadWorld(nbt)
resetAutosave()
}
}
} else Failure(new FileNotFoundException("Specified directory does not contain 'workspace.nbt'"))
}
def showFileChooserDialog(dialogType: Int, selectionMode: Int)(f: Option[File] => Try[Unit]): Unit = {
new Thread(() => {
val lastFile = savePath.map(_.toFile).orNull
val chooser = new JFileChooser(lastFile) {
override def createDialog(parent: Component): JDialog = {
val dialog = super.createDialog(parent)
dialog.setModal(true)
dialog.setAlwaysOnTop(true)
dialog
}
}
chooser.setFileSelectionMode(selectionMode)
chooser.setDialogType(dialogType)
val selectedFile =
Option.when(chooser.showDialog(null, null) == JFileChooser.APPROVE_OPTION)(chooser.getSelectedFile)
// users of [showFileChooserDialog] expect that the continuation is run on the main thread.
// let's meet this expectation.
UiHandler.UiThreadTasks.add(() => {
val result = f(selectedFile)
result match {
case f@Failure(_) => showFailureMessage(f)
case Success(_) =>
}
})
}).start()
}
private def showFailureMessage(failure: Failure[Unit]): Unit = {
val Failure(exception) = failure
logger.error("File operation failed", exception)
new NotificationDialog(
s"Something went wrong!\n($exception)\nCheck the log file for a full stacktrace.",
NotificationType.Error
).addCloseButton().show()
}
def showAddPlayerDialog(): Unit = new InputDialog(
"Add new player",
text => OcelotDesktop.selectPlayer(text)
).show()
def player: User = if (players.nonEmpty) players.head else User("myself")
def selectPlayer(name: String): Unit = {
players.indexWhere(_.nickname == name) match {
case -1 => players.prepend(User(name))
case i =>
val player = players(i)
players.remove(i)
players.prepend(player)
}
}
def removePlayer(name: String): Unit = {
players.indexWhere(_.nickname == name) match {
case -1 =>
case i => players.remove(i)
}
}
def exit(): Unit = showCloseConfirmationDialog() {
UiHandler.exit()
}
var workspace: Workspace = _
val updateThreadTasks = new TaskQueue()
private def createWorkspace(): Unit = {
workspace = new Workspace(tmpPath)
}
private def prepareSavePath(path: Path)(continuation: => Unit): Unit = {
val nonEmpty = try Using.resource(Files.list(path))(_.iterator.asScala.nonEmpty) catch {
case _: FileNotFoundException | _: NoSuchFileException =>
logger.info(s"Save path $path does not exist: creating a new directory")
Files.createDirectory(path)
false
}
if (nonEmpty) {
new NotificationDialog(
"""Chosen save path is not empty.
|Files in the save directory will be included in the workspace.
|They may be overwritten, causing loss of data.
|Proceed with saving anyway?""".stripMargin,
NotificationType.Warning
) {
addButton("Cancel") {
close()
}
addButton("Yes") {
close()
continuation
}
}.show()
} else continuation
}
private def showSaveDialog(continuation: => Unit): Unit =
showFileChooserDialog(JFileChooser.SAVE_DIALOG, JFileChooser.DIRECTORIES_ONLY) { dir =>
Try {
if (dir.nonEmpty) {
prepareSavePath(dir.get.toPath) {
Transaction.run { tx =>
val oldSavePath = savePath
savePath = dir.map(_.toPath)
tx.onFailure {
savePath = oldSavePath
}
save(continuation)
Settings.get.recentWorkspace = dir.map(_.getCanonicalPath)
resetAutosave()
}
}
}
}
}
private def showCloseConfirmationDialog(prompt: Option[String])(continuation: => Unit): Unit = {
if (UiHandler.root.modalDialogPool.children.exists(_.isInstanceOf[CloseConfirmationDialog])) {
return
}
for (savePath <- savePath if Settings.get.saveOnExit) {
saveTo(savePath)
continuation
return
}
val prompt_ = prompt
new CloseConfirmationDialog {
override def prompt: String = prompt_.getOrElse(super.prompt)
override def onSaveSelected(): Unit = save {
close()
continuation
}
override def onNoSaveSelected(): Unit = {
close()
continuation
}
}.show()
}
private def showCloseConfirmationDialog(prompt: String)(continuation: => Unit): Unit =
showCloseConfirmationDialog(Some(prompt))(continuation)
private def showCloseConfirmationDialog()(continuation: => Unit): Unit =
showCloseConfirmationDialog(None)(continuation)
}