Skip to main content

ascii_dag/render/engine/
config.rs

1//! Render options, sorted into their three homes.
2//!
3//! The normative rule: **an option that could invalidate a scene is a
4//! planning option.** [`PlanOptions`] holds everything that affects
5//! resolved semantics; [`EmitOptions`] holds how those semantics are
6//! written out (never invalidates a scene); [`ComposeBudget`] holds
7//! memory behavior only (may never affect output). [`RenderOptions`]
8//! is the composite convenience carrying one of each — it keeps the
9//! 0.10 name and presets, so one-step call sites keep compiling with a
10//! field-path change (`opts.charset` → `opts.emit.charset`).
11//!
12//! Everything here is `Copy`, `const`-constructible, `no_std`-safe.
13//! The named presets (`plain`, `colored`, `ascii`, `ascii_colored`)
14//! live in `presets.rs` per the growth-by-addition rule. The
15//! extensible structs are `#[non_exhaustive]`; each implements
16//! `Default` plus complete `with_*` builders, which together with the
17//! presets are the only downstream construction paths.
18
19use super::charset::Charset;
20use super::color::ColorMode;
21use super::style::{
22    EdgeLabelStyleFn, EdgeStyleFn, SubgraphStyleFn, default_edge_label_style, default_edge_style,
23    default_subgraph_style,
24};
25use crate::render::colors::Palette;
26
27/// Default cap on band height in rows (level-aligned bands split when
28/// they would exceed this). Typical bands are 3–15 rows, so the
29/// default never splits a level except pathological ones; embedded
30/// callers lower it to bound canvas memory (`width × cap` cells).
31pub const DEFAULT_BAND_ROWS: usize = 64;
32
33/// Where an edge label may be placed inline.
34///
35/// This is a whole-render **policy** — distinct from the per-edge
36/// [`LabelPlacement`](super::style::LabelPlacement) style, which picks
37/// a position along one edge. Until 0.11 this policy was implicit:
38/// the node-row veto silently switched on when color AND the legend
39/// were both enabled. It is now an explicit, freely combinable choice.
40#[non_exhaustive]
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum LabelPlacementPolicy {
43    /// Standard geometric placement (the 0.10 plain/ascii semantics).
44    #[default]
45    Geometric,
46    /// Additionally veto rows that host nodes (the 0.10
47    /// colored-with-legend semantics). Permanent first-class policy —
48    /// 0.10 preset parity requires it indefinitely.
49    AvoidNodeRows,
50}
51
52/// What happens to a label that found no inline position.
53#[non_exhaustive]
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum LabelOverflow {
56    /// Unplaced labels appear nowhere (a diagnostic still fires — the
57    /// output is never silently lossy).
58    #[default]
59    Omit,
60    /// Unplaced labels are recorded in the plan's legend list. Whether
61    /// that list is *printed* is emission
62    /// ([`EmitOptions::render_legend`]).
63    Legend,
64}
65
66/// The label-handling pair: inline placement policy + overflow
67/// destination.
68#[non_exhaustive]
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub struct LabelPolicy {
71    /// Where labels may sit inline.
72    pub placement: LabelPlacementPolicy,
73    /// Where unplaceable labels go.
74    pub overflow: LabelOverflow,
75}
76
77impl LabelPolicy {
78    /// The const baseline — identical to `Default::default()`, but
79    /// usable in const contexts (embedded callers build options at
80    /// compile time; `#[non_exhaustive]` rules out literals).
81    pub const fn new() -> Self {
82        Self {
83            placement: LabelPlacementPolicy::Geometric,
84            overflow: LabelOverflow::Omit,
85        }
86    }
87
88    /// Builder: set the inline placement policy.
89    pub const fn with_placement(mut self, placement: LabelPlacementPolicy) -> Self {
90        self.placement = placement;
91        self
92    }
93
94    /// Builder: set the overflow destination.
95    pub const fn with_overflow(mut self, overflow: LabelOverflow) -> Self {
96        self.overflow = overflow;
97        self
98    }
99}
100
101/// Planning options: everything that affects **resolved semantics**.
102/// Changing any field here invalidates a plan/scene.
103///
104/// There is deliberately no color on/off flag: planning always
105/// resolves colors (cheap), and plain emission simply ignores them —
106/// that is what lets one plan emit colored and plain output alike.
107#[non_exhaustive]
108#[derive(Clone, Copy)]
109pub struct PlanOptions {
110    /// Edge color palette used by the default style (modulo
111    /// assignment, legacy behavior). Ignored when an edge style fn
112    /// returns an explicit color.
113    pub palette: Palette,
114    /// Per-edge style callback.
115    pub edge_style_fn: EdgeStyleFn,
116    /// Per-subgraph style callback.
117    pub subgraph_style_fn: SubgraphStyleFn,
118    /// Per-edge-label style callback.
119    pub edge_label_style_fn: EdgeLabelStyleFn,
120    /// Label placement + overflow handling.
121    pub label_policy: LabelPolicy,
122    /// Paint dummy nodes (`◍`) when the IR contains them.
123    pub show_dummy_nodes: bool,
124}
125
126impl PlanOptions {
127    /// The const baseline — identical to `Default::default()`, but
128    /// usable in const contexts (embedded callers build options at
129    /// compile time; `#[non_exhaustive]` rules out literals).
130    pub const fn new() -> Self {
131        Self {
132            palette: Palette::Ansi,
133            edge_style_fn: default_edge_style,
134            subgraph_style_fn: default_subgraph_style,
135            edge_label_style_fn: default_edge_label_style,
136            label_policy: LabelPolicy::new(),
137            show_dummy_nodes: false,
138        }
139    }
140
141    /// Builder: set the default-style palette.
142    pub const fn with_palette(mut self, palette: Palette) -> Self {
143        self.palette = palette;
144        self
145    }
146
147    /// Builder: set the per-edge style callback.
148    pub const fn with_edge_style_fn(mut self, f: EdgeStyleFn) -> Self {
149        self.edge_style_fn = f;
150        self
151    }
152
153    /// Builder: set the per-subgraph style callback.
154    pub const fn with_subgraph_style_fn(mut self, f: SubgraphStyleFn) -> Self {
155        self.subgraph_style_fn = f;
156        self
157    }
158
159    /// Builder: set the per-edge-label style callback.
160    pub const fn with_edge_label_style_fn(mut self, f: EdgeLabelStyleFn) -> Self {
161        self.edge_label_style_fn = f;
162        self
163    }
164
165    /// Builder: set the label placement + overflow policy.
166    pub const fn with_label_policy(mut self, policy: LabelPolicy) -> Self {
167        self.label_policy = policy;
168        self
169    }
170
171    /// Builder: paint dummy nodes when the IR contains them.
172    pub const fn with_show_dummy_nodes(mut self, show: bool) -> Self {
173        self.show_dummy_nodes = show;
174        self
175    }
176}
177
178impl Default for PlanOptions {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184/// Emission options: how resolved semantics are **written**. Never
185/// invalidates a plan/scene — the same plan serves every combination
186/// here.
187#[non_exhaustive]
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub struct EmitOptions {
190    /// Output character set (decode table applied at emission).
191    pub charset: Charset,
192    /// Color output mode. `None` allocates no color planes at all.
193    pub color_mode: ColorMode,
194    /// Print the legend block after the diagram. The legend's CONTENT
195    /// is planning ([`LabelOverflow::Legend`]); whether the block is
196    /// printed is emission — that split is what makes "same plan,
197    /// legend on/off" possible.
198    pub render_legend: bool,
199}
200
201impl EmitOptions {
202    /// The const baseline — identical to `Default::default()`, but
203    /// usable in const contexts (embedded callers build options at
204    /// compile time; `#[non_exhaustive]` rules out literals).
205    pub const fn new() -> Self {
206        Self {
207            charset: Charset::Unicode,
208            color_mode: ColorMode::None,
209            render_legend: false,
210        }
211    }
212
213    /// Builder: set the output character set.
214    pub const fn with_charset(mut self, charset: Charset) -> Self {
215        self.charset = charset;
216        self
217    }
218
219    /// Builder: set the color output mode.
220    pub const fn with_color_mode(mut self, mode: ColorMode) -> Self {
221        self.color_mode = mode;
222        self
223    }
224
225    /// Builder: print (or suppress) the legend block.
226    pub const fn with_render_legend(mut self, render: bool) -> Self {
227        self.render_legend = render;
228        self
229    }
230}
231
232impl Default for EmitOptions {
233    fn default() -> Self {
234        Self::new()
235    }
236}
237
238/// Composition resources: **memory behavior only**. May never affect
239/// output; not part of plan/scene identity.
240#[non_exhaustive]
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub struct ComposeBudget {
243    /// Band height cap in rows (clamped to ≥ 1; see
244    /// [`DEFAULT_BAND_ROWS`]). Canvas memory is `width × cap` cells.
245    pub band_rows_cap: usize,
246}
247
248impl ComposeBudget {
249    /// The const baseline — identical to `Default::default()`, but
250    /// usable in const contexts (embedded callers build options at
251    /// compile time; `#[non_exhaustive]` rules out literals).
252    pub const fn new() -> Self {
253        Self {
254            band_rows_cap: DEFAULT_BAND_ROWS,
255        }
256    }
257
258    /// Builder: cap band height in rows.
259    pub const fn with_band_rows_cap(mut self, cap: usize) -> Self {
260        self.band_rows_cap = cap;
261        self
262    }
263
264    /// The effective band cap (degenerate configs clamp, never error).
265    pub(crate) fn cap(&self) -> usize {
266        self.band_rows_cap.max(1)
267    }
268}
269
270impl Default for ComposeBudget {
271    fn default() -> Self {
272        Self::new()
273    }
274}
275
276/// The composite render configuration: one option set per home.
277///
278/// Start from a preset and adjust — every field is public.
279///
280/// ```
281/// use ascii_dag::{Charset, Graph, RenderOptions};
282///
283/// let g = Graph::from_edges(&[(1, "A"), (2, "B")], &[(1, 2)]);
284/// let ir = g.compute_layout();
285///
286/// use ascii_dag::LabelOverflow;
287///
288/// let mut options = RenderOptions::plain();
289/// options.emit.charset = Charset::Ascii;   // no box-drawing glyphs
290/// options.plan.label_policy.overflow = LabelOverflow::Legend; // collect overflow…
291/// options.emit.render_legend = true;       // …and print the legend block
292/// options.compose.band_rows_cap = 16;      // cap canvas memory
293///
294/// let text = ir.render_string(&options);
295/// assert!(text.contains("[A]"));
296/// ```
297///
298/// Presets: [`plain`](Self::plain), [`colored`](Self::colored),
299/// [`ascii`](Self::ascii), [`ascii_colored`](Self::ascii_colored).
300#[derive(Clone, Copy)]
301pub struct RenderOptions {
302    /// Resolved-semantics options (invalidate a plan when changed).
303    pub plan: PlanOptions,
304    /// Output-writing options (never invalidate a plan).
305    pub emit: EmitOptions,
306    /// Memory-behavior knobs (never affect output).
307    pub compose: ComposeBudget,
308}
309
310impl RenderOptions {
311    /// Every-field default: plain Unicode, no colors, geometric labels
312    /// that omit on overflow, default band cap, default style fns.
313    /// Named presets live in `presets.rs`.
314    pub(crate) const fn defaults() -> Self {
315        Self {
316            plan: PlanOptions::new(),
317            emit: EmitOptions::new(),
318            compose: ComposeBudget::new(),
319        }
320    }
321}
322
323impl Default for RenderOptions {
324    fn default() -> Self {
325        Self::plain()
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    // Presets must stay const-constructible.
334    const _PLAIN: RenderOptions = RenderOptions::plain();
335    const _COLORED: RenderOptions = RenderOptions::colored(Palette::Ansi);
336
337    #[test]
338    fn presets_map_the_0_10_semantics() {
339        let plain = RenderOptions::plain();
340        let colored = RenderOptions::colored(Palette::Ansi);
341        assert_eq!(plain.emit.color_mode, ColorMode::None);
342        assert_eq!(colored.emit.color_mode, ColorMode::Ansi256);
343        // The 0.10 implicit `colored && legend` pair is now explicit:
344        // plain places geometrically and omits overflow; the colored
345        // preset avoids node rows, records overflow in the legend, and
346        // prints the legend block.
347        assert_eq!(
348            plain.plan.label_policy.placement,
349            LabelPlacementPolicy::Geometric
350        );
351        assert_eq!(plain.plan.label_policy.overflow, LabelOverflow::Omit);
352        assert!(!plain.emit.render_legend);
353        assert_eq!(
354            colored.plan.label_policy.placement,
355            LabelPlacementPolicy::AvoidNodeRows
356        );
357        assert_eq!(colored.plan.label_policy.overflow, LabelOverflow::Legend);
358        assert!(colored.emit.render_legend);
359
360        let mut o = RenderOptions::plain();
361        o.compose.band_rows_cap = 0;
362        assert_eq!(o.compose.cap(), 1);
363    }
364
365    // The public const baselines must stay const-constructible and
366    // equal to Default.
367    const _PLAN: PlanOptions = PlanOptions::new();
368    const _EMIT: EmitOptions = EmitOptions::new();
369    const _COMPOSE: ComposeBudget = ComposeBudget::new();
370    const _POLICY: LabelPolicy = LabelPolicy::new();
371
372    #[test]
373    fn builders_cover_every_field() {
374        let plan = PlanOptions::default()
375            .with_palette(Palette::Ansi)
376            .with_edge_style_fn(super::default_edge_style)
377            .with_subgraph_style_fn(super::default_subgraph_style)
378            .with_edge_label_style_fn(super::default_edge_label_style)
379            .with_label_policy(
380                LabelPolicy::default()
381                    .with_placement(LabelPlacementPolicy::AvoidNodeRows)
382                    .with_overflow(LabelOverflow::Legend),
383            )
384            .with_show_dummy_nodes(true);
385        assert!(plan.show_dummy_nodes);
386        assert_eq!(
387            plan.label_policy.placement,
388            LabelPlacementPolicy::AvoidNodeRows
389        );
390
391        let emit = EmitOptions::default()
392            .with_charset(Charset::Ascii)
393            .with_color_mode(ColorMode::Ansi256)
394            .with_render_legend(true);
395        assert_eq!(emit.charset, Charset::Ascii);
396        assert!(emit.render_legend);
397
398        let compose = ComposeBudget::default().with_band_rows_cap(4);
399        assert_eq!(compose.band_rows_cap, 4);
400    }
401}