Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions .config/hypr/scripts/quickshell/clipboard/ClipboardManager.qml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Item {
}
}

if (isDifferent || window.allClips.length === 0) {
if (isDifferent || window.allClips.length === 0 || searchInput.text !== "") {
window.allClips = newItems;
window.filterClips(searchInput.text);
}
Expand All @@ -133,15 +133,15 @@ Item {
if (isLoading || !hasMore) return;
isLoading = true;
currentOffset += fetchLimit;
clipFetcher.command = ["python3", Quickshell.env("HOME") + "/.config/hypr/scripts/quickshell/clipboard/clip_fetcher.py", window.currentOffset, window.fetchLimit, paths.getCacheDir("clipboard")];
clipFetcher.command = ["python3", Quickshell.env("HOME") + "/.config/hypr/scripts/quickshell/clipboard/clip_fetcher.py", window.currentOffset, window.fetchLimit, paths.getCacheDir("clipboard"), searchInput.text];
clipFetcher.running = true;
}

function appendClips(newItems) {
let q = searchInput.text.toLowerCase();
for (let i = 0; i < newItems.length; i++) {
allClips.push(newItems[i]);
if (q === "" || newItems[i].type === "image" || newItems[i].content.toLowerCase().includes(q)) {
if ((q === "" && newItems[i].type === "image") || (newItems[i].type === "text" && newItems[i].content.toLowerCase().includes(q))) {
clipModel.append(newItems[i]);
}
}
Expand All @@ -164,7 +164,7 @@ Item {
clipModel.clear();

for (let i = 0; i < allClips.length; i++) {
if (allClips[i].type === "image" || allClips[i].content.toLowerCase().includes(q)) {
if ((q === "" && allClips[i].type === "image") || (allClips[i].type === "text" && allClips[i].content.toLowerCase().includes(q))) {
clipModel.append(allClips[i]);
}
}
Expand All @@ -179,6 +179,20 @@ Item {
Quickshell.execDetached(["bash", Quickshell.env("HOME") + "/.config/hypr/scripts/qs_manager.sh", "close"]);
}

Timer {
id: searchDebounce
interval: 300
running: false
repeat: false
onTriggered: {
window.currentOffset = 0;
window.hasMore = true;
window.isLoading = true;
clipFetcher.command = ["python3", Quickshell.env("HOME") + "/.config/hypr/scripts/quickshell/clipboard/clip_fetcher.py", 0, window.fetchLimit, paths.getCacheDir("clipboard"), searchInput.text];
clipFetcher.running = true;
}
}

Timer {
id: focusTimer
interval: 50
Expand Down Expand Up @@ -351,6 +365,7 @@ Item {
if (window.previewMode) { window.previewMode = false; }
window.pendingIndex = -1;
filterClips(text);
searchDebounce.restart();
}

Keys.onTabPressed: {
Expand Down Expand Up @@ -437,6 +452,39 @@ Item {
event.accepted = true;
}
}

Button {
id: clearButton
flat: true
Layout.preferredWidth: window.s(32)
Layout.preferredHeight: window.s(32)

contentItem: Text {
text: "󰆴"
font.family: "Iosevka Nerd Font"
font.pixelSize: window.s(18)
color: clearButton.hovered ? window.mauve : window.subtext0
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
Behavior on color { ColorAnimation { duration: 200 } }
}

background: Rectangle {
color: clearButton.hovered ? Qt.rgba(window.surface1.r, window.surface1.g, window.surface1.b, 0.3) : "transparent"
radius: window.s(6)
}

onClicked: {
Quickshell.execDetached(["bash", "-c", "cliphist wipe"]);
window.allClips = [];
clipModel.clear();
Quickshell.execDetached(["bash", Quickshell.env("HOME") + "/.config/hypr/scripts/qs_manager.sh", "close"]);
}

ToolTip.visible: hovered
ToolTip.text: "Wipe all history"
ToolTip.delay: 500
}
}
}

Expand Down
62 changes: 41 additions & 21 deletions .config/hypr/scripts/quickshell/clipboard/clip_fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
import os
import sys
import threading
from concurrent.futures import ThreadPoolExecutor

def cleanup_cache(all_lines, cache_dir):
valid_ids = set()
# Keep top 100 recent IDs to prevent infinite cache bloat
for line in all_lines[:100]:
# Keep top 200 recent IDs to prevent infinite cache bloat
for line in all_lines[:200]:
if '\t' in line:
valid_ids.add(line.split('\t', 1)[0])

Expand All @@ -24,51 +25,65 @@ def cleanup_cache(all_lines, cache_dir):
except Exception:
pass

def decode_image(iid, img_path):
if not os.path.exists(img_path):
try:
with open(img_path, "wb") as f:
subprocess.run(["cliphist", "decode", iid], stdout=f, timeout=2)
except Exception:
pass

def get_cliphist():
# Implement pagination arguments
# Arguments: offset, limit, cache_dir, [query]
offset = int(sys.argv[1]) if len(sys.argv) > 1 else 0
# Slightly smaller limit to make the initial UI pop open faster
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 12

# Use dynamically provided cache dir from QML or fallback securely
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 24
cache_dir = sys.argv[3] if len(sys.argv) > 3 else os.environ.get("QS_CACHE_CLIPBOARD", os.path.expanduser("~/.cache/quickshell/clipboard"))
query = sys.argv[4] if len(sys.argv) > 4 else ""

os.makedirs(cache_dir, exist_ok=True)

try:
# Fetch the entire list quickly
result = subprocess.run(["cliphist", "list"], capture_output=True, text=True)
all_lines = result.stdout.strip().split('\n')
# Fetch the entire list
result = subprocess.run(["cliphist", "list"], capture_output=True, text=True, errors='replace')
if result.returncode != 0:
print("[]")
return

all_lines = [l for l in result.stdout.strip().split('\n') if l]

# Slice only the requested chunk
lines = all_lines[offset:offset+limit]
# Filtering
if query:
# When searching, we only care about text matches and ignore image placeholders
filtered_lines = [l for l in all_lines if query.lower() in l.lower() and "[[ binary data" not in l]
else:
filtered_lines = all_lines

# Move cleanup to a background thread so it doesn't block the UI from receiving data
if offset == 0:
# Pagination
lines = filtered_lines[offset:offset+limit]

# Background cleanup
if offset == 0 and not query:
threading.Thread(target=cleanup_cache, args=(all_lines, cache_dir), daemon=True).start()

except Exception as e:
print("[]")
return

items = []
images_to_decode = []

for line in lines:
if not line: continue
parts = line.split('\t', 1)
if len(parts) != 2: continue

iid, content = parts[0], parts[1]
item_type = "text"
display_content = content.strip()

# Detect images in cliphist output
if "[[ binary data" in content:
item_type = "image"
img_path = os.path.join(cache_dir, f"{iid}.png")

# CACHING: Only decode the specific item if it doesn't already exist
if not os.path.exists(img_path):
with open(img_path, "wb") as f:
subprocess.run(["cliphist", "decode", iid], stdout=f)
images_to_decode.append((iid, img_path))
display_content = img_path

items.append({
Expand All @@ -77,6 +92,11 @@ def get_cliphist():
"type": item_type
})

# Decode images in parallel to avoid blocking
if images_to_decode:
with ThreadPoolExecutor(max_workers=8) as executor:
executor.map(lambda p: decode_image(*p), images_to_decode)

print(json.dumps(items))

if __name__ == "__main__":
Expand Down