Fix window geometry save/restore: DPI scaling, position drift, maximized state

- LFM: Position = poDesigned (was poScreenCenter, overriding our restore)
- RestoreWindowBounds: remove position scaling (L,T invariant across DPI);
  scale size correctly per backend: LCLWin32 uses CurDPI/SavedDPI (physical px),
  Qt6/GTK/Cocoa use SavedDPI/CurDPI (logical px, inverse); fix min-size
  inflation on Qt6 (was MulDiv(900,DPI,96) which exceeds logical screen width
  at 150%+ scale, forcing window to 0,0)
- SaveWindowBounds moved from FormDestroy to FormClose while window is still
  stable; add guard for bogus RestoredLeft/Top values on Qt6 when maximized
- RestoreWindowBounds stores FRestoreL/T and re-applies position in AfterShowTick
  (200ms after show) when WM frame extents are already known to Qt, fixing the
  systematic right/down drift caused by unknown frame extents at FormCreate time
- Save and restore WindowState (maximized flag); skip AfterShowTick position
  re-apply when window is maximized

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 11:56:56 +03:00
co-authored by Claude Sonnet 4.6
parent 1eecd974fc
commit 9028a8dac0
3 changed files with 70 additions and 22 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ object MainForm: TMainForm
Font.Color = clSilver Font.Color = clSilver
Font.Height = -13 Font.Height = -13
Font.Name = 'Courier New' Font.Name = 'Courier New'
Position = poScreenCenter Position = poDesigned
LCLVersion = '4.6.0.0' LCLVersion = '4.6.0.0'
OnClose = FormClose OnClose = FormClose
OnCreate = FormCreate OnCreate = FormCreate
+52 -6
View File
@@ -343,6 +343,8 @@ type
FMeterTimer: TTimer; FMeterTimer: TTimer;
FSpectrumTimer: TTimer; FSpectrumTimer: TTimer;
FAfterShowTimer:TTimer; // однократный таймер для пост-инициализации FAfterShowTimer:TTimer; // однократный таймер для пост-инициализации
FRestoreL, FRestoreT: Integer; // позиция для отложенного восстановления
FHasPendingRestore: Boolean;
// ---- Top panel (toolbar + VFO group + S-meter) ---- // ---- Top panel (toolbar + VFO group + S-meter) ----
PanelToolbar: TPanel; PanelToolbar: TPanel;
@@ -933,10 +935,11 @@ end;
procedure TMainForm.RestoreWindowBounds; procedure TMainForm.RestoreWindowBounds;
var var
L, T, Wd, Ht, SavedDPI, CurDPI: Integer; L, T, Wd, Ht, SavedDPI, CurDPI: Integer;
Maximized: Boolean;
R, WA: TRect; R, WA: TRect;
Mon: TMonitor; Mon: TMonitor;
begin begin
FSettings.LoadWindowBounds(L, T, Wd, Ht, SavedDPI); FSettings.LoadWindowBounds(L, T, Wd, Ht, SavedDPI, Maximized);
if (Wd > 400) and (Ht > 300) then if (Wd > 400) and (Ht > 300) then
begin begin
CurDPI := CurrentScreenDPI; CurDPI := CurrentScreenDPI;
@@ -944,14 +947,32 @@ begin
if SavedDPI <> CurDPI then if SavedDPI <> CurDPI then
begin begin
L := MulDiv(L, CurDPI, SavedDPI); // Позицию не масштабируем: на LCLWin32 координаты физические и не зависят
T := MulDiv(T, CurDPI, SavedDPI); // от DPI-фактора; на Qt6/GTK/Cocoa — логические (device-independent), тоже
// инвариантны. Выход за рабочую область поймает EnsureRange ниже.
//
// Размер масштабируем, чтобы окно выглядело одинаково при смене DPI:
// LCLWin32: физические пиксели → Wd × CurDPI/SavedDPI
// Qt6/GTK/Cocoa: логические пиксели → Wd × SavedDPI/CurDPI (обратное)
{$IFDEF LCLWin32}
Wd := MulDiv(Wd, CurDPI, SavedDPI); Wd := MulDiv(Wd, CurDPI, SavedDPI);
Ht := MulDiv(Ht, CurDPI, SavedDPI); Ht := MulDiv(Ht, CurDPI, SavedDPI);
{$ELSE}
Wd := MulDiv(Wd, SavedDPI, CurDPI);
Ht := MulDiv(Ht, SavedDPI, CurDPI);
{$ENDIF}
end; end;
// Минимальный размер:
// LCLWin32 — в физических пикселях, масштабируется с DPI.
// Qt6/GTK/Cocoa — в логических пикселях, device-independent, фиксированный.
{$IFDEF LCLWin32}
Wd := Max(MulDiv(900, CurDPI, 96), Wd); Wd := Max(MulDiv(900, CurDPI, 96), Wd);
Ht := Max(MulDiv(560, CurDPI, 96), Ht); Ht := Max(MulDiv(560, CurDPI, 96), Ht);
{$ELSE}
Wd := Max(900, Wd);
Ht := Max(560, Ht);
{$ENDIF}
R := Bounds(L, T, Wd, Ht); R := Bounds(L, T, Wd, Ht);
Mon := Screen.MonitorFromRect(R); Mon := Screen.MonitorFromRect(R);
if Mon <> nil then WA := Mon.WorkareaRect if Mon <> nil then WA := Mon.WorkareaRect
@@ -962,15 +983,27 @@ begin
L := EnsureRange(L, WA.Left, Max(WA.Left, WA.Right - Wd)); L := EnsureRange(L, WA.Left, Max(WA.Left, WA.Right - Wd));
T := EnsureRange(T, WA.Top, Max(WA.Top, WA.Bottom - Ht)); T := EnsureRange(T, WA.Top, Max(WA.Top, WA.Bottom - Ht));
Position := poDesigned;
SetBounds(L, T, Wd, Ht); SetBounds(L, T, Wd, Ht);
if Maximized then
WindowState := wsMaximized
else
begin
// На Qt6/X11 frame extents ещё не известны Qt во время FormCreate,
// поэтому move() может поставить контент туда, куда мы хотели рамку.
// Повторно применим позицию в AfterShowTick, когда WM уже декорировал окно.
FRestoreL := L;
FRestoreT := T;
FHasPendingRestore := True;
end;
end; end;
end; end;
procedure TMainForm.SaveWindowBounds; procedure TMainForm.SaveWindowBounds;
var var
L, T, Wd, Ht, DPI: Integer; L, T, Wd, Ht, DPI: Integer;
Maximized: Boolean;
begin begin
Maximized := WindowState = wsMaximized;
if WindowState = wsNormal then if WindowState = wsNormal then
begin begin
L := Left; T := Top; Wd := Width; Ht := Height; L := Left; T := Top; Wd := Width; Ht := Height;
@@ -981,9 +1014,12 @@ begin
Wd := RestoredWidth; Ht := RestoredHeight; Wd := RestoredWidth; Ht := RestoredHeight;
end; end;
// RestoredLeft/Top могут вернуть мусор на Qt6 при максимизации;
// ставим разумные запасные значения
if (L < -32000) or (T < -32000) then begin L := 80; T := 80; end;
if (Wd <= 400) or (Ht <= 300) then Exit; if (Wd <= 400) or (Ht <= 300) then Exit;
DPI := CurrentScreenDPI; DPI := CurrentScreenDPI;
FSettings.SaveWindowBounds(L, T, Wd, Ht, DPI); FSettings.SaveWindowBounds(L, T, Wd, Ht, DPI, Maximized);
end; end;
function TMainForm.MakeBandSettings: TBandSettings; function TMainForm.MakeBandSettings: TBandSettings;
@@ -1497,7 +1533,6 @@ begin
end; end;
// Размер окна сохраняем всегда (не зависит от подключения) // Размер окна сохраняем всегда (не зависит от подключения)
FSettings.SaveStartupPreview(FVfoA, FVfoB, FSampleRate); FSettings.SaveStartupPreview(FVfoA, FVfoB, FSampleRate);
SaveWindowBounds;
FSettings.Save; FSettings.Save;
FSettings.Free; FSettings.Free;
FWebServer.Stop; FWebServer.Stop;
@@ -1509,6 +1544,9 @@ end;
procedure TMainForm.FormClose(Sender: TObject; var CloseAction: TCloseAction); procedure TMainForm.FormClose(Sender: TObject; var CloseAction: TCloseAction);
begin begin
// Сохраняем позицию здесь — окно ещё полностью видимо и стабильно.
// В FormDestroy Qt-виджет может быть уже в нестабильном состоянии.
SaveWindowBounds;
// Останавливаем трансивер как при нажатии STOP // Останавливаем трансивер как при нажатии STOP
if FNetwork.Connected then if FNetwork.Connected then
begin begin
@@ -6968,6 +7006,14 @@ begin
// Вызывается один раз через 200 мс после старта формы // Вызывается один раз через 200 мс после старта формы
FAfterShowTimer.Enabled := False; FAfterShowTimer.Enabled := False;
// Повторно применяем сохранённую позицию: к этому моменту WM уже декорировал
// окно и Qt знает frame extents, поэтому move() ставит рамку точно.
if FHasPendingRestore then
begin
SetBounds(FRestoreL, FRestoreT, Width, Height);
FHasPendingRestore := False;
end;
try try
FAudioOut.Open; FAudioOut.Open;
except except
+7 -5
View File
@@ -282,8 +282,8 @@ type
class procedure DefaultGlobal(out G: TGlobalSettings); class procedure DefaultGlobal(out G: TGlobalSettings);
class procedure DefaultTX(out T: TTXSettings); class procedure DefaultTX(out T: TTXSettings);
// Размер окна — не привязан к MAC, хранится в корне JSON // Размер окна — не привязан к MAC, хранится в корне JSON
procedure SaveWindowBounds(L, T, W, H, DPI: Integer); procedure SaveWindowBounds(L, T, W, H, DPI: Integer; Maximized: Boolean);
procedure LoadWindowBounds(out L, T, W, H, DPI: Integer); procedure LoadWindowBounds(out L, T, W, H, DPI: Integer; out Maximized: Boolean);
procedure SaveStartupPreview(VfoA, VfoB: Double; SampleRate: Integer); procedure SaveStartupPreview(VfoA, VfoB: Double; SampleRate: Integer);
function LoadStartupPreview(out VfoA, VfoB: Double; out SampleRate: Integer): Boolean; function LoadStartupPreview(out VfoA, VfoB: Double; out SampleRate: Integer): Boolean;
procedure SaveAudioBufferSize(BufferSize: Integer); procedure SaveAudioBufferSize(BufferSize: Integer);
@@ -757,7 +757,7 @@ begin
B.FMStepIdx := EnsureRange(JI(O,'fmstep_idx', B.FMStepIdx), 0, 3); B.FMStepIdx := EnsureRange(JI(O,'fmstep_idx', B.FMStepIdx), 0, 3);
end; end;
procedure TSettingsManager.SaveWindowBounds(L, T, W, H, DPI: Integer); procedure TSettingsManager.SaveWindowBounds(L, T, W, H, DPI: Integer; Maximized: Boolean);
var O: TJSONObject; var O: TJSONObject;
begin begin
O := EnsureObj(FRoot, 'window'); O := EnsureObj(FRoot, 'window');
@@ -766,12 +766,13 @@ begin
JW(O, 'width', W); JW(O, 'width', W);
JW(O, 'height', H); JW(O, 'height', H);
JW(O, 'dpi', DPI); JW(O, 'dpi', DPI);
JW(O, 'maximized', Maximized);
end; end;
procedure TSettingsManager.LoadWindowBounds(out L, T, W, H, DPI: Integer); procedure TSettingsManager.LoadWindowBounds(out L, T, W, H, DPI: Integer; out Maximized: Boolean);
var O: TJSONObject; var O: TJSONObject;
begin begin
L := 80; T := 80; W := 1400; H := 900; DPI := 0; L := 80; T := 80; W := 1400; H := 900; DPI := 0; Maximized := False;
if FRoot.Find('window') = nil then Exit; if FRoot.Find('window') = nil then Exit;
O := EnsureObj(FRoot, 'window'); O := EnsureObj(FRoot, 'window');
L := JI(O, 'left', 80); L := JI(O, 'left', 80);
@@ -779,6 +780,7 @@ begin
W := JI(O, 'width', 1400); W := JI(O, 'width', 1400);
H := JI(O, 'height', 900); H := JI(O, 'height', 900);
DPI := JI(O, 'dpi', 0); DPI := JI(O, 'dpi', 0);
Maximized := JB(O, 'maximized', False);
end; end;
procedure TSettingsManager.SaveStartupPreview(VfoA, VfoB: Double; SampleRate: Integer); procedure TSettingsManager.SaveStartupPreview(VfoA, VfoB: Double; SampleRate: Integer);