// 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) } } } }