Files
Panama/config/dot/quickshell/modules/settings/DesktopPage.qml
T
Gabriel Brown 185d7edaa5 Open the windows you open together, together
Arrange the desktop, run "Save Layout as Project" from the launcher, name it.
"Open Project" lays it out again on free workspaces, so it never lands on top of
what you are already doing. Saved layouts are listed on the Desktop settings
page, which is where they are removed.

Recorded rather than written by hand, and a terminal's directory is most of why
it is worth having: without it a project opens three terminals in your home
folder and you change directory three times. This machine had two terminals in
the same project when it was written, and reopening there is the difference
between a layout and a working desktop.

Four things had to be found by running it, none of which reading would have
shown.

A terminal's directory is not the terminal's working directory -- that is where
it was launched from. The shell inside it is what has been cd'd. Reading the
wrong one looked correct for exactly as long as the terminals under test had
been started from the right place, which they had.

gtk-launch cannot place a window. It activates over D-Bus, so the process
Hyprland started exits and a [workspace N silent] rule has nothing left to apply
to; Nautilus landed on whatever workspace was in front of you. The Exec line
from the desktop entry is run directly instead. But DBusActivatable
applications do the same thing regardless, so the window is found afterwards and
moved by address -- which also means a window that never appeared is reported
rather than assumed.

/proc/PID/task/PID/children is a file of pids, not a directory. Listing it as
one always raised, so the fast path was never once taken and everything went
through pgrep.

And kitty's --directory needs an equals sign or the short -d; the
space-separated long form is accepted and silently ignored.

The contract exercises a save and open round trip against a stubbed compositor,
and reads a terminal's directory out of a real process tree rather than grepping
the source for a shell name -- an earlier version passed against a helper that
had been changed back, because the constant was still there.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 13:45:16 -04:00

396 lines
16 KiB
QML

// Desktop & Dock.
//
// The dock timings used to be shown here as text -- "Instant", "250 ms" -- even
// though they were already stored, mutable integers. They are controls now.
// The window-layout card keeps text rows because those really are facts about
// how Panama tiles rather than settings: the adjustable parts of window
// appearance live on the Appearance page, next to the preview that explains
// them.
import QtQuick
import Quickshell
import qs.config
import qs.services
SettingsPage {
id: root
// Which project is one click from being forgotten. Same in-place confirm the
// SSH Keys and Snapshots pages use.
property string confirmingProject: ""
// Turning the last screen off would leave no dock anywhere and no obvious
// way back, so the final one cannot be removed -- it collapses to "every
// screen" instead, which is the same thing on one display and recoverable
// on several.
function toggleDockScreen(name: string): void {
const all = Quickshell.screens.map(screen => String(screen.name));
const current = Settings.dockScreens.length === 0
? all.slice()
: Settings.dockScreens.map(String);
const at = current.indexOf(name);
let next = current.slice();
if (at >= 0)
next.splice(at, 1);
else
next.push(name);
if (next.length === 0 || next.length === all.length)
next = [];
DesktopPreferences.set("dockScreens", next);
}
title: "Desktop & Dock"
lede: "Keep the shell instant, spatial, and out of your way."
SettingsCard {
title: "Dock"
ChoiceRow { setting: "dockPosition" }
// One row per connected screen. Nothing selected means every screen,
// which is stated rather than left as an empty list somebody has to
// interpret -- and it is what a single-monitor machine should do
// without being configured at all.
SettingRow {
label: "Screens"
detail: Settings.dockScreens.length === 0
? "On every display"
: "On " + Settings.dockScreens.length + " of "
+ Quickshell.screens.length + " displays"
visible: Quickshell.screens.length > 1
controlWidth: 260
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
Repeater {
model: Quickshell.screens
delegate: Rectangle {
id: screenPill
required property var modelData
readonly property string screenName: String(screenPill.modelData.name ?? "")
// An empty list means all, so every pill reads as on.
readonly property bool on: Settings.dockScreens.length === 0
|| Settings.dockScreens.indexOf(screenPill.screenName) >= 0
width: pillLabel.implicitWidth + 20
height: 28
radius: 8
color: screenPill.on ? Theme.alpha(Theme.accent, 0.22)
: Theme.alpha(Theme.fg, 0.06)
border.width: screenPill.on ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.5)
Text {
id: pillLabel
anchors.centerIn: parent
text: screenPill.screenName
color: screenPill.on ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
HoverHandler { cursorShape: Qt.PointingHandCursor }
TapHandler { onTapped: root.toggleDockScreen(screenPill.screenName) }
}
}
}
}
ToggleRow { setting: "dockAutohide" }
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant" }
SliderRow { setting: "dockIconSize"; divider: false }
}
SettingsCard {
title: "Pinned applications"
subtitle: "What sits in the Dock whether or not it is running. Order here is the order on screen."
DockPinsEditor {
id: pins
width: parent.width
}
}
SettingsCard {
title: "Pin another application"
DockAppPicker {
width: parent.width
pinned: pins.pinned
onPicked: id => pins.add(id)
}
}
SettingsCard {
title: "Window layout"
subtitle: "Follows the Forge mental model, with native Hyprland tiling."
// These were two read-only rows reporting "Tiling" and "Dynamic", which
// described settings rather than facts -- both are ordinary Hyprland
// options that simply had no controls. TextRow's own documentation says
// a setting the user could reasonably change does not belong in it.
ChoiceRow { setting: "windowLayout" }
ToggleRow { setting: "preserveSplit" }
ChoiceRow { setting: "forceSplit" }
ToggleRow { setting: "windowSnapping" }
ActionRow {
label: "Gaps, corners, and effects"
detail: "Adjusted on the Appearance page, beside a live preview"
action: "Open Appearance"
divider: false
onTriggered: ShellState.openSettingsSection("appearance", "windows")
}
}
// GNOME's Multitasking panel, in Hyprland's terms.
// Only meaningful when the layout above is Master and stack. Hidden
// otherwise, because a card of settings that do nothing under the layout
// you are actually running is worse than not offering the layout at all.
SettingsCard {
visible: DesktopPreferences.get("windowLayout") === "master"
title: "Master and stack"
subtitle: "How the master area behaves. These apply only while the tiling layout above is Master and stack."
SliderRow { setting: "masterFactor" }
ChoiceRow { setting: "masterOrientation" }
ChoiceRow { setting: "masterNewStatus" }
ToggleRow { setting: "masterNewOnTop"; divider: false }
}
SettingsCard {
title: "Window edges"
subtitle: "How the pointer grabs a window's border, and how floating windows behave near each other and the screen edge."
ToggleRow { setting: "resizeOnBorder" }
SliderRow { setting: "borderGrabArea"; zeroLabel: "Border only" }
ToggleRow { setting: "hoverIconOnBorder" }
SliderRow { setting: "snapWindowGap"; zeroLabel: "Touching" }
SliderRow { setting: "snapMonitorGap"; zeroLabel: "Touching" }
ToggleRow { setting: "snapRespectGaps"; divider: false }
}
// Hyprland's own interruptions. Panama turns all four off, which is a
// defensible default and was not previously a decision anyone could
// reverse without editing looks.lua.
SettingsCard {
title: "Hyprland notices"
subtitle: "Panama hides all of these by default. They are the compositor's own, not Panama's."
ToggleRow { setting: "hyprlandLogo" }
ToggleRow { setting: "hyprlandSplash" }
ToggleRow { setting: "hyprlandUpdateNews" }
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
}
// Saved here, not created here. A layout is worth recording at the moment
// you have it right, so saving is a launcher command; this is where the
// ones you kept are reviewed and the ones you did not are removed.
SettingsCard {
title: "Projects"
subtitle: Projects.projects.length > 0
? "Saved window layouts. Opening one claims free workspaces, so it never lands on top of what you are doing."
: "No saved layouts yet. Arrange your windows, then run \"Save Layout as Project\" from the launcher."
Repeater {
model: Projects.projects
delegate: SettingRow {
id: projectRow
required property var modelData
required property int index
readonly property string projectName: String(projectRow.modelData.name ?? "")
readonly property bool confirming: root.confirmingProject === projectRow.projectName
label: projectRow.projectName
detail: projectRow.confirming
? "Forgetting this only removes the layout. Nothing that is open closes."
: projectRow.modelData.windows + " windows across "
+ projectRow.modelData.workspaces + " workspaces — "
+ (projectRow.modelData.applications ?? []).join(", ")
divider: projectRow.index < Projects.projects.length - 1
controlWidth: 210
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: !projectRow.confirming
text: "Open"
enabled: !Projects.busy
onClicked: Projects.open(projectRow.projectName)
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: projectRow.confirming
text: "Forget it"
tone: "danger"
enabled: !Projects.busy
onClicked: {
root.confirmingProject = "";
Projects.remove(projectRow.projectName);
}
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: projectRow.confirming ? "Keep" : "Forget"
enabled: !Projects.busy
onClicked: root.confirmingProject =
projectRow.confirming ? "" : projectRow.projectName
}
}
}
}
TextRow {
visible: Projects.lastError !== ""
label: "Problem"
detail: Projects.lastError
divider: false
}
}
SettingsCard {
title: "Workspaces & focus"
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
ToggleRow { setting: "workspaceBackAndForth" }
ToggleRow { setting: "allowWorkspaceCycles" }
ToggleRow { setting: "focusOnActivate" }
ToggleRow { setting: "mouseMoveFocusesMonitor" }
ToggleRow { setting: "windowSwallow"; divider: false }
}
SettingsCard {
title: "Focus"
SliderRow { setting: "focusDurationMinutes"; divider: false }
}
// Distinct from the snapshots below, which put THIS machine back as it
// was. This carries settings to a different one, and deliberately leaves
// behind anything that describes hardware.
SettingsCard {
title: "Carry settings to another machine"
subtitle: SettingsSync.lastError !== ""
? SettingsSync.lastError
: "Everything except what describes this machine: the display arrangement stays here."
ActionRow {
label: "Export"
detail: SettingsSync.lastAction === "export" && SettingsSync.carried > 0
? SettingsSync.carried + " settings written to " + SettingsSync.defaultPath
: "Writes " + SettingsSync.defaultPath
action: "Export"
enabled: !SettingsSync.busy
onTriggered: SettingsSync.exportTo(SettingsSync.defaultPath)
}
ActionRow {
label: "See what an import would change"
detail: SettingsSync.previewed
? SettingsSync.changes.length + " would change, "
+ SettingsSync.skipped.length + " skipped"
: "Reads " + SettingsSync.defaultPath + " without applying anything"
action: "Preview"
enabled: !SettingsSync.busy
onTriggered: SettingsSync.preview(SettingsSync.defaultPath)
}
// Only offered once a preview has said what it would do. Importing
// settings sight unseen is how somebody ends up wondering why their
// desktop changed.
ActionRow {
visible: SettingsSync.previewed && SettingsSync.changes.length > 0
label: "Apply those " + SettingsSync.changes.length + " changes"
detail: "Settings the file does not mention are left alone"
action: "Import"
enabled: !SettingsSync.busy
onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath)
}
Repeater {
model: SettingsSync.previewed ? SettingsSync.skipped : []
delegate: TextRow {
required property var modelData
width: parent.width
label: String(modelData.key ?? "")
detail: "Skipped: " + String(modelData.reason ?? "")
value: ""
}
}
TextRow {
visible: SettingsSync.lastAction === "import" && SettingsSync.lastError === ""
label: SettingsSync.applied === 0
? "Nothing needed changing"
: SettingsSync.applied + " settings applied"
detail: "From " + (SettingsSync.exportedFrom || "the export")
value: ""
divider: false
}
}
SettingsCard {
title: "Snapshots"
subtitle: SettingsBackup.lastError !== ""
? SettingsBackup.lastError
: "Your whole desktop configuration is one file, so a snapshot is a copy of it. Restoring also snapshots what it replaces, so it is itself undoable."
ActionRow {
label: "Back up current settings"
detail: SettingsBackup.snapshots.length === 0
? "No snapshots yet"
: SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " snapshot kept" : " snapshots kept") + ", newest first"
action: "Back up now"
enabled: !SettingsBackup.busy
divider: SettingsBackup.snapshots.length > 0
onTriggered: SettingsBackup.save()
}
Repeater {
id: snapshotRows
model: SettingsBackup.snapshots
ActionRow {
required property var modelData
required property int index
label: modelData.when
detail: modelData.keys + " settings"
action: "Restore"
enabled: !SettingsBackup.busy
divider: index < snapshotRows.count - 1
onTriggered: SettingsBackup.restore(modelData.name)
}
}
}
SettingsCard {
title: "Reset"
subtitle: "Restores the appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
ActionRow {
label: "Restore defaults"
detail: "Applies immediately, including to the compositor"
action: "Restore defaults"
divider: false
onTriggered: SystemSettings.restoreDefaults()
}
}
}