import QtQuick import Quickshell import Quickshell.Wayland import Quickshell.Hyprland import qs.Commons import qs.Ui import "." import "Switchboard.js" as Switchboard // Switchboard — every window on every workspace, one keystroke away. // // Shape contract with the shell: `opened`, `open(payloadJson)`, `close()`. // omarchy-shell reads `opened` to decide what `toggle` should do, and calls // open/close through the panel Loader (shell.qml). Item { id: root // Injected by the shell for plugins of kind `overlay` (shell.qml). property var shell: null property var manifest: null property var service: null property string omarchyPath: "" property bool opened: false property string query: "" property string cursorAddress: "" readonly property var appLibrary: root.shell ? root.shell.appLibrary : null // Config lives on the service half: an `overlay` receives no `settings` // injection, so the sibling service owns the file and we read through it. readonly property int maxTileHeight: root.service ? root.service.maxTileHeight : 0 readonly property int liveCaptures: root.service ? root.service.liveCaptures : 8 readonly property bool showScratchpad: root.service ? root.service.showScratchpad : true readonly property real dimOpacity: root.service ? root.service.dimOpacity : 0.25 // ------------------------------------------------------------------ model // // Structure and match state are kept separate. `structure` changes only when // windows do; `queryState` changes on every keystroke. If the query fed the // structure, QML would rebuild every delegate as the user typed and restart // every live capture on screen. // // `structure` is refreshed explicitly rather than bound, because a binding on // Hyprland.toplevels.values does not fire when a child property changes — and // Hyprland delivers a toplevel before it knows the toplevel's workspace, so a // bound model would latch onto an empty desktop and never recover. property var structure: ({ sections: [], total: 0 }) property string structureKey: "" readonly property var queryState: Switchboard.applyQuery(root.structure, root.query) function refreshStructure() { if (!root.opened) return var toplevels = Hyprland.toplevels ? Hyprland.toplevels.values : [] var activeWorkspace = Hyprland.focusedWorkspace ? Hyprland.focusedWorkspace.id : 0 // The guard is what keeps delegates alive: an unchanged desktop must never // produce a new model object. var key = Switchboard.structureSignature(toplevels, activeWorkspace) if (key === root.structureKey) return root.structureKey = key root.structure = Switchboard.buildModel(toplevels, activeWorkspace, { includeScratchpad: root.showScratchpad }) } // Quickshell populates Hyprland.toplevels only while something observes the // model — reading `.values` alone leaves it permanently empty. This keeps a // subscription alive so the overview has windows the instant it opens, which // matters because it is summoned by a keystroke and must not need a beat to // fill in. It creates no visual items. Instantiator { model: Hyprland.toplevels delegate: QtObject { required property var modelData } } // Compositor events are the authoritative signal for windows appearing, // moving, or closing while the overview is open. Connections { target: Hyprland enabled: root.opened function onRawEvent(event) { root.refreshStructure() } } // A short poll covers the gap the event stream leaves: the properties that // arrive on an already-known toplevel emit no compositor event of their own. // The signature guard makes an idle tick free. Timer { interval: 200 running: root.opened repeat: true triggeredOnStart: true onTriggered: root.refreshStructure() } // Only the cursor tile streams. Everything else paints one frame and freezes, // which is what keeps an overview of twenty windows affordable. readonly property var liveAddresses: { var out = ({}) if (!root.opened) return out if (root.liveCaptures <= 0) return out if (root.cursorAddress !== "") out[root.cursorAddress] = true var order = root.queryState.order for (var i = 0; i < order.length && Object.keys(out).length < root.liveCaptures; i++) { out[order[i]] = true } return out } // Geometry of the chrome around the tiles, in one place so the measuring code // and the rendering code can never disagree about it. readonly property var metrics: ({ gap: Style.space(14), columnGap: Style.space(22), chipHeight: Style.space(30), headerHeight: Style.space(20), headerGap: Style.space(10), minColumnWidth: Style.space(260), minTileHeight: Style.space(96), // Left to itself a tile grows until it fills its column's width or its // height, whichever binds first — that is what makes a near-empty desktop // show large, readable thumbnails. Config sets an explicit ceiling instead. maxTileHeight: root.maxTileHeight }) readonly property int columnWidth: Switchboard.columnWidth( root.structure.sections.length, scroller.width, root.metrics) // --------------------------------------------------------------- dragging property string dragAddress: "" property int dropIndex: -1 property point dragPoint: Qt.point(0, 0) readonly property string dragAppName: { if (root.dragAddress === "") return "" for (var s = 0; s < root.structure.sections.length; s++) { var windows = root.structure.sections[s].windows for (var w = 0; w < windows.length; w++) { if (windows[w].address === root.dragAddress) return windows[w].app } } return "" } // The workspace a window currently lives on, so a drop back onto its own // column can be treated as a no-op rather than a pointless dispatch. function workspaceOf(address) { for (var s = 0; s < root.structure.sections.length; s++) { var windows = root.structure.sections[s].windows for (var w = 0; w < windows.length; w++) { if (windows[w].address === address) return root.structure.sections[s].id } } return 0 } function updateDrop(globalX, globalY) { root.dragPoint = Qt.point(globalX, globalY) // The pointer arrives in window coordinates; the column strip starts at the // scroller's origin and scrolls, so both offsets have to come back out. var origin = scroller.mapToItem(null, 0, 0) var localX = globalX - origin.x + scroller.contentX root.dropIndex = Switchboard.columnIndexAt(localX, root.structure.sections.length, root.columnWidth, root.metrics) } function finishDrop() { var address = root.dragAddress var index = root.dropIndex root.dragAddress = "" root.dropIndex = -1 if (address === "" || index < 0 || index >= root.structure.sections.length) return var target = root.structure.sections[index].id if (target === root.workspaceOf(address)) return root.sendToWorkspace(address, target) // The move is silent, so the overview stays open and the tile reappears in // its new column on the next refresh — the reorganising continues. root.cursorAddress = address } // ------------------------------------------------------------- lifecycle function open(payloadJson) { root.query = "" root.structureKey = "" root.opened = true root.refreshStructure() // Start on the window the user is already in, so Enter with no typing is a // no-op rather than a surprise jump. Qt.callLater(root.selectActiveWindow) } function close() { root.opened = false root.query = "" root.cursorAddress = "" // Dropping the model tears down every ScreencopyView with it, so a closed // overview costs nothing while it waits to be summoned again. This is what // makes `keepLoaded` affordable. root.structure = { sections: [], total: 0 } root.structureKey = "" } function selectActiveWindow() { var active = Hyprland.activeToplevel if (active && active.address) { root.cursorAddress = String(active.address) if (root.isReachable(root.cursorAddress)) return } root.moveCursorTo(0) } function isReachable(address) { var order = root.queryState.order for (var i = 0; i < order.length; i++) if (order[i] === address) return true return false } function cursorIndex() { var order = root.queryState.order for (var i = 0; i < order.length; i++) if (order[i] === root.cursorAddress) return i return -1 } function moveCursorTo(index) { var order = root.queryState.order if (order.length === 0) { root.cursorAddress = ""; return } var clamped = Math.max(0, Math.min(order.length - 1, index)) root.cursorAddress = order[clamped] } // Traversal wraps, and visits matches only: a dimmed tile is on screen for // context, not as a stop on the way somewhere. function stepCursor(delta) { var order = root.queryState.order if (order.length === 0) return var index = root.cursorIndex() if (index === -1) { root.moveCursorTo(0); return } var next = (index + delta) % order.length if (next < 0) next += order.length root.cursorAddress = order[next] } // Filtering can strip the cursor's tile out from under it. Landing on the // first surviving match keeps Enter meaningful at every point in a query. onQueryStateChanged: { if (!root.opened) return if (root.cursorAddress !== "" && root.isReachable(root.cursorAddress)) return root.moveCursorTo(0) } // --------------------------------------------------------------- actions function dismiss() { root.close() if (root.shell && typeof root.shell.hide === "function") { root.shell.hide((root.manifest && root.manifest.id) || "io.github.thebenwalther.switchboard") } } // Resolves an address back to the live toplevel handle, so actions can use // the Wayland protocol instead of a dispatcher string. function toplevelFor(address) { for (var s = 0; s < root.structure.sections.length; s++) { var windows = root.structure.sections[s].windows for (var w = 0; w < windows.length; w++) { if (windows[w].address === address) return windows[w].toplevel } } return null } // ------------------------------------------------------------- hero return // // On a jump the overlay does not cut away. The chosen tile flies to where its // window actually sits on the desktop while the rest fades, so the gesture // reads as the window coming to meet you. The geometry is free: Hyprland // already told us the window's rect. property bool heroRunning: false property rect heroFrom: Qt.rect(0, 0, 0, 0) property rect heroTo: Qt.rect(0, 0, 0, 0) property rect cursorRect: Qt.rect(0, 0, 0, 0) readonly property bool heroEnabled: root.service ? root.service.heroReturn : true // The window rect is in compositor coordinates; this surface covers one // monitor, so the monitor's origin has to come back out. function screenRectFor(address) { var monitor = Hyprland.focusedMonitor if (!monitor) return Qt.rect(0, 0, 0, 0) for (var s = 0; s < root.structure.sections.length; s++) { var windows = root.structure.sections[s].windows for (var w = 0; w < windows.length; w++) { if (windows[w].address !== address) continue var r = windows[w].rect if (!r) return Qt.rect(0, 0, 0, 0) return Qt.rect(r.x - monitor.x, r.y - monitor.y, r.width, r.height) } } return Qt.rect(0, 0, 0, 0) } function jumpTo(address, fromRect) { if (!address) return var toplevel = root.toplevelFor(address) var to = root.screenRectFor(address) var canFly = root.heroEnabled && fromRect !== undefined && fromRect.width > 0 && to.width > 0 && !root.heroRunning if (!canFly) { // Dismiss first: this surface holds exclusive keyboard focus, and the // window cannot take focus until it has been given up. root.dismiss() root.activateToplevel(toplevel, address) return } root.heroFrom = fromRect root.heroTo = to root.heroRunning = true // Focus is handed over a frame later, not in this one. Setting // `heroRunning` drops this surface's exclusive keyboard focus, but that // change only reaches the compositor on the next commit — activating before // it lands means Hyprland is still looking at a focus-grabbing layer and // the jump is quietly ignored. One tick is imperceptible, and the window // arriving underneath the ghost mid-flight is the better choreography. root.pendingToplevel = toplevel root.pendingAddress = address handoff.restart() heroFlight.restart() } property var pendingToplevel: null property string pendingAddress: "" Timer { id: handoff interval: 32 onTriggered: { root.activateToplevel(root.pendingToplevel, root.pendingAddress) root.pendingToplevel = null root.pendingAddress = "" } } function activateToplevel(toplevel, address) { if (root.service) root.service.focusToplevel(toplevel, address) else if (toplevel && toplevel.wayland) toplevel.wayland.activate() } function closeWindow(address) { if (!address) return // Step the cursor before the window disappears, so focus does not jump to // the far end of the overview when a tile is removed mid-list. var order = root.queryState.order var index = root.cursorIndex() var nextAddress = "" if (index !== -1 && order.length > 1) { nextAddress = order[index + 1 < order.length ? index + 1 : index - 1] } var toplevel = root.toplevelFor(address) if (root.service) root.service.closeToplevel(toplevel, address) else if (toplevel && toplevel.wayland) toplevel.wayland.close() root.cursorAddress = nextAddress } function sendToWorkspace(address, workspaceId) { if (!address) return if (root.service) root.service.moveWindowToWorkspace(address, workspaceId) else { var command = Switchboard.moveCommand(address, workspaceId) if (command !== "") Hyprland.dispatch(command) } } function jumpToBadge(digit) { var address = Switchboard.addressForBadge(root.queryState, digit) if (address === "") return if (address === root.cursorAddress) { root.jumpTo(address, root.cursorRect); return } // Move the cursor first and jump on the next frame, once that tile has // reported where it is. A digit still feels instant — the wait is one // frame — and it flies from the same place Enter would. root.cursorAddress = address Qt.callLater(function () { root.jumpTo(address, root.cursorRect) }) } // Reports the real geometry of every layout box, for diagnosing an overview // that lays out at the wrong size. Reachable as // `omarchy-shell switchboard geometry` through the service. function debugGeometry() { return JSON.stringify({ opened: root.opened, window: { w: window.width, h: window.height }, screen: window.screen ? String(window.screen.name) : "null", content: { w: content.width, h: content.height }, scroller: { w: scroller.width, h: scroller.height }, columns: { w: columns.implicitWidth, h: columns.implicitHeight }, columnWidth: root.columnWidth, sectionCount: root.structure.sections.length, total: root.structure.total, sections: root.structure.sections.map(function (section) { return { id: section.id, windows: section.windows.map(function (w) { return { app: w.app, aspect: Math.round(w.aspect * 100) / 100 } }), rows: Switchboard.packColumn(section.windows, root.columnWidth, scroller.height - Style.space(8), root.metrics) .rows.map(function (r) { return r.cells.map(function (c) { return c.width + "x" + c.height }).join(" ") }) } }) }) } function screenForMonitor(monitor) { if (!monitor) return null var name = String(monitor.name || "") var screens = Quickshell.screens if (!screens) return null for (var i = 0; i < screens.length; i++) { if (screens[i] && String(screens[i].name) === name) return screens[i] } return null } // ---------------------------------------------------------------- surface PanelWindow { id: window visible: root.opened // Hyprland reports the focused monitor; the overview belongs where the // user is looking. Other outputs keep showing their own desktop. // // HyprlandMonitor carries no screen handle, only a name, so the matching // Quickshell screen has to be found by name. A null result is a safe // fallback — PanelWindow then picks the default output — and it does // happen: a compositor mid-reconfigure can report a monitor Quickshell has // not seen yet. screen: root.screenForMonitor(Hyprland.focusedMonitor) anchors { top: true; bottom: true; left: true; right: true } color: "transparent" // Matches omarchy.menu's own full-screen overlay contract (Menu.qml). The // namespace is public so a user can opt into `layerrule = blur, omarchy-switchboard`; // tiles are opaque, so blur only softens the desktop behind them. WlrLayershell.namespace: "omarchy-switchboard" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: root.opened && !root.heroRunning ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None exclusionMode: ExclusionMode.Ignore Rectangle { id: scrim anchors.fill: parent // The theme's menu scrim is tuned for a small centred card and sits at // half alpha, which leaves the desktop competing with the tiles. Here the // tiles are the content, so the same theme colour is laid down opaque // enough to become a ground rather than a tint. color: Util.alpha(Color.menu.scrim, 0.97) opacity: root.opened && !root.heroRunning ? 1 : 0 Behavior on opacity { NumberAnimation { duration: root.heroRunning ? 200 : 140; easing.type: Easing.OutCubic } } MouseArea { anchors.fill: parent onClicked: root.dismiss() } } // ------------------------------------------------------------ key input FocusScope { id: keys anchors.fill: parent focus: root.opened && !root.heroRunning opacity: root.heroRunning ? 0 : 1 Behavior on opacity { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } Keys.onPressed: function (event) { if (!root.opened) return if (event.key === Qt.Key_Escape) { root.dismiss(); event.accepted = true; return } if (event.key === Qt.Key_Backspace) { // Backspace edits the query until there is none, then leaves. One key // walks all the way back out. if (root.query.length > 0) root.query = root.query.slice(0, -1) else root.dismiss() event.accepted = true; return } if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { if (event.modifiers & Qt.ShiftModifier) root.closeWindow(root.cursorAddress) else root.jumpTo(root.cursorAddress, root.cursorRect) event.accepted = true; return } if (event.key === Qt.Key_Delete) { root.closeWindow(root.cursorAddress); event.accepted = true; return } if (event.key === Qt.Key_Tab) { root.stepCursor(event.modifiers & Qt.ShiftModifier ? -1 : 1) event.accepted = true; return } if (event.key === Qt.Key_Backtab) { root.stepCursor(-1); event.accepted = true; return } if (event.key === Qt.Key_Right || event.key === Qt.Key_Down) { root.stepCursor(1); event.accepted = true; return } if (event.key === Qt.Key_Left || event.key === Qt.Key_Up) { root.stepCursor(-1); event.accepted = true; return } // Digits jump, letters filter. Two behaviours, no mode to remember, // and no ambiguity between them. if (event.key >= Qt.Key_1 && event.key <= Qt.Key_9) { var digit = event.key - Qt.Key_0 if (event.modifiers & Qt.ShiftModifier) { root.sendToWorkspace(root.cursorAddress, digit) } else { root.jumpToBadge(digit) } event.accepted = true; return } if (event.text.length > 0 && event.text.charCodeAt(0) >= 0x20) { root.query += event.text event.accepted = true } } // ---------------------------------------------------------- content Item { id: content anchors.fill: parent anchors.margins: Style.gapsOut * 2 + Style.space(18) // The key hints live at the bottom of the surface; content must stop // above them rather than sliding underneath. anchors.bottomMargin: Style.gapsOut * 2 + Style.space(46) // ------------------------------------------------------- the header Item { id: searchHeader anchors.top: parent.top anchors.horizontalCenter: parent.horizontalCenter width: parent.width height: Style.space(58) Row { anchors.centerIn: parent spacing: Style.space(12) Text { anchors.verticalCenter: parent.verticalCenter textFormat: Text.PlainText text: "" // nerd-font magnifier, the shell's own icon set color: root.query.length > 0 ? Color.accent : Qt.darker(Color.popups.text, 1.6) font.family: Style.font.family font.pixelSize: Style.font.heading renderType: Text.NativeRendering Behavior on color { ColorAnimation { duration: 160 } } } Text { id: queryLabel anchors.verticalCenter: parent.verticalCenter textFormat: Text.PlainText text: root.query color: Color.popups.text font.family: Style.font.family font.pixelSize: Style.font.heading font.bold: true renderType: Text.NativeRendering } // A caret, so an empty query still reads as somewhere you can type. Rectangle { anchors.verticalCenter: parent.verticalCenter width: Math.max(1, Style.space(2)) height: Style.font.heading color: Color.accent opacity: caretBlink.on ? 1 : 0 Behavior on opacity { NumberAnimation { duration: 90 } } } } // The one line of state, under the query. It says nothing until it // has something to say. Text { anchors.top: parent.verticalCenter anchors.topMargin: Style.space(16) anchors.horizontalCenter: parent.horizontalCenter textFormat: Text.PlainText text: { if (root.structure.total === 0) return "" if (root.query.length === 0) { // A quiet orientation line, not an instruction: the footer // already carries the keys. var windows = root.structure.total + (root.structure.total === 1 ? " window" : " windows") var spaces = root.structure.sections.length return windows + " across " + spaces + (spaces === 1 ? " workspace" : " workspaces") } if (root.queryState.matched === 0) return "no window matches" return root.queryState.matched + " of " + root.structure.total } color: root.queryState.matched === 0 ? Color.urgent : Qt.darker(Color.popups.text, 1.5) font.family: Style.font.family font.pixelSize: Style.font.caption font.letterSpacing: 0.6 renderType: Text.NativeRendering opacity: text.length > 0 ? 1 : 0 Behavior on opacity { NumberAnimation { duration: 140 } } } } // -------------------------------------------------------- the tiles Flickable { id: scroller anchors.top: searchHeader.bottom anchors.topMargin: Style.space(10) anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom contentWidth: Math.max(width, columns.implicitWidth) contentHeight: Math.max(height, columns.implicitHeight) clip: true interactive: contentWidth > width || contentHeight > height boundsBehavior: Flickable.StopAtBounds // Workspaces read left to right, the same order they carry in the // compositor and in the keybindings people already use. Row { id: columns spacing: root.metrics.columnGap // Centred while the overview fits, top-aligned once it scrolls, so // a sparse desktop sits on the screen's axis instead of clinging // to the top edge. y: Math.max(0, Math.round((scroller.height - implicitHeight) / 2)) Repeater { model: root.structure.sections WorkspaceSection { required property var modelData required property int index section: modelData columnIndex: index availableWidth: root.columnWidth // A hair less than the viewport: a column packed to exactly the // clip height loses the last pixels of its bottom chip to // rounding. availableHeight: scroller.height - Style.space(8) metrics: root.metrics queryState: root.queryState cursorAddress: root.cursorAddress liveAddresses: root.liveAddresses dimOpacity: root.dimOpacity appLibrary: root.appLibrary dropTarget: root.dragAddress !== "" && root.dropIndex === index onActivated: function (address, frameRect) { root.jumpTo(address, frameRect) } onCloseRequested: function (address) { root.closeWindow(address) } onCursorRequested: function (address) { if (root.dragAddress === "") root.cursorAddress = address } onDragStarted: function (address) { root.dragAddress = address root.cursorAddress = address } onRectReported: function (address, frameRect) { if (address === root.cursorAddress) root.cursorRect = frameRect } onDragMoved: function (gx, gy) { root.updateDrop(gx, gy) } onDragEnded: function (gx, gy) { if (gx >= 0) root.updateDrop(gx, gy) else root.dropIndex = -1 root.finishDrop() } } } } } // ------------------------------------------------------ empty state Text { anchors.centerIn: parent visible: root.opened && root.structure.total === 0 textFormat: Text.PlainText text: "Nothing open." color: Qt.darker(Color.popups.text, 1.5) font.family: Style.font.family font.pixelSize: Style.font.subtitle renderType: Text.NativeRendering } } } // ------------------------------------------------------------- footer // The tile in flight. A plain surface rather than a live capture: the real // window is already on screen underneath by this point, so re-capturing it // would cost a frame to show something the user can see anyway. BorderSurface { id: hero visible: root.heroRunning z: 90 radius: Style.cornerRadius color: Util.alpha(Color.accent, 0.14) borderSpec: Border.flat(Color.accent, Math.max(1, Style.space(2))) opacity: 0 NumberAnimation on opacity { id: heroFade; running: false } } ParallelAnimation { id: heroFlight onStarted: { hero.x = root.heroFrom.x hero.y = root.heroFrom.y hero.width = root.heroFrom.width hero.height = root.heroFrom.height hero.opacity = 1 } onStopped: { hero.opacity = 0 root.heroRunning = false // A flight cut short must never swallow the jump it was decorating. if (root.pendingAddress !== "") { handoff.stop() root.activateToplevel(root.pendingToplevel, root.pendingAddress) root.pendingToplevel = null root.pendingAddress = "" } root.dismiss() } NumberAnimation { target: hero; property: "x"; to: root.heroTo.x; duration: 240; easing.type: Easing.OutCubic } NumberAnimation { target: hero; property: "y"; to: root.heroTo.y; duration: 240; easing.type: Easing.OutCubic } NumberAnimation { target: hero; property: "width"; to: root.heroTo.width; duration: 240; easing.type: Easing.OutCubic } NumberAnimation { target: hero; property: "height"; to: root.heroTo.height; duration: 240; easing.type: Easing.OutCubic } NumberAnimation { target: hero; property: "opacity"; to: 0; duration: 240; easing.type: Easing.InCubic } } // A label carried by the pointer while dragging: the tile itself stays in // place and dims, so the grid never reflows mid-gesture. Item { id: ghost visible: root.dragAddress !== "" x: root.dragPoint.x + Style.space(14) y: root.dragPoint.y + Style.space(14) width: ghostSurface.width height: ghostSurface.height z: 100 BorderSurface { id: ghostSurface width: ghostLabel.implicitWidth + Style.space(24) height: Style.space(30) radius: Style.cornerRadius > 0 ? height / 2 : 0 color: Color.accent borderSpec: Border.none() Text { id: ghostLabel anchors.centerIn: parent textFormat: Text.PlainText text: root.dropIndex >= 0 && root.dropIndex < root.structure.sections.length ? root.dragAppName + " \u2192 " + root.structure.sections[root.dropIndex].label : root.dragAppName color: Color.popups.background font.family: Style.font.family font.pixelSize: Style.font.bodySmall font.bold: true renderType: Text.NativeRendering } } } Text { anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom anchors.bottomMargin: Style.space(14) textFormat: Text.PlainText text: root.dragAddress !== "" ? "drop on a workspace to move the window release outside to cancel" : "1–9 jump type to filter ⏎ open ⇧⏎ close ⇧1–9 move esc dismiss" color: Qt.darker(Color.popups.text, 1.9) font.family: Style.font.family font.pixelSize: Style.font.caption font.letterSpacing: 0.8 renderType: Text.NativeRendering opacity: root.opened ? 1 : 0 Behavior on opacity { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } } } } QtObject { id: caretBlink property bool on: true } Timer { interval: 560 running: root.opened repeat: true onTriggered: caretBlink.on = !caretBlink.on onRunningChanged: if (!running) caretBlink.on = true } }