From 8bc4ed68d49aa69d9ff4f78ee18ef9f4d04539fd Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 28 Aug 2026 12:48:03 +0200 Subject: [PATCH] fix(window): place the window in absolute desktop coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wails saves and restores in two different coordinate systems. WindowGetPosition returns GetWindowRect — absolute. WindowSetPosition is winc's ControlBase.SetPos, which adds the CURRENT monitor's work-area origin: w32.SetWindowPos(hwnd, HWND_TOP, int(info.RcWork.Left)+x, ...) On the primary monitor that origin is 0 and nothing shows. With a monitor to the LEFT it compounds: a station with its left screen at x=-3840 saved -3844, reopened there, had -3840 added, and stored -7684 — then -11524, one screen further out at every launch, until the window was off the desktop entirely. So the placement is done here, with SetWindowPos and no offset, falling back to the toolkit when the handle cannot be found. Compact mode had the same pairing and is fixed with it. --- app.go | 28 +++++++++++-- changelog.json | 6 ++- windowpos_other.go | 7 ++++ windowpos_windows.go | 93 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 windowpos_other.go create mode 100644 windowpos_windows.go diff --git a/app.go b/app.go index a9ace98..8eceafd 100644 --- a/app.go +++ b/app.go @@ -2124,6 +2124,21 @@ func (a *App) saveWindowState() { // position — which options can't express — remains, and it is set here while the // window is still hidden, so there is no visible jump. Nothing to do for a // maximised or first-run window. +// moveWindowTo places the window at an ABSOLUTE desktop coordinate — the same +// coordinate system WindowGetPosition reports and window.json stores. +// +// Wails' WindowSetPosition is relative to the current monitor's work area (see +// windowpos_windows.go), so on a monitor left of the primary one it added that +// monitor's negative origin to an already-absolute value and the window walked +// one screen further off the desktop at every launch. Fall back to it only when +// we cannot place the window ourselves — on the primary monitor the two agree. +func (a *App) moveWindowTo(x, y int) { + if setWindowPosAbsolute(x, y) { + return + } + wruntime.WindowSetPosition(a.ctx, x, y) +} + func (a *App) restoreWindowPosition() { if a.ctx == nil { return @@ -2151,7 +2166,7 @@ func (a *App) restoreWindowPosition() { return } wruntime.WindowUnmaximise(a.ctx) - wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y) + a.moveWindowTo(ws.X, ws.Y) wruntime.WindowMaximise(a.ctx) gx, gy := wruntime.WindowGetPosition(a.ctx) applog.Printf("window: re-maximised at the saved corner — now at %d,%d", gx, gy) @@ -2179,10 +2194,13 @@ func (a *App) restoreWindowPosition() { } applog.Printf("window: saved position %d,%d is off every monitor (%s) — moved to %d,%d", ws.X, ws.Y, describeMonitors(monitorRects()), nx, ny) - wruntime.WindowSetPosition(a.ctx, nx, ny) + a.moveWindowTo(nx, ny) return } - wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y) + a.moveWindowTo(ws.X, ws.Y) + if gx, gy := wruntime.WindowGetPosition(a.ctx); gx != ws.X || gy != ws.Y { + applog.Printf("window: asked for %d,%d and the window reports %d,%d", ws.X, ws.Y, gx, gy) + } } // onSomeMonitor reports whether a window at these coordinates would land on the @@ -7234,7 +7252,9 @@ func (a *App) SetCompactMode(on bool) { wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH) if a.preCompactValid { wruntime.WindowSetSize(a.ctx, a.preCompactW, a.preCompactH) - wruntime.WindowSetPosition(a.ctx, a.preCompactX, a.preCompactY) + // Absolute, like the capture — see moveWindowTo. Leaving compact mode on a + // monitor left of the primary one moved the window a screen further out. + a.moveWindowTo(a.preCompactX, a.preCompactY) if a.preCompactMax { wruntime.WindowMaximise(a.ctx) } diff --git a/changelog.json b/changelog.json index 134fb73..e1ce715 100644 --- a/changelog.json +++ b/changelog.json @@ -5,12 +5,14 @@ "en": [ "LoTW download: the QSL details (QSL date, grid, state, county) are now optional and off by default — LoTW takes about ten times longer to build that report, twenty minutes against two on the same account, and marking a confirmation needs none of it. Still asked for automatically when adding the QSOs not found in the log.", "Band map: the tooltip now also names a new prefix or a new grid square. They stay off the 22-pixel colour strip, but leaving them out of the text made the two panels look as though they disagreed — the cluster said NEW PFX about a spot the map called Worked, and both were right. The map’s “Worked” also says whose: it is the ENTITY that was worked on that band and mode, not the callsign, which is what made the two readings look contradictory.", - "TCI (SunSDR): clicking a cluster spot no longer needs a second click to get the mode right — the sideband was chosen from the frequency the radio had last reported instead of the one just asked for. The log also names the ExpertSDR version now, and says so when it is older than the 1.5 that panorama spots need." + "TCI (SunSDR): clicking a cluster spot no longer needs a second click to get the mode right — the sideband was chosen from the frequency the radio had last reported instead of the one just asked for. The log also names the ExpertSDR version now, and says so when it is older than the 1.5 that panorama spots need.", + "Two screens: OpsLog no longer walks off the desktop. On a monitor placed left of the primary one, the saved position was being added to that monitor’s own origin at every launch, so the window moved one screen further out each time until it was invisible." ], "fr": [ "Téléchargement LoTW : les détails QSL (date du QSL, locator, état, comté) deviennent optionnels et désactivés par défaut — LoTW met environ dix fois plus longtemps à construire ce rapport, vingt minutes contre deux sur le même compte, et marquer une confirmation n'en a pas besoin. Toujours demandés automatiquement quand on ajoute les QSO absents du log.", "Carte des bandes : l'infobulle indique aussi un nouveau préfixe ou un nouveau locator. Ils restent hors de la bande de couleur de 22 pixels, mais les omettre du texte donnait l'impression que les deux panneaux se contredisaient — le cluster annonçait NOUVEAU PFX pour un spot que la carte disait contacté, et les deux avaient raison. Le « Contacté » de la carte dit aussi de qui il parle : c'est l'ENTITÉ qui a été contactée sur cette bande et ce mode, pas l'indicatif — d'où l'impression de contradiction.", - "TCI (SunSDR) : cliquer un spot du cluster ne demande plus un second clic pour obtenir le bon mode — la bande latérale était choisie d'après la fréquence encore annoncée par la radio au lieu de celle qu'on venait de demander. Le journal indique aussi la version d'ExpertSDR, et signale si elle est antérieure à la 1.5 qu'exigent les spots sur le panorama." + "TCI (SunSDR) : cliquer un spot du cluster ne demande plus un second clic pour obtenir le bon mode — la bande latérale était choisie d'après la fréquence encore annoncée par la radio au lieu de celle qu'on venait de demander. Le journal indique aussi la version d'ExpertSDR, et signale si elle est antérieure à la 1.5 qu'exigent les spots sur le panorama.", + "Deux écrans : OpsLog ne s'échappe plus du bureau. Sur un écran placé à gauche de l'écran principal, la position enregistrée était ajoutée à l'origine de cet écran à chaque lancement, si bien que la fenêtre s'éloignait d'un écran à chaque fois jusqu'à devenir invisible." ] }, { diff --git a/windowpos_other.go b/windowpos_other.go new file mode 100644 index 0000000..2aef33d --- /dev/null +++ b/windowpos_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package main + +// Placing a window at an absolute desktop coordinate is Windows-specific; every +// caller falls back to the toolkit's own call when this says no. +func setWindowPosAbsolute(x, y int) bool { return false } diff --git a/windowpos_windows.go b/windowpos_windows.go new file mode 100644 index 0000000..f789040 --- /dev/null +++ b/windowpos_windows.go @@ -0,0 +1,93 @@ +//go:build windows + +package main + +import ( + "syscall" + "unsafe" + + "hamlog/internal/applog" +) + +// Moving the window to an ABSOLUTE virtual-desktop position. +// +// Wails' own WindowSetPosition cannot do it. Its Windows implementation reads: +// +// func (cba *ControlBase) SetPos(x, y int) { +// info := getMonitorInfo(cba.hwnd) +// w32.SetWindowPos(cba.hwnd, HWND_TOP, int(info.RcWork.Left)+x, int(info.RcWork.Top)+y, ...) +// } +// +// — the coordinates are relative to the CURRENT monitor's work area, while +// WindowGetPosition returns GetWindowRect, which is absolute. Saving one and +// restoring the other is only harmless on the primary monitor, where the work +// area starts at 0. +// +// On a second monitor to the LEFT it compounds at every launch. Reported from a +// two-screen station with the left monitor at x = -3840: OpsLog saved -3844, +// reopened on that monitor, added the monitor's own origin, and stored -7684 — +// then -11524, each launch one screen further into nowhere. +// +// So we place the window ourselves. Same call the toolkit makes, without the +// offset. +const ( + swpNoSize = 0x0001 + swpNoZOrder = 0x0004 + swpNoActivate = 0x0010 +) + +var ( + procSetWindowPos = user32Dll.NewProc("SetWindowPos") + procEnumWindows = user32Dll.NewProc("EnumWindows") + procGetWindowThreadProcessID = user32Dll.NewProc("GetWindowThreadProcessId") + procGetWindowTextLengthW = user32Dll.NewProc("GetWindowTextLengthW") + procGetWindow = user32Dll.NewProc("GetWindow") + kernel32Dll = syscall.NewLazyDLL("kernel32.dll") + procGetCurrentProcessIDWinPos = kernel32Dll.NewProc("GetCurrentProcessId") +) + +// mainWindowHandle finds this process's own top-level window. +// +// Wails does not expose the handle, so it is looked up: the first top-level +// window belonging to this process id that has no owner and a title. The window +// is created hidden (StartHidden), and EnumWindows lists hidden windows too, +// which is what makes this usable before the window is shown. +func mainWindowHandle() uintptr { + self, _, _ := procGetCurrentProcessIDWinPos.Call() + var found uintptr + cb := syscall.NewCallback(func(hwnd uintptr, _ uintptr) uintptr { + var pid uint32 + procGetWindowThreadProcessID.Call(hwnd, uintptr(unsafe.Pointer(&pid))) + if uintptr(pid) != self { + return 1 // keep going + } + // GW_OWNER = 4: skip tool windows and dialogs owned by the main one. + if owner, _, _ := procGetWindow.Call(hwnd, 4); owner != 0 { + return 1 + } + if n, _, _ := procGetWindowTextLengthW.Call(hwnd); n == 0 { + return 1 + } + found = hwnd + return 0 // stop + }) + procEnumWindows.Call(cb, 0) + return found +} + +// setWindowPosAbsolute moves the window to a virtual-desktop coordinate. +// Reports whether it could; the caller falls back to the toolkit's own call. +func setWindowPosAbsolute(x, y int) bool { + hwnd := mainWindowHandle() + if hwnd == 0 { + applog.Printf("window: could not find our own window handle — falling back to the toolkit's placement") + return false + } + r, _, err := procSetWindowPos.Call(hwnd, 0, uintptr(int32(x)), uintptr(int32(y)), 0, 0, + swpNoSize|swpNoZOrder|swpNoActivate) + if r == 0 { + applog.Printf("window: SetWindowPos(%d,%d) failed: %v", x, y, err) + return false + } + return true +}