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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user