diff --git a/CHANGELOG.md b/CHANGELOG.md index de20c80..c84a850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +- Scenes tab redesign. The header names the workspace until a scene is + applied, then the scene with how many apps are placed. Saved scenes are + cards like the layout list, with the apps drawn in their zones; the row + applies. One zone list replaces CONTENT, ZONE and CHANGE TO; the picker + under the selected zone filters as you type, shows app icons, and groups + open windows (with titles), remote desktops, and installed apps. Zone + cards show what they hold with icon, name, and state, plus Change… and + Clear; a card that needs attention is outlined and offers Retry. Digits + select zones by fill number, hovering a match previews it in the card, and + zones are listed by position ("Top left"). The catalog lists each app's + icon and each saved scene's sources, and skips placeholder window classes. + - Remote connections now belong to Remote Desktops. Remove Hypertile's legacy controller, host adapters, display scripts, and remote controls. Scenes and session recovery use ordinary installed apps; pinned swaps remain generic. diff --git a/docs/SCENES.md b/docs/SCENES.md index 6df00ac..0ada38c 100644 --- a/docs/SCENES.md +++ b/docs/SCENES.md @@ -13,20 +13,34 @@ for app scenes. ## Overlay -Open **Super+Alt+L**, switch to **Scenes**, and select a zone. Under **Change to**, -**Installed apps** offers desktop entries with a known window identity. Select -**MacBook (Remote Desktop)** to launch or reuse that computer in the selected -zone. Install its launcher in Remote Desktops first. Each computer's launcher -uses that computer's default profile. +Open **Super+Alt+L** and switch to **Scenes**. Each zone card on the screen +shows what it holds: the app's icon and name with its state, or *Local +windows · fill order*. Click a card (or a row under **Zones**, or press its +fill number) and the picker below takes the keys: type to search, ↑ ↓ pick a +match, Enter assigns it, Esc clears the search. Hovering a match previews it +in the selected card. Zones are listed by where they sit ("Top left"), with +the layout's own name where positions would collide. **Local windows** and **Empty** are the two chips above the +list. **Open here** lists the windows already on this workspace with their +titles; choosing one pins that window without launching anything. **Remote +desktops** lists each computer's Remote Desktops launcher (install it there +first; it uses that computer's default profile). **Apps** lists installed +desktop entries with a known window identity; entries whose window class is +a packaging placeholder are left out. A card's **Change…** and **Clear** do +the same from the screen, and a card that needs attention is outlined and +offers **Retry**. Applications declaring `StartupWMClass` are available immediately. For other apps, an open window whose class equals the desktop ID without `.desktop` provides the identity. Apps without either can be configured through the CLI -with an explicit class and optional exact title. **Open apps** retains the -previous local-only behavior: pin one matching tiled window already on this -workspace, without launching it. +with an explicit class and optional exact title. -**Save scene…** stores the current definition. **Apply** requests that saved +The header names the workspace until a scene is applied, then the scene, with +the layout, the workspace, and how many apps are placed. **Scenes** lists the +saved scenes as cards: the layout with the apps it places drawn in their +zones, and what it holds. Click a card to apply it; the delete appears on hover +and confirms inline. + +**Save as scene…** stores the current definition. **Apply** requests that saved arrangement again, including apps you moved or closed. **Retry** explicitly rechecks placement and may retry a failed/timed-out launch. Closing an app or moving it yourself leaves its source marked *Closed* or *Moved*. Changes to the diff --git a/plugin/Content.js b/plugin/Content.js index 5ab6260..6566502 100644 --- a/plugin/Content.js +++ b/plugin/Content.js @@ -38,10 +38,20 @@ function label(source) { if (!source) return "Local windows" if (source.type === "empty") return "Empty" if (source.type === "local") return source.app_class || "Local windows" - if (source.type === "app") return source.app_name || source.desktop_id + if (source.type === "app") return displayName(source.app_name || source.desktop_id) return "Unknown source" } +// Remote Desktops installs one launcher per computer, named "X (Remote +// Desktop)"; the picker groups them apart and drops the suffix. +function isRemoteDesktop(app) { + return !!app && (String(app.desktop_id || "").indexOf("remote-desktops-") === 0 || !!app.app_title) +} + +function displayName(name) { + return String(name || "").replace(/\s*\(Remote Desktop\)\s*$/, "") +} + // The zone card's chip shows its assigned content. function chip(source) { if (!source) return "" @@ -74,9 +84,10 @@ function detail(source) { } // The header for the workspace's scene: what it is called, what state it -// is in, and whether the saved definition is behind. -function sceneTitle(scene) { - if (!scene || !scene.phase || scene.phase === "none" || scene.phase === "restored") return "No scene" +// is in, and whether the saved definition is behind. Without a scene the +// header is about the workspace itself. +function sceneTitle(scene, workspace) { + if (!scene || !scene.phase || scene.phase === "none" || scene.phase === "restored") return workspace ? "Workspace " + workspace : "No scene" return (scene.document && scene.document.name) || "Unsaved scene" } @@ -84,10 +95,106 @@ function sceneModified(scene) { return !!(scene && scene.document && scene.document.name && scene.modified && scene.phase !== "restored") } +// How far the scene has come: "2 of 3 placed" while apps are placed, then +// the phase in words. +function sceneProgress(scene) { + var phase = scene ? scene.phase : "" + var sources = (scene && scene.sources) || [] + var n = 0, placed = 0, trouble = 0 + for (var i = 0; i < sources.length; i++) { + var s = sources[i] + if (s.type !== "app" && !(s.type === "local" && s.app_class)) continue + n++ + if (s.status === "ready") placed++ + else if (troubled(s.status) || s.error) trouble++ + } + if (n > 0 && ["connecting", "partial", "ready"].indexOf(phase) !== -1) { + var t = placed + " of " + n + " placed" + if (trouble > 0) t += " · " + trouble + (trouble === 1 ? " needs attention" : " need attention") + return t + } + return status(phase) +} + function sceneMeta(scene, layout, workspace) { var bits = [] - if (layout) bits.push(layout + (workspace ? " on workspace " + workspace : "")) - var s = scene ? status(scene.phase) : "" - if (s !== "") bits.push(s) + if (layout) bits.push(layout) + var active = !!(scene && scene.phase && scene.phase !== "none" && scene.phase !== "restored") + if (!active) { + if (scene && scene.phase === "restored") bits.push(status("restored")) + else if (layout) bits.push("local windows in every zone") + return bits.join(" · ") + } + if (workspace) bits.push("workspace " + workspace) + var p = sceneProgress(scene) + if (p !== "") bits.push(p) return bits.join(" · ") } + +// The apps a scene places, by name, and a short form for a card's meta +// line: "Chrome, Cursor +2". +function appNames(sources) { + var out = [] + for (var i = 0; i < (sources || []).length; i++) { + var s = sources[i] + if (s.type === "app") out.push(displayName(s.app_name || s.desktop_id)) + else if (s.type === "local" && s.app_class) out.push(s.app_class) + } + return out +} + +function summary(names, max) { + max = max || 2 + if (names.length <= max) return names.join(", ") + return names.slice(0, max).join(", ") + " +" + (names.length - max) +} + +// Case-insensitive substring match of a search query against any field. +function matches(query, fields) { + var q = String(query || "").trim().toLowerCase() + if (q === "") return true + for (var i = 0; i < fields.length; i++) if (String(fields[i] || "").toLowerCase().indexOf(q) !== -1) return true + return false +} + +// The windows open on a workspace, one entry per class: the one title +// when there is one window, else how many there are. +function openApps(windows, workspace) { + var by = {}, out = [] + for (var i = 0; i < (windows || []).length; i++) { + var w = windows[i] + if (String(w.workspace) !== String(workspace) || !w.class) continue + if (!by[w.class]) { by[w.class] = { app_class: w.class, count: 0, title: "" }; out.push(by[w.class]) } + by[w.class].count++ + by[w.class].title = w.title || "" + } + out.sort(function(a, b) { return a.app_class < b.app_class ? -1 : a.app_class > b.app_class ? 1 : 0 }) + return out +} + +// Where a zone sits, in words: "Top left", "Right", "Full screen". A word +// is used only when it tells zones apart, so a column that fills the +// height is just "Left". Zones whose words would collide get "" and are +// shown by their layout name instead. +function positionLabel(zone, area) { + var tol = Math.max(2, Math.min(area.w, area.h) * 0.02) + var left = zone.x <= area.x + tol, right = zone.x + zone.w >= area.x + area.w - tol + var top = zone.y <= area.y + tol, bottom = zone.y + zone.h >= area.y + area.h - tol + var h = (left && right) ? "" : left ? "left" : right ? "right" : "center" + var v = (top && bottom) ? "" : top ? "top" : bottom ? "bottom" : "middle" + var words = (v + " " + h).trim() + if (words === "") return "Full screen" + return words.charAt(0).toUpperCase() + words.slice(1) +} + +function positionLabels(zones, area) { + var labels = {}, counts = {} + if (!area) return labels + for (var i = 0; i < zones.length; i++) { + var l = positionLabel(zones[i], area) + labels[zones[i].name] = l + counts[l] = (counts[l] || 0) + 1 + } + for (var name in labels) if (counts[labels[name]] > 1) labels[name] = "" + return labels +} diff --git a/plugin/ContentPane.qml b/plugin/ContentPane.qml index b7fa4bb..9c6c8f6 100644 --- a/plugin/ContentPane.qml +++ b/plugin/ContentPane.qml @@ -2,20 +2,32 @@ import QtQuick import qs.Commons import qs.Ui import "Content.js" as Content -import "Editor.js" as Editor - -// The body of the rail's Scenes tab: the saved scenes, what each zone of -// the workspace's layout holds, and the selected zone with the choices -// for it. The header above it (the scene's name and state, Save and -// Restore) is the rail's own. Every change goes through hypertile-ctl -// scene; the catalog is re-read every couple of seconds while the -// overlay is open, so the states here follow the controller. + +// The body of the rail's Scenes tab: the saved scenes as cards, what each +// zone of the workspace's layout holds, and a picker for the selected zone +// that filters as you type. The header above it (the scene's name and +// state, Save and Restore) is the rail's own. Every change goes through +// hypertile-ctl scene; the catalog is re-read every couple of seconds while +// the overlay is open, so the states here follow the controller. Column { id: pane required property var overlay readonly property var catalog: overlay.contentCatalog || ({}) readonly property var scene: catalog.current || ({}) - readonly property var scenes: catalog.scenes || [] + // The catalog is re-read every couple of seconds; these keep their + // identity until their content changes, so the rows (and what the + // pointer is over) survive a poll. + property var scenes: [] + property var apps: [] + onCatalogChanged: syncLists() + Component.onCompleted: syncLists() + function syncLists() { + syncOpenRows() + var nextScenes = catalog.scenes || [] + if (JSON.stringify(nextScenes) !== JSON.stringify(scenes)) scenes = nextScenes + var nextApps = catalog.apps || [] + if (JSON.stringify(nextApps) !== JSON.stringify(apps)) apps = nextApps + } readonly property bool ready: overlay.contentCatalog !== null && !overlay.catalogFailed readonly property bool usable: ready && overlay.viewedIsActive // The zones in fill order, as the numerals on the screen read them. @@ -35,15 +47,108 @@ Column { readonly property color fg: overlay.foreground readonly property color accent: overlay.accent readonly property string family: overlay.fontFamily - property bool details: false + readonly property int iconSize: Math.round(overlay.uiFontSmall * 1.4) property string deleting: "" // the saved scene a delete is being confirmed for + // ---- the picker: what is typed, and the rows that match it, in the + // order they are listed (Enter takes the hot one). + readonly property string query: searchField.text + readonly property bool searching: query.trim() !== "" + property int hot: 0 + onQueryChanged: hot = 0 + property var openRows: [] + Connections { + target: pane.overlay + function onWindowsChanged() { pane.syncOpenRows() } + function onWorkspaceIdChanged() { pane.syncOpenRows() } + } + function syncOpenRows() { + var next = Content.openApps(overlay.windows, overlay.workspaceId) + if (JSON.stringify(next) !== JSON.stringify(openRows)) openRows = next + } + readonly property var builtinMatches: { + if (!searching) return [] + var out = [] + if (Content.matches(query, ["Local windows", "fill order"])) out.push({ kind: "local", name: "Local windows", trait: "by fill order", icon: "" }) + if (Content.matches(query, ["Empty", "nothing opens here"])) out.push({ kind: "empty", name: "Empty", trait: "nothing opens here", icon: "" }) + return out + } + readonly property var openMatches: { + var out = [] + for (var i = 0; i < openRows.length; i++) { + var r = openRows[i] + var name = overlay.nameForClass(r.app_class, apps) + if (!Content.matches(query, [name, r.app_class, r.title])) continue + out.push({ kind: "open", name: name, app_class: r.app_class, trait: r.count === 1 ? r.title : r.count + " windows", icon: overlay.iconForClass(r.app_class, apps) }) + } + return out + } + readonly property var remoteMatches: pane.appRows(pane.query, pane.apps, true) + readonly property var appMatches: pane.appRows(pane.query, pane.apps, false) + readonly property var matches: builtinMatches.concat(openMatches, remoteMatches, appMatches) + readonly property int matchCount: matches.length + + function appRows(query, apps, remote) { + var out = [] + for (var i = 0; i < apps.length; i++) { + var a = apps[i] + if (Content.isRemoteDesktop(a) !== remote) continue + var name = remote ? Content.displayName(a.name) : a.name + if (!Content.matches(query, [name, a.app_class, a.desktop_id])) continue + out.push({ kind: "app", name: name, desktop_id: a.desktop_id, app: a, trait: "", icon: pane.overlay.resolveIcon(a.icon) }) + } + return out + } + + function focusSearch() { + Qt.callLater(function() { if (searchField.visible) { searchField.forceActiveFocus(); searchField.selectAll() } }) + } + function setQuery(text) { searchField.text = String(text || ""); pane.focusSearch() } + // A key typed while the overlay's key handler had the focus: the search + // takes it and the ones after it. + function typeSearch(text) { + searchField.text = searchField.text + String(text || "") + Qt.callLater(function() { if (searchField.visible) { searchField.forceActiveFocus(); searchField.cursorPosition = searchField.text.length } }) + } + function pickMatch() { + if (!pane.searching || pane.matches.length === 0) return + pane.choose(pane.matches[Math.max(0, Math.min(pane.hot, pane.matches.length - 1))]) + } + function choose(m) { + pane.overlay.hoverMatch = null + if (m.kind === "local") pane.overlay.assignContent("local") + else if (m.kind === "empty") pane.overlay.assignContent("empty") + else if (m.kind === "open") pane.overlay.assignContent("local", m.app_class) + else pane.overlay.assignApp(m.app) + searchField.text = "" + } + // Preview a match by its place in the list (-1 clears), as hovering does. + function hoverMatch(index) { + if (index < 0 || index >= pane.matches.length) { pane.overlay.hoverMatch = null; return } + pane.overlay.ghost(pane.ghostFor(pane.matches[index])) + } + function ghostFor(m) { + var key = m.kind + ":" + (m.desktop_id || m.app_class || m.kind) + return { key: key, kind: m.kind, name: m.name || (m.kind === "empty" ? "Empty" : "Local windows"), icon: m.icon || "" } + } + function isCurrent(m) { + var s = pane.source + if (m.kind === "local") return s === null || (s.type === "local" && !s.app_class) + if (m.kind === "empty") return s !== null && s.type === "empty" + if (m.kind === "open") return s !== null && s.type === "local" && s.app_class === m.app_class + return s !== null && s.type === "app" && s.desktop_id === m.desktop_id + } + spacing: Style.spacing.xl - // Another zone: back to the short view of it. + // Another zone: the picker starts clean and takes the keys. Connections { target: pane.overlay - function onSelectedChanged() { pane.details = false } + function onSelectedChanged() { + searchField.text = "" + pane.overlay.hoverMatch = null + if (pane.overlay.contentMode && pane.overlay.selected !== "" && pane.usable) pane.focusSearch() + } } // ---------------------------------------------------------- pieces @@ -66,15 +171,6 @@ Column { font.pixelSize: pane.overlay.uiCaption } - component Body: Text { - textFormat: Text.PlainText - width: pane.width - wrapMode: Text.WordWrap - color: pane.fg - font.family: pane.family - font.pixelSize: pane.overlay.uiFontSmall - } - component Action: Button { bordered: true radius: pane.overlay.radiusControl @@ -94,6 +190,7 @@ Column { spacing: Style.spacing.lg PanelSeparator { foreground: pane.fg; width: pane.width } Item { + visible: section.title !== "" width: pane.width implicitHeight: Math.max(sectionTitle.implicitHeight, sectionDetail.implicitHeight) PanelSectionHeader { @@ -121,61 +218,40 @@ Column { } } - // A small collapsible group inside a section, headed like the rail's - // collapsible sections. - component Disclosure: Item { - id: disclosure - property string text: "" - property bool open: false - signal toggled() + // A group inside the picker: a small heading over its rows. + component Group: Column { + property string title: "" + property string caption: "" width: pane.width - implicitHeight: disclosureRow.implicitHeight - Row { - id: disclosureRow - spacing: Style.spacing.sm - Text { - textFormat: Text.PlainText - text: disclosure.open ? "▾" : "▸" - color: Util.alpha(pane.fg, 0.7) - font.family: pane.family - font.pixelSize: pane.overlay.uiCaption - anchors.verticalCenter: parent.verticalCenter - } - PanelSectionHeader { - text: disclosure.text - foreground: pane.fg - fontFamily: pane.family - fontSize: pane.overlay.uiCaption - anchors.verticalCenter: parent.verticalCenter - } - } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: disclosure.toggled() - } + spacing: Style.spacing.xxs + Label { text: parent.title; topPadding: Style.spacing.xs; bottomPadding: Style.spacing.xxs } + Muted { visible: parent.caption !== ""; text: parent.caption; bottomPadding: Style.spacing.xs } } - // One line in a list: a name, a quieter phrase next to it, and a state - // on the right. The current one reads like the viewed layout in the - // LAYOUTS list. + // One line in a list: an optional badge and icon, a name, a quieter + // phrase next to it, and a state on the right. The current one reads + // like the viewed layout in the LAYOUTS list; the hot one is what Enter + // takes while a search is typed. component ListRow: Rectangle { id: row property string badge: "" // the fill number, as on the zone card property bool badgeStrong: true + property string icon: "" property string text: "" property string sub: "" property string trait: "" property bool current: false + property bool hot: false property bool urgent: false signal clicked() + signal hovered(bool on) width: pane.width - implicitHeight: rowMain.implicitHeight + Style.spacing.sm * 2 + implicitHeight: Math.max(rowMain.implicitHeight, rowIcon.visible ? rowIcon.height : 0, rowBadge.visible ? rowBadge.implicitHeight : 0) + Style.spacing.sm * 2 height: implicitHeight radius: pane.overlay.radiusControl - color: current ? Util.alpha(pane.accent, 0.14) : (rowHover.containsMouse ? Util.alpha(pane.fg, 0.06) : "transparent") - border.width: current ? 1 : 0 - border.color: Util.alpha(pane.accent, 0.6) + color: current ? Util.alpha(pane.accent, 0.14) : (hot ? Util.alpha(pane.accent, 0.08) : (rowHover.containsMouse ? Util.alpha(pane.fg, 0.06) : "transparent")) + border.width: (current || hot) ? 1 : 0 + border.color: Util.alpha(pane.accent, current ? 0.6 : 0.35) Behavior on color { ColorAnimation { duration: pane.overlay.motionFast } } Chip { id: rowBadge @@ -188,11 +264,26 @@ Column { fontFamily: pane.family fontSize: pane.overlay.uiCaption } + Image { + id: rowIcon + visible: row.icon !== "" + x: rowBadge.visible ? rowBadge.x + rowBadge.width + Style.spacing.md : Style.spacing.md + anchors.verticalCenter: parent.verticalCenter + width: pane.iconSize + height: pane.iconSize + sourceSize.width: pane.iconSize + sourceSize.height: pane.iconSize + source: row.icon + smooth: true + mipmap: true + asynchronous: true + } Text { id: rowMain - x: rowBadge.visible ? rowBadge.x + rowBadge.width + Style.spacing.md : Style.spacing.md + x: rowIcon.visible ? rowIcon.x + rowIcon.width + Style.spacing.md + : (rowBadge.visible ? rowBadge.x + rowBadge.width + Style.spacing.md : Style.spacing.md) anchors.verticalCenter: parent.verticalCenter - width: parent.width - x - Style.spacing.md - (rowTrait.visible ? rowTrait.implicitWidth + Style.spacing.lg : 0) + width: parent.width - x - Style.spacing.md - (rowTrait.visible ? rowTrait.width + Style.spacing.lg : 0) textFormat: Text.PlainText text: row.text color: row.current ? pane.accent : pane.fg @@ -220,11 +311,13 @@ Column { anchors.right: parent.right anchors.rightMargin: Style.spacing.md anchors.verticalCenter: parent.verticalCenter + width: Math.min(implicitWidth, row.width * 0.45) textFormat: Text.PlainText text: row.trait color: row.urgent ? Color.urgent : Util.alpha(pane.fg, 0.62) font.family: pane.family font.pixelSize: pane.overlay.uiCaption + elide: Text.ElideRight } MouseArea { id: rowHover @@ -232,6 +325,120 @@ Column { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: row.clicked() + onEntered: row.hovered(true) + onExited: row.hovered(false) + } + } + + // A picker row for one match, by its place in the flat list of matches. + component MatchRow: ListRow { + required property var modelData + required property int index + property int offset: 0 + readonly property var match: pane.matches[offset + index] || modelData + icon: modelData.icon + text: modelData.name + trait: modelData.trait + current: pane.isCurrent(match) + hot: pane.searching && pane.hot === offset + index + onClicked: pane.choose(match) + // Hovering previews the match in the selected zone's card. + onHovered: function(on) { on ? pane.overlay.ghost(pane.ghostFor(match)) : pane.overlay.unghost(pane.ghostFor(match).key) } + } + + // A saved scene, drawn like a layout in the LAYOUTS list: a picture of + // its layout with the apps it places, its name, and what it holds. + // Clicking applies it; the delete shows on hover and confirms inline. + component SceneCard: Rectangle { + id: card + required property var modelData + required property int index + // The Repeater hands delegates a converted copy; the original entry + // keeps its plain arrays for the thumbnail. + readonly property var entry: pane.scenes[index] || modelData + readonly property bool applied: pane.appliedScene === entry.name + readonly property bool valid: entry.valid === true + readonly property bool modified: applied && pane.scene.modified === true + readonly property var spec: valid ? pane.overlay.layoutSpec(entry.layout) : null + readonly property var sources: entry.sources || [] + readonly property bool canApply: valid && !(applied && !modified) && !pane.overlay.busy + readonly property string meta: { + if (!valid) return entry.error || "This scene cannot be applied" + var bits = [] + if (applied) bits.push(modified ? "applied · modified" : "applied") + var names = Content.summary(Content.appNames(sources), 2) + bits.push(names !== "" ? names : "local windows") + bits.push(String(entry.layout || "")) + return bits.join(" · ") + } + width: pane.width + implicitHeight: cardRow.implicitHeight + Style.spacing.sm * 2 + height: implicitHeight + radius: pane.overlay.radiusControl + color: applied ? Util.alpha(pane.accent, 0.14) : (cardHover.containsMouse ? Util.alpha(pane.fg, 0.06) : "transparent") + border.width: applied ? 1 : 0 + border.color: Util.alpha(pane.accent, 0.6) + Behavior on color { ColorAnimation { duration: pane.overlay.motionFast } } + + MouseArea { + id: cardHover + anchors.fill: parent + hoverEnabled: true + cursorShape: card.canApply ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: if (card.canApply) pane.overlay.sceneAction("apply", card.entry.name) + } + Row { + id: cardRow + x: Style.spacing.sm + y: Style.spacing.sm + width: parent.width - Style.spacing.sm * 2 - deleteButton.width - Style.spacing.sm + spacing: Style.spacing.lg + Thumb { + overlay: pane.overlay + spec: card.spec + sources: card.sources + current: card.applied + width: pane.overlay.uiFont * 5 + anchors.verticalCenter: parent.verticalCenter + } + Column { + width: parent.width - pane.overlay.uiFont * 5 - Style.spacing.lg + spacing: Style.spacing.xxs + anchors.verticalCenter: parent.verticalCenter + Text { + textFormat: Text.PlainText + width: parent.width + text: card.entry.name + color: card.applied ? pane.accent : pane.fg + font.family: pane.family + font.pixelSize: pane.overlay.uiFontSmall + font.bold: card.applied + elide: Text.ElideRight + } + Text { + textFormat: Text.PlainText + width: parent.width + text: card.meta + color: card.valid ? Util.alpha(pane.fg, 0.62) : Color.urgent + font.family: pane.family + font.pixelSize: pane.overlay.uiCaption + wrapMode: card.valid ? Text.NoWrap : Text.WordWrap + elide: card.valid ? Text.ElideRight : Text.ElideNone + } + } + } + Action { + id: deleteButton + text: "✕" + bordered: false + fontSize: pane.overlay.uiCaption + anchors.right: parent.right + anchors.rightMargin: Style.spacing.xs + anchors.verticalCenter: parent.verticalCenter + opacity: (cardHover.containsMouse || hot || pane.deleting === card.entry.name) ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: pane.overlay.motionFast } } + tooltipText: "Delete this scene" + onClicked: pane.deleting = card.entry.name } } @@ -284,74 +491,14 @@ Column { Section { visible: pane.ready && pane.scenes.length > 0 - title: "SAVED SCENES" + title: "SCENES" Column { width: pane.width - spacing: Style.spacing.sm + spacing: Style.spacing.xs Repeater { model: pane.scenes - Item { - id: sceneRow - required property var modelData - readonly property bool applied: pane.appliedScene === modelData.name - readonly property bool valid: modelData.valid === true - width: pane.width - implicitHeight: Math.max(sceneText.implicitHeight, sceneTools.implicitHeight) - height: implicitHeight - - Column { - id: sceneText - anchors.left: parent.left - anchors.right: sceneTools.left - anchors.rightMargin: Style.spacing.lg - anchors.verticalCenter: parent.verticalCenter - spacing: Style.spacing.xxs - Text { - textFormat: Text.PlainText - width: parent.width - text: sceneRow.modelData.name - color: sceneRow.applied ? pane.accent : pane.fg - font.family: pane.family - font.pixelSize: pane.overlay.uiFontSmall - font.bold: sceneRow.applied - elide: Text.ElideRight - } - Text { - textFormat: Text.PlainText - width: parent.width - text: sceneRow.valid ? String(sceneRow.modelData.layout || "") - : (sceneRow.modelData.error || "This scene cannot be applied") - color: sceneRow.valid ? Util.alpha(pane.fg, 0.62) : Color.urgent - font.family: pane.family - font.pixelSize: pane.overlay.uiCaption - wrapMode: sceneRow.valid ? Text.NoWrap : Text.WordWrap - elide: sceneRow.valid ? Text.ElideRight : Text.ElideNone - } - } - Row { - id: sceneTools - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: Style.spacing.xs - Action { - text: sceneRow.applied && !pane.scene.modified ? "Applied" : "Apply" - selected: sceneRow.applied && !pane.scene.modified - fontSize: pane.overlay.uiCaption - tooltipText: sceneRow.applied && pane.scene.modified ? "Put the saved definition back" : "Use this layout and content on workspace " + pane.overlay.workspaceId - enabled: !pane.overlay.busy && sceneRow.valid && !(sceneRow.applied && !pane.scene.modified) - opacity: 1 - onClicked: pane.overlay.sceneAction("apply", sceneRow.modelData.name) - } - Action { - text: "✕" - bordered: false - fontSize: pane.overlay.uiCaption - tooltipText: "Delete this scene" - onClicked: pane.deleting = sceneRow.modelData.name - } - } - } + SceneCard {} } } @@ -359,7 +506,7 @@ Column { visible: pane.deleting !== "" warning: true PromptTitle { text: "Delete scene " + pane.deleting + "?" } - Muted { width: parent.width; text: "Its file is removed. Nothing on the workspace changes and nothing disconnects." } + Muted { width: parent.width; text: "Its file is removed. Nothing on the workspace changes and nothing closes." } Flow { width: parent.width spacing: Style.spacing.sm @@ -373,7 +520,7 @@ Column { Section { visible: pane.usable - title: "CONTENT" + title: "ZONES" detail: pane.overlay.viewed ? pane.overlay.viewed.name : "" Column { @@ -387,8 +534,9 @@ Column { readonly property var zoneState: Content.state(zoneSource) badge: modelData.badge badgeStrong: !modelData.spacer - text: modelData.name - sub: modelData.spacer ? "Spacer" : Content.label(zoneSource) + icon: modelData.spacer ? "" : pane.overlay.iconFor(zoneSource) + text: modelData.spacer ? "Spacer" : pane.overlay.contentName(zoneSource) + sub: pane.overlay.zoneLabel(modelData.name) trait: zoneState.text urgent: zoneState.urgent current: pane.overlay.selected === modelData.name @@ -396,99 +544,162 @@ Column { } } } + + Muted { + visible: pane.sel === null + text: "Click a zone on the screen, or in this list, to choose what opens there." + } + Muted { visible: pane.sel !== null && pane.sel.spacer === true; text: "A spacer never holds windows." } } - // ------------------------------------------------- the selected zone + // ------------------------------------------- what the zone could hold Section { - visible: pane.usable - title: "ZONE" - detail: pane.overlay.selected + visible: pane.usable && pane.sel !== null && pane.sel.spacer !== true - Muted { - visible: pane.sel === null - text: "Click a zone above or on the screen to change what it holds. Tab and the arrows move between zones." + Item { + width: pane.width + implicitHeight: Math.max(pickBadge.implicitHeight, pickName.implicitHeight, pickCurrent.implicitHeight) + Chip { + id: pickBadge + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: pane.sel ? (pane.sel.numbers.length > 0 ? pane.sel.numbers.join(" · ") : "—") : "" + strong: true + foreground: pane.fg + fontFamily: pane.family + fontSize: pane.overlay.uiCaption + } + Text { + id: pickName + anchors.left: pickBadge.right + anchors.leftMargin: Style.spacing.md + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, Math.min(implicitWidth, parent.width - pickBadge.width - pickCurrent.width - Style.spacing.md * 2)) + textFormat: Text.PlainText + text: pane.sel ? pane.overlay.zoneLabel(pane.sel.name) : "" + color: pane.fg + font.family: pane.family + font.pixelSize: pane.overlay.uiFontSmall + font.bold: true + elide: Text.ElideRight + Text { + // The layout's own name for the zone, when the position stands in for it. + visible: pane.sel !== null && pane.overlay.zoneLabel(pane.sel.name) !== pane.sel.name + x: parent.contentWidth + Style.spacing.md + anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText + text: pane.sel ? pane.sel.name : "" + color: Util.alpha(pane.fg, 0.62) + font.family: pane.family + font.pixelSize: pane.overlay.uiCaption + } + } + Text { + id: pickCurrent + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: Math.min(implicitWidth, parent.width * 0.55) + textFormat: Text.PlainText + text: pane.overlay.contentName(pane.source) + color: pane.accent + font.family: pane.family + font.pixelSize: pane.overlay.uiFontSmall + font.bold: true + elide: Text.ElideRight + } } + Muted { text: Content.detail(pane.source); urgent: Content.state(pane.source).urgent } - Column { - visible: pane.sel !== null + TextField { + id: searchField width: pane.width - spacing: Style.spacing.lg - - Column { - width: pane.width - spacing: Style.spacing.xxs - Body { text: Content.label(pane.source); font.bold: true } - Muted { text: Content.detail(pane.source); urgent: Content.state(pane.source).urgent } + foreground: pane.fg + accent: pane.accent + font.family: pane.family + font.pixelSize: pane.overlay.uiFontSmall + placeholderText: "Search apps…" + Component.onCompleted: background.radius = pane.overlay.radiusControl + // Esc clears, Enter takes the hot match, ↑ ↓ move it; everything + // else the overlay would do with these keys still happens. + Keys.onPressed: function(event) { + var k = event.key + if (k === Qt.Key_Escape && text !== "") { text = ""; event.accepted = true; return } + if (text === "" && k >= Qt.Key_1 && k <= Qt.Key_9 && !(event.modifiers & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier))) { pane.overlay.selectContentNumber(k - Qt.Key_0); event.accepted = true; return } + if ((k === Qt.Key_Return || k === Qt.Key_Enter) && pane.searching) { pane.pickMatch(); event.accepted = true; return } + if (k === Qt.Key_Down && pane.searching) { pane.hot = Math.min(pane.hot + 1, Math.max(0, pane.matches.length - 1)); event.accepted = true; return } + if (k === Qt.Key_Up && pane.searching) { pane.hot = Math.max(pane.hot - 1, 0); event.accepted = true; return } + if ((k === Qt.Key_Left || k === Qt.Key_Right) && text !== "") return + if ([Qt.Key_Escape, Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab, Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(k) !== -1) { + if (pane.overlay.handleKey(event)) event.accepted = true + } } - - Muted { visible: pane.sel !== null && pane.sel.spacer === true; text: "A spacer never holds windows." } } - } - // ------------------------------------------- what else it could hold - - Section { - visible: pane.usable && pane.sel !== null && pane.sel.spacer !== true - title: "CHANGE TO" + Row { + visible: !pane.searching + spacing: Style.spacing.sm + Action { + text: "Local windows" + selected: pane.isCurrent({ kind: "local" }) + tooltipText: "Windows open here in fill order" + onClicked: pane.choose({ kind: "local" }) + onHotChanged: hot ? pane.overlay.ghost(pane.ghostFor({ kind: "local" })) : pane.overlay.unghost("local:local") + } + Action { + text: "Empty" + selected: pane.isCurrent({ kind: "empty" }) + tooltipText: "Nothing opens here; the zone stays empty" + onClicked: pane.choose({ kind: "empty" }) + onHotChanged: hot ? pane.overlay.ghost(pane.ghostFor({ kind: "empty" })) : pane.overlay.unghost("empty:empty") + } + } Column { + visible: pane.builtinMatches.length > 0 width: pane.width - spacing: Style.spacing.sm + spacing: Style.spacing.xxs + Repeater { + model: pane.builtinMatches + MatchRow { offset: 0 } + } + } - Column { - width: pane.width - spacing: Style.spacing.xxs - ListRow { - text: "Local windows" - trait: "by fill order" - current: pane.source === null || (pane.source.type === "local" && !pane.source.app_class) - onClicked: pane.overlay.assignContent("local") - } - ListRow { - text: "Empty" - trait: "nothing opens here" - current: pane.source !== null && pane.source.type === "empty" - onClicked: pane.overlay.assignContent("empty") - } + Group { + visible: pane.openMatches.length > 0 + title: "OPEN HERE" + Repeater { + model: pane.openMatches + MatchRow { offset: pane.builtinMatches.length } } + } - Column { - width: pane.width - spacing: Style.spacing.xxs - Label { text: "Installed apps"; topPadding: Style.spacing.xs; bottomPadding: Style.spacing.xxs } - Repeater { - model: pane.catalog.apps || [] - ListRow { - required property var modelData - text: modelData.name - trait: "launch or reuse" - current: pane.source !== null && pane.source.type === "app" && pane.source.desktop_id === modelData.desktop_id - onClicked: pane.overlay.assignApp(modelData) - } - } - Muted { - visible: (pane.catalog.apps || []).length === 0 - text: "Apps with a known window identity appear here. Open an installed app to help identify it." - } + Group { + visible: pane.remoteMatches.length > 0 + title: "REMOTE DESKTOPS" + Repeater { + model: pane.remoteMatches + MatchRow { offset: pane.builtinMatches.length + pane.openMatches.length } } + } - Column { - visible: pane.overlay.contentWindowClasses.length > 0 - width: pane.width - spacing: Style.spacing.xxs - Label { text: "Open apps"; topPadding: Style.spacing.xs; bottomPadding: Style.spacing.xxs } - Repeater { - model: pane.overlay.contentWindowClasses - ListRow { - required property string modelData - text: modelData - trait: "one window" - current: pane.source !== null && pane.source.type === "local" && pane.source.app_class === modelData - onClicked: pane.overlay.assignContent("local", modelData) - } - } + Group { + visible: pane.appMatches.length > 0 + title: "APPS" + caption: pane.searching ? "" : "Launches the app, or reuses its open window" + Repeater { + model: pane.appMatches + MatchRow { offset: pane.builtinMatches.length + pane.openMatches.length + pane.remoteMatches.length } } } + + Muted { + visible: pane.searching && pane.matches.length === 0 + text: "Nothing matches “" + pane.query.trim() + "”" + } + Muted { + visible: !pane.searching && pane.apps.length === 0 + text: "Apps with a known window identity appear here. Open an installed app to help identify it." + } } } diff --git a/plugin/Overlay.qml b/plugin/Overlay.qml index b2fa2f2..a1842f5 100644 --- a/plugin/Overlay.qml +++ b/plugin/Overlay.qml @@ -199,10 +199,84 @@ Item { function contentLabel(zone) { return Content.label(contentFor(zone)) } function contentChip(zone) { return Content.chip(contentFor(zone)) } + // Icons and names for what a zone holds, from the desktop entries the + // catalog lists. The shell's icon provider resolves theme names; a window + // class is matched to an installed app, then to a desktop entry heuristically. + readonly property string genericIcon: Quickshell.iconPath("application-x-executable", true) + function resolveIcon(icon) { + var v = String(icon || "") + if (v === "") return genericIcon + if (v.charAt(0) === "/") return "file://" + v + var themed = Quickshell.iconPath(v, true) + return themed !== "" ? themed : genericIcon + } + function catalogApp(desktopId, apps) { + apps = apps || (contentCatalog && contentCatalog.apps) || [] + for (var i = 0; i < apps.length; i++) if (apps[i].desktop_id === desktopId) return apps[i] + return null + } + function catalogAppForClass(cls, apps) { + var want = String(cls || "").toLowerCase() + if (want === "") return null + apps = apps || (contentCatalog && contentCatalog.apps) || [] + for (var i = 0; i < apps.length; i++) if (String(apps[i].app_class || "").toLowerCase() === want) return apps[i] + return null + } + function iconForApp(desktopId, apps) { + var app = catalogApp(desktopId, apps) + return resolveIcon(app ? app.icon : "") + } + function iconForClass(cls, apps) { + var app = catalogAppForClass(cls, apps) + if (app) return resolveIcon(app.icon) + var entry = null + try { entry = DesktopEntries.heuristicLookup(String(cls || "")) } catch (e) { entry = null } + return resolveIcon(entry ? entry.icon : "") + } + function iconFor(source) { + if (!source) return "" + if (source.type === "app") return iconForApp(source.desktop_id) + if (source.type === "local" && source.app_class) return iconForClass(source.app_class) + return "" + } + function nameForClass(cls, apps) { + var app = catalogAppForClass(cls, apps) + if (app) return Content.displayName(app.name) + var entry = null + try { entry = DesktopEntries.heuristicLookup(String(cls || "")) } catch (e) { entry = null } + return entry && entry.name ? entry.name : String(cls || "") + } + // Zones by where they sit ("Top left"), falling back to the layout's name + // where positions would collide. + readonly property var zoneLabels: Content.positionLabels(zones, area) + function zoneLabel(name) { return zoneLabels[name] || name } + // The picker row under the pointer, previewed in the selected zone's card. + property var hoverMatch: null + function ghost(match) { hoverMatch = match } + function unghost(key) { if (hoverMatch && hoverMatch.key === key) hoverMatch = null } + // The zone carrying a fill number, for the digit keys. + function selectContentNumber(n) { + for (var i = 0; i < zones.length; i++) if (zones[i].numbers.indexOf(n) !== -1) { selected = zones[i].name; return } + } + function contentName(source) { + if (source && source.type === "local" && source.app_class) return nameForClass(source.app_class) + return Content.label(source) + } + function layoutSpec(name) { + for (var i = 0; i < layouts.length; i++) if (layouts[i].name === name) return layouts[i].spec + return null + } + function focusSearch() { rail.focusSearch() } + function nextContentZone() { + var names = activeSpec ? Editor.leafNames(activeSpec) : [] + if (names.length) selected = names[(names.indexOf(selected) + 1) % names.length] + } + function showContent(on) { if (editing) return contentMode = on namingScene = false + hoverMatch = null pendingSwitch = null confirmingDelete = false choosingNew = false @@ -1286,15 +1360,16 @@ Item { // The Scenes tab: the keys select zones; the layout keys are the // Layouts tab's. if (root.contentMode) { - if (k === Qt.Key_Tab) { - var contentNames = root.activeSpec ? Editor.leafNames(root.activeSpec) : [] - if (contentNames.length) root.selected = contentNames[(contentNames.indexOf(root.selected) + 1) % contentNames.length] - return true - } + if (k === Qt.Key_Tab) { nextContentZone(); return true } var dir = (k === Qt.Key_Left || (plain && k === Qt.Key_H)) ? "left" : (k === Qt.Key_Right || (plain && k === Qt.Key_L)) ? "right" : (k === Qt.Key_Up || (plain && k === Qt.Key_K)) ? "up" : (k === Qt.Key_Down || (plain && k === Qt.Key_J)) ? "down" : "" + if (dir !== "" && (k === Qt.Key_Left || k === Qt.Key_Right || k === Qt.Key_Up || k === Qt.Key_Down)) { selectContentNeighbor(dir); return true } + if (plain && k >= Qt.Key_1 && k <= Qt.Key_9) { selectContentNumber(k - Qt.Key_0); return true } + // A printable key with a zone selected starts a search in the + // picker, wherever the focus was (after a save, a click on chrome). + if (plain && root.selected !== "" && root.viewedIsActive && event.text.length === 1 && event.text.trim() !== "") { rail.typeSearch(event.text); return true } if (dir !== "") { selectContentNeighbor(dir); return true } if (k === Qt.Key_Return || k === Qt.Key_Enter) { dismiss(); return true } if (plain && k === Qt.Key_R) { refresh(); return true } @@ -1548,6 +1623,10 @@ Item { function saveSceneAs(): void { root.startSceneSave() } function deleteScene(name: string): void { root.deleteScene(name) } function confirmSwitch(): void { root.confirmSwitch() } + function search(text: string): void { rail.setSearch(text) } + function pick(): void { rail.pickMatch() } + function hover(index: int): void { rail.hoverMatch(index) } + function focusSearch(): void { root.focusSearch() } function viewed(): string { return root.viewed ? root.viewed.name : "" } function view(name: string): void { if (root.editing) return @@ -1596,7 +1675,7 @@ Item { undo: root.undoStack.length, status: root.statusText, error: root.errorText, workspaces: root.workspaces.length, windows: root.windows.length, defaultLayout: root.defaultLayout, committed: root.committedLayout, live: root.liveLayout, dockLeft: root.dockLeft, showKeys: root.showKeys, - area: root.area, contentMode: root.contentMode, + area: root.area, contentMode: root.contentMode, query: rail.searchText, matches: rail.matchCount, namingScene: root.namingScene, pendingSwitch: root.pendingSwitch, catalogFailed: root.catalogFailed, scene: root.contentCatalog ? root.contentCatalog.current : null }) } diff --git a/plugin/Rail.qml b/plugin/Rail.qml index 781e038..4f0483c 100644 --- a/plugin/Rail.qml +++ b/plugin/Rail.qml @@ -17,6 +17,15 @@ Card { id: rail property real maxHeight: 100000 readonly property string nameText: nameField.text + // The Scenes tab's picker: what is typed into its search, and the keys + // that drive it from the overlay and its IPC. + readonly property string searchText: contentPane.query + readonly property int matchCount: contentPane.matchCount + function focusSearch() { contentPane.focusSearch() } + function setSearch(text) { contentPane.setQuery(text) } + function typeSearch(text) { contentPane.typeSearch(text) } + function pickMatch() { contentPane.pickMatch() } + function hoverMatch(index) { contentPane.hoverMatch(index) } // Put the cursor in the layout-name field, preloaded with `initial`. function focusName(initial) { @@ -63,7 +72,7 @@ Card { readonly property var keyHints: { if (overlay.naming || overlay.namingScene) return [["Enter", "save"], ["Esc", "cancel"]] if (overlay.renaming) return [["Enter", "rename"], ["Esc", "cancel"]] - if (overlay.contentMode) return [["click / ← → ↑ ↓", "select zone"], ["Tab", "next zone"], ["Enter / Esc", "close"], ["r", "refresh"], ["?", "hide keys"]] + if (overlay.contentMode) return [["click / ← → ↑ ↓", "select zone"], ["Tab", "next zone"], ["1 – 9", "zone by number"], ["type", "search apps"], ["↑ ↓", "pick a match"], ["Enter", "assign the match, else close"], ["Esc", "clear the search, else close"], ["?", "hide keys"]] if (overlay.numbering) return [["click", "next in order"], ["click again", "stack"], ["Backspace", "undo"], ["Enter", "done"]] if (overlay.editing) return [["click / ← → ↑ ↓", "select zone"], ["Shift + arrows", "resize 1%"], ["Tab", "next zone"], ["drag", "resize"], ["c", "split columns"], ["r", "split rows"], ["x", "delete"], ["s", "spacer"], ["f", "renumber"], ["u", "undo"], ["Space", "hold to peek"], ["w", "save"], ["Esc", "leave"], ["?", "hide keys"]] return [["← →", "browse (the windows follow)"], ["Enter", "use and close"], ["Space", "hold to peek"], ["e", "edit"], ["n", "new"], ["F2", "rename"], ["d", "delete"], ["r", "refresh"], ["Esc", "close"], ["?", "hide keys"]] @@ -490,7 +499,7 @@ Card { width: parent.width textFormat: Text.PlainText text: overlay.editing ? overlay.draftName - : overlay.contentMode ? ((overlay.catalogFailed || overlay.contentCatalog === null) ? "Scenes" : Content.sceneTitle(rail.scene)) + : overlay.contentMode ? ((overlay.catalogFailed || overlay.contentCatalog === null) ? "Scenes" : Content.sceneTitle(rail.scene, overlay.workspaceId)) : (overlay.viewed ? overlay.viewed.name : "No layouts") color: rail.accent font.family: rail.family @@ -531,7 +540,7 @@ Card { Action { visible: !overlay.editing && !overlay.renaming && !overlay.contentMode && !overlay.confirmingDelete; text: "Delete"; accent: Color.urgent; tooltipText: overlay.viewedIsDefault ? "The default layout cannot be deleted; make another the default first" : "Delete this layout's file (d)"; enabled: overlay.viewed !== null && !overlay.viewedIsDefault && !overlay.busy; onClicked: { overlay.choosingNew = false; overlay.confirmingDelete = true } } // the Scenes tab Action { visible: rail.scenesTab && !overlay.namingScene && rail.sceneNamed; text: "Save"; tooltipText: rail.sceneModified ? "Save the changes to " + rail.scene.document.name : "Saved"; enabled: rail.sceneModified && !overlay.busy; onClicked: overlay.saveScene(rail.scene.document.name) } - Action { visible: rail.scenesTab && !overlay.namingScene && rail.contentReady; text: rail.sceneNamed ? "Save as…" : "Save scene…"; tooltipText: "Save this workspace's layout and content under a name"; enabled: !overlay.busy; onClicked: overlay.startSceneSave() } + Action { visible: rail.scenesTab && !overlay.namingScene && rail.contentReady; text: rail.sceneNamed ? "Save as…" : "Save as scene…"; tooltipText: "Save this workspace's layout and content under a name"; enabled: !overlay.busy; onClicked: overlay.startSceneSave() } Action { visible: rail.scenesTab && !overlay.namingScene && rail.scene !== null && rail.scene.can_restore === true && ["restored", "none"].indexOf(rail.scene.phase) === -1; text: "Restore previous"; tooltipText: "Put back the layout and content the workspace had before the scene"; enabled: !overlay.busy; onClicked: overlay.sceneAction("restore") } Action { visible: rail.scenesTab && !overlay.namingScene && rail.scene !== null && (rail.scene.phase === "partial" || rail.scene.phase === "needs-attention"); text: "Retry"; tooltipText: "Check the pending content again"; enabled: !overlay.busy; onClicked: overlay.sceneAction("retry") } // naming a scene @@ -604,7 +613,7 @@ Card { } // ---- The Scenes tab: saved scenes, what each zone holds, the selected zone. - ContentPane { visible: rail.scenesTab; width: column.width; overlay: rail.overlay } + ContentPane { id: contentPane; visible: rail.scenesTab; width: column.width; overlay: rail.overlay } // ---- Edit mode: unsaved changes. Prompt { diff --git a/plugin/Thumb.qml b/plugin/Thumb.qml index 598c881..c6736a8 100644 --- a/plugin/Thumb.qml +++ b/plugin/Thumb.qml @@ -4,11 +4,14 @@ import "Geometry.js" as Geometry // A small picture of a layout: its zones in the monitor's proportions, the // first fill number in each zone that has room for it. Used by the rail's -// layout list so a shape can be recognised before it is browsed to. +// layout list so a shape can be recognised before it is browsed to. Given +// a scene's sources, the zones that hold an app show its icon instead, and +// the ones kept empty are drawn like spacers. Item { id: thumb required property var overlay property var spec: null + property var sources: null property bool current: false property color foreground: overlay.foreground property color accent: overlay.accent @@ -21,6 +24,12 @@ Item { height: Math.round(width * ratio) + function sourceFor(name) { + if (!sources) return null + for (var i = 0; i < sources.length; i++) if (sources[i].zone === name) return sources[i] + return null + } + Rectangle { anchors.fill: parent radius: Style.space(3) @@ -34,9 +43,13 @@ Item { Repeater { model: thumb.boxes Rectangle { + id: cell required property var modelData - readonly property bool spacer: modelData.spacer === true + readonly property var src: thumb.sourceFor(modelData.name) + readonly property bool spacer: modelData.spacer === true || (src !== null && src.type === "empty") + readonly property string icon: src !== null ? thumb.overlay.iconFor(src) : "" readonly property var box: modelData.fitted ? modelData.fit : modelData + readonly property int iconSize: Math.round(Math.min(width, height) * 0.62) x: box.x + thumb.gap y: box.y + thumb.gap width: Math.max(1, box.w - thumb.gap * 2) @@ -45,9 +58,21 @@ Item { color: spacer ? "transparent" : Util.alpha(thumb.accent, thumb.current ? 0.35 : 0.22) border.width: 1 border.color: Util.alpha(spacer ? thumb.foreground : thumb.accent, spacer ? 0.25 : 0.7) + Image { + visible: cell.icon !== "" && cell.iconSize >= 6 + anchors.centerIn: parent + width: cell.iconSize + height: cell.iconSize + sourceSize.width: cell.iconSize + sourceSize.height: cell.iconSize + source: cell.icon + smooth: true + mipmap: true + asynchronous: true + } Text { anchors.centerIn: parent - visible: !parent.spacer && parent.width > implicitWidth + 4 && parent.height > implicitHeight + 2 && modelData.numbers.length > 0 + visible: !cell.spacer && cell.icon === "" && parent.width > implicitWidth + 4 && parent.height > implicitHeight + 2 && modelData.numbers.length > 0 textFormat: Text.PlainText text: modelData.numbers.length > 0 ? String(modelData.numbers[0]) : "" color: Util.alpha(thumb.foreground, 0.85) diff --git a/plugin/ZoneItem.qml b/plugin/ZoneItem.qml index bc7574e..238da2a 100644 --- a/plugin/ZoneItem.qml +++ b/plugin/ZoneItem.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Effects import qs.Commons import qs.Ui +import "Content.js" as Content // One zone of the viewed or edited layout, drawn at true scale over the // windows it will hold. A badge in the corner carries the fill-order @@ -24,6 +25,14 @@ Item { readonly property bool isSelected: (editing || overlay.contentMode) && overlay.selected === modelData.name readonly property bool isHovered: (editing || overlay.contentMode) && !overlay.numbering && !overlay.dragDivider && !overlay.hoverDivider && overlay.hoverZone === modelData.name readonly property var source: overlay.contentFor(modelData.name) + // The Scenes tab: the card names what the zone holds, with its icon. + readonly property bool content: overlay.contentMode + readonly property string icon: (content && source !== null) ? overlay.iconFor(source) : "" + readonly property var contentState: content ? Content.state(source) : ({ text: "", urgent: false }) + // A picker row under the pointer previews itself in the selected card. + readonly property var ghost: (content && isSelected) ? overlay.hoverMatch : null + readonly property bool urgent: content && contentState.urgent + readonly property bool showCentre: content && roomy && (ghost !== null || (source !== null && source.type !== "empty")) readonly property bool isSpacer: modelData.spacer === true || (source !== null && source.type === "empty") readonly property bool fitted: modelData.fitted === true && !isSpacer readonly property int inset: Style.space(6) @@ -70,6 +79,7 @@ Item { : Util.alpha(zone.accent, zone.fitted ? 0.04 : (zone.isSelected ? 0.16 : (zone.isHovered ? 0.12 : (zone.editing ? 0.08 : 0.03))))) border.width: zone.isSelected && !zone.peek ? Math.max(2, Style.space(2)) : 1 border.color: zone.peek ? Util.alpha(zone.isSelected ? zone.accent : zone.fg, zone.isSelected ? 0.7 : 0.25) + : zone.urgent ? Util.alpha(Color.urgent, zone.isSelected ? 1 : 0.8) : zone.isSelected ? zone.accent : (zone.isHovered ? Util.alpha(zone.accent, 0.8) : Util.alpha(zone.isSpacer ? zone.fg : zone.accent, zone.isSpacer ? 0.35 : 0.55)) @@ -86,11 +96,11 @@ Item { radius: zone.overlay.effectiveRounding color: "transparent" border.width: Math.max(2, Style.space(2)) - border.color: zone.accent + border.color: zone.urgent ? Color.urgent : zone.accent layer.enabled: visible layer.effect: MultiEffect { shadowEnabled: true - shadowColor: zone.accent + shadowColor: zone.urgent ? Color.urgent : zone.accent shadowOpacity: 0.85 shadowBlur: 1.0 blurMax: 32 @@ -131,7 +141,8 @@ Item { // Faint numeral in the middle. Text { - visible: !zone.peek + id: numeral + visible: !zone.peek && !zone.showCentre anchors.centerIn: parent width: parent.width - zone.pad * 2 textFormat: Text.PlainText @@ -144,6 +155,70 @@ Item { elide: Text.ElideRight } + // Under the numeral while scenes are edited: what an unassigned zone does. + Text { + visible: zone.content && zone.roomy && !zone.peek && !zone.showCentre + anchors.top: numeral.bottom + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - zone.pad * 2 + textFormat: Text.PlainText + text: zone.modelData.spacer === true ? "Spacer · never holds windows" + : zone.isSpacer ? "Empty · nothing opens here" + : "Local windows · fill order" + color: Util.alpha(zone.fg, zone.isSelected ? 0.7 : 0.45) + font.family: zone.overlay.fontFamily + font.pixelSize: zone.overlay.uiFontSmall + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + + // What the zone holds: the app's icon, its name, and its state. + Column { + id: centre + visible: zone.showCentre && !zone.peek + anchors.centerIn: parent + width: parent.width - zone.pad * 2 + spacing: Style.spacing.md + opacity: zone.ghost !== null ? 0.55 : 1 + Behavior on opacity { NumberAnimation { duration: zone.overlay.motionFast } } + readonly property int iconSize: Math.round(Math.min(zone.width * 0.2, zone.height * 0.26, zone.overlay.uiFont * 4.5)) + readonly property string shownIcon: zone.ghost !== null ? zone.ghost.icon : zone.icon + Image { + visible: centre.shownIcon !== "" + anchors.horizontalCenter: parent.horizontalCenter + width: centre.iconSize + height: centre.iconSize + sourceSize.width: centre.iconSize + sourceSize.height: centre.iconSize + source: centre.shownIcon + smooth: true + mipmap: true + opacity: zone.isSelected ? 1 : 0.9 + } + Text { + width: parent.width + textFormat: Text.PlainText + text: zone.ghost !== null ? zone.ghost.name : zone.overlay.contentName(zone.source) + color: Util.alpha(zone.fg, 0.92) + font.family: zone.overlay.fontFamily + font.pixelSize: Math.round(zone.overlay.uiFont * 1.25) + font.bold: true + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + Text { + visible: text !== "" + width: parent.width + textFormat: Text.PlainText + text: zone.ghost !== null ? "click to put it here" : zone.contentState.text + color: (zone.contentState.urgent && zone.ghost === null) ? Color.urgent : Util.alpha(zone.fg, 0.62) + font.family: zone.overlay.fontFamily + font.pixelSize: zone.overlay.uiFontSmall + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + // Badge row: number, name (edit mode: names only matter when editing // rules or the file), then the constraint chips. The size chip opens the // rail's exact size field. @@ -184,7 +259,7 @@ Item { // What the zone holds when it is not simply local windows: a remote // desktop (accent), an app, or nothing. Chip { - visible: zone.source !== null + visible: zone.source !== null && !zone.showCentre text: zone.overlay.contentChip(zone.modelData.name) foreground: zone.fg fontFamily: zone.overlay.fontFamily @@ -241,6 +316,19 @@ Item { radius: zone.overlay.radiusControl } + // Quick actions on a zone while scenes are edited. + Row { + visible: zone.content && (zone.isSelected || zone.isHovered || zone.urgent) && zone.roomy && !zone.peek && zone.modelData.spacer !== true + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: zone.pad + spacing: Style.spacing.sm + + ZoneButton { visible: zone.urgent; text: "Retry"; accent: Color.urgent; tooltipText: "Check the pending content again"; enabled: !zone.overlay.busy; onClicked: zone.overlay.sceneAction("retry") } + ZoneButton { text: "Change…"; tooltipText: "Choose what opens here"; onClicked: { zone.overlay.selected = zone.modelData.name; zone.overlay.focusSearch() } } + ZoneButton { visible: zone.source !== null; text: "Clear"; tooltipText: "Back to local windows in fill order"; enabled: !zone.overlay.busy; onClicked: { zone.overlay.selected = zone.modelData.name; zone.overlay.assignContent("local") } } + } + // Quick actions on the selected zone. Row { visible: zone.isSelected && zone.editing && !zone.overlay.numbering && zone.roomy && !zone.peek diff --git a/scenes/apps.py b/scenes/apps.py index fa411f2..2efc37c 100644 --- a/scenes/apps.py +++ b/scenes/apps.py @@ -47,7 +47,8 @@ def scan(self): match = app.get("X-RemoteDesktops-WindowClass") or app.get("StartupWMClass") title = app.get("X-RemoteDesktops-WindowTitle") entry = {"desktop_id": desktop_id, "name": app.get("Name", desktop_id), "path": str(path), - "visible": not app.getboolean("NoDisplay", fallback=False)} + "visible": not app.getboolean("NoDisplay", fallback=False), + "icon": (app.get("Icon") or "")[:250]} if match: entry["app_class"] = text(match, "app class") if title: @@ -90,7 +91,9 @@ def catalog(self, windows): stem = entry["desktop_id"][:-8] if any(w.get("class") == stem for w in windows): app["app_class"] = stem - if app.get("app_class"): + # A packaging placeholder such as "@@startup_wm_class" never + # matches a window; the entry would only clutter the picker. + if app.get("app_class") and "@@" not in app["app_class"]: out.append(app) return sorted(out, key=lambda v: (v["name"].casefold(), v["desktop_id"])) diff --git a/scenes/scenes.py b/scenes/scenes.py index 49c7d66..9c79146 100644 --- a/scenes/scenes.py +++ b/scenes/scenes.py @@ -258,7 +258,10 @@ def command(self, request): for path in sorted(self.directory.glob("*.json")): try: doc, _ = self.resolve(json.loads(path.read_text())) - entries.append({"name": path.stem, "layout": doc["layout"], "valid": True}) + # What the scene places, for the overlay's scene cards. + sources = [{k: v for k, v in s.items() if k in ("zone", "type", "desktop_id", "app_name", "app_class")} + for s in doc["sources"].values()] + entries.append({"name": path.stem, "layout": doc["layout"], "valid": True, "sources": sources}) except (ValueError, KeyError, TypeError) as error: entries.append({"name": path.stem, "valid": False, "error": str(error)}) return {"version": 1, "scenes": entries} diff --git a/test/apps.py b/test/apps.py index 44484e1..09a6d0a 100644 --- a/test/apps.py +++ b/test/apps.py @@ -114,6 +114,9 @@ def test_installed_entry_supplies_exact_identity(self): self.assertEqual(source["app_class"], "com.moonlight_stream.Moonlight") self.assertEqual(self.command("catalog")["apps"][0]["name"], "MacBook") self.assertNotIn("computers", self.command("catalog")) + (self.apps_dir / "placeholder.desktop").write_text('[Desktop Entry]\nType=Application\nName=Placeholder\nExec=placeholder\nStartupWMClass=@@startup_wm_class\n') + self.ctl.scenes.apps.desktop.next_scan = 0 + self.assertNotIn("Placeholder", [a["name"] for a in self.command("catalog")["apps"]]) self.assertFalse(self.desktop.launched) def test_standalone_service_does_not_take_stream_lock_or_read_computers(self): diff --git a/test/content.js b/test/content.js index b89e18d..e532881 100644 --- a/test/content.js +++ b/test/content.js @@ -23,9 +23,35 @@ assert.strictEqual(C.sceneTitle({ phase: "ready", document: { name: "work" } }), assert.strictEqual(C.sceneModified({ phase: "ready", modified: true, document: { name: "work" } }), true) assert.strictEqual(C.sceneModified({ phase: "ready", modified: true, document: {} }), false) assert.strictEqual(C.sceneModified({ phase: "restored", modified: true, document: { name: "work" } }), false) -assert.strictEqual(C.sceneMeta({ phase: "ready" }, "quad", "1"), "quad on workspace 1 · Ready") -assert.strictEqual(C.sceneMeta({ phase: "none" }, "quad", "1"), "quad on workspace 1") +assert.strictEqual(C.sceneTitle({ phase: "none", document: null }, "1"), "Workspace 1") +assert.strictEqual(C.sceneTitle({ phase: "restored", document: { name: "work" } }, "2"), "Workspace 2") +assert.strictEqual(C.sceneMeta({ phase: "ready" }, "quad", "1"), "quad · workspace 1 · Ready") +assert.strictEqual(C.sceneMeta({ phase: "none" }, "quad", "1"), "quad · local windows in every zone") +assert.strictEqual(C.sceneMeta({ phase: "restored" }, "quad", "1"), "quad · Previous arrangement restored") assert.strictEqual(C.sceneMeta(null, "", ""), "") +const placing = { phase: "connecting", sources: [ + { type: "app", zone: "a", status: "ready" }, { type: "app", zone: "b", status: "waiting-window" }, + { type: "local", zone: "c", app_class: "x", status: "needs-attention" }, { type: "local", zone: "d" }, { type: "empty", zone: "e" }] } +assert.strictEqual(C.sceneProgress(placing), "1 of 3 placed · 1 needs attention") +assert.strictEqual(C.sceneMeta(placing, "quad", "1"), "quad · workspace 1 · 1 of 3 placed · 1 needs attention") +assert.strictEqual(C.sceneProgress({ phase: "layout", sources: placing.sources }), "Applying layout…") +assert.strictEqual(C.sceneProgress({ phase: "ready", sources: [] }), "Ready") +// Scene cards and the picker. +assert.strictEqual(JSON.stringify(C.appNames([{ type: "app", app_name: "MacBook (Remote Desktop)" }, { type: "local", app_class: "foot" }, { type: "empty" }, { type: "local" }])), '["MacBook","foot"]') +assert.strictEqual(C.summary(["A", "B", "C", "D"]), "A, B +2") +assert.strictEqual(C.summary(["A", "B"]), "A, B") +assert.strictEqual(C.summary([]), "") +assert.strictEqual(C.isRemoteDesktop({ desktop_id: "remote-desktops-macbook.desktop" }), true) +assert.strictEqual(C.isRemoteDesktop({ desktop_id: "foot.desktop", app_title: "x - Moonlight" }), true) +assert.strictEqual(C.isRemoteDesktop({ desktop_id: "foot.desktop" }), false) +assert.strictEqual(C.displayName("dalbuslt0151 (Remote Desktop)"), "dalbuslt0151") +assert.strictEqual(C.label({ type: "app", desktop_id: "remote-desktops-macbook.desktop", app_name: "MacBook (Remote Desktop)" }), "MacBook") +assert.strictEqual(C.matches("", ["anything"]), true) +assert.strictEqual(C.matches(" CHR ", ["Google Chrome", "google-chrome"]), true) +assert.strictEqual(C.matches("zzz", ["Google Chrome", null]), false) +const windows = [{ class: "foot", title: "~", workspace: 1 }, { class: "foot", title: "vim", workspace: "1" }, { class: "cursor", title: "a.py - Cursor", workspace: 1 }, { class: "x", title: "", workspace: 2 }, { title: "no class", workspace: 1 }] +assert.strictEqual(JSON.stringify(C.openApps(windows, "1")), JSON.stringify([{ app_class: "cursor", count: 1, title: "a.py - Cursor" }, { app_class: "foot", count: 2, title: "vim" }])) +assert.strictEqual(JSON.stringify(C.openApps(windows, "3")), "[]") catalog.current.phase = "restored" assert.strictEqual(C.source(catalog, "1", "left", true), null) const E = {} @@ -47,3 +73,16 @@ assert.equal(C.label({ type: "app", desktop_id: "remote-desktops-macbook.desktop assert.equal(C.state({ type: "app", status: "moved" }).text, "Moved") assert.match(C.detail({ type: "app", status: "moved" }), /Moved by you/) assert.match(C.detail({ type: "app", status: "closed" }), /Closed by you/) +// Zone positions in words. +const area = { x: 10, y: 40, w: 6124, h: 2510 } +const quad = [{ name: "main", x: 10, y: 40, w: 3062, h: 1255 }, { name: "main-2", x: 3072, y: 40, w: 3062, h: 1255 }, + { name: "main-3", x: 10, y: 1295, w: 3062, h: 1255 }, { name: "main-4", x: 3072, y: 1295, w: 3062, h: 1255 }] +assert.strictEqual(JSON.stringify(C.positionLabels(quad, area)), JSON.stringify({ main: "Top left", "main-2": "Top right", "main-3": "Bottom left", "main-4": "Bottom right" })) +const cols = [{ name: "a", x: 10, y: 40, w: 2041, h: 2510 }, { name: "b", x: 2051, y: 40, w: 2041, h: 2510 }, { name: "c", x: 4092, y: 40, w: 2042, h: 2510 }] +assert.strictEqual(JSON.stringify(C.positionLabels(cols, area)), JSON.stringify({ a: "Left", b: "Center", c: "Right" })) +assert.strictEqual(C.positionLabel({ x: 10, y: 40, w: 6124, h: 2510 }, area), "Full screen") +assert.strictEqual(C.positionLabel({ x: 2000, y: 900, w: 2000, h: 800 }, area), "Middle center") +// Four columns: the two inner ones would both be "Center", so they fall back to their names. +const four = [{ name: "a", x: 10, y: 40, w: 1531, h: 2510 }, { name: "b", x: 1541, y: 40, w: 1531, h: 2510 }, { name: "c", x: 3072, y: 40, w: 1531, h: 2510 }, { name: "d", x: 4603, y: 40, w: 1531, h: 2510 }] +assert.strictEqual(JSON.stringify(C.positionLabels(four, area)), JSON.stringify({ a: "Left", b: "", c: "", d: "Right" })) +assert.strictEqual(JSON.stringify(C.positionLabels(quad, null)), "{}")