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:
1 parent
8ac4e5438d
commit
eba85f9c77
10 files changed
+344
-17
No files matched your search
@@ -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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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) = © {
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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) = © {
|
||||||
|
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());
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user