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}