Skip to content

Commit

Permalink
Implement ui less mode (app running with no windows)
Browse files Browse the repository at this point in the history
  • Loading branch information
sghpjuikit committed Jan 12, 2025
1 parent 34fbe67 commit 5e41461
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 48 deletions.
11 changes: 6 additions & 5 deletions src/player/main/sp/it/pl/core/CoreMenus.kt
Original file line number Diff line number Diff line change
Expand Up @@ -174,19 +174,20 @@ object CoreMenus: Core {
}
}
add<App> {
menu("Windows") {
item("New window") { APP.windowManager.createWindow() }
menu("All") {
menu("Ui") {
if (APP.windowManager.isDeserializeUiPossible())
item("App Ui - Load") { APP.startUi() }

menu("Windows") {
APP.windowManager.windows.forEach { w ->
menuFor("${w.stage.title} (${w.width} x ${w.height})", w)
}
}
item("New window") { APP.windowManager.createWindow() }
}
menu("Audio") {
item("Play/pause", keys = ActionRegistrar["Pause/resume"].keysUi()) { APP.audio.pauseResume() }
}
item("Restart") { APP.restart() }
item("Exit") { APP.close() }
}
add<File> {
if (value.isAudio()) {
Expand Down
22 changes: 18 additions & 4 deletions src/player/main/sp/it/pl/main/App.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package sp.it.pl.main

import com.github.ajalt.clikt.parameters.options.convert
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import sp.it.pl.main.AppSettings.app as conf
import com.sun.jna.platform.win32.Advapi32Util
import com.sun.tools.attach.VirtualMachine
Expand Down Expand Up @@ -116,6 +119,7 @@ fun main(args: Array<String>) {

CoreOshi().init()

Platform.setImplicitExit(false)
Application.launch(App::class.java, *args)
}

Expand Down Expand Up @@ -159,7 +163,7 @@ class App: Application(), GlobalConfigDelegator {
/** Whether application starts with a state. If false, state is not restored on start or stored on close. */
var isStateful = true
/** Whether application forbids no windows. If true, at least one window must be open and closing last one will close the app. */
var isUiApp = true

/** Whether application is initialized. Starts as error and transitions to ok in [App.start] if no error occurs. */
var isInitialized: Try<Unit, Throwable> = Try.error(Exception("Initialization has not run yet"))
private set
Expand Down Expand Up @@ -225,6 +229,9 @@ class App: Application(), GlobalConfigDelegator {
/** Shows whether this application's process is running with elevated permissions. */
val processElevated by cn(if (WINDOWS.isCurrent) Advapi32Util.isCurrentProcessElevated() else null)
.noPersist().apply { if (!WINDOWS.isCurrent) noUi() } def ConfigDef("Process elevated", "Whether this application's process is running with elevated permissions", editable = NONE)
/** Whether application can run with no ui. If false, at least one window must be open and closing main window will close the app. */
val isUiApp by cv(true)
.def(name = "Require ui", info = "Whether application can run with no ui. If false, at least one window must be open and closing main window will close the app.")

/** Environment core. */
val env = CoreEnv.apply { init() }
Expand Down Expand Up @@ -358,7 +365,7 @@ class App: Application(), GlobalConfigDelegator {

isInitialized = runTry {
plugins.initForApp()
collectActionsOf(PlaylistManager)
collectActionsOf(PlaylistManager)
collectActionsOf(this)
collectActionsOf(ui)
collectActionsOf(actions)
Expand All @@ -368,7 +375,8 @@ class App: Application(), GlobalConfigDelegator {
widgetManager.init()
db.init()
audio.initialize()
if (isStateful) windowManager.deserialize()
windowManager.deserialize()
Unit
}.ifError {
runLater {
logger.error(it) { "Application failed to start" }
Expand Down Expand Up @@ -399,6 +407,12 @@ class App: Application(), GlobalConfigDelegator {
}
}

/** Starts this application normally if not yet started that way, otherwise has no effect. */
@IsAction(name = "Load ui", info = conf.startNormally.cinfo)
fun startUi() {
windowManager.deserialize(force = true)
}

@Deprecated("Called automatically")
override fun stop() {
logger.info { "Stopping..." }
Expand Down Expand Up @@ -437,7 +451,7 @@ class App: Application(), GlobalConfigDelegator {
if (isInitialized.isOk) {
if (rank==MASTER && isStateful) audio.state.serialize()
if (rank==MASTER && isStateful) windowManager.dockWindow?.window?.close()
if (rank==MASTER && isStateful) windowManager.serialize()
if (rank==MASTER && isStateful) windowManager.serialize(true)
if (rank==MASTER) configuration.save(location.user.application_json)
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/player/main/sp/it/pl/main/AppActionsApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import sp.it.util.system.open
/** Denotes actions for [App] */
object AppActionsApp {


val restart = action<App>("App Restart", "Restarts application process", IconFA.REFRESH) { APP.restart() }

val exit = action<App>("App Exit", "Exits application process", IconFA.CLOSE) { APP.close() }

val openLocation = action<App>("Open app directory", "Opens directory from which this application is running from", IconFA.GEARS) { APP.location.open() }

val openHotkeysInfo = action<App>("Open hotkeys", "Display all available shortcuts", IconMD.KEYBOARD_VARIANT) { it.actions.showShortcuts() }
Expand Down
5 changes: 1 addition & 4 deletions src/player/main/sp/it/pl/main/AppCli.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,7 @@ class Cli: CliktCommand(
val version = versionOption("Application version=${APP.version}, JDK version=${Runtime.version()}, Kotlin version=${KotlinVersion.CURRENT}")
val dev by option(help = "Forces development mode to 'true' regardless of the setting")
.flag()
val uiless by option(help = "Whether application can run with no ui. If false, at least one window must be open and closing last one will close the app.")
.convert { it.toBoolean() }.default(false)
val stateless by option(help = "Whether application starts with a state. If true, state is not restored on start or stored on close.")
val stateless by option(help = "Whether application starts with a state. If true, state or ui is not restored on start or stored on close.")
.convert { it.toBoolean() }.default(false)
val singleton by option(help = "Whether application should close and delegate arguments if there is already running instance")
.convert { it.toBoolean() }.default(true)
Expand All @@ -100,7 +98,6 @@ class Cli: CliktCommand(
if (!APP.isInitialized.isOk) {
APP.isSingleton = singleton
APP.isStateful *= !stateless
APP.isUiApp *= !uiless
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ fun openWindowSettings(w: Window, eventSource: Node?) {
val c = object: ConfigurableBase<Any?>() {

val main by cv(w.isMain.value, false).readOnlyIf(w.isMain)
.def(name = "Main", info = "Whether this window is main. Closing main window closes the application. At most one window can be main. Can only be set to true.")
.def(name = "Main", info = "Whether this window is main. Closing main window closes the application if application is not set to allow no ui). At most one window can be main. Can only be set to true (which steals main from previous main window).")
val opacity by cOr(w.opacity, Inherit()).butOverridden { between(0.1, 1.0) }
.defInherit(APP.windowManager::windowOpacity)
val transparencyAllow by cOr(APP.windowManager::windowStyleAllowTransparency, if (w.stageStyleOverride) Override(w.s.style==TRANSPARENT) else Inherit(), onClose)
Expand Down
120 changes: 86 additions & 34 deletions src/player/main/sp/it/pl/ui/objects/window/stage/WindowManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.sun.jna.platform.win32.GDI32
import com.sun.jna.platform.win32.User32
import io.github.oshai.kotlinlogging.KotlinLogging
import java.io.File
import java.util.concurrent.locks.ReentrantLock
import javafx.geometry.Insets
import javafx.geometry.Orientation.VERTICAL
import javafx.geometry.Pos
Expand Down Expand Up @@ -36,18 +37,17 @@ import javafx.stage.StageStyle.UTILITY
import javafx.stage.Window as WindowFx
import javafx.stage.WindowEvent.WINDOW_SHOWING
import kotlin.math.sqrt
import kotlin.concurrent.withLock
import kotlinx.coroutines.invoke
import sp.it.pl.core.CoreMouse.onNextMouseMoveStop
import sp.it.pl.layout.Component
import sp.it.pl.layout.ComponentDb
import sp.it.pl.layout.ComponentFactory
import sp.it.pl.layout.ComponentLoader.CUSTOM
import sp.it.pl.layout.Layout
import sp.it.pl.layout.NoFactoryFactory
import sp.it.pl.layout.Widget
import sp.it.pl.layout.WidgetIoManager
import sp.it.pl.layout.WidgetUse.NEW
import sp.it.pl.layout.deduplicateIds
import sp.it.pl.layout.exportFxwl
import sp.it.pl.layout.loadComponentFxwlJson
import sp.it.pl.main.APP
Expand Down Expand Up @@ -87,19 +87,23 @@ import sp.it.util.async.coroutine.VT
import sp.it.util.async.coroutine.await
import sp.it.util.async.coroutine.launch
import sp.it.util.async.future.Fut
import sp.it.util.async.future.Fut.Companion.fut
import sp.it.util.async.runFX
import sp.it.util.async.runLater
import sp.it.util.async.runVT
import sp.it.util.bool.TRUE
import sp.it.util.collections.ObservableListRO
import sp.it.util.collections.materialize
import sp.it.util.collections.observableList
import sp.it.util.collections.readOnly
import sp.it.util.collections.setToOne
import sp.it.util.conf.Configurable
import sp.it.util.conf.GlobalSubConfigDelegator
import sp.it.util.conf.between
import sp.it.util.conf.c
import sp.it.util.conf.cv
import sp.it.util.conf.def
import sp.it.util.conf.noUi
import sp.it.util.dev.ThreadSafe
import sp.it.util.dev.failIfNotFxThread
import sp.it.util.file.Util.isValidatedDirectory
Expand Down Expand Up @@ -137,6 +141,7 @@ import sp.it.util.reactive.zip
import sp.it.util.reactive.zip2
import sp.it.util.system.Os
import sp.it.util.text.keys
import sp.it.util.type.atomic
import sp.it.util.ui.anchorPane
import sp.it.util.ui.borderPane
import sp.it.util.ui.flowPane
Expand Down Expand Up @@ -237,6 +242,10 @@ class WindowManager: GlobalSubConfigDelegator(confWindow.name) {
/** @return focused window or [getMain] if none focused or new window if no main window */
fun getActiveOrNew(): Window = getActive() ?: createWindow()

private val serializing = ReentrantLock()
private var isSerialized by atomic(true)
private var isSerializedWithAppClose by c(false).noUi()

init {
windowsFx.onItemAdded { w ->
w.asAppWindow().ifNotNull {
Expand All @@ -247,10 +256,14 @@ class WindowManager: GlobalSubConfigDelegator(confWindow.name) {
}
windowsFx.onItemRemoved { w ->
w.asAppWindow().ifNotNull {
if (APP.isUiApp && it.isMain.value) {
APP.close()
val isMain = it.isMain.value
if (APP.isUiApp.value) {
if (isMain) APP.close()
else windows -= it
} else {
if (isMain) serialize(false)
windows -= it
if (isMain) windows.materialize().forEach { it.closeWithAnim() }
}
}
}
Expand Down Expand Up @@ -325,7 +338,7 @@ class WindowManager: GlobalSubConfigDelegator(confWindow.name) {
}
}

fun create(canBeMain: Boolean = APP.isUiApp, state: WindowDb? = null) = create(
fun create(canBeMain: Boolean = APP.isUiApp.value, state: WindowDb? = null) = create(
owner = state?.let { if (!it.isTaskbarVisible) APP.windowManager.createStageOwner() else null },
style = (state?.transparent ?: windowStyleAllowTransparency.value).toWindowStyle(),
canBeMain = state?.main ?: canBeMain
Expand All @@ -334,7 +347,7 @@ class WindowManager: GlobalSubConfigDelegator(confWindow.name) {
fun create(owner: Stage?, style: StageStyle, canBeMain: Boolean): Window {
val w = Window(owner, style)

if (mainWindow==null && APP.isUiApp && canBeMain) setAsMain(w)
if (mainWindow==null && APP.isUiApp.value && canBeMain) setAsMain(w)

w.initialize()

Expand Down Expand Up @@ -362,7 +375,7 @@ class WindowManager: GlobalSubConfigDelegator(confWindow.name) {
}

@IsAction(name = "Open new window", info = "Opens new application window")
fun createWindow(): Window = createWindow(APP.isUiApp)
fun createWindow(): Window = createWindow(APP.isUiApp.value)

fun setAsMain(w: Window) {
if (mainWindow===w) return
Expand Down Expand Up @@ -501,39 +514,69 @@ class WindowManager: GlobalSubConfigDelegator(confWindow.name) {
}
}

fun serialize() {
fun serialize(appCloses: Boolean) {
failIfNotFxThread()
isSerializedWithAppClose = !appCloses
serializing.withLock {
// prevent serializing multiple times, in ui-less mode this can overwrite serialized state with empty
if (isSerialized) {
logger.info { "Serializing windows skipped. Already done." }
return
}

// make sure directory is accessible
val dir = APP.location.user.layouts.current
if (!isValidatedDirectory(dir)) {
logger.error { "Serializing windows failed. $dir not accessible." }
return
}
// make sure directory is accessible
val dir = APP.location.user.layouts.current
if (!isValidatedDirectory(dir)) {
logger.error { "Serializing windows failed. $dir not accessible." }
return
}

val filesOld = dir.children().toSet()
val ws = windows.filter { it!==dockWindow?.window && it.layout!=null }
logger.info { "Serializing ${ws.size} application windows" }

// serialize - for now each window to its own file with .ws extension
val sessionUniqueName = System.currentTimeMillis().toString()
var isError = false
val filesNew = HashSet<File>()
for (i in ws.indices) {
val w = ws[i]
val f = dir/"window_${sessionUniqueName}_$i.ws"
filesNew += f
isError = isError or APP.serializerJson.toJson(WindowDb(w), f).isError
if (isError) break
}
val filesOld = dir.children().toSet()
val ws = windows.filter { it!==dockWindow?.window && it.layout!=null }
logger.info { "Serializing ${ws.size} application windows" }

// serialize - for now each window to its own file with .ws extension
val sessionUniqueName = System.currentTimeMillis().toString()
var isError = false
val filesNew = HashSet<File>()
for (i in ws.indices) {
val w = ws[i]
val f = dir/"window_${sessionUniqueName}_$i.ws"
filesNew += f
isError = isError or APP.serializerJson.toJson(WindowDb(w), f).isError
if (isError) break
}

// remove unneeded files, either old or new session will remain
(if (isError) filesNew else filesOld).forEach { it.delete() }
// remove unneeded files, either old or new session will remain
(if (isError) filesNew else filesOld).forEach { it.delete() }

isSerialized = true
}
}

@ThreadSafe
fun deserialize(): Fut<*> =
runVT<List<WindowDb>> {
fun deserialize(force: Boolean = false): Fut<*> {
serializing.lock()

// prevent deserializing multiple times, in ui-less mode this can overwrite serialized state with empty
if (!isSerialized) {
logger.info { "Deserializing windows skipped. Already done." }
return fut()
}

// prevent deserializing multiple times, in ui-less mode this can overwrite serialized state with empty
if (!APP.isStateful) {
logger.info { "Deserializing windows skipped. App not stateful." }
return fut()
}

// prevent deserializing prematurely
if (!force && !APP.isUiApp.value && !isSerializedWithAppClose) {
logger.info { "Deserializing windows skipped. Not forced and not serializedWithAppClose." }
return fut()
}

return runVT<List<WindowDb>> {
logger.info { "Deserializing windows..." }
val dir = APP.location.user.layouts.current
if (isValidatedDirectory(dir)) {
Expand All @@ -547,7 +590,8 @@ class WindowManager: GlobalSubConfigDelegator(confWindow.name) {
}
}.ui { it ->
if (it.isEmpty()) {
createWindow(true)
if (APP.isUiApp.value)
createWindow(true)
} else {
val ws = it.map { it.toDomain() }
if (mainWindow==null) setAsMain(ws.first())
Expand All @@ -556,7 +600,15 @@ class WindowManager: GlobalSubConfigDelegator(confWindow.name) {
WidgetIoManager.requestWidgetIOUpdate()
}
getActive()?.focus()
}.onDone {
isSerialized = false
serializing.unlock()
}
}

/** @return whether [deserialize] would have any effect */
@ThreadSafe
fun isDeserializeUiPossible() = isSerialized

fun slideWindow(c: Component): Window {
val screen = getScreenForMouse()
Expand Down

0 comments on commit 5e41461

Please sign in to comment.