check / check (push) Canceled after 0s
One sidebar item, Invoices & Bills (the owner's choice, 2026-10-24), with Money in (invoices) and Money out (bills) tabs: what is owed and overdue, and the list tied to its receivables or payables account (Agrees, or what to look at); filters Open, Overdue, Paid, Void, All; rows by due date, an overdue one saying how many days late, each named in full for assistive tech. A document's page: total, received or paid, still owed, status; its lines with their projects; its timeline, a payment later reversed marked so, each row opening its entry. The overview adds Owed to you and You owe on bills once the books have documents. Every figure is the document's derived state (rules.md B-38). The sidebar's rows (and the rail's) give way to 22 points, not 24, so 13 links still fit the owner's 1479x519 window unscrolled; the sidebar's tests compute heights from the link count; the 1440x920 golden is re-blessed for the new link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
553 lines
21 KiB
Rust
553 lines
21 KiB
Rust
//! Ctrl+K: jump to anything. Pages, reports, accounts, entries (by text,
|
||
//! amount, or #id), fixed assets (by tag, serial, or description), parties
|
||
//! (vendors and customers among them, by name or display name),
|
||
//! documents, bank and card statements, and reconciliations (by account
|
||
//! and date), ranked by a simple fuzzy match.
|
||
|
||
use crate::books::Books;
|
||
use crate::fmt;
|
||
use crate::nav::{ReportKind, Route, Section};
|
||
use crate::theme::{self, pal};
|
||
use egui::{CornerRadius, Key, Margin, RichText, Sense, Stroke, vec2};
|
||
use egui_phosphor::regular as icon;
|
||
|
||
#[derive(Default)]
|
||
pub struct Palette {
|
||
open: bool,
|
||
query: String,
|
||
selected: usize,
|
||
focus: bool,
|
||
}
|
||
|
||
/// One search result.
|
||
pub struct Item {
|
||
pub icon: &'static str,
|
||
pub title: String,
|
||
pub detail: String,
|
||
pub route: Route,
|
||
pub score: i32,
|
||
}
|
||
|
||
impl Palette {
|
||
pub fn open(&mut self) {
|
||
self.open = true;
|
||
self.query.clear();
|
||
self.selected = 0;
|
||
self.focus = true;
|
||
}
|
||
|
||
/// Show the palette if open; returns where to go.
|
||
pub fn show(&mut self, ctx: &egui::Context, books: &Books) -> Option<Route> {
|
||
if !self.open {
|
||
return None;
|
||
}
|
||
let p = pal(ctx);
|
||
let items = search(books, &self.query);
|
||
let mut go = None;
|
||
let (up, down, enter, esc) = ctx.input(|i| {
|
||
(
|
||
i.key_pressed(Key::ArrowUp),
|
||
i.key_pressed(Key::ArrowDown),
|
||
i.key_pressed(Key::Enter),
|
||
i.key_pressed(Key::Escape),
|
||
)
|
||
});
|
||
if down {
|
||
self.selected = (self.selected + 1).min(items.len().saturating_sub(1));
|
||
}
|
||
if up {
|
||
self.selected = self.selected.saturating_sub(1);
|
||
}
|
||
if esc {
|
||
self.open = false;
|
||
return None;
|
||
}
|
||
if enter && let Some(it) = items.get(self.selected) {
|
||
self.open = false;
|
||
return Some(it.route.clone());
|
||
}
|
||
let modal = egui::Modal::new(egui::Id::new("palette"))
|
||
.frame(
|
||
egui::Frame::new()
|
||
.fill(p.surface)
|
||
.stroke(Stroke::new(1.0, p.border))
|
||
.corner_radius(CornerRadius::same(14))
|
||
.inner_margin(Margin::same(10)),
|
||
)
|
||
.show(ctx, |ui| {
|
||
// Never wider or taller than the window allows.
|
||
let screen = ctx.content_rect().size();
|
||
ui.set_width(620.0_f32.min(screen.x - 48.0).max(200.0));
|
||
let edit = egui::TextEdit::singleline(&mut self.query)
|
||
.hint_text("Search entries, accounts, assets, vendors, documents, reports…")
|
||
.font(theme::regular(16.0))
|
||
.frame(egui::Frame::NONE)
|
||
.desired_width(f32::INFINITY);
|
||
let r = ui.add(edit);
|
||
if self.focus {
|
||
r.request_focus();
|
||
self.focus = false;
|
||
}
|
||
if r.changed() {
|
||
self.selected = 0;
|
||
}
|
||
ui.add_space(6.0);
|
||
crate::widgets::rule(ui);
|
||
ui.add_space(4.0);
|
||
egui::ScrollArea::vertical()
|
||
.max_height(420.0_f32.min(screen.y - 160.0).max(120.0))
|
||
.show(ui, |ui| {
|
||
if items.is_empty() {
|
||
ui.label(RichText::new("Nothing matches.").color(p.muted));
|
||
}
|
||
for (i, it) in items.iter().enumerate() {
|
||
let (rect, resp) = ui.allocate_exact_size(
|
||
vec2(ui.available_width(), 40.0),
|
||
Sense::click(),
|
||
);
|
||
if i == self.selected || resp.hovered() {
|
||
ui.painter()
|
||
.rect_filled(rect, CornerRadius::same(8), p.hover);
|
||
}
|
||
if i == self.selected {
|
||
ui.scroll_to_rect(rect, None);
|
||
}
|
||
let painter = ui.painter_at(rect);
|
||
painter.text(
|
||
rect.left_center() + vec2(12.0, 0.0),
|
||
egui::Align2::LEFT_CENTER,
|
||
it.icon,
|
||
egui::FontId::proportional(16.0),
|
||
p.muted,
|
||
);
|
||
painter.text(
|
||
rect.left_center() + vec2(38.0, -7.0),
|
||
egui::Align2::LEFT_CENTER,
|
||
&it.title,
|
||
theme::regular(14.0),
|
||
p.text,
|
||
);
|
||
painter.text(
|
||
rect.left_center() + vec2(38.0, 9.0),
|
||
egui::Align2::LEFT_CENTER,
|
||
&it.detail,
|
||
theme::regular(11.5),
|
||
p.faint,
|
||
);
|
||
if resp.clicked() {
|
||
go = Some(it.route.clone());
|
||
}
|
||
}
|
||
});
|
||
});
|
||
if modal.should_close() {
|
||
self.open = false;
|
||
}
|
||
if go.is_some() {
|
||
self.open = false;
|
||
}
|
||
go
|
||
}
|
||
}
|
||
|
||
/// Subsequence match: every query character in order, rewarding
|
||
/// contiguous runs and word starts. None if no match.
|
||
/// Fuzzy match score, higher is better.
|
||
///
|
||
/// ```text
|
||
/// INVARIANTS:
|
||
/// 1. Matches (Some) exactly when the query's non-space characters,
|
||
/// lower-cased, appear in order in the lower-cased hay; the empty
|
||
/// query matches everything with score 0.
|
||
/// 2. A match's score is at least the number of non-space query
|
||
/// characters; consecutive matches and matches at a word start score
|
||
/// more. The score is the best over every way to match (not the
|
||
/// first match found), so a hay holding the query whole at a word
|
||
/// start scores at least as high as any hay's match of it.
|
||
/// ```
|
||
pub fn fuzzy(hay: &str, query: &str) -> Option<i32> {
|
||
let hay: Vec<char> = hay.to_lowercase().chars().collect();
|
||
// Query characters, each marked whether a space came just before it
|
||
// (a space ends a run: the next match earns no run bonus).
|
||
let mut q: Vec<(char, bool)> = Vec::new();
|
||
let mut after_space = false;
|
||
for c in query.to_lowercase().chars() {
|
||
if c == ' ' {
|
||
after_space = true;
|
||
} else {
|
||
q.push((c, after_space));
|
||
after_space = false;
|
||
}
|
||
}
|
||
if q.is_empty() {
|
||
return Some(0);
|
||
}
|
||
// The best score over every way to match (a greedy left-most match
|
||
// can miss a word start further on): best[j] is the top score for the
|
||
// query so far with its last character at hay[j].
|
||
let word_start = |j: usize| j == 0 || !hay[j - 1].is_alphanumeric();
|
||
let mut best: Vec<Option<i32>> = hay
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(j, &c)| (c == q[0].0).then(|| 1 + if word_start(j) { 4 } else { 0 }))
|
||
.collect();
|
||
for &(qc, gap) in &q[1..] {
|
||
let mut next = vec![None; hay.len()];
|
||
// The best score ending strictly before j.
|
||
let mut before: Option<i32> = None;
|
||
for j in 0..hay.len() {
|
||
if hay[j] == qc {
|
||
let bonus = if word_start(j) { 4 } else { 0 };
|
||
let run = if gap {
|
||
None
|
||
} else {
|
||
j.checked_sub(1)
|
||
.and_then(|p| best[p])
|
||
.map(|s| s + 6 + bonus)
|
||
};
|
||
let jump = before.map(|s| s + 1 + bonus);
|
||
next[j] = run.max(jump);
|
||
}
|
||
before = before.max(best[j]);
|
||
}
|
||
best = next;
|
||
}
|
||
best.into_iter().flatten().max()
|
||
}
|
||
|
||
/// What the palette lists for `query`, best first (at most 40).
|
||
///
|
||
/// ```text
|
||
/// INVARIANTS (fixed assets; everything else as it was):
|
||
/// 1. An asset whose tag equals the trimmed query, ignoring ASCII case,
|
||
/// scores 2000: above every other result (an entry's exact #id is
|
||
/// 1000).
|
||
/// 2. Otherwise an asset is listed when the query fuzzily matches its
|
||
/// tag, serial, and description; a query found whole in its tag or
|
||
/// serial (ignoring case) adds 500, in its description 50.
|
||
/// 3. Every asset result opens Route::Asset(its id).
|
||
/// INVARIANTS (parties):
|
||
/// 4. A party whose name or display name equals the trimmed query,
|
||
/// ignoring case, scores 1500: above an entry's exact #id, below an
|
||
/// exact asset tag.
|
||
/// 5. Otherwise a party is listed when the query fuzzily matches its
|
||
/// name and display name; a query found whole in either (ignoring
|
||
/// case) adds 500.
|
||
/// 6. Every party result opens Route::Party(its id); archived parties
|
||
/// are found too, and say so.
|
||
/// INVARIANTS (statements and reconciliations):
|
||
/// 7. A statement is listed when the query fuzzily matches "statement",
|
||
/// its account's code and name, its period's dates (as shown and as
|
||
/// YYYY-MM-DD), and its file name; it opens Route::Document(its
|
||
/// attachment).
|
||
/// 8. A reconciliation is listed when the query fuzzily matches
|
||
/// "reconciliation reconciled", its account's code and name, and its
|
||
/// statement date (as shown and as YYYY-MM-DD); it opens
|
||
/// Route::Reconciliation(its id), voided ones too, saying so.
|
||
/// INVARIANTS (projects):
|
||
/// 9. A project is listed when the query fuzzily matches its name and
|
||
/// description; a query found whole in its name (ignoring case) adds
|
||
/// 500. It opens Route::Project(its id).
|
||
/// ```
|
||
pub fn search(b: &Books, query: &str) -> Vec<Item> {
|
||
let q = query.trim();
|
||
let mut out: Vec<Item> = Vec::new();
|
||
let mut add = |icon: &'static str, title: String, detail: String, route: Route, hay: &str| {
|
||
if let Some(score) = fuzzy(hay, q) {
|
||
out.push(Item {
|
||
icon,
|
||
title,
|
||
detail,
|
||
route,
|
||
score,
|
||
});
|
||
}
|
||
};
|
||
for s in Section::ALL {
|
||
add(
|
||
s.icon(),
|
||
s.title().into(),
|
||
"Page".into(),
|
||
s.home(),
|
||
s.title(),
|
||
);
|
||
}
|
||
for r in ReportKind::ALL {
|
||
add(
|
||
icon::FILE_TEXT,
|
||
r.title().into(),
|
||
"Report".into(),
|
||
Route::Reports(r),
|
||
&format!("report {}", r.title()),
|
||
);
|
||
}
|
||
if !q.is_empty() {
|
||
for a in b.ledger.chart().all() {
|
||
add(
|
||
icon::TREE_STRUCTURE,
|
||
format!("{} {}", a.code, a.name),
|
||
format!("{:?} account", a.account_type),
|
||
Route::Account(a.id),
|
||
&format!("{} {}", a.code, a.name),
|
||
);
|
||
}
|
||
let id_query = q.strip_prefix('#').and_then(|s| s.parse::<u64>().ok());
|
||
for e in &b.entries {
|
||
let exact = id_query == Some(e.id);
|
||
let hay = &e.haystack;
|
||
let score = if exact {
|
||
Some(1000)
|
||
} else {
|
||
fuzzy(hay, q).filter(|_| q.len() >= 2)
|
||
};
|
||
if let Some(score) = score {
|
||
// Prefer substring hits for entries: fuzzy over long text is noisy.
|
||
let bonus = if hay.contains(&q.to_lowercase()) {
|
||
50
|
||
} else {
|
||
0
|
||
};
|
||
if bonus == 0 && !exact && q.len() < 4 {
|
||
continue;
|
||
}
|
||
out.push(Item {
|
||
icon: icon::RECEIPT,
|
||
title: format!("#{} {}", e.id, e.description),
|
||
detail: format!("{} · {}", fmt::date(e.date), fmt::dollars_or(e.amount)),
|
||
route: Route::Entry(e.id),
|
||
score: score + bonus,
|
||
});
|
||
}
|
||
}
|
||
let ql = q.to_lowercase();
|
||
for a in b.ledger.registered_assets() {
|
||
let tag = a.tag.as_deref().unwrap_or("");
|
||
let serial = a.serial.as_deref().unwrap_or("");
|
||
let hay = format!("{tag} {serial} {}", a.description);
|
||
let score = if !tag.is_empty() && tag.eq_ignore_ascii_case(q) {
|
||
Some(2000)
|
||
} else {
|
||
fuzzy(&hay, q).map(|s| {
|
||
let whole = |t: &str| !t.is_empty() && t.to_lowercase().contains(&ql);
|
||
s + if whole(tag) || whole(serial) {
|
||
500
|
||
} else if whole(&a.description) {
|
||
50
|
||
} else {
|
||
0
|
||
}
|
||
})
|
||
};
|
||
if let Some(score) = score {
|
||
let mut detail = vec!["Fixed asset".to_string()];
|
||
if !serial.is_empty() {
|
||
detail.push(format!("serial {serial}"));
|
||
}
|
||
detail.push(format!("in service {}", fmt::date(a.in_service_date)));
|
||
out.push(Item {
|
||
icon: icon::PACKAGE,
|
||
title: b.asset_label(a.id),
|
||
detail: detail.join(" · "),
|
||
route: Route::Asset(a.id),
|
||
score,
|
||
});
|
||
}
|
||
}
|
||
for p in b.ledger.parties() {
|
||
let display = p.display_name.as_deref().unwrap_or("");
|
||
let exact = |t: &str| !t.is_empty() && t.trim().to_lowercase() == ql;
|
||
let whole = |t: &str| !t.is_empty() && t.to_lowercase().contains(&ql);
|
||
let score = if exact(&p.name) || exact(display) {
|
||
Some(1500)
|
||
} else {
|
||
fuzzy(&format!("{} {display}", p.name), q).map(|s| {
|
||
s + if whole(&p.name) || whole(display) {
|
||
500
|
||
} else {
|
||
0
|
||
}
|
||
})
|
||
};
|
||
if let Some(score) = score {
|
||
let mut detail = vec![crate::pages::parties::roles_text(p)];
|
||
if p.display_name.is_some() {
|
||
detail.push(p.name.clone());
|
||
}
|
||
if p.archived {
|
||
detail.push("archived".into());
|
||
}
|
||
out.push(Item {
|
||
icon: crate::pages::parties::party_icon(p),
|
||
title: p.label().to_string(),
|
||
detail: detail.join(" · "),
|
||
route: Route::Party(p.id),
|
||
score,
|
||
});
|
||
}
|
||
}
|
||
for c in b.ledger.list_classes() {
|
||
let f = b.ledger.project_facts(c.id).unwrap_or_default();
|
||
let desc = f.description.as_deref().unwrap_or("");
|
||
let hay = format!("{} {desc}", c.name);
|
||
if let Some(score) = fuzzy(&hay, q) {
|
||
let whole = c.name.to_lowercase().contains(&q.to_lowercase());
|
||
let kind = f.kind.map_or("Kind not recorded", |k| k.words());
|
||
out.push(Item {
|
||
icon: icon::BRIEFCASE,
|
||
title: c.name.clone(),
|
||
detail: format!("{kind} · {}", f.status.words()),
|
||
route: Route::Project(c.id),
|
||
score: score + if whole { 500 } else { 0 },
|
||
});
|
||
}
|
||
}
|
||
for st in &b.statements {
|
||
let file = b
|
||
.attachment(st.attachment_id)
|
||
.map_or("", |a| a.file_name.as_str());
|
||
let hay = format!(
|
||
"statement {} {} {} {} {} {file}",
|
||
b.account_label(st.account_id),
|
||
fmt::date(st.period_start),
|
||
fmt::date(st.period_end),
|
||
st.period_start,
|
||
st.period_end
|
||
);
|
||
if let Some(score) = fuzzy(&hay, q) {
|
||
out.push(Item {
|
||
icon: icon::FILE_TEXT,
|
||
title: format!(
|
||
"Statement: {}, {} – {}",
|
||
b.account_name(st.account_id),
|
||
fmt::date_short(st.period_start),
|
||
fmt::date(st.period_end)
|
||
),
|
||
detail: format!("Closing {} · {file}", fmt::dollars(st.closing_balance)),
|
||
route: Route::Document(st.attachment_id),
|
||
score,
|
||
});
|
||
}
|
||
}
|
||
for r in &b.reconciliations {
|
||
let hay = format!(
|
||
"reconciliation reconciled {} {} {}",
|
||
b.account_label(r.account_id),
|
||
fmt::date(r.statement_date),
|
||
r.statement_date
|
||
);
|
||
if let Some(score) = fuzzy(&hay, q) {
|
||
let mut detail = vec![
|
||
fmt::dollars(r.statement_balance),
|
||
fmt::count(r.cleared.len(), "cleared line", "cleared lines"),
|
||
];
|
||
if r.voided.is_some() {
|
||
detail.push("voided".into());
|
||
}
|
||
out.push(Item {
|
||
icon: icon::CHECK_SQUARE,
|
||
title: format!(
|
||
"Reconciliation: {} through {}",
|
||
b.account_name(r.account_id),
|
||
fmt::date(r.statement_date)
|
||
),
|
||
detail: detail.join(" · "),
|
||
route: Route::Reconciliation(r.id),
|
||
score,
|
||
});
|
||
}
|
||
}
|
||
for a in &b.attachments {
|
||
let hay = format!("{} {}", a.file_name, a.description.as_deref().unwrap_or(""));
|
||
if let Some(score) = fuzzy(&hay, q) {
|
||
out.push(Item {
|
||
icon: icon::FILE,
|
||
title: a.file_name.clone(),
|
||
detail: format!(
|
||
"{} · {}",
|
||
fmt::label(&a.category),
|
||
a.description.as_deref().unwrap_or("")
|
||
),
|
||
route: Route::Document(a.id),
|
||
score,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
out.sort_by_key(|i| std::cmp::Reverse(i.score));
|
||
out.truncate(40);
|
||
out
|
||
}
|
||
|
||
/// The breadcrumb for a route, outermost first: its section, any page it
|
||
/// sits under (a reconciliation under its account), then the route
|
||
/// itself. Each crumb is its text and where clicking it goes; `None` for
|
||
/// the page you're on, which isn't a link.
|
||
///
|
||
/// ```text
|
||
/// INVARIANTS:
|
||
/// 1. The first crumb is the section's title, going to Section::home.
|
||
/// 2. The last crumb is the current page: describe(r) when it says
|
||
/// anything, else the section itself (a section's own page).
|
||
/// 3. Only the last crumb is None; a crumb whose route is the current
|
||
/// one is never a link.
|
||
/// ```
|
||
pub fn crumbs(b: Option<&Books>, r: &Route) -> Vec<(String, Option<Route>)> {
|
||
let section = r.section();
|
||
let here = b.map(|b| describe(b, r)).unwrap_or_default();
|
||
let mut out = vec![(section.title().to_string(), Some(section.home()))];
|
||
if let (Some(b), Route::Reconciliation(id)) = (b, r)
|
||
&& let Some(rec) = b.reconciliation(*id)
|
||
{
|
||
let account = Route::Account(rec.account_id);
|
||
out.push((describe(b, &account), Some(account)));
|
||
}
|
||
if here.is_empty() {
|
||
out.truncate(1);
|
||
} else {
|
||
out.push((here, None));
|
||
}
|
||
for c in &mut out {
|
||
if c.1.as_ref() == Some(r) {
|
||
c.1 = None;
|
||
}
|
||
}
|
||
if let Some(last) = out.last_mut() {
|
||
last.1 = None;
|
||
}
|
||
out
|
||
}
|
||
|
||
/// A short description of a route for the breadcrumb.
|
||
pub fn describe(b: &Books, r: &Route) -> String {
|
||
match r {
|
||
Route::Entry(id) => b
|
||
.entry(*id)
|
||
.map(|e| format!("#{} {}", e.id, e.description))
|
||
.unwrap_or_default(),
|
||
Route::Reports(k) => k.title().into(),
|
||
Route::Account(id) => b.account_label(*id),
|
||
Route::Document(id) => b
|
||
.attachments
|
||
.iter()
|
||
.find(|a| a.id == *id)
|
||
.map(|a| a.file_name.clone())
|
||
.unwrap_or_default(),
|
||
Route::Asset(id) => b.asset_label(*id),
|
||
Route::Invoice(id) => b
|
||
.ledger
|
||
.get_invoice(*id)
|
||
.map(|_| format!("Invoice {id}"))
|
||
.unwrap_or_default(),
|
||
Route::Bill(id) => b
|
||
.ledger
|
||
.get_bill(*id)
|
||
.map(|_| format!("Bill {id}"))
|
||
.unwrap_or_default(),
|
||
Route::Party(id) => b.party_label(*id),
|
||
Route::Project(id) => b.project_label(*id),
|
||
Route::Reconciliation(id) => b.subject_label(ledger_store::Subject::Reconciliation(*id)),
|
||
Route::JournalEvent(seq) => format!("Event {seq}"),
|
||
_ => String::new(),
|
||
}
|
||
}
|