Krolyxon
Commit 4b1d495

Branches

Refs →

aurora.configquickshellauroraCharPicker.qml

550 lines · 24.6 KB · QML

1import QtQuick
2import QtQuick.Layouts
3import Quickshell
4import Quickshell.Wayland
5
6// Glyph picker (SUPER+grave, `chars` IPC): emoji, nerd font glyphs, unicode symbols, kaomoji, fancy text.
7// Type to fuzzy search; arrows / Ctrl+HJKL move, Tab section, Ctrl+Left/Right group, Enter copies, Shift+Enter types it
8// into the focused window, Ctrl+Enter collects into a buffer, Alt+Enter copies the codepoint.
9// Looks like glyph's charms (github.com/Nebelung-cloud/glyph-rice, GPL-3.0, charms/Charms.qml): a card over the dimmed
10// screen, search pill, section chips, tiles (the selected one white... here primary), the selection large at the bottom.
11PanelWindow {
12 id: win
13 property bool opened: false
14 signal closeRequested()
15
16 property real t: opened ? 1 : 0
17 Behavior on t { NumberAnimation { duration: Theme.t; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.glide } }
18
19 visible: t > 0
20 anchors { top: true; bottom: true; left: true; right: true }
21 exclusionMode: ExclusionMode.Ignore
22 color: "transparent"
23 WlrLayershell.layer: WlrLayer.Overlay
24 WlrLayershell.namespace: "aurora-chars"
25 WlrLayershell.keyboardFocus: opened ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
26
27 readonly property var sections: [
28 { id: "all", label: "all", icon: Theme.iGrid, sets: ["emoji", "nerd", "sym"] },
29 { id: "recent", label: "recent", icon: Theme.iHistory, sets: ["emoji", "nerd", "sym"] },
30 { id: "emoji", label: "emoji", icon: Theme.iEmoji, sets: ["emoji"] },
31 { id: "nerd", label: "nerd", icon: Theme.iFontGlyph, sets: ["nerd"] },
32 { id: "sym", label: "symbols", icon: Theme.iSigma, sets: ["sym"] },
33 { id: "kao", label: "kaomoji", icon: Theme.iKaomoji, sets: ["kao"] },
34 { id: "fancy", label: "fancy", icon: Theme.iFancy, sets: [] }
35 ]
36 property int sec: 0
37 property int grp: -1
38 property string buffer: ""
39 property var results: []
40 property var hovered: null
41 readonly property var section: sections[sec]
42 readonly property bool listMode: section.id === "kao" || section.id === "fancy"
43 readonly property int tone: Settings.charTone
44 // groups of the shown set (single-set sections only)
45 readonly property var groups: {
46 if (!Chars.loaded || section.sets.length !== 1) return []
47 var i = Chars.setIndex(section.sets[0])
48 return i >= 0 ? Chars.sets[i].groups : []
49 }
50 readonly property var view: listMode ? list : grid
51 readonly property var current: hovered ? hovered : (results.length && view.currentIndex >= 0 ? results[view.currentIndex] : null)
52
53 onOpenedChanged: {
54 if (!opened) return
55 hovered = null
56 buffer = ""
57 filter.text = ""
58 grp = -1
59 Chars.load()
60 sec = Chars.loaded && Chars.recent(1).length ? 1 : 0
61 recompute()
62 filter.forceActiveFocus()
63 }
64 onSecChanged: { grp = -1; recompute() }
65 onGrpChanged: recompute()
66 Connections {
67 target: Chars
68 function onLoadedChanged() {
69 if (win.opened && Chars.recent(1).length && filter.text === "") win.sec = 1
70 win.recompute()
71 }
72 }
73 Timer { id: debounce; interval: 40; onTriggered: win.recompute() }
74
75 function setList(id) {
76 var out = []
77 for (var i = 0; i < section.sets.length; i++) out.push(Chars.setIndex(section.sets[i]))
78 return out
79 }
80 function recompute() {
81 hovered = null
82 var q = filter.text
83 if (section.id === "fancy") results = Chars.fancy(q.trim() === "" ? "aurora" : q)
84 else if (!Chars.loaded) results = []
85 else if (section.id === "recent") {
86 if (q.trim() === "") results = Chars.recent(300)
87 else {
88 var l = Chars.search(q, setList(), -1), out = []
89 for (var i = 0; i < l.length; i++) if (Chars.counts[l[i].c]) out.push(l[i])
90 results = out
91 }
92 } else if (section.id === "kao") {
93 var k = Chars.search(q, setList(), grp)
94 results = q.trim() === "" ? Chars.byUse(k) : k
95 } else results = Chars.search(q, setList(), grp)
96 grid.currentIndex = 0
97 list.currentIndex = 0
98 grid.positionViewAtBeginning()
99 list.positionViewAtBeginning()
100 }
101 function cycleSection(d) { sec = (sec + d + sections.length) % sections.length }
102 function cycleGroup(d) {
103 if (!groups.length) return
104 var n = groups.length + 1 // -1 = all
105 grp = ((grp + 1 + d + n) % n) - 1
106 }
107 function outOf(it) { return section.id === "fancy" ? it.c : Chars.glyph(it, tone) }
108 function codeOf(s) {
109 var cps = Array.from(s)
110 if (cps.length === 1) return "0x" + cps[0].codePointAt(0).toString(16).toUpperCase()
111 return Chars.hex(s)
112 }
113 // how: copy | type | buffer | code
114 function pick(it, how) {
115 if (!it && buffer === "") return
116 var s = it ? outOf(it) : ""
117 if (it && section.id !== "fancy" && (how === "buffer" || buffer === "")) Chars.bump(it.c)
118 if (how === "buffer") { buffer += s; return }
119 if (how === "code") { Chars.copy(codeOf(s)); closeRequested(); return }
120 if (buffer !== "") s = buffer
121 closeRequested()
122 if (how === "type" && Chars.hasWtype) Chars.type(s)
123 else Chars.copy(s)
124 }
125 function move(key) {
126 var v = view
127 if (listMode) {
128 if (key === Qt.Key_Up) v.decrementCurrentIndex()
129 else if (key === Qt.Key_Down) v.incrementCurrentIndex()
130 return
131 }
132 if (key === Qt.Key_Up) v.moveCurrentIndexUp()
133 else if (key === Qt.Key_Down) v.moveCurrentIndexDown()
134 else if (key === Qt.Key_Left) v.moveCurrentIndexLeft()
135 else if (key === Qt.Key_Right) v.moveCurrentIndexRight()
136 }
137 function page(d) {
138 var step = listMode ? 10 : grid.cols * Math.max(1, Math.floor(grid.height / grid.cellHeight))
139 view.currentIndex = Math.max(0, Math.min(results.length - 1, view.currentIndex + d * step))
140 }
141 function fontOf(it) {
142 return it && it.s !== undefined && Chars.sets[it.s] && Chars.sets[it.s].id === "emoji" ? "Noto Color Emoji" : Theme.nf
143 }
144
145 Rectangle {
146 anchors.fill: parent
147 color: Theme.scrim
148 opacity: win.t
149 MouseArea { anchors.fill: parent; onClicked: win.closeRequested() }
150 }
151
152 Rectangle {
153 id: card
154 anchors.horizontalCenter: parent.horizontalCenter
155 y: Math.round((parent.height - height) / 2) - 30 + 24 * (1 - win.t)
156 width: Math.min(parent.width - 80, 960)
157 height: Math.min(parent.height - 160, 740)
158 opacity: win.t
159 radius: 44
160 color: Theme.fade(Theme.s1, Theme.panelAlpha)
161 border.width: 1
162 border.color: Theme.s2
163 MouseArea { anchors.fill: parent }
164
165 ColumnLayout {
166 anchors.fill: parent
167 anchors.margins: 30
168 spacing: 14
169
170 // ---- search + tone + close ----
171 RowLayout {
172 Layout.fillWidth: true
173 spacing: 10
174 Rectangle {
175 Layout.fillWidth: true
176 implicitHeight: 60
177 radius: 30
178 color: Theme.s2
179 Icon {
180 id: lens
181 x: 22
182 anchors.verticalCenter: parent.verticalCenter
183 text: Theme.iSearch
184 color: Theme.grey
185 font.pixelSize: 20
186 }
187 Text {
188 anchors.left: filter.left
189 anchors.right: count.left
190 anchors.verticalCenter: parent.verticalCenter
191 visible: filter.text === ""
192 elide: Text.ElideRight
193 text: win.section.id === "fancy" ? "Type some text" : "Search " + win.section.label + " (name, keyword, u+f0001, or paste a glyph)"
194 color: Theme.mute
195 font.family: Theme.ui
196 font.pixelSize: 18
197 }
198 TextInput {
199 id: filter
200 anchors.left: lens.right
201 anchors.leftMargin: 14
202 anchors.right: count.left
203 anchors.rightMargin: 12
204 anchors.verticalCenter: parent.verticalCenter
205 color: Theme.fg
206 selectionColor: Theme.fill
207 selectedTextColor: Theme.fillFg
208 font.family: Theme.ui
209 font.pixelSize: 18
210 clip: true
211 cursorDelegate: Rectangle {
212 width: 2
213 color: Theme.live
214 visible: filter.activeFocus
215 Timer { running: filter.activeFocus; interval: 500; repeat: true; onTriggered: parent.opacity = 1 - parent.opacity }
216 }
217 onTextChanged: {
218 // recent is the landing page: typing there searches everything (used ones rank first anyway)
219 if (text !== "" && win.section.id === "recent") win.sec = 0
220 if (text === "") win.recompute(); else debounce.restart()
221 }
222 Keys.onEscapePressed: win.closeRequested()
223 Keys.onPressed: event => {
224 var k = event.key, m = event.modifiers
225 // ctrl+h/j/k/l move through the results like the arrows
226 var vim = { [Qt.Key_H]: Qt.Key_Left, [Qt.Key_J]: Qt.Key_Down, [Qt.Key_K]: Qt.Key_Up, [Qt.Key_L]: Qt.Key_Right }
227 if ((m & Qt.ControlModifier) && vim[k] !== undefined) {
228 win.hovered = null
229 win.move(vim[k])
230 event.accepted = true
231 return
232 }
233 if (k === Qt.Key_Return || k === Qt.Key_Enter) {
234 if (debounce.running) { debounce.stop(); win.recompute() }
235 var it = win.results[win.view.currentIndex]
236 if (m & Qt.ControlModifier) win.pick(it, "buffer")
237 else if (m & Qt.ShiftModifier) win.pick(it, "type")
238 else if (m & Qt.AltModifier) win.pick(it, "code")
239 else win.pick(it, "copy")
240 } else if (k === Qt.Key_Tab) win.cycleSection(1)
241 else if (k === Qt.Key_Backtab) win.cycleSection(-1)
242 else if ((m & Qt.ControlModifier) && (k === Qt.Key_Left || k === Qt.Key_Right)) win.cycleGroup(k === Qt.Key_Left ? -1 : 1)
243 else if ((m & Qt.AltModifier) && k >= Qt.Key_1 && k < Qt.Key_1 + win.sections.length) win.sec = k - Qt.Key_1
244 else if (k === Qt.Key_Up || k === Qt.Key_Down || (!win.listMode && (k === Qt.Key_Left || k === Qt.Key_Right))) {
245 win.hovered = null
246 win.move(k)
247 } else if (k === Qt.Key_PageDown) win.page(1)
248 else if (k === Qt.Key_PageUp) win.page(-1)
249 else if (k === Qt.Key_Backspace && filter.text === "" && win.buffer !== "")
250 win.buffer = Array.from(win.buffer).slice(0, -1).join("")
251 else return
252 event.accepted = true
253 }
254 }
255 Lb {
256 id: count
257 anchors.right: parent.right
258 anchors.rightMargin: 22
259 anchors.verticalCenter: parent.verticalCenter
260 text: win.results.length
261 }
262 }
263 // skin tone: the raised hand in each tone, click to choose (persisted)
264 Row {
265 visible: Chars.loaded && (win.section.id === "emoji" || win.section.id === "all" || win.section.id === "recent")
266 spacing: 2
267 Repeater {
268 model: 6
269 delegate: Rectangle {
270 required property int index
271 readonly property var hand: Chars.byChar["✋"]
272 width: 40
273 height: 40
274 radius: 20
275 color: Settings.charTone === index ? Theme.fill : (tm.containsMouse ? Theme.s3 : "transparent")
276 Text {
277 anchors.centerIn: parent
278 text: parent.hand ? Chars.glyph(parent.hand, parent.index) : ""
279 font.family: "Noto Color Emoji"
280 font.pixelSize: 17
281 }
282 MouseArea {
283 id: tm
284 anchors.fill: parent
285 hoverEnabled: true
286 cursorShape: Qt.PointingHandCursor
287 onClicked: { Settings.charTone = parent.index; filter.forceActiveFocus() }
288 }
289 }
290 }
291 }
292 IconBtn { implicitHeight: 48; icon: Theme.iClose; onClicked: win.closeRequested() }
293 }
294
295 // ---- sections ----
296 Row {
297 Layout.fillWidth: true
298 spacing: 2
299 Repeater {
300 model: win.sections
301 delegate: Chip {
302 required property var modelData
303 required property int index
304 text: modelData.label
305 selected: win.sec === index
306 onClicked: { win.sec = index; filter.forceActiveFocus() }
307 }
308 }
309 }
310
311 // ---- groups of the section ----
312 ListView {
313 id: chips
314 Layout.fillWidth: true
315 implicitHeight: 32
316 visible: win.groups.length > 0
317 orientation: ListView.Horizontal
318 spacing: 4
319 clip: true
320 boundsBehavior: Flickable.StopAtBounds
321 model: win.groups.length ? ["all"].concat(win.groups) : []
322 currentIndex: win.grp + 1
323 onCurrentIndexChanged: positionViewAtIndex(currentIndex, ListView.Contain)
324 delegate: Chip {
325 required property var modelData
326 required property int index
327 height: 32
328 px: 10
329 filled: true
330 text: modelData
331 selected: win.grp === index - 1
332 onClicked: { win.grp = index - 1; filter.forceActiveFocus() }
333 }
334 }
335
336 // ---- results ----
337 Item {
338 Layout.fillWidth: true
339 Layout.fillHeight: true
340
341 GridView {
342 id: grid
343 anchors.fill: parent
344 visible: !win.listMode
345 readonly property int cols: Math.max(1, Math.floor(width / 58))
346 cellWidth: Math.floor(width / cols)
347 cellHeight: 58
348 clip: true
349 model: win.listMode ? 0 : win.results.length
350 boundsBehavior: Flickable.StopAtBounds
351 highlightMoveDuration: 0
352 keyNavigationWraps: false
353 cacheBuffer: 400
354 delegate: Rectangle {
355 id: cell
356 required property int index
357 readonly property var it: win.results[index]
358 readonly property bool selected: grid.currentIndex === index
359 width: grid.cellWidth - 4
360 height: grid.cellHeight - 4
361 radius: 18
362 color: selected ? Theme.fill : (gm.containsMouse ? Theme.s2 : "transparent")
363 Text {
364 anchors.centerIn: parent
365 text: cell.it ? Chars.glyph(cell.it, win.tone) : ""
366 color: cell.selected ? Theme.fillFg : Theme.fg
367 font.family: win.fontOf(cell.it)
368 font.pixelSize: 26
369 }
370 MouseArea {
371 id: gm
372 anchors.fill: parent
373 hoverEnabled: true
374 acceptedButtons: Qt.LeftButton | Qt.RightButton
375 cursorShape: Qt.PointingHandCursor
376 onContainsMouseChanged: win.hovered = containsMouse ? cell.it : null
377 onClicked: mouse => {
378 grid.currentIndex = cell.index
379 win.pick(cell.it, mouse.button === Qt.RightButton ? "buffer" : "copy")
380 }
381 }
382 }
383 }
384
385 ListView {
386 id: list
387 anchors.fill: parent
388 visible: win.listMode
389 clip: true
390 model: win.listMode ? win.results.length : 0
391 spacing: 2
392 boundsBehavior: Flickable.StopAtBounds
393 highlightMoveDuration: 0
394 delegate: Rectangle {
395 id: row
396 required property int index
397 readonly property var it: win.results[index]
398 readonly property bool selected: list.currentIndex === index
399 width: list.width
400 height: 52
401 radius: 26
402 color: selected ? Theme.fill : (lm.containsMouse ? Theme.s2 : "transparent")
403 Text {
404 anchors.left: parent.left
405 anchors.leftMargin: 22
406 anchors.right: tag.left
407 anchors.rightMargin: 12
408 anchors.verticalCenter: parent.verticalCenter
409 text: row.it ? row.it.c : ""
410 elide: Text.ElideRight
411 maximumLineCount: 1
412 color: row.selected ? Theme.fillFg : Theme.fg
413 font.family: Theme.ui
414 font.pixelSize: 18
415 }
416 Lb {
417 id: tag
418 anchors.right: parent.right
419 anchors.rightMargin: 22
420 anchors.verticalCenter: parent.verticalCenter
421 px: 10
422 text: row.it ? (win.section.id === "fancy" ? row.it.n : row.it.k) : ""
423 color: row.selected ? Theme.fillFg : Theme.grey
424 }
425 MouseArea {
426 id: lm
427 anchors.fill: parent
428 hoverEnabled: true
429 acceptedButtons: Qt.LeftButton | Qt.RightButton
430 cursorShape: Qt.PointingHandCursor
431 onContainsMouseChanged: win.hovered = containsMouse ? row.it : null
432 onClicked: mouse => {
433 list.currentIndex = row.index
434 win.pick(row.it, mouse.button === Qt.RightButton ? "buffer" : "copy")
435 }
436 }
437 }
438 }
439
440 Lb {
441 anchors.centerIn: parent
442 visible: win.results.length === 0
443 color: Theme.mute
444 text: !Chars.loaded && win.section.id !== "fancy" ? "loading glyphs…"
445 : (win.section.id === "recent" && filter.text === "" ? "nothing used yet" : "no matches")
446 }
447 }
448
449 // ---- the selected / hovered glyph, large ----
450 Rectangle {
451 Layout.fillWidth: true
452 implicitHeight: 92
453 radius: 30
454 color: Theme.s2
455
456 Rectangle {
457 id: big
458 visible: !win.listMode
459 x: 12
460 anchors.verticalCenter: parent.verticalCenter
461 width: visible ? 68 : 0
462 height: 68
463 radius: 34
464 color: Theme.s3
465 Text {
466 anchors.centerIn: parent
467 text: win.current ? Chars.glyph(win.current, win.tone) : ""
468 color: Theme.fill
469 font.family: win.fontOf(win.current)
470 font.pixelSize: 36
471 }
472 }
473 Column {
474 anchors.left: big.right
475 anchors.leftMargin: 16
476 anchors.right: buf.left
477 anchors.rightMargin: 12
478 anchors.verticalCenter: parent.verticalCenter
479 spacing: 4
480 Text {
481 width: parent.width
482 text: win.current ? win.current.n : ""
483 elide: Text.ElideRight
484 color: Theme.fg
485 font.family: Theme.ui
486 font.pixelSize: 16
487 font.weight: Font.DemiBold
488 }
489 Lb {
490 width: parent.width
491 visible: win.current !== null && win.section.id !== "fancy"
492 px: 10
493 color: Theme.fill
494 text: {
495 var c = win.current
496 if (!c || c.s === undefined || !Chars.sets[c.s]) return ""
497 var st = Chars.sets[c.s]
498 return (st.id === "kao" ? "" : Chars.hex(Chars.glyph(c, win.tone)) + " · ")
499 + st.label + " · " + st.groups[c.g]
500 }
501 elide: Text.ElideRight
502 }
503 Lb {
504 width: parent.width
505 visible: text !== ""
506 px: 10
507 text: win.current && win.current.k && win.section.id !== "kao" ? win.current.k : ""
508 elide: Text.ElideRight
509 }
510 }
511 Row { // collected with ctrl+enter / right click
512 id: buf
513 anchors.right: parent.right
514 anchors.rightMargin: 22
515 anchors.verticalCenter: parent.verticalCenter
516 spacing: 10
517 visible: win.buffer !== ""
518 width: visible ? implicitWidth : 0
519 Icon {
520 anchors.verticalCenter: parent.verticalCenter
521 text: Theme.iBuffer
522 color: Theme.live
523 font.pixelSize: 18
524 }
525 Text {
526 anchors.verticalCenter: parent.verticalCenter
527 width: Math.min(implicitWidth, 220)
528 text: win.buffer
529 elide: Text.ElideLeft
530 color: Theme.fg
531 font.family: Theme.nf
532 font.pixelSize: 20
533 }
534 }
535 }
536 }
537 }
538
539 Row {
540 anchors.horizontalCenter: parent.horizontalCenter
541 anchors.top: card.bottom
542 anchors.topMargin: 26
543 opacity: win.t
544 spacing: 30
545 Repeater {
546 model: ["↵ copy"].concat(Chars.hasWtype ? ["shift ↵ type"] : []).concat(["ctrl ↵ collect", "alt ↵ code", "ctrl hjkl move", "tab section", "ctrl ←→ group", "esc close"])
547 Lb { required property string modelData; text: modelData }
548 }
549 }
550}