1use 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
23fn 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#[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 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#[derive(Debug, Clone, Copy)]
109pub struct MinReadableSize {
110 pub min_cols: u32,
112 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 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#[derive(Debug, Clone, Copy, Default)]
180pub struct TerminalAccessible {
181 pub min_readable: MinReadableSize,
183}
184
185impl TerminalAccessible {
186 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
209pub enum BreakpointTier {
210 Required,
212 Advisory,
214 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
230pub struct TerminalBreakpoint {
231 pub name: String,
233 pub cols: u32,
235 pub rows: u32,
237 pub tier: BreakpointTier,
239}
240
241impl TerminalBreakpoint {
242 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 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#[derive(Debug, Clone)]
272pub struct TerminalBreakpointSet {
273 breakpoints: Vec<TerminalBreakpoint>,
274}
275
276impl TerminalBreakpointSet {
277 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 pub fn breakpoints(&self) -> &[TerminalBreakpoint] {
304 &self.breakpoints
305 }
306
307 pub fn with_breakpoint(mut self, bp: TerminalBreakpoint) -> Self {
309 self.breakpoints.push(bp);
310 self
311 }
312
313 pub fn required(&self) -> Vec<&TerminalBreakpoint> {
315 self.breakpoints
316 .iter()
317 .filter(|bp| bp.tier == BreakpointTier::Required)
318 .collect()
319 }
320
321 pub fn advisory(&self) -> Vec<&TerminalBreakpoint> {
323 self.breakpoints
324 .iter()
325 .filter(|bp| bp.tier == BreakpointTier::Advisory)
326 .collect()
327 }
328
329 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub enum BreakpointOutcome {
363 Pass,
365 Fail,
367 Warning,
369 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#[derive(Debug, Clone)]
400pub struct BreakpointResult {
401 pub breakpoint: TerminalBreakpoint,
403 pub outcome: BreakpointOutcome,
405 pub verification: ConstraintVerification,
407}
408
409#[derive(Debug, Clone)]
411pub struct BreakpointReport {
412 pub results: Vec<BreakpointResult>,
414}
415
416impl BreakpointReport {
417 pub fn is_valid(&self) -> bool {
419 self.results
420 .iter()
421 .all(|r| r.outcome != BreakpointOutcome::Fail)
422 }
423
424 pub fn count(&self, outcome: BreakpointOutcome) -> usize {
426 self.results.iter().filter(|r| r.outcome == outcome).count()
427 }
428
429 pub fn failures(&self) -> Vec<&BreakpointResult> {
431 self.results
432 .iter()
433 .filter(|r| r.outcome == BreakpointOutcome::Fail)
434 .collect()
435 }
436
437 pub fn warnings(&self) -> Vec<&BreakpointResult> {
439 self.results
440 .iter()
441 .filter(|r| r.outcome == BreakpointOutcome::Warning)
442 .collect()
443 }
444
445 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}