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
@@ -1059,6 +1059,23 @@ Singleton {
detail: "How much darker unfocused windows are",
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
},
{
key: "colorFilter", type: "enum", def: "none", group: "accessibility",
label: "Color filter",
detail: "A whole-screen filter rendered by the compositor — grayscale, or a correction for one kind of color blindness. Costs nothing when off.",
// No hypr mapping, deliberately: hyprctl stores decoration:screen_shader
// as a shader *path*, not this enum, so a hypr: block would fail the
// shape and sweep contracts on read-back. hypr/looks.lua maps the enum
// to a shipped shader for reloads; SystemSettings.applyColorFilter does
// the same mapping live.
options: [
{ value: "none", label: "None" },
{ value: "grayscale", label: "Grayscale" },
{ value: "protanopia", label: "Protanopia" },
{ value: "deuteranopia", label: "Deuteranopia" },
{ value: "tritanopia", label: "Tritanopia" }
]
},
{
key: "visualAlerts", type: "bool", def: false, group: "accessibility",
label: "Flash the screen for notifications",
@@ -1757,6 +1774,63 @@ Singleton {
detail: "Shortcuts you have moved from their shipped chord"
},
// ── Custom shortcuts ────────────────────────────────────────────────
// [ { chord, kind, target, label } ]. Data, never code: `kind` is one of
// app | shell | window, `target` is a validated id resolved through the
// whitelist tables in hypr/actions.lua, and an entry that fails any check
// is silently not emitted. This is what keeps a user-editable file from
// being executable even though it now describes shortcuts the user
// invented. Edited through the Keyboard page, hence internal.
{
key: "customBinds", type: "json", def: [], group: "input",
internal: true,
label: "Custom shortcuts",
detail: "Shortcuts you invented: each one launches an application, triggers a shell action, or moves a window"
},
// ── Per-application window rules ────────────────────────────────────
// [ { class, label, float, center, size, workspace, noAnim, game,
// noDim, pin } ]. `class` is matched literally (hypr/rules.lua escapes
// it before Hyprland's RE2 sees it); `size` is [w, h] or null;
// `workspace` is 1..10 or null; everything else is a boolean. Rules
// matching the shell's own surfaces are refused at both ends. Edited
// through the Windows page, hence internal.
{
key: "windowRules", type: "json", def: [], group: "multitasking",
internal: true,
label: "Application window rules",
detail: "How specific applications behave when they open: floating, size, workspace, animations"
},
// ── Four-finger gestures ────────────────────────────────────────────
// Each holds {} (unassigned) or a named action { kind, target, label },
// the same shape customBinds stores and the same whitelists resolve.
// Registered at compositor config time, so assigning one reloads.
{
key: "gestureFourUp", type: "json", def: ({}), group: "touchpad",
internal: true,
label: "Four-finger swipe up",
detail: "What a four-finger upward swipe does"
},
{
key: "gestureFourDown", type: "json", def: ({}), group: "touchpad",
internal: true,
label: "Four-finger swipe down",
detail: "What a four-finger downward swipe does"
},
{
key: "gestureFourLeft", type: "json", def: ({}), group: "touchpad",
internal: true,
label: "Four-finger swipe left",
detail: "What a four-finger leftward swipe does"
},
{
key: "gestureFourRight", type: "json", def: ({}), group: "touchpad",
internal: true,
label: "Four-finger swipe right",
detail: "What a four-finger rightward swipe does"
},
// ── Display configuration ───────────────────────────────────────────
// { "<output>": { mode, scale, transform, x, y, primary, vrrMode,
// colorProfile, bitdepth, sdrBrightness, sdrSaturation, mirrorOf } },
@@ -170,6 +170,22 @@ Item {
onToggled: Caffeine.toggle()
}
// Do Not Disturb on its own, beside Presentation. The service, the
// IPC verb and the settings row all existed; only the tile was
// missing, so the one-press way to silence banners was a shortcut
// you had to already know. Presentation keeps its combined role --
// this is the half of it people want without the awake half.
Toggle {
width: root.cellWidth
icon: Notifs.doNotDisturb
? "notifications-disabled-symbolic"
: "preferences-system-notifications-symbolic"
label: "Do Not Disturb"
sublabel: Notifs.doNotDisturb ? "Banners held" : "Off"
active: Notifs.doNotDisturb
onToggled: Notifs.doNotDisturb = !Notifs.doNotDisturb
}
// Caffeine plus Do Not Disturb as one switch, for the projector:
// the half you forget to arm is the one that fires a message
// preview onto the big screen. Restores both exactly as found.
@@ -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
+337 -3
View File
@@ -13,11 +13,16 @@ native service stays native.
panama-network details CONNECTION
panama-network forget CONNECTION
panama-network saved
panama-network set-autoconnect CONNECTION true|false
panama-network set-mac-random CONNECTION true|false
panama-network set-metered CONNECTION yes|no|auto
panama-network set-ip CONNECTION 4|6 auto
panama-network set-ip CONNECTION 4|6 manual ADDR/PREFIX GATEWAY DNS[,DNS...]
panama-network import-vpn FILE
panama-network hotspot start SSID | hotspot stop | hotspot status
panama-network join-enterprise SSID PROFILE IDENTITY [CA_CERT] (password on stdin)
panama-network join-hidden SSID PROFILE wpa-psk|sae|none (password on stdin)
panama-network proxy get
panama-network proxy set none
panama-network proxy set manual [HOST PORT]
@@ -87,6 +92,48 @@ SSID = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9 _.:+()@'&#!-]{0,31}$")
HOSTNAME = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]{0,253}[A-Za-z0-9])?$")
PAC_URL = re.compile(r"^(https?|file)://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]{1,500}$")
# Static addressing. Written out per octet rather than as \d{1,3}, because
# 999.999.999.999 is four groups of three digits and is not an address -- and a
# static address that NetworkManager refuses is a connection that comes up with
# no address at all, which is a worse failure than being told to retype it.
IPV4 = re.compile(r"(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}")
# IPv6 in every form a person types one: full, compressed at either end, and
# "::" alone. One alternative per position the elision can take, which is long
# but is the only shape that accepts fd00::42 and refuses fd00:::42.
IPV6 = re.compile(
r"([0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}"
r"|([0-9A-Fa-f]{1,4}:){1,7}:"
r"|([0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}"
r"|([0-9A-Fa-f]{1,4}:){1,5}(:[0-9A-Fa-f]{1,4}){1,2}"
r"|([0-9A-Fa-f]{1,4}:){1,4}(:[0-9A-Fa-f]{1,4}){1,3}"
r"|([0-9A-Fa-f]{1,4}:){1,3}(:[0-9A-Fa-f]{1,4}){1,4}"
r"|([0-9A-Fa-f]{1,4}:){1,2}(:[0-9A-Fa-f]{1,4}){1,5}"
r"|[0-9A-Fa-f]{1,4}:(:[0-9A-Fa-f]{1,4}){1,6}"
r"|:((:[0-9A-Fa-f]{1,4}){1,7}|:)"
)
# The prefix lengths, which are different numbers on the two stacks: /24 means
# something on both, /64 only on one.
IPV4_PREFIX = re.compile(r"3[0-2]|[12]?[0-9]")
IPV6_PREFIX = re.compile(r"12[0-8]|1[01][0-9]|[1-9]?[0-9]")
# How many nameservers a person may list. Not a NetworkManager limit -- a
# resolver stops trying long before this, and a field with twenty addresses in
# it is a typo rather than a configuration.
MAX_DNS = 6
# What a connection's metered flag is called, in each direction. NetworkManager
# spells "decide for yourself" as unknown; a settings page says "automatic",
# and the two must never be confused with "no", which is a claim.
METERED_NAMES = {"yes": "yes", "no": "no", "unknown": "auto", "": "auto"}
METERED_VALUES = {"yes": "yes", "no": "no", "auto": "unknown"}
# Key management for a hidden network, by the name the page offers. A closed
# set: an arbitrary key-mgmt string produces a profile that never associates,
# with nothing to say why.
WIFI_SECURITY = {"wpa-psk": "wpa-psk", "sae": "sae", "none": ""}
# The EAP profiles offered. A closed set, because "type your own EAP string"
# produces a profile that fails to authenticate with no way to tell why.
EAP_PROFILES = {
@@ -199,6 +246,69 @@ def require_connection(value: str) -> str:
return require(NAME, value, "That is not a connection name.")
def require_family(value: str) -> str:
if value not in ("4", "6"):
raise BoundaryError("An address is either IPv4 or IPv6.")
return value
def address_pattern(family: str) -> re.Pattern:
return IPV4 if family == "4" else IPV6
def require_cidr(family: str, value: str) -> str:
"""An address with its prefix, which NetworkManager will not take without.
Split from the right, because an IPv6 address is mostly colons and one
slash: rpartition finds the prefix wherever the address ends.
"""
address, separator, prefix = (value or "").rpartition("/")
if not separator:
raise BoundaryError("A static address needs a prefix, like 192.168.1.50/24."
if family == "4"
else "A static address needs a prefix, like fd00::42/64.")
require(address_pattern(family), address,
"That is not an IPv4 address." if family == "4" else "That is not an IPv6 address.")
require(IPV4_PREFIX if family == "4" else IPV6_PREFIX, prefix,
"An IPv4 prefix is a number from 0 to 32." if family == "4"
else "An IPv6 prefix is a number from 0 to 128.")
return f"{address}/{prefix}"
def require_gateway(family: str, value: str) -> str:
"""A gateway, or nothing. Empty is a real answer: a network segment with no
router is not a broken configuration, it is a network with no way out."""
text = (value or "").strip()
if text == "":
return ""
return require(address_pattern(family), text, "That is not a gateway address.")
def require_dns(family: str, value: str) -> list[str]:
"""The nameserver list, comma-separated as it is typed and as nmcli takes it.
Validated per stack rather than per address: NetworkManager stores ipv6.dns
as IPv6 addresses, and an IPv4 nameserver typed into the IPv6 box is a
profile it refuses to save with an error nobody can act on.
"""
servers = [part.strip() for part in (value or "").split(",") if part.strip()]
if len(servers) > MAX_DNS:
raise BoundaryError(f"That is more than {MAX_DNS} nameservers.")
for server in servers:
require(address_pattern(family), server, f"{server} is not a nameserver address.")
return servers
def plain(value: str) -> str:
"""nmcli's way of saying a property is unset, in either of its spellings."""
text = (value or "").strip()
return "" if text in ("--", "(none)") else text
def comma_list(value: str) -> list[str]:
return [part.strip() for part in plain(value).split(",") if part.strip()]
# ---------------------------------------------------------------- reading
@@ -284,6 +394,19 @@ def connection_state(name: str, note: str = "") -> dict:
"mac": "",
"macRandomized": False,
"autoconnect": False,
"metered": "auto",
# What the PROFILE says, which is not what the four fields above say.
# Those are the addresses on the wire; these are the addresses the
# profile asks for -- and the editor has to show the second, or a static
# address that has not been applied yet looks like it was never typed.
"ip4Method": "",
"ip4Addresses": [],
"ip4Gateway": "",
"ip4Dns": [],
"ip6Method": "",
"ip6Addresses": [],
"ip6Gateway": "",
"ip6Dns": [],
"note": note,
"error": "",
}
@@ -296,6 +419,13 @@ def connection_state(name: str, note: str = "") -> dict:
state["type"] = found.get("connection.type", "")
state["autoconnect"] = found.get("connection.autoconnect", "") in ("yes", "true")
state["macRandomized"] = first(found, *CLONED_MAC_KEYS).lower() == "random"
state["metered"] = METERED_NAMES.get(plain(found.get("connection.metered", "")), "auto")
for family, stack in (("4", "ipv4"), ("6", "ipv6")):
state[f"ip{family}Method"] = plain(found.get(f"{stack}.method", ""))
state[f"ip{family}Addresses"] = comma_list(found.get(f"{stack}.addresses", ""))
state[f"ip{family}Gateway"] = plain(found.get(f"{stack}.gateway", ""))
state[f"ip{family}Dns"] = comma_list(found.get(f"{stack}.dns", ""))
state["active"] = found.get("GENERAL.STATE", "") == "activated"
state["ip4"] = (indexed(found, "IP4.ADDRESS") or [""])[0]
@@ -348,6 +478,143 @@ def set_mac_random(name: str, enabled: bool) -> dict:
return connection_state(name, "Reconnect for this to take effect.")
def set_metered(name: str, mode: str) -> dict:
"""Whether this connection costs money by the byte.
Three states, not two. "Automatic" is NetworkManager deciding from what the
network told it, and it is not the same claim as "no" -- a page that folded
the two together would report a guess as a fact.
"""
require_connection(name)
value = METERED_VALUES.get(mode)
if value is None:
raise BoundaryError("A connection is metered, not metered, or left to NetworkManager.")
nmcli("connection", "modify", name, "connection.metered", value, timeout=60)
return connection_state(name)
def set_ip(name: str, family: str, method: str, address: str = "",
gateway: str = "", dns_text: str = "") -> dict:
"""One stack's addressing, written in a single nmcli call.
Manual and automatic are one setting rather than two: switching back to
automatic has to clear the addresses manual mode left behind, or
NetworkManager keeps them and the connection comes up holding both. So every
property this verb can write is written on every call, with the empty string
where one should go back to unset.
"""
require_connection(name)
require_family(family)
stack = "ipv4" if family == "4" else "ipv6"
# Every argument is checked before NetworkManager is asked anything at all.
# Reading the connection first would mean a typo in an address costs an
# nmcli invocation before it is refused -- and, worse, would make "did
# something error" pass with the validation deleted.
if method == "auto":
settings = [f"{stack}.method", "auto",
f"{stack}.addresses", "",
f"{stack}.gateway", "",
f"{stack}.dns", "",
f"{stack}.ignore-auto-dns", "no"]
elif method == "manual":
servers = require_dns(family, dns_text)
settings = [f"{stack}.method", "manual",
f"{stack}.addresses", require_cidr(family, address),
f"{stack}.gateway", require_gateway(family, gateway),
f"{stack}.dns", ",".join(servers),
# Without this NetworkManager appends the ones DHCP handed
# out to the ones just typed, which is not what a person
# choosing "manual" is asking for.
f"{stack}.ignore-auto-dns", "yes" if servers else "no"]
else:
raise BoundaryError("An address is either automatic or manual.")
before = connection_state(name)
if not before["exists"]:
raise BoundaryError("That connection no longer exists.")
nmcli("connection", "modify", name, *settings, timeout=60)
# Brought back up only if it was up. Activating a connection that was down
# is a different action, and doing it here would join a network on the
# strength of somebody editing its addresses.
if not before["active"]:
return connection_state(name, "Applies the next time this connection comes up.")
activation = run(["nmcli", "-w", "45", "connection", "up", name], timeout=60)
if activation.returncode != 0:
raise BoundaryError(refusal(
activation, "The addresses were saved, but the connection would not come back up."))
return connection_state(name, "Applied.")
# ---------------------------------------------------------------- saved profiles
# NAME is read last and split with a limit, because a network legitimately
# called "Cafe: Guest" would otherwise be cut in half by the field separator.
# Every field before it is a UUID, a keyword or a number, none of which can
# contain a colon.
SAVED_FIELDS = "UUID,TYPE,AUTOCONNECT,ACTIVE,TIMESTAMP,NAME"
def visible_ssids() -> set[str]:
"""What the last scan saw, for the in-range flag.
`--rescan no`, deliberately: listing the profiles this machine remembers
must not make the radio go looking, or opening a settings page would cost
airtime and drop throughput on the connection being looked at.
"""
result = run(["nmcli", "-t", "-e", "no", "-f", "SSID",
"device", "wifi", "list", "--rescan", "no"])
if result.returncode != 0:
return set()
return {line.strip() for line in result.stdout.splitlines()
if line.strip() and line.strip() != "--"}
def saved_connections() -> dict:
"""Every profile NetworkManager holds, including the ones nowhere near here.
The scan is what makes this more than `nmcli connection show`: a saved
network is otherwise invisible until you are standing next to it, which is
exactly when you are least able to go and tidy it up.
"""
tool("nmcli", "NetworkManager is not available.")
result = run(["nmcli", "-t", "-e", "no", "-f", SAVED_FIELDS, "connection", "show"])
if result.returncode != 0:
raise BoundaryError(refusal(result, "NetworkManager would not list the saved networks."))
in_range = visible_ssids()
entries = []
for line in result.stdout.splitlines():
parts = line.split(":", 5)
if len(parts) != 6:
continue
uuid, kind, autoconnect, active, timestamp, name = (part.strip() for part in parts)
if not name:
continue
wireless = "wireless" in kind or kind == "wifi"
entries.append({
"name": name,
"uuid": uuid,
"type": kind,
"wifi": wireless,
"autoconnect": autoconnect in ("yes", "true"),
"active": active in ("yes", "true"),
"lastUsed": int(timestamp) if timestamp.isdigit() else 0,
# Only a Wi-Fi profile can be out of range. A wired profile is not
# somewhere else; it is a cable, and saying "out of range" about one
# would be inventing a fact. None means the question does not apply.
"inRange": (name in in_range) if wireless else None,
})
entries.sort(key=lambda entry: (not entry["active"], not entry["autoconnect"],
entry["name"].lower()))
return {"connections": entries, "error": ""}
# ---------------------------------------------------------------- VPN import
@@ -631,6 +898,53 @@ def join_enterprise_nmcli(ssid: str, profile_name: str, identity: str,
raise BoundaryError(refusal(activation, "That network refused the sign-in."))
# ---------------------------------------------------------------- hidden Wi-Fi
def join_hidden(ssid: str, profile_name: str, security: str) -> dict:
"""A network that does not broadcast its name.
The only thing that makes this different from an ordinary join is
`802-11-wireless.hidden yes`: without it NetworkManager waits to be told the
network exists and never probes for it, so the profile saves and never
connects.
Validated before stdin is read, for the same reason join-enterprise is: a
request that was always going to be refused must not sit waiting for a
password first. The passphrase goes down the editor's stdin, never argv.
"""
require(SSID, ssid, "That is not a network name.")
require(NAME, profile_name, "That is not a connection name.")
if security not in WIFI_SECURITY:
raise BoundaryError("A hidden network is WPA2, WPA3, or open.")
secured = security != "none"
password = read_password() if secured else ""
if secured and not password:
raise BoundaryError("That network needs a password.")
script = [
f"set connection.id {profile_name}",
f"set 802-11-wireless.ssid {ssid}",
"set 802-11-wireless.hidden yes",
]
if secured:
script.append(f"set 802-11-wireless-security.key-mgmt {WIFI_SECURITY[security]}")
script.append(f"set 802-11-wireless-security.psk {password}")
script += ["save", "quit", ""]
nmcli("connection", "edit", "type", "wifi", "con-name", profile_name,
timeout=60, stdin_text="\n".join(script))
activation = run(["nmcli", "-w", "45", "connection", "up", profile_name], timeout=60)
if activation.returncode != 0:
raise BoundaryError(refusal(activation, "That network did not answer."))
state = connection_state(profile_name, "Joined.")
state["joined"] = state["exists"]
return state
# ---------------------------------------------------------------- proxy
@@ -791,7 +1105,11 @@ FALLBACKS = {
"connection": {"connection": "", "exists": False, "uuid": "", "type": "",
"interface": "", "active": False, "ip4": "", "ip6": "",
"gateway": "", "dns": [], "mac": "", "macRandomized": False,
"autoconnect": False, "note": ""},
"autoconnect": False, "metered": "auto",
"ip4Method": "", "ip4Addresses": [], "ip4Gateway": "", "ip4Dns": [],
"ip6Method": "", "ip6Addresses": [], "ip6Gateway": "", "ip6Dns": [],
"note": ""},
"saved": {"connections": []},
"import": {"name": "", "uuid": "", "kind": ""},
"hotspot": {"active": False, "ssid": "", "password": "",
"connection": HOTSPOT_CONNECTION, "band": "", "interface": ""},
@@ -800,10 +1118,14 @@ FALLBACKS = {
"hardBlocked": False, "radios": 0},
}
USAGE = ("Usage: panama-network details CONNECTION | forget CONNECTION | "
USAGE = ("Usage: panama-network details CONNECTION | forget CONNECTION | saved | "
"set-autoconnect CONNECTION true|false | set-mac-random CONNECTION true|false | "
"set-metered CONNECTION yes|no|auto | "
"set-ip CONNECTION 4|6 auto | "
"set-ip CONNECTION 4|6 manual ADDR/PREFIX GATEWAY DNS[,DNS...] | "
"import-vpn FILE | hotspot start SSID|stop|status | "
"join-enterprise SSID PROFILE IDENTITY [CA_CERT] | "
"join-hidden SSID PROFILE wpa-psk|sae|none | "
"proxy get | proxy set none|manual [HOST PORT]|auto [PAC_URL] | "
"airplane status | airplane set true|false")
@@ -816,6 +1138,14 @@ def dispatch(arguments: list[str]) -> tuple[str, dict]:
return "connection", connection_state(require_connection(rest[0]))
if verb == "forget" and len(rest) == 1:
return "connection", forget(rest[0])
if verb == "saved" and not rest:
return "saved", saved_connections()
if verb == "set-metered" and len(rest) == 2:
return "connection", set_metered(rest[0], rest[1])
if verb == "set-ip" and len(rest) == 3 and rest[2] == "auto":
return "connection", set_ip(rest[0], rest[1], "auto")
if verb == "set-ip" and len(rest) == 6 and rest[2] == "manual":
return "connection", set_ip(rest[0], rest[1], "manual", rest[3], rest[4], rest[5])
if verb == "set-autoconnect" and len(rest) == 2:
return "connection", set_autoconnect(rest[0], require_bool(rest[1]))
if verb == "set-mac-random" and len(rest) == 2:
@@ -831,6 +1161,8 @@ def dispatch(arguments: list[str]) -> tuple[str, dict]:
if verb == "join-enterprise" and len(rest) in (3, 4):
return "connection", join_enterprise(
rest[0], rest[1], rest[2], rest[3] if len(rest) == 4 else "")
if verb == "join-hidden" and len(rest) == 3:
return "connection", join_hidden(rest[0], rest[1], rest[2])
if verb == "proxy" and rest == ["get"]:
return "proxy", proxy_get()
if verb == "proxy" and len(rest) >= 2 and rest[0] == "set":
@@ -847,7 +1179,9 @@ def shape_for(arguments: list[str]) -> str:
verb = arguments[0] if arguments else ""
return {"details": "connection", "forget": "connection",
"set-autoconnect": "connection", "set-mac-random": "connection",
"join-enterprise": "connection", "import-vpn": "import",
"set-metered": "connection", "set-ip": "connection",
"join-enterprise": "connection", "join-hidden": "connection",
"saved": "saved", "import-vpn": "import",
"hotspot": "hotspot", "proxy": "proxy",
"airplane": "airplane"}.get(verb, "connection")
+220 -1
View File
@@ -98,6 +98,13 @@ Singleton {
// Displayed binds come from the compositor and so already reflect any
// override; the override map is what tells us where they started.
function shippedChordFor(currentChord: string): string {
// Custom chords are outside the override map's domain: a custom bind
// has no shipped chord to have been moved from, and answering one from
// the map would let a shipped bind's override claim a user's own
// shortcut. See the customBinds section below.
if (root.isCustomChord(currentChord))
return currentChord;
for (const shipped in root.overrides) {
if (root.overrides[shipped] === currentChord)
return shipped;
@@ -131,6 +138,12 @@ Singleton {
}
function rebind(currentChord: string, newChord: string): bool {
// A custom bind is edited in place in `customBinds`; it never enters
// the override map. Routing here rather than refusing keeps the two
// mechanisms from ever meeting even if a caller does not check first.
if (root.isCustomChord(currentChord))
return root.rebindCustomBind(currentChord, newChord);
if (newChord === "" || newChord === currentChord)
return false;
@@ -183,6 +196,209 @@ Singleton {
root.applyReload();
}
// ── Named actions ───────────────────────────────────────────────────────
// A custom shortcut and an assigned four-finger gesture both store DATA,
// never a command: an enum `kind`, a validated `target`, and the `label`
// to show. hypr/actions.lua turns that data into something the compositor
// runs, through whitelist tables only -- so nothing a person can type into
// settings.json becomes executable, and an unknown kind or an invalid
// target means the bind is silently not emitted rather than guessed at.
//
// This is the same vocabulary on the QML side. `describeAction()` is the
// single authority here on whether an entry is one the Lua would emit;
// every page asks it rather than re-deriving the rules.
readonly property var actionKinds: ["app", "shell", "window"]
// An application id is an ARGUMENT to the launch-or-focus path, never text
// interpolated into a command, and this is the shape that path accepts.
readonly property var safeTargetPattern: /^[A-Za-z0-9@._-]{1,128}$/
// Shell verbs, each one a surface `shell.qml` already exposes over IPC (or,
// for the last two, a command hypr/keybinds.lua already binds). The target
// strings are keys of the whitelist table in hypr/actions.lua -- adding one
// here without adding it there means the entry simply never emits.
readonly property var shellActions: [
{ target: "dnd-toggle", label: "Toggle Do Not Disturb" },
{ target: "screenshot", label: "Screenshot or record" },
{ target: "screenshot-screen", label: "Screenshot the whole screen" },
{ target: "screenshot-window", label: "Screenshot the focused window" },
{ target: "screen-intelligence", label: "Read text on screen" },
{ target: "color-picker", label: "Pick a color" },
{ target: "clipboard", label: "Clipboard history" },
{ target: "launcher", label: "Open the launcher" },
{ target: "overview", label: "Open Mission Control" },
{ target: "quick-settings", label: "Open Quick Settings" },
{ target: "notifications", label: "Open notifications" },
{ target: "activity", label: "Open Activity" },
{ target: "cheatsheet", label: "Keyboard shortcuts" },
{ target: "settings", label: "Open Settings" },
{ target: "focus-session", label: "Focus session" },
{ target: "caffeine", label: "Keep the screen awake" },
{ target: "night-light", label: "Toggle Night Light" },
{ target: "power-menu", label: "Power menu" },
{ target: "lock", label: "Lock the screen" }
]
// Compositor verbs. The three window-state ones, then the ten workspaces
// the keymap already reaches -- generated rather than typed so the range
// and hypr/actions.lua's 1..10 check can never disagree.
//
// `workspace:N` goes TO that workspace; it does not carry the focused
// window there. Said in the label because "workspace 4" on its own reads
// like either one.
readonly property var windowActions: {
const out = [
{ target: "float-toggle", label: "Toggle floating" },
{ target: "fullscreen", label: "Fullscreen" },
{ target: "pin", label: "Pin on every workspace" }
];
for (let n = 1; n <= 10; n++)
out.push({ target: "workspace:" + n, label: "Go to workspace " + n });
return out;
}
function shellActionLabel(target: string): string {
const found = root.shellActions.find(action => action.target === target);
return found ? found.label : "";
}
function windowActionLabel(target: string): string {
const found = root.windowActions.find(action => action.target === target);
return found ? found.label : "";
}
// What an entry does, in a sentence -- or "" when it is not an action the
// Lua would emit, which is what every caller checks rather than validating
// kind and target for itself.
function describeAction(entry: var): string {
if (!entry || typeof entry !== "object")
return "";
const kind = String(entry.kind ?? "");
const target = String(entry.target ?? "");
if (target === "")
return "";
if (kind === "app")
return root.safeTargetPattern.test(target) ? "Application · launch-or-focus" : "";
if (kind === "shell") {
const shell = root.shellActionLabel(target);
return shell === "" ? "" : "Shell action · " + shell;
}
if (kind === "window") {
const window = root.windowActionLabel(target);
return window === "" ? "" : "Window · " + window;
}
return "";
}
// ── Custom shortcuts ────────────────────────────────────────────────────
// [{ chord, kind, target, label }]. hypr/keybinds.lua emits these after the
// shipped binds under a "Custom" category, skipping any entry whose chord
// is invalid, whose label is empty, whose action does not resolve, or whose
// chord a shipped bind already holds. This end refuses all four upstream so
// that a saved shortcut is a working one.
readonly property var customBinds: {
const stored = DesktopPreferences.get("customBinds");
return Array.isArray(stored) ? stored : [];
}
function normalizedChord(chord: string): string {
return String(chord).replace(/\s+/g, "").toLowerCase();
}
function isCustomChord(chord: string): bool {
const wanted = root.normalizedChord(chord);
if (wanted === "")
return false;
return root.customBinds.some(entry => root.normalizedChord(entry?.chord ?? "") === wanted);
}
function customBindFor(chord: string): var {
const wanted = root.normalizedChord(chord);
return root.customBinds.find(entry => root.normalizedChord(entry?.chord ?? "") === wanted) ?? null;
}
// Is the compositor actually answering this chord with this action? A
// stored entry is an intention; the keymap is the fact. Reported as true
// before the first read so a fresh page does not flash a warning it has no
// basis for.
function customBindApplied(entry: var): bool {
if (!root.loaded || !entry)
return true;
return root.boundTo(String(entry.chord ?? ""), "") === String(entry.label ?? "");
}
function writeCustomBinds(next: var, failure: string): bool {
if (!DesktopPreferences.set("customBinds", next)) {
root.lastError = failure;
return false;
}
root.applyReload();
return true;
}
function addCustomBind(chord: string, kind: string, target: string, label: string): bool {
const trimmed = String(label).trim();
if (chord === "" || trimmed === "") {
root.lastError = "A shortcut needs a chord and a name.";
return false;
}
if (root.describeAction({ kind: kind, target: target }) === "") {
root.lastError = "That is not an action Panama can bind.";
return false;
}
const taken = root.boundTo(chord, "");
if (taken !== "") {
root.lastError = `${chord} is already ${taken}.`;
return false;
}
if (root.isCustomChord(chord)) {
root.lastError = `${chord} is already one of your shortcuts.`;
return false;
}
const next = root.customBinds.slice();
next.push({ chord: chord, kind: String(kind), target: String(target), label: trimmed });
return root.writeCustomBinds(next, "That shortcut could not be saved.");
}
function rebindCustomBind(currentChord: string, newChord: string): bool {
if (newChord === "" || newChord === currentChord)
return false;
const at = root.customBinds.findIndex(entry =>
root.normalizedChord(entry?.chord ?? "") === root.normalizedChord(currentChord));
if (at < 0)
return false;
// `exceptCurrent` is the chord being vacated, so a shortcut can be
// re-recorded onto the chord it already holds without refusing itself.
const taken = root.boundTo(newChord, currentChord);
if (taken !== "") {
root.lastError = `${newChord} is already ${taken}.`;
return false;
}
if (root.isCustomChord(newChord)) {
root.lastError = `${newChord} is already one of your shortcuts.`;
return false;
}
const next = root.customBinds.slice();
next[at] = Object.assign({}, next[at], { chord: newChord });
return root.writeCustomBinds(next, "That shortcut could not be saved.");
}
function removeCustomBind(chord: string): bool {
const wanted = root.normalizedChord(chord);
const next = root.customBinds.filter(entry =>
root.normalizedChord(entry?.chord ?? "") !== wanted);
if (next.length === root.customBinds.length)
return false;
return root.writeCustomBinds(next, "That shortcut could not be removed.");
}
Process {
id: reloadRun
command: ["hyprctl", "reload"]
@@ -388,7 +604,10 @@ Singleton {
// after them are the ones the substring derivation produces, kept so a
// machine whose compositor has not reloaded since the manifest was added
// still sorts into a sensible order rather than alphabetically.
readonly property var groupOrder: ["Windows", "Workspaces", "Applications", "Shell",
// Custom leads: a list of a hundred and thirty shipped binds is somewhere
// to look things up, and the two you invented are the two you came for.
readonly property var groupOrder: ["Custom",
"Windows", "Workspaces", "Applications", "Shell",
"Session", "Media & hardware", "Other",
"Focus", "Move & split", "Size", "Window state",
"Applications & shell", "Media & hardware keys"]
+100 -11
View File
@@ -35,6 +35,12 @@ Singleton {
// connection name -> the helper's connection shape. See detailsFor().
property var details: ({})
// Every profile NetworkManager holds, in range or not. See the `saved`
// verb: this is the list that is otherwise invisible until you are standing
// next to the network you wanted to tidy up.
property var savedConnections: []
property bool savedScanned: false
property var hotspot: ({})
property string proxyMode: "none"
property string proxyHost: ""
@@ -54,7 +60,7 @@ Singleton {
// Guards read the Process objects directly; a derived binding is stale
// inside the handler that changes it. See DefaultApps.qml.
readonly property bool busy: mutation.running || enterprise.running || detailsQuery.running
readonly property bool busy: mutation.running || passwordJoin.running || detailsQuery.running
// Connections whose details have been asked for, in order, so a burst of
// requests becomes one query at a time rather than one Process each.
@@ -131,6 +137,18 @@ Singleton {
}
}
function absorbSaved(text: string): void {
try {
const parsed = JSON.parse(text);
root.savedConnections = Array.isArray(parsed.connections) ? parsed.connections : [];
root.savedScanned = true;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the saved networks.";
}
}
function absorbHotspot(text: string): void {
try {
const parsed = JSON.parse(text);
@@ -185,6 +203,7 @@ Singleton {
function absorb(shape: string, subject: string, text: string): void {
switch (shape) {
case "connection": root.absorbDetails(subject, text); break;
case "saved": root.absorbSaved(text); break;
case "hotspot": root.absorbHotspot(text); break;
case "proxy": root.absorbProxy(text); break;
case "airplane": root.absorbAirplane(text); break;
@@ -206,6 +225,10 @@ Singleton {
function forget(connection: string): void {
root.run("connection", connection, ["forget", connection]);
// The saved list is the one surface that shows profiles nobody is
// standing next to, so a forget nobody re-reads leaves a row for a
// profile that no longer exists. The timer waits for the mutation.
root.refreshSavedSoon();
}
function setAutoconnect(connection: string, enabled: bool): void {
@@ -218,6 +241,36 @@ Singleton {
["set-mac-random", connection, enabled ? "true" : "false"]);
}
// "yes", "no" or "auto". Three states rather than two, because "automatic"
// is NetworkManager guessing from what the network said and "no" is a claim
// — see the helper's set_metered.
function setMetered(connection: string, mode: string): void {
root.run("connection", connection, ["set-metered", connection, String(mode)]);
}
// ---- static addressing
//
// family is "4" or "6" — the helper's own spelling, so nothing has to
// translate between two vocabularies on the way down.
function setIpAuto(connection: string, family: string): void {
root.run("connection", connection, ["set-ip", connection, String(family), "auto"]);
}
// Every field on every call, including the empty ones: the helper writes
// the whole stack at once so that switching modes cannot leave half the old
// configuration behind. `dns` is the comma-separated list as typed.
function setIpManual(connection: string, family: string, address: string,
gateway: string, dns: string): void {
root.run("connection", connection,
["set-ip", connection, String(family), "manual",
String(address), String(gateway), String(dns)]);
}
// ---- saved profiles
function refreshSaved(): void { root.run("saved", "", ["saved"]); }
// ---- VPN
function importVpn(path: string): void {
@@ -273,16 +326,34 @@ Singleton {
// above: only this one ever opens stdin.
function joinEnterprise(ssid: string, profile: string, identity: string,
password: string, caCert: string): void {
if (enterprise.running)
if (passwordJoin.running)
return;
root.lastError = "";
root.pendingPassword = password;
enterprise.subject = ssid;
enterprise.command = String(caCert ?? "") !== ""
passwordJoin.subject = ssid;
passwordJoin.command = String(caCert ?? "") !== ""
? [root.helperPath, "join-enterprise", ssid, profile, identity, caCert]
: [root.helperPath, "join-enterprise", ssid, profile, identity];
enterprise.stdinEnabled = true;
enterprise.running = true;
passwordJoin.stdinEnabled = true;
passwordJoin.running = true;
}
// ---- hidden Wi-Fi
//
// Same passphrase path as the enterprise join, for the same reason: a
// passphrase in argv is published to every process on this machine through
// /proc. An open hidden network still goes down this path and writes an
// empty line, so the helper's read returns rather than waiting forever.
function joinHidden(ssid: string, profile: string, security: string,
password: string): void {
if (passwordJoin.running)
return;
root.lastError = "";
root.pendingPassword = password;
passwordJoin.subject = profile;
passwordJoin.command = [root.helperPath, "join-hidden", ssid, profile, security];
passwordJoin.stdinEnabled = true;
passwordJoin.running = true;
}
// Everything that is not per-connection, in one call: what a page asks for
@@ -291,6 +362,7 @@ Singleton {
root.refreshProxy();
root.refreshAirplaneSoon();
root.refreshHotspotSoon();
root.refreshSavedSoon();
for (const connection in root.details)
root.requestDetails(connection);
}
@@ -299,6 +371,7 @@ Singleton {
// over it rather than dropped by its running guard.
function refreshAirplaneSoon(): void { airplaneSoon.restart(); }
function refreshHotspotSoon(): void { hotspotSoon.restart(); }
function refreshSavedSoon(): void { savedSoon.restart(); }
onActiveChanged: if (root.active) root.refresh()
@@ -324,6 +397,17 @@ Singleton {
}
}
Timer {
id: savedSoon
interval: 400
onTriggered: {
if (mutation.running)
savedSoon.restart();
else
root.refreshSaved();
}
}
// The next queued details read, one tick after the last one exits. Draining
// from inside onExited would look at a `running` that has not gone false
// yet, and the queue would stall on its own guard.
@@ -369,24 +453,29 @@ Singleton {
}
Process {
id: enterprise
id: passwordJoin
property string subject: ""
onStarted: {
enterprise.write(root.pendingPassword + "\n");
passwordJoin.write(root.pendingPassword + "\n");
// Held for as long as it takes to hand over, and no longer.
root.pendingPassword = "";
// Closing stdin is what lets the helper's read return; without it
// the join waits forever for a line that is already sent.
enterprise.stdinEnabled = false;
passwordJoin.stdinEnabled = false;
}
stdout: StdioCollector {
onStreamFinished: root.absorbDetails(enterprise.subject, this.text)
onStreamFinished: root.absorbDetails(passwordJoin.subject, this.text)
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: root.pendingPassword = ""
onExited: {
root.pendingPassword = "";
// A join makes a profile, so the saved list is out of date the
// moment this returns.
root.refreshSavedSoon();
}
}
}
+121 -31
View File
@@ -69,6 +69,12 @@ Singleton {
// Settings that are real but have no schema entry, because the system owns
// them rather than Panama. Without these, searching "timezone" would fail
// on a settings app that plainly has one.
//
// An entry may name a `section` as well as a page. A page with tabs opens
// on the tab holding the thing that was searched for, rather than on
// whichever tab it opens on by default -- see SettingsSidebar. Sections are
// only worth naming for a page that consumes one (ShellState.takeSettings-
// Section); everything else leaves it off and routes as before.
readonly property var extraEntries: [
{ label: "Manual", detail: "How this desktop works, in chapters", page: "manual" },
{ label: "Getting started", detail: "Coming from GNOME, macOS or Windows", page: "manual" },
@@ -92,6 +98,22 @@ Singleton {
{ label: "IP address", detail: "The address, gateway, DNS servers, and hardware address of a connection", page: "connectivity" },
{ label: "Forget a Wi-Fi network", detail: "Remove a saved network so it stops connecting on its own", page: "connectivity" },
{ label: "Enterprise Wi-Fi", detail: "Join a network that asks for an identity and a password", page: "connectivity" },
// Tier 2 network truths. Each of these was a reason to open a terminal
// or GNOME's panel, and none of them is the label of a preference: a
// static address is a profile property, a saved network is a file
// NetworkManager keeps, and metered is a flag on both.
{ label: "Saved networks", detail: "Every network this machine remembers, including the ones nowhere near you, and forgetting one", page: "connectivity" },
{ label: "Join a hidden network", detail: "A network that does not broadcast its name — type the name and its security", page: "connectivity" },
{ label: "Hidden network", detail: "Join a Wi-Fi network that does not announce itself", page: "connectivity" },
{ label: "Metered connection", detail: "Mark a connection as costing money by the byte, so updates and large downloads wait", page: "connectivity" },
{ label: "Static IP address", detail: "Set a manual IPv4 or IPv6 address, gateway and DNS for one connection", page: "connectivity" },
{ label: "Manual IP address", detail: "Turn off DHCP for a connection and enter the address yourself", page: "connectivity" },
{ label: "DNS servers", detail: "The nameservers a connection uses, and replacing the ones it is handed", page: "connectivity" },
{ label: "Show the Wi-Fi password", detail: "A QR code a phone can scan, so nobody has to read the password out", page: "connectivity" },
// The two words people type for the same socket. Neither appears in the
// page's own labels, which say "Wired" once and "Ethernet" once.
{ label: "Wired network", detail: "The Ethernet connection, its link speed, and its addresses", page: "connectivity" },
{ label: "Ethernet", detail: "The wired connection: turn it off, or open its addresses", page: "connectivity" },
{ label: "Default applications", detail: "Browser, mail, files", page: "applications" },
// The Applications tab manages applications now, rather than only
// pointing file types at them, so the things people come looking for —
@@ -264,6 +286,11 @@ Singleton {
{ label: "Pinned applications", detail: "Reorder the dock by dragging, here or on the dock itself", page: "dock" },
{ label: "Bar text", detail: "Keep the bar legible on any wallpaper", page: "bar" },
{ label: "Control Center sections", detail: "Choose what the panel offers", page: "control-center" },
{ label: "Do Not Disturb tile", detail: "The Control Center switch that holds banners back, beside Presentation", page: "control-center" },
// Signing out has no preference anywhere: it is a power-menu verb, and
// "log out" returned nothing at all on a desktop that plainly does it.
{ label: "Log out", detail: "Sign out of this session from the power menu — the same menu that restarts and powers off", page: "power" },
{ label: "Sign out", detail: "End this session and return to the login screen", page: "power" },
{ label: "Do Not Disturb", detail: "Hold banners back until you turn it off", page: "notifications" },
{ label: "Quiet hours", detail: "The schedule the Sleep focus mode keeps", page: "notifications" },
{ label: "Critical alerts break through", detail: "Let urgent notifications past Do Not Disturb", page: "notifications" },
@@ -277,6 +304,18 @@ Singleton {
// a row — so searching for the thing people actually want to do would
// otherwise find only the shortcut it is being done to.
{ label: "Rebind a shortcut", detail: "Change the keys an action answers to, or put them back", page: "shortcuts" },
// Tier 2. A custom shortcut, an app rule and a gesture assignment are
// all the same thing wearing three hats -- a named action -- and none
// of the three is a preference with a label to find it by.
{ label: "Custom shortcut", detail: "Bind your own keys to an application, a shell action, or a window action", page: "shortcuts" },
{ label: "Add a shortcut", detail: "Press the chord you want, then pick what it should do", page: "shortcuts" },
{ label: "Launch an app with a shortcut", detail: "Give an application its own key combination", page: "shortcuts" },
{ label: "App rules", detail: "Per-application window rules: float, centre, size, workspace, and no dimming", page: "tiling" },
{ label: "Window rules", detail: "Make one application always float, open on a workspace, or skip the animations", page: "tiling" },
{ label: "Always float a window", detail: "A per-application rule, so one application stops being tiled", page: "tiling" },
{ label: "Gestures", detail: "Three-finger swipes as shipped, and four-finger swipes you assign yourself", page: "mouse" },
{ label: "Touchpad gestures", detail: "What swiping with three or four fingers does", page: "mouse" },
{ label: "Four-finger swipe", detail: "Assign an application or a shell action to each direction", page: "mouse" },
{ label: "Pointer test area", detail: "Scribble and scroll to feel a pointer change before keeping it", page: "mouse" },
{ label: "Connected input devices", detail: "The keyboards, mice, and touchpad this machine can see", page: "mouse" },
// Accessibility. The schema covers the switches by their own labels, so
@@ -294,13 +333,18 @@ Singleton {
{ label: "Screen reader", detail: "Start Orca and see whether the accessibility bus is up", page: "accessibility" },
{ label: "Orca", detail: "The screen reader: whether it is running, and starting or stopping it", page: "accessibility" },
{ label: "Sticky keys", detail: "Why sticky, slow and bounce keys are not offered in this session", page: "accessibility" },
// The colour filter is a compositor shader rather than a switch, so it
// is findable by the condition rather than only by the word "filter".
{ label: "Color filter", detail: "A whole-screen filter the compositor renders: grayscale, or one for each kind of colour blindness", page: "accessibility" },
{ label: "Grayscale", detail: "Drain the colour out of the whole screen", page: "accessibility" },
{ label: "Color blindness", detail: "Protanopia, deuteranopia and tritanopia filters applied to the whole screen", page: "accessibility" },
{ label: "Dictation", detail: "Speech to text with Super+D, typed where the cursor is", page: "dictation" },
{ label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" },
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },
{ label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" },
{ label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid color", page: "appearance" },
{ label: "Password field", detail: "Choose whether the empty lock-screen field stays visible", page: "appearance" },
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" },
{ label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid color", page: "appearance", section: "background" },
{ label: "Password field", detail: "Choose whether the empty lock-screen field stays visible", page: "appearance", section: "background" },
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance", section: "background" },
{ label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" },
{ label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" },
{ label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" },
@@ -316,18 +360,18 @@ Singleton {
{ label: "Mirror displays", detail: "Show the same picture on a second display", page: "displays" },
{ label: "Variable refresh rate", detail: "Override the gaming policy for one display", page: "displays" },
{ label: "Monitor brightness", detail: "The monitor's own backlight, over DDC", page: "displays" },
{ label: "Theme profiles", detail: "Switch between Moon, Moon Rose, Day, and saved themes", page: "appearance" },
{ label: "Themes", detail: "Light and dark mode, and the theme for each", page: "appearance" },
{ label: "Dark mode", detail: "Flip the desktop to your dark theme", page: "appearance" },
{ label: "Light mode", detail: "Flip the desktop to your light theme", page: "appearance" },
{ label: "Theme editor", detail: "Build your own theme — colors, saturation, and effects", page: "appearance" },
{ label: "Catppuccin", detail: "Mocha and Latte, in the theme galleries", page: "appearance" },
{ label: "Nord", detail: "The arctic dark theme, in the gallery", page: "appearance" },
{ label: "Gruvbox", detail: "Dark and light, in the theme galleries", page: "appearance" },
{ label: "Everforest", detail: "Dark and light, in the theme galleries", page: "appearance" },
{ label: "Tokyo Night", detail: "Moon and Day, the shipped defaults", page: "appearance" },
{ label: "Video wallpaper", detail: "A looping video as the desktop background", page: "appearance" },
{ label: "Advanced accent", detail: "Adjust primary and secondary hue, saturation, and value", page: "appearance" },
{ label: "Theme profiles", detail: "Switch between Moon, Moon Rose, Day, and saved themes", page: "appearance", section: "themes" },
{ label: "Themes", detail: "Light and dark mode, and the theme for each", page: "appearance", section: "themes" },
{ label: "Dark mode", detail: "Flip the desktop to your dark theme", page: "appearance", section: "themes" },
{ label: "Light mode", detail: "Flip the desktop to your light theme", page: "appearance", section: "themes" },
{ label: "Theme editor", detail: "Build your own theme — colors, saturation, and effects", page: "appearance", section: "editor" },
{ label: "Catppuccin", detail: "Mocha and Latte, in the theme galleries", page: "appearance", section: "themes" },
{ label: "Nord", detail: "The arctic dark theme, in the gallery", page: "appearance", section: "themes" },
{ label: "Gruvbox", detail: "Dark and light, in the theme galleries", page: "appearance", section: "themes" },
{ label: "Everforest", detail: "Dark and light, in the theme galleries", page: "appearance", section: "themes" },
{ label: "Tokyo Night", detail: "Moon and Day, the shipped defaults", page: "appearance", section: "themes" },
{ label: "Video wallpaper", detail: "A looping video as the desktop background", page: "appearance", section: "background" },
{ label: "Advanced accent", detail: "Adjust primary and secondary hue, saturation, and value", page: "appearance", section: "editor" },
{ label: "Pick colour from screen", detail: "Sample an accent colour with hyprpicker", page: "appearance" }
]
@@ -335,41 +379,77 @@ Singleton {
return root.groupPages[group] ?? "home";
}
// [{ label, detail, page, kind }] for a query. Empty query yields nothing:
// the sidebar shows its normal navigation in that case.
// Both sides of a comparison, in the one spelling.
//
// The hyphens go because they are a typographic choice rather than a word
// boundary, and the desktop's most-searched noun is the worst case:
// everything here spells it "Wi-Fi" and nobody types it that way, so "wifi"
// matched nothing at all. Applied to the query and the index alike, so the
// rule is a spelling equivalence rather than a special case for one word.
function flatten(text: string): string {
return String(text).trim().toLowerCase().replace(/-/g, "");
}
// [{ label, detail, page, kind, section }] for a query. Empty query yields
// nothing: the sidebar shows its normal navigation in that case.
//
// The needle is split on whitespace and every token has to appear somewhere
// in the haystack -- an AND over words rather than one contiguous
// substring. The old shape matched the whole query as typed, so "wifi
// password", "log out" and "metered" returned nothing on a settings app
// that has all three: the words are all present, just not adjacent and not
// in that order. Order was the accidental part, and it was doing the most
// damage.
function search(query: string): var {
const needle = String(query).trim().toLowerCase();
const needle = root.flatten(query);
if (needle === "")
return [];
const tokens = needle.split(/\s+/).filter(token => token !== "");
if (tokens.length === 0)
return [];
function hit(haystack) {
const text = root.flatten(haystack);
return tokens.every(token => text.indexOf(token) >= 0);
}
const results = [];
const seen = {};
function add(label, detail, page, kind) {
function add(label, detail, page, kind, section) {
const dedupe = `${kind}:${label}:${page}`;
if (seen[dedupe])
return;
seen[dedupe] = true;
results.push({ label: label, detail: detail, page: page, kind: kind });
results.push({
label: label,
detail: detail,
page: page,
kind: kind,
// "" means "the page's own first tab", which is every result
// that does not name one. See SettingsSidebar.
section: String(section ?? "")
});
}
for (const entry of PreferenceSchema.entries) {
if (entry.internal)
continue;
const optionLabels = (entry.options ?? []).map(option => option.label).join(" ");
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group} ${optionLabels}`.toLowerCase();
if (haystack.indexOf(needle) >= 0)
add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting");
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group} ${optionLabels}`;
if (hit(haystack))
add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting", "");
}
for (const entry of root.extraEntries) {
if (`${entry.label} ${entry.detail}`.toLowerCase().indexOf(needle) >= 0)
add(entry.label, entry.detail, entry.page, "setting");
if (hit(`${entry.label} ${entry.detail}`))
add(entry.label, entry.detail, entry.page, "setting", entry.section ?? "");
}
for (const bind of Keybinds.binds) {
if (bind.description.toLowerCase().indexOf(needle) >= 0)
add(bind.description, bind.chord, "shortcuts", "shortcut");
if (hit(bind.description))
add(bind.description, bind.chord, "shortcuts", "shortcut", "");
}
// Exact prefix matches first: typing "blur" should put "Blur" above
@@ -377,15 +457,25 @@ Singleton {
// its explanation. An exact enum option also leads: "slideshow" is a
// mode choice, so Wallpaper mode belongs above the interval row that
// merely explains it.
//
// Between those two comes the tokenized rule: a row whose LABEL holds
// every word of the query beats one that only holds some of them, or
// holds them in its explanation. Without it, "wifi password" would rank
// every row whose detail happens to say "password" alongside the rows
// that are actually about the Wi-Fi password.
return results.sort((a, b) => {
const aSpec = PreferenceSchema.entries.find(entry => entry.label === a.label);
const bSpec = PreferenceSchema.entries.find(entry => entry.label === b.label);
const ao = (aSpec?.options ?? []).some(option => option.label.toLowerCase() === needle);
const bo = (bSpec?.options ?? []).some(option => option.label.toLowerCase() === needle);
const ao = (aSpec?.options ?? []).some(option => root.flatten(option.label) === needle);
const bo = (bSpec?.options ?? []).some(option => root.flatten(option.label) === needle);
if (ao !== bo)
return ao ? -1 : 1;
const al = a.label.toLowerCase();
const bl = b.label.toLowerCase();
const al = root.flatten(a.label);
const bl = root.flatten(b.label);
const at = tokens.every(token => al.indexOf(token) >= 0) ? 0 : 1;
const bt = tokens.every(token => bl.indexOf(token) >= 0) ? 0 : 1;
if (at !== bt)
return at - bt;
const ap = al === needle ? 0 : (al.indexOf(needle) === 0 ? 1 : 2);
const bp = bl === needle ? 0 : (bl.indexOf(needle) === 0 ? 1 : 2);
return ap !== bp ? ap - bp : al.localeCompare(bl);
@@ -652,6 +652,81 @@ Singleton {
root.applyOptions({ directScanoutPolicy: policy });
}
// ── Color filters ───────────────────────────────────────────────────────
// The stored preference is an enum; what the compositor wants is a shader
// path. That mapping cannot be a `hypr:` block on the schema entry -- the
// read-back would compare "grayscale" against a filename and fail every
// shape and sweep contract -- so it lives here, and hypr/looks.lua does the
// same lookup for the value the config carries at launch.
//
// The shaders are installed by the hypr directory symlink, so the path is
// the deployed one rather than the repository's.
readonly property string shaderDir:
(Quickshell.env("XDG_CONFIG_HOME") || `${Quickshell.env("HOME")}/.config`) + "/hypr/shaders"
readonly property var colorFilterShaders: ({
"grayscale": "grayscale.frag",
"protanopia": "protanopia.frag",
"deuteranopia": "deuteranopia.frag",
"tritanopia": "tritanopia.frag"
})
// "" for none, and for any value this build does not ship a shader for --
// an unknown filter turns the filter off rather than leaving the previous
// one on under a new name.
function colorFilterPath(name: string): string {
const file = root.colorFilterShaders[String(name)];
return file === undefined ? "" : `${root.shaderDir}/${file}`;
}
property string colorFilterPending: ""
Process {
id: colorFilterWrite
stdout: StdioCollector {
onStreamFinished: {
// `hyprctl eval` exits 0 on a Lua error and reports it on
// stdout, so the exit status proves nothing. Read it back.
if (this.text.indexOf("error:") >= 0) {
root.lastError = "The color filter could not be applied.";
return;
}
colorFilterVerify.exec(["hyprctl", "-j", "getoption", "decoration:screen_shader"]);
}
}
}
Process {
id: colorFilterVerify
stdout: StdioCollector {
onStreamFinished: {
try {
const observed = String(JSON.parse(this.text).str ?? "");
if (observed !== root.colorFilterPending) {
root.lastError = "The compositor did not take the color filter.";
return;
}
root.lastError = "";
} catch (error) {
root.lastError = "The compositor did not say whether the color filter applied.";
}
}
}
}
// Applies the filter to the running compositor. The preference is written
// by the row that calls this; nothing here stores anything.
function applyColorFilter(name: string): void {
if (colorFilterWrite.running)
return;
const path = root.colorFilterPath(name);
root.colorFilterPending = path;
colorFilterWrite.exec(["hyprctl", "eval",
`hl.config({ decoration = { screen_shader = "${path.replace(/["\\]/g, "")}" } })`]);
}
// Replays every compositor-owned preference in one batch at shell start, so
// a value the user changed in Settings survives a reboot even though the
// Lua config only reads the file once, at launch.
@@ -0,0 +1,291 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Per-application window rules: how a named application behaves when it opens.
//
// Modelled on Workspaces.qml, and for the same reason. A window rule is read by
// Hyprland at config time and cannot be taken back at runtime, so the only way
// to change the set is `hyprctl reload`, which re-runs the config and lets
// hypr/rules.lua emit exactly the rules the preference asks for.
//
// That makes this service two things: the reload, and an honest answer to "has
// it taken effect yet". The second is the harder half here, because Hyprland
// publishes no window-rule listing -- `hyprctl` offers `workspacerules` and
// nothing equivalent for these. So `applied` is not a read-back of the rules
// themselves: it is the exit status of the reload that last ran, against the
// rule set that was stored when it ran. The page says applied-on-reload in
// those words rather than dressing that up as a confirmation it is not.
//
// What CAN be read back is the effect: `matchesOpen()` counts the windows open
// right now whose class a rule names, which is the difference between "we wrote
// a rule for org.gnome.Calculator" and "the calculator on your screen is the
// thing this rule is about".
//
// Nothing stored here is a command. A rule is a literal class string plus
// booleans and two bounded numbers; hypr/rules.lua regex-escapes the class
// before Hyprland's matcher sees it and skips any entry that fails validation,
// so an entry that arrived by hand-editing settings.json is inert rather than
// dangerous.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import QtQuick
import qs.config
Singleton {
id: root
// [{ class, label, float, center, size, workspace, noAnim, game, noDim, pin }]
readonly property var rules: {
const stored = DesktopPreferences.get("windowRules");
return Array.isArray(stored) ? stored : [];
}
property bool reloading: false
property string lastError: ""
// The rule set the last successful reload actually carried. Compared by
// value because the array is rewritten wholesale on every edit.
property string appliedSignature: ""
readonly property string signature: JSON.stringify(root.rules)
// Has the compositor been through a reload since the rules last changed?
// An empty rule set needs no reload to be true of the compositor, so it is
// applied by definition.
readonly property bool applied: root.rules.length === 0
|| root.appliedSignature === root.signature
// ── Validation ──────────────────────────────────────────────────────────
// The same rules hypr/rules.lua applies, so the page can refuse an entry
// rather than saving one the compositor will silently drop.
readonly property int maxClassLength: 128
readonly property int minSize: 50
readonly property int maxSize: 10000
readonly property int maxWorkspace: 10
// The shell's own surfaces are not addressable. A rule that floated or
// moved a Quickshell layer would break the desktop from inside Settings,
// and there is no legitimate reason to write one.
readonly property var shellClassPattern: /^(quickshell|qs-)/i
function isShellClass(windowClass: string): bool {
return root.shellClassPattern.test(String(windowClass).trim());
}
// A class is matched literally, so anything printable is allowed -- but a
// control character or a newline could not have come from a real window and
// would end up inside a compositor rule.
function validClass(windowClass: string): bool {
const text = String(windowClass ?? "").trim();
if (text === "" || text.length > root.maxClassLength)
return false;
if (/[\x00-\x1f\x7f]/.test(text))
return false;
return !root.isShellClass(text);
}
function validSize(size: var): bool {
if (size === null || size === undefined)
return true;
if (!Array.isArray(size) || size.length !== 2)
return false;
return size.every(value => Number.isFinite(value)
&& value >= root.minSize && value <= root.maxSize);
}
function validWorkspace(workspace: var): bool {
if (workspace === null || workspace === undefined)
return true;
return Number.isFinite(workspace) && workspace >= 1 && workspace <= root.maxWorkspace;
}
function validRule(rule: var): bool {
if (!rule || typeof rule !== "object")
return false;
return root.validClass(rule.class)
&& root.validSize(rule.size ?? null)
&& root.validWorkspace(rule.workspace ?? null);
}
// A rule that ticks nothing is a rule that does nothing, which is a row
// somebody would later wonder about.
function hasBehavior(rule: var): bool {
if (!rule)
return false;
return rule.float === true || rule.center === true || rule.noAnim === true
|| rule.game === true || rule.noDim === true || rule.pin === true
|| (Array.isArray(rule.size) && rule.size.length === 2)
|| Number.isFinite(rule.workspace);
}
// ── How a rule reads ────────────────────────────────────────────────────
// Two spellings, both from here so the page never invents a third: the
// plain sentence somebody chose these ticks by, and the compositor line
// underneath it for anyone who wants to see what was actually written.
function summaryFor(rule: var): string {
const parts = [];
if (rule?.float === true)
parts.push("Floats");
if (Array.isArray(rule?.size) && rule.size.length === 2)
parts.push(`fixed size (${rule.size[0]} × ${rule.size[1]})`);
if (rule?.center === true)
parts.push("centered");
if (Number.isFinite(rule?.workspace))
parts.push("opens on workspace " + rule.workspace);
if (rule?.game === true)
parts.push("treated as a game");
if (rule?.noAnim === true)
parts.push("no animations");
if (rule?.noDim === true)
parts.push("never dimmed");
if (rule?.pin === true)
parts.push("pinned to every workspace");
if (parts.length === 0)
return "No behavior chosen — this rule does nothing";
return parts.join(" · ");
}
function ruleLineFor(rule: var): string {
const verbs = [];
if (rule?.float === true)
verbs.push("float");
if (Array.isArray(rule?.size) && rule.size.length === 2)
verbs.push(`size ${rule.size[0]} ${rule.size[1]}`);
if (rule?.center === true)
verbs.push("center");
if (Number.isFinite(rule?.workspace))
verbs.push("workspace " + rule.workspace);
if (rule?.game === true)
verbs.push("content:game");
if (rule?.noAnim === true)
verbs.push("no_anim");
if (rule?.noDim === true)
verbs.push("no_dim");
if (rule?.pin === true)
verbs.push("pin");
return `match class ${String(rule?.class ?? "")} ${verbs.length === 0 ? "nothing" : verbs.join(", ")}`;
}
// ── The effect, read from the live desktop ──────────────────────────────
function indexOfClass(windowClass: string): int {
const wanted = String(windowClass).trim();
return root.rules.findIndex(rule => String(rule?.class ?? "").trim() === wanted);
}
// Windows open right now that this rule's class names. Hyprland reports a
// Wayland client's app id, which is the class its rules match on.
function matchesOpen(windowClass: string): int {
const wanted = String(windowClass).trim();
if (wanted === "")
return 0;
let count = 0;
for (const toplevel of (Hyprland.toplevels?.values ?? [])) {
if (toplevel?.wayland?.appId === wanted)
count++;
}
return count;
}
// ── Editing ─────────────────────────────────────────────────────────────
// Each write replaces the whole array and then reloads, because that is the
// only way a removed rule stops applying.
function write(next: var, failure: string): bool {
if (!DesktopPreferences.set("windowRules", next)) {
root.lastError = failure;
return false;
}
root.lastError = "";
root.apply();
return true;
}
function addRule(rule: var): bool {
if (!root.validRule(rule)) {
root.lastError = root.isShellClass(rule?.class ?? "")
? "Panama's own surfaces cannot be given window rules."
: "That rule is not one the compositor would accept.";
return false;
}
if (root.indexOfClass(rule.class) >= 0) {
root.lastError = `There is already a rule for ${rule.class}.`;
return false;
}
const next = root.rules.slice();
next.push(rule);
return root.write(next, "That rule could not be saved.");
}
function updateRule(windowClass: string, rule: var): bool {
const at = root.indexOfClass(windowClass);
if (at < 0)
return false;
if (!root.validRule(rule)) {
root.lastError = "That rule is not one the compositor would accept.";
return false;
}
const next = root.rules.slice();
next[at] = rule;
return root.write(next, "That rule could not be saved.");
}
function removeRule(windowClass: string): bool {
const at = root.indexOfClass(windowClass);
if (at < 0)
return false;
const next = root.rules.slice();
next.splice(at, 1);
return root.write(next, "That rule could not be removed.");
}
// ── Applying ────────────────────────────────────────────────────────────
Process {
id: reloadRun
command: ["hyprctl", "reload"]
onExited: (exitCode, exitStatus) => {
root.reloading = false;
if (exitCode !== 0) {
root.lastError = "The compositor did not reload, so the rules above are not in effect yet.";
return;
}
root.lastError = "";
settle.restart();
}
}
Timer {
id: settle
interval: 350
// The rules the reload just read are the ones stored at that moment.
// Recorded after the settle rather than before the reload so a write
// that lands late is not credited to a reload that ran before it.
onTriggered: root.appliedSignature = root.signature
}
Timer {
id: reloadDelay
interval: 120
onTriggered: reloadRun.running = true
}
function apply(): void {
if (reloadRun.running)
return;
root.reloading = true;
// DesktopPreferences coalesces its write on a timer; the reload has to
// land after it or it re-reads the previous file.
reloadDelay.restart();
}
// A rule set that was already in the config when the shell started is in
// effect: the compositor read it at login. Recorded once so an untouched
// list does not read as pending forever.
Component.onCompleted: root.appliedSignature = root.signature
}
@@ -14,7 +14,11 @@ ShellRoot {
count: hits.length,
top: hits.length > 0 ? hits[0].label : "",
topPage: hits.length > 0 ? hits[0].page : "",
labels: hits.slice(0, 6).map(hit => hit.label)
// "" for a result that names no tab, which is most of them.
// The sidebar routes those exactly as it always did.
topSection: hits.length > 0 ? String(hits[0].section ?? "") : "",
labels: hits.slice(0, 6).map(hit => hit.label),
pages: hits.slice(0, 6).map(hit => hit.page)
});
}
}