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}