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 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/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..62df6b7 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."}, @@ -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/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..651a761 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. @@ -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/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_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_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/lib/src/app/app_router.dart b/lib/src/app/app_router.dart index 1edbe49..67f7b33 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,15 @@ 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), + initialPage: settingsPageFromRouteValue( + state.uri.queryParameters['page'], + ), + ), + ), ), ], ); 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/app_theme.dart b/lib/src/app/app_theme.dart index 0850fd8..e3093d8 100644 --- a/lib/src/app/app_theme.dart +++ b/lib/src/app/app_theme.dart @@ -9,73 +9,147 @@ 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, ); + // 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 selectedContainer = colors.controlActive; final colorScheme = base.colorScheme.copyWith( brightness: brightness, primary: accentColor, onPrimary: onAccent, - primaryContainer: selectedContainer, - onPrimaryContainer: colors.foreground, - secondary: BusyMarkLinuxPalette.blueAccent, - error: BusyMarkLinuxPalette.red, + 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, - ), + 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 popoverSurfaceSide = BorderSide(color: colors.floatingBorder); + final dialogSurfaceSide = BorderSide(color: colors.dialogOutline); + 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, + ); + 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, ), - contentPadding: BusyMarkInsets.input, - hintStyle: textTheme.bodyMedium?.copyWith(color: colors.mutedForeground), + padding: BusyMarkTooltipStyle.padding, + constraints: BusyMarkTooltipStyle.constraints, + waitDuration: BusyMarkMotion.tooltipWait, ); 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 +161,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,110 +177,21 @@ ThemeData buildBusyMarkTheme({ dialogTheme: base.dialogTheme.copyWith( backgroundColor: colors.dialog, surfaceTintColor: colors.dialog, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMarkRadius.lg), - side: BorderSide(color: colors.border), - ), + shadowColor: colorScheme.shadow, + shape: _withOutlineSide(base.dialogTheme.shape, dialogSurfaceSide), titleTextStyle: textTheme.titleLarge, contentTextStyle: textTheme.bodyMedium, ), listTileTheme: base.listTileTheme.copyWith( - selectedColor: colors.foreground, - selectedTileColor: selectedContainer, 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; - }), - ), - ), + outlinedButtonTheme: OutlinedButtonThemeData(style: outlinedButtonStyle), + filledButtonTheme: FilledButtonThemeData(style: filledButtonStyle), + elevatedButtonTheme: ElevatedButtonThemeData(style: elevatedButtonStyle), + textButtonTheme: TextButtonThemeData(style: textButtonStyle), + segmentedButtonTheme: SegmentedButtonThemeData(style: segmentedButtonStyle), switchTheme: SwitchThemeData( thumbColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) { @@ -240,34 +218,59 @@ ThemeData buildBusyMarkTheme({ 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, - elevation: BusyMarkElevation.popover, - shadowColor: colors.shade, + shadowColor: colorScheme.shadow, + shape: _withOutlineSide(base.popupMenuTheme.shape, popoverSurfaceSide), 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, + ), + tooltipTheme: tooltipTheme, 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 +284,32 @@ 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, + shape: + base.cardTheme.shape ?? + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BusyMarkRadius.lg), + ), ), ); } +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 +330,61 @@ 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, + 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_app.dart b/lib/src/app/busymark_app.dart index 33640fe..45c80f4 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, @@ -70,210 +72,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 +576,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); @@ -580,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, @@ -592,21 +609,35 @@ 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, ); - 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, + 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 00a2d81..49a34e5 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -1,15 +1,19 @@ import 'dart:async'; import 'dart:math' as math; +import 'dart:ui' as ui; +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 { 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; @@ -18,17 +22,19 @@ 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 = 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; } @@ -37,12 +43,17 @@ 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 toolbarHeight = 46; + static const double settingsSidebarBreakpoint = sidebarWidth + 520; + 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 tooltipMinHeight = 30; static const double previewMinWidth = 320; static const double modalMaxWidth = 860; static const double modalHorizontalInset = 40; @@ -53,22 +64,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 +100,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; @@ -114,31 +113,28 @@ 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; - 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; } 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 groupedRowLightHoverStrength = 0.50; + static const double tooltipBackground = 0.80; + static const double tooltipBorder = 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 +144,40 @@ 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; +} + +/// 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 { @@ -173,6 +197,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; @@ -212,14 +237,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 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; + static const Curve dialogInsetsCurve = Curves.easeOutCubic; } abstract final class BusyMarkInsets { @@ -276,10 +299,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 +350,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); @@ -474,6 +380,48 @@ 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, + ); +} + +/// 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({ @@ -598,11 +546,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 +573,125 @@ 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), + }; + // 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 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) { + 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; + return Color.alphaBlend(tint.withValues(alpha: alpha), card); + } + + 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: window, + view: view, + sidebar: const Color(0xFFEBEBEB), + secondarySidebar: const Color(0xFFF0F0F0), + headerbar: const Color(0xFFFAFAFA), + headerbarFlat: const Color(0xFFFFFFFF), + panel: const Color(0xFFF0F0F0), + card: card, + groupedSurface: groupedSurface, + 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), + foreground: foreground, + mutedForeground: mutedForeground, + 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), + 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), + admonitionTip: tintedSurface(BusyMarkLinuxPalette.ubuntuGreenAccent), + admonitionWarning: tintedSurface(BusyMarkLinuxPalette.ubuntuYellowAccent), + ), + Brightness.dark => BusyMarkSurfaceColors( + window: window, + view: view, + sidebar: const Color(0xFF393939), + secondarySidebar: const Color(0xFF323232), + headerbar: const Color(0xFF393939), + headerbarFlat: const Color(0xFF272727), + panel: const Color(0xFF323232), + card: card, + groupedSurface: groupedSurface, + 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), + foreground: foreground, + mutedForeground: mutedForeground, + 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, + 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({ @@ -640,19 +703,22 @@ 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, 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.cardShade, + required this.dialogOutline, + required this.floatingBorder, required this.sidebarBorder, required this.shade, required this.muted, @@ -661,72 +727,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; @@ -737,19 +745,22 @@ 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; 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 cardShade; + final Color dialogOutline; + final Color floatingBorder; final Color sidebarBorder; final Color shade; final Color muted; @@ -767,19 +778,22 @@ class BusyMarkSurfaceColors extends ThemeExtension { Color? headerbarFlat, Color? panel, Color? card, - Color? groupedList, + Color? groupedSurface, Color? dialog, Color? popover, Color? control, Color? controlHover, Color? controlActive, - Color? activeToggle, Color? foreground, Color? mutedForeground, Color? disabledForeground, Color? disabledControl, Color? border, Color? subtleBorder, + Color? divider, + Color? cardShade, + Color? dialogOutline, + Color? floatingBorder, Color? sidebarBorder, Color? shade, Color? muted, @@ -796,19 +810,22 @@ 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, 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, + cardShade: cardShade ?? this.cardShade, + dialogOutline: dialogOutline ?? this.dialogOutline, + floatingBorder: floatingBorder ?? this.floatingBorder, sidebarBorder: sidebarBorder ?? this.sidebarBorder, shade: shade ?? this.shade, muted: muted ?? this.muted, @@ -836,13 +853,12 @@ 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)!, 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 +869,10 @@ 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)!, + 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)!, shade: Color.lerp(shade, other.shade, t)!, muted: Color.lerp(muted, other.muted, t)!, @@ -869,28 +889,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 +916,31 @@ ButtonStyle busyMarkHeaderIconButtonStyle({ WidgetStateProperty busyMarkHeaderButtonBackground( BuildContext context, ) { - final colors = BusyMarkSurfaceColors.of(context); + return Theme.of(context).filledButtonTheme.style?.backgroundColor ?? + 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) { - 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; + final stateColor = background.resolve(states); + return stateColor == null ? null : Color.alphaBlend(stateColor, surface); }); } 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 +948,15 @@ Color busyMarkSelectedBackground(BuildContext context) { } Color busyMarkRowHoverColor(BuildContext context) { - return BusyMarkSurfaceColors.of(context).controlHover; + 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) { @@ -947,6 +966,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, @@ -955,7 +988,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 +1003,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,59 +1013,121 @@ 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 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), + surfaceTintColor: const WidgetStatePropertyAll( + BusyMarkLinuxPalette.transparent, + ), + ) + : semanticStyle; + // 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( - 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, - ), + isSelected: selected, tooltip: shortcut == null ? tooltip : '$tooltip ($shortcut)', icon: Icon(icon, size: BusyMarkSizes.iconSm), + padding: EdgeInsets.zero, + style: style.merge(yaruDefaults), 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, + return YaruTheme.maybeOf(context)?.focusBorders == true + ? YaruFocusBorder.primary( + borderRadius: BorderRadius.circular(BusyMarkRadius.pill), + child: button, + ) + : 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, this.backgroundColor, this.borderRadius = BusyMarkRadius.headerButton, + this.highlightWhenOpen = true, + this.nativeMenuService = const NativeMenuService(), }); final String tooltip; @@ -1041,442 +1137,630 @@ 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; + final bool highlightWhenOpen; + final NativeMenuService nativeMenuService; + + @override + State> createState() => + _BusyMarkHeaderPopupMenuButtonState(); +} + +class _BusyMarkHeaderPopupMenuButtonState + extends State> { + 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) { - 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 KeyedSubtree( + key: _triggerKey, + child: Semantics( + expanded: _open, + child: BusyMarkHeaderIconButton( + tooltip: widget.tooltip, + icon: widget.icon, + shortcut: widget.shortcut, + selected: widget.highlightWhenOpen && (_loading || _open), + transparent: widget.transparent, + elevated: widget.elevated, + foregroundColor: widget.foregroundColor, + backgroundColor: widget.backgroundColor, + borderRadius: widget.borderRadius, + onPressed: _loadAndShowMenu, ), ), - child: Builder( - builder: (buttonContext) => IconButton( - tooltip: shortcut == null ? tooltip : '$tooltip ($shortcut)', - onPressed: () => _showMenu(buttonContext), - icon: Icon( - icon, - size: BusyMarkSizes.iconSm, - color: effectiveForeground, - ), - ), - ), - ); - 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; + } + 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 { - escapeDismiss.detach(); - } - if (result != null) { - onSelected(result); + if (mounted) { + setState(() => _loading = false); + } } } } -class BusyMarkPopupEscapeDismissBinding { - BusyMarkPopupEscapeDismissBinding(this.navigator); +/// 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; - final NavigatorState navigator; - bool _attached = false; + bool get dismissed => _dismissed; - void attach() { - if (_attached) { + Future dismiss() async { + if (_dismissed) { return; } - _attached = true; - HardwareKeyboard.instance.addHandler(_handleKeyEvent); + _dismissed = true; + _removeFallbackRoute(); + await _nativeMenuService.dismiss(_nativeSession); } - void detach() { - if (!_attached) { - return; + void _beginPresentation(NativeMenuService nativeMenuService) { + if (_started) { + throw StateError('A BusyMarkMenuSession can present only one menu.'); } - _attached = false; - HardwareKeyboard.instance.removeHandler(_handleKeyEvent); + _started = true; + _nativeMenuService = nativeMenuService; } - bool _handleKeyEvent(KeyEvent event) { - if (!_attached || - event is! KeyDownEvent || - event.logicalKey != LogicalKeyboardKey.escape) { - return false; + void _captureFallbackRoute() { + final routeContext = _fallbackRouteKey.currentContext; + final route = routeContext == null ? null : ModalRoute.of(routeContext); + if (route == null) { + return; } - detach(); - if (navigator.canPop()) { - navigator.pop(); + _fallbackRoute = route; + if (_dismissed) { + _removeFallbackRoute(); + } + } + + void _releaseFallbackRoute() { + _fallbackRoute = null; + } + + void _removeFallbackRoute() { + final route = _fallbackRoute; + final navigator = route?.navigator; + if (route != null && navigator != null && route.isActive) { + navigator.removeRoute(route); } - return true; + _fallbackRoute = null; } } -/// Shows a BusyMark-styled 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 -/// rows consistent with the rest of the application. -Future showBusyMarkContextMenu( - BuildContext context, - Offset globalPosition, { +/// Presents a menu through GTK on Linux and a themed Flutter route elsewhere. +Future showBusyMarkMenu({ + required BuildContext context, required List> items, - double width = BusyMarkSizes.popupMenuMinWidth, -}) { + 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 Future.value(); + return null; } - final navigator = Navigator.of(context, rootNavigator: true); - final overlay = navigator.overlay?.context.findRenderObject(); - if (overlay is! RenderBox) { - return Future.value(); + final itemSnapshot = List>.unmodifiable(items); + final presentation = session ?? BusyMarkMenuSession(); + if (presentation.dismissed) { + return null; } - 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; - 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( + 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, - useRootNavigator: true, - position: RelativeRect.fromLTRB( - left, - top, - math.max(minLeft, overlay.size.width - left - width), - 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, + anchor: anchor, + items: itemSnapshot, + session: presentation, + width: width, ); + return presentation.dismissed ? null : selection; } -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; +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; +} - @override - EdgeInsetsGeometry get dimensions => EdgeInsets.all(side.width); - - @override - Path getInnerPath(Rect rect, {TextDirection? textDirection}) { - return getOuterPath(rect.deflate(side.width), textDirection: textDirection); +List? _busyMarkNativeMenuEntries( + List> items, +) { + final entries = []; + for (final item in items) { + if (item is BusyMarkPopupMenuItem) { + entries.add( + NativeMenuEntry.command( + label: item.label, + iconName: BusyMarkGlyphs.nativeMenuIconName(item.icon), + 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; +} - @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, - ); +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; +} - 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(); +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(); } +} - @override - void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { - if (side == BorderSide.none || side.width == 0) { - return; +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); } - canvas.drawPath( - getOuterPath(rect.deflate(side.width / 2), textDirection: textDirection), - side.toPaint(), - ); } + return fallbackItems; +} - @override - ShapeBorder scale(double t) { - return _BusyMarkHeaderPopoverShape( - borderRadius: borderRadius * t, - side: side.scale(t), - ); +/// 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 +/// rows consistent with the rest of the application. +Future showBusyMarkContextMenu( + BuildContext context, + Offset globalPosition, { + required List> items, + double width = BusyMarkSizes.popupMenuMinWidth, +}) { + if (items.isEmpty) { + return Future.value(); } + return showBusyMarkMenu( + context: context, + anchorPoint: globalPosition, + items: items, + width: width, + ); } -class BusyMarkPopupMenuItem extends PopupMenuEntry { - const BusyMarkPopupMenuItem({ +class BusyMarkPopupMenuItem extends PopupMenuItem { + BusyMarkPopupMenuItem({ super.key, - required this.value, - required this.label, - this.icon, - this.shortcut, - this.enabled = true, - this.checked = false, - this.trailingCheck = false, - }); + required T value, + required String label, + IconData? icon, + String? shortcut, + 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: KeyedSubtree( + key: routeKey, + child: _BusyMarkPopupMenuItemContent( + label: label, + icon: icon, + shortcut: shortcut, + checked: checked, + trailingCheck: trailingCheck, + ), + ), + ); - final T value; final String label; + final T menuValue; 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; +class _BusyMarkPopupMenuItemContent extends StatelessWidget { + const _BusyMarkPopupMenuItemContent({ + required this.label, + required this.icon, + required this.shortcut, + required this.checked, + required this.trailingCheck, + }); - @override - State> createState() => - _BusyMarkPopupMenuItemState(); -} + final String label; + final IconData? icon; + final String? shortcut; + final bool checked; + final bool trailingCheck; -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; + } +} + +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()); } } @@ -1519,152 +1803,209 @@ 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; + final fallbackMenuWidth = buttonMaxWidth.clamp( + popupMinWidth, + popupMaxWidth, + ); 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: BusyMarkMenuButton( + tooltip: tooltip, enabled: selectorEnabled, - maxWidth: buttonMaxWidth, + fallbackMenuWidth: fallbackMenuWidth, + onSelected: onSelected, + items: [ + for (final option in options) + BusyMarkPopupMenuItem( + value: option.value, + label: option.label, + icon: option.icon, + checked: option.value == value, + trailingCheck: true, + ), + ], + 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, + ), + ], + ), + ), + ), + ), + ); + }, ), ), ); } } -class _BusyMarkPopupSelectorButton extends StatefulWidget { - const _BusyMarkPopupSelectorButton({ +/// 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? hintText, + String? errorText, + bool alignLabelWithHint = false, +}) { + final decoration = InputDecoration( + labelText: labelText, + hintText: hintText, + 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, + ); +} + +/// 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, - required this.enabled, - required this.maxWidth, - }); + 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 double maxWidth; - - @override - State<_BusyMarkPopupSelectorButton> createState() => - _BusyMarkPopupSelectorButtonState(); -} - -class _BusyMarkPopupSelectorButtonState - extends State<_BusyMarkPopupSelectorButton> { - var _hovered = false; + 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) { - 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, - ), - ), - 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), - ), - ), - const SizedBox(width: BusyMarkSpacing.sm), - Icon( - BusyMarkGlyphs.downArrow, - size: BusyMarkSizes.iconSm, - color: widget.enabled - ? colors.mutedForeground - : colors.disabledForeground, - ), - ], + 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, ); } } @@ -1691,65 +2032,256 @@ class BusyMarkClamp extends StatelessWidget { @override Widget build(BuildContext context) { - final clamped = Container( - constraints: BoxConstraints(maxWidth: maxWidth), - margin: margin, - padding: padding, - child: child, - ); - - final body = center - ? Align(alignment: Alignment.topCenter, child: clamped) - : clamped; + final clamped = Container( + constraints: BoxConstraints(maxWidth: maxWidth), + margin: margin, + padding: padding, + child: child, + ); + + final body = center + ? Align(alignment: Alignment.topCenter, child: clamped) + : clamped; + + return scrollable + ? SingleChildScrollView(controller: controller, child: body) + : body; + } +} + +/// 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, + 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 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: Colors.transparent, + elevation: 0, + surfaceTintColor: cardTheme.surfaceTintColor ?? Colors.transparent, + shape: shape, + clipBehavior: clipBehavior, + child: child, + ); + } +} + +/// 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) { + final highContrast = MediaQuery.highContrastOf(context); + return BusyMarkSurface( + color: busyMarkGroupedSurfaceColor(context), + side: highContrast + ? BorderSide(color: Theme.of(context).colorScheme.outline) + : null, + 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 Material( + color: colors.sidebar, + child: DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + border: BorderDirectional( + end: BorderSide( + color: colors.sidebarBorder, + width: BusyMarkStroke.hairline, + ), + ), + ), + 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 scrollable - ? SingleChildScrollView(controller: controller, child: body) - : body; + 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, + ), + ), + ); } } -class BusyMarkSurface extends StatelessWidget { - const BusyMarkSurface({ +/// A selectable row for [BusyMarkSidebarNavigation]. +class BusyMarkSidebarNavigationTile extends StatelessWidget { + const BusyMarkSidebarNavigationTile({ super.key, - required this.child, - this.filled = true, - this.clipBehavior = Clip.antiAlias, + required this.selected, + required this.leading, + required this.title, + required this.onTap, }); - final Widget child; - final bool filled; - final Clip clipBehavior; + final bool selected; + final Widget leading; + final Widget title; + final VoidCallback onTap; @override Widget build(BuildContext context) { - final borderRadius = BorderRadius.circular(BusyMarkRadius.md); - 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, - surfaceTintColor: BusyMarkLinuxPalette.transparent, - shape: shape, - clipBehavior: clipBehavior, - child: child, - ); - if (!filled) { - return material; - } - return DecoratedBox( - decoration: busyMarkSurfaceDecoration( - context, - color: color, - borderRadius: borderRadius, - border: Border.all(color: borderColor), + return YaruMasterTile( + selected: selected, + leading: IconTheme.merge( + data: const IconThemeData(size: BusyMarkSizes.iconSm), + child: leading, ), - child: material, + title: title, + onTap: onTap, ); } } @@ -1820,18 +2352,13 @@ 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: [ for (var index = 0; index < children.length; index++) ...[ children[index], if (index < children.length - 1) - Divider( - height: BusyMarkStroke.hairline, - thickness: BusyMarkStroke.hairline, - color: dividerColor, - ), + Divider(height: 1, thickness: 1, color: colors.cardShade), ], ], ); @@ -1840,107 +2367,366 @@ 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, - ), - ), - ); + return BusyMarkGroupedSurface(child: list); } } -class _BusyMarkHoverBackground extends StatefulWidget { - const _BusyMarkHoverBackground({required this.enabled, required this.child}); +/// 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 Widget child; - - @override - State<_BusyMarkHoverBackground> createState() => - _BusyMarkHoverBackgroundState(); -} - -class _BusyMarkHoverBackgroundState extends State<_BusyMarkHoverBackground> { - var _hovered = false; + final String? tooltip; + final double width; @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); - } + 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, + ); + }, + ); }, - child: ColoredBox(color: color, child: widget.child), ); } } -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 titleStyle = destructive - ? TextStyle(color: busyMarkDestructiveForeground(context)) + final colorScheme = Theme.of(context).colorScheme; + final colors = BusyMarkSurfaceColors.of(context); + final titleStyle = widget.destructive + ? TextStyle( + color: widget.enabled + ? colorScheme.error + : 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 + final subtitle = + widget.subtitleWidget ?? + (widget.subtitle == null || widget.subtitle!.isEmpty ? null - : Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis), - trailing: trailing, - enabled: enabled, - hoverColor: BusyMarkLinuxPalette.transparent, - onTap: enabled ? onTap : 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 + : _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; } } @@ -1955,80 +2741,320 @@ class BusyMarkSwitchRow extends StatelessWidget { this.enabled = true, }); - final String title; - final String? subtitle; - final bool value; - final ValueChanged onChanged; - final Widget? leading; - final bool enabled; + final String title; + final String? subtitle; + final bool value; + final ValueChanged onChanged; + final Widget? leading; + final bool enabled; + + @override + Widget build(BuildContext context) { + return YaruSwitchListTile( + value: value, + onChanged: enabled ? onChanged : null, + secondary: leading, + title: Text(title), + subtitle: subtitle == null + ? null + : _busyMarkGroupedRowSubtitle( + context, + Text(subtitle!), + enabled: enabled, + ), + shape: const RoundedRectangleBorder(), + hoverColor: busyMarkRowHoverColor(context), + ); + } +} + +class BusyMarkCheckbox extends StatelessWidget { + const BusyMarkCheckbox({ + super.key, + required this.value, + required this.onChanged, + this.tooltip, + this.tristate = false, + }); + + final bool? value; + final bool tristate; + final String? tooltip; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + final checkbox = YaruCheckbox( + value: value, + tristate: tristate, + onChanged: onChanged, + ); + final message = tooltip; + if (message == null || message.isEmpty) { + return checkbox; + } + return Tooltip(message: message, child: checkbox); + } +} + +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, + required this.message, + this.kind = BusyMarkStatusKind.information, + }); + + final String message; + final BusyMarkStatusKind kind; + + @override + Widget build(BuildContext context) { + return YaruInfoBox( + yaruInfoType: switch (kind) { + BusyMarkStatusKind.information => YaruInfoType.information, + BusyMarkStatusKind.success => YaruInfoType.success, + BusyMarkStatusKind.warning => YaruInfoType.warning, + BusyMarkStatusKind.error => YaruInfoType.danger, + }, + subtitle: Text(message), + ); + } +} + +Color busyMarkDialogSurfaceColor(BuildContext context) { + return DialogTheme.of(context).backgroundColor ?? + 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 _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 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 BusyMarkCheckbox extends StatelessWidget { - const BusyMarkCheckbox({ +class BusyMarkModalEditorScaffold extends StatelessWidget { + const BusyMarkModalEditorScaffold({ super.key, - required this.value, - required this.onChanged, - this.tooltip, - this.tristate = false, + 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 bool? value; - final bool tristate; - final String? tooltip; - final ValueChanged? onChanged; + 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) { - final checkbox = YaruCheckbox( - value: value, - tristate: tristate, - onChanged: onChanged, + 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, + ), + ), + ), + ], + ), ); - final message = tooltip; - if (message == null || message.isEmpty) { - return checkbox; - } - return Tooltip(message: message, child: checkbox); } } -enum BusyMarkStatusKind { information, success, warning, error } - -class BusyMarkStatusBox extends StatelessWidget { - const BusyMarkStatusBox({ +class BusyMarkDialogTitleBar extends StatelessWidget { + const BusyMarkDialogTitleBar({ super.key, - required this.message, - this.kind = BusyMarkStatusKind.information, + this.title, + this.centerTitle = true, + this.closeSemanticLabel, + this.closable = true, + this.showDividerInHighContrast = true, }); - final String message; - final BusyMarkStatusKind kind; + final Widget? title; + final bool centerTitle; + final String? closeSemanticLabel; + final bool closable; + final bool showDividerInHighContrast; @override Widget build(BuildContext context) { - return YaruInfoBox( - yaruInfoType: switch (kind) { - BusyMarkStatusKind.information => YaruInfoType.information, - BusyMarkStatusKind.success => YaruInfoType.success, - BusyMarkStatusKind.warning => YaruInfoType.warning, - BusyMarkStatusKind.error => YaruInfoType.danger, - }, - subtitle: Text(message), + 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, + ), ); } } @@ -2039,6 +3065,7 @@ class BusyMarkDialogShell extends StatelessWidget { required this.title, required this.children, this.maxWidth = BusyMarkSizes.dialog, + this.header, this.actions = const [], this.closable = true, }); @@ -2046,56 +3073,152 @@ 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: 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: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - for (final action in actions) ...[ - Flexible(child: action), - if (action != actions.last) - const SizedBox(width: BusyMarkSpacing.sm), - ], - ], - ), - ), - ], + ), ), ); } } -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 +3234,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 + : BusyMarkDestructiveButtonStyle.background(theme); + Color? foreground(Set states) => + states.contains(WidgetState.disabled) + ? colors.disabledForeground + : BusyMarkDestructiveButtonStyle.foreground(theme); + 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 +3286,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 +3294,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 +3302,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 +3332,6 @@ class BusyMarkFloatingTextEntry extends StatefulWidget { this.textDirection, this.textStyle, this.onSubmitted, - this.groupPosition = BusyMarkFloatingTextEntryPosition.single, }) : assert(minLines > 0), assert(maxLines >= minLines); @@ -2348,312 +3348,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_dialog_identity.dart b/lib/src/app/busymark_dialog_identity.dart new file mode 100644 index 0000000..dc4fb01 --- /dev/null +++ b/lib/src/app/busymark_dialog_identity.dart @@ -0,0 +1,96 @@ +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 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, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +/// 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 395ce92..a6909e3 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,52 +38,228 @@ 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); - await headerBarService?.setModalBarrierVisible(true); + final effectiveHeaderBarService = + 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; + await acquireBusyMarkModalBarrier(headerBarService); if (!context.mounted) { - await headerBarService?.setModalBarrierVisible(false); + await releaseBusyMarkModalBarrier(headerBarService); return null; } try { - return await showDialog( + return await showSurface(); + } finally { + await releaseBusyMarkModalBarrier(headerBarService); + if (previousFocus?.context != null && previousFocus!.canRequestFocus) { + previousFocus.requestFocus(); + } + } +} + +Future _showBusyMarkFlutterDialog( + BuildContext context, { + required WidgetBuilder builder, + Color? barrierColor, + bool barrierDismissible = true, +}) { + final navigator = Navigator.of(context, rootNavigator: true); + final themes = InheritedTheme.capture(from: context, to: navigator.context); + return navigator.push( + _BusyMarkDialogRoute( context: context, - barrierColor: barrierColor, + builder: builder, + themes: themes, + fixedBarrierColor: barrierColor, + initialBarrierColor: barrierColor ?? busyMarkModalBarrierColor(context), barrierDismissible: barrierDismissible, - 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: Center( - child: BusyMarkModalEditorSurface(child: builder(dialogContext)), - ), - ), - ); - }, - ); - } finally { - await headerBarService?.setModalBarrierVisible(false); + ), + ); +} + +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, + 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 +/// 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); + } +} + +/// 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); +} + +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; } } @@ -89,36 +267,53 @@ 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 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, - ), + 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 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: child, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: effectiveMinWidth, + maxWidth: effectiveMaxWidth, + maxHeight: effectiveMaxHeight, + ), + child: child, + ), ), ); } @@ -155,7 +350,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, @@ -698,6 +894,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( @@ -945,20 +1142,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, + ], + ), ); } } @@ -970,56 +1186,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()), + const SizedBox(height: BusyMarkSpacing.xs), + Text( + context.l10n.aboutTagline, + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith( + color: colors.mutedForeground, ), - 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.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()), + ), + ], + ), + ], + ), ); } } @@ -1037,7 +1253,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/app/busymark_glyphs.dart b/lib/src/app/busymark_glyphs.dart index 1c33f43..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; @@ -87,6 +89,204 @@ 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 == desktop) { + return 'video-display-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 == privacy) { + return 'security-high-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/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/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/app/system_accent.dart b/lib/src/app/system_accent.dart index 1624df2..47bf7ea 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( + readGnome: () => _readGnomeAccentName(object), + readFreedesktop: () => _readFreedesktopAccent(object), + ); } finally { await client.close(); } @@ -56,6 +63,22 @@ class LinuxPortalAppearance { final client = DBusClient.session(); try { final object = _portalObject(client); + final gnomeAccent = await _readAccentSafely( + () => _readGnomeAccentName(object), + ); + final resolver = LinuxAccentChangeResolver( + gnomeAuthoritative: gnomeAccent != null, + ); + if (gnomeAccent != null) { + yield gnomeAccent; + } else { + final freedesktopAccent = await _readAccentSafely( + () => _readFreedesktopAccent(object), + ); + if (freedesktopAccent != null) { + yield freedesktopAccent; + } + } 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,57 @@ class LinuxPortalAppearance { } } +/// 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() readGnome, + required Future Function() readFreedesktop, +}) async { + final gnomeAccent = await _readAccentSafely(readGnome); + if (gnomeAccent != null) { + return gnomeAccent; + } + return _readAccentSafely(readFreedesktop); +} + +Future _readAccentSafely(Future Function() read) async { + try { + return await read(); + } on Object { + return null; + } +} + +/// Resolves portal changes without allowing the generic freedesktop palette to +/// replace the Yaru variant that owns native GTK controls. +@visibleForTesting +class LinuxAccentChangeResolver { + LinuxAccentChangeResolver({bool gnomeAuthoritative = false}) + : _gnomeAuthoritative = gnomeAuthoritative; + + bool _gnomeAuthoritative; + + Color? resolve(String namespace, DBusValue value) { + if (namespace == LinuxPortalAppearance._gnomeInterface) { + final color = colorFromUbuntuAccentNameValue(value); + if (color != null) { + _gnomeAuthoritative = true; + } + return color; + } + if (namespace == LinuxPortalAppearance._freedesktopAppearance && + !_gnomeAuthoritative) { + return colorFromPortalAccentValue(value); + } + return null; + } +} + Color? colorFromPortalAccentValue(DBusValue value) { final resolved = value.signature == DBusSignature('v') ? value.asVariant() @@ -149,21 +218,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/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/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/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/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index 809526a..a6e0090 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'; @@ -63,6 +62,7 @@ class BusyMarkSourceEditorState extends State { final _sourceEditorKey = GlobalKey(); final _foldedRegionKeys = {}; final _searchController = SourceSearchController(); + final _lineLayoutCache = SourceLineLayoutCache(); List _foldRegions = const []; String _lastPath = ''; @@ -232,6 +232,7 @@ class BusyMarkSourceEditorState extends State { collapsedRegionKeys: _foldedRegionKeys, foldRegions: _foldRegions, diagnosticMarkers: markers, + layoutCache: _lineLayoutCache, onToggleFold: _toggleFold, child: SizedBox( key: _sourceEditorKey, @@ -811,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, }); @@ -830,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; @@ -866,6 +869,7 @@ class _SourceEditorFrame extends StatelessWidget { collapsedRegionKeys: collapsedRegionKeys, diagnosticMarkers: diagnosticMarkers, onToggleFold: onToggleFold, + layoutCache: layoutCache, ), ), VerticalDivider( @@ -884,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), ], ), @@ -925,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, @@ -938,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!, ), ], ); @@ -970,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; @@ -980,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) { @@ -990,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, @@ -999,6 +1012,7 @@ class _CollapsedSourceLineOverlay extends StatelessWidget { strutStyle: strutStyle, lineHeight: lineHeight, textWidth: textWidth, + diagnostics: diagnosticMarkers, ); final linesByNumber = { for (final line in sourceLineInfos(controller.fullText)) @@ -1116,71 +1130,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, - ), - _SearchPanelIconButton( - tooltip: context.l10n.sourceSearchNextMatch, - icon: YaruIcons.pan_down, - onPressed: result.totalMatchCount == 0 ? null : onNext, + 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()], ), - _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 +1206,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 +1230,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 +1260,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..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, @@ -264,31 +266,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, ); } } @@ -308,7 +294,6 @@ class _SourceDiagnosticMarkerDot extends StatelessWidget { .join('\n'); return Tooltip( message: message, - waitDuration: BusyMarkMotion.tooltipWait, child: SizedBox.square( dimension: 6, child: DecoratedBox( @@ -348,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, @@ -479,9 +795,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/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('