Shortcuts you invent, rules you write, gestures you own - all still just data

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-25 01:51:59 -04:00
parent f9e5d3f470
commit 06c53d6c21
48 changed files with 4749 additions and 140 deletions
@@ -101,7 +101,35 @@ SettingsPage {
ToggleRow { setting: "magnifierRigid" }
SliderRow { setting: "textScale" }
SliderRow { setting: "cursorSize" }
ToggleRow { setting: "highContrast"; divider: false }
ToggleRow { setting: "highContrast" }
// The stored value is the enum; what the compositor wants is a shader
// path. SystemSettings owns that mapping and applies it live, and
// hypr/looks.lua does the same lookup at config time -- so the filter
// survives a reload without this page having to reload anything.
//
// Applied on change rather than on load: the compositor already read
// the preference at launch, and re-applying the value it is already
// running would be a hyprctl call for nothing every time this page
// opens.
ChoiceRow {
id: colorFilterRow
property string appliedFilter: ""
setting: "colorFilter"
divider: false
Component.onCompleted: colorFilterRow.appliedFilter = String(colorFilterRow.current)
onCurrentChanged: {
const next = String(colorFilterRow.current);
if (next === colorFilterRow.appliedFilter)
return;
colorFilterRow.appliedFilter = next;
SystemSettings.applyColorFilter(next);
}
}
}
SettingsCard {
@@ -1,29 +1,48 @@
// The facts about one connection that otherwise need a terminal.
// The facts about one connection that otherwise need a terminal, and the two
// settings that change them.
//
// IP address, gateway, DNS and MAC are the four things people leave this app
// for, and they were the reason the Connections page still pointed at GNOME.
// They are read-only here: editing them properly means static addressing, which
// is a page of its own rather than four fields smuggled into a details drawer.
// The facts above are what is on the wire; the editors below are what the
// PROFILE asks for, which is a different question -- a static address that has
// not been applied yet is in the second and not the first, and a component that
// showed only the first would look like it had forgotten what was typed.
//
// Nothing here is ever a secret. panama-network's `details` verb returns
// addresses only -- no PSK, no enterprise password -- so this component can be
// shown for any connection without deciding what is safe to draw.
//
// Nothing applies until Apply. A half-typed address is a draft, not a broken
// network: committing per keystroke would take the connection down somewhere
// around the second octet. Same proxy-draft shape ConnectivityPage uses -- each
// field starts as a binding to the profile and stops being one at the first
// edit, so a reply landing mid-edit cannot empty the box being typed into.
//
// The values are set in the interface face with tabular figures rather than a
// monospaced one. Theme bans monospaced text outright (fontMono is the icon
// face, not a text face), and an address only needs its digits to line up.
import QtQuick
import qs.config
import qs.services
Column {
id: root
// { ip4, gateway, dns: [], mac, macRandomized } as the helper reports it,
// or null while the read has not come back. Null is NOT "no address": the
// component says it is still reading rather than claiming an answer.
// { ip4, gateway, dns: [], mac, macRandomized, metered, ip4Method … } as
// the helper reports it, or null while the read has not come back. Null is
// NOT "no address": the component says it is still reading rather than
// claiming an answer.
property var details: null
// The profile these facts belong to. Empty means "facts only" -- there is
// nothing to write to, so the editors do not appear. Every caller that has
// a connection name should pass it.
property string connection: ""
// Editing needs both a name to write to and an answer to edit from.
readonly property bool editable: root.connection !== "" && !!root.details
// [{ key, value, note }] -- only the facts that actually have a value, so a
// connection with no gateway shows three rows rather than a blank one.
readonly property var facts: {
@@ -145,4 +164,192 @@ Column {
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
// ── Metered ─────────────────────────────────────────────────────────────
//
// Three states in NetworkManager, two in this switch, and the difference is
// said rather than hidden: "automatic" is NetworkManager deciding from what
// the network told it, which is a guess, and the detail line says so while
// it is the state in force.
SwitchRow {
width: parent.width
visible: root.editable
label: "Metered connection"
detail: String(root.details?.metered ?? "auto") === "auto"
? "NetworkManager is deciding for itself. Turn this on to hold updates and large downloads back until you are somewhere unmetered."
: "Updates and large downloads wait until you are somewhere unmetered"
checked: String(root.details?.metered ?? "auto") === "yes"
enabled: !NetworkTools.busy
onToggled: value => NetworkTools.setMetered(root.connection, value ? "yes" : "no")
}
// ── Addressing, one stack at a time ─────────────────────────────────────
//
// A Repeater over the two families rather than two hand-written copies: the
// drafts, the validation and the Apply are identical, and the only things
// that differ are the property prefix and what an address looks like.
//
// Each stack applies on its own. The helper writes a whole stack in one
// nmcli call and reactivates the connection afterwards, and NetworkTools
// runs one mutation at a time -- so a single Apply for both would silently
// drop one of them.
Repeater {
model: [
{
family: "4",
label: "IPv4",
addressHint: "192.168.1.50/24",
gatewayHint: "192.168.1.1",
dnsHint: "1.1.1.1, 9.9.9.9"
},
{
family: "6",
label: "IPv6",
addressHint: "fd00::42/64",
gatewayHint: "fd00::1",
dnsHint: "2606:4700:4700::1111"
}
]
Column {
id: stack
required property var modelData
readonly property bool six: String(stack.modelData.family) === "6"
// What the profile says now. Bound, so an Apply that succeeds
// reseeds every field nobody has touched.
readonly property string profileMethod:
String((stack.six ? root.details?.ip6Method : root.details?.ip4Method) ?? "")
=== "manual" ? "manual" : "auto"
readonly property string profileAddress: {
const list = (stack.six ? root.details?.ip6Addresses : root.details?.ip4Addresses) ?? [];
return list.length > 0 ? String(list[0]) : "";
}
readonly property string profileGateway:
String((stack.six ? root.details?.ip6Gateway : root.details?.ip4Gateway) ?? "")
readonly property string profileDns: {
const list = (stack.six ? root.details?.ip6Dns : root.details?.ip4Dns) ?? [];
return list.map(entry => String(entry)).join(", ");
}
// The drafts. Bindings until the first edit, and the user's after
// it -- see the header.
property string draftMethod: stack.profileMethod
property string draftAddress: stack.profileAddress
property string draftGateway: stack.profileGateway
property string draftDns: stack.profileDns
// The helper validates properly and is the authority. This is the
// same shape one step earlier, so Apply is dark rather than a round
// trip that comes back refused.
readonly property bool addressValid: stack.six
? (stack.draftAddress.indexOf(":") >= 0
&& /^[0-9A-Fa-f:]{2,45}\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/.test(stack.draftAddress))
: /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/.test(stack.draftAddress)
readonly property bool dirty: stack.draftMethod !== stack.profileMethod
|| (stack.draftMethod === "manual"
&& (stack.draftAddress !== stack.profileAddress
|| stack.draftGateway !== stack.profileGateway
|| stack.draftDns !== stack.profileDns))
function apply(): void {
if (root.connection === "")
return;
if (stack.draftMethod === "auto") {
NetworkTools.setIpAuto(root.connection, stack.modelData.family);
return;
}
if (!stack.addressValid)
return;
NetworkTools.setIpManual(root.connection, stack.modelData.family,
stack.draftAddress.trim(),
stack.draftGateway.trim(),
stack.draftDns.trim());
}
width: parent.width
visible: root.editable
OptionPickerRow {
width: parent.width
label: String(stack.modelData.label)
detail: stack.profileMethod === "manual"
? "This connection asks for an address you chose"
: "This connection takes whatever the network hands it"
enabled: !NetworkTools.busy
options: [
{
value: "auto",
label: "Automatic",
detail: stack.six
? "Router advertisements and DHCPv6, as the network offers them"
: "DHCP, as the network offers it"
},
{
value: "manual",
label: "Manual",
detail: "An address, gateway and nameservers you enter"
}
]
current: stack.draftMethod
onPicked: value => stack.draftMethod = String(value)
}
TextFieldRow {
width: parent.width
visible: stack.draftMethod === "manual"
label: "Address / prefix"
detail: stack.draftAddress !== "" && !stack.addressValid
? "Not an address yet — it needs a prefix, like " + stack.modelData.addressHint
: "The address this machine takes on the network, with its prefix length"
placeholder: String(stack.modelData.addressHint)
text: stack.draftAddress
enabled: !NetworkTools.busy
onAccepted: value => stack.draftAddress = value.trim()
}
TextFieldRow {
width: parent.width
visible: stack.draftMethod === "manual"
label: "Gateway"
detail: "The router traffic leaves through. Leave it empty on a segment with no way out."
placeholder: String(stack.modelData.gatewayHint)
text: stack.draftGateway
enabled: !NetworkTools.busy
onAccepted: value => stack.draftGateway = value.trim()
}
TextFieldRow {
width: parent.width
visible: stack.draftMethod === "manual"
label: "DNS"
detail: "Nameservers, separated by commas. These replace the ones the network hands out rather than joining them."
placeholder: String(stack.modelData.dnsHint)
text: stack.draftDns
enabled: !NetworkTools.busy
onAccepted: value => stack.draftDns = value.trim()
}
ActionRow {
width: parent.width
visible: stack.dirty
label: "Apply " + String(stack.modelData.label)
detail: {
if (stack.draftMethod === "manual" && !stack.addressValid)
return "Fill in an address with a prefix first — nothing is written until this looks like an address.";
if (stack.draftMethod === "auto")
return "Clears the static address and takes what the network offers. This connection reconnects.";
return "Writes these to this connection only, and reconnects it.";
}
action: NetworkTools.busy ? "Working…" : "Apply"
enabled: !NetworkTools.busy
&& (stack.draftMethod === "auto" || stack.addressValid)
divider: !stack.six
onTriggered: stack.apply()
}
}
}
}
@@ -48,6 +48,20 @@ SettingsPage {
property bool importOpen: false
property string importPath: ""
// The saved list, folded at the house cap. Sorted by the helper with the
// active and autoconnecting profiles first, so a slice keeps the ones worth
// seeing and folds the tail.
property bool savedShowAll: false
readonly property int savedCap: 6
readonly property var savedShown: {
const list = NetworkTools.savedConnections;
if (root.savedShowAll || list.length <= root.savedCap)
return list;
return list.slice(0, root.savedCap);
}
readonly property int savedHidden:
NetworkTools.savedConnections.length - root.savedShown.length
// The proxy dropdown and its address, page-local until they add up to a
// whole setting.
//
@@ -187,6 +201,7 @@ SettingsPage {
ConnectionDetails {
width: parent.width
visible: root.wiredOpen && Connectivity.wiredOn
connection: root.wiredConnection
details: root.wiredConnection !== ""
? NetworkTools.detailsFor(root.wiredConnection) : null
}
@@ -303,6 +318,88 @@ SettingsPage {
}
}
// ── Saved networks ───────────────────────────────────────────────────────
//
// The list above is what is nearby. This is what this machine REMEMBERS,
// which is a different set and the more useful one to tidy: a profile you
// want rid of is invisible in a scan-driven list until you are standing
// next to it, which is exactly when you are least able to deal with it.
SettingsCard {
title: "Saved networks"
visible: NetworkTools.savedConnections.length > 0
subtitle: NetworkTools.savedConnections.length
+ (NetworkTools.savedConnections.length === 1 ? " profile" : " profiles")
+ " NetworkManager holds, including the ones nowhere near you."
Repeater {
model: root.savedShown
delegate: SettingRow {
id: savedRow
required property var modelData
required property int index
readonly property string name: String(savedRow.modelData.name ?? "")
readonly property bool wireless: savedRow.modelData.wifi === true
width: parent.width
label: savedRow.name
// Armed, the row stops describing the profile and names what
// the confirming press costs. The saved passphrase goes with
// the profile, and nothing else on this page says so.
detail: {
if (forgetSaved.armed)
return "This deletes the saved profile and its password. Rejoining "
+ savedRow.name + " means typing it again.";
const bits = [];
if (savedRow.modelData.active === true)
bits.push("Connected");
else if (savedRow.wireless)
bits.push(savedRow.modelData.inRange === true ? "In range" : "Out of range");
else
bits.push(String(savedRow.modelData.type ?? ""));
bits.push(savedRow.modelData.autoconnect === true
? "autoconnects" : "never autoconnects");
return bits.join(" · ");
}
value: savedRow.modelData.active === true ? "this one" : ""
controlWidth: 200
divider: savedRow.index < root.savedShown.length - 1 || root.savedHidden > 0
|| root.savedShowAll
ConfirmAction {
id: forgetSaved
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: savedRow.modelData.active !== true
actionId: "forget-saved-network:" + savedRow.name
armText: "Forget…"
confirmText: "Forget it"
enabled: !NetworkTools.busy
onConfirmed: NetworkTools.forget(savedRow.name)
}
}
}
SettingRow {
width: parent.width
visible: root.savedHidden > 0
|| (root.savedShowAll && NetworkTools.savedConnections.length > root.savedCap)
label: root.savedShowAll
? "Show fewer"
: root.savedHidden + (root.savedHidden === 1 ? " more" : " more")
detail: root.savedShowAll
? ""
: "Folded to keep the list short — the ones you use least are at the bottom"
activatable: true
divider: false
onActivated: root.savedShowAll = !root.savedShowAll
}
}
// ── VPN ──────────────────────────────────────────────────────────────────
SettingsCard {
@@ -0,0 +1,276 @@
// The two-step flow behind " Add shortcut".
//
// Step one records the chord, because a shortcut whose keys are already taken
// is not worth choosing an action for -- the conflict is reported here, before
// anything is stored. Step two picks what it does, from three fixed
// vocabularies: an installed application, one of the shell's own actions, or
// one of the compositor's window verbs.
//
// Nothing typed here becomes a command. The editor reports an enum kind and a
// target that Keybinds.describeAction() already recognises; hypr/actions.lua
// resolves that to something runnable through whitelist tables of its own. That
// is the whole reason there is no "run this command" option: settings.json has
// to stay a file it is safe to hand somebody.
import QtQuick
import Quickshell
import qs.config
import qs.services
import qs.modules.clipboard
Column {
id: root
// { chord, kind, target, label }
signal committed(string chord, string kind, string target, string label)
signal canceled
property string chord: ""
property string kind: "app"
property string target: ""
property string targetLabel: ""
// The action already holding a chord somebody just pressed, and the chord
// itself, so the refusal can name both.
property string conflict: ""
property string conflictChord: ""
readonly property bool capturing: root.chord === ""
readonly property bool complete: root.chord !== "" && root.target !== "" && root.targetLabel !== ""
function reset(): void {
root.chord = "";
root.kind = "app";
root.target = "";
root.targetLabel = "";
root.conflict = "";
root.conflictChord = "";
appSearch.text = "";
}
width: parent ? parent.width : 620
spacing: 0
SectionLabel {
text: root.capturing ? "New shortcut · press the keys" : "New shortcut · pick what it does"
count: root.chord === "" ? "" : root.chord
}
// ── Step one: the chord ─────────────────────────────────────────────────
Item {
width: parent.width
height: root.capturing ? 56 : 0
visible: root.capturing
clip: true
ShortcutCapture {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 300
height: 32
focus: root.visible && root.capturing
message: root.conflict === ""
? ""
: root.conflictChord + " is already " + root.conflict
onCaptured: chord => {
const taken = Keybinds.boundTo(chord, "");
if (taken !== "") {
root.conflict = taken;
root.conflictChord = chord;
return;
}
if (Keybinds.isCustomChord(chord)) {
root.conflict = "one of your own shortcuts";
root.conflictChord = chord;
return;
}
root.conflict = "";
root.chord = chord;
}
onCanceled: root.canceled()
}
Text {
anchors.left: parent.left
anchors.leftMargin: 312
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "A modifier is required. Esc cancels. A chord another action holds is reported, never taken."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
// ── Step two: the action ────────────────────────────────────────────────
Column {
width: parent.width
visible: !root.capturing
spacing: 0
ChoiceGrid {
width: parent.width
label: "What it does"
detail: "Three kinds, and only three: Panama resolves the name you pick when you press the keys, so no shortcut ever stores a command."
current: root.kind
options: [
{ value: "app", label: "Launch an application" },
{ value: "shell", label: "Shell action" },
{ value: "window", label: "Window & workspace" }
]
onPicked: value => {
root.kind = String(value);
root.target = "";
root.targetLabel = "";
}
}
// Applications are searched rather than listed: a normal machine has
// several hundred desktop entries, and a flow of pills for all of them
// is a page nobody can read.
Column {
width: parent.width
visible: root.kind === "app"
spacing: 0
SearchField {
id: appSearch
width: parent.width
placeholder: "Search installed applications"
}
Repeater {
model: root.appMatches
SettingRow {
id: candidate
required property var modelData
required property int index
label: String(candidate.modelData.name ?? "")
detail: String(candidate.modelData.id ?? "")
value: candidate.modelData.id === root.target ? "Chosen" : ""
controlWidth: 96
divider: candidate.index < root.appMatches.length - 1
activatable: true
onActivated: {
root.target = String(candidate.modelData.id ?? "");
root.targetLabel = "Launch " + String(candidate.modelData.name ?? "");
}
}
}
Text {
width: parent.width
visible: root.appMatches.length === 0
text: appSearch.text.trim() === ""
? "Type to find an application."
: "Nothing installed matches that."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
topPadding: 10
bottomPadding: 10
}
}
ChoiceGrid {
width: parent.width
visible: root.kind === "shell"
label: "Shell action"
detail: "The shell's own surfaces, each one something Panama already answers over IPC."
current: root.target
options: root.shellOptions
divider: false
onPicked: value => {
root.target = String(value);
root.targetLabel = Keybinds.shellActionLabel(root.target);
}
}
ChoiceGrid {
width: parent.width
visible: root.kind === "window"
label: "Window & workspace"
detail: "Compositor verbs: the first three act on the focused window, the rest go to a workspace."
current: root.target
options: root.windowOptions
divider: false
onPicked: value => {
root.target = String(value);
root.targetLabel = Keybinds.windowActionLabel(root.target);
}
}
Item {
width: parent.width
height: 50
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
tone: "accent"
text: "Add it"
enabled: root.complete && !Keybinds.reloading
onClicked: root.committed(root.chord, root.kind, root.target, root.targetLabel)
}
SettingsButton {
text: "Cancel"
onClicked: root.canceled()
}
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 200
horizontalAlignment: Text.AlignRight
text: root.complete
? root.targetLabel + " · saves, then the compositor reloads — same as rebinding"
: "Pick what the shortcut does."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
// ── The vocabularies ────────────────────────────────────────────────────
// Shell and window targets come from Keybinds, which is the single QML
// authority on what hypr/actions.lua will resolve. Listing them here would
// be a second opinion.
readonly property var shellOptions:
Keybinds.shellActions.map(action => ({ value: action.target, label: action.label }))
readonly property var windowOptions:
Keybinds.windowActions.map(action => ({ value: action.target, label: action.label }))
readonly property var appMatches: {
const needle = appSearch.text.trim().toLowerCase();
if (needle === "")
return [];
const out = [];
for (const entry of DesktopEntries.applications.values) {
if (entry.noDisplay)
continue;
if (String(entry.name).toLowerCase().indexOf(needle) >= 0)
out.push(entry);
if (out.length >= 8)
break;
}
return out;
}
}
@@ -0,0 +1,94 @@
// One shortcut the user invented, in the pinned Custom group.
//
// ShortcutRow renders a bind the compositor reported; this renders a stored
// `customBinds` entry, which is a different thing and deliberately not the same
// component. A custom bind has an action to describe (the compositor reports
// every Lua bind as "__lua" plus a bytecode offset, so the description has to
// come from the entry), it is removable, and it is never "overridden" -- a
// rebind rewrites the entry in place rather than adding to the override map.
//
// The row reports and never decides: conflicts, the write and the reload all
// belong to Keybinds, and the page owns the one capture at a time.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
required property var entry
property bool capturing: false
// Shown inside the capture field -- the page puts a refused chord here.
property string message: ""
signal rebindRequested
signal removeRequested
signal captured(string chord)
signal canceled
readonly property string chord: String(root.entry?.chord ?? "")
readonly property string action: Keybinds.describeAction(root.entry)
// A stored entry is an intention; the keymap is the fact. hypr/keybinds.lua
// skips an entry whose action does not resolve or whose chord a shipped
// bind already holds, and a shortcut that quietly does nothing is exactly
// what a settings page must not draw as working.
readonly property bool live: root.action !== "" && Keybinds.customBindApplied(root.entry)
label: String(root.entry?.label ?? "")
detail: root.action === ""
? "This shortcut names an action Panama no longer has — remove it"
: (root.live
? root.action
: root.action + " · not answering yet — the compositor reloads on save")
labelColor: root.action === "" ? Theme.danger : Theme.fg
controlWidth: root.capturing ? 250 : (removeConfirm.armed ? 330 : 250)
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: !root.capturing
spacing: 8
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Rebind"
enabled: !Keybinds.reloading
onClicked: root.rebindRequested()
}
ConfirmAction {
id: removeConfirm
anchors.verticalCenter: parent.verticalCenter
actionId: "custom-bind-remove:" + root.chord
armText: "Remove…"
confirmText: "Remove it"
enabled: !Keybinds.reloading
onConfirmed: root.removeRequested()
}
KeycapChord {
anchors.verticalCenter: parent.verticalCenter
chord: root.chord
}
}
Loader {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 240
height: 30
active: root.capturing
// Focus has to travel through the Loader for the capture inside it to
// ever see a key press.
focus: root.capturing
sourceComponent: ShortcutCapture {
focus: true
message: root.message
onCaptured: chord => root.captured(chord)
onCanceled: root.canceled()
}
}
}
@@ -426,51 +426,28 @@ SettingsPage {
}
}
// The one thing left that GNOME genuinely owns.
//
// This card used to be headed "Fedora system settings" and led with an
// umbrella button reading "Open GNOME Settings", which landed on the System
// panel. That button was the last door of its kind, and by the end it was
// pointing at a house Panama had bought: Users, Sharing, Printers, Online
// Accounts, Privacy, Region, Colour and the whole of Connections are pages
// here now. A generic front door to a settings app you no longer need is
// not a boundary, it is a habit -- so it is gone, and gnome-handoff-contract
// holds the door shut by naming `system` in its OWNED map.
//
// Screen time is the exception, and it is a real one: GNOME's wellbeing
// panel does something Panama does not, and that button genuinely works.
// Colour profiles used to sit here too, with a detail line explaining that
// pressing the button changed nothing, because the colord daemon that
// applies an ICC profile is not running under Hyprland. A handoff that
// documents its own uselessness is a dead button with an apology attached,
// so that one went first.
SettingsCard {
title: "Fedora system settings"
subtitle: "These areas remain owned by Fedora and GNOME's mature system panels."
title: "Digital wellbeing"
subtitle: "Screen time and break reminders are GNOME's, and this is the one panel of theirs that still does something Panama does not."
Item {
width: parent.width
implicitHeight: 42
Text {
anchors.left: parent.left
anchors.right: gnomeSettingsButton.left
anchors.rightMargin: 18
anchors.verticalCenter: parent.verticalCenter
text: "Use GNOME Settings for the parts of the system this app does not manage."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
// Lands on System rather than Network. This button used to open the
// Network panel as a generic front door, which stopped being true
// the moment Connections absorbed VPN, proxies, hotspot and
// enterprise Wi-Fi: sending someone to GNOME for a page Panama now
// owns is exactly what gnome-handoff-contract exists to catch.
SettingsButton {
id: gnomeSettingsButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Open GNOME Settings"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: SystemSettings.openGnomePanel("system")
Keys.onReturnPressed: SystemSettings.openGnomePanel("system")
Keys.onSpacePressed: SystemSettings.openGnomePanel("system")
}
}
// Color profiles used to have a row here whose own detail explained
// that pressing it changed nothing -- the colord daemon that applies an
// ICC profile is not running under Hyprland. A handoff that documents
// its own uselessness is not a boundary, it is a dead button with an
// apology attached, so it is gone. Digital wellbeing stays: GNOME
// genuinely owns screen time, and that button genuinely works.
ActionRow {
objectName: "health-fedora-handoff:wellbeing"
label: "Digital wellbeing"
@@ -11,10 +11,11 @@
// with no members, so the user would be toggling settings that can never affect
// anything with nothing to say so.
//
// The gestures that were a card of their own now sit at the bottom of the
// touchpad card. They are touchpad settings -- a three-finger swipe has nowhere
// else to happen -- and a card holding two sliders was a heading standing in
// for a section.
// Gestures are a card again. They were folded into the touchpad card when the
// only thing to say about them was how far a swipe travels and which way round
// it goes -- a heading standing in for a section. Four assignable four-finger
// directions is a subject, so the heading has content now, and the two feel
// knobs come back up with it: they are about gestures, not about the touchpad.
import QtQuick
import qs.config
@@ -126,10 +127,132 @@ SettingsPage {
}
}
// ── Gestures ────────────────────────────────────────────────────────────
// Three fingers are the desktop's and stay put: they are registered at
// config time, they reproduce GNOME's muscle memory, and a preference that
// needed a reload to turn them off would be worse than the nothing they
// cost. Four fingers are the user's, from the same named-action vocabulary
// custom shortcuts use -- an enum kind and a validated target, never a
// command. Assigning one is read at config time too, so it reloads.
readonly property var gestureKeys: [
{ key: "gestureFourUp", label: "Swipe up" },
{ key: "gestureFourDown", label: "Swipe down" },
{ key: "gestureFourLeft", label: "Swipe left" },
{ key: "gestureFourRight", label: "Swipe right" }
]
// A named action is two fields; OptionPickerRow picks one value. They are
// joined on the first colon, which `window` targets already use for
// "workspace:4", so the split has to take the first one only.
function gestureValue(key: string): string {
const stored = DesktopPreferences.get(key);
if (Keybinds.describeAction(stored) === "")
return "";
return String(stored.kind) + ":" + String(stored.target);
}
function assignGesture(key: string, value: string): void {
const at = String(value).indexOf(":");
if (at < 0) {
DesktopPreferences.set(key, ({}));
Keybinds.applyReload();
return;
}
const kind = String(value).slice(0, at);
const target = String(value).slice(at + 1);
const label = kind === "shell"
? Keybinds.shellActionLabel(target)
: Keybinds.windowActionLabel(target);
if (Keybinds.describeAction({ kind: kind, target: target }) === "" || label === "")
return;
DesktopPreferences.set(key, { kind: kind, target: target, label: label });
Keybinds.applyReload();
}
// Nothing, then the shell's own actions, then the compositor's window
// verbs -- the same lists the shortcut editor offers, read from Keybinds so
// this page never becomes a second opinion on what resolves.
//
// Applications are deliberately absent: assigning one means searching
// several hundred desktop entries, which a picker of this shape cannot do.
// A gesture that already holds an application (written by the shortcut
// vocabulary elsewhere) still shows, so it can be read and cleared.
function gestureOptions(key: string): var {
const out = [{ value: "", label: "Nothing", detail: "The swipe passes through to whatever is under it" }];
const stored = DesktopPreferences.get(key);
if (stored && stored.kind === "app" && Keybinds.describeAction(stored) !== "")
out.push({
value: "app:" + String(stored.target),
label: String(stored.label),
detail: "Application · launch-or-focus"
});
for (const action of Keybinds.shellActions)
out.push({ value: "shell:" + action.target, label: action.label, detail: "Shell action" });
for (const action of Keybinds.windowActions)
out.push({ value: "window:" + action.target, label: action.label, detail: "Window & workspace" });
return out;
}
SettingsCard {
visible: InputDevices.hasTouchpad
title: "Gestures"
subtitle: "Three fingers are Panama's — they drive workspaces and Mission Control everywhere and stay put. Four fingers are yours: give each direction a job from the same actions your shortcuts use, or leave it unassigned."
SectionLabel { text: "Three fingers"; count: "· shipped" }
TextRow {
label: "Swipe left or right"
detail: "Moves between workspaces, following your fingers"
value: "Switch workspace"
}
TextRow {
label: "Swipe up"
value: "Open Mission Control"
}
TextRow {
label: "Swipe down"
detail: "Open and close rather than one toggle, so a swipe never undoes itself mid-gesture"
value: "Close Mission Control"
divider: false
}
SectionLabel { text: "Four fingers"; count: "· yours" }
Repeater {
model: root.gestureKeys
OptionPickerRow {
id: gestureRow
required property var modelData
required property int index
label: String(gestureRow.modelData.label)
detail: gestureRow.index === root.gestureKeys.length - 1
? "Assigning one reloads the compositor — a beat of black, then it works"
: ""
options: root.gestureOptions(String(gestureRow.modelData.key))
current: root.gestureValue(String(gestureRow.modelData.key))
enabled: !Keybinds.reloading
divider: gestureRow.index < root.gestureKeys.length - 1
onPicked: value => root.assignGesture(String(gestureRow.modelData.key), String(value))
}
}
SectionLabel { text: "Feel" }
// How far a swipe has to travel and which way round it goes: the two
// knobs that are about gestures rather than about the touchpad, which
// is why they moved up out of the Touchpad card.
SliderRow { setting: "swipeDistance" }
ToggleRow { setting: "swipeInvert"; divider: false }
}
SettingsCard {
visible: InputDevices.hasTouchpad
title: "Touchpad"
subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions."
subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions. The two swipe knobs moved up into Gestures, which is what they are about."
ToggleRow { setting: "touchpadTapToClick" }
ToggleRow { setting: "touchpadClickfinger" }
@@ -138,13 +261,7 @@ SettingsPage {
ToggleRow { setting: "touchpadNaturalScroll" }
SliderRow { setting: "touchpadScrollFactor" }
ToggleRow { setting: "touchpadDisableWhileTyping" }
ToggleRow { setting: "touchpadMiddleButtonEmulation" }
// Which gestures exist is fixed by the compositor at startup -- three
// fingers sideways moves between workspaces, up opens the overview.
// What they feel like is here.
SliderRow { setting: "swipeDistance" }
ToggleRow { setting: "swipeInvert"; divider: false }
ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
}
SettingsCard {
@@ -22,6 +22,20 @@ Rectangle {
return SettingsRoutes.breadcrumb(page);
}
// A result that names a section opens the page ON that section rather than
// on whichever tab the page opens by default -- finding "Theme editor" and
// landing on the Themes tab is the search half-working. Results without one
// go through pageRequested exactly as before, which is every result the
// schema produces.
function openResult(result: var): void {
const section = String(result.section ?? "");
if (section === "") {
root.pageRequested(String(result.page));
return;
}
ShellState.openSettingsSection(String(result.page), section);
}
// One row per category. SettingsRoutes owns the taxonomy; a category with
// tabs is opened at its first available tab by ShellState's resolution.
readonly property var destinations: SettingsRoutes.categories
@@ -186,7 +200,7 @@ Rectangle {
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.pageRequested(hit.modelData.page);
root.openResult(hit.modelData);
searchInput.text = "";
}
}
@@ -106,13 +106,46 @@ SettingsPage {
readonly property bool filtering: root.filter.trim() !== ""
readonly property int overrideCount: Object.keys(Keybinds.overrides).length
// ── The shortcuts you invented ──────────────────────────────────────────
// Rendered from the stored `customBinds` array rather than from the
// compositor's report, for two reasons: the stored entry is the only place
// the ACTION is written down (Hyprland reports every Lua bind as "__lua"
// plus a bytecode offset), and a shortcut has to appear the moment it is
// added rather than after the reload settles. The row asks Keybinds whether
// the compositor is actually answering it, so nothing here claims a bind
// that did not take.
property bool adding: false
// The chord of the custom bind being re-recorded, empty when none is.
property string rebindingChord: ""
readonly property var customBinds: {
const needle = root.filter.trim().toLowerCase();
if (needle === "")
return Keybinds.customBinds;
return Keybinds.customBinds.filter(entry =>
String(entry?.label ?? "").toLowerCase().indexOf(needle) >= 0
|| "custom".indexOf(needle) >= 0);
}
function customMessage(): string {
return root.conflict === ""
? ""
: root.conflictChord + " is already " + root.conflict;
}
// Every group the compositor reports, in Keybinds' own order, narrowed by
// the filter. An empty filter narrows nothing: the page's job is to show
// the whole keymap, and searching is an extra rather than a gate.
//
// "Custom" is dropped here: those binds are drawn above from the stored
// entries, and showing them twice would read as two shortcuts on one chord.
readonly property var groups: {
const needle = root.filter.trim().toLowerCase();
const out = [];
for (const group of Keybinds.grouped()) {
if (group.name === "Custom")
continue;
const hits = needle === ""
? group.binds
: group.binds.filter(bind =>
@@ -264,7 +297,7 @@ SettingsPage {
SettingsCard {
title: "Shortcuts"
subtitle: "Click Change and press the new keys. A shortcut another action holds is refused, never stolen."
subtitle: "Click Change and press the new keys. A shortcut another action holds is refused, never stolen. Your own shortcuts live in the Custom group — none of them stores a command: each names an application, a shell action, or a window move, and Panama resolves the name when you press it."
Item {
width: parent.width
@@ -283,14 +316,107 @@ SettingsPage {
Text {
id: counts
anchors.right: parent.right
anchors.right: addButton.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: Keybinds.binds.length + " bound · " + root.overrideCount + " changed"
text: Keybinds.binds.length + " bound · " + root.overrideCount + " changed · "
+ Keybinds.customBinds.length + " custom"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
SettingsButton {
id: addButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
tone: "accent"
text: " Add shortcut"
enabled: !root.adding && !Keybinds.reloading
onClicked: {
root.capturingChord = "";
root.rebindingChord = "";
root.conflict = "";
root.conflictChord = "";
addEditor.reset();
root.adding = true;
}
}
}
CustomShortcutEditor {
id: addEditor
width: parent.width
visible: root.adding
onCommitted: (chord, kind, target, label) => {
if (Keybinds.addCustomBind(chord, kind, target, label))
root.adding = false;
}
onCanceled: root.adding = false
}
// The Custom group, pinned above everything the compositor reports.
Column {
width: parent.width
visible: root.customBinds.length > 0
spacing: 0
SectionLabel {
text: "Custom — yours"
count: root.filtering
? "· showing " + root.customBinds.length + " of " + Keybinds.customBinds.length
: "· " + Keybinds.customBinds.length
}
Repeater {
id: customRows
model: root.customBinds
CustomShortcutRow {
id: customRow
required property var modelData
required property int index
entry: customRow.modelData
capturing: root.rebindingChord === String(customRow.modelData.chord ?? "")
message: customRow.capturing ? root.customMessage() : ""
divider: customRow.index < customRows.count - 1
onRebindRequested: {
root.conflict = "";
root.conflictChord = "";
root.capturingChord = "";
root.rebindingChord = String(customRow.modelData.chord ?? "");
}
onRemoveRequested: Keybinds.removeCustomBind(String(customRow.modelData.chord ?? ""))
onCaptured: chord => {
const current = String(customRow.modelData.chord ?? "");
const taken = Keybinds.boundTo(chord, current);
if (taken !== "") {
root.conflict = taken;
root.conflictChord = chord;
return;
}
root.conflict = "";
Keybinds.rebindCustomBind(current, chord);
root.rebindingChord = "";
}
onCanceled: {
root.conflict = "";
root.rebindingChord = "";
}
}
}
}
// One Column of Repeaters rather than a Loader per row: at a hundred and
@@ -363,6 +489,7 @@ SettingsPage {
onChangeRequested: {
root.conflict = "";
root.conflictChord = "";
root.rebindingChord = "";
root.capturingChord = shortcutRow.modelData.luaChord;
}
@@ -395,7 +522,7 @@ SettingsPage {
Text {
width: parent.width
visible: root.groups.length === 0 && Keybinds.loaded
visible: root.groups.length === 0 && root.customBinds.length === 0 && Keybinds.loaded
text: root.filtering
? "Nothing matches — the filter searches shortcut names and group names."
: "The compositor reported no shortcuts."
@@ -50,6 +50,19 @@ SettingsPage {
readonly property var heldKeys: SshKeys.keys.filter(key => key.loaded === true)
// Known hosts, folded at the house cap. A machine that has been used for a
// year has dozens of these, and an unbounded list turns the card below the
// keys into most of the page -- the same reason the Wi-Fi list folds.
property bool showAllHosts: false
readonly property int hostCap: 6
readonly property var shownHosts: {
const list = SshKeys.hosts;
if (root.showAllHosts || list.length <= root.hostCap)
return list;
return list.slice(0, root.hostCap);
}
readonly property int hiddenHostCount: SshKeys.hosts.length - root.shownHosts.length
function resetForm(): void {
root.showingGenerator = false;
root.newName = "";
@@ -395,7 +408,7 @@ SettingsPage {
: "Machines this one has connected to. Forget an entry when a server legitimately changed — ssh-keygen keeps a .old copy."
Repeater {
model: SshKeys.hosts
model: root.shownHosts
delegate: SettingRow {
id: hostRow
@@ -445,6 +458,19 @@ SettingsPage {
}
}
SettingRow {
visible: root.hiddenHostCount > 0
|| (root.showAllHosts && SshKeys.hosts.length > root.hostCap)
label: root.showAllHosts
? "Show fewer hosts"
: root.hiddenHostCount + (root.hiddenHostCount === 1 ? " more host" : " more hosts")
detail: root.showAllHosts
? ""
: "Folded to keep the list short — every one of them is still in known_hosts"
activatable: true
onActivated: root.showAllHosts = !root.showAllHosts
}
ActionRow {
label: "Check again"
detail: "Re-reads ~/.ssh and asks the agent what it is holding"
@@ -16,6 +16,109 @@ SettingsPage {
title: "Tiling"
lede: "How windows share the space, and where their edges are."
// The rule being edited, or null while the editor is closed. Held here
// rather than in the editor so that opening one row's Edit closes another's.
property var editingRule: null
property bool addingRule: false
readonly property bool ruleEditorOpen: root.addingRule || root.editingRule !== null
function closeRuleEditor(): void {
root.addingRule = false;
root.editingRule = null;
}
// Per-application behavior, above the general tiling settings: a rule for
// one application is what somebody came here to write, and the gaps and
// borders below are set once and left alone.
SettingsCard {
title: "App rules"
subtitle: "How specific applications behave when they open. Behaviors, not regexes: pick an application, tick what it should do. Panama's own surfaces cannot be matched."
Repeater {
id: ruleRows
model: WindowRules.rules
WindowRuleRow {
id: ruleRow
required property var modelData
required property int index
rule: ruleRow.modelData
divider: ruleRow.index < ruleRows.count - 1
onEditRequested: {
root.addingRule = false;
root.editingRule = ruleRow.modelData;
ruleEditor.load(ruleRow.modelData);
}
onRemoveRequested: {
if (root.editingRule === ruleRow.modelData)
root.closeRuleEditor();
WindowRules.removeRule(String(ruleRow.modelData.class ?? ""));
}
}
}
Text {
width: parent.width
visible: WindowRules.rules.length === 0 && !root.ruleEditorOpen
text: "No rules yet. Every application opens the way the compositor decides."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
topPadding: 10
bottomPadding: 10
}
WindowRuleEditor {
id: ruleEditor
width: parent.width
visible: root.ruleEditorOpen
onCommitted: rule => {
const saved = root.editingRule === null
? WindowRules.addRule(rule)
: WindowRules.updateRule(String(root.editingRule.class ?? ""), rule);
if (saved)
root.closeRuleEditor();
}
onCanceled: root.closeRuleEditor()
}
SettingRow {
visible: !root.ruleEditorOpen
label: "Add a rule"
detail: WindowRules.rules.length === 0
? "Applies on save, with a compositor reload"
: (WindowRules.applied
? "Applied on the last reload — Hyprland publishes no rule listing, so this is the reload's word, not a read-back of the rules themselves"
: "Saved, waiting on the reload that puts it in effect")
controlWidth: 130
divider: false
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
tone: "accent"
text: " Add a rule"
enabled: !WindowRules.reloading
onClicked: {
root.editingRule = null;
ruleEditor.reset();
root.addingRule = true;
}
}
}
ErrorRow { message: WindowRules.lastError }
}
SettingsCard {
title: "Window layout"
subtitle: "Follows the Forge mental model, with native Hyprland tiling."
@@ -45,6 +45,24 @@ Column {
property string failedSsid: ""
property string failedText: ""
// The hidden-network form. Its passphrase lives here only while the form is
// open: the form is a Loader, so closing it destroys the field, and
// closeHidden empties this alongside it.
property bool hiddenOpen: false
property string hiddenSsid: ""
property string hiddenSecurity: "wpa-psk"
property string hiddenPassword: ""
readonly property bool hiddenReady: root.hiddenSsid.trim() !== ""
&& (root.hiddenSecurity === "none" || root.hiddenPassword !== "")
function closeHidden(): void {
root.hiddenOpen = false;
root.hiddenSsid = "";
root.hiddenSecurity = "wpa-psk";
root.hiddenPassword = "";
}
// The list shows what matters and folds the rest: connected and saved
// networks always render (they lead the sort, so a slice keeps them), and
// strangers fill the remaining slots up to the cap. Everything else waits
@@ -72,6 +90,10 @@ Column {
root.confirmingForget = "";
}
// closeAll is called from row activation, which the hidden form is not part
// of -- opening a network's drawer should not throw away a half-typed
// hidden SSID, but joining one should close the form.
// One click on a row means whatever that row's state makes it mean. The
// connected network opens rather than reconnecting to itself.
function activate(network: var): void {
@@ -202,6 +224,7 @@ Column {
ConnectionDetails {
width: parent.width
connection: entry.ssid
details: entry.details
}
@@ -426,4 +449,116 @@ Column {
: ""
divider: false
}
// ── A network that does not say it is there ─────────────────────────────
//
// A hidden network cannot appear in the list above by definition, so the
// only way in is to name it. This was the last thing on the Wi-Fi card that
// sent people back to GNOME's panel.
ActionRow {
width: parent.width
visible: Connectivity.wifiDevice !== null && Connectivity.wifiEnabled
label: "Join a hidden network…"
detail: "A network that does not broadcast its name — you type the name and its security"
action: root.hiddenOpen ? "Cancel" : "Join…"
enabled: !NetworkTools.busy
divider: root.hiddenOpen
onTriggered: {
if (root.hiddenOpen) {
root.closeHidden();
return;
}
root.closeAll();
root.hiddenOpen = true;
}
}
// Loaded rather than hidden, for the same reason the enterprise form is:
// closing it destroys the field, and with it the passphrase that was typed.
Loader {
id: hiddenLoader
width: parent.width
active: root.hiddenOpen
visible: hiddenLoader.active
sourceComponent: Column {
width: hiddenLoader.width
TextFieldRow {
width: parent.width
label: "Network name"
detail: "Exactly as whoever runs the network wrote it — a hidden network is found by name, so a typo simply never connects"
placeholder: "office-private"
text: root.hiddenSsid
enabled: !NetworkTools.busy
onAccepted: value => root.hiddenSsid = value.trim()
}
OptionPickerRow {
width: parent.width
label: "Security"
detail: "What the network expects. The wrong one associates and then fails, with nothing to say why."
enabled: !NetworkTools.busy
options: [
{
value: "wpa-psk",
label: "WPA2 (password)",
detail: "What almost every home and office network uses"
},
{
value: "sae",
label: "WPA3 (password)",
detail: "Newer, and refused outright by anything older"
},
{
value: "none",
label: "Open",
detail: "No password at all"
}
]
current: root.hiddenSecurity
onPicked: value => {
root.hiddenSecurity = String(value);
// An open network has no passphrase, so a passphrase typed
// before the mode changed must not sit in memory waiting to
// be sent to a network that will not ask for one.
if (root.hiddenSecurity === "none")
root.hiddenPassword = "";
}
}
SecretFieldRow {
width: parent.width
visible: root.hiddenSecurity !== "none"
label: "Password"
detail: "Handed to NetworkManager down a pipe, never as a command argument"
enabled: !NetworkTools.busy
onChanged: value => root.hiddenPassword = value
}
ActionRow {
width: parent.width
label: "Join this network"
detail: root.hiddenSsid.trim() === ""
? "Give the network a name first."
: (root.hiddenSecurity !== "none" && root.hiddenPassword === ""
? "This network needs a password."
: "Saves a profile that probes for " + root.hiddenSsid.trim()
+ " by name, and connects to it.")
action: NetworkTools.busy ? "Joining…" : "Join"
enabled: !NetworkTools.busy && root.hiddenReady
divider: false
onTriggered: {
if (!root.hiddenReady)
return;
const ssid = root.hiddenSsid.trim();
NetworkTools.joinHidden(ssid, ssid, root.hiddenSecurity,
root.hiddenPassword);
root.closeHidden();
}
}
}
}
}
@@ -0,0 +1,348 @@
// Writing a window rule as behaviors instead of as a regex.
//
// Two halves, in the order somebody actually thinks in: which application, then
// what it should do. The application half offers the windows open right now
// first -- that is the list where the class is a fact rather than a guess,
// because Hyprland is reporting it -- and falls back to a search over installed
// applications, whose class comes from their own StartupWMClass.
//
// The behavior half is ticks. Nothing here composes a rule string: the draft is
// booleans and two bounded numbers, WindowRules validates it, and hypr/rules.lua
// escapes the class before the compositor's matcher sees it.
//
// Panama's own surfaces are not offerable and not typeable: a rule that floated
// a Quickshell layer would break the desktop from inside Settings.
import QtQuick
import Quickshell
import Quickshell.Hyprland
import qs.config
import qs.services
import qs.modules.clipboard
Column {
id: root
// Set to an existing rule to edit it; null to add a new one.
property var editing: null
signal committed(var rule)
signal canceled
property string windowClass: ""
property string appLabel: ""
property bool floats: false
property bool center: false
property bool noAnim: false
property bool game: false
property bool noDim: false
property bool pin: false
property bool sizeOn: false
property int sizeWidth: 900
property int sizeHeight: 600
property bool workspaceOn: false
property int workspace: 1
readonly property bool chosen: root.windowClass !== ""
function load(rule: var): void {
root.editing = rule ?? null;
root.windowClass = String(rule?.class ?? "");
root.appLabel = String(rule?.label ?? rule?.class ?? "");
root.floats = rule?.float === true;
root.center = rule?.center === true;
root.noAnim = rule?.noAnim === true;
root.game = rule?.game === true;
root.noDim = rule?.noDim === true;
root.pin = rule?.pin === true;
root.sizeOn = Array.isArray(rule?.size) && rule.size.length === 2;
root.sizeWidth = root.sizeOn ? rule.size[0] : 900;
root.sizeHeight = root.sizeOn ? rule.size[1] : 600;
root.workspaceOn = Number.isFinite(rule?.workspace);
root.workspace = root.workspaceOn ? rule.workspace : 1;
appSearch.text = "";
}
function reset(): void {
root.load(null);
}
function draft(): var {
return {
"class": root.windowClass,
label: root.appLabel === "" ? root.windowClass : root.appLabel,
float: root.floats,
center: root.center,
size: root.sizeOn ? [root.sizeWidth, root.sizeHeight] : null,
workspace: root.workspaceOn ? root.workspace : null,
noAnim: root.noAnim,
game: root.game,
noDim: root.noDim,
pin: root.pin
};
}
readonly property bool complete: root.chosen
&& WindowRules.validRule(root.draft())
&& WindowRules.hasBehavior(root.draft())
width: parent ? parent.width : 620
spacing: 0
SectionLabel {
text: root.editing ? "Edit rule" : "New rule"
count: root.chosen ? root.windowClass : "pick an application"
}
// ── Which application ───────────────────────────────────────────────────
// Hidden while editing: the class is the rule's identity, and letting it be
// changed in place would be a second rule wearing the first one's history.
Column {
width: parent.width
visible: root.editing === null
spacing: 0
ChoiceGrid {
width: parent.width
visible: root.openWindows.length > 0
label: "Open right now"
detail: "The class comes from the compositor, so these are exact."
current: root.windowClass
options: root.openWindows
divider: false
onPicked: value => {
root.windowClass = String(value);
const found = root.openWindows.find(option => option.value === root.windowClass);
root.appLabel = found ? String(found.label) : root.windowClass;
}
}
SearchField {
id: appSearch
width: parent.width
placeholder: "…or search installed applications"
}
Repeater {
model: root.appMatches
SettingRow {
id: candidate
required property var modelData
required property int index
label: String(candidate.modelData.label ?? "")
detail: String(candidate.modelData.value ?? "")
value: candidate.modelData.value === root.windowClass ? "Chosen" : ""
controlWidth: 96
divider: candidate.index < root.appMatches.length - 1
activatable: true
onActivated: {
root.windowClass = String(candidate.modelData.value ?? "");
root.appLabel = String(candidate.modelData.label ?? "");
}
}
}
Text {
width: parent.width
visible: root.appMatches.length === 0 && appSearch.text.trim() !== ""
text: "Nothing installed matches that."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
topPadding: 10
bottomPadding: 10
}
}
// ── What it should do ───────────────────────────────────────────────────
Column {
width: parent.width
visible: root.chosen
spacing: 0
SectionLabel {
text: "Behavior"
count: root.appLabel
}
Flow {
width: parent.width
spacing: 7
bottomPadding: 12
SettingsChip {
text: "Float"
active: root.floats
onClicked: root.floats = !root.floats
}
SettingsChip {
text: "Center when opened"
active: root.center
onClicked: root.center = !root.center
}
SettingsChip {
text: "Fixed size"
active: root.sizeOn
onClicked: root.sizeOn = !root.sizeOn
}
SettingsChip {
text: "Open on a workspace"
active: root.workspaceOn
onClicked: root.workspaceOn = !root.workspaceOn
}
SettingsChip {
text: "No animations"
active: root.noAnim
onClicked: root.noAnim = !root.noAnim
}
SettingsChip {
text: "Treat as a game"
active: root.game
onClicked: root.game = !root.game
}
SettingsChip {
text: "Never dim"
active: root.noDim
onClicked: root.noDim = !root.noDim
}
SettingsChip {
text: "Pin on every workspace"
active: root.pin
onClicked: root.pin = !root.pin
}
}
TextFieldRow {
visible: root.sizeOn
label: "Width"
detail: "Pixels, between " + WindowRules.minSize + " and " + WindowRules.maxSize
text: String(root.sizeWidth)
onAccepted: value => {
const parsed = parseInt(value, 10);
if (Number.isFinite(parsed))
root.sizeWidth = parsed;
}
}
TextFieldRow {
visible: root.sizeOn
label: "Height"
text: String(root.sizeHeight)
onAccepted: value => {
const parsed = parseInt(value, 10);
if (Number.isFinite(parsed))
root.sizeHeight = parsed;
}
}
ChoiceGrid {
width: parent.width
visible: root.workspaceOn
label: "Workspace"
detail: "The ten the keymap reaches."
current: root.workspace
options: root.workspaceOptions
divider: false
onPicked: value => root.workspace = Number(value)
}
Item {
width: parent.width
height: 50
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
tone: "accent"
text: root.editing ? "Save the rule" : "Add the rule"
enabled: root.complete && !WindowRules.reloading
onClicked: root.committed(root.draft())
}
SettingsButton {
text: "Cancel"
onClicked: root.canceled()
}
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 240
horizontalAlignment: Text.AlignRight
text: root.complete
? WindowRules.ruleLineFor(root.draft())
: "Tick at least one behavior — a rule that does nothing is a row you will wonder about later."
color: Theme.fgMuted
font.family: root.complete ? Theme.fontMono : Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
// ── The candidate lists ─────────────────────────────────────────────────
readonly property var workspaceOptions: {
const out = [];
for (let n = 1; n <= WindowRules.maxWorkspace; n++)
out.push({ value: n, label: String(n) });
return out;
}
// Windows open now, one entry per class. `wayland.appId` is the class
// Hyprland matches rules against; there is no separate class property.
readonly property var openWindows: {
const seen = {};
const out = [];
for (const toplevel of (Hyprland.toplevels?.values ?? [])) {
const appId = String(toplevel?.wayland?.appId ?? "");
if (appId === "" || seen[appId])
continue;
if (!WindowRules.validClass(appId))
continue;
if (WindowRules.indexOfClass(appId) >= 0)
continue;
seen[appId] = true;
const entry = DesktopEntries.heuristicLookup(appId);
out.push({ value: appId, label: String(entry?.name ?? appId) });
}
return out;
}
readonly property var appMatches: {
const needle = appSearch.text.trim().toLowerCase();
if (needle === "")
return [];
const out = [];
for (const entry of DesktopEntries.applications.values) {
if (entry.noDisplay)
continue;
if (String(entry.name).toLowerCase().indexOf(needle) < 0)
continue;
const windowClass = String(entry.startupClass ?? "") !== ""
? String(entry.startupClass)
: String(entry.id ?? "").replace(/\.desktop$/, "");
if (!WindowRules.validClass(windowClass))
continue;
if (WindowRules.indexOfClass(windowClass) >= 0)
continue;
out.push({ value: windowClass, label: String(entry.name) });
if (out.length >= 8)
break;
}
return out;
}
}
@@ -0,0 +1,116 @@
// One per-application window rule.
//
// Three lines rather than SettingRow's two, and that third line is the point:
// the rule was chosen as behaviors -- float, center, workspace 4 -- and it is
// written to the compositor as a rule. Showing only the friendly sentence makes
// the card a black box; showing only the rule makes it a config file with a
// nicer font. Both, with the compositor line in mono underneath, is how
// somebody learns what the ticks actually did.
//
// The rule text and the sentence both come from WindowRules, which is the one
// place that knows what hypr/rules.lua emits.
import QtQuick
import qs.config
import qs.services
Item {
id: root
required property var rule
property bool divider: true
signal editRequested
signal removeRequested
readonly property string windowClass: String(root.rule?.class ?? "")
readonly property int openNow: WindowRules.matchesOpen(root.windowClass)
width: parent ? parent.width : 620
implicitHeight: Math.max(64, copy.implicitHeight + 22)
Column {
id: copy
anchors.left: parent.left
anchors.right: trailing.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: String(root.rule?.label ?? root.windowClass)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: WindowRules.summaryFor(root.rule)
+ (root.openNow === 0
? ""
: (root.openNow === 1
? " · 1 window open now"
: " · " + root.openNow + " windows open now"))
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Text {
width: parent.width
text: WindowRules.ruleLineFor(root.rule)
color: Theme.fgMuted
font.family: Theme.fontMono
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
elide: Text.ElideRight
}
}
Item {
id: trailing
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: removeConfirm.armed ? 250 : 170
height: 32
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Edit"
enabled: !WindowRules.reloading
onClicked: root.editRequested()
}
ConfirmAction {
id: removeConfirm
anchors.verticalCenter: parent.verticalCenter
actionId: "window-rule-remove:" + root.windowClass
armText: "Remove…"
confirmText: "Remove it"
enabled: !WindowRules.reloading
onConfirmed: root.removeRequested()
}
}
}
Rectangle {
anchors.left: copy.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
visible: root.divider
color: Theme.alpha(Theme.fg, 0.065)
}
}
@@ -139,3 +139,7 @@ IdleTimeline 1.0 IdleTimeline.qml
PowerProfileTiles 1.0 PowerProfileTiles.qml
FieldActionRow 1.0 FieldActionRow.qml
ManualChapters 1.0 ManualChapters.qml
CustomShortcutRow 1.0 CustomShortcutRow.qml
CustomShortcutEditor 1.0 CustomShortcutEditor.qml
WindowRuleRow 1.0 WindowRuleRow.qml
WindowRuleEditor 1.0 WindowRuleEditor.qml