Files
omarchycn/shell/plugins/emojis/Emojis.qml
T
David Heinemeier Hansson cd2be9da25 Insert emojis without persisting clipboard entries
Keep the first emoji result selected whenever results exist, including after search filtering, so Enter inserts immediately.

Route emoji insertion through a hidden helper that serves a transient sensitive clipboard entry long enough for Shift+Insert, then stops that clipboard owner.

Mark transient emoji inserts so clipboard history skips only that event while continuing to record normal terminal copies.
2026-05-29 14:54:49 +02:00

340 lines
11 KiB
QML

import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.Commons
import "EmojiSearch.js" as EmojiSearch
Item {
id: root
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
property var shell: null
property var manifest: null
property bool opened: false
property string filterText: ""
property int selectedIndex: 0
property bool cursorActive: false
property var emojis: []
property var filteredEmojis: []
// Shares the [menu] surface tokens — themes that style the menu also
// style emojis. Selected-cell colors composed in the
// singleton so consumers drop them straight into Rectangle bindings.
property color background: Color.menu.background
property color foreground: Color.menu.text
property color border: Color.menu.border
property color scrim: Color.menu.scrim
property color selectedBackground: Color.menu.selectedBackground
property color selectedText: Color.menu.selectedText
readonly property int cornerRadius: Style.cornerRadius
property string fontFamily: Style.font.menuFamily
property int contentMargin: Style.spacing.panelPadding
property int headerHeight: Math.max(Style.space(34), Style.font.title + Style.spacing.controlPaddingY * 2)
property int contentSpacing: Style.spacing.md
property int cardWidth: Math.min(Style.space(400), panel.width - Style.gapsOut * 2)
property int cardHeight: Math.min(Style.space(500), panel.height - Style.gapsOut * 2)
property int cellWidth: Math.max(Style.space(44), Style.font.display + Style.spacing.md)
property int cellHeight: Math.max(Style.space(44), Style.font.display + Style.spacing.md)
property int columns: Math.floor((cardWidth - contentMargin * 2) / cellWidth)
function open(payloadJson) {
root.opened = true
root.filterText = ""
root.selectedIndex = 0
root.cursorActive = true
root.rebuildDisplay()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function close() {
root.opened = false
}
function dismiss() {
root.opened = false
if (root.shell && typeof root.shell.hide === "function")
root.shell.hide((root.manifest && root.manifest.id) || "omarchy.emojis")
}
function toggle() {
if (root.opened) root.dismiss()
else root.open("{}")
}
function loadEmojis(raw) {
root.emojis = EmojiSearch.parseEmojis(raw)
if (root.opened) root.rebuildDisplay()
}
function rebuildDisplay() {
var out = EmojiSearch.filterEmojis(root.emojis, root.filterText, 1000)
root.filteredEmojis = out
displayModel.clear()
for (var j = 0; j < out.length; j++) {
displayModel.append({ emoji: out[j].e, index: j })
}
if (displayModel.count === 0) selectedIndex = 0
else if (selectedIndex >= displayModel.count) selectedIndex = displayModel.count - 1
else if (selectedIndex < 0) selectedIndex = 0
cursorActive = displayModel.count > 0
Qt.callLater(function() {
if (displayModel.count > 0) resultGrid.positionViewAtIndex(root.selectedIndex, GridView.Contain)
})
}
function select(delta) {
if (displayModel.count === 0) return
if (!cursorActive) {
cursorActive = true
selectedIndex = delta < 0 ? displayModel.count - 1 : 0
} else {
selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count
}
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
}
function selectRow(delta) {
if (displayModel.count === 0) return
if (!cursorActive) {
cursorActive = true
selectedIndex = delta < 0 ? displayModel.count - 1 : 0
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
return
}
var newIndex = selectedIndex + delta * columns
if (newIndex < 0) newIndex = 0
if (newIndex >= displayModel.count) newIndex = displayModel.count - 1
selectedIndex = newIndex
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
}
function selectPage(delta) {
if (displayModel.count === 0) return
if (!cursorActive) {
cursorActive = true
selectedIndex = delta < 0 ? displayModel.count - 1 : 0
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
return
}
var visibleRows = Math.max(1, Math.floor(resultGrid.height / cellHeight))
var newIndex = selectedIndex + delta * columns * visibleRows
if (newIndex < 0) newIndex = 0
if (newIndex >= displayModel.count) newIndex = displayModel.count - 1
selectedIndex = newIndex
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
}
function setFilter(nextFilter) {
root.filterText = nextFilter
root.selectedIndex = 0
root.cursorActive = true
root.rebuildDisplay()
}
function activateIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
root.applySelected(row.emoji)
}
function applySelected(emoji) {
if (!emoji) return
root.dismiss()
Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-menu-emoji-insert", emoji])
}
ListModel { id: displayModel }
FileView {
path: root.omarchyPath + "/shell/plugins/emojis/emojis.json"
onLoaded: root.loadEmojis(text())
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "omarchy-emojis"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: root.scrim
}
MouseArea {
anchors.fill: parent
onClicked: root.dismiss()
}
Rectangle {
id: card
width: root.cardWidth
height: root.cardHeight
radius: root.cornerRadius
anchors.centerIn: parent
color: root.background
border.color: root.border
border.width: Math.max(1, Style.space(2))
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
id: keyCatcher
anchors.fill: parent
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
if (root.filterText) root.setFilter("")
else root.dismiss()
event.accepted = true
} else if (event.key === Qt.Key_Backspace) {
if (root.filterText.length > 0) root.setFilter(root.filterText.slice(0, -1))
event.accepted = true
} else if (event.key === Qt.Key_Left) {
root.select(-1)
event.accepted = true
} else if (event.key === Qt.Key_Right) {
root.select(1)
event.accepted = true
} else if (event.key === Qt.Key_Up) {
root.selectRow(-1)
event.accepted = true
} else if (event.key === Qt.Key_Down) {
root.selectRow(1)
event.accepted = true
} else if (event.key === Qt.Key_PageUp) {
root.selectPage(-1)
event.accepted = true
} else if (event.key === Qt.Key_PageDown) {
root.selectPage(1)
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (root.cursorActive) root.activateIndex(root.selectedIndex)
else if (displayModel.count > 0) root.cursorActive = true
event.accepted = true
} else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) {
root.setFilter(root.filterText + event.text)
event.accepted = true
}
}
}
Column {
anchors.fill: parent
anchors.margins: root.contentMargin
spacing: root.contentSpacing
Rectangle {
width: parent.width
height: root.headerHeight
radius: root.cornerRadius
color: "transparent"
border.width: 0
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.filterText || "Search emojis…"
color: root.foreground
opacity: root.filterText ? 1 : 0.58
font.family: root.fontFamily
font.pixelSize: Style.font.heading
elide: Text.ElideRight
}
}
Item {
width: parent.width
height: parent.height - root.headerHeight - root.contentSpacing
GridView {
id: resultGrid
anchors.fill: parent
model: displayModel
clip: true
cellWidth: root.cellWidth
cellHeight: root.cellHeight
boundsBehavior: Flickable.StopAtBounds
delegate: Rectangle {
required property int index
required property string emoji
readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex
width: root.cellWidth
height: root.cellHeight
radius: root.cornerRadius
color: hasCursor ? root.selectedBackground : "transparent"
border.width: 0
Text {
text: parent.emoji
font.family: root.fontFamily
font.pixelSize: Style.font.display
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: if (containsMouse) {
root.cursorActive = true
root.selectedIndex = index
}
onClicked: {
root.cursorActive = true
root.selectedIndex = index
root.activateIndex(index)
}
}
}
}
Column {
anchors.centerIn: parent
spacing: Style.space(8)
visible: displayModel.count === 0
Text {
text: "󰈉"
color: root.selectedText
opacity: 0.8
font.family: root.fontFamily
font.pixelSize: Style.font.displayLarge
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
Text {
text: "No matches for “" + root.filterText + "”"
color: root.foreground
opacity: 0.7
font.family: root.fontFamily
font.pixelSize: Style.font.title
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
}
}
}
}
}
}