Skip to main content

retroglyph_window/
diagnostics.rs

1//! Shared render-time glyph diagnostics, called identically by every graphical backend so a
2//! consumer sees the same message regardless of which one it built with.
3//!
4//! [`DiagnosticLog`](crate::diagnostics::DiagnosticLog) is the state a renderer carries: one
5//! field per backend instead of one `BTreeSet<char>` per diagnostic, so "which diagnostics does a
6//! windowed renderer emit" is answered by this struct's methods rather than by grepping every
7//! backend for `warned_*` fields.
8//! [`DiagnosticLog::notdef_glyph`](crate::diagnostics::DiagnosticLog::notdef_glyph) is not gated
9//! behind the `tilesets` feature: it fires from plain [`FontChain`](crate::font::FontChain)
10//! resolution, which has nothing to do with sprites and is reachable with no other feature
11//! enabled. The sprite-only diagnostics
12//! ([`sprite_needs_span`](crate::diagnostics::DiagnosticLog::sprite_needs_span),
13//! [`tint_needs_sprite`](crate::diagnostics::DiagnosticLog::tint_needs_sprite)) are.
14
15use retroglyph_core::dev_only;
16use std::collections::BTreeSet;
17
18#[cfg(feature = "tilesets")]
19use crate::sprite_cache::{warn_sprite_needs_span, warn_tint_needs_sprite};
20#[cfg(feature = "tilesets")]
21use retroglyph_core::color::Tint;
22
23/// The dedup state behind every render-time diagnostic a windowed renderer can emit.
24///
25/// Each graphical backend holds one `DiagnosticLog` field (`Default`-constructed) instead of a
26/// separate `BTreeSet<char>` per diagnostic, so the diagnostics a backend emits are exactly this
27/// struct's methods, not something a reader has to reconstruct by finding every `warned_*` field
28/// and the free function it feeds. The methods here are thin wrappers over the free functions in
29/// this module and in [`sprite_cache`](crate::sprite_cache), which remain the implementation and
30/// stay public for any caller that wants the dedup set itself rather than one owned alongside the
31/// others.
32///
33/// Constructing a `DiagnosticLog` never allocates: every set starts empty, and
34/// [`BTreeSet::default`] does not allocate until its first insert, so a release build (where every
35/// method's body is compiled out by [`dev_only!`]) pays nothing for carrying this field.
36#[derive(Debug, Default)]
37pub struct DiagnosticLog {
38    notdef: BTreeSet<char>,
39    #[cfg(feature = "tilesets")]
40    oversized: BTreeSet<char>,
41    #[cfg(feature = "tilesets")]
42    dropped_tint: BTreeSet<char>,
43}
44
45impl DiagnosticLog {
46    /// Reports `ch` if it resolved to the fallback notdef glyph rather than its own, at most once
47    /// per character. See [`warn_notdef_glyph`] for what this means and why it is worth a
48    /// diagnostic.
49    pub fn notdef_glyph(&mut self, ch: char) -> bool {
50        warn_notdef_glyph(&mut self.notdef, ch)
51    }
52
53    /// Reports `glyph`'s sprite as drawn without a span reserving the cells it covers, at most
54    /// once per glyph. See [`warn_sprite_needs_span`] for the full contract.
55    #[cfg(feature = "tilesets")]
56    pub fn sprite_needs_span(&mut self, glyph: char, sprite: (u32, u32), cell: (u32, u32)) -> bool {
57        warn_sprite_needs_span(&mut self.oversized, glyph, sprite, cell)
58    }
59
60    /// Reports `glyph`'s tint as dropped because it resolved to a bitmap font glyph rather than a
61    /// sprite, at most once per glyph. See [`warn_tint_needs_sprite`] for the full contract.
62    #[cfg(feature = "tilesets")]
63    pub fn tint_needs_sprite(&mut self, glyph: char, tint: Tint) -> bool {
64        warn_tint_needs_sprite(&mut self.dropped_tint, glyph, tint)
65    }
66
67    /// Whether [`notdef_glyph`](Self::notdef_glyph) has already reported `ch`.
68    ///
69    /// For a backend's own test suite to assert the dedup behavior directly; production code only
70    /// ever needs `notdef_glyph`'s own return value.
71    #[must_use]
72    pub fn has_reported_notdef(&self, ch: char) -> bool {
73        self.notdef.contains(&ch)
74    }
75
76    /// Whether [`tint_needs_sprite`](Self::tint_needs_sprite) has already reported `glyph`.
77    ///
78    /// For a backend's own test suite to assert the dedup behavior directly; production code only
79    /// ever needs `tint_needs_sprite`'s own return value.
80    #[cfg(feature = "tilesets")]
81    #[must_use]
82    pub fn has_reported_dropped_tint(&self, glyph: char) -> bool {
83        self.dropped_tint.contains(&glyph)
84    }
85
86    /// How many distinct glyphs [`tint_needs_sprite`](Self::tint_needs_sprite) has reported so
87    /// far.
88    ///
89    /// For a backend's own test suite to assert dedup collapses repeat reports to one; production
90    /// code has no use for the count.
91    #[cfg(feature = "tilesets")]
92    #[must_use]
93    pub fn dropped_tint_report_count(&self) -> usize {
94        self.dropped_tint.len()
95    }
96}
97
98/// Warns, at most once per character, that `ch` did not resolve to its own glyph anywhere in the
99/// chain.
100///
101/// No font covered it, so [`FontChain::resolve`](crate::font::FontChain::resolve) substituted the
102/// solid block (or, for a chain built entirely from
103/// [`BitmapFont::with_charset`](crate::font::BitmapFont::with_charset) fonts, some other font's
104/// own notdef box) instead of `ch`'s real shape. That substitute is a legitimate cell on its own
105/// (a full block is a valid glyph to draw on purpose), so nothing about the rendered output
106/// distinguishes "this glyph is missing" from "this glyph is a block" without a diagnostic naming
107/// which one happened (crates-lurey-io/retroglyph#1292). Call this from the branch that already
108/// knows a resolved glyph was flagged
109/// [`ResolvedGlyph::is_notdef`](crate::font::ResolvedGlyph::is_notdef); both graphical backends
110/// do, right after their own resolve call, so the diagnostic and the character it names are
111/// identical on each.
112///
113/// `seen` is caller-owned state so a redraw loop reports each offending character once rather
114/// than every frame; entries are only ever added.
115///
116/// Returns whether a warning was emitted, which is always `false` in a build that compiles
117/// diagnostics out: the `seen` bookkeeping and the message both sit inside
118/// [`dev_only!`](retroglyph_core::dev_only), so a release build does neither. See
119/// [`BuildMode`](retroglyph_core::dev::BuildMode).
120pub fn warn_notdef_glyph(seen: &mut BTreeSet<char>, ch: char) -> bool {
121    dev_only!({
122        if !seen.insert(ch) {
123            return false;
124        }
125        #[allow(clippy::cast_lossless)]
126        let cp = ch as u32;
127        log::warn!(
128            "no font in the chain covers {ch:?} (U+{cp:04X}); it rendered as the substituted \
129             solid block instead of its own glyph"
130        );
131        return true;
132    });
133    false
134}
135
136#[cfg(test)]
137mod tests {
138    use super::warn_notdef_glyph;
139    use std::collections::BTreeSet;
140
141    #[test]
142    fn warns_once_per_char() {
143        let mut seen = BTreeSet::new();
144        assert_eq!(
145            warn_notdef_glyph(&mut seen, 'あ'),
146            retroglyph_core::dev::DEV
147        );
148        // Second call for the same character is always silent, dev build or not.
149        assert!(!warn_notdef_glyph(&mut seen, 'あ'));
150    }
151
152    #[test]
153    fn distinct_characters_each_warn_once() {
154        let mut seen = BTreeSet::new();
155        assert_eq!(
156            warn_notdef_glyph(&mut seen, 'あ'),
157            retroglyph_core::dev::DEV
158        );
159        assert_eq!(
160            warn_notdef_glyph(&mut seen, '\u{2603}'),
161            retroglyph_core::dev::DEV
162        );
163    }
164}