mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-26 16:15:51 +01:00
* 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:
1 parent
bb0e9eb327
commit
feb4b4e2c9
21 files changed
+1280
-13
No files matched your search
@@ -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)))
|
||||
}
|
||||
}
|
||||
+49
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user