diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 3b02ebe..7034f8e 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -675,12 +675,23 @@ fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) { } else { (icon::WARNING_CIRCLE, p.negative) }; - ui.label( - RichText::new(format!("{glyph} {text}")) - .font(theme::regular(11.5)) - .color(color), - ) - .on_hover_text(tip); + let r = ui + .label( + RichText::new(format!("{glyph} {text}")) + .font(theme::regular(11.5)) + .color(color), + ) + .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(), + ); + }); + } } }); } diff --git a/crates/ledger-gui/src/fmt.rs b/crates/ledger-gui/src/fmt.rs index b50eae9..b478431 100644 --- a/crates/ledger-gui/src/fmt.rs +++ b/crates/ledger-gui/src/fmt.rs @@ -87,6 +87,31 @@ fn group(d: Decimal) -> String { 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 { + 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. pub fn pct_change(prev: Decimal, now: Decimal) -> Option { if prev.is_zero() { @@ -213,5 +238,12 @@ mod tests { assert_eq!(label("ein"), "EIN"); assert_eq!(label("irs_name_control"), "IRS name control"); 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); } } diff --git a/crates/ledger-gui/src/pages/accounts.rs b/crates/ledger-gui/src/pages/accounts.rs index 92ef8bf..ab9feb6 100644 --- a/crates/ledger-gui/src/pages/accounts.rs +++ b/crates/ledger-gui/src/pages/accounts.rs @@ -168,18 +168,22 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) { .range .unwrap_or((first.min(b.today), b.today)); page(ui, "account", |ui| { - ui.label( + widgets::copyable( + ui, RichText::new(format!("{:?} account · {}", a.account_type, a.code)) .font(theme::regular(13.0)) .color(p.muted), + &a.code, ); ui.label(RichText::new(&a.name).font(theme::bold(26.0))); ui.add_space(4.0); let bal = b.balance_on(id, b.today); - ui.label( + widgets::copyable( + ui, RichText::new(format!("Balance today {}", fmt::dollars(bal))) .font(theme::regular(15.0)) .color(p.muted), + &fmt::plain(&fmt::dollars(bal)).unwrap_or_default(), ); ui.add_space(14.0); 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 }), 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 { ui.painter().rect_filled(r, CornerRadius::same(6), p.hover); } diff --git a/crates/ledger-gui/src/pages/company.rs b/crates/ledger-gui/src/pages/company.rs index 368040b..e45f93b 100644 --- a/crates/ledger-gui/src/pages/company.rs +++ b/crates/ledger-gui/src/pages/company.rs @@ -20,7 +20,7 @@ fn kv(ui: &mut Ui, k: &str, v: &str) { // Narrow, the label goes above its value. if ui.available_width() < 360.0 { 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); return; } @@ -34,7 +34,7 @@ fn kv(ui: &mut Ui, k: &str, v: &str) { theme::regular(13.0), 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); }); } diff --git a/crates/ledger-gui/src/pages/documents.rs b/crates/ledger-gui/src/pages/documents.rs index bf22c97..707e02f 100644 --- a/crates/ledger-gui/src/pages/documents.rs +++ b/crates/ledger-gui/src/pages/documents.rs @@ -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); egui::Grid::new(("docfacts", a.id)) .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.end_row(); k(ui, "SHA-256"); - ui.add( - egui::Label::new(RichText::new(&a.blob_sha).monospace().size(11.5)).wrap(), + widgets::copyable( + ui, + RichText::new(&a.blob_sha).monospace().size(11.5), + &a.blob_sha, ); ui.end_row(); if let Some(e) = a.entry_id { diff --git a/crates/ledger-gui/src/pages/entry.rs b/crates/ledger-gui/src/pages/entry.rs index f74f8e0..18b04cf 100644 --- a/crates/ledger-gui/src/pages/entry.rs +++ b/crates/ledger-gui/src/pages/entry.rs @@ -24,10 +24,12 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) { .auto_shrink(false) .show(ui, |ui| { ui.horizontal(|ui| { - ui.label( + widgets::copyable( + ui, RichText::new(format!("Entry #{}", e.id)) .font(theme::regular(12.5)) .color(p.muted), + &format!("#{}", e.id), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |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.end_row(); 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(); if let Some(c) = &created { k(ui, "Recorded"); diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs index 8d038ba..3504b8d 100644 --- a/crates/ledger-gui/src/pages/reports.rs +++ b/crates/ledger-gui/src/pages/reports.rs @@ -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, rr.bottom()), ); + let copy = fmt::plain(&text); let cresp = ui.interact( cell, ui.id().with(("cell", ri, i)), - if c.drill.is_some() { + if c.drill.is_some() || copy.is_some() { Sense::click() } else { 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) = © { + 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 vcolor = if hovered { p.accent } else { color }; 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| -> egui::Response { 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 { ui.painter().rect_filled(r, CornerRadius::same(4), p.hover); } diff --git a/crates/ledger-gui/src/pages/transactions.rs b/crates/ledger-gui/src/pages/transactions.rs index b6d34de..4d2d1a6 100644 --- a/crates/ledger-gui/src/pages/transactions.rs +++ b/crates/ledger-gui/src/pages/transactions.rs @@ -134,6 +134,22 @@ fn list(ui: &mut Ui, b: &Books, selected: Option, shell: &mut Shell<'_>) { fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> egui::Response { let p = pal(ui.ctx()); 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; if on { ui.painter() diff --git a/crates/ledger-gui/src/widgets.rs b/crates/ledger-gui/src/widgets.rs index e3d211e..39c2297 100644 --- a/crates/ledger-gui/src/widgets.rs +++ b/crates/ledger-gui/src/widgets.rs @@ -114,15 +114,35 @@ pub fn amount_cell( } else { 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( vec2(width, 22.0), - if drill { + if drill || copy.is_some() { Sense::click() } else { Sense::hover() }, ); let p = pal(ui.ctx()); + let copy_id = resp.id.with("copy"); + if let Some(v) = © { + 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 galley = ui.painter().layout_no_wrap( text.to_string(), @@ -143,6 +163,15 @@ pub fn amount_cell( 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); 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 = 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. pub fn copy_prompt(ui: &mut Ui, prompt: &str) { let p = pal(ui.ctx()); diff --git a/crates/ledger-gui/tests/copy.rs b/crates/ledger-gui/tests/copy.rs new file mode 100644 index 0000000..62bce6a --- /dev/null +++ b/crates/ledger-gui/tests/copy.rs @@ -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::().unwrap(), d, "{} copied as {}", shown, plain); + } + } +} + +fn copied(h: &Harness<'_, App>) -> Vec { + 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::().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); +}