| 1 | pragma Singleton |
| 2 | import QtQuick |
| 3 | import Quickshell |
| 4 | import Quickshell.Io |
| 5 | |
| 6 | // Glyph data for CharPicker: chars.json (emoji, nerd font, unicode symbols, kaomoji; built by |
| 7 | // chars-gen.py), use counts in ~/.cache/aurora/chars.json, fancy text styles, copy / type. |
| 8 | // The 900K json is only parsed the first time the picker opens. |
| 9 | Singleton { |
| 10 | id: root |
| 11 | readonly property string home: Quickshell.env("HOME") |
| 12 | property bool loaded: false |
| 13 | property var sets: [] // [{id, label, groups: [label]}] |
| 14 | property var items: [] // [{c, n, k, s, g, t, nl, kl}], t = 5 skin-tone variants or undefined |
| 15 | property var byChar: ({}) |
| 16 | property var counts: ({}) // char -> {n: uses, t: last used ms} |
| 17 | property bool hasWtype: false |
| 18 | |
| 19 | function load() { if (data.path === "") data.path = home + "/.config/quickshell/aurora/chars.json" } |
| 20 | // parse it in the background a bit after startup, so the first SUPER+grave is instant |
| 21 | Timer { interval: 5000; running: true; onTriggered: root.load() } |
| 22 | |
| 23 | FileView { |
| 24 | id: data |
| 25 | printErrors: true |
| 26 | onLoaded: { |
| 27 | var d |
| 28 | try { d = JSON.parse(text()) } catch (e) { console.warn("chars.json:", e); return } |
| 29 | var out = [], by = {}, l = d.items |
| 30 | for (var i = 0; i < l.length; i++) { |
| 31 | var r = l[i] |
| 32 | var it = { i: i, c: r[0], n: r[1], k: r[2], s: r[3], g: r[4], t: r[5], |
| 33 | nl: r[1].toLowerCase(), kl: " " + r[2].toLowerCase() + " " + d.sets[r[3]].groups[r[4]].toLowerCase() + " " } |
| 34 | out.push(it) |
| 35 | if (by[it.c] === undefined) by[it.c] = it |
| 36 | } |
| 37 | root.sets = d.sets |
| 38 | root.byChar = by |
| 39 | root.items = out |
| 40 | root.loaded = true |
| 41 | } |
| 42 | } |
| 43 | FileView { |
| 44 | id: store |
| 45 | path: root.home + "/.cache/aurora/chars.json" |
| 46 | printErrors: false |
| 47 | onLoaded: { try { root.counts = JSON.parse(text()) } catch (e) { root.counts = {} } } |
| 48 | } |
| 49 | Process { |
| 50 | command: ["sh", "-c", "command -v wtype"] |
| 51 | running: true |
| 52 | onExited: code => root.hasWtype = code === 0 |
| 53 | } |
| 54 | |
| 55 | function setIndex(id) { |
| 56 | for (var i = 0; i < sets.length; i++) if (sets[i].id === id) return i |
| 57 | return -1 |
| 58 | } |
| 59 | // the glyph to output for an item: skin tone applied when it has variants |
| 60 | function glyph(it, tone) { return it.t && tone > 0 ? it.t[tone - 1] : it.c } |
| 61 | function hex(c) { |
| 62 | var out = [], i = 0 |
| 63 | while (i < c.length) { |
| 64 | var cp = c.codePointAt(i) |
| 65 | if (cp !== 0xFE0F && cp !== 0x200D) out.push("U+" + cp.toString(16).toUpperCase().padStart(4, "0")) |
| 66 | i += cp > 0xFFFF ? 2 : 1 |
| 67 | } |
| 68 | return out.join(" ") |
| 69 | } |
| 70 | |
| 71 | // ---- search ---- |
| 72 | // sets: list of set indexes to search, group: -1 or a group of a single set. |
| 73 | // Every query word must match the name (fuzzy) or a keyword (word start / substring). |
| 74 | // Also: a pasted glyph, or a codepoint (u+f0001, 0xf0001, f0001). |
| 75 | function search(q, sl, group) { |
| 76 | var out = [], l = items |
| 77 | q = q.trim().toLowerCase() |
| 78 | var inSet = {} |
| 79 | for (var j = 0; j < sl.length; j++) inSet[sl[j]] = true |
| 80 | if (q === "") { |
| 81 | for (var i = 0; i < l.length; i++) |
| 82 | if (inSet[l[i].s] && (group < 0 || l[i].g === group)) out.push(l[i]) |
| 83 | return out |
| 84 | } |
| 85 | var cpm = q.match(/^(?:u\+|0x|\\u)([0-9a-f]{2,6})$/), cpq = -1 |
| 86 | if (cpm) cpq = parseInt(cpm[1], 16) |
| 87 | else if (/^[0-9a-f]{4,6}$/.test(q) && /[0-9]/.test(q)) cpq = parseInt(q, 16) // "face" is a word, "f001" a codepoint |
| 88 | var glyphq = /[^\x00-\x7f]/.test(q) ? q : "" |
| 89 | var words = q.split(/\s+/), scored = [] |
| 90 | for (i = 0; i < l.length; i++) { |
| 91 | var it = l[i] |
| 92 | if (!inSet[it.s] || (group >= 0 && it.g !== group)) continue |
| 93 | var sc = 0 |
| 94 | if (glyphq !== "") { |
| 95 | if (it.c === glyphq || it.c.replace("️", "") === glyphq.replace("️", "")) sc = 5000 |
| 96 | } else if (cpq >= 0 && it.c.codePointAt(0) === cpq && it.c.length <= 2) { |
| 97 | sc = 5000 |
| 98 | } else { |
| 99 | for (j = 0; j < words.length; j++) { |
| 100 | var w = words[j] |
| 101 | var s = Fuzzy.scoreLow(it.nl, w) |
| 102 | if (s < 300 && s < 99) s = 0 // scattered: one skipped letter at most ("thmbs"), not letters strewn over a name |
| 103 | if (s < 450 && it.kl.indexOf(" " + w) >= 0) s = 450 |
| 104 | else if (s < 150 && w.length > 1 && it.kl.indexOf(w) >= 0) s = 150 |
| 105 | if (s === 0) { sc = 0; break } |
| 106 | sc += s |
| 107 | } |
| 108 | if (sc === 0) continue |
| 109 | if (it.nl === q) sc += 800 |
| 110 | sc -= it.nl.length * 0.5 |
| 111 | if (sets[it.s].id === "emoji") sc += 40 // 11k nerd glyphs shouldn't drown the emoji |
| 112 | } |
| 113 | if (sc <= 0) continue |
| 114 | var u = counts[it.c] |
| 115 | if (u) sc += Math.min(u.n, 20) * 15 |
| 116 | scored.push({ it: it, sc: sc }) |
| 117 | } |
| 118 | scored.sort((a, b) => b.sc - a.sc || a.it.i - b.it.i) // ties keep file order (canonical ¯\_(ツ)_/¯ first) |
| 119 | for (i = 0; i < scored.length; i++) out.push(scored[i].it) |
| 120 | return out |
| 121 | } |
| 122 | // most recently used first; kaomoji excluded (they live in their own list view) |
| 123 | function recent(limit) { |
| 124 | var l = [] |
| 125 | for (var c in counts) { |
| 126 | var it = byChar[c] |
| 127 | if (it && sets[it.s].id !== "kao") l.push({ it: it, t: counts[c].t }) |
| 128 | } |
| 129 | l.sort((a, b) => b.t - a.t) |
| 130 | var out = [] |
| 131 | for (var i = 0; i < l.length && i < limit; i++) out.push(l[i].it) |
| 132 | return out |
| 133 | } |
| 134 | // used kaomoji first (by count), rest in file order |
| 135 | function byUse(list) { |
| 136 | var u = [], rest = [] |
| 137 | for (var i = 0; i < list.length; i++) (counts[list[i].c] ? u : rest).push(list[i]) |
| 138 | u.sort((a, b) => counts[b.c].n - counts[a.c].n) |
| 139 | return u.concat(rest) |
| 140 | } |
| 141 | function bump(c) { |
| 142 | var n = {} |
| 143 | for (var k in counts) n[k] = counts[k] |
| 144 | n[c] = { n: (n[c] ? n[c].n : 0) + 1, t: Date.now() } |
| 145 | counts = n |
| 146 | store.setText(JSON.stringify(n)) |
| 147 | } |
| 148 | |
| 149 | // ---- output ---- |
| 150 | function copy(s) { |
| 151 | Quickshell.execDetached(["sh", "-c", 'wl-copy -- "$1" && notify-send -a aurora -t 1500 "copied" "$1"', "sh", s]) |
| 152 | } |
| 153 | // the picker closes first, so give the focus a moment to go back to the app |
| 154 | function type(s) { |
| 155 | Quickshell.execDetached(["sh", "-c", 'sleep 0.15; wtype -- "$1"', "sh", s]) |
| 156 | } |
| 157 | |
| 158 | // ---- fancy text ---- |
| 159 | // [label, A, a, 0] start codepoints in Mathematical Alphanumeric Symbols, holes filled from Letterlike |
| 160 | readonly property var mathStyles: [ |
| 161 | ["bold", 0x1D400, 0x1D41A, 0x1D7CE], ["italic", 0x1D434, 0x1D44E, 0], ["bold italic", 0x1D468, 0x1D482, 0], |
| 162 | ["script", 0x1D49C, 0x1D4B6, 0], ["bold script", 0x1D4D0, 0x1D4EA, 0], |
| 163 | ["fraktur", 0x1D504, 0x1D51E, 0], ["bold fraktur", 0x1D56C, 0x1D586, 0], |
| 164 | ["double-struck", 0x1D538, 0x1D552, 0x1D7D8], ["sans", 0x1D5A0, 0x1D5BA, 0x1D7E2], |
| 165 | ["sans bold", 0x1D5D4, 0x1D5EE, 0x1D7EC], ["sans italic", 0x1D608, 0x1D622, 0], |
| 166 | ["monospace", 0x1D670, 0x1D68A, 0x1D7F6]] |
| 167 | readonly property var holes: ({ |
| 168 | "italic": { h: 0x210E }, |
| 169 | "script": { B: 0x212C, E: 0x2130, F: 0x2131, H: 0x210B, I: 0x2110, L: 0x2112, M: 0x2133, R: 0x211B, |
| 170 | e: 0x212F, g: 0x210A, o: 0x2134 }, |
| 171 | "fraktur": { C: 0x212D, H: 0x210C, I: 0x2111, R: 0x211C, Z: 0x2128 }, |
| 172 | "double-struck": { C: 0x2102, H: 0x210D, N: 0x2115, P: 0x2119, Q: 0x211A, R: 0x211D, Z: 0x2124 } |
| 173 | }) |
| 174 | readonly property string smallCaps: "ᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘǫʀꜱᴛᴜᴠᴡxʏᴢ" |
| 175 | readonly property string flipLower: "ɐqɔpǝɟƃɥᴉɾʞlɯuodbɹsʇnʌʍxʎz" |
| 176 | readonly property var flipOther: ({ "0": "0", "1": "Ɩ", "2": "ᄅ", "3": "Ɛ", "4": "ㄣ", "5": "ϛ", "6": "9", "7": "ㄥ", |
| 177 | "8": "8", "9": "6", ".": "˙", ",": "'", "'": ",", "\"": "„", "?": "¿", "!": "¡", "(": ")", ")": "(", |
| 178 | "[": "]", "]": "[", "{": "}", "}": "{", "<": ">", ">": "<", "_": "‾", "&": "⅋" }) |
| 179 | |
| 180 | function mapLetters(t, f) { |
| 181 | var out = "", l = Array.from(t) // by codepoint, so emoji in the text survive |
| 182 | for (var i = 0; i < l.length; i++) { |
| 183 | var r = f(l[i], l[i].codePointAt(0)) |
| 184 | out += r === null ? l[i] : r |
| 185 | } |
| 186 | return out |
| 187 | } |
| 188 | function fancy(t) { |
| 189 | var out = [], st = mathStyles |
| 190 | for (var i = 0; i < st.length; i++) { |
| 191 | var s = st[i], h = holes[s[0]] || {} |
| 192 | out.push({ c: mapLetters(t, (ch, code) => { |
| 193 | if (h[ch]) return String.fromCodePoint(h[ch]) |
| 194 | if (code >= 65 && code <= 90) return String.fromCodePoint(s[1] + code - 65) |
| 195 | if (code >= 97 && code <= 122) return String.fromCodePoint(s[2] + code - 97) |
| 196 | if (code >= 48 && code <= 57 && s[3]) return String.fromCodePoint(s[3] + code - 48) |
| 197 | return null |
| 198 | }), n: s[0] }) |
| 199 | } |
| 200 | out.push({ n: "small caps", c: mapLetters(t, (ch, code) => { |
| 201 | var c = ch.toLowerCase().charCodeAt(0) |
| 202 | return c >= 97 && c <= 122 ? smallCaps[c - 97] : null |
| 203 | }) }) |
| 204 | out.push({ n: "full width", c: mapLetters(t, (ch, code) => |
| 205 | code === 32 ? " " : (code > 32 && code < 127 ? String.fromCharCode(code + 0xFEE0) : null)) }) |
| 206 | out.push({ n: "circled", c: mapLetters(t, (ch, code) => { |
| 207 | if (code >= 65 && code <= 90) return String.fromCodePoint(0x24B6 + code - 65) |
| 208 | if (code >= 97 && code <= 122) return String.fromCodePoint(0x24D0 + code - 97) |
| 209 | if (code === 48) return "⓪" |
| 210 | if (code >= 49 && code <= 57) return String.fromCodePoint(0x2460 + code - 49) |
| 211 | return null |
| 212 | }) }) |
| 213 | out.push({ n: "negative circled", c: mapLetters(t, (ch, code) => { |
| 214 | var c = ch.toUpperCase().charCodeAt(0) |
| 215 | return c >= 65 && c <= 90 ? String.fromCodePoint(0x1F150 + c - 65) : null |
| 216 | }) }) |
| 217 | out.push({ n: "squared", c: mapLetters(t, (ch, code) => { |
| 218 | var c = ch.toUpperCase().charCodeAt(0) |
| 219 | return c >= 65 && c <= 90 ? String.fromCodePoint(0x1F130 + c - 65) : null |
| 220 | }) }) |
| 221 | out.push({ n: "negative squared", c: mapLetters(t, (ch, code) => { |
| 222 | var c = ch.toUpperCase().charCodeAt(0) |
| 223 | return c >= 65 && c <= 90 ? String.fromCodePoint(0x1F170 + c - 65) : null |
| 224 | }) }) |
| 225 | out.push({ n: "strikethrough", c: mapLetters(t, (ch, code) => code === 32 ? null : ch + "̶") }) |
| 226 | out.push({ n: "underline", c: mapLetters(t, (ch, code) => ch + "̲") }) |
| 227 | out.push({ n: "upside down", c: Array.from(mapLetters(t, (ch, code) => { |
| 228 | var c = ch.toLowerCase().charCodeAt(0) |
| 229 | if (c >= 97 && c <= 122) return flipLower[c - 97] |
| 230 | return flipOther[ch] !== undefined ? flipOther[ch] : null |
| 231 | })).reverse().join("") }) |
| 232 | out.push({ n: "spaced", c: Array.from(t).join(" ") }) |
| 233 | return out |
| 234 | } |
| 235 | } |