Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c55ea4756 | ||
| b52bf77ed3 | |||
|
|
28ff4bc982 |
3 changed files with 298 additions and 132 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -121,6 +121,8 @@ source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bread-shared",
|
"bread-shared",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"gtk4",
|
||||||
|
"gtk4-layer-shell",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] }
|
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"] }
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client", "gtk"] }
|
||||||
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
|
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
|
||||||
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
||||||
breadbox-shared = { path = "../breadbox-shared" }
|
breadbox-shared = { path = "../breadbox-shared" }
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette};
|
use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette};
|
||||||
use bread_utils::bread_client::BreadClient;
|
use bread_utils::bread_client::BreadClient;
|
||||||
use std::{
|
use std::{
|
||||||
cell::RefCell,
|
cell::{Cell, RefCell},
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
env,
|
env, fs,
|
||||||
fs,
|
|
||||||
io::{Read, Write},
|
io::{Read, Write},
|
||||||
os::unix::net::UnixStream,
|
os::unix::net::UnixStream,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::{Command, Stdio},
|
process::{Command, Stdio},
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// This app's id in bread's sibling-app namespace registry
|
/// This app's id in bread's sibling-app namespace registry
|
||||||
|
|
@ -20,13 +20,9 @@ use breadbox_shared::{
|
||||||
config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, LaunchHistory,
|
config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, LaunchHistory,
|
||||||
};
|
};
|
||||||
use gtk4::{
|
use gtk4::{
|
||||||
glib,
|
glib, pango::EllipsizeMode, prelude::*, Application, Box as GBox, CssProvider, Entry,
|
||||||
pango::EllipsizeMode,
|
EventControllerKey, Label, ListBox, Orientation, PolicyType, ScrolledWindow, SelectionMode,
|
||||||
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 listen;
|
||||||
mod screenshot;
|
mod screenshot;
|
||||||
|
|
@ -90,7 +86,9 @@ fn load_sorted_entries(
|
||||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||||
(None, None) => {
|
(None, None) => {
|
||||||
// Most-launched first, then alphabetical
|
// 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()))
|
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -138,58 +136,171 @@ fn matches_term(field: &str, term: &str) -> bool {
|
||||||
|
|
||||||
// ---- Theming ----------------------------------------------------------------
|
// ---- Theming ----------------------------------------------------------------
|
||||||
|
|
||||||
|
const STAGGER_ROWS: usize = 12;
|
||||||
|
|
||||||
fn build_css(p: &Palette) -> String {
|
fn build_css(p: &Palette) -> String {
|
||||||
let bg_panel = hex_to_rgba(&p.background, 0.60);
|
let bg_panel = hex_to_rgba(&p.background, 0.68);
|
||||||
// breadbox-specific rules only — fonts, palette, and generic widgets come
|
// breadbox-specific rules only — fonts, palette, and generic widgets come
|
||||||
// from the shared ecosystem stylesheet (applied first in connect_activate).
|
// from the shared ecosystem stylesheet (applied first in connect_activate).
|
||||||
// Colour is set on each surface (panel, search box, hovered/selected row) so
|
// Colour is set on each surface (panel, search, hovered/selected row) so
|
||||||
// child labels inherit the legible ink for that background. `on_*` are
|
// child labels inherit the legible ink for that background. `on_*` are
|
||||||
// luminance-picked black/white — the pywal hues are untouched. Without this a
|
// luminance-picked black/white — the pywal hues are untouched.
|
||||||
// light `surface` slot makes the selected row's text vanish.
|
//
|
||||||
|
// 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!(
|
format!(
|
||||||
"window {{ background-color: transparent; }}\
|
".launcher-bg.just-opened list row.stagger-{i} {{ animation-delay: {}ms; }}",
|
||||||
.launcher-bg {{ background-color: {bg_panel}; color: {on_bg}; border-radius: 8px;\
|
i * 28
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 -----------------------------------------------------------
|
// ---- Icon loading -----------------------------------------------------------
|
||||||
|
|
||||||
fn make_icon(icon_name: &str, icon_path: Option<&Path>) -> gtk4::Image {
|
fn make_icon(entry: &DesktopEntry) -> gtk4::Image {
|
||||||
// Try loading from resolved cached path via gio::File
|
let img = gtk4::Image::from_icon_name(symbolic_icon(entry));
|
||||||
if let Some(path) = icon_path {
|
img.set_pixel_size(18);
|
||||||
let gio_file = gtk4::gio::File::for_path(path);
|
img.add_css_class("app-icon");
|
||||||
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
|
img
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,10 +382,18 @@ fn fuzzy_score(query: &str, entry: &DesktopEntry) -> u32 {
|
||||||
let q = query.to_lowercase();
|
let q = query.to_lowercase();
|
||||||
let name = entry.name.to_lowercase();
|
let name = entry.name.to_lowercase();
|
||||||
let wm = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
|
let wm = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
|
||||||
if name == q || wm == q { return 0; }
|
if name == q || wm == q {
|
||||||
if name.starts_with(&q) { return 1; }
|
return 0;
|
||||||
if name.contains(&q) { return 2; }
|
}
|
||||||
if wm.starts_with(&q) || wm.contains(&q) { return 3; }
|
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
|
4 // subsequence match
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,6 +406,26 @@ fn get_row_entry(row: >k4::ListBoxRow) -> Option<DesktopEntry> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
fn run_ui(
|
||||||
entries: Vec<DesktopEntry>,
|
entries: Vec<DesktopEntry>,
|
||||||
history: LaunchHistory,
|
history: LaunchHistory,
|
||||||
|
|
@ -320,16 +459,8 @@ fn run_ui(
|
||||||
bread_theme::gtk::apply_user_css(&user_css_path, &user_cell);
|
bread_theme::gtk::apply_user_css(&user_css_path, &user_cell);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full-screen transparent window; clicks outside the launcher panel close it.
|
// Full-screen transparent overlay; panel widget is positioned inside it.
|
||||||
let window = ApplicationWindow::builder().application(app).build();
|
let window = bread_utils::gtk_popup::new_overlay_window(app, "breadbox");
|
||||||
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);
|
bread_theme::gtk::bind_window_auto(&window);
|
||||||
|
|
||||||
let close_all: Rc<dyn Fn()> = Rc::new({
|
let close_all: Rc<dyn Fn()> = Rc::new({
|
||||||
|
|
@ -343,13 +474,25 @@ fn run_ui(
|
||||||
vbox.add_css_class("launcher-bg");
|
vbox.add_css_class("launcher-bg");
|
||||||
vbox.set_halign(gtk4::Align::Center);
|
vbox.set_halign(gtk4::Align::Center);
|
||||||
vbox.set_valign(gtk4::Align::Start);
|
vbox.set_valign(gtk4::Align::Start);
|
||||||
vbox.set_margin_top(120);
|
vbox.set_margin_top(88);
|
||||||
vbox.set_size_request(600, -1);
|
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 = SearchEntry::new();
|
let search = Entry::new();
|
||||||
search.set_placeholder_text(Some("breadbox"));
|
search.set_placeholder_text(Some("Search"));
|
||||||
|
search.set_has_frame(false);
|
||||||
vbox.append(&search);
|
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();
|
let scroll = ScrolledWindow::new();
|
||||||
scroll.set_policy(PolicyType::Never, PolicyType::Automatic);
|
scroll.set_policy(PolicyType::Never, PolicyType::Automatic);
|
||||||
scroll.set_max_content_height(480);
|
scroll.set_max_content_height(480);
|
||||||
|
|
@ -360,28 +503,44 @@ fn run_ui(
|
||||||
|
|
||||||
for (idx, entry) in entries.iter().enumerate() {
|
for (idx, entry) in entries.iter().enumerate() {
|
||||||
let row = gtk4::ListBoxRow::new();
|
let row = gtk4::ListBoxRow::new();
|
||||||
|
if idx < STAGGER_ROWS {
|
||||||
|
row.add_css_class(&format!("stagger-{idx}"));
|
||||||
|
}
|
||||||
let hbox = GBox::new(Orientation::Horizontal, 0);
|
let hbox = GBox::new(Orientation::Horizontal, 0);
|
||||||
hbox.set_margin_start(6);
|
hbox.add_css_class("app-row");
|
||||||
hbox.set_margin_end(6);
|
|
||||||
hbox.set_valign(gtk4::Align::Center);
|
hbox.set_valign(gtk4::Align::Center);
|
||||||
|
|
||||||
let icon = make_icon(&entry.icon_name, entry.icon_path.as_deref());
|
let well = GBox::new(Orientation::Horizontal, 0);
|
||||||
hbox.append(&icon);
|
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 name_lbl = Label::new(Some(&entry.name));
|
let name_lbl = Label::new(Some(&entry.name));
|
||||||
name_lbl.add_css_class("app-name");
|
name_lbl.add_css_class("app-name");
|
||||||
name_lbl.set_xalign(0.0);
|
name_lbl.set_xalign(0.0);
|
||||||
name_lbl.set_hexpand(true);
|
|
||||||
name_lbl.set_ellipsize(EllipsizeMode::End);
|
name_lbl.set_ellipsize(EllipsizeMode::End);
|
||||||
hbox.append(&name_lbl);
|
text.append(&name_lbl);
|
||||||
|
|
||||||
if let Some(ref wm) = entry.wm_class {
|
let sub_lbl = Label::new(Some(category_label(entry)));
|
||||||
let wm_lbl = Label::new(Some(wm));
|
sub_lbl.add_css_class("app-muted");
|
||||||
wm_lbl.add_css_class("app-muted");
|
sub_lbl.set_xalign(0.0);
|
||||||
wm_lbl.set_xalign(1.0);
|
sub_lbl.set_ellipsize(EllipsizeMode::End);
|
||||||
hbox.append(&wm_lbl);
|
text.append(&sub_lbl);
|
||||||
}
|
|
||||||
|
|
||||||
|
hbox.append(&text);
|
||||||
row.set_child(Some(&hbox));
|
row.set_child(Some(&hbox));
|
||||||
unsafe { row.set_data("entry", entry.clone()) };
|
unsafe { row.set_data("entry", entry.clone()) };
|
||||||
unsafe { row.set_data("initial_order", idx as u32) };
|
unsafe { row.set_data("initial_order", idx as u32) };
|
||||||
|
|
@ -395,8 +554,16 @@ fn run_ui(
|
||||||
list.set_sort_func(move |row_a, row_b| {
|
list.set_sort_func(move |row_a, row_b| {
|
||||||
let query = sort_query.borrow();
|
let query = sort_query.borrow();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
let oa = unsafe { row_a.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref()) };
|
let oa = unsafe {
|
||||||
let ob = unsafe { row_b.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref()) };
|
row_a
|
||||||
|
.data::<u32>("initial_order")
|
||||||
|
.map_or(u32::MAX, |p| *p.as_ref())
|
||||||
|
};
|
||||||
|
let ob = unsafe {
|
||||||
|
row_b
|
||||||
|
.data::<u32>("initial_order")
|
||||||
|
.map_or(u32::MAX, |p| *p.as_ref())
|
||||||
|
};
|
||||||
return oa.cmp(&ob).into();
|
return oa.cmp(&ob).into();
|
||||||
}
|
}
|
||||||
let (Some(ea), Some(eb)) = (get_row_entry(row_a), get_row_entry(row_b)) else {
|
let (Some(ea), Some(eb)) = (get_row_entry(row_a), get_row_entry(row_b)) else {
|
||||||
|
|
@ -419,11 +586,29 @@ fn run_ui(
|
||||||
|
|
||||||
scroll.set_child(Some(&list));
|
scroll.set_child(Some(&list));
|
||||||
vbox.append(&scroll);
|
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));
|
window.set_child(Some(&vbox));
|
||||||
|
|
||||||
// Filter on keystroke
|
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.
|
||||||
let list_f = list.clone();
|
let list_f = list.clone();
|
||||||
|
let footer_f = footer.clone();
|
||||||
|
let vbox_f = vbox.clone();
|
||||||
let filter_query = Rc::clone(&query_rc);
|
let filter_query = Rc::clone(&query_rc);
|
||||||
|
let reflow_gen = Rc::new(Cell::new(0u32));
|
||||||
search.connect_changed(move |entry| {
|
search.connect_changed(move |entry| {
|
||||||
let text = entry.text();
|
let text = entry.text();
|
||||||
let query = text.as_str();
|
let query = text.as_str();
|
||||||
|
|
@ -433,6 +618,7 @@ fn run_ui(
|
||||||
let vis = get_row_entry(&row)
|
let vis = get_row_entry(&row)
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
fuzzy_matches(query, &e.name)
|
fuzzy_matches(query, &e.name)
|
||||||
|
|| fuzzy_matches(query, category_label(&e))
|
||||||
|| e.wm_class
|
|| e.wm_class
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|w| fuzzy_matches(query, w))
|
.is_some_and(|w| fuzzy_matches(query, w))
|
||||||
|
|
@ -443,10 +629,23 @@ fn run_ui(
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
list_f.invalidate_sort();
|
list_f.invalidate_sort();
|
||||||
let first_vis = (0i32..).find_map(|j| {
|
let first_vis =
|
||||||
list_f.row_at_index(j).filter(|r| r.is_visible())
|
(0i32..).find_map(|j| list_f.row_at_index(j).filter(|r| r.is_visible()));
|
||||||
});
|
|
||||||
list_f.select_row(first_vis.as_ref());
|
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
|
// Keyboard handling — capture phase on window
|
||||||
|
|
@ -474,36 +673,11 @@ fn run_ui(
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
Key::Down => {
|
Key::Down => {
|
||||||
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1);
|
bread_utils::gtk_popup::select_next_visible(&list_k);
|
||||||
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
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
Key::Up => {
|
Key::Up => {
|
||||||
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0);
|
bread_utils::gtk_popup::select_prev_visible(&list_k);
|
||||||
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::Stop
|
||||||
}
|
}
|
||||||
_ => glib::Propagation::Proceed,
|
_ => glib::Propagation::Proceed,
|
||||||
|
|
@ -524,22 +698,10 @@ fn run_ui(
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click outside launcher panel → close
|
// Click outside launcher panel → close
|
||||||
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();
|
let close_outside = Rc::clone(&close_all);
|
||||||
|
bread_utils::gtk_popup::close_on_outside_click(&window, &vbox, move || close_outside());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
window.add_controller(outside_click);
|
|
||||||
|
|
||||||
if let Some(req) = screenshot_req.clone() {
|
if let Some(req) = screenshot_req.clone() {
|
||||||
screenshot::dispatch(&window, req);
|
screenshot::dispatch(&window, req);
|
||||||
|
|
@ -587,7 +749,9 @@ fn main() {
|
||||||
Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard),
|
Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard),
|
||||||
Ok(bread_utils::singleton::Toggle::KilledExisting) => return,
|
Ok(bread_utils::singleton::Toggle::KilledExisting) => return,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("breadbox: single-instance lock unavailable ({e}); continuing without it");
|
eprintln!(
|
||||||
|
"breadbox: single-instance lock unavailable ({e}); continuing without it"
|
||||||
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue