use super::*;
fn constraint_text_color(settings: &brep_render::style::RenderSettings) -> egui::Color32 {
let c = settings.sketch_colors().constraint;
egui::Color32::from_rgb((c >> 16) as u8, (c >> 8) as u8, c as u8)
}
fn label_font(ui: &egui::Ui, scale: f32) -> egui::FontId {
let mut font = egui::TextStyle::Monospace.resolve(ui.style());
font.size *= scale;
font
}
fn scale_label_spacing(ui: &mut egui::Ui, scale: f32) {
let spacing = ui.spacing_mut();
spacing.interact_size *= scale;
spacing.button_padding *= scale;
spacing.item_spacing *= scale;
}
fn chip_margin(x: f32, y: f32, scale: f32) -> egui::Margin {
egui::Margin::symmetric((x * scale).round() as i8, (y * scale).round() as i8)
}
fn text_edit_width(ui: &egui::Ui, text: &str, font: &egui::FontId) -> f32 {
let measured = ui.ctx().fonts_mut(|f| {
f.layout_no_wrap(text.to_owned(), font.clone(), egui::Color32::PLACEHOLDER)
.size()
.x
});
let base = egui::TextStyle::Monospace.resolve(ui.style()).size;
let k = if base > 0.0 { font.size / base } else { 1.0 };
(measured + 12.0 * k).max(24.0 * k)
}
fn label_text_edit<'t>(
ui: &egui::Ui,
buf: &'t mut String,
font: &egui::FontId,
scale: f32,
) -> egui::TextEdit<'t> {
let width = text_edit_width(ui, buf.as_str(), font);
egui::TextEdit::singleline(buf)
.desired_width(width)
.margin(chip_margin(4.0, 2.0, scale))
.font(egui::FontSelection::FontId(font.clone()))
}
fn select_all_text_edit(ctx: &egui::Context, id: egui::Id, char_count: usize) {
if let Some(mut state) = egui::TextEdit::load_state(ctx, id) {
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(
egui::text::CCursor::new(0),
egui::text::CCursor::new(char_count),
)));
egui::TextEdit::store_state(ctx, id, state);
}
}
impl Viewport {
pub(super) fn draw_dimension_labels(
&mut self,
ctx: &egui::Context,
rect: egui::Rect,
state: &mut EngineState,
) {
if !state.sketch_mode() {
self.editing_dim = None;
return;
}
let labels: Vec<serde_json::Value> =
serde_json::from_str(&state.sketch_dimension_labels_json()).unwrap_or_default();
if let Some((id, _)) = self.editing_dim.as_ref() {
let key = id.to_string();
if !labels.iter().any(|l| l["id"].to_string() == key) {
self.editing_dim = None;
}
}
if labels.is_empty() {
return;
}
let worlds: Vec<[f64; 3]> = labels
.iter()
.map(|l| {
let w = &l["world"];
[
w[0].as_f64().unwrap_or(0.0),
w[1].as_f64().unwrap_or(0.0),
w[2].as_f64().unwrap_or(0.0),
]
})
.collect();
let screens: Vec<[f64; 4]> = serde_json::to_string(&worlds)
.ok()
.and_then(|s| state.world_to_screen_json(&s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
if screens.len() != labels.len() {
return;
}
let fresh = self.dim_edit_fresh;
self.dim_edit_fresh = false;
let mut editing = self.editing_dim.take();
let editing_key = editing.as_ref().map(|(id, _)| id.to_string());
let dim_text_color = constraint_text_color(&state.settings);
let label_scale = state.settings.label_scale;
let mut start_edit: Option<serde_json::Value> = None;
let mut apply: bool = false;
let mut cancel: bool = false;
let mut drag_to: Option<(serde_json::Value, f64, f64)> = None;
let mut drag_ended = false;
for (i, label) in labels.iter().enumerate() {
let scr = screens[i];
if scr[3] < 0.5 {
continue;
}
let cid = label["id"].clone();
let cid_key = cid.to_string();
let pos = egui::pos2(rect.min.x + scr[0] as f32, rect.min.y + scr[1] as f32);
let is_editing = editing_key.as_deref() == Some(cid_key.as_str());
let area_id = egui::Id::new(("brep-dim-label", cid_key.clone()));
egui::Area::new(area_id)
.order(egui::Order::Middle)
.fixed_pos(pos)
.pivot(egui::Align2::CENTER_CENTER)
.show(ctx, |ui| {
let font = label_font(ui, label_scale);
egui::Frame::popup(ui.style())
.inner_margin(chip_margin(4.0, 2.0, label_scale))
.show(ui, |ui| {
scale_label_spacing(ui, label_scale);
if is_editing {
let buf = &mut editing.as_mut().expect("editing buffer").1;
let editor = label_text_edit(ui, buf, &font, label_scale);
let resp = ui.add(editor);
if fresh {
resp.request_focus();
select_all_text_edit(ui.ctx(), resp.id, buf.chars().count());
}
let enter =
ui.input(|i| i.key_pressed(egui::Key::Enter));
if resp.lost_focus() {
if enter {
apply = true;
} else if !fresh {
cancel = true;
}
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
cancel = true;
}
} else {
let text = label["text"].as_str().unwrap_or("").to_string();
let resp = ui.add(
egui::Button::new(
egui::RichText::new(text)
.font(font.clone())
.color(dim_text_color),
)
.wrap_mode(egui::TextWrapMode::Extend)
.sense(egui::Sense::click_and_drag()),
);
if resp.double_clicked() {
start_edit = Some(cid.clone());
}
if resp.dragged() {
if let Some(p) = resp.interact_pointer_pos() {
drag_to = Some((
cid.clone(),
(p.x - rect.min.x) as f64,
(p.y - rect.min.y) as f64,
));
}
}
if resp.drag_stopped() {
drag_ended = true;
}
}
});
});
}
if apply {
if let Some((id, text)) = editing.take() {
state.sketch_set_dimension_value(&id, &text);
}
self.editing_dim = None;
} else if cancel {
self.editing_dim = None;
} else {
self.editing_dim = editing;
}
if let Some((id, lx, ly)) = drag_to {
state.sketch_dimension_drag_to(&id, lx, ly);
}
if drag_ended {
state.sketch_dimension_drag_end();
}
if let Some(id) = start_edit {
let seed: serde_json::Value =
serde_json::from_str(&state.sketch_dimension_value_json(&id)).unwrap_or_default();
let text = seed
.get("valueExpr")
.and_then(|v| v.as_str())
.map(str::to_string)
.or_else(|| {
seed.get("value")
.and_then(|v| v.as_f64())
.map(|n| format!("{n}"))
})
.unwrap_or_default();
self.editing_dim = Some((id, text));
self.dim_edit_fresh = true;
}
if self.editing_dim.is_some() {
ctx.request_repaint();
}
}
pub(super) fn draw_feature_dimension_labels(
&mut self,
ctx: &egui::Context,
rect: egui::Rect,
state: &mut EngineState,
) {
if state.gizmo_mode() != "dimension" {
self.editing_feature_dim = None;
return;
}
let feature = state.dimension_armed_feature();
if feature.is_empty() {
self.editing_feature_dim = None;
return;
}
let annotations: Vec<serde_json::Value> =
serde_json::from_str(&state.feature_dimension_annotations_json(&feature))
.unwrap_or_default();
if let Some((_, field, _)) = self.editing_feature_dim.as_ref() {
let key = field.clone();
if !annotations
.iter()
.any(|a| a["fieldKey"].as_str() == Some(key.as_str()))
{
self.editing_feature_dim = None;
}
}
if annotations.is_empty() {
return;
}
let worlds: Vec<[f64; 3]> = annotations
.iter()
.map(|a| {
let m = &a["mid"];
[
m[0].as_f64().unwrap_or(0.0),
m[1].as_f64().unwrap_or(0.0),
m[2].as_f64().unwrap_or(0.0),
]
})
.collect();
let screens: Vec<[f64; 4]> = serde_json::to_string(&worlds)
.ok()
.and_then(|s| state.world_to_screen_json(&s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
if screens.len() != annotations.len() {
return;
}
let label_scale = state.settings.label_scale;
let fresh = self.feature_dim_edit_fresh;
self.feature_dim_edit_fresh = false;
let mut editing = self.editing_feature_dim.take();
let editing_key = editing.as_ref().map(|(_, field, _)| field.clone());
let mut start_edit: Option<(String, String)> = None; let mut apply = false;
let mut cancel = false;
let mut drag_to: Option<(String, f64, f64)> = None;
for (i, annotation) in annotations.iter().enumerate() {
let scr = screens[i];
if scr[3] < 0.5 {
continue;
}
let Some(field) = annotation["fieldKey"].as_str() else {
continue;
};
let field = field.to_string();
let value = annotation["value"].as_f64().unwrap_or(0.0);
let prefix = annotation["label"].as_str().unwrap_or("").to_string();
let is_angular = annotation["kind"].as_str() == Some("angular");
let pos = egui::pos2(rect.min.x + scr[0] as f32, rect.min.y + scr[1] as f32);
let is_editing = editing_key.as_deref() == Some(field.as_str());
let area_id = egui::Id::new(("brep-feature-dim", feature.clone(), field.clone()));
egui::Area::new(area_id)
.order(egui::Order::Middle)
.fixed_pos(pos)
.pivot(egui::Align2::CENTER_CENTER)
.show(ctx, |ui| {
let orange = egui::Color32::from_rgb(245, 166, 35);
let dark = egui::Color32::from_rgb(20, 20, 20);
let font = label_font(ui, label_scale);
egui::Frame::new()
.fill(dark)
.stroke(egui::Stroke::new(1.0, orange))
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(chip_margin(6.0, 3.0, label_scale))
.show(ui, |ui| {
scale_label_spacing(ui, label_scale);
if is_editing {
let buf = &mut editing.as_mut().expect("editing buffer").2;
let editor = label_text_edit(ui, buf, &font, label_scale)
.text_color(orange)
.frame(egui::Frame::NONE);
let resp = ui.add(editor);
if fresh {
resp.request_focus();
select_all_text_edit(ui.ctx(), resp.id, buf.chars().count());
}
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
if resp.lost_focus() {
if enter {
apply = true;
} else if !fresh {
cancel = true;
}
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
cancel = true;
}
} else {
let text = if is_angular {
format!("{prefix} {}\u{00b0}", fmt_dim_value(value))
} else {
format!("{prefix} {}", fmt_dim_value(value))
};
let resp = ui.add(
egui::Button::new(
egui::RichText::new(text)
.font(font.clone())
.color(orange),
)
.frame(false)
.wrap_mode(egui::TextWrapMode::Extend)
.sense(egui::Sense::click_and_drag()),
);
if resp.double_clicked() {
start_edit = Some((field.clone(), fmt_dim_value(value)));
}
if resp.dragged() {
if let Some(p) = resp.interact_pointer_pos() {
drag_to = Some((
field.clone(),
(p.x - rect.min.x) as f64,
(p.y - rect.min.y) as f64,
));
}
}
}
});
});
}
if apply {
if let Some((feat, field, text)) = editing.take() {
state.feature_dimension_set_value(&feat, &field, &text);
}
self.editing_feature_dim = None;
} else if cancel {
self.editing_feature_dim = None;
} else {
self.editing_feature_dim = editing;
}
if let Some((field, lx, ly)) = drag_to {
state.feature_dimension_drag(&feature, &field, lx, ly);
}
if let Some((field, seed)) = start_edit {
self.editing_feature_dim = Some((feature.clone(), field, seed));
self.feature_dim_edit_fresh = true;
}
if self.editing_feature_dim.is_some() {
ctx.request_repaint();
}
}
pub(super) fn draw_constraint_labels(
&mut self,
ctx: &egui::Context,
rect: egui::Rect,
state: &mut EngineState,
) {
let labels: Vec<serde_json::Value> =
serde_json::from_str(&state.constraint_labels_json()).unwrap_or_default();
if labels.is_empty() {
if self.constraint_label_hovered.take().is_some() {
state.constraint_hover_end();
}
return;
}
let worlds: Vec<[f64; 3]> = labels
.iter()
.map(|l| {
let w = &l["world"];
[
w[0].as_f64().unwrap_or(0.0),
w[1].as_f64().unwrap_or(0.0),
w[2].as_f64().unwrap_or(0.0),
]
})
.collect();
let screens: Vec<[f64; 4]> = serde_json::to_string(&worlds)
.ok()
.and_then(|s| state.world_to_screen_json(&s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
if screens.len() != labels.len() {
return;
}
let label_scale = state.settings.label_scale;
let mut hovered: Option<String> = None;
let mut clicked: Option<String> = None;
for (i, label) in labels.iter().enumerate() {
let scr = screens[i];
if scr[3] < 0.5 {
continue;
}
let Some(id) = label["id"].as_str() else {
continue;
};
let text = label["text"].as_str().unwrap_or(id).to_string();
let message = label["message"].as_str().unwrap_or("").to_string();
let status = label["status"].as_str().unwrap_or("").to_string();
let rgb = &label["color"];
let color = egui::Color32::from_rgb(
(rgb[0].as_f64().unwrap_or(1.0) * 255.0).round() as u8,
(rgb[1].as_f64().unwrap_or(1.0) * 255.0).round() as u8,
(rgb[2].as_f64().unwrap_or(1.0) * 255.0).round() as u8,
);
let pos = egui::pos2(rect.min.x + scr[0] as f32, rect.min.y + scr[1] as f32);
let area_id = egui::Id::new(("brep-constraint-label", id));
egui::Area::new(area_id)
.order(egui::Order::Middle)
.fixed_pos(pos)
.pivot(egui::Align2::CENTER_CENTER)
.show(ctx, |ui| {
let selected = label["selected"].as_bool().unwrap_or(false);
let dark = egui::Color32::from_rgb(20, 20, 20);
let font = label_font(ui, label_scale);
egui::Frame::new()
.fill(dark)
.stroke(egui::Stroke::new(if selected { 2.5 } else { 1.0 }, color))
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(chip_margin(6.0, 3.0, label_scale))
.show(ui, |ui| {
scale_label_spacing(ui, label_scale);
let resp = ui.add(
egui::Button::new(
egui::RichText::new(text).font(font.clone()).color(color),
)
.frame(false)
.wrap_mode(egui::TextWrapMode::Extend)
.sense(egui::Sense::click()),
);
let resp = if message.is_empty() {
resp.on_hover_text(status.clone())
} else {
resp.on_hover_text(format!("{status}: {message}"))
};
if resp.hovered() {
hovered = Some(id.to_string());
}
if resp.clicked() {
clicked = Some(id.to_string());
}
});
});
}
match hovered {
Some(id) => {
state.constraint_hover(&id);
self.constraint_label_hovered = Some(id);
}
None => {
if self.constraint_label_hovered.take().is_some() {
state.constraint_hover_end();
}
}
}
if let Some(id) = clicked {
state.constraint_label_clicked(&id);
}
}
pub(super) fn draw_transform_axis_labels(
&mut self,
ctx: &egui::Context,
rect: egui::Rect,
state: &mut EngineState,
) {
if state.gizmo_mode() != "transform" {
return;
}
let labels: Vec<serde_json::Value> =
serde_json::from_str(&state.transform_axis_labels_json()).unwrap_or_default();
if labels.is_empty() {
return;
}
let worlds: Vec<[f64; 3]> = labels
.iter()
.map(|l| {
let w = &l["world"];
[
w[0].as_f64().unwrap_or(0.0),
w[1].as_f64().unwrap_or(0.0),
w[2].as_f64().unwrap_or(0.0),
]
})
.collect();
let screens: Vec<[f64; 4]> = serde_json::to_string(&worlds)
.ok()
.and_then(|s| state.world_to_screen_json(&s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
if screens.len() != labels.len() {
return;
}
let label_scale = state.settings.label_scale;
for (i, label) in labels.iter().enumerate() {
let scr = screens[i];
if scr[3] < 0.5 {
continue;
}
let text = label["text"].as_str().unwrap_or("").to_string();
let rgb = &label["rgb"];
let color = egui::Color32::from_rgb(
(rgb[0].as_f64().unwrap_or(1.0) * 255.0).round() as u8,
(rgb[1].as_f64().unwrap_or(1.0) * 255.0).round() as u8,
(rgb[2].as_f64().unwrap_or(1.0) * 255.0).round() as u8,
);
let pos = egui::pos2(rect.min.x + scr[0] as f32, rect.min.y + scr[1] as f32);
let area_id = egui::Id::new(("brep-transform-axis", i));
egui::Area::new(area_id)
.order(egui::Order::Middle)
.interactable(false)
.fixed_pos(pos)
.pivot(egui::Align2::CENTER_CENTER)
.show(ctx, |ui| {
let font = label_font(ui, label_scale);
ui.label(egui::RichText::new(text).font(font).strong().color(color));
});
}
}
pub(super) fn draw_gizmo_hit_areas(
&self,
ctx: &egui::Context,
rect: egui::Rect,
state: &EngineState,
) {
if !state.settings.debug_grab_handles {
return;
}
let json = match state.gizmo_mode() {
"dimension" => state.dimension_hit_areas_json(),
_ => state.transform_hit_areas_json(),
};
let areas: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap_or_default();
if areas.is_empty() {
return;
}
let stroke = egui::Stroke::new(1.0, egui::Color32::RED);
let mut painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("brep-gizmo-hit-areas"),
));
painter.set_clip_rect(rect);
let at = |v: &serde_json::Value| {
egui::pos2(
rect.min.x + v[0].as_f64().unwrap_or(0.0) as f32,
rect.min.y + v[1].as_f64().unwrap_or(0.0) as f32,
)
};
for area in &areas {
let r = area["r"].as_f64().unwrap_or(0.0) as f32;
match area["kind"].as_str() {
Some("capsule") => {
draw_capsule_outline(&painter, at(&area["a"]), at(&area["b"]), r, stroke);
}
Some("circle") => {
painter.circle_stroke(at(&area["c"]), r, stroke);
}
_ => {}
}
}
}
}
fn draw_capsule_outline(
painter: &egui::Painter,
a: egui::Pos2,
b: egui::Pos2,
px: f32,
stroke: egui::Stroke,
) {
let seg = b - a;
let len = seg.length();
if len < 1.0 {
painter.circle_stroke(a, px, stroke);
return;
}
let dir = seg / len;
let perp = egui::vec2(-dir.y, dir.x) * px; let ang0 = perp.y.atan2(perp.x);
const CAP_SEGS: usize = 8;
let pi = std::f32::consts::PI;
let mut pts: Vec<egui::Pos2> = Vec::with_capacity(4 + 2 * CAP_SEGS);
pts.push(a + perp);
pts.push(b + perp);
for k in 1..CAP_SEGS {
let t = ang0 - pi * (k as f32) / (CAP_SEGS as f32);
pts.push(b + egui::vec2(t.cos(), t.sin()) * px);
}
pts.push(b - perp);
pts.push(a - perp);
for k in 1..CAP_SEGS {
let t = (ang0 + pi) - pi * (k as f32) / (CAP_SEGS as f32);
pts.push(a + egui::vec2(t.cos(), t.sin()) * px);
}
painter.add(egui::Shape::closed_line(pts, stroke));
}
fn fmt_dim_value(value: f64) -> String {
let s = format!("{value:.4}");
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
if trimmed.is_empty() || trimmed == "-" {
"0".to_string()
} else {
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn measure_chip(scale: f32, editing: bool, legacy: bool) -> (egui::Rect, egui::Rect, f32) {
let ctx = egui::Context::default();
let (mut frame_rect, mut widget_rect, mut pad) =
(egui::Rect::ZERO, egui::Rect::ZERO, 0.0f32);
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let font = label_font(ui, scale);
let frame = egui::Frame::popup(ui.style()).inner_margin(chip_margin(4.0, 2.0, scale));
pad = frame.total_margin().sum().y;
let shown = frame.show(ui, |ui| {
if !legacy {
scale_label_spacing(ui, scale);
}
let mut buf = "12.5".to_string();
if editing {
if legacy {
let width = text_edit_width(ui, buf.as_str(), &font);
ui.add(
egui::TextEdit::singleline(&mut buf)
.desired_width(width)
.font(egui::FontSelection::FontId(font.clone())),
)
} else {
let editor = label_text_edit(ui, &mut buf, &font, scale);
ui.add(editor)
}
} else {
ui.add(
egui::Button::new(egui::RichText::new(buf).font(font.clone()))
.wrap_mode(egui::TextWrapMode::Extend)
.sense(egui::Sense::click_and_drag()),
)
}
});
widget_rect = shown.inner.rect;
frame_rect = shown.response.rect;
});
(frame_rect, widget_rect, pad)
}
#[test]
fn text_edit_width_grows_with_text_and_floors_when_empty() {
let ctx = egui::Context::default();
let (mut empty_w, mut short_w, mut long_w) = (0.0f32, 0.0f32, 0.0f32);
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let font = label_font(ui, 1.0);
empty_w = text_edit_width(ui, "", &font);
short_w = text_edit_width(ui, "20", &font);
long_w = text_edit_width(ui, "1234.5678", &font);
});
assert!(long_w > short_w, "long {long_w} must exceed short {short_w}");
assert!(short_w >= empty_w, "text is at least as wide as the empty box");
assert!(empty_w >= 24.0, "empty box floored, got {empty_w}");
}
#[test]
fn label_font_scales_the_base_monospace_size() {
let ctx = egui::Context::default();
let (mut raw, mut base, mut half, mut triple) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
let mut quarter = 0.0f32;
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
raw = egui::TextStyle::Monospace.resolve(ui.style()).size;
base = label_font(ui, 1.0).size;
quarter = label_font(ui, 0.25).size;
half = label_font(ui, 0.5).size;
triple = label_font(ui, 3.0).size;
});
let unscaled = raw;
assert!(unscaled > 0.0, "the base monospace size must be positive");
assert_eq!(
base, unscaled,
"scale 1.0 must be the base Monospace size (no change by default)"
);
assert!(
(quarter - unscaled * 0.25).abs() < 1e-4,
"0.25x (the clamp FLOOR) must quarter the size: {quarter} vs {unscaled}"
);
assert!(
(half - unscaled * 0.5).abs() < 1e-4,
"0.5x must halve the size: {half} vs {unscaled}"
);
assert!(
(triple - unscaled * 3.0).abs() < 1e-4,
"3.0x must triple the size: {triple} vs {unscaled}"
);
}
#[test]
fn text_edit_width_follows_the_label_scale() {
let ctx = egui::Context::default();
let (mut w1, mut w2, mut w3) = (0.0f32, 0.0f32, 0.0f32);
let (mut empty1, mut empty3) = (0.0f32, 0.0f32);
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let text = "1234.5678";
w1 = text_edit_width(ui, text, &label_font(ui, 1.0));
w2 = text_edit_width(ui, text, &label_font(ui, 2.0));
w3 = text_edit_width(ui, text, &label_font(ui, 3.0));
empty1 = text_edit_width(ui, "", &label_font(ui, 1.0));
empty3 = text_edit_width(ui, "", &label_font(ui, 3.0));
});
assert!(w2 > w1, "2x must widen the box: {w2} vs {w1}");
assert!(w3 > w2, "3x must widen it further: {w3} vs {w2}");
let ratio = w2 / w1;
assert!(
(1.7..=2.3).contains(&ratio),
"2x must roughly double the measured box width, got {ratio}x ({w1} -> {w2})"
);
assert!(
empty3 > empty1 * 2.0,
"the empty-box floor must scale with the font: {empty3} vs {empty1}"
);
}
#[test]
fn chip_height_follows_the_label_scale() {
let h = |scale: f32, editing: bool| measure_chip(scale, editing, false).1.height();
let (ro_q, ro_1, ro_3) = (h(0.25, false), h(1.0, false), h(3.0, false));
let (ed_q, ed_1, ed_3) = (h(0.25, true), h(1.0, true), h(3.0, true));
assert!(
ro_q < ro_1 && ro_1 < ro_3,
"the read-only chip height must grow with the scale: {ro_q} / {ro_1} / {ro_3}"
);
assert!(
ed_q < ed_1 && ed_1 < ed_3,
"the edit chip height must grow with the scale: {ed_q} / {ed_1} / {ed_3}"
);
for (label, scale, got, at_one) in [
("read-only 0.25x", 0.25, ro_q, ro_1),
("read-only 3x", 3.0, ro_3, ro_1),
("edit 0.25x", 0.25, ed_q, ed_1),
("edit 3x", 3.0, ed_3, ed_1),
] {
let want = scale * at_one;
let tol = 0.1 * want + 1.0;
assert!(
(got - want).abs() <= tol,
"{label}: height must be ~{want} (= {scale} x {at_one}), got {got} (tol {tol})"
);
}
for (label, scale) in [("0.25x", 0.25f32), ("3x", 3.0)] {
let at_one = measure_chip(1.0, false, false).1.width();
let got = measure_chip(scale, false, false).1.width();
let want = scale * at_one;
assert!(
(got - want).abs() <= 0.1 * want + 1.0,
"{label}: the chip WIDTH must be ~{want}, got {got}"
);
}
let gap_at_one = (ro_1 - ed_1).abs();
for (label, scale, ro, ed) in [
("0.25x", 0.25, ro_q, ed_q),
("1x", 1.0, ro_1, ed_1),
("3x", 3.0, ro_3, ed_3),
] {
let tol = gap_at_one * scale + 1.5;
assert!(
(ro - ed).abs() <= tol,
"{label}: the chip must not jump when clicked to edit — \
read-only {ro} vs edit {ed} (gap tol {tol})"
);
}
for scale in [0.25f32, 1.0, 3.0] {
for editing in [false, true] {
let (frame_rect, widget_rect, pad) = measure_chip(scale, editing, false);
assert!(
frame_rect.contains_rect(widget_rect),
"scale {scale} editing {editing}: the click rect {widget_rect:?} must sit \
inside the painted chip {frame_rect:?}"
);
assert!(
(frame_rect.height() - widget_rect.height() - pad).abs() < 1e-3,
"scale {scale} editing {editing}: the chip must be the hit rect plus \
exactly its frame margin ({pad}), got {} vs {}",
frame_rect.height(),
widget_rect.height()
);
}
}
}
#[test]
fn scale_1_0_is_pixel_identical_to_the_unscaled_chip() {
for editing in [false, true] {
let (frame_new, widget_new, _) = measure_chip(1.0, editing, false);
let (frame_old, widget_old, _) = measure_chip(1.0, editing, true);
assert_eq!(
widget_new.size(),
widget_old.size(),
"editing {editing}: the click rect drifted at scale 1.0"
);
assert_eq!(
frame_new.size(),
frame_old.size(),
"editing {editing}: the painted chip drifted at scale 1.0"
);
}
let ctx = egui::Context::default();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let before = ui.spacing().clone();
scale_label_spacing(ui, 1.0);
let after = ui.spacing().clone();
assert_eq!(before.interact_size, after.interact_size);
assert_eq!(before.button_padding, after.button_padding);
assert_eq!(before.item_spacing, after.item_spacing);
let m = chip_margin(4.0, 2.0, 1.0);
assert_eq!(
(m.left, m.right, m.top, m.bottom),
(4, 4, 2, 2),
"the scaled TextEdit margin at 1.0 must be egui's own default"
);
});
}
#[test]
fn chip_margin_scales_and_stays_non_negative() {
let base = chip_margin(6.0, 3.0, 1.0);
assert_eq!((base.left, base.top), (6, 3), "1.0 is the base padding");
let big = chip_margin(6.0, 3.0, 3.0);
assert_eq!((big.left, big.top), (18, 9), "3x triples the padding");
let small = chip_margin(6.0, 3.0, 0.5);
assert!(
small.left >= 0 && small.top >= 0 && small.left < base.left,
"0.5x shrinks the padding without going negative, got {small:?}"
);
let floor6 = chip_margin(6.0, 3.0, 0.25);
assert_eq!((floor6.left, floor6.top), (2, 1), "0.25x of (6,3)");
let floor4 = chip_margin(4.0, 2.0, 0.25);
assert_eq!((floor4.left, floor4.top), (1, 1), "0.25x of (4,2)");
}
}