rust_widgets 2.9.2

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! Host-registered icons: a name and its outline, added to the built-in set at runtime.
//!
//! # The gap this closes
//!
//! [`IconName`](crate::widget::IconName) is a closed enum generated from
//! `tools/icon_tokens.txt` — a *fixed* set. That is the right shape for the crate's own icons
//! (a compile-time table, no lookup cost, no missing-icon surprise), but it leaves a host with no
//! way to draw **its own** icon: an application-specific glyph, a brand mark, a product icon set
//! the crate does not ship.
//!
//! This module is that way in. [`register_icon`] binds a token to SVG path data, and
//! [`Icon::set_icon`](crate::widget::Icon::set_icon) — which already accepts any string — draws the
//! registered outline for a name no `IconName` variant covers.
//!
//! # Why this is separate from `IconName`
//!
//! The two answer different questions and must not be conflated:
//!
//! * **`IconName`** is the crate's *published vocabulary*. A variant existing is a promise that the
//!   icon ships and is tested (the census covers every variant). It must stay a closed set, or that
//!   promise has no meaning.
//! * **The registry** is the *host's extension point*. It makes no promise about what is
//!   registered, only about how it is looked up and drawn.
//!
//! Folding host icons into `IconName` would need a runtime-sized enum, which is what the crate's
//! zero-cost-abstraction rule (principle #28) exists to avoid — and would make `ALL` unanswerable.
//!
//! # Why this is worth having when the icon set is already 68 deep
//!
//! `docs/plans/research-icons.md` §2 rejects an *icon font* partly because a glyph cannot be cached
//! the way static geometry can. That reasoning applies to the crate's **own** set (which is why it
//! is bundled path data) and not to this: a registered icon carries the same `&'static str` path
//! data as a bundled one, so it goes through the identical draw path. What the registry adds is
//! *reach* — a host's own icons — for the cost of one small table.

use crate::widget::display_widgets::icon::{IconPaintError, IconRef};
use crate::widget::display_widgets::icon_data_set::{
    lookup_registered, register_icon_data, register_icon_data_with_density,
};

/// How many host icons may be registered at once.
///
/// A fixed capacity, like the runtime font registry: the lookup runs per draw, from threads that
/// hold no lock, and a growable table behind a lock would put that lock on the draw path. Sixteen
/// is a UI's worth of application-specific glyphs (a title bar and a toolbar) without making the
/// per-lookup copy large.
pub const MAX_REGISTERED_ICONS: usize = 16;

/// Register `paths` as the outline for `name`, replacing any earlier registration of that name.
///
/// Returns `true` when the icon was accepted, `false` when the registry is full or an argument is
/// empty. A `false` is a refusal, not a silent drop: a host that registered an icon and got `false`
/// must not believe its icon is drawable.
///
/// # The path data
///
/// `paths` is SVG path data (`d`) on the same 960-unit design grid as the built-in icons, with
/// **negative `y` upward** — see [`IconData`](crate::widget::IconData). A host can take that
/// straight from a Material Symbols file, or from any source that uses that grid; a different grid
/// will draw at the wrong scale, because the grid is a property of the data and this signature
/// does not carry it. `register_icon_on_grid` exists for a source on another grid.
///
/// # Why the name is `&'static str`
///
/// The same reason the font registry takes `&'static [u8]`: the lookup runs per draw and must not
/// allocate or lock. A `String` would force one or the other. A host that has a runtime name can
/// `Box::leak` it — an explicit, one-time cost.
///
/// # Example
///
/// ```no_run
/// use rust_widgets::widget::register_icon;
///
/// // A 960-grid outline with negative y upward, as Material Symbols files use.
/// // The path table must be `&'static [&'static str]` (see "Why the name is `&'static str`"),
/// // so it is a `static` rather than a local binding.
/// static TRIANGLE: [&str; 1] = ["M480-200 240-440l56-56 184 184 184-184 56 56-240 240Z"];
/// assert!(register_icon("disclosure", &TRIANGLE));
/// ```
pub fn register_icon(name: &'static str, paths: &'static [&'static str]) -> bool {
    try_register_icon(name, paths).is_ok()
}

/// [`register_icon`], for a source on a grid other than the built-in 960 units.
///
/// A host that draws from a 24-unit viewBox (Lucide, Tabler, Feather) passes `24` here rather than
/// rescaling the path, so the data stays the source's own numbers.
pub fn register_icon_on_grid(
    name: &'static str,
    paths: &'static [&'static str],
    grid: u16,
) -> bool {
    try_register_icon_on_grid(name, paths, grid).is_ok()
}

/// Register `paths` as the source for `name` at a specific pixel `density`.
///
/// # Why a name can have more than one source
///
/// A vector outline is resolution-independent, but a host icon need not be: an application
/// may ship a 16 px bitmap or a hand-tuned `@2x` outline next to the ordinary one. Both are
/// registered under the **same name** and differ only in density, so the name stays the
/// identity a control uses and the density remains an attribute of the stored source. The
/// icon widget selects the best source for its configured scale factor through
/// `lookup_registered_scaled`; a source registered only at `@2x` still draws at a normal
/// scale rather than disappearing.
///
/// `density` below `1` is treated as `1` (a source cannot be less dense than the logical
/// grid). Re-registration replaces the entry for `(name, density)` and leaves the other
/// densities of the same name in place, so adding a high-density source never removes the
/// ordinary one.
///
/// Returns `false` when the registry is full, or when the name or path data is invalid.
/// Use [`try_register_icon_at_density`] when the caller needs the reason.
pub fn register_icon_at_density(
    name: &'static str,
    paths: &'static [&'static str],
    density: u8,
) -> bool {
    try_register_icon_at_density(name, paths, density).is_ok()
}

/// [`register_icon_at_density`], reporting **why** a registration was refused.
///
/// The validation is shared with [`try_register_icon`], so a density variant is accepted
/// exactly when an ordinary registration would be.
pub fn try_register_icon_at_density(
    name: &'static str,
    paths: &'static [&'static str],
    density: u8,
) -> Result<(), IconRegistrationError> {
    try_register_icon_on_grid_at_density(name, paths, 960, density)
}

/// [`register_icon_at_density`], for a source on a grid other than the built-in 960 units.
pub fn try_register_icon_on_grid_at_density(
    name: &'static str,
    paths: &'static [&'static str],
    grid: u16,
    density: u8,
) -> Result<(), IconRegistrationError> {
    validate_registration(name, paths, grid)?;
    validate_drawable(name, paths, grid)?;
    // Capacity only refuses a **new** name; a re-registration (of either the same name or
    // the same name+density) replaces in place, so it is checked after that case is
    // excluded rather than before.
    if !is_registered_icon(name) && registered_icon_count() >= MAX_REGISTERED_ICONS {
        return Err(IconRegistrationError::RegistryFull { capacity: MAX_REGISTERED_ICONS });
    }
    if register_icon_data_with_density(name, paths, grid, density) {
        Ok(())
    } else {
        // The storage refuses only when the table is full and this is not a replacement.
        // Reported as a capacity refusal rather than `unreachable!()` because this is a
        // public entry point.
        Err(IconRegistrationError::RegistryFull { capacity: MAX_REGISTERED_ICONS })
    }
}

/// Checks everything a registration must satisfy before it occupies a slot.
///
/// Shared by the ordinary, on-grid and density entry points so the three cannot accept
/// different path data.
fn validate_registration(
    name: &str,
    paths: &[&'static str],
    grid: u16,
) -> Result<(), IconRegistrationError> {
    use IconRegistrationError as Refused;

    if name.is_empty() {
        return Err(Refused::EmptyName);
    }
    if paths.is_empty() {
        return Err(Refused::NoPaths);
    }
    if grid == 0 {
        return Err(Refused::ZeroGrid);
    }
    if paths.iter().any(|d| d.trim().is_empty()) {
        return Err(Refused::BlankPath);
    }
    Ok(())
}

/// Refuses geometry the paint path could not draw.
///
/// The validation goes through the same validator a draw uses, so "accepted" and "will
/// draw" cannot diverge — in either entry point.
fn validate_drawable(
    name: &'static str,
    paths: &'static [&'static str],
    grid: u16,
) -> Result<(), IconRegistrationError> {
    let data = crate::widget::IconData { name, grid, paths };
    crate::widget::display_widgets::icon::validate_icon(IconRef::StaticGeometry(&data))
        .map(|_| ())
        .map_err(IconRegistrationError::Undrawable)
}

/// Why a host icon registration was refused.
///
/// # Why this exists alongside the `bool` return
///
/// `bool` cannot say *which* of half a dozen reasons applied, and a host that registered a
/// broken icon needs the reason: "the registry is full" and "your path data does not parse" call
/// for completely different responses, and the old `false` conflated them with "your name was
/// empty". The `bool` wrappers are unchanged and delegate here, so both spellings answer from one
/// implementation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IconRegistrationError {
    /// The token was empty, so nothing could name the registration.
    EmptyName,
    /// No path data was supplied, so there would be nothing to draw.
    NoPaths,
    /// The design grid is zero, so no device scale can be derived from it.
    ZeroGrid,
    /// A path entry was blank. An empty `d` draws nothing, which is indistinguishable on screen
    /// from a missing icon, so it is refused rather than stored.
    BlankPath,
    /// The registry is full and this name is not already registered.
    ///
    /// A **re-registration** of an existing name is never refused by capacity: it replaces in
    /// place, which is why the name is reported here.
    RegistryFull {
        /// How many icons the registry holds at once.
        capacity: usize,
    },
    /// The geometry could not be drawn. The reason comes from the crate's own source validator, so
    /// a registration is refused exactly when a draw would fail.
    Undrawable(IconPaintError),
}

/// [`register_icon`], reporting **why** a registration was refused.
///
/// # Declared behaviour change
///
/// The geometry is now validated before it is stored, so a registration whose path data cannot be
/// drawn is refused instead of being stored and then silently drawing nothing every frame. This
/// tightens what [`register_icon`] accepts — a host that registered unparseable data used to get
/// `true` and a blank icon, and now gets `false` and a reason. The doc comment on
/// [`register_icon`] always claimed a `false` means "not drawable"; this is what makes that true.
/// A name that is already registered is still replaced, so capacity is not a limit for updates.
pub fn try_register_icon(
    name: &'static str,
    paths: &'static [&'static str],
) -> Result<(), IconRegistrationError> {
    try_register_icon_on_grid(name, paths, 960)
}

/// [`register_icon_on_grid`], reporting **why** a registration was refused.
///
/// See [`try_register_icon`] for the declared behaviour change.
pub fn try_register_icon_on_grid(
    name: &'static str,
    paths: &'static [&'static str],
    grid: u16,
) -> Result<(), IconRegistrationError> {
    use IconRegistrationError as Refused;

    validate_registration(name, paths, grid)?;
    // Validate before storing, through the same validator the paint path uses, so "accepted" and
    // "will draw" cannot diverge.
    validate_drawable(name, paths, grid)?;
    // Capacity only refuses a **new** name; a re-registration replaces in place, so it is checked
    // after the replacement case is excluded rather than before.
    if !is_registered_icon(name) && registered_icon_count() >= MAX_REGISTERED_ICONS {
        return Err(Refused::RegistryFull { capacity: MAX_REGISTERED_ICONS });
    }
    if register_icon_data(name, paths, grid) {
        Ok(())
    } else {
        // Unreachable while the capacity check above and the storage check agree; reported as a
        // capacity refusal rather than `unreachable!()` because this is a public entry point.
        Err(Refused::RegistryFull { capacity: MAX_REGISTERED_ICONS })
    }
}

/// Forget every registered icon. The built-in set is unaffected.
///
/// Returns how many were removed, so the effect is observable rather than something a test has to
/// infer.
pub fn clear_registered_icons() -> usize {
    crate::widget::display_widgets::icon_data_set::clear_registered_icon_data()
}

/// How many host icons are currently registered.
pub fn registered_icon_count() -> usize {
    crate::widget::display_widgets::icon_data_set::registered_icon_data_count()
}

/// Whether `name` is a registered host icon (as opposed to a built-in [`IconName`] token).
///
/// A caller uses this to tell "my icon is registered" from "the crate happens to ship this name",
/// which matters when deciding whether a missing icon is a registration bug.
///
/// [`IconName`]: crate::widget::IconName
pub fn is_registered_icon(name: &str) -> bool {
    lookup_registered(name).is_some()
}

/// A registered icon's outline, or `None` when the name is not registered.
///
/// Exposed so a test can assert what was stored without rendering, and so a host can check its own
/// registration without a draw.
pub fn registered_icon(name: &str) -> Option<RegisteredIcon> {
    lookup_registered(name).map(|data| RegisteredIcon {
        name: data.name,
        grid: data.grid,
        paths: data.paths,
    })
}

/// A host-registered icon, as [`registered_icon`] returns it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RegisteredIcon {
    /// The token the host registered.
    pub name: &'static str,
    /// The design grid its path data is on.
    pub grid: u16,
    /// The SVG path data, one entry per `<path>`.
    pub paths: &'static [&'static str],
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The registry is process-global, so one test owns the whole sequence to avoid interleaving.
    #[test]
    fn registration_refuses_junk_and_honours_capacity_and_replacement() {
        clear_registered_icons();
        assert_eq!(registered_icon_count(), 0);

        // Junk is refused: an empty name, empty paths, an empty `d`, a zero grid.
        assert!(!register_icon("", &["M0 0Z"]));
        assert!(!register_icon("empty", &[]));
        assert!(!register_icon("blank", &["   "]));
        assert!(!register_icon_on_grid("zerogrid", &["M0 0Z"], 0));
        assert_eq!(registered_icon_count(), 0, "a refused icon must not occupy a slot");

        assert!(register_icon("mine", &["M0 0L10 0L10 10Z"]));
        assert!(is_registered_icon("mine"));
        assert!(registered_icon("mine").is_some());
        assert_eq!(registered_icon("mine").map(|icon| icon.grid), Some(960));

        // Replacement, not duplication: the same name twice is one entry with the newer data.
        assert!(register_icon_on_grid("mine", &["M0 0L20 0L20 20Z"], 24));
        assert_eq!(registered_icon_count(), 1, "a same-named registration replaces");
        let stored = registered_icon("mine").expect("registered");
        assert_eq!(stored.grid, 24, "the replacement's grid wins");
        assert_eq!(stored.paths, &["M0 0L20 0L20 20Z"]);

        // ── Density variants share the name and are selected by scale ──
        //
        // A `@2x` source is a second slot under the *same* name, so a control still looks
        // the icon up by one token. Selecting it is the icon widget's scale factor's job;
        // what this asserts is that the two sources are both stored and distinguishable.
        assert!(register_icon_at_density("mine", &["M0 0L40 0L40 40Z"], 2));
        assert_eq!(registered_icon_count(), 2, "a @2x source is its own slot");
        let ordinary =
            crate::widget::display_widgets::icon_data_set::lookup_registered_scaled("mine", 1.0)
                .expect("the density-1 source");
        let high =
            crate::widget::display_widgets::icon_data_set::lookup_registered_scaled("mine", 2.0)
                .expect("the density-2 source");
        assert_eq!(ordinary.paths, &["M0 0L20 0L20 20Z"], "1x keeps the ordinary source");
        assert_eq!(high.paths, &["M0 0L40 0L40 40Z"], "2x selects the denser source");
        // The same validation applies to a density registration.
        assert_eq!(
            try_register_icon_at_density("mine", &["M0 0"], 2),
            Err(IconRegistrationError::Undrawable(
                crate::widget::display_widgets::icon::IconPaintError::EmptyGeometry
            ))
        );

        // Capacity: the two occupied slots plus `MAX - 2` more fill the table, and the next
        // is refused.
        for index in 2..MAX_REGISTERED_ICONS {
            let name: &'static str = Box::leak(format!("icon{index}").into_boxed_str());
            assert!(register_icon(name, &["M0 0L1 0L1 1Z"]), "slot {index} must be accepted");
        }
        assert_eq!(registered_icon_count(), MAX_REGISTERED_ICONS);
        assert!(
            !register_icon("overflow", &["M0 0L1 0L1 1Z"]),
            "a full registry must refuse, not evict"
        );

        // ── The `try_` pair says **which** reason applied ──
        //
        // The legacy `bool` conflated "the registry is full" with "your name was empty" and "your
        // path data does not parse", and a host cannot react to those the same way. The names below
        // are new, so the capacity assertions above stay meaningful.
        assert!(
            try_register_icon("mine", &["M0 0L1 0L1 1Z"]).is_ok(),
            "re-registering an *existing* name replaces in place, so a full registry still accepts it"
        );
        assert_eq!(
            try_register_icon("overflow-new", &["M0 0L1 0L1 1Z"]),
            Err(IconRegistrationError::RegistryFull { capacity: MAX_REGISTERED_ICONS })
        );
        assert_eq!(
            try_register_icon("", &["M0 0L1 0L1 1Z"]),
            Err(IconRegistrationError::EmptyName)
        );
        assert_eq!(try_register_icon("x", &[]), Err(IconRegistrationError::NoPaths));
        assert_eq!(
            try_register_icon_on_grid("x", &["M0 0L1 0L1 1Z"], 0),
            Err(IconRegistrationError::ZeroGrid)
        );
        assert_eq!(try_register_icon("x", &["   "]), Err(IconRegistrationError::BlankPath));

        // Geometry that cannot be drawn is refused **with the validator's own reason**, instead of
        // being stored and then silently drawing nothing every frame.
        assert!(matches!(
            try_register_icon("broken", &["M0 0L oops"]),
            Err(IconRegistrationError::Undrawable(
                crate::widget::display_widgets::icon::IconPaintError::InvalidGeometry(_)
            ))
        ));
        // `M0 0` is a legitimate `d` that declares no contour, which is a different refusal from
        // `BlankPath` ("nothing was typed") -- the distinction `bool` could not make.
        assert!(matches!(
            try_register_icon("no-contour", &["M0 0"]),
            Err(IconRegistrationError::Undrawable(
                crate::widget::display_widgets::icon::IconPaintError::EmptyGeometry
            ))
        ));
        assert_eq!(
            registered_icon_count(),
            MAX_REGISTERED_ICONS,
            "a refused registration must not occupy a slot"
        );

        assert_eq!(clear_registered_icons(), MAX_REGISTERED_ICONS);
        assert_eq!(registered_icon_count(), 0);
        assert!(!is_registered_icon("mine"), "clearing removes the lookups too");
    }
}