From 1666af293ca6cc4d71c9338decbaceac04083656 Mon Sep 17 00:00:00 2001 From: Kristen McWilliam <9575627+Merrit@users.noreply.github.com> Date: Mon, 28 Aug 2023 16:54:31 -0400 Subject: [PATCH] new window position algorithm --- lib/app.dart | 30 ++- lib/apps_list/apps_list_page.dart | 35 +-- lib/main.dart | 19 +- lib/settings/cubit/settings_cubit.dart | 4 - lib/window/app_window.dart | 242 +++++++++++++++---- pubspec.lock | 4 +- pubspec.yaml | 2 +- test/settings/cubit/settings_cubit_test.dart | 6 - 8 files changed, 240 insertions(+), 102 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 96eb557a..c40bc454 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -36,12 +38,34 @@ class _AppState extends State with TrayListener, WindowListener { @override void onWindowClose() { + final appWindow = context.read(); + if (settingsCubit.state.closeToTray) { - AppWindow.instance.hide(); - return; + appWindow.hide(); } else { - super.onWindowClose(); + appWindow.close(); + } + } + + Timer? timer; + + @override + void onWindowEvent(String eventName) { + if (eventName == 'move' || eventName == 'resize') { + /// Set a timer between events that trigger saving the window size and + /// location. This is required because there is no notification available + /// for when these events *finish*, and therefore it would be triggered + /// hundreds of times otherwise during a move event. + timer?.cancel(); + timer = null; + timer = Timer( + const Duration(seconds: 5), + () { + context.read().saveWindowSizeAndPosition(); + }, + ); } + super.onWindowEvent(eventName); } @override diff --git a/lib/apps_list/apps_list_page.dart b/lib/apps_list/apps_list_page.dart index 8f61a9eb..6dc1b350 100644 --- a/lib/apps_list/apps_list_page.dart +++ b/lib/apps_list/apps_list_page.dart @@ -7,7 +7,6 @@ import '../app/app.dart'; import '../core/core.dart'; import '../settings/cubit/settings_cubit.dart'; import '../theme/theme.dart'; -import '../window/app_window.dart'; import 'apps_list.dart'; /// The main screen for Nyrna. @@ -22,27 +21,7 @@ class AppsListPage extends StatefulWidget { State createState() => _AppsListPageState(); } -class _AppsListPageState extends State - with WidgetsBindingObserver { - /// Tracks the current window size. - /// - /// Updated in [initState], [dispose], and [didChangeMetrics] so that - /// we can save the window size when the user resizes the window. - late Size _appWindowSize; - - @override - void initState() { - super.initState(); - // Listen for changes to the application's window size. - WidgetsBinding.instance.addObserver(this); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - +class _AppsListPageState extends State { @override void didChangeDependencies() { SchedulerBinding.instance.addPostFrameCallback((_) { @@ -53,16 +32,6 @@ class _AppsListPageState extends State super.didChangeDependencies(); } - @override - void didChangeMetrics() { - final updatedWindowSize = View.of(context).physicalSize; - if (_appWindowSize != updatedWindowSize) { - _appWindowSize = updatedWindowSize; - AppWindow.instance.saveWindowSize(); - } - super.didChangeMetrics(); - } - final ScrollController scrollController = ScrollController(); void _showFirstRunDialog() { @@ -75,8 +44,6 @@ class _AppsListPageState extends State @override Widget build(BuildContext context) { - _appWindowSize = View.of(context).physicalSize; - return Scaffold( appBar: const CustomAppBar(), body: BlocBuilder( diff --git a/lib/main.dart b/lib/main.dart index cd8e1325..216d23df 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,7 +7,6 @@ import 'package:helpers/helpers.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:window_manager/window_manager.dart'; import 'active_window/active_window.dart'; import 'app.dart'; @@ -28,7 +27,6 @@ import 'window/app_window.dart'; Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); - await windowManager.ensureInitialized(); // Parse command-line arguments. final argParser = ArgumentParser() // @@ -118,14 +116,19 @@ Future main(List args) async { ); runApp( - MultiBlocProvider( + MultiRepositoryProvider( providers: [ - BlocProvider.value(value: appCubit), - BlocProvider.value(value: appsListCubit), - BlocProvider.value(value: settingsCubit), - BlocProvider.value(value: themeCubit), + RepositoryProvider.value(value: appWindow), ], - child: const App(), + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: appCubit), + BlocProvider.value(value: appsListCubit), + BlocProvider.value(value: settingsCubit), + BlocProvider.value(value: themeCubit), + ], + child: const App(), + ), ), ); } diff --git a/lib/settings/cubit/settings_cubit.dart b/lib/settings/cubit/settings_cubit.dart index 336c29ba..6224ca05 100644 --- a/lib/settings/cubit/settings_cubit.dart +++ b/lib/settings/cubit/settings_cubit.dart @@ -11,7 +11,6 @@ import '../../core/core.dart'; import '../../hotkey/hotkey_service.dart'; import '../../logs/logging_manager.dart'; import '../../storage/storage_repository.dart'; -import '../../window/app_window.dart'; part 'settings_state.dart'; part 'settings_cubit.freezed.dart'; @@ -36,8 +35,6 @@ class SettingsCubit extends Cubit { for (final hotkey in state.appSpecificHotKeys) { _hotkeyService.addHotkey(hotkey.hotkey); } - - AppWindow.instance.preventClose(state.closeToTray); } static Future init({ @@ -134,7 +131,6 @@ class SettingsCubit extends Cubit { Future updateCloseToTray([bool? closeToTray]) async { if (closeToTray == null) return; - await AppWindow.instance.preventClose(closeToTray); await _storage.saveValue(key: 'closeToTray', value: closeToTray); emit(state.copyWith(closeToTray: closeToTray)); } diff --git a/lib/window/app_window.dart b/lib/window/app_window.dart index 07f4c30b..d99e9404 100644 --- a/lib/window/app_window.dart +++ b/lib/window/app_window.dart @@ -1,73 +1,227 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:ui'; +import 'package:flutter/material.dart'; import 'package:window_manager/window_manager.dart'; +import 'package:window_size/window_size.dart' as window_size; +import 'package:window_size/window_size.dart' show PlatformWindow, Screen; -import '../core/helpers/json_converters.dart'; -import '../logs/logs.dart'; +import '../logs/logging_manager.dart'; import '../storage/storage_repository.dart'; -/// Represents the main window of the app. class AppWindow { - final StorageRepository _storage; + final StorageRepository _storageRepository; - static late final AppWindow instance; + const AppWindow(this._storageRepository); - AppWindow(this._storage) { - instance = this; + Future initialize() async { + await windowManager.ensureInitialized(); + + const WindowOptions windowOptions = WindowOptions(); + windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.setPreventClose(true); + await setWindowSizeAndPosition(); + await windowManager.show(); + }); } - void initialize() { - windowManager.waitUntilReadyToShow().then((_) async { - final bool? startHiddenInTray = - await _storage.getValue('startHiddenInTray'); + void close() => exit(0); - if (startHiddenInTray != true) { - await show(); - } - }); + Future hide() async => await windowManager.hide(); + + /// Reset window size and position to default. + /// + /// This will also center the window on the primary screen. + /// Useful if the window is somehow moved off screen. + Future reset() async { + final screenConfigurationId = await _getScreenConfigurationId(); + await _storageRepository.deleteValue( + screenConfigurationId, + storageArea: 'windowSizeAndPosition', + ); + await setWindowSizeAndPosition(); } - Future preventClose(bool shouldPreventClose) async { - await windowManager.setPreventClose(shouldPreventClose); + /// Saves the current window size and position to storage. + /// + /// Allows us to restore the window size and position on the next run. + Future saveWindowSizeAndPosition() async { + // final windowInfo = await window_size.getWindowInfo(); + // final Rect frame = windowInfo.frame; + // final String screenConfigurationId = await _getScreenConfigurationId(); + + // log.t( + // 'Saving window size and position. \n' + // 'Screen configuration ID: $screenConfigurationId \n' + // 'Window bounds: left: ${frame.left}, top: ${frame.top}, ' + // 'width: ${frame.width}, height: ${frame.height}', + // ); + + // await _storageRepository.saveValue( + // storageArea: 'windowSizeAndPosition', + // key: screenConfigurationId, + // value: frame.toJson(), + // ); + + // `window_size` uses screen coordinates, so we need to convert them to + // logical pixels using the screen's `scaleFactor`. + final String screenConfigurationId = await _getScreenConfigurationId(); + final windowInfo = await window_size.getWindowInfo(); + + double? scaleFactor = windowInfo.screen?.scaleFactor ?? 1.0; + if (scaleFactor == 0) scaleFactor = 1.0; + + Rect frame = windowInfo.frame; + frame = Rect.fromLTWH( + frame.left / scaleFactor, + frame.top / scaleFactor, + frame.width / scaleFactor, + frame.height / scaleFactor, + ); } - /// Closes the app. - void close() => exit(0); + /// Sets the window size and position. + /// + /// If the window size and position has been saved previously, it will be + /// restored. Otherwise, the window will be centered on the primary screen. + Future setWindowSizeAndPosition() async { + log.t('Setting window size and position.'); + final screenConfigurationId = await _getScreenConfigurationId(); + final windowInfo = await window_size.getWindowInfo(); + final Rect currentWindowFrame = windowInfo.frame; + final double scaleFactor = windowInfo.scaleFactor; + + final String? savedWindowRectJson = await _storageRepository.getValue( + screenConfigurationId, + storageArea: 'windowSizeAndPosition', + ); + + Rect targetWindowFrame; + // Adjust for the scale factor. + if (savedWindowRectJson != null) { + targetWindowFrame = _rectFromJson(savedWindowRectJson); + targetWindowFrame = Rect.fromLTWH( + targetWindowFrame.left, + targetWindowFrame.top, + targetWindowFrame.width * scaleFactor, + targetWindowFrame.height * scaleFactor, + ); + } else { + targetWindowFrame = Rect.fromLTWH( + 0, + 0, + 530 * scaleFactor, + 600 * scaleFactor, + ); + } - /// Hides the app window. - Future hide() async { - await saveWindowSize(); - await windowManager.hide(); + if (targetWindowFrame == currentWindowFrame) { + log.t('Target matches current window frame, nothing to do.'); + return; + } + + log.t( + 'Screen configuration ID: $screenConfigurationId \n' + 'Current window bounds: \n' + 'left: ${currentWindowFrame.left}, top: ${currentWindowFrame.top}, ' + 'width: ${currentWindowFrame.width}, ' + 'height: ${currentWindowFrame.height} \n' + '~~~\n' + 'Target window bounds: \n' + 'left: ${targetWindowFrame.left}, top: ${targetWindowFrame.top}, ' + 'width: ${targetWindowFrame.width}, height: ${targetWindowFrame.height}', + ); + + window_size.setWindowFrame(targetWindowFrame); + + // If first run, center window. + if (savedWindowRectJson == null) await windowManager.center(); } - /// Reset the app window position. + Future show() async => await windowManager.show(); + + /// Returns a unique identifier for the current configuration of screens. /// - /// This can be useful if the window has been moved off-screen. - Future reset() async { - await windowManager.center(); + /// By using this, we can save the window position for each screen + /// configuration, and then restore the window position for the current + /// screen configuration. + Future _getScreenConfigurationId() async { + final screens = await window_size.getScreenList(); + final StringBuffer buffer = StringBuffer(); + for (final screen in screens) { + buffer + ..write(screen.frame.left) + ..write(screen.frame.top) + ..write(screen.frame.width) + ..write(screen.frame.height) + ..write(screen.scaleFactor); + } + return buffer.toString(); } +} - /// Shows the app window. - Future show() async { - final Rect? savedWindowSize = await getSavedWindowSize(); - if (savedWindowSize != null) windowManager.setBounds(savedWindowSize); - await windowManager.show(); +extension _PlatformWindowHelper on PlatformWindow { + Map toMap() { + return { + 'frame': frame.toMap(), + 'scaleFactor': scaleFactor, + 'screen': screen?.toMap(), + }; } - Future saveWindowSize() async { - final windowInfo = await windowManager.getBounds(); - final rectJson = windowInfo.toJson(); - log.i('Saving window info:\n$rectJson'); - await _storage.saveValue(key: 'windowSize', value: rectJson); + String toJson() => json.encode(toMap()); +} + +PlatformWindow _platformWindowFromJson(String source) { + final Map map = json.decode(source); + return PlatformWindow( + _rectFromJson(map['frame']), + map['scaleFactor'], + map['screen'] != null ? _screenFromJson(map['screen']) : null, + ); +} + +extension _RectHelper on Rect { + Map toMap() { + return { + 'left': left, + 'top': top, + 'width': width, + 'height': height, + }; } - /// Returns if available the last window size and position. - Future getSavedWindowSize() async { - final String? rectJson = await _storage.getValue('windowSize'); - if (rectJson == null) return null; - log.i('Retrieved saved window info:\n$rectJson'); - final windowRect = RectConverter.fromJson(rectJson); - return windowRect; + String toJson() => json.encode(toMap()); +} + +Rect _rectFromJson(String source) { + final Map map = json.decode(source); + return Rect.fromLTWH( + map['left'], + map['top'], + map['width'], + map['height'], + ); +} + +extension _ScreenHelper on Screen { + Map toMap() { + return { + 'frame': frame.toMap(), + 'visibleFrame': visibleFrame.toMap(), + 'scaleFactor': scaleFactor, + }; } + + String toJson() => json.encode(toMap()); +} + +Screen _screenFromJson(String source) { + final Map map = json.decode(source); + return Screen( + _rectFromJson(map['frame']), + _rectFromJson(map['visibleFrame']), + map['scaleFactor'], + ); } diff --git a/pubspec.lock b/pubspec.lock index 66f87fa9..c59a43f7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1160,8 +1160,8 @@ packages: dependency: "direct main" description: path: "plugins/window_size" - ref: "03d957e8b5c99fc83cd4a781031b154ab3de8753" - resolved-ref: "03d957e8b5c99fc83cd4a781031b154ab3de8753" + ref: "6c66ad23ee79749f30a8eece542cf54eaf157ed8" + resolved-ref: "6c66ad23ee79749f30a8eece542cf54eaf157ed8" url: "https://github.com/google/flutter-desktop-embedding.git" source: git version: "0.1.0" diff --git a/pubspec.yaml b/pubspec.yaml index 8cdfd08f..5702b272 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,7 +50,7 @@ dependencies: git: url: https://github.com/google/flutter-desktop-embedding.git path: plugins/window_size - ref: 03d957e8b5c99fc83cd4a781031b154ab3de8753 + ref: 6c66ad23ee79749f30a8eece542cf54eaf157ed8 xdg_desktop_portal: ^0.1.12 dev_dependencies: diff --git a/test/settings/cubit/settings_cubit_test.dart b/test/settings/cubit/settings_cubit_test.dart index c186cd9f..71577edf 100644 --- a/test/settings/cubit/settings_cubit_test.dart +++ b/test/settings/cubit/settings_cubit_test.dart @@ -30,10 +30,6 @@ late SettingsCubit cubit; SettingsState get state => cubit.state; void main() { - setUpAll((() { - AppWindow.instance = appWindow; - })); - setUp((() async { reset(appsListCubit); reset(appWindow); @@ -47,8 +43,6 @@ void main() { when(hotkeyService.addHotkey(any)).thenAnswer((_) async {}); when(hotkeyService.removeHotkey(any)).thenAnswer((_) async {}); - when(appWindow.preventClose(any)).thenAnswer((_) async {}); - when(storage.getValue('hotkey')).thenAnswer((_) async {}); when(storage.deleteValue(any)).thenAnswer((_) async {}); when(storage.saveValue(