1172 lines
42 KiB
QML
1172 lines
42 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
|
|
|
|
property var svc: 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
|
|
|
|
function bindService() {
|
|
var host = bar && bar.shell ? bar.shell : null
|
|
if (!host || typeof host.serviceFor !== "function") return
|
|
var service = host.serviceFor("alanfortlink.attention-required")
|
|
if (service) root.svc = service
|
|
}
|
|
|
|
onBarChanged: {
|
|
root.svc = null
|
|
root.bindService()
|
|
}
|
|
Component.onCompleted: root.bindService()
|
|
Timer {
|
|
interval: 200
|
|
running: root.svc === null
|
|
repeat: true
|
|
onTriggered: root.bindService()
|
|
}
|
|
|
|
// ---------- 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 {
|
|
id: hero
|
|
width: parent.width
|
|
title: "Attention Required"
|
|
meta: root.armedLabel()
|
|
detail: !root.svc ? "Service unavailable" : (root.svc.configError !== "" ? root.svc.configError : "")
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
readonly property bool attentionPaused: root.paused
|
|
readonly property var attentionService: root.svc
|
|
function toggleAttention() {
|
|
if (attentionService) attentionService.setEnabled(attentionPaused)
|
|
}
|
|
iconComponent: Component {
|
|
Text {
|
|
text: hero.attentionPaused ? "" : ""
|
|
color: hero.foreground
|
|
opacity: hero.attentionPaused ? 0.5 : 1
|
|
font.family: hero.fontFamily
|
|
font.pixelSize: Style.font.display
|
|
}
|
|
}
|
|
trailingControl: Component {
|
|
ToggleSwitch {
|
|
checked: !hero.attentionPaused
|
|
foreground: hero.foreground
|
|
cursorPad: Style.space(6)
|
|
onToggled: hero.toggleAttention()
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|