multilayer

6811748 · Gabriel Morell · 2026-09-28 18:27

10 files +780 -161

Files changed

modified examples/make_presets.rs
+4 −0
@@ -43,8 +43,12 @@ fn main() {
43 43 background: Background::DiagonalSplit,
44 44 angle: 45.0,
45 45 scale: *scale,
46 + mark_size: 1.0,
47 + mark_rotation: 0.0,
48 + mark_offset: [0.0, 0.0],
46 49 show_guides: false,
47 50 filled: false,
51 + layers: Vec::new(),
48 52 },
49 53 palette: "Casabeza".into(),
50 54 export_name: "ic_icon".into(),
modified src/app.rs
+254 −73
@@ -13,7 +13,7 @@ use crate::icons::{self, IconEntry};
13 13 use crate::manifest;
14 14 use crate::palette::{self, NamedColor, Theme};
15 15 use crate::render;
16 −use crate::spec::{Background, Foreground, IconSpec};
16 +use crate::spec::{Background, Foreground, IconSpec, Layer};
17 17 use crate::target::{self, Kind};
18 18
19 19 const GRID_CELL: u32 = 96;
@@ -59,23 +59,41 @@ fn swatch_color(c: NamedColor) -> Color32 {
59 59 }
60 60
61 61 /// Identifies a rendered image so textures can be reused between frames.
62 +///
63 +/// Built from the spec's debug form, so every field — including each layer's —
64 +/// is part of the key without having to be listed here and kept in step.
62 65 fn cache_key(spec: &IconSpec, size: u32, masked: bool) -> String {
63 − let fg = match &spec.foreground {
64 − Foreground::Icon(c) => format!("i{}", *c as u32),
65 − Foreground::Text(t) => format!("t{t}"),
66 − };
67 − format!(
68 − "{fg}|{}|{:?}|{:?}|{:?}|{:?}|{:.1}|{:.3}|{}|{}|{size}|{masked}",
69 − spec.sub_banner,
70 − spec.fg_color.rgb,
71 − spec.bg_a.rgb,
72 − spec.bg_b.rgb,
73 − spec.background,
74 − spec.angle,
75 − spec.scale,
76 − spec.filled,
77 − spec.show_guides,
78 − )
66 + format!("{spec:?}|{size}|{masked}")
67 +}
68 +
69 +/// What the detail panel's swatches edit: the main mark's colour is the
70 +/// foreground itself, while a layer's may be unset to follow it.
71 +enum ColorSlot<'a> {
72 + Mark(&'a mut NamedColor),
73 + Layer(&'a mut Option<NamedColor>, NamedColor),
74 +}
75 +
76 +/// A row of palette swatches; clicking one assigns it to `current`.
77 +fn swatch_picker(ui: &mut egui::Ui, swatches: &[NamedColor], current: &mut NamedColor) {
78 + ui.horizontal_wrapped(|ui| {
79 + for c in swatches {
80 + let selected = current.role == c.role && current.rgb == c.rgb;
81 + let (rect, resp) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::click());
82 + ui.painter().rect_filled(rect, 4.0, swatch_color(*c));
83 + if selected {
84 + ui.painter().rect_stroke(
85 + rect,
86 + 4.0,
87 + egui::Stroke::new(2.0_f32, ui.visuals().strong_text_color()),
88 + egui::StrokeKind::Outside,
89 + );
90 + }
91 + if resp.clicked() {
92 + *current = *c;
93 + }
94 + resp.on_hover_text(c.role);
95 + }
96 + });
79 97 }
80 98
81 99 pub struct Studio {
@@ -85,6 +103,9 @@ pub struct Studio {
85 103 icon_query: String,
86 104 text_mode: bool,
87 105 text_value: String,
106 + /// Which stacked layer the icon picker is choosing for; `None` is the
107 + /// main mark.
108 + editing_layer: Option<usize>,
88 109
89 110 /// Which swatches and backgrounds feed the combination grid.
90 111 /// Palette roles, not indices: swatch lists differ between themes, so
@@ -145,6 +166,7 @@ impl Studio {
145 166 icon_query: "lock".into(),
146 167 text_mode: false,
147 168 text_value: "1234".into(),
169 + editing_layer: None,
148 170 grid_colors: vec!["primary".into(), "accent".into(), "base-200".into()],
149 171 grid_backgrounds: vec![
150 172 Background::Solid,
@@ -229,6 +251,7 @@ impl Studio {
229 251 }
230 252 }
231 253 self.spec = s.spec;
254 + self.editing_layer = None;
232 255 }
233 256
234 257 fn new_document(&mut self) {
@@ -237,6 +260,7 @@ impl Studio {
237 260 self.theme_idx = fresh.theme_idx;
238 261 self.text_mode = fresh.text_mode;
239 262 self.text_value = fresh.text_value;
263 + self.editing_layer = None;
240 264 self.icon_query = fresh.icon_query;
241 265 self.grid_colors = fresh.grid_colors;
242 266 self.grid_backgrounds = fresh.grid_backgrounds;
@@ -394,6 +418,14 @@ impl Studio {
394 418 self.spec.fg_color = rebind(self.spec.fg_color, theme, "base-content");
395 419 self.spec.bg_a = rebind(self.spec.bg_a, theme, "primary");
396 420 self.spec.bg_b = rebind(self.spec.bg_b, theme, "accent");
421 + // A layer's role may be missing from the new palette; it then lands
422 + // with the foreground rather than on an arbitrary swatch.
423 + let fg_role = self.spec.fg_color.role;
424 + for layer in &mut self.spec.layers {
425 + if let Some(c) = layer.color {
426 + layer.color = Some(rebind(c, theme, fg_role));
427 + }
428 + }
397 429 }
398 430
399 431 /// The spec as currently configured; exposed so the theme behaviour can be
@@ -437,35 +469,12 @@ impl Studio {
437 469 fn swatch_row(&mut self, ui: &mut egui::Ui, label: &str, target: usize) {
438 470 let swatches = self.theme().swatches();
439 471 ui.label(label);
440 − ui.horizontal_wrapped(|ui| {
441 − for c in &swatches {
442 − let current = match target {
443 − 0 => self.spec.fg_color,
444 − 1 => self.spec.bg_a,
445 − _ => self.spec.bg_b,
446 − };
447 − let selected = current.role == c.role && current.rgb == c.rgb;
448 − let (rect, resp) =
449 − ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::click());
450 − ui.painter().rect_filled(rect, 4.0, swatch_color(*c));
451 − if selected {
452 − ui.painter().rect_stroke(
453 − rect,
454 − 4.0,
455 − egui::Stroke::new(2.0_f32, ui.visuals().strong_text_color()),
456 − egui::StrokeKind::Outside,
457 − );
458 − }
459 − if resp.clicked() {
460 − match target {
461 − 0 => self.spec.fg_color = *c,
462 − 1 => self.spec.bg_a = *c,
463 − _ => self.spec.bg_b = *c,
464 − }
465 − }
466 − resp.on_hover_text(c.role);
467 − }
468 − });
472 + let current = match target {
473 + 0 => &mut self.spec.fg_color,
474 + 1 => &mut self.spec.bg_a,
475 + _ => &mut self.spec.bg_b,
476 + };
477 + swatch_picker(ui, &swatches, current);
469 478 }
470 479
471 480 fn controls(&mut self, ui: &mut egui::Ui) {
@@ -493,11 +502,17 @@ impl Studio {
493 502
494 503 ui.separator();
495 504 ui.horizontal(|ui| {
505 + // The kind switch is about the main mark; touching it moves the
506 + // picker back there too.
507 + let before = self.text_mode;
496 508 ui.selectable_value(&mut self.text_mode, false, "Icon");
497 509 ui.selectable_value(&mut self.text_mode, true, "Text");
510 + if self.text_mode != before {
511 + self.editing_layer = None;
512 + }
498 513 });
499 514
500 − if self.text_mode {
515 + if self.text_mode && self.editing_layer.is_none() {
501 516 if ui.text_edit_singleline(&mut self.text_value).changed() {
502 517 self.spec.foreground = Foreground::Text(self.text_value.clone());
503 518 }
@@ -505,32 +520,12 @@ impl Studio {
505 520 self.spec.foreground = Foreground::Text(self.text_value.clone());
506 521 }
507 522 } else {
508 − ui.horizontal(|ui| {
509 − ui.label("Search");
510 − ui.text_edit_singleline(&mut self.icon_query);
511 − });
512 − let hits = icons::search(&self.all_icons, &self.icon_query, 60);
513 − egui::ScrollArea::vertical()
514 − .max_height(150.0)
515 − .id_salt("iconlist")
516 − .show(ui, |ui| {
517 − ui.horizontal_wrapped(|ui| {
518 − for e in hits {
519 − let selected =
520 − matches!(self.spec.foreground, Foreground::Icon(c) if c == e.ch);
521 − let btn = ui.selectable_label(
522 − selected,
523 − RichText::new(e.ch.to_string()).size(22.0),
524 − );
525 − if btn.clicked() {
526 − self.spec.foreground = Foreground::Icon(e.ch);
527 − }
528 − btn.on_hover_text(e.name);
529 − }
530 − });
531 − });
523 + self.icon_picker(ui);
532 524 }
533 525
526 + ui.separator();
527 + self.layer_controls(ui);
528 +
534 529 ui.separator();
535 530 ui.horizontal(|ui| {
536 531 ui.label("Sub banner");
@@ -558,14 +553,200 @@ impl Studio {
558 553 }
559 554
560 555 ui.add(egui::Slider::new(&mut self.spec.angle, 0.0..=360.0).text("Angle"));
561 − ui.add(egui::Slider::new(&mut self.spec.scale, 0.2..=0.95).text("Scale"));
562 − ui.checkbox(&mut self.spec.filled, "Filled face");
563 556 ui.checkbox(&mut self.spec.show_guides, "Show safe-zone guides");
564 557
565 558 ui.separator();
566 559 self.export_controls(ui);
567 560 }
568 561
562 + /// Glyph search and grid. Picks for whichever layer is being edited.
563 + fn icon_picker(&mut self, ui: &mut egui::Ui) {
564 + ui.horizontal(|ui| {
565 + ui.label("Search");
566 + ui.text_edit_singleline(&mut self.icon_query);
567 + });
568 + let current = match self.editing_layer {
569 + Some(i) => self.spec.layers.get(i).map(|l| l.glyph),
570 + None => match self.spec.foreground {
571 + Foreground::Icon(c) => Some(c),
572 + Foreground::Text(_) => None,
573 + },
574 + };
575 + let hits = icons::search(&self.all_icons, &self.icon_query, 60);
576 + egui::ScrollArea::vertical()
577 + .max_height(150.0)
578 + .id_salt("iconlist")
579 + .show(ui, |ui| {
580 + ui.horizontal_wrapped(|ui| {
581 + for e in hits {
582 + let btn = ui.selectable_label(
583 + current == Some(e.ch),
584 + RichText::new(e.ch.to_string()).size(22.0),
585 + );
586 + if btn.clicked() {
587 + match self.editing_layer.and_then(|i| self.spec.layers.get_mut(i)) {
588 + Some(layer) => layer.glyph = e.ch,
589 + None => self.spec.foreground = Foreground::Icon(e.ch),
590 + }
591 + }
592 + btn.on_hover_text(e.name);
593 + }
594 + });
595 + });
596 + }
597 +
598 + /// The main mark and the glyphs stacked on its centre, one row each with
599 + /// the same controls, under one overall scale.
600 + fn layer_controls(&mut self, ui: &mut egui::Ui) {
601 + ui.label("Layers");
602 + ui.add(egui::Slider::new(&mut self.spec.scale, 0.2..=0.95).text("Overall scale"))
603 + .on_hover_text("Size of the combined icon; every layer keeps its proportion");
604 +
605 + // Up to twice the overall scale, so a layer can frame the others.
606 + fn size_slider(v: &mut f32) -> egui::Slider<'_> {
607 + egui::Slider::new(v, 0.1..=2.0)
608 + .text("Size")
609 + .fixed_decimals(2)
610 + }
611 +
612 + ui.horizontal(|ui| {
613 + let label = match &self.spec.foreground {
614 + Foreground::Icon(c) => RichText::new(c.to_string()).size(20.0),
615 + Foreground::Text(t) => RichText::new(format!("\"{t}\"")),
616 + };
617 + if ui
618 + .selectable_label(self.editing_layer.is_none(), label)
619 + .on_hover_text("Main mark — click to pick its glyph")
620 + .clicked()
621 + {
622 + self.editing_layer = None;
623 + }
624 + ui.add(size_slider(&mut self.spec.mark_size))
625 + .on_hover_text("Size relative to the overall scale");
626 + ui.checkbox(&mut self.spec.filled, "Filled");
627 + });
628 +
629 + let mut remove = None;
630 + for i in 0..self.spec.layers.len() {
631 + let name = self.icon_name_for(self.spec.layers[i].glyph);
632 + let editing = self.editing_layer == Some(i);
633 + let layer = &mut self.spec.layers[i];
634 + ui.horizontal(|ui| {
635 + let label = RichText::new(layer.glyph.to_string()).size(20.0);
636 + if ui
637 + .selectable_label(editing, label)
638 + .on_hover_text(format!("{name} — click to pick its glyph"))
639 + .clicked()
640 + {
641 + self.editing_layer = Some(i);
642 + }
643 + ui.add(size_slider(&mut layer.size))
644 + .on_hover_text("Size relative to the overall scale");
645 + ui.checkbox(&mut layer.filled, "Filled");
646 + if ui.small_button("✕").on_hover_text("Remove layer").clicked() {
647 + remove = Some(i);
648 + }
649 + });
650 + }
651 + if let Some(i) = remove {
652 + self.spec.layers.remove(i);
653 + self.editing_layer = match self.editing_layer {
654 + Some(e) if e == i => None,
655 + Some(e) if e > i => Some(e - 1),
656 + e => e,
657 + };
658 + }
659 +
660 + self.selected_layer_controls(ui);
661 +
662 + if ui
663 + .button("Add layer")
664 + .on_hover_text("Stack another glyph on the centre of the mark")
665 + .clicked()
666 + {
667 + // Start small enough to sit inside the mark, so the new layer is
668 + // visible straight away rather than hidden under it.
669 + self.spec.layers.push(Layer {
670 + glyph: '\u{e899}', // lock
671 + color: None,
672 + size: 0.45 * self.spec.mark_size,
673 + rotation: 0.0,
674 + offset: [0.0, 0.0],
675 + filled: false,
676 + });
677 + self.editing_layer = Some(self.spec.layers.len() - 1);
678 + self.icon_query = "lock".into();
679 + }
680 + }
681 +
682 + /// Placement and colour for whichever row is selected — the main mark or
683 + /// a layer. Kept out of the rows themselves so the list stays scannable.
684 + fn selected_layer_controls(&mut self, ui: &mut egui::Ui) {
685 + let swatches = self.theme().swatches();
686 + let title = match self.editing_layer {
687 + Some(i) => format!("Layer {}", i + 1),
688 + None => "Main mark".to_string(),
689 + };
690 + let fg = self.spec.fg_color;
691 + let (rotation, offset, color) =
692 + match self.editing_layer.and_then(|i| self.spec.layers.get_mut(i)) {
693 + Some(l) => (
694 + &mut l.rotation,
695 + &mut l.offset,
696 + ColorSlot::Layer(&mut l.color, fg),
697 + ),
698 + None => (
699 + &mut self.spec.mark_rotation,
700 + &mut self.spec.mark_offset,
701 + ColorSlot::Mark(&mut self.spec.fg_color),
702 + ),
703 + };
704 +
705 + ui.group(|ui| {
706 + ui.horizontal(|ui| {
707 + ui.strong(title);
708 + if ui
709 + .small_button("Recentre")
710 + .on_hover_text("Clear rotation and offset")
711 + .clicked()
712 + {
713 + *rotation = 0.0;
714 + *offset = [0.0, 0.0];
715 + }
716 + });
717 + ui.add(
718 + egui::Slider::new(rotation, -180.0..=180.0)
719 + .text("Rotation")
720 + .suffix("°"),
721 + );
722 + // Offsets are in units of the overall scale, so ±1 reaches the
723 + // edge of the combined icon at any size.
724 + ui.add(egui::Slider::new(&mut offset[0], -1.0..=1.0).text("X"));
725 + ui.add(egui::Slider::new(&mut offset[1], -1.0..=1.0).text("Y"))
726 + .on_hover_text("Positive is down");
727 + match color {
728 + ColorSlot::Mark(c) => swatch_picker(ui, &swatches, c),
729 + ColorSlot::Layer(c, fg) => {
730 + let mut follow = c.is_none();
731 + if ui
732 + .checkbox(&mut follow, "Use foreground colour")
733 + .on_hover_text("Follow the Foreground colour, including later changes")
734 + .changed()
735 + {
736 + *c = if follow { None } else { Some(fg) };
737 + }
738 + // Picking a swatch gives the layer its own colour; while
739 + // it follows, the foreground shows as the selection.
740 + let mut shown = c.unwrap_or(fg);
741 + swatch_picker(ui, &swatches, &mut shown);
742 + if c.is_some() || shown != fg {
743 + *c = Some(shown);
744 + }
745 + }
746 + }
747 + });
748 + }
749 +
569 750 fn export_controls(&mut self, ui: &mut egui::Ui) {
570 751 ui.label("Export");
571 752 ui.horizontal(|ui| {
modified src/doc.rs
+102 −8
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
11 11 use serde::{Deserialize, Serialize};
12 12
13 13 use crate::palette::{self, NamedColor};
14 −use crate::spec::{Background, Foreground, IconSpec};
14 +use crate::spec::{Background, Foreground, IconSpec, Layer};
15 15
16 16 /// Bumped only for a breaking change to the file shape. Readers refuse a
17 17 /// version from the future rather than silently misreading it.
@@ -38,6 +38,11 @@ pub struct Document {
38 38 pub export: DocExport,
39 39 #[serde(default)]
40 40 pub grid: DocGrid,
41 + /// Glyphs stacked on the mark. Last, because TOML arrays of tables have to
42 + /// follow every plain table; omitted entirely when there are none, so
43 + /// single-mark documents read exactly as they did before layers existed.
44 + #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 + pub layers: Vec<DocLayer>,
41 46 }
42 47
43 48 #[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
@@ -78,8 +83,67 @@ pub struct DocBackground {
78 83
79 84 #[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
80 85 pub struct DocMark {
86 + /// Overall scale of the mark and its layers.
81 87 pub scale: f64,
82 88 pub filled: bool,
89 + /// The main mark's size relative to `scale`. Left out at 1.0, which is
90 + /// what documents written before layers existed mean.
91 + #[serde(default = "one", skip_serializing_if = "is_one")]
92 + pub size: f64,
93 + /// Degrees clockwise. Left out when zero, as are offsets, so a plain
94 + /// centred mark reads as it always has.
95 + #[serde(default, skip_serializing_if = "is_zero")]
96 + pub rotation: f64,
97 + /// Shift from centre in units of `scale`, `[x, y]`, +y down.
98 + #[serde(default, skip_serializing_if = "is_origin")]
99 + pub offset: [f64; 2],
100 +}
101 +
102 +fn one() -> f64 {
103 + 1.0
104 +}
105 +
106 +fn is_one(v: &f64) -> bool {
107 + *v == 1.0
108 +}
109 +
110 +fn is_zero(v: &f64) -> bool {
111 + *v == 0.0
112 +}
113 +
114 +fn is_origin(v: &[f64; 2]) -> bool {
115 + *v == [0.0, 0.0]
116 +}
117 +
118 +fn tidy2(v: [f32; 2]) -> [f64; 2] {
119 + [tidy(v[0]), tidy(v[1])]
120 +}
121 +
122 +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
123 +pub struct DocLayer {
124 + /// Hex codepoint, as for an icon foreground. Authoritative.
125 + pub codepoint: String,
126 + /// Glyph name, for readers. Ignored on load.
127 + #[serde(default)]
128 + pub name: String,
129 + /// Relative to `mark.scale`, not to the canvas.
130 + pub size: f64,
131 + #[serde(default, skip_serializing_if = "is_zero")]
132 + pub rotation: f64,
133 + #[serde(default, skip_serializing_if = "is_origin")]
134 + pub offset: [f64; 2],
135 + #[serde(default)]
136 + pub filled: bool,
137 + /// Absent means the layer follows the foreground colour. Last, because a
138 + /// TOML sub-table has to follow the layer's plain values.
139 + #[serde(default, skip_serializing_if = "Option::is_none")]
140 + pub color: Option<DocColor>,
141 +}
142 +
143 +fn parse_codepoint(codepoint: &str) -> Result<char, String> {
144 + let n = u32::from_str_radix(codepoint.trim_start_matches("U+").trim(), 16)
145 + .map_err(|_| format!("bad codepoint {codepoint:?}"))?;
146 + char::from_u32(n).ok_or_else(|| format!("codepoint {codepoint:?} is not a character"))
83 147 }
84 148
85 149 /// Widening an f32 to f64 exposes its binary tail — 0.54f32 becomes
@@ -174,6 +238,9 @@ impl Session {
174 238 mark: DocMark {
175 239 scale: tidy(self.spec.scale),
176 240 filled: self.spec.filled,
241 + size: tidy(self.spec.mark_size),
242 + rotation: tidy(self.spec.mark_rotation),
243 + offset: tidy2(self.spec.mark_offset),
177 244 },
178 245 export: DocExport {
179 246 name: self.export_name.clone(),
@@ -188,6 +255,20 @@ impl Session {
188 255 .map(|b| b.id().to_string())
189 256 .collect(),
190 257 },
258 + layers: self
259 + .spec
260 + .layers
261 + .iter()
262 + .map(|l| DocLayer {
263 + codepoint: format!("{:x}", l.glyph as u32),
264 + name: icon_name_for(l.glyph),
265 + size: tidy(l.size),
266 + rotation: tidy(l.rotation),
267 + offset: tidy2(l.offset),
268 + filled: l.filled,
269 + color: l.color.map(color_to_doc),
270 + })
271 + .collect(),
191 272 }
192 273 }
193 274
@@ -202,16 +283,25 @@ impl Session {
202 283 let theme = palette::theme_by_name(&doc.palette);
203 284
204 285 let foreground = match &doc.foreground {
205 − DocForeground::Icon { codepoint, .. } => {
206 − let n = u32::from_str_radix(codepoint.trim_start_matches("U+").trim(), 16)
207 − .map_err(|_| format!("bad codepoint {codepoint:?}"))?;
208 − let ch = char::from_u32(n)
209 − .ok_or_else(|| format!("codepoint {codepoint:?} is not a character"))?;
210 − Foreground::Icon(ch)
211 − }
286 + DocForeground::Icon { codepoint, .. } => Foreground::Icon(parse_codepoint(codepoint)?),
212 287 DocForeground::Text { value } => Foreground::Text(value.clone()),
213 288 };
214 289
290 + let layers = doc
291 + .layers
292 + .iter()
293 + .map(|l| {
294 + Ok(Layer {
295 + glyph: parse_codepoint(&l.codepoint)?,
296 + color: l.color.as_ref().map(|c| color_from_doc(c, theme)),
297 + size: l.size as f32,
298 + rotation: l.rotation as f32,
299 + offset: [l.offset[0] as f32, l.offset[1] as f32],
300 + filled: l.filled,
301 + })
302 + })
303 + .collect::<Result<Vec<_>, String>>()?;
304 +
215 305 let background = Background::from_id(&doc.background.style)
216 306 .ok_or_else(|| format!("unknown background style {:?}", doc.background.style))?;
217 307
@@ -225,8 +315,12 @@ impl Session {
225 315 background,
226 316 angle: doc.background.angle as f32,
227 317 scale: doc.mark.scale as f32,
318 + mark_size: doc.mark.size as f32,
319 + mark_rotation: doc.mark.rotation as f32,
320 + mark_offset: [doc.mark.offset[0] as f32, doc.mark.offset[1] as f32],
228 321 show_guides: doc.show_guides,
229 322 filled: doc.mark.filled,
323 + layers,
230 324 },
231 325 palette: doc.palette.clone(),
232 326 export_name: if doc.export.name.is_empty() {
modified src/render.rs
+123 −78
@@ -5,7 +5,7 @@
5 5 //! means the renderer is testable headlessly, which is how the reconstruction
6 6 //! is checked against the icons the original studio shipped.
7 7
8 −use ab_glyph::{Font, FontRef, Glyph, OutlinedGlyph, Point as GlyphPoint, ScaleFont};
8 +use ab_glyph::{Font, FontRef, OutlineCurve, Point as GlyphPoint, ScaleFont};
9 9 use tiny_skia::{
10 10 Color, FillRule, GradientStop, LinearGradient, Paint, Path, PathBuilder, Pattern, Pixmap,
11 11 PixmapPaint, Point, RadialGradient, Rect, Shader, SpreadMode, Transform,
@@ -289,87 +289,101 @@ fn band_path(s: f32, dx: f32, dy: f32, t: f32, band: f32) -> Option<Path> {
289 289 pb.finish()
290 290 }
291 291
292 −/// Rasterises one glyph or a text run, centred on `(cx, cy)`.
293 −fn draw_text(pm: &mut Pixmap, text: &str, face: Face, px: f32, cx: f32, cy: f32, rgb: [u8; 3]) {
292 +/// Where and how one run lands: centre, pixel size, rotation and colour.
293 +struct Placed {
294 + cx: f32,
295 + cy: f32,
296 + px: f32,
297 + /// Degrees, clockwise on screen.
298 + rotation: f32,
299 + rgb: [u8; 3],
300 +}
301 +
302 +/// The outline of a glyph or text run as one path, in pixels, y down, with the
303 +/// pen starting at the origin.
304 +fn run_path(text: &str, face: Face, px: f32) -> Option<Path> {
294 305 let f = font(face);
295 306 let scaled = f.as_scaled(px);
307 + let (hs, vs) = (scaled.h_scale_factor(), scaled.v_scale_factor());
296 308
297 − // Lay the run out first so it can be centred on its own ink rather than on
298 − // the font's nominal advance, which for Material glyphs is much wider.
309 + let mut pb = PathBuilder::new();
299 310 let mut pen = 0.0f32;
300 − let mut outlines: Vec<OutlinedGlyph> = Vec::new();
301 311 let mut prev: Option<ab_glyph::GlyphId> = None;
302 312 for ch in text.chars() {
303 313 let id = f.glyph_id(ch);
304 314 if let Some(p) = prev {
305 315 pen += scaled.kern(p, id);
306 316 }
307 − let glyph: Glyph = id.with_scale_and_position(px, GlyphPoint { x: pen, y: 0.0 });
308 − pen += scaled.h_advance(id);
309 317 prev = Some(id);
310 − if let Some(o) = f.outline_glyph(glyph) {
311 − outlines.push(o);
312 − }
313 − }
314 − if outlines.is_empty() {
315 − return;
316 − }
317 −
318 − let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
319 − for o in &outlines {
320 − let b = o.px_bounds();
321 − x0 = x0.min(b.min.x);
322 − y0 = y0.min(b.min.y);
323 − x1 = x1.max(b.max.x);
324 − y1 = y1.max(b.max.y);
325 − }
326 − let (ox, oy) = (cx - (x0 + x1) / 2.0, cy - (y0 + y1) / 2.0);
327 −
328 − // Draw into a coverage mask, then composite once, so overlapping glyph
329 − // outlines do not double-darken at their seams.
330 − let w = pm.width();
331 − let h = pm.height();
332 − let mut cov = vec![0f32; (w * h) as usize];
333 − for o in &outlines {
334 − let b = o.px_bounds();
335 − o.draw(|gx, gy, c| {
336 − let x = (b.min.x + ox) as i32 + gx as i32;
337 − let y = (b.min.y + oy) as i32 + gy as i32;
338 − if x >= 0 && y >= 0 && (x as u32) < w && (y as u32) < h {
339 − let i = (y as u32 * w + x as u32) as usize;
340 − cov[i] = cov[i].max(c);
341 − }
342 − });
343 − }
344 −
345 − // Coverage becomes alpha, and the colour is stored premultiplied. Forcing
346 − // alpha to 255 here instead would turn every faintly-covered pixel into
347 − // opaque black, which is exactly what it looks like: a dark box around
348 − // the mark.
349 − let data = pm.pixels_mut();
350 − for (i, &c) in cov.iter().enumerate() {
351 − let a = c.clamp(0.0, 1.0);
352 − if a <= 0.0 {
318 + let origin = pen;
319 + pen += scaled.h_advance(id);
320 + let Some(outline) = f.outline(id) else {
353 321 continue;
354 − }
355 − let prem = |v: u8| (v as f32 * a).round().clamp(0.0, 255.0) as u8;
356 − let alpha = (a * 255.0).round().clamp(0.0, 255.0) as u8;
357 − if let Some(p) = tiny_skia::PremultipliedColorU8::from_rgba(
358 − prem(rgb[0]),
359 − prem(rgb[1]),
360 − prem(rgb[2]),
361 − alpha,
362 − ) {
363 − // Layers start transparent and each pixel is written once, so the
364 − // brighter coverage simply wins where glyphs in a run overlap.
365 − if p.alpha() >= data[i].alpha() {
366 − data[i] = p;
322 + };
323 +
324 + // Font units are y up; flip into image space.
325 + let at = |p: GlyphPoint| (origin + p.x * hs, -p.y * vs);
326 + let mut last: Option<GlyphPoint> = None;
327 + for curve in &outline.curves {
328 + let (start, end) = match *curve {
329 + OutlineCurve::Line(a, b) => (a, b),
330 + OutlineCurve::Quad(a, _, b) => (a, b),
331 + OutlineCurve::Cubic(a, _, _, b) => (a, b),
332 + };
333 + // Curves arrive as loose segments; a break in continuity is the
334 + // start of the next contour.
335 + if last != Some(start) {
336 + let (x, y) = at(start);
337 + pb.move_to(x, y);
338 + }
339 + match *curve {
340 + OutlineCurve::Line(_, b) => {
341 + let (x, y) = at(b);
342 + pb.line_to(x, y);
343 + }
344 + OutlineCurve::Quad(_, c, b) => {
345 + let ((cx, cy), (x, y)) = (at(c), at(b));
346 + pb.quad_to(cx, cy, x, y);
347 + }
348 + OutlineCurve::Cubic(_, c1, c2, b) => {
349 + let ((ax, ay), (bx, by), (x, y)) = (at(c1), at(c2), at(b));
350 + pb.cubic_to(ax, ay, bx, by, x, y);
351 + }
367 352 }
353 + last = Some(end);
368 354 }
369 355 }
356 + pb.finish()
357 +}
358 +
359 +/// Draws one glyph or a text run, centred on its own ink.
360 +///
361 +/// Drawn as vector outlines rather than rasterised coverage so rotation is a
362 +/// transform, not a resample: edges stay as crisp at 30 degrees as at 0. The
363 +/// whole run is one path filled non-zero, so glyphs that overlap within a run
364 +/// merge instead of darkening at their seams.
365 +fn draw_text(pm: &mut Pixmap, text: &str, face: Face, at: &Placed) {
366 + let Some(path) = run_path(text, face, at.px) else {
367 + return;
368 + };
369 + // Centre on the ink rather than the nominal advance, which for Material
370 + // glyphs is much wider than the mark.
371 + let b = path.bounds();
372 + let (bx, by) = ((b.left() + b.right()) / 2.0, (b.top() + b.bottom()) / 2.0);
373 + let transform = Transform::from_translate(-bx, -by)
374 + .post_rotate(at.rotation)
375 + .post_translate(at.cx, at.cy);
376 +
377 + let paint = Paint {
378 + shader: Shader::SolidColor(color_of(at.rgb)),
379 + anti_alias: true,
380 + ..Default::default()
381 + };
382 + pm.fill_path(&path, &paint, FillRule::Winding, transform, None);
370 383 }
371 384
372 −/// Renders the foreground layer: glyph or text, plus the optional sub-banner.
385 +/// Renders the foreground layer: glyph or text, any stacked layers, plus the
386 +/// optional sub-banner.
373 387 pub fn render_foreground(spec: &IconSpec, size: u32) -> Pixmap {
374 388 let mut pm = Pixmap::new(size, size).expect("non-zero size");
375 389 let s = size as f32;
@@ -377,17 +391,29 @@ pub fn render_foreground(spec: &IconSpec, size: u32) -> Pixmap {
377 391
378 392 // With a banner the mark lifts so the two share the safe zone.
379 393 let cy = if has_banner { s * 0.44 } else { s * 0.5 };
380 − let px = s * spec.scale * if has_banner { 0.82 } else { 1.0 };
394 + let lift = if has_banner { 0.82 } else { 1.0 };
395 + // Sizes and offsets are in units of the overall scale, so resizing the
396 + // icon keeps its composition.
397 + let unit = s * spec.scale * lift;
398 + let place = |size: f32, rotation: f32, offset: [f32; 2], rgb: [u8; 3]| Placed {
399 + cx: s / 2.0 + offset[0] * unit,
400 + cy: cy + offset[1] * unit,
401 + px: unit * size,
402 + rotation,
403 + rgb,
404 + };
381 405
382 406 match &spec.foreground {
383 407 Foreground::Icon(ch) => draw_text(
384 408 &mut pm,
385 409 &ch.to_string(),
386 410 Face::icon(spec.filled),
387 − px,
388 − s / 2.0,
389 − cy,
390 − spec.fg_color.rgb,
411 + &place(
412 + spec.mark_size,
413 + spec.mark_rotation,
414 + spec.mark_offset,
415 + spec.fg_color.rgb,
416 + ),
391 417 ),
392 418 Foreground::Text(t) if !t.trim().is_empty() => {
393 419 // Text runs wide, so scale down as it lengthens to stay inside the
@@ -398,24 +424,43 @@ pub fn render_foreground(spec: &IconSpec, size: u32) -> Pixmap {
398 424 &mut pm,
399 425 t,
400 426 Face::text(spec.filled),
401 − px * fit,
402 − s / 2.0,
403 − cy,
404 − spec.fg_color.rgb,
427 + &place(
428 + spec.mark_size * fit,
429 + spec.mark_rotation,
430 + spec.mark_offset,
431 + spec.fg_color.rgb,
432 + ),
405 433 )
406 434 }
407 435 _ => {}
408 436 }
409 437
438 + for layer in &spec.layers {
439 + draw_text(
440 + &mut pm,
441 + &layer.glyph.to_string(),
442 + Face::icon(layer.filled),
443 + &place(
444 + layer.size,
445 + layer.rotation,
446 + layer.offset,
447 + spec.layer_color(layer).rgb,
448 + ),
449 + );
450 + }
451 +
410 452 if has_banner {
411 453 draw_text(
412 454 &mut pm,
413 455 spec.sub_banner.trim(),
414 456 Face::text(true),
415 − s * 0.13,
416 − s / 2.0,
417 − s * 0.72,
418 − spec.fg_color.rgb,
457 + &Placed {
458 + cx: s / 2.0,
459 + cy: s * 0.72,
460 + px: s * 0.13,
461 + rotation: 0.0,
462 + rgb: spec.fg_color.rgb,
463 + },
419 464 );
420 465 }
421 466
modified src/spec.rs
+44 −1
@@ -85,6 +85,27 @@ pub enum Foreground {
85 85 Text(String),
86 86 }
87 87
88 +/// An extra glyph stacked on the main mark, for badge shapes like a lock
89 +/// inside a shield. Layers carry the same controls as the mark itself: `size`,
90 +/// `rotation`, `offset`, `filled` and `color` here correspond to the `mark_*`
91 +/// fields, [`IconSpec::filled`] and [`IconSpec::fg_color`].
92 +#[derive(Clone, Copy, PartialEq, Debug)]
93 +pub struct Layer {
94 + pub glyph: char,
95 + /// `None` follows [`IconSpec::fg_color`], including later changes to it;
96 + /// a layer only keeps a colour of its own once one is picked for it.
97 + pub color: Option<NamedColor>,
98 + /// Size relative to [`IconSpec::scale`], so the overall scale resizes the
99 + /// whole stack without changing its proportions.
100 + pub size: f32,
101 + /// Degrees, clockwise, about the layer's own centre.
102 + pub rotation: f32,
103 + /// Shift from the shared centre, in units of [`IconSpec::scale`]; +y is
104 + /// down.
105 + pub offset: [f32; 2],
106 + pub filled: bool,
107 +}
108 +
88 109 #[derive(Clone, PartialEq, Debug)]
89 110 pub struct IconSpec {
90 111 pub foreground: Foreground,
@@ -98,11 +119,29 @@ pub struct IconSpec {
98 119 /// Rotation of the pattern in degrees; the shipped split runs corner to
99 120 /// corner, which is 45.
100 121 pub angle: f32,
101 − /// Foreground size as a fraction of the canvas.
122 + /// Overall size of the combined mark and layers, as a fraction of the
123 + /// canvas.
102 124 pub scale: f32,
125 + /// The main mark's own size relative to `scale`, like [`Layer::size`].
126 + /// 1.0 for a lone mark, which then fills `scale` exactly as before layers
127 + /// existed.
128 + pub mark_size: f32,
129 + /// The main mark's rotation, like [`Layer::rotation`].
130 + pub mark_rotation: f32,
131 + /// The main mark's shift from centre, like [`Layer::offset`].
132 + pub mark_offset: [f32; 2],
103 133 /// Draw the Android adaptive-icon safe zone and mask guides.
104 134 pub show_guides: bool,
105 135 pub filled: bool,
136 + /// Further glyphs drawn over the main mark, bottom to top.
137 + pub layers: Vec<Layer>,
138 +}
139 +
140 +impl IconSpec {
141 + /// The colour a layer actually draws in.
142 + pub fn layer_color(&self, layer: &Layer) -> NamedColor {
143 + layer.color.unwrap_or(self.fg_color)
144 + }
106 145 }
107 146
108 147 impl Default for IconSpec {
@@ -125,8 +164,12 @@ impl Default for IconSpec {
125 164 background: Background::DiagonalSplit,
126 165 angle: 45.0,
127 166 scale: 0.58,
167 + mark_size: 1.0,
168 + mark_rotation: 0.0,
169 + mark_offset: [0.0, 0.0],
128 170 show_guides: false,
129 171 filled: false,
172 + layers: Vec::new(),
130 173 }
131 174 }
132 175 }
modified tests/documents.rs
+82 −1
@@ -2,7 +2,7 @@
2 2
3 3 use icon_studio::doc::{self, Session};
4 4 use icon_studio::palette::NamedColor;
5 −use icon_studio::spec::{Background, Foreground, IconSpec};
5 +use icon_studio::spec::{Background, Foreground, IconSpec, Layer};
6 6
7 7 fn sample() -> Session {
8 8 Session {
@@ -24,8 +24,22 @@ fn sample() -> Session {
24 24 background: Background::Stripes,
25 25 angle: 30.0,
26 26 scale: 0.54,
27 + mark_size: 0.9,
28 + mark_rotation: -15.0,
29 + mark_offset: [0.0, 0.25],
27 30 show_guides: true,
28 31 filled: true,
32 + layers: vec![Layer {
33 + glyph: '\u{e86c}',
34 + color: Some(NamedColor {
35 + role: "accent",
36 + rgb: [0xAA, 0xD4, 0x00],
37 + }),
38 + size: 0.3,
39 + rotation: 45.0,
40 + offset: [-0.5, 0.125],
41 + filled: false,
42 + }],
29 43 },
30 44 palette: "Casabeza".into(),
31 45 export_name: "ic_icon".into(),
@@ -61,6 +75,73 @@ fn documents_are_toml_not_json() {
61 75 );
62 76 }
63 77
78 +#[test]
79 +fn layers_are_written_as_an_array_of_tables() {
80 + let text = doc::to_toml(&sample().to_document(|_| "lock".into())).unwrap();
81 + assert!(text.contains("[[layers]]"), "{text}");
82 + assert!(text.contains("codepoint = \"e86c\""), "{text}");
83 + assert!(
84 + text.contains("size = 0.3\n"),
85 + "floats should stay tidy:\n{text}"
86 + );
87 +}
88 +
89 +#[test]
90 +fn a_single_mark_document_has_no_layers_section() {
91 + // Older documents and single-mark ones should read exactly as before.
92 + let mut s = sample();
93 + s.spec.layers.clear();
94 + let text = doc::to_toml(&s.to_document(|_| "lock".into())).unwrap();
95 + assert!(!text.contains("layers"), "{text}");
96 + let back = Session::from_document(&doc::from_toml(&text).unwrap()).unwrap();
97 + assert!(back.spec.layers.is_empty());
98 +}
99 +
100 +#[test]
101 +fn a_centred_unrotated_mark_writes_no_placement() {
102 + // rotation and offset only appear once they are used, so presets and
103 + // other plain documents are unchanged by their existence.
104 + let mut s = sample();
105 + s.spec.mark_rotation = 0.0;
106 + s.spec.mark_offset = [0.0, 0.0];
107 + s.spec.layers.clear();
108 + let text = doc::to_toml(&s.to_document(|_| "lock".into())).unwrap();
109 + assert!(!text.contains("rotation"), "{text}");
110 + assert!(!text.contains("offset"), "{text}");
111 +}
112 +
113 +#[test]
114 +fn a_layer_following_the_foreground_writes_no_colour() {
115 + let mut s = sample();
116 + s.spec.layers[0].color = None;
117 + let text = doc::to_toml(&s.to_document(|_| "lock".into())).unwrap();
118 + assert!(!text.contains("[layers.color]"), "{text}");
119 + let back = Session::from_document(&doc::from_toml(&text).unwrap()).unwrap();
120 + assert_eq!(back.spec.layers[0].color, None);
121 + assert_eq!(
122 + back.spec.layer_color(&back.spec.layers[0]),
123 + back.spec.fg_color
124 + );
125 +}
126 +
127 +#[test]
128 +fn a_full_size_mark_writes_no_size() {
129 + // mark.size only appears once it differs from what older documents mean.
130 + let mut s = sample();
131 + s.spec.mark_size = 1.0;
132 + let text = doc::to_toml(&s.to_document(|_| "lock".into())).unwrap();
133 + assert!(!text.contains("\nsize = 1"), "{text}");
134 + let back = Session::from_document(&doc::from_toml(&text).unwrap()).unwrap();
135 + assert_eq!(back.spec.mark_size, 1.0);
136 +}
137 +
138 +#[test]
139 +fn a_bad_layer_codepoint_is_an_error() {
140 + let mut d = sample().to_document(|_| String::new());
141 + d.layers[0].codepoint = "zz".into();
142 + assert!(Session::from_document(&d).unwrap_err().contains("zz"));
143 +}
144 +
64 145 #[test]
65 146 fn a_document_from_the_future_is_refused() {
66 147 let mut d = sample().to_document(|_| String::new());
modified tests/reproduce.rs
+171 −0
@@ -29,6 +29,26 @@ fn casabeza_split() -> IconSpec {
29 29 }
30 30 }
31 31
32 +/// Ink bounds of a foreground: (x0, y0, x1, y1).
33 +fn ink(pm: &tiny_skia::Pixmap) -> (u32, u32, u32, u32) {
34 + let w = pm.width();
35 + let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
36 + for (i, p) in pm.pixels().iter().enumerate() {
37 + if p.alpha() > 128 {
38 + let (x, y) = (i as u32 % w, i as u32 / w);
39 + x0 = x0.min(x);
40 + y0 = y0.min(y);
41 + x1 = x1.max(x);
42 + y1 = y1.max(y);
43 + }
44 + }
45 + (x0, y0, x1, y1)
46 +}
47 +
48 +fn centre(r: (u32, u32, u32, u32)) -> (f32, f32) {
49 + ((r.0 + r.2) as f32 / 2.0, (r.1 + r.3) as f32 / 2.0)
50 +}
51 +
32 52 /// Counts of each fully-opaque colour in the image.
33 53 fn histogram(px: &[u8]) -> std::collections::HashMap<[u8; 3], usize> {
34 54 let mut h = std::collections::HashMap::new();
@@ -145,6 +165,157 @@ fn foreground_is_transparent_outside_the_mark() {
145 165 );
146 166 }
147 167
168 +#[test]
169 +fn stacked_layers_share_the_centre_and_the_overall_scale() {
170 + use icon_studio::spec::{Foreground, Layer};
171 +
172 + // A filled circle alone, then the same circle at half size as the only
173 + // layer over an empty text mark: the layer must land on the same centre,
174 + // and at the size it asks for relative to the overall scale.
175 + let circle = '\u{ef4a}'; // circle
176 + let big = IconSpec {
177 + foreground: Foreground::Icon(circle),
178 + scale: 0.8,
179 + filled: true,
180 + ..casabeza_split()
181 + };
182 + let small = IconSpec {
183 + foreground: Foreground::Text(String::new()),
184 + layers: vec![Layer {
185 + glyph: circle,
186 + color: None,
187 + size: 0.5,
188 + rotation: 0.0,
189 + offset: [0.0, 0.0],
190 + filled: true,
191 + }],
192 + ..big.clone()
193 + };
194 + let (b, s) = (
195 + ink(&render::render_foreground(&big, 432)),
196 + ink(&render::render_foreground(&small, 432)),
197 + );
198 + let (bc, sc) = (centre(b), centre(s));
199 + assert!(
200 + (bc.0 - sc.0).abs() <= 1.0 && (bc.1 - sc.1).abs() <= 1.0,
201 + "{bc:?} vs {sc:?}"
202 + );
203 + let ratio = (s.2 - s.0) as f32 / (b.2 - b.0) as f32;
204 + assert!((ratio - 0.5).abs() < 0.03, "width ratio {ratio}");
205 +
206 + // And stacking both keeps everything the big one had.
207 + let both = IconSpec {
208 + layers: small.layers.clone(),
209 + ..big.clone()
210 + };
211 + assert_eq!(ink(&render::render_foreground(&both, 432)), b);
212 +
213 + // The overall scale resizes the layers with it: halving it halves the
214 + // layer.
215 + let shrunk = IconSpec {
216 + scale: 0.4,
217 + ..small.clone()
218 + };
219 + let h = ink(&render::render_foreground(&shrunk, 432));
220 + let ratio = (h.2 - h.0) as f32 / (s.2 - s.0) as f32;
221 + assert!((ratio - 0.5).abs() < 0.03, "width ratio {ratio}");
222 + let hc = centre(h);
223 + assert!(
224 + (hc.0 - sc.0).abs() <= 1.0 && (hc.1 - sc.1).abs() <= 1.0,
225 + "{hc:?} vs {sc:?}"
226 + );
227 +
228 + // The main mark's own size is the same control as a layer's: a half-size
229 + // mark matches a half-size layer exactly.
230 + let half_mark = IconSpec {
231 + mark_size: 0.5,
232 + ..big.clone()
233 + };
234 + assert_eq!(ink(&render::render_foreground(&half_mark, 432)), s);
235 +}
236 +
237 +#[test]
238 +fn each_layer_rotates_moves_and_colours_on_its_own() {
239 + use icon_studio::spec::{Foreground, Layer};
240 +
241 + const RED: [u8; 3] = [0xE0, 0x20, 0x20];
242 + let bar = '\u{e15b}'; // remove: a wide, flat bar
243 + let flat = IconSpec {
244 + foreground: Foreground::Text(String::new()),
245 + scale: 0.6,
246 + layers: vec![Layer {
247 + glyph: bar,
248 + color: Some(NamedColor {
249 + role: "error",
250 + rgb: RED,
251 + }),
252 + size: 1.0,
253 + rotation: 0.0,
254 + offset: [0.0, 0.0],
255 + filled: true,
256 + }],
257 + ..casabeza_split()
258 + };
259 + let f = ink(&render::render_foreground(&flat, 432));
260 + assert!(f.2 - f.0 > 4 * (f.3 - f.1), "expected a flat bar: {f:?}");
261 +
262 + // Rotation turns it on its own centre: a quarter turn swaps its extent.
263 + let mut turned = flat.clone();
264 + turned.layers[0].rotation = 90.0;
265 + let t = ink(&render::render_foreground(&turned, 432));
266 + assert!(
267 + ((t.3 - t.1) as i32 - (f.2 - f.0) as i32).abs() <= 2,
268 + "{t:?} vs {f:?}"
269 + );
270 + assert!(
271 + ((t.2 - t.0) as i32 - (f.3 - f.1) as i32).abs() <= 2,
272 + "{t:?} vs {f:?}"
273 + );
274 + let (fc, tc) = (centre(f), centre(t));
275 + assert!((fc.0 - tc.0).abs() <= 1.0 && (fc.1 - tc.1).abs() <= 1.0);
276 +
277 + // Offsets are in units of the overall scale: 0.5 here is 0.5 * 0.6 * 432.
278 + let mut moved = flat.clone();
279 + moved.layers[0].offset = [0.5, -0.25];
280 + let mc = centre(ink(&render::render_foreground(&moved, 432)));
281 + let unit = 0.6 * 432.0;
282 + assert!((mc.0 - fc.0 - 0.5 * unit).abs() <= 1.0, "{mc:?}");
283 + assert!((mc.1 - fc.1 + 0.25 * unit).abs() <= 1.0, "{mc:?}");
284 +
285 + // The layer draws in its own colour, not the foreground's.
286 + let pm = render::render_foreground(&flat, 432);
287 + let solid: Vec<[u8; 3]> = pm
288 + .pixels()
289 + .iter()
290 + .filter(|p| p.alpha() == 255)
291 + .map(|p| [p.red(), p.green(), p.blue()])
292 + .collect();
293 + assert!(!solid.is_empty());
294 + assert!(solid.iter().all(|c| *c == RED), "layer should be red");
295 +
296 + // And the main mark's rotation leaves the layer alone.
297 + let mark_turned = IconSpec {
298 + mark_rotation: 90.0,
299 + ..flat.clone()
300 + };
301 + assert_eq!(ink(&render::render_foreground(&mark_turned, 432)), f);
302 +
303 + // Unset, it follows the foreground — including a foreground changed after
304 + // the layer was made.
305 + let mut follows = flat.clone();
306 + follows.layers[0].color = None;
307 + follows.fg_color = NamedColor {
308 + role: "base-100",
309 + rgb: [0x10, 0x60, 0xF0],
310 + };
311 + let pm = render::render_foreground(&follows, 432);
312 + assert!(pm.pixels().iter().filter(|p| p.alpha() == 255).all(|p| [
313 + p.red(),
314 + p.green(),
315 + p.blue()
316 + ] == [0x10, 0x60, 0xF0]));
317 +}
318 +
148 319 #[test]
149 320 fn export_writes_every_density_and_layer() {
150 321 let dir = std::env::temp_dir().join(format!("icon-studio-test-{}", std::process::id()));

Parents: 6ed9d8a