Merge remote-tracking branch 'origin/main' into reland-c

This commit is contained in:
Brandon Thomas 2026-10-10 16:07:08 -04:00
commit cd9faa677b
2 changed files with 110 additions and 11 deletions

View File

@ -52,13 +52,7 @@ fn header(ui: &mut Ui, title: &str) -> bool {
ui.label(egui::RichText::new(tl!(title)).font(semibold(15.0)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Drawn, not the "✕" character: the interface fonts have no glyph for it.
let t = crate::theme::Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 4.0, t.hover);
}
crate::icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), egui::vec2(14.0, 14.0)), "close", t.icon, t.accent);
if resp.on_hover_text(tl!("Close")).clicked() {
if crate::widgets::icon_button(ui, "close", tl!("Close")).clicked() {
close = true;
}
});

View File

@ -390,17 +390,68 @@ pub fn combo_list(ui: &mut Ui, width: f32, current: &str, items: &[String], prev
out
}
/// How a drawn button looks this frame. Hover fades in over egui's animation time and the
/// pressed state blends in while the pointer is down, as on egui's own buttons. Framed buttons
/// take egui's widget visuals (fill, stroke, text colour, expansion) so they match `ui.button`;
/// frameless ones take the ribbon's hover/pressed tokens, like [`small`] and [`big`].
pub struct DrawnState {
pub fill: Color32,
pub stroke: Stroke,
pub text: Color32,
pub expansion: f32,
}
pub fn drawn_state(ui: &Ui, resp: &Response, framed: bool) -> DrawnState {
let pressed = resp.is_pointer_button_down_on();
let h = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() || pressed);
let p = ui.ctx().animate_bool_with_time(resp.id.with("press"), pressed, 0.06);
if framed {
let w = &ui.visuals().widgets;
let mix = |a: Color32, b: Color32, c: Color32| a.lerp_to_gamma(b, h).lerp_to_gamma(c, p);
let num = |a: f32, b: f32, c: f32| egui::lerp(egui::lerp(a..=b, h)..=c, p);
DrawnState {
fill: mix(w.inactive.weak_bg_fill, w.hovered.weak_bg_fill, w.active.weak_bg_fill),
stroke: Stroke::new(
num(w.inactive.bg_stroke.width, w.hovered.bg_stroke.width, w.active.bg_stroke.width),
mix(w.inactive.bg_stroke.color, w.hovered.bg_stroke.color, w.active.bg_stroke.color),
),
text: mix(w.inactive.fg_stroke.color, w.hovered.fg_stroke.color, w.active.fg_stroke.color),
expansion: num(w.inactive.expansion, w.hovered.expansion, w.active.expansion),
}
} else {
let t = Tokens::get(ui.ctx());
DrawnState {
fill: Color32::TRANSPARENT.lerp_to_gamma(t.hover, h).lerp_to_gamma(t.pressed, p),
stroke: Stroke::NONE,
text: t.text,
expansion: 0.0,
}
}
}
/// A frameless 22 px icon button (pane close buttons): ribbon-style hover and press feedback.
pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str) -> Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
let st = drawn_state(ui, &resp, false);
ui.painter().rect_filled(r, CornerRadius::same(4), st.fill);
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), icon, t.icon, t.accent);
resp.on_hover_text(tip)
}
/// A text button with a drawn icon in front. Symbols such as ✎ or 💬 aren't in the interface
/// fonts and would draw as empty boxes, so icons are painted (`icons.rs`) rather than typed.
/// It looks and reacts like `ui.button` (see [`drawn_state`]).
pub fn icon_text_button(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId) -> Response {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(text.to_string(), font, t.text);
let galley = ui.painter().layout_no_wrap(text.to_string(), font, Color32::PLACEHOLDER);
let pad = ui.spacing().button_padding;
let icon_w = 16.0;
let size = vec2(pad.x + icon_w + 4.0 + galley.size().x + pad.x, galley.size().y.max(icon_w) + 2.0 * pad.y);
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
let v = ui.style().interact(&resp);
ui.painter().rect(r, v.corner_radius, v.weak_bg_fill, v.bg_stroke, egui::StrokeKind::Inside);
let st = drawn_state(ui, &resp, true);
let r = r.expand(st.expansion);
ui.painter().rect(r, ui.visuals().widgets.inactive.corner_radius, st.fill, st.stroke, egui::StrokeKind::Inside);
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(r.min.x + pad.x + icon_w / 2.0, r.center().y), vec2(icon_w, icon_w)),
@ -408,7 +459,7 @@ pub fn icon_text_button(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId)
t.icon,
t.accent,
);
ui.painter().galley(pos2(r.min.x + pad.x + icon_w + 4.0, r.center().y - galley.size().y / 2.0), galley, t.text);
ui.painter().galley(pos2(r.min.x + pad.x + icon_w + 4.0, r.center().y - galley.size().y / 2.0), galley, st.text);
resp
}
@ -416,6 +467,60 @@ pub fn icon_text_button(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId)
mod tests {
use super::*;
#[test]
fn drawn_buttons_fade_in_on_hover_and_darken_while_pressed() {
// #305's drawn replacements for ✕, 💬, ✎ and ➕ must react like egui's buttons.
let ctx = egui::Context::default();
crate::theme::apply(&ctx, &Tokens::light());
let clock = std::cell::Cell::new(0u32);
let seen = std::cell::Cell::new((Rect::NOTHING, Rect::NOTHING, Color32::PLACEHOLDER, Color32::PLACEHOLDER));
// One 60 fps frame; returns both buttons' rects and fills (framed, frameless).
let frame = |events: Vec<egui::Event>| {
clock.set(clock.get() + 1);
let input = egui::RawInput { time: Some(f64::from(clock.get()) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
ctx.run_ui(input, |ui| {
let framed = icon_text_button(ui, "comment", "Comments", regular(12.0));
let frameless = icon_button(ui, "close", "Close");
let (a, b) = (drawn_state(ui, &framed, true).fill, drawn_state(ui, &frameless, false).fill);
seen.set((framed.rect, frameless.rect, a, b));
})
.drop_without_applying_deltas();
seen.get()
};
// Half a second of frames, so animations finish; returns the last one.
let settle = || {
for _ in 0..29 {
frame(vec![]);
}
frame(vec![])
};
let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
let w = ctx.global_style().visuals.widgets.clone();
let t = Tokens::light();
let (framed, frameless, idle, idle_bare) = settle();
assert_eq!(idle, w.inactive.weak_bg_fill);
assert_eq!(idle_bare, Color32::TRANSPARENT);
// Hover fades in over a few frames rather than jumping, then settles on egui's hovered fill.
let first = frame(vec![egui::Event::PointerMoved(framed.center())]).2;
let between = |c: Color32, a: Color32, b: Color32| a.r().min(b.r()) < c.r() && c.r() < a.r().max(b.r());
assert!(between(first, idle, w.hovered.weak_bg_fill), "hover should animate: {idle:?} → {first:?} → {:?}", w.hovered.weak_bg_fill);
assert_eq!(settle().2, w.hovered.weak_bg_fill);
// Held down: blends to the pressed (active) fill.
frame(vec![press(framed.center(), true)]);
assert_eq!(settle().2, w.active.weak_bg_fill);
frame(vec![press(framed.center(), false)]);
// The frameless close button uses the ribbon's hover and pressed tokens; the other fades out.
frame(vec![egui::Event::PointerMoved(frameless.center())]);
let after = settle();
assert_eq!(after.3, t.hover);
assert_eq!(after.2, w.inactive.weak_bg_fill);
frame(vec![press(frameless.center(), true)]);
assert_eq!(settle().3, t.pressed);
}
#[test]
fn a_long_combo_list_builds_only_the_rows_in_view() {
// #121: the font menu built (and so loaded the font of) every installed family.