114 lines
5.7 KiB
Markdown
114 lines
5.7 KiB
Markdown
|
|
# 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.
|