diff --git a/Cargo.lock b/Cargo.lock index b47dd23..c9780a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,8 +121,6 @@ source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517 dependencies = [ "bread-shared", "dirs", - "gtk4", - "gtk4-layer-shell", "serde", "serde_json", ] diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index fde186f..0977c1e 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -10,7 +10,7 @@ path = "src/main.rs" [dependencies] bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client", "gtk"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } breadbox-shared = { path = "../breadbox-shared" } diff --git a/breadbox/src/main.rs b/breadbox/src/main.rs index b29b80c..3570852 100644 --- a/breadbox/src/main.rs +++ b/breadbox/src/main.rs @@ -1,15 +1,15 @@ use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette}; use bread_utils::bread_client::BreadClient; use std::{ - cell::{Cell, RefCell}, + cell::RefCell, collections::HashMap, - env, fs, + env, + fs, io::{Read, Write}, os::unix::net::UnixStream, path::{Path, PathBuf}, process::{Command, Stdio}, rc::Rc, - time::Duration, }; /// This app's id in bread's sibling-app namespace registry @@ -20,9 +20,13 @@ use breadbox_shared::{ config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, LaunchHistory, }; use gtk4::{ - glib, pango::EllipsizeMode, prelude::*, Application, Box as GBox, CssProvider, Entry, - EventControllerKey, Label, ListBox, Orientation, PolicyType, ScrolledWindow, SelectionMode, + glib, + pango::EllipsizeMode, + prelude::*, + Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label, + ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode, }; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; mod listen; mod screenshot; @@ -86,9 +90,7 @@ fn load_sorted_entries( (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => { // Most-launched first, then alphabetical - history - .count(&b.name) - .cmp(&history.count(&a.name)) + history.count(&b.name).cmp(&history.count(&a.name)) .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) } } @@ -136,171 +138,58 @@ fn matches_term(field: &str, term: &str) -> bool { // ---- Theming ---------------------------------------------------------------- -const STAGGER_ROWS: usize = 12; - fn build_css(p: &Palette) -> String { - let bg_panel = hex_to_rgba(&p.background, 0.68); + let bg_panel = hex_to_rgba(&p.background, 0.60); // breadbox-specific rules only — fonts, palette, and generic widgets come // from the shared ecosystem stylesheet (applied first in connect_activate). - // Colour is set on each surface (panel, search, hovered/selected row) so + // Colour is set on each surface (panel, search box, hovered/selected row) so // child labels inherit the legible ink for that background. `on_*` are - // luminance-picked black/white — the pywal hues are untouched. - // - // GTK4 ListBox's node is `list`, not `listbox`. These `list row:selected` - // rules beat the shared sheet's solid accent fill + on-accent ink so the - // glass card keeps a tinted selection and a left inset hairline. - let stagger = (0..STAGGER_ROWS) - .map(|i| { - format!( - ".launcher-bg.just-opened list row.stagger-{i} {{ animation-delay: {}ms; }}", - i * 28 - ) - }) - .collect::>() - .join(""); + // luminance-picked black/white — the pywal hues are untouched. Without this a + // light `surface` slot makes the selected row's text vanish. format!( - "\ -window {{ background-color: rgba(0, 0, 0, 0.28); animation: scrim-in 0.28s ease both; }}\ -@keyframes scrim-in {{\ - from {{ background-color: rgba(0, 0, 0, 0); }}\ - to {{ background-color: rgba(0, 0, 0, 0.28); }}\ -}}\ -.launcher-bg {{\ - background-color: {bg_panel}; color: {on_bg}; border-radius: 20px;\ - border: 1px solid alpha({on_bg}, 0.14);\ - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.50);\ - animation: card-in 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;\ -}}\ -@keyframes card-in {{\ - from {{ opacity: 0; margin-top: 112px; }}\ - to {{ opacity: 1; margin-top: 88px; }}\ -}}\ -.launcher-bg entry {{\ - background-color: transparent; color: {on_bg}; caret-color: {accent};\ - border: none; outline: none; box-shadow: none;\ - padding: 20px 22px 14px; border-radius: 20px 20px 0 0;\ - font-size: 17px; min-height: 28px;\ -}}\ -.launcher-bg entry:focus, .launcher-bg entry:focus-within {{\ - border: none; outline: none; box-shadow: none; background-color: transparent;\ -}}\ -entry > text {{ background: transparent; }}\ -entry image {{ opacity: 0; min-width: 0; margin: 0; padding: 0; }}\ -.launcher-caret {{\ - min-height: 2px; max-height: 2px; margin: 0 20px; border-radius: 2px;\ - background-color: {accent};\ - background-image: linear-gradient(90deg, {accent}, {accent2});\ -}}\ -.launcher-bg.just-opened .launcher-caret {{\ - animation: caret-draw 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;\ -}}\ -@keyframes caret-draw {{\ - from {{ margin-right: 600px; opacity: 0.25; }}\ - to {{ margin-right: 20px; opacity: 1; }}\ -}}\ -scrolledwindow {{ background: transparent; }}\ -list {{ background-color: transparent; padding: 8px 8px 4px; }}\ -list row {{\ - padding: 6px 8px; color: {on_bg}; background-color: transparent;\ - border-radius: 14px; margin: 1px 10px; outline: none;\ -}}\ -list row:hover {{ background-color: alpha({on_bg}, 0.07); color: {on_bg}; }}\ -row:selected, list row:selected, list row:selected:focus,\ -list row:selected:hover, list row:selected:focus:hover {{\ - background-color: alpha({accent}, 0.22); color: {on_bg};\ - outline: none; box-shadow: none;\ -}}\ -list row:selected label, list row:selected .app-name, list row:selected .app-muted {{\ - color: {on_bg};\ -}}\ -.app-row {{ min-height: 48px; }}\ -.app-icon-well {{\ - min-width: 38px; min-height: 38px; margin-right: 12px;\ - border-radius: 999px; background-color: alpha({on_bg}, 0.08);\ -}}\ -.app-icon {{ color: {on_bg}; opacity: 0.88; }}\ -.app-name {{ font-size: 14px; font-weight: bold; }}\ -.app-muted {{ opacity: 0.48; font-size: 11px; }}\ -.launcher-footer {{\ - padding: 8px 18px 12px; font-size: 11px; opacity: 0.40;\ - letter-spacing: 0.08em; text-transform: uppercase;\ -}}\ -.launcher-bg.just-opened list row {{\ - animation: row-in 0.32s cubic-bezier(0.22, 1, 0.36, 1) both;\ -}}\ -@keyframes row-in {{\ - from {{ opacity: 0; }}\ - to {{ opacity: 1; }}\ -}}\ -{stagger}\ -list.reflow row {{ animation: row-fade 0.16s ease both; }}\ -@keyframes row-fade {{\ - from {{ opacity: 0.40; }}\ - to {{ opacity: 1; }}\ -}}\ -.no-motion, .no-motion * {{ animation: none; transition: none; }}", - bg_panel = bg_panel, - accent = p.color4, - accent2 = p.color5, - on_bg = ink_on(&p.background), - stagger = stagger, + "window {{ background-color: transparent; }}\ + .launcher-bg {{ background-color: {bg_panel}; color: {on_bg}; border-radius: 8px;\ + box-shadow: 0 8px 32px rgba(0,0,0,0.6); }}\ + searchentry {{ background-color: {surface}; color: {on_surface}; caret-color: {accent};\ + border: none; outline: none; box-shadow: none;\ + padding: 12px 16px; border-radius: 6px 6px 0 0; }}\ + listbox {{ background-color: transparent; padding: 4px; }}\ + row {{ padding: 8px 12px; color: {on_bg}; background-color: transparent;\ + border-radius: 6px; }}\ + row:hover {{ background-color: {surface}; color: {on_surface}; }}\ + row:selected {{ background-color: {surface}; color: {on_surface}; }}\ + .app-name {{ font-size: 14px; }}\ + .app-muted {{ opacity: 0.6; font-size: 12px; }}\ + image {{ margin-right: 8px; }}", + bg_panel = bg_panel, + surface = p.color0, + accent = p.color4, + on_bg = ink_on(&p.background), + on_surface = ink_on(&p.color0), ) } -fn category_label(entry: &DesktopEntry) -> &'static str { - let has = |needle: &str| { - entry - .categories - .iter() - .any(|c| c.eq_ignore_ascii_case(needle) || c.to_ascii_lowercase().contains(needle)) - }; - if entry.terminal || has("terminalemulator") { - "Terminal" - } else if has("webbrowser") { - "Browser" - } else if has("game") { - "Games" - } else if has("instantmessaging") || has("chat") || has("ircclient") { - "Chat" - } else if has("settings") || has("desktopsettings") || has("system") { - "System" - } else if has("ide") || has("development") { - "IDE" - } else if has("office") || has("wordprocessor") || has("texteditor") || has("notes") { - "Notes" - } else if has("audio") || has("player") || has("audiovideo") { - "Music" - } else if has("graphics") || has("photography") || has("camera") { - "Capture" - } else if has("filemanager") { - "Files" - } else { - "App" - } -} - -fn symbolic_icon(entry: &DesktopEntry) -> &'static str { - match category_label(entry) { - "Browser" => "web-browser-symbolic", - "Terminal" => "utilities-terminal-symbolic", - "Notes" => "accessories-text-editor-symbolic", - "System" => "emblem-system-symbolic", - "Chat" => "user-available-symbolic", - "Games" => "applications-games-symbolic", - "Music" => "audio-x-generic-symbolic", - "Capture" => "camera-photo-symbolic", - "IDE" => "applications-engineering-symbolic", - "Files" => "folder-symbolic", - _ => "application-x-executable-symbolic", - } -} - // ---- Icon loading ----------------------------------------------------------- -fn make_icon(entry: &DesktopEntry) -> gtk4::Image { - let img = gtk4::Image::from_icon_name(symbolic_icon(entry)); - img.set_pixel_size(18); - img.add_css_class("app-icon"); +fn make_icon(icon_name: &str, icon_path: Option<&Path>) -> gtk4::Image { + // Try loading from resolved cached path via gio::File + if let Some(path) = icon_path { + let gio_file = gtk4::gio::File::for_path(path); + if let Ok(texture) = gtk4::gdk::Texture::from_file(&gio_file) { + let img = gtk4::Image::new(); + img.set_paintable(Some(&texture)); + img.set_pixel_size(32); + return img; + } + } + // Fall back to GTK icon theme lookup by name + let name = if icon_name.is_empty() { + "application-x-executable" + } else { + icon_name + }; + let img = gtk4::Image::from_icon_name(name); + img.set_pixel_size(32); img } @@ -382,18 +271,10 @@ fn fuzzy_score(query: &str, entry: &DesktopEntry) -> u32 { let q = query.to_lowercase(); let name = entry.name.to_lowercase(); let wm = entry.wm_class.as_deref().unwrap_or("").to_lowercase(); - if name == q || wm == q { - return 0; - } - if name.starts_with(&q) { - return 1; - } - if name.contains(&q) { - return 2; - } - if wm.starts_with(&q) || wm.contains(&q) { - return 3; - } + if name == q || wm == q { return 0; } + if name.starts_with(&q) { return 1; } + if name.contains(&q) { return 2; } + if wm.starts_with(&q) || wm.contains(&q) { return 3; } 4 // subsequence match } @@ -406,26 +287,6 @@ fn get_row_entry(row: >k4::ListBoxRow) -> Option { } } -fn visible_row_count(list: &ListBox) -> u32 { - let mut n = 0; - let mut i = 0; - while let Some(row) = list.row_at_index(i) { - if row.is_visible() { - n += 1; - } - i += 1; - } - n -} - -fn set_footer_count(footer: &Label, n: u32) { - match n { - 0 => footer.set_text("no match"), - 1 => footer.set_text("1 app"), - n => footer.set_text(&format!("{n} apps")), - } -} - fn run_ui( entries: Vec, history: LaunchHistory, @@ -459,8 +320,16 @@ fn run_ui( bread_theme::gtk::apply_user_css(&user_css_path, &user_cell); } - // Full-screen transparent overlay; panel widget is positioned inside it. - let window = bread_utils::gtk_popup::new_overlay_window(app, "breadbox"); + // Full-screen transparent window; clicks outside the launcher panel close it. + let window = ApplicationWindow::builder().application(app).build(); + window.init_layer_shell(); + window.set_namespace(Some("breadbox")); + 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); bread_theme::gtk::bind_window_auto(&window); let close_all: Rc = Rc::new({ @@ -474,25 +343,13 @@ fn run_ui( vbox.add_css_class("launcher-bg"); vbox.set_halign(gtk4::Align::Center); vbox.set_valign(gtk4::Align::Start); - vbox.set_margin_top(88); + vbox.set_margin_top(120); vbox.set_size_request(600, -1); - if is_screenshot_run { - window.add_css_class("no-motion"); - vbox.add_css_class("no-motion"); - } else { - vbox.add_css_class("just-opened"); - } - let search = Entry::new(); - search.set_placeholder_text(Some("Search")); - search.set_has_frame(false); + let search = SearchEntry::new(); + search.set_placeholder_text(Some("breadbox")); vbox.append(&search); - let caret = GBox::new(Orientation::Horizontal, 0); - caret.add_css_class("launcher-caret"); - caret.set_hexpand(true); - vbox.append(&caret); - let scroll = ScrolledWindow::new(); scroll.set_policy(PolicyType::Never, PolicyType::Automatic); scroll.set_max_content_height(480); @@ -503,44 +360,28 @@ fn run_ui( for (idx, entry) in entries.iter().enumerate() { let row = gtk4::ListBoxRow::new(); - if idx < STAGGER_ROWS { - row.add_css_class(&format!("stagger-{idx}")); - } let hbox = GBox::new(Orientation::Horizontal, 0); - hbox.add_css_class("app-row"); + hbox.set_margin_start(6); + hbox.set_margin_end(6); hbox.set_valign(gtk4::Align::Center); - let well = GBox::new(Orientation::Horizontal, 0); - well.add_css_class("app-icon-well"); - well.set_size_request(38, 38); - well.set_halign(gtk4::Align::Center); - well.set_valign(gtk4::Align::Center); - well.set_hexpand(false); - let icon = make_icon(entry); - icon.set_halign(gtk4::Align::Center); - icon.set_valign(gtk4::Align::Center); - icon.set_hexpand(true); - well.append(&icon); - hbox.append(&well); - - let text = GBox::new(Orientation::Vertical, 1); - text.add_css_class("app-text"); - text.set_hexpand(true); - text.set_valign(gtk4::Align::Center); + let icon = make_icon(&entry.icon_name, entry.icon_path.as_deref()); + hbox.append(&icon); let name_lbl = Label::new(Some(&entry.name)); name_lbl.add_css_class("app-name"); name_lbl.set_xalign(0.0); + name_lbl.set_hexpand(true); name_lbl.set_ellipsize(EllipsizeMode::End); - text.append(&name_lbl); + hbox.append(&name_lbl); - let sub_lbl = Label::new(Some(category_label(entry))); - sub_lbl.add_css_class("app-muted"); - sub_lbl.set_xalign(0.0); - sub_lbl.set_ellipsize(EllipsizeMode::End); - text.append(&sub_lbl); + if let Some(ref wm) = entry.wm_class { + let wm_lbl = Label::new(Some(wm)); + wm_lbl.add_css_class("app-muted"); + wm_lbl.set_xalign(1.0); + hbox.append(&wm_lbl); + } - hbox.append(&text); row.set_child(Some(&hbox)); unsafe { row.set_data("entry", entry.clone()) }; unsafe { row.set_data("initial_order", idx as u32) }; @@ -554,16 +395,8 @@ fn run_ui( list.set_sort_func(move |row_a, row_b| { let query = sort_query.borrow(); if query.is_empty() { - let oa = unsafe { - row_a - .data::("initial_order") - .map_or(u32::MAX, |p| *p.as_ref()) - }; - let ob = unsafe { - row_b - .data::("initial_order") - .map_or(u32::MAX, |p| *p.as_ref()) - }; + let oa = unsafe { row_a.data::("initial_order").map_or(u32::MAX, |p| *p.as_ref()) }; + let ob = unsafe { row_b.data::("initial_order").map_or(u32::MAX, |p| *p.as_ref()) }; return oa.cmp(&ob).into(); } let (Some(ea), Some(eb)) = (get_row_entry(row_a), get_row_entry(row_b)) else { @@ -586,29 +419,11 @@ fn run_ui( scroll.set_child(Some(&list)); vbox.append(&scroll); - - let footer = Label::new(None); - footer.add_css_class("launcher-footer"); - footer.set_xalign(0.0); - set_footer_count(&footer, visible_row_count(&list)); - vbox.append(&footer); - window.set_child(Some(&vbox)); - if !is_screenshot_run { - let vbox_open = vbox.clone(); - glib::timeout_add_local_once(Duration::from_millis(520), move || { - vbox_open.remove_css_class("just-opened"); - }); - } - - // Filter on keystroke. ListBox keeps row identity across sort, so the - // reorder is already a cheap FLIP analog; a short CSS fade is the extra. + // Filter on keystroke let list_f = list.clone(); - let footer_f = footer.clone(); - let vbox_f = vbox.clone(); let filter_query = Rc::clone(&query_rc); - let reflow_gen = Rc::new(Cell::new(0u32)); search.connect_changed(move |entry| { let text = entry.text(); let query = text.as_str(); @@ -618,7 +433,6 @@ fn run_ui( let vis = get_row_entry(&row) .map(|e| { fuzzy_matches(query, &e.name) - || fuzzy_matches(query, category_label(&e)) || e.wm_class .as_deref() .is_some_and(|w| fuzzy_matches(query, w)) @@ -629,23 +443,10 @@ fn run_ui( i += 1; } list_f.invalidate_sort(); - let first_vis = - (0i32..).find_map(|j| list_f.row_at_index(j).filter(|r| r.is_visible())); + let first_vis = (0i32..).find_map(|j| { + list_f.row_at_index(j).filter(|r| r.is_visible()) + }); list_f.select_row(first_vis.as_ref()); - set_footer_count(&footer_f, visible_row_count(&list_f)); - if !vbox_f.has_css_class("just-opened") { - list_f.remove_css_class("reflow"); - list_f.add_css_class("reflow"); - let gen = reflow_gen.get().wrapping_add(1); - reflow_gen.set(gen); - let list_fade = list_f.clone(); - let reflow_gen = Rc::clone(&reflow_gen); - glib::timeout_add_local_once(Duration::from_millis(180), move || { - if reflow_gen.get() == gen { - list_fade.remove_css_class("reflow"); - } - }); - } }); // Keyboard handling — capture phase on window @@ -673,11 +474,36 @@ fn run_ui( glib::Propagation::Stop } Key::Down => { - bread_utils::gtk_popup::select_next_visible(&list_k); + 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 => { - bread_utils::gtk_popup::select_prev_visible(&list_k); + 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, @@ -698,10 +524,22 @@ fn run_ui( }); // Click outside launcher panel → close - { - let close_outside = Rc::clone(&close_all); - bread_utils::gtk_popup::close_on_outside_click(&window, &vbox, move || close_outside()); - } + let close_outside = Rc::clone(&close_all); + let vbox_ref = vbox.clone(); + let win_ref = window.clone(); + let outside_click = gtk4::GestureClick::new(); + outside_click.connect_pressed(move |_, _, x, y| { + if let Some(b) = vbox_ref.compute_bounds(&win_ref) { + if x < b.x() as f64 + || x > (b.x() + b.width()) as f64 + || y < b.y() as f64 + || y > (b.y() + b.height()) as f64 + { + close_outside(); + } + } + }); + window.add_controller(outside_click); if let Some(req) = screenshot_req.clone() { screenshot::dispatch(&window, req); @@ -749,9 +587,7 @@ fn main() { Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard), Ok(bread_utils::singleton::Toggle::KilledExisting) => return, Err(e) => { - eprintln!( - "breadbox: single-instance lock unavailable ({e}); continuing without it" - ); + eprintln!("breadbox: single-instance lock unavailable ({e}); continuing without it"); None } }