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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* Match the fit echo with tolerance and consume it once (#59)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rsclub2_2andClaude Opus 5.5 authored and GitHub committed 2026-10-16 17:58:28 +02:00
1 parent bb0e9eb327
commit feb4b4e2c9
21 files changed
+1280 -13

No files matched your search

@@ -26,6 +26,7 @@ gui.language=Sprache
gui.language.system=Systemsprache gui.language.system=Systemsprache
gui.startupDesign=Design beim Start gui.startupDesign=Design beim Start
gui.startupFilters=Beim Start aktive Nachrichtenfilter gui.startupFilters=Beim Start aktive Nachrichtenfilter
gui.windows=Fenster
logSynch.fileChooserTitle=Nur-Lese-Loginterpreter-Datei wählen logSynch.fileChooserTitle=Nur-Lese-Loginterpreter-Datei wählen
logSynch.fileEnabled=Gearbeitete Rufzeichen regelmäßig aus einer Logdatei lesen (ohne Bandangabe) logSynch.fileEnabled=Gearbeitete Rufzeichen regelmäßig aus einer Logdatei lesen (ohne Bandangabe)
logSynch.fileLabel=Zu überwachende Logdatei: logSynch.fileLabel=Zu überwachende Logdatei:
@@ -399,6 +400,7 @@ gui.filterPmToOthers=Privatnachrichten an andere
gui.filterNothing=Kein Filter gui.filterNothing=Kein Filter
gui.hintFreshCall=Ein zum ersten Mal gesehenes Rufzeichen markieren gui.hintFreshCall=Ein zum ersten Mal gesehenes Rufzeichen markieren
gui.hintLargeGridWorked=Ein gearbeitetes Großfeld 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.choose=Wählen...
form.notSelected=nicht gewählt form.notSelected=nicht gewählt
dialog.apply=Übernehmen dialog.apply=Übernehmen
@@ -35,6 +35,7 @@ gui.language=Language
gui.language.system=System language gui.language.system=System language
gui.startupDesign=Startup design gui.startupDesign=Startup design
gui.startupFilters=Message filters active at startup gui.startupFilters=Message filters active at startup
gui.windows=Windows
logSynch.fileChooserTitle=Choose Readonly-Loginterpreter-File logSynch.fileChooserTitle=Choose Readonly-Loginterpreter-File
logSynch.fileEnabled=Read worked callsigns periodically from a log file (without band information) logSynch.fileEnabled=Read worked callsigns periodically from a log file (without band information)
logSynch.fileLabel=Log file to be monitored: logSynch.fileLabel=Log file to be monitored:
@@ -408,6 +409,7 @@ gui.filterPmToOthers=Private messages to others
gui.filterNothing=No filter gui.filterNothing=No filter
gui.hintFreshCall=Mark a callsign seen for the first time gui.hintFreshCall=Mark a callsign seen for the first time
gui.hintLargeGridWorked=Mark a worked large grid field 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.choose=Choose...
form.notSelected=not selected form.notSelected=not selected
dialog.apply=Apply dialog.apply=Apply
@@ -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() { private void requestLayoutSave() {
if (layoutAutosave != null) { if (layoutAutosave != null) {
layoutAutosave.requestSave(); layoutAutosave.requestSave();
@@ -2163,6 +2173,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
.isMessageHandling_debugModeToFileEnabled()); .isMessageHandling_debugModeToFileEnabled());
layoutAutosave = new LayoutAutosave( layoutAutosave = new LayoutAutosave(
chatcontroller.getChatPreferences()::writeLayoutPreferencesToXmlFile, uiDispatcher); chatcontroller.getChatPreferences()::writeLayoutPreferencesToXmlFile, uiDispatcher);
installWindowPositionMemory(chatcontroller.getChatPreferences());
// Every profile starts with its own configured design; the menu quick switch only lasts for the session. // Every profile starts with its own configured design; the menu quick switch only lasts for the session.
chatcontroller.getChatPreferences().setGUI_darkModeActive( chatcontroller.getChatPreferences().setGUI_darkModeActive(
chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault()); chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault());
@@ -254,7 +254,7 @@ object ComposeAlert {
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS) val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS)
dialog.title = title dialog.title = title
dialog.size = DIALOG_SIZE dialog.size = DIALOG_SIZE
dialog.setLocationRelativeTo(null) WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
/* Disposed and not hidden, for the reason spelled out in open(). */ /* Disposed and not hidden, for the reason spelled out in open(). */
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
dialog.isAlwaysOnTop = true dialog.isAlwaysOnTop = true
@@ -322,7 +322,7 @@ object ComposeAlert {
) )
dialog.title = title dialog.title = title
dialog.size = DIALOG_SIZE dialog.size = DIALOG_SIZE
dialog.setLocationRelativeTo(null) WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
/* /*
* Disposed, not hidden. The JDialog default is HIDE_ON_CLOSE, which would leave the * 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, * window and its Skia layer alive behind the operator's back — one per dismissal,
@@ -11,16 +11,25 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.loadImageBitmap import androidx.compose.ui.res.loadImageBitmap
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope 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.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState import androidx.compose.ui.window.rememberWindowState
import androidx.compose.ui.awt.ComposeWindow import androidx.compose.ui.awt.ComposeWindow
import java.awt.EventQueue import java.awt.EventQueue
import java.awt.Frame import java.awt.Frame
import java.awt.Point
import java.awt.Toolkit import java.awt.Toolkit
import java.util.concurrent.atomic.AtomicBoolean 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. * 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 * - **It must follow a design switch.** The operator can change the design from the
* main window's menu while this window is open. * 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 * @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 open = AtomicBoolean(false)
private val darkModeState = mutableStateOf(false) private val darkModeState = mutableStateOf(false)
@@ -61,6 +80,16 @@ class ComposeWindowHost(private val threadName: String) {
@Volatile @Volatile
private var frame: ComposeWindow? = null 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. */ /** Whether the window is currently open, so a caller can tell without opening one. */
val isOpen: Boolean val isOpen: Boolean
get() = open.get() get() = open.get()
@@ -216,9 +245,34 @@ class ComposeWindowHost(private val threadName: String) {
return@application 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( val windowState = rememberWindowState(
width = widthDp.dp, position = placed
height = heightDp.dp, ?.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 * comfort: these tables are wider than the default window, so an
* operator who widens it once must not have to do it again. * 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) { if (onResized != null) {
LaunchedEffect(windowState) { LaunchedEffect(windowState) {
snapshotFlow { windowState.size }.collect { size -> /*
if (size.width.isSpecified && size.height.isSpecified) { * A secondary window skips the opening size: it may be the
onResized(size.width.value, size.height.value) * 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) { DisposableEffect(window) {
frame = window frame = window
if (isMainWindow) {
WindowPlacementOnScreen.mainWindow = window
}
/* /*
* The only place the handle is dropped. Clearing it again in * The only place the handle is dropped. Clearing it again in
* the thread's finally block looks like a belt to this brace, * 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 * the late write would wipe a live handle and bring back the
* dead button this whole method exists to remove. * dead button this whole method exists to remove.
*/ */
onDispose { frame = null } onDispose {
frame = null
if (WindowPlacementOnScreen.mainWindow === window) {
WindowPlacementOnScreen.mainWindow = null
}
}
} }
CompositionLocalProvider( 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 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 * the default Java cup. Null when the resource is missing rather than failing: a window
@@ -16,7 +16,7 @@ import androidx.compose.runtime.setValue
*/ */
object MainWindowHost { object MainWindowHost {
private val host = ComposeWindowHost("main-window") private val host = ComposeWindowHost("main-window", isMainWindow = true)
@JvmStatic @JvmStatic
val isOpen: Boolean val isOpen: Boolean
@@ -123,7 +123,7 @@ object OperatorProfilePickerWindow {
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL) val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL)
dialog.title = kst4contest.view.i18n.CurrentStrings.get().pickerTitle dialog.title = kst4contest.view.i18n.CurrentStrings.get().pickerTitle
dialog.size = DIALOG_SIZE dialog.size = DIALOG_SIZE
dialog.setLocationRelativeTo(null) WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
/* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */ /* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
/* /*
@@ -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()
}
}
@@ -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<ScreenArea>,
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<ScreenArea>, 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<ScreenArea>, 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<ScreenArea>, 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<ScreenArea>, 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<ScreenArea>,
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<ScreenArea> = 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
}
}
}
@@ -1,5 +1,6 @@
package kst4contest.view.compose.map package kst4contest.view.compose.map
import kst4contest.view.compose.WindowPlacementOnScreen
import kst4contest.view.i18n.LocalStrings import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -61,7 +62,13 @@ object StationMapWindow {
onResized = onResized, onResized = onResized,
) { _ -> ) { _ ->
val window = this.window 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() } val tileFetcher = remember { TileFetcher() }
/* /*
@@ -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
}
}
} }
} }
@@ -42,6 +42,8 @@ class GuiOptionsTabState(
get() = languageStore.stored get() = languageStore.stored
set(value) { languageStore.use(value) } 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) } var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns() }) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(it) }
} }
@@ -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))
}
}
@@ -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)))
}
}
@@ -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"))
}
}
@@ -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))
}
}
@@ -435,6 +435,21 @@ public class ChatPreferences {
private boolean GUI_darkModeActive = false; private boolean GUI_darkModeActive = false;
private boolean GUI_darkModeActiveByDefault = 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<String, int[]> windowPositions = new LinkedHashMap<>();
private static final String TAG_WINDOW_POSITION = "windowPosition";
private double[] GUIscn_ChatwindowMainSceneSizeHW = new double[] {768, 1234}; private double[] GUIscn_ChatwindowMainSceneSizeHW = new double[] {768, 1234};
private double[] GUIclusterAndQSOMonStage_SceneSizeHW = new double[] {700, 500}; private double[] GUIclusterAndQSOMonStage_SceneSizeHW = new double[] {700, 500};
private double[] GUIstage_updateStage_SceneSizeHW = new double[] {580, 480}; private double[] GUIstage_updateStage_SceneSizeHW = new double[] {580, 480};
@@ -2223,6 +2238,10 @@ public class ChatPreferences {
guiOptions_darkModeActiveByDefault.setTextContent(this.GUI_darkModeActiveByDefault + ""); guiOptions_darkModeActiveByDefault.setTextContent(this.GUI_darkModeActiveByDefault + "");
guiSaveableOptions.appendChild(guiOptions_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 // --- GUImainWindowRightSplitPane_dividerposition (2 dividers => 2 values) --- NEW
ensureMainWindowRightSplitPaneDividerPositions(2); ensureMainWindowRightSplitPaneDividerPositions(2);
@@ -2305,6 +2324,7 @@ public class ChatPreferences {
guiOptions.appendChild(GUIstationMapClusteringEnabled); guiOptions.appendChild(GUIstationMapClusteringEnabled);
appendTableColumnWidths(doc, guiOptions); appendTableColumnWidths(doc, guiOptions);
appendWindowPositions(doc, guiOptions);
/**************************************************************************************** /****************************************************************************************
****************************** now write this XML! ************************************* ****************************** now write this XML! *************************************
@@ -2357,6 +2377,8 @@ public class ChatPreferences {
updateLayoutElements(document, guiOptions); updateLayoutElements(document, guiOptions);
removeDirectChildren(guiOptions, TAG_TABLE_COLUMN_WIDTH); removeDirectChildren(guiOptions, TAG_TABLE_COLUMN_WIDTH);
appendTableColumnWidths(document, guiOptions); appendTableColumnWidths(document, guiOptions);
removeDirectChildren(guiOptions, TAG_WINDOW_POSITION);
appendWindowPositions(document, guiOptions);
writeDocumentAtomically(document); writeDocumentAtomically(document);
return true; return true;
} catch (ParserConfigurationException | SAXException | IOException | TransformerException exception) { } catch (ParserConfigurationException | SAXException | IOException | TransformerException exception) {
@@ -3227,6 +3249,8 @@ public class ChatPreferences {
this.setGUIpnl_directedMSGWin_dividerpositionDefault(csvStringToDoubleArray(s5)); this.setGUIpnl_directedMSGWin_dividerpositionDefault(csvStringToDoubleArray(s5));
} }
readWindowPositions(element);
tableColumnWidths.clear(); tableColumnWidths.clear();
NodeList widthElements = element.getElementsByTagName(TAG_TABLE_COLUMN_WIDTH); NodeList widthElements = element.getElementsByTagName(TAG_TABLE_COLUMN_WIDTH);
for (int widthIndex = 0; widthIndex < widthElements.getLength(); widthIndex++) { for (int widthIndex = 0; widthIndex < widthElements.getLength(); widthIndex++) {
@@ -3268,6 +3292,9 @@ public class ChatPreferences {
// Added in later versions: dark mode flags // Added in later versions: dark mode flags
this.GUI_darkModeActive = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActive, "guiOptions_darkModeActive"); this.GUI_darkModeActive = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActive, "guiOptions_darkModeActive");
this.GUI_darkModeActiveByDefault = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActiveByDefault, "guiOptions_darkModeActiveByDefault"); 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; 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<String, int[]> 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 * If the file-reading goes wrong, set the defaults
@@ -8,7 +8,9 @@ import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -126,6 +128,51 @@ class ChatPreferencesLayoutPersistenceTest {
restored.getTableColumnWidth("qso-other-monitor", "call-tx").orElseThrow()); 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, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>8</configVersion>
<guiOptions>
<windowPosition windowId="Map" x="1" y="2" screenX="0" screenY="0" screenWidth="10" screenHeight="10"/>
<windowPosition windowId="broken" x="a" y="2" screenX="0" screenY="0" screenWidth="10" screenHeight="10"/>
</guiOptions>
</praktiKST>
""");
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) { private ChatPreferences preferencesAt(Path preferencesFile) {
return new ChatPreferences(preferencesFile); return new ChatPreferences(preferencesFile);
} }
+1
View File
@@ -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. - 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. - 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 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. - 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. - 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. - 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.
+2
View File
@@ -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. - **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 ### 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. - **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.
+2
View File
@@ -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. - **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 ### 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. - **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.