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