┌─ QML ──────────────────────────────────────────────────────────────────────┐
│ import QtQuick │
│ import Quickshell │
│ │
│ // ascii tmux-pane card (urgency-colored ╭─╮ border) wrapped in a hyprland-style │
│ // window frame: neutral 2px border + sharp drop shadow (values from hyprland). │
│ // ┌ window frame (neutral) ─────────┐ │
│ // │ ╭─ AppName ───────────╮ │ │
│ // │ │ Summary │ │ │
│ // │ │ body... │ │ │
│ // │ ╰─────────────────────╯ │ │
│ // └─────────────────────────────────┘ │
│ Item { │
│ id: card │
│ required property var notif │
│ │
│ readonly property int cols: Config.notifColumns │
│ readonly property int innerW: cols - 4 // text area between "│ " and " │" │
│ readonly property int pad: Config.notifPadding │
│ readonly property var g: Config.notifGlyphs │
│ readonly property var c: Config.notifColors │
│ readonly property var sh: Config.notifShadow │
│ │
│ // per-app overrides (brand color / layout), keyed by appName │
│ readonly property var appRule: Config.notifApps[(notif.appName || "").toLowerC │
│ ase()] ?? ({}) │
│ │
│ // ascii border color: app brand color if set, else urgency │
│ readonly property color borderColor: { │
│ if (appRule.border && appRule.border.length) return appRule.border; │
│ const u = notif.urgency; │
│ const uc = c.urgency ?? ({}); │
│ return u === 2 ? uc.critical : u === 0 ? uc.low : uc.normal; │
│ } │
│ │
│ readonly property int timeoutMs: { │
│ if (notif.expireTimeout > 0) return notif.expireTimeout; │
│ if (notif.expireTimeout === 0) return 0; │
│ const u = notif.urgency; │
│ return u === 2 ? Config.notifTimeoutCritical │
│ : u === 0 ? Config.notifTimeoutLow │
│ : Config.notifTimeoutNormal; │
│ } │
│ │
│ // strip unicode bidi controls/isolates (discord wraps names + mentions in │
│ // them); they render as tofu in the mono card │
│ function clean(s) { │
│ return (s || "").replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, ""); │
│ } │
│ function rep(ch, n) { │
│ return n > 0 ? ch.repeat(n) : ""; │
│ } │
│ function padEnd(s, n) { │
│ s = s.substring(0, n); │
│ return s + rep(" ", n - s.length); │
│ } │
│ function wrap(t, w) { │
│ const words = (t || "").trim().split(/\s+/).filter(x => x.length); │
│ const lines = []; │
│ let cur = ""; │
│ for (let word of words) { │
│ while (word.length > w) { │
│ if (cur.length) { │
│ lines.push(cur); │
│ cur = ""; │
│ } │
│ lines.push(word.substring(0, w)); │
│ word = word.substring(w); │
│ } │
│ if (!cur.length) cur = word; │
│ else if (cur.length + 1 + word.length <= w) cur += " " + word; │
│ else { │
│ lines.push(cur); │
│ cur = word; │
│ } │
│ } │
│ if (cur.length) lines.push(cur); │
│ return lines; │
│ } │
│ │
│ // sender name parsed from the summary. discord summaries are │
│ // "Name (#channel, Category)" - keep just the name │
│ readonly property string senderName: { │
│ let s = clean(notif.summary).trim(); │
│ const p = s.indexOf(" ("); │
│ return p !== -1 ? s.substring(0, p) : s; │
│ } │
│ │
│ readonly property string appLabel: { │
│ let a = appRule.titleFromName && senderName.length │
│ ? senderName │
│ : (notif.appName || "notify").toLowerCase(); │
│ const maxLen = cols - 6; │
│ return a.length > maxLen ? a.substring(0, maxLen) : a; │
│ } │
│ readonly property string topRight: " " + rep(g.horizontal, Math.max(0, cols - │
│ 5 - appLabel.length)) + g.topRight │
│ readonly property string bottomLine: g.bottomLeft + rep(g.horizontal, cols - 2 │
│ ) + g.bottomRight │
│ │
│ readonly property var bodyLines: { │
│ let lines = []; │
│ const summary = clean(notif.summary).trim(); │
│ const body = clean(notif.body); │
│ // titleFromName apps (discord) put the sender in the header, so the summa │
│ ry │
│ // is just name + channel context - only the message body goes in the card │
│ const showSummary = !appRule.titleFromName; │
│ // some apps (satty etc.) set the title to their own name, which the heade │
│ r │
│ // already shows - drop it from the body unless it's all we have │
│ const dupTitle = summary.toLowerCase() === (notif.appName || "").trim().to │
│ LowerCase(); │
│ if (showSummary && summary.length && !(dupTitle && body.length)) lines = l │
│ ines.concat(wrap(summary, innerW)); │
│ if (body.length) lines = lines.concat(wrap(body, innerW)); │
│ const max = Config.notifMaxBodyLines; │
│ if (lines.length > max) { │
│ lines = lines.slice(0, max); │
│ lines[max - 1] = padEnd(lines[max - 1], innerW - 1) + "..."; │
│ } │
│ if (!lines.length) lines = [""]; │
│ return lines; │
│ } │
│ │
│ // some apps (notably satty via GIO/GNotification) deliver their themed app │
│ // icon through the image-path hint instead of app_icon, so quickshell exposes │
│ // it as `image`. don't blow a tiny icon up to full width and hide the body - │
│ // only real content (screenshots, album art) lives outside an icon theme dir. │
│ readonly property string imageSrc: notif.image || "" │
│ readonly property bool imageIsAppIcon: imageSrc.indexOf("/icons/") !== -1 || i │
│ mageSrc.indexOf("/pixmaps/") !== -1 │
│ readonly property bool hasImage: imageSrc.length > 0 && !imageIsAppIcon && !ap │
│ pRule.hideImage │
│ │
│ // a notification is "editable" (click to open an editor) if it carries the │
│ // x-ookshell-edit hint, or falls back to a screenshot app with a file image │
│ readonly property string editPath: { │
│ if (notif.hints && notif.hints["x-ookshell-edit"]) │
│ return String(notif.hints["x-ookshell-edit"]); │
│ if ((notif.appName || "").toLowerCase() === "screenshot") { │
│ const im = notif.image || ""; │
│ if (im.startsWith("file://")) return im.substring(7); │
│ } │
│ return ""; │
│ } │
│ readonly property bool editable: editPath.length > 0 && Config.notifImageEdito │
│ r.length > 0 │
│ │
│ readonly property var actions: notif.actions || [] │
│ readonly property int actionsLen: { │
│ let n = 0; │
│ for (let i = 0; i < actions.length; i++) │
│ n += (i > 0 ? 1 : 0) + 4 + actions[i].text.length; │
│ return n; │
│ } │
│ │
│ component Mono: Text { │
│ font.family: Config.fontFamily │
│ font.pixelSize: Config.fontSize │
│ textFormat: Text.PlainText │
│ } │
│ │
│ implicitWidth: frame.width + (sh.enabled ? Math.max(0, sh.offsetX ?? 0) : 0) │
│ implicitHeight: frame.height + (sh.enabled ? Math.max(0, sh.offsetY ?? 0) : 0) │
│ │
│ // sharp drop shadow (solid offset rect, matches hyprland sharp shadow) │
│ Rectangle { │
│ visible: card.sh.enabled ?? false │
│ x: card.sh.offsetX ?? 0 │
│ y: card.sh.offsetY ?? 0 │
│ width: frame.width │
│ height: frame.height │
│ radius: frame.radius │
│ color: card.sh.color ?? "#000000" │
│ } │
│ │
│ // hyprland-style window frame (neutral border + bg) │
│ Rectangle { │
│ id: frame │
│ width: box.implicitWidth + card.pad * 2 │
│ height: box.implicitHeight + card.pad * 2 │
│ color: card.c.background │
│ radius: Config.notifRounding │
│ border.width: Config.notifBorderWidth │
│ border.color: Config.notifFrameBorder │
│ │
│ MouseArea { │
│ anchors.fill: parent │
│ acceptedButtons: Qt.LeftButton | Qt.RightButton │
│ cursorShape: card.editable ? Qt.PointingHandCursor : Qt.ArrowCursor │
│ onClicked: mouse => { │
│ if (mouse.button === Qt.RightButton) { │
│ card.notif.dismiss(); │
│ return; │
│ } │
│ if (card.editable) { │
│ Quickshell.execDetached(Config.notifImageEditor.concat([card.e │
│ ditPath])); │
│ card.notif.dismiss(); │
│ return; │
│ } │
│ const def = card.actions.find(a => a.identifier === "default"); │
│ if (def) def.invoke(); │
│ card.notif.dismiss(); │
│ } │
│ } │
│ │
│ // the ascii box, urgency-colored │
│ Column { │
│ id: box │
│ x: card.pad │
│ y: card.pad │
│ │
│ // top: ╭─ AppName ───────╮ │
│ Row { │
│ Mono { │
│ color: card.borderColor │
│ text: card.g.topLeft + card.g.horizontal + " " │
│ } │
│ Mono { │
│ color: card.c.title │
│ text: card.appLabel │
│ } │
│ Mono { │
│ color: card.borderColor │
│ text: card.topRight │
│ } │
│ } │
│ │
│ // body: │ text │ │
│ Repeater { │
│ model: card.bodyLines │
│ delegate: Row { │
│ required property var modelData │
│ visible: !card.hasImage │
│ Mono { │
│ color: card.borderColor │
│ text: card.g.vertical + " " │
│ } │
│ Mono { │
│ color: card.c.text │
│ text: card.padEnd(modelData, card.innerW) │
│ } │
│ Mono { │
│ color: card.borderColor │
│ text: " " + card.g.vertical │
│ } │
│ } │
│ } │
│ │
│ // image (screenshots, album art) spans the box width │
│ Image { │
│ id: shot │
│ visible: card.hasImage │
│ width: bottomBorder.implicitWidth │
│ height: implicitWidth > 0 ? width * implicitHeight / implicitWidth │
│ : 0 │
│ source: card.hasImage ? card.imageSrc : "" │
│ sourceSize.width: 700 │
│ fillMode: Image.PreserveAspectFit │
│ smooth: true │
│ mipmap: true │
│ } │
│ │
│ // actions: │ [ Reply ] │ │
│ Row { │
│ visible: !card.hasImage && card.actions.length > 0 │
│ Mono { │
│ color: card.borderColor │
│ text: card.g.vertical + " " │
│ } │
│ Repeater { │
│ model: card.actions │
│ delegate: Mono { │
│ required property var modelData │
│ required property int index │
│ color: card.c.action │
│ text: (index > 0 ? " " : "") + "[ " + modelData.text + " ] │
│ " │
│ MouseArea { │
│ anchors.fill: parent │
│ onClicked: { │
│ modelData.invoke(); │
│ card.notif.dismiss(); │
│ } │
│ } │
│ } │
│ } │
│ Mono { │
│ color: card.borderColor │
│ text: card.rep(" ", Math.max(0, card.innerW - card.actionsLen) │
│ ) + " " + card.g.vertical │
│ } │
│ } │
│ │
│ // bottom: └──────────────┘ │
│ Mono { │
│ id: bottomBorder │
│ color: card.borderColor │
│ text: card.bottomLine │
│ } │
│ } │
│ } │
│ │
│ HoverHandler { │
│ id: hover │
│ } │
│ │
│ Timer { │
│ interval: card.timeoutMs │
│ running: card.timeoutMs > 0 && !hover.hovered │
│ onTriggered: card.notif.expire() │
│ } │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ QML ────────────────────────────────┐
│ import QtQuick │
│ import Quickshell │
│ │
│ // ascii tmux-pane card (urgency-colored ╭─╮ │
│ border) wrapped in a hyprland-style │
│ // window frame: neutral 2px border + sharp │
│ drop shadow (values from hyprland). │
│ // ┌ window frame (neutral) ─────────┐ │
│ // │ ╭─ AppName ───────────╮ │ │
│ // │ │ Summary │ │ │
│ // │ │ body... │ │ │
│ // │ ╰─────────────────────╯ │ │
│ // └─────────────────────────────────┘ │
│ Item { │
│ id: card │
│ required property var notif │
│ │
│ readonly property int cols: Config.notif │
│ Columns │
│ readonly property int innerW: cols - 4 / │
│ / text area between "│ " and " │" │
│ readonly property int pad: Config.notifP │
│ adding │
│ readonly property var g: Config.notifGly │
│ phs │
│ readonly property var c: Config.notifCol │
│ ors │
│ readonly property var sh: Config.notifSh │
│ adow │
│ │
│ // per-app overrides (brand color / layo │
│ ut), keyed by appName │
│ readonly property var appRule: Config.no │
│ tifApps[(notif.appName || "").toLowerCase()] │
│ ?? ({}) │
│ │
│ // ascii border color: app brand color i │
│ f set, else urgency │
│ readonly property color borderColor: { │
│ if (appRule.border && appRule.border │
│ .length) return appRule.border; │
│ const u = notif.urgency; │
│ const uc = c.urgency ?? ({}); │
│ return u === 2 ? uc.critical : u === │
│ 0 ? uc.low : uc.normal; │
│ } │
│ │
│ readonly property int timeoutMs: { │
│ if (notif.expireTimeout > 0) return │
│ notif.expireTimeout; │
│ if (notif.expireTimeout === 0) retur │
│ n 0; │
│ const u = notif.urgency; │
│ return u === 2 ? Config.notifTimeout │
│ Critical │
│ : u === 0 ? Config.notifTimeoutL │
│ ow │
│ : Config.notifTimeoutNormal; │
│ } │
│ │
│ // strip unicode bidi controls/isolates │
│ (discord wraps names + mentions in │
│ // them); they render as tofu in the mon │
│ o card │
│ function clean(s) { │
│ return (s || "").replace(/[\u200e\u2 │
│ 00f\u202a-\u202e\u2066-\u2069]/g, ""); │
│ } │
│ function rep(ch, n) { │
│ return n > 0 ? ch.repeat(n) : ""; │
│ } │
│ function padEnd(s, n) { │
│ s = s.substring(0, n); │
│ return s + rep(" ", n - s.length); │
│ } │
│ function wrap(t, w) { │
│ const words = (t || "").trim().split │
│ (/\s+/).filter(x => x.length); │
│ const lines = []; │
│ let cur = ""; │
│ for (let word of words) { │
│ while (word.length > w) { │
│ if (cur.length) { │
│ lines.push(cur); │
│ cur = ""; │
│ } │
│ lines.push(word.substring(0, │
│ w)); │
│ word = word.substring(w); │
│ } │
│ if (!cur.length) cur = word; │
│ else if (cur.length + 1 + word.l │
│ ength <= w) cur += " " + word; │
│ else { │
│ lines.push(cur); │
│ cur = word; │
│ } │
│ } │
│ if (cur.length) lines.push(cur); │
│ return lines; │
│ } │
│ │
│ // sender name parsed from the summary. │
│ discord summaries are │
│ // "Name (#channel, Category)" - keep ju │
│ st the name │
│ readonly property string senderName: { │
│ let s = clean(notif.summary).trim(); │
│ const p = s.indexOf(" ("); │
│ return p !== -1 ? s.substring(0, p) │
│ : s; │
│ } │
│ │
│ readonly property string appLabel: { │
│ let a = appRule.titleFromName && sen │
│ derName.length │
│ ? senderName │
│ : (notif.appName || "notify").to │
│ LowerCase(); │
│ const maxLen = cols - 6; │
│ return a.length > maxLen ? a.substri │
│ ng(0, maxLen) : a; │
│ } │
│ readonly property string topRight: " " + │
│ rep(g.horizontal, Math.max(0, cols - 5 - ap │
│ pLabel.length)) + g.topRight │
│ readonly property string bottomLine: g.b │
│ ottomLeft + rep(g.horizontal, cols - 2) + g. │
│ bottomRight │
│ │
│ readonly property var bodyLines: { │
│ let lines = []; │
│ const summary = clean(notif.summary) │
│ .trim(); │
│ const body = clean(notif.body); │
│ // titleFromName apps (discord) put │
│ the sender in the header, so the summary │
│ // is just name + channel context - │
│ only the message body goes in the card │
│ const showSummary = !appRule.titleFr │
│ omName; │
│ // some apps (satty etc.) set the ti │
│ tle to their own name, which the header │
│ // already shows - drop it from the │
│ body unless it's all we have │
│ const dupTitle = summary.toLowerCase │
│ () === (notif.appName || "").trim().toLowerC │
│ ase(); │
│ if (showSummary && summary.length && │
│ !(dupTitle && body.length)) lines = lines.c │
│ oncat(wrap(summary, innerW)); │
│ if (body.length) lines = lines.conca │
│ t(wrap(body, innerW)); │
│ const max = Config.notifMaxBodyLines │
│ ; │
│ if (lines.length > max) { │
│ lines = lines.slice(0, max); │
│ lines[max - 1] = padEnd(lines[ma │
│ x - 1], innerW - 1) + "..."; │
│ } │
│ if (!lines.length) lines = [""]; │
│ return lines; │
│ } │
│ │
│ // some apps (notably satty via GIO/GNot │
│ ification) deliver their themed app │
│ // icon through the image-path hint inst │
│ ead of app_icon, so quickshell exposes │
│ // it as `image`. don't blow a tiny icon │
│ up to full width and hide the body - │
│ // only real content (screenshots, album │
│ art) lives outside an icon theme dir. │
│ readonly property string imageSrc: notif │
│ .image || "" │
│ readonly property bool imageIsAppIcon: i │
│ mageSrc.indexOf("/icons/") !== -1 || imageSr │
│ c.indexOf("/pixmaps/") !== -1 │
│ readonly property bool hasImage: imageSr │
│ c.length > 0 && !imageIsAppIcon && !appRule. │
│ hideImage │
│ │
│ // a notification is "editable" (click t │
│ o open an editor) if it carries the │
│ // x-ookshell-edit hint, or falls back t │
│ o a screenshot app with a file image │
│ readonly property string editPath: { │
│ if (notif.hints && notif.hints["x-oo │
│ kshell-edit"]) │
│ return String(notif.hints["x-ook │
│ shell-edit"]); │
│ if ((notif.appName || "").toLowerCas │
│ e() === "screenshot") { │
│ const im = notif.image || ""; │
│ if (im.startsWith("file://")) re │
│ turn im.substring(7); │
│ } │
│ return ""; │
│ } │
│ readonly property bool editable: editPat │
│ h.length > 0 && Config.notifImageEditor.leng │
│ th > 0 │
│ │
│ readonly property var actions: notif.act │
│ ions || [] │
│ readonly property int actionsLen: { │
│ let n = 0; │
│ for (let i = 0; i < actions.length; │
│ i++) │
│ n += (i > 0 ? 1 : 0) + 4 + actio │
│ ns[i].text.length; │
│ return n; │
│ } │
│ │
│ component Mono: Text { │
│ font.family: Config.fontFamily │
│ font.pixelSize: Config.fontSize │
│ textFormat: Text.PlainText │
│ } │
│ │
│ implicitWidth: frame.width + (sh.enabled │
│ ? Math.max(0, sh.offsetX ?? 0) : 0) │
│ implicitHeight: frame.height + (sh.enabl │
│ ed ? Math.max(0, sh.offsetY ?? 0) : 0) │
│ │
│ // sharp drop shadow (solid offset rect, │
│ matches hyprland sharp shadow) │
│ Rectangle { │
│ visible: card.sh.enabled ?? false │
│ x: card.sh.offsetX ?? 0 │
│ y: card.sh.offsetY ?? 0 │
│ width: frame.width │
│ height: frame.height │
│ radius: frame.radius │
│ color: card.sh.color ?? "#000000" │
│ } │
│ │
│ // hyprland-style window frame (neutral │
│ border + bg) │
│ Rectangle { │
│ id: frame │
│ width: box.implicitWidth + card.pad │
│ * 2 │
│ height: box.implicitHeight + card.pa │
│ d * 2 │
│ color: card.c.background │
│ radius: Config.notifRounding │
│ border.width: Config.notifBorderWidt │
│ h │
│ border.color: Config.notifFrameBorde │
│ r │
│ │
│ MouseArea { │
│ anchors.fill: parent │
│ acceptedButtons: Qt.LeftButton | │
│ Qt.RightButton │
│ cursorShape: card.editable ? Qt. │
│ PointingHandCursor : Qt.ArrowCursor │
│ onClicked: mouse => { │
│ if (mouse.button === Qt.Righ │
│ tButton) { │
│ card.notif.dismiss(); │
│ return; │
│ } │
│ if (card.editable) { │
│ Quickshell.execDetached( │
│ Config.notifImageEditor.concat([card.editPat │
│ h])); │
│ card.notif.dismiss(); │
│ return; │
│ } │
│ const def = card.actions.fin │
│ d(a => a.identifier === "default"); │
│ if (def) def.invoke(); │
│ card.notif.dismiss(); │
│ } │
│ } │
│ │
│ // the ascii box, urgency-colored │
│ Column { │
│ id: box │
│ x: card.pad │
│ y: card.pad │
│ │
│ // top: ╭─ AppName ───────╮ │
│ Row { │
│ Mono { │
│ color: card.borderColor │
│ text: card.g.topLeft + c │
│ ard.g.horizontal + " " │
│ } │
│ Mono { │
│ color: card.c.title │
│ text: card.appLabel │
│ } │
│ Mono { │
│ color: card.borderColor │
│ text: card.topRight │
│ } │
│ } │
│ │
│ // body: │ text │ │
│ Repeater { │
│ model: card.bodyLines │
│ delegate: Row { │
│ required property var mo │
│ delData │
│ visible: !card.hasImage │
│ Mono { │
│ color: card.borderCo │
│ lor │
│ text: card.g.vertica │
│ l + " " │
│ } │
│ Mono { │
│ color: card.c.text │
│ text: card.padEnd(mo │
│ delData, card.innerW) │
│ } │
│ Mono { │
│ color: card.borderCo │
│ lor │
│ text: " " + card.g.v │
│ ertical │
│ } │
│ } │
│ } │
│ │
│ // image (screenshots, album art │
│ ) spans the box width │
│ Image { │
│ id: shot │
│ visible: card.hasImage │
│ width: bottomBorder.implicit │
│ Width │
│ height: implicitWidth > 0 ? │
│ width * implicitHeight / implicitWidth : 0 │
│ source: card.hasImage ? card │
│ .imageSrc : "" │
│ sourceSize.width: 700 │
│ fillMode: Image.PreserveAspe │
│ ctFit │
│ smooth: true │
│ mipmap: true │
│ } │
│ │
│ // actions: │ [ Reply ] │ │
│ Row { │
│ visible: !card.hasImage && c │
│ ard.actions.length > 0 │
│ Mono { │
│ color: card.borderColor │
│ text: card.g.vertical + │
│ " " │
│ } │
│ Repeater { │
│ model: card.actions │
│ delegate: Mono { │
│ required property va │
│ r modelData │
│ required property in │
│ t index │
│ color: card.c.action │
│ text: (index > 0 ? " │
│ " : "") + "[ " + modelData.text + " ]" │
│ MouseArea { │
│ anchors.fill: pa │
│ rent │
│ onClicked: { │
│ modelData.in │
│ voke(); │
│ card.notif.d │
│ ismiss(); │
│ } │
│ } │
│ } │
│ } │
│ Mono { │
│ color: card.borderColor │
│ text: card.rep(" ", Math │
│ .max(0, card.innerW - card.actionsLen)) + " │
│ " + card.g.vertical │
│ } │
│ } │
│ │
│ // bottom: └──────────────┘ │
│ Mono { │
│ id: bottomBorder │
│ color: card.borderColor │
│ text: card.bottomLine │
│ } │
│ } │
│ } │
│ │
│ HoverHandler { │
│ id: hover │
│ } │
│ │
│ Timer { │
│ interval: card.timeoutMs │
│ running: card.timeoutMs > 0 && !hove │
│ r.hovered │
│ onTriggered: card.notif.expire() │
│ } │
│ } │
└──────────────────────────────────────────────┘