clipboard history using cliphist
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 10s
Python / lint-format (pull_request) Successful in 15s
Python / test (pull_request) Successful in 29s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m6s

This commit is contained in:
2026-06-11 14:35:42 +02:00
parent e90f1facb7
commit 130e613eb5
14 changed files with 492 additions and 70 deletions
+82
View File
@@ -0,0 +1,82 @@
import QtQuick
import qs.Config
CustomRect {
id: root
enum ButtonType {
Filled,
Tonal,
Text
}
property color activeColor
property color activeOnColor
property bool checked
property real checkedRadius: Appearance.rounding.medium
property real defaultRadius: Appearance.rounding.large
property color disabledColor: Qt.alpha(DynamicColors.palette.m3onSurface, 0.1)
property color disabledOnColor: Qt.alpha(DynamicColors.palette.m3onSurface, 0.38)
property bool fillWidth
property real horizontalPadding: padding
readonly property alias hovered: stateLayer.containsMouse
required implicitHeight
required implicitWidth
property color inactiveColor
property color inactiveOnColor
property bool internalChecked
property bool isRound
property bool isToggle
readonly property color onColor: !enabled ? disabledOnColor : internalChecked ? activeOnColor : inactiveOnColor
property real padding
readonly property alias pressed: stateLayer.pressed
property real pressedRadius: Appearance.rounding.small
readonly property alias radiusAnim: radiusAnim
property bool radiusMorph: true
property alias shapeMorph: stateLayer.shapeMorph
property real shapeMorphExpansion: shapeMorph && pressed ? 24 : 0
readonly property alias stateLayer: stateLayer
property int type: ButtonBase.Filled
property real verticalPadding: padding
signal clicked
color: type === ButtonBase.Text ? "transparent" : !enabled ? disabledColor : internalChecked ? activeColor : inactiveColor
radius: {
if (radiusMorph && pressed)
return pressedRadius;
if (internalChecked)
return checkedRadius;
if (isRound)
return (height || implicitHeight) / 2 * Math.min(1, Appearance.rounding.scale);
return defaultRadius;
}
Behavior on radius {
Anim {
id: radiusAnim
type: Anim.DefaultEffects
}
}
Behavior on shapeMorphExpansion {
Anim {
type: Anim.FastSpatial
}
}
onCheckedChanged: internalChecked = checked
StateLayer {
id: stateLayer
color: root.internalChecked ? root.activeOnColor : root.inactiveOnColor
enabled: enabled
onClicked: {
if (root.isToggle)
root.internalChecked = !root.internalChecked;
root.clicked();
}
}
}
+22 -53
View File
@@ -1,76 +1,45 @@
import qs.Config
import QtQuick import QtQuick
import qs.Config
CustomRect { ButtonBase {
id: root id: root
enum Type {
Filled,
Tonal,
Text
}
property color activeColour: type === IconButton.Filled ? DynamicColors.palette.m3primary : DynamicColors.palette.m3secondary
property color activeOnColour: type === IconButton.Filled ? DynamicColors.palette.m3onPrimary : type === IconButton.Tonal ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3primary
property bool checked
property color disabledColour: Qt.alpha(DynamicColors.palette.m3onSurface, 0.1)
property color disabledOnColour: Qt.alpha(DynamicColors.palette.m3onSurface, 0.38)
property alias font: label.font property alias font: label.font
property alias icon: label.text property alias icon: label.text
property color inactiveColour: { readonly property alias label: label
if (!toggle && type === IconButton.Filled)
activeColor: type === IconButton.Filled ? DynamicColors.palette.m3primary : DynamicColors.palette.m3secondary
activeOnColor: type === IconButton.Filled ? DynamicColors.palette.m3onPrimary : type === IconButton.Tonal ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3primary
implicitHeight: {
const h = label.implicitHeight + padding * 2;
if (h % 2 !== 0)
return h + 1;
return h;
}
implicitWidth: implicitHeight
inactiveColor: {
if (!isToggle && type === IconButton.Filled)
return DynamicColors.palette.m3primary; return DynamicColors.palette.m3primary;
return type === IconButton.Filled ? DynamicColors.tPalette.m3surfaceContainer : DynamicColors.palette.m3secondaryContainer; return type === IconButton.Filled ? DynamicColors.tPalette.m3surfaceContainer : DynamicColors.palette.m3secondaryContainer;
} }
property color inactiveOnColour: { inactiveOnColor: {
if (!toggle && type === IconButton.Filled) if (!isToggle && type === IconButton.Filled)
return DynamicColors.palette.m3onPrimary; return DynamicColors.palette.m3onPrimary;
return type === IconButton.Tonal ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurfaceVariant; return type === IconButton.Tonal ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurfaceVariant;
} }
property bool internalChecked padding: type === IconButton.Text ? Appearance.padding.extraSmall / 2 : Appearance.padding.small
property alias label: label
property real padding: type === IconButton.Text ? 10 / 2 : 7
property alias radiusAnim: radiusAnim
property alias stateLayer: stateLayer
property bool toggle
property int type: IconButton.Filled
signal clicked
color: type === IconButton.Text ? "transparent" : !enabled ? disabledColour : internalChecked ? activeColour : inactiveColour
implicitHeight: label.implicitHeight + padding * 2
implicitWidth: implicitHeight
radius: internalChecked ? 6 : (implicitHeight / 2 * Math.min(1, 1)) * Appearance.rounding.scale
Behavior on radius {
Anim {
id: radiusAnim
}
}
onCheckedChanged: internalChecked = checked
StateLayer {
id: stateLayer
color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
onClicked: {
if (root.toggle)
root.internalChecked = !root.internalChecked;
root.clicked();
}
}
MaterialIcon { MaterialIcon {
id: label id: label
anchors.centerIn: parent anchors.centerIn: parent
color: !root.enabled ? root.disabledOnColour : root.internalChecked ? root.activeOnColour : root.inactiveOnColour anchors.verticalCenterOffset: 1
fill: !root.toggle || root.internalChecked ? 1 : 0 color: root.onColor
fill: !root.isToggle || root.internalChecked ? 1 : 0
Behavior on fill { Behavior on fill {
Anim { Anim {
type: Anim.DefaultEffects
} }
} }
} }
+5 -2
View File
@@ -74,7 +74,7 @@ Item {
const dragX = x - centroid.pressPosition.x; const dragX = x - centroid.pressPosition.x;
const dragY = y - centroid.pressPosition.y; const dragY = y - centroid.pressPosition.y;
if (centroid.pressPosition.y >= root.screen.height - Config.barConfig.border && dragY < -200) if (centroid.pressPosition.y >= root.screen.height - Config.barConfig.border && centroid.pressPosition.x > root.screen.width / 5 && dragY < -200)
root.visibilities.launcher = true; root.visibilities.launcher = true;
if (root.singleGestureTriggered) if (root.singleGestureTriggered)
@@ -90,7 +90,10 @@ Item {
} }
} }
if (!Config.dock.hoverToReveal && centroid.pressPosition.y > root.screen.height - root.bar.implicitHeight) if (centroid.pressPosition.y > root.screen.height - Config.barConfig.border && centroid.pressPosition.x < root.screen.width / 5 && dragY < -50)
root.visibilities.clipboard = true;
if (!Config.dock.hoverToReveal && centroid.pressPosition.y > root.screen.height - root.bar.implicitHeight && centroid.pressPosition.x > root.screen.width / 5)
if (dragY < -10) { if (dragY < -10) {
root.visibilities.dock = true; root.visibilities.dock = true;
root.singleGestureTriggered = true; root.singleGestureTriggered = true;
+11
View File
@@ -13,12 +13,14 @@ import qs.Modules.Resources as Resources
import qs.Modules.Settings as Settings import qs.Modules.Settings as Settings
import qs.Modules.Drawing as Drawing import qs.Modules.Drawing as Drawing
import qs.Modules.Dock as Dock import qs.Modules.Dock as Dock
import qs.Modules.Clipboard as Clipboard
import qs.Config import qs.Config
Item { Item {
id: root id: root
required property Item bar required property Item bar
readonly property alias clipboard: clipboard
readonly property alias dashboard: dashboard readonly property alias dashboard: dashboard
readonly property alias dashboardWrapper: dashboardWrapper readonly property alias dashboardWrapper: dashboardWrapper
readonly property alias dock: dock readonly property alias dock: dock
@@ -207,4 +209,13 @@ Item {
screen: root.screen screen: root.screen
visibilities: root.visibilities visibilities: root.visibilities
} }
Clipboard.Wrapper {
id: clipboard
anchors.bottom: parent.bottom
anchors.left: parent.left
screen: root.screen
visibilities: root.visibilities
}
} }
+16 -1
View File
@@ -40,6 +40,7 @@ CustomWindow {
visibilities.settings = false; visibilities.settings = false;
visibilities.resources = false; visibilities.resources = false;
visibilities.dock = false; visibilities.dock = false;
visibilities.clipboard = false;
panels.popouts.hasCurrent = false; panels.popouts.hasCurrent = false;
} }
onHasFullscreenChanged: { onHasFullscreenChanged: {
@@ -49,6 +50,7 @@ CustomWindow {
visibilities.osd = false; visibilities.osd = false;
visibilities.settings = false; visibilities.settings = false;
visibilities.resources = false; visibilities.resources = false;
visibilities.clipboard = false;
visibilities.dock = false; visibilities.dock = false;
panels.popouts.hasCurrent = false; panels.popouts.hasCurrent = false;
} }
@@ -96,7 +98,7 @@ CustomWindow {
HyprlandFocusGrab { HyprlandFocusGrab {
id: focusGrab id: focusGrab
active: visibilities.dock || visibilities.resources || visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu")) active: visibilities.dock || visibilities.resources || visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings || visibilities.clipboard || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu"))
windows: [root] windows: [root]
onCleared: { onCleared: {
@@ -106,6 +108,7 @@ CustomWindow {
visibilities.osd = false; visibilities.osd = false;
visibilities.settings = false; visibilities.settings = false;
visibilities.resources = false; visibilities.resources = false;
visibilities.clipboard = false;
visibilities.dock = false; visibilities.dock = false;
panels.popouts.hasCurrent = false; panels.popouts.hasCurrent = false;
} }
@@ -115,6 +118,7 @@ CustomWindow {
id: visibilities id: visibilities
property bool bar property bool bar
property bool clipboard
property bool dashboard property bool dashboard
property bool dock property bool dock
property bool isDrawing property bool isDrawing
@@ -303,6 +307,14 @@ CustomWindow {
panel: panels.drawing panel: panels.drawing
radius: Appearance.rounding.normal radius: Appearance.rounding.normal
} }
PanelBg {
id: clipboardBg
deformAmount: 0.03
panel: panels.clipboard
radius: 29
}
} }
Loader { Loader {
@@ -355,6 +367,9 @@ CustomWindow {
screen: root.screen screen: root.screen
visibilities: visibilities visibilities: visibilities
clipboard.transform: Matrix4x4 {
matrix: clipboardBg.deformMatrix
}
dashboard.transform: Matrix4x4 { dashboard.transform: Matrix4x4 {
matrix: dashBg.deformMatrix matrix: dashBg.deformMatrix
} }
+150
View File
@@ -0,0 +1,150 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Config
import "../scripts/fuzzysort.js" as Fuzzy
Singleton {
id: root
property string cliphistBinary: "cliphist"
property list<string> entries: []
property real pasteDelay: 0.05
readonly property var preparedEntries: entries.map(a => ({
name: Fuzzy.prepare(`${a.replace(/^\s*\S+\s+/, "")}`),
entry: a
}))
property string pressPasteCommand: "ydotool key -d 1 29:1 47:1 47:0 29:0"
property real scoreThreshold: 0.2
function copy(entry) {
if (root.cliphistBinary.includes("cliphist"))
Quickshell.execDetached(["bash", "-c", `printf '${shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy`]);
else {
const entryNumber = entry.split("\t")[0];
Quickshell.execDetached(["bash", "-c", `${root.cliphistBinary} decode ${entryNumber} | wl-copy`]);
}
}
function deleteEntry(entry) {
deleteProc.deleteEntry(entry);
}
function entryIsImage(entry) {
return !!(/^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(entry));
}
function fuzzyQuery(search: string): var {
if (search.trim() === "") {
return entries;
}
return Fuzzy.go(search, preparedEntries, {
all: true,
key: "name"
}).map(r => {
return r.obj.entry;
});
}
function paste(entry) {
if (root.cliphistBinary.includes("cliphist"))
Quickshell.execDetached(["bash", "-c", `printf '${shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy && wl-paste`]);
else {
const entryNumber = entry.split("\t")[0];
Quickshell.execDetached(["bash", "-c", `${root.cliphistBinary} decode ${entryNumber} | wl-copy; ${root.pressPasteCommand}`]);
}
}
function refresh() {
readProc.buffer = [];
readProc.running = true;
}
function shellSingleQuoteEscape(str) {
return String(str).replace(/'/g, "'\\''");
}
function wipe() {
wipeProc.running = true;
}
Process {
id: deleteProc
property string entry: ""
function deleteEntry(entry) {
deleteProc.entry = entry;
deleteProc.running = true;
deleteProc.entry = "";
}
command: ["bash", "-c", `echo '${root.shellSingleQuoteEscape(deleteProc.entry)}' | ${root.cliphistBinary} delete`]
onExited: (exitCode, exitStatus) => {
root.refresh();
}
}
Process {
id: wipeProc
command: [root.cliphistBinary, "wipe"]
onExited: (exitCode, exitStatus) => {
root.refresh();
}
}
Connections {
function onClipboardTextChanged() {
delayedUpdateTimer.restart();
}
target: Quickshell
}
Timer {
id: delayedUpdateTimer
interval: 50
repeat: false
onTriggered: {
root.refresh();
}
}
Process {
id: readProc
property list<string> buffer: []
command: [root.cliphistBinary, "list"]
stdout: SplitParser {
onRead: line => {
readProc.buffer.push(line);
}
}
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
root.entries = readProc.buffer;
} else {
console.error("[Cliphist] Failed to refresh with code", exitCode, "and status", exitStatus);
}
}
}
IpcHandler {
function update(): void {
root.refresh();
}
target: "cliphistService"
}
}
-6
View File
@@ -17,21 +17,15 @@ Item {
implicitWidth: content.implicitWidth implicitWidth: content.implicitWidth
visible: width > 0 && height > 0 visible: width > 0 && height > 0
x: { x: {
if (content.isDetached)
return (parent.width - content.nonAnimWidth) / 2;
const off = content.currentCenter - Config.barConfig.border - content.nonAnimWidth / 2; const off = content.currentCenter - Config.barConfig.border - content.nonAnimWidth / 2;
const diff = parent.width - Math.floor(off + content.nonAnimWidth); const diff = parent.width - Math.floor(off + content.nonAnimWidth);
if (diff < 0) if (diff < 0)
return off + diff; return off + diff;
return Math.floor(Math.max(off, 0)); return Math.floor(Math.max(off, 0));
} }
y: content.isDetached ? (parent.height - content.nonAnimHeight) / 2 : 0
Behavior on offsetScale { Behavior on offsetScale {
Anim { Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
} }
} }
Behavior on x { Behavior on x {
+148
View File
@@ -0,0 +1,148 @@
pragma ComponentBehavior: Bound
import Quickshell
import QtQuick
import QtQuick.Layouts
import qs.Components
import qs.Helpers
import qs.Config
Item {
id: root
required property ShellScreen screen
required property PersistentProperties visibilities
implicitHeight: screen.height / 2
implicitWidth: 500
Component.onCompleted: {
if (!ClipHistory.entries.length > 0)
ClipHistory.refresh();
searchField.forceActiveFocus();
}
CustomClippingRect {
id: search
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
color: DynamicColors.tPalette.m3surfaceContainer
implicitHeight: 50
radius: Appearance.rounding.full
MaterialIcon {
id: searchIcon
anchors.left: parent.left
anchors.margins: Appearance.padding.large
anchors.verticalCenter: parent.verticalCenter
text: "search"
}
CustomTextField {
id: searchField
anchors.bottom: parent.bottom
anchors.left: searchIcon.right
anchors.leftMargin: Appearance.spacing.small
anchors.right: parent.right
anchors.top: parent.top
color: DynamicColors.palette.m3onSurface
placeholderText: "Search clipboard history..."
}
}
CustomClippingRect {
id: entries
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.top: search.bottom
anchors.topMargin: Appearance.spacing.normal
radius: Appearance.rounding.normal
ListView {
id: view
anchors.fill: parent
spacing: Appearance.spacing.normal
delegate: RowLayout {
id: clipItem
required property string modelData
height: 50
width: view.width
CustomClippingRect {
id: textRect
Layout.fillHeight: true
Layout.fillWidth: true
// Layout.preferredWidth: implicitWidth + (textLayer.pressed ? 18 : 0)
// implicitWidth: 250
radius: textLayer.pressed ? (Appearance.rounding.small / 2) : Appearance.rounding.small
Behavior on Layout.preferredWidth {
Anim {
type: Anim.FastEffects
}
}
Behavior on radius {
Anim {
type: Anim.FastEffects
}
}
CustomText {
id: text
anchors.left: parent.left
anchors.margins: Appearance.padding.normal
anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight
text: clipItem.modelData
}
StateLayer {
id: textLayer
onClicked: ClipHistory.copy(clipItem.modelData)
}
}
IconButton {
Layout.fillHeight: true
Layout.margins: Appearance.padding.smallest
Layout.preferredWidth: height
icon: "content_copy"
isToggle: false
// implicitWidth: 30
}
IconButton {
Layout.fillHeight: true
Layout.margins: Appearance.padding.smallest
Layout.preferredWidth: height
icon: "delete"
inactiveColor: Qt.alpha(DynamicColors.palette.m3error, 0.8)
inactiveOnColor: DynamicColors.palette.m3onError
isToggle: false
}
}
model: ScriptModel {
values: {
const entries = ClipHistory.entries;
const search = searchField.text;
var regex = new RegExp(search, "i");
return entries.filter(n => regex.test(n));
}
}
}
}
}
+42
View File
@@ -0,0 +1,42 @@
pragma ComponentBehavior: Bound
import Quickshell
import QtQuick
import qs.Components
import qs.Config
Item {
id: root
property int contentHeight
property real offsetScale: shouldBeActive ? 0 : 1
required property ShellScreen screen
readonly property bool shouldBeActive: visibilities.clipboard
required property PersistentProperties visibilities
anchors.bottomMargin: (-implicitHeight - 5) * offsetScale
implicitHeight: content.implicitHeight + Appearance.padding.normal * 2
implicitWidth: content.implicitWidth + Appearance.padding.normal * 2 || 400
opacity: 1 - offsetScale
visible: offsetScale < 1
Behavior on offsetScale {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
Loader {
id: content
active: root.shouldBeActive || root.visible
anchors.centerIn: parent
asynchronous: true
sourceComponent: Content {
screen: root.screen
visibilities: root.visibilities
}
}
}
@@ -267,8 +267,8 @@ CustomRect {
checked: Recorder.paused checked: Recorder.paused
font.pointSize: Appearance.font.size.large font.pointSize: Appearance.font.size.large
icon: Recorder.paused ? "play_arrow" : "pause" icon: Recorder.paused ? "play_arrow" : "pause"
isToggle: true
label.animate: true label.animate: true
toggle: true
type: IconButton.Tonal type: IconButton.Tonal
onClicked: { onClicked: {
@@ -280,8 +280,8 @@ CustomRect {
IconButton { IconButton {
font.pointSize: Appearance.font.size.large font.pointSize: Appearance.font.size.large
icon: "stop" icon: "stop"
inactiveColour: DynamicColors.palette.m3error inactiveColor: DynamicColors.palette.m3error
inactiveOnColour: DynamicColors.palette.m3onError inactiveOnColor: DynamicColors.palette.m3onError
onClicked: Recorder.stop() onClicked: Recorder.stop()
} }
@@ -101,11 +101,11 @@ CustomRect {
component Toggle: IconButton { component Toggle: IconButton {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? 18 : internalChecked ? 7 : 0) Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? 18 : internalChecked ? 7 : 0)
inactiveColour: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2) inactiveColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2)
isToggle: true
radius: stateLayer.pressed ? 6 / 2 : internalChecked ? 6 : 8 radius: stateLayer.pressed ? 6 / 2 : internalChecked ? 6 : 8
radiusAnim.duration: MaterialEasing.expressiveEffectsTime radiusAnim.duration: MaterialEasing.expressiveEffectsTime
radiusAnim.easing.bezierCurve: MaterialEasing.expressiveEffects radiusAnim.easing.bezierCurve: MaterialEasing.expressiveEffects
toggle: true
Behavior on Layout.preferredWidth { Behavior on Layout.preferredWidth {
Anim { Anim {
@@ -221,11 +221,11 @@ Item {
font: Appearance.font.family.sans font: Appearance.font.family.sans
// icon: root.iconForId(modelData.entry.id) // icon: root.iconForId(modelData.entry.id)
icon: root.labelForId(modelData.entry.id) icon: root.labelForId(modelData.entry.id)
inactiveColour: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2) inactiveColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2)
isToggle: true
radius: stateLayer.pressed ? 6 / 2 : internalChecked ? 6 : 8 radius: stateLayer.pressed ? 6 / 2 : internalChecked ? 6 : 8
radiusAnim.duration: MaterialEasing.expressiveEffectsTime radiusAnim.duration: MaterialEasing.expressiveEffectsTime
radiusAnim.easing.bezierCurve: MaterialEasing.expressiveEffects radiusAnim.easing.bezierCurve: MaterialEasing.expressiveEffects
toggle: true
visible: !["spacer", "upower", "dash", "audio"].some(prefix => modelData.entry.id.startsWith(prefix)) visible: !["spacer", "upower", "dash", "audio"].some(prefix => modelData.entry.id.startsWith(prefix))
Behavior on Layout.preferredWidth { Behavior on Layout.preferredWidth {
+9
View File
@@ -59,4 +59,13 @@ Scope {
visibilities.settings = !visibilities.settings; visibilities.settings = !visibilities.settings;
} }
} }
CustomShortcut {
name: "toggle-clipboard"
onPressed: {
const visibilities = Visibilities.getForActive();
visibilities.clipboard = !visibilities.clipboard;
}
}
} }
-1
View File
@@ -27,7 +27,6 @@ Item {
detachedMode = ""; detachedMode = "";
} }
focus: hasCurrent
implicitHeight: nonAnimHeight implicitHeight: nonAnimHeight
implicitWidth: nonAnimWidth implicitWidth: nonAnimWidth