Skip to main content

elicit_ui/constraints/
terminal.rs

1//! Terminal-specific constraint implementations.
2//!
3//! Constraints for cell-based terminal UIs where dimensions are measured
4//! in character cells (columns × rows) rather than pixels.
5//!
6//! - [`TerminalNoOverflow`] — WCAG 1.4.10 adapted for cell viewports
7//! - [`MinReadableSize`] — minimum pane dimensions for usability
8//! - [`TerminalAccessible`] — convenience builder combining terminal + WCAG constraints
9//! - [`TerminalBreakpoint`] — named terminal size (cols × rows)
10//! - [`TerminalBreakpointSet`] — industry-standard terminal sizes
11//! - [`BreakpointReport`] — verification results at each breakpoint
12
13use super::{
14    Constraint, ConstraintContext, ConstraintSet, ConstraintSetBuilder, ConstraintVerification,
15    SpecReference, Violation, WcagLevel,
16};
17use super::{HasLabelConstraint, KeyboardAccessibleConstraint, ValidRoleConstraint};
18use crate::Viewport;
19use accesskit::NodeId;
20use accesskit::Role;
21use std::collections::BTreeMap;
22
23/// Container-like roles whose children contribute to layout size.
24fn is_container_role(role: Role) -> bool {
25    matches!(
26        role,
27        Role::Window
28            | Role::Group
29            | Role::GenericContainer
30            | Role::List
31            | Role::Table
32            | Role::TabList
33            | Role::MenuBar
34            | Role::Menu
35            | Role::Toolbar
36            | Role::Dialog
37            | Role::Application
38            | Role::Form
39            | Role::Grid
40            | Role::TreeGrid
41            | Role::Tree
42    )
43}
44
45/// WCAG 1.4.10 (Reflow) adapted for cell-based terminal viewports.
46///
47/// Checks that each node's bounds fit within the terminal viewport measured
48/// in columns × rows. Cell geometry: 1 cell = 1 unit in AccessKit bounds.
49///
50/// Unlike the pixel-based [`super::NoOverflowConstraint`], this constraint
51/// uses the viewport as a cell grid (e.g., 80×24 for VT100).
52#[derive(Debug, Clone, Copy)]
53pub struct TerminalNoOverflow;
54
55impl Constraint for TerminalNoOverflow {
56    #[tracing::instrument(level = "debug", skip(self, ctx))]
57    fn check(&self, node_id: NodeId, ctx: &ConstraintContext<'_>) -> Result<(), Violation> {
58        let node = match ctx.nodes.get(&node_id) {
59            Some(n) => n,
60            None => return Ok(()),
61        };
62
63        if let Some(bounds) = node.bounds() {
64            let col = bounds.x0 as i32;
65            let row = bounds.y0 as i32;
66            let width = bounds.width() as u32;
67            let height = bounds.height() as u32;
68
69            // Viewport dimensions are terminal cells (cols × rows)
70            let fits_cols = col >= 0 && (col as u32 + width) <= ctx.viewport.width;
71            let fits_rows = row >= 0 && (row as u32 + height) <= ctx.viewport.height;
72
73            if fits_cols && fits_rows {
74                Ok(())
75            } else {
76                Err(Violation::TerminalOverflow {
77                    element: crate::ElementId::from(node_id),
78                    element_col: col,
79                    element_row: row,
80                    element_cols: width,
81                    element_rows: height,
82                    viewport_cols: ctx.viewport.width,
83                    viewport_rows: ctx.viewport.height,
84                })
85            }
86        } else {
87            Ok(())
88        }
89    }
90
91    fn spec_ref(&self) -> SpecReference {
92        SpecReference::Wcag {
93            criterion: "1.4.10",
94            level: WcagLevel::AA,
95            url: "https://www.w3.org/WAI/WCAG22/Understanding/reflow",
96        }
97    }
98}
99
100/// Minimum readable size constraint for terminal panes.
101///
102/// Ensures every container pane has at least `min_rows` × `min_cols` to be
103/// usable. Default thresholds: 3 rows × 10 columns (enough for a label,
104/// a one-line input, and a status line).
105///
106/// Anchored to ISO 9241-3 (visual display requirements) and common usability
107/// heuristics for terminal applications.
108#[derive(Debug, Clone, Copy)]
109pub struct MinReadableSize {
110    /// Minimum columns required for a pane.
111    pub min_cols: u32,
112    /// Minimum rows required for a pane.
113    pub min_rows: u32,
114}
115
116impl Default for MinReadableSize {
117    fn default() -> Self {
118        Self {
119            min_cols: 10,
120            min_rows: 3,
121        }
122    }
123}
124
125impl Constraint for MinReadableSize {
126    #[tracing::instrument(level = "debug", skip(self, ctx))]
127    fn check(&self, node_id: NodeId, ctx: &ConstraintContext<'_>) -> Result<(), Violation> {
128        let node = match ctx.nodes.get(&node_id) {
129            Some(n) => n,
130            None => return Ok(()),
131        };
132
133        // Only check container-like roles (panes, groups, windows)
134        if !is_container_role(node.role()) {
135            return Ok(());
136        }
137
138        if let Some(bounds) = node.bounds() {
139            let cols = bounds.width() as u32;
140            let rows = bounds.height() as u32;
141
142            if cols >= self.min_cols && rows >= self.min_rows {
143                Ok(())
144            } else {
145                Err(Violation::BelowMinReadableSize {
146                    element: crate::ElementId::from(node_id),
147                    actual_cols: cols,
148                    actual_rows: rows,
149                    min_cols: self.min_cols,
150                    min_rows: self.min_rows,
151                })
152            }
153        } else {
154            Ok(())
155        }
156    }
157
158    fn spec_ref(&self) -> SpecReference {
159        SpecReference::Iso {
160            standard: "ISO 9241-3",
161            section: "Visual display requirements — minimum readable area",
162        }
163    }
164}
165
166/// Convenience builder for terminal-accessible constraint sets.
167///
168/// Combines WCAG accessibility constraints with terminal-specific constraints:
169/// - Hard: `HasLabel`, `ValidRole`, `KeyboardAccessible`, `TerminalNoOverflow`
170/// - Hard: `MinReadableSize` (default 3×10)
171///
172/// # Example
173///
174/// ```rust
175/// use elicit_ui::TerminalAccessible;
176///
177/// let constraint_set = TerminalAccessible::default().to_constraint_set();
178/// ```
179#[derive(Debug, Clone, Copy, Default)]
180pub struct TerminalAccessible {
181    /// Minimum readable size constraint settings.
182    pub min_readable: MinReadableSize,
183}
184
185impl TerminalAccessible {
186    /// Create with custom minimum readable size thresholds.
187    pub fn with_min_readable(min_cols: u32, min_rows: u32) -> Self {
188        Self {
189            min_readable: MinReadableSize { min_cols, min_rows },
190        }
191    }
192
193    /// Convert to a [`ConstraintSet`] with all terminal + WCAG constraints.
194    pub fn to_constraint_set(&self) -> ConstraintSet {
195        ConstraintSetBuilder::default()
196            .hard(HasLabelConstraint)
197            .hard(ValidRoleConstraint)
198            .hard(KeyboardAccessibleConstraint)
199            .hard(TerminalNoOverflow)
200            .hard(self.min_readable)
201            .build()
202    }
203}
204
205/// Enforcement tier for a terminal breakpoint.
206///
207/// Controls how verification failures at this size are reported.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
209pub enum BreakpointTier {
210    /// Must pass — layout is invalid if it fails at this size.
211    Required,
212    /// Warning only — informational, does not block validity.
213    Advisory,
214    /// Expected to fail — documents known limitations at extreme sizes.
215    ExpectedFail,
216}
217
218impl std::fmt::Display for BreakpointTier {
219    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        match self {
221            Self::Required => write!(f, "required"),
222            Self::Advisory => write!(f, "advisory"),
223            Self::ExpectedFail => write!(f, "expected-fail"),
224        }
225    }
226}
227
228/// A named terminal size breakpoint (cols × rows).
229#[derive(Debug, Clone, PartialEq, Eq, Hash)]
230pub struct TerminalBreakpoint {
231    /// Human-readable name (e.g., "VT100", "small", "ultrawide").
232    pub name: String,
233    /// Width in columns.
234    pub cols: u32,
235    /// Height in rows.
236    pub rows: u32,
237    /// Enforcement tier.
238    pub tier: BreakpointTier,
239}
240
241impl TerminalBreakpoint {
242    /// Create a new terminal breakpoint.
243    pub fn new(name: impl Into<String>, cols: u32, rows: u32, tier: BreakpointTier) -> Self {
244        Self {
245            name: name.into(),
246            cols,
247            rows,
248            tier,
249        }
250    }
251
252    /// Convert to a [`Viewport`] for constraint verification.
253    pub fn to_viewport(&self) -> Viewport {
254        Viewport::new(self.cols, self.rows)
255    }
256}
257
258impl std::fmt::Display for TerminalBreakpoint {
259    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260        write!(
261            f,
262            "{} ({}×{}, {})",
263            self.name, self.cols, self.rows, self.tier
264        )
265    }
266}
267
268/// A set of terminal breakpoints for multi-size verification.
269///
270/// Industry-standard terminal sizes from VT100 to ultrawide monitors.
271#[derive(Debug, Clone)]
272pub struct TerminalBreakpointSet {
273    breakpoints: Vec<TerminalBreakpoint>,
274}
275
276impl TerminalBreakpointSet {
277    /// Standard terminal breakpoint set covering industry sizes.
278    ///
279    /// | Name       | Size    | Tier         |
280    /// |------------|---------|--------------|
281    /// | micro      | 40×12   | ExpectedFail |
282    /// | tiny       | 60×20   | Advisory     |
283    /// | VT100      | 80×24   | Required     |
284    /// | small      | 100×30  | Required     |
285    /// | medium     | 120×40  | Required     |
286    /// | large      | 160×50  | Required     |
287    /// | ultrawide  | 200×60  | Required     |
288    pub fn standard() -> Self {
289        Self {
290            breakpoints: vec![
291                TerminalBreakpoint::new("micro", 40, 12, BreakpointTier::ExpectedFail),
292                TerminalBreakpoint::new("tiny", 60, 20, BreakpointTier::Advisory),
293                TerminalBreakpoint::new("VT100", 80, 24, BreakpointTier::Required),
294                TerminalBreakpoint::new("small", 100, 30, BreakpointTier::Required),
295                TerminalBreakpoint::new("medium", 120, 40, BreakpointTier::Required),
296                TerminalBreakpoint::new("large", 160, 50, BreakpointTier::Required),
297                TerminalBreakpoint::new("ultrawide", 200, 60, BreakpointTier::Required),
298            ],
299        }
300    }
301
302    /// Get all breakpoints.
303    pub fn breakpoints(&self) -> &[TerminalBreakpoint] {
304        &self.breakpoints
305    }
306
307    /// Add a custom breakpoint.
308    pub fn with_breakpoint(mut self, bp: TerminalBreakpoint) -> Self {
309        self.breakpoints.push(bp);
310        self
311    }
312
313    /// Get only required-tier breakpoints.
314    pub fn required(&self) -> Vec<&TerminalBreakpoint> {
315        self.breakpoints
316            .iter()
317            .filter(|bp| bp.tier == BreakpointTier::Required)
318            .collect()
319    }
320
321    /// Get only advisory-tier breakpoints.
322    pub fn advisory(&self) -> Vec<&TerminalBreakpoint> {
323        self.breakpoints
324            .iter()
325            .filter(|bp| bp.tier == BreakpointTier::Advisory)
326            .collect()
327    }
328
329    /// Verify a tree against all breakpoints, producing a [`BreakpointReport`].
330    ///
331    /// Runs the given constraint set at each breakpoint's viewport size.
332    #[tracing::instrument(skip(self, constraint_set, nodes))]
333    pub fn verify_all(
334        &self,
335        root: NodeId,
336        nodes: &BTreeMap<NodeId, accesskit::Node>,
337        constraint_set: &ConstraintSet,
338    ) -> BreakpointReport {
339        let mut results = Vec::with_capacity(self.breakpoints.len());
340
341        for bp in &self.breakpoints {
342            let ctx = ConstraintContext {
343                nodes,
344                viewport: bp.to_viewport(),
345            };
346            let verification = constraint_set.verify(root, &ctx);
347            let outcome = BreakpointOutcome::from_verification(&verification, bp.tier);
348
349            results.push(BreakpointResult {
350                breakpoint: bp.clone(),
351                outcome,
352                verification,
353            });
354        }
355
356        BreakpointReport { results }
357    }
358}
359
360/// Outcome of verifying at a single breakpoint.
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub enum BreakpointOutcome {
363    /// All constraints passed.
364    Pass,
365    /// Hard constraints failed at a required breakpoint.
366    Fail,
367    /// Hard constraints failed at an advisory breakpoint (warning only).
368    Warning,
369    /// Hard constraints failed at an expected-fail breakpoint (documented).
370    ExpectedFailure,
371}
372
373impl BreakpointOutcome {
374    fn from_verification(v: &ConstraintVerification, tier: BreakpointTier) -> Self {
375        if v.is_valid() {
376            Self::Pass
377        } else {
378            match tier {
379                BreakpointTier::Required => Self::Fail,
380                BreakpointTier::Advisory => Self::Warning,
381                BreakpointTier::ExpectedFail => Self::ExpectedFailure,
382            }
383        }
384    }
385}
386
387impl std::fmt::Display for BreakpointOutcome {
388    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389        match self {
390            Self::Pass => write!(f, "✅ pass"),
391            Self::Fail => write!(f, "❌ fail"),
392            Self::Warning => write!(f, "⚠️ warning"),
393            Self::ExpectedFailure => write!(f, "📝 expected-fail"),
394        }
395    }
396}
397
398/// Result of verifying at a single breakpoint.
399#[derive(Debug, Clone)]
400pub struct BreakpointResult {
401    /// The breakpoint that was tested.
402    pub breakpoint: TerminalBreakpoint,
403    /// Overall outcome.
404    pub outcome: BreakpointOutcome,
405    /// Full verification details.
406    pub verification: ConstraintVerification,
407}
408
409/// Report of verification across all terminal breakpoints.
410#[derive(Debug, Clone)]
411pub struct BreakpointReport {
412    /// Per-breakpoint results.
413    pub results: Vec<BreakpointResult>,
414}
415
416impl BreakpointReport {
417    /// Whether all required breakpoints pass.
418    pub fn is_valid(&self) -> bool {
419        self.results
420            .iter()
421            .all(|r| r.outcome != BreakpointOutcome::Fail)
422    }
423
424    /// Count of results by outcome.
425    pub fn count(&self, outcome: BreakpointOutcome) -> usize {
426        self.results.iter().filter(|r| r.outcome == outcome).count()
427    }
428
429    /// Get results that failed at required breakpoints.
430    pub fn failures(&self) -> Vec<&BreakpointResult> {
431        self.results
432            .iter()
433            .filter(|r| r.outcome == BreakpointOutcome::Fail)
434            .collect()
435    }
436
437    /// Get results that warned at advisory breakpoints.
438    pub fn warnings(&self) -> Vec<&BreakpointResult> {
439        self.results
440            .iter()
441            .filter(|r| r.outcome == BreakpointOutcome::Warning)
442            .collect()
443    }
444
445    /// Format a summary table of all breakpoint results.
446    pub fn summary(&self) -> String {
447        let mut out = String::from("Terminal Breakpoint Report\n");
448        out.push_str("─────────────────────────────────────────\n");
449        for r in &self.results {
450            out.push_str(&format!(
451                "{:<12} {:>3}×{:<3} [{}] {}\n",
452                r.breakpoint.name,
453                r.breakpoint.cols,
454                r.breakpoint.rows,
455                r.breakpoint.tier,
456                r.outcome,
457            ));
458        }
459        out.push_str("─────────────────────────────────────────\n");
460        out.push_str(&format!(
461            "Result: {} ({} pass, {} fail, {} warn, {} expected-fail)\n",
462            if self.is_valid() { "PASS" } else { "FAIL" },
463            self.count(BreakpointOutcome::Pass),
464            self.count(BreakpointOutcome::Fail),
465            self.count(BreakpointOutcome::Warning),
466            self.count(BreakpointOutcome::ExpectedFailure),
467        ));
468        out
469    }
470}