Skip to content

Commit e7f52ed

Browse files
authored
feat: ContextMenu control (#5761)
* initial commit * Refactor docstrings for improved formatting * Replace `ContextMenuRegion` with `ContextMenu`, refactor examples, tests, and docs * Refactor examples and tests; update docs and add screenshots * rename `ContextMenuEvent` to `ContextMenuDismissEvent`, remove `on_open`, and update docs/examples accordingly. * apply review suggestions * update path to images * fix formatting in docs
1 parent ce1d1c8 commit e7f52ed

File tree

31 files changed

+1031
-285
lines changed

31 files changed

+1031
-285
lines changed
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
import 'dart:async';
2+
3+
import 'package:collection/collection.dart';
4+
import 'package:flet/src/utils/numbers.dart';
5+
import 'package:flutter/gestures.dart';
6+
import 'package:flutter/material.dart';
7+
8+
import '../extensions/control.dart';
9+
import '../models/control.dart';
10+
import '../utils/popup_menu.dart';
11+
import '../utils/transforms.dart';
12+
import '../widgets/error.dart';
13+
import 'base_controls.dart';
14+
15+
class ContextMenuControl extends StatefulWidget {
16+
final Control control;
17+
18+
const ContextMenuControl({super.key, required this.control});
19+
20+
@override
21+
State<ContextMenuControl> createState() => _ContextMenuControlState();
22+
}
23+
24+
class _ContextMenuControlState extends State<ContextMenuControl> {
25+
ContextMenuTrigger _primaryTrigger = ContextMenuTrigger.disabled;
26+
ContextMenuTrigger _secondaryTrigger = ContextMenuTrigger.down;
27+
ContextMenuTrigger _tertiaryTrigger = ContextMenuTrigger.down;
28+
29+
Future<String?>? _pendingMenu;
30+
31+
@override
32+
void initState() {
33+
super.initState();
34+
// Allow backend code to invoke methods on this control instance.
35+
widget.control.addInvokeMethodListener(_invokeMethod);
36+
}
37+
38+
@override
39+
void dispose() {
40+
widget.control.removeInvokeMethodListener(_invokeMethod);
41+
super.dispose();
42+
}
43+
44+
@override
45+
Widget build(BuildContext context) {
46+
debugPrint("ContextMenu build: ${widget.control.id}");
47+
48+
var content = widget.control.buildWidget("content");
49+
if (content == null) {
50+
return const ErrorControl("ContextMenu.content must be visible");
51+
}
52+
53+
_primaryTrigger = parseContextMenuTrigger(
54+
widget.control.getString("primary_trigger"),
55+
ContextMenuTrigger.disabled)!;
56+
_secondaryTrigger = parseContextMenuTrigger(
57+
widget.control.getString("secondary_trigger"),
58+
ContextMenuTrigger.down)!;
59+
_tertiaryTrigger = parseContextMenuTrigger(
60+
widget.control.getString("tertiary_trigger"), ContextMenuTrigger.down)!;
61+
62+
Widget result = GestureDetector(
63+
behavior: HitTestBehavior.deferToChild,
64+
onLongPressStart: _primaryTrigger == ContextMenuTrigger.longPress
65+
? (LongPressStartDetails details) => _handleLongPress(
66+
_MouseButton.primary,
67+
details.globalPosition,
68+
details.localPosition,
69+
)
70+
: null,
71+
onSecondaryLongPressStart:
72+
_secondaryTrigger == ContextMenuTrigger.longPress
73+
? (LongPressStartDetails details) => _handleLongPress(
74+
_MouseButton.secondary,
75+
details.globalPosition,
76+
details.localPosition,
77+
)
78+
: null,
79+
onTertiaryLongPressStart: _tertiaryTrigger == ContextMenuTrigger.longPress
80+
? (LongPressStartDetails details) => _handleLongPress(
81+
_MouseButton.tertiary,
82+
details.globalPosition,
83+
details.localPosition,
84+
)
85+
: null,
86+
child: Listener(
87+
behavior: HitTestBehavior.translucent,
88+
onPointerDown: _handlePointerDown,
89+
child: content,
90+
),
91+
);
92+
93+
return LayoutControl(control: widget.control, child: result);
94+
}
95+
96+
/// Handles pointer down events to determine if a context menu should be shown.
97+
/// Only responds to mouse events and triggers the menu if the configured trigger is `down`.
98+
void _handlePointerDown(PointerDownEvent event) {
99+
if (event.kind != PointerDeviceKind.mouse) return;
100+
101+
final button = _mouseButtonFromEvent(event.buttons);
102+
if (button == null) return;
103+
104+
final trigger = _getTriggerFromButton(button);
105+
if (trigger != ContextMenuTrigger.down) return;
106+
107+
_showMenu(
108+
button: button,
109+
globalPosition: event.position,
110+
localPosition: event.localPosition,
111+
);
112+
}
113+
114+
void _handleLongPress(
115+
_MouseButton button, Offset globalPosition, Offset localPosition) {
116+
final trigger = _getTriggerFromButton(button);
117+
if (trigger != ContextMenuTrigger.longPress) return;
118+
119+
_showMenu(
120+
button: button,
121+
globalPosition: globalPosition,
122+
localPosition: localPosition,
123+
);
124+
}
125+
126+
ContextMenuTrigger? _getTriggerFromButton(_MouseButton? button) {
127+
switch (button) {
128+
case _MouseButton.primary:
129+
return _primaryTrigger;
130+
case _MouseButton.secondary:
131+
return _secondaryTrigger;
132+
case _MouseButton.tertiary:
133+
return _tertiaryTrigger;
134+
default:
135+
return null;
136+
}
137+
}
138+
139+
/// Returns the corresponding [_MouseButton] based on the
140+
/// given button bitmask, and `null` if no recognized button is pressed.
141+
_MouseButton? _mouseButtonFromEvent(int buttons) {
142+
if ((buttons & kPrimaryButton) != 0) {
143+
return _MouseButton.primary;
144+
} else if ((buttons & kSecondaryMouseButton) != 0) {
145+
return _MouseButton.secondary;
146+
} else if ((buttons & kTertiaryButton) != 0) {
147+
return _MouseButton.tertiary;
148+
}
149+
return null;
150+
}
151+
152+
/// Picks popup menu items configured for the provided button, falling back
153+
/// to the shared `items` collection when a button-specific list is empty.
154+
List<Control> _getPopupItemsFromButton(_MouseButton? button) {
155+
switch (button) {
156+
case _MouseButton.primary:
157+
return widget.control.children("primary_items");
158+
case _MouseButton.secondary:
159+
return widget.control.children("secondary_items");
160+
case _MouseButton.tertiary:
161+
return widget.control.children("tertiary_items");
162+
default:
163+
return widget.control.children("items");
164+
}
165+
}
166+
167+
/// Serialises menu event data to a compact payload sent to Python handlers.
168+
Map<String, dynamic> _eventPayload(
169+
_MouseButton? button, Offset globalPosition, Offset? localPosition,
170+
{int? itemId, int? itemIndex, int? itemCount}) {
171+
return {
172+
"b": button?.name,
173+
"tr": _getTriggerFromButton(button)?.name,
174+
"id": itemId,
175+
"idx": itemIndex,
176+
"ic": itemCount,
177+
"g": {"x": globalPosition.dx, "y": globalPosition.dy},
178+
"l": localPosition != null
179+
? {"x": localPosition.dx, "y": localPosition.dy}
180+
: null,
181+
};
182+
}
183+
184+
/// Opens the context menu for a specific button at the requested position.
185+
Future<void> _showMenu(
186+
{required Offset globalPosition,
187+
Offset? localPosition,
188+
_MouseButton? button}) async {
189+
// If a menu is already open, close it and wait for it to finish.
190+
if (_pendingMenu != null) {
191+
Navigator.of(context).pop();
192+
await _pendingMenu;
193+
if (!mounted) return;
194+
}
195+
196+
// Get the overlay state and its render box for positioning the menu.
197+
final overlayState = Overlay.of(context, rootOverlay: true);
198+
final overlayRenderBox =
199+
overlayState.context.findRenderObject() as RenderBox?;
200+
if (overlayRenderBox == null || !overlayRenderBox.hasSize) return;
201+
202+
// Calculate the position for the popup menu relative to the overlay.
203+
final overlayOffset = overlayRenderBox.globalToLocal(globalPosition);
204+
final position = RelativeRect.fromLTRB(
205+
overlayOffset.dx,
206+
overlayOffset.dy,
207+
overlayRenderBox.size.width - overlayOffset.dx,
208+
overlayRenderBox.size.height - overlayOffset.dy,
209+
);
210+
211+
// Build popup menu entries.
212+
final popupItems = _getPopupItemsFromButton(button).toList(growable: false);
213+
final entries = buildPopupMenuEntries(popupItems, context);
214+
215+
// Prepare event payload for menu events.
216+
final basePayload = _eventPayload(button, globalPosition, localPosition,
217+
itemCount: entries.length);
218+
219+
// If there are no menu entries, send dismiss event.
220+
if (entries.isEmpty) {
221+
widget.control.triggerEvent("dismiss", basePayload);
222+
return;
223+
}
224+
225+
// Show the popup menu and wait for user selection.
226+
final menuFuture = showMenu<String>(
227+
context: context,
228+
position: position,
229+
items: entries,
230+
);
231+
_pendingMenu = menuFuture;
232+
final selection = await menuFuture;
233+
234+
if (!mounted) return;
235+
_pendingMenu = null;
236+
237+
// Handle the user's selection or dismissal.
238+
if (selection != null) {
239+
final selectedControl = popupItems
240+
.firstWhereOrNull((item) => item.id.toString() == selection);
241+
widget.control.triggerEvent(
242+
"select",
243+
_eventPayload(
244+
button,
245+
globalPosition,
246+
localPosition,
247+
itemId: parseInt(selection),
248+
itemCount: popupItems.length,
249+
itemIndex: selectedControl != null
250+
? popupItems.indexOf(selectedControl)
251+
: null,
252+
));
253+
} else {
254+
widget.control.triggerEvent(
255+
"dismiss",
256+
_eventPayload(
257+
button,
258+
globalPosition,
259+
localPosition,
260+
itemCount: popupItems.length,
261+
));
262+
}
263+
}
264+
265+
Future<dynamic> _invokeMethod(String name, dynamic args) async {
266+
switch (name) {
267+
case "open":
268+
// Get the render box for positioning the context menu.
269+
final renderBox = context.findRenderObject() as RenderBox?;
270+
if (renderBox == null || !renderBox.hasSize) {
271+
throw StateError(
272+
"ContextMenu render box is not ready to display a menu.");
273+
}
274+
275+
var globalPosition = parseOffset(args["global_position"]);
276+
var localPosition = parseOffset(args["local_position"]);
277+
278+
// If only local position is provided, obtain global position from it.
279+
if (globalPosition == null && localPosition != null) {
280+
globalPosition = renderBox.localToGlobal(localPosition);
281+
}
282+
// If only global position is provided, obtain local position from it.
283+
else if (globalPosition != null && localPosition == null) {
284+
localPosition = renderBox.globalToLocal(globalPosition);
285+
}
286+
287+
// Default to center of the render box if positions are missing.
288+
localPosition ??= renderBox.size.center(Offset.zero);
289+
globalPosition ??= renderBox.localToGlobal(localPosition);
290+
291+
// Show the context menu at the calculated position.
292+
_showMenu(globalPosition: globalPosition, localPosition: localPosition);
293+
return null;
294+
default:
295+
throw ArgumentError("Unsupported method: $name");
296+
}
297+
}
298+
}
299+
300+
enum _MouseButton { primary, secondary, tertiary }
301+
302+
enum ContextMenuTrigger { disabled, down, longPress }
303+
304+
ContextMenuTrigger? parseContextMenuTrigger(String? value,
305+
[ContextMenuTrigger? defaultValue]) {
306+
if (value == null) return defaultValue;
307+
return ContextMenuTrigger.values.firstWhereOrNull(
308+
(e) => e.name.toLowerCase() == value.toLowerCase()) ??
309+
defaultValue;
310+
}

packages/flet/lib/src/controls/popup_menu_button.dart

Lines changed: 3 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import 'package:flet/src/utils/text.dart';
21
import 'package:flutter/material.dart';
32

43
import '../extensions/control.dart';
@@ -10,8 +9,8 @@ import '../utils/buttons.dart';
109
import '../utils/colors.dart';
1110
import '../utils/edge_insets.dart';
1211
import '../utils/misc.dart';
13-
import '../utils/mouse.dart';
1412
import '../utils/numbers.dart';
13+
import '../utils/popup_menu.dart';
1514
import 'base_controls.dart';
1615

1716
class PopupMenuButtonControl extends StatelessWidget {
@@ -51,60 +50,8 @@ class PopupMenuButtonControl extends StatelessWidget {
5150
control.triggerEvent("select", selection),
5251
onCanceled: () => control.triggerEvent("cancel"),
5352
onOpened: () => control.triggerEvent("open"),
54-
itemBuilder: (BuildContext context) => control
55-
.children("items")
56-
.where((i) => i.type == "PopupMenuItem")
57-
.map((item) {
58-
var checked = item.getBool("checked");
59-
var height = item.getDouble("height", 48.0)!;
60-
var padding = item.getPadding("padding");
61-
var itemContent = item.buildTextOrWidget("content");
62-
var itemIcon = item.buildIconOrWidget("icon");
63-
var mouseCursor = item.getMouseCursor("mouse_cursor");
64-
var labelTextStyle = item.getWidgetStateTextStyle(
65-
"label_text_style", Theme.of(context));
66-
67-
Widget? child;
68-
if (itemContent != null && itemIcon == null) {
69-
child = itemContent;
70-
} else if (itemContent == null && itemIcon != null) {
71-
child = itemIcon;
72-
} else if (itemContent != null && itemIcon != null) {
73-
child = Row(children: [
74-
itemIcon,
75-
const SizedBox(width: 8),
76-
itemContent
77-
]);
78-
}
79-
80-
var result = checked != null
81-
? CheckedPopupMenuItem<String>(
82-
value: item.id.toString(),
83-
checked: checked,
84-
height: height,
85-
padding: padding,
86-
enabled: !item.disabled,
87-
mouseCursor: mouseCursor,
88-
labelTextStyle: labelTextStyle,
89-
onTap: () => item.triggerEvent("click", !checked),
90-
child: child,
91-
)
92-
: PopupMenuItem<String>(
93-
value: item.id.toString(),
94-
height: height,
95-
padding: padding,
96-
labelTextStyle: labelTextStyle,
97-
enabled: !item.disabled,
98-
mouseCursor: mouseCursor,
99-
onTap: () {
100-
item.triggerEvent("click");
101-
},
102-
child: child);
103-
104-
return child != null
105-
? result
106-
: const PopupMenuDivider() as PopupMenuEntry<String>;
107-
}).toList(),
53+
itemBuilder: (BuildContext context) =>
54+
buildPopupMenuEntries(control.children("items"), context),
10855
child: content);
10956

11057
return LayoutControl(

0 commit comments

Comments
 (0)