Skip to main content

osp_cli/ui/
resolution.rs

1//! Internal lowering from caller-facing [`crate::ui::RenderSettings`] into
2//! semantic render plans.
3//!
4//! The UI surface intentionally exposes a broad configuration object because
5//! callers need to express product intent in one place. The formatter and
6//! renderer layers should not consume that broad shape directly. This module is
7//! the narrowing seam: it resolves runtime-aware rendering facts and the
8//! guide/MREG-specific lowering knobs once, then downstream code consumes the
9//! smaller resolved plan.
10
11use crate::core::output::{ColorMode, OutputFormat, RenderMode, UnicodeMode};
12use crate::core::output_model::OutputResult;
13use crate::ui::chrome::{RuledSectionPolicy, SectionFrameStyle};
14use crate::ui::theme;
15use crate::ui::{
16    HelpChromeSettings, RenderBackend, RenderSettings, StyleOverrides, TableBorderStyle,
17    TableOverflow, ThemeDefinition,
18};
19
20/// Fully resolved rendering settings used by the document renderer.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ResolvedRenderSettings {
23    /// Concrete renderer backend selected for this render pass.
24    pub backend: RenderBackend,
25    /// Whether ANSI styling is enabled.
26    pub color: bool,
27    /// Whether Unicode rendering is enabled.
28    pub unicode: bool,
29    /// Effective width constraint, if any.
30    pub width: Option<usize>,
31    /// Effective left margin.
32    pub margin: usize,
33    /// Effective indentation width.
34    pub indent_size: usize,
35    /// Effective short-list threshold.
36    pub short_list_max: usize,
37    /// Effective medium-list threshold.
38    pub medium_list_max: usize,
39    /// Effective grid padding.
40    pub grid_padding: usize,
41    /// Effective grid column override.
42    pub grid_columns: Option<usize>,
43    /// Effective adaptive grid weight.
44    pub column_weight: usize,
45    /// Effective table overflow policy.
46    pub table_overflow: TableOverflow,
47    /// Effective general table border style.
48    pub table_border: TableBorderStyle,
49    /// Effective help-table border style.
50    pub help_table_border: TableBorderStyle,
51    /// Effective theme name.
52    pub theme_name: String,
53    /// Effective resolved theme.
54    pub theme: ThemeDefinition,
55    /// Effective style overrides layered over the theme.
56    pub style_overrides: StyleOverrides,
57    /// Effective section frame style.
58    pub chrome_frame: SectionFrameStyle,
59}
60
61/// Internal semantic render plan for one output payload.
62///
63/// This is intentionally broader than [`ResolvedRenderSettings`]: once the UI
64/// has planned a render, downstream formatters should not re-read raw
65/// [`RenderSettings`] to rediscover guide, MREG, or output-format choices.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub(crate) struct ResolvedRenderPlan {
68    /// Final output format selected for this payload.
69    pub(crate) format: OutputFormat,
70    /// Terminal-aware rendering settings used by the renderer.
71    pub(crate) render: ResolvedRenderSettings,
72    /// Guide/help lowering settings derived for this payload.
73    pub(crate) guide: ResolvedGuideRenderSettings,
74    /// MREG lowering settings derived for this payload.
75    pub(crate) mreg: ResolvedMregBuildSettings,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub(crate) struct ResolvedHelpChromeSettings {
80    pub(crate) table_border: TableBorderStyle,
81    pub(crate) entry_indent: Option<usize>,
82    pub(crate) entry_gap: Option<usize>,
83    pub(crate) section_spacing: Option<usize>,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub(crate) struct ResolvedGuideRenderSettings {
88    pub(crate) frame_style: SectionFrameStyle,
89    pub(crate) ruled_section_policy: RuledSectionPolicy,
90    pub(crate) help_chrome: ResolvedHelpChromeSettings,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub(crate) struct ResolvedMregBuildSettings {
95    pub(crate) short_list_max: usize,
96    pub(crate) medium_list_max: usize,
97    pub(crate) indent_size: usize,
98    pub(crate) stack_min_col_width: usize,
99    pub(crate) stack_overflow_ratio: usize,
100}
101
102impl RenderSettings {
103    pub(crate) fn resolve_guide_render_settings(&self) -> ResolvedGuideRenderSettings {
104        ResolvedGuideRenderSettings {
105            frame_style: self.chrome_frame,
106            ruled_section_policy: self.ruled_section_policy,
107            help_chrome: self.help_chrome.resolve(self.table_border),
108        }
109    }
110
111    pub(crate) fn resolve_mreg_build_settings(&self) -> ResolvedMregBuildSettings {
112        ResolvedMregBuildSettings {
113            short_list_max: self.short_list_max.max(1),
114            medium_list_max: self.medium_list_max.max(self.short_list_max.max(1) + 1),
115            indent_size: self.indent_size.max(1),
116            stack_min_col_width: self.mreg_stack_min_col_width.max(1),
117            stack_overflow_ratio: self.mreg_stack_overflow_ratio.max(100),
118        }
119    }
120
121    fn resolve_color_mode(&self) -> bool {
122        match self.color {
123            ColorMode::Always => true,
124            ColorMode::Never => false,
125            ColorMode::Auto => !self.runtime.no_color && self.runtime.stdout_is_tty,
126        }
127    }
128
129    fn resolve_unicode_mode(&self) -> bool {
130        match self.unicode {
131            UnicodeMode::Always => true,
132            UnicodeMode::Never => false,
133            UnicodeMode::Auto => {
134                if !self.runtime.stdout_is_tty {
135                    return false;
136                }
137                if matches!(self.runtime.terminal.as_deref(), Some("dumb")) {
138                    return false;
139                }
140                match self.runtime.locale_utf8 {
141                    Some(true) => true,
142                    Some(false) => false,
143                    None => true,
144                }
145            }
146        }
147    }
148
149    /// Resolves terminal-aware rendering settings from the configured
150    /// preferences.
151    ///
152    /// Plain mode is a strict fallback: once selected, the resolved settings
153    /// will not emit ANSI color or Unicode box-drawing even if the runtime can.
154    ///
155    /// # Examples
156    ///
157    /// ```
158    /// use osp_cli::core::output::{ColorMode, OutputFormat, RenderMode, UnicodeMode};
159    /// use osp_cli::ui::{RenderBackend, RenderSettings};
160    ///
161    /// let mut settings = RenderSettings::test_plain(OutputFormat::Json);
162    /// settings.mode = RenderMode::Auto;
163    /// settings.color = ColorMode::Always;
164    /// settings.unicode = UnicodeMode::Always;
165    ///
166    /// let resolved = settings.resolve_render_settings();
167    ///
168    /// assert_eq!(resolved.backend, RenderBackend::Rich);
169    /// assert!(resolved.color);
170    /// assert!(resolved.unicode);
171    /// ```
172    pub fn resolve_render_settings(&self) -> ResolvedRenderSettings {
173        let backend = match self.mode {
174            RenderMode::Plain => RenderBackend::Plain,
175            RenderMode::Rich => RenderBackend::Rich,
176            RenderMode::Auto => {
177                if matches!(self.color, ColorMode::Always)
178                    || matches!(self.unicode, UnicodeMode::Always)
179                {
180                    RenderBackend::Rich
181                } else if !self.runtime.stdout_is_tty
182                    || matches!(self.runtime.terminal.as_deref(), Some("dumb"))
183                {
184                    RenderBackend::Plain
185                } else {
186                    RenderBackend::Rich
187                }
188            }
189        };
190
191        let theme = self
192            .theme
193            .clone()
194            .unwrap_or_else(|| theme::resolve_theme(&self.theme_name));
195        let theme_name = theme::normalize_theme_name(&theme.id);
196
197        match backend {
198            RenderBackend::Plain => ResolvedRenderSettings {
199                backend,
200                color: false,
201                unicode: false,
202                width: self.resolve_width(),
203                margin: self.margin,
204                indent_size: self.indent_size.max(1),
205                short_list_max: self.short_list_max.max(1),
206                medium_list_max: self.medium_list_max.max(self.short_list_max.max(1) + 1),
207                grid_padding: self.grid_padding.max(1),
208                grid_columns: self.grid_columns.filter(|value| *value > 0),
209                column_weight: self.column_weight.max(1),
210                table_overflow: self.table_overflow,
211                table_border: self.table_border,
212                help_table_border: self.help_chrome.table_chrome.resolve(self.table_border),
213                theme_name,
214                theme: theme.clone(),
215                style_overrides: self.style_overrides.clone(),
216                chrome_frame: self.chrome_frame,
217            },
218            RenderBackend::Rich => ResolvedRenderSettings {
219                backend,
220                color: self.resolve_color_mode(),
221                unicode: self.resolve_unicode_mode(),
222                width: self.resolve_width(),
223                margin: self.margin,
224                indent_size: self.indent_size.max(1),
225                short_list_max: self.short_list_max.max(1),
226                medium_list_max: self.medium_list_max.max(self.short_list_max.max(1) + 1),
227                grid_padding: self.grid_padding.max(1),
228                grid_columns: self.grid_columns.filter(|value| *value > 0),
229                column_weight: self.column_weight.max(1),
230                table_overflow: self.table_overflow,
231                table_border: self.table_border,
232                help_table_border: self.help_chrome.table_chrome.resolve(self.table_border),
233                theme_name,
234                theme,
235                style_overrides: self.style_overrides.clone(),
236                chrome_frame: self.chrome_frame,
237            },
238        }
239    }
240
241    /// Resolves the full UI render plan for one output payload.
242    pub(crate) fn resolve_render_plan(&self, output: &OutputResult) -> ResolvedRenderPlan {
243        ResolvedRenderPlan {
244            format: crate::ui::format::resolve_output_format(output, self),
245            render: self.resolve_render_settings(),
246            guide: self.resolve_guide_render_settings(),
247            mreg: self.resolve_mreg_build_settings(),
248        }
249    }
250
251    fn resolve_width(&self) -> Option<usize> {
252        if let Some(width) = self.width {
253            return (width > 0).then_some(width);
254        }
255        self.runtime.width.filter(|width| *width > 0)
256    }
257
258    pub(crate) fn plain_copy_settings(&self) -> Self {
259        Self {
260            format: self.format,
261            format_explicit: self.format_explicit,
262            mode: RenderMode::Plain,
263            color: ColorMode::Never,
264            unicode: UnicodeMode::Never,
265            width: self.width,
266            margin: self.margin,
267            indent_size: self.indent_size,
268            short_list_max: self.short_list_max,
269            medium_list_max: self.medium_list_max,
270            grid_padding: self.grid_padding,
271            grid_columns: self.grid_columns,
272            column_weight: self.column_weight,
273            table_overflow: self.table_overflow,
274            table_border: self.table_border,
275            help_chrome: self.help_chrome,
276            mreg_stack_min_col_width: self.mreg_stack_min_col_width,
277            mreg_stack_overflow_ratio: self.mreg_stack_overflow_ratio,
278            theme_name: self.theme_name.clone(),
279            theme: self.theme.clone(),
280            style_overrides: self.style_overrides.clone(),
281            chrome_frame: self.chrome_frame,
282            ruled_section_policy: self.ruled_section_policy,
283            guide_default_format: self.guide_default_format,
284            runtime: self.runtime.clone(),
285        }
286    }
287}
288
289impl HelpChromeSettings {
290    pub(crate) fn resolve(self, table_border: TableBorderStyle) -> ResolvedHelpChromeSettings {
291        ResolvedHelpChromeSettings {
292            table_border: self.table_chrome.resolve(table_border),
293            entry_indent: self.entry_indent,
294            entry_gap: self.entry_gap,
295            section_spacing: self.section_spacing,
296        }
297    }
298}
299
300impl ResolvedGuideRenderSettings {
301    #[cfg(test)]
302    pub(crate) fn plain_help(
303        frame_style: SectionFrameStyle,
304        table_border: TableBorderStyle,
305    ) -> Self {
306        Self {
307            frame_style,
308            ruled_section_policy: RuledSectionPolicy::PerSection,
309            help_chrome: HelpChromeSettings {
310                table_chrome: match table_border {
311                    TableBorderStyle::None => crate::ui::HelpTableChrome::None,
312                    TableBorderStyle::Square => crate::ui::HelpTableChrome::Square,
313                    TableBorderStyle::Round => crate::ui::HelpTableChrome::Round,
314                },
315                ..HelpChromeSettings::default()
316            }
317            .resolve(table_border),
318        }
319    }
320}