v0.2.0 (6/N): docs/native-dialogs.md — boundary doc + Qt.labs.platform examples

PLAN.md §12 noted "Native dialogs (file pickers, notifications) — where
do they live?" as an open question with the bias "QML side via Qt".
That bias was never written up; without the doc, a Symfony developer
new to Qt would reasonably reach for "POST /api/show-dialog" or roll
a custom QML "FileDialog" using Window + ListView. Both are wrong.

The doc:
- States the boundary plainly (native UI = QML side, never PHP) plus
  the architectural reason (PHP's process can't reach the user's
  window manager; Qt's can and already wraps every platform's
  native API).
- Walks through Qt.labs.platform.FileDialog / MessageDialog /
  SystemTrayIcon / StandardPaths with copy-pasteable examples so
  apps don't need to discover Qt.labs the hard way.
- Explains the trigger-vs-effect split: user-initiated confirmations
  open from the QML handler that fired the action; server-side
  events route through Mercure and let QML decide how to surface
  them (toast / dialog / tray notification).

Anti-pattern callouts: don't dispatch dialogs from Doctrine listeners,
don't add HTTP endpoints whose only job is to trigger UI side-effects,
don't roll a custom QML file browser.

Notifications caveat: Qt.labs.platform.SystemTrayIcon::showMessage
covers the common case but routes through the tray. Richer
notifications (action buttons, replies) need platform-specific code
and are deferred — flagged in-doc.

PLAN.md §13 also mentioned "ship a small Q_INVOKABLE helper for the
common cases". Skipped: every common case Qt.labs.platform already
covers, and a wrapper would just shadow upstream's API. If a future
need surfaces a real gap (XDG portal notifications without tray,
say), that's the time to add framework-side code; the doc will
point at it.

No code changes; doc-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 20:21:23 +02:00
parent a589b1c30d
commit 91f4d619fc
3 changed files with 118 additions and 0 deletions

View File

@@ -31,6 +31,10 @@ This section tracks work landing on `dev` toward **v0.2.0** (next minor; pre-1.0
- **`BridgeOpTest` wire-format contract.** Locks the four enum case values (`upsert` / `delete` / `replace` / `event`) against accidental rename — QML clients hardcode the strings, so a `value` change is a wire-protocol break and the test fails the build before it ships.
### Documentation
- **`docs/native-dialogs.md`.** Documents the framework boundary §12 already implied: native UI affordances (file pickers, confirmations, system notifications) live on the QML side via `Qt.labs.platform`, not in PHP. Includes copy-pasteable examples of `FileDialog`, `MessageDialog`, `SystemTrayIcon`, and the trigger-vs-effect split for server-pushed-event-driven dialogs.
## [0.1.2] — 2026-05-03
Bugfix release. Bundles the v0.1.1 follow-up that surfaced during the v0.1.2 cycle (bundled-mode supervisor cleanly SIGTERMs its child on host exit) with three non-breaking fixes from a post-v0.1.1 architecture audit.

View File

@@ -18,6 +18,7 @@ Developer documentation for [php-qml](../README.md). Design rationale and roadma
- **[Makers](makers.md)** — `make:bridge:resource`, `make:bridge:command`, `make:bridge:window`.
- **[Dev workflow](dev-workflow.md)** — hot reload (PHP + QML), dev console (`Ctrl+\``), editor configs, `bridge:doctor`.
- **[Linux packaging](packaging-linux.md)** — `make appimage`, AppImageUpdate, performance budgets.
- **[Native dialogs](native-dialogs.md)** — file pickers, confirmations, system notifications: where they live (QML, not PHP) and how to use the platform-native components Qt already ships.
## Reference

113
docs/native-dialogs.md Normal file
View File

@@ -0,0 +1,113 @@
# Native dialogs and notifications
> **The rule:** native UI affordances — file pickers, message boxes, system notifications, tray icons — live on the **QML side**, not in PHP. PLAN.md §12 ([Native dialogs row](../PLAN.md#12-open-questions-and-risks)) treats this as a framework boundary, not an open question.
## Why this boundary
The Qt host owns the window, the input loop, and the platform integration. Anything that needs to talk to the OS's native chrome (a save-file dialog drawn by `kdialog` / `NSSavePanel` / `IFileSaveDialog`, a notification routed through `org.freedesktop.Notifications` / `NSUserNotificationCenter` / `ToastNotification`) is reachable from Qt and unreachable from PHP. PHP can produce data and decide *what* should happen; QML decides *how to surface it natively*.
Trying to dispatch a "show me a save-file dialog" from a Symfony controller means either polling the Qt side over HTTP (slow, ugly) or shipping a second Qt↔PHP transport just for UI side-effects (unnecessary). Don't.
## File pickers
Use [`Qt.labs.platform.FileDialog`](https://doc.qt.io/qt-6/qml-qt-labs-platform-filedialog.html) — it draws the platform-native dialog, not Qt's fallback rendering.
```qml
import QtQuick
import QtQuick.Controls
import Qt.labs.platform as Platform
Button {
text: "Open document…"
onClicked: openDialog.open()
Platform.FileDialog {
id: openDialog
title: "Open document"
nameFilters: ["JSON files (*.json)", "All files (*)"]
fileMode: Platform.FileDialog.OpenFile
onAccepted: console.log("user picked:", currentFile)
}
}
```
Save-file is the same component with `fileMode: Platform.FileDialog.SaveFile`. Multi-select uses `OpenFiles` and `currentFiles` (plural).
The dialog returns a `file://` URL — convert it for filesystem use with the standard QML idiom `Qt.url.toLocalFile(currentFile)` or pass the URL straight to `QFile` / `QNetworkAccessManager` if upload-as-URL fits.
## Confirmations / message boxes
Use [`Qt.labs.platform.MessageDialog`](https://doc.qt.io/qt-6/qml-qt-labs-platform-messagedialog.html). Same idiom — declarative component, `open()` to show, signal handlers to react.
```qml
Platform.MessageDialog {
id: confirmDelete
title: "Delete todo?"
text: `${todo.title} will be removed permanently.`
buttons: Platform.MessageDialog.Yes | Platform.MessageDialog.Cancel
onAccepted: todoModel.removeRow(index)
}
```
For a non-modal toast-style "Saved!" feel, use Qt Quick Controls' `ToolTip.show(text, timeoutMs)` rather than a MessageDialog — that's a *banner*, not a confirmation, and shouldn't grab focus.
## Notifications
For "X has finished" / "you have a new Y" *system tray* notifications, use [`Qt.labs.platform.SystemTrayIcon`](https://doc.qt.io/qt-6/qml-qt-labs-platform-systemtrayicon.html) with `showMessage(title, body, icon, msecs)`:
```qml
Platform.SystemTrayIcon {
id: tray
visible: true
icon.source: "qrc:/icons/app.png"
Connections {
target: ImportJobs
function onCompleted(job) {
tray.showMessage("Import finished", `${job.rowCount} rows`)
}
}
}
```
Caveats:
- Cross-platform but **routed through the tray**. Users who hide the tray won't see the notification on Linux (the [XDG Notifications portal](https://flatpak.github.io/xdg-desktop-portal/) is the long-term fix; planned for a later release).
- macOS: notifications go to the Notification Center even if the tray isn't visible — works without caveat.
- Windows: same, via the action center.
Richer notifications (action buttons, replies, persistent banners) need platform-specific code per OS and aren't in scope for v0.x. If your app needs them sooner, drop a `QtPlatformNotification` wrapper next to `BackendConnection` and surface it as a QML singleton — happy to merge.
## Folder pickers
Same `FileDialog` component, `fileMode: Platform.FileDialog.OpenDirectory`. The native dialog hides files automatically.
## Standard paths
Don't hard-code `~/Downloads` or `%USERPROFILE%`. Use [`Qt.labs.platform.StandardPaths`](https://doc.qt.io/qt-6/qml-qt-labs-platform-standardpaths.html):
```qml
Platform.FileDialog {
folder: Platform.StandardPaths.writableLocation(Platform.StandardPaths.DocumentsLocation)
}
```
These resolve to the OS-correct location (`XDG_DOCUMENTS_DIR`, `~/Documents`, the Documents knownfolder, etc.) and stay correct under user policy / corporate config.
## Anti-patterns
- **Don't** `POST /api/show-dialog`. The PHP side has no hook into the user's window manager.
- **Don't** open a `QDialog` from a Doctrine listener. Doctrine listeners run inside the FrankenPHP child process; even if they could reach Qt, they'd block the worker.
- **Don't** roll a custom QML "FileDialog" using `Window` + `ListView` to browse the filesystem. It looks wrong on every OS, can't access OS-restricted paths (sandboxed downloads, Photos, etc.), and reinvents what Qt already ships.
## When to publish a Mercure event vs open a dialog directly
- **User initiates an action that needs confirmation** (delete, overwrite) → open the dialog from the QML handler that fired the action; the action only proceeds on `onAccepted`.
- **Server-side event the user should be told about** (background job done, push notification) → publish a Mercure event on `app://event/<name>`; QML's `MercureClient` listener fires the dialog or `tray.showMessage`.
The split keeps the *trigger* close to the user's intent and the *side-effect* declarative on the QML side.
## See also
- [QML API reference — `BackendConnection`, `MercureClient`](qml-api.md) — for handling server-pushed events that drive notifications.
- [Update semantics](update-semantics.md) — for the `commandFailed` / `commandTimedOut` signals that often want toast or dialog feedback.