Build the Panama Hyprland desktop
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
// The overview — a replacement for GNOME's Activities. Opened with Super+grave
|
||||
// (the lead wires the IPC; this file only reacts to ShellState.overviewOpen).
|
||||
//
|
||||
// The compositor blurs and dims what is behind this surface via the
|
||||
// `qs-overlay` layer rule; the scrim below adds the tint on top of that.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename.
|
||||
WlrLayershell.namespace: "qs-overview"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: root.open ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
|
||||
|
||||
readonly property bool open: ShellState.overviewOpen
|
||||
|
||||
// The window has to stay mapped for the length of the close animation,
|
||||
// otherwise it vanishes instantly and only the open transition is ever
|
||||
// seen. `mapped` tracks overviewOpen, delayed on the way down.
|
||||
property bool mapped: false
|
||||
visible: mapped
|
||||
|
||||
// Each open bumps the token, which makes every thumbnail re-capture once.
|
||||
// Nothing captures while the overview is closed or idle.
|
||||
property int refreshToken: 0
|
||||
|
||||
onOpenChanged: {
|
||||
if (open) {
|
||||
unmapTimer.stop();
|
||||
// The IPC/toplevel caches can lag a fast workspace switch.
|
||||
Hyprland.refreshWorkspaces();
|
||||
Hyprland.refreshToplevels();
|
||||
mapped = true;
|
||||
refreshToken++;
|
||||
Qt.callLater(() => body.prepare());
|
||||
} else {
|
||||
unmapTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: unmapTimer
|
||||
interval: Theme.durNormal
|
||||
onTriggered: root.mapped = false
|
||||
}
|
||||
|
||||
Item {
|
||||
id: surface
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
|
||||
Keys.onEscapePressed: ShellState.close()
|
||||
|
||||
opacity: root.open ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Theme.durNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
transform: Scale {
|
||||
origin.x: surface.width / 2
|
||||
origin.y: surface.height / 2
|
||||
xScale: root.open ? 1 : 0.96
|
||||
yScale: xScale
|
||||
|
||||
Behavior on xScale {
|
||||
NumberAnimation {
|
||||
duration: Theme.durNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
|
||||
|
||||
// Clicking anywhere that is not a card dismisses, as in GNOME.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: ShellState.close()
|
||||
}
|
||||
}
|
||||
|
||||
OverviewBody {
|
||||
id: body
|
||||
anchors.fill: parent
|
||||
refreshToken: root.refreshToken
|
||||
open: root.open
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// One global place to set windows aside. Empty, it is a quiet drop target;
|
||||
// populated, it expands into a compact row of restorable window cards.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var workspace
|
||||
property int refreshToken: 0
|
||||
|
||||
signal windowDropped(var toplevel)
|
||||
signal restoreRequested(var toplevel)
|
||||
signal closeRequested(var toplevel)
|
||||
|
||||
readonly property var windows: root.workspace?.toplevels?.values ?? []
|
||||
readonly property bool populated: root.windows.length > 0
|
||||
|
||||
implicitHeight: root.populated ? 112 : 42
|
||||
|
||||
Rectangle {
|
||||
id: surface
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
width: root.populated
|
||||
? Math.min(parent.width, Math.max(360, root.windows.length * 198 + 28))
|
||||
: 292
|
||||
height: root.populated ? 112 : 42
|
||||
radius: root.populated ? Theme.popoverRadius : Theme.pillRadius
|
||||
border.width: 1
|
||||
border.color: dropArea.containsDrag ? Theme.ok : Theme.alpha(Theme.fg, root.populated ? 0.1 : 0.08)
|
||||
color: Theme.alpha(Theme.bgPopover, root.populated ? 0.76 : 0.42)
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
|
||||
}
|
||||
Behavior on height {
|
||||
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
|
||||
}
|
||||
Behavior on border.color {
|
||||
ColorAnimation { duration: Theme.durFast }
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
visible: !root.populated
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: dropArea.containsDrag ? "\u{F04E7}" : "\u{F02D4}"
|
||||
color: dropArea.containsDrag ? Theme.ok : Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 15
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: dropArea.containsDrag ? "Release to set aside" : "Drop to Scratchpad"
|
||||
color: dropArea.containsDrag ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: heading
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 14
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 9
|
||||
visible: root.populated
|
||||
text: "Scratchpad · " + root.windows.length
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: windowStrip
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
anchors.bottomMargin: 8
|
||||
height: 76
|
||||
visible: root.populated
|
||||
clip: true
|
||||
contentWidth: Math.max(width, cards.implicitWidth)
|
||||
contentHeight: height
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Row {
|
||||
id: cards
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
Repeater {
|
||||
model: root.windows
|
||||
|
||||
WindowThumbnail {
|
||||
required property var modelData
|
||||
toplevel: modelData
|
||||
refreshToken: root.refreshToken
|
||||
width: 190
|
||||
height: 76
|
||||
onActivated: root.restoreRequested(modelData)
|
||||
onCloseRequested: root.closeRequested(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropArea {
|
||||
id: dropArea
|
||||
anchors.fill: parent
|
||||
z: 5
|
||||
onDropped: drop => {
|
||||
if (!drop.source?.toplevel)
|
||||
return;
|
||||
root.windowDropped(drop.source.toplevel);
|
||||
drop.acceptProposedAction();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// A single live window preview inside a workspace card, with the app icon and
|
||||
// title underneath — GNOME's overview window chrome.
|
||||
//
|
||||
// Capture is deliberately one-shot (`live: false` + an explicit captureFrame()
|
||||
// on open). Streaming every window continuously would repaint the whole
|
||||
// overview forever, which is exactly the idle-repaint cost this shell avoids.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Widgets
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var toplevel // HyprlandToplevel
|
||||
|
||||
// Bumped by Overview each time it opens; each bump re-captures.
|
||||
property int refreshToken: 0
|
||||
property bool selected: false
|
||||
|
||||
signal activated
|
||||
signal closeRequested
|
||||
signal dragStarted
|
||||
signal dragFinished
|
||||
|
||||
property real dragStartX: 0
|
||||
property real dragStartY: 0
|
||||
|
||||
z: dragHandler.active ? 100 : 0
|
||||
opacity: dragHandler.active ? 0.88 : 1
|
||||
Drag.active: dragHandler.active
|
||||
Drag.hotSpot.x: width / 2
|
||||
Drag.hotSpot.y: height / 2
|
||||
Drag.supportedActions: Qt.MoveAction
|
||||
|
||||
// Null until Hyprland reports the address — and always null on compositors
|
||||
// without wlr-screencopy, which is why every use of it is guarded.
|
||||
readonly property var source: toplevel ? toplevel.wayland : null
|
||||
readonly property string title: toplevel && toplevel.title ? toplevel.title : ""
|
||||
readonly property string appId: source && source.appId ? source.appId : ""
|
||||
// The `applications` read is deliberate: the entry scan finishes
|
||||
// asynchronously, and heuristicLookup() alone creates no binding dependency.
|
||||
readonly property var entry: appId && DesktopEntries.applications.values.length > 0 ? DesktopEntries.heuristicLookup(appId) : null
|
||||
|
||||
// "" when nothing resolves — a missing icon is left blank rather than shown
|
||||
// as the theme's broken-icon placeholder.
|
||||
readonly property string iconSource: {
|
||||
const name = entry && entry.icon ? entry.icon : appId;
|
||||
if (!name)
|
||||
return "";
|
||||
if (name.startsWith("/"))
|
||||
return "file://" + name;
|
||||
return Quickshell.iconPath(name, true);
|
||||
}
|
||||
|
||||
onRefreshTokenChanged: root.recapture()
|
||||
|
||||
function recapture(): void {
|
||||
if (shotLoader.item)
|
||||
shotLoader.item.tryCapture();
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: preview
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: caption.top
|
||||
anchors.bottomMargin: 4
|
||||
|
||||
radius: Theme.cardRadius
|
||||
border.width: 0 // QTBUG-137166
|
||||
color: Theme.alpha(Theme.bg, 0.6)
|
||||
clip: true
|
||||
|
||||
scale: dragHandler.active ? 1.04 : (hover.hovered ? 1.025 : 1)
|
||||
|
||||
Behavior on scale {
|
||||
NumberAnimation {
|
||||
duration: Theme.durFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
// Shown until (or unless) a frame arrives. On a compositor without
|
||||
// screencopy this is all the user ever sees, rather than an error.
|
||||
IconImage {
|
||||
anchors.centerIn: parent
|
||||
visible: root.iconSource !== ""
|
||||
source: root.iconSource
|
||||
implicitSize: Math.max(24, Math.min(64, Math.min(preview.width, preview.height) * 0.4))
|
||||
asynchronous: true
|
||||
mipmap: true
|
||||
opacity: shotLoader.hasFrame ? 0 : 1
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: shotLoader
|
||||
anchors.fill: parent
|
||||
|
||||
readonly property bool hasFrame: item ? item.hasContent : false
|
||||
|
||||
// Only instantiate a capture when there is something to capture AND
|
||||
// the overview has been opened at least once.
|
||||
//
|
||||
// refreshToken is incremented by Overview.qml at the moment it maps,
|
||||
// so gating on it guarantees the ScreencopyView is created while the
|
||||
// window is actually mapped. Creating it earlier makes the initial
|
||||
// captureFrame() fail with "no recording context is ready", because
|
||||
// an unmapped layer surface has no capture context yet.
|
||||
active: !!root.source && root.refreshToken > 0
|
||||
sourceComponent: shotComponent
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: root.selected
|
||||
radius: parent.radius
|
||||
color: "transparent"
|
||||
border.width: 2
|
||||
border.color: Theme.accent
|
||||
z: 3
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: closeButton
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 7
|
||||
visible: hover.hovered && !dragHandler.active
|
||||
width: 26
|
||||
height: 26
|
||||
radius: 13
|
||||
z: 4
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.1)
|
||||
color: closeHover.hovered ? Theme.alpha(Theme.danger, 0.28) : Theme.alpha(Theme.bgDark, 0.82)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "\u{F0156}"
|
||||
color: closeHover.hovered ? Theme.danger : Theme.fg
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 13
|
||||
}
|
||||
|
||||
HoverHandler { id: closeHover }
|
||||
TapHandler {
|
||||
onTapped: root.closeRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: shotComponent
|
||||
|
||||
ScreencopyView {
|
||||
id: shot
|
||||
captureSource: root.source
|
||||
live: false
|
||||
paintCursor: false
|
||||
constraintSize: Qt.size(preview.width, preview.height)
|
||||
|
||||
// Fit inside the preview without distorting the window's shape.
|
||||
readonly property real aspect: sourceSize.height > 0 ? sourceSize.width / sourceSize.height : 16 / 9
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width, parent.height * aspect)
|
||||
height: aspect > 0 ? width / aspect : parent.height
|
||||
opacity: hasContent ? 1 : 0
|
||||
|
||||
// The capture context isn't ready the instant the layer surface is
|
||||
// told to become visible — it needs the surface to actually map,
|
||||
// which takes a frame or two. Calling captureFrame() before then
|
||||
// logs "no recording context is ready" and yields nothing, so
|
||||
// retry a few times and then give up quietly (the caption and app
|
||||
// icon are still shown, so a missing thumbnail is cosmetic).
|
||||
property int captureAttempts: 0
|
||||
|
||||
function tryCapture(): void {
|
||||
captureAttempts = 0;
|
||||
captureRetry.restart();
|
||||
}
|
||||
|
||||
// `shot`, not `parent`: Timer is a QtObject, so `parent` does not
|
||||
// resolve to the enclosing ScreencopyView.
|
||||
Timer {
|
||||
id: captureRetry
|
||||
interval: 80
|
||||
repeat: true
|
||||
running: false
|
||||
onTriggered: {
|
||||
if (shot.hasContent || shot.captureAttempts >= 6) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
shot.captureAttempts++;
|
||||
shot.captureFrame();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: tryCapture()
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: caption
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: 6
|
||||
height: 18
|
||||
|
||||
IconImage {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.iconSource !== ""
|
||||
width: visible ? 16 : 0
|
||||
height: 16
|
||||
source: root.iconSource
|
||||
asynchronous: true
|
||||
mipmap: true
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// Clamped against the thumbnail rather than the Row: sizing against
|
||||
// the Row would make its width depend on its own implicitWidth.
|
||||
width: Math.min(implicitWidth, Math.max(0, root.width - 22))
|
||||
text: root.title
|
||||
elide: Text.ElideRight
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: hover
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.activated()
|
||||
}
|
||||
|
||||
DragHandler {
|
||||
id: dragHandler
|
||||
target: root
|
||||
cursorShape: Qt.DragMoveCursor
|
||||
|
||||
onActiveChanged: {
|
||||
if (active) {
|
||||
root.dragStartX = root.x;
|
||||
root.dragStartY = root.y;
|
||||
root.dragStarted();
|
||||
} else {
|
||||
root.Drag.drop();
|
||||
root.x = root.dragStartX;
|
||||
root.y = root.dragStartY;
|
||||
root.dragFinished();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// One workspace in the overview: a proportional card holding live thumbnails
|
||||
// of its windows, labelled underneath. The focused workspace is outlined in the
|
||||
// accent colour, as GNOME outlines the current workspace.
|
||||
//
|
||||
// A card with a null `workspace` is the trailing "new workspace" affordance
|
||||
// that dynamic-workspace setups (GNOME's, and Hyprland's) always show.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// HyprlandWorkspace, or null for the trailing "new workspace" card.
|
||||
required property var workspace
|
||||
// Shown on the empty trailing card.
|
||||
property int newWorkspaceId: 0
|
||||
|
||||
property int refreshToken: 0
|
||||
// `emphasized` is the overview's current spatial selection. It normally
|
||||
// follows compositor focus, but can point at a requested focus workspace
|
||||
// before the user commits to switching there.
|
||||
property bool emphasized: focused
|
||||
property bool focusBound: false
|
||||
|
||||
signal activated
|
||||
signal windowActivated(var toplevel)
|
||||
signal windowCloseRequested(var toplevel)
|
||||
signal windowDropped(var toplevel, string workspaceName)
|
||||
|
||||
readonly property bool isNew: !workspace
|
||||
readonly property var windows: workspace ? workspace.toplevels.values : []
|
||||
readonly property bool focused: workspace ? workspace.focused : false
|
||||
readonly property string dropWorkspaceName: workspace ? String(workspace.id)
|
||||
: (newWorkspaceId > 0 ? String(newWorkspaceId) : "")
|
||||
|
||||
readonly property string label: {
|
||||
if (isNew)
|
||||
return qsTr("New");
|
||||
// Named workspaces keep their name; numbered ones read better as
|
||||
// "Workspace N" only when there is room, so just show the name.
|
||||
return workspace.name ? workspace.name : String(workspace.id);
|
||||
}
|
||||
|
||||
// Card body — the label sits under it, so reserve that space.
|
||||
readonly property int labelHeight: 22
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: root.labelHeight
|
||||
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.bgDark, 0.55)
|
||||
border.width: 2
|
||||
border.color: {
|
||||
if (dropArea.containsDrag)
|
||||
return Theme.ok;
|
||||
if (root.emphasized)
|
||||
return Theme.accent;
|
||||
if (root.focusBound)
|
||||
return Theme.alpha(Theme.accentSecondary, 0.72);
|
||||
return Theme.alpha(Theme.fg, 0.08);
|
||||
}
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
// Windows are laid out on a near-square grid. Hyprland does not expose
|
||||
// live per-window geometry, so an honest grid beats a wrong guess at
|
||||
// the real tiling.
|
||||
readonly property int columns: Math.max(1, Math.ceil(Math.sqrt(root.windows.length)))
|
||||
readonly property int rows: Math.max(1, Math.ceil(root.windows.length / columns))
|
||||
readonly property int cellW: Math.floor((width - Theme.itemSpacing * (columns + 1)) / columns)
|
||||
readonly property int cellH: Math.floor((height - Theme.itemSpacing * (rows + 1)) / rows)
|
||||
|
||||
Grid {
|
||||
anchors.centerIn: parent
|
||||
columns: card.columns
|
||||
spacing: Theme.itemSpacing
|
||||
visible: root.windows.length > 0
|
||||
|
||||
Repeater {
|
||||
model: root.windows
|
||||
|
||||
WindowThumbnail {
|
||||
required property var modelData
|
||||
toplevel: modelData
|
||||
refreshToken: root.refreshToken
|
||||
width: card.cellW
|
||||
height: card.cellH
|
||||
onActivated: root.windowActivated(modelData)
|
||||
onCloseRequested: root.windowCloseRequested(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropArea {
|
||||
id: dropArea
|
||||
anchors.fill: parent
|
||||
z: 3
|
||||
enabled: root.dropWorkspaceName !== ""
|
||||
onDropped: drop => {
|
||||
if (!drop.source?.toplevel)
|
||||
return;
|
||||
root.windowDropped(drop.source.toplevel, root.dropWorkspaceName);
|
||||
drop.acceptProposedAction();
|
||||
}
|
||||
}
|
||||
|
||||
// Empty workspace: a big soft "+" so the card still reads as clickable.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: root.windows.length === 0
|
||||
text: "+"
|
||||
color: Theme.alpha(Theme.fg, 0.25)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(Theme.fontSizeTitle, Math.round(card.height * 0.28))
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 8
|
||||
visible: root.focusBound
|
||||
width: focusBadge.implicitWidth + 16
|
||||
height: 24
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.bgDark, 0.82)
|
||||
|
||||
Row {
|
||||
id: focusBadge
|
||||
anchors.centerIn: parent
|
||||
spacing: 5
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "\u{F051F}"
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Focus"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sits below the thumbnails so clicking a window still focuses it.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
z: -1
|
||||
onClicked: root.activated()
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
height: root.labelHeight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
text: root.label
|
||||
color: root.emphasized ? Theme.accent : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.bold: root.emphasized
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user