Build the Panama Hyprland desktop
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user