From 5dc92fc36c6cffab7f8da401542d2248b7b09608 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 22 Jul 2026 16:03:53 -0700 Subject: [PATCH 01/29] Fix home-relative images in Snap --- lib/src/core/local_image_resolver.dart | 19 +++++++++++++++++-- test/src/app_smoke_test.dart | 24 +++++++++++++++--------- test/src/markdown_parser_test.dart | 7 +++++++ 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/lib/src/core/local_image_resolver.dart b/lib/src/core/local_image_resolver.dart index e8e8420..5ca43c9 100644 --- a/lib/src/core/local_image_resolver.dart +++ b/lib/src/core/local_image_resolver.dart @@ -5,6 +5,7 @@ import 'package:path/path.dart' as p; import 'path_utils.dart'; String? debugLocalImageHomeDirectoryOverride; +Map? debugLocalImageEnvironmentOverride; String? resolveLocalImagePath({ required String activeFilePath, @@ -243,10 +244,15 @@ String _expandHomeDirectory(String value) { if (value != '~' && !value.startsWith('~/') && !value.startsWith(r'~\')) { return value; } + final environment = + debugLocalImageEnvironmentOverride ?? Platform.environment; final home = debugLocalImageHomeDirectoryOverride ?? - Platform.environment['HOME'] ?? - Platform.environment['USERPROFILE']; + _firstNonEmpty([ + environment['SNAP_REAL_HOME'], + environment['HOME'], + environment['USERPROFILE'], + ]); if (home == null || home.trim().isEmpty) { return value; } @@ -255,3 +261,12 @@ String _expandHomeDirectory(String value) { } return p.normalize(p.join(home, value.substring(2))); } + +String? _firstNonEmpty(Iterable values) { + for (final value in values) { + if (value != null && value.trim().isNotEmpty) { + return value; + } + } + return null; +} diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 8199e6f..bbd5bfc 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -4061,22 +4061,28 @@ void main() { }, ); - testWidgets('shared Markdown image renderer resolves home-relative paths', ( + testWidgets('shared Markdown image renderer resolves Snap real-home paths', ( tester, ) async { - final fakeHome = Directory.systemTemp.createTempSync( - 'busymark_preview_image_home_', + final root = Directory.systemTemp.createTempSync( + 'busymark_preview_image_snap_home_', ); try { - final downloads = Directory('${fakeHome.path}/Downloads')..createSync(); - File('${downloads.path}/example.jpg').writeAsBytesSync( + final realHome = Directory(p.join(root.path, 'real-home'))..createSync(); + final snapHome = Directory(p.join(root.path, 'snap-home'))..createSync(); + final downloads = Directory(p.join(realHome.path, 'Downloads')) + ..createSync(); + File(p.join(downloads.path, 'example.jpg')).writeAsBytesSync( base64Decode( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8Kz3wAAAABJRU5ErkJggg==', ), ); - debugLocalImageHomeDirectoryOverride = fakeHome.path; + debugLocalImageEnvironmentOverride = { + 'SNAP_REAL_HOME': realHome.path, + 'HOME': snapHome.path, + }; addTearDown(() { - debugLocalImageHomeDirectoryOverride = null; + debugLocalImageEnvironmentOverride = null; }); await tester.pumpWidget( @@ -4101,8 +4107,8 @@ void main() { expect(find.byType(Image), findsOneWidget); expect(find.textContaining('~/Downloads/example.jpg'), findsNothing); } finally { - debugLocalImageHomeDirectoryOverride = null; - fakeHome.deleteSync(recursive: true); + debugLocalImageEnvironmentOverride = null; + root.deleteSync(recursive: true); } }); diff --git a/test/src/markdown_parser_test.dart b/test/src/markdown_parser_test.dart index d03d5cf..17c27d7 100644 --- a/test/src/markdown_parser_test.dart +++ b/test/src/markdown_parser_test.dart @@ -144,9 +144,15 @@ void main() { final downloads = Directory(p.join(fakeHome.path, 'Downloads')) ..createSync(); File(p.join(downloads.path, 'example.jpg')).writeAsBytesSync([0]); + final competingHome = Directory(p.join(fakeHome.path, 'snap-real-home')) + ..createSync(); debugLocalImageHomeDirectoryOverride = fakeHome.path; + debugLocalImageEnvironmentOverride = { + 'SNAP_REAL_HOME': competingHome.path, + }; addTearDown(() { debugLocalImageHomeDirectoryOverride = null; + debugLocalImageEnvironmentOverride = null; }); final parsed = parser.parse( @@ -161,6 +167,7 @@ void main() { ); } finally { debugLocalImageHomeDirectoryOverride = null; + debugLocalImageEnvironmentOverride = null; fakeHome.deleteSync(recursive: true); } }); From 57c4b3faf36b0ddad49728ca678d795bfdb16f21 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 25 Jul 2026 12:44:31 -0700 Subject: [PATCH 02/29] Add document outline support and improve heading ID generation --- lib/src/app/app_router.dart | 48 +- lib/src/app/app_theme.dart | 389 ++-- lib/src/app/busymark_app.dart | 434 ++-- lib/src/app/busymark_design.dart | 1960 ++++++----------- lib/src/app/busymark_dialogs.dart | 101 +- lib/src/app/busymark_search_field.dart | 115 + lib/src/app/system_accent.dart | 126 +- lib/src/core/path_utils.dart | 15 + lib/src/editor/source/source_editor.dart | 226 +- lib/src/editor/source/source_gutter.dart | 45 +- .../editor/wysiwyg/wysiwyg_block_widgets.dart | 33 +- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 186 +- lib/src/editor/wysiwyg/wysiwyg_toolbar.dart | 32 - .../presentation/feedback_dialog.dart | 3 - .../git/presentation/git_changes_view.dart | 31 +- .../git/presentation/git_history_view.dart | 27 +- lib/src/git/presentation/git_sidebar_tab.dart | 76 +- lib/src/markdown/document_outline.dart | 78 + lib/src/markdown/markdown_ast_adapter.dart | 6 +- lib/src/markdown/markdown_parser.dart | 284 +-- lib/src/markdown/preview_model.dart | 39 + lib/src/markdown/raw_html_adapter.dart | 6 +- .../platform/header_bar_configuration.dart | 546 +++++ .../platform/linux_header_bar_service.dart | 432 ++-- .../presentation/settings_screen.dart | 333 ++- .../presentation/welcome_screen.dart | 168 +- .../presentation/workspace_screen.dart | 1043 ++++----- lib/src/workspace/workspace_controller.dart | 70 +- lib/src/workspace/workspace_model.dart | 33 +- lib/src/workspace/workspace_safety.dart | 18 +- linux/runner/my_application.cc | 1518 +++++++------ test/src/app_router_test.dart | 130 ++ test/src/app_smoke_test.dart | 334 ++- test/src/busymark_design_test.dart | 844 ++++++- test/src/busymark_dialogs_test.dart | 63 + test/src/busymark_document_test.dart | 96 +- test/src/busymark_search_field_test.dart | 94 + test/src/editor_ui_primitives_audit_test.dart | 74 + test/src/feedback_dialog_test.dart | 2 +- test/src/git/git_widget_test.dart | 3 + test/src/header_bar_configuration_test.dart | 489 ++++ test/src/linux_header_bar_service_test.dart | 57 + test/src/markdown_parser_test.dart | 55 + test/src/native_headerbar_audit_test.dart | 694 +++--- test/src/preview_builder_test.dart | 21 + test/src/source_audit_test.dart | 458 ++-- test/src/source_editor_widget_test.dart | 67 + test/src/status_semantics_test.dart | 190 ++ test/src/system_accent_test.dart | 89 +- test/src/workspace_controller_test.dart | 12 +- test/src/workspace_safety_test.dart | 46 +- ...writerside_topic_removal_service_test.dart | 10 +- 52 files changed, 7502 insertions(+), 4747 deletions(-) create mode 100644 lib/src/app/busymark_search_field.dart create mode 100644 lib/src/markdown/document_outline.dart create mode 100644 lib/src/platform/header_bar_configuration.dart create mode 100644 test/src/app_router_test.dart create mode 100644 test/src/busymark_search_field_test.dart create mode 100644 test/src/editor_ui_primitives_audit_test.dart create mode 100644 test/src/header_bar_configuration_test.dart create mode 100644 test/src/status_semantics_test.dart diff --git a/lib/src/app/app_router.dart b/lib/src/app/app_router.dart index 1edbe49..9e9b6ed 100644 --- a/lib/src/app/app_router.dart +++ b/lib/src/app/app_router.dart @@ -6,6 +6,45 @@ import '../workspace/presentation/settings_screen.dart'; import '../workspace/presentation/welcome_screen.dart'; import '../workspace/presentation/workspace_screen.dart'; +const settingsRoutePath = '/settings'; +const _settingsReturnTargetParameter = 'returnTo'; + +enum SettingsReturnTarget { + welcome('/'), + workspace('/workspace'); + + const SettingsReturnTarget(this.location); + + final String location; + + static SettingsReturnTarget fromSettingsUri(Uri uri) { + final encodedTarget = uri.queryParameters[_settingsReturnTargetParameter]; + return SettingsReturnTarget.values.firstWhere( + (target) => target.name == encodedTarget, + orElse: () => SettingsReturnTarget.welcome, + ); + } +} + +String settingsLocation(SettingsReturnTarget returnTarget) { + return Uri( + path: settingsRoutePath, + queryParameters: {_settingsReturnTargetParameter: returnTarget.name}, + ).toString(); +} + +SettingsReturnTarget settingsReturnTargetForUri(Uri currentUri) { + return switch (currentUri.path) { + settingsRoutePath => SettingsReturnTarget.fromSettingsUri(currentUri), + '/workspace' => SettingsReturnTarget.workspace, + _ => SettingsReturnTarget.welcome, + }; +} + +String settingsLocationForUri(Uri currentUri) { + return settingsLocation(settingsReturnTargetForUri(currentUri)); +} + final rootNavigatorKey = GlobalKey( debugLabel: 'BusyMark root navigator', ); @@ -26,9 +65,12 @@ final appRouterProvider = Provider((ref) { const NoTransitionPage(child: WorkspaceScreen()), ), GoRoute( - path: '/settings', - pageBuilder: (context, state) => - const NoTransitionPage(child: SettingsScreen()), + path: settingsRoutePath, + pageBuilder: (context, state) => NoTransitionPage( + child: SettingsScreen( + returnTarget: SettingsReturnTarget.fromSettingsUri(state.uri), + ), + ), ), ], ); diff --git a/lib/src/app/app_theme.dart b/lib/src/app/app_theme.dart index 0850fd8..9fec03b 100644 --- a/lib/src/app/app_theme.dart +++ b/lib/src/app/app_theme.dart @@ -9,73 +9,131 @@ ThemeData buildBusyMarkTheme({ required Color accentColor, }) { final base = switch (brightness) { - Brightness.light => createYaruLightTheme( - primaryColor: BusyMarkLinuxPalette.light4, - ), - Brightness.dark => createYaruDarkTheme( - primaryColor: BusyMarkLinuxPalette.light2, - ), + Brightness.light => createYaruLightTheme(primaryColor: accentColor), + Brightness.dark => createYaruDarkTheme(primaryColor: accentColor), }; - final colors = BusyMarkSurfaceColors.fromBrightness(brightness); + final colors = BusyMarkSurfaceColors.fromTheme(base); final syntaxColors = BusyMarkSyntaxColors.fromSurfaceColors( brightness, colors, ); - final onAccent = contrastColor(accentColor); - final selectedContainer = colors.controlActive; + final onAccent = _accessibleForeground(accentColor); + final accentContainer = Color.alphaBlend( + accentColor.withValues(alpha: brightness == Brightness.dark ? 0.24 : 0.14), + colors.view, + ); final colorScheme = base.colorScheme.copyWith( brightness: brightness, primary: accentColor, onPrimary: onAccent, - primaryContainer: selectedContainer, - onPrimaryContainer: colors.foreground, - secondary: BusyMarkLinuxPalette.blueAccent, - error: BusyMarkLinuxPalette.red, + primaryContainer: accentContainer, + onPrimaryContainer: _accessibleForeground(accentContainer), + secondary: accentColor, + onError: _accessibleForeground(base.colorScheme.error), surface: colors.view, onSurface: colors.foreground, onSurfaceVariant: colors.mutedForeground, - surfaceContainerLowest: colors.window, - surfaceContainerLow: colors.view, + // Keep Material fallbacks on the same opaque, neutral elevation ladder as + // BusyMark's native Linux surfaces. Control fills remain translucent state + // layers and must not leak into generic surface-container backgrounds. + surfaceContainerLowest: colors.view, + surfaceContainerLow: colors.window, surfaceContainer: colors.panel, - surfaceContainerHigh: colors.control, - surfaceContainerHighest: colors.controlHover, + surfaceContainerHigh: colors.secondarySidebar, + surfaceContainerHighest: colors.sidebar, outline: colors.border, - outlineVariant: colors.subtleBorder, + outlineVariant: colors.divider, scrim: BusyMarkLinuxPalette.black, ); - final buttonShape = RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMarkRadius.headerButton), + final textTheme = _busyMarkTextTheme(base.textTheme, colors); + final inputDecorationTheme = base.inputDecorationTheme; + final outlinedButtonStyle = _semanticButtonStyle( + base.outlinedButtonTheme.style, + foreground: colors.foreground, + background: BusyMarkLinuxPalette.transparent, + disabledForeground: colors.disabledForeground, + disabledBackground: BusyMarkLinuxPalette.transparent, ); - final inputBorder = OutlineInputBorder( - borderSide: BorderSide(color: colors.border), - borderRadius: BorderRadius.circular(BusyMarkRadius.sm + 2), + final filledButtonStyle = _semanticButtonStyle( + base.filledButtonTheme.style, + foreground: colors.foreground, + background: colors.control, + selectedBackground: colors.controlActive, + disabledForeground: colors.disabledForeground, + disabledBackground: colors.disabledControl, ); - final focusedInputBorder = inputBorder.copyWith( - borderSide: BorderSide(color: accentColor, width: BusyMarkStroke.focus), + final elevatedButtonStyle = _semanticButtonStyle( + base.elevatedButtonTheme.style, + foreground: onAccent, + background: accentColor, + disabledForeground: colors.disabledForeground, + disabledBackground: colors.disabledControl, ); - final textTheme = _busyMarkTextTheme(base.textTheme, colors); - final buttonText = WidgetStatePropertyAll(textTheme.labelLarge); - final inputDecorationTheme = base.inputDecorationTheme.copyWith( - filled: true, - fillColor: colors.control, - border: inputBorder, - enabledBorder: inputBorder, - focusedBorder: focusedInputBorder, - focusedErrorBorder: inputBorder.copyWith( - borderSide: BorderSide( - color: colorScheme.error, - width: BusyMarkStroke.focus, - ), - ), - contentPadding: BusyMarkInsets.input, - hintStyle: textTheme.bodyMedium?.copyWith(color: colors.mutedForeground), + final textButtonStyle = _semanticButtonStyle( + base.textButtonTheme.style, + foreground: accentColor, + background: BusyMarkLinuxPalette.transparent, + disabledForeground: colors.disabledForeground, + disabledBackground: BusyMarkLinuxPalette.transparent, + ); + final yaruButtonGeometry = base.filledButtonTheme.style; + final toggleConstraints = base.toggleButtonsTheme.constraints; + final segmentedShape = + yaruButtonGeometry?.shape ?? + switch (base.toggleButtonsTheme.borderRadius) { + final BorderRadius borderRadius => + WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: borderRadius), + ), + _ => null, + }; + final segmentedMinimumSize = + yaruButtonGeometry?.minimumSize ?? + (toggleConstraints == null + ? null + : WidgetStatePropertyAll( + Size(toggleConstraints.minWidth, toggleConstraints.minHeight), + )); + final segmentedButtonStyle = + (base.segmentedButtonTheme.style ?? const ButtonStyle()).copyWith( + shape: segmentedShape, + padding: yaruButtonGeometry?.padding, + minimumSize: segmentedMinimumSize, + visualDensity: yaruButtonGeometry?.visualDensity, + tapTargetSize: yaruButtonGeometry?.tapTargetSize, + foregroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colors.disabledForeground; + } + return colors.foreground; + }), + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colors.disabledControl; + } + if (states.contains(WidgetState.selected)) { + return colors.controlActive; + } + return colors.control; + }), + side: const WidgetStatePropertyAll(BorderSide.none), + ); + final menuStyle = _semanticMenuSurfaceStyle( + base.menuTheme.style, + color: colors.popover, + shadowColor: colorScheme.shadow, + ); + final dropdownMenuStyle = _semanticMenuSurfaceStyle( + base.dropdownMenuTheme.menuStyle, + color: colors.popover, + shadowColor: colorScheme.shadow, ); return base.copyWith( brightness: brightness, colorScheme: colorScheme, primaryColor: accentColor, - shadowColor: colors.shade, + shadowColor: colorScheme.shadow, scaffoldBackgroundColor: colors.window, canvasColor: colors.window, cardColor: colors.card, @@ -87,20 +145,13 @@ ThemeData buildBusyMarkTheme({ colors, syntaxColors, ], - dividerColor: colors.subtleBorder, - visualDensity: VisualDensity.compact, - splashFactory: NoSplash.splashFactory, - focusColor: accentColor.withValues(alpha: BusyMarkAlpha.focus), - hoverColor: colors.controlHover, - splashColor: accentColor.withValues(alpha: BusyMarkAlpha.splash), + dividerColor: colors.divider, appBarTheme: base.appBarTheme.copyWith( elevation: BusyMarkElevation.none, scrolledUnderElevation: BusyMarkElevation.none, backgroundColor: colors.headerbar, foregroundColor: colors.foreground, surfaceTintColor: colors.headerbar, - shape: Border(bottom: BorderSide(color: colors.subtleBorder)), - toolbarHeight: BusyMarkSizes.toolbarHeight, systemOverlayStyle: brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, @@ -110,164 +161,47 @@ ThemeData buildBusyMarkTheme({ dialogTheme: base.dialogTheme.copyWith( backgroundColor: colors.dialog, surfaceTintColor: colors.dialog, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMarkRadius.lg), - side: BorderSide(color: colors.border), - ), titleTextStyle: textTheme.titleLarge, contentTextStyle: textTheme.bodyMedium, ), listTileTheme: base.listTileTheme.copyWith( selectedColor: colors.foreground, - selectedTileColor: selectedContainer, + selectedTileColor: accentContainer, iconColor: colors.mutedForeground, textColor: colors.foreground, - contentPadding: BusyMarkInsets.listTile, - titleTextStyle: textTheme.bodyMedium, - subtitleTextStyle: textTheme.bodySmall, - leadingAndTrailingTextStyle: textTheme.labelSmall, ), inputDecorationTheme: inputDecorationTheme, - outlinedButtonTheme: OutlinedButtonThemeData( - style: _buttonStyle( - base.outlinedButtonTheme.style, - shape: buttonShape, - foreground: colors.foreground, - background: colors.control, - disabledForeground: colors.disabledForeground, - disabledBackground: colors.disabledControl, - textStyle: buttonText, - side: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.focused)) { - return BorderSide(color: accentColor); - } - return BorderSide.none; - }), - ), - ), - filledButtonTheme: FilledButtonThemeData( - style: _buttonStyle( - base.filledButtonTheme.style, - shape: buttonShape, - foreground: onAccent, - background: accentColor, - disabledForeground: colors.disabledForeground, - disabledBackground: colors.disabledControl, - overlayColor: _controlOverlay(onAccent), - textStyle: buttonText, - ), - ), - textButtonTheme: TextButtonThemeData( - style: _buttonStyle( - base.textButtonTheme.style, - shape: buttonShape, - foreground: accentColor, - background: BusyMarkLinuxPalette.transparent, - disabledForeground: colors.disabledForeground, - disabledBackground: BusyMarkLinuxPalette.transparent, - overlayColor: _controlOverlay(accentColor), - textStyle: buttonText, - ), - ), - iconButtonTheme: IconButtonThemeData( - style: _buttonStyle( - base.iconButtonTheme.style, - shape: buttonShape, - foreground: colors.mutedForeground, - background: BusyMarkLinuxPalette.transparent, - disabledForeground: colors.disabledForeground, - disabledBackground: BusyMarkLinuxPalette.transparent, - overlayColor: _controlOverlay(accentColor), - textStyle: buttonText, - ), - ), - segmentedButtonTheme: SegmentedButtonThemeData( - style: - _buttonStyle( - base.segmentedButtonTheme.style, - shape: buttonShape, - foreground: colors.foreground, - background: colors.control, - disabledForeground: colors.disabledForeground, - disabledBackground: colors.disabledControl, - overlayColor: _controlOverlay(accentColor), - textStyle: buttonText, - side: const WidgetStatePropertyAll(BorderSide.none), - ).copyWith( - foregroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return colors.disabledForeground; - } - if (states.contains(WidgetState.selected)) { - return onAccent; - } - return colors.foreground; - }), - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return colors.disabledControl; - } - if (states.contains(WidgetState.selected)) { - return accentColor; - } - return selectedContainer; - }), - ), - ), - switchTheme: SwitchThemeData( - thumbColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return colors.disabledForeground; - } - if (states.contains(WidgetState.selected)) { - return onAccent; - } - return colors.view; - }), - trackColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return colors.disabledControl; - } - if (states.contains(WidgetState.selected)) { - return accentColor; - } - return colors.controlHover; - }), - trackOutlineColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return accentColor; - } - return colors.border; - }), - ), + outlinedButtonTheme: OutlinedButtonThemeData(style: outlinedButtonStyle), + filledButtonTheme: FilledButtonThemeData(style: filledButtonStyle), + elevatedButtonTheme: ElevatedButtonThemeData(style: elevatedButtonStyle), + textButtonTheme: TextButtonThemeData(style: textButtonStyle), + segmentedButtonTheme: SegmentedButtonThemeData(style: segmentedButtonStyle), popupMenuTheme: base.popupMenuTheme.copyWith( color: colors.popover, surfaceTintColor: colors.popover, - elevation: BusyMarkElevation.popover, - shadowColor: colors.shade, + shadowColor: colorScheme.shadow, iconColor: colors.mutedForeground, - iconSize: BusyMarkSizes.iconSm, textStyle: textTheme.bodyMedium, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMarkRadius.md), - ), + labelTextStyle: WidgetStateProperty.resolveWith((states) { + return textTheme.bodyMedium?.copyWith( + color: states.contains(WidgetState.disabled) + ? colors.disabledForeground + : colors.foreground, + ); + }), + ), + menuTheme: MenuThemeData( + style: menuStyle, + submenuIcon: base.menuTheme.submenuIcon, + ), + dropdownMenuTheme: base.dropdownMenuTheme.copyWith( + textStyle: textTheme.bodyMedium, + menuStyle: dropdownMenuStyle, ), tabBarTheme: base.tabBarTheme.copyWith( labelStyle: textTheme.labelLarge, unselectedLabelStyle: textTheme.labelLarge, - dividerColor: colors.subtleBorder, - ), - tooltipTheme: base.tooltipTheme.copyWith( - decoration: BoxDecoration( - color: colors.popover, - borderRadius: BorderRadius.circular(BusyMarkRadius.headerButton), - boxShadow: BusyMarkShadow.floatingShadows(colors.shade), - ), - padding: const EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.tooltipHorizontal, - vertical: BusyMarkSpacing.tooltipVertical, - ), - textStyle: textTheme.bodyMedium?.copyWith(color: colors.foreground), + dividerColor: colors.divider, ), progressIndicatorTheme: ProgressIndicatorThemeData( color: accentColor, @@ -281,27 +215,27 @@ ThemeData buildBusyMarkTheme({ ), selectionHandleColor: accentColor, ), - cardTheme: CardThemeData( + cardTheme: base.cardTheme.copyWith( color: colors.card, elevation: BusyMarkElevation.surface, surfaceTintColor: BusyMarkLinuxPalette.transparent, - shadowColor: colors.shade, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMarkRadius.md), - ), + shadowColor: colorScheme.shadow, ), ); } +Color _accessibleForeground(Color background) { + final backgroundLuminance = background.computeLuminance(); + final blackContrast = (backgroundLuminance + 0.05) / 0.05; + final whiteContrast = 1.05 / (backgroundLuminance + 0.05); + return blackContrast >= whiteContrast + ? BusyMarkLinuxPalette.black + : BusyMarkLinuxPalette.white; +} + TextTheme _busyMarkTextTheme(TextTheme base, BusyMarkSurfaceColors colors) { - TextStyle? apply(TextStyle? style, {Color? color}) { - return style?.copyWith( - color: color, - fontFamily: BusyMarkTypography.fontFamily, - fontFamilyFallback: BusyMarkTypography.fontFamilyFallback, - letterSpacing: 0, - ); - } + TextStyle? apply(TextStyle? style, {Color? color}) => + style?.copyWith(color: color); return base.copyWith( displayLarge: apply(base.displayLarge, color: colors.foreground), @@ -322,50 +256,51 @@ TextTheme _busyMarkTextTheme(TextTheme base, BusyMarkSurfaceColors colors) { ); } -ButtonStyle _buttonStyle( +/// Applies BusyMark's semantic roles without replacing Yaru's geometry, +/// typography, hover/press overlays, focus treatment, or motion. +ButtonStyle _semanticButtonStyle( ButtonStyle? base, { - required OutlinedBorder shape, required Color foreground, required Color background, + Color? selectedBackground, required Color disabledForeground, required Color disabledBackground, - WidgetStateProperty? overlayColor, - WidgetStateProperty? side, - WidgetStateProperty? textStyle, }) { return (base ?? const ButtonStyle()).copyWith( - visualDensity: const VisualDensity(horizontal: -1, vertical: -1), - textStyle: textStyle, - shape: WidgetStatePropertyAll(shape), foregroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) { return disabledForeground; } return foreground; }), + iconColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return disabledForeground; + } + return foreground; + }), backgroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) { return disabledBackground; } + if (selectedBackground != null && states.contains(WidgetState.selected)) { + return selectedBackground; + } return background; }), - overlayColor: overlayColor ?? _controlOverlay(foreground), - side: side ?? const WidgetStatePropertyAll(BorderSide.none), - elevation: const WidgetStatePropertyAll(BusyMarkElevation.none), ); } -WidgetStateProperty _controlOverlay(Color foreground) { - return WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) { - return foreground.withValues(alpha: BusyMarkAlpha.overlayPressed); - } - if (states.contains(WidgetState.hovered)) { - return foreground.withValues(alpha: BusyMarkAlpha.overlayHover); - } - if (states.contains(WidgetState.focused)) { - return foreground.withValues(alpha: BusyMarkAlpha.overlayFocus); - } - return null; - }); +/// Changes only the floating-surface roles and keeps Yaru's menu geometry, +/// item states, padding, focus behavior, and animation. +MenuStyle _semanticMenuSurfaceStyle( + MenuStyle? base, { + required Color color, + required Color shadowColor, +}) { + return (base ?? const MenuStyle()).copyWith( + backgroundColor: WidgetStatePropertyAll(color), + surfaceTintColor: WidgetStatePropertyAll(color), + shadowColor: WidgetStatePropertyAll(shadowColor), + ); } diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart index 33640fe..e17d05f 100644 --- a/lib/src/app/busymark_app.dart +++ b/lib/src/app/busymark_app.dart @@ -70,210 +70,224 @@ class BusyMarkApp extends ConsumerWidget { ], supportedLocales: AppLocalizations.supportedLocales, builder: (context, child) { - _configureNativeHeaderBar(context, ref, settings); - return _BusyMarkWindowLifecycle( - child: Shortcuts( - shortcuts: { - BusyMarkAppShortcutActivators.newDocument: - const _NewMarkdownIntent(), - BusyMarkAppShortcutActivators.open: const _OpenWorkspaceIntent(), - BusyMarkAppShortcutActivators.save: const _SaveActiveIntent(), - BusyMarkAppShortcutActivators.keyboardShortcuts: - const _KeyboardShortcutsIntent(), - BusyMarkAppShortcutActivators.settings: const _SettingsIntent(), - BusyMarkAppShortcutActivators.markdownAndHtml: - const _MarkdownAndHtmlIntent(), - BusyMarkAppShortcutActivators.nextTab: const _NextTabIntent(), - BusyMarkAppShortcutActivators.previousTab: - const _PreviousTabIntent(), - BusyMarkAppShortcutActivators.closeTab: const _CloseTabIntent(), - BusyMarkAppShortcutActivators.closeAllTabs: - const _CloseAllTabsIntent(), - BusyMarkAppShortcutActivators.search: const _OpenSearchIntent(), - BusyMarkAppShortcutActivators.toggleSidebar: - const _ToggleSidebarIntent(), - BusyMarkDocumentViewShortcutActivators.editor: - const _DocumentViewModeIntent( - DocumentViewModePreference.editor, - ), - BusyMarkDocumentViewShortcutActivators.source: - const _DocumentViewModeIntent( - DocumentViewModePreference.source, + final headerBarDefaults = _nativeHeaderBarDefaults(context, settings); + return HeaderBarConfigurationDefaults( + configuration: headerBarDefaults, + child: _BusyMarkWindowLifecycle( + child: Shortcuts( + shortcuts: { + BusyMarkAppShortcutActivators.newDocument: + const _NewMarkdownIntent(), + BusyMarkAppShortcutActivators.open: + const _OpenWorkspaceIntent(), + BusyMarkAppShortcutActivators.save: const _SaveActiveIntent(), + BusyMarkAppShortcutActivators.keyboardShortcuts: + const _KeyboardShortcutsIntent(), + BusyMarkAppShortcutActivators.settings: const _SettingsIntent(), + BusyMarkAppShortcutActivators.markdownAndHtml: + const _MarkdownAndHtmlIntent(), + BusyMarkAppShortcutActivators.nextTab: const _NextTabIntent(), + BusyMarkAppShortcutActivators.previousTab: + const _PreviousTabIntent(), + BusyMarkAppShortcutActivators.closeTab: const _CloseTabIntent(), + BusyMarkAppShortcutActivators.closeAllTabs: + const _CloseAllTabsIntent(), + BusyMarkAppShortcutActivators.search: const _OpenSearchIntent(), + BusyMarkAppShortcutActivators.toggleSidebar: + const _ToggleSidebarIntent(), + BusyMarkDocumentViewShortcutActivators.editor: + const _DocumentViewModeIntent( + DocumentViewModePreference.editor, + ), + BusyMarkDocumentViewShortcutActivators.source: + const _DocumentViewModeIntent( + DocumentViewModePreference.source, + ), + BusyMarkDocumentViewShortcutActivators.preview: + const _DocumentViewModeIntent( + DocumentViewModePreference.preview, + ), + BusyMarkDocumentViewShortcutActivators.split: + const _DocumentViewModeIntent( + DocumentViewModePreference.split, + ), + }, + child: Actions( + actions: { + _NewMarkdownIntent: CallbackAction<_NewMarkdownIntent>( + onInvoke: (intent) { + unawaited(() async { + final navigatorContext = + rootNavigatorKey.currentContext; + if (navigatorContext == null) { + return; + } + final safe = await confirmSafeToContinue( + navigatorContext, + ref, + ); + if (!safe || !navigatorContext.mounted) { + return; + } + await ref + .read(workspaceControllerProvider.notifier) + .createMarkdownFile(); + if (navigatorContext.mounted) { + router.go('/workspace'); + } + }()); + return null; + }, ), - BusyMarkDocumentViewShortcutActivators.preview: - const _DocumentViewModeIntent( - DocumentViewModePreference.preview, + _OpenWorkspaceIntent: CallbackAction<_OpenWorkspaceIntent>( + onInvoke: (intent) { + final navigatorContext = rootNavigatorKey.currentContext; + if (navigatorContext != null) { + unawaited( + _showOpenChooser(navigatorContext, ref, router), + ); + } + return null; + }, ), - BusyMarkDocumentViewShortcutActivators.split: - const _DocumentViewModeIntent( - DocumentViewModePreference.split, + _SaveActiveIntent: CallbackAction<_SaveActiveIntent>( + onInvoke: (intent) { + final state = ref.read(workspaceControllerProvider); + final navigatorContext = rootNavigatorKey.currentContext; + if (state.workspace != null && navigatorContext != null) { + unawaited( + saveActiveWithOverwriteConfirmation( + navigatorContext, + ref, + ), + ); + } + return null; + }, ), - }, - child: Actions( - actions: { - _NewMarkdownIntent: CallbackAction<_NewMarkdownIntent>( - onInvoke: (intent) { - unawaited(() async { + _KeyboardShortcutsIntent: + CallbackAction<_KeyboardShortcutsIntent>( + onInvoke: (intent) { + final navigatorContext = + rootNavigatorKey.currentContext; + if (navigatorContext != null) { + showBusyMarkKeyboardShortcutsDialog( + navigatorContext, + ); + } + return null; + }, + ), + _SettingsIntent: CallbackAction<_SettingsIntent>( + onInvoke: (intent) { final navigatorContext = rootNavigatorKey.currentContext; - if (navigatorContext == null) { - return; + if (navigatorContext != null) { + GoRouter.of(navigatorContext).go( + settingsLocationForUri( + router.routeInformationProvider.value.uri, + ), + ); } - final safe = await confirmSafeToContinue( - navigatorContext, - ref, - ); - if (!safe || !navigatorContext.mounted) { - return; + return null; + }, + ), + _MarkdownAndHtmlIntent: + CallbackAction<_MarkdownAndHtmlIntent>( + onInvoke: (intent) { + final navigatorContext = + rootNavigatorKey.currentContext; + if (navigatorContext != null) { + showBusyMarkMarkdownHtmlDialog(navigatorContext); + } + return null; + }, + ), + _NextTabIntent: CallbackAction<_NextTabIntent>( + onInvoke: (intent) { + final navigatorContext = rootNavigatorKey.currentContext; + if (navigatorContext != null) { + unawaited( + _activateOpenFileTab( + navigatorContext, + ref, + next: true, + ), + ); } - await ref - .read(workspaceControllerProvider.notifier) - .createMarkdownFile(); - if (navigatorContext.mounted) { - router.go('/workspace'); + return null; + }, + ), + _PreviousTabIntent: CallbackAction<_PreviousTabIntent>( + onInvoke: (intent) { + final navigatorContext = rootNavigatorKey.currentContext; + if (navigatorContext != null) { + unawaited( + _activateOpenFileTab( + navigatorContext, + ref, + next: false, + ), + ); } - }()); - return null; - }, - ), - _OpenWorkspaceIntent: CallbackAction<_OpenWorkspaceIntent>( - onInvoke: (intent) { - final navigatorContext = rootNavigatorKey.currentContext; - if (navigatorContext != null) { - unawaited( - _showOpenChooser(navigatorContext, ref, router), - ); - } - return null; - }, - ), - _SaveActiveIntent: CallbackAction<_SaveActiveIntent>( - onInvoke: (intent) { - final state = ref.read(workspaceControllerProvider); - final navigatorContext = rootNavigatorKey.currentContext; - if (state.workspace != null && navigatorContext != null) { - unawaited( - saveActiveWithOverwriteConfirmation( - navigatorContext, - ref, - ), - ); - } - return null; - }, - ), - _KeyboardShortcutsIntent: - CallbackAction<_KeyboardShortcutsIntent>( - onInvoke: (intent) { - final navigatorContext = - rootNavigatorKey.currentContext; - if (navigatorContext != null) { - showBusyMarkKeyboardShortcutsDialog(navigatorContext); - } - return null; - }, - ), - _SettingsIntent: CallbackAction<_SettingsIntent>( - onInvoke: (intent) { - final navigatorContext = rootNavigatorKey.currentContext; - if (navigatorContext != null) { - GoRouter.of(navigatorContext).go('/settings'); - } - return null; - }, - ), - _MarkdownAndHtmlIntent: CallbackAction<_MarkdownAndHtmlIntent>( - onInvoke: (intent) { - final navigatorContext = rootNavigatorKey.currentContext; - if (navigatorContext != null) { - showBusyMarkMarkdownHtmlDialog(navigatorContext); - } - return null; - }, - ), - _NextTabIntent: CallbackAction<_NextTabIntent>( - onInvoke: (intent) { - final navigatorContext = rootNavigatorKey.currentContext; - if (navigatorContext != null) { - unawaited( - _activateOpenFileTab(navigatorContext, ref, next: true), - ); - } - return null; - }, - ), - _PreviousTabIntent: CallbackAction<_PreviousTabIntent>( - onInvoke: (intent) { - final navigatorContext = rootNavigatorKey.currentContext; - if (navigatorContext != null) { - unawaited( - _activateOpenFileTab( - navigatorContext, - ref, - next: false, - ), - ); - } - return null; - }, - ), - _CloseTabIntent: CallbackAction<_CloseTabIntent>( - onInvoke: (intent) { - final navigatorContext = rootNavigatorKey.currentContext; - if (navigatorContext != null) { - unawaited(_closeActiveOpenFileTab(navigatorContext, ref)); - } - return null; - }, - ), - _CloseAllTabsIntent: CallbackAction<_CloseAllTabsIntent>( - onInvoke: (intent) { - final navigatorContext = rootNavigatorKey.currentContext; - if (navigatorContext != null) { - unawaited(_closeAllOpenFileTabs(navigatorContext, ref)); - } - return null; - }, - ), - _OpenSearchIntent: CallbackAction<_OpenSearchIntent>( - onInvoke: (intent) { - if (ref.read(workspaceControllerProvider).workspace != - null) { - final notifier = ref.read( - workspaceSearchOpenRequestProvider.notifier, - ); - notifier.request(); - } - return null; - }, - ), - _ToggleSidebarIntent: CallbackAction<_ToggleSidebarIntent>( - onInvoke: (intent) { - _toggleSidebar( - ref, - allowWithoutWorkspace: - router.routeInformationProvider.value.uri.path == '/', - ); - return null; - }, - ), - _DocumentViewModeIntent: - CallbackAction<_DocumentViewModeIntent>( - onInvoke: (intent) { + return null; + }, + ), + _CloseTabIntent: CallbackAction<_CloseTabIntent>( + onInvoke: (intent) { + final navigatorContext = rootNavigatorKey.currentContext; + if (navigatorContext != null) { unawaited( - ref - .read(appSettingsControllerProvider.notifier) - .setDocumentViewMode(intent.mode), + _closeActiveOpenFileTab(navigatorContext, ref), ); - return null; - }, - ), - }, - child: _BusyMarkSearchShortcutHandler( - child: ClipRRect( - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(BusyMarkRadius.window), + } + return null; + }, + ), + _CloseAllTabsIntent: CallbackAction<_CloseAllTabsIntent>( + onInvoke: (intent) { + final navigatorContext = rootNavigatorKey.currentContext; + if (navigatorContext != null) { + unawaited(_closeAllOpenFileTabs(navigatorContext, ref)); + } + return null; + }, + ), + _OpenSearchIntent: CallbackAction<_OpenSearchIntent>( + onInvoke: (intent) { + if (ref.read(workspaceControllerProvider).workspace != + null) { + final notifier = ref.read( + workspaceSearchOpenRequestProvider.notifier, + ); + notifier.request(); + } + return null; + }, ), - clipBehavior: Clip.antiAliasWithSaveLayer, + _ToggleSidebarIntent: CallbackAction<_ToggleSidebarIntent>( + onInvoke: (intent) { + _toggleSidebar( + ref, + allowWithoutWorkspace: + router.routeInformationProvider.value.uri.path == + '/', + ); + return null; + }, + ), + _DocumentViewModeIntent: + CallbackAction<_DocumentViewModeIntent>( + onInvoke: (intent) { + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(intent.mode), + ); + return null; + }, + ), + }, + child: _BusyMarkSearchShortcutHandler( child: ColoredBox( color: BusyMarkSurfaceColors.of(context).window, child: child ?? const SizedBox.shrink(), @@ -560,15 +574,10 @@ class BusyMarkApp extends ConsumerWidget { } } - void _configureNativeHeaderBar( + HeaderBarConfiguration _nativeHeaderBarDefaults( BuildContext context, - WidgetRef ref, AppSettings settings, ) { - final service = ref.watch(linuxHeaderBarServiceProvider); - if (!service.isAvailable) { - return; - } final material = MaterialLocalizations.of(context); final l10n = context.l10n; final theme = HeaderBarTheme.fromContext(context); @@ -599,14 +608,23 @@ class BusyMarkApp extends ConsumerWidget { reportIssue: l10n.reportIssue, aboutBusyMark: l10n.aboutBusyMark, ); - WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(() async { - await service.setTextDirection(textDirection); - await service.setSidebarWidth(BusyMarkSizes.sidebarWidth); - await service.setTheme(theme); - await service.setLocalizedLabels(labels); - }()); - }); + return HeaderBarConfiguration( + title: l10n.appTitle, + viewMode: AppViewMode.editor, + searchQuery: '', + textDirection: textDirection, + canRefresh: false, + documentControlsVisible: false, + searchActive: false, + searchVisible: false, + sidebarVisible: false, + sidebarToggleVisible: false, + backVisible: false, + modalBarrierVisible: false, + sidebarWidth: BusyMarkSizes.sidebarWidth, + labels: labels, + theme: theme, + ); } } diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index 00a2d81..59a917c 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:yaru/yaru.dart'; import 'busymark_glyphs.dart'; @@ -25,10 +24,11 @@ abstract final class BusyMarkSpacing { abstract final class BusyMarkRadius { static const double sm = 4; static const double md = 8; - static const double lg = 12; - static const double headerButton = 8; - static const double nativeHeaderButton = 6; - static const double window = 14; + static const double lg = kYaruContainerRadius; + static const double headerButton = kYaruButtonRadius; + // Compatibility name for sidebar callers; geometry remains Yaru-owned. + static const double nativeHeaderButton = kYaruButtonRadius; + static const double window = kYaruWindowRadius; static const double pill = 999; static const double selection = 3; } @@ -38,9 +38,11 @@ abstract final class BusyMarkSizes { static const double documentContentWidth = contentWidth; static const double sidebarWidth = 300; static const double settingsWidth = 760; - static const double toolbarHeight = 46; + static const double toolbarHeight = kYaruTitleBarHeight; static const double paneHeaderHeight = 38; - static const double iconButton = 34; + static const double iconButton = kYaruTitleBarItemHeight; + static const double compactIconButton = 24; + static const double compactIcon = 13; static const double iconSm = 16; static const double iconMd = 20; static const double previewMinWidth = 320; @@ -53,22 +55,9 @@ abstract final class BusyMarkSizes { static const double dialogCompact = 460; static const double dialogWide = 560; static const double popupMenuMinWidth = 180; - static const double popupMenuShortcutWidth = 240; - static const double popupMenuItemHeight = 36; static const double languagePopupMinWidth = 220; static const double languagePopupMaxWidth = 280; static const double languageButtonMaxWidth = 256; - static const double dialogButtonMinWidth = 72; - static const double dialogButtonMaxWidth = 220; - static const double floatingEntryHeight = 58; - static const double floatingTextAreaHeight = 154; - static const double floatingEntryInset = 12; - static const double floatingEntryLabelTop = 7; - static const double floatingEntryLabelRestTop = 16; - static const double floatingEntryLabelHeight = 18; - static const double floatingEntryLabelRestHeight = 24; - static const double floatingEntryInputTop = 25; - static const double floatingEntryInputBottom = 6; static const double aboutLogoViewport = 136; static const double aboutLogoAsset = 216; static const double sidebarSeparatorHeight = 22; @@ -102,6 +91,7 @@ abstract final class BusyMarkSizes { static const double tableMaxWidth = 980; static const double tableControl = 34; static const double markerDot = 6; + static const double listMarkerTopInset = 7; static const double thematicBreakHandleWidth = 44; static const double controlRowWidth = 256; static const double sliderRowWidth = 260; @@ -117,13 +107,11 @@ abstract final class BusyMarkSizes { abstract final class BusyMarkElevation { static const double none = 0; static const double surface = 2; - static const double popover = 6; - static const double window = 12; } abstract final class BusyMarkStroke { static const double hairline = 1; - static const double focus = 2; + static const double focus = kYaruFocusBorderWidth; static const double sourceCursor = 1.4; static const double thematicBreak = 1.6; static const double selectionInflate = 1.5; @@ -131,14 +119,7 @@ abstract final class BusyMarkStroke { abstract final class BusyMarkAlpha { static const double modalBarrier = 0.32; - static const double focus = 0.18; - static const double splash = 0.12; - static const double overlayPressed = 0.14; - static const double overlayHover = 0.08; - static const double overlayFocus = 0.10; static const double textSelection = 0.32; - static const double floatingTextSelection = 0.28; - static const double languageMenuShadow = 0.42; static const double sourceCollapsedLine = 0.045; static const double sourceCursor = 0.82; static const double sourceSyntaxBackground = 0.10; @@ -148,12 +129,6 @@ abstract final class BusyMarkAlpha { static const double thematicBreak = 0.34; static const double thematicBreakHandle = 0.24; static const double thematicBreakSelected = 0.72; - static const double floatingEntryIcon = 0.72; - static const double toolbarPressed = 0.18; - static const double toolbarHover = 0.10; - static const double windowShadowHigh = 0.75; - static const double windowShadowMedium = 0.45; - static const double windowShadowLow = 0.25; } abstract final class BusyMarkTypography { @@ -214,12 +189,10 @@ String busyMarkBidiIsolateFor(BuildContext context, Object value) { abstract final class BusyMarkMotion { static const Duration modalPadding = Duration(milliseconds: 100); static const Duration sidebarExpand = Duration(milliseconds: 120); - static const Duration floatingEntry = Duration(milliseconds: 140); static const Duration scroll = Duration(milliseconds: 180); static const Duration previewSearchDelay = Duration(milliseconds: 80); static const Duration tooltipWait = Duration(milliseconds: 450); static const Curve modalPaddingCurve = Curves.decelerate; - static const Curve floatingEntryCurve = Curves.easeOutCubic; } abstract final class BusyMarkInsets { @@ -276,10 +249,6 @@ abstract final class BusyMarkInsets { horizontal: BusyMarkSpacing.sm, vertical: BusyMarkSpacing.xs, ); - static const dialogButton = EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.mdPlus, - vertical: 7, - ); static const sectionLabel = EdgeInsets.fromLTRB( BusyMarkSpacing.md, BusyMarkSpacing.mdPlus, @@ -331,119 +300,6 @@ abstract final class BusyMarkSourceEditorMetrics { static const double paddingRight = BusyMarkSpacing.lg; } -abstract final class BusyMarkShadow { - static const double floatingBlur = 24; - static const Offset floatingOffset = Offset(0, 8); - static const double windowMargin = 32; - - static Color _scaleAlpha(Color color, double scale) { - return color.withValues( - alpha: (color.a * scale).clamp(0.0, 1.0).toDouble(), - ); - } - - static Color floatingColor(BuildContext context) { - return BusyMarkSurfaceColors.of(context).shade; - } - - static List surfaceShadows(Color color) { - return [ - BoxShadow( - color: _scaleAlpha(color, 0.28), - blurRadius: 8, - offset: const Offset(0, 2), - ), - BoxShadow( - color: _scaleAlpha(color, 0.18), - blurRadius: 3, - offset: const Offset(0, -1), - ), - BoxShadow( - color: _scaleAlpha(color, 0.16), - blurRadius: 1, - offset: Offset.zero, - ), - ]; - } - - static List surfaceShadowsFor(BuildContext context) { - return surfaceShadows(floatingColor(context)); - } - - static List floatingShadows(Color color) { - return [ - BoxShadow(color: color, blurRadius: floatingBlur, offset: floatingOffset), - ]; - } - - static List floatingShadowsFor(BuildContext context) { - return floatingShadows(floatingColor(context)); - } - - static List windowShadows(Color color) { - return [ - BoxShadow( - color: color.withValues( - alpha: color.a * BusyMarkAlpha.windowShadowHigh, - ), - blurRadius: 22, - offset: const Offset(0, 10), - ), - BoxShadow( - color: color.withValues( - alpha: color.a * BusyMarkAlpha.windowShadowMedium, - ), - blurRadius: 10, - offset: const Offset(0, 3), - ), - BoxShadow( - color: color.withValues(alpha: color.a * BusyMarkAlpha.windowShadowLow), - blurRadius: 3, - offset: const Offset(0, 1), - ), - ]; - } - - static List windowShadowsFor(BuildContext context) { - return windowShadows(floatingColor(context)); - } - - static List edgeShadows(Color color, {required bool below}) { - return [ - BoxShadow( - color: color, - blurRadius: floatingBlur / 2, - offset: Offset( - 0, - below ? floatingOffset.dy / 2 : -floatingOffset.dy / 2, - ), - ), - ]; - } - - static List edgeShadowsFor( - BuildContext context, { - required bool below, - }) { - return edgeShadows(floatingColor(context), below: below); - } -} - -BoxDecoration busyMarkSurfaceDecoration( - BuildContext context, { - required Color color, - required BorderRadius borderRadius, - Border? border, - bool elevated = true, -}) { - return BoxDecoration( - color: color, - borderRadius: borderRadius, - border: border, - boxShadow: elevated ? BusyMarkShadow.surfaceShadowsFor(context) : null, - ); -} - abstract final class BusyMarkLinuxPalette { static Color fromArgb(int value) => Color(value); @@ -598,11 +454,7 @@ enum BusyMarkVcsFileColor { } Color busyMarkDestructiveForeground(BuildContext context) { - final theme = Theme.of(context); - if (theme.brightness == Brightness.dark) { - return const Color(0xFFFFA99B); - } - return theme.colorScheme.error; + return Theme.of(context).colorScheme.error; } Color busyMarkVcsFileStatusColor( @@ -629,6 +481,95 @@ Color busyMarkVcsFileStatusColor( }; } +BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { + final foreground = switch (brightness) { + Brightness.light => const Color(0xFF3D3D3D), + Brightness.dark => const Color(0xFFF7F7F7), + }; + // These colors are used by non-disabled 10–14 px labels. Keep them opaque so + // their contrast is stable across every neutral surface instead of stacking + // a dim-label alpha on whichever view happens to be underneath. + final mutedForeground = switch (brightness) { + Brightness.light => const Color(0xFF666666), + Brightness.dark => const Color(0xFFB5B5B5), + }; + final groupedList = switch (brightness) { + Brightness.light => const Color(0xFFFFFFFF), + Brightness.dark => const Color(0xFF3D3D3D), + }; + + Color tintedSurface(Color tint) { + final alpha = brightness == Brightness.dark ? 0.16 : 0.08; + return Color.alphaBlend(tint.withValues(alpha: alpha), groupedList); + } + + return switch (brightness) { + Brightness.light => BusyMarkSurfaceColors( + // Modern Yaru/libadwaita semantic roles. Flutter's Yaru theme exposes + // geometry and interaction behavior, but not every contemporary + // surface role, so these neutral fallbacks live in one resolver. + window: const Color(0xFFFAFAFA), + view: const Color(0xFFFFFFFF), + sidebar: const Color(0xFFEBEBEB), + secondarySidebar: const Color(0xFFF0F0F0), + headerbar: const Color(0xFFFAFAFA), + headerbarFlat: const Color(0xFFFFFFFF), + panel: const Color(0xFFF0F0F0), + card: const Color(0xFFFFFFFF), + groupedList: groupedList, + dialog: const Color(0xFFFAFAFA), + popover: const Color(0xFFFAFAFA), + control: const Color.fromRGBO(0, 0, 0, 0.10), + controlHover: const Color.fromRGBO(0, 0, 0, 0.14), + controlActive: const Color.fromRGBO(0, 0, 0, 0.18), + foreground: foreground, + mutedForeground: mutedForeground, + disabledForeground: const Color.fromRGBO(0, 0, 0, 0.38), + disabledControl: const Color.fromRGBO(0, 0, 0, 0.04), + border: const Color.fromRGBO(0, 0, 0, 0.18), + subtleBorder: const Color.fromRGBO(0, 0, 0, 0.10), + divider: const Color.fromRGBO(0, 0, 0, 0.10), + floatingBorder: const Color.fromRGBO(0, 0, 0, 0.10), + sidebarBorder: const Color.fromRGBO(0, 0, 0, 0.07), + shade: const Color.fromRGBO(0, 0, 0, 0.07), + muted: mutedForeground, + admonitionNote: tintedSurface(BusyMarkLinuxPalette.ubuntuBlueAccent), + admonitionTip: tintedSurface(BusyMarkLinuxPalette.ubuntuGreenAccent), + admonitionWarning: tintedSurface(BusyMarkLinuxPalette.ubuntuYellowAccent), + ), + Brightness.dark => BusyMarkSurfaceColors( + window: const Color(0xFF2C2C2C), + view: const Color(0xFF272727), + sidebar: const Color(0xFF393939), + secondarySidebar: const Color(0xFF323232), + headerbar: const Color(0xFF393939), + headerbarFlat: const Color(0xFF272727), + panel: const Color(0xFF323232), + card: const Color(0xFF3D3D3D), + groupedList: groupedList, + dialog: const Color(0xFF3E3E3E), + popover: const Color(0xFF3E3E3E), + control: const Color.fromRGBO(255, 255, 255, 0.10), + controlHover: const Color.fromRGBO(255, 255, 255, 0.14), + controlActive: const Color.fromRGBO(255, 255, 255, 0.18), + foreground: foreground, + mutedForeground: mutedForeground, + disabledForeground: const Color.fromRGBO(255, 255, 255, 0.38), + disabledControl: const Color.fromRGBO(255, 255, 255, 0.06), + border: const Color.fromRGBO(0, 0, 0, 0.75), + subtleBorder: const Color.fromRGBO(255, 255, 255, 0.10), + divider: const Color.fromRGBO(255, 255, 255, 0.10), + floatingBorder: const Color.fromRGBO(255, 255, 255, 0.10), + sidebarBorder: const Color.fromRGBO(255, 255, 255, 0.10), + shade: const Color.fromRGBO(0, 0, 0, 0.25), + muted: mutedForeground, + admonitionNote: tintedSurface(BusyMarkLinuxPalette.ubuntuBlueAccent), + admonitionTip: tintedSurface(BusyMarkLinuxPalette.ubuntuGreenAccent), + admonitionWarning: tintedSurface(BusyMarkLinuxPalette.ubuntuYellowAccent), + ), + }; +} + @immutable class BusyMarkSurfaceColors extends ThemeExtension { const BusyMarkSurfaceColors({ @@ -646,13 +587,14 @@ class BusyMarkSurfaceColors extends ThemeExtension { required this.control, required this.controlHover, required this.controlActive, - required this.activeToggle, required this.foreground, required this.mutedForeground, required this.disabledForeground, required this.disabledControl, required this.border, required this.subtleBorder, + required this.divider, + required this.floatingBorder, required this.sidebarBorder, required this.shade, required this.muted, @@ -661,72 +603,14 @@ class BusyMarkSurfaceColors extends ThemeExtension { required this.admonitionWarning, }); - factory BusyMarkSurfaceColors.fromBrightness(Brightness brightness) { - return switch (brightness) { - Brightness.light => const BusyMarkSurfaceColors( - window: Color(0xFFFAFAFA), - view: Color(0xFFFFFFFF), - sidebar: Color(0xFFEFEFEF), - secondarySidebar: Color(0xFFF6F6F6), - headerbar: Color(0xFFFFFFFF), - headerbarFlat: Color(0xFFFFFFFF), - panel: Color(0xFFF6F5F4), - card: Color(0xFFFFFFFF), - groupedList: Color(0xFFFFFFFF), - dialog: Color(0xFFFAFAFA), - popover: Color(0xFFFFFFFF), - control: Color(0xFFFFFFFF), - controlHover: Color(0xFFF6F6F6), - controlActive: Color(0xFFEDEDED), - activeToggle: Color(0xFFFFFFFF), - foreground: Color.fromRGBO(0, 0, 0, 0.82), - mutedForeground: Color.fromRGBO(0, 0, 0, 0.58), - disabledForeground: Color.fromRGBO(0, 0, 0, 0.38), - disabledControl: Color(0xFFF3F3F3), - border: Color.fromRGBO(0, 0, 0, 0.18), - subtleBorder: Color.fromRGBO(0, 0, 0, 0.10), - sidebarBorder: Color.fromRGBO(0, 0, 0, 0.08), - shade: Color.fromRGBO(0, 0, 0, 0.22), - muted: Color.fromRGBO(0, 0, 0, 0.58), - admonitionNote: Color(0xFFF0F4F8), - admonitionTip: Color(0xFFEAF8EF), - admonitionWarning: Color(0xFFFFF3D6), - ), - Brightness.dark => const BusyMarkSurfaceColors( - window: Color(0xFF1E1E1E), - view: Color(0xFF242424), - sidebar: Color(0xFF303030), - secondarySidebar: Color(0xFF2A2A2A), - headerbar: Color(0xFF303030), - headerbarFlat: Color(0xFF242424), - panel: Color(0xFF2A2A2A), - card: Color(0xFF2A2A2A), - groupedList: Color(0xFF383838), - dialog: Color(0xFF2A2A2A), - popover: Color(0xFF383838), - control: Color(0xFF383838), - controlHover: Color(0xFF424242), - controlActive: Color(0xFF4A4A4A), - activeToggle: Color(0xFF4A4A4A), - foreground: Color(0xFFFFFFFF), - mutedForeground: Color.fromRGBO(255, 255, 255, 0.70), - disabledForeground: Color.fromRGBO(255, 255, 255, 0.38), - disabledControl: Color(0xFF303030), - border: Color.fromRGBO(0, 0, 0, 0.70), - subtleBorder: Color.fromRGBO(255, 255, 255, 0.10), - sidebarBorder: Color.fromRGBO(0, 0, 0, 0.36), - shade: Color.fromRGBO(0, 0, 0, 0.25), - muted: Color.fromRGBO(255, 255, 255, 0.70), - admonitionNote: Color(0xFF333333), - admonitionTip: Color(0xFF26352C), - admonitionWarning: Color(0xFF3B321F), - ), - }; + factory BusyMarkSurfaceColors.fromTheme(ThemeData theme) { + return _busyMarkSemanticSurfaceColors(theme.brightness); } static BusyMarkSurfaceColors of(BuildContext context) { - return Theme.of(context).extension() ?? - BusyMarkSurfaceColors.fromBrightness(Theme.of(context).brightness); + final theme = Theme.of(context); + return theme.extension() ?? + BusyMarkSurfaceColors.fromTheme(theme); } final Color window; @@ -743,13 +627,14 @@ class BusyMarkSurfaceColors extends ThemeExtension { final Color control; final Color controlHover; final Color controlActive; - final Color activeToggle; final Color foreground; final Color mutedForeground; final Color disabledForeground; final Color disabledControl; final Color border; final Color subtleBorder; + final Color divider; + final Color floatingBorder; final Color sidebarBorder; final Color shade; final Color muted; @@ -773,13 +658,14 @@ class BusyMarkSurfaceColors extends ThemeExtension { Color? control, Color? controlHover, Color? controlActive, - Color? activeToggle, Color? foreground, Color? mutedForeground, Color? disabledForeground, Color? disabledControl, Color? border, Color? subtleBorder, + Color? divider, + Color? floatingBorder, Color? sidebarBorder, Color? shade, Color? muted, @@ -802,13 +688,14 @@ class BusyMarkSurfaceColors extends ThemeExtension { control: control ?? this.control, controlHover: controlHover ?? this.controlHover, controlActive: controlActive ?? this.controlActive, - activeToggle: activeToggle ?? this.activeToggle, foreground: foreground ?? this.foreground, mutedForeground: mutedForeground ?? this.mutedForeground, disabledForeground: disabledForeground ?? this.disabledForeground, disabledControl: disabledControl ?? this.disabledControl, border: border ?? this.border, subtleBorder: subtleBorder ?? this.subtleBorder, + divider: divider ?? this.divider, + floatingBorder: floatingBorder ?? this.floatingBorder, sidebarBorder: sidebarBorder ?? this.sidebarBorder, shade: shade ?? this.shade, muted: muted ?? this.muted, @@ -842,7 +729,6 @@ class BusyMarkSurfaceColors extends ThemeExtension { control: Color.lerp(control, other.control, t)!, controlHover: Color.lerp(controlHover, other.controlHover, t)!, controlActive: Color.lerp(controlActive, other.controlActive, t)!, - activeToggle: Color.lerp(activeToggle, other.activeToggle, t)!, foreground: Color.lerp(foreground, other.foreground, t)!, mutedForeground: Color.lerp(mutedForeground, other.mutedForeground, t)!, disabledForeground: Color.lerp( @@ -853,6 +739,8 @@ class BusyMarkSurfaceColors extends ThemeExtension { disabledControl: Color.lerp(disabledControl, other.disabledControl, t)!, border: Color.lerp(border, other.border, t)!, subtleBorder: Color.lerp(subtleBorder, other.subtleBorder, t)!, + divider: Color.lerp(divider, other.divider, t)!, + floatingBorder: Color.lerp(floatingBorder, other.floatingBorder, t)!, sidebarBorder: Color.lerp(sidebarBorder, other.sidebarBorder, t)!, shade: Color.lerp(shade, other.shade, t)!, muted: Color.lerp(muted, other.muted, t)!, @@ -869,28 +757,24 @@ class BusyMarkSurfaceColors extends ThemeExtension { ButtonStyle busyMarkHeaderIconButtonStyle({ Color? foregroundColor, + Color? disabledForegroundColor, WidgetStateProperty? backgroundColor, WidgetStateProperty? overlayColor, double borderRadius = BusyMarkRadius.headerButton, }) { return ButtonStyle( - fixedSize: const WidgetStatePropertyAll( - Size.square(BusyMarkSizes.iconButton), - ), - minimumSize: const WidgetStatePropertyAll( - Size.square(BusyMarkSizes.iconButton), - ), - maximumSize: const WidgetStatePropertyAll( - Size.square(BusyMarkSizes.iconButton), - ), - padding: const WidgetStatePropertyAll(EdgeInsets.zero), tapTargetSize: MaterialTapTargetSize.shrinkWrap, foregroundColor: foregroundColor == null ? null - : WidgetStatePropertyAll(foregroundColor), + : WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled) && + disabledForegroundColor != null) { + return disabledForegroundColor; + } + return foregroundColor; + }), backgroundColor: backgroundColor, overlayColor: overlayColor, - side: const WidgetStatePropertyAll(BorderSide.none), shape: WidgetStatePropertyAll( RoundedRectangleBorder(borderRadius: BorderRadius.circular(borderRadius)), ), @@ -900,36 +784,14 @@ ButtonStyle busyMarkHeaderIconButtonStyle({ WidgetStateProperty busyMarkHeaderButtonBackground( BuildContext context, ) { - final colors = BusyMarkSurfaceColors.of(context); - return WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return colors.disabledControl; - } - if (states.contains(WidgetState.pressed)) { - return colors.controlActive; - } - if (states.contains(WidgetState.hovered) || - states.contains(WidgetState.focused)) { - return colors.controlHover; - } - return colors.control; - }); + return Theme.of(context).filledButtonTheme.style?.backgroundColor ?? + WidgetStatePropertyAll(BusyMarkSurfaceColors.of(context).control); } WidgetStateProperty busyMarkTransparentHeaderButtonBackground( - BuildContext context, + BuildContext _, ) { - final colors = BusyMarkSurfaceColors.of(context); - return WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) { - return colors.controlActive; - } - if (states.contains(WidgetState.hovered) || - states.contains(WidgetState.focused)) { - return colors.controlHover; - } - return BusyMarkLinuxPalette.transparent; - }); + return const WidgetStatePropertyAll(BusyMarkLinuxPalette.transparent); } Color busyMarkSelectedBackground(BuildContext context) { @@ -937,7 +799,7 @@ Color busyMarkSelectedBackground(BuildContext context) { } Color busyMarkRowHoverColor(BuildContext context) { - return BusyMarkSurfaceColors.of(context).controlHover; + return Theme.of(context).hoverColor; } TextStyle? busyMarkSectionHeaderStyle(BuildContext context) { @@ -955,7 +817,7 @@ class BusyMarkHeaderIconButton extends StatelessWidget { required this.onPressed, this.selected = false, this.accented = false, - this.transparent = false, + this.transparent = true, this.elevated = false, this.shortcut, this.foregroundColor, @@ -970,7 +832,8 @@ class BusyMarkHeaderIconButton extends StatelessWidget { final bool accented; final bool transparent; - /// Paints the shared theme-aware surface shadow behind this control. + /// Uses the theme's physical button elevation without drawing a custom + /// shadow surface around the control. final bool elevated; final String? shortcut; final Color? foregroundColor; @@ -979,54 +842,98 @@ class BusyMarkHeaderIconButton extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; final colors = BusyMarkSurfaceColors.of(context); - final colorScheme = Theme.of(context).colorScheme; - final button = IconButton( - style: busyMarkHeaderIconButtonStyle( - foregroundColor: - foregroundColor ?? - (accented - ? colorScheme.onPrimary - : selected - ? colorScheme.primary - : colors.mutedForeground), - backgroundColor: - backgroundColor ?? - (accented - ? WidgetStatePropertyAll(colorScheme.primary) - : selected - ? WidgetStatePropertyAll(colors.controlActive) - : transparent - ? busyMarkTransparentHeaderButtonBackground(context) - : busyMarkHeaderButtonBackground(context)), - borderRadius: borderRadius, - ), + final semanticStyle = busyMarkHeaderIconButtonStyle( + foregroundColor: + foregroundColor ?? (accented ? colorScheme.onPrimary : null), + disabledForegroundColor: colors.disabledForeground, + backgroundColor: + backgroundColor ?? + (accented + ? WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colors.disabledControl; + } + return colorScheme.primary; + }) + : elevated || !transparent + ? busyMarkHeaderButtonBackground(context) + : null), + borderRadius: borderRadius, + ); + final style = elevated + ? semanticStyle.copyWith( + elevation: WidgetStatePropertyAll( + theme.cardTheme.elevation ?? BusyMarkElevation.surface, + ), + shadowColor: WidgetStatePropertyAll(colorScheme.shadow), + ) + : semanticStyle; + return YaruIconButton( + iconSize: BusyMarkSizes.iconButton, + isSelected: selected, + style: style, tooltip: shortcut == null ? tooltip : '$tooltip ($shortcut)', icon: Icon(icon, size: BusyMarkSizes.iconSm), onPressed: onPressed, ); - final shadows = elevated ? BusyMarkShadow.surfaceShadowsFor(context) : null; - if (shadows == null || shadows.isEmpty) { - return button; - } - return DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius), - boxShadow: shadows, - ), - child: button, + } +} + +/// A compact icon action that keeps Yaru's interaction and focus behavior. +/// +/// Compact controls are appropriate inside dense editor affordances, where a +/// full title-bar item would consume too much document space. +class BusyMarkCompactIconButton extends StatelessWidget { + const BusyMarkCompactIconButton({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + this.size = BusyMarkSizes.compactIconButton, + this.glyphSize = BusyMarkSizes.compactIcon, + this.foregroundColor, + }); + + final String tooltip; + final IconData icon; + final VoidCallback? onPressed; + final double size; + final double glyphSize; + final Color? foregroundColor; + + @override + Widget build(BuildContext context) { + final colors = BusyMarkSurfaceColors.of(context); + return YaruIconButton( + iconSize: size, + tooltip: tooltip, + style: foregroundColor == null + ? null + : ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colors.disabledForeground; + } + return foregroundColor; + }), + ), + icon: Icon(icon, size: glyphSize), + onPressed: onPressed, ); } } -class BusyMarkHeaderPopupMenuButton extends StatelessWidget { +class BusyMarkHeaderPopupMenuButton extends StatefulWidget { const BusyMarkHeaderPopupMenuButton({ super.key, required this.tooltip, required this.icon, required this.itemBuilder, required this.onSelected, - this.transparent = false, + this.transparent = true, this.elevated = false, this.shortcut, this.foregroundColor, @@ -1041,166 +948,90 @@ class BusyMarkHeaderPopupMenuButton extends StatelessWidget { final ValueChanged onSelected; final bool transparent; - /// Paints the shared theme-aware surface shadow behind this control. + /// Uses the theme's physical button elevation. final bool elevated; final String? shortcut; final Color? foregroundColor; final WidgetStateProperty? backgroundColor; final double borderRadius; + @override + State> createState() => + _BusyMarkHeaderPopupMenuButtonState(); +} + +class _BusyMarkHeaderPopupMenuButtonState + extends State> { + final _menuKey = GlobalKey>(); + List> _items = const []; + var _loading = false; + var _open = false; + @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = BusyMarkSurfaceColors.of(context); - final effectiveForeground = foregroundColor ?? colors.mutedForeground; - final button = Theme( - data: theme.copyWith( - iconButtonTheme: IconButtonThemeData( - style: busyMarkHeaderIconButtonStyle( - foregroundColor: effectiveForeground, - backgroundColor: - backgroundColor ?? - (transparent - ? busyMarkTransparentHeaderButtonBackground(context) - : busyMarkHeaderButtonBackground(context)), - borderRadius: borderRadius, + return Stack( + alignment: Alignment.center, + children: [ + // PopupMenuButton owns route placement, RTL growth, focus, Escape, and + // menu semantics. The visible Yaru button only performs the async load + // before asking that framework control to open. + ExcludeSemantics( + child: PopupMenuButton( + key: _menuKey, + enabled: false, + tooltip: '', + useRootNavigator: true, + position: PopupMenuPosition.under, + requestFocus: true, + itemBuilder: (_) => _items, + onOpened: () => _setOpen(true), + onCanceled: () => _setOpen(false), + onSelected: (selection) { + _setOpen(false); + widget.onSelected(selection); + }, + child: const SizedBox.square(dimension: BusyMarkSizes.iconButton), ), ), - ), - child: Builder( - builder: (buttonContext) => IconButton( - tooltip: shortcut == null ? tooltip : '$tooltip ($shortcut)', - onPressed: () => _showMenu(buttonContext), - icon: Icon( - icon, - size: BusyMarkSizes.iconSm, - color: effectiveForeground, - ), + BusyMarkHeaderIconButton( + tooltip: widget.tooltip, + icon: widget.icon, + shortcut: widget.shortcut, + selected: _loading || _open, + transparent: widget.transparent, + elevated: widget.elevated, + foregroundColor: widget.foregroundColor, + backgroundColor: widget.backgroundColor, + borderRadius: widget.borderRadius, + onPressed: _loadAndShowMenu, ), - ), - ); - final shadows = elevated ? BusyMarkShadow.surfaceShadowsFor(context) : null; - if (shadows == null || shadows.isEmpty) { - return button; - } - return DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius), - boxShadow: shadows, - ), - child: button, + ], ); } - Future _showMenu(BuildContext context) async { - final button = context.findRenderObject(); - final navigator = Navigator.of(context, rootNavigator: true); - final overlay = navigator.overlay?.context.findRenderObject(); - final theme = Theme.of(context); - final colors = BusyMarkSurfaceColors.of(context); - final popupTheme = theme.popupMenuTheme; - final items = await itemBuilder(context); - if (!context.mounted || items.isEmpty) { + Future _loadAndShowMenu() async { + if (_loading || _open) { return; } - if (button is! RenderBox || overlay is! RenderBox) { - return; - } - - final escapeDismiss = BusyMarkPopupEscapeDismissBinding(navigator); - final buttonRect = - button.localToGlobal(Offset.zero, ancestor: overlay) & button.size; - final hasShortcutItems = items.whereType>().any(( - item, - ) { - final shortcut = item.shortcut; - return shortcut != null && shortcut.isNotEmpty; - }); - final menuWidth = hasShortcutItems - ? BusyMarkSizes.popupMenuShortcutWidth - : BusyMarkSizes.popupMenuMinWidth; - final minLeft = BusyMarkSpacing.sm; - final maxLeft = overlay.size.width - menuWidth - BusyMarkSpacing.sm; - final rawLeft = buttonRect.center.dx - menuWidth / 2; - final left = maxLeft <= minLeft - ? minLeft - : rawLeft.clamp(minLeft, maxLeft).toDouble(); - final top = buttonRect.bottom + BusyMarkSpacing.xs + BusyMarkSpacing.xxs; - T? result; - escapeDismiss.attach(); + setState(() => _loading = true); try { - result = await showMenu( - context: context, - useRootNavigator: true, - items: items, - position: RelativeRect.fromLTRB( - left, - top, - math.max(minLeft, overlay.size.width - left - menuWidth), - math.max(BusyMarkSpacing.sm, overlay.size.height - top), - ), - color: popupTheme.color ?? colors.popover, - surfaceTintColor: BusyMarkLinuxPalette.transparent, - elevation: BusyMarkElevation.popover, - shadowColor: colors.shade, - shape: _BusyMarkHeaderPopoverShape( - borderRadius: BorderRadius.circular(BusyMarkRadius.window), - side: BorderSide( - color: colors.subtleBorder, - width: BusyMarkStroke.hairline, - ), - ), - menuPadding: const EdgeInsets.only( - top: _busyMarkHeaderPopoverArrowHeight + BusyMarkSpacing.sm, - bottom: BusyMarkSpacing.sm, - ), - constraints: BoxConstraints.tightFor(width: menuWidth), - clipBehavior: Clip.antiAlias, - popUpAnimationStyle: AnimationStyle.noAnimation, - requestFocus: true, - ); + final items = await widget.itemBuilder(context); + if (!mounted || items.isEmpty) { + return; + } + _items = List.unmodifiable(items); + _menuKey.currentState?.showButtonMenu(); } finally { - escapeDismiss.detach(); - } - if (result != null) { - onSelected(result); - } - } -} - -class BusyMarkPopupEscapeDismissBinding { - BusyMarkPopupEscapeDismissBinding(this.navigator); - - final NavigatorState navigator; - bool _attached = false; - - void attach() { - if (_attached) { - return; - } - _attached = true; - HardwareKeyboard.instance.addHandler(_handleKeyEvent); - } - - void detach() { - if (!_attached) { - return; + if (mounted) { + setState(() => _loading = false); + } } - _attached = false; - HardwareKeyboard.instance.removeHandler(_handleKeyEvent); } - bool _handleKeyEvent(KeyEvent event) { - if (!_attached || - event is! KeyDownEvent || - event.logicalKey != LogicalKeyboardKey.escape) { - return false; - } - detach(); - if (navigator.canPop()) { - navigator.pop(); + void _setOpen(bool value) { + if (mounted && _open != value) { + setState(() => _open = value); } - return true; } } @@ -1223,9 +1054,6 @@ Future showBusyMarkContextMenu( if (overlay is! RenderBox) { return Future.value(); } - final theme = Theme.of(context); - final colors = BusyMarkSurfaceColors.of(context); - final popupTheme = theme.popupMenuTheme; final localPosition = overlay.globalToLocal(globalPosition); final minLeft = BusyMarkSpacing.sm; final maxLeft = overlay.size.width - width - BusyMarkSpacing.sm; @@ -1250,233 +1078,101 @@ Future showBusyMarkContextMenu( math.max(BusyMarkSpacing.sm, overlay.size.height - top), ), items: items, - color: popupTheme.color ?? colors.popover, - surfaceTintColor: BusyMarkLinuxPalette.transparent, - elevation: BusyMarkElevation.popover, - shadowColor: colors.shade, constraints: BoxConstraints.tightFor(width: width), - clipBehavior: Clip.antiAlias, - popUpAnimationStyle: AnimationStyle.noAnimation, requestFocus: true, ); } -const double _busyMarkHeaderPopoverArrowWidth = 16; -const double _busyMarkHeaderPopoverArrowHeight = 8; - -class _BusyMarkHeaderPopoverShape extends ShapeBorder { - const _BusyMarkHeaderPopoverShape({ - required this.borderRadius, - required this.side, - }); - - final BorderRadius borderRadius; - final BorderSide side; - - @override - EdgeInsetsGeometry get dimensions => EdgeInsets.all(side.width); - - @override - Path getInnerPath(Rect rect, {TextDirection? textDirection}) { - return getOuterPath(rect.deflate(side.width), textDirection: textDirection); - } - - @override - Path getOuterPath(Rect rect, {TextDirection? textDirection}) { - final resolved = borderRadius.resolve(textDirection); - final body = Rect.fromLTWH( - rect.left, - rect.top + _busyMarkHeaderPopoverArrowHeight, - rect.width, - math.max(0, rect.height - _busyMarkHeaderPopoverArrowHeight), - ); - final maxRadius = math.min(body.width, body.height) / 2; - final topLeft = math.min(resolved.topLeft.x, maxRadius); - final topRight = math.min(resolved.topRight.x, maxRadius); - final bottomRight = math.min(resolved.bottomRight.x, maxRadius); - final bottomLeft = math.min(resolved.bottomLeft.x, maxRadius); - const arrowHalf = _busyMarkHeaderPopoverArrowWidth / 2; - final arrowCenter = body.center.dx.clamp( - body.left + topLeft + arrowHalf, - body.right - topRight - arrowHalf, - ); - - return Path() - ..moveTo(body.left + topLeft, body.top) - ..lineTo(arrowCenter - arrowHalf, body.top) - ..lineTo(arrowCenter, rect.top) - ..lineTo(arrowCenter + arrowHalf, body.top) - ..lineTo(body.right - topRight, body.top) - ..quadraticBezierTo(body.right, body.top, body.right, body.top + topRight) - ..lineTo(body.right, body.bottom - bottomRight) - ..quadraticBezierTo( - body.right, - body.bottom, - body.right - bottomRight, - body.bottom, - ) - ..lineTo(body.left + bottomLeft, body.bottom) - ..quadraticBezierTo( - body.left, - body.bottom, - body.left, - body.bottom - bottomLeft, - ) - ..lineTo(body.left, body.top + topLeft) - ..quadraticBezierTo(body.left, body.top, body.left + topLeft, body.top) - ..close(); - } - - @override - void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { - if (side == BorderSide.none || side.width == 0) { - return; - } - canvas.drawPath( - getOuterPath(rect.deflate(side.width / 2), textDirection: textDirection), - side.toPaint(), - ); - } +class BusyMarkPopupMenuItem extends PopupMenuItem { + BusyMarkPopupMenuItem({ + super.key, + required T value, + required String label, + IconData? icon, + String? shortcut, + super.enabled = true, + bool checked = false, + bool trailingCheck = false, + }) : label = label, + icon = icon, + shortcut = shortcut, + checked = checked, + trailingCheck = trailingCheck, + super( + value: value, + child: _BusyMarkPopupMenuItemContent( + label: label, + icon: icon, + shortcut: shortcut, + checked: checked, + trailingCheck: trailingCheck, + ), + ); - @override - ShapeBorder scale(double t) { - return _BusyMarkHeaderPopoverShape( - borderRadius: borderRadius * t, - side: side.scale(t), - ); - } + final String label; + final IconData? icon; + final String? shortcut; + final bool checked; + final bool trailingCheck; } -class BusyMarkPopupMenuItem extends PopupMenuEntry { - const BusyMarkPopupMenuItem({ - super.key, - required this.value, +class _BusyMarkPopupMenuItemContent extends StatelessWidget { + const _BusyMarkPopupMenuItemContent({ required this.label, - this.icon, - this.shortcut, - this.enabled = true, - this.checked = false, - this.trailingCheck = false, + required this.icon, + required this.shortcut, + required this.checked, + required this.trailingCheck, }); - final T value; final String label; final IconData? icon; final String? shortcut; - final bool enabled; final bool checked; final bool trailingCheck; - @override - double get height => BusyMarkSizes.popupMenuItemHeight; - - @override - bool represents(T? value) => value == this.value; - - @override - State> createState() => - _BusyMarkPopupMenuItemState(); -} - -class _BusyMarkPopupMenuItemState extends State> { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final colors = BusyMarkSurfaceColors.of(context); - final popupTheme = theme.popupMenuTheme; - final textStyle = - popupTheme.textStyle ?? theme.textTheme.bodyMedium ?? const TextStyle(); - final foreground = widget.enabled - ? colors.foreground - : colors.disabledForeground; - final iconColor = widget.enabled - ? colors.mutedForeground - : colors.disabledForeground; - final labelText = Text( - widget.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - final shortcut = widget.shortcut; - final shortcutText = shortcut == null || shortcut.isEmpty + final labelText = Text(label, maxLines: 1, overflow: TextOverflow.ellipsis); + final shortcutText = shortcut == null || shortcut!.isEmpty ? null : Directionality( textDirection: TextDirection.ltr, child: Text( - shortcut, + shortcut!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: textStyle.copyWith(color: colors.mutedForeground), + style: theme.textTheme.labelSmall, ), ); - final item = Semantics( - checked: widget.trailingCheck ? widget.checked : null, - button: true, - enabled: widget.enabled, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: BusyMarkSpacing.sm), - child: InkWell( - onTap: widget.enabled - ? () => Navigator.pop(context, widget.value) - : null, - borderRadius: BorderRadius.circular(BusyMarkRadius.sm), - hoverColor: colors.controlHover, - focusColor: colors.controlHover, - highlightColor: colors.controlActive, - splashColor: BusyMarkLinuxPalette.transparent, - child: SizedBox( - height: BusyMarkSizes.popupMenuItemHeight, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.sm, - ), - child: DefaultTextStyle( - style: textStyle.copyWith(color: foreground), - child: IconTheme( - data: IconThemeData( - size: BusyMarkSizes.iconSm, - color: iconColor, - ), - child: widget.trailingCheck - ? Row( - children: [ - if (widget.icon != null) ...[ - Icon(widget.icon), - const SizedBox(width: BusyMarkSpacing.sm), - ], - Expanded(child: labelText), - const SizedBox(width: BusyMarkSpacing.sm), - if (shortcutText != null) ...[ - shortcutText, - const SizedBox(width: BusyMarkSpacing.sm), - ], - Opacity( - opacity: widget.checked ? 1 : 0, - child: const Icon(BusyMarkGlyphs.check), - ), - ], - ) - : Row( - children: [ - if (widget.icon != null) ...[ - Icon(widget.icon), - const SizedBox(width: BusyMarkSpacing.sm), - ], - Expanded(child: labelText), - if (shortcutText != null) ...[ - const SizedBox(width: BusyMarkSpacing.sm), - shortcutText, - ], - ], - ), - ), + return Semantics( + checked: trailingCheck ? checked : null, + inMutuallyExclusiveGroup: trailingCheck, + child: IconTheme.merge( + data: const IconThemeData(size: BusyMarkSizes.iconSm), + child: Row( + children: [ + if (icon != null) ...[ + Icon(icon), + const SizedBox(width: BusyMarkSpacing.sm), + ], + Expanded(child: labelText), + if (shortcutText != null) ...[ + const SizedBox(width: BusyMarkSpacing.sm), + shortcutText, + ], + if (trailingCheck) ...[ + const SizedBox(width: BusyMarkSpacing.sm), + Visibility.maintain( + visible: checked, + child: const Icon(BusyMarkGlyphs.check), ), - ), - ), + ], + ], ), ), ); - return item; } } @@ -1519,150 +1215,48 @@ class BusyMarkPopupSelector extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - final popupTheme = Theme.of(context).popupMenuTheme; - final navigator = Navigator.of(context, rootNavigator: true); - final escapeDismiss = BusyMarkPopupEscapeDismissBinding(navigator); final selectorEnabled = enabled && options.isNotEmpty; return Align( alignment: AlignmentDirectional.centerEnd, - child: PopupMenuButton( - enabled: selectorEnabled, - tooltip: tooltip, - padding: EdgeInsets.zero, - position: PopupMenuPosition.under, - offset: const Offset(0, BusyMarkSpacing.xs + BusyMarkSpacing.xxs), - color: popupTheme.color ?? colors.popover, - surfaceTintColor: BusyMarkLinuxPalette.transparent, - elevation: BusyMarkElevation.window, - shadowColor: colors.shade.withValues( - alpha: BusyMarkAlpha.languageMenuShadow, - ), - shape: - popupTheme.shape ?? - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMarkRadius.md), - ), - constraints: BoxConstraints( - minWidth: popupMinWidth, - maxWidth: popupMaxWidth, - ), - useRootNavigator: true, - requestFocus: true, - onOpened: escapeDismiss.attach, - onCanceled: escapeDismiss.detach, - onSelected: (selection) { - escapeDismiss.detach(); - onSelected(selection); - }, - itemBuilder: (context) => [ - for (final option in options) - BusyMarkPopupMenuItem( - value: option.value, - label: option.label, - icon: option.icon, - checked: option.value == value, - trailingCheck: true, - ), - ], - child: _BusyMarkPopupSelectorButton( - label: label, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: buttonMaxWidth), + child: YaruPopupMenuButton( + initialValue: value, enabled: selectorEnabled, - maxWidth: buttonMaxWidth, - ), - ), - ); - } -} - -class _BusyMarkPopupSelectorButton extends StatefulWidget { - const _BusyMarkPopupSelectorButton({ - required this.label, - required this.enabled, - required this.maxWidth, - }); - - final String label; - final bool enabled; - final double maxWidth; - - @override - State<_BusyMarkPopupSelectorButton> createState() => - _BusyMarkPopupSelectorButtonState(); -} - -class _BusyMarkPopupSelectorButtonState - extends State<_BusyMarkPopupSelectorButton> { - var _hovered = false; - - @override - Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - final theme = Theme.of(context); - final foreground = widget.enabled - ? colors.foreground - : colors.disabledForeground; - return MouseRegion( - cursor: widget.enabled - ? SystemMouseCursors.click - : SystemMouseCursors.basic, - onEnter: widget.enabled - ? (_) { - if (!_hovered) { - setState(() => _hovered = true); - } - } - : null, - onExit: widget.enabled - ? (_) { - if (_hovered) { - setState(() => _hovered = false); - } - } - : null, - child: Container( - constraints: BoxConstraints( - minHeight: BusyMarkSizes.iconButton, - maxWidth: widget.maxWidth, - ), - padding: const EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.sm, - vertical: BusyMarkSpacing.xs, - ), - decoration: BoxDecoration( - color: _hovered - ? colors.controlHover - : BusyMarkLinuxPalette.transparent, - borderRadius: BorderRadius.circular(BusyMarkRadius.headerButton), - border: Border.all( - color: _hovered - ? colors.subtleBorder - : BusyMarkLinuxPalette.transparent, + tooltip: tooltip, + semanticLabel: tooltip, + style: Theme.of(context).filledButtonTheme.style, + constraints: BoxConstraints( + minWidth: popupMinWidth, + maxWidth: popupMaxWidth, ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - widget.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - textAlign: TextAlign.end, - style: theme.textTheme.bodyMedium?.copyWith(color: foreground), + onSelected: onSelected, + itemBuilder: (context) => [ + for (final option in options) + BusyMarkPopupMenuItem( + value: option.value, + label: option.label, + icon: option.icon, + checked: option.value == value, + trailingCheck: true, + ), + ], + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: math.max( + 0, + buttonMaxWidth - + BusyMarkSizes.iconButton - + BusyMarkSpacing.smPlus, ), ), - const SizedBox(width: BusyMarkSpacing.sm), - Icon( - BusyMarkGlyphs.downArrow, - size: BusyMarkSizes.iconSm, - color: widget.enabled - ? colors.mutedForeground - : colors.disabledForeground, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, ), - ], + ), ), ), ); @@ -1713,43 +1307,85 @@ class BusyMarkSurface extends StatelessWidget { super.key, required this.child, this.filled = true, + this.color, + this.side = BorderSide.none, this.clipBehavior = Clip.antiAlias, }); final Widget child; final bool filled; + final Color? color; + final BorderSide side; final Clip clipBehavior; @override Widget build(BuildContext context) { - final borderRadius = BorderRadius.circular(BusyMarkRadius.md); + final borderRadius = BorderRadius.circular(BusyMarkRadius.lg); final cardTheme = Theme.of(context).cardTheme; final colors = BusyMarkSurfaceColors.of(context); - final borderColor = colors.subtleBorder; - final color = filled - ? cardTheme.color ?? colors.card - : BusyMarkLinuxPalette.transparent; final shape = cardTheme.shape ?? RoundedRectangleBorder(borderRadius: borderRadius); - final material = Material( - color: color, - elevation: BusyMarkElevation.none, + final effectiveShape = side == BorderSide.none + ? shape + : switch (shape) { + final OutlinedBorder outlined => outlined.copyWith(side: side), + _ => shape, + }; + return Material( + color: filled + ? color ?? cardTheme.color ?? colors.card + : BusyMarkLinuxPalette.transparent, + elevation: filled ? BusyMarkElevation.surface : BusyMarkElevation.none, + shadowColor: Theme.of(context).colorScheme.shadow, surfaceTintColor: BusyMarkLinuxPalette.transparent, - shape: shape, + shape: effectiveShape, clipBehavior: clipBehavior, child: child, ); - if (!filled) { - return material; - } + } +} + +/// The single semantic raised surface for grouped rows and cards. +class BusyMarkGroupedSurface extends StatelessWidget { + const BusyMarkGroupedSurface({ + super.key, + required this.child, + this.clipBehavior = Clip.antiAlias, + }); + + final Widget child; + final Clip clipBehavior; + + @override + Widget build(BuildContext context) { + return BusyMarkSurface( + color: BusyMarkSurfaceColors.of(context).groupedList, + clipBehavior: clipBehavior, + child: child, + ); + } +} + +/// Shared split-view sidebar surface and reading-direction boundary. +class BusyMarkSidebarSurface extends StatelessWidget { + const BusyMarkSidebarSurface({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final colors = BusyMarkSurfaceColors.of(context); return DecoratedBox( - decoration: busyMarkSurfaceDecoration( - context, - color: color, - borderRadius: borderRadius, - border: Border.all(color: borderColor), + decoration: BoxDecoration( + color: colors.sidebar, + border: BorderDirectional( + end: BorderSide( + color: colors.sidebarBorder, + width: BusyMarkStroke.hairline, + ), + ), ), - child: material, + child: child, ); } } @@ -1820,7 +1456,6 @@ class _BusyMarkGroupedListSurface extends StatelessWidget { @override Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); - final dividerColor = colors.view; final list = Column( mainAxisSize: MainAxisSize.min, children: [ @@ -1830,7 +1465,7 @@ class _BusyMarkGroupedListSurface extends StatelessWidget { Divider( height: BusyMarkStroke.hairline, thickness: BusyMarkStroke.hairline, - color: dividerColor, + color: colors.divider, ), ], ], @@ -1840,60 +1475,7 @@ class _BusyMarkGroupedListSurface extends StatelessWidget { return list; } - final borderRadius = BorderRadius.circular(BusyMarkRadius.md); - final color = colors.groupedList; - return DecoratedBox( - decoration: busyMarkSurfaceDecoration( - context, - color: color, - borderRadius: borderRadius, - ), - child: ClipRRect( - borderRadius: borderRadius, - clipBehavior: Clip.antiAlias, - child: Material( - color: BusyMarkLinuxPalette.transparent, - elevation: BusyMarkElevation.none, - surfaceTintColor: BusyMarkLinuxPalette.transparent, - child: list, - ), - ), - ); - } -} - -class _BusyMarkHoverBackground extends StatefulWidget { - const _BusyMarkHoverBackground({required this.enabled, required this.child}); - - final bool enabled; - final Widget child; - - @override - State<_BusyMarkHoverBackground> createState() => - _BusyMarkHoverBackgroundState(); -} - -class _BusyMarkHoverBackgroundState extends State<_BusyMarkHoverBackground> { - var _hovered = false; - - @override - Widget build(BuildContext context) { - final color = widget.enabled && _hovered - ? busyMarkRowHoverColor(context) - : BusyMarkLinuxPalette.transparent; - return MouseRegion( - onEnter: (_) { - if (!_hovered) { - setState(() => _hovered = true); - } - }, - onExit: (_) { - if (_hovered) { - setState(() => _hovered = false); - } - }, - child: ColoredBox(color: color, child: widget.child), - ); + return BusyMarkGroupedSurface(child: list); } } @@ -1919,27 +1501,28 @@ class BusyMarkActionRow extends StatelessWidget { @override Widget build(BuildContext context) { + final colors = BusyMarkSurfaceColors.of(context); final titleStyle = destructive - ? TextStyle(color: busyMarkDestructiveForeground(context)) + ? TextStyle( + color: enabled + ? busyMarkDestructiveForeground(context) + : colors.disabledForeground, + ) : null; - return _BusyMarkHoverBackground( - enabled: enabled, - child: YaruListTile.square( - leading: leading, - title: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: titleStyle, - ), - subtitle: subtitle == null || subtitle!.isEmpty - ? null - : Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis), - trailing: trailing, - enabled: enabled, - hoverColor: BusyMarkLinuxPalette.transparent, - onTap: enabled ? onTap : null, + return YaruListTile.square( + leading: leading, + title: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: titleStyle, ), + subtitle: subtitle == null || subtitle!.isEmpty + ? null + : Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: trailing, + enabled: enabled, + onTap: enabled ? onTap : null, ); } } @@ -1964,16 +1547,14 @@ class BusyMarkSwitchRow extends StatelessWidget { @override Widget build(BuildContext context) { - return _BusyMarkHoverBackground( - enabled: enabled, - child: YaruSwitchListTile( - value: value, - onChanged: enabled ? onChanged : null, - secondary: leading, - title: Text(title), - subtitle: subtitle == null ? null : Text(subtitle!), - hoverColor: BusyMarkLinuxPalette.transparent, - ), + return YaruSwitchListTile( + value: value, + onChanged: enabled ? onChanged : null, + secondary: leading, + title: Text(title), + subtitle: subtitle == null ? null : Text(subtitle!), + shape: const RoundedRectangleBorder(), + hoverColor: busyMarkRowHoverColor(context), ); } } @@ -2009,6 +1590,16 @@ class BusyMarkCheckbox extends StatelessWidget { enum BusyMarkStatusKind { information, success, warning, error } +Color busyMarkStatusColor(BuildContext context, BusyMarkStatusKind kind) { + final colors = YaruColors.of(context); + return switch (kind) { + BusyMarkStatusKind.information => colors.link, + BusyMarkStatusKind.success => colors.success, + BusyMarkStatusKind.warning => colors.warning, + BusyMarkStatusKind.error => colors.error, + }; +} + class BusyMarkStatusBox extends StatelessWidget { const BusyMarkStatusBox({ super.key, @@ -2078,15 +1669,12 @@ class BusyMarkDialogShell extends StatelessWidget { if (actions.isNotEmpty) Padding( padding: const EdgeInsets.all(BusyMarkSpacing.lg), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - for (final action in actions) ...[ - Flexible(child: action), - if (action != actions.last) - const SizedBox(width: BusyMarkSpacing.sm), - ], - ], + child: OverflowBar( + alignment: MainAxisAlignment.end, + overflowAlignment: OverflowBarAlignment.end, + spacing: BusyMarkSpacing.sm, + overflowSpacing: BusyMarkSpacing.sm, + children: actions, ), ), ], @@ -2095,7 +1683,93 @@ class BusyMarkDialogShell extends StatelessWidget { } } -class BusyMarkDialogButton extends StatefulWidget { +/// Semantic desktop push-button roles backed by real Yaru-themed controls. +abstract final class BusyMarkPushButton { + static FilledButton standard({ + required Widget child, + required VoidCallback? onPressed, + ButtonStyle? style, + FocusNode? focusNode, + bool autofocus = false, + WidgetStatesController? statesController, + Key? key, + }) { + return FilledButton( + key: key, + onPressed: onPressed, + style: style, + focusNode: focusNode, + autofocus: autofocus, + statesController: statesController, + child: child, + ); + } + + static FilledButton standardIcon({ + required Widget icon, + required Widget label, + required VoidCallback? onPressed, + ButtonStyle? style, + FocusNode? focusNode, + bool autofocus = false, + WidgetStatesController? statesController, + Key? key, + }) { + return FilledButton.icon( + key: key, + onPressed: onPressed, + style: style, + focusNode: focusNode, + autofocus: autofocus, + statesController: statesController, + icon: icon, + label: label, + ); + } + + static ElevatedButton suggested({ + required Widget child, + required VoidCallback? onPressed, + ButtonStyle? style, + FocusNode? focusNode, + bool autofocus = false, + WidgetStatesController? statesController, + Key? key, + }) { + return ElevatedButton( + key: key, + onPressed: onPressed, + style: style, + focusNode: focusNode, + autofocus: autofocus, + statesController: statesController, + child: child, + ); + } + + static ElevatedButton destructive({ + required BuildContext context, + required Widget child, + required VoidCallback? onPressed, + ButtonStyle? style, + FocusNode? focusNode, + bool autofocus = false, + WidgetStatesController? statesController, + Key? key, + }) { + return ElevatedButton( + key: key, + onPressed: onPressed, + style: _destructiveButtonStyle(context).merge(style), + focusNode: focusNode, + autofocus: autofocus, + statesController: statesController, + child: child, + ); + } +} + +class BusyMarkDialogButton extends StatelessWidget { const BusyMarkDialogButton({ super.key, required this.label, @@ -2111,155 +1785,50 @@ class BusyMarkDialogButton extends StatefulWidget { final bool suggested; final bool destructive; - @override - State createState() => _BusyMarkDialogButtonState(); -} - -class _BusyMarkDialogButtonState extends State { - var _hovered = false; - var _focused = false; - var _pressed = false; - - bool get _enabled => widget.onPressed != null; - @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = BusyMarkSurfaceColors.of(context); - final colorScheme = theme.colorScheme; - final background = _buttonBackground(context, colors, colorScheme); - final foreground = !_enabled - ? colors.disabledForeground - : widget.suggested - ? colorScheme.onPrimary - : widget.destructive - ? busyMarkDestructiveForeground(context) - : colors.foreground; - final button = Semantics( - button: true, - enabled: _enabled, - label: widget.label, - child: FocusableActionDetector( - enabled: _enabled, - mouseCursor: _enabled - ? SystemMouseCursors.click - : SystemMouseCursors.basic, - shortcuts: const { - SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), - SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), - }, - actions: >{ - ActivateIntent: CallbackAction( - onInvoke: (_) { - widget.onPressed?.call(); - return null; - }, - ), - }, - onShowHoverHighlight: (value) { - if (_hovered != value) { - setState(() => _hovered = value); - } - }, - onShowFocusHighlight: (value) { - if (_focused != value) { - setState(() => _focused = value); - } - }, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: widget.onPressed, - onTapDown: _enabled ? (_) => setState(() => _pressed = true) : null, - onTapUp: _enabled ? (_) => setState(() => _pressed = false) : null, - onTapCancel: _enabled ? () => setState(() => _pressed = false) : null, - child: Container( - constraints: const BoxConstraints( - minHeight: BusyMarkSizes.iconButton, - minWidth: BusyMarkSizes.dialogButtonMinWidth, - maxWidth: BusyMarkSizes.dialogButtonMaxWidth, - ), - padding: BusyMarkInsets.dialogButton, - decoration: busyMarkSurfaceDecoration( - context, - color: background, - borderRadius: BorderRadius.circular(BusyMarkRadius.headerButton), - elevated: _enabled, - ), - child: _BusyMarkDialogButtonContent( - label: widget.label, - icon: widget.icon, - foreground: foreground, - ), - ), - ), - ), - ); - return button; - } - - Color _buttonBackground( - BuildContext context, - BusyMarkSurfaceColors colors, - ColorScheme colorScheme, - ) { - if (!_enabled) { - return colors.disabledControl; - } - if (!widget.suggested) { - if (_pressed) { - return colors.controlActive; - } - if (_hovered || _focused) { - return colors.controlHover; - } - return colors.control; - } - if (_pressed) { - return _mixForState( - context, - colorScheme.primary, - BusyMarkAlpha.overlayPressed, + final child = _BusyMarkDialogButtonContent(label: label, icon: icon); + if (destructive) { + return BusyMarkPushButton.destructive( + context: context, + onPressed: onPressed, + child: child, ); } - if (_hovered || _focused) { - return _mixForState( - context, - colorScheme.primary, - BusyMarkAlpha.overlayHover, - ); + if (suggested) { + return BusyMarkPushButton.suggested(onPressed: onPressed, child: child); } - return colorScheme.primary; + return BusyMarkPushButton.standard(onPressed: onPressed, child: child); } +} - Color _mixForState(BuildContext context, Color color, double amount) { - final target = Theme.of(context).brightness == Brightness.dark - ? BusyMarkLinuxPalette.white - : BusyMarkLinuxPalette.black; - return Color.lerp(color, target, amount)!; - } +ButtonStyle _destructiveButtonStyle(BuildContext context) { + final theme = Theme.of(context); + final colors = BusyMarkSurfaceColors.of(context); + Color? background(Set states) => + states.contains(WidgetState.disabled) + ? colors.disabledControl + : theme.colorScheme.error; + Color? foreground(Set states) => + states.contains(WidgetState.disabled) + ? colors.disabledForeground + : theme.colorScheme.onError; + return ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith(background), + foregroundColor: WidgetStateProperty.resolveWith(foreground), + iconColor: WidgetStateProperty.resolveWith(foreground), + ); } class _BusyMarkDialogButtonContent extends StatelessWidget { - const _BusyMarkDialogButtonContent({ - required this.label, - required this.foreground, - this.icon, - }); + const _BusyMarkDialogButtonContent({required this.label, this.icon}); final String label; - final Color foreground; final IconData? icon; @override Widget build(BuildContext context) { - final text = Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: foreground), - ); + final text = Text(label, maxLines: 1, overflow: TextOverflow.ellipsis); final icon = this.icon; if (icon == null) { return Center(widthFactor: 1, child: text); @@ -2268,7 +1837,7 @@ class _BusyMarkDialogButtonContent extends StatelessWidget { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: BusyMarkSizes.iconSm, color: foreground), + Icon(icon, size: BusyMarkSizes.iconSm), const SizedBox(width: BusyMarkSpacing.sm), Flexible(child: text), ], @@ -2276,8 +1845,6 @@ class _BusyMarkDialogButtonContent extends StatelessWidget { } } -enum BusyMarkFloatingTextEntryPosition { single, first, middle, last } - class BusyMarkFloatingTextEntryGroup extends StatelessWidget { const BusyMarkFloatingTextEntryGroup({super.key, required this.children}) : assert(children.length > 1); @@ -2286,36 +1853,21 @@ class BusyMarkFloatingTextEntryGroup extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - final borderRadius = BorderRadius.circular(BusyMarkRadius.headerButton); - return DecoratedBox( - decoration: busyMarkSurfaceDecoration( - context, - color: colors.control, - borderRadius: borderRadius, - ), - child: ClipRRect( - borderRadius: borderRadius, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final child in children) ...[ - child, - if (child != children.last) - Divider( - height: BusyMarkStroke.hairline, - thickness: BusyMarkStroke.hairline, - color: colors.view, - ), - ], + return AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < children.length; index++) ...[ + if (index > 0) const SizedBox(height: BusyMarkSpacing.sm), + children[index], ], - ), + ], ), ); } } -class BusyMarkFloatingTextEntry extends StatefulWidget { +class BusyMarkFloatingTextEntry extends StatelessWidget { const BusyMarkFloatingTextEntry({ super.key, required this.label, @@ -2331,7 +1883,6 @@ class BusyMarkFloatingTextEntry extends StatefulWidget { this.textDirection, this.textStyle, this.onSubmitted, - this.groupPosition = BusyMarkFloatingTextEntryPosition.single, }) : assert(minLines > 0), assert(maxLines >= minLines); @@ -2348,312 +1899,27 @@ class BusyMarkFloatingTextEntry extends StatefulWidget { final TextDirection? textDirection; final TextStyle? textStyle; final ValueChanged? onSubmitted; - final BusyMarkFloatingTextEntryPosition groupPosition; - - @override - State createState() => - _BusyMarkFloatingTextEntryState(); -} - -class _BusyMarkFloatingTextEntryState extends State { - late final FocusNode _focusNode; - late final ScrollController _scrollController; - var _hovered = false; - - @override - void initState() { - super.initState(); - _focusNode = FocusNode(canRequestFocus: widget.enabled); - _scrollController = ScrollController(); - widget.controller.addListener(_handleTextChanged); - _focusNode.addListener(_handleFocusChanged); - } - - @override - void didUpdateWidget(covariant BusyMarkFloatingTextEntry oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_handleTextChanged); - widget.controller.addListener(_handleTextChanged); - } - if (oldWidget.enabled != widget.enabled) { - _focusNode.canRequestFocus = widget.enabled; - if (!widget.enabled) { - _focusNode.unfocus(); - _hovered = false; - } - } - } - - @override - void dispose() { - widget.controller.removeListener(_handleTextChanged); - _focusNode.removeListener(_handleFocusChanged); - _focusNode.dispose(); - _scrollController.dispose(); - super.dispose(); - } @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = BusyMarkSurfaceColors.of(context); - final colorScheme = theme.colorScheme; - final hasError = widget.errorText != null; - final focused = _focusNode.hasFocus; - final floating = focused || widget.controller.text.isNotEmpty; - final grouped = - widget.groupPosition != BusyMarkFloatingTextEntryPosition.single; - final activeBorder = focused || hasError; - final radius = _borderRadius(); - final borderColor = focused - ? colorScheme.primary - : hasError - ? colorScheme.error - : colors.border; - final labelColor = widget.enabled - ? colors.mutedForeground - : colors.disabledForeground; - final entryHeight = widget.maxLines == 1 - ? BusyMarkSizes.floatingEntryHeight - : BusyMarkSizes.floatingTextAreaHeight; - final foreground = widget.enabled - ? colors.foreground - : colors.disabledForeground; - final inputStyle = - (widget.textStyle ?? theme.textTheme.bodyMedium ?? const TextStyle()) - .copyWith(color: foreground); - return Semantics( - enabled: widget.enabled, - textField: true, - label: widget.label, - hint: widget.errorText ?? widget.hintText, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - MouseRegion( - cursor: widget.enabled - ? SystemMouseCursors.text - : SystemMouseCursors.basic, - onEnter: widget.enabled - ? (_) => setState(() => _hovered = true) - : null, - onExit: widget.enabled - ? (_) => setState(() => _hovered = false) - : null, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: widget.enabled ? _focusNode.requestFocus : null, - child: Container( - height: entryHeight, - decoration: busyMarkSurfaceDecoration( - context, - color: !widget.enabled - ? colors.disabledControl - : _hovered || focused - ? colors.controlHover - : colors.control, - borderRadius: radius, - border: activeBorder - ? _border( - color: borderColor, - width: focused - ? BusyMarkStroke.focus - : BusyMarkStroke.hairline, - ) - : null, - elevated: !grouped, - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - AnimatedPositionedDirectional( - duration: BusyMarkMotion.floatingEntry, - curve: BusyMarkMotion.floatingEntryCurve, - start: BusyMarkSizes.floatingEntryInset, - end: BusyMarkSizes.iconButton, - top: floating - ? BusyMarkSizes.floatingEntryLabelTop - : BusyMarkSizes.floatingEntryLabelRestTop, - height: floating - ? BusyMarkSizes.floatingEntryLabelHeight - : BusyMarkSizes.floatingEntryLabelRestHeight, - child: IgnorePointer( - child: AnimatedDefaultTextStyle( - duration: BusyMarkMotion.floatingEntry, - curve: BusyMarkMotion.floatingEntryCurve, - style: - (floating - ? theme.textTheme.labelSmall - : theme.textTheme.bodyMedium) - ?.copyWith(color: labelColor) ?? - TextStyle(color: labelColor), - child: Text( - widget.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - ), - ), - ), - ), - PositionedDirectional( - start: BusyMarkSizes.floatingEntryInset, - end: BusyMarkSizes.iconButton, - top: BusyMarkSizes.floatingEntryInputTop, - bottom: BusyMarkSizes.floatingEntryInputBottom, - child: AnimatedOpacity( - duration: BusyMarkMotion.floatingEntry, - curve: BusyMarkMotion.floatingEntryCurve, - opacity: floating ? 1 : 0, - child: Stack( - fit: StackFit.expand, - children: [ - if (widget.hintText case final hint? - when widget.controller.text.isEmpty) - ExcludeSemantics( - child: Align( - alignment: AlignmentDirectional.topStart, - child: Text( - hint, - maxLines: widget.maxLines, - overflow: TextOverflow.ellipsis, - textDirection: widget.textDirection, - style: inputStyle.copyWith( - color: labelColor, - ), - ), - ), - ), - EditableText( - controller: widget.controller, - focusNode: _focusNode, - scrollController: _scrollController, - autofocus: widget.enabled && widget.autofocus, - keyboardType: widget.keyboardType, - textInputAction: widget.textInputAction, - textDirection: widget.textDirection, - onSubmitted: widget.enabled - ? widget.onSubmitted - : null, - readOnly: !widget.enabled, - showCursor: widget.enabled, - enableInteractiveSelection: widget.enabled, - minLines: widget.minLines, - maxLines: widget.maxLines, - forceLine: true, - style: inputStyle, - cursorColor: colorScheme.primary, - backgroundCursorColor: colors.controlActive, - selectionColor: colorScheme.primary.withValues( - alpha: BusyMarkAlpha.floatingTextSelection, - ), - ), - ], - ), - ), - ), - PositionedDirectional( - end: BusyMarkSpacing.md, - top: 0, - bottom: 0, - child: IgnorePointer( - child: AnimatedOpacity( - duration: BusyMarkMotion.floatingEntry, - curve: BusyMarkMotion.floatingEntryCurve, - opacity: focused || !widget.enabled ? 0 : 1, - child: Center( - child: Icon( - BusyMarkGlyphs.edit, - size: BusyMarkSizes.iconSm, - color: colors.mutedForeground.withValues( - alpha: BusyMarkAlpha.floatingEntryIcon, - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), - if (hasError) - Padding( - padding: const EdgeInsetsDirectional.fromSTEB( - BusyMarkSpacing.md, - BusyMarkSpacing.xs, - BusyMarkSpacing.md, - BusyMarkSpacing.sm, - ), - child: Text( - widget.errorText!, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.error, - ), - ), - ), - ], + return TextFormField( + controller: controller, + enabled: enabled, + autofocus: autofocus, + keyboardType: keyboardType, + minLines: minLines, + maxLines: maxLines, + textInputAction: textInputAction, + textDirection: textDirection, + style: textStyle, + onFieldSubmitted: enabled ? onSubmitted : null, + decoration: InputDecoration( + labelText: label, + hintText: hintText, + errorText: errorText, ), ); } - - void _handleTextChanged() { - if (mounted) { - setState(() {}); - } - } - - void _handleFocusChanged() { - if (mounted) { - setState(() {}); - } - } - - BorderRadius _borderRadius() { - const radius = Radius.circular(BusyMarkRadius.headerButton); - return switch (widget.groupPosition) { - BusyMarkFloatingTextEntryPosition.single => const BorderRadius.all( - radius, - ), - BusyMarkFloatingTextEntryPosition.first => const BorderRadius.vertical( - top: radius, - ), - BusyMarkFloatingTextEntryPosition.middle => BorderRadius.zero, - BusyMarkFloatingTextEntryPosition.last => const BorderRadius.vertical( - bottom: radius, - ), - }; - } - - Border _border({required Color color, required double width}) { - final side = BorderSide(color: color, width: width); - return switch (widget.groupPosition) { - BusyMarkFloatingTextEntryPosition.single => Border.all( - color: color, - width: width, - ), - BusyMarkFloatingTextEntryPosition.first => Border( - top: side, - right: side, - bottom: side, - left: side, - ), - BusyMarkFloatingTextEntryPosition.middle => Border( - top: side, - right: side, - bottom: side, - left: side, - ), - BusyMarkFloatingTextEntryPosition.last => Border( - top: side, - right: side, - bottom: side, - left: side, - ), - }; - } } class SectionLabel extends StatelessWidget { diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index 395ce92..8d762d6 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -49,9 +49,11 @@ Future showBusyMarkModalDialog( bool barrierDismissible = true, }) async { final barrierColor = busyMarkModalBarrierColor(context); - await headerBarService?.setModalBarrierVisible(true); + final barrierLease = _BusyMarkModalBarrierCoordinator.acquire( + headerBarService ?? LinuxHeaderBarService.instance, + ); if (!context.mounted) { - await headerBarService?.setModalBarrierVisible(false); + barrierLease.release(); return null; } try { @@ -73,15 +75,75 @@ Future showBusyMarkModalDialog( padding: padding, duration: BusyMarkMotion.modalPadding, curve: BusyMarkMotion.modalPaddingCurve, - child: Center( - child: BusyMarkModalEditorSurface(child: builder(dialogContext)), - ), + child: BusyMarkModalEditorSurface(child: builder(dialogContext)), ), ); }, ); } finally { - await headerBarService?.setModalBarrierVisible(false); + barrierLease.release(); + } +} + +class _BusyMarkModalBarrierCoordinator { + const _BusyMarkModalBarrierCoordinator._(); + + static final Map _activeDialogs = {}; + static final Map> _pendingUpdates = {}; + + static _BusyMarkModalBarrierLease acquire(LinuxHeaderBarService service) { + final activeCount = _activeDialogs[service] ?? 0; + _activeDialogs[service] = activeCount + 1; + if (activeCount == 0) { + unawaited(_enqueueUpdate(service, visible: true)); + } + return _BusyMarkModalBarrierLease(service); + } + + static Future release(LinuxHeaderBarService service) async { + final activeCount = _activeDialogs[service]; + if (activeCount == null) { + return; + } + if (activeCount > 1) { + _activeDialogs[service] = activeCount - 1; + return; + } + _activeDialogs.remove(service); + await _enqueueUpdate(service, visible: false); + } + + static Future _enqueueUpdate( + LinuxHeaderBarService service, { + required bool visible, + }) async { + final previous = _pendingUpdates[service] ?? Future.value(); + final update = previous + .catchError((Object _) {}) + .then((_) => service.setModalBarrierVisible(visible)); + _pendingUpdates[service] = update; + try { + await update; + } finally { + if (identical(_pendingUpdates[service], update)) { + _pendingUpdates.remove(service); + } + } + } +} + +class _BusyMarkModalBarrierLease { + _BusyMarkModalBarrierLease(this._service); + + final LinuxHeaderBarService _service; + var _released = false; + + void release() { + if (_released) { + return; + } + _released = true; + unawaited(_BusyMarkModalBarrierCoordinator.release(_service)); } } @@ -99,25 +161,16 @@ class BusyMarkModalEditorSurface extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return ConstrainedBox( - constraints: BoxConstraints( - maxWidth: maxWidth, - maxHeight: - maxHeight ?? - MediaQuery.sizeOf(context).height * - BusyMarkSizes.modalMaxHeightFraction, - ), - child: Material( - color: colors.dialog, - elevation: BusyMarkElevation.popover, - shadowColor: BusyMarkShadow.floatingColor(context), - surfaceTintColor: BusyMarkLinuxPalette.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMarkRadius.lg), - side: BorderSide.none, + return Dialog( + insetPadding: EdgeInsets.zero, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: + maxHeight ?? + MediaQuery.sizeOf(context).height * + BusyMarkSizes.modalMaxHeightFraction, ), - clipBehavior: Clip.antiAlias, child: child, ), ); diff --git a/lib/src/app/busymark_search_field.dart b/lib/src/app/busymark_search_field.dart new file mode 100644 index 0000000..6a4eb72 --- /dev/null +++ b/lib/src/app/busymark_search_field.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:yaru/yaru.dart'; + +/// Flutter fallback for BusyMark's native Linux search entry. +/// +/// Linux header bars use `GtkSearchEntry`. Flutter-owned layouts delegate +/// geometry, icons, focus presentation, and clear behavior to Yaru. +class BusyMarkSearchField extends StatefulWidget { + const BusyMarkSearchField({ + super.key, + this.controller, + this.hintText, + this.autofocus = false, + this.focusRequest = 0, + this.onChanged, + this.onSubmitted, + this.onClear, + this.onEscape, + this.clearButtonSemanticLabel, + }); + + final TextEditingController? controller; + final String? hintText; + final bool autofocus; + + /// Increment this value to focus the Yaru-owned text entry again. + final int focusRequest; + + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final VoidCallback? onClear; + final VoidCallback? onEscape; + final String? clearButtonSemanticLabel; + + @override + State createState() => _BusyMarkSearchFieldState(); +} + +class _BusyMarkSearchFieldState extends State { + final _focusScopeNode = FocusScopeNode( + debugLabel: 'BusyMarkSearchField scope', + ); + final _yaruKeyboardFocusNode = FocusNode( + debugLabel: 'BusyMarkSearchField keyboard listener', + skipTraversal: true, + ); + + @override + void initState() { + super.initState(); + if (widget.autofocus) { + _requestTextFocus(); + } + } + + @override + void didUpdateWidget(covariant BusyMarkSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.focusRequest != widget.focusRequest) { + _requestTextFocus(); + } + } + + @override + void dispose() { + _focusScopeNode.dispose(); + _yaruKeyboardFocusNode.dispose(); + super.dispose(); + } + + void _requestTextFocus() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + for (final node in _focusScopeNode.traversalDescendants) { + if (node.canRequestFocus) { + node.requestFocus(); + return; + } + } + }); + } + + @override + Widget build(BuildContext context) { + return Focus( + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape && + widget.onEscape != null) { + widget.onEscape!(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: FocusScope( + node: _focusScopeNode, + child: YaruSearchField( + controller: widget.controller, + focusNode: _yaruKeyboardFocusNode, + hintText: widget.hintText, + autofocus: widget.autofocus, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + onClear: widget.onClear, + clearIconSemanticLabel: + widget.clearButtonSemanticLabel ?? + MaterialLocalizations.of(context).clearButtonTooltip, + ), + ), + ); + } +} diff --git a/lib/src/app/system_accent.dart b/lib/src/app/system_accent.dart index 1624df2..045d9a5 100644 --- a/lib/src/app/system_accent.dart +++ b/lib/src/app/system_accent.dart @@ -4,10 +4,9 @@ import 'dart:io'; import 'package:dbus/dbus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:yaru/yaru.dart'; -import 'busymark_design.dart'; - -const busyMarkDefaultAccentColor = BusyMarkLinuxPalette.blueAccent; +final busyMarkDefaultAccentColor = YaruVariant.orange.color; final initialSystemAccentColorProvider = Provider( (ref) => busyMarkDefaultAccentColor, @@ -15,7 +14,8 @@ final initialSystemAccentColorProvider = Provider( final systemAccentColorProvider = StreamProvider((ref) async* { final fallback = ref.watch(initialSystemAccentColorProvider); - yield fallback; + var current = fallback; + yield current; if (!Platform.isLinux) { return; @@ -23,10 +23,17 @@ final systemAccentColorProvider = StreamProvider((ref) async* { final appearance = LinuxPortalAppearance(); final initial = await appearance.readAccentColor(); - if (initial != null && initial != fallback) { + if (initial != null && initial != current) { + current = initial; yield initial; } - yield* appearance.accentColorChanges().distinct(); + await for (final color in appearance.accentColorChanges()) { + if (color == current) { + continue; + } + current = color; + yield color; + } }); class LinuxPortalAppearance { @@ -43,10 +50,10 @@ class LinuxPortalAppearance { final client = DBusClient.session(); try { final object = _portalObject(client); - return await _readFreedesktopAccent(object) ?? - await _readGnomeAccentName(object); - } on Object { - return null; + return await readPreferredLinuxAccentColor( + readFreedesktop: () => _readFreedesktopAccent(object), + readGnome: () => _readGnomeAccentName(object), + ); } finally { await client.close(); } @@ -56,6 +63,22 @@ class LinuxPortalAppearance { final client = DBusClient.session(); try { final object = _portalObject(client); + final freedesktopAccent = await _readAccentSafely( + () => _readFreedesktopAccent(object), + ); + final resolver = LinuxAccentChangeResolver( + freedesktopAuthoritative: freedesktopAccent != null, + ); + if (freedesktopAccent != null) { + yield freedesktopAccent; + } else { + final gnomeAccent = await _readAccentSafely( + () => _readGnomeAccentName(object), + ); + if (gnomeAccent != null) { + yield gnomeAccent; + } + } final signals = DBusRemoteObjectSignalStream( object: object, interface: _settingsInterface, @@ -68,12 +91,7 @@ class LinuxPortalAppearance { if (key != _accentColor) { continue; } - final value = signal.values[2].asVariant(); - final color = namespace == _freedesktopAppearance - ? colorFromPortalAccentValue(value) - : namespace == _gnomeInterface - ? colorFromUbuntuAccentNameValue(value) - : null; + final color = resolver.resolve(namespace, signal.values[2].asVariant()); if (color != null) { yield color; } @@ -120,6 +138,53 @@ class LinuxPortalAppearance { } } +/// Reads the exact freedesktop RGB value first, while keeping the Ubuntu +/// named-accent setting as an independent fallback for older portals. +@visibleForTesting +Future readPreferredLinuxAccentColor({ + required Future Function() readFreedesktop, + required Future Function() readGnome, +}) async { + final freedesktopAccent = await _readAccentSafely(readFreedesktop); + if (freedesktopAccent != null) { + return freedesktopAccent; + } + return _readAccentSafely(readGnome); +} + +Future _readAccentSafely(Future Function() read) async { + try { + return await read(); + } on Object { + return null; + } +} + +/// Resolves portal change signals without allowing an approximate named color +/// to replace an exact RGB value once the modern freedesktop key is available. +@visibleForTesting +class LinuxAccentChangeResolver { + LinuxAccentChangeResolver({bool freedesktopAuthoritative = false}) + : _freedesktopAuthoritative = freedesktopAuthoritative; + + bool _freedesktopAuthoritative; + + Color? resolve(String namespace, DBusValue value) { + if (namespace == LinuxPortalAppearance._freedesktopAppearance) { + final color = colorFromPortalAccentValue(value); + if (color != null) { + _freedesktopAuthoritative = true; + } + return color; + } + if (namespace == LinuxPortalAppearance._gnomeInterface && + !_freedesktopAuthoritative) { + return colorFromUbuntuAccentNameValue(value); + } + return null; + } +} + Color? colorFromPortalAccentValue(DBusValue value) { final resolved = value.signature == DBusSignature('v') ? value.asVariant() @@ -149,21 +214,20 @@ Color? colorFromUbuntuAccentNameValue(DBusValue value) { Color? ubuntuAccentNameColor(String name) { return switch (name) { - 'blue' => BusyMarkLinuxPalette.ubuntuBlueAccent, - 'teal' => BusyMarkLinuxPalette.ubuntuTealAccent, - 'green' => BusyMarkLinuxPalette.ubuntuGreenAccent, - 'yellow' => BusyMarkLinuxPalette.ubuntuYellowAccent, - 'orange' => BusyMarkLinuxPalette.ubuntuOrangeAccent, - 'red' => BusyMarkLinuxPalette.ubuntuRedAccent, - 'pink' => BusyMarkLinuxPalette.ubuntuPinkAccent, - 'purple' => BusyMarkLinuxPalette.ubuntuPurpleAccent, - 'slate' => BusyMarkLinuxPalette.ubuntuSlateAccent, - 'brown' => BusyMarkLinuxPalette.ubuntuBrownAccent, - 'magenta' => BusyMarkLinuxPalette.ubuntuMagentaAccent, - 'olive' => BusyMarkLinuxPalette.ubuntuOliveAccent, - 'prussiangreen' => BusyMarkLinuxPalette.ubuntuPrussianGreenAccent, - 'sage' => BusyMarkLinuxPalette.ubuntuSageAccent, - 'wartybrown' => BusyMarkLinuxPalette.ubuntuWartyBrownAccent, + 'blue' => YaruVariant.blue.color, + 'teal' => YaruVariant.adwaitaTeal.color, + 'green' => YaruVariant.adwaitaGreen.color, + 'yellow' => YaruVariant.adwaitaYellow.color, + 'orange' => YaruVariant.orange.color, + 'red' => YaruVariant.red.color, + 'pink' => YaruVariant.magenta.color, + 'purple' => YaruVariant.purple.color, + 'slate' => YaruVariant.adwaitaSlate.color, + 'brown' || 'wartybrown' => YaruVariant.wartyBrown.color, + 'magenta' => YaruVariant.magenta.color, + 'olive' => YaruVariant.olive.color, + 'prussiangreen' => YaruVariant.prussianGreen.color, + 'sage' => YaruVariant.sage.color, _ => null, }; } diff --git a/lib/src/core/path_utils.dart b/lib/src/core/path_utils.dart index d162528..161d67b 100644 --- a/lib/src/core/path_utils.dart +++ b/lib/src/core/path_utils.dart @@ -230,3 +230,18 @@ String slugForHeading(String text) { } return buffer.toString(); } + +/// Returns the next source-order ID for a generated Markdown heading. +/// +/// [occurrenceCounts] is updated so every parser or editor projection applies +/// the same duplicate suffixes. Empty slugs use the conventional `section` +/// fallback. +String nextGeneratedHeadingId( + String baseId, + Map occurrenceCounts, +) { + final normalizedBase = baseId.isEmpty ? 'section' : baseId; + final occurrence = occurrenceCounts[normalizedBase] ?? 0; + occurrenceCounts[normalizedBase] = occurrence + 1; + return occurrence == 0 ? normalizedBase : '$normalizedBase-$occurrence'; +} diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index 809526a..0bb5859 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -7,7 +7,6 @@ import 'package:flutter/services.dart'; import 'package:yaru/yaru.dart'; import '../../app/busymark_design.dart'; -import '../../app/busymark_glyphs.dart'; import '../../app/busymark_shortcuts.dart'; import '../../app/localization.dart'; import '../../core/diagnostic.dart'; @@ -1116,71 +1115,63 @@ class _SourceSearchPanel extends StatelessWidget { : result.totalMatchCount == 0 ? '0 / 0' : '${(result.currentMatchIndex ?? 0) + 1} / ${result.totalMatchCount}'; - return Material( - elevation: 2, + return BusyMarkSurface( color: colors.panel, - borderRadius: BorderRadius.circular(BusyMarkRadius.sm), - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(BusyMarkRadius.sm), - border: Border.all(color: colors.subtleBorder), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.xs, + vertical: BusyMarkSpacing.xxs, ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.xs, - vertical: BusyMarkSpacing.xxs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - status, - textDirection: result.invalidRegex - ? Directionality.of(context) - : TextDirection.ltr, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: result.invalidRegex - ? Theme.of(context).colorScheme.error - : colors.mutedForeground, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - const SizedBox(width: BusyMarkSpacing.xs), - _SearchPanelIconButton( - tooltip: context.l10n.sourceSearchPreviousMatch, - icon: YaruIcons.pan_up, - onPressed: result.totalMatchCount == 0 ? null : onPrevious, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + status, + textDirection: result.invalidRegex + ? Directionality.of(context) + : TextDirection.ltr, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: result.invalidRegex + ? Theme.of(context).colorScheme.error + : colors.mutedForeground, + fontFeatures: const [FontFeature.tabularFigures()], ), - _SearchPanelIconButton( - tooltip: context.l10n.sourceSearchNextMatch, - icon: YaruIcons.pan_down, - onPressed: result.totalMatchCount == 0 ? null : onNext, - ), - _SearchOptionButton( - label: 'Aa', - tooltip: context.l10n.sourceSearchCaseSensitive, - selected: result.options.caseSensitive, - onPressed: onToggleCaseSensitive, - ), - _SearchOptionButton( - label: 'W', - tooltip: context.l10n.sourceSearchWholeWord, - selected: result.options.wholeWord, - onPressed: onToggleWholeWord, - ), - _SearchOptionButton( - label: '.*', - tooltip: context.l10n.sourceSearchRegex, - selected: result.options.regex, - onPressed: onToggleRegex, - ), - _SearchPanelIconButton( - tooltip: context.l10n.close, - icon: YaruIcons.window_close, - onPressed: onClose, - ), - ], - ), + ), + const SizedBox(width: BusyMarkSpacing.xs), + _SearchPanelIconButton( + tooltip: context.l10n.sourceSearchPreviousMatch, + icon: YaruIcons.pan_up, + onPressed: result.totalMatchCount == 0 ? null : onPrevious, + ), + _SearchPanelIconButton( + tooltip: context.l10n.sourceSearchNextMatch, + icon: YaruIcons.pan_down, + onPressed: result.totalMatchCount == 0 ? null : onNext, + ), + _SearchOptionButton( + label: 'Aa', + tooltip: context.l10n.sourceSearchCaseSensitive, + selected: result.options.caseSensitive, + onPressed: onToggleCaseSensitive, + ), + _SearchOptionButton( + label: 'W', + tooltip: context.l10n.sourceSearchWholeWord, + selected: result.options.wholeWord, + onPressed: onToggleWholeWord, + ), + _SearchOptionButton( + label: '.*', + tooltip: context.l10n.sourceSearchRegex, + selected: result.options.regex, + onPressed: onToggleRegex, + ), + _SearchPanelIconButton( + tooltip: context.l10n.close, + icon: YaruIcons.window_close, + onPressed: onClose, + ), + ], ), ), ); @@ -1200,19 +1191,11 @@ class _SearchPanelIconButton extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return Tooltip( - message: tooltip, - waitDuration: BusyMarkMotion.tooltipWait, - child: IconButton( - visualDensity: VisualDensity.compact, - constraints: const BoxConstraints.tightFor(width: 28, height: 28), - padding: EdgeInsets.zero, - iconSize: 14, - color: colors.mutedForeground, - onPressed: onPressed, - icon: Icon(icon), - ), + return YaruIconButton( + tooltip: tooltip, + iconSize: 28, + onPressed: onPressed, + icon: Icon(icon, size: 14), ); } } @@ -1232,43 +1215,28 @@ class _SearchOptionButton extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return Tooltip( - message: tooltip, - waitDuration: BusyMarkMotion.tooltipWait, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 1), - child: InkWell( - borderRadius: BorderRadius.circular(BusyMarkRadius.sm), - onTap: onPressed, - child: DecoratedBox( - decoration: BoxDecoration( - color: selected - ? colors.controlActive - : BusyMarkLinuxPalette.transparent, - borderRadius: BorderRadius.circular(BusyMarkRadius.sm), - ), - child: SizedBox( - width: 28, - height: 28, - child: Center( - child: Text( - label, - textDirection: TextDirection.ltr, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: selected - ? colors.foreground - : colors.mutedForeground, - fontWeight: FontWeight.w700, - letterSpacing: 0, - ), - ), - ), - ), - ), + Widget optionLabel() => Builder( + builder: (context) => Text( + label, + textDirection: TextDirection.ltr, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: IconTheme.of(context).color, + fontWeight: FontWeight.w700, + letterSpacing: 0, ), ), ); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: YaruIconButton( + tooltip: tooltip, + iconSize: 28, + isSelected: selected, + onPressed: onPressed, + icon: optionLabel(), + selectedIcon: optionLabel(), + ), + ); } } @@ -1277,40 +1245,10 @@ class _SourceLargeFileBanner extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return Material( - color: colors.panel, - elevation: 1, - borderRadius: BorderRadius.circular(BusyMarkRadius.sm), - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(BusyMarkRadius.sm), - border: Border.all(color: colors.subtleBorder), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.sm, - vertical: BusyMarkSpacing.xs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - BusyMarkGlyphs.info, - size: BusyMarkSizes.iconSm, - color: colors.mutedForeground, - ), - const SizedBox(width: BusyMarkSpacing.xs), - Text( - context.l10n.sourceLargeFileFeaturesPaused, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colors.mutedForeground, - letterSpacing: 0, - ), - ), - ], - ), - ), + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: BusyMarkSizes.dialogCompact), + child: BusyMarkStatusBox( + message: context.l10n.sourceLargeFileFeaturesPaused, ), ); } diff --git a/lib/src/editor/source/source_gutter.dart b/lib/src/editor/source/source_gutter.dart index b36c167..85e24b2 100644 --- a/lib/src/editor/source/source_gutter.dart +++ b/lib/src/editor/source/source_gutter.dart @@ -264,31 +264,15 @@ class _SourceFoldButton extends StatelessWidget { @override Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); - return Tooltip( - message: collapsed + return BusyMarkCompactIconButton( + tooltip: collapsed ? context.l10n.expandKind(_foldKindLabel(context, region.kind)) : context.l10n.collapseKind(_foldKindLabel(context, region.kind)), - waitDuration: BusyMarkMotion.tooltipWait, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onToggleFold(region), - child: SizedBox.square( - dimension: _SourceGutterRow._foldButtonSize, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(BusyMarkRadius.sm), - ), - child: Icon( - collapsed ? YaruIcons.pan_end : YaruIcons.pan_down, - size: 12, - color: colors.mutedForeground, - ), - ), - ), - ), - ), + size: _SourceGutterRow._foldButtonSize, + glyphSize: 12, + foregroundColor: colors.mutedForeground, + onPressed: () => onToggleFold(region), + icon: collapsed ? YaruIcons.pan_end : YaruIcons.pan_down, ); } } @@ -479,9 +463,18 @@ Color sourceDiagnosticColorForSeverity( DiagnosticSeverity severity, ) { return switch (severity) { - DiagnosticSeverity.error => Theme.of(context).colorScheme.error, - DiagnosticSeverity.warning => BusyMarkLinuxPalette.yellow, - DiagnosticSeverity.info => Theme.of(context).colorScheme.primary, + DiagnosticSeverity.error => busyMarkStatusColor( + context, + BusyMarkStatusKind.error, + ), + DiagnosticSeverity.warning => busyMarkStatusColor( + context, + BusyMarkStatusKind.warning, + ), + DiagnosticSeverity.info => busyMarkStatusColor( + context, + BusyMarkStatusKind.information, + ), DiagnosticSeverity.hint => BusyMarkSurfaceColors.of(context).muted, }; } diff --git a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart index 362fb4b..ac0ffba 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart @@ -595,16 +595,10 @@ class _RenderedHtmlBlockEditor extends StatelessWidget { ), ), const Spacer(), - IconButton( + BusyMarkHeaderIconButton( tooltip: context.l10n.editHtml, - style: busyMarkHeaderIconButtonStyle( - foregroundColor: colors.mutedForeground, - backgroundColor: busyMarkHeaderButtonBackground(context), - ), - icon: const Icon( - BusyMarkGlyphs.edit, - size: BusyMarkSizes.iconSm, - ), + icon: BusyMarkGlyphs.edit, + foregroundColor: colors.mutedForeground, onPressed: onEdit, ), ], @@ -1267,13 +1261,10 @@ class _TableBlockEditor extends StatelessWidget { children: [ Align( alignment: AlignmentDirectional.centerEnd, - child: IconButton( + child: BusyMarkHeaderIconButton( tooltip: context.l10n.deleteTable, - style: busyMarkHeaderIconButtonStyle( - foregroundColor: colors.mutedForeground, - backgroundColor: busyMarkHeaderButtonBackground(context), - ), - icon: const Icon(BusyMarkGlyphs.delete, size: BusyMarkSizes.iconSm), + icon: BusyMarkGlyphs.delete, + foregroundColor: colors.mutedForeground, onPressed: onTableDeleted, ), ), @@ -1460,17 +1451,17 @@ class _TableControlMenuButton extends StatelessWidget { tooltip: tooltip, icon: icon, itemBuilder: (context) => [ - PopupMenuItem( + BusyMarkPopupMenuItem( value: _TableControlAction.insertBefore, - child: Text(beforeLabel), + label: beforeLabel, ), - PopupMenuItem( + BusyMarkPopupMenuItem( value: _TableControlAction.insertAfter, - child: Text(afterLabel), + label: afterLabel, ), - PopupMenuItem( + BusyMarkPopupMenuItem( value: _TableControlAction.delete, - child: Text(deleteLabel), + label: deleteLabel, ), ], onSelected: onSelected, diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 33a44cc..a1911cb 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -13,6 +13,7 @@ import '../../app/busymark_glyphs.dart'; import '../../app/busymark_shortcuts.dart'; import '../../app/localization.dart'; import '../../markdown/busymark_document.dart'; +import '../../markdown/document_outline.dart'; import '../../platform/linux_header_bar_service.dart'; import '../document_callout.dart'; import '../document_code_block.dart'; @@ -42,6 +43,7 @@ class BusyMarkWysiwygEditor extends StatefulWidget { this.onToolbarPlacementChanged, this.onToolbarDirectionChanged, this.scrollToHeadingId, + this.scrollToBlockId, this.scrollToSearchQuery, this.scrollRequest = 0, this.onOpenSearch, @@ -63,6 +65,7 @@ class BusyMarkWysiwygEditor extends StatefulWidget { final ValueChanged? onToolbarPlacementChanged; final ValueChanged? onToolbarDirectionChanged; final String? scrollToHeadingId; + final String? scrollToBlockId; final String? scrollToSearchQuery; final int scrollRequest; final VoidCallback? onOpenSearch; @@ -977,24 +980,33 @@ class _BusyMarkWysiwygEditorState extends State { void _scheduleHeadingScroll() { final headingId = widget.scrollToHeadingId; - if (headingId == null || headingId.isEmpty || widget.scrollRequest == 0) { + final editorBlockId = widget.scrollToBlockId; + if (widget.scrollRequest == 0 || + ((headingId == null || headingId.isEmpty) && + (editorBlockId == null || editorBlockId.isEmpty))) { return; } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } - final heading = _headingBlockForId(headingId); + var heading = editorBlockId == null + ? null + : _documentController.blockById(editorBlockId); + if (heading?.kind != BusyBlockKind.heading) { + heading = headingId == null ? null : _headingBlockForId(headingId); + } if (heading == null) { return; } - if (_ensureBlockVisible(heading.id)) { + final headingBlockId = heading.id; + if (_ensureBlockVisible(headingBlockId)) { return; } - _jumpNearBlock(heading.id); + _jumpNearBlock(headingBlockId); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - _ensureBlockVisible(heading.id); + _ensureBlockVisible(headingBlockId); } }); }); @@ -1079,10 +1091,13 @@ class _BusyMarkWysiwygEditorState extends State { } BusyBlock? _headingBlockForId(String headingId) { - for (final block in _flattenBlocks(_documentController.document.blocks)) { - if (block.kind == BusyBlockKind.heading && - (block.id == headingId || block.attributes['id'] == headingId)) { - return block; + for (final heading in _documentController.document.outline) { + if (heading.id != headingId) { + continue; + } + final blockId = heading.editorBlockId; + if (blockId != null) { + return _documentController.blockById(blockId); } } return null; @@ -2108,28 +2123,32 @@ class _BusyMarkWysiwygEditorState extends State { var destination = ''; return _showEditorDialog( context, - builder: (context) => AlertDialog( - title: Text(context.l10n.link), - content: TextFormField( - key: const ValueKey('wysiwyg-link-destination-field'), - autofocus: true, - textDirection: TextDirection.ltr, - onChanged: (value) => destination = value, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: BusyMarkTypography.monoFontFamily, - fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, - ), - decoration: const InputDecoration(hintText: 'https://example.com'), - onFieldSubmitted: (value) => Navigator.pop(context, value), - ), + builder: (context) => BusyMarkDialogShell( + title: context.l10n.link, + maxWidth: BusyMarkSizes.dialogCompact, actions: [ - TextButton( + BusyMarkDialogButton( + label: context.l10n.cancel, onPressed: () => Navigator.pop(context), - child: Text(context.l10n.cancel), ), - FilledButton( + BusyMarkDialogButton( + label: context.l10n.apply, onPressed: () => Navigator.pop(context, destination), - child: Text(context.l10n.apply), + suggested: true, + ), + ], + children: [ + TextFormField( + key: const ValueKey('wysiwyg-link-destination-field'), + autofocus: true, + textDirection: TextDirection.ltr, + onChanged: (value) => destination = value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: BusyMarkTypography.monoFontFamily, + fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, + ), + decoration: const InputDecoration(hintText: 'https://example.com'), + onFieldSubmitted: (value) => Navigator.pop(context, value), ), ], ), @@ -2176,11 +2195,22 @@ class _BusyMarkWysiwygEditorState extends State { var source = initialSource; return _showEditorDialog( context, - builder: (context) => AlertDialog( - title: Text(context.l10n.editHtml), - content: SizedBox( - width: BusyMarkSizes.dialogNarrow, - child: TextFormField( + builder: (context) => BusyMarkDialogShell( + title: context.l10n.editHtml, + maxWidth: BusyMarkSizes.dialogNarrow, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context), + ), + BusyMarkDialogButton( + label: submitLabel, + onPressed: () => Navigator.pop(context, source), + suggested: true, + ), + ], + children: [ + TextFormField( key: const ValueKey('wysiwyg-html-source-field'), initialValue: initialSource, onChanged: (value) => source = value, @@ -2195,16 +2225,6 @@ class _BusyMarkWysiwygEditorState extends State { ), decoration: InputDecoration(labelText: context.l10n.htmlSource), ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.cancel), - ), - FilledButton( - onPressed: () => Navigator.pop(context, source), - child: Text(submitLabel), - ), ], ), ); @@ -2217,11 +2237,22 @@ class _BusyMarkWysiwygEditorState extends State { var language = initialLanguage; return _showEditorDialog( context, - builder: (context) => AlertDialog( - title: Text(context.l10n.codeBlockLanguage), - content: SizedBox( - width: BusyMarkSizes.tableDialogWidth, - child: TextFormField( + builder: (context) => BusyMarkDialogShell( + title: context.l10n.codeBlockLanguage, + maxWidth: BusyMarkSizes.dialogCompact, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context), + ), + BusyMarkDialogButton( + label: context.l10n.apply, + onPressed: () => Navigator.pop(context, language), + suggested: true, + ), + ], + children: [ + TextFormField( key: const ValueKey('wysiwyg-code-language-field'), initialValue: initialLanguage, onChanged: (value) => language = value, @@ -2237,16 +2268,6 @@ class _BusyMarkWysiwygEditorState extends State { ), onFieldSubmitted: (value) => Navigator.pop(context, value), ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.cancel), - ), - FilledButton( - onPressed: () => Navigator.pop(context, language), - child: Text(context.l10n.apply), - ), ], ), ); @@ -3009,21 +3030,7 @@ class _FloatingWysiwygToolbar extends StatelessWidget { ? placement._isRight : !placement._isTop; final toolbar = visible - ? axis == Axis.horizontal - ? SizedBox( - width: math.max( - 0, - maxWidth - BusyMarkSizes.wysiwygToolbarReserve, - ), - child: child, - ) - : SizedBox( - height: math.max( - 0, - maxHeight - BusyMarkSizes.wysiwygToolbarReserve, - ), - child: child, - ) + ? Flexible(fit: FlexFit.loose, child: child) : const SizedBox.shrink(); final configurable = onPlacementChanged != null || onDirectionChanged != null; @@ -3245,13 +3252,6 @@ String? _imageSourceFromInline(BusyInline inline) { return null; } -Iterable _flattenBlocks(List blocks) sync* { - for (final block in blocks) { - yield block; - yield* _flattenBlocks(block.children); - } -} - class _ImageDialogResult { const _ImageDialogResult({required this.source, required this.alt}); @@ -3444,11 +3444,22 @@ class _TableDialogState extends State<_TableDialog> { @override Widget build(BuildContext context) { - return AlertDialog( - title: Text(context.l10n.table), - content: SizedBox( - width: BusyMarkSizes.tableDialogWidth, - child: Row( + return BusyMarkDialogShell( + title: context.l10n.table, + maxWidth: BusyMarkSizes.dialogCompact, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context), + ), + BusyMarkDialogButton( + label: context.l10n.insert, + onPressed: _submit, + suggested: true, + ), + ], + children: [ + Row( children: [ Expanded( child: TextField( @@ -3476,13 +3487,6 @@ class _TableDialogState extends State<_TableDialog> { ), ], ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.cancel), - ), - FilledButton(onPressed: _submit, child: Text(context.l10n.insert)), ], ); } diff --git a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart index 1d0d145..0b08b45 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart @@ -243,14 +243,10 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { } Widget _blockStyleMenu(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; return BusyMarkHeaderPopupMenuButton( tooltip: context.l10n.textStyle, icon: BusyMarkGlyphs.font, shortcut: BusyMarkEditorShortcutLabels.textStyle, - foregroundColor: colorScheme.onPrimary, - backgroundColor: _toolbarButtonBackground(context), - elevated: true, itemBuilder: (context) => [ BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.paragraph, @@ -299,39 +295,11 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { required VoidCallback onPressed, String? shortcut, }) { - final colorScheme = Theme.of(context).colorScheme; return BusyMarkHeaderIconButton( tooltip: tooltip, icon: icon, onPressed: onPressed, shortcut: shortcut, - foregroundColor: colorScheme.onPrimary, - backgroundColor: _toolbarButtonBackground(context), - elevated: true, ); } - - WidgetStateProperty _toolbarButtonBackground(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final colors = BusyMarkSurfaceColors.of(context); - return WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return colors.disabledControl; - } - if (states.contains(WidgetState.pressed)) { - return Color.alphaBlend( - colorScheme.onPrimary.withValues(alpha: BusyMarkAlpha.toolbarPressed), - colorScheme.primary, - ); - } - if (states.contains(WidgetState.hovered) || - states.contains(WidgetState.focused)) { - return Color.alphaBlend( - colorScheme.onPrimary.withValues(alpha: BusyMarkAlpha.toolbarHover), - colorScheme.primary, - ); - } - return colorScheme.primary; - }); - } } diff --git a/lib/src/feedback/presentation/feedback_dialog.dart b/lib/src/feedback/presentation/feedback_dialog.dart index 12ba42e..8076a7d 100644 --- a/lib/src/feedback/presentation/feedback_dialog.dart +++ b/lib/src/feedback/presentation/feedback_dialog.dart @@ -172,7 +172,6 @@ class _BusyMarkFeedbackDialogState autofocus: true, textInputAction: TextInputAction.next, errorText: subjectError, - groupPosition: BusyMarkFloatingTextEntryPosition.first, ), BusyMarkFloatingTextEntry( key: BusyMarkFeedbackKeys.message, @@ -184,7 +183,6 @@ class _BusyMarkFeedbackDialogState keyboardType: TextInputType.multiline, textInputAction: TextInputAction.newline, errorText: messageError, - groupPosition: BusyMarkFloatingTextEntryPosition.middle, ), BusyMarkFloatingTextEntry( key: BusyMarkFeedbackKeys.replyEmail, @@ -200,7 +198,6 @@ class _BusyMarkFeedbackDialogState } }, errorText: replyEmailError, - groupPosition: BusyMarkFloatingTextEntryPosition.last, ), ], ), diff --git a/lib/src/git/presentation/git_changes_view.dart b/lib/src/git/presentation/git_changes_view.dart index d41bd59..ba88e1f 100644 --- a/lib/src/git/presentation/git_changes_view.dart +++ b/lib/src/git/presentation/git_changes_view.dart @@ -154,24 +154,16 @@ class _CommitPanel extends StatelessWidget { style: busyMarkSectionHeaderStyle(context), ), const SizedBox(height: BusyMarkSpacing.sm), - DecoratedBox( - decoration: BoxDecoration( - color: colors.control, - borderRadius: BorderRadius.circular(BusyMarkRadius.md), - border: Border.all(color: colors.subtleBorder), - ), - child: TextField( - controller: controller, - minLines: 3, - maxLines: 5, - textInputAction: TextInputAction.newline, - decoration: const InputDecoration( - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.all(BusyMarkSpacing.sm), - ), - style: Theme.of(context).textTheme.bodyMedium, + TextField( + controller: controller, + minLines: 3, + maxLines: 5, + textInputAction: TextInputAction.newline, + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.all(BusyMarkSpacing.sm), ), + style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: BusyMarkSpacing.sm), Row( @@ -189,10 +181,9 @@ class _CommitPanel extends StatelessWidget { ), ), const SizedBox(width: BusyMarkSpacing.sm), - BusyMarkDialogButton( - label: context.l10n.gitCommit, - suggested: true, + BusyMarkPushButton.suggested( onPressed: canCommit ? () => onCommit() : null, + child: Text(context.l10n.gitCommit), ), ], ), diff --git a/lib/src/git/presentation/git_history_view.dart b/lib/src/git/presentation/git_history_view.dart index 9a92395..5ff6240 100644 --- a/lib/src/git/presentation/git_history_view.dart +++ b/lib/src/git/presentation/git_history_view.dart @@ -63,23 +63,9 @@ Future<_CommitFileAction?> _showCommitFileMenu( BuildContext context, Offset position, ) { - final navigator = Navigator.of(context, rootNavigator: true); - final overlay = navigator.overlay?.context.findRenderObject(); - if (overlay is! RenderBox) { - return Future.value(null); - } - final theme = Theme.of(context); - final colors = BusyMarkSurfaceColors.of(context); - final popupTheme = theme.popupMenuTheme; - return showMenu<_CommitFileAction>( - context: context, - useRootNavigator: true, - position: RelativeRect.fromLTRB( - position.dx, - position.dy, - overlay.size.width - position.dx, - overlay.size.height - position.dy, - ), + return showBusyMarkContextMenu<_CommitFileAction>( + context, + position, items: [ BusyMarkPopupMenuItem( value: _CommitFileAction.showDiff, @@ -87,13 +73,6 @@ Future<_CommitFileAction?> _showCommitFileMenu( icon: BusyMarkGlyphs.preview, ), ], - color: popupTheme.color ?? colors.popover, - surfaceTintColor: BusyMarkLinuxPalette.transparent, - elevation: BusyMarkElevation.popover, - shadowColor: colors.shade, - constraints: const BoxConstraints.tightFor( - width: BusyMarkSizes.popupMenuMinWidth, - ), ); } diff --git a/lib/src/git/presentation/git_sidebar_tab.dart b/lib/src/git/presentation/git_sidebar_tab.dart index 39a9c76..73e51df 100644 --- a/lib/src/git/presentation/git_sidebar_tab.dart +++ b/lib/src/git/presentation/git_sidebar_tab.dart @@ -118,37 +118,12 @@ class _GitMessage extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return DecoratedBox( - decoration: BoxDecoration( - color: colors.admonitionWarning, - border: Border(bottom: BorderSide(color: colors.subtleBorder)), - ), - child: Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(BusyMarkGlyphs.warning, size: BusyMarkSizes.iconSm), - const SizedBox(width: BusyMarkSpacing.sm), - Expanded(child: Text(_failureMessage(context, failure))), - ], - ), - if (failure.rawMessage.trim().isNotEmpty) ...[ - const SizedBox(height: BusyMarkSpacing.xs), - SelectableText( - failure.rawMessage.trim(), - textDirection: TextDirection.ltr, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: BusyMarkTypography.monoFontFamily, - fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, - ), - ), - ], - ], - ), + final message = _failureMessage(context, failure); + final rawMessage = failure.rawMessage.trim(); + return SelectionArea( + child: BusyMarkStatusBox( + message: rawMessage.isEmpty ? message : '$message\n$rawMessage', + kind: _gitFailureStatusKind(failure.code), ), ); } @@ -184,20 +159,34 @@ class _GitOperationMessage extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return DecoratedBox( - decoration: BoxDecoration( - color: colors.admonitionTip, - border: Border(bottom: BorderSide(color: colors.subtleBorder)), - ), - child: Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.sm), - child: Text(message, maxLines: 4, overflow: TextOverflow.ellipsis), - ), + return BusyMarkStatusBox( + message: message, + kind: BusyMarkStatusKind.success, ); } } +BusyMarkStatusKind _gitFailureStatusKind(GitFailureCode code) { + return switch (code) { + GitFailureCode.noStagedFiles || + GitFailureCode.noRemote || + GitFailureCode.noUpstream || + GitFailureCode.multipleRemotes => BusyMarkStatusKind.information, + GitFailureCode.dirtyWorkspace || + GitFailureCode.diverged || + GitFailureCode.conflict => BusyMarkStatusKind.warning, + GitFailureCode.unavailable || + GitFailureCode.unsupportedVersion || + GitFailureCode.notRepository || + GitFailureCode.invalidPath || + GitFailureCode.invalidBranchName || + GitFailureCode.invalidCommitMessage || + GitFailureCode.authentication || + GitFailureCode.network || + GitFailureCode.commandFailed => BusyMarkStatusKind.error, + }; +} + class _GitEmptyState extends StatelessWidget { const _GitEmptyState({ required this.icon, @@ -239,7 +228,10 @@ class _GitEmptyState extends StatelessWidget { ), if (actionLabel != null) ...[ const SizedBox(height: BusyMarkSpacing.md), - FilledButton(onPressed: onAction, child: Text(actionLabel!)), + BusyMarkPushButton.standard( + onPressed: onAction, + child: Text(actionLabel!), + ), ], ], ), diff --git a/lib/src/markdown/document_outline.dart b/lib/src/markdown/document_outline.dart new file mode 100644 index 0000000..f50242b --- /dev/null +++ b/lib/src/markdown/document_outline.dart @@ -0,0 +1,78 @@ +import '../core/path_utils.dart'; +import 'busymark_document.dart'; +import 'markdown_model.dart'; + +/// One heading in the active document's outline. +/// +/// Parsed previews provide source coordinates, while a live WYSIWYG document +/// additionally provides [editorBlockId]. Newly inserted editor blocks may not +/// have source coordinates until the serialized Markdown is parsed. +class DocumentOutlineHeading { + const DocumentOutlineHeading({ + required this.level, + required this.text, + required this.id, + this.sourceStartLine, + this.sourceStartOffset, + this.editorBlockId, + }); + + factory DocumentOutlineHeading.fromMarkdown(MarkdownHeading heading) { + return DocumentOutlineHeading( + level: heading.level, + text: heading.text, + id: heading.id, + sourceStartLine: heading.span.startLine, + sourceStartOffset: heading.span.startOffset, + ); + } + + final int level; + final String text; + final String id; + final int? sourceStartLine; + final int? sourceStartOffset; + final String? editorBlockId; +} + +/// Projects the editable, top-level headings that serialize into the outline. +/// +/// Nested headings (for example inside a blockquote) are deliberately omitted: +/// the Markdown outline scanner does not expose them either. Generated IDs are +/// recalculated from live semantic text so unsaved WYSIWYG renames and duplicate +/// suffixes stay aligned with the parser without mutating stable editor IDs. +extension BusyDocumentOutline on BusyDocument { + List get outline { + final headings = []; + final generatedIdOccurrences = {}; + + for (final block in blocks) { + if (block.kind != BusyBlockKind.heading) { + continue; + } + final generated = block.attributes['generatedId'] != 'false'; + final id = generated + ? nextGeneratedHeadingId( + slugForHeading(block.plainText), + generatedIdOccurrences, + ) + : block.attributes['id'] ?? block.id; + final level = (int.tryParse(block.attributes['level'] ?? '') ?? 1) + .clamp(1, 6) + .toInt(); + final sourceSpan = block.sourceSpan; + headings.add( + DocumentOutlineHeading( + level: level, + text: block.plainText, + id: id, + sourceStartLine: sourceSpan?.startLine, + sourceStartOffset: sourceSpan?.startOffset, + editorBlockId: block.id, + ), + ); + } + + return List.unmodifiable(headings); + } +} diff --git a/lib/src/markdown/markdown_ast_adapter.dart b/lib/src/markdown/markdown_ast_adapter.dart index d7cebd5..3604dfb 100644 --- a/lib/src/markdown/markdown_ast_adapter.dart +++ b/lib/src/markdown/markdown_ast_adapter.dart @@ -169,15 +169,15 @@ class MarkdownAstAdapter { final rawText = node.textContent.trim(); final attrId = _attributeValue(rawText, 'id'); final text = _stripTrailingAttributeBlock(rawText); - final id = attrId ?? slugForHeading(text); + final anchorId = attrId ?? slugForHeading(text); return [ BusyBlock( - id: id.isEmpty ? nextId() : id, + id: nextId(), kind: BusyBlockKind.heading, inlines: _stripTrailingAttributeInline(_inlinesFromNodes(children)), attributes: { 'level': '$level', - 'id': id, + 'id': anchorId, 'generatedId': '${attrId == null}', }, ), diff --git a/lib/src/markdown/markdown_parser.dart b/lib/src/markdown/markdown_parser.dart index b3f23c3..8004278 100644 --- a/lib/src/markdown/markdown_parser.dart +++ b/lib/src/markdown/markdown_parser.dart @@ -66,12 +66,9 @@ class MarkdownParser { final codeBlocks = []; final xmlBlocks = []; final variables = []; - final ids = {}; - final generatedIds = {}; + const astAdapter = MarkdownAstAdapter(); var title = _frontMatterTitle(filePath, source, diagnostics); - void addScannedHeading({ - required int level, - required String rawText, + void inspectScannedHeadingAttributes({ required String? attrText, required int startOffset, required int endOffset, @@ -90,58 +87,12 @@ class MarkdownParser { sourceSpan: SourceSpan.fromOffsets( filePath: filePath, source: source, - startOffset: startOffset + rawText.length, + startOffset: startOffset, endOffset: endOffset, ), ), ); } - final text = _stripTrailingAttributeBlock(rawText).trim(); - final baseId = explicitId ?? slugForHeading(text); - final generated = explicitId == null; - final id = generated ? _deduplicatedId(baseId, generatedIds) : baseId; - final span = SourceSpan.fromOffsets( - filePath: filePath, - source: source, - startOffset: startOffset, - endOffset: endOffset, - ); - headings.add( - MarkdownHeading( - level: level, - text: text, - id: id, - generatedId: generated, - span: span, - ), - ); - title ??= level == 1 ? text : null; - if (ids.containsKey(id)) { - diagnostics.add( - Diagnostic( - code: 'markdown.heading.duplicate-id', - severity: DiagnosticSeverity.warning, - filePath: filePath, - args: {'id': id}, - sourceSpan: span, - relatedSpans: [ids[id]!], - ), - ); - } else { - ids[id] = span; - } - if (mode == MarkdownMode.writersideMarkdown && - level == 1 && - headings.where((item) => item.level == 1).length > 1) { - diagnostics.add( - Diagnostic( - code: 'writerside.topic.h1-converted-to-chapter', - severity: DiagnosticSeverity.warning, - filePath: filePath, - sourceSpan: span, - ), - ); - } } MarkdownFence? openFence; @@ -218,29 +169,27 @@ class MarkdownParser { } final inRawHtmlBlock = offset < rawHtmlBlockEndOffset; final heading = RegExp( - r'^(#{1,6})\s+(.+?)\s*(\{[^}]+\})?\s*$', + r'^[ ]{0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$', ).firstMatch(inRawHtmlBlock ? '' : trimmed); if (heading != null) { - addScannedHeading( - level: heading.group(1)!.length, - rawText: heading.group(2)!.trim(), - attrText: heading.group(3), + final headingText = (heading.group(2) ?? '').replaceFirst( + RegExp(r'[ \t]+#+[ \t]*$'), + '', + ); + final attributeMatch = RegExp( + r'(\{[^}]+\})[ \t]*$', + ).firstMatch(headingText); + inspectScannedHeadingAttributes( + attrText: attributeMatch?.group(1), startOffset: offset, endOffset: offset + line.length, ); previousSetextCandidateLine = null; previousSetextCandidateOffset = null; - } else if (_setextUnderlineLevel(trimmed) case final level? - when !inRawHtmlBlock && - previousSetextCandidateLine != null && - previousSetextCandidateOffset != null) { - addScannedHeading( - level: level, - rawText: previousSetextCandidateLine.trim(), - attrText: null, - startOffset: previousSetextCandidateOffset, - endOffset: offset + line.length, - ); + } else if (_setextUnderlineLevel(trimmed) != null && + !inRawHtmlBlock && + previousSetextCandidateLine != null && + previousSetextCandidateOffset != null) { previousSetextCandidateLine = null; previousSetextCandidateOffset = null; } @@ -285,6 +234,26 @@ class MarkdownParser { lineIndex += 1; } + final renderedDocument = astAdapter.parse( + filePath: filePath, + source: source, + mode: mode, + title: title, + ); + var busyDocument = _withScannedSourceMetadata( + renderedDocument, + sortDiagnostics(diagnostics), + ); + final canonicalHeadings = _canonicalizeHeadings( + document: busyDocument, + filePath: filePath, + mode: mode, + initialTitle: title, + ); + busyDocument = canonicalHeadings.document; + headings.addAll(canonicalHeadings.headings); + diagnostics.addAll(canonicalHeadings.diagnostics); + title = canonicalHeadings.title; if (mode == MarkdownMode.writersideMarkdown && title == null) { diagnostics.add( Diagnostic( @@ -300,18 +269,6 @@ class MarkdownParser { ), ); } - - final renderedDocument = const MarkdownAstAdapter().parse( - filePath: filePath, - source: source, - mode: mode, - title: title, - ); - var busyDocument = _withScannedMetadata( - renderedDocument, - headings, - sortDiagnostics(diagnostics), - ); final inlineReferences = _extractAstInlineReferences( filePath: filePath, source: source, @@ -352,12 +309,10 @@ class MarkdownParser { ); } - BusyDocument _withScannedMetadata( + BusyDocument _withScannedSourceMetadata( BusyDocument document, - List headings, List diagnostics, ) { - var headingIndex = 0; final sourceChunks = document.source == null ? const <_ScannedBlockSource>[] : _scannedBlockSources(document.filePath, document.source!); @@ -377,12 +332,7 @@ class MarkdownParser { if (!canAssignSource) { final contentWithMetadata = [ for (final block in contentBlocks) - _blockWithScannedMetadata( - block, - headings, - null, - headingIndexRef: () => headingIndex++, - ), + _blockWithScannedSourceMetadata(block, null), ]; if (sourceChunks.any((chunk) => chunk.protectEdits)) { final source = document.source!; @@ -435,17 +385,102 @@ class MarkdownParser { if (chunk.sourceOnly) _sourceOnlyBlock(chunk) else - _blockWithScannedMetadata( - contentBlocks[blockIndex++], - headings, - chunk, - headingIndexRef: () => headingIndex++, - ), + _blockWithScannedSourceMetadata(contentBlocks[blockIndex++], chunk), ...generatedBlocks, ], ); } + _CanonicalHeadingProjection _canonicalizeHeadings({ + required BusyDocument document, + required String filePath, + required MarkdownMode mode, + required String? initialTitle, + }) { + final headings = []; + final diagnostics = []; + final generatedIdOccurrences = {}; + final firstSpansById = {}; + var title = initialTitle; + var levelOneCount = 0; + final blocks = []; + + for (final block in document.blocks) { + if (block.kind != BusyBlockKind.heading) { + blocks.add(block); + continue; + } + final level = (int.tryParse(block.attributes['level'] ?? '') ?? 1) + .clamp(1, 6) + .toInt(); + final generated = block.attributes['generatedId'] != 'false'; + final id = generated + ? nextGeneratedHeadingId( + slugForHeading(block.plainText), + generatedIdOccurrences, + ) + : block.attributes['id'] ?? block.id; + blocks.add( + block.copyWith( + attributes: { + ...block.attributes, + 'id': id, + 'level': '$level', + 'generatedId': '$generated', + }, + ), + ); + final span = block.sourceSpan; + if (span == null) { + continue; + } + final heading = MarkdownHeading( + level: level, + text: block.plainText, + id: id, + generatedId: generated, + span: span, + ); + headings.add(heading); + if (level == 1) { + title ??= heading.text; + levelOneCount += 1; + if (mode == MarkdownMode.writersideMarkdown && levelOneCount > 1) { + diagnostics.add( + Diagnostic( + code: 'writerside.topic.h1-converted-to-chapter', + severity: DiagnosticSeverity.warning, + filePath: filePath, + sourceSpan: span, + ), + ); + } + } + final firstSpan = firstSpansById[id]; + if (firstSpan == null) { + firstSpansById[id] = span; + } else { + diagnostics.add( + Diagnostic( + code: 'markdown.heading.duplicate-id', + severity: DiagnosticSeverity.warning, + filePath: filePath, + args: {'id': id}, + sourceSpan: span, + relatedSpans: [firstSpan], + ), + ); + } + } + + return _CanonicalHeadingProjection( + document: document.copyWith(title: title, blocks: blocks), + headings: List.unmodifiable(headings), + diagnostics: List.unmodifiable(diagnostics), + title: title, + ); + } + ParsedMarkdownDocument _withDiagnostics( ParsedMarkdownDocument parsed, List diagnostics, @@ -532,19 +567,11 @@ class MarkdownParser { return _AstInlineReferences(links: links, images: images); } - BusyBlock _blockWithScannedMetadata( + BusyBlock _blockWithScannedSourceMetadata( BusyBlock block, - List headings, - _ScannedBlockSource? sourceChunk, { - required int Function() headingIndexRef, - }) { + _ScannedBlockSource? sourceChunk, + ) { var updated = block; - if (updated.kind == BusyBlockKind.heading) { - final headingIndex = headingIndexRef(); - if (headingIndex < headings.length) { - updated = _headingWithScannedMetadata(updated, headings[headingIndex]); - } - } if (updated.kind != BusyBlockKind.frontMatter && sourceChunk != null) { updated = updated.copyWith( rawSource: sourceChunk.rawSource, @@ -579,22 +606,6 @@ class MarkdownParser { ); } - BusyBlock _headingWithScannedMetadata( - BusyBlock block, - MarkdownHeading heading, - ) { - return block.copyWith( - id: heading.id, - attributes: { - ...block.attributes, - 'id': heading.id, - 'level': '${heading.level}', - 'generatedId': '${heading.generatedId}', - }, - sourceSpan: heading.span, - ); - } - List<_ScannedBlockSource> _scannedBlockSources( String filePath, String source, @@ -937,7 +948,7 @@ class MarkdownParser { } bool _isAtxHeading(String line) { - return RegExp(r'^\s{0,3}#{1,6}\s+').hasMatch(line); + return RegExp(r'^[ ]{0,3}#{1,6}(?:[ \t]+|$)').hasMatch(line); } bool _isBlockquoteStart(String line) { @@ -1068,10 +1079,6 @@ class MarkdownParser { return match?.group(1); } - String _stripTrailingAttributeBlock(String value) { - return value.replaceFirst(RegExp(r'\s*\{[^}]+\}\s*$'), '').trimRight(); - } - int? _setextUnderlineLevel(String trimmedLine) { if (RegExp(r'^=+\s*$').hasMatch(trimmedLine)) { return 1; @@ -1086,7 +1093,7 @@ class MarkdownParser { if (trimmedLine.isEmpty) { return false; } - if (RegExp(r'^(#{1,6})\s+').hasMatch(trimmedLine)) { + if (RegExp(r'^#{1,6}(?:[ \t]+|$)').hasMatch(trimmedLine)) { return false; } if (RegExp(r'^([-+*]|\d+[.)])\s+').hasMatch(trimmedLine)) { @@ -1098,13 +1105,6 @@ class MarkdownParser { return _setextUnderlineLevel(trimmedLine) == null; } - String _deduplicatedId(String baseId, Map counts) { - final base = baseId.isEmpty ? 'section' : baseId; - final count = counts[base] ?? 0; - counts[base] = count + 1; - return count == 0 ? base : '$base-$count'; - } - void _extractVariableTokens({ required String filePath, required String source, @@ -1586,6 +1586,20 @@ class _AstInlineReferences { final List images; } +class _CanonicalHeadingProjection { + const _CanonicalHeadingProjection({ + required this.document, + required this.headings, + required this.diagnostics, + required this.title, + }); + + final BusyDocument document; + final List headings; + final List diagnostics; + final String? title; +} + String _decodeLocalReferencePath(String value) { try { return Uri.decodeComponent(value); diff --git a/lib/src/markdown/preview_model.dart b/lib/src/markdown/preview_model.dart index a3a1481..e559977 100644 --- a/lib/src/markdown/preview_model.dart +++ b/lib/src/markdown/preview_model.dart @@ -1,6 +1,7 @@ import '../core/source_span.dart'; import '../core/uri_utils.dart'; import 'busymark_document.dart'; +import 'document_outline.dart'; import 'markdown_model.dart'; enum PreviewBlockKind { @@ -88,6 +89,43 @@ class PreviewDocument { final List blocks; } +extension PreviewDocumentOutline on PreviewDocument { + List get outline { + final headings = []; + + void collect(List blocks) { + for (final block in blocks) { + if (block.kind == PreviewBlockKind.heading) { + final level = block.level; + final id = block.attributes['id']; + final sourceStartLine = block.sourceStartLine; + final sourceStartOffset = block.sourceStartOffset; + if (level != null && + id != null && + id.isNotEmpty && + sourceStartLine != null && + sourceStartOffset != null) { + headings.add( + DocumentOutlineHeading( + level: level, + text: block.text, + id: id, + sourceStartLine: sourceStartLine, + sourceStartOffset: sourceStartOffset, + editorBlockId: block.attributes['editorBlockId'], + ), + ); + } + } + collect(block.children); + } + } + + collect(blocks); + return List.unmodifiable(headings); + } +} + class MarkdownPreviewBuilder { const MarkdownPreviewBuilder(); @@ -131,6 +169,7 @@ class BusyMarkPreviewBuilder { attributes: { ...block.attributes, if (block.attributes['id'] case final id?) 'id': id, + 'editorBlockId': block.id, }, ), BusyBlockKind.paragraph => PreviewBlock( diff --git a/lib/src/markdown/raw_html_adapter.dart b/lib/src/markdown/raw_html_adapter.dart index cabf6de..3949f0f 100644 --- a/lib/src/markdown/raw_html_adapter.dart +++ b/lib/src/markdown/raw_html_adapter.dart @@ -174,16 +174,16 @@ class RawHtmlAdapter { final text = _plainTextFromNodes(children).trim(); if (_headingLevel(tag) case final level?) { - final id = attributes['id'] ?? slugForHeading(text); + final anchorId = attributes['id'] ?? slugForHeading(text); return _applyHtmlDirection([ BusyBlock( - id: id.isEmpty ? nextId() : id, + id: nextId(), kind: BusyBlockKind.heading, inlines: _trimInlineEdges(_inlinesFromNodes(children)), attributes: { ...attributes, 'level': '$level', - 'id': id, + 'id': anchorId, 'generatedId': '${attributes['id'] == null}', }, ), diff --git a/lib/src/platform/header_bar_configuration.dart b/lib/src/platform/header_bar_configuration.dart new file mode 100644 index 0000000..b13b0f9 --- /dev/null +++ b/lib/src/platform/header_bar_configuration.dart @@ -0,0 +1,546 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../app/busymark_design.dart'; + +enum AppViewMode { editor, source, preview, split } + +@immutable +class HeaderBarLabels { + const HeaderBarLabels({ + required this.editor, + required this.source, + required this.preview, + required this.split, + required this.viewMode, + required this.editorShortcut, + required this.sourceShortcut, + required this.previewShortcut, + required this.splitShortcut, + required this.search, + required this.refresh, + required this.menu, + required this.sidebar, + required this.sidebarShortcut, + required this.back, + required this.save, + required this.settings, + required this.settingsShortcut, + required this.keyboardShortcuts, + required this.keyboardShortcutsShortcut, + required this.markdownAndHtml, + required this.markdownAndHtmlShortcut, + required this.reportIssue, + required this.aboutBusyMark, + }); + + final String editor; + final String source; + final String preview; + final String split; + final String viewMode; + final String editorShortcut; + final String sourceShortcut; + final String previewShortcut; + final String splitShortcut; + final String search; + final String refresh; + final String menu; + final String sidebar; + final String sidebarShortcut; + final String back; + final String save; + final String settings; + final String settingsShortcut; + final String keyboardShortcuts; + final String keyboardShortcutsShortcut; + final String markdownAndHtml; + final String markdownAndHtmlShortcut; + final String reportIssue; + final String aboutBusyMark; + + Map toMap() => { + 'editor': editor, + 'source': source, + 'preview': preview, + 'split': split, + 'viewMode': viewMode, + 'editorShortcut': editorShortcut, + 'sourceShortcut': sourceShortcut, + 'previewShortcut': previewShortcut, + 'splitShortcut': splitShortcut, + 'search': search, + 'refresh': refresh, + 'menu': menu, + 'sidebar': sidebar, + 'sidebarShortcut': sidebarShortcut, + 'back': back, + 'save': save, + 'settings': settings, + 'settingsShortcut': settingsShortcut, + 'keyboardShortcuts': keyboardShortcuts, + 'keyboardShortcutsShortcut': keyboardShortcutsShortcut, + 'markdownAndHtml': markdownAndHtml, + 'markdownAndHtmlShortcut': markdownAndHtmlShortcut, + 'reportIssue': reportIssue, + 'aboutBusyMark': aboutBusyMark, + }; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is HeaderBarLabels && mapEquals(toMap(), other.toMap()); + } + + @override + int get hashCode => Object.hashAll(toMap().entries); +} + +@immutable +class HeaderBarTheme { + const HeaderBarTheme({ + required this.preferDark, + required this.backgroundColor, + required this.sidebarBackgroundColor, + required this.foregroundColor, + required this.popoverBackgroundColor, + required this.borderColor, + required this.sidebarBorderColor, + required this.floatingBorderColor, + required this.modalBarrierColor, + }); + + factory HeaderBarTheme.fromContext(BuildContext context) { + final colors = BusyMarkSurfaceColors.of(context); + final barrier = Theme.of( + context, + ).colorScheme.scrim.withValues(alpha: BusyMarkAlpha.modalBarrier); + return HeaderBarTheme( + preferDark: Theme.of(context).brightness == Brightness.dark, + backgroundColor: colors.view, + sidebarBackgroundColor: colors.sidebar, + foregroundColor: colors.foreground, + popoverBackgroundColor: colors.popover, + borderColor: colors.subtleBorder, + sidebarBorderColor: colors.sidebarBorder, + floatingBorderColor: colors.floatingBorder, + modalBarrierColor: barrier, + ); + } + + final bool preferDark; + final Color backgroundColor; + final Color sidebarBackgroundColor; + final Color foregroundColor; + final Color popoverBackgroundColor; + final Color borderColor; + final Color sidebarBorderColor; + final Color floatingBorderColor; + final Color modalBarrierColor; + + Map toMap() => { + 'preferDark': preferDark, + 'backgroundColor': _cssColor(backgroundColor), + 'sidebarBackgroundColor': _cssColor(sidebarBackgroundColor), + 'foregroundColor': _cssColor(foregroundColor), + 'popoverBackgroundColor': _cssColor(popoverBackgroundColor), + 'borderColor': _cssColor(borderColor), + 'sidebarBorderColor': _cssColor(sidebarBorderColor), + 'floatingBorderColor': _cssColor(floatingBorderColor), + 'modalBarrierColor': _cssColor(modalBarrierColor), + }; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is HeaderBarTheme && + preferDark == other.preferDark && + backgroundColor == other.backgroundColor && + sidebarBackgroundColor == other.sidebarBackgroundColor && + foregroundColor == other.foregroundColor && + popoverBackgroundColor == other.popoverBackgroundColor && + borderColor == other.borderColor && + sidebarBorderColor == other.sidebarBorderColor && + floatingBorderColor == other.floatingBorderColor && + modalBarrierColor == other.modalBarrierColor; + } + + @override + int get hashCode => Object.hashAll([ + preferDark, + backgroundColor, + sidebarBackgroundColor, + foregroundColor, + popoverBackgroundColor, + borderColor, + sidebarBorderColor, + floatingBorderColor, + modalBarrierColor, + ]); +} + +@immutable +class HeaderBarConfiguration { + const HeaderBarConfiguration({ + this.revision = 0, + required this.title, + required this.viewMode, + required this.searchQuery, + required this.textDirection, + required this.canRefresh, + required this.documentControlsVisible, + required this.searchActive, + required this.searchVisible, + required this.sidebarVisible, + required this.sidebarToggleVisible, + required this.backVisible, + required this.modalBarrierVisible, + required this.sidebarWidth, + required this.labels, + required this.theme, + }) : assert(revision >= 0); + + final int revision; + final String title; + final AppViewMode viewMode; + final String searchQuery; + final TextDirection textDirection; + final bool canRefresh; + final bool documentControlsVisible; + final bool searchActive; + final bool searchVisible; + final bool sidebarVisible; + final bool sidebarToggleVisible; + final bool backVisible; + final bool modalBarrierVisible; + final double sidebarWidth; + final HeaderBarLabels labels; + final HeaderBarTheme theme; + + HeaderBarConfiguration copyWith({ + int? revision, + String? title, + AppViewMode? viewMode, + String? searchQuery, + TextDirection? textDirection, + bool? canRefresh, + bool? documentControlsVisible, + bool? searchActive, + bool? searchVisible, + bool? sidebarVisible, + bool? sidebarToggleVisible, + bool? backVisible, + bool? modalBarrierVisible, + double? sidebarWidth, + HeaderBarLabels? labels, + HeaderBarTheme? theme, + }) { + return HeaderBarConfiguration( + revision: revision ?? this.revision, + title: title ?? this.title, + viewMode: viewMode ?? this.viewMode, + searchQuery: searchQuery ?? this.searchQuery, + textDirection: textDirection ?? this.textDirection, + canRefresh: canRefresh ?? this.canRefresh, + documentControlsVisible: + documentControlsVisible ?? this.documentControlsVisible, + searchActive: searchActive ?? this.searchActive, + searchVisible: searchVisible ?? this.searchVisible, + sidebarVisible: sidebarVisible ?? this.sidebarVisible, + sidebarToggleVisible: sidebarToggleVisible ?? this.sidebarToggleVisible, + backVisible: backVisible ?? this.backVisible, + modalBarrierVisible: modalBarrierVisible ?? this.modalBarrierVisible, + sidebarWidth: sidebarWidth ?? this.sidebarWidth, + labels: labels ?? this.labels, + theme: theme ?? this.theme, + ); + } + + Map toMap() => { + 'revision': revision, + 'title': title, + 'viewMode': viewMode.name, + 'searchQuery': searchQuery, + 'textDirection': textDirection == TextDirection.rtl ? 'rtl' : 'ltr', + 'canRefresh': canRefresh, + 'documentControlsVisible': documentControlsVisible, + 'searchActive': searchActive, + 'searchVisible': searchVisible, + 'sidebarVisible': sidebarVisible, + 'sidebarToggleVisible': sidebarToggleVisible, + 'backVisible': backVisible, + 'modalBarrierVisible': modalBarrierVisible, + 'sidebarWidth': sidebarWidth, + 'labels': labels.toMap(), + 'theme': theme.toMap(), + }; + + bool hasSameContentAs(HeaderBarConfiguration other) { + return title == other.title && + viewMode == other.viewMode && + searchQuery == other.searchQuery && + textDirection == other.textDirection && + canRefresh == other.canRefresh && + documentControlsVisible == other.documentControlsVisible && + searchActive == other.searchActive && + searchVisible == other.searchVisible && + sidebarVisible == other.sidebarVisible && + sidebarToggleVisible == other.sidebarToggleVisible && + backVisible == other.backVisible && + modalBarrierVisible == other.modalBarrierVisible && + sidebarWidth == other.sidebarWidth && + labels == other.labels && + theme == other.theme; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is HeaderBarConfiguration && + revision == other.revision && + hasSameContentAs(other); + } + + @override + int get hashCode => Object.hashAll([ + revision, + title, + viewMode, + searchQuery, + textDirection, + canRefresh, + documentControlsVisible, + searchActive, + searchVisible, + sidebarVisible, + sidebarToggleVisible, + backVisible, + modalBarrierVisible, + sidebarWidth, + labels, + theme, + ]); +} + +typedef HeaderBarConfigurationApplier = + Future Function(HeaderBarConfiguration configuration); + +/// Serializes native header updates, coalesces pending state, and assigns the +/// monotonic revisions that make the native side latest-wins. +class HeaderBarConfigurationSynchronizer { + HeaderBarConfigurationSynchronizer({ + required HeaderBarConfigurationApplier apply, + }) : _apply = apply; + + final HeaderBarConfigurationApplier _apply; + final Map>> _waiters = {}; + + HeaderBarConfiguration? _baseConfiguration; + HeaderBarConfiguration? _desiredConfiguration; + HeaderBarConfiguration? _appliedConfiguration; + Future? _drainFuture; + var _lastRevision = 0; + var _modalBarrierVisible = false; + + HeaderBarConfiguration? get desiredConfiguration => _desiredConfiguration; + HeaderBarConfiguration? get appliedConfiguration => _appliedConfiguration; + int get latestRevision => _lastRevision; + + bool isLatestRevision(int revision) { + return _desiredConfiguration?.revision == revision; + } + + Future setConfiguration(HeaderBarConfiguration configuration) { + _baseConfiguration = configuration.copyWith(revision: 0); + return _enqueueEffectiveConfiguration(); + } + + Future setModalBarrierVisible(bool visible) { + if (_modalBarrierVisible == visible) { + return _waitForDesiredConfiguration(); + } + _modalBarrierVisible = visible; + return _enqueueEffectiveConfiguration(); + } + + Future _enqueueEffectiveConfiguration() { + final base = _baseConfiguration; + if (base == null) { + return Future.value(true); + } + final effective = base.copyWith( + revision: 0, + modalBarrierVisible: _modalBarrierVisible, + ); + final desired = _desiredConfiguration; + if (desired != null && desired.hasSameContentAs(effective)) { + if (_appliedConfiguration case final applied? + when applied.revision >= desired.revision) { + return Future.value(true); + } + final future = _waitForRevision(desired.revision); + _drainFuture ??= _drain(); + return future; + } + final applied = _appliedConfiguration; + if (_drainFuture == null && + applied != null && + applied.hasSameContentAs(effective)) { + return Future.value(true); + } + + final revision = ++_lastRevision; + _desiredConfiguration = effective.copyWith(revision: revision); + final future = _waitForRevision(revision); + _drainFuture ??= _drain(); + return future; + } + + Future _waitForDesiredConfiguration() { + final desired = _desiredConfiguration; + if (desired == null || + _appliedConfiguration?.revision == desired.revision) { + return Future.value(true); + } + final future = _waitForRevision(desired.revision); + _drainFuture ??= _drain(); + return future; + } + + Future _waitForRevision(int revision) { + if (_appliedConfiguration case final applied? + when applied.revision >= revision) { + return Future.value(true); + } + final completer = Completer(); + _waiters.putIfAbsent(revision, () => []).add(completer); + return completer.future; + } + + Future _drain() async { + try { + while (true) { + final target = _desiredConfiguration; + if (target == null || + _appliedConfiguration?.revision == target.revision) { + return; + } + var succeeded = false; + try { + succeeded = await _apply(target); + } on Object { + succeeded = false; + } + if (succeeded) { + _appliedConfiguration = target; + _completeWaitersThrough(target.revision, succeeded: true); + continue; + } + if (_desiredConfiguration?.revision != target.revision) { + continue; + } + _completeWaitersThrough(target.revision, succeeded: false); + return; + } + } finally { + _drainFuture = null; + } + } + + void _completeWaitersThrough(int revision, {required bool succeeded}) { + final completedRevisions = _waiters.keys + .where((candidate) => candidate <= revision) + .toList(growable: false); + for (final completedRevision in completedRevisions) { + final completers = _waiters.remove(completedRevision); + if (completers == null) { + continue; + } + for (final completer in completers) { + if (!completer.isCompleted) { + completer.complete(succeeded); + } + } + } + } +} + +class HeaderBarConfigurationDefaults extends InheritedWidget { + const HeaderBarConfigurationDefaults({ + super.key, + required this.configuration, + required super.child, + }); + + final HeaderBarConfiguration configuration; + + static HeaderBarConfiguration of(BuildContext context) { + final defaults = context + .dependOnInheritedWidgetOfExactType(); + assert(defaults != null, 'No HeaderBarConfigurationDefaults in context'); + return defaults!.configuration; + } + + @override + bool updateShouldNotify(HeaderBarConfigurationDefaults oldWidget) { + return configuration != oldWidget.configuration; + } +} + +class HeaderBarConfigurationPublisher extends StatefulWidget { + const HeaderBarConfigurationPublisher({ + super.key, + required this.synchronizer, + required this.configuration, + required this.enabled, + required this.child, + }); + + final HeaderBarConfigurationSynchronizer synchronizer; + final HeaderBarConfiguration configuration; + final bool enabled; + final Widget child; + + @override + State createState() => + _HeaderBarConfigurationPublisherState(); +} + +class _HeaderBarConfigurationPublisherState + extends State { + @override + void initState() { + super.initState(); + _publish(); + } + + @override + void didUpdateWidget(HeaderBarConfigurationPublisher oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.enabled != oldWidget.enabled || + widget.synchronizer != oldWidget.synchronizer || + widget.configuration != oldWidget.configuration) { + _publish(); + } + } + + @override + Widget build(BuildContext context) => widget.child; + + void _publish() { + if (!widget.enabled) { + return; + } + unawaited(widget.synchronizer.setConfiguration(widget.configuration)); + } +} + +String _cssColor(Color color) { + final alpha = (color.a).clamp(0.0, 1.0).toStringAsFixed(3); + return 'rgba(${(color.r * 255).round()},' + '${(color.g * 255).round()},' + '${(color.b * 255).round()},' + '$alpha)'; +} diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 8e77515..27c9225 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -1,11 +1,14 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; -import '../app/busymark_design.dart'; +import 'header_bar_configuration.dart'; + +export 'header_bar_configuration.dart'; enum HeaderBarAction { back, @@ -25,8 +28,6 @@ enum HeaderBarAction { viewModeSplit, } -enum AppViewMode { editor, source, preview, split } - class HeaderBarActionEvent { const HeaderBarActionEvent({required this.sequence, required this.action}); @@ -44,184 +45,71 @@ class HeaderBarActionEvent { int get hashCode => Object.hash(sequence, action); } -class HeaderBarLabels { - const HeaderBarLabels({ - required this.editor, - required this.source, - required this.preview, - required this.split, - required this.viewMode, - required this.editorShortcut, - required this.sourceShortcut, - required this.previewShortcut, - required this.splitShortcut, - required this.search, - required this.refresh, - required this.menu, - required this.sidebar, - required this.sidebarShortcut, - required this.back, - required this.save, - required this.settings, - required this.settingsShortcut, - required this.keyboardShortcuts, - required this.keyboardShortcutsShortcut, - required this.markdownAndHtml, - required this.markdownAndHtmlShortcut, - required this.reportIssue, - required this.aboutBusyMark, - }); - - final String editor; - final String source; - final String preview; - final String split; - final String viewMode; - final String editorShortcut; - final String sourceShortcut; - final String previewShortcut; - final String splitShortcut; - final String search; - final String refresh; - final String menu; - final String sidebar; - final String sidebarShortcut; - final String back; - final String save; - final String settings; - final String settingsShortcut; - final String keyboardShortcuts; - final String keyboardShortcutsShortcut; - final String markdownAndHtml; - final String markdownAndHtmlShortcut; - final String reportIssue; - final String aboutBusyMark; - - Map toMap() => { - 'editor': editor, - 'source': source, - 'preview': preview, - 'split': split, - 'viewMode': viewMode, - 'editorShortcut': editorShortcut, - 'sourceShortcut': sourceShortcut, - 'previewShortcut': previewShortcut, - 'splitShortcut': splitShortcut, - 'search': search, - 'refresh': refresh, - 'menu': menu, - 'sidebar': sidebar, - 'sidebarShortcut': sidebarShortcut, - 'back': back, - 'save': save, - 'settings': settings, - 'settingsShortcut': settingsShortcut, - 'keyboardShortcuts': keyboardShortcuts, - 'keyboardShortcutsShortcut': keyboardShortcutsShortcut, - 'markdownAndHtml': markdownAndHtml, - 'markdownAndHtmlShortcut': markdownAndHtmlShortcut, - 'reportIssue': reportIssue, - 'aboutBusyMark': aboutBusyMark, - }; +sealed class HeaderBarSearchEvent { + const HeaderBarSearchEvent(); } -class HeaderBarTheme { - const HeaderBarTheme({ - required this.preferDark, - required this.backgroundColor, - required this.sidebarBackgroundColor, - required this.foregroundColor, - required this.mutedForegroundColor, - required this.disabledForegroundColor, - required this.controlColor, - required this.controlHoverColor, - required this.accentColor, - required this.accentForegroundColor, - required this.popoverBackgroundColor, - required this.borderColor, - required this.shadeColor, - required this.modalBarrierColor, - }); - - factory HeaderBarTheme.fromContext(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - final barrier = Theme.of( - context, - ).colorScheme.scrim.withValues(alpha: BusyMarkAlpha.modalBarrier); - return HeaderBarTheme( - preferDark: Theme.of(context).brightness == Brightness.dark, - backgroundColor: colors.view, - sidebarBackgroundColor: colors.sidebar, - foregroundColor: colors.foreground, - mutedForegroundColor: colors.mutedForeground, - disabledForegroundColor: colors.disabledForeground, - controlColor: colors.control, - controlHoverColor: colors.controlHover, - accentColor: Theme.of(context).colorScheme.primary, - accentForegroundColor: Theme.of(context).colorScheme.onPrimary, - popoverBackgroundColor: colors.popover, - borderColor: colors.subtleBorder, - shadeColor: colors.shade, - modalBarrierColor: barrier, - ); - } +class HeaderBarSearchQueryChanged extends HeaderBarSearchEvent { + const HeaderBarSearchQueryChanged(this.query); + + final String query; +} + +class HeaderBarSearchSubmitted extends HeaderBarSearchEvent { + const HeaderBarSearchSubmitted(this.query); + + final String query; +} + +class HeaderBarSearchFocusChanged extends HeaderBarSearchEvent { + const HeaderBarSearchFocusChanged(this.focused); + + final bool focused; +} - final bool preferDark; - final Color backgroundColor; - final Color sidebarBackgroundColor; - final Color foregroundColor; - final Color mutedForegroundColor; - final Color disabledForegroundColor; - final Color controlColor; - final Color controlHoverColor; - final Color accentColor; - final Color accentForegroundColor; - final Color popoverBackgroundColor; - final Color borderColor; - final Color shadeColor; - final Color modalBarrierColor; - - Map toMap() => { - 'preferDark': preferDark, - 'backgroundColor': _cssColor(backgroundColor), - 'sidebarBackgroundColor': _cssColor(sidebarBackgroundColor), - 'foregroundColor': _cssColor(foregroundColor), - 'mutedForegroundColor': _cssColor(mutedForegroundColor), - 'disabledForegroundColor': _cssColor(disabledForegroundColor), - 'controlColor': _cssColor(controlColor), - 'controlHoverColor': _cssColor(controlHoverColor), - 'accentColor': _cssColor(accentColor), - 'accentForegroundColor': _cssColor(accentForegroundColor), - 'popoverBackgroundColor': _cssColor(popoverBackgroundColor), - 'borderColor': _cssColor(borderColor), - 'shadeColor': _cssColor(shadeColor), - 'modalBarrierColor': _cssColor(modalBarrierColor), - }; +class HeaderBarSearchCleared extends HeaderBarSearchEvent { + const HeaderBarSearchCleared(); } -class LinuxHeaderBarService { - LinuxHeaderBarService({MethodChannel? channel}) - : _channel = channel ?? const MethodChannel('com.busymark.app/headerbar') { +class HeaderBarSearchEscapePressed extends HeaderBarSearchEvent { + const HeaderBarSearchEscapePressed(); +} + +class LinuxHeaderBarService extends ChangeNotifier { + LinuxHeaderBarService({ + MethodChannel? channel, + @visibleForTesting String? sessionId, + }) : assert(sessionId == null || sessionId != ''), + _channel = channel ?? const MethodChannel('com.busymark.app/headerbar'), + _sessionId = sessionId ?? const Uuid().v4() { + configurationSynchronizer = HeaderBarConfigurationSynchronizer( + apply: _applyConfiguration, + ); _channel.setMethodCallHandler(_handleNativeAction); } static final LinuxHeaderBarService instance = LinuxHeaderBarService(); final MethodChannel _channel; + final String _sessionId; final _actions = StreamController.broadcast(); final _actionEvents = StreamController.broadcast(); - final _searchQueries = StreamController.broadcast(); + final _searchEvents = StreamController.broadcast(); + + late final HeaderBarConfigurationSynchronizer configurationSynchronizer; + var _initialized = false; var _channelReady = false; var _available = false; var _actionSequence = 0; + bool? _atomicConfigurationSupported; bool get isAvailable => _available; bool get usesNativeHeaderBar => _available; Stream get actions => _actions.stream; Stream get actionEvents => _actionEvents.stream; - Stream get searchQueries => _searchQueries.stream; + Stream get searchEvents => _searchEvents.stream; Future initialize() async { if (_channelReady || (_initialized && !Platform.isLinux)) { @@ -233,112 +121,232 @@ class LinuxHeaderBarService { } _initialized = true; try { - _available = await _channel.invokeMethod('initialize') ?? false; + final available = + await _channel.invokeMethod('initialize', { + 'sessionId': _sessionId, + }) ?? + false; _channelReady = true; + _setAvailable(available); } on MissingPluginException { - _initialized = false; - _channelReady = false; - _available = false; + _markUnavailable(); } on Object { - _initialized = false; - _channelReady = false; - _available = false; + _markUnavailable(); } } + /// Legacy setters remain available for an older Linux runner. Product + /// screens publish a complete [HeaderBarConfiguration] instead. Future setTitleRange(String value) { - return _invoke('setTitleRange', value); + return _invokeLegacy('setTitleRange', value); } Future setViewMode(AppViewMode mode) { - return _invoke('setViewMode', mode.name); + return _invokeLegacy('setViewMode', mode.name); } Future setCanRefresh(bool value) { - return _invoke('setCanRefresh', value); - } - - Future setCanSave(bool value) { - return _invoke('setCanSave', value); + return _invokeLegacy('setCanRefresh', value); } Future setDocumentControlsVisible(bool value) { - return _invoke('setDocumentControlsVisible', value); + return _invokeLegacy('setDocumentControlsVisible', value); } Future setSearchActive(bool value) { - return _invoke('setSearchActive', value); + return _invokeLegacy('setSearchActive', value); } Future setSearchVisible(bool value) { - return _invoke('setSearchVisible', value); + return _invokeLegacy('setSearchVisible', value); } Future setSearchQuery(String value) { - return _invoke('setSearchQuery', value); + return _invokeLegacy('setSearchQuery', value); + } + + Future focusSearch() async { + if (!_channelReady) { + await initialize(); + } + if (!_channelReady || !_available) { + return false; + } + try { + return await _channel.invokeMethod('focusSearch') ?? false; + } on MissingPluginException { + _markUnavailable(); + } on Object { + _markUnavailable(); + } + return false; } Future setSidebarVisible(bool value) { - return _invoke('setSidebarVisible', value); + return _invokeLegacy('setSidebarVisible', value); } Future setSidebarToggleVisible(bool value) { - return _invoke('setSidebarToggleVisible', value); + return _invokeLegacy('setSidebarToggleVisible', value); } Future setSidebarWidth(double value) { - return _invoke('setSidebarWidth', value); + return _invokeLegacy('setSidebarWidth', value); } Future setTextDirection(TextDirection value) { - return _invoke( + return _invokeLegacy( 'setTextDirection', value == TextDirection.rtl ? 'rtl' : 'ltr', ); } Future setBackVisible(bool value) { - return _invoke('setBackVisible', value); + return _invokeLegacy('setBackVisible', value); } Future setLocalizedLabels(HeaderBarLabels labels) { - return _invoke('setLocalizedLabels', labels.toMap()); + return _invokeLegacy('setLocalizedLabels', labels.toMap()); } Future setTheme(HeaderBarTheme theme) { - return _invoke('setTheme', theme.toMap()); + return _invokeLegacy('setTheme', theme.toMap()); } - Future setModalBarrierVisible(bool value) { - return _invoke('setModalBarrierVisible', value); + Future setModalBarrierVisible(bool value) async { + final hasPublishedConfiguration = + configurationSynchronizer.desiredConfiguration != null; + await configurationSynchronizer.setModalBarrierVisible(value); + if (!hasPublishedConfiguration) { + await _invokeLegacy('setModalBarrierVisible', value); + } } - Future _invoke( - String method, [ - Object? arguments, - bool requireHeaderBar = true, - ]) async { + Future _applyConfiguration(HeaderBarConfiguration configuration) async { if (!_channelReady) { await initialize(); } - if (!_channelReady || (requireHeaderBar && !_available)) { - return; + if (!_channelReady || !_available) { + return false; + } + if (_atomicConfigurationSupported == false) { + return _applyLegacyConfiguration(configuration); + } + try { + final appliedRevision = await _channel.invokeMethod( + 'applyConfiguration', + {'sessionId': _sessionId, ...configuration.toMap()}, + ); + if (appliedRevision != configuration.revision) { + _markUnavailable(); + return false; + } + _atomicConfigurationSupported = true; + return true; + } on MissingPluginException { + _atomicConfigurationSupported = false; + return _applyLegacyConfiguration(configuration); + } on PlatformException catch (error) { + if (error.code == 'not_implemented' || + error.code == 'unimplemented' || + error.code == 'method_not_found') { + _atomicConfigurationSupported = false; + return _applyLegacyConfiguration(configuration); + } + _markUnavailable(); + return false; + } on Object { + _markUnavailable(); + return false; + } + } + + Future _applyLegacyConfiguration( + HeaderBarConfiguration configuration, + ) async { + final updates = <(String, Object?)>[ + ('setTextDirection', configuration.textDirection.name), + ('setSidebarWidth', configuration.sidebarWidth), + ('setTheme', configuration.theme.toMap()), + ('setLocalizedLabels', configuration.labels.toMap()), + ('setTitleRange', configuration.title), + ('setViewMode', configuration.viewMode.name), + ('setCanRefresh', configuration.canRefresh), + ('setDocumentControlsVisible', configuration.documentControlsVisible), + ('setSearchVisible', configuration.searchVisible), + ('setSidebarVisible', configuration.sidebarVisible), + ('setSidebarToggleVisible', configuration.sidebarToggleVisible), + ('setBackVisible', configuration.backVisible), + ('setSearchQuery', configuration.searchQuery), + ('setSearchActive', configuration.searchActive), + ('setModalBarrierVisible', configuration.modalBarrierVisible), + ]; + for (final (method, arguments) in updates) { + if (!configurationSynchronizer.isLatestRevision(configuration.revision)) { + return false; + } + if (!await _tryInvokeLegacy(method, arguments)) { + return false; + } + } + return configurationSynchronizer.isLatestRevision(configuration.revision); + } + + Future _invokeLegacy(String method, [Object? arguments]) async { + await _tryInvokeLegacy(method, arguments); + } + + Future _tryInvokeLegacy(String method, [Object? arguments]) async { + if (!_channelReady) { + await initialize(); + } + if (!_channelReady || !_available) { + return false; } try { await _channel.invokeMethod(method, arguments); + return true; } on MissingPluginException { - _channelReady = false; - _available = false; + _markUnavailable(); + return false; } on Object { - // Native headerbar is a progressive Linux enhancement. Flutter fallback - // remains usable if the host shell rejects an update. + _markUnavailable(); + return false; } } + void _markUnavailable() { + _initialized = false; + _channelReady = false; + _setAvailable(false); + } + + void _setAvailable(bool value) { + if (_available == value) { + return; + } + _available = value; + notifyListeners(); + } + Future _handleNativeAction(MethodCall call) async { - if (call.method == 'searchQueryChanged') { - if (!_searchQueries.isClosed) { - _searchQueries.add((call.arguments as String?) ?? ''); + final searchEvent = switch ((call.method, call.arguments)) { + ('searchQueryChanged', final String query) => HeaderBarSearchQueryChanged( + query, + ), + ('searchSubmitted', final String query) => HeaderBarSearchSubmitted( + query, + ), + ('searchFocusChanged', final bool focused) => HeaderBarSearchFocusChanged( + focused, + ), + ('searchCleared', _) => const HeaderBarSearchCleared(), + ('searchEscapePressed', _) => const HeaderBarSearchEscapePressed(), + _ => null, + }; + if (searchEvent != null) { + if (!_searchEvents.isClosed) { + _searchEvents.add(searchEvent); } return; } @@ -378,22 +386,20 @@ class LinuxHeaderBarService { } } -final linuxHeaderBarServiceProvider = Provider( - (ref) => LinuxHeaderBarService.instance, -); +final linuxHeaderBarServiceProvider = Provider((ref) { + final service = LinuxHeaderBarService.instance; + void notifyConsumers() => ref.notifyListeners(); + service.addListener(notifyConsumers); + ref.onDispose(() => service.removeListener(notifyConsumers)); + return service; +}); final headerBarActionsProvider = StreamProvider((ref) { return ref.watch(linuxHeaderBarServiceProvider).actionEvents; }); -final headerBarSearchQueriesProvider = StreamProvider((ref) { - return ref.watch(linuxHeaderBarServiceProvider).searchQueries; +final headerBarSearchEventsProvider = StreamProvider(( + ref, +) { + return ref.watch(linuxHeaderBarServiceProvider).searchEvents; }); - -String _cssColor(Color color) { - final alpha = (color.a).clamp(0.0, 1.0).toStringAsFixed(3); - return 'rgba(${(color.r * 255).round()},' - '${(color.g * 255).round()},' - '${(color.b * 255).round()},' - '$alpha)'; -} diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index fa45d5c..8c4df96 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -1,10 +1,9 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_router.dart'; import '../../app/app_settings.dart'; import '../../app/busymark_dialogs.dart'; import '../../app/busymark_design.dart'; @@ -13,212 +12,204 @@ import '../../app/busymark_main_menu.dart'; import '../../app/localization.dart'; import '../../feedback/presentation/feedback_dialog.dart'; import '../../platform/linux_header_bar_service.dart'; -import '../workspace_controller.dart'; class SettingsScreen extends ConsumerWidget { - const SettingsScreen({super.key}); + const SettingsScreen({required this.returnTarget, super.key}); + + final SettingsReturnTarget returnTarget; @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final settings = ref.watch(appSettingsControllerProvider); final controller = ref.watch(appSettingsControllerProvider.notifier); - final workspaceOpen = - ref.watch(workspaceControllerProvider).workspace != null; final colors = BusyMarkSurfaceColors.of(context); final headerBar = ref.watch(linuxHeaderBarServiceProvider); final useNativeHeaderBar = headerBar.usesNativeHeaderBar; ref.listen(headerBarActionsProvider, (previous, next) { next.whenData((event) { - _handleHeaderBarAction(context, workspaceOpen, headerBar, event.action); + _handleHeaderBarAction(context, headerBar, event.action); }); }); - if (headerBar.isAvailable) { - _configureHeaderBar(context, headerBar); - } + final headerConfiguration = HeaderBarConfigurationDefaults.of(context) + .copyWith( + title: l10n.settingsTitle, + viewMode: AppViewMode.editor, + searchQuery: '', + canRefresh: false, + documentControlsVisible: false, + searchActive: false, + searchVisible: false, + sidebarVisible: false, + sidebarToggleVisible: false, + backVisible: true, + ); - return Scaffold( - backgroundColor: colors.view, - appBar: useNativeHeaderBar - ? null - : AppBar( - leadingWidth: 50, - titleSpacing: 0, - leading: Center( - child: BusyMarkHeaderIconButton( - tooltip: context.l10n.back, - icon: BusyMarkGlyphs.backFor(Directionality.of(context)), - onPressed: () => - context.go(workspaceOpen ? '/workspace' : '/'), + return HeaderBarConfigurationPublisher( + synchronizer: headerBar.configurationSynchronizer, + configuration: headerConfiguration, + enabled: headerBar.isAvailable, + child: Scaffold( + backgroundColor: colors.view, + appBar: useNativeHeaderBar + ? null + : AppBar( + leading: Center( + child: BusyMarkHeaderIconButton( + tooltip: context.l10n.back, + icon: BusyMarkGlyphs.backFor(Directionality.of(context)), + onPressed: () => context.go(returnTarget.location), + ), + ), + title: Text( + context.l10n.settingsTitle, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), ), + actions: [ + BusyMarkMainMenuButton( + onSelected: (action) => + _handleMainMenuAction(context, headerBar, action), + ), + const SizedBox(width: BusyMarkSpacing.sm), + ], ), - title: Text( - context.l10n.settingsTitle, - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + body: BusyMarkClamp( + maxWidth: BusyMarkSizes.settingsWidth, + margin: EdgeInsets.zero, + padding: BusyMarkInsets.settingsPage, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BusyMarkGroupedList( + title: context.l10n.appearance, + filled: true, + children: [ + _LanguageRow( + selectedLocaleTag: settings.localeTag, + onChanged: controller.setLocaleTag, + ), + _ThemeModeRow( + selected: settings.themeModePreference, + onChanged: controller.setThemeModePreference, + ), + ], ), - actions: [ - BusyMarkMainMenuButton( - onSelected: (action) => - _handleMainMenuAction(context, headerBar, action), - ), - const SizedBox(width: BusyMarkSpacing.sm), - ], - ), - body: BusyMarkClamp( - maxWidth: BusyMarkSizes.settingsWidth, - margin: EdgeInsets.zero, - padding: BusyMarkInsets.settingsPage, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - BusyMarkGroupedList( - title: context.l10n.appearance, - filled: true, - children: [ - _LanguageRow( - selectedLocaleTag: settings.localeTag, - onChanged: controller.setLocaleTag, - ), - _ThemeModeRow( - selected: settings.themeModePreference, - onChanged: controller.setThemeModePreference, - ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.editor, - filled: true, - children: [ - BusyMarkSwitchRow( - title: context.l10n.autoSave, - subtitle: context.l10n.autoSaveDescription, - value: settings.autoSave, - onChanged: controller.setAutoSave, - leading: const Icon(BusyMarkGlyphs.save), - ), - BusyMarkSwitchRow( - title: context.l10n.wordWrap, - value: settings.wordWrap, - onChanged: controller.setWordWrap, - leading: Icon( - BusyMarkGlyphs.wordWrapFor(Directionality.of(context)), + BusyMarkGroupedList( + title: context.l10n.editor, + filled: true, + children: [ + BusyMarkSwitchRow( + title: context.l10n.autoSave, + subtitle: context.l10n.autoSaveDescription, + value: settings.autoSave, + onChanged: controller.setAutoSave, + leading: const Icon(BusyMarkGlyphs.save), ), - ), - _EditorFontSizeRow( - value: settings.editorFontSize, - onChanged: controller.setEditorFontSize, - ), - _EditorToolbarPlacementRow( - selected: settings.editorToolbarPlacement, - onChanged: controller.setEditorToolbarPlacement, - ), - _EditorToolbarDirectionRow( - selected: settings.editorToolbarDirection, - onChanged: controller.setEditorToolbarDirection, - ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.validation, - filled: true, - children: [ - BusyMarkSwitchRow( - title: context.l10n.validateOnEdit, - value: settings.validateOnEdit, - onChanged: controller.setValidateOnEdit, - leading: const Icon(BusyMarkGlyphs.diagnostics), - ), - ], - ), - BusyMarkGroupedList( - title: l10n.settingsWindowSectionTitle, - filled: true, - children: [ - BusyMarkSwitchRow( - title: l10n.settingsConfirmCloseWithUnsavedChangesTitle, - subtitle: - l10n.settingsConfirmCloseWithUnsavedChangesDescription, - value: settings.confirmCloseWithUnsavedChanges, - onChanged: controller.setConfirmCloseWithUnsavedChanges, - leading: const Icon(BusyMarkGlyphs.warning), - ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.privacy, - filled: true, - children: [ - BusyMarkSwitchRow( - title: context.l10n.allowRemoteImages, - subtitle: context.l10n.allowRemoteImagesDescription, - value: settings.allowRemoteImages, - onChanged: controller.setAllowRemoteImages, - leading: const Icon(BusyMarkGlyphs.image), - ), - if (settings.remoteImageAllowedWorkspacePaths.isNotEmpty) - BusyMarkActionRow( - title: context.l10n.clearRemoteImagePermissions, + BusyMarkSwitchRow( + title: context.l10n.wordWrap, + value: settings.wordWrap, + onChanged: controller.setWordWrap, + leading: Icon( + BusyMarkGlyphs.wordWrapFor(Directionality.of(context)), + ), + ), + _EditorFontSizeRow( + value: settings.editorFontSize, + onChanged: controller.setEditorFontSize, + ), + _EditorToolbarPlacementRow( + selected: settings.editorToolbarPlacement, + onChanged: controller.setEditorToolbarPlacement, + ), + _EditorToolbarDirectionRow( + selected: settings.editorToolbarDirection, + onChanged: controller.setEditorToolbarDirection, + ), + ], + ), + BusyMarkGroupedList( + title: context.l10n.validation, + filled: true, + children: [ + BusyMarkSwitchRow( + title: context.l10n.validateOnEdit, + value: settings.validateOnEdit, + onChanged: controller.setValidateOnEdit, + leading: const Icon(BusyMarkGlyphs.diagnostics), + ), + ], + ), + BusyMarkGroupedList( + title: l10n.settingsWindowSectionTitle, + filled: true, + children: [ + BusyMarkSwitchRow( + title: l10n.settingsConfirmCloseWithUnsavedChangesTitle, subtitle: - context.l10n.clearRemoteImagePermissionsDescription, - leading: const Icon(BusyMarkGlyphs.clearAll), - onTap: controller.clearRemoteImageWorkspacePermissions, + l10n.settingsConfirmCloseWithUnsavedChangesDescription, + value: settings.confirmCloseWithUnsavedChanges, + onChanged: controller.setConfirmCloseWithUnsavedChanges, + leading: const Icon(BusyMarkGlyphs.warning), + ), + ], + ), + BusyMarkGroupedList( + title: context.l10n.privacy, + filled: true, + children: [ + BusyMarkSwitchRow( + title: context.l10n.allowRemoteImages, + subtitle: context.l10n.allowRemoteImagesDescription, + value: settings.allowRemoteImages, + onChanged: controller.setAllowRemoteImages, + leading: const Icon(BusyMarkGlyphs.image), ), - if (settings.trustedGitWorkspacePaths.isNotEmpty) + if (settings.remoteImageAllowedWorkspacePaths.isNotEmpty) + BusyMarkActionRow( + title: context.l10n.clearRemoteImagePermissions, + subtitle: + context.l10n.clearRemoteImagePermissionsDescription, + leading: const Icon(BusyMarkGlyphs.clearAll), + onTap: controller.clearRemoteImageWorkspacePermissions, + ), + if (settings.trustedGitWorkspacePaths.isNotEmpty) + BusyMarkActionRow( + title: context.l10n.clearGitWorkspaceTrust, + subtitle: context.l10n.clearGitWorkspaceTrustDescription, + leading: const Icon(BusyMarkGlyphs.clearAll), + onTap: controller.clearTrustedGitWorkspaces, + ), + ], + ), + BusyMarkGroupedList( + title: context.l10n.advanced, + filled: true, + children: [ BusyMarkActionRow( - title: context.l10n.clearGitWorkspaceTrust, - subtitle: context.l10n.clearGitWorkspaceTrustDescription, + title: context.l10n.clearRecentWorkspaces, leading: const Icon(BusyMarkGlyphs.clearAll), - onTap: controller.clearTrustedGitWorkspaces, + destructive: true, + onTap: controller.clearRecentWorkspaces, ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.advanced, - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.clearRecentWorkspaces, - leading: const Icon(BusyMarkGlyphs.clearAll), - destructive: true, - onTap: controller.clearRecentWorkspaces, - ), - ], - ), - ], + ], + ), + ], + ), ), ), ); } - void _configureHeaderBar( - BuildContext context, - LinuxHeaderBarService headerBar, - ) { - WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(() async { - await headerBar.setTitleRange(context.l10n.settings); - await headerBar.setSidebarVisible(false); - await headerBar.setSidebarToggleVisible(false); - await headerBar.setSearchVisible(false); - await headerBar.setBackVisible(true); - await headerBar.setDocumentControlsVisible(false); - await headerBar.setCanRefresh(false); - await headerBar.setSearchActive(false); - }()); - }); - } - void _handleHeaderBarAction( BuildContext context, - bool workspaceOpen, LinuxHeaderBarService headerBar, HeaderBarAction action, ) { switch (action) { case HeaderBarAction.back: - context.go(workspaceOpen ? '/workspace' : '/'); + context.go(returnTarget.location); case HeaderBarAction.aboutBusyMark: showBusyMarkAboutDialog(context); case HeaderBarAction.keyboardShortcuts: diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart index b37b0da..bfdebe3 100644 --- a/lib/src/workspace/presentation/welcome_screen.dart +++ b/lib/src/workspace/presentation/welcome_screen.dart @@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart'; import 'package:path/path.dart' as p; import '../../app/app_settings.dart'; +import '../../app/app_router.dart'; import '../../app/busymark_dialogs.dart'; import '../../app/busymark_design.dart'; import '../../app/busymark_glyphs.dart'; @@ -54,9 +55,6 @@ class _WelcomeScreenState extends ConsumerState { _handleHeaderBarAction(context, event.action); }); }); - if (headerBar.isAvailable) { - _configureHeaderBar(headerBar, sidebarVisible); - } final startupPath = ref.watch(startupPathProvider); if (!_startupPathConsumed && startupPath != null && @@ -141,8 +139,9 @@ class _WelcomeScreenState extends ConsumerState { ], if (state.message != null) ...[ const SizedBox(height: BusyMarkSpacing.lg), - _WelcomeMessage( + BusyMarkStatusBox( message: localizeWorkspaceMessage(context, state.message!), + kind: busyMarkWorkspaceMessageStatusKind(state.message!.code), ), ], ], @@ -156,68 +155,65 @@ class _WelcomeScreenState extends ConsumerState { welcomeContent, if (sidebarOnRight && sidebarVisible) welcomeSidebar, ]; + final headerConfiguration = HeaderBarConfigurationDefaults.of(context) + .copyWith( + title: context.l10n.appTitle, + viewMode: AppViewMode.editor, + searchQuery: '', + canRefresh: false, + documentControlsVisible: false, + searchActive: false, + searchVisible: false, + sidebarVisible: sidebarVisible, + sidebarToggleVisible: true, + backVisible: false, + ); - return Scaffold( - backgroundColor: welcomeMainColor, - appBar: useNativeHeaderBar - ? null - : AppBar( - leadingWidth: 0, - titleSpacing: BusyMarkSpacing.lg, - title: Text( - context.l10n.appTitle, - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), - ), - actions: [ - BusyMarkHeaderIconButton( - tooltip: sidebarVisible - ? context.l10n.hideSidebar - : context.l10n.showSidebar, - icon: BusyMarkGlyphs.sidebar, - selected: sidebarVisible, - shortcut: BusyMarkSidebarShortcutLabels.toggleSidebar, - onPressed: _toggleSidebar, - ), - BusyMarkMainMenuButton( - onSelected: (action) => - _handleMainMenuAction(context, headerBar, action), + return HeaderBarConfigurationPublisher( + synchronizer: headerBar.configurationSynchronizer, + configuration: headerConfiguration, + enabled: headerBar.isAvailable, + child: Scaffold( + backgroundColor: welcomeMainColor, + appBar: useNativeHeaderBar + ? null + : AppBar( + title: Text( + context.l10n.appTitle, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), ), - const SizedBox(width: BusyMarkSpacing.sm), - ], - ), - body: Row( - textDirection: TextDirection.ltr, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: bodyChildren, + actions: [ + BusyMarkHeaderIconButton( + tooltip: sidebarVisible + ? context.l10n.hideSidebar + : context.l10n.showSidebar, + icon: BusyMarkGlyphs.sidebar, + selected: sidebarVisible, + shortcut: BusyMarkSidebarShortcutLabels.toggleSidebar, + onPressed: _toggleSidebar, + ), + BusyMarkMainMenuButton( + onSelected: (action) => + _handleMainMenuAction(context, headerBar, action), + ), + const SizedBox(width: BusyMarkSpacing.sm), + ], + ), + body: Row( + textDirection: TextDirection.ltr, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: bodyChildren, + ), ), ); } - void _configureHeaderBar( - LinuxHeaderBarService headerBar, - bool sidebarVisible, - ) { - WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(() async { - await headerBar.setTitleRange(context.l10n.appTitle); - await headerBar.setSidebarWidth(BusyMarkSizes.sidebarWidth); - await headerBar.setSidebarVisible(sidebarVisible); - await headerBar.setSidebarToggleVisible(true); - await headerBar.setSearchVisible(false); - await headerBar.setBackVisible(false); - await headerBar.setDocumentControlsVisible(false); - await headerBar.setCanRefresh(false); - await headerBar.setSearchActive(false); - }()); - }); - } - void _handleHeaderBarAction(BuildContext context, HeaderBarAction action) { switch (action) { case HeaderBarAction.settings: - context.go('/settings'); + context.go(settingsLocation(SettingsReturnTarget.welcome)); case HeaderBarAction.aboutBusyMark: showBusyMarkAboutDialog(context); case HeaderBarAction.keyboardShortcuts: @@ -260,7 +256,7 @@ class _WelcomeScreenState extends ConsumerState { ) { switch (action) { case BusyMarkMainMenuAction.settings: - context.go('/settings'); + context.go(settingsLocation(SettingsReturnTarget.welcome)); case BusyMarkMainMenuAction.keyboardShortcuts: showBusyMarkKeyboardShortcutsDialog(context); case BusyMarkMainMenuAction.markdownAndHtml: @@ -427,9 +423,7 @@ class _WelcomeSidebar extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return DecoratedBox( - decoration: BoxDecoration(color: colors.sidebar), + return BusyMarkSidebarSurface( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -629,7 +623,6 @@ class _CreateWritersideProjectDialogState controller: _projectNameController, textInputAction: TextInputAction.next, errorText: projectError, - groupPosition: BusyMarkFloatingTextEntryPosition.first, ), BusyMarkFloatingTextEntry( label: context.l10n.directoryName, @@ -637,7 +630,6 @@ class _CreateWritersideProjectDialogState textDirection: TextDirection.ltr, textInputAction: TextInputAction.next, errorText: directoryError, - groupPosition: BusyMarkFloatingTextEntryPosition.last, ), ], ), @@ -648,7 +640,6 @@ class _CreateWritersideProjectDialogState label: context.l10n.instanceName, controller: _instanceNameController, textInputAction: TextInputAction.next, - groupPosition: BusyMarkFloatingTextEntryPosition.first, ), BusyMarkFloatingTextEntry( label: context.l10n.instanceId, @@ -656,7 +647,6 @@ class _CreateWritersideProjectDialogState textDirection: TextDirection.ltr, textInputAction: TextInputAction.next, errorText: instanceIdError, - groupPosition: BusyMarkFloatingTextEntryPosition.last, ), ], ), @@ -674,7 +664,10 @@ class _CreateWritersideProjectDialogState ), const SizedBox(height: BusyMarkSpacing.lg), if (_creationError != null) ...[ - _WelcomeMessage(message: _creationError!), + BusyMarkStatusBox( + message: _creationError!, + kind: BusyMarkStatusKind.error, + ), const SizedBox(height: BusyMarkSpacing.lg), ], Text( @@ -866,31 +859,20 @@ class _CreateWritersideProjectDialogState } } -class _WelcomeMessage extends StatelessWidget { - const _WelcomeMessage({required this.message}); - - final String message; - - @override - Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return DecoratedBox( - decoration: BoxDecoration( - color: colors.admonitionWarning, - borderRadius: BorderRadius.circular(BusyMarkRadius.md), - border: Border.all(color: colors.subtleBorder), - ), - child: Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.md), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(BusyMarkGlyphs.warning), - const SizedBox(width: BusyMarkSpacing.sm), - Expanded(child: Text(message)), - ], - ), - ), - ); - } +BusyMarkStatusKind busyMarkWorkspaceMessageStatusKind( + WorkspaceMessageCode code, +) { + return switch (code) { + WorkspaceMessageCode.chooseWhereToSaveMarkdown => + BusyMarkStatusKind.information, + WorkspaceMessageCode.saveBlockedFileChangedOnDisk => + BusyMarkStatusKind.warning, + WorkspaceMessageCode.openFailed || + WorkspaceMessageCode.createWritersideProjectFailed || + WorkspaceMessageCode.createWritersideTopicFailed || + WorkspaceMessageCode.couldNotOpenFile || + WorkspaceMessageCode.saveFailed || + WorkspaceMessageCode.fileOperationFailed || + WorkspaceMessageCode.validationFailed => BusyMarkStatusKind.error, + }; } diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 97e824e..07b0431 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -12,10 +12,12 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:yaru/yaru.dart'; import '../../app/app_settings.dart'; +import '../../app/app_router.dart'; import '../../app/busymark_dialogs.dart'; import '../../app/busymark_design.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/busymark_main_menu.dart'; +import '../../app/busymark_search_field.dart'; import '../../app/busymark_shortcuts.dart'; import '../../app/localization.dart'; import '../../core/diagnostic.dart'; @@ -40,6 +42,7 @@ import '../../git/presentation/git_file_status_colors.dart'; import '../../git/presentation/git_history_view.dart'; import '../../git/presentation/git_sidebar_tab.dart'; import '../../markdown/busymark_document.dart'; +import '../../markdown/document_outline.dart'; import '../../markdown/markdown_model.dart'; import '../../markdown/markdown_parser.dart'; import '../../markdown/preview_model.dart'; @@ -137,14 +140,18 @@ ScrollPosition? _safeScrollPosition(ScrollController controller) { class _OutlineNavigationTarget { const _OutlineNavigationTarget({ + required this.workspaceId, required this.filePath, required this.headingId, required this.line, + this.editorBlockId, }); - final String filePath; + final String workspaceId; + final String? filePath; final String headingId; - final int line; + final int? line; + final String? editorBlockId; } class _SourceNavigationTarget { @@ -263,6 +270,7 @@ class WorkspaceScreen extends ConsumerWidget { width: BusyMarkSizes.sidebarWidth, child: _Sidebar( workspace: workspace, + outline: _activeDocumentOutline(state), searchState: searchState, searchResults: searchResults, onOpenSearchResult: (result) => _openSearchResult(context, ref, result), @@ -307,26 +315,39 @@ class WorkspaceScreen extends ConsumerWidget { _handleHeaderBarAction(context, ref, event.action); }); }); - ref.listen(headerBarSearchQueriesProvider, (previous, next) { - next.whenData((query) { - final current = ref.read(_workspaceSearchProvider); - if (current.query == query && current.active) { - return; + ref.listen(headerBarSearchEventsProvider, (previous, next) { + next.whenData((event) { + switch (event) { + case HeaderBarSearchQueryChanged(:final query): + final current = ref.read(_workspaceSearchProvider); + if (current.query == query && current.active) { + return; + } + _clearGitDetailSelection(ref); + ref + .read(_workspaceSearchProvider.notifier) + .set(current.copyWith(active: true, query: query)); + unawaited(settingsController.setSidebarVisible(true)); + case HeaderBarSearchSubmitted(): + if (searchResults.isNotEmpty) { + unawaited(_openSearchResult(context, ref, searchResults.first)); + } + case HeaderBarSearchCleared(): + _clearSearchQuery(ref); + case HeaderBarSearchEscapePressed(): + _closeSearch(ref); + case HeaderBarSearchFocusChanged(): + break; } - _clearGitDetailSelection(ref); - ref - .read(_workspaceSearchProvider.notifier) - .set(current.copyWith(active: true, query: query)); - unawaited(settingsController.setSidebarVisible(true)); }); }); ref.listen(workspaceSearchOpenRequestProvider, (previous, next) { - if (previous != null && next != previous) { + if (next != previous) { _openSearch(ref); } }); ref.listen(workspaceSearchCloseRequestProvider, (previous, next) { - if (previous != null && next != previous) { + if (next != previous) { _closeSearch(ref); } }); @@ -342,185 +363,206 @@ class WorkspaceScreen extends ConsumerWidget { ref.read(gitControllerProvider.notifier).attachWorkspace(workspace); }); } - if (headerBar.isAvailable) { - _configureHeaderBar( - context, - headerBar, - workspace, - state, - settings, - searchState, - ); - } + final title = state.isDirty + ? '*${_activeFileName(context, workspace)}' + : _activeFileName(context, workspace); + final hasSidebar = _hasWorkspaceSidebar(workspace); + final headerConfiguration = HeaderBarConfigurationDefaults.of(context) + .copyWith( + title: busyMarkBidiIsolateFor(context, title), + viewMode: _headerBarViewMode(settings.documentViewMode), + searchQuery: searchState.query, + canRefresh: true, + documentControlsVisible: true, + searchActive: searchState.active, + searchVisible: true, + sidebarVisible: sidebarVisible, + sidebarToggleVisible: hasSidebar, + backVisible: true, + ); - return Shortcuts( - shortcuts: { - BusyMarkAppShortcutActivators.search: const _OpenSearchIntent(), - BusyMarkSidebarShortcutActivators.files: const _SelectSidebarTabIntent( - _SidebarTab.files, - ), - const SingleActivator(LogicalKeyboardKey.numpad1, control: true): - const _SelectSidebarTabIntent(_SidebarTab.files), - BusyMarkSidebarShortcutActivators.toc: const _SelectSidebarTabIntent( - _SidebarTab.toc, - ), - const SingleActivator(LogicalKeyboardKey.numpad2, control: true): - const _SelectSidebarTabIntent(_SidebarTab.toc), - BusyMarkSidebarShortcutActivators.outline: - const _SelectSidebarTabIntent(_SidebarTab.outline), - const SingleActivator(LogicalKeyboardKey.numpad3, control: true): - const _SelectSidebarTabIntent(_SidebarTab.outline), - BusyMarkSidebarShortcutActivators.git: const _SelectSidebarTabIntent( - _SidebarTab.git, - ), - const SingleActivator(LogicalKeyboardKey.numpad4, control: true): - const _SelectSidebarTabIntent(_SidebarTab.git), - BusyMarkSidebarShortcutActivators.history: - const _SelectSidebarTabIntent(_SidebarTab.gitHistory), - const SingleActivator(LogicalKeyboardKey.numpad5, control: true): - const _SelectSidebarTabIntent(_SidebarTab.gitHistory), - }, - child: Actions( - actions: { - _OpenSearchIntent: CallbackAction<_OpenSearchIntent>( - onInvoke: (intent) { - _openSearch(ref); - return null; - }, - ), - _ToggleSearchIntent: CallbackAction<_ToggleSearchIntent>( - onInvoke: (intent) { - _toggleSearch(ref); - return null; - }, + return HeaderBarConfigurationPublisher( + synchronizer: headerBar.configurationSynchronizer, + configuration: headerConfiguration, + enabled: headerBar.isAvailable, + child: Shortcuts( + shortcuts: { + BusyMarkAppShortcutActivators.search: const _OpenSearchIntent(), + BusyMarkSidebarShortcutActivators.files: + const _SelectSidebarTabIntent(_SidebarTab.files), + const SingleActivator(LogicalKeyboardKey.numpad1, control: true): + const _SelectSidebarTabIntent(_SidebarTab.files), + BusyMarkSidebarShortcutActivators.toc: const _SelectSidebarTabIntent( + _SidebarTab.toc, ), - _SelectSidebarTabIntent: CallbackAction<_SelectSidebarTabIntent>( - onInvoke: (intent) { - _selectSidebarShortcut(ref, intent.tab); - return null; - }, + const SingleActivator(LogicalKeyboardKey.numpad2, control: true): + const _SelectSidebarTabIntent(_SidebarTab.toc), + BusyMarkSidebarShortcutActivators.outline: + const _SelectSidebarTabIntent(_SidebarTab.outline), + const SingleActivator(LogicalKeyboardKey.numpad3, control: true): + const _SelectSidebarTabIntent(_SidebarTab.outline), + BusyMarkSidebarShortcutActivators.git: const _SelectSidebarTabIntent( + _SidebarTab.git, ), + const SingleActivator(LogicalKeyboardKey.numpad4, control: true): + const _SelectSidebarTabIntent(_SidebarTab.git), + BusyMarkSidebarShortcutActivators.history: + const _SelectSidebarTabIntent(_SidebarTab.gitHistory), + const SingleActivator(LogicalKeyboardKey.numpad5, control: true): + const _SelectSidebarTabIntent(_SidebarTab.gitHistory), }, - child: Focus( - autofocus: true, - child: Scaffold( - backgroundColor: colors.window, - appBar: useNativeHeaderBar - ? null - : AppBar( - leadingWidth: 50, - titleSpacing: 0, - leading: Center( - child: BusyMarkHeaderIconButton( - tooltip: context.l10n.welcome, - icon: BusyMarkGlyphs.home, - onPressed: () async { - final router = GoRouter.of(context); - if (await confirmSafeToContinue(context, ref)) { - router.go('/'); - } - }, + child: Actions( + actions: { + _OpenSearchIntent: CallbackAction<_OpenSearchIntent>( + onInvoke: (intent) { + _openSearch(ref); + return null; + }, + ), + _ToggleSearchIntent: CallbackAction<_ToggleSearchIntent>( + onInvoke: (intent) { + _toggleSearch(ref); + return null; + }, + ), + _SelectSidebarTabIntent: CallbackAction<_SelectSidebarTabIntent>( + onInvoke: (intent) { + _selectSidebarShortcut(ref, intent.tab); + return null; + }, + ), + }, + child: Focus( + autofocus: true, + child: Scaffold( + backgroundColor: colors.window, + appBar: useNativeHeaderBar + ? null + : AppBar( + leading: Center( + child: BusyMarkHeaderIconButton( + tooltip: context.l10n.welcome, + icon: BusyMarkGlyphs.home, + onPressed: () async { + final router = GoRouter.of(context); + if (await confirmSafeToContinue(context, ref)) { + router.go('/'); + } + }, + ), ), - ), - title: searchState.active - ? _HeaderSearchField( - query: searchState.query, - onChanged: (query) => _setSearchQuery(ref, query), - onSubmitted: () { - if (searchResults.isNotEmpty) { - unawaited( - _openSearchResult( - context, - ref, - searchResults.first, - ), - ); - } - }, - ) - : _HeaderTitle( - title: _activeFileName(context, workspace), - subtitle: _workspaceKindLabel( - context, - workspace.kind, + title: searchState.active + ? _HeaderSearchField( + query: searchState.query, + onChanged: (query) => _setSearchQuery(ref, query), + onClear: () => _clearSearchQuery(ref), + onSubmitted: () { + if (searchResults.isNotEmpty) { + unawaited( + _openSearchResult( + context, + ref, + searchResults.first, + ), + ); + } + }, + onEscape: () => _closeSearch(ref), + ) + : _HeaderTitle( + title: _activeFileName(context, workspace), + subtitle: _workspaceKindLabel( + context, + workspace.kind, + ), + dirty: state.isDirty, ), - dirty: state.isDirty, + actions: [ + const SizedBox(width: BusyMarkSpacing.sm), + BusyMarkHeaderIconButton( + tooltip: context.l10n.validate, + icon: BusyMarkGlyphs.diagnostics, + onPressed: () => unawaited( + _validateActiveAndShowProblems(context, ref), ), - actions: [ - const SizedBox(width: BusyMarkSpacing.sm), - BusyMarkHeaderIconButton( - tooltip: context.l10n.validate, - icon: BusyMarkGlyphs.diagnostics, - onPressed: () => unawaited( - _validateActiveAndShowProblems(context, ref), ), - ), - const _HeaderSeparator(), - BusyMarkHeaderIconButton( - tooltip: settings.sidebarVisible - ? context.l10n.hideSidebar - : context.l10n.showSidebar, - icon: BusyMarkGlyphs.sidebar, - selected: settings.sidebarVisible, - shortcut: BusyMarkSidebarShortcutLabels.toggleSidebar, - onPressed: () { - final visible = !settings.sidebarVisible; - if (!visible) { - _clearGitDetailSelection(ref); - } - unawaited( - settingsController.setSidebarVisible(visible), - ); - }, - ), - BusyMarkHeaderIconButton( - tooltip: context.l10n.search, - icon: BusyMarkGlyphs.search, - selected: searchState.active, - shortcut: BusyMarkAppShortcutLabels.search, - onPressed: () => _toggleSearch(ref), - ), - BusyMarkHeaderPopupMenuButton( - tooltip: context.l10n.viewMode, - icon: _documentViewModeIcon(settings.documentViewMode), - shortcut: _documentViewModeShortcut( - settings.documentViewMode, + const _HeaderSeparator(), + BusyMarkHeaderIconButton( + tooltip: settings.sidebarVisible + ? context.l10n.hideSidebar + : context.l10n.showSidebar, + icon: BusyMarkGlyphs.sidebar, + selected: settings.sidebarVisible, + shortcut: BusyMarkSidebarShortcutLabels.toggleSidebar, + onPressed: () { + final visible = !settings.sidebarVisible; + if (!visible) { + _clearGitDetailSelection(ref); + } + unawaited( + settingsController.setSidebarVisible(visible), + ); + }, ), - itemBuilder: (context) => [ - for (final mode in DocumentViewModePreference.values) - BusyMarkPopupMenuItem( - value: mode, - label: _documentViewModeLabel(context, mode), - icon: _documentViewModeIcon(mode), - shortcut: _documentViewModeShortcut(mode), - checked: mode == settings.documentViewMode, - trailingCheck: true, - ), - ], - onSelected: (mode) => - settingsController.setDocumentViewMode(mode), + BusyMarkHeaderIconButton( + tooltip: context.l10n.search, + icon: BusyMarkGlyphs.search, + selected: searchState.active, + shortcut: BusyMarkAppShortcutLabels.search, + onPressed: () => _toggleSearch(ref), + ), + BusyMarkHeaderPopupMenuButton< + DocumentViewModePreference + >( + tooltip: context.l10n.viewMode, + icon: _documentViewModeIcon( + settings.documentViewMode, + ), + shortcut: _documentViewModeShortcut( + settings.documentViewMode, + ), + itemBuilder: (context) => [ + for (final mode + in DocumentViewModePreference.values) + BusyMarkPopupMenuItem( + value: mode, + label: _documentViewModeLabel(context, mode), + icon: _documentViewModeIcon(mode), + shortcut: _documentViewModeShortcut(mode), + checked: mode == settings.documentViewMode, + trailingCheck: true, + ), + ], + onSelected: (mode) => + settingsController.setDocumentViewMode(mode), + ), + BusyMarkMainMenuButton( + onSelected: (action) => + _handleMainMenuAction(context, ref, action), + ), + const SizedBox(width: BusyMarkSpacing.sm), + ], + ), + body: Column( + children: [ + if (state.message != null) + BusyMarkStatusBox( + message: localizeWorkspaceMessage( + context, + state.message!, ), - BusyMarkMainMenuButton( - onSelected: (action) => - _handleMainMenuAction(context, ref, action), + kind: busyMarkWorkspaceMessageStatusKind( + state.message!.code, ), - const SizedBox(width: BusyMarkSpacing.sm), - ], - ), - body: Column( - children: [ - if (state.message != null) - _InlineMessage( - icon: BusyMarkGlyphs.warning, - message: localizeWorkspaceMessage(context, state.message!), - ), - Expanded( - child: Row( - textDirection: TextDirection.ltr, - children: workspaceChildren, + ), + Expanded( + child: Row( + textDirection: TextDirection.ltr, + children: workspaceChildren, + ), ), - ), - ], + ], + ), ), ), ), @@ -539,13 +581,19 @@ class WorkspaceScreen extends ConsumerWidget { void _openSearch(WidgetRef ref) { final search = ref.read(_workspaceSearchProvider); + if (search.active) { + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setSidebarVisible(true), + ); + unawaited(ref.read(linuxHeaderBarServiceProvider).focusSearch()); + return; + } _clearGitDetailSelection(ref); ref .read(_workspaceSearchProvider.notifier) .set(search.copyWith(active: true)); - final headerBar = ref.read(linuxHeaderBarServiceProvider); - unawaited(headerBar.setSearchActive(true)); - unawaited(headerBar.setSearchQuery(search.query)); unawaited( ref.read(appSettingsControllerProvider.notifier).setSidebarVisible(true), ); @@ -559,7 +607,6 @@ class WorkspaceScreen extends ConsumerWidget { ref .read(_workspaceSearchProvider.notifier) .set(search.copyWith(active: false)); - unawaited(ref.read(linuxHeaderBarServiceProvider).setSearchActive(false)); } void _selectSidebarShortcut(WidgetRef ref, _SidebarTab tab) { @@ -575,39 +622,13 @@ class WorkspaceScreen extends ConsumerWidget { ref .read(_workspaceSearchProvider.notifier) .set(current.copyWith(active: true, query: query)); - unawaited(ref.read(linuxHeaderBarServiceProvider).setSearchQuery(query)); } - void _configureHeaderBar( - BuildContext context, - LinuxHeaderBarService headerBar, - Workspace workspace, - WorkspaceState state, - AppSettings settings, - _WorkspaceSearchState searchState, - ) { - final title = state.isDirty - ? '*${_activeFileName(context, workspace)}' - : _activeFileName(context, workspace); - final hasSidebar = _hasWorkspaceSidebar(workspace); - WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(() async { - await headerBar.setTitleRange(busyMarkBidiIsolateFor(context, title)); - await headerBar.setSidebarWidth(BusyMarkSizes.sidebarWidth); - await headerBar.setSidebarVisible( - settings.sidebarVisible && hasSidebar, - ); - await headerBar.setSidebarToggleVisible(hasSidebar); - await headerBar.setSearchVisible(true); - await headerBar.setBackVisible(true); - await headerBar.setDocumentControlsVisible(true); - await headerBar.setViewMode( - _headerBarViewMode(settings.documentViewMode), - ); - await headerBar.setCanRefresh(true); - await headerBar.setSearchActive(searchState.active); - }()); - }); + void _clearSearchQuery(WidgetRef ref) { + final current = ref.read(_workspaceSearchProvider); + ref + .read(_workspaceSearchProvider.notifier) + .set(current.copyWith(query: '')); } void _handleHeaderBarAction( @@ -619,10 +640,9 @@ class WorkspaceScreen extends ConsumerWidget { final settingsController = ref.read(appSettingsControllerProvider.notifier); switch (action) { case HeaderBarAction.back: - final router = GoRouter.of(context); unawaited(() async { - if (await confirmSafeToContinue(context, ref)) { - router.go('/'); + if (await confirmSafeToContinue(context, ref) && context.mounted) { + context.go('/'); } }()); case HeaderBarAction.sidebarToggle: @@ -636,7 +656,7 @@ class WorkspaceScreen extends ConsumerWidget { case HeaderBarAction.save: break; case HeaderBarAction.settings: - context.go('/settings'); + context.go(settingsLocation(SettingsReturnTarget.workspace)); case HeaderBarAction.keyboardShortcuts: showBusyMarkKeyboardShortcutsDialog(context); case HeaderBarAction.markdownAndHtml: @@ -687,7 +707,7 @@ class WorkspaceScreen extends ConsumerWidget { ) { switch (action) { case BusyMarkMainMenuAction.settings: - context.go('/settings'); + context.go(settingsLocation(SettingsReturnTarget.workspace)); case BusyMarkMainMenuAction.keyboardShortcuts: showBusyMarkKeyboardShortcutsDialog(context); case BusyMarkMainMenuAction.markdownAndHtml: @@ -917,13 +937,14 @@ Future _confirmDiscardGitFiles( title: title, maxWidth: BusyMarkSizes.dialogWide, actions: [ - TextButton( + BusyMarkDialogButton( + label: context.l10n.cancel, onPressed: () => Navigator.pop(context, false), - child: Text(context.l10n.cancel), ), - FilledButton( + BusyMarkDialogButton( + label: context.l10n.gitDiscard, + destructive: true, onPressed: () => Navigator.pop(context, true), - child: Text(context.l10n.gitDiscard), ), ], children: [ @@ -952,13 +973,14 @@ Future _confirmSwitchGitBranch( title: context.l10n.gitConfirmSwitchBranchTitle(branchName), maxWidth: BusyMarkSizes.dialog, actions: [ - TextButton( + BusyMarkDialogButton( + label: context.l10n.cancel, onPressed: () => Navigator.pop(context, false), - child: Text(context.l10n.cancel), ), - FilledButton( + BusyMarkDialogButton( + label: context.l10n.gitSwitchBranch, + suggested: true, onPressed: () => Navigator.pop(context, true), - child: Text(context.l10n.gitSwitchBranch), ), ], children: [Text(context.l10n.gitConfirmSwitchBranchMessage)], @@ -983,13 +1005,14 @@ Future _confirmGitPushSetUpstream( title: context.l10n.gitConfirmPushSetUpstreamTitle, maxWidth: BusyMarkSizes.dialog, actions: [ - TextButton( + BusyMarkDialogButton( + label: context.l10n.cancel, onPressed: () => Navigator.pop(context, false), - child: Text(context.l10n.cancel), ), - FilledButton( + BusyMarkDialogButton( + label: context.l10n.gitPush, + suggested: true, onPressed: () => Navigator.pop(context, true), - child: Text(context.l10n.gitPush), ), ], children: [ @@ -1279,13 +1302,7 @@ class _GitFileList extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return DecoratedBox( - decoration: BoxDecoration( - color: colors.control, - borderRadius: BorderRadius.circular(BusyMarkRadius.md), - border: Border.all(color: colors.subtleBorder), - ), + return BusyMarkGroupedSurface( child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 180), child: ListView.builder( @@ -1354,12 +1371,16 @@ class _HeaderSearchField extends StatefulWidget { const _HeaderSearchField({ required this.query, required this.onChanged, + required this.onClear, required this.onSubmitted, + required this.onEscape, }); final String query; final ValueChanged onChanged; + final VoidCallback onClear; final VoidCallback onSubmitted; + final VoidCallback onEscape; @override State<_HeaderSearchField> createState() => _HeaderSearchFieldState(); @@ -1367,19 +1388,11 @@ class _HeaderSearchField extends StatefulWidget { class _HeaderSearchFieldState extends State<_HeaderSearchField> { late final TextEditingController _controller; - late final FocusNode _focusNode; @override void initState() { super.initState(); - _controller = TextEditingController(text: widget.query) - ..addListener(_handleChanged); - _focusNode = FocusNode(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _focusNode.requestFocus(); - } - }); + _controller = TextEditingController(text: widget.query); } @override @@ -1395,52 +1408,20 @@ class _HeaderSearchFieldState extends State<_HeaderSearchField> { @override void dispose() { - _controller - ..removeListener(_handleChanged) - ..dispose(); - _focusNode.dispose(); + _controller.dispose(); super.dispose(); } - void _handleChanged() { - widget.onChanged(_controller.text); - } - @override Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return SizedBox( - height: BusyMarkSizes.iconButton, - child: TextField( - controller: _controller, - focusNode: _focusNode, - textInputAction: TextInputAction.search, - onSubmitted: (_) => widget.onSubmitted(), - textAlignVertical: TextAlignVertical.center, - decoration: InputDecoration( - isDense: true, - prefixIcon: Icon( - BusyMarkGlyphs.search, - color: colors.mutedForeground, - size: BusyMarkSizes.iconSm, - ), - prefixIconConstraints: const BoxConstraints( - minWidth: BusyMarkSizes.iconButton, - minHeight: BusyMarkSizes.iconButton, - ), - hintText: context.l10n.search, - filled: true, - fillColor: colors.control, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(BusyMarkRadius.headerButton), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.md, - vertical: 0, - ), - ), - ), + return BusyMarkSearchField( + controller: _controller, + hintText: context.l10n.search, + autofocus: true, + onChanged: widget.onChanged, + onSubmitted: (_) => widget.onSubmitted(), + onClear: widget.onClear, + onEscape: widget.onEscape, ); } } @@ -1459,52 +1440,17 @@ class _HeaderSeparator extends StatelessWidget { } } -class _InlineMessage extends StatelessWidget { - const _InlineMessage({required this.icon, required this.message}); - - final IconData icon; - final String message; - - @override - Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return DecoratedBox( - decoration: BoxDecoration( - color: colors.admonitionWarning, - border: Border(bottom: BorderSide(color: colors.subtleBorder)), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.lg, - vertical: BusyMarkSpacing.sm, - ), - child: Row( - children: [ - Icon(icon, size: BusyMarkSizes.iconSm), - const SizedBox(width: BusyMarkSpacing.sm), - Expanded( - child: Text( - message, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ); - } -} - class _Sidebar extends ConsumerStatefulWidget { const _Sidebar({ required this.workspace, + required this.outline, required this.searchState, required this.searchResults, required this.onOpenSearchResult, }); final Workspace workspace; + final List outline; final _WorkspaceSearchState searchState; final List<_WorkspaceSearchResult> searchResults; final Future Function(_WorkspaceSearchResult result) onOpenSearchResult; @@ -1554,7 +1500,6 @@ class _SidebarState extends ConsumerState<_Sidebar> { @override Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); final tabs = _sidebarTabsFor(widget.workspace.kind); final gitState = ref.watch(gitControllerProvider); final repositoryInfo = gitState.attachedWorkspace?.id == widget.workspace.id @@ -1571,8 +1516,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { ? 0 : _tab.clamp(0, tabs.length - 1).toInt(); final selectedTab = tabs.isEmpty ? null : tabs[selectedIndex]; - return DecoratedBox( - decoration: BoxDecoration(color: colors.sidebar), + return BusyMarkSidebarSurface( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1625,6 +1569,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { ), _SidebarTab.outline => _OutlineTab( workspace: widget.workspace, + headings: widget.outline, ), _SidebarTab.git => GitSidebarTab( workspace: widget.workspace, @@ -2624,13 +2569,17 @@ class _WritersideTopicRemovalDialogState ), if (_analysis.childCount > 0) ...[ const SizedBox(height: BusyMarkSpacing.md), - _DialogMessage( + BusyMarkStatusBox( message: context.l10n.childTopicsPromoted(_analysis.childCount), + kind: BusyMarkStatusKind.warning, ), ], if (_analysis.isStartPage) ...[ const SizedBox(height: BusyMarkSpacing.md), - _DialogMessage(message: context.l10n.topicIsStartPageRemovalWarning), + BusyMarkStatusBox( + message: context.l10n.topicIsStartPageRemovalWarning, + kind: BusyMarkStatusKind.warning, + ), ], BusyMarkGroupedList( title: context.l10n.topicUsagesCount(relevantUsages.length), @@ -2686,15 +2635,29 @@ class _WritersideTopicRemovalDialogState ), if (_redirectTarget != null) ...[ const SizedBox(height: BusyMarkSpacing.md), - DropdownButtonFormField( - initialValue: _redirectTarget, - isExpanded: true, - decoration: InputDecoration(labelText: context.l10n.redirectTarget), - items: [ - for (final target in _analysis.redirectTargets) - DropdownMenuItem(value: target, child: Text(target.label)), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkActionRow( + title: context.l10n.redirectTarget, + leading: const Icon(BusyMarkGlyphs.link), + trailing: BusyMarkPopupSelector( + value: _redirectTarget, + label: _redirectTarget!.label, + tooltip: context.l10n.redirectTarget, + options: [ + for (final target in _analysis.redirectTargets) + BusyMarkPopupSelectorOption( + value: target, + label: target.label, + ), + ], + onSelected: (value) { + setState(() => _redirectTarget = value); + }, + ), + ), ], - onChanged: (value) => setState(() => _redirectTarget = value), ), const SizedBox(height: BusyMarkSpacing.xs), Text( @@ -2706,7 +2669,10 @@ class _WritersideTopicRemovalDialogState ], if (!_canApply) ...[ const SizedBox(height: BusyMarkSpacing.md), - _DialogMessage(message: context.l10n.remainingUsagesBlockRemoval), + BusyMarkStatusBox( + message: context.l10n.remainingUsagesBlockRemoval, + kind: BusyMarkStatusKind.warning, + ), ], ], ); @@ -2828,7 +2794,7 @@ class _WritersideTopicUsagesSidebar extends StatelessWidget { ), Padding( padding: const EdgeInsets.all(BusyMarkSpacing.sm), - child: FilledButton.icon( + child: BusyMarkPushButton.standardIcon( onPressed: onDoRefactor, icon: const Icon(BusyMarkGlyphs.edit), label: Text(context.l10n.doRefactor), @@ -5113,68 +5079,74 @@ class _CreateWritersideTopicDialogState title: _dialogTitle(context), maxWidth: BusyMarkSizes.dialogWide, actions: [ - TextButton( + BusyMarkDialogButton( + label: context.l10n.cancel, onPressed: () => Navigator.pop(context), - child: Text(context.l10n.cancel), ), - FilledButton( + BusyMarkDialogButton( + label: _creating ? context.l10n.creating : context.l10n.create, + suggested: true, onPressed: canCreate ? _submit : null, - child: Text(_creating ? context.l10n.creating : context.l10n.create), ), ], children: [ - TextField( - controller: _titleController, - autofocus: true, - textInputAction: TextInputAction.next, - decoration: InputDecoration( - labelText: context.l10n.topicTitle, - errorText: titleError, - ), - ), - const SizedBox(height: BusyMarkSpacing.md), - TextField( - controller: _fileNameController, - textDirection: TextDirection.ltr, - textInputAction: TextInputAction.done, - onSubmitted: (_) { - if (canCreate) { - _submit(); - } - }, - decoration: InputDecoration( - labelText: context.l10n.fileName, - errorText: fileNameError, - ), + BusyMarkFloatingTextEntryGroup( + children: [ + BusyMarkFloatingTextEntry( + label: context.l10n.topicTitle, + controller: _titleController, + autofocus: true, + textInputAction: TextInputAction.next, + errorText: titleError, + ), + BusyMarkFloatingTextEntry( + label: context.l10n.fileName, + controller: _fileNameController, + textDirection: TextDirection.ltr, + textInputAction: TextInputAction.done, + errorText: fileNameError, + onSubmitted: (_) { + if (canCreate) { + _submit(); + } + }, + ), + ], ), const SizedBox(height: BusyMarkSpacing.md), - DropdownButtonFormField( - initialValue: _placement, - isExpanded: true, - decoration: InputDecoration(labelText: context.l10n.topicPlacement), - items: [ - DropdownMenuItem( - value: WritersideTopicCreatePlacement.root, - child: Text(context.l10n.tocRoot), - ), - if (widget.referencePath != null) - DropdownMenuItem( - value: WritersideTopicCreatePlacement.sibling, - child: Text(context.l10n.afterSelectedTopic), - ), - if (widget.referencePath != null) - DropdownMenuItem( - value: WritersideTopicCreatePlacement.child, - child: Text(context.l10n.insideSelectedTopic), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkActionRow( + title: context.l10n.topicPlacement, + leading: const Icon(BusyMarkGlyphs.tree), + trailing: BusyMarkPopupSelector( + value: _placement, + label: _placementLabel(context, _placement), + tooltip: context.l10n.topicPlacement, + enabled: !_creating, + options: [ + BusyMarkPopupSelectorOption( + value: WritersideTopicCreatePlacement.root, + label: context.l10n.tocRoot, + ), + if (widget.referencePath != null) + BusyMarkPopupSelectorOption( + value: WritersideTopicCreatePlacement.sibling, + label: context.l10n.afterSelectedTopic, + ), + if (widget.referencePath != null) + BusyMarkPopupSelectorOption( + value: WritersideTopicCreatePlacement.child, + label: context.l10n.insideSelectedTopic, + ), + ], + onSelected: (value) { + setState(() => _placement = value); + }, ), + ), ], - onChanged: _creating - ? null - : (value) { - if (value != null) { - setState(() => _placement = value); - } - }, ), if (_placement != WritersideTopicCreatePlacement.root && widget.referenceLabel != null) ...[ @@ -5206,7 +5178,10 @@ class _CreateWritersideTopicDialogState ), const SizedBox(height: BusyMarkSpacing.lg), if (_creationError != null) ...[ - _DialogMessage(message: _creationError!), + BusyMarkStatusBox( + message: _creationError!, + kind: BusyMarkStatusKind.error, + ), const SizedBox(height: BusyMarkSpacing.lg), ], Text( @@ -5247,6 +5222,17 @@ class _CreateWritersideTopicDialogState : context.l10n.newTopic; } + String _placementLabel( + BuildContext context, + WritersideTopicCreatePlacement placement, + ) { + return switch (placement) { + WritersideTopicCreatePlacement.root => context.l10n.tocRoot, + WritersideTopicCreatePlacement.sibling => context.l10n.afterSelectedTopic, + WritersideTopicCreatePlacement.child => context.l10n.insideSelectedTopic, + }; + } + String? _titleError(BuildContext context) { if (_titleController.text.trim().isEmpty) { return context.l10n.topicTitleRequired; @@ -5411,35 +5397,6 @@ class _CreateWritersideTopicDialogState } } -class _DialogMessage extends StatelessWidget { - const _DialogMessage({required this.message}); - - final String message; - - @override - Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return DecoratedBox( - decoration: BoxDecoration( - color: colors.admonitionWarning, - borderRadius: BorderRadius.circular(BusyMarkRadius.md), - border: Border.all(color: colors.subtleBorder), - ), - child: Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.md), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(BusyMarkGlyphs.warning), - const SizedBox(width: BusyMarkSpacing.sm), - Expanded(child: Text(message)), - ], - ), - ), - ); - } -} - class _TocTreeEntry { const _TocTreeEntry({ required this.node, @@ -5626,9 +5583,10 @@ Set _activeTocAncestorKeys(Workspace workspace, {String? treePath}) { } class _OutlineTab extends ConsumerStatefulWidget { - const _OutlineTab({required this.workspace}); + const _OutlineTab({required this.workspace, required this.headings}); final Workspace workspace; + final List headings; @override ConsumerState<_OutlineTab> createState() => _OutlineTabState(); @@ -5641,32 +5599,32 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { @override void initState() { super.initState(); - _outlineStateKey = _outlineStateSignature(widget.workspace); - _expandedNodeKeys = _initialExpandedOutlineNodeKeys(widget.workspace); + _outlineStateKey = _outlineStateSignature( + widget.workspace, + widget.headings, + ); + _expandedNodeKeys = _initialExpandedOutlineNodeKeys(widget.headings); } @override void didUpdateWidget(covariant _OutlineTab oldWidget) { super.didUpdateWidget(oldWidget); - final nextKey = _outlineStateSignature(widget.workspace); + final nextKey = _outlineStateSignature(widget.workspace, widget.headings); if (nextKey != _outlineStateKey) { _outlineStateKey = nextKey; - _expandedNodeKeys = _initialExpandedOutlineNodeKeys(widget.workspace); + _expandedNodeKeys = _initialExpandedOutlineNodeKeys(widget.headings); } } @override Widget build(BuildContext context) { - final headings = - widget.workspace.markdown?.headings ?? const []; + final headings = widget.headings; if (headings.isEmpty) { return _SidebarEmptyState( icon: BusyMarkGlyphs.font, title: context.l10n.noOutline, ); } - final activeFilePath = - widget.workspace.activeFilePath ?? widget.workspace.markdown?.filePath; final tree = _buildOutlineTree(headings); final entries = _visibleOutlineTreeEntries(tree, _expandedNodeKeys); return ListView.builder( @@ -5701,36 +5659,55 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { hasChildren: hasChildren, expanded: expanded, onToggle: hasChildren ? toggle : null, - onTap: activeFilePath == null - ? null - : () { - ref - .read(_outlineNavigationTargetProvider.notifier) - .set( - _OutlineNavigationTarget( - filePath: activeFilePath, - headingId: heading.id, - line: heading.span.startLine, - ), - ); - }, + onTap: () { + ref + .read(_outlineNavigationTargetProvider.notifier) + .set( + _OutlineNavigationTarget( + workspaceId: widget.workspace.id, + filePath: widget.workspace.activeFilePath, + headingId: heading.id, + line: heading.sourceStartLine, + editorBlockId: heading.editorBlockId, + ), + ); + }, ); }, ); } } +List _activeDocumentOutline(WorkspaceState state) { + final liveOutline = state.liveOutline; + final workspace = state.workspace; + if (liveOutline != null && + workspace != null && + liveOutline.matches(workspace, state.activeText)) { + return liveOutline.headings; + } + final preview = state.preview; + if (preview != null) { + return preview.outline; + } + return [ + for (final heading + in state.workspace?.markdown?.headings ?? const []) + DocumentOutlineHeading.fromMarkdown(heading), + ]; +} + class _OutlineTreeNode { const _OutlineTreeNode({required this.heading, required this.children}); - final MarkdownHeading heading; + final DocumentOutlineHeading heading; final List<_OutlineTreeNode> children; } class _MutableOutlineTreeNode { _MutableOutlineTreeNode(this.heading); - final MarkdownHeading heading; + final DocumentOutlineHeading heading; final children = <_MutableOutlineTreeNode>[]; } @@ -5741,7 +5718,9 @@ class _OutlineTreeEntry { final int depth; } -List<_OutlineTreeNode> _buildOutlineTree(List headings) { +List<_OutlineTreeNode> _buildOutlineTree( + List headings, +) { final roots = <_MutableOutlineTreeNode>[]; final stack = <_MutableOutlineTreeNode>[]; @@ -5794,8 +5773,9 @@ List<_OutlineTreeEntry> _visibleOutlineTreeEntries( return entries; } -Set _initialExpandedOutlineNodeKeys(Workspace workspace) { - final headings = workspace.markdown?.headings ?? const []; +Set _initialExpandedOutlineNodeKeys( + List headings, +) { return { for (final node in _flattenOutlineTree(_buildOutlineTree(headings))) if (node.children.isNotEmpty) _outlineNodeKey(node.heading), @@ -5811,16 +5791,26 @@ Iterable<_OutlineTreeNode> _flattenOutlineTree( } } -String _outlineNodeKey(MarkdownHeading heading) { - return '${heading.id}:${heading.span.startOffset}'; +String _outlineNodeKey(DocumentOutlineHeading heading) { + return [ + heading.id, + heading.editorBlockId ?? heading.sourceStartOffset ?? 'live', + ].join(':'); } -String _outlineStateSignature(Workspace workspace) { - final headings = workspace.markdown?.headings ?? const []; +String _outlineStateSignature( + Workspace workspace, + List headings, +) { return [ - workspace.activeFilePath ?? workspace.markdown?.filePath ?? workspace.id, + workspace.id, + workspace.activeFilePath ?? workspace.markdown?.filePath ?? '', for (final heading in headings) - '${heading.id}:${heading.level}:${heading.span.startOffset}', + [ + heading.id, + heading.level, + heading.editorBlockId ?? heading.sourceStartOffset ?? 'live', + ].join(':'), ].join('|'); } @@ -6107,17 +6097,10 @@ class _WorkspaceTabButton extends StatelessWidget { ), ), const SizedBox(width: BusyMarkSpacing.xs), - IconButton( + BusyMarkCompactIconButton( tooltip: MaterialLocalizations.of(context).closeButtonTooltip, - icon: const Icon(BusyMarkGlyphs.clear), - iconSize: 13, - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: 24, - height: 24, - ), - visualDensity: VisualDensity.compact, - color: foreground, + icon: BusyMarkGlyphs.clear, + foregroundColor: foreground, onPressed: onClose, ), ], @@ -7125,6 +7108,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { String? _cachedWysiwygPath; String? _cachedWysiwygSource; String? _wysiwygScrollHeadingId; + String? _wysiwygScrollBlockId; String? _wysiwygSearchQuery; var _wysiwygScrollRequest = 0; @@ -7145,23 +7129,26 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { _previewHeadingKeys.clear(); _previewSearchKeys.clear(); _wysiwygScrollHeadingId = null; + _wysiwygScrollBlockId = null; _wysiwygSearchQuery = null; _wysiwygScrollRequest = 0; } if (oldWidget.viewMode == DocumentViewModePreference.editor && widget.viewMode != DocumentViewModePreference.editor && - widget.viewMode != DocumentViewModePreference.source && widget.state.workspace != null && widget.state.isDirty) { - final activeText = widget.state.activeText; + final workspaceId = widget.state.workspace!.id; final sourceFilePath = _activeEditorPath(); WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { + if (!mounted || + widget.viewMode == DocumentViewModePreference.editor || + widget.state.workspace?.id != workspaceId || + _activeEditorPath() != sourceFilePath) { return; } ref .read(workspaceControllerProvider.notifier) - .updateActiveText(activeText, sourceFilePath: sourceFilePath); + .refreshActivePreview(sourceFilePath: sourceFilePath); }); } } @@ -7178,7 +7165,10 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { if (next == null) { return; } - if (next.filePath != widget.state.workspace?.activeFilePath) { + final workspace = widget.state.workspace; + if (workspace == null || + next.workspaceId != workspace.id || + next.filePath != workspace.activeFilePath) { return; } _scrollToOutlineTarget(next); @@ -7267,6 +7257,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { .read(appSettingsControllerProvider.notifier) .setEditorToolbarDirection, scrollToHeadingId: _wysiwygScrollHeadingId, + scrollToBlockId: _wysiwygScrollBlockId, scrollToSearchQuery: _wysiwygSearchQuery, scrollRequest: _wysiwygScrollRequest, documentLayout: standaloneDocumentLayout, @@ -7329,29 +7320,34 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { ); } - void _handleSourceChanged( - String value, - String? sourceFilePath, { - bool updatePreview = true, - }) { + void _handleSourceChanged(String value, String? sourceFilePath) { final activePath = _activeEditorPath(); if (sourceFilePath != null && sourceFilePath != activePath) { return; } - if (updatePreview) { - _clearWysiwygCache(); - } + _clearWysiwygCache(); ref .read(workspaceControllerProvider.notifier) - .updateActiveText( - value, - updatePreview: updatePreview, - sourceFilePath: sourceFilePath ?? activePath, - ); + .updateActiveText(value, sourceFilePath: sourceFilePath ?? activePath); } void _handleWysiwygSourceChanged(String filePath, String value) { - _handleSourceChanged(value, filePath, updatePreview: false); + if (filePath != _activeEditorPath()) { + return; + } + final document = _cachedWysiwygDocument; + final controller = ref.read(workspaceControllerProvider.notifier); + if (document == null || + document.filePath != filePath || + document.source != value) { + controller.updateActiveText(value, sourceFilePath: filePath); + return; + } + controller.updateActiveWysiwygText( + value, + document: document, + sourceFilePath: filePath, + ); } void _cacheWysiwygDocument(BusyDocument document) { @@ -7414,10 +7410,13 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { } setState(() { _wysiwygScrollHeadingId = target.headingId; + _wysiwygScrollBlockId = target.editorBlockId; _wysiwygSearchQuery = null; _wysiwygScrollRequest += 1; }); - _sourceEditorKey.currentState?.scrollToLine(target.line); + if (target.line case final line?) { + _sourceEditorKey.currentState?.scrollToLine(line); + } _scrollPreviewToHeading(target.headingId); }); } @@ -7443,6 +7442,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { if (wysiwygVisible) { setState(() { _wysiwygScrollHeadingId = null; + _wysiwygScrollBlockId = null; _wysiwygSearchQuery = target.query; _wysiwygScrollRequest += 1; }); @@ -8438,7 +8438,7 @@ class _ListMarker extends StatelessWidget { ); } return Padding( - padding: const EdgeInsets.only(top: BusyMarkSizes.floatingEntryLabelTop), + padding: const EdgeInsets.only(top: BusyMarkSizes.listMarkerTopInset), child: SizedBox.square( dimension: BusyMarkSizes.markerDot, child: DecoratedBox( @@ -9086,8 +9086,10 @@ void _navigatePreviewAnchor( if (anchor == null || anchor.isEmpty) { return; } - final markdown = ref.read(workspaceControllerProvider).workspace?.markdown; - if (markdown == null || markdown.filePath != filePath) { + final state = ref.read(workspaceControllerProvider); + final workspace = state.workspace; + final activePath = workspace?.activeFilePath ?? workspace?.markdown?.filePath; + if (workspace == null || activePath != filePath) { return; } final normalizedAnchor = anchor.startsWith('#') @@ -9095,7 +9097,7 @@ void _navigatePreviewAnchor( : anchor; final decodedAnchor = _decodePreviewAnchor(normalizedAnchor); final slug = slugForHeading(decodedAnchor); - final heading = markdown.headings + final heading = state.preview?.outline .where( (heading) => heading.id == normalizedAnchor || @@ -9112,9 +9114,11 @@ void _navigatePreviewAnchor( .read(_outlineNavigationTargetProvider.notifier) .set( _OutlineNavigationTarget( - filePath: filePath, + workspaceId: workspace.id, + filePath: workspace.activeFilePath, headingId: heading.id, - line: heading.span.startLine, + line: heading.sourceStartLine, + editorBlockId: heading.editorBlockId, ), ); } @@ -9209,31 +9213,19 @@ class _ProblemsList extends StatelessWidget { @override Widget build(BuildContext context) { final diagnostics = workspace.diagnostics; - return DecoratedBox( - decoration: BoxDecoration( - color: BusyMarkSurfaceColors.of(context).view, - borderRadius: BorderRadius.circular(BusyMarkRadius.md), - border: Border.all( - color: BusyMarkSurfaceColors.of(context).subtleBorder, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(BusyMarkRadius.md), - child: diagnostics.isEmpty - ? _EmptyPane( - icon: BusyMarkGlyphs.check, - title: context.l10n.noProblemsFound, - ) - : ListView.builder( - padding: const EdgeInsets.symmetric( - vertical: BusyMarkSpacing.xs, - ), - itemCount: diagnostics.length, - itemBuilder: (context, index) { - return _DiagnosticRow(diagnostic: diagnostics[index]); - }, - ), - ), + return BusyMarkGroupedSurface( + child: diagnostics.isEmpty + ? _EmptyPane( + icon: BusyMarkGlyphs.check, + title: context.l10n.noProblemsFound, + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(vertical: BusyMarkSpacing.xs), + itemCount: diagnostics.length, + itemBuilder: (context, index) { + return _DiagnosticRow(diagnostic: diagnostics[index]); + }, + ), ); } } @@ -9760,9 +9752,18 @@ Color _diagnosticColorForSeverity( DiagnosticSeverity severity, ) { return switch (severity) { - DiagnosticSeverity.error => Theme.of(context).colorScheme.error, - DiagnosticSeverity.warning => BusyMarkLinuxPalette.yellow, - DiagnosticSeverity.info => Theme.of(context).colorScheme.primary, + DiagnosticSeverity.error => busyMarkStatusColor( + context, + BusyMarkStatusKind.error, + ), + DiagnosticSeverity.warning => busyMarkStatusColor( + context, + BusyMarkStatusKind.warning, + ), + DiagnosticSeverity.info => busyMarkStatusColor( + context, + BusyMarkStatusKind.information, + ), DiagnosticSeverity.hint => BusyMarkSurfaceColors.of(context).muted, }; } diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 4867579..3820aca 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -7,6 +7,8 @@ import 'package:path/path.dart' as p; import '../app/app_settings.dart'; import '../core/debug_log.dart'; import '../core/diagnostic.dart'; +import '../markdown/busymark_document.dart'; +import '../markdown/document_outline.dart'; import '../markdown/preview_model.dart'; import '../writerside/writerside_project_creator.dart'; import '../writerside/writerside_topic_removal_service.dart'; @@ -672,10 +674,47 @@ class WorkspaceController extends Notifier { } } - void updateActiveText( + void updateActiveText(String text, {String? sourceFilePath}) { + _updateActiveText( + text, + sourceFilePath: sourceFilePath, + rebuildPreview: true, + ); + } + + /// Applies a serialized WYSIWYG edit without reparsing the whole Markdown + /// document on every keystroke. + void updateActiveWysiwygText( + String text, { + required BusyDocument document, + String? sourceFilePath, + }) { + _updateActiveText( + text, + sourceFilePath: sourceFilePath, + rebuildPreview: false, + liveOutline: document.outline, + ); + } + + /// Rebuilds derived preview data after leaving WYSIWYG mode without creating + /// another edit revision for text that is already in state. + void refreshActivePreview({String? sourceFilePath}) { + final workspace = state.workspace; + final activeEditorPath = + workspace?.activeFilePath ?? workspace?.markdown?.filePath; + if (workspace == null || + (sourceFilePath != null && activeEditorPath != sourceFilePath)) { + return; + } + state = state.copyWith(preview: _safePreview(workspace, state.activeText)); + } + + void _updateActiveText( String text, { - bool updatePreview = true, + required bool rebuildPreview, String? sourceFilePath, + List? liveOutline, }) { final workspace = state.workspace; final activeEditorPath = @@ -684,13 +723,26 @@ class WorkspaceController extends Notifier { return; } _editRevision++; - state = state.copyWith( - activeText: text, - preview: workspace == null || !updatePreview - ? state.preview - : _safePreview(workspace, text), - isDirty: true, - ); + state = rebuildPreview + ? state.copyWith( + activeText: text, + preview: workspace == null + ? state.preview + : _safePreview(workspace, text), + isDirty: true, + ) + : state.copyWith( + activeText: text, + liveOutline: workspace == null || liveOutline == null + ? null + : ActiveDocumentOutline( + workspaceId: workspace.id, + filePath: workspace.activeFilePath, + source: text, + headings: liveOutline, + ), + isDirty: true, + ); _parseDebounce?.cancel(); if (!_settingsController.state.validateOnEdit) { _scheduleAutoSave(); diff --git a/lib/src/workspace/workspace_model.dart b/lib/src/workspace/workspace_model.dart index c78c948..ad53bae 100644 --- a/lib/src/workspace/workspace_model.dart +++ b/lib/src/workspace/workspace_model.dart @@ -1,4 +1,5 @@ import '../core/diagnostic.dart'; +import '../markdown/document_outline.dart'; import '../markdown/markdown_model.dart'; import '../markdown/preview_model.dart'; import '../writerside/writerside_model.dart'; @@ -30,6 +31,26 @@ enum DocumentKind { unknown, } +class ActiveDocumentOutline { + const ActiveDocumentOutline({ + required this.workspaceId, + required this.filePath, + required this.source, + required this.headings, + }); + + final String workspaceId; + final String? filePath; + final String source; + final List headings; + + bool matches(Workspace workspace, String activeSource) { + return workspaceId == workspace.id && + filePath == workspace.activeFilePath && + source == activeSource; + } +} + class WorkspaceFileSnapshot { const WorkspaceFileSnapshot({ required this.modifiedAt, @@ -171,6 +192,7 @@ class WorkspaceState { this.workspace, this.activeText = '', this.preview, + this.liveOutline, this.isDirty = false, this.isLoading = false, this.message, @@ -179,6 +201,7 @@ class WorkspaceState { final Workspace? workspace; final String activeText; final PreviewDocument? preview; + final ActiveDocumentOutline? liveOutline; final bool isDirty; final bool isLoading; final WorkspaceMessage? message; @@ -189,18 +212,26 @@ class WorkspaceState { Workspace? workspace, String? activeText, Object? preview = _copyWithUnset, + Object? liveOutline = _copyWithUnset, bool? isDirty, bool? isLoading, WorkspaceMessage? message, bool clearMessage = false, }) { - final nextPreview = identical(preview, _copyWithUnset) + final replacesPreview = !identical(preview, _copyWithUnset); + final nextPreview = !replacesPreview ? this.preview : preview as PreviewDocument?; + final nextLiveOutline = !identical(liveOutline, _copyWithUnset) + ? liveOutline as ActiveDocumentOutline? + : replacesPreview + ? null + : this.liveOutline; return WorkspaceState( workspace: workspace ?? this.workspace, activeText: activeText ?? this.activeText, preview: nextPreview, + liveOutline: nextLiveOutline, isDirty: isDirty ?? this.isDirty, isLoading: isLoading ?? this.isLoading, message: clearMessage ? null : message ?? this.message, diff --git a/lib/src/workspace/workspace_safety.dart b/lib/src/workspace/workspace_safety.dart index 6f93f63..85d9ba1 100644 --- a/lib/src/workspace/workspace_safety.dart +++ b/lib/src/workspace/workspace_safety.dart @@ -126,13 +126,14 @@ Future saveActiveWithOverwriteConfirmation( title: context.l10n.fileChangedOnDisk, maxWidth: BusyMarkSizes.dialog, actions: [ - TextButton( + BusyMarkDialogButton( + label: context.l10n.cancel, onPressed: () => Navigator.pop(context, _OverwriteAction.cancel), - child: Text(context.l10n.cancel), ), - FilledButton( + BusyMarkDialogButton( + label: context.l10n.overwrite, + destructive: true, onPressed: () => Navigator.pop(context, _OverwriteAction.overwrite), - child: Text(context.l10n.overwrite), ), ], children: [Text(context.l10n.fileChangedOnDiskMessage)], @@ -207,13 +208,14 @@ Future<_OverwriteAction?> _confirmSaveAsOverwrite( title: context.l10n.warning, maxWidth: BusyMarkSizes.dialog, actions: [ - TextButton( + BusyMarkDialogButton( + label: context.l10n.cancel, onPressed: () => Navigator.pop(context, _OverwriteAction.cancel), - child: Text(context.l10n.cancel), ), - FilledButton( + BusyMarkDialogButton( + label: context.l10n.overwrite, + destructive: true, onPressed: () => Navigator.pop(context, _OverwriteAction.overwrite), - child: Text(context.l10n.overwrite), ), ], children: [Text(context.l10n.errorPathAlreadyExists(savePath))], diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index c6eb01a..b5520d9 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,10 +1,8 @@ #include "my_application.h" -#include #include #include #include -#include #include #include #ifdef GDK_WINDOWING_X11 @@ -16,20 +14,12 @@ constexpr char kApplicationDisplayName[] = "BusyMark"; constexpr char kHeaderBarChannel[] = "com.busymark.app/headerbar"; constexpr gint kHeaderButtonHeight = 32; -constexpr gint kHeaderControlHeight = 34; -constexpr gint kHeaderSearchEntryBorderWidth = 1; -constexpr gint kHeaderSearchEntryContentHeight = - kHeaderButtonHeight - kHeaderSearchEntryBorderWidth * 2; -constexpr gint kHeaderButtonRadius = 8; -constexpr gint kHeaderControlHorizontalPadding = 8; constexpr gint kHeaderButtonSpacing = 8; constexpr gint kHeaderSidebarInset = 8; -constexpr gint kHeaderWindowRadius = 14; -constexpr gint kHeaderWindowControlsBalanceWidth = kHeaderButtonHeight * 3; -constexpr gint kHeaderTooltipVerticalPadding = 5; -constexpr gint kHeaderTooltipHorizontalPadding = 8; -constexpr char kDefaultHeaderbarBackground[] = "#242424"; -constexpr char kDefaultSidebarBackground[] = "#303030"; +constexpr char kDefaultHeaderbarBackground[] = "#272727"; +constexpr char kDefaultSidebarBackground[] = "#393939"; +constexpr char kDefaultForeground[] = "#F7F7F7"; +constexpr char kDefaultPopoverBackground[] = "#3E3E3E"; constexpr char kLtrIsolateStart[] = "\xE2\x81\xA6"; constexpr char kBidiIsolateEnd[] = "\xE2\x81\xA9"; @@ -38,7 +28,6 @@ struct _MyApplication { char** dart_entrypoint_arguments; FlMethodChannel* header_bar_channel; GtkCssProvider* header_bar_css_provider; - GtkCssProvider* gtk_accent_css_provider; GtkWindow* main_window; GtkWidget* flutter_view; GtkWidget* titlebar_box; @@ -48,11 +37,7 @@ struct _MyApplication { GtkWidget* sidebar_title_label; GtkWidget* sidebar_menu_button; GtkWidget* sidebar_menu; - GtkWidget* settings_item; - GtkWidget* keyboard_shortcuts_item; - GtkWidget* markdown_html_item; - GtkWidget* report_issue_item; - GtkWidget* about_item; + GMenu* main_menu_model; GtkWidget* header_start_box; GtkWidget* back_button; GtkWidget* sidebar_toggle_button; @@ -65,24 +50,22 @@ struct _MyApplication { GtkWidget* view_mode_button; GtkWidget* view_mode_icon; GtkWidget* view_mode_menu; - GtkWidget* view_mode_editor_item; - GtkWidget* view_mode_source_item; - GtkWidget* view_mode_preview_item; - GtkWidget* view_mode_split_item; + GMenu* view_mode_menu_model; GtkWidget* refresh_button; + GtkWidget* adaptive_search_button; + GtkWidget* adaptive_menu_button; + GtkWidget* adaptive_menu; + GSimpleActionGroup* header_action_group; + GSimpleAction* view_mode_action; gchar* view_mode; + gchar* search_query; gchar* background_color; gchar* sidebar_background_color; gchar* foreground_color; - gchar* muted_foreground_color; - gchar* disabled_foreground_color; - gchar* control_color; - gchar* control_hover_color; - gchar* accent_color; - gchar* accent_foreground_color; gchar* popover_background_color; gchar* border_color; - gchar* shade_color; + gchar* sidebar_border_color; + gchar* floating_border_color; gchar* modal_barrier_color; gint sidebar_width; gboolean sidebar_visible; @@ -91,6 +74,28 @@ struct _MyApplication { gboolean search_active; gboolean modal_barrier_visible; gboolean suppress_header_actions; + gchar* header_configuration_session_id; + gint64 header_configuration_revision; +}; + +struct HeaderBarConfiguration { + const gchar* session_id; + gint64 revision; + const gchar* title; + const gchar* view_mode; + gboolean can_refresh; + gboolean document_controls_visible; + gboolean search_active; + gboolean search_visible; + gboolean sidebar_visible; + gboolean sidebar_toggle_visible; + gboolean back_visible; + gboolean modal_barrier_visible; + const gchar* search_query; + const gchar* text_direction; + gdouble sidebar_width; + FlValue* labels; + FlValue* theme; }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) @@ -194,21 +199,15 @@ static void set_gtk_theme_preference(gboolean prefer_dark) { g_autofree gchar* theme_name = nullptr; g_object_get(settings, "gtk-theme-name", &theme_name, nullptr); const gchar* fallback = available_gtk_theme_fallback(prefer_dark); - if (fallback != nullptr) { - if (!gtk_theme_exists(theme_name) || - g_strcmp0(theme_name, fallback) != 0) { - g_object_set(settings, "gtk-theme-name", fallback, nullptr); - } + if (fallback != nullptr && !gtk_theme_exists(theme_name)) { + g_object_set(settings, "gtk-theme-name", fallback, nullptr); } g_autofree gchar* icon_theme_name = nullptr; g_object_get(settings, "gtk-icon-theme-name", &icon_theme_name, nullptr); const gchar* icon_fallback = available_icon_theme_fallback(prefer_dark); - if (icon_fallback != nullptr) { - if (!icon_theme_exists(icon_theme_name) || - g_strcmp0(icon_theme_name, icon_fallback) != 0) { - g_object_set(settings, "gtk-icon-theme-name", icon_fallback, nullptr); - } + if (icon_fallback != nullptr && !icon_theme_exists(icon_theme_name)) { + g_object_set(settings, "gtk-icon-theme-name", icon_fallback, nullptr); } } } @@ -223,6 +222,17 @@ static void respond_bool(FlMethodCall* method_call, gboolean value) { fl_method_call_respond_success(method_call, result, nullptr); } +static void respond_int64(FlMethodCall* method_call, gint64 value) { + g_autoptr(FlValue) result = fl_value_new_int(value); + fl_method_call_respond_success(method_call, result, nullptr); +} + +static void respond_invalid_configuration(FlMethodCall* method_call, + const gchar* message) { + fl_method_call_respond_error(method_call, "invalid-header-configuration", + message, nullptr, nullptr); +} + static const gchar* fl_method_string_arg(FlValue* args) { return args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_STRING ? fl_value_get_string(args) @@ -274,6 +284,51 @@ static gboolean fl_lookup_optional_bool_arg(FlValue* args, return TRUE; } +static gboolean fl_lookup_int64_arg(FlValue* args, + const gchar* key, + gint64* value_out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr || fl_value_get_type(value) != FL_VALUE_TYPE_INT) { + return FALSE; + } + *value_out = fl_value_get_int(value); + return TRUE; +} + +static gboolean fl_lookup_double_arg(FlValue* args, + const gchar* key, + gdouble* value_out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr) { + return FALSE; + } + if (fl_value_get_type(value) == FL_VALUE_TYPE_FLOAT) { + *value_out = fl_value_get_float(value); + return TRUE; + } + if (fl_value_get_type(value) == FL_VALUE_TYPE_INT) { + *value_out = static_cast(fl_value_get_int(value)); + return TRUE; + } + return FALSE; +} + +static FlValue* fl_lookup_map_arg(FlValue* args, const gchar* key) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return nullptr; + } + FlValue* value = fl_value_lookup_string(args, key); + return value != nullptr && fl_value_get_type(value) == FL_VALUE_TYPE_MAP + ? value + : nullptr; +} + static gboolean has_header_bar(MyApplication* self) { return self->header_bar != nullptr && GTK_IS_HEADER_BAR(self->header_bar); } @@ -315,16 +370,53 @@ static const gchar* css_color_or(const gchar* value, const gchar* fallback) { return is_css_color_token(value) ? value : fallback; } -static void set_css_color_field(gchar** target, const gchar* value) { - if (!is_css_color_token(value)) { - return; - } +static void replace_css_color_field(gchar** target, const gchar* value) { g_free(*target); - *target = g_strdup(value); + *target = is_css_color_token(value) ? g_strdup(value) : nullptr; +} + +static GdkRGBA composite_rgba(const GdkRGBA& foreground, + const GdkRGBA& background) { + const gdouble inverse_foreground_alpha = 1.0 - foreground.alpha; + const gdouble alpha = + foreground.alpha + background.alpha * inverse_foreground_alpha; + if (alpha <= 0) { + return GdkRGBA{0, 0, 0, 0}; + } + return GdkRGBA{ + (foreground.red * foreground.alpha + + background.red * background.alpha * inverse_foreground_alpha) / + alpha, + (foreground.green * foreground.alpha + + background.green * background.alpha * inverse_foreground_alpha) / + alpha, + (foreground.blue * foreground.alpha + + background.blue * background.alpha * inverse_foreground_alpha) / + alpha, + alpha, + }; +} + +static gchar* modal_sidebar_border_css_color(const gchar* border_color, + const gchar* sidebar_color, + const gchar* barrier_color) { + GdkRGBA border; + GdkRGBA sidebar; + GdkRGBA barrier; + if (!gdk_rgba_parse(&border, border_color) || + !gdk_rgba_parse(&sidebar, sidebar_color) || + !gdk_rgba_parse(&barrier, barrier_color)) { + return g_strdup(border_color); + } + + const GdkRGBA visible_border = composite_rgba(border, sidebar); + const GdkRGBA dimmed_border = composite_rgba(barrier, visible_border); + return gdk_rgba_to_string(&dimmed_border); } static void set_widget_visible(GtkWidget* widget, gboolean visible) { if (widget != nullptr && GTK_IS_WIDGET(widget)) { + gtk_widget_set_no_show_all(widget, !visible); gtk_widget_set_visible(widget, visible); } } @@ -354,37 +446,6 @@ static void set_widget_horizontal_margins(GtkWidget* widget, } } -static void update_header_menu_item_direction(GtkWidget* item, - GtkTextDirection direction) { - if (item == nullptr || !GTK_IS_WIDGET(item)) { - return; - } - set_widget_direction(item, direction); - GtkWidget* label = static_cast( - g_object_get_data(G_OBJECT(item), "busymark-label-widget")); - if (label != nullptr && GTK_IS_LABEL(label)) { - set_widget_direction(label, direction); - gtk_label_set_xalign(GTK_LABEL(label), - direction == GTK_TEXT_DIR_RTL ? 1.0 : 0.0); - } - GtkWidget* shortcut = static_cast( - g_object_get_data(G_OBJECT(item), "busymark-shortcut-widget")); - if (shortcut != nullptr && GTK_IS_LABEL(shortcut)) { - set_widget_direction(shortcut, GTK_TEXT_DIR_LTR); - gtk_label_set_xalign(GTK_LABEL(shortcut), - direction == GTK_TEXT_DIR_RTL ? 0.0 : 1.0); - } -} - -static void update_title_stack_alignment(MyApplication* self) { - if (self->title_stack == nullptr || !GTK_IS_WIDGET(self->title_stack)) { - return; - } - set_widget_horizontal_margins( - self->title_stack, - self->search_active ? 0 : kHeaderWindowControlsBalanceWidth, 0); -} - static void set_toggle_button_active(MyApplication* self, GtkWidget* widget, gboolean active) { @@ -397,6 +458,13 @@ static void set_toggle_button_active(MyApplication* self, self->suppress_header_actions = previous; } +static void update_adaptive_header_actions(MyApplication* self) { + const gboolean use_main_header = !self->sidebar_visible; + set_widget_visible(self->adaptive_search_button, + use_main_header && self->search_visible); + set_widget_visible(self->adaptive_menu_button, use_main_header); +} + static void update_sidebar_header_geometry(MyApplication* self) { if (self->sidebar_header_box == nullptr || !GTK_IS_WIDGET(self->sidebar_header_box)) { @@ -405,6 +473,7 @@ static void update_sidebar_header_geometry(MyApplication* self) { const gint width = self->sidebar_visible ? self->sidebar_width : 0; gtk_widget_set_size_request(self->sidebar_header_box, width, -1); set_widget_visible(self->sidebar_header_box, width > 0); + update_adaptive_header_actions(self); } static void update_titlebar_direction(MyApplication* self) { @@ -438,23 +507,16 @@ static void update_titlebar_direction(MyApplication* self) { set_widget_direction(self->view_mode_button, direction); set_widget_direction(self->view_mode_icon, direction); set_widget_direction(self->refresh_button, direction); + set_widget_direction(self->adaptive_search_button, direction); + set_widget_direction(self->adaptive_menu_button, direction); + set_widget_direction(self->adaptive_menu, direction); set_widget_direction(self->sidebar_search_button, direction); set_widget_direction(self->sidebar_menu_button, direction); set_widget_direction(self->sidebar_menu, direction); - update_header_menu_item_direction(self->settings_item, direction); - update_header_menu_item_direction(self->keyboard_shortcuts_item, direction); - update_header_menu_item_direction(self->markdown_html_item, direction); - update_header_menu_item_direction(self->report_issue_item, direction); - update_header_menu_item_direction(self->about_item, direction); set_widget_direction(self->view_mode_menu, direction); - update_header_menu_item_direction(self->view_mode_editor_item, direction); - update_header_menu_item_direction(self->view_mode_source_item, direction); - update_header_menu_item_direction(self->view_mode_preview_item, direction); - update_header_menu_item_direction(self->view_mode_split_item, direction); // GTK 3 resolves logical margins against the widget direction at setter // time, so reapply both sides after a live LTR/RTL direction change. - update_title_stack_alignment(self); set_widget_horizontal_margins(self->sidebar_search_button, kHeaderSidebarInset, 0); set_widget_horizontal_margins(self->sidebar_menu_button, 0, @@ -473,32 +535,19 @@ static void refresh_header_bar_css(MyApplication* self) { const gchar* sidebar_background = css_color_or(self->sidebar_background_color, kDefaultSidebarBackground); const gchar* foreground = - css_color_or(self->foreground_color, "rgba(255,255,255,0.92)"); - const gchar* muted = - css_color_or(self->muted_foreground_color, "rgba(255,255,255,0.70)"); - const gchar* control = - css_color_or(self->control_color, "rgba(255,255,255,0.10)"); - const gchar* control_hover = - css_color_or(self->control_hover_color, "rgba(255,255,255,0.14)"); - const gchar* accent = css_color_or(self->accent_color, "#3584e4"); - const gchar* popover = - css_color_or(self->popover_background_color, background); + css_color_or(self->foreground_color, kDefaultForeground); + const gchar* popover_background = css_color_or( + self->popover_background_color, kDefaultPopoverBackground); const gchar* border = css_color_or(self->border_color, "rgba(255,255,255,0.10)"); - const gchar* shade = css_color_or(self->shade_color, "rgba(0,0,0,0.28)"); + const gchar* sidebar_border = + css_color_or(self->sidebar_border_color, border); + const gchar* floating_border = + css_color_or(self->floating_border_color, border); const gchar* modal = css_color_or(self->modal_barrier_color, "rgba(0,0,0,0.32)"); - const gint headerbar_left_radius = - self->sidebar_visible && !self->text_direction_rtl - ? 0 - : kHeaderWindowRadius; - const gint headerbar_right_radius = - self->sidebar_visible && self->text_direction_rtl ? 0 - : kHeaderWindowRadius; - const gint sidebar_left_radius = - self->text_direction_rtl ? 0 : kHeaderWindowRadius; - const gint sidebar_right_radius = - self->text_direction_rtl ? kHeaderWindowRadius : 0; + g_autofree gchar* modal_sidebar_border = + modal_sidebar_border_css_color(sidebar_border, sidebar_background, modal); gtk_style_context_add_class(gtk_widget_get_style_context(self->titlebar_box), "busymark-titlebar"); @@ -509,35 +558,28 @@ static void refresh_header_bar_css(MyApplication* self) { g_autofree gchar* css = g_strdup_printf( "window#busymark-window," "window#busymark-window:backdrop {" - "background-color: transparent;" + "background-color: %s;" "background-image: none;" "}" "window#busymark-window decoration," "window#busymark-window decoration:backdrop {" - "background-color: transparent;" - "background-image: none;" - "border: none;" - "outline: none;" - "border-radius: %dpx;" - "box-shadow: 0 2px 10px 0 %s;" + "border-color: %s;" "}" ".busymark-titlebar," ".busymark-titlebar:backdrop {" "background-color: %s;" "background-image: none;" + "color: %s;" "border: none;" "box-shadow: none;" - "border-top-left-radius: %dpx;" - "border-top-right-radius: %dpx;" "}" "headerbar.busymark-headerbar," "headerbar.busymark-headerbar:backdrop {" "background-color: %s;" "background-image: none;" + "color: %s;" "border: none;" "box-shadow: none;" - "border-top-left-radius: %dpx;" - "border-top-right-radius: %dpx;" "}" "headerbar.busymark-headerbar:dir(ltr) {" "padding-left: 0;" @@ -548,118 +590,80 @@ static void refresh_header_bar_css(MyApplication* self) { ".busymark-sidebar-header {" "background-color: %s;" "background-image: none;" + "color: %s;" "border: none;" "box-shadow: none;" - "border-top-left-radius: %dpx;" - "border-top-right-radius: %dpx;" "}" - ".busymark-sidebar-header label," - ".busymark-header-title {" - "color: %s;" + ".busymark-sidebar-header:dir(ltr) {" + "border-right: 1px solid %s;" "}" - ".busymark-titlebar entry.busymark-search-entry," - ".busymark-titlebar entry.busymark-search-entry:backdrop {" - "color: %s;" - "background-color: %s;" - "background-image: none;" - "border: 1px solid %s;" - "box-shadow: none;" - "text-shadow: none;" - "min-height: %dpx;" - "border-radius: %dpx;" - "padding: 0 %dpx;" + ".busymark-sidebar-header:dir(rtl) {" + "border-left: 1px solid %s;" "}" - ".busymark-titlebar entry.busymark-search-entry:focus {" - "border-color: %s;" + // Legacy Yaru GTK 3 uses an absolute near-black image for active and + // checked buttons. BusyMark-owned controls use neutral current-color + // layers while GTK continues to own geometry, focus, and motion. + ".busymark-titlebar " + ".busymark-header-control:not(.suggested-action):not(:disabled) {" + "background-color: transparent;" + "background-image: none;" + "border-color: transparent;" "box-shadow: none;" "}" - ".busymark-titlebar.busymark-modal-barrier," - ".busymark-titlebar.busymark-modal-barrier " - "headerbar.busymark-headerbar," - ".busymark-titlebar.busymark-modal-barrier " - "headerbar.busymark-headerbar:backdrop," - ".busymark-titlebar.busymark-modal-barrier .busymark-sidebar-header {" - "background-image: linear-gradient(%s, %s);" + ".busymark-titlebar " + ".busymark-header-control:not(.suggested-action):not(:disabled):hover {" + "background-color: alpha(currentColor, 0.07);" + "background-image: none;" "}" - "popover.busymark-header-popover," - "popover.background.busymark-header-popover," - "popover.background.busymark-header-popover > contents," - "popover.background.busymark-header-popover arrow {" - "background-color: %s;" - "color: %s;" + ".busymark-titlebar " + ".busymark-header-control:not(.suggested-action):not(:disabled):active {" + "background-color: alpha(currentColor, 0.16);" + "background-image: none;" "}" - "popover.busymark-header-popover > contents {" - "border: 1px solid %s;" - "box-shadow: 0 6px 18px %s;" + ".busymark-titlebar " + ".busymark-header-control:not(.suggested-action):not(:disabled):checked {" + "background-color: alpha(currentColor, 0.10);" + "background-image: none;" "}" - "popover.busymark-header-popover button.busymark-menu-row {" - "color: %s;" - "background-color: transparent;" + ".busymark-titlebar " + ".busymark-header-control:not(.suggested-action):" + "not(:disabled):checked:hover {" + "background-color: alpha(currentColor, 0.13);" "background-image: none;" - "border: none;" - "border-width: 0;" - "border-color: transparent;" - "box-shadow: none;" - "text-shadow: none;" - "outline-style: none;" - "outline-width: 0;" - "outline-offset: 0;" - "transition: none;" - "min-height: %dpx;" - "padding: 0 %dpx;" - "border-radius: %dpx;" "}" - "popover.busymark-header-popover button.busymark-menu-row:focus," - "popover.busymark-header-popover button.busymark-menu-row:active," - "popover.busymark-header-popover button.busymark-menu-row:checked {" - "border: none;" - "border-width: 0;" - "border-color: transparent;" - "box-shadow: none;" - "outline-style: none;" - "outline-width: 0;" - "outline-offset: 0;" - "text-shadow: none;" + ".busymark-titlebar " + ".busymark-header-control:not(.suggested-action):" + "not(:disabled):checked:active {" + "background-color: alpha(currentColor, 0.19);" + "background-image: none;" "}" - "popover.busymark-header-popover button.busymark-menu-row:hover {" + "popover.background.busymark-header-popover," + "popover.background.busymark-header-popover:backdrop {" "background-color: %s;" - "}" - "popover.busymark-header-popover button.busymark-menu-row label {" - "color: %s;" - "}" - "popover.busymark-header-popover button.busymark-menu-row image {" + "background-image: none;" + "border-color: %s;" "color: %s;" "}" - "tooltip, tooltip.background {" - "margin: 0;" - "padding: 0;" - "min-height: 0;" - "border-radius: %dpx;" + ".busymark-titlebar.busymark-modal-barrier," + ".busymark-titlebar.busymark-modal-barrier " + "headerbar.busymark-headerbar," + ".busymark-titlebar.busymark-modal-barrier " + "headerbar.busymark-headerbar:backdrop," + ".busymark-titlebar.busymark-modal-barrier .busymark-sidebar-header {" + "background-image: linear-gradient(%s, %s);" "}" - "tooltip > box, tooltip.background > box {" - "margin: 0;" - "padding: 0;" - "min-height: 0;" + ".busymark-titlebar.busymark-modal-barrier " + ".busymark-sidebar-header:dir(ltr) {" + "border-right-color: %s;" "}" - "tooltip label {" - "margin: 0;" - "padding: %dpx %dpx;" - "min-height: 0;" - "border-radius: %dpx;" + ".busymark-titlebar.busymark-modal-barrier " + ".busymark-sidebar-header:dir(rtl) {" + "border-left-color: %s;" "}", - kHeaderWindowRadius, shade, background, kHeaderWindowRadius, - kHeaderWindowRadius, background, headerbar_left_radius, - headerbar_right_radius, - sidebar_background, - sidebar_left_radius, sidebar_right_radius, foreground, foreground, control, border, - kHeaderSearchEntryContentHeight, kHeaderButtonRadius, - kHeaderControlHorizontalPadding, accent, modal, modal, - popover, foreground, border, shade, foreground, - kHeaderControlHeight, kHeaderControlHorizontalPadding, - kHeaderButtonRadius, control_hover, foreground, muted, - kHeaderButtonRadius, - kHeaderTooltipVerticalPadding, kHeaderTooltipHorizontalPadding, - kHeaderButtonRadius); + background, floating_border, background, foreground, background, + foreground, sidebar_background, foreground, sidebar_border, + sidebar_border, popover_background, floating_border, foreground, modal, + modal, modal_sidebar_border, modal_sidebar_border); g_autoptr(GError) error = nullptr; GtkCssProvider* provider = gtk_css_provider_new(); @@ -682,85 +686,6 @@ static void refresh_header_bar_css(MyApplication* self) { GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); } -static void refresh_gtk_accent_css(MyApplication* self) { - const gchar* accent = css_color_or(self->accent_color, "#3584e4"); - const gchar* accent_foreground = - css_color_or(self->accent_foreground_color, "#ffffff"); - - g_autofree gchar* css = g_strdup_printf( - "@define-color theme_selected_bg_color %s;" - "@define-color theme_selected_fg_color %s;" - "@define-color theme_unfocused_selected_bg_color %s;" - "@define-color theme_unfocused_selected_fg_color %s;" - "@define-color accent_bg_color %s;" - "@define-color accent_fg_color %s;" - "treeview.view:selected," - "treeview.view:selected:focus," - "iconview:selected," - "iconview:selected:focus," - "row:selected," - "row:selected:focus," - "rubberband," - ".rubberband {" - "background-color: %s;" - "color: %s;" - "}" - "treeview.view:selected *," - "iconview:selected *," - "row:selected * {" - "color: %s;" - "}" - "button.suggested-action," - "button.suggested-action:hover," - "button.suggested-action:active," - "button.suggested-action:checked {" - "background-color: %s;" - "background-image: none;" - "border-color: %s;" - "color: %s;" - "}" - "button.suggested-action label," - "button.suggested-action image {" - "color: %s;" - "}" - "checkbutton check:checked," - "radiobutton radio:checked," - "switch:checked," - "scale trough highlight," - "progressbar progress {" - "background-color: %s;" - "background-image: none;" - "border-color: %s;" - "}", - accent, accent_foreground, accent, accent_foreground, accent, - accent_foreground, accent, accent_foreground, accent_foreground, accent, - accent, accent_foreground, accent_foreground, accent, accent); - - g_autoptr(GError) error = nullptr; - GtkCssProvider* provider = gtk_css_provider_new(); - gtk_css_provider_load_from_data(provider, css, -1, &error); - if (error != nullptr) { - g_warning("Failed to load BusyMark GTK accent CSS: %s", error->message); - g_object_unref(provider); - return; - } - - GdkScreen* screen = gdk_screen_get_default(); - if (screen == nullptr) { - g_object_unref(provider); - return; - } - if (self->gtk_accent_css_provider != nullptr) { - gtk_style_context_remove_provider_for_screen( - screen, GTK_STYLE_PROVIDER(self->gtk_accent_css_provider)); - g_clear_object(&self->gtk_accent_css_provider); - } - self->gtk_accent_css_provider = provider; - gtk_style_context_add_provider_for_screen( - screen, GTK_STYLE_PROVIDER(self->gtk_accent_css_provider), - GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + 1); -} - static void set_header_bar_theme(MyApplication* self, FlValue* args) { if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { return; @@ -769,33 +694,26 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { if (fl_lookup_optional_bool_arg(args, "preferDark", &prefer_dark)) { set_gtk_theme_preference(prefer_dark); } - set_css_color_field(&self->background_color, - fl_lookup_string_arg(args, "backgroundColor")); - set_css_color_field(&self->sidebar_background_color, - fl_lookup_string_arg(args, "sidebarBackgroundColor")); - set_css_color_field(&self->foreground_color, - fl_lookup_string_arg(args, "foregroundColor")); - set_css_color_field(&self->muted_foreground_color, - fl_lookup_string_arg(args, "mutedForegroundColor")); - set_css_color_field(&self->disabled_foreground_color, - fl_lookup_string_arg(args, "disabledForegroundColor")); - set_css_color_field(&self->control_color, - fl_lookup_string_arg(args, "controlColor")); - set_css_color_field(&self->control_hover_color, - fl_lookup_string_arg(args, "controlHoverColor")); - set_css_color_field(&self->accent_color, - fl_lookup_string_arg(args, "accentColor")); - set_css_color_field(&self->accent_foreground_color, - fl_lookup_string_arg(args, "accentForegroundColor")); - set_css_color_field(&self->popover_background_color, - fl_lookup_string_arg(args, "popoverBackgroundColor")); - set_css_color_field(&self->border_color, - fl_lookup_string_arg(args, "borderColor")); - set_css_color_field(&self->shade_color, - fl_lookup_string_arg(args, "shadeColor")); - set_css_color_field(&self->modal_barrier_color, - fl_lookup_string_arg(args, "modalBarrierColor")); - refresh_gtk_accent_css(self); + replace_css_color_field(&self->background_color, + fl_lookup_string_arg(args, "backgroundColor")); + replace_css_color_field( + &self->sidebar_background_color, + fl_lookup_string_arg(args, "sidebarBackgroundColor")); + replace_css_color_field(&self->foreground_color, + fl_lookup_string_arg(args, "foregroundColor")); + replace_css_color_field( + &self->popover_background_color, + fl_lookup_string_arg(args, "popoverBackgroundColor")); + replace_css_color_field(&self->border_color, + fl_lookup_string_arg(args, "borderColor")); + replace_css_color_field( + &self->sidebar_border_color, + fl_lookup_string_arg(args, "sidebarBorderColor")); + replace_css_color_field( + &self->floating_border_color, + fl_lookup_string_arg(args, "floatingBorderColor")); + replace_css_color_field(&self->modal_barrier_color, + fl_lookup_string_arg(args, "modalBarrierColor")); refresh_header_bar_css(self); } @@ -825,6 +743,86 @@ static void invoke_header_bar_string_action(MyApplication* self, nullptr, nullptr, nullptr); } +static void invoke_header_bar_bool_action(MyApplication* self, + const gchar* action, + gboolean value) { + if (self->header_bar_channel == nullptr || action == nullptr) { + return; + } + g_autoptr(FlValue) args = fl_value_new_bool(value); + fl_method_channel_invoke_method(self->header_bar_channel, action, args, + nullptr, nullptr, nullptr); +} + +enum class SearchQueryUpdateDisposition { + kAlreadyCurrent, + kPreserveNativeText, + kApplyDartSnapshot, +}; + +constexpr SearchQueryUpdateDisposition resolve_search_query_update( + bool queries_match, + bool native_entry_has_authority) { + if (queries_match) { + return SearchQueryUpdateDisposition::kAlreadyCurrent; + } + return native_entry_has_authority + ? SearchQueryUpdateDisposition::kPreserveNativeText + : SearchQueryUpdateDisposition::kApplyDartSnapshot; +} + +static_assert( + resolve_search_query_update(false, true) == + SearchQueryUpdateDisposition::kPreserveNativeText, + "A newer focused native edit must survive a delayed Dart snapshot"); +static_assert( + resolve_search_query_update(false, false) == + SearchQueryUpdateDisposition::kApplyDartSnapshot, + "Dart owns search text while the native entry is not being edited"); + +static void cache_search_query(MyApplication* self, const gchar* query) { + const gchar* normalized_query = query == nullptr ? "" : query; + if (g_strcmp0(self->search_query, normalized_query) == 0) { + return; + } + g_free(self->search_query); + self->search_query = g_strdup(normalized_query); +} + +static void set_search_query(MyApplication* self, const gchar* query) { + const gchar* normalized_query = query == nullptr ? "" : query; + if (self->search_entry == nullptr || !GTK_IS_ENTRY(self->search_entry)) { + cache_search_query(self, normalized_query); + return; + } + const gchar* current_query = + gtk_entry_get_text(GTK_ENTRY(self->search_entry)); + const bool native_entry_has_authority = + self->search_active && gtk_widget_has_focus(self->search_entry); + switch (resolve_search_query_update( + g_strcmp0(current_query, normalized_query) == 0, + native_entry_has_authority)) { + case SearchQueryUpdateDisposition::kAlreadyCurrent: + cache_search_query(self, normalized_query); + return; + case SearchQueryUpdateDisposition::kPreserveNativeText: + // GtkSearchEntry emits search-changed after a short delay. Dart can + // therefore publish an older mirrored snapshot after the user has + // already typed more text. While the active entry has focus, native + // text is authoritative. Do not update the cache here: a pending + // search-changed signal still needs to publish the newer native text. + return; + case SearchQueryUpdateDisposition::kApplyDartSnapshot: + break; + } + + cache_search_query(self, normalized_query); + const gboolean previous_suppression = self->suppress_header_actions; + self->suppress_header_actions = TRUE; + gtk_entry_set_text(GTK_ENTRY(self->search_entry), normalized_query); + self->suppress_header_actions = previous_suppression; +} + static void header_button_clicked_cb(GtkWidget* widget, gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); if (self->suppress_header_actions) { @@ -845,101 +843,97 @@ static void connect_header_action(MyApplication* self, self); } -static void search_entry_changed_cb(GtkEditable* editable, gpointer user_data) { +static void search_entry_changed_cb(GtkSearchEntry* entry, + gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_actions) { + if (self->suppress_header_actions || !self->search_active) { + return; + } + const gchar* query = gtk_entry_get_text(GTK_ENTRY(entry)); + if (g_strcmp0(self->search_query, query) == 0) { return; } - const gchar* text = gtk_entry_get_text(GTK_ENTRY(editable)); - invoke_header_bar_string_action(self, "searchQueryChanged", text); + cache_search_query(self, query); + invoke_header_bar_string_action(self, "searchQueryChanged", query); } static void search_entry_activate_cb(GtkEntry* entry, gpointer user_data) { - focus_flutter_view(MY_APPLICATION(user_data)); + MyApplication* self = MY_APPLICATION(user_data); + if (self->suppress_header_actions || !self->search_active) { + return; + } + invoke_header_bar_string_action(self, "searchSubmitted", + gtk_entry_get_text(entry)); + focus_flutter_view(self); } -static gboolean search_entry_key_press_cb(GtkWidget* widget, - GdkEventKey* event, +static gboolean search_entry_focus_in_cb(GtkWidget*, + GdkEventFocus*, + gpointer user_data) { + invoke_header_bar_bool_action(MY_APPLICATION(user_data), + "searchFocusChanged", TRUE); + return FALSE; +} + +static gboolean search_entry_focus_out_cb(GtkWidget*, + GdkEventFocus*, gpointer user_data) { - if (event != nullptr && event->keyval == GDK_KEY_Escape) { - MyApplication* self = MY_APPLICATION(user_data); - invoke_header_bar_action(self, "search"); - return TRUE; - } + invoke_header_bar_bool_action(MY_APPLICATION(user_data), + "searchFocusChanged", FALSE); return FALSE; } +static void search_entry_icon_release_cb(GtkEntry* entry, + GtkEntryIconPosition icon_position, + GdkEvent*, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (self->suppress_header_actions || !self->search_active || + icon_position != GTK_ENTRY_ICON_SECONDARY || + gtk_entry_get_text(entry)[0] == '\0') { + return; + } + + // GtkSearchEntry clears the entry after this signal. Cache the semantic + // result now so its subsequent search-changed signal is deduplicated. + cache_search_query(self, ""); + invoke_header_bar_action(self, "searchCleared"); +} + +static void search_entry_stop_search_cb(GtkSearchEntry*, gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (self->suppress_header_actions || !self->search_active) { + return; + } + invoke_header_bar_action(self, "searchEscapePressed"); +} + static void make_icon_button_square(GtkWidget* button) { gtk_widget_set_size_request(button, kHeaderButtonHeight, kHeaderButtonHeight); gtk_widget_set_valign(button, GTK_ALIGN_CENTER); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - "busymark-header-icon-button"); } static GtkWidget* create_header_icon_button(const gchar* icon_name) { GtkWidget* button = gtk_button_new(); + gtk_style_context_add_class(gtk_widget_get_style_context(button), + "busymark-header-control"); GtkWidget* image = gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU); gtk_button_set_image(GTK_BUTTON(button), image); - gtk_button_set_relief(GTK_BUTTON(button), GTK_RELIEF_NONE); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - "busymark-header-button"); make_icon_button_square(button); return button; } static GtkWidget* create_header_toggle_button(const gchar* icon_name) { GtkWidget* button = gtk_toggle_button_new(); + gtk_style_context_add_class(gtk_widget_get_style_context(button), + "busymark-header-control"); GtkWidget* image = gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU); gtk_button_set_image(GTK_BUTTON(button), image); - gtk_button_set_relief(GTK_BUTTON(button), GTK_RELIEF_NONE); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - "busymark-header-button"); make_icon_button_square(button); return button; } -static GtkWidget* create_header_popover() { - GtkWidget* popover = gtk_popover_menu_new(); - gtk_popover_set_position(GTK_POPOVER(popover), GTK_POS_BOTTOM); - gtk_style_context_add_class(gtk_widget_get_style_context(popover), - "busymark-header-popover"); - return popover; -} - -static GtkWidget* create_popover_box(GtkWidget* popover) { - GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_widget_set_margin_top(box, kHeaderButtonSpacing); - gtk_widget_set_margin_bottom(box, kHeaderButtonSpacing); - gtk_widget_set_margin_start(box, kHeaderButtonSpacing); - gtk_widget_set_margin_end(box, kHeaderButtonSpacing); - gtk_container_add(GTK_CONTAINER(popover), box); - return box; -} - -static void close_menu_button(GtkWidget* button) { - if (button == nullptr || !GTK_IS_MENU_BUTTON(button)) { - return; - } - GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(button)); - if (popover != nullptr && GTK_IS_POPOVER(popover)) { - gtk_popover_popdown(popover); - } -} - -static void menu_item_clicked_cb(GtkWidget* widget, gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - const gchar* action = static_cast( - g_object_get_data(G_OBJECT(widget), "busymark-action")); - close_menu_button(self->sidebar_menu_button); - focus_flutter_view(self); - invoke_header_bar_action(self, action); -} - static const gchar* main_menu_icon_name(const gchar* action) { if (g_strcmp0(action, "settings") == 0) { return "preferences-system-symbolic"; @@ -959,43 +953,63 @@ static const gchar* main_menu_icon_name(const gchar* action) { return "open-menu-symbolic"; } -static GtkWidget* create_menu_item(MyApplication* self, const gchar* action) { - GtkWidget* item = gtk_button_new(); - GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - GtkWidget* icon = - gtk_image_new_from_icon_name(main_menu_icon_name(action), - GTK_ICON_SIZE_MENU); - GtkWidget* label = gtk_label_new(""); - GtkWidget* shortcut = gtk_label_new(""); - gtk_widget_set_valign(box, GTK_ALIGN_CENTER); - gtk_widget_set_valign(icon, GTK_ALIGN_CENTER); - gtk_widget_set_valign(label, GTK_ALIGN_CENTER); - gtk_widget_set_valign(shortcut, GTK_ALIGN_CENTER); - gtk_label_set_xalign(GTK_LABEL(label), 0.0); - gtk_label_set_xalign(GTK_LABEL(shortcut), 1.0); - gtk_widget_set_hexpand(label, TRUE); - gtk_style_context_add_class(gtk_widget_get_style_context(shortcut), - "dim-label"); - gtk_box_pack_start(GTK_BOX(box), icon, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(box), label, TRUE, TRUE, 0); - gtk_box_pack_start(GTK_BOX(box), shortcut, FALSE, FALSE, 0); - gtk_container_add(GTK_CONTAINER(item), box); - gtk_button_set_relief(GTK_BUTTON(item), GTK_RELIEF_NONE); - gtk_widget_set_halign(item, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(item, TRUE); - gtk_style_context_add_class(gtk_widget_get_style_context(item), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(item), - "busymark-menu-row"); - g_object_set_data(G_OBJECT(item), "busymark-label-widget", label); - g_object_set_data(G_OBJECT(item), "busymark-shortcut-widget", shortcut); - g_object_set_data_full(G_OBJECT(item), "busymark-action", - g_strdup(action), g_free); - g_signal_connect(item, "clicked", G_CALLBACK(menu_item_clicked_cb), self); - return item; -} - -static const gchar* view_mode_action(const gchar* mode) { +static const gchar* localized_label_or(FlValue* labels, + const gchar* key, + const gchar* fallback) { + const gchar* value = fl_lookup_string_arg(labels, key); + return value != nullptr ? value : fallback; +} + +static void append_action_menu_item(GMenu* menu, + const gchar* label, + const gchar* action, + const gchar* icon_name, + const gchar* shortcut) { + GMenuItem* item = g_menu_item_new(label, action); + if (icon_name != nullptr) { + GIcon* icon = g_themed_icon_new(icon_name); + g_menu_item_set_icon(item, icon); + g_object_unref(icon); + } + if (shortcut != nullptr && shortcut[0] != '\0') { + g_menu_item_set_attribute(item, "accel", "s", shortcut); + } + g_menu_append_item(menu, item); + g_object_unref(item); +} + +static void rebuild_main_menu_model(MyApplication* self, FlValue* labels) { + if (self->main_menu_model == nullptr) { + return; + } + g_menu_remove_all(self->main_menu_model); + append_action_menu_item( + self->main_menu_model, + localized_label_or(labels, "settings", ""), "header.settings", + main_menu_icon_name("settings"), + fl_lookup_string_arg(labels, "settingsShortcut")); + append_action_menu_item( + self->main_menu_model, + localized_label_or(labels, "keyboardShortcuts", ""), + "header.keyboard-shortcuts", + main_menu_icon_name("keyboardShortcuts"), + fl_lookup_string_arg(labels, "keyboardShortcutsShortcut")); + append_action_menu_item( + self->main_menu_model, + localized_label_or(labels, "markdownAndHtml", ""), + "header.markdown-and-html", main_menu_icon_name("markdownAndHtml"), + fl_lookup_string_arg(labels, "markdownAndHtmlShortcut")); + append_action_menu_item( + self->main_menu_model, + localized_label_or(labels, "reportIssue", ""), + "header.report-issue", main_menu_icon_name("reportIssue"), nullptr); + append_action_menu_item( + self->main_menu_model, + localized_label_or(labels, "aboutBusyMark", ""), + "header.about", main_menu_icon_name("aboutBusyMark"), nullptr); +} + +static const gchar* view_mode_dart_action(const gchar* mode) { if (g_strcmp0(mode, "editor") == 0) { return "viewModeEditor"; } @@ -1027,40 +1041,6 @@ static const gchar* view_mode_icon_name(const gchar* mode) { return "view-dual-symbolic"; } -static void set_menu_item_label(GtkWidget* item, const gchar* text) { - if (item == nullptr || text == nullptr) { - return; - } - GtkWidget* label = static_cast( - g_object_get_data(G_OBJECT(item), "busymark-label-widget")); - if (label != nullptr && GTK_IS_LABEL(label)) { - gtk_label_set_text(GTK_LABEL(label), text); - } -} - -static void set_menu_item_shortcut(GtkWidget* item, const gchar* shortcut) { - if (item == nullptr) { - return; - } - GtkWidget* label = static_cast( - g_object_get_data(G_OBJECT(item), "busymark-shortcut-widget")); - if (label != nullptr && GTK_IS_LABEL(label)) { - gtk_label_set_text(GTK_LABEL(label), shortcut != nullptr ? shortcut : ""); - gtk_widget_set_visible(label, shortcut != nullptr && shortcut[0] != '\0'); - } -} - -static void set_menu_item_checked(GtkWidget* item, gboolean checked) { - if (item == nullptr) { - return; - } - GtkWidget* check = static_cast( - g_object_get_data(G_OBJECT(item), "busymark-check-widget")); - if (check != nullptr && GTK_IS_WIDGET(check)) { - gtk_widget_set_opacity(check, checked ? 1.0 : 0.0); - } -} - static void update_view_mode_icon(MyApplication* self) { if (self->view_mode_icon == nullptr || !GTK_IS_IMAGE(self->view_mode_icon)) { @@ -1072,92 +1052,145 @@ static void update_view_mode_icon(MyApplication* self) { } static void set_view_mode(MyApplication* self, const gchar* mode) { - if (view_mode_action(mode) == nullptr) { + if (view_mode_dart_action(mode) == nullptr) { return; } g_free(self->view_mode); self->view_mode = g_strdup(mode); - set_menu_item_checked(self->view_mode_editor_item, - g_strcmp0(mode, "editor") == 0); - set_menu_item_checked(self->view_mode_source_item, - g_strcmp0(mode, "source") == 0); - set_menu_item_checked(self->view_mode_preview_item, - g_strcmp0(mode, "preview") == 0); - set_menu_item_checked(self->view_mode_split_item, - g_strcmp0(mode, "split") == 0); + if (self->view_mode_action != nullptr) { + g_simple_action_set_state(self->view_mode_action, + g_variant_new_string(mode)); + } update_view_mode_icon(self); } -static void view_mode_clicked_cb(GtkWidget* widget, gpointer user_data) { +static void header_gaction_activated_cb(GSimpleAction* action, + GVariant* parameter, + gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); - const gchar* mode = static_cast( - g_object_get_data(G_OBJECT(widget), "busymark-view-mode")); - const gchar* action = view_mode_action(mode); - if (action == nullptr) { + const gchar* dart_action = static_cast( + g_object_get_data(G_OBJECT(action), "busymark-dart-action")); + if (dart_action == nullptr) { + return; + } + focus_flutter_view(self); + invoke_header_bar_action(self, dart_action); +} + +static void view_mode_gaction_activated_cb(GSimpleAction* action, + GVariant* parameter, + gpointer user_data) { + if (parameter == nullptr || + !g_variant_is_of_type(parameter, G_VARIANT_TYPE_STRING)) { + return; + } + MyApplication* self = MY_APPLICATION(user_data); + const gchar* mode = g_variant_get_string(parameter, nullptr); + const gchar* dart_action = view_mode_dart_action(mode); + if (dart_action == nullptr) { return; } set_view_mode(self, mode); - close_menu_button(self->view_mode_button); focus_flutter_view(self); - invoke_header_bar_action(self, action); + invoke_header_bar_action(self, dart_action); } -static GtkWidget* create_view_mode_item(MyApplication* self, - const gchar* mode) { - GtkWidget* item = gtk_button_new(); - GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - GtkWidget* icon = - gtk_image_new_from_icon_name(view_mode_icon_name(mode), GTK_ICON_SIZE_MENU); - GtkWidget* check = - gtk_image_new_from_icon_name("object-select-symbolic", GTK_ICON_SIZE_MENU); - GtkWidget* label = gtk_label_new(""); - GtkWidget* shortcut = gtk_label_new(""); - gtk_widget_set_opacity(check, 0.0); - gtk_widget_set_valign(box, GTK_ALIGN_CENTER); - gtk_widget_set_valign(icon, GTK_ALIGN_CENTER); - gtk_widget_set_valign(check, GTK_ALIGN_CENTER); - gtk_widget_set_valign(label, GTK_ALIGN_CENTER); - gtk_widget_set_valign(shortcut, GTK_ALIGN_CENTER); - gtk_label_set_xalign(GTK_LABEL(label), 0.0); - gtk_label_set_xalign(GTK_LABEL(shortcut), 1.0); - gtk_widget_set_hexpand(label, TRUE); - gtk_style_context_add_class(gtk_widget_get_style_context(shortcut), - "dim-label"); - gtk_box_pack_start(GTK_BOX(box), icon, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(box), label, TRUE, TRUE, 0); - gtk_box_pack_start(GTK_BOX(box), shortcut, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(box), check, FALSE, FALSE, 0); - gtk_container_add(GTK_CONTAINER(item), box); - gtk_button_set_relief(GTK_BUTTON(item), GTK_RELIEF_NONE); - gtk_widget_set_halign(item, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(item, TRUE); - gtk_style_context_add_class(gtk_widget_get_style_context(item), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(item), - "busymark-menu-row"); - g_object_set_data(G_OBJECT(item), "busymark-label-widget", label); - g_object_set_data(G_OBJECT(item), "busymark-shortcut-widget", shortcut); - g_object_set_data(G_OBJECT(item), "busymark-check-widget", check); - g_object_set_data_full(G_OBJECT(item), "busymark-view-mode", - g_strdup(mode), g_free); - g_signal_connect(item, "clicked", G_CALLBACK(view_mode_clicked_cb), self); - return item; -} - -static GtkWidget* create_menu_button(GtkWidget* popover, - const gchar* icon_name) { +static void add_header_gaction(MyApplication* self, + const gchar* action_name, + const gchar* dart_action) { + GSimpleAction* action = g_simple_action_new(action_name, nullptr); + g_object_set_data_full(G_OBJECT(action), "busymark-dart-action", + g_strdup(dart_action), g_free); + g_signal_connect(action, "activate", G_CALLBACK(header_gaction_activated_cb), + self); + g_action_map_add_action(G_ACTION_MAP(self->header_action_group), + G_ACTION(action)); + g_object_unref(action); +} + +static void setup_header_actions(MyApplication* self) { + self->header_action_group = g_simple_action_group_new(); + add_header_gaction(self, "settings", "settings"); + add_header_gaction(self, "keyboard-shortcuts", "keyboardShortcuts"); + add_header_gaction(self, "markdown-and-html", "markdownAndHtml"); + add_header_gaction(self, "report-issue", "reportIssue"); + add_header_gaction(self, "about", "aboutBusyMark"); + + self->view_mode_action = g_simple_action_new_stateful( + "view-mode", G_VARIANT_TYPE_STRING, g_variant_new_string("split")); + g_signal_connect(self->view_mode_action, "activate", + G_CALLBACK(view_mode_gaction_activated_cb), self); + g_action_map_add_action(G_ACTION_MAP(self->header_action_group), + G_ACTION(self->view_mode_action)); + gtk_widget_insert_action_group(self->titlebar_box, "header", + G_ACTION_GROUP(self->header_action_group)); +} + +static void append_view_mode_menu_item(GMenu* menu, + const gchar* label, + const gchar* mode, + const gchar* shortcut) { + GMenuItem* item = g_menu_item_new(label, nullptr); + g_menu_item_set_action_and_target(item, "header.view-mode", "s", mode); + GIcon* icon = g_themed_icon_new(view_mode_icon_name(mode)); + g_menu_item_set_icon(item, icon); + g_object_unref(icon); + if (shortcut != nullptr && shortcut[0] != '\0') { + g_menu_item_set_attribute(item, "accel", "s", shortcut); + } + g_menu_append_item(menu, item); + g_object_unref(item); +} + +static void rebuild_view_mode_menu_model(MyApplication* self, + FlValue* labels) { + if (self->view_mode_menu_model == nullptr) { + return; + } + g_menu_remove_all(self->view_mode_menu_model); + append_view_mode_menu_item( + self->view_mode_menu_model, + localized_label_or(labels, "editor", ""), "editor", + fl_lookup_string_arg(labels, "editorShortcut")); + append_view_mode_menu_item( + self->view_mode_menu_model, + localized_label_or(labels, "source", ""), "source", + fl_lookup_string_arg(labels, "sourceShortcut")); + append_view_mode_menu_item( + self->view_mode_menu_model, + localized_label_or(labels, "preview", ""), "preview", + fl_lookup_string_arg(labels, "previewShortcut")); + append_view_mode_menu_item( + self->view_mode_menu_model, + localized_label_or(labels, "split", ""), "split", + fl_lookup_string_arg(labels, "splitShortcut")); +} + +static GtkWidget* create_model_menu_button(GMenuModel* model, + const gchar* icon_name, + GtkWidget** popover_out) { GtkWidget* button = gtk_menu_button_new(); - gtk_button_set_relief(GTK_BUTTON(button), GTK_RELIEF_NONE); + gtk_style_context_add_class(gtk_widget_get_style_context(button), + "busymark-header-control"); gtk_button_set_image(GTK_BUTTON(button), gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU)); + // Pointer-opened model menus should not paint a keyboard focus ring around + // their first row. Keyboard traversal can still focus the trigger. + gtk_widget_set_focus_on_click(button, FALSE); gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), TRUE); - gtk_menu_button_set_popover(GTK_MENU_BUTTON(button), popover); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - "busymark-header-button"); + gtk_menu_button_set_menu_model(GTK_MENU_BUTTON(button), model); make_icon_button_square(button); + GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(button)); + if (popover != nullptr) { + gtk_popover_set_position(popover, GTK_POS_BOTTOM); + gtk_style_context_add_class( + gtk_widget_get_style_context(GTK_WIDGET(popover)), + "busymark-header-popover"); + if (popover_out != nullptr) { + *popover_out = GTK_WIDGET(popover); + } + } return button; } @@ -1183,23 +1216,8 @@ static void set_widget_tooltip_with_shortcut(GtkWidget* widget, g_free(value); } -static void set_menu_item_label_with_shortcut(GtkWidget* item, - const gchar* text, - const gchar* shortcut) { - set_menu_item_label(item, text); - set_menu_item_shortcut(item, shortcut); -} - static void set_localized_labels(MyApplication* self, FlValue* args) { - const gchar* editor = fl_lookup_string_arg(args, "editor"); - const gchar* source = fl_lookup_string_arg(args, "source"); - const gchar* preview = fl_lookup_string_arg(args, "preview"); - const gchar* split = fl_lookup_string_arg(args, "split"); const gchar* view_mode = fl_lookup_string_arg(args, "viewMode"); - const gchar* editor_shortcut = fl_lookup_string_arg(args, "editorShortcut"); - const gchar* source_shortcut = fl_lookup_string_arg(args, "sourceShortcut"); - const gchar* preview_shortcut = fl_lookup_string_arg(args, "previewShortcut"); - const gchar* split_shortcut = fl_lookup_string_arg(args, "splitShortcut"); const gchar* search = fl_lookup_string_arg(args, "search"); const gchar* refresh = fl_lookup_string_arg(args, "refresh"); const gchar* menu = fl_lookup_string_arg(args, "menu"); @@ -1207,47 +1225,22 @@ static void set_localized_labels(MyApplication* self, FlValue* args) { const gchar* sidebar_shortcut = fl_lookup_string_arg(args, "sidebarShortcut"); const gchar* back = fl_lookup_string_arg(args, "back"); - const gchar* settings = fl_lookup_string_arg(args, "settings"); - const gchar* settings_shortcut = - fl_lookup_string_arg(args, "settingsShortcut"); - const gchar* keyboard_shortcuts = - fl_lookup_string_arg(args, "keyboardShortcuts"); - const gchar* keyboard_shortcuts_shortcut = - fl_lookup_string_arg(args, "keyboardShortcutsShortcut"); - const gchar* markdown_html = fl_lookup_string_arg(args, "markdownAndHtml"); - const gchar* markdown_html_shortcut = - fl_lookup_string_arg(args, "markdownAndHtmlShortcut"); - const gchar* report_issue = fl_lookup_string_arg(args, "reportIssue"); - const gchar* about = fl_lookup_string_arg(args, "aboutBusyMark"); set_widget_tooltip(self->back_button, back); set_widget_tooltip_with_shortcut(self->sidebar_toggle_button, sidebar, sidebar_shortcut); set_widget_tooltip(self->sidebar_search_button, search); + set_widget_tooltip(self->adaptive_search_button, search); if (self->search_entry != nullptr && GTK_IS_ENTRY(self->search_entry) && search != nullptr) { gtk_entry_set_placeholder_text(GTK_ENTRY(self->search_entry), search); } set_widget_tooltip(self->sidebar_menu_button, menu); + set_widget_tooltip(self->adaptive_menu_button, menu); set_widget_tooltip(self->refresh_button, refresh); set_widget_tooltip(self->view_mode_button, view_mode); - set_menu_item_label_with_shortcut(self->view_mode_editor_item, editor, - editor_shortcut); - set_menu_item_label_with_shortcut(self->view_mode_source_item, source, - source_shortcut); - set_menu_item_label_with_shortcut(self->view_mode_preview_item, preview, - preview_shortcut); - set_menu_item_label_with_shortcut(self->view_mode_split_item, split, - split_shortcut); - set_menu_item_label_with_shortcut(self->settings_item, settings, - settings_shortcut); - set_menu_item_label_with_shortcut(self->keyboard_shortcuts_item, - keyboard_shortcuts, - keyboard_shortcuts_shortcut); - set_menu_item_label_with_shortcut(self->markdown_html_item, - markdown_html, markdown_html_shortcut); - set_menu_item_label(self->report_issue_item, report_issue); - set_menu_item_label(self->about_item, about); + rebuild_main_menu_model(self, args); + rebuild_view_mode_menu_model(self, args); update_view_mode_icon(self); } @@ -1306,7 +1299,7 @@ static void set_search_active(MyApplication* self, gboolean active) { const gboolean changed = self->search_active != active; self->search_active = active; set_toggle_button_active(self, self->sidebar_search_button, active); - update_title_stack_alignment(self); + set_toggle_button_active(self, self->adaptive_search_button, active); if (self->title_stack != nullptr && GTK_IS_STACK(self->title_stack)) { GtkWidget* visible_child = active ? self->search_entry : self->title_label; if (visible_child != nullptr && GTK_IS_WIDGET(visible_child)) { @@ -1326,20 +1319,143 @@ static void set_search_active(MyApplication* self, gboolean active) { } } +static gboolean focus_search_entry(MyApplication* self) { + if (self->modal_barrier_visible || !self->search_active || + self->search_entry == nullptr || !GTK_IS_ENTRY(self->search_entry) || + !gtk_widget_get_visible(self->search_entry) || + !gtk_widget_get_child_visible(self->search_entry) || + !gtk_widget_get_sensitive(self->search_entry)) { + return FALSE; + } + + gtk_widget_grab_focus(self->search_entry); + gtk_editable_select_region(GTK_EDITABLE(self->search_entry), 0, -1); + return gtk_widget_has_focus(self->search_entry); +} + static void set_search_visible(MyApplication* self, gboolean visible) { self->search_visible = visible; set_widget_visible(self->sidebar_search_button, visible); + update_adaptive_header_actions(self); if (!visible && self->search_active) { set_search_active(self, FALSE); } } +static gboolean begin_header_configuration_session(MyApplication* self, + FlValue* args) { + const gchar* session_id = fl_lookup_string_arg(args, "sessionId"); + if (session_id == nullptr || session_id[0] == '\0') { + return FALSE; + } + if (g_strcmp0(session_id, self->header_configuration_session_id) != 0) { + g_free(self->header_configuration_session_id); + self->header_configuration_session_id = g_strdup(session_id); + self->header_configuration_revision = -1; + } + return TRUE; +} + +static gboolean decode_header_bar_configuration( + FlValue* args, + HeaderBarConfiguration* configuration) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP || + (configuration->session_id = + fl_lookup_string_arg(args, "sessionId")) == nullptr || + !fl_lookup_int64_arg(args, "revision", &configuration->revision)) { + return FALSE; + } + + configuration->title = fl_lookup_string_arg(args, "title"); + configuration->view_mode = fl_lookup_string_arg(args, "viewMode"); + configuration->search_query = fl_lookup_string_arg(args, "searchQuery"); + configuration->text_direction = + fl_lookup_string_arg(args, "textDirection"); + configuration->labels = fl_lookup_map_arg(args, "labels"); + configuration->theme = fl_lookup_map_arg(args, "theme"); + + if (configuration->session_id[0] == '\0' || configuration->revision < 0 || + configuration->title == nullptr || + configuration->view_mode == nullptr || + view_mode_dart_action(configuration->view_mode) == nullptr || + configuration->search_query == nullptr || + configuration->text_direction == nullptr || + (g_strcmp0(configuration->text_direction, "ltr") != 0 && + g_strcmp0(configuration->text_direction, "rtl") != 0) || + configuration->labels == nullptr || configuration->theme == nullptr || + !fl_lookup_double_arg(args, "sidebarWidth", + &configuration->sidebar_width) || + configuration->sidebar_width <= 0 || + !fl_lookup_optional_bool_arg(args, "canRefresh", + &configuration->can_refresh) || + !fl_lookup_optional_bool_arg( + args, "documentControlsVisible", + &configuration->document_controls_visible) || + !fl_lookup_optional_bool_arg(args, "searchActive", + &configuration->search_active) || + !fl_lookup_optional_bool_arg(args, "searchVisible", + &configuration->search_visible) || + !fl_lookup_optional_bool_arg(args, "sidebarVisible", + &configuration->sidebar_visible) || + !fl_lookup_optional_bool_arg(args, "sidebarToggleVisible", + &configuration->sidebar_toggle_visible) || + !fl_lookup_optional_bool_arg(args, "backVisible", + &configuration->back_visible) || + !fl_lookup_optional_bool_arg(args, "modalBarrierVisible", + &configuration->modal_barrier_visible)) { + return FALSE; + } + + return configuration->search_visible || !configuration->search_active; +} + +static void apply_header_bar_configuration( + MyApplication* self, + const HeaderBarConfiguration& configuration) { + const gboolean previous_suppression = self->suppress_header_actions; + self->suppress_header_actions = TRUE; + if (self->titlebar_box != nullptr) { + g_object_freeze_notify(G_OBJECT(self->titlebar_box)); + } + + set_header_bar_theme(self, configuration.theme); + set_localized_labels(self, configuration.labels); + if (self->title_label != nullptr && GTK_IS_LABEL(self->title_label)) { + gtk_label_set_text(GTK_LABEL(self->title_label), configuration.title); + } + set_widget_sensitive(self->refresh_button, configuration.can_refresh); + set_sidebar_width(self, configuration.sidebar_width); + set_text_direction(self, configuration.text_direction); + set_sidebar_visible(self, configuration.sidebar_visible); + set_sidebar_toggle_visible(self, configuration.sidebar_toggle_visible); + set_search_visible(self, configuration.search_visible); + set_back_visible(self, configuration.back_visible); + set_document_controls_visible( + self, configuration.document_controls_visible); + set_search_query(self, configuration.search_query); + set_search_active(self, configuration.search_active); + set_view_mode(self, configuration.view_mode); + set_modal_barrier_visible(self, configuration.modal_barrier_visible); + self->header_configuration_revision = configuration.revision; + + if (self->titlebar_box != nullptr) { + g_object_thaw_notify(G_OBJECT(self->titlebar_box)); + gtk_widget_queue_draw(self->titlebar_box); + } + self->suppress_header_actions = previous_suppression; +} + static GtkWidget* create_busymark_titlebar(MyApplication* self) { self->titlebar_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); gtk_widget_set_halign(self->titlebar_box, GTK_ALIGN_FILL); gtk_widget_set_hexpand(self->titlebar_box, TRUE); gtk_style_context_add_class(gtk_widget_get_style_context(self->titlebar_box), "busymark-titlebar"); + setup_header_actions(self); + self->main_menu_model = g_menu_new(); + self->view_mode_menu_model = g_menu_new(); + rebuild_main_menu_model(self, nullptr); + rebuild_view_mode_menu_model(self, nullptr); self->sidebar_header_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); @@ -1369,27 +1485,9 @@ static GtkWidget* create_busymark_titlebar(MyApplication* self) { gtk_box_pack_start(GTK_BOX(self->sidebar_header_box), sidebar_title_box, TRUE, TRUE, 0); - self->sidebar_menu = create_header_popover(); - GtkWidget* sidebar_menu_box = create_popover_box(self->sidebar_menu); - self->settings_item = create_menu_item(self, "settings"); - self->keyboard_shortcuts_item = - create_menu_item(self, "keyboardShortcuts"); - self->markdown_html_item = create_menu_item(self, "markdownAndHtml"); - self->report_issue_item = create_menu_item(self, "reportIssue"); - self->about_item = create_menu_item(self, "aboutBusyMark"); - gtk_box_pack_start(GTK_BOX(sidebar_menu_box), self->settings_item, FALSE, - FALSE, 0); - gtk_box_pack_start(GTK_BOX(sidebar_menu_box), - self->keyboard_shortcuts_item, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(sidebar_menu_box), self->markdown_html_item, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(sidebar_menu_box), self->report_issue_item, FALSE, - FALSE, 0); - gtk_box_pack_start(GTK_BOX(sidebar_menu_box), self->about_item, FALSE, - FALSE, 0); - gtk_widget_show_all(sidebar_menu_box); - self->sidebar_menu_button = - create_menu_button(self->sidebar_menu, "open-menu-symbolic"); + self->sidebar_menu_button = create_model_menu_button( + G_MENU_MODEL(self->main_menu_model), "open-menu-symbolic", + &self->sidebar_menu); gtk_style_context_add_class(gtk_widget_get_style_context(self->sidebar_menu_button), "busymark-sidebar-action-button"); gtk_box_pack_end(GTK_BOX(self->sidebar_header_box), @@ -1418,7 +1516,6 @@ static GtkWidget* create_busymark_titlebar(MyApplication* self) { self->title_stack = gtk_stack_new(); gtk_widget_set_hexpand(self->title_stack, TRUE); - update_title_stack_alignment(self); gtk_stack_set_transition_type(GTK_STACK(self->title_stack), GTK_STACK_TRANSITION_TYPE_NONE); self->title_label = gtk_label_new(""); @@ -1429,15 +1526,20 @@ static GtkWidget* create_busymark_titlebar(MyApplication* self) { self->search_entry = gtk_search_entry_new(); gtk_entry_set_placeholder_text(GTK_ENTRY(self->search_entry), ""); gtk_widget_set_hexpand(self->search_entry, TRUE); - gtk_widget_set_size_request(self->search_entry, 360, kHeaderButtonHeight); gtk_style_context_add_class(gtk_widget_get_style_context(self->search_entry), "busymark-search-entry"); g_signal_connect(self->search_entry, "search-changed", G_CALLBACK(search_entry_changed_cb), self); g_signal_connect(self->search_entry, "activate", G_CALLBACK(search_entry_activate_cb), self); - g_signal_connect(self->search_entry, "key-press-event", - G_CALLBACK(search_entry_key_press_cb), self); + g_signal_connect(self->search_entry, "focus-in-event", + G_CALLBACK(search_entry_focus_in_cb), self); + g_signal_connect(self->search_entry, "focus-out-event", + G_CALLBACK(search_entry_focus_out_cb), self); + g_signal_connect(self->search_entry, "icon-release", + G_CALLBACK(search_entry_icon_release_cb), self); + g_signal_connect(self->search_entry, "stop-search", + G_CALLBACK(search_entry_stop_search_cb), self); gtk_stack_add_named(GTK_STACK(self->title_stack), self->title_label, "title"); gtk_stack_add_named(GTK_STACK(self->title_stack), self->search_entry, "search"); @@ -1447,41 +1549,13 @@ static GtkWidget* create_busymark_titlebar(MyApplication* self) { GtkWidget* end_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); self->view_mode_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - self->view_mode_menu = create_header_popover(); - GtkWidget* view_menu_box = create_popover_box(self->view_mode_menu); - self->view_mode_editor_item = create_view_mode_item(self, "editor"); - self->view_mode_source_item = create_view_mode_item(self, "source"); - self->view_mode_preview_item = create_view_mode_item(self, "preview"); - self->view_mode_split_item = create_view_mode_item(self, "split"); - gtk_box_pack_start(GTK_BOX(view_menu_box), self->view_mode_editor_item, FALSE, - FALSE, 0); - gtk_box_pack_start(GTK_BOX(view_menu_box), self->view_mode_source_item, FALSE, - FALSE, 0); - gtk_box_pack_start(GTK_BOX(view_menu_box), self->view_mode_preview_item, FALSE, - FALSE, 0); - gtk_box_pack_start(GTK_BOX(view_menu_box), self->view_mode_split_item, FALSE, - FALSE, 0); - gtk_widget_show_all(view_menu_box); - - self->view_mode_button = gtk_menu_button_new(); - gtk_button_set_relief(GTK_BUTTON(self->view_mode_button), GTK_RELIEF_NONE); - gtk_widget_set_valign(self->view_mode_button, GTK_ALIGN_CENTER); - gtk_style_context_add_class(gtk_widget_get_style_context(self->view_mode_button), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(self->view_mode_button), - "busymark-header-button"); + self->view_mode_button = create_model_menu_button( + G_MENU_MODEL(self->view_mode_menu_model), view_mode_icon_name("split"), + &self->view_mode_menu); gtk_style_context_add_class(gtk_widget_get_style_context(self->view_mode_button), "busymark-view-mode-button"); - gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(self->view_mode_button), - TRUE); - gtk_menu_button_set_popover(GTK_MENU_BUTTON(self->view_mode_button), - self->view_mode_menu); self->view_mode_icon = - gtk_image_new_from_icon_name(view_mode_icon_name("split"), - GTK_ICON_SIZE_MENU); - gtk_container_add(GTK_CONTAINER(self->view_mode_button), - self->view_mode_icon); - make_icon_button_square(self->view_mode_button); + gtk_button_get_image(GTK_BUTTON(self->view_mode_button)); gtk_box_pack_start(GTK_BOX(self->view_mode_box), self->view_mode_button, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(end_box), self->view_mode_box, FALSE, FALSE, 0); @@ -1489,6 +1563,16 @@ static GtkWidget* create_busymark_titlebar(MyApplication* self) { self->refresh_button = create_header_icon_button("tools-check-spelling-symbolic"); connect_header_action(self, self->refresh_button, "refresh"); gtk_box_pack_start(GTK_BOX(end_box), self->refresh_button, FALSE, FALSE, 0); + self->adaptive_search_button = + create_header_toggle_button("system-search-symbolic"); + connect_header_action(self, self->adaptive_search_button, "search"); + gtk_box_pack_start(GTK_BOX(end_box), self->adaptive_search_button, FALSE, + FALSE, 0); + self->adaptive_menu_button = create_model_menu_button( + G_MENU_MODEL(self->main_menu_model), "open-menu-symbolic", + &self->adaptive_menu); + gtk_box_pack_start(GTK_BOX(end_box), self->adaptive_menu_button, FALSE, FALSE, + 0); gtk_header_bar_pack_end(self->header_bar, end_box); gtk_box_pack_start(GTK_BOX(self->titlebar_box), GTK_WIDGET(self->header_bar), @@ -1509,7 +1593,27 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, const gchar* method = fl_method_call_get_name(method_call); FlValue* args = fl_method_call_get_args(method_call); if (strcmp(method, "initialize") == 0) { - respond_bool(method_call, has_header_bar(self)); + respond_bool(method_call, + begin_header_configuration_session(self, args) && + has_header_bar(self)); + } else if (strcmp(method, "applyConfiguration") == 0) { + HeaderBarConfiguration configuration = {}; + if (!decode_header_bar_configuration(args, &configuration)) { + respond_invalid_configuration( + method_call, + "applyConfiguration requires a complete, typed header snapshot"); + } else if (g_strcmp0(configuration.session_id, + self->header_configuration_session_id) != 0) { + respond_invalid_configuration( + method_call, + "applyConfiguration requires the active Dart session"); + } else if (configuration.revision <= + self->header_configuration_revision) { + respond_int64(method_call, self->header_configuration_revision); + } else { + apply_header_bar_configuration(self, configuration); + respond_int64(method_call, self->header_configuration_revision); + } } else if (strcmp(method, "setTitleRange") == 0) { const gchar* value = fl_method_string_arg(args); if (self->title_label != nullptr && GTK_IS_LABEL(self->title_label) && @@ -1523,23 +1627,16 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, } else if (strcmp(method, "setCanRefresh") == 0) { set_widget_sensitive(self->refresh_button, fl_method_bool_arg(args)); respond_success(method_call); - } else if (strcmp(method, "setCanSave") == 0) { - respond_success(method_call); } else if (strcmp(method, "setDocumentControlsVisible") == 0) { set_document_controls_visible(self, fl_method_bool_arg(args)); respond_success(method_call); } else if (strcmp(method, "setSearchActive") == 0) { set_search_active(self, fl_method_bool_arg(args)); respond_success(method_call); + } else if (strcmp(method, "focusSearch") == 0) { + respond_bool(method_call, focus_search_entry(self)); } else if (strcmp(method, "setSearchQuery") == 0) { - const gchar* value = fl_method_string_arg(args); - if (self->search_entry != nullptr && GTK_IS_ENTRY(self->search_entry) && - value != nullptr) { - const gboolean previous = self->suppress_header_actions; - self->suppress_header_actions = TRUE; - gtk_entry_set_text(GTK_ENTRY(self->search_entry), value); - self->suppress_header_actions = previous; - } + set_search_query(self, fl_method_string_arg(args)); respond_success(method_call); } else if (strcmp(method, "setSidebarVisible") == 0) { set_sidebar_visible(self, fl_method_bool_arg(args)); @@ -1582,109 +1679,6 @@ static void register_header_bar_channel(MyApplication* self, FlView* view) { self->header_bar_channel, header_bar_method_call_cb, self, nullptr); } -static gboolean clear_transparent_window_cb(GtkWidget* widget, - cairo_t* cr, - gpointer user_data) { - cairo_save(cr); - cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR); - cairo_paint(cr); - cairo_restore(cr); - return FALSE; -} - -static cairo_region_t* create_rounded_window_region(gint width, - gint height, - gint radius) { - cairo_region_t* region = cairo_region_create(); - if (width <= 0 || height <= 0) { - return region; - } - - if (radius <= 0 || width < radius * 2 || height < radius * 2) { - const cairo_rectangle_int_t rect = {0, 0, width, height}; - cairo_region_union_rectangle(region, &rect); - return region; - } - - const gdouble radius_squared = radius * radius; - for (gint y = 0; y < height; y++) { - gint inset = 0; - if (y < radius) { - const gdouble dy = radius - y - 1; - inset = radius - static_cast(std::sqrt(radius_squared - dy * dy)); - } else if (y >= height - radius) { - const gdouble dy = y - (height - radius); - inset = radius - static_cast(std::sqrt(radius_squared - dy * dy)); - } - - const gint row_width = width - inset * 2; - if (row_width <= 0) { - continue; - } - - const cairo_rectangle_int_t row = {inset, y, row_width, 1}; - cairo_region_union_rectangle(region, &row); - } - - return region; -} - -static void configure_rounded_window_shape(GtkWidget* widget) { - if (widget == nullptr || !GTK_IS_WIDGET(widget) || - !gtk_widget_get_realized(widget)) { - return; - } - - GdkWindow* window = gtk_widget_get_window(widget); - if (window == nullptr || !GDK_IS_WINDOW(window)) { - return; - } - - const GdkWindowState state = gdk_window_get_state(window); - if ((state & GDK_WINDOW_STATE_MAXIMIZED) != 0 || - (state & GDK_WINDOW_STATE_FULLSCREEN) != 0) { - gdk_window_shape_combine_region(window, nullptr, 0, 0); - return; - } - - const gint width = gtk_widget_get_allocated_width(widget); - const gint height = gtk_widget_get_allocated_height(widget); - if (width <= 0 || height <= 0) { - return; - } - - cairo_region_t* region = - create_rounded_window_region(width, height, kHeaderWindowRadius); - gdk_window_shape_combine_region(window, region, 0, 0); - cairo_region_destroy(region); -} - -static void rounded_window_realize_cb(GtkWidget* widget, gpointer user_data) { - configure_rounded_window_shape(widget); -} - -static gboolean rounded_window_configure_event_cb(GtkWidget* widget, - GdkEventConfigure* event, - gpointer user_data) { - configure_rounded_window_shape(widget); - return FALSE; -} - -static void configure_transparent_window_backing(GtkWindow* window) { - GdkScreen* screen = gtk_window_get_screen(window); - GdkVisual* visual = gdk_screen_get_rgba_visual(screen); - if (visual != nullptr) { - gtk_widget_set_visual(GTK_WIDGET(window), visual); - } - gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE); - g_signal_connect(window, "draw", G_CALLBACK(clear_transparent_window_cb), - nullptr); - g_signal_connect_after(window, "realize", - G_CALLBACK(rounded_window_realize_cb), nullptr); - g_signal_connect(window, "configure-event", - G_CALLBACK(rounded_window_configure_event_cb), nullptr); -} - // Called when first Flutter frame received. static void first_frame_cb(MyApplication* self, FlView* view) { gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); @@ -1698,7 +1692,6 @@ static void my_application_activate(GApplication* application) { self->main_window = window; gtk_window_set_title(window, kApplicationDisplayName); gtk_widget_set_name(GTK_WIDGET(window), "busymark-window"); - configure_transparent_window_backing(window); g_autoptr(GdkPixbuf) application_icon = load_application_icon(); if (application_icon != nullptr) { gtk_window_set_default_icon(application_icon); @@ -1790,28 +1783,24 @@ static void my_application_dispose(GObject* object) { gtk_style_context_remove_provider_for_screen( screen, GTK_STYLE_PROVIDER(self->header_bar_css_provider)); } - if (self->gtk_accent_css_provider != nullptr) { - gtk_style_context_remove_provider_for_screen( - screen, GTK_STYLE_PROVIDER(self->gtk_accent_css_provider)); - } } g_clear_object(&self->header_bar_css_provider); - g_clear_object(&self->gtk_accent_css_provider); g_clear_object(&self->header_bar_channel); + g_clear_object(&self->main_menu_model); + g_clear_object(&self->view_mode_menu_model); + g_clear_object(&self->view_mode_action); + g_clear_object(&self->header_action_group); g_clear_pointer(&self->background_color, g_free); g_clear_pointer(&self->sidebar_background_color, g_free); - g_clear_pointer(&self->foreground_color, g_free); - g_clear_pointer(&self->muted_foreground_color, g_free); - g_clear_pointer(&self->disabled_foreground_color, g_free); - g_clear_pointer(&self->control_color, g_free); - g_clear_pointer(&self->control_hover_color, g_free); - g_clear_pointer(&self->accent_color, g_free); - g_clear_pointer(&self->accent_foreground_color, g_free); - g_clear_pointer(&self->popover_background_color, g_free); g_clear_pointer(&self->border_color, g_free); - g_clear_pointer(&self->shade_color, g_free); + g_clear_pointer(&self->sidebar_border_color, g_free); + g_clear_pointer(&self->floating_border_color, g_free); g_clear_pointer(&self->modal_barrier_color, g_free); g_clear_pointer(&self->view_mode, g_free); + g_clear_pointer(&self->search_query, g_free); + g_clear_pointer(&self->foreground_color, g_free); + g_clear_pointer(&self->popover_background_color, g_free); + g_clear_pointer(&self->header_configuration_session_id, g_free); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); G_OBJECT_CLASS(my_application_parent_class)->dispose(object); } @@ -1829,7 +1818,6 @@ static void my_application_init(MyApplication* self) { self->dart_entrypoint_arguments = nullptr; self->header_bar_channel = nullptr; self->header_bar_css_provider = nullptr; - self->gtk_accent_css_provider = nullptr; self->main_window = nullptr; self->flutter_view = nullptr; self->titlebar_box = nullptr; @@ -1839,11 +1827,7 @@ static void my_application_init(MyApplication* self) { self->sidebar_title_label = nullptr; self->sidebar_menu_button = nullptr; self->sidebar_menu = nullptr; - self->settings_item = nullptr; - self->keyboard_shortcuts_item = nullptr; - self->markdown_html_item = nullptr; - self->report_issue_item = nullptr; - self->about_item = nullptr; + self->main_menu_model = nullptr; self->header_start_box = nullptr; self->back_button = nullptr; self->sidebar_toggle_button = nullptr; @@ -1856,24 +1840,22 @@ static void my_application_init(MyApplication* self) { self->view_mode_button = nullptr; self->view_mode_icon = nullptr; self->view_mode_menu = nullptr; - self->view_mode_editor_item = nullptr; - self->view_mode_source_item = nullptr; - self->view_mode_preview_item = nullptr; - self->view_mode_split_item = nullptr; + self->view_mode_menu_model = nullptr; self->refresh_button = nullptr; + self->adaptive_search_button = nullptr; + self->adaptive_menu_button = nullptr; + self->adaptive_menu = nullptr; + self->header_action_group = nullptr; + self->view_mode_action = nullptr; self->view_mode = nullptr; + self->search_query = g_strdup(""); self->background_color = g_strdup(kDefaultHeaderbarBackground); self->sidebar_background_color = g_strdup(kDefaultSidebarBackground); - self->foreground_color = nullptr; - self->muted_foreground_color = nullptr; - self->disabled_foreground_color = nullptr; - self->control_color = nullptr; - self->control_hover_color = nullptr; - self->accent_color = nullptr; - self->accent_foreground_color = nullptr; - self->popover_background_color = nullptr; + self->foreground_color = g_strdup(kDefaultForeground); + self->popover_background_color = g_strdup(kDefaultPopoverBackground); self->border_color = nullptr; - self->shade_color = nullptr; + self->sidebar_border_color = nullptr; + self->floating_border_color = nullptr; self->modal_barrier_color = nullptr; self->sidebar_width = 300; self->sidebar_visible = TRUE; @@ -1882,6 +1864,8 @@ static void my_application_init(MyApplication* self) { self->search_active = FALSE; self->modal_barrier_visible = FALSE; self->suppress_header_actions = FALSE; + self->header_configuration_session_id = nullptr; + self->header_configuration_revision = -1; } MyApplication* my_application_new() { diff --git a/test/src/app_router_test.dart b/test/src/app_router_test.dart new file mode 100644 index 0000000..de45f02 --- /dev/null +++ b/test/src/app_router_test.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:busymark/l10n/generated/app_localizations_en.dart'; +import 'package:busymark/src/app/app_router.dart'; +import 'package:busymark/src/app/busymark_app.dart'; +import 'package:busymark/src/platform/linux_header_bar_service.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:busymark/src/workspace/workspace_model.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final l10n = AppLocalizationsEn(); + + test('settings return targets are explicit and validated', () { + expect( + SettingsReturnTarget.fromSettingsUri( + Uri.parse('/settings?returnTo=workspace'), + ), + SettingsReturnTarget.workspace, + ); + expect( + SettingsReturnTarget.fromSettingsUri( + Uri.parse('/settings?returnTo=welcome'), + ), + SettingsReturnTarget.welcome, + ); + expect( + SettingsReturnTarget.fromSettingsUri(Uri.parse('/settings')), + SettingsReturnTarget.welcome, + ); + expect( + SettingsReturnTarget.fromSettingsUri( + Uri.parse('/settings?returnTo=/workspace'), + ), + SettingsReturnTarget.welcome, + ); + expect( + SettingsReturnTarget.fromSettingsUri( + Uri.parse('/settings?returnTo=unexpected'), + ), + SettingsReturnTarget.welcome, + ); + }); + + test('opening Settings preserves a validated Settings origin', () { + expect( + settingsLocationForUri(Uri.parse('/')), + '/settings?returnTo=welcome', + ); + expect( + settingsLocationForUri(Uri.parse('/workspace')), + '/settings?returnTo=workspace', + ); + expect( + settingsLocationForUri(Uri.parse('/settings?returnTo=workspace')), + '/settings?returnTo=workspace', + ); + expect( + settingsLocationForUri(Uri.parse('/settings?returnTo=untrusted')), + '/settings?returnTo=welcome', + ); + }); + + testWidgets( + 'Settings opened from Welcome returns to Welcome with a stale workspace', + (tester) async { + final headerBar = _FallbackHeaderBarService(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBar), + workspaceControllerProvider.overrideWith( + () => _StaleWorkspaceController(), + ), + ], + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text(l10n.createMarkdownFile), findsOneWidget); + + await tester.tap(find.byTooltip(l10n.mainMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.settings)); + await tester.pumpAndSettle(); + + expect(find.text(l10n.settingsTitle), findsOneWidget); + + await tester.tap(find.byTooltip(l10n.back)); + await tester.pumpAndSettle(); + + expect(find.text(l10n.settingsTitle), findsNothing); + expect(find.text(l10n.createMarkdownFile), findsOneWidget); + }, + ); +} + +class _FallbackHeaderBarService extends LinuxHeaderBarService { + _FallbackHeaderBarService() + : super(channel: const MethodChannel('test.busymark/headerbar.router')); + + @override + bool get isAvailable => false; + + @override + bool get usesNativeHeaderBar => false; + + @override + Stream get actions => const Stream.empty(); +} + +class _StaleWorkspaceController extends WorkspaceController { + @override + WorkspaceState build() { + return WorkspaceState( + workspace: Workspace( + id: 'stale-workspace', + rootPath: '/tmp/stale-workspace.md', + kind: WorkspaceKind.singleMarkdown, + openedAt: DateTime(2026), + files: const [], + diagnostics: const [], + ), + ); + } +} diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index bbd5bfc..1919c63 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -24,6 +24,7 @@ import 'package:busymark/src/editor/document_callout.dart'; import 'package:busymark/src/editor/document_code_block.dart'; import 'package:busymark/src/editor/document_layout.dart'; import 'package:busymark/src/editor/markdown_image_view.dart'; +import 'package:busymark/src/editor/source/source_editor.dart'; import 'package:busymark/src/editor/source/source_read_only_view.dart'; import 'package:busymark/src/feedback/presentation/feedback_dialog.dart'; import 'package:busymark/src/git/application/git_controller.dart'; @@ -965,8 +966,15 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); } - await tester.tap(find.byTooltip(l10n.sidebarViewMenu)); - await tester.pump(const Duration(milliseconds: 200)); + Future openPopup( + Finder anchor, { + int buttons = kPrimaryButton, + }) async { + await tester.tap(anchor, buttons: buttons); + await tester.pumpAndSettle(); + } + + await openPopup(find.byTooltip(l10n.sidebarViewMenu)); await tester.tap(find.text(l10n.files)); await tester.pump(const Duration(milliseconds: 300)); await tester.tap(find.text('target.md')); @@ -985,8 +993,7 @@ void main() { await tester.tap(find.text(l10n.cancel)); await tester.pump(const Duration(milliseconds: 200)); - await tester.tap(find.byTooltip(l10n.sidebarViewMenu)); - await tester.pump(const Duration(milliseconds: 200)); + await openPopup(find.byTooltip(l10n.sidebarViewMenu)); await tester.tap(find.text(l10n.toc)); await tester.pump(const Duration(milliseconds: 300)); @@ -994,8 +1001,7 @@ void main() { expect(find.byTooltip(l10n.tocActions), findsOneWidget); expect(find.byTooltip(l10n.newTopic), findsNothing); expect(find.byTooltip(l10n.newChildTopic), findsNothing); - await tester.tap(find.byTooltip(l10n.tocActions)); - await tester.pump(const Duration(milliseconds: 200)); + await openPopup(find.byTooltip(l10n.tocActions)); expect(find.text(l10n.newTopic), findsOneWidget); await tester.tap(find.text(l10n.newTopic)); await tester.pump(const Duration(milliseconds: 300)); @@ -1003,7 +1009,7 @@ void main() { expect(find.text(l10n.topicPlacement), findsOneWidget); expect(find.text(l10n.tocRoot), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, l10n.create)); + await tester.tap(find.widgetWithText(BusyMarkDialogButton, l10n.create)); await tester.pump(const Duration(milliseconds: 300)); expect(controller.createdTopicRequest, isNotNull); @@ -1015,23 +1021,20 @@ void main() { expect(controller.createdTopicRequest!.referenceTopic, isNull); expect(controller.createdTopicTreePath, p.join(root.path, 'guide.tree')); - await tester.tap(find.byTooltip(l10n.instanceName)); - await tester.pump(const Duration(milliseconds: 300)); + await openPopup(find.byTooltip(l10n.instanceName)); await tester.tap(find.text('API Reference')); await tester.pump(const Duration(milliseconds: 300)); expect(find.text('api.md'), findsOneWidget); - await tester.tap(find.byTooltip(l10n.tocActions)); - await tester.pump(const Duration(milliseconds: 200)); + await openPopup(find.byTooltip(l10n.tocActions)); expect(find.text(l10n.newTopic), findsOneWidget); await tester.tap(find.text(l10n.newTopic)); await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(find.widgetWithText(FilledButton, l10n.create)); + await tester.tap(find.widgetWithText(BusyMarkDialogButton, l10n.create)); await tester.pump(const Duration(milliseconds: 300)); expect(controller.createdTopicTreePath, p.join(root.path, 'api.tree')); - await tester.tap(find.byTooltip(l10n.instanceName)); - await tester.pump(const Duration(milliseconds: 300)); + await openPopup(find.byTooltip(l10n.instanceName)); await tester.tap(find.text('Guide').last); await tester.pump(const Duration(milliseconds: 300)); @@ -1049,8 +1052,7 @@ void main() { await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(find.text('Nested entry'), buttons: kSecondaryButton); - await tester.pump(const Duration(milliseconds: 300)); + await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); for (final label in [ l10n.newSiblingTopic, @@ -1073,7 +1075,7 @@ void main() { await tester.tap(find.text(l10n.newChildTopic)); await tester.pump(const Duration(milliseconds: 300)); expect(find.text(l10n.insideSelectedTopic), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, l10n.create)); + await tester.tap(find.widgetWithText(BusyMarkDialogButton, l10n.create)); await tester.pump(const Duration(milliseconds: 300)); expect( controller.createdTopicRequest!.placement, @@ -1083,12 +1085,11 @@ void main() { expect(controller.createdTopicRequest!.referenceTopic, 'nested.md'); expect(controller.createdTopicRequest!.referenceTocIdentity, isNotNull); - await tester.tap(find.text('Nested entry'), buttons: kSecondaryButton); - await tester.pump(const Duration(milliseconds: 300)); + await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); await tester.tap(find.text(l10n.newSiblingTopic)); await tester.pump(const Duration(milliseconds: 300)); expect(find.text(l10n.afterSelectedTopic), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, l10n.create)); + await tester.tap(find.widgetWithText(BusyMarkDialogButton, l10n.create)); await tester.pump(const Duration(milliseconds: 300)); expect( controller.createdTopicRequest!.placement, @@ -1096,13 +1097,11 @@ void main() { ); expect(controller.createdTopicRequest!.referenceTocPath, [0, 0]); - await tester.tap(find.text('Nested entry'), buttons: kSecondaryButton); - await tester.pump(const Duration(milliseconds: 300)); + await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); await tester.tap(find.text(l10n.cut)); await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(find.text('parent.md'), buttons: kSecondaryButton); - await tester.pump(const Duration(milliseconds: 300)); + await openPopup(find.text('parent.md'), buttons: kSecondaryButton); expect( find.byWidgetPredicate( (widget) => @@ -1114,7 +1113,7 @@ void main() { ); await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); File( p.join(root.path, 'topics', 'inserted.md'), ).writeAsStringSync('# Inserted\n'); @@ -1134,8 +1133,7 @@ void main() { controller.replaceWorkspace(refreshedWorkspace); await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(find.text('parent.md'), buttons: kSecondaryButton); - await tester.pump(const Duration(milliseconds: 300)); + await openPopup(find.text('parent.md'), buttons: kSecondaryButton); expect( find.byWidgetPredicate( (widget) => @@ -1147,13 +1145,11 @@ void main() { ); await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(find.text('loose.md'), buttons: kSecondaryButton); - await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + await openPopup(find.text('loose.md'), buttons: kSecondaryButton); await tester.tap(find.text(l10n.cut)); await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(find.text('target.md'), buttons: kSecondaryButton); - await tester.pump(const Duration(milliseconds: 300)); + await openPopup(find.text('target.md'), buttons: kSecondaryButton); expect( find.byWidgetPredicate( (widget) => @@ -3720,6 +3716,280 @@ void main() { expect(find.text(l10n.noOutline), findsNothing); }); + testWidgets( + 'outline follows unsaved source headings without live validation', + (tester) async { + const startupPath = 'test/fixtures/markdown/basic.md'; + const unsaved = ''' +# Unsaved title + +Draft paragraph. + +Another draft paragraph. + +## Unsaved target + +Body. +'''; + final service = _StartupWorkspaceService(); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + validateOnEdit: false, + documentViewMode: DocumentViewModePreference.source, + ) + .toJson(); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), + startupPathProvider.overrideWithValue(startupPath), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + for (var i = 0; i < 20; i += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.byType(TextField).evaluate().isNotEmpty) { + break; + } + } + + final sourceField = find.descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byType(TextField), + ); + expect(sourceField, findsOneWidget); + await tester.enterText(sourceField, unsaved); + await tester.pump(); + + final state = container.read(workspaceControllerProvider); + expect(state.isDirty, isTrue); + expect(state.workspace?.markdown?.title, 'Basic Markdown'); + final outlineTree = find.byKey( + const ValueKey('workspace-sidebar-outline-tree'), + ); + expect( + find.descendant(of: outlineTree, matching: find.text('Unsaved target')), + findsOneWidget, + ); + expect( + find.descendant(of: outlineTree, matching: find.text('Basic Markdown')), + findsNothing, + ); + + await tester.tap( + find.descendant(of: outlineTree, matching: find.text('Unsaved target')), + ); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final controller = tester.widget(sourceField).controller!; + expect( + controller.selection, + TextSelection.collapsed(offset: unsaved.indexOf('## Unsaved target')), + ); + expect(service.saveCount, 0); + }, + ); + + testWidgets('outline navigates to a renamed unsaved editor heading', ( + tester, + ) async { + tester.view.physicalSize = const Size(1200, 720); + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + const startupPath = '/tmp/unsaved-editor-outline.md'; + final source = [ + '# [Saved heading](https://example.com)', + '', + for (var index = 0; index < 60; index += 1) ...[ + 'Paragraph $index keeps the editor scrollable.', + '', + ], + ].join('\n'); + final service = _SearchWorkspaceService(source); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + validateOnEdit: false, + documentViewMode: DocumentViewModePreference.editor, + ) + .toJson(); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), + startupPathProvider.overrideWithValue(startupPath), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + final editorScroll = find.byKey(const ValueKey('wysiwyg-document-scroll')); + for (var i = 0; i < 30; i += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (editorScroll.evaluate().isNotEmpty) { + break; + } + } + + final editorFields = find.descendant( + of: editorScroll, + matching: find.byType(TextField), + ); + expect(editorFields, findsWidgets); + final scrollController = tester.widget(editorScroll).controller!; + final outlineTree = find.byKey( + const ValueKey('workspace-sidebar-outline-tree'), + ); + final formattedTarget = find.descendant( + of: outlineTree, + matching: find.text('Saved heading'), + ); + expect(formattedTarget, findsOneWidget); + + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pump(); + final offsetBeforeFormattedNavigation = scrollController.offset; + expect(offsetBeforeFormattedNavigation, greaterThan(0)); + await tester.tap(formattedTarget); + await tester.pumpAndSettle(); + expect(scrollController.offset, lessThan(offsetBeforeFormattedNavigation)); + + final previewBeforeEdit = container + .read(workspaceControllerProvider) + .preview; + await tester.enterText(editorFields.first, 'Unsaved heading'); + await tester.pump(); + + final state = container.read(workspaceControllerProvider); + expect( + state.activeText, + startsWith('# [Unsaved heading](https://example.com)\n'), + ); + expect(state.isDirty, isTrue); + expect(identical(state.preview, previewBeforeEdit), isTrue); + expect(state.liveOutline?.headings.map((heading) => heading.text), [ + 'Unsaved heading', + ]); + final target = find.descendant( + of: outlineTree, + matching: find.text('Unsaved heading'), + ); + expect(target, findsOneWidget); + expect( + find.descendant(of: outlineTree, matching: find.text('Saved heading')), + findsNothing, + ); + + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pump(); + expect(scrollController.offset, greaterThan(0)); + final offsetBeforeNavigation = scrollController.offset; + + await tester.tap(target); + await tester.pumpAndSettle(); + + expect(scrollController.offset, lessThan(offsetBeforeNavigation)); + final viewportBounds = tester.getRect(editorScroll); + final headingBounds = tester.getRect(editorFields.first); + expect(headingBounds.bottom, greaterThan(viewportBounds.top)); + expect(headingBounds.top, lessThan(viewportBounds.bottom)); + }); + + testWidgets('untitled outline navigates before the first save', ( + tester, + ) async { + const unsaved = ''' +# New document + +Draft paragraph. + +## Unsaved target +'''; + final service = _StartupWorkspaceService(); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + validateOnEdit: false, + documentViewMode: DocumentViewModePreference.source, + ) + .toJson(); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text(l10n.createMarkdownFile)); + await tester.pumpAndSettle(); + final sourceField = find.descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byType(TextField), + ); + expect(sourceField, findsOneWidget); + await tester.enterText(sourceField, unsaved); + await tester.pump(); + + expect( + container.read(workspaceControllerProvider).workspace?.activeFilePath, + isNull, + ); + final outlineTree = find.byKey( + const ValueKey('workspace-sidebar-outline-tree'), + ); + final target = find.descendant( + of: outlineTree, + matching: find.text('Unsaved target'), + ); + expect(target, findsOneWidget); + + await tester.tap(target); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final controller = tester.widget(sourceField).controller!; + expect( + controller.selection, + TextSelection.collapsed(offset: unsaved.indexOf('## Unsaved target')), + ); + expect(service.saveCount, 0); + }); + testWidgets('preview tolerates duplicate heading anchors', (tester) async { const startupPath = '/tmp/duplicate-headings.md'; const source = ''' diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index 1240ea9..5e9ae1e 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -1,9 +1,14 @@ +import 'dart:async'; + import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/app/busymark_glyphs.dart'; import 'package:busymark/src/editor/document_layout.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; void main() { test('Split Preview stays fluid without copying Source-only chrome', () { @@ -134,81 +139,143 @@ void main() { expect(tocRtl.right, BusyMarkSpacing.sm); }); - testWidgets('elevated header controls use the shared surface shadow', ( + testWidgets('header controls delegate geometry and elevation to Yaru', ( tester, ) async { - late BuildContext themedContext; + final theme = buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); await tester.pumpWidget( MaterialApp( - home: Builder( - builder: (context) { - themedContext = context; - return Scaffold( - body: Row( - children: [ - BusyMarkHeaderIconButton( - key: const ValueKey('elevated-icon-button'), - tooltip: 'Elevated action', - icon: BusyMarkGlyphs.edit, - elevated: true, - borderRadius: BusyMarkRadius.lg, - onPressed: () {}, - ), - BusyMarkHeaderIconButton( - key: const ValueKey('flat-icon-button'), - tooltip: 'Flat action', - icon: BusyMarkGlyphs.edit, - onPressed: () {}, - ), - BusyMarkHeaderPopupMenuButton( - key: const ValueKey('elevated-popup-button'), - tooltip: 'Elevated menu', - icon: BusyMarkGlyphs.menuVertical, - elevated: true, - itemBuilder: (_) => const [ - BusyMarkPopupMenuItem(value: 'action', label: 'Action'), - ], - onSelected: (_) {}, - ), + theme: theme, + home: Scaffold( + body: Row( + children: [ + BusyMarkHeaderIconButton( + key: const ValueKey('elevated-icon-button'), + tooltip: 'Elevated action', + icon: BusyMarkGlyphs.edit, + elevated: true, + borderRadius: BusyMarkRadius.lg, + onPressed: () {}, + ), + BusyMarkHeaderIconButton( + key: const ValueKey('flat-icon-button'), + tooltip: 'Flat action', + icon: BusyMarkGlyphs.edit, + onPressed: () {}, + ), + const BusyMarkHeaderIconButton( + key: ValueKey('disabled-accented-icon-button'), + tooltip: 'Disabled accented action', + icon: BusyMarkGlyphs.edit, + accented: true, + onPressed: null, + ), + const BusyMarkCompactIconButton( + key: ValueKey('disabled-compact-icon-button'), + tooltip: 'Disabled compact action', + icon: BusyMarkGlyphs.clear, + foregroundColor: Color(0xFF7764D8), + onPressed: null, + ), + BusyMarkHeaderPopupMenuButton( + key: const ValueKey('elevated-popup-button'), + tooltip: 'Elevated menu', + icon: BusyMarkGlyphs.menuVertical, + elevated: true, + itemBuilder: (_) => [ + BusyMarkPopupMenuItem(value: 'action', label: 'Action'), ], + onSelected: (_) {}, ), - ); - }, + ], + ), ), ), ); - List shadowDecorations(Finder control) { - return tester - .widgetList( - find.descendant(of: control, matching: find.byType(DecoratedBox)), - ) - .map((widget) => widget.decoration) - .whereType() - .where((decoration) => decoration.boxShadow?.isNotEmpty ?? false) - .toList(); + YaruIconButton yaruButton(String key) { + return tester.widget( + find.descendant( + of: find.byKey(ValueKey(key)), + matching: find.byType(YaruIconButton), + ), + ); } - final expectedShadows = BusyMarkShadow.surfaceShadowsFor(themedContext); - final iconDecorations = shadowDecorations( - find.byKey(const ValueKey('elevated-icon-button')), + final elevatedIcon = yaruButton('elevated-icon-button'); + final flatIcon = yaruButton('flat-icon-button'); + final disabledAccentedIcon = yaruButton('disabled-accented-icon-button'); + final disabledCompactIcon = yaruButton('disabled-compact-icon-button'); + final elevatedPopup = yaruButton('elevated-popup-button'); + final expectedElevation = + theme.cardTheme.elevation ?? BusyMarkElevation.surface; + final colors = theme.extension()!; + + expect(elevatedIcon.iconSize, BusyMarkSizes.iconButton); + expect(elevatedIcon.isSelected, isFalse); + expect(elevatedIcon.style?.tapTargetSize, MaterialTapTargetSize.shrinkWrap); + expect( + tester.getSize( + find.descendant( + of: find.byKey(const ValueKey('elevated-icon-button')), + matching: find.byType(YaruIconButton), + ), + ), + const Size.square(BusyMarkSizes.iconButton), ); - final popupDecorations = shadowDecorations( - find.byKey(const ValueKey('elevated-popup-button')), + expect( + tester.getSize( + find.descendant( + of: find.byKey(const ValueKey('elevated-popup-button')), + matching: find.byType(YaruIconButton), + ), + ), + const Size.square(BusyMarkSizes.iconButton), ); - - expect(iconDecorations, hasLength(1)); - expect(iconDecorations.single.boxShadow, expectedShadows); + expect(elevatedIcon.style?.elevation?.resolve({}), expectedElevation); expect( - iconDecorations.single.borderRadius, - BorderRadius.circular(BusyMarkRadius.lg), + elevatedIcon.style?.shadowColor?.resolve({}), + theme.colorScheme.shadow, ); - expect(popupDecorations, hasLength(1)); - expect(popupDecorations.single.boxShadow, expectedShadows); + expect(elevatedPopup.style?.elevation?.resolve({}), expectedElevation); + expect(flatIcon.style?.elevation, isNull); + expect(flatIcon.style?.backgroundColor, isNull); + expect(elevatedIcon.style?.backgroundColor?.resolve({}), colors.control); + expect(elevatedPopup.style?.backgroundColor?.resolve({}), colors.control); expect( - shadowDecorations(find.byKey(const ValueKey('flat-icon-button'))), - isEmpty, + disabledAccentedIcon.style?.foregroundColor?.resolve({ + WidgetState.disabled, + }), + colors.disabledForeground, + ); + expect( + disabledAccentedIcon.style?.backgroundColor?.resolve({ + WidgetState.disabled, + }), + colors.disabledControl, + ); + expect( + disabledCompactIcon.style?.foregroundColor?.resolve({ + WidgetState.disabled, + }), + colors.disabledForeground, + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('elevated-icon-button')), + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is BoxDecoration && + ((widget.decoration as BoxDecoration).boxShadow?.isNotEmpty ?? + false), + ), + ), + findsNothing, ); }); @@ -218,28 +285,675 @@ void main() { await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Column( - children: [ - BusyMarkHeaderIconButton( - tooltip: 'Main menu', - icon: BusyMarkGlyphs.menuVertical, - onPressed: () {}, - ), - const BusyMarkPopupMenuItem( + body: BusyMarkHeaderPopupMenuButton( + tooltip: 'Main menu', + icon: BusyMarkGlyphs.menuVertical, + itemBuilder: (_) => [ + BusyMarkPopupMenuItem( value: 'editor', label: 'Editor', shortcut: 'Ctrl+1', ), ], + onSelected: (_) {}, ), ), ), ); + await tester.tap(find.byTooltip('Main menu')); + await tester.pumpAndSettle(); expect(find.text('Editor'), findsOneWidget); expect(find.text('Ctrl+1'), findsOneWidget); expect(find.byTooltip('Editor (Ctrl+1)'), findsNothing); expect(find.byTooltip('Main menu'), findsOneWidget); + expect( + FocusManager.instance.primaryFocus, + isA(), + reason: + 'The popup route, rather than its first row, should own initial ' + 'focus so Escape works without an unwanted row focus ring.', + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text('Editor'), findsNothing); }, ); + + testWidgets('header popup preserves asynchronous menu loading', ( + tester, + ) async { + final items = Completer>>(); + String? selection; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BusyMarkHeaderPopupMenuButton( + tooltip: 'Async menu', + icon: BusyMarkGlyphs.menuVertical, + itemBuilder: (_) => items.future, + onSelected: (value) => selection = value, + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Async menu')); + await tester.pump(); + expect(find.text('Loaded action'), findsNothing); + + items.complete([ + BusyMarkPopupMenuItem(value: 'loaded', label: 'Loaded action'), + ]); + await tester.pumpAndSettle(); + expect(find.text('Loaded action'), findsOneWidget); + + await tester.tap(find.text('Loaded action')); + await tester.pumpAndSettle(); + expect(selection, 'loaded'); + }); + + test('semantic theme retains Yaru geometry, typography, and interaction', () { + const accent = Color(0xFF7764D8); + + for (final brightness in Brightness.values) { + final base = switch (brightness) { + Brightness.light => createYaruLightTheme(primaryColor: accent), + Brightness.dark => createYaruDarkTheme(primaryColor: accent), + }; + final theme = buildBusyMarkTheme( + brightness: brightness, + accentColor: accent, + ); + + expect(theme.colorScheme.primary, accent); + expect(theme.colorScheme.secondary, accent); + expect( + _contrastRatio(theme.colorScheme.onPrimary, theme.colorScheme.primary), + greaterThanOrEqualTo(4.5), + ); + expect( + _contrastRatio( + theme.colorScheme.onPrimaryContainer, + theme.colorScheme.primaryContainer, + ), + greaterThanOrEqualTo(4.5), + ); + expect( + _contrastRatio(theme.colorScheme.onError, theme.colorScheme.error), + greaterThanOrEqualTo(4.5), + ); + expect(theme.visualDensity, base.visualDensity); + expect(theme.splashFactory.runtimeType, base.splashFactory.runtimeType); + expect( + theme.textTheme.bodyMedium?.fontFamily, + base.textTheme.bodyMedium?.fontFamily, + ); + expect( + theme.textTheme.bodyMedium?.letterSpacing, + base.textTheme.bodyMedium?.letterSpacing, + ); + expect(theme.dialogTheme.shape, base.dialogTheme.shape); + final colors = theme.extension()!; + expect(theme.colorScheme.surface, colors.view); + expect(theme.colorScheme.onSurface, colors.foreground); + expect(theme.colorScheme.onSurfaceVariant, colors.mutedForeground); + expect(theme.colorScheme.surfaceContainerLowest, colors.view); + expect(theme.colorScheme.surfaceContainerLow, colors.window); + expect(theme.colorScheme.surfaceContainer, colors.panel); + expect(theme.colorScheme.surfaceContainerHigh, colors.secondarySidebar); + expect(theme.colorScheme.surfaceContainerHighest, colors.sidebar); + final containers = [ + theme.colorScheme.surfaceContainerLowest, + theme.colorScheme.surfaceContainerLow, + theme.colorScheme.surfaceContainer, + theme.colorScheme.surfaceContainerHigh, + theme.colorScheme.surfaceContainerHighest, + ]; + for (final color in containers) { + expect(color.a, 1, reason: '$brightness surface containers are opaque'); + } + final containerLuminances = [ + for (final color in containers) color.computeLuminance(), + ]; + for (var index = 0; index < containerLuminances.length - 1; index++) { + final current = containerLuminances[index]; + final next = containerLuminances[index + 1]; + expect( + brightness == Brightness.light ? current >= next : current <= next, + isTrue, + reason: '$brightness surface containers follow elevation order', + ); + } + expect(theme.colorScheme.outline, colors.border); + expect(theme.colorScheme.outlineVariant, colors.divider); + expect(theme.colorScheme.scrim, BusyMarkLinuxPalette.black); + expect( + theme.inputDecorationTheme.filled, + base.inputDecorationTheme.filled, + ); + expect( + theme.inputDecorationTheme.fillColor, + base.inputDecorationTheme.fillColor, + ); + expect( + theme.inputDecorationTheme.border, + base.inputDecorationTheme.border, + ); + expect( + theme.inputDecorationTheme.enabledBorder, + base.inputDecorationTheme.enabledBorder, + ); + expect( + theme.inputDecorationTheme.focusedBorder, + base.inputDecorationTheme.focusedBorder, + ); + expect( + theme.inputDecorationTheme.contentPadding, + base.inputDecorationTheme.contentPadding, + ); + expect( + theme.dropdownMenuTheme.inputDecorationTheme, + base.dropdownMenuTheme.inputDecorationTheme, + ); + expect( + theme.filledButtonTheme.style?.minimumSize?.resolve({}), + base.filledButtonTheme.style?.minimumSize?.resolve({}), + ); + expect( + theme.filledButtonTheme.style?.padding?.resolve({}), + base.filledButtonTheme.style?.padding?.resolve({}), + ); + expect( + theme.filledButtonTheme.style?.shape?.resolve({}), + base.filledButtonTheme.style?.shape?.resolve({}), + ); + expect( + theme.filledButtonTheme.style?.overlayColor?.resolve({ + WidgetState.hovered, + }), + base.filledButtonTheme.style?.overlayColor?.resolve({ + WidgetState.hovered, + }), + ); + final segmentedShape = theme.segmentedButtonTheme.style?.shape?.resolve( + const {}, + ); + expect(segmentedShape, isA()); + expect(segmentedShape, isNot(isA())); + expect( + (segmentedShape! as RoundedRectangleBorder).borderRadius, + BorderRadius.circular(kYaruButtonRadius), + ); + expect( + theme.segmentedButtonTheme.style?.padding?.resolve({}), + base.filledButtonTheme.style?.padding?.resolve({}), + ); + expect( + theme.segmentedButtonTheme.style?.minimumSize?.resolve({}), + base.filledButtonTheme.style?.minimumSize?.resolve({}), + ); + } + }); + + test('semantic surfaces use one modern neutral Linux role ladder', () { + const blue = Color(0xFF3584E4); + const orange = Color(0xFFE95420); + + BusyMarkSurfaceColors colors(Brightness brightness, Color accent) { + final base = switch (brightness) { + Brightness.light => createYaruLightTheme(primaryColor: accent), + Brightness.dark => createYaruDarkTheme(primaryColor: accent), + }; + return BusyMarkSurfaceColors.fromTheme(base); + } + + final light = colors(Brightness.light, blue); + expect(light.window, const Color(0xFFFAFAFA)); + expect(light.view, const Color(0xFFFFFFFF)); + expect(light.sidebar, const Color(0xFFEBEBEB)); + expect(light.secondarySidebar, const Color(0xFFF0F0F0)); + expect(light.headerbar, const Color(0xFFFAFAFA)); + expect(light.card, const Color(0xFFFFFFFF)); + expect(light.groupedList, light.card); + expect(light.dialog, const Color(0xFFFAFAFA)); + expect(light.popover, const Color(0xFFFAFAFA)); + expect(light.control, const Color.fromRGBO(0, 0, 0, 0.10)); + expect(light.controlHover, const Color.fromRGBO(0, 0, 0, 0.14)); + expect(light.controlActive, const Color.fromRGBO(0, 0, 0, 0.18)); + + final dark = colors(Brightness.dark, blue); + expect(dark.window, const Color(0xFF2C2C2C)); + expect(dark.view, const Color(0xFF272727)); + expect(dark.sidebar, const Color(0xFF393939)); + expect(dark.secondarySidebar, const Color(0xFF323232)); + expect(dark.headerbar, const Color(0xFF393939)); + expect(dark.card, const Color(0xFF3D3D3D)); + expect(dark.groupedList, dark.card); + expect(dark.dialog, const Color(0xFF3E3E3E)); + expect(dark.popover, const Color(0xFF3E3E3E)); + expect(dark.control, const Color.fromRGBO(255, 255, 255, 0.10)); + expect(dark.controlHover, const Color.fromRGBO(255, 255, 255, 0.14)); + expect(dark.controlActive, const Color.fromRGBO(255, 255, 255, 0.18)); + + final orangeLight = colors(Brightness.light, orange); + final orangeDark = colors(Brightness.dark, orange); + expect(orangeLight.window, light.window); + expect(orangeLight.sidebar, light.sidebar); + expect(orangeLight.groupedList, light.groupedList); + expect(orangeDark.window, dark.window); + expect(orangeDark.sidebar, dark.sidebar); + expect(orangeDark.groupedList, dark.groupedList); + for (final palette in [light, dark]) { + expect(palette.mutedForeground.a, 1); + for (final background in [ + palette.view, + palette.window, + palette.sidebar, + palette.secondarySidebar, + palette.headerbar, + palette.headerbarFlat, + palette.panel, + palette.card, + palette.groupedList, + palette.dialog, + palette.popover, + ]) { + expect( + _contrastRatio(palette.mutedForeground, background), + greaterThanOrEqualTo(4.5), + reason: 'Muted text must remain legible on $background', + ); + } + expect(palette.admonitionNote, isNot(palette.groupedList)); + expect(palette.admonitionTip, isNot(palette.groupedList)); + expect(palette.admonitionWarning, isNot(palette.groupedList)); + } + }); + + test('equal-choice segmented selection stays neutral', () { + const accent = Color(0xFFED5B00); + final theme = buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: accent, + ); + final colors = theme.extension()!; + final style = theme.segmentedButtonTheme.style!; + + expect(style.backgroundColor?.resolve({}), colors.control); + expect( + style.backgroundColor?.resolve({WidgetState.selected}), + colors.controlActive, + ); + expect( + style.foregroundColor?.resolve({WidgetState.selected}), + colors.foreground, + ); + expect( + style.backgroundColor?.resolve({WidgetState.selected}), + isNot(accent), + ); + expect(style.side?.resolve({WidgetState.selected}), BorderSide.none); + }); + + testWidgets('grouped cards use one semantic raised-surface token', ( + tester, + ) async { + final theme = buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + backgroundColor: colors.dialog, + body: Column( + children: [ + const BusyMarkSurface(child: SizedBox(height: 20)), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkActionRow(title: 'One', onTap: () {}), + BusyMarkActionRow(title: 'Two', onTap: () {}), + ], + ), + ], + ), + ), + ), + ); + + final groupedMaterial = tester.widget( + find.descendant( + of: find.byType(BusyMarkGroupedSurface), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == colors.groupedList, + ), + ), + ); + expect(groupedMaterial.elevation, BusyMarkElevation.surface); + expect(groupedMaterial.shadowColor, theme.colorScheme.shadow); + expect( + (groupedMaterial.shape! as RoundedRectangleBorder).borderRadius, + BorderRadius.circular(BusyMarkRadius.lg), + ); + + final cardMaterial = tester.widget( + find.descendant( + of: find.byType(BusyMarkSurface).first, + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == colors.card, + ), + ), + ); + expect(cardMaterial.elevation, BusyMarkElevation.surface); + expect(cardMaterial.shadowColor, theme.colorScheme.shadow); + expect( + (cardMaterial.shape! as RoundedRectangleBorder).borderRadius, + BorderRadius.circular(BusyMarkRadius.lg), + ); + expect(tester.widget(find.byType(Divider)).color, colors.divider); + }); + + testWidgets('dialog roles and popup selectors use real themed buttons', ( + tester, + ) async { + final theme = buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + String? selectedTheme; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Column( + children: [ + BusyMarkDialogButton(label: 'Cancel', onPressed: () {}), + BusyMarkDialogButton( + label: 'Save', + suggested: true, + onPressed: () {}, + ), + BusyMarkDialogButton( + label: 'Delete', + destructive: true, + onPressed: () {}, + ), + BusyMarkPopupSelector( + value: 'system', + label: 'System', + tooltip: 'Theme', + options: const [ + BusyMarkPopupSelectorOption(value: 'system', label: 'System'), + BusyMarkPopupSelectorOption(value: 'light', label: 'Light'), + ], + onSelected: (value) => selectedTheme = value, + ), + ], + ), + ), + ), + ); + + expect( + find.descendant( + of: find.widgetWithText(BusyMarkDialogButton, 'Cancel'), + matching: find.byType(FilledButton), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.widgetWithText(BusyMarkDialogButton, 'Save'), + matching: find.byType(ElevatedButton), + ), + findsOneWidget, + ); + final destructive = tester.widget( + find.descendant( + of: find.widgetWithText(BusyMarkDialogButton, 'Delete'), + matching: find.byType(ElevatedButton), + ), + ); + expect( + destructive.style?.foregroundColor?.resolve({}), + theme.colorScheme.onError, + ); + expect( + destructive.style?.backgroundColor?.resolve({}), + theme.colorScheme.error, + ); + + final selectorFinder = find.descendant( + of: find.byType(BusyMarkPopupSelector), + matching: find.byType(YaruPopupMenuButton), + ); + final selector = tester.widget>(selectorFinder); + expect(selector.style?.backgroundColor?.resolve({}), colors.control); + expect(selector.initialValue, 'system'); + expect(selector.enabled, isTrue); + + await tester.tap(selectorFinder); + await tester.pumpAndSettle(); + expect(find.text('Light'), findsOneWidget); + await tester.tap(find.text('Light')); + await tester.pumpAndSettle(); + expect(selectedTheme, 'light'); + }); + + testWidgets('dialog actions wrap at narrow localized text widths', ( + tester, + ) async { + final theme = buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: const Color(0xFFE95420), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(1.4)), + child: SizedBox( + width: 320, + height: 520, + child: BusyMarkDialogShell( + title: 'Unsaved changes', + actions: [ + BusyMarkDialogButton( + label: 'Keep editing', + onPressed: () {}, + ), + BusyMarkDialogButton( + label: 'Discard changes', + destructive: true, + onPressed: () {}, + ), + BusyMarkDialogButton( + label: 'Save changes', + suggested: true, + onPressed: () {}, + ), + ], + children: const [Text('Choose how to continue.')], + ), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(OverflowBar), findsOneWidget); + final actionRows = { + for (final label in ['Keep editing', 'Discard changes', 'Save changes']) + tester.getCenter(find.text(label)).dy, + }; + expect(actionRows.length, greaterThan(1)); + }); + + testWidgets('disabled destructive rows use the disabled semantic color', ( + tester, + ) async { + final theme = buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: const Color(0xFFE95420), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Column( + children: [ + BusyMarkActionRow( + title: 'Enabled delete', + destructive: true, + onTap: () {}, + ), + const BusyMarkActionRow( + title: 'Disabled delete', + destructive: true, + enabled: false, + ), + ], + ), + ), + ), + ); + + expect( + tester.widget(find.text('Enabled delete')).style?.color, + theme.colorScheme.error, + ); + expect( + tester.widget(find.text('Disabled delete')).style?.color, + colors.disabledForeground, + ); + }); + + testWidgets('shared text-entry group delegates fields to the framework', ( + tester, + ) async { + final first = TextEditingController(); + final second = TextEditingController(); + addTearDown(first.dispose); + addTearDown(second.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), + home: Scaffold( + body: BusyMarkFloatingTextEntryGroup( + children: [ + BusyMarkFloatingTextEntry(label: 'Name', controller: first), + BusyMarkFloatingTextEntry( + label: 'Description', + controller: second, + errorText: 'Required', + ), + ], + ), + ), + ), + ); + + expect(find.byType(AutofillGroup), findsOneWidget); + expect(find.byType(TextFormField), findsNWidgets(2)); + expect(find.text('Required'), findsOneWidget); + }); + + testWidgets('semantic standard icon button delegates to FilledButton.icon', ( + tester, + ) async { + var pressed = false; + + await tester.pumpWidget( + MaterialApp( + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), + home: Scaffold( + body: BusyMarkPushButton.standardIcon( + key: const ValueKey('semantic-standard-icon-button'), + icon: const Icon(BusyMarkGlyphs.edit), + label: const Text('Refactor'), + onPressed: () => pressed = true, + ), + ), + ), + ); + + final button = find.byKey(const ValueKey('semantic-standard-icon-button')); + expect(button, findsOneWidget); + expect(find.descendant(of: button, matching: find.byType(Icon)), findsOne); + expect( + find.descendant(of: button, matching: find.text('Refactor')), + findsOne, + ); + + await tester.tap(button); + expect(pressed, isTrue); + }); + + testWidgets('sidebar surface owns one directional semantic boundary', ( + tester, + ) async { + final theme = buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Directionality( + textDirection: TextDirection.rtl, + child: BusyMarkSidebarSurface(child: SizedBox(width: 100)), + ), + ), + ); + + final box = tester.widget( + find.descendant( + of: find.byType(BusyMarkSidebarSurface), + matching: find.byType(DecoratedBox), + ), + ); + final decoration = box.decoration as BoxDecoration; + expect(decoration.color, colors.sidebar); + expect( + (decoration.border! as BorderDirectional).end.color, + colors.sidebarBorder, + ); + }); +} + +double _contrastRatio(Color foreground, Color background) { + final foregroundLuminance = foreground.computeLuminance(); + final backgroundLuminance = background.computeLuminance(); + final lighter = foregroundLuminance > backgroundLuminance + ? foregroundLuminance + : backgroundLuminance; + final darker = foregroundLuminance > backgroundLuminance + ? backgroundLuminance + : foregroundLuminance; + return (lighter + 0.05) / (darker + 0.05); } diff --git a/test/src/busymark_dialogs_test.dart b/test/src/busymark_dialogs_test.dart index e7b7d16..56df889 100644 --- a/test/src/busymark_dialogs_test.dart +++ b/test/src/busymark_dialogs_test.dart @@ -1,5 +1,6 @@ import 'package:busymark/src/app/busymark_dialogs.dart'; import 'package:busymark/src/app/busymark_shortcuts.dart'; +import 'package:busymark/src/platform/linux_header_bar_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -76,6 +77,68 @@ void main() { expect(documentViewShortcutInvocations, 0); expect(find.text('Dismiss'), findsOneWidget); }); + + testWidgets('overlapping dialogs retain one native modal barrier lease', ( + tester, + ) async { + const channel = MethodChannel('com.busymark.test/modal-barrier'); + final barrierStates = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + if (call.method == 'initialize') { + return true; + } + if (call.method == 'setModalBarrierVisible') { + barrierStates.add(call.arguments as bool); + } + return null; + }); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + }); + final headerBar = LinuxHeaderBarService(channel: channel); + late BuildContext hostContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + hostContext = context; + return const Scaffold(body: SizedBox.expand()); + }, + ), + ), + ); + + final first = showBusyMarkModalDialog( + hostContext, + headerBarService: headerBar, + builder: (_) => const Text('First dialog'), + ); + await tester.pumpAndSettle(); + final second = showBusyMarkModalDialog( + hostContext, + headerBarService: headerBar, + builder: (_) => const Text('Second dialog'), + ); + await tester.pumpAndSettle(); + + expect(barrierStates, [isTrue]); + + Navigator.of(hostContext, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + await second; + expect(barrierStates, [isTrue]); + + Navigator.of(hostContext, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + await first; + expect(barrierStates, [isTrue, isFalse]); + }); } Future _pressControlShortcut( diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index 563de5d..dc5fe06 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -21,6 +21,7 @@ import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_toolbar.dart'; import 'package:busymark/src/markdown/busymark_document.dart'; import 'package:busymark/src/markdown/busymark_markdown_serializer.dart'; +import 'package:busymark/src/markdown/document_outline.dart'; import 'package:busymark/src/markdown/markdown_model.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; import 'package:busymark/src/markdown/preview_model.dart'; @@ -31,6 +32,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:markdown/markdown.dart' as md; +import 'package:yaru/yaru.dart'; void main() { const parser = MarkdownParser(); @@ -481,7 +483,7 @@ void main() {} document: parsed.busyDocument, ); - expect(heading.id, 'source-only-0'); + expect(heading.attributes['id'], 'source-only-0'); expect(opaque.id, isNot(heading.id)); controller.applyBlockCommand(heading.id, BusyWysiwygBlockCommand.paragraph); @@ -837,6 +839,90 @@ void main() {} expect(parsed.anchors, containsAll(['title', 'section'])); }); + test('live document outline follows top-level generated heading order', () { + const headingAttributes = {'level': '1', 'generatedId': 'true'}; + const nestedHeading = BusyBlock( + id: 'nested-heading', + kind: BusyBlockKind.heading, + inlines: [BusyInline(kind: BusyInlineKind.text, text: 'Same')], + attributes: headingAttributes, + ); + const firstHeading = BusyBlock( + id: 'first-heading', + kind: BusyBlockKind.heading, + inlines: [BusyInline(kind: BusyInlineKind.text, text: 'Same')], + attributes: headingAttributes, + ); + const secondHeading = BusyBlock( + id: 'second-heading', + kind: BusyBlockKind.heading, + inlines: [BusyInline(kind: BusyInlineKind.text, text: 'Same')], + attributes: headingAttributes, + ); + const document = BusyDocument( + filePath: 'topic.md', + mode: MarkdownMode.commonMark, + blocks: [ + BusyBlock( + id: 'quote', + kind: BusyBlockKind.blockquote, + children: [nestedHeading], + ), + firstHeading, + secondHeading, + ], + ); + + expect(document.outline.map((heading) => heading.id), ['same', 'same-1']); + expect(document.outline.map((heading) => heading.editorBlockId), [ + 'first-heading', + 'second-heading', + ]); + + final renamed = document.copyWith( + blocks: [ + document.blocks.first, + firstHeading.copyWith( + inlines: const [BusyInline(kind: BusyInlineKind.text, text: 'Other')], + ), + secondHeading, + ], + ); + expect(renamed.outline.map((heading) => heading.id), ['other', 'same']); + }); + + test('duplicate anchors retain distinct editor block identities', () { + final parsed = parser.parse( + filePath: 'topic.md', + source: + '# First {id="same"}\n\n' + '# Second {id="same"}\n', + ); + final headingBlocks = parsed.busyDocument.blocks + .where((block) => block.kind == BusyBlockKind.heading) + .toList(); + + expect(headingBlocks.map((block) => block.id).toSet(), hasLength(2)); + expect( + headingBlocks.map((block) => block.attributes['id']), + everyElement('same'), + ); + expect(parsed.busyDocument.outline.map((heading) => heading.id), [ + 'same', + 'same', + ]); + expect( + parsed.busyDocument.outline + .map((heading) => heading.editorBlockId) + .toSet(), + hasLength(2), + ); + expect( + parsed.diagnostics.map((diagnostic) => diagnostic.code), + contains('markdown.heading.duplicate-id'), + ); + }); + test('preview and WYSIWYG use the same semantic document', () { final parsed = parser.parse( filePath: 'topic.md', @@ -2322,10 +2408,10 @@ void main() {} ); await tester.pump(); - IconButton editingToggle(String tooltip) { - return tester.widget( + YaruIconButton editingToggle(String tooltip) { + return tester.widget( find.byWidgetPredicate( - (widget) => widget is IconButton && widget.tooltip == tooltip, + (widget) => widget is YaruIconButton && widget.tooltip == tooltip, ), ); } @@ -3641,9 +3727,9 @@ void main() {} expect(find.text('Apply'), findsOneWidget); expect(find.byType(BusyMarkDialogShell), findsOneWidget); expect(find.byType(BusyMarkFloatingTextEntry), findsNWidgets(2)); + expect(find.byType(TextFormField), findsNWidgets(2)); expect(find.byType(BusyMarkDialogButton), findsNWidgets(3)); expect(find.byType(AlertDialog), findsNothing); - expect(find.byType(TextField), findsNothing); final dialogRect = tester.getRect(find.byType(BusyMarkDialogShell)); final sourceEntryRect = tester.getRect( find.byKey(BusyMarkImageDialogKeys.source), diff --git a/test/src/busymark_search_field_test.dart b/test/src/busymark_search_field_test.dart new file mode 100644 index 0000000..d157259 --- /dev/null +++ b/test/src/busymark_search_field_test.dart @@ -0,0 +1,94 @@ +import 'package:busymark/src/app/busymark_search_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +void main() { + testWidgets('search fallback delegates behavior and geometry to Yaru', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + String? changedQuery; + String? submittedQuery; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BusyMarkSearchField( + controller: controller, + hintText: 'Search documents', + onChanged: (value) => changedQuery = value, + onSubmitted: (value) => submittedQuery = value, + ), + ), + ), + ); + + expect(find.byType(YaruSearchField), findsOneWidget); + expect(find.text('Search documents'), findsOneWidget); + + await tester.enterText(find.byType(EditableText), 'native'); + expect(changedQuery, 'native'); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + expect(submittedQuery, 'native'); + }); + + testWidgets('focus request targets the Yaru-owned text entry', ( + tester, + ) async { + var focusRequest = 0; + late StateSetter setState; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, update) { + setState = update; + return Scaffold( + body: BusyMarkSearchField(focusRequest: focusRequest), + ); + }, + ), + ), + ); + + setState(() => focusRequest += 1); + await tester.pump(); + await tester.pump(); + + final editable = tester.widget(find.byType(EditableText)); + expect(editable.focusNode.hasFocus, isTrue); + }); + + testWidgets('Escape keeps Yaru clear behavior and closes the owner', ( + tester, + ) async { + final controller = TextEditingController(text: 'query'); + addTearDown(controller.dispose); + var escapeCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BusyMarkSearchField( + controller: controller, + autofocus: true, + onClear: controller.clear, + onEscape: () => escapeCount++, + ), + ), + ), + ); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + expect(controller.text, isEmpty); + expect(escapeCount, 1); + }); +} diff --git a/test/src/editor_ui_primitives_audit_test.dart b/test/src/editor_ui_primitives_audit_test.dart new file mode 100644 index 0000000..1b59db0 --- /dev/null +++ b/test/src/editor_ui_primitives_audit_test.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('source editor overlays use shared semantic surfaces', () { + final source = File( + 'lib/src/editor/source/source_editor.dart', + ).readAsStringSync(); + final searchPanel = RegExp( + r'class _SourceSearchPanel[\s\S]*?class _SearchPanelIconButton', + ).firstMatch(source)!.group(0)!; + final searchButtons = RegExp( + r'class _SearchPanelIconButton[\s\S]*?class _SourceLargeFileBanner', + ).firstMatch(source)!.group(0)!; + final largeFileBanner = RegExp( + r'class _SourceLargeFileBanner[\s\S]*?' + r'class _SourceEditorShortcutIntent', + ).firstMatch(source)!.group(0)!; + + expect(searchPanel, contains('return BusyMarkSurface(')); + expect(searchPanel, contains('color: colors.panel')); + expect(searchPanel, isNot(contains('DecoratedBox('))); + expect(searchPanel, isNot(contains('Border.all('))); + expect(searchPanel, isNot(contains('elevation:'))); + expect(RegExp(r'YaruIconButton\(').allMatches(searchButtons).length, 2); + expect(searchButtons, contains('iconSize: 28')); + expect(searchButtons, contains('isSelected: selected')); + expect(searchButtons, isNot(contains('WidgetStateProperty.resolveWith'))); + expect(searchButtons, isNot(contains('backgroundColor:'))); + expect(searchButtons, isNot(contains('fixedSize:'))); + expect(largeFileBanner, contains('return ConstrainedBox(')); + expect(largeFileBanner, contains('child: BusyMarkStatusBox(')); + expect(largeFileBanner, contains('maxWidth: BusyMarkSizes.dialogCompact')); + expect(largeFileBanner, isNot(contains('Material('))); + expect(largeFileBanner, isNot(contains('DecoratedBox('))); + }); + + test('WYSIWYG content actions and table menus use shared controls', () { + final widgets = File( + 'lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart', + ).readAsStringSync(); + final htmlBlock = RegExp( + r'class _RenderedHtmlBlock[\s\S]*?class _RenderedHtmlBlocks', + ).firstMatch(widgets)!.group(0)!; + final tableEditor = RegExp( + r'class _TableBlockEditor[\s\S]*?class _TableCornerCell', + ).firstMatch(widgets)!.group(0)!; + final tableMenu = RegExp( + r'class _TableControlMenuButton[\s\S]*?class _TableCellEditor', + ).firstMatch(widgets)!.group(0)!; + + expect(htmlBlock, contains('BusyMarkHeaderIconButton(')); + expect(htmlBlock, isNot(matches(RegExp(r'(?(find.byType(TextField)); + expect(commitField.decoration?.border, isNull); + expect(commitField.decoration?.filled, isNull); await tester.tap(find.text(l10n.gitCommit)); await tester.pump(); expect(committedMessage, isNull); diff --git a/test/src/header_bar_configuration_test.dart b/test/src/header_bar_configuration_test.dart new file mode 100644 index 0000000..e09bcb5 --- /dev/null +++ b/test/src/header_bar_configuration_test.dart @@ -0,0 +1,489 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:busymark/src/platform/linux_header_bar_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'rapid navigation applies only monotonic complete configurations', + () async { + if (!Platform.isLinux) { + return; + } + const channel = MethodChannel( + 'com.busymark.test/headerbar-atomic-navigation', + ); + final atomicCalls = >[]; + Map? initializePayload; + final firstApplyStarted = Completer(); + final releaseFirstApply = Completer(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'initialize') { + initializePayload = Map.from( + call.arguments! as Map, + ); + return true; + } + if (call.method != 'applyConfiguration') { + fail('Unexpected legacy call: ${call.method}'); + } + final payload = Map.from( + call.arguments! as Map, + ); + atomicCalls.add(payload); + if (atomicCalls.length == 1) { + firstApplyStarted.complete(); + await releaseFirstApply.future; + } + return payload['revision']; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService( + channel: channel, + sessionId: 'navigation-session', + ); + await service.initialize(); + final workspace = _configuration( + title: 'Workspace', + documentControlsVisible: true, + searchVisible: true, + sidebarVisible: true, + sidebarToggleVisible: true, + backVisible: true, + ); + final settings = _configuration(title: 'Settings', backVisible: true); + + final workspaceApplied = service.configurationSynchronizer + .setConfiguration(workspace); + await firstApplyStarted.future; + final settingsApplied = service.configurationSynchronizer + .setConfiguration(settings); + final duplicateSettingsApplied = service.configurationSynchronizer + .setConfiguration(settings); + releaseFirstApply.complete(); + await Future.wait([ + workspaceApplied, + settingsApplied, + duplicateSettingsApplied, + ]); + + expect(atomicCalls, hasLength(2)); + expect(initializePayload, {'sessionId': 'navigation-session'}); + expect(atomicCalls.map((payload) => payload['sessionId']).toSet(), { + 'navigation-session', + }); + expect(atomicCalls.map((payload) => payload['revision']), [1, 2]); + expect(atomicCalls.map((payload) => payload['title']), [ + 'Workspace', + 'Settings', + ]); + expect(atomicCalls.last, { + 'sessionId': 'navigation-session', + 'revision': 2, + 'title': 'Settings', + 'viewMode': 'editor', + 'searchQuery': '', + 'textDirection': 'ltr', + 'canRefresh': false, + 'documentControlsVisible': false, + 'searchActive': false, + 'searchVisible': false, + 'sidebarVisible': false, + 'sidebarToggleVisible': false, + 'backVisible': true, + 'modalBarrierVisible': false, + 'sidebarWidth': 300.0, + 'labels': _labels.toMap(), + 'theme': _theme.toMap(), + }); + expect( + service.configurationSynchronizer.appliedConfiguration?.title, + 'Settings', + ); + }, + ); + + test('a new Dart session can restart native revisions from one', () async { + if (!Platform.isLinux) { + return; + } + const channel = MethodChannel( + 'com.busymark.test/headerbar-hot-restart-session', + ); + var activeSession = 'previous-session'; + var nativeRevision = 42; + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + final payload = Map.from( + call.arguments! as Map, + ); + final sessionId = payload['sessionId']! as String; + if (call.method == 'initialize') { + if (sessionId != activeSession) { + activeSession = sessionId; + nativeRevision = -1; + } + return true; + } + expect(call.method, 'applyConfiguration'); + expect(sessionId, activeSession); + final revision = payload['revision']! as int; + if (revision > nativeRevision) { + nativeRevision = revision; + } + return nativeRevision; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService( + channel: channel, + sessionId: 'restarted-session', + ); + + expect( + await service.configurationSynchronizer.setConfiguration( + _configuration(title: 'Welcome'), + ), + true, + ); + expect(service.isAvailable, true); + expect(activeSession, 'restarted-session'); + expect(nativeRevision, 1); + expect(calls.map((call) => call.method), [ + 'initialize', + 'applyConfiguration', + ]); + }); + + test('modal barrier is an atomic overlay on the latest page state', () async { + if (!Platform.isLinux) { + return; + } + const channel = MethodChannel('com.busymark.test/headerbar-atomic-modal'); + final atomicCalls = >[]; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'initialize') { + return true; + } + expect(call.method, 'applyConfiguration'); + final payload = Map.from( + call.arguments! as Map, + ); + atomicCalls.add(payload); + return payload['revision']; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService(channel: channel); + await service.initialize(); + await service.configurationSynchronizer.setConfiguration( + _configuration(title: 'Settings', backVisible: true), + ); + await service.setModalBarrierVisible(true); + await service.setModalBarrierVisible(true); + await service.setModalBarrierVisible(false); + + expect(atomicCalls, hasLength(3)); + expect(atomicCalls.map((payload) => payload['revision']), [1, 2, 3]); + expect(atomicCalls.map((payload) => payload['modalBarrierVisible']), [ + false, + true, + false, + ]); + expect( + atomicCalls.every((payload) => payload['title'] == 'Settings'), + true, + ); + }); + + test('modal barrier is retained before the first page publishes', () async { + if (!Platform.isLinux) { + return; + } + const channel = MethodChannel( + 'com.busymark.test/headerbar-atomic-early-modal', + ); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (call.method == 'initialize') { + return true; + } + if (call.method == 'applyConfiguration') { + final payload = Map.from( + call.arguments! as Map, + ); + return payload['revision']; + } + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService(channel: channel); + await service.setModalBarrierVisible(true); + await service.configurationSynchronizer.setConfiguration( + _configuration(title: 'Welcome'), + ); + + expect( + calls + .where((call) => call.method == 'setModalBarrierVisible') + .single + .arguments, + true, + ); + final atomicPayload = + calls + .where((call) => call.method == 'applyConfiguration') + .single + .arguments + as Map; + expect(atomicPayload['modalBarrierVisible'], true); + }); + + test('older runners fall back once to the ordered legacy protocol', () async { + if (!Platform.isLinux) { + return; + } + const channel = MethodChannel( + 'com.busymark.test/headerbar-atomic-fallback', + ); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + if (call.method == 'initialize') { + return true; + } + if (call.method == 'applyConfiguration') { + throw MissingPluginException(); + } + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService(channel: channel); + await service.initialize(); + await service.configurationSynchronizer.setConfiguration( + _configuration(title: 'Welcome'), + ); + await service.configurationSynchronizer.setConfiguration( + _configuration(title: 'Settings', backVisible: true), + ); + + expect( + calls.where((method) => method == 'applyConfiguration'), + hasLength(1), + ); + expect(calls.where((method) => method == 'setTitleRange'), hasLength(2)); + expect(calls.last, 'setModalBarrierVisible'); + expect(service.isAvailable, true); + }); + + test( + 'failed atomic apply keeps the last success and retries the same desired state', + () async { + if (!Platform.isLinux) { + return; + } + const channel = MethodChannel('com.busymark.test/headerbar-atomic-retry'); + final atomicCalls = >[]; + var initializeCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'initialize') { + initializeCalls++; + return true; + } + expect(call.method, 'applyConfiguration'); + final payload = Map.from( + call.arguments! as Map, + ); + atomicCalls.add(payload); + if (atomicCalls.length == 2) { + throw PlatformException(code: 'native_update_failed'); + } + return payload['revision']; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService(channel: channel); + final availabilityChanges = []; + service.addListener( + () => availabilityChanges.add(service.usesNativeHeaderBar), + ); + await service.initialize(); + final workspace = _configuration(title: 'Workspace'); + final settings = _configuration(title: 'Settings', backVisible: true); + + expect( + await service.configurationSynchronizer.setConfiguration(workspace), + true, + ); + expect( + await service.configurationSynchronizer.setConfiguration(settings), + false, + ); + + expect(service.usesNativeHeaderBar, false); + expect( + service.configurationSynchronizer.appliedConfiguration?.title, + 'Workspace', + ); + expect( + service.configurationSynchronizer.desiredConfiguration?.title, + 'Settings', + ); + + expect( + await service.configurationSynchronizer.setConfiguration(settings), + true, + ); + expect(service.usesNativeHeaderBar, true); + expect( + service.configurationSynchronizer.appliedConfiguration?.title, + 'Settings', + ); + expect(initializeCalls, 2); + expect(availabilityChanges, [true, false, true]); + expect(atomicCalls.map((payload) => payload['revision']), [1, 2, 2]); + expect(atomicCalls.map((payload) => payload['title']), [ + 'Workspace', + 'Settings', + 'Settings', + ]); + }, + ); + + test('theme map contains only structural colors with distinct borders', () { + expect(_theme.toMap().keys, { + 'preferDark', + 'backgroundColor', + 'sidebarBackgroundColor', + 'foregroundColor', + 'popoverBackgroundColor', + 'borderColor', + 'sidebarBorderColor', + 'floatingBorderColor', + 'modalBarrierColor', + }); + expect( + _theme.toMap(), + containsPair('sidebarBorderColor', 'rgba(1,2,3,0.067)'), + ); + expect( + _theme.toMap(), + containsPair('floatingBorderColor', 'rgba(4,5,6,0.133)'), + ); + expect( + _theme.toMap(), + containsPair('foregroundColor', 'rgba(32,32,32,1.000)'), + ); + expect( + _theme.toMap(), + containsPair('popoverBackgroundColor', 'rgba(250,250,250,1.000)'), + ); + expect( + _theme.toMap()['borderColor'], + isNot(_theme.toMap()['sidebarBorderColor']), + ); + expect( + _theme.toMap()['borderColor'], + isNot(_theme.toMap()['floatingBorderColor']), + ); + }); +} + +HeaderBarConfiguration _configuration({ + required String title, + bool documentControlsVisible = false, + bool searchVisible = false, + bool sidebarVisible = false, + bool sidebarToggleVisible = false, + bool backVisible = false, +}) { + return HeaderBarConfiguration( + title: title, + viewMode: AppViewMode.editor, + searchQuery: '', + textDirection: TextDirection.ltr, + canRefresh: false, + documentControlsVisible: documentControlsVisible, + searchActive: false, + searchVisible: searchVisible, + sidebarVisible: sidebarVisible, + sidebarToggleVisible: sidebarToggleVisible, + backVisible: backVisible, + modalBarrierVisible: false, + sidebarWidth: 300, + labels: _labels, + theme: _theme, + ); +} + +const _labels = HeaderBarLabels( + editor: 'Editor', + source: 'Source', + preview: 'Preview', + split: 'Split', + viewMode: 'View mode', + editorShortcut: 'Ctrl+1', + sourceShortcut: 'Ctrl+2', + previewShortcut: 'Ctrl+3', + splitShortcut: 'Ctrl+4', + search: 'Search', + refresh: 'Refresh', + menu: 'Menu', + sidebar: 'Sidebar', + sidebarShortcut: 'F9', + back: 'Back', + save: 'Save', + settings: 'Settings', + settingsShortcut: 'Ctrl+,', + keyboardShortcuts: 'Keyboard Shortcuts', + keyboardShortcutsShortcut: 'Ctrl+?', + markdownAndHtml: 'Markdown and HTML', + markdownAndHtmlShortcut: 'F1', + reportIssue: 'Report Issue', + aboutBusyMark: 'About BusyMark', +); + +const _theme = HeaderBarTheme( + preferDark: false, + backgroundColor: Color(0xFFFFFFFF), + sidebarBackgroundColor: Color(0xFFF6F6F6), + foregroundColor: Color(0xFF202020), + popoverBackgroundColor: Color(0xFFFAFAFA), + borderColor: Color(0x22000000), + sidebarBorderColor: Color(0x11010203), + floatingBorderColor: Color(0x22040506), + modalBarrierColor: Color(0x55000000), +); diff --git a/test/src/linux_header_bar_service_test.dart b/test/src/linux_header_bar_service_test.dart index d3adef7..6816442 100644 --- a/test/src/linux_header_bar_service_test.dart +++ b/test/src/linux_header_bar_service_test.dart @@ -110,4 +110,61 @@ void main() { expect(events.map((event) => event.sequence), [1, 2, 3]); expect(events.first, isNot(events.last)); }); + + test( + 'native search events and focus request keep semantic meaning', + () async { + if (!Platform.isLinux) { + return; + } + TestWidgetsFlutterBinding.ensureInitialized(); + const channelName = 'com.busymark.test/headerbar-search-events'; + const channel = MethodChannel(channelName); + const codec = StandardMethodCodec(); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return switch (call.method) { + 'initialize' || 'focusSearch' => true, + _ => null, + }; + }); + final service = LinuxHeaderBarService(channel: channel); + final events = []; + final subscription = service.searchEvents.listen(events.add); + + addTearDown(() async { + await subscription.cancel(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + channel.setMethodCallHandler(null); + }); + + Future sendNativeEvent(String method, [Object? arguments]) { + return TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channelName, + codec.encodeMethodCall(MethodCall(method, arguments)), + (_) {}, + ); + } + + expect(await service.focusSearch(), isTrue); + expect(calls.map((call) => call.method), ['initialize', 'focusSearch']); + + await sendNativeEvent('searchQueryChanged', 'draft'); + await sendNativeEvent('searchSubmitted', 'draft'); + await sendNativeEvent('searchFocusChanged', true); + await sendNativeEvent('searchCleared'); + await sendNativeEvent('searchEscapePressed'); + + expect(events, hasLength(5)); + expect((events[0] as HeaderBarSearchQueryChanged).query, 'draft'); + expect((events[1] as HeaderBarSearchSubmitted).query, 'draft'); + expect((events[2] as HeaderBarSearchFocusChanged).focused, isTrue); + expect(events[3], isA()); + expect(events[4], isA()); + }, + ); } diff --git a/test/src/markdown_parser_test.dart b/test/src/markdown_parser_test.dart index 17c27d7..2fce5d3 100644 --- a/test/src/markdown_parser_test.dart +++ b/test/src/markdown_parser_test.dart @@ -98,6 +98,61 @@ void main() { ); }); + test('deduplicates generated heading IDs in source order', () { + final parsed = parser.parse( + filePath: 'duplicates.md', + source: '# Same\n\n# Same\n\n# !\n\n# ?\n', + ); + + expect(parsed.headings.map((heading) => heading.id), [ + 'same', + 'same-1', + 'section', + 'section-1', + ]); + }); + + test('generates heading IDs from semantic inline text', () { + final parsed = parser.parse( + filePath: 'formatted-heading.md', + source: + '# [Hello](https://example.com) and **friends** ' + '![Logo](logo.png)\n', + ); + + expect(parsed.headings.single.text, 'Hello and friends Logo'); + expect(parsed.headings.single.id, 'hello-and-friends-logo'); + expect( + parsed.busyDocument.blocks + .singleWhere((block) => block.kind == BusyBlockKind.heading) + .attributes['id'], + 'hello-and-friends-logo', + ); + }); + + test('canonicalizes valid ATX and setext heading variants', () { + final parsed = parser.parse( + filePath: 'heading-variants.md', + source: + ' # [Indented](https://example.com) ###\n\n' + '#\n\n' + 'Setext {id="stable"}\n' + '====================\n', + ); + + expect( + parsed.headings.map( + (heading) => + (heading.level, heading.text, heading.id, heading.generatedId), + ), + [ + (1, 'Indented', 'indented', true), + (1, '', 'section', true), + (1, 'Setext', 'stable', false), + ], + ); + }); + test('detects unresolved links, missing images, and missing alt text', () { final path = fixture('links_images.md'); final parsed = parser.parse( diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 460031d..f193577 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -9,9 +9,7 @@ void main() { expect(source, contains('gtk_header_bar_new()')); expect(source, contains('gtk_header_bar_set_show_close_button')); expect(source, contains('gtk_window_set_titlebar')); - expect(source, contains('gtk_popover_menu_new()')); - expect(source, contains('configure_transparent_window_backing')); - expect(source, contains('CAIRO_OPERATOR_CLEAR')); + expect(source, contains('gtk_menu_button_set_menu_model')); expect(source, contains('window#busymark-window decoration')); expect(source, contains('fl_view_set_background_color')); expect(source, contains('"#00000000"')); @@ -20,10 +18,12 @@ void main() { expect(source, contains('setSidebarWidth')); expect(source, contains('setSidebarToggleVisible')); expect(source, contains('setTextDirection')); - expect(source, contains('setCanSave')); expect(source, contains('setDocumentControlsVisible')); expect(source, contains('setLocalizedLabels')); expect(source, contains('setTheme')); + expect(source, contains('applyConfiguration')); + expect(source, contains('header_configuration_session_id')); + expect(source, contains('header_configuration_revision')); expect(source, contains('busymark-sidebar-header')); expect(source, contains('self->sidebar_width')); expect(source, isNot(contains('self->save_button'))); @@ -36,6 +36,44 @@ void main() { expect(source, isNot(contains('window-maximize-symbolic'))); }); + test('native header configuration is atomic and latest-wins', () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + + expect(source, contains('struct HeaderBarConfiguration')); + expect(source, contains('decode_header_bar_configuration')); + expect(source, contains('apply_header_bar_configuration')); + expect(source, contains('begin_header_configuration_session')); + expect(source, contains('strcmp(method, "applyConfiguration")')); + expect(source, contains('fl_lookup_string_arg(args, "sessionId")')); + expect(source, contains('fl_lookup_int64_arg(args, "revision"')); + expect(source, contains('active Dart session')); + expect(source, contains('configuration.revision <=')); + expect(source, contains('self->header_configuration_revision')); + expect(source, contains('g_object_freeze_notify')); + expect(source, contains('g_object_thaw_notify')); + expect(source, contains('self->suppress_header_actions = TRUE')); + for (final key in [ + 'sessionId', + 'title', + 'viewMode', + 'canRefresh', + 'documentControlsVisible', + 'searchActive', + 'searchVisible', + 'searchQuery', + 'sidebarVisible', + 'sidebarToggleVisible', + 'sidebarWidth', + 'textDirection', + 'backVisible', + 'modalBarrierVisible', + 'labels', + 'theme', + ]) { + expect(source, contains('"$key"'), reason: key); + } + }); + test('Linux desktop identity uses the standard Snap launcher mapping', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); final cmake = File('linux/CMakeLists.txt').readAsStringSync(); @@ -169,55 +207,33 @@ void main() { expect(mainMenu, contains('tooltip: l10n.mainMenu')); expect(mainMenu, contains('label: l10n.reportIssue')); expect(native, contains('GtkWidget* sidebar_menu_button;')); - expect(native, contains('GtkWidget* keyboard_shortcuts_item;')); - expect(native, contains('GtkWidget* markdown_html_item;')); - expect(native, contains('GtkWidget* report_issue_item;')); - expect(native, contains('fl_lookup_string_arg(args, "keyboardShortcuts")')); - expect(native, contains('fl_lookup_string_arg(args, "markdownAndHtml")')); - expect(native, contains('fl_lookup_string_arg(args, "reportIssue")')); - expect(native, contains('create_menu_item(self, "keyboardShortcuts")')); - expect(native, contains('create_menu_item(self, "markdownAndHtml")')); - expect(native, contains('create_menu_item(self, "reportIssue")')); - expect(native, contains('main_menu_icon_name(action)')); + expect(native, contains('GMenu* main_menu_model;')); + expect(native, contains('GSimpleActionGroup* header_action_group;')); + expect(native, contains('rebuild_main_menu_model')); + expect(native, contains('"header.keyboard-shortcuts"')); + expect(native, contains('"header.markdown-and-html"')); + expect(native, contains('"header.report-issue"')); + expect(native, contains('static const gchar* main_menu_icon_name')); expect(native, contains('"preferences-system-symbolic"')); expect(native, contains('"input-keyboard-symbolic"')); expect(native, contains('"text-x-generic-symbolic"')); expect(native, contains('"dialog-warning-symbolic"')); expect(native, contains('"help-about-symbolic"')); final reportIssuePack = native.indexOf( - 'gtk_box_pack_start(GTK_BOX(sidebar_menu_box), self->report_issue_item', + 'localized_label_or(labels, "reportIssue", "")', ); final aboutPack = native.indexOf( - 'gtk_box_pack_start(GTK_BOX(sidebar_menu_box), self->about_item', + 'localized_label_or(labels, "aboutBusyMark", "")', ); expect(reportIssuePack, isNonNegative); expect(aboutPack, isNonNegative); expect(reportIssuePack, lessThan(aboutPack)); - final mainMenuItemOffset = native.indexOf( - 'static GtkWidget* create_menu_item', - ); - final mainMenuIconPack = native.indexOf( - 'gtk_box_pack_start(GTK_BOX(box), icon, FALSE, FALSE, 0)', - mainMenuItemOffset, - ); - final mainMenuLabelPack = native.indexOf( - 'gtk_box_pack_start(GTK_BOX(box), label, TRUE, TRUE, 0)', - mainMenuItemOffset, - ); - final mainMenuShortcutPack = native.indexOf( - 'gtk_box_pack_start(GTK_BOX(box), shortcut, FALSE, FALSE, 0)', - mainMenuItemOffset, - ); - expect(mainMenuItemOffset, isNonNegative); - expect(mainMenuIconPack, isNonNegative); - expect(mainMenuLabelPack, isNonNegative); - expect(mainMenuShortcutPack, isNonNegative); - expect(mainMenuIconPack, lessThan(mainMenuLabelPack)); - expect(mainMenuLabelPack, lessThan(mainMenuShortcutPack)); - expect(native, contains('"busymark-shortcut-widget", shortcut')); - expect(native, contains('close_menu_button(self->sidebar_menu_button)')); - expect(native, isNot(contains('GtkWidget* header_menu_button;'))); - expect(native, isNot(contains('GtkWidget* header_menu;'))); + expect(native, contains('g_menu_item_set_icon(item, icon)')); + expect(native, contains('g_menu_item_set_attribute(item, "accel"')); + expect(native, contains('g_action_map_add_action')); + expect(native, contains('gtk_widget_insert_action_group')); + expect(native, isNot(contains('static GtkWidget* create_menu_item'))); + expect(native, isNot(contains('"busymark-menu-row"'))); }); test( @@ -241,6 +257,9 @@ void main() { ); test('native modal barrier and semantic theme are centralized', () { + final configuration = File( + 'lib/src/platform/header_bar_configuration.dart', + ).readAsStringSync(); final service = File( 'lib/src/platform/linux_header_bar_service.dart', ).readAsStringSync(); @@ -248,23 +267,33 @@ void main() { 'lib/src/app/busymark_dialogs.dart', ).readAsStringSync(); - expect(service, contains('class HeaderBarTheme')); - expect(service, contains('BusyMarkSurfaceColors.of(context)')); + expect(configuration, contains('class HeaderBarTheme')); + expect(configuration, contains('HeaderBarTheme.fromContext')); + expect(configuration, contains('BusyMarkSurfaceColors.of(context)')); + expect( + configuration, + contains('modalBarrierVisible: _modalBarrierVisible'), + ); + expect(configuration, contains('setModalBarrierVisible(bool visible)')); expect(service, contains('setModalBarrierVisible')); + expect( + service, + contains('configurationSynchronizer.setModalBarrierVisible(value)'), + ); expect(dialogs, contains('showBusyMarkModalDialog')); expect(dialogs, contains('busyMarkModalBarrierColor')); expect(dialogs, contains('BusyMarkModalEditorSurface')); }); - test('native GTK theme follows Flutter brightness for snap runtime widgets', () { - final service = File( - 'lib/src/platform/linux_header_bar_service.dart', + test('native GTK theme follows brightness without replacing a valid user theme', () { + final configuration = File( + 'lib/src/platform/header_bar_configuration.dart', ).readAsStringSync(); final native = File('linux/runner/my_application.cc').readAsStringSync(); final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); - expect(service, contains('preferDark: Theme.of(context).brightness')); - expect(service, contains("'preferDark': preferDark")); + expect(configuration, contains('preferDark: Theme.of(context).brightness')); + expect(configuration, contains("'preferDark': preferDark")); expect(native, contains('static void set_gtk_theme_preference')); expect(native, contains('gtk_settings_get_default()')); expect( @@ -280,7 +309,11 @@ void main() { 'const gchar* fallback = available_gtk_theme_fallback(prefer_dark);', ), ); - expect(native, contains('g_strcmp0(theme_name, fallback) != 0')); + expect( + native, + contains('fallback != nullptr && !gtk_theme_exists(theme_name)'), + ); + expect(native, isNot(contains('g_strcmp0(theme_name, fallback) != 0'))); expect( native, contains('g_object_set(settings, "gtk-theme-name", fallback, nullptr);'), @@ -296,7 +329,16 @@ void main() { 'const gchar* icon_fallback = available_icon_theme_fallback(prefer_dark);', ), ); - expect(native, contains('g_strcmp0(icon_theme_name, icon_fallback) != 0')); + expect( + native, + contains( + 'icon_fallback != nullptr && !icon_theme_exists(icon_theme_name)', + ), + ); + expect( + native, + isNot(contains('g_strcmp0(icon_theme_name, icon_fallback) != 0')), + ); expect( native, contains( @@ -304,12 +346,15 @@ void main() { ), ); expect(native, isNot(contains('gtk_icon_theme_set_custom_theme'))); - expect(native, contains('gtk_accent_css_provider')); - expect(native, contains('@define-color theme_selected_bg_color %s;')); - expect(native, contains('@define-color accent_bg_color %s;')); - expect(native, contains('treeview.view:selected')); - expect(native, contains('button.suggested-action')); - expect(native, contains('GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + 1')); + expect(native, isNot(contains('gtk_accent_css_provider'))); + expect(native, isNot(contains('@define-color theme_selected_bg_color'))); + expect(native, isNot(contains('@define-color accent_bg_color'))); + expect(native, isNot(contains('treeview.view:selected'))); + expect(native, isNot(contains('button.suggested-action'))); + expect( + native, + isNot(contains('GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + 1')), + ); expect(native, isNot(contains('gtk_theme_name_for_preference'))); expect(native, isNot(contains('icon_theme_name_for_preference'))); expect(native, contains('fl_lookup_optional_bool_arg')); @@ -392,41 +437,100 @@ void main() { expect(script, contains('--skip-bundled-git')); }); - test('native headerbar tooltips keep GTK theme opacity', () { + test('native headerbar delegates tooltip appearance to GTK', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); - final tooltipBlock = RegExp( - r'"tooltip, tooltip\.background \{"(.*?)"tooltip > box', - dotAll: true, - ).firstMatch(native)!.group(1)!; - final tooltipLabelBlock = RegExp( - r'"tooltip label \{"(.*?)"\}', - dotAll: true, - ).firstMatch(native)!.group(1)!; - expect(tooltipBlock, contains('"border-radius: %dpx;"')); - expect(tooltipBlock, isNot(contains('"background-color: %s;"'))); - expect(tooltipBlock, isNot(contains('"opacity: 1;"'))); - expect(tooltipBlock, isNot(contains('"transition: none;"'))); - expect(tooltipBlock, isNot(contains('"box-shadow: none;"'))); - expect(tooltipLabelBlock, contains('"padding: %dpx %dpx;"')); - expect(tooltipLabelBlock, isNot(contains('"color: %s;"'))); - expect(tooltipLabelBlock, isNot(contains('"opacity: 1;"'))); + expect(native, contains('gtk_widget_set_tooltip_text')); + expect(native, isNot(contains('"tooltip, tooltip.background {"'))); + expect(native, isNot(contains('"tooltip > box'))); + expect(native, isNot(contains('"tooltip label {"'))); + }); + + test('native header CSS is balanced and narrowly semantic', () { + final native = File('linux/runner/my_application.cc').readAsStringSync(); + final refreshFunction = RegExp( + r'static void refresh_header_bar_css[\s\S]*?' + r'(?=static void set_header_bar_theme)', + ).firstMatch(native)?.group(0); + expect(refreshFunction, isNotNull); + + final formatArguments = RegExp( + r'g_strdup_printf\(([\s\S]*?)\);\s*' + r'g_autoptr\(GError\)', + ).firstMatch(refreshFunction!)?.group(1); + expect(formatArguments, isNotNull); + + final css = RegExp( + r'"((?:\\.|[^"\\])*)"', + ).allMatches(formatArguments!).map((match) => match.group(1)!).join(); + var braceDepth = 0; + for (final codeUnit in css.codeUnits) { + if (codeUnit == 0x7B) { + braceDepth++; + } else if (codeUnit == 0x7D) { + braceDepth--; + expect( + braceDepth, + isNonNegative, + reason: 'premature CSS closing brace', + ); + } + } + expect(braceDepth, 0, reason: 'unbalanced structural CSS blocks'); + expect(css, contains('.busymark-titlebar.busymark-modal-barrier')); + expect(css, contains('.busymark-header-control')); + expect(css, contains('background-color: alpha(currentColor, 0.07)')); + expect(css, contains('background-color: alpha(currentColor, 0.16)')); + expect(css, contains('background-color: alpha(currentColor, 0.10)')); + expect(css, contains('popover.background.busymark-header-popover')); + for (final interactionSelector in [ + 'button.', + 'modelbutton', + 'tooltip', + ':focus', + '@define-color', + ]) { + expect(css, isNot(contains(interactionSelector))); + } }); test('native headerbar uses split sidebar and main content surfaces', () { - final service = File( - 'lib/src/platform/linux_header_bar_service.dart', + final configuration = File( + 'lib/src/platform/header_bar_configuration.dart', ).readAsStringSync(); final native = File('linux/runner/my_application.cc').readAsStringSync(); final workspace = File( 'lib/src/workspace/presentation/workspace_screen.dart', ).readAsStringSync(); - expect(service, contains('backgroundColor: colors.view')); - expect(service, contains('sidebarBackgroundColor: colors.sidebar')); - expect(service, isNot(contains('sidebarBorderColor'))); - expect(native, contains('kDefaultHeaderbarBackground[] = "#242424"')); - expect(native, contains('kDefaultSidebarBackground[] = "#303030"')); + expect(configuration, contains('backgroundColor: colors.view')); + expect(configuration, contains('sidebarBackgroundColor: colors.sidebar')); + expect(configuration, contains('foregroundColor: colors.foreground')); + expect(configuration, contains('popoverBackgroundColor: colors.popover')); + expect(configuration, contains('borderColor: colors.subtleBorder')); + expect(native, contains('kDefaultHeaderbarBackground[] = "#272727"')); + expect(native, contains('kDefaultSidebarBackground[] = "#393939"')); + expect(native, contains('kDefaultPopoverBackground[] = "#3E3E3E"')); + expect( + native, + contains('fl_lookup_string_arg(args, "sidebarBorderColor")'), + ); + expect( + native, + contains('fl_lookup_string_arg(args, "floatingBorderColor")'), + ); + expect( + native, + contains('fl_lookup_string_arg(args, "popoverBackgroundColor")'), + ); + expect( + native, + contains('css_color_or(self->sidebar_border_color, border)'), + ); + expect( + native, + contains('css_color_or(self->floating_border_color, border)'), + ); expect(native, contains('.busymark-sidebar-header {')); expect(native, contains('background-color: %s;')); final headerbarBlock = RegExp( @@ -437,8 +541,7 @@ void main() { expect(headerbarBlock, contains('"background-image: none;"')); expect(headerbarBlock, contains('"border: none;"')); expect(headerbarBlock, contains('"box-shadow: none;"')); - expect(headerbarBlock, contains('"border-top-left-radius: %dpx;"')); - expect(headerbarBlock, contains('"border-top-right-radius: %dpx;"')); + expect(headerbarBlock, isNot(contains('border-radius'))); expect(headerbarBlock, isNot(contains('"padding-left: 0;"'))); expect(headerbarBlock, isNot(contains('"padding-right: 0;"'))); expect( @@ -448,7 +551,20 @@ void main() { '\n "headerbar.busymark-headerbar,"', ), ); - expect(native, isNot(contains('border-right: 1px solid'))); + expect(native, contains('".busymark-sidebar-header:dir(ltr) {"')); + expect(native, contains('"border-right: 1px solid %s;"')); + expect(native, contains('".busymark-sidebar-header:dir(rtl) {"')); + expect(native, contains('"border-left: 1px solid %s;"')); + expect(native, contains('modal_sidebar_border_css_color')); + expect( + native, + contains( + '".busymark-titlebar.busymark-modal-barrier "' + '\n ".busymark-sidebar-header:dir(ltr) {"', + ), + ); + expect(native, contains('"border-right-color: %s;"')); + expect(native, contains('"border-left-color: %s;"')); expect(workspace, isNot(contains('Border(right:'))); }); @@ -472,46 +588,60 @@ void main() { }); test('native headerbar mirrors sidebar surface for text direction', () { - final service = File( - 'lib/src/platform/linux_header_bar_service.dart', + final configuration = File( + 'lib/src/platform/header_bar_configuration.dart', ).readAsStringSync(); final app = File('lib/src/app/busymark_app.dart').readAsStringSync(); + final settings = File( + 'lib/src/workspace/presentation/settings_screen.dart', + ).readAsStringSync(); + final welcome = File( + 'lib/src/workspace/presentation/welcome_screen.dart', + ).readAsStringSync(); + final workspace = File( + 'lib/src/workspace/presentation/workspace_screen.dart', + ).readAsStringSync(); final native = File('linux/runner/my_application.cc').readAsStringSync(); - expect(service, contains('Future setTextDirection')); - expect(service, contains("'setTextDirection'")); + expect(configuration, contains('final TextDirection textDirection;')); + expect( + configuration, + contains( + "'textDirection': textDirection == TextDirection.rtl ? 'rtl' : 'ltr'", + ), + ); expect(app, contains('Directionality.maybeOf(context)')); - expect(app, contains('service.setTextDirection(textDirection)')); + expect(app, contains('textDirection: textDirection')); + for (final screen in [settings, welcome, workspace]) { + expect(screen, contains('HeaderBarConfigurationDefaults.of(context)')); + expect(screen, contains('HeaderBarConfigurationPublisher(')); + } expect(native, contains('gboolean text_direction_rtl;')); expect(native, contains('static void update_titlebar_direction')); - expect(native, contains('update_header_menu_item_direction')); expect(native, contains('set_widget_direction(self->sidebar_menu')); expect(native, contains('set_widget_direction(self->view_mode_menu')); - expect(native, contains('direction == GTK_TEXT_DIR_RTL ? 1.0 : 0.0')); + expect(native, contains('set_widget_direction(self->adaptive_menu')); expect( native, - contains('set_widget_direction(shortcut, GTK_TEXT_DIR_LTR)'), + contains('set_widget_direction(self->adaptive_search_button'), ); expect(native, contains('kLtrIsolateStart')); expect(native, contains('kBidiIsolateEnd')); expect(native, contains('gtk_box_reorder_child')); expect(native, contains('static void set_text_direction')); - expect(native, contains('strcmp(method, "setTextDirection")')); + expect( + native, + contains('set_text_direction(self, configuration.text_direction)'), + ); expect(native, contains('g_strcmp0(value, "rtl") == 0')); - expect(native, contains('headerbar_right_radius')); - expect(native, contains('sidebar_right_radius')); }); - test('native headerbar reapplies logical insets after direction changes', () { + test('native headerbar reapplies owned insets after direction changes', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); final marginHelper = RegExp( r'static void set_widget_horizontal_margins[\s\S]*?^}', multiLine: true, ).firstMatch(native)?.group(0); - final titleAlignment = RegExp( - r'static void update_title_stack_alignment[\s\S]*?(?=^static void set_toggle_button_active)', - multiLine: true, - ).firstMatch(native)?.group(0); final directionUpdate = RegExp( r'static void update_titlebar_direction[\s\S]*?(?=^static void refresh_header_bar_css)', multiLine: true, @@ -520,16 +650,8 @@ void main() { expect(marginHelper, isNotNull); expect(marginHelper, contains('gtk_widget_set_margin_start')); expect(marginHelper, contains('gtk_widget_set_margin_end')); - expect(titleAlignment, isNotNull); - expect( - titleAlignment, - matches( - RegExp( - r'set_widget_horizontal_margins\(\s*self->title_stack,\s*' - r'self->search_active \? 0 : kHeaderWindowControlsBalanceWidth,\s*0\);', - ), - ), - ); + expect(native, isNot(contains('kHeaderWindowControlsBalanceWidth'))); + expect(native, isNot(contains('update_title_stack_alignment'))); expect(directionUpdate, isNotNull); expect( directionUpdate, @@ -559,21 +681,12 @@ void main() { ), ); - final titleDirectionOffset = directionUpdate!.indexOf( - 'set_widget_direction(self->title_stack, direction)', - ); - final titleAlignmentOffset = directionUpdate.indexOf( - 'update_title_stack_alignment(self);', - ); - expect(titleDirectionOffset, isNonNegative); - expect(titleAlignmentOffset, greaterThan(titleDirectionOffset)); - for (final widget in [ 'sidebar_search_button', 'sidebar_menu_button', 'header_start_box', ]) { - final directionOffset = directionUpdate.indexOf( + final directionOffset = directionUpdate!.indexOf( 'set_widget_direction(self->$widget, direction)', ); final marginOffset = directionUpdate.indexOf( @@ -599,93 +712,102 @@ void main() { ); }); - test('native headerbar restores outer corners without sidebar', () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); + test( + 'sidebar visibility updates split geometry without corner emulation', + () { + final native = File('linux/runner/my_application.cc').readAsStringSync(); - expect(native, contains('const gint headerbar_left_radius')); - expect(native, contains('const gint headerbar_right_radius')); - expect(native, contains('const gint sidebar_left_radius')); - expect(native, contains('const gint sidebar_right_radius')); - expect( - native, - contains('self->sidebar_visible && !self->text_direction_rtl'), - ); - expect( - native, - contains('self->sidebar_visible && self->text_direction_rtl'), - ); - expect(native, contains('headerbar_left_radius')); - expect(native, contains('headerbar_right_radius')); - expect(native, contains('sidebar_background')); - expect(native, contains('update_sidebar_header_geometry(self);')); - expect(native, contains('refresh_header_bar_css(self);')); - expect(native, isNot(contains('"border-top-left-radius: 0;"'))); - }); + expect(native, contains('sidebar_background')); + expect(native, contains('update_sidebar_header_geometry(self);')); + expect(native, contains('refresh_header_bar_css(self);')); + expect(native, isNot(contains('headerbar_left_radius'))); + expect(native, isNot(contains('headerbar_right_radius'))); + expect(native, isNot(contains('sidebar_left_radius'))); + expect(native, isNot(contains('sidebar_right_radius'))); + expect(native, isNot(contains('"border-top-left-radius:'))); + expect(native, isNot(contains('"border-top-right-radius:'))); + }, + ); - test('native GTK decoration owns window shadow and rounded shape', () { + test('GTK CSD exclusively owns window shadow and rounded shape', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); expect(native, contains('window#busymark-window decoration,')); expect(native, contains('window#busymark-window decoration:backdrop {')); - expect(native, contains('kHeaderWindowRadius = 14')); - expect(native, contains('"background-color: transparent;"')); - expect(native, contains('"border: none;"')); - expect(native, contains('"outline: none;"')); - expect(native, contains('"box-shadow: 0 2px 10px 0 %s;"')); - expect(native, contains('const gchar* shade = css_color_or')); - expect(native, contains('fl_lookup_string_arg(args, "shadeColor")')); - expect(native, contains('create_rounded_window_region')); - expect(native, contains('gdk_window_shape_combine_region')); - expect(native, contains('rounded_window_configure_event_cb')); - expect(native, contains('configure_transparent_window_backing(window);')); + expect(native, contains('"border-color: %s;"')); + expect(native, contains('gtk_window_set_titlebar(window, titlebar)')); + expect(native, isNot(contains('kHeaderWindowRadius'))); + expect(native, isNot(contains('create_rounded_window_region'))); + expect(native, isNot(contains('gdk_window_shape_combine_region'))); + expect(native, isNot(contains('rounded_window_configure_event_cb'))); + expect(native, isNot(contains('configure_transparent_window_backing'))); + expect(native, isNot(contains('gtk_widget_set_app_paintable'))); + expect(native, isNot(contains('CAIRO_OPERATOR_CLEAR'))); + expect(native, isNot(contains('#include '))); + expect(native, isNot(contains('"box-shadow: 0 2px 10px'))); + expect(native, isNot(contains('"border-radius:'))); }); - test('native headerbar buttons use GTK-style themed controls', () { + test('native header controls preserve GTK geometry with neutral states', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); expect(native, contains('kHeaderButtonHeight = 32')); - expect(native, contains('kHeaderControlHeight = 34')); - expect(native, contains('kHeaderSearchEntryBorderWidth = 1')); - expect( - native, - contains( - 'kHeaderSearchEntryContentHeight =\n kHeaderButtonHeight - kHeaderSearchEntryBorderWidth * 2', - ), - ); - expect(native, contains('kHeaderControlHorizontalPadding = 8')); expect(native, contains('kHeaderButtonSpacing = 8')); + expect(native, contains('self->search_entry = gtk_search_entry_new()')); expect( native, - isNot(contains('".busymark-titlebar button.busymark-header-button,"')), + contains('gtk_widget_set_hexpand(self->search_entry, TRUE)'), ); expect( native, - isNot( - contains('".busymark-titlebar button.busymark-view-mode-button {"'), - ), + isNot(contains('gtk_widget_set_size_request(self->search_entry')), ); expect( native, isNot( - contains('".busymark-titlebar button.busymark-header-button image,"'), + contains('".busymark-titlebar entry.busymark-search-entry:focus {"'), ), ); + expect(native, isNot(contains('button.busymark-header-button:hover'))); + expect(native, isNot(contains('button.busymark-header-button:checked'))); + expect(native, isNot(contains('button.busymark-header-button:focus'))); + expect(native, isNot(contains('GTK_RELIEF_NONE'))); + expect(native, isNot(contains('GTK_STYLE_CLASS_FLAT'))); + expect(native, contains('"busymark-header-control"')); + expect(native, isNot(contains('"busymark-header-icon-button"'))); + expect(native, contains('alpha(currentColor, 0.07)')); + expect(native, contains('alpha(currentColor, 0.16)')); + expect(native, contains('alpha(currentColor, 0.10)')); + expect(native, contains('alpha(currentColor, 0.13)')); + expect(native, contains('alpha(currentColor, 0.19)')); + expect(native, isNot(contains('"outline-width: 2px;"'))); + expect(native, isNot(contains('"outline-width: 0;"'))); + expect(native, contains('"searchSubmitted"')); expect( native, - contains( - 'gtk_widget_set_size_request(self->search_entry, 360, kHeaderButtonHeight)', - ), + contains('invoke_header_bar_string_action(self, "searchSubmitted"'), ); + expect(native, contains('gtk_widget_set_focus_on_click(button, FALSE)')); + expect(native, contains('"stop-search"')); + expect(native, contains('"searchFocusChanged"')); + expect(native, contains('"searchCleared"')); + expect(native, contains('"searchEscapePressed"')); + expect(native, contains('strcmp(method, "focusSearch") == 0')); + expect(native, contains('enum class SearchQueryUpdateDisposition')); + expect(native, contains('resolve_search_query_update(false, true)')); expect( native, - contains('control, border,\n kHeaderSearchEntryContentHeight'), + contains('SearchQueryUpdateDisposition::kPreserveNativeText'), ); - expect(native, isNot(contains('"box-shadow: 0 0 0 1px %s;"'))); expect( native, - isNot(contains('"box-shadow: 0 -1px 1px %s, 0 1px 1px %s;"')), + contains( + 'A newer focused native edit must survive a delayed Dart snapshot', + ), ); - expect(native, contains('fl_lookup_string_arg(args, "shadeColor")')); + expect(native, contains('native_entry_has_authority')); + expect(native, isNot(contains('echoes_last_native_query'))); + expect(native, isNot(contains('"key-press-event"'))); }); test('native window controls are not styled by BusyMark CSS', () { @@ -734,22 +856,56 @@ void main() { }, ); + test('search and main menu adapt when the sidebar header is hidden', () { + final native = File('linux/runner/my_application.cc').readAsStringSync(); + + expect(native, contains('GtkWidget* adaptive_search_button;')); + expect(native, contains('GtkWidget* adaptive_menu_button;')); + expect(native, contains('static void update_adaptive_header_actions')); + expect( + native, + contains('const gboolean use_main_header = !self->sidebar_visible'), + ); + expect(native, contains('use_main_header && self->search_visible')); + expect(native, contains('set_widget_visible(self->adaptive_menu_button')); + expect( + native, + contains( + 'set_toggle_button_active(self, self->adaptive_search_button, active)', + ), + ); + expect( + native, + contains('G_MENU_MODEL(self->main_menu_model), "open-menu-symbolic"'), + ); + }); + test( - 'surface palette uses neutral grays instead of blue-tinted surfaces', + 'native fallback surfaces are neutral grays, not blue-tinted colors', () { - final design = File( - 'lib/src/app/busymark_design.dart', - ).readAsStringSync(); final native = File('linux/runner/my_application.cc').readAsStringSync(); - expect(design, contains('sidebarWidth = 300')); - expect(design, contains('view: Color(0xFF242424)')); - expect(design, contains('sidebar: Color(0xFF303030)')); - expect(design, contains('headerbarFlat: Color(0xFF242424)')); - expect(native, contains('"#242424"')); - expect(design, isNot(contains('Color(0xFF1D1D20)'))); - expect(design, isNot(contains('Color(0xFF2E2E32)'))); - expect(design, isNot(contains('Color.fromRGBO(0, 0, 6'))); + int fallbackValue(String constantName) { + final match = RegExp( + 'constexpr char $constantName\\[\\] = "#([0-9A-Fa-f]{6})";', + ).firstMatch(native); + expect(match, isNotNull, reason: constantName); + return int.parse(match!.group(1)!, radix: 16); + } + + void expectNeutral(String constantName, int value) { + final red = (value >> 16) & 0xFF; + final green = (value >> 8) & 0xFF; + final blue = value & 0xFF; + expect(red, green, reason: constantName); + expect(green, blue, reason: constantName); + } + + final header = fallbackValue('kDefaultHeaderbarBackground'); + final sidebar = fallbackValue('kDefaultSidebarBackground'); + expectNeutral('kDefaultHeaderbarBackground', header); + expectNeutral('kDefaultSidebarBackground', sidebar); + expect(sidebar, greaterThan(header)); expect(native, isNot(contains('"#1D1D20"'))); expect(native, isNot(contains('"#2E2E32"'))); }, @@ -768,19 +924,20 @@ void main() { expect(service, contains("'save' => HeaderBarAction.save")); expect(service, isNot(contains('problems,'))); expect(service, isNot(contains("'problems' => HeaderBarAction.problems"))); - expect(service, contains('setCanSave')); expect(workspace, contains('case HeaderBarAction.save:')); expect(workspace, isNot(contains('case HeaderBarAction.problems:'))); expect(workspace, isNot(contains('saveActiveWithOverwriteConfirmation'))); expect(workspace, contains('_showProblemsDialog(context, ref)')); expect(workspace, contains('_validateActiveAndShowProblems')); - expect(workspace, isNot(contains('setCanSave(state.isDirty)'))); + expect(workspace, contains('HeaderBarConfigurationPublisher(')); + expect(workspace, isNot(contains('headerBar.setCanSave'))); expect(workspace, isNot(contains('accented: state.isDirty'))); - expect( - service, - contains('accentColor: Theme.of(context).colorScheme.primary'), - ); - expect(service, contains('accentForegroundColor')); + expect(native, isNot(contains('gboolean can_save;'))); + expect(native, isNot(contains('gboolean can_undo;'))); + expect(native, isNot(contains('gboolean can_redo;'))); + expect(native, isNot(contains('strcmp(method, "setCanSave")'))); + expect(native, isNot(contains('strcmp(method, "setCanUndo")'))); + expect(native, isNot(contains('strcmp(method, "setCanRedo")'))); expect( native, isNot(contains('create_header_icon_button("emblem-ok-symbolic")')), @@ -832,6 +989,9 @@ void main() { final service = File( 'lib/src/platform/linux_header_bar_service.dart', ).readAsStringSync(); + final configuration = File( + 'lib/src/platform/header_bar_configuration.dart', + ).readAsStringSync(); final app = File('lib/src/app/busymark_app.dart').readAsStringSync(); final workspace = File( 'lib/src/workspace/presentation/workspace_screen.dart', @@ -839,7 +999,7 @@ void main() { final native = File('linux/runner/my_application.cc').readAsStringSync(); expect( - service, + configuration, contains('enum AppViewMode { editor, source, preview, split }'), ); expect(service, contains('viewModeEditor')); @@ -875,67 +1035,46 @@ void main() { expect(workspace, contains('case HeaderBarAction.viewModePreview:')); expect(workspace, contains('case HeaderBarAction.viewModeSplit:')); expect(workspace, contains('setDocumentViewMode(')); - expect(workspace, contains('setViewMode(')); + expect(workspace, contains('HeaderBarConfigurationPublisher(')); expect( workspace, - contains('_headerBarViewMode(settings.documentViewMode)'), - ); - expect(native, contains('create_view_mode_item(self, "editor")')); - expect(native, contains('create_view_mode_item(self, "source")')); - expect(native, contains('create_view_mode_item(self, "preview")')); - expect(native, contains('create_view_mode_item(self, "split")')); - expect(native, contains('object-select-symbolic')); - final viewModeItemOffset = native.indexOf( - 'static GtkWidget* create_view_mode_item', - ); - final viewModeLabelPack = native.indexOf( - 'gtk_box_pack_start(GTK_BOX(box), label, TRUE, TRUE, 0)', - viewModeItemOffset, - ); - final viewModeIconPack = native.indexOf( - 'gtk_box_pack_start(GTK_BOX(box), icon, FALSE, FALSE, 0)', - viewModeItemOffset, - ); - final viewModeShortcutPack = native.indexOf( - 'gtk_box_pack_start(GTK_BOX(box), shortcut, FALSE, FALSE, 0)', - viewModeItemOffset, - ); - final viewModeCheckPack = native.indexOf( - 'gtk_box_pack_start(GTK_BOX(box), check, FALSE, FALSE, 0)', - viewModeItemOffset, - ); - expect(viewModeItemOffset, isNonNegative); - expect(viewModeIconPack, isNonNegative); - expect(viewModeLabelPack, isNonNegative); - expect(viewModeShortcutPack, isNonNegative); - expect(viewModeCheckPack, isNonNegative); - expect(viewModeIconPack, lessThan(viewModeLabelPack)); - expect(viewModeLabelPack, lessThan(viewModeCheckPack)); - expect(viewModeShortcutPack, lessThan(viewModeCheckPack)); + contains('viewMode: _headerBarViewMode(settings.documentViewMode)'), + ); + expect(workspace, isNot(contains('headerBar.setViewMode'))); + expect(service, contains("('setViewMode', configuration.viewMode.name)")); + expect(native, contains('GMenu* view_mode_menu_model;')); + expect(native, contains('GSimpleAction* view_mode_action;')); + expect(native, contains('g_simple_action_new_stateful(')); + expect(native, contains('"view-mode", G_VARIANT_TYPE_STRING')); + expect(native, contains('g_menu_item_set_action_and_target(')); + expect(native, contains('"header.view-mode"')); + expect(native, contains('g_simple_action_set_state(')); + expect(native, contains('append_view_mode_menu_item(')); + for (final mode in ['editor', 'source', 'preview', 'split']) { + expect( + native, + contains('localized_label_or(labels, "$mode", "")'), + reason: mode, + ); + } expect(native, contains('view_mode_icon_name(mode)')); expect(native, contains('view_mode_icon_name("split")')); - expect(native, contains('"busymark-shortcut-widget", shortcut')); - expect(native, contains('set_menu_item_shortcut(item, shortcut)')); - expect(native, contains('button.busymark-menu-row:focus')); - expect(native, contains('button.busymark-menu-row:active')); - expect(native, contains('outline-width: 0;')); - expect(native, contains('self->view_mode_button = gtk_menu_button_new()')); - expect( - native, - contains( - 'gtk_widget_set_valign(self->view_mode_button, GTK_ALIGN_CENTER)', - ), - ); + expect(native, isNot(contains('modelbutton:hover'))); + expect(native, isNot(contains('modelbutton:focus'))); + expect(native, isNot(contains('modelbutton:active'))); + expect(native, isNot(contains('outline-width: 0;'))); + expect(native, contains('static GtkWidget* create_model_menu_button')); + expect(native, contains('gtk_menu_button_set_menu_model')); expect( native, contains('gtk_image_set_from_icon_name(GTK_IMAGE(self->view_mode_icon)'), ); expect( native, - contains('gtk_container_add(GTK_CONTAINER(self->view_mode_button)'), + contains('gtk_button_get_image(GTK_BUTTON(self->view_mode_button))'), ); - expect(native, contains('make_icon_button_square(self->view_mode_button)')); - expect(native, isNot(contains('create_menu_button(self->view_mode_menu'))); + expect(native, isNot(contains('create_view_mode_item'))); + expect(native, isNot(contains('set_menu_item_checked'))); expect(native, isNot(contains('self->view_mode_label'))); expect( native, @@ -947,12 +1086,7 @@ void main() { native, contains('set_widget_tooltip(self->view_mode_button, view_mode)'), ); - expect( - native, - contains( - 'set_menu_item_label_with_shortcut(self->view_mode_editor_item, editor', - ), - ); + expect(native, contains('rebuild_view_mode_menu_model(self, args)')); expect( native, contains('set_widget_visible(self->view_mode_box, effective_visible)'), @@ -963,16 +1097,14 @@ void main() { expect(native, isNot(contains('viewModeAgenda'))); }); - test('native popover rows do not open redundant hover tooltips', () { + test('native popovers use menu-model accelerators without fake rows', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); - final helper = RegExp( - r'static void set_menu_item_label_with_shortcut\([\s\S]*?\n\}', - ).firstMatch(native)?.group(0); - expect(helper, isNotNull); - expect(helper, contains('set_menu_item_label(item, text);')); - expect(helper, contains('set_menu_item_shortcut(item, shortcut);')); - expect(helper, isNot(contains('gtk_widget_set_tooltip_text'))); + expect(native, contains('g_menu_item_set_attribute(item, "accel"')); + expect(native, contains('g_menu_item_set_icon(item, icon)')); + expect(native, contains('gtk_menu_button_set_menu_model')); + expect(native, isNot(contains('busymark-shortcut-widget'))); + expect(native, isNot(contains('busymark-menu-row'))); expect(native, contains('set_widget_tooltip(self->view_mode_button')); }); @@ -983,8 +1115,8 @@ void main() { final workspace = File( 'lib/src/workspace/presentation/workspace_screen.dart', ).readAsStringSync(); - final service = File( - 'lib/src/platform/linux_header_bar_service.dart', + final configuration = File( + 'lib/src/platform/header_bar_configuration.dart', ).readAsStringSync(); final native = File('linux/runner/my_application.cc').readAsStringSync(); @@ -998,23 +1130,24 @@ void main() { expect(welcome, contains('BorderRadius.circular(BusyMarkRadius.md)')); expect(welcome, contains('if (!sidebarOnRight && sidebarVisible)')); expect(welcome, contains('if (sidebarOnRight && sidebarVisible)')); - expect(welcome, contains('setSidebarVisible(sidebarVisible)')); expect(welcome, contains('welcomeMainColor = colors.view')); expect(welcome, contains('backgroundColor: welcomeMainColor')); expect(welcome, contains('crossAxisAlignment: CrossAxisAlignment.stretch')); - expect(welcome, contains('setSidebarToggleVisible(true)')); + expect(welcome, contains('HeaderBarConfigurationPublisher(')); + expect(welcome, contains('documentControlsVisible: false')); + expect(welcome, contains('searchVisible: false')); + expect(welcome, contains('sidebarVisible: sidebarVisible')); + expect(welcome, contains('sidebarToggleVisible: true')); expect(welcome, contains('case HeaderBarAction.sidebarToggle:')); expect(welcome, contains('setSidebarVisible(!settings.sidebarVisible)')); - expect(welcome, contains('setSearchVisible(false)')); - expect(welcome, contains('setDocumentControlsVisible(false)')); - expect(workspace, contains('setSidebarVisible(')); - expect(workspace, contains('settings.sidebarVisible && hasSidebar')); - expect(workspace, contains('setSidebarToggleVisible(hasSidebar)')); - expect(workspace, contains('setSearchVisible(true)')); - expect(workspace, contains('setDocumentControlsVisible(true)')); - expect(service, contains('setDocumentControlsVisible')); - expect(service, contains('setSidebarToggleVisible')); - expect(service, contains('setSearchVisible')); + expect(workspace, contains('HeaderBarConfigurationPublisher(')); + expect(workspace, contains('sidebarVisible: sidebarVisible')); + expect(workspace, contains('sidebarToggleVisible: hasSidebar')); + expect(workspace, contains('searchVisible: true')); + expect(workspace, contains('documentControlsVisible: true')); + expect(configuration, contains('final bool documentControlsVisible;')); + expect(configuration, contains('final bool sidebarToggleVisible;')); + expect(configuration, contains('final bool searchVisible;')); expect(native, contains('set_document_controls_visible')); expect(native, contains('set_sidebar_toggle_visible')); expect(native, contains('set_search_visible')); @@ -1061,22 +1194,21 @@ void main() { ).readAsStringSync(); final native = File('linux/runner/my_application.cc').readAsStringSync(); - expect(welcome, contains('setTitleRange(context.l10n.appTitle)')); - expect(settings, contains('setTitleRange(context.l10n.settings)')); + expect(welcome, contains('HeaderBarConfigurationPublisher(')); + expect(welcome, contains('title: context.l10n.appTitle')); + expect(settings, contains('HeaderBarConfigurationPublisher(')); + expect(settings, contains('title: l10n.settings')); expect( workspace, - contains('setTitleRange(busyMarkBidiIsolateFor(context, title))'), + contains('title: busyMarkBidiIsolateFor(context, title)'), ); expect(settings, isNot(contains("setTitleRange('BusyMark Settings')"))); expect( native, - contains('kHeaderWindowControlsBalanceWidth = kHeaderButtonHeight * 3'), - ); - expect(native, contains('update_title_stack_alignment(self);')); - expect( - native, - contains('self->search_active ? 0 : kHeaderWindowControlsBalanceWidth'), + contains('gtk_header_bar_set_custom_title(self->header_bar'), ); + expect(native, isNot(contains('kHeaderWindowControlsBalanceWidth'))); + expect(native, isNot(contains('update_title_stack_alignment'))); }, ); } diff --git a/test/src/preview_builder_test.dart b/test/src/preview_builder_test.dart index d001e67..e1f220a 100644 --- a/test/src/preview_builder_test.dart +++ b/test/src/preview_builder_test.dart @@ -31,6 +31,27 @@ void main() { parsed.headings[0].id, 'details', ]); + expect( + preview.outline + .map( + (heading) => ( + heading.text, + heading.id, + heading.sourceStartLine, + heading.sourceStartOffset, + ), + ) + .toList(), + [ + ('Title', parsed.headings[0].id, 1, 0), + ( + 'Details', + 'details', + parsed.headings[1].span.startLine, + parsed.headings[1].span.startOffset, + ), + ], + ); }); test('preview blocks carry source locations for search navigation', () { diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index a68e867..8521343 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -93,7 +93,8 @@ void main() { router, contains('NoTransitionPage(child: WorkspaceScreen())'), ); - expect(router, contains('NoTransitionPage(child: SettingsScreen())')); + expect(router, contains('NoTransitionPage(')); + expect(router, contains('child: SettingsScreen(')); expect(router, isNot(contains('builder: (context, state)'))); expect(router, isNot(contains('CustomTransitionPage'))); }); @@ -189,7 +190,8 @@ void main() { expect(design, contains('class _BusyMarkGroupedListSurface')); expect(design, contains('cardTheme.shape ?? RoundedRectangleBorder')); - expect(design, contains('clipBehavior: Clip.antiAlias')); + expect(design, contains('BorderRadius.circular(BusyMarkRadius.lg)')); + expect(design, contains('this.clipBehavior = Clip.antiAlias')); expect(design, contains('height: BusyMarkStroke.hairline')); expect(design, isNot(contains('YaruTileList'))); expect(design, isNot(contains('YaruBorderContainer'))); @@ -207,86 +209,107 @@ void main() { expect(dialogShell, contains('border: BorderSide.none')); }); - test('BusyMark dialog buttons use shared shadowed accent surfaces', () { + test('BusyMark dialog buttons are thin semantic framework adapters', () { final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); final dialogButton = RegExp( - r'class BusyMarkDialogButton[\s\S]*?enum BusyMarkFloatingTextEntryPosition', + r'class BusyMarkDialogButton[\s\S]*?class BusyMarkFloatingTextEntryGroup', ).firstMatch(design)!.group(0)!; - expect(dialogButton, contains('busyMarkSurfaceDecoration(')); - expect(dialogButton, contains('color: background')); - expect(dialogButton, contains('elevated: _enabled')); - expect(dialogButton, contains('return colorScheme.primary;')); - expect(dialogButton, contains('colorScheme.onPrimary')); - expect(dialogButton, isNot(contains('border: Border.all'))); - expect(dialogButton, isNot(contains('final borderColor'))); + expect(design, contains('abstract final class BusyMarkPushButton')); + expect(design, contains('static FilledButton standard(')); + expect(design, contains('static FilledButton standardIcon(')); + expect(design, contains('return FilledButton.icon(')); + expect(design, contains('static ElevatedButton suggested(')); + expect(design, contains('static ElevatedButton destructive(')); + expect(dialogButton, contains('BusyMarkPushButton.standard(')); + expect(dialogButton, contains('BusyMarkPushButton.suggested(')); + expect(dialogButton, contains('BusyMarkPushButton.destructive(')); + expect(dialogButton, isNot(contains('FocusableActionDetector('))); + expect(dialogButton, isNot(contains('GestureDetector('))); + expect(dialogButton, isNot(contains('busyMarkSurfaceDecoration('))); + expect(dialogButton, isNot(contains('minHeight:'))); + expect(dialogButton, isNot(contains('minWidth:'))); }); - test('shared row hover uses the themed control hover color', () { + test('Writerside topic controls use shared semantic adapters', () { + final workspace = File( + 'lib/src/workspace/presentation/workspace_screen.dart', + ).readAsStringSync(); + final createTopicDialog = RegExp( + r'class _CreateWritersideTopicDialogState[\s\S]*?' + r' String _dialogTitle', + ).firstMatch(workspace)!.group(0)!; + + expect(workspace, contains('BusyMarkPushButton.standardIcon(')); + expect(workspace, isNot(contains('FilledButton.icon('))); + expect(createTopicDialog, contains('BusyMarkFloatingTextEntryGroup(')); + expect( + RegExp( + r'BusyMarkFloatingTextEntry\(', + ).allMatches(createTopicDialog).length, + 2, + ); + expect(createTopicDialog, isNot(contains('TextField('))); + expect(createTopicDialog, isNot(contains('InputDecoration('))); + }); + + test('shared row hover delegates to Yaru interaction state', () { final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); final helper = RegExp( r'Color busyMarkRowHoverColor\(BuildContext context\) \{(.*?)\n\}', dotAll: true, ).firstMatch(design)!.group(1)!; - expect(helper, contains('BusyMarkSurfaceColors.of(context).controlHover')); + expect(helper, contains('Theme.of(context).hoverColor')); expect(helper, isNot(contains('colors.foreground.withValues'))); - expect(design, contains('class _BusyMarkHoverBackground')); - expect(design, contains('return MouseRegion(')); - expect(design, contains('ColoredBox(')); - expect(design, isNot(contains('AnimatedContainer('))); + expect(design, isNot(contains('class _BusyMarkHoverBackground'))); final actionRow = RegExp( r'class BusyMarkActionRow[\s\S]*?class BusyMarkSwitchRow', ).firstMatch(design)!.group(0)!; - expect(actionRow, contains('_BusyMarkHoverBackground(')); - expect(actionRow, contains('hoverColor: BusyMarkLinuxPalette.transparent')); + expect(actionRow, contains('return YaruListTile.square(')); + expect(actionRow, isNot(contains('MouseRegion('))); + expect(actionRow, isNot(contains('hoverColor:'))); final switchRow = RegExp( r'class BusyMarkSwitchRow[\s\S]*?class BusyMarkDialogShell', ).firstMatch(design)!.group(0)!; - expect(switchRow, contains('_BusyMarkHoverBackground(')); - expect(switchRow, contains('hoverColor: BusyMarkLinuxPalette.transparent')); + expect(switchRow, contains('return YaruSwitchListTile(')); + expect(switchRow, isNot(contains('MouseRegion('))); + expect(switchRow, contains('hoverColor: busyMarkRowHoverColor(context)')); + expect(switchRow, contains('shape: const RoundedRectangleBorder()')); }); - test('shared surfaces use semantic BusyMark shadows', () { + test('shared surfaces use one semantic physical-elevation path', () { final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); final dialogs = File( 'lib/src/app/busymark_dialogs.dart', ).readAsStringSync(); final theme = File('lib/src/app/app_theme.dart').readAsStringSync(); - expect(design, contains('return BusyMarkSurfaceColors.of(context).shade')); - expect(design, contains('surfaceShadowsFor')); - expect(design, contains('floatingShadowsFor')); - expect(design, contains('windowShadowsFor')); - expect(design, contains('edgeShadowsFor')); - expect(design, contains('_scaleAlpha(color, 0.28)')); - expect(design, contains('blurRadius: 8')); - expect(design, contains('offset: const Offset(0, -1)')); - expect(design, contains('BoxDecoration busyMarkSurfaceDecoration')); - expect( - design, - contains( - 'boxShadow: elevated ? BusyMarkShadow.surfaceShadowsFor(context) : null', - ), - ); + expect(design, isNot(contains('abstract final class BusyMarkShadow'))); + expect(design, isNot(contains('BoxShadow('))); + expect(design, isNot(contains('busyMarkSurfaceDecoration'))); expect(design, contains('final cardTheme = Theme.of(context).cardTheme')); final surface = RegExp( r'class BusyMarkSurface.*?class BusyMarkGroupedList', dotAll: true, ).firstMatch(design)!.group(0)!; expect(surface, contains('cardTheme.color ?? colors.card')); - expect(surface, contains('decoration: busyMarkSurfaceDecoration')); - expect(theme, contains('shadowColor: colors.shade')); - expect(theme, contains('cardTheme: CardThemeData')); - expect(dialogs, contains('elevation: BusyMarkElevation.popover')); - expect( - dialogs, - contains('shadowColor: BusyMarkShadow.floatingColor(context)'), - ); - expect( - theme, - contains('boxShadow: BusyMarkShadow.floatingShadows(colors.shade)'), - ); + expect(surface, contains('elevation: filled ? BusyMarkElevation.surface')); + expect( + surface, + contains('shadowColor: Theme.of(context).colorScheme.shadow'), + ); + expect(theme, contains('shadowColor: colorScheme.shadow')); + expect(theme, contains('cardTheme: base.cardTheme.copyWith')); + + final modalEditor = RegExp( + r'class BusyMarkModalEditorSurface[\s\S]*?void showBusyMarkAboutDialog', + ).firstMatch(dialogs)!.group(0)!; + expect(modalEditor, contains('Dialog(')); + expect(modalEditor, isNot(contains('AlertDialog('))); + expect(modalEditor, isNot(contains('child: Material('))); + expect(modalEditor, isNot(contains('BusyMarkElevation.'))); + expect(modalEditor, isNot(contains('BusyMarkShadow.'))); }); test('filled grouped action surfaces use the shared grouped surface', () { @@ -299,26 +322,99 @@ void main() { r'class _BusyMarkGroupedListSurface.*?class BusyMarkActionRow', dotAll: true, ).firstMatch(design)!.group(0)!; - expect(groupedSurface, contains('final dividerColor = colors.view')); expect(groupedSurface, contains('height: BusyMarkStroke.hairline')); expect(groupedSurface, contains('thickness: BusyMarkStroke.hairline')); - expect(groupedSurface, contains('color: dividerColor')); + expect(groupedSurface, contains('color: colors.divider')); expect(design, contains('required this.groupedList')); - expect(design, contains('groupedList: Color(0xFFFFFFFF)')); - expect(design, contains('groupedList: Color(0xFF383838)')); - expect(groupedSurface, contains('final color = colors.groupedList')); - expect(groupedSurface, contains('decoration: busyMarkSurfaceDecoration')); - expect(groupedSurface, contains('ClipRRect(')); - expect(groupedSurface, contains('color: BusyMarkLinuxPalette.transparent')); + expect( + design, + contains('BusyMarkSurfaceColors.fromTheme(ThemeData theme)'), + ); + expect( + design, + contains('return _busyMarkSemanticSurfaceColors(theme.brightness)'), + ); + expect(design, contains('groupedList: groupedList')); + expect(design, contains('class BusyMarkGroupedSurface')); + expect(groupedSurface, contains('return BusyMarkGroupedSurface(')); + expect(groupedSurface, isNot(contains('busyMarkSurfaceDecoration'))); expect(groupedSurface, isNot(contains('borderColor'))); expect(groupedSurface, isNot(contains('Border.all'))); expect(groupedSurface, isNot(contains('color: colors.control'))); - expect(groupedSurface, isNot(contains('BusyMarkShadow.surfaceShadows'))); - expect(groupedSurface, isNot(contains('elevation: cardTheme.elevation'))); expect(welcome, isNot(contains('_welcomeGroupedCardColor'))); expect(welcome, isNot(contains('cardTheme: theme.cardTheme.copyWith'))); }); + test( + 'semantic surfaces are centralized while inputs retain Yaru geometry', + () { + final design = File( + 'lib/src/app/busymark_design.dart', + ).readAsStringSync(); + final theme = File('lib/src/app/app_theme.dart').readAsStringSync(); + final surfaceFactory = RegExp( + r'factory BusyMarkSurfaceColors\.fromTheme[\s\S]*?' + r'static BusyMarkSurfaceColors of', + ).firstMatch(design)!.group(0)!; + + expect(theme, contains('BusyMarkSurfaceColors.fromTheme(base)')); + expect( + theme, + contains( + 'final inputDecorationTheme = ' + 'base.inputDecorationTheme', + ), + ); + expect(theme, isNot(contains('_semanticInputDecorationTheme'))); + expect(theme, isNot(contains('filled: true'))); + expect(theme, isNot(contains('fillColor: colors.control'))); + expect( + surfaceFactory, + contains('return _busyMarkSemanticSurfaceColors(theme.brightness)'), + ); + expect(design, contains('Modern Yaru/libadwaita semantic roles')); + expect(design, contains('window: const Color(0xFFFAFAFA)')); + expect(design, contains('window: const Color(0xFF2C2C2C)')); + expect(design, contains('groupedList: groupedList')); + expect(design, contains('popover: const Color(0xFF3E3E3E)')); + expect(theme, contains('surfaceContainerLowest: colors.view')); + expect(theme, contains('surfaceContainerLow: colors.window')); + expect(theme, contains('surfaceContainer: colors.panel')); + expect(theme, contains('surfaceContainerHigh: colors.secondarySidebar')); + expect(theme, contains('surfaceContainerHighest: colors.sidebar')); + expect(theme, contains('outlineVariant: colors.divider')); + expect(surfaceFactory, isNot(contains('fromBrightness'))); + }, + ); + + test('split-view sidebars share one directional semantic boundary', () { + final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); + final workspace = File( + 'lib/src/workspace/presentation/workspace_screen.dart', + ).readAsStringSync(); + final welcome = File( + 'lib/src/workspace/presentation/welcome_screen.dart', + ).readAsStringSync(); + final sidebarSurface = RegExp( + r'class BusyMarkSidebarSurface[\s\S]*?class BusyMarkGroupedList', + ).firstMatch(design)!.group(0)!; + + expect(sidebarSurface, contains('color: colors.sidebar')); + expect(sidebarSurface, contains('BorderDirectional(')); + expect(sidebarSurface, contains('end: BorderSide(')); + expect(sidebarSurface, contains('color: colors.sidebarBorder')); + expect(workspace, contains('return BusyMarkSidebarSurface(')); + expect(welcome, contains('return BusyMarkSidebarSurface(')); + expect( + workspace, + isNot(contains('decoration: BoxDecoration(color: colors.sidebar)')), + ); + expect( + welcome, + isNot(contains('decoration: BoxDecoration(color: colors.sidebar)')), + ); + }); + test('hardcoded UI colors stay in the shared design layer', () { final colorLiteral = RegExp( r'\bColors\.|\b(?:const\s+)?Color\(|\bColor\.fromRGBO\(', @@ -375,6 +471,13 @@ void main() { expect(workspace, contains('_toggleSearch(ref)')); expect(workspace, contains('_workspaceSearchProvider')); expect(workspace, contains('class _HeaderSearchField')); + expect(workspace, contains('return BusyMarkSearchField(')); + final searchField = RegExp( + r'class _HeaderSearchField[\s\S]*?class _HeaderSeparator', + ).firstMatch(workspace)!.group(0)!; + expect(searchField, isNot(contains('TextField('))); + expect(searchField, isNot(contains('OutlineInputBorder('))); + expect(searchField, contains('onEscape: widget.onEscape')); expect(workspace, contains('class _SearchSidebar')); expect(workspace, contains('_workspaceSearchResults')); expect(workspace, contains('_searchNavigationTargetProvider')); @@ -581,13 +684,22 @@ void main() { ).readAsStringSync(); expect(theme, contains('segmentedButtonTheme: SegmentedButtonThemeData')); + expect( + theme, + contains('final yaruButtonGeometry = base.filledButtonTheme.style'), + ); + expect(theme, contains('shape: segmentedShape')); + expect(theme, contains('padding: yaruButtonGeometry?.padding')); + expect(theme, contains('minimumSize: segmentedMinimumSize')); + expect(theme, isNot(contains('StadiumBorder'))); expect( theme, contains('side: const WidgetStatePropertyAll(BorderSide.none)'), ); - expect(theme, contains('return accentColor;')); - expect(theme, contains('return onAccent;')); - expect(theme, contains('return selectedContainer;')); + expect(theme, contains('return colors.controlActive;')); + expect(theme, contains('return colors.control;')); + expect(theme, contains('return colors.foreground;')); + expect(theme, isNot(contains('return selectedContainer;'))); expect(settings, contains('class _SegmentLabel')); expect(settings, contains('maxLines: 1')); expect(settings, contains('overflow: TextOverflow.ellipsis')); @@ -669,35 +781,60 @@ void main() { expect(workspace, isNot(contains('BusyMarkMenuSelectorButton'))); }); - test('shared header popup menu matches native popover shape', () { + test('shared popup menus delegate rows and surfaces to framework themes', () { final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); + final headerPopup = RegExp( + r'class BusyMarkHeaderPopupMenuButton[\s\S]*?Future ' + r'showBusyMarkContextMenu', + ).firstMatch(design)!.group(0)!; + final popupItem = RegExp( + r'class BusyMarkPopupMenuItem[\s\S]*?class BusyMarkPopupSelectorOption', + ).firstMatch(design)!.group(0)!; - expect(design, contains('showMenu')); - expect(design, contains('_BusyMarkHeaderPopoverShape')); - expect(design, contains('_busyMarkHeaderPopoverArrowHeight')); - expect(design, contains('BorderRadius.circular(BusyMarkRadius.window)')); - expect(design, contains('color: colors.subtleBorder')); - expect(design, contains('popupMenuShortcutWidth')); - expect(design, contains('BoxConstraints.tightFor(width: menuWidth)')); - expect(design, contains('popUpAnimationStyle: AnimationStyle.noAnimation')); - expect(design, contains('hoverColor: colors.controlHover')); - expect(design, contains('static const double nativeHeaderButton = 6')); + expect(headerPopup, contains('PopupMenuButton(')); + expect(headerPopup, contains('GlobalKey>()')); + expect(headerPopup, contains('showButtonMenu()')); + expect(headerPopup, contains('BusyMarkHeaderIconButton(')); + expect(headerPopup, contains('selected: _loading || _open')); + expect(headerPopup, contains('requestFocus: true')); + expect( + RegExp('requestFocus: true').allMatches(design).length, + 2, + reason: 'Header and context-menu routes must both own Escape handling.', + ); + expect(headerPopup, isNot(contains('findRenderObject()'))); + expect(headerPopup, isNot(contains('BoxConstraints.tightFor'))); + expect(headerPopup, isNot(contains('popupMenuShortcutWidth'))); + expect(headerPopup, isNot(contains('RelativeRect.fromLTRB'))); + expect(headerPopup, isNot(contains('_BusyMarkHeaderPopoverShape'))); + expect(headerPopup, isNot(contains('shape:'))); + expect(headerPopup, isNot(contains('color:'))); + expect(headerPopup, isNot(contains('elevation:'))); + expect(headerPopup, isNot(contains('shadowColor:'))); + expect(design, isNot(contains('BusyMarkPopupEscapeDismissBinding'))); expect( design, - contains('double borderRadius = BusyMarkRadius.headerButton'), + contains('static const double nativeHeaderButton = kYaruButtonRadius'), ); - expect(design, contains('BorderRadius.circular(borderRadius)')); - expect(design, contains('final String? shortcut;')); - expect(design, contains('final shortcutText = shortcut == null')); - expect(design, contains('textDirection: TextDirection.ltr')); - expect(design, isNot(contains("message: '\${widget.label}"))); - expect(design, contains('this.enabled = true')); - expect(design, contains('enabled: widget.enabled')); - expect(design, contains('colors.disabledForeground')); expect( design, - contains('EdgeInsets.symmetric(horizontal: BusyMarkSpacing.sm)'), + contains('double borderRadius = BusyMarkRadius.headerButton'), ); + expect(design, contains('BorderRadius.circular(borderRadius)')); + final headerIcon = RegExp( + r'class BusyMarkHeaderIconButton[\s\S]*?class ' + r'BusyMarkHeaderPopupMenuButton', + ).firstMatch(design)!.group(0)!; + expect(headerIcon, contains('return YaruIconButton(')); + expect(headerIcon, contains('isSelected: selected')); + expect(headerIcon, isNot(contains('DecoratedBox('))); + expect(headerIcon, isNot(contains('BoxShadow('))); + expect(popupItem, contains('extends PopupMenuItem')); + expect(popupItem, contains('child: _BusyMarkPopupMenuItemContent(')); + expect(popupItem, contains('textDirection: TextDirection.ltr')); + expect(popupItem, isNot(contains('Navigator.pop'))); + expect(popupItem, isNot(contains('InkWell('))); + expect(popupItem, isNot(contains('createState()'))); }); test('Git branch actions use the shared workspace-header popup', () { @@ -837,7 +974,8 @@ void main() { expect(gitChanges, contains('busyMarkVcsFileStatusColor')); expect(gitChanges, contains('busyMarkVcsFileColorForGitStatus(file)')); expect(gitFileStatusColors, contains('BusyMarkVcsFileColor.modified')); - expect(gitChanges, contains('BusyMarkDialogButton(')); + expect(gitChanges, contains('BusyMarkPushButton.suggested(')); + expect(gitChanges, isNot(contains('BusyMarkDialogButton('))); expect(gitChanges, isNot(contains('context.l10n.git${'Include'}InCommit'))); expect( gitChanges, @@ -853,21 +991,51 @@ void main() { expect(gitChanges, isNot(contains('GitCommitDialog'))); }); - test('settings language selector uses native hover and popover styling', () { + test('Git sidebar actions use the shared semantic button adapter', () { + final gitSidebar = File( + 'lib/src/git/presentation/git_sidebar_tab.dart', + ).readAsStringSync(); + + expect(gitSidebar, contains('BusyMarkPushButton.standard(')); + expect(gitSidebar, isNot(contains('FilledButton('))); + }); + + test('settings language selector delegates to the shared Yaru popup', () { final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); final settings = File( 'lib/src/workspace/presentation/settings_screen.dart', ).readAsStringSync(); + final workspace = File( + 'lib/src/workspace/presentation/workspace_screen.dart', + ).readAsStringSync(); expect(settings, isNot(contains('DropdownButton'))); expect(settings, contains('BusyMarkPopupSelector(')); expect(settings, isNot(contains('class _LanguageSelectorButton'))); - expect(design, contains('class BusyMarkPopupSelector')); - expect(design, contains('MouseRegion(')); - expect(design, contains('colors.controlHover')); - expect(design, contains('elevation: BusyMarkElevation.window')); - expect(design, contains('BusyMarkAlpha.languageMenuShadow')); - expect(design, contains('softWrap: false')); + expect(workspace, isNot(contains('DropdownButtonFormField'))); + expect( + workspace, + contains('BusyMarkPopupSelector'), + ); + expect( + workspace, + contains('BusyMarkPopupSelector'), + ); + final selector = RegExp( + r'class BusyMarkPopupSelector[\s\S]*?class BusyMarkClamp', + ).firstMatch(design)!.group(0)!; + expect(selector, contains('YaruPopupMenuButton(')); + expect(selector, contains('Theme.of(context).filledButtonTheme.style')); + expect(selector, contains('BusyMarkPopupMenuItem(')); + expect(selector, contains('softWrap: false')); + expect(selector, isNot(contains('BusyMarkPushButton.standard('))); + expect(selector, isNot(contains('WidgetStatesController()'))); + expect(selector, isNot(contains('showMenu('))); + expect(selector, isNot(contains('MouseRegion('))); + expect(selector, isNot(contains('shape:'))); + expect(selector, isNot(contains('color:'))); + expect(selector, isNot(contains('elevation:'))); + expect(selector, isNot(contains('shadowColor:'))); }); test('report issue form uses shared BusyMark desktop controls', () { @@ -896,64 +1064,49 @@ void main() { expect(feedback, isNot(contains('InkWell('))); }); - test('Writerside project dialog uses floating Adwaita-style entries', () { - final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); - final welcome = File( - 'lib/src/workspace/presentation/welcome_screen.dart', - ).readAsStringSync(); + test( + 'dialog text entries delegate input behavior and geometry to Flutter', + () { + final design = File( + 'lib/src/app/busymark_design.dart', + ).readAsStringSync(); + final welcome = File( + 'lib/src/workspace/presentation/welcome_screen.dart', + ).readAsStringSync(); - expect(design, contains('class BusyMarkFloatingTextEntry')); - expect(design, contains('class BusyMarkFloatingTextEntryGroup')); - expect(design, contains('busyMarkSurfaceDecoration(')); - expect(design, contains('elevated: !grouped')); - expect(design, contains('AnimatedPositionedDirectional(')); - expect(design, contains('AnimatedDefaultTextStyle(')); - expect(design, contains('AnimatedOpacity(')); - expect(design, contains('BusyMarkRadius.headerButton')); - expect(design, contains('MouseRegion(')); - expect(design, contains('EditableText(')); - expect(design, contains('BusyMarkGlyphs.edit')); - expect(design, contains('end: BusyMarkSizes.iconButton')); - expect(design, contains('opacity: focused || !widget.enabled ? 0 : 1')); - expect(design, contains('final labelColor = widget.enabled')); - expect(design, contains(': colors.disabledForeground;')); - expect(design, isNot(contains('final labelColor = focused'))); - expect(design, contains('hint: widget.errorText')); - expect(design, contains('if (hasError)')); - expect(design, contains('widget.errorText!')); - expect(design, contains('final bool enabled;')); - expect(design, contains('final TextInputType? keyboardType;')); - expect(design, contains('final int minLines;')); - expect(design, contains('final int maxLines;')); - expect(design, contains('BusyMarkSizes.floatingTextAreaHeight')); - expect(design, contains('readOnly: !widget.enabled')); - expect(design, isNot(contains('class BusyMarkDialogTextEntry'))); - expect(design, isNot(contains('InputDecoration('))); - expect(welcome, contains('BusyMarkFloatingTextEntryGroup(')); - expect( - RegExp(r'BusyMarkFloatingTextEntryGroup\(').allMatches(welcome).length, - 2, - ); - expect( - RegExp( - r'groupPosition: BusyMarkFloatingTextEntryPosition\.first', - ).allMatches(welcome).length, - 2, - ); - expect( - RegExp( - r'groupPosition: BusyMarkFloatingTextEntryPosition\.last', - ).allMatches(welcome).length, - 2, - ); - expect(welcome, contains('BusyMarkFloatingTextEntry(')); - expect(welcome, isNot(contains('BusyMarkDialogTextEntry('))); - expect(welcome, isNot(contains('autofocus: true'))); - expect( - welcome, - isNot(contains('hintText: context.l10n.defaultProjectName')), - ); - }); + expect(design, contains('class BusyMarkFloatingTextEntry')); + expect(design, contains('class BusyMarkFloatingTextEntryGroup')); + final entries = RegExp( + r'class BusyMarkFloatingTextEntryGroup[\s\S]*?class SectionLabel', + ).firstMatch(design)!.group(0)!; + expect(entries, contains('return AutofillGroup(')); + expect(entries, contains('return TextFormField(')); + expect(entries, contains('InputDecoration(')); + expect(entries, contains('labelText: label')); + expect(entries, contains('errorText: errorText')); + expect(entries, isNot(contains('EditableText('))); + expect(entries, isNot(contains('MouseRegion('))); + expect(entries, isNot(contains('GestureDetector('))); + expect(entries, isNot(contains('busyMarkSurfaceDecoration('))); + expect(entries, isNot(contains('AnimatedPositionedDirectional('))); + expect(entries, isNot(contains('groupPosition'))); + expect(design, isNot(contains('class BusyMarkDialogTextEntry'))); + expect(welcome, contains('BusyMarkFloatingTextEntryGroup(')); + expect( + RegExp(r'BusyMarkFloatingTextEntryGroup\(').allMatches(welcome).length, + 2, + ); + expect(welcome, isNot(contains('BusyMarkFloatingTextEntryPosition'))); + expect(welcome, isNot(contains('groupPosition:'))); + expect(welcome, contains('BusyMarkFloatingTextEntry(')); + expect(welcome, isNot(contains('BusyMarkDialogTextEntry('))); + expect(welcome, isNot(contains('autofocus: true'))); + expect( + welcome, + isNot(contains('hintText: context.l10n.defaultProjectName')), + ); + }, + ); test('sidebar trees share the expandable Yaru-style row', () { final workspace = File( @@ -1079,8 +1232,12 @@ void main() { expect(workspace, contains('_outlineNavigationTargetProvider')); expect(workspace, contains('_OutlineNavigationTarget')); + expect(workspace, contains('outline: _activeDocumentOutline(state)')); + expect(workspace, contains('return preview.outline')); expect(workspace, contains('headingId: heading.id')); - expect(workspace, contains('line: heading.span.startLine')); + expect(workspace, contains('line: heading.sourceStartLine')); + expect(workspace, contains('workspaceId: widget.workspace.id')); + expect(workspace, isNot(contains('widget.workspace.markdown?.headings'))); expect(workspace, contains('_sourceEditorKey.currentState?.scrollToLine')); expect(sourceEditor, contains('_focusNode.requestFocus()')); expect(sourceEditor, contains('_unfoldSourceLine(line)')); @@ -1294,7 +1451,8 @@ void main() { expect(serializer, contains('String _indentBlock(')); expect(toolbar, isNot(contains('transparent: true'))); expect(toolbar, contains('BusyMarkHeaderIconButton(')); - expect(toolbar, contains('elevated: true')); + expect(toolbar, isNot(contains('elevated: true'))); + expect(toolbar, isNot(contains('accented: true'))); expect(toolbar, contains('clipBehavior: Clip.none')); expect(toolbar, contains('hitTestBehavior: HitTestBehavior.deferToChild')); expect(toolbar, contains('horizontal: BusyMarkSpacing.sm')); diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index 200cd72..2a44fc8 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -1,5 +1,6 @@ import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/l10n/generated/app_localizations_de.dart'; +import 'package:busymark/l10n/generated/app_localizations_en.dart'; import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/core/diagnostic.dart'; @@ -9,6 +10,7 @@ import 'package:busymark/src/editor/source/source_search.dart'; import 'package:busymark/src/editor/source_language.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; void main() { testWidgets('source editor remains LTR inside an Arabic interface', ( @@ -188,4 +190,69 @@ void main() { expect(find.text(de.sourceSearchInvalidRegex), findsOneWidget); }); + + testWidgets('source fold and search options use semantic icon buttons', ( + tester, + ) async { + final en = AppLocalizationsEn(); + SourceSearchOptions? updatedOptions; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: BusyMarkLinuxPalette.blueAccent, + ), + home: Scaffold( + body: SizedBox( + width: 900, + height: 600, + child: BusyMarkSourceEditor( + text: '# Intro\nBody\n', + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: true, + searchOptions: const SourceSearchOptions(caseSensitive: true), + onSearchOptionsChanged: (options) => updatedOptions = options, + onChanged: (_, _) {}, + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ), + ), + ), + ); + + final foldTooltip = find.byTooltip(en.collapseKind(en.foldKindSection)); + final foldButton = find.ancestor( + of: foldTooltip, + matching: find.byType(BusyMarkCompactIconButton), + ); + expect(foldTooltip, findsOneWidget); + expect(foldButton, findsOneWidget); + + final caseButton = find.ancestor( + of: find.byTooltip(en.sourceSearchCaseSensitive), + matching: find.byType(YaruIconButton), + ); + final wholeWordButton = find.ancestor( + of: find.byTooltip(en.sourceSearchWholeWord), + matching: find.byType(YaruIconButton), + ); + expect(tester.widget(caseButton).isSelected, isTrue); + expect(tester.widget(wholeWordButton).isSelected, isFalse); + + await tester.tap(wholeWordButton); + await tester.pump(); + expect(updatedOptions?.caseSensitive, isTrue); + expect(updatedOptions?.wholeWord, isTrue); + + await tester.tap(foldButton); + await tester.pump(); + expect(find.byTooltip(en.expandKind(en.foldKindSection)), findsOneWidget); + }); } diff --git a/test/src/status_semantics_test.dart b/test/src/status_semantics_test.dart new file mode 100644 index 0000000..d432795 --- /dev/null +++ b/test/src/status_semantics_test.dart @@ -0,0 +1,190 @@ +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/app/app_theme.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/system_accent.dart'; +import 'package:busymark/src/git/application/git_controller.dart'; +import 'package:busymark/src/git/domain/git_models.dart'; +import 'package:busymark/src/git/presentation/git_sidebar_tab.dart'; +import 'package:busymark/src/workspace/presentation/welcome_screen.dart'; +import 'package:busymark/src/workspace/workspace_message.dart'; +import 'package:busymark/src/workspace/workspace_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +void main() { + testWidgets('status colors stay semantic across accents and brightness', ( + tester, + ) async { + for (final brightness in Brightness.values) { + Map? colorsForFirstAccent; + for (final accent in const [Color(0xFFE95420), Color(0xFF7764D8)]) { + await tester.pumpWidget( + MaterialApp( + theme: buildBusyMarkTheme( + brightness: brightness, + accentColor: accent, + ), + home: const Scaffold( + body: SizedBox(key: ValueKey('status-color-probe')), + ), + ), + ); + + final context = tester.element( + find.byKey(const ValueKey('status-color-probe')), + ); + final semanticColors = YaruColors.of(context); + final actual = { + for (final kind in BusyMarkStatusKind.values) + kind: busyMarkStatusColor(context, kind), + }; + + expect(actual, { + BusyMarkStatusKind.information: semanticColors.link, + BusyMarkStatusKind.success: semanticColors.success, + BusyMarkStatusKind.warning: semanticColors.warning, + BusyMarkStatusKind.error: semanticColors.error, + }); + colorsForFirstAccent ??= actual; + expect(actual, colorsForFirstAccent); + expect(actual[BusyMarkStatusKind.information], isNot(accent)); + } + } + }); + + test('workspace messages use semantic status roles', () { + expect( + busyMarkWorkspaceMessageStatusKind( + WorkspaceMessageCode.chooseWhereToSaveMarkdown, + ), + BusyMarkStatusKind.information, + ); + expect( + busyMarkWorkspaceMessageStatusKind( + WorkspaceMessageCode.saveBlockedFileChangedOnDisk, + ), + BusyMarkStatusKind.warning, + ); + + const operationalFailures = { + WorkspaceMessageCode.openFailed, + WorkspaceMessageCode.createWritersideProjectFailed, + WorkspaceMessageCode.createWritersideTopicFailed, + WorkspaceMessageCode.couldNotOpenFile, + WorkspaceMessageCode.saveFailed, + WorkspaceMessageCode.fileOperationFailed, + WorkspaceMessageCode.validationFailed, + }; + for (final code in operationalFailures) { + expect( + busyMarkWorkspaceMessageStatusKind(code), + BusyMarkStatusKind.error, + reason: '$code is an operational failure', + ); + } + }); + + testWidgets('Git failures use their semantic status roles', (tester) async { + const cases = { + GitFailureCode.commandFailed: BusyMarkStatusKind.error, + GitFailureCode.dirtyWorkspace: BusyMarkStatusKind.warning, + GitFailureCode.noUpstream: BusyMarkStatusKind.information, + }; + + for (final entry in cases.entries) { + await tester.pumpWidget( + _testApp( + _gitSidebar( + GitFailure( + code: entry.key, + userMessageKey: 'unused', + rawMessage: '', + commandName: 'test', + ), + ), + ), + ); + await tester.pump(); + + final status = tester.widget( + find.byType(BusyMarkStatusBox), + ); + expect(status.kind, entry.value, reason: '${entry.key}'); + } + }); + + testWidgets('successful Git operations use the success role', (tester) async { + await tester.pumpWidget(_testApp(_gitSidebar(null, message: 'Done'))); + await tester.pump(); + + final status = tester.widget( + find.byType(BusyMarkStatusBox), + ); + expect(status.kind, BusyMarkStatusKind.success); + }); +} + +Widget _gitSidebar(GitFailure? failure, {String? message}) { + const repository = GitRepositoryInfo( + rootPath: '/repo', + gitDirPath: '/repo/.git', + ); + final state = GitState( + availability: const GitAvailability( + available: true, + executablePath: '/usr/bin/git', + version: '2.50.0', + ), + repositoryInfo: repository, + statusSnapshot: const GitStatusSnapshot( + repositoryInfo: repository, + files: [], + ), + lastError: failure, + lastOperationMessage: message, + ); + return ProviderScope( + key: ValueKey((failure?.code, message)), + overrides: [ + gitControllerProvider.overrideWith(() => _PresetGitController(state)), + ], + child: GitSidebarTab( + workspace: Workspace( + id: '/repo', + rootPath: '/repo', + kind: WorkspaceKind.markdownFolder, + openedAt: DateTime(2026), + files: const [], + diagnostics: const [], + ), + onOpenFile: (_) {}, + onConfirmDiscard: (_) async => true, + onAfterWorkspaceFilesChanged: () async {}, + onConfirmSwitchBranch: (_) async => true, + onConfirmPushSetUpstream: () async => true, + ), + ); +} + +Widget _testApp(Widget child) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: busyMarkDefaultAccentColor, + ), + home: Scaffold(body: child), + ); +} + +class _PresetGitController extends GitController { + _PresetGitController(this.initialState); + + final GitState initialState; + + @override + GitState build() => initialState; +} diff --git a/test/src/system_accent_test.dart b/test/src/system_accent_test.dart index d718c2b..83b10e9 100644 --- a/test/src/system_accent_test.dart +++ b/test/src/system_accent_test.dart @@ -2,9 +2,14 @@ import 'package:busymark/src/app/system_accent.dart'; import 'package:dbus/dbus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; void main() { - test('reads RGB portal accent color values', () { + test('uses the native Yaru orange accent as the offline fallback', () { + expect(busyMarkDefaultAccentColor, YaruVariant.orange.color); + }); + + test('keeps exact RGB portal accent color values authoritative', () { final value = DBusStruct([ const DBusDouble(0.7843137383460999), const DBusDouble(0.5333333611488342), @@ -19,11 +24,89 @@ void main() { }); test('maps Ubuntu accent names when RGB portal value is unavailable', () { - expect(ubuntuAccentNameColor('yellow'), const Color(0xffc88800)); + const expectedVariants = { + 'blue': YaruVariant.blue, + 'teal': YaruVariant.adwaitaTeal, + 'green': YaruVariant.adwaitaGreen, + 'yellow': YaruVariant.adwaitaYellow, + 'orange': YaruVariant.orange, + 'red': YaruVariant.red, + 'pink': YaruVariant.magenta, + 'purple': YaruVariant.purple, + 'slate': YaruVariant.adwaitaSlate, + 'brown': YaruVariant.wartyBrown, + 'wartybrown': YaruVariant.wartyBrown, + 'magenta': YaruVariant.magenta, + 'olive': YaruVariant.olive, + 'prussiangreen': YaruVariant.prussianGreen, + 'sage': YaruVariant.sage, + }; + + for (final MapEntry(:key, :value) in expectedVariants.entries) { + expect(ubuntuAccentNameColor(key), value.color, reason: key); + } expect( colorFromUbuntuAccentNameValue(const DBusString('purple')), - const Color(0xff7764d8), + YaruVariant.purple.color, ); expect(colorFromUbuntuAccentNameValue(const DBusString('unknown')), isNull); }); + + test('falls back to the Ubuntu setting when the RGB read fails', () async { + var readGnome = false; + + final color = await readPreferredLinuxAccentColor( + readFreedesktop: () => Future.error( + StateError('freedesktop accent key is unavailable'), + ), + readGnome: () async { + readGnome = true; + return YaruVariant.orange.color; + }, + ); + + expect(color, YaruVariant.orange.color); + expect(readGnome, isTrue); + }); + + test('does not read the named fallback after an exact RGB result', () async { + var readGnome = false; + const exactRgb = Color(0xFF336699); + + final color = await readPreferredLinuxAccentColor( + readFreedesktop: () async => exactRgb, + readGnome: () async { + readGnome = true; + return YaruVariant.blue.color; + }, + ); + + expect(color, exactRgb); + expect(readGnome, isFalse); + }); + + test('an exact RGB signal remains authoritative over named signals', () { + final resolver = LinuxAccentChangeResolver(); + final exactRgb = DBusStruct([ + const DBusDouble(0.2), + const DBusDouble(0.4), + const DBusDouble(0.6), + ]); + + expect( + resolver.resolve( + 'org.gnome.desktop.interface', + const DBusString('orange'), + ), + YaruVariant.orange.color, + ); + expect( + resolver.resolve('org.freedesktop.appearance', exactRgb), + const Color(0xFF336699), + ); + expect( + resolver.resolve('org.gnome.desktop.interface', const DBusString('blue')), + isNull, + ); + }); } diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index d2e3e1b..b17c7d9 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -1012,16 +1012,8 @@ class _WorkspaceControllerDriver { Future closeAllOpenFileTabs() => _notifier.closeAllOpenFileTabs(); - void updateActiveText( - String text, { - bool updatePreview = true, - String? sourceFilePath, - }) { - _notifier.updateActiveText( - text, - updatePreview: updatePreview, - sourceFilePath: sourceFilePath, - ); + void updateActiveText(String text, {String? sourceFilePath}) { + _notifier.updateActiveText(text, sourceFilePath: sourceFilePath); } Future saveActive({bool overwriteExternalChanges = false}) => diff --git a/test/src/workspace_safety_test.dart b/test/src/workspace_safety_test.dart index 265cf29..67909b8 100644 --- a/test/src/workspace_safety_test.dart +++ b/test/src/workspace_safety_test.dart @@ -150,41 +150,37 @@ void main() { testWidgets('destructive dialog buttons stay readable on dark controls', ( tester, ) async { - late Color activeControl; + final theme = buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: Colors.green, + ); await tester.pumpWidget( MaterialApp( - theme: buildBusyMarkTheme( - brightness: Brightness.dark, - accentColor: Colors.green, - ), + theme: theme, home: Scaffold( - body: Builder( - builder: (context) { - activeControl = BusyMarkSurfaceColors.of(context).controlActive; - return BusyMarkDialogButton( - label: l10n.discard, - icon: BusyMarkGlyphs.delete, - destructive: true, - onPressed: () {}, - ); - }, + body: BusyMarkDialogButton( + label: l10n.discard, + icon: BusyMarkGlyphs.delete, + destructive: true, + onPressed: () {}, ), ), ), ); - final text = tester.widget(find.text(l10n.discard)); - final foreground = text.style?.color; + final button = tester.widget( + find.descendant( + of: find.byType(BusyMarkDialogButton), + matching: find.byType(ElevatedButton), + ), + ); + final foreground = button.style?.foregroundColor?.resolve({}); + final background = button.style?.backgroundColor?.resolve({}); expect(foreground, isNotNull); - expect( - _contrastRatio(foreground!, activeControl), - greaterThanOrEqualTo(4.5), - ); - expect( - tester.widget(find.byIcon(BusyMarkGlyphs.delete)).color, - foreground, - ); + expect(background, isNotNull); + expect(_contrastRatio(foreground!, background!), greaterThanOrEqualTo(4.5)); + expect(button.style?.iconColor?.resolve({}), foreground); }); testWidgets( diff --git a/test/src/writerside_topic_removal_service_test.dart b/test/src/writerside_topic_removal_service_test.dart index 893609b..278b837 100644 --- a/test/src/writerside_topic_removal_service_test.dart +++ b/test/src/writerside_topic_removal_service_test.dart @@ -517,16 +517,10 @@ Read [the old topic](doomed.md). '''); final preprocessingDisabled = await service.analyze( module: fixture.module, - topicPath: _topic( - fixture.module, - 'Document_everything.topic', - ).filePath, + topicPath: _topic(fixture.module, 'Document_everything.topic').filePath, mode: WritersideTopicRemovalMode.safeDeleteFile, ); - expect( - preprocessingDisabled.oldWebFileName, - 'Document_everything.html', - ); + expect(preprocessingDisabled.oldWebFileName, 'Document_everything.html'); }, ); From d8dc1d975bc30bad43bebd3859b9b06973b3faf0 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 25 Jul 2026 17:41:21 -0700 Subject: [PATCH 03/29] Refactor theme color handling and improve accent color resolution. Add support for Handy library and improve window shadow compatibility --- README.md | 4 + lib/src/app/app_theme.dart | 22 +- lib/src/app/busymark_design.dart | 69 +++- lib/src/app/system_accent.dart | 60 ++-- lib/src/editor/wysiwyg/wysiwyg_toolbar.dart | 2 + .../platform/header_bar_configuration.dart | 18 - linux/CMakeLists.txt | 1 + linux/runner/CMakeLists.txt | 1 + linux/runner/my_application.cc | 157 +++++---- snap/snapcraft.yaml | 3 + test/src/busymark_design_test.dart | 318 +++++++++++++++-- test/src/header_bar_configuration_test.dart | 24 +- test/src/native_headerbar_audit_test.dart | 137 ++++++-- test/src/source_audit_test.dart | 36 +- test/src/surface_palette_render_test.dart | 320 ++++++++++++++++++ test/src/system_accent_test.dart | 52 ++- 16 files changed, 976 insertions(+), 248 deletions(-) create mode 100644 test/src/surface_palette_render_test.dart diff --git a/README.md b/README.md index e2abeb3..145dd46 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,11 @@ Store listing translations are managed outside `snap/snapcraft.yaml`. ## Build Linux Locally +Source builds require the libhandy development headers. Packaged users receive +the runtime library with BusyMark and do not install development packages. + ```bash +sudo apt-get install libhandy-1-dev flutter build linux ``` diff --git a/lib/src/app/app_theme.dart b/lib/src/app/app_theme.dart index 9fec03b..f031c0d 100644 --- a/lib/src/app/app_theme.dart +++ b/lib/src/app/app_theme.dart @@ -18,16 +18,10 @@ ThemeData buildBusyMarkTheme({ colors, ); final onAccent = _accessibleForeground(accentColor); - final accentContainer = Color.alphaBlend( - accentColor.withValues(alpha: brightness == Brightness.dark ? 0.24 : 0.14), - colors.view, - ); final colorScheme = base.colorScheme.copyWith( brightness: brightness, primary: accentColor, onPrimary: onAccent, - primaryContainer: accentContainer, - onPrimaryContainer: _accessibleForeground(accentContainer), secondary: accentColor, onError: _accessibleForeground(base.colorScheme.error), surface: colors.view, @@ -118,15 +112,18 @@ ThemeData buildBusyMarkTheme({ }), side: const WidgetStatePropertyAll(BorderSide.none), ); + final popoverSurfaceSide = BorderSide(color: colors.floatingBorder); final menuStyle = _semanticMenuSurfaceStyle( base.menuTheme.style, color: colors.popover, shadowColor: colorScheme.shadow, + side: popoverSurfaceSide, ); final dropdownMenuStyle = _semanticMenuSurfaceStyle( base.dropdownMenuTheme.menuStyle, color: colors.popover, shadowColor: colorScheme.shadow, + side: popoverSurfaceSide, ); return base.copyWith( @@ -165,8 +162,6 @@ ThemeData buildBusyMarkTheme({ contentTextStyle: textTheme.bodyMedium, ), listTileTheme: base.listTileTheme.copyWith( - selectedColor: colors.foreground, - selectedTileColor: accentContainer, iconColor: colors.mutedForeground, textColor: colors.foreground, ), @@ -180,6 +175,7 @@ ThemeData buildBusyMarkTheme({ color: colors.popover, surfaceTintColor: colors.popover, shadowColor: colorScheme.shadow, + shape: _withOutlineSide(base.popupMenuTheme.shape, popoverSurfaceSide), iconColor: colors.mutedForeground, textStyle: textTheme.bodyMedium, labelTextStyle: WidgetStateProperty.resolveWith((states) { @@ -297,10 +293,20 @@ MenuStyle _semanticMenuSurfaceStyle( MenuStyle? base, { required Color color, required Color shadowColor, + required BorderSide side, }) { return (base ?? const MenuStyle()).copyWith( backgroundColor: WidgetStatePropertyAll(color), surfaceTintColor: WidgetStatePropertyAll(color), shadowColor: WidgetStatePropertyAll(shadowColor), + side: WidgetStatePropertyAll(side), ); } + +ShapeBorder? _withOutlineSide(ShapeBorder? shape, BorderSide side) { + return switch (shape) { + final InputBorder input => input.copyWith(borderSide: side), + final OutlinedBorder outlined => outlined.copyWith(side: side), + _ => shape, + }; +} diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index 59a917c..0c8804a 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -482,6 +482,20 @@ Color busyMarkVcsFileStatusColor( } BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { + final window = switch (brightness) { + Brightness.light => const Color(0xFFFAFAFA), + Brightness.dark => const Color(0xFF2C2C2C), + }; + final view = switch (brightness) { + Brightness.light => const Color(0xFFFFFFFF), + Brightness.dark => const Color(0xFF272727), + }; + final floatingSurface = switch (brightness) { + // Installed Yaru/libadwaita owns this neutral role independently from the + // window and content-view elevation ladder. + Brightness.light => const Color(0xFFFAFAFA), + Brightness.dark => const Color(0xFF3E3E3E), + }; final foreground = switch (brightness) { Brightness.light => const Color(0xFF3D3D3D), Brightness.dark => const Color(0xFFF7F7F7), @@ -497,6 +511,12 @@ BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { Brightness.light => const Color(0xFFFFFFFF), Brightness.dark => const Color(0xFF3D3D3D), }; + // Yaru renders the split-view boundary as a recessed divider in both + // brightness modes. A foreground tint in dark mode produces a light seam. + final sidebarBorder = switch (brightness) { + Brightness.light => const Color.fromRGBO(24, 24, 24, 0.08), + Brightness.dark => const Color.fromRGBO(16, 16, 16, 0.35), + }; Color tintedSurface(Color tint) { final alpha = brightness == Brightness.dark ? 0.16 : 0.08; @@ -508,8 +528,8 @@ BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { // Modern Yaru/libadwaita semantic roles. Flutter's Yaru theme exposes // geometry and interaction behavior, but not every contemporary // surface role, so these neutral fallbacks live in one resolver. - window: const Color(0xFFFAFAFA), - view: const Color(0xFFFFFFFF), + window: window, + view: view, sidebar: const Color(0xFFEBEBEB), secondarySidebar: const Color(0xFFF0F0F0), headerbar: const Color(0xFFFAFAFA), @@ -517,8 +537,8 @@ BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { panel: const Color(0xFFF0F0F0), card: const Color(0xFFFFFFFF), groupedList: groupedList, - dialog: const Color(0xFFFAFAFA), - popover: const Color(0xFFFAFAFA), + dialog: floatingSurface, + popover: floatingSurface, control: const Color.fromRGBO(0, 0, 0, 0.10), controlHover: const Color.fromRGBO(0, 0, 0, 0.14), controlActive: const Color.fromRGBO(0, 0, 0, 0.18), @@ -529,8 +549,10 @@ BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { border: const Color.fromRGBO(0, 0, 0, 0.18), subtleBorder: const Color.fromRGBO(0, 0, 0, 0.10), divider: const Color.fromRGBO(0, 0, 0, 0.10), - floatingBorder: const Color.fromRGBO(0, 0, 0, 0.10), - sidebarBorder: const Color.fromRGBO(0, 0, 0, 0.07), + // Installed libadwaita uses the same subtle black perimeter in either + // brightness mode; opacity is part of the semantic role. + floatingBorder: const Color.fromRGBO(0, 0, 0, 0.14), + sidebarBorder: sidebarBorder, shade: const Color.fromRGBO(0, 0, 0, 0.07), muted: mutedForeground, admonitionNote: tintedSurface(BusyMarkLinuxPalette.ubuntuBlueAccent), @@ -538,8 +560,8 @@ BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { admonitionWarning: tintedSurface(BusyMarkLinuxPalette.ubuntuYellowAccent), ), Brightness.dark => BusyMarkSurfaceColors( - window: const Color(0xFF2C2C2C), - view: const Color(0xFF272727), + window: window, + view: view, sidebar: const Color(0xFF393939), secondarySidebar: const Color(0xFF323232), headerbar: const Color(0xFF393939), @@ -547,8 +569,8 @@ BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { panel: const Color(0xFF323232), card: const Color(0xFF3D3D3D), groupedList: groupedList, - dialog: const Color(0xFF3E3E3E), - popover: const Color(0xFF3E3E3E), + dialog: floatingSurface, + popover: floatingSurface, control: const Color.fromRGBO(255, 255, 255, 0.10), controlHover: const Color.fromRGBO(255, 255, 255, 0.14), controlActive: const Color.fromRGBO(255, 255, 255, 0.18), @@ -559,8 +581,8 @@ BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { border: const Color.fromRGBO(0, 0, 0, 0.75), subtleBorder: const Color.fromRGBO(255, 255, 255, 0.10), divider: const Color.fromRGBO(255, 255, 255, 0.10), - floatingBorder: const Color.fromRGBO(255, 255, 255, 0.10), - sidebarBorder: const Color.fromRGBO(255, 255, 255, 0.10), + floatingBorder: const Color.fromRGBO(0, 0, 0, 0.14), + sidebarBorder: sidebarBorder, shade: const Color.fromRGBO(0, 0, 0, 0.25), muted: mutedForeground, admonitionNote: tintedSurface(BusyMarkLinuxPalette.ubuntuBlueAccent), @@ -871,14 +893,27 @@ class BusyMarkHeaderIconButton extends StatelessWidget { shadowColor: WidgetStatePropertyAll(colorScheme.shadow), ) : semanticStyle; - return YaruIconButton( + // YaruIconButton merges its defaults as the receiver, so non-null default + // colors win over caller-supplied semantic colors. Compose the styles in + // the opposite direction and give the result directly to IconButton. + final yaruDefaults = YaruIconButton( + icon: const SizedBox.shrink(), iconSize: BusyMarkSizes.iconButton, + ).defaultStyleOf(context); + final button = IconButton( isSelected: selected, - style: style, tooltip: shortcut == null ? tooltip : '$tooltip ($shortcut)', icon: Icon(icon, size: BusyMarkSizes.iconSm), + padding: EdgeInsets.zero, + style: style.merge(yaruDefaults), onPressed: onPressed, ); + return YaruTheme.maybeOf(context)?.focusBorders == true + ? YaruFocusBorder.primary( + borderRadius: BorderRadius.circular(BusyMarkRadius.pill), + child: button, + ) + : button; } } @@ -1225,7 +1260,11 @@ class BusyMarkPopupSelector extends StatelessWidget { enabled: selectorEnabled, tooltip: tooltip, semanticLabel: tooltip, - style: Theme.of(context).filledButtonTheme.style, + // Preserve Yaru's selector geometry and interaction states, while + // matching libadwaita's inline grouped-row value affordance. + style: Theme.of(context).outlinedButtonTheme.style?.copyWith( + side: const WidgetStatePropertyAll(BorderSide.none), + ), constraints: BoxConstraints( minWidth: popupMinWidth, maxWidth: popupMaxWidth, diff --git a/lib/src/app/system_accent.dart b/lib/src/app/system_accent.dart index 045d9a5..47bf7ea 100644 --- a/lib/src/app/system_accent.dart +++ b/lib/src/app/system_accent.dart @@ -51,8 +51,8 @@ class LinuxPortalAppearance { try { final object = _portalObject(client); return await readPreferredLinuxAccentColor( - readFreedesktop: () => _readFreedesktopAccent(object), readGnome: () => _readGnomeAccentName(object), + readFreedesktop: () => _readFreedesktopAccent(object), ); } finally { await client.close(); @@ -63,20 +63,20 @@ class LinuxPortalAppearance { final client = DBusClient.session(); try { final object = _portalObject(client); - final freedesktopAccent = await _readAccentSafely( - () => _readFreedesktopAccent(object), + final gnomeAccent = await _readAccentSafely( + () => _readGnomeAccentName(object), ); final resolver = LinuxAccentChangeResolver( - freedesktopAuthoritative: freedesktopAccent != null, + gnomeAuthoritative: gnomeAccent != null, ); - if (freedesktopAccent != null) { - yield freedesktopAccent; + if (gnomeAccent != null) { + yield gnomeAccent; } else { - final gnomeAccent = await _readAccentSafely( - () => _readGnomeAccentName(object), + final freedesktopAccent = await _readAccentSafely( + () => _readFreedesktopAccent(object), ); - if (gnomeAccent != null) { - yield gnomeAccent; + if (freedesktopAccent != null) { + yield freedesktopAccent; } } final signals = DBusRemoteObjectSignalStream( @@ -138,18 +138,22 @@ class LinuxPortalAppearance { } } -/// Reads the exact freedesktop RGB value first, while keeping the Ubuntu -/// named-accent setting as an independent fallback for older portals. +/// Resolves the Yaru accent selected by Ubuntu before consulting the generic +/// freedesktop RGB fallback. +/// +/// Ubuntu's portal exposes both values, but its generic RGB is an Adwaita +/// palette color and can differ from the active Yaru GTK theme. The named +/// setting maps to the same [YaruVariant] used by native GTK controls. @visibleForTesting Future readPreferredLinuxAccentColor({ - required Future Function() readFreedesktop, required Future Function() readGnome, + required Future Function() readFreedesktop, }) async { - final freedesktopAccent = await _readAccentSafely(readFreedesktop); - if (freedesktopAccent != null) { - return freedesktopAccent; + final gnomeAccent = await _readAccentSafely(readGnome); + if (gnomeAccent != null) { + return gnomeAccent; } - return _readAccentSafely(readGnome); + return _readAccentSafely(readFreedesktop); } Future _readAccentSafely(Future Function() read) async { @@ -160,26 +164,26 @@ Future _readAccentSafely(Future Function() read) async { } } -/// Resolves portal change signals without allowing an approximate named color -/// to replace an exact RGB value once the modern freedesktop key is available. +/// Resolves portal changes without allowing the generic freedesktop palette to +/// replace the Yaru variant that owns native GTK controls. @visibleForTesting class LinuxAccentChangeResolver { - LinuxAccentChangeResolver({bool freedesktopAuthoritative = false}) - : _freedesktopAuthoritative = freedesktopAuthoritative; + LinuxAccentChangeResolver({bool gnomeAuthoritative = false}) + : _gnomeAuthoritative = gnomeAuthoritative; - bool _freedesktopAuthoritative; + bool _gnomeAuthoritative; Color? resolve(String namespace, DBusValue value) { - if (namespace == LinuxPortalAppearance._freedesktopAppearance) { - final color = colorFromPortalAccentValue(value); + if (namespace == LinuxPortalAppearance._gnomeInterface) { + final color = colorFromUbuntuAccentNameValue(value); if (color != null) { - _freedesktopAuthoritative = true; + _gnomeAuthoritative = true; } return color; } - if (namespace == LinuxPortalAppearance._gnomeInterface && - !_freedesktopAuthoritative) { - return colorFromUbuntuAccentNameValue(value); + if (namespace == LinuxPortalAppearance._freedesktopAppearance && + !_gnomeAuthoritative) { + return colorFromPortalAccentValue(value); } return null; } diff --git a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart index 0b08b45..764a7bf 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart @@ -247,6 +247,7 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { tooltip: context.l10n.textStyle, icon: BusyMarkGlyphs.font, shortcut: BusyMarkEditorShortcutLabels.textStyle, + transparent: false, itemBuilder: (context) => [ BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.paragraph, @@ -300,6 +301,7 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { icon: icon, onPressed: onPressed, shortcut: shortcut, + transparent: false, ); } } diff --git a/lib/src/platform/header_bar_configuration.dart b/lib/src/platform/header_bar_configuration.dart index b13b0f9..614433c 100644 --- a/lib/src/platform/header_bar_configuration.dart +++ b/lib/src/platform/header_bar_configuration.dart @@ -105,10 +105,7 @@ class HeaderBarTheme { required this.backgroundColor, required this.sidebarBackgroundColor, required this.foregroundColor, - required this.popoverBackgroundColor, - required this.borderColor, required this.sidebarBorderColor, - required this.floatingBorderColor, required this.modalBarrierColor, }); @@ -122,10 +119,7 @@ class HeaderBarTheme { backgroundColor: colors.view, sidebarBackgroundColor: colors.sidebar, foregroundColor: colors.foreground, - popoverBackgroundColor: colors.popover, - borderColor: colors.subtleBorder, sidebarBorderColor: colors.sidebarBorder, - floatingBorderColor: colors.floatingBorder, modalBarrierColor: barrier, ); } @@ -134,10 +128,7 @@ class HeaderBarTheme { final Color backgroundColor; final Color sidebarBackgroundColor; final Color foregroundColor; - final Color popoverBackgroundColor; - final Color borderColor; final Color sidebarBorderColor; - final Color floatingBorderColor; final Color modalBarrierColor; Map toMap() => { @@ -145,10 +136,7 @@ class HeaderBarTheme { 'backgroundColor': _cssColor(backgroundColor), 'sidebarBackgroundColor': _cssColor(sidebarBackgroundColor), 'foregroundColor': _cssColor(foregroundColor), - 'popoverBackgroundColor': _cssColor(popoverBackgroundColor), - 'borderColor': _cssColor(borderColor), 'sidebarBorderColor': _cssColor(sidebarBorderColor), - 'floatingBorderColor': _cssColor(floatingBorderColor), 'modalBarrierColor': _cssColor(modalBarrierColor), }; @@ -160,10 +148,7 @@ class HeaderBarTheme { backgroundColor == other.backgroundColor && sidebarBackgroundColor == other.sidebarBackgroundColor && foregroundColor == other.foregroundColor && - popoverBackgroundColor == other.popoverBackgroundColor && - borderColor == other.borderColor && sidebarBorderColor == other.sidebarBorderColor && - floatingBorderColor == other.floatingBorderColor && modalBarrierColor == other.modalBarrierColor; } @@ -173,10 +158,7 @@ class HeaderBarTheme { backgroundColor, sidebarBackgroundColor, foregroundColor, - popoverBackgroundColor, - borderColor, sidebarBorderColor, - floatingBorderColor, modalBarrierColor, ]); } diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 928d0ca..c25b538 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -53,6 +53,7 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) # System-level dependencies. find_package(PkgConfig REQUIRED) pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(HANDY REQUIRED IMPORTED_TARGET libhandy-1) # Application build; see runner/CMakeLists.txt. add_subdirectory("runner") diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt index e97dabc..ccbb420 100644 --- a/linux/runner/CMakeLists.txt +++ b/linux/runner/CMakeLists.txt @@ -22,5 +22,6 @@ add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") # Add dependency libraries. Add any application-specific dependencies here. target_link_libraries(${BINARY_NAME} PRIVATE flutter) target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::HANDY) target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index b5520d9..a704fb6 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -2,12 +2,10 @@ #include #include +#include #include #include #include -#ifdef GDK_WINDOWING_X11 -#include -#endif #include "flutter/generated_plugin_registrant.h" @@ -18,8 +16,35 @@ constexpr gint kHeaderButtonSpacing = 8; constexpr gint kHeaderSidebarInset = 8; constexpr char kDefaultHeaderbarBackground[] = "#272727"; constexpr char kDefaultSidebarBackground[] = "#393939"; +constexpr char kDefaultSidebarBorder[] = "rgba(16,16,16,0.35)"; constexpr char kDefaultForeground[] = "#F7F7F7"; -constexpr char kDefaultPopoverBackground[] = "#3E3E3E"; +// Yaru GTK 3 adds a zero-blur 23%/75% black ring around CSD windows. Current +// Ubuntu apps retain the diffuse shadow without that legacy hard edge. Reuse +// Yaru's geometry here; Handy continues to own clipping, radii, and states. +constexpr char kLegacyYaruWindowShadowCompatibilityCss[] = + "window#busymark-window:not(.solid-csd):not(.maximized):" + "not(.fullscreen):not(.tiled):not(.tiled-top):not(.tiled-right):" + "not(.tiled-bottom):not(.tiled-left) > decoration {" + "box-shadow: 0 3px 9px 1px rgba(0,0,0,0.5);" + "}" + "window#busymark-window:not(.solid-csd):not(.maximized):" + "not(.fullscreen):not(.tiled):not(.tiled-top):not(.tiled-right):" + "not(.tiled-bottom):not(.tiled-left) > decoration:backdrop {" + "box-shadow: 0 3px 9px 1px transparent," + "0 2px 6px 2px rgba(0,0,0,0.2);" + "}" + "window#busymark-window.tiled:not(.solid-csd):not(.maximized):" + "not(.fullscreen) > decoration," + "window#busymark-window.tiled-top:not(.solid-csd):not(.maximized):" + "not(.fullscreen) > decoration," + "window#busymark-window.tiled-right:not(.solid-csd):not(.maximized):" + "not(.fullscreen) > decoration," + "window#busymark-window.tiled-bottom:not(.solid-csd):not(.maximized):" + "not(.fullscreen) > decoration," + "window#busymark-window.tiled-left:not(.solid-csd):not(.maximized):" + "not(.fullscreen) > decoration {" + "box-shadow: 0 0 0 20px transparent;" + "}"; constexpr char kLtrIsolateStart[] = "\xE2\x81\xA6"; constexpr char kBidiIsolateEnd[] = "\xE2\x81\xA9"; @@ -30,6 +55,7 @@ struct _MyApplication { GtkCssProvider* header_bar_css_provider; GtkWindow* main_window; GtkWidget* flutter_view; + GtkWidget* titlebar_handle; GtkWidget* titlebar_box; GtkHeaderBar* header_bar; GtkWidget* sidebar_header_box; @@ -62,10 +88,7 @@ struct _MyApplication { gchar* background_color; gchar* sidebar_background_color; gchar* foreground_color; - gchar* popover_background_color; - gchar* border_color; gchar* sidebar_border_color; - gchar* floating_border_color; gchar* modal_barrier_color; gint sidebar_width; gboolean sidebar_visible; @@ -212,6 +235,26 @@ static void set_gtk_theme_preference(gboolean prefer_dark) { } } +static gboolean uses_legacy_yaru_window_shadow() { + GtkSettings* settings = gtk_settings_get_default(); + if (settings == nullptr) { + return FALSE; + } + + g_autofree gchar* theme_name = nullptr; + g_object_get(settings, "gtk-theme-name", &theme_name, nullptr); + if (theme_name == nullptr) { + return FALSE; + } + + g_autofree gchar* normalized_theme = g_ascii_strdown(theme_name, -1); + const gboolean is_yaru = + g_strcmp0(normalized_theme, "yaru") == 0 || + g_str_has_prefix(normalized_theme, "yaru-"); + return is_yaru && strstr(normalized_theme, "highcontrast") == nullptr && + strstr(normalized_theme, "high-contrast") == nullptr; +} + static void respond_success(FlMethodCall* method_call) { g_autoptr(FlValue) result = fl_value_new_null(); fl_method_call_respond_success(method_call, result, nullptr); @@ -536,18 +579,15 @@ static void refresh_header_bar_css(MyApplication* self) { css_color_or(self->sidebar_background_color, kDefaultSidebarBackground); const gchar* foreground = css_color_or(self->foreground_color, kDefaultForeground); - const gchar* popover_background = css_color_or( - self->popover_background_color, kDefaultPopoverBackground); - const gchar* border = - css_color_or(self->border_color, "rgba(255,255,255,0.10)"); const gchar* sidebar_border = - css_color_or(self->sidebar_border_color, border); - const gchar* floating_border = - css_color_or(self->floating_border_color, border); + css_color_or(self->sidebar_border_color, kDefaultSidebarBorder); const gchar* modal = css_color_or(self->modal_barrier_color, "rgba(0,0,0,0.32)"); g_autofree gchar* modal_sidebar_border = modal_sidebar_border_css_color(sidebar_border, sidebar_background, modal); + const gchar* window_shadow_css = uses_legacy_yaru_window_shadow() + ? kLegacyYaruWindowShadowCompatibilityCss + : ""; gtk_style_context_add_class(gtk_widget_get_style_context(self->titlebar_box), "busymark-titlebar"); @@ -561,10 +601,7 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: %s;" "background-image: none;" "}" - "window#busymark-window decoration," - "window#busymark-window decoration:backdrop {" - "border-color: %s;" - "}" + "%s" ".busymark-titlebar," ".busymark-titlebar:backdrop {" "background-color: %s;" @@ -637,13 +674,6 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: alpha(currentColor, 0.19);" "background-image: none;" "}" - "popover.background.busymark-header-popover," - "popover.background.busymark-header-popover:backdrop {" - "background-color: %s;" - "background-image: none;" - "border-color: %s;" - "color: %s;" - "}" ".busymark-titlebar.busymark-modal-barrier," ".busymark-titlebar.busymark-modal-barrier " "headerbar.busymark-headerbar," @@ -660,10 +690,10 @@ static void refresh_header_bar_css(MyApplication* self) { ".busymark-sidebar-header:dir(rtl) {" "border-left-color: %s;" "}", - background, floating_border, background, foreground, background, + background, window_shadow_css, background, foreground, background, foreground, sidebar_background, foreground, sidebar_border, - sidebar_border, popover_background, floating_border, foreground, modal, - modal, modal_sidebar_border, modal_sidebar_border); + sidebar_border, modal, modal, modal_sidebar_border, + modal_sidebar_border); g_autoptr(GError) error = nullptr; GtkCssProvider* provider = gtk_css_provider_new(); @@ -686,6 +716,12 @@ static void refresh_header_bar_css(MyApplication* self) { GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); } +static void gtk_theme_name_changed_cb(GtkSettings*, + GParamSpec*, + gpointer user_data) { + refresh_header_bar_css(MY_APPLICATION(user_data)); +} + static void set_header_bar_theme(MyApplication* self, FlValue* args) { if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { return; @@ -701,17 +737,9 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { fl_lookup_string_arg(args, "sidebarBackgroundColor")); replace_css_color_field(&self->foreground_color, fl_lookup_string_arg(args, "foregroundColor")); - replace_css_color_field( - &self->popover_background_color, - fl_lookup_string_arg(args, "popoverBackgroundColor")); - replace_css_color_field(&self->border_color, - fl_lookup_string_arg(args, "borderColor")); replace_css_color_field( &self->sidebar_border_color, fl_lookup_string_arg(args, "sidebarBorderColor")); - replace_css_color_field( - &self->floating_border_color, - fl_lookup_string_arg(args, "floatingBorderColor")); replace_css_color_field(&self->modal_barrier_color, fl_lookup_string_arg(args, "modalBarrierColor")); refresh_header_bar_css(self); @@ -1184,9 +1212,6 @@ static GtkWidget* create_model_menu_button(GMenuModel* model, GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(button)); if (popover != nullptr) { gtk_popover_set_position(popover, GTK_POS_BOTTOM); - gtk_style_context_add_class( - gtk_widget_get_style_context(GTK_WIDGET(popover)), - "busymark-header-popover"); if (popover_out != nullptr) { *popover_out = GTK_WIDGET(popover); } @@ -1253,7 +1278,12 @@ static void set_modal_barrier_visible(MyApplication* self, gboolean visible) { } else { gtk_style_context_remove_class(context, "busymark-modal-barrier"); } - gtk_widget_set_sensitive(self->titlebar_box, !visible); + } + if (self->titlebar_handle != nullptr && + GTK_IS_WIDGET(self->titlebar_handle)) { + // The Handy handle owns native drag, double-click, and window-menu input. + // Disable the interaction surface itself while Flutter has a modal open. + gtk_widget_set_sensitive(self->titlebar_handle, !visible); } } @@ -1687,8 +1717,8 @@ static void first_frame_cb(MyApplication* self, FlView* view) { // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + GtkWindow* window = GTK_WINDOW(hdy_application_window_new()); + gtk_application_add_window(GTK_APPLICATION(application), window); self->main_window = window; gtk_window_set_title(window, kApplicationDisplayName); gtk_widget_set_name(GTK_WIDGET(window), "busymark-window"); @@ -1704,21 +1734,11 @@ static void my_application_activate(GApplication* application) { gtk_window_set_icon_name(window, APPLICATION_ID); } - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkWidget* titlebar = create_busymark_titlebar(self); - gtk_widget_show_all(titlebar); - gtk_window_set_titlebar(window, titlebar); - } + self->titlebar_handle = hdy_window_handle_new(); + gtk_widget_set_hexpand(self->titlebar_handle, TRUE); + gtk_container_add(GTK_CONTAINER(self->titlebar_handle), + create_busymark_titlebar(self)); + gtk_widget_show_all(self->titlebar_handle); gtk_window_set_default_size(window, 1280, 720); @@ -1732,7 +1752,13 @@ static void my_application_activate(GApplication* application) { gdk_rgba_parse(&background_color, "#00000000"); fl_view_set_background_color(view, &background_color); gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + GtkWidget* window_content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_box_pack_start(GTK_BOX(window_content), self->titlebar_handle, FALSE, + FALSE, 0); + gtk_box_pack_start(GTK_BOX(window_content), GTK_WIDGET(view), TRUE, TRUE, 0); + gtk_widget_show(window_content); + gtk_container_add(GTK_CONTAINER(window), window_content); g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); @@ -1767,6 +1793,14 @@ static gboolean my_application_local_command_line(GApplication* application, // Implements GApplication::startup. static void my_application_startup(GApplication* application) { G_APPLICATION_CLASS(my_application_parent_class)->startup(application); + hdy_init(); + + GtkSettings* settings = gtk_settings_get_default(); + if (settings != nullptr) { + g_signal_connect_object(settings, "notify::gtk-theme-name", + G_CALLBACK(gtk_theme_name_changed_cb), application, + G_CONNECT_DEFAULT); + } } // Implements GApplication::shutdown. @@ -1792,14 +1826,11 @@ static void my_application_dispose(GObject* object) { g_clear_object(&self->header_action_group); g_clear_pointer(&self->background_color, g_free); g_clear_pointer(&self->sidebar_background_color, g_free); - g_clear_pointer(&self->border_color, g_free); g_clear_pointer(&self->sidebar_border_color, g_free); - g_clear_pointer(&self->floating_border_color, g_free); g_clear_pointer(&self->modal_barrier_color, g_free); g_clear_pointer(&self->view_mode, g_free); g_clear_pointer(&self->search_query, g_free); g_clear_pointer(&self->foreground_color, g_free); - g_clear_pointer(&self->popover_background_color, g_free); g_clear_pointer(&self->header_configuration_session_id, g_free); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); G_OBJECT_CLASS(my_application_parent_class)->dispose(object); @@ -1820,6 +1851,7 @@ static void my_application_init(MyApplication* self) { self->header_bar_css_provider = nullptr; self->main_window = nullptr; self->flutter_view = nullptr; + self->titlebar_handle = nullptr; self->titlebar_box = nullptr; self->header_bar = nullptr; self->sidebar_header_box = nullptr; @@ -1852,10 +1884,7 @@ static void my_application_init(MyApplication* self) { self->background_color = g_strdup(kDefaultHeaderbarBackground); self->sidebar_background_color = g_strdup(kDefaultSidebarBackground); self->foreground_color = g_strdup(kDefaultForeground); - self->popover_background_color = g_strdup(kDefaultPopoverBackground); - self->border_color = nullptr; self->sidebar_border_color = nullptr; - self->floating_border_color = nullptr; self->modal_barrier_color = nullptr; self->sidebar_width = 300; self->sidebar_visible = TRUE; diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index b7cb813..627a2ed 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -52,7 +52,10 @@ parts: source: . flutter-target: lib/main.dart flutter-channel: stable + build-packages: + - libhandy-1-dev stage-packages: + - libhandy-1-0 - libx11-6 - libxdamage1 - libxext6 diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index 5e9ae1e..1863425 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -1,11 +1,16 @@ import 'dart:async'; +import 'dart:ui' as ui; +import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/app/busymark_glyphs.dart'; import 'package:busymark/src/editor/document_layout.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_toolbar.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; @@ -174,12 +179,13 @@ void main() { accented: true, onPressed: null, ), - const BusyMarkCompactIconButton( - key: ValueKey('disabled-compact-icon-button'), - tooltip: 'Disabled compact action', + BusyMarkHeaderIconButton( + key: const ValueKey('custom-icon-button'), + tooltip: 'Custom action', icon: BusyMarkGlyphs.clear, - foregroundColor: Color(0xFF7764D8), - onPressed: null, + foregroundColor: const Color(0xFF7764D8), + transparent: false, + onPressed: () {}, ), BusyMarkHeaderPopupMenuButton( key: const ValueKey('elevated-popup-button'), @@ -197,32 +203,35 @@ void main() { ), ); - YaruIconButton yaruButton(String key) { - return tester.widget( + IconButton iconButton(String key) { + return tester.widget( find.descendant( of: find.byKey(ValueKey(key)), - matching: find.byType(YaruIconButton), + matching: find.byType(IconButton), ), ); } - final elevatedIcon = yaruButton('elevated-icon-button'); - final flatIcon = yaruButton('flat-icon-button'); - final disabledAccentedIcon = yaruButton('disabled-accented-icon-button'); - final disabledCompactIcon = yaruButton('disabled-compact-icon-button'); - final elevatedPopup = yaruButton('elevated-popup-button'); + final elevatedIcon = iconButton('elevated-icon-button'); + final flatIcon = iconButton('flat-icon-button'); + final disabledAccentedIcon = iconButton('disabled-accented-icon-button'); + final customIcon = iconButton('custom-icon-button'); + final elevatedPopup = iconButton('elevated-popup-button'); final expectedElevation = theme.cardTheme.elevation ?? BusyMarkElevation.surface; final colors = theme.extension()!; - expect(elevatedIcon.iconSize, BusyMarkSizes.iconButton); + expect( + elevatedIcon.style?.fixedSize?.resolve({}), + const Size.square(BusyMarkSizes.iconButton), + ); expect(elevatedIcon.isSelected, isFalse); expect(elevatedIcon.style?.tapTargetSize, MaterialTapTargetSize.shrinkWrap); expect( tester.getSize( find.descendant( of: find.byKey(const ValueKey('elevated-icon-button')), - matching: find.byType(YaruIconButton), + matching: find.byType(IconButton), ), ), const Size.square(BusyMarkSizes.iconButton), @@ -231,7 +240,7 @@ void main() { tester.getSize( find.descendant( of: find.byKey(const ValueKey('elevated-popup-button')), - matching: find.byType(YaruIconButton), + matching: find.byType(IconButton), ), ), const Size.square(BusyMarkSizes.iconButton), @@ -243,7 +252,7 @@ void main() { ); expect(elevatedPopup.style?.elevation?.resolve({}), expectedElevation); expect(flatIcon.style?.elevation, isNull); - expect(flatIcon.style?.backgroundColor, isNull); + expect(flatIcon.style?.backgroundColor?.resolve({}), isNull); expect(elevatedIcon.style?.backgroundColor?.resolve({}), colors.control); expect(elevatedPopup.style?.backgroundColor?.resolve({}), colors.control); expect( @@ -259,11 +268,10 @@ void main() { colors.disabledControl, ); expect( - disabledCompactIcon.style?.foregroundColor?.resolve({ - WidgetState.disabled, - }), - colors.disabledForeground, + customIcon.style?.foregroundColor?.resolve({}), + const Color(0xFF7764D8), ); + expect(customIcon.style?.backgroundColor?.resolve({}), colors.control); expect( find.descendant( of: find.byKey(const ValueKey('elevated-icon-button')), @@ -370,6 +378,14 @@ void main() { expect(theme.colorScheme.primary, accent); expect(theme.colorScheme.secondary, accent); + expect( + theme.colorScheme.primaryContainer, + base.colorScheme.primaryContainer, + ); + expect( + theme.colorScheme.onPrimaryContainer, + base.colorScheme.onPrimaryContainer, + ); expect( _contrastRatio(theme.colorScheme.onPrimary, theme.colorScheme.primary), greaterThanOrEqualTo(4.5), @@ -395,8 +411,35 @@ void main() { theme.textTheme.bodyMedium?.letterSpacing, base.textTheme.bodyMedium?.letterSpacing, ); - expect(theme.dialogTheme.shape, base.dialogTheme.shape); final colors = theme.extension()!; + final floatingSide = BorderSide(color: colors.floatingBorder); + expect(theme.dialogTheme.shape, base.dialogTheme.shape); + _expectSameGeometryWithSide( + theme.popupMenuTheme.shape, + base.popupMenuTheme.shape, + floatingSide, + ); + expect( + theme.menuTheme.style?.shape?.resolve({}), + base.menuTheme.style?.shape?.resolve({}), + ); + expect(theme.menuTheme.style?.side?.resolve({}), floatingSide); + expect( + theme.dropdownMenuTheme.menuStyle?.shape?.resolve({}), + base.dropdownMenuTheme.menuStyle?.shape?.resolve({}), + ); + expect( + theme.dropdownMenuTheme.menuStyle?.side?.resolve({}), + floatingSide, + ); + expect( + theme.listTileTheme.selectedColor, + base.listTileTheme.selectedColor, + ); + expect( + theme.listTileTheme.selectedTileColor, + base.listTileTheme.selectedTileColor, + ); expect(theme.colorScheme.surface, colors.view); expect(theme.colorScheme.onSurface, colors.foreground); expect(theme.colorScheme.onSurfaceVariant, colors.mutedForeground); @@ -515,12 +558,16 @@ void main() { expect(light.view, const Color(0xFFFFFFFF)); expect(light.sidebar, const Color(0xFFEBEBEB)); expect(light.secondarySidebar, const Color(0xFFF0F0F0)); + expect(light.sidebarBorder, const Color.fromRGBO(24, 24, 24, 0.08)); expect(light.headerbar, const Color(0xFFFAFAFA)); expect(light.card, const Color(0xFFFFFFFF)); expect(light.groupedList, light.card); expect(light.dialog, const Color(0xFFFAFAFA)); expect(light.popover, const Color(0xFFFAFAFA)); + expect(light.card, isNot(light.dialog)); + expect(light.groupedList, isNot(light.dialog)); expect(light.control, const Color.fromRGBO(0, 0, 0, 0.10)); + expect(light.floatingBorder, const Color.fromRGBO(0, 0, 0, 0.14)); expect(light.controlHover, const Color.fromRGBO(0, 0, 0, 0.14)); expect(light.controlActive, const Color.fromRGBO(0, 0, 0, 0.18)); @@ -529,12 +576,16 @@ void main() { expect(dark.view, const Color(0xFF272727)); expect(dark.sidebar, const Color(0xFF393939)); expect(dark.secondarySidebar, const Color(0xFF323232)); + expect(dark.sidebarBorder, const Color.fromRGBO(16, 16, 16, 0.35)); expect(dark.headerbar, const Color(0xFF393939)); expect(dark.card, const Color(0xFF3D3D3D)); expect(dark.groupedList, dark.card); expect(dark.dialog, const Color(0xFF3E3E3E)); expect(dark.popover, const Color(0xFF3E3E3E)); + expect(dark.card, isNot(dark.dialog)); + expect(dark.groupedList, isNot(dark.dialog)); expect(dark.control, const Color.fromRGBO(255, 255, 255, 0.10)); + expect(dark.floatingBorder, const Color.fromRGBO(0, 0, 0, 0.14)); expect(dark.controlHover, const Color.fromRGBO(255, 255, 255, 0.14)); expect(dark.controlActive, const Color.fromRGBO(255, 255, 255, 0.18)); @@ -667,7 +718,6 @@ void main() { brightness: Brightness.dark, accentColor: const Color(0xFF3584E4), ); - final colors = theme.extension()!; String? selectedTheme; await tester.pumpWidget( @@ -737,7 +787,23 @@ void main() { matching: find.byType(YaruPopupMenuButton), ); final selector = tester.widget>(selectorFinder); - expect(selector.style?.backgroundColor?.resolve({}), colors.control); + expect( + selector.style?.backgroundColor?.resolve({}), + BusyMarkLinuxPalette.transparent, + ); + expect(selector.style?.side?.resolve({}), BorderSide.none); + expect( + selector.style?.minimumSize?.resolve({}), + theme.outlinedButtonTheme.style?.minimumSize?.resolve({}), + ); + expect( + selector.style?.padding?.resolve({}), + theme.outlinedButtonTheme.style?.padding?.resolve({}), + ); + expect( + selector.style?.shape?.resolve({}), + theme.outlinedButtonTheme.style?.shape?.resolve({}), + ); expect(selector.initialValue, 'system'); expect(selector.enabled, isTrue); @@ -749,6 +815,107 @@ void main() { expect(selectedTheme, 'light'); }); + for (final brightness in Brightness.values) { + testWidgets( + 'WYSIWYG toolbar uses contained Yaru controls in ${brightness.name}', + (tester) async { + final theme = buildBusyMarkTheme( + brightness: brightness, + accentColor: const Color(0xFFE95420), + ); + final colors = theme.extension()!; + final boundaryKey = GlobalKey(); + tester.view + ..physicalSize = const Size(900, 240) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: RepaintBoundary( + key: boundaryKey, + child: Scaffold( + body: BusyMarkWysiwygToolbar( + onBlockCommand: (_) {}, + onInlineCommand: (_) {}, + onLinkCommand: () {}, + onImageCommand: () {}, + onInlineImageCommand: () {}, + onTableCommand: () {}, + onHtmlCommand: () {}, + onIndentCommand: () {}, + onOutdentCommand: () {}, + onToggleTaskCommand: () {}, + onHardBreakCommand: () {}, + onCodeLanguageCommand: () {}, + ), + ), + ), + ), + ); + await tester.pump(); + + final toolbar = find.byType(BusyMarkWysiwygToolbar); + final popup = tester.widget( + find.descendant( + of: toolbar, + matching: find.byWidgetPredicate( + (widget) => widget is BusyMarkHeaderPopupMenuButton, + ), + ), + ); + expect(popup.transparent, isFalse); + + final actions = tester.widgetList( + find.descendant( + of: toolbar, + matching: find.byType(BusyMarkHeaderIconButton), + ), + ); + expect(actions, isNotEmpty); + expect(actions.every((button) => !button.transparent), isTrue); + + final renderedButtons = tester.widgetList( + find.descendant(of: toolbar, matching: find.byType(IconButton)), + ); + expect(renderedButtons, isNotEmpty); + for (final button in renderedButtons) { + expect(button.style?.backgroundColor?.resolve({}), colors.control); + } + + final actionButton = find.ancestor( + of: find.byIcon(BusyMarkGlyphs.unorderedList), + matching: find.byType(IconButton), + ); + expect(actionButton, findsOneWidget); + final buttonSize = tester.getSize(actionButton); + expect(buttonSize, const Size.square(BusyMarkSizes.iconButton)); + final probe = Offset(5, buttonSize.height / 2); + final restPixels = await _capturePixels(tester, boundaryKey); + final rest = _pixelAtLocal(tester, restPixels, actionButton, probe); + final expectedRest = Color.alphaBlend(colors.control, colors.window); + _expectColorNear(rest, expectedRest); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(actionButton)); + await tester.pumpAndSettle(); + final hoverPixels = await _capturePixels(tester, boundaryKey); + final hover = _pixelAtLocal(tester, hoverPixels, actionButton, probe); + final expectedHover = Color.alphaBlend( + theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.08), + expectedRest, + ); + _expectColorNear(hover, expectedHover); + expect(hover, isNot(rest)); + }, + ); + } + testWidgets('dialog actions wrap at narrow localized text widths', ( tester, ) async { @@ -957,3 +1124,106 @@ double _contrastRatio(Color foreground, Color background) { : foregroundLuminance; return (lighter + 0.05) / (darker + 0.05); } + +void _expectSameGeometryWithSide( + ShapeBorder? actual, + ShapeBorder? base, + BorderSide expectedSide, +) { + expect(actual.runtimeType, base.runtimeType); + expect(_shapeSide(actual), expectedSide); + expect( + _shapeWithSide(actual, BorderSide.none), + _shapeWithSide(base, BorderSide.none), + ); +} + +BorderSide? _shapeSide(ShapeBorder? shape) { + return switch (shape) { + final InputBorder input => input.borderSide, + final OutlinedBorder outlined => outlined.side, + _ => null, + }; +} + +ShapeBorder? _shapeWithSide(ShapeBorder? shape, BorderSide side) { + return switch (shape) { + final InputBorder input => input.copyWith(borderSide: side), + final OutlinedBorder outlined => outlined.copyWith(side: side), + _ => shape, + }; +} + +Future<_CapturedPixels> _capturePixels( + WidgetTester tester, + GlobalKey boundaryKey, +) async { + final boundary = + boundaryKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = (await tester.binding.runAsync( + () => boundary.toImage(pixelRatio: 1), + ))!; + try { + final data = (await tester.binding.runAsync( + () => image.toByteData(format: ui.ImageByteFormat.rawStraightRgba), + ))!; + return _CapturedPixels( + bytes: data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + width: image.width, + boundary: boundary, + ); + } finally { + image.dispose(); + } +} + +Color _pixelAtLocal( + WidgetTester tester, + _CapturedPixels pixels, + Finder finder, + Offset localOffset, +) { + final box = tester.renderObject(finder); + final globalPoint = box.localToGlobal(localOffset); + final point = pixels.boundary.globalToLocal(globalPoint); + final x = point.dx.floor(); + final y = point.dy.floor(); + final offset = (y * pixels.width + x) * 4; + return Color.fromARGB( + pixels.bytes[offset + 3], + pixels.bytes[offset], + pixels.bytes[offset + 1], + pixels.bytes[offset + 2], + ); +} + +void _expectColorNear(Color actual, Color expected, {int tolerance = 4}) { + expect( + (actual.r * 255 - expected.r * 255).abs(), + lessThanOrEqualTo(tolerance), + ); + expect( + (actual.g * 255 - expected.g * 255).abs(), + lessThanOrEqualTo(tolerance), + ); + expect( + (actual.b * 255 - expected.b * 255).abs(), + lessThanOrEqualTo(tolerance), + ); + expect( + (actual.a * 255 - expected.a * 255).abs(), + lessThanOrEqualTo(tolerance), + ); +} + +class _CapturedPixels { + const _CapturedPixels({ + required this.bytes, + required this.width, + required this.boundary, + }); + + final Uint8List bytes; + final int width; + final RenderRepaintBoundary boundary; +} diff --git a/test/src/header_bar_configuration_test.dart b/test/src/header_bar_configuration_test.dart index e09bcb5..8b77aad 100644 --- a/test/src/header_bar_configuration_test.dart +++ b/test/src/header_bar_configuration_test.dart @@ -383,42 +383,23 @@ void main() { }, ); - test('theme map contains only structural colors with distinct borders', () { + test('theme map contains only native header structure roles', () { expect(_theme.toMap().keys, { 'preferDark', 'backgroundColor', 'sidebarBackgroundColor', 'foregroundColor', - 'popoverBackgroundColor', - 'borderColor', 'sidebarBorderColor', - 'floatingBorderColor', 'modalBarrierColor', }); expect( _theme.toMap(), containsPair('sidebarBorderColor', 'rgba(1,2,3,0.067)'), ); - expect( - _theme.toMap(), - containsPair('floatingBorderColor', 'rgba(4,5,6,0.133)'), - ); expect( _theme.toMap(), containsPair('foregroundColor', 'rgba(32,32,32,1.000)'), ); - expect( - _theme.toMap(), - containsPair('popoverBackgroundColor', 'rgba(250,250,250,1.000)'), - ); - expect( - _theme.toMap()['borderColor'], - isNot(_theme.toMap()['sidebarBorderColor']), - ); - expect( - _theme.toMap()['borderColor'], - isNot(_theme.toMap()['floatingBorderColor']), - ); }); } @@ -481,9 +462,6 @@ const _theme = HeaderBarTheme( backgroundColor: Color(0xFFFFFFFF), sidebarBackgroundColor: Color(0xFFF6F6F6), foregroundColor: Color(0xFF202020), - popoverBackgroundColor: Color(0xFFFAFAFA), - borderColor: Color(0x22000000), sidebarBorderColor: Color(0x11010203), - floatingBorderColor: Color(0x22040506), modalBarrierColor: Color(0x55000000), ); diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index f193577..1bb93c1 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -6,11 +6,15 @@ void main() { test('Linux runner owns one native GTK headerbar', () { final source = File('linux/runner/my_application.cc').readAsStringSync(); + expect(source, contains('#include ')); + expect(source, contains('hdy_init()')); + expect(source, contains('hdy_application_window_new()')); + expect(source, contains('hdy_window_handle_new()')); expect(source, contains('gtk_header_bar_new()')); expect(source, contains('gtk_header_bar_set_show_close_button')); - expect(source, contains('gtk_window_set_titlebar')); + expect(source, isNot(contains('gtk_window_set_titlebar'))); expect(source, contains('gtk_menu_button_set_menu_model')); - expect(source, contains('window#busymark-window decoration')); + expect(source, contains('kLegacyYaruWindowShadowCompatibilityCss')); expect(source, contains('fl_view_set_background_color')); expect(source, contains('"#00000000"')); expect(source, contains('kHeaderBarChannel')); @@ -482,7 +486,7 @@ void main() { expect(css, contains('background-color: alpha(currentColor, 0.07)')); expect(css, contains('background-color: alpha(currentColor, 0.16)')); expect(css, contains('background-color: alpha(currentColor, 0.10)')); - expect(css, contains('popover.background.busymark-header-popover')); + expect(css, isNot(contains('popover.background'))); for (final interactionSelector in [ 'button.', 'modelbutton', @@ -506,30 +510,28 @@ void main() { expect(configuration, contains('backgroundColor: colors.view')); expect(configuration, contains('sidebarBackgroundColor: colors.sidebar')); expect(configuration, contains('foregroundColor: colors.foreground')); - expect(configuration, contains('popoverBackgroundColor: colors.popover')); - expect(configuration, contains('borderColor: colors.subtleBorder')); + expect(configuration, isNot(contains('borderColor'))); + expect(configuration, isNot(contains('popoverBackgroundColor'))); + expect(configuration, isNot(contains('floatingBorderColor'))); expect(native, contains('kDefaultHeaderbarBackground[] = "#272727"')); expect(native, contains('kDefaultSidebarBackground[] = "#393939"')); - expect(native, contains('kDefaultPopoverBackground[] = "#3E3E3E"')); + expect(native, contains('kDefaultSidebarBorder[] = "rgba(16,16,16,0.35)"')); expect( native, contains('fl_lookup_string_arg(args, "sidebarBorderColor")'), ); + expect(native, isNot(contains('"floatingBorderColor"'))); + expect(native, isNot(contains('"popoverBackgroundColor"'))); expect( native, - contains('fl_lookup_string_arg(args, "floatingBorderColor")'), - ); - expect( - native, - contains('fl_lookup_string_arg(args, "popoverBackgroundColor")'), - ); - expect( - native, - contains('css_color_or(self->sidebar_border_color, border)'), + contains( + 'css_color_or(self->sidebar_border_color, kDefaultSidebarBorder)', + ), ); + expect(native, isNot(contains('busymark-header-popover'))); expect( native, - contains('css_color_or(self->floating_border_color, border)'), + contains('gtk_popover_set_position(popover, GTK_POS_BOTTOM)'), ); expect(native, contains('.busymark-sidebar-header {')); expect(native, contains('background-color: %s;')); @@ -729,24 +731,91 @@ void main() { }, ); - test('GTK CSD exclusively owns window shadow and rounded shape', () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(native, contains('window#busymark-window decoration,')); - expect(native, contains('window#busymark-window decoration:backdrop {')); - expect(native, contains('"border-color: %s;"')); - expect(native, contains('gtk_window_set_titlebar(window, titlebar)')); - expect(native, isNot(contains('kHeaderWindowRadius'))); - expect(native, isNot(contains('create_rounded_window_region'))); - expect(native, isNot(contains('gdk_window_shape_combine_region'))); - expect(native, isNot(contains('rounded_window_configure_event_cb'))); - expect(native, isNot(contains('configure_transparent_window_backing'))); - expect(native, isNot(contains('gtk_widget_set_app_paintable'))); - expect(native, isNot(contains('CAIRO_OPERATOR_CLEAR'))); - expect(native, isNot(contains('#include '))); - expect(native, isNot(contains('"box-shadow: 0 2px 10px'))); - expect(native, isNot(contains('"border-radius:'))); - }); + test( + 'Handy owns window shape while Yaru receives a scoped frame adapter', + () { + final native = File('linux/runner/my_application.cc').readAsStringSync(); + final linuxCmake = File('linux/CMakeLists.txt').readAsStringSync(); + final runnerCmake = File( + 'linux/runner/CMakeLists.txt', + ).readAsStringSync(); + final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); + final readme = File('README.md').readAsStringSync(); + final compatibilityCss = RegExp( + r'constexpr char kLegacyYaruWindowShadowCompatibilityCss\[\][\s\S]*?' + r'constexpr char kLtrIsolateStart', + ).firstMatch(native)!.group(0)!; + + expect(native, contains('#include ')); + expect(native, contains('hdy_application_window_new()')); + expect(native, contains('hdy_window_handle_new()')); + expect(native, contains('GtkWidget* titlebar_handle;')); + expect( + native, + contains('gtk_widget_set_sensitive(self->titlebar_handle, !visible)'), + ); + expect(native, contains('uses_legacy_yaru_window_shadow()')); + expect(native, contains('g_strcmp0(normalized_theme, "yaru")')); + expect(native, contains('g_str_has_prefix(normalized_theme, "yaru-")')); + expect(native, contains('strstr(normalized_theme, "highcontrast")')); + expect(native, contains('"notify::gtk-theme-name"')); + expect(native, contains('G_CALLBACK(gtk_theme_name_changed_cb)')); + expect(native, contains('g_signal_connect_object(')); + expect(native, isNot(contains('gtk_window_set_titlebar('))); + expect(native, isNot(contains('gtk_application_window_new('))); + expect( + compatibilityCss, + contains('box-shadow: 0 3px 9px 1px rgba(0,0,0,0.5)'), + ); + expect( + compatibilityCss, + contains( + '0 3px 9px 1px transparent,' + '"\n "0 2px 6px 2px rgba(0,0,0,0.2)', + ), + ); + expect(compatibilityCss, contains(':not(.solid-csd)')); + expect(compatibilityCss, contains(':not(.maximized)')); + expect(compatibilityCss, contains('not(.fullscreen)')); + expect( + RegExp(r'not\(\.maximized\)').allMatches(compatibilityCss), + hasLength(7), + ); + expect( + RegExp(r'not\(\.fullscreen\)').allMatches(compatibilityCss), + hasLength(7), + ); + expect(compatibilityCss, contains('0 0 0 20px transparent')); + expect(compatibilityCss, isNot(contains('0 0 0 1px'))); + expect(compatibilityCss, isNot(contains('border-radius'))); + expect(compatibilityCss, isNot(contains('border-color'))); + expect(native, isNot(contains('"window#busymark-window decoration,"'))); + expect( + linuxCmake, + contains( + 'pkg_check_modules(HANDY REQUIRED IMPORTED_TARGET libhandy-1)', + ), + ); + expect( + runnerCmake, + contains( + r'target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::HANDY)', + ), + ); + expect(snapcraft, contains('- libhandy-1-dev')); + expect(snapcraft, contains('- libhandy-1-0')); + expect(readme, contains('sudo apt-get install libhandy-1-dev')); + expect(native, isNot(contains('kHeaderWindowRadius'))); + expect(native, isNot(contains('create_rounded_window_region'))); + expect(native, isNot(contains('gdk_window_shape_combine_region'))); + expect(native, isNot(contains('rounded_window_configure_event_cb'))); + expect(native, isNot(contains('configure_transparent_window_backing'))); + expect(native, isNot(contains('gtk_widget_set_app_paintable'))); + expect(native, isNot(contains('CAIRO_OPERATOR_CLEAR'))); + expect(native, isNot(contains('#include '))); + expect(native, isNot(contains('"border-radius:'))); + }, + ); test('native header controls preserve GTK geometry with neutral states', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 8521343..09864c4 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -373,10 +373,24 @@ void main() { contains('return _busyMarkSemanticSurfaceColors(theme.brightness)'), ); expect(design, contains('Modern Yaru/libadwaita semantic roles')); - expect(design, contains('window: const Color(0xFFFAFAFA)')); - expect(design, contains('window: const Color(0xFF2C2C2C)')); + expect(design, contains('final window = switch (brightness)')); + expect(design, contains('final floatingSurface = switch (brightness)')); + expect(design, contains('window: window')); expect(design, contains('groupedList: groupedList')); - expect(design, contains('popover: const Color(0xFF3E3E3E)')); + expect(design, contains('dialog: floatingSurface')); + expect(design, contains('popover: floatingSurface')); + expect(theme, contains('ShapeBorder? _withOutlineSide')); + expect( + theme, + isNot(contains('final accentContainer = Color.alphaBlend')), + ); + expect(theme, isNot(contains('selectedTileColor: accentContainer'))); + expect( + theme, + isNot(contains('shape: _withOutlineSide(base.dialogTheme.shape')), + ); + expect(theme, contains('_withOutlineSide(base.popupMenuTheme.shape')); + expect(theme, contains('side: WidgetStatePropertyAll(side)')); expect(theme, contains('surfaceContainerLowest: colors.view')); expect(theme, contains('surfaceContainerLow: colors.window')); expect(theme, contains('surfaceContainer: colors.panel')); @@ -823,10 +837,15 @@ void main() { expect(design, contains('BorderRadius.circular(borderRadius)')); final headerIcon = RegExp( r'class BusyMarkHeaderIconButton[\s\S]*?class ' - r'BusyMarkHeaderPopupMenuButton', + r'BusyMarkCompactIconButton', ).firstMatch(design)!.group(0)!; - expect(headerIcon, contains('return YaruIconButton(')); + expect(headerIcon, contains('final button = IconButton(')); + expect(headerIcon, contains('style: style.merge(yaruDefaults)')); expect(headerIcon, contains('isSelected: selected')); + expect(headerIcon, contains('padding: EdgeInsets.zero')); + expect(headerIcon, contains('YaruFocusBorder.primary(')); + expect(headerIcon, contains('YaruTheme.maybeOf(context)?.focusBorders')); + expect(headerIcon, isNot(contains('return YaruIconButton('))); expect(headerIcon, isNot(contains('DecoratedBox('))); expect(headerIcon, isNot(contains('BoxShadow('))); expect(popupItem, contains('extends PopupMenuItem')); @@ -1025,7 +1044,11 @@ void main() { r'class BusyMarkPopupSelector[\s\S]*?class BusyMarkClamp', ).firstMatch(design)!.group(0)!; expect(selector, contains('YaruPopupMenuButton(')); - expect(selector, contains('Theme.of(context).filledButtonTheme.style')); + expect(selector, contains('Theme.of(context).outlinedButtonTheme.style')); + expect( + selector, + contains('side: const WidgetStatePropertyAll(BorderSide.none)'), + ); expect(selector, contains('BusyMarkPopupMenuItem(')); expect(selector, contains('softWrap: false')); expect(selector, isNot(contains('BusyMarkPushButton.standard('))); @@ -1450,6 +1473,7 @@ void main() { expect(serializer, contains('String _listItem(')); expect(serializer, contains('String _indentBlock(')); expect(toolbar, isNot(contains('transparent: true'))); + expect(RegExp(r'transparent: false').allMatches(toolbar), hasLength(2)); expect(toolbar, contains('BusyMarkHeaderIconButton(')); expect(toolbar, isNot(contains('elevated: true'))); expect(toolbar, isNot(contains('accented: true'))); diff --git a/test/src/surface_palette_render_test.dart b/test/src/surface_palette_render_test.dart new file mode 100644 index 0000000..4908a83 --- /dev/null +++ b/test/src/surface_palette_render_test.dart @@ -0,0 +1,320 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:busymark/src/app/app_theme.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/busymark_dialogs.dart'; +import 'package:busymark/src/app/busymark_glyphs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + for (final baseline in const [ + _SurfaceBaseline( + brightness: Brightness.light, + view: Color(0xFFFFFFFF), + window: Color(0xFFFAFAFA), + dialog: Color(0xFFFAFAFA), + sidebar: Color(0xFFEBEBEB), + card: Color(0xFFFFFFFF), + popover: Color(0xFFFAFAFA), + floatingBorder: Color.fromRGBO(0, 0, 0, 0.14), + ), + _SurfaceBaseline( + brightness: Brightness.dark, + view: Color(0xFF272727), + window: Color(0xFF2C2C2C), + dialog: Color(0xFF3E3E3E), + sidebar: Color(0xFF393939), + card: Color(0xFF3D3D3D), + popover: Color(0xFF3E3E3E), + floatingBorder: Color.fromRGBO(0, 0, 0, 0.14), + ), + ]) { + testWidgets( + 'renders the reviewed ${baseline.brightness.name} surface palette', + (tester) async { + tester.view + ..physicalSize = const Size(800, 600) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final boundaryKey = GlobalKey(); + final viewProbe = GlobalKey(); + final windowProbe = GlobalKey(); + final sidebarProbe = GlobalKey(); + final dialogProbe = GlobalKey(); + final cardProbe = GlobalKey(); + final popupButtonKey = GlobalKey(); + final popoverProbe = GlobalKey(); + final theme = buildBusyMarkTheme( + brightness: baseline.brightness, + accentColor: const Color(0xFFE95464), + ); + + await tester.pumpWidget( + RepaintBoundary( + key: boundaryKey, + child: MaterialApp( + theme: theme, + home: Builder( + builder: (context) { + final colors = BusyMarkSurfaceColors.of(context); + return Scaffold( + backgroundColor: colors.window, + body: Stack( + children: [ + Positioned( + left: 120, + top: 0, + right: 0, + bottom: 80, + child: ColoredBox( + color: colors.view, + child: Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.all(24), + child: SizedBox.square( + key: viewProbe, + dimension: 16, + ), + ), + ), + ), + ), + Positioned( + left: 0, + top: 0, + bottom: 80, + width: 120, + child: BusyMarkSidebarSurface( + child: Center( + child: SizedBox.square( + key: sidebarProbe, + dimension: 16, + ), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 24), + child: SizedBox.square( + key: windowProbe, + dimension: 16, + ), + ), + ), + BusyMarkModalEditorSurface( + maxWidth: 320, + maxHeight: 260, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.square( + key: dialogProbe, + dimension: 16, + ), + const SizedBox(height: 24), + BusyMarkGroupedSurface( + child: SizedBox( + key: cardProbe, + width: 180, + height: 72, + ), + ), + ], + ), + ), + ), + Positioned( + right: 24, + bottom: 24, + child: PopupMenuButton( + key: popupButtonKey, + tooltip: 'Open palette probe', + itemBuilder: (context) => [ + PopupMenuItem( + enabled: false, + value: 1, + child: SizedBox( + key: popoverProbe, + width: 96, + height: 24, + ), + ), + ], + child: const SizedBox.square( + dimension: 32, + child: Icon(BusyMarkGlyphs.menuVertical), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + expect(find.byKey(popoverProbe), findsOneWidget); + final popupMaterial = find.ancestor( + of: find.byKey(popoverProbe), + matching: find.byWidgetPredicate( + (widget) => + widget is Material && + widget.shape == theme.popupMenuTheme.shape, + ), + ); + expect(popupMaterial, findsOneWidget); + + final pixels = await _capturePixels(tester, boundaryKey); + expect(_pixelAtProbe(tester, pixels, viewProbe), baseline.view); + expect(_pixelAtProbe(tester, pixels, windowProbe), baseline.window); + expect(_pixelAtProbe(tester, pixels, sidebarProbe), baseline.sidebar); + expect(_pixelAtProbe(tester, pixels, dialogProbe), baseline.dialog); + expect(_pixelAtProbe(tester, pixels, cardProbe), baseline.card); + expect(_pixelAtProbe(tester, pixels, popoverProbe), baseline.popover); + final popupSize = tester.getSize(popupMaterial); + final popupEdge = _pixelAtLocal( + tester, + pixels, + popupMaterial, + Offset(0.5, popupSize.height / 2), + ); + final expectedEdge = Color.alphaBlend( + baseline.floatingBorder, + baseline.popover, + ); + _expectColorNear(popupEdge, expectedEdge, tolerance: 3); + expect( + popupEdge.computeLuminance(), + lessThan(baseline.popover.computeLuminance()), + ); + }, + ); + } +} + +class _SurfaceBaseline { + const _SurfaceBaseline({ + required this.brightness, + required this.view, + required this.window, + required this.dialog, + required this.sidebar, + required this.card, + required this.popover, + required this.floatingBorder, + }); + + final Brightness brightness; + final Color view; + final Color window; + final Color dialog; + final Color sidebar; + final Color card; + final Color popover; + final Color floatingBorder; +} + +Future<_CapturedPixels> _capturePixels( + WidgetTester tester, + GlobalKey boundaryKey, +) async { + final boundary = + boundaryKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = (await tester.binding.runAsync( + () => boundary.toImage(pixelRatio: 1), + ))!; + try { + final data = (await tester.binding.runAsync( + () => image.toByteData(format: ui.ImageByteFormat.rawStraightRgba), + ))!; + return _CapturedPixels( + bytes: data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + width: image.width, + boundary: boundary, + ); + } finally { + image.dispose(); + } +} + +Color _pixelAtProbe( + WidgetTester tester, + _CapturedPixels pixels, + GlobalKey probeKey, +) { + final globalCenter = tester.getCenter(find.byKey(probeKey)); + final localCenter = pixels.boundary.globalToLocal(globalCenter); + final x = localCenter.dx.round(); + final y = localCenter.dy.round(); + final offset = (y * pixels.width + x) * 4; + return Color.fromARGB( + pixels.bytes[offset + 3], + pixels.bytes[offset], + pixels.bytes[offset + 1], + pixels.bytes[offset + 2], + ); +} + +Color _pixelAtLocal( + WidgetTester tester, + _CapturedPixels pixels, + Finder finder, + Offset localOffset, +) { + final box = tester.renderObject(finder); + final globalPoint = box.localToGlobal(localOffset); + final point = pixels.boundary.globalToLocal(globalPoint); + final x = point.dx.floor(); + final y = point.dy.floor(); + final offset = (y * pixels.width + x) * 4; + return Color.fromARGB( + pixels.bytes[offset + 3], + pixels.bytes[offset], + pixels.bytes[offset + 1], + pixels.bytes[offset + 2], + ); +} + +void _expectColorNear(Color actual, Color expected, {required int tolerance}) { + expect( + (actual.r * 255 - expected.r * 255).abs(), + lessThanOrEqualTo(tolerance), + ); + expect( + (actual.g * 255 - expected.g * 255).abs(), + lessThanOrEqualTo(tolerance), + ); + expect( + (actual.b * 255 - expected.b * 255).abs(), + lessThanOrEqualTo(tolerance), + ); + expect( + (actual.a * 255 - expected.a * 255).abs(), + lessThanOrEqualTo(tolerance), + ); +} + +class _CapturedPixels { + const _CapturedPixels({ + required this.bytes, + required this.width, + required this.boundary, + }); + + final Uint8List bytes; + final int width; + final RenderRepaintBoundary boundary; +} diff --git a/test/src/system_accent_test.dart b/test/src/system_accent_test.dart index 83b10e9..88d6f01 100644 --- a/test/src/system_accent_test.dart +++ b/test/src/system_accent_test.dart @@ -52,40 +52,39 @@ void main() { expect(colorFromUbuntuAccentNameValue(const DBusString('unknown')), isNull); }); - test('falls back to the Ubuntu setting when the RGB read fails', () async { - var readGnome = false; + test('falls back to RGB when the Ubuntu accent is unavailable', () async { + var readFreedesktop = false; + const exactRgb = Color(0xFF336699); final color = await readPreferredLinuxAccentColor( - readFreedesktop: () => Future.error( - StateError('freedesktop accent key is unavailable'), - ), - readGnome: () async { - readGnome = true; - return YaruVariant.orange.color; + readGnome: () => + Future.error(StateError('Ubuntu accent key is unavailable')), + readFreedesktop: () async { + readFreedesktop = true; + return exactRgb; }, ); - expect(color, YaruVariant.orange.color); - expect(readGnome, isTrue); + expect(color, exactRgb); + expect(readFreedesktop, isTrue); }); - test('does not read the named fallback after an exact RGB result', () async { - var readGnome = false; - const exactRgb = Color(0xFF336699); + test('Yaru accent wins over a different generic portal RGB', () async { + var readFreedesktop = false; final color = await readPreferredLinuxAccentColor( - readFreedesktop: () async => exactRgb, - readGnome: () async { - readGnome = true; - return YaruVariant.blue.color; + readGnome: () async => YaruVariant.magenta.color, + readFreedesktop: () async { + readFreedesktop = true; + return const Color(0xFFD56199); }, ); - expect(color, exactRgb); - expect(readGnome, isFalse); + expect(color, const Color(0xFFB34CB3)); + expect(readFreedesktop, isFalse); }); - test('an exact RGB signal remains authoritative over named signals', () { + test('a Yaru accent signal remains authoritative over generic RGB', () { final resolver = LinuxAccentChangeResolver(); final exactRgb = DBusStruct([ const DBusDouble(0.2), @@ -93,6 +92,10 @@ void main() { const DBusDouble(0.6), ]); + expect( + resolver.resolve('org.freedesktop.appearance', exactRgb), + const Color(0xFF336699), + ); expect( resolver.resolve( 'org.gnome.desktop.interface', @@ -100,13 +103,6 @@ void main() { ), YaruVariant.orange.color, ); - expect( - resolver.resolve('org.freedesktop.appearance', exactRgb), - const Color(0xFF336699), - ); - expect( - resolver.resolve('org.gnome.desktop.interface', const DBusString('blue')), - isNull, - ); + expect(resolver.resolve('org.freedesktop.appearance', exactRgb), isNull); }); } From 77d121147c8ea4ad8dbcc777abecd1eae0cf0355 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 16:37:29 -0700 Subject: [PATCH 04/29] Add titlebar overlay and modal scrim. Add contained control background resolution for semantic host surfaces in BusyMark --- lib/src/app/busymark_design.dart | 17 ++++ lib/src/editor/wysiwyg/wysiwyg_toolbar.dart | 12 +++ linux/runner/my_application.cc | 103 +++++++------------- test/src/busymark_design_test.dart | 56 ++++++++--- test/src/busymark_document_test.dart | 14 ++- test/src/native_headerbar_audit_test.dart | 33 ++++--- test/src/source_audit_test.dart | 2 + 7 files changed, 135 insertions(+), 102 deletions(-) diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index 0c8804a..3c72e73 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -810,6 +810,23 @@ WidgetStateProperty busyMarkHeaderButtonBackground( WidgetStatePropertyAll(BusyMarkSurfaceColors.of(context).control); } +/// Resolves a contained control state against its semantic host surface. +/// +/// Yaru control fills are translucent state layers, which is appropriate when +/// a parent control surface owns the background. Free-floating controls, such +/// as the editing toolbar over a document, have no such parent and must resolve +/// that layer once so document content cannot show through the button. +WidgetStateProperty busyMarkContainedControlBackground( + BuildContext context, { + required Color surface, +}) { + final background = busyMarkHeaderButtonBackground(context); + return WidgetStateProperty.resolveWith((states) { + final stateColor = background.resolve(states); + return stateColor == null ? null : Color.alphaBlend(stateColor, surface); + }); +} + WidgetStateProperty busyMarkTransparentHeaderButtonBackground( BuildContext _, ) { diff --git a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart index 764a7bf..26a931d 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart @@ -243,11 +243,17 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { } Widget _blockStyleMenu(BuildContext context) { + final colors = BusyMarkSurfaceColors.of(context); return BusyMarkHeaderPopupMenuButton( tooltip: context.l10n.textStyle, icon: BusyMarkGlyphs.font, shortcut: BusyMarkEditorShortcutLabels.textStyle, transparent: false, + foregroundColor: colors.foreground, + backgroundColor: busyMarkContainedControlBackground( + context, + surface: colors.view, + ), itemBuilder: (context) => [ BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.paragraph, @@ -296,12 +302,18 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { required VoidCallback onPressed, String? shortcut, }) { + final colors = BusyMarkSurfaceColors.of(context); return BusyMarkHeaderIconButton( tooltip: tooltip, icon: icon, onPressed: onPressed, shortcut: shortcut, transparent: false, + foregroundColor: colors.foreground, + backgroundColor: busyMarkContainedControlBackground( + context, + surface: colors.view, + ), ); } } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index a704fb6..3c0a43d 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -56,6 +56,8 @@ struct _MyApplication { GtkWindow* main_window; GtkWidget* flutter_view; GtkWidget* titlebar_handle; + GtkWidget* titlebar_overlay; + GtkWidget* modal_scrim; GtkWidget* titlebar_box; GtkHeaderBar* header_bar; GtkWidget* sidebar_header_box; @@ -418,45 +420,6 @@ static void replace_css_color_field(gchar** target, const gchar* value) { *target = is_css_color_token(value) ? g_strdup(value) : nullptr; } -static GdkRGBA composite_rgba(const GdkRGBA& foreground, - const GdkRGBA& background) { - const gdouble inverse_foreground_alpha = 1.0 - foreground.alpha; - const gdouble alpha = - foreground.alpha + background.alpha * inverse_foreground_alpha; - if (alpha <= 0) { - return GdkRGBA{0, 0, 0, 0}; - } - return GdkRGBA{ - (foreground.red * foreground.alpha + - background.red * background.alpha * inverse_foreground_alpha) / - alpha, - (foreground.green * foreground.alpha + - background.green * background.alpha * inverse_foreground_alpha) / - alpha, - (foreground.blue * foreground.alpha + - background.blue * background.alpha * inverse_foreground_alpha) / - alpha, - alpha, - }; -} - -static gchar* modal_sidebar_border_css_color(const gchar* border_color, - const gchar* sidebar_color, - const gchar* barrier_color) { - GdkRGBA border; - GdkRGBA sidebar; - GdkRGBA barrier; - if (!gdk_rgba_parse(&border, border_color) || - !gdk_rgba_parse(&sidebar, sidebar_color) || - !gdk_rgba_parse(&barrier, barrier_color)) { - return g_strdup(border_color); - } - - const GdkRGBA visible_border = composite_rgba(border, sidebar); - const GdkRGBA dimmed_border = composite_rgba(barrier, visible_border); - return gdk_rgba_to_string(&dimmed_border); -} - static void set_widget_visible(GtkWidget* widget, gboolean visible) { if (widget != nullptr && GTK_IS_WIDGET(widget)) { gtk_widget_set_no_show_all(widget, !visible); @@ -583,8 +546,6 @@ static void refresh_header_bar_css(MyApplication* self) { css_color_or(self->sidebar_border_color, kDefaultSidebarBorder); const gchar* modal = css_color_or(self->modal_barrier_color, "rgba(0,0,0,0.32)"); - g_autofree gchar* modal_sidebar_border = - modal_sidebar_border_css_color(sidebar_border, sidebar_background, modal); const gchar* window_shadow_css = uses_legacy_yaru_window_shadow() ? kLegacyYaruWindowShadowCompatibilityCss : ""; @@ -674,26 +635,13 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: alpha(currentColor, 0.19);" "background-image: none;" "}" - ".busymark-titlebar.busymark-modal-barrier," - ".busymark-titlebar.busymark-modal-barrier " - "headerbar.busymark-headerbar," - ".busymark-titlebar.busymark-modal-barrier " - "headerbar.busymark-headerbar:backdrop," - ".busymark-titlebar.busymark-modal-barrier .busymark-sidebar-header {" - "background-image: linear-gradient(%s, %s);" - "}" - ".busymark-titlebar.busymark-modal-barrier " - ".busymark-sidebar-header:dir(ltr) {" - "border-right-color: %s;" - "}" - ".busymark-titlebar.busymark-modal-barrier " - ".busymark-sidebar-header:dir(rtl) {" - "border-left-color: %s;" + ".busymark-modal-scrim {" + "background-color: %s;" + "background-image: none;" "}", background, window_shadow_css, background, foreground, background, foreground, sidebar_background, foreground, sidebar_border, - sidebar_border, modal, modal, modal_sidebar_border, - modal_sidebar_border); + sidebar_border, modal); g_autoptr(GError) error = nullptr; GtkCssProvider* provider = gtk_css_provider_new(); @@ -1271,14 +1219,7 @@ static void set_localized_labels(MyApplication* self, FlValue* args) { static void set_modal_barrier_visible(MyApplication* self, gboolean visible) { self->modal_barrier_visible = visible; - if (self->titlebar_box != nullptr && GTK_IS_WIDGET(self->titlebar_box)) { - GtkStyleContext* context = gtk_widget_get_style_context(self->titlebar_box); - if (visible) { - gtk_style_context_add_class(context, "busymark-modal-barrier"); - } else { - gtk_style_context_remove_class(context, "busymark-modal-barrier"); - } - } + set_widget_visible(self->modal_scrim, visible); if (self->titlebar_handle != nullptr && GTK_IS_WIDGET(self->titlebar_handle)) { // The Handy handle owns native drag, double-click, and window-menu input. @@ -1616,6 +1557,32 @@ static GtkWidget* create_busymark_titlebar(MyApplication* self) { return self->titlebar_box; } +static GtkWidget* create_busymark_titlebar_overlay(MyApplication* self) { + self->titlebar_overlay = gtk_overlay_new(); + gtk_widget_set_halign(self->titlebar_overlay, GTK_ALIGN_FILL); + gtk_widget_set_valign(self->titlebar_overlay, GTK_ALIGN_FILL); + gtk_widget_set_hexpand(self->titlebar_overlay, TRUE); + gtk_container_add(GTK_CONTAINER(self->titlebar_overlay), + create_busymark_titlebar(self)); + + // A real overlay is the GTK equivalent of Flutter's modal barrier. Painting + // only the header backgrounds leaves descendant icons and button surfaces + // above the scrim. + self->modal_scrim = gtk_event_box_new(); + gtk_widget_set_halign(self->modal_scrim, GTK_ALIGN_FILL); + gtk_widget_set_valign(self->modal_scrim, GTK_ALIGN_FILL); + gtk_widget_set_hexpand(self->modal_scrim, TRUE); + gtk_widget_set_vexpand(self->modal_scrim, TRUE); + gtk_style_context_add_class(gtk_widget_get_style_context(self->modal_scrim), + "busymark-modal-scrim"); + set_widget_visible(self->modal_scrim, FALSE); + gtk_overlay_add_overlay(GTK_OVERLAY(self->titlebar_overlay), + self->modal_scrim); + gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(self->titlebar_overlay), + self->modal_scrim, FALSE); + return self->titlebar_overlay; +} + static void header_bar_method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { @@ -1737,7 +1704,7 @@ static void my_application_activate(GApplication* application) { self->titlebar_handle = hdy_window_handle_new(); gtk_widget_set_hexpand(self->titlebar_handle, TRUE); gtk_container_add(GTK_CONTAINER(self->titlebar_handle), - create_busymark_titlebar(self)); + create_busymark_titlebar_overlay(self)); gtk_widget_show_all(self->titlebar_handle); gtk_window_set_default_size(window, 1280, 720); @@ -1853,6 +1820,8 @@ static void my_application_init(MyApplication* self) { self->flutter_view = nullptr; self->titlebar_handle = nullptr; self->titlebar_box = nullptr; + self->titlebar_overlay = nullptr; + self->modal_scrim = nullptr; self->header_bar = nullptr; self->sidebar_header_box = nullptr; self->sidebar_search_button = nullptr; diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index 1863425..358d903 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -838,19 +838,22 @@ void main() { home: RepaintBoundary( key: boundaryKey, child: Scaffold( - body: BusyMarkWysiwygToolbar( - onBlockCommand: (_) {}, - onInlineCommand: (_) {}, - onLinkCommand: () {}, - onImageCommand: () {}, - onInlineImageCommand: () {}, - onTableCommand: () {}, - onHtmlCommand: () {}, - onIndentCommand: () {}, - onOutdentCommand: () {}, - onToggleTaskCommand: () {}, - onHardBreakCommand: () {}, - onCodeLanguageCommand: () {}, + body: ColoredBox( + color: colors.view, + child: BusyMarkWysiwygToolbar( + onBlockCommand: (_) {}, + onInlineCommand: (_) {}, + onLinkCommand: () {}, + onImageCommand: () {}, + onInlineImageCommand: () {}, + onTableCommand: () {}, + onHtmlCommand: () {}, + onIndentCommand: () {}, + onOutdentCommand: () {}, + onToggleTaskCommand: () {}, + onHardBreakCommand: () {}, + onCodeLanguageCommand: () {}, + ), ), ), ), @@ -868,6 +871,13 @@ void main() { ), ); expect(popup.transparent, isFalse); + final expectedRest = Color.alphaBlend(colors.control, colors.view); + final expectedDisabled = Color.alphaBlend( + colors.disabledControl, + colors.view, + ); + expect(popup.foregroundColor, colors.foreground); + expect(popup.backgroundColor?.resolve({}), expectedRest); final actions = tester.widgetList( find.descendant( @@ -877,13 +887,30 @@ void main() { ); expect(actions, isNotEmpty); expect(actions.every((button) => !button.transparent), isTrue); + expect( + actions.every( + (button) => button.foregroundColor == colors.foreground, + ), + isTrue, + ); + expect( + actions.every( + (button) => button.backgroundColor?.resolve({}) == expectedRest, + ), + isTrue, + ); final renderedButtons = tester.widgetList( find.descendant(of: toolbar, matching: find.byType(IconButton)), ); expect(renderedButtons, isNotEmpty); for (final button in renderedButtons) { - expect(button.style?.backgroundColor?.resolve({}), colors.control); + expect(button.style?.backgroundColor?.resolve({}), expectedRest); + expect(button.style?.foregroundColor?.resolve({}), colors.foreground); + expect( + button.style?.backgroundColor?.resolve({WidgetState.disabled}), + expectedDisabled, + ); } final actionButton = find.ancestor( @@ -896,7 +923,6 @@ void main() { final probe = Offset(5, buttonSize.height / 2); final restPixels = await _capturePixels(tester, boundaryKey); final rest = _pixelAtLocal(tester, restPixels, actionButton, probe); - final expectedRest = Color.alphaBlend(colors.control, colors.window); _expectColorNear(rest, expectedRest); final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index dc5fe06..bc5e244 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -32,7 +32,6 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:markdown/markdown.dart' as md; -import 'package:yaru/yaru.dart'; void main() { const parser = MarkdownParser(); @@ -2408,10 +2407,15 @@ void main() {} ); await tester.pump(); - YaruIconButton editingToggle(String tooltip) { - return tester.widget( - find.byWidgetPredicate( - (widget) => widget is YaruIconButton && widget.tooltip == tooltip, + IconButton editingToggle(String tooltip) { + return tester.widget( + find.descendant( + of: find.byWidgetPredicate( + (widget) => + widget is BusyMarkHeaderIconButton && + widget.tooltip == tooltip, + ), + matching: find.byType(IconButton), ), ); } diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 1bb93c1..4a3f016 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -19,6 +19,12 @@ void main() { expect(source, contains('"#00000000"')); expect(source, contains('kHeaderBarChannel')); expect(source, contains('setModalBarrierVisible')); + expect(source, contains('gtk_overlay_new()')); + expect(source, contains('gtk_overlay_add_overlay')); + expect(source, contains('gtk_event_box_new()')); + expect(source, contains('"busymark-modal-scrim"')); + expect(source, contains('set_widget_visible(self->modal_scrim, visible)')); + expect(source, contains('set_widget_visible(self->modal_scrim, FALSE)')); expect(source, contains('setSidebarWidth')); expect(source, contains('setSidebarToggleVisible')); expect(source, contains('setTextDirection')); @@ -481,7 +487,7 @@ void main() { } } expect(braceDepth, 0, reason: 'unbalanced structural CSS blocks'); - expect(css, contains('.busymark-titlebar.busymark-modal-barrier')); + expect(css, contains('.busymark-modal-scrim')); expect(css, contains('.busymark-header-control')); expect(css, contains('background-color: alpha(currentColor, 0.07)')); expect(css, contains('background-color: alpha(currentColor, 0.16)')); @@ -546,27 +552,22 @@ void main() { expect(headerbarBlock, isNot(contains('border-radius'))); expect(headerbarBlock, isNot(contains('"padding-left: 0;"'))); expect(headerbarBlock, isNot(contains('"padding-right: 0;"'))); - expect( - native, - contains( - '".busymark-titlebar.busymark-modal-barrier "' - '\n "headerbar.busymark-headerbar,"', - ), - ); expect(native, contains('".busymark-sidebar-header:dir(ltr) {"')); expect(native, contains('"border-right: 1px solid %s;"')); expect(native, contains('".busymark-sidebar-header:dir(rtl) {"')); expect(native, contains('"border-left: 1px solid %s;"')); - expect(native, contains('modal_sidebar_border_css_color')); + expect(native, contains('".busymark-modal-scrim {"')); + expect(native, contains('create_busymark_titlebar_overlay')); + expect(native, contains('gtk_overlay_set_overlay_pass_through')); + expect(native, isNot(contains('busymark-titlebar.busymark-modal-barrier'))); + expect(native, isNot(contains('modal_sidebar_border_css_color'))); + expect(native, isNot(contains('composite_rgba'))); + expect(native, isNot(contains('"border-right-color: %s;"'))); + expect(native, isNot(contains('"border-left-color: %s;"'))); expect( native, - contains( - '".busymark-titlebar.busymark-modal-barrier "' - '\n ".busymark-sidebar-header:dir(ltr) {"', - ), + contains('gtk_widget_set_sensitive(self->titlebar_handle, !visible)'), ); - expect(native, contains('"border-right-color: %s;"')); - expect(native, contains('"border-left-color: %s;"')); expect(workspace, isNot(contains('Border(right:'))); }); @@ -750,6 +751,8 @@ void main() { expect(native, contains('hdy_application_window_new()')); expect(native, contains('hdy_window_handle_new()')); expect(native, contains('GtkWidget* titlebar_handle;')); + expect(native, contains('GtkWidget* titlebar_overlay;')); + expect(native, contains('GtkWidget* modal_scrim;')); expect( native, contains('gtk_widget_set_sensitive(self->titlebar_handle, !visible)'), diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 09864c4..01ebf4c 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -1475,6 +1475,8 @@ void main() { expect(toolbar, isNot(contains('transparent: true'))); expect(RegExp(r'transparent: false').allMatches(toolbar), hasLength(2)); expect(toolbar, contains('BusyMarkHeaderIconButton(')); + expect(toolbar, contains('busyMarkContainedControlBackground(')); + expect(toolbar, contains('foregroundColor: colors.foreground')); expect(toolbar, isNot(contains('elevated: true'))); expect(toolbar, isNot(contains('accented: true'))); expect(toolbar, contains('clipBehavior: Clip.none')); From 56683bfd5ef0b388cc6b8145ff0d50e0a3afc566 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 17:28:28 -0700 Subject: [PATCH 05/29] Add GitHub Actions workflow for Flutter Linux build and verification --- .github/workflows/flutter-linux.yml | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/flutter-linux.yml diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml new file mode 100644 index 0000000..1a1d2f5 --- /dev/null +++ b/.github/workflows/flutter-linux.yml @@ -0,0 +1,56 @@ +name: Flutter Linux + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + clang \ + cmake \ + ninja-build \ + pkg-config \ + libgtk-3-dev \ + libhandy-1-dev + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Enable Linux desktop + run: flutter config --enable-linux-desktop + + - name: Resolve dependencies + run: flutter pub get + + - name: Generate localizations + run: flutter gen-l10n + + - name: Verify generated files are committed + run: git diff --exit-code + + - name: Check formatting + run: dart format --set-exit-if-changed . + + - name: Analyze + run: flutter analyze + + - name: Test + run: flutter test + + - name: Build Linux release + run: flutter build linux --release From 282dfe9bdc9ecf92f0ff874008d8c9502c1fcedf Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 17:47:16 -0700 Subject: [PATCH 06/29] Add locale management for BusyMark with support for multiple languages --- lib/src/app/app_locale.dart | 118 +++++++++ lib/src/app/app_settings.dart | 59 +---- lib/src/app/busymark_app.dart | 4 +- lib/src/app/busymark_design.dart | 3 + lib/src/app/busymark_dialogs.dart | 80 +++--- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 1 + lib/src/editor/wysiwyg/wysiwyg_toolbar.dart | 31 ++- .../platform/header_bar_configuration.dart | 33 ++- .../platform/linux_header_bar_service.dart | 11 +- .../presentation/settings_screen.dart | 45 +--- test/src/app_settings_test.dart | 23 +- test/src/busymark_design_test.dart | 249 ++++++++++-------- test/src/busymark_dialogs_test.dart | 28 +- test/src/busymark_document_test.dart | 12 +- test/src/header_bar_configuration_test.dart | 32 ++- test/src/localization_audit_test.dart | 28 ++ test/src/native_headerbar_audit_test.dart | 51 +++- test/src/source_audit_test.dart | 18 +- 18 files changed, 523 insertions(+), 303 deletions(-) create mode 100644 lib/src/app/app_locale.dart diff --git a/lib/src/app/app_locale.dart b/lib/src/app/app_locale.dart new file mode 100644 index 0000000..fc076c1 --- /dev/null +++ b/lib/src/app/app_locale.dart @@ -0,0 +1,118 @@ +import 'package:flutter/widgets.dart'; + +/// A locale that can be selected explicitly in BusyMark. +/// +/// Labels are endonyms so the language selector remains usable even when the +/// current application language is unfamiliar to the user. +class BusyMarkLocaleOption { + const BusyMarkLocaleOption({required this.locale, required this.endonym}); + + final Locale locale; + final String endonym; + + String get tag => locale.toLanguageTag(); +} + +const busyMarkLocaleOptions = [ + BusyMarkLocaleOption(locale: Locale('ar'), endonym: 'العربية'), + BusyMarkLocaleOption(locale: Locale('de'), endonym: 'Deutsch'), + BusyMarkLocaleOption(locale: Locale('en'), endonym: 'English'), + BusyMarkLocaleOption(locale: Locale('es'), endonym: 'Español'), + BusyMarkLocaleOption(locale: Locale('et'), endonym: 'Eesti'), + BusyMarkLocaleOption(locale: Locale('fa'), endonym: 'فارسی'), + BusyMarkLocaleOption(locale: Locale('fr'), endonym: 'Français'), + BusyMarkLocaleOption(locale: Locale('hi'), endonym: 'हिन्दी'), + BusyMarkLocaleOption(locale: Locale('it'), endonym: 'Italiano'), + BusyMarkLocaleOption(locale: Locale('nb'), endonym: 'Norsk'), + BusyMarkLocaleOption(locale: Locale('pl'), endonym: 'Polski'), + BusyMarkLocaleOption(locale: Locale('pt'), endonym: 'Português'), + BusyMarkLocaleOption(locale: Locale('ru'), endonym: 'Русский'), + BusyMarkLocaleOption(locale: Locale('uk'), endonym: 'Українська'), +]; + +Locale? busyMarkLocaleFromTag(String? tag) { + final normalized = normalizeBusyMarkLocaleTag(tag); + if (normalized == null) { + return null; + } + return busyMarkLocaleOptions + .firstWhere((option) => option.tag == normalized) + .locale; +} + +String? normalizeBusyMarkLocaleTag(String? tag) { + final trimmed = tag?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + final migrated = trimmed.toLowerCase() == 'no' + ? 'nb' + : trimmed.toLowerCase().startsWith('no-') || + trimmed.toLowerCase().startsWith('no_') + ? 'nb${trimmed.substring(2)}' + : trimmed; + final parsed = _parseLocaleTag(migrated); + if (parsed == null) { + return null; + } + + for (final option in busyMarkLocaleOptions) { + if (option.locale == parsed || + option.locale.languageCode == parsed.languageCode) { + return option.tag; + } + } + return null; +} + +/// Resolves all platform language preferences with English as the deliberate +/// fallback. Generated locale lists are alphabetical, so relying on their +/// first item would otherwise make Arabic the fallback for an unknown locale. +Locale resolveBusyMarkLocales( + List? requestedLocales, + Iterable supportedLocales, +) { + final supported = supportedLocales.toList(growable: false); + if (supported.isEmpty) { + return const Locale('en'); + } + final english = supported.cast().firstWhere( + (locale) => locale?.languageCode == 'en', + orElse: () => null, + ); + final orderedSupported = [ + if (english != null) english, + for (final locale in supported) + if (locale != english) locale, + ]; + return basicLocaleListResolution(requestedLocales, orderedSupported); +} + +Locale? _parseLocaleTag(String tag) { + final parts = tag.replaceAll('_', '-').split('-'); + if (parts.isEmpty || !RegExp(r'^[A-Za-z]{2,3}$').hasMatch(parts.first)) { + return null; + } + final languageCode = parts.first.toLowerCase(); + String? scriptCode; + String? countryCode; + for (final part in parts.skip(1)) { + if (scriptCode == null && RegExp(r'^[A-Za-z]{4}$').hasMatch(part)) { + scriptCode = + '${part.substring(0, 1).toUpperCase()}' + '${part.substring(1).toLowerCase()}'; + continue; + } + if (countryCode == null && + RegExp(r'^(?:[A-Za-z]{2}|[0-9]{3})$').hasMatch(part)) { + countryCode = part.toUpperCase(); + continue; + } + return null; + } + return Locale.fromSubtags( + languageCode: languageCode, + scriptCode: scriptCode, + countryCode: countryCode, + ); +} diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index c15dc21..c9f687b 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -7,6 +7,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import 'app_locale.dart'; + enum BusyMarkThemeModePreference { system, light, dark } enum DocumentViewModePreference { editor, source, preview, split } @@ -114,7 +116,7 @@ class AppSettings { json['themeModePreference'], defaults.themeModePreference, ), - localeTag: _localeTagFromJson(json['localeTag']), + localeTag: normalizeBusyMarkLocaleTag(json['localeTag']?.toString()), sidebarVisible: json['sidebarVisible'] as bool? ?? defaults.sidebarVisible, previewVisible: documentViewMode != DocumentViewModePreference.source, @@ -179,7 +181,7 @@ class AppSettings { ThemeMode get themeMode => themeModePreference.themeMode; - Locale? get locale => _localeFromTag(_normalizeLocaleTag(localeTag)); + Locale? get locale => busyMarkLocaleFromTag(localeTag); Map toJson() => { 'themeModePreference': themeModePreference.name, @@ -245,7 +247,7 @@ class AppSettings { themeModePreference: themeModePreference ?? this.themeModePreference, localeTag: identical(localeTag, _unset) ? this.localeTag - : localeTag as String?, + : normalizeBusyMarkLocaleTag(localeTag as String?), sidebarVisible: sidebarVisible ?? this.sidebarVisible, previewVisible: previewVisible ?? this.previewVisible, documentViewMode: documentViewMode ?? this.documentViewMode, @@ -327,7 +329,7 @@ class AppSettingsController extends Notifier { Future setLocaleTag(String? localeTag) { return _mutate( (settings) => - settings.copyWith(localeTag: _normalizeLocaleTag(localeTag)), + settings.copyWith(localeTag: normalizeBusyMarkLocaleTag(localeTag)), ); } @@ -572,52 +574,3 @@ String? _normalizedStoredGitWorkspacePath(String? value) { } return p.normalize(p.absolute(value)); } - -String? _localeTagFromJson(Object? value) { - if (value == null) { - return null; - } - final tag = value.toString().trim(); - return _normalizeLocaleTag(tag); -} - -String? _normalizeLocaleTag(String? tag) { - if (tag == null) { - return null; - } - final trimmed = tag.trim(); - if (trimmed.isEmpty) { - return null; - } - if (trimmed == 'no') { - return 'nb'; - } - if (trimmed.startsWith('no_') || trimmed.startsWith('no-')) { - return 'nb${trimmed.substring(2)}'; - } - return trimmed; -} - -Locale? _localeFromTag(String? tag) { - if (tag == null || tag.isEmpty) { - return null; - } - final parts = tag.split(RegExp('[-_]')); - if (parts.length == 1) { - return Locale(parts.first); - } - if (parts.length == 2) { - if (parts.last.length == 4) { - return Locale.fromSubtags( - languageCode: parts.first, - scriptCode: parts.last, - ); - } - return Locale(parts.first, parts.last); - } - return Locale.fromSubtags( - languageCode: parts[0], - scriptCode: parts[1].isEmpty ? null : parts[1], - countryCode: parts[2].isEmpty ? null : parts[2], - ); -} diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart index e17d05f..717808b 100644 --- a/lib/src/app/busymark_app.dart +++ b/lib/src/app/busymark_app.dart @@ -17,6 +17,7 @@ import '../workspace/workspace_model.dart'; import '../workspace/workspace_safety.dart'; import '../workspace/workspace_tabs.dart'; import 'app_router.dart'; +import 'app_locale.dart'; import 'app_settings.dart'; import 'busymark_shortcuts.dart'; import 'app_theme.dart'; @@ -61,6 +62,7 @@ class BusyMarkApp extends ConsumerWidget { ), themeMode: settings.themeMode, locale: settings.locale, + localeListResolutionCallback: resolveBusyMarkLocales, localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, @@ -620,7 +622,7 @@ class BusyMarkApp extends ConsumerWidget { sidebarVisible: false, sidebarToggleVisible: false, backVisible: false, - modalBarrierVisible: false, + modalBarrierDepth: 0, sidebarWidth: BusyMarkSizes.sidebarWidth, labels: labels, theme: theme, diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index 3c72e73..f433123 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -908,6 +908,9 @@ class BusyMarkHeaderIconButton extends StatelessWidget { theme.cardTheme.elevation ?? BusyMarkElevation.surface, ), shadowColor: WidgetStatePropertyAll(colorScheme.shadow), + surfaceTintColor: const WidgetStatePropertyAll( + BusyMarkLinuxPalette.transparent, + ), ) : semanticStyle; // YaruIconButton merges its defaults as the receiver, so non-null default diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index 8d762d6..440ecc2 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -49,11 +49,12 @@ Future showBusyMarkModalDialog( bool barrierDismissible = true, }) async { final barrierColor = busyMarkModalBarrierColor(context); - final barrierLease = _BusyMarkModalBarrierCoordinator.acquire( - headerBarService ?? LinuxHeaderBarService.instance, - ); + final effectiveHeaderBarService = + headerBarService ?? LinuxHeaderBarService.instance; + final previousFocus = FocusManager.instance.primaryFocus; + await _BusyMarkModalBarrierCoordinator.acquire(effectiveHeaderBarService); if (!context.mounted) { - barrierLease.release(); + await _BusyMarkModalBarrierCoordinator.release(effectiveHeaderBarService); return null; } try { @@ -61,6 +62,7 @@ Future showBusyMarkModalDialog( context: context, barrierColor: barrierColor, barrierDismissible: barrierDismissible, + traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, builder: (dialogContext) { final viewInsets = MediaQuery.viewInsetsOf(dialogContext); final padding = EdgeInsets.fromLTRB( @@ -81,46 +83,63 @@ Future showBusyMarkModalDialog( }, ); } finally { - barrierLease.release(); + await _BusyMarkModalBarrierCoordinator.release(effectiveHeaderBarService); + if (previousFocus?.context != null && previousFocus!.canRequestFocus) { + previousFocus.requestFocus(); + } } } class _BusyMarkModalBarrierCoordinator { const _BusyMarkModalBarrierCoordinator._(); - static final Map _activeDialogs = {}; - static final Map> _pendingUpdates = {}; + static final _activeDialogs = Map.identity(); + static final _pendingUpdates = + Map>.identity(); - static _BusyMarkModalBarrierLease acquire(LinuxHeaderBarService service) { - final activeCount = _activeDialogs[service] ?? 0; - _activeDialogs[service] = activeCount + 1; - if (activeCount == 0) { - unawaited(_enqueueUpdate(service, visible: true)); + static Future acquire(LinuxHeaderBarService service) async { + final depth = _activeDialogs[service] ?? 0; + final nextDepth = depth + 1; + _activeDialogs[service] = nextDepth; + final depthUpdate = _enqueueUpdate(service, depth: nextDepth); + try { + await depthUpdate; + } on Object catch (error, stackTrace) { + final remainingDepth = (_activeDialogs[service] ?? 0) - 1; + if (remainingDepth > 0) { + _activeDialogs[service] = remainingDepth; + } else { + _activeDialogs.remove(service); + try { + await _enqueueUpdate(service, depth: 0); + } on Object { + // Preserve the acquisition failure if its best-effort rollback fails. + } + } + Error.throwWithStackTrace(error, stackTrace); } - return _BusyMarkModalBarrierLease(service); } static Future release(LinuxHeaderBarService service) async { - final activeCount = _activeDialogs[service]; - if (activeCount == null) { + final depth = _activeDialogs[service] ?? 0; + if (depth <= 1) { + _activeDialogs.remove(service); + await _enqueueUpdate(service, depth: 0); return; } - if (activeCount > 1) { - _activeDialogs[service] = activeCount - 1; - return; - } - _activeDialogs.remove(service); - await _enqueueUpdate(service, visible: false); + final nextDepth = depth - 1; + _activeDialogs[service] = nextDepth; + await _enqueueUpdate(service, depth: nextDepth); } static Future _enqueueUpdate( LinuxHeaderBarService service, { - required bool visible, + required int depth, }) async { final previous = _pendingUpdates[service] ?? Future.value(); final update = previous .catchError((Object _) {}) - .then((_) => service.setModalBarrierVisible(visible)); + .then((_) => service.setModalBarrierDepth(depth)); _pendingUpdates[service] = update; try { await update; @@ -132,21 +151,6 @@ class _BusyMarkModalBarrierCoordinator { } } -class _BusyMarkModalBarrierLease { - _BusyMarkModalBarrierLease(this._service); - - final LinuxHeaderBarService _service; - var _released = false; - - void release() { - if (_released) { - return; - } - _released = true; - unawaited(_BusyMarkModalBarrierCoordinator.release(_service)); - } -} - class BusyMarkModalEditorSurface extends StatelessWidget { const BusyMarkModalEditorSurface({ super.key, diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index a1911cb..67a5a2d 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -3048,6 +3048,7 @@ class _FloatingWysiwygToolbar extends StatelessWidget { onPressed: onToggle, accented: true, elevated: true, + foregroundColor: BusyMarkLinuxPalette.white, ), ); final gap = visible diff --git a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart index 26a931d..5feafed 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart @@ -243,17 +243,14 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { } Widget _blockStyleMenu(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); return BusyMarkHeaderPopupMenuButton( tooltip: context.l10n.textStyle, icon: BusyMarkGlyphs.font, shortcut: BusyMarkEditorShortcutLabels.textStyle, transparent: false, - foregroundColor: colors.foreground, - backgroundColor: busyMarkContainedControlBackground( - context, - surface: colors.view, - ), + elevated: true, + foregroundColor: BusyMarkLinuxPalette.white, + backgroundColor: _editorToolbarButtonBackground(context), itemBuilder: (context) => [ BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.paragraph, @@ -302,18 +299,28 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { required VoidCallback onPressed, String? shortcut, }) { - final colors = BusyMarkSurfaceColors.of(context); return BusyMarkHeaderIconButton( tooltip: tooltip, icon: icon, onPressed: onPressed, shortcut: shortcut, transparent: false, - foregroundColor: colors.foreground, - backgroundColor: busyMarkContainedControlBackground( - context, - surface: colors.view, - ), + elevated: true, + foregroundColor: BusyMarkLinuxPalette.white, + backgroundColor: _editorToolbarButtonBackground(context), ); } + + WidgetStateProperty _editorToolbarButtonBackground( + BuildContext context, + ) { + final theme = Theme.of(context); + final colors = BusyMarkSurfaceColors.of(context); + return WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colors.disabledControl; + } + return theme.colorScheme.primary; + }); + } } diff --git a/lib/src/platform/header_bar_configuration.dart b/lib/src/platform/header_bar_configuration.dart index 614433c..fafb93b 100644 --- a/lib/src/platform/header_bar_configuration.dart +++ b/lib/src/platform/header_bar_configuration.dart @@ -178,11 +178,12 @@ class HeaderBarConfiguration { required this.sidebarVisible, required this.sidebarToggleVisible, required this.backVisible, - required this.modalBarrierVisible, + required this.modalBarrierDepth, required this.sidebarWidth, required this.labels, required this.theme, - }) : assert(revision >= 0); + }) : assert(revision >= 0), + assert(modalBarrierDepth >= 0); final int revision; final String title; @@ -196,11 +197,13 @@ class HeaderBarConfiguration { final bool sidebarVisible; final bool sidebarToggleVisible; final bool backVisible; - final bool modalBarrierVisible; + final int modalBarrierDepth; final double sidebarWidth; final HeaderBarLabels labels; final HeaderBarTheme theme; + bool get modalBarrierVisible => modalBarrierDepth > 0; + HeaderBarConfiguration copyWith({ int? revision, String? title, @@ -214,7 +217,7 @@ class HeaderBarConfiguration { bool? sidebarVisible, bool? sidebarToggleVisible, bool? backVisible, - bool? modalBarrierVisible, + int? modalBarrierDepth, double? sidebarWidth, HeaderBarLabels? labels, HeaderBarTheme? theme, @@ -233,7 +236,7 @@ class HeaderBarConfiguration { sidebarVisible: sidebarVisible ?? this.sidebarVisible, sidebarToggleVisible: sidebarToggleVisible ?? this.sidebarToggleVisible, backVisible: backVisible ?? this.backVisible, - modalBarrierVisible: modalBarrierVisible ?? this.modalBarrierVisible, + modalBarrierDepth: modalBarrierDepth ?? this.modalBarrierDepth, sidebarWidth: sidebarWidth ?? this.sidebarWidth, labels: labels ?? this.labels, theme: theme ?? this.theme, @@ -254,6 +257,7 @@ class HeaderBarConfiguration { 'sidebarToggleVisible': sidebarToggleVisible, 'backVisible': backVisible, 'modalBarrierVisible': modalBarrierVisible, + 'modalBarrierDepth': modalBarrierDepth, 'sidebarWidth': sidebarWidth, 'labels': labels.toMap(), 'theme': theme.toMap(), @@ -271,7 +275,7 @@ class HeaderBarConfiguration { sidebarVisible == other.sidebarVisible && sidebarToggleVisible == other.sidebarToggleVisible && backVisible == other.backVisible && - modalBarrierVisible == other.modalBarrierVisible && + modalBarrierDepth == other.modalBarrierDepth && sidebarWidth == other.sidebarWidth && labels == other.labels && theme == other.theme; @@ -299,7 +303,7 @@ class HeaderBarConfiguration { sidebarVisible, sidebarToggleVisible, backVisible, - modalBarrierVisible, + modalBarrierDepth, sidebarWidth, labels, theme, @@ -324,7 +328,7 @@ class HeaderBarConfigurationSynchronizer { HeaderBarConfiguration? _appliedConfiguration; Future? _drainFuture; var _lastRevision = 0; - var _modalBarrierVisible = false; + var _modalBarrierDepth = 0; HeaderBarConfiguration? get desiredConfiguration => _desiredConfiguration; HeaderBarConfiguration? get appliedConfiguration => _appliedConfiguration; @@ -339,14 +343,19 @@ class HeaderBarConfigurationSynchronizer { return _enqueueEffectiveConfiguration(); } - Future setModalBarrierVisible(bool visible) { - if (_modalBarrierVisible == visible) { + Future setModalBarrierDepth(int depth) { + final effectiveDepth = depth < 0 ? 0 : depth; + if (_modalBarrierDepth == effectiveDepth) { return _waitForDesiredConfiguration(); } - _modalBarrierVisible = visible; + _modalBarrierDepth = effectiveDepth; return _enqueueEffectiveConfiguration(); } + Future setModalBarrierVisible(bool visible) { + return setModalBarrierDepth(visible ? 1 : 0); + } + Future _enqueueEffectiveConfiguration() { final base = _baseConfiguration; if (base == null) { @@ -354,7 +363,7 @@ class HeaderBarConfigurationSynchronizer { } final effective = base.copyWith( revision: 0, - modalBarrierVisible: _modalBarrierVisible, + modalBarrierDepth: _modalBarrierDepth, ); final desired = _desiredConfiguration; if (desired != null && desired.hasSameContentAs(effective)) { diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 27c9225..3024aa3 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -213,15 +213,20 @@ class LinuxHeaderBarService extends ChangeNotifier { return _invokeLegacy('setTheme', theme.toMap()); } - Future setModalBarrierVisible(bool value) async { + Future setModalBarrierDepth(int value) async { + final depth = value < 0 ? 0 : value; final hasPublishedConfiguration = configurationSynchronizer.desiredConfiguration != null; - await configurationSynchronizer.setModalBarrierVisible(value); + await configurationSynchronizer.setModalBarrierDepth(depth); if (!hasPublishedConfiguration) { - await _invokeLegacy('setModalBarrierVisible', value); + await _invokeLegacy('setModalBarrierDepth', depth); } } + Future setModalBarrierVisible(bool value) { + return setModalBarrierDepth(value ? 1 : 0); + } + Future _applyConfiguration(HeaderBarConfiguration configuration) async { if (!_channelReady) { await initialize(); diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index 8c4df96..91f685c 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../../../l10n/generated/app_localizations.dart'; import '../../app/app_router.dart'; import '../../app/app_settings.dart'; +import '../../app/app_locale.dart'; import '../../app/busymark_dialogs.dart'; import '../../app/busymark_design.dart'; import '../../app/busymark_glyphs.dart'; @@ -331,11 +332,8 @@ class _LanguageControl extends StatelessWidget { value: _systemLocaleTag, label: context.l10n.systemLanguage, ), - for (final option in _languageOptions()) - BusyMarkPopupSelectorOption( - value: option.localeTag, - label: option.label, - ), + for (final option in busyMarkLocaleOptions) + BusyMarkPopupSelectorOption(value: option.tag, label: option.endonym), ], onSelected: (value) { onChanged(value == _systemLocaleTag ? null : value); @@ -347,41 +345,18 @@ class _LanguageControl extends StatelessWidget { if (value == _systemLocaleTag) { return context.l10n.systemLanguage; } - return _languageOptions() + return busyMarkLocaleOptions .firstWhere( - (option) => option.localeTag == value, - orElse: () => const _LanguageOption('en', 'English'), + (option) => option.tag == value, + orElse: () => const BusyMarkLocaleOption( + locale: Locale('en'), + endonym: 'English', + ), ) - .label; - } - - List<_LanguageOption> _languageOptions() { - return const [ - _LanguageOption('en', 'English'), - _LanguageOption('et', 'Eesti'), - _LanguageOption('de', 'Deutsch'), - _LanguageOption('it', 'Italiano'), - _LanguageOption('nb', 'Norsk'), - _LanguageOption('fr', 'Français'), - _LanguageOption('ru', 'Русский'), - _LanguageOption('uk', 'Українська'), - _LanguageOption('pl', 'Polski'), - _LanguageOption('es', 'Español'), - _LanguageOption('pt', 'Português'), - _LanguageOption('ar', 'العربية'), - _LanguageOption('fa', 'فارسی'), - _LanguageOption('hi', 'हिन्दी'), - ]; + .endonym; } } -class _LanguageOption { - const _LanguageOption(this.localeTag, this.label); - - final String localeTag; - final String label; -} - class _ThemeModeRow extends StatelessWidget { const _ThemeModeRow({required this.selected, required this.onChanged}); diff --git a/test/src/app_settings_test.dart b/test/src/app_settings_test.dart index dc89597..8758dfa 100644 --- a/test/src/app_settings_test.dart +++ b/test/src/app_settings_test.dart @@ -101,15 +101,28 @@ void main() { expect(settings.toJson()['localeTag'], 'nb'); }); - test('script-only locale tag is not treated as a country code', () { + test('supported script variants canonicalize to the available catalog', () { final settings = AppSettings.fromJson({ 'localeTag': 'fa-Arab', }); - expect( - settings.locale, - const Locale.fromSubtags(languageCode: 'fa', scriptCode: 'Arab'), - ); + expect(settings.localeTag, 'fa'); + expect(settings.locale, const Locale('fa')); + }); + + test('regional variants canonicalize and unsupported tags are discarded', () { + final regional = AppSettings.fromJson({ + 'localeTag': 'de-DE', + }); + final unsupported = AppSettings.fromJson({ + 'localeTag': 'eo', + }); + + expect(regional.localeTag, 'de'); + expect(regional.locale, const Locale('de')); + expect(unsupported.localeTag, isNull); + expect(unsupported.locale, isNull); + expect(AppSettings.defaults().copyWith(localeTag: 'eo').localeTag, isNull); }); test('unused product settings are not persisted', () { diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index 358d903..dce4bd0 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -250,6 +250,10 @@ void main() { elevatedIcon.style?.shadowColor?.resolve({}), theme.colorScheme.shadow, ); + expect( + elevatedIcon.style?.surfaceTintColor?.resolve({}), + BusyMarkLinuxPalette.transparent, + ); expect(elevatedPopup.style?.elevation?.resolve({}), expectedElevation); expect(flatIcon.style?.elevation, isNull); expect(flatIcon.style?.backgroundColor?.resolve({}), isNull); @@ -816,130 +820,153 @@ void main() { }); for (final brightness in Brightness.values) { - testWidgets( - 'WYSIWYG toolbar uses contained Yaru controls in ${brightness.name}', - (tester) async { - final theme = buildBusyMarkTheme( - brightness: brightness, - accentColor: const Color(0xFFE95420), - ); - final colors = theme.extension()!; - final boundaryKey = GlobalKey(); - tester.view - ..physicalSize = const Size(900, 240) - ..devicePixelRatio = 1; - addTearDown(tester.view.reset); + testWidgets('WYSIWYG toolbar uses accent controls in ${brightness.name}', ( + tester, + ) async { + const accent = Color(0xFFE95420); + final theme = buildBusyMarkTheme( + brightness: brightness, + accentColor: accent, + ); + final colors = theme.extension()!; + final boundaryKey = GlobalKey(); + tester.view + ..physicalSize = const Size(900, 240) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); - await tester.pumpWidget( - MaterialApp( - theme: theme, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: RepaintBoundary( - key: boundaryKey, - child: Scaffold( - body: ColoredBox( - color: colors.view, - child: BusyMarkWysiwygToolbar( - onBlockCommand: (_) {}, - onInlineCommand: (_) {}, - onLinkCommand: () {}, - onImageCommand: () {}, - onInlineImageCommand: () {}, - onTableCommand: () {}, - onHtmlCommand: () {}, - onIndentCommand: () {}, - onOutdentCommand: () {}, - onToggleTaskCommand: () {}, - onHardBreakCommand: () {}, - onCodeLanguageCommand: () {}, - ), + await tester.pumpWidget( + MaterialApp( + theme: theme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: RepaintBoundary( + key: boundaryKey, + child: Scaffold( + body: ColoredBox( + color: colors.view, + child: BusyMarkWysiwygToolbar( + onBlockCommand: (_) {}, + onInlineCommand: (_) {}, + onLinkCommand: () {}, + onImageCommand: () {}, + onInlineImageCommand: () {}, + onTableCommand: () {}, + onHtmlCommand: () {}, + onIndentCommand: () {}, + onOutdentCommand: () {}, + onToggleTaskCommand: () {}, + onHardBreakCommand: () {}, + onCodeLanguageCommand: () {}, ), ), ), ), - ); - await tester.pump(); - - final toolbar = find.byType(BusyMarkWysiwygToolbar); - final popup = tester.widget( - find.descendant( - of: toolbar, - matching: find.byWidgetPredicate( - (widget) => widget is BusyMarkHeaderPopupMenuButton, - ), - ), - ); - expect(popup.transparent, isFalse); - final expectedRest = Color.alphaBlend(colors.control, colors.view); - final expectedDisabled = Color.alphaBlend( - colors.disabledControl, - colors.view, - ); - expect(popup.foregroundColor, colors.foreground); - expect(popup.backgroundColor?.resolve({}), expectedRest); + ), + ); + await tester.pump(); - final actions = tester.widgetList( - find.descendant( - of: toolbar, - matching: find.byType(BusyMarkHeaderIconButton), + final toolbar = find.byType(BusyMarkWysiwygToolbar); + final popup = tester.widget( + find.descendant( + of: toolbar, + matching: find.byWidgetPredicate( + (widget) => widget is BusyMarkHeaderPopupMenuButton, ), - ); - expect(actions, isNotEmpty); - expect(actions.every((button) => !button.transparent), isTrue); + ), + ); + expect(popup.transparent, isFalse); + expect(popup.elevated, isTrue); + expect(popup.foregroundColor, BusyMarkLinuxPalette.white); + expect(popup.backgroundColor?.resolve({}), accent); + expect( + popup.backgroundColor?.resolve({WidgetState.disabled}), + colors.disabledControl, + ); + + final actions = tester.widgetList( + find.descendant( + of: toolbar, + matching: find.byType(BusyMarkHeaderIconButton), + ), + ); + expect(actions, isNotEmpty); + expect(actions.every((button) => !button.transparent), isTrue); + expect(actions.every((button) => button.elevated), isTrue); + expect( + actions.every( + (button) => button.foregroundColor == BusyMarkLinuxPalette.white, + ), + isTrue, + ); + expect( + actions.every( + (button) => button.backgroundColor?.resolve({}) == accent, + ), + isTrue, + ); + expect( + actions.every( + (button) => + button.backgroundColor?.resolve({WidgetState.disabled}) == + colors.disabledControl, + ), + isTrue, + ); + + final renderedButtons = tester.widgetList( + find.descendant(of: toolbar, matching: find.byType(IconButton)), + ); + expect(renderedButtons, isNotEmpty); + final expectedElevation = + theme.cardTheme.elevation ?? BusyMarkElevation.surface; + for (final button in renderedButtons) { + expect(button.style?.backgroundColor?.resolve({}), accent); + expect(button.style?.elevation?.resolve({}), expectedElevation); expect( - actions.every( - (button) => button.foregroundColor == colors.foreground, - ), - isTrue, + button.style?.shadowColor?.resolve({}), + theme.colorScheme.shadow, ); expect( - actions.every( - (button) => button.backgroundColor?.resolve({}) == expectedRest, - ), - isTrue, - ); - - final renderedButtons = tester.widgetList( - find.descendant(of: toolbar, matching: find.byType(IconButton)), + button.style?.surfaceTintColor?.resolve({}), + BusyMarkLinuxPalette.transparent, ); - expect(renderedButtons, isNotEmpty); - for (final button in renderedButtons) { - expect(button.style?.backgroundColor?.resolve({}), expectedRest); - expect(button.style?.foregroundColor?.resolve({}), colors.foreground); - expect( - button.style?.backgroundColor?.resolve({WidgetState.disabled}), - expectedDisabled, - ); - } - - final actionButton = find.ancestor( - of: find.byIcon(BusyMarkGlyphs.unorderedList), - matching: find.byType(IconButton), + expect( + button.style?.foregroundColor?.resolve({}), + BusyMarkLinuxPalette.white, ); - expect(actionButton, findsOneWidget); - final buttonSize = tester.getSize(actionButton); - expect(buttonSize, const Size.square(BusyMarkSizes.iconButton)); - final probe = Offset(5, buttonSize.height / 2); - final restPixels = await _capturePixels(tester, boundaryKey); - final rest = _pixelAtLocal(tester, restPixels, actionButton, probe); - _expectColorNear(rest, expectedRest); - - final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); - addTearDown(mouse.removePointer); - await mouse.addPointer(location: Offset.zero); - await mouse.moveTo(tester.getCenter(actionButton)); - await tester.pumpAndSettle(); - final hoverPixels = await _capturePixels(tester, boundaryKey); - final hover = _pixelAtLocal(tester, hoverPixels, actionButton, probe); - final expectedHover = Color.alphaBlend( - theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.08), - expectedRest, + expect( + button.style?.backgroundColor?.resolve({WidgetState.disabled}), + colors.disabledControl, ); - _expectColorNear(hover, expectedHover); - expect(hover, isNot(rest)); - }, - ); + } + + final actionButton = find.ancestor( + of: find.byIcon(BusyMarkGlyphs.unorderedList), + matching: find.byType(IconButton), + ); + expect(actionButton, findsOneWidget); + final buttonSize = tester.getSize(actionButton); + expect(buttonSize, const Size.square(BusyMarkSizes.iconButton)); + final probe = Offset(5, buttonSize.height / 2); + final restPixels = await _capturePixels(tester, boundaryKey); + final rest = _pixelAtLocal(tester, restPixels, actionButton, probe); + _expectColorNear(rest, accent); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(actionButton)); + await tester.pumpAndSettle(); + final hoverPixels = await _capturePixels(tester, boundaryKey); + final hover = _pixelAtLocal(tester, hoverPixels, actionButton, probe); + final expectedHover = Color.alphaBlend( + theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.08), + accent, + ); + _expectColorNear(hover, expectedHover); + expect(hover, isNot(rest)); + }); } testWidgets('dialog actions wrap at narrow localized text widths', ( diff --git a/test/src/busymark_dialogs_test.dart b/test/src/busymark_dialogs_test.dart index 56df889..fc5e3b5 100644 --- a/test/src/busymark_dialogs_test.dart +++ b/test/src/busymark_dialogs_test.dart @@ -9,6 +9,19 @@ void main() { testWidgets('modal dialogs stop app and document-view shortcuts', ( tester, ) async { + const channel = MethodChannel('com.busymark.test/modal-shortcuts'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + return call.method == 'initialize' ? true : null; + }); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + }); + final headerBar = LinuxHeaderBarService(channel: channel); var appShortcutInvocations = 0; var documentViewShortcutInvocations = 0; @@ -49,6 +62,7 @@ void main() { onPressed: () { showBusyMarkModalDialog( context, + headerBarService: headerBar, builder: (dialogContext) => TextButton( autofocus: true, onPressed: () => Navigator.pop(dialogContext), @@ -78,19 +92,19 @@ void main() { expect(find.text('Dismiss'), findsOneWidget); }); - testWidgets('overlapping dialogs retain one native modal barrier lease', ( + testWidgets('overlapping dialogs synchronize native modal depth', ( tester, ) async { const channel = MethodChannel('com.busymark.test/modal-barrier'); - final barrierStates = []; + final barrierDepths = []; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( call, ) async { if (call.method == 'initialize') { return true; } - if (call.method == 'setModalBarrierVisible') { - barrierStates.add(call.arguments as bool); + if (call.method == 'setModalBarrierDepth') { + barrierDepths.add(call.arguments as int); } return null; }); @@ -127,17 +141,17 @@ void main() { ); await tester.pumpAndSettle(); - expect(barrierStates, [isTrue]); + expect(barrierDepths, [1, 2]); Navigator.of(hostContext, rootNavigator: true).pop(); await tester.pumpAndSettle(); await second; - expect(barrierStates, [isTrue]); + expect(barrierDepths, [1, 2, 1]); Navigator.of(hostContext, rootNavigator: true).pop(); await tester.pumpAndSettle(); await first; - expect(barrierStates, [isTrue, isFalse]); + expect(barrierDepths, [1, 2, 1, 0]); }); } diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index bc5e244..78a89c7 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -2430,7 +2430,7 @@ void main() {} ); expect( hideButton.style?.foregroundColor?.resolve(const {}), - colorScheme.onPrimary, + BusyMarkLinuxPalette.white, ); final hideRect = tester.getRect(find.byTooltip('Hide editing buttons')); @@ -2447,7 +2447,7 @@ void main() {} ); expect( showButton.style?.foregroundColor?.resolve(const {}), - colorScheme.onPrimary, + BusyMarkLinuxPalette.white, ); }, ); @@ -3161,16 +3161,16 @@ void main() {} await tester.pumpAndSettle(); expect( - calls.where((call) => call.method == 'setModalBarrierVisible').last, - isA().having((call) => call.arguments, 'arguments', true), + calls.where((call) => call.method == 'setModalBarrierDepth').last, + isA().having((call) => call.arguments, 'arguments', 1), ); await tester.tap(find.text(l10n.cancel)); await tester.pumpAndSettle(); expect( - calls.where((call) => call.method == 'setModalBarrierVisible').last, - isA().having((call) => call.arguments, 'arguments', false), + calls.where((call) => call.method == 'setModalBarrierDepth').last, + isA().having((call) => call.arguments, 'arguments', 0), ); }); diff --git a/test/src/header_bar_configuration_test.dart b/test/src/header_bar_configuration_test.dart index 8b77aad..08db41e 100644 --- a/test/src/header_bar_configuration_test.dart +++ b/test/src/header_bar_configuration_test.dart @@ -101,6 +101,7 @@ void main() { 'sidebarToggleVisible': false, 'backVisible': true, 'modalBarrierVisible': false, + 'modalBarrierDepth': 0, 'sidebarWidth': 300.0, 'labels': _labels.toMap(), 'theme': _theme.toMap(), @@ -169,7 +170,7 @@ void main() { ]); }); - test('modal barrier is an atomic overlay on the latest page state', () async { + test('modal depth is an atomic overlay on the latest page state', () async { if (!Platform.isLinux) { return; } @@ -197,17 +198,27 @@ void main() { await service.configurationSynchronizer.setConfiguration( _configuration(title: 'Settings', backVisible: true), ); - await service.setModalBarrierVisible(true); - await service.setModalBarrierVisible(true); - await service.setModalBarrierVisible(false); + await service.setModalBarrierDepth(1); + await service.setModalBarrierDepth(2); + await service.setModalBarrierDepth(1); + await service.setModalBarrierDepth(0); - expect(atomicCalls, hasLength(3)); - expect(atomicCalls.map((payload) => payload['revision']), [1, 2, 3]); + expect(atomicCalls, hasLength(5)); + expect(atomicCalls.map((payload) => payload['revision']), [1, 2, 3, 4, 5]); expect(atomicCalls.map((payload) => payload['modalBarrierVisible']), [ false, true, + true, + true, false, ]); + expect(atomicCalls.map((payload) => payload['modalBarrierDepth']), [ + 0, + 1, + 2, + 1, + 0, + ]); expect( atomicCalls.every((payload) => payload['title'] == 'Settings'), true, @@ -242,17 +253,17 @@ void main() { }); final service = LinuxHeaderBarService(channel: channel); - await service.setModalBarrierVisible(true); + await service.setModalBarrierDepth(1); await service.configurationSynchronizer.setConfiguration( _configuration(title: 'Welcome'), ); expect( calls - .where((call) => call.method == 'setModalBarrierVisible') + .where((call) => call.method == 'setModalBarrierDepth') .single .arguments, - true, + 1, ); final atomicPayload = calls @@ -261,6 +272,7 @@ void main() { .arguments as Map; expect(atomicPayload['modalBarrierVisible'], true); + expect(atomicPayload['modalBarrierDepth'], 1); }); test('older runners fall back once to the ordered legacy protocol', () async { @@ -423,7 +435,7 @@ HeaderBarConfiguration _configuration({ sidebarVisible: sidebarVisible, sidebarToggleVisible: sidebarToggleVisible, backVisible: backVisible, - modalBarrierVisible: false, + modalBarrierDepth: 0, sidebarWidth: 300, labels: _labels, theme: _theme, diff --git a/test/src/localization_audit_test.dart b/test/src/localization_audit_test.dart index f018b71..a35b4c7 100644 --- a/test/src/localization_audit_test.dart +++ b/test/src/localization_audit_test.dart @@ -7,6 +7,7 @@ import 'package:busymark/l10n/generated/app_localizations_en.dart'; import 'package:busymark/l10n/generated/app_localizations_fa.dart'; import 'package:busymark/src/core/diagnostic.dart'; import 'package:busymark/src/core/diagnostic_localizations.dart'; +import 'package:busymark/src/app/app_locale.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -203,6 +204,33 @@ void main() { expect(AppLocalizationsAr().diagnosticCount(12), contains('12')); }); + test( + 'locale resolution considers all preferences and falls back to English', + () { + expect( + resolveBusyMarkLocales(const [ + Locale('eo'), + Locale('de', 'DE'), + ], AppLocalizations.supportedLocales), + const Locale('de'), + ); + expect( + resolveBusyMarkLocales(const [ + Locale('eo'), + Locale('kl'), + ], AppLocalizations.supportedLocales), + const Locale('en'), + ); + }, + ); + + test('every selectable locale has a generated catalog', () { + expect( + busyMarkLocaleOptions.map((option) => option.locale).toSet(), + AppLocalizations.supportedLocales.toSet(), + ); + }); + testWidgets('diagnostics localize at render time from codes and args', ( tester, ) async { diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 4a3f016..d900000 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -19,12 +19,35 @@ void main() { expect(source, contains('"#00000000"')); expect(source, contains('kHeaderBarChannel')); expect(source, contains('setModalBarrierVisible')); + expect(source, contains('setModalBarrierDepth')); + expect(source, contains('modal_barrier_color_for_depth')); + expect( + source, + contains('1.0 - std::pow(1.0 - barrier.alpha, effective_depth)'), + ); expect(source, contains('gtk_overlay_new()')); expect(source, contains('gtk_overlay_add_overlay')); expect(source, contains('gtk_event_box_new()')); expect(source, contains('"busymark-modal-scrim"')); expect(source, contains('set_widget_visible(self->modal_scrim, visible)')); expect(source, contains('set_widget_visible(self->modal_scrim, FALSE)')); + expect(source, contains('gtk_widget_show_all(widget)')); + expect(source, isNot(contains('gtk_widget_set_visible(widget, visible)'))); + expect(source, contains('G_CALLBACK(stop_modal_scrim_event)')); + expect( + source, + isNot( + contains('gtk_widget_set_sensitive(self->titlebar_handle, !visible)'), + ), + ); + expect( + source, + contains('gtk_widget_set_vexpand(self->modal_scrim, FALSE)'), + ); + expect( + source, + isNot(contains('gtk_widget_set_vexpand(self->modal_scrim, TRUE)')), + ); expect(source, contains('setSidebarWidth')); expect(source, contains('setSidebarToggleVisible')); expect(source, contains('setTextDirection')); @@ -77,6 +100,7 @@ void main() { 'textDirection', 'backVisible', 'modalBarrierVisible', + 'modalBarrierDepth', 'labels', 'theme', ]) { @@ -280,15 +304,14 @@ void main() { expect(configuration, contains('class HeaderBarTheme')); expect(configuration, contains('HeaderBarTheme.fromContext')); expect(configuration, contains('BusyMarkSurfaceColors.of(context)')); - expect( - configuration, - contains('modalBarrierVisible: _modalBarrierVisible'), - ); + expect(configuration, contains('modalBarrierDepth: _modalBarrierDepth')); + expect(configuration, contains('setModalBarrierDepth(int depth)')); expect(configuration, contains('setModalBarrierVisible(bool visible)')); + expect(service, contains('setModalBarrierDepth')); expect(service, contains('setModalBarrierVisible')); expect( service, - contains('configurationSynchronizer.setModalBarrierVisible(value)'), + contains('configurationSynchronizer.setModalBarrierDepth(depth)'), ); expect(dialogs, contains('showBusyMarkModalDialog')); expect(dialogs, contains('busyMarkModalBarrierColor')); @@ -564,9 +587,12 @@ void main() { expect(native, isNot(contains('composite_rgba'))); expect(native, isNot(contains('"border-right-color: %s;"'))); expect(native, isNot(contains('"border-left-color: %s;"'))); + expect(native, contains('G_CALLBACK(stop_modal_scrim_event)')); expect( native, - contains('gtk_widget_set_sensitive(self->titlebar_handle, !visible)'), + isNot( + contains('gtk_widget_set_sensitive(self->titlebar_handle, !visible)'), + ), ); expect(workspace, isNot(contains('Border(right:'))); }); @@ -755,7 +781,18 @@ void main() { expect(native, contains('GtkWidget* modal_scrim;')); expect( native, - contains('gtk_widget_set_sensitive(self->titlebar_handle, !visible)'), + contains('gtk_widget_set_vexpand(self->titlebar_handle, FALSE)'), + ); + expect( + native, + contains('gtk_widget_set_vexpand(self->titlebar_overlay, FALSE)'), + ); + expect(native, contains('G_CALLBACK(stop_modal_scrim_event)')); + expect( + native, + isNot( + contains('gtk_widget_set_sensitive(self->titlebar_handle, !visible)'), + ), ); expect(native, contains('uses_legacy_yaru_window_shadow()')); expect(native, contains('g_strcmp0(normalized_theme, "yaru")')); diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 01ebf4c..a15f9cb 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -1475,9 +1475,17 @@ void main() { expect(toolbar, isNot(contains('transparent: true'))); expect(RegExp(r'transparent: false').allMatches(toolbar), hasLength(2)); expect(toolbar, contains('BusyMarkHeaderIconButton(')); - expect(toolbar, contains('busyMarkContainedControlBackground(')); - expect(toolbar, contains('foregroundColor: colors.foreground')); - expect(toolbar, isNot(contains('elevated: true'))); + expect(toolbar, contains('_editorToolbarButtonBackground(context)')); + expect(toolbar, contains('return theme.colorScheme.primary')); + expect( + RegExp( + r'foregroundColor: BusyMarkLinuxPalette\.white', + ).allMatches(toolbar), + hasLength(2), + ); + expect(toolbar, isNot(contains('busyMarkContainedControlBackground('))); + expect(toolbar, isNot(contains('foregroundColor: colors.foreground'))); + expect(RegExp(r'elevated: true').allMatches(toolbar), hasLength(2)); expect(toolbar, isNot(contains('accented: true'))); expect(toolbar, contains('clipBehavior: Clip.none')); expect(toolbar, contains('hitTestBehavior: HitTestBehavior.deferToChild')); @@ -1492,6 +1500,10 @@ void main() { ).firstMatch(editor)!.group(0)!; expect(floatingToolbar, contains('elevated: true')); expect(floatingToolbar, contains('accented: true')); + expect( + floatingToolbar, + contains('foregroundColor: BusyMarkLinuxPalette.white'), + ); expect(floatingToolbar, isNot(contains('_editorToolbarButtonBackground'))); expect(floatingToolbar, isNot(contains('boxShadow:'))); expect(floatingToolbar, isNot(contains('BusyMarkShadow.'))); From b5a8f01481f5b71417fe08fa5c89fe432f405fbd Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 17:47:24 -0700 Subject: [PATCH 07/29] Add modal barrier depth management and improve visibility handling --- linux/runner/my_application.cc | 87 +++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 13 deletions(-) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 3c0a43d..9b9979b 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include @@ -98,6 +100,7 @@ struct _MyApplication { gboolean back_visible; gboolean search_active; gboolean modal_barrier_visible; + gint modal_barrier_depth; gboolean suppress_header_actions; gchar* header_configuration_session_id; gint64 header_configuration_revision; @@ -116,6 +119,7 @@ struct HeaderBarConfiguration { gboolean sidebar_toggle_visible; gboolean back_visible; gboolean modal_barrier_visible; + gint64 modal_barrier_depth; const gchar* search_query; const gchar* text_direction; gdouble sidebar_width; @@ -290,6 +294,12 @@ static gboolean fl_method_bool_arg(FlValue* args) { : FALSE; } +static gint64 fl_method_int_arg(FlValue* args, gint64 fallback) { + return args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_INT + ? fl_value_get_int(args) + : fallback; +} + static gdouble fl_method_double_arg(FlValue* args, gdouble fallback) { if (args == nullptr) { return fallback; @@ -415,6 +425,17 @@ static const gchar* css_color_or(const gchar* value, const gchar* fallback) { return is_css_color_token(value) ? value : fallback; } +static gchar* modal_barrier_color_for_depth(const gchar* color, gint depth) { + GdkRGBA barrier; + if (!gdk_rgba_parse(&barrier, color)) { + return g_strdup(color); + } + const gint effective_depth = std::max(0, depth); + barrier.alpha = + 1.0 - std::pow(1.0 - barrier.alpha, effective_depth); + return gdk_rgba_to_string(&barrier); +} + static void replace_css_color_field(gchar** target, const gchar* value) { g_free(*target); *target = is_css_color_token(value) ? g_strdup(value) : nullptr; @@ -423,7 +444,14 @@ static void replace_css_color_field(gchar** target, const gchar* value) { static void set_widget_visible(GtkWidget* widget, gboolean visible) { if (widget != nullptr && GTK_IS_WIDGET(widget)) { gtk_widget_set_no_show_all(widget, !visible); - gtk_widget_set_visible(widget, visible); + if (visible) { + // Some header controls are nested in containers that start hidden. + // Showing only the container leaves children skipped by the initial + // gtk_widget_show_all() invisible, producing an empty header slot. + gtk_widget_show_all(widget); + } else { + gtk_widget_hide(widget); + } } } @@ -433,6 +461,10 @@ static void set_widget_sensitive(GtkWidget* widget, gboolean sensitive) { } } +static gboolean stop_modal_scrim_event(GtkWidget*, GdkEvent*, gpointer) { + return GDK_EVENT_STOP; +} + static GtkTextDirection app_text_direction(MyApplication* self) { return self->text_direction_rtl ? GTK_TEXT_DIR_RTL : GTK_TEXT_DIR_LTR; } @@ -544,8 +576,9 @@ static void refresh_header_bar_css(MyApplication* self) { css_color_or(self->foreground_color, kDefaultForeground); const gchar* sidebar_border = css_color_or(self->sidebar_border_color, kDefaultSidebarBorder); - const gchar* modal = - css_color_or(self->modal_barrier_color, "rgba(0,0,0,0.32)"); + g_autofree gchar* modal = modal_barrier_color_for_depth( + css_color_or(self->modal_barrier_color, "rgba(0,0,0,0.32)"), + self->modal_barrier_depth); const gchar* window_shadow_css = uses_legacy_yaru_window_shadow() ? kLegacyYaruWindowShadowCompatibilityCss : ""; @@ -1217,15 +1250,19 @@ static void set_localized_labels(MyApplication* self, FlValue* args) { update_view_mode_icon(self); } -static void set_modal_barrier_visible(MyApplication* self, gboolean visible) { +static void set_modal_barrier_depth(MyApplication* self, gint64 depth) { + const gint effective_depth = + depth <= 0 ? 0 + : depth > G_MAXINT ? G_MAXINT : static_cast(depth); + const gboolean visible = effective_depth > 0; + self->modal_barrier_depth = effective_depth; self->modal_barrier_visible = visible; + refresh_header_bar_css(self); set_widget_visible(self->modal_scrim, visible); - if (self->titlebar_handle != nullptr && - GTK_IS_WIDGET(self->titlebar_handle)) { - // The Handy handle owns native drag, double-click, and window-menu input. - // Disable the interaction surface itself while Flutter has a modal open. - gtk_widget_set_sensitive(self->titlebar_handle, !visible); - } +} + +static void set_modal_barrier_visible(MyApplication* self, gboolean visible) { + set_modal_barrier_depth(self, visible ? 1 : 0); } static void set_sidebar_visible(MyApplication* self, gboolean visible) { @@ -1373,7 +1410,13 @@ static gboolean decode_header_bar_configuration( !fl_lookup_optional_bool_arg(args, "backVisible", &configuration->back_visible) || !fl_lookup_optional_bool_arg(args, "modalBarrierVisible", - &configuration->modal_barrier_visible)) { + &configuration->modal_barrier_visible) || + !fl_lookup_int64_arg(args, "modalBarrierDepth", + &configuration->modal_barrier_depth) || + configuration->modal_barrier_depth < 0 || + configuration->modal_barrier_depth > G_MAXINT || + configuration->modal_barrier_visible != + (configuration->modal_barrier_depth > 0)) { return FALSE; } @@ -1406,7 +1449,7 @@ static void apply_header_bar_configuration( set_search_query(self, configuration.search_query); set_search_active(self, configuration.search_active); set_view_mode(self, configuration.view_mode); - set_modal_barrier_visible(self, configuration.modal_barrier_visible); + set_modal_barrier_depth(self, configuration.modal_barrier_depth); self->header_configuration_revision = configuration.revision; if (self->titlebar_box != nullptr) { @@ -1562,6 +1605,10 @@ static GtkWidget* create_busymark_titlebar_overlay(MyApplication* self) { gtk_widget_set_halign(self->titlebar_overlay, GTK_ALIGN_FILL); gtk_widget_set_valign(self->titlebar_overlay, GTK_ALIGN_FILL); gtk_widget_set_hexpand(self->titlebar_overlay, TRUE); + // The titlebar is packed as a fixed-height row above the Flutter view. + // Prevent expansion requests from overlay children from turning it into a + // second vertically expanding application surface. + gtk_widget_set_vexpand(self->titlebar_overlay, FALSE); gtk_container_add(GTK_CONTAINER(self->titlebar_overlay), create_busymark_titlebar(self)); @@ -1572,9 +1619,18 @@ static GtkWidget* create_busymark_titlebar_overlay(MyApplication* self) { gtk_widget_set_halign(self->modal_scrim, GTK_ALIGN_FILL); gtk_widget_set_valign(self->modal_scrim, GTK_ALIGN_FILL); gtk_widget_set_hexpand(self->modal_scrim, TRUE); - gtk_widget_set_vexpand(self->modal_scrim, TRUE); + // GTK_ALIGN_FILL gives the scrim the overlay's full allocation. Asking it + // to expand vertically instead propagates through GtkOverlay and + // HdyWindowHandle when the scrim becomes visible, making the header consume + // the window's spare height. + gtk_widget_set_vexpand(self->modal_scrim, FALSE); gtk_style_context_add_class(gtk_widget_get_style_context(self->modal_scrim), "busymark-modal-scrim"); + // Keep the header subtree visually enabled beneath the modal tint. The + // overlay owns pointer input while visible and consumes it here so events + // cannot bubble into HdyWindowHandle's drag and window-menu handlers. + g_signal_connect(self->modal_scrim, "event", + G_CALLBACK(stop_modal_scrim_event), nullptr); set_widget_visible(self->modal_scrim, FALSE); gtk_overlay_add_overlay(GTK_OVERLAY(self->titlebar_overlay), self->modal_scrim); @@ -1662,6 +1718,9 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, } else if (strcmp(method, "setModalBarrierVisible") == 0) { set_modal_barrier_visible(self, fl_method_bool_arg(args)); respond_success(method_call); + } else if (strcmp(method, "setModalBarrierDepth") == 0) { + set_modal_barrier_depth(self, fl_method_int_arg(args, 0)); + respond_success(method_call); } else { fl_method_call_respond_not_implemented(method_call, nullptr); } @@ -1703,6 +1762,7 @@ static void my_application_activate(GApplication* application) { self->titlebar_handle = hdy_window_handle_new(); gtk_widget_set_hexpand(self->titlebar_handle, TRUE); + gtk_widget_set_vexpand(self->titlebar_handle, FALSE); gtk_container_add(GTK_CONTAINER(self->titlebar_handle), create_busymark_titlebar_overlay(self)); gtk_widget_show_all(self->titlebar_handle); @@ -1861,6 +1921,7 @@ static void my_application_init(MyApplication* self) { self->back_visible = FALSE; self->search_active = FALSE; self->modal_barrier_visible = FALSE; + self->modal_barrier_depth = 0; self->suppress_header_actions = FALSE; self->header_configuration_session_id = nullptr; self->header_configuration_revision = -1; From c6b764cc49e972c849050feed7560ca13d032fdb Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 17:50:14 -0700 Subject: [PATCH 08/29] Improve modal barrier handling and add sidebar header styling --- lib/src/app/busymark_dialogs.dart | 13 ++++++++++--- linux/runner/my_application.cc | 12 ++++++++++-- test/src/busymark_dialogs_test.dart | 2 ++ test/src/native_headerbar_audit_test.dart | 4 ++++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index 440ecc2..fe19eb9 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -51,10 +51,15 @@ Future showBusyMarkModalDialog( final barrierColor = busyMarkModalBarrierColor(context); final effectiveHeaderBarService = headerBarService ?? LinuxHeaderBarService.instance; + final coordinateNativeBarrier = effectiveHeaderBarService.isAvailable; final previousFocus = FocusManager.instance.primaryFocus; - await _BusyMarkModalBarrierCoordinator.acquire(effectiveHeaderBarService); + if (coordinateNativeBarrier) { + await _BusyMarkModalBarrierCoordinator.acquire(effectiveHeaderBarService); + } if (!context.mounted) { - await _BusyMarkModalBarrierCoordinator.release(effectiveHeaderBarService); + if (coordinateNativeBarrier) { + await _BusyMarkModalBarrierCoordinator.release(effectiveHeaderBarService); + } return null; } try { @@ -83,7 +88,9 @@ Future showBusyMarkModalDialog( }, ); } finally { - await _BusyMarkModalBarrierCoordinator.release(effectiveHeaderBarService); + if (coordinateNativeBarrier) { + await _BusyMarkModalBarrierCoordinator.release(effectiveHeaderBarService); + } if (previousFocus?.context != null && previousFocus!.canRequestFocus) { previousFocus.requestFocus(); } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 9b9979b..f93ad57 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -16,6 +16,7 @@ constexpr char kHeaderBarChannel[] = "com.busymark.app/headerbar"; constexpr gint kHeaderButtonHeight = 32; constexpr gint kHeaderButtonSpacing = 8; constexpr gint kHeaderSidebarInset = 8; +constexpr gdouble kHeaderBackdropForegroundOpacity = 0.50; constexpr char kDefaultHeaderbarBackground[] = "#272727"; constexpr char kDefaultSidebarBackground[] = "#393939"; constexpr char kDefaultSidebarBorder[] = "rgba(16,16,16,0.35)"; @@ -625,6 +626,13 @@ static void refresh_header_bar_css(MyApplication* self) { "border: none;" "box-shadow: none;" "}" + ".busymark-sidebar-header label {" + "color: %s;" + "font-weight: 800;" + "}" + ".busymark-sidebar-header label:backdrop {" + "color: alpha(%s, %.2f);" + "}" ".busymark-sidebar-header:dir(ltr) {" "border-right: 1px solid %s;" "}" @@ -673,8 +681,8 @@ static void refresh_header_bar_css(MyApplication* self) { "background-image: none;" "}", background, window_shadow_css, background, foreground, background, - foreground, sidebar_background, foreground, sidebar_border, - sidebar_border, modal); + foreground, sidebar_background, foreground, foreground, foreground, + kHeaderBackdropForegroundOpacity, sidebar_border, sidebar_border, modal); g_autoptr(GError) error = nullptr; GtkCssProvider* provider = gtk_css_provider_new(); diff --git a/test/src/busymark_dialogs_test.dart b/test/src/busymark_dialogs_test.dart index fc5e3b5..7a8765e 100644 --- a/test/src/busymark_dialogs_test.dart +++ b/test/src/busymark_dialogs_test.dart @@ -22,6 +22,7 @@ void main() { ); }); final headerBar = LinuxHeaderBarService(channel: channel); + await headerBar.initialize(); var appShortcutInvocations = 0; var documentViewShortcutInvocations = 0; @@ -115,6 +116,7 @@ void main() { ); }); final headerBar = LinuxHeaderBarService(channel: channel); + await headerBar.initialize(); late BuildContext hostContext; await tester.pumpWidget( diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index d900000..09eaba2 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -564,6 +564,10 @@ void main() { ); expect(native, contains('.busymark-sidebar-header {')); expect(native, contains('background-color: %s;')); + expect(native, contains('".busymark-sidebar-header label {"')); + expect(native, contains('"font-weight: 800;"')); + expect(native, contains('".busymark-sidebar-header label:backdrop {"')); + expect(native, contains('kHeaderBackdropForegroundOpacity = 0.50')); final headerbarBlock = RegExp( r'"headerbar\.busymark-headerbar,"(.*?)"\}', dotAll: true, From 4228b2c9e10746b62f20a4a9e5ce99ef1d7f24a3 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 19:36:14 -0700 Subject: [PATCH 09/29] Add modal barrier color customization and improve header menu behavior. Update application subtitles for localization consistency across multiple languages --- lib/l10n/app_ar.arb | 2 +- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_es.arb | 2 +- lib/l10n/app_fa.arb | 2 +- lib/l10n/app_fr.arb | 2 +- lib/l10n/app_hi.arb | 2 +- lib/l10n/app_it.arb | 2 +- lib/l10n/app_nb.arb | 2 +- lib/l10n/app_pl.arb | 2 +- lib/l10n/app_pt.arb | 2 +- lib/l10n/app_ru.arb | 2 +- lib/l10n/app_uk.arb | 2 +- lib/l10n/generated/app_localizations.dart | 2 +- lib/l10n/generated/app_localizations_ar.dart | 3 +- lib/l10n/generated/app_localizations_de.dart | 2 +- lib/l10n/generated/app_localizations_en.dart | 2 +- lib/l10n/generated/app_localizations_es.dart | 2 +- lib/l10n/generated/app_localizations_fa.dart | 2 +- lib/l10n/generated/app_localizations_fr.dart | 2 +- lib/l10n/generated/app_localizations_hi.dart | 3 +- lib/l10n/generated/app_localizations_it.dart | 2 +- lib/l10n/generated/app_localizations_nb.dart | 2 +- lib/l10n/generated/app_localizations_pl.dart | 3 +- lib/l10n/generated/app_localizations_pt.dart | 2 +- lib/l10n/generated/app_localizations_ru.dart | 2 +- lib/l10n/generated/app_localizations_uk.dart | 2 +- lib/src/app/app_theme.dart | 3 + lib/src/app/busymark_design.dart | 147 +++++-- lib/src/app/busymark_dialog_identity.dart | 89 ++++ lib/src/app/busymark_dialogs.dart | 389 +++++++++++------- .../platform/header_bar_configuration.dart | 5 +- .../platform/linux_header_bar_service.dart | 3 + linux/io.busystack.busymark.desktop | 26 +- linux/io.busystack.busymark.metainfo.xml | 26 +- linux/runner/my_application.cc | 126 ++++-- test/src/app_smoke_test.dart | 4 +- test/src/busymark_design_test.dart | 36 +- test/src/busymark_dialogs_test.dart | 145 +++++++ test/src/busymark_document_test.dart | 2 +- test/src/feedback_dialog_test.dart | 10 +- test/src/header_bar_configuration_test.dart | 1 + test/src/localization_audit_test.dart | 61 +++ test/src/modal_barrier_test.dart | 97 +++++ test/src/native_headerbar_audit_test.dart | 16 + test/src/source_audit_test.dart | 28 +- test/src/surface_palette_render_test.dart | 63 ++- 47 files changed, 1018 insertions(+), 316 deletions(-) create mode 100644 lib/src/app/busymark_dialog_identity.dart create mode 100644 test/src/modal_barrier_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 0fdedbd..1313b5e 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -29,7 +29,7 @@ "@appTitle": { "description": "Application name." }, - "appSubtitle": "محرر وثائق متوافق مع Markdown وWriterside.", + "appSubtitle": "محرر لملفات Markdown ومشاريع التوثيق المتوافقة مع Writerside.", "@appSubtitle": { "description": "Short application description." }, diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 402869b..56505b0 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -29,7 +29,7 @@ "@appTitle": { "description": "Application name." }, - "appSubtitle": "Markdown- und Writerside-kompatibler Dokumentationseditor.", + "appSubtitle": "Editor für Markdown-Dateien und Writerside-kompatible Dokumentationsprojekte.", "@appSubtitle": { "description": "Short application description." }, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d2c2c99..fdb7a78 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2,7 +2,7 @@ "@@locale": "en", "appTitle": "BusyMark", "@appTitle": {"description": "Application name."}, - "appSubtitle": "Markdown and Writerside-compatible documentation editor.", + "appSubtitle": "Editor for Markdown files and Writerside-compatible documentation projects.", "@appSubtitle": {"description": "Short application description."}, "aboutBusyMark": "About BusyMark", "@aboutBusyMark": {"description": "Menu item and tooltip for the About dialog."}, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index f511c8a..68a7996 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -29,7 +29,7 @@ "@appTitle": { "description": "Application name." }, - "appSubtitle": "Editor de documentación para Markdown y proyectos compatibles con Writerside.", + "appSubtitle": "Editor de archivos Markdown y proyectos de documentación compatibles con Writerside.", "@appSubtitle": { "description": "Short application description." }, diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 72361da..b9e196b 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -29,7 +29,7 @@ "@appTitle": { "description": "Application name." }, - "appSubtitle": "ویرایشگر مستندات Markdown و پروژه‌های سازگار با Writerside.", + "appSubtitle": "ویرایشگر فایل‌های Markdown و پروژه‌های مستندسازی سازگار با Writerside.", "@appSubtitle": { "description": "Short application description." }, diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index b264149..14fe097 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -29,7 +29,7 @@ "@appTitle": { "description": "Application name." }, - "appSubtitle": "Éditeur de documentation Markdown compatible avec Writerside.", + "appSubtitle": "Éditeur de fichiers Markdown et de projets de documentation compatibles avec Writerside.", "@appSubtitle": { "description": "Short application description." }, diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 48cbf78..8ccc37f 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -29,7 +29,7 @@ "@appTitle": { "description": "Application name." }, - "appSubtitle": "Markdown और Writerside-संगत दस्तावेज़ीकरण संपादक।", + "appSubtitle": "Markdown फ़ाइलों और Writerside-संगत दस्तावेज़ीकरण परियोजनाओं का संपादक।", "@appSubtitle": { "description": "Short application description." }, diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 0a469f2..51387cb 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -29,7 +29,7 @@ "@appTitle": { "description": "Application name." }, - "appSubtitle": "Editor di documentazione Markdown compatibile con Writerside.", + "appSubtitle": "Editor per file Markdown e progetti di documentazione compatibili con Writerside.", "@appSubtitle": { "description": "Short application description." }, diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 9b2b6ca..5a09a4c 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -29,7 +29,7 @@ "@appTitle": { "description": "Application name." }, - "appSubtitle": "Dokumentasjonsredigerer for Markdown og Writerside-kompatibel dokumentasjon.", + "appSubtitle": "Redigerer for Markdown-filer og Writerside-kompatible dokumentasjonsprosjekter.", "@appSubtitle": { "description": "Short application description." }, diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 634a0c1..b85c965 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -29,7 +29,7 @@ "@appTitle": { "description": "Application name." }, - "appSubtitle": "Edytor dokumentacji Markdown zgodny z Writerside.", + "appSubtitle": "Edytor plików Markdown i projektów dokumentacji zgodnych z Writerside.", "@appSubtitle": { "description": "Short application description." }, diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index bbd2e6e..df5ea2e 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -29,7 +29,7 @@ "@appTitle": { "description": "Application name." }, - "appSubtitle": "Editor de documentação compatível com Markdown e Writerside.", + "appSubtitle": "Editor de arquivos Markdown e projetos de documentação compatíveis com o Writerside.", "@appSubtitle": { "description": "Short application description." }, diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2edf18d..5ec9fca 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -29,7 +29,7 @@ "@appTitle": { "description": "Application name." }, - "appSubtitle": "Редактор Markdown-документации и проектов, совместимых с Writerside.", + "appSubtitle": "Редактор файлов Markdown и проектов документации, совместимых с Writerside.", "@appSubtitle": { "description": "Short application description." }, diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 96034e2..fe36fd2 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -29,7 +29,7 @@ "@appTitle": { "description": "Application name." }, - "appSubtitle": "Редактор документації Markdown, сумісний із Writerside.", + "appSubtitle": "Редактор файлів Markdown і проєктів документації, сумісних із Writerside.", "@appSubtitle": { "description": "Short application description." }, diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 0507db3..ceeaeb5 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -132,7 +132,7 @@ abstract class AppLocalizations { /// Short application description. /// /// In en, this message translates to: - /// **'Markdown and Writerside-compatible documentation editor.'** + /// **'Editor for Markdown files and Writerside-compatible documentation projects.'** String get appSubtitle; /// Menu item and tooltip for the About dialog. diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 80844fd..e8a7e57 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -14,7 +14,8 @@ class AppLocalizationsAr extends AppLocalizations { String get appTitle => 'BusyMark'; @override - String get appSubtitle => 'محرر وثائق متوافق مع Markdown وWriterside.'; + String get appSubtitle => + 'محرر لملفات Markdown ومشاريع التوثيق المتوافقة مع Writerside.'; @override String get aboutBusyMark => 'حول BusyMark'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 209790e..7800990 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -15,7 +15,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get appSubtitle => - 'Markdown- und Writerside-kompatibler Dokumentationseditor.'; + 'Editor für Markdown-Dateien und Writerside-kompatible Dokumentationsprojekte.'; @override String get aboutBusyMark => 'Über BusyMark'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 1b24275..fac59c4 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -15,7 +15,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appSubtitle => - 'Markdown and Writerside-compatible documentation editor.'; + 'Editor for Markdown files and Writerside-compatible documentation projects.'; @override String get aboutBusyMark => 'About BusyMark'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 15797e0..107049e 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -15,7 +15,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get appSubtitle => - 'Editor de documentación para Markdown y proyectos compatibles con Writerside.'; + 'Editor de archivos Markdown y proyectos de documentación compatibles con Writerside.'; @override String get aboutBusyMark => 'Acerca de BusyMark'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 5d7aa8e..2099d7e 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -15,7 +15,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String get appSubtitle => - 'ویرایشگر مستندات Markdown و پروژه‌های سازگار با Writerside.'; + 'ویرایشگر فایل‌های Markdown و پروژه‌های مستندسازی سازگار با Writerside.'; @override String get aboutBusyMark => 'دربارهٔ BusyMark'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index b019881..6216c48 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -15,7 +15,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get appSubtitle => - 'Éditeur de documentation Markdown compatible avec Writerside.'; + 'Éditeur de fichiers Markdown et de projets de documentation compatibles avec Writerside.'; @override String get aboutBusyMark => 'À propos de BusyMark'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 9056c32..207f909 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -14,7 +14,8 @@ class AppLocalizationsHi extends AppLocalizations { String get appTitle => 'BusyMark'; @override - String get appSubtitle => 'Markdown और Writerside-संगत दस्तावेज़ीकरण संपादक।'; + String get appSubtitle => + 'Markdown फ़ाइलों और Writerside-संगत दस्तावेज़ीकरण परियोजनाओं का संपादक।'; @override String get aboutBusyMark => 'BusyMark के बारे में'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 30933d5..62717cf 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -15,7 +15,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get appSubtitle => - 'Editor di documentazione Markdown compatibile con Writerside.'; + 'Editor per file Markdown e progetti di documentazione compatibili con Writerside.'; @override String get aboutBusyMark => 'Informazioni su BusyMark'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 1826ccb..fa2ff3c 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -15,7 +15,7 @@ class AppLocalizationsNb extends AppLocalizations { @override String get appSubtitle => - 'Dokumentasjonsredigerer for Markdown og Writerside-kompatibel dokumentasjon.'; + 'Redigerer for Markdown-filer og Writerside-kompatible dokumentasjonsprosjekter.'; @override String get aboutBusyMark => 'Om BusyMark'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index f60c288..13ebdf3 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -14,7 +14,8 @@ class AppLocalizationsPl extends AppLocalizations { String get appTitle => 'BusyMark'; @override - String get appSubtitle => 'Edytor dokumentacji Markdown zgodny z Writerside.'; + String get appSubtitle => + 'Edytor plików Markdown i projektów dokumentacji zgodnych z Writerside.'; @override String get aboutBusyMark => 'O aplikacji BusyMark'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 50a5570..0c92726 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -15,7 +15,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get appSubtitle => - 'Editor de documentação compatível com Markdown e Writerside.'; + 'Editor de arquivos Markdown e projetos de documentação compatíveis com o Writerside.'; @override String get aboutBusyMark => 'Sobre BusyMark'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index c46a3a2..7727ddc 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -15,7 +15,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appSubtitle => - 'Редактор Markdown-документации и проектов, совместимых с Writerside.'; + 'Редактор файлов Markdown и проектов документации, совместимых с Writerside.'; @override String get aboutBusyMark => 'О приложении BusyMark'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 91042c2..7aa6963 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -15,7 +15,7 @@ class AppLocalizationsUk extends AppLocalizations { @override String get appSubtitle => - 'Редактор документації Markdown, сумісний із Writerside.'; + 'Редактор файлів Markdown і проєктів документації, сумісних із Writerside.'; @override String get aboutBusyMark => 'Про BusyMark'; diff --git a/lib/src/app/app_theme.dart b/lib/src/app/app_theme.dart index f031c0d..336fef2 100644 --- a/lib/src/app/app_theme.dart +++ b/lib/src/app/app_theme.dart @@ -113,6 +113,7 @@ ThemeData buildBusyMarkTheme({ side: const WidgetStatePropertyAll(BorderSide.none), ); final popoverSurfaceSide = BorderSide(color: colors.floatingBorder); + final dialogSurfaceSide = BorderSide(color: colors.dialogOutline); final menuStyle = _semanticMenuSurfaceStyle( base.menuTheme.style, color: colors.popover, @@ -158,6 +159,8 @@ ThemeData buildBusyMarkTheme({ dialogTheme: base.dialogTheme.copyWith( backgroundColor: colors.dialog, surfaceTintColor: colors.dialog, + shadowColor: colorScheme.shadow, + shape: _withOutlineSide(base.dialogTheme.shape, dialogSurfaceSide), titleTextStyle: textTheme.titleLarge, contentTextStyle: textTheme.bodyMedium, ), diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index f433123..13221d7 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -118,7 +118,6 @@ abstract final class BusyMarkStroke { } abstract final class BusyMarkAlpha { - static const double modalBarrier = 0.32; static const double textSelection = 0.32; static const double sourceCollapsedLine = 0.045; static const double sourceCursor = 0.82; @@ -187,12 +186,12 @@ String busyMarkBidiIsolateFor(BuildContext context, Object value) { } abstract final class BusyMarkMotion { - static const Duration modalPadding = Duration(milliseconds: 100); + static const Duration dialogInsets = Duration(milliseconds: 160); static const Duration sidebarExpand = Duration(milliseconds: 120); static const Duration scroll = Duration(milliseconds: 180); static const Duration previewSearchDelay = Duration(milliseconds: 80); static const Duration tooltipWait = Duration(milliseconds: 450); - static const Curve modalPaddingCurve = Curves.decelerate; + static const Curve dialogInsetsCurve = Curves.easeOutCubic; } abstract final class BusyMarkInsets { @@ -549,8 +548,9 @@ BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { border: const Color.fromRGBO(0, 0, 0, 0.18), subtleBorder: const Color.fromRGBO(0, 0, 0, 0.10), divider: const Color.fromRGBO(0, 0, 0, 0.10), - // Installed libadwaita uses the same subtle black perimeter in either - // brightness mode; opacity is part of the semantic role. + // Dialogs use libadwaita's restrained inside highlight. This is + // intentionally distinct from the darker popover perimeter. + dialogOutline: const Color.fromRGBO(255, 255, 255, 0.07), floatingBorder: const Color.fromRGBO(0, 0, 0, 0.14), sidebarBorder: sidebarBorder, shade: const Color.fromRGBO(0, 0, 0, 0.07), @@ -581,6 +581,7 @@ BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { border: const Color.fromRGBO(0, 0, 0, 0.75), subtleBorder: const Color.fromRGBO(255, 255, 255, 0.10), divider: const Color.fromRGBO(255, 255, 255, 0.10), + dialogOutline: const Color.fromRGBO(255, 255, 255, 0.07), floatingBorder: const Color.fromRGBO(0, 0, 0, 0.14), sidebarBorder: sidebarBorder, shade: const Color.fromRGBO(0, 0, 0, 0.25), @@ -616,6 +617,7 @@ class BusyMarkSurfaceColors extends ThemeExtension { required this.border, required this.subtleBorder, required this.divider, + required this.dialogOutline, required this.floatingBorder, required this.sidebarBorder, required this.shade, @@ -656,6 +658,7 @@ class BusyMarkSurfaceColors extends ThemeExtension { final Color border; final Color subtleBorder; final Color divider; + final Color dialogOutline; final Color floatingBorder; final Color sidebarBorder; final Color shade; @@ -687,6 +690,7 @@ class BusyMarkSurfaceColors extends ThemeExtension { Color? border, Color? subtleBorder, Color? divider, + Color? dialogOutline, Color? floatingBorder, Color? sidebarBorder, Color? shade, @@ -717,6 +721,7 @@ class BusyMarkSurfaceColors extends ThemeExtension { border: border ?? this.border, subtleBorder: subtleBorder ?? this.subtleBorder, divider: divider ?? this.divider, + dialogOutline: dialogOutline ?? this.dialogOutline, floatingBorder: floatingBorder ?? this.floatingBorder, sidebarBorder: sidebarBorder ?? this.sidebarBorder, shade: shade ?? this.shade, @@ -762,6 +767,7 @@ class BusyMarkSurfaceColors extends ThemeExtension { border: Color.lerp(border, other.border, t)!, subtleBorder: Color.lerp(subtleBorder, other.subtleBorder, t)!, divider: Color.lerp(divider, other.divider, t)!, + dialogOutline: Color.lerp(dialogOutline, other.dialogOutline, t)!, floatingBorder: Color.lerp(floatingBorder, other.floatingBorder, t)!, sidebarBorder: Color.lerp(sidebarBorder, other.sidebarBorder, t)!, shade: Color.lerp(shade, other.shade, t)!, @@ -1683,12 +1689,65 @@ class BusyMarkStatusBox extends StatelessWidget { } } +Color busyMarkDialogSurfaceColor(BuildContext context) { + return DialogTheme.of(context).backgroundColor ?? + BusyMarkSurfaceColors.of(context).dialog; +} + +class BusyMarkDialogTitleBar extends StatelessWidget { + const BusyMarkDialogTitleBar({ + super.key, + this.title, + this.centerTitle = true, + this.closeSemanticLabel, + this.closable = true, + this.showDividerInHighContrast = true, + }); + + final Widget? title; + final bool centerTitle; + final String? closeSemanticLabel; + final bool closable; + final bool showDividerInHighContrast; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = BusyMarkSurfaceColors.of(context); + final dialogSurface = busyMarkDialogSurfaceColor(context); + return Theme( + data: theme.copyWith( + appBarTheme: theme.appBarTheme.copyWith( + backgroundColor: dialogSurface, + surfaceTintColor: dialogSurface, + shadowColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + ), + ), + child: YaruDialogTitleBar( + title: title, + centerTitle: centerTitle, + isClosable: closable, + isActive: true, + backgroundColor: dialogSurface, + border: showDividerInHighContrast && theme.colorScheme.isHighContrast + ? BorderSide(color: colors.divider) + : BorderSide.none, + closeSemanticLabel: closeSemanticLabel, + heroTag: null, + ), + ); + } +} + class BusyMarkDialogShell extends StatelessWidget { const BusyMarkDialogShell({ super.key, required this.title, required this.children, this.maxWidth = BusyMarkSizes.dialog, + this.header, this.actions = const [], this.closable = true, }); @@ -1696,47 +1755,57 @@ class BusyMarkDialogShell extends StatelessWidget { final String title; final List children; final double maxWidth; + final Widget? header; final List actions; final bool closable; @override Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxWidth), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - YaruDialogTitleBar( - title: Text(title), - isClosable: closable, - centerTitle: true, - backgroundColor: colors.dialog, - border: BorderSide.none, - ), - Flexible( - child: SingleChildScrollView( - padding: const EdgeInsets.all(BusyMarkSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, + final dialogSurface = busyMarkDialogSurfaceColor(context); + return Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: title, + child: Dialog( + backgroundColor: dialogSurface, + surfaceTintColor: dialogSurface, + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header ?? + BusyMarkDialogTitleBar( + title: Text(title), + closable: closable, + ), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(BusyMarkSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), ), - ), + if (actions.isNotEmpty) + Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.lg), + child: OverflowBar( + alignment: MainAxisAlignment.end, + overflowAlignment: OverflowBarAlignment.end, + spacing: BusyMarkSpacing.sm, + overflowSpacing: BusyMarkSpacing.sm, + children: actions, + ), + ), + ], ), - if (actions.isNotEmpty) - Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.lg), - child: OverflowBar( - alignment: MainAxisAlignment.end, - overflowAlignment: OverflowBarAlignment.end, - spacing: BusyMarkSpacing.sm, - overflowSpacing: BusyMarkSpacing.sm, - children: actions, - ), - ), - ], + ), ), ); } diff --git a/lib/src/app/busymark_dialog_identity.dart b/lib/src/app/busymark_dialog_identity.dart new file mode 100644 index 0000000..3b56baa --- /dev/null +++ b/lib/src/app/busymark_dialog_identity.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +import 'busymark_design.dart'; + +/// Native Yaru chrome shared by BusyMark's informational dialogs. +/// +/// The title bar stays outside the scroll viewport so its close control remains +/// fixed while long reference content scrolls independently. +class BusyMarkInformationalDialog extends StatelessWidget { + const BusyMarkInformationalDialog({ + required this.closeLabel, + required this.maxWidth, + required this.child, + this.maxHeight, + super.key, + }); + + final String closeLabel; + final double maxWidth; + final double? maxHeight; + final Widget child; + + @override + Widget build(BuildContext context) { + final dialogSurface = busyMarkDialogSurfaceColor(context); + return Dialog( + backgroundColor: dialogSurface, + surfaceTintColor: dialogSurface, + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: maxHeight ?? double.infinity, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + BusyMarkDialogTitleBar(closeSemanticLabel: closeLabel), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(BusyMarkSpacing.lg), + child: child, + ), + ), + ], + ), + ), + ); + } +} + +/// Shared application identity treatment for informational dialogs. +class BusyMarkDialogIdentity extends StatelessWidget { + const BusyMarkDialogIdentity({ + required this.visual, + required this.title, + super.key, + }); + + static const visualExtent = 128.0; + static const titleWeight = FontWeight.bold; + + final Widget visual; + final String title; + + @override + Widget build(BuildContext context) { + final titleStyle = + Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: titleWeight) ?? + const TextStyle(fontWeight: titleWeight); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: BusyMarkSpacing.md), + child: SizedBox.square(dimension: visualExtent, child: visual), + ), + ), + const SizedBox(height: BusyMarkSpacing.md), + Text(title, textAlign: TextAlign.center, style: titleStyle), + ], + ); + } +} diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index fe19eb9..b0dafe0 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -1,11 +1,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:url_launcher/url_launcher.dart'; import '../platform/linux_header_bar_service.dart'; import 'app_metadata.dart'; +import 'busymark_dialog_identity.dart'; import 'busymark_shortcuts.dart'; import 'busymark_design.dart'; import 'busymark_glyphs.dart'; @@ -36,125 +38,168 @@ final _busyMarkModalShortcuts = { shortcut.activator: const DoNothingAndStopPropagationIntent(), }; +/// Prevents application navigation shortcuts from escaping a modal surface. +/// +/// Use this around modal UI that is not presented by +/// [showBusyMarkModalDialog], such as an in-page editor overlay. +class BusyMarkModalShortcutBoundary extends StatelessWidget { + const BusyMarkModalShortcutBoundary({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Shortcuts(shortcuts: _busyMarkModalShortcuts, child: child); + } +} + +final _busyMarkModalDepths = Map.identity(); +final _busyMarkModalBarrierUpdateTails = + Map>.identity(); + Color busyMarkModalBarrierColor(BuildContext context) { - return Theme.of( - context, - ).colorScheme.scrim.withValues(alpha: BusyMarkAlpha.modalBarrier); + return BusyMarkSurfaceColors.of(context).shade; } Future showBusyMarkModalDialog( BuildContext context, { required WidgetBuilder builder, LinuxHeaderBarService? headerBarService, + Color? barrierColor, bool barrierDismissible = true, }) async { - final barrierColor = busyMarkModalBarrierColor(context); final effectiveHeaderBarService = - headerBarService ?? LinuxHeaderBarService.instance; - final coordinateNativeBarrier = effectiveHeaderBarService.isAvailable; + headerBarService ?? _busyMarkHeaderBarServiceFrom(context); + return _coordinateBusyMarkModal( + context, + headerBarService: effectiveHeaderBarService, + showSurface: () => _showBusyMarkFlutterDialog( + context, + builder: builder, + barrierColor: barrierColor, + barrierDismissible: barrierDismissible, + ), + ); +} + +Future _coordinateBusyMarkModal( + BuildContext context, { + required LinuxHeaderBarService? headerBarService, + required Future Function() showSurface, +}) async { final previousFocus = FocusManager.instance.primaryFocus; - if (coordinateNativeBarrier) { - await _BusyMarkModalBarrierCoordinator.acquire(effectiveHeaderBarService); - } + await acquireBusyMarkModalBarrier(headerBarService); if (!context.mounted) { - if (coordinateNativeBarrier) { - await _BusyMarkModalBarrierCoordinator.release(effectiveHeaderBarService); - } + await releaseBusyMarkModalBarrier(headerBarService); return null; } try { - return await showDialog( - context: context, - barrierColor: barrierColor, - barrierDismissible: barrierDismissible, - traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, - builder: (dialogContext) { - final viewInsets = MediaQuery.viewInsetsOf(dialogContext); - final padding = EdgeInsets.fromLTRB( - viewInsets.left + BusyMarkSizes.modalHorizontalInset, - viewInsets.top + BusyMarkSizes.modalVerticalInset, - viewInsets.right + BusyMarkSizes.modalHorizontalInset, - viewInsets.bottom + BusyMarkSizes.modalVerticalInset, - ); - return Shortcuts( - shortcuts: _busyMarkModalShortcuts, - child: AnimatedPadding( - padding: padding, - duration: BusyMarkMotion.modalPadding, - curve: BusyMarkMotion.modalPaddingCurve, - child: BusyMarkModalEditorSurface(child: builder(dialogContext)), - ), - ); - }, - ); + return await showSurface(); } finally { - if (coordinateNativeBarrier) { - await _BusyMarkModalBarrierCoordinator.release(effectiveHeaderBarService); - } + await releaseBusyMarkModalBarrier(headerBarService); if (previousFocus?.context != null && previousFocus!.canRequestFocus) { previousFocus.requestFocus(); } } } -class _BusyMarkModalBarrierCoordinator { - const _BusyMarkModalBarrierCoordinator._(); - - static final _activeDialogs = Map.identity(); - static final _pendingUpdates = - Map>.identity(); +Future _showBusyMarkFlutterDialog( + BuildContext context, { + required WidgetBuilder builder, + Color? barrierColor, + bool barrierDismissible = true, +}) { + return showDialog( + context: context, + barrierColor: barrierColor ?? busyMarkModalBarrierColor(context), + barrierDismissible: barrierDismissible, + traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, + builder: (dialogContext) => + BusyMarkModalShortcutBoundary(child: builder(dialogContext)), + ); +} - static Future acquire(LinuxHeaderBarService service) async { - final depth = _activeDialogs[service] ?? 0; - final nextDepth = depth + 1; - _activeDialogs[service] = nextDepth; - final depthUpdate = _enqueueUpdate(service, depth: nextDepth); - try { - await depthUpdate; - } on Object catch (error, stackTrace) { - final remainingDepth = (_activeDialogs[service] ?? 0) - 1; - if (remainingDepth > 0) { - _activeDialogs[service] = remainingDepth; - } else { - _activeDialogs.remove(service); - try { - await _enqueueUpdate(service, depth: 0); - } on Object { - // Preserve the acquisition failure if its best-effort rollback fails. - } +/// Acquires a reference-counted native header-bar modal barrier. +/// +/// Every call must be paired with [releaseBusyMarkModalBarrier]. Route +/// dialogs acquire it automatically. +Future acquireBusyMarkModalBarrier(LinuxHeaderBarService? service) async { + if (service == null) { + return; + } + final depth = _busyMarkModalDepths[service] ?? 0; + final nextDepth = depth + 1; + _busyMarkModalDepths[service] = nextDepth; + final depthUpdate = _enqueueBusyMarkModalBarrierUpdate( + service, + depth: nextDepth, + ); + try { + await depthUpdate; + } on Object catch (error, stackTrace) { + final remainingDepth = (_busyMarkModalDepths[service] ?? 0) - 1; + if (remainingDepth > 0) { + _busyMarkModalDepths[service] = remainingDepth; + } else { + _busyMarkModalDepths.remove(service); + try { + await _enqueueBusyMarkModalBarrierUpdate(service, depth: 0); + } on Object { + // Preserve the acquisition failure if its best-effort rollback fails. } - Error.throwWithStackTrace(error, stackTrace); } + Error.throwWithStackTrace(error, stackTrace); } +} - static Future release(LinuxHeaderBarService service) async { - final depth = _activeDialogs[service] ?? 0; - if (depth <= 1) { - _activeDialogs.remove(service); - await _enqueueUpdate(service, depth: 0); - return; - } - final nextDepth = depth - 1; - _activeDialogs[service] = nextDepth; - await _enqueueUpdate(service, depth: nextDepth); +/// Releases a barrier acquired by [acquireBusyMarkModalBarrier]. +Future releaseBusyMarkModalBarrier(LinuxHeaderBarService? service) async { + if (service == null) { + return; + } + final depth = _busyMarkModalDepths[service] ?? 0; + if (depth <= 1) { + _busyMarkModalDepths.remove(service); + await _enqueueBusyMarkModalBarrierUpdate(service, depth: 0); + return; } + final nextDepth = depth - 1; + _busyMarkModalDepths[service] = nextDepth; + await _enqueueBusyMarkModalBarrierUpdate(service, depth: nextDepth); +} - static Future _enqueueUpdate( - LinuxHeaderBarService service, { - required int depth, - }) async { - final previous = _pendingUpdates[service] ?? Future.value(); - final update = previous - .catchError((Object _) {}) - .then((_) => service.setModalBarrierDepth(depth)); - _pendingUpdates[service] = update; - try { - await update; - } finally { - if (identical(_pendingUpdates[service], update)) { - _pendingUpdates.remove(service); +Future _enqueueBusyMarkModalBarrierUpdate( + LinuxHeaderBarService service, { + required int depth, +}) { + final previous = + _busyMarkModalBarrierUpdateTails[service] ?? Future.value(); + final ready = previous.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + late final Future update; + update = ready.then((_) => service.setModalBarrierDepth(depth)).whenComplete( + () { + if (identical(_busyMarkModalBarrierUpdateTails[service], update)) { + _busyMarkModalBarrierUpdateTails.remove(service); } - } + }, + ); + _busyMarkModalBarrierUpdateTails[service] = update; + return update; +} + +LinuxHeaderBarService? _busyMarkHeaderBarServiceFrom(BuildContext context) { + try { + return ProviderScope.containerOf( + context, + listen: false, + ).read(linuxHeaderBarServiceProvider); + } on StateError { + // Standalone widget hosts may not install Riverpod. Explicit injection + // remains available for those hosts. + return null; } } @@ -162,25 +207,48 @@ class BusyMarkModalEditorSurface extends StatelessWidget { const BusyMarkModalEditorSurface({ super.key, required this.child, - this.maxWidth = BusyMarkSizes.modalMaxWidth, + this.minWidth = 0, + this.maxWidth = 700, this.maxHeight, + this.insetPadding = EdgeInsets.zero, }); final Widget child; + final double minWidth; final double maxWidth; final double? maxHeight; + final EdgeInsets insetPadding; @override Widget build(BuildContext context) { + final editorSurface = Theme.of(context).scaffoldBackgroundColor; + final effectiveMaxWidth = maxWidth.isFinite + ? maxWidth.clamp(0.0, double.infinity).toDouble() + : maxWidth; + final effectiveMinWidth = minWidth + .clamp( + 0.0, + effectiveMaxWidth.isFinite ? effectiveMaxWidth : double.infinity, + ) + .toDouble(); + final effectiveMaxHeight = maxHeight == null + ? double.infinity + : maxHeight!.clamp(0.0, double.infinity).toDouble(); + return Dialog( - insetPadding: EdgeInsets.zero, + backgroundColor: editorSurface, + surfaceTintColor: editorSurface, + insetPadding: insetPadding, + insetAnimationDuration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : BusyMarkMotion.dialogInsets, + insetAnimationCurve: BusyMarkMotion.dialogInsetsCurve, + clipBehavior: Clip.antiAlias, child: ConstrainedBox( constraints: BoxConstraints( - maxWidth: maxWidth, - maxHeight: - maxHeight ?? - MediaQuery.sizeOf(context).height * - BusyMarkSizes.modalMaxHeightFraction, + minWidth: effectiveMinWidth, + maxWidth: effectiveMaxWidth, + maxHeight: effectiveMaxHeight, ), child: child, ), @@ -219,7 +287,8 @@ void showBusyMarkKeyboardShortcutsDialog(BuildContext context) { headerBarService: headerBar.isAvailable ? headerBar : null, builder: (context) => _BusyMarkInfoDialog( title: context.l10n.keyboardShortcuts, - maxWidth: BusyMarkSizes.dialogNarrow, + icon: BusyMarkGlyphs.keyboard, + maxWidth: 460, children: [ BusyMarkGroupedList( title: context.l10n.shortcutGroupGeneral, @@ -762,6 +831,7 @@ void showBusyMarkMarkdownHtmlDialog(BuildContext context) { headerBarService: headerBar.isAvailable ? headerBar : null, builder: (context) => _BusyMarkInfoDialog( title: context.l10n.markdownAndHtml, + icon: BusyMarkGlyphs.markdownFile, maxWidth: BusyMarkSizes.dialogWide, children: [ BusyMarkGroupedList( @@ -1009,20 +1079,39 @@ class _ReferenceRow extends StatelessWidget { class _BusyMarkInfoDialog extends StatelessWidget { const _BusyMarkInfoDialog({ required this.title, + required this.icon, required this.children, - this.maxWidth = 420, + this.maxWidth = 460, }); final String title; + final IconData icon; final List children; final double maxWidth; @override Widget build(BuildContext context) { - return BusyMarkDialogShell( - title: title, + final colorScheme = Theme.of(context).colorScheme; + return BusyMarkInformationalDialog( + closeLabel: context.l10n.close, maxWidth: maxWidth, - children: children, + maxHeight: 560, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BusyMarkDialogIdentity( + visual: Icon( + icon, + size: BusyMarkDialogIdentity.visualExtent, + color: colorScheme.primary, + ), + title: title, + ), + const SizedBox(height: BusyMarkSpacing.lg), + ...children, + ], + ), ); } } @@ -1034,56 +1123,56 @@ class _BusyMarkAboutDialog extends StatelessWidget { Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); final textTheme = Theme.of(context).textTheme; - return BusyMarkDialogShell( - title: context.l10n.aboutBusyMark, - maxWidth: BusyMarkSizes.dialogCompact, - children: [ - _BusyMarkAboutLogo(label: context.l10n.appTitle), - const SizedBox(height: BusyMarkSpacing.xs), - Text( - context.l10n.appTitle, - textAlign: TextAlign.center, - style: textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - color: colors.foreground, + return BusyMarkInformationalDialog( + closeLabel: context.l10n.close, + maxWidth: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BusyMarkDialogIdentity( + visual: _BusyMarkAboutLogo(label: context.l10n.appTitle), + title: context.l10n.appTitle, ), - ), - const SizedBox(height: BusyMarkSpacing.xs), - Text( - context.l10n.aboutTagline, - textAlign: TextAlign.center, - style: textTheme.bodyMedium?.copyWith(color: colors.mutedForeground), - ), - const SizedBox(height: BusyMarkSpacing.sm), - const _AboutVersionTag(version: busyMarkAppVersion), - const SizedBox(height: BusyMarkSpacing.md), - BusyMarkGroupedList( - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.aboutLicenseLabel, - subtitle: context.l10n.aboutLicenseName, - leading: const Icon(BusyMarkGlyphs.info), - trailing: const Icon(BusyMarkGlyphs.externalLink), - onTap: () => unawaited(_openApacheLicense()), - ), - BusyMarkActionRow( - title: context.l10n.aboutWebsite, - subtitle: _busyMarkWebsiteUrl, - leading: const Icon(BusyMarkGlyphs.home), - trailing: const Icon(BusyMarkGlyphs.externalLink), - onTap: () => unawaited(_openBusyMarkWebsite()), - ), - BusyMarkActionRow( - title: context.l10n.aboutSourceCode, - subtitle: _busyMarkRepositoryUrl, - leading: const Icon(BusyMarkGlyphs.code), - trailing: const Icon(BusyMarkGlyphs.externalLink), - onTap: () => unawaited(_openBusyMarkRepository()), + const SizedBox(height: BusyMarkSpacing.xs), + Text( + context.l10n.aboutTagline, + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith( + color: colors.mutedForeground, ), - ], - ), - ], + ), + const SizedBox(height: BusyMarkSpacing.sm), + const _AboutVersionTag(version: busyMarkAppVersion), + const SizedBox(height: BusyMarkSpacing.md), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkActionRow( + title: context.l10n.aboutLicenseLabel, + subtitle: context.l10n.aboutLicenseName, + leading: const Icon(BusyMarkGlyphs.info), + trailing: const Icon(BusyMarkGlyphs.externalLink), + onTap: () => unawaited(_openApacheLicense()), + ), + BusyMarkActionRow( + title: context.l10n.aboutWebsite, + subtitle: _busyMarkWebsiteUrl, + leading: const Icon(BusyMarkGlyphs.home), + trailing: const Icon(BusyMarkGlyphs.externalLink), + onTap: () => unawaited(_openBusyMarkWebsite()), + ), + BusyMarkActionRow( + title: context.l10n.aboutSourceCode, + subtitle: _busyMarkRepositoryUrl, + leading: const Icon(BusyMarkGlyphs.code), + trailing: const Icon(BusyMarkGlyphs.externalLink), + onTap: () => unawaited(_openBusyMarkRepository()), + ), + ], + ), + ], + ), ); } } @@ -1101,7 +1190,7 @@ class _BusyMarkAboutLogo extends StatelessWidget { label: label, child: ExcludeSemantics( child: SizedBox.square( - dimension: BusyMarkSizes.aboutLogoViewport, + dimension: BusyMarkDialogIdentity.visualExtent, child: SvgPicture.asset(_busyMarkLogoAsset, fit: BoxFit.contain), ), ), diff --git a/lib/src/platform/header_bar_configuration.dart b/lib/src/platform/header_bar_configuration.dart index fafb93b..92821fc 100644 --- a/lib/src/platform/header_bar_configuration.dart +++ b/lib/src/platform/header_bar_configuration.dart @@ -111,16 +111,13 @@ class HeaderBarTheme { factory HeaderBarTheme.fromContext(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); - final barrier = Theme.of( - context, - ).colorScheme.scrim.withValues(alpha: BusyMarkAlpha.modalBarrier); return HeaderBarTheme( preferDark: Theme.of(context).brightness == Brightness.dark, backgroundColor: colors.view, sidebarBackgroundColor: colors.sidebar, foregroundColor: colors.foreground, sidebarBorderColor: colors.sidebarBorder, - modalBarrierColor: barrier, + modalBarrierColor: colors.shade, ); } diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 3024aa3..5d6056f 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -214,6 +214,9 @@ class LinuxHeaderBarService extends ChangeNotifier { } Future setModalBarrierDepth(int value) async { + if (!_available) { + return; + } final depth = value < 0 ? 0 : value; final hasPublishedConfiguration = configurationSynchronizer.desiredConfiguration != null; diff --git a/linux/io.busystack.busymark.desktop b/linux/io.busystack.busymark.desktop index 402b75e..b21b756 100644 --- a/linux/io.busystack.busymark.desktop +++ b/linux/io.busystack.busymark.desktop @@ -1,33 +1,33 @@ [Desktop Entry] Type=Application Name=BusyMark -Comment=Markdown and Writerside-compatible documentation editor +Comment=Editor for Markdown files and Writerside-compatible documentation projects Name[de]=BusyMark -Comment[de]=Dokumentationseditor für Markdown und Writerside-kompatible Projekte +Comment[de]=Editor für Markdown-Dateien und Writerside-kompatible Dokumentationsprojekte Name[et]=BusyMark Comment[et]=Markdowni failide ja Writerside’iga ühilduvate dokumentatsiooniprojektide redaktor Name[it]=BusyMark -Comment[it]=Editor per Markdown e progetti di documentazione compatibili con Writerside +Comment[it]=Editor per file Markdown e progetti di documentazione compatibili con Writerside Name[nb]=BusyMark -Comment[nb]=Dokumentasjonsredigerer for Markdown og Writerside-kompatible prosjekter +Comment[nb]=Redigerer for Markdown-filer og Writerside-kompatible dokumentasjonsprosjekter Name[fr]=BusyMark -Comment[fr]=Éditeur pour Markdown et les projets de documentation compatibles avec Writerside +Comment[fr]=Éditeur de fichiers Markdown et de projets de documentation compatibles avec Writerside Name[ru]=BusyMark -Comment[ru]=Редактор Markdown-документации и проектов, совместимых с Writerside +Comment[ru]=Редактор файлов Markdown и проектов документации, совместимых с Writerside Name[uk]=BusyMark -Comment[uk]=Редактор документації Markdown і проєктів, сумісних із Writerside +Comment[uk]=Редактор файлів Markdown і проєктів документації, сумісних із Writerside Name[pl]=BusyMark -Comment[pl]=Edytor dokumentacji Markdown i projektów zgodnych z Writerside +Comment[pl]=Edytor plików Markdown i projektów dokumentacji zgodnych z Writerside Name[es]=BusyMark -Comment[es]=Editor de documentación para Markdown y proyectos compatibles con Writerside +Comment[es]=Editor de archivos Markdown y proyectos de documentación compatibles con Writerside Name[pt]=BusyMark -Comment[pt]=Editor de documentação para Markdown e projetos compatíveis com o Writerside +Comment[pt]=Editor de arquivos Markdown e projetos de documentação compatíveis com o Writerside Name[ar]=BusyMark -Comment[ar]=محرر وثائق Markdown ومشاريع توثيق متوافقة مع Writerside +Comment[ar]=محرر لملفات Markdown ومشاريع التوثيق المتوافقة مع Writerside Name[fa]=BusyMark -Comment[fa]=ویرایشگر مستندات Markdown و پروژه‌های سازگار با Writerside +Comment[fa]=ویرایشگر فایل‌های Markdown و پروژه‌های مستندسازی سازگار با Writerside Name[hi]=BusyMark -Comment[hi]=Markdown और Writerside-संगत दस्तावेज़ीकरण प्रोजेक्टों का संपादक +Comment[hi]=Markdown फ़ाइलों और Writerside-संगत दस्तावेज़ीकरण परियोजनाओं का संपादक Exec=busymark %f Icon=io.busystack.busymark Terminal=false diff --git a/linux/io.busystack.busymark.metainfo.xml b/linux/io.busystack.busymark.metainfo.xml index a2cb061..27d7c1d 100644 --- a/linux/io.busystack.busymark.metainfo.xml +++ b/linux/io.busystack.busymark.metainfo.xml @@ -20,20 +20,20 @@ BusyMark BusyMark BusyMark - Markdown and Writerside-compatible documentation editor - Dokumentationseditor für Markdown und Writerside-kompatible Projekte + Editor for Markdown files and Writerside-compatible documentation projects + Editor für Markdown-Dateien und Writerside-kompatible Dokumentationsprojekte Markdowni failide ja Writerside’iga ühilduvate dokumentatsiooniprojektide redaktor - Editor per Markdown e progetti di documentazione compatibili con Writerside - Dokumentasjonsredigerer for Markdown og Writerside-kompatible prosjekter - Éditeur pour Markdown et les projets de documentation compatibles avec Writerside - Редактор Markdown-документации и проектов, совместимых с Writerside - Редактор документації Markdown і проєктів, сумісних із Writerside - Edytor dokumentacji Markdown i projektów zgodnych z Writerside - Editor de documentación para Markdown y proyectos compatibles con Writerside - Editor de documentação para Markdown e projetos compatíveis com o Writerside - محرر وثائق Markdown ومشاريع توثيق متوافقة مع Writerside - ویرایشگر مستندات Markdown و پروژه‌های سازگار با Writerside - Markdown और Writerside-संगत दस्तावेज़ीकरण प्रोजेक्टों का संपादक + Editor per file Markdown e progetti di documentazione compatibili con Writerside + Redigerer for Markdown-filer og Writerside-kompatible dokumentasjonsprosjekter + Éditeur de fichiers Markdown et de projets de documentation compatibles avec Writerside + Редактор файлов Markdown и проектов документации, совместимых с Writerside + Редактор файлів Markdown і проєктів документації, сумісних із Writerside + Edytor plików Markdown i projektów dokumentacji zgodnych z Writerside + Editor de archivos Markdown y proyectos de documentación compatibles con Writerside + Editor de arquivos Markdown e projetos de documentação compatíveis com o Writerside + محرر لملفات Markdown ومشاريع التوثيق المتوافقة مع Writerside + ویرایشگر فایل‌های Markdown و پروژه‌های مستندسازی سازگار با Writerside + Markdown फ़ाइलों और Writerside-संगत दस्तावेज़ीकरण परियोजनाओं का संपादक

BusyMark is a Linux editor for Markdown files and Writerside-compatible documentation projects.

BusyMark ist ein Linux-Editor für Markdown-Dateien und Writerside-kompatible Dokumentationsprojekte.

diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index f93ad57..cb2eff6 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -21,6 +21,7 @@ constexpr char kDefaultHeaderbarBackground[] = "#272727"; constexpr char kDefaultSidebarBackground[] = "#393939"; constexpr char kDefaultSidebarBorder[] = "rgba(16,16,16,0.35)"; constexpr char kDefaultForeground[] = "#F7F7F7"; +constexpr char kDefaultModalBarrierColor[] = "rgba(0,0,0,0.25)"; // Yaru GTK 3 adds a zero-blur 23%/75% black ring around CSD windows. Current // Ubuntu apps retain the diffuse shadow without that legacy hard edge. Reuse // Yaru's geometry here; Handy continues to own clipping, radii, and states. @@ -466,6 +467,20 @@ static gboolean stop_modal_scrim_event(GtkWidget*, GdkEvent*, gpointer) { return GDK_EVENT_STOP; } +static void close_header_menu_button(GtkWidget* menu_button) { + if (menu_button == nullptr || !GTK_IS_MENU_BUTTON(menu_button)) { + return; + } + GtkPopover* popover = + gtk_menu_button_get_popover(GTK_MENU_BUTTON(menu_button)); + if (popover != nullptr && GTK_IS_POPOVER(popover)) { + gtk_popover_popdown(popover); + } + if (GTK_IS_TOGGLE_BUTTON(menu_button)) { + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(menu_button), FALSE); + } +} + static GtkTextDirection app_text_direction(MyApplication* self) { return self->text_direction_rtl ? GTK_TEXT_DIR_RTL : GTK_TEXT_DIR_LTR; } @@ -578,7 +593,7 @@ static void refresh_header_bar_css(MyApplication* self) { const gchar* sidebar_border = css_color_or(self->sidebar_border_color, kDefaultSidebarBorder); g_autofree gchar* modal = modal_barrier_color_for_depth( - css_color_or(self->modal_barrier_color, "rgba(0,0,0,0.32)"), + css_color_or(self->modal_barrier_color, kDefaultModalBarrierColor), self->modal_barrier_depth); const gchar* window_shadow_css = uses_legacy_yaru_window_shadow() ? kLegacyYaruWindowShadowCompatibilityCss @@ -676,6 +691,26 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: alpha(currentColor, 0.19);" "background-image: none;" "}" + // Keep native controls visually enabled beneath the modal tint while + // removing transient hover and checked surfaces. The overlay below owns + // interaction blocking. + ".busymark-titlebar.busymark-modal-open " + ".busymark-header-control," + ".busymark-titlebar.busymark-modal-open " + ".busymark-header-control:hover," + ".busymark-titlebar.busymark-modal-open " + ".busymark-header-control:active," + ".busymark-titlebar.busymark-modal-open " + ".busymark-header-control:checked," + ".busymark-titlebar.busymark-modal-open " + ".busymark-header-control:checked:hover," + ".busymark-titlebar.busymark-modal-open " + ".busymark-header-control:checked:active {" + "background-color: transparent;" + "background-image: none;" + "border-color: transparent;" + "box-shadow: none;" + "}" ".busymark-modal-scrim {" "background-color: %s;" "background-image: none;" @@ -742,7 +777,8 @@ static void focus_flutter_view(MyApplication* self) { static void invoke_header_bar_action(MyApplication* self, const gchar* action) { - if (self->header_bar_channel == nullptr || action == nullptr) { + if (self->modal_barrier_visible || + self->header_bar_channel == nullptr || action == nullptr) { return; } fl_method_channel_invoke_method(self->header_bar_channel, action, nullptr, @@ -752,7 +788,8 @@ static void invoke_header_bar_action(MyApplication* self, static void invoke_header_bar_string_action(MyApplication* self, const gchar* action, const gchar* value) { - if (self->header_bar_channel == nullptr || action == nullptr) { + if (self->modal_barrier_visible || + self->header_bar_channel == nullptr || action == nullptr) { return; } g_autoptr(FlValue) args = fl_value_new_string(value == nullptr ? "" : value); @@ -763,7 +800,8 @@ static void invoke_header_bar_string_action(MyApplication* self, static void invoke_header_bar_bool_action(MyApplication* self, const gchar* action, gboolean value) { - if (self->header_bar_channel == nullptr || action == nullptr) { + if (self->modal_barrier_visible || + self->header_bar_channel == nullptr || action == nullptr) { return; } g_autoptr(FlValue) args = fl_value_new_bool(value); @@ -842,7 +880,7 @@ static void set_search_query(MyApplication* self, const gchar* query) { static void header_button_clicked_cb(GtkWidget* widget, gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_actions) { + if (self->suppress_header_actions || self->modal_barrier_visible) { return; } const gchar* action = static_cast( @@ -863,7 +901,8 @@ static void connect_header_action(MyApplication* self, static void search_entry_changed_cb(GtkSearchEntry* entry, gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_actions || !self->search_active) { + if (self->suppress_header_actions || self->modal_barrier_visible || + !self->search_active) { return; } const gchar* query = gtk_entry_get_text(GTK_ENTRY(entry)); @@ -876,7 +915,8 @@ static void search_entry_changed_cb(GtkSearchEntry* entry, static void search_entry_activate_cb(GtkEntry* entry, gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_actions || !self->search_active) { + if (self->suppress_header_actions || self->modal_barrier_visible || + !self->search_active) { return; } invoke_header_bar_string_action(self, "searchSubmitted", @@ -887,16 +927,20 @@ static void search_entry_activate_cb(GtkEntry* entry, gpointer user_data) { static gboolean search_entry_focus_in_cb(GtkWidget*, GdkEventFocus*, gpointer user_data) { - invoke_header_bar_bool_action(MY_APPLICATION(user_data), - "searchFocusChanged", TRUE); + MyApplication* self = MY_APPLICATION(user_data); + if (!self->modal_barrier_visible) { + invoke_header_bar_bool_action(self, "searchFocusChanged", TRUE); + } return FALSE; } static gboolean search_entry_focus_out_cb(GtkWidget*, GdkEventFocus*, gpointer user_data) { - invoke_header_bar_bool_action(MY_APPLICATION(user_data), - "searchFocusChanged", FALSE); + MyApplication* self = MY_APPLICATION(user_data); + if (!self->modal_barrier_visible) { + invoke_header_bar_bool_action(self, "searchFocusChanged", FALSE); + } return FALSE; } @@ -905,7 +949,8 @@ static void search_entry_icon_release_cb(GtkEntry* entry, GdkEvent*, gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_actions || !self->search_active || + if (self->suppress_header_actions || self->modal_barrier_visible || + !self->search_active || icon_position != GTK_ENTRY_ICON_SECONDARY || gtk_entry_get_text(entry)[0] == '\0') { return; @@ -919,7 +964,8 @@ static void search_entry_icon_release_cb(GtkEntry* entry, static void search_entry_stop_search_cb(GtkSearchEntry*, gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_actions || !self->search_active) { + if (self->suppress_header_actions || self->modal_barrier_visible || + !self->search_active) { return; } invoke_header_bar_action(self, "searchEscapePressed"); @@ -1085,6 +1131,9 @@ static void header_gaction_activated_cb(GSimpleAction* action, GVariant* parameter, gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); + if (self->suppress_header_actions || self->modal_barrier_visible) { + return; + } const gchar* dart_action = static_cast( g_object_get_data(G_OBJECT(action), "busymark-dart-action")); if (dart_action == nullptr) { @@ -1097,11 +1146,14 @@ static void header_gaction_activated_cb(GSimpleAction* action, static void view_mode_gaction_activated_cb(GSimpleAction* action, GVariant* parameter, gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (self->suppress_header_actions || self->modal_barrier_visible) { + return; + } if (parameter == nullptr || !g_variant_is_of_type(parameter, G_VARIANT_TYPE_STRING)) { return; } - MyApplication* self = MY_APPLICATION(user_data); const gchar* mode = g_variant_get_string(parameter, nullptr); const gchar* dart_action = view_mode_dart_action(mode); if (dart_action == nullptr) { @@ -1266,7 +1318,23 @@ static void set_modal_barrier_depth(MyApplication* self, gint64 depth) { self->modal_barrier_depth = effective_depth; self->modal_barrier_visible = visible; refresh_header_bar_css(self); + if (self->titlebar_box != nullptr && + GTK_IS_WIDGET(self->titlebar_box)) { + GtkStyleContext* context = + gtk_widget_get_style_context(self->titlebar_box); + if (visible) { + gtk_style_context_add_class(context, "busymark-modal-open"); + } else { + gtk_style_context_remove_class(context, "busymark-modal-open"); + } + } set_widget_visible(self->modal_scrim, visible); + if (visible) { + close_header_menu_button(self->sidebar_menu_button); + close_header_menu_button(self->adaptive_menu_button); + close_header_menu_button(self->view_mode_button); + focus_flutter_view(self); + } } static void set_modal_barrier_visible(MyApplication* self, gboolean visible) { @@ -1479,40 +1547,44 @@ static GtkWidget* create_busymark_titlebar(MyApplication* self) { rebuild_main_menu_model(self, nullptr); rebuild_view_mode_menu_model(self, nullptr); - self->sidebar_header_box = - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); + self->sidebar_header_box = gtk_overlay_new(); gtk_widget_set_halign(self->sidebar_header_box, GTK_ALIGN_FILL); gtk_widget_set_hexpand(self->sidebar_header_box, FALSE); gtk_style_context_add_class(gtk_widget_get_style_context(self->sidebar_header_box), "busymark-sidebar-header"); + GtkWidget* sidebar_action_box = + gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); + gtk_widget_set_halign(sidebar_action_box, GTK_ALIGN_FILL); + gtk_widget_set_hexpand(sidebar_action_box, TRUE); + gtk_container_add(GTK_CONTAINER(self->sidebar_header_box), + sidebar_action_box); + self->sidebar_search_button = create_header_toggle_button("system-search-symbolic"); gtk_style_context_add_class(gtk_widget_get_style_context(self->sidebar_search_button), "busymark-sidebar-action-button"); connect_header_action(self, self->sidebar_search_button, "search"); - gtk_box_pack_start(GTK_BOX(self->sidebar_header_box), + gtk_box_pack_start(GTK_BOX(sidebar_action_box), self->sidebar_search_button, FALSE, FALSE, 0); - GtkWidget* sidebar_title_box = - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - gtk_widget_set_hexpand(sidebar_title_box, TRUE); - gtk_widget_set_halign(sidebar_title_box, GTK_ALIGN_CENTER); self->sidebar_title_label = gtk_label_new(kApplicationDisplayName); + gtk_widget_set_halign(self->sidebar_title_label, GTK_ALIGN_CENTER); + gtk_widget_set_valign(self->sidebar_title_label, GTK_ALIGN_CENTER); gtk_label_set_ellipsize(GTK_LABEL(self->sidebar_title_label), PANGO_ELLIPSIZE_END); gtk_style_context_add_class(gtk_widget_get_style_context(self->sidebar_title_label), GTK_STYLE_CLASS_TITLE); - gtk_box_pack_start(GTK_BOX(sidebar_title_box), self->sidebar_title_label, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(self->sidebar_header_box), sidebar_title_box, - TRUE, TRUE, 0); + gtk_overlay_add_overlay(GTK_OVERLAY(self->sidebar_header_box), + self->sidebar_title_label); + gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(self->sidebar_header_box), + self->sidebar_title_label, TRUE); self->sidebar_menu_button = create_model_menu_button( G_MENU_MODEL(self->main_menu_model), "open-menu-symbolic", &self->sidebar_menu); gtk_style_context_add_class(gtk_widget_get_style_context(self->sidebar_menu_button), "busymark-sidebar-action-button"); - gtk_box_pack_end(GTK_BOX(self->sidebar_header_box), + gtk_box_pack_end(GTK_BOX(sidebar_action_box), self->sidebar_menu_button, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(self->titlebar_box), self->sidebar_header_box, FALSE, FALSE, 0); @@ -1624,6 +1696,7 @@ static GtkWidget* create_busymark_titlebar_overlay(MyApplication* self) { // only the header backgrounds leaves descendant icons and button surfaces // above the scrim. self->modal_scrim = gtk_event_box_new(); + gtk_event_box_set_visible_window(GTK_EVENT_BOX(self->modal_scrim), TRUE); gtk_widget_set_halign(self->modal_scrim, GTK_ALIGN_FILL); gtk_widget_set_valign(self->modal_scrim, GTK_ALIGN_FILL); gtk_widget_set_hexpand(self->modal_scrim, TRUE); @@ -1632,6 +1705,7 @@ static GtkWidget* create_busymark_titlebar_overlay(MyApplication* self) { // HdyWindowHandle when the scrim becomes visible, making the header consume // the window's spare height. gtk_widget_set_vexpand(self->modal_scrim, FALSE); + gtk_widget_add_events(self->modal_scrim, GDK_ALL_EVENTS_MASK); gtk_style_context_add_class(gtk_widget_get_style_context(self->modal_scrim), "busymark-modal-scrim"); // Keep the header subtree visually enabled beneath the modal tint. The diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 1919c63..db85989 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -12,7 +12,7 @@ import 'package:busymark/src/app/app_metadata.dart'; import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/app/busymark_app.dart'; import 'package:busymark/src/app/busymark_design.dart'; -import 'package:busymark/src/app/busymark_dialogs.dart'; +import 'package:busymark/src/app/busymark_dialog_identity.dart'; import 'package:busymark/src/app/busymark_glyphs.dart'; import 'package:busymark/src/app/busymark_shortcuts.dart'; import 'package:busymark/src/app/startup_path.dart'; @@ -624,7 +624,7 @@ void main() { ), }; final shortcutRowFinder = find.descendant( - of: find.byType(BusyMarkModalEditorSurface), + of: find.byType(BusyMarkInformationalDialog), matching: find.byType(BusyMarkActionRow), ); final shortcutRows = tester diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index dce4bd0..44b21e2 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -5,6 +5,7 @@ import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/busymark_dialog_identity.dart'; import 'package:busymark/src/app/busymark_glyphs.dart'; import 'package:busymark/src/editor/document_layout.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_toolbar.dart'; @@ -16,6 +17,32 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; void main() { + testWidgets( + 'informational dialog keeps the close control at the right edge', + (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: const Color(0xFFB34CB4), + ), + home: const Scaffold( + body: BusyMarkInformationalDialog( + closeLabel: 'Close', + maxWidth: 460, + child: Text('Information'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final dialogRect = tester.getRect(find.byType(Dialog)); + final closeRect = tester.getRect(find.byType(YaruWindowControl)); + expect(closeRect.center.dx, greaterThan(dialogRect.center.dx)); + }, + ); + test('Split Preview stays fluid without copying Source-only chrome', () { const layout = BusyMarkDocumentLayoutSpec.splitPreview; @@ -417,7 +444,12 @@ void main() { ); final colors = theme.extension()!; final floatingSide = BorderSide(color: colors.floatingBorder); - expect(theme.dialogTheme.shape, base.dialogTheme.shape); + final dialogSide = BorderSide(color: colors.dialogOutline); + _expectSameGeometryWithSide( + theme.dialogTheme.shape, + base.dialogTheme.shape, + dialogSide, + ); _expectSameGeometryWithSide( theme.popupMenuTheme.shape, base.popupMenuTheme.shape, @@ -571,6 +603,7 @@ void main() { expect(light.card, isNot(light.dialog)); expect(light.groupedList, isNot(light.dialog)); expect(light.control, const Color.fromRGBO(0, 0, 0, 0.10)); + expect(light.dialogOutline, const Color.fromRGBO(255, 255, 255, 0.07)); expect(light.floatingBorder, const Color.fromRGBO(0, 0, 0, 0.14)); expect(light.controlHover, const Color.fromRGBO(0, 0, 0, 0.14)); expect(light.controlActive, const Color.fromRGBO(0, 0, 0, 0.18)); @@ -589,6 +622,7 @@ void main() { expect(dark.card, isNot(dark.dialog)); expect(dark.groupedList, isNot(dark.dialog)); expect(dark.control, const Color.fromRGBO(255, 255, 255, 0.10)); + expect(dark.dialogOutline, const Color.fromRGBO(255, 255, 255, 0.07)); expect(dark.floatingBorder, const Color.fromRGBO(0, 0, 0, 0.14)); expect(dark.controlHover, const Color.fromRGBO(255, 255, 255, 0.14)); expect(dark.controlActive, const Color.fromRGBO(255, 255, 255, 0.18)); diff --git a/test/src/busymark_dialogs_test.dart b/test/src/busymark_dialogs_test.dart index 7a8765e..11d1540 100644 --- a/test/src/busymark_dialogs_test.dart +++ b/test/src/busymark_dialogs_test.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:busymark/src/app/busymark_dialogs.dart'; import 'package:busymark/src/app/busymark_shortcuts.dart'; import 'package:busymark/src/platform/linux_header_bar_service.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -155,6 +158,134 @@ void main() { await first; expect(barrierDepths, [1, 2, 1, 0]); }); + + testWidgets('serializes rapid manual native barrier transitions', ( + tester, + ) async { + const channel = MethodChannel('com.busymark.test/serialized-modal-barrier'); + final firstUpdate = Completer(); + final transitions = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + if (call.method == 'initialize') { + return true; + } + if (call.method == 'setModalBarrierDepth') { + transitions.add(call.arguments as int); + if (transitions.length == 1) { + await firstUpdate.future; + } + } + return null; + }); + addTearDown(() { + if (!firstUpdate.isCompleted) { + firstUpdate.complete(); + } + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + }); + final headerBar = LinuxHeaderBarService(channel: channel); + addTearDown(headerBar.dispose); + await headerBar.initialize(); + + final acquire = acquireBusyMarkModalBarrier(headerBar); + await tester.pump(); + expect(transitions, [1]); + + final release = releaseBusyMarkModalBarrier(headerBar); + await tester.pump(); + expect( + transitions, + [1], + reason: 'the native hide must wait for the in-flight native show', + ); + + firstUpdate.complete(); + await Future.wait([acquire, release]); + expect(transitions, [1, 0]); + }); + + testWidgets('failed native barrier acquisition rolls back and can retry', ( + tester, + ) async { + final headerBar = _FailingModalBarrierService(); + addTearDown(headerBar.dispose); + + await expectLater( + acquireBusyMarkModalBarrier(headerBar), + throwsA(isA()), + ); + expect(headerBar.transitions, [1, 0]); + + await acquireBusyMarkModalBarrier(headerBar); + await releaseBusyMarkModalBarrier(headerBar); + expect(headerBar.transitions, [1, 0, 1, 0]); + }); + + testWidgets('modal coordinator resolves the service from ProviderScope', ( + tester, + ) async { + const channel = MethodChannel('com.busymark.test/automatic-modal-barrier'); + final calls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + calls.add(call); + return call.method == 'initialize' ? true : null; + }); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + }); + final headerBar = LinuxHeaderBarService(channel: channel); + addTearDown(headerBar.dispose); + await headerBar.initialize(); + late BuildContext hostContext; + + await tester.pumpWidget( + ProviderScope( + overrides: [linuxHeaderBarServiceProvider.overrideWithValue(headerBar)], + child: MaterialApp( + home: Builder( + builder: (context) { + hostContext = context; + return const Scaffold(body: SizedBox.expand()); + }, + ), + ), + ), + ); + + final result = showBusyMarkModalDialog( + hostContext, + builder: (_) => const Text('Automatic barrier dialog'), + ); + await tester.pumpAndSettle(); + + expect( + calls + .where((call) => call.method == 'setModalBarrierDepth') + .map((call) => call.arguments), + [1], + ); + + Navigator.of(hostContext, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + await result; + + expect( + calls + .where((call) => call.method == 'setModalBarrierDepth') + .map((call) => call.arguments), + [1, 0], + ); + }); } Future _pressControlShortcut( @@ -189,3 +320,17 @@ class _AppShortcutIntent extends Intent { class _DocumentViewShortcutIntent extends Intent { const _DocumentViewShortcutIntent(); } + +class _FailingModalBarrierService extends LinuxHeaderBarService { + final transitions = []; + var _failNextShow = true; + + @override + Future setModalBarrierDepth(int value) async { + transitions.add(value); + if (value > 0 && _failNextShow) { + _failNextShow = false; + throw StateError('simulated native modal-barrier failure'); + } + } +} diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index 78a89c7..ba164d3 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -3734,7 +3734,7 @@ void main() {} expect(find.byType(TextFormField), findsNWidgets(2)); expect(find.byType(BusyMarkDialogButton), findsNWidgets(3)); expect(find.byType(AlertDialog), findsNothing); - final dialogRect = tester.getRect(find.byType(BusyMarkDialogShell)); + final dialogRect = tester.getRect(find.byType(BusyMarkDialogTitleBar)); final sourceEntryRect = tester.getRect( find.byKey(BusyMarkImageDialogKeys.source), ); diff --git a/test/src/feedback_dialog_test.dart b/test/src/feedback_dialog_test.dart index 6c4a9dc..83abe85 100644 --- a/test/src/feedback_dialog_test.dart +++ b/test/src/feedback_dialog_test.dart @@ -4,7 +4,6 @@ import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/l10n/generated/app_localizations_en.dart'; import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/app/busymark_design.dart'; -import 'package:busymark/src/app/busymark_dialogs.dart'; import 'package:busymark/src/feedback/feedback_metadata.dart'; import 'package:busymark/src/feedback/feedback_service.dart'; import 'package:busymark/src/feedback/feedback_submission.dart'; @@ -341,14 +340,7 @@ Future _pumpDialog( brightness: Brightness.light, accentColor: BusyMarkLinuxPalette.blueAccent, ), - home: Scaffold( - body: Center( - child: BusyMarkModalEditorSurface( - maxHeight: 840, - child: const BusyMarkFeedbackDialog(), - ), - ), - ), + home: Scaffold(body: Center(child: const BusyMarkFeedbackDialog())), ), ), ); diff --git a/test/src/header_bar_configuration_test.dart b/test/src/header_bar_configuration_test.dart index 08db41e..b69896c 100644 --- a/test/src/header_bar_configuration_test.dart +++ b/test/src/header_bar_configuration_test.dart @@ -253,6 +253,7 @@ void main() { }); final service = LinuxHeaderBarService(channel: channel); + await service.initialize(); await service.setModalBarrierDepth(1); await service.configurationSynchronizer.setConfiguration( _configuration(title: 'Welcome'), diff --git a/test/src/localization_audit_test.dart b/test/src/localization_audit_test.dart index a35b4c7..bac6b4c 100644 --- a/test/src/localization_audit_test.dart +++ b/test/src/localization_audit_test.dart @@ -91,6 +91,67 @@ void main() { expect(failures, isEmpty, reason: failures.join('\n')); }); + test('package metadata uses reviewed product wording in every locale', () { + final desktop = File( + 'linux/io.busystack.busymark.desktop', + ).readAsStringSync(); + final metainfo = File( + 'linux/io.busystack.busymark.metainfo.xml', + ).readAsStringSync(); + const summaries = { + 'ar': 'محرر لملفات Markdown ومشاريع التوثيق المتوافقة مع Writerside', + 'de': + 'Editor für Markdown-Dateien und Writerside-kompatible ' + 'Dokumentationsprojekte', + 'es': + 'Editor de archivos Markdown y proyectos de documentación ' + 'compatibles con Writerside', + 'et': + 'Markdowni failide ja Writerside’iga ühilduvate ' + 'dokumentatsiooniprojektide redaktor', + 'fa': + 'ویرایشگر فایل‌های Markdown و پروژه‌های مستندسازی سازگار با ' + 'Writerside', + 'fr': + 'Éditeur de fichiers Markdown et de projets de documentation ' + 'compatibles avec Writerside', + 'hi': + 'Markdown फ़ाइलों और Writerside-संगत दस्तावेज़ीकरण परियोजनाओं का ' + 'संपादक', + 'it': + 'Editor per file Markdown e progetti di documentazione compatibili ' + 'con Writerside', + 'nb': + 'Redigerer for Markdown-filer og Writerside-kompatible ' + 'dokumentasjonsprosjekter', + 'pl': + 'Edytor plików Markdown i projektów dokumentacji zgodnych z ' + 'Writerside', + 'pt': + 'Editor de arquivos Markdown e projetos de documentação compatíveis ' + 'com o Writerside', + 'ru': + 'Редактор файлов Markdown и проектов документации, совместимых с ' + 'Writerside', + 'uk': + 'Редактор файлів Markdown і проєктів документації, сумісних із ' + 'Writerside', + }; + + for (final entry in summaries.entries) { + expect( + desktop, + contains('Comment[${entry.key}]=${entry.value}'), + reason: 'desktop ${entry.key}', + ); + expect( + metainfo, + contains('${entry.value}'), + reason: 'AppStream ${entry.key}', + ); + } + }); + test('target ARBs match the English messages and placeholders', () { final templateFile = File('lib/l10n/app_en.arb'); final templateArb = _arbMessages(templateFile); diff --git a/test/src/modal_barrier_test.dart b/test/src/modal_barrier_test.dart new file mode 100644 index 0000000..c58ad20 --- /dev/null +++ b/test/src/modal_barrier_test.dart @@ -0,0 +1,97 @@ +import 'dart:io'; + +import 'package:busymark/src/app/app_theme.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/busymark_dialogs.dart'; +import 'package:busymark/src/platform/header_bar_configuration.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('native dark startup fallback matches the semantic shade', () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + final match = RegExp( + r'kDefaultModalBarrierColor\[\] = ' + r'"rgba\(0,0,0,([0-9.]+)\)"', + ).firstMatch(source); + + expect(match, isNotNull); + expect(double.parse(match!.group(1)!), 0.25); + expect(source, contains('modal_barrier_color_for_depth(')); + }); + + test('native headerbar becomes inert while a modal route is active', () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + + expect( + source, + contains( + 'if (self->modal_barrier_visible ||\n' + ' self->header_bar_channel == nullptr || action == nullptr)', + ), + ); + expect( + source, + contains('close_header_menu_button(self->sidebar_menu_button);'), + ); + expect( + source, + contains('close_header_menu_button(self->adaptive_menu_button);'), + ); + expect( + source, + contains('close_header_menu_button(self->view_mode_button);'), + ); + expect(source, contains('focus_flutter_view(self);')); + expect( + source, + contains( + 'gtk_event_box_set_visible_window(GTK_EVENT_BOX(self->modal_scrim), ' + 'TRUE);', + ), + ); + expect( + source, + contains( + 'gtk_widget_add_events(self->modal_scrim, GDK_ALL_EVENTS_MASK);', + ), + ); + expect(source, contains('busymark-modal-open')); + }); + + for (final (brightness, expectedAlpha) in [ + (Brightness.light, 0.07), + (Brightness.dark, 0.25), + ]) { + testWidgets('$brightness modal barriers use the semantic shade role', ( + tester, + ) async { + final theme = buildBusyMarkTheme( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + late Color flutterBarrier; + late Color nativeBarrier; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (context) { + flutterBarrier = busyMarkModalBarrierColor(context); + nativeBarrier = HeaderBarTheme.fromContext( + context, + ).modalBarrierColor; + return const SizedBox.shrink(); + }, + ), + ), + ); + + final shade = theme.extension()!.shade; + expect(flutterBarrier, shade); + expect(nativeBarrier, shade); + expect(flutterBarrier.a, closeTo(expectedAlpha, 0.0001)); + }); + } +} diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 09eaba2..768a8b4 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -568,6 +568,22 @@ void main() { expect(native, contains('"font-weight: 800;"')); expect(native, contains('".busymark-sidebar-header label:backdrop {"')); expect(native, contains('kHeaderBackdropForegroundOpacity = 0.50')); + expect(native, contains('self->sidebar_header_box = gtk_overlay_new()')); + expect(native, contains('GtkWidget* sidebar_action_box =')); + expect( + native, + contains( + 'gtk_overlay_add_overlay(GTK_OVERLAY(self->sidebar_header_box),', + ), + ); + expect( + native, + contains( + 'gtk_overlay_set_overlay_pass_through(' + 'GTK_OVERLAY(self->sidebar_header_box),', + ), + ); + expect(native, isNot(contains('GtkWidget* sidebar_title_box ='))); final headerbarBlock = RegExp( r'"headerbar\.busymark-headerbar,"(.*?)"\}', dotAll: true, diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index a15f9cb..cdbce37 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -199,14 +199,19 @@ void main() { test('BusyMark dialog title bars use the same surface as dialog body', () { final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); + final dialogChrome = RegExp( + r'Color busyMarkDialogSurfaceColor[\s\S]*?class BusyMarkDialogShell', + ).firstMatch(design)!.group(0)!; final dialogShell = RegExp( - r'class BusyMarkDialogShell[\s\S]*?class SectionLabel', + r'class BusyMarkDialogShell[\s\S]*?abstract final class BusyMarkPushButton', ).firstMatch(design)!.group(0)!; - expect(dialogShell, contains('final colors = BusyMarkSurfaceColors.of')); - expect(dialogShell, contains('YaruDialogTitleBar(')); - expect(dialogShell, contains('backgroundColor: colors.dialog')); - expect(dialogShell, contains('border: BorderSide.none')); + expect(dialogChrome, contains('busyMarkDialogSurfaceColor(context)')); + expect(dialogChrome, contains('YaruDialogTitleBar(')); + expect(dialogChrome, contains('backgroundColor: dialogSurface')); + expect(dialogChrome, contains('border:')); + expect(dialogShell, contains('child: Dialog(')); + expect(dialogShell, contains('clipBehavior: Clip.antiAlias')); }); test('BusyMark dialog buttons are thin semantic framework adapters', () { @@ -379,16 +384,21 @@ void main() { expect(design, contains('groupedList: groupedList')); expect(design, contains('dialog: floatingSurface')); expect(design, contains('popover: floatingSurface')); - expect(theme, contains('ShapeBorder? _withOutlineSide')); + expect(design, contains('dialogOutline:')); expect( theme, - isNot(contains('final accentContainer = Color.alphaBlend')), + contains( + 'final dialogSurfaceSide = ' + 'BorderSide(color: colors.dialogOutline)', + ), ); - expect(theme, isNot(contains('selectedTileColor: accentContainer'))); + expect(theme, contains('ShapeBorder? _withOutlineSide')); expect( theme, - isNot(contains('shape: _withOutlineSide(base.dialogTheme.shape')), + isNot(contains('final accentContainer = Color.alphaBlend')), ); + expect(theme, isNot(contains('selectedTileColor: accentContainer'))); + expect(theme, contains('shape: _withOutlineSide(base.dialogTheme.shape')); expect(theme, contains('_withOutlineSide(base.popupMenuTheme.shape')); expect(theme, contains('side: WidgetStatePropertyAll(side)')); expect(theme, contains('surfaceContainerLowest: colors.view')); diff --git a/test/src/surface_palette_render_test.dart b/test/src/surface_palette_render_test.dart index 4908a83..411046f 100644 --- a/test/src/surface_palette_render_test.dart +++ b/test/src/surface_palette_render_test.dart @@ -3,7 +3,6 @@ import 'dart:ui' as ui; import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/app/busymark_design.dart'; -import 'package:busymark/src/app/busymark_dialogs.dart'; import 'package:busymark/src/app/busymark_glyphs.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -19,6 +18,7 @@ void main() { sidebar: Color(0xFFEBEBEB), card: Color(0xFFFFFFFF), popover: Color(0xFFFAFAFA), + dialogOutline: Color.fromRGBO(255, 255, 255, 0.07), floatingBorder: Color.fromRGBO(0, 0, 0, 0.14), ), _SurfaceBaseline( @@ -29,6 +29,7 @@ void main() { sidebar: Color(0xFF393939), card: Color(0xFF3D3D3D), popover: Color(0xFF3E3E3E), + dialogOutline: Color.fromRGBO(255, 255, 255, 0.07), floatingBorder: Color.fromRGBO(0, 0, 0, 0.14), ), ]) { @@ -108,29 +109,25 @@ void main() { ), ), ), - BusyMarkModalEditorSurface( + BusyMarkDialogShell( + title: 'Palette', maxWidth: 320, - maxHeight: 260, - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox.square( - key: dialogProbe, - dimension: 16, - ), - const SizedBox(height: 24), - BusyMarkGroupedSurface( - child: SizedBox( - key: cardProbe, - width: 180, - height: 72, - ), - ), - ], + children: [ + Center( + child: SizedBox.square( + key: dialogProbe, + dimension: 16, + ), ), - ), + const SizedBox(height: 24), + BusyMarkGroupedSurface( + child: SizedBox( + key: cardProbe, + width: 180, + height: 72, + ), + ), + ], ), Positioned( right: 24, @@ -163,6 +160,14 @@ void main() { ), ), ); + final dialogMaterial = find.ancestor( + of: find.byKey(dialogProbe), + matching: find.byWidgetPredicate( + (widget) => + widget is Material && widget.shape == theme.dialogTheme.shape, + ), + ); + expect(dialogMaterial, findsOneWidget); await tester.pumpAndSettle(); await tester.tap(find.byKey(popupButtonKey)); await tester.pumpAndSettle(); @@ -184,6 +189,18 @@ void main() { expect(_pixelAtProbe(tester, pixels, dialogProbe), baseline.dialog); expect(_pixelAtProbe(tester, pixels, cardProbe), baseline.card); expect(_pixelAtProbe(tester, pixels, popoverProbe), baseline.popover); + final dialogSize = tester.getSize(dialogMaterial); + final dialogEdge = _pixelAtLocal( + tester, + pixels, + dialogMaterial, + Offset(0.5, dialogSize.height / 2), + ); + final expectedDialogEdge = Color.alphaBlend( + baseline.dialogOutline, + baseline.dialog, + ); + _expectColorNear(dialogEdge, expectedDialogEdge, tolerance: 3); final popupSize = tester.getSize(popupMaterial); final popupEdge = _pixelAtLocal( tester, @@ -214,6 +231,7 @@ class _SurfaceBaseline { required this.sidebar, required this.card, required this.popover, + required this.dialogOutline, required this.floatingBorder, }); @@ -224,6 +242,7 @@ class _SurfaceBaseline { final Color sidebar; final Color card; final Color popover; + final Color dialogOutline; final Color floatingBorder; } From 1accf05039187a79d42debc68f0b3648da15fd5c Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 20:44:45 -0700 Subject: [PATCH 10/29] Add accelerator key management for model buttons and menu items. Enhance tooltip functionality with shortcuts for improved user experience. Enhance BusyMark UI with GTK accelerator support and improve card styling. Add grouped surface colors and update dialog structure for better layout management. --- lib/src/app/app_theme.dart | 5 + lib/src/app/busymark_app.dart | 11 + lib/src/app/busymark_design.dart | 442 ++++++++++++++---- lib/src/app/busymark_dialog_identity.dart | 45 +- lib/src/app/busymark_shortcuts.dart | 61 +++ .../platform/header_bar_configuration.dart | 24 + linux/runner/my_application.cc | 155 +++++- test/src/busymark_design_test.dart | 197 +++++++- test/src/header_bar_configuration_test.dart | 8 + test/src/native_headerbar_audit_test.dart | 29 +- test/src/source_audit_test.dart | 49 +- test/src/surface_palette_render_test.dart | 14 +- 12 files changed, 861 insertions(+), 179 deletions(-) diff --git a/lib/src/app/app_theme.dart b/lib/src/app/app_theme.dart index 336fef2..f337fbf 100644 --- a/lib/src/app/app_theme.dart +++ b/lib/src/app/app_theme.dart @@ -219,6 +219,11 @@ ThemeData buildBusyMarkTheme({ elevation: BusyMarkElevation.surface, surfaceTintColor: BusyMarkLinuxPalette.transparent, shadowColor: colorScheme.shadow, + shape: + base.cardTheme.shape ?? + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BusyMarkRadius.lg), + ), ), ); } diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart index 717808b..45c80f4 100644 --- a/lib/src/app/busymark_app.dart +++ b/lib/src/app/busymark_app.dart @@ -591,10 +591,16 @@ class BusyMarkApp extends ConsumerWidget { split: l10n.split, viewMode: l10n.viewMode, editorShortcut: BusyMarkDocumentViewShortcutLabels.editor, + editorGtkAccelerator: BusyMarkDocumentViewShortcutGtkAccelerators.editor, sourceShortcut: BusyMarkDocumentViewShortcutLabels.source, + sourceGtkAccelerator: BusyMarkDocumentViewShortcutGtkAccelerators.source, previewShortcut: BusyMarkDocumentViewShortcutLabels.preview, + previewGtkAccelerator: + BusyMarkDocumentViewShortcutGtkAccelerators.preview, splitShortcut: BusyMarkDocumentViewShortcutLabels.split, + splitGtkAccelerator: BusyMarkDocumentViewShortcutGtkAccelerators.split, search: material.searchFieldLabel, + searchShortcut: BusyMarkAppShortcutLabels.search, refresh: l10n.validate, menu: l10n.mainMenu, sidebar: settings.sidebarVisible ? l10n.hideSidebar : l10n.showSidebar, @@ -603,10 +609,15 @@ class BusyMarkApp extends ConsumerWidget { save: l10n.save, settings: l10n.settings, settingsShortcut: BusyMarkAppShortcutLabels.settings, + settingsGtkAccelerator: BusyMarkAppShortcutGtkAccelerators.settings, keyboardShortcuts: l10n.keyboardShortcuts, keyboardShortcutsShortcut: BusyMarkAppShortcutLabels.keyboardShortcuts, + keyboardShortcutsGtkAccelerator: + BusyMarkAppShortcutGtkAccelerators.keyboardShortcuts, markdownAndHtml: l10n.markdownAndHtml, markdownAndHtmlShortcut: BusyMarkAppShortcutLabels.markdownAndHtml, + markdownAndHtmlGtkAccelerator: + BusyMarkAppShortcutGtkAccelerators.markdownAndHtml, reportIssue: l10n.reportIssue, aboutBusyMark: l10n.aboutBusyMark, ); diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index 13221d7..88452a5 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:yaru/yaru.dart'; @@ -118,6 +119,7 @@ abstract final class BusyMarkStroke { } abstract final class BusyMarkAlpha { + static const double groupedRowLightHoverStrength = 0.50; static const double textSelection = 0.32; static const double sourceCollapsedLine = 0.045; static const double sourceCursor = 0.82; @@ -130,6 +132,40 @@ abstract final class BusyMarkAlpha { static const double thematicBreakSelected = 0.72; } +/// Native libadwaita/Yaru shadow layers shared by grouped card surfaces. +abstract final class BusyMarkShadow { + static List nativeCardShadows(Color semanticShadow) { + Color layer(double opacity) { + return semanticShadow.withValues(alpha: semanticShadow.a * opacity); + } + + // ShapeDecoration paints later shadows over earlier ones. Keep the + // perimeter last so the compact native edge remains above the broad layer. + return [ + BoxShadow( + color: layer(0.03), + blurRadius: 6, + spreadRadius: 2, + offset: const Offset(0, 2), + ), + BoxShadow( + color: layer(0.07), + blurRadius: 3, + spreadRadius: 1, + offset: const Offset(0, 1), + ), + BoxShadow(color: layer(0.03), spreadRadius: 1), + ]; + } + + static List nativeCardShadowsFor(BuildContext context) { + final theme = Theme.of(context); + return nativeCardShadows( + CardTheme.of(context).shadowColor ?? theme.colorScheme.shadow, + ); + } +} + abstract final class BusyMarkTypography { static const String fontFamily = 'Ubuntu'; static const String monoFontFamily = 'Ubuntu Mono'; @@ -506,10 +542,14 @@ BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { Brightness.light => const Color(0xFF666666), Brightness.dark => const Color(0xFFB5B5B5), }; - final groupedList = switch (brightness) { + final card = switch (brightness) { Brightness.light => const Color(0xFFFFFFFF), Brightness.dark => const Color(0xFF3D3D3D), }; + final groupedSurface = switch (brightness) { + Brightness.light => const Color(0xFFFFFFFF), + Brightness.dark => const Color.fromRGBO(255, 255, 255, 0.08), + }; // Yaru renders the split-view boundary as a recessed divider in both // brightness modes. A foreground tint in dark mode produces a light seam. final sidebarBorder = switch (brightness) { @@ -519,7 +559,7 @@ BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { Color tintedSurface(Color tint) { final alpha = brightness == Brightness.dark ? 0.16 : 0.08; - return Color.alphaBlend(tint.withValues(alpha: alpha), groupedList); + return Color.alphaBlend(tint.withValues(alpha: alpha), card); } return switch (brightness) { @@ -534,8 +574,8 @@ BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { headerbar: const Color(0xFFFAFAFA), headerbarFlat: const Color(0xFFFFFFFF), panel: const Color(0xFFF0F0F0), - card: const Color(0xFFFFFFFF), - groupedList: groupedList, + card: card, + groupedSurface: groupedSurface, dialog: floatingSurface, popover: floatingSurface, control: const Color.fromRGBO(0, 0, 0, 0.10), @@ -543,11 +583,12 @@ BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { controlActive: const Color.fromRGBO(0, 0, 0, 0.18), foreground: foreground, mutedForeground: mutedForeground, - disabledForeground: const Color.fromRGBO(0, 0, 0, 0.38), + disabledForeground: foreground.withValues(alpha: 0.38), disabledControl: const Color.fromRGBO(0, 0, 0, 0.04), border: const Color.fromRGBO(0, 0, 0, 0.18), subtleBorder: const Color.fromRGBO(0, 0, 0, 0.10), divider: const Color.fromRGBO(0, 0, 0, 0.10), + cardShade: const Color.fromRGBO(24, 24, 24, 0.08), // Dialogs use libadwaita's restrained inside highlight. This is // intentionally distinct from the darker popover perimeter. dialogOutline: const Color.fromRGBO(255, 255, 255, 0.07), @@ -567,8 +608,8 @@ BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { headerbar: const Color(0xFF393939), headerbarFlat: const Color(0xFF272727), panel: const Color(0xFF323232), - card: const Color(0xFF3D3D3D), - groupedList: groupedList, + card: card, + groupedSurface: groupedSurface, dialog: floatingSurface, popover: floatingSurface, control: const Color.fromRGBO(255, 255, 255, 0.10), @@ -576,11 +617,12 @@ BusyMarkSurfaceColors _busyMarkSemanticSurfaceColors(Brightness brightness) { controlActive: const Color.fromRGBO(255, 255, 255, 0.18), foreground: foreground, mutedForeground: mutedForeground, - disabledForeground: const Color.fromRGBO(255, 255, 255, 0.38), + disabledForeground: foreground.withValues(alpha: 0.38), disabledControl: const Color.fromRGBO(255, 255, 255, 0.06), border: const Color.fromRGBO(0, 0, 0, 0.75), subtleBorder: const Color.fromRGBO(255, 255, 255, 0.10), divider: const Color.fromRGBO(255, 255, 255, 0.10), + cardShade: const Color.fromRGBO(0, 0, 0, 0.36), dialogOutline: const Color.fromRGBO(255, 255, 255, 0.07), floatingBorder: const Color.fromRGBO(0, 0, 0, 0.14), sidebarBorder: sidebarBorder, @@ -604,7 +646,7 @@ class BusyMarkSurfaceColors extends ThemeExtension { required this.headerbarFlat, required this.panel, required this.card, - required this.groupedList, + required this.groupedSurface, required this.dialog, required this.popover, required this.control, @@ -617,6 +659,7 @@ class BusyMarkSurfaceColors extends ThemeExtension { required this.border, required this.subtleBorder, required this.divider, + required this.cardShade, required this.dialogOutline, required this.floatingBorder, required this.sidebarBorder, @@ -645,7 +688,7 @@ class BusyMarkSurfaceColors extends ThemeExtension { final Color headerbarFlat; final Color panel; final Color card; - final Color groupedList; + final Color groupedSurface; final Color dialog; final Color popover; final Color control; @@ -658,6 +701,7 @@ class BusyMarkSurfaceColors extends ThemeExtension { final Color border; final Color subtleBorder; final Color divider; + final Color cardShade; final Color dialogOutline; final Color floatingBorder; final Color sidebarBorder; @@ -677,7 +721,7 @@ class BusyMarkSurfaceColors extends ThemeExtension { Color? headerbarFlat, Color? panel, Color? card, - Color? groupedList, + Color? groupedSurface, Color? dialog, Color? popover, Color? control, @@ -690,6 +734,7 @@ class BusyMarkSurfaceColors extends ThemeExtension { Color? border, Color? subtleBorder, Color? divider, + Color? cardShade, Color? dialogOutline, Color? floatingBorder, Color? sidebarBorder, @@ -708,7 +753,7 @@ class BusyMarkSurfaceColors extends ThemeExtension { headerbarFlat: headerbarFlat ?? this.headerbarFlat, panel: panel ?? this.panel, card: card ?? this.card, - groupedList: groupedList ?? this.groupedList, + groupedSurface: groupedSurface ?? this.groupedSurface, dialog: dialog ?? this.dialog, popover: popover ?? this.popover, control: control ?? this.control, @@ -721,6 +766,7 @@ class BusyMarkSurfaceColors extends ThemeExtension { border: border ?? this.border, subtleBorder: subtleBorder ?? this.subtleBorder, divider: divider ?? this.divider, + cardShade: cardShade ?? this.cardShade, dialogOutline: dialogOutline ?? this.dialogOutline, floatingBorder: floatingBorder ?? this.floatingBorder, sidebarBorder: sidebarBorder ?? this.sidebarBorder, @@ -750,7 +796,7 @@ class BusyMarkSurfaceColors extends ThemeExtension { headerbarFlat: Color.lerp(headerbarFlat, other.headerbarFlat, t)!, panel: Color.lerp(panel, other.panel, t)!, card: Color.lerp(card, other.card, t)!, - groupedList: Color.lerp(groupedList, other.groupedList, t)!, + groupedSurface: Color.lerp(groupedSurface, other.groupedSurface, t)!, dialog: Color.lerp(dialog, other.dialog, t)!, popover: Color.lerp(popover, other.popover, t)!, control: Color.lerp(control, other.control, t)!, @@ -767,6 +813,7 @@ class BusyMarkSurfaceColors extends ThemeExtension { border: Color.lerp(border, other.border, t)!, subtleBorder: Color.lerp(subtleBorder, other.subtleBorder, t)!, divider: Color.lerp(divider, other.divider, t)!, + cardShade: Color.lerp(cardShade, other.cardShade, t)!, dialogOutline: Color.lerp(dialogOutline, other.dialogOutline, t)!, floatingBorder: Color.lerp(floatingBorder, other.floatingBorder, t)!, sidebarBorder: Color.lerp(sidebarBorder, other.sidebarBorder, t)!, @@ -844,7 +891,15 @@ Color busyMarkSelectedBackground(BuildContext context) { } Color busyMarkRowHoverColor(BuildContext context) { - return Theme.of(context).hoverColor; + final theme = Theme.of(context); + final hover = theme.hoverColor; + if (theme.colorScheme.isHighContrast || + theme.colorScheme.brightness == Brightness.dark) { + return hover; + } + return hover.withValues( + alpha: hover.a * BusyMarkAlpha.groupedRowLightHoverStrength, + ); } TextStyle? busyMarkSectionHeaderStyle(BuildContext context) { @@ -854,6 +909,20 @@ TextStyle? busyMarkSectionHeaderStyle(BuildContext context) { ); } +Widget _busyMarkGroupedRowSubtitle( + BuildContext context, + Widget child, { + bool enabled = true, +}) { + final colors = BusyMarkSurfaceColors.of(context); + return DefaultTextStyle.merge( + style: TextStyle( + color: enabled ? colors.mutedForeground : colors.disabledForeground, + ), + child: child, + ); +} + class BusyMarkHeaderIconButton extends StatelessWidget { const BusyMarkHeaderIconButton({ super.key, @@ -1367,43 +1436,110 @@ class BusyMarkClamp extends StatelessWidget { } } +/// Semantic parent surfaces that can contain a grouped card. +enum BusyMarkSurfaceRole { window, view, sidebar, dialog, popover } + +class BusyMarkSurfaceScope extends InheritedWidget { + const BusyMarkSurfaceScope({ + super.key, + required this.role, + required super.child, + }); + + final BusyMarkSurfaceRole role; + + static BusyMarkSurfaceRole roleOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.role ?? + BusyMarkSurfaceRole.window; + } + + @override + bool updateShouldNotify(BusyMarkSurfaceScope oldWidget) { + return role != oldWidget.role; + } +} + +Color busyMarkGroupedSurfaceColor( + BuildContext context, { + BusyMarkSurfaceRole? parentRole, +}) { + final colors = BusyMarkSurfaceColors.of(context); + final role = parentRole ?? BusyMarkSurfaceScope.roleOf(context); + if (role == BusyMarkSurfaceRole.window) { + return colors.card; + } + final parent = switch (role) { + BusyMarkSurfaceRole.window => colors.window, + BusyMarkSurfaceRole.view => colors.view, + BusyMarkSurfaceRole.sidebar => colors.sidebar, + BusyMarkSurfaceRole.dialog => colors.dialog, + BusyMarkSurfaceRole.popover => colors.popover, + }; + return Color.alphaBlend(colors.groupedSurface, parent); +} + class BusyMarkSurface extends StatelessWidget { const BusyMarkSurface({ super.key, required this.child, this.filled = true, this.color, - this.side = BorderSide.none, + this.side, this.clipBehavior = Clip.antiAlias, }); final Widget child; final bool filled; final Color? color; - final BorderSide side; + final BorderSide? side; final Clip clipBehavior; @override Widget build(BuildContext context) { - final borderRadius = BorderRadius.circular(BusyMarkRadius.lg); - final cardTheme = Theme.of(context).cardTheme; - final colors = BusyMarkSurfaceColors.of(context); - final shape = - cardTheme.shape ?? RoundedRectangleBorder(borderRadius: borderRadius); - final effectiveShape = side == BorderSide.none - ? shape - : switch (shape) { - final OutlinedBorder outlined => outlined.copyWith(side: side), - _ => shape, - }; + final cardTheme = CardTheme.of(context); + final surfaceColors = BusyMarkSurfaceColors.of(context); + final fallbackShape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BusyMarkRadius.lg), + ); + final themedShape = cardTheme.shape; + final ShapeBorder shape; + if (themedShape is OutlinedBorder) { + shape = side == null ? themedShape : themedShape.copyWith(side: side); + } else if (themedShape != null && side == null) { + shape = themedShape; + } else { + shape = fallbackShape.copyWith(side: side ?? BorderSide.none); + } + final surfaceColor = filled + ? color ?? cardTheme.color ?? surfaceColors.card + : Colors.transparent; + if (filled) { + final shadowShape = shape is OutlinedBorder + ? shape.copyWith(side: BorderSide.none) + : shape; + return DecoratedBox( + decoration: ShapeDecoration( + shape: shadowShape, + shadows: BusyMarkShadow.nativeCardShadowsFor(context), + ), + child: Card( + margin: EdgeInsets.zero, + semanticContainer: false, + color: surfaceColor, + shadowColor: Colors.transparent, + shape: shape, + clipBehavior: clipBehavior, + child: child, + ), + ); + } return Material( - color: filled - ? color ?? cardTheme.color ?? colors.card - : BusyMarkLinuxPalette.transparent, - elevation: filled ? BusyMarkElevation.surface : BusyMarkElevation.none, - shadowColor: Theme.of(context).colorScheme.shadow, - surfaceTintColor: BusyMarkLinuxPalette.transparent, - shape: effectiveShape, + color: Colors.transparent, + elevation: 0, + surfaceTintColor: cardTheme.surfaceTintColor ?? Colors.transparent, + shape: shape, clipBehavior: clipBehavior, child: child, ); @@ -1423,8 +1559,12 @@ class BusyMarkGroupedSurface extends StatelessWidget { @override Widget build(BuildContext context) { + final highContrast = MediaQuery.highContrastOf(context); return BusyMarkSurface( - color: BusyMarkSurfaceColors.of(context).groupedList, + color: busyMarkGroupedSurfaceColor(context), + side: highContrast + ? BorderSide(color: Theme.of(context).colorScheme.outline) + : null, clipBehavior: clipBehavior, child: child, ); @@ -1527,11 +1667,7 @@ class _BusyMarkGroupedListSurface extends StatelessWidget { for (var index = 0; index < children.length; index++) ...[ children[index], if (index < children.length - 1) - Divider( - height: BusyMarkStroke.hairline, - thickness: BusyMarkStroke.hairline, - color: colors.divider, - ), + Divider(height: 1, thickness: 1, color: colors.cardShade), ], ], ); @@ -1544,51 +1680,162 @@ class _BusyMarkGroupedListSurface extends StatelessWidget { } } -class BusyMarkActionRow extends StatelessWidget { +typedef BusyMarkRowActivationCallback = + void Function(BuildContext context, Offset? globalPosition); + +class BusyMarkActionRow extends StatefulWidget { const BusyMarkActionRow({ super.key, required this.title, this.subtitle, + this.titleWidget, + this.subtitleWidget, this.leading, this.trailing, this.onTap, + this.onActivated, this.enabled = true, + this.tooltip, this.destructive = false, - }); + this.autofocus = false, + this.hoverColor, + }) : assert(onTap == null || onActivated == null); final String title; final String? subtitle; + final Widget? titleWidget; + final Widget? subtitleWidget; final Widget? leading; final Widget? trailing; final VoidCallback? onTap; + final BusyMarkRowActivationCallback? onActivated; final bool enabled; + final String? tooltip; final bool destructive; + final bool autofocus; + final Color? hoverColor; + + @override + State createState() => _BusyMarkActionRowState(); +} + +class _BusyMarkActionRowState extends State { + int? _primaryPointer; + Offset? _pointerDownPosition; + + @override + void didUpdateWidget(covariant BusyMarkActionRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (!widget.enabled || widget.onActivated == null) { + _clearPointer(); + } + } @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; final colors = BusyMarkSurfaceColors.of(context); - final titleStyle = destructive + final titleStyle = widget.destructive ? TextStyle( - color: enabled - ? busyMarkDestructiveForeground(context) + color: widget.enabled + ? colorScheme.error : colors.disabledForeground, ) : null; - return YaruListTile.square( - leading: leading, - title: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: titleStyle, - ), - subtitle: subtitle == null || subtitle!.isEmpty + final subtitle = + widget.subtitleWidget ?? + (widget.subtitle == null || widget.subtitle!.isEmpty + ? null + : Text( + widget.subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + )); + final interactive = + widget.enabled && (widget.onTap != null || widget.onActivated != null); + final row = YaruListTile.square( + leading: widget.leading, + title: + widget.titleWidget ?? + Text( + widget.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: titleStyle, + ), + subtitle: subtitle == null ? null - : Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis), - trailing: trailing, - enabled: enabled, - onTap: enabled ? onTap : null, + : _busyMarkGroupedRowSubtitle( + context, + subtitle, + enabled: widget.enabled, + ), + trailing: widget.trailing, + enabled: widget.enabled, + autofocus: widget.autofocus, + hoverColor: widget.hoverColor ?? busyMarkRowHoverColor(context), + onTap: interactive ? _activate : null, ); + + final trackedRow = widget.onActivated == null + ? row + : Listener( + onPointerDown: widget.enabled ? _handlePointerDown : null, + onPointerUp: widget.enabled ? _handlePointerUp : null, + onPointerCancel: widget.enabled ? _handlePointerCancel : null, + child: row, + ); + + if (widget.enabled || widget.tooltip == null) { + return trackedRow; + } + + return Tooltip( + message: widget.tooltip!, + child: Opacity(opacity: 0.6, child: IgnorePointer(child: trackedRow)), + ); + } + + void _handlePointerDown(PointerDownEvent event) { + if (event.buttons != kPrimaryButton) { + return; + } + _primaryPointer = event.pointer; + _pointerDownPosition = event.position; + } + + void _handlePointerUp(PointerUpEvent event) { + if (_primaryPointer != event.pointer) { + return; + } + final pointer = event.pointer; + scheduleMicrotask(() { + if (mounted && _primaryPointer == pointer) { + _clearPointer(); + } + }); + } + + void _handlePointerCancel(PointerCancelEvent event) { + if (_primaryPointer == event.pointer) { + _clearPointer(); + } + } + + void _activate() { + final onActivated = widget.onActivated; + if (onActivated == null) { + widget.onTap?.call(); + return; + } + final globalPosition = _pointerDownPosition; + _clearPointer(); + onActivated(context, globalPosition); + } + + void _clearPointer() { + _primaryPointer = null; + _pointerDownPosition = null; } } @@ -1617,7 +1864,13 @@ class BusyMarkSwitchRow extends StatelessWidget { onChanged: enabled ? onChanged : null, secondary: leading, title: Text(title), - subtitle: subtitle == null ? null : Text(subtitle!), + subtitle: subtitle == null + ? null + : _busyMarkGroupedRowSubtitle( + context, + Text(subtitle!), + enabled: enabled, + ), shape: const RoundedRectangleBorder(), hoverColor: busyMarkRowHoverColor(context), ); @@ -1767,43 +2020,46 @@ class BusyMarkDialogShell extends StatelessWidget { namesRoute: true, explicitChildNodes: true, label: title, - child: Dialog( - backgroundColor: dialogSurface, - surfaceTintColor: dialogSurface, - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxWidth), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - header ?? - BusyMarkDialogTitleBar( - title: Text(title), - closable: closable, - ), - Flexible( - child: SingleChildScrollView( - padding: const EdgeInsets.all(BusyMarkSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, + child: BusyMarkSurfaceScope( + role: BusyMarkSurfaceRole.dialog, + child: Dialog( + backgroundColor: dialogSurface, + surfaceTintColor: dialogSurface, + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header ?? + BusyMarkDialogTitleBar( + title: Text(title), + closable: closable, + ), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(BusyMarkSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), ), ), - ), - if (actions.isNotEmpty) - Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.lg), - child: OverflowBar( - alignment: MainAxisAlignment.end, - overflowAlignment: OverflowBarAlignment.end, - spacing: BusyMarkSpacing.sm, - overflowSpacing: BusyMarkSpacing.sm, - children: actions, + if (actions.isNotEmpty) + Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.lg), + child: OverflowBar( + alignment: MainAxisAlignment.end, + overflowAlignment: OverflowBarAlignment.end, + spacing: BusyMarkSpacing.sm, + overflowSpacing: BusyMarkSpacing.sm, + children: actions, + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/src/app/busymark_dialog_identity.dart b/lib/src/app/busymark_dialog_identity.dart index 3b56baa..dc4fb01 100644 --- a/lib/src/app/busymark_dialog_identity.dart +++ b/lib/src/app/busymark_dialog_identity.dart @@ -23,27 +23,34 @@ class BusyMarkInformationalDialog extends StatelessWidget { @override Widget build(BuildContext context) { final dialogSurface = busyMarkDialogSurfaceColor(context); - return Dialog( - backgroundColor: dialogSurface, - surfaceTintColor: dialogSurface, - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: maxWidth, - maxHeight: maxHeight ?? double.infinity, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - BusyMarkDialogTitleBar(closeSemanticLabel: closeLabel), - Flexible( - child: SingleChildScrollView( - padding: const EdgeInsets.all(BusyMarkSpacing.lg), - child: child, + return BusyMarkSurfaceScope( + role: BusyMarkSurfaceRole.dialog, + child: Builder( + builder: (context) { + return Dialog( + backgroundColor: dialogSurface, + surfaceTintColor: dialogSurface, + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: maxHeight ?? double.infinity, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + BusyMarkDialogTitleBar(closeSemanticLabel: closeLabel), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(BusyMarkSpacing.lg), + child: child, + ), + ), + ], ), ), - ], - ), + ); + }, ), ); } diff --git a/lib/src/app/busymark_shortcuts.dart b/lib/src/app/busymark_shortcuts.dart index c2de3b7..4896e2f 100644 --- a/lib/src/app/busymark_shortcuts.dart +++ b/lib/src/app/busymark_shortcuts.dart @@ -5,10 +5,17 @@ class BusyMarkShortcutDefinition { const BusyMarkShortcutDefinition({ required this.label, required this.activator, + this.gtkAccelerator, }); final String label; final ShortcutActivator activator; + + /// GTK accelerator syntax for native controls that render this command. + /// + /// This is deliberately separate from [label]: GTK parses strings such as + /// `comma`, then formats the visible label for the current desktop. + final String? gtkAccelerator; } enum BusyMarkAppShortcutAction { @@ -42,21 +49,38 @@ abstract final class BusyMarkAppShortcuts { static const closeAllTabsLabel = 'Ctrl+Shift+W'; static const toggleSidebarLabel = 'F9'; + static const newDocumentGtkAccelerator = 'n'; + static const openGtkAccelerator = 'o'; + static const saveGtkAccelerator = 's'; + static const searchGtkAccelerator = 'f'; + static const keyboardShortcutsGtkAccelerator = 'k'; + static const markdownAndHtmlGtkAccelerator = 'm'; + static const settingsGtkAccelerator = 's'; + static const nextTabGtkAccelerator = 'Tab'; + static const previousTabGtkAccelerator = 'Tab'; + static const closeTabGtkAccelerator = 'w'; + static const closeAllTabsGtkAccelerator = 'w'; + static const toggleSidebarGtkAccelerator = 'F9'; + static const newDocument = BusyMarkShortcutDefinition( label: newDocumentLabel, activator: SingleActivator(LogicalKeyboardKey.keyN, control: true), + gtkAccelerator: newDocumentGtkAccelerator, ); static const open = BusyMarkShortcutDefinition( label: openLabel, activator: SingleActivator(LogicalKeyboardKey.keyO, control: true), + gtkAccelerator: openGtkAccelerator, ); static const save = BusyMarkShortcutDefinition( label: saveLabel, activator: SingleActivator(LogicalKeyboardKey.keyS, control: true), + gtkAccelerator: saveGtkAccelerator, ); static const search = BusyMarkShortcutDefinition( label: searchLabel, activator: SingleActivator(LogicalKeyboardKey.keyF, control: true), + gtkAccelerator: searchGtkAccelerator, ); static const keyboardShortcuts = BusyMarkShortcutDefinition( label: keyboardShortcutsLabel, @@ -65,6 +89,7 @@ abstract final class BusyMarkAppShortcuts { control: true, alt: true, ), + gtkAccelerator: keyboardShortcutsGtkAccelerator, ); static const markdownAndHtml = BusyMarkShortcutDefinition( label: markdownAndHtmlLabel, @@ -73,6 +98,7 @@ abstract final class BusyMarkAppShortcuts { control: true, alt: true, ), + gtkAccelerator: markdownAndHtmlGtkAccelerator, ); static const settings = BusyMarkShortcutDefinition( label: settingsLabel, @@ -81,10 +107,12 @@ abstract final class BusyMarkAppShortcuts { control: true, alt: true, ), + gtkAccelerator: settingsGtkAccelerator, ); static const nextTab = BusyMarkShortcutDefinition( label: nextTabLabel, activator: SingleActivator(LogicalKeyboardKey.tab, control: true), + gtkAccelerator: nextTabGtkAccelerator, ); static const previousTab = BusyMarkShortcutDefinition( label: previousTabLabel, @@ -93,10 +121,12 @@ abstract final class BusyMarkAppShortcuts { control: true, shift: true, ), + gtkAccelerator: previousTabGtkAccelerator, ); static const closeTab = BusyMarkShortcutDefinition( label: closeTabLabel, activator: SingleActivator(LogicalKeyboardKey.keyW, control: true), + gtkAccelerator: closeTabGtkAccelerator, ); static const closeAllTabs = BusyMarkShortcutDefinition( label: closeAllTabsLabel, @@ -105,10 +135,12 @@ abstract final class BusyMarkAppShortcuts { control: true, shift: true, ), + gtkAccelerator: closeAllTabsGtkAccelerator, ); static const toggleSidebar = BusyMarkShortcutDefinition( label: toggleSidebarLabel, activator: SingleActivator(LogicalKeyboardKey.f9), + gtkAccelerator: toggleSidebarGtkAccelerator, ); static const definitions = @@ -170,6 +202,17 @@ abstract final class BusyMarkAppShortcutActivators { BusyMarkAppShortcuts.toggleSidebar.activator; } +abstract final class BusyMarkAppShortcutGtkAccelerators { + const BusyMarkAppShortcutGtkAccelerators._(); + + static const search = BusyMarkAppShortcuts.searchGtkAccelerator; + static const keyboardShortcuts = + BusyMarkAppShortcuts.keyboardShortcutsGtkAccelerator; + static const markdownAndHtml = + BusyMarkAppShortcuts.markdownAndHtmlGtkAccelerator; + static const settings = BusyMarkAppShortcuts.settingsGtkAccelerator; +} + enum BusyMarkDocumentViewShortcutAction { editor, source, preview, split } abstract final class BusyMarkDocumentViewShortcuts { @@ -180,6 +223,11 @@ abstract final class BusyMarkDocumentViewShortcuts { static const previewLabel = 'Ctrl+Alt+3'; static const splitLabel = 'Ctrl+Alt+4'; + static const editorGtkAccelerator = '1'; + static const sourceGtkAccelerator = '2'; + static const previewGtkAccelerator = '3'; + static const splitGtkAccelerator = '4'; + static const editor = BusyMarkShortcutDefinition( label: editorLabel, activator: SingleActivator( @@ -187,6 +235,7 @@ abstract final class BusyMarkDocumentViewShortcuts { control: true, alt: true, ), + gtkAccelerator: editorGtkAccelerator, ); static const source = BusyMarkShortcutDefinition( label: sourceLabel, @@ -195,6 +244,7 @@ abstract final class BusyMarkDocumentViewShortcuts { control: true, alt: true, ), + gtkAccelerator: sourceGtkAccelerator, ); static const preview = BusyMarkShortcutDefinition( label: previewLabel, @@ -203,6 +253,7 @@ abstract final class BusyMarkDocumentViewShortcuts { control: true, alt: true, ), + gtkAccelerator: previewGtkAccelerator, ); static const split = BusyMarkShortcutDefinition( label: splitLabel, @@ -211,6 +262,7 @@ abstract final class BusyMarkDocumentViewShortcuts { control: true, alt: true, ), + gtkAccelerator: splitGtkAccelerator, ); static const definitions = @@ -244,6 +296,15 @@ abstract final class BusyMarkDocumentViewShortcutActivators { BusyMarkDocumentViewShortcuts.split.activator; } +abstract final class BusyMarkDocumentViewShortcutGtkAccelerators { + const BusyMarkDocumentViewShortcutGtkAccelerators._(); + + static const editor = BusyMarkDocumentViewShortcuts.editorGtkAccelerator; + static const source = BusyMarkDocumentViewShortcuts.sourceGtkAccelerator; + static const preview = BusyMarkDocumentViewShortcuts.previewGtkAccelerator; + static const split = BusyMarkDocumentViewShortcuts.splitGtkAccelerator; +} + enum BusyMarkTextEditingShortcutAction { selectAll, cut, diff --git a/lib/src/platform/header_bar_configuration.dart b/lib/src/platform/header_bar_configuration.dart index 92821fc..a349e49 100644 --- a/lib/src/platform/header_bar_configuration.dart +++ b/lib/src/platform/header_bar_configuration.dart @@ -16,10 +16,15 @@ class HeaderBarLabels { required this.split, required this.viewMode, required this.editorShortcut, + required this.editorGtkAccelerator, required this.sourceShortcut, + required this.sourceGtkAccelerator, required this.previewShortcut, + required this.previewGtkAccelerator, required this.splitShortcut, + required this.splitGtkAccelerator, required this.search, + required this.searchShortcut, required this.refresh, required this.menu, required this.sidebar, @@ -28,10 +33,13 @@ class HeaderBarLabels { required this.save, required this.settings, required this.settingsShortcut, + required this.settingsGtkAccelerator, required this.keyboardShortcuts, required this.keyboardShortcutsShortcut, + required this.keyboardShortcutsGtkAccelerator, required this.markdownAndHtml, required this.markdownAndHtmlShortcut, + required this.markdownAndHtmlGtkAccelerator, required this.reportIssue, required this.aboutBusyMark, }); @@ -42,10 +50,15 @@ class HeaderBarLabels { final String split; final String viewMode; final String editorShortcut; + final String editorGtkAccelerator; final String sourceShortcut; + final String sourceGtkAccelerator; final String previewShortcut; + final String previewGtkAccelerator; final String splitShortcut; + final String splitGtkAccelerator; final String search; + final String searchShortcut; final String refresh; final String menu; final String sidebar; @@ -54,10 +67,13 @@ class HeaderBarLabels { final String save; final String settings; final String settingsShortcut; + final String settingsGtkAccelerator; final String keyboardShortcuts; final String keyboardShortcutsShortcut; + final String keyboardShortcutsGtkAccelerator; final String markdownAndHtml; final String markdownAndHtmlShortcut; + final String markdownAndHtmlGtkAccelerator; final String reportIssue; final String aboutBusyMark; @@ -68,10 +84,15 @@ class HeaderBarLabels { 'split': split, 'viewMode': viewMode, 'editorShortcut': editorShortcut, + 'editorGtkAccelerator': editorGtkAccelerator, 'sourceShortcut': sourceShortcut, + 'sourceGtkAccelerator': sourceGtkAccelerator, 'previewShortcut': previewShortcut, + 'previewGtkAccelerator': previewGtkAccelerator, 'splitShortcut': splitShortcut, + 'splitGtkAccelerator': splitGtkAccelerator, 'search': search, + 'searchShortcut': searchShortcut, 'refresh': refresh, 'menu': menu, 'sidebar': sidebar, @@ -80,10 +101,13 @@ class HeaderBarLabels { 'save': save, 'settings': settings, 'settingsShortcut': settingsShortcut, + 'settingsGtkAccelerator': settingsGtkAccelerator, 'keyboardShortcuts': keyboardShortcuts, 'keyboardShortcutsShortcut': keyboardShortcutsShortcut, + 'keyboardShortcutsGtkAccelerator': keyboardShortcutsGtkAccelerator, 'markdownAndHtml': markdownAndHtml, 'markdownAndHtmlShortcut': markdownAndHtmlShortcut, + 'markdownAndHtmlGtkAccelerator': markdownAndHtmlGtkAccelerator, 'reportIssue': reportIssue, 'aboutBusyMark': aboutBusyMark, }; diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index cb2eff6..30adae8 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -51,6 +51,9 @@ constexpr char kLegacyYaruWindowShadowCompatibilityCss[] = "}"; constexpr char kLtrIsolateStart[] = "\xE2\x81\xA6"; constexpr char kBidiIsolateEnd[] = "\xE2\x81\xA9"; +constexpr char kMenuAcceleratorAttribute[] = "x-busymark-accelerator"; +constexpr char kModelButtonAcceleratorKey[] = + "busymark-model-button-accelerator"; struct _MyApplication { GtkApplication parent_instance; @@ -1023,19 +1026,110 @@ static const gchar* localized_label_or(FlValue* labels, return value != nullptr ? value : fallback; } +static void add_model_button_accelerator(GtkWidget* button, + const gchar* accelerator) { + if (button == nullptr || !GTK_IS_MODEL_BUTTON(button) || + accelerator == nullptr || accelerator[0] == '\0' || + g_object_get_data(G_OBJECT(button), kModelButtonAcceleratorKey) != + nullptr) { + return; + } + + guint accelerator_key = 0; + GdkModifierType accelerator_modifiers = static_cast(0); + gtk_accelerator_parse(accelerator, &accelerator_key, + &accelerator_modifiers); + if (accelerator_key == 0) { + return; + } + + GtkWidget* content = gtk_bin_get_child(GTK_BIN(button)); + if (content == nullptr || !GTK_IS_WIDGET(content)) { + return; + } + g_object_ref(content); + gtk_container_remove(GTK_CONTAINER(button), content); + + GtkWidget* row = + gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); + gtk_widget_set_hexpand(content, TRUE); + gtk_box_pack_start(GTK_BOX(row), content, TRUE, TRUE, 0); + + // GtkAccelLabel computes its accelerator width only after mapping, while a + // model popover allocates its width before mapping. Let GTK format the + // platform-native text, then render it as a regular label so the shortcut's + // natural width participates in the popover's first allocation. + g_autofree gchar* accelerator_text = + gtk_accelerator_get_label(accelerator_key, accelerator_modifiers); + GtkWidget* accelerator_label = gtk_label_new(accelerator_text); + gtk_widget_set_direction(accelerator_label, GTK_TEXT_DIR_LTR); + gtk_widget_set_halign(accelerator_label, GTK_ALIGN_END); + gtk_widget_set_valign(accelerator_label, GTK_ALIGN_CENTER); + gtk_label_set_xalign(GTK_LABEL(accelerator_label), 1.0); + gtk_style_context_add_class( + gtk_widget_get_style_context(accelerator_label), "dim-label"); + gtk_box_pack_end(GTK_BOX(row), accelerator_label, FALSE, FALSE, 0); + + gtk_container_add(GTK_CONTAINER(button), row); + gtk_widget_show_all(row); + g_object_unref(content); + g_object_set_data(G_OBJECT(button), kModelButtonAcceleratorKey, + GINT_TO_POINTER(1)); +} + +struct ModelMenuAcceleratorDecoration { + GMenuModel* model; + gint item_index; +}; + +static void decorate_model_menu_accelerators_cb(GtkWidget* widget, + gpointer user_data) { + auto* decoration = + static_cast(user_data); + if (GTK_IS_MODEL_BUTTON(widget)) { + if (decoration->item_index < + g_menu_model_get_n_items(decoration->model)) { + g_autoptr(GVariant) value = g_menu_model_get_item_attribute_value( + decoration->model, decoration->item_index, + kMenuAcceleratorAttribute, G_VARIANT_TYPE_STRING); + if (value != nullptr) { + add_model_button_accelerator( + widget, g_variant_get_string(value, nullptr)); + } + } + decoration->item_index++; + return; + } + if (GTK_IS_CONTAINER(widget)) { + gtk_container_foreach(GTK_CONTAINER(widget), + decorate_model_menu_accelerators_cb, user_data); + } +} + +static void decorate_model_menu_accelerators(GtkWidget* popover, + GMenuModel* model) { + if (popover == nullptr || !GTK_IS_CONTAINER(popover) || model == nullptr) { + return; + } + ModelMenuAcceleratorDecoration decoration = {model, 0}; + gtk_container_foreach(GTK_CONTAINER(popover), + decorate_model_menu_accelerators_cb, &decoration); +} + static void append_action_menu_item(GMenu* menu, const gchar* label, const gchar* action, const gchar* icon_name, - const gchar* shortcut) { + const gchar* accelerator) { GMenuItem* item = g_menu_item_new(label, action); if (icon_name != nullptr) { GIcon* icon = g_themed_icon_new(icon_name); g_menu_item_set_icon(item, icon); g_object_unref(icon); } - if (shortcut != nullptr && shortcut[0] != '\0') { - g_menu_item_set_attribute(item, "accel", "s", shortcut); + if (accelerator != nullptr && accelerator[0] != '\0') { + g_menu_item_set_attribute(item, kMenuAcceleratorAttribute, "s", + accelerator); } g_menu_append_item(menu, item); g_object_unref(item); @@ -1050,18 +1144,18 @@ static void rebuild_main_menu_model(MyApplication* self, FlValue* labels) { self->main_menu_model, localized_label_or(labels, "settings", ""), "header.settings", main_menu_icon_name("settings"), - fl_lookup_string_arg(labels, "settingsShortcut")); + fl_lookup_string_arg(labels, "settingsGtkAccelerator")); append_action_menu_item( self->main_menu_model, localized_label_or(labels, "keyboardShortcuts", ""), "header.keyboard-shortcuts", main_menu_icon_name("keyboardShortcuts"), - fl_lookup_string_arg(labels, "keyboardShortcutsShortcut")); + fl_lookup_string_arg(labels, "keyboardShortcutsGtkAccelerator")); append_action_menu_item( self->main_menu_model, localized_label_or(labels, "markdownAndHtml", ""), "header.markdown-and-html", main_menu_icon_name("markdownAndHtml"), - fl_lookup_string_arg(labels, "markdownAndHtmlShortcut")); + fl_lookup_string_arg(labels, "markdownAndHtmlGtkAccelerator")); append_action_menu_item( self->main_menu_model, localized_label_or(labels, "reportIssue", ""), @@ -1070,6 +1164,10 @@ static void rebuild_main_menu_model(MyApplication* self, FlValue* labels) { self->main_menu_model, localized_label_or(labels, "aboutBusyMark", ""), "header.about", main_menu_icon_name("aboutBusyMark"), nullptr); + decorate_model_menu_accelerators( + self->sidebar_menu, G_MENU_MODEL(self->main_menu_model)); + decorate_model_menu_accelerators( + self->adaptive_menu, G_MENU_MODEL(self->main_menu_model)); } static const gchar* view_mode_dart_action(const gchar* mode) { @@ -1198,14 +1296,15 @@ static void setup_header_actions(MyApplication* self) { static void append_view_mode_menu_item(GMenu* menu, const gchar* label, const gchar* mode, - const gchar* shortcut) { + const gchar* accelerator) { GMenuItem* item = g_menu_item_new(label, nullptr); g_menu_item_set_action_and_target(item, "header.view-mode", "s", mode); GIcon* icon = g_themed_icon_new(view_mode_icon_name(mode)); g_menu_item_set_icon(item, icon); g_object_unref(icon); - if (shortcut != nullptr && shortcut[0] != '\0') { - g_menu_item_set_attribute(item, "accel", "s", shortcut); + if (accelerator != nullptr && accelerator[0] != '\0') { + g_menu_item_set_attribute(item, kMenuAcceleratorAttribute, "s", + accelerator); } g_menu_append_item(menu, item); g_object_unref(item); @@ -1220,19 +1319,21 @@ static void rebuild_view_mode_menu_model(MyApplication* self, append_view_mode_menu_item( self->view_mode_menu_model, localized_label_or(labels, "editor", ""), "editor", - fl_lookup_string_arg(labels, "editorShortcut")); + fl_lookup_string_arg(labels, "editorGtkAccelerator")); append_view_mode_menu_item( self->view_mode_menu_model, localized_label_or(labels, "source", ""), "source", - fl_lookup_string_arg(labels, "sourceShortcut")); + fl_lookup_string_arg(labels, "sourceGtkAccelerator")); append_view_mode_menu_item( self->view_mode_menu_model, localized_label_or(labels, "preview", ""), "preview", - fl_lookup_string_arg(labels, "previewShortcut")); + fl_lookup_string_arg(labels, "previewGtkAccelerator")); append_view_mode_menu_item( self->view_mode_menu_model, localized_label_or(labels, "split", ""), "split", - fl_lookup_string_arg(labels, "splitShortcut")); + fl_lookup_string_arg(labels, "splitGtkAccelerator")); + decorate_model_menu_accelerators( + self->view_mode_menu, G_MENU_MODEL(self->view_mode_menu_model)); } static GtkWidget* create_model_menu_button(GMenuModel* model, @@ -1253,6 +1354,7 @@ static GtkWidget* create_model_menu_button(GMenuModel* model, GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(button)); if (popover != nullptr) { gtk_popover_set_position(popover, GTK_POS_BOTTOM); + decorate_model_menu_accelerators(GTK_WIDGET(popover), model); if (popover_out != nullptr) { *popover_out = GTK_WIDGET(popover); } @@ -1282,9 +1384,26 @@ static void set_widget_tooltip_with_shortcut(GtkWidget* widget, g_free(value); } +static const gchar* view_mode_shortcut_label(MyApplication* self, + FlValue* labels) { + const gchar* mode = self->view_mode != nullptr ? self->view_mode : "split"; + if (g_strcmp0(mode, "editor") == 0) { + return fl_lookup_string_arg(labels, "editorShortcut"); + } + if (g_strcmp0(mode, "source") == 0) { + return fl_lookup_string_arg(labels, "sourceShortcut"); + } + if (g_strcmp0(mode, "preview") == 0) { + return fl_lookup_string_arg(labels, "previewShortcut"); + } + return fl_lookup_string_arg(labels, "splitShortcut"); +} + static void set_localized_labels(MyApplication* self, FlValue* args) { const gchar* view_mode = fl_lookup_string_arg(args, "viewMode"); const gchar* search = fl_lookup_string_arg(args, "search"); + const gchar* search_shortcut = + fl_lookup_string_arg(args, "searchShortcut"); const gchar* refresh = fl_lookup_string_arg(args, "refresh"); const gchar* menu = fl_lookup_string_arg(args, "menu"); const gchar* sidebar = fl_lookup_string_arg(args, "sidebar"); @@ -1295,8 +1414,10 @@ static void set_localized_labels(MyApplication* self, FlValue* args) { set_widget_tooltip(self->back_button, back); set_widget_tooltip_with_shortcut(self->sidebar_toggle_button, sidebar, sidebar_shortcut); - set_widget_tooltip(self->sidebar_search_button, search); - set_widget_tooltip(self->adaptive_search_button, search); + set_widget_tooltip_with_shortcut(self->sidebar_search_button, search, + search_shortcut); + set_widget_tooltip_with_shortcut(self->adaptive_search_button, search, + search_shortcut); if (self->search_entry != nullptr && GTK_IS_ENTRY(self->search_entry) && search != nullptr) { gtk_entry_set_placeholder_text(GTK_ENTRY(self->search_entry), search); @@ -1304,7 +1425,9 @@ static void set_localized_labels(MyApplication* self, FlValue* args) { set_widget_tooltip(self->sidebar_menu_button, menu); set_widget_tooltip(self->adaptive_menu_button, menu); set_widget_tooltip(self->refresh_button, refresh); - set_widget_tooltip(self->view_mode_button, view_mode); + set_widget_tooltip_with_shortcut( + self->view_mode_button, view_mode, + view_mode_shortcut_label(self, args)); rebuild_main_menu_model(self, args); rebuild_view_mode_menu_model(self, args); update_view_mode_icon(self); diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index 44b21e2..9f66f8b 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -597,11 +597,12 @@ void main() { expect(light.sidebarBorder, const Color.fromRGBO(24, 24, 24, 0.08)); expect(light.headerbar, const Color(0xFFFAFAFA)); expect(light.card, const Color(0xFFFFFFFF)); - expect(light.groupedList, light.card); + expect(light.groupedSurface, const Color(0xFFFFFFFF)); + expect(light.cardShade, const Color.fromRGBO(24, 24, 24, 0.08)); expect(light.dialog, const Color(0xFFFAFAFA)); expect(light.popover, const Color(0xFFFAFAFA)); expect(light.card, isNot(light.dialog)); - expect(light.groupedList, isNot(light.dialog)); + expect(light.groupedSurface, isNot(light.dialog)); expect(light.control, const Color.fromRGBO(0, 0, 0, 0.10)); expect(light.dialogOutline, const Color.fromRGBO(255, 255, 255, 0.07)); expect(light.floatingBorder, const Color.fromRGBO(0, 0, 0, 0.14)); @@ -616,11 +617,16 @@ void main() { expect(dark.sidebarBorder, const Color.fromRGBO(16, 16, 16, 0.35)); expect(dark.headerbar, const Color(0xFF393939)); expect(dark.card, const Color(0xFF3D3D3D)); - expect(dark.groupedList, dark.card); + expect(dark.groupedSurface, const Color.fromRGBO(255, 255, 255, 0.08)); + expect(dark.cardShade, const Color.fromRGBO(0, 0, 0, 0.36)); + expect( + Color.alphaBlend(dark.groupedSurface, dark.window).toARGB32(), + dark.card.toARGB32(), + ); expect(dark.dialog, const Color(0xFF3E3E3E)); expect(dark.popover, const Color(0xFF3E3E3E)); expect(dark.card, isNot(dark.dialog)); - expect(dark.groupedList, isNot(dark.dialog)); + expect(dark.groupedSurface, isNot(dark.dialog)); expect(dark.control, const Color.fromRGBO(255, 255, 255, 0.10)); expect(dark.dialogOutline, const Color.fromRGBO(255, 255, 255, 0.07)); expect(dark.floatingBorder, const Color.fromRGBO(0, 0, 0, 0.14)); @@ -631,10 +637,10 @@ void main() { final orangeDark = colors(Brightness.dark, orange); expect(orangeLight.window, light.window); expect(orangeLight.sidebar, light.sidebar); - expect(orangeLight.groupedList, light.groupedList); + expect(orangeLight.groupedSurface, light.groupedSurface); expect(orangeDark.window, dark.window); expect(orangeDark.sidebar, dark.sidebar); - expect(orangeDark.groupedList, dark.groupedList); + expect(orangeDark.groupedSurface, dark.groupedSurface); for (final palette in [light, dark]) { expect(palette.mutedForeground.a, 1); for (final background in [ @@ -646,7 +652,6 @@ void main() { palette.headerbarFlat, palette.panel, palette.card, - palette.groupedList, palette.dialog, palette.popover, ]) { @@ -656,9 +661,9 @@ void main() { reason: 'Muted text must remain legible on $background', ); } - expect(palette.admonitionNote, isNot(palette.groupedList)); - expect(palette.admonitionTip, isNot(palette.groupedList)); - expect(palette.admonitionWarning, isNot(palette.groupedList)); + expect(palette.admonitionNote, isNot(palette.card)); + expect(palette.admonitionTip, isNot(palette.card)); + expect(palette.admonitionWarning, isNot(palette.card)); } }); @@ -687,7 +692,7 @@ void main() { expect(style.side?.resolve({WidgetState.selected}), BorderSide.none); }); - testWidgets('grouped cards use one semantic raised-surface token', ( + testWidgets('grouped cards use native boxed-list depth and roles', ( tester, ) async { final theme = buildBusyMarkTheme( @@ -717,19 +722,24 @@ void main() { ), ); + final groupedSurface = find.byType(BusyMarkGroupedSurface); final groupedMaterial = tester.widget( find.descendant( - of: find.byType(BusyMarkGroupedSurface), + of: groupedSurface, matching: find.byWidgetPredicate( - (widget) => widget is Material && widget.color == colors.groupedList, + (widget) => widget is Material && widget.color == colors.card, ), ), ); - expect(groupedMaterial.elevation, BusyMarkElevation.surface); - expect(groupedMaterial.shadowColor, theme.colorScheme.shadow); + expect(groupedMaterial.elevation, theme.cardTheme.elevation); + expect(groupedMaterial.shadowColor, Colors.transparent); + expect( + _nativeCardDecoration(tester, groupedSurface).shadows, + BusyMarkShadow.nativeCardShadows(theme.colorScheme.shadow), + ); expect( (groupedMaterial.shape! as RoundedRectangleBorder).borderRadius, - BorderRadius.circular(BusyMarkRadius.lg), + BorderRadius.circular(kYaruContainerRadius), ); final cardMaterial = tester.widget( @@ -740,13 +750,144 @@ void main() { ), ), ); - expect(cardMaterial.elevation, BusyMarkElevation.surface); - expect(cardMaterial.shadowColor, theme.colorScheme.shadow); + expect(cardMaterial.elevation, theme.cardTheme.elevation); + expect(cardMaterial.shadowColor, Colors.transparent); expect( (cardMaterial.shape! as RoundedRectangleBorder).borderRadius, - BorderRadius.circular(BusyMarkRadius.lg), + BorderRadius.circular(kYaruContainerRadius), + ); + expect( + tester.widget(find.byType(Divider)).color, + colors.cardShade, + ); + expect( + DefaultTextStyle.of(tester.element(find.text('One'))).style.color, + isNot(colors.mutedForeground), + ); + }); + + testWidgets('dialog grouped cards resolve the contextual native layer', ( + tester, + ) async { + final theme = buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: BusyMarkSurfaceScope( + role: BusyMarkSurfaceRole.dialog, + child: const BusyMarkGroupedSurface(child: SizedBox(height: 40)), + ), + ), + ); + + final expected = Color.alphaBlend(colors.groupedSurface, colors.dialog); + final material = tester.widget( + find.descendant( + of: find.byType(BusyMarkGroupedSurface), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == expected, + ), + ), + ); + expect(material.color, expected); + expect(expected, isNot(colors.card)); + }); + + for (final brightness in Brightness.values) { + testWidgets('grouped rows use the native ${brightness.name} hover role', ( + tester, + ) async { + final baseTheme = buildBusyMarkTheme( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + const rowHover = Color(0x1A2A7FFF); + final theme = baseTheme.copyWith(hoverColor: rowHover); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Column( + children: [ + BusyMarkActionRow(title: 'Open', onTap: () {}), + BusyMarkSwitchRow( + title: 'Enabled', + value: true, + onChanged: (_) {}, + ), + ], + ), + ), + ), + ); + + final expectedHover = brightness == Brightness.dark + ? rowHover + : rowHover.withValues( + alpha: rowHover.a * BusyMarkAlpha.groupedRowLightHoverStrength, + ); + final actionTile = tester.widget( + find.descendant( + of: find.byType(BusyMarkActionRow), + matching: find.byType(YaruListTile), + ), + ); + final switchTile = tester.widget( + find.descendant( + of: find.byType(BusyMarkSwitchRow), + matching: find.byType(YaruListTile), + ), + ); + expect(actionTile.hoverColor, expectedHover); + expect(switchTile.hoverColor, expectedHover); + }); + } + + testWidgets('grouped row subtitles use semantic native text roles', ( + tester, + ) async { + final theme = buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: Column( + children: [ + BusyMarkActionRow(title: 'Open', subtitle: 'Markdown document'), + BusyMarkActionRow( + title: 'Unavailable', + subtitle: 'Disabled description', + enabled: false, + ), + ], + ), + ), + ), + ); + + expect( + DefaultTextStyle.of( + tester.element(find.text('Markdown document')), + ).style.color, + colors.mutedForeground, + ); + expect( + DefaultTextStyle.of( + tester.element(find.text('Disabled description')), + ).style.color, + colors.disabledForeground, ); - expect(tester.widget(find.byType(Divider)).color, colors.divider); }); testWidgets('dialog roles and popup selectors use real themed buttons', ( @@ -1241,6 +1382,22 @@ ShapeBorder? _shapeWithSide(ShapeBorder? shape, BorderSide side) { }; } +ShapeDecoration _nativeCardDecoration(WidgetTester tester, Finder surface) { + final decoratedBox = tester.widget( + find.descendant( + of: surface, + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is ShapeDecoration && + ((widget.decoration as ShapeDecoration).shadows?.isNotEmpty ?? + false), + ), + ), + ); + return decoratedBox.decoration as ShapeDecoration; +} + Future<_CapturedPixels> _capturePixels( WidgetTester tester, GlobalKey boundaryKey, diff --git a/test/src/header_bar_configuration_test.dart b/test/src/header_bar_configuration_test.dart index b69896c..6adf3a3 100644 --- a/test/src/header_bar_configuration_test.dart +++ b/test/src/header_bar_configuration_test.dart @@ -450,10 +450,15 @@ const _labels = HeaderBarLabels( split: 'Split', viewMode: 'View mode', editorShortcut: 'Ctrl+1', + editorGtkAccelerator: '1', sourceShortcut: 'Ctrl+2', + sourceGtkAccelerator: '2', previewShortcut: 'Ctrl+3', + previewGtkAccelerator: '3', splitShortcut: 'Ctrl+4', + splitGtkAccelerator: '4', search: 'Search', + searchShortcut: 'Ctrl+F', refresh: 'Refresh', menu: 'Menu', sidebar: 'Sidebar', @@ -462,10 +467,13 @@ const _labels = HeaderBarLabels( save: 'Save', settings: 'Settings', settingsShortcut: 'Ctrl+,', + settingsGtkAccelerator: 'comma', keyboardShortcuts: 'Keyboard Shortcuts', keyboardShortcutsShortcut: 'Ctrl+?', + keyboardShortcutsGtkAccelerator: 'question', markdownAndHtml: 'Markdown and HTML', markdownAndHtmlShortcut: 'F1', + markdownAndHtmlGtkAccelerator: 'F1', reportIssue: 'Report Issue', aboutBusyMark: 'About BusyMark', ); diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 768a8b4..4f5e2bf 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -263,7 +263,8 @@ void main() { expect(aboutPack, isNonNegative); expect(reportIssuePack, lessThan(aboutPack)); expect(native, contains('g_menu_item_set_icon(item, icon)')); - expect(native, contains('g_menu_item_set_attribute(item, "accel"')); + expect(native, contains('kMenuAcceleratorAttribute')); + expect(native, contains('gtk_accelerator_get_label')); expect(native, contains('g_action_map_add_action')); expect(native, contains('gtk_widget_insert_action_group')); expect(native, isNot(contains('static GtkWidget* create_menu_item'))); @@ -1143,6 +1144,8 @@ void main() { app, contains('editorShortcut: BusyMarkDocumentViewShortcutLabels.editor'), ); + expect(app, contains('editorGtkAccelerator:')); + expect(app, contains('BusyMarkDocumentViewShortcutGtkAccelerators.editor')); expect( app, contains('sourceShortcut: BusyMarkDocumentViewShortcutLabels.source'), @@ -1159,6 +1162,7 @@ void main() { app, contains('sidebarShortcut: BusyMarkSidebarShortcutLabels.toggleSidebar'), ); + expect(app, contains('searchShortcut: BusyMarkAppShortcutLabels.search')); expect(workspace, contains('case HeaderBarAction.viewModeEditor:')); expect(workspace, contains('case HeaderBarAction.viewModeSource:')); expect(workspace, contains('case HeaderBarAction.viewModePreview:')); @@ -1211,9 +1215,13 @@ void main() { 'set_widget_tooltip_with_shortcut(self->sidebar_toggle_button, sidebar', ), ); + expect(native, contains('view_mode_shortcut_label(self, args)')); expect( native, - contains('set_widget_tooltip(self->view_mode_button, view_mode)'), + contains( + 'set_widget_tooltip_with_shortcut(\n' + ' self->view_mode_button', + ), ); expect(native, contains('rebuild_view_mode_menu_model(self, args)')); expect( @@ -1226,15 +1234,26 @@ void main() { expect(native, isNot(contains('viewModeAgenda'))); }); - test('native popovers use menu-model accelerators without fake rows', () { + test('native model-menu rows render GTK-formatted accelerator labels', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); - expect(native, contains('g_menu_item_set_attribute(item, "accel"')); + expect(native, contains('kMenuAcceleratorAttribute')); + expect(native, contains('GTK_IS_MODEL_BUTTON(widget)')); + expect(native, contains('gtk_accelerator_parse(accelerator')); + expect(native, contains('gtk_accelerator_get_label')); + expect(native, contains('gtk_label_new(accelerator_text)')); + expect(native, contains('decorate_model_menu_accelerators(')); expect(native, contains('g_menu_item_set_icon(item, icon)')); expect(native, contains('gtk_menu_button_set_menu_model')); expect(native, isNot(contains('busymark-shortcut-widget'))); expect(native, isNot(contains('busymark-menu-row'))); - expect(native, contains('set_widget_tooltip(self->view_mode_button')); + expect( + native, + contains( + 'set_widget_tooltip_with_shortcut(\n' + ' self->view_mode_button', + ), + ); }); test('welcome page has a sidebar but no document controls', () { diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index cdbce37..d6eba9d 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -189,10 +189,10 @@ void main() { final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); expect(design, contains('class _BusyMarkGroupedListSurface')); - expect(design, contains('cardTheme.shape ?? RoundedRectangleBorder')); + expect(design, contains('final fallbackShape = RoundedRectangleBorder')); expect(design, contains('BorderRadius.circular(BusyMarkRadius.lg)')); expect(design, contains('this.clipBehavior = Clip.antiAlias')); - expect(design, contains('height: BusyMarkStroke.hairline')); + expect(design, contains('height: 1')); expect(design, isNot(contains('YaruTileList'))); expect(design, isNot(contains('YaruBorderContainer'))); }); @@ -265,15 +265,21 @@ void main() { r'Color busyMarkRowHoverColor\(BuildContext context\) \{(.*?)\n\}', dotAll: true, ).firstMatch(design)!.group(1)!; - expect(helper, contains('Theme.of(context).hoverColor')); + expect(helper, contains('final hover = theme.hoverColor')); + expect(helper, contains('BusyMarkAlpha.groupedRowLightHoverStrength')); expect(helper, isNot(contains('colors.foreground.withValues'))); expect(design, isNot(contains('class _BusyMarkHoverBackground'))); final actionRow = RegExp( r'class BusyMarkActionRow[\s\S]*?class BusyMarkSwitchRow', ).firstMatch(design)!.group(0)!; - expect(actionRow, contains('return YaruListTile.square(')); + expect(actionRow, contains('final row = YaruListTile.square(')); expect(actionRow, isNot(contains('MouseRegion('))); - expect(actionRow, isNot(contains('hoverColor:'))); + expect( + actionRow, + contains( + 'hoverColor: widget.hoverColor ?? busyMarkRowHoverColor(context)', + ), + ); final switchRow = RegExp( r'class BusyMarkSwitchRow[\s\S]*?class BusyMarkDialogShell', ).firstMatch(design)!.group(0)!; @@ -283,27 +289,26 @@ void main() { expect(switchRow, contains('shape: const RoundedRectangleBorder()')); }); - test('shared surfaces use one semantic physical-elevation path', () { + test('shared grouped surfaces use native card shadow layers', () { final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); final dialogs = File( 'lib/src/app/busymark_dialogs.dart', ).readAsStringSync(); final theme = File('lib/src/app/app_theme.dart').readAsStringSync(); - expect(design, isNot(contains('abstract final class BusyMarkShadow'))); - expect(design, isNot(contains('BoxShadow('))); + expect(design, contains('abstract final class BusyMarkShadow')); + expect(design, contains('nativeCardShadows(Color semanticShadow)')); + expect(design, contains('BoxShadow(')); expect(design, isNot(contains('busyMarkSurfaceDecoration'))); - expect(design, contains('final cardTheme = Theme.of(context).cardTheme')); + expect(design, contains('final cardTheme = CardTheme.of(context)')); final surface = RegExp( r'class BusyMarkSurface.*?class BusyMarkGroupedList', dotAll: true, ).firstMatch(design)!.group(0)!; - expect(surface, contains('cardTheme.color ?? colors.card')); - expect(surface, contains('elevation: filled ? BusyMarkElevation.surface')); - expect( - surface, - contains('shadowColor: Theme.of(context).colorScheme.shadow'), - ); + expect(surface, contains('cardTheme.color ?? surfaceColors.card')); + expect(surface, contains('BusyMarkShadow.nativeCardShadowsFor(context)')); + expect(surface, contains('shadowColor: Colors.transparent')); + expect(surface, contains('color: busyMarkGroupedSurfaceColor(context)')); expect(theme, contains('shadowColor: colorScheme.shadow')); expect(theme, contains('cardTheme: base.cardTheme.copyWith')); @@ -327,10 +332,11 @@ void main() { r'class _BusyMarkGroupedListSurface.*?class BusyMarkActionRow', dotAll: true, ).firstMatch(design)!.group(0)!; - expect(groupedSurface, contains('height: BusyMarkStroke.hairline')); - expect(groupedSurface, contains('thickness: BusyMarkStroke.hairline')); - expect(groupedSurface, contains('color: colors.divider')); - expect(design, contains('required this.groupedList')); + expect(groupedSurface, contains('height: 1')); + expect(groupedSurface, contains('thickness: 1')); + expect(groupedSurface, contains('color: colors.cardShade')); + expect(design, contains('required this.groupedSurface')); + expect(design, contains('required this.cardShade')); expect( design, contains('BusyMarkSurfaceColors.fromTheme(ThemeData theme)'), @@ -339,7 +345,8 @@ void main() { design, contains('return _busyMarkSemanticSurfaceColors(theme.brightness)'), ); - expect(design, contains('groupedList: groupedList')); + expect(design, contains('groupedSurface: groupedSurface')); + expect(design, contains('cardShade:')); expect(design, contains('class BusyMarkGroupedSurface')); expect(groupedSurface, contains('return BusyMarkGroupedSurface(')); expect(groupedSurface, isNot(contains('busyMarkSurfaceDecoration'))); @@ -381,7 +388,7 @@ void main() { expect(design, contains('final window = switch (brightness)')); expect(design, contains('final floatingSurface = switch (brightness)')); expect(design, contains('window: window')); - expect(design, contains('groupedList: groupedList')); + expect(design, contains('groupedSurface: groupedSurface')); expect(design, contains('dialog: floatingSurface')); expect(design, contains('popover: floatingSurface')); expect(design, contains('dialogOutline:')); diff --git a/test/src/surface_palette_render_test.dart b/test/src/surface_palette_render_test.dart index 411046f..dd9ba6f 100644 --- a/test/src/surface_palette_render_test.dart +++ b/test/src/surface_palette_render_test.dart @@ -16,7 +16,7 @@ void main() { window: Color(0xFFFAFAFA), dialog: Color(0xFFFAFAFA), sidebar: Color(0xFFEBEBEB), - card: Color(0xFFFFFFFF), + groupedSurface: Color(0xFFFFFFFF), popover: Color(0xFFFAFAFA), dialogOutline: Color.fromRGBO(255, 255, 255, 0.07), floatingBorder: Color.fromRGBO(0, 0, 0, 0.14), @@ -27,7 +27,7 @@ void main() { window: Color(0xFF2C2C2C), dialog: Color(0xFF3E3E3E), sidebar: Color(0xFF393939), - card: Color(0xFF3D3D3D), + groupedSurface: Color.fromRGBO(255, 255, 255, 0.08), popover: Color(0xFF3E3E3E), dialogOutline: Color.fromRGBO(255, 255, 255, 0.07), floatingBorder: Color.fromRGBO(0, 0, 0, 0.14), @@ -187,7 +187,11 @@ void main() { expect(_pixelAtProbe(tester, pixels, windowProbe), baseline.window); expect(_pixelAtProbe(tester, pixels, sidebarProbe), baseline.sidebar); expect(_pixelAtProbe(tester, pixels, dialogProbe), baseline.dialog); - expect(_pixelAtProbe(tester, pixels, cardProbe), baseline.card); + _expectColorNear( + _pixelAtProbe(tester, pixels, cardProbe), + Color.alphaBlend(baseline.groupedSurface, baseline.dialog), + tolerance: 1, + ); expect(_pixelAtProbe(tester, pixels, popoverProbe), baseline.popover); final dialogSize = tester.getSize(dialogMaterial); final dialogEdge = _pixelAtLocal( @@ -229,7 +233,7 @@ class _SurfaceBaseline { required this.window, required this.dialog, required this.sidebar, - required this.card, + required this.groupedSurface, required this.popover, required this.dialogOutline, required this.floatingBorder, @@ -240,7 +244,7 @@ class _SurfaceBaseline { final Color window; final Color dialog; final Color sidebar; - final Color card; + final Color groupedSurface; final Color popover; final Color dialogOutline; final Color floatingBorder; From 7d8c890a9a393a83273dc4e3f8a79315c4edc89f Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 22:44:20 -0700 Subject: [PATCH 11/29] Add native menu support with shortcut handling and session management. Introduce a new method channel for native menus and enhance button shortcut label functionality for improved user interaction. Add native menu support with GTK integration and fallback for Flutter. Implement BusyMarkMenuButton for enhanced menu presentation and user interaction. Improve menu session management and cleanup. --- lib/src/app/busymark_design.dart | 620 +++++++++++++++++---- lib/src/platform/native_menu_service.dart | 125 +++++ linux/runner/my_application.cc | 624 +++++++++++++++++++++- test/flutter_test_config.dart | 22 + test/src/busymark_design_test.dart | 117 +++- test/src/native_headerbar_audit_test.dart | 30 +- test/src/native_menu_service_test.dart | 124 +++++ test/src/source_audit_test.dart | 31 +- 8 files changed, 1545 insertions(+), 148 deletions(-) create mode 100644 lib/src/platform/native_menu_service.dart create mode 100644 test/flutter_test_config.dart create mode 100644 test/src/native_menu_service_test.dart diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index 88452a5..dbfba47 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -3,8 +3,10 @@ import 'dart:math' as math; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:yaru/yaru.dart'; +import '../platform/native_menu_service.dart'; import 'busymark_glyphs.dart'; abstract final class BusyMarkSpacing { @@ -1069,6 +1071,7 @@ class BusyMarkHeaderPopupMenuButton extends StatefulWidget { this.foregroundColor, this.backgroundColor, this.borderRadius = BusyMarkRadius.headerButton, + this.nativeMenuService = const NativeMenuService(), }); final String tooltip; @@ -1084,6 +1087,7 @@ class BusyMarkHeaderPopupMenuButton extends StatefulWidget { final Color? foregroundColor; final WidgetStateProperty? backgroundColor; final double borderRadius; + final NativeMenuService nativeMenuService; @override State> createState() => @@ -1092,38 +1096,28 @@ class BusyMarkHeaderPopupMenuButton extends StatefulWidget { class _BusyMarkHeaderPopupMenuButtonState extends State> { - final _menuKey = GlobalKey>(); - List> _items = const []; + final _triggerKey = GlobalKey(); + BusyMarkMenuSession? _activeMenuSession; var _loading = false; var _open = false; + @override + void dispose() { + final session = _activeMenuSession; + _activeMenuSession = null; + if (session != null) { + unawaited(session.dismiss()); + } + super.dispose(); + } + @override Widget build(BuildContext context) { - return Stack( - alignment: Alignment.center, - children: [ - // PopupMenuButton owns route placement, RTL growth, focus, Escape, and - // menu semantics. The visible Yaru button only performs the async load - // before asking that framework control to open. - ExcludeSemantics( - child: PopupMenuButton( - key: _menuKey, - enabled: false, - tooltip: '', - useRootNavigator: true, - position: PopupMenuPosition.under, - requestFocus: true, - itemBuilder: (_) => _items, - onOpened: () => _setOpen(true), - onCanceled: () => _setOpen(false), - onSelected: (selection) { - _setOpen(false); - widget.onSelected(selection); - }, - child: const SizedBox.square(dimension: BusyMarkSizes.iconButton), - ), - ), - BusyMarkHeaderIconButton( + return KeyedSubtree( + key: _triggerKey, + child: Semantics( + expanded: _open, + child: BusyMarkHeaderIconButton( tooltip: widget.tooltip, icon: widget.icon, shortcut: widget.shortcut, @@ -1135,7 +1129,7 @@ class _BusyMarkHeaderPopupMenuButtonState borderRadius: widget.borderRadius, onPressed: _loadAndShowMenu, ), - ], + ), ); } @@ -1149,23 +1143,274 @@ class _BusyMarkHeaderPopupMenuButtonState if (!mounted || items.isEmpty) { return; } - _items = List.unmodifiable(items); - _menuKey.currentState?.showButtonMenu(); + final triggerContext = _triggerKey.currentContext; + if (triggerContext == null || !triggerContext.mounted) { + return; + } + final session = BusyMarkMenuSession(); + _activeMenuSession = session; + setState(() => _open = true); + T? selection; + try { + selection = await showBusyMarkMenu( + context: triggerContext, + anchorContext: triggerContext, + items: List.unmodifiable(items), + nativeMenuService: widget.nativeMenuService, + session: session, + ); + } finally { + if (mounted && identical(_activeMenuSession, session)) { + setState(() { + _activeMenuSession = null; + _open = false; + }); + } + } + if (mounted && !session.dismissed && selection != null) { + widget.onSelected(selection); + } } finally { if (mounted) { setState(() => _loading = false); } } } +} + +/// Owns one native or Flutter fallback menu presentation. +final class BusyMarkMenuSession { + BusyMarkMenuSession() : _nativeSession = NativeMenuSession(); + + final NativeMenuSession _nativeSession; + final GlobalKey _fallbackRouteKey = GlobalKey(); + NativeMenuService _nativeMenuService = const NativeMenuService(); + Route? _fallbackRoute; + var _started = false; + var _dismissed = false; + + bool get dismissed => _dismissed; + + Future dismiss() async { + if (_dismissed) { + return; + } + _dismissed = true; + _removeFallbackRoute(); + await _nativeMenuService.dismiss(_nativeSession); + } + + void _beginPresentation(NativeMenuService nativeMenuService) { + if (_started) { + throw StateError('A BusyMarkMenuSession can present only one menu.'); + } + _started = true; + _nativeMenuService = nativeMenuService; + } + + void _captureFallbackRoute() { + final routeContext = _fallbackRouteKey.currentContext; + final route = routeContext == null ? null : ModalRoute.of(routeContext); + if (route == null) { + return; + } + _fallbackRoute = route; + if (_dismissed) { + _removeFallbackRoute(); + } + } + + void _releaseFallbackRoute() { + _fallbackRoute = null; + } - void _setOpen(bool value) { - if (mounted && _open != value) { - setState(() => _open = value); + void _removeFallbackRoute() { + final route = _fallbackRoute; + final navigator = route?.navigator; + if (route != null && navigator != null && route.isActive) { + navigator.removeRoute(route); } + _fallbackRoute = null; } } -/// Shows a BusyMark-styled context menu at a global pointer position. +/// Presents a menu through GTK on Linux and a themed Flutter route elsewhere. +Future showBusyMarkMenu({ + required BuildContext context, + required List> items, + BuildContext? anchorContext, + Offset? anchorPoint, + Rect? anchorRect, + NativeMenuService nativeMenuService = const NativeMenuService(), + BusyMarkMenuSession? session, + bool focusFirst = false, + bool preferAbove = false, + double? width, +}) async { + assert( + anchorRect == null || (anchorContext == null && anchorPoint == null), + 'anchorRect cannot be combined with anchorContext or anchorPoint.', + ); + if (items.isEmpty) { + return null; + } + final itemSnapshot = List>.unmodifiable(items); + final presentation = session ?? BusyMarkMenuSession(); + if (presentation.dismissed) { + return null; + } + presentation._beginPresentation(nativeMenuService); + final anchor = + anchorRect ?? + _busyMarkMenuAnchorRect(anchorContext ?? context, anchorPoint); + final nativeEntries = _busyMarkNativeMenuEntries(itemSnapshot); + if (nativeEntries != null) { + final nativeResult = await nativeMenuService.show( + session: presentation._nativeSession, + anchor: anchor, + entries: nativeEntries, + focusFirst: focusFirst, + preferAbove: preferAbove, + ); + if (presentation.dismissed) { + return null; + } + if (nativeResult.available) { + return _busyMarkMenuValueAt(itemSnapshot, nativeResult.selectedIndex); + } + } + if (!context.mounted) { + return null; + } + final selection = await _showBusyMarkFallbackMenu( + context: context, + anchor: anchor, + items: itemSnapshot, + session: presentation, + width: width, + ); + return presentation.dismissed ? null : selection; +} + +Rect _busyMarkMenuAnchorRect(BuildContext anchorContext, Offset? anchorPoint) { + if (anchorPoint != null) { + return Rect.fromLTWH(anchorPoint.dx, anchorPoint.dy, 0, 0); + } + final renderObject = anchorContext.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) { + return Rect.zero; + } + return renderObject.localToGlobal(Offset.zero) & renderObject.size; +} + +List? _busyMarkNativeMenuEntries( + List> items, +) { + final entries = []; + for (final item in items) { + if (item is BusyMarkPopupMenuItem) { + entries.add( + NativeMenuEntry.command( + label: item.label, + shortcut: item.shortcut, + enabled: item.enabled, + checkable: item.trailingCheck, + selected: item.trailingCheck && item.checked, + ), + ); + } else if (item is PopupMenuDivider) { + entries.add(const NativeMenuEntry.separator()); + } else { + return null; + } + } + return entries; +} + +T? _busyMarkMenuValueAt(List> items, int? index) { + if (index == null || index < 0 || index >= items.length) { + return null; + } + final item = items[index]; + if (item is! BusyMarkPopupMenuItem || !item.enabled) { + return null; + } + return item.menuValue; +} + +Future _showBusyMarkFallbackMenu({ + required BuildContext context, + required Rect anchor, + required List> items, + required BusyMarkMenuSession session, + required double? width, +}) async { + final navigator = Navigator.of(context, rootNavigator: true); + final overlay = navigator.overlay?.context.findRenderObject(); + if (overlay is! RenderBox || !overlay.hasSize) { + return null; + } + final localAnchor = Rect.fromPoints( + overlay.globalToLocal(anchor.topLeft), + overlay.globalToLocal(anchor.bottomRight), + ); + final menuAnchor = Rect.fromLTWH( + localAnchor.left, + localAnchor.bottom, + localAnchor.width, + 0, + ); + final fallbackItems = _busyMarkFallbackItems( + items, + session._fallbackRouteKey, + ); + final selection = showMenu( + context: context, + useRootNavigator: true, + position: RelativeRect.fromRect(menuAnchor, Offset.zero & overlay.size), + items: fallbackItems, + constraints: width == null ? null : BoxConstraints.tightFor(width: width), + requestFocus: true, + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + session._captureFallbackRoute(); + }); + try { + return await selection; + } finally { + session._releaseFallbackRoute(); + } +} + +List> _busyMarkFallbackItems( + List> items, + GlobalKey routeKey, +) { + final fallbackItems = >[]; + var routeKeyPending = true; + for (final item in items) { + if (item is BusyMarkPopupMenuItem) { + fallbackItems.add( + BusyMarkPopupMenuItem( + value: item.menuValue, + label: item.label, + icon: item.icon, + shortcut: item.shortcut, + enabled: item.enabled, + checked: item.checked, + trailingCheck: item.trailingCheck, + routeKey: routeKeyPending ? routeKey : null, + ), + ); + routeKeyPending = false; + } else { + fallbackItems.add(item); + } + } + return fallbackItems; +} + +/// Shows a BusyMark context menu at a global pointer position. /// /// The menu opens away from the pointer's reading-direction edge and stays /// inside the root overlay. Use [BusyMarkPopupMenuItem] entries to keep menu @@ -1179,37 +1424,11 @@ Future showBusyMarkContextMenu( if (items.isEmpty) { return Future.value(); } - final navigator = Navigator.of(context, rootNavigator: true); - final overlay = navigator.overlay?.context.findRenderObject(); - if (overlay is! RenderBox) { - return Future.value(); - } - final localPosition = overlay.globalToLocal(globalPosition); - final minLeft = BusyMarkSpacing.sm; - final maxLeft = overlay.size.width - width - BusyMarkSpacing.sm; - final preferredLeft = Directionality.of(context) == TextDirection.rtl - ? localPosition.dx - width - : localPosition.dx; - final left = maxLeft <= minLeft - ? minLeft - : preferredLeft.clamp(minLeft, maxLeft).toDouble(); - final maxTop = math.max( - BusyMarkSpacing.sm, - overlay.size.height - BusyMarkSpacing.sm, - ); - final top = localPosition.dy.clamp(BusyMarkSpacing.sm, maxTop).toDouble(); - return showMenu( + return showBusyMarkMenu( context: context, - useRootNavigator: true, - position: RelativeRect.fromLTRB( - left, - top, - math.max(minLeft, overlay.size.width - left - width), - math.max(BusyMarkSpacing.sm, overlay.size.height - top), - ), + anchorPoint: globalPosition, items: items, - constraints: BoxConstraints.tightFor(width: width), - requestFocus: true, + width: width, ); } @@ -1223,23 +1442,29 @@ class BusyMarkPopupMenuItem extends PopupMenuItem { super.enabled = true, bool checked = false, bool trailingCheck = false, + Key? routeKey, }) : label = label, + menuValue = value, icon = icon, shortcut = shortcut, checked = checked, trailingCheck = trailingCheck, super( value: value, - child: _BusyMarkPopupMenuItemContent( - label: label, - icon: icon, - shortcut: shortcut, - checked: checked, - trailingCheck: trailingCheck, + child: KeyedSubtree( + key: routeKey, + child: _BusyMarkPopupMenuItemContent( + label: label, + icon: icon, + shortcut: shortcut, + checked: checked, + trailingCheck: trailingCheck, + ), ), ); final String label; + final T menuValue; final IconData? icon; final String? shortcut; final bool checked; @@ -1306,6 +1531,181 @@ class _BusyMarkPopupMenuItemContent extends StatelessWidget { } } +typedef BusyMarkMenuTriggerBuilder = + Widget Function(BuildContext context, BusyMarkMenuTriggerDetails trigger); + +@immutable +class BusyMarkMenuTriggerDetails { + const BusyMarkMenuTriggerDetails._({ + required this.onPressed, + required this.focusNode, + required this.isOpen, + required GlobalKey anchorKey, + }) : _anchorKey = anchorKey; + + final VoidCallback? onPressed; + final FocusNode focusNode; + final bool isOpen; + final GlobalKey _anchorKey; + + Widget anchor({required Widget child}) { + return KeyedSubtree(key: _anchorKey, child: child); + } +} + +/// A shared trigger that presents GTK menus with a Flutter fallback. +class BusyMarkMenuButton extends StatefulWidget { + const BusyMarkMenuButton({ + super.key, + required this.tooltip, + required this.items, + required this.onSelected, + required this.triggerBuilder, + this.enabled = true, + this.nativeMenuService = const NativeMenuService(), + this.fallbackMenuWidth, + }); + + final String tooltip; + final List> items; + final ValueChanged onSelected; + final BusyMarkMenuTriggerBuilder triggerBuilder; + final bool enabled; + final NativeMenuService nativeMenuService; + final double? fallbackMenuWidth; + + @override + State> createState() => _BusyMarkMenuButtonState(); +} + +class _BusyMarkMenuButtonState extends State> { + final _triggerKey = GlobalKey(); + final _anchorKey = GlobalKey(); + late final FocusNode _focusNode; + BusyMarkMenuSession? _activeMenuSession; + var _open = false; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode( + debugLabel: 'BusyMark menu trigger', + onKeyEvent: _handleKeyEvent, + ); + } + + @override + void didUpdateWidget(covariant BusyMarkMenuButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.enabled && !widget.enabled && _open) { + _closeMenu(); + } + } + + @override + void dispose() { + final session = _activeMenuSession; + _activeMenuSession = null; + if (session != null) { + unawaited(session.dismiss()); + } + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final trigger = BusyMarkMenuTriggerDetails._( + onPressed: widget.enabled ? _toggleMenu : null, + focusNode: _focusNode, + isOpen: _open, + anchorKey: _anchorKey, + ); + return KeyedSubtree( + key: _triggerKey, + child: widget.triggerBuilder(context, trigger), + ); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (!widget.enabled || event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown || + event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.space) { + if (!_open) { + unawaited(_openMenu(focusFirst: true)); + } + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.escape && _open) { + _closeMenu(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _toggleMenu() { + if (_open) { + _closeMenu(); + return; + } + unawaited(_openMenu()); + } + + Future _openMenu({bool focusFirst = false}) async { + final triggerContext = _triggerKey.currentContext; + if (!widget.enabled || + _open || + triggerContext == null || + widget.items.isEmpty) { + return; + } + final items = List>.unmodifiable(widget.items); + final onSelected = widget.onSelected; + final session = BusyMarkMenuSession(); + _activeMenuSession = session; + setState(() => _open = true); + + T? selection; + try { + final anchorContext = _anchorKey.currentContext ?? triggerContext; + selection = await showBusyMarkMenu( + context: triggerContext, + anchorContext: anchorContext, + items: items, + nativeMenuService: widget.nativeMenuService, + session: session, + focusFirst: focusFirst, + width: widget.fallbackMenuWidth, + ); + } finally { + if (mounted && identical(_activeMenuSession, session)) { + setState(() { + _activeMenuSession = null; + _open = false; + }); + } + } + if (mounted && !session.dismissed && selection != null) { + onSelected(selection); + } + } + + void _closeMenu() { + final session = _activeMenuSession; + if (session == null) { + return; + } + setState(() { + _activeMenuSession = null; + _open = false; + }); + unawaited(session.dismiss()); + } +} + class BusyMarkPopupSelectorOption { const BusyMarkPopupSelectorOption({ required this.value, @@ -1346,26 +1746,20 @@ class BusyMarkPopupSelector extends StatelessWidget { @override Widget build(BuildContext context) { final selectorEnabled = enabled && options.isNotEmpty; + final fallbackMenuWidth = buttonMaxWidth.clamp( + popupMinWidth, + popupMaxWidth, + ); return Align( alignment: AlignmentDirectional.centerEnd, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: buttonMaxWidth), - child: YaruPopupMenuButton( - initialValue: value, - enabled: selectorEnabled, + child: BusyMarkMenuButton( tooltip: tooltip, - semanticLabel: tooltip, - // Preserve Yaru's selector geometry and interaction states, while - // matching libadwaita's inline grouped-row value affordance. - style: Theme.of(context).outlinedButtonTheme.style?.copyWith( - side: const WidgetStatePropertyAll(BorderSide.none), - ), - constraints: BoxConstraints( - minWidth: popupMinWidth, - maxWidth: popupMaxWidth, - ), + enabled: selectorEnabled, + fallbackMenuWidth: fallbackMenuWidth, onSelected: onSelected, - itemBuilder: (context) => [ + items: [ for (final option in options) BusyMarkPopupMenuItem( value: option.value, @@ -1375,22 +1769,52 @@ class BusyMarkPopupSelector extends StatelessWidget { trailingCheck: true, ), ], - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: math.max( - 0, - buttonMaxWidth - - BusyMarkSizes.iconButton - - BusyMarkSpacing.smPlus, + triggerBuilder: (context, trigger) { + return trigger.anchor( + child: Tooltip( + message: tooltip, + child: Semantics( + expanded: trigger.isOpen, + child: BusyMarkPushButton.standard( + onPressed: trigger.onPressed, + focusNode: trigger.focusNode, + style: Theme.of(context).outlinedButtonTheme.style + ?.copyWith( + side: const WidgetStatePropertyAll(BorderSide.none), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: math.max( + 0, + buttonMaxWidth - + BusyMarkSizes.iconButton - + BusyMarkSpacing.smPlus, + ), + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + ), + ), + ), + const SizedBox(width: BusyMarkSpacing.sm), + const Icon( + BusyMarkGlyphs.downArrow, + size: BusyMarkSizes.iconSm, + ), + ], + ), + ), + ), ), - ), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - ), - ), + ); + }, ), ), ); diff --git a/lib/src/platform/native_menu_service.dart b/lib/src/platform/native_menu_service.dart new file mode 100644 index 0000000..c0230e5 --- /dev/null +++ b/lib/src/platform/native_menu_service.dart @@ -0,0 +1,125 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +@visibleForTesting +const nativeMenuChannelName = 'busymark/native_menus'; + +/// Identifies one native menu presentation. +/// +/// The host compares this identity when dismissing a menu, so a retiring +/// widget cannot close a newer caller's popover. +@immutable +final class NativeMenuSession { + NativeMenuSession() : id = _nextId++; + + static int _nextId = 1; + + final int id; +} + +/// One semantic row exposed by a host-toolkit menu. +@immutable +final class NativeMenuEntry { + const NativeMenuEntry.command({ + required this.label, + this.shortcut, + this.enabled = true, + this.checkable = false, + this.selected = false, + }) : separator = false; + + const NativeMenuEntry.separator() + : label = '', + shortcut = null, + enabled = false, + checkable = false, + selected = false, + separator = true; + + final String label; + final String? shortcut; + final bool enabled; + final bool checkable; + final bool selected; + final bool separator; + + Map _toPlatformMap() { + return { + 'label': label, + if (shortcut != null && shortcut!.isNotEmpty) 'shortcut': shortcut!, + 'enabled': enabled, + 'checkable': checkable, + 'selected': selected, + 'separator': separator, + }; + } +} + +/// Result of asking the host toolkit to present a native menu. +@immutable +final class NativeMenuResult { + const NativeMenuResult.available({this.selectedIndex}) : available = true; + + const NativeMenuResult.unavailable() + : available = false, + selectedIndex = null; + + final bool available; + final int? selectedIndex; +} + +/// Presents anchored menus through the host desktop toolkit when available. +class NativeMenuService { + const NativeMenuService({ + MethodChannel channel = const MethodChannel(nativeMenuChannelName), + }) : _channel = channel; + + final MethodChannel _channel; + + Future show({ + required NativeMenuSession session, + required Rect anchor, + required List entries, + bool focusFirst = false, + bool preferAbove = false, + }) async { + try { + final selectedIndex = await _channel.invokeMethod('show', { + 'sessionId': session.id, + 'anchor': { + 'x': anchor.left, + 'y': anchor.top, + 'width': anchor.width, + 'height': anchor.height, + }, + 'entries': [for (final entry in entries) entry._toPlatformMap()], + 'focusFirst': focusFirst, + 'preferredPosition': preferAbove ? 'top' : 'bottom', + }); + return NativeMenuResult.available(selectedIndex: selectedIndex); + } on MissingPluginException { + return const NativeMenuResult.unavailable(); + } on PlatformException catch (error) { + if (error.code == 'unavailable') { + return const NativeMenuResult.unavailable(); + } + rethrow; + } + } + + Future dismiss(NativeMenuSession session) async { + try { + return await _channel.invokeMethod('dismiss', { + 'sessionId': session.id, + }) ?? + false; + } on MissingPluginException { + return false; + } on PlatformException catch (error) { + if (error.code == 'unavailable') { + return false; + } + rethrow; + } + } +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 30adae8..a16a312 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -13,6 +13,7 @@ constexpr char kApplicationDisplayName[] = "BusyMark"; constexpr char kHeaderBarChannel[] = "com.busymark.app/headerbar"; +constexpr char kNativeMenuChannel[] = "busymark/native_menus"; constexpr gint kHeaderButtonHeight = 32; constexpr gint kHeaderButtonSpacing = 8; constexpr gint kHeaderSidebarInset = 8; @@ -59,6 +60,7 @@ struct _MyApplication { GtkApplication parent_instance; char** dart_entrypoint_arguments; FlMethodChannel* header_bar_channel; + FlMethodChannel* native_menu_channel; GtkCssProvider* header_bar_css_provider; GtkWindow* main_window; GtkWidget* flutter_view; @@ -1026,23 +1028,15 @@ static const gchar* localized_label_or(FlValue* labels, return value != nullptr ? value : fallback; } -static void add_model_button_accelerator(GtkWidget* button, - const gchar* accelerator) { +static void add_model_button_shortcut_label(GtkWidget* button, + const gchar* shortcut) { if (button == nullptr || !GTK_IS_MODEL_BUTTON(button) || - accelerator == nullptr || accelerator[0] == '\0' || + shortcut == nullptr || shortcut[0] == '\0' || g_object_get_data(G_OBJECT(button), kModelButtonAcceleratorKey) != nullptr) { return; } - guint accelerator_key = 0; - GdkModifierType accelerator_modifiers = static_cast(0); - gtk_accelerator_parse(accelerator, &accelerator_key, - &accelerator_modifiers); - if (accelerator_key == 0) { - return; - } - GtkWidget* content = gtk_bin_get_child(GTK_BIN(button)); if (content == nullptr || !GTK_IS_WIDGET(content)) { return; @@ -1055,20 +1049,14 @@ static void add_model_button_accelerator(GtkWidget* button, gtk_widget_set_hexpand(content, TRUE); gtk_box_pack_start(GTK_BOX(row), content, TRUE, TRUE, 0); - // GtkAccelLabel computes its accelerator width only after mapping, while a - // model popover allocates its width before mapping. Let GTK format the - // platform-native text, then render it as a regular label so the shortcut's - // natural width participates in the popover's first allocation. - g_autofree gchar* accelerator_text = - gtk_accelerator_get_label(accelerator_key, accelerator_modifiers); - GtkWidget* accelerator_label = gtk_label_new(accelerator_text); - gtk_widget_set_direction(accelerator_label, GTK_TEXT_DIR_LTR); - gtk_widget_set_halign(accelerator_label, GTK_ALIGN_END); - gtk_widget_set_valign(accelerator_label, GTK_ALIGN_CENTER); - gtk_label_set_xalign(GTK_LABEL(accelerator_label), 1.0); + GtkWidget* shortcut_label = gtk_label_new(shortcut); + gtk_widget_set_direction(shortcut_label, GTK_TEXT_DIR_LTR); + gtk_widget_set_halign(shortcut_label, GTK_ALIGN_END); + gtk_widget_set_valign(shortcut_label, GTK_ALIGN_CENTER); + gtk_label_set_xalign(GTK_LABEL(shortcut_label), 1.0); gtk_style_context_add_class( - gtk_widget_get_style_context(accelerator_label), "dim-label"); - gtk_box_pack_end(GTK_BOX(row), accelerator_label, FALSE, FALSE, 0); + gtk_widget_get_style_context(shortcut_label), "dim-label"); + gtk_box_pack_end(GTK_BOX(row), shortcut_label, FALSE, FALSE, 0); gtk_container_add(GTK_CONTAINER(button), row); gtk_widget_show_all(row); @@ -1077,6 +1065,28 @@ static void add_model_button_accelerator(GtkWidget* button, GINT_TO_POINTER(1)); } +static void add_model_button_accelerator(GtkWidget* button, + const gchar* accelerator) { + if (accelerator == nullptr || accelerator[0] == '\0') { + return; + } + guint accelerator_key = 0; + GdkModifierType accelerator_modifiers = static_cast(0); + gtk_accelerator_parse(accelerator, &accelerator_key, + &accelerator_modifiers); + if (accelerator_key == 0) { + return; + } + + // GtkAccelLabel computes its accelerator width only after mapping, while a + // model popover allocates its width before mapping. Let GTK format the + // platform-native text, then render it as a regular label so the shortcut's + // natural width participates in the popover's first allocation. + g_autofree gchar* accelerator_text = + gtk_accelerator_get_label(accelerator_key, accelerator_modifiers); + add_model_button_shortcut_label(button, accelerator_text); +} + struct ModelMenuAcceleratorDecoration { GMenuModel* model; gint item_index; @@ -1940,6 +1950,569 @@ static void register_header_bar_channel(MyApplication* self, FlView* view) { self->header_bar_channel, header_bar_method_call_cb, self, nullptr); } +constexpr char kNativeMenuActionNamespace[] = "busymark-native-menu"; +constexpr char kNativeMenuActionIndexKey[] = "busymark-native-menu-index"; + +struct NativeMenuHandlerData; + +struct NativeMenuSession { + NativeMenuHandlerData* owner; + gint64 id; + size_t entry_count; + GtkWidget* popover; + GMenu* model; + GSimpleActionGroup* action_group; + FlMethodCall* method_call; + GPtrArray* shortcut_labels; + gulong closed_signal_id; + guint cleanup_source_id; + gint pending_selected_index; +}; + +struct NativeMenuHandlerData { + GtkWidget* view; + NativeMenuSession* active; +}; + +static void native_menu_session_respond(NativeMenuSession* session, + gint selected_index) { + if (session->method_call == nullptr) { + return; + } + g_autoptr(FlValue) result = selected_index < 0 + ? fl_value_new_null() + : fl_value_new_int(selected_index); + fl_method_call_respond_success(session->method_call, result, nullptr); + g_clear_object(&session->method_call); +} + +static void native_menu_session_dispose(NativeMenuSession* session) { + if (session == nullptr) { + return; + } + + NativeMenuHandlerData* owner = session->owner; + if (owner != nullptr && owner->active == session) { + owner->active = nullptr; + } + if (session->cleanup_source_id != 0) { + g_source_remove(session->cleanup_source_id); + session->cleanup_source_id = 0; + } + if (session->popover != nullptr) { + if (session->closed_signal_id != 0) { + g_signal_handler_disconnect(session->popover, + session->closed_signal_id); + session->closed_signal_id = 0; + } + if (gtk_widget_get_visible(session->popover)) { + gtk_widget_hide(session->popover); + } + gtk_widget_destroy(session->popover); + g_clear_object(&session->popover); + } + if (owner != nullptr && owner->view != nullptr) { + gtk_widget_insert_action_group(owner->view, kNativeMenuActionNamespace, + nullptr); + if (gtk_widget_get_realized(owner->view)) { + gtk_widget_grab_focus(owner->view); + } + } + g_clear_pointer(&session->shortcut_labels, g_ptr_array_unref); + g_clear_object(&session->model); + g_clear_object(&session->action_group); + native_menu_session_respond(session, session->pending_selected_index); + g_free(session); +} + +static gboolean native_menu_cleanup_idle_cb(gpointer user_data) { + auto* session = static_cast(user_data); + session->cleanup_source_id = 0; + native_menu_session_dispose(session); + return G_SOURCE_REMOVE; +} + +static void native_menu_closed_cb(GtkPopover*, gpointer user_data) { + auto* session = static_cast(user_data); + if (session->cleanup_source_id == 0) { + session->cleanup_source_id = g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, native_menu_cleanup_idle_cb, session, + nullptr); + } +} + +static void native_menu_action_activated_cb(GSimpleAction* action, + GVariant*, + gpointer user_data) { + auto* session = static_cast(user_data); + session->pending_selected_index = + GPOINTER_TO_INT( + g_object_get_data(G_OBJECT(action), kNativeMenuActionIndexKey)) - + 1; + if (session->popover != nullptr) { + gtk_popover_popdown(GTK_POPOVER(session->popover)); + } +} + +static void native_menu_selection_activated_cb(GSimpleAction* action, + GVariant* parameter, + gpointer user_data) { + if (parameter == nullptr || + !g_variant_is_of_type(parameter, G_VARIANT_TYPE_STRING)) { + return; + } + const gchar* target = g_variant_get_string(parameter, nullptr); + gchar* end = nullptr; + const guint64 parsed = g_ascii_strtoull(target, &end, 10); + auto* session = static_cast(user_data); + if (target[0] == '\0' || end == nullptr || *end != '\0' || + parsed > static_cast(G_MAXINT) || + parsed >= session->entry_count) { + return; + } + + g_simple_action_set_state(action, parameter); + session->pending_selected_index = static_cast(parsed); + if (session->popover != nullptr) { + gtk_popover_popdown(GTK_POPOVER(session->popover)); + } +} + +static gboolean native_menu_dismiss_active(NativeMenuHandlerData* data, + gint64 session_id) { + NativeMenuSession* session = data->active; + if (session == nullptr || session->id != session_id) { + return FALSE; + } + if (session->popover != nullptr && + gtk_widget_get_visible(session->popover)) { + gtk_popover_popdown(GTK_POPOVER(session->popover)); + } else { + native_menu_session_dispose(session); + } + return TRUE; +} + +static gboolean fl_lookup_optional_bool_with_default( + FlValue* args, + const gchar* key, + gboolean fallback, + gboolean* value_out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr) { + *value_out = fallback; + return TRUE; + } + if (fl_value_get_type(value) != FL_VALUE_TYPE_BOOL) { + return FALSE; + } + *value_out = fl_value_get_bool(value); + return TRUE; +} + +static gboolean fl_lookup_positive_int64_arg(FlValue* args, + const gchar* key, + gint64* value_out) { + if (!fl_lookup_int64_arg(args, key, value_out) || *value_out <= 0) { + return FALSE; + } + return TRUE; +} + +static void respond_native_menu_argument_error(FlMethodCall* method_call, + const gchar* message) { + fl_method_call_respond_error(method_call, "invalid-arguments", message, + nullptr, nullptr); +} + +static gboolean parse_native_menu_anchor(FlValue* args, + GdkRectangle* rectangle_out) { + FlValue* anchor = fl_lookup_map_arg(args, "anchor"); + if (anchor == nullptr) { + return FALSE; + } + gdouble x = 0; + gdouble y = 0; + gdouble width = 0; + gdouble height = 0; + if (!fl_lookup_double_arg(anchor, "x", &x) || + !fl_lookup_double_arg(anchor, "y", &y) || + !fl_lookup_double_arg(anchor, "width", &width) || + !fl_lookup_double_arg(anchor, "height", &height) || + !std::isfinite(x) || !std::isfinite(y) || !std::isfinite(width) || + !std::isfinite(height) || width < 0 || height < 0) { + return FALSE; + } + + const gdouble left = std::floor(x); + const gdouble top = std::floor(y); + const gdouble right = std::ceil(x + width); + const gdouble bottom = std::ceil(y + height); + const gdouble pixel_width = std::max(1.0, right - left); + const gdouble pixel_height = std::max(1.0, bottom - top); + if (left < G_MININT || left > G_MAXINT || top < G_MININT || + top > G_MAXINT || pixel_width > G_MAXINT || + pixel_height > G_MAXINT) { + return FALSE; + } + + rectangle_out->x = static_cast(left); + rectangle_out->y = static_cast(top); + rectangle_out->width = static_cast(pixel_width); + rectangle_out->height = static_cast(pixel_height); + return TRUE; +} + +struct NativeMenuShortcutDecoration { + GPtrArray* labels; + guint index; +}; + +static void decorate_native_menu_shortcuts_cb(GtkWidget* widget, + gpointer user_data) { + auto* decoration = static_cast(user_data); + if (GTK_IS_MODEL_BUTTON(widget)) { + if (decoration->index < decoration->labels->len) { + add_model_button_shortcut_label( + widget, static_cast( + g_ptr_array_index(decoration->labels, + decoration->index))); + } + decoration->index++; + return; + } + if (GTK_IS_CONTAINER(widget)) { + gtk_container_foreach(GTK_CONTAINER(widget), + decorate_native_menu_shortcuts_cb, user_data); + } +} + +static void decorate_native_menu_shortcuts(GtkWidget* popover, + GPtrArray* labels) { + if (popover == nullptr || !GTK_IS_CONTAINER(popover) || labels == nullptr) { + return; + } + NativeMenuShortcutDecoration decoration = {labels, 0}; + gtk_container_foreach(GTK_CONTAINER(popover), + decorate_native_menu_shortcuts_cb, &decoration); +} + +static void show_native_menu(NativeMenuHandlerData* data, + FlMethodCall* method_call, + FlValue* args) { + if (data->view == nullptr || !gtk_widget_get_realized(data->view)) { + fl_method_call_respond_error(method_call, "unavailable", + "The native menu host is unavailable.", + nullptr, nullptr); + return; + } + + GdkRectangle anchor = {}; + gint64 session_id = 0; + if (!fl_lookup_positive_int64_arg(args, "sessionId", &session_id) || + !parse_native_menu_anchor(args, &anchor)) { + respond_native_menu_argument_error( + method_call, + "sessionId must be positive and anchor must contain finite geometry."); + return; + } + + FlValue* entries = fl_value_lookup_string(args, "entries"); + gboolean focus_first = FALSE; + const gchar* preferred_position_arg = + fl_lookup_string_arg(args, "preferredPosition"); + GtkPositionType preferred_position = GTK_POS_BOTTOM; + if (g_strcmp0(preferred_position_arg, "top") == 0) { + preferred_position = GTK_POS_TOP; + } else if (preferred_position_arg != nullptr && + g_strcmp0(preferred_position_arg, "bottom") != 0) { + respond_native_menu_argument_error( + method_call, "preferredPosition must be top or bottom."); + return; + } + if (entries == nullptr || + fl_value_get_type(entries) != FL_VALUE_TYPE_LIST || + fl_value_get_length(entries) == 0 || + fl_value_get_length(entries) > static_cast(G_MAXINT) || + !fl_lookup_optional_bool_with_default(args, "focusFirst", FALSE, + &focus_first)) { + respond_native_menu_argument_error( + method_call, + "entries must be non-empty and focusFirst must be boolean."); + return; + } + + size_t command_count = 0; + size_t checkable_run_selected_count = 0; + gboolean checkable_run_has_disabled_entry = FALSE; + gboolean in_checkable_run = FALSE; + for (size_t index = 0; index < fl_value_get_length(entries); index++) { + FlValue* entry = fl_value_get_list_value(entries, index); + gboolean separator = FALSE; + gboolean enabled = TRUE; + gboolean checkable = FALSE; + gboolean selected = FALSE; + if (entry == nullptr || fl_value_get_type(entry) != FL_VALUE_TYPE_MAP || + !fl_lookup_optional_bool_with_default(entry, "separator", FALSE, + &separator) || + !fl_lookup_optional_bool_with_default(entry, "enabled", TRUE, + &enabled) || + !fl_lookup_optional_bool_with_default(entry, "checkable", FALSE, + &checkable) || + !fl_lookup_optional_bool_with_default(entry, "selected", FALSE, + &selected) || + (!separator && fl_lookup_string_arg(entry, "label") == nullptr) || + (selected && !checkable)) { + respond_native_menu_argument_error( + method_call, + "entries must contain valid command or separator presentation."); + return; + } + if (!separator) { + command_count++; + } + if (!separator && checkable) { + if (!in_checkable_run) { + checkable_run_selected_count = 0; + checkable_run_has_disabled_entry = FALSE; + in_checkable_run = TRUE; + } + checkable_run_selected_count += selected ? 1 : 0; + checkable_run_has_disabled_entry = + checkable_run_has_disabled_entry || !enabled; + continue; + } + if (in_checkable_run && + (checkable_run_selected_count > 1 || + checkable_run_has_disabled_entry)) { + respond_native_menu_argument_error( + method_call, + "single-choice groups allow at most one selected entry and require " + "enabled entries."); + return; + } + in_checkable_run = FALSE; + } + if (in_checkable_run && + (checkable_run_selected_count > 1 || + checkable_run_has_disabled_entry)) { + respond_native_menu_argument_error( + method_call, + "single-choice groups allow at most one selected entry and require " + "enabled entries."); + return; + } + if (command_count == 0) { + respond_native_menu_argument_error(method_call, + "entries must contain a command."); + return; + } + + if (data->active != nullptr) { + native_menu_session_dispose(data->active); + } + + auto* session = g_new0(NativeMenuSession, 1); + session->owner = data; + session->id = session_id; + session->entry_count = fl_value_get_length(entries); + session->pending_selected_index = -1; + session->method_call = + FL_METHOD_CALL(g_object_ref(G_OBJECT(method_call))); + session->action_group = g_simple_action_group_new(); + session->model = g_menu_new(); + session->shortcut_labels = g_ptr_array_new_with_free_func(g_free); + data->active = session; + + GMenu* section = g_menu_new(); + guint section_length = 0; + auto flush_section = [&]() { + if (section_length > 0) { + g_menu_append_section(session->model, nullptr, G_MENU_MODEL(section)); + } + g_object_unref(section); + section = g_menu_new(); + section_length = 0; + }; + + guint checkable_group_index = 0; + for (size_t index = 0; index < fl_value_get_length(entries);) { + FlValue* entry = fl_value_get_list_value(entries, index); + gboolean separator = FALSE; + fl_lookup_optional_bool_with_default(entry, "separator", FALSE, + &separator); + if (separator) { + flush_section(); + index++; + continue; + } + + const gchar* label = fl_lookup_string_arg(entry, "label"); + const gchar* shortcut = fl_lookup_string_arg(entry, "shortcut"); + gboolean enabled = TRUE; + gboolean checkable = FALSE; + gboolean selected = FALSE; + fl_lookup_optional_bool_with_default(entry, "enabled", TRUE, &enabled); + fl_lookup_optional_bool_with_default(entry, "checkable", FALSE, + &checkable); + fl_lookup_optional_bool_with_default(entry, "selected", FALSE, &selected); + + if (checkable) { + const size_t run_start = index; + size_t run_end = run_start; + g_autofree gchar* selected_target = g_strdup(""); + while (run_end < fl_value_get_length(entries)) { + FlValue* run_entry = fl_value_get_list_value(entries, run_end); + gboolean run_separator = FALSE; + gboolean run_checkable = FALSE; + gboolean run_selected = FALSE; + fl_lookup_optional_bool_with_default( + run_entry, "separator", FALSE, &run_separator); + fl_lookup_optional_bool_with_default( + run_entry, "checkable", FALSE, &run_checkable); + if (run_separator || !run_checkable) { + break; + } + fl_lookup_optional_bool_with_default( + run_entry, "selected", FALSE, &run_selected); + if (run_selected) { + g_free(selected_target); + selected_target = g_strdup_printf("%zu", run_end); + } + run_end++; + } + + g_autofree gchar* group_action_name = g_strdup_printf( + "select-group-%u", checkable_group_index++); + GSimpleAction* group_action = g_simple_action_new_stateful( + group_action_name, G_VARIANT_TYPE_STRING, + g_variant_new_string(selected_target)); + g_signal_connect(group_action, "activate", + G_CALLBACK(native_menu_selection_activated_cb), + session); + g_action_map_add_action(G_ACTION_MAP(session->action_group), + G_ACTION(group_action)); + g_autofree gchar* detailed_group_action = g_strdup_printf( + "%s.%s", kNativeMenuActionNamespace, group_action_name); + + for (size_t run_index = run_start; run_index < run_end; run_index++) { + FlValue* run_entry = fl_value_get_list_value(entries, run_index); + const gchar* run_label = + fl_lookup_string_arg(run_entry, "label"); + const gchar* run_shortcut = + fl_lookup_string_arg(run_entry, "shortcut"); + g_autofree gchar* target = g_strdup_printf("%zu", run_index); + g_autoptr(GMenuItem) item = g_menu_item_new(run_label, nullptr); + g_menu_item_set_action_and_target_value( + item, detailed_group_action, g_variant_new_string(target)); + g_menu_append_item(section, item); + section_length++; + g_ptr_array_add( + session->shortcut_labels, + g_strdup(run_shortcut != nullptr ? run_shortcut : "")); + } + g_object_unref(group_action); + index = run_end; + continue; + } + + g_autofree gchar* action_name = g_strdup_printf("select-%zu", index); + GSimpleAction* action = g_simple_action_new(action_name, nullptr); + g_simple_action_set_enabled(action, enabled); + g_object_set_data(G_OBJECT(action), kNativeMenuActionIndexKey, + GINT_TO_POINTER(static_cast(index) + 1)); + g_signal_connect(action, "activate", + G_CALLBACK(native_menu_action_activated_cb), session); + g_action_map_add_action(G_ACTION_MAP(session->action_group), + G_ACTION(action)); + + g_autofree gchar* detailed_action = + g_strdup_printf("%s.%s", kNativeMenuActionNamespace, action_name); + g_autoptr(GMenuItem) item = g_menu_item_new(label, detailed_action); + g_menu_append_item(section, item); + g_object_unref(action); + section_length++; + g_ptr_array_add(session->shortcut_labels, + g_strdup(shortcut != nullptr ? shortcut : "")); + index++; + } + flush_section(); + g_object_unref(section); + + gtk_widget_insert_action_group( + data->view, kNativeMenuActionNamespace, + G_ACTION_GROUP(session->action_group)); + session->popover = gtk_popover_new_from_model( + data->view, G_MENU_MODEL(session->model)); + g_object_ref_sink(session->popover); + gtk_popover_set_pointing_to(GTK_POPOVER(session->popover), &anchor); + gtk_popover_set_position(GTK_POPOVER(session->popover), + preferred_position); + gtk_popover_set_constrain_to(GTK_POPOVER(session->popover), + GTK_POPOVER_CONSTRAINT_WINDOW); + gtk_popover_set_modal(GTK_POPOVER(session->popover), TRUE); + decorate_native_menu_shortcuts(session->popover, + session->shortcut_labels); + session->closed_signal_id = g_signal_connect( + session->popover, "closed", G_CALLBACK(native_menu_closed_cb), session); + gtk_popover_popup(GTK_POPOVER(session->popover)); + if (focus_first) { + gtk_widget_child_focus(session->popover, GTK_DIR_TAB_FORWARD); + } +} + +static void native_menu_handler_data_free(gpointer user_data) { + auto* data = static_cast(user_data); + if (data->active != nullptr) { + native_menu_session_dispose(data->active); + } + if (data->view != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(data->view), + reinterpret_cast(&data->view)); + } + g_free(data); +} + +static void native_menu_method_call_cb(FlMethodChannel*, + FlMethodCall* method_call, + gpointer user_data) { + auto* data = static_cast(user_data); + const gchar* method = fl_method_call_get_name(method_call); + if (strcmp(method, "show") == 0) { + show_native_menu(data, method_call, fl_method_call_get_args(method_call)); + } else if (strcmp(method, "dismiss") == 0) { + gint64 session_id = 0; + if (!fl_lookup_positive_int64_arg(fl_method_call_get_args(method_call), + "sessionId", &session_id)) { + respond_native_menu_argument_error( + method_call, "sessionId must be a positive integer."); + return; + } + respond_bool(method_call, + native_menu_dismiss_active(data, session_id)); + } else { + fl_method_call_respond_not_implemented(method_call, nullptr); + } +} + +static void register_native_menu_channel(MyApplication* self, FlView* view) { + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + self->native_menu_channel = fl_method_channel_new( + fl_engine_get_binary_messenger(fl_view_get_engine(view)), + kNativeMenuChannel, FL_METHOD_CODEC(codec)); + auto* data = g_new0(NativeMenuHandlerData, 1); + data->view = GTK_WIDGET(view); + g_object_add_weak_pointer(G_OBJECT(data->view), + reinterpret_cast(&data->view)); + fl_method_channel_set_method_call_handler( + self->native_menu_channel, native_menu_method_call_cb, data, + native_menu_handler_data_free); +} + // Called when first Flutter frame received. static void first_frame_cb(MyApplication* self, FlView* view) { gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); @@ -1998,6 +2571,7 @@ static void my_application_activate(GApplication* application) { fl_register_plugins(FL_PLUGIN_REGISTRY(view)); register_header_bar_channel(self, view); + register_native_menu_channel(self, view); gtk_widget_grab_focus(GTK_WIDGET(view)); } @@ -2052,6 +2626,7 @@ static void my_application_dispose(GObject* object) { } g_clear_object(&self->header_bar_css_provider); g_clear_object(&self->header_bar_channel); + g_clear_object(&self->native_menu_channel); g_clear_object(&self->main_menu_model); g_clear_object(&self->view_mode_menu_model); g_clear_object(&self->view_mode_action); @@ -2080,6 +2655,7 @@ static void my_application_class_init(MyApplicationClass* klass) { static void my_application_init(MyApplication* self) { self->dart_entrypoint_arguments = nullptr; self->header_bar_channel = nullptr; + self->native_menu_channel = nullptr; self->header_bar_css_provider = nullptr; self->main_window = nullptr; self->flutter_view = nullptr; diff --git a/test/flutter_test_config.dart b/test/flutter_test_config.dart new file mode 100644 index 0000000..3cbf526 --- /dev/null +++ b/test/flutter_test_config.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import 'package:busymark/src/platform/native_menu_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel(nativeMenuChannelName); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + setUp(() { + messenger.setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); + }); + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + }); + await testMain(); +} diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index 9f66f8b..7d32edc 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -9,6 +9,7 @@ import 'package:busymark/src/app/busymark_dialog_identity.dart'; import 'package:busymark/src/app/busymark_glyphs.dart'; import 'package:busymark/src/editor/document_layout.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_toolbar.dart'; +import 'package:busymark/src/platform/native_menu_service.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -17,6 +18,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + testWidgets( 'informational dialog keeps the close control at the right edge', (tester) async { @@ -360,6 +363,97 @@ void main() { }, ); + testWidgets( + 'header popup sends shortcuts and separators to GTK and maps selection', + (tester) async { + const channel = MethodChannel('busymark/test/header-native-menu'); + MethodCall? showCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'show') { + showCall = call; + return 2; + } + return false; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + String? selection; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: BusyMarkHeaderPopupMenuButton( + tooltip: 'Native menu', + icon: BusyMarkGlyphs.menuVertical, + nativeMenuService: const NativeMenuService(channel: channel), + itemBuilder: (_) => [ + BusyMarkPopupMenuItem( + value: 'open', + label: 'Open', + shortcut: 'Ctrl+O', + ), + const PopupMenuDivider(), + BusyMarkPopupMenuItem( + value: 'preview', + label: 'Preview', + shortcut: 'Ctrl+3', + checked: true, + trailingCheck: true, + ), + ], + onSelected: (value) => selection = value, + ), + ), + ), + ), + ); + + final triggerRect = tester.getRect(find.byTooltip('Native menu')); + await tester.tap(find.byTooltip('Native menu')); + await tester.pumpAndSettle(); + + expect(selection, 'preview'); + expect(showCall?.method, 'show'); + final arguments = showCall?.arguments as Map; + expect(arguments['entries'], [ + { + 'label': 'Open', + 'shortcut': 'Ctrl+O', + 'enabled': true, + 'checkable': false, + 'selected': false, + 'separator': false, + }, + { + 'label': '', + 'enabled': false, + 'checkable': false, + 'selected': false, + 'separator': true, + }, + { + 'label': 'Preview', + 'shortcut': 'Ctrl+3', + 'enabled': true, + 'checkable': true, + 'selected': true, + 'separator': false, + }, + ]); + expect(arguments['anchor'], { + 'x': triggerRect.left, + 'y': triggerRect.top, + 'width': triggerRect.width, + 'height': triggerRect.height, + }); + }, + ); + testWidgets('header popup preserves asynchronous menu loading', ( tester, ) async { @@ -961,32 +1055,37 @@ void main() { theme.colorScheme.error, ); + final menuFinder = find.descendant( + of: find.byType(BusyMarkPopupSelector), + matching: find.byType(BusyMarkMenuButton), + ); + final selector = tester.widget>(menuFinder); final selectorFinder = find.descendant( of: find.byType(BusyMarkPopupSelector), - matching: find.byType(YaruPopupMenuButton), + matching: find.byType(FilledButton), ); - final selector = tester.widget>(selectorFinder); + final selectorButton = tester.widget(selectorFinder); expect( - selector.style?.backgroundColor?.resolve({}), + selectorButton.style?.backgroundColor?.resolve({}), BusyMarkLinuxPalette.transparent, ); - expect(selector.style?.side?.resolve({}), BorderSide.none); + expect(selectorButton.style?.side?.resolve({}), BorderSide.none); expect( - selector.style?.minimumSize?.resolve({}), + selectorButton.style?.minimumSize?.resolve({}), theme.outlinedButtonTheme.style?.minimumSize?.resolve({}), ); expect( - selector.style?.padding?.resolve({}), + selectorButton.style?.padding?.resolve({}), theme.outlinedButtonTheme.style?.padding?.resolve({}), ); expect( - selector.style?.shape?.resolve({}), + selectorButton.style?.shape?.resolve({}), theme.outlinedButtonTheme.style?.shape?.resolve({}), ); - expect(selector.initialValue, 'system'); expect(selector.enabled, isTrue); + expect(selector.items, hasLength(2)); - await tester.tap(selectorFinder); + await tester.tap(find.byTooltip('Theme')); await tester.pumpAndSettle(); expect(find.text('Light'), findsOneWidget); await tester.tap(find.text('Light')); diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 4f5e2bf..df62261 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -1241,7 +1241,7 @@ void main() { expect(native, contains('GTK_IS_MODEL_BUTTON(widget)')); expect(native, contains('gtk_accelerator_parse(accelerator')); expect(native, contains('gtk_accelerator_get_label')); - expect(native, contains('gtk_label_new(accelerator_text)')); + expect(native, contains('gtk_label_new(shortcut)')); expect(native, contains('decorate_model_menu_accelerators(')); expect(native, contains('g_menu_item_set_icon(item, icon)')); expect(native, contains('gtk_menu_button_set_menu_model')); @@ -1256,6 +1256,34 @@ void main() { ); }); + test('Flutter content menus use GTK popovers with native menu semantics', () { + final native = File('linux/runner/my_application.cc').readAsStringSync(); + final service = File( + 'lib/src/platform/native_menu_service.dart', + ).readAsStringSync(); + final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); + + expect(native, contains('kNativeMenuChannel')); + expect(native, contains('"busymark/native_menus"')); + expect(native, contains('gtk_popover_new_from_model(')); + expect(native, contains('g_menu_append_section(')); + expect(native, contains('decorate_native_menu_shortcuts(')); + expect(native, contains('native_menu_selection_activated_cb')); + expect(native, contains('G_VARIANT_TYPE_STRING')); + expect(native, contains('gtk_popover_set_modal')); + expect(native, contains('gtk_popover_set_constrain_to')); + expect(service, contains('final String? shortcut')); + expect(service, contains("'shortcut': shortcut!")); + expect(service, contains('this.checkable = false')); + expect(service, contains('separator = false')); + expect(design, contains('NativeMenuEntry.separator()')); + expect(design, contains('NativeMenuEntry.command(')); + expect(design, contains('shortcut: item.shortcut')); + expect(design, contains('checkable: item.trailingCheck')); + expect(design, contains('class BusyMarkMenuButton')); + expect(design, isNot(contains('YaruPopupMenuButton('))); + }); + test('welcome page has a sidebar but no document controls', () { final welcome = File( 'lib/src/workspace/presentation/welcome_screen.dart', diff --git a/test/src/native_menu_service_test.dart b/test/src/native_menu_service_test.dart new file mode 100644 index 0000000..7b012c5 --- /dev/null +++ b/test/src/native_menu_service_test.dart @@ -0,0 +1,124 @@ +import 'package:busymark/src/platform/native_menu_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'show serializes native menu presentation and returns its selection', + () async { + const channel = MethodChannel('busymark/test/native-menu'); + MethodCall? call; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (value) async { + call = value; + return 2; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final session = NativeMenuSession(); + final result = await const NativeMenuService(channel: channel).show( + session: session, + anchor: const Rect.fromLTWH(12, 34, 56, 78), + entries: const [ + NativeMenuEntry.command( + label: 'Open', + shortcut: 'Ctrl+O', + checkable: true, + selected: true, + ), + NativeMenuEntry.separator(), + NativeMenuEntry.command(label: 'Disabled', enabled: false), + ], + focusFirst: true, + preferAbove: true, + ); + + expect(result.available, isTrue); + expect(result.selectedIndex, 2); + expect(call?.method, 'show'); + expect(call?.arguments, { + 'sessionId': session.id, + 'anchor': {'x': 12.0, 'y': 34.0, 'width': 56.0, 'height': 78.0}, + 'entries': [ + { + 'label': 'Open', + 'shortcut': 'Ctrl+O', + 'enabled': true, + 'checkable': true, + 'selected': true, + 'separator': false, + }, + { + 'label': '', + 'enabled': false, + 'checkable': false, + 'selected': false, + 'separator': true, + }, + { + 'label': 'Disabled', + 'enabled': false, + 'checkable': false, + 'selected': false, + 'separator': false, + }, + ], + 'focusFirst': true, + 'preferredPosition': 'top', + }); + }, + ); + + test('dismiss is scoped to the native menu session', () async { + const channel = MethodChannel('busymark/test/native-menu-dismiss'); + MethodCall? call; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (value) async { + call = value; + return true; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final session = NativeMenuSession(); + final dismissed = await const NativeMenuService( + channel: channel, + ).dismiss(session); + + expect(dismissed, isTrue); + expect(call?.method, 'dismiss'); + expect(call?.arguments, {'sessionId': session.id}); + }); + + test( + 'missing native host reports unavailable for a themed fallback', + () async { + const channel = MethodChannel('busymark/test/native-menu-missing'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final result = await const NativeMenuService(channel: channel).show( + session: NativeMenuSession(), + anchor: Rect.zero, + entries: const [NativeMenuEntry.command(label: 'Fallback')], + ); + + expect(result.available, isFalse); + expect(result.selectedIndex, isNull); + }, + ); +} diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index d6eba9d..91fa778 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -812,7 +812,7 @@ void main() { expect(workspace, isNot(contains('BusyMarkMenuSelectorButton'))); }); - test('shared popup menus delegate rows and surfaces to framework themes', () { + test('shared popup menus prefer GTK with a themed framework fallback', () { final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); final headerPopup = RegExp( r'class BusyMarkHeaderPopupMenuButton[\s\S]*?Future ' @@ -822,19 +822,18 @@ void main() { r'class BusyMarkPopupMenuItem[\s\S]*?class BusyMarkPopupSelectorOption', ).firstMatch(design)!.group(0)!; - expect(headerPopup, contains('PopupMenuButton(')); - expect(headerPopup, contains('GlobalKey>()')); - expect(headerPopup, contains('showButtonMenu()')); + expect(headerPopup, contains('NativeMenuService')); + expect(headerPopup, contains('showBusyMarkMenu(')); + expect(headerPopup, contains('_busyMarkNativeMenuEntries')); + expect(headerPopup, contains('BusyMarkMenuSession')); + expect(headerPopup, contains('nativeMenuService.show(')); + expect(headerPopup, contains('showMenu(')); expect(headerPopup, contains('BusyMarkHeaderIconButton(')); expect(headerPopup, contains('selected: _loading || _open')); expect(headerPopup, contains('requestFocus: true')); - expect( - RegExp('requestFocus: true').allMatches(design).length, - 2, - reason: 'Header and context-menu routes must both own Escape handling.', - ); - expect(headerPopup, isNot(contains('findRenderObject()'))); - expect(headerPopup, isNot(contains('BoxConstraints.tightFor'))); + expect(headerPopup, contains('findRenderObject()')); + expect(headerPopup, contains('BoxConstraints.tightFor')); + expect(headerPopup, isNot(contains('PopupMenuButton('))); expect(headerPopup, isNot(contains('popupMenuShortcutWidth'))); expect(headerPopup, isNot(contains('RelativeRect.fromLTRB'))); expect(headerPopup, isNot(contains('_BusyMarkHeaderPopoverShape'))); @@ -866,11 +865,11 @@ void main() { expect(headerIcon, isNot(contains('DecoratedBox('))); expect(headerIcon, isNot(contains('BoxShadow('))); expect(popupItem, contains('extends PopupMenuItem')); - expect(popupItem, contains('child: _BusyMarkPopupMenuItemContent(')); + expect(popupItem, contains('child: KeyedSubtree(')); + expect(popupItem, contains('_BusyMarkPopupMenuItemContent(')); expect(popupItem, contains('textDirection: TextDirection.ltr')); expect(popupItem, isNot(contains('Navigator.pop'))); expect(popupItem, isNot(contains('InkWell('))); - expect(popupItem, isNot(contains('createState()'))); }); test('Git branch actions use the shared workspace-header popup', () { @@ -1036,7 +1035,7 @@ void main() { expect(gitSidebar, isNot(contains('FilledButton('))); }); - test('settings language selector delegates to the shared Yaru popup', () { + test('settings language selector delegates to the shared native menu', () { final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); final settings = File( 'lib/src/workspace/presentation/settings_screen.dart', @@ -1060,7 +1059,7 @@ void main() { final selector = RegExp( r'class BusyMarkPopupSelector[\s\S]*?class BusyMarkClamp', ).firstMatch(design)!.group(0)!; - expect(selector, contains('YaruPopupMenuButton(')); + expect(selector, contains('BusyMarkMenuButton(')); expect(selector, contains('Theme.of(context).outlinedButtonTheme.style')); expect( selector, @@ -1068,7 +1067,7 @@ void main() { ); expect(selector, contains('BusyMarkPopupMenuItem(')); expect(selector, contains('softWrap: false')); - expect(selector, isNot(contains('BusyMarkPushButton.standard('))); + expect(selector, contains('BusyMarkPushButton.standard(')); expect(selector, isNot(contains('WidgetStatesController()'))); expect(selector, isNot(contains('showMenu('))); expect(selector, isNot(contains('MouseRegion('))); From 11cb7173de7c81b60e3be2b75e09d4b72afa6abb Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 13:48:42 -0700 Subject: [PATCH 12/29] Add icon support for model buttons in native menus. Enhance button presentation by introducing an icon attribute and updating the shortcut label handling. Improve the decoration of native menu shortcuts to include icons for better visual representation. Enhance native menu support with icon mapping and tooltip customization. Introduce highlight behavior for open menu items and improve metadata handling for headings in markdown parsing. --- lib/src/app/app_theme.dart | 4 + lib/src/app/busymark_design.dart | 5 +- lib/src/app/busymark_glyphs.dart | 192 ++++++++++++++++++ lib/src/editor/wysiwyg/wysiwyg_toolbar.dart | 7 + lib/src/markdown/markdown_parser.dart | 27 ++- lib/src/platform/native_menu_service.dart | 4 + .../presentation/workspace_screen.dart | 1 + linux/runner/my_application.cc | 135 ++++++++---- test/src/busymark_design_test.dart | 17 ++ test/src/markdown_parser_test.dart | 45 ++++ test/src/native_headerbar_audit_test.dart | 76 ++++++- test/src/native_menu_service_test.dart | 2 + test/src/source_audit_test.dart | 5 +- 13 files changed, 467 insertions(+), 53 deletions(-) diff --git a/lib/src/app/app_theme.dart b/lib/src/app/app_theme.dart index f337fbf..404b64e 100644 --- a/lib/src/app/app_theme.dart +++ b/lib/src/app/app_theme.dart @@ -197,6 +197,10 @@ ThemeData buildBusyMarkTheme({ textStyle: textTheme.bodyMedium, menuStyle: dropdownMenuStyle, ), + // Keep Yaru's native desktop tooltip palette. Re-deriving Material's + // tooltip defaults from BusyMark's remapped surface ColorScheme gives + // dropdown triggers a different floating color than native GTK. + tooltipTheme: base.tooltipTheme, tabBarTheme: base.tabBarTheme.copyWith( labelStyle: textTheme.labelLarge, unselectedLabelStyle: textTheme.labelLarge, diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index dbfba47..7298d90 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -1071,6 +1071,7 @@ class BusyMarkHeaderPopupMenuButton extends StatefulWidget { this.foregroundColor, this.backgroundColor, this.borderRadius = BusyMarkRadius.headerButton, + this.highlightWhenOpen = true, this.nativeMenuService = const NativeMenuService(), }); @@ -1087,6 +1088,7 @@ class BusyMarkHeaderPopupMenuButton extends StatefulWidget { final Color? foregroundColor; final WidgetStateProperty? backgroundColor; final double borderRadius; + final bool highlightWhenOpen; final NativeMenuService nativeMenuService; @override @@ -1121,7 +1123,7 @@ class _BusyMarkHeaderPopupMenuButtonState tooltip: widget.tooltip, icon: widget.icon, shortcut: widget.shortcut, - selected: _loading || _open, + selected: widget.highlightWhenOpen && (_loading || _open), transparent: widget.transparent, elevated: widget.elevated, foregroundColor: widget.foregroundColor, @@ -1312,6 +1314,7 @@ List? _busyMarkNativeMenuEntries( entries.add( NativeMenuEntry.command( label: item.label, + iconName: BusyMarkGlyphs.nativeMenuIconName(item.icon), shortcut: item.shortcut, enabled: item.enabled, checkable: item.trailingCheck, diff --git a/lib/src/app/busymark_glyphs.dart b/lib/src/app/busymark_glyphs.dart index 1c33f43..c0c65b3 100644 --- a/lib/src/app/busymark_glyphs.dart +++ b/lib/src/app/busymark_glyphs.dart @@ -87,6 +87,198 @@ abstract final class BusyMarkGlyphs { static const IconData warning = YaruIcons.warning; static const IconData writersideProject = YaruIcons.book; + /// Maps Flutter menu glyphs to freedesktop themed-icon names for GTK. + /// + /// The native menu bridge cannot render an [IconData] font glyph directly. + /// Keeping the mapping beside the semantic glyph catalog lets Flutter and + /// GTK use equivalent, platform-native artwork for the same command. + static String? nativeMenuIconName(IconData? icon) { + if (icon == null) { + return null; + } + if (icon == about || icon == info) { + return 'help-about-symbolic'; + } + if (icon == appearance || icon == settings) { + return 'preferences-system-symbolic'; + } + if (icon == blockquote || icon == feedback) { + return 'chat-symbolic'; + } + if (icon == bold) { + return 'format-text-bold-symbolic'; + } + if (icon == branch || icon == tree) { + return 'view-treemap-symbolic'; + } + if (icon == category || icon == tag) { + return 'tag-symbolic'; + } + if (icon == checkedBox) { + return 'checkbox-checked-symbolic'; + } + if (icon == checklist || icon == diagnostics) { + return 'view-tasks-unscheduled-symbolic'; + } + if (icon == clear) { + return 'edit-clear-symbolic'; + } + if (icon == clearAll) { + return 'edit-clear-all-symbolic'; + } + if (icon == code || icon == sourceView || icon == symbols) { + return 'text-x-generic-symbolic'; + } + if (icon == copy) { + return 'edit-copy-symbolic'; + } + if (icon == cut) { + return 'edit-cut-symbolic'; + } + if (icon == delete) { + return 'user-trash-symbolic'; + } + if (icon == document || icon == startTopic) { + return 'text-x-generic-symbolic'; + } + if (icon == documentHistory || icon == history) { + return 'document-open-recent-symbolic'; + } + if (icon == documentOpen) { + return 'document-open-symbolic'; + } + if (icon == edit) { + return 'document-edit-symbolic'; + } + if (icon == error) { + return 'dialog-error-symbolic'; + } + if (icon == externalLink) { + return 'external-link-symbolic'; + } + if (icon == folder) { + return 'folder-symbolic'; + } + if (icon == folderOpen) { + return 'folder-open-symbolic'; + } + if (icon == font || icon == heading) { + return 'font-select-symbolic'; + } + if (icon == goTop || icon == toolbarPlacement) { + return 'go-top-symbolic'; + } + if (icon == hardBreak) { + return 'go-down-symbolic'; + } + if (icon == hide) { + return 'eye-not-looking-symbolic'; + } + if (icon == home) { + return 'go-home-symbolic'; + } + if (icon == image) { + return 'image-x-generic-symbolic'; + } + if (icon == imageMissing) { + return 'image-missing-symbolic'; + } + if (icon == indent) { + return 'format-indent-more-symbolic'; + } + if (icon == inlineImage) { + return 'insert-image-symbolic'; + } + if (icon == insertObject) { + return 'insert-object-symbolic'; + } + if (icon == italic) { + return 'format-text-italic-symbolic'; + } + if (icon == keyboard) { + return 'input-keyboard-symbolic'; + } + if (icon == link) { + return 'insert-link-symbolic'; + } + if (icon == markdownFile || icon == editorView || icon == text) { + return 'accessories-text-editor-symbolic'; + } + if (icon == newDocument) { + return 'document-new-symbolic'; + } + if (icon == orderedList) { + return 'format-ordered-list-symbolic'; + } + if (icon == outdent) { + return 'format-indent-less-symbolic'; + } + if (icon == paragraph) { + return 'insert-text-symbolic'; + } + if (icon == paste) { + return 'edit-paste-symbolic'; + } + if (icon == preview || icon == previewView) { + return 'image-viewer-symbolic'; + } + if (icon == pull) { + return 'folder-download-symbolic'; + } + if (icon == push) { + return 'document-send-symbolic'; + } + if (icon == redo) { + return 'edit-redo-symbolic'; + } + if (icon == save) { + return 'document-save-symbolic'; + } + if (icon == search) { + return 'system-search-symbolic'; + } + if (icon == searchUnavailable) { + return 'edit-find-replace-symbolic'; + } + if (icon == selectAll) { + return 'edit-select-all-symbolic'; + } + if (icon == sidebar) { + return 'sidebar-show-symbolic'; + } + if (icon == strikethrough) { + return 'format-text-strikethrough-symbolic'; + } + if (icon == splitView) { + return 'panel-right-symbolic'; + } + if (icon == table) { + return 'x-office-spreadsheet-symbolic'; + } + if (icon == task) { + return 'checkbox-symbolic'; + } + if (icon == thematicBreak) { + return 'list-remove-symbolic'; + } + if (icon == underline) { + return 'format-text-underline-symbolic'; + } + if (icon == undo) { + return 'edit-undo-symbolic'; + } + if (icon == unorderedList) { + return 'format-unordered-list-symbolic'; + } + if (icon == warning) { + return 'dialog-warning-symbolic'; + } + if (icon == writersideProject) { + return 'folder-documents-symbolic'; + } + return null; + } + /// Resolves a navigation glyph against the surrounding reading direction. /// /// Yaru's directional glyphs do not opt in to Flutter's automatic icon diff --git a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart index 5feafed..dabdfcb 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart @@ -256,36 +256,43 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { value: BusyWysiwygBlockCommand.paragraph, label: context.l10n.paragraph, icon: BusyMarkGlyphs.paragraph, + shortcut: BusyMarkEditorShortcutLabels.paragraph, ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading1, label: context.l10n.heading1, icon: BusyMarkGlyphs.heading, + shortcut: BusyMarkEditorShortcutLabels.heading1, ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading2, label: context.l10n.heading2, icon: BusyMarkGlyphs.heading, + shortcut: BusyMarkEditorShortcutLabels.heading2, ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading3, label: context.l10n.heading3, icon: BusyMarkGlyphs.heading, + shortcut: BusyMarkEditorShortcutLabels.heading3, ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading4, label: context.l10n.heading4, icon: BusyMarkGlyphs.heading, + shortcut: BusyMarkEditorShortcutLabels.heading4, ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading5, label: context.l10n.heading5, icon: BusyMarkGlyphs.heading, + shortcut: BusyMarkEditorShortcutLabels.heading5, ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading6, label: context.l10n.heading6, icon: BusyMarkGlyphs.heading, + shortcut: BusyMarkEditorShortcutLabels.heading6, ), ], onSelected: onBlockCommand, diff --git a/lib/src/markdown/markdown_parser.dart b/lib/src/markdown/markdown_parser.dart index 8004278..bc7e112 100644 --- a/lib/src/markdown/markdown_parser.dart +++ b/lib/src/markdown/markdown_parser.dart @@ -330,9 +330,23 @@ class MarkdownParser { .toList(growable: false); final canAssignSource = modeledSourceChunks.length == contentBlocks.length; if (!canAssignSource) { + // A complex construct can be modeled as a different number of blocks by + // the AST and the lossless source scanner. Preserve top-level heading + // spans independently: title and outline projection must not disappear + // merely because unrelated content falls back to protected source. + final headingSourceChunks = sourceChunks + .where(_isScannedHeadingSource) + .toList(growable: false); + var headingSourceIndex = 0; final contentWithMetadata = [ for (final block in contentBlocks) - _blockWithScannedSourceMetadata(block, null), + _blockWithScannedSourceMetadata( + block, + block.kind == BusyBlockKind.heading && + headingSourceIndex < headingSourceChunks.length + ? headingSourceChunks[headingSourceIndex++] + : null, + ), ]; if (sourceChunks.any((chunk) => chunk.protectEdits)) { final source = document.source!; @@ -391,6 +405,17 @@ class MarkdownParser { ); } + bool _isScannedHeadingSource(_ScannedBlockSource chunk) { + final lines = chunk.rawSource.split('\n'); + if (lines.isEmpty) { + return false; + } + if (_isAtxHeading(lines.first)) { + return true; + } + return lines.length > 1 && _setextUnderlineLevel(lines[1].trim()) != null; + } + _CanonicalHeadingProjection _canonicalizeHeadings({ required BusyDocument document, required String filePath, diff --git a/lib/src/platform/native_menu_service.dart b/lib/src/platform/native_menu_service.dart index c0230e5..1914106 100644 --- a/lib/src/platform/native_menu_service.dart +++ b/lib/src/platform/native_menu_service.dart @@ -22,6 +22,7 @@ final class NativeMenuSession { final class NativeMenuEntry { const NativeMenuEntry.command({ required this.label, + this.iconName, this.shortcut, this.enabled = true, this.checkable = false, @@ -30,6 +31,7 @@ final class NativeMenuEntry { const NativeMenuEntry.separator() : label = '', + iconName = null, shortcut = null, enabled = false, checkable = false, @@ -37,6 +39,7 @@ final class NativeMenuEntry { separator = true; final String label; + final String? iconName; final String? shortcut; final bool enabled; final bool checkable; @@ -46,6 +49,7 @@ final class NativeMenuEntry { Map _toPlatformMap() { return { 'label': label, + if (iconName != null && iconName!.isNotEmpty) 'icon': iconName!, if (shortcut != null && shortcut!.isNotEmpty) 'shortcut': shortcut!, 'enabled': enabled, 'checkable': checkable, diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 07b0431..fe8684d 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -1971,6 +1971,7 @@ class _SidebarHeader extends StatelessWidget { ), transparent: true, borderRadius: BusyMarkRadius.nativeHeaderButton, + highlightWhenOpen: false, itemBuilder: (context) => [ for (final tab in tabs) BusyMarkPopupMenuItem( diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index a16a312..7aca3e4 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -53,6 +53,7 @@ constexpr char kLegacyYaruWindowShadowCompatibilityCss[] = constexpr char kLtrIsolateStart[] = "\xE2\x81\xA6"; constexpr char kBidiIsolateEnd[] = "\xE2\x81\xA9"; constexpr char kMenuAcceleratorAttribute[] = "x-busymark-accelerator"; +constexpr char kMenuIconAttribute[] = "x-busymark-icon"; constexpr char kModelButtonAcceleratorKey[] = "busymark-model-button-accelerator"; @@ -639,24 +640,25 @@ static void refresh_header_bar_css(MyApplication* self) { "headerbar.busymark-headerbar:dir(rtl) {" "padding-right: 0;" "}" - ".busymark-sidebar-header {" + ".busymark-titlebar .busymark-sidebar-header," + ".busymark-titlebar .busymark-sidebar-header:backdrop {" "background-color: %s;" "background-image: none;" "color: %s;" "border: none;" "box-shadow: none;" "}" - ".busymark-sidebar-header label {" + ".busymark-titlebar .busymark-sidebar-header label {" "color: %s;" "font-weight: 800;" "}" - ".busymark-sidebar-header label:backdrop {" + ".busymark-titlebar .busymark-sidebar-header label:backdrop {" "color: alpha(%s, %.2f);" "}" - ".busymark-sidebar-header:dir(ltr) {" + ".busymark-titlebar .busymark-sidebar-header:dir(ltr) {" "border-right: 1px solid %s;" "}" - ".busymark-sidebar-header:dir(rtl) {" + ".busymark-titlebar .busymark-sidebar-header:dir(rtl) {" "border-left: 1px solid %s;" "}" // Legacy Yaru GTK 3 uses an absolute near-black image for active and @@ -1028,10 +1030,12 @@ static const gchar* localized_label_or(FlValue* labels, return value != nullptr ? value : fallback; } -static void add_model_button_shortcut_label(GtkWidget* button, - const gchar* shortcut) { +static void add_model_button_presentation(GtkWidget* button, + const gchar* icon_name, + const gchar* shortcut) { if (button == nullptr || !GTK_IS_MODEL_BUTTON(button) || - shortcut == nullptr || shortcut[0] == '\0' || + ((icon_name == nullptr || icon_name[0] == '\0') && + (shortcut == nullptr || shortcut[0] == '\0')) || g_object_get_data(G_OBJECT(button), kModelButtonAcceleratorKey) != nullptr) { return; @@ -1046,17 +1050,25 @@ static void add_model_button_shortcut_label(GtkWidget* button, GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); + if (icon_name != nullptr && icon_name[0] != '\0') { + GtkWidget* icon = + gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU); + gtk_widget_set_valign(icon, GTK_ALIGN_CENTER); + gtk_box_pack_start(GTK_BOX(row), icon, FALSE, FALSE, 0); + } gtk_widget_set_hexpand(content, TRUE); gtk_box_pack_start(GTK_BOX(row), content, TRUE, TRUE, 0); - GtkWidget* shortcut_label = gtk_label_new(shortcut); - gtk_widget_set_direction(shortcut_label, GTK_TEXT_DIR_LTR); - gtk_widget_set_halign(shortcut_label, GTK_ALIGN_END); - gtk_widget_set_valign(shortcut_label, GTK_ALIGN_CENTER); - gtk_label_set_xalign(GTK_LABEL(shortcut_label), 1.0); - gtk_style_context_add_class( - gtk_widget_get_style_context(shortcut_label), "dim-label"); - gtk_box_pack_end(GTK_BOX(row), shortcut_label, FALSE, FALSE, 0); + if (shortcut != nullptr && shortcut[0] != '\0') { + GtkWidget* shortcut_label = gtk_label_new(shortcut); + gtk_widget_set_direction(shortcut_label, GTK_TEXT_DIR_LTR); + gtk_widget_set_halign(shortcut_label, GTK_ALIGN_END); + gtk_widget_set_valign(shortcut_label, GTK_ALIGN_CENTER); + gtk_label_set_xalign(GTK_LABEL(shortcut_label), 1.0); + gtk_style_context_add_class( + gtk_widget_get_style_context(shortcut_label), "dim-label"); + gtk_box_pack_end(GTK_BOX(row), shortcut_label, FALSE, FALSE, 0); + } gtk_container_add(GTK_CONTAINER(button), row); gtk_widget_show_all(row); @@ -1066,25 +1078,24 @@ static void add_model_button_shortcut_label(GtkWidget* button, } static void add_model_button_accelerator(GtkWidget* button, + const gchar* icon_name, const gchar* accelerator) { - if (accelerator == nullptr || accelerator[0] == '\0') { - return; - } - guint accelerator_key = 0; - GdkModifierType accelerator_modifiers = static_cast(0); - gtk_accelerator_parse(accelerator, &accelerator_key, - &accelerator_modifiers); - if (accelerator_key == 0) { - return; + g_autofree gchar* accelerator_text = nullptr; + if (accelerator != nullptr && accelerator[0] != '\0') { + guint accelerator_key = 0; + GdkModifierType accelerator_modifiers = static_cast(0); + gtk_accelerator_parse(accelerator, &accelerator_key, + &accelerator_modifiers); + if (accelerator_key != 0) { + // GtkAccelLabel computes its accelerator width only after mapping, while + // a model popover allocates its width before mapping. Let GTK format the + // platform-native text, then render it as a regular label so the + // shortcut's natural width participates in the first allocation. + accelerator_text = + gtk_accelerator_get_label(accelerator_key, accelerator_modifiers); + } } - - // GtkAccelLabel computes its accelerator width only after mapping, while a - // model popover allocates its width before mapping. Let GTK format the - // platform-native text, then render it as a regular label so the shortcut's - // natural width participates in the popover's first allocation. - g_autofree gchar* accelerator_text = - gtk_accelerator_get_label(accelerator_key, accelerator_modifiers); - add_model_button_shortcut_label(button, accelerator_text); + add_model_button_presentation(button, icon_name, accelerator_text); } struct ModelMenuAcceleratorDecoration { @@ -1102,10 +1113,15 @@ static void decorate_model_menu_accelerators_cb(GtkWidget* widget, g_autoptr(GVariant) value = g_menu_model_get_item_attribute_value( decoration->model, decoration->item_index, kMenuAcceleratorAttribute, G_VARIANT_TYPE_STRING); - if (value != nullptr) { - add_model_button_accelerator( - widget, g_variant_get_string(value, nullptr)); - } + g_autoptr(GVariant) icon_value = + g_menu_model_get_item_attribute_value( + decoration->model, decoration->item_index, kMenuIconAttribute, + G_VARIANT_TYPE_STRING); + add_model_button_accelerator( + widget, + icon_value != nullptr ? g_variant_get_string(icon_value, nullptr) + : nullptr, + value != nullptr ? g_variant_get_string(value, nullptr) : nullptr); } decoration->item_index++; return; @@ -1136,6 +1152,7 @@ static void append_action_menu_item(GMenu* menu, GIcon* icon = g_themed_icon_new(icon_name); g_menu_item_set_icon(item, icon); g_object_unref(icon); + g_menu_item_set_attribute(item, kMenuIconAttribute, "s", icon_name); } if (accelerator != nullptr && accelerator[0] != '\0') { g_menu_item_set_attribute(item, kMenuAcceleratorAttribute, "s", @@ -1312,6 +1329,8 @@ static void append_view_mode_menu_item(GMenu* menu, GIcon* icon = g_themed_icon_new(view_mode_icon_name(mode)); g_menu_item_set_icon(item, icon); g_object_unref(icon); + g_menu_item_set_attribute(item, kMenuIconAttribute, "s", + view_mode_icon_name(mode)); if (accelerator != nullptr && accelerator[0] != '\0') { g_menu_item_set_attribute(item, kMenuAcceleratorAttribute, "s", accelerator); @@ -1964,6 +1983,7 @@ struct NativeMenuSession { GSimpleActionGroup* action_group; FlMethodCall* method_call; GPtrArray* shortcut_labels; + GPtrArray* icon_names; gulong closed_signal_id; guint cleanup_source_id; gint pending_selected_index; @@ -2019,6 +2039,7 @@ static void native_menu_session_dispose(NativeMenuSession* session) { } } g_clear_pointer(&session->shortcut_labels, g_ptr_array_unref); + g_clear_pointer(&session->icon_names, g_ptr_array_unref); g_clear_object(&session->model); g_clear_object(&session->action_group); native_menu_session_respond(session, session->pending_selected_index); @@ -2168,6 +2189,7 @@ static gboolean parse_native_menu_anchor(FlValue* args, struct NativeMenuShortcutDecoration { GPtrArray* labels; + GPtrArray* icon_names; guint index; }; @@ -2176,10 +2198,12 @@ static void decorate_native_menu_shortcuts_cb(GtkWidget* widget, auto* decoration = static_cast(user_data); if (GTK_IS_MODEL_BUTTON(widget)) { if (decoration->index < decoration->labels->len) { - add_model_button_shortcut_label( - widget, static_cast( - g_ptr_array_index(decoration->labels, - decoration->index))); + add_model_button_presentation( + widget, + static_cast( + g_ptr_array_index(decoration->icon_names, decoration->index)), + static_cast( + g_ptr_array_index(decoration->labels, decoration->index))); } decoration->index++; return; @@ -2191,11 +2215,13 @@ static void decorate_native_menu_shortcuts_cb(GtkWidget* widget, } static void decorate_native_menu_shortcuts(GtkWidget* popover, - GPtrArray* labels) { - if (popover == nullptr || !GTK_IS_CONTAINER(popover) || labels == nullptr) { + GPtrArray* labels, + GPtrArray* icon_names) { + if (popover == nullptr || !GTK_IS_CONTAINER(popover) || labels == nullptr || + icon_names == nullptr) { return; } - NativeMenuShortcutDecoration decoration = {labels, 0}; + NativeMenuShortcutDecoration decoration = {labels, icon_names, 0}; gtk_container_foreach(GTK_CONTAINER(popover), decorate_native_menu_shortcuts_cb, &decoration); } @@ -2265,6 +2291,9 @@ static void show_native_menu(NativeMenuHandlerData* data, !fl_lookup_optional_bool_with_default(entry, "selected", FALSE, &selected) || (!separator && fl_lookup_string_arg(entry, "label") == nullptr) || + (fl_value_lookup_string(entry, "icon") != nullptr && + fl_value_get_type(fl_value_lookup_string(entry, "icon")) != + FL_VALUE_TYPE_STRING) || (selected && !checkable)) { respond_native_menu_argument_error( method_call, @@ -2325,6 +2354,7 @@ static void show_native_menu(NativeMenuHandlerData* data, session->action_group = g_simple_action_group_new(); session->model = g_menu_new(); session->shortcut_labels = g_ptr_array_new_with_free_func(g_free); + session->icon_names = g_ptr_array_new_with_free_func(g_free); data->active = session; GMenu* section = g_menu_new(); @@ -2351,6 +2381,7 @@ static void show_native_menu(NativeMenuHandlerData* data, } const gchar* label = fl_lookup_string_arg(entry, "label"); + const gchar* icon_name = fl_lookup_string_arg(entry, "icon"); const gchar* shortcut = fl_lookup_string_arg(entry, "shortcut"); gboolean enabled = TRUE; gboolean checkable = FALSE; @@ -2402,17 +2433,24 @@ static void show_native_menu(NativeMenuHandlerData* data, FlValue* run_entry = fl_value_get_list_value(entries, run_index); const gchar* run_label = fl_lookup_string_arg(run_entry, "label"); + const gchar* run_icon = fl_lookup_string_arg(run_entry, "icon"); const gchar* run_shortcut = fl_lookup_string_arg(run_entry, "shortcut"); g_autofree gchar* target = g_strdup_printf("%zu", run_index); g_autoptr(GMenuItem) item = g_menu_item_new(run_label, nullptr); g_menu_item_set_action_and_target_value( item, detailed_group_action, g_variant_new_string(target)); + if (run_icon != nullptr && run_icon[0] != '\0') { + g_autoptr(GIcon) icon = g_themed_icon_new(run_icon); + g_menu_item_set_icon(item, icon); + } g_menu_append_item(section, item); section_length++; g_ptr_array_add( session->shortcut_labels, g_strdup(run_shortcut != nullptr ? run_shortcut : "")); + g_ptr_array_add(session->icon_names, + g_strdup(run_icon != nullptr ? run_icon : "")); } g_object_unref(group_action); index = run_end; @@ -2432,11 +2470,17 @@ static void show_native_menu(NativeMenuHandlerData* data, g_autofree gchar* detailed_action = g_strdup_printf("%s.%s", kNativeMenuActionNamespace, action_name); g_autoptr(GMenuItem) item = g_menu_item_new(label, detailed_action); + if (icon_name != nullptr && icon_name[0] != '\0') { + g_autoptr(GIcon) icon = g_themed_icon_new(icon_name); + g_menu_item_set_icon(item, icon); + } g_menu_append_item(section, item); g_object_unref(action); section_length++; g_ptr_array_add(session->shortcut_labels, g_strdup(shortcut != nullptr ? shortcut : "")); + g_ptr_array_add(session->icon_names, + g_strdup(icon_name != nullptr ? icon_name : "")); index++; } flush_section(); @@ -2455,7 +2499,8 @@ static void show_native_menu(NativeMenuHandlerData* data, GTK_POPOVER_CONSTRAINT_WINDOW); gtk_popover_set_modal(GTK_POPOVER(session->popover), TRUE); decorate_native_menu_shortcuts(session->popover, - session->shortcut_labels); + session->shortcut_labels, + session->icon_names); session->closed_signal_id = g_signal_connect( session->popover, "closed", G_CALLBACK(native_menu_closed_cb), session); gtk_popover_popup(GTK_POPOVER(session->popover)); diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index 7d32edc..90d0ffd 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -330,6 +330,7 @@ void main() { body: BusyMarkHeaderPopupMenuButton( tooltip: 'Main menu', icon: BusyMarkGlyphs.menuVertical, + highlightWhenOpen: false, itemBuilder: (_) => [ BusyMarkPopupMenuItem( value: 'editor', @@ -345,6 +346,17 @@ void main() { await tester.tap(find.byTooltip('Main menu')); await tester.pumpAndSettle(); + expect( + tester + .widget( + find.descendant( + of: find.byType(BusyMarkHeaderPopupMenuButton), + matching: find.byType(IconButton), + ), + ) + .isSelected, + isFalse, + ); expect(find.text('Editor'), findsOneWidget); expect(find.text('Ctrl+1'), findsOneWidget); expect(find.byTooltip('Editor (Ctrl+1)'), findsNothing); @@ -395,12 +407,14 @@ void main() { BusyMarkPopupMenuItem( value: 'open', label: 'Open', + icon: BusyMarkGlyphs.folderOpen, shortcut: 'Ctrl+O', ), const PopupMenuDivider(), BusyMarkPopupMenuItem( value: 'preview', label: 'Preview', + icon: BusyMarkGlyphs.previewView, shortcut: 'Ctrl+3', checked: true, trailingCheck: true, @@ -423,6 +437,7 @@ void main() { expect(arguments['entries'], [ { 'label': 'Open', + 'icon': 'folder-open-symbolic', 'shortcut': 'Ctrl+O', 'enabled': true, 'checkable': false, @@ -438,6 +453,7 @@ void main() { }, { 'label': 'Preview', + 'icon': 'image-viewer-symbolic', 'shortcut': 'Ctrl+3', 'enabled': true, 'checkable': true, @@ -528,6 +544,7 @@ void main() { ); expect(theme.visualDensity, base.visualDensity); expect(theme.splashFactory.runtimeType, base.splashFactory.runtimeType); + expect(theme.tooltipTheme, base.tooltipTheme); expect( theme.textTheme.bodyMedium?.fontFamily, base.textTheme.bodyMedium?.fontFamily, diff --git a/test/src/markdown_parser_test.dart b/test/src/markdown_parser_test.dart index 2fce5d3..b67d974 100644 --- a/test/src/markdown_parser_test.dart +++ b/test/src/markdown_parser_test.dart @@ -153,6 +153,51 @@ void main() { ); }); + test('retains Writerside title when complex list source needs fallback', () { + final parsed = parser.parse( + filePath: 'Wi-Fi-Interface.md', + source: ''' +# Wi-Fi Interface + +## Development + +1. Create the component. + + ```Bash + idf.py create-component wi_fi_sta_interface -C components + ``` + +2. Rename and move the source file. {collapsible="true"} + + 1. Rename the file. + 2. Move it to the source folder. +3. Update the build file. {collapsible="true"} + + 1. Add the required dependencies. + + The build file should look like this: + + ```CMake + idf_component_register(SRCS "src/wi_fi_sta_interface.cpp") + ``` + {collapsible="true" collapsed-title="CMakeLists.txt"} + +## References +''', + mode: MarkdownMode.writersideMarkdown, + ); + + expect(parsed.title, 'Wi-Fi Interface'); + expect( + parsed.headings.map((heading) => heading.text), + containsAll(['Wi-Fi Interface', 'Development', 'References']), + ); + expect( + parsed.diagnostics.map((diagnostic) => diagnostic.code), + isNot(contains('writerside.topic.missing-title')), + ); + }); + test('detects unresolved links, missing images, and missing alt text', () { final path = fixture('links_images.md'); final parsed = parser.parse( diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index df62261..2cbc8ba 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -480,6 +480,29 @@ void main() { expect(native, isNot(contains('"tooltip label {"'))); }); + test( + 'native sidebar header uses the same semantic surface as the sidebar', + () { + final native = File('linux/runner/my_application.cc').readAsStringSync(); + + expect( + native, + contains( + '".busymark-titlebar .busymark-sidebar-header,"' + '\n ' + '".busymark-titlebar .busymark-sidebar-header:backdrop {"', + ), + ); + expect( + native, + contains( + 'css_color_or(self->sidebar_background_color, ' + 'kDefaultSidebarBackground)', + ), + ); + }, + ); + test('native header CSS is balanced and narrowly semantic', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); final refreshFunction = RegExp( @@ -563,11 +586,22 @@ void main() { native, contains('gtk_popover_set_position(popover, GTK_POS_BOTTOM)'), ); - expect(native, contains('.busymark-sidebar-header {')); + expect( + native, + contains('".busymark-titlebar .busymark-sidebar-header:backdrop {"'), + ); expect(native, contains('background-color: %s;')); - expect(native, contains('".busymark-sidebar-header label {"')); + expect( + native, + contains('".busymark-titlebar .busymark-sidebar-header label {"'), + ); expect(native, contains('"font-weight: 800;"')); - expect(native, contains('".busymark-sidebar-header label:backdrop {"')); + expect( + native, + contains( + '".busymark-titlebar .busymark-sidebar-header label:backdrop {"', + ), + ); expect(native, contains('kHeaderBackdropForegroundOpacity = 0.50')); expect(native, contains('self->sidebar_header_box = gtk_overlay_new()')); expect(native, contains('GtkWidget* sidebar_action_box =')); @@ -596,9 +630,15 @@ void main() { expect(headerbarBlock, isNot(contains('border-radius'))); expect(headerbarBlock, isNot(contains('"padding-left: 0;"'))); expect(headerbarBlock, isNot(contains('"padding-right: 0;"'))); - expect(native, contains('".busymark-sidebar-header:dir(ltr) {"')); + expect( + native, + contains('".busymark-titlebar .busymark-sidebar-header:dir(ltr) {"'), + ); expect(native, contains('"border-right: 1px solid %s;"')); - expect(native, contains('".busymark-sidebar-header:dir(rtl) {"')); + expect( + native, + contains('".busymark-titlebar .busymark-sidebar-header:dir(rtl) {"'), + ); expect(native, contains('"border-left: 1px solid %s;"')); expect(native, contains('".busymark-modal-scrim {"')); expect(native, contains('create_busymark_titlebar_overlay')); @@ -1262,26 +1302,52 @@ void main() { 'lib/src/platform/native_menu_service.dart', ).readAsStringSync(); final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); + final toolbar = File( + 'lib/src/editor/wysiwyg/wysiwyg_toolbar.dart', + ).readAsStringSync(); expect(native, contains('kNativeMenuChannel')); expect(native, contains('"busymark/native_menus"')); expect(native, contains('gtk_popover_new_from_model(')); expect(native, contains('g_menu_append_section(')); expect(native, contains('decorate_native_menu_shortcuts(')); + expect(native, contains('kMenuIconAttribute')); + expect(native, contains('gtk_image_new_from_icon_name(icon_name')); + expect(native, contains('session->icon_names')); expect(native, contains('native_menu_selection_activated_cb')); expect(native, contains('G_VARIANT_TYPE_STRING')); expect(native, contains('gtk_popover_set_modal')); expect(native, contains('gtk_popover_set_constrain_to')); expect(service, contains('final String? shortcut')); + expect(service, contains('final String? iconName')); + expect(service, contains("'icon': iconName!")); expect(service, contains("'shortcut': shortcut!")); expect(service, contains('this.checkable = false')); expect(service, contains('separator = false')); expect(design, contains('NativeMenuEntry.separator()')); expect(design, contains('NativeMenuEntry.command(')); + expect( + design, + contains('iconName: BusyMarkGlyphs.nativeMenuIconName(item.icon)'), + ); expect(design, contains('shortcut: item.shortcut')); expect(design, contains('checkable: item.trailingCheck')); expect(design, contains('class BusyMarkMenuButton')); expect(design, isNot(contains('YaruPopupMenuButton('))); + for (final shortcut in [ + 'paragraph', + 'heading1', + 'heading2', + 'heading3', + 'heading4', + 'heading5', + 'heading6', + ]) { + expect( + toolbar, + contains('shortcut: BusyMarkEditorShortcutLabels.$shortcut'), + ); + } }); test('welcome page has a sidebar but no document controls', () { diff --git a/test/src/native_menu_service_test.dart b/test/src/native_menu_service_test.dart index 7b012c5..92b518a 100644 --- a/test/src/native_menu_service_test.dart +++ b/test/src/native_menu_service_test.dart @@ -27,6 +27,7 @@ void main() { entries: const [ NativeMenuEntry.command( label: 'Open', + iconName: 'document-open-symbolic', shortcut: 'Ctrl+O', checkable: true, selected: true, @@ -47,6 +48,7 @@ void main() { 'entries': [ { 'label': 'Open', + 'icon': 'document-open-symbolic', 'shortcut': 'Ctrl+O', 'enabled': true, 'checkable': true, diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 91fa778..e29c1d9 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -829,7 +829,10 @@ void main() { expect(headerPopup, contains('nativeMenuService.show(')); expect(headerPopup, contains('showMenu(')); expect(headerPopup, contains('BusyMarkHeaderIconButton(')); - expect(headerPopup, contains('selected: _loading || _open')); + expect( + headerPopup, + contains('selected: widget.highlightWhenOpen && (_loading || _open)'), + ); expect(headerPopup, contains('requestFocus: true')); expect(headerPopup, contains('findRenderObject()')); expect(headerPopup, contains('BoxConstraints.tightFor')); From c673e3c9d35309b8d2255074b53a33889355ee11 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 15:06:21 -0700 Subject: [PATCH 13/29] Add header menu shadow color and native popover styling. Introduce new CSS classes for header menu depth and native popover. Enhance sidebar header layout with title box and improve hover color handling for menu items. Add popover and menu hover color customization to header bar theme. Introduce new properties for popover background, menu hover color, and popover shadow opacity to enhance UI consistency and visual appeal. --- lib/src/app/busymark_design.dart | 1 + .../platform/header_bar_configuration.dart | 25 +++- linux/runner/my_application.cc | 136 +++++++++++++++--- test/src/busymark_design_test.dart | 75 ++++++++++ test/src/header_bar_configuration_test.dart | 6 + test/src/native_headerbar_audit_test.dart | 72 +++++----- 6 files changed, 258 insertions(+), 57 deletions(-) diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index 7298d90..6bcdfaf 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -122,6 +122,7 @@ abstract final class BusyMarkStroke { abstract final class BusyMarkAlpha { static const double groupedRowLightHoverStrength = 0.50; + static const double nativeHeaderMenuShadowOpacity = 0.30; static const double textSelection = 0.32; static const double sourceCollapsedLine = 0.045; static const double sourceCursor = 0.82; diff --git a/lib/src/platform/header_bar_configuration.dart b/lib/src/platform/header_bar_configuration.dart index a349e49..676540e 100644 --- a/lib/src/platform/header_bar_configuration.dart +++ b/lib/src/platform/header_bar_configuration.dart @@ -130,17 +130,28 @@ class HeaderBarTheme { required this.sidebarBackgroundColor, required this.foregroundColor, required this.sidebarBorderColor, + required this.popoverBackgroundColor, + required this.menuHoverColor, + required this.popoverShadowColor, required this.modalBarrierColor, }); factory HeaderBarTheme.fromContext(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); + final theme = Theme.of(context); return HeaderBarTheme( - preferDark: Theme.of(context).brightness == Brightness.dark, + preferDark: theme.brightness == Brightness.dark, backgroundColor: colors.view, sidebarBackgroundColor: colors.sidebar, foregroundColor: colors.foreground, sidebarBorderColor: colors.sidebarBorder, + popoverBackgroundColor: colors.popover, + menuHoverColor: colors.controlHover, + popoverShadowColor: theme.colorScheme.shadow.withValues( + alpha: + theme.colorScheme.shadow.a * + BusyMarkAlpha.nativeHeaderMenuShadowOpacity, + ), modalBarrierColor: colors.shade, ); } @@ -150,6 +161,9 @@ class HeaderBarTheme { final Color sidebarBackgroundColor; final Color foregroundColor; final Color sidebarBorderColor; + final Color popoverBackgroundColor; + final Color menuHoverColor; + final Color popoverShadowColor; final Color modalBarrierColor; Map toMap() => { @@ -158,6 +172,9 @@ class HeaderBarTheme { 'sidebarBackgroundColor': _cssColor(sidebarBackgroundColor), 'foregroundColor': _cssColor(foregroundColor), 'sidebarBorderColor': _cssColor(sidebarBorderColor), + 'popoverBackgroundColor': _cssColor(popoverBackgroundColor), + 'menuHoverColor': _cssColor(menuHoverColor), + 'popoverShadowColor': _cssColor(popoverShadowColor), 'modalBarrierColor': _cssColor(modalBarrierColor), }; @@ -170,6 +187,9 @@ class HeaderBarTheme { sidebarBackgroundColor == other.sidebarBackgroundColor && foregroundColor == other.foregroundColor && sidebarBorderColor == other.sidebarBorderColor && + popoverBackgroundColor == other.popoverBackgroundColor && + menuHoverColor == other.menuHoverColor && + popoverShadowColor == other.popoverShadowColor && modalBarrierColor == other.modalBarrierColor; } @@ -180,6 +200,9 @@ class HeaderBarTheme { sidebarBackgroundColor, foregroundColor, sidebarBorderColor, + popoverBackgroundColor, + menuHoverColor, + popoverShadowColor, modalBarrierColor, ]); } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 7aca3e4..2df9627 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -22,6 +22,7 @@ constexpr char kDefaultHeaderbarBackground[] = "#272727"; constexpr char kDefaultSidebarBackground[] = "#393939"; constexpr char kDefaultSidebarBorder[] = "rgba(16,16,16,0.35)"; constexpr char kDefaultForeground[] = "#F7F7F7"; +constexpr char kDefaultHeaderMenuShadowColor[] = "rgba(0,0,0,0.3)"; constexpr char kDefaultModalBarrierColor[] = "rgba(0,0,0,0.25)"; // Yaru GTK 3 adds a zero-blur 23%/75% black ring around CSD windows. Current // Ubuntu apps retain the diffuse shadow without that legacy hard edge. Reuse @@ -56,6 +57,9 @@ constexpr char kMenuAcceleratorAttribute[] = "x-busymark-accelerator"; constexpr char kMenuIconAttribute[] = "x-busymark-icon"; constexpr char kModelButtonAcceleratorKey[] = "busymark-model-button-accelerator"; +constexpr char kNativePopoverStyleClass[] = "busymark-native-popover"; +constexpr char kHeaderMenuDepthStyleClass[] = + "busymark-header-menu-depth"; struct _MyApplication { GtkApplication parent_instance; @@ -101,6 +105,9 @@ struct _MyApplication { gchar* sidebar_background_color; gchar* foreground_color; gchar* sidebar_border_color; + gchar* popover_background_color; + gchar* popover_shadow_color; + gchar* menu_hover_color; gchar* modal_barrier_color; gint sidebar_width; gboolean sidebar_visible; @@ -137,6 +144,23 @@ struct HeaderBarConfiguration { G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) +static void style_native_popover(GtkWidget* popover) { + if (popover == nullptr || !GTK_IS_POPOVER(popover)) { + return; + } + gtk_style_context_add_class(gtk_widget_get_style_context(popover), + kNativePopoverStyleClass); +} + +static void style_header_menu_popover(GtkWidget* popover) { + style_native_popover(popover); + if (popover == nullptr || !GTK_IS_POPOVER(popover)) { + return; + } + gtk_style_context_add_class(gtk_widget_get_style_context(popover), + kHeaderMenuDepthStyleClass); +} + static GdkPixbuf* load_application_icon_at_size(gint size) { g_autofree gchar* executable_path = g_file_read_link("/proc/self/exe", nullptr); @@ -598,10 +622,49 @@ static void refresh_header_bar_css(MyApplication* self) { css_color_or(self->foreground_color, kDefaultForeground); const gchar* sidebar_border = css_color_or(self->sidebar_border_color, kDefaultSidebarBorder); + const gboolean use_legacy_yaru_compatibility = + uses_legacy_yaru_window_shadow(); + g_autofree gchar* native_popover_css = + is_css_color_token(self->popover_background_color) + ? g_strdup_printf( + "popover.background.%s," + "popover.background.%s:backdrop {" + "background-color: %s;" + "background-image: none;" + "}", + kNativePopoverStyleClass, kNativePopoverStyleClass, + self->popover_background_color) + : g_strdup(""); + g_autofree gchar* native_menu_state_css = + is_css_color_token(self->menu_hover_color) + ? g_strdup_printf( + "popover.background.%s " + "modelbutton:hover:not(:disabled) {" + "background-color: %s;" + "background-image: none;" + "}" + "popover.background.%s " + "row:hover:not(:disabled) {" + "background-color: %s;" + "background-image: none;" + "}", + kNativePopoverStyleClass, self->menu_hover_color, + kNativePopoverStyleClass, self->menu_hover_color) + : g_strdup(""); + g_autofree gchar* header_menu_shadow_css = + use_legacy_yaru_compatibility + ? g_strdup_printf( + "popover.background.%s.%s:not(:backdrop) {" + "box-shadow: 0 1px 3px %s;" + "}", + kNativePopoverStyleClass, kHeaderMenuDepthStyleClass, + css_color_or(self->popover_shadow_color, + kDefaultHeaderMenuShadowColor)) + : g_strdup(""); g_autofree gchar* modal = modal_barrier_color_for_depth( css_color_or(self->modal_barrier_color, kDefaultModalBarrierColor), self->modal_barrier_depth); - const gchar* window_shadow_css = uses_legacy_yaru_window_shadow() + const gchar* window_shadow_css = use_legacy_yaru_compatibility ? kLegacyYaruWindowShadowCompatibilityCss : ""; @@ -618,6 +681,9 @@ static void refresh_header_bar_css(MyApplication* self) { "background-image: none;" "}" "%s" + "%s" + "%s" + "%s" ".busymark-titlebar," ".busymark-titlebar:backdrop {" "background-color: %s;" @@ -640,25 +706,25 @@ static void refresh_header_bar_css(MyApplication* self) { "headerbar.busymark-headerbar:dir(rtl) {" "padding-right: 0;" "}" - ".busymark-titlebar .busymark-sidebar-header," - ".busymark-titlebar .busymark-sidebar-header:backdrop {" + ".busymark-sidebar-header," + ".busymark-sidebar-header:backdrop {" "background-color: %s;" "background-image: none;" "color: %s;" "border: none;" "box-shadow: none;" "}" - ".busymark-titlebar .busymark-sidebar-header label {" + ".busymark-sidebar-header label {" "color: %s;" "font-weight: 800;" "}" - ".busymark-titlebar .busymark-sidebar-header label:backdrop {" + ".busymark-sidebar-header label:backdrop {" "color: alpha(%s, %.2f);" "}" - ".busymark-titlebar .busymark-sidebar-header:dir(ltr) {" + ".busymark-sidebar-header:dir(ltr) {" "border-right: 1px solid %s;" "}" - ".busymark-titlebar .busymark-sidebar-header:dir(rtl) {" + ".busymark-sidebar-header:dir(rtl) {" "border-left: 1px solid %s;" "}" // Legacy Yaru GTK 3 uses an absolute near-black image for active and @@ -722,8 +788,9 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: %s;" "background-image: none;" "}", - background, window_shadow_css, background, foreground, background, - foreground, sidebar_background, foreground, foreground, foreground, + background, window_shadow_css, native_popover_css, native_menu_state_css, + header_menu_shadow_css, background, foreground, background, foreground, + sidebar_background, foreground, foreground, foreground, kHeaderBackdropForegroundOpacity, sidebar_border, sidebar_border, modal); g_autoptr(GError) error = nullptr; @@ -771,6 +838,13 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { replace_css_color_field( &self->sidebar_border_color, fl_lookup_string_arg(args, "sidebarBorderColor")); + replace_css_color_field( + &self->popover_background_color, + fl_lookup_string_arg(args, "popoverBackgroundColor")); + replace_css_color_field(&self->popover_shadow_color, + fl_lookup_string_arg(args, "popoverShadowColor")); + replace_css_color_field(&self->menu_hover_color, + fl_lookup_string_arg(args, "menuHoverColor")); replace_css_color_field(&self->modal_barrier_color, fl_lookup_string_arg(args, "modalBarrierColor")); refresh_header_bar_css(self); @@ -1382,6 +1456,7 @@ static GtkWidget* create_model_menu_button(GMenuModel* model, make_icon_button_square(button); GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(button)); if (popover != nullptr) { + style_header_menu_popover(GTK_WIDGET(popover)); gtk_popover_set_position(popover, GTK_POS_BOTTOM); decorate_model_menu_accelerators(GTK_WIDGET(popover), model); if (popover_out != nullptr) { @@ -1699,26 +1774,29 @@ static GtkWidget* create_busymark_titlebar(MyApplication* self) { rebuild_main_menu_model(self, nullptr); rebuild_view_mode_menu_model(self, nullptr); - self->sidebar_header_box = gtk_overlay_new(); + // An ordinary GtkBox owns the complete painted sidebar-brand allocation. + // GtkOverlay can leave its own background node transparent under some GTK + // 3/Yaru combinations, which exposed the main headerbar color even though + // Dart supplied the sidebar color. + self->sidebar_header_box = + gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); gtk_widget_set_halign(self->sidebar_header_box, GTK_ALIGN_FILL); gtk_widget_set_hexpand(self->sidebar_header_box, FALSE); gtk_style_context_add_class(gtk_widget_get_style_context(self->sidebar_header_box), "busymark-sidebar-header"); - GtkWidget* sidebar_action_box = - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - gtk_widget_set_halign(sidebar_action_box, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(sidebar_action_box, TRUE); - gtk_container_add(GTK_CONTAINER(self->sidebar_header_box), - sidebar_action_box); - self->sidebar_search_button = create_header_toggle_button("system-search-symbolic"); gtk_style_context_add_class(gtk_widget_get_style_context(self->sidebar_search_button), "busymark-sidebar-action-button"); connect_header_action(self, self->sidebar_search_button, "search"); - gtk_box_pack_start(GTK_BOX(sidebar_action_box), + gtk_box_pack_start(GTK_BOX(self->sidebar_header_box), self->sidebar_search_button, FALSE, FALSE, 0); + GtkWidget* sidebar_title_box = + gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); + gtk_widget_set_halign(sidebar_title_box, GTK_ALIGN_CENTER); + gtk_widget_set_valign(sidebar_title_box, GTK_ALIGN_CENTER); + gtk_widget_set_hexpand(sidebar_title_box, TRUE); self->sidebar_title_label = gtk_label_new(kApplicationDisplayName); gtk_widget_set_halign(self->sidebar_title_label, GTK_ALIGN_CENTER); gtk_widget_set_valign(self->sidebar_title_label, GTK_ALIGN_CENTER); @@ -1726,17 +1804,17 @@ static GtkWidget* create_busymark_titlebar(MyApplication* self) { PANGO_ELLIPSIZE_END); gtk_style_context_add_class(gtk_widget_get_style_context(self->sidebar_title_label), GTK_STYLE_CLASS_TITLE); - gtk_overlay_add_overlay(GTK_OVERLAY(self->sidebar_header_box), - self->sidebar_title_label); - gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(self->sidebar_header_box), - self->sidebar_title_label, TRUE); + gtk_box_pack_start(GTK_BOX(sidebar_title_box), self->sidebar_title_label, + FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(self->sidebar_header_box), sidebar_title_box, + TRUE, TRUE, 0); self->sidebar_menu_button = create_model_menu_button( G_MENU_MODEL(self->main_menu_model), "open-menu-symbolic", &self->sidebar_menu); gtk_style_context_add_class(gtk_widget_get_style_context(self->sidebar_menu_button), "busymark-sidebar-action-button"); - gtk_box_pack_end(GTK_BOX(sidebar_action_box), + gtk_box_pack_end(GTK_BOX(self->sidebar_header_box), self->sidebar_menu_button, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(self->titlebar_box), self->sidebar_header_box, FALSE, FALSE, 0); @@ -2492,6 +2570,7 @@ static void show_native_menu(NativeMenuHandlerData* data, session->popover = gtk_popover_new_from_model( data->view, G_MENU_MODEL(session->model)); g_object_ref_sink(session->popover); + style_native_popover(session->popover); gtk_popover_set_pointing_to(GTK_POPOVER(session->popover), &anchor); gtk_popover_set_position(GTK_POPOVER(session->popover), preferred_position); @@ -2586,6 +2665,11 @@ static void my_application_activate(GApplication* application) { self->titlebar_handle = hdy_window_handle_new(); gtk_widget_set_hexpand(self->titlebar_handle, TRUE); gtk_widget_set_vexpand(self->titlebar_handle, FALSE); + // Keep the style scope on the outer native owner. This also makes + // focused/backdrop selectors independent of the inner box hierarchy. + gtk_style_context_add_class( + gtk_widget_get_style_context(self->titlebar_handle), + "busymark-titlebar"); gtk_container_add(GTK_CONTAINER(self->titlebar_handle), create_busymark_titlebar_overlay(self)); gtk_widget_show_all(self->titlebar_handle); @@ -2679,6 +2763,9 @@ static void my_application_dispose(GObject* object) { g_clear_pointer(&self->background_color, g_free); g_clear_pointer(&self->sidebar_background_color, g_free); g_clear_pointer(&self->sidebar_border_color, g_free); + g_clear_pointer(&self->popover_background_color, g_free); + g_clear_pointer(&self->popover_shadow_color, g_free); + g_clear_pointer(&self->menu_hover_color, g_free); g_clear_pointer(&self->modal_barrier_color, g_free); g_clear_pointer(&self->view_mode, g_free); g_clear_pointer(&self->search_query, g_free); @@ -2740,6 +2827,9 @@ static void my_application_init(MyApplication* self) { self->sidebar_background_color = g_strdup(kDefaultSidebarBackground); self->foreground_color = g_strdup(kDefaultForeground); self->sidebar_border_color = nullptr; + self->popover_background_color = nullptr; + self->popover_shadow_color = nullptr; + self->menu_hover_color = nullptr; self->modal_barrier_color = nullptr; self->sidebar_width = 300; self->sidebar_visible = TRUE; diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index 90d0ffd..5d25379 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -321,6 +321,81 @@ void main() { ); }); + testWidgets('dark header menu delegates its tooltip to IconButton', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ), + home: Scaffold( + body: Center( + child: BusyMarkHeaderPopupMenuButton( + tooltip: 'Dark menu', + icon: BusyMarkGlyphs.menuVertical, + itemBuilder: (_) => [ + BusyMarkPopupMenuItem(value: 'action', label: 'Action'), + ], + onSelected: (_) {}, + ), + ), + ), + ), + ); + + final iconButton = tester.widget( + find.descendant( + of: find.byType(BusyMarkHeaderPopupMenuButton), + matching: find.byType(IconButton), + ), + ); + expect(iconButton.tooltip, 'Dark menu'); + expect( + find.descendant( + of: find.byType(BusyMarkHeaderPopupMenuButton), + matching: find.byWidgetPredicate( + (widget) => widget is Tooltip && widget.message == 'Dark menu', + ), + ), + findsOneWidget, + ); + }); + + testWidgets('settings dropdowns use the framework tooltip', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ), + home: Scaffold( + body: BusyMarkPopupSelector( + value: 'dark', + label: 'Dark', + tooltip: 'Theme', + options: const [ + BusyMarkPopupSelectorOption(value: 'dark', label: 'Dark'), + BusyMarkPopupSelectorOption(value: 'light', label: 'Light'), + ], + onSelected: (_) {}, + ), + ), + ), + ); + + expect( + find.descendant( + of: find.byType(BusyMarkPopupSelector), + matching: find.byWidgetPredicate( + (widget) => widget is Tooltip && widget.message == 'Theme', + ), + ), + findsOneWidget, + ); + }); + testWidgets( 'popup menu rows show shortcuts without redundant hover tooltips', (tester) async { diff --git a/test/src/header_bar_configuration_test.dart b/test/src/header_bar_configuration_test.dart index 6adf3a3..644cbdd 100644 --- a/test/src/header_bar_configuration_test.dart +++ b/test/src/header_bar_configuration_test.dart @@ -403,6 +403,9 @@ void main() { 'sidebarBackgroundColor', 'foregroundColor', 'sidebarBorderColor', + 'popoverBackgroundColor', + 'menuHoverColor', + 'popoverShadowColor', 'modalBarrierColor', }); expect( @@ -484,5 +487,8 @@ const _theme = HeaderBarTheme( sidebarBackgroundColor: Color(0xFFF6F6F6), foregroundColor: Color(0xFF202020), sidebarBorderColor: Color(0x11010203), + popoverBackgroundColor: Color(0xFFFAFAFA), + menuHoverColor: Color(0x16000000), + popoverShadowColor: Color(0x4D000000), modalBarrierColor: Color(0x55000000), ); diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 2cbc8ba..4b15ed4 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -326,7 +326,7 @@ void main() { final native = File('linux/runner/my_application.cc').readAsStringSync(); final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); - expect(configuration, contains('preferDark: Theme.of(context).brightness')); + expect(configuration, contains('preferDark: theme.brightness')); expect(configuration, contains("'preferDark': preferDark")); expect(native, contains('static void set_gtk_theme_preference')); expect(native, contains('gtk_settings_get_default()')); @@ -488,9 +488,9 @@ void main() { expect( native, contains( - '".busymark-titlebar .busymark-sidebar-header,"' + '".busymark-sidebar-header,"' '\n ' - '".busymark-titlebar .busymark-sidebar-header:backdrop {"', + '".busymark-sidebar-header:backdrop {"', ), ); expect( @@ -539,10 +539,11 @@ void main() { expect(css, contains('background-color: alpha(currentColor, 0.07)')); expect(css, contains('background-color: alpha(currentColor, 0.16)')); expect(css, contains('background-color: alpha(currentColor, 0.10)')); - expect(css, isNot(contains('popover.background'))); + expect(css, contains('popover.background.')); + expect(css, contains('modelbutton:hover:not(:disabled)')); + expect(css, contains('box-shadow: 0 1px 3px')); for (final interactionSelector in [ 'button.', - 'modelbutton', 'tooltip', ':focus', '@define-color', @@ -563,8 +564,9 @@ void main() { expect(configuration, contains('backgroundColor: colors.view')); expect(configuration, contains('sidebarBackgroundColor: colors.sidebar')); expect(configuration, contains('foregroundColor: colors.foreground')); + expect(configuration, contains('popoverBackgroundColor: colors.popover')); + expect(configuration, contains('menuHoverColor: colors.controlHover')); expect(configuration, isNot(contains('borderColor'))); - expect(configuration, isNot(contains('popoverBackgroundColor'))); expect(configuration, isNot(contains('floatingBorderColor'))); expect(native, contains('kDefaultHeaderbarBackground[] = "#272727"')); expect(native, contains('kDefaultSidebarBackground[] = "#393939"')); @@ -573,52 +575,62 @@ void main() { native, contains('fl_lookup_string_arg(args, "sidebarBorderColor")'), ); - expect(native, isNot(contains('"floatingBorderColor"'))); - expect(native, isNot(contains('"popoverBackgroundColor"'))); expect( native, - contains( - 'css_color_or(self->sidebar_border_color, kDefaultSidebarBorder)', - ), + contains('fl_lookup_string_arg(args, "popoverBackgroundColor")'), ); - expect(native, isNot(contains('busymark-header-popover'))); + expect(native, contains('fl_lookup_string_arg(args, "menuHoverColor")')); expect( native, - contains('gtk_popover_set_position(popover, GTK_POS_BOTTOM)'), + contains('fl_lookup_string_arg(args, "popoverShadowColor")'), ); + expect(native, isNot(contains('"floatingBorderColor"'))); expect( native, - contains('".busymark-titlebar .busymark-sidebar-header:backdrop {"'), + contains( + 'css_color_or(self->sidebar_border_color, kDefaultSidebarBorder)', + ), ); - expect(native, contains('background-color: %s;')); + expect(native, contains('kNativePopoverStyleClass')); + expect(native, contains('kHeaderMenuDepthStyleClass')); + expect(native, contains('style_header_menu_popover(GTK_WIDGET(popover))')); + expect(native, contains('style_native_popover(session->popover)')); expect( native, - contains('".busymark-titlebar .busymark-sidebar-header label {"'), + contains('gtk_popover_set_position(popover, GTK_POS_BOTTOM)'), ); + expect(native, contains('".busymark-sidebar-header:backdrop {"')); + expect(native, contains('background-color: %s;')); + expect(native, contains('".busymark-sidebar-header label {"')); expect(native, contains('"font-weight: 800;"')); + expect(native, contains('".busymark-sidebar-header label:backdrop {"')); + expect(native, contains('kHeaderBackdropForegroundOpacity = 0.50')); expect( native, contains( - '".busymark-titlebar .busymark-sidebar-header label:backdrop {"', + 'self->sidebar_header_box =\n' + ' gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing)', ), ); - expect(native, contains('kHeaderBackdropForegroundOpacity = 0.50')); - expect(native, contains('self->sidebar_header_box = gtk_overlay_new()')); - expect(native, contains('GtkWidget* sidebar_action_box =')); + expect(native, contains('GtkWidget* sidebar_title_box =')); expect( native, contains( - 'gtk_overlay_add_overlay(GTK_OVERLAY(self->sidebar_header_box),', + 'gtk_box_pack_start(GTK_BOX(self->sidebar_header_box), ' + 'sidebar_title_box,', ), ); + expect( + native, + isNot(contains('self->sidebar_header_box = gtk_overlay_new()')), + ); expect( native, contains( - 'gtk_overlay_set_overlay_pass_through(' - 'GTK_OVERLAY(self->sidebar_header_box),', + 'gtk_widget_get_style_context(self->titlebar_handle),\n' + ' "busymark-titlebar"', ), ); - expect(native, isNot(contains('GtkWidget* sidebar_title_box ='))); final headerbarBlock = RegExp( r'"headerbar\.busymark-headerbar,"(.*?)"\}', dotAll: true, @@ -630,15 +642,9 @@ void main() { expect(headerbarBlock, isNot(contains('border-radius'))); expect(headerbarBlock, isNot(contains('"padding-left: 0;"'))); expect(headerbarBlock, isNot(contains('"padding-right: 0;"'))); - expect( - native, - contains('".busymark-titlebar .busymark-sidebar-header:dir(ltr) {"'), - ); + expect(native, contains('".busymark-sidebar-header:dir(ltr) {"')); expect(native, contains('"border-right: 1px solid %s;"')); - expect( - native, - contains('".busymark-titlebar .busymark-sidebar-header:dir(rtl) {"'), - ); + expect(native, contains('".busymark-sidebar-header:dir(rtl) {"')); expect(native, contains('"border-left: 1px solid %s;"')); expect(native, contains('".busymark-modal-scrim {"')); expect(native, contains('create_busymark_titlebar_overlay')); @@ -1232,7 +1238,7 @@ void main() { } expect(native, contains('view_mode_icon_name(mode)')); expect(native, contains('view_mode_icon_name("split")')); - expect(native, isNot(contains('modelbutton:hover'))); + expect(native, contains('modelbutton:hover:not(:disabled)')); expect(native, isNot(contains('modelbutton:focus'))); expect(native, isNot(contains('modelbutton:active'))); expect(native, isNot(contains('outline-width: 0;'))); From 95ef6ca185a8b71d00b7cde7c2b94ffc06f9d725 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 17:14:35 -0700 Subject: [PATCH 14/29] Enhance settings screen with initial page handling and sidebar navigation. Refactor to use a stateful widget for dynamic page selection and improve layout management. Introduce new settings page enumeration and update sidebar navigation for better user experience. --- lib/src/app/app_router.dart | 3 + lib/src/app/busymark_design.dart | 96 ++- lib/src/app/busymark_glyphs.dart | 8 + lib/src/editor/document_surface.dart | 8 + .../editor/wysiwyg/wysiwyg_block_widgets.dart | 4 +- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 5 +- .../presentation/settings_screen.dart | 572 +++++++++++++----- .../presentation/workspace_screen.dart | 15 +- linux/runner/my_application.cc | 194 ++++-- test/src/app_router_test.dart | 26 +- test/src/app_smoke_test.dart | 218 ++++++- test/src/busymark_design_test.dart | 10 +- test/src/modal_barrier_test.dart | 6 +- test/src/native_headerbar_audit_test.dart | 166 ++--- test/src/source_audit_test.dart | 21 + 15 files changed, 994 insertions(+), 358 deletions(-) diff --git a/lib/src/app/app_router.dart b/lib/src/app/app_router.dart index 9e9b6ed..67f7b33 100644 --- a/lib/src/app/app_router.dart +++ b/lib/src/app/app_router.dart @@ -69,6 +69,9 @@ final appRouterProvider = Provider((ref) { pageBuilder: (context, state) => NoTransitionPage( child: SettingsScreen( returnTarget: SettingsReturnTarget.fromSettingsUri(state.uri), + initialPage: settingsPageFromRouteValue( + state.uri.queryParameters['page'], + ), ), ), ), diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index 6bcdfaf..f0586a8 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -40,7 +40,9 @@ abstract final class BusyMarkSizes { static const double contentWidth = 760; static const double documentContentWidth = contentWidth; static const double sidebarWidth = 300; + static const double sidebarRowHeight = 36; static const double settingsWidth = 760; + static const double settingsSidebarBreakpoint = sidebarWidth + 520; static const double toolbarHeight = kYaruTitleBarHeight; static const double paneHeaderHeight = 38; static const double iconButton = kYaruTitleBarItemHeight; @@ -2008,17 +2010,95 @@ class BusyMarkSidebarSurface extends StatelessWidget { @override Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); - return DecoratedBox( - decoration: BoxDecoration( - color: colors.sidebar, - border: BorderDirectional( - end: BorderSide( - color: colors.sidebarBorder, - width: BusyMarkStroke.hairline, + return Material( + color: colors.sidebar, + child: DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + border: BorderDirectional( + end: BorderSide( + color: colors.sidebarBorder, + width: BusyMarkStroke.hairline, + ), ), ), + child: child, ), - child: child, + ); + } +} + +/// A GTK-style navigation list for a persistent desktop sidebar. +class BusyMarkSidebarNavigation extends StatelessWidget { + const BusyMarkSidebarNavigation({super.key, required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = BusyMarkSurfaceColors.of(context); + final masterDetailTheme = YaruMasterDetailTheme.of(context); + + return Theme( + data: theme.copyWith( + listTileTheme: theme.listTileTheme.copyWith( + selectedColor: colors.foreground, + selectedTileColor: Color.alphaBlend(colors.control, colors.sidebar), + tileColor: Colors.transparent, + iconColor: colors.mutedForeground, + textColor: colors.foreground, + titleTextStyle: theme.textTheme.bodyMedium, + contentPadding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.sm, + ), + horizontalTitleGap: BusyMarkSpacing.sm, + minVerticalPadding: 0, + minLeadingWidth: BusyMarkSizes.iconSm, + minTileHeight: BusyMarkSizes.sidebarRowHeight, + visualDensity: VisualDensity.standard, + titleAlignment: ListTileTitleAlignment.center, + ), + ), + child: ListView.separated( + padding: + masterDetailTheme.listPadding ?? + const EdgeInsets.symmetric(vertical: BusyMarkSpacing.sm), + itemCount: children.length, + itemBuilder: (context, index) => children[index], + separatorBuilder: (context, index) => SizedBox( + height: masterDetailTheme.tileSpacing ?? BusyMarkSpacing.xxs, + ), + ), + ); + } +} + +/// A selectable row for [BusyMarkSidebarNavigation]. +class BusyMarkSidebarNavigationTile extends StatelessWidget { + const BusyMarkSidebarNavigationTile({ + super.key, + required this.selected, + required this.leading, + required this.title, + required this.onTap, + }); + + final bool selected; + final Widget leading; + final Widget title; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return YaruMasterTile( + selected: selected, + leading: IconTheme.merge( + data: const IconThemeData(size: BusyMarkSizes.iconSm), + child: leading, + ), + title: title, + onTap: onTap, ); } } diff --git a/lib/src/app/busymark_glyphs.dart b/lib/src/app/busymark_glyphs.dart index c0c65b3..a854ffd 100644 --- a/lib/src/app/busymark_glyphs.dart +++ b/lib/src/app/busymark_glyphs.dart @@ -21,6 +21,7 @@ abstract final class BusyMarkGlyphs { static const IconData copy = YaruIcons.copy; static const IconData cut = YaruIcons.cut; static const IconData delete = YaruIcons.trash; + static const IconData desktop = YaruIcons.desktop; static const IconData diagnostics = YaruIcons.task_list; static const IconData document = YaruIcons.document; static const IconData documentHistory = YaruIcons.document_history; @@ -58,6 +59,7 @@ abstract final class BusyMarkGlyphs { static const IconData paste = YaruIcons.paste; static const IconData preview = YaruIcons.eye; static const IconData previewView = YaruIcons.eye; + static const IconData privacy = YaruIcons.shield_warning; static const IconData pull = YaruIcons.download; static const IconData push = YaruIcons.send; static const IconData redo = YaruIcons.redo; @@ -138,6 +140,9 @@ abstract final class BusyMarkGlyphs { if (icon == delete) { return 'user-trash-symbolic'; } + if (icon == desktop) { + return 'video-display-symbolic'; + } if (icon == document || icon == startTopic) { return 'text-x-generic-symbolic'; } @@ -222,6 +227,9 @@ abstract final class BusyMarkGlyphs { if (icon == preview || icon == previewView) { return 'image-viewer-symbolic'; } + if (icon == privacy) { + return 'security-high-symbolic'; + } if (icon == pull) { return 'folder-download-symbolic'; } diff --git a/lib/src/editor/document_surface.dart b/lib/src/editor/document_surface.dart index 99377a1..92354d8 100644 --- a/lib/src/editor/document_surface.dart +++ b/lib/src/editor/document_surface.dart @@ -2,6 +2,14 @@ import 'package:flutter/material.dart'; import '../app/busymark_design.dart'; +/// Shared prose typography for editable and rendered document views. +TextStyle busyMarkDocumentBodyTextStyle(BuildContext context, {Color? color}) { + return (Theme.of(context).textTheme.bodyMedium ?? const TextStyle()).copyWith( + color: color, + height: BusyMarkTypography.bodyLineHeight, + ); +} + /// Resolves the actual child inset of [BusyMarkDocumentSurface]. /// /// Flutter includes a decorated container's border dimensions in addition to diff --git a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart index ac0ffba..4753d07 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart @@ -446,9 +446,7 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { fontWeight: FontWeight.w700, ), BusyBlockKind.codeBlock => busyMarkDocumentCodeTextStyle(context), - _ => theme.bodyMedium!.copyWith( - height: BusyMarkTypography.bodyLineHeight, - ), + _ => busyMarkDocumentBodyTextStyle(context), }; } diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 67a5a2d..1ee4c53 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -18,6 +18,7 @@ import '../../platform/linux_header_bar_service.dart'; import '../document_callout.dart'; import '../document_code_block.dart'; import '../document_layout.dart'; +import '../document_surface.dart'; import 'wysiwyg_block_widgets.dart'; import 'wysiwyg_commands.dart'; import 'wysiwyg_document_controller.dart'; @@ -2926,9 +2927,7 @@ class _BusyMarkWysiwygEditorState extends State { fontWeight: FontWeight.w700, ), BusyBlockKind.codeBlock => busyMarkDocumentCodeTextStyle(context), - _ => theme.bodyMedium!.copyWith( - height: BusyMarkTypography.bodyLineHeight, - ), + _ => busyMarkDocumentBodyTextStyle(context), }; } diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index 91f685c..537d5e0 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -14,191 +16,265 @@ import '../../app/localization.dart'; import '../../feedback/presentation/feedback_dialog.dart'; import '../../platform/linux_header_bar_service.dart'; -class SettingsScreen extends ConsumerWidget { - const SettingsScreen({required this.returnTarget, super.key}); +class SettingsScreen extends ConsumerStatefulWidget { + const SettingsScreen({ + required this.returnTarget, + this.initialPage = SettingsPage.appearance, + super.key, + }); final SettingsReturnTarget returnTarget; + final SettingsPage initialPage; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends ConsumerState { + late SettingsPage _page = widget.initialPage; + + @override + void didUpdateWidget(covariant SettingsScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialPage != widget.initialPage) { + _page = widget.initialPage; + } + } + + @override + Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final settings = ref.watch(appSettingsControllerProvider); - final controller = ref.watch(appSettingsControllerProvider.notifier); + final controller = ref.read(appSettingsControllerProvider.notifier); final colors = BusyMarkSurfaceColors.of(context); final headerBar = ref.watch(linuxHeaderBarServiceProvider); final useNativeHeaderBar = headerBar.usesNativeHeaderBar; + final title = _settingsPageLabel(context, _page); ref.listen(headerBarActionsProvider, (previous, next) { next.whenData((event) { _handleHeaderBarAction(context, headerBar, event.action); }); }); - final headerConfiguration = HeaderBarConfigurationDefaults.of(context) - .copyWith( - title: l10n.settingsTitle, - viewMode: AppViewMode.editor, - searchQuery: '', - canRefresh: false, - documentControlsVisible: false, - searchActive: false, - searchVisible: false, - sidebarVisible: false, - sidebarToggleVisible: false, - backVisible: true, - ); + final pageBody = switch (_page) { + SettingsPage.appearance => BusyMarkGroupedList( + title: l10n.appearance, + filled: true, + children: [ + _LanguageRow( + selectedLocaleTag: settings.localeTag, + onChanged: controller.setLocaleTag, + ), + _ThemeModeRow( + selected: settings.themeModePreference, + onChanged: controller.setThemeModePreference, + ), + ], + ), + SettingsPage.editor => BusyMarkGroupedList( + title: l10n.editor, + filled: true, + children: [ + BusyMarkSwitchRow( + title: l10n.autoSave, + subtitle: l10n.autoSaveDescription, + value: settings.autoSave, + onChanged: controller.setAutoSave, + leading: const Icon(BusyMarkGlyphs.save), + ), + BusyMarkSwitchRow( + title: l10n.wordWrap, + value: settings.wordWrap, + onChanged: controller.setWordWrap, + leading: Icon( + BusyMarkGlyphs.wordWrapFor(Directionality.of(context)), + ), + ), + _EditorFontSizeRow( + value: settings.editorFontSize, + onChanged: controller.setEditorFontSize, + ), + _EditorToolbarPlacementRow( + selected: settings.editorToolbarPlacement, + onChanged: controller.setEditorToolbarPlacement, + ), + _EditorToolbarDirectionRow( + selected: settings.editorToolbarDirection, + onChanged: controller.setEditorToolbarDirection, + ), + ], + ), + SettingsPage.validation => BusyMarkGroupedList( + title: l10n.validation, + filled: true, + children: [ + BusyMarkSwitchRow( + title: l10n.validateOnEdit, + value: settings.validateOnEdit, + onChanged: controller.setValidateOnEdit, + leading: const Icon(BusyMarkGlyphs.diagnostics), + ), + ], + ), + SettingsPage.window => BusyMarkGroupedList( + title: l10n.settingsWindowSectionTitle, + filled: true, + children: [ + BusyMarkSwitchRow( + title: l10n.settingsConfirmCloseWithUnsavedChangesTitle, + subtitle: l10n.settingsConfirmCloseWithUnsavedChangesDescription, + value: settings.confirmCloseWithUnsavedChanges, + onChanged: controller.setConfirmCloseWithUnsavedChanges, + leading: const Icon(BusyMarkGlyphs.warning), + ), + ], + ), + SettingsPage.privacy => BusyMarkGroupedList( + title: l10n.privacy, + filled: true, + children: [ + BusyMarkSwitchRow( + title: l10n.allowRemoteImages, + subtitle: l10n.allowRemoteImagesDescription, + value: settings.allowRemoteImages, + onChanged: controller.setAllowRemoteImages, + leading: const Icon(BusyMarkGlyphs.image), + ), + if (settings.remoteImageAllowedWorkspacePaths.isNotEmpty) + BusyMarkActionRow( + title: l10n.clearRemoteImagePermissions, + subtitle: l10n.clearRemoteImagePermissionsDescription, + leading: const Icon(BusyMarkGlyphs.clearAll), + onTap: controller.clearRemoteImageWorkspacePermissions, + ), + if (settings.trustedGitWorkspacePaths.isNotEmpty) + BusyMarkActionRow( + title: l10n.clearGitWorkspaceTrust, + subtitle: l10n.clearGitWorkspaceTrustDescription, + leading: const Icon(BusyMarkGlyphs.clearAll), + onTap: controller.clearTrustedGitWorkspaces, + ), + ], + ), + SettingsPage.advanced => BusyMarkGroupedList( + title: l10n.advanced, + filled: true, + children: [ + BusyMarkActionRow( + title: l10n.clearRecentWorkspaces, + leading: const Icon(BusyMarkGlyphs.clearAll), + destructive: true, + onTap: controller.clearRecentWorkspaces, + ), + ], + ), + }; - return HeaderBarConfigurationPublisher( - synchronizer: headerBar.configurationSynchronizer, - configuration: headerConfiguration, - enabled: headerBar.isAvailable, - child: Scaffold( - backgroundColor: colors.view, - appBar: useNativeHeaderBar - ? null - : AppBar( - leading: Center( - child: BusyMarkHeaderIconButton( - tooltip: context.l10n.back, - icon: BusyMarkGlyphs.backFor(Directionality.of(context)), - onPressed: () => context.go(returnTarget.location), - ), - ), - title: Text( - context.l10n.settingsTitle, - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), - ), - actions: [ - BusyMarkMainMenuButton( - onSelected: (action) => - _handleMainMenuAction(context, headerBar, action), - ), - const SizedBox(width: BusyMarkSpacing.sm), - ], - ), - body: BusyMarkClamp( - maxWidth: BusyMarkSizes.settingsWidth, - margin: EdgeInsets.zero, - padding: BusyMarkInsets.settingsPage, + return LayoutBuilder( + builder: (context, constraints) { + final showSidebar = + constraints.maxWidth >= BusyMarkSizes.settingsSidebarBreakpoint; + final headerConfiguration = HeaderBarConfigurationDefaults.of(context) + .copyWith( + title: title, + viewMode: AppViewMode.editor, + searchQuery: '', + canRefresh: false, + documentControlsVisible: false, + searchActive: false, + searchVisible: false, + sidebarVisible: showSidebar, + sidebarToggleVisible: false, + backVisible: true, + ); + final content = ColoredBox( + key: const ValueKey('settings-content-surface'), + color: colors.view, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - BusyMarkGroupedList( - title: context.l10n.appearance, - filled: true, - children: [ - _LanguageRow( - selectedLocaleTag: settings.localeTag, - onChanged: controller.setLocaleTag, - ), - _ThemeModeRow( - selected: settings.themeModePreference, - onChanged: controller.setThemeModePreference, - ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.editor, - filled: true, - children: [ - BusyMarkSwitchRow( - title: context.l10n.autoSave, - subtitle: context.l10n.autoSaveDescription, - value: settings.autoSave, - onChanged: controller.setAutoSave, - leading: const Icon(BusyMarkGlyphs.save), - ), - BusyMarkSwitchRow( - title: context.l10n.wordWrap, - value: settings.wordWrap, - onChanged: controller.setWordWrap, - leading: Icon( - BusyMarkGlyphs.wordWrapFor(Directionality.of(context)), - ), - ), - _EditorFontSizeRow( - value: settings.editorFontSize, - onChanged: controller.setEditorFontSize, - ), - _EditorToolbarPlacementRow( - selected: settings.editorToolbarPlacement, - onChanged: controller.setEditorToolbarPlacement, - ), - _EditorToolbarDirectionRow( - selected: settings.editorToolbarDirection, - onChanged: controller.setEditorToolbarDirection, + if (!useNativeHeaderBar) + _SettingsFallbackHeader( + title: title, + onBack: _goBack, + onMenuSelected: (action) => + _handleMainMenuAction(context, headerBar, action), + ), + if (!showSidebar) + Padding( + padding: const EdgeInsets.fromLTRB( + BusyMarkSpacing.lg, + BusyMarkSpacing.md, + BusyMarkSpacing.lg, + 0, ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.validation, - filled: true, - children: [ - BusyMarkSwitchRow( - title: context.l10n.validateOnEdit, - value: settings.validateOnEdit, - onChanged: controller.setValidateOnEdit, - leading: const Icon(BusyMarkGlyphs.diagnostics), + child: _SettingsPageSelector( + selected: _page, + onSelected: _selectPage, ), - ], - ), - BusyMarkGroupedList( - title: l10n.settingsWindowSectionTitle, - filled: true, - children: [ - BusyMarkSwitchRow( - title: l10n.settingsConfirmCloseWithUnsavedChangesTitle, - subtitle: - l10n.settingsConfirmCloseWithUnsavedChangesDescription, - value: settings.confirmCloseWithUnsavedChanges, - onChanged: controller.setConfirmCloseWithUnsavedChanges, - leading: const Icon(BusyMarkGlyphs.warning), - ), - ], + ), + Expanded( + child: BusyMarkClamp( + maxWidth: BusyMarkSizes.settingsWidth, + margin: EdgeInsets.zero, + padding: BusyMarkInsets.settingsPage, + child: pageBody, + ), ), - BusyMarkGroupedList( - title: context.l10n.privacy, - filled: true, + ], + ), + ); + final body = showSidebar + ? Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - BusyMarkSwitchRow( - title: context.l10n.allowRemoteImages, - subtitle: context.l10n.allowRemoteImagesDescription, - value: settings.allowRemoteImages, - onChanged: controller.setAllowRemoteImages, - leading: const Icon(BusyMarkGlyphs.image), - ), - if (settings.remoteImageAllowedWorkspacePaths.isNotEmpty) - BusyMarkActionRow( - title: context.l10n.clearRemoteImagePermissions, - subtitle: - context.l10n.clearRemoteImagePermissionsDescription, - leading: const Icon(BusyMarkGlyphs.clearAll), - onTap: controller.clearRemoteImageWorkspacePermissions, + SizedBox( + width: BusyMarkSizes.sidebarWidth, + child: _SettingsSidebar( + selected: _page, + onSelected: _selectPage, ), - if (settings.trustedGitWorkspacePaths.isNotEmpty) - BusyMarkActionRow( - title: context.l10n.clearGitWorkspaceTrust, - subtitle: context.l10n.clearGitWorkspaceTrustDescription, - leading: const Icon(BusyMarkGlyphs.clearAll), - onTap: controller.clearTrustedGitWorkspaces, - ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.advanced, - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.clearRecentWorkspaces, - leading: const Icon(BusyMarkGlyphs.clearAll), - destructive: true, - onTap: controller.clearRecentWorkspaces, ), + Expanded(child: content), ], - ), - ], - ), - ), + ) + : content; + + return HeaderBarConfigurationPublisher( + synchronizer: headerBar.configurationSynchronizer, + configuration: headerConfiguration, + enabled: headerBar.isAvailable, + child: Scaffold(backgroundColor: colors.view, body: body), + ); + }, + ); + } + + void _goBack() { + context.go(widget.returnTarget.location); + } + + void _selectPage(SettingsPage page) { + if (_page != page) { + setState(() => _page = page); + } + final router = GoRouter.maybeOf(context); + final uri = router?.state.uri; + if (router == null || uri == null || uri.path != settingsRoutePath) { + return; + } + if (uri.queryParameters['page'] == settingsPageRouteValue(page)) { + return; + } + unawaited( + router.replace( + uri + .replace( + queryParameters: { + ...uri.queryParameters, + 'page': settingsPageRouteValue(page), + }, + ) + .toString(), ), ); } @@ -210,7 +286,7 @@ class SettingsScreen extends ConsumerWidget { ) { switch (action) { case HeaderBarAction.back: - context.go(returnTarget.location); + _goBack(); case HeaderBarAction.aboutBusyMark: showBusyMarkAboutDialog(context); case HeaderBarAction.keyboardShortcuts: @@ -223,6 +299,7 @@ class SettingsScreen extends ConsumerWidget { headerBarService: headerBar.isAvailable ? headerBar : null, ); case HeaderBarAction.settings: + _selectPage(SettingsPage.appearance); case HeaderBarAction.sidebarToggle: case HeaderBarAction.search: case HeaderBarAction.refresh: @@ -243,7 +320,7 @@ class SettingsScreen extends ConsumerWidget { ) { switch (action) { case BusyMarkMainMenuAction.settings: - break; + _selectPage(SettingsPage.appearance); case BusyMarkMainMenuAction.keyboardShortcuts: showBusyMarkKeyboardShortcutsDialog(context); case BusyMarkMainMenuAction.markdownAndHtml: @@ -259,6 +336,177 @@ class SettingsScreen extends ConsumerWidget { } } +enum SettingsPage { appearance, editor, validation, window, privacy, advanced } + +SettingsPage settingsPageFromRouteValue(String? value) { + return switch (value) { + 'editor' => SettingsPage.editor, + 'validation' => SettingsPage.validation, + 'window' => SettingsPage.window, + 'privacy' => SettingsPage.privacy, + 'advanced' => SettingsPage.advanced, + _ => SettingsPage.appearance, + }; +} + +String settingsPageRouteValue(SettingsPage page) => page.name; + +String _settingsPageLabel(BuildContext context, SettingsPage page) { + final l10n = context.l10n; + return switch (page) { + SettingsPage.appearance => l10n.appearance, + SettingsPage.editor => l10n.editor, + SettingsPage.validation => l10n.validation, + SettingsPage.window => l10n.settingsWindowSectionTitle, + SettingsPage.privacy => l10n.privacy, + SettingsPage.advanced => l10n.advanced, + }; +} + +IconData _settingsPageIcon(SettingsPage page) { + return switch (page) { + SettingsPage.appearance => BusyMarkGlyphs.appearance, + SettingsPage.editor => BusyMarkGlyphs.editorView, + SettingsPage.validation => BusyMarkGlyphs.diagnostics, + SettingsPage.window => BusyMarkGlyphs.desktop, + SettingsPage.privacy => BusyMarkGlyphs.privacy, + SettingsPage.advanced => BusyMarkGlyphs.settings, + }; +} + +class _SettingsSidebar extends StatelessWidget { + const _SettingsSidebar({required this.selected, required this.onSelected}); + + final SettingsPage selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return BusyMarkSidebarSurface( + child: BusyMarkSidebarNavigation( + children: [ + for (final page in SettingsPage.values) + BusyMarkSidebarNavigationTile( + key: ValueKey('settings-navigation-${page.name}'), + selected: page == selected, + leading: Icon(_settingsPageIcon(page)), + title: Text(_settingsPageLabel(context, page)), + onTap: () => onSelected(page), + ), + ], + ), + ); + } +} + +class _SettingsPageSelector extends StatelessWidget { + const _SettingsPageSelector({ + required this.selected, + required this.onSelected, + }); + + final SettingsPage selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return SizedBox( + key: const ValueKey('settings-page-selector'), + width: double.infinity, + child: BusyMarkMenuButton( + tooltip: _settingsPageLabel(context, selected), + fallbackMenuWidth: BusyMarkSizes.languagePopupMaxWidth, + items: [ + for (final page in SettingsPage.values) + BusyMarkPopupMenuItem( + value: page, + label: _settingsPageLabel(context, page), + icon: _settingsPageIcon(page), + checked: page == selected, + trailingCheck: true, + ), + ], + onSelected: onSelected, + triggerBuilder: (context, trigger) { + return trigger.anchor( + child: Tooltip( + message: _settingsPageLabel(context, selected), + child: Semantics( + expanded: trigger.isOpen, + child: BusyMarkPushButton.standard( + onPressed: trigger.onPressed, + focusNode: trigger.focusNode, + child: Row( + children: [ + Icon(_settingsPageIcon(selected)), + const SizedBox(width: BusyMarkSpacing.sm), + Expanded( + child: Text( + _settingsPageLabel(context, selected), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(BusyMarkGlyphs.downArrow), + ], + ), + ), + ), + ), + ); + }, + ), + ); + } +} + +class _SettingsFallbackHeader extends StatelessWidget { + const _SettingsFallbackHeader({ + required this.title, + required this.onBack, + required this.onMenuSelected, + }); + + final String title; + final VoidCallback onBack; + final ValueChanged onMenuSelected; + + @override + Widget build(BuildContext context) { + final colors = BusyMarkSurfaceColors.of(context); + return Material( + color: colors.window, + child: SizedBox( + height: BusyMarkSizes.toolbarHeight, + child: Row( + children: [ + const SizedBox(width: BusyMarkSpacing.sm), + BusyMarkHeaderIconButton( + tooltip: context.l10n.back, + icon: BusyMarkGlyphs.backFor(Directionality.of(context)), + onPressed: onBack, + ), + const SizedBox(width: BusyMarkSpacing.sm), + Expanded( + child: Text( + title, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + ), + BusyMarkMainMenuButton(onSelected: onMenuSelected), + const SizedBox(width: BusyMarkSpacing.sm), + ], + ), + ), + ); + } +} + class _LanguageRow extends StatelessWidget { const _LanguageRow({ required this.selectedLocaleTag, diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index fe8684d..b420913 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -27,6 +27,7 @@ import '../../core/uri_utils.dart'; import '../../editor/document_callout.dart'; import '../../editor/document_code_block.dart'; import '../../editor/document_layout.dart'; +import '../../editor/document_surface.dart'; import '../../editor/document_text_direction.dart'; import '../../editor/markdown_image_view.dart'; import '../../editor/source/source_controller.dart'; @@ -7947,7 +7948,7 @@ class _PreviewBlockView extends StatelessWidget { return TextSpan(style: blockStyle, children: spans); } - TextStyle? _diffPreviewTextStyle( + TextStyle _diffPreviewTextStyle( BuildContext context, PreviewBlock block, TextStyle? base, @@ -7959,22 +7960,22 @@ class _PreviewBlockView extends StatelessWidget { ); } - TextStyle? _diffPreviewTextStyleForTone( + TextStyle _diffPreviewTextStyleForTone( BuildContext context, _DiffPreviewTone? tone, TextStyle? base, ) { final colors = BusyMarkSurfaceColors.of(context); - final effectiveBase = base ?? Theme.of(context).textTheme.bodyMedium; + final effectiveBase = base ?? busyMarkDocumentBodyTextStyle(context); return switch (tone) { _DiffPreviewTone.added || _DiffPreviewTone.changed => - effectiveBase?.copyWith(backgroundColor: colors.admonitionTip), - _DiffPreviewTone.removed => effectiveBase?.copyWith( + effectiveBase.copyWith(backgroundColor: colors.admonitionTip), + _DiffPreviewTone.removed => effectiveBase.copyWith( color: colors.mutedForeground, backgroundColor: colors.admonitionWarning, decoration: TextDecoration.lineThrough, ), - null => base, + null => effectiveBase, }; } @@ -8180,7 +8181,7 @@ class _PreviewInlineText extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final baseStyle = style ?? Theme.of(context).textTheme.bodyMedium; + final baseStyle = style ?? busyMarkDocumentBodyTextStyle(context); final searchState = ref.watch(_workspaceSearchProvider); final workspace = ref.watch(workspaceControllerProvider).workspace; final settings = ref.watch(appSettingsControllerProvider); diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 2df9627..c9449a1 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -18,6 +18,9 @@ constexpr gint kHeaderButtonHeight = 32; constexpr gint kHeaderButtonSpacing = 8; constexpr gint kHeaderSidebarInset = 8; constexpr gdouble kHeaderBackdropForegroundOpacity = 0.50; +constexpr gdouble kHeaderDisabledForegroundOpacity = 0.38; +constexpr gdouble kHeaderDisabledBackdropForegroundOpacity = + kHeaderDisabledForegroundOpacity * kHeaderBackdropForegroundOpacity; constexpr char kDefaultHeaderbarBackground[] = "#272727"; constexpr char kDefaultSidebarBackground[] = "#393939"; constexpr char kDefaultSidebarBorder[] = "rgba(16,16,16,0.35)"; @@ -60,6 +63,10 @@ constexpr char kModelButtonAcceleratorKey[] = constexpr char kNativePopoverStyleClass[] = "busymark-native-popover"; constexpr char kHeaderMenuDepthStyleClass[] = "busymark-header-menu-depth"; +constexpr char kHeaderApplicationActiveStyleClass[] = + "busymark-focus-active"; +constexpr char kHeaderApplicationBackdropStyleClass[] = + "busymark-focus-backdrop"; struct _MyApplication { GtkApplication parent_instance; @@ -75,10 +82,7 @@ struct _MyApplication { GtkWidget* titlebar_box; GtkHeaderBar* header_bar; GtkWidget* sidebar_header_box; - GtkWidget* sidebar_search_button; GtkWidget* sidebar_title_label; - GtkWidget* sidebar_menu_button; - GtkWidget* sidebar_menu; GMenu* main_menu_model; GtkWidget* header_start_box; GtkWidget* back_button; @@ -94,9 +98,9 @@ struct _MyApplication { GtkWidget* view_mode_menu; GMenu* view_mode_menu_model; GtkWidget* refresh_button; - GtkWidget* adaptive_search_button; - GtkWidget* adaptive_menu_button; - GtkWidget* adaptive_menu; + GtkWidget* search_button; + GtkWidget* main_menu_button; + GtkWidget* main_menu; GSimpleActionGroup* header_action_group; GSimpleAction* view_mode_action; gchar* view_mode; @@ -144,6 +148,8 @@ struct HeaderBarConfiguration { G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) +static void schedule_header_bar_focus_state_refresh(MyApplication* self); + static void style_native_popover(GtkWidget* popover) { if (popover == nullptr || !GTK_IS_POPOVER(popover)) { return; @@ -542,13 +548,6 @@ static void set_toggle_button_active(MyApplication* self, self->suppress_header_actions = previous; } -static void update_adaptive_header_actions(MyApplication* self) { - const gboolean use_main_header = !self->sidebar_visible; - set_widget_visible(self->adaptive_search_button, - use_main_header && self->search_visible); - set_widget_visible(self->adaptive_menu_button, use_main_header); -} - static void update_sidebar_header_geometry(MyApplication* self) { if (self->sidebar_header_box == nullptr || !GTK_IS_WIDGET(self->sidebar_header_box)) { @@ -557,7 +556,6 @@ static void update_sidebar_header_geometry(MyApplication* self) { const gint width = self->sidebar_visible ? self->sidebar_width : 0; gtk_widget_set_size_request(self->sidebar_header_box, width, -1); set_widget_visible(self->sidebar_header_box, width > 0); - update_adaptive_header_actions(self); } static void update_titlebar_direction(MyApplication* self) { @@ -591,20 +589,13 @@ static void update_titlebar_direction(MyApplication* self) { set_widget_direction(self->view_mode_button, direction); set_widget_direction(self->view_mode_icon, direction); set_widget_direction(self->refresh_button, direction); - set_widget_direction(self->adaptive_search_button, direction); - set_widget_direction(self->adaptive_menu_button, direction); - set_widget_direction(self->adaptive_menu, direction); - set_widget_direction(self->sidebar_search_button, direction); - set_widget_direction(self->sidebar_menu_button, direction); - set_widget_direction(self->sidebar_menu, direction); + set_widget_direction(self->search_button, direction); + set_widget_direction(self->main_menu_button, direction); + set_widget_direction(self->main_menu, direction); set_widget_direction(self->view_mode_menu, direction); // GTK 3 resolves logical margins against the widget direction at setter // time, so reapply both sides after a live LTR/RTL direction change. - set_widget_horizontal_margins(self->sidebar_search_button, - kHeaderSidebarInset, 0); - set_widget_horizontal_margins(self->sidebar_menu_button, 0, - kHeaderSidebarInset); set_widget_horizontal_margins(self->header_start_box, kHeaderSidebarInset, 0); } @@ -661,6 +652,55 @@ static void refresh_header_bar_css(MyApplication* self) { css_color_or(self->popover_shadow_color, kDefaultHeaderMenuShadowColor)) : g_strdup(""); + g_autofree gchar* header_focus_css = g_strdup_printf( + ".busymark-titlebar.%s .busymark-sidebar-header label," + ".busymark-titlebar.%s .busymark-header-title {" + "color: %s;" + "}" + ".busymark-titlebar.%s .busymark-sidebar-header label," + ".busymark-titlebar.%s .busymark-header-title {" + "color: alpha(%s, %.2f);" + "}" + ".busymark-titlebar.%s " + ".busymark-header-control:not(:disabled)," + ".busymark-titlebar.%s " + "headerbar button.titlebutton:not(:disabled) {" + "color: %s;" + "-gtk-icon-effect: none;" + "}" + ".busymark-titlebar.%s " + ".busymark-header-control:not(:disabled)," + ".busymark-titlebar.%s " + "headerbar button.titlebutton:not(:disabled) {" + "color: alpha(%s, %.2f);" + "-gtk-icon-effect: none;" + "}" + ".busymark-titlebar.%s .busymark-header-control:disabled," + ".busymark-titlebar.%s headerbar button.titlebutton:disabled {" + "color: alpha(%s, %.2f);" + "-gtk-icon-effect: none;" + "}" + ".busymark-titlebar.%s .busymark-header-control:disabled," + ".busymark-titlebar.%s headerbar button.titlebutton:disabled {" + "color: alpha(%s, %.2f);" + "-gtk-icon-effect: none;" + "}", + kHeaderApplicationActiveStyleClass, + kHeaderApplicationActiveStyleClass, foreground, + kHeaderApplicationBackdropStyleClass, + kHeaderApplicationBackdropStyleClass, foreground, + kHeaderBackdropForegroundOpacity, + kHeaderApplicationActiveStyleClass, + kHeaderApplicationActiveStyleClass, foreground, + kHeaderApplicationBackdropStyleClass, + kHeaderApplicationBackdropStyleClass, foreground, + kHeaderBackdropForegroundOpacity, + kHeaderApplicationActiveStyleClass, + kHeaderApplicationActiveStyleClass, foreground, + kHeaderDisabledForegroundOpacity, + kHeaderApplicationBackdropStyleClass, + kHeaderApplicationBackdropStyleClass, foreground, + kHeaderDisabledBackdropForegroundOpacity); g_autofree gchar* modal = modal_barrier_color_for_depth( css_color_or(self->modal_barrier_color, kDefaultModalBarrierColor), self->modal_barrier_depth); @@ -727,6 +767,7 @@ static void refresh_header_bar_css(MyApplication* self) { ".busymark-sidebar-header:dir(rtl) {" "border-left: 1px solid %s;" "}" + "%s" // Legacy Yaru GTK 3 uses an absolute near-black image for active and // checked buttons. BusyMark-owned controls use neutral current-color // layers while GTK continues to own geometry, focus, and motion. @@ -791,7 +832,8 @@ static void refresh_header_bar_css(MyApplication* self) { background, window_shadow_css, native_popover_css, native_menu_state_css, header_menu_shadow_css, background, foreground, background, foreground, sidebar_background, foreground, foreground, foreground, - kHeaderBackdropForegroundOpacity, sidebar_border, sidebar_border, modal); + kHeaderBackdropForegroundOpacity, sidebar_border, sidebar_border, + header_focus_css, modal); g_autoptr(GError) error = nullptr; GtkCssProvider* provider = gtk_css_provider_new(); @@ -814,6 +856,48 @@ static void refresh_header_bar_css(MyApplication* self) { GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); } +static gboolean refresh_header_bar_focus_state_cb(gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (self->titlebar_handle == nullptr || + !GTK_IS_WIDGET(self->titlebar_handle)) { + return G_SOURCE_REMOVE; + } + + const gboolean application_active = + self->main_window != nullptr && + gtk_window_is_active(self->main_window); + GtkStyleContext* context = + gtk_widget_get_style_context(self->titlebar_handle); + gtk_style_context_remove_class(context, + kHeaderApplicationActiveStyleClass); + gtk_style_context_remove_class(context, + kHeaderApplicationBackdropStyleClass); + gtk_style_context_add_class( + context, + application_active ? kHeaderApplicationActiveStyleClass + : kHeaderApplicationBackdropStyleClass); + + // The headerbar is embedded above Flutter rather than installed as + // GtkWindow's titlebar. Reset its subtree after the compositor's focus + // transfer settles so :backdrop declarations cannot remain one event late. + gtk_widget_reset_style(self->titlebar_handle); + gtk_widget_queue_draw(self->titlebar_handle); + return G_SOURCE_REMOVE; +} + +static void schedule_header_bar_focus_state_refresh(MyApplication* self) { + g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, + refresh_header_bar_focus_state_cb, g_object_ref(self), + g_object_unref); +} + +static void header_focus_window_is_active_notify_cb( + GtkWindow*, + GParamSpec*, + gpointer user_data) { + schedule_header_bar_focus_state_refresh(MY_APPLICATION(user_data)); +} + static void gtk_theme_name_changed_cb(GtkSettings*, GParamSpec*, gpointer user_data) { @@ -1266,9 +1350,7 @@ static void rebuild_main_menu_model(MyApplication* self, FlValue* labels) { localized_label_or(labels, "aboutBusyMark", ""), "header.about", main_menu_icon_name("aboutBusyMark"), nullptr); decorate_model_menu_accelerators( - self->sidebar_menu, G_MENU_MODEL(self->main_menu_model)); - decorate_model_menu_accelerators( - self->adaptive_menu, G_MENU_MODEL(self->main_menu_model)); + self->main_menu, G_MENU_MODEL(self->main_menu_model)); } static const gchar* view_mode_dart_action(const gchar* mode) { @@ -1518,16 +1600,13 @@ static void set_localized_labels(MyApplication* self, FlValue* args) { set_widget_tooltip(self->back_button, back); set_widget_tooltip_with_shortcut(self->sidebar_toggle_button, sidebar, sidebar_shortcut); - set_widget_tooltip_with_shortcut(self->sidebar_search_button, search, - search_shortcut); - set_widget_tooltip_with_shortcut(self->adaptive_search_button, search, + set_widget_tooltip_with_shortcut(self->search_button, search, search_shortcut); if (self->search_entry != nullptr && GTK_IS_ENTRY(self->search_entry) && search != nullptr) { gtk_entry_set_placeholder_text(GTK_ENTRY(self->search_entry), search); } - set_widget_tooltip(self->sidebar_menu_button, menu); - set_widget_tooltip(self->adaptive_menu_button, menu); + set_widget_tooltip(self->main_menu_button, menu); set_widget_tooltip(self->refresh_button, refresh); set_widget_tooltip_with_shortcut( self->view_mode_button, view_mode, @@ -1557,8 +1636,7 @@ static void set_modal_barrier_depth(MyApplication* self, gint64 depth) { } set_widget_visible(self->modal_scrim, visible); if (visible) { - close_header_menu_button(self->sidebar_menu_button); - close_header_menu_button(self->adaptive_menu_button); + close_header_menu_button(self->main_menu_button); close_header_menu_button(self->view_mode_button); focus_flutter_view(self); } @@ -1609,8 +1687,7 @@ static void set_document_controls_visible(MyApplication* self, static void set_search_active(MyApplication* self, gboolean active) { const gboolean changed = self->search_active != active; self->search_active = active; - set_toggle_button_active(self, self->sidebar_search_button, active); - set_toggle_button_active(self, self->adaptive_search_button, active); + set_toggle_button_active(self, self->search_button, active); if (self->title_stack != nullptr && GTK_IS_STACK(self->title_stack)) { GtkWidget* visible_child = active ? self->search_entry : self->title_label; if (visible_child != nullptr && GTK_IS_WIDGET(visible_child)) { @@ -1646,8 +1723,7 @@ static gboolean focus_search_entry(MyApplication* self) { static void set_search_visible(MyApplication* self, gboolean visible) { self->search_visible = visible; - set_widget_visible(self->sidebar_search_button, visible); - update_adaptive_header_actions(self); + set_widget_visible(self->search_button, visible); if (!visible && self->search_active) { set_search_active(self, FALSE); } @@ -1785,13 +1861,6 @@ static GtkWidget* create_busymark_titlebar(MyApplication* self) { gtk_style_context_add_class(gtk_widget_get_style_context(self->sidebar_header_box), "busymark-sidebar-header"); - self->sidebar_search_button = create_header_toggle_button("system-search-symbolic"); - gtk_style_context_add_class(gtk_widget_get_style_context(self->sidebar_search_button), - "busymark-sidebar-action-button"); - connect_header_action(self, self->sidebar_search_button, "search"); - gtk_box_pack_start(GTK_BOX(self->sidebar_header_box), - self->sidebar_search_button, FALSE, FALSE, 0); - GtkWidget* sidebar_title_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); gtk_widget_set_halign(sidebar_title_box, GTK_ALIGN_CENTER); @@ -1809,13 +1878,6 @@ static GtkWidget* create_busymark_titlebar(MyApplication* self) { gtk_box_pack_start(GTK_BOX(self->sidebar_header_box), sidebar_title_box, TRUE, TRUE, 0); - self->sidebar_menu_button = create_model_menu_button( - G_MENU_MODEL(self->main_menu_model), "open-menu-symbolic", - &self->sidebar_menu); - gtk_style_context_add_class(gtk_widget_get_style_context(self->sidebar_menu_button), - "busymark-sidebar-action-button"); - gtk_box_pack_end(GTK_BOX(self->sidebar_header_box), - self->sidebar_menu_button, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(self->titlebar_box), self->sidebar_header_box, FALSE, FALSE, 0); @@ -1887,15 +1949,14 @@ static GtkWidget* create_busymark_titlebar(MyApplication* self) { self->refresh_button = create_header_icon_button("tools-check-spelling-symbolic"); connect_header_action(self, self->refresh_button, "refresh"); gtk_box_pack_start(GTK_BOX(end_box), self->refresh_button, FALSE, FALSE, 0); - self->adaptive_search_button = + self->search_button = create_header_toggle_button("system-search-symbolic"); - connect_header_action(self, self->adaptive_search_button, "search"); - gtk_box_pack_start(GTK_BOX(end_box), self->adaptive_search_button, FALSE, - FALSE, 0); - self->adaptive_menu_button = create_model_menu_button( + connect_header_action(self, self->search_button, "search"); + gtk_box_pack_start(GTK_BOX(end_box), self->search_button, FALSE, FALSE, 0); + self->main_menu_button = create_model_menu_button( G_MENU_MODEL(self->main_menu_model), "open-menu-symbolic", - &self->adaptive_menu); - gtk_box_pack_start(GTK_BOX(end_box), self->adaptive_menu_button, FALSE, FALSE, + &self->main_menu); + gtk_box_pack_start(GTK_BOX(end_box), self->main_menu_button, FALSE, FALSE, 0); gtk_header_bar_pack_end(self->header_bar, end_box); @@ -2675,6 +2736,9 @@ static void my_application_activate(GApplication* application) { gtk_widget_show_all(self->titlebar_handle); gtk_window_set_default_size(window, 1280, 720); + g_signal_connect( + window, "notify::is-active", + G_CALLBACK(header_focus_window_is_active_notify_cb), self); g_autoptr(FlDartProject) project = fl_dart_project_new(); fl_dart_project_set_dart_entrypoint_arguments( @@ -2703,6 +2767,7 @@ static void my_application_activate(GApplication* application) { register_native_menu_channel(self, view); gtk_widget_grab_focus(GTK_WIDGET(view)); + schedule_header_bar_focus_state_refresh(self); } // Implements GApplication::local_command_line. @@ -2797,10 +2862,7 @@ static void my_application_init(MyApplication* self) { self->modal_scrim = nullptr; self->header_bar = nullptr; self->sidebar_header_box = nullptr; - self->sidebar_search_button = nullptr; self->sidebar_title_label = nullptr; - self->sidebar_menu_button = nullptr; - self->sidebar_menu = nullptr; self->main_menu_model = nullptr; self->header_start_box = nullptr; self->back_button = nullptr; @@ -2816,9 +2878,9 @@ static void my_application_init(MyApplication* self) { self->view_mode_menu = nullptr; self->view_mode_menu_model = nullptr; self->refresh_button = nullptr; - self->adaptive_search_button = nullptr; - self->adaptive_menu_button = nullptr; - self->adaptive_menu = nullptr; + self->search_button = nullptr; + self->main_menu_button = nullptr; + self->main_menu = nullptr; self->header_action_group = nullptr; self->view_mode_action = nullptr; self->view_mode = nullptr; diff --git a/test/src/app_router_test.dart b/test/src/app_router_test.dart index de45f02..c0552e9 100644 --- a/test/src/app_router_test.dart +++ b/test/src/app_router_test.dart @@ -4,9 +4,11 @@ import 'package:busymark/l10n/generated/app_localizations_en.dart'; import 'package:busymark/src/app/app_router.dart'; import 'package:busymark/src/app/busymark_app.dart'; import 'package:busymark/src/platform/linux_header_bar_service.dart'; +import 'package:busymark/src/workspace/presentation/settings_screen.dart'; import 'package:busymark/src/workspace/workspace_controller.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -63,6 +65,20 @@ void main() { ); }); + test('settings page routes are explicit and validated', () { + expect(settingsPageFromRouteValue(null), SettingsPage.appearance); + expect(settingsPageFromRouteValue('editor'), SettingsPage.editor); + expect(settingsPageFromRouteValue('validation'), SettingsPage.validation); + expect(settingsPageFromRouteValue('window'), SettingsPage.window); + expect(settingsPageFromRouteValue('privacy'), SettingsPage.privacy); + expect(settingsPageFromRouteValue('advanced'), SettingsPage.advanced); + expect(settingsPageFromRouteValue('unexpected'), SettingsPage.appearance); + expect( + SettingsPage.values.map(settingsPageRouteValue), + SettingsPage.values.map((page) => page.name), + ); + }); + testWidgets( 'Settings opened from Welcome returns to Welcome with a stale workspace', (tester) async { @@ -88,12 +104,18 @@ void main() { await tester.tap(find.text(l10n.settings)); await tester.pumpAndSettle(); - expect(find.text(l10n.settingsTitle), findsOneWidget); + expect( + find.byKey(const ValueKey('settings-page-selector')), + findsOneWidget, + ); await tester.tap(find.byTooltip(l10n.back)); await tester.pumpAndSettle(); - expect(find.text(l10n.settingsTitle), findsNothing); + expect( + find.byKey(const ValueKey('settings-page-selector')), + findsNothing, + ); expect(find.text(l10n.createMarkdownFile), findsOneWidget); }, ); diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index db85989..b992919 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -34,6 +34,7 @@ import 'package:busymark/src/platform/linux_header_bar_service.dart'; import 'package:busymark/src/writerside/writerside_model.dart'; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; import 'package:busymark/src/writerside/writerside_topic_removal_service.dart'; +import 'package:busymark/src/workspace/presentation/settings_screen.dart'; import 'package:busymark/src/workspace/workspace_controller.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; @@ -214,7 +215,10 @@ void main() { await tester.tap(find.text(l10n.settings)); await tester.pumpAndSettle(); - expect(find.text(l10n.settingsTitle), findsOneWidget); + expect( + find.byKey(const ValueKey('settings-page-selector')), + findsOneWidget, + ); expect(find.text(l10n.reportIssue), findsNothing); await tester.tap(find.byTooltip(l10n.mainMenu)); @@ -289,7 +293,10 @@ void main() { await tester.pumpAndSettle(); await pressShortcut(LogicalKeyboardKey.keyS, control: true, alt: true); - expect(find.text(l10n.settingsTitle), findsOneWidget); + expect( + find.byKey(const ValueKey('settings-page-selector')), + findsOneWidget, + ); }); testWidgets('settings screen opens', (tester) async { @@ -313,22 +320,13 @@ void main() { await tester.tap(find.text(l10n.settings)); await tester.pumpAndSettle(); - expect(find.text(l10n.settingsTitle), findsOneWidget); - expect(find.text(l10n.appLanguage), findsOneWidget); - expect(find.text(l10n.systemLanguage), findsWidgets); - expect(find.text(l10n.autoSave), findsOneWidget); - expect(find.text(l10n.autoSaveDescription), findsOneWidget); - expect(find.text(l10n.validateOnEdit), findsOneWidget); - expect(find.byType(DropdownButton), findsNothing); - expect(find.text(l10n.settingsWindowSectionTitle), findsOneWidget); - expect( - find.text(l10n.settingsConfirmCloseWithUnsavedChangesTitle), - findsOneWidget, - ); expect( - find.text(l10n.settingsConfirmCloseWithUnsavedChangesDescription), + find.byKey(const ValueKey('settings-page-selector')), findsOneWidget, ); + expect(find.text(l10n.appLanguage), findsOneWidget); + expect(find.text(l10n.systemLanguage), findsWidgets); + expect(find.byType(DropdownButton), findsNothing); await tester.tap(find.byTooltip(l10n.appLanguage)); await tester.pumpAndSettle(); @@ -350,15 +348,38 @@ void main() { await tester.tap(find.text(l10n.systemLanguage).last); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.editor)); + await tester.pumpAndSettle(); + + expect(find.text(l10n.autoSave), findsOneWidget); + expect(find.text(l10n.autoSaveDescription), findsOneWidget); await tester.tap(find.text(l10n.autoSave)); await tester.pumpAndSettle(); expect(settingsStore.value['autoSave'], isFalse); - await tester.ensureVisible( + await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.validation)); + await tester.pumpAndSettle(); + + expect(find.text(l10n.validateOnEdit), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.settingsWindowSectionTitle)); + await tester.pumpAndSettle(); + + expect( find.text(l10n.settingsConfirmCloseWithUnsavedChangesTitle), + findsOneWidget, + ); + expect( + find.text(l10n.settingsConfirmCloseWithUnsavedChangesDescription), + findsOneWidget, ); - await tester.pumpAndSettle(); await tester.tap( find.text(l10n.settingsConfirmCloseWithUnsavedChangesTitle), ); @@ -367,6 +388,135 @@ void main() { expect(settingsStore.value['confirmCloseWithUnsavedChanges'], isFalse); }); + testWidgets('settings uses the regular split sidebar at desktop width', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 760); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + ], + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip(l10n.mainMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.settings)); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMarkSidebarSurface), findsOneWidget); + expect(find.byType(BusyMarkSidebarNavigation), findsOneWidget); + expect( + find.byType(BusyMarkSidebarNavigationTile), + findsNWidgets(SettingsPage.values.length), + ); + expect( + tester.getSize(find.byType(BusyMarkSidebarSurface)).width, + BusyMarkSizes.sidebarWidth, + ); + expect(find.byKey(const ValueKey('settings-page-selector')), findsNothing); + + final appearanceTile = tester.widget( + find.byKey(const ValueKey('settings-navigation-appearance')), + ); + final editorTile = tester.widget( + find.byKey(const ValueKey('settings-navigation-editor')), + ); + expect(appearanceTile.selected, isTrue); + expect(editorTile.selected, isFalse); + + var header = tester.widget( + find.byType(HeaderBarConfigurationPublisher), + ); + expect(header.configuration.sidebarVisible, isTrue); + expect(header.configuration.sidebarToggleVisible, isFalse); + expect(header.configuration.title, l10n.appearance); + + await tester.tap(find.byKey(const ValueKey('settings-navigation-editor'))); + await tester.pumpAndSettle(); + + expect( + tester + .widget( + find.byKey(const ValueKey('settings-navigation-editor')), + ) + .selected, + isTrue, + ); + expect(find.text(l10n.autoSave), findsOneWidget); + header = tester.widget( + find.byType(HeaderBarConfigurationPublisher), + ); + expect(header.configuration.sidebarVisible, isTrue); + expect(header.configuration.title, l10n.editor); + }); + + testWidgets('settings main surface matches the headerbar in light and dark', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 760); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + for (final preference in [ + BusyMarkThemeModePreference.light, + BusyMarkThemeModePreference.dark, + ]) { + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(themeModePreference: preference) + .toJson(); + headerBarService = _FallbackHeaderBarService(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + ], + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip(l10n.mainMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.settings)); + await tester.pumpAndSettle(); + + final surfaceFinder = find.byKey( + const ValueKey('settings-content-surface'), + ); + expect(surfaceFinder, findsOneWidget); + final surface = tester.widget(surfaceFinder); + final surfaceContext = tester.element(surfaceFinder); + final colors = BusyMarkSurfaceColors.of(surfaceContext); + final header = tester.widget( + find.byType(HeaderBarConfigurationPublisher), + ); + + expect( + Theme.of(surfaceContext).brightness, + preference == BusyMarkThemeModePreference.dark + ? Brightness.dark + : Brightness.light, + ); + expect(surface.color, colors.view); + expect(surface.color, header.configuration.theme.backgroundColor); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + } + }); + testWidgets('stored language override localizes app text', (tester) async { final de = AppLocalizationsDe(); final settingsStore = _MemorySettingsStore() @@ -388,8 +538,17 @@ void main() { await tester.tap(find.text(de.settings)); await tester.pumpAndSettle(); - expect(find.text(de.settingsTitle), findsOneWidget); + expect( + find.byKey(const ValueKey('settings-page-selector')), + findsOneWidget, + ); expect(find.text(de.appLanguage), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(de.validation)); + await tester.pumpAndSettle(); + expect(find.text(de.validateOnEdit), findsOneWidget); }); @@ -2998,7 +3157,7 @@ void main() { .copyWith(documentViewMode: DocumentViewModePreference.editor) .toJson(); const service = _SearchWorkspaceService( - '# Shared document frame\n\nParagraph\n', + '# Shared document frame\n\nParagraph line one\nParagraph line two\n', ); final container = ProviderContainer( overrides: [ @@ -3039,6 +3198,13 @@ void main() { final editorParagraphRect = tester.getRect( find.descendant(of: editorScroll, matching: find.byType(TextField)).at(1), ); + final editorParagraphStyle = tester + .widget( + find + .descendant(of: editorScroll, matching: find.byType(TextField)) + .at(1), + ) + .style; final editorPadding = tester.widget(editorScroll).padding; final expectedStandalone = BusyMarkDocumentLayoutSpec.standalone .withEditingToolbar( @@ -3073,11 +3239,17 @@ void main() { of: previewContent, matching: find.byWidgetPredicate( (widget) => - widget is RichText && widget.text.toPlainText() == 'Paragraph', + widget is Text && + widget.textSpan?.toPlainText().contains('Paragraph line one') == + true, ), ); expect(previewParagraph, findsOneWidget); final previewParagraphRect = tester.getRect(previewParagraph); + final previewParagraphStyle = tester + .widget(previewParagraph) + .textSpan + ?.style; expect(previewRect.left, closeTo(editorRect.left, 0.1)); expect(previewRect.right, closeTo(editorRect.right, 0.1)); expect(previewRect.top, closeTo(editorRect.top, 0.1)); @@ -3085,6 +3257,12 @@ void main() { expect(previewHeadingRect.top, closeTo(editorHeadingRect.top, 0.1)); expect(previewParagraphRect.left, closeTo(editorParagraphRect.left, 0.1)); expect(previewParagraphRect.top, closeTo(editorParagraphRect.top, 0.1)); + expect(editorParagraphStyle?.height, BusyMarkTypography.bodyLineHeight); + expect(previewParagraphStyle?.height, editorParagraphStyle?.height); + expect( + previewParagraphRect.height, + closeTo(editorParagraphRect.height, 0.1), + ); expect(tester.widget(previewScroll).padding, editorPadding); await container diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index 5d25379..ea48574 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -1517,6 +1517,12 @@ void main() { ), ); + final surface = tester.widget( + find.descendant( + of: find.byType(BusyMarkSidebarSurface), + matching: find.byType(Material), + ), + ); final box = tester.widget( find.descendant( of: find.byType(BusyMarkSidebarSurface), @@ -1524,7 +1530,9 @@ void main() { ), ); final decoration = box.decoration as BoxDecoration; - expect(decoration.color, colors.sidebar); + expect(surface.color, colors.sidebar); + expect(decoration.color, isNull); + expect(box.position, DecorationPosition.foreground); expect( (decoration.border! as BorderDirectional).end.color, colors.sidebarBorder, diff --git a/test/src/modal_barrier_test.dart b/test/src/modal_barrier_test.dart index c58ad20..f02d4b4 100644 --- a/test/src/modal_barrier_test.dart +++ b/test/src/modal_barrier_test.dart @@ -32,11 +32,7 @@ void main() { ); expect( source, - contains('close_header_menu_button(self->sidebar_menu_button);'), - ); - expect( - source, - contains('close_header_menu_button(self->adaptive_menu_button);'), + contains('close_header_menu_button(self->main_menu_button);'), ); expect( source, diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 4b15ed4..3d471d8 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -240,7 +240,7 @@ void main() { expect(mainMenu, contains('BusyMarkHeaderPopupMenuButton')); expect(mainMenu, contains('tooltip: l10n.mainMenu')); expect(mainMenu, contains('label: l10n.reportIssue')); - expect(native, contains('GtkWidget* sidebar_menu_button;')); + expect(native, contains('GtkWidget* main_menu_button;')); expect(native, contains('GMenu* main_menu_model;')); expect(native, contains('GSimpleActionGroup* header_action_group;')); expect(native, contains('rebuild_main_menu_model')); @@ -284,10 +284,15 @@ void main() { final source = file.readAsStringSync(); expect(source, contains('useNativeHeaderBar')); expect(source, contains('usesNativeHeaderBar')); - expect(source, contains('appBar: useNativeHeaderBar')); expect(source, contains('linuxHeaderBarServiceProvider')); expect(source, contains('headerBarActionsProvider')); } + for (final file in files.take(2)) { + expect(file.readAsStringSync(), contains('appBar: useNativeHeaderBar')); + } + final settings = files.last.readAsStringSync(); + expect(settings, contains('if (!useNativeHeaderBar)')); + expect(settings, contains('_SettingsFallbackHeader(')); }, ); @@ -480,6 +485,34 @@ void main() { expect(native, isNot(contains('"tooltip label {"'))); }); + test( + 'native headerbar refreshes focus state when window activation changes', + () { + final native = File('linux/runner/my_application.cc').readAsStringSync(); + + expect(native, contains('kHeaderApplicationActiveStyleClass')); + expect(native, contains('kHeaderApplicationBackdropStyleClass')); + expect(native, contains('"notify::is-active"')); + expect( + native, + contains('G_CALLBACK(header_focus_window_is_active_notify_cb), self'), + ); + expect( + native, + contains('schedule_header_bar_focus_state_refresh(self);'), + ); + expect(native, contains('gtk_window_is_active(self->main_window)')); + expect( + native, + contains('gtk_widget_reset_style(self->titlebar_handle);'), + ); + expect(native, contains('gtk_widget_queue_draw(self->titlebar_handle);')); + expect(native, contains('G_PRIORITY_DEFAULT_IDLE')); + expect(native, contains('g_object_ref(self)')); + expect(native, contains('g_object_unref')); + }, + ); + test( 'native sidebar header uses the same semantic surface as the sidebar', () { @@ -543,7 +576,6 @@ void main() { expect(css, contains('modelbutton:hover:not(:disabled)')); expect(css, contains('box-shadow: 0 1px 3px')); for (final interactionSelector in [ - 'button.', 'tooltip', ':focus', '@define-color', @@ -714,13 +746,9 @@ void main() { } expect(native, contains('gboolean text_direction_rtl;')); expect(native, contains('static void update_titlebar_direction')); - expect(native, contains('set_widget_direction(self->sidebar_menu')); + expect(native, contains('set_widget_direction(self->main_menu')); expect(native, contains('set_widget_direction(self->view_mode_menu')); - expect(native, contains('set_widget_direction(self->adaptive_menu')); - expect( - native, - contains('set_widget_direction(self->adaptive_search_button'), - ); + expect(native, contains('set_widget_direction(self->search_button')); expect(native, contains('kLtrIsolateStart')); expect(native, contains('kBidiIsolateEnd')); expect(native, contains('gtk_box_reorder_child')); @@ -749,24 +777,6 @@ void main() { expect(native, isNot(contains('kHeaderWindowControlsBalanceWidth'))); expect(native, isNot(contains('update_title_stack_alignment'))); expect(directionUpdate, isNotNull); - expect( - directionUpdate, - matches( - RegExp( - r'set_widget_horizontal_margins\(\s*self->sidebar_search_button,\s*' - r'kHeaderSidebarInset,\s*0\);', - ), - ), - ); - expect( - directionUpdate, - matches( - RegExp( - r'set_widget_horizontal_margins\(\s*self->sidebar_menu_button,\s*' - r'0,\s*kHeaderSidebarInset\);', - ), - ), - ); expect( directionUpdate, matches( @@ -777,11 +787,7 @@ void main() { ), ); - for (final widget in [ - 'sidebar_search_button', - 'sidebar_menu_button', - 'header_start_box', - ]) { + for (final widget in ['header_start_box']) { final directionOffset = directionUpdate!.indexOf( 'set_widget_direction(self->$widget, direction)', ); @@ -792,16 +798,6 @@ void main() { expect(marginOffset, greaterThan(directionOffset), reason: widget); } - expect( - native, - isNot( - contains('gtk_widget_set_margin_start(self->sidebar_search_button'), - ), - ); - expect( - native, - isNot(contains('gtk_widget_set_margin_end(self->sidebar_menu_button')), - ); expect( native, isNot(contains('gtk_widget_set_margin_start(self->header_start_box')), @@ -986,38 +982,30 @@ void main() { expect(native, isNot(contains('"key-press-event"'))); }); - test('native window controls are not styled by BusyMark CSS', () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); + test( + 'native window controls retain GTK geometry with synchronized focus color', + () { + final native = File('linux/runner/my_application.cc').readAsStringSync(); - expect(native, isNot(contains('button.titlebutton'))); - expect(native, isNot(contains('const gchar* title_button ='))); - expect( - native, - isNot(contains('css_color_or(self->title_button_color, control)')), - ); - expect(native, isNot(contains('-gtk-gradient'))); - }); + expect(native, contains('headerbar button.titlebutton:not(:disabled)')); + expect(native, contains('headerbar button.titlebutton:disabled')); + expect(native, isNot(contains('const gchar* title_button ='))); + expect( + native, + isNot(contains('css_color_or(self->title_button_color, control)')), + ); + expect(native, isNot(contains('button.titlebutton {background'))); + expect(native, isNot(contains('-gtk-gradient'))); + }, + ); - test('native sidebar header buttons do not fake borders or shadows', () { + test('native sidebar header contains branding but no action buttons', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); - expect(native, contains('"busymark-sidebar-action-button"')); - expect( - native, - isNot( - contains( - '".busymark-sidebar-header button.busymark-sidebar-action-button,"', - ), - ), - ); - expect( - native, - isNot( - contains( - '".busymark-sidebar-header button.busymark-sidebar-action-button:hover {"', - ), - ), - ); + expect(native, contains('self->sidebar_title_label = gtk_label_new(')); + expect(native, isNot(contains('sidebar_search_button'))); + expect(native, isNot(contains('sidebar_menu_button'))); + expect(native, isNot(contains('busymark-sidebar-action-button'))); }); test( @@ -1032,22 +1020,27 @@ void main() { }, ); - test('search and main menu adapt when the sidebar header is hidden', () { + test('search and main menu stay in the main header', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); - expect(native, contains('GtkWidget* adaptive_search_button;')); - expect(native, contains('GtkWidget* adaptive_menu_button;')); - expect(native, contains('static void update_adaptive_header_actions')); + expect(native, contains('GtkWidget* search_button;')); + expect(native, contains('GtkWidget* main_menu_button;')); + expect(native, isNot(contains('adaptive_search_button'))); + expect(native, isNot(contains('adaptive_menu_button'))); expect( native, - contains('const gboolean use_main_header = !self->sidebar_visible'), + contains('set_toggle_button_active(self, self->search_button, active)'), + ); + expect( + native, + contains( + 'gtk_box_pack_start(GTK_BOX(end_box), self->search_button, FALSE, FALSE, 0)', + ), ); - expect(native, contains('use_main_header && self->search_visible')); - expect(native, contains('set_widget_visible(self->adaptive_menu_button')); expect( native, contains( - 'set_toggle_button_active(self, self->adaptive_search_button, active)', + 'gtk_box_pack_start(GTK_BOX(end_box), self->main_menu_button, FALSE, FALSE', ), ); expect( @@ -1419,13 +1412,23 @@ void main() { ); }); - test('settings page uses themed page surface under the headerbar', () { + test('settings page matches the native header surface and split shell', () { final settings = File( 'lib/src/workspace/presentation/settings_screen.dart', ).readAsStringSync(); expect(settings, contains('backgroundColor: colors.view')); - expect(settings, isNot(contains('backgroundColor: colors.window'))); + expect(settings, contains('color: colors.view')); + expect(settings, contains('BusyMarkSidebarSurface(')); + expect(settings, contains('BusyMarkSidebarNavigation(')); + expect(settings, contains('sidebarVisible: showSidebar')); + expect(settings, contains('sidebarToggleVisible: false')); + expect( + settings, + contains( + 'constraints.maxWidth >= BusyMarkSizes.settingsSidebarBreakpoint', + ), + ); }); test( @@ -1445,7 +1448,8 @@ void main() { expect(welcome, contains('HeaderBarConfigurationPublisher(')); expect(welcome, contains('title: context.l10n.appTitle')); expect(settings, contains('HeaderBarConfigurationPublisher(')); - expect(settings, contains('title: l10n.settings')); + expect(settings, contains('final title = _settingsPageLabel(')); + expect(settings, contains('title: title')); expect( workspace, contains('title: busyMarkBidiIsolateFor(context, title)'), diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index e29c1d9..5f014da 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -627,6 +627,27 @@ void main() { ); }); + test('Editor and Preview reuse one prose line-height style', () { + final workspace = File( + 'lib/src/workspace/presentation/workspace_screen.dart', + ).readAsStringSync(); + final blocks = File( + 'lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart', + ).readAsStringSync(); + final editor = File( + 'lib/src/editor/wysiwyg/wysiwyg_editor.dart', + ).readAsStringSync(); + final surface = File( + 'lib/src/editor/document_surface.dart', + ).readAsStringSync(); + + expect(surface, contains('busyMarkDocumentBodyTextStyle(')); + expect(surface, contains('height: BusyMarkTypography.bodyLineHeight')); + expect(workspace, contains('busyMarkDocumentBodyTextStyle(context)')); + expect(blocks, contains('busyMarkDocumentBodyTextStyle(context)')); + expect(editor, contains('busyMarkDocumentBodyTextStyle(context)')); + }); + test('workspace isolates technical labels only at rendering boundaries', () { final workspace = File( 'lib/src/workspace/presentation/workspace_screen.dart', From 2897f2d7fadee8726c3dc0429b64adb361a71991 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 17:30:53 -0700 Subject: [PATCH 15/29] Refactor settings screen to use BusyMarkPopupSelector for theme mode, editor toolbar placement, and direction selection. Improve label handling with a dedicated method for better readability and maintainability. --- .../presentation/settings_screen.dart | 100 ++++++++++-------- test/src/app_smoke_test.dart | 45 ++++++++ test/src/source_audit_test.dart | 31 +++++- 3 files changed, 129 insertions(+), 47 deletions(-) diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index 537d5e0..7d04d96 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -659,26 +659,35 @@ class _ThemeModeControl extends StatelessWidget { @override Widget build(BuildContext context) { - return SegmentedButton( - showSelectedIcon: false, - segments: [ - ButtonSegment( + return BusyMarkPopupSelector( + value: selected, + label: _label(context, selected), + tooltip: context.l10n.theme, + options: [ + BusyMarkPopupSelectorOption( value: BusyMarkThemeModePreference.system, - label: _SegmentLabel(context.l10n.systemTheme), + label: context.l10n.systemTheme, ), - ButtonSegment( + BusyMarkPopupSelectorOption( value: BusyMarkThemeModePreference.light, - label: _SegmentLabel(context.l10n.lightTheme), + label: context.l10n.lightTheme, ), - ButtonSegment( + BusyMarkPopupSelectorOption( value: BusyMarkThemeModePreference.dark, - label: _SegmentLabel(context.l10n.darkTheme), + label: context.l10n.darkTheme, ), ], - selected: {selected}, - onSelectionChanged: (value) => onChanged(value.first), + onSelected: onChanged, ); } + + String _label(BuildContext context, BusyMarkThemeModePreference preference) { + return switch (preference) { + BusyMarkThemeModePreference.system => context.l10n.systemTheme, + BusyMarkThemeModePreference.light => context.l10n.lightTheme, + BusyMarkThemeModePreference.dark => context.l10n.darkTheme, + }; + } } class _EditorFontSizeRow extends StatelessWidget { @@ -849,30 +858,40 @@ class _EditorToolbarPlacementControl extends StatelessWidget { @override Widget build(BuildContext context) { - return SegmentedButton( - showSelectedIcon: false, - segments: [ - ButtonSegment( + return BusyMarkPopupSelector( + value: selected, + label: _label(context, selected), + tooltip: context.l10n.editingButtonsPosition, + options: [ + BusyMarkPopupSelectorOption( value: EditorToolbarPlacement.topLeft, - label: _SegmentLabel(context.l10n.topLeft), + label: context.l10n.topLeft, ), - ButtonSegment( + BusyMarkPopupSelectorOption( value: EditorToolbarPlacement.topRight, - label: _SegmentLabel(context.l10n.topRight), + label: context.l10n.topRight, ), - ButtonSegment( + BusyMarkPopupSelectorOption( value: EditorToolbarPlacement.bottomLeft, - label: _SegmentLabel(context.l10n.bottomLeft), + label: context.l10n.bottomLeft, ), - ButtonSegment( + BusyMarkPopupSelectorOption( value: EditorToolbarPlacement.bottomRight, - label: _SegmentLabel(context.l10n.bottomRight), + label: context.l10n.bottomRight, ), ], - selected: {selected}, - onSelectionChanged: (value) => onChanged(value.first), + onSelected: onChanged, ); } + + String _label(BuildContext context, EditorToolbarPlacement placement) { + return switch (placement) { + EditorToolbarPlacement.topLeft => context.l10n.topLeft, + EditorToolbarPlacement.topRight => context.l10n.topRight, + EditorToolbarPlacement.bottomLeft => context.l10n.bottomLeft, + EditorToolbarPlacement.bottomRight => context.l10n.bottomRight, + }; + } } class _EditorToolbarDirectionControl extends StatelessWidget { @@ -886,31 +905,28 @@ class _EditorToolbarDirectionControl extends StatelessWidget { @override Widget build(BuildContext context) { - return SegmentedButton( - showSelectedIcon: false, - segments: [ - ButtonSegment( + return BusyMarkPopupSelector( + value: selected, + label: _label(context, selected), + tooltip: context.l10n.editingButtonsDirection, + options: [ + BusyMarkPopupSelectorOption( value: EditorToolbarDirection.horizontal, - label: _SegmentLabel(context.l10n.horizontal), + label: context.l10n.horizontal, ), - ButtonSegment( + BusyMarkPopupSelectorOption( value: EditorToolbarDirection.vertical, - label: _SegmentLabel(context.l10n.vertical), + label: context.l10n.vertical, ), ], - selected: {selected}, - onSelectionChanged: (value) => onChanged(value.first), + onSelected: onChanged, ); } -} - -class _SegmentLabel extends StatelessWidget { - const _SegmentLabel(this.text); - - final String text; - @override - Widget build(BuildContext context) { - return Text(text, maxLines: 1, overflow: TextOverflow.ellipsis); + String _label(BuildContext context, EditorToolbarDirection direction) { + return switch (direction) { + EditorToolbarDirection.horizontal => context.l10n.horizontal, + EditorToolbarDirection.vertical => context.l10n.vertical, + }; } } diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index b992919..61ee6de 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -348,11 +348,56 @@ void main() { await tester.tap(find.text(l10n.systemLanguage).last); await tester.pumpAndSettle(); + expect( + find.byType(BusyMarkPopupSelector), + findsOneWidget, + ); + expect( + find.byType(SegmentedButton), + findsNothing, + ); + await tester.tap(find.byTooltip(l10n.theme)); + await tester.pumpAndSettle(); + + expect(find.text(l10n.systemTheme), findsWidgets); + expect(find.text(l10n.lightTheme), findsOneWidget); + expect(find.text(l10n.darkTheme), findsOneWidget); + await tester.tap(find.text(l10n.darkTheme)); + await tester.pumpAndSettle(); + + expect(settingsStore.value['themeModePreference'], 'dark'); + await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); await tester.pumpAndSettle(); await tester.tap(find.text(l10n.editor)); await tester.pumpAndSettle(); + expect( + find.byWidgetPredicate((widget) => widget is SegmentedButton), + findsNothing, + ); + expect( + find.byType(BusyMarkPopupSelector), + findsOneWidget, + ); + expect( + find.byType(BusyMarkPopupSelector), + findsOneWidget, + ); + await tester.tap(find.byTooltip(l10n.editingButtonsPosition)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.bottomRight)); + await tester.pumpAndSettle(); + + expect(settingsStore.value['editorToolbarPlacement'], 'bottomRight'); + + await tester.tap(find.byTooltip(l10n.editingButtonsDirection)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.vertical)); + await tester.pumpAndSettle(); + + expect(settingsStore.value['editorToolbarDirection'], 'vertical'); + expect(find.text(l10n.autoSave), findsOneWidget); expect(find.text(l10n.autoSaveDescription), findsOneWidget); await tester.tap(find.text(l10n.autoSave)); diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 5f014da..5311be7 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -752,12 +752,20 @@ void main() { expect(theme, contains('return colors.control;')); expect(theme, contains('return colors.foreground;')); expect(theme, isNot(contains('return selectedContainer;'))); - expect(settings, contains('class _SegmentLabel')); - expect(settings, contains('maxLines: 1')); - expect(settings, contains('overflow: TextOverflow.ellipsis')); + expect(settings, isNot(contains('SegmentedButton<'))); + expect(settings, isNot(contains('ButtonSegment('))); + expect(settings, isNot(contains('class _SegmentLabel'))); expect( settings, - contains('label: _SegmentLabel(context.l10n.bottomRight)'), + contains('BusyMarkPopupSelector('), + ); + expect( + settings, + contains('BusyMarkPopupSelector('), + ); + expect( + settings, + contains('BusyMarkPopupSelector('), ); expect(workspace, contains('BusyMarkHeaderPopupMenuButton<_SidebarTab>')); expect(workspace, isNot(contains('class _SidebarSegmentLabel'))); @@ -1059,7 +1067,7 @@ void main() { expect(gitSidebar, isNot(contains('FilledButton('))); }); - test('settings language selector delegates to the shared native menu', () { + test('settings selectors delegate to the shared native menu', () { final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); final settings = File( 'lib/src/workspace/presentation/settings_screen.dart', @@ -1070,6 +1078,19 @@ void main() { expect(settings, isNot(contains('DropdownButton'))); expect(settings, contains('BusyMarkPopupSelector(')); + expect( + settings, + contains('BusyMarkPopupSelector('), + ); + expect( + settings, + contains('BusyMarkPopupSelector('), + ); + expect( + settings, + contains('BusyMarkPopupSelector('), + ); + expect(settings, isNot(contains('SegmentedButton<'))); expect(settings, isNot(contains('class _LanguageSelectorButton'))); expect(workspace, isNot(contains('DropdownButtonFormField'))); expect( From f978c6d6416c140d74f05442f7566c69a24273b0 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 18:05:37 -0700 Subject: [PATCH 16/29] Remove unused native menu state CSS generation to streamline code and improve readability. Enhance theme and dialog components with new input decoration and modal editor support. Introduce BusyMarkEditorHeader and BusyMarkModalEditorScaffold for improved user interaction. Update switch, checkbox, and radio themes for better accessibility and visual consistency. --- lib/src/app/app_theme.dart | 57 ++- lib/src/app/busymark_design.dart | 434 ++++++++++++++++++ lib/src/app/busymark_dialogs.dart | 53 ++- .../presentation/feedback_dialog.dart | 272 ++++++----- .../presentation/workspace_screen.dart | 1 + linux/runner/my_application.cc | 19 +- test/src/app_smoke_test.dart | 6 + test/src/busymark_design_test.dart | 22 + test/src/feedback_dialog_test.dart | 142 ++++-- test/src/native_headerbar_audit_test.dart | 6 +- test/src/source_audit_test.dart | 37 +- 11 files changed, 851 insertions(+), 198 deletions(-) diff --git a/lib/src/app/app_theme.dart b/lib/src/app/app_theme.dart index 404b64e..0dabfee 100644 --- a/lib/src/app/app_theme.dart +++ b/lib/src/app/app_theme.dart @@ -17,7 +17,11 @@ ThemeData buildBusyMarkTheme({ brightness, colors, ); - final onAccent = _accessibleForeground(accentColor); + // Match Yaru/GTK suggested-action buttons. The native toolkit classifies + // mid-tone Ubuntu accents such as magenta as dark and uses white content; + // choosing whichever of black or white has a fractionally higher WCAG ratio + // makes those buttons look unlike their native counterparts. + final onAccent = contrastColor(accentColor); final colorScheme = base.colorScheme.copyWith( brightness: brightness, primary: accentColor, @@ -174,6 +178,57 @@ ThemeData buildBusyMarkTheme({ elevatedButtonTheme: ElevatedButtonThemeData(style: elevatedButtonStyle), textButtonTheme: TextButtonThemeData(style: textButtonStyle), segmentedButtonTheme: SegmentedButtonThemeData(style: segmentedButtonStyle), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colors.disabledForeground; + } + if (states.contains(WidgetState.selected)) { + return onAccent; + } + return colors.view; + }), + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colors.disabledControl; + } + if (states.contains(WidgetState.selected)) { + return accentColor; + } + return colors.controlHover; + }), + trackOutlineColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return accentColor; + } + return colors.border; + }), + ), + checkboxTheme: base.checkboxTheme.copyWith( + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colors.disabledControl; + } + if (states.contains(WidgetState.selected)) { + return accentColor; + } + return colors.control; + }), + // The mark is content on an accent surface, not ordinary foreground. + checkColor: WidgetStatePropertyAll(onAccent), + side: BorderSide(color: colors.border), + ), + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colors.disabledForeground; + } + if (states.contains(WidgetState.selected)) { + return accentColor; + } + return colors.mutedForeground; + }), + ), popupMenuTheme: base.popupMenuTheme.copyWith( color: colors.popover, surfaceTintColor: colors.popover, diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index f0586a8..27c33c2 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -12,6 +13,7 @@ import 'busymark_glyphs.dart'; abstract final class BusyMarkSpacing { static const double xxs = 2; static const double xs = 4; + static const double headerInset = 6; static const double sm = 8; static const double smPlus = 10; static const double md = 12; @@ -109,6 +111,10 @@ abstract final class BusyMarkSizes { static const int tableMaxRows = 50; } +abstract final class BusyMarkFormLayout { + static const double comboInlineMaxFraction = 0.46; +} + abstract final class BusyMarkElevation { static const double none = 0; static const double surface = 2; @@ -1827,6 +1833,60 @@ class BusyMarkPopupSelector extends StatelessWidget { } } +/// Input decoration inherited by controls hosted in a grouped-list row. +/// +/// The grouped list owns the surface, outline, padding, and separators. Yaru +/// and Flutter continue to own editing behavior without painting a second +/// Material input surface inside the native desktop row. +InputDecorationThemeData busyMarkGroupedInputDecorationTheme( + BuildContext context, +) { + final theme = Theme.of(context); + final labelColor = theme.colorScheme.onSurfaceVariant; + final labelStyle = theme.textTheme.bodyMedium?.copyWith(color: labelColor); + + return theme.inputDecorationTheme.copyWith( + filled: false, + fillColor: Colors.transparent, + hoverColor: Colors.transparent, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + labelStyle: labelStyle, + floatingLabelStyle: labelStyle, + floatingLabelBehavior: FloatingLabelBehavior.auto, + ); +} + +InputDecoration busyMarkGroupedTextFieldDecoration( + BuildContext context, { + required String labelText, + String? errorText, + bool alignLabelWithHint = false, +}) { + final decoration = InputDecoration( + labelText: labelText, + errorText: errorText, + alignLabelWithHint: alignLabelWithHint, + ); + final defaults = busyMarkGroupedInputDecorationTheme(context); + final resolved = decoration.applyDefaults(defaults); + if (errorText == null) { + return resolved; + } + final errorLabelStyle = Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.error); + return resolved.copyWith( + labelStyle: errorLabelStyle, + floatingLabelStyle: errorLabelStyle, + ); +} + class BusyMarkClamp extends StatelessWidget { const BusyMarkClamp({ super.key, @@ -2188,6 +2248,206 @@ class _BusyMarkGroupedListSurface extends StatelessWidget { } } +/// A single-selection row following the native AdwComboRow interaction model. +/// +/// The complete row owns hover, focus, and activation. Menu presentation is +/// delegated to BusyMark's GTK menu bridge on Linux. +class BusyMarkComboRow extends StatelessWidget { + BusyMarkComboRow({ + super.key, + required this.title, + required List values, + required this.selected, + required this.labelFor, + required this.onSelected, + this.subtitle, + this.errorText, + this.leading, + this.enabled = true, + this.tooltip, + this.width = BusyMarkSizes.controlRowWidth, + }) : values = List.unmodifiable(values) { + if (this.values.isEmpty) { + throw ArgumentError.value( + values, + 'values', + 'A combo row requires at least one value.', + ); + } + if (this.values.toSet().length != this.values.length) { + throw ArgumentError.value( + values, + 'values', + 'A combo row requires unique values.', + ); + } + if (!this.values.contains(selected)) { + throw ArgumentError.value( + selected, + 'selected', + 'The selected value must be present in values.', + ); + } + if (!width.isFinite || width <= 0) { + throw ArgumentError.value( + width, + 'width', + 'The maximum value width must be finite and positive.', + ); + } + } + + final String title; + final List values; + final T selected; + final String Function(T value) labelFor; + final ValueChanged onSelected; + final String? subtitle; + final String? errorText; + final Widget? leading; + final bool enabled; + final String? tooltip; + final double width; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final hasError = errorText?.isNotEmpty ?? false; + final subtitleWidget = hasError + ? Text( + errorText!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ) + : subtitle == null + ? null + : Text(subtitle!); + final styledSubtitle = subtitleWidget == null + ? null + : _busyMarkGroupedRowSubtitle( + context, + subtitleWidget, + enabled: enabled, + ); + final availableWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : width + BusyMarkSpacing.md * 2; + final maximumValueWidth = + (availableWidth * BusyMarkFormLayout.comboInlineMaxFraction) + .clamp(0.0, width) + .toDouble(); + + return BusyMarkMenuButton( + tooltip: tooltip ?? title, + enabled: enabled, + onSelected: (index) { + final value = values[index]; + if (value != selected) { + onSelected(value); + } + }, + items: [ + for (var index = 0; index < values.length; index++) + BusyMarkPopupMenuItem( + value: index, + label: labelFor(values[index]), + checked: values[index] == selected, + trailingCheck: true, + ), + ], + triggerBuilder: (context, trigger) { + final colors = BusyMarkSurfaceColors.of(context); + final valueForeground = enabled + ? colors.foreground + : colors.disabledForeground; + final value = ExcludeSemantics( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maximumValueWidth), + child: DefaultTextStyle.merge( + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: valueForeground), + child: IconTheme.merge( + data: IconThemeData( + color: valueForeground, + size: BusyMarkSizes.iconSm, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + labelFor(selected), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: BusyMarkSpacing.sm), + trigger.anchor( + child: const Icon(BusyMarkGlyphs.downArrow), + ), + ], + ), + ), + ), + ), + ); + final row = YaruListTile.square( + leading: leading == null + ? null + : ExcludeSemantics(child: leading!), + title: ExcludeSemantics(child: Text(title)), + subtitle: styledSubtitle == null + ? null + : ExcludeSemantics(child: styledSubtitle), + trailing: value, + onTap: trigger.onPressed, + focusNode: trigger.focusNode, + hoverColor: busyMarkRowHoverColor(context), + enabled: enabled, + ); + final semanticRow = Semantics( + container: true, + button: true, + enabled: enabled, + expanded: trigger.isOpen, + onTap: enabled ? trigger.onPressed : null, + label: subtitle == null || subtitle!.isEmpty + ? title + : '$title, $subtitle', + value: labelFor(selected), + hint: hasError ? errorText : null, + liveRegion: hasError, + validationResult: hasError + ? ui.SemanticsValidationResult.invalid + : ui.SemanticsValidationResult.valid, + child: ExcludeSemantics(child: row), + ); + final statefulRow = ColoredBox( + color: trigger.isOpen + ? busyMarkRowHoverColor(context) + : Colors.transparent, + child: semanticRow, + ); + final boundedRow = constraints.hasBoundedWidth + ? statefulRow + : SizedBox(width: availableWidth, child: statefulRow); + return tooltip == null + ? boundedRow + : Tooltip( + message: tooltip!, + excludeFromSemantics: true, + child: boundedRow, + ); + }, + ); + }, + ); + } +} + typedef BusyMarkRowActivationCallback = void Function(BuildContext context, Offset? globalPosition); @@ -2455,6 +2715,180 @@ Color busyMarkDialogSurfaceColor(BuildContext context) { BusyMarkSurfaceColors.of(context).dialog; } +class BusyMarkEditorHeader extends StatelessWidget { + const BusyMarkEditorHeader({ + super.key, + required this.title, + required this.cancelLabel, + required this.saveLabel, + required this.onCancel, + required this.onSave, + this.saving = false, + this.cancelEnabled = true, + this.cancelKey, + this.saveKey, + }); + + final String title; + final String cancelLabel; + final String saveLabel; + final VoidCallback onCancel; + final VoidCallback? onSave; + final bool saving; + final bool cancelEnabled; + final Key? cancelKey; + final Key? saveKey; + + @override + Widget build(BuildContext context) { + final actionStyle = ButtonStyle( + textStyle: WidgetStatePropertyAll(Theme.of(context).textTheme.titleSmall), + ); + return Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.headerInset), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Align( + alignment: AlignmentDirectional.centerStart, + heightFactor: 1, + child: BusyMarkPushButton.standard( + key: cancelKey, + onPressed: cancelEnabled ? onCancel : null, + style: actionStyle, + child: Text(cancelLabel, overflow: TextOverflow.ellipsis), + ), + ), + ), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Expanded( + child: Align( + alignment: AlignmentDirectional.centerEnd, + heightFactor: 1, + child: BusyMarkPushButton.suggested( + key: saveKey, + onPressed: onSave, + style: actionStyle, + child: saving + ? const ExcludeSemantics( + child: SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : Text(saveLabel, overflow: TextOverflow.ellipsis), + ), + ), + ), + ], + ), + ); + } +} + +class BusyMarkEditorScrollBody extends StatelessWidget { + const BusyMarkEditorScrollBody({ + super.key, + required this.child, + this.maxWidth = 640, + }); + + final Widget child; + final double maxWidth; + + @override + Widget build(BuildContext context) { + return YaruScrollViewUndershoot.builder( + endUndershoot: false, + builder: (context, controller) => BusyMarkClamp( + maxWidth: maxWidth, + margin: EdgeInsets.zero, + padding: const EdgeInsets.fromLTRB( + BusyMarkSpacing.lg, + BusyMarkSpacing.headerInset, + BusyMarkSpacing.lg, + 0, + ), + controller: controller, + child: child, + ), + ); + } +} + +class BusyMarkModalEditorScaffold extends StatelessWidget { + const BusyMarkModalEditorScaffold({ + super.key, + required this.title, + required this.cancelLabel, + required this.saveLabel, + required this.onCancel, + required this.onSave, + required this.children, + this.saving = false, + this.cancelEnabled = true, + this.contentMaxWidth = 640, + this.cancelKey, + this.saveKey, + }); + + final String title; + final String cancelLabel; + final String saveLabel; + final VoidCallback onCancel; + final VoidCallback? onSave; + final bool saving; + final bool cancelEnabled; + final double contentMaxWidth; + final List children; + final Key? cancelKey; + final Key? saveKey; + + @override + Widget build(BuildContext context) { + return Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: title, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + BusyMarkEditorHeader( + title: title, + cancelLabel: cancelLabel, + saveLabel: saveLabel, + onCancel: onCancel, + onSave: onSave, + saving: saving, + cancelEnabled: cancelEnabled, + cancelKey: cancelKey, + saveKey: saveKey, + ), + Flexible( + child: BusyMarkEditorScrollBody( + maxWidth: contentMaxWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), + ), + ], + ), + ); + } +} + class BusyMarkDialogTitleBar extends StatelessWidget { const BusyMarkDialogTitleBar({ super.key, diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index b0dafe0..30581ab 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -119,6 +119,26 @@ Future _showBusyMarkFlutterDialog( ); } +Future showBusyMarkModalEditorDialog( + BuildContext context, { + required WidgetBuilder builder, + LinuxHeaderBarService? headerBarService, + double maxWidth = 700, + double? maxHeight = 760, +}) { + return showBusyMarkModalDialog( + context, + headerBarService: headerBarService, + barrierDismissible: false, + builder: (dialogContext) => BusyMarkModalEditorSurface( + maxWidth: maxWidth, + maxHeight: maxHeight, + insetPadding: const EdgeInsets.all(BusyMarkSpacing.lg), + child: builder(dialogContext), + ), + ); +} + /// Acquires a reference-counted native header-bar modal barrier. /// /// Every call must be paired with [releaseBusyMarkModalBarrier]. Route @@ -235,22 +255,25 @@ class BusyMarkModalEditorSurface extends StatelessWidget { ? double.infinity : maxHeight!.clamp(0.0, double.infinity).toDouble(); - return Dialog( - backgroundColor: editorSurface, - surfaceTintColor: editorSurface, - insetPadding: insetPadding, - insetAnimationDuration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : BusyMarkMotion.dialogInsets, - insetAnimationCurve: BusyMarkMotion.dialogInsetsCurve, - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: effectiveMinWidth, - maxWidth: effectiveMaxWidth, - maxHeight: effectiveMaxHeight, + return BusyMarkSurfaceScope( + role: BusyMarkSurfaceRole.window, + child: Dialog( + backgroundColor: editorSurface, + surfaceTintColor: editorSurface, + insetPadding: insetPadding, + insetAnimationDuration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : BusyMarkMotion.dialogInsets, + insetAnimationCurve: BusyMarkMotion.dialogInsetsCurve, + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: effectiveMinWidth, + maxWidth: effectiveMaxWidth, + maxHeight: effectiveMaxHeight, + ), + child: child, ), - child: child, ), ); } diff --git a/lib/src/feedback/presentation/feedback_dialog.dart b/lib/src/feedback/presentation/feedback_dialog.dart index 8076a7d..05a6eaa 100644 --- a/lib/src/feedback/presentation/feedback_dialog.dart +++ b/lib/src/feedback/presentation/feedback_dialog.dart @@ -2,12 +2,13 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; +import 'package:yaru/yaru.dart'; import '../../app/busymark_design.dart'; import '../../app/busymark_dialogs.dart'; -import '../../app/busymark_glyphs.dart'; import '../../app/localization.dart'; import '../../platform/linux_header_bar_service.dart'; import '../feedback_metadata.dart'; @@ -38,17 +39,22 @@ void showBusyMarkFeedbackDialog( LinuxHeaderBarService? headerBarService, }) { unawaited( - showBusyMarkModalDialog( + showBusyMarkModalEditorDialog( context, headerBarService: headerBarService, - builder: (context) => const BusyMarkFeedbackDialog(), - barrierDismissible: false, + maxWidth: 680, + maxHeight: 760, + builder: (dialogContext) => BusyMarkFeedbackDialog( + onCancel: () => Navigator.of(dialogContext).pop(), + ), ), ); } class BusyMarkFeedbackDialog extends ConsumerStatefulWidget { - const BusyMarkFeedbackDialog({super.key}); + const BusyMarkFeedbackDialog({super.key, this.onCancel}); + + final VoidCallback? onCancel; @override ConsumerState createState() => @@ -110,130 +116,148 @@ class _BusyMarkFeedbackDialogState final replyEmailError = _showValidationErrors ? _validationMessage(context, validation[FeedbackField.replyEmail]) : null; - return BusyMarkDialogShell( - title: context.l10n.reportIssue, - closable: !_submitting, - maxWidth: BusyMarkSizes.dialogWide, - actions: [ - BusyMarkDialogButton( - key: BusyMarkFeedbackKeys.cancel, - label: context.l10n.cancel, - onPressed: _submitting ? null : () => Navigator.pop(context), - ), - BusyMarkDialogButton( - key: BusyMarkFeedbackKeys.submit, - label: _submitting - ? context.l10n.feedbackSubmitting - : context.l10n.feedbackSubmit, - onPressed: _submitting ? null : _submit, - suggested: true, - ), - ], - children: [ - BusyMarkGroupedList( - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.feedbackCategory, - subtitle: categoryError, - leading: const Icon(BusyMarkGlyphs.category), - destructive: categoryError != null, - trailing: SizedBox( - width: BusyMarkSizes.controlRowWidth, - child: BusyMarkPopupSelector( - key: BusyMarkFeedbackKeys.category, - value: _category, - label: _category == null - ? context.l10n.feedbackChooseCategory - : _categoryLabel(context, _category!), - tooltip: context.l10n.feedbackCategory, - enabled: !_submitting, - options: [ - for (final category in FeedbackCategory.values) - BusyMarkPopupSelectorOption( - value: category, - label: _categoryLabel(context, category), + return PopScope( + canPop: !_submitting, + child: CallbackShortcuts( + bindings: {const SingleActivator(LogicalKeyboardKey.escape): _cancel}, + child: Focus( + autofocus: true, + child: BusyMarkModalEditorScaffold( + title: context.l10n.reportIssue, + cancelLabel: context.l10n.cancel, + saveLabel: context.l10n.feedbackSubmit, + cancelKey: BusyMarkFeedbackKeys.cancel, + saveKey: BusyMarkFeedbackKeys.submit, + onCancel: _cancel, + cancelEnabled: !_submitting, + onSave: _submitting ? null : _submit, + saving: _submitting, + children: [ + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkComboRow( + key: BusyMarkFeedbackKeys.category, + title: context.l10n.feedbackCategory, + errorText: categoryError, + values: const [null, ...FeedbackCategory.values], + selected: _category, + labelFor: (category) => category == null + ? context.l10n.feedbackChooseCategory + : _categoryLabel(context, category), + enabled: !_submitting, + onSelected: _setCategory, + ), + YaruListTile.square( + title: TextField( + key: BusyMarkFeedbackKeys.subject, + controller: _subjectController, + enabled: !_submitting, + textInputAction: TextInputAction.next, + decoration: busyMarkGroupedTextFieldDecoration( + context, + labelText: context.l10n.feedbackSubject, + errorText: subjectError, ), - ], - onSelected: _setCategory, - ), + ), + ), + YaruListTile.square( + title: TextField( + key: BusyMarkFeedbackKeys.message, + controller: _messageController, + enabled: !_submitting, + minLines: 4, + maxLines: 8, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + decoration: busyMarkGroupedTextFieldDecoration( + context, + labelText: context.l10n.feedbackMessage, + alignLabelWithHint: true, + errorText: messageError, + ), + ), + ), + YaruListTile.square( + title: TextField( + key: BusyMarkFeedbackKeys.replyEmail, + controller: _replyEmailController, + enabled: !_submitting, + textDirection: TextDirection.ltr, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + decoration: busyMarkGroupedTextFieldDecoration( + context, + labelText: context.l10n.feedbackReplyEmail, + errorText: replyEmailError, + ), + onSubmitted: (_) { + if (!_submitting) { + _submit(); + } + }, + ), + ), + ], ), - ), - ], - ), - const SizedBox(height: BusyMarkSpacing.md), - BusyMarkFloatingTextEntryGroup( - children: [ - BusyMarkFloatingTextEntry( - key: BusyMarkFeedbackKeys.subject, - label: context.l10n.feedbackSubject, - controller: _subjectController, - enabled: !_submitting, - autofocus: true, - textInputAction: TextInputAction.next, - errorText: subjectError, - ), - BusyMarkFloatingTextEntry( - key: BusyMarkFeedbackKeys.message, - label: context.l10n.feedbackMessage, - controller: _messageController, - enabled: !_submitting, - minLines: 5, - maxLines: 8, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - errorText: messageError, - ), - BusyMarkFloatingTextEntry( - key: BusyMarkFeedbackKeys.replyEmail, - label: context.l10n.feedbackReplyEmail, - controller: _replyEmailController, - enabled: !_submitting, - textDirection: TextDirection.ltr, - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.done, - onSubmitted: (_) { - if (!_submitting) { - _submit(); - } - }, - errorText: replyEmailError, - ), - ], - ), - BusyMarkGroupedList( - filled: true, - children: [ - BusyMarkSwitchRow( - key: BusyMarkFeedbackKeys.technicalDetails, - title: context.l10n.feedbackIncludeTechnicalDetails, - subtitle: context.l10n.feedbackTechnicalDetailsDisclosure, - leading: const Icon(BusyMarkGlyphs.diagnostics), - value: _includeTechnicalDetails, - enabled: !_submitting, - onChanged: _setIncludeTechnicalDetails, - ), - ], - ), - if (_receiptId != null || _failure != null) ...[ - const SizedBox(height: BusyMarkSpacing.lg), - Semantics( - key: BusyMarkFeedbackKeys.status, - liveRegion: true, - child: BusyMarkStatusBox( - message: _receiptId != null - ? context.l10n.feedbackSuccess(_receiptId!) - : _failureMessage(context, _failure!), - kind: _receiptId != null - ? BusyMarkStatusKind.success - : BusyMarkStatusKind.error, - ), + BusyMarkGroupedList( + filled: true, + children: [ + YaruCheckboxListTile( + key: BusyMarkFeedbackKeys.technicalDetails, + value: _includeTechnicalDetails, + onChanged: _submitting + ? null + : (value) => + _setIncludeTechnicalDetails(value ?? false), + title: Text(context.l10n.feedbackIncludeTechnicalDetails), + subtitle: Text( + context.l10n.feedbackTechnicalDetailsDisclosure, + ), + shape: const RoundedRectangleBorder(), + ), + ], + ), + if (_receiptId != null || _failure != null) ...[ + const SizedBox(height: BusyMarkSpacing.md), + Semantics( + key: BusyMarkFeedbackKeys.status, + liveRegion: true, + child: Text( + _receiptId != null + ? context.l10n.feedbackSuccess(_receiptId!) + : _failureMessage(context, _failure!), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: _failure != null + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.primary, + ), + ), + ), + ], + const SizedBox(height: BusyMarkSpacing.lg), + ], ), - ], - ], + ), + ), ); } + void _cancel() { + if (_submitting) { + return; + } + final onCancel = widget.onCancel; + if (onCancel != null) { + onCancel(); + return; + } + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(); + } + } + void _handleFieldChanged() { if (_suppressFieldChanges || !mounted) { return; @@ -257,7 +281,7 @@ class _BusyMarkFeedbackDialogState }); } - void _setCategory(FeedbackCategory category) { + void _setCategory(FeedbackCategory? category) { setState(() { if (category != _category) { _rotateSubmissionIdAfterAttempt(); diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index b420913..daf56a4 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -2065,6 +2065,7 @@ class _SidebarHeader extends StatelessWidget { icon: BusyMarkGlyphs.menuVertical, transparent: true, borderRadius: BusyMarkRadius.nativeHeaderButton, + highlightWhenOpen: false, itemBuilder: (menuContext) => loadBranchMenuItems(menuContext, repository), onSelected: (action) => diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index c9449a1..15045e3 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -626,22 +626,6 @@ static void refresh_header_bar_css(MyApplication* self) { kNativePopoverStyleClass, kNativePopoverStyleClass, self->popover_background_color) : g_strdup(""); - g_autofree gchar* native_menu_state_css = - is_css_color_token(self->menu_hover_color) - ? g_strdup_printf( - "popover.background.%s " - "modelbutton:hover:not(:disabled) {" - "background-color: %s;" - "background-image: none;" - "}" - "popover.background.%s " - "row:hover:not(:disabled) {" - "background-color: %s;" - "background-image: none;" - "}", - kNativePopoverStyleClass, self->menu_hover_color, - kNativePopoverStyleClass, self->menu_hover_color) - : g_strdup(""); g_autofree gchar* header_menu_shadow_css = use_legacy_yaru_compatibility ? g_strdup_printf( @@ -723,7 +707,6 @@ static void refresh_header_bar_css(MyApplication* self) { "%s" "%s" "%s" - "%s" ".busymark-titlebar," ".busymark-titlebar:backdrop {" "background-color: %s;" @@ -829,7 +812,7 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: %s;" "background-image: none;" "}", - background, window_shadow_css, native_popover_css, native_menu_state_css, + background, window_shadow_css, native_popover_css, header_menu_shadow_css, background, foreground, background, foreground, sidebar_background, foreground, foreground, foreground, kHeaderBackdropForegroundOpacity, sidebar_border, sidebar_border, diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 61ee6de..c218337 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -2133,9 +2133,15 @@ void main() { ), findsOneWidget, ); + final branchMenuButton = find.descendant( + of: branchMenu, + matching: find.byType(IconButton), + ); + expect(tester.widget(branchMenuButton).isSelected, isFalse); expect(gitController.branchLoadCount, 0); await tester.tap(branchMenu); await tester.pumpAndSettle(); + expect(tester.widget(branchMenuButton).isSelected, isFalse); expect(gitController.branchLoadCount, 1); expect(find.text(l10n.gitPull), findsOneWidget); expect(find.text(l10n.gitPush), findsOneWidget); diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index ea48574..b9d53dc 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -763,6 +763,28 @@ void main() { } }); + test('accent controls use the native Yaru foreground and fill roles', () { + final theme = buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: BusyMarkLinuxPalette.ubuntuMagentaAccent, + ); + const selected = {WidgetState.selected}; + + expect(theme.colorScheme.onPrimary, BusyMarkLinuxPalette.white); + expect( + theme.elevatedButtonTheme.style?.foregroundColor?.resolve({}), + BusyMarkLinuxPalette.white, + ); + expect( + theme.checkboxTheme.checkColor?.resolve(selected), + BusyMarkLinuxPalette.white, + ); + expect( + theme.radioTheme.fillColor?.resolve(selected), + BusyMarkLinuxPalette.ubuntuMagentaAccent, + ); + }); + test('semantic surfaces use one modern neutral Linux role ladder', () { const blue = Color(0xFF3584E4); const orange = Color(0xFFE95420); diff --git a/test/src/feedback_dialog_test.dart b/test/src/feedback_dialog_test.dart index 83abe85..549a26a 100644 --- a/test/src/feedback_dialog_test.dart +++ b/test/src/feedback_dialog_test.dart @@ -4,6 +4,7 @@ import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/l10n/generated/app_localizations_en.dart'; import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/busymark_dialogs.dart'; import 'package:busymark/src/feedback/feedback_metadata.dart'; import 'package:busymark/src/feedback/feedback_service.dart'; import 'package:busymark/src/feedback/feedback_submission.dart'; @@ -17,7 +18,7 @@ import 'package:yaru/yaru.dart'; void main() { final l10n = AppLocalizationsEn(); - testWidgets('uses shared BusyMark desktop form controls', (tester) async { + testWidgets('uses the native grouped modal-editor structure', (tester) async { final service = _FakeFeedbackService( handler: (_) async => throw const FeedbackSubmissionException(FeedbackFailureKind.rejected), @@ -28,29 +29,55 @@ void main() { Finder dialogDescendant(Finder matching) => find.descendant(of: dialog, matching: matching, matchRoot: true); - expect(dialogDescendant(find.byType(BusyMarkDialogShell)), findsOneWidget); - expect(dialogDescendant(find.byType(YaruDialogTitleBar)), findsOneWidget); expect( - dialogDescendant(find.byType(BusyMarkDialogButton)), - findsNWidgets(2), - ); - expect( - dialogDescendant( - find.byWidgetPredicate( - (widget) => widget is BusyMarkPopupSelector, - ), - ), + dialogDescendant(find.byType(BusyMarkModalEditorScaffold)), findsOneWidget, ); + expect(dialogDescendant(find.byType(BusyMarkEditorHeader)), findsOneWidget); expect( - dialogDescendant(find.byType(BusyMarkFloatingTextEntry)), - findsNWidgets(3), + dialogDescendant(find.byType(BusyMarkComboRow)), + findsOneWidget, ); expect( dialogDescendant(find.byType(BusyMarkGroupedList)), findsNWidgets(2), ); - expect(dialogDescendant(find.byType(BusyMarkSwitchRow)), findsOneWidget); + expect(dialogDescendant(find.byType(YaruCheckboxListTile)), findsOneWidget); + expect(dialogDescendant(find.byType(TextField)), findsNWidgets(3)); + expect(dialogDescendant(find.byType(YaruListTile)), findsNWidgets(5)); + + for (final key in const [ + BusyMarkFeedbackKeys.subject, + BusyMarkFeedbackKeys.message, + BusyMarkFeedbackKeys.replyEmail, + ]) { + final field = tester.widget(find.byKey(key)); + final decoration = field.decoration!; + + expect(decoration.filled, isFalse); + expect(decoration.fillColor, Colors.transparent); + expect(decoration.hoverColor, Colors.transparent); + expect(decoration.border, InputBorder.none); + expect(decoration.enabledBorder, InputBorder.none); + expect(decoration.focusedBorder, InputBorder.none); + expect(decoration.disabledBorder, InputBorder.none); + expect(decoration.errorBorder, InputBorder.none); + expect(decoration.focusedErrorBorder, InputBorder.none); + expect(decoration.contentPadding, EdgeInsets.zero); + expect( + find.ancestor(of: find.byKey(key), matching: find.byType(YaruListTile)), + findsOneWidget, + ); + } + + expect(dialogDescendant(find.byType(BusyMarkDialogShell)), findsNothing); + expect(dialogDescendant(find.byType(YaruDialogTitleBar)), findsNothing); + expect(dialogDescendant(find.byType(BusyMarkDialogButton)), findsNothing); + expect( + dialogDescendant(find.byType(BusyMarkFloatingTextEntry)), + findsNothing, + ); + expect(dialogDescendant(find.byType(BusyMarkSwitchRow)), findsNothing); expect(dialogDescendant(find.byType(BusyMarkStatusBox)), findsNothing); expect(dialogDescendant(find.byType(AlertDialog)), findsNothing); expect( @@ -61,13 +88,14 @@ void main() { ), findsNothing, ); - expect(dialogDescendant(find.byType(TextFormField)), findsNWidgets(3)); + expect(dialogDescendant(find.byType(TextFormField)), findsNothing); await _enterValidRequiredFields(tester, l10n); await tester.tap(find.byKey(BusyMarkFeedbackKeys.submit)); await tester.pumpAndSettle(); - expect(dialogDescendant(find.byType(BusyMarkStatusBox)), findsOneWidget); + expect(find.byKey(BusyMarkFeedbackKeys.status), findsOneWidget); + expect(dialogDescendant(find.byType(BusyMarkStatusBox)), findsNothing); }); testWidgets('shared report form remains directional in an Arabic UI', ( @@ -98,6 +126,41 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('dark route keeps the modal and fields theme-owned', ( + tester, + ) async { + final service = _FakeFeedbackService(); + await _pumpDialogRoute(tester, service, brightness: Brightness.dark); + + final feedback = find.byType(BusyMarkFeedbackDialog); + final feedbackContext = tester.element(feedback); + final dialog = tester.widget( + find.descendant( + of: find.byType(BusyMarkModalEditorSurface), + matching: find.byType(Dialog), + ), + ); + expect( + dialog.backgroundColor, + Theme.of(feedbackContext).scaffoldBackgroundColor, + ); + expect(dialog.surfaceTintColor, dialog.backgroundColor); + + for (final key in const [ + BusyMarkFeedbackKeys.subject, + BusyMarkFeedbackKeys.message, + BusyMarkFeedbackKeys.replyEmail, + ]) { + final decoration = tester.widget(find.byKey(key)).decoration!; + expect(decoration.filled, isFalse); + expect(decoration.fillColor, Colors.transparent); + expect(decoration.border, InputBorder.none); + } + + expect(find.byType(BusyMarkDialogShell), findsNothing); + expect(find.byType(YaruDialogTitleBar), findsNothing); + }); + testWidgets('shows required-field validation without sending', ( tester, ) async { @@ -129,6 +192,27 @@ void main() { expect(service.submissions, isEmpty); }); + testWidgets('category can return to the unselected placeholder', ( + tester, + ) async { + final service = _FakeFeedbackService(); + await _pumpDialog(tester, service); + + await tester.tap(find.byKey(BusyMarkFeedbackKeys.category)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.feedbackCategoryProblem).last); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(BusyMarkFeedbackKeys.category)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.feedbackChooseCategory).last); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(BusyMarkFeedbackKeys.submit)); + await tester.pump(); + + expect(find.text(l10n.feedbackCategoryRequired), findsOneWidget); + expect(service.submissions, isEmpty); + }); + testWidgets('disables submit while a request is active', (tester) async { final completion = Completer(); final service = _FakeFeedbackService(handler: (_) => completion.future); @@ -140,8 +224,9 @@ void main() { expect(service.submissions, hasLength(1)); expect(service.submissions.single.technicalDetails, isNull); - expect(find.text(l10n.feedbackSubmitting), findsOneWidget); - final button = tester.widget( + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text(l10n.feedbackSubmit), findsNothing); + final button = tester.widget( find.byKey(BusyMarkFeedbackKeys.submit), ); expect(button.onPressed, isNull); @@ -283,9 +368,9 @@ void main() { expect(find.byType(BusyMarkFeedbackDialog), findsOneWidget); expect( tester - .widget(find.byType(YaruDialogTitleBar)) - .isClosable, - isFalse, + .widget(find.byKey(BusyMarkFeedbackKeys.cancel)) + .onPressed, + isNull, ); await tester.sendKeyEvent(LogicalKeyboardKey.escape); @@ -300,9 +385,9 @@ void main() { await tester.pumpAndSettle(); expect( tester - .widget(find.byType(YaruDialogTitleBar)) - .isClosable, - isTrue, + .widget(find.byKey(BusyMarkFeedbackKeys.cancel)) + .onPressed, + isNotNull, ); await tester.tap(find.byKey(BusyMarkFeedbackKeys.cancel)); await tester.pumpAndSettle(); @@ -349,8 +434,9 @@ Future _pumpDialog( Future _pumpDialogRoute( WidgetTester tester, - _FakeFeedbackService service, -) async { + _FakeFeedbackService service, { + Brightness brightness = Brightness.light, +}) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(1000, 900); addTearDown(tester.view.resetDevicePixelRatio); @@ -372,7 +458,7 @@ Future _pumpDialogRoute( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, theme: buildBusyMarkTheme( - brightness: Brightness.light, + brightness: brightness, accentColor: BusyMarkLinuxPalette.blueAccent, ), home: Builder( diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 3d471d8..5641c03 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -573,7 +573,7 @@ void main() { expect(css, contains('background-color: alpha(currentColor, 0.16)')); expect(css, contains('background-color: alpha(currentColor, 0.10)')); expect(css, contains('popover.background.')); - expect(css, contains('modelbutton:hover:not(:disabled)')); + expect(css, isNot(contains('modelbutton:hover'))); expect(css, contains('box-shadow: 0 1px 3px')); for (final interactionSelector in [ 'tooltip', @@ -1231,7 +1231,7 @@ void main() { } expect(native, contains('view_mode_icon_name(mode)')); expect(native, contains('view_mode_icon_name("split")')); - expect(native, contains('modelbutton:hover:not(:disabled)')); + expect(native, isNot(contains('modelbutton:hover'))); expect(native, isNot(contains('modelbutton:focus'))); expect(native, isNot(contains('modelbutton:active'))); expect(native, isNot(contains('outline-width: 0;'))); @@ -1283,6 +1283,8 @@ void main() { expect(native, contains('gtk_label_new(shortcut)')); expect(native, contains('decorate_model_menu_accelerators(')); expect(native, contains('g_menu_item_set_icon(item, icon)')); + expect(native, isNot(contains('native_menu_state_css'))); + expect(native, isNot(contains('row:hover:not(:disabled)'))); expect(native, contains('gtk_menu_button_set_menu_model')); expect(native, isNot(contains('busymark-shortcut-widget'))); expect(native, isNot(contains('busymark-menu-row'))); diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 5311be7..d3a72af 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -1102,7 +1102,8 @@ void main() { contains('BusyMarkPopupSelector'), ); final selector = RegExp( - r'class BusyMarkPopupSelector[\s\S]*?class BusyMarkClamp', + r'class BusyMarkPopupSelector[\s\S]*?' + r'InputDecorationThemeData busyMarkGroupedInputDecorationTheme', ).firstMatch(design)!.group(0)!; expect(selector, contains('BusyMarkMenuButton(')); expect(selector, contains('Theme.of(context).outlinedButtonTheme.style')); @@ -1122,7 +1123,7 @@ void main() { expect(selector, isNot(contains('shadowColor:'))); }); - test('report issue form uses shared BusyMark desktop controls', () { + test('report issue form uses the native grouped modal editor', () { final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); final settings = File( 'lib/src/workspace/presentation/settings_screen.dart', @@ -1132,20 +1133,36 @@ void main() { ).readAsStringSync(); expect(design, contains('class BusyMarkPopupSelector')); - expect(design, contains('class BusyMarkStatusBox')); + expect(design, contains('class BusyMarkComboRow')); + expect(design, contains('class BusyMarkModalEditorScaffold')); + expect(design, contains('busyMarkGroupedTextFieldDecoration(')); expect(settings, contains('BusyMarkPopupSelector(')); - expect(feedback, contains('BusyMarkPopupSelector(')); + expect(RegExp(r'YaruListTile\.square\(').allMatches(feedback).length, 3); + expect(feedback, contains('BusyMarkModalEditorScaffold(')); + expect(feedback, contains('BusyMarkComboRow(')); expect( - RegExp(r'BusyMarkFloatingTextEntry\(').allMatches(feedback).length, - 3, + feedback, + contains('values: const [null, ...FeedbackCategory.values]'), ); - expect(feedback, contains('BusyMarkGroupedList(')); - expect(feedback, contains('BusyMarkSwitchRow(')); - expect(feedback, contains('BusyMarkStatusBox(')); + expect(feedback, contains('busyMarkGroupedTextFieldDecoration(')); + expect(feedback, contains('YaruCheckboxListTile(')); + expect(feedback, contains('CallbackShortcuts(')); + expect(feedback, isNot(contains('BusyMarkDialogShell('))); + expect(feedback, isNot(contains('BusyMarkDialogButton('))); + expect( + feedback, + isNot(contains('BusyMarkPopupSelector')), + ); + expect(feedback, isNot(contains('BusyMarkFloatingTextEntry('))); + expect(feedback, isNot(contains('BusyMarkSwitchRow('))); + expect(feedback, isNot(contains('BusyMarkStatusBox('))); expect(feedback, isNot(contains('DropdownButtonFormField'))); - expect(feedback, isNot(contains('TextField('))); expect(feedback, isNot(contains('InputDecoration('))); expect(feedback, isNot(contains('InkWell('))); + expect( + design, + contains("delegated to BusyMark's GTK menu bridge on Linux"), + ); }); test( From a14df1278addccceba50bad2de06e0313b6c310d Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 19:16:07 -0700 Subject: [PATCH 17/29] Add dynamic CSS generation for native menu hover states to enhance UI responsiveness. Introduce hover color handling for model buttons and rows in popovers, improving visual feedback and consistency across the application. --- linux/runner/my_application.cc | 19 ++++++++++++++++++- test/src/native_headerbar_audit_test.dart | 6 ++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 15045e3..c9449a1 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -626,6 +626,22 @@ static void refresh_header_bar_css(MyApplication* self) { kNativePopoverStyleClass, kNativePopoverStyleClass, self->popover_background_color) : g_strdup(""); + g_autofree gchar* native_menu_state_css = + is_css_color_token(self->menu_hover_color) + ? g_strdup_printf( + "popover.background.%s " + "modelbutton:hover:not(:disabled) {" + "background-color: %s;" + "background-image: none;" + "}" + "popover.background.%s " + "row:hover:not(:disabled) {" + "background-color: %s;" + "background-image: none;" + "}", + kNativePopoverStyleClass, self->menu_hover_color, + kNativePopoverStyleClass, self->menu_hover_color) + : g_strdup(""); g_autofree gchar* header_menu_shadow_css = use_legacy_yaru_compatibility ? g_strdup_printf( @@ -707,6 +723,7 @@ static void refresh_header_bar_css(MyApplication* self) { "%s" "%s" "%s" + "%s" ".busymark-titlebar," ".busymark-titlebar:backdrop {" "background-color: %s;" @@ -812,7 +829,7 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: %s;" "background-image: none;" "}", - background, window_shadow_css, native_popover_css, + background, window_shadow_css, native_popover_css, native_menu_state_css, header_menu_shadow_css, background, foreground, background, foreground, sidebar_background, foreground, foreground, foreground, kHeaderBackdropForegroundOpacity, sidebar_border, sidebar_border, diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 5641c03..3d471d8 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -573,7 +573,7 @@ void main() { expect(css, contains('background-color: alpha(currentColor, 0.16)')); expect(css, contains('background-color: alpha(currentColor, 0.10)')); expect(css, contains('popover.background.')); - expect(css, isNot(contains('modelbutton:hover'))); + expect(css, contains('modelbutton:hover:not(:disabled)')); expect(css, contains('box-shadow: 0 1px 3px')); for (final interactionSelector in [ 'tooltip', @@ -1231,7 +1231,7 @@ void main() { } expect(native, contains('view_mode_icon_name(mode)')); expect(native, contains('view_mode_icon_name("split")')); - expect(native, isNot(contains('modelbutton:hover'))); + expect(native, contains('modelbutton:hover:not(:disabled)')); expect(native, isNot(contains('modelbutton:focus'))); expect(native, isNot(contains('modelbutton:active'))); expect(native, isNot(contains('outline-width: 0;'))); @@ -1283,8 +1283,6 @@ void main() { expect(native, contains('gtk_label_new(shortcut)')); expect(native, contains('decorate_model_menu_accelerators(')); expect(native, contains('g_menu_item_set_icon(item, icon)')); - expect(native, isNot(contains('native_menu_state_css'))); - expect(native, isNot(contains('row:hover:not(:disabled)'))); expect(native, contains('gtk_menu_button_set_menu_model')); expect(native, isNot(contains('busymark-shortcut-widget'))); expect(native, isNot(contains('busymark-menu-row'))); From 8a26ced001a451938a516aff19e84c296e864ee9 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 20:11:50 -0700 Subject: [PATCH 18/29] Enhance tooltip styling and integration across the application. Introduce a new HeaderBarTooltipTheme for consistent tooltip visuals, including background, foreground, border colors, and padding. Update tooltip handling in various components to utilize the new theme, improving UI consistency and user experience. --- lib/src/app/app_theme.dart | 19 +- lib/src/app/busymark_design.dart | 111 ++++++- lib/src/app/busymark_dialogs.dart | 54 +++- lib/src/editor/source/source_gutter.dart | 1 - lib/src/editor/wysiwyg/wysiwyg_editor.dart | 293 ++++++++--------- .../platform/header_bar_configuration.dart | 81 ++++- .../presentation/welcome_screen.dart | 167 +++++----- .../presentation/workspace_screen.dart | 296 ++++++++---------- linux/runner/my_application.cc | 113 ++++++- test/src/app_smoke_test.dart | 49 ++- test/src/busymark_design_test.dart | 133 +++++++- test/src/busymark_dialogs_test.dart | 74 +++++ test/src/busymark_document_test.dart | 24 +- test/src/header_bar_configuration_test.dart | 25 +- test/src/native_headerbar_audit_test.dart | 43 ++- test/src/source_audit_test.dart | 104 +++--- test/src/wysiwyg_rtl_test.dart | 16 +- 17 files changed, 1089 insertions(+), 514 deletions(-) diff --git a/lib/src/app/app_theme.dart b/lib/src/app/app_theme.dart index 0dabfee..e3093d8 100644 --- a/lib/src/app/app_theme.dart +++ b/lib/src/app/app_theme.dart @@ -130,6 +130,20 @@ ThemeData buildBusyMarkTheme({ shadowColor: colorScheme.shadow, side: popoverSurfaceSide, ); + final tooltipTheme = base.tooltipTheme.copyWith( + decoration: BoxDecoration( + color: BusyMarkTooltipStyle.background, + border: Border.all(color: BusyMarkTooltipStyle.border), + borderRadius: BusyMarkTooltipStyle.borderRadius, + ), + textStyle: textTheme.bodyMedium?.copyWith( + color: BusyMarkTooltipStyle.foreground, + fontSize: BusyMarkTypography.tooltipFontSize, + ), + padding: BusyMarkTooltipStyle.padding, + constraints: BusyMarkTooltipStyle.constraints, + waitDuration: BusyMarkMotion.tooltipWait, + ); return base.copyWith( brightness: brightness, @@ -252,10 +266,7 @@ ThemeData buildBusyMarkTheme({ textStyle: textTheme.bodyMedium, menuStyle: dropdownMenuStyle, ), - // Keep Yaru's native desktop tooltip palette. Re-deriving Material's - // tooltip defaults from BusyMark's remapped surface ColorScheme gives - // dropdown triggers a different floating color than native GTK. - tooltipTheme: base.tooltipTheme, + tooltipTheme: tooltipTheme, tabBarTheme: base.tabBarTheme.copyWith( labelStyle: textTheme.labelLarge, unselectedLabelStyle: textTheme.labelLarge, diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index 27c33c2..b80f455 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -22,12 +22,13 @@ abstract final class BusyMarkSpacing { static const double lgPlus = 18; static const double xl = 24; static const double xxl = 32; - static const double tooltipHorizontal = 8; - static const double tooltipVertical = 5; + static const double tooltipHorizontal = 10; + static const double tooltipVertical = 6; } abstract final class BusyMarkRadius { static const double sm = 4; + static const double tooltip = kYaruButtonRadius; static const double md = 8; static const double lg = kYaruContainerRadius; static const double headerButton = kYaruButtonRadius; @@ -52,6 +53,7 @@ abstract final class BusyMarkSizes { static const double compactIcon = 13; static const double iconSm = 16; static const double iconMd = 20; + static const double tooltipMinHeight = 30; static const double previewMinWidth = 320; static const double modalMaxWidth = 860; static const double modalHorizontalInset = 40; @@ -131,6 +133,8 @@ abstract final class BusyMarkStroke { abstract final class BusyMarkAlpha { static const double groupedRowLightHoverStrength = 0.50; static const double nativeHeaderMenuShadowOpacity = 0.30; + static const double tooltipBackground = 0.80; + static const double tooltipBorder = 0.10; static const double textSelection = 0.32; static const double sourceCollapsedLine = 0.045; static const double sourceCursor = 0.82; @@ -194,6 +198,7 @@ abstract final class BusyMarkTypography { static const double codeLineHeight = 1.45; static const double bodyLineHeight = 1.5; static const double defaultFontSize = 14; + static const double tooltipFontSize = defaultFontSize; static const double previewThematicBreakHeight = BusyMarkStroke.thematicBreak; static const double sourceCursorHeightScale = 1.22; static const double sourceLineNumberScale = 0.92; @@ -376,6 +381,31 @@ abstract final class BusyMarkLinuxPalette { static const black = Color(0xFF000000); } +/// Cross-toolkit tooltip visuals. +/// +/// Flutter and the native GTK header bar render their own tooltip widgets. +/// Keeping the palette and shape here lets each toolkit retain its native +/// layout, positioning, focus, and motion while presenting the same surface. +abstract final class BusyMarkTooltipStyle { + static final Color background = BusyMarkLinuxPalette.black.withValues( + alpha: BusyMarkAlpha.tooltipBackground, + ); + static const Color foreground = BusyMarkLinuxPalette.white; + static final Color border = BusyMarkLinuxPalette.white.withValues( + alpha: BusyMarkAlpha.tooltipBorder, + ); + static const EdgeInsets padding = EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.tooltipHorizontal, + vertical: BusyMarkSpacing.tooltipVertical, + ); + static const BorderRadius borderRadius = BorderRadius.all( + Radius.circular(BusyMarkRadius.tooltip), + ); + static const BoxConstraints constraints = BoxConstraints( + minHeight: BusyMarkSizes.tooltipMinHeight, + ); +} + @immutable class BusyMarkSyntaxColors extends ThemeExtension { const BusyMarkSyntaxColors({ @@ -1865,11 +1895,13 @@ InputDecorationThemeData busyMarkGroupedInputDecorationTheme( InputDecoration busyMarkGroupedTextFieldDecoration( BuildContext context, { required String labelText, + String? hintText, String? errorText, bool alignLabelWithHint = false, }) { final decoration = InputDecoration( labelText: labelText, + hintText: hintText, errorText: errorText, alignLabelWithHint: alignLabelWithHint, ); @@ -1887,6 +1919,81 @@ InputDecoration busyMarkGroupedTextFieldDecoration( ); } +/// A text entry hosted by the native grouped-list form surface. +/// +/// The row owns the background, outline, padding, and separators while +/// [TextFormField] continues to own editing, validation, and focus behavior. +class BusyMarkGroupedTextEntry extends StatelessWidget { + const BusyMarkGroupedTextEntry({ + super.key, + required this.label, + this.controller, + this.initialValue, + this.errorText, + this.hintText, + this.enabled = true, + this.autofocus = false, + this.keyboardType, + this.minLines = 1, + this.maxLines = 1, + this.textInputAction, + this.textDirection, + this.textStyle, + this.alignLabelWithHint = false, + this.trailing, + this.onChanged, + this.onSubmitted, + }) : assert(controller == null || initialValue == null), + assert(minLines > 0), + assert(maxLines >= minLines); + + final String label; + final TextEditingController? controller; + final String? initialValue; + final String? errorText; + final String? hintText; + final bool enabled; + final bool autofocus; + final TextInputType? keyboardType; + final int minLines; + final int maxLines; + final TextInputAction? textInputAction; + final TextDirection? textDirection; + final TextStyle? textStyle; + final bool alignLabelWithHint; + final Widget? trailing; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + + @override + Widget build(BuildContext context) { + return YaruListTile.square( + title: TextFormField( + controller: controller, + initialValue: initialValue, + enabled: enabled, + autofocus: autofocus, + keyboardType: keyboardType, + minLines: minLines, + maxLines: maxLines, + textInputAction: textInputAction, + textDirection: textDirection, + style: textStyle, + onChanged: enabled ? onChanged : null, + onFieldSubmitted: enabled ? onSubmitted : null, + decoration: busyMarkGroupedTextFieldDecoration( + context, + labelText: label, + hintText: hintText, + errorText: errorText, + alignLabelWithHint: alignLabelWithHint, + ), + ), + trailing: trailing, + ); + } +} + class BusyMarkClamp extends StatelessWidget { const BusyMarkClamp({ super.key, diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index 30581ab..a6909e3 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -109,16 +109,56 @@ Future _showBusyMarkFlutterDialog( Color? barrierColor, bool barrierDismissible = true, }) { - return showDialog( - context: context, - barrierColor: barrierColor ?? busyMarkModalBarrierColor(context), - barrierDismissible: barrierDismissible, - traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, - builder: (dialogContext) => - BusyMarkModalShortcutBoundary(child: builder(dialogContext)), + final navigator = Navigator.of(context, rootNavigator: true); + final themes = InheritedTheme.capture(from: context, to: navigator.context); + return navigator.push( + _BusyMarkDialogRoute( + context: context, + builder: builder, + themes: themes, + fixedBarrierColor: barrierColor, + initialBarrierColor: barrierColor ?? busyMarkModalBarrierColor(context), + barrierDismissible: barrierDismissible, + ), ); } +class _BusyMarkDialogRoute extends DialogRoute { + _BusyMarkDialogRoute({ + required super.context, + required WidgetBuilder builder, + required CapturedThemes themes, + required Color? fixedBarrierColor, + required Color initialBarrierColor, + required super.barrierDismissible, + }) : _fixedBarrierColor = fixedBarrierColor, + _initialBarrierColor = initialBarrierColor, + super( + builder: (dialogContext) => + BusyMarkModalShortcutBoundary(child: builder(dialogContext)), + themes: themes, + barrierColor: initialBarrierColor, + traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, + ); + + final Color? _fixedBarrierColor; + final Color _initialBarrierColor; + + /// Unlike [DialogRoute]'s constructor value, this getter is reevaluated + /// when the Navigator's inherited theme changes. + @override + Color? get barrierColor { + final fixedColor = _fixedBarrierColor; + if (fixedColor != null) { + return fixedColor; + } + final navigatorContext = navigator?.context; + return navigatorContext == null + ? _initialBarrierColor + : busyMarkModalBarrierColor(navigatorContext); + } +} + Future showBusyMarkModalEditorDialog( BuildContext context, { required WidgetBuilder builder, diff --git a/lib/src/editor/source/source_gutter.dart b/lib/src/editor/source/source_gutter.dart index 85e24b2..cc93057 100644 --- a/lib/src/editor/source/source_gutter.dart +++ b/lib/src/editor/source/source_gutter.dart @@ -292,7 +292,6 @@ class _SourceDiagnosticMarkerDot extends StatelessWidget { .join('\n'); return Tooltip( message: message, - waitDuration: BusyMarkMotion.tooltipWait, child: SizedBox.square( dimension: 6, child: DecoratedBox( diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 1ee4c53..c8b43b9 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -2109,13 +2109,15 @@ class _BusyMarkWysiwygEditorState extends State { Future _showEditorDialog( BuildContext context, { required WidgetBuilder builder, + double maxWidth = BusyMarkSizes.dialogCompact, }) { final headerBar = widget.headerBarService; - return showBusyMarkModalDialog( + return showBusyMarkModalEditorDialog( context, headerBarService: headerBar != null && headerBar.isAvailable ? headerBar : null, + maxWidth: maxWidth, builder: builder, ); } @@ -2124,33 +2126,32 @@ class _BusyMarkWysiwygEditorState extends State { var destination = ''; return _showEditorDialog( context, - builder: (context) => BusyMarkDialogShell( + builder: (context) => BusyMarkModalEditorScaffold( title: context.l10n.link, - maxWidth: BusyMarkSizes.dialogCompact, - actions: [ - BusyMarkDialogButton( - label: context.l10n.cancel, - onPressed: () => Navigator.pop(context), - ), - BusyMarkDialogButton( - label: context.l10n.apply, - onPressed: () => Navigator.pop(context, destination), - suggested: true, - ), - ], + cancelLabel: context.l10n.cancel, + saveLabel: context.l10n.apply, + onCancel: () => Navigator.pop(context), + onSave: () => Navigator.pop(context, destination), children: [ - TextFormField( - key: const ValueKey('wysiwyg-link-destination-field'), - autofocus: true, - textDirection: TextDirection.ltr, - onChanged: (value) => destination = value, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: BusyMarkTypography.monoFontFamily, - fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, - ), - decoration: const InputDecoration(hintText: 'https://example.com'), - onFieldSubmitted: (value) => Navigator.pop(context, value), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + key: const ValueKey('wysiwyg-link-destination-field'), + label: context.l10n.source, + autofocus: true, + textDirection: TextDirection.ltr, + onChanged: (value) => destination = value, + textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: BusyMarkTypography.monoFontFamily, + fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, + ), + hintText: 'https://example.com', + onSubmitted: (value) => Navigator.pop(context, value), + ), + ], ), + const SizedBox(height: BusyMarkSpacing.lg), ], ), ); @@ -2196,36 +2197,36 @@ class _BusyMarkWysiwygEditorState extends State { var source = initialSource; return _showEditorDialog( context, - builder: (context) => BusyMarkDialogShell( + maxWidth: BusyMarkSizes.dialogNarrow, + builder: (context) => BusyMarkModalEditorScaffold( title: context.l10n.editHtml, - maxWidth: BusyMarkSizes.dialogNarrow, - actions: [ - BusyMarkDialogButton( - label: context.l10n.cancel, - onPressed: () => Navigator.pop(context), - ), - BusyMarkDialogButton( - label: submitLabel, - onPressed: () => Navigator.pop(context, source), - suggested: true, - ), - ], + cancelLabel: context.l10n.cancel, + saveLabel: submitLabel, + onCancel: () => Navigator.pop(context), + onSave: () => Navigator.pop(context, source), children: [ - TextFormField( - key: const ValueKey('wysiwyg-html-source-field'), - initialValue: initialSource, - onChanged: (value) => source = value, - autofocus: true, - minLines: 8, - maxLines: 16, - textInputAction: TextInputAction.newline, - textDirection: TextDirection.ltr, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: BusyMarkTypography.monoFontFamily, - fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, - ), - decoration: InputDecoration(labelText: context.l10n.htmlSource), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + key: const ValueKey('wysiwyg-html-source-field'), + label: context.l10n.htmlSource, + initialValue: initialSource, + onChanged: (value) => source = value, + autofocus: true, + minLines: 8, + maxLines: 16, + textInputAction: TextInputAction.newline, + textDirection: TextDirection.ltr, + textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: BusyMarkTypography.monoFontFamily, + fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, + ), + alignLabelWithHint: true, + ), + ], ), + const SizedBox(height: BusyMarkSpacing.lg), ], ), ); @@ -2238,37 +2239,33 @@ class _BusyMarkWysiwygEditorState extends State { var language = initialLanguage; return _showEditorDialog( context, - builder: (context) => BusyMarkDialogShell( + builder: (context) => BusyMarkModalEditorScaffold( title: context.l10n.codeBlockLanguage, - maxWidth: BusyMarkSizes.dialogCompact, - actions: [ - BusyMarkDialogButton( - label: context.l10n.cancel, - onPressed: () => Navigator.pop(context), - ), - BusyMarkDialogButton( - label: context.l10n.apply, - onPressed: () => Navigator.pop(context, language), - suggested: true, - ), - ], + cancelLabel: context.l10n.cancel, + saveLabel: context.l10n.apply, + onCancel: () => Navigator.pop(context), + onSave: () => Navigator.pop(context, language), children: [ - TextFormField( - key: const ValueKey('wysiwyg-code-language-field'), - initialValue: initialLanguage, - onChanged: (value) => language = value, - autofocus: true, - textDirection: TextDirection.ltr, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: BusyMarkTypography.monoFontFamily, - fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, - ), - decoration: InputDecoration( - labelText: context.l10n.language, - hintText: 'dart', - ), - onFieldSubmitted: (value) => Navigator.pop(context, value), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + key: const ValueKey('wysiwyg-code-language-field'), + label: context.l10n.language, + initialValue: initialLanguage, + onChanged: (value) => language = value, + autofocus: true, + textDirection: TextDirection.ltr, + textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: BusyMarkTypography.monoFontFamily, + fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, + ), + hintText: 'dart', + onSubmitted: (value) => Navigator.pop(context, value), + ), + ], ), + const SizedBox(height: BusyMarkSpacing.lg), ], ), ); @@ -3306,59 +3303,48 @@ class _ImageDialogState extends State<_ImageDialog> { @override Widget build(BuildContext context) { - return BusyMarkDialogShell( + return BusyMarkModalEditorScaffold( title: widget.title, - maxWidth: BusyMarkSizes.dialogCompact, - actions: [ - BusyMarkDialogButton( - key: BusyMarkImageDialogKeys.cancel, - label: context.l10n.cancel, - onPressed: () => Navigator.pop(context), - ), - BusyMarkDialogButton( - key: BusyMarkImageDialogKeys.submit, - label: widget.submitLabel, - onPressed: _canSubmit ? _submit : null, - suggested: true, - ), - ], + cancelLabel: context.l10n.cancel, + saveLabel: widget.submitLabel, + cancelKey: BusyMarkImageDialogKeys.cancel, + saveKey: BusyMarkImageDialogKeys.submit, + onCancel: () => Navigator.pop(context), + onSave: _canSubmit ? _submit : null, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, + BusyMarkGroupedList( + filled: true, children: [ - Expanded( - child: BusyMarkFloatingTextEntry( - key: BusyMarkImageDialogKeys.source, - label: context.l10n.source, - controller: _sourceController, - hintText: 'images/example.png', - autofocus: true, - textDirection: TextDirection.ltr, - textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: BusyMarkTypography.monoFontFamily, - fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, - ), - textInputAction: TextInputAction.next, + BusyMarkGroupedTextEntry( + key: BusyMarkImageDialogKeys.source, + label: context.l10n.source, + controller: _sourceController, + hintText: 'images/example.png', + autofocus: true, + textDirection: TextDirection.ltr, + textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: BusyMarkTypography.monoFontFamily, + fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, + ), + textInputAction: TextInputAction.next, + trailing: BusyMarkPushButton.standardIcon( + key: BusyMarkImageDialogKeys.choose, + icon: const Icon(BusyMarkGlyphs.folderOpen), + label: Text(context.l10n.choose), + onPressed: _chooseImage, ), ), - const SizedBox(width: BusyMarkSpacing.sm), - BusyMarkDialogButton( - key: BusyMarkImageDialogKeys.choose, - label: context.l10n.choose, - icon: BusyMarkGlyphs.folderOpen, - onPressed: _chooseImage, + BusyMarkGroupedTextEntry( + key: BusyMarkImageDialogKeys.alt, + label: context.l10n.altText, + controller: _altController, + hintText: context.l10n.describeTheImage, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), ), ], ), - const SizedBox(height: BusyMarkSpacing.md), - BusyMarkFloatingTextEntry( - key: BusyMarkImageDialogKeys.alt, - label: context.l10n.altText, - controller: _altController, - hintText: context.l10n.describeTheImage, - textInputAction: TextInputAction.done, - onSubmitted: (_) => _submit(), - ), + const SizedBox(height: BusyMarkSpacing.lg), ], ); } @@ -3444,49 +3430,34 @@ class _TableDialogState extends State<_TableDialog> { @override Widget build(BuildContext context) { - return BusyMarkDialogShell( + return BusyMarkModalEditorScaffold( title: context.l10n.table, - maxWidth: BusyMarkSizes.dialogCompact, - actions: [ - BusyMarkDialogButton( - label: context.l10n.cancel, - onPressed: () => Navigator.pop(context), - ), - BusyMarkDialogButton( - label: context.l10n.insert, - onPressed: _submit, - suggested: true, - ), - ], + cancelLabel: context.l10n.cancel, + saveLabel: context.l10n.insert, + onCancel: () => Navigator.pop(context), + onSave: _submit, children: [ - Row( + BusyMarkGroupedList( + filled: true, children: [ - Expanded( - child: TextField( - controller: _columnsController, - autofocus: true, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: context.l10n.columns, - hintText: '2', - ), - onSubmitted: (_) => _submit(), - ), + BusyMarkGroupedTextEntry( + label: context.l10n.columns, + controller: _columnsController, + autofocus: true, + keyboardType: TextInputType.number, + hintText: '2', + onSubmitted: (_) => _submit(), ), - const SizedBox(width: BusyMarkSpacing.md), - Expanded( - child: TextField( - controller: _rowsController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: context.l10n.rows, - hintText: '2', - ), - onSubmitted: (_) => _submit(), - ), + BusyMarkGroupedTextEntry( + label: context.l10n.rows, + controller: _rowsController, + keyboardType: TextInputType.number, + hintText: '2', + onSubmitted: (_) => _submit(), ), ], ), + const SizedBox(height: BusyMarkSpacing.lg), ], ); } diff --git a/lib/src/platform/header_bar_configuration.dart b/lib/src/platform/header_bar_configuration.dart index 676540e..fc343f6 100644 --- a/lib/src/platform/header_bar_configuration.dart +++ b/lib/src/platform/header_bar_configuration.dart @@ -122,6 +122,79 @@ class HeaderBarLabels { int get hashCode => Object.hashAll(toMap().entries); } +@immutable +class HeaderBarTooltipTheme { + const HeaderBarTooltipTheme({ + required this.backgroundColor, + required this.foregroundColor, + required this.borderColor, + required this.borderRadius, + required this.fontSize, + required this.horizontalPadding, + required this.verticalPadding, + required this.minimumHeight, + }); + + factory HeaderBarTooltipTheme.busyMark() { + return HeaderBarTooltipTheme( + backgroundColor: BusyMarkTooltipStyle.background, + foregroundColor: BusyMarkTooltipStyle.foreground, + borderColor: BusyMarkTooltipStyle.border, + borderRadius: BusyMarkRadius.tooltip, + fontSize: BusyMarkTypography.tooltipFontSize, + horizontalPadding: BusyMarkSpacing.tooltipHorizontal, + verticalPadding: BusyMarkSpacing.tooltipVertical, + minimumHeight: BusyMarkSizes.tooltipMinHeight, + ); + } + + final Color backgroundColor; + final Color foregroundColor; + final Color borderColor; + final double borderRadius; + final double fontSize; + final double horizontalPadding; + final double verticalPadding; + final double minimumHeight; + + Map toMap() => { + 'backgroundColor': _cssColor(backgroundColor), + 'foregroundColor': _cssColor(foregroundColor), + 'borderColor': _cssColor(borderColor), + 'borderRadius': borderRadius, + 'fontSize': fontSize, + 'horizontalPadding': horizontalPadding, + 'verticalPadding': verticalPadding, + 'minimumHeight': minimumHeight, + }; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is HeaderBarTooltipTheme && + backgroundColor == other.backgroundColor && + foregroundColor == other.foregroundColor && + borderColor == other.borderColor && + borderRadius == other.borderRadius && + fontSize == other.fontSize && + horizontalPadding == other.horizontalPadding && + verticalPadding == other.verticalPadding && + minimumHeight == other.minimumHeight; + } + + @override + int get hashCode => Object.hash( + backgroundColor, + foregroundColor, + borderColor, + borderRadius, + fontSize, + horizontalPadding, + verticalPadding, + minimumHeight, + ); +} + @immutable class HeaderBarTheme { const HeaderBarTheme({ @@ -134,6 +207,7 @@ class HeaderBarTheme { required this.menuHoverColor, required this.popoverShadowColor, required this.modalBarrierColor, + required this.tooltip, }); factory HeaderBarTheme.fromContext(BuildContext context) { @@ -153,6 +227,7 @@ class HeaderBarTheme { BusyMarkAlpha.nativeHeaderMenuShadowOpacity, ), modalBarrierColor: colors.shade, + tooltip: HeaderBarTooltipTheme.busyMark(), ); } @@ -165,6 +240,7 @@ class HeaderBarTheme { final Color menuHoverColor; final Color popoverShadowColor; final Color modalBarrierColor; + final HeaderBarTooltipTheme tooltip; Map toMap() => { 'preferDark': preferDark, @@ -176,6 +252,7 @@ class HeaderBarTheme { 'menuHoverColor': _cssColor(menuHoverColor), 'popoverShadowColor': _cssColor(popoverShadowColor), 'modalBarrierColor': _cssColor(modalBarrierColor), + 'tooltip': tooltip.toMap(), }; @override @@ -190,7 +267,8 @@ class HeaderBarTheme { popoverBackgroundColor == other.popoverBackgroundColor && menuHoverColor == other.menuHoverColor && popoverShadowColor == other.popoverShadowColor && - modalBarrierColor == other.modalBarrierColor; + modalBarrierColor == other.modalBarrierColor && + tooltip == other.tooltip; } @override @@ -204,6 +282,7 @@ class HeaderBarTheme { menuHoverColor, popoverShadowColor, modalBarrierColor, + tooltip, ]); } diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart index bfdebe3..557d4ca 100644 --- a/lib/src/workspace/presentation/welcome_screen.dart +++ b/lib/src/workspace/presentation/welcome_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:path/path.dart' as p; +import 'package:yaru/yaru.dart'; import '../../app/app_settings.dart'; import '../../app/app_router.dart'; @@ -364,9 +365,10 @@ class _WelcomeScreenState extends ConsumerState { _logSelectedPickerPath(parentPath); final headerBar = ref.read(linuxHeaderBarServiceProvider); - final created = await showBusyMarkModalDialog( + final created = await showBusyMarkModalEditorDialog( context, headerBarService: headerBar.isAvailable ? headerBar : null, + maxWidth: BusyMarkSizes.dialogWide, builder: (context) => _CreateWritersideProjectDialog( parentDirectoryPath: parentPath, onCreate: (request) => ref @@ -601,100 +603,85 @@ class _CreateWritersideProjectDialogState directoryError == null && instanceIdError == null && topicTitleError == null; - return BusyMarkDialogShell( - title: context.l10n.createWritersideProject, - maxWidth: BusyMarkSizes.dialogWide, - actions: [ - BusyMarkDialogButton( - label: context.l10n.cancel, - onPressed: () => Navigator.pop(context), - ), - BusyMarkDialogButton( - label: _creating ? context.l10n.creating : context.l10n.create, - onPressed: canCreate ? _submit : null, - suggested: true, - ), - ], - children: [ - BusyMarkFloatingTextEntryGroup( - children: [ - BusyMarkFloatingTextEntry( - label: context.l10n.projectName, - controller: _projectNameController, - textInputAction: TextInputAction.next, - errorText: projectError, - ), - BusyMarkFloatingTextEntry( - label: context.l10n.directoryName, - controller: _directoryNameController, - textDirection: TextDirection.ltr, - textInputAction: TextInputAction.next, - errorText: directoryError, - ), - ], - ), - const SizedBox(height: BusyMarkSpacing.md), - BusyMarkFloatingTextEntryGroup( - children: [ - BusyMarkFloatingTextEntry( - label: context.l10n.instanceName, - controller: _instanceNameController, - textInputAction: TextInputAction.next, - ), - BusyMarkFloatingTextEntry( - label: context.l10n.instanceId, - controller: _instanceIdController, - textDirection: TextDirection.ltr, - textInputAction: TextInputAction.next, - errorText: instanceIdError, + return PopScope( + canPop: !_creating, + child: BusyMarkModalEditorScaffold( + title: context.l10n.createWritersideProject, + cancelLabel: context.l10n.cancel, + saveLabel: context.l10n.create, + onCancel: () => Navigator.pop(context), + cancelEnabled: !_creating, + onSave: canCreate ? _submit : null, + saving: _creating, + children: [ + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + label: context.l10n.projectName, + controller: _projectNameController, + textInputAction: TextInputAction.next, + errorText: projectError, + ), + BusyMarkGroupedTextEntry( + label: context.l10n.directoryName, + controller: _directoryNameController, + textDirection: TextDirection.ltr, + textInputAction: TextInputAction.next, + errorText: directoryError, + ), + BusyMarkGroupedTextEntry( + label: context.l10n.instanceName, + controller: _instanceNameController, + textInputAction: TextInputAction.next, + ), + BusyMarkGroupedTextEntry( + label: context.l10n.instanceId, + controller: _instanceIdController, + textDirection: TextDirection.ltr, + textInputAction: TextInputAction.next, + errorText: instanceIdError, + ), + BusyMarkGroupedTextEntry( + label: context.l10n.startTopicTitle, + controller: _topicTitleController, + textInputAction: TextInputAction.done, + onSubmitted: (_) { + if (canCreate) { + _submit(); + } + }, + errorText: topicTitleError, + ), + ], + ), + if (_creationError != null) ...[ + const SizedBox(height: BusyMarkSpacing.md), + BusyMarkStatusBox( + message: _creationError!, + kind: BusyMarkStatusKind.error, ), ], - ), - const SizedBox(height: BusyMarkSpacing.md), - BusyMarkFloatingTextEntry( - label: context.l10n.startTopicTitle, - controller: _topicTitleController, - textInputAction: TextInputAction.done, - onSubmitted: (_) { - if (canCreate) { - _submit(); - } - }, - errorText: topicTitleError, - ), - const SizedBox(height: BusyMarkSpacing.lg), - if (_creationError != null) ...[ - BusyMarkStatusBox( - message: _creationError!, - kind: BusyMarkStatusKind.error, + BusyMarkGroupedList( + title: context.l10n.location, + filled: true, + children: [ + YaruListTile.square( + title: Directionality( + textDirection: TextDirection.ltr, + child: SelectableText( + _targetPath, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colors.foreground), + ), + ), + ), + ], ), const SizedBox(height: BusyMarkSpacing.lg), ], - Text( - context.l10n.location, - style: Theme.of( - context, - ).textTheme.labelMedium?.copyWith(color: colors.mutedForeground), - ), - const SizedBox(height: BusyMarkSpacing.xs), - DecoratedBox( - decoration: BoxDecoration( - color: colors.control, - borderRadius: BorderRadius.circular(BusyMarkRadius.md), - border: Border.all(color: colors.subtleBorder), - ), - child: Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.md), - child: Directionality( - textDirection: TextDirection.ltr, - child: SelectableText( - _targetPath, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ), - ), - ], + ), ); } diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index daf56a4..891c86d 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -1190,8 +1190,9 @@ Future<_PathMenuAction?> _showSidebarPathMenu( } Future _showCreateBranchDialog(BuildContext context) { - return showBusyMarkModalDialog( + return showBusyMarkModalEditorDialog( context, + maxWidth: BusyMarkSizes.dialogCompact, builder: (context) => const _CreateBranchDialog(), ); } @@ -1246,29 +1247,27 @@ class _CreateBranchDialogState extends State<_CreateBranchDialog> { @override Widget build(BuildContext context) { - return BusyMarkDialogShell( + return BusyMarkModalEditorScaffold( title: context.l10n.gitCreateBranch, - maxWidth: BusyMarkSizes.dialogCompact, - actions: [ - BusyMarkDialogButton( - label: context.l10n.cancel, - onPressed: () => Navigator.pop(context), - ), - BusyMarkDialogButton( - label: context.l10n.gitCreateBranch, - suggested: true, - onPressed: _canCreate ? _submit : null, - ), - ], + cancelLabel: context.l10n.cancel, + saveLabel: context.l10n.gitCreateBranch, + onCancel: () => Navigator.pop(context), + onSave: _canCreate ? _submit : null, children: [ - BusyMarkFloatingTextEntry( - label: context.l10n.gitBranchName, - controller: _controller, - textDirection: TextDirection.ltr, - autofocus: true, - textInputAction: TextInputAction.done, - onSubmitted: (_) => _submit(), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + label: context.l10n.gitBranchName, + controller: _controller, + textDirection: TextDirection.ltr, + autofocus: true, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + ), + ], ), + const SizedBox(height: BusyMarkSpacing.lg), ], ); } @@ -3501,8 +3500,9 @@ Future _showFileNameDialog( required String actionLabel, required String initialValue, }) { - return showBusyMarkModalDialog( + return showBusyMarkModalEditorDialog( context, + maxWidth: BusyMarkSizes.dialogCompact, builder: (context) => _FileNameDialog( title: title, actionLabel: actionLabel, @@ -3548,29 +3548,27 @@ class _FileNameDialogState extends State<_FileNameDialog> { @override Widget build(BuildContext context) { - return BusyMarkDialogShell( + return BusyMarkModalEditorScaffold( title: widget.title, - maxWidth: BusyMarkSizes.dialogCompact, - actions: [ - BusyMarkDialogButton( - label: context.l10n.cancel, - onPressed: () => Navigator.pop(context), - ), - BusyMarkDialogButton( - label: widget.actionLabel, - suggested: true, - onPressed: _canSubmit ? _submit : null, - ), - ], + cancelLabel: context.l10n.cancel, + saveLabel: widget.actionLabel, + onCancel: () => Navigator.pop(context), + onSave: _canSubmit ? _submit : null, children: [ - BusyMarkFloatingTextEntry( - label: context.l10n.fileName, - controller: _controller, - textDirection: TextDirection.ltr, - autofocus: true, - textInputAction: TextInputAction.done, - onSubmitted: (_) => _submit(), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + label: context.l10n.fileName, + controller: _controller, + textDirection: TextDirection.ltr, + autofocus: true, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + ), + ], ), + const SizedBox(height: BusyMarkSpacing.lg), ], ); } @@ -4571,9 +4569,10 @@ class _TocTabState extends ConsumerState<_TocTab> { return; } final headerBar = ref.read(linuxHeaderBarServiceProvider); - await showBusyMarkModalDialog( + await showBusyMarkModalEditorDialog( context, headerBarService: headerBar.isAvailable ? headerBar : null, + maxWidth: BusyMarkSizes.dialogWide, builder: (dialogContext) => _CreateWritersideTopicDialog( workspace: widget.workspace, instanceTreePath: instanceTreePath, @@ -5078,142 +5077,107 @@ class _CreateWritersideTopicDialogState final titleError = _titleError(context); final fileNameError = _fileNameError(context); final canCreate = !_creating && titleError == null && fileNameError == null; - return BusyMarkDialogShell( - title: _dialogTitle(context), - maxWidth: BusyMarkSizes.dialogWide, - actions: [ - BusyMarkDialogButton( - label: context.l10n.cancel, - onPressed: () => Navigator.pop(context), - ), - BusyMarkDialogButton( - label: _creating ? context.l10n.creating : context.l10n.create, - suggested: true, - onPressed: canCreate ? _submit : null, - ), - ], - children: [ - BusyMarkFloatingTextEntryGroup( - children: [ - BusyMarkFloatingTextEntry( - label: context.l10n.topicTitle, - controller: _titleController, - autofocus: true, - textInputAction: TextInputAction.next, - errorText: titleError, - ), - BusyMarkFloatingTextEntry( - label: context.l10n.fileName, - controller: _fileNameController, - textDirection: TextDirection.ltr, - textInputAction: TextInputAction.done, - errorText: fileNameError, - onSubmitted: (_) { - if (canCreate) { - _submit(); - } - }, - ), - ], - ), - const SizedBox(height: BusyMarkSpacing.md), - BusyMarkGroupedList( - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.topicPlacement, - leading: const Icon(BusyMarkGlyphs.tree), - trailing: BusyMarkPopupSelector( - value: _placement, - label: _placementLabel(context, _placement), - tooltip: context.l10n.topicPlacement, - enabled: !_creating, - options: [ - BusyMarkPopupSelectorOption( - value: WritersideTopicCreatePlacement.root, - label: context.l10n.tocRoot, - ), + return PopScope( + canPop: !_creating, + child: BusyMarkModalEditorScaffold( + title: _dialogTitle(context), + cancelLabel: context.l10n.cancel, + saveLabel: context.l10n.create, + onCancel: () => Navigator.pop(context), + cancelEnabled: !_creating, + onSave: canCreate ? _submit : null, + saving: _creating, + children: [ + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + label: context.l10n.topicTitle, + controller: _titleController, + autofocus: true, + textInputAction: TextInputAction.next, + errorText: titleError, + ), + BusyMarkGroupedTextEntry( + label: context.l10n.fileName, + controller: _fileNameController, + textDirection: TextDirection.ltr, + textInputAction: TextInputAction.done, + errorText: fileNameError, + onSubmitted: (_) { + if (canCreate) { + _submit(); + } + }, + ), + ], + ), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkComboRow( + title: context.l10n.topicPlacement, + subtitle: + _placement != WritersideTopicCreatePlacement.root && + widget.referenceLabel != null + ? widget.referenceLabel + : null, + leading: const Icon(BusyMarkGlyphs.tree), + values: [ + WritersideTopicCreatePlacement.root, if (widget.referencePath != null) - BusyMarkPopupSelectorOption( - value: WritersideTopicCreatePlacement.sibling, - label: context.l10n.afterSelectedTopic, - ), + WritersideTopicCreatePlacement.sibling, if (widget.referencePath != null) - BusyMarkPopupSelectorOption( - value: WritersideTopicCreatePlacement.child, - label: context.l10n.insideSelectedTopic, - ), + WritersideTopicCreatePlacement.child, ], + selected: _placement, + labelFor: (value) => _placementLabel(context, value), + enabled: !_creating, onSelected: (value) { setState(() => _placement = value); }, ), - ), - ], - ), - if (_placement != WritersideTopicCreatePlacement.root && - widget.referenceLabel != null) ...[ - const SizedBox(height: BusyMarkSpacing.xs), - Text( - widget.referenceLabel!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: BusyMarkSurfaceColors.of(context).mutedForeground, - ), + BusyMarkComboRow( + title: context.l10n.file, + leading: const Icon(BusyMarkGlyphs.document), + values: WritersideTopicFormat.values, + selected: _format, + labelFor: (value) => switch (value) { + WritersideTopicFormat.markdown => context.l10n.markdown, + WritersideTopicFormat.xml => context.l10n.xml, + }, + enabled: !_creating, + onSelected: _setFormat, + ), + ], ), - ], - const SizedBox(height: BusyMarkSpacing.md), - SegmentedButton( - showSelectedIcon: false, - segments: [ - ButtonSegment( - value: WritersideTopicFormat.markdown, - label: Text(context.l10n.markdown), - ), - ButtonSegment( - value: WritersideTopicFormat.xml, - label: Text(context.l10n.xml), + if (_creationError != null) ...[ + const SizedBox(height: BusyMarkSpacing.md), + BusyMarkStatusBox( + message: _creationError!, + kind: BusyMarkStatusKind.error, ), ], - selected: {_format}, - onSelectionChanged: (value) => _setFormat(value.first), - ), - const SizedBox(height: BusyMarkSpacing.lg), - if (_creationError != null) ...[ - BusyMarkStatusBox( - message: _creationError!, - kind: BusyMarkStatusKind.error, + BusyMarkGroupedList( + title: context.l10n.location, + filled: true, + children: [ + YaruListTile.square( + title: Directionality( + textDirection: TextDirection.ltr, + child: SelectableText( + _targetPath, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: BusyMarkSurfaceColors.of(context).foreground, + ), + ), + ), + ), + ], ), const SizedBox(height: BusyMarkSpacing.lg), ], - Text( - context.l10n.location, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: BusyMarkSurfaceColors.of(context).mutedForeground, - ), - ), - const SizedBox(height: BusyMarkSpacing.xs), - DecoratedBox( - decoration: BoxDecoration( - color: BusyMarkSurfaceColors.of(context).control, - borderRadius: BorderRadius.circular(BusyMarkRadius.md), - border: Border.all( - color: BusyMarkSurfaceColors.of(context).subtleBorder, - ), - ), - child: Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.md), - child: Directionality( - textDirection: TextDirection.ltr, - child: SelectableText( - _targetPath, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ), - ), - ], + ), ); } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index c9449a1..22e1944 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -27,6 +27,14 @@ constexpr char kDefaultSidebarBorder[] = "rgba(16,16,16,0.35)"; constexpr char kDefaultForeground[] = "#F7F7F7"; constexpr char kDefaultHeaderMenuShadowColor[] = "rgba(0,0,0,0.3)"; constexpr char kDefaultModalBarrierColor[] = "rgba(0,0,0,0.25)"; +constexpr char kDefaultTooltipBackground[] = "rgba(0,0,0,0.8)"; +constexpr char kDefaultTooltipForeground[] = "#FFFFFF"; +constexpr char kDefaultTooltipBorder[] = "rgba(255,255,255,0.1)"; +constexpr gdouble kDefaultTooltipRadius = 8.0; +constexpr gdouble kDefaultTooltipFontSize = 14.0; +constexpr gdouble kDefaultTooltipHorizontalPadding = 10.0; +constexpr gdouble kDefaultTooltipVerticalPadding = 6.0; +constexpr gdouble kDefaultTooltipMinimumHeight = 30.0; // Yaru GTK 3 adds a zero-blur 23%/75% black ring around CSD windows. Current // Ubuntu apps retain the diffuse shadow without that legacy hard edge. Reuse // Yaru's geometry here; Handy continues to own clipping, radii, and states. @@ -113,6 +121,14 @@ struct _MyApplication { gchar* popover_shadow_color; gchar* menu_hover_color; gchar* modal_barrier_color; + gchar* tooltip_background_color; + gchar* tooltip_foreground_color; + gchar* tooltip_border_color; + gdouble tooltip_radius; + gdouble tooltip_font_size; + gdouble tooltip_horizontal_padding; + gdouble tooltip_vertical_padding; + gdouble tooltip_minimum_height; gint sidebar_width; gboolean sidebar_visible; gboolean text_direction_rtl; @@ -412,6 +428,18 @@ static gboolean fl_lookup_double_arg(FlValue* args, return FALSE; } +static void update_bounded_double_arg(FlValue* args, + const gchar* key, + gdouble minimum, + gdouble maximum, + gdouble* target) { + gdouble value = 0; + if (fl_lookup_double_arg(args, key, &value) && value >= minimum && + value <= maximum) { + *target = value; + } +} + static FlValue* fl_lookup_map_arg(FlValue* args, const gchar* key) { if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { return nullptr; @@ -652,6 +680,54 @@ static void refresh_header_bar_css(MyApplication* self) { css_color_or(self->popover_shadow_color, kDefaultHeaderMenuShadowColor)) : g_strdup(""); + const gchar* tooltip_background = css_color_or( + self->tooltip_background_color, kDefaultTooltipBackground); + const gchar* tooltip_foreground = css_color_or( + self->tooltip_foreground_color, kDefaultTooltipForeground); + const gchar* tooltip_border = + css_color_or(self->tooltip_border_color, kDefaultTooltipBorder); + g_autofree gchar* tooltip_css = g_strdup_printf( + "tooltip," + "tooltip.background {" + "margin: 0;" + "padding: 0;" + "min-height: %.2fpx;" + "}" + "tooltip.background {" + "background-color: %s;" + "background-image: none;" + "background-clip: padding-box;" + "border: 1px solid %s;" + "border-radius: %.2fpx;" + "}" + "tooltip decoration," + "tooltip.csd decoration {" + "background-color: transparent;" + "border-radius: %.2fpx;" + "box-shadow: none;" + "}" + "tooltip > box," + "tooltip.background > box {" + "margin: 0;" + "padding: 0;" + "min-height: 0;" + "}" + "tooltip * {" + "background-color: transparent;" + "color: %s;" + "}" + "tooltip label {" + "margin: 0;" + "padding: %.2fpx %.2fpx;" + "min-height: 0;" + "font-family: Ubuntu;" + "font-size: %.2fpx;" + "font-weight: 400;" + "}", + self->tooltip_minimum_height, tooltip_background, tooltip_border, + self->tooltip_radius, self->tooltip_radius, tooltip_foreground, + self->tooltip_vertical_padding, self->tooltip_horizontal_padding, + self->tooltip_font_size); g_autofree gchar* header_focus_css = g_strdup_printf( ".busymark-titlebar.%s .busymark-sidebar-header label," ".busymark-titlebar.%s .busymark-header-title {" @@ -724,6 +800,7 @@ static void refresh_header_bar_css(MyApplication* self) { "%s" "%s" "%s" + "%s" ".busymark-titlebar," ".busymark-titlebar:backdrop {" "background-color: %s;" @@ -830,8 +907,8 @@ static void refresh_header_bar_css(MyApplication* self) { "background-image: none;" "}", background, window_shadow_css, native_popover_css, native_menu_state_css, - header_menu_shadow_css, background, foreground, background, foreground, - sidebar_background, foreground, foreground, foreground, + header_menu_shadow_css, tooltip_css, background, foreground, background, + foreground, sidebar_background, foreground, foreground, foreground, kHeaderBackdropForegroundOpacity, sidebar_border, sidebar_border, header_focus_css, modal); @@ -931,6 +1008,27 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { fl_lookup_string_arg(args, "menuHoverColor")); replace_css_color_field(&self->modal_barrier_color, fl_lookup_string_arg(args, "modalBarrierColor")); + FlValue* tooltip = fl_lookup_map_arg(args, "tooltip"); + if (tooltip != nullptr) { + replace_css_color_field( + &self->tooltip_background_color, + fl_lookup_string_arg(tooltip, "backgroundColor")); + replace_css_color_field( + &self->tooltip_foreground_color, + fl_lookup_string_arg(tooltip, "foregroundColor")); + replace_css_color_field(&self->tooltip_border_color, + fl_lookup_string_arg(tooltip, "borderColor")); + update_bounded_double_arg(tooltip, "borderRadius", 0, 64, + &self->tooltip_radius); + update_bounded_double_arg(tooltip, "fontSize", 1, 64, + &self->tooltip_font_size); + update_bounded_double_arg(tooltip, "horizontalPadding", 0, 64, + &self->tooltip_horizontal_padding); + update_bounded_double_arg(tooltip, "verticalPadding", 0, 64, + &self->tooltip_vertical_padding); + update_bounded_double_arg(tooltip, "minimumHeight", 1, 128, + &self->tooltip_minimum_height); + } refresh_header_bar_css(self); } @@ -2832,6 +2930,9 @@ static void my_application_dispose(GObject* object) { g_clear_pointer(&self->popover_shadow_color, g_free); g_clear_pointer(&self->menu_hover_color, g_free); g_clear_pointer(&self->modal_barrier_color, g_free); + g_clear_pointer(&self->tooltip_background_color, g_free); + g_clear_pointer(&self->tooltip_foreground_color, g_free); + g_clear_pointer(&self->tooltip_border_color, g_free); g_clear_pointer(&self->view_mode, g_free); g_clear_pointer(&self->search_query, g_free); g_clear_pointer(&self->foreground_color, g_free); @@ -2893,6 +2994,14 @@ static void my_application_init(MyApplication* self) { self->popover_shadow_color = nullptr; self->menu_hover_color = nullptr; self->modal_barrier_color = nullptr; + self->tooltip_background_color = nullptr; + self->tooltip_foreground_color = nullptr; + self->tooltip_border_color = nullptr; + self->tooltip_radius = kDefaultTooltipRadius; + self->tooltip_font_size = kDefaultTooltipFontSize; + self->tooltip_horizontal_padding = kDefaultTooltipHorizontalPadding; + self->tooltip_vertical_padding = kDefaultTooltipVerticalPadding; + self->tooltip_minimum_height = kDefaultTooltipMinimumHeight; self->sidebar_width = 300; self->sidebar_visible = TRUE; self->text_direction_rtl = FALSE; diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index c218337..e631eeb 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -635,6 +635,11 @@ void main() { await tester.pumpAndSettle(); expect(find.text(l10n.createWritersideProject), findsWidgets); + expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); + expect(find.byType(BusyMarkEditorHeader), findsOneWidget); + expect(find.byType(BusyMarkGroupedTextEntry), findsNWidgets(5)); + expect(find.byType(BusyMarkDialogShell), findsNothing); + expect(find.byType(BusyMarkDialogButton), findsNothing); final entries = find.byWidgetPredicate( (widget) => widget is EditableText && !widget.readOnly, @@ -1212,8 +1217,29 @@ void main() { expect(find.text(l10n.topicPlacement), findsOneWidget); expect(find.text(l10n.tocRoot), findsOneWidget); + expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); + expect(find.byType(BusyMarkGroupedTextEntry), findsNWidgets(2)); + expect( + find.byWidgetPredicate( + (widget) => widget is BusyMarkComboRow, + ), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (widget) => widget is BusyMarkComboRow, + ), + findsOneWidget, + ); + expect(find.byType(BusyMarkDialogShell), findsNothing); + expect(find.byType(SegmentedButton), findsNothing); - await tester.tap(find.widgetWithText(BusyMarkDialogButton, l10n.create)); + await tester.tap( + find.descendant( + of: find.byType(BusyMarkEditorHeader), + matching: find.widgetWithText(ElevatedButton, l10n.create), + ), + ); await tester.pump(const Duration(milliseconds: 300)); expect(controller.createdTopicRequest, isNotNull); @@ -1234,7 +1260,12 @@ void main() { expect(find.text(l10n.newTopic), findsOneWidget); await tester.tap(find.text(l10n.newTopic)); await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(find.widgetWithText(BusyMarkDialogButton, l10n.create)); + await tester.tap( + find.descendant( + of: find.byType(BusyMarkEditorHeader), + matching: find.widgetWithText(ElevatedButton, l10n.create), + ), + ); await tester.pump(const Duration(milliseconds: 300)); expect(controller.createdTopicTreePath, p.join(root.path, 'api.tree')); @@ -1279,7 +1310,12 @@ void main() { await tester.tap(find.text(l10n.newChildTopic)); await tester.pump(const Duration(milliseconds: 300)); expect(find.text(l10n.insideSelectedTopic), findsOneWidget); - await tester.tap(find.widgetWithText(BusyMarkDialogButton, l10n.create)); + await tester.tap( + find.descendant( + of: find.byType(BusyMarkEditorHeader), + matching: find.widgetWithText(ElevatedButton, l10n.create), + ), + ); await tester.pump(const Duration(milliseconds: 300)); expect( controller.createdTopicRequest!.placement, @@ -1293,7 +1329,12 @@ void main() { await tester.tap(find.text(l10n.newSiblingTopic)); await tester.pump(const Duration(milliseconds: 300)); expect(find.text(l10n.afterSelectedTopic), findsOneWidget); - await tester.tap(find.widgetWithText(BusyMarkDialogButton, l10n.create)); + await tester.tap( + find.descendant( + of: find.byType(BusyMarkEditorHeader), + matching: find.widgetWithText(ElevatedButton, l10n.create), + ), + ); await tester.pump(const Duration(milliseconds: 300)); expect( controller.createdTopicRequest!.placement, diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index b9d53dc..7f5d4f1 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -396,6 +396,78 @@ void main() { ); }); + testWidgets('desktop tooltips use one explicit natural-width geometry', ( + tester, + ) async { + Future<({Rect surface, Rect text})> measureTooltip({ + required Brightness brightness, + required String message, + }) async { + final tooltipKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + theme: buildBusyMarkTheme( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ), + home: Scaffold( + body: Center( + child: Tooltip( + key: tooltipKey, + message: message, + child: const SizedBox.square(dimension: 32), + ), + ), + ), + ), + ); + expect(tooltipKey.currentState!.ensureTooltipVisible(), isTrue); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final messageFinder = find.text(message); + expect(messageFinder, findsOneWidget); + final textRect = tester.getRect(messageFinder); + Rect? surfaceRect; + tester.element(messageFinder).visitAncestorElements((element) { + if (element.widget case final ConstrainedBox constrained + when constrained.constraints == BusyMarkTooltipStyle.constraints) { + final box = element.renderObject! as RenderBox; + surfaceRect = box.localToGlobal(Offset.zero) & box.size; + return false; + } + return true; + }); + expect(surfaceRect, isNotNull); + return (surface: surfaceRect!, text: textRect); + } + + for (final brightness in Brightness.values) { + final short = await measureTooltip( + brightness: brightness, + message: 'Main menu', + ); + final long = await measureTooltip( + brightness: brightness, + message: 'Show editing buttons', + ); + + for (final measurement in [short, long]) { + expect(measurement.surface.height, BusyMarkSizes.tooltipMinHeight); + expect( + measurement.surface.width, + moreOrLessEquals( + measurement.text.width + + (BusyMarkSpacing.tooltipHorizontal + BusyMarkStroke.hairline) * + 2, + epsilon: 0.01, + ), + ); + } + expect(long.surface.width, greaterThan(short.surface.width)); + } + }); + testWidgets( 'popup menu rows show shortcuts without redundant hover tooltips', (tester) async { @@ -619,7 +691,22 @@ void main() { ); expect(theme.visualDensity, base.visualDensity); expect(theme.splashFactory.runtimeType, base.splashFactory.runtimeType); - expect(theme.tooltipTheme, base.tooltipTheme); + final tooltipDecoration = theme.tooltipTheme.decoration! as BoxDecoration; + final tooltipBorder = tooltipDecoration.border! as Border; + expect(tooltipDecoration.color, BusyMarkTooltipStyle.background); + expect(tooltipDecoration.borderRadius, BusyMarkTooltipStyle.borderRadius); + expect(tooltipBorder.top.color, BusyMarkTooltipStyle.border); + expect( + theme.tooltipTheme.textStyle?.color, + BusyMarkTooltipStyle.foreground, + ); + expect( + theme.tooltipTheme.textStyle?.fontSize, + BusyMarkTypography.tooltipFontSize, + ); + expect(theme.tooltipTheme.padding, BusyMarkTooltipStyle.padding); + expect(theme.tooltipTheme.constraints, BusyMarkTooltipStyle.constraints); + expect(theme.tooltipTheme.waitDuration, BusyMarkMotion.tooltipWait); expect( theme.textTheme.bodyMedium?.fontFamily, base.textTheme.bodyMedium?.fontFamily, @@ -1486,6 +1573,50 @@ void main() { expect(find.text('Required'), findsOneWidget); }); + testWidgets('grouped text entry uses the native row-owned form surface', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), + home: Scaffold( + body: BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + label: 'Language', + controller: controller, + hintText: 'dart', + errorText: 'Required', + ), + ], + ), + ), + ), + ); + + expect(find.byType(YaruListTile), findsOneWidget); + expect(find.byType(TextFormField), findsOneWidget); + final field = tester.widget(find.byType(TextField)); + final decoration = field.decoration!; + expect(decoration.filled, isFalse); + expect(decoration.fillColor, Colors.transparent); + expect(decoration.hoverColor, Colors.transparent); + expect(decoration.border, InputBorder.none); + expect(decoration.enabledBorder, InputBorder.none); + expect(decoration.focusedBorder, InputBorder.none); + expect(decoration.contentPadding, EdgeInsets.zero); + expect(decoration.labelText, 'Language'); + expect(decoration.hintText, 'dart'); + expect(find.text('Required'), findsOneWidget); + }); + testWidgets('semantic standard icon button delegates to FilledButton.icon', ( tester, ) async { diff --git a/test/src/busymark_dialogs_test.dart b/test/src/busymark_dialogs_test.dart index 11d1540..3fc90e2 100644 --- a/test/src/busymark_dialogs_test.dart +++ b/test/src/busymark_dialogs_test.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:busymark/src/app/app_theme.dart'; +import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/app/busymark_dialogs.dart'; import 'package:busymark/src/app/busymark_shortcuts.dart'; import 'package:busymark/src/platform/linux_header_bar_service.dart'; @@ -286,6 +288,78 @@ void main() { [1, 0], ); }); + + testWidgets('open modal barrier follows live theme changes', (tester) async { + const accent = Color(0xFF3584E4); + final lightTheme = buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: accent, + ); + final darkTheme = buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: accent, + ); + final themeMode = ValueNotifier(ThemeMode.light); + addTearDown(themeMode.dispose); + late BuildContext hostContext; + + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: themeMode, + builder: (context, mode, child) { + return MaterialApp( + theme: lightTheme, + darkTheme: darkTheme, + themeMode: mode, + home: Builder( + builder: (context) { + hostContext = context; + return const SizedBox.shrink(); + }, + ), + ); + }, + ), + ); + + final result = showBusyMarkModalDialog( + hostContext, + builder: (context) => const Dialog(child: Text('Theme-aware dialog')), + ); + await tester.pumpAndSettle(); + + Color? currentBarrierColor() { + return tester + .widget(find.byType(AnimatedModalBarrier).last) + .color + .value; + } + + expect( + currentBarrierColor(), + lightTheme.extension()!.shade, + ); + + themeMode.value = ThemeMode.dark; + await tester.pumpAndSettle(); + + expect( + currentBarrierColor(), + darkTheme.extension()!.shade, + ); + + themeMode.value = ThemeMode.light; + await tester.pumpAndSettle(); + + expect( + currentBarrierColor(), + lightTheme.extension()!.shade, + ); + + Navigator.of(hostContext, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + await result; + }); } Future _pressControlShortcut( diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index ba164d3..da7bd44 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -8,6 +8,7 @@ import 'package:busymark/l10n/generated/app_localizations_en.dart'; import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/busymark_dialogs.dart'; import 'package:busymark/src/app/busymark_glyphs.dart'; import 'package:busymark/src/app/busymark_shortcuts.dart'; import 'package:busymark/src/editor/document_callout.dart'; @@ -32,6 +33,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:markdown/markdown.dart' as md; +import 'package:yaru/yaru.dart'; void main() { const parser = MarkdownParser(); @@ -3729,12 +3731,20 @@ void main() {} expect(find.text('Image'), findsOneWidget); expect(find.text('Apply'), findsOneWidget); - expect(find.byType(BusyMarkDialogShell), findsOneWidget); - expect(find.byType(BusyMarkFloatingTextEntry), findsNWidgets(2)); + expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); + expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); + expect(find.byType(BusyMarkEditorHeader), findsOneWidget); + expect(find.byType(BusyMarkGroupedList), findsOneWidget); + expect(find.byType(BusyMarkGroupedTextEntry), findsNWidgets(2)); expect(find.byType(TextFormField), findsNWidgets(2)); - expect(find.byType(BusyMarkDialogButton), findsNWidgets(3)); + expect(find.byType(YaruListTile), findsNWidgets(2)); + expect(find.byType(BusyMarkDialogShell), findsNothing); + expect(find.byType(BusyMarkDialogButton), findsNothing); + expect(find.byType(YaruDialogTitleBar), findsNothing); expect(find.byType(AlertDialog), findsNothing); - final dialogRect = tester.getRect(find.byType(BusyMarkDialogTitleBar)); + final dialogRect = tester.getRect( + find.byType(BusyMarkModalEditorScaffold), + ); final sourceEntryRect = tester.getRect( find.byKey(BusyMarkImageDialogKeys.source), ); @@ -3753,10 +3763,8 @@ void main() {} altEntryRect.left - dialogRect.left, closeTo(BusyMarkSpacing.lg, 0.1), ); - expect( - dialogRect.right - chooseRect.right, - closeTo(BusyMarkSpacing.lg, 0.1), - ); + expect(chooseRect.right, lessThan(sourceEntryRect.right)); + expect(chooseRect.center.dy, closeTo(sourceEntryRect.center.dy, 0.1)); final sourceField = find.descendant( of: find.byKey(BusyMarkImageDialogKeys.source), matching: find.byType(EditableText), diff --git a/test/src/header_bar_configuration_test.dart b/test/src/header_bar_configuration_test.dart index 644cbdd..2b0d7a6 100644 --- a/test/src/header_bar_configuration_test.dart +++ b/test/src/header_bar_configuration_test.dart @@ -396,7 +396,7 @@ void main() { }, ); - test('theme map contains only native header structure roles', () { + test('theme map contains the native semantic visual roles', () { expect(_theme.toMap().keys, { 'preferDark', 'backgroundColor', @@ -407,6 +407,7 @@ void main() { 'menuHoverColor', 'popoverShadowColor', 'modalBarrierColor', + 'tooltip', }); expect( _theme.toMap(), @@ -416,6 +417,16 @@ void main() { _theme.toMap(), containsPair('foregroundColor', 'rgba(32,32,32,1.000)'), ); + expect(_theme.toMap(), containsPair('tooltip', _tooltipTheme.toMap())); + expect( + _tooltipTheme.toMap(), + containsPair('backgroundColor', 'rgba(0,0,0,0.800)'), + ); + expect(_tooltipTheme.toMap(), containsPair('borderRadius', 8.0)); + expect(_tooltipTheme.toMap(), containsPair('fontSize', 14.0)); + expect(_tooltipTheme.toMap(), containsPair('horizontalPadding', 10.0)); + expect(_tooltipTheme.toMap(), containsPair('verticalPadding', 6.0)); + expect(_tooltipTheme.toMap(), containsPair('minimumHeight', 30.0)); }); } @@ -491,4 +502,16 @@ const _theme = HeaderBarTheme( menuHoverColor: Color(0x16000000), popoverShadowColor: Color(0x4D000000), modalBarrierColor: Color(0x55000000), + tooltip: _tooltipTheme, +); + +const _tooltipTheme = HeaderBarTooltipTheme( + backgroundColor: Color.fromRGBO(0, 0, 0, 0.8), + foregroundColor: Color(0xFFFFFFFF), + borderColor: Color.fromRGBO(255, 255, 255, 0.1), + borderRadius: 8, + fontSize: 14, + horizontalPadding: 10, + verticalPadding: 6, + minimumHeight: 30, ); diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 3d471d8..c1b9bba 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -476,13 +476,32 @@ void main() { expect(script, contains('--skip-bundled-git')); }); - test('native headerbar delegates tooltip appearance to GTK', () { + test('native headerbar uses the shared tooltip visuals', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); expect(native, contains('gtk_widget_set_tooltip_text')); - expect(native, isNot(contains('"tooltip, tooltip.background {"'))); - expect(native, isNot(contains('"tooltip > box'))); - expect(native, isNot(contains('"tooltip label {"'))); + expect(native, contains('kDefaultTooltipBackground')); + expect(native, contains('kDefaultTooltipForeground')); + expect(native, contains('kDefaultTooltipBorder')); + expect(native, contains('kDefaultTooltipRadius')); + expect(native, contains('"tooltip,"')); + expect(native, contains('"tooltip.background {"')); + expect(native, contains('"tooltip decoration,"')); + expect(native, contains('"tooltip.csd decoration {"')); + expect(native, contains('"tooltip > box,"')); + expect(native, contains('"tooltip label {"')); + expect(native, contains('"border-radius: %.2fpx;"')); + expect(native, contains('fl_lookup_map_arg(args, "tooltip")')); + expect(native, contains('"backgroundColor"')); + expect(native, contains('"foregroundColor"')); + expect(native, contains('"borderColor"')); + expect(native, contains('"borderRadius"')); + expect(native, contains('"fontSize"')); + expect(native, contains('"horizontalPadding"')); + expect(native, contains('"verticalPadding"')); + expect(native, contains('"minimumHeight"')); + expect(native, contains('"font-family: Ubuntu;"')); + expect(native, contains('"font-weight: 400;"')); }); test( @@ -575,11 +594,9 @@ void main() { expect(css, contains('popover.background.')); expect(css, contains('modelbutton:hover:not(:disabled)')); expect(css, contains('box-shadow: 0 1px 3px')); - for (final interactionSelector in [ - 'tooltip', - ':focus', - '@define-color', - ]) { + expect(css, contains('tooltip.background')); + expect(css, contains('tooltip decoration')); + for (final interactionSelector in [':focus', '@define-color']) { expect(css, isNot(contains(interactionSelector))); } }); @@ -598,7 +615,7 @@ void main() { expect(configuration, contains('foregroundColor: colors.foreground')); expect(configuration, contains('popoverBackgroundColor: colors.popover')); expect(configuration, contains('menuHoverColor: colors.controlHover')); - expect(configuration, isNot(contains('borderColor'))); + expect(configuration, isNot(contains('borderColor: colors.'))); expect(configuration, isNot(contains('floatingBorderColor'))); expect(native, contains('kDefaultHeaderbarBackground[] = "#272727"')); expect(native, contains('kDefaultSidebarBackground[] = "#393939"')); @@ -916,7 +933,11 @@ void main() { expect(native, isNot(contains('gtk_widget_set_app_paintable'))); expect(native, isNot(contains('CAIRO_OPERATOR_CLEAR'))); expect(native, isNot(contains('#include '))); - expect(native, isNot(contains('"border-radius:'))); + expect( + RegExp(r'"border-radius:').allMatches(native), + hasLength(2), + reason: 'Only the tooltip surface and native window clip own a radius', + ); }, ); diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index d3a72af..8808853 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -247,14 +247,20 @@ void main() { expect(workspace, contains('BusyMarkPushButton.standardIcon(')); expect(workspace, isNot(contains('FilledButton.icon('))); - expect(createTopicDialog, contains('BusyMarkFloatingTextEntryGroup(')); + expect(createTopicDialog, contains('BusyMarkModalEditorScaffold(')); expect( RegExp( - r'BusyMarkFloatingTextEntry\(', + r'BusyMarkGroupedTextEntry\(', ).allMatches(createTopicDialog).length, 2, ); - expect(createTopicDialog, isNot(contains('TextField('))); + expect( + RegExp(r'BusyMarkComboRow<').allMatches(createTopicDialog).length, + 2, + ); + expect(createTopicDialog, isNot(contains('BusyMarkDialogShell('))); + expect(createTopicDialog, isNot(contains('SegmentedButton<'))); + expect(createTopicDialog, isNot(contains('BusyMarkFloatingTextEntry'))); expect(createTopicDialog, isNot(contains('InputDecoration('))); }); @@ -1099,7 +1105,7 @@ void main() { ); expect( workspace, - contains('BusyMarkPopupSelector'), + contains('BusyMarkComboRow'), ); final selector = RegExp( r'class BusyMarkPopupSelector[\s\S]*?' @@ -1165,49 +1171,45 @@ void main() { ); }); - test( - 'dialog text entries delegate input behavior and geometry to Flutter', - () { - final design = File( - 'lib/src/app/busymark_design.dart', - ).readAsStringSync(); - final welcome = File( - 'lib/src/workspace/presentation/welcome_screen.dart', - ).readAsStringSync(); + test('data-entry dialogs use native grouped modal editors', () { + final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); + final welcome = File( + 'lib/src/workspace/presentation/welcome_screen.dart', + ).readAsStringSync(); + final workspace = File( + 'lib/src/workspace/presentation/workspace_screen.dart', + ).readAsStringSync(); + final editor = File( + 'lib/src/editor/wysiwyg/wysiwyg_editor.dart', + ).readAsStringSync(); - expect(design, contains('class BusyMarkFloatingTextEntry')); - expect(design, contains('class BusyMarkFloatingTextEntryGroup')); - final entries = RegExp( - r'class BusyMarkFloatingTextEntryGroup[\s\S]*?class SectionLabel', - ).firstMatch(design)!.group(0)!; - expect(entries, contains('return AutofillGroup(')); - expect(entries, contains('return TextFormField(')); - expect(entries, contains('InputDecoration(')); - expect(entries, contains('labelText: label')); - expect(entries, contains('errorText: errorText')); - expect(entries, isNot(contains('EditableText('))); - expect(entries, isNot(contains('MouseRegion('))); - expect(entries, isNot(contains('GestureDetector('))); - expect(entries, isNot(contains('busyMarkSurfaceDecoration('))); - expect(entries, isNot(contains('AnimatedPositionedDirectional('))); - expect(entries, isNot(contains('groupPosition'))); - expect(design, isNot(contains('class BusyMarkDialogTextEntry'))); - expect(welcome, contains('BusyMarkFloatingTextEntryGroup(')); - expect( - RegExp(r'BusyMarkFloatingTextEntryGroup\(').allMatches(welcome).length, - 2, - ); - expect(welcome, isNot(contains('BusyMarkFloatingTextEntryPosition'))); - expect(welcome, isNot(contains('groupPosition:'))); - expect(welcome, contains('BusyMarkFloatingTextEntry(')); - expect(welcome, isNot(contains('BusyMarkDialogTextEntry('))); - expect(welcome, isNot(contains('autofocus: true'))); - expect( - welcome, - isNot(contains('hintText: context.l10n.defaultProjectName')), - ); - }, - ); + expect(design, contains('class BusyMarkGroupedTextEntry')); + final entries = RegExp( + r'class BusyMarkGroupedTextEntry[\s\S]*?class BusyMarkClamp', + ).firstMatch(design)!.group(0)!; + expect(entries, contains('return YaruListTile.square(')); + expect(entries, contains('title: TextFormField(')); + expect(entries, contains('busyMarkGroupedTextFieldDecoration(')); + expect(entries, contains('trailing: trailing')); + expect(entries, isNot(contains('EditableText('))); + expect(entries, isNot(contains('MouseRegion('))); + expect(entries, isNot(contains('GestureDetector('))); + expect(entries, isNot(contains('busyMarkSurfaceDecoration('))); + expect(welcome, contains('showBusyMarkModalEditorDialog(')); + expect(welcome, contains('BusyMarkModalEditorScaffold(')); + expect(RegExp(r'BusyMarkGroupedTextEntry\(').allMatches(welcome).length, 5); + expect(workspace, contains('showBusyMarkModalEditorDialog(')); + expect(workspace, contains('showBusyMarkModalEditorDialog(')); + expect(editor, contains('showBusyMarkModalEditorDialog(')); + expect(welcome, isNot(contains('BusyMarkFloatingTextEntry'))); + expect(workspace, isNot(contains('BusyMarkFloatingTextEntry'))); + expect(editor, isNot(contains('BusyMarkFloatingTextEntry'))); + expect(welcome, isNot(contains('autofocus: true'))); + expect( + welcome, + isNot(contains('hintText: context.l10n.defaultProjectName')), + ); + }); test('sidebar trees share the expandable Yaru-style row', () { final workspace = File( @@ -1657,11 +1659,15 @@ void main() { expect(editor, contains('_handleImageBlockEditRequested')); expect(editor, contains('initialSource: _imageSourceForBlock(block)')); expect(editor, contains('submitLabel: context.l10n.apply')); - expect(imageDialog, contains('BusyMarkDialogShell(')); - expect(imageDialog, contains('BusyMarkFloatingTextEntry(')); - expect(imageDialog, contains('BusyMarkDialogButton(')); + expect(imageDialog, contains('BusyMarkModalEditorScaffold(')); + expect(imageDialog, contains('BusyMarkGroupedList(')); + expect(imageDialog, contains('BusyMarkGroupedTextEntry(')); + expect(imageDialog, contains('BusyMarkPushButton.standardIcon(')); expect(imageDialog, contains("hintText: 'images/example.png'")); expect(imageDialog, contains('hintText: context.l10n.describeTheImage')); + expect(imageDialog, isNot(contains('BusyMarkDialogShell('))); + expect(imageDialog, isNot(contains('BusyMarkFloatingTextEntry('))); + expect(imageDialog, isNot(contains('BusyMarkDialogButton('))); expect(imageDialog, isNot(contains('AlertDialog('))); expect(imageDialog, isNot(contains('TextField('))); expect(imageDialog, isNot(contains('InputDecoration('))); diff --git a/test/src/wysiwyg_rtl_test.dart b/test/src/wysiwyg_rtl_test.dart index 2098ad8..f2c81a8 100644 --- a/test/src/wysiwyg_rtl_test.dart +++ b/test/src/wysiwyg_rtl_test.dart @@ -1,5 +1,6 @@ import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/busymark_dialogs.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; import 'package:busymark/src/markdown/busymark_document.dart'; import 'package:busymark/src/markdown/markdown_model.dart'; @@ -271,9 +272,7 @@ void main() { ); expect( tester - .widget( - find.byKey(BusyMarkImageDialogKeys.submit), - ) + .widget(find.byKey(BusyMarkImageDialogKeys.submit)) .onPressed, isNull, ); @@ -281,9 +280,7 @@ void main() { await tester.pump(); expect( tester - .widget( - find.byKey(BusyMarkImageDialogKeys.submit), - ) + .widget(find.byKey(BusyMarkImageDialogKeys.submit)) .onPressed, isNotNull, ); @@ -314,6 +311,13 @@ void main() { matching: find.byType(TextField), ), ); + expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); + expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); + expect(find.byType(BusyMarkEditorHeader), findsOneWidget); + expect(find.byType(BusyMarkGroupedList), findsOneWidget); + expect(find.byType(BusyMarkGroupedTextEntry), findsOneWidget); + expect(find.byType(BusyMarkDialogShell), findsNothing); + expect(find.byType(BusyMarkDialogButton), findsNothing); expect(languageField.textDirection, TextDirection.ltr); Navigator.of( tester.element(find.byKey(const ValueKey('wysiwyg-code-language-field'))), From 70e123fa15a18a9ec15b0e1f1a61ab8d5eb97e65 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 20:15:16 -0700 Subject: [PATCH 19/29] Update localization descriptions for discard buttons to clarify functionality. Distinguish between discard and cancel actions for better user understanding. --- lib/l10n/app_en.arb | 4 ++-- lib/l10n/generated/app_localizations.dart | 4 ++-- test/src/localization_audit_test.dart | 25 +++++++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fdb7a78..62df6b7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -106,7 +106,7 @@ "delete": "Delete", "@delete": {"description": "Delete command label."}, "discard": "Discard", - "@discard": {"description": "Discard unsaved changes button label."}, + "@discard": {"description": "Destructive button that throws away unsaved editor changes. Translate it distinctly from Cancel."}, "editor": "Editor", "@editor": {"description": "Editor view label."}, "file": "File", @@ -442,7 +442,7 @@ "closeUnsavedChangesCancel": "Cancel", "@closeUnsavedChangesCancel": {"description":"Cancel button label in the window close unsaved-changes dialog."}, "closeUnsavedChangesDiscard": "Discard", - "@closeUnsavedChangesDiscard": {"description":"Discard button label in the window close unsaved-changes dialog."}, + "@closeUnsavedChangesDiscard": {"description":"Destructive button that closes BusyMark without saving editor changes. Translate it distinctly from Cancel."}, "closeUnsavedChangesSave": "Save", "@closeUnsavedChangesSave": {"description":"Save button label in the window close unsaved-changes dialog."}, "currentFile": "current file", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index ceeaeb5..651a761 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -429,7 +429,7 @@ abstract class AppLocalizations { /// **'Delete'** String get delete; - /// Discard unsaved changes button label. + /// Destructive button that throws away unsaved editor changes. Translate it distinctly from Cancel. /// /// In en, this message translates to: /// **'Discard'** @@ -1407,7 +1407,7 @@ abstract class AppLocalizations { /// **'Cancel'** String get closeUnsavedChangesCancel; - /// Discard button label in the window close unsaved-changes dialog. + /// Destructive button that closes BusyMark without saving editor changes. Translate it distinctly from Cancel. /// /// In en, this message translates to: /// **'Discard'** diff --git a/test/src/localization_audit_test.dart b/test/src/localization_audit_test.dart index bac6b4c..1594cf2 100644 --- a/test/src/localization_audit_test.dart +++ b/test/src/localization_audit_test.dart @@ -187,6 +187,31 @@ void main() { expect(failures, isEmpty, reason: failures.join('\n')); }); + test('unsaved-changes discard actions are distinct from cancel', () { + for (final locale in AppLocalizations.supportedLocales) { + final localizations = lookupAppLocalizations(locale); + expect( + localizations.discard, + isNot(localizations.cancel), + reason: + '${locale.toLanguageTag()} must not translate Discard as Cancel', + ); + expect( + localizations.closeUnsavedChangesDiscard, + isNot(localizations.closeUnsavedChangesCancel), + reason: + '${locale.toLanguageTag()} must not translate window-close ' + 'Discard as Cancel', + ); + } + + final russian = lookupAppLocalizations(const Locale('ru')); + expect(russian.discard, 'Не сохранять'); + expect(russian.closeUnsavedChangesDiscard, 'Не сохранять'); + expect(russian.unsavedChanges, 'Несохранённые изменения'); + expect(russian.closeUnsavedChangesTitle, 'Несохранённые изменения'); + }); + test('English-identical target messages are explicitly reviewed', () { final english = _arbMessageStrings(File('lib/l10n/app_en.arb')); final failures = []; From 58794806a95da7856b6cbe822cfcce6b7af7acb4 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 20:39:20 -0700 Subject: [PATCH 20/29] Refactor WYSIWYG editor and workspace controller to improve file handling. Remove hint text in editor and enhance new file creation flow with view mode handling for better user experience. --- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 1 - lib/src/workspace/workspace_controller.dart | 18 +++++++- test/src/app_smoke_test.dart | 38 ++++++++++++++++ test/src/workspace_controller_test.dart | 49 +++++++++++++++++++++ test/src/wysiwyg_rtl_test.dart | 1 + 5 files changed, 105 insertions(+), 2 deletions(-) diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index c8b43b9..f45ef6a 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -2260,7 +2260,6 @@ class _BusyMarkWysiwygEditorState extends State { fontFamily: BusyMarkTypography.monoFontFamily, fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, ), - hintText: 'dart', onSubmitted: (value) => Navigator.pop(context, value), ), ], diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 3820aca..5b268fe 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -148,6 +148,7 @@ class WorkspaceController extends Notifier { _autoSaveDebounce?.cancel(); _invalidateActiveDocumentOperations(); _resetSaveTracking(dirty: true); + final viewModeChange = _showEditorForNewFile(); final workspace = _service.createUntitledMarkdown(); state = WorkspaceState( workspace: workspace, @@ -155,6 +156,7 @@ class WorkspaceController extends Notifier { isDirty: true, isLoading: false, ); + await viewModeChange; } Future openPath(String path) async { @@ -370,9 +372,13 @@ class WorkspaceController extends Notifier { String directoryPath, String fileName, ) async { - return _runWorkspaceFileOperation((workspace) async { + final created = await _runWorkspaceFileOperation((workspace) async { return _service.createFile(workspace, directoryPath, fileName); }); + if (created) { + await _showEditorForNewFile(); + } + return created; } Future renameWorkspaceEntity(String path, String newName) async { @@ -1279,6 +1285,16 @@ class WorkspaceController extends Notifier { } } + Future _showEditorForNewFile() { + if (_settingsController.state.documentViewMode != + DocumentViewModePreference.preview) { + return Future.value(); + } + return _settingsController.setDocumentViewMode( + DocumentViewModePreference.editor, + ); + } + void _scheduleAutoSave() { _autoSaveDebounce?.cancel(); if (!_settingsController.state.autoSave || !_canAutoSaveActive()) { diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index e631eeb..3f8d083 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -4327,6 +4327,44 @@ Draft paragraph. ); }); + testWidgets('new empty document leaves remembered preview mode for editor', ( + tester, + ) async { + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(documentViewMode: DocumentViewModePreference.preview) + .toJson(); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(_StartupWorkspaceService()), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text(l10n.createMarkdownFile)); + await tester.pumpAndSettle(); + + expect( + container.read(appSettingsControllerProvider).documentViewMode, + DocumentViewModePreference.editor, + ); + expect( + find.byKey(const ValueKey('wysiwyg-document-scroll')), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('preview-document-scroll')), findsNothing); + }); + testWidgets('blocked remote image prompt allows the current workspace', ( tester, ) async { diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index b17c7d9..e05dc2f 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -75,6 +75,9 @@ void main() { final controller = harness.controller; await controller.openPath(directory.path); + await settingsController.setDocumentViewMode( + DocumentViewModePreference.preview, + ); final created = p.join(directory.path, 'new.md'); expect( @@ -83,6 +86,10 @@ void main() { ); expect(File(created).existsSync(), isTrue); expect(controller.state.workspace?.activeFilePath, created); + expect( + settingsController.state.documentViewMode, + DocumentViewModePreference.editor, + ); final renamed = p.join(directory.path, 'renamed.md'); expect( @@ -132,6 +139,45 @@ void main() { }, ); + test('new Markdown files leave preview mode for editor mode', () async { + final harness = await _createControllerHarness(); + final settingsController = harness.settingsController; + final controller = harness.controller; + + await settingsController.setDocumentViewMode( + DocumentViewModePreference.preview, + ); + await controller.createMarkdownFile(); + + expect( + settingsController.state.documentViewMode, + DocumentViewModePreference.editor, + ); + + controller.dispose(); + settingsController.dispose(); + }); + + test('new Markdown files preserve non-preview view modes', () async { + for (final mode in [ + DocumentViewModePreference.editor, + DocumentViewModePreference.source, + DocumentViewModePreference.split, + ]) { + final harness = await _createControllerHarness(); + final settingsController = harness.settingsController; + final controller = harness.controller; + + await settingsController.setDocumentViewMode(mode); + await controller.createMarkdownFile(); + + expect(settingsController.state.documentViewMode, mode); + + controller.dispose(); + settingsController.dispose(); + } + }); + test('discarding an untitled Markdown file clears the workspace', () async { final harness = await _createControllerHarness(); final settingsController = harness.settingsController; @@ -1041,6 +1087,9 @@ class _AppSettingsControllerDriver { AppSettings get state => _container.read(appSettingsControllerProvider); + Future setDocumentViewMode(DocumentViewModePreference mode) => + _notifier.setDocumentViewMode(mode); + Future setValidateOnEdit(bool enabled) => _notifier.setValidateOnEdit(enabled); diff --git a/test/src/wysiwyg_rtl_test.dart b/test/src/wysiwyg_rtl_test.dart index f2c81a8..b1a2cc9 100644 --- a/test/src/wysiwyg_rtl_test.dart +++ b/test/src/wysiwyg_rtl_test.dart @@ -319,6 +319,7 @@ void main() { expect(find.byType(BusyMarkDialogShell), findsNothing); expect(find.byType(BusyMarkDialogButton), findsNothing); expect(languageField.textDirection, TextDirection.ltr); + expect(languageField.decoration?.hintText, isNull); Navigator.of( tester.element(find.byKey(const ValueKey('wysiwyg-code-language-field'))), ).pop(); From c22e8ffb46e95817496150ce1ef58c32a1b5b894 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 21:52:11 -0700 Subject: [PATCH 21/29] Bump version to 0.2.3 and update metadata across application files. Enhance tooltip CSS for improved styling and consistency. --- lib/src/app/app_metadata.dart | 2 +- linux/io.busystack.busymark.metainfo.xml | 2 +- linux/runner/my_application.cc | 42 +++++++++++++++-------- pubspec.yaml | 2 +- snap/snapcraft.yaml | 2 +- test/src/native_headerbar_audit_test.dart | 15 ++++++-- 6 files changed, 45 insertions(+), 20 deletions(-) diff --git a/lib/src/app/app_metadata.dart b/lib/src/app/app_metadata.dart index 453c336..94760b3 100644 --- a/lib/src/app/app_metadata.dart +++ b/lib/src/app/app_metadata.dart @@ -1 +1 @@ -const busyMarkAppVersion = '0.2.2'; +const busyMarkAppVersion = '0.2.3'; diff --git a/linux/io.busystack.busymark.metainfo.xml b/linux/io.busystack.busymark.metainfo.xml index 27d7c1d..318b33d 100644 --- a/linux/io.busystack.busymark.metainfo.xml +++ b/linux/io.busystack.busymark.metainfo.xml @@ -63,7 +63,7 @@ https://github.com/busystack/busymark/issues - + diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 22e1944..665b381 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -35,6 +35,11 @@ constexpr gdouble kDefaultTooltipFontSize = 14.0; constexpr gdouble kDefaultTooltipHorizontalPadding = 10.0; constexpr gdouble kDefaultTooltipVerticalPadding = 6.0; constexpr gdouble kDefaultTooltipMinimumHeight = 30.0; +constexpr gdouble kTooltipBorderWidth = 1.0; +// GtkTooltipWindow applies a private GtkContainer border-width of 6 px around +// its content. Compensate for it so native header hints have the same visible +// border-to-text padding as Flutter tooltips. +constexpr gdouble kGtkTooltipContainerInset = 6.0; // Yaru GTK 3 adds a zero-blur 23%/75% black ring around CSD windows. Current // Ubuntu apps retain the diffuse shadow without that legacy hard edge. Reuse // Yaru's geometry here; Handy continues to own clipping, radii, and states. @@ -686,18 +691,31 @@ static void refresh_header_bar_css(MyApplication* self) { self->tooltip_foreground_color, kDefaultTooltipForeground); const gchar* tooltip_border = css_color_or(self->tooltip_border_color, kDefaultTooltipBorder); + const gdouble tooltip_label_horizontal_padding = std::max( + 0.0, self->tooltip_horizontal_padding - + (kGtkTooltipContainerInset - kTooltipBorderWidth)); + const gdouble tooltip_label_vertical_padding = std::max( + 0.0, self->tooltip_vertical_padding - + (kGtkTooltipContainerInset - kTooltipBorderWidth)); + const gdouble tooltip_label_minimum_height = std::max( + 0.0, self->tooltip_minimum_height - + kGtkTooltipContainerInset * 2 - + tooltip_label_vertical_padding * 2); g_autofree gchar* tooltip_css = g_strdup_printf( "tooltip," - "tooltip.background {" + "tooltip.background," + "tooltip box," + "tooltip.background box {" "margin: 0;" "padding: 0;" - "min-height: %.2fpx;" + "min-width: 0;" + "min-height: 0;" "}" "tooltip.background {" "background-color: %s;" "background-image: none;" "background-clip: padding-box;" - "border: 1px solid %s;" + "border: %.2fpx solid %s;" "border-radius: %.2fpx;" "}" "tooltip decoration," @@ -706,28 +724,24 @@ static void refresh_header_bar_css(MyApplication* self) { "border-radius: %.2fpx;" "box-shadow: none;" "}" - "tooltip > box," - "tooltip.background > box {" - "margin: 0;" - "padding: 0;" - "min-height: 0;" - "}" "tooltip * {" "background-color: transparent;" "color: %s;" "}" - "tooltip label {" + "tooltip label," + "tooltip.background label {" "margin: 0;" "padding: %.2fpx %.2fpx;" - "min-height: 0;" + "min-width: 0;" + "min-height: %.2fpx;" "font-family: Ubuntu;" "font-size: %.2fpx;" "font-weight: 400;" "}", - self->tooltip_minimum_height, tooltip_background, tooltip_border, + tooltip_background, kTooltipBorderWidth, tooltip_border, self->tooltip_radius, self->tooltip_radius, tooltip_foreground, - self->tooltip_vertical_padding, self->tooltip_horizontal_padding, - self->tooltip_font_size); + tooltip_label_vertical_padding, tooltip_label_horizontal_padding, + tooltip_label_minimum_height, self->tooltip_font_size); g_autofree gchar* header_focus_css = g_strdup_printf( ".busymark-titlebar.%s .busymark-sidebar-header label," ".busymark-titlebar.%s .busymark-header-title {" diff --git a/pubspec.yaml b/pubspec.yaml index 7de1dcf..26aef35 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymark description: Local-first Markdown and Writerside-compatible documentation editor. publish_to: 'none' -version: 0.2.2 +version: 0.2.3 environment: sdk: ^3.12.1 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 627a2ed..2c2bf47 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymark title: BusyMark -version: "0.2.2" +version: "0.2.3" summary: Markdown and Writerside documentation editor # Snap Store listing translations are managed outside this Flutter package. # Update store metadata when approved translated listing text is supplied. diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index c1b9bba..3095a52 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -484,13 +484,24 @@ void main() { expect(native, contains('kDefaultTooltipForeground')); expect(native, contains('kDefaultTooltipBorder')); expect(native, contains('kDefaultTooltipRadius')); + expect(native, contains('kTooltipBorderWidth')); + expect(native, contains('kGtkTooltipContainerInset')); expect(native, contains('"tooltip,"')); expect(native, contains('"tooltip.background {"')); expect(native, contains('"tooltip decoration,"')); expect(native, contains('"tooltip.csd decoration {"')); - expect(native, contains('"tooltip > box,"')); - expect(native, contains('"tooltip label {"')); + expect(native, contains('"tooltip box,"')); + expect(native, contains('"tooltip.background box {"')); + expect(native, contains('"tooltip label,"')); + expect(native, contains('"tooltip.background label {"')); + expect(native, contains('"min-width: 0;"')); expect(native, contains('"border-radius: %.2fpx;"')); + expect(native, contains('tooltip_label_horizontal_padding')); + expect(native, contains('tooltip_label_vertical_padding')); + expect(native, contains('tooltip_label_minimum_height')); + expect(native, contains('self->tooltip_minimum_height -')); + expect(native, contains('kGtkTooltipContainerInset * 2')); + expect(native, contains('tooltip_label_vertical_padding * 2')); expect(native, contains('fl_lookup_map_arg(args, "tooltip")')); expect(native, contains('"backgroundColor"')); expect(native, contains('"foregroundColor"')); From 8bc7fc580078bfd5baf3256deef8a1db2486fc61 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 21:55:48 -0700 Subject: [PATCH 22/29] Add BusyMarkDestructiveButtonStyle for consistent styling of destructive buttons in dark theme --- lib/src/app/busymark_design.dart | 21 ++++++++- test/src/busymark_design_test.dart | 4 +- test/src/workspace_safety_test.dart | 73 ++++++++++++++++++++++++++++- 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index b80f455..8c02bcf 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -406,6 +406,23 @@ abstract final class BusyMarkTooltipStyle { ); } +/// Filled destructive actions use Yaru's dark red button treatment. +/// +/// The dark theme's generic error role is intentionally a light tint with +/// black content, which is appropriate for error text but not for destructive +/// push buttons. +abstract final class BusyMarkDestructiveButtonStyle { + static Color background(ThemeData theme) => + theme.brightness == Brightness.dark + ? BusyMarkLinuxPalette.red + : theme.colorScheme.error; + + static Color foreground(ThemeData theme) => + theme.brightness == Brightness.dark + ? BusyMarkLinuxPalette.white + : theme.colorScheme.onError; +} + @immutable class BusyMarkSyntaxColors extends ThemeExtension { const BusyMarkSyntaxColors({ @@ -3241,11 +3258,11 @@ ButtonStyle _destructiveButtonStyle(BuildContext context) { Color? background(Set states) => states.contains(WidgetState.disabled) ? colors.disabledControl - : theme.colorScheme.error; + : BusyMarkDestructiveButtonStyle.background(theme); Color? foreground(Set states) => states.contains(WidgetState.disabled) ? colors.disabledForeground - : theme.colorScheme.onError; + : BusyMarkDestructiveButtonStyle.foreground(theme); return ButtonStyle( backgroundColor: WidgetStateProperty.resolveWith(background), foregroundColor: WidgetStateProperty.resolveWith(foreground), diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index 7f5d4f1..befebdf 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -1249,11 +1249,11 @@ void main() { ); expect( destructive.style?.foregroundColor?.resolve({}), - theme.colorScheme.onError, + BusyMarkDestructiveButtonStyle.foreground(theme), ); expect( destructive.style?.backgroundColor?.resolve({}), - theme.colorScheme.error, + BusyMarkDestructiveButtonStyle.background(theme), ); final menuFinder = find.descendant( diff --git a/test/src/workspace_safety_test.dart b/test/src/workspace_safety_test.dart index 67909b8..c6bc1b7 100644 --- a/test/src/workspace_safety_test.dart +++ b/test/src/workspace_safety_test.dart @@ -177,12 +177,81 @@ void main() { final foreground = button.style?.foregroundColor?.resolve({}); final background = button.style?.backgroundColor?.resolve({}); - expect(foreground, isNotNull); - expect(background, isNotNull); + expect(foreground, BusyMarkDestructiveButtonStyle.foreground(theme)); + expect(background, BusyMarkDestructiveButtonStyle.background(theme)); expect(_contrastRatio(foreground!, background!), greaterThanOrEqualTo(4.5)); expect(button.style?.iconColor?.resolve({}), foreground); }); + testWidgets( + 'unsaved changes Discard action renders white in the dark dialog', + (tester) async { + late WidgetRef widgetRef; + await tester.pumpWidget( + ProviderScope( + overrides: [ + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore(), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: Colors.green, + ), + home: Scaffold( + body: Consumer( + builder: (context, ref, child) { + widgetRef = ref; + return TextButton( + onPressed: () => confirmSafeToContinue(context, ref), + child: const Text('Navigate'), + ); + }, + ), + ), + ), + ), + ); + + final controller = widgetRef.read(workspaceControllerProvider.notifier); + await tester.runAsync(() async { + await controller.openPath('test/fixtures/markdown/other.md'); + controller.updateActiveText('# Dirty\n'); + }); + + await tester.tap(find.text('Navigate')); + await tester.pumpAndSettle(); + + final discardButton = find.widgetWithText( + BusyMarkDialogButton, + l10n.discard, + ); + final elevated = tester.widget( + find.descendant( + of: discardButton, + matching: find.byType(ElevatedButton), + ), + ); + expect( + elevated.style?.foregroundColor?.resolve({}), + BusyMarkLinuxPalette.white, + ); + expect( + elevated.style?.backgroundColor?.resolve({}), + BusyMarkLinuxPalette.red, + ); + expect( + DefaultTextStyle.of( + tester.element(find.text(l10n.discard)), + ).style.color, + BusyMarkLinuxPalette.white, + ); + }, + ); + testWidgets( 'overwrite confirmation stays pinned to the document being saved', (tester) async { From 69eeb476b675738b4332a0b84cc75da1d33a0999 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 23:21:55 -0700 Subject: [PATCH 23/29] Refactor gesture handling in WYSIWYG editor and related components to use onSecondaryTapUp for improved touch interaction consistency --- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 2 +- .../git/presentation/git_history_view.dart | 2 +- .../platform/header_bar_configuration.dart | 5 +- .../presentation/workspace_screen.dart | 26 +- linux/runner/my_application.cc | 239 +++++++++++++++--- test/src/native_headerbar_audit_test.dart | 96 ++++++- test/src/source_audit_test.dart | 19 +- 7 files changed, 339 insertions(+), 50 deletions(-) diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index f45ef6a..327b6c1 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -3031,7 +3031,7 @@ class _FloatingWysiwygToolbar extends StatelessWidget { onPlacementChanged != null || onDirectionChanged != null; final toggle = GestureDetector( behavior: HitTestBehavior.opaque, - onSecondaryTapDown: configurable + onSecondaryTapUp: configurable ? (details) => unawaited(_showSettingsMenu(context, details.globalPosition)) : null, diff --git a/lib/src/git/presentation/git_history_view.dart b/lib/src/git/presentation/git_history_view.dart index 5ff6240..2722808 100644 --- a/lib/src/git/presentation/git_history_view.dart +++ b/lib/src/git/presentation/git_history_view.dart @@ -152,7 +152,7 @@ class _CommitFileRow extends StatelessWidget { child: InkWell( hoverColor: busyMarkRowHoverColor(context), onTap: path.isEmpty ? null : onShowDiff, - onSecondaryTapDown: path.isEmpty + onSecondaryTapUp: path.isEmpty ? null : (details) async { final action = await _showCommitFileMenu( diff --git a/lib/src/platform/header_bar_configuration.dart b/lib/src/platform/header_bar_configuration.dart index fc343f6..c00a593 100644 --- a/lib/src/platform/header_bar_configuration.dart +++ b/lib/src/platform/header_bar_configuration.dart @@ -220,7 +220,10 @@ class HeaderBarTheme { foregroundColor: colors.foreground, sidebarBorderColor: colors.sidebarBorder, popoverBackgroundColor: colors.popover, - menuHoverColor: colors.controlHover, + // GTK themes do not all composite translucent model-button backgrounds + // consistently. Send the already-composited semantic hover surface so + // native rows get the same visible result on X11, Wayland, and Snap. + menuHoverColor: Color.alphaBlend(colors.controlHover, colors.popover), popoverShadowColor: theme.colorScheme.shadow.withValues( alpha: theme.colorScheme.shadow.a * diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 891c86d..c5bc961 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -2005,7 +2005,7 @@ class _SidebarHeader extends StatelessWidget { style: detailsStyle, leadingEllipsis: true, tooltip: busyMarkLtrIsolateFor(context, path), - onSecondaryTapDown: (lineContext, details) => + onSecondaryTapUp: (lineContext, details) => _showWorkspacePathMenu( lineContext, name: _workspaceName(context, workspace), @@ -2134,7 +2134,7 @@ List _branchSyncIndicators( } typedef _SidebarHeaderSecondaryTapHandler = - Future Function(BuildContext context, TapDownDetails details); + Future Function(BuildContext context, TapUpDetails details); class _SidebarHeaderRow extends StatelessWidget { const _SidebarHeaderRow({super.key, required this.child}); @@ -2159,7 +2159,7 @@ class _SidebarHeaderLine extends StatelessWidget { this.leadingEllipsis = false, this.inlineTrailing = const [], this.tooltip, - this.onSecondaryTapDown, + this.onSecondaryTapUp, }); final IconData icon; @@ -2168,7 +2168,7 @@ class _SidebarHeaderLine extends StatelessWidget { final bool leadingEllipsis; final List inlineTrailing; final String? tooltip; - final _SidebarHeaderSecondaryTapHandler? onSecondaryTapDown; + final _SidebarHeaderSecondaryTapHandler? onSecondaryTapUp; @override Widget build(BuildContext context) { @@ -2209,13 +2209,13 @@ class _SidebarHeaderLine extends StatelessWidget { ), ], ); - final secondaryTapHandler = onSecondaryTapDown; + final secondaryTapHandler = onSecondaryTapUp; if (secondaryTapHandler == null) { return line; } final clickable = GestureDetector( behavior: HitTestBehavior.opaque, - onSecondaryTapDown: (details) => + onSecondaryTapUp: (details) => unawaited(secondaryTapHandler(context, details)), child: line, ); @@ -3046,7 +3046,7 @@ class _FilesTabState extends ConsumerState<_FilesTab> { } } - void onSecondaryTapDown(TapDownDetails details) { + void onSecondaryTapUp(TapUpDetails details) { selectEntry(); unawaited( _showFileContextMenu( @@ -3094,7 +3094,7 @@ class _FilesTabState extends ConsumerState<_FilesTab> { } } : null, - onSecondaryTapDown: onSecondaryTapDown, + onSecondaryTapUp: onSecondaryTapUp, ); }, ), @@ -3640,7 +3640,7 @@ class _SidebarTreeRow extends StatelessWidget { this.vcsColor, this.onToggle, this.onTap, - this.onSecondaryTapDown, + this.onSecondaryTapUp, }); final String title; @@ -3655,13 +3655,13 @@ class _SidebarTreeRow extends StatelessWidget { final BusyMarkVcsFileColor? vcsColor; final VoidCallback? onToggle; final VoidCallback? onTap; - final GestureTapDownCallback? onSecondaryTapDown; + final GestureTapUpCallback? onSecondaryTapUp; @override Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); final direction = Directionality.of(context); - final clickable = enabled && (onTap != null || onSecondaryTapDown != null); + final clickable = enabled && (onTap != null || onSecondaryTapUp != null); final vcsForeground = vcsColor == null ? null : busyMarkVcsFileStatusColor(context, vcsColor!); @@ -3688,7 +3688,7 @@ class _SidebarTreeRow extends StatelessWidget { ? busyMarkRowHoverColor(context) : BusyMarkLinuxPalette.transparent, onTap: enabled ? onTap : null, - onSecondaryTapDown: enabled ? onSecondaryTapDown : null, + onSecondaryTapUp: enabled ? onSecondaryTapUp : null, child: SizedBox( height: BusyMarkSizes.sidebarTreeRowHeight, child: Row( @@ -4294,7 +4294,7 @@ class _TocTabState extends ConsumerState<_TocTab> { : () { selectEntry(); }, - onSecondaryTapDown: (details) { + onSecondaryTapUp: (details) { selectEntry(); unawaited( _showTopicContextMenu( diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 665b381..2a26cbb 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -74,6 +74,13 @@ constexpr char kMenuIconAttribute[] = "x-busymark-icon"; constexpr char kModelButtonAcceleratorKey[] = "busymark-model-button-accelerator"; constexpr char kNativePopoverStyleClass[] = "busymark-native-popover"; +constexpr char kNativeMenuItemStyleClass[] = "busymark-native-menu-item"; +constexpr char kNativeMenuItemHoverStyleClass[] = + "busymark-native-menu-item-hover"; +constexpr char kNativeMenuItemHoverHandlersKey[] = + "busymark-native-menu-item-hover-handlers"; +constexpr char kNativeMenuPopoverHoverResetHandlerKey[] = + "busymark-native-menu-popover-hover-reset-handler"; constexpr char kHeaderMenuDepthStyleClass[] = "busymark-header-menu-depth"; constexpr char kHeaderApplicationActiveStyleClass[] = @@ -170,6 +177,8 @@ struct HeaderBarConfiguration { G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) static void schedule_header_bar_focus_state_refresh(MyApplication* self); +static void native_menu_popover_hidden_reset_hover_cb(GtkWidget* widget, + gpointer user_data); static void style_native_popover(GtkWidget* popover) { if (popover == nullptr || !GTK_IS_POPOVER(popover)) { @@ -177,6 +186,107 @@ static void style_native_popover(GtkWidget* popover) { } gtk_style_context_add_class(gtk_widget_get_style_context(popover), kNativePopoverStyleClass); + if (g_object_get_data( + G_OBJECT(popover), + kNativeMenuPopoverHoverResetHandlerKey) == nullptr) { + g_signal_connect( + popover, "hide", + G_CALLBACK(native_menu_popover_hidden_reset_hover_cb), nullptr); + g_object_set_data(G_OBJECT(popover), + kNativeMenuPopoverHoverResetHandlerKey, + GINT_TO_POINTER(1)); + } +} + +static void set_native_menu_item_hovered(GtkWidget* widget, + gboolean hovered) { + if (widget == nullptr || !GTK_IS_MODEL_BUTTON(widget)) { + return; + } + GtkStyleContext* context = gtk_widget_get_style_context(widget); + const gboolean should_hover = hovered && gtk_widget_is_sensitive(widget); + if (gtk_style_context_has_class(context, kNativeMenuItemHoverStyleClass) == + should_hover) { + return; + } + if (should_hover) { + gtk_style_context_add_class(context, kNativeMenuItemHoverStyleClass); + } else { + gtk_style_context_remove_class(context, kNativeMenuItemHoverStyleClass); + } + gtk_widget_queue_draw(widget); +} + +static gboolean native_menu_item_enter_cb(GtkWidget* widget, + GdkEventCrossing*, + gpointer) { + set_native_menu_item_hovered(widget, TRUE); + return GDK_EVENT_PROPAGATE; +} + +static gboolean native_menu_item_motion_cb(GtkWidget* widget, + GdkEventMotion*, + gpointer) { + set_native_menu_item_hovered(widget, TRUE); + return GDK_EVENT_PROPAGATE; +} + +static gboolean native_menu_item_leave_cb(GtkWidget* widget, + GdkEventCrossing*, + gpointer) { + set_native_menu_item_hovered(widget, FALSE); + return GDK_EVENT_PROPAGATE; +} + +static void native_menu_item_sensitive_changed_cb(GtkWidget* widget, + GParamSpec*, + gpointer) { + if (!gtk_widget_is_sensitive(widget)) { + set_native_menu_item_hovered(widget, FALSE); + } +} + +static void style_native_menu_item(GtkWidget* widget) { + if (widget == nullptr || !GTK_IS_MODEL_BUTTON(widget)) { + return; + } + gtk_style_context_add_class(gtk_widget_get_style_context(widget), + kNativeMenuItemStyleClass); + if (g_object_get_data(G_OBJECT(widget), + kNativeMenuItemHoverHandlersKey) != nullptr) { + return; + } + gtk_widget_add_events(widget, GDK_ENTER_NOTIFY_MASK | + GDK_LEAVE_NOTIFY_MASK | + GDK_POINTER_MOTION_MASK); + g_signal_connect(widget, "enter-notify-event", + G_CALLBACK(native_menu_item_enter_cb), nullptr); + g_signal_connect(widget, "motion-notify-event", + G_CALLBACK(native_menu_item_motion_cb), nullptr); + g_signal_connect(widget, "leave-notify-event", + G_CALLBACK(native_menu_item_leave_cb), nullptr); + g_signal_connect(widget, "notify::sensitive", + G_CALLBACK(native_menu_item_sensitive_changed_cb), nullptr); + g_object_set_data(G_OBJECT(widget), kNativeMenuItemHoverHandlersKey, + GINT_TO_POINTER(1)); +} + +static void clear_native_menu_item_hover_cb(GtkWidget* widget, gpointer) { + if (GTK_IS_MODEL_BUTTON(widget)) { + set_native_menu_item_hovered(widget, FALSE); + } + if (GTK_IS_CONTAINER(widget)) { + gtk_container_foreach(GTK_CONTAINER(widget), + clear_native_menu_item_hover_cb, nullptr); + } +} + +static void native_menu_popover_hidden_reset_hover_cb(GtkWidget* widget, + gpointer) { + if (GTK_IS_CONTAINER(widget)) { + gtk_container_foreach(GTK_CONTAINER(widget), + clear_native_menu_item_hover_cb, nullptr); + } } static void style_header_menu_popover(GtkWidget* popover) { @@ -663,17 +773,16 @@ static void refresh_header_bar_css(MyApplication* self) { is_css_color_token(self->menu_hover_color) ? g_strdup_printf( "popover.background.%s " - "modelbutton:hover:not(:disabled) {" - "background-color: %s;" - "background-image: none;" - "}" + "modelbutton.%s:hover:not(:disabled)," "popover.background.%s " - "row:hover:not(:disabled) {" + "modelbutton.%s.%s:not(:disabled) {" "background-color: %s;" "background-image: none;" "}", - kNativePopoverStyleClass, self->menu_hover_color, - kNativePopoverStyleClass, self->menu_hover_color) + kNativePopoverStyleClass, kNativeMenuItemStyleClass, + kNativePopoverStyleClass, kNativeMenuItemStyleClass, + kNativeMenuItemHoverStyleClass, + self->menu_hover_color) : g_strdup(""); g_autofree gchar* header_menu_shadow_css = use_legacy_yaru_compatibility @@ -1378,6 +1487,7 @@ static void decorate_model_menu_accelerators_cb(GtkWidget* widget, auto* decoration = static_cast(user_data); if (GTK_IS_MODEL_BUTTON(widget)) { + style_native_menu_item(widget); if (decoration->item_index < g_menu_model_get_n_items(decoration->model)) { g_autoptr(GVariant) value = g_menu_model_get_item_attribute_value( @@ -2236,7 +2346,10 @@ struct NativeMenuSession { GPtrArray* shortcut_labels; GPtrArray* icon_names; gulong closed_signal_id; + gulong hide_signal_id; + guint popup_source_id; guint cleanup_source_id; + gboolean focus_first; gint pending_selected_index; }; @@ -2257,25 +2370,76 @@ static void native_menu_session_respond(NativeMenuSession* session, g_clear_object(&session->method_call); } -static void native_menu_session_dispose(NativeMenuSession* session) { +static gboolean native_menu_cleanup_idle_cb(gpointer user_data); + +static void native_menu_release_input_grab(NativeMenuSession* session) { + if (session == nullptr || session->popover == nullptr || + !GTK_IS_POPOVER(session->popover)) { + return; + } + // GtkPopover's modal property owns a GTK grab over the whole toplevel. + // Release it before every close path so a delayed transition or signal can + // never leave the embedded Flutter view unable to receive pointer input. + gtk_popover_set_modal(GTK_POPOVER(session->popover), FALSE); + // Keep an explicit, idempotent removal beside the modal-property update so + // cleanup is safe even if GTK's internal popover state is already changing. + gtk_grab_remove(session->popover); +} + +static void native_menu_schedule_cleanup(NativeMenuSession* session) { + if (session != nullptr && session->cleanup_source_id == 0) { + session->cleanup_source_id = g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, native_menu_cleanup_idle_cb, session, + nullptr); + } +} + +static void native_menu_close(NativeMenuSession* session) { if (session == nullptr) { return; } + if (session->popup_source_id != 0) { + g_source_remove(session->popup_source_id); + session->popup_source_id = 0; + } + native_menu_release_input_grab(session); + if (session->popover != nullptr && + gtk_widget_get_visible(session->popover)) { + // A menu selection and an explicit dismiss do not need an animated + // popdown. Hiding immediately releases the native surface while cleanup + // stays deferred until the current GTK callback has returned. + gtk_widget_hide(session->popover); + } + native_menu_schedule_cleanup(session); +} +static void native_menu_session_dispose(NativeMenuSession* session) { + if (session == nullptr) { + return; + } NativeMenuHandlerData* owner = session->owner; if (owner != nullptr && owner->active == session) { owner->active = nullptr; } + if (session->popup_source_id != 0) { + g_source_remove(session->popup_source_id); + session->popup_source_id = 0; + } if (session->cleanup_source_id != 0) { g_source_remove(session->cleanup_source_id); session->cleanup_source_id = 0; } if (session->popover != nullptr) { + native_menu_release_input_grab(session); if (session->closed_signal_id != 0) { g_signal_handler_disconnect(session->popover, session->closed_signal_id); session->closed_signal_id = 0; } + if (session->hide_signal_id != 0) { + g_signal_handler_disconnect(session->popover, session->hide_signal_id); + session->hide_signal_id = 0; + } if (gtk_widget_get_visible(session->popover)) { gtk_widget_hide(session->popover); } @@ -2306,11 +2470,17 @@ static gboolean native_menu_cleanup_idle_cb(gpointer user_data) { static void native_menu_closed_cb(GtkPopover*, gpointer user_data) { auto* session = static_cast(user_data); - if (session->cleanup_source_id == 0) { - session->cleanup_source_id = g_idle_add_full( - G_PRIORITY_DEFAULT_IDLE, native_menu_cleanup_idle_cb, session, - nullptr); - } + // ::closed is emitted when a popdown starts. Drop the grab now, but let the + // transition reach ::hide before destroying the popover. + native_menu_release_input_grab(session); +} + +static void native_menu_hidden_cb(GtkWidget*, gpointer user_data) { + auto* session = static_cast(user_data); + // ::hide is the end of the visual lifecycle, including an animated outside + // dismissal. Resolve the pending Dart method call only after GTK reaches it. + native_menu_release_input_grab(session); + native_menu_schedule_cleanup(session); } static void native_menu_action_activated_cb(GSimpleAction* action, @@ -2321,9 +2491,7 @@ static void native_menu_action_activated_cb(GSimpleAction* action, GPOINTER_TO_INT( g_object_get_data(G_OBJECT(action), kNativeMenuActionIndexKey)) - 1; - if (session->popover != nullptr) { - gtk_popover_popdown(GTK_POPOVER(session->popover)); - } + native_menu_close(session); } static void native_menu_selection_activated_cb(GSimpleAction* action, @@ -2345,9 +2513,7 @@ static void native_menu_selection_activated_cb(GSimpleAction* action, g_simple_action_set_state(action, parameter); session->pending_selected_index = static_cast(parsed); - if (session->popover != nullptr) { - gtk_popover_popdown(GTK_POPOVER(session->popover)); - } + native_menu_close(session); } static gboolean native_menu_dismiss_active(NativeMenuHandlerData* data, @@ -2356,12 +2522,7 @@ static gboolean native_menu_dismiss_active(NativeMenuHandlerData* data, if (session == nullptr || session->id != session_id) { return FALSE; } - if (session->popover != nullptr && - gtk_widget_get_visible(session->popover)) { - gtk_popover_popdown(GTK_POPOVER(session->popover)); - } else { - native_menu_session_dispose(session); - } + native_menu_close(session); return TRUE; } @@ -2448,6 +2609,7 @@ static void decorate_native_menu_shortcuts_cb(GtkWidget* widget, gpointer user_data) { auto* decoration = static_cast(user_data); if (GTK_IS_MODEL_BUTTON(widget)) { + style_native_menu_item(widget); if (decoration->index < decoration->labels->len) { add_model_button_presentation( widget, @@ -2477,6 +2639,24 @@ static void decorate_native_menu_shortcuts(GtkWidget* popover, decorate_native_menu_shortcuts_cb, &decoration); } +static gboolean native_menu_popup_idle_cb(gpointer user_data) { + auto* session = static_cast(user_data); + session->popup_source_id = 0; + if (session->owner == nullptr || session->owner->active != session || + session->popover == nullptr) { + return G_SOURCE_REMOVE; + } + + // Flutter requests pointer-opened menus while GTK is still dispatching the + // originating button event. Start the modal popover after that dispatch has + // unwound so its grab begins from a clean pointer state. + gtk_popover_popup(GTK_POPOVER(session->popover)); + if (session->focus_first) { + gtk_widget_child_focus(session->popover, GTK_DIR_TAB_FORWARD); + } + return G_SOURCE_REMOVE; +} + static void show_native_menu(NativeMenuHandlerData* data, FlMethodCall* method_call, FlValue* args) { @@ -2599,6 +2779,7 @@ static void show_native_menu(NativeMenuHandlerData* data, session->owner = data; session->id = session_id; session->entry_count = fl_value_get_length(entries); + session->focus_first = focus_first; session->pending_selected_index = -1; session->method_call = FL_METHOD_CALL(g_object_ref(G_OBJECT(method_call))); @@ -2755,10 +2936,10 @@ static void show_native_menu(NativeMenuHandlerData* data, session->icon_names); session->closed_signal_id = g_signal_connect( session->popover, "closed", G_CALLBACK(native_menu_closed_cb), session); - gtk_popover_popup(GTK_POPOVER(session->popover)); - if (focus_first) { - gtk_widget_child_focus(session->popover, GTK_DIR_TAB_FORWARD); - } + session->hide_signal_id = g_signal_connect( + session->popover, "hide", G_CALLBACK(native_menu_hidden_cb), session); + session->popup_source_id = g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, native_menu_popup_idle_cb, session, nullptr); } static void native_menu_handler_data_free(gpointer user_data) { diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 3095a52..de6aae3 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -603,7 +603,8 @@ void main() { expect(css, contains('background-color: alpha(currentColor, 0.16)')); expect(css, contains('background-color: alpha(currentColor, 0.10)')); expect(css, contains('popover.background.')); - expect(css, contains('modelbutton:hover:not(:disabled)')); + expect(css, contains('modelbutton.%s:hover:not(:disabled)')); + expect(css, contains('modelbutton.%s.%s:not(:disabled)')); expect(css, contains('box-shadow: 0 1px 3px')); expect(css, contains('tooltip.background')); expect(css, contains('tooltip decoration')); @@ -625,7 +626,12 @@ void main() { expect(configuration, contains('sidebarBackgroundColor: colors.sidebar')); expect(configuration, contains('foregroundColor: colors.foreground')); expect(configuration, contains('popoverBackgroundColor: colors.popover')); - expect(configuration, contains('menuHoverColor: colors.controlHover')); + expect( + configuration, + contains( + 'menuHoverColor: Color.alphaBlend(colors.controlHover, colors.popover)', + ), + ); expect(configuration, isNot(contains('borderColor: colors.'))); expect(configuration, isNot(contains('floatingBorderColor'))); expect(native, contains('kDefaultHeaderbarBackground[] = "#272727"')); @@ -1263,7 +1269,29 @@ void main() { } expect(native, contains('view_mode_icon_name(mode)')); expect(native, contains('view_mode_icon_name("split")')); - expect(native, contains('modelbutton:hover:not(:disabled)')); + expect(native, contains('modelbutton.%s:hover:not(:disabled)')); + expect(native, contains('modelbutton.%s.%s:not(:disabled)')); + expect(native, contains('kNativeMenuItemStyleClass')); + expect(native, contains('kNativeMenuItemHoverStyleClass')); + expect(native, contains('style_native_menu_item(widget)')); + expect(native, contains('native_menu_item_enter_cb')); + expect(native, contains('native_menu_item_motion_cb')); + expect(native, contains('native_menu_item_leave_cb')); + expect(native, contains('native_menu_popover_hidden_reset_hover_cb')); + expect( + native, + contains( + 'gtk_style_context_add_class(context, ' + 'kNativeMenuItemHoverStyleClass)', + ), + ); + expect( + native, + contains( + 'gtk_style_context_remove_class(context, ' + 'kNativeMenuItemHoverStyleClass)', + ), + ); expect(native, isNot(contains('modelbutton:focus'))); expect(native, isNot(contains('modelbutton:active'))); expect(native, isNot(contains('outline-width: 0;'))); @@ -1349,6 +1377,16 @@ void main() { expect(native, contains('G_VARIANT_TYPE_STRING')); expect(native, contains('gtk_popover_set_modal')); expect(native, contains('gtk_popover_set_constrain_to')); + expect(native, contains('native_menu_release_input_grab(session)')); + expect(native, contains('native_menu_close(session)')); + expect(native, contains('native_menu_popup_idle_cb')); + expect( + native, + contains( + 'session->popup_source_id = g_idle_add_full(\n' + ' G_PRIORITY_DEFAULT_IDLE, native_menu_popup_idle_cb', + ), + ); expect(service, contains('final String? shortcut')); expect(service, contains('final String? iconName')); expect(service, contains("'icon': iconName!")); @@ -1381,6 +1419,58 @@ void main() { } }); + test('native content menus release their GTK grab on every close path', () { + final native = File('linux/runner/my_application.cc').readAsStringSync(); + + final releaseGrab = RegExp( + r'static void native_menu_release_input_grab[\s\S]*?' + r'(?=static void native_menu_schedule_cleanup)', + ).firstMatch(native)?.group(0); + final close = RegExp( + r'static void native_menu_close[\s\S]*?' + r'(?=static void native_menu_session_dispose)', + ).firstMatch(native)?.group(0); + final dispose = RegExp( + r'static void native_menu_session_dispose[\s\S]*?' + r'(?=static gboolean native_menu_cleanup_idle_cb)', + ).firstMatch(native)?.group(0); + final closed = RegExp( + r'static void native_menu_closed_cb[\s\S]*?' + r'(?=static void native_menu_hidden_cb)', + ).firstMatch(native)?.group(0); + final hidden = RegExp( + r'static void native_menu_hidden_cb[\s\S]*?' + r'(?=static void native_menu_action_activated_cb)', + ).firstMatch(native)?.group(0); + final show = RegExp( + r'static void show_native_menu[\s\S]*?' + r'(?=static void native_menu_handler_data_free)', + ).firstMatch(native)?.group(0); + + expect(releaseGrab, contains('gtk_popover_set_modal(')); + expect(releaseGrab, contains('FALSE')); + expect(releaseGrab, contains('gtk_grab_remove(session->popover)')); + expect(close, contains('native_menu_release_input_grab(session)')); + expect(close, contains('gtk_widget_hide(session->popover)')); + expect(close, contains('native_menu_schedule_cleanup(session)')); + expect( + close!.indexOf('native_menu_release_input_grab(session)'), + lessThan(close.indexOf('gtk_widget_hide(session->popover)')), + ); + expect(dispose, contains('native_menu_release_input_grab(session)')); + expect(closed, contains('native_menu_release_input_grab(session)')); + expect(closed, isNot(contains('native_menu_schedule_cleanup(session)'))); + expect(hidden, contains('native_menu_release_input_grab(session)')); + expect(hidden, contains('native_menu_schedule_cleanup(session)')); + expect(show, contains('native_menu_popup_idle_cb')); + expect(show, contains('"hide", G_CALLBACK(native_menu_hidden_cb)')); + expect(show, isNot(contains('gtk_popover_popup('))); + expect( + native, + isNot(contains('gtk_popover_popdown(GTK_POPOVER(session->popover))')), + ); + }); + test('welcome page has a sidebar but no document controls', () { final welcome = File( 'lib/src/workspace/presentation/welcome_screen.dart', diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 8808853..4c57c7a 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -979,7 +979,7 @@ void main() { expect(workspace, contains('tooltip: context.l10n.pathActions')); expect(workspace, contains('icon: BusyMarkGlyphs.menuVertical')); expect(workspace, isNot(contains('SystemMouseCursors.contextMenu'))); - expect(workspace, contains('onSecondaryTapDown: (lineContext, details)')); + expect(workspace, contains('onSecondaryTapUp: (lineContext, details)')); expect(workspace, contains('position: details.globalPosition')); expect( workspace, @@ -1249,7 +1249,7 @@ void main() { expect(workspace, contains('enabled: node.isFolder || openable')); expect(workspace, contains('openActiveFile(file.absolutePath)')); expect(workspace, contains('_showFileTreeMenu')); - expect(workspace, contains('onSecondaryTapDown')); + expect(workspace, contains('onSecondaryTapUp')); expect( RegExp( r'BusyMarkTreeShortcutActivators\.deleteSelection', @@ -1676,6 +1676,21 @@ void main() { expect(imageDialog, isNot(contains('FilledButton('))); }); + test('native context menus wait for the secondary pointer release', () { + final contextMenuSources = [ + File( + 'lib/src/workspace/presentation/workspace_screen.dart', + ).readAsStringSync(), + File('lib/src/git/presentation/git_history_view.dart').readAsStringSync(), + File('lib/src/editor/wysiwyg/wysiwyg_editor.dart').readAsStringSync(), + ]; + + for (final source in contextMenuSources) { + expect(source, contains('onSecondaryTapUp')); + expect(source, isNot(contains('onSecondaryTapDown'))); + } + }); + test('source view has compact gutter without pane status chrome', () { final workspace = File( 'lib/src/workspace/presentation/workspace_screen.dart', From 0b279baa363660407fdac15a1a25b97d72dc370c Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 23:42:47 -0700 Subject: [PATCH 24/29] Disable highlight when menu is open in workspace screen for improved user experience --- lib/src/workspace/presentation/workspace_screen.dart | 1 + test/src/app_smoke_test.dart | 6 ++++++ test/src/source_audit_test.dart | 1 + 3 files changed, 8 insertions(+) diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index c5bc961..3ea4551 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -4988,6 +4988,7 @@ class _TocHeader extends StatelessWidget { icon: BusyMarkGlyphs.menuVertical, transparent: true, borderRadius: BusyMarkRadius.nativeHeaderButton, + highlightWhenOpen: false, itemBuilder: (context) => [ BusyMarkPopupMenuItem( value: _TocHeaderAction.newTopic, diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 3f8d083..ac4b9f6 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -1210,7 +1210,13 @@ void main() { expect(find.byTooltip(l10n.tocActions), findsOneWidget); expect(find.byTooltip(l10n.newTopic), findsNothing); expect(find.byTooltip(l10n.newChildTopic), findsNothing); + final tocMenuButton = find.descendant( + of: find.byKey(const ValueKey('workspace-sidebar-toc-menu')), + matching: find.byType(IconButton), + ); + expect(tester.widget(tocMenuButton).isSelected, isFalse); await openPopup(find.byTooltip(l10n.tocActions)); + expect(tester.widget(tocMenuButton).isSelected, isFalse); expect(find.text(l10n.newTopic), findsOneWidget); await tester.tap(find.text(l10n.newTopic)); await tester.pump(const Duration(milliseconds: 300)); diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 4c57c7a..2c7b075 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -1309,6 +1309,7 @@ void main() { expect(tocHeader, contains("ValueKey('workspace-sidebar-toc-menu')")); expect(tocHeader, contains('tooltip: context.l10n.tocActions')); expect(tocHeader, contains('icon: BusyMarkGlyphs.menuVertical')); + expect(tocHeader, contains('highlightWhenOpen: false')); expect(tocHeader, contains('label: context.l10n.newTopic')); expect(tocHeader, isNot(contains('BusyMarkHeaderIconButton'))); expect(tocHeader, isNot(contains('context.l10n.newChildTopic'))); From cdae0ef9245b4d5ca913f5d60ac3fb11948aa5f0 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 31 Jul 2026 02:50:19 -0700 Subject: [PATCH 25/29] Disable highlight when menu is open in workspace screen for improved user experience --- lib/src/workspace/presentation/workspace_screen.dart | 1 + test/src/app_smoke_test.dart | 6 ++++++ test/src/source_audit_test.dart | 5 +++++ 3 files changed, 12 insertions(+) diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 3ea4551..d0b1063 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -2021,6 +2021,7 @@ class _SidebarHeader extends StatelessWidget { icon: BusyMarkGlyphs.menuVertical, transparent: true, borderRadius: BusyMarkRadius.nativeHeaderButton, + highlightWhenOpen: false, itemBuilder: _sidebarPathMenuItems, onSelected: (action) => unawaited( _performWorkspacePathAction( diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index ac4b9f6..8921a74 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -1935,8 +1935,14 @@ void main() { await tester.pumpAndSettle(); expect(find.text(l10n.openInFiles), findsNothing); + final pathMenuButton = find.descendant( + of: find.byKey(const ValueKey('workspace-sidebar-path-menu')), + matching: find.byType(IconButton), + ); + expect(tester.widget(pathMenuButton).isSelected, isFalse); await tester.tap(find.byTooltip(l10n.pathActions)); await tester.pumpAndSettle(); + expect(tester.widget(pathMenuButton).isSelected, isFalse); expect(find.text(l10n.copyName), findsOneWidget); expect(find.text(l10n.copyPath), findsOneWidget); expect(find.text(l10n.openInFiles), findsOneWidget); diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 2c7b075..67a1c0b 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -978,6 +978,11 @@ void main() { expect(workspace, contains("ValueKey('workspace-sidebar-path-menu')")); expect(workspace, contains('tooltip: context.l10n.pathActions')); expect(workspace, contains('icon: BusyMarkGlyphs.menuVertical')); + final pathMenu = RegExp( + r'BusyMarkHeaderPopupMenuButton<_PathMenuAction>[\s\S]*?' + r'itemBuilder: _sidebarPathMenuItems', + ).firstMatch(workspace)?.group(0); + expect(pathMenu, contains('highlightWhenOpen: false')); expect(workspace, isNot(contains('SystemMouseCursors.contextMenu'))); expect(workspace, contains('onSecondaryTapUp: (lineContext, details)')); expect(workspace, contains('position: details.globalPosition')); From b88e7a6a5a0cf2d88998ba5cbb63bfaaa466f7e2 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 31 Jul 2026 03:36:14 -0700 Subject: [PATCH 26/29] Refactor WYSIWYG editor to use ScrollablePositionedList for improved scrolling performance and update dependencies --- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 69 ++++++++-------- pubspec.lock | 8 ++ pubspec.yaml | 1 + test/src/app_smoke_test.dart | 32 +++++--- test/src/busymark_document_test.dart | 95 ++++++++++++++++++++-- 5 files changed, 157 insertions(+), 48 deletions(-) diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 327b6c1..91d4027 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -5,6 +5,7 @@ import 'package:file_selector/file_selector.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../app/app_settings.dart'; import '../../app/busymark_dialogs.dart'; @@ -88,7 +89,7 @@ class _BusyMarkWysiwygEditorState extends State { final _blockKeys = {}; final _undoStack = []; final _redoStack = []; - final _scrollController = ScrollController(); + final _itemScrollController = ItemScrollController(); final _selectionFocusNode = FocusNode( debugLabel: 'BusyMark WYSIWYG block selection', ); @@ -153,7 +154,6 @@ class _BusyMarkWysiwygEditorState extends State { for (final focusNode in _focusNodes.values) { focusNode.dispose(); } - _scrollController.dispose(); _selectionFocusNode.dispose(); super.dispose(); } @@ -307,9 +307,9 @@ class _BusyMarkWysiwygEditorState extends State { }, child: Focus( focusNode: _selectionFocusNode, - child: ListView.builder( + child: ScrollablePositionedList.builder( key: const ValueKey('wysiwyg-document-scroll'), - controller: _scrollController, + itemScrollController: _itemScrollController, padding: documentLayout.scrollPadding, itemCount: renderEntries.length, itemBuilder: (context, index) => _buildRenderEntry( @@ -1000,16 +1000,7 @@ class _BusyMarkWysiwygEditorState extends State { if (heading == null) { return; } - final headingBlockId = heading.id; - if (_ensureBlockVisible(headingBlockId)) { - return; - } - _jumpNearBlock(headingBlockId); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _ensureBlockVisible(headingBlockId); - } - }); + _jumpToBlockAndAlign(heading.id); }); } @@ -1038,15 +1029,7 @@ class _BusyMarkWysiwygEditorState extends State { extentOffset: matchStart + query.length, ); } - if (_ensureBlockVisible(target.id)) { - return; - } - _jumpNearBlock(target.id); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _ensureBlockVisible(target.id); - } - }); + _jumpToBlockAndAlign(target.id); }); } @@ -1076,19 +1059,41 @@ class _BusyMarkWysiwygEditorState extends State { return true; } - void _jumpNearBlock(String blockId) { - if (!_scrollController.hasClients) { + void _jumpToBlockAndAlign(String blockId) { + final request = widget.scrollRequest; + if (!_jumpToBlock(blockId)) { return; } - final entries = _editableBlockEntries(_documentController.document.blocks); - final index = entries.indexWhere((entry) => entry.block.id == blockId); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.scrollRequest == request) { + _ensureBlockVisible(blockId); + } + }); + } + + bool _jumpToBlock(String blockId) { + if (!_itemScrollController.isAttached) { + return false; + } + final entries = _editorRenderEntries(_documentController.document.blocks); + final index = entries.indexWhere( + (entry) => _renderEntryContainsBlock(entry, blockId), + ); if (index < 0) { - return; + return false; } - final targetOffset = (index * 72.0) - .clamp(0.0, _scrollController.position.maxScrollExtent) - .toDouble(); - _scrollController.jumpTo(targetOffset); + _itemScrollController.jumpTo(index: index, alignment: 0.04); + return true; + } + + bool _renderEntryContainsBlock(_EditorRenderEntry entry, String blockId) { + if (entry.block.id == blockId) { + return true; + } + return entry.children?.any( + (child) => _renderEntryContainsBlock(child, blockId), + ) ?? + false; } BusyBlock? _headingBlockForId(String headingId) { diff --git a/pubspec.lock b/pubspec.lock index b9849cb..d2400a3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -749,6 +749,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + scrollable_positioned_list: + dependency: "direct main" + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.dev" + source: hosted + version: "0.3.8" shelf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 26aef35..aad88ef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,7 @@ dependencies: package_info_plus: ^10.1.0 path: ^1.9.0 path_provider: ^2.1.0 + scrollable_positioned_list: ^0.3.8 ubuntu_localizations: ^0.5.2+3 url_launcher: ^6.3.2 uuid: ^4.6.0 diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 8921a74..3a7ea53 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -43,6 +43,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:window_manager/window_manager.dart'; @@ -3309,7 +3310,9 @@ void main() { .at(1), ) .style; - final editorPadding = tester.widget(editorScroll).padding; + final editorPadding = tester + .widget(editorScroll) + .padding; final expectedStandalone = BusyMarkDocumentLayoutSpec.standalone .withEditingToolbar( placement: EditorToolbarPlacement.topLeft, @@ -4141,7 +4144,14 @@ Body. matching: find.byType(TextField), ); expect(editorFields, findsWidgets); - final scrollController = tester.widget(editorScroll).controller!; + ScrollablePositionedList editorList() => + tester.widget(editorScroll); + Finder editorFieldWithText(String text) => find.descendant( + of: editorScroll, + matching: find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller?.text == text, + ), + ); final outlineTree = find.byKey( const ValueKey('workspace-sidebar-outline-tree'), ); @@ -4151,13 +4161,14 @@ Body. ); expect(formattedTarget, findsOneWidget); - scrollController.jumpTo(scrollController.position.maxScrollExtent); + editorList().itemScrollController!.jumpTo( + index: editorList().itemCount - 1, + ); await tester.pump(); - final offsetBeforeFormattedNavigation = scrollController.offset; - expect(offsetBeforeFormattedNavigation, greaterThan(0)); + expect(editorFieldWithText('Saved heading'), findsNothing); await tester.tap(formattedTarget); await tester.pumpAndSettle(); - expect(scrollController.offset, lessThan(offsetBeforeFormattedNavigation)); + expect(editorFieldWithText('Saved heading'), findsOneWidget); final previewBeforeEdit = container .read(workspaceControllerProvider) @@ -4185,15 +4196,16 @@ Body. findsNothing, ); - scrollController.jumpTo(scrollController.position.maxScrollExtent); + editorList().itemScrollController!.jumpTo( + index: editorList().itemCount - 1, + ); await tester.pump(); - expect(scrollController.offset, greaterThan(0)); - final offsetBeforeNavigation = scrollController.offset; + expect(editorFieldWithText('Unsaved heading'), findsNothing); await tester.tap(target); await tester.pumpAndSettle(); - expect(scrollController.offset, lessThan(offsetBeforeNavigation)); + expect(editorFieldWithText('Unsaved heading'), findsOneWidget); final viewportBounds = tester.getRect(editorScroll); final headingBounds = tester.getRect(editorFields.first); expect(headingBounds.bottom, greaterThan(viewportBounds.top)); diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index da7bd44..3ad48ef 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -33,6 +33,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:markdown/markdown.dart' as md; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:yaru/yaru.dart'; void main() { @@ -1008,6 +1009,84 @@ void main() {} expect(find.text('Editable text'), findsOneWidget); }); + testWidgets('WYSIWYG heading navigation skips across large documents', ( + tester, + ) async { + tester.view.physicalSize = const Size(1200, 720); + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + final source = StringBuffer(); + for (var index = 0; index <= 1800; index += 1) { + if (index % 600 == 0) { + source + ..writeln('# Heading $index') + ..writeln(); + } + source + ..writeln('Paragraph $index keeps the rich editor scrollable.') + ..writeln(); + } + final document = parser + .parse(filePath: 'large.md', source: source.toString()) + .busyDocument; + final headings = document.outline; + expect(document.blocks.length, greaterThan(1800)); + expect(headings.map((heading) => heading.text), [ + 'Heading 0', + 'Heading 600', + 'Heading 1200', + 'Heading 1800', + ]); + + Widget editor(DocumentOutlineHeading? target, int request) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkWysiwygEditor( + document: document, + scrollToHeadingId: target?.id, + scrollToBlockId: target?.editorBlockId, + scrollRequest: request, + onSourceChanged: (_, _) {}, + ), + ), + ); + + await tester.pumpWidget(editor(null, 0)); + await tester.pump(); + + var request = 0; + Future navigateTo(DocumentOutlineHeading target) async { + request += 1; + await tester.pumpWidget(editor(target, request)); + await tester.pump(); + await tester.pump(BusyMarkMotion.scroll); + await tester.pump(); + await tester.pump(BusyMarkMotion.scroll); + + final targetField = find.byWidgetPredicate( + (widget) => + widget is TextField && widget.controller?.text == target.text, + ); + expect(targetField, findsOneWidget); + final viewport = tester.getRect( + find.byKey(const ValueKey('wysiwyg-document-scroll')), + ); + final targetBounds = tester.getRect(targetField); + expect(targetBounds.bottom, greaterThan(viewport.top)); + expect(targetBounds.top, lessThan(viewport.bottom)); + } + + await navigateTo(headings.last); + await navigateTo(headings.first); + await navigateTo(headings[2]); + await navigateTo(headings.last); + }); + testWidgets('WYSIWYG renders a blockquote around its editable text', ( tester, ) async { @@ -2463,10 +2542,8 @@ void main() {} BusyMarkSpacing.sm, ); - Finder editorList() => find.descendant( - of: find.byType(BusyMarkWysiwygEditor), - matching: find.byType(ListView), - ); + Finder editorList() => + find.byKey(const ValueKey('wysiwyg-document-scroll')); for (final direction in EditorToolbarDirection.values) { for (final placement in EditorToolbarPlacement.values) { @@ -2505,7 +2582,10 @@ void main() {} : BusyMarkSpacing.xl * 2, ); expect(editorList(), findsOneWidget); - expect(tester.widget(editorList()).padding, expectedPadding); + expect( + tester.widget(editorList()).padding, + expectedPadding, + ); final toolbarScrollView = tester.widget( find.descendant( of: find.byType(BusyMarkWysiwygToolbar), @@ -2537,7 +2617,10 @@ void main() {} await tester.tap(find.byTooltip('Hide editing buttons')); await tester.pump(); - expect(tester.widget(editorList()).padding, expectedPadding); + expect( + tester.widget(editorList()).padding, + expectedPadding, + ); expect(tester.getRect(find.byType(TextField).first), shownFieldRect); } } From 56033514fe80fcc4373294b40444823a594a5830 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 1 Aug 2026 18:06:55 -0700 Subject: [PATCH 27/29] Refactor native menu handling to improve structure and add input layer support --- linux/runner/my_application.cc | 398 ++++++++++------------ test/src/native_headerbar_audit_test.dart | 205 +++++++---- 2 files changed, 308 insertions(+), 295 deletions(-) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 2a26cbb..4968443 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -75,12 +75,6 @@ constexpr char kModelButtonAcceleratorKey[] = "busymark-model-button-accelerator"; constexpr char kNativePopoverStyleClass[] = "busymark-native-popover"; constexpr char kNativeMenuItemStyleClass[] = "busymark-native-menu-item"; -constexpr char kNativeMenuItemHoverStyleClass[] = - "busymark-native-menu-item-hover"; -constexpr char kNativeMenuItemHoverHandlersKey[] = - "busymark-native-menu-item-hover-handlers"; -constexpr char kNativeMenuPopoverHoverResetHandlerKey[] = - "busymark-native-menu-popover-hover-reset-handler"; constexpr char kHeaderMenuDepthStyleClass[] = "busymark-header-menu-depth"; constexpr char kHeaderApplicationActiveStyleClass[] = @@ -177,8 +171,6 @@ struct HeaderBarConfiguration { G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) static void schedule_header_bar_focus_state_refresh(MyApplication* self); -static void native_menu_popover_hidden_reset_hover_cb(GtkWidget* widget, - gpointer user_data); static void style_native_popover(GtkWidget* popover) { if (popover == nullptr || !GTK_IS_POPOVER(popover)) { @@ -186,64 +178,6 @@ static void style_native_popover(GtkWidget* popover) { } gtk_style_context_add_class(gtk_widget_get_style_context(popover), kNativePopoverStyleClass); - if (g_object_get_data( - G_OBJECT(popover), - kNativeMenuPopoverHoverResetHandlerKey) == nullptr) { - g_signal_connect( - popover, "hide", - G_CALLBACK(native_menu_popover_hidden_reset_hover_cb), nullptr); - g_object_set_data(G_OBJECT(popover), - kNativeMenuPopoverHoverResetHandlerKey, - GINT_TO_POINTER(1)); - } -} - -static void set_native_menu_item_hovered(GtkWidget* widget, - gboolean hovered) { - if (widget == nullptr || !GTK_IS_MODEL_BUTTON(widget)) { - return; - } - GtkStyleContext* context = gtk_widget_get_style_context(widget); - const gboolean should_hover = hovered && gtk_widget_is_sensitive(widget); - if (gtk_style_context_has_class(context, kNativeMenuItemHoverStyleClass) == - should_hover) { - return; - } - if (should_hover) { - gtk_style_context_add_class(context, kNativeMenuItemHoverStyleClass); - } else { - gtk_style_context_remove_class(context, kNativeMenuItemHoverStyleClass); - } - gtk_widget_queue_draw(widget); -} - -static gboolean native_menu_item_enter_cb(GtkWidget* widget, - GdkEventCrossing*, - gpointer) { - set_native_menu_item_hovered(widget, TRUE); - return GDK_EVENT_PROPAGATE; -} - -static gboolean native_menu_item_motion_cb(GtkWidget* widget, - GdkEventMotion*, - gpointer) { - set_native_menu_item_hovered(widget, TRUE); - return GDK_EVENT_PROPAGATE; -} - -static gboolean native_menu_item_leave_cb(GtkWidget* widget, - GdkEventCrossing*, - gpointer) { - set_native_menu_item_hovered(widget, FALSE); - return GDK_EVENT_PROPAGATE; -} - -static void native_menu_item_sensitive_changed_cb(GtkWidget* widget, - GParamSpec*, - gpointer) { - if (!gtk_widget_is_sensitive(widget)) { - set_native_menu_item_hovered(widget, FALSE); - } } static void style_native_menu_item(GtkWidget* widget) { @@ -252,41 +186,6 @@ static void style_native_menu_item(GtkWidget* widget) { } gtk_style_context_add_class(gtk_widget_get_style_context(widget), kNativeMenuItemStyleClass); - if (g_object_get_data(G_OBJECT(widget), - kNativeMenuItemHoverHandlersKey) != nullptr) { - return; - } - gtk_widget_add_events(widget, GDK_ENTER_NOTIFY_MASK | - GDK_LEAVE_NOTIFY_MASK | - GDK_POINTER_MOTION_MASK); - g_signal_connect(widget, "enter-notify-event", - G_CALLBACK(native_menu_item_enter_cb), nullptr); - g_signal_connect(widget, "motion-notify-event", - G_CALLBACK(native_menu_item_motion_cb), nullptr); - g_signal_connect(widget, "leave-notify-event", - G_CALLBACK(native_menu_item_leave_cb), nullptr); - g_signal_connect(widget, "notify::sensitive", - G_CALLBACK(native_menu_item_sensitive_changed_cb), nullptr); - g_object_set_data(G_OBJECT(widget), kNativeMenuItemHoverHandlersKey, - GINT_TO_POINTER(1)); -} - -static void clear_native_menu_item_hover_cb(GtkWidget* widget, gpointer) { - if (GTK_IS_MODEL_BUTTON(widget)) { - set_native_menu_item_hovered(widget, FALSE); - } - if (GTK_IS_CONTAINER(widget)) { - gtk_container_foreach(GTK_CONTAINER(widget), - clear_native_menu_item_hover_cb, nullptr); - } -} - -static void native_menu_popover_hidden_reset_hover_cb(GtkWidget* widget, - gpointer) { - if (GTK_IS_CONTAINER(widget)) { - gtk_container_foreach(GTK_CONTAINER(widget), - clear_native_menu_item_hover_cb, nullptr); - } } static void style_header_menu_popover(GtkWidget* popover) { @@ -769,19 +668,38 @@ static void refresh_header_bar_css(MyApplication* self) { kNativePopoverStyleClass, kNativePopoverStyleClass, self->popover_background_color) : g_strdup(""); + g_autofree gchar* native_menu_geometry_css = g_strdup_printf( + "popover.background.%s .%s {" + "font-size: 0.92em;" + "padding: 2px 6px;" + "}", + kNativePopoverStyleClass, kNativeMenuItemStyleClass); g_autofree gchar* native_menu_state_css = is_css_color_token(self->menu_hover_color) ? g_strdup_printf( "popover.background.%s " - "modelbutton.%s:hover:not(:disabled)," + "modelbutton:hover:not(:disabled) {" + "background-color: %s;" + "background-image: none;" + "}" "popover.background.%s " - "modelbutton.%s.%s:not(:disabled) {" + "row:hover:not(:disabled) {" "background-color: %s;" "background-image: none;" + "}" + "popover.background.%s " + ".%s:hover:not(:disabled)," + "popover.background.%s " + ".%s:focus:not(:disabled) {" + "background-color: %s;" + "background-image: none;" + "border-color: transparent;" + "outline-width: 0;" "}", + kNativePopoverStyleClass, self->menu_hover_color, + kNativePopoverStyleClass, self->menu_hover_color, kNativePopoverStyleClass, kNativeMenuItemStyleClass, kNativePopoverStyleClass, kNativeMenuItemStyleClass, - kNativeMenuItemHoverStyleClass, self->menu_hover_color) : g_strdup(""); g_autofree gchar* header_menu_shadow_css = @@ -924,6 +842,7 @@ static void refresh_header_bar_css(MyApplication* self) { "%s" "%s" "%s" + "%s" ".busymark-titlebar," ".busymark-titlebar:backdrop {" "background-color: %s;" @@ -1029,11 +948,12 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: %s;" "background-image: none;" "}", - background, window_shadow_css, native_popover_css, native_menu_state_css, - header_menu_shadow_css, tooltip_css, background, foreground, background, - foreground, sidebar_background, foreground, foreground, foreground, - kHeaderBackdropForegroundOpacity, sidebar_border, sidebar_border, - header_focus_css, modal); + background, window_shadow_css, native_popover_css, + native_menu_geometry_css, native_menu_state_css, + header_menu_shadow_css, tooltip_css, + background, foreground, background, foreground, sidebar_background, + foreground, foreground, foreground, kHeaderBackdropForegroundOpacity, + sidebar_border, sidebar_border, header_focus_css, modal); g_autoptr(GError) error = nullptr; GtkCssProvider* provider = gtk_css_provider_new(); @@ -2346,18 +2266,25 @@ struct NativeMenuSession { GPtrArray* shortcut_labels; GPtrArray* icon_names; gulong closed_signal_id; - gulong hide_signal_id; - guint popup_source_id; guint cleanup_source_id; - gboolean focus_first; gint pending_selected_index; }; struct NativeMenuHandlerData { GtkWidget* view; + GtkWidget* input_layer; + GtkWidget* menu_layer; + GtkWidget* menu_button; NativeMenuSession* active; }; +struct NativeMenuHostWidgets { + GtkWidget* overlay; + GtkWidget* input_layer; + GtkWidget* menu_layer; + GtkWidget* menu_button; +}; + static void native_menu_session_respond(NativeMenuSession* session, gint selected_index) { if (session->method_call == nullptr) { @@ -2370,49 +2297,6 @@ static void native_menu_session_respond(NativeMenuSession* session, g_clear_object(&session->method_call); } -static gboolean native_menu_cleanup_idle_cb(gpointer user_data); - -static void native_menu_release_input_grab(NativeMenuSession* session) { - if (session == nullptr || session->popover == nullptr || - !GTK_IS_POPOVER(session->popover)) { - return; - } - // GtkPopover's modal property owns a GTK grab over the whole toplevel. - // Release it before every close path so a delayed transition or signal can - // never leave the embedded Flutter view unable to receive pointer input. - gtk_popover_set_modal(GTK_POPOVER(session->popover), FALSE); - // Keep an explicit, idempotent removal beside the modal-property update so - // cleanup is safe even if GTK's internal popover state is already changing. - gtk_grab_remove(session->popover); -} - -static void native_menu_schedule_cleanup(NativeMenuSession* session) { - if (session != nullptr && session->cleanup_source_id == 0) { - session->cleanup_source_id = g_idle_add_full( - G_PRIORITY_DEFAULT_IDLE, native_menu_cleanup_idle_cb, session, - nullptr); - } -} - -static void native_menu_close(NativeMenuSession* session) { - if (session == nullptr) { - return; - } - if (session->popup_source_id != 0) { - g_source_remove(session->popup_source_id); - session->popup_source_id = 0; - } - native_menu_release_input_grab(session); - if (session->popover != nullptr && - gtk_widget_get_visible(session->popover)) { - // A menu selection and an explicit dismiss do not need an animated - // popdown. Hiding immediately releases the native surface while cleanup - // stays deferred until the current GTK callback has returned. - gtk_widget_hide(session->popover); - } - native_menu_schedule_cleanup(session); -} - static void native_menu_session_dispose(NativeMenuSession* session) { if (session == nullptr) { return; @@ -2421,34 +2305,34 @@ static void native_menu_session_dispose(NativeMenuSession* session) { if (owner != nullptr && owner->active == session) { owner->active = nullptr; } - if (session->popup_source_id != 0) { - g_source_remove(session->popup_source_id); - session->popup_source_id = 0; - } if (session->cleanup_source_id != 0) { g_source_remove(session->cleanup_source_id); session->cleanup_source_id = 0; } if (session->popover != nullptr) { - native_menu_release_input_grab(session); if (session->closed_signal_id != 0) { g_signal_handler_disconnect(session->popover, session->closed_signal_id); session->closed_signal_id = 0; } - if (session->hide_signal_id != 0) { - g_signal_handler_disconnect(session->popover, session->hide_signal_id); - session->hide_signal_id = 0; - } if (gtk_widget_get_visible(session->popover)) { gtk_widget_hide(session->popover); } - gtk_widget_destroy(session->popover); - g_clear_object(&session->popover); } - if (owner != nullptr && owner->view != nullptr) { - gtk_widget_insert_action_group(owner->view, kNativeMenuActionNamespace, + if (owner != nullptr && owner->menu_button != nullptr) { + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(owner->menu_button), + FALSE); + gtk_menu_button_set_menu_model(GTK_MENU_BUTTON(owner->menu_button), nullptr); + gtk_widget_insert_action_group(owner->menu_button, + kNativeMenuActionNamespace, nullptr); + } + g_clear_object(&session->popover); + if (owner != nullptr && owner->input_layer != nullptr) { + gtk_widget_hide(owner->input_layer); + } + + if (owner != nullptr && owner->view != nullptr) { if (gtk_widget_get_realized(owner->view)) { gtk_widget_grab_focus(owner->view); } @@ -2470,17 +2354,11 @@ static gboolean native_menu_cleanup_idle_cb(gpointer user_data) { static void native_menu_closed_cb(GtkPopover*, gpointer user_data) { auto* session = static_cast(user_data); - // ::closed is emitted when a popdown starts. Drop the grab now, but let the - // transition reach ::hide before destroying the popover. - native_menu_release_input_grab(session); -} - -static void native_menu_hidden_cb(GtkWidget*, gpointer user_data) { - auto* session = static_cast(user_data); - // ::hide is the end of the visual lifecycle, including an animated outside - // dismissal. Resolve the pending Dart method call only after GTK reaches it. - native_menu_release_input_grab(session); - native_menu_schedule_cleanup(session); + if (session->cleanup_source_id == 0) { + session->cleanup_source_id = g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, native_menu_cleanup_idle_cb, session, + nullptr); + } } static void native_menu_action_activated_cb(GSimpleAction* action, @@ -2491,7 +2369,9 @@ static void native_menu_action_activated_cb(GSimpleAction* action, GPOINTER_TO_INT( g_object_get_data(G_OBJECT(action), kNativeMenuActionIndexKey)) - 1; - native_menu_close(session); + if (session->popover != nullptr) { + gtk_popover_popdown(GTK_POPOVER(session->popover)); + } } static void native_menu_selection_activated_cb(GSimpleAction* action, @@ -2513,7 +2393,9 @@ static void native_menu_selection_activated_cb(GSimpleAction* action, g_simple_action_set_state(action, parameter); session->pending_selected_index = static_cast(parsed); - native_menu_close(session); + if (session->popover != nullptr) { + gtk_popover_popdown(GTK_POPOVER(session->popover)); + } } static gboolean native_menu_dismiss_active(NativeMenuHandlerData* data, @@ -2522,7 +2404,12 @@ static gboolean native_menu_dismiss_active(NativeMenuHandlerData* data, if (session == nullptr || session->id != session_id) { return FALSE; } - native_menu_close(session); + if (session->popover != nullptr && + gtk_widget_get_visible(session->popover)) { + gtk_popover_popdown(GTK_POPOVER(session->popover)); + } else { + native_menu_session_dispose(session); + } return TRUE; } @@ -2639,34 +2526,22 @@ static void decorate_native_menu_shortcuts(GtkWidget* popover, decorate_native_menu_shortcuts_cb, &decoration); } -static gboolean native_menu_popup_idle_cb(gpointer user_data) { - auto* session = static_cast(user_data); - session->popup_source_id = 0; - if (session->owner == nullptr || session->owner->active != session || - session->popover == nullptr) { - return G_SOURCE_REMOVE; - } - - // Flutter requests pointer-opened menus while GTK is still dispatching the - // originating button event. Start the modal popover after that dispatch has - // unwound so its grab begins from a clean pointer state. - gtk_popover_popup(GTK_POPOVER(session->popover)); - if (session->focus_first) { - gtk_widget_child_focus(session->popover, GTK_DIR_TAB_FORWARD); - } - return G_SOURCE_REMOVE; -} - static void show_native_menu(NativeMenuHandlerData* data, FlMethodCall* method_call, FlValue* args) { - if (data->view == nullptr || !gtk_widget_get_realized(data->view)) { + if (data->view == nullptr || data->input_layer == nullptr || + data->menu_layer == nullptr || data->menu_button == nullptr || + !gtk_widget_get_realized(data->view) || + !GTK_IS_FIXED(data->menu_layer) || + gtk_widget_get_parent(data->menu_button) != data->menu_layer || + gtk_widget_get_parent(data->menu_layer) != data->input_layer || + !GTK_IS_EVENT_BOX(data->input_layer) || + !GTK_IS_OVERLAY(gtk_widget_get_parent(data->input_layer))) { fl_method_call_respond_error(method_call, "unavailable", "The native menu host is unavailable.", nullptr, nullptr); return; } - GdkRectangle anchor = {}; gint64 session_id = 0; if (!fl_lookup_positive_int64_arg(args, "sessionId", &session_id) || @@ -2779,7 +2654,6 @@ static void show_native_menu(NativeMenuHandlerData* data, session->owner = data; session->id = session_id; session->entry_count = fl_value_get_length(entries); - session->focus_first = focus_first; session->pending_selected_index = -1; session->method_call = FL_METHOD_CALL(g_object_ref(G_OBJECT(method_call))); @@ -2821,7 +2695,8 @@ static void show_native_menu(NativeMenuHandlerData* data, fl_lookup_optional_bool_with_default(entry, "enabled", TRUE, &enabled); fl_lookup_optional_bool_with_default(entry, "checkable", FALSE, &checkable); - fl_lookup_optional_bool_with_default(entry, "selected", FALSE, &selected); + fl_lookup_optional_bool_with_default(entry, "selected", FALSE, + &selected); if (checkable) { const size_t run_start = index; @@ -2848,8 +2723,8 @@ static void show_native_menu(NativeMenuHandlerData* data, run_end++; } - g_autofree gchar* group_action_name = g_strdup_printf( - "select-group-%u", checkable_group_index++); + g_autofree gchar* group_action_name = + g_strdup_printf("select-group-%u", checkable_group_index++); GSimpleAction* group_action = g_simple_action_new_stateful( group_action_name, G_VARIANT_TYPE_STRING, g_variant_new_string(selected_target)); @@ -2863,8 +2738,7 @@ static void show_native_menu(NativeMenuHandlerData* data, for (size_t run_index = run_start; run_index < run_end; run_index++) { FlValue* run_entry = fl_value_get_list_value(entries, run_index); - const gchar* run_label = - fl_lookup_string_arg(run_entry, "label"); + const gchar* run_label = fl_lookup_string_arg(run_entry, "label"); const gchar* run_icon = fl_lookup_string_arg(run_entry, "icon"); const gchar* run_shortcut = fl_lookup_string_arg(run_entry, "shortcut"); @@ -2918,16 +2792,33 @@ static void show_native_menu(NativeMenuHandlerData* data, flush_section(); g_object_unref(section); + gtk_fixed_move(GTK_FIXED(data->menu_layer), data->menu_button, anchor.x, + anchor.y); + gtk_widget_set_size_request(data->menu_button, anchor.width, anchor.height); + gtk_widget_show(data->input_layer); + + gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(data->menu_button), TRUE); + gtk_menu_button_set_direction( + GTK_MENU_BUTTON(data->menu_button), + preferred_position == GTK_POS_TOP ? GTK_ARROW_UP : GTK_ARROW_DOWN); gtk_widget_insert_action_group( - data->view, kNativeMenuActionNamespace, + data->menu_button, kNativeMenuActionNamespace, G_ACTION_GROUP(session->action_group)); - session->popover = gtk_popover_new_from_model( - data->view, G_MENU_MODEL(session->model)); - g_object_ref_sink(session->popover); + gtk_menu_button_set_menu_model(GTK_MENU_BUTTON(data->menu_button), + G_MENU_MODEL(session->model)); + session->popover = + GTK_WIDGET(gtk_menu_button_get_popover(GTK_MENU_BUTTON(data->menu_button))); + if (session->popover == nullptr) { + fl_method_call_respond_error(method_call, "unavailable", + "GTK could not create the native menu.", + nullptr, nullptr); + g_clear_object(&session->method_call); + native_menu_session_dispose(session); + return; + } + g_object_ref(session->popover); style_native_popover(session->popover); - gtk_popover_set_pointing_to(GTK_POPOVER(session->popover), &anchor); - gtk_popover_set_position(GTK_POPOVER(session->popover), - preferred_position); + gtk_popover_set_position(GTK_POPOVER(session->popover), preferred_position); gtk_popover_set_constrain_to(GTK_POPOVER(session->popover), GTK_POPOVER_CONSTRAINT_WINDOW); gtk_popover_set_modal(GTK_POPOVER(session->popover), TRUE); @@ -2936,10 +2827,10 @@ static void show_native_menu(NativeMenuHandlerData* data, session->icon_names); session->closed_signal_id = g_signal_connect( session->popover, "closed", G_CALLBACK(native_menu_closed_cb), session); - session->hide_signal_id = g_signal_connect( - session->popover, "hide", G_CALLBACK(native_menu_hidden_cb), session); - session->popup_source_id = g_idle_add_full( - G_PRIORITY_DEFAULT_IDLE, native_menu_popup_idle_cb, session, nullptr); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(data->menu_button), TRUE); + if (focus_first) { + gtk_widget_child_focus(session->popover, GTK_DIR_TAB_FORWARD); + } } static void native_menu_handler_data_free(gpointer user_data) { @@ -2952,6 +2843,21 @@ static void native_menu_handler_data_free(gpointer user_data) { G_OBJECT(data->view), reinterpret_cast(&data->view)); } + if (data->input_layer != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(data->input_layer), + reinterpret_cast(&data->input_layer)); + } + if (data->menu_layer != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(data->menu_layer), + reinterpret_cast(&data->menu_layer)); + } + if (data->menu_button != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(data->menu_button), + reinterpret_cast(&data->menu_button)); + } g_free(data); } @@ -2977,15 +2883,60 @@ static void native_menu_method_call_cb(FlMethodChannel*, } } -static void register_native_menu_channel(MyApplication* self, FlView* view) { +static NativeMenuHostWidgets create_native_menu_host(FlView* view) { + NativeMenuHostWidgets host = {}; + host.overlay = gtk_overlay_new(); + gtk_container_add(GTK_CONTAINER(host.overlay), GTK_WIDGET(view)); + + host.input_layer = gtk_event_box_new(); + gtk_event_box_set_above_child(GTK_EVENT_BOX(host.input_layer), TRUE); + gtk_event_box_set_visible_window(GTK_EVENT_BOX(host.input_layer), FALSE); + gtk_widget_set_halign(host.input_layer, GTK_ALIGN_FILL); + gtk_widget_set_valign(host.input_layer, GTK_ALIGN_FILL); + gtk_overlay_add_overlay(GTK_OVERLAY(host.overlay), host.input_layer); + + host.menu_layer = gtk_fixed_new(); + gtk_container_add(GTK_CONTAINER(host.input_layer), host.menu_layer); + + host.menu_button = gtk_menu_button_new(); + gtk_widget_set_opacity(host.menu_button, 0); + gtk_widget_set_can_focus(host.menu_button, FALSE); + gtk_widget_set_focus_on_click(host.menu_button, FALSE); + gtk_widget_set_size_request(host.menu_button, 1, 1); + gtk_fixed_put(GTK_FIXED(host.menu_layer), host.menu_button, 0, 0); + + gtk_widget_show(host.menu_button); + gtk_widget_show(host.menu_layer); + gtk_widget_set_no_show_all(host.input_layer, TRUE); + gtk_widget_hide(host.input_layer); + gtk_widget_show(host.overlay); + return host; +} + +static void register_native_menu_channel( + MyApplication* self, + FlView* view, + const NativeMenuHostWidgets& host) { g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); self->native_menu_channel = fl_method_channel_new( fl_engine_get_binary_messenger(fl_view_get_engine(view)), kNativeMenuChannel, FL_METHOD_CODEC(codec)); auto* data = g_new0(NativeMenuHandlerData, 1); data->view = GTK_WIDGET(view); + data->input_layer = host.input_layer; + data->menu_layer = host.menu_layer; + data->menu_button = host.menu_button; g_object_add_weak_pointer(G_OBJECT(data->view), reinterpret_cast(&data->view)); + g_object_add_weak_pointer( + G_OBJECT(data->input_layer), + reinterpret_cast(&data->input_layer)); + g_object_add_weak_pointer( + G_OBJECT(data->menu_layer), + reinterpret_cast(&data->menu_layer)); + g_object_add_weak_pointer( + G_OBJECT(data->menu_button), + reinterpret_cast(&data->menu_button)); fl_method_channel_set_method_call_handler( self->native_menu_channel, native_menu_method_call_cb, data, native_menu_handler_data_free); @@ -3044,10 +2995,13 @@ static void my_application_activate(GApplication* application) { fl_view_set_background_color(view, &background_color); gtk_widget_show(GTK_WIDGET(view)); + NativeMenuHostWidgets native_menu_host = create_native_menu_host(view); + GtkWidget* window_content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_box_pack_start(GTK_BOX(window_content), self->titlebar_handle, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(window_content), GTK_WIDGET(view), TRUE, TRUE, 0); + gtk_box_pack_start(GTK_BOX(window_content), native_menu_host.overlay, TRUE, + TRUE, 0); gtk_widget_show(window_content); gtk_container_add(GTK_CONTAINER(window), window_content); @@ -3057,7 +3011,7 @@ static void my_application_activate(GApplication* application) { fl_register_plugins(FL_PLUGIN_REGISTRY(view)); register_header_bar_channel(self, view); - register_native_menu_channel(self, view); + register_native_menu_channel(self, view, native_menu_host); gtk_widget_grab_focus(GTK_WIDGET(view)); schedule_header_bar_focus_state_refresh(self); diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index de6aae3..767d1b4 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -603,12 +603,19 @@ void main() { expect(css, contains('background-color: alpha(currentColor, 0.16)')); expect(css, contains('background-color: alpha(currentColor, 0.10)')); expect(css, contains('popover.background.')); - expect(css, contains('modelbutton.%s:hover:not(:disabled)')); - expect(css, contains('modelbutton.%s.%s:not(:disabled)')); + expect(css, contains('modelbutton:hover:not(:disabled)')); + expect(css, contains('row:hover:not(:disabled)')); + expect(css, contains('.%s:hover:not(:disabled)')); + expect(css, contains('.%s:focus:not(:disabled)')); + expect(css, contains('border-color: transparent')); + expect(css, contains('outline-width: 0')); + expect(css, contains('font-size: 0.92em')); + expect(css, contains('padding: 2px 6px')); + expect(css, isNot(contains('modelbutton.%s.%s:not(:disabled)'))); expect(css, contains('box-shadow: 0 1px 3px')); expect(css, contains('tooltip.background')); expect(css, contains('tooltip decoration')); - for (final interactionSelector in [':focus', '@define-color']) { + for (final interactionSelector in ['@define-color']) { expect(css, isNot(contains(interactionSelector))); } }); @@ -960,6 +967,11 @@ void main() { test('native header controls preserve GTK geometry with neutral states', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); + final contentMenuStart = native.indexOf( + 'constexpr char kNativeMenuActionNamespace', + ); + expect(contentMenuStart, isNonNegative); + final headerOnly = native.substring(0, contentMenuStart); expect(native, contains('kHeaderButtonHeight = 32')); expect(native, contains('kHeaderButtonSpacing = 8')); @@ -981,8 +993,8 @@ void main() { expect(native, isNot(contains('button.busymark-header-button:hover'))); expect(native, isNot(contains('button.busymark-header-button:checked'))); expect(native, isNot(contains('button.busymark-header-button:focus'))); - expect(native, isNot(contains('GTK_RELIEF_NONE'))); - expect(native, isNot(contains('GTK_STYLE_CLASS_FLAT'))); + expect(headerOnly, isNot(contains('GTK_RELIEF_NONE'))); + expect(headerOnly, isNot(contains('GTK_STYLE_CLASS_FLAT'))); expect(native, contains('"busymark-header-control"')); expect(native, isNot(contains('"busymark-header-icon-button"'))); expect(native, contains('alpha(currentColor, 0.07)')); @@ -991,7 +1003,7 @@ void main() { expect(native, contains('alpha(currentColor, 0.13)')); expect(native, contains('alpha(currentColor, 0.19)')); expect(native, isNot(contains('"outline-width: 2px;"'))); - expect(native, isNot(contains('"outline-width: 0;"'))); + expect(native, contains('"outline-width: 0;"')); expect(native, contains('"searchSubmitted"')); expect( native, @@ -1269,32 +1281,21 @@ void main() { } expect(native, contains('view_mode_icon_name(mode)')); expect(native, contains('view_mode_icon_name("split")')); - expect(native, contains('modelbutton.%s:hover:not(:disabled)')); - expect(native, contains('modelbutton.%s.%s:not(:disabled)')); + expect(native, contains('modelbutton:hover:not(:disabled)')); + expect(native, isNot(contains('modelbutton.%s:hover:not(:disabled)'))); expect(native, contains('kNativeMenuItemStyleClass')); - expect(native, contains('kNativeMenuItemHoverStyleClass')); expect(native, contains('style_native_menu_item(widget)')); - expect(native, contains('native_menu_item_enter_cb')); - expect(native, contains('native_menu_item_motion_cb')); - expect(native, contains('native_menu_item_leave_cb')); - expect(native, contains('native_menu_popover_hidden_reset_hover_cb')); - expect( - native, - contains( - 'gtk_style_context_add_class(context, ' - 'kNativeMenuItemHoverStyleClass)', - ), - ); + expect(native, isNot(contains('kNativeMenuItemHoverStyleClass'))); + expect(native, isNot(contains('native_menu_item_enter_cb'))); + expect(native, isNot(contains('native_menu_item_motion_cb'))); + expect(native, isNot(contains('native_menu_item_leave_cb'))); expect( native, - contains( - 'gtk_style_context_remove_class(context, ' - 'kNativeMenuItemHoverStyleClass)', - ), + isNot(contains('native_menu_popover_hidden_reset_hover_cb')), ); expect(native, isNot(contains('modelbutton:focus'))); expect(native, isNot(contains('modelbutton:active'))); - expect(native, isNot(contains('outline-width: 0;'))); + expect(native, contains('outline-width: 0;')); expect(native, contains('static GtkWidget* create_model_menu_button')); expect(native, contains('gtk_menu_button_set_menu_model')); expect( @@ -1364,29 +1365,68 @@ void main() { final toolbar = File( 'lib/src/editor/wysiwyg/wysiwyg_toolbar.dart', ).readAsStringSync(); + final nativeMenuStart = native.indexOf( + 'constexpr char kNativeMenuActionNamespace', + ); + final nativeMenuEnd = native.indexOf( + 'static void my_application_activate', + nativeMenuStart, + ); + expect(nativeMenuStart, isNonNegative); + expect(nativeMenuEnd, greaterThan(nativeMenuStart)); + final nativeMenu = native.substring(nativeMenuStart, nativeMenuEnd); expect(native, contains('kNativeMenuChannel')); expect(native, contains('"busymark/native_menus"')); - expect(native, contains('gtk_popover_new_from_model(')); - expect(native, contains('g_menu_append_section(')); - expect(native, contains('decorate_native_menu_shortcuts(')); - expect(native, contains('kMenuIconAttribute')); - expect(native, contains('gtk_image_new_from_icon_name(icon_name')); - expect(native, contains('session->icon_names')); - expect(native, contains('native_menu_selection_activated_cb')); - expect(native, contains('G_VARIANT_TYPE_STRING')); - expect(native, contains('gtk_popover_set_modal')); - expect(native, contains('gtk_popover_set_constrain_to')); - expect(native, contains('native_menu_release_input_grab(session)')); - expect(native, contains('native_menu_close(session)')); - expect(native, contains('native_menu_popup_idle_cb')); - expect( - native, + expect(nativeMenu, contains('struct NativeMenuHostWidgets')); + expect(nativeMenu, contains('gtk_event_box_set_above_child(')); + expect(nativeMenu, contains('gtk_widget_show(data->input_layer)')); + expect(nativeMenu, contains('GMenu* model;')); + expect(nativeMenu, contains('GSimpleActionGroup* action_group;')); + expect(nativeMenu, contains('g_simple_action_new_stateful(')); + expect(nativeMenu, contains('g_menu_item_set_action_and_target_value(')); + expect(nativeMenu, contains('GTK_IS_MODEL_BUTTON(widget)')); + expect(nativeMenu, contains('style_native_menu_item(widget)')); + expect(nativeMenu, contains('gtk_menu_button_set_menu_model(')); + expect(nativeMenu, contains('gtk_menu_button_get_popover(')); + expect( + nativeMenu, + contains( + 'gtk_widget_insert_action_group(\n' + ' data->menu_button, kNativeMenuActionNamespace', + ), + ); + expect(nativeMenu, isNot(contains('gtk_button_new()'))); + expect(nativeMenu, isNot(contains('gtk_radio_button_new('))); + expect(nativeMenu, isNot(contains('gtk_toggle_button_new()'))); + expect(nativeMenu, isNot(contains('"radio-checked-symbolic"'))); + expect(nativeMenu, isNot(contains('"radio-symbolic"'))); + expect(native, isNot(contains('kNativeMenuSelectedIndicatorStyleClass'))); + expect(nativeMenu, isNot(contains('create_native_content_menu_item('))); + expect(nativeMenu, isNot(contains('gtk_render_option('))); + expect(native, isNot(contains('kNativeContentMenuPopoverStyleClass'))); + expect(nativeMenu, contains('g_menu_append_section(')); + expect(nativeMenu, contains('add_model_button_presentation(')); + expect(nativeMenu, contains('native_menu_action_activated_cb')); + expect(nativeMenu, contains('native_menu_selection_activated_cb')); + expect( + nativeMenu, contains( - 'session->popup_source_id = g_idle_add_full(\n' - ' G_PRIORITY_DEFAULT_IDLE, native_menu_popup_idle_cb', + 'gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(data->menu_button), TRUE)', ), ); + expect(nativeMenu, isNot(contains('gtk_popover_new_from_model('))); + expect(nativeMenu, isNot(contains('gtk_popover_new(data->view'))); + expect( + nativeMenu, + isNot(contains('gtk_widget_insert_action_group(\n data->view')), + ); + expect(nativeMenu, isNot(contains('GTK_STATE_FLAG_PRELIGHT'))); + expect(nativeMenu, isNot(contains('gtk_widget_set_state_flags'))); + expect(nativeMenu, contains('gtk_popover_set_modal')); + expect(nativeMenu, contains('gtk_popover_set_constrain_to')); + expect(nativeMenu, isNot(contains('native_menu_release_input_grab'))); + expect(nativeMenu, isNot(contains('native_menu_popup_idle_cb'))); expect(service, contains('final String? shortcut')); expect(service, contains('final String? iconName')); expect(service, contains("'icon': iconName!")); @@ -1419,27 +1459,37 @@ void main() { } }); - test('native content menus release their GTK grab on every close path', () { + test('content menu consolidation leaves GTK headerbar menus intact', () { + final native = File('linux/runner/my_application.cc').readAsStringSync(); + final nativeMenuStart = native.indexOf( + 'constexpr char kNativeMenuActionNamespace', + ); + final nativeMenuEnd = native.indexOf( + 'static void my_application_activate', + nativeMenuStart, + ); + expect(nativeMenuStart, isNonNegative); + expect(nativeMenuEnd, greaterThan(nativeMenuStart)); + + final headerbar = native.substring(0, nativeMenuStart); + final contentMenu = native.substring(nativeMenuStart, nativeMenuEnd); + expect(headerbar, contains('static GtkWidget* create_model_menu_button')); + expect(headerbar, contains('gtk_menu_button_set_menu_model(')); + expect(headerbar, contains('GTK_IS_MODEL_BUTTON(widget)')); + expect(contentMenu, contains('gtk_menu_button_set_menu_model(')); + expect(contentMenu, contains('GTK_IS_MODEL_BUTTON(widget)')); + expect(contentMenu, isNot(contains('create_model_menu_button('))); + }); + + test('native content menus retire their mapped GTK input host', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); - final releaseGrab = RegExp( - r'static void native_menu_release_input_grab[\s\S]*?' - r'(?=static void native_menu_schedule_cleanup)', - ).firstMatch(native)?.group(0); - final close = RegExp( - r'static void native_menu_close[\s\S]*?' - r'(?=static void native_menu_session_dispose)', - ).firstMatch(native)?.group(0); final dispose = RegExp( r'static void native_menu_session_dispose[\s\S]*?' r'(?=static gboolean native_menu_cleanup_idle_cb)', ).firstMatch(native)?.group(0); final closed = RegExp( r'static void native_menu_closed_cb[\s\S]*?' - r'(?=static void native_menu_hidden_cb)', - ).firstMatch(native)?.group(0); - final hidden = RegExp( - r'static void native_menu_hidden_cb[\s\S]*?' r'(?=static void native_menu_action_activated_cb)', ).firstMatch(native)?.group(0); final show = RegExp( @@ -1447,28 +1497,37 @@ void main() { r'(?=static void native_menu_handler_data_free)', ).firstMatch(native)?.group(0); - expect(releaseGrab, contains('gtk_popover_set_modal(')); - expect(releaseGrab, contains('FALSE')); - expect(releaseGrab, contains('gtk_grab_remove(session->popover)')); - expect(close, contains('native_menu_release_input_grab(session)')); - expect(close, contains('gtk_widget_hide(session->popover)')); - expect(close, contains('native_menu_schedule_cleanup(session)')); - expect( - close!.indexOf('native_menu_release_input_grab(session)'), - lessThan(close.indexOf('gtk_widget_hide(session->popover)')), - ); - expect(dispose, contains('native_menu_release_input_grab(session)')); - expect(closed, contains('native_menu_release_input_grab(session)')); - expect(closed, isNot(contains('native_menu_schedule_cleanup(session)'))); - expect(hidden, contains('native_menu_release_input_grab(session)')); - expect(hidden, contains('native_menu_schedule_cleanup(session)')); - expect(show, contains('native_menu_popup_idle_cb')); - expect(show, contains('"hide", G_CALLBACK(native_menu_hidden_cb)')); + expect(dispose, contains('gtk_widget_hide(session->popover)')); + expect( + dispose, + matches( + RegExp( + r'gtk_toggle_button_set_active\(GTK_TOGGLE_BUTTON\(owner->menu_button\),\s*FALSE\)', + ), + ), + ); + expect(dispose, contains('gtk_menu_button_set_menu_model(')); + expect(dispose, contains('kNativeMenuActionNamespace, nullptr')); + expect(dispose, isNot(contains('gtk_widget_destroy(session->popover)'))); + expect(dispose, contains('gtk_widget_hide(owner->input_layer)')); + expect(dispose, contains('g_clear_object(&session->popover)')); + expect(closed, contains('g_idle_add_full(')); + expect(show, contains('gtk_fixed_move(')); + expect(show, contains('gtk_widget_show(data->input_layer)')); + expect(show, contains('gtk_menu_button_set_menu_model(')); + expect(show, contains('gtk_menu_button_get_popover(')); + expect(show, contains('G_ACTION_GROUP(session->action_group)')); + expect(show, isNot(contains('gtk_button_new()'))); + expect(show, isNot(contains('gtk_radio_button_new('))); + expect(show, contains('gtk_toggle_button_set_active(')); + expect(show, isNot(contains('gtk_popover_new_from_model('))); + expect(show, isNot(contains('gtk_menu_button_set_popover('))); expect(show, isNot(contains('gtk_popover_popup('))); expect( native, - isNot(contains('gtk_popover_popdown(GTK_POPOVER(session->popover))')), + contains('gtk_popover_popdown(GTK_POPOVER(session->popover))'), ); + expect(native, isNot(contains('gtk_grab_remove('))); }); test('welcome page has a sidebar but no document controls', () { From 18a8cc89d6ef6544fee7f1366373b5304541c099 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 1 Aug 2026 22:00:43 -0700 Subject: [PATCH 28/29] Refactor markdown parsing and source editor components for improved performance and layout caching --- lib/src/editor/source/source_editor.dart | 67 +-- lib/src/editor/source/source_gutter.dart | 335 ++++++++++++++- lib/src/editor/source_highlighter.dart | 245 ++++++++--- .../wysiwyg/wysiwyg_document_controller.dart | 37 +- lib/src/markdown/markdown_parser.dart | 28 +- .../presentation/workspace_screen.dart | 387 ++++++++++-------- lib/src/workspace/workspace_controller.dart | 148 +++++-- lib/src/workspace/workspace_model.dart | 10 +- lib/src/workspace/workspace_service.dart | 67 ++- test/src/app_smoke_test.dart | 108 ++++- test/src/busymark_document_test.dart | 18 + test/src/markdown_parser_test.dart | 18 + test/src/source_audit_test.dart | 29 +- test/src/source_gutter_diagnostics_test.dart | 113 ++++- test/src/source_highlighter_test.dart | 69 ++++ test/src/workspace_controller_test.dart | 69 +++- test/src/workspace_service_test.dart | 28 +- 17 files changed, 1424 insertions(+), 352 deletions(-) diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index 0bb5859..a6e0090 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -62,6 +62,7 @@ class BusyMarkSourceEditorState extends State { final _sourceEditorKey = GlobalKey(); final _foldedRegionKeys = {}; final _searchController = SourceSearchController(); + final _lineLayoutCache = SourceLineLayoutCache(); List _foldRegions = const []; String _lastPath = ''; @@ -231,6 +232,7 @@ class BusyMarkSourceEditorState extends State { collapsedRegionKeys: _foldedRegionKeys, foldRegions: _foldRegions, diagnosticMarkers: markers, + layoutCache: _lineLayoutCache, onToggleFold: _toggleFold, child: SizedBox( key: _sourceEditorKey, @@ -810,6 +812,7 @@ class _SourceEditorFrame extends StatelessWidget { required this.foldRegions, required this.collapsedRegionKeys, required this.diagnosticMarkers, + required this.layoutCache, required this.onToggleFold, required this.child, }); @@ -829,6 +832,7 @@ class _SourceEditorFrame extends StatelessWidget { final List foldRegions; final Set collapsedRegionKeys; final List diagnosticMarkers; + final SourceLineLayoutCache layoutCache; final ValueChanged onToggleFold; final Widget child; @@ -865,6 +869,7 @@ class _SourceEditorFrame extends StatelessWidget { collapsedRegionKeys: collapsedRegionKeys, diagnosticMarkers: diagnosticMarkers, onToggleFold: onToggleFold, + layoutCache: layoutCache, ), ), VerticalDivider( @@ -883,18 +888,21 @@ class _SourceEditorFrame extends StatelessWidget { textWidth: textWidth, ), ), - Positioned.fill( - child: _CollapsedSourceLineOverlay( - controller: controller, - scrollController: scrollController, - lineHeight: lineHeight, - textWidth: textWidth, - textStyle: textStyle, - strutStyle: strutStyle, - foldRegions: foldRegions, - collapsedRegionKeys: collapsedRegionKeys, + if (collapsedRegionKeys.isNotEmpty) + Positioned.fill( + child: _CollapsedSourceLineOverlay( + controller: controller, + scrollController: scrollController, + lineHeight: lineHeight, + textWidth: textWidth, + textStyle: textStyle, + strutStyle: strutStyle, + foldRegions: foldRegions, + collapsedRegionKeys: collapsedRegionKeys, + diagnosticMarkers: diagnosticMarkers, + layoutCache: layoutCache, + ), ), - ), Positioned.fill(child: child), ], ), @@ -924,11 +932,24 @@ class _SourceRenderedTextLayer extends StatelessWidget { @override Widget build(BuildContext context) { + final renderedText = RichText( + textDirection: TextDirection.ltr, + text: controller.buildSourceTextSpan( + context: context, + style: textStyle, + hideCollapsedStartLines: true, + ), + strutStyle: strutStyle, + textHeightBehavior: sourceTextHeightBehavior, + textScaler: MediaQuery.textScalerOf(context), + textWidthBasis: TextWidthBasis.parent, + ); return IgnorePointer( child: ClipRect( child: AnimatedBuilder( - animation: Listenable.merge([controller, scrollController]), - builder: (context, _) { + animation: scrollController, + child: renderedText, + builder: (context, child) { final scrollOffset = safeScrollOffset(scrollController); return Stack( clipBehavior: Clip.none, @@ -937,18 +958,7 @@ class _SourceRenderedTextLayer extends StatelessWidget { top: _SourceEditorFrame.editorPaddingTop - scrollOffset, left: _SourceEditorFrame.editorPaddingLeft, width: textWidth, - child: RichText( - textDirection: TextDirection.ltr, - text: controller.buildSourceTextSpan( - context: context, - style: textStyle, - hideCollapsedStartLines: true, - ), - strutStyle: strutStyle, - textHeightBehavior: sourceTextHeightBehavior, - textScaler: MediaQuery.textScalerOf(context), - textWidthBasis: TextWidthBasis.parent, - ), + child: child!, ), ], ); @@ -969,6 +979,8 @@ class _CollapsedSourceLineOverlay extends StatelessWidget { required this.strutStyle, required this.foldRegions, required this.collapsedRegionKeys, + required this.diagnosticMarkers, + required this.layoutCache, }); final BusyMarkSourceEditingController controller; @@ -979,6 +991,8 @@ class _CollapsedSourceLineOverlay extends StatelessWidget { final StrutStyle? strutStyle; final List foldRegions; final Set collapsedRegionKeys; + final List diagnosticMarkers; + final SourceLineLayoutCache layoutCache; @override Widget build(BuildContext context) { @@ -989,7 +1003,7 @@ class _CollapsedSourceLineOverlay extends StatelessWidget { return AnimatedBuilder( animation: Listenable.merge([controller, scrollController]), builder: (context, _) { - final layouts = sourceLineLayoutEntries( + final layouts = layoutCache.resolve( context, controller: controller, foldRegions: foldRegions, @@ -998,6 +1012,7 @@ class _CollapsedSourceLineOverlay extends StatelessWidget { strutStyle: strutStyle, lineHeight: lineHeight, textWidth: textWidth, + diagnostics: diagnosticMarkers, ); final linesByNumber = { for (final line in sourceLineInfos(controller.fullText)) diff --git a/lib/src/editor/source/source_gutter.dart b/lib/src/editor/source/source_gutter.dart index cc93057..fe22220 100644 --- a/lib/src/editor/source/source_gutter.dart +++ b/lib/src/editor/source/source_gutter.dart @@ -90,6 +90,7 @@ class BusyMarkSourceGutter extends StatelessWidget { required this.collapsedRegionKeys, required this.diagnosticMarkers, required this.onToggleFold, + required this.layoutCache, }); final BusyMarkSourceEditingController controller; @@ -102,6 +103,7 @@ class BusyMarkSourceGutter extends StatelessWidget { final Set collapsedRegionKeys; final List diagnosticMarkers; final ValueChanged onToggleFold; + final SourceLineLayoutCache layoutCache; @override Widget build(BuildContext context) { @@ -114,7 +116,7 @@ class BusyMarkSourceGutter extends StatelessWidget { return AnimatedBuilder( animation: Listenable.merge([controller, scrollController]), builder: (context, _) { - final layouts = sourceLineLayoutEntries( + final layouts = layoutCache.resolve( context, controller: controller, foldRegions: foldRegions, @@ -331,6 +333,337 @@ class SourceLineLayoutEntry { final double height; } +/// Retains expensive full-document text geometry while only the viewport's +/// scroll offset changes. Text edits, folding, diagnostics, typography, or +/// width changes naturally invalidate the cached entries. +class SourceLineLayoutCache { + SourceDocument? _document; + SourceSyntaxLanguage? _language; + List? _foldRegions; + Set _collapsedRegionKeys = const {}; + TextStyle? _textStyle; + StrutStyle? _strutStyle; + double? _lineHeight; + double? _textWidth; + TextScaler? _textScaler; + List? _diagnostics; + List? _entries; + + List resolve( + BuildContext context, { + required BusyMarkSourceEditingController controller, + required List foldRegions, + required Set collapsedRegionKeys, + required TextStyle textStyle, + required StrutStyle? strutStyle, + required double lineHeight, + required double textWidth, + required List diagnostics, + }) { + final textScaler = MediaQuery.textScalerOf(context); + final cached = _entries; + final geometryMatches = + cached != null && + _language == controller.language && + _textStyle == textStyle && + _strutStyle == strutStyle && + _lineHeight == lineHeight && + _textWidth == textWidth && + _textScaler == textScaler && + _collapsedRegionKeys.isEmpty && + collapsedRegionKeys.isEmpty; + if (geometryMatches) { + final document = controller.document; + if (identical(_document, document)) { + if (identical(_foldRegions, foldRegions) && + identical(_diagnostics, diagnostics)) { + return cached; + } + final updated = _entriesWithCurrentGutterModel( + cached, + document: document, + foldRegions: foldRegions, + collapsedRegionKeys: collapsedRegionKeys, + diagnostics: diagnostics, + ); + _remember( + controller: controller, + foldRegions: foldRegions, + collapsedRegionKeys: collapsedRegionKeys, + textStyle: textStyle, + strutStyle: strutStyle, + lineHeight: lineHeight, + textWidth: textWidth, + textScaler: textScaler, + diagnostics: diagnostics, + entries: updated, + ); + return updated; + } + final incremental = _incrementalEntries( + context, + controller: controller, + previousDocument: _document, + previousEntries: cached, + foldRegions: foldRegions, + collapsedRegionKeys: collapsedRegionKeys, + textStyle: textStyle, + strutStyle: strutStyle, + lineHeight: lineHeight, + textWidth: textWidth, + diagnostics: diagnostics, + ); + if (incremental != null) { + _remember( + controller: controller, + foldRegions: foldRegions, + collapsedRegionKeys: collapsedRegionKeys, + textStyle: textStyle, + strutStyle: strutStyle, + lineHeight: lineHeight, + textWidth: textWidth, + textScaler: textScaler, + diagnostics: diagnostics, + entries: incremental, + ); + return incremental; + } + } + final entries = sourceLineLayoutEntries( + context, + controller: controller, + foldRegions: foldRegions, + collapsedRegionKeys: collapsedRegionKeys, + textStyle: textStyle, + strutStyle: strutStyle, + lineHeight: lineHeight, + textWidth: textWidth, + diagnostics: diagnostics, + ); + _remember( + controller: controller, + foldRegions: foldRegions, + collapsedRegionKeys: collapsedRegionKeys, + textStyle: textStyle, + strutStyle: strutStyle, + lineHeight: lineHeight, + textWidth: textWidth, + textScaler: textScaler, + diagnostics: diagnostics, + entries: entries, + ); + return entries; + } + + void _remember({ + required BusyMarkSourceEditingController controller, + required List foldRegions, + required Set collapsedRegionKeys, + required TextStyle textStyle, + required StrutStyle? strutStyle, + required double lineHeight, + required double textWidth, + required TextScaler textScaler, + required List diagnostics, + required List entries, + }) { + _document = controller.document; + _language = controller.language; + _foldRegions = foldRegions; + _collapsedRegionKeys = Set.unmodifiable(collapsedRegionKeys); + _textStyle = textStyle; + _strutStyle = strutStyle; + _lineHeight = lineHeight; + _textWidth = textWidth; + _textScaler = textScaler; + _diagnostics = diagnostics; + _entries = entries; + } +} + +List _entriesWithCurrentGutterModel( + List entries, { + required SourceDocument document, + required List foldRegions, + required Set collapsedRegionKeys, + required Iterable diagnostics, +}) { + final gutterLines = sourceGutterModel( + document: document, + foldRegions: foldRegions, + collapsedRegionKeys: collapsedRegionKeys, + diagnostics: diagnostics, + ); + if (gutterLines.length != entries.length) { + return entries; + } + return List.generate( + entries.length, + (index) => SourceLineLayoutEntry( + gutterLine: gutterLines[index], + top: entries[index].top, + height: entries[index].height, + ), + growable: false, + ); +} + +List? _incrementalEntries( + BuildContext context, { + required BusyMarkSourceEditingController controller, + required SourceDocument? previousDocument, + required List previousEntries, + required List foldRegions, + required Set collapsedRegionKeys, + required TextStyle textStyle, + required StrutStyle? strutStyle, + required double lineHeight, + required double textWidth, + required Iterable diagnostics, +}) { + final edit = controller.lastVisibleEdit; + if (previousDocument == null || + previousDocument.hasHiddenRanges || + controller.document.hasHiddenRanges || + edit == null || + previousEntries.length != previousDocument.lineIndex.lineCount || + edit.fullStart < 0 || + edit.fullEnd < edit.fullStart || + edit.fullEnd > previousDocument.fullText.length || + controller.fullText.length != + previousDocument.fullText.length + edit.fullDelta) { + return null; + } + final previousPrefix = previousDocument.fullText.substring(0, edit.fullStart); + final previousSuffix = previousDocument.fullText.substring(edit.fullEnd); + if (!controller.fullText.startsWith(previousPrefix) || + !controller.fullText.endsWith(previousSuffix)) { + return null; + } + + final previousStartLine = previousDocument.lineIndex.lineNumberAtOffset( + edit.fullStart, + ); + final previousEndLine = previousDocument.lineIndex.lineNumberAtOffset( + edit.fullEnd, + ); + final document = controller.document; + final nextEndLine = document.lineIndex.lineNumberAtOffset( + edit.fullStart + edit.replacement.length, + ); + if (controller.language == SourceSyntaxLanguage.markdown && + (_markdownEditCanChangeLayout( + previousDocument, + previousStartLine, + previousEndLine, + ) || + _markdownEditCanChangeLayout( + document, + previousStartLine, + nextEndLine, + ))) { + return null; + } + final gutterLines = sourceGutterModel( + document: document, + foldRegions: foldRegions, + collapsedRegionKeys: collapsedRegionKeys, + diagnostics: diagnostics, + ); + if (gutterLines.length != document.lineIndex.lineCount) { + return null; + } + + final result = []; + var top = previousEntries.first.top; + for (var lineNumber = 1; lineNumber <= gutterLines.length; lineNumber++) { + final double height; + final double advance; + if (lineNumber < previousStartLine) { + height = previousEntries[lineNumber - 1].height; + advance = _sourceLineAdvance(previousEntries, lineNumber - 1); + } else if (lineNumber <= nextEndLine) { + final measurement = _measureSourceLogicalLine( + context, + document.lineIndex.lineAt(lineNumber), + textStyle: textStyle, + strutStyle: strutStyle, + lineHeight: lineHeight, + textWidth: textWidth, + ); + height = measurement.height; + advance = measurement.advance; + } else { + final previousLineNumber = lineNumber + previousEndLine - nextEndLine; + if (previousLineNumber < 1 || + previousLineNumber > previousEntries.length) { + return null; + } + height = previousEntries[previousLineNumber - 1].height; + advance = _sourceLineAdvance(previousEntries, previousLineNumber - 1); + } + result.add( + SourceLineLayoutEntry( + gutterLine: gutterLines[lineNumber - 1], + top: top, + height: height, + ), + ); + top += advance; + } + return result; +} + +bool _markdownEditCanChangeLayout( + SourceDocument document, + int startLine, + int endLine, +) { + for (var lineNumber = startLine; lineNumber <= endLine; lineNumber++) { + if (lineNumber < 1 || lineNumber > document.lineIndex.lineCount) { + continue; + } + final text = document.lineIndex.lineAt(lineNumber).text; + // These characters can introduce a heading, emphasis, inline code, or a + // fence whose layout rules affect this line or later lines. Falling back + // to the full painter keeps incremental geometry exactly equivalent. + if (text.contains(RegExp(r'[#*_`~]'))) { + return true; + } + } + return false; +} + +double _sourceLineAdvance(List entries, int index) { + if (index + 1 >= entries.length) { + return entries[index].height; + } + return entries[index + 1].top - entries[index].top; +} + +({double height, double advance}) _measureSourceLogicalLine( + BuildContext context, + SourceLine line, { + required TextStyle textStyle, + required StrutStyle? strutStyle, + required double lineHeight, + required double textWidth, +}) { + final painter = TextPainter( + text: TextSpan(text: '${line.text}\n ', style: textStyle), + strutStyle: strutStyle, + textDirection: TextDirection.ltr, + textHeightBehavior: sourceTextHeightBehavior, + textScaler: MediaQuery.textScalerOf(context), + )..layout(minWidth: math.max(1, textWidth), maxWidth: math.max(1, textWidth)); + final top = sourceTextTopForOffset(painter, 0); + final nextTop = sourceTextTopForOffset(painter, line.text.length + 1); + final advance = nextTop - top; + final height = math.max(lineHeight, advance); + painter.dispose(); + return (height: height, advance: advance); +} + List sourceLineLayoutEntries( BuildContext context, { required BusyMarkSourceEditingController controller, diff --git a/lib/src/editor/source_highlighter.dart b/lib/src/editor/source_highlighter.dart index 9b35b0a..34229cf 100644 --- a/lib/src/editor/source_highlighter.dart +++ b/lib/src/editor/source_highlighter.dart @@ -12,6 +12,40 @@ import 'source_language.dart'; export 'source_language.dart'; +final _markdownHeadingPattern = RegExp(r'^(\s{0,3}#{1,6}(?:\s+|$))(.*)$'); +final _markdownInlineCodePattern = RegExp(r'`[^`\n]+`'); +final _markdownStrongPattern = RegExp(r'(\*\*[^*\n]+\*\*|__[^_\n]+__)'); +final _markdownBlockquotePattern = RegExp(r'^\s{0,3}>\s?'); +final _markdownListMarkerPattern = RegExp(r'^\s*(?:[-*+]|\d+\.)\s+'); +final _markdownTaskMarkerPattern = RegExp( + r'^\s*(?:[-*+]|\d+\.)\s+(\[[ xX]\])\s+', +); +final _markdownThematicBreakPattern = RegExp(r'^\s{0,3}(?:(?:[-*_])\s*){3,}$'); +final _markdownLinkPattern = RegExp(r'!?\[[^\]\n]+\]\([^\)\n]+\)'); +final _markdownInlineHtmlPattern = RegExp(r'\n]*>'); +final _markdownStrikethroughPattern = RegExp(r'~~[^~\n]+~~'); +final _jsonAttributePattern = RegExp(r'"(?:\\.|[^"\\])*"(?=\s*:)'); +final _codeNumberPattern = RegExp(r'\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b'); +final _codeWordPattern = RegExp(r'\b[A-Za-z_][A-Za-z0-9_]*\b'); +final _codeTypePattern = RegExp(r'\b[A-Z][A-Za-z0-9_]*\b'); +final _codeFunctionPattern = RegExp(r'\b[A-Za-z_][A-Za-z0-9_]*\b(?=\s*\()'); +final _codePunctuationPattern = RegExp(r'[{}()\[\],.;:+\-*/%=<>!&|?]+'); +final _jsonStringPattern = RegExp(r'"(?:\\.|[^"\\])*"'); +final _codeStringPattern = RegExp( + "\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|`(?:\\\\.|[^`\\\\])*`", +); +final _codeBlockCommentPattern = RegExp(r'/\*.*?(?:\*/|$)'); +final _xmlCommentPattern = RegExp(r''); +final _xmlTagPattern = RegExp(r']+/?>'); +final _xmlTagNamePattern = RegExp(r'^\s?)(.*)$'); + TextSpan buildBusyMarkReadOnlySourceTextSpan({ required BuildContext context, required String source, @@ -263,14 +297,33 @@ class BusyMarkSourceEditingController extends TextEditingController { final hiddenRanges = hideCollapsedStartLines ? _collapsedStartLineHiddenRanges() : const <_HiddenRange>[]; + if (!visible) { + return TextSpan( + style: baseStyle, + children: _language == SourceSyntaxLanguage.markdown + ? _layoutOnlyMarkdownSpans( + source, + baseStyle, + hiddenRanges, + styleOverride: _transparentLayoutStyle, + ) + : _spansFromRanges( + source, + const <_HighlightRange>[], + hiddenRanges, + baseStyle, + styleOverride: _transparentLayoutStyle, + ), + ); + } final palette = BusyMarkSyntaxColors.of(context); - final styleOverride = visible ? null : _transparentLayoutStyle; - final searchRanges = visible - ? _searchHighlightRanges(source, baseStyle, colors, _searchResult) - : const <_HighlightRange>[]; - if (visible && - visualMarkdown && - _language == SourceSyntaxLanguage.markdown) { + final searchRanges = _searchHighlightRanges( + source, + baseStyle, + colors, + _searchResult, + ); + if (visualMarkdown && _language == SourceSyntaxLanguage.markdown) { return _visualMarkdownTextSpan(source, baseStyle, palette); } return TextSpan( @@ -282,7 +335,6 @@ class BusyMarkSourceEditingController extends TextEditingController { palette, hiddenRanges, overlayRanges: searchRanges, - styleOverride: styleOverride, ), SourceSyntaxLanguage.xml => _highlightXml( source, @@ -290,16 +342,9 @@ class BusyMarkSourceEditingController extends TextEditingController { palette, hiddenRanges, overlayRanges: searchRanges, - styleOverride: styleOverride, ), SourceSyntaxLanguage.plain => [ - ..._spansFromRanges( - source, - searchRanges, - hiddenRanges, - baseStyle, - styleOverride: styleOverride, - ), + ..._spansFromRanges(source, searchRanges, hiddenRanges, baseStyle), ], }, ); @@ -346,6 +391,102 @@ class BusyMarkSourceEditingController extends TextEditingController { } } +List _layoutOnlyMarkdownSpans( + String source, + TextStyle baseStyle, + List<_HiddenRange> hiddenRanges, { + required TextStyle Function(TextStyle style) styleOverride, +}) { + final ranges = <_HighlightRange>[]; + var offset = 0; + MarkdownFence? openFence; + var inFrontMatter = source.startsWith('---\n') || source == '---'; + for (final line in source.split('\n')) { + final lineStart = offset; + final lineEnd = lineStart + line.length; + if (inFrontMatter) { + if (lineStart > 0 && line.trim() == '---') { + inFrontMatter = false; + } + offset = lineEnd + 1; + continue; + } + final activeFence = openFence; + if (activeFence != null) { + if (activeFence.closes(line)) { + openFence = null; + } + offset = lineEnd + 1; + continue; + } + final openingFence = MarkdownFence.parse(line); + if (openingFence != null) { + openFence = openingFence; + offset = lineEnd + 1; + continue; + } + final heading = _markdownHeadingPattern.firstMatch(line); + if (heading != null) { + final marker = heading.group(1)!; + final content = heading.group(2)!; + if (content.isNotEmpty) { + _addRange( + ranges, + lineStart + marker.length, + lineEnd, + _markdownHeadingStyle(baseStyle, marker.trim().length), + ); + } + offset = lineEnd + 1; + continue; + } + _addDelimitedInlineMatches( + ranges, + lineStart, + line, + _markdownInlineCodePattern, + baseStyle.copyWith(fontFamily: BusyMarkTypography.monoFontFamily), + openingLength: 1, + closingLength: 1, + markerStyle: baseStyle, + ); + _addDelimitedInlineMatches( + ranges, + lineStart, + line, + _markdownStrongPattern, + baseStyle.copyWith(fontWeight: FontWeight.w700), + openingLength: 2, + closingLength: 2, + markerStyle: baseStyle, + ); + _addSingleDelimiterInlineMatches( + ranges, + lineStart, + line, + '*', + baseStyle.copyWith(fontStyle: FontStyle.italic), + markerStyle: baseStyle, + ); + _addSingleDelimiterInlineMatches( + ranges, + lineStart, + line, + '_', + baseStyle.copyWith(fontStyle: FontStyle.italic), + markerStyle: baseStyle, + ); + offset = lineEnd + 1; + } + return _spansFromRanges( + source, + ranges, + hiddenRanges, + baseStyle, + styleOverride: styleOverride, + ); +} + class _HighlightRange { const _HighlightRange(this.start, this.end, this.style, {this.priority = 0}); @@ -454,7 +595,7 @@ List _highlightMarkdown( continue; } - final heading = RegExp(r'^(\s{0,3}#{1,6}(?:\s+|$))(.*)$').firstMatch(line); + final heading = _markdownHeadingPattern.firstMatch(line); if (heading != null) { final marker = heading.group(1)!; final content = heading.group(2)!; @@ -472,7 +613,7 @@ List _highlightMarkdown( continue; } - final blockquote = RegExp(r'^\s{0,3}>\s?').firstMatch(line); + final blockquote = _markdownBlockquotePattern.firstMatch(line); if (blockquote != null) { _addRange( ranges, @@ -482,7 +623,7 @@ List _highlightMarkdown( ); } - final listMarker = RegExp(r'^\s*(?:[-*+]|\d+\.)\s+').firstMatch(line); + final listMarker = _markdownListMarkerPattern.firstMatch(line); if (listMarker != null) { _addRange( ranges, @@ -492,9 +633,7 @@ List _highlightMarkdown( ); } - final taskMarker = RegExp( - r'^\s*(?:[-*+]|\d+\.)\s+(\[[ xX]\])\s+', - ).firstMatch(line); + final taskMarker = _markdownTaskMarkerPattern.firstMatch(line); if (taskMarker != null) { final checkbox = taskMarker.group(1)!; final checkboxStart = line.indexOf(checkbox, listMarker?.end ?? 0); @@ -508,9 +647,7 @@ List _highlightMarkdown( } } - final thematicBreak = RegExp( - r'^\s{0,3}(?:(?:[-*_])\s*){3,}$', - ).firstMatch(line); + final thematicBreak = _markdownThematicBreakPattern.firstMatch(line); if (thematicBreak != null) { _addRange(ranges, lineStart, lineEnd, blockMarkerStyle); } @@ -519,7 +656,7 @@ List _highlightMarkdown( ranges, lineStart, line, - RegExp(r'`[^`\n]+`'), + _markdownInlineCodePattern, baseStyle.copyWith( fontFamily: BusyMarkTypography.monoFontFamily, backgroundColor: palette.punctuation.withValues( @@ -534,7 +671,7 @@ List _highlightMarkdown( ranges, lineStart, line, - RegExp(r'!?\[[^\]\n]+\]\([^\)\n]+\)'), + _markdownLinkPattern, baseStyle.copyWith( color: palette.link, decoration: TextDecoration.underline, @@ -546,14 +683,14 @@ List _highlightMarkdown( ranges, lineStart, line, - RegExp(r'\n]*>'), + _markdownInlineHtmlPattern, baseStyle.copyWith(color: palette.tag), ); _addDelimitedInlineMatches( ranges, lineStart, line, - RegExp(r'(\*\*[^*\n]+\*\*|__[^_\n]+__)'), + _markdownStrongPattern, baseStyle.copyWith(fontWeight: FontWeight.w700), openingLength: 2, closingLength: 2, @@ -579,7 +716,7 @@ List _highlightMarkdown( ranges, lineStart, line, - RegExp(r'~~[^~\n]+~~'), + _markdownStrikethroughPattern, baseStyle.copyWith(decoration: TextDecoration.lineThrough), openingLength: 2, closingLength: 2, @@ -635,7 +772,7 @@ bool _addFencedCodeLineRanges( ranges, lineStart, line, - RegExp(r'"(?:\\.|[^"\\])*"(?=\s*:)'), + _jsonAttributePattern, attributeStyle, ); } @@ -643,16 +780,10 @@ bool _addFencedCodeLineRanges( _addCodeStringRanges(ranges, lineStart, line, language, stringStyle); _addCodeCommentRanges(ranges, lineStart, line, language, commentStyle); - _addInlineMatches( - ranges, - lineStart, - line, - RegExp(r'\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b'), - literalStyle, - ); + _addInlineMatches(ranges, lineStart, line, _codeNumberPattern, literalStyle); final keywords = _codeKeywords(language); - for (final word in RegExp(r'\b[A-Za-z_][A-Za-z0-9_]*\b').allMatches(line)) { + for (final word in _codeWordPattern.allMatches(line)) { final token = word.group(0)!; final style = _codeLiterals.contains(token.toLowerCase()) ? literalStyle @@ -664,13 +795,11 @@ bool _addFencedCodeLineRanges( } } - for (final type in RegExp(r'\b[A-Z][A-Za-z0-9_]*\b').allMatches(line)) { + for (final type in _codeTypePattern.allMatches(line)) { _addRange(ranges, lineStart + type.start, lineStart + type.end, tagStyle); } - for (final function in RegExp( - r'\b[A-Za-z_][A-Za-z0-9_]*\b(?=\s*\()', - ).allMatches(line)) { + for (final function in _codeFunctionPattern.allMatches(line)) { final token = function.group(0)!; if (!keywords.contains(token) && !_codeLiterals.contains(token.toLowerCase())) { @@ -687,7 +816,7 @@ bool _addFencedCodeLineRanges( ranges, lineStart, line, - RegExp(r'[{}()\[\],.;:+\-*/%=<>!&|?]+'), + _codePunctuationPattern, punctuationStyle, ); @@ -721,11 +850,7 @@ void _addCodeStringRanges( String language, TextStyle style, ) { - final pattern = language == 'json' - ? RegExp(r'"(?:\\.|[^"\\])*"') - : RegExp( - "\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|`(?:\\\\.|[^`\\\\])*`", - ); + final pattern = language == 'json' ? _jsonStringPattern : _codeStringPattern; _addInlineMatches(ranges, lineStart, line, pattern, style); } @@ -736,7 +861,7 @@ void _addCodeCommentRanges( String language, TextStyle style, ) { - for (final block in RegExp(r'/\*.*?(?:\*/|$)').allMatches(line)) { + for (final block in _codeBlockCommentPattern.allMatches(line)) { _addRange(ranges, lineStart + block.start, lineStart + block.end, style); } @@ -949,7 +1074,7 @@ void _addXmlRanges( final stringStyle = baseStyle.copyWith(color: palette.string); final punctuationStyle = baseStyle.copyWith(color: palette.punctuation); - for (final comment in RegExp(r'').allMatches(source)) { + for (final comment in _xmlCommentPattern.allMatches(source)) { _addRange( ranges, sourceOffset + comment.start, @@ -958,7 +1083,7 @@ void _addXmlRanges( ); } - for (final tag in RegExp(r']+/?>').allMatches(source)) { + for (final tag in _xmlTagPattern.allMatches(source)) { final text = tag.group(0)!; if (text.startsWith('