Files
php-qml/docs/native-dialogs.md
magdev 91f4d619fc 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>
2026-05-03 20:21:23 +02:00

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 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