bread-launcher: implement query modes and result sections (phase 6c)
New query module: parse_query splits a leading =/>/. into calc/cmd/url (falling through to a literal apps query otherwise), eval_calc is a small four-function arithmetic evaluator matching the demo's evalCalc semantics (bad expr/err/∞ included), and builtin_commands/filter_commands cover the > palette. matching::split_sections groups already-sorted entries into recent/apps using LaunchHistory's existing counts, no new tracking. ResultsList::new takes a new `sections` bool: true builds non-selectable "Recent"/"Apps" header rows (build_header_row) ahead of their groups, visible only in the idle empty-query view; set_query/select_next/ select_prev all learned to treat a header row (no DesktopEntry data) as never-selectable. breadbox passes sections=false to keep its own overlay exactly as it was.
This commit is contained in:
parent
d2c68f18b0
commit
00e8d4f28e
5 changed files with 603 additions and 28 deletions
|
|
@ -17,7 +17,7 @@ use gtk4::{
|
|||
|
||||
use crate::desktop::DesktopEntry;
|
||||
use crate::history::LaunchHistory;
|
||||
use crate::matching::{fuzzy_matches, fuzzy_score};
|
||||
use crate::matching::{fuzzy_matches, fuzzy_score, split_sections};
|
||||
|
||||
fn make_icon(icon_name: &str, icon_path: Option<&Path>, icon_px: i32) -> Image {
|
||||
// Try loading from resolved cached path via gio::File
|
||||
|
|
@ -71,6 +71,24 @@ fn build_row(entry: &DesktopEntry, idx: u32, icon_px: i32) -> ListBoxRow {
|
|||
row
|
||||
}
|
||||
|
||||
/// A non-selectable, non-activatable "Recent"/"Apps" label row (plan phase
|
||||
/// 6c, `[launcher].sections`) — deliberately carries no `"entry"` row data,
|
||||
/// which is exactly what [`row_entry`] (and everything downstream of it:
|
||||
/// `set_query`'s filter, `select_next`/`select_prev`'s traversal) already
|
||||
/// uses to tell a header apart from a real app row.
|
||||
fn build_header_row(label: &str, idx: u32) -> ListBoxRow {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
row.set_activatable(false);
|
||||
row.add_css_class("bread-drawer-section-header");
|
||||
let lbl = Label::new(Some(label));
|
||||
lbl.add_css_class("section-header-label");
|
||||
lbl.set_xalign(0.0);
|
||||
row.set_child(Some(&lbl));
|
||||
unsafe { row.set_data("initial_order", idx) };
|
||||
row
|
||||
}
|
||||
|
||||
/// Reads the [`DesktopEntry`] a row was built from — e.g. from a
|
||||
/// `ListBox::connect_row_activated` handler, which hands back a row
|
||||
/// reference rather than going through [`ResultsList::selected_entry`].
|
||||
|
|
@ -99,12 +117,48 @@ impl ResultsList {
|
|||
/// Builds one row per entry (in `entries`' given order — that order is
|
||||
/// also the fallback sort when the query is empty) and wires up sorting
|
||||
/// against `history`'s launch counts.
|
||||
pub fn new(entries: &[DesktopEntry], icon_px: i32, history: Rc<RefCell<LaunchHistory>>) -> Self {
|
||||
///
|
||||
/// `sections` (`[launcher].sections`, plan phase 6c): when true, the
|
||||
/// idle (empty-query) view groups `entries` into "Recent"/"Apps"
|
||||
/// [`build_header_row`]s via [`split_sections`] instead of one flat
|
||||
/// list. Sections disappear the moment a query is typed — `set_query`
|
||||
/// falls back to the same flat fuzzy-ranked list either way — so this
|
||||
/// only changes the initial build order and the header rows' presence,
|
||||
/// never the (unchanged) search behaviour. `false` reproduces the
|
||||
/// exact pre-phase-6c flat list breadbox's own overlay still uses.
|
||||
pub fn new(
|
||||
entries: &[DesktopEntry],
|
||||
icon_px: i32,
|
||||
history: Rc<RefCell<LaunchHistory>>,
|
||||
sections: bool,
|
||||
) -> Self {
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(SelectionMode::Browse);
|
||||
|
||||
for (idx, entry) in entries.iter().enumerate() {
|
||||
list.append(&build_row(entry, idx as u32, icon_px));
|
||||
let mut idx = 0u32;
|
||||
if sections {
|
||||
let (recent, apps) = split_sections(entries.to_vec(), &history.borrow());
|
||||
if !recent.is_empty() {
|
||||
list.append(&build_header_row("Recent", idx));
|
||||
idx += 1;
|
||||
for entry in &recent {
|
||||
list.append(&build_row(entry, idx, icon_px));
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
if !apps.is_empty() {
|
||||
list.append(&build_header_row("Apps", idx));
|
||||
idx += 1;
|
||||
for entry in &apps {
|
||||
list.append(&build_row(entry, idx, icon_px));
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for entry in entries {
|
||||
list.append(&build_row(entry, idx, icon_px));
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let query: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
|
||||
|
|
@ -122,22 +176,34 @@ impl ResultsList {
|
|||
};
|
||||
return oa.cmp(&ob).into();
|
||||
}
|
||||
let (Some(ea), Some(eb)) = (row_entry(row_a), row_entry(row_b)) else {
|
||||
return std::cmp::Ordering::Equal.into();
|
||||
};
|
||||
let sa = fuzzy_score(&query, &ea);
|
||||
let sb = fuzzy_score(&query, &eb);
|
||||
let history = history.borrow();
|
||||
let ca = history.count(&ea.name);
|
||||
let cb = history.count(&eb.name);
|
||||
sa.cmp(&sb)
|
||||
.then(cb.cmp(&ca))
|
||||
.then(ea.name.to_lowercase().cmp(&eb.name.to_lowercase()))
|
||||
.into()
|
||||
// A header row carries no "entry" data — sort it after any
|
||||
// real row rather than treating the comparison as `Equal`,
|
||||
// though `set_query` also hides every header outright once
|
||||
// a query is non-empty, so this only matters for the
|
||||
// underlying (invisible) list order, never what's shown.
|
||||
match (row_entry(row_a), row_entry(row_b)) {
|
||||
(Some(ea), Some(eb)) => {
|
||||
let sa = fuzzy_score(&query, &ea);
|
||||
let sb = fuzzy_score(&query, &eb);
|
||||
let history = history.borrow();
|
||||
let ca = history.count(&ea.name);
|
||||
let cb = history.count(&eb.name);
|
||||
sa.cmp(&sb)
|
||||
.then(cb.cmp(&ca))
|
||||
.then(ea.name.to_lowercase().cmp(&eb.name.to_lowercase()))
|
||||
.into()
|
||||
}
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater.into(),
|
||||
(Some(_), None) => std::cmp::Ordering::Less.into(),
|
||||
(None, None) => std::cmp::Ordering::Equal.into(),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(first) = list.row_at_index(0) {
|
||||
let first_real = (0i32..)
|
||||
.map_while(|i| list.row_at_index(i))
|
||||
.find(|r| row_entry(r).is_some());
|
||||
if let Some(first) = first_real {
|
||||
list.select_row(Some(&first));
|
||||
}
|
||||
|
||||
|
|
@ -151,23 +217,29 @@ impl ResultsList {
|
|||
}
|
||||
|
||||
/// Re-filters (fuzzy match against name, `wm_class`, and `exec`) and
|
||||
/// re-sorts by `query`, then selects the first visible row.
|
||||
/// re-sorts by `query`, then selects the first visible row. A header
|
||||
/// row (see [`build_header_row`]) only ever shows in the idle
|
||||
/// (empty-query) browse view — it has no name/`wm_class`/`exec` of its
|
||||
/// own to filter against.
|
||||
pub fn set_query(&self, query: &str) {
|
||||
*self.query.borrow_mut() = query.to_string();
|
||||
let mut i = 0i32;
|
||||
while let Some(row) = self.list.row_at_index(i) {
|
||||
let vis = row_entry(&row)
|
||||
.map(|e| {
|
||||
let vis = match row_entry(&row) {
|
||||
Some(e) => {
|
||||
fuzzy_matches(query, &e.name)
|
||||
|| e.wm_class.as_deref().is_some_and(|w| fuzzy_matches(query, w))
|
||||
|| fuzzy_matches(query, &e.exec)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
}
|
||||
None => query.is_empty(),
|
||||
};
|
||||
row.set_visible(vis);
|
||||
i += 1;
|
||||
}
|
||||
self.list.invalidate_sort();
|
||||
let first_vis = (0i32..).find_map(|j| self.list.row_at_index(j).filter(|r| r.is_visible()));
|
||||
let first_vis = (0i32..)
|
||||
.map_while(|j| self.list.row_at_index(j))
|
||||
.find(|r| r.is_visible() && row_entry(r).is_some());
|
||||
self.list.select_row(first_vis.as_ref());
|
||||
}
|
||||
|
||||
|
|
@ -175,13 +247,16 @@ impl ResultsList {
|
|||
self.list.selected_row().and_then(|r| row_entry(&r))
|
||||
}
|
||||
|
||||
/// Moves the selection to the next visible row, if any.
|
||||
/// Moves the selection to the next visible row, if any. Skips header
|
||||
/// rows even though they may be visible (the idle browse view) —
|
||||
/// `set_selectable(false)` alone doesn't stop a programmatic
|
||||
/// `select_row` call from landing on one.
|
||||
pub fn select_next(&self) {
|
||||
let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(-1);
|
||||
let mut i = cur + 1;
|
||||
loop {
|
||||
match self.list.row_at_index(i) {
|
||||
Some(r) if r.is_visible() => {
|
||||
Some(r) if r.is_visible() && row_entry(&r).is_some() => {
|
||||
self.list.select_row(Some(&r));
|
||||
break;
|
||||
}
|
||||
|
|
@ -191,7 +266,8 @@ impl ResultsList {
|
|||
}
|
||||
}
|
||||
|
||||
/// Moves the selection to the previous visible row, if any.
|
||||
/// Moves the selection to the previous visible row, if any. See
|
||||
/// [`select_next`](Self::select_next) on skipping header rows.
|
||||
pub fn select_prev(&self) {
|
||||
let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(0);
|
||||
let mut i = cur - 1;
|
||||
|
|
@ -200,7 +276,7 @@ impl ResultsList {
|
|||
break;
|
||||
}
|
||||
match self.list.row_at_index(i) {
|
||||
Some(r) if r.is_visible() => {
|
||||
Some(r) if r.is_visible() && row_entry(&r).is_some() => {
|
||||
self.list.select_row(Some(&r));
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,4 +30,16 @@ impl LaunchHistory {
|
|||
let _ = fs::write(&self.path, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory history with no backing file — [`save`](Self::save) is a
|
||||
/// silent no-op (an empty `path`). Lets a test (or a future in-memory
|
||||
/// host) control counts directly instead of writing through
|
||||
/// `~/.cache/<app>/history.json`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_counts(counts: HashMap<String, u32>) -> Self {
|
||||
LaunchHistory {
|
||||
counts,
|
||||
path: PathBuf::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ mod icon;
|
|||
mod launch;
|
||||
mod matching;
|
||||
mod paths;
|
||||
mod query;
|
||||
|
||||
#[cfg(feature = "gtk")]
|
||||
pub mod gtk;
|
||||
|
|
@ -28,8 +29,11 @@ pub use desktop::{load_all_desktop_entries, parse_desktop, strip_exec_codes, Des
|
|||
pub use history::LaunchHistory;
|
||||
pub use icon::IconCache;
|
||||
pub use launch::{do_launch, emit_launched};
|
||||
pub use matching::{fuzzy_matches, fuzzy_score, load_sorted_entries, matches_term, priority_rank};
|
||||
pub use matching::{
|
||||
fuzzy_matches, fuzzy_score, load_sorted_entries, matches_term, priority_rank, split_sections,
|
||||
};
|
||||
pub use paths::{app_dirs, cache_dir, config_dir, home_dir};
|
||||
pub use query::{builtin_commands, eval_calc, filter_commands, parse_query, Command, ParsedQuery, QueryKind};
|
||||
|
||||
/// The launcher's one shared identity, passed to [`cache_dir`]/[`config_dir`]/
|
||||
/// [`IconCache::new`]/[`LaunchHistory::load`] by every host that embeds this
|
||||
|
|
|
|||
|
|
@ -132,6 +132,39 @@ pub fn load_sorted_entries(
|
|||
entries
|
||||
}
|
||||
|
||||
/// The demo's own cap (`BOS.pushRecent`'s `.slice(0, 4)`) on how many
|
||||
/// entries the "Recent" section shows.
|
||||
pub const MAX_RECENT: usize = 4;
|
||||
|
||||
/// Splits `entries` (already ordered by [`load_sorted_entries`]) into a
|
||||
/// "recent" section — the entries `history` has any launch count for, most-
|
||||
/// launched first, capped at [`MAX_RECENT`] — and an "apps" section: every
|
||||
/// other entry, in its existing relative order. No new tracking beyond
|
||||
/// `LaunchHistory`'s existing counts (THEME_SYSTEM_PLAN.md phase 6c task
|
||||
/// notes: "`LaunchHistory` already tracks counts for a recents list").
|
||||
///
|
||||
/// Only meaningful when `entries` has no priority-ranked prefix (breadbar's
|
||||
/// capsule calls [`load_sorted_entries`] with an empty `priority` list) —
|
||||
/// with a non-empty priority list, priority-ranked entries still sort first
|
||||
/// and would be treated as "apps" here even if launched often, since this
|
||||
/// function has no way to tell "sorted first because launched a lot" from
|
||||
/// "sorted first because priority-ranked" apart from the count itself.
|
||||
pub fn split_sections(
|
||||
entries: Vec<DesktopEntry>,
|
||||
history: &LaunchHistory,
|
||||
) -> (Vec<DesktopEntry>, Vec<DesktopEntry>) {
|
||||
let mut recent = Vec::new();
|
||||
let mut apps = Vec::new();
|
||||
for entry in entries {
|
||||
if recent.len() < MAX_RECENT && history.count(&entry.name) > 0 {
|
||||
recent.push(entry);
|
||||
} else {
|
||||
apps.push(entry);
|
||||
}
|
||||
}
|
||||
(recent, apps)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -269,4 +302,63 @@ mod tests {
|
|||
let priority = vec!["code".to_string()];
|
||||
assert_eq!(priority_rank(&e, &priority), None);
|
||||
}
|
||||
|
||||
// ---- split_sections --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn split_sections_no_history_is_all_apps() {
|
||||
let entries = vec![entry("Firefox", None), entry("GoLand", None)];
|
||||
let history = LaunchHistory::from_counts(HashMap::new());
|
||||
let (recent, apps) = split_sections(entries, &history);
|
||||
assert!(recent.is_empty());
|
||||
assert_eq!(apps.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_sections_launched_entries_go_to_recent() {
|
||||
let entries = vec![
|
||||
entry("Firefox", None),
|
||||
entry("GoLand", None),
|
||||
entry("Steam", None),
|
||||
];
|
||||
let mut counts = HashMap::new();
|
||||
counts.insert("Firefox".to_string(), 5);
|
||||
let history = LaunchHistory::from_counts(counts);
|
||||
let (recent, apps) = split_sections(entries, &history);
|
||||
assert_eq!(recent.iter().map(|e| &e.name).collect::<Vec<_>>(), vec!["Firefox"]);
|
||||
assert_eq!(
|
||||
apps.iter().map(|e| &e.name).collect::<Vec<_>>(),
|
||||
vec!["GoLand", "Steam"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_sections_caps_recent_at_max() {
|
||||
let entries: Vec<DesktopEntry> = (0..(MAX_RECENT + 2))
|
||||
.map(|i| entry(&format!("App{i}"), None))
|
||||
.collect();
|
||||
let counts = entries
|
||||
.iter()
|
||||
.map(|e| (e.name.clone(), 1))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let history = LaunchHistory::from_counts(counts);
|
||||
let (recent, apps) = split_sections(entries, &history);
|
||||
assert_eq!(recent.len(), MAX_RECENT);
|
||||
assert_eq!(apps.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_sections_preserves_relative_order_within_each_group() {
|
||||
let mut counts = HashMap::new();
|
||||
counts.insert("A".to_string(), 1);
|
||||
counts.insert("C".to_string(), 3);
|
||||
let history = LaunchHistory::from_counts(counts);
|
||||
// load_sorted_entries would already have ordered these by count
|
||||
// desc before calling split_sections; split_sections itself just
|
||||
// partitions in whatever order it's handed, so feed it pre-sorted.
|
||||
let pre_sorted = vec![entry("C", None), entry("A", None), entry("B", None)];
|
||||
let (recent, apps) = split_sections(pre_sorted, &history);
|
||||
assert_eq!(recent.iter().map(|e| &e.name).collect::<Vec<_>>(), vec!["C", "A"]);
|
||||
assert_eq!(apps.iter().map(|e| &e.name).collect::<Vec<_>>(), vec!["B"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
391
bread-launcher/src/query.rs
Normal file
391
bread-launcher/src/query.rs
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
//! Query modes (`04-spotlight.html`'s `BOS.parseQuery`/`BOS.evalCalc`/
|
||||
//! `BOS.COMMANDS`, THEME_SYSTEM_PLAN.md phase 6c): a leading `=`/`>`/`.`
|
||||
//! switches the launcher from filtering apps to evaluating an arithmetic
|
||||
//! expression, listing bread commands, or treating the rest of the query as
|
||||
//! a URL to open. Pure/headless — no GTK here — so both breadbar's embedded
|
||||
//! capsule and (per the module doc comment on `crate`) breadbox's own
|
||||
//! overlay window can adopt the same parsing/eval/filter logic. A host is
|
||||
//! expected to gate which prefixes it actually acts on against its theme's
|
||||
//! `[launcher].modes` list — this module recognizes all four kinds
|
||||
//! unconditionally and leaves that gating to the caller.
|
||||
|
||||
use crate::matching::fuzzy_matches;
|
||||
|
||||
/// Which of the four query modes a raw entry-text string names, per its
|
||||
/// leading character (mirrors `BOS.parseQuery`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum QueryKind {
|
||||
/// No recognized prefix — the ordinary app-filter query.
|
||||
Apps,
|
||||
/// Leading `=` — the rest is an arithmetic expression for [`eval_calc`].
|
||||
Calc,
|
||||
/// Leading `>` — the rest filters [`builtin_commands`].
|
||||
Cmd,
|
||||
/// Leading `.` — the rest is a URL to open.
|
||||
Url,
|
||||
}
|
||||
|
||||
/// A parsed query: which mode it names, and the text after the prefix
|
||||
/// character (empty string for a bare `=`/`>`/`.` with nothing typed yet).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParsedQuery {
|
||||
pub kind: QueryKind,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Splits `raw` on its leading mode character, if any. Mirrors
|
||||
/// `BOS.parseQuery` exactly: only the FIRST character is checked, and it is
|
||||
/// stripped along with any immediately-following whitespace.
|
||||
pub fn parse_query(raw: &str) -> ParsedQuery {
|
||||
let (kind, rest) = if let Some(rest) = raw.strip_prefix('=') {
|
||||
(QueryKind::Calc, rest)
|
||||
} else if let Some(rest) = raw.strip_prefix('>') {
|
||||
(QueryKind::Cmd, rest)
|
||||
} else if let Some(rest) = raw.strip_prefix('.') {
|
||||
(QueryKind::Url, rest)
|
||||
} else {
|
||||
(QueryKind::Apps, raw)
|
||||
};
|
||||
ParsedQuery {
|
||||
kind,
|
||||
value: rest.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Calc ---------------------------------------------------------------
|
||||
|
||||
/// Evaluates `expr` as a small four-function arithmetic expression
|
||||
/// (`+ - * / ( )`, decimal literals, unary minus) and formats the result
|
||||
/// the same way `BOS.evalCalc` does: rounded to 8 decimal places, an
|
||||
/// integer result prints with no trailing `.0`, a non-finite result prints
|
||||
/// "∞", an expression containing anything outside `[0-9.\s+\-*/()]`
|
||||
/// returns "bad expr", and anything else that fails to parse or evaluate
|
||||
/// (unbalanced parens, division producing NaN, trailing garbage) returns
|
||||
/// "err". `None` only for an empty/whitespace-only expression — the
|
||||
/// caller's "nothing typed after `=` yet" case, which has no result to
|
||||
/// show at all (the demo's own `if (!expr) return null;`).
|
||||
pub fn eval_calc(expr: &str) -> Option<String> {
|
||||
let expr = expr.trim();
|
||||
if expr.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !expr.chars().all(|c| c.is_ascii_digit() || " .+-*/()".contains(c)) {
|
||||
return Some("bad expr".to_string());
|
||||
}
|
||||
let mut p = CalcParser {
|
||||
bytes: expr.as_bytes(),
|
||||
pos: 0,
|
||||
};
|
||||
let result = p.parse_expr().filter(|_| p.skip_ws() == p.bytes.len());
|
||||
Some(match result {
|
||||
Some(n) if !n.is_finite() => "∞".to_string(),
|
||||
Some(n) => format_calc_result(n),
|
||||
None => "err".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Rounds to 8 decimal places and formats without a trailing `.0` for whole
|
||||
/// numbers — `String(Math.round(n * 1e8) / 1e8)` in JS, `{}` on `f64`
|
||||
/// already behaves the same way in Rust (`format!("{}", 4.0_f64)` == "4").
|
||||
fn format_calc_result(n: f64) -> String {
|
||||
let rounded = (n * 1e8).round() / 1e8;
|
||||
// Avoid printing "-0" for a result that rounds to negative zero.
|
||||
let rounded = if rounded == 0.0 { 0.0 } else { rounded };
|
||||
format!("{rounded}")
|
||||
}
|
||||
|
||||
/// Minimal recursive-descent parser: `expr := term (('+'|'-') term)*`,
|
||||
/// `term := factor (('*'|'/') factor)*`, `factor := '-' factor | number |
|
||||
/// '(' expr ')'`. Byte-indexed since the character set is already
|
||||
/// restricted to ASCII by [`eval_calc`]'s pre-check.
|
||||
struct CalcParser<'a> {
|
||||
bytes: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> CalcParser<'a> {
|
||||
fn skip_ws(&mut self) -> usize {
|
||||
while self.pos < self.bytes.len() && self.bytes[self.pos] == b' ' {
|
||||
self.pos += 1;
|
||||
}
|
||||
self.pos
|
||||
}
|
||||
|
||||
fn peek(&mut self) -> Option<u8> {
|
||||
self.skip_ws();
|
||||
self.bytes.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn parse_expr(&mut self) -> Option<f64> {
|
||||
let mut val = self.parse_term()?;
|
||||
loop {
|
||||
match self.peek() {
|
||||
Some(b'+') => {
|
||||
self.pos += 1;
|
||||
val += self.parse_term()?;
|
||||
}
|
||||
Some(b'-') => {
|
||||
self.pos += 1;
|
||||
val -= self.parse_term()?;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Some(val)
|
||||
}
|
||||
|
||||
fn parse_term(&mut self) -> Option<f64> {
|
||||
let mut val = self.parse_factor()?;
|
||||
loop {
|
||||
match self.peek() {
|
||||
Some(b'*') => {
|
||||
self.pos += 1;
|
||||
val *= self.parse_factor()?;
|
||||
}
|
||||
Some(b'/') => {
|
||||
self.pos += 1;
|
||||
val /= self.parse_factor()?;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Some(val)
|
||||
}
|
||||
|
||||
fn parse_factor(&mut self) -> Option<f64> {
|
||||
match self.peek()? {
|
||||
b'-' => {
|
||||
self.pos += 1;
|
||||
Some(-self.parse_factor()?)
|
||||
}
|
||||
b'+' => {
|
||||
self.pos += 1;
|
||||
self.parse_factor()
|
||||
}
|
||||
b'(' => {
|
||||
self.pos += 1;
|
||||
let val = self.parse_expr()?;
|
||||
if self.peek() == Some(b')') {
|
||||
self.pos += 1;
|
||||
Some(val)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
c if c.is_ascii_digit() || c == b'.' => self.parse_number(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_number(&mut self) -> Option<f64> {
|
||||
self.skip_ws();
|
||||
let start = self.pos;
|
||||
let mut seen_dot = false;
|
||||
let mut seen_digit = false;
|
||||
while let Some(&c) = self.bytes.get(self.pos) {
|
||||
if c.is_ascii_digit() {
|
||||
seen_digit = true;
|
||||
self.pos += 1;
|
||||
} else if c == b'.' && !seen_dot {
|
||||
seen_dot = true;
|
||||
self.pos += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !seen_digit {
|
||||
return None;
|
||||
}
|
||||
std::str::from_utf8(&self.bytes[start..self.pos])
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Commands -------------------------------------------------------------
|
||||
|
||||
/// One `>`-mode command palette entry: a display `name` and the shell
|
||||
/// command it runs (via `bash -c`, same spawn convention [`crate::do_launch`]
|
||||
/// already uses for a desktop entry's `Exec=` line).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Command {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub exec: &'static str,
|
||||
}
|
||||
|
||||
/// A small, real bread-ecosystem command palette — deliberately not the
|
||||
/// demo's placeholder set (`Lock session`/`Open settings`/`Test
|
||||
/// notification` map to nothing real in this codebase). `loginctl
|
||||
/// lock-session` and `bread reload` are both already-documented, safe,
|
||||
/// no-argument commands (see the bread CLI reference / systemd-logind).
|
||||
pub fn builtin_commands() -> &'static [Command] {
|
||||
&[
|
||||
Command {
|
||||
id: "lock",
|
||||
name: "Lock session",
|
||||
exec: "loginctl lock-session",
|
||||
},
|
||||
Command {
|
||||
id: "reload-breadd",
|
||||
name: "Reload breadd",
|
||||
exec: "bread reload",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Fuzzy-filters `commands` by `query` against each command's `name` (same
|
||||
/// subsequence match [`crate::fuzzy_matches`] uses for app rows) — an empty
|
||||
/// query matches everything, same as the app list's own empty-query case.
|
||||
pub fn filter_commands(query: &str, commands: &[Command]) -> Vec<Command> {
|
||||
commands
|
||||
.iter()
|
||||
.filter(|c| fuzzy_matches(query, c.name))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ---- parse_query -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_query_bare_text_is_apps() {
|
||||
let p = parse_query("firefox");
|
||||
assert_eq!(p.kind, QueryKind::Apps);
|
||||
assert_eq!(p.value, "firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_query_empty_is_apps() {
|
||||
let p = parse_query("");
|
||||
assert_eq!(p.kind, QueryKind::Apps);
|
||||
assert_eq!(p.value, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_query_equals_is_calc() {
|
||||
let p = parse_query("=2+2");
|
||||
assert_eq!(p.kind, QueryKind::Calc);
|
||||
assert_eq!(p.value, "2+2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_query_gt_is_cmd() {
|
||||
let p = parse_query(">lock");
|
||||
assert_eq!(p.kind, QueryKind::Cmd);
|
||||
assert_eq!(p.value, "lock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_query_dot_is_url() {
|
||||
let p = parse_query(".breadway.dev");
|
||||
assert_eq!(p.kind, QueryKind::Url);
|
||||
assert_eq!(p.value, "breadway.dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_query_strips_leading_whitespace_after_prefix() {
|
||||
let p = parse_query("= 2 + 2 ");
|
||||
assert_eq!(p.kind, QueryKind::Calc);
|
||||
assert_eq!(p.value, "2 + 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_query_bare_prefix_has_empty_value() {
|
||||
assert_eq!(parse_query("=").value, "");
|
||||
assert_eq!(parse_query(">").value, "");
|
||||
assert_eq!(parse_query(".").value, "");
|
||||
}
|
||||
|
||||
// ---- eval_calc -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn eval_calc_empty_expr_is_none() {
|
||||
assert_eq!(eval_calc(""), None);
|
||||
assert_eq!(eval_calc(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_simple_addition() {
|
||||
assert_eq!(eval_calc("2+2"), Some("4".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_precedence() {
|
||||
assert_eq!(eval_calc("2+3*4"), Some("14".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_parens() {
|
||||
assert_eq!(eval_calc("(2+3)*4"), Some("20".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_unary_minus() {
|
||||
assert_eq!(eval_calc("-5+2"), Some("-3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_decimals_round_to_8_places() {
|
||||
assert_eq!(eval_calc("0.1+0.2"), Some("0.3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_division_by_zero_is_infinity_symbol() {
|
||||
assert_eq!(eval_calc("1/0"), Some("∞".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_bad_chars_is_bad_expr() {
|
||||
assert_eq!(eval_calc("2+alert(1)"), Some("bad expr".to_string()));
|
||||
assert_eq!(eval_calc("rm -rf /"), Some("bad expr".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_unbalanced_parens_is_err() {
|
||||
assert_eq!(eval_calc("(2+3"), Some("err".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_trailing_garbage_is_err() {
|
||||
assert_eq!(eval_calc("2+3)"), Some("err".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_double_operator_is_err() {
|
||||
assert_eq!(eval_calc("2++"), Some("err".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_whitespace_is_tolerated() {
|
||||
assert_eq!(eval_calc(" 2 + 2 "), Some("4".to_string()));
|
||||
}
|
||||
|
||||
// ---- commands --------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn builtin_commands_are_non_empty() {
|
||||
assert!(!builtin_commands().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_commands_empty_query_matches_all() {
|
||||
let all = builtin_commands();
|
||||
assert_eq!(filter_commands("", all).len(), all.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_commands_filters_by_name_subsequence() {
|
||||
let matches = filter_commands("lock", builtin_commands());
|
||||
assert!(matches.iter().any(|c| c.id == "lock"));
|
||||
assert!(!matches.iter().any(|c| c.id == "reload-breadd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_commands_no_match_is_empty() {
|
||||
assert!(filter_commands("zzzznotacommand", builtin_commands()).is_empty());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue