viewer: click to copy values, amounts, and identifiers
check / check (push) Canceled after 0s

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

+25 -2
View File
@@ -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);
}
+2 -2
View File
@@ -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);
});
}
+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);
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 {
+8 -2
View File
@@ -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");
+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, 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) = &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 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);
}
@@ -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 {
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()