viewer: click to copy values, amounts, and identifiers
check / check (push) Waiting to run

Company details (EIN, state number, address, every profile value), an
entry's number and amount, an account's code and balance, a document's
file name and SHA-256 copy on click: a pointer and copy icon on hover,
a check for a moment after. Amounts copy as their exact plain decimal
(fmt::plain: no "$", commas, or parentheses; "-" for negatives); where a
click already drills down (report cells, register and transaction rows,
general ledger lines), a right-click menu copies the amounts, the entry
number, or the description. The sidebar's chain check copies the
journal head and data folder.

tests/copy.rs: every display style copies back to the exact value
(property), and clicking the EIN, an entry's amount, and its number puts
exactly that on the clipboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-14 15:38:05 -06:00
1 parent 8ac4e5438d
commit eba85f9c77
10 files changed
+344 -17

No files matched your search

+17 -6
View File
@@ -675,12 +675,23 @@ fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
} else { } else {
(icon::WARNING_CIRCLE, p.negative) (icon::WARNING_CIRCLE, p.negative)
}; };
ui.label( let r = ui
RichText::new(format!("{glyph} {text}")) .label(
.font(theme::regular(11.5)) RichText::new(format!("{glyph} {text}"))
.color(color), .font(theme::regular(11.5))
) .color(color),
.on_hover_text(tip); )
.on_hover_text(tip);
if text == "Journal chain verified" {
r.context_menu(|ui| {
crate::widgets::copy_item(ui, "Copy journal head", &b.head);
crate::widgets::copy_item(
ui,
"Copy data folder",
&b.data_dir.display().to_string(),
);
});
}
} }
}); });
} }
+32
View File
@@ -87,6 +87,31 @@ fn group(d: Decimal) -> String {
format!("{out}.{frac}") format!("{out}.{frac}")
} }
/// The plain decimal behind a displayed amount, for the clipboard:
/// `$1,234.56` → `1234.56`, `(207.68)` and `−12.00` → `-207.68`, `-12.00`.
/// Exact (the display never rounds). None when the text isn't an amount.
pub fn plain(display: &str) -> Option<String> {
let t = display.trim();
let (neg, t) = match t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
Some(inner) => (true, inner),
None => (false, t),
};
let (neg, t) = match t.strip_prefix('\u{2212}').or_else(|| t.strip_prefix('-')) {
Some(rest) => (!neg, rest),
None => (neg, t.strip_prefix('+').unwrap_or(t)),
};
let digits: String = t.chars().filter(|c| !matches!(c, '$' | ',')).collect();
let d: Decimal = digits.parse().ok()?;
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit() || c == '.') {
return None;
}
Some(if neg && !d.is_zero() {
format!("-{digits}")
} else {
digits
})
}
/// A percent change from `prev` to `now`, if `prev` isn't zero. /// A percent change from `prev` to `now`, if `prev` isn't zero.
pub fn pct_change(prev: Decimal, now: Decimal) -> Option<f64> { pub fn pct_change(prev: Decimal, now: Decimal) -> Option<f64> {
if prev.is_zero() { if prev.is_zero() {
@@ -213,5 +238,12 @@ mod tests {
assert_eq!(label("ein"), "EIN"); assert_eq!(label("ein"), "EIN");
assert_eq!(label("irs_name_control"), "IRS name control"); assert_eq!(label("irs_name_control"), "IRS name control");
assert_eq!(words("PrepaidExpense"), "Prepaid expense"); assert_eq!(words("PrepaidExpense"), "Prepaid expense");
assert_eq!(plain("$1,234.56").as_deref(), Some("1234.56"));
assert_eq!(plain("(207.68)").as_deref(), Some("-207.68"));
assert_eq!(plain("\u{2212}$12.00").as_deref(), Some("-12.00"));
assert_eq!(plain("+1,000.005").as_deref(), Some("1000.005"));
assert_eq!(plain("0.00").as_deref(), Some("0.00"));
assert_eq!(plain("Debit"), None);
assert_eq!(plain(""), None);
} }
} }
+25 -2
View File
@@ -168,18 +168,22 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
.range .range
.unwrap_or((first.min(b.today), b.today)); .unwrap_or((first.min(b.today), b.today));
page(ui, "account", |ui| { page(ui, "account", |ui| {
ui.label( widgets::copyable(
ui,
RichText::new(format!("{:?} account · {}", a.account_type, a.code)) RichText::new(format!("{:?} account · {}", a.account_type, a.code))
.font(theme::regular(13.0)) .font(theme::regular(13.0))
.color(p.muted), .color(p.muted),
&a.code,
); );
ui.label(RichText::new(&a.name).font(theme::bold(26.0))); ui.label(RichText::new(&a.name).font(theme::bold(26.0)));
ui.add_space(4.0); ui.add_space(4.0);
let bal = b.balance_on(id, b.today); let bal = b.balance_on(id, b.today);
ui.label( widgets::copyable(
ui,
RichText::new(format!("Balance today {}", fmt::dollars(bal))) RichText::new(format!("Balance today {}", fmt::dollars(bal)))
.font(theme::regular(15.0)) .font(theme::regular(15.0))
.color(p.muted), .color(p.muted),
&fmt::plain(&fmt::dollars(bal)).unwrap_or_default(),
); );
ui.add_space(14.0); ui.add_space(14.0);
let hist = balance_history(&b.ledger, &[id]); let hist = balance_history(&b.ledger, &[id]);
@@ -274,6 +278,25 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
vec2(w, if sub.is_empty() { 30.0 } else { 42.0 }), vec2(w, if sub.is_empty() { 30.0 } else { 42.0 }),
Sense::click(), Sense::click(),
); );
resp.context_menu(|ui| {
for (label, v) in [("Copy debit", d), ("Copy credit", c)] {
if !v.is_zero() {
widgets::copy_item(
ui,
label,
&fmt::plain(&fmt::money(v)).unwrap_or_default(),
);
}
}
widgets::copy_item(
ui,
"Copy balance",
&fmt::plain(&fmt::money(bal)).unwrap_or_default(),
);
if let Some(id) = sub.strip_prefix('#').and_then(|t| t.split(' ').next()) {
widgets::copy_item(ui, "Copy entry number", &format!("#{id}"));
}
});
if resp.hovered() && !strong { if resp.hovered() && !strong {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover); ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
} }
+2 -2
View File
@@ -20,7 +20,7 @@ fn kv(ui: &mut Ui, k: &str, v: &str) {
// Narrow, the label goes above its value. // Narrow, the label goes above its value.
if ui.available_width() < 360.0 { if ui.available_width() < 360.0 {
ui.label(RichText::new(k).font(theme::regular(12.0)).color(p.muted)); ui.label(RichText::new(k).font(theme::regular(12.0)).color(p.muted));
ui.add(egui::Label::new(RichText::new(v).font(theme::regular(13.0))).wrap()); widgets::copyable(ui, RichText::new(v).font(theme::regular(13.0)), v);
ui.add_space(4.0); ui.add_space(4.0);
return; return;
} }
@@ -34,7 +34,7 @@ fn kv(ui: &mut Ui, k: &str, v: &str) {
theme::regular(13.0), theme::regular(13.0),
p.muted, p.muted,
); );
ui.add(egui::Label::new(RichText::new(v).font(theme::regular(13.0))).wrap()); widgets::copyable(ui, RichText::new(v).font(theme::regular(13.0)), v);
}); });
} }
+9 -3
View File
@@ -246,7 +246,11 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
} }
}); });
}); });
ui.label(RichText::new(&a.file_name).font(theme::bold(20.0))); widgets::copyable(
ui,
RichText::new(&a.file_name).font(theme::bold(20.0)),
&a.file_name,
);
ui.add_space(8.0); ui.add_space(8.0);
egui::Grid::new(("docfacts", a.id)) egui::Grid::new(("docfacts", a.id))
.num_columns(2) .num_columns(2)
@@ -265,8 +269,10 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
ui.label(fmt::timestamp(&a.created_at)); ui.label(fmt::timestamp(&a.created_at));
ui.end_row(); ui.end_row();
k(ui, "SHA-256"); k(ui, "SHA-256");
ui.add( widgets::copyable(
egui::Label::new(RichText::new(&a.blob_sha).monospace().size(11.5)).wrap(), ui,
RichText::new(&a.blob_sha).monospace().size(11.5),
&a.blob_sha,
); );
ui.end_row(); ui.end_row();
if let Some(e) = a.entry_id { if let Some(e) = a.entry_id {
+8 -2
View File
@@ -24,10 +24,12 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
.auto_shrink(false) .auto_shrink(false)
.show(ui, |ui| { .show(ui, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label( widgets::copyable(
ui,
RichText::new(format!("Entry #{}", e.id)) RichText::new(format!("Entry #{}", e.id))
.font(theme::regular(12.5)) .font(theme::regular(12.5))
.color(p.muted), .color(p.muted),
&format!("#{}", e.id),
); );
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui if ui
@@ -106,7 +108,11 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
ui.label(fmt::date(e.date)); ui.label(fmt::date(e.date));
ui.end_row(); ui.end_row();
k(ui, "Amount"); k(ui, "Amount");
ui.label(RichText::new(fmt::dollars(e.amount)).font(theme::semibold(14.0))); widgets::copyable(
ui,
RichText::new(fmt::dollars(e.amount)).font(theme::semibold(14.0)),
&fmt::plain(&fmt::dollars(e.amount)).unwrap_or_default(),
);
ui.end_row(); ui.end_row();
if let Some(c) = &created { if let Some(c) = &created {
k(ui, "Recorded"); k(ui, "Recorded");
+32 -1
View File
@@ -1540,15 +1540,37 @@ fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, shell: &mut Shell<'_>) {
egui::pos2(x1 - col_w + 8.0, rr.top()), egui::pos2(x1 - col_w + 8.0, rr.top()),
egui::pos2(x1, rr.bottom()), egui::pos2(x1, rr.bottom()),
); );
let copy = fmt::plain(&text);
let cresp = ui.interact( let cresp = ui.interact(
cell, cell,
ui.id().with(("cell", ri, i)), ui.id().with(("cell", ri, i)),
if c.drill.is_some() { if c.drill.is_some() || copy.is_some() {
Sense::click() Sense::click()
} else { } else {
Sense::hover() Sense::hover()
}, },
); );
// An amount copies as its plain decimal: a click
// where the cell doesn't drill down, the right-click
// menu where it does.
if let Some(v) = &copy {
if c.drill.is_some() {
cresp.context_menu(|ui| widgets::copy_item(ui, "Copy amount", v));
} else {
if cresp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if cresp.clicked() {
widgets::copy_now(ui, cresp.id, v);
}
let note = if widgets::recently_copied(ui, cresp.id) {
"Copied"
} else {
"Click to copy"
};
cresp.clone().on_hover_text(note);
}
}
let hovered = c.drill.is_some() && cresp.hovered(); let hovered = c.drill.is_some() && cresp.hovered();
let vcolor = if hovered { p.accent } else { color }; let vcolor = if hovered { p.accent } else { color };
let vfont = if matches!(r.kind, Kind::Line) { let vfont = if matches!(r.kind, Kind::Line) {
@@ -1677,6 +1699,15 @@ fn gl(ui: &mut Ui, b: &Books, s: NaiveDate, e: NaiveDate, shell: &mut Shell<'_>)
color: egui::Color32| color: egui::Color32|
-> egui::Response { -> egui::Response {
let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click()); let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click());
resp.context_menu(|ui| {
for v in &vals {
if let Some(id) = v.strip_prefix('#') {
widgets::copy_item(ui, "Copy entry number", &format!("#{id}"));
} else if let Some(a) = fmt::plain(v) {
widgets::copy_item(ui, &format!("Copy {v}"), &a);
}
}
});
if resp.hovered() && !strong { if resp.hovered() && !strong {
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover); ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
} }
@@ -134,6 +134,22 @@ fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> egui::Response { fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> egui::Response {
let p = pal(ui.ctx()); let p = pal(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 50.0), Sense::click()); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 50.0), Sense::click());
resp.context_menu(|ui| {
widgets::copy_item(
ui,
"Copy amount",
&fmt::plain(&fmt::money(e.amount)).unwrap_or_default(),
);
if !e.cash_effect.is_zero() {
widgets::copy_item(
ui,
"Copy cash effect",
&fmt::plain(&fmt::money(e.cash_effect)).unwrap_or_default(),
);
}
widgets::copy_item(ui, "Copy entry number", &format!("#{}", e.id));
widgets::copy_item(ui, "Copy description", &e.description);
});
let fresh = b.fresh.contains(&e.id) && since_update < 8.0; let fresh = b.fresh.contains(&e.id) && since_update < 8.0;
if on { if on {
ui.painter() ui.painter()
+97 -1
View File
@@ -114,15 +114,35 @@ pub fn amount_cell(
} else { } else {
theme::regular(14.0) theme::regular(14.0)
}; };
// An amount copies as its plain decimal: a click when the cell doesn't
// drill down, the right-click menu when it does.
let copy = crate::fmt::plain(text);
let (rect, resp) = ui.allocate_exact_size( let (rect, resp) = ui.allocate_exact_size(
vec2(width, 22.0), vec2(width, 22.0),
if drill { if drill || copy.is_some() {
Sense::click() Sense::click()
} else { } else {
Sense::hover() Sense::hover()
}, },
); );
let p = pal(ui.ctx()); let p = pal(ui.ctx());
let copy_id = resp.id.with("copy");
if let Some(v) = &copy {
if drill {
resp.context_menu(|ui| copy_item(ui, "Copy amount", v));
} else if resp.clicked() {
copy_now(ui, copy_id, v);
}
}
let copied = copy.is_some() && !drill && recently_copied(ui, copy_id);
if copy.is_some() && !drill && resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
let resp = if copy.is_some() && !drill {
resp.on_hover_text(if copied { "Copied" } else { "Click to copy" })
} else {
resp
};
let hovered = drill && resp.hovered(); let hovered = drill && resp.hovered();
let galley = ui.painter().layout_no_wrap( let galley = ui.painter().layout_no_wrap(
text.to_string(), text.to_string(),
@@ -143,6 +163,15 @@ pub fn amount_cell(
Stroke::new(1.0, p.accent), Stroke::new(1.0, p.accent),
); );
} }
if copied {
ui.painter().text(
egui::pos2(pos.x - 4.0, rect.center().y),
egui::Align2::RIGHT_CENTER,
egui_phosphor::regular::CHECK,
egui::FontId::proportional(12.0),
p.positive,
);
}
ui.painter().galley(pos, galley, color); ui.painter().galley(pos, galley, color);
resp resp
} }
@@ -223,6 +252,73 @@ pub fn empty(ui: &mut Ui, icon: &str, title: &str, text: &str) {
}); });
} }
/// Put `value` on the clipboard, remembering when under `id` (for the
/// brief "Copied" check).
pub fn copy_now(ui: &Ui, id: egui::Id, value: &str) {
ui.ctx().copy_text(value.to_string());
let now = ui.input(|i| i.time);
ui.ctx().data_mut(|d| d.insert_temp(id, now));
}
/// Whether `id` was copied in the last moment; keeps repainting until the
/// check fades.
pub fn recently_copied(ui: &Ui, id: egui::Id) -> bool {
let at: Option<f64> = ui.ctx().data(|d| d.get_temp(id));
let now = ui.input(|i| i.time);
let recent = at.is_some_and(|t| now - t < 1.2);
if recent {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(200));
}
recent
}
/// A value that copies itself when clicked: a pointer and a copy icon on
/// hover, a check for a moment after.
pub fn copyable(ui: &mut Ui, text: RichText, value: &str) -> Response {
let id = ui.id().with(("copy", value));
let p = pal(ui.ctx());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let r = ui.add(egui::Label::new(text).sense(Sense::click()));
let recent = recently_copied(ui, id);
let (slot, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
if r.hovered() || recent {
ui.painter().text(
slot.center(),
egui::Align2::CENTER_CENTER,
if recent {
egui_phosphor::regular::CHECK
} else {
egui_phosphor::regular::COPY
},
egui::FontId::proportional(12.0),
if recent { p.positive } else { p.muted },
);
}
if r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
let r = r.on_hover_text(if recent { "Copied" } else { "Click to copy" });
if r.clicked() {
copy_now(ui, id, value);
}
r
})
.inner
}
/// A "Copy …" item for a right-click menu.
pub fn copy_item(ui: &mut Ui, label: &str, value: &str) {
if ui
.button(format!("{} {label}", egui_phosphor::regular::COPY))
.clicked()
{
ui.ctx().copy_text(value.to_string());
ui.close();
}
}
/// "Copy prompt": puts an agent prompt on the clipboard. /// "Copy prompt": puts an agent prompt on the clipboard.
pub fn copy_prompt(ui: &mut Ui, prompt: &str) { pub fn copy_prompt(ui: &mut Ui, prompt: &str) {
let p = pal(ui.ctx()); let p = pal(ui.ctx());
+106
View File
@@ -0,0 +1,106 @@
//! Click to copy: values leave the viewer exactly.
//!
//! ```text
//! GOAL: what people retype into forms (the EIN, an amount, an entry
//! number) can be copied instead, and copies exactly.
//! SOURCE: DECISION (Stephen, 2026-10-14: click-to-copy on the company
//! page, amounts, and identifiers).
//! INVARIANTS:
//! 1. An amount copies as its plain decimal: fmt::plain of money,
//! accounting, or dollars text is the exact value, with "-" for a
//! negative and no "$", commas, or parentheses.
//! 2. Clicking a company value or an entry's amount puts exactly that
//! value on the clipboard.
//! ```
use egui::OutputCommand;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use ledger_gui::nav::Route;
use ledger_gui::{App, Source, data, fmt};
use proptest::prelude::*;
use rust_decimal::Decimal;
proptest! {
/// INVARIANT 1: every display style copies back to the same value.
#[test]
fn an_amount_copies_as_its_exact_value(
mantissa in -10i128.pow(16)..10i128.pow(16),
scale in 0u32..8,
) {
let d = Decimal::from_i128_with_scale(mantissa, scale);
for shown in [fmt::money(d), fmt::accounting(d), fmt::dollars(d)] {
let plain = fmt::plain(&shown).expect("an amount");
prop_assert!(!plain.contains(['$', ',', '(', ')', '\u{2212}']), "{}", plain);
prop_assert_eq!(plain.parse::<Decimal>().unwrap(), d, "{} copied as {}", shown, plain);
}
}
}
fn copied(h: &Harness<'_, App>) -> Vec<String> {
h.output()
.platform_output
.commands
.iter()
.filter_map(|c| match c {
OutputCommand::CopyText(t) => Some(t.clone()),
_ => None,
})
.collect()
}
/// INVARIANT 2, on the demo books.
#[test]
fn clicking_a_value_copies_it() {
let dir = std::env::temp_dir().join(format!("ledger-gui-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
ledger_gui::demo::build(&dir, today).unwrap();
let source = Source {
data_dir: dir.join("books-data"),
books_toml: Some(dir.join("wanderbooks.toml")),
today: Some(today),
};
let open = |route: Route| {
let books = data::load_once(&source).unwrap();
let mut h = Harness::builder()
.with_size(egui::vec2(1280.0, 900.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route));
h.run_steps(3);
h
};
// The company's EIN, as written in wanderbooks.toml.
let ein = data::load_once(&source)
.unwrap()
.settings
.company
.profile
.iter()
.find(|(k, _)| k == "ein")
.map(|(_, v)| v.clone())
.expect("the demo has an EIN");
let mut h = open(Route::Company);
h.get_by_label(&ein).click();
h.step();
assert_eq!(copied(&h), [ein]);
// An entry's amount, as a plain decimal.
let books = data::load_once(&source).unwrap();
let e = books.entries.iter().find(|e| !e.amount.is_zero()).unwrap();
let (id, amount) = (e.id, e.amount);
drop(books);
let mut h = open(Route::Entry(id));
let shown = fmt::dollars(amount);
h.get_by_label(&shown).click();
h.step();
assert_eq!(copied(&h), [fmt::plain(&shown).unwrap()]);
assert_eq!(copied(&h)[0].parse::<Decimal>().unwrap(), amount);
// The entry number.
let mut h = open(Route::Entry(id));
h.get_by_label(&format!("Entry #{id}")).click();
h.step();
assert_eq!(copied(&h), [format!("#{id}")]);
let _ = std::fs::remove_dir_all(&dir);
}