added more contextual menu buttons

This commit is contained in:
Fabian Freund
2026-03-11 14:57:17 +01:00
parent a457d3dd24
commit 19ec600949
12 changed files with 1054 additions and 0 deletions
@@ -96,6 +96,16 @@ class TabSession extends _$TabSession {
return _sessionService.translateRestore();
}
Future<void> pageUp() {
// Android KeyEvent.KEYCODE_PAGE_UP = 92
return _sessionService.dispatchKeyEvent(keyCode: 92);
}
Future<void> pageDown() {
// Android KeyEvent.KEYCODE_PAGE_DOWN = 93
return _sessionService.dispatchKeyEvent(keyCode: 93);
}
@override
void build({required String? tabId}) {
_sessionService = (tabId != null)
@@ -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 <http://www.gnu.org/licenses/>.
*/
const fontSizeStep = 0.1;
const fontSizeMin = 0.5;
const fontSizeMax = 3.0;
const fontSizeDefault = 1.0;
@@ -26,4 +26,18 @@ enum ToolbarButtonId {
addTab,
tabsCount,
navigationMenu,
reload,
readerMode,
desktop,
translation,
findInPage,
closeTab,
inputUrl,
duplicateTab,
increaseFont,
decreaseFont,
moveToBackground,
pageUp,
pageDown,
font,
}
@@ -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<String, ToolbarButtonSpec> toolbarButtonSpecsById = {
@@ -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<ToolbarButtonDefinition> 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<String, ToolbarButtonDefinition> toolbarButtonRegistryById = {
for (final def in toolbarButtonRegistry) def.spec.id.name: def,
};
Future<void> _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<void> _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,
),
);
}
}
@@ -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<void> _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<void> _cloneTabAsRegular(
BuildContext context,
WidgetRef ref,
String selectedTabId,
) {
return _cloneTabAsMode(context, ref, selectedTabId, mode: TabMode.regular);
}
Future<void> _cloneTabAsPrivate(
BuildContext context,
WidgetRef ref,
String selectedTabId,
) {
return _cloneTabAsMode(context, ref, selectedTabId, mode: TabMode.private);
}
Future<void> _cloneTabAsIsolated(
BuildContext context,
WidgetRef ref,
String selectedTabId,
) {
return _cloneTabAsMode(
context,
ref,
selectedTabId,
mode: TabMode.newIsolated(),
);
}
Future<void> _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,
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void> 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<void> _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<void> _resetFontSize(WidgetRef ref) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.fontSizeFactor(fontSizeDefault),
);
}
}
@@ -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<ByteArray?>) -> Unit) {
try {
val tab = components.core.store.state.selectedTab
@@ -5401,6 +5401,7 @@ interface GeckoSessionApi {
fun purgeHistory()
fun updateLastAccess(tabId: String?, lastAccess: Long?)
fun requestScreenshot(sendBack: Boolean, callback: (Result<ByteArray?>) -> 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<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.dispatchKeyEvent$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val keyCodeArg = args[0] as Long
val wrapped: List<Any?> = try {
api.dispatchKeyEvent(keyCodeArg)
listOf(null)
} catch (exception: Throwable) {
GeckoPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -117,6 +117,10 @@ class GeckoSessionService {
return _api.requestScreenshot(sendBack);
}
Future<void> dispatchKeyEvent({required int keyCode}) {
return _api.dispatchKeyEvent(keyCode: keyCode);
}
Future<void> updateLastAccess({
String? tabId, //If null = current tab
DateTime? lastAccess, //If null datetime.now
@@ -6673,6 +6673,28 @@ class GeckoSessionApi {
return (pigeonVar_replyList[0] as Uint8List?);
}
}
Future<void> dispatchKeyEvent({required int keyCode}) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.dispatchKeyEvent$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[keyCode]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
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 {
@@ -1378,6 +1378,8 @@ abstract class GeckoSessionApi {
@async
Uint8List? requestScreenshot(bool sendBack);
void dispatchKeyEvent({required int keyCode});
}
@HostApi()