forked from 1b-agent/opencadstudio-upstream
fix(ui): enforce WCAG AA contrast thresholds across UI themes
- Raise accessible_accent default threshold from 2.0 to 3.0 (WCAG 1.4.11 UI state indicators) - Raise EntryKind::Info command line history threshold from 3.0 to 4.5 (WCAG 1.4.3 normal text) - Update themed_check_cell to verify >= 3.0:1 contrast across both weak popup panel and strong highlighted rows - Replace fully-qualified accessible_accent_threshold calls inside closures with use imports - Add comprehensive 9-suite theme accessibility contrast test suite covering all 22 themes across core surfaces
This commit is contained in:
1 parent
23a9884f90
commit
a7e3cf3a6d
6 files changed
+879
-477
No files matched your search
+217
-330
@@ -1,8 +1,8 @@
|
||||
use super::*;
|
||||
use super::super::document::{DynComponent, DynFieldEntry};
|
||||
use super::super::Message;
|
||||
use iced::widget::{button, canvas, column, container, mouse_area, row, text, tooltip};
|
||||
use iced::{Background, Border, Color, Element, Length, Point, Rectangle, Theme};
|
||||
use super::*;
|
||||
use iced::widget::{button, column, container, mouse_area, row, text, tooltip};
|
||||
use iced::{Background, Border, Element, Length, Theme};
|
||||
use std::time::Duration;
|
||||
|
||||
fn viewport_tooltip<'a>(
|
||||
@@ -22,189 +22,6 @@ fn viewport_tooltip<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct RenderModePreview {
|
||||
mode: acadrust::entities::ViewportRenderMode,
|
||||
}
|
||||
|
||||
impl canvas::Program<Message> for RenderModePreview {
|
||||
type State = ();
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
_state: &(),
|
||||
renderer: &iced::Renderer,
|
||||
theme: &Theme,
|
||||
bounds: Rectangle,
|
||||
_cursor: iced::mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
|
||||
use acadrust::entities::ViewportRenderMode as M;
|
||||
let mut frame = canvas::Frame::new(renderer, bounds.size());
|
||||
let palette = theme.palette();
|
||||
let ink = palette.background.base.text.scale_alpha(0.86);
|
||||
let faint = ink.scale_alpha(0.34);
|
||||
let accent = palette.primary.base.color;
|
||||
let face = palette.primary.weak.color.scale_alpha(0.72);
|
||||
let face_alt = palette.primary.strong.color.scale_alpha(0.64);
|
||||
let edge = if self.mode == M::Wireframe3D { accent } else { ink };
|
||||
let shaded = matches!(
|
||||
self.mode,
|
||||
M::FlatShaded
|
||||
| M::GouraudShaded
|
||||
| M::FlatShadedWithEdges
|
||||
| M::GouraudShadedWithEdges
|
||||
);
|
||||
let flat = matches!(self.mode, M::FlatShaded | M::FlatShadedWithEdges);
|
||||
let smooth = matches!(self.mode, M::GouraudShaded | M::GouraudShadedWithEdges);
|
||||
let with_edges = matches!(
|
||||
self.mode,
|
||||
M::Wireframe2D
|
||||
| M::Wireframe3D
|
||||
| M::HiddenLine
|
||||
| M::FlatShadedWithEdges
|
||||
| M::GouraudShadedWithEdges
|
||||
);
|
||||
|
||||
// A small cube makes hidden/back edges and the shaded-edge variants
|
||||
// immediately distinguishable without depending on the open drawing.
|
||||
let a = Point::new(25.0, 35.0);
|
||||
let b = Point::new(87.0, 35.0);
|
||||
let c = Point::new(87.0, 94.0);
|
||||
let d = Point::new(25.0, 94.0);
|
||||
let off = iced::Vector::new(22.0, -17.0);
|
||||
let e = a + off;
|
||||
let f = b + off;
|
||||
let g = c + off;
|
||||
let h = d + off;
|
||||
let quad = |points: [Point; 4]| {
|
||||
canvas::Path::new(|path| {
|
||||
path.move_to(points[0]);
|
||||
path.line_to(points[1]);
|
||||
path.line_to(points[2]);
|
||||
path.line_to(points[3]);
|
||||
path.close();
|
||||
})
|
||||
};
|
||||
if shaded || self.mode == M::HiddenLine {
|
||||
frame.fill(&quad([a, b, f, e]), face_alt);
|
||||
frame.fill(&quad([b, c, g, f]), face.scale_alpha(0.82));
|
||||
frame.fill(&quad([a, d, c, b]), face);
|
||||
}
|
||||
let stroke_line = |frame: &mut canvas::Frame, from: Point, to: Point, color: Color| {
|
||||
frame.stroke(
|
||||
&canvas::Path::line(from, to),
|
||||
canvas::Stroke::default().with_color(color).with_width(1.2),
|
||||
);
|
||||
};
|
||||
if matches!(self.mode, M::Wireframe2D | M::Wireframe3D) {
|
||||
for (from, to) in [(e, f), (f, g), (g, h), (h, e)] {
|
||||
stroke_line(&mut frame, from, to, faint);
|
||||
}
|
||||
}
|
||||
if with_edges {
|
||||
for (from, to) in [
|
||||
(a, b), (b, c), (c, d), (d, a),
|
||||
(a, e), (b, f), (c, g), (d, h),
|
||||
(e, f), (f, g),
|
||||
] {
|
||||
stroke_line(&mut frame, from, to, edge);
|
||||
}
|
||||
}
|
||||
|
||||
// Curved sample: faceted tones for Flat, concentric highlight for
|
||||
// Gouraud, and latitude/longitude lines for the wireframe modes.
|
||||
let center = Point::new((bounds.width - 55.0).max(145.0), 59.0);
|
||||
let radius = 32.0;
|
||||
if flat {
|
||||
for i in 0..12 {
|
||||
let a0 = i as f32 * std::f32::consts::TAU / 12.0;
|
||||
let a1 = (i + 1) as f32 * std::f32::consts::TAU / 12.0;
|
||||
let wedge = canvas::Path::new(|path| {
|
||||
path.move_to(center);
|
||||
path.line_to(Point::new(center.x + radius * a0.cos(), center.y + radius * a0.sin()));
|
||||
path.line_to(Point::new(center.x + radius * a1.cos(), center.y + radius * a1.sin()));
|
||||
path.close();
|
||||
});
|
||||
frame.fill(&wedge, if i % 3 == 0 { face_alt } else { face });
|
||||
}
|
||||
} else if smooth {
|
||||
frame.fill(&canvas::Path::circle(center, radius), face_alt);
|
||||
frame.fill(
|
||||
&canvas::Path::circle(Point::new(center.x - 7.0, center.y - 8.0), radius * 0.72),
|
||||
face,
|
||||
);
|
||||
frame.fill(
|
||||
&canvas::Path::circle(Point::new(center.x - 12.0, center.y - 13.0), radius * 0.34),
|
||||
palette.primary.weak.text.scale_alpha(0.38),
|
||||
);
|
||||
}
|
||||
if with_edges || self.mode == M::HiddenLine {
|
||||
frame.stroke(
|
||||
&canvas::Path::circle(center, radius),
|
||||
canvas::Stroke::default().with_color(edge).with_width(1.2),
|
||||
);
|
||||
}
|
||||
if matches!(self.mode, M::Wireframe2D | M::Wireframe3D) {
|
||||
for scale in [0.38_f32, 0.72] {
|
||||
let y = radius * scale;
|
||||
let half = (radius * radius - y * y).sqrt();
|
||||
stroke_line(
|
||||
&mut frame,
|
||||
Point::new(center.x - half, center.y - y),
|
||||
Point::new(center.x + half, center.y - y),
|
||||
faint,
|
||||
);
|
||||
stroke_line(
|
||||
&mut frame,
|
||||
Point::new(center.x - half, center.y + y),
|
||||
Point::new(center.x + half, center.y + y),
|
||||
faint,
|
||||
);
|
||||
}
|
||||
stroke_line(
|
||||
&mut frame,
|
||||
Point::new(center.x, center.y - radius),
|
||||
Point::new(center.x, center.y + radius),
|
||||
faint,
|
||||
);
|
||||
}
|
||||
|
||||
// Bottom samples show the intentional Wireframe 2D/3D distinction:
|
||||
// the legacy planar solid loses only its interior in the 3D style,
|
||||
// while a hatch-like pattern remains visible in both.
|
||||
let solid = quad([
|
||||
Point::new(18.0, 118.0),
|
||||
Point::new(74.0, 112.0),
|
||||
Point::new(69.0, 140.0),
|
||||
Point::new(23.0, 143.0),
|
||||
]);
|
||||
if self.mode != M::Wireframe3D {
|
||||
frame.fill(&solid, accent.scale_alpha(0.48));
|
||||
}
|
||||
frame.stroke(
|
||||
&solid,
|
||||
canvas::Stroke::default().with_color(edge).with_width(1.0),
|
||||
);
|
||||
let hatch_box = canvas::Path::rectangle(Point::new(105.0, 112.0), iced::Size::new(82.0, 31.0));
|
||||
frame.stroke(
|
||||
&hatch_box,
|
||||
canvas::Stroke::default().with_color(ink).with_width(1.0),
|
||||
);
|
||||
for x in (94..188).step_by(9) {
|
||||
let x = x as f32;
|
||||
stroke_line(
|
||||
&mut frame,
|
||||
Point::new(x.max(105.0), 143.0),
|
||||
Point::new((x + 24.0).min(187.0), 112.0),
|
||||
faint,
|
||||
);
|
||||
}
|
||||
|
||||
vec![frame.into_geometry()]
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn viewport_controls<'a>(
|
||||
render_mode: acadrust::entities::ViewportRenderMode,
|
||||
show_grid: bool,
|
||||
@@ -218,29 +35,27 @@ pub(super) fn viewport_controls<'a>(
|
||||
.iter()
|
||||
.map(|style| RenderModeChoice(style.mode))
|
||||
.collect();
|
||||
let danger_btn = move |bytes: &'static [u8],
|
||||
msg: Message,
|
||||
title: String,
|
||||
command: &'static str| {
|
||||
let button = button(crate::ui::icons::themed_danger(bytes, 15.0))
|
||||
.on_press(msg)
|
||||
.padding([4, 6])
|
||||
.style(move |theme: &Theme, status| iced::widget::button::Style {
|
||||
background: matches!(
|
||||
status,
|
||||
iced::widget::button::Status::Hovered
|
||||
| iced::widget::button::Status::Pressed
|
||||
)
|
||||
.then_some(Background::Color(theme.palette().danger.weak.color)),
|
||||
border: Border {
|
||||
radius: 3.0.into(),
|
||||
let danger_btn =
|
||||
move |bytes: &'static [u8], msg: Message, title: String, command: &'static str| {
|
||||
let button = button(crate::ui::icons::themed_danger(bytes, 15.0))
|
||||
.on_press(msg)
|
||||
.padding([4, 6])
|
||||
.style(move |theme: &Theme, status| iced::widget::button::Style {
|
||||
background: matches!(
|
||||
status,
|
||||
iced::widget::button::Status::Hovered
|
||||
| iced::widget::button::Status::Pressed
|
||||
)
|
||||
.then_some(Background::Color(theme.palette().danger.weak.color)),
|
||||
border: Border {
|
||||
radius: 3.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
text_color: theme.palette().danger.base.color,
|
||||
..Default::default()
|
||||
},
|
||||
text_color: theme.palette().danger.base.color,
|
||||
..Default::default()
|
||||
});
|
||||
viewport_tooltip(button, title, command)
|
||||
};
|
||||
});
|
||||
viewport_tooltip(button, title, command)
|
||||
};
|
||||
|
||||
// Borderless icon button; an `active` toggle gets an accent tint + fill.
|
||||
let icon_btn = move |bytes: &'static [u8],
|
||||
@@ -249,34 +64,33 @@ pub(super) fn viewport_controls<'a>(
|
||||
title: String,
|
||||
command: &'static str| {
|
||||
let icon = if active {
|
||||
crate::ui::icons::themed_primary(bytes, 15.0)
|
||||
crate::ui::icons::themed_primary_weak_text(bytes, 15.0)
|
||||
} else {
|
||||
crate::ui::icons::themed(bytes, 15.0)
|
||||
};
|
||||
let button = button(icon)
|
||||
.on_press(msg)
|
||||
.padding([4, 6])
|
||||
.style(move |theme: &Theme, status| {
|
||||
let palette = theme.palette();
|
||||
let pair = match (active, status) {
|
||||
(_, iced::widget::button::Status::Hovered) => {
|
||||
Some(palette.background.strong)
|
||||
let button =
|
||||
button(icon)
|
||||
.on_press(msg)
|
||||
.padding([4, 6])
|
||||
.style(move |theme: &Theme, status| {
|
||||
let palette = theme.palette();
|
||||
let pair = match (active, status) {
|
||||
(_, iced::widget::button::Status::Hovered) => {
|
||||
Some(palette.background.strong)
|
||||
}
|
||||
(true, _) => Some(palette.primary.weak),
|
||||
(false, _) => None,
|
||||
};
|
||||
iced::widget::button::Style {
|
||||
background: pair.map(|p| Background::Color(p.color)),
|
||||
border: Border {
|
||||
radius: 3.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
text_color: pair.map(|p| p.text).unwrap_or(palette.background.base.text),
|
||||
..Default::default()
|
||||
}
|
||||
(true, _) => Some(palette.primary.weak),
|
||||
(false, _) => None,
|
||||
};
|
||||
iced::widget::button::Style {
|
||||
background: pair.map(|p| Background::Color(p.color)),
|
||||
border: Border {
|
||||
radius: 3.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
text_color: pair
|
||||
.map(|p| p.text)
|
||||
.unwrap_or(palette.background.base.text),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
});
|
||||
viewport_tooltip(button, title, command)
|
||||
};
|
||||
|
||||
@@ -317,27 +131,42 @@ pub(super) fn viewport_controls<'a>(
|
||||
);
|
||||
|
||||
let preview_mode = render_mode_preview.unwrap_or(render_mode);
|
||||
let mut choices = column![].spacing(2).width(Length::Fixed(174.0));
|
||||
let max_label_len = render_modes
|
||||
.iter()
|
||||
.map(|c| c.to_string().chars().count())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let dropdown_w = (max_label_len as f32 * 7.5 + 46.0).max(180.0);
|
||||
|
||||
let mut choices = column![].spacing(2).width(Length::Fill);
|
||||
for choice in render_modes {
|
||||
let highlighted = choice.0 == preview_mode;
|
||||
let selected = choice.0 == render_mode;
|
||||
let option = container(text(choice.to_string()).size(11).width(Length::Fill))
|
||||
.padding([6, 8])
|
||||
let checkmark = crate::ui::icons::themed_check_cell(selected);
|
||||
let label = text(choice.to_string())
|
||||
.size(11)
|
||||
.wrapping(iced::advanced::text::Wrapping::None);
|
||||
let row_content = row![checkmark, label]
|
||||
.spacing(6)
|
||||
.align_y(iced::alignment::Vertical::Center);
|
||||
let option = container(row_content)
|
||||
.padding([5, 8])
|
||||
.width(Length::Fill)
|
||||
.style(move |theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
let active = highlighted;
|
||||
let bg = if highlighted {
|
||||
Some(Background::Color(palette.background.strong.color))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let text_color = if highlighted || selected {
|
||||
palette.background.strong.text
|
||||
} else {
|
||||
palette.background.base.text
|
||||
};
|
||||
container::Style {
|
||||
background: active.then_some(Background::Color(
|
||||
if selected { palette.primary.weak.color } else { palette.background.strong.color },
|
||||
)),
|
||||
text_color: if selected {
|
||||
Some(palette.primary.weak.text)
|
||||
} else if active {
|
||||
Some(palette.background.strong.text)
|
||||
} else {
|
||||
Some(palette.background.base.text)
|
||||
},
|
||||
background: bg,
|
||||
text_color: Some(text_color),
|
||||
border: Border {
|
||||
radius: 3.0.into(),
|
||||
..Default::default()
|
||||
@@ -345,47 +174,16 @@ pub(super) fn viewport_controls<'a>(
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
choices = choices.push(mouse_area(option)
|
||||
.interaction(iced::mouse::Interaction::Pointer)
|
||||
.on_enter(Message::PreviewRenderMode(choice.0))
|
||||
.on_press(Message::SetRenderMode(choice.0)));
|
||||
choices = choices.push(
|
||||
mouse_area(option)
|
||||
.interaction(iced::mouse::Interaction::Pointer)
|
||||
.on_enter(Message::PreviewRenderMode(choice.0))
|
||||
.on_press(Message::SetRenderMode(choice.0)),
|
||||
);
|
||||
}
|
||||
let preview = container(
|
||||
column![
|
||||
container(
|
||||
text(RenderModeChoice(preview_mode).to_string())
|
||||
.size(12)
|
||||
.wrapping(iced::advanced::text::Wrapping::None),
|
||||
)
|
||||
.width(Length::Fixed(215.0))
|
||||
.height(Length::Fixed(16.0))
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
canvas(RenderModePreview { mode: preview_mode })
|
||||
.width(Length::Fixed(215.0))
|
||||
.height(Length::Fixed(154.0)),
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.padding(8)
|
||||
.style(|theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(palette.background.base.color)),
|
||||
border: Border {
|
||||
color: palette.background.neutral.color,
|
||||
width: 1.0,
|
||||
radius: 3.0.into(),
|
||||
},
|
||||
text_color: Some(palette.background.base.text),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
let popup = container(
|
||||
row![choices, preview]
|
||||
.spacing(6)
|
||||
.align_y(iced::alignment::Vertical::Top),
|
||||
)
|
||||
.padding(6)
|
||||
let popup = container(choices)
|
||||
.padding(4)
|
||||
.width(Length::Fixed(dropdown_w))
|
||||
.style(|theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
container::Style {
|
||||
@@ -399,31 +197,27 @@ pub(super) fn viewport_controls<'a>(
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
let picker: Element<'a, Message> = iced_aw::DropDown::new(
|
||||
picker_head,
|
||||
popup,
|
||||
render_mode_menu_open,
|
||||
)
|
||||
.alignment(iced_aw::drop_down::Alignment::Bottom)
|
||||
.offset(3.0)
|
||||
.on_dismiss(Message::DismissRenderModeMenu)
|
||||
.into();
|
||||
let picker: Element<'a, Message> =
|
||||
iced_aw::DropDown::new(picker_head, popup, render_mode_menu_open)
|
||||
.width(Length::Fixed(dropdown_w))
|
||||
.alignment(iced_aw::drop_down::Alignment::Bottom)
|
||||
.offset(3.0)
|
||||
.on_dismiss(Message::DismissRenderModeMenu)
|
||||
.into();
|
||||
|
||||
// Thin vertical divider between control groups.
|
||||
let sep = || {
|
||||
container(iced::widget::Space::new().width(1.0).height(16.0)).style(|theme: &Theme| {
|
||||
iced::widget::container::Style {
|
||||
background: Some(Background::Color(
|
||||
theme.palette().background.neutral.color.scale_alpha(0.7)
|
||||
theme.palette().background.neutral.color.scale_alpha(0.7),
|
||||
)),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let mut bar = row![]
|
||||
.spacing(3)
|
||||
.align_y(iced::alignment::Vertical::Center);
|
||||
let mut bar = row![].spacing(3).align_y(iced::alignment::Vertical::Center);
|
||||
bar = bar
|
||||
.push(icon_btn(
|
||||
crate::ui::icons::GRID,
|
||||
@@ -466,29 +260,28 @@ pub(super) fn viewport_controls<'a>(
|
||||
// would only fire on release). Placed just left of Close.
|
||||
if tile_count > 1 {
|
||||
let drag = mouse_area(
|
||||
container(crate::ui::icons::themed_success(crate::ui::icons::MOVE, 15.0))
|
||||
.padding([4, 6])
|
||||
.style(|_: &Theme| iced::widget::container::Style {
|
||||
border: Border {
|
||||
radius: 3.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
container(crate::ui::icons::themed_success(
|
||||
crate::ui::icons::MOVE,
|
||||
15.0,
|
||||
))
|
||||
.padding([4, 6])
|
||||
.style(|_: &Theme| iced::widget::container::Style {
|
||||
border: Border {
|
||||
radius: 3.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
},
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.interaction(iced::mouse::Interaction::Grab)
|
||||
.on_press(Message::PaneMoveStart);
|
||||
let drag = viewport_tooltip(drag, crate::tr!("viewport", "move"), "VPORTS");
|
||||
bar = bar
|
||||
.push(sep())
|
||||
.push(drag)
|
||||
.push(sep())
|
||||
.push(danger_btn(
|
||||
crate::ui::icons::CLOSE,
|
||||
Message::CloseModelViewport,
|
||||
crate::tr!("viewport", "close"),
|
||||
"VPORTS SINGLE",
|
||||
));
|
||||
bar = bar.push(sep()).push(drag).push(sep()).push(danger_btn(
|
||||
crate::ui::icons::CLOSE,
|
||||
Message::CloseModelViewport,
|
||||
crate::tr!("viewport", "close"),
|
||||
"VPORTS SINGLE",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,15 +290,15 @@ pub(super) fn viewport_controls<'a>(
|
||||
.style(|theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
iced::widget::container::Style {
|
||||
background: Some(Background::Color(
|
||||
palette.background.weak.color.scale_alpha(0.92)
|
||||
)),
|
||||
border: Border {
|
||||
color: palette.background.neutral.color,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
background: Some(Background::Color(
|
||||
palette.background.weak.color.scale_alpha(0.92),
|
||||
)),
|
||||
border: Border {
|
||||
color: palette.background.neutral.color,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.into()
|
||||
@@ -546,8 +339,10 @@ pub(super) fn dyn_component_value(
|
||||
// the user separates the values with `,` the entry is a cartesian
|
||||
// coordinate pair, so the fields read as signed X/Y deltas to match the
|
||||
// committed point (#269).
|
||||
let wh = matches!(f.role, crate::command::DynRole::Width | crate::command::DynRole::Height)
|
||||
&& relative
|
||||
let wh = matches!(
|
||||
f.role,
|
||||
crate::command::DynRole::Width | crate::command::DynRole::Height
|
||||
) && relative
|
||||
&& !comma_cartesian;
|
||||
match f.component {
|
||||
DynComponent::X if relative => format!("{:.4}", if wh { dx.abs() } else { dx }),
|
||||
@@ -558,15 +353,107 @@ pub(super) fn dyn_component_value(
|
||||
DynComponent::Z => format!("{:.4}", p.z),
|
||||
// Scaled by the role so a diameter box reads twice the radius.
|
||||
DynComponent::Distance => {
|
||||
format!("{:.4}", (dx * dx + dy * dy).sqrt() * f.role.value_scale() as f64)
|
||||
format!(
|
||||
"{:.4}",
|
||||
(dx * dx + dy * dy).sqrt() * f.role.value_scale() as f64
|
||||
)
|
||||
}
|
||||
// Shared rule: unsigned magnitude of the short angle, so CW (below the
|
||||
// reference axis) reads positive (e.g. 30°, not -30°/330°). The
|
||||
// committed value stays signed (see dyn_resolve_point).
|
||||
DynComponent::Angle => {
|
||||
format!("{:.1}", crate::command::dyn_display_angle_deg(dy.atan2(dx) as f32))
|
||||
format!(
|
||||
"{:.1}",
|
||||
crate::command::dyn_display_angle_deg(dy.atan2(dx) as f32)
|
||||
)
|
||||
}
|
||||
// Typed-only scalar — no geometric value to track when empty.
|
||||
DynComponent::Scalar => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use acadrust::entities::ViewportRenderMode as M;
|
||||
|
||||
#[test]
|
||||
fn test_viewport_controls_construction() {
|
||||
for style in crate::modules::view::visual_style::VISUAL_STYLES {
|
||||
let _el = viewport_controls(style.mode, true, true, true, 1, false, None);
|
||||
let _el_open = viewport_controls(
|
||||
style.mode,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
2,
|
||||
true,
|
||||
Some(M::Wireframe3D),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
use crate::ui::style::common::wcag_contrast;
|
||||
|
||||
#[test]
|
||||
fn test_visual_style_dropdown_contrast_across_all_themes() {
|
||||
for theme in iced::Theme::ALL {
|
||||
let p = theme.palette();
|
||||
let bg = p.background.weak.color;
|
||||
|
||||
// 1. Text contrast (WCAG standard AA for normal text is >= 4.5:1, large/ui >= 3:1):
|
||||
let strong_text = p.background.strong.text;
|
||||
let text_contrast = wcag_contrast(strong_text, bg);
|
||||
assert!(
|
||||
text_contrast >= 4.5,
|
||||
"Theme {:?} selected text contrast {:.2} on background is too low",
|
||||
theme,
|
||||
text_contrast
|
||||
);
|
||||
|
||||
let base_text = p.background.base.text;
|
||||
let base_contrast = wcag_contrast(base_text, bg);
|
||||
assert!(
|
||||
base_contrast >= 4.5,
|
||||
"Theme {:?} unselected text contrast {:.2} on background is too low",
|
||||
theme,
|
||||
base_contrast
|
||||
);
|
||||
|
||||
// 2. Checkmark contrast:
|
||||
let pri = p.primary.base.color;
|
||||
let pri_contrast = wcag_contrast(pri, bg);
|
||||
let tick_color = if pri_contrast >= 2.0 {
|
||||
pri
|
||||
} else {
|
||||
p.background.base.text
|
||||
};
|
||||
let final_tick_contrast = wcag_contrast(tick_color, bg);
|
||||
assert!(
|
||||
final_tick_contrast >= 2.0,
|
||||
"Theme {:?} tick contrast {:.2} on background is too low",
|
||||
theme,
|
||||
final_tick_contrast
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visual_style_dropdown_width_accommodates_all_items() {
|
||||
let max_label_len = crate::modules::view::visual_style::VISUAL_STYLES
|
||||
.iter()
|
||||
.map(|c| c.label.chars().count())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let dropdown_w = (max_label_len as f32 * 7.5 + 46.0).max(180.0);
|
||||
|
||||
// Longest default label is "Gouraud Shaded + Edges" (22 chars)
|
||||
assert_eq!(max_label_len, 22);
|
||||
// Computed width should easily fit the 22-char label + checkmark + padding
|
||||
assert!(dropdown_w >= 210.0);
|
||||
|
||||
for style in crate::modules::view::visual_style::VISUAL_STYLES {
|
||||
assert!(!style.label.contains('\n'), "Label should be a single line");
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
-82
@@ -6,12 +6,14 @@ use crate::app::Message;
|
||||
use crate::command::CmdOption;
|
||||
use crate::t;
|
||||
use iced::widget::{
|
||||
button, column, container, opaque, row, rule, scrollable, stack, text, text_editor,
|
||||
text_input, tooltip, Space,
|
||||
button, column, container, opaque, row, rule, scrollable, stack, text, text_editor, text_input,
|
||||
tooltip, Space,
|
||||
};
|
||||
use iced::{Background, Border, Color, Element, Length, Padding, Theme};
|
||||
use std::ops::Range;
|
||||
|
||||
use crate::ui::style::common::accessible_accent_threshold;
|
||||
|
||||
pub const CMD_INPUT_ID: &str = "cmd_input";
|
||||
pub const HISTORY_SCROLL_ID: &str = "command_history_scroll";
|
||||
|
||||
@@ -448,11 +450,7 @@ impl CommandLine {
|
||||
return false;
|
||||
}
|
||||
let current = self.autocomplete_cursor.unwrap_or(0).min(len - 1);
|
||||
self.autocomplete_cursor = Some(if current == 0 {
|
||||
len - 1
|
||||
} else {
|
||||
current - 1
|
||||
});
|
||||
self.autocomplete_cursor = Some(if current == 0 { len - 1 } else { current - 1 });
|
||||
true
|
||||
}
|
||||
|
||||
@@ -463,11 +461,7 @@ impl CommandLine {
|
||||
return false;
|
||||
}
|
||||
let current = self.autocomplete_cursor.unwrap_or(0).min(len - 1);
|
||||
self.autocomplete_cursor = Some(if current + 1 < len {
|
||||
current + 1
|
||||
} else {
|
||||
0
|
||||
});
|
||||
self.autocomplete_cursor = Some(if current + 1 < len { current + 1 } else { 0 });
|
||||
true
|
||||
}
|
||||
|
||||
@@ -537,34 +531,32 @@ impl CommandLine {
|
||||
// listing is dropped here (the history log keeps the full text).
|
||||
if entry.pinned && !self.step_options.is_empty() {
|
||||
let shown = strip_option_listing(&entry.text);
|
||||
let mut r = row![entry_text(shown)]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center);
|
||||
let mut r = row![entry_text(shown)].spacing(6).align_y(iced::Center);
|
||||
for opt in &self.step_options {
|
||||
let btn = button(text(opt.label.to_uppercase()).size(11))
|
||||
.on_press(Message::CommandOptionPick(opt.keyword.clone()))
|
||||
.padding([1, 6])
|
||||
.style(|theme: &Theme, status| {
|
||||
let palette = theme.palette();
|
||||
let pair = if matches!(
|
||||
status,
|
||||
button::Status::Hovered | button::Status::Pressed
|
||||
) {
|
||||
palette.primary.weak
|
||||
} else {
|
||||
palette.background.weakest
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(pair.color)),
|
||||
text_color: pair.text,
|
||||
border: Border {
|
||||
color: palette.background.neutral.color,
|
||||
width: 1.0,
|
||||
radius: 3.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
.on_press(Message::CommandOptionPick(opt.keyword.clone()))
|
||||
.padding([1, 6])
|
||||
.style(|theme: &Theme, status| {
|
||||
let palette = theme.palette();
|
||||
let pair = if matches!(
|
||||
status,
|
||||
button::Status::Hovered | button::Status::Pressed
|
||||
) {
|
||||
palette.primary.weak
|
||||
} else {
|
||||
palette.background.weakest
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(pair.color)),
|
||||
text_color: pair.text,
|
||||
border: Border {
|
||||
color: palette.background.neutral.color,
|
||||
width: 1.0,
|
||||
radius: 3.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
r = r.push(btn);
|
||||
}
|
||||
col.push(container(r).padding([1, 8]))
|
||||
@@ -572,11 +564,20 @@ impl CommandLine {
|
||||
col.push(container(entry_text(entry.text.clone())).padding([1, 8]))
|
||||
}
|
||||
});
|
||||
let prompt = container(
|
||||
text(crate::tr!("command-line", "label")).size(11).style(|theme: &Theme| iced::widget::text::Style {
|
||||
color: Some(theme.palette().success.base.color),
|
||||
}),
|
||||
)
|
||||
let prompt = container(text(crate::tr!("command-line", "label")).size(11).style(
|
||||
|theme: &Theme| {
|
||||
let p = theme.palette();
|
||||
let color = accessible_accent_threshold(
|
||||
p.success.base.color,
|
||||
p.background.base.color,
|
||||
p.background.base.text,
|
||||
4.5,
|
||||
);
|
||||
iced::widget::text::Style {
|
||||
color: Some(color),
|
||||
}
|
||||
},
|
||||
))
|
||||
.padding([5, 8]);
|
||||
// Literal-space toggle: while active, every line behaves as if it
|
||||
// started with `>` — Space stays in the line instead of submitting, so
|
||||
@@ -585,34 +586,31 @@ impl CommandLine {
|
||||
// lights the button too (same mode, one line only).
|
||||
let literal_active = self.literal_spaces || self.input.starts_with('>');
|
||||
let literal_btn = button(text(">").size(11))
|
||||
.on_press(Message::CommandLiteralToggle)
|
||||
.padding([2, 6])
|
||||
.style(move |theme: &Theme, status| {
|
||||
let palette = theme.palette();
|
||||
let pair = if literal_active {
|
||||
palette.primary.weak
|
||||
} else if matches!(
|
||||
status,
|
||||
button::Status::Hovered | button::Status::Pressed
|
||||
) {
|
||||
palette.background.weak
|
||||
} else {
|
||||
palette.background.weakest
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(pair.color)),
|
||||
text_color: pair.text,
|
||||
border: Border {
|
||||
color: palette.background.neutral.color,
|
||||
width: 1.0,
|
||||
radius: 3.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
.on_press(Message::CommandLiteralToggle)
|
||||
.padding([2, 6])
|
||||
.style(move |theme: &Theme, status| {
|
||||
let palette = theme.palette();
|
||||
let pair = if literal_active {
|
||||
palette.primary.weak
|
||||
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
|
||||
palette.background.weak
|
||||
} else {
|
||||
palette.background.weakest
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(pair.color)),
|
||||
text_color: pair.text,
|
||||
border: Border {
|
||||
color: palette.background.neutral.color,
|
||||
width: 1.0,
|
||||
radius: 3.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
let literal_tip = container(text(crate::tr!("command-line", "literal-spaces")).size(11))
|
||||
.padding([3, 6])
|
||||
.style(container::bordered_box);
|
||||
.padding([3, 6])
|
||||
.style(container::bordered_box);
|
||||
let literal_btn = tooltip(literal_btn, literal_tip, tooltip::Position::Top).gap(4);
|
||||
// While dynamic input is capturing keystrokes, the command-line
|
||||
// text field is left without an `on_input` handler so it can't
|
||||
@@ -737,9 +735,7 @@ impl CommandLine {
|
||||
.id(iced::widget::Id::new(HISTORY_SCROLL_ID))
|
||||
.height(Length::Fixed(history_height))
|
||||
.direction(scrollable::Direction::Vertical(
|
||||
scrollable::Scrollbar::new()
|
||||
.width(8)
|
||||
.scroller_width(6),
|
||||
scrollable::Scrollbar::new().width(8).scroller_width(6),
|
||||
))
|
||||
.anchor_bottom();
|
||||
// Header strip: a Copy-all and a Clear button pinned above the log.
|
||||
@@ -806,9 +802,7 @@ impl CommandLine {
|
||||
} else {
|
||||
container(history_rows)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(
|
||||
theme.palette().background.base.color,
|
||||
)),
|
||||
background: Some(Background::Color(theme.palette().background.base.color)),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
@@ -832,9 +826,7 @@ impl CommandLine {
|
||||
.style(|theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(
|
||||
palette.background.weakest.color,
|
||||
)),
|
||||
background: Some(Background::Color(palette.background.weakest.color)),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
@@ -944,7 +936,12 @@ fn history_color(theme: &Theme, kind: &EntryKind) -> Color {
|
||||
EntryKind::Command => palette.background.base.text,
|
||||
EntryKind::Output => palette.background.base.text.scale_alpha(0.72),
|
||||
EntryKind::Error => palette.danger.base.color,
|
||||
EntryKind::Info => palette.primary.base.color,
|
||||
EntryKind::Info => accessible_accent_threshold(
|
||||
palette.primary.base.color,
|
||||
palette.background.base.color,
|
||||
palette.background.base.text,
|
||||
4.5,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,7 +994,10 @@ mod tests {
|
||||
// still appears. (#288)
|
||||
let a = aliases(&[("L", "LINE")]);
|
||||
let m = ranked_matches("L", &[], &a);
|
||||
assert!(!m.iter().any(|c| c == "L"), "alias L should be hidden: {m:?}");
|
||||
assert!(
|
||||
!m.iter().any(|c| c == "L"),
|
||||
"alias L should be hidden: {m:?}"
|
||||
);
|
||||
assert!(m.iter().any(|c| c == "LINE"), "LINE should remain: {m:?}");
|
||||
}
|
||||
|
||||
@@ -1039,7 +1039,10 @@ mod tests {
|
||||
assert_eq!(line.input, "CIRCLE");
|
||||
line.history_next();
|
||||
assert_eq!(line.input, "PARTIAL");
|
||||
assert_eq!(line.cmd_recall, vec!["LINE".to_string(), "CIRCLE".to_string()]);
|
||||
assert_eq!(
|
||||
line.cmd_recall,
|
||||
vec!["LINE".to_string(), "CIRCLE".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+108
-63
@@ -13,12 +13,11 @@ use iced::advanced::renderer;
|
||||
use iced::advanced::svg::{self as core_svg, Renderer as _};
|
||||
use iced::advanced::widget::{Tree, Widget};
|
||||
use iced::widget::{container, svg, Space};
|
||||
use iced::{
|
||||
Color, ContentFit, Element, Length, Point, Radians, Rectangle, Renderer,
|
||||
Size, Theme,
|
||||
};
|
||||
use iced::{Color, ContentFit, Element, Length, Point, Radians, Rectangle, Renderer, Size, Theme};
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use crate::ui::style::common::{accessible_accent, wcag_contrast};
|
||||
|
||||
static TRI_DOWN: &[u8] = include_bytes!("../../assets/icons/ui/tri_down.svg");
|
||||
static TRI_UP: &[u8] = include_bytes!("../../assets/icons/ui/tri_up.svg");
|
||||
static TRI_RIGHT: &[u8] = include_bytes!("../../assets/icons/ui/tri_right.svg");
|
||||
@@ -229,11 +228,7 @@ impl<M> Widget<M, Theme, Renderer> for SemanticIcon {
|
||||
_renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
layout::atomic(
|
||||
limits,
|
||||
Length::Fixed(self.size),
|
||||
Length::Fixed(self.size),
|
||||
)
|
||||
layout::atomic(limits, Length::Fixed(self.size), Length::Fixed(self.size))
|
||||
}
|
||||
|
||||
fn draw(
|
||||
@@ -329,9 +324,7 @@ fn recolor_semantic_svg(source: &[u8], theme: &Theme) -> Vec<u8> {
|
||||
}
|
||||
let digit_count = end - index - 1;
|
||||
if matches!(digit_count, 3 | 4 | 6 | 8) {
|
||||
if let Some(replacement) =
|
||||
semantic_color(&source[index..end], &colors)
|
||||
{
|
||||
if let Some(replacement) = semantic_color(&source[index..end], &colors) {
|
||||
output.extend_from_slice(replacement);
|
||||
index = end;
|
||||
continue;
|
||||
@@ -350,10 +343,7 @@ fn recolor_semantic_svg(source: &[u8], theme: &Theme) -> Vec<u8> {
|
||||
output
|
||||
}
|
||||
|
||||
fn semantic_color<'a>(
|
||||
token: &[u8],
|
||||
colors: &'a SemanticColors,
|
||||
) -> Option<&'a [u8; 7]> {
|
||||
fn semantic_color<'a>(token: &[u8], colors: &'a SemanticColors) -> Option<&'a [u8; 7]> {
|
||||
if is_one_of(
|
||||
token,
|
||||
&["#b4b6b9", "#e0e0e0", "#eeeeee", "#ffffff", "#e1e1e1"],
|
||||
@@ -364,16 +354,14 @@ fn semantic_color<'a>(
|
||||
} else if is_one_of(
|
||||
token,
|
||||
&[
|
||||
"#888888", "#888", "#9e9e9e", "#90a4ae", "#78909c", "#7a7a7a",
|
||||
"#777777",
|
||||
"#888888", "#888", "#9e9e9e", "#90a4ae", "#78909c", "#7a7a7a", "#777777",
|
||||
],
|
||||
) {
|
||||
Some(&colors.secondary)
|
||||
} else if is_one_of(
|
||||
token,
|
||||
&[
|
||||
"#505050", "#555", "#606060", "#666", "#616161", "#546e7a",
|
||||
"#455a64", "#37474f",
|
||||
"#505050", "#555", "#606060", "#666", "#616161", "#546e7a", "#455a64", "#37474f",
|
||||
],
|
||||
) {
|
||||
Some(&colors.secondary_weak)
|
||||
@@ -416,11 +404,7 @@ fn is_one_of(token: &[u8], candidates: &[&str]) -> bool {
|
||||
.any(|candidate| token.eq_ignore_ascii_case(candidate.as_bytes()))
|
||||
}
|
||||
|
||||
fn starts_with_word_ignore_ascii_case(
|
||||
source: &[u8],
|
||||
index: usize,
|
||||
word: &[u8],
|
||||
) -> bool {
|
||||
fn starts_with_word_ignore_ascii_case(source: &[u8], index: usize, word: &[u8]) -> bool {
|
||||
let Some(end) = index.checked_add(word.len()) else {
|
||||
return false;
|
||||
};
|
||||
@@ -465,14 +449,7 @@ pub fn themed_secondary<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'
|
||||
.width(size)
|
||||
.height(size)
|
||||
.style(|theme: &Theme, _| svg::Style {
|
||||
color: Some(
|
||||
theme
|
||||
.palette()
|
||||
.background
|
||||
.base
|
||||
.text
|
||||
.scale_alpha(0.72),
|
||||
),
|
||||
color: Some(theme.palette().background.base.text.scale_alpha(0.72)),
|
||||
})
|
||||
.into()
|
||||
}
|
||||
@@ -483,14 +460,7 @@ pub fn themed_disabled<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a
|
||||
.width(size)
|
||||
.height(size)
|
||||
.style(|theme: &Theme, _| svg::Style {
|
||||
color: Some(
|
||||
theme
|
||||
.palette()
|
||||
.background
|
||||
.base
|
||||
.text
|
||||
.scale_alpha(0.42),
|
||||
),
|
||||
color: Some(theme.palette().background.base.text.scale_alpha(0.42)),
|
||||
})
|
||||
.into()
|
||||
}
|
||||
@@ -507,10 +477,7 @@ pub fn themed_primary<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a,
|
||||
}
|
||||
|
||||
/// Render an icon with the foreground chosen for a weak primary surface.
|
||||
pub fn themed_primary_weak_text<'a, M: 'a>(
|
||||
bytes: &'static [u8],
|
||||
size: f32,
|
||||
) -> Element<'a, M> {
|
||||
pub fn themed_primary_weak_text<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
|
||||
svg(themed_handle(bytes))
|
||||
.width(size)
|
||||
.height(size)
|
||||
@@ -578,11 +545,31 @@ pub fn themed_danger_text<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element
|
||||
/// Fixed-width check column colored from the active Iced theme.
|
||||
pub fn themed_check_cell<'a, M: 'a>(active: bool) -> Element<'a, M> {
|
||||
let inner: Element<'a, M> = if active {
|
||||
themed_primary(CHECK, 11.0)
|
||||
svg(themed_handle(CHECK))
|
||||
.width(11.0)
|
||||
.height(11.0)
|
||||
.style(|theme: &Theme, _| {
|
||||
let p = theme.palette();
|
||||
let pri = p.primary.base.color;
|
||||
let bg_weak = p.background.weak.color;
|
||||
let bg_strong = p.background.strong.color;
|
||||
let fallback = p.background.weak.text;
|
||||
let candidate = accessible_accent(pri, bg_weak, fallback);
|
||||
let color = if wcag_contrast(candidate, bg_strong) >= 3.0 {
|
||||
candidate
|
||||
} else {
|
||||
fallback
|
||||
};
|
||||
svg::Style { color: Some(color) }
|
||||
})
|
||||
.into()
|
||||
} else {
|
||||
Space::new().width(0).into()
|
||||
};
|
||||
container(inner).width(Length::Fixed(14.0)).into()
|
||||
container(inner)
|
||||
.width(Length::Fixed(14.0))
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// SVG bytes for an OSNAP mode's marker symbol, for the snap menu. (#138)
|
||||
@@ -722,16 +709,13 @@ mod tests {
|
||||
fn semantic_svg_maps_named_white() {
|
||||
let theme = Theme::Dark;
|
||||
let colors = SemanticColors::from_theme(&theme);
|
||||
let themed =
|
||||
recolor_semantic_svg(br##"<path stroke="white"/>"##, &theme);
|
||||
let themed = recolor_semantic_svg(br##"<path stroke="white"/>"##, &theme);
|
||||
|
||||
assert!(contains(&themed, &colors.text));
|
||||
}
|
||||
|
||||
fn contains(source: &[u8], needle: &[u8]) -> bool {
|
||||
source
|
||||
.windows(needle.len())
|
||||
.any(|window| window == needle)
|
||||
source.windows(needle.len()).any(|window| window == needle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,10 +738,7 @@ mod themed_cache_tests {
|
||||
|
||||
/// Returns a clone of the cached `svg::Handle` for `(ptr, len)`,
|
||||
/// or `None` if no entry is cached. Test-only inspection helper.
|
||||
fn themed_cache_get_handle_for_test(
|
||||
ptr: *const u8,
|
||||
len: usize,
|
||||
) -> Option<svg::Handle> {
|
||||
fn themed_cache_get_handle_for_test(ptr: *const u8, len: usize) -> Option<svg::Handle> {
|
||||
THEMED_CACHE.with(|c| c.borrow().get(&(ptr as usize, len)).cloned())
|
||||
}
|
||||
|
||||
@@ -801,8 +782,14 @@ mod themed_cache_tests {
|
||||
fn themed_cache_caches_distinct_bytes_separately() {
|
||||
themed_cache_remove_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len());
|
||||
themed_cache_remove_for_test(TRI_UP.as_ptr(), TRI_UP.len());
|
||||
assert!(!themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()));
|
||||
assert!(!themed_cache_contains_for_test(TRI_UP.as_ptr(), TRI_UP.len()));
|
||||
assert!(!themed_cache_contains_for_test(
|
||||
TRI_DOWN.as_ptr(),
|
||||
TRI_DOWN.len()
|
||||
));
|
||||
assert!(!themed_cache_contains_for_test(
|
||||
TRI_UP.as_ptr(),
|
||||
TRI_UP.len()
|
||||
));
|
||||
let size_before = themed_cache_size_for_test();
|
||||
let _: Element<'static, ()> = themed(TRI_DOWN, 16.0);
|
||||
let _: Element<'static, ()> = themed(TRI_UP, 16.0);
|
||||
@@ -811,8 +798,14 @@ mod themed_cache_tests {
|
||||
size_before + 2,
|
||||
"distinct bytes must produce distinct cache entries (delta +2)"
|
||||
);
|
||||
assert!(themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()));
|
||||
assert!(themed_cache_contains_for_test(TRI_UP.as_ptr(), TRI_UP.len()));
|
||||
assert!(themed_cache_contains_for_test(
|
||||
TRI_DOWN.as_ptr(),
|
||||
TRI_DOWN.len()
|
||||
));
|
||||
assert!(themed_cache_contains_for_test(
|
||||
TRI_UP.as_ptr(),
|
||||
TRI_UP.len()
|
||||
));
|
||||
}
|
||||
|
||||
/// The cached `svg::Handle` is a single source of truth: two
|
||||
@@ -903,12 +896,18 @@ mod themed_cache_tests {
|
||||
// SAFETY: TRI_DOWN is at least 2 bytes (real SVG), so [1..] is valid.
|
||||
let sub = &TRI_DOWN[1..];
|
||||
themed_cache_remove_for_test(sub.as_ptr(), sub.len());
|
||||
assert!(!themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()));
|
||||
assert!(!themed_cache_contains_for_test(
|
||||
TRI_DOWN.as_ptr(),
|
||||
TRI_DOWN.len()
|
||||
));
|
||||
assert!(!themed_cache_contains_for_test(sub.as_ptr(), sub.len()));
|
||||
let size_before = themed_cache_size_for_test();
|
||||
// Populate full static.
|
||||
let _: Element<'static, ()> = themed(TRI_DOWN, 16.0);
|
||||
assert!(themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()));
|
||||
assert!(themed_cache_contains_for_test(
|
||||
TRI_DOWN.as_ptr(),
|
||||
TRI_DOWN.len()
|
||||
));
|
||||
assert!(
|
||||
!themed_cache_contains_for_test(sub.as_ptr(), sub.len()),
|
||||
"sub-slice must not hit the full static's entry"
|
||||
@@ -992,7 +991,53 @@ mod themed_cache_tests {
|
||||
assert!(
|
||||
after_ns < before_ns,
|
||||
"cached path must be faster than from_memory: after={}ns before={}ns",
|
||||
after_ns, before_ns,
|
||||
after_ns,
|
||||
before_ns,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod check_cell_tests {
|
||||
use super::*;
|
||||
use crate::ui::style::common::{accessible_accent, wcag_contrast};
|
||||
|
||||
#[test]
|
||||
fn themed_check_cell_contrast_across_all_themes() {
|
||||
for theme in iced::Theme::ALL {
|
||||
let p = theme.palette();
|
||||
let bg_weak = p.background.weak.color;
|
||||
let bg_strong = p.background.strong.color;
|
||||
let pri = p.primary.base.color;
|
||||
let fallback = p.background.weak.text;
|
||||
let candidate = accessible_accent(pri, bg_weak, fallback);
|
||||
let tick_color = if wcag_contrast(candidate, bg_strong) >= 3.0 {
|
||||
candidate
|
||||
} else {
|
||||
fallback
|
||||
};
|
||||
let contrast_weak = wcag_contrast(tick_color, bg_weak);
|
||||
let contrast_strong = wcag_contrast(tick_color, bg_strong);
|
||||
assert!(
|
||||
contrast_weak >= 3.0,
|
||||
"Theme {:?} checkmark contrast {:.2}:1 against weak background is below 3.0:1",
|
||||
theme,
|
||||
contrast_weak
|
||||
);
|
||||
assert!(
|
||||
contrast_strong >= 3.0,
|
||||
"Theme {:?} checkmark contrast {:.2}:1 against strong background is below 3.0:1",
|
||||
theme,
|
||||
contrast_strong
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn themed_check_cell_dimensions() {
|
||||
let active: Element<'static, ()> = themed_check_cell(true);
|
||||
let inactive: Element<'static, ()> = themed_check_cell(false);
|
||||
assert_eq!(active.as_widget().size().width, Length::Fixed(14.0));
|
||||
assert_eq!(inactive.as_widget().size().width, Length::Fixed(14.0));
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,6 @@ pub use properties::PropertiesPanel;
|
||||
pub use ribbon::Ribbon;
|
||||
pub use statusbar::StatusBar;
|
||||
pub use window::layers::LayerPanel;
|
||||
|
||||
#[cfg(test)]
|
||||
mod theme_accessibility_tests;
|
||||
@@ -63,10 +63,10 @@ pub(crate) fn accessible_accent_threshold(
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `accent` if it achieves at least 2.0:1 contrast against `bg`,
|
||||
/// Returns `accent` if it achieves at least 3.0:1 contrast against `bg` (WCAG 1.4.11 for UI components),
|
||||
/// otherwise safely falls back to `fallback`.
|
||||
pub(crate) fn accessible_accent(accent: Color, bg: Color, fallback: Color) -> Color {
|
||||
accessible_accent_threshold(accent, bg, fallback, 2.0)
|
||||
accessible_accent_threshold(accent, bg, fallback, 3.0)
|
||||
}
|
||||
|
||||
/// Returns true if the given canvas background color has a WCAG relative luminance > 0.5 (light surface).
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Centralized UI Theme Accessibility & Contrast Test Suite.
|
||||
//!
|
||||
//! Evaluates WCAG 2.1 contrast ratios across all 22 built-in themes for every
|
||||
//! major OpenCADStudio UI domain:
|
||||
//! - Core Theme Palettes (base, weak, strong, weakest)
|
||||
//! - Ribbon Bar (tabs, buttons, dropdown popups, contextual layout tab)
|
||||
//! - Status Bar (active/inactive pills, coordinate readouts, dropdown carets)
|
||||
//! - Command Line & History (input prompt, placeholder, option buttons, output logs)
|
||||
//! - Properties & Dock Panels (field labels, section headers, muted text)
|
||||
//! - Modals & Action Buttons (dialog body, Primary, Danger, Success buttons)
|
||||
//! - Dropdowns & Selection Overlays (visual style picker, item checkmarks)
|
||||
|
||||
use crate::ui::style::common::{accessible_accent, accessible_accent_threshold, wcag_contrast};
|
||||
use iced::{Color, Theme};
|
||||
|
||||
/// Composite a semi-transparent foreground color over a solid background color.
|
||||
fn composite_over(fg: Color, bg: Color) -> Color {
|
||||
let a = fg.a;
|
||||
Color {
|
||||
r: fg.r * a + bg.r * (1.0 - a),
|
||||
g: fg.g * a + bg.g * (1.0 - a),
|
||||
b: fg.b * a + bg.b * (1.0 - a),
|
||||
a: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_core_surfaces_contrast() {
|
||||
for theme in Theme::ALL {
|
||||
let p = theme.palette();
|
||||
|
||||
// Base surface & text
|
||||
let base_contrast = wcag_contrast(p.background.base.text, p.background.base.color);
|
||||
assert!(
|
||||
base_contrast >= 4.5,
|
||||
"Theme {:?} base text ({:.2}:1) fails WCAG AA on base background",
|
||||
theme,
|
||||
base_contrast
|
||||
);
|
||||
|
||||
// Weak surface & text
|
||||
let weak_contrast = wcag_contrast(p.background.weak.text, p.background.weak.color);
|
||||
assert!(
|
||||
weak_contrast >= 4.5,
|
||||
"Theme {:?} weak text ({:.2}:1) fails WCAG AA on weak background",
|
||||
theme,
|
||||
weak_contrast
|
||||
);
|
||||
|
||||
// Strong surface & text
|
||||
let strong_contrast = wcag_contrast(p.background.strong.text, p.background.strong.color);
|
||||
assert!(
|
||||
strong_contrast >= 4.5,
|
||||
"Theme {:?} strong text ({:.2}:1) fails WCAG AA on strong background",
|
||||
theme,
|
||||
strong_contrast
|
||||
);
|
||||
|
||||
// Weakest surface & text
|
||||
let weakest_contrast = wcag_contrast(p.background.weakest.text, p.background.weakest.color);
|
||||
assert!(
|
||||
weakest_contrast >= 4.5,
|
||||
"Theme {:?} weakest text ({:.2}:1) fails WCAG AA on weakest background",
|
||||
theme,
|
||||
weakest_contrast
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ribbon_contrast() {
|
||||
for theme in Theme::ALL {
|
||||
let p = theme.palette();
|
||||
|
||||
// 1. Active Tab: rendered on background.weakest.color
|
||||
let active_tab_bg = p.background.weakest.color;
|
||||
let active_tab_text = p.background.weakest.text;
|
||||
let active_contrast = wcag_contrast(active_tab_text, active_tab_bg);
|
||||
assert!(
|
||||
active_contrast >= 4.5,
|
||||
"Theme {:?} active ribbon tab text ({:.2}:1) fails WCAG AA",
|
||||
theme,
|
||||
active_contrast
|
||||
);
|
||||
|
||||
// 2. Inactive Tab: rendered on ribbon background.base.color with 0.72 alpha
|
||||
let ribbon_bg = p.background.base.color;
|
||||
let inactive_tab_text = composite_over(p.background.base.text.scale_alpha(0.72), ribbon_bg);
|
||||
let inactive_contrast = wcag_contrast(inactive_tab_text, ribbon_bg);
|
||||
assert!(
|
||||
inactive_contrast >= 3.0,
|
||||
"Theme {:?} inactive ribbon tab text ({:.2}:1) fails secondary text threshold (>=3.0:1)",
|
||||
theme,
|
||||
inactive_contrast
|
||||
);
|
||||
|
||||
// 3. Hovered Tab: rendered on background.weak.color
|
||||
let hovered_tab_bg = p.background.weak.color;
|
||||
let hovered_tab_text = p.background.weak.text;
|
||||
let hovered_contrast = wcag_contrast(hovered_tab_text, hovered_tab_bg);
|
||||
assert!(
|
||||
hovered_contrast >= 4.5,
|
||||
"Theme {:?} hovered ribbon tab text ({:.2}:1) fails WCAG AA",
|
||||
theme,
|
||||
hovered_contrast
|
||||
);
|
||||
|
||||
// 4. Ribbon Button normal & hover
|
||||
let btn_normal = wcag_contrast(p.background.base.text, ribbon_bg);
|
||||
assert!(
|
||||
btn_normal >= 4.5,
|
||||
"Theme {:?} normal ribbon button text ({:.2}:1) fails WCAG AA",
|
||||
theme,
|
||||
btn_normal
|
||||
);
|
||||
|
||||
let btn_hover_bg = p.background.weak.color;
|
||||
let btn_hover_text = p.background.weak.text;
|
||||
let btn_hover = wcag_contrast(btn_hover_text, btn_hover_bg);
|
||||
assert!(
|
||||
btn_hover >= 4.5,
|
||||
"Theme {:?} hovered ribbon button text ({:.2}:1) fails WCAG AA",
|
||||
theme,
|
||||
btn_hover
|
||||
);
|
||||
|
||||
// 5. Ribbon Dropdown Popup Panels
|
||||
let popup_bg = p.background.base.color;
|
||||
let popup_row_normal = wcag_contrast(p.background.base.text, popup_bg);
|
||||
assert!(
|
||||
popup_row_normal >= 4.5,
|
||||
"Theme {:?} ribbon popup normal row ({:.2}:1) fails WCAG AA",
|
||||
theme,
|
||||
popup_row_normal
|
||||
);
|
||||
|
||||
let popup_row_hover_bg = p.background.weak.color;
|
||||
let popup_row_hover_text = p.background.weak.text;
|
||||
let popup_row_hover = wcag_contrast(popup_row_hover_text, popup_row_hover_bg);
|
||||
assert!(
|
||||
popup_row_hover >= 4.5,
|
||||
"Theme {:?} ribbon popup hovered row ({:.2}:1) fails WCAG AA",
|
||||
theme,
|
||||
popup_row_hover
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_statusbar_contrast() {
|
||||
for theme in Theme::ALL {
|
||||
let p = theme.palette();
|
||||
let statusbar_bg = p.background.base.color;
|
||||
|
||||
// 1. Status Bar Coordinate & Scale Text (normal text: >= 4.5:1)
|
||||
let label_contrast = wcag_contrast(p.background.base.text, statusbar_bg);
|
||||
assert!(
|
||||
label_contrast >= 4.5,
|
||||
"Theme {:?} statusbar coordinate/scale text ({:.2}:1) fails WCAG AA",
|
||||
theme,
|
||||
label_contrast
|
||||
);
|
||||
|
||||
// 2. Inactive Pill: background is weakest.color, text is base.text with 0.72 alpha (UI badge: >= 3.0:1)
|
||||
let pill_inactive_bg = p.background.weakest.color;
|
||||
let pill_inactive_text =
|
||||
composite_over(p.background.base.text.scale_alpha(0.72), pill_inactive_bg);
|
||||
let pill_inactive_contrast = wcag_contrast(pill_inactive_text, pill_inactive_bg);
|
||||
assert!(
|
||||
pill_inactive_contrast >= 3.0,
|
||||
"Theme {:?} inactive statusbar pill text ({:.2}:1) fails secondary text threshold (>=3.0:1)",
|
||||
theme,
|
||||
pill_inactive_contrast
|
||||
);
|
||||
|
||||
// 3. Active Pill: UI badge component. WCAG 1.4.11 specifies >= 3.0:1 for UI components.
|
||||
let pill_active_bg = p.primary.weak.color;
|
||||
let pill_active_text = p.primary.weak.text;
|
||||
let pill_active_contrast = wcag_contrast(pill_active_text, pill_active_bg);
|
||||
assert!(
|
||||
pill_active_contrast >= 3.0,
|
||||
"Theme {:?} active statusbar pill text ({:.2}:1) fails UI component threshold (>=3.0:1)",
|
||||
theme,
|
||||
pill_active_contrast
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_line_contrast() {
|
||||
for theme in Theme::ALL {
|
||||
let p = theme.palette();
|
||||
let cli_bg = p.background.base.color;
|
||||
|
||||
// 1. Input Value Text (normal text: >= 4.5:1)
|
||||
let input_contrast = wcag_contrast(p.background.base.text, cli_bg);
|
||||
assert!(
|
||||
input_contrast >= 4.5,
|
||||
"Theme {:?} command line input text ({:.2}:1) fails WCAG AA",
|
||||
theme,
|
||||
input_contrast
|
||||
);
|
||||
|
||||
// 1b. Prompt ("Command:") text (normal text: >= 4.5:1)
|
||||
let prompt_color = accessible_accent_threshold(
|
||||
p.success.base.color,
|
||||
cli_bg,
|
||||
p.background.base.text,
|
||||
4.5,
|
||||
);
|
||||
let prompt_contrast = wcag_contrast(prompt_color, cli_bg);
|
||||
assert!(
|
||||
prompt_contrast >= 4.5,
|
||||
"Theme {:?} command prompt text ({:.2}:1) fails WCAG AA (>=4.5:1)",
|
||||
theme,
|
||||
prompt_contrast
|
||||
);
|
||||
|
||||
// 2. Placeholder Text (0.72 alpha, secondary text: >= 3.0:1)
|
||||
let placeholder = composite_over(p.background.base.text.scale_alpha(0.72), cli_bg);
|
||||
let placeholder_contrast = wcag_contrast(placeholder, cli_bg);
|
||||
assert!(
|
||||
placeholder_contrast >= 3.0,
|
||||
"Theme {:?} command line placeholder text ({:.2}:1) fails secondary text threshold (>=3.0:1)",
|
||||
theme,
|
||||
placeholder_contrast
|
||||
);
|
||||
|
||||
// 3. Option Keyword Button: normal is weakest pair, hovered is primary.weak pair
|
||||
let opt_normal_contrast =
|
||||
wcag_contrast(p.background.weakest.text, p.background.weakest.color);
|
||||
assert!(
|
||||
opt_normal_contrast >= 4.5,
|
||||
"Theme {:?} option button normal text ({:.2}:1) fails WCAG AA",
|
||||
theme,
|
||||
opt_normal_contrast
|
||||
);
|
||||
|
||||
let opt_hover_contrast = wcag_contrast(p.primary.weak.text, p.primary.weak.color);
|
||||
assert!(
|
||||
opt_hover_contrast >= 3.0,
|
||||
"Theme {:?} option button hover text ({:.2}:1) fails UI component threshold (>=3.0:1)",
|
||||
theme,
|
||||
opt_hover_contrast
|
||||
);
|
||||
|
||||
// 4. Command History Entries:
|
||||
// EntryKind::Command -> background.base.text (normal text: >= 4.5:1)
|
||||
let cmd_contrast = wcag_contrast(p.background.base.text, cli_bg);
|
||||
assert!(
|
||||
cmd_contrast >= 4.5,
|
||||
"Theme {:?} history command text ({:.2}:1) fails WCAG AA",
|
||||
theme,
|
||||
cmd_contrast
|
||||
);
|
||||
|
||||
// EntryKind::Output -> background.base.text at 0.72 alpha (secondary text: >= 3.0:1)
|
||||
let output_text = composite_over(p.background.base.text.scale_alpha(0.72), cli_bg);
|
||||
let output_contrast = wcag_contrast(output_text, cli_bg);
|
||||
assert!(
|
||||
output_contrast >= 3.0,
|
||||
"Theme {:?} history output text ({:.2}:1) fails secondary text threshold (>=3.0:1)",
|
||||
theme,
|
||||
output_contrast
|
||||
);
|
||||
|
||||
// EntryKind::Info -> primary.base.color (with fallback if primary has low contrast)
|
||||
let info_color =
|
||||
accessible_accent_threshold(p.primary.base.color, cli_bg, p.background.base.text, 4.5);
|
||||
let info_contrast = wcag_contrast(info_color, cli_bg);
|
||||
|
||||
// EntryKind::Error -> danger.base.color
|
||||
let error_contrast = wcag_contrast(p.danger.base.color, cli_bg);
|
||||
assert!(
|
||||
info_contrast >= 4.5,
|
||||
"Theme {:?} info text ({:.2}:1) fails WCAG AA normal text threshold (>=4.5:1)",
|
||||
theme,
|
||||
info_contrast
|
||||
);
|
||||
assert!(
|
||||
error_contrast >= 2.0,
|
||||
"Theme {:?} error text ({:.2}:1) fails non-text indicator threshold (>=2.0:1)",
|
||||
theme,
|
||||
error_contrast
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_properties_and_dock_contrast() {
|
||||
for theme in Theme::ALL {
|
||||
let p = theme.palette();
|
||||
let dock_bg = p.background.base.color;
|
||||
|
||||
// 1. Property Field Labels & Values
|
||||
let label_contrast = wcag_contrast(p.background.base.text, dock_bg);
|
||||
assert!(
|
||||
label_contrast >= 4.5,
|
||||
"Theme {:?} properties label text ({:.2}:1) fails WCAG AA",
|
||||
theme,
|
||||
label_contrast
|
||||
);
|
||||
|
||||
// 2. Muted Helper Text: muted_style uses background.base.text at 0.68 alpha
|
||||
let muted_text = composite_over(p.background.base.text.scale_alpha(0.68), dock_bg);
|
||||
let muted_contrast = wcag_contrast(muted_text, dock_bg);
|
||||
assert!(
|
||||
muted_contrast >= 3.0,
|
||||
"Theme {:?} properties muted text ({:.2}:1) fails secondary text threshold (>=3.0:1)",
|
||||
theme,
|
||||
muted_contrast
|
||||
);
|
||||
|
||||
// 3. Section Header: rendered on background.weakest.color
|
||||
let header_bg = p.background.weakest.color;
|
||||
let header_text = p.background.base.text;
|
||||
let header_contrast = wcag_contrast(header_text, header_bg);
|
||||
assert!(
|
||||
header_contrast >= 4.0,
|
||||
"Theme {:?} properties header text ({:.2}:1) fails header contrast threshold (>=4.0:1)",
|
||||
theme,
|
||||
header_contrast
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_modals_and_action_buttons_contrast() {
|
||||
for theme in Theme::ALL {
|
||||
let p = theme.palette();
|
||||
let modal_bg = p.background.base.color;
|
||||
|
||||
// 1. Modal Body Text (normal text: >= 4.5:1)
|
||||
let body_contrast = wcag_contrast(p.background.base.text, modal_bg);
|
||||
assert!(
|
||||
body_contrast >= 4.5,
|
||||
"Theme {:?} modal body text ({:.2}:1) fails WCAG AA",
|
||||
theme,
|
||||
body_contrast
|
||||
);
|
||||
|
||||
// 2. Primary Action Button: UI button component (WCAG 1.4.11 >= 3.0:1)
|
||||
let primary_btn_contrast = wcag_contrast(p.primary.base.text, p.primary.base.color);
|
||||
assert!(
|
||||
primary_btn_contrast >= 3.0,
|
||||
"Theme {:?} Primary button text ({:.2}:1) fails button threshold (>=3.0:1)",
|
||||
theme,
|
||||
primary_btn_contrast
|
||||
);
|
||||
|
||||
// 3. Danger Action Button: UI button component (WCAG 1.4.11 >= 3.0:1)
|
||||
let danger_btn_contrast = wcag_contrast(p.danger.base.text, p.danger.base.color);
|
||||
assert!(
|
||||
danger_btn_contrast >= 3.0,
|
||||
"Theme {:?} Danger button text ({:.2}:1) fails button threshold (>=3.0:1)",
|
||||
theme,
|
||||
danger_btn_contrast
|
||||
);
|
||||
|
||||
// 4. Success Action Button: UI button component (WCAG 1.4.11 >= 3.0:1)
|
||||
let success_btn_contrast = wcag_contrast(p.success.base.text, p.success.base.color);
|
||||
assert!(
|
||||
success_btn_contrast >= 3.0,
|
||||
"Theme {:?} Success button text ({:.2}:1) fails button threshold (>=3.0:1)",
|
||||
theme,
|
||||
success_btn_contrast
|
||||
);
|
||||
|
||||
// 5. Modal Close Button Hover: danger.strong pair (WCAG 1.4.11 >= 3.0:1)
|
||||
let close_hover_contrast = wcag_contrast(p.danger.strong.text, p.danger.strong.color);
|
||||
assert!(
|
||||
close_hover_contrast >= 3.0,
|
||||
"Theme {:?} close button hover text ({:.2}:1) fails button threshold (>=3.0:1)",
|
||||
theme,
|
||||
close_hover_contrast
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dropdowns_and_selection_overlays() {
|
||||
for theme in Theme::ALL {
|
||||
let p = theme.palette();
|
||||
|
||||
// 1. Visual Style Dropdown Popup: background is weak.color
|
||||
let dropdown_bg = p.background.weak.color;
|
||||
|
||||
// Selected / hovered text uses strong.text
|
||||
let selected_contrast = wcag_contrast(p.background.strong.text, dropdown_bg);
|
||||
assert!(
|
||||
selected_contrast >= 4.5,
|
||||
"Theme {:?} visual style selected text ({:.2}:1) fails WCAG AA",
|
||||
theme,
|
||||
selected_contrast
|
||||
);
|
||||
|
||||
// Inactive item text uses base.text
|
||||
let inactive_contrast = wcag_contrast(p.background.base.text, dropdown_bg);
|
||||
assert!(
|
||||
inactive_contrast >= 4.5,
|
||||
"Theme {:?} visual style inactive text ({:.2}:1) fails WCAG AA",
|
||||
theme,
|
||||
inactive_contrast
|
||||
);
|
||||
|
||||
// 2. Active Checkmark with Dynamic Fallback
|
||||
// Dropdown panel is background.weak.color; highlighted row can be strong.color.
|
||||
let pri = p.primary.base.color;
|
||||
let bg_weak = dropdown_bg;
|
||||
let bg_strong = p.background.strong.color;
|
||||
let fallback = p.background.weak.text;
|
||||
let candidate = accessible_accent(pri, bg_weak, fallback);
|
||||
let tick_color = if wcag_contrast(candidate, bg_strong) >= 3.0 {
|
||||
candidate
|
||||
} else {
|
||||
fallback
|
||||
};
|
||||
let final_tick_contrast_weak = wcag_contrast(tick_color, dropdown_bg);
|
||||
let final_tick_contrast_strong = wcag_contrast(tick_color, p.background.strong.color);
|
||||
assert!(
|
||||
final_tick_contrast_weak >= 3.0,
|
||||
"Theme {:?} checkmark ({:.2}:1) against weak background fails UI threshold (>=3.0:1)",
|
||||
theme,
|
||||
final_tick_contrast_weak
|
||||
);
|
||||
assert!(
|
||||
final_tick_contrast_strong >= 3.0,
|
||||
"Theme {:?} checkmark ({:.2}:1) against strong background fails UI threshold (>=3.0:1)",
|
||||
theme,
|
||||
final_tick_contrast_strong
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_viewport_controls_toggle_buttons_contrast() {
|
||||
for theme in Theme::ALL {
|
||||
let p = theme.palette();
|
||||
|
||||
// 1. Active Toggle Button (Grid & Snap):
|
||||
// Background is primary.weak.color, icon uses primary.weak.text
|
||||
let active_bg = p.primary.weak.color;
|
||||
let active_icon = p.primary.weak.text;
|
||||
let active_contrast = wcag_contrast(active_icon, active_bg);
|
||||
assert!(
|
||||
active_contrast >= 3.0,
|
||||
"Theme {:?} active viewport toggle button icon ({:.2}:1) fails UI threshold (>=3.0:1)",
|
||||
theme,
|
||||
active_contrast
|
||||
);
|
||||
|
||||
// 2. Inactive Toggle Button:
|
||||
// Background is transparent on the viewport HUD bar (background.base.color)
|
||||
let inactive_icon = p.background.base.text;
|
||||
let inactive_contrast = wcag_contrast(inactive_icon, p.background.base.color);
|
||||
assert!(
|
||||
inactive_contrast >= 4.5,
|
||||
"Theme {:?} inactive viewport toggle button icon ({:.2}:1) fails WCAG AA (>=4.5:1)",
|
||||
theme,
|
||||
inactive_contrast
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user