From b1a7b1881197c5b3ce2361107574a53bf710140e Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 1 May 2026 21:29:45 -0400 Subject: [PATCH 1/3] feat: add window position persistence feature with settings toggle --- app/lib/l10n/app_en.arb | 4 + app/lib/l10n/app_es.arb | 2 + app/lib/l10n/app_localizations.dart | 12 ++ app/lib/l10n/app_localizations_en.dart | 7 + app/lib/l10n/app_localizations_es.dart | 7 + app/lib/main.dart | 80 ++++++--- app/lib/screens/settings_screen.dart | 16 ++ app/lib/shell/app_window.dart | 200 +++++++++++++++++++++- app/test/shell/app_window_range_test.dart | 55 ++++++ core/lib/config/app_config.dart | 26 +++ core/test/app_config_test.dart | 130 ++++++++++++++ 11 files changed, 508 insertions(+), 31 deletions(-) create mode 100644 app/test/shell/app_window_range_test.dart diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index aa03f2a7..f6379e6c 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -278,6 +278,8 @@ "@settingLinesExpanded": { "description": "Lines expanded label" }, "settingHideOnDeactivate": "Hide on deactivate", "@settingHideOnDeactivate": { "description": "Hide on deactivate label" }, + "settingRememberWindowPosition": "Remember window position", + "@settingRememberWindowPosition": { "description": "Remember window position toggle label" }, "settingScrollToTopOnOpen": "Scroll to top on open", "@settingScrollToTopOnOpen": { "description": "Scroll to top on open label" }, "settingClearSearchOnOpen": "Clear search on open", @@ -293,6 +295,8 @@ "@subtitleStartupDesc": { "description": "Startup subtitle" }, "subtitleHideOnDeactivate": "Close window when clicking outside", "@subtitleHideOnDeactivate": { "description": "Hide on deactivate subtitle" }, + "subtitleRememberWindowPosition": "Reopen the window where you left it last time", + "@subtitleRememberWindowPosition": { "description": "Remember window position subtitle" }, "subtitleScrollToTopOnOpen": "Resets scroll and selects latest item", "@subtitleScrollToTopOnOpen": { "description": "Scroll to top on open subtitle" }, "subtitleClearSearchOnOpen": "Clears the search text each time", diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index bb063c15..51a73624 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -139,6 +139,7 @@ "settingLinesCollapsed": "L\u00edneas contra\u00eddas", "settingLinesExpanded": "L\u00edneas expandidas", "settingHideOnDeactivate": "Ocultar al hacer clic fuera", + "settingRememberWindowPosition": "Recordar posición de la ventana", "settingScrollToTopOnOpen": "Ir al inicio al abrir", "settingClearSearchOnOpen": "Limpiar b\u00fasqueda al abrir", "settingRetentionDaysLabel": "D\u00edas de retenci\u00f3n (0 = sin l\u00edmite)", @@ -147,6 +148,7 @@ "subtitleStartupDesc": "Se inicia en segundo plano al iniciar sesi\u00f3n", "subtitleHideOnDeactivate": "Cerrar la ventana al hacer clic fuera", + "subtitleRememberWindowPosition": "Reabrir la ventana donde la dejaste la última vez", "subtitleScrollToTopOnOpen": "Restablece el desplazamiento y selecciona el \u00faltimo elemento", "subtitleClearSearchOnOpen": "Borra el texto de b\u00fasqueda cada vez", "subtitlePasteSpeed": "Ajustar tiempos de restauraci\u00f3n y pegado", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 2b08b4d3..5c2c5ca0 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -806,6 +806,12 @@ abstract class AppLocalizations { /// **'Hide on deactivate'** String get settingHideOnDeactivate; + /// Remember window position toggle label + /// + /// In en, this message translates to: + /// **'Remember window position'** + String get settingRememberWindowPosition; + /// Scroll to top on open label /// /// In en, this message translates to: @@ -848,6 +854,12 @@ abstract class AppLocalizations { /// **'Close window when clicking outside'** String get subtitleHideOnDeactivate; + /// Remember window position subtitle + /// + /// In en, this message translates to: + /// **'Reopen the window where you left it last time'** + String get subtitleRememberWindowPosition; + /// Scroll to top on open subtitle /// /// In en, this message translates to: diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index c470986c..89dc2276 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -376,6 +376,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingHideOnDeactivate => 'Hide on deactivate'; + @override + String get settingRememberWindowPosition => 'Remember window position'; + @override String get settingScrollToTopOnOpen => 'Scroll to top on open'; @@ -397,6 +400,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get subtitleHideOnDeactivate => 'Close window when clicking outside'; + @override + String get subtitleRememberWindowPosition => + 'Reopen the window where you left it last time'; + @override String get subtitleScrollToTopOnOpen => 'Resets scroll and selects latest item'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index a6ba6d8d..519f209c 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -377,6 +377,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settingHideOnDeactivate => 'Ocultar al hacer clic fuera'; + @override + String get settingRememberWindowPosition => 'Recordar posición de la ventana'; + @override String get settingScrollToTopOnOpen => 'Ir al inicio al abrir'; @@ -400,6 +403,10 @@ class AppLocalizationsEs extends AppLocalizations { String get subtitleHideOnDeactivate => 'Cerrar la ventana al hacer clic fuera'; + @override + String get subtitleRememberWindowPosition => + 'Reabrir la ventana donde la dejaste la última vez'; + @override String get subtitleScrollToTopOnOpen => 'Restablece el desplazamiento y selecciona el último elemento'; diff --git a/app/lib/main.dart b/app/lib/main.dart index 4df0d366..42091366 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -226,6 +226,7 @@ class _CopyPasteAppState extends State final _navigatorKey = GlobalKey(); StreamSubscription? _listenerSubscription; String? _lastTrayLocale; + Future? _pendingConfigSave; bool _showPermissionGate = false; bool _showOnboarding = false; bool _showWaylandUnsupported = false; @@ -246,6 +247,14 @@ class _CopyPasteAppState extends State showInTaskbar: false, popupWidth: _config.popupWidth.toDouble(), popupHeight: _config.popupHeight.toDouble(), + rememberPositionEnabled: () => _config.rememberWindowPosition, + savedPositionProvider: () { + final x = _config.lastWindowX; + final y = _config.lastWindowY; + if (x == null || y == null) return null; + return (x, y); + }, + onPositionPersist: _onPositionPersist, ); _trayIcon = TrayIcon(onToggle: _toggleWindow, onExit: _exitApp); _hotkeyHandler = HotkeyHandler(config: _config, onHotkey: _onHotkey); @@ -386,9 +395,8 @@ class _CopyPasteAppState extends State await _appWindow.enterGateMode(); } else { if (!_config.accessibilityWasGranted) { - _config = _config.copyWith(accessibilityWasGranted: true); unawaited( - _config.save('${widget.storage.configPath}/${AppConfig.fileName}'), + _persistConfig((c) => c.copyWith(accessibilityWasGranted: true)), ); } if (isFirstRun) { @@ -411,9 +419,10 @@ class _CopyPasteAppState extends State widget.storage.markAsInitialized(); } if (isUpdate && Platform.isWindows) { - _config = _config.copyWith(lastRunVersion: AppConfig.appVersion); unawaited( - _config.save('${widget.storage.configPath}/${AppConfig.fileName}'), + _persistConfig( + (c) => c.copyWith(lastRunVersion: AppConfig.appVersion), + ), ); } } @@ -434,9 +443,8 @@ class _CopyPasteAppState extends State AppLogger.error('Classifier migration failed: $e\n$s'); return; // version not saved → retries on next startup } - _config = _config.copyWith(lastRunVersion: AppConfig.appVersion); unawaited( - _config.save('${widget.storage.configPath}/${AppConfig.fileName}'), + _persistConfig((c) => c.copyWith(lastRunVersion: AppConfig.appVersion)), ); } @@ -728,13 +736,23 @@ class _CopyPasteAppState extends State void _dismissHint() { if (_config.hasSeenHint) return; - _config = _config.copyWith(hasSeenHint: true); - unawaited( - _config.save('${widget.storage.configPath}/${AppConfig.fileName}'), - ); + unawaited(_persistConfig((c) => c.copyWith(hasSeenHint: true))); if (mounted) setState(() {}); } + Future _persistConfig(AppConfig Function(AppConfig) update) { + _config = update(_config); + final path = '${widget.storage.configPath}/${AppConfig.fileName}'; + final next = (_pendingConfigSave ?? Future.value()) + .catchError((Object _) {}) + .then((_) => _config.save(path)); + _pendingConfigSave = next; + next.catchError((Object e) { + AppLogger.warn('config save failed: $e'); + }); + return next; + } + Future _updateLinuxConfig(AppConfig Function(AppConfig) update) async { final next = update(_config); if (identical(next, _config)) return; @@ -761,6 +779,13 @@ class _CopyPasteAppState extends State } } + void _onPositionPersist(double x, double y) { + if (_config.lastWindowX == x && _config.lastWindowY == y) return; + unawaited( + _persistConfig((c) => c.copyWith(lastWindowX: x, lastWindowY: y)), + ); + } + Future _onPasteItem( ClipboardItem item, { bool plainText = false, @@ -1047,24 +1072,22 @@ class _CopyPasteAppState extends State } Future _onPermissionGranted() async { - _config = _config.copyWith(accessibilityWasGranted: true); - unawaited( - _config.save('${widget.storage.configPath}/${AppConfig.fileName}'), - ); + unawaited(_persistConfig((c) => c.copyWith(accessibilityWasGranted: true))); await _appWindow.exitGateMode(); if (mounted) setState(() => _showPermissionGate = false); } Future _onOnboardingDismissed(AppConfig fromOnboarding) async { - _config = fromOnboarding.copyWith( - hasSeenOnboarding: true, - hasCompletedOnboarding: true, - lastRunVersion: AppConfig.appVersion, - ); - _applyOnboardingPersistence(); unawaited( - _config.save('${widget.storage.configPath}/${AppConfig.fileName}'), + _persistConfig( + (_) => fromOnboarding.copyWith( + hasSeenOnboarding: true, + hasCompletedOnboarding: true, + lastRunVersion: AppConfig.appVersion, + ), + ), ); + _applyOnboardingPersistence(); setState(() => _showOnboarding = false); await _appWindow.exitGateMode(); unawaited(_showStartupBalloon()); @@ -1074,15 +1097,16 @@ class _CopyPasteAppState extends State BuildContext ctx, AppConfig fromOnboarding, ) async { - _config = fromOnboarding.copyWith( - hasSeenOnboarding: true, - hasCompletedOnboarding: true, - lastRunVersion: AppConfig.appVersion, - ); - _applyOnboardingPersistence(); unawaited( - _config.save('${widget.storage.configPath}/${AppConfig.fileName}'), + _persistConfig( + (_) => fromOnboarding.copyWith( + hasSeenOnboarding: true, + hasCompletedOnboarding: true, + lastRunVersion: AppConfig.appVersion, + ), + ), ); + _applyOnboardingPersistence(); setState(() => _showOnboarding = false); await _appWindow.exitGateMode(); await Future.delayed(const Duration(milliseconds: 150)); diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index 3ec89e94..0f6bec94 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -83,6 +83,7 @@ class _SettingsScreenState extends State { late String _themeMode; late bool _hideOnDeactivate; + late bool _rememberWindowPosition; late bool _resetScrollOnShow; late bool _resetSearchOnShow; late bool _resetFiltersOnShow; @@ -131,6 +132,7 @@ class _SettingsScreenState extends State { _cardMaxLines = widget.config.cardMaxLines; _themeMode = widget.config.themeMode; _hideOnDeactivate = widget.config.hideOnDeactivate; + _rememberWindowPosition = widget.config.rememberWindowPosition; _resetScrollOnShow = widget.config.resetScrollOnShow; _resetSearchOnShow = widget.config.resetSearchOnShow; _resetFiltersOnShow = widget.config.resetFiltersOnShow; @@ -182,6 +184,9 @@ class _SettingsScreenState extends State { cardMaxLines: _cardMaxLines, themeMode: _themeMode, hideOnDeactivate: _hideOnDeactivate, + rememberWindowPosition: _rememberWindowPosition, + lastWindowX: _rememberWindowPosition ? widget.config.lastWindowX : null, + lastWindowY: _rememberWindowPosition ? widget.config.lastWindowY : null, resetScrollOnShow: _resetScrollOnShow, resetSearchOnShow: _resetSearchOnShow, resetFiltersOnShow: _resetFiltersOnShow, @@ -251,6 +256,7 @@ class _SettingsScreenState extends State { _cardMaxLines = d.cardMaxLines; _themeMode = d.themeMode; _hideOnDeactivate = d.hideOnDeactivate; + _rememberWindowPosition = d.rememberWindowPosition; _resetScrollOnShow = d.resetScrollOnShow; _resetSearchOnShow = d.resetSearchOnShow; _resetFiltersOnShow = d.resetFiltersOnShow; @@ -803,6 +809,16 @@ class _SettingsScreenState extends State { _markChanged(); }, ), + _ToggleRow( + label: l.settingRememberWindowPosition, + subtitle: l.subtitleRememberWindowPosition, + value: _rememberWindowPosition, + colors: colors, + onChanged: (v) { + setState(() => _rememberWindowPosition = v); + _markChanged(); + }, + ), _ToggleRow( label: l.settingScrollToTopOnOpen, subtitle: l.subtitleScrollToTopOnOpen, diff --git a/app/lib/shell/app_window.dart b/app/lib/shell/app_window.dart index 47c3e7d8..2ea46812 100644 --- a/app/lib/shell/app_window.dart +++ b/app/lib/shell/app_window.dart @@ -31,6 +31,36 @@ typedef _MonitorFromPointDart = int Function(int x, int y, int dwFlags); typedef _GetMonitorInfoWNative = Int32 Function(IntPtr hMonitor, Pointer lpmi); typedef _GetMonitorInfoWDart = int Function(int hMonitor, Pointer lpmi); +typedef _SetWindowPosNative = + Int32 Function( + IntPtr hWnd, + IntPtr hWndInsertAfter, + Int32 x, + Int32 y, + Int32 cx, + Int32 cy, + Uint32 uFlags, + ); +typedef _SetWindowPosDart = + int Function( + int hWnd, + int hWndInsertAfter, + int x, + int y, + int cx, + int cy, + int uFlags, + ); + +typedef _FindWindowWNative = + IntPtr Function(Pointer lpClassName, Pointer lpWindowName); +typedef _FindWindowWDart = + int Function(Pointer lpClassName, Pointer lpWindowName); + +typedef _GetWindowRectNative = + Int32 Function(IntPtr hWnd, Pointer lpRect); +typedef _GetWindowRectDart = int Function(int hWnd, Pointer lpRect); + class _Win32Pos { _Win32Pos._(); static _Win32Pos? _instance; @@ -51,6 +81,14 @@ class _Win32Pos { .lookupFunction<_GetMonitorInfoWNative, _GetMonitorInfoWDart>( 'GetMonitorInfoW', ); + late final setWindowPosFunc = _u32 + .lookupFunction<_SetWindowPosNative, _SetWindowPosDart>('SetWindowPos'); + late final findWindowFunc = _u32 + .lookupFunction<_FindWindowWNative, _FindWindowWDart>('FindWindowW'); + late final getWindowRectFunc = _u32 + .lookupFunction<_GetWindowRectNative, _GetWindowRectDart>( + 'GetWindowRect', + ); } class AppWindow { @@ -59,6 +97,9 @@ class AppWindow { this.showInTaskbar = true, double popupWidth = 360, double popupHeight = 500, + this.rememberPositionEnabled, + this.savedPositionProvider, + this.onPositionPersist, }) : _popupWidth = popupWidth, _popupHeight = popupHeight; @@ -68,6 +109,9 @@ class AppWindow { static const double _settingsHeight = 680; final void Function(bool visible)? onVisibilityChanged; + final bool Function()? rememberPositionEnabled; + final (double, double)? Function()? savedPositionProvider; + final void Function(double x, double y)? onPositionPersist; double _popupWidth; double _popupHeight; bool _visible = false; @@ -270,7 +314,48 @@ class AppWindow { x = x.clamp(waLeft, waRight - _popupWidth); y = y.clamp(waTop, waBottom - _popupHeight); - await windowManager.setPosition(Offset(x, y)); + if (Platform.isWindows) { + final ok = _setPositionWin32(x, y); + if (!ok) { + AppLogger.warn( + '_setPositionWin32 returned false, falling back to windowManager.setPosition', + ); + await windowManager.setPosition(Offset(x, y)); + } + } else { + await windowManager.setPosition(Offset(x, y)); + } + } + + static bool _setPositionWin32(double x, double y) { + try { + const swpNoSize = 0x0001; + const swpNoZOrder = 0x0004; + const swpNoActivate = 0x0010; + final w = _Win32Pos.instance; + final className = 'FLUTTER_RUNNER_WIN32_WINDOW'.toNativeUtf16(); + final windowName = 'CopyPaste'.toNativeUtf16(); + try { + final hwnd = w.findWindowFunc(className, windowName); + if (hwnd == 0) return false; + final result = w.setWindowPosFunc( + hwnd, + 0, + x.toInt(), + y.toInt(), + 0, + 0, + swpNoSize | swpNoZOrder | swpNoActivate, + ); + return result != 0; + } finally { + calloc.free(className); + calloc.free(windowName); + } + } catch (e) { + AppLogger.warn('_setPositionWin32 failed: $e'); + return false; + } } static (double, double)? _getCursorPosWin32() { @@ -332,15 +417,97 @@ class AppWindow { } } + static (double, double)? _getPositionWin32() { + try { + final w = _Win32Pos.instance; + final className = 'FLUTTER_RUNNER_WIN32_WINDOW'.toNativeUtf16(); + final windowName = 'CopyPaste'.toNativeUtf16(); + final rect = calloc(4); + try { + final hwnd = w.findWindowFunc(className, windowName); + if (hwnd == 0) return null; + final result = w.getWindowRectFunc(hwnd, rect); + if (result == 0) return null; + return (rect[0].toDouble(), rect[1].toDouble()); + } finally { + calloc.free(className); + calloc.free(windowName); + calloc.free(rect); + } + } catch (e) { + AppLogger.warn('_getPositionWin32 failed: $e'); + return null; + } + } + + static bool isPositionInSaneRange(double x, double y) { + if (!x.isFinite || !y.isFinite) return false; + if (x < -10000 || x > 50000) return false; + if (y < -10000 || y > 30000) return false; + return true; + } + + bool _isPositionVisible(double x, double y) { + if (!isPositionInSaneRange(x, y)) return false; + if (Platform.isWindows) { + try { + const monitorDefaultToNull = 0x00000000; + final w = _Win32Pos.instance; + final centerX = (x + _popupWidth / 2).toInt(); + final centerY = (y + _popupHeight / 2).toInt(); + final hMonitor = w.monitorFromPointFunc( + centerX, + centerY, + monitorDefaultToNull, + ); + return hMonitor != 0; + } catch (e) { + AppLogger.warn('_isPositionVisible failed: $e'); + return false; + } + } + return true; + } + + Future _tryRestoreSavedPosition() async { + if (rememberPositionEnabled?.call() != true) return false; + final saved = savedPositionProvider?.call(); + if (saved == null) return false; + final (x, y) = saved; + if (!_isPositionVisible(x, y)) return false; + if (Platform.isWindows) { + final ok = _setPositionWin32(x, y); + if (!ok) { + await windowManager.setPosition(Offset(x, y)); + } + } else { + await windowManager.setPosition(Offset(x, y)); + await Future.delayed(const Duration(milliseconds: 50)); + try { + final actual = await windowManager.getPosition(); + if ((actual.dx - x).abs() > 100 || (actual.dy - y).abs() > 100) { + return false; + } + } catch (_) {} + } + return true; + } + Future show() async { AppLogger.info('AppWindow.show: starting'); if (Platform.isLinux) { await windowManager.setSkipTaskbar(false); await windowManager.show(); - await _positionNearCursor(); + final restored = await _tryRestoreSavedPosition(); + if (!restored) { + await _positionNearCursor(); + } await LinuxShell.focusWindow(); } else { - await _positionNearCursor(); + final restored = await _tryRestoreSavedPosition(); + if (!restored) { + await _positionNearCursor(); + } if (Platform.isWindows) { await windowManager.setSkipTaskbar(false); } @@ -355,9 +522,34 @@ class AppWindow { onVisibilityChanged?.call(true); } + Future _captureCurrentPosition() async { + if (rememberPositionEnabled?.call() != true) return; + try { + double? x; + double? y; + if (Platform.isWindows) { + final pos = _getPositionWin32(); + if (pos != null) { + x = pos.$1; + y = pos.$2; + } + } else { + final pos = await windowManager.getPosition(); + x = pos.dx; + y = pos.dy; + } + if (x != null && y != null) { + onPositionPersist?.call(x, y); + } + } catch (e) { + AppLogger.warn('hide: failed to read window position: $e'); + } + } + Future hide() async { if (!_visible) return; _visible = false; + await _captureCurrentPosition(); if (showInTaskbar && Platform.isWindows) { await windowManager.minimize(); } else { @@ -394,6 +586,7 @@ class AppWindow { } Future enterSettingsMode() async { + await _captureCurrentPosition(); _settingsMode = true; await windowManager.setResizable(true); Future? configureFuture; @@ -447,6 +640,7 @@ class AppWindow { Future enterGateMode() async { AppLogger.info('AppWindow.enterGateMode: starting'); + await _captureCurrentPosition(); _gateMode = true; await windowManager.setResizable(false); await windowManager.setMinimumSize(const Size(_gateWidth, _gateHeight)); diff --git a/app/test/shell/app_window_range_test.dart b/app/test/shell/app_window_range_test.dart new file mode 100644 index 00000000..075f3ce2 --- /dev/null +++ b/app/test/shell/app_window_range_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:copypaste/shell/app_window.dart'; + +void main() { + group('AppWindow.isPositionInSaneRange', () { + test('origin (0, 0) is valid', () { + expect(AppWindow.isPositionInSaneRange(0, 0), isTrue); + }); + + test('(-9999, -9999) is within range', () { + expect(AppWindow.isPositionInSaneRange(-9999, -9999), isTrue); + }); + + test('x below -10000 is out of range', () { + expect(AppWindow.isPositionInSaneRange(-10001, 0), isFalse); + }); + + test('y below -10000 is out of range', () { + expect(AppWindow.isPositionInSaneRange(0, -10001), isFalse); + }); + + test('x above 50000 is out of range', () { + expect(AppWindow.isPositionInSaneRange(50001, 0), isFalse); + }); + + test('y above 30000 is out of range', () { + expect(AppWindow.isPositionInSaneRange(0, 30001), isFalse); + }); + + test('NaN x is invalid', () { + expect(AppWindow.isPositionInSaneRange(double.nan, 0), isFalse); + }); + + test('NaN y is invalid', () { + expect(AppWindow.isPositionInSaneRange(0, double.nan), isFalse); + }); + + test('infinite x is invalid', () { + expect(AppWindow.isPositionInSaneRange(double.infinity, 0), isFalse); + }); + + test('(-32000, -32000) is out of range (minimized Windows position)', () { + expect(AppWindow.isPositionInSaneRange(-32000, -32000), isFalse); + }); + + test('(1920, 1080) is valid (typical secondary monitor)', () { + expect(AppWindow.isPositionInSaneRange(1920, 1080), isTrue); + }); + + test('(3840, 0) is valid (4K secondary monitor to the right)', () { + expect(AppWindow.isPositionInSaneRange(3840, 0), isTrue); + }); + }); +} diff --git a/core/lib/config/app_config.dart b/core/lib/config/app_config.dart index d4a9ccb3..1a5b827e 100644 --- a/core/lib/config/app_config.dart +++ b/core/lib/config/app_config.dart @@ -47,6 +47,9 @@ class AppConfig { this.imagesQuotaMB = 0, this.linuxAppindicatorWarningDismissed = false, this.linuxXtestWarningDismissed = false, + this.rememberWindowPosition = false, + this.lastWindowX, + this.lastWindowY, }); factory AppConfig.fromJson(Map json) { @@ -137,6 +140,11 @@ class AppConfig { linuxXtestWarningDismissed: json['linuxXtestWarningDismissed'] as bool? ?? defaults.linuxXtestWarningDismissed, + rememberWindowPosition: + json['rememberWindowPosition'] as bool? ?? + defaults.rememberWindowPosition, + lastWindowX: (json['lastWindowX'] as num?)?.toDouble(), + lastWindowY: (json['lastWindowY'] as num?)?.toDouble(), ); } @@ -213,6 +221,10 @@ class AppConfig { final bool linuxAppindicatorWarningDismissed; final bool linuxXtestWarningDismissed; + final bool rememberWindowPosition; + final double? lastWindowX; + final double? lastWindowY; + AppConfig copyWith({ String? preferredLanguage, bool? runOnStartup, @@ -254,6 +266,9 @@ class AppConfig { int? imagesQuotaMB, bool? linuxAppindicatorWarningDismissed, bool? linuxXtestWarningDismissed, + bool? rememberWindowPosition, + Object? lastWindowX = _sentinel, + Object? lastWindowY = _sentinel, }) => AppConfig( preferredLanguage: preferredLanguage ?? this.preferredLanguage, runOnStartup: runOnStartup ?? this.runOnStartup, @@ -308,6 +323,14 @@ class AppConfig { this.linuxAppindicatorWarningDismissed, linuxXtestWarningDismissed: linuxXtestWarningDismissed ?? this.linuxXtestWarningDismissed, + rememberWindowPosition: + rememberWindowPosition ?? this.rememberWindowPosition, + lastWindowX: lastWindowX == _sentinel + ? this.lastWindowX + : lastWindowX as double?, + lastWindowY: lastWindowY == _sentinel + ? this.lastWindowY + : lastWindowY as double?, ); Map toJson() => { @@ -352,6 +375,9 @@ class AppConfig { 'imagesQuotaMB': imagesQuotaMB, 'linuxAppindicatorWarningDismissed': linuxAppindicatorWarningDismissed, 'linuxXtestWarningDismissed': linuxXtestWarningDismissed, + 'rememberWindowPosition': rememberWindowPosition, + if (lastWindowX != null) 'lastWindowX': lastWindowX, + if (lastWindowY != null) 'lastWindowY': lastWindowY, }; static Future load(String configPath) async { diff --git a/core/test/app_config_test.dart b/core/test/app_config_test.dart index 9b257302..f927d77d 100644 --- a/core/test/app_config_test.dart +++ b/core/test/app_config_test.dart @@ -795,4 +795,134 @@ void main() { ); }); }); + + group('AppConfig PR #12 window position fields', () { + test('default values', () { + const c = AppConfig(); + expect(c.rememberWindowPosition, isFalse); + expect(c.lastWindowX, isNull); + expect(c.lastWindowY, isNull); + }); + + test('toJson with defaults omits lastWindowX and lastWindowY', () { + const c = AppConfig(); + final json = c.toJson(); + expect(json.containsKey('lastWindowX'), isFalse); + expect(json.containsKey('lastWindowY'), isFalse); + expect(json.containsKey('rememberWindowPosition'), isTrue); + }); + + test('toJson with values present includes lastWindowX and lastWindowY', () { + const c = AppConfig(lastWindowX: 100.0, lastWindowY: 200.0); + final json = c.toJson(); + expect(json['lastWindowX'], equals(100.0)); + expect(json['lastWindowY'], equals(200.0)); + }); + + test('fromJson with values present reads them correctly', () { + final c = AppConfig.fromJson({ + 'lastWindowX': 123.5, + 'lastWindowY': 456.5, + 'rememberWindowPosition': true, + }); + expect(c.lastWindowX, equals(123.5)); + expect(c.lastWindowY, equals(456.5)); + expect(c.rememberWindowPosition, isTrue); + }); + + test( + 'fromJson with values absent leaves lastWindowX and lastWindowY null', + () { + final c = AppConfig.fromJson({}); + expect(c.lastWindowX, isNull); + expect(c.lastWindowY, isNull); + }, + ); + + test('fromJson with lastWindowX as int converts to double', () { + final c = AppConfig.fromJson({'lastWindowX': 1920}); + expect(c.lastWindowX, equals(1920.0)); + expect(c.lastWindowX, isA()); + }); + + test('rememberWindowPosition is always present in toJson', () { + const c1 = AppConfig(rememberWindowPosition: false); + const c2 = AppConfig(rememberWindowPosition: true); + expect(c1.toJson().containsKey('rememberWindowPosition'), isTrue); + expect(c1.toJson()['rememberWindowPosition'], isFalse); + expect(c2.toJson()['rememberWindowPosition'], isTrue); + }); + + test('copyWith without lastWindowX preserves existing value', () { + const c = AppConfig(lastWindowX: 100.0); + final updated = c.copyWith(rememberWindowPosition: true); + expect(updated.lastWindowX, equals(100.0)); + }); + + test('copyWith(lastWindowX: null) clears the value', () { + const c = AppConfig(lastWindowX: 100.0); + final updated = c.copyWith(lastWindowX: null); + expect(updated.lastWindowX, isNull); + }); + + test('copyWith(lastWindowX: 200.0) updates the value', () { + const c = AppConfig(lastWindowX: 100.0); + final updated = c.copyWith(lastWindowX: 200.0); + expect(updated.lastWindowX, equals(200.0)); + }); + + test('copyWith without lastWindowY preserves existing value', () { + const c = AppConfig(lastWindowY: 50.0); + final updated = c.copyWith(rememberWindowPosition: true); + expect(updated.lastWindowY, equals(50.0)); + }); + + test('copyWith(lastWindowY: null) clears the value', () { + const c = AppConfig(lastWindowY: 50.0); + final updated = c.copyWith(lastWindowY: null); + expect(updated.lastWindowY, isNull); + }); + + test('copyWith(lastWindowY: 300.0) updates the value', () { + const c = AppConfig(lastWindowY: 50.0); + final updated = c.copyWith(lastWindowY: 300.0); + expect(updated.lastWindowY, equals(300.0)); + }); + + test('copyWith(rememberWindowPosition: true) updates correctly', () { + const c = AppConfig(); + final updated = c.copyWith(rememberWindowPosition: true); + expect(updated.rememberWindowPosition, isTrue); + }); + + test( + 'copyWith without rememberWindowPosition preserves existing value', + () { + const c = AppConfig(rememberWindowPosition: true); + final updated = c.copyWith(lastWindowX: 10.0); + expect(updated.rememberWindowPosition, isTrue); + }, + ); + + test('save and load round-trip preserves window position fields', () async { + final dir = Directory.systemTemp.createTempSync( + 'config_window_pos_test_', + ); + final path = '${dir.path}/config.json'; + try { + const original = AppConfig( + rememberWindowPosition: true, + lastWindowX: 123.5, + lastWindowY: 456.5, + ); + await original.save(path); + final loaded = await AppConfig.load(path); + expect(loaded.rememberWindowPosition, isTrue); + expect(loaded.lastWindowX, equals(123.5)); + expect(loaded.lastWindowY, equals(456.5)); + } finally { + dir.deleteSync(recursive: true); + } + }); + }); } From 0595fd9350e278511200ea379cd22f94a0302da1 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 1 May 2026 21:51:54 -0400 Subject: [PATCH 2/3] fix: update monitor position handling for Windows and improve logging --- app/lib/shell/app_window.dart | 37 +++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/app/lib/shell/app_window.dart b/app/lib/shell/app_window.dart index 2ea46812..0acdd3a5 100644 --- a/app/lib/shell/app_window.dart +++ b/app/lib/shell/app_window.dart @@ -24,9 +24,8 @@ typedef _SystemParametersInfoWDart = typedef _GetCursorPosNative = Int32 Function(Pointer lpPoint); typedef _GetCursorPosDart = int Function(Pointer lpPoint); -typedef _MonitorFromPointNative = - IntPtr Function(Int32 x, Int32 y, Uint32 dwFlags); -typedef _MonitorFromPointDart = int Function(int x, int y, int dwFlags); +typedef _MonitorFromPointNative = IntPtr Function(Int64 pt, Uint32 dwFlags); +typedef _MonitorFromPointDart = int Function(int pt, int dwFlags); typedef _GetMonitorInfoWNative = Int32 Function(IntPtr hMonitor, Pointer lpmi); typedef _GetMonitorInfoWDart = int Function(int hMonitor, Pointer lpmi); @@ -337,6 +336,7 @@ class AppWindow { final windowName = 'CopyPaste'.toNativeUtf16(); try { final hwnd = w.findWindowFunc(className, windowName); + AppLogger.info('_setPositionWin32: hwnd=$hwnd target=($x,$y)'); if (hwnd == 0) return false; final result = w.setWindowPosFunc( hwnd, @@ -347,6 +347,7 @@ class AppWindow { 0, swpNoSize | swpNoZOrder | swpNoActivate, ); + AppLogger.info('_setPositionWin32: SetWindowPos result=$result'); return result != 0; } finally { calloc.free(className); @@ -370,6 +371,9 @@ class AppWindow { } } + static int _packPointWin32(int x, int y) => + ((y & 0xFFFFFFFF) << 32) | (x & 0xFFFFFFFF); + static (double, double, double, double)? _getWorkAreaForPointWin32( double x, double y, @@ -377,8 +381,7 @@ class AppWindow { const monitorDefaultToNearest = 0x00000002; final w = _Win32Pos.instance; final hMonitor = w.monitorFromPointFunc( - x.toInt(), - y.toInt(), + _packPointWin32(x.toInt(), y.toInt()), monitorDefaultToNearest, ); if (hMonitor == 0) return _getWorkAreaWin32(); @@ -456,8 +459,7 @@ class AppWindow { final centerX = (x + _popupWidth / 2).toInt(); final centerY = (y + _popupHeight / 2).toInt(); final hMonitor = w.monitorFromPointFunc( - centerX, - centerY, + _packPointWin32(centerX, centerY), monitorDefaultToNull, ); return hMonitor != 0; @@ -470,13 +472,19 @@ class AppWindow { } Future _tryRestoreSavedPosition() async { - if (rememberPositionEnabled?.call() != true) return false; + final enabled = rememberPositionEnabled?.call() == true; + AppLogger.info('_tryRestoreSavedPosition: enabled=$enabled'); + if (!enabled) return false; final saved = savedPositionProvider?.call(); + AppLogger.info('_tryRestoreSavedPosition: saved=$saved'); if (saved == null) return false; final (x, y) = saved; - if (!_isPositionVisible(x, y)) return false; + final visible = _isPositionVisible(x, y); + AppLogger.info('_tryRestoreSavedPosition: visible($x,$y)=$visible'); + if (!visible) return false; if (Platform.isWindows) { final ok = _setPositionWin32(x, y); + AppLogger.info('_tryRestoreSavedPosition: _setPositionWin32 ok=$ok'); if (!ok) { await windowManager.setPosition(Offset(x, y)); } @@ -486,6 +494,9 @@ class AppWindow { try { final actual = await windowManager.getPosition(); if ((actual.dx - x).abs() > 100 || (actual.dy - y).abs() > 100) { + AppLogger.info( + '_tryRestoreSavedPosition: actual=$actual rejected (>100px from target)', + ); return false; } } catch (_) {} @@ -505,6 +516,7 @@ class AppWindow { await LinuxShell.focusWindow(); } else { final restored = await _tryRestoreSavedPosition(); + AppLogger.info('AppWindow.show: restored=$restored'); if (!restored) { await _positionNearCursor(); } @@ -513,7 +525,12 @@ class AppWindow { } await windowManager.show(); await windowManager.focus(); - AppLogger.info('AppWindow.show: window shown and focused'); + if (Platform.isWindows) { + final actual = _getPositionWin32(); + AppLogger.info('AppWindow.show: window shown, actual position=$actual'); + } else { + AppLogger.info('AppWindow.show: window shown and focused'); + } if (Platform.isWindows) { await applyEffect(); } From 770b4c60a09653d3cb27e671a7744c3f51a175e5 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 1 May 2026 21:55:33 -0400 Subject: [PATCH 3/3] fix: attempt to restore saved window position before positioning near cursor --- app/lib/shell/app_window.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/lib/shell/app_window.dart b/app/lib/shell/app_window.dart index 0acdd3a5..368edd3b 100644 --- a/app/lib/shell/app_window.dart +++ b/app/lib/shell/app_window.dart @@ -646,7 +646,10 @@ class AppWindow { await configureFuture; } await windowManager.setResizable(false); - await _positionNearCursor(); + final restored = await _tryRestoreSavedPosition(); + if (!restored) { + await _positionNearCursor(); + } } static const double _gateWidth = 480;