Narrow windows overlapped and clipped: the top bar's crumb, date, and search ran into each other; big numbers wrapped mid-figure; tables painted columns over each other; the entry and document panes squeezed their lists to slivers; anything too wide widened the whole page. - Below 1000 points the sidebar becomes an icon rail (names, attention, and integrity checks in tooltips). - The top bar lays out right to left: search shrinks to its icon, the date drops, the crumb truncates. - Pages are pinned to the window width with narrower margins when tight, so one wide thing can't push everything off screen. - Tiles (KPIs, cash accounts, documents) fit as many columns as the width allows, balanced across rows; KPI figures shrink rather than wrap. - Painted tables shrink their amount columns, clip labels with an ellipsis, then scroll sideways rather than overlap. - List/detail splits show only the detail when both won't fit. - Reports: the side list becomes a picker; controls wrap by known width. - Smaller fixes: chart labels thin out, company facts stack, journal timestamps yield, transaction filters become a dropdown. - Min window size drops to 360x400; the screenshot test covers every report, a register, a document, and a journal event. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
387 lines
12 KiB
Rust
387 lines
12 KiB
Rust
//! Small building blocks shared by every screen.
|
|
|
|
use crate::theme::{self, pal};
|
|
use egui::{
|
|
Align, Color32, CornerRadius, Frame, Layout, Margin, Response, RichText, Sense, Stroke, Ui,
|
|
vec2,
|
|
};
|
|
|
|
/// A white (or dark) rounded surface with a hairline border.
|
|
pub fn card<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
|
|
let p = pal(ui.ctx());
|
|
Frame::new()
|
|
.fill(p.surface)
|
|
.stroke(Stroke::new(1.0, p.border))
|
|
.corner_radius(CornerRadius::same(12))
|
|
.inner_margin(Margin::same(16))
|
|
.show(ui, |ui| {
|
|
ui.set_width(ui.available_width());
|
|
add(ui)
|
|
})
|
|
.inner
|
|
}
|
|
|
|
/// A card with a title row and a right-aligned extra (a link, a pill).
|
|
/// Returns what `extra` and `add` return.
|
|
pub fn titled_card<E, R>(
|
|
ui: &mut Ui,
|
|
title: &str,
|
|
extra: impl FnOnce(&mut Ui) -> E,
|
|
add: impl FnOnce(&mut Ui) -> R,
|
|
) -> (E, R) {
|
|
card(ui, |ui| {
|
|
let e = ui
|
|
.horizontal(|ui| {
|
|
ui.label(RichText::new(title).font(theme::semibold(14.0)));
|
|
ui.with_layout(Layout::right_to_left(Align::Center), extra)
|
|
.inner
|
|
})
|
|
.inner;
|
|
ui.add_space(8.0);
|
|
(e, add(ui))
|
|
})
|
|
}
|
|
|
|
/// Page title and the sentence under it.
|
|
pub fn page_header(ui: &mut Ui, title: &str, subtitle: &str) {
|
|
let p = pal(ui.ctx());
|
|
ui.label(RichText::new(title).font(theme::bold(26.0)));
|
|
if !subtitle.is_empty() {
|
|
ui.add_space(2.0);
|
|
let w = ui.available_width().min(860.0);
|
|
ui.allocate_ui(vec2(w, 0.0), |ui| {
|
|
ui.set_max_width(w);
|
|
ui.label(
|
|
RichText::new(subtitle)
|
|
.font(theme::regular(15.0))
|
|
.color(p.muted),
|
|
);
|
|
});
|
|
}
|
|
ui.add_space(16.0);
|
|
}
|
|
|
|
/// A rounded tag.
|
|
pub fn pill(ui: &mut Ui, text: &str, fg: Color32, bg: Color32) -> Response {
|
|
Frame::new()
|
|
.fill(bg)
|
|
.corner_radius(CornerRadius::same(255))
|
|
.inner_margin(Margin::symmetric(8, 2))
|
|
.show(ui, |ui| {
|
|
ui.label(RichText::new(text).font(theme::semibold(11.5)).color(fg))
|
|
})
|
|
.response
|
|
}
|
|
|
|
/// Text that looks like plain text until you hover it, then like a link.
|
|
pub fn link(ui: &mut Ui, text: RichText) -> Response {
|
|
link_label(ui, egui::Label::new(text))
|
|
}
|
|
|
|
/// [`link`] on one line, cut with an ellipsis where the space ends.
|
|
pub fn link_truncated(ui: &mut Ui, text: RichText) -> Response {
|
|
link_label(ui, egui::Label::new(text).truncate())
|
|
}
|
|
|
|
fn link_label(ui: &mut Ui, label: egui::Label) -> Response {
|
|
let p = pal(ui.ctx());
|
|
let r = ui.add(label.sense(Sense::click()));
|
|
if r.hovered() {
|
|
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
|
let rect = r.rect;
|
|
ui.painter().line_segment(
|
|
[
|
|
rect.left_bottom() + vec2(0.0, 1.0),
|
|
rect.right_bottom() + vec2(0.0, 1.0),
|
|
],
|
|
Stroke::new(1.0, p.accent),
|
|
);
|
|
}
|
|
r
|
|
}
|
|
|
|
/// An amount, right-aligned in a fixed-width cell, clickable when `drill`.
|
|
pub fn amount_cell(
|
|
ui: &mut Ui,
|
|
text: &str,
|
|
width: f32,
|
|
color: Color32,
|
|
strong: bool,
|
|
drill: bool,
|
|
) -> Response {
|
|
let font = if strong {
|
|
theme::semibold(14.0)
|
|
} else {
|
|
theme::regular(14.0)
|
|
};
|
|
let (rect, resp) = ui.allocate_exact_size(
|
|
vec2(width, 22.0),
|
|
if drill {
|
|
Sense::click()
|
|
} else {
|
|
Sense::hover()
|
|
},
|
|
);
|
|
let p = pal(ui.ctx());
|
|
let hovered = drill && resp.hovered();
|
|
let galley = ui.painter().layout_no_wrap(
|
|
text.to_string(),
|
|
font,
|
|
if hovered { p.accent } else { color },
|
|
);
|
|
let pos = egui::pos2(
|
|
rect.right() - galley.size().x,
|
|
rect.center().y - galley.size().y / 2.0,
|
|
);
|
|
if hovered {
|
|
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
|
ui.painter().line_segment(
|
|
[
|
|
egui::pos2(pos.x, rect.center().y + galley.size().y / 2.0),
|
|
egui::pos2(rect.right(), rect.center().y + galley.size().y / 2.0),
|
|
],
|
|
Stroke::new(1.0, p.accent),
|
|
);
|
|
}
|
|
ui.painter().galley(pos, galley, color);
|
|
resp
|
|
}
|
|
|
|
/// A big number with a label, a change line, and an optional sparkline.
|
|
pub struct Kpi<'a> {
|
|
pub label: &'a str,
|
|
pub value: String,
|
|
pub sub: Option<(String, Color32)>,
|
|
pub spark: Option<&'a [f64]>,
|
|
pub how: &'a str,
|
|
}
|
|
|
|
pub fn kpi(ui: &mut Ui, k: Kpi<'_>) -> Response {
|
|
let p = pal(ui.ctx());
|
|
let resp = Frame::new()
|
|
.fill(p.surface)
|
|
.stroke(Stroke::new(1.0, p.border))
|
|
.corner_radius(CornerRadius::same(12))
|
|
.inner_margin(Margin::same(14))
|
|
.show(ui, |ui| {
|
|
ui.set_width(ui.available_width());
|
|
ui.set_min_height(104.0);
|
|
ui.label(
|
|
RichText::new(k.label)
|
|
.font(theme::regular(12.5))
|
|
.color(p.muted),
|
|
);
|
|
ui.add_space(4.0);
|
|
let font = fit_font(
|
|
ui,
|
|
&k.value,
|
|
theme::semibold,
|
|
24.0,
|
|
15.0,
|
|
ui.available_width(),
|
|
);
|
|
ui.add(egui::Label::new(RichText::new(&k.value).font(font)).truncate());
|
|
if let Some((text, color)) = &k.sub {
|
|
ui.add_space(2.0);
|
|
ui.label(RichText::new(text).font(theme::regular(12.0)).color(*color));
|
|
}
|
|
if let Some(values) = k.spark
|
|
&& values.len() > 1
|
|
{
|
|
ui.add_space(6.0);
|
|
let (rect, _) =
|
|
ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover());
|
|
crate::charts::sparkline(ui.painter(), rect, values, p.accent);
|
|
}
|
|
})
|
|
.response;
|
|
if !k.how.is_empty() {
|
|
resp.on_hover_text(k.how)
|
|
} else {
|
|
resp
|
|
}
|
|
}
|
|
|
|
/// A friendly placeholder when there's nothing to show yet.
|
|
pub fn empty(ui: &mut Ui, icon: &str, title: &str, text: &str) {
|
|
let p = pal(ui.ctx());
|
|
ui.vertical_centered(|ui| {
|
|
ui.add_space(18.0);
|
|
ui.label(RichText::new(icon).size(28.0).color(p.faint));
|
|
ui.add_space(4.0);
|
|
ui.label(
|
|
RichText::new(title)
|
|
.font(theme::semibold(14.0))
|
|
.color(p.muted),
|
|
);
|
|
ui.label(
|
|
RichText::new(text)
|
|
.font(theme::regular(12.5))
|
|
.color(p.faint),
|
|
);
|
|
ui.add_space(18.0);
|
|
});
|
|
}
|
|
|
|
/// "Copy prompt": puts an agent prompt on the clipboard.
|
|
pub fn copy_prompt(ui: &mut Ui, prompt: &str) {
|
|
let p = pal(ui.ctx());
|
|
let id = ui.id().with(("copied", prompt));
|
|
let copied_at: Option<f64> = ui.ctx().data(|d| d.get_temp(id));
|
|
let now = ui.input(|i| i.time);
|
|
let recently = copied_at.is_some_and(|t| now - t < 1.5);
|
|
let label = if recently {
|
|
format!("{} Copied", egui_phosphor::regular::CHECK)
|
|
} else {
|
|
format!("{} Ask the agent", egui_phosphor::regular::SPARKLE)
|
|
};
|
|
let r = ui
|
|
.add(
|
|
egui::Button::new(
|
|
RichText::new(label)
|
|
.font(theme::regular(12.0))
|
|
.color(p.accent),
|
|
)
|
|
.fill(p.accent_soft)
|
|
.stroke(Stroke::NONE)
|
|
.corner_radius(CornerRadius::same(6)),
|
|
)
|
|
.on_hover_text(format!(
|
|
"Copies this prompt for your agent session:\n\n{prompt}"
|
|
));
|
|
if r.clicked() {
|
|
ui.ctx().copy_text(prompt.to_string());
|
|
ui.ctx().data_mut(|d| d.insert_temp(id, now));
|
|
}
|
|
if recently {
|
|
ui.ctx()
|
|
.request_repaint_after(std::time::Duration::from_millis(300));
|
|
}
|
|
}
|
|
|
|
/// A horizontal rule in the border color.
|
|
pub fn rule(ui: &mut Ui) {
|
|
let p = pal(ui.ctx());
|
|
let w = ui.available_width();
|
|
let (rect, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover());
|
|
ui.painter().rect_filled(rect, 0.0, p.border);
|
|
}
|
|
|
|
/// Selectable segmented control.
|
|
pub fn segmented<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, options: &[(T, &str)]) -> bool {
|
|
let p = pal(ui.ctx());
|
|
let mut changed = false;
|
|
Frame::new()
|
|
.fill(p.hover)
|
|
.corner_radius(CornerRadius::same(8))
|
|
.inner_margin(Margin::same(2))
|
|
.show(ui, |ui| {
|
|
ui.spacing_mut().item_spacing.x = 2.0;
|
|
ui.horizontal(|ui| {
|
|
for (v, label) in options {
|
|
let on = *value == *v;
|
|
let b = egui::Button::new(
|
|
RichText::new(*label)
|
|
.font(if on {
|
|
theme::semibold(12.5)
|
|
} else {
|
|
theme::regular(12.5)
|
|
})
|
|
.color(if on { p.text } else { p.muted }),
|
|
)
|
|
.fill(if on { p.surface } else { Color32::TRANSPARENT })
|
|
.stroke(if on {
|
|
Stroke::new(1.0, p.border)
|
|
} else {
|
|
Stroke::NONE
|
|
})
|
|
.corner_radius(CornerRadius::same(6))
|
|
.min_size(vec2(0.0, 24.0));
|
|
if ui.add(b).clicked() && !on {
|
|
*value = *v;
|
|
changed = true;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
changed
|
|
}
|
|
|
|
/// Left-aligned single-line text, vertically centered at `pos`, cut with
|
|
/// an ellipsis at `max_width` so it never runs into whatever is to its
|
|
/// right (an amount, a date).
|
|
pub fn clipped_text(
|
|
painter: &egui::Painter,
|
|
pos: egui::Pos2,
|
|
text: &str,
|
|
font: egui::FontId,
|
|
color: Color32,
|
|
max_width: f32,
|
|
) {
|
|
use egui::text::{LayoutJob, TextFormat, TextWrapping};
|
|
let mut job = LayoutJob::single_section(text.to_string(), TextFormat::simple(font, color));
|
|
job.wrap = TextWrapping {
|
|
max_width: max_width.max(10.0),
|
|
max_rows: 1,
|
|
break_anywhere: true,
|
|
overflow_character: Some('…'),
|
|
};
|
|
let galley = painter.layout_job(job);
|
|
let y = pos.y - galley.size().y / 2.0;
|
|
painter.galley(egui::pos2(pos.x, y), galley, color);
|
|
}
|
|
|
|
/// How many columns at least `min_width` wide (with `gap` between) fit
|
|
/// in `avail`, never fewer than one nor more than `max`.
|
|
pub fn fit_columns(avail: f32, min_width: f32, gap: f32, max: usize) -> usize {
|
|
(((avail + gap) / (min_width + gap)).floor() as usize).clamp(1, max.max(1))
|
|
}
|
|
|
|
/// The width of each of `n` right-aligned amount columns in a painted
|
|
/// table `width` wide, leaving at least `label_min` for the label on the
|
|
/// left: `max` when there's room, shrinking to a floor that still fits an
|
|
/// amount. Pair with [`min_width`] so a table that can't fit scrolls.
|
|
pub fn amount_col_w(width: f32, n: usize, label_min: f32, max: f32) -> f32 {
|
|
((width - label_min) / n.max(1) as f32).clamp(AMOUNT_MIN, max)
|
|
}
|
|
|
|
/// The narrowest an amount column gets ("(1,234,567.89)" at 13.5px).
|
|
pub const AMOUNT_MIN: f32 = 92.0;
|
|
|
|
/// Lay out `add` at least `width` wide: as is when there's room, in a
|
|
/// sideways scroll when the window is narrower, so a table never paints
|
|
/// over itself or past the edge.
|
|
pub fn min_width<R>(ui: &mut Ui, id: &str, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R {
|
|
if ui.available_width() >= width {
|
|
return add(ui);
|
|
}
|
|
egui::ScrollArea::horizontal()
|
|
.id_salt(id)
|
|
.show(ui, |ui| {
|
|
ui.set_width(width);
|
|
add(ui)
|
|
})
|
|
.inner
|
|
}
|
|
|
|
/// The largest size up to `size` at which `text` fits on one line in
|
|
/// `width` (down to `floor`): big numbers shrink rather than wrap.
|
|
pub fn fit_font(
|
|
ui: &Ui,
|
|
text: &str,
|
|
font: impl Fn(f32) -> egui::FontId,
|
|
size: f32,
|
|
floor: f32,
|
|
width: f32,
|
|
) -> egui::FontId {
|
|
let measured = ui
|
|
.painter()
|
|
.layout_no_wrap(text.to_string(), font(size), Color32::WHITE)
|
|
.size()
|
|
.x;
|
|
if measured <= width {
|
|
return font(size);
|
|
}
|
|
font((size * width / measured).floor().max(floor))
|
|
}
|