26f8c8cb9f
A trash button on every rule line (asks first). An effect row opens on click everywhere, with the pointer showing, so the gear is gone.
1147 lines
41 KiB
QML
1147 lines
41 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import qs.Commons
|
|
import qs.Ui
|
|
import "EffectCatalog.js" as Catalog
|
|
|
|
// Bar widget + settings popup.
|
|
//
|
|
// In the bar: a bell, ringing while the effects are armed, asleep and dimmed
|
|
// while paused. Click opens the settings; right-click pauses or resumes
|
|
// without opening anything, like the notification-silencing bell.
|
|
//
|
|
// The popup is three pages deep: the list of rules; one rule (name, words,
|
|
// apps, which effects, cooldown); one effect of that rule (its sliders and
|
|
// choices). Turning an effect on for a rule opens its page right away.
|
|
// Everything is written to ~/.config/attention-required/rules.json through
|
|
// the service (Service.qml); the file stays the source of truth and can
|
|
// still be edited by hand.
|
|
Panel {
|
|
id: root
|
|
moduleName: "alanfortlink.attention-required"
|
|
ipcTarget: "alanfortlink.attention-required"
|
|
implicitWidth: button.implicitWidth
|
|
implicitHeight: button.implicitHeight
|
|
|
|
readonly property var svc: bar && bar.shell ? bar.shell.serviceFor("alanfortlink.attention-required") : null
|
|
readonly property bool paused: svc ? !svc.enabled : false
|
|
readonly property var cfg: svc ? svc.rawConfig : ({})
|
|
readonly property var rules: svc ? svc.rawRules : []
|
|
readonly property bool whileDnd: cfg && cfg.whileDnd !== false
|
|
readonly property bool letThrough: cfg && cfg.letThrough !== false
|
|
readonly property var appSuggestions: svc ? svc.appSuggestions : []
|
|
|
|
readonly property color fg: bar ? bar.foreground : Color.foreground
|
|
readonly property color dim: Qt.darker(fg, 1.4)
|
|
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
|
readonly property int trailInset: Style.space(6)
|
|
readonly property int labelW: Style.space(96)
|
|
readonly property var catalog: Catalog.EFFECTS
|
|
|
|
// ---------- navigation ----------
|
|
property string page: "list" // list | rule | effect
|
|
property int current: -1 // the rule the deeper pages are about
|
|
property string currentEffect: "" // the effect page's type
|
|
property int confirmDeleteIndex: -1
|
|
readonly property var rule: current >= 0 && current < rules.length ? rules[current] : null
|
|
readonly property var effectDef: Catalog.find(currentEffect)
|
|
|
|
function openRule(i) { current = i; page = "rule" }
|
|
function openEffect(type) { currentEffect = type; page = "effect" }
|
|
function back() {
|
|
if (page === "effect") page = "rule"
|
|
else if (page === "rule") page = "list"
|
|
keyCatcher.forceActiveFocus()
|
|
}
|
|
|
|
onOpenedChanged: {
|
|
if (opened && svc) svc.refreshApps()
|
|
if (!opened) { confirmDeleteIndex = -1; page = "list" }
|
|
}
|
|
onRulesChanged: {
|
|
if (current >= rules.length) { current = -1; page = "list" }
|
|
if (confirmDeleteIndex >= rules.length) confirmDeleteIndex = -1
|
|
}
|
|
|
|
// ---------- helpers over a raw rule ----------
|
|
function strList(v) {
|
|
if (Array.isArray(v)) return v.map(function(x) { return String(x) }).filter(function(x) { return x.length > 0 })
|
|
if (v === undefined || v === null || v === "") return []
|
|
return [String(v)]
|
|
}
|
|
function ruleName(r, index) {
|
|
return r && r.name ? String(r.name) : ("rule-" + (index + 1))
|
|
}
|
|
function effectNames(index) {
|
|
if (!svc) return []
|
|
return svc.ruleEffectList(index).map(svc.effectTypeOf).filter(function(n) { return n.length > 0 })
|
|
}
|
|
function ruleSummary(r, index) {
|
|
var words = strList(r ? r.words : [])
|
|
var apps = strList(r ? r.apps : [])
|
|
var effects = effectNames(index).map(Catalog.labelFor)
|
|
return [words.length ? words.join(", ") : "any notification",
|
|
apps.length ? apps.join(", ") : "any app",
|
|
effects.length ? effects.join(", ") : "no effect"].join(" · ")
|
|
}
|
|
function armedLabel() {
|
|
var n = rules.length
|
|
return (paused ? "Paused" : "Armed") + " · " + n + (n === 1 ? " rule" : " rules")
|
|
}
|
|
function fmt(v, step, unit) {
|
|
var n = Number(v)
|
|
var text = step >= 1 ? String(Math.round(n)) : (step >= 0.1 ? n.toFixed(1) : n.toFixed(2))
|
|
return text + (unit || "")
|
|
}
|
|
|
|
// Drives the popup from a terminal, for screenshots and tests:
|
|
// omarchy-shell alanfortlink.attention-required.ui show list
|
|
// omarchy-shell alanfortlink.attention-required.ui show rule 0
|
|
// omarchy-shell alanfortlink.attention-required.ui show effect 0 banner
|
|
IpcHandler {
|
|
target: "alanfortlink.attention-required.ui"
|
|
function show(what: string, index: string, type: string): string {
|
|
var i = Number(index)
|
|
if (what === "rule" && i >= 0) root.openRule(i)
|
|
else if (what === "effect" && i >= 0) { root.current = i; root.openEffect(type) }
|
|
else root.page = "list"
|
|
root.open()
|
|
return root.page
|
|
}
|
|
}
|
|
|
|
// ---------- bar ----------
|
|
BarIconButton {
|
|
id: button
|
|
anchors.fill: parent
|
|
bar: root.bar
|
|
text: root.paused ? "" : ""
|
|
dimmed: root.paused
|
|
tooltipText: (root.paused ? "Attention effects paused" : "Attention effects armed") + " · click: settings · right-click: " + (root.paused ? "resume" : "pause")
|
|
onPressed: function(b) {
|
|
if (b === Qt.RightButton) { if (root.svc) root.svc.setEnabled(root.paused) }
|
|
else root.toggle()
|
|
}
|
|
}
|
|
|
|
// ---------- popup ----------
|
|
KeyboardPanel {
|
|
id: panel
|
|
anchorItem: root
|
|
owner: root
|
|
bar: root.bar
|
|
open: root.opened
|
|
focusTarget: keyCatcher
|
|
contentWidth: panel.fittedContentWidth(Style.space(540))
|
|
contentHeight: panel.fittedContentHeight(topBlock.implicitHeight + Style.space(10) + Math.min(body.implicitHeight, Style.space(640)) + Style.space(4))
|
|
|
|
PanelKeyCatcher {
|
|
id: keyCatcher
|
|
anchors.fill: parent
|
|
blocked: root.confirmDeleteIndex !== -1
|
|
onCloseRequested: root.page === "list" ? root.close() : root.back()
|
|
onTabRequested: function(direction) {
|
|
var n = keyCatcher.activeFocus ? null : keyCatcher.nextItemInFocusChain(direction > 0)
|
|
if (n && n !== keyCatcher) n.forceActiveFocus(Qt.TabFocusReason)
|
|
else if (root.page === "list") root.switchPanel(direction)
|
|
}
|
|
Keys.onPressed: function(event) {
|
|
if (deleteDialog.handleKey(event)) event.accepted = true
|
|
}
|
|
|
|
ConfirmDialog {
|
|
id: deleteDialog
|
|
anchors.fill: parent
|
|
z: 10
|
|
opened: root.confirmDeleteIndex !== -1
|
|
message: "Delete the rule “" + root.ruleName(root.rules[root.confirmDeleteIndex], root.confirmDeleteIndex) + "”?"
|
|
confirmText: "Delete"
|
|
selectedIndex: 0
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onCanceled: root.confirmDeleteIndex = -1
|
|
onConfirmed: {
|
|
var i = root.confirmDeleteIndex
|
|
root.confirmDeleteIndex = -1
|
|
root.page = "list"
|
|
root.current = -1
|
|
if (root.svc) root.svc.removeRule(i)
|
|
keyCatcher.forceActiveFocus()
|
|
}
|
|
}
|
|
Connections {
|
|
target: root
|
|
function onConfirmDeleteIndexChanged() { if (root.confirmDeleteIndex !== -1) { deleteDialog.selectedIndex = 0; keyCatcher.forceActiveFocus() } }
|
|
}
|
|
|
|
// ---------- fixed top: hero, then the page's own header ----------
|
|
Column {
|
|
id: topBlock
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
spacing: Style.space(10)
|
|
|
|
PanelHero {
|
|
width: parent.width
|
|
title: "Attention Required"
|
|
meta: root.armedLabel()
|
|
detail: root.svc && root.svc.configError !== "" ? root.svc.configError : ""
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
iconComponent: Component {
|
|
Text {
|
|
text: root.paused ? "" : ""
|
|
color: root.fg
|
|
opacity: root.paused ? 0.5 : 1
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.display
|
|
}
|
|
}
|
|
trailingControl: Component {
|
|
ToggleSwitch {
|
|
checked: !root.paused
|
|
foreground: root.fg
|
|
cursorPad: root.trailInset
|
|
onToggled: if (root.svc) root.svc.setEnabled(root.paused)
|
|
}
|
|
}
|
|
}
|
|
|
|
PanelSeparator { width: parent.width; foreground: root.fg }
|
|
|
|
// Breadcrumb: where you are, and the way back.
|
|
Item {
|
|
width: parent.width
|
|
height: Style.spacing.controlHeight
|
|
Button {
|
|
id: backButton
|
|
visible: root.page !== "list"
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
iconText: ""
|
|
text: root.page === "effect" ? root.ruleName(root.rule, root.current) : "Rules"
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
tooltipText: "Back (Esc)"
|
|
onClicked: root.back()
|
|
}
|
|
PanelSectionHeader {
|
|
visible: root.page === "list"
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "Rules"
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
}
|
|
Text {
|
|
visible: root.page !== "list"
|
|
anchors.left: backButton.right
|
|
anchors.leftMargin: Style.space(10)
|
|
anchors.right: headerTrailing.left
|
|
anchors.rightMargin: Style.space(8)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.page === "effect" ? (root.effectDef ? root.effectDef.label : root.currentEffect) : root.ruleName(root.rule, root.current)
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
font.weight: Font.DemiBold
|
|
elide: Text.ElideRight
|
|
}
|
|
Row {
|
|
id: headerTrailing
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: root.trailInset
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: Style.space(6)
|
|
PanelActionButton {
|
|
visible: root.page === "list"
|
|
iconText: ""
|
|
tooltipText: "Add a rule"
|
|
bordered: true
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onClicked: if (root.svc) root.openRule(root.svc.addRule())
|
|
}
|
|
Button {
|
|
visible: root.page !== "list"
|
|
text: "Try"
|
|
iconText: ""
|
|
bordered: true
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
tooltipText: root.page === "effect" ? "Run this effect as this rule would" : "Run this rule's effects now"
|
|
onClicked: {
|
|
if (!root.svc) return
|
|
if (root.page === "effect") root.svc.tryRuleEffect(root.current, root.currentEffect)
|
|
else root.svc.tryRule(root.current)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- scrolling body: the page ----------
|
|
Flickable {
|
|
id: scroller
|
|
anchors.top: topBlock.bottom
|
|
anchors.topMargin: Style.space(10)
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
contentWidth: width
|
|
contentHeight: body.implicitHeight
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
interactive: contentHeight > height
|
|
|
|
Column {
|
|
id: body
|
|
width: scroller.width - (scroller.interactive ? Style.space(8) : 0)
|
|
Loader {
|
|
width: parent.width
|
|
sourceComponent: root.page === "rule" ? ruleView : (root.page === "effect" ? effectView : listView)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ======================= page 1: the rules =======================
|
|
Component {
|
|
id: listView
|
|
Column {
|
|
spacing: Style.space(4)
|
|
|
|
Column {
|
|
visible: root.rules.length === 0
|
|
width: parent.width
|
|
spacing: Style.space(2)
|
|
Text {
|
|
text: "No rules yet"
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
Note { text: "Add one with the plus button, then give it the words to watch for and an effect." }
|
|
}
|
|
|
|
Repeater {
|
|
model: root.rules.length
|
|
delegate: RuleLine {
|
|
width: parent.width
|
|
ruleIndex: index
|
|
rule: root.rules[index]
|
|
}
|
|
}
|
|
|
|
Section { title: "While notifications are silenced" }
|
|
SwitchRow {
|
|
label: "Rules still fire"
|
|
checked: root.whileDnd
|
|
onToggled: if (root.svc) root.svc.setWhileDnd(!root.whileDnd)
|
|
}
|
|
SwitchRow {
|
|
label: "Matched notifications are shown anyway"
|
|
checked: root.whileDnd && root.letThrough
|
|
enabled: root.whileDnd
|
|
opacity: root.whileDnd ? 1 : 0.5
|
|
onToggled: if (root.svc && root.whileDnd) root.svc.setLetThrough(!root.letThrough)
|
|
}
|
|
Note { text: "Silence everything with the bell in the bar and only what a rule matches gets through: its effects run and its toast is posted again so you see it." }
|
|
Item { width: 1; height: Style.space(2) }
|
|
}
|
|
}
|
|
|
|
component RuleLine: Item {
|
|
id: line
|
|
property int ruleIndex: -1
|
|
property var rule: ({})
|
|
readonly property bool ruleOn: rule && rule.enabled !== false
|
|
readonly property bool idle: root.effectNames(ruleIndex).length === 0
|
|
height: Style.space(38)
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: Style.space(6)
|
|
color: lineArea.containsMouse ? Util.alpha(root.fg, 0.06) : "transparent"
|
|
}
|
|
ToggleSwitch {
|
|
id: onSwitch
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
checked: line.ruleOn
|
|
foreground: root.fg
|
|
onToggled: if (root.svc) root.svc.updateRule(line.ruleIndex, { enabled: line.ruleOn ? false : undefined })
|
|
}
|
|
Text {
|
|
id: nameText
|
|
anchors.left: onSwitch.right
|
|
anchors.leftMargin: Style.space(10)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.ruleName(line.rule, line.ruleIndex)
|
|
color: root.fg
|
|
opacity: line.ruleOn ? 1 : 0.55
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
font.weight: Font.DemiBold
|
|
elide: Text.ElideRight
|
|
width: Math.min(implicitWidth, Style.space(170))
|
|
}
|
|
Text {
|
|
anchors.left: nameText.right
|
|
anchors.leftMargin: Style.space(10)
|
|
anchors.right: deleteButton.left
|
|
anchors.rightMargin: Style.space(8)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.ruleSummary(line.rule, line.ruleIndex)
|
|
color: line.idle ? Color.urgent : root.dim
|
|
opacity: line.ruleOn ? 1 : 0.55
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
id: chevron
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: root.trailInset + Style.space(6)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: ""
|
|
color: lineArea.containsMouse ? root.fg : root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
MouseArea {
|
|
id: lineArea
|
|
anchors.fill: parent
|
|
anchors.leftMargin: onSwitch.width + Style.space(6)
|
|
z: 1
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.openRule(line.ruleIndex)
|
|
}
|
|
PanelActionButton {
|
|
id: deleteButton
|
|
z: 2
|
|
anchors.right: chevron.left
|
|
anchors.rightMargin: Style.space(4)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
iconText: ""
|
|
tooltipText: "Delete this rule"
|
|
focusable: true
|
|
foreground: root.fg
|
|
hoverColor: Color.urgent
|
|
fontFamily: root.fontFamily
|
|
opacity: lineArea.containsMouse || hovered ? 1 : 0.55
|
|
property bool hovered: false
|
|
onHovered: function(on) { hovered = on }
|
|
onClicked: root.confirmDeleteIndex = line.ruleIndex
|
|
}
|
|
}
|
|
|
|
// ======================= page 2: one rule =======================
|
|
// Controls sit in the order Tab walks them: name, words, match, apps,
|
|
// effects, cooldown, delete.
|
|
Component {
|
|
id: ruleView
|
|
Column {
|
|
id: editor
|
|
spacing: Style.space(10)
|
|
|
|
EditorRow {
|
|
label: "Name"
|
|
ConfigField {
|
|
id: nameField
|
|
width: parent.width - root.trailInset
|
|
placeholderText: "empty: rule-" + (root.current + 1)
|
|
current: root.rule && root.rule.name ? String(root.rule.name) : ""
|
|
commit: function(t) { if (root.svc) root.svc.updateRule(root.current, { name: t.trim() || undefined }) }
|
|
}
|
|
}
|
|
EditorRow {
|
|
label: "Words"
|
|
ChipField {
|
|
width: parent.width
|
|
values: root.strList(root.rule ? root.rule.words : [])
|
|
placeholder: "add a word or /regex/"
|
|
emptyText: "empty: any notification"
|
|
allowRegex: true
|
|
onCommitted: function(v) { if (root.svc) root.svc.updateRule(root.current, { words: v }) }
|
|
}
|
|
}
|
|
EditorRow {
|
|
label: "Match"
|
|
Row {
|
|
spacing: Style.space(10)
|
|
ButtonGroup {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
options: [{ value: "any", label: "Any word" }, { value: "all", label: "All words" }]
|
|
value: root.rule && String(root.rule.match || "").toLowerCase() === "all" ? "all" : "any"
|
|
foreground: root.fg
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onChanged: function(v) { if (root.svc) root.svc.updateRule(root.current, { match: v === "all" ? "all" : undefined }) }
|
|
}
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.rule && String(root.rule.match || "").toLowerCase() === "all" ? "every word must appear" : "one word is enough"
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
}
|
|
}
|
|
EditorRow {
|
|
label: "Apps"
|
|
ChipField {
|
|
width: parent.width
|
|
values: root.strList(root.rule ? root.rule.apps : [])
|
|
placeholder: "add an app"
|
|
emptyText: "empty: any app"
|
|
suggestions: root.appSuggestions
|
|
onCommitted: function(v) { if (root.svc) root.svc.updateRule(root.current, { apps: v }) }
|
|
}
|
|
}
|
|
// The rule's effects, one row each with its settings at a glance, a
|
|
// button to open it and one to drop it; then a button that unfolds
|
|
// the effects not yet on the rule.
|
|
EditorRow {
|
|
label: "Effects"
|
|
Column {
|
|
id: effectsBox
|
|
width: parent.width - root.trailInset
|
|
spacing: Style.space(6)
|
|
property bool adding: false
|
|
readonly property var active: root.effectNames(root.current)
|
|
readonly property var available: root.catalog.filter(function(e) { return effectsBox.active.indexOf(e.type) === -1 })
|
|
|
|
Repeater {
|
|
model: effectsBox.active
|
|
delegate: EffectLine {
|
|
required property string modelData
|
|
width: effectsBox.width
|
|
type: modelData
|
|
}
|
|
}
|
|
|
|
Note {
|
|
visible: effectsBox.active.length === 0
|
|
text: "empty: the rule matches but nothing happens yet"
|
|
}
|
|
|
|
Button {
|
|
text: effectsBox.adding ? "Pick an effect below" : "Add effect"
|
|
iconText: effectsBox.adding ? "" : ""
|
|
bordered: true
|
|
focusable: true
|
|
enabled: effectsBox.available.length > 0
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: effectsBox.adding = !effectsBox.adding
|
|
}
|
|
|
|
Flow {
|
|
visible: effectsBox.adding
|
|
width: parent.width
|
|
spacing: Style.spacing.md
|
|
Repeater {
|
|
model: effectsBox.available
|
|
delegate: Button {
|
|
required property var modelData
|
|
text: modelData.label
|
|
iconText: modelData.icon || ""
|
|
bordered: true
|
|
focusable: true
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
tooltipText: modelData.subtitle
|
|
onClicked: {
|
|
if (!root.svc) return
|
|
effectsBox.adding = false
|
|
root.svc.addRuleEffect(root.current, modelData.type)
|
|
root.openEffect(modelData.type)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
EditorRow {
|
|
label: "Cooldown"
|
|
Row {
|
|
spacing: Style.space(8)
|
|
NumberField {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
label: ""
|
|
from: 0
|
|
to: 3600
|
|
stepSize: 1
|
|
value: root.rule && isFinite(Number(root.rule.cooldown)) ? Math.round(Number(root.rule.cooldown)) : 3
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onModified: function(v) { if (root.svc) root.svc.updateRule(root.current, { cooldown: v }) }
|
|
}
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "seconds before this rule can fire again"
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
}
|
|
}
|
|
|
|
Item { width: 1; height: Style.space(4) }
|
|
Item {
|
|
width: parent.width
|
|
height: Style.spacing.controlHeight
|
|
Button {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: root.trailInset
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "Delete rule"
|
|
iconText: ""
|
|
bordered: true
|
|
focusable: true
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.confirmDeleteIndex = root.current
|
|
}
|
|
}
|
|
Item { width: 1; height: Style.space(2) }
|
|
}
|
|
}
|
|
|
|
// ======================= page 3: one effect of that rule =======================
|
|
Component {
|
|
id: effectView
|
|
Column {
|
|
spacing: Style.space(10)
|
|
readonly property var def: root.effectDef
|
|
|
|
Note { text: def ? def.subtitle : "" }
|
|
|
|
Repeater {
|
|
model: def ? def.rows : []
|
|
delegate: Item {
|
|
id: sliderRow
|
|
required property var modelData
|
|
readonly property real stored: root.svc ? Number(root.svc.ruleEffectOption(root.current, root.currentEffect, modelData.key, modelData.fallback)) : modelData.fallback
|
|
property real shown: stored
|
|
onStoredChanged: if (!slider.dragging) shown = stored
|
|
width: parent.width
|
|
height: Style.spacing.controlHeight
|
|
|
|
RowLabel { anchors.left: parent.left; text: sliderRow.modelData.label }
|
|
PanelSlider {
|
|
id: slider
|
|
bar: root.bar
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: root.labelW
|
|
anchors.right: valueText.left
|
|
anchors.rightMargin: Style.space(10)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
minimum: sliderRow.modelData.min
|
|
maximum: sliderRow.modelData.max
|
|
step: sliderRow.modelData.step
|
|
integer: sliderRow.modelData.step >= 1
|
|
value: isFinite(sliderRow.stored) ? sliderRow.stored : sliderRow.modelData.fallback
|
|
onMoved: function(v) { sliderRow.shown = v }
|
|
onReleased: function(v) {
|
|
sliderRow.shown = v
|
|
var rounded = sliderRow.modelData.step >= 1 ? Math.round(v) : Math.round(v * 100) / 100
|
|
if (root.svc) root.svc.setRuleEffectOption(root.current, root.currentEffect, sliderRow.modelData.key, rounded)
|
|
}
|
|
}
|
|
Text {
|
|
id: valueText
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: root.trailInset
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: Style.space(52)
|
|
horizontalAlignment: Text.AlignRight
|
|
text: root.fmt(sliderRow.shown, sliderRow.modelData.step, sliderRow.modelData.unit)
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
}
|
|
}
|
|
|
|
Repeater {
|
|
model: def ? def.options : []
|
|
delegate: EditorRow {
|
|
id: optionRow
|
|
required property var modelData
|
|
readonly property var stored: root.svc ? root.svc.ruleEffectOption(root.current, root.currentEffect, modelData.key, modelData.fallback) : modelData.fallback
|
|
label: modelData.label
|
|
Loader {
|
|
width: parent.width
|
|
sourceComponent: optionRow.modelData.type === "enum" ? enumOption : textOption
|
|
property var option: optionRow.modelData
|
|
property var stored: optionRow.stored
|
|
}
|
|
}
|
|
}
|
|
|
|
Note {
|
|
visible: def && def.rows.length === 0 && def.options.length === 0
|
|
text: "Nothing to set up for this effect."
|
|
}
|
|
|
|
Item { width: 1; height: Style.space(4) }
|
|
Item {
|
|
width: parent.width
|
|
height: Style.spacing.controlHeight
|
|
Button {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: root.trailInset
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "Remove from this rule"
|
|
iconText: ""
|
|
bordered: true
|
|
focusable: true
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: {
|
|
if (root.svc) root.svc.removeRuleEffect(root.current, root.currentEffect)
|
|
root.back()
|
|
}
|
|
}
|
|
}
|
|
Item { width: 1; height: Style.space(2) }
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: enumOption
|
|
ButtonGroup {
|
|
options: option.values
|
|
value: String(stored === undefined || stored === null ? option.fallback : stored)
|
|
foreground: root.fg
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onChanged: function(v) {
|
|
if (root.svc) root.svc.setRuleEffectOption(root.current, root.currentEffect, option.key, v === option.fallback ? undefined : v)
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: textOption
|
|
ConfigField {
|
|
width: parent.width - root.trailInset
|
|
placeholderText: option.placeholder || ""
|
|
current: stored === undefined || stored === null ? "" : String(stored)
|
|
commit: function(t) {
|
|
if (root.svc) root.svc.setRuleEffectOption(root.current, root.currentEffect, option.key, t.trim() || undefined)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ======================= pieces =======================
|
|
|
|
component Section: Column {
|
|
property string title: ""
|
|
width: parent ? parent.width : 200
|
|
spacing: Style.space(10)
|
|
Item { width: 1; height: Style.space(6) }
|
|
PanelSeparator { width: parent.width; foreground: root.fg }
|
|
PanelSectionHeader { text: parent.title; foreground: root.fg; fontFamily: root.fontFamily }
|
|
}
|
|
|
|
component Note: Text {
|
|
width: parent ? parent.width - root.trailInset : 200
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.Wrap
|
|
}
|
|
|
|
component RowLabel: Text {
|
|
anchors.verticalCenter: parent ? parent.verticalCenter : undefined
|
|
width: root.labelW
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
component SwitchRow: Item {
|
|
id: sw
|
|
property string label: ""
|
|
property bool checked: false
|
|
signal toggled()
|
|
width: parent ? parent.width : 200
|
|
height: Style.spacing.controlHeight
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.right: swToggle.left
|
|
anchors.rightMargin: Style.space(8)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: sw.label
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
elide: Text.ElideRight
|
|
}
|
|
ToggleSwitch {
|
|
id: swToggle
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
cursorPad: root.trailInset
|
|
checked: sw.checked
|
|
foreground: root.fg
|
|
onToggled: sw.toggled()
|
|
}
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.rightMargin: swToggle.width
|
|
onClicked: sw.toggled()
|
|
}
|
|
}
|
|
|
|
// A text field that follows a value but never overwrites what is being
|
|
// typed: it re-reads only while unfocused, commits on Enter or focus loss,
|
|
// Esc puts the old value back, Tab moves on to the next control.
|
|
component ConfigField: TextField {
|
|
id: cf
|
|
property string current: ""
|
|
property var commit: null
|
|
foreground: root.fg
|
|
font.family: root.fontFamily
|
|
onCurrentChanged: if (!activeFocus) text = current
|
|
Component.onCompleted: text = current
|
|
function revert() { text = current }
|
|
onEditingFinished: {
|
|
if (text === current) return
|
|
if (commit) commit(text)
|
|
}
|
|
Keys.onEscapePressed: function(e) { revert(); keyCatcher.forceActiveFocus(); e.accepted = true }
|
|
Keys.onTabPressed: function(e) { var n = cf.nextItemInFocusChain(true); if (n) n.forceActiveFocus(Qt.TabFocusReason); e.accepted = true }
|
|
Keys.onBacktabPressed: function(e) { var n = cf.nextItemInFocusChain(false); if (n) n.forceActiveFocus(Qt.BacktabFocusReason); e.accepted = true }
|
|
}
|
|
|
|
// Chips with a text entry after them. Enter or comma adds what was typed,
|
|
// Backspace on an empty entry takes the last chip back, Esc drops the
|
|
// typing, Tab commits and moves on. With `suggestions`, a short list of
|
|
// matches sits under the field: arrows pick, Enter takes the pick, and a
|
|
// name not in the list is still added as typed.
|
|
component ChipField: Column {
|
|
id: chip
|
|
property var values: []
|
|
property string placeholder: ""
|
|
property string emptyText: ""
|
|
property var suggestions: []
|
|
property bool allowRegex: false
|
|
signal committed(var values)
|
|
width: parent ? parent.width : 200
|
|
spacing: Style.space(4)
|
|
|
|
property int highlight: 0
|
|
readonly property string query: entry.text.trim()
|
|
readonly property var filtered: {
|
|
if (!suggestions || !suggestions.length || !entry.activeFocus) return []
|
|
var q = query.toLowerCase()
|
|
var out = []
|
|
for (var i = 0; i < suggestions.length; i++) {
|
|
var s = suggestions[i]
|
|
if (values.indexOf(s.name) !== -1) continue
|
|
if (q && String(s.name).toLowerCase().indexOf(q) === -1) continue
|
|
out.push(s)
|
|
if (out.length >= 6) break
|
|
}
|
|
return out
|
|
}
|
|
onFilteredChanged: if (highlight >= filtered.length) highlight = 0
|
|
|
|
function add(text) {
|
|
var t = String(text || "").trim()
|
|
if (t.length && values.indexOf(t) === -1) {
|
|
var next = values.slice()
|
|
next.push(t)
|
|
chip.committed(next)
|
|
}
|
|
entry.text = ""
|
|
}
|
|
function removeAt(i) {
|
|
var next = values.slice()
|
|
next.splice(i, 1)
|
|
chip.committed(next)
|
|
}
|
|
// Enter takes the highlighted suggestion, unless what was typed is
|
|
// already exactly one of them, in which case it is taken as typed.
|
|
function accept() {
|
|
var exact = false
|
|
for (var i = 0; i < filtered.length; i++)
|
|
if (String(filtered[i].name).toLowerCase() === query.toLowerCase()) exact = true
|
|
if (filtered.length && !exact && highlight >= 0 && highlight < filtered.length) return add(filtered[highlight].name)
|
|
add(entry.text)
|
|
}
|
|
|
|
Rectangle {
|
|
id: box
|
|
width: parent.width - root.trailInset
|
|
height: flow.implicitHeight + Style.space(8)
|
|
color: "transparent"
|
|
radius: Style.cornerRadius > 0 ? Math.min(Style.cornerRadius, Style.space(6)) : 0
|
|
border.width: Math.max(1, Style.normalBorderWidth)
|
|
border.color: entry.activeFocus ? Color.accent : Util.alpha(root.fg, 0.3)
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.IBeamCursor
|
|
onClicked: entry.forceActiveFocus()
|
|
}
|
|
|
|
Flow {
|
|
id: flow
|
|
anchors.fill: parent
|
|
anchors.margins: Style.space(4)
|
|
spacing: Style.space(4)
|
|
|
|
Repeater {
|
|
model: chip.values
|
|
delegate: Rectangle {
|
|
id: tag
|
|
required property var modelData
|
|
required property int index
|
|
height: Style.space(22)
|
|
width: tagText.implicitWidth + tagClose.width + Style.space(14)
|
|
radius: height / 2
|
|
color: Util.alpha(root.fg, 0.12)
|
|
Text {
|
|
id: tagText
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: Style.space(8)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: String(tag.modelData)
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
Text {
|
|
id: tagClose
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: Style.space(4)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: Style.space(14)
|
|
horizontalAlignment: Text.AlignHCenter
|
|
text: ""
|
|
color: closeArea.containsMouse ? Color.urgent : root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
MouseArea {
|
|
id: closeArea
|
|
anchors.fill: parent
|
|
anchors.margins: -Style.space(4)
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: chip.removeAt(tag.index)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TextInput {
|
|
id: entry
|
|
width: Math.min(flow.width, Math.max(Style.space(230), implicitWidth + Style.space(12)))
|
|
height: Style.space(22)
|
|
activeFocusOnTab: true
|
|
verticalAlignment: TextInput.AlignVCenter
|
|
leftPadding: Style.space(4)
|
|
color: root.fg
|
|
selectionColor: Util.alpha(Color.accent, 0.4)
|
|
selectedTextColor: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
clip: true
|
|
Text {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: parent.leftPadding
|
|
verticalAlignment: Text.AlignVCenter
|
|
visible: !entry.text.length
|
|
text: chip.values.length ? chip.placeholder : (chip.emptyText || chip.placeholder)
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
elide: Text.ElideRight
|
|
}
|
|
onActiveFocusChanged: if (!activeFocus && text.trim().length) chip.add(text)
|
|
Keys.onPressed: function(e) {
|
|
if (e.key === Qt.Key_Return || e.key === Qt.Key_Enter) { chip.accept(); e.accepted = true; return }
|
|
if (e.key === Qt.Key_Comma && !(chip.allowRegex && text.trim().charAt(0) === "/")) { chip.add(text); e.accepted = true; return }
|
|
if (e.key === Qt.Key_Backspace && text.length === 0 && chip.values.length) { chip.removeAt(chip.values.length - 1); e.accepted = true; return }
|
|
if (e.key === Qt.Key_Down && chip.filtered.length) { chip.highlight = (chip.highlight + 1) % chip.filtered.length; e.accepted = true; return }
|
|
if (e.key === Qt.Key_Up && chip.filtered.length) { chip.highlight = (chip.highlight - 1 + chip.filtered.length) % chip.filtered.length; e.accepted = true; return }
|
|
if (e.key === Qt.Key_Escape) { text = ""; keyCatcher.forceActiveFocus(); e.accepted = true; return }
|
|
if (e.key === Qt.Key_Tab) { if (text.trim().length) chip.add(text); var n = entry.nextItemInFocusChain(true); if (n) n.forceActiveFocus(Qt.TabFocusReason); e.accepted = true; return }
|
|
if (e.key === Qt.Key_Backtab) { var p = entry.nextItemInFocusChain(false); if (p) p.forceActiveFocus(Qt.BacktabFocusReason); e.accepted = true; return }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
visible: chip.filtered.length > 0
|
|
width: parent.width - root.trailInset
|
|
height: suggestionCol.implicitHeight + Style.space(6)
|
|
color: Color.popups.background
|
|
radius: Style.cornerRadius > 0 ? Math.min(Style.cornerRadius, Style.space(6)) : 0
|
|
border.width: Math.max(1, Style.normalBorderWidth)
|
|
border.color: Util.alpha(root.fg, 0.2)
|
|
Column {
|
|
id: suggestionCol
|
|
anchors.fill: parent
|
|
anchors.margins: Style.space(3)
|
|
Repeater {
|
|
model: chip.filtered
|
|
delegate: Rectangle {
|
|
id: srow
|
|
required property var modelData
|
|
required property int index
|
|
width: suggestionCol.width
|
|
height: Style.space(26)
|
|
radius: Style.space(4)
|
|
color: (srow.index === chip.highlight || srowArea.containsMouse) ? Util.alpha(root.fg, 0.08) : "transparent"
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: Style.space(8)
|
|
anchors.right: srcText.left
|
|
anchors.rightMargin: Style.space(8)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: String(srow.modelData.name)
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
id: srcText
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: Style.space(8)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: srow.modelData.source === "notified" ? "seen in notifications" : (srow.modelData.source === "running" ? "running" : "installed")
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
MouseArea {
|
|
id: srowArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: { chip.add(srow.modelData.name); entry.forceActiveFocus() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// One effect on the rule: name, its settings in a line, open and remove.
|
|
component EffectLine: Rectangle {
|
|
id: line
|
|
property string type: ""
|
|
readonly property var def: Catalog.find(type)
|
|
height: Style.space(34)
|
|
radius: Style.space(6)
|
|
color: lineArea.containsMouse ? Util.alpha(root.fg, 0.08) : Util.alpha(root.fg, 0.04)
|
|
|
|
function summary() {
|
|
if (!def || !root.svc) return ""
|
|
var parts = []
|
|
for (var i = 0; i < def.rows.length && parts.length < 3; i++) {
|
|
var r = def.rows[i]
|
|
parts.push(root.fmt(root.svc.ruleEffectOption(root.current, line.type, r.key, r.fallback), r.step, r.unit))
|
|
}
|
|
return parts.join(" · ")
|
|
}
|
|
|
|
Text {
|
|
id: lineIcon
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: Style.space(10)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: line.def && line.def.icon ? line.def.icon : ""
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
width: Style.space(18)
|
|
}
|
|
Text {
|
|
id: lineName
|
|
anchors.left: lineIcon.right
|
|
anchors.leftMargin: Style.space(6)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: line.def ? line.def.label : line.type
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
font.weight: Font.DemiBold
|
|
}
|
|
Text {
|
|
anchors.left: lineName.right
|
|
anchors.leftMargin: Style.space(10)
|
|
anchors.right: removeButton.left
|
|
anchors.rightMargin: Style.space(6)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: line.summary()
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
elide: Text.ElideRight
|
|
}
|
|
// The whole row opens the effect; above the texts so the pointer shows
|
|
// everywhere on it, below the remove button so that still wins.
|
|
MouseArea {
|
|
id: lineArea
|
|
anchors.fill: parent
|
|
z: 1
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.openEffect(line.type)
|
|
}
|
|
PanelActionButton {
|
|
id: removeButton
|
|
z: 2
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: Style.space(4)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
iconText: ""
|
|
tooltipText: "Remove from this rule"
|
|
focusable: true
|
|
foreground: root.fg
|
|
hoverColor: Color.urgent
|
|
fontFamily: root.fontFamily
|
|
onClicked: if (root.svc) root.svc.removeRuleEffect(root.current, line.type)
|
|
}
|
|
}
|
|
|
|
component EditorRow: Item {
|
|
property string label: ""
|
|
default property alias content: slot.data
|
|
width: parent ? parent.width : 200
|
|
height: Math.max(slot.childrenRect.height, Style.spacing.controlHeight)
|
|
RowLabel { anchors.left: parent.left; text: parent.label }
|
|
Item {
|
|
id: slot
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: root.labelW
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
height: childrenRect.height
|
|
}
|
|
}
|
|
}
|