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>
5.7 KiB
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) 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 — it draws the platform-native dialog, not Qt's fallback rendering.
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. Same idiom — declarative component, open() to show, signal handlers to react.
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 with showMessage(title, body, icon, msecs):
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 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:
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
QDialogfrom 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+ListViewto 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'sMercureClientlistener fires the dialog ortray.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— for handling server-pushed events that drive notifications. - Update semantics — for the
commandFailed/commandTimedOutsignals that often want toast or dialog feedback.