From e53ace15c83e1b36fcf25137697006eeaf2a950d Mon Sep 17 00:00:00 2001 From: Owen McDonnell Date: Fri, 24 Jul 2026 16:11:11 -0700 Subject: [PATCH 1/2] fix keyboard_listener escape loop --- .../lib/src/controls/keyboard_listener.dart | 91 +++++++++++++++++-- 1 file changed, 83 insertions(+), 8 deletions(-) diff --git a/packages/flet/lib/src/controls/keyboard_listener.dart b/packages/flet/lib/src/controls/keyboard_listener.dart index 88f2dd9eeb..3d2a782aa4 100644 --- a/packages/flet/lib/src/controls/keyboard_listener.dart +++ b/packages/flet/lib/src/controls/keyboard_listener.dart @@ -1,3 +1,4 @@ + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -19,6 +20,8 @@ class KeyboardListenerControl extends StatefulWidget { class _KeyboardListenerControlState extends State { final FocusNode _focusNode = FocusNode(); + bool _escapeDownSent = false; + DateTime? _lastSynthesizedEscapeUpAt; @override void initState() { @@ -43,6 +46,75 @@ class _KeyboardListenerControlState extends State { } } + void _triggerKeyEvent(String eventName, KeyEvent keyEvent) { + if (keyEvent.logicalKey == LogicalKeyboardKey.escape) { + _escapeDownSent = eventName == "key_down"; + } + widget.control + .triggerEvent(eventName, {"key": keyEvent.logicalKey.keyLabel}); + } + + void _triggerEscapeKeyUp() { + if (!_escapeDownSent) { + return; + } + _escapeDownSent = false; + widget.control.triggerEvent("key_up", {"key": "Escape"}); + } + + KeyEventResult _handleEscapeLoop(KeyEvent keyEvent) { + final isEscape = keyEvent.logicalKey == LogicalKeyboardKey.escape; + final isPhysicalEscape = + isEscape && keyEvent.physicalKey == PhysicalKeyboardKey.escape; + final isPeriodEscape = + isEscape && keyEvent.physicalKey == PhysicalKeyboardKey.period; + final isMetaUp = keyEvent is KeyUpEvent && + (keyEvent.physicalKey == PhysicalKeyboardKey.metaLeft || + keyEvent.physicalKey == PhysicalKeyboardKey.metaRight || + keyEvent.logicalKey == LogicalKeyboardKey.meta || + keyEvent.logicalKey == LogicalKeyboardKey.metaLeft || + keyEvent.logicalKey == LogicalKeyboardKey.metaRight); + + if (keyEvent is KeyDownEvent && + isPeriodEscape && + HardwareKeyboard.instance.isMetaPressed) { + _triggerKeyEvent("key_down", keyEvent); + return KeyEventResult.handled; + } + + if (keyEvent is KeyUpEvent && isPeriodEscape) { + _triggerEscapeKeyUp(); + return KeyEventResult.handled; + } + + if (_escapeDownSent && isMetaUp) { + _triggerEscapeKeyUp(); + } + + if (keyEvent is KeyUpEvent && isPhysicalEscape) { + _lastSynthesizedEscapeUpAt = DateTime.now(); + if (keyEvent.synthesized) { + _triggerEscapeKeyUp(); + return KeyEventResult.handled; + } + } + + if (keyEvent is KeyDownEvent && isPhysicalEscape) { + final lastUpAt = _lastSynthesizedEscapeUpAt; + if (lastUpAt != null && + DateTime.now().difference(lastUpAt) < + const Duration(milliseconds: 300)) { + return KeyEventResult.handled; + } + } + + if (!isEscape) { + _lastSynthesizedEscapeUpAt = null; + } + + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { debugPrint("KeyboardListener build: ${widget.control.id}"); @@ -53,23 +125,26 @@ class _KeyboardListenerControlState extends State { return const ErrorControl("KeyboardListener control has no content."); } - return KeyboardListener( + return Focus( focusNode: _focusNode, autofocus: widget.control.getBool("autofocus", false)!, includeSemantics: widget.control.getBool("include_semantics", true)!, - onKeyEvent: (keyEvent) { + onKeyEvent: (FocusNode node, KeyEvent keyEvent) { + final result = _handleEscapeLoop(keyEvent); + if (result == KeyEventResult.handled) { + return result; + } if (keyEvent is KeyDownEvent) { - widget.control - .triggerEvent("key_down", {"key": keyEvent.logicalKey.keyLabel}); + _triggerKeyEvent("key_down", keyEvent); } else if (keyEvent is KeyUpEvent) { - widget.control - .triggerEvent("key_up", {"key": keyEvent.logicalKey.keyLabel}); + _triggerKeyEvent("key_up", keyEvent); } else if (keyEvent is KeyRepeatEvent) { - widget.control.triggerEvent( - "key_repeat", {"key": keyEvent.logicalKey.keyLabel}); + _triggerKeyEvent("key_repeat", keyEvent); } + return KeyEventResult.ignored; }, child: content, ); } } + From 356b7c59acb97693640d6b4cc56bb7aa46ecc1f5 Mon Sep 17 00:00:00 2001 From: Owen McDonnell Date: Mon, 27 Jul 2026 10:48:27 -0700 Subject: [PATCH 2/2] add bugfix to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69cd579f84..6cdcf91435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * Fix iOS apps built with `flet build ipa` crashing at startup with `Failed to lookup symbol 'serious_python_run': dlsym(RTLD_DEFAULT, serious_python_run): symbol not found`. `serious_python` shipped `dart_bridge` — which provides the in-process Dart↔Python transport — as a **static** library linked into the app executable. An iOS executable exports nothing to the dynamic symbol table by default and the release build strips local symbols, so the `dlsym` lookups that Dart (`DynamicLibrary.process()`) and Python (`import dart_bridge`) perform at runtime could not resolve. Only release/archive (device) builds under the Swift Package Manager path were affected — debug and simulator builds don't dead-strip, so the failure did not reproduce there, and Android was never affected (its `dart_bridge` is a dynamic `.so`, which exports its symbols). Bumps `serious_python` to **4.4.0**, which ships `dart_bridge` as a dynamic framework — embedded and signed into the app like `Python.xcframework`, with its symbols exported — and re-pins the bundled python-build snapshot to **20260726** (`dart_bridge` **1.5.1 → 1.6.1**, Pyodide 3.14 **314.0.2 → 314.0.3**); the bundled Python versions (**3.12.13 / 3.13.14 / 3.14.6**) are unchanged by @FeodorFitsner. * Fix `MatplotlibChart` freezing permanently when its platform view is disposed with a frame in flight — the common trigger is switching to another tab inside the app, which races the frame stream: `DataChannel.send` on a disposed channel silently drops, so the frame's `[0xFF]` frame-applied ack never arrives and `_send_and_wait`'s unbounded await parks `MatplotlibChart._receive_loop` — the sole consumer of the frame queue — for the rest of the session, with no exception raised; remounting opens a fresh channel but the stale ack futures were never resolved, so the chart stayed frozen. `_capture_channel` now resolves all pending ack futures when a new channel is captured (a fresh channel means every pending ack belongs to the disposed one), unparking the producer instantly on remount, and the ack await is bounded by `FRAME_ACK_TIMEOUT` (5s; a healthy ack lands in milliseconds) — on expiry the frame is dropped and its future removed from the ack FIFO so subsequent acks keep resolving the right entries ([#6709](https://github.com/flet-dev/flet/issues/6709), [#6710](https://github.com/flet-dev/flet/pull/6710)) by @ForsakenDurian. +* Fix `KeyboardListener` on macOS getting stuck in a repeated `Escape` event loop after pressing Command+Period (`Command+.`). The client now handles the macOS/Flutter synthesized Escape sequence for that shortcut so the app remains responsive. * Fix a system/edge-swipe back gesture exiting the whole host app instead of navigating back when it lands on an embedded `FletApp` (an app rendered inside another Flet app — e.g. a gallery host running example apps in-process). The embedded app's `WidgetsApp` (`MaterialApp`/`CupertinoApp`) ran the default `NavigationNotification` handler, which reported `SystemNavigator.setFrameworkHandlesBack(false)` for a nested app that couldn't pop (typically a single-view example) and swallowed the notification, so the OS finished the whole activity on back and the host never got to report that it could pop. An embedded page now lets that notification bubble to the host (which re-reports `canHandlePop`) and chains a `ChildBackButtonDispatcher` to the host Router, so a system back propagates to the host and pops the view that embeds it by @FeodorFitsner. * Fix `page.window.maximized = True` intermittently reverting to unmaximized right after startup on macOS, when set in the same patch as `page.title` (e.g. `page.title = "My App"; page.window.maximized = True` in `main()`) by @davidlawson. * Fix `flet build` picking a non-decodable icon/splash image when several files share a base name, producing a machine-dependent `NoDecoderForImageFormatException` from `flutter_launcher_icons`. When an app's `assets` held, say, both `icon.png` and `icon.svg`, `find_platform_image` selected the first match from `glob.glob(...)` — whose order is filesystem-dependent — so the same app could pick `icon.png` on one machine and `icon.svg` on another (SVG is vector and can't be decoded by the raster icon/splash generators), turning a working build into a crash purely based on directory listing order. Candidates are now filtered to formats the generators can actually decode (`.svg` is dropped everywhere; `.icns` stays macOS-only and `.ico` Windows-only) and ranked so a raster image (`.png` first) always wins, making the choice deterministic across machines. When the only supplied image is an SVG (no raster sibling), it's skipped with a build-log warning and the default Flet icon is used instead of crashing by @FeodorFitsner.