Add the four components the Focus commit referenced but forgot to ship
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user