From 19ec6009493903684e99430593ac4fbe9eae1e68 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Wed, 11 Mar 2026 14:57:17 +0100 Subject: [PATCH] added more contextual menu buttons --- .../domain/providers/tab_session.dart | 10 + .../domain/entities/font_size_constants.dart | 24 + .../domain/entities/toolbar_button_id.dart | 14 + .../domain/entities/toolbar_button_spec.dart | 90 ++++ .../presentation/toolbar_button_registry.dart | 452 ++++++++++++++++++ .../widgets/contextual_bar_buttons.dart | 231 +++++++++ .../widgets/font_size_bottom_sheet.dart | 164 +++++++ .../api/GeckoSessionApiImpl.kt | 22 + .../pigeons/Gecko.g.kt | 19 + .../src/domain/services/gecko_session.dart | 4 + .../lib/src/pigeons/gecko.g.dart | 22 + .../pigeons/gecko.dart | 2 + 12 files changed, 1054 insertions(+) create mode 100644 app/lib/features/geckoview/features/browser/domain/entities/font_size_constants.dart create mode 100644 app/lib/features/geckoview/features/browser/presentation/widgets/font_size_bottom_sheet.dart diff --git a/app/lib/features/geckoview/domain/providers/tab_session.dart b/app/lib/features/geckoview/domain/providers/tab_session.dart index be323f89..fa9238b7 100644 --- a/app/lib/features/geckoview/domain/providers/tab_session.dart +++ b/app/lib/features/geckoview/domain/providers/tab_session.dart @@ -96,6 +96,16 @@ class TabSession extends _$TabSession { return _sessionService.translateRestore(); } + Future pageUp() { + // Android KeyEvent.KEYCODE_PAGE_UP = 92 + return _sessionService.dispatchKeyEvent(keyCode: 92); + } + + Future pageDown() { + // Android KeyEvent.KEYCODE_PAGE_DOWN = 93 + return _sessionService.dispatchKeyEvent(keyCode: 93); + } + @override void build({required String? tabId}) { _sessionService = (tabId != null) diff --git a/app/lib/features/geckoview/features/browser/domain/entities/font_size_constants.dart b/app/lib/features/geckoview/features/browser/domain/entities/font_size_constants.dart new file mode 100644 index 00000000..d1d5a29a --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/entities/font_size_constants.dart @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const fontSizeStep = 0.1; +const fontSizeMin = 0.5; +const fontSizeMax = 3.0; +const fontSizeDefault = 1.0; diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart index ebc47c26..f239a748 100644 --- a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart @@ -26,4 +26,18 @@ enum ToolbarButtonId { addTab, tabsCount, navigationMenu, + reload, + readerMode, + desktop, + translation, + findInPage, + closeTab, + inputUrl, + duplicateTab, + increaseFont, + decreaseFont, + moveToBackground, + pageUp, + pageDown, + font, } diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart index f3c1fe39..4e94fb60 100644 --- a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart @@ -71,6 +71,82 @@ const navigationMenuToolbarButtonSpec = ToolbarButtonSpec( defaultVisible: true, ); +const reloadToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.reload, + defaultVisible: false, +); + +const readerModeToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.readerMode, + defaultVisible: false, + canBeFallbackTarget: false, +); + +const desktopToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.desktop, + defaultVisible: false, +); + +const translationToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.translation, + defaultVisible: false, + canBeFallbackTarget: false, +); + +const findInPageToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.findInPage, + defaultVisible: false, +); + +const closeTabToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.closeTab, + defaultVisible: false, + canBeFallbackTarget: false, +); + +const inputUrlToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.inputUrl, + defaultVisible: false, +); + +const duplicateTabToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.duplicateTab, + defaultVisible: false, +); + +const increaseFontToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.increaseFont, + defaultVisible: false, +); + +const decreaseFontToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.decreaseFont, + defaultVisible: false, +); + +const moveToBackgroundToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.moveToBackground, + defaultVisible: false, + canBeFallbackTarget: false, +); + +const pageUpToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.pageUp, + defaultVisible: false, + canBeFallbackTarget: false, +); + +const pageDownToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.pageDown, + defaultVisible: false, + canBeFallbackTarget: false, +); + +const fontToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.font, + defaultVisible: false, +); + const toolbarButtonSpecs = [ backToolbarButtonSpec, forwardToolbarButtonSpec, @@ -79,6 +155,20 @@ const toolbarButtonSpecs = [ addTabToolbarButtonSpec, tabsCountToolbarButtonSpec, navigationMenuToolbarButtonSpec, + reloadToolbarButtonSpec, + readerModeToolbarButtonSpec, + desktopToolbarButtonSpec, + translationToolbarButtonSpec, + findInPageToolbarButtonSpec, + closeTabToolbarButtonSpec, + inputUrlToolbarButtonSpec, + duplicateTabToolbarButtonSpec, + increaseFontToolbarButtonSpec, + decreaseFontToolbarButtonSpec, + moveToBackgroundToolbarButtonSpec, + pageUpToolbarButtonSpec, + pageDownToolbarButtonSpec, + fontToolbarButtonSpec, ]; final Map toolbarButtonSpecsById = { diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart index 220c1437..c015f9f0 100644 --- a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart @@ -22,11 +22,29 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart'; +import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/entities/font_size_constants.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/font_size_bottom_sheet.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/translation_bottom_sheet.dart'; +import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; +import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; +import 'package:weblibre/features/user/data/models/engine_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/utils/move_to_background.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; class ToolbarButtonDefinition { final ToolbarButtonSpec spec; @@ -155,8 +173,442 @@ final List toolbarButtonRegistry = [ ? NavigationMenuButtonView(onTap: () {}) : NavigationMenuButton(selectedTabId: scope.selectedTabId), ), + ToolbarButtonDefinition( + spec: reloadToolbarButtonSpec, + label: 'Reload', + icon: Icons.refresh, + builder: (scope, context, ref) { + return IconButton( + onPressed: scope.isPreview + ? () {} + : () async { + final tabId = scope.selectedTabId; + if (tabId != null) { + await ref + .read(tabSessionProvider(tabId: tabId).notifier) + .reload(); + } + }, + icon: const Icon(Icons.refresh), + ); + }, + ), + ToolbarButtonDefinition( + spec: readerModeToolbarButtonSpec, + label: 'Reader Mode', + icon: MdiIcons.bookOpenOutline, + isPrimaryAvailable: (scope, ref) { + final readerableState = + scope.tabState?.readerableState ?? ReaderableState.$default(); + final enableReadability = ref.read( + generalSettingsWithDefaultsProvider.select( + (value) => value.enableReadability, + ), + ); + final enforceReadability = ref.read( + generalSettingsWithDefaultsProvider.select( + (value) => value.enforceReadability, + ), + ); + return (readerableState.readerable && + (enableReadability || readerableState.active)) || + (enforceReadability && enableReadability); + }, + builder: (scope, context, ref) { + if (scope.isPreview) { + return IconButton( + onPressed: () {}, + icon: const Icon(MdiIcons.bookOpenOutline), + ); + } + return _ReaderModeToolbarButton(selectedTabId: scope.selectedTabId); + }, + ), + ToolbarButtonDefinition( + spec: desktopToolbarButtonSpec, + label: 'Desktop Site', + icon: Icons.desktop_windows, + builder: (scope, context, ref) { + if (scope.isPreview) { + return IconButton( + onPressed: () {}, + icon: const Icon(Icons.desktop_windows_outlined), + ); + } + return _DesktopModeToolbarButton(selectedTabId: scope.selectedTabId); + }, + ), + ToolbarButtonDefinition( + spec: translationToolbarButtonSpec, + label: 'Translate', + icon: Icons.translate, + isPrimaryAvailable: (scope, ref) { + final engineState = ref.read(translationEngineStateProvider); + final readerActive = scope.tabState?.readerableState.active ?? false; + return !readerActive && engineState?.isEngineSupported == true; + }, + builder: (scope, context, ref) { + if (scope.isPreview) { + return IconButton(onPressed: () {}, icon: const Icon(Icons.translate)); + } + return _TranslateToolbarButton(selectedTabId: scope.selectedTabId); + }, + ), + ToolbarButtonDefinition( + spec: findInPageToolbarButtonSpec, + label: 'Find in Page', + icon: Icons.search, + builder: (scope, context, ref) { + return IconButton( + onPressed: scope.isPreview + ? () {} + : () { + final tabId = scope.selectedTabId; + if (tabId != null) { + ref.read(findInPageControllerProvider(tabId).notifier).show(); + } + }, + icon: const Icon(Icons.search), + ); + }, + ), + ToolbarButtonDefinition( + spec: closeTabToolbarButtonSpec, + label: 'Close Tab', + icon: MdiIcons.tabMinus, + builder: (scope, context, ref) { + return IconButton( + onPressed: scope.isPreview + ? () {} + : () => _closeTab(context, ref, scope.selectedTabId), + icon: const Icon(MdiIcons.tabMinus), + ); + }, + ), + ToolbarButtonDefinition( + spec: inputUrlToolbarButtonSpec, + label: 'Address Bar', + icon: Icons.edit, + builder: (scope, context, ref) { + return IconButton( + onPressed: scope.isPreview + ? () {} + : () async { + final tabState = scope.tabState; + if (tabState != null) { + final searchText = tabState.url.scheme == 'about' + ? SearchRoute.emptySearchText + : tabState.url.toString(); + await SearchRoute( + tabId: tabState.id, + searchText: searchText.isEmpty + ? SearchRoute.emptySearchText + : searchText, + tabType: tabState.tabMode.toTabType(), + ).push(context); + } else { + await const SearchRoute( + tabType: TabType.regular, + ).push(context); + } + }, + icon: const Icon(Icons.edit), + ); + }, + ), + ToolbarButtonDefinition( + spec: duplicateTabToolbarButtonSpec, + label: 'Duplicate Tab', + icon: MdiIcons.tabPlus, + builder: (scope, context, ref) { + return scope.isPreview + ? CloneTabButtonView(onPressed: () {}, onLongPress: () {}) + : CloneTabButton(selectedTabId: scope.selectedTabId); + }, + ), + ToolbarButtonDefinition( + spec: increaseFontToolbarButtonSpec, + label: 'Increase Font', + icon: MdiIcons.formatFontSizeIncrease, + builder: (scope, context, ref) { + return IconButton( + onPressed: scope.isPreview + ? () {} + : () => _adjustFontSize(context, ref, increase: true), + icon: const Icon(MdiIcons.formatFontSizeIncrease), + ); + }, + ), + ToolbarButtonDefinition( + spec: decreaseFontToolbarButtonSpec, + label: 'Decrease Font', + icon: MdiIcons.formatFontSizeDecrease, + builder: (scope, context, ref) { + return IconButton( + onPressed: scope.isPreview + ? () {} + : () => _adjustFontSize(context, ref, increase: false), + icon: const Icon(MdiIcons.formatFontSizeDecrease), + ); + }, + ), + ToolbarButtonDefinition( + spec: moveToBackgroundToolbarButtonSpec, + label: 'Background', + icon: MdiIcons.arrowCollapseDown, + builder: (scope, context, ref) { + return IconButton( + onPressed: scope.isPreview ? () {} : moveToBackground, + icon: const Icon(MdiIcons.arrowCollapseDown), + ); + }, + ), + ToolbarButtonDefinition( + spec: pageUpToolbarButtonSpec, + label: 'Page Up', + icon: MdiIcons.chevronDoubleUp, + builder: (scope, context, ref) { + return IconButton( + onPressed: scope.isPreview + ? () {} + : () async { + final tabId = scope.selectedTabId; + if (tabId != null) { + await ref + .read(tabSessionProvider(tabId: tabId).notifier) + .pageUp(); + } + }, + icon: const Icon(MdiIcons.chevronDoubleUp), + ); + }, + ), + ToolbarButtonDefinition( + spec: pageDownToolbarButtonSpec, + label: 'Page Down', + icon: MdiIcons.chevronDoubleDown, + builder: (scope, context, ref) { + return IconButton( + onPressed: scope.isPreview + ? () {} + : () async { + final tabId = scope.selectedTabId; + if (tabId != null) { + await ref + .read(tabSessionProvider(tabId: tabId).notifier) + .pageDown(); + } + }, + icon: const Icon(MdiIcons.chevronDoubleDown), + ); + }, + ), + ToolbarButtonDefinition( + spec: fontToolbarButtonSpec, + label: 'Text Size', + icon: MdiIcons.formatSize, + builder: (scope, context, ref) { + if (scope.isPreview) { + return IconButton( + onPressed: () {}, + icon: const Icon(MdiIcons.formatSize), + ); + } + return _FontToolbarButton(selectedTabId: scope.selectedTabId); + }, + ), ]; final Map toolbarButtonRegistryById = { for (final def in toolbarButtonRegistry) def.spec.id.name: def, }; + +Future _closeTab( + BuildContext context, + WidgetRef ref, + String? selectedTabId, +) async { + if (selectedTabId == null) return; + + final tabState = ref.read(tabStateProvider(selectedTabId)); + if (tabState != null && tabState.tabMode is IsolatedTabMode) { + final allStates = ref.read(tabStatesProvider); + final groupCount = allStates.values + .where((s) => s.isolationContextId == tabState.isolationContextId) + .length; + if (groupCount <= 1 && context.mounted) { + final confirmed = await ui_helper.confirmIsolatedTabClose(context); + if (!confirmed) return; + } + } + + await ref.read(tabRepositoryProvider.notifier).closeTab(selectedTabId); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref.read(tabRepositoryProvider.notifier).undoClose, + ); + } +} + +Future _adjustFontSize( + BuildContext context, + WidgetRef ref, { + required bool increase, +}) async { + final settings = ref.read(engineSettingsWithDefaultsProvider); + + if (settings.automaticFontSizeAdjustment) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Disable automatic font size in settings to adjust manually', + ), + duration: Duration(seconds: 2), + ), + ); + } + return; + } + + final current = settings.fontSizeFactor; + final newValue = increase + ? (current + fontSizeStep).clamp(fontSizeMin, fontSizeMax) + : (current - fontSizeStep).clamp(fontSizeMin, fontSizeMax); + final rounded = (newValue * 10).round() / 10; + + if (rounded == current) return; + + await ref + .read(saveEngineSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith.fontSizeFactor(rounded), + ); +} + +class _ReaderModeToolbarButton extends ConsumerWidget { + final String? selectedTabId; + + const _ReaderModeToolbarButton({required this.selectedTabId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final readerableState = ref.watch( + tabStateProvider( + selectedTabId, + ).select((state) => state?.readerableState ?? ReaderableState.$default()), + ); + final isReaderLoading = ref.watch( + readerableScreenControllerProvider.select((state) => state.isLoading), + ); + + return IconButton( + onPressed: isReaderLoading + ? null + : () async { + await ref + .read(readerableScreenControllerProvider.notifier) + .toggleReaderView(!readerableState.active); + }, + icon: Icon( + readerableState.active ? MdiIcons.bookOpen : MdiIcons.bookOpenOutline, + color: readerableState.active + ? Theme.of(context).colorScheme.primary + : null, + ), + ); + } +} + +class _DesktopModeToolbarButton extends ConsumerWidget { + final String? selectedTabId; + + const _DesktopModeToolbarButton({required this.selectedTabId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (selectedTabId == null) { + return IconButton( + onPressed: () {}, + icon: const Icon(Icons.desktop_windows), + ); + } + + final desktopEnabled = ref.watch(desktopModeProvider(selectedTabId!)); + + return IconButton( + onPressed: () { + ref.read(desktopModeProvider(selectedTabId!).notifier).toggle(); + }, + icon: Icon( + desktopEnabled ? Icons.desktop_windows : Icons.desktop_windows_outlined, + color: desktopEnabled ? Theme.of(context).colorScheme.primary : null, + ), + ); + } +} + +class _TranslateToolbarButton extends ConsumerWidget { + final String? selectedTabId; + + const _TranslateToolbarButton({required this.selectedTabId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isTranslated = ref.watch( + tabStateProvider( + selectedTabId, + ).select((s) => s?.translationState.isTranslated ?? false), + ); + + return IconButton( + onPressed: () async { + final tabId = selectedTabId; + if (tabId != null) { + if (isTranslated) { + await ref + .read(tabSessionProvider(tabId: tabId).notifier) + .translateRestore(); + } else { + await showTranslationBottomSheet(context, selectedTabId: tabId); + } + } + }, + onLongPress: () async { + final tabId = selectedTabId; + if (tabId != null) { + await showTranslationBottomSheet(context, selectedTabId: tabId); + } + }, + icon: Icon( + isTranslated ? MdiIcons.translateOff : Icons.translate, + color: isTranslated ? Theme.of(context).colorScheme.primary : null, + ), + ); + } +} + +class _FontToolbarButton extends ConsumerWidget { + final String? selectedTabId; + + const _FontToolbarButton({required this.selectedTabId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isCustom = ref.watch( + engineSettingsWithDefaultsProvider.select( + (s) => !s.automaticFontSizeAdjustment && s.fontSizeFactor != 1.0, + ), + ); + + return IconButton( + onPressed: () => showFontSizeBottomSheet(context), + icon: Icon( + MdiIcons.formatSize, + color: isCustom ? Theme.of(context).colorScheme.primary : null, + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart index cd6f400f..5db45989 100644 --- a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart @@ -20,17 +20,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart' + as tab_data; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; class ShareMenuButton extends StatelessWidget { final String? selectedTabId; @@ -138,6 +145,112 @@ class AddTabButtonView extends StatelessWidget { } } +class CloneTabButton extends HookConsumerWidget { + const CloneTabButton({super.key, required this.selectedTabId}); + + final String? selectedTabId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tabMenuController = useMenuController(); + + return CloneTabMenu( + controller: tabMenuController, + selectedTabId: selectedTabId, + child: CloneTabButtonView( + onPressed: selectedTabId == null + ? null + : () => _cloneCurrentTabMode(context, ref, selectedTabId!), + onLongPress: selectedTabId == null + ? null + : () { + if (tabMenuController.isOpen) { + tabMenuController.close(); + } else { + tabMenuController.open(); + } + }, + ), + ); + } +} + +class CloneTabButtonView extends StatelessWidget { + const CloneTabButtonView({super.key, this.onPressed, this.onLongPress}); + + final VoidCallback? onPressed; + final VoidCallback? onLongPress; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onPressed, + onLongPress: onLongPress, + icon: const Icon(MdiIcons.contentDuplicate), + ); + } +} + +class CloneTabMenu extends HookConsumerWidget { + const CloneTabMenu({ + super.key, + required this.child, + required this.controller, + required this.selectedTabId, + }); + + final Widget child; + final MenuController controller; + final String? selectedTabId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final showIsolatedTabUi = ref.watch( + generalSettingsWithDefaultsProvider.select( + (value) => value.showIsolatedTabUi, + ), + ); + + return MenuAnchor( + controller: controller, + builder: (context, controller, child) { + return child!; + }, + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(MdiIcons.tab), + onPressed: selectedTabId == null + ? null + : () => _cloneTabAsRegular(context, ref, selectedTabId!), + child: const Text('Clone as Regular'), + ), + MenuItemButton( + leadingIcon: Icon( + MdiIcons.dominoMask, + color: AppColors.of(context).privateTabPurple, + ), + onPressed: selectedTabId == null + ? null + : () => _cloneTabAsPrivate(context, ref, selectedTabId!), + child: const Text('Clone as Private'), + ), + if (showIsolatedTabUi) + MenuItemButton( + leadingIcon: Icon( + MdiIcons.snowflake, + color: AppColors.of(context).isolatedTabTeal, + ), + onPressed: selectedTabId == null + ? null + : () => _cloneTabAsIsolated(context, ref, selectedTabId!), + child: const Text('Clone as Isolated'), + ), + ], + child: child, + ); + } +} + class TabsCountButtonView extends StatelessWidget { const TabsCountButtonView({ super.key, @@ -169,6 +282,124 @@ class TabsCountButtonView extends StatelessWidget { } } +Future _cloneCurrentTabMode( + BuildContext context, + WidgetRef ref, + String selectedTabId, +) async { + final containerData = await ref + .read(tab_data.tabDataRepositoryProvider.notifier) + .getTabContainerData(selectedTabId); + + final tabId = await ref + .read(tabRepositoryProvider.notifier) + .duplicateTab( + selectTabId: selectedTabId, + containerData: containerData, + selectTab: false, + ); + + if (context.mounted) { + final repo = ref.read(tabRepositoryProvider.notifier); + ui_helper.showTabSwitchMessage( + context, + onSwitch: () => repo.selectTab(tabId), + ); + } +} + +Future _cloneTabAsRegular( + BuildContext context, + WidgetRef ref, + String selectedTabId, +) { + return _cloneTabAsMode(context, ref, selectedTabId, mode: TabMode.regular); +} + +Future _cloneTabAsPrivate( + BuildContext context, + WidgetRef ref, + String selectedTabId, +) { + return _cloneTabAsMode(context, ref, selectedTabId, mode: TabMode.private); +} + +Future _cloneTabAsIsolated( + BuildContext context, + WidgetRef ref, + String selectedTabId, +) { + return _cloneTabAsMode( + context, + ref, + selectedTabId, + mode: TabMode.newIsolated(), + ); +} + +Future _cloneTabAsMode( + BuildContext context, + WidgetRef ref, + String selectedTabId, { + required TabMode mode, +}) async { + final tabState = ref.read(tabStateProvider(selectedTabId)); + if (tabState == null) return; + + final containerData = await ref + .read(tab_data.tabDataRepositoryProvider.notifier) + .getTabContainerData(selectedTabId); + final repo = ref.read(tabRepositoryProvider.notifier); + + final tabId = switch (mode) { + RegularTabMode() => + tabState.tabMode is RegularTabMode + ? await repo.duplicateTab( + selectTabId: selectedTabId, + containerData: containerData, + selectTab: false, + ) + : await repo.addTab( + tabMode: TabMode.regular, + url: tabState.url, + containerSelection: containerData == null + ? const TabContainerSelection.unassigned() + : TabContainerSelection.specific(containerData), + selectTab: false, + ), + PrivateTabMode() => + tabState.tabMode is PrivateTabMode + ? await repo.duplicateTab( + selectTabId: selectedTabId, + containerData: containerData, + selectTab: false, + ) + : await repo.addTab( + tabMode: TabMode.private, + url: tabState.url, + containerSelection: containerData == null + ? const TabContainerSelection.unassigned() + : TabContainerSelection.specific(containerData), + selectTab: false, + ), + IsolatedTabMode() => await repo.addTab( + tabMode: TabMode.newIsolated(), + url: tabState.url, + containerSelection: containerData == null + ? const TabContainerSelection.unassigned() + : TabContainerSelection.specific(containerData), + selectTab: false, + ), + }; + + if (context.mounted) { + ui_helper.showTabSwitchMessage( + context, + onSwitch: () => repo.selectTab(tabId), + ); + } +} + class TabsCountButton extends HookConsumerWidget { const TabsCountButton({ super.key, diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/font_size_bottom_sheet.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/font_size_bottom_sheet.dart new file mode 100644 index 00000000..dd4ae9c4 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/font_size_bottom_sheet.dart @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/entities/font_size_constants.dart'; +import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; +import 'package:weblibre/features/user/data/models/engine_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; + +Future showFontSizeBottomSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => const FontSizeBottomSheet(), + ); +} + +class FontSizeBottomSheet extends ConsumerWidget { + const FontSizeBottomSheet({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(engineSettingsWithDefaultsProvider); + final factor = settings.fontSizeFactor; + final isAutomatic = settings.automaticFontSizeAdjustment; + final canDecrease = !isAutomatic && factor > fontSizeMin; + final canIncrease = !isAutomatic && factor < fontSizeMax; + final isDefault = factor == fontSizeDefault; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon( + MdiIcons.formatSize, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Text Size', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 24), + if (isAutomatic) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Card( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Automatic font size is enabled. ' + 'Disable in Settings to adjust manually.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton.filled( + onPressed: canDecrease + ? () => _adjustFontSize(ref, increase: false) + : null, + icon: const Icon(MdiIcons.formatFontSizeDecrease), + ), + const SizedBox(width: 24), + SizedBox( + width: 72, + child: Text( + '${(factor * 100).round()}%', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium, + ), + ), + const SizedBox(width: 24), + IconButton.filled( + onPressed: canIncrease + ? () => _adjustFontSize(ref, increase: true) + : null, + icon: const Icon(MdiIcons.formatFontSizeIncrease), + ), + ], + ), + if (!isDefault && !isAutomatic) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: () => _resetFontSize(ref), + child: const Text('Reset to 100%'), + ), + ], + ], + ), + ), + ); + } + + Future _adjustFontSize(WidgetRef ref, {required bool increase}) async { + final current = ref.read(engineSettingsWithDefaultsProvider).fontSizeFactor; + final newValue = increase + ? (current + fontSizeStep).clamp(fontSizeMin, fontSizeMax) + : (current - fontSizeStep).clamp(fontSizeMin, fontSizeMax); + final rounded = (newValue * 10).round() / 10; + + if (rounded == current) return; + + await ref + .read(saveEngineSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith.fontSizeFactor(rounded), + ); + } + + Future _resetFontSize(WidgetRef ref) async { + await ref + .read(saveEngineSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.fontSizeFactor(fontSizeDefault), + ); + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSessionApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSessionApiImpl.kt index e80f3cdc..6ee288ec 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSessionApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSessionApiImpl.kt @@ -245,6 +245,28 @@ class GeckoSessionApiImpl : GeckoSessionApi { } } + override fun dispatchKeyEvent(keyCode: Long) { + try { + logger.debug("$TAG: Dispatching key event with keyCode: $keyCode") + val engineView = components.activeEngineView + ?: throw IllegalStateException("No active engine view") + val view = engineView.asView() + + val downEvent = android.view.KeyEvent( + android.view.KeyEvent.ACTION_DOWN, keyCode.toInt() + ) + view.dispatchKeyEvent(downEvent) + + val upEvent = android.view.KeyEvent( + android.view.KeyEvent.ACTION_UP, keyCode.toInt() + ) + view.dispatchKeyEvent(upEvent) + } catch (e: Exception) { + logger.error("$TAG: Failed to dispatch key event", e) + throw e + } + } + override fun requestScreenshot(sendBack: Boolean, callback: (Result) -> Unit) { try { val tab = components.core.store.state.selectedTab diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index edef1515..124caa25 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -5401,6 +5401,7 @@ interface GeckoSessionApi { fun purgeHistory() fun updateLastAccess(tabId: String?, lastAccess: Long?) fun requestScreenshot(sendBack: Boolean, callback: (Result) -> Unit) + fun dispatchKeyEvent(keyCode: Long) companion object { /** The codec used by GeckoSessionApi. */ @@ -5732,6 +5733,24 @@ interface GeckoSessionApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.dispatchKeyEvent$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val keyCodeArg = args[0] as Long + val wrapped: List = try { + api.dispatchKeyEvent(keyCodeArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_session.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_session.dart index 4019674c..dd1fff2a 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_session.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_session.dart @@ -117,6 +117,10 @@ class GeckoSessionService { return _api.requestScreenshot(sendBack); } + Future dispatchKeyEvent({required int keyCode}) { + return _api.dispatchKeyEvent(keyCode: keyCode); + } + Future updateLastAccess({ String? tabId, //If null = current tab DateTime? lastAccess, //If null datetime.now diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index 2c34de50..2c883e1a 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -6673,6 +6673,28 @@ class GeckoSessionApi { return (pigeonVar_replyList[0] as Uint8List?); } } + + Future dispatchKeyEvent({required int keyCode}) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.dispatchKeyEvent$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([keyCode]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } } class GeckoTabsApi { diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index 67d44558..c81de867 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -1378,6 +1378,8 @@ abstract class GeckoSessionApi { @async Uint8List? requestScreenshot(bool sendBack); + + void dispatchKeyEvent({required int keyCode}); } @HostApi()