Compare commits
No commits in common. "main" and "v0.3.1" have entirely different histories.
13 changed files with 450 additions and 482 deletions
|
|
@ -22,16 +22,7 @@ jobs:
|
||||||
# `backend` in config.toml. All three are ort load-dynamic (dlopen)
|
# `backend` in config.toml. All three are ort load-dynamic (dlopen)
|
||||||
# EPs, so this doesn't require the NPU/ROCm/CUDA toolkits to be
|
# EPs, so this doesn't require the NPU/ROCm/CUDA toolkits to be
|
||||||
# present on the build host — see breadmill/Cargo.toml.
|
# present on the build host — see breadmill/Cargo.toml.
|
||||||
run: |
|
run: cd src && bash ci/build.sh cargo build --release --locked --workspace --features full
|
||||||
set -euo pipefail
|
|
||||||
if [ ! -f src/ci/build.sh ]; then
|
|
||||||
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cd src && bash ci/build.sh cargo build --release --locked --workspace --features full || {
|
|
||||||
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
run: cd src && bash ci/build.sh cargo test --release --locked --workspace --features full
|
run: cd src && bash ci/build.sh cargo test --release --locked --workspace --features full
|
||||||
|
|
@ -54,14 +45,8 @@ jobs:
|
||||||
ln -sfn "${VERSION}" "/srv/breadway-dl/breadsearch/latest"
|
ln -sfn "${VERSION}" "/srv/breadway-dl/breadsearch/latest"
|
||||||
|
|
||||||
- name: regenerate index.json
|
- name: regenerate index.json
|
||||||
env:
|
|
||||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
|
||||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
rm -rf /tmp/bread-ecosystem-ci
|
rm -rf /tmp/bread-ecosystem-ci
|
||||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
||||||
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
||||||
|
|
|
||||||
550
Cargo.lock
generated
550
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
44
EVENTS.md
44
EVENTS.md
|
|
@ -10,10 +10,8 @@ convention this follows.
|
||||||
App id: **`search`**. Transport: `bread-utils`'s `bread_client` module
|
App id: **`search`**. Transport: `bread-utils`'s `bread_client` module
|
||||||
(feature `bread-client`) — the overlay links it directly. Each `emit` is
|
(feature `bread-client`) — the overlay links it directly. Each `emit` is
|
||||||
its own short-lived connection (`BreadClient::emit` is fire-and-forget,
|
its own short-lived connection (`BreadClient::emit` is fire-and-forget,
|
||||||
the same stance as `bread-emit`). Command verbs are only received while
|
the same stance as `bread-emit`). breadmill, the indexing daemon, does
|
||||||
`breadsearch listen` is running — that process holds the
|
not talk to breadd.
|
||||||
`bread.command.search.**` subscription open. breadmill, the indexing
|
|
||||||
daemon, does not talk to breadd.
|
|
||||||
|
|
||||||
## Events published (`bread.search.*`)
|
## Events published (`bread.search.*`)
|
||||||
|
|
||||||
|
|
@ -21,42 +19,18 @@ daemon, does not talk to breadd.
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
| `bread.search.opened` | `{}` | The overlay window maps (the search panel is shown). |
|
| `bread.search.opened` | `{}` | The overlay window maps (the search panel is shown). |
|
||||||
| `bread.search.opened_result` | `{ "path": "<hit path>" }` | The user opens a hit — Enter / click opens the file, Ctrl+Enter reveals its folder. `path` is the hit's document path, not the parent folder. |
|
| `bread.search.opened_result` | `{ "path": "<hit path>" }` | The user opens a hit — Enter / click opens the file, Ctrl+Enter reveals its folder. `path` is the hit's document path, not the parent folder. |
|
||||||
| `bread.search.open.done` | `{}` | `bread.command.search.open` was received and `breadsearch` was spawned. This is the command confirmation, not proof the overlay mapped — the spawned process is the same PID-file toggle as a keybind. |
|
|
||||||
| `bread.search.open.failed` | `{ "error": "<message>" }` | `bread.command.search.open` was received but this binary could not be started. |
|
|
||||||
|
|
||||||
## Commands honored (`bread.command.search.*`)
|
## Commands honored (`bread.command.search.*`)
|
||||||
|
|
||||||
These are only received while `breadsearch listen` is running. Publishing a
|
None. The overlay is a short-lived toggle process with no existing command
|
||||||
command with no subscriber is a silent no-op — that is the documented
|
surface (no show/hide/query IPC beyond the PID-file toggle and breadmill's
|
||||||
bread convention, not a breadsearch bug.
|
own query socket). Adding verbs would mean inventing a control plane that
|
||||||
|
does not exist; if/when breadsearch grows one, the corresponding
|
||||||
| Verb | Data | Effect |
|
`bread.command.search.*` verbs should be added at the same time, not stubbed
|
||||||
|------|------|--------|
|
out ahead of it.
|
||||||
| `open` | none | Same as running `breadsearch` (PID-file toggle: show the overlay, or dismiss it if it is already up). Emits `bread.search.open.done` / `.failed`. |
|
|
||||||
|
|
||||||
```lua
|
|
||||||
bread.spawn(function()
|
|
||||||
bread.emit("bread.command.search.open")
|
|
||||||
bread.wait("bread.search.open.done", { timeout = 5000 })
|
|
||||||
end)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Not implemented: extra verbs
|
|
||||||
|
|
||||||
There is no `query` / `close` / `reindex` command verb. breadmill already
|
|
||||||
has its own query socket; inventing a bus query plane would be a new
|
|
||||||
product surface. If/when that exists, add the corresponding
|
|
||||||
`bread.command.search.*` verb at the same time, not stubbed as a no-op
|
|
||||||
ahead of it.
|
|
||||||
|
|
||||||
## Fail-safe behavior
|
## Fail-safe behavior
|
||||||
|
|
||||||
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
||||||
(`BreadClient::emit` never blocks or errors the caller) and the
|
(`BreadClient::emit` never blocks or errors the caller) — breadsearch's
|
||||||
command subscription simply never receives anything — breadsearch's
|
|
||||||
overlay and breadmill's indexing/query path are entirely unaffected.
|
overlay and breadmill's indexing/query path are entirely unaffected.
|
||||||
- If breadd restarts, the command subscription reconnects automatically
|
|
||||||
(`BreadClient::subscribe`'s background thread has its own backoff
|
|
||||||
loop); no restart of `breadsearch listen` is needed.
|
|
||||||
- If `breadsearch listen` is not running, commands are a graceful no-op at
|
|
||||||
the bus (no subscriber). The overlay CLI still works.
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadmill"
|
name = "breadmill"
|
||||||
version = "0.3.3"
|
version = "0.3.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,10 @@ fn split_by_chars(chunk: Chunk, max_chars: usize) -> Vec<Chunk> {
|
||||||
let text = &chunk.text;
|
let text = &chunk.text;
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut seg_start = 0usize;
|
let mut seg_start = 0usize;
|
||||||
|
let mut count = 0usize;
|
||||||
|
|
||||||
for (count, (byte_idx, _)) in text.char_indices().enumerate() {
|
for (byte_idx, _) in text.char_indices() {
|
||||||
if count > 0 && count.is_multiple_of(max_chars) {
|
if count > 0 && count % max_chars == 0 {
|
||||||
result.push(Chunk {
|
result.push(Chunk {
|
||||||
text: text[seg_start..byte_idx].to_string(),
|
text: text[seg_start..byte_idx].to_string(),
|
||||||
start: chunk.start + seg_start,
|
start: chunk.start + seg_start,
|
||||||
|
|
@ -91,6 +92,7 @@ fn split_by_chars(chunk: Chunk, max_chars: usize) -> Vec<Chunk> {
|
||||||
});
|
});
|
||||||
seg_start = byte_idx;
|
seg_start = byte_idx;
|
||||||
}
|
}
|
||||||
|
count += 1;
|
||||||
}
|
}
|
||||||
if seg_start < text.len() {
|
if seg_start < text.len() {
|
||||||
result.push(Chunk {
|
result.push(Chunk {
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ impl Indexer {
|
||||||
pub fn full_reindex(&self) {
|
pub fn full_reindex(&self) {
|
||||||
eprintln!("breadmill: full reindex triggered");
|
eprintln!("breadmill: full reindex triggered");
|
||||||
{
|
{
|
||||||
let store = self.state.store.lock_recover();
|
let mut store = self.state.store.lock_recover();
|
||||||
// Clear all state
|
// Clear all state
|
||||||
let _ = store.conn.execute_batch("DELETE FROM chunks; DELETE FROM files;");
|
let _ = store.conn.execute_batch("DELETE FROM chunks; DELETE FROM files;");
|
||||||
let _ = store.index.reserve(4096);
|
let _ = store.index.reserve(4096);
|
||||||
|
|
@ -416,9 +416,9 @@ fn sha256_str(bytes: &[u8]) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn expand_home(path: &str) -> PathBuf {
|
pub fn expand_home(path: &str) -> PathBuf {
|
||||||
if let Some(rest) = path.strip_prefix("~/") {
|
if path.starts_with("~/") {
|
||||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
|
||||||
PathBuf::from(home).join(rest)
|
PathBuf::from(home).join(&path[2..])
|
||||||
} else {
|
} else {
|
||||||
PathBuf::from(path)
|
PathBuf::from(path)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use usearch::{Index, IndexOptions, MetricKind, ScalarKind, new_index};
|
||||||
pub struct Store {
|
pub struct Store {
|
||||||
pub conn: Connection,
|
pub conn: Connection,
|
||||||
pub index: Index,
|
pub index: Index,
|
||||||
|
pub dim: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
// usearch::Index wraps a raw C++ pointer; access is serialized by the Mutex<Store>.
|
// usearch::Index wraps a raw C++ pointer; access is serialized by the Mutex<Store>.
|
||||||
|
|
@ -86,7 +87,7 @@ impl Store {
|
||||||
index.reserve(4096).map_err(|e| e.to_string())?;
|
index.reserve(4096).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Self { conn, index })
|
Ok(Self { conn, index, dim })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- file state ---------------------------------------------------------
|
// ---- file state ---------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadsearch-shared"
|
name = "breadsearch-shared"
|
||||||
version = "0.3.3"
|
version = "0.3.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ pub fn socket_path() -> PathBuf {
|
||||||
|
|
||||||
// ---- Config -----------------------------------------------------------------
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub index: IndexConfig,
|
pub index: IndexConfig,
|
||||||
|
|
@ -157,6 +157,16 @@ impl Default for ModelConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
index: IndexConfig::default(),
|
||||||
|
search: SearchConfig::default(),
|
||||||
|
model: ModelConfig::default(),
|
||||||
|
power: PowerConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PowerConfig {
|
pub struct PowerConfig {
|
||||||
|
|
@ -245,7 +255,8 @@ pub struct StatusInfo {
|
||||||
pub fn send_request(req: &Request) -> std::io::Result<Response> {
|
pub fn send_request(req: &Request) -> std::io::Result<Response> {
|
||||||
let mut stream = UnixStream::connect(socket_path())?;
|
let mut stream = UnixStream::connect(socket_path())?;
|
||||||
|
|
||||||
let mut line = serde_json::to_string(req).map_err(std::io::Error::other)?;
|
let mut line = serde_json::to_string(req)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
||||||
line.push('\n');
|
line.push('\n');
|
||||||
stream.write_all(line.as_bytes())?;
|
stream.write_all(line.as_bytes())?;
|
||||||
stream.flush()?;
|
stream.flush()?;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadsearch"
|
name = "breadsearch"
|
||||||
version = "0.3.3"
|
version = "0.3.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
|
@ -10,9 +10,9 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
breadsearch-shared = { path = "../breadsearch-shared" }
|
breadsearch-shared = { path = "../breadsearch-shared" }
|
||||||
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.2", features = ["gtk"] }
|
||||||
# Bread event fabric client — emit bread.search.* (fail-silent if breadd is down).
|
# Bread event fabric client — emit bread.search.* (fail-silent if breadd is down).
|
||||||
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.
|
# 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" }
|
||||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||||
|
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
//! Long-running command subscription for `bread.command.search.*`.
|
|
||||||
//!
|
|
||||||
//! `breadsearch` is still a one-shot toggle overlay by default.
|
|
||||||
//! `breadsearch listen` is the optional persistent process that can honor
|
|
||||||
//! bus commands. See `EVENTS.md`.
|
|
||||||
|
|
||||||
use bread_utils::bread_client::{BreadClient, BreadEvent};
|
|
||||||
|
|
||||||
use crate::bread_events::APP_ID;
|
|
||||||
|
|
||||||
/// Subscribe to `bread.command.search.**` and block until the process is killed.
|
|
||||||
///
|
|
||||||
/// breadd being absent is not an error: [`BreadClient::subscribe`] reconnects
|
|
||||||
/// with backoff, and `on_event` simply isn't called until the daemon is up.
|
|
||||||
pub fn run() {
|
|
||||||
let client = BreadClient::connect(APP_ID);
|
|
||||||
if client.health().is_none() {
|
|
||||||
eprintln!(
|
|
||||||
"breadsearch: breadd unreachable; command subscription will connect when it comes back"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let _commands = client.subscribe("bread.command.search.**", |event| {
|
|
||||||
handle_command(&event);
|
|
||||||
});
|
|
||||||
|
|
||||||
eprintln!("breadsearch: listening for bread.command.search.**");
|
|
||||||
loop {
|
|
||||||
std::thread::park();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reacts to `bread.command.search.*` verbs. Only `open` is honored today —
|
|
||||||
/// other verbs are ignored, not stubbed as no-ops that pretend to succeed.
|
|
||||||
fn handle_command(event: &BreadEvent) {
|
|
||||||
let Some(verb) = command_verb(&event.event) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
match verb {
|
|
||||||
"open" => handle_open(),
|
|
||||||
other => {
|
|
||||||
eprintln!("breadsearch: ignoring unrecognized bread.command.search.{other}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_open() {
|
|
||||||
// Same as running `breadsearch` from a keybind: the PID-file toggle
|
|
||||||
// shows the overlay (or dismisses it if it is already up).
|
|
||||||
let result = spawn_self();
|
|
||||||
let client = BreadClient::connect(APP_ID);
|
|
||||||
match result {
|
|
||||||
Ok(_) => client.emit("bread.search.open.done", serde_json::json!({})),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("breadsearch: bread.command.search.open failed: {e}");
|
|
||||||
client.emit(
|
|
||||||
"bread.search.open.failed",
|
|
||||||
serde_json::json!({ "error": e.to_string() }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_self() -> std::io::Result<std::process::Child> {
|
|
||||||
let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadsearch"));
|
|
||||||
std::process::Command::new(exe).spawn()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn command_verb(event_name: &str) -> Option<&str> {
|
|
||||||
event_name.strip_prefix("bread.command.search.")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_verb_strips_search_prefix() {
|
|
||||||
assert_eq!(command_verb("bread.command.search.open"), Some("open"));
|
|
||||||
assert_eq!(command_verb("bread.command.search.query"), Some("query"));
|
|
||||||
assert_eq!(command_verb("bread.command.box.open"), None);
|
|
||||||
assert_eq!(command_verb("bread.search.opened"), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,24 @@
|
||||||
use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette};
|
use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette};
|
||||||
use breadsearch_shared::{Hit, Request, Response};
|
use breadsearch_shared::{Hit, Request, Response};
|
||||||
use std::{cell::RefCell, process::Command, rc::Rc, sync::mpsc};
|
use std::{
|
||||||
|
cell::RefCell,
|
||||||
use gtk4::{
|
env, fs,
|
||||||
glib, pango::EllipsizeMode, prelude::*, Application, Box as GBox, CssProvider,
|
path::PathBuf,
|
||||||
EventControllerKey, Image, Label, ListBox, Orientation, PolicyType, ScrolledWindow,
|
process::Command,
|
||||||
SearchEntry, SelectionMode,
|
rc::Rc,
|
||||||
|
sync::mpsc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use gtk4::{
|
||||||
|
glib,
|
||||||
|
pango::EllipsizeMode,
|
||||||
|
prelude::*,
|
||||||
|
Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Image, Label,
|
||||||
|
ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode,
|
||||||
|
};
|
||||||
|
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
||||||
|
|
||||||
mod bread_events;
|
mod bread_events;
|
||||||
mod listen;
|
|
||||||
mod screenshot;
|
mod screenshot;
|
||||||
|
|
||||||
// ---- Theming ----------------------------------------------------------------
|
// ---- Theming ----------------------------------------------------------------
|
||||||
|
|
@ -33,14 +42,47 @@ fn build_css(p: &Palette) -> String {
|
||||||
.hit-snippet {{ opacity: 0.75; font-size: 11px; font-style: italic; }}\
|
.hit-snippet {{ opacity: 0.75; font-size: 11px; font-style: italic; }}\
|
||||||
.hit-score {{ opacity: 0.5; font-size: 11px; }}\
|
.hit-score {{ opacity: 0.5; font-size: 11px; }}\
|
||||||
image {{ margin-right: 8px; }}",
|
image {{ margin-right: 8px; }}",
|
||||||
bg_panel = bg_panel,
|
bg_panel = bg_panel,
|
||||||
surface = p.color0,
|
surface = p.color0,
|
||||||
accent = p.color4,
|
accent = p.color4,
|
||||||
on_bg = ink_on(&p.background),
|
on_bg = ink_on(&p.background),
|
||||||
on_surface = ink_on(&p.color0),
|
on_surface = ink_on(&p.color0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- PID file toggle --------------------------------------------------------
|
||||||
|
|
||||||
|
fn pid_file() -> PathBuf {
|
||||||
|
env::var("XDG_RUNTIME_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("/tmp"))
|
||||||
|
.join("breadsearch.pid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_breadsearch_pid(pid: u32) -> bool {
|
||||||
|
fs::read_to_string(format!("/proc/{}/comm", pid))
|
||||||
|
.map(|s| s.trim() == "breadsearch")
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>() {
|
||||||
|
if is_breadsearch_pid(pid) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Row builder ------------------------------------------------------------
|
// ---- Row builder ------------------------------------------------------------
|
||||||
|
|
||||||
fn make_hit_row(hit: &Hit) -> gtk4::ListBoxRow {
|
fn make_hit_row(hit: &Hit) -> gtk4::ListBoxRow {
|
||||||
|
|
@ -195,13 +237,20 @@ fn run_ui(screenshot_req: Option<screenshot::ScreenshotRequest>) {
|
||||||
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 overlay; panel widget is positioned inside it.
|
let window = ApplicationWindow::builder().application(app).build();
|
||||||
let window = bread_utils::gtk_popup::new_overlay_window(app, "breadsearch");
|
window.init_layer_shell();
|
||||||
bread_theme::gtk::bind_window_auto(&window);
|
window.set_namespace(Some("breadsearch"));
|
||||||
|
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);
|
||||||
|
|
||||||
let close_all: Rc<dyn Fn()> = Rc::new({
|
let close_all: Rc<dyn Fn()> = Rc::new({
|
||||||
let w = window.clone();
|
let w = window.clone();
|
||||||
move || {
|
move || {
|
||||||
|
cleanup_pid();
|
||||||
w.close();
|
w.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -257,10 +306,7 @@ fn run_ui(screenshot_req: Option<screenshot::ScreenshotRequest>) {
|
||||||
let (tx, rx) = mpsc::sync_channel::<std::io::Result<Response>>(1);
|
let (tx, rx) = mpsc::sync_channel::<std::io::Result<Response>>(1);
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let req = Request::Query {
|
let req = Request::Query { query: q, limit: 10 };
|
||||||
query: q,
|
|
||||||
limit: 10,
|
|
||||||
};
|
|
||||||
let _ = tx.send(breadsearch_shared::send_request(&req));
|
let _ = tx.send(breadsearch_shared::send_request(&req));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -320,11 +366,36 @@ fn run_ui(screenshot_req: Option<screenshot::ScreenshotRequest>) {
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
Key::Down => {
|
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_selectable() => {
|
||||||
|
list_k.select_row(Some(&r));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(_) => i += 1,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
Key::Up => {
|
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_selectable() => {
|
||||||
|
list_k.select_row(Some(&r));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(_) => i -= 1,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
_ => glib::Propagation::Proceed,
|
_ => glib::Propagation::Proceed,
|
||||||
|
|
@ -342,10 +413,24 @@ fn run_ui(screenshot_req: Option<screenshot::ScreenshotRequest>) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click outside launcher panel → close
|
// Click outside launcher panel → close
|
||||||
{
|
let close_outside = Rc::clone(&close_all);
|
||||||
let close_outside = Rc::clone(&close_all);
|
let vbox_ref = vbox.clone();
|
||||||
bread_utils::gtk_popup::close_on_outside_click(&window, &vbox, move || close_outside());
|
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);
|
||||||
|
|
||||||
|
window.connect_destroy(|_| cleanup_pid());
|
||||||
|
|
||||||
if let Some(req) = screenshot_req.clone() {
|
if let Some(req) = screenshot_req.clone() {
|
||||||
screenshot::dispatch(&window, req);
|
screenshot::dispatch(&window, req);
|
||||||
|
|
@ -369,38 +454,15 @@ fn run_ui(screenshot_req: Option<screenshot::ScreenshotRequest>) {
|
||||||
// ---- Main -------------------------------------------------------------------
|
// ---- Main -------------------------------------------------------------------
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
if std::env::args().nth(1).as_deref() == Some("listen") {
|
|
||||||
listen::run();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
let cli = screenshot::Cli::parse();
|
let cli = screenshot::Cli::parse();
|
||||||
let screenshot_req = cli.screenshot_request();
|
let screenshot_req = cli.screenshot_request();
|
||||||
|
|
||||||
// `toggle_or_kill` kills whatever's holding the single-instance lock —
|
// The PID-file toggle kills whatever's holding the file — a real,
|
||||||
// a real, already-running breadsearch included. A screenshot run must
|
// already-running breadsearch instance included. A screenshot run must
|
||||||
// never touch it: it's a separate, disposable instance by design (same
|
// never touch it: it's a separate, disposable instance by design.
|
||||||
// reasoning as breadbar's `allow_multiple_instances`), not a toggle of
|
if screenshot_req.is_none() && !toggle_or_continue() {
|
||||||
// the operator's real search panel.
|
return;
|
||||||
//
|
}
|
||||||
// Kept alive for the rest of `main` — dropping it releases the
|
|
||||||
// single-instance lock and removes the pid file, which happens
|
|
||||||
// naturally once `run_ui` returns (after the window closes).
|
|
||||||
let _singleton_guard = if screenshot_req.is_some() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
match bread_utils::singleton::toggle_or_kill("breadsearch") {
|
|
||||||
Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard),
|
|
||||||
Ok(bread_utils::singleton::Toggle::KilledExisting) => return,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(
|
|
||||||
"breadsearch: single-instance lock unavailable ({e}); continuing without it"
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
run_ui(screenshot_req);
|
run_ui(screenshot_req);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,15 @@
|
||||||
//! the panel shows at settle time — normally the "Type to search…" empty
|
//! the panel shows at settle time — normally the "Type to search…" empty
|
||||||
//! state, since there's no query to type in an automated run.
|
//! state, since there's no query to type in an automated run.
|
||||||
|
|
||||||
use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH, SETTLE_DELAY};
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Extra settle time after `map` for the first frame to actually paint
|
||||||
|
/// before grim runs — `map` fires once the surface exists, not once
|
||||||
|
/// anything has been drawn into it.
|
||||||
|
const SETTLE_DELAY: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "breadsearch")]
|
#[command(name = "breadsearch")]
|
||||||
|
|
@ -26,11 +31,11 @@ pub struct Cli {
|
||||||
|
|
||||||
/// Capture canvas width — matches the isolated compositor's output width
|
/// Capture canvas width — matches the isolated compositor's output width
|
||||||
/// (`bread-capture --isolate-width`).
|
/// (`bread-capture --isolate-width`).
|
||||||
#[arg(long, default_value_t = DEFAULT_WIDTH)]
|
#[arg(long, default_value_t = 1920)]
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
|
|
||||||
/// Capture canvas height — see `width`.
|
/// Capture canvas height — see `width`.
|
||||||
#[arg(long, default_value_t = DEFAULT_HEIGHT)]
|
#[arg(long, default_value_t = 1080)]
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,20 +48,16 @@ pub struct ScreenshotRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cli {
|
impl Cli {
|
||||||
/// `None` for a normal run. Exits the process with an error if the
|
/// `None` for a normal run. Exits the process with an error if
|
||||||
/// `--screenshot` / `--output` pair is incomplete, before any GTK setup
|
/// `--screenshot` was given without `--output`, before any GTK setup
|
||||||
/// happens.
|
/// happens.
|
||||||
pub fn screenshot_request(&self) -> Option<ScreenshotRequest> {
|
pub fn screenshot_request(&self) -> Option<ScreenshotRequest> {
|
||||||
if let Err(e) = validate_pair(self.screenshot.as_deref(), self.output.as_deref()) {
|
let view = self.screenshot.clone()?;
|
||||||
eprintln!("breadsearch: {e}");
|
let Some(output) = self.output.clone() else {
|
||||||
|
eprintln!("breadsearch: --screenshot requires --output");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
};
|
||||||
Some(ScreenshotRequest {
|
Some(ScreenshotRequest { view, output, width: self.width, height: self.height })
|
||||||
view: self.screenshot.clone()?,
|
|
||||||
output: self.output.clone()?,
|
|
||||||
width: self.width,
|
|
||||||
height: self.height,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue