Krolyxon
Commit 84eb284

Branches

Refs →

aurora.configquickshellauroraCharPicker.qml

599 lines · 27.1 KB · QML

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