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.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
@@ -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
@@ -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());
@@ -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,
@@ -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
@@ -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
@@ -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
/*
@@ -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
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() }
/*
@@ -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
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) }
}
@@ -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))
}
}