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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user