Initial commit

This commit is contained in:
Breadway 2026-07-02 20:59:21 +08:00
commit 69bc67e29a
16 changed files with 2376 additions and 0 deletions

31
.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load diff

3
Cargo.toml Normal file
View file

@ -0,0 +1,3 @@
[workspace]
members = ["breadclip-core", "breadclipd", "breadclip"]
resolver = "2"

21
LICENSE Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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: &gtk4::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
View 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
View 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
View 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));
}
}

View 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
View 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