Initial commit
This commit is contained in:
commit
69bc67e29a
16 changed files with 2376 additions and 0 deletions
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Rust build artifacts
|
||||
target/
|
||||
|
||||
# Editor and IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS artifacts
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Environment and secrets
|
||||
.env
|
||||
.env.*
|
||||
*.env
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
secrets/
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Runtime files
|
||||
*.sock
|
||||
*.pid
|
||||
1147
Cargo.lock
generated
Normal file
1147
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
3
Cargo.toml
Normal file
3
Cargo.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[workspace]
|
||||
members = ["breadclip-core", "breadclipd", "breadclip"]
|
||||
resolver = "2"
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Breadway
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
101
README.md
Normal file
101
README.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# breadclip
|
||||
|
||||
A Wayland clipboard history manager for Hyprland. It consists of two binaries:
|
||||
|
||||
- **`breadclipd`** — a background daemon that watches the clipboard and persists entries to a local SQLite database.
|
||||
- **`breadclip`** — a GTK4 Layer Shell popup for browsing and recalling clipboard history.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rust toolchain (edition 2021)
|
||||
- GTK 4.12+
|
||||
- `gtk4-layer-shell`
|
||||
- `wl-clipboard` (`wl-paste` and `wl-copy` must be on `$PATH`)
|
||||
- Hyprland (or any Wayland compositor with Layer Shell support, though the panel-positioning logic is Hyprland-specific)
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
git clone https://git.breadway.dev/breadway/breadclip
|
||||
cd breadclip
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The compiled binaries are at `target/release/breadclip` and `target/release/breadclipd`.
|
||||
|
||||
## Install
|
||||
|
||||
Copy the binaries to somewhere on your `$PATH`, e.g.:
|
||||
|
||||
```sh
|
||||
cp target/release/breadclip target/release/breadclipd ~/.local/bin/
|
||||
```
|
||||
|
||||
### systemd user service
|
||||
|
||||
A unit file is provided in `contrib/`:
|
||||
|
||||
```sh
|
||||
cp contrib/breadclipd.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now breadclipd
|
||||
```
|
||||
|
||||
### Hyprland keybind
|
||||
|
||||
Add the contents of `contrib/hyprland.conf` to your `hyprland.conf`:
|
||||
|
||||
```
|
||||
layerrule = blur, breadclip
|
||||
layerrule = ignorezero, breadclip
|
||||
|
||||
bind = $mainMod, V, exec, breadclip
|
||||
```
|
||||
|
||||
The `layerrule` lines enable the frosted-glass blur effect behind the panel.
|
||||
|
||||
## Usage
|
||||
|
||||
Start the daemon (or let the systemd unit handle it):
|
||||
|
||||
```sh
|
||||
breadclipd
|
||||
```
|
||||
|
||||
Open the clipboard history popup:
|
||||
|
||||
```sh
|
||||
breadclip
|
||||
```
|
||||
|
||||
Running `breadclip` a second time while it is open closes it (toggle behaviour).
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Up` / `Down` | Move selection |
|
||||
| `Enter` | Copy selected entry to clipboard and close |
|
||||
| `Delete` | Remove selected entry from history |
|
||||
| `Escape` | Close without copying |
|
||||
|
||||
Clicking an entry copies it and closes the popup. Clicking outside the panel closes it.
|
||||
|
||||
### Filtering and search
|
||||
|
||||
The popup has three filter chips — **All**, **Text**, **Images** — and a search box. The search box filters text entries by content; image entries only appear under the **Images** filter.
|
||||
|
||||
## Data storage
|
||||
|
||||
History is stored under `$XDG_DATA_HOME/breadclip/` (typically `~/.local/share/breadclip/`):
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `history.db` | SQLite database of all entries |
|
||||
| `images/` | PNG files for image entries |
|
||||
|
||||
The daemon keeps at most 200 text entries and 50 image entries, trimming oldest entries automatically.
|
||||
|
||||
## Theming
|
||||
|
||||
`breadclip` inherits its colour palette from `bread-theme`. The panel renders with an 80% opaque background so Hyprland's `layerrule = blur` can show a frosted-glass effect behind it.
|
||||
15
bakery.toml
Normal file
15
bakery.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
name = "breadclip"
|
||||
description = "Wayland clipboard history manager for Hyprland — daemon + GTK4 popup"
|
||||
binaries = ["breadclip", "breadclipd"]
|
||||
system_deps = ["gtk4", "gtk4-layer-shell", "wl-clipboard"]
|
||||
optional_system_deps = ["hyprland"]
|
||||
bread_deps = []
|
||||
|
||||
[[service]]
|
||||
unit = "breadclipd.service"
|
||||
enable = true
|
||||
|
||||
[install]
|
||||
post_install = [
|
||||
"systemctl --user is-active --quiet breadclipd || systemctl --user start breadclipd",
|
||||
]
|
||||
10
breadclip-core/Cargo.toml
Normal file
10
breadclip-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "breadclip-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
dirs = "5"
|
||||
165
breadclip-core/src/lib.rs
Normal file
165
breadclip-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
use rusqlite::{params, Connection, Result as SqlResult};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClipEntry {
|
||||
pub id: i64,
|
||||
pub timestamp: i64,
|
||||
pub mime_type: String,
|
||||
pub content: Option<String>,
|
||||
pub image_path: Option<String>,
|
||||
pub content_hash: String,
|
||||
}
|
||||
|
||||
pub struct HistoryDb {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl HistoryDb {
|
||||
pub fn open() -> SqlResult<Self> {
|
||||
let dir = data_dir();
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
let conn = Connection::open(dir.join("history.db"))?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
content TEXT,
|
||||
image_path TEXT,
|
||||
content_hash TEXT NOT NULL UNIQUE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS history_ts ON history(timestamp DESC);",
|
||||
)?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn insert_text(&self, text: &str) -> SqlResult<()> {
|
||||
let hash = sha256_hex(text.as_bytes());
|
||||
let ts = unix_now();
|
||||
self.conn.execute(
|
||||
"INSERT INTO history (timestamp, mime_type, content, content_hash)
|
||||
VALUES (?1, 'text/plain', ?2, ?3)
|
||||
ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1",
|
||||
params![ts, text, hash],
|
||||
)?;
|
||||
self.trim(200, 50)
|
||||
}
|
||||
|
||||
pub fn insert_image(&self, png_bytes: &[u8]) -> SqlResult<()> {
|
||||
let hash = sha256_hex(png_bytes);
|
||||
let images_dir = data_dir().join("images");
|
||||
std::fs::create_dir_all(&images_dir).ok();
|
||||
// Use first 16 hex chars for the filename (collision-safe for 50 images)
|
||||
let path = images_dir.join(format!("{}.png", &hash[..16]));
|
||||
if !path.exists() {
|
||||
std::fs::write(&path, png_bytes).ok();
|
||||
}
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let ts = unix_now();
|
||||
self.conn.execute(
|
||||
"INSERT INTO history (timestamp, mime_type, image_path, content_hash)
|
||||
VALUES (?1, 'image/png', ?2, ?3)
|
||||
ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1",
|
||||
params![ts, path_str, hash],
|
||||
)?;
|
||||
self.trim(200, 50)
|
||||
}
|
||||
|
||||
pub fn list_entries(&self, limit: usize) -> SqlResult<Vec<ClipEntry>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, timestamp, mime_type, content, image_path, content_hash
|
||||
FROM history ORDER BY timestamp DESC LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([limit as i64], |row| {
|
||||
Ok(ClipEntry {
|
||||
id: row.get(0)?,
|
||||
timestamp: row.get(1)?,
|
||||
mime_type: row.get(2)?,
|
||||
content: row.get(3)?,
|
||||
image_path: row.get(4)?,
|
||||
content_hash: row.get(5)?,
|
||||
})
|
||||
})?
|
||||
.collect::<SqlResult<Vec<_>>>();
|
||||
rows
|
||||
}
|
||||
|
||||
pub fn delete_entry(&self, id: i64) -> SqlResult<()> {
|
||||
// Clean up image file if present
|
||||
let image_path: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT image_path FROM history WHERE id = ?1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok()
|
||||
.flatten();
|
||||
if let Some(p) = image_path {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
self.conn.execute("DELETE FROM history WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trim(&self, max_text: usize, max_images: usize) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"DELETE FROM history
|
||||
WHERE mime_type = 'text/plain'
|
||||
AND id NOT IN (
|
||||
SELECT id FROM history WHERE mime_type = 'text/plain'
|
||||
ORDER BY timestamp DESC LIMIT ?1
|
||||
)",
|
||||
[max_text as i64],
|
||||
)?;
|
||||
// Collect old image paths before deleting rows
|
||||
let old_paths: Vec<String> = {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT image_path FROM history
|
||||
WHERE mime_type = 'image/png'
|
||||
AND image_path IS NOT NULL
|
||||
AND id NOT IN (
|
||||
SELECT id FROM history WHERE mime_type = 'image/png'
|
||||
ORDER BY timestamp DESC LIMIT ?1
|
||||
)",
|
||||
)?;
|
||||
let paths = stmt.query_map([max_images as i64], |row| row.get(0))?
|
||||
.collect::<SqlResult<Vec<_>>>()?;
|
||||
paths
|
||||
};
|
||||
for path in old_paths {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
self.conn.execute(
|
||||
"DELETE FROM history
|
||||
WHERE mime_type = 'image/png'
|
||||
AND id NOT IN (
|
||||
SELECT id FROM history WHERE mime_type = 'image/png'
|
||||
ORDER BY timestamp DESC LIMIT ?1
|
||||
)",
|
||||
[max_images as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn data_dir() -> PathBuf {
|
||||
dirs::data_local_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("breadclip")
|
||||
}
|
||||
|
||||
fn unix_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64
|
||||
}
|
||||
15
breadclip/Cargo.toml
Normal file
15
breadclip/Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "breadclip"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "breadclip"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
breadclip-core = { path = "../breadclip-core" }
|
||||
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||
gtk4-layer-shell = "0.8"
|
||||
serde_json = "1"
|
||||
119
breadclip/src/css.rs
Normal file
119
breadclip/src/css.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
use bread_theme::{hex_to_rgba, ink_on, tokens::*, Palette};
|
||||
|
||||
pub fn build_css(p: &Palette) -> String {
|
||||
let bg_panel = hex_to_rgba(&p.background, 0.80);
|
||||
let surface = hex_to_rgba(&p.color0, 0.85);
|
||||
let on_bg = ink_on(&p.background);
|
||||
let on_surface = ink_on(&p.color0);
|
||||
let accent = &p.color4;
|
||||
let on_accent = ink_on(&p.color4);
|
||||
let overlay = &p.color7;
|
||||
let on_overlay = ink_on(&p.color7);
|
||||
|
||||
// `window` must be transparent so Hyprland's `layerrule = blur` can render
|
||||
// the frosted effect behind the panel. The panel itself carries the 80% fill.
|
||||
//
|
||||
// Row colours are set on `.clip-row` (not the shared `row` selector) so
|
||||
// they override the shared `row:selected { background: @accent }` rule and
|
||||
// use the muted surface tone instead — consistent with breadbox.
|
||||
format!(
|
||||
"window {{ background-color: transparent; }}\
|
||||
.clip-panel {{\
|
||||
background-color: {bg_panel};\
|
||||
color: {on_bg};\
|
||||
border-radius: {r1}px;\
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.6);\
|
||||
}}\
|
||||
.clip-row {{\
|
||||
background-color: transparent;\
|
||||
border-radius: {r2}px;\
|
||||
padding: {sm}px {md}px;\
|
||||
color: {on_bg};\
|
||||
}}\
|
||||
.clip-row:hover {{\
|
||||
background-color: {surface};\
|
||||
color: {on_surface};\
|
||||
}}\
|
||||
.clip-row:selected {{\
|
||||
background-color: {surface};\
|
||||
color: {on_surface};\
|
||||
}}\
|
||||
.clip-text-preview {{\
|
||||
font-size: {base}px;\
|
||||
}}\
|
||||
.clip-meta {{\
|
||||
font-size: {sec}px;\
|
||||
opacity: 0.6;\
|
||||
}}\
|
||||
.clip-thumbnail {{\
|
||||
min-width: 48px;\
|
||||
min-height: 48px;\
|
||||
max-width: 48px;\
|
||||
max-height: 48px;\
|
||||
}}\
|
||||
.chips-row {{\
|
||||
padding: {xs}px {sm}px;\
|
||||
}}\
|
||||
.chip {{\
|
||||
background-color: {overlay};\
|
||||
color: {on_overlay};\
|
||||
border: none;\
|
||||
background-image: none;\
|
||||
box-shadow: none;\
|
||||
outline: none;\
|
||||
border-radius: 999px;\
|
||||
padding: {xs}px {md}px;\
|
||||
font-size: {sec}px;\
|
||||
}}\
|
||||
.chip:hover {{\
|
||||
background-color: {overlay};\
|
||||
background-image: none;\
|
||||
box-shadow: none;\
|
||||
}}\
|
||||
.chip.active {{\
|
||||
background-color: {accent};\
|
||||
color: {on_accent};\
|
||||
background-image: none;\
|
||||
box-shadow: none;\
|
||||
}}\
|
||||
.chip.active:hover {{\
|
||||
background-color: {accent};\
|
||||
background-image: none;\
|
||||
box-shadow: none;\
|
||||
}}\
|
||||
searchentry {{\
|
||||
background-color: {surface_raw};\
|
||||
color: {on_surface};\
|
||||
caret-color: {accent};\
|
||||
border: none;\
|
||||
outline: none;\
|
||||
box-shadow: none;\
|
||||
padding: {md}px {lg}px;\
|
||||
border-radius: {r1}px {r1}px 0 0;\
|
||||
}}\
|
||||
listbox {{\
|
||||
background-color: transparent;\
|
||||
padding: {xs}px;\
|
||||
}}\
|
||||
row {{ padding: 0; }}\
|
||||
row:selected {{ background-color: transparent; }}\
|
||||
row:hover {{ background-color: transparent; }}",
|
||||
bg_panel = bg_panel,
|
||||
surface = surface,
|
||||
surface_raw = p.color0,
|
||||
on_bg = on_bg,
|
||||
on_surface = on_surface,
|
||||
accent = accent,
|
||||
on_accent = on_accent,
|
||||
overlay = overlay,
|
||||
on_overlay = on_overlay,
|
||||
base = FONT_SIZE_BASE,
|
||||
sec = FONT_SIZE_SECONDARY,
|
||||
xs = SPACE_XS,
|
||||
sm = SPACE_SM,
|
||||
md = SPACE_MD,
|
||||
lg = SPACE_LG,
|
||||
r1 = RADIUS_PRIMARY,
|
||||
r2 = RADIUS_SECONDARY,
|
||||
)
|
||||
}
|
||||
513
breadclip/src/main.rs
Normal file
513
breadclip/src/main.rs
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
mod css;
|
||||
mod position;
|
||||
|
||||
use breadclip_core::{ClipEntry, HistoryDb};
|
||||
use bread_theme::{load_palette};
|
||||
use gtk4::{
|
||||
glib,
|
||||
pango::EllipsizeMode,
|
||||
prelude::*,
|
||||
Application, ApplicationWindow, Box as GBox, Button, ContentFit, EventControllerKey, Label,
|
||||
ListBox, Orientation, Picture, PolicyType, ScrolledWindow, SearchEntry, SelectionMode,
|
||||
};
|
||||
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
||||
use std::{
|
||||
cell::Cell,
|
||||
env,
|
||||
fs,
|
||||
io::Write,
|
||||
path::PathBuf,
|
||||
process::{Command, Stdio},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
const PANEL_WIDTH: i32 = 520;
|
||||
const MAX_ENTRIES: usize = 200;
|
||||
const PANEL_GAP: i32 = 8; // gap between focused window bottom and panel top
|
||||
// Worst-case panel height (search + chips + full-height list), used to keep
|
||||
// the panel fully on-screen before its natural size is known.
|
||||
const PANEL_HEIGHT_ESTIMATE: i32 = 580;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Filter {
|
||||
All,
|
||||
Text,
|
||||
Images,
|
||||
}
|
||||
|
||||
// ---- Helpers ----------------------------------------------------------------
|
||||
|
||||
fn format_timestamp(ts: i64) -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
let diff = now.saturating_sub(ts);
|
||||
if diff < 60 {
|
||||
"just now".to_string()
|
||||
} else if diff < 3600 {
|
||||
format!("{}m ago", diff / 60)
|
||||
} else if diff < 86400 {
|
||||
format!("{}h ago", diff / 3600)
|
||||
} else {
|
||||
format!("{}d ago", diff / 86400)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_row_entry(row: >k4::ListBoxRow) -> Option<ClipEntry> {
|
||||
unsafe { row.data::<ClipEntry>("entry").map(|p| p.as_ref().clone()) }
|
||||
}
|
||||
|
||||
fn entry_matches(entry: &ClipEntry, query: &str, filter: Filter) -> bool {
|
||||
let is_image = entry.mime_type.starts_with("image/");
|
||||
let type_ok = match filter {
|
||||
Filter::All => true,
|
||||
Filter::Text => !is_image,
|
||||
Filter::Images => is_image,
|
||||
};
|
||||
if !type_ok {
|
||||
return false;
|
||||
}
|
||||
if query.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if is_image {
|
||||
// Images are unsearchable by text; only show in Images filter
|
||||
return filter == Filter::Images;
|
||||
}
|
||||
entry
|
||||
.content
|
||||
.as_deref()
|
||||
.map(|t| t.to_lowercase().contains(&query.to_lowercase()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn refresh_list(list: &ListBox, query: &str, filter: Filter) {
|
||||
let mut i = 0i32;
|
||||
while let Some(row) = list.row_at_index(i) {
|
||||
let vis = get_row_entry(&row)
|
||||
.map(|e| entry_matches(&e, query, filter))
|
||||
.unwrap_or(false);
|
||||
row.set_visible(vis);
|
||||
i += 1;
|
||||
}
|
||||
let first = (0i32..).find_map(|j| list.row_at_index(j).filter(|r| r.is_visible()));
|
||||
list.select_row(first.as_ref());
|
||||
}
|
||||
|
||||
fn build_row(entry: &ClipEntry) -> gtk4::ListBoxRow {
|
||||
let row = gtk4::ListBoxRow::new();
|
||||
row.add_css_class("clip-row");
|
||||
|
||||
let hbox = GBox::new(Orientation::Horizontal, 8);
|
||||
hbox.set_valign(gtk4::Align::Center);
|
||||
|
||||
if entry.mime_type.starts_with("image/") {
|
||||
// Thumbnail
|
||||
let thumb = if let Some(ref path) = entry.image_path {
|
||||
let pic = Picture::new();
|
||||
pic.set_filename(Some(path.as_str()));
|
||||
pic.set_can_shrink(true);
|
||||
pic.set_content_fit(ContentFit::Contain);
|
||||
pic.add_css_class("clip-thumbnail");
|
||||
pic
|
||||
} else {
|
||||
let pic = Picture::new();
|
||||
pic.add_css_class("clip-thumbnail");
|
||||
pic
|
||||
};
|
||||
hbox.append(&thumb);
|
||||
|
||||
let type_lbl = Label::new(Some("image"));
|
||||
type_lbl.add_css_class("clip-meta");
|
||||
type_lbl.set_hexpand(true);
|
||||
type_lbl.set_xalign(0.0);
|
||||
hbox.append(&type_lbl);
|
||||
} else {
|
||||
// Text preview — first line, truncated
|
||||
let preview = entry
|
||||
.content
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(120)
|
||||
.collect::<String>();
|
||||
|
||||
let text_lbl = Label::new(Some(&preview));
|
||||
text_lbl.add_css_class("clip-text-preview");
|
||||
text_lbl.set_hexpand(true);
|
||||
text_lbl.set_xalign(0.0);
|
||||
text_lbl.set_ellipsize(EllipsizeMode::End);
|
||||
text_lbl.set_single_line_mode(true);
|
||||
hbox.append(&text_lbl);
|
||||
}
|
||||
|
||||
let ts_lbl = Label::new(Some(&format_timestamp(entry.timestamp)));
|
||||
ts_lbl.add_css_class("clip-meta");
|
||||
ts_lbl.set_xalign(1.0);
|
||||
hbox.append(&ts_lbl);
|
||||
|
||||
row.set_child(Some(&hbox));
|
||||
unsafe { row.set_data("entry", entry.clone()) };
|
||||
row
|
||||
}
|
||||
|
||||
fn do_copy(entry: &ClipEntry) {
|
||||
match entry.mime_type.as_str() {
|
||||
"image/png" => {
|
||||
if let Some(ref path) = entry.image_path {
|
||||
if let Ok(file) = fs::File::open(path) {
|
||||
let _ = Command::new("wl-copy")
|
||||
.args(["--type", "image/png"])
|
||||
.stdin(Stdio::from(file))
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(ref content) = entry.content {
|
||||
if let Ok(mut child) = Command::new("wl-copy")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
let _ = stdin.write_all(content.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PID file toggle (single-instance, matches breadbox pattern) -------------
|
||||
|
||||
fn pid_file() -> PathBuf {
|
||||
env::var("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("/tmp"))
|
||||
.join("breadclip.pid")
|
||||
}
|
||||
|
||||
fn toggle_or_continue() -> bool {
|
||||
let pf = pid_file();
|
||||
if let Ok(content) = fs::read_to_string(&pf) {
|
||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
||||
let alive = fs::read_to_string(format!("/proc/{}/comm", pid))
|
||||
.map(|s| s.trim() == "breadclip")
|
||||
.unwrap_or(false);
|
||||
if alive {
|
||||
let _ = Command::new("kill").arg(pid.to_string()).status();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = fs::write(&pf, std::process::id().to_string());
|
||||
true
|
||||
}
|
||||
|
||||
fn cleanup_pid() {
|
||||
let _ = fs::remove_file(pid_file());
|
||||
}
|
||||
|
||||
// ---- UI ---------------------------------------------------------------------
|
||||
|
||||
fn run_ui(entries: Vec<ClipEntry>) {
|
||||
let app = Application::builder()
|
||||
.application_id("com.breadway.breadclip")
|
||||
.build();
|
||||
|
||||
app.connect_activate(move |app| {
|
||||
bread_theme::gtk::apply_shared();
|
||||
bread_theme::gtk::apply_app_css(|| css::build_css(&load_palette()));
|
||||
|
||||
// Full-screen transparent overlay; panel widget is positioned inside it.
|
||||
let window = ApplicationWindow::builder().application(app).build();
|
||||
window.init_layer_shell();
|
||||
window.set_namespace(Some("breadclip"));
|
||||
window.set_layer(Layer::Overlay);
|
||||
window.set_keyboard_mode(KeyboardMode::Exclusive);
|
||||
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
|
||||
window.set_anchor(edge, true);
|
||||
}
|
||||
window.set_exclusive_zone(0);
|
||||
|
||||
// ---- Position panel relative to the active window ----
|
||||
// If a non-fullscreen window is focused, anchor the panel just below it.
|
||||
// Otherwise, centre the panel on screen.
|
||||
let active_win = position::get_active_window();
|
||||
let monitor = position::get_focused_monitor();
|
||||
|
||||
let panel = GBox::new(Orientation::Vertical, 0);
|
||||
panel.add_css_class("clip-panel");
|
||||
panel.set_size_request(PANEL_WIDTH, -1);
|
||||
|
||||
if let Some(ref win) = active_win {
|
||||
let (mon_x, mon_y, mon_w, mon_h) = monitor
|
||||
.as_ref()
|
||||
.map(|m| (m.x, m.y, m.width, m.height))
|
||||
.unwrap_or((0, 0, 1920, 1080));
|
||||
|
||||
// Clamp horizontally so the panel never runs off the left/right
|
||||
// edge of the focused monitor.
|
||||
let clamped_left = win
|
||||
.x
|
||||
.min(mon_x + mon_w - PANEL_WIDTH - PANEL_GAP)
|
||||
.max(mon_x + PANEL_GAP);
|
||||
|
||||
// Prefer anchoring below the window, but flip above it when there
|
||||
// isn't enough room underneath (e.g. a maximized/tiled window
|
||||
// with a text box near the bottom of the screen) — otherwise the
|
||||
// panel gets pushed off-screen and never becomes visible.
|
||||
let space_below = (mon_y + mon_h) - (win.y + win.height + PANEL_GAP);
|
||||
let space_above = win.y - mon_y - PANEL_GAP;
|
||||
let top = if space_below >= PANEL_HEIGHT_ESTIMATE || space_below >= space_above {
|
||||
win.y + win.height + PANEL_GAP
|
||||
} else {
|
||||
win.y - PANEL_GAP - PANEL_HEIGHT_ESTIMATE
|
||||
};
|
||||
let clamped_top = top
|
||||
.min(mon_y + mon_h - PANEL_HEIGHT_ESTIMATE - PANEL_GAP)
|
||||
.max(mon_y + PANEL_GAP);
|
||||
|
||||
panel.set_halign(gtk4::Align::Start);
|
||||
panel.set_valign(gtk4::Align::Start);
|
||||
panel.set_margin_top(clamped_top);
|
||||
panel.set_margin_start(clamped_left);
|
||||
} else {
|
||||
panel.set_halign(gtk4::Align::Center);
|
||||
panel.set_valign(gtk4::Align::Center);
|
||||
}
|
||||
|
||||
// ---- Search entry ----
|
||||
let search = SearchEntry::new();
|
||||
search.set_placeholder_text(Some("Search clipboard…"));
|
||||
panel.append(&search);
|
||||
|
||||
// ---- Filter chips ----
|
||||
let chips_box = GBox::new(Orientation::Horizontal, 4);
|
||||
chips_box.add_css_class("chips-row");
|
||||
|
||||
let chip_labels = ["All", "Text", "Images"];
|
||||
let chip_filters = [Filter::All, Filter::Text, Filter::Images];
|
||||
let chip_buttons: Vec<Button> = chip_labels
|
||||
.iter()
|
||||
.map(|lbl| {
|
||||
let b = Button::with_label(lbl);
|
||||
b.add_css_class("chip");
|
||||
b
|
||||
})
|
||||
.collect();
|
||||
chip_buttons[0].add_css_class("active");
|
||||
for btn in &chip_buttons {
|
||||
chips_box.append(btn);
|
||||
}
|
||||
panel.append(&chips_box);
|
||||
|
||||
// ---- Scrolled list ----
|
||||
let scroll = ScrolledWindow::new();
|
||||
scroll.set_policy(PolicyType::Never, PolicyType::Automatic);
|
||||
scroll.set_max_content_height(480);
|
||||
scroll.set_propagate_natural_height(true);
|
||||
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(SelectionMode::Browse);
|
||||
|
||||
for entry in &entries {
|
||||
list.append(&build_row(entry));
|
||||
}
|
||||
if let Some(first) = list.row_at_index(0) {
|
||||
list.select_row(Some(&first));
|
||||
}
|
||||
|
||||
scroll.set_child(Some(&list));
|
||||
panel.append(&scroll);
|
||||
window.set_child(Some(&panel));
|
||||
|
||||
// ---- Shared state ----
|
||||
let query_rc: Rc<std::cell::RefCell<String>> = Rc::new(std::cell::RefCell::new(String::new()));
|
||||
let filter_rc: Rc<Cell<Filter>> = Rc::new(Cell::new(Filter::All));
|
||||
|
||||
let close_all: Rc<dyn Fn()> = Rc::new({
|
||||
let w = window.clone();
|
||||
move || {
|
||||
cleanup_pid();
|
||||
w.close();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Filter chip handlers ----
|
||||
let buttons_rc = Rc::new(chip_buttons);
|
||||
for (i, filter_val) in chip_filters.iter().enumerate() {
|
||||
let filter_r = Rc::clone(&filter_rc);
|
||||
let buttons_r = Rc::clone(&buttons_rc);
|
||||
let buttons_for_closure = Rc::clone(&buttons_rc);
|
||||
let list_r = list.clone();
|
||||
let query_r = Rc::clone(&query_rc);
|
||||
let fv = *filter_val;
|
||||
// Borrow buttons_r only for the index, move the separate clone into the closure
|
||||
buttons_r[i].connect_clicked(move |_| {
|
||||
filter_r.set(fv);
|
||||
for (j, b) in buttons_for_closure.iter().enumerate() {
|
||||
if j == i {
|
||||
b.add_css_class("active");
|
||||
} else {
|
||||
b.remove_css_class("active");
|
||||
}
|
||||
}
|
||||
refresh_list(&list_r, &query_r.borrow(), fv);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Search handler ----
|
||||
{
|
||||
let list_f = list.clone();
|
||||
let filter_f = Rc::clone(&filter_rc);
|
||||
let query_f = Rc::clone(&query_rc);
|
||||
search.connect_changed(move |entry| {
|
||||
let text = entry.text().to_string();
|
||||
*query_f.borrow_mut() = text.clone();
|
||||
refresh_list(&list_f, &text, filter_f.get());
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Keyboard handler (capture phase, same as breadbox) ----
|
||||
let key_ctrl = EventControllerKey::new();
|
||||
key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||
{
|
||||
let close_k = Rc::clone(&close_all);
|
||||
let list_k = list.clone();
|
||||
key_ctrl.connect_key_pressed(move |_, key, _, _| {
|
||||
use gtk4::gdk::Key;
|
||||
match key {
|
||||
Key::Escape => {
|
||||
close_k();
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
Key::Return | Key::KP_Enter => {
|
||||
if let Some(row) = list_k.selected_row() {
|
||||
if let Some(entry) = get_row_entry(&row) {
|
||||
do_copy(&entry);
|
||||
close_k();
|
||||
}
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
Key::Delete => {
|
||||
if let Some(row) = list_k.selected_row() {
|
||||
if let Some(entry) = get_row_entry(&row) {
|
||||
if let Ok(db) = HistoryDb::open() {
|
||||
let _ = db.delete_entry(entry.id);
|
||||
}
|
||||
// Select adjacent row before hiding so focus doesn't vanish
|
||||
let next = list_k
|
||||
.row_at_index(row.index() + 1)
|
||||
.filter(|r| r.is_visible())
|
||||
.or_else(|| {
|
||||
list_k
|
||||
.row_at_index((row.index() - 1).max(0))
|
||||
.filter(|r| r.is_visible())
|
||||
});
|
||||
list_k.select_row(next.as_ref());
|
||||
row.set_visible(false);
|
||||
}
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
Key::Down => {
|
||||
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1);
|
||||
let mut i = cur + 1;
|
||||
loop {
|
||||
match list_k.row_at_index(i) {
|
||||
Some(r) if r.is_visible() => {
|
||||
list_k.select_row(Some(&r));
|
||||
break;
|
||||
}
|
||||
Some(_) => i += 1,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
Key::Up => {
|
||||
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0);
|
||||
let mut i = cur - 1;
|
||||
loop {
|
||||
if i < 0 {
|
||||
break;
|
||||
}
|
||||
match list_k.row_at_index(i) {
|
||||
Some(r) if r.is_visible() => {
|
||||
list_k.select_row(Some(&r));
|
||||
break;
|
||||
}
|
||||
Some(_) => i -= 1,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
_ => glib::Propagation::Proceed,
|
||||
}
|
||||
});
|
||||
}
|
||||
window.add_controller(key_ctrl);
|
||||
|
||||
// ---- Row click ----
|
||||
{
|
||||
let close_a = Rc::clone(&close_all);
|
||||
list.connect_row_activated(move |_, row| {
|
||||
if let Some(entry) = get_row_entry(row) {
|
||||
do_copy(&entry);
|
||||
close_a();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Click outside panel → close (same pattern as breadbox) ----
|
||||
{
|
||||
let close_outside = Rc::clone(&close_all);
|
||||
let panel_ref = panel.clone();
|
||||
let win_ref = window.clone();
|
||||
let outside_click = gtk4::GestureClick::new();
|
||||
outside_click.connect_pressed(move |_, _, x, y| {
|
||||
if let Some(b) = panel_ref.compute_bounds(&win_ref) {
|
||||
let outside = x < b.x() as f64
|
||||
|| x > (b.x() + b.width()) as f64
|
||||
|| y < b.y() as f64
|
||||
|| y > (b.y() + b.height()) as f64;
|
||||
if outside {
|
||||
close_outside();
|
||||
}
|
||||
}
|
||||
});
|
||||
window.add_controller(outside_click);
|
||||
}
|
||||
|
||||
window.connect_destroy(|_| cleanup_pid());
|
||||
window.present();
|
||||
search.grab_focus();
|
||||
});
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
||||
// ---- Main -------------------------------------------------------------------
|
||||
|
||||
fn main() {
|
||||
if !toggle_or_continue() {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = HistoryDb::open()
|
||||
.and_then(|db| db.list_entries(MAX_ENTRIES))
|
||||
.unwrap_or_default();
|
||||
|
||||
run_ui(entries);
|
||||
}
|
||||
71
breadclip/src/position.rs
Normal file
71
breadclip/src/position.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
use std::{env, io::{Read, Write}, os::unix::net::UnixStream};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct WindowInfo {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct MonitorInfo {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
/// Query Hyprland for the currently active (focused) window via its IPC socket.
|
||||
/// Returns `None` if the window is fullscreen or no window is focused.
|
||||
pub fn get_active_window() -> Option<WindowInfo> {
|
||||
let json = hyprctl_json("j/activewindow")?;
|
||||
let v: serde_json::Value = serde_json::from_str(&json).ok()?;
|
||||
|
||||
// Fullscreen windows should cause centred fallback positioning
|
||||
if v["fullscreen"].as_i64().unwrap_or(0) != 0 {
|
||||
return None;
|
||||
}
|
||||
// "class" is empty when no window is focused
|
||||
if v["class"].as_str().unwrap_or("").is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let x = v["at"][0].as_i64()? as i32;
|
||||
let y = v["at"][1].as_i64()? as i32;
|
||||
let w = v["size"][0].as_i64()? as i32;
|
||||
let h = v["size"][1].as_i64()? as i32;
|
||||
Some(WindowInfo { x, y, width: w, height: h })
|
||||
}
|
||||
|
||||
/// Query Hyprland for the focused monitor's dimensions.
|
||||
pub fn get_focused_monitor() -> Option<MonitorInfo> {
|
||||
let json = hyprctl_json("j/monitors")?;
|
||||
let v: serde_json::Value = serde_json::from_str(&json).ok()?;
|
||||
let monitors = v.as_array()?;
|
||||
let m = monitors
|
||||
.iter()
|
||||
.find(|m| m["focused"].as_bool().unwrap_or(false))
|
||||
.or_else(|| monitors.first())?;
|
||||
Some(MonitorInfo {
|
||||
x: m["x"].as_i64()? as i32,
|
||||
y: m["y"].as_i64()? as i32,
|
||||
width: m["width"].as_i64()? as i32,
|
||||
height: m["height"].as_i64()? as i32,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a request to Hyprland's IPC socket and return the response string.
|
||||
fn hyprctl_json(request: &str) -> Option<String> {
|
||||
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
|
||||
let rt = env::var("XDG_RUNTIME_DIR").ok()?;
|
||||
let socket = format!("{}/hypr/{}/.socket.sock", rt, sig);
|
||||
|
||||
let mut stream = UnixStream::connect(&socket).ok()?;
|
||||
stream.write_all(request.as_bytes()).ok()?;
|
||||
stream.shutdown(std::net::Shutdown::Write).ok()?;
|
||||
|
||||
let mut buf = String::new();
|
||||
stream.read_to_string(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
}
|
||||
11
breadclipd/Cargo.toml
Normal file
11
breadclipd/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "breadclipd"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "breadclipd"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
breadclip-core = { path = "../breadclip-core" }
|
||||
125
breadclipd/src/main.rs
Normal file
125
breadclipd/src/main.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
use breadclip_core::{sha256_hex, HistoryDb};
|
||||
use std::{env, fs, path::PathBuf, process::Command, thread, time::Duration};
|
||||
|
||||
fn get_available_types() -> Vec<String> {
|
||||
Command::new("wl-paste")
|
||||
.args(["--list-types"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.lines().map(str::trim).map(String::from).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn get_clipboard_text() -> Option<String> {
|
||||
let output = Command::new("wl-paste")
|
||||
.args(["--no-newline", "--type", "text/plain"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() || output.stdout.is_empty() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(output.stdout)
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
fn get_clipboard_image() -> Option<Vec<u8>> {
|
||||
let output = Command::new("wl-paste")
|
||||
.args(["--type", "image/png"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() || output.stdout.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(output.stdout)
|
||||
}
|
||||
|
||||
fn lock_file() -> PathBuf {
|
||||
env::var("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("/tmp"))
|
||||
.join("breadclipd.lock")
|
||||
}
|
||||
|
||||
// Returns false if another instance is already running.
|
||||
fn acquire_lock() -> bool {
|
||||
let path = lock_file();
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
||||
let alive = fs::read_to_string(format!("/proc/{}/comm", pid))
|
||||
.map(|s| s.trim() == "breadclipd")
|
||||
.unwrap_or(false);
|
||||
if alive {
|
||||
eprintln!("breadclipd: already running (pid {})", pid);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = fs::write(&path, std::process::id().to_string());
|
||||
true
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if !acquire_lock() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Wait briefly for WAYLAND_DISPLAY — common when started early in the session
|
||||
let mut retries = 0;
|
||||
while env::var("WAYLAND_DISPLAY").is_err() && retries < 20 {
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
retries += 1;
|
||||
}
|
||||
if env::var("WAYLAND_DISPLAY").is_err() {
|
||||
eprintln!("breadclipd: WAYLAND_DISPLAY not set after waiting, exiting");
|
||||
let _ = fs::remove_file(lock_file());
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let db = match HistoryDb::open() {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
eprintln!("breadclipd: failed to open database: {e}");
|
||||
let _ = fs::remove_file(lock_file());
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("breadclipd: started (pid {})", std::process::id());
|
||||
|
||||
let mut last_text_hash: Option<String> = None;
|
||||
let mut last_image_hash: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let types = get_available_types();
|
||||
let has_image = types.iter().any(|t| t == "image/png" || t == "image/jpeg");
|
||||
let has_text = types.iter().any(|t| t.starts_with("text/"));
|
||||
|
||||
if has_image && !has_text {
|
||||
if let Some(bytes) = get_clipboard_image() {
|
||||
let hash = sha256_hex(&bytes);
|
||||
if Some(&hash) != last_image_hash.as_ref() {
|
||||
last_image_hash = Some(hash);
|
||||
last_text_hash = None;
|
||||
if let Err(e) = db.insert_image(&bytes) {
|
||||
eprintln!("breadclipd: insert image: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if has_text {
|
||||
if let Some(text) = get_clipboard_text() {
|
||||
let hash = sha256_hex(text.as_bytes());
|
||||
if Some(&hash) != last_text_hash.as_ref() {
|
||||
last_text_hash = Some(hash);
|
||||
if let Err(e) = db.insert_text(&text) {
|
||||
eprintln!("breadclipd: insert text: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
19
contrib/breadclipd.service
Normal file
19
contrib/breadclipd.service
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[Unit]
|
||||
Description=breadclip clipboard daemon
|
||||
Documentation=https://git.breadway.dev/breadway/breadclip
|
||||
# Start after the graphical session is ready so WAYLAND_DISPLAY is set
|
||||
After=graphical-session.target
|
||||
PartOf=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/.cargo/bin/breadclipd
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
# Forward stdout/stderr to the journal so `journalctl --user -u breadclipd` works
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
10
contrib/hyprland.conf
Normal file
10
contrib/hyprland.conf
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# breadclip — add these to your hyprland.conf
|
||||
#
|
||||
# Blur: blurs what's behind the transparent window, giving the frosted-glass look.
|
||||
# ignorezero: skips blurring fully-transparent pixels (outside the panel) for
|
||||
# a cleaner result.
|
||||
layerrule = blur, breadclip
|
||||
layerrule = ignorezero, breadclip
|
||||
|
||||
# Keybind: Super+V opens the clipboard history popup.
|
||||
bind = $mainMod, V, exec, breadclip
|
||||
Loading…
Add table
Add a link
Reference in a new issue