Files
Panama/config/dot/quickshell/modules/settings/FocusModeRow.qml
T

548 lines
20 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 application 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 ? " application" : " applications")));
}
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 application 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 {
// `name` is what a screen reader says: four of the seven
// initials repeat, so the letter alone is not a day.
model: [
{ value: 1, label: "M", name: "Monday" },
{ value: 2, label: "T", name: "Tuesday" },
{ value: 3, label: "W", name: "Wednesday" },
{ value: 4, label: "T", name: "Thursday" },
{ value: 5, label: "F", name: "Friday" },
{ value: 6, label: "S", name: "Saturday" },
{ value: 0, label: "S", name: "Sunday" }
]
// Seven of them across, so they share one width rather than
// each taking the width of its own letter.
delegate: SettingsChip {
id: dayPill
required property var modelData
width: 30
text: String(dayPill.modelData.label)
active: (root.trigger.days ?? []).indexOf(dayPill.modelData.value) >= 0
Accessible.name: String(dayPill.modelData.name)
onClicked: 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"
// The permanence is said on the armed press, where it is the
// question being answered, rather than under a button that had
// already done it by the time the sentence was read.
detail: deleteConfirm.armed
? "Removes " + (root.modeName === "" ? "this mode" : root.modeName)
+ (root.allow.length === 0
? ""
: " and its " + root.allow.length
+ (root.allow.length === 1 ? " exception" : " exceptions"))
+ " for good"
: "Takes it out of the list"
controlWidth: deleteConfirm.armed ? 180 : 90
divider: false
ConfirmAction {
id: deleteConfirm
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
actionId: "focus-mode-delete:" + root.modeId
armText: "Delete…"
confirmText: "Delete it"
onConfirmed: FocusModes.removeMode(root.modeId)
}
}
}
}