use crate::icons;
use eframe::egui::{
self,
text::{ByteIndex, LayoutJob, LayoutSection},
Align, FontSelection, Response, Sense, TextWrapMode, Widget, WidgetText,
};
pub fn image(icon: &'static icons::Icon, height: f32) -> egui::Image<'static> {
let size = egui::vec2(height * icon.aspect, height);
egui::Image::new(egui::ImageSource::Bytes {
uri: icon.uri.into(),
bytes: egui::load::Bytes::Static(icon.svg.as_bytes()),
})
.fit_to_exact_size(size)
}
pub fn icon_button<'a>(ui: &egui::Ui, label: &'a str) -> egui::Button<'a> {
icon_button_colored(ui, label, None)
}
pub fn icon_button_colored<'a>(
ui: &egui::Ui,
label: &'a str,
color: Option<egui::Color32>,
) -> egui::Button<'a> {
let Some((icon, rest)) = split_leading(label) else {
return match color {
Some(c) => egui::Button::new(egui::RichText::new(label).color(c)),
None => egui::Button::new(label),
};
};
egui_extras::install_image_loaders(ui.ctx());
let mut art = image(icon, ui.text_style_height(&egui::TextStyle::Body));
if let (Some(c), true) = (color, icon.mono) {
art = art.tint(c);
}
let button = match (rest.is_empty(), color) {
(true, _) => egui::Button::new(art),
(false, Some(c)) => egui::Button::new((art, egui::RichText::new(rest).color(c))),
(false, None) => egui::Button::new((art, rest)),
};
button.image_tint_follows_text_color(icon.mono && color.is_none())
}
fn split_leading(label: &str) -> Option<(&'static icons::Icon, &str)> {
let mut chars = label.chars();
let icon = icons::lookup(chars.next()?)?;
Some((icon, chars.as_str().trim_start()))
}
pub fn selectable_icon_label(ui: &mut egui::Ui, selected: bool, label: &str) -> Response {
let Some((icon, rest)) = split_leading(label) else {
return ui.selectable_label(selected, label);
};
egui_extras::install_image_loaders(ui.ctx());
let art = image(icon, ui.text_style_height(&egui::TextStyle::Body));
ui.add(
egui::Button::selectable(selected, (art, rest))
.image_tint_follows_text_color(icon.mono),
)
}
pub fn glyph(ui: &egui::Ui, glyph: &str, color: egui::Color32) -> Option<egui::Image<'static>> {
let icon = icons::artwork(glyph)?;
egui_extras::install_image_loaders(ui.ctx());
let art = image(icon, ui.text_style_height(&egui::TextStyle::Body));
Some(if icon.mono { art.tint(color) } else { art })
}
pub fn split_caption(label: &str) -> (Option<&'static icons::Icon>, &str) {
match split_leading(label) {
Some((icon, rest)) => (Some(icon), rest),
None => (None, label),
}
}
enum Segment {
Text(LayoutJob),
Icon(&'static icons::Icon, egui::TextFormat),
}
#[derive(Clone, Copy, Default)]
struct Opts {
wrap_mode: Option<TextWrapMode>,
sense: Option<Sense>,
selectable: Option<bool>,
}
#[must_use = "widgets do nothing unless you add them to a Ui"]
pub struct IconText {
text: WidgetText,
opts: Opts,
}
impl IconText {
pub fn new(text: impl Into<WidgetText>) -> Self {
Self { text: text.into(), opts: Opts::default() }
}
#[inline]
pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
self.opts.wrap_mode = Some(wrap_mode);
self
}
#[inline]
pub fn wrap(self) -> Self {
self.wrap_mode(TextWrapMode::Wrap)
}
#[inline]
pub fn truncate(self) -> Self {
self.wrap_mode(TextWrapMode::Truncate)
}
#[inline]
pub fn extend(self) -> Self {
self.wrap_mode(TextWrapMode::Extend)
}
#[inline]
pub fn sense(mut self, sense: Sense) -> Self {
self.opts.sense = Some(sense);
self
}
#[inline]
pub fn selectable(mut self, selectable: bool) -> Self {
self.opts.selectable = Some(selectable);
self
}
pub fn show(self, ui: &mut egui::Ui) -> Response {
let Self { text, opts } = self;
let job = std::sync::Arc::unwrap_or_clone(text.into_layout_job(
ui.style(),
FontSelection::Default,
ui.text_valign(),
));
let segments = split(&job);
if segments.len() == 1 {
if let Some(Segment::Text(_)) = segments.first() {
return opts.label(job).ui(ui);
}
}
egui_extras::install_image_loaders(ui.ctx());
if ui.layout().is_horizontal() && ui.layout().main_wrap() {
opts.emit(ui, segments)
} else {
ui.horizontal_wrapped(|ui| opts.emit(ui, segments)).inner
}
}
}
impl Opts {
fn emit(&self, ui: &mut egui::Ui, segments: Vec<Segment>) -> Response {
let spacing = ui.spacing().item_spacing;
ui.spacing_mut().item_spacing.x = 0.0;
let mut response: Option<Response> = None;
let mut union = |acc: &mut Option<Response>, r: Response| {
*acc = Some(match acc.take() {
Some(prev) => prev | r,
None => r,
});
};
for segment in segments {
match segment {
Segment::Text(job) => union(&mut response, self.label(job).ui(ui)),
Segment::Icon(icon, format) => {
union(&mut response, self.icon(ui, icon, &format));
}
}
}
ui.spacing_mut().item_spacing = spacing;
response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, Sense::hover()))
}
fn icon(&self, ui: &mut egui::Ui, icon: &'static icons::Icon, format: &egui::TextFormat) -> Response {
let height = ui.fonts_mut(|f| f.row_height(&format.font_id));
let size = egui::vec2(height * icon.aspect, height);
let mut image = image(icon, height);
if icon.mono {
image = image.tint(resolve(ui, format.color));
}
let response = ui.add_sized(size, image);
ui.put(
response.rect,
egui::Label::new(
egui::RichText::new(icon.ch)
.font(format.font_id.clone())
.color(egui::Color32::TRANSPARENT),
)
.selectable(self.selectable.unwrap_or(false)),
) | response
}
fn label(&self, job: LayoutJob) -> egui::Label {
let mut label = egui::Label::new(job);
if let Some(wrap_mode) = self.wrap_mode {
label = label.wrap_mode(wrap_mode);
}
if let Some(sense) = self.sense {
label = label.sense(sense);
}
if let Some(selectable) = self.selectable {
label = label.selectable(selectable);
}
label
}
}
impl Widget for IconText {
fn ui(self, ui: &mut egui::Ui) -> Response {
self.show(ui)
}
}
fn resolve(ui: &egui::Ui, color: egui::Color32) -> egui::Color32 {
if color == egui::Color32::PLACEHOLDER {
ui.visuals().text_color()
} else {
color
}
}
fn split(job: &LayoutJob) -> Vec<Segment> {
let mut segments = Vec::new();
let mut run_start = 0;
for (at, ch) in job.text.char_indices() {
let Some(icon) = icons::lookup(ch) else { continue };
if run_start < at {
segments.push(Segment::Text(slice(job, run_start, at)));
}
segments.push(Segment::Icon(icon, format_at(job, at)));
run_start = at + ch.len_utf8();
}
if segments.is_empty() {
return vec![Segment::Text(job.clone())];
}
if run_start < job.text.len() {
segments.push(Segment::Text(slice(job, run_start, job.text.len())));
}
segments
}
fn format_at(job: &LayoutJob, at: usize) -> egui::TextFormat {
if job.sections.is_empty() {
return egui::TextFormat::default();
}
job.format_at_byte(ByteIndex(at)).clone()
}
fn slice(job: &LayoutJob, start: usize, end: usize) -> LayoutJob {
let mut out = job.clone();
out.text = job.text[start..end].to_owned();
out.sections = job
.sections
.iter()
.filter_map(|s| {
let lo = s.byte_range.start.0.max(start);
let hi = s.byte_range.end.0.min(end);
(lo < hi).then(|| LayoutSection {
leading_space: if s.byte_range.start.0 >= start { s.leading_space } else { 0.0 },
byte_range: ByteIndex(lo - start)..ByteIndex(hi - start),
format: s.format.clone(),
})
})
.collect();
out.first_row_min_height = 0.0;
out.halign = Align::LEFT;
out
}
pub trait IconTextUi {
fn icon_label(&mut self, text: impl Into<WidgetText>) -> Response;
}
impl IconTextUi for egui::Ui {
fn icon_label(&mut self, text: impl Into<WidgetText>) -> Response {
IconText::new(text).show(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_labels_leading_glyph_is_split_off_as_artwork() {
let label = brep_render::features::feature_long_name("P.CU");
let (icon, rest) = split_leading(&label).expect("the cube glyph is catalogued");
assert_eq!(icon.ch, brep_render::features::feature_icon("P.CU").unwrap());
assert!(!icon.mono, "feature artwork carries its own colours");
assert_eq!(rest, "Primitive Cube", "the glyph must not survive in the text");
let (gear, rest) = split_leading("\u{2699} Settings").expect("the gear is catalogued");
assert!(gear.mono, "the gear is monochrome artwork");
assert_eq!(rest, "Settings");
assert!(split_leading("Primitive Cube").is_none());
assert!(split_leading("").is_none());
assert_eq!(split_caption("Primitive Cube"), (None, "Primitive Cube"));
}
fn job(text: &str) -> LayoutJob {
let style = egui::Style::default();
std::sync::Arc::unwrap_or_clone(
WidgetText::from(text).into_layout_job(&style, FontSelection::Default, Align::Center),
)
}
fn shape(job: &LayoutJob) -> Vec<String> {
split(job)
.iter()
.map(|s| match s {
Segment::Text(j) => format!("text({:?})", j.text),
Segment::Icon(i, _) => format!("icon({})", i.name),
})
.collect()
}
#[test]
fn splits_around_a_catalogued_character() {
assert_eq!(
shape(&job("Open \u{2699} settings")),
["text(\"Open \")", "icon(icon_2699)", "text(\" settings\")"]
);
}
#[test]
fn plain_text_stays_one_segment() {
assert_eq!(shape(&job("no icons here")), ["text(\"no icons here\")"]);
}
#[test]
fn empty_text_stays_one_segment() {
assert_eq!(shape(&job("")), ["text(\"\")"]);
}
#[test]
fn handles_leading_trailing_and_adjacent_icons() {
assert_eq!(
shape(&job("\u{2699}\u{2699} x \u{2699}")),
[
"icon(icon_2699)",
"icon(icon_2699)",
"text(\" x \")",
"icon(icon_2699)"
]
);
}
#[test]
fn multibyte_text_around_an_icon_is_sliced_on_char_boundaries() {
assert_eq!(
shape(&job("é\u{2699}é")),
["text(\"é\")", "icon(icon_2699)", "text(\"é\")"]
);
}
#[test]
fn uncatalogued_characters_are_left_as_text() {
assert_eq!(shape(&job("a ✨ b")), ["text(\"a ✨ b\")"]);
}
#[test]
fn slices_preserve_per_run_formatting() {
let style = egui::Style::default();
let mut source = LayoutJob::default();
egui::RichText::new("big ")
.size(24.0)
.color(egui::Color32::RED)
.append_to(&mut source, &style, FontSelection::Default, Align::Center);
egui::RichText::new("\u{2699} small")
.size(9.0)
.color(egui::Color32::GREEN)
.append_to(&mut source, &style, FontSelection::Default, Align::Center);
let segments = split(&source);
assert_eq!(segments.len(), 3);
let Segment::Text(first) = &segments[0] else { panic!("expected a text run") };
assert_eq!(first.text, "big ");
assert_eq!(first.sections[0].format.font_id.size, 24.0);
assert_eq!(first.sections[0].format.color, egui::Color32::RED);
let Segment::Icon(_, format) = &segments[1] else { panic!("expected an icon") };
assert_eq!(format.font_id.size, 9.0);
assert_eq!(format.color, egui::Color32::GREEN);
let Segment::Text(last) = &segments[2] else { panic!("expected a text run") };
assert_eq!(last.text, " small");
assert_eq!(last.sections[0].format.font_id.size, 9.0);
}
#[test]
fn slices_reassemble_into_the_original() {
let source = job("a \u{2699} b \u{2261} c");
let rebuilt: String = split(&source)
.iter()
.map(|s| match s {
Segment::Text(j) => j.text.clone(),
Segment::Icon(i, _) => i.ch.to_string(),
})
.collect();
assert_eq!(rebuilt, source.text);
}
#[test]
fn placeholder_colour_is_never_used_as_a_tint() {
let ctx = egui::Context::default();
let _ = ctx.run_ui(Default::default(), |ui| {
let resolved = resolve(ui, egui::Color32::PLACEHOLDER);
assert_ne!(resolved, egui::Color32::PLACEHOLDER);
assert_eq!(resolved, ui.visuals().text_color());
assert_eq!(resolve(ui, egui::Color32::RED), egui::Color32::RED);
});
}
}