forked from 1b-agent/opencadstudio-upstream
Merge pull request #1380 from huaninstratech/pr/io-locked-drawings
fix(io): open locked, shrinking and cloud-placeholder drawings without crashing
This commit is contained in:
commit
7204d4f98d
6 files changed
+425
-81
No files matched your search
@@ -5511,9 +5511,10 @@ properties={:.1}ms picked={}",
|
|||||||
}
|
}
|
||||||
let eye_dir = r_ucs.transform_vector3(region.snap_direction());
|
let eye_dir = r_ucs.transform_vector3(region.snap_direction());
|
||||||
|
|
||||||
// Faces snap to a canonical upright orientation (never upside
|
// Faces and edges/corners all snap deterministically (world +Z
|
||||||
// down); edges/corners keep the current up-sense so they spin
|
// horizon, north +Y for top/bottom) so a cube click is repeatable
|
||||||
// smoothly around the clicked feature.
|
// and matches the turntable orbit; snap_to_direction preserves an
|
||||||
|
// intentional upside-down sense by sign-flipping only.
|
||||||
let is_face = matches!(region, scene::CubeRegion::Face(_));
|
let is_face = matches!(region, scene::CubeRegion::Face(_));
|
||||||
if self.tabs[i].scene.active_viewport.is_some() {
|
if self.tabs[i].scene.active_viewport.is_some() {
|
||||||
if is_face {
|
if is_face {
|
||||||
|
|||||||
+126
-15
@@ -319,10 +319,24 @@ async fn open_path_with_phase_attempt(
|
|||||||
});
|
});
|
||||||
callback
|
callback
|
||||||
};
|
};
|
||||||
std::fs::File::open(&path2).map_err(|error| OpenAttemptFailure {
|
std::fs::File::open(&path2).map_err(|error| {
|
||||||
message: format!("failed to open drawing: {error}"),
|
// 32 = ERROR_SHARING_VIOLATION, 33 = ERROR_LOCK_VIOLATION:
|
||||||
read_stats: None,
|
// another program holds the drawing without read sharing,
|
||||||
recoverable: false,
|
// so nothing on the machine can read the bytes until it
|
||||||
|
// closes the file — not even read-only.
|
||||||
|
let message = match error.raw_os_error() {
|
||||||
|
Some(32) | Some(33) => format!(
|
||||||
|
"\"{}\" is in use by another program. Close the file there and \
|
||||||
|
reopen it here, or open a copy of the file.",
|
||||||
|
path2.display()
|
||||||
|
),
|
||||||
|
_ => format!("failed to open drawing: {error}"),
|
||||||
|
};
|
||||||
|
OpenAttemptFailure {
|
||||||
|
message,
|
||||||
|
read_stats: None,
|
||||||
|
recoverable: false,
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
let outcome = load_file_for_open(&path2, Some(parser_progress), &attempt)?;
|
let outcome = load_file_for_open(&path2, Some(parser_progress), &attempt)?;
|
||||||
let read_stats = outcome.stats;
|
let read_stats = outcome.stats;
|
||||||
@@ -1036,20 +1050,117 @@ fn read_dwg_path(
|
|||||||
DwgReadOptions::default()
|
DwgReadOptions::default()
|
||||||
};
|
};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let mut reader = {
|
{
|
||||||
let mut reader = DwgReader::from_mmap(path).map_err(ReaderFailure::from_reader)?;
|
// The memory-mapped read is the fast path, but a mapped file is read
|
||||||
reader.options = options;
|
// through the page-fault handler: a file that shrinks while mapped
|
||||||
|
// (a writer saving over it) or a cloud placeholder that cannot page
|
||||||
|
// in raises an in-page exception the process cannot catch. Both are
|
||||||
|
// ordinary I/O errors through plain reads, so anything that can
|
||||||
|
// fault — or refuses to open under a third-party lock — falls back
|
||||||
|
// to one in-memory snapshot read below.
|
||||||
|
if !cloud_placeholder(path) {
|
||||||
|
match DwgReader::from_mmap(path) {
|
||||||
|
Ok(mut reader) => {
|
||||||
|
reader.options = options.clone();
|
||||||
|
if let Some(progress) = &progress {
|
||||||
|
reader.set_progress_callback(progress.clone());
|
||||||
|
}
|
||||||
|
match reader.read_with_stats() {
|
||||||
|
Ok(outcome) => return Ok(outcome),
|
||||||
|
// The file shrank or became unreadable mid-parse;
|
||||||
|
// retry from a snapshot instead of reporting a bare
|
||||||
|
// I/O error.
|
||||||
|
Err(acadrust::DxfError::Io(_)) => {}
|
||||||
|
Err(error) => return Err(ReaderFailure::from_reader(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Locked or unmappable: the snapshot read surfaces the real
|
||||||
|
// error when the file genuinely cannot be read.
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bytes = read_drawing_snapshot(path).map_err(|error| {
|
||||||
|
ReaderFailure::terminal(format!(
|
||||||
|
"failed to open drawing (is it in use by another program?): {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let mut reader = DwgReader::from_stream_with_options(std::io::Cursor::new(bytes), options);
|
||||||
|
if let Some(progress) = progress {
|
||||||
|
reader.set_progress_callback(progress);
|
||||||
|
}
|
||||||
reader
|
reader
|
||||||
};
|
.read_with_stats()
|
||||||
|
.map_err(ReaderFailure::from_reader)
|
||||||
|
}
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
let mut reader = DwgReader::from_file_with_options(path, options)
|
{
|
||||||
.map_err(ReaderFailure::from_reader)?;
|
let mut reader = DwgReader::from_file_with_options(path, options)
|
||||||
if let Some(progress) = progress {
|
.map_err(ReaderFailure::from_reader)?;
|
||||||
reader.set_progress_callback(progress);
|
if let Some(progress) = progress {
|
||||||
|
reader.set_progress_callback(progress);
|
||||||
|
}
|
||||||
|
reader
|
||||||
|
.read_with_stats()
|
||||||
|
.map_err(ReaderFailure::from_reader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows cloud-placeholder files (OneDrive "Files On Demand" and similar)
|
||||||
|
/// page their bytes in on demand; faulting a placeholder through a memory map
|
||||||
|
/// can raise an in-page exception that kills the process. Plain reads hydrate
|
||||||
|
/// the file instead. Checks `FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS`,
|
||||||
|
/// `FILE_ATTRIBUTE_RECALL_ON_OPEN` and `FILE_ATTRIBUTE_OFFLINE`.
|
||||||
|
#[cfg(all(not(target_arch = "wasm32"), target_os = "windows"))]
|
||||||
|
fn cloud_placeholder(path: &Path) -> bool {
|
||||||
|
use std::os::windows::fs::MetadataExt;
|
||||||
|
|
||||||
|
const FILE_ATTRIBUTE_OFFLINE: u32 = 0x1000;
|
||||||
|
const FILE_ATTRIBUTE_RECALL_ON_OPEN: u32 = 0x0004_0000;
|
||||||
|
const FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS: u32 = 0x0040_0000;
|
||||||
|
std::fs::metadata(path)
|
||||||
|
.map(|meta| {
|
||||||
|
let attrs = meta.file_attributes();
|
||||||
|
attrs
|
||||||
|
& (FILE_ATTRIBUTE_OFFLINE
|
||||||
|
| FILE_ATTRIBUTE_RECALL_ON_OPEN
|
||||||
|
| FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS)
|
||||||
|
!= 0
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
|
||||||
|
fn cloud_placeholder(_path: &Path) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the whole drawing through the most permissive share mode the platform
|
||||||
|
/// offers. On Windows `std::fs::read` opens without `FILE_SHARE_DELETE`, so a
|
||||||
|
/// concurrent atomic-save replace makes the read fail even though every byte
|
||||||
|
/// was readable. A snapshot can never fault: a writer shrinking the file
|
||||||
|
/// mid-read just ends the read early.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn read_drawing_snapshot(path: &Path) -> std::io::Result<Vec<u8>> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::io::Read;
|
||||||
|
use std::os::windows::fs::OpenOptionsExt;
|
||||||
|
use windows_sys::Win32::Storage::FileSystem::{
|
||||||
|
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
|
||||||
|
.open(path)?;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
file.read_to_end(&mut bytes)?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
std::fs::read(path)
|
||||||
}
|
}
|
||||||
reader
|
|
||||||
.read_with_stats()
|
|
||||||
.map_err(ReaderFailure::from_reader)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_dxf_path(path: &Path, failsafe: bool) -> Result<acadrust::ReadOutcome, ReaderFailure> {
|
fn read_dxf_path(path: &Path, failsafe: bool) -> Result<acadrust::ReadOutcome, ReaderFailure> {
|
||||||
|
|||||||
@@ -142,24 +142,39 @@ impl CubeRegion {
|
|||||||
|
|
||||||
/// Unit eye-direction vector (from target toward the camera) that
|
/// Unit eye-direction vector (from target toward the camera) that
|
||||||
/// looks straight at this region. Used by `Camera::snap_to_direction`
|
/// looks straight at this region. Used by `Camera::snap_to_direction`
|
||||||
/// which derives the full orientation by re-using the current
|
/// / `snap_to_face` which derive a deterministic horizon (world +Z,
|
||||||
/// camera's up vector, projected onto the plane perpendicular to
|
/// except top/bottom where north +Y is used) so every cube click
|
||||||
/// this direction — so clicking an edge spins the cube around the
|
/// lands repeatably.
|
||||||
/// edge without rolling the user's "up" sense.
|
|
||||||
pub fn snap_direction(self) -> glam::Vec3 {
|
pub fn snap_direction(self) -> glam::Vec3 {
|
||||||
let c = region_centroids()[self.id()];
|
let c = region_centroids()[self.id()];
|
||||||
glam::Vec3::new(c[0], c[1], c[2]).normalize_or(glam::Vec3::Z)
|
glam::Vec3::new(c[0], c[1], c[2]).normalize_or(glam::Vec3::Z)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn opposite(self) -> CubeRegion {
|
pub fn opposite(self) -> CubeRegion {
|
||||||
match self {
|
// Generic opposite: the region whose centroid is nearest to -self.
|
||||||
CubeRegion::Face(FACE_TOP) => CubeRegion::Face(FACE_BOTTOM),
|
// Covers faces, edges and corners so "already there → flip" works
|
||||||
CubeRegion::Face(FACE_BOTTOM) => CubeRegion::Face(FACE_TOP),
|
// for every cube click, not just faces.
|
||||||
CubeRegion::Face(FACE_FRONT) => CubeRegion::Face(FACE_BACK),
|
let centroids = region_centroids();
|
||||||
CubeRegion::Face(FACE_BACK) => CubeRegion::Face(FACE_FRONT),
|
let c = centroids[self.id()];
|
||||||
CubeRegion::Face(FACE_RIGHT) => CubeRegion::Face(FACE_LEFT),
|
let neg = [-c[0], -c[1], -c[2]];
|
||||||
CubeRegion::Face(FACE_LEFT) => CubeRegion::Face(FACE_RIGHT),
|
let mut best = self.id();
|
||||||
other => other,
|
let mut best_d = f32::MAX;
|
||||||
|
for (i, cc) in centroids.iter().enumerate() {
|
||||||
|
let dx = cc[0] - neg[0];
|
||||||
|
let dy = cc[1] - neg[1];
|
||||||
|
let dz = cc[2] - neg[2];
|
||||||
|
let d = dx * dx + dy * dy + dz * dz;
|
||||||
|
if d < best_d {
|
||||||
|
best_d = d;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best < 6 {
|
||||||
|
CubeRegion::Face(best)
|
||||||
|
} else if best < 18 {
|
||||||
|
CubeRegion::Edge(best)
|
||||||
|
} else {
|
||||||
|
CubeRegion::Corner(best)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-34
@@ -645,44 +645,29 @@ impl Camera {
|
|||||||
/// Snap to a canonical view direction (called by ViewCubeSnap).
|
/// Snap to a canonical view direction (called by ViewCubeSnap).
|
||||||
/// `eye_dir` is the unit vector from the target toward the camera.
|
/// `eye_dir` is the unit vector from the target toward the camera.
|
||||||
///
|
///
|
||||||
/// Up vector resolution:
|
/// Deterministic horizon: when looking close to ±Z (top/bottom) the
|
||||||
/// 1. Take the current up.
|
/// up is north (+Y), otherwise world up (+Z), projected onto the
|
||||||
/// 2. Pick the world axis (±X, ±Y, ±Z) whose dot product with the
|
/// plane ⊥ `new_eye`. This matches the turntable `orbit` (which never
|
||||||
/// current up is highest — skipping any axis (anti-)parallel to
|
/// banks) so a cube corner always lands with the same roll and the
|
||||||
/// the new gaze direction.
|
/// base never appears rotated. The sign is flipped only if the
|
||||||
/// 3. Project that axis onto the plane ⊥ `new_eye` and use that as
|
/// current view is intentionally upside-down, preserving that sense.
|
||||||
/// the new up.
|
|
||||||
///
|
|
||||||
/// Result: small tilts collapse onto the nearest world axis (so the
|
|
||||||
/// view always lands cleanly aligned), while genuine flips of the
|
|
||||||
/// up-sense (e.g. orbited upside-down) are preserved.
|
|
||||||
pub fn snap_to_direction(&mut self, eye_dir: Vec3, ucs: glam::Mat4) {
|
pub fn snap_to_direction(&mut self, eye_dir: Vec3, ucs: glam::Mat4) {
|
||||||
let new_eye = eye_dir.normalize_or(Vec3::Z);
|
let new_eye = eye_dir.normalize_or(Vec3::Z);
|
||||||
let cur_up = self.rotation * Vec3::Y;
|
|
||||||
// Candidate up axes are the UCS axes, not world X/Y/Z, so a face snap
|
|
||||||
// lands the view square to the user's coordinate system (in-plane roll
|
|
||||||
// included). Identity `ucs` reproduces the world-aligned snap.
|
|
||||||
let ux = ucs.transform_vector3(Vec3::X).normalize_or(Vec3::X);
|
|
||||||
let uy = ucs.transform_vector3(Vec3::Y).normalize_or(Vec3::Y);
|
let uy = ucs.transform_vector3(Vec3::Y).normalize_or(Vec3::Y);
|
||||||
let uz = ucs.transform_vector3(Vec3::Z).normalize_or(Vec3::Z);
|
let uz = ucs.transform_vector3(Vec3::Z).normalize_or(Vec3::Z);
|
||||||
let cardinals = [ux, -ux, uy, -uy, uz, -uz];
|
// Deterministic horizon: top/bottom views (eye ≈ ±Z) use north (+Y)
|
||||||
let mut best_score = f32::NEG_INFINITY;
|
// as up; every other direction uses world up (+Z). This keeps the
|
||||||
let mut best_up = uz;
|
// cube's "Top Front Right" etc. repeatable and aligned with the
|
||||||
for axis in cardinals {
|
// turntable orbit (which never banks), and avoids the previous
|
||||||
// Skip axes (nearly) collinear with the new gaze — they can't
|
// adaptive choice that picked the nearest cardinal to the current
|
||||||
// serve as up because the projection onto the plane would
|
// up — from a top view that was +Y, so an oblique top corner kept
|
||||||
// vanish.
|
// Y as up and tilted the base (roll) instead of keeping the horizon
|
||||||
if axis.dot(new_eye).abs() > 0.999 {
|
// level. Preserve the sign only to keep an intentionally inverted
|
||||||
continue;
|
// (upside-down) view inverted.
|
||||||
}
|
let cur_up = self.rotation * Vec3::Y;
|
||||||
let score = axis.dot(cur_up);
|
let raw_ref = if new_eye.dot(uz).abs() > 0.9 { uy } else { uz };
|
||||||
if score > best_score {
|
let up_ref = if cur_up.dot(raw_ref) < 0.0 { -raw_ref } else { raw_ref };
|
||||||
best_score = score;
|
let projected = up_ref - new_eye * up_ref.dot(new_eye);
|
||||||
best_up = axis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Project the chosen axis onto the plane ⊥ new_eye and normalize.
|
|
||||||
let projected = best_up - new_eye * best_up.dot(new_eye);
|
|
||||||
let new_up = projected.normalize_or(if new_eye.dot(uz).abs() < 0.99 {
|
let new_up = projected.normalize_or(if new_eye.dot(uz).abs() < 0.99 {
|
||||||
(uz - new_eye * uz.dot(new_eye)).normalize()
|
(uz - new_eye * uz.dot(new_eye)).normalize()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
//! - Properties & Dock Panels (field labels, section headers, muted text)
|
//! - Properties & Dock Panels (field labels, section headers, muted text)
|
||||||
//! - Modals & Action Buttons (dialog body, Primary, Danger, Success buttons)
|
//! - Modals & Action Buttons (dialog body, Primary, Danger, Success buttons)
|
||||||
//! - Dropdowns & Selection Overlays (visual style picker, item checkmarks)
|
//! - Dropdowns & Selection Overlays (visual style picker, item checkmarks)
|
||||||
|
//! - Block Palette (card labels in normal/hover/pressed/placing states,
|
||||||
|
//! header icon buttons, empty-state muted text)
|
||||||
|
|
||||||
use crate::ui::style::common::{accessible_accent, accessible_accent_threshold, wcag_contrast};
|
use crate::ui::style::common::{accessible_accent, accessible_accent_threshold, wcag_contrast};
|
||||||
use iced::{Color, Theme};
|
use iced::{Color, Theme};
|
||||||
@@ -462,3 +464,174 @@ fn test_viewport_controls_toggle_buttons_contrast() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_block_palette_contrast() {
|
||||||
|
use crate::ui::window::block_palette::{
|
||||||
|
block_card_border, block_card_colors, block_icon_button_text_color,
|
||||||
|
};
|
||||||
|
use iced::widget::button::Status as BtnStatus;
|
||||||
|
|
||||||
|
for theme in Theme::ALL {
|
||||||
|
let p = theme.palette();
|
||||||
|
|
||||||
|
// ── 1. Card label pairs resolve to the theme's own text colors ──
|
||||||
|
// Regression guard for the light-theme bug where labels were hardcoded
|
||||||
|
// to `Color::WHITE` (unreadable white-on-light). If anyone reintroduces
|
||||||
|
// a hardcoded foreground, these equality checks fail on every theme
|
||||||
|
// whose surface text is not pure white.
|
||||||
|
let (normal_bg, normal_fg) = block_card_colors(theme, false, BtnStatus::Active);
|
||||||
|
assert_eq!(
|
||||||
|
normal_bg, p.background.base.color,
|
||||||
|
"Theme {:?} block card normal background drifted from base pair",
|
||||||
|
theme
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normal_fg, p.background.base.text,
|
||||||
|
"Theme {:?} block card normal label must use base text (was hardcoded WHITE)",
|
||||||
|
theme
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, hover_fg) = block_card_colors(theme, false, BtnStatus::Hovered);
|
||||||
|
assert_eq!(
|
||||||
|
hover_fg, p.background.strong.text,
|
||||||
|
"Theme {:?} block card hover label must use strong text (was hardcoded WHITE)",
|
||||||
|
theme
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, pressed_fg) = block_card_colors(theme, false, BtnStatus::Pressed);
|
||||||
|
assert_eq!(
|
||||||
|
pressed_fg, p.background.strong.text,
|
||||||
|
"Theme {:?} block card pressed label must use strong text (was hardcoded WHITE)",
|
||||||
|
theme
|
||||||
|
);
|
||||||
|
|
||||||
|
// Default-branch statuses (Active/Disabled/Focused/...) share the base pair.
|
||||||
|
let (_, disabled_fg) = block_card_colors(theme, false, BtnStatus::Disabled);
|
||||||
|
assert_eq!(
|
||||||
|
disabled_fg, p.background.base.text,
|
||||||
|
"Theme {:?} block card default-state label must use base text",
|
||||||
|
theme
|
||||||
|
);
|
||||||
|
|
||||||
|
// Placing (selected) cards always use the primary pair, regardless of
|
||||||
|
// hover — the background does not switch on hover while placing.
|
||||||
|
for status in [BtnStatus::Active, BtnStatus::Hovered, BtnStatus::Pressed] {
|
||||||
|
let (bg, fg) = block_card_colors(theme, true, status);
|
||||||
|
assert_eq!(bg, p.primary.base.color, "Theme {:?} placing card bg must be primary.base", theme);
|
||||||
|
assert_eq!(fg, p.primary.base.text, "Theme {:?} placing card label must be primary.base.text", theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Card label contrast: normal / hover / pressed ──
|
||||||
|
// 11px single-line labels are normal text → WCAG AA >= 4.5:1.
|
||||||
|
let normal_contrast = wcag_contrast(normal_fg, normal_bg);
|
||||||
|
assert!(
|
||||||
|
normal_contrast >= 4.5,
|
||||||
|
"Theme {:?} block card label ({:.2}:1) fails WCAG AA on card background",
|
||||||
|
theme, normal_contrast
|
||||||
|
);
|
||||||
|
|
||||||
|
let (hover_bg, hover_fg) = block_card_colors(theme, false, BtnStatus::Hovered);
|
||||||
|
let hover_contrast = wcag_contrast(hover_fg, hover_bg);
|
||||||
|
assert!(
|
||||||
|
hover_contrast >= 4.5,
|
||||||
|
"Theme {:?} block card hovered label ({:.2}:1) fails WCAG AA on hovered background",
|
||||||
|
theme, hover_contrast
|
||||||
|
);
|
||||||
|
|
||||||
|
let (pressed_bg, pressed_fg) = block_card_colors(theme, false, BtnStatus::Pressed);
|
||||||
|
let pressed_contrast = wcag_contrast(pressed_fg, pressed_bg);
|
||||||
|
assert!(
|
||||||
|
pressed_contrast >= 4.5,
|
||||||
|
"Theme {:?} block card pressed label ({:.2}:1) fails WCAG AA on pressed background",
|
||||||
|
theme, pressed_contrast
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 3. Placing card contrast ──
|
||||||
|
// Selected-card label on `primary.base`: UI button component threshold
|
||||||
|
// (>= 3.0:1 per WCAG 1.4.11, matching the Primary-button test above).
|
||||||
|
let (placing_bg, placing_fg) = block_card_colors(theme, true, BtnStatus::Active);
|
||||||
|
let placing_contrast = wcag_contrast(placing_fg, placing_bg);
|
||||||
|
assert!(
|
||||||
|
placing_contrast >= 3.0,
|
||||||
|
"Theme {:?} placing block card label ({:.2}:1) fails button threshold (>=3.0:1)",
|
||||||
|
theme, placing_contrast
|
||||||
|
);
|
||||||
|
// Placing + hover must not silently drop contrast (bg is sticky).
|
||||||
|
let (placing_hover_bg, placing_hover_fg) =
|
||||||
|
block_card_colors(theme, true, BtnStatus::Hovered);
|
||||||
|
let placing_hover_contrast = wcag_contrast(placing_hover_fg, placing_hover_bg);
|
||||||
|
assert!(
|
||||||
|
placing_hover_contrast >= 3.0,
|
||||||
|
"Theme {:?} placing+hover block card label ({:.2}:1) fails button threshold (>=3.0:1)",
|
||||||
|
theme, placing_hover_contrast
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 4. Card border is theme-driven (no invisible borders) ──
|
||||||
|
assert_eq!(
|
||||||
|
block_card_border(theme, false),
|
||||||
|
p.background.neutral.color,
|
||||||
|
"Theme {:?} block card border must use neutral color",
|
||||||
|
theme
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
block_card_border(theme, true),
|
||||||
|
p.primary.base.color,
|
||||||
|
"Theme {:?} placing block card border must use primary color",
|
||||||
|
theme
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 5. Header icon buttons (glyphs inherit button text_color) ──
|
||||||
|
// Resting buttons are transparent over the dock (`base`) background.
|
||||||
|
let icon_rest = block_icon_button_text_color(theme, BtnStatus::Active);
|
||||||
|
assert_eq!(icon_rest, p.background.base.text);
|
||||||
|
let icon_rest_contrast = wcag_contrast(icon_rest, p.background.base.color);
|
||||||
|
assert!(
|
||||||
|
icon_rest_contrast >= 4.5,
|
||||||
|
"Theme {:?} block palette icon button ({:.2}:1) fails WCAG AA on dock background",
|
||||||
|
theme, icon_rest_contrast
|
||||||
|
);
|
||||||
|
// Hovered/pressed buttons sit on the `strong` surface with its text.
|
||||||
|
for status in [BtnStatus::Hovered, BtnStatus::Pressed] {
|
||||||
|
let icon_fg = block_icon_button_text_color(theme, status);
|
||||||
|
assert_eq!(
|
||||||
|
icon_fg, p.background.strong.text,
|
||||||
|
"Theme {:?} hovered icon button must use strong text",
|
||||||
|
theme
|
||||||
|
);
|
||||||
|
let c = wcag_contrast(icon_fg, p.background.strong.color);
|
||||||
|
assert!(
|
||||||
|
c >= 4.5,
|
||||||
|
"Theme {:?} hovered icon button ({:.2}:1) fails WCAG AA on hovered background",
|
||||||
|
theme, c
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Empty-state muted text ("No blocks in this drawing") ──
|
||||||
|
// Rendered as `base.text` at 0.72 alpha over the dock `base`
|
||||||
|
// background → secondary text threshold >= 3.0:1. (The old hardcoded
|
||||||
|
// 0.55-gray failed this on light themes.)
|
||||||
|
let empty_text = composite_over(p.background.base.text.scale_alpha(0.72), p.background.base.color);
|
||||||
|
let empty_contrast = wcag_contrast(empty_text, p.background.base.color);
|
||||||
|
assert!(
|
||||||
|
empty_contrast >= 3.0,
|
||||||
|
"Theme {:?} block palette empty-state text ({:.2}:1) fails secondary text threshold (>=3.0:1)",
|
||||||
|
theme, empty_contrast
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 7. Hardcoded-white regression probe ──
|
||||||
|
// The exact reported bug: pure white labels on the light card
|
||||||
|
// background. Every theme's card background must NOT be near-white
|
||||||
|
// with white text — i.e. white-on-card must fail while the real
|
||||||
|
// theme pair passes (proves the fix matters on light themes and the
|
||||||
|
// test would catch a WHITE hardcode).
|
||||||
|
let white_on_normal = wcag_contrast(Color::WHITE, normal_bg);
|
||||||
|
if white_on_normal < 4.5 {
|
||||||
|
assert!(
|
||||||
|
normal_contrast >= 4.5,
|
||||||
|
"Theme {:?} needs theme-aware labels: white-on-card is {:.2}:1 but theme pair is {:.2}:1",
|
||||||
|
theme, white_on_normal, normal_contrast
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -264,7 +264,20 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element<
|
|||||||
} else {
|
} else {
|
||||||
"No matches"
|
"No matches"
|
||||||
};
|
};
|
||||||
container(text(crate::t!(msg)).size(12).color(iced::Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 }))
|
container(
|
||||||
|
text(crate::t!(msg))
|
||||||
|
.size(12)
|
||||||
|
.style(|theme: &Theme| iced::widget::text::Style {
|
||||||
|
color: Some(
|
||||||
|
theme
|
||||||
|
.palette()
|
||||||
|
.background
|
||||||
|
.base
|
||||||
|
.text
|
||||||
|
.scale_alpha(0.72),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.center_x(Fill)
|
.center_x(Fill)
|
||||||
.center_y(Fill)
|
.center_y(Fill)
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
@@ -307,6 +320,56 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element<
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Theme-aware foreground/background pair for a block card.
|
||||||
|
///
|
||||||
|
/// Centralizes the card's text/background mapping so the contrast suite can
|
||||||
|
/// exercise the exact colors the UI renders (instead of re-implementing the
|
||||||
|
/// match). Normal cards use the `base` pair, hovered/pressed cards use the
|
||||||
|
/// `strong` pair, and the actively-placing card uses the `primary.base` pair.
|
||||||
|
pub(crate) fn block_card_colors(
|
||||||
|
theme: &Theme,
|
||||||
|
is_placing: bool,
|
||||||
|
status: button::Status,
|
||||||
|
) -> (Color, Color) {
|
||||||
|
let palette = theme.palette();
|
||||||
|
if is_placing {
|
||||||
|
(palette.primary.base.color, palette.primary.base.text)
|
||||||
|
} else {
|
||||||
|
match status {
|
||||||
|
button::Status::Hovered | button::Status::Pressed => {
|
||||||
|
(palette.background.strong.color, palette.background.strong.text)
|
||||||
|
}
|
||||||
|
_ => (
|
||||||
|
palette.background.base.color,
|
||||||
|
palette.background.base.text,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Border color for a block card in a given state (exposed for contrast tests).
|
||||||
|
pub(crate) fn block_card_border(theme: &Theme, is_placing: bool) -> Color {
|
||||||
|
let palette = theme.palette();
|
||||||
|
if is_placing {
|
||||||
|
palette.primary.base.color
|
||||||
|
} else {
|
||||||
|
palette.background.neutral.color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Theme-aware foreground for the palette's header icon buttons.
|
||||||
|
///
|
||||||
|
/// Transparent resting state composites over the dock (`base`) background;
|
||||||
|
/// hovered/pressed state sits on the `strong` surface, so each state pairs
|
||||||
|
/// with its own background's text color to preserve contrast.
|
||||||
|
pub(crate) fn block_icon_button_text_color(theme: &Theme, status: button::Status) -> Color {
|
||||||
|
let palette = theme.palette();
|
||||||
|
match status {
|
||||||
|
button::Status::Hovered | button::Status::Pressed => palette.background.strong.text,
|
||||||
|
_ => palette.background.base.text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'a, Message> {
|
fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'a, Message> {
|
||||||
let is_placing = palette.placing.as_deref() == Some(block.name.as_str());
|
let is_placing = palette.placing.as_deref() == Some(block.name.as_str());
|
||||||
let preview = canvas(BlockPreviewCanvas { wires: &block.wires })
|
let preview = canvas(BlockPreviewCanvas { wires: &block.wires })
|
||||||
@@ -315,9 +378,13 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'
|
|||||||
// Fixed height guarantees the label occupies exactly one line, so every
|
// Fixed height guarantees the label occupies exactly one line, so every
|
||||||
// cell in a row keeps the same height even for long names (the elide budget
|
// cell in a row keeps the same height even for long names (the elide budget
|
||||||
// handles overflow, and this clips anything that still won't fit).
|
// handles overflow, and this clips anything that still won't fit).
|
||||||
|
//
|
||||||
|
// NOTE: no explicit `.color(...)` here — the label inherits the button's
|
||||||
|
// `text_color` so normal/hover/placing states each get a theme-aware
|
||||||
|
// foreground with sufficient contrast on both light and dark themes.
|
||||||
|
// (A hardcoded `Color::WHITE` is unreadable on light themes.)
|
||||||
let label = text(crate::ui::text_util::elide(&block.name, MAX_LABEL_CHARS))
|
let label = text(crate::ui::text_util::elide(&block.name, MAX_LABEL_CHARS))
|
||||||
.size(11)
|
.size(11)
|
||||||
.color(Color::WHITE)
|
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
.height(Length::Fixed(LABEL_LINE_H))
|
.height(Length::Fixed(LABEL_LINE_H))
|
||||||
.center();
|
.center();
|
||||||
@@ -326,18 +393,8 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'
|
|||||||
.on_press(Message::BlockPalette(BlockPaletteMsg::Insert(block.name.clone())))
|
.on_press(Message::BlockPalette(BlockPaletteMsg::Insert(block.name.clone())))
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
.style(move |theme: &Theme, status| {
|
.style(move |theme: &Theme, status| {
|
||||||
let palette = theme.palette();
|
let (bg, fg) = block_card_colors(theme, is_placing, status);
|
||||||
let neutral = palette.background.neutral.color;
|
let border = block_card_border(theme, is_placing);
|
||||||
let base = palette.background.base.color;
|
|
||||||
let strong = palette.background.strong.color;
|
|
||||||
let (bg, border) = if is_placing {
|
|
||||||
(palette.primary.base.color, palette.primary.base.color)
|
|
||||||
} else {
|
|
||||||
match status {
|
|
||||||
button::Status::Hovered | button::Status::Pressed => (strong, neutral),
|
|
||||||
_ => (base, neutral),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
button::Style {
|
button::Style {
|
||||||
background: Some(Background::Color(bg)),
|
background: Some(Background::Color(bg)),
|
||||||
border: Border {
|
border: Border {
|
||||||
@@ -345,7 +402,7 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'
|
|||||||
width: 1.0,
|
width: 1.0,
|
||||||
radius: 3.0.into(),
|
radius: 3.0.into(),
|
||||||
},
|
},
|
||||||
text_color: Color::WHITE,
|
text_color: fg,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -354,7 +411,9 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'
|
|||||||
|
|
||||||
fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message> {
|
fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message> {
|
||||||
let icon_el: Element<'_, Message> = match icon {
|
let icon_el: Element<'_, Message> = match icon {
|
||||||
IconKind::Glyph(s) => text(s).size(15).color(Color::WHITE).into(),
|
// No explicit color — inherits the button's theme-aware `text_color`
|
||||||
|
// so glyphs stay legible on light themes.
|
||||||
|
IconKind::Glyph(s) => text(s).size(15).into(),
|
||||||
IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, TOOL_H),
|
IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, TOOL_H),
|
||||||
};
|
};
|
||||||
button(icon_el)
|
button(icon_el)
|
||||||
@@ -372,7 +431,7 @@ fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message>
|
|||||||
radius: 3.0.into(),
|
radius: 3.0.into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
text_color: Color::WHITE,
|
text_color: block_icon_button_text_color(theme, status),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
|
|||||||
Reference in new issue
Block a user