546 lines
19 KiB
QML
546 lines
19 KiB
QML
// 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)
|
||
}
|
||
}
|
||
}
|
||
}
|