From feb4b4e2c9ca743d1c89b233da96fe27d737f575 Mon Sep 17 00:00:00 2001 From: Philipp Wagner Date: Fri, 16 Oct 2026 17:58:28 +0200 Subject: [PATCH] Open secondary windows on the main window's screen (#59) (#135) * Open secondary windows on the main window's screen (#59) Every Compose window but the main one went to the platform's default position, which on a multi-monitor desk is the primary screen - the station map could open on a monitor that was switched off. They now open centred on the screen the main window is on, with their size clamped to that screen's usable area (insets considered). A window the operator moved during the session reopens there, as long as its title bar can still be grabbed on a connected screen; otherwise it falls back to the main window's screen. The position lives only in the window's host and is never persisted. ComposeAlert dialogs and the profile picker are centred the same way once the main window exists. The rule is a pure function (WindowPlacement) with 18 unit tests; the AWT side (WindowPlacementOnScreen) only reads the screens and the main window's bounds on the event thread. The clamped opening size of a secondary window is not written back, so the stored size survives a smaller screen. Co-Authored-By: Claude Opus 5.5 * Track window moves reliably and keep remembered windows on screen (#59) Review fixes for the window placement: - Once a window has left the tolerance around its opening position, every later position is remembered. Before, the anchor was never re-set, so dragging away and back to near the opening spot left the stale far position remembered. - A window the host did not place (opened before the main window exists) only trusts its anchor once two consecutive reports lie within the tolerance, so a provisional position followed by the window manager's placement jump is no longer taken as the operator's move. - A remembered position is shifted the least distance that puts the (clamped) window wholly inside the usable area of the screen holding its title bar, as the KDoc promised. The move rule moves into a pure MoveTracker with 5 tests; WindowPlacement gains keptWithin and 2 more tests. Co-Authored-By: Claude Opus 5.5 * Keep window positions across restarts as an opt-in; fit windows to small screens (#59) A new GUI tab option, off by default, keeps the position the operator last moved each window to, the main window included. It is stored per window with the bounds of the screen it was on, written by the layout autosave, and restored only when a screen with exactly those bounds is connected again; otherwise the session rule applies. Only operator moves are stored, never window manager nudges, so a restored window cannot drift. An open window that rests on a screen too small for it is now cut down to that screen's usable area, and the station map's 900x600 minimum is capped to the smallest connected screen, so 640x480 monitors stay usable. Co-Authored-By: Claude Opus 5.5 * Never store a size or position the host fitted a window to (#59) The cut-down to a small screen was written back as the stored size (main window via onResized, others via the tracker) and counted as an operator move. The host now records its own fit: that size is not written back, and that position re-anchors the move tracker instead of being stored. A main window opened at a stored position skips its opening size like the other windows, since it may be clamped too. The opt-in gate moves into PreferencesWindowPositionMemory, with tests. Co-Authored-By: Claude Opus 5.5 * Match the fit echo with tolerance and consume it once (#59) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../src/main/i18n/strings_de.properties | 2 + .../src/main/i18n/strings_en.properties | 2 + .../view/Kst4ContestApplication.java | 11 + .../kst4contest/view/compose/ComposeAlert.kt | 4 +- .../view/compose/ComposeWindowHost.kt | 290 +++++++++++++- .../view/compose/MainWindowHost.kt | 2 +- .../compose/OperatorProfilePickerWindow.kt | 2 +- .../PreferencesWindowPositionMemory.kt | 39 ++ .../view/compose/WindowPlacement.kt | 353 ++++++++++++++++++ .../view/compose/map/StationMapWindow.kt | 9 +- .../view/compose/tabs/GuiOptionsTab.kt | 7 + .../view/compose/tabs/GuiOptionsTabState.kt | 2 + .../kst4contest/view/compose/FitRecordTest.kt | 54 +++ .../view/compose/MoveTrackerTest.kt | 65 ++++ .../PreferencesWindowPositionMemoryTest.kt | 49 +++ .../view/compose/WindowPlacementTest.kt | 252 +++++++++++++ .../kst4contest/model/ChatPreferences.java | 98 +++++ .../ChatPreferencesLayoutPersistenceTest.java | 47 +++ docs/PROJECT_CONTEXT.md | 1 + github_docs/de-Changelog.md | 2 + github_docs/en-Changelog.md | 2 + 21 files changed, 1280 insertions(+), 13 deletions(-) create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/PreferencesWindowPositionMemory.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/WindowPlacement.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/FitRecordTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/MoveTrackerTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/PreferencesWindowPositionMemoryTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/WindowPlacementTest.kt diff --git a/app-desktop/src/main/i18n/strings_de.properties b/app-desktop/src/main/i18n/strings_de.properties index c0e23c67..f9825649 100644 --- a/app-desktop/src/main/i18n/strings_de.properties +++ b/app-desktop/src/main/i18n/strings_de.properties @@ -26,6 +26,7 @@ gui.language=Sprache gui.language.system=Systemsprache gui.startupDesign=Design beim Start gui.startupFilters=Beim Start aktive Nachrichtenfilter +gui.windows=Fenster logSynch.fileChooserTitle=Nur-Lese-Loginterpreter-Datei wählen logSynch.fileEnabled=Gearbeitete Rufzeichen regelmäßig aus einer Logdatei lesen (ohne Bandangabe) logSynch.fileLabel=Zu überwachende Logdatei: @@ -399,6 +400,7 @@ gui.filterPmToOthers=Privatnachrichten an andere gui.filterNothing=Kein Filter gui.hintFreshCall=Ein zum ersten Mal gesehenes Rufzeichen markieren gui.hintLargeGridWorked=Ein gearbeitetes Großfeld markieren +gui.rememberWindowPositions=Fenster nach einem Neustart dort öffnen, wohin sie zuletzt geschoben wurden (nur auf demselben Bildschirm) form.choose=Wählen... form.notSelected=nicht gewählt dialog.apply=Übernehmen diff --git a/app-desktop/src/main/i18n/strings_en.properties b/app-desktop/src/main/i18n/strings_en.properties index 58725812..570850f6 100644 --- a/app-desktop/src/main/i18n/strings_en.properties +++ b/app-desktop/src/main/i18n/strings_en.properties @@ -35,6 +35,7 @@ gui.language=Language gui.language.system=System language gui.startupDesign=Startup design gui.startupFilters=Message filters active at startup +gui.windows=Windows logSynch.fileChooserTitle=Choose Readonly-Loginterpreter-File logSynch.fileEnabled=Read worked callsigns periodically from a log file (without band information) logSynch.fileLabel=Log file to be monitored: @@ -408,6 +409,7 @@ gui.filterPmToOthers=Private messages to others gui.filterNothing=No filter gui.hintFreshCall=Mark a callsign seen for the first time gui.hintLargeGridWorked=Mark a worked large grid field +gui.rememberWindowPositions=Reopen windows across restarts where they were last moved to (same screen only) form.choose=Choose... form.notSelected=not selected dialog.apply=Apply diff --git a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java index e74e59d9..eb96921c 100644 --- a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -2120,6 +2120,16 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos } + /** + * Hands the window hosts the active profile's stored window positions. Set on every + * startup, so a profile switch replaces the previous profile's positions. The opt-in is + * read on each call, so applying the setting takes effect without a restart. + */ + private void installWindowPositionMemory(ChatPreferences prefs) { + kst4contest.view.compose.WindowPlacementOnScreen.setMemory( + new kst4contest.view.compose.PreferencesWindowPositionMemory(prefs, this::requestLayoutSave)); + } + private void requestLayoutSave() { if (layoutAutosave != null) { layoutAutosave.requestSave(); @@ -2163,6 +2173,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos .isMessageHandling_debugModeToFileEnabled()); layoutAutosave = new LayoutAutosave( chatcontroller.getChatPreferences()::writeLayoutPreferencesToXmlFile, uiDispatcher); + installWindowPositionMemory(chatcontroller.getChatPreferences()); // Every profile starts with its own configured design; the menu quick switch only lasts for the session. chatcontroller.getChatPreferences().setGUI_darkModeActive( chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault()); diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt index f62d7ef3..cd019d1d 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt @@ -254,7 +254,7 @@ object ComposeAlert { val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS) dialog.title = title dialog.size = DIALOG_SIZE - dialog.setLocationRelativeTo(null) + WindowPlacementOnScreen.centreOnMainWindowScreen(dialog) /* Disposed and not hidden, for the reason spelled out in open(). */ dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE dialog.isAlwaysOnTop = true @@ -322,7 +322,7 @@ object ComposeAlert { ) dialog.title = title dialog.size = DIALOG_SIZE - dialog.setLocationRelativeTo(null) + WindowPlacementOnScreen.centreOnMainWindowScreen(dialog) /* * Disposed, not hidden. The JDialog default is HIDE_ON_CLOSE, which would leave the * window and its Skia layer alive behind the operator's back — one per dismissal, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt index f0b04b2c..ebbf9655 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt @@ -11,16 +11,25 @@ import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.loadImageBitmap import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.FrameWindowScope +import androidx.compose.ui.window.WindowPlacement as ComposeWindowPlacement +import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import androidx.compose.ui.awt.ComposeWindow import java.awt.EventQueue import java.awt.Frame +import java.awt.Point import java.awt.Toolkit import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop /** * A Compose window that lives beside the JavaFX application. @@ -45,10 +54,20 @@ import java.util.concurrent.atomic.AtomicBoolean * - **It must follow a design switch.** The operator can change the design from the * main window's menu while this window is open. * + * Every window but the main one also opens on the screen the operator is working on + * (issue #59): centred on the main window's screen, or where the operator moved it to + * earlier in the session — see [WindowPlacement] for the rule. + * * @param threadName names the thread in a stack dump, which is where one looks when a - * window will not close + * window will not close. It is also the key of the window's stored position, so + * renaming it forgets where the operator put the window. + * @param isMainWindow true for the main window only. It keeps the platform's placement + * and publishes its frame, which is what every other window is placed against. */ -class ComposeWindowHost(private val threadName: String) { +class ComposeWindowHost( + private val threadName: String, + private val isMainWindow: Boolean = false, +) { private val open = AtomicBoolean(false) private val darkModeState = mutableStateOf(false) @@ -61,6 +80,16 @@ class ComposeWindowHost(private val threadName: String) { @Volatile private var frame: ComposeWindow? = null + /** + * Where the operator moved this window to, for this session. Not persisted here: a + * stored position outlives the monitor layout it was made on, which is the very problem + * of issue #59. Keeping positions across restarts is the operator's opt-in, handled by + * [WindowPlacementOnScreen.memory] and used only on the very screen it was made on. Lives as long as the host, and the hosts are + * singletons, so it survives closing and reopening the window and a profile switch. + */ + @Volatile + private var movedTo: Point? = null + /** Whether the window is currently open, so a caller can tell without opening one. */ val isOpen: Boolean get() = open.get() @@ -216,9 +245,34 @@ class ComposeWindowHost(private val threadName: String) { return@application } + /* + * Decided once per opening, on the event thread this composition runs + * on — which is where the screens and the main window may be read. + * Null keeps the platform's placement: always for the main window, and + * for any window that opens before the main window exists. + */ + val placed = remember { + val width = widthDp.roundToInt() + val height = heightDp.roundToInt() + when { + /* + * The main window only ever uses a stored position; without + * one the platform places it, as before. + */ + isMainWindow -> WindowPlacementOnScreen.restore(threadName, width, height) + /* This session's move is newer than anything stored. */ + movedTo != null -> WindowPlacementOnScreen.place(movedTo, width, height) + else -> WindowPlacementOnScreen.restore(threadName, width, height) + ?: WindowPlacementOnScreen.place(null, width, height) + } + } + val windowState = rememberWindowState( - width = widthDp.dp, - height = heightDp.dp, + position = placed + ?.let { WindowPosition(it.x.dp, it.y.dp) } + ?: WindowPosition.PlatformDefault, + width = placed?.width?.dp ?: widthDp.dp, + height = placed?.height?.dp ?: heightDp.dp, ) /* @@ -227,11 +281,103 @@ class ComposeWindowHost(private val threadName: String) { * comfort: these tables are wider than the default window, so an * operator who widens it once must not have to do it again. */ + /* + * The last size and position this host cut the window down to. Neither + * is the operator's choice: the size must not be written back as the + * stored size, and the position must not count as a move. + */ + val fit = remember { FitRecord() } + if (onResized != null) { LaunchedEffect(windowState) { - snapshotFlow { windowState.size }.collect { size -> - if (size.width.isSpecified && size.height.isSpecified) { - onResized(size.width.value, size.height.value) + /* + * A secondary window skips the opening size: it may be the + * screen-clamped one, and writing that back would shrink the + * stored size for good because the window once opened on a + * smaller screen. The main window keeps writing it, as before, + * unless it was opened at a stored position, where the size may + * be clamped the same way. A size this host fitted the window to + * is skipped for the same reason. + */ + snapshotFlow { windowState.size } + .drop(if (isMainWindow && placed == null) 0 else 1) + .collect { size -> + if (size.width.isSpecified && size.height.isSpecified + && !fit.takeSize(size.width.value.roundToInt(), size.height.value.roundToInt()) + ) { + onResized(size.width.value, size.height.value) + } + } + } + } + + LaunchedEffect(windowState) { + /* + * One tracker per opening; the rule is in MoveTracker. An operator + * move is remembered for the session (secondary windows) and handed + * to the stored positions, which keep it only when the operator + * opted in. Only moves are stored, never the window manager's + * nudges, so a restored window cannot drift a little on every start. + */ + var tracker = MoveTracker(placed?.location) + snapshotFlow { windowState.position }.collect { position -> + if (position is WindowPosition.Absolute + && windowState.placement == ComposeWindowPlacement.Floating + && !windowState.isMinimized + ) { + val now = Point( + position.x.value.roundToInt(), + position.y.value.roundToInt(), + ) + val size = windowState.size + /* + * The host's own fit is a new anchor, not a move; the window + * manager may still nudge it within the tolerance. + */ + if (fit.takePosition(now)) { + tracker = MoveTracker(now) + return@collect + } + tracker.observe(now)?.let { moved -> + if (!isMainWindow) { + movedTo = moved + } + WindowPlacementOnScreen.store( + threadName, + moved, + if (size.width.isSpecified) size.width.value.roundToInt() else widthDp.roundToInt(), + if (size.height.isSpecified) size.height.value.roundToInt() else heightDp.roundToInt(), + ) + } + } + } + } + + LaunchedEffect(windowState) { + /* + * A window moved onto a smaller screen -- a laptop panel, a 640x480 + * field monitor -- is cut down to it, so no edge or button ends up out + * of reach. Only once it has rested: resizing in the middle of a drag + * would fight the window manager for the window. + */ + snapshotFlow { windowState.position to windowState.size }.collectLatest { (position, size) -> + delay(FIT_SETTLE_MILLIS) + if (position is WindowPosition.Absolute + && size.width.isSpecified && size.height.isSpecified + && windowState.placement == ComposeWindowPlacement.Floating + && !windowState.isMinimized + ) { + WindowPlacementOnScreen.fitted( + java.awt.Rectangle( + position.x.value.roundToInt(), + position.y.value.roundToInt(), + size.width.value.roundToInt(), + size.height.value.roundToInt(), + ), + )?.let { fitted -> + fit.record(fitted) + windowState.size = DpSize(fitted.width.dp, fitted.height.dp) + windowState.position = WindowPosition(fitted.x.dp, fitted.y.dp) } } } @@ -251,6 +397,9 @@ class ComposeWindowHost(private val threadName: String) { */ DisposableEffect(window) { frame = window + if (isMainWindow) { + WindowPlacementOnScreen.mainWindow = window + } /* * The only place the handle is dropped. Clearing it again in * the thread's finally block looks like a belt to this brace, @@ -259,7 +408,12 @@ class ComposeWindowHost(private val threadName: String) { * the late write would wipe a live handle and bring back the * dead button this whole method exists to remove. */ - onDispose { frame = null } + onDispose { + frame = null + if (WindowPlacementOnScreen.mainWindow === window) { + WindowPlacementOnScreen.mainWindow = null + } + } } CompositionLocalProvider( @@ -297,6 +451,126 @@ class ComposeWindowHost(private val threadName: String) { } } +/** + * How far a window must have gone from where it was opened to count as moved by the + * operator. + * + * Not zero, because window managers nudge a new window on their own — by the decoration, + * or to keep it clear of a panel — and that nudge would otherwise be remembered as the + * operator's choice. The window would then stop following the main window to another + * screen, and on X11 reopening it would drift by the decoration each time. + */ +internal const val MOVE_TOLERANCE_PX = 48 + +/** How long a window must rest after a move before it is cut down to a smaller screen. */ +private const val FIT_SETTLE_MILLIS = 500L + +/** + * What the host last cut a window down to, so the size and position it set itself are not + * taken for the operator's. Each is consumed by the first report that follows: a matching + * report is the fit echoing back, any other report is the operator's and clears it, so a + * stale record cannot swallow a later resize or move. Only touched on the window's event + * thread. + */ +internal class FitRecord { + private var pendingPosition: Point? = null + private var pendingWidth = 0 + private var pendingHeight = 0 + private var sizePending = false + + fun record(bounds: java.awt.Rectangle) { + pendingPosition = bounds.location + pendingWidth = bounds.width + pendingHeight = bounds.height + sizePending = true + } + + /** + * Whether [now] is the fitted position reported back. Compared with the move + * tolerance, because dp rounding under display scaling and a window manager's nudge + * both shift it slightly. + */ + fun takePosition(now: Point): Boolean { + val pending = pendingPosition ?: return false + pendingPosition = null + return !movedAway(pending, now) + } + + /** Whether this size is the fitted one reported back, allowing for dp rounding. */ + fun takeSize(width: Int, height: Int): Boolean { + if (!sizePending) { + return false + } + sizePending = false + return abs(width - pendingWidth) <= SIZE_ROUNDING_PX && abs(height - pendingHeight) <= SIZE_ROUNDING_PX + } + + private companion object { + const val SIZE_ROUNDING_PX = 2 + } +} + +/** Whether [now] is far enough from [opened] to be the operator's move rather than a nudge. */ +internal fun movedAway(opened: Point, now: Point): Boolean = + abs(now.x - opened.x) > MOVE_TOLERANCE_PX || abs(now.y - opened.y) > MOVE_TOLERANCE_PX + +/** + * Tells the operator's moves of one opened window from the window manager's, fed with every + * floating position the window reports. + * + * - **Placed by the host** ([placedAt] not null): the anchor is that placement. Reports + * within [MOVE_TOLERANCE_PX] of it are the window manager's nudge and are ignored. This + * also holds when the window was reopened at its remembered spot, so a decoration nudge + * cannot make it drift a little further on every opening. + * - **Once a report has left the tolerance**, the operator is moving the window, and from + * then on every report is taken — including a move back to near where it opened, which + * would otherwise leave the stale far-away spot remembered. + * - **Not placed by the host** (opened before the main window existed): the platform picks + * the spot, and it may report a provisional position before its real placement. So the + * anchor is not trusted until it has settled: every report replaces it until one lands + * within the tolerance of the previous one. A window manager's placement jump is + * therefore a new anchor, not a move. The price: a single far jump by the operator + * before any second report — a keyboard "move to other monitor" right after opening — + * is taken as the anchor too, and the window then simply opens on the main window's + * screen next time, which is the default anyway. A time-based settle was the + * alternative; it would need a clock in a rule that is otherwise pure, and a drag + * produces a stream of close reports that settles the anchor on its own. + * + * @param placedAt where the host placed the window, or null when the platform did + */ +internal class MoveTracker(placedAt: Point?) { + + private var anchor: Point? = placedAt + private var settled = placedAt != null + private var moving = false + + /** + * @param now the position the window reports + * @return the position to remember as the operator's choice, or null to remember nothing + */ + fun observe(now: Point): Point? { + if (moving) { + return now + } + + val current = anchor + if (!settled || current == null) { + if (current != null && !movedAway(current, now)) { + settled = true + } else { + anchor = now + } + return null + } + + if (movedAway(current, now)) { + moving = true + return now + } + return null + } +} + /** * The application icon, so these windows are not the only ones in the taskbar showing * the default Java cup. Null when the resource is missing rather than failing: a window diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt index a4f6ddc3..1196ed98 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt @@ -16,7 +16,7 @@ import androidx.compose.runtime.setValue */ object MainWindowHost { - private val host = ComposeWindowHost("main-window") + private val host = ComposeWindowHost("main-window", isMainWindow = true) @JvmStatic val isOpen: Boolean diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt index 90be29a2..fdcf2d1c 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt @@ -123,7 +123,7 @@ object OperatorProfilePickerWindow { val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL) dialog.title = kst4contest.view.i18n.CurrentStrings.get().pickerTitle dialog.size = DIALOG_SIZE - dialog.setLocationRelativeTo(null) + WindowPlacementOnScreen.centreOnMainWindowScreen(dialog) /* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */ dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE /* diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/PreferencesWindowPositionMemory.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/PreferencesWindowPositionMemory.kt new file mode 100644 index 00000000..9444b341 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/PreferencesWindowPositionMemory.kt @@ -0,0 +1,39 @@ +package kst4contest.view.compose + +import java.awt.Point +import java.awt.Rectangle +import kst4contest.model.ChatPreferences + +/** + * The stored window positions of one profile, gated by the operator's opt-in. + * + * The opt-in is read on every call, so applying the setting takes effect without a + * restart: off means nothing is restored and nothing is written. + * + * @param requestSave asks the layout autosave to write the preferences file + */ +class PreferencesWindowPositionMemory( + private val prefs: ChatPreferences, + private val requestSave: Runnable, +) : WindowPositionMemory { + + override fun load(windowId: String): StoredWindowPosition? { + if (!prefs.isGuiOptions_rememberWindowPositions()) { + return null + } + val p = prefs.getWindowPosition(windowId) ?: return null + return StoredWindowPosition(Point(p[0], p[1]), Rectangle(p[2], p[3], p[4], p[5])) + } + + override fun save(windowId: String, stored: StoredWindowPosition) { + if (!prefs.isGuiOptions_rememberWindowPositions()) { + return + } + prefs.setWindowPosition( + windowId, + stored.position.x, stored.position.y, + stored.screen.x, stored.screen.y, stored.screen.width, stored.screen.height, + ) + requestSave.run() + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/WindowPlacement.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/WindowPlacement.kt new file mode 100644 index 00000000..d6f5f514 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/WindowPlacement.kt @@ -0,0 +1,353 @@ +package kst4contest.view.compose + +import java.awt.GraphicsEnvironment +import java.awt.Point +import java.awt.Rectangle +import java.awt.Toolkit +import java.awt.Window + +/** + * One connected screen: its whole area and the part a window may use. + * + * @param bounds the screen in the shared desktop coordinate space. A screen left of or + * above the primary one has negative coordinates. + * @param usable [bounds] minus task bars, docks and panels + */ +data class ScreenArea(val bounds: Rectangle, val usable: Rectangle) + +/** + * A window position kept across restarts, together with the screen it was on. + * + * @param position the window's top-left corner in desktop coordinates + * @param screen the bounds of the screen the window was on when it was stored + */ +data class StoredWindowPosition(val position: Point, val screen: Rectangle) + +/** + * Where window positions are kept across restarts, when the operator opted in. + * + * The application sets it per profile; [load] answers null and [save] does nothing while + * the opt-in is off, so the hosts need not know about the setting. + */ +interface WindowPositionMemory { + fun load(windowId: String): StoredWindowPosition? + fun save(windowId: String, stored: StoredWindowPosition) +} + +/** + * Where a secondary window opens, decided without touching AWT (issue #59). + * + * The rule: a window the operator moved during this session reopens where it was moved to, + * as long as its title bar can still be reached on a connected screen — shifted just far + * enough to lie wholly within that screen's usable area. Otherwise it opens centred on the + * screen the main window is on. Either way its size is cut down to the usable area of the + * screen it lands on, so no button sits past the edge. + * + * The cut-down size is only the opening size. The host does not write it back, so the + * stored size survives a stay on a smaller screen; a resize the operator makes there is + * an ordinary resize and is saved. + * + * Before this, every secondary window got the platform's default position, which on a + * multi-monitor desk is the primary screen — the station map opened on a screen the + * operator was not looking at, or on one that was switched off. + * + * Pure on purpose: the rule is the part that can be wrong, and it needs screens that no + * test machine has. [WindowPlacementOnScreen] feeds it the real screens. + */ +object WindowPlacement { + + /** + * How much of the window's top edge counts as the title bar. A remembered position + * is only kept when this strip can be grabbed, because a window whose title bar is + * off-screen cannot be moved back by the operator. + */ + const val TITLE_BAR_HEIGHT = 32 + + /** + * How wide a piece of the title bar must be visible to count as reachable. Narrower + * windows need their whole width. + */ + const val MIN_VISIBLE_TITLE_WIDTH = 100 + + /** + * Where the window opens and how big it is. + * + * @param screens the connected screens; empty when there is no display + * @param mainWindow the main window's frame bounds, null while it does not exist + * @param remembered where the operator moved this window to during the session, or null + * @param width the size the window asks for + * @param height the size the window asks for + * @return the frame bounds to open with, or null to leave placement to the platform — + * which is the behaviour before the main window exists + */ + fun place( + screens: List, + mainWindow: Rectangle?, + remembered: Point?, + width: Int, + height: Int, + ): Rectangle? { + if (screens.isEmpty()) { + return null + } + + if (remembered != null) { + val screen = screenShowingTitleBar(screens, remembered, width) + if (screen != null) { + return keptWithin(usableArea(screen), remembered, width, height) + } + } + + if (mainWindow == null) { + return null + } + + return centredOn(usableArea(screenOf(screens, mainWindow)), width, height) + } + + /** + * Where a window opens from a position stored in an earlier run. + * + * Only on the very screen it was stored on: same place in the desktop, same size. A + * changed layout -- a monitor unplugged, rearranged or switched to another resolution -- + * makes the stored position meaningless, and the caller falls back to the session rule. + * On that screen the window is kept within the usable area, like a session position. + * + * @return the frame bounds to open with, or null when the stored screen is not connected + */ + fun restore(screens: List, stored: StoredWindowPosition, width: Int, height: Int): Rectangle? { + val screen = screens.firstOrNull { it.bounds == stored.screen } ?: return null + return keptWithin(usableArea(screen), stored.position, width, height) + } + + /** + * The window cut down to the usable area of the screen it is on, or null when it fits. + * + * For a window that is already open and was moved onto a smaller screen -- a laptop + * panel, a 640x480 field monitor. Opening sizes are cut by [place] and [restore]; this + * covers the move. Shifted the least distance into the usable area, like [keptWithin]. + */ + fun fitted(screens: List, window: Rectangle): Rectangle? { + if (screens.isEmpty()) { + return null + } + val usable = usableArea(screenOf(screens, window)) + if (window.width <= usable.width && window.height <= usable.height) { + return null + } + return keptWithin(usable, window.location, window.width, window.height) + } + + /** + * A minimum size no larger than the smallest usable area: AWT enforces the minimum even when it does + * not fit the screen, which leaves the window's edges unreachable. + */ + fun minimumWithin(screens: List, width: Int, height: Int): java.awt.Dimension { + if (screens.isEmpty()) { + return java.awt.Dimension(width, height) + } + val areas = screens.map(::usableArea) + return java.awt.Dimension( + clamp(width, areas.minOf { it.width }), + clamp(height, areas.minOf { it.height }), + ) + } + + /** + * The window centred on [usable], cut down to fit it. + */ + fun centredOn(usable: Rectangle, width: Int, height: Int): Rectangle { + val w = clamp(width, usable.width) + val h = clamp(height, usable.height) + return Rectangle( + usable.x + (usable.width - w) / 2, + usable.y + (usable.height - h) / 2, + w, + h, + ) + } + + /** + * The window at [position], cut down to fit [usable] and shifted the least distance + * that puts it wholly inside. The cut comes first, so the shift always has room. + */ + fun keptWithin(usable: Rectangle, position: Point, width: Int, height: Int): Rectangle { + val w = clamp(width, usable.width) + val h = clamp(height, usable.height) + return Rectangle( + position.x.coerceIn(usable.x, usable.x + usable.width - w), + position.y.coerceIn(usable.y, usable.y + usable.height - h), + w, + h, + ) + } + + /** + * The screen a window is on: the one holding its centre, else the one it overlaps + * most, else the first. The centre decides first because a window straddling two + * screens is "on" the one the operator sees most of it on, and the centre says so + * without an area comparison in the common case. + */ + fun screenOf(screens: List, window: Rectangle): ScreenArea { + val centre = Point(window.x + window.width / 2, window.y + window.height / 2) + screens.firstOrNull { it.bounds.contains(centre) }?.let { return it } + + return screens.maxByOrNull { overlapArea(it.bounds, window) } + ?.takeIf { overlapArea(it.bounds, window) > 0 } + ?: screens.first() + } + + /** + * The screen on which the title bar of a window at [position] can be grabbed, or null + * when it cannot be grabbed on any — the screen was unplugged, or the window was + * pushed past an edge. + */ + private fun screenShowingTitleBar( + screens: List, + position: Point, + width: Int, + ): ScreenArea? { + val titleBar = Rectangle(position.x, position.y, width.coerceAtLeast(1), TITLE_BAR_HEIGHT) + val neededWidth = minOf(MIN_VISIBLE_TITLE_WIDTH, titleBar.width) + val neededHeight = TITLE_BAR_HEIGHT / 2 + + return screens + .filter { + val visible = usableArea(it).intersection(titleBar) + !visible.isEmpty && visible.width >= neededWidth && visible.height >= neededHeight + } + .maxByOrNull { overlapArea(usableArea(it), titleBar) } + } + + /** + * The usable area, or the whole screen when the insets leave nothing — a broken inset + * report must not shrink every window to a single pixel. + */ + private fun usableArea(screen: ScreenArea): Rectangle = + if (screen.usable.isEmpty) screen.bounds else screen.usable + + private fun clamp(wanted: Int, available: Int): Int = + wanted.coerceAtMost(available).coerceAtLeast(1) + + private fun overlapArea(a: Rectangle, b: Rectangle): Long { + val overlap = a.intersection(b) + return if (overlap.isEmpty) 0L else overlap.width.toLong() * overlap.height + } +} + +/** + * The AWT side of [WindowPlacement]: the real screens and the real main window. + * + * Kept thin so that what no headless test can see is only reading the screens and + * handing them over. Call it on the AWT event thread, like everything that touches a + * window. + */ +object WindowPlacementOnScreen { + + /** + * The main window's frame, published by its host while it is open. Read only for its + * bounds; nothing here moves it. + */ + @Volatile + var mainWindow: Window? = null + + /** + * The positions kept across restarts, set by the application for the active profile. + * Null in tests and before startup, which means nothing is restored or stored. + */ + @JvmStatic + @Volatile + var memory: WindowPositionMemory? = null + + /** + * Where a window opens from its stored position, or null when none applies. + */ + fun restore(windowId: String, width: Int, height: Int): Rectangle? { + val stored = memory?.load(windowId) ?: return null + return WindowPlacement.restore(connectedScreens(), stored, width, height) + } + + /** + * The minimum size a window may enforce, capped to the smallest connected screen. The + * smallest rather than the current one, because the window may be moved there later + * and must then still be cut down to fit. + */ + fun minimumSizeOnScreens(width: Int, height: Int): java.awt.Dimension = + WindowPlacement.minimumWithin(connectedScreens(), width, height) + + /** + * The window cut down to the screen it is now on, or null when it fits. + */ + fun fitted(window: Rectangle): Rectangle? = WindowPlacement.fitted(connectedScreens(), window) + + /** + * Stores the position the operator moved a window to, with the screen it is now on. + */ + fun store(windowId: String, position: Point, width: Int, height: Int) { + val target = memory ?: return + val screens = connectedScreens() + if (screens.isEmpty()) { + return + } + val screen = WindowPlacement.screenOf(screens, Rectangle(position.x, position.y, width, height)) + target.save(windowId, StoredWindowPosition(position, screen.bounds)) + } + + /** The connected screens, the default one first. Empty without a display. */ + fun connectedScreens(): List = runCatching { + if (GraphicsEnvironment.isHeadless()) { + return emptyList() + } + + val environment = GraphicsEnvironment.getLocalGraphicsEnvironment() + val toolkit = Toolkit.getDefaultToolkit() + val default = environment.defaultScreenDevice + + environment.screenDevices + .sortedByDescending { it == default } + .map { device -> + val configuration = device.defaultConfiguration + val bounds = configuration.bounds + val insets = toolkit.getScreenInsets(configuration) + ScreenArea( + bounds = bounds, + usable = Rectangle( + bounds.x + insets.left, + bounds.y + insets.top, + bounds.width - insets.left - insets.right, + bounds.height - insets.top - insets.bottom, + ), + ) + } + }.getOrDefault(emptyList()) + + /** + * The main window's bounds, or null while there is no main window on screen. + */ + fun mainWindowBounds(): Rectangle? = + mainWindow?.takeIf { it.isDisplayable && it.isShowing }?.bounds + + /** + * Where a secondary window opens, from the live screens and main window. + * + * @return null to leave placement to the platform + */ + fun place(remembered: Point?, width: Int, height: Int): Rectangle? = + WindowPlacement.place(connectedScreens(), mainWindowBounds(), remembered, width, height) + + /** + * Centres a dialog on the main window's screen, cut down to fit. + * + * Before the main window exists — the profile picker at startup — the dialog is + * centred the way it always was, on the primary screen. + */ + @JvmStatic + fun centreOnMainWindowScreen(dialog: Window) { + val placed = place(null, dialog.width, dialog.height) + if (placed == null) { + dialog.setLocationRelativeTo(null) + } else { + dialog.bounds = placed + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt index c413335f..e4f90743 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.map +import kst4contest.view.compose.WindowPlacementOnScreen import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -61,7 +62,13 @@ object StationMapWindow { onResized = onResized, ) { _ -> val window = this.window - androidx.compose.runtime.LaunchedEffect(window) { window?.minimumSize = java.awt.Dimension(900, 600) } + /* + * Never larger than the smallest screen: AWT enforces a minimum size even past + * the edge, and some operators work on 640x480. + */ + androidx.compose.runtime.LaunchedEffect(window) { + window?.minimumSize = WindowPlacementOnScreen.minimumSizeOnScreens(900, 600) + } val tileFetcher = remember { TileFetcher() } /* diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt index 9ebafe54..e147d86f 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt @@ -76,5 +76,12 @@ fun GuiOptionsTab(state: GuiOptionsTabState) { ) } + /* Appended after the language for the same reason: no section position moves. */ + Form.section(strings.guiWindows) { + Form.check(strings.guiRememberWindowPositions, state.guiOptions_rememberWindowPositions) { + state.guiOptions_rememberWindowPositions = it + } + } + } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt index 79666641..c2541b7d 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt @@ -42,6 +42,8 @@ class GuiOptionsTabState( get() = languageStore.stored set(value) { languageStore.use(value) } + var guiOptions_rememberWindowPositions: Boolean by draft.setting({ prefs.isGuiOptions_rememberWindowPositions() }) { prefs.setGuiOptions_rememberWindowPositions(it) } + var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns() }) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(it) } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/FitRecordTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/FitRecordTest.kt new file mode 100644 index 00000000..8de23548 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/FitRecordTest.kt @@ -0,0 +1,54 @@ +package kst4contest.view.compose + +import java.awt.Point +import java.awt.Rectangle +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The size and position the host fitted a window to are not the operator's: the first + * matching report is swallowed, and nothing after it. + */ +class FitRecordTest { + + private val fitted = Rectangle(1920, 0, 640, 480) + + @Test + fun `nothing counts as fitted before a fit`() { + val fit = FitRecord() + + assertFalse(fit.takeSize(640, 480)) + assertFalse(fit.takePosition(Point(1920, 0))) + } + + @Test + fun `the fit echoing back is recognised despite rounding and a nudge`() { + val fit = FitRecord() + fit.record(fitted) + + assertTrue(fit.takeSize(641, 479)) + assertTrue(fit.takePosition(Point(1920 + 10, 0 + 28))) + } + + @Test + fun `a fit is consumed once, so a later identical resize or move is the operator's`() { + val fit = FitRecord() + fit.record(fitted) + fit.takeSize(640, 480) + fit.takePosition(Point(1920, 0)) + + assertFalse(fit.takeSize(640, 480)) + assertFalse(fit.takePosition(Point(1920, 0))) + } + + @Test + fun `an operator report in between clears the record`() { + val fit = FitRecord() + fit.record(fitted) + + assertFalse(fit.takeSize(900, 700)) + assertFalse(fit.takePosition(Point(100, 100))) + assertFalse(fit.takeSize(640, 480)) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MoveTrackerTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MoveTrackerTest.kt new file mode 100644 index 00000000..15a35a84 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MoveTrackerTest.kt @@ -0,0 +1,65 @@ +package kst4contest.view.compose + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.awt.Point + +/** + * Which reported positions of an opened window count as the operator's move (issue #59). + */ +class MoveTrackerTest { + + private val opened = Point(560, 220) + + private fun offset(dx: Int, dy: Int = 0) = Point(opened.x + dx, opened.y + dy) + + @Test + fun `a nudge around the placed position is not remembered`() { + val tracker = MoveTracker(opened) + + assertNull(tracker.observe(offset(0, 30))) + assertNull(tracker.observe(offset(-MOVE_TOLERANCE_PX))) + } + + @Test + fun `a move away from the placed position is remembered`() { + val tracker = MoveTracker(opened) + + assertEquals(offset(100), tracker.observe(offset(100))) + } + + /** The review case: open at O, drag to O+100, drag back to O+10. */ + @Test + fun `a move back to near the opening spot replaces the far one`() { + val tracker = MoveTracker(opened) + + assertEquals(offset(100), tracker.observe(offset(100))) + assertEquals(offset(10), tracker.observe(offset(10))) + assertEquals(opened, tracker.observe(opened)) + } + + @Test + fun `an unplaced window takes the platform's placement jump as its anchor`() { + val tracker = MoveTracker(null) + + /* Provisional position, then the window manager's real placement on another screen. */ + assertNull(tracker.observe(Point(0, 0))) + assertNull(tracker.observe(Point(2400, 300))) + /* A decoration nudge settles it. */ + assertNull(tracker.observe(Point(2400, 330))) + /* Now the operator drags. */ + assertEquals(Point(2600, 330), tracker.observe(Point(2600, 330))) + } + + @Test + fun `an unplaced window settled by the start of a drag remembers the rest of it`() { + val tracker = MoveTracker(null) + + assertNull(tracker.observe(Point(500, 300))) + assertNull(tracker.observe(Point(510, 300))) + assertNull(tracker.observe(Point(540, 300))) + assertEquals(Point(700, 300), tracker.observe(Point(700, 300))) + assertEquals(Point(520, 300), tracker.observe(Point(520, 300))) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/PreferencesWindowPositionMemoryTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/PreferencesWindowPositionMemoryTest.kt new file mode 100644 index 00000000..da5723a1 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/PreferencesWindowPositionMemoryTest.kt @@ -0,0 +1,49 @@ +package kst4contest.view.compose + +import java.awt.Point +import java.awt.Rectangle +import java.nio.file.Path +import kst4contest.model.ChatPreferences +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +/** + * The opt-in gate of the stored window positions: off means nothing is restored and + * nothing is written, not even a request to save. + */ +class PreferencesWindowPositionMemoryTest { + + @TempDir + lateinit var directory: Path + + private val stored = StoredWindowPosition(Point(2100, 40), Rectangle(1920, 0, 2560, 1440)) + + @Test + fun `with the opt-in off nothing is written or restored`() { + val prefs = ChatPreferences(directory.resolve("preferences.xml")) + prefs.setWindowPosition("Map", 1, 2, 0, 0, 10, 10) + var saves = 0 + val memory = PreferencesWindowPositionMemory(prefs) { saves++ } + + memory.save("settings-window", stored) + + assertEquals(0, saves) + assertNull(prefs.getWindowPosition("settings-window")) + assertNull(memory.load("Map")) + } + + @Test + fun `with the opt-in on a position is stored, saved and restored`() { + val prefs = ChatPreferences(directory.resolve("preferences.xml")) + prefs.setGuiOptions_rememberWindowPositions(true) + var saves = 0 + val memory = PreferencesWindowPositionMemory(prefs) { saves++ } + + memory.save("Map", stored) + + assertEquals(1, saves) + assertEquals(stored, memory.load("Map")) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/WindowPlacementTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/WindowPlacementTest.kt new file mode 100644 index 00000000..0def2e0e --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/WindowPlacementTest.kt @@ -0,0 +1,252 @@ +package kst4contest.view.compose + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.awt.Point +import java.awt.Rectangle + +/** + * Where a secondary window opens (issue #59). + * + * The screens here are made up, which is the point: no test machine has a second + * monitor left of the primary one, or one that was just unplugged. + */ +class WindowPlacementTest { + + /** 1920x1080 primary with a 40 px task bar at the bottom. */ + private val primary = ScreenArea( + bounds = Rectangle(0, 0, 1920, 1080), + usable = Rectangle(0, 0, 1920, 1040), + ) + + /** 2560x1440 to the right of the primary, without panels. */ + private val right = ScreenArea( + bounds = Rectangle(1920, 0, 2560, 1440), + usable = Rectangle(1920, 0, 2560, 1440), + ) + + /** 1280x1024 left of the primary, so with negative coordinates, and a 30 px top bar. */ + private val left = ScreenArea( + bounds = Rectangle(-1280, 0, 1280, 1024), + usable = Rectangle(-1280, 30, 1280, 994), + ) + + @Test + fun `a window opens centred on the screen the main window is on`() { + val main = Rectangle(2200, 100, 1200, 800) + + val placed = WindowPlacement.place(listOf(primary, right), main, null, 800, 600) + + assertEquals(Rectangle(1920 + 880, 420, 800, 600), placed) + } + + @Test + fun `the main window on the primary screen keeps the window there`() { + val main = Rectangle(100, 100, 1200, 800) + + val placed = WindowPlacement.place(listOf(primary, right), main, null, 800, 600) + + assertEquals(Rectangle(560, 220, 800, 600), placed) + } + + @Test + fun `a screen left of the primary one works with negative coordinates`() { + val main = Rectangle(-1200, 50, 1000, 700) + + val placed = WindowPlacement.place(listOf(primary, left), main, null, 800, 600) + + assertEquals(Rectangle(-1280 + 240, 30 + 197, 800, 600), placed) + } + + @Test + fun `a main window straddling two screens counts for the one holding its centre`() { + val main = Rectangle(1500, 100, 1200, 800) + + val placed = WindowPlacement.place(listOf(primary, right), main, null, 800, 600) + + assertEquals(right.bounds, WindowPlacement.screenOf(listOf(primary, right), main).bounds) + assertEquals(1920 + 880, placed!!.x) + } + + @Test + fun `a window larger than the screen is cut down to its usable area`() { + val main = Rectangle(100, 100, 1200, 800) + + val placed = WindowPlacement.place(listOf(primary), main, null, 2500, 1300) + + assertEquals(Rectangle(0, 0, 1920, 1040), placed) + } + + @Test + fun `only the oversized dimension is cut`() { + val main = Rectangle(-1200, 50, 1000, 700) + + val placed = WindowPlacement.place(listOf(primary, left), main, null, 800, 2000) + + assertEquals(Rectangle(-1280 + 240, 30, 800, 994), placed) + } + + @Test + fun `a window moved during the session reopens where it was moved to`() { + val main = Rectangle(100, 100, 1200, 800) + + val placed = WindowPlacement.place(listOf(primary, right), main, Point(3000, 200), 800, 600) + + assertEquals(Rectangle(3000, 200, 800, 600), placed) + } + + @Test + fun `a remembered position on an unplugged screen falls back to the main window's screen`() { + val main = Rectangle(100, 100, 1200, 800) + + /* Moved to the right-hand screen, which is no longer connected. */ + val placed = WindowPlacement.place(listOf(primary), main, Point(3000, 200), 800, 600) + + assertEquals(Rectangle(560, 220, 800, 600), placed) + } + + @Test + fun `a remembered position with the title bar above the screen falls back`() { + val main = Rectangle(100, 100, 1200, 800) + + val placed = WindowPlacement.place(listOf(primary), main, Point(400, -200), 800, 600) + + assertEquals(Rectangle(560, 220, 800, 600), placed) + } + + @Test + fun `a remembered position with the title bar under the task bar falls back`() { + val main = Rectangle(100, 100, 1200, 800) + + val placed = WindowPlacement.place(listOf(primary), main, Point(400, 1035), 800, 600) + + assertEquals(Rectangle(560, 220, 800, 600), placed) + } + + @Test + fun `a title bar that is mostly past the edge but still grabbable is pulled back in`() { + val main = Rectangle(100, 100, 1200, 800) + + /* 120 px of the title bar remain on the primary screen: the spot counts, the overhang goes. */ + val placed = WindowPlacement.place(listOf(primary), main, Point(1800, 300), 800, 600) + + assertEquals(Rectangle(1920 - 800, 300, 800, 600), placed) + } + + @Test + fun `a remembered window hanging into the task bar is lifted above it`() { + val main = Rectangle(100, 100, 1200, 800) + + val placed = WindowPlacement.place(listOf(primary), main, Point(400, 700), 800, 600) + + assertEquals(Rectangle(400, 1040 - 600, 800, 600), placed) + } + + @Test + fun `a remembered window that fits stays exactly where it was`() { + val placed = WindowPlacement.place(listOf(primary, left), null, Point(-1100, 200), 800, 600) + + assertEquals(Rectangle(-1100, 200, 800, 600), placed) + } + + @Test + fun `a title bar with too little left to grab falls back`() { + val main = Rectangle(100, 100, 1200, 800) + + /* Only 20 px of the title bar remain on the primary screen. */ + val placed = WindowPlacement.place(listOf(primary), main, Point(1900, 300), 800, 600) + + assertEquals(Rectangle(560, 220, 800, 600), placed) + } + + @Test + fun `a remembered window is cut down to the screen it is on`() { + val placed = WindowPlacement.place(listOf(primary, left), null, Point(-1200, 100), 2000, 600) + + assertEquals(Rectangle(-1280, 100, 1280, 600), placed) + } + + @Test + fun `without a main window the platform decides`() { + assertNull(WindowPlacement.place(listOf(primary, right), null, null, 800, 600)) + } + + @Test + fun `without a display the platform decides`() { + assertNull(WindowPlacement.place(emptyList(), Rectangle(0, 0, 100, 100), Point(10, 10), 800, 600)) + } + + @Test + fun `a main window off every screen falls back to the first screen`() { + val main = Rectangle(9000, 9000, 800, 600) + + assertEquals(primary, WindowPlacement.screenOf(listOf(primary, right), main)) + } + + @Test + fun `empty insets leave the whole screen usable`() { + val broken = ScreenArea(Rectangle(0, 0, 1920, 1080), Rectangle(0, 0, 0, 0)) + + val placed = WindowPlacement.place(listOf(broken), Rectangle(0, 0, 100, 100), null, 800, 600) + + assertEquals(Rectangle(560, 240, 800, 600), placed) + } + + @Test + fun `a window manager's nudge is not taken for the operator's move`() { + assertFalse(movedAway(Point(560, 220), Point(560, 252))) + assertFalse(movedAway(Point(560, 220), Point(560 - MOVE_TOLERANCE_PX, 220))) + assertTrue(movedAway(Point(560, 220), Point(560, 220 + MOVE_TOLERANCE_PX + 1))) + assertTrue(movedAway(Point(560, 220), Point(3000, 220))) + } + + @Test + fun `a stored position is restored on the very screen it was stored on`() { + val stored = StoredWindowPosition(Point(2500, 300), right.bounds) + + assertEquals(Rectangle(2500, 300, 800, 600), WindowPlacement.restore(listOf(primary, right), stored, 800, 600)) + } + + @Test + fun `a stored position is kept within the usable area of its screen`() { + val stored = StoredWindowPosition(Point(1500, 900), primary.bounds) + + assertEquals(Rectangle(1120, 440, 800, 600), WindowPlacement.restore(listOf(primary), stored, 800, 600)) + } + + @Test + fun `a stored position is not used when its screen is gone or changed`() { + val unplugged = StoredWindowPosition(Point(2500, 300), right.bounds) + val otherResolution = StoredWindowPosition(Point(100, 100), Rectangle(0, 0, 2560, 1440)) + + assertNull(WindowPlacement.restore(listOf(primary, left), unplugged, 800, 600)) + assertNull(WindowPlacement.restore(listOf(primary), otherResolution, 800, 600)) + } + + /** A 640x480 field monitor right of the primary screen. */ + private val tiny = ScreenArea( + bounds = Rectangle(1920, 0, 640, 480), + usable = Rectangle(1920, 0, 640, 480), + ) + + @Test + fun `a window moved onto a smaller screen is cut down to it`() { + val moved = Rectangle(1950, 20, 1000, 800) + + assertEquals(Rectangle(1920, 0, 640, 480), WindowPlacement.fitted(listOf(primary, tiny), moved)) + } + + @Test + fun `a window that fits its screen is left alone`() { + assertNull(WindowPlacement.fitted(listOf(primary, tiny), Rectangle(100, 100, 800, 600))) + } + + @Test + fun `a minimum size is capped to the smallest connected screen`() { + assertEquals(java.awt.Dimension(640, 480), WindowPlacement.minimumWithin(listOf(primary, tiny), 900, 600)) + assertEquals(java.awt.Dimension(900, 600), WindowPlacement.minimumWithin(listOf(primary), 900, 600)) + } +} diff --git a/core/src/main/java/kst4contest/model/ChatPreferences.java b/core/src/main/java/kst4contest/model/ChatPreferences.java index d6c40d1c..ed4072cd 100644 --- a/core/src/main/java/kst4contest/model/ChatPreferences.java +++ b/core/src/main/java/kst4contest/model/ChatPreferences.java @@ -435,6 +435,21 @@ public class ChatPreferences { private boolean GUI_darkModeActive = false; private boolean GUI_darkModeActiveByDefault = false; + /** + * Opt-in: windows reopen across restarts where the operator last moved them, on the + * same screen. Off by default, because a stored position outlives the monitor layout + * it was made on; the positions are only used when that exact screen is connected. + */ + private boolean guiOptions_rememberWindowPositions = false; + + /** + * Last operator-chosen window positions by window id, each as + * {x, y, screenX, screenY, screenWidth, screenHeight} in desktop coordinates. + */ + private final Map windowPositions = new LinkedHashMap<>(); + + private static final String TAG_WINDOW_POSITION = "windowPosition"; + private double[] GUIscn_ChatwindowMainSceneSizeHW = new double[] {768, 1234}; private double[] GUIclusterAndQSOMonStage_SceneSizeHW = new double[] {700, 500}; private double[] GUIstage_updateStage_SceneSizeHW = new double[] {580, 480}; @@ -2223,6 +2238,10 @@ public class ChatPreferences { guiOptions_darkModeActiveByDefault.setTextContent(this.GUI_darkModeActiveByDefault + ""); guiSaveableOptions.appendChild(guiOptions_darkModeActiveByDefault); + Element guiOptions_rememberWindowPositions = doc.createElement("guiOptions_rememberWindowPositions"); + guiOptions_rememberWindowPositions.setTextContent(String.valueOf(this.guiOptions_rememberWindowPositions)); + guiSaveableOptions.appendChild(guiOptions_rememberWindowPositions); + // --- GUImainWindowRightSplitPane_dividerposition (2 dividers => 2 values) --- NEW ensureMainWindowRightSplitPaneDividerPositions(2); @@ -2305,6 +2324,7 @@ public class ChatPreferences { guiOptions.appendChild(GUIstationMapClusteringEnabled); appendTableColumnWidths(doc, guiOptions); + appendWindowPositions(doc, guiOptions); /**************************************************************************************** ****************************** now write this XML! ************************************* @@ -2357,6 +2377,8 @@ public class ChatPreferences { updateLayoutElements(document, guiOptions); removeDirectChildren(guiOptions, TAG_TABLE_COLUMN_WIDTH); appendTableColumnWidths(document, guiOptions); + removeDirectChildren(guiOptions, TAG_WINDOW_POSITION); + appendWindowPositions(document, guiOptions); writeDocumentAtomically(document); return true; } catch (ParserConfigurationException | SAXException | IOException | TransformerException exception) { @@ -3227,6 +3249,8 @@ public class ChatPreferences { this.setGUIpnl_directedMSGWin_dividerpositionDefault(csvStringToDoubleArray(s5)); } + readWindowPositions(element); + tableColumnWidths.clear(); NodeList widthElements = element.getElementsByTagName(TAG_TABLE_COLUMN_WIDTH); for (int widthIndex = 0; widthIndex < widthElements.getLength(); widthIndex++) { @@ -3268,6 +3292,9 @@ public class ChatPreferences { // Added in later versions: dark mode flags this.GUI_darkModeActive = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActive, "guiOptions_darkModeActive"); this.GUI_darkModeActiveByDefault = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActiveByDefault, "guiOptions_darkModeActiveByDefault"); + + // Added in release 1.50: opt-in for window positions across restarts. + this.guiOptions_rememberWindowPositions = getBoolean(guiSaveableOptionsEl, false, "guiOptions_rememberWindowPositions"); } @@ -3405,6 +3432,77 @@ public class ChatPreferences { this.GUI_darkModeActiveByDefault = GUI_darkModeActiveByDefault; } + public boolean isGuiOptions_rememberWindowPositions() { + return guiOptions_rememberWindowPositions; + } + + public void setGuiOptions_rememberWindowPositions(boolean guiOptions_rememberWindowPositions) { + this.guiOptions_rememberWindowPositions = guiOptions_rememberWindowPositions; + } + + /** + * Returns the stored position of one window. + * + * @param windowId stable window identifier + * @return a copy of {x, y, screenX, screenY, screenWidth, screenHeight}, or null when + * nothing usable is stored + */ + public synchronized int[] getWindowPosition(String windowId) { + int[] stored = windowPositions.get(windowId); + return stored == null ? null : stored.clone(); + } + + /** + * Updates one window position in memory. Persistence is coordinated by the layout + * autosave layer, like the table column widths. + */ + public synchronized void setWindowPosition(String windowId, int x, int y, + int screenX, int screenY, int screenWidth, int screenHeight) { + if (isValidWindowId(windowId) && screenWidth > 0 && screenHeight > 0) { + windowPositions.put(windowId, new int[] { x, y, screenX, screenY, screenWidth, screenHeight }); + } + } + + private static boolean isValidWindowId(String windowId) { + return windowId != null && !windowId.isBlank(); + } + + private void appendWindowPositions(Document document, Element guiOptions) { + for (Map.Entry entry : windowPositions.entrySet()) { + int[] p = entry.getValue(); + Element element = document.createElement(TAG_WINDOW_POSITION); + element.setAttribute("windowId", entry.getKey()); + element.setAttribute("x", String.valueOf(p[0])); + element.setAttribute("y", String.valueOf(p[1])); + element.setAttribute("screenX", String.valueOf(p[2])); + element.setAttribute("screenY", String.valueOf(p[3])); + element.setAttribute("screenWidth", String.valueOf(p[4])); + element.setAttribute("screenHeight", String.valueOf(p[5])); + guiOptions.appendChild(element); + } + } + + private void readWindowPositions(Element guiOptions) { + windowPositions.clear(); + NodeList elements = guiOptions.getElementsByTagName(TAG_WINDOW_POSITION); + for (int index = 0; index < elements.getLength(); index++) { + if (!(elements.item(index) instanceof Element element)) { + continue; + } + try { + setWindowPosition(element.getAttribute("windowId"), + Integer.parseInt(element.getAttribute("x")), + Integer.parseInt(element.getAttribute("y")), + Integer.parseInt(element.getAttribute("screenX")), + Integer.parseInt(element.getAttribute("screenY")), + Integer.parseInt(element.getAttribute("screenWidth")), + Integer.parseInt(element.getAttribute("screenHeight"))); + } catch (NumberFormatException ignored) { + // A damaged entry is dropped; the window then opens by the default rule. + } + } + } + /** * * If the file-reading goes wrong, set the defaults diff --git a/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java b/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java index 0e462a14..f46d9f46 100644 --- a/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java +++ b/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java @@ -8,7 +8,9 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -126,6 +128,51 @@ class ChatPreferencesLayoutPersistenceTest { restored.getTableColumnWidth("qso-other-monitor", "call-tx").orElseThrow()); } + @Test + void windowPositionsAndTheirOptInSurviveFullXmlRoundTrip() { + Path preferencesFile = temporaryDirectory.resolve("windows.xml"); + ChatPreferences written = preferencesAt(preferencesFile); + written.setGuiOptions_rememberWindowPositions(true); + written.setWindowPosition("Map", 2100, -40, 1920, -200, 2560, 1440); + + assertTrue(written.writePreferencesToXmlFile()); + + ChatPreferences restored = preferencesAt(preferencesFile); + assertTrue(restored.readPreferencesFromXmlFile()); + assertTrue(restored.isGuiOptions_rememberWindowPositions()); + assertArrayEquals(new int[] { 2100, -40, 1920, -200, 2560, 1440 }, restored.getWindowPosition("Map")); + assertNull(restored.getWindowPosition("settings-window")); + } + + @Test + void windowPositionsAreWrittenByTheLayoutAutosaveAndTheOptInDefaultsOff() throws IOException { + Path preferencesFile = temporaryDirectory.resolve("windows-layout.xml"); + Files.writeString(preferencesFile, """ + + + 8 + + + + + + """); + + ChatPreferences preferences = preferencesAt(preferencesFile); + assertTrue(preferences.readPreferencesFromXmlFile()); + assertFalse(preferences.isGuiOptions_rememberWindowPositions()); + assertNull(preferences.getWindowPosition("broken")); + preferences.setWindowPosition("main-window", 5, 6, 0, 0, 1920, 1080); + + assertTrue(preferences.writeLayoutPreferencesToXmlFile()); + + ChatPreferences restored = preferencesAt(preferencesFile); + assertTrue(restored.readPreferencesFromXmlFile()); + assertArrayEquals(new int[] { 1, 2, 0, 0, 10, 10 }, restored.getWindowPosition("Map")); + assertArrayEquals(new int[] { 5, 6, 0, 0, 1920, 1080 }, restored.getWindowPosition("main-window")); + assertEquals(1, Files.readString(preferencesFile).split("windowId=\"Map\"", -1).length - 1); + } + private ChatPreferences preferencesAt(Path preferencesFile) { return new ChatPreferences(preferencesFile); } diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 4e3ee3fb..8729ed4e 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -64,6 +64,7 @@ A batch of changes belongs in one `SimpleRoster.mutate(...)`, not one call per e - Stored widths take precedence. Without a usable entry, a managed column is sized once when meaningful table data first becomes available. Message and similar free-text columns use a flexible initial width instead of following the longest value. - Main-window and separate-monitor DXCluster/QSO tables use distinct layout IDs even though they share the underlying message stores. - Window sizes and positions, relevant divider positions and managed column widths are selectively autosaved after a short debounce. A pending write is flushed during application shutdown. +- Window positions are persisted only as an **opt-in** (`guiOptions_rememberWindowPositions`, GUI tab, off by default; issue #59). Every secondary window and every `ComposeAlert`/profile-picker dialog opens centred on the screen the main window is on (`WindowPlacement`, fed by `WindowPlacementOnScreen`). A window the operator moved during the running session reopens there (shifted wholly into that screen's usable area), held in its `ComposeWindowHost` and tracked by `MoveTracker`; the remembered spot is dropped in favour of the main window's screen when its title bar cannot be reached on any connected screen. With the opt-in, operator moves (never window-manager nudges) are also stored per window id (the host's thread name) as `windowPosition` elements with the screen bounds they were made on, written by the layout autosave; a stored position is restored only when a screen with exactly those bounds is connected (`WindowPlacement.restore`), for the main window too. Order for secondary windows: session move, then stored position, then main window's screen. Opening sizes are clamped to the target screen's usable area without writing the clamped size back; an open window that rests on a screen too small for it is cut down (`WindowPlacement.fitted`), and enforced minimum sizes are capped to the smallest connected screen. Without a stored position the main window keeps the platform's placement; before it exists (update window, profile picker at startup) the platform decides as before. `GUIstationMapStagePositionXY` is an unused JavaFX leftover and must not be revived for this. - Selective layout writes update the XML already on disk, preserve unknown XML nodes and must not persist unconfirmed functional settings from the current UI. **Save Settings** remains the full settings writer and includes the current layout. - Full and selective writes are synchronized and replace `preferences.xml` atomically. Missing, unknown or malformed width entries do not prevent loading and fall back to initial sizing. - Older configuration files require no migration. Older KST4Contest versions can ignore the additional elements; a complete rewrite by such a version may discard column widths without invalidating the remaining file. diff --git a/github_docs/de-Changelog.md b/github_docs/de-Changelog.md index 3726fdd5..9123bb21 100644 --- a/github_docs/de-Changelog.md +++ b/github_docs/de-Changelog.md @@ -42,6 +42,8 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un - **Einstellungen wirken erst mit Übernehmen:** Änderungen im Einstellungsfenster wirken nicht mehr während der Eingabe, sondern erst mit **Übernehmen/Schließen** oder **Einstellungen speichern**; **Verbinden** übernimmt sie ebenfalls vorher. Das gilt auch während einer laufenden Sitzung. Wer mit offenen Änderungen das Fenster schließt, das Profil wechselt oder das Programm beendet, wird zuerst gefragt, ob sie übernommen oder verworfen werden sollen, oder kann zurück. Farben, Sprache und die Anmeldedaten im Reiter **Station** wirken wie bisher sofort. +- **Fenster öffnen auf dem Bildschirm des Hauptfensters:** Stationskarte, Monitor, Einstellungen, Update-Fenster, Top-Kandidaten und die Hinweisdialoge öffneten bisher auf dem primären Bildschirm, auch wenn das Hauptfenster auf einem anderen lag oder der primäre Bildschirm ausgeschaltet war. Sie öffnen jetzt zentriert auf dem Bildschirm, auf dem das Hauptfenster gerade steht. Ein Fenster, das in der laufenden Sitzung verschoben wurde, öffnet beim nächsten Mal wieder dort, sofern seine Titelleiste noch auf einem angeschlossenen Bildschirm erreichbar ist. Mit der neuen Option **Fenster nach einem Neustart dort öffnen, wohin sie zuletzt geschoben wurden** im Reiter **GUI** (standardmäßig aus) behalten das Hauptfenster und alle anderen Fenster ihre Position auch über einen Neustart; eine gespeicherte Position wird nur verwendet, wenn genau derselbe Bildschirm (gleiche Lage und Auflösung) wieder angeschlossen ist. Ist ein Fenster größer als der Bildschirm, wird es auf dessen nutzbare Fläche verkleinert, auch wenn es später auf einen kleineren Bildschirm geschoben wird, etwa einen 640×480-Monitor; die gespeicherte Fenstergröße bleibt dabei unverändert. Das behebt [Issue #59](https://github.com/praktimarc/kst4contest/issues/59). + ### Behoben - **Einheitliche Schriftgröße:** Unter macOS und Linux verwendet JavaFX eine etwas größere Standardschrift als unter Windows. Dadurch wurden Beschriftungen in fest bemessenen Bedienelementen abgeschnitten. KST4Contest verwendet jetzt auf allen Systemen dieselbe Grundschriftgröße; unter Windows ändert sich nichts. diff --git a/github_docs/en-Changelog.md b/github_docs/en-Changelog.md index 836d21ab..8a697809 100644 --- a/github_docs/en-Changelog.md +++ b/github_docs/en-Changelog.md @@ -42,6 +42,8 @@ Several operators sharing one computer can now use their own callsigns, locators - **Settings take effect with Apply:** changes in the settings window no longer take effect while they are typed, but with **Apply/Close prefs** or **Save settings**; **Connect** applies them first as well. This also works during a running session. Closing the window, switching the operator profile or exiting with changes pending first asks whether to apply or discard them, or to go back. Colours, language and the login data on the **Station** tab still take effect immediately. +- **Windows open on the main window's screen:** the station map, monitor, settings, update window, top candidates and the notice dialogs used to open on the primary screen, even when the main window was on another one or the primary screen was switched off. They now open centred on the screen the main window is currently on. A window that was moved during the running session reopens there next time, as long as its title bar can still be reached on a connected screen. With the new option **Reopen windows across restarts where they were last moved to** on the **GUI** tab (off by default), the main window and all other windows also keep their position across restarts; a stored position is only used when the very same screen (same place and resolution) is connected again. A window larger than the screen is reduced to its usable area, also when it is moved onto a smaller screen later, for example a 640x480 monitor; the stored window size stays unchanged. This fixes [Issue #59](https://github.com/praktimarc/kst4contest/issues/59). + ### Fixed - **Consistent font size:** on macOS and Linux JavaFX uses a slightly larger default font than on Windows, which cut off labels in fixed-size controls. KST4Contest now uses the same base font size on all systems; nothing changes on Windows.