From 1c8eeac85eb3298b82aa5bcf203930e3869af040 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 30 Oct 2025 15:56:21 +0100 Subject: [PATCH 1/8] initial commit --- .../lib/src/controls/context_menu_region.dart | 265 ++++++++++++++++++ .../lib/src/controls/popup_menu_button.dart | 59 +--- .../flet/lib/src/flet_core_extension.dart | 3 + packages/flet/lib/src/utils/popup_menu.dart | 66 +++++ .../controls/context_menu_region/basic.py | 54 ++++ .../flet/docs/controls/contextmenuregion.md | 14 + .../flet/docs/types/contextmenutrigger.md | 14 + .../material/test_context_menu_region.py | 75 +++++ sdk/python/packages/flet/mkdocs.yml | 2 + sdk/python/packages/flet/src/flet/__init__.py | 8 + .../controls/material/context_menu_region.py | 135 +++++++++ .../packages/flet/src/flet/controls/page.py | 21 +- 12 files changed, 647 insertions(+), 69 deletions(-) create mode 100644 packages/flet/lib/src/controls/context_menu_region.dart create mode 100644 packages/flet/lib/src/utils/popup_menu.dart create mode 100644 sdk/python/examples/controls/context_menu_region/basic.py create mode 100644 sdk/python/packages/flet/docs/controls/contextmenuregion.md create mode 100644 sdk/python/packages/flet/docs/types/contextmenutrigger.md create mode 100644 sdk/python/packages/flet/integration_tests/controls/material/test_context_menu_region.py create mode 100644 sdk/python/packages/flet/src/flet/controls/material/context_menu_region.py diff --git a/packages/flet/lib/src/controls/context_menu_region.dart b/packages/flet/lib/src/controls/context_menu_region.dart new file mode 100644 index 0000000000..19b5f5f7d1 --- /dev/null +++ b/packages/flet/lib/src/controls/context_menu_region.dart @@ -0,0 +1,265 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flet/src/utils/numbers.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../extensions/control.dart'; +import '../models/control.dart'; +import '../utils/keys.dart'; +import '../utils/popup_menu.dart'; +import '../widgets/error.dart'; +import 'base_controls.dart'; + +class ContextMenuRegionControl extends StatefulWidget { + final Control control; + + const ContextMenuRegionControl({super.key, required this.control}); + + @override + State createState() => + _ContextMenuRegionControlState(); +} + +class _ContextMenuRegionControlState extends State { + ContextMenuTrigger _primaryTrigger = ContextMenuTrigger.disabled; + ContextMenuTrigger _secondaryTrigger = ContextMenuTrigger.down; + ContextMenuTrigger _tertiaryTrigger = ContextMenuTrigger.down; + + Future? _pendingMenu; + + @override + Widget build(BuildContext context) { + debugPrint("ContextMenuRegion build: ${widget.control.id}"); + + var content = widget.control.buildWidget("content"); + if (content == null) { + return const ErrorControl("ContextMenuRegion.content must be visible"); + } + + _primaryTrigger = parseContextMenuTrigger( + widget.control.getString("primary_trigger"), + ContextMenuTrigger.disabled)!; + _secondaryTrigger = parseContextMenuTrigger( + widget.control.getString("secondary_trigger"), + ContextMenuTrigger.down)!; + _tertiaryTrigger = parseContextMenuTrigger( + widget.control.getString("tertiary_trigger"), ContextMenuTrigger.down)!; + + Widget result = GestureDetector( + behavior: HitTestBehavior.deferToChild, + onLongPressStart: _primaryTrigger == ContextMenuTrigger.longPress + ? (LongPressStartDetails details) => _handleLongPress( + _MouseButton.primary, + details.globalPosition, + details.localPosition, + ) + : null, + onSecondaryLongPressStart: + _secondaryTrigger == ContextMenuTrigger.longPress + ? (LongPressStartDetails details) => _handleLongPress( + _MouseButton.secondary, + details.globalPosition, + details.localPosition, + ) + : null, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _handlePointerDown, + child: content, + ), + ); + + return LayoutControl(control: widget.control, child: result); + } + + /// Handles pointer down events to determine if a context menu should be shown. + /// Only responds to mouse events and triggers the menu if the configured trigger is `down`. + void _handlePointerDown(PointerDownEvent event) { + if (event.kind != PointerDeviceKind.mouse) return; + + final button = _mouseButtonFromEvent(event.buttons); + if (button == null) return; + + final trigger = _getTriggerFromButton(button); + if (trigger != ContextMenuTrigger.down) return; + + _showMenu( + button, + globalPosition: event.position, + localPosition: event.localPosition, + ); + } + + void _handleLongPress( + _MouseButton button, Offset globalPosition, Offset localPosition) { + final trigger = _getTriggerFromButton(button); + if (trigger != ContextMenuTrigger.longPress) return; + + _showMenu( + button, + globalPosition: globalPosition, + localPosition: localPosition, + ); + } + + ContextMenuTrigger _getTriggerFromButton(_MouseButton button) { + switch (button) { + case _MouseButton.primary: + return _primaryTrigger; + case _MouseButton.secondary: + return _secondaryTrigger; + case _MouseButton.tertiary: + return _tertiaryTrigger; + } + } + + /// Returns the corresponding [_MouseButton] based on the + /// given button bitmask, and `null` if no recognized button is pressed. + _MouseButton? _mouseButtonFromEvent(int buttons) { + if ((buttons & kPrimaryButton) != 0) { + return _MouseButton.primary; + } else if ((buttons & kSecondaryMouseButton) != 0) { + return _MouseButton.secondary; + } else if ((buttons & kTertiaryButton) != 0) { + return _MouseButton.tertiary; + } + return null; + } + + List _getPopupItemsFromButton(_MouseButton button) { + List items; + switch (button) { + case _MouseButton.primary: + items = widget.control.children("primary_items"); + break; + case _MouseButton.secondary: + items = widget.control.children("secondary_items"); + break; + case _MouseButton.tertiary: + items = widget.control.children("tertiary_items"); + break; + } + + return items.isEmpty ? items = widget.control.children("items") : items; + } + + Map _eventPayload( + _MouseButton button, Offset globalPosition, Offset? localPosition, + {int? itemId, + String? itemControlId, + int? itemIndex, + Object? itemKey, + int? itemCount}) { + return { + "b": button.name, + "tr": _getTriggerFromButton(button).name, + "iid": itemId, + "cid": itemControlId, + "idx": itemIndex, + "key": itemKey, + "ic": itemCount, + "g": {"x": globalPosition.dx, "y": globalPosition.dy}, + "l": localPosition != null + ? {"x": localPosition.dx, "y": localPosition.dy} + : null, + }; + } + + Future _showMenu(_MouseButton button, + {required Offset globalPosition, Offset? localPosition}) async { + if (_pendingMenu != null) { + Navigator.of(context).pop(); + await _pendingMenu; + if (!mounted) return; + } + + final overlayState = Overlay.of(context); + final overlayRenderBox = + overlayState.context.findRenderObject() as RenderBox?; + if (overlayRenderBox == null || !overlayRenderBox.hasSize) { + return; + } + + final overlayOffset = overlayRenderBox.globalToLocal(globalPosition); + final position = RelativeRect.fromLTRB( + overlayOffset.dx, + overlayOffset.dy, + overlayRenderBox.size.width - overlayOffset.dx, + overlayRenderBox.size.height - overlayOffset.dy, + ); + + final basePayload = _eventPayload(button, globalPosition, localPosition); + + widget.control.triggerEvent("request", basePayload); + + final popupItems = _getPopupItemsFromButton(button) + .where((c) => c.type == "PopupMenuItem") + .toList(growable: false); + final entries = buildPopupMenuEntries(context, popupItems); + basePayload.addAll({"ic": entries.length}); + + // If there are no menu items, trigger the dismiss event and return. + if (entries.isEmpty) { + widget.control.triggerEvent("dismiss", basePayload); + return; + } + + widget.control.triggerEvent("open", basePayload); + + final menuFuture = showMenu( + context: context, + position: position, + items: entries, + ); + _pendingMenu = menuFuture; + final selection = await menuFuture; + + if (!mounted) return; + + _pendingMenu = null; + + if (selection != null) { + final selectedControl = popupItems + .firstWhereOrNull((item) => item.id.toString() == selection); + final itemId = parseInt(selection); + final controlKey = selectedControl?.getKey("key"); + widget.control.triggerEvent( + "select", + _eventPayload( + button, + globalPosition, + localPosition, + itemId: itemId, + itemControlId: selection, + itemKey: controlKey?.value, + itemCount: popupItems.length, + itemIndex: selectedControl != null + ? popupItems.indexOf(selectedControl) + : null, + )); + } else { + widget.control.triggerEvent( + "dismiss", + _eventPayload( + button, + globalPosition, + localPosition, + itemCount: popupItems.length, + )); + } + } +} + +enum _MouseButton { primary, secondary, tertiary } + +enum ContextMenuTrigger { disabled, down, longPress } + +ContextMenuTrigger? parseContextMenuTrigger(String? value, + [ContextMenuTrigger? defaultValue]) { + if (value == null) return defaultValue; + return ContextMenuTrigger.values.firstWhereOrNull( + (e) => e.name.toLowerCase() == value.toLowerCase()) ?? + defaultValue; +} diff --git a/packages/flet/lib/src/controls/popup_menu_button.dart b/packages/flet/lib/src/controls/popup_menu_button.dart index 4936326ab1..5d4214108d 100644 --- a/packages/flet/lib/src/controls/popup_menu_button.dart +++ b/packages/flet/lib/src/controls/popup_menu_button.dart @@ -1,4 +1,4 @@ -import 'package:flet/src/utils/text.dart'; +import '../utils/text.dart'; import 'package:flutter/material.dart'; import '../extensions/control.dart'; @@ -12,6 +12,7 @@ import '../utils/edge_insets.dart'; import '../utils/misc.dart'; import '../utils/mouse.dart'; import '../utils/numbers.dart'; +import '../utils/popup_menu.dart'; import 'base_controls.dart'; class PopupMenuButtonControl extends StatelessWidget { @@ -51,60 +52,8 @@ class PopupMenuButtonControl extends StatelessWidget { control.triggerEvent("select", selection), onCanceled: () => control.triggerEvent("cancel"), onOpened: () => control.triggerEvent("open"), - itemBuilder: (BuildContext context) => control - .children("items") - .where((i) => i.type == "PopupMenuItem") - .map((item) { - var checked = item.getBool("checked"); - var height = item.getDouble("height", 48.0)!; - var padding = item.getPadding("padding"); - var itemContent = item.buildTextOrWidget("content"); - var itemIcon = item.buildIconOrWidget("icon"); - var mouseCursor = item.getMouseCursor("mouse_cursor"); - var labelTextStyle = item.getWidgetStateTextStyle( - "label_text_style", Theme.of(context)); - - Widget? child; - if (itemContent != null && itemIcon == null) { - child = itemContent; - } else if (itemContent == null && itemIcon != null) { - child = itemIcon; - } else if (itemContent != null && itemIcon != null) { - child = Row(children: [ - itemIcon, - const SizedBox(width: 8), - itemContent - ]); - } - - var result = checked != null - ? CheckedPopupMenuItem( - value: item.id.toString(), - checked: checked, - height: height, - padding: padding, - enabled: !item.disabled, - mouseCursor: mouseCursor, - labelTextStyle: labelTextStyle, - onTap: () => item.triggerEvent("click", !checked), - child: child, - ) - : PopupMenuItem( - value: item.id.toString(), - height: height, - padding: padding, - labelTextStyle: labelTextStyle, - enabled: !item.disabled, - mouseCursor: mouseCursor, - onTap: () { - item.triggerEvent("click"); - }, - child: child); - - return child != null - ? result - : const PopupMenuDivider() as PopupMenuEntry; - }).toList(), + itemBuilder: (BuildContext context) => + buildPopupMenuEntries(context, control.children("items")), child: content); return LayoutControl( diff --git a/packages/flet/lib/src/flet_core_extension.dart b/packages/flet/lib/src/flet_core_extension.dart index 04d20cc3c0..c16b67e308 100644 --- a/packages/flet/lib/src/flet_core_extension.dart +++ b/packages/flet/lib/src/flet_core_extension.dart @@ -20,6 +20,7 @@ import 'controls/chip.dart'; import 'controls/circle_avatar.dart'; import 'controls/column.dart'; import 'controls/container.dart'; +import 'controls/context_menu_region.dart'; import 'controls/cupertino_action_sheet.dart'; import 'controls/cupertino_action_sheet_action.dart'; import 'controls/cupertino_activity_indicator.dart'; @@ -172,6 +173,8 @@ class FletCoreExtension extends FletExtension { return ColumnControl(key: key, control: control); case "Container": return ContainerControl(key: key, control: control); + case "ContextMenuRegion": + return ContextMenuRegionControl(key: key, control: control); case "CupertinoActionSheet": return CupertinoActionSheetControl(key: key, control: control); case "CupertinoActionSheetAction": diff --git a/packages/flet/lib/src/utils/popup_menu.dart b/packages/flet/lib/src/utils/popup_menu.dart new file mode 100644 index 0000000000..200fcbd3c2 --- /dev/null +++ b/packages/flet/lib/src/utils/popup_menu.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../extensions/control.dart'; +import '../models/control.dart'; +import 'edge_insets.dart'; +import 'mouse.dart'; +import 'numbers.dart'; +import 'text.dart'; + +/// Builds a list of [PopupMenuEntry] widgets from a collection of Flet controls. +/// +/// Only controls with type `PopupMenuItem` are converted. Controls without any +/// visible content are treated as menu dividers. +List> buildPopupMenuEntries( + BuildContext context, Iterable items) { + return items.where((item) => item.type == "PopupMenuItem").map((item) { + var checked = item.getBool("checked"); + var height = item.getDouble("height", 48.0)!; + var padding = item.getPadding("padding"); + var itemContent = item.buildTextOrWidget("content"); + var itemIcon = item.buildIconOrWidget("icon"); + var mouseCursor = item.getMouseCursor("mouse_cursor"); + var labelTextStyle = + item.getWidgetStateTextStyle("label_text_style", Theme.of(context)); + + Widget? child; + if (itemContent != null && itemIcon == null) { + child = itemContent; + } else if (itemContent == null && itemIcon != null) { + child = itemIcon; + } else if (itemContent != null && itemIcon != null) { + child = Row(children: [ + itemIcon, + const SizedBox(width: 8), + itemContent, + ]); + } + + var entry = checked != null + ? CheckedPopupMenuItem( + value: item.id.toString(), + checked: checked, + height: height, + padding: padding, + enabled: !item.disabled, + mouseCursor: mouseCursor, + labelTextStyle: labelTextStyle, + onTap: () => item.triggerEvent("click", !checked), + child: child, + ) + : PopupMenuItem( + value: item.id.toString(), + height: height, + padding: padding, + labelTextStyle: labelTextStyle, + enabled: !item.disabled, + mouseCursor: mouseCursor, + onTap: () => item.triggerEvent("click"), + child: child, + ); + + return child != null + ? entry + : const PopupMenuDivider() as PopupMenuEntry; + }).toList(); +} diff --git a/sdk/python/examples/controls/context_menu_region/basic.py b/sdk/python/examples/controls/context_menu_region/basic.py new file mode 100644 index 0000000000..e7336d6578 --- /dev/null +++ b/sdk/python/examples/controls/context_menu_region/basic.py @@ -0,0 +1,54 @@ +import flet as ft + + +def main(page: ft.Page): + def handle_select(e: ft.ContextMenuEvent): + item_label = e.item_control_id or e.item_key or e.item_index + page.show_dialog( + ft.SnackBar( + content=f"{e.button} button selected item {item_label}.", + duration=ft.Duration(seconds=4), + ) + ) + page.add(ft.Text(f"{e}")) + print(e.item) + + page.add( + ft.Column( + spacing=20, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + controls=[ + ft.Text("Right-click the card to open the menu."), + ft.ContextMenuRegion( + primary_items=[ + ft.PopupMenuItem(content="Rename"), + ft.PopupMenuItem(content="Share"), + ], + primary_trigger=ft.ContextMenuTrigger.LONG_PRESS, + secondary_items=[ + ft.PopupMenuItem(content="Copy"), + ft.PopupMenuItem(content="Delete"), + ], + tertiary_items=[ + ft.PopupMenuItem(content="Open in new tab"), + ], + on_select=handle_select, + on_dismiss=lambda e: page.add(ft.Text(f"{e}")), + on_request=lambda e: page.add(ft.Text(f"{e}")), + on_open=lambda e: page.add(ft.Text(f"{e}")), + content=ft.Container( + width=220, + height=120, + bgcolor=ft.Colors.BLUE, + alignment=ft.Alignment.CENTER, + border_radius=ft.BorderRadius.all(12), + content=ft.Text("Context menu area"), + ), + ), + ], + ) + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/packages/flet/docs/controls/contextmenuregion.md b/sdk/python/packages/flet/docs/controls/contextmenuregion.md new file mode 100644 index 0000000000..aae7ef1922 --- /dev/null +++ b/sdk/python/packages/flet/docs/controls/contextmenuregion.md @@ -0,0 +1,14 @@ +--- +class_name: flet.ContextMenuRegion +examples: ../../examples/controls/context_menu_region +--- + +{{ class_summary(class_name) }} + +## Examples + +```python +--8<-- "{{ examples }}/basic.py" +``` + +{{ class_members(class_name) }} diff --git a/sdk/python/packages/flet/docs/types/contextmenutrigger.md b/sdk/python/packages/flet/docs/types/contextmenutrigger.md new file mode 100644 index 0000000000..aae7ef1922 --- /dev/null +++ b/sdk/python/packages/flet/docs/types/contextmenutrigger.md @@ -0,0 +1,14 @@ +--- +class_name: flet.ContextMenuRegion +examples: ../../examples/controls/context_menu_region +--- + +{{ class_summary(class_name) }} + +## Examples + +```python +--8<-- "{{ examples }}/basic.py" +``` + +{{ class_members(class_name) }} diff --git a/sdk/python/packages/flet/integration_tests/controls/material/test_context_menu_region.py b/sdk/python/packages/flet/integration_tests/controls/material/test_context_menu_region.py new file mode 100644 index 0000000000..14fa0ea5fd --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/controls/material/test_context_menu_region.py @@ -0,0 +1,75 @@ +import pytest + +import flet as ft +import flet.testing as ftt + + +@pytest.mark.asyncio(loop_scope="function") +async def test_primary_select(flet_app: ftt.FletTestApp): + status = ft.Text("idle", key="status") + + def handle_select(e: ft.ContextMenuEvent): + status.value = e.item_key + e.page.update() + + region = ft.ContextMenuRegion( + primary_trigger=ft.ContextMenuTrigger.DOWN, + primary_items=[ + ft.PopupMenuItem(content="Rename", key="rename"), + ft.PopupMenuItem(content="Duplicate", key="duplicate"), + ], + on_select=handle_select, + content=ft.Container( + key="context-region", + width=160, + height=100, + bgcolor=ft.Colors.SURFACE_VARIANT, + alignment=ft.Alignment.CENTER, + content=ft.Text("Primary menu"), + ), + ) + + flet_app.page.add(status, region) + await flet_app.tester.pump_and_settle() + + await flet_app.tester.tap(await flet_app.tester.find_by_key("context-region")) + await flet_app.tester.pump_and_settle() + + await flet_app.tester.tap(await flet_app.tester.find_by_text("Rename")) + await flet_app.tester.pump_and_settle() + + assert status.value == "rename" + + +@pytest.mark.asyncio(loop_scope="function") +async def test_dismiss_event(flet_app: ftt.FletTestApp): + status = ft.Text("idle", key="status") + + def handle_dismiss(e: ft.ContextMenuEvent): + status.value = e.reason + e.page.update() + + region = ft.ContextMenuRegion( + primary_trigger=ft.ContextMenuTrigger.DOWN, + primary_items=[ft.PopupMenuItem(content="Rename", key="rename")], + on_dismiss=handle_dismiss, + content=ft.Container( + key="context-region", + width=160, + height=100, + bgcolor=ft.Colors.SURFACE_VARIANT, + alignment=ft.Alignment.CENTER, + content=ft.Text("Dismiss menu"), + ), + ) + + flet_app.page.add(status, region) + await flet_app.tester.pump_and_settle() + + await flet_app.tester.tap(await flet_app.tester.find_by_key("context-region")) + await flet_app.tester.pump_and_settle() + + await flet_app.tester.tap(await flet_app.tester.find_by_key("status")) + await flet_app.tester.pump_and_settle() + + assert status.value == "cancelled" diff --git a/sdk/python/packages/flet/mkdocs.yml b/sdk/python/packages/flet/mkdocs.yml index c7be23738c..2116797ad6 100644 --- a/sdk/python/packages/flet/mkdocs.yml +++ b/sdk/python/packages/flet/mkdocs.yml @@ -306,6 +306,7 @@ nav: - CircleAvatar: controls/circleavatar.md - Column: controls/column.md - Container: controls/container.md + - ContextMenuRegion: controls/contextmenuregion.md - CupertinoActionSheet: - controls/cupertinoactionsheet/index.md - CupertinoActionSheetAction: controls/cupertinoactionsheetaction.md @@ -761,6 +762,7 @@ nav: - CardVariant: types/cardvariant.md - ClipBehavior: types/clipbehavior.md - Colors: types/colors.md + - ContextMenuTrigger: types/contextmenutrigger.md - CrossAxisAlignment: types/crossaxisalignment.md - CupertinoButtonSize: types/cupertinobuttonsize.md - CupertinoColors: types/cupertinocolors.md diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index 0b23b2c31b..47f3ba73d0 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -265,6 +265,11 @@ from flet.controls.material.chip import Chip from flet.controls.material.circle_avatar import CircleAvatar from flet.controls.material.container import Container +from flet.controls.material.context_menu_region import ( + ContextMenuEvent, + ContextMenuRegion, + ContextMenuTrigger, +) from flet.controls.material.datatable import ( DataCell, DataColumn, @@ -607,6 +612,9 @@ "ConstrainedControl", "Container", "Context", + "ContextMenuEvent", + "ContextMenuRegion", + "ContextMenuTrigger", "ContinuousRectangleBorder", "Control", "ControlEvent", diff --git a/sdk/python/packages/flet/src/flet/controls/material/context_menu_region.py b/sdk/python/packages/flet/src/flet/controls/material/context_menu_region.py new file mode 100644 index 0000000000..944311a304 --- /dev/null +++ b/sdk/python/packages/flet/src/flet/controls/material/context_menu_region.py @@ -0,0 +1,135 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + +from flet.controls.base_control import control +from flet.controls.control import Control +from flet.controls.control_event import Event, EventHandler +from flet.controls.layout_control import LayoutControl +from flet.controls.material.popup_menu_button import PopupMenuItem +from flet.controls.transform import Offset + +__all__ = ["ContextMenuEvent", "ContextMenuRegion", "ContextMenuTrigger"] + + +class ContextMenuTrigger(Enum): + """Defines how a menu is shown for a specific mouse button.""" + + DISABLED = "disabled" + DOWN = "down" + LONG_PRESS = "longPress" + + +@dataclass(kw_only=True) +class ContextMenuEvent(Event["ContextMenuRegion"]): + button: str = field(metadata={"data_field": "b"}) + """Mouse button that triggered the menu.""" + + trigger: str = field(metadata={"data_field": "tr"}) + """Trigger mode that opened the menu.""" + + global_position: Offset = field(metadata={"data_field": "g"}) + """Global pointer position in logical pixels.""" + + local_position: Optional[Offset] = field(default=None, metadata={"data_field": "l"}) + """Local pointer position relative to the wrapped control.""" + + item_id: Optional[int] = field(default=None, metadata={"data_field": "iid"}) + """Internal numeric identifier of the selected menu item.""" + + item_control_id: Optional[str] = field(default=None, metadata={"data_field": "cid"}) + """Control identifier of the selected menu entry.""" + + item_index: Optional[int] = field(default=None, metadata={"data_field": "idx"}) + """Index of the selected menu entry within the rendered list.""" + + item_key: Optional[str] = field(default=None, metadata={"data_field": "key"}) + """Key associated with the selected menu entry, if provided.""" + + item_count: Optional[int] = field(default=None, metadata={"data_field": "ic"}) + """Total number of entries displayed in the context menu.""" + + @property + def item(self) -> Optional[PopupMenuItem]: + return self.page.get_control(self.item_control_id) + + +@control("ContextMenuRegion") +class ContextMenuRegion(LayoutControl): + """ + Wraps its [`content`][(c).] and displays contextual + menus for specific mouse events. + + Tip: + On the web, call [`disable()`][`flet.BrowserContextMenu.disable`] method of + [`Page.browser_context_menu`][`flet.`] to suppress the default browser menu + before relying on custom menus. + """ + + content: Control + """ + The child control that listens for mouse interaction. + + Raises: + ValueError: If not visible. + """ + + items: list[PopupMenuItem] = field(default_factory=list) + """ + A default menu definition used when button-specific items are not supplied. + """ + + primary_items: list[PopupMenuItem] = field(default_factory=list) + """ + The menu displayed for primary (usually left) mouse button actions. + """ + + secondary_items: list[PopupMenuItem] = field(default_factory=list) + """ + The menu displayed for secondary (usually right) mouse button actions. + """ + + tertiary_items: list[PopupMenuItem] = field(default_factory=list) + """ + The menu displayed for tertiary (usually middle) mouse button actions. + """ + + primary_trigger: ContextMenuTrigger = ContextMenuTrigger.DISABLED + """ + How the menu for the primary button is invoked. + """ + + secondary_trigger: ContextMenuTrigger = ContextMenuTrigger.DOWN + """ + How the menu for the secondary button is invoked. + """ + + tertiary_trigger: ContextMenuTrigger = ContextMenuTrigger.DOWN + """ + How the menu for the tertiary button is invoked. + """ + + on_request: Optional[EventHandler[ContextMenuEvent]] = None + """ + Fires when a menu is about to be shown. + """ + + on_open: Optional[EventHandler[ContextMenuEvent]] = None + """ + Fires immediately after the menu is shown. + """ + + on_select: Optional[EventHandler[ContextMenuEvent]] = None + """ + Fires when a `PopupMenuItem` is selected. + """ + + on_dismiss: Optional[EventHandler[ContextMenuEvent]] = None + """ + Fires when the menu is dismissed without a selection. + """ + + def before_update(self): + super().before_update() + if not self.content.visible: + raise ValueError("content must be visible") diff --git a/sdk/python/packages/flet/src/flet/controls/page.py b/sdk/python/packages/flet/src/flet/controls/page.py index 6c358a1974..c369a6e979 100644 --- a/sdk/python/packages/flet/src/flet/controls/page.py +++ b/sdk/python/packages/flet/src/flet/controls/page.py @@ -448,24 +448,17 @@ def __post_init__( self.__query: QueryString = QueryString(self) self.__authorization: Optional[Authorization] = None - def get_control(self, id: int) -> Optional[BaseControl]: + def get_control(self, id: str) -> Optional[BaseControl]: """ Get a control by its `id`. Example: - - ```python - import flet as ft - - - def main(page: ft.Page): - x = ft.IconButton(ft.Icons.ADD) - page.add(x) - print(type(page.get_control(x.uid))) - - - ft.run(main) - ``` + ```python + def main(page: ft.Page): + x = ft.IconButton(ft.Icons.ADD) + page.add(x) + print(type(page.get_control(x.uid))) + ``` """ return self.session.index.get(id) From 47db95febcfbbd34cad5751a99bbf5a026650f91 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 30 Oct 2025 16:37:17 +0100 Subject: [PATCH 2/8] Refactor docstrings for improved formatting --- .../packages/flet/src/flet/components/memo.py | 25 ++-- .../flet/src/flet/components/observable.py | 50 ++++---- .../flet/src/flet/controls/base_page.py | 15 +-- .../flet/src/flet/controls/context.py | 65 +++++----- .../packages/flet/src/flet/controls/page.py | 112 +++++++++--------- .../src/flet/controls/scrollable_control.py | 107 +++++------------ 6 files changed, 162 insertions(+), 212 deletions(-) diff --git a/sdk/python/packages/flet/src/flet/components/memo.py b/sdk/python/packages/flet/src/flet/components/memo.py index 0968c09969..2a931938f3 100644 --- a/sdk/python/packages/flet/src/flet/components/memo.py +++ b/sdk/python/packages/flet/src/flet/components/memo.py @@ -6,24 +6,23 @@ def memo(fn): Lets you skip re-rendering a component when its props are unchanged. Example: + ```python + import flet as ft - ```python - import flet as ft + @ft.component + def MyComponent(x, y): + return ft.Text(f"x={x}, y={y}") - @ft.component - def MyComponent(x, y): - return ft.Text(f"x={x}, y={y}") + MemoizedMyComponent = ft.memo(MyComponent) - MemoizedMyComponent = ft.memo(MyComponent) - - flet.run( - lambda page: page.render( - lambda: MemoizedMyComponent(x=1, y=2), - ), - ) - ``` + flet.run( + lambda page: page.render( + lambda: MemoizedMyComponent(x=1, y=2), + ), + ) + ``` """ def memo_wrapper(*args, **kwargs): diff --git a/sdk/python/packages/flet/src/flet/components/observable.py b/sdk/python/packages/flet/src/flet/components/observable.py index 011e39f859..4ed66dd786 100644 --- a/sdk/python/packages/flet/src/flet/components/observable.py +++ b/sdk/python/packages/flet/src/flet/components/observable.py @@ -23,18 +23,17 @@ def observable(cls): the `@dataclass` decorator. Example: + ```python + from dataclasses import dataclass + import flet as ft - ```python - from dataclasses import dataclass - import flet as ft - - @ft.observable - @dataclass - class MyDataClass: - x: int - y: int - ``` + @ft.observable + @dataclass + class MyDataClass: + x: int + y: int + ``` """ if Observable in cls.__mro__: return cls @@ -73,30 +72,29 @@ class Observable: Mixin: notifies when fields change; auto-wraps lists/dicts to be observable. Example: - - ```python - import flet as ft - from dataclasses import dataclass + ```python + import flet as ft + from dataclasses import dataclass - @ft.observable - @dataclass - class MyDataClass: - x: int - y: int + @ft.observable + @dataclass + class MyDataClass: + x: int + y: int - obj = MyDataClass(1, 2) + obj = MyDataClass(1, 2) - def listener(sender, field): - print(f"Changed: {field} in {sender}") + def listener(sender, field): + print(f"Changed: {field} in {sender}") - obj.subscribe(listener) - obj.x = 3 - obj.y = 4 - ``` + obj.subscribe(listener) + obj.x = 3 + obj.y = 4 + ``` """ __version__ = 0 # optional version counter diff --git a/sdk/python/packages/flet/src/flet/controls/base_page.py b/sdk/python/packages/flet/src/flet/controls/base_page.py index 320907fff3..88d0f53d7d 100644 --- a/sdk/python/packages/flet/src/flet/controls/base_page.py +++ b/sdk/python/packages/flet/src/flet/controls/base_page.py @@ -177,15 +177,16 @@ class BasePage(AdaptiveControl): on_resize: Optional[EventHandler["PageResizeEvent"]] = None """ - Called when a user resizes a browser or native OS window containing Flet app, for - example: + Called when a user resizes a browser or native OS window containing Flet app - ```python - def page_resize(e): - print("New page size:", page.window.width, page.window_height) + Example: + ```python + def def main(page: ft.Page): + def handle_page_size(e): + print("New page size:", page.window.width, page.window_height) - page.on_resize = page_resize - ``` + page.on_resize = handle_page_size + ``` """ on_media_change: Optional[EventHandler[PageMediaData]] = None diff --git a/sdk/python/packages/flet/src/flet/controls/context.py b/sdk/python/packages/flet/src/flet/controls/context.py index e31053bc86..a55181a202 100644 --- a/sdk/python/packages/flet/src/flet/controls/context.py +++ b/sdk/python/packages/flet/src/flet/controls/context.py @@ -21,12 +21,11 @@ def page(self) -> "Page": """ Returns the current [`Page`][flet.] associated with the context. - For example: - - ```python - # take page width anywhere in the app - width = ft.context.page.width - ``` + Example: + ```python + # take page width anywhere in the app + width = ft.context.page.width + ``` Returns: The current page. @@ -47,25 +46,24 @@ def enable_auto_update(self): """ Enables auto-update behavior for the current context. - For example: - - ```python - import flet as ft + Example: + ```python + import flet as ft - # disable auto-update globally for the app - ft.context.disable_auto_update() + # disable auto-update globally for the app + ft.context.disable_auto_update() - def main(page: ft.Page): - # enable auto-update just inside main - ft.context.enable_auto_update() + def main(page: ft.Page): + # enable auto-update just inside main + ft.context.enable_auto_update() - page.controls.append(ft.Text("Hello, world!")) - # page.update() - we don't need to call it explicitly + page.controls.append(ft.Text("Hello, world!")) + # page.update() - we don't need to call it explicitly - ft.run(main) - ``` + ft.run(main) + ``` """ _update_behavior_context_var.get()._auto_update_enabled = True @@ -73,27 +71,26 @@ def disable_auto_update(self): """ Disables auto-update behavior for the current context. - For example: - - ```python - import flet as ft + Example: + ```python + import flet as ft - def main(page: ft.Page): - def button_click(): - ft.context.disable_auto_update() - b.content = "Button clicked!" - # update just the button - b.update() + def main(page: ft.Page): + def button_click(): + ft.context.disable_auto_update() + b.content = "Button clicked!" + # update just the button + b.update() - page.controls.append(ft.Text("This won't appear")) - # no page.update() will be called here + page.controls.append(ft.Text("This won't appear")) + # no page.update() will be called here - page.controls.append(b := ft.Button("Action!", on_click=button_click)) - # page.update() - auto-update is enabled by default + page.controls.append(b := ft.Button("Action!", on_click=button_click)) + # page.update() - auto-update is enabled by default - ft.run(main) + ft.run(main) ``` """ _update_behavior_context_var.get()._auto_update_enabled = False diff --git a/sdk/python/packages/flet/src/flet/controls/page.py b/sdk/python/packages/flet/src/flet/controls/page.py index c369a6e979..0c0434b49c 100644 --- a/sdk/python/packages/flet/src/flet/controls/page.py +++ b/sdk/python/packages/flet/src/flet/controls/page.py @@ -602,59 +602,58 @@ async def push_route(self, route: str, **kwargs: Any) -> None: handler. Example: + ```python + import flet as ft + import asyncio - ```python - import flet as ft - import asyncio - - - def main(page: ft.Page): - page.title = "Push Route Example" - - def route_change(e): - page.views.clear() - page.views.append( - ft.View( - route="/", - controls=[ - ft.AppBar(title=ft.Text("Flet app")), - ft.ElevatedButton( - "Visit Store", - on_click=lambda _: asyncio.create_task( - page.push_route("/store") - ), - ), - ], - ) - ) - if page.route == "/store": + + def main(page: ft.Page): + page.title = "Push Route Example" + + def route_change(e): + page.views.clear() page.views.append( ft.View( - route="/store", - can_pop=True, + route="/", controls=[ - ft.AppBar(title=ft.Text("Store")), + ft.AppBar(title=ft.Text("Flet app")), ft.ElevatedButton( - "Go Home", + "Visit Store", on_click=lambda _: asyncio.create_task( - page.push_route("/") + page.push_route("/store") ), ), ], ) ) + if page.route == "/store": + page.views.append( + ft.View( + route="/store", + can_pop=True, + controls=[ + ft.AppBar(title=ft.Text("Store")), + ft.ElevatedButton( + "Go Home", + on_click=lambda _: asyncio.create_task( + page.push_route("/") + ), + ), + ], + ) + ) - async def view_pop(e): - page.views.pop() - top_view = page.views[-1] - await page.push_route(top_view.route) + async def view_pop(e): + page.views.pop() + top_view = page.views[-1] + await page.push_route(top_view.route) - page.on_route_change = route_change - page.on_view_pop = view_pop + page.on_route_change = route_change + page.on_view_pop = view_pop - ft.run(main) - ``` + ft.run(main) + ``` Args: route: New navigation route. @@ -674,18 +673,17 @@ def get_upload_url(self, file_name: str, expires: int) -> str: * `file_name` - a relative to upload storage path. * `expires` - a URL time-to-live in seconds. - For example: - - ```python - upload_url = page.get_upload_url("dir/filename.ext", 60) - ``` + Example: + ```python + upload_url = page.get_upload_url("dir/filename.ext", 60) + ``` - To enable built-in upload storage provide `upload_dir` argument to `flet.app()` - call: + To enable built-in upload storage, provide the `upload_dir ` + argument to `ft.run()` call: - ```python - ft.run(main, upload_dir="uploads") - ``` + ```python + ft.run(main, upload_dir="uploads") + ``` """ return self.session.connection.get_upload_url(file_name, expires) @@ -832,15 +830,15 @@ async def can_launch_url(self, url: str) -> bool: Returns: `True` if it is possible to verify that there is a handler available. - `False` if there is no handler available, - or the application does not have permission to check. For example: - - - On recent versions of Android and iOS, this will always return `False` - unless the application has been configuration to allow querying the - system for launch support. - - In web mode, this will always return `False` except for a few specific - schemes that are always assumed to be supported (such as http(s)), - as web pages are never allowed to query installed applications. + `False` if there is no handler available, or the application does not + have permission to check. For example: + + - On recent versions of Android and iOS, this will always return `False` + unless the application has been configuration to allow querying the + system for launch support. + - In web mode, this will always return `False` except for a few specific + schemes that are always assumed to be supported (such as http(s)), + as web pages are never allowed to query installed applications. """ return await self.url_launcher.can_launch_url(url) diff --git a/sdk/python/packages/flet/src/flet/controls/scrollable_control.py b/sdk/python/packages/flet/src/flet/controls/scrollable_control.py index f65607f7be..f216907eb2 100644 --- a/sdk/python/packages/flet/src/flet/controls/scrollable_control.py +++ b/sdk/python/packages/flet/src/flet/controls/scrollable_control.py @@ -48,25 +48,25 @@ class ScrollableControl(Control): scroll: Optional[ScrollMode] = None """ Enables a vertical scrolling for the Column to prevent its content overflow. - - Defaults to `ScrollMode.None`. """ auto_scroll: bool = False """ - `True` if scrollbar should automatically move its position to the end when children - updated. Must be `False` for `scroll_to()` method to work. + Whether the scrollbar should automatically move its position to the end when + children updated. + + Note: + Must be `False` for [`scroll_to()`][(c).scroll_to] method to work. """ scroll_interval: Number = 10 """ - Throttling in milliseconds for `on_scroll` event. + Throttling in milliseconds for [`on_scroll`][(c).] event. """ on_scroll: Optional[EventHandler[OnScrollEvent]] = None """ Called when scroll position is changed by a user. - class. """ async def scroll_to( @@ -74,77 +74,34 @@ async def scroll_to( offset: Optional[float] = None, delta: Optional[float] = None, scroll_key: Union[ScrollKey, str, int, float, bool, None] = None, - duration: Optional[DurationValue] = None, - curve: Optional[AnimationCurve] = None, + duration: DurationValue = 0, + curve: AnimationCurve = AnimationCurve.EASE, ): """ - Moves scroll position to either absolute `offset`, relative `delta` or jump to - the control with specified `key`. - - `offset` is an absolute value between minimum and maximum extents of a - scrollable control, for example: - - ```python - await products.scroll_to(offset=100, duration=1000) - ``` - - `offset` could be a negative to scroll from the end of a scrollable. For - example, to scroll to the very end: - - ```python - await products.scroll_to(offset=-1, duration=1000) - ``` - - `delta` allows moving scroll relatively to the current position. Use positive - `delta` to scroll forward and negative `delta` to scroll backward. For example, - to move scroll on 50 pixels forward: - - ```python - await products.scroll_to(delta=50) - ``` - - `key` allows moving scroll position to a control with specified `key`. Most of - Flet controls have `key` property which is translated to Flutter as - "global key". `key` must be unique for the entire page/view. For example: - - ```python - import flet as ft - - - def main(page: ft.Page): - cl = ft.Column( - spacing=10, - height=200, - width=200, - scroll=ft.ScrollMode.ALWAYS, - ) - for i in range(0, 50): - cl.controls.append(ft.Text(f"Text line {i}", key=str(i))) - - async def scroll_to_key(e): - await cl.scroll_to(scroll_key="20", duration=1000) - - page.add( - ft.Container(cl, border=ft.border.all(1)), - ft.Button("Scroll to key '20'", on_click=scroll_to_key), - ) - - - ft.run(main) - ``` - - Note: - `scroll_to()` method won't work with `ListView` and `GridView` controls - building their items dynamically. - - `duration` is scrolling animation duration in milliseconds. Defaults to `0` - - no animation. - - `curve` configures animation curve. Property value is - [`AnimationCurve`][flet.] - enum. - - Defaults to `AnimationCurve.EASE`. + Moves the scroll position. + + Args: + offset: Absolute scroll target in pixels. A negative value is interpreted + relative to the end (e.g. `-1` to jump to the very end). + delta: Relative scroll change in pixels. Positive values scroll forward, + negative values scroll backward. + scroll_key: Key of the target control to scroll to. + duration: The scroll animation duration. + curve: The scroll animation curve. + + Notes: + - Exactly one of `offset`, `delta` or `scroll_key` should be provided. + - [`auto_scroll`][(c).] must be `False`. + - This method is ineffective for controls (e.g. + [`ListView`][(c).], [`GridView`][(c).]) that build items dynamically. + + Examples: + ```python + await products.scroll_to(offset=100, duration=1000) + await products.scroll_to(offset=-1, duration=1000) # to the end + await products.scroll_to(delta=50) # forward 50px + await products.scroll_to(scroll_key="item_20", duration=500) + ``` """ await self._invoke_method( From 07147715fdd63ce0c7a8c75d644421423f8646d9 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 31 Oct 2025 00:36:20 +0100 Subject: [PATCH 3/8] Replace `ContextMenuRegion` with `ContextMenu`, refactor examples, tests, and docs --- .../lib/src/controls/context_menu_region.dart | 135 ++++++++++++------ .../lib/src/controls/popup_menu_button.dart | 4 +- .../flet/lib/src/flet_core_extension.dart | 4 +- packages/flet/lib/src/utils/popup_menu.dart | 2 +- .../controls/context_menu/__init__.py | 0 .../examples/controls/context_menu/basic.py | 44 ++++++ .../controls/context_menu/manual_open.py | 38 +++++ .../controls/context_menu_region/basic.py | 54 ------- .../flet/docs/controls/contextmenuregion.md | 16 ++- .../flet/docs/types/contextmenutrigger.md | 2 +- ...xt_menu_region.py => test_context_menu.py} | 36 ++++- .../examples/material/test_card.py | 2 +- .../examples/material/test_context_menu.py | 44 ++++++ sdk/python/packages/flet/mkdocs.yml | 4 +- sdk/python/packages/flet/src/flet/__init__.py | 9 +- ...context_menu_region.py => context_menu.py} | 83 +++++++---- .../packages/flet/src/flet/controls/page.py | 4 +- 17 files changed, 334 insertions(+), 147 deletions(-) create mode 100644 sdk/python/examples/controls/context_menu/__init__.py create mode 100644 sdk/python/examples/controls/context_menu/basic.py create mode 100644 sdk/python/examples/controls/context_menu/manual_open.py delete mode 100644 sdk/python/examples/controls/context_menu_region/basic.py rename sdk/python/packages/flet/integration_tests/controls/material/{test_context_menu_region.py => test_context_menu.py} (68%) create mode 100644 sdk/python/packages/flet/integration_tests/examples/material/test_context_menu.py rename sdk/python/packages/flet/src/flet/controls/material/{context_menu_region.py => context_menu.py} (65%) diff --git a/packages/flet/lib/src/controls/context_menu_region.dart b/packages/flet/lib/src/controls/context_menu_region.dart index 19b5f5f7d1..72aaab3356 100644 --- a/packages/flet/lib/src/controls/context_menu_region.dart +++ b/packages/flet/lib/src/controls/context_menu_region.dart @@ -7,35 +7,47 @@ import 'package:flutter/material.dart'; import '../extensions/control.dart'; import '../models/control.dart'; -import '../utils/keys.dart'; import '../utils/popup_menu.dart'; +import '../utils/transforms.dart'; import '../widgets/error.dart'; import 'base_controls.dart'; -class ContextMenuRegionControl extends StatefulWidget { +class ContextMenuControl extends StatefulWidget { final Control control; - const ContextMenuRegionControl({super.key, required this.control}); + const ContextMenuControl({super.key, required this.control}); @override - State createState() => - _ContextMenuRegionControlState(); + State createState() => _ContextMenuControlState(); } -class _ContextMenuRegionControlState extends State { +class _ContextMenuControlState extends State { ContextMenuTrigger _primaryTrigger = ContextMenuTrigger.disabled; ContextMenuTrigger _secondaryTrigger = ContextMenuTrigger.down; ContextMenuTrigger _tertiaryTrigger = ContextMenuTrigger.down; Future? _pendingMenu; + @override + void initState() { + super.initState(); + // Allow backend code to invoke methods on this control instance. + widget.control.addInvokeMethodListener(_invokeMethod); + } + + @override + void dispose() { + widget.control.removeInvokeMethodListener(_invokeMethod); + super.dispose(); + } + @override Widget build(BuildContext context) { - debugPrint("ContextMenuRegion build: ${widget.control.id}"); + debugPrint("ContextMenu build: ${widget.control.id}"); var content = widget.control.buildWidget("content"); if (content == null) { - return const ErrorControl("ContextMenuRegion.content must be visible"); + return const ErrorControl("ContextMenu.content must be visible"); } _primaryTrigger = parseContextMenuTrigger( @@ -104,7 +116,7 @@ class _ContextMenuRegionControlState extends State { ); } - ContextMenuTrigger _getTriggerFromButton(_MouseButton button) { + ContextMenuTrigger? _getTriggerFromButton(_MouseButton button) { switch (button) { case _MouseButton.primary: return _primaryTrigger; @@ -112,6 +124,8 @@ class _ContextMenuRegionControlState extends State { return _secondaryTrigger; case _MouseButton.tertiary: return _tertiaryTrigger; + default: + return null; } } @@ -128,37 +142,30 @@ class _ContextMenuRegionControlState extends State { return null; } + /// Picks popup menu items configured for the provided button, falling back + /// to the shared `items` collection when a button-specific list is empty. List _getPopupItemsFromButton(_MouseButton button) { - List items; switch (button) { case _MouseButton.primary: - items = widget.control.children("primary_items"); - break; + return widget.control.children("primary_items"); case _MouseButton.secondary: - items = widget.control.children("secondary_items"); - break; + return widget.control.children("secondary_items"); case _MouseButton.tertiary: - items = widget.control.children("tertiary_items"); - break; + return widget.control.children("tertiary_items"); + default: + return widget.control.children("items"); } - - return items.isEmpty ? items = widget.control.children("items") : items; } + /// Serialises menu event data to a compact payload sent to Python handlers. Map _eventPayload( _MouseButton button, Offset globalPosition, Offset? localPosition, - {int? itemId, - String? itemControlId, - int? itemIndex, - Object? itemKey, - int? itemCount}) { + {int? itemId, int? itemIndex, int? itemCount}) { return { "b": button.name, - "tr": _getTriggerFromButton(button).name, - "iid": itemId, - "cid": itemControlId, + "tr": _getTriggerFromButton(button)?.name, + "id": itemId, "idx": itemIndex, - "key": itemKey, "ic": itemCount, "g": {"x": globalPosition.dx, "y": globalPosition.dy}, "l": localPosition != null @@ -167,21 +174,23 @@ class _ContextMenuRegionControlState extends State { }; } + /// Opens the context menu for a specific button at the requested position. Future _showMenu(_MouseButton button, {required Offset globalPosition, Offset? localPosition}) async { + // If a menu is already open, close it and wait for it to finish. if (_pendingMenu != null) { Navigator.of(context).pop(); await _pendingMenu; if (!mounted) return; } - final overlayState = Overlay.of(context); + // Get the overlay state and its render box for positioning the menu. + final overlayState = Overlay.of(context, rootOverlay: true); final overlayRenderBox = overlayState.context.findRenderObject() as RenderBox?; - if (overlayRenderBox == null || !overlayRenderBox.hasSize) { - return; - } + if (overlayRenderBox == null || !overlayRenderBox.hasSize) return; + // Calculate the position for the popup menu relative to the overlay. final overlayOffset = overlayRenderBox.globalToLocal(globalPosition); final position = RelativeRect.fromLTRB( overlayOffset.dx, @@ -190,24 +199,24 @@ class _ContextMenuRegionControlState extends State { overlayRenderBox.size.height - overlayOffset.dy, ); - final basePayload = _eventPayload(button, globalPosition, localPosition); + // Build popup menu entries. + final popupItems = _getPopupItemsFromButton(button).toList(growable: false); + final entries = buildPopupMenuEntries(popupItems, context); - widget.control.triggerEvent("request", basePayload); + // Prepare event payload for menu events. + final basePayload = _eventPayload(button, globalPosition, localPosition, + itemCount: entries.length); - final popupItems = _getPopupItemsFromButton(button) - .where((c) => c.type == "PopupMenuItem") - .toList(growable: false); - final entries = buildPopupMenuEntries(context, popupItems); - basePayload.addAll({"ic": entries.length}); - - // If there are no menu items, trigger the dismiss event and return. + // If there are no menu entries, send dismiss event. if (entries.isEmpty) { widget.control.triggerEvent("dismiss", basePayload); return; } + // Notify that the menu is opening. widget.control.triggerEvent("open", basePayload); + // Show the popup menu and wait for user selection. final menuFuture = showMenu( context: context, position: position, @@ -217,23 +226,19 @@ class _ContextMenuRegionControlState extends State { final selection = await menuFuture; if (!mounted) return; - _pendingMenu = null; + // Handle the user's selection or dismissal. if (selection != null) { final selectedControl = popupItems .firstWhereOrNull((item) => item.id.toString() == selection); - final itemId = parseInt(selection); - final controlKey = selectedControl?.getKey("key"); widget.control.triggerEvent( "select", _eventPayload( button, globalPosition, localPosition, - itemId: itemId, - itemControlId: selection, - itemKey: controlKey?.value, + itemId: parseInt(selection), itemCount: popupItems.length, itemIndex: selectedControl != null ? popupItems.indexOf(selectedControl) @@ -250,9 +255,45 @@ class _ContextMenuRegionControlState extends State { )); } } + + /// Handles invoke-method calls originating from Python (programmatic open). + Future _invokeMethod(String name, dynamic args) async { + switch (name) { + case "open": + // Get the render box for positioning the context menu. + final renderBox = context.findRenderObject() as RenderBox?; + if (renderBox == null || !renderBox.hasSize) { + throw StateError( + "ContextMenu render box is not ready to display a menu."); + } + + var globalPosition = parseOffset(args["global_position"]); + var localPosition = parseOffset(args["local_position"]); + + // If only local position is provided, obtain global position from it. + if (globalPosition == null && localPosition != null) { + globalPosition = renderBox.localToGlobal(localPosition); + } + // If only global position is provided, obtain local position from it. + else if (globalPosition != null && localPosition == null) { + localPosition = renderBox.globalToLocal(globalPosition); + } + + // Default to center of the render box if positions are missing. + localPosition ??= renderBox.size.center(Offset.zero); + globalPosition ??= renderBox.localToGlobal(localPosition); + + // Show the context menu at the calculated position. + _showMenu(_MouseButton.none, + globalPosition: globalPosition, localPosition: localPosition); + return null; + default: + throw ArgumentError("Unsupported method: $name"); + } + } } -enum _MouseButton { primary, secondary, tertiary } +enum _MouseButton { primary, secondary, tertiary, none } enum ContextMenuTrigger { disabled, down, longPress } diff --git a/packages/flet/lib/src/controls/popup_menu_button.dart b/packages/flet/lib/src/controls/popup_menu_button.dart index 5d4214108d..6b0c21e32a 100644 --- a/packages/flet/lib/src/controls/popup_menu_button.dart +++ b/packages/flet/lib/src/controls/popup_menu_button.dart @@ -1,4 +1,3 @@ -import '../utils/text.dart'; import 'package:flutter/material.dart'; import '../extensions/control.dart'; @@ -10,7 +9,6 @@ import '../utils/buttons.dart'; import '../utils/colors.dart'; import '../utils/edge_insets.dart'; import '../utils/misc.dart'; -import '../utils/mouse.dart'; import '../utils/numbers.dart'; import '../utils/popup_menu.dart'; import 'base_controls.dart'; @@ -53,7 +51,7 @@ class PopupMenuButtonControl extends StatelessWidget { onCanceled: () => control.triggerEvent("cancel"), onOpened: () => control.triggerEvent("open"), itemBuilder: (BuildContext context) => - buildPopupMenuEntries(context, control.children("items")), + buildPopupMenuEntries(control.children("items"), context), child: content); return LayoutControl( diff --git a/packages/flet/lib/src/flet_core_extension.dart b/packages/flet/lib/src/flet_core_extension.dart index c16b67e308..32beea710f 100644 --- a/packages/flet/lib/src/flet_core_extension.dart +++ b/packages/flet/lib/src/flet_core_extension.dart @@ -173,8 +173,8 @@ class FletCoreExtension extends FletExtension { return ColumnControl(key: key, control: control); case "Container": return ContainerControl(key: key, control: control); - case "ContextMenuRegion": - return ContextMenuRegionControl(key: key, control: control); + case "ContextMenu": + return ContextMenuControl(key: key, control: control); case "CupertinoActionSheet": return CupertinoActionSheetControl(key: key, control: control); case "CupertinoActionSheetAction": diff --git a/packages/flet/lib/src/utils/popup_menu.dart b/packages/flet/lib/src/utils/popup_menu.dart index 200fcbd3c2..94425f4da8 100644 --- a/packages/flet/lib/src/utils/popup_menu.dart +++ b/packages/flet/lib/src/utils/popup_menu.dart @@ -12,7 +12,7 @@ import 'text.dart'; /// Only controls with type `PopupMenuItem` are converted. Controls without any /// visible content are treated as menu dividers. List> buildPopupMenuEntries( - BuildContext context, Iterable items) { + Iterable items, BuildContext context) { return items.where((item) => item.type == "PopupMenuItem").map((item) { var checked = item.getBool("checked"); var height = item.getDouble("height", 48.0)!; diff --git a/sdk/python/examples/controls/context_menu/__init__.py b/sdk/python/examples/controls/context_menu/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sdk/python/examples/controls/context_menu/basic.py b/sdk/python/examples/controls/context_menu/basic.py new file mode 100644 index 0000000000..53a585641a --- /dev/null +++ b/sdk/python/examples/controls/context_menu/basic.py @@ -0,0 +1,44 @@ +import flet as ft + + +def main(page: ft.Page): + def handle_item_click(e: ft.Event[ft.PopupMenuItem]): + action = e.control.content + page.show_dialog( + ft.SnackBar( + content=f"Item {action!r} selected.", + ) + ) + + page.add( + ft.Text("Right-click or long-press the card to open a menu."), + ft.ContextMenu( + primary_items=[ + ft.PopupMenuItem(content="Rename", on_click=handle_item_click), + ft.PopupMenuItem(content="Share", on_click=handle_item_click), + ], + primary_trigger=ft.ContextMenuTrigger.LONG_PRESS, + secondary_items=[ + ft.PopupMenuItem(content="Copy", on_click=handle_item_click), + ft.PopupMenuItem(content="Delete", on_click=handle_item_click), + ], + tertiary_items=[ + ft.PopupMenuItem(content="New tab", on_click=handle_item_click), + ], + on_select=lambda e: print(e), + # on_dismiss=lambda e: page.add(ft.Text(f"{e}")), + # on_open=lambda e: page.add(ft.Text(f"{e}")), + expand=True, + content=ft.Container( + expand=True, + bgcolor=ft.Colors.BLUE, + alignment=ft.Alignment.CENTER, + border_radius=ft.BorderRadius.all(12), + content=ft.Text("Context menu area"), + ), + ), + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/controls/context_menu/manual_open.py b/sdk/python/examples/controls/context_menu/manual_open.py new file mode 100644 index 0000000000..58f69783f1 --- /dev/null +++ b/sdk/python/examples/controls/context_menu/manual_open.py @@ -0,0 +1,38 @@ +import flet as ft + + +def main(page: ft.Page): + page.horizontal_alignment = ft.CrossAxisAlignment.CENTER + page.vertical_alignment = ft.MainAxisAlignment.CENTER + + def handle_select(e: ft.ContextMenuSelectEvent): + action = e.item.content + page.show_dialog(ft.SnackBar(content=f"Item '{action}' selected.")) + + async def open_menu(e: ft.Event[ft.Button]): + await context_menu.open() + + page.add( + context_menu := ft.ContextMenu( + on_select=handle_select, + content=ft.Button("Open Menu", on_click=open_menu), + items=[ + ft.PopupMenuItem( + content="Cut", + on_click=lambda e: print(f"Action '{e.control.content}' clicked!"), + ), + ft.PopupMenuItem( + content="Copy", + on_click=lambda e: print(f"Action '{e.control.content}' clicked!"), + ), + ft.PopupMenuItem( + content="Paste", + on_click=lambda e: print(f"Action '{e.control.content}' clicked!"), + ), + ], + ), + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/controls/context_menu_region/basic.py b/sdk/python/examples/controls/context_menu_region/basic.py deleted file mode 100644 index e7336d6578..0000000000 --- a/sdk/python/examples/controls/context_menu_region/basic.py +++ /dev/null @@ -1,54 +0,0 @@ -import flet as ft - - -def main(page: ft.Page): - def handle_select(e: ft.ContextMenuEvent): - item_label = e.item_control_id or e.item_key or e.item_index - page.show_dialog( - ft.SnackBar( - content=f"{e.button} button selected item {item_label}.", - duration=ft.Duration(seconds=4), - ) - ) - page.add(ft.Text(f"{e}")) - print(e.item) - - page.add( - ft.Column( - spacing=20, - horizontal_alignment=ft.CrossAxisAlignment.CENTER, - controls=[ - ft.Text("Right-click the card to open the menu."), - ft.ContextMenuRegion( - primary_items=[ - ft.PopupMenuItem(content="Rename"), - ft.PopupMenuItem(content="Share"), - ], - primary_trigger=ft.ContextMenuTrigger.LONG_PRESS, - secondary_items=[ - ft.PopupMenuItem(content="Copy"), - ft.PopupMenuItem(content="Delete"), - ], - tertiary_items=[ - ft.PopupMenuItem(content="Open in new tab"), - ], - on_select=handle_select, - on_dismiss=lambda e: page.add(ft.Text(f"{e}")), - on_request=lambda e: page.add(ft.Text(f"{e}")), - on_open=lambda e: page.add(ft.Text(f"{e}")), - content=ft.Container( - width=220, - height=120, - bgcolor=ft.Colors.BLUE, - alignment=ft.Alignment.CENTER, - border_radius=ft.BorderRadius.all(12), - content=ft.Text("Context menu area"), - ), - ), - ], - ) - ) - - -if __name__ == "__main__": - ft.run(main) diff --git a/sdk/python/packages/flet/docs/controls/contextmenuregion.md b/sdk/python/packages/flet/docs/controls/contextmenuregion.md index aae7ef1922..7b4fe3af86 100644 --- a/sdk/python/packages/flet/docs/controls/contextmenuregion.md +++ b/sdk/python/packages/flet/docs/controls/contextmenuregion.md @@ -1,14 +1,28 @@ --- -class_name: flet.ContextMenuRegion +class_name: flet.ContextMenu examples: ../../examples/controls/context_menu_region --- {{ class_summary(class_name) }} +> Tip: On the web, call `await page.browser_context_menu.disable()` to suppress the +> default browser menu before relying on custom menus. Handlers receive a +> [`ContextMenuEvent`][flet.ContextMenuEvent] with the pointer location, trigger +> button, and selection metadata. + ## Examples ```python --8<-- "{{ examples }}/basic.py" ``` +## Programmatic open + +```python +await region.open( + button=ft.ContextMenuButton.SECONDARY, + local_position=(120, 40), +) +``` + {{ class_members(class_name) }} diff --git a/sdk/python/packages/flet/docs/types/contextmenutrigger.md b/sdk/python/packages/flet/docs/types/contextmenutrigger.md index aae7ef1922..1d4cf0440f 100644 --- a/sdk/python/packages/flet/docs/types/contextmenutrigger.md +++ b/sdk/python/packages/flet/docs/types/contextmenutrigger.md @@ -1,5 +1,5 @@ --- -class_name: flet.ContextMenuRegion +class_name: flet.ContextMenu examples: ../../examples/controls/context_menu_region --- diff --git a/sdk/python/packages/flet/integration_tests/controls/material/test_context_menu_region.py b/sdk/python/packages/flet/integration_tests/controls/material/test_context_menu.py similarity index 68% rename from sdk/python/packages/flet/integration_tests/controls/material/test_context_menu_region.py rename to sdk/python/packages/flet/integration_tests/controls/material/test_context_menu.py index 14fa0ea5fd..3bb94752f3 100644 --- a/sdk/python/packages/flet/integration_tests/controls/material/test_context_menu_region.py +++ b/sdk/python/packages/flet/integration_tests/controls/material/test_context_menu.py @@ -12,7 +12,7 @@ def handle_select(e: ft.ContextMenuEvent): status.value = e.item_key e.page.update() - region = ft.ContextMenuRegion( + region = ft.ContextMenu( primary_trigger=ft.ContextMenuTrigger.DOWN, primary_items=[ ft.PopupMenuItem(content="Rename", key="rename"), @@ -49,7 +49,7 @@ def handle_dismiss(e: ft.ContextMenuEvent): status.value = e.reason e.page.update() - region = ft.ContextMenuRegion( + region = ft.ContextMenu( primary_trigger=ft.ContextMenuTrigger.DOWN, primary_items=[ft.PopupMenuItem(content="Rename", key="rename")], on_dismiss=handle_dismiss, @@ -73,3 +73,35 @@ def handle_dismiss(e: ft.ContextMenuEvent): await flet_app.tester.pump_and_settle() assert status.value == "cancelled" + + +@pytest.mark.asyncio(loop_scope="function") +async def test_programmatic_open(flet_app: ftt.FletTestApp): + status = ft.Text("idle", key="status") + + def handle_select(e: ft.ContextMenuEvent): + status.value = e.item_key + e.page.update() + + region = ft.ContextMenu( + primary_items=[ft.PopupMenuItem(content="Program", key="program")], + on_select=handle_select, + content=ft.Container( + width=160, + height=100, + bgcolor=ft.Colors.SURFACE_VARIANT, + alignment=ft.Alignment.CENTER, + content=ft.Text("Programmatic menu"), + ), + ) + + flet_app.page.add(status, region) + await flet_app.tester.pump_and_settle() + + await region.open(button=ft.ContextMenuButton.PRIMARY) + await flet_app.tester.pump_and_settle() + + await flet_app.tester.tap(await flet_app.tester.find_by_text("Program")) + await flet_app.tester.pump_and_settle() + + assert status.value == "program" diff --git a/sdk/python/packages/flet/integration_tests/examples/material/test_card.py b/sdk/python/packages/flet/integration_tests/examples/material/test_card.py index 9dfda7bf06..b38a7279ca 100644 --- a/sdk/python/packages/flet/integration_tests/examples/material/test_card.py +++ b/sdk/python/packages/flet/integration_tests/examples/material/test_card.py @@ -32,7 +32,7 @@ async def test_image_for_docs(flet_app_function: ftt.FletTestApp, request): indirect=True, ) @pytest.mark.asyncio(loop_scope="function") -async def test_basic(flet_app_function: ftt.FletTestApp): +async def test_music_info(flet_app_function: ftt.FletTestApp): flet_app_function.assert_screenshot( "music_info", await flet_app_function.take_page_controls_screenshot(), diff --git a/sdk/python/packages/flet/integration_tests/examples/material/test_context_menu.py b/sdk/python/packages/flet/integration_tests/examples/material/test_context_menu.py new file mode 100644 index 0000000000..2566cdabb7 --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/examples/material/test_context_menu.py @@ -0,0 +1,44 @@ +import pytest + +import flet as ft +import flet.testing as ftt +from examples.controls.context_menu import manual_open + + +@pytest.mark.asyncio(loop_scope="function") +async def test_image_for_docs(flet_app_function: ftt.FletTestApp, request): + flet_app_function.page.add( + menu := ft.ContextMenu( + items=[ + ft.PopupMenuItem(content="Rename"), + ft.PopupMenuItem(content="Duplicate"), + ], + content=ft.IconButton(icon=ft.Icons.MENU), + ) + ) + + await menu.open() + await flet_app_function.tester.pump_and_settle() + + await flet_app_function.assert_control_screenshot( + request.node.name, + await flet_app_function.take_page_controls_screenshot(), + ) + + +@pytest.mark.parametrize( + "flet_app_function", + [{"flet_app_main": manual_open.main}], + indirect=True, +) +@pytest.mark.asyncio(loop_scope="function") +async def test_manual_open(flet_app_function: ftt.FletTestApp, request): + await flet_app_function.tester.tap( + await flet_app_function.tester.find_by_text("Open Menu") + ) + await flet_app_function.tester.pump_and_settle() + + flet_app_function.assert_screenshot( + "manual_open", + await flet_app_function.take_page_controls_screenshot(), + ) diff --git a/sdk/python/packages/flet/mkdocs.yml b/sdk/python/packages/flet/mkdocs.yml index 2116797ad6..9e23dc9892 100644 --- a/sdk/python/packages/flet/mkdocs.yml +++ b/sdk/python/packages/flet/mkdocs.yml @@ -306,7 +306,7 @@ nav: - CircleAvatar: controls/circleavatar.md - Column: controls/column.md - Container: controls/container.md - - ContextMenuRegion: controls/contextmenuregion.md + - ContextMenu: controls/contextmenu.md - CupertinoActionSheet: - controls/cupertinoactionsheet/index.md - CupertinoActionSheetAction: controls/cupertinoactionsheetaction.md @@ -762,7 +762,7 @@ nav: - CardVariant: types/cardvariant.md - ClipBehavior: types/clipbehavior.md - Colors: types/colors.md - - ContextMenuTrigger: types/contextmenutrigger.md + - ContextMenuTrigger:w types/contextmenutrigger.md - CrossAxisAlignment: types/crossaxisalignment.md - CupertinoButtonSize: types/cupertinobuttonsize.md - CupertinoColors: types/cupertinocolors.md diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index 47f3ba73d0..db842b7f93 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -265,9 +265,10 @@ from flet.controls.material.chip import Chip from flet.controls.material.circle_avatar import CircleAvatar from flet.controls.material.container import Container -from flet.controls.material.context_menu_region import ( +from flet.controls.material.context_menu import ( + ContextMenu, ContextMenuEvent, - ContextMenuRegion, + ContextMenuSelectEvent, ContextMenuTrigger, ) from flet.controls.material.datatable import ( @@ -612,8 +613,10 @@ "ConstrainedControl", "Container", "Context", + "ContextMenu", "ContextMenuEvent", - "ContextMenuRegion", + "ContextMenuSelectEvent", + "ContextMenuSelectEvent", "ContextMenuTrigger", "ContinuousRectangleBorder", "Control", diff --git a/sdk/python/packages/flet/src/flet/controls/material/context_menu_region.py b/sdk/python/packages/flet/src/flet/controls/material/context_menu.py similarity index 65% rename from sdk/python/packages/flet/src/flet/controls/material/context_menu_region.py rename to sdk/python/packages/flet/src/flet/controls/material/context_menu.py index 944311a304..6054112708 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/context_menu_region.py +++ b/sdk/python/packages/flet/src/flet/controls/material/context_menu.py @@ -7,12 +7,17 @@ from flet.controls.control_event import Event, EventHandler from flet.controls.layout_control import LayoutControl from flet.controls.material.popup_menu_button import PopupMenuItem -from flet.controls.transform import Offset +from flet.controls.transform import Offset, OffsetValue -__all__ = ["ContextMenuEvent", "ContextMenuRegion", "ContextMenuTrigger"] +__all__ = [ + "ContextMenu", + "ContextMenuEvent", + "ContextMenuSelectEvent", + "ContextMenuTrigger", +] -class ContextMenuTrigger(Enum): +class ContextMenuTrigger(str, Enum): """Defines how a menu is shown for a specific mouse button.""" DISABLED = "disabled" @@ -21,41 +26,43 @@ class ContextMenuTrigger(Enum): @dataclass(kw_only=True) -class ContextMenuEvent(Event["ContextMenuRegion"]): +class ContextMenuEvent(Event["ContextMenu"]): + """Event fired when a context menu is shown or dismissed.""" + button: str = field(metadata={"data_field": "b"}) """Mouse button that triggered the menu.""" - trigger: str = field(metadata={"data_field": "tr"}) - """Trigger mode that opened the menu.""" - global_position: Offset = field(metadata={"data_field": "g"}) """Global pointer position in logical pixels.""" local_position: Optional[Offset] = field(default=None, metadata={"data_field": "l"}) """Local pointer position relative to the wrapped control.""" - item_id: Optional[int] = field(default=None, metadata={"data_field": "iid"}) - """Internal numeric identifier of the selected menu item.""" + trigger: Optional[str] = field(default=None, metadata={"data_field": "tr"}) + """Trigger mode that opened the menu.""" - item_control_id: Optional[str] = field(default=None, metadata={"data_field": "cid"}) - """Control identifier of the selected menu entry.""" + item_count: Optional[int] = field(default=None, metadata={"data_field": "ic"}) + """Total number of entries displayed in the context menu.""" - item_index: Optional[int] = field(default=None, metadata={"data_field": "idx"}) - """Index of the selected menu entry within the rendered list.""" - item_key: Optional[str] = field(default=None, metadata={"data_field": "key"}) - """Key associated with the selected menu entry, if provided.""" +@dataclass(kw_only=True) +class ContextMenuSelectEvent(ContextMenuEvent): + """Event fired when a context menu item is selected.""" + + item_id: Optional[int] = field(default=None, metadata={"data_field": "id"}) + """Internal numeric identifier of the selected menu item.""" - item_count: Optional[int] = field(default=None, metadata={"data_field": "ic"}) - """Total number of entries displayed in the context menu.""" + item_index: Optional[int] = field(default=None, metadata={"data_field": "idx"}) + """Index of the selected menu entry within the rendered list.""" @property def item(self) -> Optional[PopupMenuItem]: - return self.page.get_control(self.item_control_id) + """The selected menu item.""" + return self.page.get_control(self.item_id) -@control("ContextMenuRegion") -class ContextMenuRegion(LayoutControl): +@control("ContextMenu") +class ContextMenu(LayoutControl): """ Wraps its [`content`][(c).] and displays contextual menus for specific mouse events. @@ -109,25 +116,45 @@ class ContextMenuRegion(LayoutControl): How the menu for the tertiary button is invoked. """ - on_request: Optional[EventHandler[ContextMenuEvent]] = None - """ - Fires when a menu is about to be shown. - """ - on_open: Optional[EventHandler[ContextMenuEvent]] = None """ Fires immediately after the menu is shown. """ - on_select: Optional[EventHandler[ContextMenuEvent]] = None + on_select: Optional[EventHandler[ContextMenuSelectEvent]] = None """ Fires when a `PopupMenuItem` is selected. """ on_dismiss: Optional[EventHandler[ContextMenuEvent]] = None """ - Fires when the menu is dismissed without a selection. - """ + Fires when the menu is dismissed without a selection, or when an attempt is made + to open the menu but no items are available. + """ + + async def open( + self, + global_position: Optional[OffsetValue] = None, + local_position: Optional[OffsetValue] = None, + ) -> None: + """ + Opens the context menu programmatically, and displays [`items`][(c).]. + + Args: + global_position: A global coordinate describing where the menu + should appear. If omitted, `local_position` or the center of the + wrapped control is used. + local_position: A local coordinate relative to the wrapped control. + When provided without `global_position`, the coordinate is translated + to global space automatically. + """ + await self._invoke_method( + "open", + { + "global_position": global_position, + "local_position": local_position, + }, + ) def before_update(self): super().before_update() diff --git a/sdk/python/packages/flet/src/flet/controls/page.py b/sdk/python/packages/flet/src/flet/controls/page.py index 0c0434b49c..5b677905b6 100644 --- a/sdk/python/packages/flet/src/flet/controls/page.py +++ b/sdk/python/packages/flet/src/flet/controls/page.py @@ -448,7 +448,7 @@ def __post_init__( self.__query: QueryString = QueryString(self) self.__authorization: Optional[Authorization] = None - def get_control(self, id: str) -> Optional[BaseControl]: + def get_control(self, id: int) -> Optional[BaseControl]: """ Get a control by its `id`. @@ -457,7 +457,7 @@ def get_control(self, id: str) -> Optional[BaseControl]: def main(page: ft.Page): x = ft.IconButton(ft.Icons.ADD) page.add(x) - print(type(page.get_control(x.uid))) + print(type(page.get_control(x._i))) ``` """ return self.session.index.get(id) From ae0ec2748d151acdbdaca941d5589be28bc7d4c6 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 31 Oct 2025 16:03:44 +0100 Subject: [PATCH 4/8] Refactor examples and tests; update docs and add screenshots --- ...ext_menu_region.dart => context_menu.dart} | 31 +++-- .../flet/lib/src/flet_core_extension.dart | 2 +- .../examples/controls/context_menu/basic.py | 44 ------- .../controls/context_menu/custom_trigger.py | 42 +++++++ .../{manual_open.py => programmatic_open.py} | 20 ++-- .../controls/context_menu/triggers.py | 46 +++++++ .../flet/docs/controls/contextmenu.md | 31 +++++ .../flet/docs/controls/contextmenuregion.md | 28 ----- .../flet/docs/types/contextmenuevent.md | 1 + .../flet/docs/types/contextmenuselectevent.md | 1 + .../flet/docs/types/contextmenutrigger.md | 15 +-- .../context_menu/programmatic_open_1.png | Bin 0 -> 9205 bytes .../context_menu/programmatic_open_2.png | Bin 0 -> 9205 bytes .../controls/material/test_alert_dialog.py | 2 +- .../controls/material/test_context_menu.py | 113 ++++-------------- .../macos/context_menu/image_for_docs.png | Bin 0 -> 9053 bytes .../macos/context_menu/programmatic_open.png | Bin 0 -> 14717 bytes .../examples/material/test_card.py | 2 +- .../examples/material/test_context_menu.py | 35 ++++-- .../examples/material/test_expansion_tile.py | 2 +- sdk/python/packages/flet/mkdocs.yml | 2 + .../flet/controls/material/context_menu.py | 84 +++++++++---- .../controls/material/popup_menu_button.py | 4 + 23 files changed, 266 insertions(+), 239 deletions(-) rename packages/flet/lib/src/controls/{context_menu_region.dart => context_menu.dart} (92%) delete mode 100644 sdk/python/examples/controls/context_menu/basic.py create mode 100644 sdk/python/examples/controls/context_menu/custom_trigger.py rename sdk/python/examples/controls/context_menu/{manual_open.py => programmatic_open.py} (50%) create mode 100644 sdk/python/examples/controls/context_menu/triggers.py create mode 100644 sdk/python/packages/flet/docs/controls/contextmenu.md delete mode 100644 sdk/python/packages/flet/docs/controls/contextmenuregion.md create mode 100644 sdk/python/packages/flet/docs/types/contextmenuevent.md create mode 100644 sdk/python/packages/flet/docs/types/contextmenuselectevent.md create mode 100644 sdk/python/packages/flet/integration_tests/controls/material/golden/macos/context_menu/programmatic_open_1.png create mode 100644 sdk/python/packages/flet/integration_tests/controls/material/golden/macos/context_menu/programmatic_open_2.png create mode 100644 sdk/python/packages/flet/integration_tests/examples/material/golden/macos/context_menu/image_for_docs.png create mode 100644 sdk/python/packages/flet/integration_tests/examples/material/golden/macos/context_menu/programmatic_open.png diff --git a/packages/flet/lib/src/controls/context_menu_region.dart b/packages/flet/lib/src/controls/context_menu.dart similarity index 92% rename from packages/flet/lib/src/controls/context_menu_region.dart rename to packages/flet/lib/src/controls/context_menu.dart index 72aaab3356..f2201317e6 100644 --- a/packages/flet/lib/src/controls/context_menu_region.dart +++ b/packages/flet/lib/src/controls/context_menu.dart @@ -76,6 +76,13 @@ class _ContextMenuControlState extends State { details.localPosition, ) : null, + onTertiaryLongPressStart: _tertiaryTrigger == ContextMenuTrigger.longPress + ? (LongPressStartDetails details) => _handleLongPress( + _MouseButton.tertiary, + details.globalPosition, + details.localPosition, + ) + : null, child: Listener( behavior: HitTestBehavior.translucent, onPointerDown: _handlePointerDown, @@ -98,7 +105,7 @@ class _ContextMenuControlState extends State { if (trigger != ContextMenuTrigger.down) return; _showMenu( - button, + button: button, globalPosition: event.position, localPosition: event.localPosition, ); @@ -110,13 +117,13 @@ class _ContextMenuControlState extends State { if (trigger != ContextMenuTrigger.longPress) return; _showMenu( - button, + button: button, globalPosition: globalPosition, localPosition: localPosition, ); } - ContextMenuTrigger? _getTriggerFromButton(_MouseButton button) { + ContextMenuTrigger? _getTriggerFromButton(_MouseButton? button) { switch (button) { case _MouseButton.primary: return _primaryTrigger; @@ -144,7 +151,7 @@ class _ContextMenuControlState extends State { /// Picks popup menu items configured for the provided button, falling back /// to the shared `items` collection when a button-specific list is empty. - List _getPopupItemsFromButton(_MouseButton button) { + List _getPopupItemsFromButton(_MouseButton? button) { switch (button) { case _MouseButton.primary: return widget.control.children("primary_items"); @@ -159,10 +166,10 @@ class _ContextMenuControlState extends State { /// Serialises menu event data to a compact payload sent to Python handlers. Map _eventPayload( - _MouseButton button, Offset globalPosition, Offset? localPosition, + _MouseButton? button, Offset globalPosition, Offset? localPosition, {int? itemId, int? itemIndex, int? itemCount}) { return { - "b": button.name, + "b": button?.name, "tr": _getTriggerFromButton(button)?.name, "id": itemId, "idx": itemIndex, @@ -175,8 +182,10 @@ class _ContextMenuControlState extends State { } /// Opens the context menu for a specific button at the requested position. - Future _showMenu(_MouseButton button, - {required Offset globalPosition, Offset? localPosition}) async { + Future _showMenu( + {required Offset globalPosition, + Offset? localPosition, + _MouseButton? button}) async { // If a menu is already open, close it and wait for it to finish. if (_pendingMenu != null) { Navigator.of(context).pop(); @@ -256,7 +265,6 @@ class _ContextMenuControlState extends State { } } - /// Handles invoke-method calls originating from Python (programmatic open). Future _invokeMethod(String name, dynamic args) async { switch (name) { case "open": @@ -284,8 +292,7 @@ class _ContextMenuControlState extends State { globalPosition ??= renderBox.localToGlobal(localPosition); // Show the context menu at the calculated position. - _showMenu(_MouseButton.none, - globalPosition: globalPosition, localPosition: localPosition); + _showMenu(globalPosition: globalPosition, localPosition: localPosition); return null; default: throw ArgumentError("Unsupported method: $name"); @@ -293,7 +300,7 @@ class _ContextMenuControlState extends State { } } -enum _MouseButton { primary, secondary, tertiary, none } +enum _MouseButton { primary, secondary, tertiary } enum ContextMenuTrigger { disabled, down, longPress } diff --git a/packages/flet/lib/src/flet_core_extension.dart b/packages/flet/lib/src/flet_core_extension.dart index 32beea710f..fe04f49fb8 100644 --- a/packages/flet/lib/src/flet_core_extension.dart +++ b/packages/flet/lib/src/flet_core_extension.dart @@ -20,7 +20,7 @@ import 'controls/chip.dart'; import 'controls/circle_avatar.dart'; import 'controls/column.dart'; import 'controls/container.dart'; -import 'controls/context_menu_region.dart'; +import 'controls/context_menu.dart'; import 'controls/cupertino_action_sheet.dart'; import 'controls/cupertino_action_sheet_action.dart'; import 'controls/cupertino_activity_indicator.dart'; diff --git a/sdk/python/examples/controls/context_menu/basic.py b/sdk/python/examples/controls/context_menu/basic.py deleted file mode 100644 index 53a585641a..0000000000 --- a/sdk/python/examples/controls/context_menu/basic.py +++ /dev/null @@ -1,44 +0,0 @@ -import flet as ft - - -def main(page: ft.Page): - def handle_item_click(e: ft.Event[ft.PopupMenuItem]): - action = e.control.content - page.show_dialog( - ft.SnackBar( - content=f"Item {action!r} selected.", - ) - ) - - page.add( - ft.Text("Right-click or long-press the card to open a menu."), - ft.ContextMenu( - primary_items=[ - ft.PopupMenuItem(content="Rename", on_click=handle_item_click), - ft.PopupMenuItem(content="Share", on_click=handle_item_click), - ], - primary_trigger=ft.ContextMenuTrigger.LONG_PRESS, - secondary_items=[ - ft.PopupMenuItem(content="Copy", on_click=handle_item_click), - ft.PopupMenuItem(content="Delete", on_click=handle_item_click), - ], - tertiary_items=[ - ft.PopupMenuItem(content="New tab", on_click=handle_item_click), - ], - on_select=lambda e: print(e), - # on_dismiss=lambda e: page.add(ft.Text(f"{e}")), - # on_open=lambda e: page.add(ft.Text(f"{e}")), - expand=True, - content=ft.Container( - expand=True, - bgcolor=ft.Colors.BLUE, - alignment=ft.Alignment.CENTER, - border_radius=ft.BorderRadius.all(12), - content=ft.Text("Context menu area"), - ), - ), - ) - - -if __name__ == "__main__": - ft.run(main) diff --git a/sdk/python/examples/controls/context_menu/custom_trigger.py b/sdk/python/examples/controls/context_menu/custom_trigger.py new file mode 100644 index 0000000000..e433df487b --- /dev/null +++ b/sdk/python/examples/controls/context_menu/custom_trigger.py @@ -0,0 +1,42 @@ +import flet as ft + + +def main(page: ft.Page): + async def open_menu(e: ft.TapEvent[ft.GestureDetector]): + await menu.open( + local_position=e.local_position, + global_position=e.global_position, + ) + + page.add( + menu := ft.ContextMenu( + expand=True, + items=[ + ft.PopupMenuItem( + content="Cut", + on_click=lambda e: print(f"{e.control.content}"), + ), + ft.PopupMenuItem( + content="Copy", + on_click=lambda e: print(f"{e.control.content}"), + ), + ft.PopupMenuItem( + content="Paste", + on_click=lambda e: print(f"{e.control.content}"), + ), + ], + content=ft.GestureDetector( + expand=True, + on_double_tap_down=open_menu, + content=ft.Container( + content=ft.Text("Double-click to open the context menu."), + bgcolor=ft.Colors.BLUE, + alignment=ft.Alignment.CENTER, + ), + ), + ), + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/controls/context_menu/manual_open.py b/sdk/python/examples/controls/context_menu/programmatic_open.py similarity index 50% rename from sdk/python/examples/controls/context_menu/manual_open.py rename to sdk/python/examples/controls/context_menu/programmatic_open.py index 58f69783f1..62e63e266c 100644 --- a/sdk/python/examples/controls/context_menu/manual_open.py +++ b/sdk/python/examples/controls/context_menu/programmatic_open.py @@ -7,27 +7,27 @@ def main(page: ft.Page): def handle_select(e: ft.ContextMenuSelectEvent): action = e.item.content - page.show_dialog(ft.SnackBar(content=f"Item '{action}' selected.")) + page.show_dialog(ft.SnackBar(f"Item '{action}' selected.")) async def open_menu(e: ft.Event[ft.Button]): - await context_menu.open() + await menu.open() page.add( - context_menu := ft.ContextMenu( + menu := ft.ContextMenu( on_select=handle_select, - content=ft.Button("Open Menu", on_click=open_menu), + content=ft.Button("Click to open menu", on_click=open_menu), items=[ ft.PopupMenuItem( - content="Cut", - on_click=lambda e: print(f"Action '{e.control.content}' clicked!"), + content="Item 1", + on_click=lambda e: print(f"{e.control.content}"), ), ft.PopupMenuItem( - content="Copy", - on_click=lambda e: print(f"Action '{e.control.content}' clicked!"), + content="Item 2", + on_click=lambda e: print(f"{e.control.content}"), ), ft.PopupMenuItem( - content="Paste", - on_click=lambda e: print(f"Action '{e.control.content}' clicked!"), + content="Item 3", + on_click=lambda e: print(f"{e.control.content}"), ), ], ), diff --git a/sdk/python/examples/controls/context_menu/triggers.py b/sdk/python/examples/controls/context_menu/triggers.py new file mode 100644 index 0000000000..5a271cef05 --- /dev/null +++ b/sdk/python/examples/controls/context_menu/triggers.py @@ -0,0 +1,46 @@ +import flet as ft + + +async def main(page: ft.Page): + # on web, disable default browser context menu + if page.web: + await page.browser_context_menu.disable() + + def handle_item_click(e: ft.Event[ft.PopupMenuItem]): + action = e.control.content + page.show_dialog(ft.SnackBar(content=f"Item '{action}' selected.")) + + page.add( + ft.ContextMenu( + primary_items=[ + ft.PopupMenuItem(content="Primary 1", on_click=handle_item_click), + ft.PopupMenuItem(content="Primary 2", on_click=handle_item_click), + ], + primary_trigger=ft.ContextMenuTrigger.DOWN, + secondary_items=[ + ft.PopupMenuItem(content="Secondary 1", on_click=handle_item_click), + ft.PopupMenuItem(content="Secondary 2", on_click=handle_item_click), + ], + secondary_trigger=ft.ContextMenuTrigger.DOWN, + tertiary_items=[ + ft.PopupMenuItem(content="Tertiary 1", on_click=handle_item_click), + ft.PopupMenuItem(content="Tertiary 2", on_click=handle_item_click), + ], + tertiary_trigger=ft.ContextMenuTrigger.DOWN, + on_open=lambda e: print("Menu opened"), + on_select=lambda e: print(f"Selected item: {e.item.content}"), + on_dismiss=lambda e: print("Menu dismissed"), + expand=True, + content=ft.Container( + expand=True, + bgcolor=ft.Colors.BLUE, + alignment=ft.Alignment.CENTER, + border_radius=ft.BorderRadius.all(12), + content=ft.Text("Left/middle/right click to open a context menu."), + ), + ), + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/packages/flet/docs/controls/contextmenu.md b/sdk/python/packages/flet/docs/controls/contextmenu.md new file mode 100644 index 0000000000..12d1f867e8 --- /dev/null +++ b/sdk/python/packages/flet/docs/controls/contextmenu.md @@ -0,0 +1,31 @@ +--- +class_name: flet.ContextMenu +examples: ../../examples/controls/context_menu +example_images: ../test-images/examples/cupertino/golden/macos/context_menu +--- + +{{ class_summary(class_name, example_images + "/image_for_docs.png", image_caption="Basic ContextMenu") }} + +## Examples + +### Triggers + +```python +--8<-- "{{ examples }}/triggers.py" +``` + +## Programmatic open + +```python +--8<-- "{{ examples }}/programmatic_open.py" +``` + +{{ image(example_images + "/programmatic_open.png", width="80%") }} + +## Programmatic open with custom trigger + +```python +--8<-- "{{ examples }}/custom_trigger.py" +``` + +{{ class_members(class_name) }} diff --git a/sdk/python/packages/flet/docs/controls/contextmenuregion.md b/sdk/python/packages/flet/docs/controls/contextmenuregion.md deleted file mode 100644 index 7b4fe3af86..0000000000 --- a/sdk/python/packages/flet/docs/controls/contextmenuregion.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -class_name: flet.ContextMenu -examples: ../../examples/controls/context_menu_region ---- - -{{ class_summary(class_name) }} - -> Tip: On the web, call `await page.browser_context_menu.disable()` to suppress the -> default browser menu before relying on custom menus. Handlers receive a -> [`ContextMenuEvent`][flet.ContextMenuEvent] with the pointer location, trigger -> button, and selection metadata. - -## Examples - -```python ---8<-- "{{ examples }}/basic.py" -``` - -## Programmatic open - -```python -await region.open( - button=ft.ContextMenuButton.SECONDARY, - local_position=(120, 40), -) -``` - -{{ class_members(class_name) }} diff --git a/sdk/python/packages/flet/docs/types/contextmenuevent.md b/sdk/python/packages/flet/docs/types/contextmenuevent.md new file mode 100644 index 0000000000..a9c7125b5d --- /dev/null +++ b/sdk/python/packages/flet/docs/types/contextmenuevent.md @@ -0,0 +1 @@ +{{ class_all_options("flet.ContextMenuEvent") }} diff --git a/sdk/python/packages/flet/docs/types/contextmenuselectevent.md b/sdk/python/packages/flet/docs/types/contextmenuselectevent.md new file mode 100644 index 0000000000..9530295882 --- /dev/null +++ b/sdk/python/packages/flet/docs/types/contextmenuselectevent.md @@ -0,0 +1 @@ +{{ class_all_options("flet.ContextMenuSelectEvent") }} diff --git a/sdk/python/packages/flet/docs/types/contextmenutrigger.md b/sdk/python/packages/flet/docs/types/contextmenutrigger.md index 1d4cf0440f..1b23c4ccd8 100644 --- a/sdk/python/packages/flet/docs/types/contextmenutrigger.md +++ b/sdk/python/packages/flet/docs/types/contextmenutrigger.md @@ -1,14 +1 @@ ---- -class_name: flet.ContextMenu -examples: ../../examples/controls/context_menu_region ---- - -{{ class_summary(class_name) }} - -## Examples - -```python ---8<-- "{{ examples }}/basic.py" -``` - -{{ class_members(class_name) }} +{{ class_all_options("flet.ContextMenuTrigger", separate_signature=False) }} diff --git a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/context_menu/programmatic_open_1.png b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/context_menu/programmatic_open_1.png new file mode 100644 index 0000000000000000000000000000000000000000..3e32905bff573ae38db59b92bac06a7d06b81fab GIT binary patch literal 9205 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 X06G3v*A3hq1DIX2xQf2wne;yZSbIXz literal 0 HcmV?d00001 diff --git a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/context_menu/programmatic_open_2.png b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/context_menu/programmatic_open_2.png new file mode 100644 index 0000000000000000000000000000000000000000..3e32905bff573ae38db59b92bac06a7d06b81fab GIT binary patch literal 9205 zcmd^_c{E#X-|x37S}oe5RWnnkNMRBIk;)~dOvSrAcfb+;;1i`Eo#YD(Ig zL!>&XF=>O8BGeQ@lmrzdXM4|ipMTDJpL5pv>v^A(b!BB`XRn=WU;Fy~exL96x*l0s zm8%46_@E8NE93V%}^B=^|a>X-Fv*Sr9InL(*T@}gOWrB74FqUJVD zvQE>2XPUZ$4WRn4nW=%hKAUat=!$B(YM}u%Jxc&6wmnoH3wCbu_t6LdI4na205L-% z7jPl5m>ci}F7pBJfFwa6k!!ys0B|4B0RY}3|D8`vw5sO2FRP%axSsFL$zHtuv1R6G zTEzQ?AFRRPa=_ny1GRmns*4 zRYo-pFl_YyK4a}c$pnv5hn3C@83DjMvOhR(!07O~s8((uQN{QlgKCIsdEGaG+h)E` z>$TN?BKAyq2i>tVt5WfwJ@^rQj||W47)%hFgzAi*;Wo(-ub9BaHIfn&2YN##I2@&B zt_HltQRc=^4e7}h$qx_M0s;a7ex?}Pg~W&{EyTVDiAkL@xA}BTa|N*T&^0~F4-W-N zEumhbf~iBA5RXpJUw^NZsfq`YRreLXoYvxRMN4lOCK^R0ZVaAFDk>~29NcEOTB7Fz z0=h+Dw59rIu@oRLrU>=$voQ}h7qkdvCI4|)hls%TrFc6xeK~dN)X?|u^5n>~6SopK z=Ar!i_wlD(xhm;r;&w;+RAi8fe50D^e3rwW6^Zu3>H%z=$@!Ht}5^@ zr5QOYKsV_eB1}a^h0Jkb(aPKjaFQPa>ZM-2py_Hk_(0^ogo!mdn`S%6T zt2B|zoL^-b%+HHMCOCK6cx=&pccj~R#56NX>~!LpDJPP%DiSy1-bcyV*Pbwlh2EtX;vw`pb03r(0t|1={)MjM5_8SX>J-b8f|4h_*JJChe*=I1X?ak&lqYH0Kf3^@4s zG|$o6G~K2PYr43)Ft+zfq=n$q?OSTPKvjfabQ|vGL7B!rK1m=E{cjMS2P$F#2Mu-g zS8v`}XNmBqJb(K1>Eh{%!cnXa6s@@GyC3LES7Jtmk=>%JU%xU;xyFOtMf|YUFK9TH zj%97a;SM}JC>Go}nlnSU6FLOwXfzRjJfcC_%QC8?d|QeW&Js9f{lJbRCB@F_pL)$=E`;A8Y^Q6wZ-$%@J-#px!FzOj+1$cpHI? zcByfyUs*-*9Tt1_NLa2qB%y=PEJl) z;TV8lohv>192=sQICfV=PV^JN>&hK%S(&r3mOzND$k_o5n*`EtP(^MWiRo=b+ zsL~Su71=SFbSQE`NO=|291L4|1y&P3kNC`&bHlhsWuMrIGRE$UX04TW#+`la#XwB_ z6l?(N8`XDw=NCqZe9~*+7b-j~EMh#8+*KYO;8fKxQE)JS6a4``Ght?GNd$grj)l!jD#e4C`97@ zKEgpDGkz(|GJIB4l)Y>!TlzE9OQ_94_L#MoSJ{=4-}njP?G61UR*+=pK;tl%4D3xx z(Of`)fqvzBKw#iAgX4f9>&_wl{`DT3sM>CCsYbgN` zwU08@b14BQp!kH{9oruO;Fs=3_y1XzVQ^gw|GJon#|4s=aUAyc!Q7hK+CkPjtubLY z7T3Ivi#jC-$mkzsiuCmMGeR(~jW$C=tGN+M3JNwB76^#j#mL7!y}e#JRj*zVHArZO z?!j{kFLkaRI-z`T{wX)`O`vVe){j_x*_kmX4#&p0zTxNRj~Mmm!A6ZXW|jB#-B5CG zxSH$Af>muf{}LXM!j!)MBmF>Xqc1^zdt35YlqqCri%fY=mW93{;=CQhNsB(D-=Aw zC@#3_aax*JR1IIjZ+wob*Jr=4q%=FbThD)W3~2k7CT#y)iJ-U6|0&j6BB}b|QbMm2 zEqwTgcnB@@tWMXiU!#=cg@c=XfZI!3mw9NF)YZ4pA~^zM28%BJ`U-4|i;H$HaaQ^+ zu;9Yr0NSER&^_JKUmw9N?=f=U01Gqqd6wDP*>&JZ z@FZW}Yx}sg5sVbh9G8St{xyGDMK#-=KE0)|*4EaZ{{H1mkd<`!_E6~G5Gw!03E5D7 zi2HSQNy+8(^z^K9EBW##PoCHbovUj>*GTF{`7!4DM;#k2QHQ0`Qy(m?gM)*sQ+a{H zW0ynZqQ@gq?$C8nvv18EkqD{0ZL8|k=f@XmwDmMT_DvQ1&f7npnvQLz3PG-gOBd9z zcehb@;{l@6n_gUDOVK=K!&P(YqMGjju_feq;FxCGI+J7>JxR4WbL)m_o zvujpUFwF7Zq1BJpaRLQ`*_)ISg$qXFgo8Z7fxq&J01u zuXUTma{%JdQKsN*O-`1hkuCmIp&J`-z zESH~(j&;lr?p9XC@8AEiTvt12u7aOYsRtP(Xyom+GLsC8MP&bIBmD2J%hO1<|6Q9E zY2Z9aAm_n!=I92^9&EQCKgJ|k=no+&qYt?G)PqJf*b!P1!)oS5;8^6yE|jJz4}TAN zG9(#2p=`ESJ?d_~xiXKG#=GD@{a&43Cyu4=QsL3xWZmLduc`Jvzj5x-R>5}CA?3je zNXtj$1a=))&Wi)*ee^iYv5%q0=&R3);sBnKCatK}$dJ0UmqnY@c*va6fbL5Bi$pwY zRM8^_Sxj+MsOMigUgYo^K^Q=Yti1527dBHiLgvA_F2Y}h#p=*&IF3SYPIa}lwf)AraJ)vdL58;)pHnMh6cM%t^J7Z^LyfTMYX42uz&4K4sPl2dE0) zXN%@~6cu)UJT<-gY5KENR3XR(V+_AprT+qZ9Lv=DFWGw6D&h#6=R9T|>{O4)=P8 z2YjcfxUOts>g>S$ubUwV=s-YW^z2WVrFoIlF|`m<<;_r3e!7_I6-(v#>y933L&)a6 zsQKPZAG1k;L+Ej!6dw|G+_B2_h-#JSGVA^g6sz6IjnfU) zC*YS`gWr4m3>1`vQqB*9Btu(3A?feFRy>@CIyX@LLG^0%{)M>>6xu2k|BGupp=((u z=9gz`#!)~=v59Ewx6%RT^Xe?wWeUfsI+a9EU?A~d%NSDYsBPv#S?Kg<|96QGSih&? zenZvvY-AT$&T2r8$crS@iXg-Q9ib$IAOqBftY%Q3GQk2e*cL4fpAaBhk|W6;s68N( zUD%rEm_s_2yLx(hu6M_96|0^82hL=HKE}>gcAR1(Y&*(qPyehj7{X`|FlYC>T%t|G z<7>fa=^?DtoHlw$Eq`Vaz)%eg2xthQbhK=P#$z*hO_9UA8h5Ej1SBu2``~uBHv>U? zV)RXT>YC*jM=c2lqyg-h!RM)+-4!@_uLh7dckVje=sR2Gh;h%m2z^^g(Hci?u69WW zf|{y}`2rwj|1-bhH(@YW$)%Df12VUg6TIA~M)dMz3^?mVRDrxs~m)uafmOX z(n9GgpLG0@`mqt=f#F)G`t00X)VxQGd-%M87{kb=Q<4r)Gqf2Xy|)`5C5OFin|8#*pJ>ixPVXcH`uLWsha=k=;>S1k;-?iPYnlj=sV zgLKSN=wLxvvovi-?E{YNIR{euaRGCE`De`t2HQ{Yjrw6T@;6^xxSYkzCM+9xv<27s z^mlY2rNE;5r$xc5j;$@0t&;OT*N=_(#&!i|pL@&{ zPd^euKx#J5ES8T$fLkUYY&!T|Y3;l5U_Z;l>K zT0ZLQwG)9$nx7XDx*@r;b*01+!noNbmS_Z_zA3_SBJu9Q3 z+YEdP!H(Y7TCxn;rZsuRTuL1r9Hcgq-G>4(IPnt<&~Pd5Rc_m9p-Tf;aj@!7K(~W9 zQD9tLDaizt?sNwW-iXJ$g6>WJrl#jR!*%+&ZZI1(Y(lKzS(h|v)g7fJ3D)JIh5<2LKBhXdb1zZ)!-f!6!fht>t2bKDcJO5nZ(Ra zBRdiGBRQk zT@-^A(~oWFSWSQ9+_oWQS&zI<2!-G4^m=S8%;ClQsaJy$_X~w*4Pl}t<++L(e^Ado&yAycVzO%- zsQGwC|#*s3_KFbZF1I0g=gX>V3+x-db5Sy_K^>nP?2m z4c=a@tFEegdrCM_5)gaU}XoBQF6mvKZx+FEq6pkM5qlU{)pv< zz_VsAU%qs1_j{z=%G|bLJh<~;a}=+TR4o{4oB{@g!`7&CX?6go$Z+BV?)9lu>BZ${ zj)X7+G>#YIDBJG*CIUbqp9}Szy1z|)?WLg6^5_YuuFu5ZN6#>4-lOi!fjD*9Jm)+j zZHpTpV|B&^5r8@>jqEz1cQl-<0K{gH|4H8}2>#2XkdGgCo~Zg#A@KSe`mw)yoq$Qx zBh3G2%+kHHuk#~(wvONw64+kXl}(k{{LtOg^KBP`ENfd~ndL(wKqcV@-xY9vJsApe zkkq;tVCXnII1F{K=6iY%Tts2$wW4dlhX^)MKkWJ4gfS-0)GWxYurxh=5u6C zoGs`|Dypg3c6wTY?SY{oVmf{b4+T<9h+Gk3NrDa1|DQNZ5ig5%5ogRE8<$l{@e|KB zwr@DQrvX_tz5CtzLu3Wk*Q=y>iDxwi$-?2ql978Sml?_wW3gB}As%WLX!MC3*#EZ< z(E@8nl$0w5AC;+W*5VZ4b{({C)y1=?k37eV91yu|7iG&*jH zcGPg~g%EbeG%SxDzK?%@o*<(4%a=?V7R=A~(5+w%c9}JrX0~Rd?^!Gy$hxHn%2t== zV36QJDtx%_fgwYAGqGTfe3x`0@vf>Qvyyu%55LbMye~g$HznzG>q)d8;o0>5=SLqa zEMlvqE)o~Og-xEM%ieeI%AYx7RN`s+9~15MxkWCyxyFMs+BbM{vo}l64W}aR3xUg< zy!*nEFSnj{Jgs6Bkih*Ii`FB1Z%b$iB4klpk;f0Gi12&Miv_Sw>xdmtM>ZYzOSpLn zWAyz&`Gu3rI&znSZwm z=I72i{o9>ZU{5+T_jP(Q(3?57OS#YEWMxC>%7ub_(hM-H$sW_JW-b4QB1I-K^4{IVo6@{mnsiKJs&@_Zk; z%t(td*>eO^{(C9E#uTZ^@CcdiK3?3l-u!F5!JBifVG`W}TKp|&S50|B_$R68$={6y zQlnwUW+cZYdK9==id-nxggvBhsYbhzGh#`n9@iMYTaRuOj+p$dZ9#KeN&SPp#Yv;I z^U7u~6f0gHbV0;NyJ5ECxS$Uz$Cvj@Y9-uEN9b$xL-=q*IohgxS-tVZ_10IK2jz(h zKW~~e3rEPDypxP>X-Rv*yeK-+8I-5Kp9-;*Rhp>YyM&Q;41#nU2-)3vWzpilm@3>gak^a!> X06G3v*A3hq1DIX2xQf2wne;yZSbIXz literal 0 HcmV?d00001 diff --git a/sdk/python/packages/flet/integration_tests/controls/material/test_alert_dialog.py b/sdk/python/packages/flet/integration_tests/controls/material/test_alert_dialog.py index 119dbdefa6..6d2efa8650 100644 --- a/sdk/python/packages/flet/integration_tests/controls/material/test_alert_dialog.py +++ b/sdk/python/packages/flet/integration_tests/controls/material/test_alert_dialog.py @@ -30,7 +30,7 @@ async def test_alert_dialog_basic(flet_app: ftt.FletTestApp, request): await flet_app.tester.pump_and_settle() flet_app.assert_screenshot( - "alert_dialog_basic", + request.node.name, await flet_app.page.take_screenshot( pixel_ratio=flet_app.screenshots_pixel_ratio ), diff --git a/sdk/python/packages/flet/integration_tests/controls/material/test_context_menu.py b/sdk/python/packages/flet/integration_tests/controls/material/test_context_menu.py index 3bb94752f3..d4c59d55b4 100644 --- a/sdk/python/packages/flet/integration_tests/controls/material/test_context_menu.py +++ b/sdk/python/packages/flet/integration_tests/controls/material/test_context_menu.py @@ -4,104 +4,35 @@ import flet.testing as ftt -@pytest.mark.asyncio(loop_scope="function") -async def test_primary_select(flet_app: ftt.FletTestApp): - status = ft.Text("idle", key="status") - - def handle_select(e: ft.ContextMenuEvent): - status.value = e.item_key - e.page.update() - - region = ft.ContextMenu( - primary_trigger=ft.ContextMenuTrigger.DOWN, - primary_items=[ - ft.PopupMenuItem(content="Rename", key="rename"), - ft.PopupMenuItem(content="Duplicate", key="duplicate"), - ], - on_select=handle_select, - content=ft.Container( - key="context-region", - width=160, - height=100, - bgcolor=ft.Colors.SURFACE_VARIANT, - alignment=ft.Alignment.CENTER, - content=ft.Text("Primary menu"), - ), +@pytest.mark.asyncio(loop_scope="module") +async def test_programmatic_open(flet_app: ftt.FletTestApp, request): + flet_app.page.enable_screenshots = True + await flet_app.resize_page(250, 250) + flet_app.page.add( + menu := ft.ContextMenu( + content=ft.IconButton(ft.Icons.MENU), + items=[ + ft.PopupMenuItem("Item 1"), + ft.PopupMenuItem("Item 2"), + ft.PopupMenuItem("Item 3"), + ], + ) ) - - flet_app.page.add(status, region) await flet_app.tester.pump_and_settle() - await flet_app.tester.tap(await flet_app.tester.find_by_key("context-region")) + await menu.open() await flet_app.tester.pump_and_settle() - - await flet_app.tester.tap(await flet_app.tester.find_by_text("Rename")) - await flet_app.tester.pump_and_settle() - - assert status.value == "rename" - - -@pytest.mark.asyncio(loop_scope="function") -async def test_dismiss_event(flet_app: ftt.FletTestApp): - status = ft.Text("idle", key="status") - - def handle_dismiss(e: ft.ContextMenuEvent): - status.value = e.reason - e.page.update() - - region = ft.ContextMenu( - primary_trigger=ft.ContextMenuTrigger.DOWN, - primary_items=[ft.PopupMenuItem(content="Rename", key="rename")], - on_dismiss=handle_dismiss, - content=ft.Container( - key="context-region", - width=160, - height=100, - bgcolor=ft.Colors.SURFACE_VARIANT, - alignment=ft.Alignment.CENTER, - content=ft.Text("Dismiss menu"), + flet_app.assert_screenshot( + "programmatic_open_1", + await flet_app.page.take_screenshot( + pixel_ratio=flet_app.screenshots_pixel_ratio ), ) - flet_app.page.add(status, region) await flet_app.tester.pump_and_settle() - - await flet_app.tester.tap(await flet_app.tester.find_by_key("context-region")) - await flet_app.tester.pump_and_settle() - - await flet_app.tester.tap(await flet_app.tester.find_by_key("status")) - await flet_app.tester.pump_and_settle() - - assert status.value == "cancelled" - - -@pytest.mark.asyncio(loop_scope="function") -async def test_programmatic_open(flet_app: ftt.FletTestApp): - status = ft.Text("idle", key="status") - - def handle_select(e: ft.ContextMenuEvent): - status.value = e.item_key - e.page.update() - - region = ft.ContextMenu( - primary_items=[ft.PopupMenuItem(content="Program", key="program")], - on_select=handle_select, - content=ft.Container( - width=160, - height=100, - bgcolor=ft.Colors.SURFACE_VARIANT, - alignment=ft.Alignment.CENTER, - content=ft.Text("Programmatic menu"), + flet_app.assert_screenshot( + "programmatic_open_2", + await flet_app.page.take_screenshot( + pixel_ratio=flet_app.screenshots_pixel_ratio ), ) - - flet_app.page.add(status, region) - await flet_app.tester.pump_and_settle() - - await region.open(button=ft.ContextMenuButton.PRIMARY) - await flet_app.tester.pump_and_settle() - - await flet_app.tester.tap(await flet_app.tester.find_by_text("Program")) - await flet_app.tester.pump_and_settle() - - assert status.value == "program" diff --git a/sdk/python/packages/flet/integration_tests/examples/material/golden/macos/context_menu/image_for_docs.png b/sdk/python/packages/flet/integration_tests/examples/material/golden/macos/context_menu/image_for_docs.png new file mode 100644 index 0000000000000000000000000000000000000000..f5d6def08e99f837f183ce3f7bd3c90d02eb9f0f GIT binary patch literal 9053 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 literal 0 HcmV?d00001 diff --git a/sdk/python/packages/flet/integration_tests/examples/material/golden/macos/context_menu/programmatic_open.png b/sdk/python/packages/flet/integration_tests/examples/material/golden/macos/context_menu/programmatic_open.png new file mode 100644 index 0000000000000000000000000000000000000000..1f516099d487b9a9fcb672a85a5db6d589e42aa0 GIT binary patch literal 14717 zcmeHuXIN8P7iL6JFDi%%NLPW&l_Dx2y@((p(o5(dO0NMDq()RgK#0<%SLuY_LQyG^ z-iZ(b0@4BrQbOp==9}_kW`4}fzj>a^b58Q)Oj}d+5*-^I1OmCF z_C#410--TF+0UN^N1AODC&Ak(PhC|dNNFF(GWc-DQ%Oz#JoxiJ|MEQqas#5K{80Z@ z3W?z3uRs5-c76JDa3{@8=To0F3_o477yr#fTWA$5Z&IB;Yk_|)@UI2_s|EbSxFC=W z1PAuhoFv%>qS{d*L_IFLldNQQO7S$5$#BNqsK(_g^!3Ado6foQA1f5LOTsi4AZ&N> zHH0#{;MO)KZ3dz4=>8hQz{abngYyC>UuF~<7is^K<1SfT=ECp$L@|3_GOFv*O&>Wf zsVJWGe($zQ<1@?wn} zeW-zj#T_i{CF@V~QyI)c-2`FFYRmvEr8QiL=0a#MEG2wSNhz~SB6v@R{j@z@;cQop zOI$*N;5GUJ<4W5wF6MzMqj;svkNXx21(PmDII1|!1^5-8XYcjb&-GWxt~NoUw14-w zNpc{LX)pa+@)WT9BseuEpj1@)NyRGDkOQk@Z2Xx+b>o%n#;e9Kt}6pe`Xp;~A}4+K z+KX+vCuh1VuF;3iZ|+Va5AMFD%?9V+z5XGw-GmG?D&{%I8$I_k&Qfzk;hkfI z1Kkf8M_hdTeJ5HmNEEv%c;v8`cqUP3TuU6hSwYlD!%W32?lM&I?Js4HwNFds5Dh{Q z;ce*V{u3+l=TUR{IXU_kXobN?ryvm|AQ@?lMjG#3@N~gS zt86D9*ER9V`l;eeR0;JhZ2)Yxz@@&|xCFNrU%UkQI;dzRehNHDW84M_g;CA;q&ysR@yNEh-&MJ5kgxYbhCA7$urL7zXE%Sr8W~NMGo+v9`IBz~ z=z?9U)zo$JwoAOM6F#uL-PqVaD&@pn;8SbC%nDsn$VKfp8L%Uk#tP8t(P>B2TTL5z zMI$vfh2`ZUI|rtO<5|Dx9K>=jrC-6qVYU1eBuv#x+=HnSIv+{=g}}nLrlQ67aG`?L zQv3>JmmyQ$m5EI)oUw7hY@lVd5+gQe9I?gkbLY_4b7VW*t!AnCAwSpp&mv_m1}GOj zyPp|Eu@#KeBWey4ODK(XbZ^EPm8h#2b`L!Jf6C}(`Ujd;`} z5{GKi&UdqbnuY!Rm3^;H?P*ueYeEdmE0Iz=1nvl{Z@EJ<)NYos)ocI5n~sKYASp*f;`do}TclBlpq+_W@*)uFiS z#G0I0R>Emu$6%tn_~B9LSO^JI6#&hoqoWoDtS}Q{{-)JZ-`~zgJbfeZ&*YkGt){wx z<3=Vb*ONol)RX!o`spp(k_{@eQ+FSYny!a#r6a>n27Jw!>r_GGn_Em|5rZP;+r$BB z4LO2C7ueZ{{a)p>4iz+tC9*};P2-nkY_%unEY9#r>U7;(Yww?$U zM?KAE1nIW!Q(aM;D#y&Ln3{xs)5Bu7M%X(SQ})`mu?Aru$InSQnykyjq2|%&>Gc>F z99LX?Y@r7JK`0#gA2+jR!di+)wX3uoxF&C(>n4ykL$`5d+}p+5+rqlhUgP6=_QawK zgq7x9dFW>Xs%UhxWI8^NJ@25K5a_v4{q(Okt{*kl1Vg#?3FIu7vt{yWQc-1IzH+T* z03k9r_O&HSt4_P1Gc>SO_$M^0h>X0wu;b4Ur2D~5|W>scwK=cdrZ-LEUYTk+{m(?=O!4p(?2vUs@dzUSH%B2iJJ zQ9S63Cwg|mBhjPD*3Ai1w~Su|J}|Pgqn3xS#;{OC?{(n6c+Lw|L_YwtW6bZ~r>A6Uv@lBO-%YI9=1v=9N`? zUHnM`s%L1Pr*fp{Pw9|&vWFM;>M@k``Gdi^2?gpx9v!}#fiN3=|BzfDkv&WNWExoN ziXEMH?wHhqt_(t%+b(gdO(bDdTaXLF0Y|;@XV*C*pl;z4mpC>hwfYMXhZR|e4+)f! zWZ2x(xMRZKEn5#G`E{QRAq`7$FDjXVn+nYN9lWSCFzhqVQq*g^Xvkv#dyV12I&efp zlup-lH>F(pQ)Cbk%eHp)&%RfC{zJ~IQLY;^-KBUrFh=nJ@;UC z(Etp5;NvL*pBl+jwLf2I&$@}r$0i7qdgYJPkFROOK!5gtrhEJXV&s&gkhI1_YYI79 zr)Yfy=h-NI>`8@%9F-*;$fMH`<6C~` zJ859ZE`_;3>7~|_w+$8#TJr0na}-6>JC0{B=AJot`qvJH62d8Pry#K4U75?ux0V*} z>aWX|Zn9bd4OQqosLu1{q$OH|wI;`xJ*!sKC>QagXwgX$$Z8bASmQf+Rm|4H9tr)p z4#uYz&qt8-ec#|M`)6?u|CVd6iO*@Wo8=<=<*QA%PqiHVN&ZAeOr_l_!B)N5k*O>m54CcPh_X+WEAH;_M+mcIF>hw4M{VmyVIR56z25M`o$(v*x!n4juXxwS3)tE1rD|g; zIZ^t7GePF)CVE(p9*F3`yaa*;?fy2@51+lx!OwwfFr4nj>*at2F0S>Leg8ugbxH8T zmVF!Sc{S_tH))$3Y*Vw3hx5THdy~X(j;m~)Vutvw1@gQb@vfgP_bpt)Q@2n4Kg46` zRe>dY(Pp}t%}jA;2;HoV3UjY+C9vmrcm1KPbA+0_SpBs|nlROuifT3OZRYJ`ePclH zohk^41eyPI?^Y%gA;zKf#xH5zxn$5n`n995hxJTNxo+!0oDfbU3+VLg?( zVxYs+C?~yptv_cou$Ya!aiU@GGb5P8XWbY9#4^HK90>^x`s$biSb&W|s@khnnG&Qo zHTIQS()yS+0XF{U(UjeIQg6flvJ`py5SNy{%47DAUi3tc3U@}6jorg)b-H)oYxbvq zX&3!C-fR|(i?PgKNA)7m7;1ejrN>P~=jl);)J@@viz!#4j!ETh)u_3))VOi@fUh3h zIJa3o?Zwy1IXGwlIil6V^sR-*F5cazCSKmUv2)e8kjO< z%8~e&k6UL_zUX&)Lqo2yo>~^}t?Q#`JAIaO+}up8fuH1$teYN3A$7C=md(|!IlGuSz(~O=2;|KnFj{ znIBk{Hh0M9G~4_|ZvW|42iCFy&ySFEXSR)q1@Oe*O0N94_@Hzmom6;9W@}f%^kmOM zz-Sa*z}iW)G2ZZP5)hPWe4)j5B^{TY{FouK!F5BOdnralqODQQwW|}S!O-u)vZ6n) zx-zmQ6<9qg%a<{{@9kZ?D7^pJE%3l#L~?^JTx=J)h{5wz?A3|h4)vH}RrnyCOa@TV zfZbhOEkcr=JQuf8&EGfftUw%=a=h8sa`3{UXXAW&V6~1&%kgInq1H51n)9b6cYnEp zQK?#g|7huZ%HTG00`VKNk-J*=KR?B;X=|`+ZEL-YLWW;WPcH%> zN4ng}FD~@ENs9|~q+lm{r23K5;a;9C)QcNTfXUYUS?~RIWI#?!3r1gIbyN-SO^P(( zSC8(KldCjDIT;V7CPWTUVl3e(K3|V*EOA&oyU&gpu{`kf*c|?gC*)4!KJBF?c;neE z*(vjxskKeVuDT0et-p=Qq*A{n1Nhp{#{}t`!6X27v0}NkPsW-USCmH|2gqST{DyaO z+ZOs^A8h!_={zjy>Nh(`AKp_jr&bOlGtd(AORZhTRV}^dS@r3qhGS~xag#lHA~-jc zZ^~*Q!Lcb^9-?f0wurG(2qnvf?&>O1xIi@W>__*HyphgTJEAwK$4_%Z~#(g(WWEWuR^`e9yDsgM!?*MK~dnJXE!~I4llM4 z5Bh%v-opi~lOB{z)$tG;rcy=40M5=X#g_%t${#qJ>+j^8Iq>zG1QQw}dTQ?8A%}$d zE2{^dt=oiqu+ckB_2ga3_=a!RarovdA;Nn1jP!0>9~tiB7cNE}9`y~FVbMVi=c$K) z)3k^OCZ`n77|t2qij2#g= z0t>BMki&nV8Y{gNDb+XQJbu{VuppP9LP8WqU-`2Fh>Fw1y9;xH)9mY$fo`kc)&Qg) zc1cKEc3lHwFF=nq+Un&*xtKamxUk&DSk~s7d#%&oJ$}BX>ASOV!RzVt9T>OMPX<3n z;?5w^|BT(pXa6WJZ0+a=zdOSjF%2C7DYqrk(N!ZmIwY!(__{>4*RnCc>Y-U-mAYH$ zdO}KOL2Xp&`kB*OjSAP#9a3or#Ef&lJ@!aqwU&&^1_YIauhp_ZYZVk!D{QV9l?UJ~ zNI$Eg^|UG*yN$$&=C}qfoXM%}U3FRR^B4OjY!GA3{4Zw9-l zcJ>~Ga+nStWgtkxx@yKxdSj9bTzoy}y0u3rCs3IOtFY{<;M*O<@SHwAxT?}B-5GEA zc%`at&?_HHsIGkytdhI&N{AEpCAa(q#j$8skA-9WL}d`XMpslUlph_fTkVki6BSI8 zR1pyD7Z)2_ta#%F8Yc)g6jskCKCl=0%@ZKc#r0Resrv?IW|RM%E_#8FS8a@L_$$a( z9^PmFbA@m8fpl`-nGeM*h^jQ6fgTphpcs5+(|05jo+MqRS=3%~Dq}kl>)KX`VBGVU zA4eF?xJwQ`q5*90Mj4w_cg62b-?)^N;wC@p$SjbYem(h5N)}S*LPG;vELBw6M@zFV ziM@=^&3{qkA8N$J4Pm39_i&>DgS~rwq&Xm%F8I46J{}(MI^@mU$)J$sCk!5TEYGiM zS7-U%_?S~+rTy&&_u(lZAKWTSdR_R{ajXX&JEBjTY7zvGG4MJzA)zQIN12b0Pryz2 z!qsGbwu>90N~u5|P;MW87YL4bu2Af$WCEbUug}h=`H{ymR)sEg^$nS~U<3zS9kjnc zH%0E_Bf7ez#}8Q#j~H6Cw#wWu{<=Z^d-ki4p=j=xF9JyK=IEH1*yy31u?`w-2}E0F zT@>eW4ENv9uA}GEMG|-qi51=Awmpg251N%bdwWL--p88i@pr-+nj6v?FNd)f|DEHg z0p>?dc*x`*pqEsaLq@tv37FXQbo9Z2KNAxPFlZv-V0V*Jx!D&PCYVtJz=d^VocaS^ zett0H4)_{j;!v(>RwymxQpafq`3&UIrj56ZxVS-(qT=rE?q8fNQNm{ouf8yjmx-{! z*Q}P6thG!R-!ZpV5Mxmsw#C;xaGMeWT>TCL-H(sUi)5F08n^!G()@Of;&olJ3WT{#-CtV#+Z3hc z`aLIycf5wj&oUD{Q~n+EhVt{bA<)3U=0L<${XJa&XHiwpg`Q8mslLPl-|f$f)~=#X zV?r9pjj*~(dAk(Ym%lYqu1UPM2;AkLisb&QuWrE ztpWRV)v!5VPNcsbEe&P=VGD$*Lj`-|h$fsd;_Z~=#b3};eT_pqs3%lwpW z=MbMslW+A|dJdx%oSe(VRrt>BQ|n;58fu+Iaa?IYHBa4~!J(Eh;lGD$?-L z(lijeiU9q}EBq=w|2u}T!b6&FvBX&-Q!%N)JwA(C=@`t>R`|H>*^)Q3^ z6wkgex*mbta%CGIw*w2RrSI>NXa7}ZG(ww@XIT4GlIy(uLrNg%yH@6DSx ziRe42n7UfiW`Cb*6t3DyqS}$kuTBZYB{$i9`rO42n22&H`m`PE#^8e|XAq3v4Ja__ z`1w!$&~gjIO50O$v8zzcK6=vR=WN3glOh*9B8*Gm^;vs6*HXXwx;mprp_dBQ<=fla zfq1&HYdsw$UFBvucUUCzsO-+U>eG~#hCQ+0I%?T_fm z$yDHKIwmF!L&L-K>vtLH6hq~P%}q=S7kZP~xfwn_Im2wNPrASLA60zApzkG!pn`mS zD&IgLw$jFIARIv?a$KMaevB>t{rlwG+n3M(Slk4Gp`w&|;9XAbGt@-j)~5oPn;rgn zTwI*s?c2b$Z%gg|87rV+yH9grw9te?NbTL&XF7i-Hibji$EUulyIbJ6#Vk|%$^JG| zVa5$hP@agI`>}9ZCx}fdN^jSu`$P(^0jDutj3;j0Y`r4aDBbz~XD+TlkKgsEMNg)VU4YU z!fKoD_&OXz7h6utAxTAT2*j^ySAsN!OpsYX`1tscXEPmyhrM(ynYWbZ9$96M-SX!2 zZJJ7y2nk09$$%n>%s~np1j5RG(hG2|Jcvp6uF)684Bo*<5kvP&a{YY3;u84h2eim= zkQ*M8np!e4qVN5w|K1DqU@`PKcbievw7$N+;V5LK3f#6}Tl#f&@K>c& z<9H^xv%$+}oCn7cdg1$Jg|@-$v+EHAj5qE(in##~T*1K5FkVxNPhG+c`IZ+<#ZvI1 z-BPqV@}@yx{LK5x5J5lfnmrp^0e*h0d1q6XIMgyA*77fRuggY~*5}T#yq<`-SX?-r z>ns$@^&o^vN(41u2Z~eS!lI_m)5BrH@wQBH?J$3a_wuA@eJni42GE ziajc~HY)qNnWCeQOIcsQRw#Nk2FDSZVB=6U#hCAvhV+$aq~ILO$g-m zb5Ond_x1lH3p6V;Kp-EDzj-ztC+u zCN@4kekn8a6i*o>BMh^chN1Lf)}BNMZp2hl{=|k}mCHGsSzP=K8L%YcyHBbGh?!&a zusJ|$D7I5?Bv(D*-3NwC?3nYw)hju%O{+fOJOlxVhQnZOb4K@ri1`gpJKjJ~b7U{VQ@Qz5uax zSjZS!2JQy?nQvz}r%JynI|yuv7d9^p<`#|>c(l497?=xSlr}(GS0o+9#6T4fa{2lC zY`YFjd?D$c@6j%c7sah~j48n~(_uJmprm$^4W15=3%^y4{g1?NG>|rMlM@hV&^Myr z`QIR5w630B+$Tol!WS^|B181hwhP|C#3bv(hi&3`Wu7!*)rjQD%*^Zv5cEA%(+e!U zuw}EE1sW>nN6|;8HD!^Z`uaK5PB_Qm1&uEIgv_3vXCQ*^m03`SY}5(efJ@=(V6|3E-HaE&e?9ILtiPt3MATIb|NN;LxZBf-z6uP+M5c zNQI4h)ApWI(tR6`o^o9fawz{5`RV52Qj;;mO|mb43^=+;SbbF#Hu{svTTqvuuh}2e zf%-}Vxi0Rv?R>c0k1XEazrgGdDvoBoubzJa=)b7RT+N}N1I5jK6&fw(b|hPH|1}xo zraQSWV%(rS*Fh-GOY?2eRM(PqKYr``>*$kKa$K`+p4BbtgEHCu{lC ziVRDJQoW}#3-q{Sa4V7f2L{-OAW@mxrAe9j(b0)5mPnDGjW+IXP7ZW+bq!@a9G{3w z$g7~89f&Q-7Sr8r*VkQ`X*w;jwvWq)5gr?BL2;^b zm7_J;{gW`MNl74EG~Oo4fByWrtEb1S78h8#eb++6A0VR!^4>~TlrV-m6}I8)BcmdA zqzr+4Fsio>_LBmPNj_bRmX`LOrMWb>(XRjQ#{8(f1+e%?p+QmNir?1Kj0KmZmDQyA zhbCW6$?meo#)C3Ty&RwnM5enJjTP@sG?Dv}dzApr<9^I#!%mXN*Y~OQ_2l{4S?f!j z4{|#8K@b3}t#dIVK!!Nb91uxGlP^9TPS3ke9|~V|TN^+g?^{wE@PAL1ZuZGcFKuS5 zTb;cCu~L3MGu7H)>EKYXUY>Z39>3Sx)Pg(5?f=Vynmlb*B8YChpE;-0+HsK?+;J;@ zSqG1jzb{TEIm;ARRCt__-3lQ-ew<5cVRk2sL+;P7bOOAEcR9Qp{oc5fQ}TKcE#&Sk z9_s{)Jk8X}T#hIky)XY+WRoPpJ1t7J(}AHv-A|Y52L6;sT!v`#o7Omo`5k5yKP1nW znASQH8h{F#kdTnq9>L-SBF~=m?CbTAg|KCZZLuV%p>Vfv?9ffkwLv<^?rvspRdGc&ttm()+YI+Pn> z;k#^eiBnq4Q6d8y9>C5j`>HGrN%pZ}=!)7mvP+lKJ*KWnh&hhrOTLi0?40IksEYc~ z0Ct3lxL*Umo) zQjDxsS682_;}ug|y>$ixx%&Zxt$$zt|FJ+%!qO=SwDz;)`bF2KO|ndMPWr)bXF1vV8D)loZ$@SA zL20k7lK?Yf*=*V-;a9>1153J*@Ols_e(VgmpG1lzNjz+_JIV#y)cfwQIq_BOqm!L7 z>DkZOKFAj`CkfEWA|UX+pk5x0AWs%_#&DnLZ|WdosRn$o|9JvWM(lB9bASdCc>lW` zCWvm%!g3*d2(E;N>X1w9UrNm}4*T0KNeam3{mn!fs!MK8j{R)Y)vJ=$Vqz!Y{oF3K z<(OpA2l8L59+x3VnS>SBW``8l#^uKWyYnp3IXH{`gh0PVH=viseq7I*xp|WP#%oG? zlO@!n!y_UHE1QUUhq;c(=p4Y+Y+k->YeIrPF~bszToS&fsH#etm6bGFX{QBW{aHS- zC?q5_LMC8daQR`QI8B=U>l-c5zT3nC3pqa(0y;nci4(4L^h14c-FY_RyhHB)lpg-waoILqPK+cDX<%7{lO1$m9zw{OFJ2>?#- zQPAjE-rQQ}0$}>~CUXp8@cDS7%BHj&1DgbB1+*Gj9j~P1DIAwitcd^&wXxwLJ{SD? zsitP>Hi9oVmrWti|CyQE5I`fmYL7xLjs{RQ!u^g<;^<^uGG#qtZGGgut$?frlgCUs#Jge4SRReG@zzmlTeGyw9|tnR(Xr6Aas5mF{!3e10a4Lmg2jICMC&W@bJv)e ziA~h0QNOh&P=jL7@-b@J8=Y6(+ObaoVd6ETAhg(B1gK?Qi<<=*88;S+)EDoNN%10< z7Nj{iX+opKq&mF5Srx@ao)0>Gwu8|Avj`Vdd9k~@n_*vEEgE+f$?}DlfY^5dS50V_ z)4Zs0!l@vSS9^NlA}lN{PD^Ds#i#Os&;`PmyyYrhCv=0Te$&B2cY(?^b_f)( zNL0HRAc;Vx18P^19V(4Y)b>MKAd!{24A@PIjBtx{fx$ zS7xW~h=^!Ku!xM!wntFlZK^|J*P>i7PR?@o4*v>n1_7qcawig|(X zm+@G5n4Y6i4v-V3Yi)sNG!cjz!0!m86TPV~RVLdtCONrirL@KHMTbT9$-!YQ1@nF$ zG)5;ItiP!ScoF%4#3>*o^r1mRN9U7k6D2+$waDsEbf76LJ3)lmAl5m}s9qqDGFw9g z1O?UeQlfL&OIsdBUps$h>-(mtS7&a^M*)TdF4&kmKskmjJY z7y}(NHZE?y)Q57kzqTBs#q99wbr5l)B)R$c$gjj`-ACUHP8?BVv3T{IMl~`d1k8qF z2bZ|iC*Xox&bRL19i0mI&McUd%A0bnVL4+6?evj&j8BPy3xm*o= zDBXWA?4(X0Y_bK@H%oo6Bi6LnB_!_F7b-Ug0f8*3?nkh++^GK@tAsn|TSMw1PD=xs zI+#;(hO77=zrMYt9C-Ev2@NrcmEK?dwAX@c$<)by+|Jq^#3{(-c2JxDL%i~Ts8%tc zt|155-`=4dGXDXMsHiA>SVO{TZ;KxYSB8s=igUJcXF2%?-$Gu0>4I^9rfGZ)6Mg8( zcJb^9>t=Si8i>@8<3PBdGf%C1nlzy{j8D~cZ-eMX=3e$#Lk1<{HjRN0LFMR)%bvVDCPG|8*! zN^)p!X}|uvfar}Tdat_!b}#@jj?dkws8D7&Z6Dp$-4*puDacQXt)SE<%Y}OL%_=LD zMobOJk6frw;H>t~PFiMsK=U|IkW!XrAR13$u=4LSWkL~OXty<2HE*2;khfOj=UpH9U zSs}mP8;yOlsMZb5G{n0bzMcE~`iR8+Rr-Tc`s(7lQmlyN>0 z;^ouP9#pHXwHM}$U|d#4(Z%7%##}zKOy@BBhk*bFS(WL_c$2m5O`p_|o_Ji5v-)-2 zu$+Ag#CiZU=*~m~EIGgaO2C`Z_ocy+rlJ4nB>+$^DYgfZ;w-s}?w1N640amj#{*4) zd|x5EvHzV8kH`wW$g$bTg_5;E14#>P{ub!~CdqQSsl4HECX zc9(r+xxh~f#x2m!>B8xA2?EoXL3cg2=5q^8)N8)Z!UMU@J^zi}gN;3bBb_X8zTH1^|E)wEIb5mJASfT>-9&Dg44!Zmy*y6%mR*pPJYR2STv5-0wawb%6LrPw%h9^`?=vSGs zblHV6V$VY~j^A=#C>}cpNAfc)zPkb<$rYhJ5jT`9nMPW3-f}oz*3iH!Sn4b1-M?pF zVY}wa1(dq4-g`SQRXkUAg0^oI9&MCPXc+mF2&q@d#mj=i{&pA4xjgi;q=!LwrhaO< z={i%B-ye5+ca{?iKWe{c6vYk&<%>@S>nx4JC@jMJ(iNkxpDK%gmNfGC-)CGYF@tx&10?w1VoV&tQ-g= Optional[PopupMenuItem]: @@ -68,9 +92,9 @@ class ContextMenu(LayoutControl): menus for specific mouse events. Tip: - On the web, call [`disable()`][`flet.BrowserContextMenu.disable`] method of - [`Page.browser_context_menu`][`flet.`] to suppress the default browser menu - before relying on custom menus. + On web, call [`disable()`][`flet.BrowserContextMenu.disable`] method of + [`Page.browser_context_menu`][`flet.`] to suppress the default browser + context menu before relying on custom menus. """ content: Control @@ -83,37 +107,47 @@ class ContextMenu(LayoutControl): items: list[PopupMenuItem] = field(default_factory=list) """ - A default menu definition used when button-specific items are not supplied. + A list of menu items to display in the context menu, + when [`open()`][(c).open] is called. """ primary_items: list[PopupMenuItem] = field(default_factory=list) """ - The menu displayed for primary (usually left) mouse button actions. + A list of menu items to display in the context menu, + for primary (usually left) mouse button actions. """ secondary_items: list[PopupMenuItem] = field(default_factory=list) """ - The menu displayed for secondary (usually right) mouse button actions. + A list of menu items to display in the context menu, + for secondary (usually right) mouse button actions. """ tertiary_items: list[PopupMenuItem] = field(default_factory=list) """ - The menu displayed for tertiary (usually middle) mouse button actions. + A list of menu items to display in the context menu, + for tertiary (usually middle) mouse button actions. """ - primary_trigger: ContextMenuTrigger = ContextMenuTrigger.DISABLED + primary_trigger: Optional[ContextMenuTrigger] = None """ - How the menu for the primary button is invoked. + Defines a trigger mode for the display of [`primary_items`][(c).]. + + If set to `None`, the trigger is disabled. """ - secondary_trigger: ContextMenuTrigger = ContextMenuTrigger.DOWN + secondary_trigger: Optional[ContextMenuTrigger] = ContextMenuTrigger.DOWN """ - How the menu for the secondary button is invoked. + Defines a trigger mode for the display of [`secondary_items`][(c).]. + + If set to `None`, the trigger is disabled. """ - tertiary_trigger: ContextMenuTrigger = ContextMenuTrigger.DOWN + tertiary_trigger: Optional[ContextMenuTrigger] = ContextMenuTrigger.DOWN """ - How the menu for the tertiary button is invoked. + Defines a trigger mode for the display of [`tertiary_items`][(c).]. + + If set to `None`, the trigger is disabled. """ on_open: Optional[EventHandler[ContextMenuEvent]] = None diff --git a/sdk/python/packages/flet/src/flet/controls/material/popup_menu_button.py b/sdk/python/packages/flet/src/flet/controls/material/popup_menu_button.py index 98d44a6b74..428f89d84d 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/popup_menu_button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/popup_menu_button.py @@ -28,6 +28,10 @@ class PopupMenuPosition(Enum): @control("PopupMenuItem") class PopupMenuItem(Control): + """ + A popup menu item. + """ + content: Optional[StrOrControl] = None """ A `Control` representing custom content of this menu item. From d54664e9bc78009905ffb9e360e7467e4ef9972f Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 31 Oct 2025 16:22:59 +0100 Subject: [PATCH 5/8] rename `ContextMenuEvent` to `ContextMenuDismissEvent`, remove `on_open`, and update docs/examples accordingly. --- .../flet/lib/src/controls/context_menu.dart | 3 -- .../controls/context_menu/triggers.py | 1 - .../flet/docs/types/contextmenuevent.md | 2 +- sdk/python/packages/flet/src/flet/__init__.py | 4 +-- .../flet/controls/material/context_menu.py | 30 +++++++++++-------- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/flet/lib/src/controls/context_menu.dart b/packages/flet/lib/src/controls/context_menu.dart index f2201317e6..189fc01353 100644 --- a/packages/flet/lib/src/controls/context_menu.dart +++ b/packages/flet/lib/src/controls/context_menu.dart @@ -222,9 +222,6 @@ class _ContextMenuControlState extends State { return; } - // Notify that the menu is opening. - widget.control.triggerEvent("open", basePayload); - // Show the popup menu and wait for user selection. final menuFuture = showMenu( context: context, diff --git a/sdk/python/examples/controls/context_menu/triggers.py b/sdk/python/examples/controls/context_menu/triggers.py index 5a271cef05..9ad3e619fb 100644 --- a/sdk/python/examples/controls/context_menu/triggers.py +++ b/sdk/python/examples/controls/context_menu/triggers.py @@ -27,7 +27,6 @@ def handle_item_click(e: ft.Event[ft.PopupMenuItem]): ft.PopupMenuItem(content="Tertiary 2", on_click=handle_item_click), ], tertiary_trigger=ft.ContextMenuTrigger.DOWN, - on_open=lambda e: print("Menu opened"), on_select=lambda e: print(f"Selected item: {e.item.content}"), on_dismiss=lambda e: print("Menu dismissed"), expand=True, diff --git a/sdk/python/packages/flet/docs/types/contextmenuevent.md b/sdk/python/packages/flet/docs/types/contextmenuevent.md index a9c7125b5d..52eb242a3d 100644 --- a/sdk/python/packages/flet/docs/types/contextmenuevent.md +++ b/sdk/python/packages/flet/docs/types/contextmenuevent.md @@ -1 +1 @@ -{{ class_all_options("flet.ContextMenuEvent") }} +{{ class_all_options("flet.ContextMenuDismissEvent") }} diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index b717e610ad..4728499c53 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -267,7 +267,7 @@ from flet.controls.material.container import Container from flet.controls.material.context_menu import ( ContextMenu, - ContextMenuEvent, + ContextMenuDismissEvent, ContextMenuSelectEvent, ContextMenuTrigger, ) @@ -613,7 +613,7 @@ "Container", "Context", "ContextMenu", - "ContextMenuEvent", + "ContextMenuDismissEvent", "ContextMenuSelectEvent", "ContextMenuSelectEvent", "ContextMenuTrigger", diff --git a/sdk/python/packages/flet/src/flet/controls/material/context_menu.py b/sdk/python/packages/flet/src/flet/controls/material/context_menu.py index 83d2f6c542..df07c85e14 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/context_menu.py +++ b/sdk/python/packages/flet/src/flet/controls/material/context_menu.py @@ -11,7 +11,7 @@ __all__ = [ "ContextMenu", - "ContextMenuEvent", + "ContextMenuDismissEvent", "ContextMenuSelectEvent", "ContextMenuTrigger", ] @@ -34,8 +34,8 @@ class ContextMenuTrigger(Enum): @dataclass(kw_only=True) -class ContextMenuEvent(Event["ContextMenu"]): - """Event fired when a context menu is shown or dismissed.""" +class ContextMenuDismissEvent(Event["ContextMenu"]): + """Event fired when a context menu is dismissed.""" global_position: Offset = field(metadata={"data_field": "g"}) """ @@ -66,7 +66,7 @@ class ContextMenuEvent(Event["ContextMenu"]): @dataclass(kw_only=True) -class ContextMenuSelectEvent(ContextMenuEvent): +class ContextMenuSelectEvent(ContextMenuDismissEvent): """Event fired when a context menu item is selected.""" item_id: Optional[int] = field(default=None, metadata={"data_field": "id"}) @@ -115,18 +115,27 @@ class ContextMenu(LayoutControl): """ A list of menu items to display in the context menu, for primary (usually left) mouse button actions. + + These items are displayed when the corresponding + [`primary_trigger`][(c).] is activated. """ secondary_items: list[PopupMenuItem] = field(default_factory=list) """ - A list of menu items to display in the context menu, + A list of menu items to display in the context menu for secondary (usually right) mouse button actions. + + These items are displayed when the corresponding + [`secondary_trigger`][(c).] is activated. """ tertiary_items: list[PopupMenuItem] = field(default_factory=list) """ - A list of menu items to display in the context menu, + A list of menu items to display in the context menu for tertiary (usually middle) mouse button actions. + + These items are displayed when the corresponding + [`tertiary_trigger`][(c).] is activated. """ primary_trigger: Optional[ContextMenuTrigger] = None @@ -150,17 +159,12 @@ class ContextMenu(LayoutControl): If set to `None`, the trigger is disabled. """ - on_open: Optional[EventHandler[ContextMenuEvent]] = None - """ - Fires immediately after the menu is shown. - """ - on_select: Optional[EventHandler[ContextMenuSelectEvent]] = None """ - Fires when a `PopupMenuItem` is selected. + Fires when a context menu item is selected. """ - on_dismiss: Optional[EventHandler[ContextMenuEvent]] = None + on_dismiss: Optional[EventHandler[ContextMenuDismissEvent]] = None """ Fires when the menu is dismissed without a selection, or when an attempt is made to open the menu but no items are available. From 23b0fc80f9750084f553418a21fc53b8dbc8a7c0 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 31 Oct 2025 16:31:15 +0100 Subject: [PATCH 6/8] apply review suggestions --- .../types/{contextmenuevent.md => contextmenudismissevent.md} | 0 sdk/python/packages/flet/mkdocs.yml | 4 ++-- sdk/python/packages/flet/src/flet/__init__.py | 1 - sdk/python/packages/flet/src/flet/controls/base_page.py | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) rename sdk/python/packages/flet/docs/types/{contextmenuevent.md => contextmenudismissevent.md} (100%) diff --git a/sdk/python/packages/flet/docs/types/contextmenuevent.md b/sdk/python/packages/flet/docs/types/contextmenudismissevent.md similarity index 100% rename from sdk/python/packages/flet/docs/types/contextmenuevent.md rename to sdk/python/packages/flet/docs/types/contextmenudismissevent.md diff --git a/sdk/python/packages/flet/mkdocs.yml b/sdk/python/packages/flet/mkdocs.yml index f1c98e7827..d22146d102 100644 --- a/sdk/python/packages/flet/mkdocs.yml +++ b/sdk/python/packages/flet/mkdocs.yml @@ -762,7 +762,7 @@ nav: - CardVariant: types/cardvariant.md - ClipBehavior: types/clipbehavior.md - Colors: types/colors.md - - ContextMenuTrigger:w types/contextmenutrigger.md + - ContextMenuTrigger: types/contextmenutrigger.md - CrossAxisAlignment: types/crossaxisalignment.md - CupertinoButtonSize: types/cupertinobuttonsize.md - CupertinoColors: types/cupertinocolors.md @@ -834,7 +834,7 @@ nav: - AppLifecycleStateChangeEvent: types/applifecyclestatechangeevent.md - AutoCompleteSelectEvent: types/autocompleteselectevent.md - CanvasResizeEvent: types/canvasresizeevent.md - - ContextMenuEvent: types/contextmenuevent.md + - ContextMenuDismissEvent: types/contextmenudismissevent.md - ContextMenuSelectEvent: types/contextmenuselectevent.md - DataColumnSortEvent: types/datacolumnsortevent.md - DatePickerEntryModeChangeEvent: types/datepickerentrymodechangeevent.md diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index 4728499c53..a16828e7f6 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -615,7 +615,6 @@ "ContextMenu", "ContextMenuDismissEvent", "ContextMenuSelectEvent", - "ContextMenuSelectEvent", "ContextMenuTrigger", "ContinuousRectangleBorder", "Control", diff --git a/sdk/python/packages/flet/src/flet/controls/base_page.py b/sdk/python/packages/flet/src/flet/controls/base_page.py index 417e547abf..6196ef772b 100644 --- a/sdk/python/packages/flet/src/flet/controls/base_page.py +++ b/sdk/python/packages/flet/src/flet/controls/base_page.py @@ -181,7 +181,7 @@ class BasePage(AdaptiveControl): Example: ```python - def def main(page: ft.Page): + def main(page: ft.Page): def handle_page_size(e): print("New page size:", page.window.width, page.window_height) From 577fb324b4a921abcbba5dd2260d46a6bebfe8ed Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 31 Oct 2025 16:45:44 +0100 Subject: [PATCH 7/8] update path to images --- .../flet/docs/controls/contextmenu.md | 2 +- .../flet/controls/material/context_menu.py | 21 ++++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/sdk/python/packages/flet/docs/controls/contextmenu.md b/sdk/python/packages/flet/docs/controls/contextmenu.md index 12d1f867e8..9b86b016b7 100644 --- a/sdk/python/packages/flet/docs/controls/contextmenu.md +++ b/sdk/python/packages/flet/docs/controls/contextmenu.md @@ -1,7 +1,7 @@ --- class_name: flet.ContextMenu examples: ../../examples/controls/context_menu -example_images: ../test-images/examples/cupertino/golden/macos/context_menu +example_images: ../test-images/examples/material/golden/macos/context_menu --- {{ class_summary(class_name, example_images + "/image_for_docs.png", image_caption="Basic ContextMenu") }} diff --git a/sdk/python/packages/flet/src/flet/controls/material/context_menu.py b/sdk/python/packages/flet/src/flet/controls/material/context_menu.py index df07c85e14..6bf5960c20 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/context_menu.py +++ b/sdk/python/packages/flet/src/flet/controls/material/context_menu.py @@ -35,7 +35,7 @@ class ContextMenuTrigger(Enum): @dataclass(kw_only=True) class ContextMenuDismissEvent(Event["ContextMenu"]): - """Event fired when a context menu is dismissed.""" + """Event fired when a [`ContextMenu`][flet.] is dismissed.""" global_position: Offset = field(metadata={"data_field": "g"}) """ @@ -50,24 +50,29 @@ class ContextMenuDismissEvent(Event["ContextMenu"]): button: Optional[str] = field(default=None, metadata={"data_field": "b"}) """ Mouse button that triggered the menu. + + If a string, can be one of: + `"primary"` (linked to [`ContextMenu.primary_items`][flet.]), + `"secondary"` (linked to [`ContextMenu.secondary_items`][flet.]), + or `"tertiary"` (linked to [`ContextMenu.tertiary_items`][flet.]). """ trigger: Optional[ContextMenuTrigger] = field( default=None, metadata={"data_field": "tr"} ) """ - Trigger mode that opened the menu. + The trigger mode that opened the menu. """ item_count: Optional[int] = field(default=None, metadata={"data_field": "ic"}) """ - Total number of entries displayed in the context menu. + Total number of entries displayed in the corresponding context menu. """ @dataclass(kw_only=True) class ContextMenuSelectEvent(ContextMenuDismissEvent): - """Event fired when a context menu item is selected.""" + """Event fired when a [`ContextMenu`][flet.] item is selected.""" item_id: Optional[int] = field(default=None, metadata={"data_field": "id"}) """ @@ -166,8 +171,8 @@ class ContextMenu(LayoutControl): on_dismiss: Optional[EventHandler[ContextMenuDismissEvent]] = None """ - Fires when the menu is dismissed without a selection, or when an attempt is made - to open the menu but no items are available. + Fires when the menu is dismissed without a selection, + or when an attempt is made to open the menu but no items are available. """ async def open( @@ -181,8 +186,8 @@ async def open( Args: global_position: A global coordinate describing where the menu should appear. If omitted, `local_position` or the center of the - wrapped control is used. - local_position: A local coordinate relative to the wrapped control. + [`content`][(c).] is used. + local_position: A local coordinate relative to the [`content`][(c).]. When provided without `global_position`, the coordinate is translated to global space automatically. """ From 0be605518206b5dfafe583e7752a0d4056e1b8ab Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 31 Oct 2025 16:51:57 +0100 Subject: [PATCH 8/8] fix formatting in docs --- .../packages/flet/src/flet/controls/material/context_menu.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/python/packages/flet/src/flet/controls/material/context_menu.py b/sdk/python/packages/flet/src/flet/controls/material/context_menu.py index 6bf5960c20..6e73e0e02e 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/context_menu.py +++ b/sdk/python/packages/flet/src/flet/controls/material/context_menu.py @@ -97,8 +97,8 @@ class ContextMenu(LayoutControl): menus for specific mouse events. Tip: - On web, call [`disable()`][`flet.BrowserContextMenu.disable`] method of - [`Page.browser_context_menu`][`flet.`] to suppress the default browser + On web, call [`disable()`][flet.BrowserContextMenu.disable] method of + [`Page.browser_context_menu`][flet.] to suppress the default browser context menu before relying on custom menus. """