neiam
/icon-studio
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
multilayer
6811748 · Gabriel Morell · 2026-09-28 18:27
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