497 lines
18 KiB
QML
497 lines
18 KiB
QML
// Continuum Mission Control: a GNOME-like workspace filmstrip above a large
|
|
// selected desktop, with cross-workspace window search when the user types.
|
|
|
|
import Quickshell
|
|
import Quickshell.Hyprland
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
import qs.config
|
|
import qs.services
|
|
|
|
Item {
|
|
id: root
|
|
|
|
property int refreshToken: 0
|
|
property bool open: false
|
|
property int selectedWorkspaceId: 0
|
|
property string query: ""
|
|
property int actionRefreshToken: 0
|
|
property int selectedResultIndex: 0
|
|
|
|
readonly property int sideMargin: 52
|
|
readonly property int filmCardWidth: 210
|
|
readonly property int filmCardHeight: 140
|
|
|
|
readonly property var workspaceList: {
|
|
const out = [];
|
|
const all = Hyprland.workspaces?.values ?? [];
|
|
for (const workspace of all) {
|
|
if (workspace && workspace.id > 0)
|
|
out.push(workspace);
|
|
}
|
|
out.sort((a, b) => a.id - b.id);
|
|
return out;
|
|
}
|
|
|
|
readonly property var scratchWorkspace: {
|
|
const all = Hyprland.workspaces?.values ?? [];
|
|
return all.find(workspace => workspace?.name === "special:scratch") ?? null;
|
|
}
|
|
|
|
readonly property int nextWorkspaceId: {
|
|
let max = 0;
|
|
for (const workspace of root.workspaceList)
|
|
max = Math.max(max, workspace.id);
|
|
return max + 1;
|
|
}
|
|
|
|
readonly property var selectedWorkspace: {
|
|
for (const workspace of root.workspaceList) {
|
|
if (workspace.id === root.selectedWorkspaceId)
|
|
return workspace;
|
|
}
|
|
return Hyprland.focusedWorkspace?.id > 0 ? Hyprland.focusedWorkspace : (root.workspaceList[0] ?? null);
|
|
}
|
|
|
|
readonly property real screenAspect: {
|
|
const monitor = Hyprland.focusedMonitor;
|
|
return monitor && monitor.height > 0 ? monitor.width / monitor.height : 3 / 2;
|
|
}
|
|
|
|
readonly property var windowEntries: {
|
|
const out = [];
|
|
for (const workspace of root.workspaceList) {
|
|
const windows = workspace.toplevels?.values ?? [];
|
|
for (const toplevel of windows)
|
|
out.push({ workspace, toplevel });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
readonly property var filteredWindows: {
|
|
const needle = root.query.trim().toLowerCase();
|
|
if (!needle)
|
|
return [];
|
|
return root.windowEntries.filter(entry => {
|
|
const title = entry.toplevel?.title ?? "";
|
|
const appId = entry.toplevel?.wayland?.appId ?? "";
|
|
return `${title} ${appId}`.toLowerCase().includes(needle);
|
|
});
|
|
}
|
|
|
|
readonly property int resultColumns: Math.min(3, Math.max(1, root.filteredWindows.length))
|
|
readonly property var selectedResult: root.filteredWindows.length > 0
|
|
? root.filteredWindows[Math.min(root.selectedResultIndex, root.filteredWindows.length - 1)]
|
|
: null
|
|
|
|
onQueryChanged: root.selectedResultIndex = 0
|
|
onFilteredWindowsChanged: {
|
|
if (root.selectedResultIndex >= root.filteredWindows.length)
|
|
root.selectedResultIndex = Math.max(0, root.filteredWindows.length - 1);
|
|
}
|
|
|
|
function prepare(): void {
|
|
root.query = ShellState.overviewQuery;
|
|
const requested = ShellState.overviewWorkspaceId;
|
|
const focused = Hyprland.focusedWorkspace?.id ?? 0;
|
|
root.selectedWorkspaceId = root.hasWorkspace(requested) ? requested : focused;
|
|
Qt.callLater(() => searchInput.forceActiveFocus());
|
|
}
|
|
|
|
function hasWorkspace(id: int): bool {
|
|
if (id <= 0)
|
|
return false;
|
|
return root.workspaceList.some(workspace => workspace.id === id);
|
|
}
|
|
|
|
function selectWorkspace(workspace: var): void {
|
|
if (!workspace)
|
|
return;
|
|
root.selectedWorkspaceId = workspace.id;
|
|
workspace.activate();
|
|
}
|
|
|
|
function cycleWorkspace(delta: int): void {
|
|
if (root.workspaceList.length === 0)
|
|
return;
|
|
let index = root.workspaceList.findIndex(workspace => workspace.id === root.selectedWorkspaceId);
|
|
if (index < 0)
|
|
index = 0;
|
|
const next = Math.max(0, Math.min(root.workspaceList.length - 1, index + delta));
|
|
root.selectWorkspace(root.workspaceList[next]);
|
|
}
|
|
|
|
function focusToplevel(toplevel: var): void {
|
|
if (!toplevel)
|
|
return;
|
|
|
|
const workspace = toplevel.workspace;
|
|
if (workspace)
|
|
workspace.activate();
|
|
|
|
if (toplevel.address) {
|
|
const address = toplevel.address.startsWith("0x") ? toplevel.address : "0x" + toplevel.address;
|
|
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${address}" })`);
|
|
} else if (toplevel.wayland) {
|
|
toplevel.wayland.activate();
|
|
}
|
|
ShellState.close();
|
|
}
|
|
|
|
function addressOf(toplevel: var): string {
|
|
const raw = String(toplevel?.address ?? "");
|
|
if (!raw)
|
|
return "";
|
|
return raw.startsWith("0x") ? raw : "0x" + raw;
|
|
}
|
|
|
|
function closeToplevel(toplevel: var): void {
|
|
const address = root.addressOf(toplevel);
|
|
if (!address)
|
|
return;
|
|
Hyprland.dispatch(`hl.dsp.window.close({ window = "address:${address}" })`);
|
|
root.refreshAfterAction();
|
|
}
|
|
|
|
function moveToplevel(toplevel: var, workspaceName: string): void {
|
|
const address = root.addressOf(toplevel);
|
|
if (!address || !/^(?:[1-9]\d*|special:scratch)$/.test(workspaceName))
|
|
return;
|
|
Hyprland.dispatch(`hl.dsp.window.move({ workspace = "${workspaceName}", follow = false, window = "address:${address}" })`);
|
|
root.refreshAfterAction();
|
|
}
|
|
|
|
function restoreToplevel(toplevel: var): void {
|
|
const address = root.addressOf(toplevel);
|
|
const target = root.selectedWorkspaceId > 0 ? root.selectedWorkspaceId : (Hyprland.focusedWorkspace?.id ?? 1);
|
|
if (!address || target <= 0)
|
|
return;
|
|
Hyprland.dispatch(`hl.dsp.window.move({ workspace = ${target}, follow = true, window = "address:${address}" })`);
|
|
ShellState.close();
|
|
}
|
|
|
|
function refreshAfterAction(): void {
|
|
actionRefresh.restart();
|
|
}
|
|
|
|
Timer {
|
|
id: actionRefresh
|
|
interval: 140
|
|
onTriggered: {
|
|
Hyprland.refreshWorkspaces();
|
|
Hyprland.refreshToplevels();
|
|
root.actionRefreshToken++;
|
|
}
|
|
}
|
|
|
|
// Address-only actions make the exact same boundary testable end-to-end
|
|
// without synthesizing pointer input. They are also useful to launcher
|
|
// commands later; validation still limits destinations to Panama's model.
|
|
IpcHandler {
|
|
target: "overview-actions"
|
|
function move(address: string, workspaceName: string): void {
|
|
root.moveToplevel({ address }, workspaceName);
|
|
}
|
|
function close(address: string): void {
|
|
root.closeToplevel({ address });
|
|
}
|
|
function restore(address: string): void {
|
|
root.restoreToplevel({ address });
|
|
}
|
|
}
|
|
|
|
// ── Header and search ──────────────────────────────────────────────────
|
|
Item {
|
|
id: header
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 42
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.leftMargin: root.sideMargin
|
|
anchors.rightMargin: root.sideMargin
|
|
height: 42
|
|
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "Activities"
|
|
color: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeTitle
|
|
font.weight: Font.DemiBold
|
|
}
|
|
|
|
Rectangle {
|
|
id: searchBox
|
|
anchors.centerIn: parent
|
|
width: 390
|
|
height: 38
|
|
radius: Theme.pillRadius
|
|
border.width: searchInput.activeFocus ? 1 : 0
|
|
border.color: Theme.alpha(Theme.accent, 0.72)
|
|
color: Theme.alpha(Theme.bgPopover, 0.84)
|
|
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 14
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "\u{F0349}" // magnify
|
|
color: searchInput.activeFocus ? Theme.accent : Theme.fgDim
|
|
font.family: Theme.fontMono
|
|
font.pixelSize: 16
|
|
}
|
|
|
|
TextInput {
|
|
id: searchInput
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 40
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 14
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.query
|
|
color: Theme.fg
|
|
selectionColor: Theme.accent
|
|
selectedTextColor: Theme.bgDark
|
|
clip: true
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
onTextEdited: root.query = text
|
|
|
|
Keys.onReturnPressed: {
|
|
if (root.query.trim() === "") {
|
|
if (root.selectedWorkspace)
|
|
root.selectWorkspace(root.selectedWorkspace);
|
|
ShellState.close();
|
|
} else if (root.selectedResult) {
|
|
root.focusToplevel(root.selectedResult.toplevel);
|
|
}
|
|
}
|
|
|
|
Keys.onLeftPressed: event => {
|
|
if (root.query.trim() !== "")
|
|
return;
|
|
root.cycleWorkspace(-1);
|
|
event.accepted = true;
|
|
}
|
|
|
|
Keys.onRightPressed: event => {
|
|
if (root.query.trim() !== "")
|
|
return;
|
|
root.cycleWorkspace(1);
|
|
event.accepted = true;
|
|
}
|
|
|
|
Keys.onUpPressed: event => {
|
|
if (!root.selectedResult)
|
|
return;
|
|
root.selectedResultIndex = Math.max(0, root.selectedResultIndex - root.resultColumns);
|
|
event.accepted = true;
|
|
}
|
|
|
|
Keys.onDownPressed: event => {
|
|
if (!root.selectedResult)
|
|
return;
|
|
root.selectedResultIndex = Math.min(root.filteredWindows.length - 1, root.selectedResultIndex + root.resultColumns);
|
|
event.accepted = true;
|
|
}
|
|
|
|
Keys.onDeletePressed: event => {
|
|
if (!root.selectedResult)
|
|
return;
|
|
root.closeToplevel(root.selectedResult.toplevel);
|
|
event.accepted = true;
|
|
}
|
|
}
|
|
|
|
Text {
|
|
anchors.left: searchInput.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: searchInput.text === ""
|
|
text: "Find a window"
|
|
color: Theme.fgMuted
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
}
|
|
}
|
|
|
|
Text {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.workspaceList.length + (root.workspaceList.length === 1 ? " workspace" : " workspaces")
|
|
color: Theme.fgDim
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
}
|
|
}
|
|
|
|
ScratchpadShelf {
|
|
id: scratchpadShelf
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.leftMargin: root.sideMargin
|
|
anchors.rightMargin: root.sideMargin
|
|
anchors.bottom: parent.bottom
|
|
anchors.bottomMargin: 20
|
|
workspace: root.scratchWorkspace
|
|
refreshToken: root.refreshToken + root.actionRefreshToken
|
|
onWindowDropped: toplevel => root.moveToplevel(toplevel, "special:scratch")
|
|
onRestoreRequested: toplevel => root.restoreToplevel(toplevel)
|
|
onCloseRequested: toplevel => root.closeToplevel(toplevel)
|
|
}
|
|
|
|
// ── Workspace filmstrip ────────────────────────────────────────────────
|
|
Flickable {
|
|
id: filmstrip
|
|
anchors.top: header.bottom
|
|
anchors.topMargin: 18
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.leftMargin: root.sideMargin
|
|
anchors.rightMargin: root.sideMargin
|
|
height: root.filmCardHeight
|
|
clip: true
|
|
contentWidth: Math.max(width, filmRow.implicitWidth)
|
|
contentHeight: height
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.NoButton
|
|
onWheel: event => root.cycleWorkspace(event.angleDelta.y > 0 ? -1 : 1)
|
|
}
|
|
|
|
Row {
|
|
id: filmRow
|
|
x: filmstrip.contentWidth > filmstrip.width ? 0 : (filmstrip.width - implicitWidth) / 2
|
|
spacing: Theme.itemSpacing
|
|
|
|
Repeater {
|
|
model: root.workspaceList
|
|
|
|
WorkspaceCard {
|
|
required property var modelData
|
|
workspace: modelData
|
|
refreshToken: root.refreshToken + root.actionRefreshToken
|
|
emphasized: modelData.id === root.selectedWorkspaceId
|
|
focusBound: FocusSession.active && modelData.id === FocusSession.workspaceId
|
|
width: root.filmCardWidth
|
|
height: root.filmCardHeight
|
|
onActivated: root.selectWorkspace(modelData)
|
|
onWindowActivated: toplevel => root.focusToplevel(toplevel)
|
|
onWindowCloseRequested: toplevel => root.closeToplevel(toplevel)
|
|
onWindowDropped: (toplevel, workspaceName) => root.moveToplevel(toplevel, workspaceName)
|
|
}
|
|
}
|
|
|
|
WorkspaceCard {
|
|
workspace: null
|
|
newWorkspaceId: root.nextWorkspaceId
|
|
emphasized: false
|
|
width: root.filmCardWidth
|
|
height: root.filmCardHeight
|
|
onActivated: {
|
|
Hyprland.dispatch(`hl.dsp.focus({ workspace = ${root.nextWorkspaceId} })`);
|
|
root.selectedWorkspaceId = root.nextWorkspaceId;
|
|
}
|
|
onWindowCloseRequested: toplevel => root.closeToplevel(toplevel)
|
|
onWindowDropped: (toplevel, workspaceName) => root.moveToplevel(toplevel, workspaceName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Large selected desktop ─────────────────────────────────────────────
|
|
Item {
|
|
id: desktopStage
|
|
anchors.top: filmstrip.bottom
|
|
anchors.topMargin: 26
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: hint.top
|
|
anchors.bottomMargin: 18
|
|
visible: root.query.trim() === ""
|
|
|
|
WorkspaceCard {
|
|
id: largeWorkspace
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 18
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
workspace: root.selectedWorkspace
|
|
refreshToken: root.refreshToken + root.actionRefreshToken
|
|
emphasized: true
|
|
focusBound: FocusSession.active && root.selectedWorkspace?.id === FocusSession.workspaceId
|
|
width: Math.min(parent.width - root.sideMargin * 2, 1760)
|
|
height: Math.min(parent.height, Math.round(width / root.screenAspect) + labelHeight)
|
|
onActivated: {
|
|
if (workspace)
|
|
workspace.activate();
|
|
ShellState.close();
|
|
}
|
|
onWindowActivated: toplevel => root.focusToplevel(toplevel)
|
|
onWindowCloseRequested: toplevel => root.closeToplevel(toplevel)
|
|
onWindowDropped: (toplevel, workspaceName) => root.moveToplevel(toplevel, workspaceName)
|
|
}
|
|
}
|
|
|
|
// ── Cross-workspace search results ─────────────────────────────────────
|
|
Flickable {
|
|
id: searchResults
|
|
anchors.top: filmstrip.bottom
|
|
anchors.topMargin: 28
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: hint.top
|
|
anchors.leftMargin: root.sideMargin
|
|
anchors.rightMargin: root.sideMargin
|
|
anchors.bottomMargin: 18
|
|
visible: root.query.trim() !== ""
|
|
clip: true
|
|
contentWidth: width
|
|
contentHeight: resultFlow.implicitHeight
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
Flow {
|
|
id: resultFlow
|
|
readonly property real cardWidth: (searchResults.width - Theme.sectionSpacing * 2) / 3
|
|
x: Math.max(0, (searchResults.width - width) / 2)
|
|
width: root.resultColumns * cardWidth + (root.resultColumns - 1) * Theme.sectionSpacing
|
|
spacing: Theme.sectionSpacing
|
|
|
|
Repeater {
|
|
model: root.filteredWindows
|
|
|
|
WindowThumbnail {
|
|
required property var modelData
|
|
required property int index
|
|
toplevel: modelData.toplevel
|
|
refreshToken: root.refreshToken + root.actionRefreshToken
|
|
selected: index === root.selectedResultIndex
|
|
width: resultFlow.cardWidth
|
|
height: Math.round(width / root.screenAspect) + 18
|
|
onActivated: root.focusToplevel(modelData.toplevel)
|
|
onCloseRequested: root.closeToplevel(modelData.toplevel)
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
visible: root.filteredWindows.length === 0
|
|
text: "No windows match “" + root.query + "”"
|
|
color: Theme.fgDim
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeLarge
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: hint
|
|
anchors.bottom: scratchpadShelf.top
|
|
anchors.bottomMargin: 8
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: root.query.trim() === "" ? "Drag windows between workspaces or into Scratchpad · Esc to return" : "Type to search every workspace · Enter opens the first match"
|
|
color: Theme.fgMuted
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
}
|