pdfcraft/crates/ui-egui/src/widgets.rs

372 lines
20 KiB
Rust

//! Small custom widgets built on the design tokens.
use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, vec2};
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, icons};
/// A mode-bar tab: text with an underline when active.
pub fn mode_tab(ui: &mut egui::Ui, label: &str, active: bool) -> Response {
let t = Tokens::get(ui.ctx());
let font = if active { theme::semibold(13.5) } else { theme::medium(13.5) };
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
let (rect, resp) = ui.allocate_exact_size(vec2(w + 22.0, 48.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), active, label));
if resp.hovered() && !active {
ui.painter().rect_filled(rect.shrink2(vec2(2.0, 9.0)), CornerRadius::same(6), t.hover);
}
ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, font, if active { t.text } else { t.text_muted });
if active {
let r = Rect::from_min_max(rect.left_bottom() + vec2(11.0, -3.0), rect.right_bottom() - vec2(11.0, 0.0));
ui.painter().rect_filled(r, CornerRadius::same(1), t.text);
}
resp
}
/// Rounded pill button; `primary` fills with the accent.
pub fn pill_button(ui: &mut egui::Ui, label: &str, primary: bool) -> Response {
let t = Tokens::get(ui.ctx());
let font = theme::medium(12.5);
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
let (rect, resp) = ui.allocate_exact_size(vec2(w + 26.0, 28.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
let (fill, stroke, text) = if primary {
(if resp.hovered() { t.accent_text } else { t.accent }, Stroke::NONE, Color32::WHITE)
} else {
(if resp.hovered() { t.hover } else { t.card }, Stroke::new(1.2, t.text_muted), t.text)
};
ui.painter().rect(rect, CornerRadius::same(14), fill, stroke, egui::StrokeKind::Inside);
ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, font, text);
resp
}
/// Icon + label, transparent until hovered.
pub fn ghost_button(ui: &mut egui::Ui, icon: &str, label: &str) -> Response {
let t = Tokens::get(ui.ctx());
let font = theme::medium(13.0);
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
let (rect, resp) = ui.allocate_exact_size(vec2(w + 38.0, 30.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
if resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover);
}
icons::paint(ui, Rect::from_min_size(rect.min + vec2(6.0, 6.0), vec2(18.0, 18.0)), icon, 17.0, t.icon);
ui.painter().text(rect.left_center() + vec2(30.0, 0.0), Align2::LEFT_CENTER, label, font, t.text);
resp
}
/// A search-field lookalike that opens the command palette.
pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(width, 32.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), placeholder));
let fill = if resp.hovered() { t.hover } else { t.field };
ui.painter().rect(rect, CornerRadius::same(16), fill, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
icons::paint(ui, Rect::from_min_size(rect.min + vec2(10.0, 8.0), vec2(16.0, 16.0)), "search", 15.0, t.text_muted);
let shortcut = ui.ctx().format_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, egui::Key::K));
let shortcut = ui.painter().layout_no_wrap(shortcut, theme::regular(11.5), t.text_faint);
let shortcut_pos = rect.right_center() - vec2(12.0 + shortcut.size().x, shortcut.size().y * 0.5);
// Reserve the measured shortcut width in every language; long translations are elided
// within the remaining space rather than painted underneath the shortcut.
let text_right = (shortcut_pos.x - 10.0).max(rect.left());
let text_left = (rect.left() + 34.0).min(text_right);
let text_rect = Rect::from_min_max(egui::pos2(text_left, rect.top()), egui::pos2(text_right, rect.bottom()));
let mut job = egui::text::LayoutJob::simple_singleline(placeholder.to_owned(), theme::regular(13.0), t.text_faint);
job.wrap = egui::text::TextWrapping { max_width: text_rect.width(), max_rows: 1, break_anywhere: true, ..Default::default() };
let text = ui.fonts_mut(|f| f.layout_job(job));
let elided = text.elided;
ui.painter().with_clip_rect(text_rect).galley(egui::pos2(text_left, rect.center().y - text.size().y * 0.5), text, t.text_faint);
ui.painter().with_clip_rect(rect).galley(shortcut_pos, shortcut, t.text_faint);
let resp = resp.on_hover_cursor(egui::CursorIcon::Text);
if elided { resp.on_hover_text(placeholder) } else { resp }
}
pub fn menu_item(ui: &mut egui::Ui, label: &str, shortcut: &str) -> Response {
ui.add(egui::Button::new(label).shortcut_text(shortcut))
}
pub fn section_title(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.add_space(10.0);
// Section titles across every panel go through here, so one translation point covers them.
ui.label(egui::RichText::new(tl!(text).to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6));
ui.add_space(2.0);
}
/// Paint one line of `text` left-aligned and vertically centred on `pos`, cut with "…" so it is
/// at most `max_width` wide: a long file name or path stays inside its row (#425).
pub fn row_text(ui: &egui::Ui, pos: egui::Pos2, text: impl Into<String>, font: egui::FontId, color: Color32, max_width: f32) {
let mut job = egui::text::LayoutJob::simple_singleline(text.into(), font, color);
job.wrap = egui::text::TextWrapping::truncate_at_width(max_width.max(0.0));
let galley = ui.painter().layout_job(job);
let rect = Align2::LEFT_CENTER.anchor_size(pos, galley.size());
ui.painter().galley(rect.min, galley, color);
}
/// Transient message at the bottom centre.
pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) {
let Some((msg, start)) = app.toast.clone() else { return };
let now = ctx.input(|i| i.time);
let start = if start == 0.0 { now } else { start };
app.toast = Some((msg.clone(), start));
if now - start > 3.5 {
app.toast = None;
return;
}
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
// Above the progress notice while a job runs.
let card = if app.progress_notice.is_some() { ctx.data(|d| d.get_temp::<f32>(egui::Id::new(PROGRESS_HEIGHT))).unwrap_or(0.0) } else { 0.0 };
let lift = if card > 0.0 { 28.0 + card + 10.0 } else { 28.0 };
egui::Area::new(egui::Id::new("toast"))
.order(egui::Order::Tooltip)
.pivot(Align2::CENTER_BOTTOM)
.fixed_pos(screen.center_bottom() - vec2(0.0, lift))
.show(ctx, |ui| {
egui::Frame::NONE
.fill(if t.dark() { Color32::from_rgb(0xEC, 0xEC, 0xEF) } else { Color32::from_rgb(0x2A, 0x2A, 0x2F) })
.corner_radius(CornerRadius::same(8))
.inner_margin(egui::Margin::symmetric(16, 10))
.show(ui, |ui| {
// An Area remembers its previous content width. A short notice
// must not force the next validation message into a narrow column.
// Allow natural short labels; wrap longer ones within the viewport,
// including the frame's 32 points and 16-point outside margins.
ui.set_max_width((screen.width() - 64.0).clamp(1.0, 560.0));
ui.add(
egui::Label::new(egui::RichText::new(msg).color(if t.dark() { Color32::from_rgb(0x22, 0x22, 0x26) } else { Color32::WHITE }))
.wrap(),
);
});
});
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
/// A background job's progress, shown at the bottom centre until the job is done.
#[derive(Clone, Debug, PartialEq)]
pub struct ProgressNotice {
/// What is happening ("Optimizing… image 3 of 40").
pub label: String,
/// From 0 to 1.
pub fraction: f32,
/// Show a Cancel button.
pub cancellable: bool,
}
/// Where the progress notice keeps its height this frame, for placing the toast above it.
const PROGRESS_HEIGHT: &str = "progress-notice-height";
/// Draw [`PdfCraftApp::progress_notice`]: the label, a bar that eases towards the fraction with
/// a light sweeping across it, the percentage and Cancel. Returns true when Cancel was clicked.
pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool {
let Some(p) = &app.progress_notice else { return false };
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
// The same inverted card as the toast.
let (fill, text) = if t.dark() {
(Color32::from_rgb(0xEC, 0xEC, 0xEF), Color32::from_rgb(0x22, 0x22, 0x26))
} else {
(Color32::from_rgb(0x2A, 0x2A, 0x2F), Color32::WHITE)
};
let fraction = if p.fraction.is_finite() { p.fraction.clamp(0.0, 1.0) } else { 0.0 };
let shown = ctx.animate_value_with_time(egui::Id::new("progress-notice-bar"), fraction, 0.3);
let now = ctx.input(|i| i.time);
let mut cancel = false;
let area = egui::Area::new(egui::Id::new("progress-notice"))
.order(egui::Order::Tooltip)
.pivot(Align2::CENTER_BOTTOM)
.fixed_pos(screen.center_bottom() - vec2(0.0, 28.0))
.show(ctx, |ui| {
egui::Frame::NONE.fill(fill).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::symmetric(16, 12)).show(ui, |ui| {
let width = 340.0_f32.min(screen.width() - 64.0).max(160.0);
ui.set_width(width);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&p.label).color(text));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(format!("{:.0}%", shown * 100.0)).font(theme::medium(12.0)).color(text.gamma_multiply(0.7)));
});
});
ui.add_space(6.0);
let (track, resp) = ui.allocate_exact_size(vec2(width, 6.0), Sense::hover());
// The label above names the job; the bar carries the percentage.
resp.widget_info(|| {
let mut info = egui::WidgetInfo::new(egui::WidgetType::ProgressIndicator);
info.value = Some(f64::from(fraction) * 100.0);
info
});
let painter = ui.painter();
painter.rect_filled(track, CornerRadius::same(3), text.gamma_multiply(0.18));
let done = Rect::from_min_size(track.min, vec2(track.width() * shown, track.height()));
if done.width() > 0.5 {
painter.rect_filled(done, CornerRadius::same(3), t.accent);
// A soft light sweeping along the filled part says the job is alive between
// updates.
let band = 48.0;
let x = done.left() - band + ((now * 160.0) % f64::from(done.width() + band)) as f32;
let clip = painter.with_clip_rect(done);
for (i, a) in [0.10_f32, 0.22, 0.10].into_iter().enumerate() {
let r = Rect::from_min_size(egui::pos2(x + i as f32 * band / 3.0, done.top()), vec2(band / 3.0, done.height()));
clip.rect_filled(r, CornerRadius::ZERO, Color32::WHITE.gamma_multiply(a));
}
}
if p.cancellable {
ui.add_space(4.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let b = egui::Button::new(egui::RichText::new(tl!("Cancel")).font(theme::medium(12.5)).color(text)).frame(false);
cancel = ui.add(b).clicked();
});
}
});
});
ctx.data_mut(|d| d.insert_temp(egui::Id::new(PROGRESS_HEIGHT), area.response.rect.height()));
ctx.request_repaint_after(std::time::Duration::from_millis(33));
cancel
}
/// The ArtCraft wordmark (Storyteller's brand, docs/brand/; not open source), sized to `height`.
pub fn artcraft_logo(ui: &mut egui::Ui, height: f32) -> Response {
let dark = ui.visuals().dark_mode;
let (uri, bytes): (&str, &'static [u8]) = if dark {
("bytes://artcraft-logo-white.svg", include_bytes!("../../../docs/brand/artcraft-logo-white.svg"))
} else {
("bytes://artcraft-logo.svg", include_bytes!("../../../docs/brand/artcraft-logo.svg"))
};
ui.add(egui::Image::from_bytes(uri, bytes).max_height(height).alt_text("ArtCraft"))
}
/// The ArtCraft mark (brand blue, works on light and dark), `size` points square.
pub fn artcraft_mark(ui: &mut egui::Ui, size: f32) -> Response {
ui.add(
egui::Image::from_bytes("bytes://artcraft-mark.svg", include_bytes!("../../../docs/brand/artcraft-mark.svg"))
.fit_to_exact_size(vec2(size, size))
.alt_text("ArtCraft"),
)
}
/// Buttons for every community link (`pdfcraft_engine::links`), Discord first and prominent.
/// Returns the registry command of the one clicked.
pub fn community_links(ui: &mut egui::Ui) -> Option<&'static str> {
let mut clicked = None;
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
for (i, l) in pdfcraft_engine::links::LINKS.iter().enumerate() {
let resp = if i == 0 { icon_pill(ui, l.icon, tl!("Join our Discord"), true) } else { icon_pill(ui, l.icon, tl!(l.label), false) };
if resp.on_hover_text(l.url).clicked() {
clicked = Some(l.command);
}
}
});
clicked
}
/// [`icon_pill`] with a ▾ part at its end that opens a menu of related choices. Returns the
/// main button's response and the ▾'s (give it to `egui::Popup::menu`).
pub fn split_pill(ui: &mut egui::Ui, icon: &str, label: &str, more: &str) -> (Response, Response) {
let t = Tokens::get(ui.ctx());
let font = theme::medium(12.5);
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
let (rect, _) = ui.allocate_exact_size(vec2(w + 46.0 + 28.0, 30.0), Sense::hover());
let (main_rect, more_rect) = rect.split_left_right_at_x(rect.right() - 28.0);
let main = ui.interact(main_rect, ui.id().with(("split-pill", label)), Sense::click());
let arrow = ui.interact(more_rect, ui.id().with(("split-pill-more", label)), Sense::click());
main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), more));
let radius = CornerRadius::same(15);
ui.painter().rect(rect, radius, t.card, Stroke::new(1.2, t.text_muted), egui::StrokeKind::Inside);
let left = CornerRadius { nw: 15, sw: 15, ne: 0, se: 0 };
let right = CornerRadius { nw: 0, sw: 0, ne: 15, se: 15 };
for (r, resp, corners) in [(main_rect, &main, left), (more_rect, &arrow, right)] {
if resp.hovered() {
ui.painter().rect_filled(r.shrink(1.2), corners, t.hover);
}
}
ui.painter().vline(more_rect.left(), rect.y_range().shrink(7.0), Stroke::new(1.0, t.text_muted));
crate::icons::paint(ui, Rect::from_min_size(rect.min + vec2(12.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, t.text);
ui.painter().text(rect.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, label, font, t.text);
crate::icons::paint(ui, Rect::from_center_size(more_rect.center(), vec2(14.0, 14.0)), "chevron-down", 13.0, t.text);
(main.on_hover_text(label), arrow.on_hover_text(more))
}
/// A pill button with an icon (primary = filled accent).
pub fn icon_pill(ui: &mut egui::Ui, icon: &str, label: &str, primary: bool) -> Response {
let t = Tokens::get(ui.ctx());
let font = theme::medium(12.5);
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
let (rect, resp) = ui.allocate_exact_size(vec2(w + 46.0, 30.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
let (fill, stroke, text) = if primary {
(if resp.hovered() { t.accent_text } else { t.accent }, Stroke::NONE, Color32::WHITE)
} else {
(if resp.hovered() { t.hover } else { t.card }, Stroke::new(1.2, t.text_muted), t.text)
};
ui.painter().rect(rect, CornerRadius::same(15), fill, stroke, egui::StrokeKind::Inside);
crate::icons::paint(ui, Rect::from_min_size(rect.min + vec2(12.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, text);
ui.painter().text(rect.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, label, font, text);
resp
}
#[cfg(test)]
mod tests {
use super::*;
use egui::Pos2;
#[test]
fn search_placeholder_never_overlaps_the_shortcut() {
for width in [260.0, 160.0, 90.0] {
let ctx = egui::Context::default();
ctx.set_fonts(theme::font_definitions());
let placeholder = "Werkzeuge und Befehle suchen";
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
search_box(ui, placeholder, width);
});
let text_shapes: Vec<_> = output
.shapes
.iter()
.filter_map(|shape| if let egui::Shape::Text(text) = &shape.shape { Some((shape.clip_rect, text)) } else { None })
.collect();
let (clip, hint) = text_shapes.iter().find(|(_, text)| text.galley.job.text == placeholder).unwrap();
let (_, shortcut) = text_shapes.iter().find(|(_, text)| text.galley.job.text.contains('K')).unwrap();
assert!(clip.right() <= shortcut.pos.x - 9.0, "hint must leave a gap before the shortcut at width {width}");
assert!(hint.galley.elided, "the long original German hint must be elided at width {width}");
assert_eq!(hint.galley.rows.len(), 1);
output.drop_without_applying_deltas();
}
}
fn toast_rect(app: &mut PdfCraftApp, ctx: &egui::Context, width: f32, now: &mut f64) -> Rect {
// Let the Area settle after a notice/viewport change, without advancing to
// expiry. No native rendering or renderer worker is needed for this layout.
for _ in 0..3 {
*now += 0.016;
let output = ctx.run_ui(
egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(width, 900.0))), time: Some(*now), ..Default::default() },
|ui| toast(app, ui.ctx()),
);
output.drop_without_applying_deltas();
}
ctx.memory(|memory| memory.area_rect(egui::Id::new("toast")).unwrap())
}
#[test]
fn toast_width_adapts_to_long_notices_and_narrow_viewports() {
let ctx = egui::Context::default();
let mut app = PdfCraftApp::new();
let mut now = 1.0;
app.notify("Saved");
let short = toast_rect(&mut app, &ctx, 1280.0, &mut now);
assert!(short.width() < 160.0, "short notices retain their natural width: {short:?}");
app.notify("Fill in name failed: The value entered is not valid for the field [ name ]");
let long = toast_rect(&mut app, &ctx, 1280.0, &mut now);
assert!(long.width() > 300.0, "a previous short Area must not squeeze validation feedback: {long:?}");
assert!(long.height() < 85.0, "ordinary feedback must not become a seven-line column: {long:?}");
for width in [320.0, 180.0] {
let rect = toast_rect(&mut app, &ctx, width, &mut now);
assert!(rect.width() <= width - 32.0 + 1.0, "frame and viewport margins must fit: {rect:?}");
assert!(rect.left() >= 15.0 && rect.right() <= width - 15.0, "notice remains horizontally on screen: {rect:?}");
}
app.notify("Saved");
let again = toast_rect(&mut app, &ctx, 1280.0, &mut now);
assert!((again.width() - short.width()).abs() < 1.0, "long notices must not impose a permanent minimum width");
}
}