diff --git a/.gitignore b/.gitignore index 6795b1796a..2799e26867 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ client/devtools_options.yaml # Actual screenshots written by failed golden comparisons in integration tests *_actual.png + +# Files uploaded while running the file_picker examples, which pass +# upload_dir="examples" to ft.run() and so write under the example's own folder +sdk/python/examples/**/examples/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f811207d6c..73a87135b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,14 @@ ### New features +* **Client actions: an `action` property that performs gesture-gated work without a round trip to Python.** Browsers only let a page open a file picker, write to the clipboard, show a share sheet or open a new tab while they are still handling the user's click or key press. Sending that click to your Python code and acting on the reply takes longer than the permission lasts, so on iOS Safari those operations were silently ignored while Android and desktop browsers let them through - which made a browser rule look like a Flet bug. `Button`, `Container`, `IconButton`, `ListTile`, `CupertinoButton`, `CupertinoListTile`, `FloatingActionButton`, `OutlinedButton`, `TextButton` and `TextSpan` now accept `action` - a single `ClientAction` or a list of them - which the client performs inside the original gesture, before your `on_click` handler is even notified: `ft.Button("Open", action=ft.OpenUrl("https://flet.dev", target=ft.UrlTarget.BLANK))`. Because an action runs before your code sees the click, its arguments have to be known in advance; to act on a value computed at click time, set it on the control ahead of the click. `url` is unchanged and keeps working exactly as before by @FeodorFitsner. * **App icon and desktop integration for `flet build linux`.** Linux was the one platform `flet build` shipped without an icon — `flutter_launcher_icons` has no Linux generator, so `assets/icon.png` was silently ignored and built apps ran with a generic icon. The resolved icon (`icon_linux.png`, falling back to `icon.png` or the default Flet icon) is now bundled as `data/app_icon.png` and set as the window icon on startup, which taskbars and window switchers pick up on X11 and XWayland. Because Wayland has no window-icon protocol — desktops resolve icons from an installed desktop entry matching the app id — the bundle also ships a ready-to-install freedesktop tree: `share/applications/.desktop` (name from `--product`, comment from `--description`, `StartupWMClass` set) plus the icon at `share/icons/hicolor//apps/.png`. The entry's application categories default to `Utility` and are configurable with `--linux-categories` or `[tool.flet.linux].categories`, and the runner now sets its program name to the bundle ID (matching upstream Flutter's runner template) so the running app maps to that entry on both Wayland and X11. See the new [App icon](https://flet.dev/docs/publish/linux#app-icon) docs ([#2269](https://github.com/flet-dev/flet/issues/2269)) by @ndonkoHenri. * **macOS code signing, notarization, and Mac App Store builds in `flet build macos`.** Select a distribution lane with `--macos-distribution` (or `[tool.flet.macos.signing].distribution`): `developer-id` signs every bundled binary with your Developer ID certificate — hardened runtime, entitlements, secure timestamp — then notarizes and staples the app for direct distribution, while `app-store` produces a sandboxed app with your provisioning profile embedded, packaged into an installer-signed `.pkg` ready for App Store Connect and TestFlight. Signing identities are auto-discovered from the keychain when not explicitly configured (via CLI options, `pyproject.toml` — including per-lane `[tool.flet.macos.signing.]` subtables — or environment variables), and the whole configuration is validated before the build starts, so a typo'd identity, expired certificate, or missing store prerequisite fails in seconds instead of after the full build. See the new [Code signing](https://flet.dev/docs/publish/macos#code-signing), [Notarization](https://flet.dev/docs/publish/macos#notarization), and [Mac App Store](https://flet.dev/docs/publish/macos#mac-app-store) docs ([#2347](https://github.com/flet-dev/flet/issues/2347), [#4543](https://github.com/flet-dev/flet/issues/4543), [#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. ### Improvements +* `FilePicker` gained an `on_result` event, called when files are selected through a `PickFiles` action. A `PickFiles` action opens the dialog on the client before your code sees the click, so the selection cannot be returned to the caller the way `pick_files()` returns it - it arrives here instead. The picked files stay associated with the `FilePicker`, so they can be passed straight to `upload()` by @FeodorFitsner. * `flet build ipa` now validates the configured provisioning profile before the build starts, instead of letting Xcode fail at the signing step minutes later with "No profile for team 'X' matching 'Y' found" — a message that cannot say what *is* installed. The profile is resolved the same way Xcode's `PROVISIONING_PROFILE_SPECIFIER` does (by name **or** UUID, across both directories Xcode reads), and is additionally checked for expiry, team match, and bundle-id coverage. A name that matches nothing now fails in seconds, listing the installed profiles with their teams and UUIDs so a typo — or a profile that was downloaded but never installed — is immediately obvious ([#5100](https://github.com/flet-dev/flet/issues/5100), [#6796](https://github.com/flet-dev/flet/pull/6796)) by @ndonkoHenri. * `flet build ipa` now reports the artifact it actually produced. An unsigned build yields only an `.xcarchive`, yet the command announced "Successfully built your .ipa bundle" and pointed at an output directory holding no `.ipa`; it now names the `.xcarchive` and explains that Xcode exports an `.ipa` only for a signed app. A failed export is also caught properly: `flutter build ipa` exits 0 when Xcode's export step fails, and the existing check for it inspected captured output — which is empty whenever `-v` is used, so verbose builds reported the failure as success. The check now looks for the `.ipa` itself ([#6796](https://github.com/flet-dev/flet/pull/6796)) by @ndonkoHenri. * Web builds and `flet publish` now read the `FLET_WEB_RENDERER`, `FLET_WEB_ROUTE_URL_STRATEGY`, and `FLET_WEB_NO_CDN` environment variables as fallbacks behind the CLI options and `[tool.flet.web]` pyproject keys, matching the `[env: ...]` notation the options already advertised, by @ndonkoHenri. @@ -48,6 +50,8 @@ ### Bug fixes +* Fix `UrlTarget.BLANK` not actually opening a new tab per link on the web. Its value was `"blank"` while `SELF`, `PARENT` and `TOP` all carry the leading underscore the HTML spec defines, so the value reached `window.open()` as an ordinary window *name* rather than the reserved `_blank` keyword: the first such link opened a tab called `blank` and every later one reused that same tab instead of opening its own. The `LaunchMode.externalApplication` upgrade that `openWebBrowser()` applies to `_blank` never fired either, so on non-web platforms `BLANK` did not force an external browser as intended. The enum value is now `"_blank"` by @FeodorFitsner. +* Fix `FilePicker.pick_files()` never opening a dialog in a web app on iOS, and `Clipboard.set()`, `Clipboard.set_image()` and the `Share` methods doing nothing there either. A browser opens a file picker, writes to the clipboard or shows a share sheet only while it is handling the user's click or key press, and a service method called from an event handler misses that window entirely: the click travels to Python, the handler runs, and the instruction travels back long after the permission has lapsed. WebKit enforces this and reports nothing, while Chrome and Firefox allow the same calls, so an app worked on Android and on the desktop and silently did nothing on an iPhone or iPad - which made a browser rule look like a Flet bug, and made `FilePicker` in particular look broken since `save_file()` kept working (it clicks a download link, which is not gated). These operations are now available as client actions - `ft.PickFiles`, `ft.CopyToClipboard`, `ft.ShareText`, `ft.OpenUrl` - assigned to a control's `action` property and performed by the client inside the original gesture. `pick_files()` also no longer hangs for its full one-hour timeout when a browser has already reported it will not open the dialog: it raises straight away, pointing at `ft.PickFiles` ([#3710](https://github.com/flet-dev/flet/issues/3710)) by @FeodorFitsner. * Fix `flet build --description` (and `project.description` / `tool.poetry.description` from `pyproject.toml`) never reaching the built app. The value was passed to the build template under a key no file consumed (`description`) while every template reads `project_description`, so it always rendered as its empty default — a web app's `` and PWA `manifest.json` description have been silently blank since the option was introduced. Both now receive it, as does the new Linux desktop entry's `Comment=`, and the value is escaped per format, so a description containing quotes, newlines or backslashes can no longer produce an unparsable `pubspec.yaml`/`manifest.json` or a desktop entry the desktop environment discards. The option is now documented under [Description](https://flet.dev/docs/publish#description) ([#2269](https://github.com/flet-dev/flet/issues/2269)) by @ndonkoHenri. * Fix Linux apps packaged with `flet pack` appearing in the taskbar as "flet", grouped together with every other Flet app and unable to carry an icon. `flet pack` runs the shared prebuilt client binary, and the Linux desktop keys a window's identity on its X11 `WM_CLASS` or Wayland `app_id` — both of which GTK derives from the client's `argv[0]`, so every packed app inherited that binary's own name. The client is now launched under the app's own identity instead, taken from the new `FLET_APP_ID` environment variable that the PyInstaller runtime hook sets to `--bundle-id` when one is given and to the executable's name otherwise — so an executable named something a desktop entry should not be keyed on, such as a versioned `my-app-1.2.3`, can be given a stable identity; the binary is unchanged, so this needs no client rebuild and works with clients already cached. A Linux app's display name and icon come from an installed desktop entry rather than from the executable, so `flet pack` now writes one next to the binary — with `StartupWMClass` already matching the app's identity, which is the part that is impossible to guess — plus the icon itself when `--icon` is a `.png`, closing the other half of the report. Neither is installed for you, since that would change your application menu as a side effect of building; [Linux taskbar identity](https://flet.dev/docs/publish/using-pyinstaller#linux-taskbar-identity) shows the two `cp` commands ([#5422](https://github.com/flet-dev/flet/issues/5422), [#6800](https://github.com/flet-dev/flet/pull/6800)) by @ndonkoHenri. * Fix Windows apps packaged with `flet pack` showing a second taskbar identity named "Flet description", whose right-click entry and pin launch a blank Flet client window instead of the app. Two defects stacked up: the PyInstaller runtime hook carrying the AppUserModelID fix from [#6403](https://github.com/flet-dev/flet/pull/6403) was never bundled into packed apps (its `rthooks.dat` manifest was missing from the `flet-cli` wheel, and PyInstaller skips a missing manifest silently), and a process-level AppUserModelID only fixes taskbar *grouping* anyway — the taskbar name, icon and pin target resolve through the shell's relaunch properties, which were never set, so they fell back to the cached `flet.exe`. The wheel now ships the manifest, and `flet_desktop` stamps `System.AppUserModel.ID`/`RelaunchCommand`/`RelaunchDisplayNameResource`/`RelaunchIconResource` on the client window right after launch (new `flet_desktop.win_taskbar` module, pure ctypes), driven by environment variables the runtime hook sets — and settable manually when packaging by other means, such as Nuitka. Apps started hidden (`AppView.FLET_APP_HIDDEN`) get their taskbar identity as well, and executable paths containing spaces or longer than 128 characters are supported ([#6767](https://github.com/flet-dev/flet/discussions/6767), [#6793](https://github.com/flet-dev/flet/pull/6793)) by @ndonkoHenri. diff --git a/packages/flet/CHANGELOG.md b/packages/flet/CHANGELOG.md index e428fafe53..ca4172fa42 100644 --- a/packages/flet/CHANGELOG.md +++ b/packages/flet/CHANGELOG.md @@ -1,5 +1,7 @@ ## 1.0.0 +* Add `runClientActions()` and `runControlActions()` in `utils/client_actions.dart`, which perform a control's `url` and `action` properties on the client, synchronously, from inside the gesture callback that triggered them. `runClientActions()` resolves each action's target service through `FletBackend.controlsIndex` and calls `Control.invokeMethod()` on it without awaiting, because browsers grant gesture-gated APIs - opening a file picker, writing to the clipboard, `navigator.share`, `window.open` - only while user activation is live, and activation does not survive an async gap. The new `Control.hasInvokeMethodListeners` getter guards that path: `Control.invokeMethod()` waits for a listener when the target service is not mounted yet, and awaiting that wait would silently consume the gesture, so an unresolvable action is skipped and logged instead. A new `Control.hasControlActions` extension getter reports whether a control has anything to run, for controls that only install a tap handler when something is wired to them. `openWebBrowser()` is no longer called directly by `Button`, `Container`, `IconButton`, `ListTile`, `CupertinoListTile`, `CupertinoButton` and `FloatingActionButton`, which now dispatch through `runControlActions()`; `parseTextSpans()` and `parseInlineSpan()` take an optional trailing `BuildContext` so a `TextSpan` can carry actions too. +* Client actions add `"_from_gesture": true` to the arguments they invoke a service method with, so a service can tell the two entry points apart. `FilePickerService` uses it to emit a `result` event only for the gesture path - a gesture-triggered pick has no caller to return to - and to skip the new `isGestureGatedDialogBlocked()` check, added to `utils/platform_utils_web.dart` and its non-web counterpart. That check reports `true` only for an Apple/WebKit browser whose `navigator.userActivation` says no activation is live, so a `pick_files()` call that cannot possibly open a dialog fails immediately instead of waiting out its timeout; Chrome and Firefox permit programmatic file input clicks outside a gesture and are never reported as blocked. * Replace the `FormFieldInputBorder` enum and its `parseFormFieldInputBorder()` / `Control.getFormFieldInputBorder()` helpers with `parseInputBorder()`, which builds an `InputBorder` from a serialized border object. `parseFormFieldBorders()` maps a control's `border` property — a single border or a map of control states — onto the `InputDecoration` border slots, including `errorBorder`, `focusedErrorBorder` and `disabledBorder`, and `parseFormFieldBoxBorder()` translates the same property for controls decorated with a `BoxDecoration` instead ([#6773](https://github.com/flet-dev/flet/pull/6773)) by @ndonkoHenri. * `_onPatchControl` now reports a `PATCH_CONTROL` whose target id is missing from `controlsIndex` instead of silently discarding it. A dropped patch means the client and server have diverged and the screen is stale from that point on; previously nothing was logged anywhere, making the resulting "app stopped responding" impossible to diagnose. Uses `print` rather than `debugPrint`, which `main.dart` nulls in release builds - exactly where the message needs to be visible. * `FletJS.canvasKitBaseUrl` is now `String?`. `flutter_bootstrap.js` applies `flet.canvasKitBaseUrl` and `flet.fontFallbackBaseUrl` whenever they are set rather than only when `flet.noCdn` is true, and both default to `null` in CDN mode — so a host serving its own copy of the runtime can point them anywhere without also claiming a no-CDN build. The getter has no readers in this package; the annotation now matches the value it can carry. diff --git a/packages/flet/lib/src/controls/button.dart b/packages/flet/lib/src/controls/button.dart index 3d2b276ad9..279e84e93a 100644 --- a/packages/flet/lib/src/controls/button.dart +++ b/packages/flet/lib/src/controls/button.dart @@ -4,7 +4,7 @@ import '../extensions/control.dart'; import '../models/control.dart'; import '../utils/buttons.dart'; import '../utils/colors.dart'; -import '../utils/launch_url.dart'; +import '../utils/client_actions.dart'; import '../utils/misc.dart'; import '../utils/numbers.dart'; import '../widgets/error.dart'; @@ -63,7 +63,6 @@ class _ButtonControlState extends State with FletStoreMixin { bool isTextButton = widget.control.type == "TextButton"; bool isOutlinedButton = widget.control.type == "OutlinedButton"; - var url = widget.control.getUrl("url"); var iconColor = widget.control.getColor("icon_color", context); var clipBehavior = widget.control.getClipBehavior("clip_behavior", Clip.none)!; @@ -74,9 +73,7 @@ class _ButtonControlState extends State with FletStoreMixin { Function()? onPressed = !widget.control.disabled ? () { - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, widget.control); widget.control.triggerEvent("click"); } : null; diff --git a/packages/flet/lib/src/controls/container.dart b/packages/flet/lib/src/controls/container.dart index 3c1a06670e..e3f6874c8e 100644 --- a/packages/flet/lib/src/controls/container.dart +++ b/packages/flet/lib/src/controls/container.dart @@ -6,12 +6,12 @@ import '../utils/alignment.dart'; import '../utils/animations.dart'; import '../utils/borders.dart'; import '../utils/box.dart'; +import '../utils/client_actions.dart'; import '../utils/colors.dart'; import '../utils/edge_insets.dart'; import '../utils/events.dart'; import '../utils/gradient.dart'; import '../utils/images.dart'; -import '../utils/launch_url.dart'; import '../utils/misc.dart'; import '../utils/numbers.dart'; import '../widgets/flet_store_mixin.dart'; @@ -34,7 +34,7 @@ class ContainerControl extends StatelessWidget with FletStoreMixin { var ink = control.getBool("ink", false)!; var onClick = control.hasEventHandler("click"); var onTapDown = control.hasEventHandler("tap_down"); - var url = control.getUrl("url"); + var hasActions = control.hasControlActions; var onLongPress = control.hasEventHandler("long_press"); var onHover = control.hasEventHandler("hover"); var ignoreInteractions = control.getBool("ignore_interactions", false)!; @@ -68,7 +68,7 @@ class ContainerControl extends StatelessWidget with FletStoreMixin { var onAnimationEnd = control.hasEventHandler("animation_end") ? () => control.triggerEvent("animation_end", "container") : null; - if ((onClick || url != null || onLongPress || onHover || onTapDown) && + if ((onClick || hasActions || onLongPress || onHover || onTapDown) && ink && !control.disabled) { // `padding` and `alignment` are applied inside the `InkWell` only, so that @@ -96,11 +96,9 @@ class ContainerControl extends StatelessWidget with FletStoreMixin { // Dummy callback to enable widget // see https://github.com/flutter/flutter/issues/50116#issuecomment-582047374 // and https://github.com/flutter/flutter/blob/eed80afe2c641fb14b82a22279d2d78c19661787/packages/flutter/lib/src/material/ink_well.dart#L1125-L1129 - onTap: onClick || url != null || onTapDown + onTap: onClick || hasActions || onTapDown ? () { - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, control); if (onClick) { control.triggerEvent("click"); } @@ -168,10 +166,10 @@ class ContainerControl extends StatelessWidget with FletStoreMixin { onEnd: onAnimationEnd, child: content); - if ((onClick || onLongPress || onHover || onTapDown || url != null) && + if ((onClick || onLongPress || onHover || onTapDown || hasActions) && !control.disabled) { container = MouseRegion( - cursor: onClick || onTapDown || url != null + cursor: onClick || onTapDown || hasActions ? SystemMouseCursors.click : MouseCursor.defer, onEnter: onHover @@ -185,11 +183,9 @@ class ContainerControl extends StatelessWidget with FletStoreMixin { } : null, child: GestureDetector( - onTap: onClick || url != null + onTap: onClick || hasActions ? () { - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, control); if (onClick) { control.triggerEvent("click"); } diff --git a/packages/flet/lib/src/controls/cupertino_button.dart b/packages/flet/lib/src/controls/cupertino_button.dart index 11fe836cd3..9d7189328e 100644 --- a/packages/flet/lib/src/controls/cupertino_button.dart +++ b/packages/flet/lib/src/controls/cupertino_button.dart @@ -5,10 +5,10 @@ import '../models/control.dart'; import '../utils/alignment.dart'; import '../utils/borders.dart'; import '../utils/buttons.dart'; +import '../utils/client_actions.dart'; import '../utils/colors.dart'; import '../utils/edge_insets.dart'; import '../utils/geometry.dart'; -import '../utils/launch_url.dart'; import '../utils/mouse.dart'; import '../utils/numbers.dart'; import 'base_controls.dart'; @@ -136,12 +136,9 @@ class _CupertinoButtonControlState extends State { .copyWith(color: color), child: child); } - var url = widget.control.getUrl("url"); Function()? onPressed = !widget.control.disabled ? () { - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, widget.control); widget.control.triggerEvent("click"); } : null; diff --git a/packages/flet/lib/src/controls/cupertino_list_tile.dart b/packages/flet/lib/src/controls/cupertino_list_tile.dart index ebab294b70..eff083e544 100644 --- a/packages/flet/lib/src/controls/cupertino_list_tile.dart +++ b/packages/flet/lib/src/controls/cupertino_list_tile.dart @@ -2,9 +2,9 @@ import 'package:flutter/cupertino.dart'; import '../extensions/control.dart'; import '../models/control.dart'; +import '../utils/client_actions.dart'; import '../utils/colors.dart'; import '../utils/edge_insets.dart'; -import '../utils/launch_url.dart'; import '../utils/numbers.dart'; import '../widgets/error.dart'; import 'base_controls.dart'; @@ -38,17 +38,14 @@ class CupertinoListTileControl extends StatelessWidget { control.getDouble("leading_to_title", notched ? 12.0 : 16.0)!; var onclick = control.hasEventHandler("click"); var toggleInputs = control.getBool("toggle_inputs", false)!; - var url = control.getUrl("url"); - Function()? onPressed = - (onclick || toggleInputs || url != null) && !control.disabled + (onclick || toggleInputs || control.hasControlActions) && + !control.disabled ? () { if (toggleInputs) { _clickNotifier.onClick(); } - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, control); if (onclick) { control.triggerEvent("click"); } diff --git a/packages/flet/lib/src/controls/floating_action_button.dart b/packages/flet/lib/src/controls/floating_action_button.dart index 9c56bc6f46..72d04c615f 100644 --- a/packages/flet/lib/src/controls/floating_action_button.dart +++ b/packages/flet/lib/src/controls/floating_action_button.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import '../extensions/control.dart'; import '../models/control.dart'; import '../utils/borders.dart'; +import '../utils/client_actions.dart'; import '../utils/colors.dart'; -import '../utils/launch_url.dart'; import '../utils/misc.dart'; import '../utils/mouse.dart'; import '../utils/numbers.dart'; @@ -25,7 +25,6 @@ class FloatingActionButtonControl extends StatelessWidget { var content = control.buildTextOrWidget("content"); var icon = control.buildIconOrWidget("icon"); - var url = control.getUrl("url"); var disabledElevation = control.getDouble("disabled_elevation"); var elevation = control.getDouble("elevation"); var hoverElevation = control.getDouble("hover_elevation"); @@ -46,9 +45,7 @@ class FloatingActionButtonControl extends StatelessWidget { Function()? onPressed = control.disabled ? null : () { - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, control); control.triggerEvent("click"); }; diff --git a/packages/flet/lib/src/controls/icon_button.dart b/packages/flet/lib/src/controls/icon_button.dart index ac7e6b1be2..6c9af211f6 100644 --- a/packages/flet/lib/src/controls/icon_button.dart +++ b/packages/flet/lib/src/controls/icon_button.dart @@ -6,10 +6,10 @@ import '../models/control.dart'; import '../utils/alignment.dart'; import '../utils/box.dart'; import '../utils/buttons.dart'; +import '../utils/client_actions.dart'; import '../utils/colors.dart'; import '../utils/edge_insets.dart'; import '../utils/icons.dart'; -import '../utils/launch_url.dart'; import '../utils/mouse.dart'; import '../utils/numbers.dart'; import '../widgets/error.dart'; @@ -96,13 +96,10 @@ class _IconButtonControlState extends State var enableFeedback = widget.control.getBool("enable_feedback", true)!; var selected = widget.control.getBool("selected"); var mouseCursor = widget.control.getMouseCursor("mouse_cursor"); - var url = widget.control.getUrl("url"); Function()? onPressed = !widget.control.disabled ? () { - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, widget.control); widget.control.triggerEvent("click"); } : null; diff --git a/packages/flet/lib/src/controls/list_tile.dart b/packages/flet/lib/src/controls/list_tile.dart index 6aa011e766..359fc493ce 100644 --- a/packages/flet/lib/src/controls/list_tile.dart +++ b/packages/flet/lib/src/controls/list_tile.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import '../extensions/control.dart'; import '../models/control.dart'; import '../utils/borders.dart'; +import '../utils/client_actions.dart'; import '../utils/colors.dart'; import '../utils/edge_insets.dart'; -import '../utils/launch_url.dart'; import '../utils/misc.dart'; import '../utils/mouse.dart'; import '../utils/numbers.dart'; @@ -47,17 +47,14 @@ class ListTileControl extends StatelessWidget with FletStoreMixin { var trailing = control.buildIconOrWidget("trailing"); var onClick = control.hasEventHandler("click"); var toggleInputs = control.getBool("toggle_inputs", false)!; - var url = control.getUrl("url"); - Function()? onPressed = - (onClick || toggleInputs || url != null) && !control.disabled + (onClick || toggleInputs || control.hasControlActions) && + !control.disabled ? () { if (toggleInputs) { _clickNotifier.onClick(); } - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, control); if (onClick) { control.triggerEvent("click"); } diff --git a/packages/flet/lib/src/controls/text.dart b/packages/flet/lib/src/controls/text.dart index d44aaa9e50..3f5c536208 100644 --- a/packages/flet/lib/src/controls/text.dart +++ b/packages/flet/lib/src/controls/text.dart @@ -32,6 +32,7 @@ class TextControl extends StatelessWidget { (Control control, String eventName, [dynamic eventData]) { control.triggerEvent(eventName, eventData); }, + context, ); var semanticsLabel = control.getString("semantics_label"); var noWrap = control.getBool("no_wrap", false)!; diff --git a/packages/flet/lib/src/models/control.dart b/packages/flet/lib/src/models/control.dart index f7a6977a40..1d6f0be53e 100644 --- a/packages/flet/lib/src/models/control.dart +++ b/packages/flet/lib/src/models/control.dart @@ -481,6 +481,15 @@ class Control extends ChangeNotifier { _invokeMethodListeners.remove(listener); } + /// Whether a listener is already registered, i.e. whether [invokeMethod] + /// will dispatch synchronously instead of awaiting one. + /// + /// Callers that must not lose the browser's user activation - see + /// `runClientActions` in `utils/client_actions.dart` - check this first, + /// because the wait in [invokeMethod] is an async gap that silently discards + /// the gesture. + bool get hasInvokeMethodListeners => _invokeMethodListeners.isNotEmpty; + Future invokeMethod( String name, dynamic args, Duration timeout) async { debugPrint("$type($id).$name($args)"); diff --git a/packages/flet/lib/src/services/file_picker.dart b/packages/flet/lib/src/services/file_picker.dart index 1c7c0560e9..a410e76523 100644 --- a/packages/flet/lib/src/services/file_picker.dart +++ b/packages/flet/lib/src/services/file_picker.dart @@ -7,6 +7,8 @@ import '../flet_service.dart'; import '../utils/file_picker.dart'; import '../utils/numbers.dart'; import '../utils/platform.dart'; +import '../utils/platform_utils_web.dart' + if (dart.library.io) '../utils/platform_utils_non_web.dart'; class FilePickerService extends FletService { FilePickerService({required super.control}); @@ -51,6 +53,25 @@ class FilePickerService extends FletService { uploadFiles(files, control.backend.pageUri); } case "pick_files": + var fromGesture = args["_from_gesture"] == true; + + // Not reached from a gesture and the browser has already told us it + // will not open the dialog: fail now with something actionable, rather + // than leaving the caller waiting out its timeout on nothing. + if (!fromGesture && isGestureGatedDialogBlocked()) { + throw Exception( + "This browser only opens a file picker while it is handling a " + "user's click, so pick_files() cannot open one - by the time it " + "reaches your Python code the click is over. Attach " + "ft.PickFiles(...) to the control's `action` property instead " + "and handle FilePicker.on_result. " + "See https://flet.dev/docs/cookbook/client-actions"); + } + + // Note for future edits: nothing may be awaited between entering this + // method and the FilePicker.pickFiles() call below. On the gesture + // path an async gap discards the browser's user activation and the + // dialog silently never opens. See flet-dev/flet#3710. _files = (await FilePicker.pickFiles( dialogTitle: dialogTitle, initialDirectory: initialDirectory, @@ -63,7 +84,7 @@ class FilePickerService extends FletService { withReadStream: !withData, cancelUploadOnWindowBlur: cancelUploadOnWindowBlur)) ?.files; - return _files != null + var pickedFiles = _files != null ? _files!.asMap().entries.map((file) { return FilePickerFile( id: file.key, // use entry's index as id @@ -74,6 +95,11 @@ class FilePickerService extends FletService { .toMap(); }).toList() : []; + if (fromGesture) { + // A gesture-triggered pick has no caller to return to. + control.triggerEvent("result", {"files": pickedFiles}); + } + return pickedFiles; case "save_file": if ((kIsWeb || isAndroidMobile() || isIOSMobile()) && srcBytes == null) { diff --git a/packages/flet/lib/src/utils/client_actions.dart b/packages/flet/lib/src/utils/client_actions.dart new file mode 100644 index 0000000000..bf064de0d8 --- /dev/null +++ b/packages/flet/lib/src/utils/client_actions.dart @@ -0,0 +1,75 @@ +import 'package:flutter/widgets.dart'; + +import '../flet_backend.dart'; +import '../models/control.dart'; +import 'launch_url.dart'; + +/// Client actions can run for as long as the user keeps a native dialog open, +/// so the invoke-method timeout has to be generous rather than interactive. +const _kClientActionTimeout = Duration(hours: 1); + +/// Runs the client actions declared on a control, by invoking the target +/// service's method directly on this client. +/// +/// MUST be called synchronously from a gesture callback, and its result MUST +/// NOT be awaited. Browsers - WebKit strictly, others more leniently - permit +/// gesture-gated APIs such as opening a file picker, writing to the clipboard, +/// `navigator.share` and `window.open` only while user activation is live, and +/// activation does not survive an async gap. Awaiting anything before the +/// service method is entered silently breaks every one of them on iOS Safari +/// while leaving Android and desktop working. See flet-dev/flet#3710. +/// +/// For the same reason each targeted service's `_invokeMethod` must reach its +/// gated call before its own first `await`. +void runClientActions(BuildContext context, dynamic actions) { + if (actions == null) return; + var backend = FletBackend.of(context); + for (var action in actions is List ? actions : [actions]) { + if (action is! Map) continue; + var serviceId = action["service_id"]; + var method = action["method"]; + var service = backend.controlsIndex.get(serviceId); + + // Skip rather than await: invokeMethod() waits for a listener when the + // service is not mounted yet, and that wait would consume the gesture and + // leave the action failing silently. + if (service == null || !service.hasInvokeMethodListeners) { + debugPrint( + "Client action target is not available: $method on service $serviceId"); + continue; + } + + // Services distinguish the two entry points: reaching them from a gesture + // is what makes gesture-gated APIs work, and a result cannot be returned + // to a caller that does not exist, so services report it as an event. + var args = {...?(action["args"] as Map?), "_from_gesture": true}; + + service + .invokeMethod(method, args, _kClientActionTimeout) + .catchError((e) => debugPrint("Client action $method failed: $e")); + } +} + +/// Runs everything a control performs on the client when it is activated: its +/// `url`, then its `action`s, in that order. +/// +/// Call this from the control's tap/press callback in place of handling `url` +/// separately. The same synchronous-call rule as [runClientActions] applies - +/// `url` is subject to it too, since opening a new tab is gesture-gated as +/// well. +void runControlActions(BuildContext context, Control control) { + var url = control.getUrl("url"); + if (url != null) { + openWebBrowser(url); + } + runClientActions(context, control.get("action")); +} + +extension ClientActionParsers on Control { + /// Whether this control performs anything on the client when it is + /// activated - a `url` to open, or one or more `action`s. + /// + /// Controls that stay untappable unless something is wired to them use this + /// to decide whether to install a tap handler at all. + bool get hasControlActions => get("url") != null || get("action") != null; +} diff --git a/packages/flet/lib/src/utils/platform_utils_non_web.dart b/packages/flet/lib/src/utils/platform_utils_non_web.dart index 1a2d3edb5b..5fadf78d01 100644 --- a/packages/flet/lib/src/utils/platform_utils_non_web.dart +++ b/packages/flet/lib/src/utils/platform_utils_non_web.dart @@ -30,3 +30,7 @@ Map getViewInitialData(int viewId) { void openPopupBrowserWindow( String url, String windowName, int minWidth, int minHeight) {} + +bool isGestureGatedDialogBlocked() { + return false; +} diff --git a/packages/flet/lib/src/utils/platform_utils_web.dart b/packages/flet/lib/src/utils/platform_utils_web.dart index 235d360650..ba22660746 100644 --- a/packages/flet/lib/src/utils/platform_utils_web.dart +++ b/packages/flet/lib/src/utils/platform_utils_web.dart @@ -77,3 +77,20 @@ void openPopupBrowserWindow( web.window.open(url, windowName, "top=$top,left=$left,width=$width,height=$height,scrollbars=yes"); } + +/// Whether the browser will refuse to open a gesture-gated dialog, such as a +/// file picker, right now. +/// +/// Only reports `true` when that is certain: an Apple (WebKit) browser that +/// says no user activation is live. Chrome and Firefox let a page click a file +/// input outside a gesture, so they are never reported as blocked - guessing +/// there would break apps that work today. +bool isGestureGatedDialogBlocked() { + try { + if (!web.window.navigator.vendor.startsWith("Apple")) return false; + return !web.window.navigator.userActivation.isActive; + } catch (_) { + // userActivation is unsupported on older browsers - never guess. + return false; + } +} diff --git a/packages/flet/lib/src/utils/text.dart b/packages/flet/lib/src/utils/text.dart index 8a3ad5a567..b9700b2f3d 100644 --- a/packages/flet/lib/src/utils/text.dart +++ b/packages/flet/lib/src/utils/text.dart @@ -7,6 +7,7 @@ import '../models/control.dart'; import '../utils/box.dart'; import '../utils/drawing.dart'; import '../utils/numbers.dart'; +import 'client_actions.dart'; import 'colors.dart'; import 'enums.dart'; import 'launch_url.dart'; @@ -53,15 +54,17 @@ FontWeight? parseFontWeight(String? weightName, [FontWeight? defaultWeight]) { } List parseTextSpans(List spans, ThemeData theme, - [void Function(Control, String, [dynamic eventData])? sendControlEvent]) { + [void Function(Control, String, [dynamic eventData])? sendControlEvent, + BuildContext? context]) { return spans - .map((span) => parseInlineSpan(span, theme, sendControlEvent)) + .map((span) => parseInlineSpan(span, theme, sendControlEvent, context)) .nonNulls .toList(); } TextSpan? parseInlineSpan(Control span, ThemeData theme, - [void Function(Control, String, [dynamic eventData])? sendControlEvent]) { + [void Function(Control, String, [dynamic eventData])? sendControlEvent, + BuildContext? context]) { span.notifyParent = true; var onClick = span.hasEventHandler("click"); var url = span.getUrl("url"); @@ -71,18 +74,25 @@ TextSpan? parseInlineSpan(Control span, ThemeData theme, style: span.getTextStyle("style", theme), spellOut: span.getBool("spell_out"), semanticsLabel: span.getString("semantics_label"), - children: parseTextSpans(span.children("spans"), theme, sendControlEvent), + children: parseTextSpans( + span.children("spans"), theme, sendControlEvent, context), mouseCursor: onClick && !span.disabled && sendControlEvent != null ? SystemMouseCursors.click : null, - recognizer: - (onClick || url != null) && !span.disabled && sendControlEvent != null - ? (TapGestureRecognizer() - ..onTap = () { - if (url != null) openWebBrowser(url); - if (onClick) sendControlEvent(span, "click"); - }) - : null, + recognizer: (onClick || span.hasControlActions) && + !span.disabled && + sendControlEvent != null + ? (TapGestureRecognizer() + ..onTap = () { + if (url != null) openWebBrowser(url); + // Spans rendered outside a widget tree (e.g. on a Canvas) have no + // context to resolve the target service from; only `url` works there. + if (context != null) { + runClientActions(context, span.get("action")); + } + if (onClick) sendControlEvent(span, "click"); + }) + : null, onEnter: span.hasEventHandler("enter") && !span.disabled && sendControlEvent != null diff --git a/sdk/python/examples/services/clipboard/copy_action/main.py b/sdk/python/examples/services/clipboard/copy_action/main.py new file mode 100644 index 0000000000..2bf4325a23 --- /dev/null +++ b/sdk/python/examples/services/clipboard/copy_action/main.py @@ -0,0 +1,51 @@ +import flet as ft + + +def main(page: ft.Page): + # `action` is performed by the client while it is still handling the click. + # That is the only moment Safari lets a page write to the clipboard, which + # is why `Clipboard().set()` - which has to reach Python first - does + # nothing on iOS. + token = "flet-1234-5678" + + def handle_copied(e): + page.show_dialog(ft.SnackBar(ft.Text("Copied to clipboard"))) + + page.add( + ft.SafeArea( + content=ft.Column( + controls=[ + ft.Text(f"Token: {token}", selectable=True), + ft.Button( + "Copy token", + icon=ft.Icons.CONTENT_COPY, + action=ft.CopyToClipboard(token), + on_click=handle_copied, + ), + ft.Divider(), + ft.Text( + "An action's arguments are fixed before the click, so " + "to copy something typed just now, update the action " + "as the text changes." + ), + note := ft.TextField( + label="Note", + value="Edit me, then copy", + on_change=lambda e: setattr( + copy_note, "action", ft.CopyToClipboard(note.value) + ), + ), + copy_note := ft.Button( + "Copy note", + icon=ft.Icons.CONTENT_COPY, + action=ft.CopyToClipboard("Edit me, then copy"), + on_click=handle_copied, + ), + ], + ), + ) + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/services/clipboard/copy_action/pyproject.toml b/sdk/python/examples/services/clipboard/copy_action/pyproject.toml new file mode 100644 index 0000000000..a02a1e944c --- /dev/null +++ b/sdk/python/examples/services/clipboard/copy_action/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "copy-action" +version = "1.0.0" +description = "Copies text to the clipboard using a client action, which is the only form that works on iOS Safari." +requires-python = ">=3.10" +keywords = ["clipboard", "clientaction", "basic", "services"] +authors = [{ name = "Flet team", email = "hello@flet.dev" }] +dependencies = ["flet"] + +[dependency-groups] +dev = ["flet-cli", "flet-desktop", "flet-web"] + +[tool.flet.gallery] +categories = ["Utility/Clipboard"] + +[tool.flet.metadata] +title = "Copy to clipboard action" +controls = ["SafeArea", "Column", "Page", "Button", "Text", "TextField", "CopyToClipboard", "SnackBar", "Divider"] +layout_pattern = "inline-actions" +complexity = "basic" +features = ["client actions", "clipboard"] + +[tool.flet] +org = "dev.flet" +company = "Flet" +copyright = "Copyright (C) 2023-2026 by Flet" diff --git a/sdk/python/examples/services/file_picker/pick_files_action/main.py b/sdk/python/examples/services/file_picker/pick_files_action/main.py new file mode 100644 index 0000000000..fed1f5655d --- /dev/null +++ b/sdk/python/examples/services/file_picker/pick_files_action/main.py @@ -0,0 +1,87 @@ +# +# Picking and uploading files in a way that also works in a web app on iOS. +# +# Run this example with: +# export FLET_SECRET_KEY= +# uv run flet run --web examples/services/file_picker/pick_files_action/main.py +# +from dataclasses import dataclass, field + +import flet as ft + + +@dataclass +class State: + picked_files: list[ft.FilePickerFile] = field(default_factory=list) + + +state = State() + + +def main(page: ft.Page): + prog_bars: dict[str, ft.ProgressRing] = {} + + def handle_upload_progress(e: ft.FilePickerUploadEvent): + prog_bars[e.file_name].value = e.progress + + def handle_result(e: ft.FilePickerResultEvent): + # A PickFiles action opens the dialog before Python sees the click, so + # the selection arrives here instead of being returned to a caller. + state.picked_files = e.files + + prog_bars.clear() + upload_progress.controls.clear() + for f in e.files: + prog = ft.ProgressRing(value=0, bgcolor="#eeeeee", width=20, height=20) + prog_bars[f.name] = prog + upload_progress.controls.append( + ft.Row([prog, ft.Text(f"{f.name} ({f.size} bytes)")]) + ) + upload_button.disabled = len(e.files) == 0 + + async def handle_file_upload(e: ft.Event[ft.Button]): + upload_button.disabled = True + # The picked files stay on the FilePicker, so upload() takes them as-is. + await file_picker.upload( + files=[ + ft.FilePickerUploadFile( + name=file.name, + upload_url=page.get_upload_url(f"dir/{file.name}", 60), + ) + for file in state.picked_files + ] + ) + + file_picker = ft.FilePicker( + on_result=handle_result, + on_upload=handle_upload_progress, + ) + page.services.append(file_picker) + + page.add( + ft.SafeArea( + content=ft.Column( + controls=[ + ft.Button( + content="Select files...", + icon=ft.Icons.FOLDER_OPEN, + # Attaching the pick to the control - rather than + # calling file_picker.pick_files() from an on_click + # handler - is what makes the dialog open on iOS. + action=ft.PickFiles(file_picker, allow_multiple=True), + ), + upload_progress := ft.Column(), + upload_button := ft.Button( + content="Upload", + icon=ft.Icons.UPLOAD, + on_click=handle_file_upload, + disabled=True, + ), + ], + ), + ) + ) + + +if __name__ == "__main__": + ft.run(main, upload_dir="examples") diff --git a/sdk/python/examples/services/file_picker/pick_files_action/pyproject.toml b/sdk/python/examples/services/file_picker/pick_files_action/pyproject.toml new file mode 100644 index 0000000000..0c730aafdb --- /dev/null +++ b/sdk/python/examples/services/file_picker/pick_files_action/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "pick-files-action" +version = "1.0.0" +description = "Picks and uploads files using a client action, so the dialog also opens in a web app on iOS." +requires-python = ">=3.10" +keywords = ["filepicker", "clientaction", "upload", "services"] +authors = [{ name = "Flet team", email = "hello@flet.dev" }] +dependencies = ["flet"] + +[dependency-groups] +dev = ["flet-cli", "flet-desktop", "flet-web"] + +[tool.flet.gallery] +categories = ["Utility/FilePicker"] + +[tool.flet.metadata] +title = "Pick files action" +controls = ["SafeArea", "Column", "Row", "Page", "Button", "Text", "ProgressRing", "FilePicker", "PickFiles", "FilePickerUploadFile"] +layout_pattern = "inline-actions" +complexity = "intermediate" +features = ["client actions", "file picking", "file upload"] + +[tool.flet] +org = "dev.flet" +company = "Flet" +copyright = "Copyright (C) 2023-2026 by Flet" diff --git a/sdk/python/examples/services/share/share_text_action/main.py b/sdk/python/examples/services/share/share_text_action/main.py new file mode 100644 index 0000000000..43dea94c27 --- /dev/null +++ b/sdk/python/examples/services/share/share_text_action/main.py @@ -0,0 +1,34 @@ +import flet as ft + + +def main(page: ft.Page): + # `action` is performed by the client while it is still handling the click. + # Browsers only open the share sheet during a gesture, so `Share()` called + # from Python has no effect on the web. + page.add( + ft.SafeArea( + content=ft.Column( + controls=[ + ft.Text("Share this page with someone:"), + ft.Button( + "Share", + icon=ft.Icons.SHARE, + action=ft.ShareText( + "Flet lets you build multi-platform apps in Python: " + "https://flet.dev", + subject="Flet", + ), + ), + ft.Text( + "The share sheet is a system dialog - what it offers " + "depends on the platform, and on desktop browsers it " + "may not be available at all." + ), + ], + ), + ) + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/services/share/share_text_action/pyproject.toml b/sdk/python/examples/services/share/share_text_action/pyproject.toml new file mode 100644 index 0000000000..0630da327c --- /dev/null +++ b/sdk/python/examples/services/share/share_text_action/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "share-text-action" +version = "1.0.0" +description = "Opens the platform share sheet from a button using a client action." +requires-python = ">=3.10" +keywords = ["share", "clientaction", "basic", "services"] +authors = [{ name = "Flet team", email = "hello@flet.dev" }] +dependencies = ["flet"] + +[dependency-groups] +dev = ["flet-cli", "flet-desktop", "flet-web"] + +[tool.flet.gallery] +categories = ["Utility/Share"] + +[tool.flet.metadata] +title = "Share text action" +controls = ["SafeArea", "Column", "Page", "Button", "Text", "ShareText"] +layout_pattern = "inline-actions" +complexity = "basic" +features = ["client actions", "share sheet"] + +[tool.flet] +org = "dev.flet" +company = "Flet" +copyright = "Copyright (C) 2023-2026 by Flet" diff --git a/sdk/python/examples/services/url_launcher/open_url_action/main.py b/sdk/python/examples/services/url_launcher/open_url_action/main.py new file mode 100644 index 0000000000..3d31ec47d3 --- /dev/null +++ b/sdk/python/examples/services/url_launcher/open_url_action/main.py @@ -0,0 +1,43 @@ +import flet as ft + + +def main(page: ft.Page): + # `action` is performed by the client while it is still handling the click, + # so opening a new tab is not treated as an unsolicited popup. Compare with + # `UrlLauncher().launch_url()`, which has to reach Python first and is + # therefore blocked by Safari on iOS. + page.add( + ft.SafeArea( + content=ft.Column( + controls=[ + ft.Text("Both buttons open the same page:"), + ft.Button( + "Open in this tab", + action=ft.OpenUrl("https://flet.dev", target=ft.UrlTarget.SELF), + ), + ft.Button( + "Open in a new tab", + action=ft.OpenUrl( + "https://flet.dev", + target=ft.UrlTarget.BLANK, + ), + ), + ft.Text( + "An action can be combined with on_click - the action " + "runs on the client, then your handler runs in Python." + ), + ft.Button( + "Open and log", + action=ft.OpenUrl("https://flet.dev/docs"), + on_click=lambda e: page.show_dialog( + ft.SnackBar(ft.Text("Docs opened")) + ), + ), + ], + ), + ) + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/services/url_launcher/open_url_action/pyproject.toml b/sdk/python/examples/services/url_launcher/open_url_action/pyproject.toml new file mode 100644 index 0000000000..3990525752 --- /dev/null +++ b/sdk/python/examples/services/url_launcher/open_url_action/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "open-url-action" +version = "1.0.0" +description = "Opens URLs from a button using a client action, so new tabs are not blocked on iOS Safari." +requires-python = ">=3.10" +keywords = ["urllauncher", "clientaction", "basic", "services"] +authors = [{ name = "Flet team", email = "hello@flet.dev" }] +dependencies = ["flet"] + +[dependency-groups] +dev = ["flet-cli", "flet-desktop", "flet-web"] + +[tool.flet.gallery] +categories = ["Utility/UrlLauncher", "Navigation/Routing"] + +[tool.flet.metadata] +title = "Open URL action" +controls = ["SafeArea", "Column", "Page", "Button", "Text", "OpenUrl", "UrlTarget", "SnackBar"] +layout_pattern = "inline-actions" +complexity = "basic" +features = ["client actions", "URL launching"] + +[tool.flet] +org = "dev.flet" +company = "Flet" +copyright = "Copyright (C) 2023-2026 by Flet" diff --git a/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py b/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py index 1bbde15bde..d81b806a67 100644 --- a/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py +++ b/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py @@ -18,10 +18,10 @@ from flet.controls.exceptions import FletPageDisconnectedException from flet.messaging.connection import Connection from flet.messaging.protocol import ( - ClientAction, - ClientMessage, ControlEventBody, InvokeMethodResponseBody, + Message, + MessageAction, RegisterClientRequestBody, RegisterClientResponseBody, UpdateControlPropsBody, @@ -259,7 +259,7 @@ async def __receive_loop(self): async def __on_message(self, data: Any): """ Handle one decoded client message and dispatch - by `ClientAction`. + by `MessageAction`. Args: data: Decoded message payload from msgpack transport. @@ -268,11 +268,11 @@ async def __on_message(self, data: Any): RuntimeError: If message action is unknown. """ - action = ClientAction(data[0]) + action = MessageAction(data[0]) body = data[1] transport_log.debug(f"_on_message: {action} {body}") task = None - if action == ClientAction.REGISTER_CLIENT: + if action == MessageAction.REGISTER_CLIENT: req = RegisterClientRequestBody(**body) new_session = False @@ -335,8 +335,8 @@ async def __on_message(self, data: Any): # register response self.send_message( - ClientMessage( - ClientAction.REGISTER_CLIENT, + Message( + MessageAction.REGISTER_CLIENT, RegisterClientResponseBody( session_id=self.__session.id, page_patch=self.__session.get_page_patch() @@ -379,17 +379,17 @@ async def __on_message(self, data: Any): } ) - elif action == ClientAction.CONTROL_EVENT: + elif action == MessageAction.CONTROL_EVENT: req = ControlEventBody(**body) task = asyncio.create_task( self.__session.dispatch_event(req.target, req.name, req.data) ) - elif action == ClientAction.UPDATE_CONTROL_PROPS: + elif action == MessageAction.UPDATE_CONTROL_PROPS: req = UpdateControlPropsBody(**body) self.__session.apply_patch(req.id, req.props) - elif action == ClientAction.INVOKE_METHOD: + elif action == MessageAction.INVOKE_METHOD: req = InvokeMethodResponseBody(**body) self.__session.handle_invoke_method_results( req.control_id, req.call_id, req.result, req.error @@ -403,7 +403,7 @@ async def __on_message(self, data: Any): self.__running_tasks.add(task) task.add_done_callback(self.__running_tasks.discard) - def send_message(self, message: ClientMessage): + def send_message(self, message: Message): """ Serialize and enqueue a server message for transport to the client. diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index af47eece7e..973f255ca9 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -54,6 +54,7 @@ margin, padding, ) + from flet.controls.action_control import ActionControl from flet.controls.adaptive_control import AdaptiveControl from flet.controls.alignment import ( Alignment, @@ -114,6 +115,7 @@ ShapeBorder, StadiumBorder, ) + from flet.controls.client_action import ClientAction from flet.controls.colors import Colors from flet.controls.context import ( Context, @@ -558,7 +560,7 @@ BatteryStateChangeEvent, ) from flet.controls.services.browser_context_menu import BrowserContextMenu - from flet.controls.services.clipboard import Clipboard + from flet.controls.services.clipboard import Clipboard, CopyToClipboard from flet.controls.services.connectivity import ( Connectivity, ConnectivityChangeEvent, @@ -568,8 +570,10 @@ FilePicker, FilePickerFile, FilePickerFileType, + FilePickerResultEvent, FilePickerUploadEvent, FilePickerUploadFile, + PickFiles, ) from flet.controls.services.gyroscope import ( Gyroscope, @@ -597,12 +601,14 @@ ShareFile, ShareResult, ShareResultStatus, + ShareText, ) from flet.controls.services.shared_preferences import SharedPreferences from flet.controls.services.storage_paths import StoragePaths from flet.controls.services.url_launcher import ( BrowserConfiguration, LaunchMode, + OpenUrl, UrlLauncher, WebViewConfiguration, ) @@ -730,6 +736,7 @@ __all__ = [ "Accelerometer", "AccelerometerReadingEvent", + "ActionControl", "AdaptiveControl", "AlertDialog", "Alignment", @@ -807,6 +814,7 @@ "CircleAvatar", "CircleBorder", "CircularRectangleNotchShape", + "ClientAction", "ClipBehavior", "Clipboard", "ColorFilter", @@ -831,6 +839,7 @@ "ControlEventHandler", "ControlState", "ControlStateValue", + "CopyToClipboard", "CrossAxisAlignment", "CupertinoActionSheet", "CupertinoActionSheetAction", @@ -914,6 +923,7 @@ "FilePicker", "FilePickerFile", "FilePickerFileType", + "FilePickerResultEvent", "FilePickerUploadEvent", "FilePickerUploadFile", "FilledButton", @@ -1028,6 +1038,7 @@ "OffsetValue", "OnReorderEvent", "OnScrollEvent", + "OpenUrl", "Orientation", "OutlineInputBorder", "OutlinedBorder", @@ -1051,6 +1062,7 @@ "PaintRadialGradient", "PaintSweepGradient", "PaintingStyle", + "PickFiles", "Placeholder", "PlatformBrightnessChangeEvent", "PointerDeviceType", @@ -1124,6 +1136,7 @@ "ShareFile", "ShareResult", "ShareResultStatus", + "ShareText", "SharedPreferences", "Shimmer", "ShimmerDirection", @@ -1263,6 +1276,7 @@ _LAZY = { "Accelerometer": "flet.controls.services.accelerometer", "AccelerometerReadingEvent": "flet.controls.services.accelerometer", + "ActionControl": "flet.controls.action_control", "AdaptiveControl": "flet.controls.adaptive_control", "AlertDialog": "flet.controls.material.alert_dialog", "Alignment": "flet.controls.alignment", @@ -1340,6 +1354,7 @@ "CircleAvatar": "flet.controls.material.circle_avatar", "CircleBorder": "flet.controls.buttons", "CircularRectangleNotchShape": "flet.controls.types", + "ClientAction": "flet.controls.client_action", "ClipBehavior": "flet.controls.types", "Clipboard": "flet.controls.services.clipboard", "ColorFilter": "flet.controls.box", @@ -1364,6 +1379,7 @@ "ControlEventHandler": "flet.controls.control_event", "ControlState": "flet.controls.control_state", "ControlStateValue": "flet.controls.control_state", + "CopyToClipboard": "flet.controls.services.clipboard", "CrossAxisAlignment": "flet.controls.types", "CupertinoActionSheet": "flet.controls.cupertino.cupertino_action_sheet", "CupertinoActionSheetAction": "flet.controls.cupertino.cupertino_action_sheet_action", # noqa: E501 @@ -1447,6 +1463,7 @@ "FilePicker": "flet.controls.services.file_picker", "FilePickerFile": "flet.controls.services.file_picker", "FilePickerFileType": "flet.controls.services.file_picker", + "FilePickerResultEvent": "flet.controls.services.file_picker", "FilePickerUploadEvent": "flet.controls.services.file_picker", "FilePickerUploadFile": "flet.controls.services.file_picker", "FilledButton": "flet.controls.material.filled_button", @@ -1561,6 +1578,7 @@ "OffsetValue": "flet.controls.transform", "OnReorderEvent": "flet.controls.material.reorderable_list_view", "OnScrollEvent": "flet.controls.scrollable_control", + "OpenUrl": "flet.controls.services.url_launcher", "Orientation": "flet.controls.types", "OutlineInputBorder": "flet.controls.material.form_field_control", "OutlinedBorder": "flet.controls.buttons", @@ -1584,6 +1602,7 @@ "PaintRadialGradient": "flet.controls.painting", "PaintSweepGradient": "flet.controls.painting", "PaintingStyle": "flet.controls.painting", + "PickFiles": "flet.controls.services.file_picker", "Placeholder": "flet.controls.core.placeholder", "PlatformBrightnessChangeEvent": "flet.controls.page", "PointerDeviceType": "flet.controls.types", @@ -1657,6 +1676,7 @@ "ShareFile": "flet.controls.services.share", "ShareResult": "flet.controls.services.share", "ShareResultStatus": "flet.controls.services.share", + "ShareText": "flet.controls.services.share", "SharedPreferences": "flet.controls.services.shared_preferences", "Shimmer": "flet.controls.core.shimmer", "ShimmerDirection": "flet.controls.core.shimmer", diff --git a/sdk/python/packages/flet/src/flet/controls/action_control.py b/sdk/python/packages/flet/src/flet/controls/action_control.py new file mode 100644 index 0000000000..a44473610b --- /dev/null +++ b/sdk/python/packages/flet/src/flet/controls/action_control.py @@ -0,0 +1,38 @@ +from typing import Optional, Union + +from flet.controls.base_control import control +from flet.controls.client_action import ClientAction +from flet.controls.control import Control + +__all__ = ["ActionControl"] + + +@control(kw_only=True) +class ActionControl(Control): + """ + Base class for controls that can perform :class:`~flet.ClientAction` work when + they are activated. + + Browsers only allow a page to open a file picker, write to the clipboard, show + a share sheet or open a new tab while they are handling the user's click or key + press. Sending that click to your Python code and acting on the reply takes + longer than the permission lasts, so those operations are silently ignored on + iOS Safari while Android and desktop browsers let them through. Controls + inheriting from this class accept an :attr:`action`, which the client performs + inside the original gesture instead. + + Extension developers can inherit from this class to give their own controls the + same capability; the client runs whatever actions the control declares before + the control's own click event is dispatched. + """ + + action: Optional[Union[ClientAction, list[ClientAction]]] = None + """ + Action(s) performed by the client when this control is activated, without a + round trip to your Python code. + + Use this for operations a browser only permits while it is handling the + user's click, such as opening a file picker, writing to the clipboard, + showing a share sheet or opening a new tab. + See :class:`~flet.ClientAction`. + """ diff --git a/sdk/python/packages/flet/src/flet/controls/client_action.py b/sdk/python/packages/flet/src/flet/controls/client_action.py new file mode 100644 index 0000000000..c389ff2f32 --- /dev/null +++ b/sdk/python/packages/flet/src/flet/controls/client_action.py @@ -0,0 +1,118 @@ +from dataclasses import MISSING, dataclass, field +from typing import Any, Optional, TypeVar +from weakref import WeakKeyDictionary + +from flet.controls.context import context +from flet.controls.services.service import Service + +__all__ = ["ClientAction"] + +S = TypeVar("S", bound=Service) + + +_shared_services: "WeakKeyDictionary[Any, dict[type, Service]]" = WeakKeyDictionary() +""" +Per-page cache of the services client actions target, keyed weakly so it does +not keep a finished page alive. + +Deliberately not stored on the page itself: `BaseControl._internals` is sent to +the client (it is how `Button` ships its resolved style), and a cache keyed by +class would put a class object on the wire. +""" + + +def shared_service(service_type: type[S]) -> S: + """ + Returns the page's single instance of `service_type`, creating it on first use. + + Internal, but shared: each action lives in its own service's module and calls + this to reach a service the user never instantiated. + + Client actions are attached to controls, so a page can easily hold dozens of + them. Instantiating a service per action would register a service per action + with the client for no benefit, since these services carry no per-action + state. + """ + page = context.page + services = _shared_services.setdefault(page, {}) + service = services.get(service_type) + if service is None: + service = service_type() + services[service_type] = service + return service + + +def action_field(default: Any = MISSING): + """ + Declares a field that configures an action but is not sent to the client. + + Internal, but shared: used by the `ClientAction` subclasses, which live in + the modules of the services they drive. + + Only `service_id`, `method` and `args` cross the wire; the properties users + set are kept readable on the Python object without duplicating them into the + payload. Omit `default` to make the field required. + """ + if default is MISSING: + return field(metadata={"skip": True}) + return field(default=default, metadata={"skip": True}) + + +@dataclass +class ClientAction: + """ + Base class for actions performed by the client, without a round trip to your + Python code. + + Assign one - or a list of them - to the `action` property of a control such + as :class:`~flet.Button`, and it runs the moment the control is activated. + + Browsers only allow a page to open a file picker, write to the clipboard, + show a share sheet or open a new tab while the user's click or key press is + still being handled. Sending the click to your Python code and acting on the + reply takes longer than that permission lasts, so on iOS Safari those + operations are silently ignored, while Android and desktop browsers let them + through. Client actions close that gap by performing the operation on the + client, inside the original gesture. + + Because an action runs before your code sees the click, its arguments have to + be known in advance. To act on a value that is only computed at click time, + set it on the control ahead of the click instead. + + Note: + Actions are not constructed directly - use one of the subclasses, each of + which lives alongside the service it drives: + :class:`~flet.OpenUrl`, :class:`~flet.CopyToClipboard`, + :class:`~flet.PickFiles` and :class:`~flet.ShareText`. + """ + + service_id: int = field(init=False, default=0) + """ + Internal id of the service that performs this action on the client. + """ + + method: str = field(init=False, default="") + """ + Name of the service method invoked on the client. + """ + + args: dict[str, Any] = field(init=False, default_factory=dict) + """ + Arguments passed to :attr:`method`. + """ + + _service: Optional[Service] = field( + init=False, default=None, repr=False, metadata={"skip": True} + ) + + def _bind(self, service: Service, method: str, args: dict[str, Any]) -> None: + """ + Targets this action at `method` of `service`. + + Holding on to `service` matters: the page drops services that nothing + references any more, and an action outlives the call that created it. + """ + self._service = service + self.service_id = service._i + self.method = method + self.args = {k: v for k, v in args.items() if v is not None} diff --git a/sdk/python/packages/flet/src/flet/controls/core/text_span.py b/sdk/python/packages/flet/src/flet/controls/core/text_span.py index b912b308c6..29bf2191f1 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/text_span.py +++ b/sdk/python/packages/flet/src/flet/controls/core/text_span.py @@ -1,7 +1,7 @@ from typing import Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.base_control import control -from flet.controls.control import Control from flet.controls.control_event import ControlEventHandler from flet.controls.text_style import TextStyle from flet.controls.types import Url @@ -11,7 +11,7 @@ @control("TextSpan") -class TextSpan(Control): +class TextSpan(ActionControl): """ A text span. diff --git a/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_button.py b/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_button.py index 06e6ce8deb..eeb2e21faa 100644 --- a/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_button.py +++ b/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_button.py @@ -2,6 +2,7 @@ from enum import Enum from typing import Annotated, Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.alignment import Alignment from flet.controls.base_control import control from flet.controls.border_radius import BorderRadius, BorderRadiusValue @@ -46,7 +47,7 @@ class CupertinoButtonSize(Enum): @control("CupertinoButton") -class CupertinoButton(LayoutControl): +class CupertinoButton(LayoutControl, ActionControl): """ An iOS-style button. diff --git a/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_list_tile.py b/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_list_tile.py index 7ff3a4c481..cbbabc21fd 100644 --- a/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_list_tile.py +++ b/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_list_tile.py @@ -1,5 +1,6 @@ from typing import Annotated, Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.base_control import control from flet.controls.control_event import ControlEventHandler from flet.controls.layout_control import LayoutControl @@ -17,7 +18,7 @@ @control("CupertinoListTile") -class CupertinoListTile(LayoutControl): +class CupertinoListTile(LayoutControl, ActionControl): """ An iOS-style list tile. diff --git a/sdk/python/packages/flet/src/flet/controls/material/button.py b/sdk/python/packages/flet/src/flet/controls/material/button.py index 077fcf8969..bc13a6aa3b 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/button.py @@ -1,6 +1,7 @@ from dataclasses import field from typing import Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.adaptive_control import AdaptiveControl from flet.controls.base_control import control from flet.controls.buttons import ButtonStyle @@ -22,7 +23,7 @@ @control("Button") -class Button(LayoutControl, AdaptiveControl): +class Button(LayoutControl, AdaptiveControl, ActionControl): """ A material button. diff --git a/sdk/python/packages/flet/src/flet/controls/material/container.py b/sdk/python/packages/flet/src/flet/controls/material/container.py index b46ba5c27f..9fded128a4 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/container.py +++ b/sdk/python/packages/flet/src/flet/controls/material/container.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.adaptive_control import AdaptiveControl from flet.controls.alignment import Alignment from flet.controls.animation import AnimationValue @@ -35,7 +36,7 @@ @control("Container") -class Container(LayoutControl, AdaptiveControl): +class Container(LayoutControl, AdaptiveControl, ActionControl): """ Allows to decorate a control with background color and border and position it with \ padding, margin and alignment. diff --git a/sdk/python/packages/flet/src/flet/controls/material/floating_action_button.py b/sdk/python/packages/flet/src/flet/controls/material/floating_action_button.py index 1e6270b77c..028447f463 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/floating_action_button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/floating_action_button.py @@ -1,5 +1,6 @@ from typing import Annotated, Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.base_control import control from flet.controls.buttons import OutlinedBorder from flet.controls.control import Control @@ -21,7 +22,7 @@ @control("FloatingActionButton") -class FloatingActionButton(LayoutControl): +class FloatingActionButton(LayoutControl, ActionControl): """ A floating action button is a circular icon button that hovers over content to \ promote a primary action in the application. Floating action button is usually set \ diff --git a/sdk/python/packages/flet/src/flet/controls/material/icon_button.py b/sdk/python/packages/flet/src/flet/controls/material/icon_button.py index fd5998713e..e4da50426f 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/icon_button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/icon_button.py @@ -1,6 +1,7 @@ from dataclasses import field from typing import Annotated, Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.adaptive_control import AdaptiveControl from flet.controls.alignment import Alignment from flet.controls.base_control import control @@ -28,7 +29,7 @@ @control("IconButton") -class IconButton(LayoutControl, AdaptiveControl): +class IconButton(LayoutControl, AdaptiveControl, ActionControl): """ An icon button is a round button with an icon in the middle that reacts to touches \ by filling with color (ink). diff --git a/sdk/python/packages/flet/src/flet/controls/material/list_tile.py b/sdk/python/packages/flet/src/flet/controls/material/list_tile.py index 035cc146bd..5bd3973c70 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/list_tile.py +++ b/sdk/python/packages/flet/src/flet/controls/material/list_tile.py @@ -1,6 +1,7 @@ from enum import Enum from typing import Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.adaptive_control import AdaptiveControl from flet.controls.base_control import control from flet.controls.buttons import OutlinedBorder @@ -90,7 +91,7 @@ class ListTileStyle(Enum): @control("ListTile") -class ListTile(LayoutControl, AdaptiveControl): +class ListTile(LayoutControl, AdaptiveControl, ActionControl): """ A single fixed-height row that typically contains some text as well as a leading \ or trailing icon. diff --git a/sdk/python/packages/flet/src/flet/controls/material/outlined_button.py b/sdk/python/packages/flet/src/flet/controls/material/outlined_button.py index 631b07213f..d2bfebff09 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/outlined_button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/outlined_button.py @@ -1,5 +1,6 @@ from typing import Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.adaptive_control import AdaptiveControl from flet.controls.base_control import control from flet.controls.buttons import ButtonStyle @@ -20,7 +21,7 @@ @control("OutlinedButton") -class OutlinedButton(LayoutControl, AdaptiveControl): +class OutlinedButton(LayoutControl, AdaptiveControl, ActionControl): """ Outlined buttons are medium-emphasis buttons. They contain actions that are \ important, but aren't the primary action in an app. Outlined buttons pair well \ diff --git a/sdk/python/packages/flet/src/flet/controls/material/text_button.py b/sdk/python/packages/flet/src/flet/controls/material/text_button.py index 0d999a999b..c13739e02c 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/text_button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/text_button.py @@ -1,5 +1,6 @@ from typing import Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.adaptive_control import AdaptiveControl from flet.controls.base_control import control from flet.controls.buttons import ButtonStyle @@ -17,7 +18,7 @@ @control("TextButton") -class TextButton(LayoutControl, AdaptiveControl): +class TextButton(LayoutControl, AdaptiveControl, ActionControl): """ Text buttons are used for the lowest priority actions, especially when presenting \ multiple options. Text buttons can be placed on a variety of backgrounds. Until \ diff --git a/sdk/python/packages/flet/src/flet/controls/services/clipboard.py b/sdk/python/packages/flet/src/flet/controls/services/clipboard.py index 0a235e6097..15ea45975f 100644 --- a/sdk/python/packages/flet/src/flet/controls/services/clipboard.py +++ b/sdk/python/packages/flet/src/flet/controls/services/clipboard.py @@ -1,11 +1,13 @@ +from dataclasses import dataclass from typing import Optional from flet.controls.base_control import control +from flet.controls.client_action import ClientAction, action_field, shared_service from flet.controls.exceptions import FletUnsupportedPlatformException from flet.controls.services.service import Service from flet.controls.types import PagePlatform -__all__ = ["Clipboard"] +__all__ = ["Clipboard", "CopyToClipboard"] @control("Clipboard") @@ -100,3 +102,30 @@ async def get_files(self) -> list[str]: "get_files is supported on desktop and Android platforms only" ) return await self._invoke_method("get_files") + + +@dataclass +class CopyToClipboard(ClientAction): + """ + Copies text to the clipboard when the control is activated. + + Equivalent to :meth:`flet.Clipboard.set`, but performed by the client inside + the user's gesture, which is the only time Safari permits a page to write to + the clipboard. + + Example: + ```python + ft.Button("Copy token", action=ft.CopyToClipboard(token)) + ``` + """ + + data: str = action_field() + """ + The text to copy. + + Must be known before the control is activated. To copy something computed at + click time, assign it to this action ahead of the click. + """ + + def __post_init__(self) -> None: + self._bind(shared_service(Clipboard), "set", {"data": self.data}) diff --git a/sdk/python/packages/flet/src/flet/controls/services/file_picker.py b/sdk/python/packages/flet/src/flet/controls/services/file_picker.py index a3fdb02c66..a3b695e289 100644 --- a/sdk/python/packages/flet/src/flet/controls/services/file_picker.py +++ b/sdk/python/packages/flet/src/flet/controls/services/file_picker.py @@ -3,6 +3,7 @@ from typing import Any, Optional from flet.controls.base_control import control +from flet.controls.client_action import ClientAction, action_field from flet.controls.control_event import Event, EventHandler from flet.controls.exceptions import FletUnsupportedPlatformException from flet.controls.services.service import Service @@ -11,8 +12,10 @@ "FilePicker", "FilePickerFile", "FilePickerFileType", + "FilePickerResultEvent", "FilePickerUploadEvent", "FilePickerUploadFile", + "PickFiles", ] @@ -140,6 +143,22 @@ class FilePickerFile: """ +@dataclass +class FilePickerResultEvent(Event["FilePicker"]): + """ + Event emitted when files are selected through a + :class:`~flet.PickFiles` action. + + Not emitted by :meth:`flet.FilePicker.pick_files`, which returns the + selected files directly. + """ + + files: list[FilePickerFile] + """ + The selected files, or an empty list if the user cancelled. + """ + + @dataclass class FilePickerUploadEvent(Event["FilePicker"]): """ @@ -180,6 +199,18 @@ class FilePicker(Service): ``` """ + on_result: Optional[EventHandler[FilePickerResultEvent]] = None + """ + Called when files are selected through a :class:`~flet.PickFiles` action. + + A `PickFiles` action opens the dialog on the client before your code sees + the click, so the selection cannot be returned to the caller the way + :meth:`pick_files` returns it - it arrives here instead. + + The picked files stay associated with this `FilePicker`, so they can be + passed straight to :meth:`upload`. + """ + on_upload: Optional[EventHandler[FilePickerUploadEvent]] = None """ Called when a file is uploaded via :meth:`upload` method. @@ -365,3 +396,90 @@ def _normalize_file(self, file: dict[str, Any]) -> dict[str, Any]: if isinstance(value, list): file["bytes"] = bytes(value) return file + + +@dataclass +class PickFiles(ClientAction): + """ + Opens a file picker dialog when the control is activated. + + Equivalent to :meth:`flet.FilePicker.pick_files`, but performed by the + client inside the user's gesture, which is the only time a browser opens a + file picker. This is what makes file picking work in a web app on iOS. + + Unlike `pick_files()`, the selection is not returned to the caller - it + arrives at :attr:`flet.FilePicker.on_result`. The files stay associated with + `file_picker`, so they can be passed straight to + :meth:`flet.FilePicker.upload`. + + Example: + ```python + picker = ft.FilePicker(on_result=handle_result) + page.services.append(picker) + + ft.Button("Upload", action=ft.PickFiles(picker, allow_multiple=True)) + ``` + """ + + file_picker: FilePicker = action_field() + """ + The :class:`~flet.FilePicker` that opens the dialog and reports the result + through its :attr:`~flet.FilePicker.on_result` event. + """ + + dialog_title: Optional[str] = action_field(None) + """ + The title of the dialog window. + """ + + initial_directory: Optional[str] = action_field(None) + """ + The initial directory where the dialog should open. + """ + + file_type: Optional[FilePickerFileType] = action_field(None) + """ + The file types allowed to be selected. + """ + + allowed_extensions: Optional[list[str]] = action_field(None) + """ + The allowed file extensions. Has effect only if :attr:`file_type` is + :attr:`flet.FilePickerFileType.CUSTOM`. + """ + + allow_multiple: bool = action_field(False) + """ + Allow the selection of multiple files at once. + """ + + with_data: bool = action_field(False) + """ + Read selected file contents into :attr:`flet.FilePickerFile.bytes`. + """ + + compression_quality: int = action_field(0) + """ + Image compression quality from `0` to `100`. `0` disables compression. + """ + + cancel_upload_on_window_blur: bool = action_field(True) + """ + Web-only. Whether to treat browser window blur as a cancelled selection. + """ + + def __post_init__(self) -> None: + self._bind( + self.file_picker, + "pick_files", + { + "dialog_title": self.dialog_title, + "initial_directory": self.initial_directory, + "file_type": self.file_type or FilePickerFileType.ANY, + "allowed_extensions": self.allowed_extensions, + "allow_multiple": self.allow_multiple, + "with_data": self.with_data, + "compression_quality": self.compression_quality, + "cancel_upload_on_window_blur": self.cancel_upload_on_window_blur, + }, + ) diff --git a/sdk/python/packages/flet/src/flet/controls/services/share.py b/sdk/python/packages/flet/src/flet/controls/services/share.py index 60c39ba7d0..0197635a97 100644 --- a/sdk/python/packages/flet/src/flet/controls/services/share.py +++ b/sdk/python/packages/flet/src/flet/controls/services/share.py @@ -4,6 +4,7 @@ from typing import Optional from flet.controls.base_control import control +from flet.controls.client_action import ClientAction, action_field, shared_service from flet.controls.services.service import Service from flet.controls.transform import Offset from flet.utils.from_dict import from_dict @@ -14,6 +15,7 @@ "ShareFile", "ShareResult", "ShareResultStatus", + "ShareText", ] @@ -323,3 +325,48 @@ def _share_args( ] return args + + +@dataclass +class ShareText(ClientAction): + """ + Opens the platform share sheet with text when the control is activated. + + Equivalent to :meth:`flet.Share.share_text`, but performed by the client + inside the user's gesture, which is the only time a browser permits + `navigator.share`. + + The share result is not reported back - use + :meth:`flet.Share.share_text` if you need it, keeping in mind that it does + not work on the web on iOS. + + Example: + ```python + ft.Button( + "Share", + action=ft.ShareText("Check out Flet", subject="Flet"), + ) + ``` + """ + + text: str = action_field() + """ + The text to share. + """ + + title: Optional[str] = action_field(None) + """ + Title shown in the share sheet. + """ + + subject: Optional[str] = action_field(None) + """ + Subject used by targets that support one, such as email. + """ + + def __post_init__(self) -> None: + self._bind( + shared_service(Share), + "share_text", + _share_args(text=self.text, title=self.title, subject=self.subject), + ) diff --git a/sdk/python/packages/flet/src/flet/controls/services/url_launcher.py b/sdk/python/packages/flet/src/flet/controls/services/url_launcher.py index 59bce71e07..b01a1c2bee 100644 --- a/sdk/python/packages/flet/src/flet/controls/services/url_launcher.py +++ b/sdk/python/packages/flet/src/flet/controls/services/url_launcher.py @@ -3,12 +3,14 @@ from typing import Optional, Union from flet.controls.base_control import control +from flet.controls.client_action import ClientAction, action_field, shared_service from flet.controls.services.service import Service -from flet.controls.types import Url +from flet.controls.types import Url, UrlTarget __all__ = [ "BrowserConfiguration", "LaunchMode", + "OpenUrl", "UrlLauncher", "WebViewConfiguration", ] @@ -180,3 +182,41 @@ async def supports_close_for_launch_mode(self, mode: LaunchMode) -> bool: return await self._invoke_method( "supports_close_for_launch_mode", {"mode": mode} ) + + +@dataclass +class OpenUrl(ClientAction): + """ + Opens a URL when the control is activated. + + Equivalent to :meth:`flet.UrlLauncher.launch_url`, but performed by the + client inside the user's gesture, so that opening a new tab is not blocked as + an unsolicited popup. + + Example: + ```python + ft.Button( + "Open flet.dev", + action=ft.OpenUrl("https://flet.dev", target=ft.UrlTarget.BLANK), + ) + ``` + """ + + url: str = action_field() + """ + The URL to open. + """ + + target: Optional[Union[UrlTarget, str]] = action_field(None) + """ + Where to open the URL, for example :attr:`flet.UrlTarget.BLANK` for a new tab. + + Web-only; ignored on other platforms. + """ + + def __post_init__(self) -> None: + self._bind( + shared_service(UrlLauncher), + "launch_url", + {"url": Url(url=self.url, target=self.target)}, + ) diff --git a/sdk/python/packages/flet/src/flet/controls/types.py b/sdk/python/packages/flet/src/flet/controls/types.py index a745290e6f..72384789b9 100644 --- a/sdk/python/packages/flet/src/flet/controls/types.py +++ b/sdk/python/packages/flet/src/flet/controls/types.py @@ -124,7 +124,7 @@ class UrlTarget(Enum): Specifies where to open a URL. """ - BLANK = "blank" + BLANK = "_blank" """ Opens the URL in a new browser tab or window. """ diff --git a/sdk/python/packages/flet/src/flet/messaging/connection.py b/sdk/python/packages/flet/src/flet/messaging/connection.py index 7ccbae4b7c..fd7a1e7584 100644 --- a/sdk/python/packages/flet/src/flet/messaging/connection.py +++ b/sdk/python/packages/flet/src/flet/messaging/connection.py @@ -3,7 +3,7 @@ from concurrent.futures import ThreadPoolExecutor from typing import Any, Optional -from flet.messaging.protocol import ClientMessage +from flet.messaging.protocol import Message from flet.pubsub.pubsub_hub import PubSubHub logger = logging.getLogger("flet") @@ -87,7 +87,7 @@ def pubsubhub(self) -> PubSubHub: def pubsubhub(self, value: PubSubHub): self.__pubsubhub = value - def send_message(self, message: ClientMessage): + def send_message(self, message: Message): """ Sends a message to the connected Flet client. diff --git a/sdk/python/packages/flet/src/flet/messaging/flet_dart_bridge_server.py b/sdk/python/packages/flet/src/flet/messaging/flet_dart_bridge_server.py index a232a06efe..2499de667b 100644 --- a/sdk/python/packages/flet/src/flet/messaging/flet_dart_bridge_server.py +++ b/sdk/python/packages/flet/src/flet/messaging/flet_dart_bridge_server.py @@ -35,10 +35,10 @@ from flet.controls.base_control import BaseControl from flet.messaging.connection import Connection from flet.messaging.protocol import ( - ClientAction, - ClientMessage, ControlEventBody, InvokeMethodResponseBody, + Message, + MessageAction, RegisterClientRequestBody, RegisterClientResponseBody, UpdateControlPropsBody, @@ -154,11 +154,11 @@ async def __on_message(self, data: Any): Duplicated here to keep the two transports decoupled; refactor into a shared base once both have stabilised. """ - action = ClientAction(data[0]) + action = MessageAction(data[0]) body = data[1] transport_log.debug("_on_message: %s %s", action, body) task = None - if action == ClientAction.REGISTER_CLIENT: + if action == MessageAction.REGISTER_CLIENT: req = RegisterClientRequestBody(**body) # create new session @@ -180,8 +180,8 @@ async def __on_message(self, data: Any): # register response self.send_message( - ClientMessage( - ClientAction.REGISTER_CLIENT, + Message( + MessageAction.REGISTER_CLIENT, RegisterClientResponseBody( session_id=self.session.id, page_patch=self.session.get_page_patch(), @@ -195,17 +195,17 @@ async def __on_message(self, data: Any): elif self.__on_session_created is not None: task = asyncio.create_task(self.__on_session_created(self.session)) - elif action == ClientAction.CONTROL_EVENT: + elif action == MessageAction.CONTROL_EVENT: req = ControlEventBody(**body) task = asyncio.create_task( self.session.dispatch_event(req.target, req.name, req.data) ) - elif action == ClientAction.UPDATE_CONTROL_PROPS: + elif action == MessageAction.UPDATE_CONTROL_PROPS: req = UpdateControlPropsBody(**body) self.session.apply_patch(req.id, req.props) - elif action == ClientAction.INVOKE_METHOD: + elif action == MessageAction.INVOKE_METHOD: req = InvokeMethodResponseBody(**body) self.session.handle_invoke_method_results( req.control_id, req.call_id, req.result, req.error @@ -218,7 +218,7 @@ async def __on_message(self, data: Any): self.__running_tasks.add(task) task.add_done_callback(self.__running_tasks.discard) - def send_message(self, message: ClientMessage): + def send_message(self, message: Message): """ Encodes a protocol message and posts it to the Dart side via `dart_bridge.send_bytes`. Non-blocking; ordering is preserved by the diff --git a/sdk/python/packages/flet/src/flet/messaging/flet_socket_server.py b/sdk/python/packages/flet/src/flet/messaging/flet_socket_server.py index ee7935034f..e6e28325ff 100644 --- a/sdk/python/packages/flet/src/flet/messaging/flet_socket_server.py +++ b/sdk/python/packages/flet/src/flet/messaging/flet_socket_server.py @@ -15,10 +15,10 @@ from flet.controls.base_control import BaseControl from flet.messaging.connection import Connection from flet.messaging.protocol import ( - ClientAction, - ClientMessage, ControlEventBody, InvokeMethodResponseBody, + Message, + MessageAction, RegisterClientRequestBody, RegisterClientResponseBody, UpdateControlPropsBody, @@ -357,11 +357,11 @@ async def __on_message(self, data: Any): Raises: RuntimeError: If the action code is unknown. """ - action = ClientAction(data[0]) + action = MessageAction(data[0]) body = data[1] transport_log.debug("_on_message: %s %s", action, body) task = None - if action == ClientAction.REGISTER_CLIENT: + if action == MessageAction.REGISTER_CLIENT: req = RegisterClientRequestBody(**body) # create new session @@ -383,8 +383,8 @@ async def __on_message(self, data: Any): # register response self.send_message( - ClientMessage( - ClientAction.REGISTER_CLIENT, + Message( + MessageAction.REGISTER_CLIENT, RegisterClientResponseBody( session_id=self.session.id, page_patch=self.session.get_page_patch(), @@ -398,17 +398,17 @@ async def __on_message(self, data: Any): elif self.__on_session_created is not None: task = asyncio.create_task(self.__on_session_created(self.session)) - elif action == ClientAction.CONTROL_EVENT: + elif action == MessageAction.CONTROL_EVENT: req = ControlEventBody(**body) task = asyncio.create_task( self.session.dispatch_event(req.target, req.name, req.data) ) - elif action == ClientAction.UPDATE_CONTROL_PROPS: + elif action == MessageAction.UPDATE_CONTROL_PROPS: req = UpdateControlPropsBody(**body) self.session.apply_patch(req.id, req.props) - elif action == ClientAction.INVOKE_METHOD: + elif action == MessageAction.INVOKE_METHOD: req = InvokeMethodResponseBody(**body) self.session.handle_invoke_method_results( req.control_id, req.call_id, req.result, req.error @@ -422,7 +422,7 @@ async def __on_message(self, data: Any): self.__running_tasks.add(task) task.add_done_callback(self.__running_tasks.discard) - def send_message(self, message: ClientMessage): + def send_message(self, message: Message): """ Encodes and queues an outbound message for the active connection. diff --git a/sdk/python/packages/flet/src/flet/messaging/protocol.py b/sdk/python/packages/flet/src/flet/messaging/protocol.py index e8a45c36f9..4e2ac0687b 100644 --- a/sdk/python/packages/flet/src/flet/messaging/protocol.py +++ b/sdk/python/packages/flet/src/flet/messaging/protocol.py @@ -228,12 +228,13 @@ def decode_ext_from_msgpack(code, data): return msgpack.ExtType(code, data) -class ClientAction(Enum): +class MessageAction(Enum): """ Wire-level action codes exchanged between Python and Dart clients. - Integer values must stay in sync with Dart `MessageAction` values because - protocol frames are encoded as `[action_code, body]`. + Integer values must stay in sync with the Dart enum of the same name + (`protocol/message.dart`) because protocol frames are encoded as + `[action_code, body]`. """ REGISTER_CLIENT = 1 @@ -275,14 +276,15 @@ class ClientAction(Enum): @dataclass -class ClientMessage: +class Message: """ - Top-level protocol frame with action and payload. + Top-level protocol frame, mirroring the Dart class of the same name + (`protocol/message.dart`). - Messages are serialized as a two-item sequence: `[action_code, body]`. + Serialized as a two-item sequence: `[action_code, body]`. """ - action: ClientAction + action: MessageAction """ Action discriminator for this message. """ diff --git a/sdk/python/packages/flet/src/flet/messaging/pyodide_connection.py b/sdk/python/packages/flet/src/flet/messaging/pyodide_connection.py index 5de5bfa55b..855ead0ea4 100644 --- a/sdk/python/packages/flet/src/flet/messaging/pyodide_connection.py +++ b/sdk/python/packages/flet/src/flet/messaging/pyodide_connection.py @@ -11,10 +11,10 @@ from flet.controls.base_control import BaseControl from flet.messaging.connection import Connection from flet.messaging.protocol import ( - ClientAction, - ClientMessage, ControlEventBody, InvokeMethodResponseBody, + Message, + MessageAction, RegisterClientRequestBody, RegisterClientResponseBody, UpdateControlPropsBody, @@ -127,11 +127,11 @@ async def __on_message(self, data: Any): Raises: RuntimeError: If the action type is unknown. """ - action = ClientAction(data[0]) + action = MessageAction(data[0]) body = data[1] transport_log.debug("_on_message: %s %s", action, body) task = None - if action == ClientAction.REGISTER_CLIENT: + if action == MessageAction.REGISTER_CLIENT: req = RegisterClientRequestBody(**body) # create new session @@ -152,8 +152,8 @@ async def __on_message(self, data: Any): # register response self.send_message( - ClientMessage( - ClientAction.REGISTER_CLIENT, + Message( + MessageAction.REGISTER_CLIENT, RegisterClientResponseBody( session_id=self.session.id, page_patch=self.session.get_page_patch(), @@ -168,17 +168,17 @@ async def __on_message(self, data: Any): elif register_error: self.session.error(register_error) - elif action == ClientAction.CONTROL_EVENT: + elif action == MessageAction.CONTROL_EVENT: req = ControlEventBody(**body) task = asyncio.create_task( self.session.dispatch_event(req.target, req.name, req.data) ) - elif action == ClientAction.UPDATE_CONTROL_PROPS: + elif action == MessageAction.UPDATE_CONTROL_PROPS: req = UpdateControlPropsBody(**body) self.session.apply_patch(req.id, req.props) - elif action == ClientAction.INVOKE_METHOD: + elif action == MessageAction.INVOKE_METHOD: req = InvokeMethodResponseBody(**body) self.session.handle_invoke_method_results( req.control_id, req.call_id, req.result, req.error @@ -192,7 +192,7 @@ async def __on_message(self, data: Any): self.__running_tasks.add(task) task.add_done_callback(self.__running_tasks.discard) - def send_message(self, message: ClientMessage): + def send_message(self, message: Message): """ Serializes and sends an outbound protocol message to JavaScript. diff --git a/sdk/python/packages/flet/src/flet/messaging/session.py b/sdk/python/packages/flet/src/flet/messaging/session.py index c76093081b..3ad3027486 100644 --- a/sdk/python/packages/flet/src/flet/messaging/session.py +++ b/sdk/python/packages/flet/src/flet/messaging/session.py @@ -13,9 +13,9 @@ from flet.controls.page import Page from flet.messaging.connection import Connection from flet.messaging.protocol import ( - ClientAction, - ClientMessage, InvokeMethodRequestBody, + Message, + MessageAction, PatchControlBody, SessionCrashedBody, ) @@ -46,7 +46,7 @@ class Session: def __init__(self, conn: Connection): self.__conn = conn - self.__send_buffer: list[ClientMessage] = [] + self.__send_buffer: list[Message] = [] self.__id = random_string(16) self.__expires_at = None self.__index: weakref.WeakValueDictionary[int, BaseControl] = ( @@ -275,8 +275,8 @@ def patch_control( if len(patch) > 1: self.__send_message( - ClientMessage( - ClientAction.PATCH_CONTROL, + Message( + MessageAction.PATCH_CONTROL, PatchControlBody(parent._i if parent else control._i, patch), ) ) @@ -485,8 +485,8 @@ async def invoke_method( # call method self.__send_message( - ClientMessage( - ClientAction.INVOKE_METHOD, + Message( + MessageAction.INVOKE_METHOD, InvokeMethodRequestBody( control_id=control_id, call_id=call_id, name=method_name, args=args ), @@ -588,10 +588,10 @@ def error(self, message: str): message: Error message to report. """ self.__send_message( - ClientMessage(ClientAction.SESSION_CRASHED, SessionCrashedBody(message)) + Message(MessageAction.SESSION_CRASHED, SessionCrashedBody(message)) ) - def __send_message(self, message: ClientMessage): + def __send_message(self, message: Message): """ Sends a message immediately or buffers it until reconnection. diff --git a/sdk/python/packages/flet/tests/test_client_actions.py b/sdk/python/packages/flet/tests/test_client_actions.py new file mode 100644 index 0000000000..5cc4447f56 --- /dev/null +++ b/sdk/python/packages/flet/tests/test_client_actions.py @@ -0,0 +1,111 @@ +import weakref + +import msgpack +import pytest + +import flet as ft +from flet.controls.base_control import BaseControl +from flet.controls.context import _context_page +from flet.messaging.protocol import configure_encode_object_for_msgpack + + +class FakeSession: + def __init__(self): + self.index: dict[int, object] = {} + + def patch_control(self, control, **kwargs): + pass + + def schedule_update(self, control): + pass + + async def after_event(self, control): + pass + + +@pytest.fixture +def page(): + # Page holds its session weakly, so the local reference has to outlive the + # test or `page.session` raises "An attempt to fetch destroyed session". + session = FakeSession() + page = ft.Page(sess=session) + page._dialogs._parent = weakref.ref(page) + token = _context_page.set(page) + yield page + _context_page.reset(token) + del session + + +def pack(obj): + return msgpack.unpackb( + msgpack.packb(obj, default=configure_encode_object_for_msgpack(BaseControl)), + strict_map_key=False, + ) + + +def test_page_is_serializable_after_creating_an_action(page): + """ + The services an action targets must not be cached anywhere that reaches the + wire. Stashing them in `page._internals` - which *is* serialized - made the + whole page unpackable with "type object 'Clipboard' has no attribute '_i'". + """ + page.add(ft.Button("Copy", action=ft.CopyToClipboard("hello"))) + pack(page) + + +def test_action_serializes_as_a_service_call(page): + action = ft.CopyToClipboard("hello") + assert pack(action) == { + "service_id": action._service._i, + "method": "set", + "args": {"data": "hello"}, + } + + +def test_actions_of_one_kind_share_a_single_service(page): + first = ft.CopyToClipboard("a") + second = ft.CopyToClipboard("b") + assert first.service_id == second.service_id + + +def test_actions_of_different_kinds_target_different_services(page): + assert ( + ft.CopyToClipboard("a").service_id != ft.OpenUrl("https://flet.dev").service_id + ) + + +def test_open_url_passes_target_through(page): + action = ft.OpenUrl("https://flet.dev", target=ft.UrlTarget.BLANK) + assert action.args["url"].target is ft.UrlTarget.BLANK + # "_blank" is the reserved keyword; "blank" would be read as a window name. + assert pack(action)["args"]["url"]["target"] == "_blank" + + +def test_pick_files_targets_the_users_file_picker(page): + picker = ft.FilePicker() + action = ft.PickFiles(picker, allow_multiple=True) + assert action.service_id == picker._i + assert action.method == "pick_files" + assert action.args["allow_multiple"] is True + + +def test_action_is_carried_by_every_control_that_declares_one(page): + for control in [ + ft.Button("x"), + ft.IconButton(icon=ft.Icons.ADD), + ft.Container(), + ft.ListTile(), + ft.FloatingActionButton(icon=ft.Icons.ADD), + ft.OutlinedButton("x"), + ft.TextButton("x"), + ft.CupertinoButton(content="x"), + ft.CupertinoListTile(title=ft.Text("x")), + ft.TextSpan("x"), + ]: + assert isinstance(control, ft.ActionControl) + control.action = ft.CopyToClipboard("hello") + assert pack(control)["action"]["method"] == "set" + + +def test_action_is_omitted_when_unset(page): + assert "action" not in pack(ft.Button("x")) diff --git a/sdk/python/packages/flet/tests/test_session_disconnect_buffering.py b/sdk/python/packages/flet/tests/test_session_disconnect_buffering.py index ba561a41fb..2ef7110308 100644 --- a/sdk/python/packages/flet/tests/test_session_disconnect_buffering.py +++ b/sdk/python/packages/flet/tests/test_session_disconnect_buffering.py @@ -3,7 +3,7 @@ from flet.components.component import Component from flet.components.hooks.use_effect import EffectHook from flet.messaging.connection import Connection -from flet.messaging.protocol import ClientAction, ClientMessage, SessionCrashedBody +from flet.messaging.protocol import Message, MessageAction, SessionCrashedBody from flet.messaging.session import Session from flet.pubsub.pubsub_hub import PubSubHub @@ -25,7 +25,7 @@ def test_disconnected_session_drops_incremental_messages(): session._Session__expires_at = datetime.now(timezone.utc) session._Session__send_message( # type: ignore[attr-defined] - ClientMessage(ClientAction.SESSION_CRASHED, SessionCrashedBody("x")) + Message(MessageAction.SESSION_CRASHED, SessionCrashedBody("x")) ) assert session._Session__send_buffer == [] @@ -54,8 +54,8 @@ def test_attach_connection_restores_state_and_flushes_buffer(): initial_conn.pubsubhub = PubSubHub() session = Session(initial_conn) - buffered_message = ClientMessage( - ClientAction.SESSION_CRASHED, SessionCrashedBody("buffered") + buffered_message = Message( + MessageAction.SESSION_CRASHED, SessionCrashedBody("buffered") ) session._Session__conn = None session._Session__expires_at = datetime.now(timezone.utc) diff --git a/sdk/python/packages/flet/tests/test_use_dialog.py b/sdk/python/packages/flet/tests/test_use_dialog.py index 15d5bea48c..6139576cc0 100644 --- a/sdk/python/packages/flet/tests/test_use_dialog.py +++ b/sdk/python/packages/flet/tests/test_use_dialog.py @@ -10,7 +10,7 @@ from flet.controls.context import _context_page from flet.controls.control_event import ControlEvent from flet.messaging.connection import Connection -from flet.messaging.protocol import ClientAction, configure_encode_object_for_msgpack +from flet.messaging.protocol import MessageAction, configure_encode_object_for_msgpack from flet.messaging.session import Session from flet.pubsub.pubsub_hub import PubSubHub @@ -70,7 +70,7 @@ async def flush_async(turns: int = 5) -> None: def crash_messages(conn: _RecordingConnection): - return [m for m in conn.messages if m.action == ClientAction.SESSION_CRASHED] + return [m for m in conn.messages if m.action == MessageAction.SESSION_CRASHED] def find_filled_button(session: Session, label: str) -> ft.FilledButton: diff --git a/website/docs/controls/actioncontrol.md b/website/docs/controls/actioncontrol.md new file mode 100644 index 0000000000..9c69689049 --- /dev/null +++ b/website/docs/controls/actioncontrol.md @@ -0,0 +1,8 @@ +--- +class_name: "flet.ActionControl" +title: "ActionControl" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/cookbook/client-actions.md b/website/docs/cookbook/client-actions.md new file mode 100644 index 0000000000..f305c180bd --- /dev/null +++ b/website/docs/cookbook/client-actions.md @@ -0,0 +1,101 @@ +--- +title: "Client actions" +--- + +import {CodeExample} from '@site/src/components/crocodocs'; + +Some things a browser can do are only allowed while it is handling a click or a +key press: opening a file picker, writing to the clipboard, showing a share +sheet, opening a new tab. The permission lasts for that one gesture and no +longer. + +That is a problem for the usual Flet pattern. When you call +[`FilePicker.pick_files()`](../services/filepicker.md#flet.FilePicker.pick_files) +from an `on_click` handler, the click travels to your Python code, your code +runs, and the instruction to open the dialog travels back - by which point the +browser no longer considers a gesture to be in progress and quietly refuses. + +Safari enforces this strictly, Chrome and Firefox are lenient about it, so the +symptom is confusing: the same app works on Android and on the desktop, and +silently does nothing on an iPhone or iPad. Nothing is logged, because from the +browser's point of view nothing went wrong. + +**Client actions** close that gap. An action is attached to a control instead of +being called from a handler, so the client already knows what to do when the tap +arrives and can do it immediately, inside the gesture: + +```python +ft.Button("Upload", action=ft.PickFiles(file_picker, allow_multiple=True)) +``` + +## Opening a URL + +[`OpenUrl`](../types/openurl.md) opens a link. Opening a *new tab* is the part +that browsers guard, since that is what a popup blocker exists to stop. + + + +The [`url`](../controls/button.md#flet.Button.url) property that controls have +always had works the same way and is unchanged - `OpenUrl` is for when you want +to combine it with other actions, or keep every gesture-gated operation written +the same way. + +## Copying to the clipboard + + + +## Sharing + + + +## Picking files + +[`PickFiles`](../types/pickfiles.md) is the action that fixes file picking in a +web app on iOS. + +Because the dialog opens before your code sees the click, the selection cannot +be returned to a caller the way `pick_files()` returns it. It arrives at +[`FilePicker.on_result`](../services/filepicker.md#flet.FilePicker.on_result) +instead. The picked files stay associated with the `FilePicker`, so +[`upload()`](../services/filepicker.md#flet.FilePicker.upload) works exactly as +before. + + + +## What each action maps to + +| Action | Equivalent method | +|---|---| +| [`OpenUrl`](../types/openurl.md) | [`UrlLauncher.launch_url()`](../services/urllauncher.md#flet.UrlLauncher.launch_url) | +| [`CopyToClipboard`](../types/copytoclipboard.md) | [`Clipboard.set()`](../services/clipboard.md#flet.Clipboard.set) | +| [`ShareText`](../types/sharetext.md) | [`Share.share_text()`](../services/share.md#flet.Share.share_text) | +| [`PickFiles`](../types/pickfiles.md) | [`FilePicker.pick_files()`](../services/filepicker.md#flet.FilePicker.pick_files) | + +An action runs first, then your `on_click` handler is called as usual - so you +can still react to the click in Python. + +A control accepts a list as well as a single action, if you need more than one: + +```python +ft.Button( + "Copy and open", + action=[ft.CopyToClipboard(link), ft.OpenUrl(link, target=ft.UrlTarget.BLANK)], +) +``` + +## Limits + +**An action's arguments are fixed before the click.** This follows from what an +action is - the client has to know the whole operation in advance, because there +is no time to ask. To copy or share a value that changes, update the action when +the value changes, as the clipboard example above does. There is no way around +this; it is the browser's rule, not Flet's. + +**Reading the clipboard still prompts.** Safari shows a paste-confirmation UI +for [`Clipboard.get()`](../services/clipboard.md#flet.Clipboard.get) whatever you +do. An action makes the read possible, not invisible. + +**Outside the browser this does not apply.** On desktop, Android and iOS apps +there is no such restriction, and the ordinary method calls work fine. Actions +work everywhere, so you can use them unconditionally if your app also runs on +the web - but there is nothing to fix if it does not. diff --git a/website/docs/services/clipboard.md b/website/docs/services/clipboard.md index 78e244d84c..0ba2d4c734 100644 --- a/website/docs/services/clipboard.md +++ b/website/docs/services/clipboard.md @@ -8,6 +8,22 @@ import {ClassMembers, ClassSummary, CodeExample} from '@site/src/components/croc +:::warning[Copying in a web app] +Browsers only let a page write to the clipboard while they are handling a click +or key press, and calling [`set()`](clipboard.md#flet.Clipboard.set) from an +event handler is already too late - the click has travelled to your Python code +and back. Safari refuses silently; Chrome and Firefox are more forgiving, so the +same app often works everywhere except on iPhone and iPad. + +Use a [`CopyToClipboard`](../types/copytoclipboard.md) action instead, which the +client performs inside the original gesture. See +[Client actions](../cookbook/client-actions.md). +::: + +## Copying with a client action + + + ## Examples diff --git a/website/docs/services/filepicker.md b/website/docs/services/filepicker.md index 6173d86765..0055188866 100644 --- a/website/docs/services/filepicker.md +++ b/website/docs/services/filepicker.md @@ -27,11 +27,32 @@ In most cases you can use a lambda function for that: ```python ft.Button( - content="Pick files, - on_click=lambda _: file_picker.pick_files(allow_multiple=True) + content="Pick files", + on_click=lambda e: file_picker.pick_files(allow_multiple=True) ) ``` +:::warning[Picking files in a web app] +A browser only opens a file picker while it is handling a click or key press. +Calling `pick_files()` from an `on_click` handler is already too late - the click +has travelled to your Python code and the instruction has travelled back, and by +then the permission is gone. Safari refuses silently, while Chrome and Firefox +allow it, so the same app often works everywhere except on iPhone and iPad. + +Attach a [`PickFiles`](../types/pickfiles.md) action to the control instead, so +the dialog opens inside the original gesture, and handle the selection in +[`on_result`](filepicker.md#flet.FilePicker.on_result). +See [Client actions](../cookbook/client-actions.md). +::: + +## Picking files with a client action + +The picked files stay associated with the `FilePicker`, so +[`upload()`](filepicker.md#flet.FilePicker.upload) works exactly as it does +after `pick_files()`. + + + ### Uploading files To upload one or more files, call [`FilePicker.pick_files()`](filepicker.md#flet.FilePicker.pick_files) diff --git a/website/docs/services/share.md b/website/docs/services/share.md index 852661dfd4..417abfc50c 100644 --- a/website/docs/services/share.md +++ b/website/docs/services/share.md @@ -8,6 +8,20 @@ import {ClassMembers, ClassSummary, CodeExample} from '@site/src/components/croc +:::warning[Sharing from a web app] +Browsers only open the share sheet while they are handling a click or key press, +which the methods below cannot satisfy - by the time the click has reached your +Python code and the instruction has travelled back, the permission is gone. + +Use a [`ShareText`](../types/sharetext.md) action instead, which the client +performs inside the original gesture. See +[Client actions](../cookbook/client-actions.md). +::: + +## Sharing with a client action + + + ## Examples diff --git a/website/docs/services/urllauncher.md b/website/docs/services/urllauncher.md index 1610c2afac..ff1fe36a37 100644 --- a/website/docs/services/urllauncher.md +++ b/website/docs/services/urllauncher.md @@ -8,6 +8,21 @@ import {ClassMembers, ClassSummary, CodeExample} from '@site/src/components/croc +:::note[Opening a new tab in a web app] +A browser treats a new tab that was not opened during a click as a popup and +blocks it, so [`launch_url()`](urllauncher.md#flet.UrlLauncher.launch_url) with +`UrlTarget.BLANK` may not work on the web. + +Set a control's `url` property, or use an +[`OpenUrl`](../types/openurl.md) action - both are performed by the client +inside the original gesture. See +[Client actions](../cookbook/client-actions.md). +::: + +## Opening a URL with a client action + + + ## Examples diff --git a/website/docs/types/clientaction.md b/website/docs/types/clientaction.md new file mode 100644 index 0000000000..5fb544dd96 --- /dev/null +++ b/website/docs/types/clientaction.md @@ -0,0 +1,7 @@ +--- +title: "ClientAction" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/types/copytoclipboard.md b/website/docs/types/copytoclipboard.md new file mode 100644 index 0000000000..19d4015330 --- /dev/null +++ b/website/docs/types/copytoclipboard.md @@ -0,0 +1,7 @@ +--- +title: "CopyToClipboard" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/types/filepickerresultevent.md b/website/docs/types/filepickerresultevent.md new file mode 100644 index 0000000000..d65f6af145 --- /dev/null +++ b/website/docs/types/filepickerresultevent.md @@ -0,0 +1,7 @@ +--- +title: "FilePickerResultEvent" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/types/openurl.md b/website/docs/types/openurl.md new file mode 100644 index 0000000000..98ffbb6fd4 --- /dev/null +++ b/website/docs/types/openurl.md @@ -0,0 +1,7 @@ +--- +title: "OpenUrl" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/types/pickfiles.md b/website/docs/types/pickfiles.md new file mode 100644 index 0000000000..e8175f2d89 --- /dev/null +++ b/website/docs/types/pickfiles.md @@ -0,0 +1,7 @@ +--- +title: "PickFiles" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/types/sharetext.md b/website/docs/types/sharetext.md new file mode 100644 index 0000000000..077bcf4fae --- /dev/null +++ b/website/docs/types/sharetext.md @@ -0,0 +1,7 @@ +--- +title: "ShareText" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/sidebars.yml b/website/sidebars.yml index cecfa12ca2..2709d3c0ee 100644 --- a/website/sidebars.yml +++ b/website/sidebars.yml @@ -43,6 +43,7 @@ docs: Authentication: cookbook/authentication.md Encrypting sensitive data: cookbook/encrypting-sensitive-data.md Declarative dialogs: cookbook/declarative-dialogs.md + Client actions: cookbook/client-actions.md Flet MCP server: cookbook/flet-mcp.md Publishing Flet app: _index: publish/index.md @@ -397,6 +398,7 @@ docs: - types/testing/finder.md - types/testing/disposalmode.md Base Controls: + - controls/actioncontrol.md - controls/adaptivecontrol.md - controls/basecontrol.md - controls/basepage.md @@ -670,6 +672,11 @@ docs: TimePickerTheme: types/timepickertheme.md TooltipTheme: types/tooltiptheme.md - types/browserconfiguration.md + - types/clientaction.md + - types/copytoclipboard.md + - types/openurl.md + - types/pickfiles.md + - types/sharetext.md - types/tooltip.md - types/url.md - types/webviewconfiguration.md @@ -839,6 +846,7 @@ docs: - types/dragwillacceptevent.md - types/event.md - types/expansionpanellistchangeevent.md + - types/filepickerresultevent.md - types/filepickeruploadevent.md - types/gyroscopereadingevent.md - types/hoverevent.md