breadclip: pin toggle, primary/pin badges, config-driven panel
- Ctrl+P pins/unpins the selected row, re-reads history (pinned rows sort first), rebuilds the list and re-selects the toggled row. - ★ badge on pinned rows, "primary" badge on primary-selection rows. - Panel width comes from `config.panel_width`; positioning now runs after the panel is realised and measures its natural height instead of always assuming the worst-case estimate, so a short history gets a correctly-anchored short panel. - Delete in the search box edits the query again instead of deleting the selected history row. - Re-copy offers an image row as its stored MIME type (JPEG stays JPEG). History fetch is a flat cap since pinned rows can exceed retention.
This commit is contained in:
parent
83630033cd
commit
d308593efd
1 changed files with 160 additions and 74 deletions
|
|
@ -18,11 +18,13 @@ use std::{
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PANEL_WIDTH: i32 = 520;
|
// Pinned entries are exempt from trimming, so the fetch limit can't be
|
||||||
const MAX_ENTRIES: usize = 200;
|
// derived from the retention caps alone. History stays tiny in practice —
|
||||||
|
// fetching it all keeps every pinned row reachable regardless of count.
|
||||||
|
const FETCH_ALL: usize = 10_000;
|
||||||
const PANEL_GAP: i32 = 8; // gap between focused window bottom and panel top
|
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
|
// Fallback panel height, used only if measuring the panel before it's shown
|
||||||
// the panel fully on-screen before its natural size is known.
|
// reports nothing sensible.
|
||||||
const PANEL_HEIGHT_ESTIMATE: i32 = 580;
|
const PANEL_HEIGHT_ESTIMATE: i32 = 580;
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
|
@ -142,6 +144,21 @@ fn build_row(entry: &ClipEntry) -> gtk4::ListBoxRow {
|
||||||
hbox.append(&text_lbl);
|
hbox.append(&text_lbl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pin / primary-selection badges
|
||||||
|
if entry.pinned {
|
||||||
|
let pin_lbl = Label::new(Some("★"));
|
||||||
|
pin_lbl.add_css_class("clip-meta");
|
||||||
|
pin_lbl.add_css_class("clip-pinned");
|
||||||
|
pin_lbl.set_tooltip_text(Some("Pinned — Ctrl+P toggles"));
|
||||||
|
hbox.append(&pin_lbl);
|
||||||
|
}
|
||||||
|
if entry.is_primary {
|
||||||
|
let prim_lbl = Label::new(Some("primary"));
|
||||||
|
prim_lbl.add_css_class("clip-meta");
|
||||||
|
prim_lbl.set_tooltip_text(Some("Copied from the primary (middle-click) selection"));
|
||||||
|
hbox.append(&prim_lbl);
|
||||||
|
}
|
||||||
|
|
||||||
let ts_lbl = Label::new(Some(&format_timestamp(entry.timestamp)));
|
let ts_lbl = Label::new(Some(&format_timestamp(entry.timestamp)));
|
||||||
ts_lbl.add_css_class("clip-meta");
|
ts_lbl.add_css_class("clip-meta");
|
||||||
ts_lbl.set_xalign(1.0);
|
ts_lbl.set_xalign(1.0);
|
||||||
|
|
@ -153,20 +170,21 @@ fn build_row(entry: &ClipEntry) -> gtk4::ListBoxRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn do_copy(entry: &ClipEntry) {
|
fn do_copy(entry: &ClipEntry) {
|
||||||
match entry.mime_type.as_str() {
|
if entry.mime_type.starts_with("image/") {
|
||||||
"image/png" => {
|
// Re-paste with the same type we stored — a JPEG row must be offered
|
||||||
|
// as image/jpeg, not relabeled as image/png.
|
||||||
if let Some(ref path) = entry.image_path {
|
if let Some(ref path) = entry.image_path {
|
||||||
if let Ok(file) = fs::File::open(path) {
|
if let Ok(file) = fs::File::open(path) {
|
||||||
let _ = Command::new("wl-copy")
|
let _ = Command::new("wl-copy")
|
||||||
.args(["--type", "image/png"])
|
.args(["--type", entry.mime_type.as_str()])
|
||||||
.stdin(Stdio::from(file))
|
.stdin(Stdio::from(file))
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.spawn();
|
.spawn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
_ => {
|
|
||||||
if let Some(ref content) = entry.content {
|
if let Some(ref content) = entry.content {
|
||||||
if let Ok(mut child) = Command::new("wl-copy")
|
if let Ok(mut child) = Command::new("wl-copy")
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
|
|
@ -179,13 +197,18 @@ fn do_copy(entry: &ClipEntry) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- UI ---------------------------------------------------------------------
|
// ---- UI ---------------------------------------------------------------------
|
||||||
|
|
||||||
fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::ScreenshotRequest>) {
|
fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::ScreenshotRequest>) {
|
||||||
|
// Live snapshot of the entries backing the list — pinning re-reads the
|
||||||
|
// DB (pinned rows sort first) and rebuilds the rows from this. Built
|
||||||
|
// outside connect_activate because that handler is `Fn` (runs once per
|
||||||
|
// activation) and can't consume the entries vector.
|
||||||
|
let entries_rc: Rc<std::cell::RefCell<Vec<ClipEntry>>> =
|
||||||
|
Rc::new(std::cell::RefCell::new(entries));
|
||||||
|
|
||||||
let mut builder = Application::builder().application_id("com.breadway.breadclip");
|
let mut builder = Application::builder().application_id("com.breadway.breadclip");
|
||||||
if screenshot_req.is_some() {
|
if screenshot_req.is_some() {
|
||||||
// GApplication is single-instance by default; this machine typically
|
// GApplication is single-instance by default; this machine typically
|
||||||
|
|
@ -205,52 +228,10 @@ fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::Screenshot
|
||||||
let window = bread_utils::gtk_popup::new_overlay_window(app, "breadclip");
|
let window = bread_utils::gtk_popup::new_overlay_window(app, "breadclip");
|
||||||
bread_theme::gtk::bind_window_auto(&window);
|
bread_theme::gtk::bind_window_auto(&window);
|
||||||
|
|
||||||
// ---- Position panel relative to the active window ----
|
let panel_width = breadclip_core::config::load().panel_width;
|
||||||
// If a non-fullscreen window is focused, anchor the panel just below it.
|
|
||||||
// Otherwise, centre the panel on screen.
|
|
||||||
let active_win = bread_utils::hypr::active_window();
|
|
||||||
let monitor = bread_utils::hypr::focused_monitor();
|
|
||||||
|
|
||||||
let panel = GBox::new(Orientation::Vertical, 0);
|
let panel = GBox::new(Orientation::Vertical, 0);
|
||||||
panel.add_css_class("clip-panel");
|
panel.add_css_class("clip-panel");
|
||||||
panel.set_size_request(PANEL_WIDTH, -1);
|
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 ----
|
// ---- Search entry ----
|
||||||
let search = SearchEntry::new();
|
let search = SearchEntry::new();
|
||||||
|
|
@ -286,7 +267,7 @@ fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::Screenshot
|
||||||
let list = ListBox::new();
|
let list = ListBox::new();
|
||||||
list.set_selection_mode(SelectionMode::Browse);
|
list.set_selection_mode(SelectionMode::Browse);
|
||||||
|
|
||||||
for entry in &entries {
|
for entry in entries_rc.borrow().iter() {
|
||||||
list.append(&build_row(entry));
|
list.append(&build_row(entry));
|
||||||
}
|
}
|
||||||
if let Some(first) = list.row_at_index(0) {
|
if let Some(first) = list.row_at_index(0) {
|
||||||
|
|
@ -297,6 +278,60 @@ fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::Screenshot
|
||||||
panel.append(&scroll);
|
panel.append(&scroll);
|
||||||
window.set_child(Some(&panel));
|
window.set_child(Some(&panel));
|
||||||
|
|
||||||
|
// ---- Position panel relative to the active window ----
|
||||||
|
// Measure the panel's natural height (a short history yields a short
|
||||||
|
// panel) so the anchor math uses the real size instead of a
|
||||||
|
// worst-case estimate. Falls back to the estimate if measurement
|
||||||
|
// reports nothing sensible.
|
||||||
|
let (_min_h, natural_h, _min_baseline, _natural_baseline) =
|
||||||
|
panel.measure(gtk4::Orientation::Vertical, -1);
|
||||||
|
let panel_height = if natural_h > 0 {
|
||||||
|
natural_h
|
||||||
|
} else {
|
||||||
|
PANEL_HEIGHT_ESTIMATE
|
||||||
|
};
|
||||||
|
|
||||||
|
// If a non-fullscreen window is focused, anchor the panel just below it.
|
||||||
|
// Otherwise, centre the panel on screen.
|
||||||
|
let active_win = bread_utils::hypr::active_window();
|
||||||
|
let monitor = bread_utils::hypr::focused_monitor();
|
||||||
|
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 || space_below >= space_above {
|
||||||
|
win.y() + win.height() + PANEL_GAP
|
||||||
|
} else {
|
||||||
|
win.y() - PANEL_GAP - panel_height
|
||||||
|
};
|
||||||
|
let clamped_top = top
|
||||||
|
.min(mon_y + mon_h - panel_height - 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);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Shared state ----
|
// ---- Shared state ----
|
||||||
let query_rc: Rc<std::cell::RefCell<String>> = Rc::new(std::cell::RefCell::new(String::new()));
|
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 filter_rc: Rc<Cell<Filter>> = Rc::new(Cell::new(Filter::All));
|
||||||
|
|
@ -349,7 +384,11 @@ fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::Screenshot
|
||||||
{
|
{
|
||||||
let close_k = Rc::clone(&close_all);
|
let close_k = Rc::clone(&close_all);
|
||||||
let list_k = list.clone();
|
let list_k = list.clone();
|
||||||
key_ctrl.connect_key_pressed(move |_, key, _, _| {
|
let search_k = search.clone();
|
||||||
|
let entries_k = Rc::clone(&entries_rc);
|
||||||
|
let query_k = Rc::clone(&query_rc);
|
||||||
|
let filter_k = Rc::clone(&filter_rc);
|
||||||
|
key_ctrl.connect_key_pressed(move |_, key, _, state| {
|
||||||
use gtk4::gdk::Key;
|
use gtk4::gdk::Key;
|
||||||
match key {
|
match key {
|
||||||
Key::Escape => {
|
Key::Escape => {
|
||||||
|
|
@ -366,6 +405,11 @@ fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::Screenshot
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
Key::Delete => {
|
Key::Delete => {
|
||||||
|
// Deleting a character in the search box must not
|
||||||
|
// delete the selected history row.
|
||||||
|
if search_k.has_focus() {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
}
|
||||||
if let Some(row) = list_k.selected_row() {
|
if let Some(row) = list_k.selected_row() {
|
||||||
if let Some(entry) = get_row_entry(&row) {
|
if let Some(entry) = get_row_entry(&row) {
|
||||||
if let Ok(db) = HistoryDb::open() {
|
if let Ok(db) = HistoryDb::open() {
|
||||||
|
|
@ -394,6 +438,48 @@ fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::Screenshot
|
||||||
bread_utils::gtk_popup::select_prev_visible(&list_k);
|
bread_utils::gtk_popup::select_prev_visible(&list_k);
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
|
// Pin/unpin the selected entry. Pinned rows sort to the
|
||||||
|
// top and survive trimming, so re-read the DB and rebuild
|
||||||
|
// the list, then re-select the row we just toggled.
|
||||||
|
Key::P if state.contains(gtk4::gdk::ModifierType::CONTROL_MASK) => {
|
||||||
|
if let Some(row) = list_k.selected_row() {
|
||||||
|
if let Some(entry) = get_row_entry(&row) {
|
||||||
|
if let Ok(db) = HistoryDb::open() {
|
||||||
|
let _ = db.set_pinned(entry.id, !entry.pinned);
|
||||||
|
}
|
||||||
|
let target_id = entry.id;
|
||||||
|
let new_entries = HistoryDb::open()
|
||||||
|
.and_then(|db| db.list_entries(FETCH_ALL))
|
||||||
|
.unwrap_or_default();
|
||||||
|
*entries_k.borrow_mut() = new_entries;
|
||||||
|
while let Some(r) = list_k.row_at_index(0) {
|
||||||
|
list_k.remove(&r);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let rows = entries_k.borrow();
|
||||||
|
for e in rows.iter() {
|
||||||
|
list_k.append(&build_row(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let query = query_k.borrow().clone();
|
||||||
|
refresh_list(&list_k, &query, filter_k.get());
|
||||||
|
// Prefer re-selecting the row we just toggled.
|
||||||
|
let mut i = 0;
|
||||||
|
while let Some(r) = list_k.row_at_index(i) {
|
||||||
|
if get_row_entry(&r)
|
||||||
|
.map(|e| e.id == target_id)
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& r.is_visible()
|
||||||
|
{
|
||||||
|
list_k.select_row(Some(&r));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
glib::Propagation::Stop
|
||||||
|
}
|
||||||
_ => glib::Propagation::Proceed,
|
_ => glib::Propagation::Proceed,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -465,7 +551,7 @@ fn main() {
|
||||||
};
|
};
|
||||||
|
|
||||||
let entries = HistoryDb::open()
|
let entries = HistoryDb::open()
|
||||||
.and_then(|db| db.list_entries(MAX_ENTRIES))
|
.and_then(|db| db.list_entries(FETCH_ALL))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
run_ui(entries, screenshot_req);
|
run_ui(entries, screenshot_req);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue