From b8f88a91f3f3b38617077ce3f7d6c0abaf5de4da Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 24 Aug 2026 15:14:02 -0400 Subject: [PATCH] Add the four components the Focus commit referenced but forgot to ship Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8 --- .../modules/settings/FocusAllowChips.qml | 257 +++++++++ .../modules/settings/FocusModeRow.qml | 545 ++++++++++++++++++ .../quickshell/modules/settings/FocusPage.qml | 160 +++++ .../modules/settings/NotificationAppRow.qml | 297 ++++++++++ 4 files changed, 1259 insertions(+) create mode 100644 config/dot/quickshell/modules/settings/FocusAllowChips.qml create mode 100644 config/dot/quickshell/modules/settings/FocusModeRow.qml create mode 100644 config/dot/quickshell/modules/settings/FocusPage.qml create mode 100644 config/dot/quickshell/modules/settings/NotificationAppRow.qml diff --git a/config/dot/quickshell/modules/settings/FocusAllowChips.qml b/config/dot/quickshell/modules/settings/FocusAllowChips.qml new file mode 100644 index 0000000..fbe8433 --- /dev/null +++ b/config/dot/quickshell/modules/settings/FocusAllowChips.qml @@ -0,0 +1,257 @@ +// The applications a focus mode lets through, as chips rather than a second +// unbounded list of every application that has ever notified. +// +// The old editor unfolded the whole application universe under every mode, +// which meant scrolling a hundred switches to add one exception. What a person +// wants to read is the short list of things that may interrupt; adding to it is +// a search, not a scan. +// +// An id that is allowed but no longer known still gets a chip, spelled as the +// raw id, so an exception can always be removed even after the application that +// earned it is gone. + +import QtQuick +import qs.config +import qs.modules.clipboard + +Column { + id: root + + // The mode's allow list: application ids. + property var allow: [] + + // The same universe the per-application rules use: [{ id, name }]. + property var knownApps: [] + + property bool divider: true + + signal toggled(string appId, bool allowed) + + property bool adding: false + + function nameOf(appId: string): string { + const known = root.knownApps.find(app => String(app.id) === appId); + return known ? String(known.name) : appId; + } + + readonly property var allowed: (root.allow ?? []).map(String) + + // Capped at eight: this is a search box, not a browser, and a list long + // enough to scroll defeats the point of having replaced one. + readonly property var matches: { + const needle = search.text.trim().toLowerCase(); + const out = []; + for (const app of root.knownApps) { + const id = String(app.id); + if (root.allowed.indexOf(id) >= 0) + continue; + const haystack = (String(app.name) + " " + id).toLowerCase(); + if (needle !== "" && haystack.indexOf(needle) < 0) + continue; + out.push(app); + if (out.length >= 8) + break; + } + return out; + } + + width: parent ? parent.width : 620 + spacing: 0 + + Item { + id: head + + width: parent.width + implicitHeight: Math.max(56, Math.max(copy.implicitHeight, chips.implicitHeight) + 20) + + Column { + id: copy + + anchors.left: parent.left + anchors.right: chips.left + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Text { + width: parent.width + text: "May interrupt" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: "Apps that break through while this mode is on" + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + } + + Flow { + id: chips + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: Math.max(160, parent.width * 0.45) + spacing: 7 + + Repeater { + model: root.allowed + + Rectangle { + id: chip + + required property string modelData + + height: 26 + width: chipLabel.implicitWidth + remove.width + 22 + radius: Theme.pillRadius + color: Theme.alpha(Theme.accent, 0.1) + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.28) + + Text { + id: chipLabel + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: root.nameOf(chip.modelData) + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + + Item { + id: remove + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 20 + height: 20 + + Text { + anchors.centerIn: parent + text: "×" + color: removeHover.hovered ? Theme.danger : Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + 2 + } + + HoverHandler { + id: removeHover + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + onTapped: root.toggled(chip.modelData, false) + } + } + } + } + + Rectangle { + id: addChip + + height: 26 + width: addLabel.implicitWidth + 20 + radius: Theme.pillRadius + color: Theme.alpha(Theme.fg, addHover.hovered ? 0.1 : 0.05) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.2) + + Text { + id: addLabel + anchors.centerIn: parent + text: root.adding ? "Cancel" : "+ Add app" + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + + HoverHandler { + id: addHover + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + onTapped: { + root.adding = !root.adding; + if (!root.adding) + search.text = ""; + } + } + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + visible: root.divider && !root.adding + color: Theme.alpha(Theme.fg, 0.065) + } + } + + Column { + width: parent.width + visible: root.adding + spacing: 0 + + Item { + width: parent.width + height: 40 + + SearchField { + id: search + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + placeholder: "Search apps" + } + } + + Repeater { + model: root.matches + + SettingRow { + id: candidate + + required property var modelData + required property int index + + width: parent.width + label: String(candidate.modelData.name ?? "") + detail: String(candidate.modelData.id ?? "") + controlWidth: 86 + divider: candidate.index < root.matches.length - 1 + + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Add" + onClicked: { + root.toggled(String(candidate.modelData.id), true); + search.text = ""; + } + } + } + } + + SettingRow { + width: parent.width + visible: root.matches.length === 0 + label: "No applications left to add" + detail: "Applications appear here after their first notification" + divider: false + } + } +} diff --git a/config/dot/quickshell/modules/settings/FocusModeRow.qml b/config/dot/quickshell/modules/settings/FocusModeRow.qml new file mode 100644 index 0000000..5234d35 --- /dev/null +++ b/config/dot/quickshell/modules/settings/FocusModeRow.qml @@ -0,0 +1,545 @@ +// One focus mode, as a line that unfolds into its whole editor. +// +// Order is priority -- the first mode whose condition is true wins -- so the +// row carries a grip that moves it, by pointer or by the arrow keys once it has +// keyboard focus. Everything else is the mode itself: what turns it on, what it +// does, and what it lets through anyway. +// +// The editor edits `triggers[0]`. Shipped modes each carry exactly one trigger; +// a hand-edited mode with more than one keeps the extras through field edits and +// collapses to a single trigger the first time its kind is changed here, which +// is what FocusModes.setTriggerKind is defined to do. + +import QtQuick +import qs.config +import qs.services + +Column { + id: root + + required property var mode + required property var knownApps + + property bool expanded: false + property bool running: false + property bool divider: true + + signal activated + signal allowToggled(string appId, bool allowed) + + readonly property string modeId: String(root.mode.id ?? "") + readonly property string modeName: String(root.mode.name ?? "") + readonly property var trigger: (root.mode.triggers ?? [])[0] ?? ({ kind: "manual" }) + readonly property string kind: String(root.trigger.kind ?? "manual") + readonly property var allow: Array.isArray(root.mode.allow) ? root.mode.allow.map(String) : [] + + // Writes one field of the mode's first trigger, leaving any others alone. + function editTrigger(changes: var): void { + const triggers = (root.mode.triggers ?? []).slice(); + triggers[0] = Object.assign({}, triggers[0] ?? { kind: root.kind }, changes); + FocusModes.update(root.modeId, { triggers: triggers }); + } + + function toggleDay(day: int): void { + const days = Array.isArray(root.trigger.days) ? root.trigger.days.map(Number) : []; + const at = days.indexOf(day); + if (at >= 0) + days.splice(at, 1); + else + days.push(day); + days.sort((a, b) => a - b); + root.editTrigger({ days: days }); + } + + // A time is written only if it is a real one. FocusModes treats a malformed + // schedule as off, and silencing a machine because a string was wrong is the + // worst way for this to fail -- so a bad entry is simply not stored. + function normalizedTime(text: string): string { + const match = /^(\d{1,2}):(\d{2})$/.exec(String(text ?? "").trim()); + if (!match) + return ""; + const hours = Number(match[1]); + const minutes = Number(match[2]); + if (hours > 23 || minutes > 59) + return ""; + return String(hours).padStart(2, "0") + ":" + match[2]; + } + + function daysText(days: var): string { + const list = Array.isArray(days) ? days.map(Number).slice().sort((a, b) => a - b) : []; + if (list.length === 0) + return "no days"; + if (list.length === 7) + return "every day"; + if (list.join(",") === "1,2,3,4,5") + return "weekdays"; + if (list.join(",") === "0,6") + return "weekends"; + const names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + return list.map(day => names[day]).join(", "); + } + + readonly property string whenPhrase: { + switch (root.kind) { + case "game": + return "when a game runs"; + case "schedule": + return "on a schedule — " + String(root.trigger.start ?? "") + + " to " + String(root.trigger.end ?? "") + + ", " + root.daysText(root.trigger.days); + case "workspace": + return "on workspace " + String(root.trigger.id ?? 1); + case "fullscreen": + return "when an app is fullscreen"; + default: + return "manually"; + } + } + + // What this mode does, said in a tense the shell can keep. A manual mode has + // no activation path at all -- FocusModes.triggerActive is false for + // "manual" and the automatic list filters it out -- so its switch is saved + // intent and nothing more, and the summary must not claim otherwise. + readonly property string summaryText: { + if (root.running) + return "On now — " + FocusModes.activeReason; + + const hypothetical = root.kind === "manual" || root.mode.enabled !== true; + const effects = []; + if (root.mode.silence === true) { + effects.push((hypothetical ? "would silence everything" : "silences everything") + + (root.allow.length === 0 + ? "" + : " except " + root.allow.length + + (root.allow.length === 1 ? " app" : " apps"))); + } + if (root.mode.keepAwake === true) + effects.push(hypothetical ? "would keep the screen awake" : "keeps the screen awake"); + + const opening = root.kind === "manual" + ? "Nothing turns this on automatically" + : (root.mode.enabled === true + ? "Turns on " + root.whenPhrase + : "Off · would turn on " + root.whenPhrase); + return effects.length > 0 ? opening + " · " + effects.join(", ") : opening; + } + + readonly property color tileColor: { + const palette = [Theme.accent, Theme.teal, Theme.magenta, Theme.cyan, + Theme.green, Theme.orange, Theme.pink]; + let hash = 0; + for (let index = 0; index < root.modeId.length; index++) + hash = (hash * 31 + root.modeId.charCodeAt(index)) % 9973; + return palette[hash % palette.length]; + } + + width: parent ? parent.width : 620 + spacing: 0 + + Item { + id: head + + width: parent.width + implicitHeight: Math.max(60, copy.implicitHeight + 22) + + Rectangle { + anchors.fill: parent + anchors.bottomMargin: 1 + radius: 10 + z: -1 + visible: headHover.hovered + color: Theme.alpha(Theme.fg, 0.05) + border.width: 0 + } + + Item { + id: grip + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 20 + height: 32 + activeFocusOnTab: true + + Accessible.role: Accessible.Button + Accessible.name: "Reorder " + root.modeName + Accessible.description: "Up and down move this mode's priority" + Accessible.focusable: true + + Keys.onUpPressed: FocusModes.moveMode(root.modeId, -1) + Keys.onDownPressed: FocusModes.moveMode(root.modeId, 1) + + Rectangle { + anchors.fill: parent + radius: 6 + visible: grip.activeFocus + color: "transparent" + border.width: 2 + border.color: Theme.alpha(Theme.accent, 0.55) + } + + Text { + anchors.centerIn: parent + text: "⠿" + color: gripHover.hovered || grip.activeFocus ? Theme.fgDim : Theme.fgMuted + font.family: Theme.fontMono + font.pixelSize: Theme.fontSize + 2 + } + + HoverHandler { + id: gripHover + cursorShape: Qt.OpenHandCursor + } + + // A row swap on drop rather than a live-reordering list: the whole + // gesture is "put this one further up", and one write at the end + // says exactly that. + DragHandler { + id: gripDrag + + property real startY: 0 + + target: null + onActiveChanged: { + if (gripDrag.active) { + gripDrag.startY = gripDrag.centroid.scenePosition.y; + return; + } + const travelled = gripDrag.centroid.scenePosition.y - gripDrag.startY; + const steps = Math.round(travelled / Math.max(1, head.height)); + if (steps !== 0) + FocusModes.moveMode(root.modeId, steps); + } + } + } + + Rectangle { + id: tile + + anchors.left: grip.right + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + width: 32 + height: 32 + radius: 9 + color: Theme.alpha(root.tileColor, root.mode.enabled === true ? 0.9 : 0.35) + border.width: 0 + + Text { + anchors.centerIn: parent + text: root.modeName.length > 0 ? root.modeName.charAt(0).toUpperCase() : "?" + color: Theme.bgDark + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + 1 + font.weight: Font.Bold + } + } + + Column { + id: copy + + anchors.left: tile.right + anchors.leftMargin: 12 + anchors.right: controls.left + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Text { + width: parent.width + text: root.modeName + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Text { + width: parent.width + text: root.summaryText + color: root.running ? Theme.ok : Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + } + + Row { + id: controls + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 11 + + SettingsToggle { + anchors.verticalCenter: parent.verticalCenter + checked: root.mode.enabled === true + onToggled: value => FocusModes.setEnabled(root.modeId, value) + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.expanded ? "▴" : "▾" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + Rectangle { + anchors.left: tile.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + visible: root.divider && !root.expanded + color: Theme.alpha(Theme.fg, 0.065) + } + + HoverHandler { + id: headHover + cursorShape: Qt.PointingHandCursor + } + + // The grip moves the mode and the trailing controls switch it on; the + // band between them is the expander. + TapHandler { + onTapped: eventPoint => { + if (eventPoint.position.x <= grip.width + || eventPoint.position.x >= controls.x) + return; + root.activated(); + } + } + } + + Column { + id: body + + x: 44 + width: Math.max(0, parent.width - 44) + visible: root.expanded + + OptionPickerRow { + width: parent.width + label: "Turns on" + detail: root.kind === "manual" + ? "Nothing turns this on automatically — pick a trigger, or start a focus session below for quiet by hand" + : "The condition is asked continuously, so this is right after a reboot or a suspend" + options: [ + { + value: "manual", + label: "Manually", + detail: "No condition: the mode is saved, and nothing switches it on" + }, + { + value: "game", + label: "When a game runs", + detail: "The gamemode hook reports the game; this mode does the silencing" + }, + { + value: "schedule", + label: "On a schedule", + detail: "A window of the clock, on the days you choose" + }, + { + value: "workspace", + label: "On a workspace", + detail: "On while that workspace is the focused one" + }, + { + value: "fullscreen", + label: "When an app is fullscreen", + detail: "On while a window is fullscreen on the focused display" + } + ] + current: root.kind + onPicked: value => FocusModes.setTriggerKind(root.modeId, value, {}) + } + + TextFieldRow { + width: parent.width + visible: root.kind === "schedule" + label: "From" + detail: "24-hour, such as 23:30" + text: String(root.trigger.start ?? "") + placeholder: "23:30" + onAccepted: value => { + const time = root.normalizedTime(value); + if (time !== "") + root.editTrigger({ start: time }); + } + } + + TextFieldRow { + width: parent.width + visible: root.kind === "schedule" + label: "Until" + detail: "A time earlier than the start means the window crosses midnight" + text: String(root.trigger.end ?? "") + placeholder: "07:00" + onAccepted: value => { + const time = root.normalizedTime(value); + if (time !== "") + root.editTrigger({ end: time }); + } + } + + SettingRow { + width: parent.width + visible: root.kind === "schedule" + label: "On these days" + detail: "A window that crosses midnight belongs to the day it starts on" + controlWidth: 250 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 5 + + Repeater { + model: [ + { value: 1, label: "M" }, { value: 2, label: "T" }, + { value: 3, label: "W" }, { value: 4, label: "T" }, + { value: 5, label: "F" }, { value: 6, label: "S" }, + { value: 0, label: "S" } + ] + + delegate: Rectangle { + id: dayPill + + required property var modelData + + readonly property bool on: + (root.trigger.days ?? []).indexOf(dayPill.modelData.value) >= 0 + + width: 30 + height: 28 + radius: 8 + color: dayPill.on ? Theme.alpha(Theme.accent, 0.22) + : Theme.alpha(Theme.fg, 0.06) + border.width: 1 + border.color: dayPill.on ? Theme.alpha(Theme.accent, 0.5) + : Theme.alpha(Theme.fg, 0.08) + + Text { + anchors.centerIn: parent + text: String(dayPill.modelData.label) + color: dayPill.on ? Theme.fg : Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: dayPill.on ? Font.DemiBold : Font.Medium + } + + HoverHandler { cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: root.toggleDay(dayPill.modelData.value) } + } + } + } + } + + SettingRow { + id: workspaceRow + + readonly property int current: Math.max(1, Number(root.trigger.id ?? 1)) + + width: parent.width + visible: root.kind === "workspace" + label: "Workspace" + detail: "On while this workspace is the focused one" + controlWidth: 130 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: "−" + enabled: workspaceRow.current > 1 + onClicked: root.editTrigger({ id: workspaceRow.current - 1 }) + } + + Text { + anchors.verticalCenter: parent.verticalCenter + width: 24 + horizontalAlignment: Text.AlignHCenter + text: String(workspaceRow.current) + color: Theme.fg + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSize + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: "+" + enabled: workspaceRow.current < 10 + onClicked: root.editTrigger({ id: workspaceRow.current + 1 }) + } + } + } + + SettingRow { + width: parent.width + label: "Silence notifications" + detail: "Banners are held until the mode ends" + controlWidth: 48 + + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: root.mode.silence === true + onToggled: value => FocusModes.update(root.modeId, { silence: value }) + } + } + + FocusAllowChips { + width: parent.width + visible: root.mode.silence === true + allow: root.allow + knownApps: root.knownApps + onToggled: (appId, allowed) => root.allowToggled(appId, allowed) + } + + SettingRow { + width: parent.width + label: "Keep the screen awake" + detail: "Caffeine, for as long as the mode is on" + controlWidth: 48 + + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: root.mode.keepAwake === true + onToggled: value => FocusModes.update(root.modeId, { keepAwake: value }) + } + } + + TextFieldRow { + width: parent.width + label: "Name" + detail: "What this mode is called everywhere it appears" + text: root.modeName + placeholder: "Focus mode" + onAccepted: value => FocusModes.renameMode(root.modeId, value) + } + + SettingRow { + width: parent.width + label: "Delete this mode" + detail: "Removes it and its exceptions for good" + controlWidth: 90 + divider: false + + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Delete" + tone: "danger" + onClicked: FocusModes.removeMode(root.modeId) + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/FocusPage.qml b/config/dot/quickshell/modules/settings/FocusPage.qml new file mode 100644 index 0000000..b49dd87 --- /dev/null +++ b/config/dot/quickshell/modules/settings/FocusPage.qml @@ -0,0 +1,160 @@ +// The focus tab: the modes that quiet this machine, and the timed session that +// is deliberately not one of them. +// +// Modes are conditions rather than alarms -- a mode is on because something is +// true right now -- so the editor never asks anyone to schedule an event. It +// asks what has to be true. Order is priority, which is why the list can be +// reordered and says so. + +import QtQuick +import qs.config +import qs.services + +SettingsPage { + id: root + + // One mode is open for editing at a time; the point is a card you can read, + // not every mode unfolded at once. + property string expandedMode: "" + + // The exception list belongs to the page rather than to the row: the + // applications it is chosen from are the same universe the per-application + // notification rules use, and the page is what reads it. + function toggleAllowed(mode: var, appId: string, allowed: bool): void { + const current = Array.isArray(mode.allow) ? mode.allow.map(String) : []; + const at = current.indexOf(appId); + if (allowed && at < 0) + current.push(appId); + else if (!allowed && at >= 0) + current.splice(at, 1); + FocusModes.update(String(mode.id), { allow: current }); + } + + title: "Focus" + lede: "Modes quiet this machine on their own terms — first matching mode wins, and the order below is the priority." + + SettingsCard { + title: "Focus modes" + subtitle: FocusModes.active + ? FocusModes.activeName + " is on because " + FocusModes.activeReason + "." + : "Drag a mode by its grip, or press up and down on it, to change which one wins." + + TextRow { + visible: FocusModes.modes.length === 0 + label: "No focus modes" + detail: "Add one below and give it something to react to." + divider: false + } + + Repeater { + model: FocusModes.modes + + delegate: FocusModeRow { + id: modeRow + + required property var modelData + + width: parent.width + mode: modeRow.modelData + knownApps: Notifs.applications + expanded: root.expandedMode === String(modeRow.modelData.id ?? "") + running: FocusModes.activeMode?.id === String(modeRow.modelData.id ?? "") + onActivated: root.expandedMode = modeRow.expanded + ? "" + : String(modeRow.modelData.id ?? "") + onAllowToggled: (appId, allowed) => + root.toggleAllowed(modeRow.modelData, appId, allowed) + } + } + + Item { + width: parent.width + height: 58 + + Rectangle { + id: newMode + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + height: 44 + radius: Theme.cardRadius + color: Theme.alpha(Theme.fg, newModeHover.hovered ? 0.06 : 0.02) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.16) + + Text { + anchors.centerIn: parent + text: "+ New focus mode" + color: newModeHover.hovered ? Theme.fg : Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + } + + HoverHandler { + id: newModeHover + cursorShape: Qt.PointingHandCursor + } + + // Created open, so the first thing anyone sees is the editor + // rather than a row named "New mode" with nothing to react to. + TapHandler { + onTapped: root.expandedMode = String(FocusModes.createMode("New mode")) + } + } + } + } + + SettingsCard { + title: "Focus sessions" + subtitle: "A timed, workspace-bound sprint — separate from modes, and it owns Do Not Disturb while it runs." + + SegmentRow { + label: "Default duration" + detail: "Used by Super+Shift+F and Quick Settings" + options: [ + { value: 25, label: "25 min" }, + { value: 45, label: "45 min" }, + { value: 60, label: "60 min" }, + { value: 90, label: "90 min" } + ] + value: DesktopPreferences.get("focusDurationMinutes") + onSelected: value => SystemSettings.commitPreference("focusDurationMinutes", value) + } + + SettingRow { + label: "Keep the machine awake" + detail: Caffeine.enabled + ? "The display will not blank or lock while this is on" + : "Caffeine — outside of sessions and modes too" + controlWidth: 48 + + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: Caffeine.enabled + onToggled: value => Caffeine.enabled = value + } + } + + SettingRow { + label: FocusSession.active + ? `Active on ${FocusSession.workspaceLabel}` + : "No session running" + detail: FocusSession.active + ? `${FocusSession.remainingText} remaining` + : "Start one without leaving Settings" + divider: false + controlWidth: 120 + + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: FocusSession.active ? "Show controls" : "Start focus" + tone: FocusSession.active ? "normal" : "accent" + onClicked: FocusSession.reveal() + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/NotificationAppRow.qml b/config/dot/quickshell/modules/settings/NotificationAppRow.qml new file mode 100644 index 0000000..866fb57 --- /dev/null +++ b/config/dot/quickshell/modules/settings/NotificationAppRow.qml @@ -0,0 +1,297 @@ +// One application's notification rule: a line you can read, unfolding in place +// into the four controls that make up the rule. +// +// The head is hand-built rather than a SettingRow because it needs two things +// SettingRow has no room for -- the application's own icon, and a subtitle +// whose second half is coloured when the rule is no longer the default, so a +// muted or redirected application is obvious without opening it. +// +// Every write leaves through `edited`/`forgotten`. The page owns the service +// calls, so a rule changed here goes down exactly the same path as one changed +// anywhere else. + +import QtQuick +import Quickshell +import Quickshell.Widgets +import qs.config + +Column { + id: root + + required property string appId + required property string appName + + // Notifs.appRule(appId), passed in as a binding so a rule changed elsewhere + // still reaches this row. + required property var rule + + // Relative last-seen ("Today", "3 days ago"), or "" when nothing has been + // recorded for this application yet. + property string lastSeen: "" + + // The live-resolved icon name from Notifs.applications. Falls back to the + // one cached on the rule, which is what a no-longer-installed application + // still has. + property string iconName: "" + + property bool expanded: false + property bool divider: true + + signal activated + signal edited(var patch) + signal forgotten + + // What is different from the defaults, in the page's own words. Empty when + // the rule is untouched, which is what puts the raw id in the subtitle + // instead -- an honest identity beats an empty half-line. + readonly property string stateSummary: { + const parts = []; + if (root.rule.enabled === false) + parts.push("Notifications off"); + if (root.rule.display === "history") + parts.push("History only"); + if (root.rule.sound === false) + parts.push("sound off"); + if (root.rule.urgency === "low") + parts.push("treat as low"); + else if (root.rule.urgency === "critical") + parts.push("treat as critical"); + return parts.join(" · "); + } + + // Nothing resolvable leaves this empty, and the letter tile below shows + // through instead of a broken image. + readonly property string iconSource: { + const name = root.iconName !== "" ? root.iconName : String(root.rule.icon ?? ""); + return name === "" ? "" : Quickshell.iconPath(name, true); + } + + // A stable colour per application, so the tile is a recognisable shape + // rather than a different colour every time the list re-sorts. + readonly property color tileColor: { + const palette = [Theme.accent, Theme.teal, Theme.magenta, Theme.cyan, + Theme.green, Theme.orange, Theme.pink]; + let hash = 0; + for (let index = 0; index < root.appId.length; index++) + hash = (hash * 31 + root.appId.charCodeAt(index)) % 9973; + return palette[hash % palette.length]; + } + + width: parent ? parent.width : 620 + spacing: 0 + + Item { + id: head + + width: parent.width + implicitHeight: Math.max(56, copy.implicitHeight + 20) + + Rectangle { + anchors.fill: parent + anchors.bottomMargin: 1 + radius: 9 + z: -1 + visible: headHover.hovered + color: Theme.alpha(Theme.fg, 0.05) + border.width: 0 + } + + Rectangle { + id: tile + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 30 + height: 30 + radius: 8 + color: root.iconSource === "" ? root.tileColor : "transparent" + border.width: 0 + + Text { + anchors.centerIn: parent + visible: root.iconSource === "" + text: root.appName.length > 0 ? root.appName.charAt(0).toUpperCase() : "?" + color: Theme.bgDark + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Bold + } + + IconImage { + anchors.centerIn: parent + implicitSize: 24 + asynchronous: true + visible: root.iconSource !== "" + source: root.iconSource + } + } + + Column { + id: copy + + anchors.left: tile.right + anchors.leftMargin: 12 + anchors.right: controls.left + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Text { + width: parent.width + text: root.appName + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + elide: Text.ElideRight + } + + Row { + width: parent.width + spacing: 0 + + Text { + id: seenLabel + visible: root.lastSeen !== "" + width: visible ? implicitWidth : 0 + text: root.lastSeen + " · " + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + Text { + width: Math.max(0, parent.width - seenLabel.width) + text: root.stateSummary === "" ? root.appId : root.stateSummary + color: root.stateSummary === "" ? Theme.fgDim : Theme.warn + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + } + } + + Row { + id: controls + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 11 + + SettingsToggle { + anchors.verticalCenter: parent.verticalCenter + checked: root.rule.enabled !== false + onToggled: value => root.edited({ enabled: value }) + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.expanded ? "▴" : "▾" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + Rectangle { + anchors.left: copy.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + visible: root.divider && !root.expanded + color: Theme.alpha(Theme.fg, 0.065) + } + + HoverHandler { + id: headHover + cursorShape: Qt.PointingHandCursor + } + + // The whole row is the expand target except the trailing controls: a tap + // meant for the switch must not also unfold the row underneath it. + TapHandler { + onTapped: eventPoint => { + if (eventPoint.position.x >= controls.x) + return; + root.activated(); + } + } + } + + // Indented under the head, so the controls read as belonging to the + // application above them rather than to the card. + Column { + id: body + + x: 42 + width: Math.max(0, parent.width - 42) + visible: root.expanded + + SettingRow { + width: parent.width + label: "Play sound" + detail: "The notification chime, for this application only" + controlWidth: 48 + + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: root.rule.sound !== false + onToggled: value => root.edited({ sound: value }) + } + } + + OptionPickerRow { + width: parent.width + label: "Show as" + options: [ + { + value: "banners", + label: "Banners & history", + detail: "A banner on arrival, kept in the notification center afterwards" + }, + { + value: "history", + label: "History only", + detail: "Filed in the notification center with no banner and no sound" + } + ] + current: String(root.rule.display ?? "banners") + onPicked: value => root.edited({ display: value }) + } + + OptionPickerRow { + width: parent.width + label: "Urgency" + detail: "Override what the app claims its notifications are" + options: [ + { + value: "auto", + label: "App decides", + detail: "Whatever urgency the notification arrives with" + }, + { + value: "low", + label: "Treat as low", + detail: "Never chimes, and never breaks through Do Not Disturb" + }, + { + value: "critical", + label: "Treat as critical", + detail: "Marked urgent, and kept up for the critical duration" + } + ] + current: String(root.rule.urgency ?? "auto") + onPicked: value => root.edited({ urgency: value }) + } + + ActionRow { + width: parent.width + label: "Forget this app" + detail: "Remove its rule; it returns on its next notification" + action: "Forget" + divider: false + onTriggered: root.forgotten() + } + } +}