diff --git a/Cargo.lock b/Cargo.lock index bfb39bf61..5ad30ad66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,6 +45,7 @@ dependencies = [ "objc2-foundation 0.2.2", "ocs_plugin_api", "open", + "pollster", "printpdf", "rayon", "resvg", diff --git a/Cargo.toml b/Cargo.toml index 062ce7acb..ae041add8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ ttf-parser = "0.25" cosmic-text = "0.15" unicode-bidi = "0.3" lyon_tessellation = "1.0.20" +pollster = "0.4" [dev-dependencies] fluent-syntax = "0.12" diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index 7535af350..cffd8f1c4 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -193,7 +193,7 @@ fn call_timeout() -> Duration { /// Per-request-kind timeout floors. The user-configured default is raised to /// these minima so that no request kind can be configured into an unsafe value. -fn request_timeout(kind: &'static str) -> Duration { +pub(crate) fn request_timeout(kind: &'static str) -> Duration { base_max_floor(call_timeout(), kind) } @@ -228,7 +228,7 @@ fn base_max_floor(base: Duration, kind: &'static str) -> Duration { base.max(floor) } -fn request_kind(req: &HostRequest) -> &'static str { +pub(crate) fn request_kind(req: &HostRequest) -> &'static str { match req { HostRequest::GetManifest => "GetManifest", HostRequest::GetRibbon => "GetRibbon", diff --git a/crates/ocs_plugin_api/src/process/v4.rs b/crates/ocs_plugin_api/src/process/v4.rs index 05e7dd490..102255ca4 100644 --- a/crates/ocs_plugin_api/src/process/v4.rs +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -17,63 +17,7 @@ use crate::ipc::v4::server::{ default_notify_rate_limit, run_host_reader_thread, HostIncoming, RateLimiter, V4HostShared, }; use crate::process::PluginError; - -/// Default maximum time to wait for a plugin call to respond. -const CALL_TIMEOUT_DEFAULT: Duration = Duration::from_secs(30); - -fn call_timeout() -> Duration { - std::env::var("OCS_PLUGIN_CALL_TIMEOUT_SECS") - .ok() - .and_then(|s| s.parse().ok()) - .map(Duration::from_secs) - .unwrap_or(CALL_TIMEOUT_DEFAULT) -} - -/// Per-request-kind timeout floors. -fn request_timeout(kind: &'static str) -> Duration { - base_max_floor(call_timeout(), kind) -} - -fn execute_code_timeout() -> Duration { - const DEFAULT: Duration = Duration::from_secs(60); - std::env::var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS") - .ok() - .and_then(|s| s.parse().ok()) - .map(Duration::from_secs) - .unwrap_or(DEFAULT) - .max(DEFAULT) -} - -fn base_max_floor(base: Duration, kind: &'static str) -> Duration { - #[cfg(test)] - if let Some(secs) = std::env::var("OCS_PLUGIN_TEST_FLOOR_SECS") - .ok() - .and_then(|s| s.parse().ok()) - { - return base.max(Duration::from_secs(secs)); - } - let floor = match kind { - "GetManifest" | "GetRibbon" => Duration::from_secs(5), - "Dispatch" => Duration::from_secs(10), - "InteractiveEvent" | "GetPrompt" | "NeedsEntityPick" => Duration::from_secs(2), - "ExecuteCode" => execute_code_timeout(), - _ => Duration::from_secs(1), - }; - base.max(floor) -} - -fn request_kind(req: &HostRequest) -> &'static str { - match req { - HostRequest::GetManifest => "GetManifest", - HostRequest::GetRibbon => "GetRibbon", - HostRequest::Dispatch { .. } => "Dispatch", - HostRequest::InteractiveEvent { .. } => "InteractiveEvent", - HostRequest::GetPrompt { .. } => "GetPrompt", - HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick", - HostRequest::ExecuteCode { .. } => "ExecuteCode", - HostRequest::Shutdown => "Shutdown", - } -} +use crate::process::{request_kind, request_timeout}; type DeferredRequest = (u64, Option, Box); diff --git a/packaging/windows/main.wxs b/packaging/windows/main.wxs index 55132ffc0..0db903780 100644 --- a/packaging/windows/main.wxs +++ b/packaging/windows/main.wxs @@ -196,15 +196,6 @@ - - - NOT REMOVE - diff --git a/src/app/mod.rs b/src/app/mod.rs index a33e7e081..8a4319beb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4393,6 +4393,17 @@ impl OpenCADStudio { // `--read-only` disables saving. `--script` queues command lines. let cfg = crate::cli::gui_config(); s.read_only = cfg.read_only; + // GPU backend / renderer fallback: the resolver ran before iced + // booted, so surface its verdict here where the user can see it. + if let Some(notice) = cfg.gpu_fallback_notice { + s.command_line.push_warning(¬ice); + crate::scene::pipeline::report_gpu_line(&format!("[gpu] {notice}")); + } + if cfg.gpu_compat_auto { + let notice = crate::gpu_backend::compat_notice(); + s.command_line.push_warning(¬ice); + crate::scene::pipeline::report_gpu_line(&format!("[gpu] {notice}")); + } let cli_open: Task = if !cfg.files.is_empty() { Task::batch( cfg.files diff --git a/src/cli.rs b/src/cli.rs index 881932bfe..1e2d5153c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -100,6 +100,13 @@ pub struct Cli { #[arg(help = crate::t!("Log level (error|warn|info|debug|trace). Also reads RUST_LOG.").into_owned(), long_help = None)] pub log: Option, + /// Internal: probe one GPU backend offscreen (`dx12|vulkan|gl|sw`), print + /// one JSON line on success, then exit 0/1. Spawned by the GPU backend + /// resolver so a legacy-driver abort kills only the probe child, never + /// the editor itself. + #[arg(long, value_name = "BACKEND", hide = true)] + pub gpu_probe: Option, + /// Internal: run as the plugin runner child process. #[arg(long, value_names = ["SOCKET", "CDYLIB"], num_args = 2, hide = true)] pub ocs_plugin_runner: Option>, @@ -124,6 +131,12 @@ pub struct GuiConfig { pub compat_renderer: bool, /// Command lines to run once the editor is up. pub script_lines: Vec, + /// Why this launch switched graphics backend, if it did. Shown once + /// as a command-line warning so the switch is never silent. + pub gpu_fallback_notice: Option, + /// The GPU probe found no shader storage buffers: the packed + /// compatibility renderer was enabled automatically for this session. + pub gpu_compat_auto: bool, } /// Set once by `main` before the GUI boots; read by `app::boot`. diff --git a/src/gpu_backend.rs b/src/gpu_backend.rs new file mode 100644 index 000000000..216db5e86 --- /dev/null +++ b/src/gpu_backend.rs @@ -0,0 +1,1030 @@ +//! Cross-platform GPU-backend selection with automatic fallback for older GPUs. +//! +//! Background: older GPUs without usable DirectX 12 Feature Level 12_0 or +//! working Vulkan support (e.g. legacy Intel iGPUs and other old discrete or +//! integrated parts), and old GLES drivers, can crash the app at startup or +//! on the first drawing viewport: +//! - DX12 path: iced's wgpu `StagingBelt` hits an invalid staging buffer +//! and panics in `Buffer::get_mapped_range` (`panic = "abort"` in +//! release, so the process dies silently). +//! - GLES path: shader compilation failure (e.g. `no default precision` +//! inside iced's own gradient pipeline) on old Mesa. +//! Selecting a working backend via `WGPU_BACKEND` before iced boots avoids +//! both classes. +//! +//! How it works: +//! - an explicit `--backend` / `--safe-mode` / pre-set `WGPU_BACKEND` always wins; +//! - otherwise each candidate backend (Windows: dx12, vulkan, gl; Linux: +//! vulkan, gl) is exercised in a short-lived `--gpu-probe ` +//! child process: device request, staging-style mapped upload, render +//! pipeline creation (catches shader-compile failures), offscreen render +//! and readback. The first fully-passing backend wins; +//! - backends implicated in the previous run's crash (crash sentinel) are +//! skipped; +//! - if no hardware backend passes, one software-adapter probe (`--gpu-probe +//! sw`, e.g. WARP / llvmpipe / SwiftShader) is tried: slow but alive; +//! - the winning probe also reports adapter limits, so the packed +//! compatibility renderer is enabled automatically on GPUs without +//! shader storage buffers. +//! +//! Probes run out-of-process because the failure modes are `panic = "abort"` +//! or driver hangs: an in-process probe would die or stall with the app +//! instead of reporting failure. + +use std::path::PathBuf; +use std::time::Duration; + +/// Hidden child-process flag: `--gpu-probe `. +pub const PROBE_ARG: &str = "--gpu-probe"; +/// Per-backend probe timeout: broken drivers can hang enumeration, so never +/// block boot long on a single backend. +pub const PROBE_TIMEOUT: Duration = Duration::from_secs(10); +/// Test hook: every probe reports failure without spawning a child. +pub const FORCE_FAIL_ENV: &str = "OCS_GPU_PROBE_FORCE_FAIL"; +/// Test hook: the first candidate probe reports success without spawning. +pub const FORCE_OK_ENV: &str = "OCS_GPU_PROBE_FORCE_OK"; +/// Test hook: `max_storage_buffers_per_shader_stage` of the synthetic success +/// (drives compat-renderer auto-enable in tests). +pub const FORCE_CAPS_ENV: &str = "OCS_GPU_PROBE_FORCE_CAPS"; +/// Test hook: point the sentinel at a temp dir instead of the user config dir. +pub const SENTINEL_DIR_ENV: &str = "OCS_GPU_SENTINEL_DIR"; + +/// Wire shader path needs one storage buffer; batched hatch needs four (see +/// `DeviceCapabilities`). Below one storage buffer the packed compatibility +/// renderer is required, so the probe enables it automatically. +pub const WIRE_STORAGE_NEEDED: u32 = 1; + +/// Candidate backends in preference order. Windows keeps DX12/Vulkan first so +/// the AMD OpenGL ICD is never touched unless both fail; Linux prefers Vulkan +/// because old Mesa GLES is a known crash source. macOS stays on its Metal +/// default (guard inactive there). +pub fn candidate_backends() -> Vec { + if cfg!(target_os = "windows") { + vec!["dx12".to_string(), "vulkan".to_string(), "gl".to_string()] + } else if cfg!(target_os = "linux") { + vec!["vulkan".to_string(), "gl".to_string()] + } else { + Vec::new() + } +} + +/// Whether the resolver is active on this OS (Windows + Linux; macOS keeps +/// iced's Metal default, wasm has no native GPU choice to make). +pub fn gpu_guard_active() -> bool { + !candidate_backends().is_empty() +} + +/// Capabilities reported by a passing probe child (one JSON line on stdout). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProbeCaps { + /// Backend that passed (`dx12`, `vulkan` or `gl`). + pub backend: String, + /// Human-readable adapter name for logs and notices. + pub adapter: String, + /// `max_storage_buffers_per_shader_stage` of the adapter. + pub max_storage: u32, + /// True when this is a software rasterizer (WARP / llvmpipe / SwiftShader). + pub software: bool, +} + +impl ProbeCaps { + fn synthetic(backend: &str) -> Self { + let max_storage = std::env::var(FORCE_CAPS_ENV) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(8); + Self { + backend: backend.to_string(), + adapter: "synthetic-test-adapter".to_string(), + max_storage, + software: false, + } + } + + /// Parse the JSON line a probe child prints on success. + fn from_json_line(line: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(line.trim()).ok()?; + Some(Self { + backend: v.get("backend")?.as_str()?.to_string(), + adapter: v.get("adapter")?.as_str().unwrap_or("unknown").to_string(), + max_storage: v.get("max_storage")?.as_u64()? as u32, + software: v.get("software")?.as_bool()?, + }) + } + + fn to_json_line(&self) -> String { + serde_json::json!({ + "backend": self.backend, + "adapter": self.adapter, + "max_storage": self.max_storage, + "software": self.software, + }) + .to_string() + } +} + +/// Whether the packed compatibility renderer is needed for these caps. +/// Software rasterizers always take it: the packed path works everywhere and +/// these GPUs are slow enough already. +pub fn needs_compat_renderer(caps: &ProbeCaps) -> bool { + caps.software || caps.max_storage < WIRE_STORAGE_NEEDED +} + +/// What the resolver decided, and why (for the startup notice). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendDecision { + /// Value to set `WGPU_BACKEND` to, or `None` to leave the env alone. + pub backend_value: Option, + /// Human-readable reason when this is not the silent fast path. + pub reason: Option, + /// Probe says the packed compatibility renderer is needed. + pub compat_renderer: bool, + /// Adapter name of the winning probe, for logs. + pub adapter_name: Option, +} + +/// Pure decision function — all IO (env reads, probes, sentinel) happens in +/// the caller so this is unit-testable. +/// * `candidates`: platform-ordered backends to consider. +/// * `excluded`: backends implicated in the previous run's crash. +/// * `results`: probe outcome per tried backend, in any order. +/// * `software`: software-adapter probe outcome. +#[allow(clippy::too_many_arguments)] +pub fn decide_backend( + explicit_cli: Option<&str>, + env_backend: Option<&str>, + safe_mode: bool, + candidates: &[String], + excluded: &[String], + results: &[(String, Option)], + software: Option, + guard_active: bool, +) -> BackendDecision { + if let Some(b) = explicit_cli { + return BackendDecision { + backend_value: Some(b.to_string()), + reason: None, + compat_renderer: false, + adapter_name: None, + }; + } + if let Some(b) = env_backend { + return BackendDecision { + backend_value: Some(b.to_string()), + reason: None, + compat_renderer: false, + adapter_name: None, + }; + } + if safe_mode { + return BackendDecision { + backend_value: Some("gl".to_string()), + reason: Some("safe mode requested (--safe-mode)".to_string()), + compat_renderer: false, + adapter_name: None, + }; + } + if !guard_active { + return BackendDecision { + backend_value: None, + reason: None, + compat_renderer: false, + adapter_name: None, + }; + } + let failed: Vec<&String> = results + .iter() + .filter(|(_, r)| r.is_none()) + .map(|(b, _)| b) + .collect(); + // First passing backend in preference order, skipping backends that died + // with the previous run. + for backend in candidates { + if excluded.iter().any(|e| e == backend) { + continue; + } + if let Some((_, Some(caps))) = results.iter().find(|(b, _)| b == backend) { + let fell_back = backend != &candidates[0] || !excluded.is_empty(); + let reason = if fell_back { + Some(failover_reason(&failed, excluded, backend)) + } else { + None + }; + return BackendDecision { + backend_value: Some(backend.clone()), + reason, + compat_renderer: needs_compat_renderer(caps), + adapter_name: Some(caps.adapter.clone()), + }; + } + } + if let Some(caps) = software { + return BackendDecision { + backend_value: Some(caps.backend.clone()), + reason: Some( + "no hardware-accelerated backend survived probing, \ + so this launch uses software rendering (slow, but stable)" + .to_string(), + ), + compat_renderer: true, + adapter_name: Some(caps.adapter.clone()), + }; + } + // Best effort: GL is the most widely available backend on old hardware. + BackendDecision { + backend_value: Some("gl".to_string()), + reason: Some( + "every graphics probe failed, so this launch tries the GL \ + compatibility backend as a last resort" + .to_string(), + ), + compat_renderer: true, + adapter_name: None, + } +} + +fn failover_reason(failed: &[&String], excluded: &[String], chosen: &str) -> String { + let mut parts: Vec = failed.iter().map(|b| b.to_string()).collect(); + parts.extend(excluded.iter().cloned()); + parts.sort(); + parts.dedup(); + let what_failed = if parts.is_empty() { + "the preferred backend was skipped after the previous crash".to_string() + } else { + format!("{} {}", parts.join(", "), "is not usable on this GPU") + }; + let chosen_text = match chosen { + "gl" => "the GL compatibility backend", + "vulkan" => "Vulkan", + "dx12" => "DirectX 12", + other => other, + }; + format!("{what_failed}, so this launch uses {chosen_text}") +} + +/// Notice text shown in the command line when the backend was switched. +pub fn fallback_notice(reason: &str) -> String { + format!( + "Graphics compatibility mode: {reason}. \ + If drawings render slowly, update the GPU driver; \ + override with --backend ." + ) +} + +/// Notice text when the packed compatibility renderer was auto-enabled. +pub fn compat_notice() -> String { + "Older GPU detected: using the packed compatibility renderer \ + (no shader storage buffers). Override with --compat-renderer / --backend." + .to_string() +} + +/// Sentinel file: armed before `app::run`, disarmed on clean exit. A stale +/// file at startup means the last run died while the GPU was active; its +/// content names the attempted backend(s) so they are skipped next launch. +pub fn sentinel_path() -> Option { + if let Ok(dir) = std::env::var(SENTINEL_DIR_ENV) { + return Some(PathBuf::from(dir).join("gpu_crash_sentinel")); + } + crate::config::config_dir().map(|d| d.join("gpu_crash_sentinel")) +} + +pub fn previous_run_crashed() -> bool { + sentinel_path().is_some_and(|p| p.exists()) +} + +/// Backends implicated in the previous run's crash, from the stale sentinel. +pub fn crashed_backends() -> Vec { + let Some(p) = sentinel_path() else { + return Vec::new(); + }; + std::fs::read_to_string(p) + .unwrap_or_default() + .lines() + .find_map(|l| l.strip_prefix("backend=")) + .unwrap_or("") + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +pub fn arm_sentinel(backend: &str) { + if let Some(p) = sentinel_path() { + if let Some(parent) = p.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write( + &p, + format!( + "backend={backend}\npid={}\nt={}\n", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + ), + ); + } +} + +pub fn disarm_sentinel() { + if let Some(p) = sentinel_path() { + let _ = std::fs::remove_file(&p); + } +} + +/// Outcome of the whole resolution: what to set, what to tell the user, and +/// whether the packed renderer was auto-enabled. +pub struct GpuResolution { + /// Value written to `WGPU_BACKEND` (`None` = env untouched). + pub backend_value: Option, + /// Fallback reason for the startup notice, if any. + pub reason: Option, + /// Probe says the packed compatibility renderer is needed. + pub compat_renderer: bool, +} + +/// Spawn one `--gpu-probe ` child and return its reported capabilities +/// on success. `None` = crashed / failed / timed out. +fn run_probe_for(arg: &str) -> Option { + if std::env::var_os(FORCE_FAIL_ENV).is_some() { + return None; + } + if std::env::var_os(FORCE_OK_ENV).is_some() { + return Some(ProbeCaps::synthetic(arg)); + } + let exe = std::env::current_exe().ok()?; + let mut child = std::process::Command::new(exe) + .arg(PROBE_ARG) + .arg(arg) + .env("RUST_LOG", "error") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + let start = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return None; + } + let out = child.wait_with_output().ok()?; + let line = String::from_utf8_lossy(&out.stdout); + return line.lines().rev().find_map(ProbeCaps::from_json_line); + } + Ok(None) => { + if start.elapsed() > PROBE_TIMEOUT { + let _ = child.kill(); + let _ = child.wait(); + eprintln!("[gpu] {arg} probe timed out; trying the next backend"); + return None; + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(_) => return None, + } + } +} + +/// Resolve and apply `WGPU_BACKEND` for this launch. +/// +/// Precedence: `--backend` > existing `WGPU_BACKEND` > `--safe-mode` > +/// per-backend probes (minus crash-implicated backends) > software adapter > +/// last-resort `gl`. Inactive on macOS/wasm (env untouched). +pub fn resolve_gpu(cli_backend: Option<&str>, safe_mode: bool) -> GpuResolution { + let env_backend = std::env::var("WGPU_BACKEND").ok(); + if cli_backend.is_some() || env_backend.is_some() || safe_mode || !gpu_guard_active() { + let d = decide_backend( + cli_backend, + env_backend.as_deref(), + safe_mode, + &[], + &[], + &[], + None, + gpu_guard_active(), + ); + if let Some(v) = &d.backend_value { + std::env::set_var("WGPU_BACKEND", v); + } + return GpuResolution { + backend_value: d.backend_value, + reason: d.reason, + compat_renderer: false, + }; + } + let candidates = candidate_backends(); + let excluded = if previous_run_crashed() { + crashed_backends() + } else { + Vec::new() + }; + let mut results: Vec<(String, Option)> = Vec::new(); + for backend in &candidates { + if excluded.iter().any(|e| e == backend) { + continue; + } + let caps = run_probe_for(backend); + eprintln!( + "[gpu] probe {backend}: {}", + caps.as_ref() + .map(|c| c.adapter.as_str()) + .unwrap_or("FAILED") + ); + results.push((backend.clone(), caps)); + if results.iter().any(|(_, c)| c.is_some()) { + break; + } + } + let sw = if results.iter().all(|(_, c)| c.is_none()) { + run_probe_for("sw") + } else { + None + }; + let d = decide_backend( + None, + None, + false, + &candidates, + &excluded, + &results, + sw, + true, + ); + if let Some(v) = &d.backend_value { + std::env::set_var("WGPU_BACKEND", v); + if let Some(adapter) = &d.adapter_name { + eprintln!("[gpu] backend: {v} ({adapter})"); + } + if let Some(reason) = &d.reason { + eprintln!("[gpu] {}", fallback_notice(reason)); + } + if d.compat_renderer { + eprintln!("[gpu] {}", compat_notice()); + } + } + GpuResolution { + backend_value: d.backend_value, + reason: d.reason, + compat_renderer: d.compat_renderer, + } +} + +/// Child-process entry point for `--gpu-probe `: exercise one +/// backend the way the canvas does (device + staging-style mapped upload + +/// render pipeline creation + offscreen render + readback). Prints one JSON +/// line on success and exits 0; any failure (including a driver abort panics) +/// exits non-zero with no output. +pub fn run_probe_child(which: &str) -> ! { + let code = match which { + "dx12" | "vulkan" | "gl" => probe_single_backend(which).map_or(1, |caps| { + println!("{}", caps.to_json_line()); + 0 + }), + "sw" => probe_software_adapters().map_or(1, |caps| { + println!("{}", caps.to_json_line()); + 0 + }), + _ => { + eprintln!("[gpu-probe] unknown backend '{which}' (want dx12|vulkan|gl|sw)"); + 2 + } + }; + std::process::exit(code); +} + +fn wgpu_backends_for(name: &str) -> iced::wgpu::Backends { + use iced::wgpu::Backends; + match name { + "dx12" => Backends::DX12, + "vulkan" => Backends::VULKAN, + "gl" => Backends::GL, + _ => Backends::empty(), + } +} + +fn backend_name(backend: iced::wgpu::Backend) -> &'static str { + use iced::wgpu::Backend; + match backend { + Backend::Dx12 => "dx12", + Backend::Vulkan => "vulkan", + Backend::Gl => "gl", + Backend::Metal => "metal", + _ => "other", + } +} + +/// Adapter names that mean "no GPU" (same family as the app's software +/// detection): these never count as a passing *hardware* probe. +const SOFTWARE_RASTERIZER_NAMES: [&str; 6] = [ + "llvmpipe", + "lavapipe", + "softpipe", + "swiftshader", + "warp", + "microsoft basic render", +]; + +fn is_software_adapter(info: &iced::wgpu::AdapterInfo) -> bool { + if info.device_type == iced::wgpu::DeviceType::Cpu { + return true; + } + let name = info.name.to_ascii_lowercase(); + SOFTWARE_RASTERIZER_NAMES + .iter() + .any(|marker| name.contains(marker)) +} + +fn probe_single_backend(name: &str) -> Option { + use iced::wgpu; + let backends = wgpu_backends_for(name); + let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); + desc.backends = backends; + let instance = wgpu::Instance::new(desc); + let adapters: Vec = pollster::block_on(instance.enumerate_adapters(backends)); + // Hardware only: a software rasterizer proving "usable" would hide a + // broken hardware path (software gets its own dedicated `sw` probe). + let mut candidates: Vec<_> = adapters + .into_iter() + .filter(|a| !is_software_adapter(&a.get_info())) + .collect(); + if candidates.is_empty() { + eprintln!("[gpu-probe] {name}: no hardware adapters enumerated"); + return None; + } + candidates.sort_by_key(|a| match a.get_info().device_type { + wgpu::DeviceType::DiscreteGpu => 0, + wgpu::DeviceType::IntegratedGpu => 1, + wgpu::DeviceType::Other => 2, + _ => 3, + }); + for adapter in &candidates { + if let Some(caps) = exercise_adapter(adapter) { + return Some(caps); + } + } + eprintln!("[gpu-probe] {name}: no adapter survived the exercise"); + None +} + +fn probe_software_adapters() -> Option { + use iced::wgpu; + let backends = wgpu::Backends::DX12 | wgpu::Backends::VULKAN | wgpu::Backends::GL; + let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); + desc.backends = backends; + let instance = wgpu::Instance::new(desc); + let adapters: Vec = pollster::block_on(instance.enumerate_adapters(backends)); + let mut software: Vec<_> = adapters + .into_iter() + .filter(|a| is_software_adapter(&a.get_info())) + .collect(); + if software.is_empty() { + // No software adapter enumerated: ask wgpu for its fallback adapter. + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::None, + force_fallback_adapter: true, + compatible_surface: None, + })) + .ok()?; + software.push(adapter); + } + for adapter in &software { + if let Some(caps) = exercise_adapter(adapter) { + return Some(caps); + } + } + eprintln!("[gpu-probe] sw: no software adapter survived the exercise"); + None +} + +/// Full exercise for one adapter: device request (iced-style limits), staging +/// upload, render pipeline creation, offscreen render, readback. Any driver +/// abort or validation failure fails the probe. +fn exercise_adapter(adapter: &iced::wgpu::Adapter) -> Option { + use iced::wgpu; + let info = adapter.get_info(); + eprintln!( + "[gpu-probe] trying {} ({:?}, {:?})", + info.name, info.backend, info.device_type + ); + // Mirror iced's compositor: full limits first, then the downlevel set. + let limits = [ + wgpu::Limits::default().using_resolution(adapter.limits()), + wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()), + ]; + for required_limits in limits { + let Ok((device, queue)) = + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("ocs-gpu-probe"), + required_features: wgpu::Features::empty(), + required_limits: required_limits.clone(), + memory_hints: wgpu::MemoryHints::MemoryUsage, + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + })) + else { + continue; + }; + let errors = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = errors.clone(); + device.on_uncaptured_error(std::sync::Arc::new(move |e: wgpu::Error| { + eprintln!("[gpu-probe] uncaptured error: {e}"); + flag.store(true, std::sync::atomic::Ordering::SeqCst); + })); + if !staging_upload(&device) + || !pipeline_render_readback(&device, &queue) + || errors.load(std::sync::atomic::Ordering::SeqCst) + { + continue; + } + eprintln!("[gpu-probe] OK via {}", info.name); + return Some(ProbeCaps { + backend: backend_name(info.backend).to_string(), + adapter: info.name.clone(), + max_storage: adapter.limits().max_storage_buffers_per_shader_stage, + software: is_software_adapter(&info), + }); + } + None +} + +/// Mapped-memory upload through the queue (never `mapped_at_creation`): the +/// same host-mapping path `StagingBelt` and glyph uploads take. +fn staging_upload(device: &iced::wgpu::Device) -> bool { + use iced::wgpu; + let staging = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("ocs-gpu-probe-staging"), + size: 4096, + usage: wgpu::BufferUsages::MAP_WRITE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let slice = staging.slice(..); + let mapped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = mapped.clone(); + slice.map_async(wgpu::MapMode::Write, move |r| { + flag.store(r.is_ok(), std::sync::atomic::Ordering::SeqCst); + }); + device.poll(wgpu::PollType::wait_indefinitely()).ok(); + if !mapped.load(std::sync::atomic::Ordering::SeqCst) { + return false; + } + { + let mut view = slice.get_mapped_range_mut(); + view.slice(0..1).copy_from_slice(&[0xABu8]); + } + staging.unmap(); + true +} + +/// Render pipeline creation + offscreen render + readback. +/// +/// The fragment shader deliberately reads a `private array, 8>`, +/// the same pattern iced's gradient pipeline compiles: old GLES drivers +/// (Mesa on legacy Intel iGPUs) reject naga's `vec4[8]` GLSL with `no +/// default precision`, so pipeline creation fails there — exactly what this +/// probe must catch before the editor boots. +fn pipeline_render_readback(device: &iced::wgpu::Device, queue: &iced::wgpu::Queue) -> bool { + use iced::wgpu; + const SHADER: &str = r#" +struct VsOut { + @builtin(position) pos: vec4, + @location(0) colour: vec4, +}; +var palette: array, 8>; +@vertex +fn vs(@builtin(vertex_index) idx: u32) -> VsOut { + var out: VsOut; + let corners = array, 3>( + vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + out.pos = vec4(corners[idx], 0.0, 1.0); + out.colour = palette[idx % 8u]; + return out; +} +@fragment +fn fs(in: VsOut) -> @location(0) vec4 { + return in.colour + palette[0]; +} +"#; + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("ocs-gpu-probe-shader"), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(SHADER)), + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("ocs-gpu-probe-pipeline"), + layout: None, + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs"), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs"), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Bgra8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + multiview_mask: None, + cache: None, + }); + // 64px offscreen render. 64*4 = 256 bytes per row satisfies + // COPY_BYTES_PER_ROW_ALIGNMENT. + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("ocs-gpu-probe-target"), + size: wgpu::Extent3d { + width: 64, + height: 64, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("ocs-gpu-probe-encoder"), + }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("ocs-gpu-probe-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_pipeline(&pipeline); + pass.draw(0..3, 0..1); + } + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("ocs-gpu-probe-readback"), + size: 256 * 64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(64 * 4), + rows_per_image: Some(64), + }, + }, + wgpu::Extent3d { + width: 64, + height: 64, + depth_or_array_layers: 1, + }, + ); + queue.submit([encoder.finish()]); + let slice = readback.slice(..); + let ok = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = ok.clone(); + slice.map_async(wgpu::MapMode::Read, move |r| { + flag.store(r.is_ok(), std::sync::atomic::Ordering::SeqCst); + }); + device.poll(wgpu::PollType::wait_indefinitely()).ok(); + if !ok.load(std::sync::atomic::Ordering::SeqCst) { + return false; + } + let _ = slice.get_mapped_range(); + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cands(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + fn ok_caps(backend: &str, storage: u32) -> ProbeCaps { + ProbeCaps { + backend: backend.to_string(), + adapter: format!("test {backend}"), + max_storage: storage, + software: false, + } + } + + #[test] + fn explicit_backend_and_env_beat_everything() { + let d = decide_backend( + Some("vulkan"), + None, + true, + &cands(&["dx12", "vulkan", "gl"]), + &cands(&["dx12"]), + &[], + None, + true, + ); + assert_eq!(d.backend_value.as_deref(), Some("vulkan")); + assert!(d.reason.is_none()); + let d = decide_backend(None, Some("gl"), true, &[], &[], &[], None, true); + assert_eq!(d.backend_value.as_deref(), Some("gl")); + assert!(d.reason.is_none()); + } + + #[test] + fn safe_mode_forces_gl() { + let d = decide_backend( + None, + None, + true, + &cands(&["vulkan", "gl"]), + &[], + &[], + None, + true, + ); + assert_eq!(d.backend_value.as_deref(), Some("gl")); + assert!(d.reason.is_some()); + } + + #[test] + fn first_passing_backend_wins_silently() { + let results = vec![("vulkan".to_string(), Some(ok_caps("vulkan", 8)))]; + let d = decide_backend( + None, + None, + false, + &cands(&["vulkan", "gl"]), + &[], + &results, + None, + true, + ); + assert_eq!(d.backend_value.as_deref(), Some("vulkan")); + assert!(d.reason.is_none()); + assert!(!d.compat_renderer); + assert_eq!(d.adapter_name.as_deref(), Some("test vulkan")); + } + + #[test] + fn failover_picks_next_backend_with_reason() { + let results = vec![ + ("dx12".to_string(), None), + ("vulkan".to_string(), Some(ok_caps("vulkan", 8))), + ]; + let d = decide_backend( + None, + None, + false, + &cands(&["dx12", "vulkan", "gl"]), + &[], + &results, + None, + true, + ); + assert_eq!(d.backend_value.as_deref(), Some("vulkan")); + let reason = d.reason.unwrap(); + assert!(reason.contains("dx12"), "{reason}"); + let notice = fallback_notice(&reason); + assert!(notice.contains("--backend"), "{notice}"); + } + + #[test] + fn crash_implicated_backends_are_skipped() { + // dx12 died last run: even a passing dx12 probe must not win. + let results = vec![ + ("dx12".to_string(), Some(ok_caps("dx12", 8))), + ("gl".to_string(), Some(ok_caps("gl", 0))), + ]; + let d = decide_backend( + None, + None, + false, + &cands(&["dx12", "vulkan", "gl"]), + &cands(&["dx12"]), + &results, + None, + true, + ); + assert_eq!(d.backend_value.as_deref(), Some("gl")); + assert!(d.reason.is_some()); + // No storage buffers on the GL probe: packed renderer auto-enabled. + assert!(d.compat_renderer); + } + + #[test] + fn software_adapter_is_last_resort_before_gl() { + let results = vec![("vulkan".to_string(), None), ("gl".to_string(), None)]; + let sw = ProbeCaps { + backend: "vulkan".to_string(), + adapter: "llvmpipe".to_string(), + max_storage: 8, + software: true, + }; + let d = decide_backend( + None, + None, + false, + &cands(&["vulkan", "gl"]), + &[], + &results, + Some(sw), + true, + ); + assert_eq!(d.backend_value.as_deref(), Some("vulkan")); + assert!(d.compat_renderer); + assert!(d.reason.unwrap().contains("software"),); + } + + #[test] + fn total_failure_falls_back_to_gl_with_compat() { + let d = decide_backend( + None, + None, + false, + &cands(&["vulkan", "gl"]), + &[], + &[("vulkan".to_string(), None), ("gl".to_string(), None)], + None, + true, + ); + assert_eq!(d.backend_value.as_deref(), Some("gl")); + assert!(d.compat_renderer); + assert!(d.reason.is_some()); + } + + #[test] + fn inactive_guard_leaves_the_env_alone() { + let results = vec![("vulkan".to_string(), None)]; + let d = decide_backend(None, None, false, &[], &[], &results, None, false); + assert!(d.backend_value.is_none()); + } + + #[test] + fn probe_caps_json_round_trip() { + let caps = ProbeCaps { + backend: "vulkan".to_string(), + adapter: "Intel HD 405 (Braswell)".to_string(), + max_storage: 8, + software: false, + }; + let line = caps.to_json_line(); + assert_eq!(ProbeCaps::from_json_line(&line), Some(caps)); + assert!(ProbeCaps::from_json_line("not json").is_none()); + assert!(ProbeCaps::from_json_line("{\"backend\":\"gl\"}").is_none()); + } + + #[test] + fn sentinel_round_trip_records_backends() { + let dir = std::env::temp_dir().join(format!("ocs-gpu-test-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + // SAFETY: single-threaded test mutating a process-local test hook. + unsafe { std::env::set_var(SENTINEL_DIR_ENV, &dir) }; + assert!(!previous_run_crashed()); + arm_sentinel("dx12"); + assert!(previous_run_crashed()); + assert_eq!(crashed_backends(), vec!["dx12".to_string()]); + disarm_sentinel(); + assert!(!previous_run_crashed()); + assert!(crashed_backends().is_empty()); + unsafe { std::env::remove_var(SENTINEL_DIR_ENV) }; + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn force_hooks_drive_probes_without_spawning() { + // SAFETY: single-threaded test mutating process-local test hooks. + unsafe { std::env::set_var(FORCE_FAIL_ENV, "1") }; + assert!(run_probe_for("vulkan").is_none()); + unsafe { std::env::remove_var(FORCE_FAIL_ENV) }; + unsafe { std::env::set_var(FORCE_OK_ENV, "1") }; + let caps = run_probe_for("vulkan").expect("forced success"); + assert_eq!(caps.backend, "vulkan"); + assert!(!needs_compat_renderer(&caps)); + unsafe { std::env::set_var(FORCE_CAPS_ENV, "0") }; + let bare = run_probe_for("gl").expect("forced success"); + assert!(needs_compat_renderer(&bare)); + unsafe { std::env::remove_var(FORCE_OK_ENV) }; + unsafe { std::env::remove_var(FORCE_CAPS_ENV) }; + } +} diff --git a/src/lib.rs b/src/lib.rs index 073c04980..6d0303ee3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,8 @@ pub mod command; pub mod config; pub mod discussions; pub mod entities; +#[cfg(not(target_arch = "wasm32"))] +pub mod gpu_backend; pub mod i18n; pub(crate) mod input; pub mod io; diff --git a/src/main.rs b/src/main.rs index a691981e2..0975cc38a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,14 @@ fn main() -> iced::Result { use clap::Parser; let args = cli::Cli::parse(); + // GPU probe child: exercise one backend offscreen, print one JSON + // line on success and exit 0/1. This must run before any logging/GUI + // setup; the parent interprets any non-zero exit (including a driver + // abort) as "this backend is unusable". + if let Some(which) = &args.gpu_probe { + OpenCADStudio::gpu_backend::run_probe_child(which); + } + // Plugin runner mode: the host spawns itself with this hidden flag to // load a plugin cdylib in an isolated process. Hand off immediately so // the child never touches GUI state. @@ -77,19 +85,20 @@ fn main() -> iced::Result { } // GPU backend selection. Explicit `--backend` wins; `--safe-mode` - // forces GL for flaky drivers. On Windows, fall back to DX12/Vulkan so - // the AMD OpenGL ICD (atio6axx.dll) is never touched at startup — it - // access-violates on some hybrid-GPU laptops before any window appears - // (#55). An already-set WGPU_BACKEND always wins. - if let Some(backend) = &args.backend { - std::env::set_var("WGPU_BACKEND", backend); - } else if args.safe_mode { - std::env::set_var("WGPU_BACKEND", "gl"); - } - #[cfg(target_os = "windows")] - if std::env::var_os("WGPU_BACKEND").is_none() { - std::env::set_var("WGPU_BACKEND", "dx12,vulkan"); - } + // forces GL for flaky drivers. On Windows the preference order starts + // with DX12/Vulkan so the AMD OpenGL ICD (atio6axx.dll) is never + // touched at startup — it access-violates on some hybrid-GPU laptops + // before any window appears (#55). An already-set WGPU_BACKEND always + // wins. + // + // Older GPUs without usable DirectX 12 Feature Level 12_0 or working + // Vulkan (e.g. legacy Intel iGPUs), and old GLES drivers whose shader + // compiler rejects iced's own shaders, crash at startup or on the + // first drawing viewport instead. `resolve_gpu` probes each candidate + // backend in an isolated child process and selects the first one that + // survives a real offscreen render; a crash sentinel skips backends + // that died with the previous run. + let gpu = OpenCADStudio::gpu_backend::resolve_gpu(args.backend.as_deref(), args.safe_mode); // Headless modes exit without ever creating a window. if args.serve { @@ -145,12 +154,22 @@ fn main() -> iced::Result { } }) .unwrap_or_default(); + let gpu_fallback_notice = gpu + .reason + .map(|reason| OpenCADStudio::gpu_backend::fallback_notice(&reason)); + // The probe enables the packed renderer automatically on GPUs without + // shader storage buffers; an explicit `--compat-renderer` does the + // same without the notice. + let compat_renderer = args.compat_renderer || gpu.compat_renderer; + let gpu_compat_auto = !args.compat_renderer && gpu.compat_renderer; let _ = cli::GUI_CONFIG.set(cli::GuiConfig { files: if args.new { Vec::new() } else { args.files }, new: args.new, read_only: args.read_only, - compat_renderer: args.compat_renderer, + compat_renderer, script_lines, + gpu_fallback_notice, + gpu_compat_auto, }); // Register (or refresh) the freedesktop DWG thumbnailer so file managers @@ -159,6 +178,19 @@ fn main() -> iced::Result { // mode. Silently ignored on failure or non-Linux. io::file_association::install_thumbnailer(); - app::run() + // Crash sentinel: armed while the GPU is live, naming the attempted + // backend. A stale file at the next launch means this run aborted, so + // the resolver skips those backends instead of crashing again. + // Inactive on macOS/wasm, where the resolver leaves the env alone. + if OpenCADStudio::gpu_backend::gpu_guard_active() { + OpenCADStudio::gpu_backend::arm_sentinel( + gpu.backend_value.as_deref().unwrap_or("auto"), + ); + let result = app::run(); + OpenCADStudio::gpu_backend::disarm_sentinel(); + result + } else { + app::run() + } } } diff --git a/src/ui/style/dimstyle.rs b/src/ui/style/dimstyle.rs index aed2e9b74..c1d01ccab 100644 --- a/src/ui/style/dimstyle.rs +++ b/src/ui/style/dimstyle.rs @@ -6,6 +6,7 @@ use iced::widget::{ }; use iced::{mouse, Background, Border, Element, Length, Point, Rectangle, Theme}; use crate::ui::style::common::muted_style; +use crate::ui::style::form::hdivider; use crate::t; use std::borrow::Cow; use std::fmt; @@ -263,19 +264,6 @@ impl canvas::Program for DimensionPreview { } } -fn hdivider<'a>(width: iced::Length) -> Element<'a, Message> { - container(Space::new().width(width).height(1)) - .width(width) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() -} - pub fn view_window<'a>( styles: Vec, selected: &'a str, diff --git a/src/ui/style/form.rs b/src/ui/style/form.rs index 6f79d4fec..050fa0eb4 100644 --- a/src/ui/style/form.rs +++ b/src/ui/style/form.rs @@ -83,6 +83,12 @@ pub fn vseparator<'a>(height: Length) -> Element<'a, Message> { .into() } +/// Alias kept for call sites that historically used `vsep`. +/// Same implementation as [`vseparator`]. +pub fn vsep<'a>(height: Length) -> Element<'a, Message> { + vseparator(height) +} + /// Muted section heading. pub fn section_label<'a>(label: Cow<'static, str>) -> Element<'a, Message> { text(label).size(11).style(muted_style).into() diff --git a/src/ui/style/plotstyle.rs b/src/ui/style/plotstyle.rs index 22a01c9e6..cf2b93a0b 100644 --- a/src/ui/style/plotstyle.rs +++ b/src/ui/style/plotstyle.rs @@ -4,6 +4,7 @@ use crate::app::Message; use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; use iced::{Background, Border, Element, Theme}; use crate::ui::style::common::muted_style; +use crate::ui::style::form::{hdivider, vsep}; use crate::t; use std::borrow::Cow; use std::fmt; @@ -83,32 +84,6 @@ fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { } } -fn hdivider<'a>(width: iced::Length) -> Element<'a, Message> { - container(Space::new().width(width).height(1)) - .width(width) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() -} - -fn vsep<'a>(height: iced::Length) -> Element<'a, Message> { - container(Space::new().width(1).height(height)) - .width(1) - .height(height) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() -} - pub fn view_window<'a>( document: &'a acadrust::CadDocument, table: Option<&'a crate::io::plot_style::PlotStyleTable>, diff --git a/src/ui/style/style_manager.rs b/src/ui/style/style_manager.rs index 64cc1be9d..7595f48ff 100644 --- a/src/ui/style/style_manager.rs +++ b/src/ui/style/style_manager.rs @@ -327,28 +327,4 @@ pub(crate) fn muted_text_style(theme: &Theme) -> iced::widget::text::Style { crate::ui::style::common::muted_style(theme) } -pub(crate) fn hdivider<'a>(width: iced::Length) -> Element<'a, Message> { - container(Space::new().width(width).height(1)) - .width(width) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() -} - -pub(crate) fn vsep<'a>(height: iced::Length) -> Element<'a, Message> { - container(Space::new().width(1).height(height)) - .width(1) - .height(height) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() -} +pub(crate) use crate::ui::style::form::{hdivider, vsep}; diff --git a/src/ui/window/about.rs b/src/ui/window/about.rs index 388f0e295..a9e4fc809 100644 --- a/src/ui/window/about.rs +++ b/src/ui/window/about.rs @@ -1,14 +1,9 @@ use crate::app::Message; use crate::t; +use crate::ui::style::common::muted_style; use iced::widget::{button, column, container, row, svg, text, Space}; use iced::{Background, Border, Element, Fill, Length, Shrink, Theme}; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn primary_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { color: Some(theme.palette().primary.base.color), diff --git a/src/ui/window/alias_editor.rs b/src/ui/window/alias_editor.rs index 3e0bdb277..66835b7a7 100644 --- a/src/ui/window/alias_editor.rs +++ b/src/ui/window/alias_editor.rs @@ -7,6 +7,7 @@ //! unsaved-changes guard on close. use crate::app::Message; +use crate::ui::style::common::muted_style; use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; use iced::{Background, Element, Length, Theme}; use crate::t; @@ -21,12 +22,6 @@ pub enum AliasField { /// Right-hand lane reserved for the scrollbar so it never overlaps the ✕ column. const GUTTER: f32 = 16.0; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn danger_text_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { color: Some(theme.palette().danger.base.color), diff --git a/src/ui/window/attribute_editor.rs b/src/ui/window/attribute_editor.rs index 65dc18914..0128ce1b2 100644 --- a/src/ui/window/attribute_editor.rs +++ b/src/ui/window/attribute_editor.rs @@ -25,6 +25,8 @@ use std::borrow::Cow; use std::fmt; use crate::ui::properties::{lw_options, LwItem}; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::hdivider; const LABEL_W: f32 = 120.0; @@ -139,12 +141,6 @@ pub fn color_from_label(label: &str) -> Option { }) } -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { let palette = theme.palette(); let border = match status { @@ -161,20 +157,6 @@ fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { } } -/// Horizontal 1px divider in the shared border colour (matches style windows). -fn hdivider<'a>(width: Length) -> Element<'a, Message> { - container(Space::new().width(width).height(1)) - .width(width) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() -} - /// One `label : widget` row with a fixed-width label column. fn field_row<'a>( label: Cow<'static, str>, diff --git a/src/ui/window/layers.rs b/src/ui/window/layers.rs index f25b08eac..bf2ee1d9f 100644 --- a/src/ui/window/layers.rs +++ b/src/ui/window/layers.rs @@ -2,6 +2,7 @@ use crate::app::Message; use crate::ui::properties::{lw_options, LinetypeItem, LwItem}; +use crate::ui::style::common::muted_style; use crate::ui::ROW_H; use acadrust::tables::layer::Layer as DocLayer; use acadrust::tables::Table; @@ -51,12 +52,6 @@ const LINETYPE_MENU_W: f32 = 220.0; /// scrolled into view after it is added (#271). pub const LAYER_TABLE_SCROLL_ID: &str = "layer-manager-table-scroll"; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn table_input_style( theme: &Theme, status: iced::widget::text_input::Status, diff --git a/src/ui/window/layout_manager.rs b/src/ui/window/layout_manager.rs index 4891a2c0f..9d0132cec 100644 --- a/src/ui/window/layout_manager.rs +++ b/src/ui/window/layout_manager.rs @@ -1,16 +1,12 @@ //! Layout Manager window — fills the entire OS window. use crate::app::Message; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::{hdivider, vsep}; use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; use iced::{Background, Element, Theme}; use crate::t; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn primary_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { color: Some(theme.palette().primary.base.color), @@ -37,32 +33,6 @@ fn list_item(active: bool) -> impl Fn(&Theme, button::Status) -> button::Style { } } -fn hdivider<'a>(width: iced::Length) -> Element<'a, Message> { - container(Space::new().width(width).height(1)) - .width(width) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color, - )), - ..Default::default() - }) - .into() -} - -fn vsep<'a>(height: iced::Length) -> Element<'a, Message> { - container(Space::new().width(1).height(height)) - .width(1) - .height(height) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color, - )), - ..Default::default() - }) - .into() -} - pub fn view_window<'a>( layouts: Vec, selected: &'a str, diff --git a/src/ui/window/named_parameters.rs b/src/ui/window/named_parameters.rs index 88afb3bf8..9148cac98 100644 --- a/src/ui/window/named_parameters.rs +++ b/src/ui/window/named_parameters.rs @@ -17,6 +17,7 @@ use crate::app::Message; use crate::scene::named_parameters::ParameterTable; use crate::scene::Scene; use crate::t; +use crate::ui::style::common::muted_style; use acadrust::types::Handle; use acadrust::EntityType; use iced::widget::tooltip::Position as TipPos; @@ -113,12 +114,6 @@ pub struct ParamEditorRow { /// column — same convention and value as `alias_editor::GUTTER`. const GUTTER: f32 = 16.0; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn danger_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { color: Some(theme.palette().danger.base.color), diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index 451860c9a..21b0936f3 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -9,6 +9,7 @@ use crate::io::paper_catalog::{self, CustomPaper, Margins, PaperSize, PaperUnits use crate::io::plot_device::PrinterCapabilities; use crate::ui::style::common::muted_style; use crate::ui::style::form; +use crate::ui::style::form::{hdivider, vseparator as vsep}; use iced::widget::{ button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, Space, @@ -543,18 +544,10 @@ fn btn(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style { form::button_style(accent) } -fn hdivider<'a>(width: Length) -> Element<'a, Message> { - form::hdivider(width) -} - fn section_label<'a>(s: Cow<'static, str>) -> Element<'a, Message> { form::section_label(s) } -fn vsep<'a>(height: Length) -> Element<'a, Message> { - form::vseparator(height) -} - fn setup_row<'a>( name: &'a str, selected: &str, diff --git a/src/ui/window/plugin_manager.rs b/src/ui/window/plugin_manager.rs index 341fc09a6..a6c2637fa 100644 --- a/src/ui/window/plugin_manager.rs +++ b/src/ui/window/plugin_manager.rs @@ -8,6 +8,7 @@ use crate::app::Message; use crate::plugin::external::{ExternalPlugin, RegistryEntry, ReleaseInfo}; +use crate::ui::style::common::muted_style; use iced::widget::{ button, column, container, markdown, row, rule, scrollable, text, text_input, Space, }; @@ -48,12 +49,6 @@ inventory::submit!(crate::command::CommandRegistration { names: &["PLUGINS", "PLUGINMANAGER"] }); -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn primary_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { color: Some(theme.palette().primary.base.color), diff --git a/src/ui/window/print_all.rs b/src/ui/window/print_all.rs index 05b5f5265..c95f39619 100644 --- a/src/ui/window/print_all.rs +++ b/src/ui/window/print_all.rs @@ -1,5 +1,6 @@ use crate::app::Message; use crate::t; +use crate::ui::style::common::muted_style as muted; use iced::widget::{button, checkbox, column, container, row, scrollable, text, Space}; use iced::{Background, Border, Element, Length, Theme}; @@ -13,12 +14,6 @@ fn button_style(primary: bool) -> impl Fn(&Theme, button::Status) -> button::Sty } } -fn muted(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - pub fn view_window<'a>( layouts: &'a [(String, bool)], printer: Option<&'a str>, diff --git a/src/ui/window/recovery.rs b/src/ui/window/recovery.rs index 0ae3ed5f3..8bfa1d8da 100644 --- a/src/ui/window/recovery.rs +++ b/src/ui/window/recovery.rs @@ -1,14 +1,9 @@ use crate::app::Message; use crate::io::recovery::{RecoveryReport, RecoveryStatus}; +use crate::ui::style::common::muted_style; use iced::widget::{button, column, container, row, scrollable, text, Space}; use iced::{Background, Border, Element, Fill, Length, Shrink, Theme}; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn status_style( status: RecoveryStatus, ) -> impl Fn(&Theme) -> iced::widget::text::Style + Copy { diff --git a/src/ui/window/shortcuts.rs b/src/ui/window/shortcuts.rs index 63440fa33..50e3b098d 100644 --- a/src/ui/window/shortcuts.rs +++ b/src/ui/window/shortcuts.rs @@ -2,6 +2,7 @@ use crate::app::Message; use crate::t; +use crate::ui::style::common::muted_style; use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; use iced::{Background, Element, Length, Theme}; @@ -13,12 +14,6 @@ pub enum ShortcutField { const GUTTER: f32 = 16.0; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn danger_text_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { color: Some(theme.palette().danger.base.color), diff --git a/src/ui/window/update_notice.rs b/src/ui/window/update_notice.rs index 2aa020118..2ac897d32 100644 --- a/src/ui/window/update_notice.rs +++ b/src/ui/window/update_notice.rs @@ -1,15 +1,10 @@ use crate::app::Message; +use crate::ui::style::common::muted_style; use iced::widget::{button, column, container, row, scrollable, text, Space}; use iced::{Background, Border, Element, Theme}; use crate::t; use std::borrow::Cow; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn primary_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { color: Some(theme.palette().primary.base.color), diff --git a/src/ui/window/xref_help.rs b/src/ui/window/xref_help.rs index 39ec474e9..09e07490c 100644 --- a/src/ui/window/xref_help.rs +++ b/src/ui/window/xref_help.rs @@ -5,6 +5,7 @@ //! unreadable sliver. use crate::app::Message; +use crate::ui::style::common::muted_style; use iced::widget::{button, column, container, row, text, Space}; use iced::{Background, Element, Fill, Length, Theme}; @@ -12,12 +13,6 @@ use iced::{Background, Element, Fill, Length, Theme}; /// missing or broken behavior where it will be seen. pub const XREF_ISSUES_URL: &str = "https://github.com/HakanSeven12/OpenCADStudio/issues"; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - pub fn view_window(sizing: crate::ui::modal::ModalSizing) -> Element<'static, Message> { // Fixed window width: paragraphs wrap against this instead of the // toolbar, which is what made the dropdown unreadable. diff --git a/src/ui/window/xref_manager.rs b/src/ui/window/xref_manager.rs index 7ea68ed8b..e3edf4148 100644 --- a/src/ui/window/xref_manager.rs +++ b/src/ui/window/xref_manager.rs @@ -9,6 +9,7 @@ use crate::app::Message; use crate::io::xref::collect_entries_with_prev; use crate::io::xref_model::{normalize_lexical, Pathtype, RefKind, RefStatus, RefType, ReferenceEntry}; use crate::ui::ROW_H; +use crate::ui::style::common::muted_style; use acadrust::CadDocument; use iced::widget::{button, column, container, mouse_area, row, scrollable, text, tooltip}; use iced::Padding; @@ -1286,12 +1287,6 @@ fn format_date(modified: Option) -> String { // ── Widget helpers (layers.rs conventions) ──────────────────────────────── -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn row_button_style(selected: bool, index: usize) -> impl Fn(&Theme, button::Status) -> button::Style { move |theme: &Theme, status: button::Status| { let palette = theme.palette();