mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Animation effects follow the operating system's setting; View ▸ Accessibility ▸ Animation Effects (#759)
With Windows set to show no animations (Settings ▸ Accessibility ▸ Visual
effects ▸ Animation effects), dialogs and menus still faded in and wheel
input glided. All of that motion is egui's default behaviour, and nothing
asked the operating system.
- crates/ui-egui/src/animation.rs: AnimationPref (System, On, Off). Off sets
egui's animation_time to 0 and scroll_animation to none (window, menu,
tooltip and popup fades, collapsing sections, scroll bars, scroll-to
glides), hands each wheel notch to scroll areas whole, and lets
Cmd/Ctrl+wheel zoom reach its zoom in one step.
- System, the default, follows the OS live: UISettings::AnimationsEnabled on
Windows, NSWorkspace's Reduce motion on macOS, the XDG Desktop Portal's
reduced-motion (then GNOME's enable-animations) on Linux and the BSDs,
prefers-reduced-motion on the web.
- View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting / On / Off,
the same choice in Options ▸ Display, and `ui.animations`, `options` and
`ui.set` {"animations"}; `ui.inspect` reports whether animations show.
Drafting behaviour is untouched: tracking's acquisition pause, the
time-sensitive right-click, previews, the caret blink, realtime pan and
zoom. Plain wheel zoom already moved a whole notch per frame (#692).
macOS and Linux are compile-checked (clippy -D warnings for
aarch64-apple-darwin and x86_64-unknown-linux-gnu), not run.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
26 files changed
+828
-12
No files matched your search
Generated
+2
@@ -557,9 +557,11 @@ dependencies = [
|
||||
"hidapi",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zbus",
|
||||
|
||||
@@ -252,8 +252,25 @@ have accessible labels and visible keyboard focus. Property controls remain appl
|
||||
|
||||
## Accessibility
|
||||
|
||||
### System cursor
|
||||
|
||||
Over the drawing area CADCraft hides the system cursor and draws its own crosshair. Tools that follow the system cursor (Windows Magnifier set to follow or centre the mouse pointer, other screen magnifiers, screen recorders, remote desktops) lose track of it there. Turn on **View ▸ Accessibility ▸ Show System Cursor** to keep a small system crosshair visible at the centre of the drawn one, or start CADCraft with the environment variable `CADCRAFT_SYSTEM_CURSOR=1` (on Windows, set it once with `setx CADCRAFT_SYSTEM_CURSOR 1` and start CADCraft again). Agents can switch it with the control channel: `ui.set {"systemCursor": true}`.
|
||||
|
||||
### Animation effects
|
||||
|
||||
CADCraft follows the operating system's animation setting. When the system is set to show no animations, dialogs, menus and tooltips appear at once instead of fading in, collapsing sections and scroll bars change at once, and the mouse wheel scrolls lists a whole step at a time instead of gliding. Zooming the drawing with the wheel always moves a whole step; with Ctrl held (a touchpad pinch, on Windows) it glides to the new zoom only while animation effects show.
|
||||
|
||||
| System | Where the setting is |
|
||||
|---|---|
|
||||
| Windows | Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects |
|
||||
| macOS | System Settings ▸ Accessibility ▸ Reduce motion |
|
||||
| Linux, BSD | the desktop's reduced-motion (or animations) setting, read through the XDG Desktop Portal |
|
||||
| Web | the browser's `prefers-reduced-motion` |
|
||||
|
||||
A change to the system setting is picked up while CADCraft runs, within a few seconds. To choose for CADCraft alone, use **View ▸ Accessibility ▸ Animation Effects**: **Use System Setting** (the default), **On** or **Off**. Options ▸ Display has the same choice. Agents can set it with the control channel: `ui.set {"animations": "off"}` (`"system"`, `"on"` or `"off"`), and `ui.inspect` reports whether animations show (`animations`).
|
||||
|
||||
Drafting feedback is not an animation effect and stays as it is: rubber bands and previews, snap markers, the pause that acquires an object snap tracking point, the text cursor's blink, the spinner shown while a drawing opens, and pan and zoom, which follow the pointer and the wheel directly.
|
||||
|
||||
## Roadmap
|
||||
|
||||
See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of remaining work, and [docs/gaps.md](docs/gaps.md) for everything still missing.
|
||||
|
||||
@@ -29,4 +29,4 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"]
|
||||
eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
|
||||
@@ -23,14 +23,16 @@ mod web {
|
||||
const CANVAS_ID: &str = "cadcraft_canvas";
|
||||
const LOADING_ID: &str = "cadcraft_loading";
|
||||
|
||||
/// The app, the files File > Open… and drag and drop read for it in the background, and the
|
||||
/// page embedding it (`?host=parent`).
|
||||
struct Shell(CadApp, crate::upload::Inbox, Option<(crate::host::Bridge, crate::host::DirtyReport)>);
|
||||
/// The app, the files File > Open… and drag and drop read for it in the background, the
|
||||
/// page embedding it (`?host=parent`), and the browser's `prefers-reduced-motion` (it stays
|
||||
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change).
|
||||
struct Shell(CadApp, crate::upload::Inbox, Option<(crate::host::Bridge, crate::host::DirtyReport)>, Option<web_sys::MediaQueryList>);
|
||||
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.1.take_dropped(ctx);
|
||||
self.1.drain(&mut self.0);
|
||||
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
|
||||
self.0.logic(ctx);
|
||||
if let Some((bridge, reported)) = &self.2 {
|
||||
bridge.report_dirty(&self.0, reported);
|
||||
@@ -149,7 +151,8 @@ mod web {
|
||||
bridge.post_ready();
|
||||
(bridge, crate::host::DirtyReport::default())
|
||||
});
|
||||
Ok(Box::new(Shell(app, inbox, host)))
|
||||
let reduced_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
|
||||
Ok(Box::new(Shell(app, inbox, host, reduced_motion)))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -35,10 +35,16 @@ sys-locale = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.21", default-features = false }
|
||||
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion
|
||||
# (system_animations.rs). Already in Cargo.lock through muda.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
|
||||
|
||||
# Rust HID backends: no proprietary SDK or C HID library.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: Windows' Animation effects
|
||||
# setting (system_animations.rs). Already in Cargo.lock through wgpu's DirectX 12 backend.
|
||||
windows = { version = "0.62", features = ["UI_ViewManagement"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["linux-native-basic-udev"] }
|
||||
@@ -22,6 +22,7 @@ mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod spacemouse;
|
||||
mod system_animations;
|
||||
mod workspace;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
@@ -32,6 +33,8 @@ struct App {
|
||||
spacemouse: spacemouse::SpaceMouse,
|
||||
/// The desktop's light/dark choice where winit reports none (Linux, the BSDs).
|
||||
desktop_theme: desktop_theme::DesktopTheme,
|
||||
/// Whether the OS shows animations, for the System choice of View ▸ Accessibility ▸ Animation Effects.
|
||||
system_animations: system_animations::SystemAnimations,
|
||||
workspace: workspace::Workspace,
|
||||
workspace_error: Option<String>,
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -72,6 +75,7 @@ impl eframe::App for App {
|
||||
m.poll(&mut self.cad, ctx);
|
||||
}
|
||||
}
|
||||
self.cad.system_animations = self.system_animations.current();
|
||||
self.cad.logic(ctx);
|
||||
self.spacemouse.poll(&mut self.cad, ctx);
|
||||
self.persist_workspace(false);
|
||||
@@ -357,8 +361,11 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
let system_animations = system_animations::SystemAnimations::start(move || ctx.request_repaint());
|
||||
Ok(Box::new(App {
|
||||
desktop_theme,
|
||||
system_animations,
|
||||
cad: app,
|
||||
spacemouse: spacemouse::SpaceMouse::start(cc.egui_ctx.clone()),
|
||||
workspace,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Whether the operating system shows animations, for View ▸ Accessibility ▸ Animation Effects ▸
|
||||
//! Use System Setting.
|
||||
//!
|
||||
//! egui animates whatever the OS says (windows and menus fade in, wheel scrolling glides), and
|
||||
//! winit reports no such setting. This asks the OS itself:
|
||||
//!
|
||||
//! - Windows: Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects.
|
||||
//! - macOS: System Settings ▸ Accessibility ▸ Reduce motion.
|
||||
//! - Linux and the BSDs: the XDG Desktop Portal's Settings interface, as for the theme
|
||||
//! (`desktop_theme.rs`): its `reduced-motion`, or GNOME's own `enable-animations`.
|
||||
//!
|
||||
//! The answer goes to `CadApp::system_animations` once a frame.
|
||||
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How often the OS is asked again, so a change to its setting is followed while CADCraft runs.
|
||||
const POLL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Watches the OS setting.
|
||||
#[derive(Default)]
|
||||
pub struct SystemAnimations {
|
||||
/// The watcher thread's answers, where asking can block (the portal); `None` elsewhere, or
|
||||
/// when no thread could be started.
|
||||
changes: Option<Receiver<Option<bool>>>,
|
||||
/// The OS's last answer.
|
||||
current: Option<bool>,
|
||||
/// When the OS was last asked from the UI thread, where asking is one quick call.
|
||||
asked: Option<Instant>,
|
||||
}
|
||||
|
||||
impl SystemAnimations {
|
||||
/// Start watching. Where a thread does the asking, `wake` is called after each answer, so an
|
||||
/// idle window repaints and picks it up.
|
||||
pub fn start(wake: impl Fn() + Send + 'static) -> Self {
|
||||
if !ASKS_THE_PORTAL {
|
||||
return Self::default();
|
||||
}
|
||||
let (tx, rx) = channel();
|
||||
let started = std::thread::Builder::new().name("cadcraft-system-animations".into()).spawn(move || {
|
||||
let mut last = read();
|
||||
if tx.send(last).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
loop {
|
||||
std::thread::sleep(POLL);
|
||||
let now = read();
|
||||
if now != last {
|
||||
last = now;
|
||||
if tx.send(now).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { changes: started.ok().map(|_| rx), ..Self::default() }
|
||||
}
|
||||
|
||||
/// Whether the OS shows animations; `None` when it states nothing or there is nobody to ask.
|
||||
/// Polled once a frame; an OS that stops answering keeps its last answer.
|
||||
pub fn current(&mut self) -> Option<bool> {
|
||||
if let Some(changes) = &self.changes {
|
||||
loop {
|
||||
match changes.try_recv() {
|
||||
Ok(Some(animate)) => self.current = Some(animate),
|
||||
Ok(None) => {}
|
||||
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
} else if !ASKS_THE_PORTAL && self.asked.is_none_or(|at| at.elapsed() >= POLL) {
|
||||
self.asked = Some(Instant::now());
|
||||
if let Some(animate) = read() {
|
||||
self.current = Some(animate);
|
||||
}
|
||||
}
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the answer comes over D-Bus, which can block: a thread asks. Elsewhere the UI thread asks.
|
||||
const ASKS_THE_PORTAL: bool = cfg!(all(unix, not(target_os = "macos")));
|
||||
|
||||
/// Ask Windows once: `UISettings::AnimationsEnabled`, the setting behind
|
||||
/// `SPI_GETCLIENTAREAANIMATION`.
|
||||
#[cfg(windows)]
|
||||
fn read() -> Option<bool> {
|
||||
windows::UI::ViewManagement::UISettings::new().and_then(|settings| settings.AnimationsEnabled()).ok()
|
||||
}
|
||||
|
||||
/// Ask macOS once. Called on the main thread (`SystemAnimations::current`).
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read() -> Option<bool> {
|
||||
Some(!objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion())
|
||||
}
|
||||
|
||||
/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's
|
||||
/// thread is its only caller.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn read() -> Option<bool> {
|
||||
const PORTAL: &str = "org.freedesktop.portal.Desktop";
|
||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
const SETTINGS: &str = "org.freedesktop.portal.Settings";
|
||||
// The standard key first (0 no preference, 1 reduced motion); then GNOME's own switch, which
|
||||
// its portal answered before the standard key existed.
|
||||
const KEYS: [(&str, &str); 2] = [("org.freedesktop.appearance", "reduced-motion"), ("org.gnome.desktop.interface", "enable-animations")];
|
||||
let connection = zbus::blocking::Connection::session().ok()?;
|
||||
for (namespace, key) in KEYS {
|
||||
// `ReadOne` (Settings version 2) answers the value; older portals only have `Read`, which
|
||||
// wraps it in one more variant. `parse` unwraps either.
|
||||
let reply = connection
|
||||
.call_method(Some(PORTAL), PATH, Some(SETTINGS), "ReadOne", &(namespace, key))
|
||||
.or_else(|_| connection.call_method(Some(PORTAL), PATH, Some(SETTINGS), "Read", &(namespace, key)));
|
||||
let Ok(reply) = reply else { continue };
|
||||
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
|
||||
if let Some(animate) = parse(&answer, 0) {
|
||||
return Some(animate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's answer as "show animations": `reduced-motion` is a number (1 asks for less
|
||||
/// motion; 0 and values from a later version state no preference), `enable-animations` a
|
||||
/// boolean. `None` when it reads as neither. Variants are unwrapped a few levels deep.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn parse(value: &zbus::zvariant::Value<'_>, depth: u8) -> Option<bool> {
|
||||
use zbus::zvariant::Value;
|
||||
match value {
|
||||
Value::U32(reduced) => Some(*reduced != 1),
|
||||
Value::Bool(enabled) => Some(*enabled),
|
||||
Value::Value(inner) if depth < 4 => parse(inner, depth + 1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn read() -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
// macOS is asked on the main thread only, so there is nothing to test off it.
|
||||
#[cfg(all(test, not(target_os = "macos")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Windows is asked on the first frame, then not again until the poll interval has passed.
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn the_os_is_asked_on_the_first_frame_and_its_answer_kept() {
|
||||
let mut watch = SystemAnimations::start(|| {});
|
||||
assert!(watch.changes.is_none(), "no thread: asking is one quick call");
|
||||
let first = watch.current();
|
||||
assert_eq!(first, read());
|
||||
let asked = watch.asked;
|
||||
assert!(asked.is_some());
|
||||
assert_eq!(watch.current(), first);
|
||||
assert_eq!(watch.asked, asked, "not asked again within the poll interval");
|
||||
}
|
||||
|
||||
/// The standard key answers a number, GNOME's switch a boolean; either may come wrapped in
|
||||
/// variants (the older `Read`).
|
||||
#[test]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn the_portal_answer_reads_as_show_animations_or_as_none() {
|
||||
use zbus::zvariant::Value;
|
||||
assert_eq!(parse(&Value::U32(1), 0), Some(false));
|
||||
assert_eq!(parse(&Value::U32(0), 0), Some(true));
|
||||
assert_eq!(parse(&Value::U32(7), 0), Some(true), "unknown values are no preference");
|
||||
assert_eq!(parse(&Value::Bool(false), 0), Some(false));
|
||||
assert_eq!(parse(&Value::Bool(true), 0), Some(true));
|
||||
assert_eq!(parse(&Value::Value(Box::new(Value::U32(1))), 0), Some(false));
|
||||
assert_eq!(parse(&Value::Str("off".into()), 0), None);
|
||||
// A hostile answer nested without end is given up on.
|
||||
let mut nested = Value::U32(1);
|
||||
for _ in 0..16 {
|
||||
nested = Value::Value(Box::new(nested));
|
||||
}
|
||||
assert_eq!(parse(&nested, 0), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Animation effects: the interface's fades and glides, and the choice to show them.
|
||||
//!
|
||||
//! None of the motion is CADCraft's own; it is egui's. Windows, menus, tooltips and popups fade in
|
||||
//! over [`egui::Style::animation_time`], collapsing sections and toggles slide over it, scroll
|
||||
//! areas glide to a scroll target, and each mouse-wheel notch is spread over several frames. With
|
||||
//! animation effects off all of that happens at once.
|
||||
//!
|
||||
//! What tells the user about the work stays, on or off: rubber bands and previews, snap markers
|
||||
//! and tracking's dwell, the text cursor's blink, spinners, and pan and zoom, which follow the
|
||||
//! pointer and the wheel directly.
|
||||
//!
|
||||
//! [`AnimationPref::System`], the default, follows the operating system (Windows: Settings ▸
|
||||
//! Accessibility ▸ Visual effects ▸ Animation effects; a browser: `prefers-reduced-motion`). The
|
||||
//! host asks the OS and hands its answer to [`CadApp::system_animations`](crate::CadApp).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Whether the interface shows animation effects.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AnimationPref {
|
||||
/// The default: follow the operating system's setting, live.
|
||||
#[default]
|
||||
System,
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
|
||||
impl AnimationPref {
|
||||
pub const ALL: [AnimationPref; 3] = [AnimationPref::System, AnimationPref::On, AnimationPref::Off];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
AnimationPref::System => "system",
|
||||
AnimationPref::On => "on",
|
||||
AnimationPref::Off => "off",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<AnimationPref> {
|
||||
AnimationPref::ALL.into_iter().find(|p| p.as_str().eq_ignore_ascii_case(s.trim()))
|
||||
}
|
||||
|
||||
/// Whether to animate, given what the OS reports (`None`: it states nothing, or the host
|
||||
/// cannot ask it; `System` then animates, as egui does by default).
|
||||
pub fn resolve(self, system: Option<bool>) -> bool {
|
||||
match self {
|
||||
AnimationPref::System => system.unwrap_or(true),
|
||||
AnimationPref::On => true,
|
||||
AnimationPref::Off => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn egui's own animations on or off, in the styles of both themes.
|
||||
pub fn apply(ctx: &egui::Context, animate: bool) {
|
||||
let animated = egui::Style::default();
|
||||
ctx.all_styles_mut(|s| {
|
||||
s.animation_time = if animate { animated.animation_time } else { 0.0 };
|
||||
s.scroll_animation = if animate { animated.scroll_animation } else { egui::style::ScrollAnimation::none() };
|
||||
});
|
||||
}
|
||||
|
||||
/// One frame of mouse-wheel input, read from the raw wheel events as egui reads them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Wheel {
|
||||
/// Points to scroll by.
|
||||
scroll: egui::Vec2,
|
||||
/// The zoom of wheel turns with the zoom modifier held (⌘/Ctrl); 1 when there are none.
|
||||
zoom: f32,
|
||||
}
|
||||
|
||||
/// Units convert to points as egui does (a line is `line_scroll_speed` points, a page the view's
|
||||
/// height); the zoom modifier turns a scroll into a zoom, ⇧ into a sideways scroll.
|
||||
fn wheel(events: &[egui::Event], opts: &egui::InputOptions, page: f32) -> Wheel {
|
||||
let (mut scroll, mut zoom_points) = (egui::Vec2::ZERO, 0.0);
|
||||
for e in events {
|
||||
let egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers } = e else { continue };
|
||||
let per = match unit {
|
||||
egui::MouseWheelUnit::Point => 1.0,
|
||||
egui::MouseWheelUnit::Line => opts.line_scroll_speed,
|
||||
egui::MouseWheelUnit::Page => page,
|
||||
};
|
||||
let d = *delta * per;
|
||||
if modifiers.matches_any(opts.zoom_modifier) {
|
||||
zoom_points += d.x + d.y;
|
||||
continue;
|
||||
}
|
||||
let (sideways, upright) = (modifiers.matches_any(opts.horizontal_scroll_modifier), modifiers.matches_any(opts.vertical_scroll_modifier));
|
||||
scroll += match (sideways, upright) {
|
||||
(true, false) => egui::vec2(d.x + d.y, 0.0),
|
||||
(false, true) => egui::vec2(0.0, d.x + d.y),
|
||||
_ => d,
|
||||
};
|
||||
}
|
||||
let zoom = (opts.scroll_zoom_speed * zoom_points).exp();
|
||||
Wheel {
|
||||
scroll: if scroll.x.is_finite() && scroll.y.is_finite() { scroll } else { egui::Vec2::ZERO },
|
||||
zoom: if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 },
|
||||
}
|
||||
}
|
||||
|
||||
/// With animation effects off, hand each wheel notch to the scroll areas whole, in the frame it
|
||||
/// arrives. egui spreads a notch over several frames ([`egui::InputState::smooth_scroll_delta`])
|
||||
/// and has no switch for it, so the smoothed delta is replaced by the frame's raw one; the part
|
||||
/// egui still holds back is dropped the same way on the frames that follow. Call once a frame,
|
||||
/// before any scroll area is shown.
|
||||
pub fn unsmooth_scroll(ctx: &egui::Context) {
|
||||
let opts = ctx.options(|o| o.input_options);
|
||||
ctx.input_mut(|i| {
|
||||
let page = i.viewport_rect().height();
|
||||
i.smooth_scroll_delta = wheel(&i.raw.events, &opts, page).scroll;
|
||||
});
|
||||
}
|
||||
|
||||
/// The zoom gesture this frame. With animation effects on this is egui's own
|
||||
/// [`egui::InputState::zoom_delta`]; off, a ⌘/Ctrl+wheel notch (a touchpad pinch, on Windows)
|
||||
/// zooms by its whole step at once instead of gliding there. A real pinch (touch screen, macOS
|
||||
/// trackpad) follows the fingers either way.
|
||||
pub fn zoom_delta(i: &egui::InputState, opts: &egui::InputOptions, animate: bool) -> f32 {
|
||||
if animate {
|
||||
return i.zoom_delta();
|
||||
}
|
||||
if let Some(touch) = i.multi_touch() {
|
||||
return touch.zoom_delta;
|
||||
}
|
||||
let pinch: f32 = i
|
||||
.raw
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::Zoom(factor) => Some(*factor),
|
||||
_ => None,
|
||||
})
|
||||
.product();
|
||||
let zoom = pinch * wheel(&i.raw.events, opts, i.viewport_rect().height()).zoom;
|
||||
if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn notch(unit: egui::MouseWheelUnit, delta: egui::Vec2, modifiers: egui::Modifiers) -> egui::Event {
|
||||
egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_choice_parses_and_resolves_against_the_os() {
|
||||
assert_eq!(AnimationPref::default(), AnimationPref::System, "the OS decides until the user does");
|
||||
for p in AnimationPref::ALL {
|
||||
assert_eq!(AnimationPref::parse(p.as_str()), Some(p));
|
||||
}
|
||||
assert_eq!(AnimationPref::parse(" OFF "), Some(AnimationPref::Off));
|
||||
assert_eq!(AnimationPref::parse("sometimes"), None);
|
||||
assert!(!AnimationPref::System.resolve(Some(false)));
|
||||
assert!(AnimationPref::System.resolve(Some(true)));
|
||||
assert!(AnimationPref::System.resolve(None), "an OS that states nothing keeps egui's animations");
|
||||
for system in [None, Some(true), Some(false)] {
|
||||
assert!(AnimationPref::On.resolve(system));
|
||||
assert!(!AnimationPref::Off.resolve(system));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn egui_stops_animating_and_starts_again() {
|
||||
let ctx = egui::Context::default();
|
||||
let animated = egui::Style::default();
|
||||
apply(&ctx, false);
|
||||
for theme in [egui::Theme::Dark, egui::Theme::Light] {
|
||||
let s = ctx.style_of(theme);
|
||||
assert_eq!(s.animation_time, 0.0, "{theme:?}: fades and slides end at once");
|
||||
assert_eq!(s.scroll_animation, egui::style::ScrollAnimation::none(), "{theme:?}");
|
||||
}
|
||||
apply(&ctx, true);
|
||||
for theme in [egui::Theme::Dark, egui::Theme::Light] {
|
||||
let s = ctx.style_of(theme);
|
||||
assert_eq!(s.animation_time, animated.animation_time, "{theme:?}");
|
||||
assert_eq!(s.scroll_animation, animated.scroll_animation, "{theme:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_reads_as_egui_reads_it() {
|
||||
use egui::{Modifiers, MouseWheelUnit, vec2};
|
||||
let o = egui::InputOptions::default();
|
||||
let none = Modifiers::NONE;
|
||||
let plain = wheel(&[notch(MouseWheelUnit::Line, vec2(0.0, -1.0), none), notch(MouseWheelUnit::Point, vec2(3.0, 5.0), none)], &o, 500.0);
|
||||
assert_eq!(plain, Wheel { scroll: vec2(3.0, 5.0 - o.line_scroll_speed), zoom: 1.0 });
|
||||
assert_eq!(wheel(&[notch(MouseWheelUnit::Page, vec2(0.0, 1.0), none)], &o, 500.0).scroll, vec2(0.0, 500.0));
|
||||
// ⇧ scrolls sideways; ⌘/Ctrl zooms and scrolls nothing.
|
||||
assert_eq!(wheel(&[notch(MouseWheelUnit::Point, vec2(0.0, 12.0), Modifiers::SHIFT)], &o, 500.0).scroll, vec2(12.0, 0.0));
|
||||
let zoomed = wheel(&[notch(MouseWheelUnit::Line, vec2(0.0, 1.0), Modifiers::COMMAND)], &o, 500.0);
|
||||
assert_eq!(zoomed.scroll, egui::Vec2::ZERO);
|
||||
assert!((zoomed.zoom - (o.scroll_zoom_speed * o.line_scroll_speed).exp()).abs() < 1e-6 && zoomed.zoom > 1.0);
|
||||
// A touchpad's begin and end markers carry no scroll.
|
||||
let ended = egui::Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 9.0), phase: egui::TouchPhase::End, modifiers: none };
|
||||
assert_eq!(wheel(&[ended], &o, 500.0), Wheel { scroll: egui::Vec2::ZERO, zoom: 1.0 });
|
||||
// Hostile deltas scroll and zoom nothing.
|
||||
for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
assert_eq!(wheel(&[notch(MouseWheelUnit::Line, vec2(0.0, bad), none)], &o, 500.0).scroll, egui::Vec2::ZERO);
|
||||
assert_eq!(wheel(&[notch(MouseWheelUnit::Line, vec2(0.0, bad), Modifiers::COMMAND)], &o, 500.0).zoom, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame with `events`, returning what the scroll areas would scroll by and the zoom.
|
||||
fn frame(ctx: &egui::Context, events: Vec<egui::Event>, animate: bool) -> (egui::Vec2, f32) {
|
||||
let o = ctx.options(|o| o.input_options);
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 500.0))), events, ..Default::default() };
|
||||
let mut seen = (egui::Vec2::ZERO, 1.0);
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
if !animate {
|
||||
unsmooth_scroll(ui.ctx());
|
||||
}
|
||||
seen = ui.input(|i| (i.smooth_scroll_delta, zoom_delta(i, &o, animate)));
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
seen
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wheel_notch_scrolls_whole_in_its_frame_with_animation_effects_off() {
|
||||
let line = egui::InputOptions::default().line_scroll_speed;
|
||||
let turn = || vec![notch(egui::MouseWheelUnit::Line, egui::vec2(0.0, -3.0), egui::Modifiers::NONE)];
|
||||
|
||||
// egui glides: part of the notch now, the rest over the frames that follow.
|
||||
let ctx = egui::Context::default();
|
||||
let (first, _) = frame(&ctx, turn(), true);
|
||||
assert!(first.y < 0.0 && first.y > -3.0 * line, "egui's smoothed delta lags behind: {first:?}");
|
||||
assert!(frame(&ctx, Vec::new(), true).0.y < 0.0, "and keeps arriving");
|
||||
|
||||
// Off: all of it at once, and nothing trickles in afterwards.
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(frame(&ctx, turn(), false).0, egui::vec2(0.0, -3.0 * line));
|
||||
for _ in 0..4 {
|
||||
assert_eq!(frame(&ctx, Vec::new(), false).0, egui::Vec2::ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zoom_notch_zooms_whole_in_its_frame_with_animation_effects_off() {
|
||||
let o = egui::InputOptions::default();
|
||||
let whole = (o.scroll_zoom_speed * o.line_scroll_speed).exp();
|
||||
let turn = || vec![notch(egui::MouseWheelUnit::Line, egui::vec2(0.0, 1.0), egui::Modifiers::COMMAND)];
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
let (_, first) = frame(&ctx, turn(), true);
|
||||
assert!(first > 1.0 && first < whole, "egui's zoom glides there: {first} of {whole}");
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
assert!((frame(&ctx, turn(), false).1 - whole).abs() < 1e-6);
|
||||
assert_eq!(frame(&ctx, Vec::new(), false).1, 1.0, "nothing trickles in afterwards");
|
||||
// A pinch gesture's own zoom events pass through.
|
||||
assert_eq!(frame(&ctx, vec![egui::Event::Zoom(1.25)], false).1, 1.25);
|
||||
}
|
||||
}
|
||||
@@ -1124,7 +1124,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let about = xf.to_world(hp);
|
||||
let _ = app.session.zoom_about(f, about);
|
||||
}
|
||||
let zoom_pinch = ui.input(|i| i.zoom_delta());
|
||||
let zoom_pinch = ui.input(|i| crate::animation::zoom_delta(i, &opts, app.shows_animations()));
|
||||
if inside
|
||||
&& (zoom_pinch - 1.0).abs() > 1e-4
|
||||
&& let Some(hp) = hover_pos
|
||||
|
||||
@@ -258,6 +258,7 @@ pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value {
|
||||
json!({
|
||||
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
|
||||
"theme": if app.shown_theme() == egui::Theme::Light { "light" } else { "dark" },
|
||||
"animations": app.shows_animations(),
|
||||
"focus": focus_json(ctx),
|
||||
"canvasRect": app.canvas.rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
|
||||
"window": [r.width(), r.height()],
|
||||
|
||||
@@ -1177,6 +1177,8 @@
|
||||
Hide Others Skrýt ostatní
|
||||
Accessibility Zpřístupnění
|
||||
Show System Cursor Zobrazit systémový kurzor
|
||||
Animation Effects Efekty animace
|
||||
Animation effects Efekty animace
|
||||
Import... Importovat...
|
||||
Export... Exportovat...
|
||||
Batch Publish... Dávkově publikovat...
|
||||
|
||||
|
@@ -1004,6 +1004,8 @@
|
||||
Hide Others Andere ausblenden
|
||||
Accessibility Bedienungshilfen
|
||||
Show System Cursor Systemzeiger anzeigen
|
||||
Animation Effects Animationseffekte
|
||||
Animation effects Animationseffekte
|
||||
3D Mouse... 3D-Maus...
|
||||
3D Mouse 3D-Maus
|
||||
Calibrate 3D Mouse 3D-Maus kalibrieren
|
||||
|
||||
|
@@ -1219,6 +1219,8 @@
|
||||
Hide Others Ocultar otros
|
||||
Accessibility Accesibilidad
|
||||
Show System Cursor Mostrar cursor del sistema
|
||||
Animation Effects Efectos de animación
|
||||
Animation effects Efectos de animación
|
||||
3D Mouse... Ratón 3D...
|
||||
3D Mouse Ratón 3D
|
||||
Calibrate 3D Mouse Calibrar ratón 3D
|
||||
|
||||
|
@@ -998,6 +998,8 @@
|
||||
Hide Others Masquer les autres
|
||||
Accessibility Accessibilité
|
||||
Show System Cursor Afficher le curseur système
|
||||
Animation Effects Effets d'animation
|
||||
Animation effects Effets d'animation
|
||||
Import... Importer...
|
||||
Export... Exporter...
|
||||
Batch Publish... Publication par lots...
|
||||
|
||||
|
@@ -1180,6 +1180,8 @@ menu Modify Edita
|
||||
Hide Others Nascondi altre
|
||||
Accessibility Accessibilità
|
||||
Show System Cursor Mostra cursore di sistema
|
||||
Animation Effects Effetti di animazione
|
||||
Animation effects Effetti di animazione
|
||||
Import... Importa...
|
||||
Export... Esporta...
|
||||
Batch Publish... Pubblicazione batch...
|
||||
|
||||
|
@@ -1219,6 +1219,8 @@
|
||||
Hide Others Ocultar outros
|
||||
Accessibility Acessibilidade
|
||||
Show System Cursor Exibir cursor do sistema
|
||||
Animation Effects Efeitos de animação
|
||||
Animation effects Efeitos de animação
|
||||
3D Mouse... Mouse 3D...
|
||||
3D Mouse Mouse 3D
|
||||
Calibrate 3D Mouse Calibrar mouse 3D
|
||||
|
||||
|
@@ -998,6 +998,8 @@
|
||||
Hide Others Скрыть остальные
|
||||
Accessibility Специальные возможности
|
||||
Show System Cursor Показывать системный курсор
|
||||
Animation Effects Эффекты анимации
|
||||
Animation effects Эффекты анимации
|
||||
Import... Импорт...
|
||||
Export... Экспорт...
|
||||
Batch Publish... Пакетная публикация...
|
||||
|
||||
|
@@ -1192,6 +1192,8 @@
|
||||
Hide Others Приховати інші
|
||||
Accessibility Спеціальні можливості
|
||||
Show System Cursor Показувати системний курсор
|
||||
Animation Effects Ефекти анімації
|
||||
Animation effects Ефекти анімації
|
||||
Import... Імпорт...
|
||||
Export... Експорт...
|
||||
Batch Publish... Пакетна публікація...
|
||||
|
||||
|
@@ -999,6 +999,8 @@
|
||||
Hide Others 隐藏其他人
|
||||
Accessibility 辅助功能
|
||||
Show System Cursor 显示系统光标
|
||||
Animation Effects 动画效果
|
||||
Animation effects 动画效果
|
||||
3D Mouse... 3D 鼠标...
|
||||
3D Mouse 3D 鼠标
|
||||
Calibrate 3D Mouse 校准 3D 鼠标
|
||||
|
||||
|
@@ -8,6 +8,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod animation;
|
||||
pub mod autosave;
|
||||
pub mod canvas;
|
||||
pub mod chrome;
|
||||
@@ -94,6 +95,9 @@ pub struct UiState {
|
||||
pub history_lines: usize,
|
||||
/// Interface theme: "system" (follow the OS light/dark appearance), "light" or "dark".
|
||||
pub theme: theme::ThemePref,
|
||||
/// Animation effects (dialog and menu fades, smoothed scrolling): "system" (follow the OS
|
||||
/// setting), "on" or "off".
|
||||
pub animations: animation::AnimationPref,
|
||||
/// The format Save As suggests for a drawing that has no file yet: "dxf" or "dwg".
|
||||
pub save_format: SaveFormat,
|
||||
/// The version Save As writes for a drawing that has no file yet ("2000" … "2018"); a
|
||||
@@ -168,6 +172,7 @@ impl Default for UiState {
|
||||
dialog: None,
|
||||
history_lines: 3,
|
||||
theme: theme::ThemePref::default(),
|
||||
animations: animation::AnimationPref::default(),
|
||||
save_format: SaveFormat::default(),
|
||||
save_version: SaveVersion::default().year().to_string(),
|
||||
recent_files: Vec::new(),
|
||||
@@ -184,6 +189,9 @@ pub struct Services {
|
||||
|
||||
pub struct CadApp {
|
||||
pub system_languages: Vec<String>,
|
||||
/// Whether the operating system shows animations, as the host reads it; `None` when the OS
|
||||
/// states nothing or the host cannot ask ([`animation`]).
|
||||
pub system_animations: Option<bool>,
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
/// The interface's own preferences from the Options dialog ([`options`]).
|
||||
@@ -211,6 +219,8 @@ pub struct CadApp {
|
||||
shown_theme: Option<egui::Theme>,
|
||||
/// Whether the last applied theme was handed to egui as the System preference.
|
||||
shown_follows_system: bool,
|
||||
/// Whether egui was last told to animate (`None` until the first frame).
|
||||
shown_animations: Option<bool>,
|
||||
pub frame_ms: f64,
|
||||
pub quit_requested: bool,
|
||||
/// The app may close although drawings have unsaved changes (they were dealt with, or the
|
||||
@@ -234,6 +244,7 @@ impl CadApp {
|
||||
pub fn new(session: Session, services: Services) -> Self {
|
||||
CadApp {
|
||||
system_languages: Vec::new(),
|
||||
system_animations: None,
|
||||
session,
|
||||
ui: UiState::default(),
|
||||
options: options::UiOptions::default(),
|
||||
@@ -254,6 +265,7 @@ impl CadApp {
|
||||
fonts_cjk: None,
|
||||
shown_theme: None,
|
||||
shown_follows_system: false,
|
||||
shown_animations: None,
|
||||
frame_ms: 0.0,
|
||||
quit_requested: false,
|
||||
quit_confirmed: false,
|
||||
@@ -336,10 +348,17 @@ impl CadApp {
|
||||
self.shown_theme.unwrap_or_else(|| self.ui.theme.resolve(None))
|
||||
}
|
||||
|
||||
/// Whether the interface shows animation effects now (the choice resolved against the OS
|
||||
/// setting).
|
||||
pub fn shows_animations(&self) -> bool {
|
||||
self.ui.animations.resolve(self.system_animations)
|
||||
}
|
||||
|
||||
/// Preferences kept across restarts, as JSON for the host's storage ([`PREFS_KEY`]).
|
||||
pub fn prefs_json(&self) -> String {
|
||||
let mut prefs = json!({
|
||||
"theme": self.ui.theme.as_str(),
|
||||
"animations": self.ui.animations.as_str(),
|
||||
"interfaceLanguage": self.ui.interface_language.code(),
|
||||
"saveFormat": self.ui.save_format.as_str(),
|
||||
"saveVersion": self.ui.save_version,
|
||||
@@ -362,6 +381,9 @@ impl CadApp {
|
||||
if let Some(t) = v.get("theme").and_then(Value::as_str).and_then(theme::ThemePref::parse) {
|
||||
self.ui.theme = t;
|
||||
}
|
||||
if let Some(a) = v.get("animations").and_then(Value::as_str).and_then(animation::AnimationPref::parse) {
|
||||
self.ui.animations = a;
|
||||
}
|
||||
if let Some(l) = v.get("interfaceLanguage").and_then(Value::as_str).and_then(i18n::Preference::parse) {
|
||||
self.ui.interface_language = l;
|
||||
}
|
||||
@@ -519,6 +541,15 @@ impl CadApp {
|
||||
self.shown_theme = Some(shown);
|
||||
self.shown_follows_system = follows_system;
|
||||
}
|
||||
// Resolved every frame too, so a System choice follows the OS setting live.
|
||||
let animate = self.shows_animations();
|
||||
if self.shown_animations != Some(animate) {
|
||||
animation::apply(ctx, animate);
|
||||
self.shown_animations = Some(animate);
|
||||
}
|
||||
if !animate {
|
||||
animation::unsmooth_scroll(ctx);
|
||||
}
|
||||
self.drain_control(ctx);
|
||||
self.finish_opening();
|
||||
self.opening.show(ctx);
|
||||
@@ -750,7 +781,7 @@ mod tests {
|
||||
frame(&mut a, &ctx, None);
|
||||
assert_eq!(Tokens::get(), Tokens::of(theme::SYSTEM_FALLBACK), "no OS appearance: the documented fallback");
|
||||
assert_eq!(a.ui.theme, ThemePref::System);
|
||||
assert_eq!(a.prefs_json(), r#"{"interfaceLanguage":"auto","saveFormat":"dxf","saveVersion":"2018","theme":"system"}"#);
|
||||
assert_eq!(a.prefs_json(), r#"{"animations":"system","interfaceLanguage":"auto","saveFormat":"dxf","saveVersion":"2018","theme":"system"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -29,6 +29,10 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
|
||||
("ui.panel.layers", "Layers Palette", &["Window", "Layers Palette"], None),
|
||||
("ui.panel.quickcalc", "QuickCalc", &["Window", "QuickCalc"], Some("Cmd+8")),
|
||||
("ui.toggle.systemcursor", "Show System Cursor", &["View", "Accessibility", "Show System Cursor"], None),
|
||||
("ui.animations.system", "Use System Setting", &["View", "Accessibility", "Animation Effects", "Use System Setting"], None),
|
||||
("ui.animations.on", "On", &["View", "Accessibility", "Animation Effects", "On"], None),
|
||||
("ui.animations.off", "Off", &["View", "Accessibility", "Animation Effects", "Off"], None),
|
||||
("ui.animations", "Animation Effects", &[], None),
|
||||
("ui.toggle.toolbar", "Tool Bar", &["Window", "Tool Bar"], None),
|
||||
("ui.toggle.filetabs", "File Tab", &["Window", "File Tab"], None),
|
||||
("ui.toggle.statusbar", "Status Bar", &["Window", "Status Bar"], None),
|
||||
@@ -249,6 +253,18 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
},
|
||||
None => Ok(theme_json(app)),
|
||||
},
|
||||
"ui.animations.system" | "ui.animations.on" | "ui.animations.off" => {
|
||||
let pref = crate::animation::AnimationPref::parse(id.trim_start_matches("ui.animations.")).unwrap_or_default();
|
||||
Ok(set_animations(app, pref))
|
||||
}
|
||||
// `{"animations": "system"|"on"|"off"}` sets the choice; without it, reports it.
|
||||
"ui.animations" => match params.get("animations").and_then(Value::as_str) {
|
||||
Some(name) => match crate::animation::AnimationPref::parse(name) {
|
||||
Some(pref) => Ok(set_animations(app, pref)),
|
||||
None => Err(format!("unknown animations choice \"{}\" (system, on or off)", name.chars().take(40).collect::<String>())),
|
||||
},
|
||||
None => Ok(animations_json(app)),
|
||||
},
|
||||
"ui.saveformat.dxf" | "ui.saveformat.dwg" => {
|
||||
let format = crate::SaveFormat::parse(id.trim_start_matches("ui.saveformat.")).unwrap_or_default();
|
||||
Ok(set_save_format(app, format))
|
||||
@@ -288,8 +304,8 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
}
|
||||
"ui.resetpalettes" => {
|
||||
let (menu, theme, interface_language, save_format) = (app.ui.in_window_menu, app.ui.theme, app.ui.interface_language, app.ui.save_format);
|
||||
let save_version = std::mem::take(&mut app.ui.save_version);
|
||||
app.ui = crate::UiState { in_window_menu: menu, theme, interface_language, save_format, save_version, ..Default::default() };
|
||||
let (animations, save_version) = (app.ui.animations, std::mem::take(&mut app.ui.save_version));
|
||||
app.ui = crate::UiState { in_window_menu: menu, theme, animations, interface_language, save_format, save_version, ..Default::default() };
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"ui.start" => {
|
||||
@@ -418,6 +434,22 @@ fn theme_json(app: &CadApp) -> Value {
|
||||
json!({ "theme": app.ui.theme.as_str(), "shown": if shown == egui::Theme::Light { "light" } else { "dark" } })
|
||||
}
|
||||
|
||||
fn set_animations(app: &mut CadApp, pref: crate::animation::AnimationPref) -> Value {
|
||||
app.ui.animations = pref;
|
||||
let shown = if app.shows_animations() { "on" } else { "off" };
|
||||
app.session.echo(match pref {
|
||||
crate::animation::AnimationPref::System => format!("Animation effects follow the system setting ({shown})"),
|
||||
_ => format!("Animation effects are {shown}"),
|
||||
});
|
||||
animations_json(app)
|
||||
}
|
||||
|
||||
/// The animation effects choice and whether the interface animates with it (System resolves
|
||||
/// against the OS setting).
|
||||
fn animations_json(app: &CadApp) -> Value {
|
||||
json!({ "animations": app.ui.animations.as_str(), "shown": app.shows_animations() })
|
||||
}
|
||||
|
||||
/// A menu entry.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Entry {
|
||||
|
||||
@@ -8,6 +8,7 @@ use egui::{Color32, RichText, Sense, Stroke, vec2};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::animation::AnimationPref;
|
||||
use crate::theme::{ThemePref, Tokens};
|
||||
|
||||
/// The tabs: (id in the dialog name, label).
|
||||
@@ -100,6 +101,14 @@ fn theme_label(t: ThemePref) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn animations_label(a: AnimationPref) -> &'static str {
|
||||
match a {
|
||||
AnimationPref::System => crate::tl!("Use System Setting"),
|
||||
AnimationPref::On => crate::tl!("On"),
|
||||
AnimationPref::Off => crate::tl!("Off"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour button, its hex value and a Default button; a change goes into `changes` under `key`.
|
||||
fn color_row(ui: &mut egui::Ui, current: Option<Color32>, default: Color32, key: &str, changes: &mut Map<String, Value>) {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -140,6 +149,18 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
||||
}
|
||||
ui.end_row();
|
||||
|
||||
ui.label(crate::tl!("Animation effects"));
|
||||
let mut animations = app.ui.animations;
|
||||
egui::ComboBox::from_id_salt("options_animations").selected_text(animations_label(animations)).show_ui(ui, |ui| {
|
||||
for a in AnimationPref::ALL {
|
||||
ui.selectable_value(&mut animations, a, animations_label(a));
|
||||
}
|
||||
});
|
||||
if animations != app.ui.animations {
|
||||
changes.insert("animations".into(), json!(animations.as_str()));
|
||||
}
|
||||
ui.end_row();
|
||||
|
||||
ui.label(crate::tl!("Model background"));
|
||||
color_row(ui, app.options.model_background, Tokens::DARK.canvas, "modelBackground", changes);
|
||||
ui.end_row();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! | name | value |
|
||||
//! |---|---|
|
||||
//! | `theme` | `"system"`, `"light"` or `"dark"` |
|
||||
//! | `animations` | `"system"`, `"on"` or `"off"`: dialog and menu fades and smoothed scrolling |
|
||||
//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"` or `"it"` |
|
||||
//! | `saveFormat` | `"dxf"` or `"dwg"`: the format Save As suggests for new drawings |
|
||||
//! | `saveVersion` | `"2000"` … `"2018"`: the version drawings with no file yet are saved in |
|
||||
@@ -35,6 +36,7 @@ use egui::Color32;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::animation::AnimationPref;
|
||||
use crate::theme::{self, ThemePref};
|
||||
|
||||
/// The host storage key for [`CadApp::profile_json`].
|
||||
@@ -112,6 +114,7 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
|
||||
#[derive(Default)]
|
||||
struct UiChanges {
|
||||
theme: Option<ThemePref>,
|
||||
animations: Option<AnimationPref>,
|
||||
language: Option<crate::i18n::Preference>,
|
||||
save_format: Option<crate::SaveFormat>,
|
||||
system_cursor: Option<bool>,
|
||||
@@ -137,6 +140,7 @@ fn parse_ui(key: &str, v: &Value, c: &mut UiChanges) -> Result<bool, String> {
|
||||
let text = || v.as_str().ok_or_else(|| format!("{key} must be text"));
|
||||
match key {
|
||||
"theme" => c.theme = Some(ThemePref::parse(text()?).ok_or("theme must be system, light or dark")?),
|
||||
"animations" => c.animations = Some(AnimationPref::parse(text()?).ok_or("animations must be system, on or off")?),
|
||||
"interfaceLanguage" => {
|
||||
c.language = Some(
|
||||
crate::i18n::Preference::parse(text()?).ok_or_else(|| format!("interfaceLanguage must be {}", crate::i18n::Preference::codes()))?,
|
||||
@@ -181,6 +185,7 @@ fn ui_json(app: &CadApp) -> Map<String, Value> {
|
||||
let o = &app.options;
|
||||
let mut m = Map::new();
|
||||
m.insert("theme".into(), json!(app.ui.theme.as_str()));
|
||||
m.insert("animations".into(), json!(app.ui.animations.as_str()));
|
||||
m.insert("interfaceLanguage".into(), json!(app.ui.interface_language.code()));
|
||||
m.insert("saveFormat".into(), json!(app.ui.save_format.as_str()));
|
||||
m.insert("systemCursor".into(), json!(app.ui.system_cursor));
|
||||
@@ -223,6 +228,9 @@ fn run_json(app: &mut CadApp, params: &Value) -> Result<Value, String> {
|
||||
if let Some(t) = ui.theme {
|
||||
app.ui.theme = t;
|
||||
}
|
||||
if let Some(a) = ui.animations {
|
||||
app.ui.animations = a;
|
||||
}
|
||||
if let Some(l) = ui.language {
|
||||
app.ui.interface_language = l;
|
||||
}
|
||||
@@ -263,6 +271,7 @@ fn run_json(app: &mut CadApp, params: &Value) -> Result<Value, String> {
|
||||
fn reset_ui(app: &mut CadApp) {
|
||||
let d = crate::UiState::default();
|
||||
app.ui.theme = d.theme;
|
||||
app.ui.animations = d.animations;
|
||||
app.ui.save_format = d.save_format;
|
||||
app.ui.save_version = d.save_version;
|
||||
app.ui.system_cursor = d.system_cursor;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Animation effects: with the operating system set to show none, dialogs and menus appear at
|
||||
//! once and wheel scrolling doesn't glide. View ▸ Accessibility ▸ Animation Effects follows the OS
|
||||
//! by default, or turns them on or off whatever it says.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::animation::AnimationPref;
|
||||
use cadcraft_ui_egui::{CadApp, Services, UiState, menus};
|
||||
use serde_json::json;
|
||||
|
||||
fn app() -> CadApp {
|
||||
CadApp::new(Session::new(), Services::default())
|
||||
}
|
||||
|
||||
/// One frame of the whole app with `events`; `look` reads the frame's input after `logic`.
|
||||
fn frame<R>(
|
||||
app: &mut CadApp,
|
||||
ctx: &egui::Context,
|
||||
events: Vec<egui::Event>,
|
||||
look: impl Fn(&egui::Context) -> R,
|
||||
) -> (R, Vec<egui::epaint::ClippedShape>) {
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), events, ..Default::default() };
|
||||
let mut seen = None;
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
seen = Some(look(ui.ctx()));
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear(); // no renderer here
|
||||
(seen.unwrap(), out.shapes)
|
||||
}
|
||||
|
||||
/// The opacity (0–255) `text` is painted with, if it is painted.
|
||||
fn opacity_of(shapes: &[egui::epaint::ClippedShape], text: &str) -> Option<u8> {
|
||||
fn find(shape: &egui::Shape, text: &str) -> Option<u8> {
|
||||
match shape {
|
||||
egui::Shape::Text(s) if s.galley.text().contains(text) => Some(s.fallback_color.a()),
|
||||
egui::Shape::Vec(shapes) => shapes.iter().find_map(|s| find(s, text)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
shapes.iter().find_map(|s| find(&s.shape, text))
|
||||
}
|
||||
|
||||
/// The opacity the About dialog's text has in the first frame it is painted, and a second later.
|
||||
fn about_fade(app: &mut CadApp) -> (u8, u8) {
|
||||
const TEXT: &str = "clean-room CAD application";
|
||||
let ctx = egui::Context::default();
|
||||
frame(app, &ctx, Vec::new(), |_| ()); // the app itself, before the dialog opens
|
||||
app.start("ui.dialog.about");
|
||||
let mut first = None;
|
||||
for _ in 0..60 {
|
||||
let (_, shapes) = frame(app, &ctx, Vec::new(), |_| ());
|
||||
first = first.or(opacity_of(&shapes, TEXT));
|
||||
}
|
||||
let (_, shapes) = frame(app, &ctx, Vec::new(), |_| ());
|
||||
app.ui.dialog = None;
|
||||
(first.unwrap(), opacity_of(&shapes, TEXT).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialogs_fade_in_only_while_animation_effects_show() {
|
||||
// The OS shows animations (or says nothing): egui's fade, as before.
|
||||
for system in [None, Some(true)] {
|
||||
let mut a = app();
|
||||
a.system_animations = system;
|
||||
let (first, settled) = about_fade(&mut a);
|
||||
assert!(first < 255, "{system:?}: the dialog fades in ({first})");
|
||||
assert_eq!(settled, 255, "{system:?}");
|
||||
}
|
||||
// The OS shows none: the dialog is there, whole, in its first frame.
|
||||
let mut a = app();
|
||||
a.system_animations = Some(false);
|
||||
assert_eq!(about_fade(&mut a), (255, 255));
|
||||
// The user's own choice outranks the OS, either way.
|
||||
a.start("ui.animations.on");
|
||||
assert!(about_fade(&mut a).0 < 255);
|
||||
a.system_animations = Some(true);
|
||||
a.start("ui.animations.off");
|
||||
assert_eq!(about_fade(&mut a), (255, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_system_choice_follows_the_os_live() {
|
||||
let mut a = app();
|
||||
let ctx = egui::Context::default();
|
||||
let animated = egui::Style::default().animation_time;
|
||||
assert_eq!(a.ui.animations, AnimationPref::System, "the default");
|
||||
for (system, time) in [(Some(false), 0.0), (Some(true), animated), (None, animated), (Some(false), 0.0)] {
|
||||
a.system_animations = system;
|
||||
frame(&mut a, &ctx, Vec::new(), |_| ());
|
||||
assert_eq!(ctx.global_style().animation_time, time, "{system:?}");
|
||||
assert_eq!(a.shows_animations(), time > 0.0, "{system:?}");
|
||||
}
|
||||
assert_eq!(a.ui.animations, AnimationPref::System, "following the OS never changes the saved choice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_scrolling_glides_only_while_animation_effects_show() {
|
||||
let line = egui::InputOptions::default().line_scroll_speed;
|
||||
let notches = || {
|
||||
vec![egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: egui::vec2(0.0, -3.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
}]
|
||||
};
|
||||
let scroll = |ctx: &egui::Context| ctx.input(|i| i.smooth_scroll_delta.y);
|
||||
|
||||
let (mut a, ctx) = (app(), egui::Context::default());
|
||||
let (first, _) = frame(&mut a, &ctx, notches(), scroll);
|
||||
assert!(first < 0.0 && first > -3.0 * line, "egui spreads the notches over several frames: {first}");
|
||||
|
||||
let (mut a, ctx) = (app(), egui::Context::default());
|
||||
a.system_animations = Some(false);
|
||||
assert_eq!(frame(&mut a, &ctx, notches(), scroll).0, -3.0 * line, "all of it in the frame it arrives");
|
||||
assert_eq!(frame(&mut a, &ctx, Vec::new(), scroll).0, 0.0, "and nothing afterwards");
|
||||
}
|
||||
|
||||
/// How far the drawing zooms in, frame by frame, when the wheel turns one notch over the drawing
|
||||
/// area with `modifiers` held: the view height before over the height after each of 40 frames.
|
||||
fn wheel_zoom(app: &mut CadApp, modifiers: egui::Modifiers) -> Vec<f64> {
|
||||
let ctx = egui::Context::default();
|
||||
let height = |app: &CadApp| app.session.state().map(|s| s.view().height).unwrap();
|
||||
frame(app, &ctx, Vec::new(), |_| ());
|
||||
let over = app.canvas.rect.unwrap().center();
|
||||
// A few frames: layout settles and the pointer hover is known from the second frame on.
|
||||
for _ in 0..3 {
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(over)], |_| ());
|
||||
}
|
||||
let before = height(app);
|
||||
let notch = egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, 1.0), phase: egui::TouchPhase::Move, modifiers };
|
||||
let mut events = vec![notch];
|
||||
(0..40)
|
||||
.map(|_| {
|
||||
frame(app, &ctx, std::mem::take(&mut events), |_| ());
|
||||
before / height(app)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zooming_the_drawing_with_the_wheel_never_glides_with_animation_effects_off() {
|
||||
let o = egui::InputOptions::default();
|
||||
for system in [Some(true), Some(false)] {
|
||||
// The wheel alone zooms a whole notch in the frame it arrives, animation effects or not.
|
||||
let mut a = app();
|
||||
a.system_animations = system;
|
||||
let notch = a.session.wheel_zoom_factor(f64::from(o.line_scroll_speed));
|
||||
let zoom = wheel_zoom(&mut a, egui::Modifiers::NONE);
|
||||
assert!(notch > 1.0 && zoom.iter().all(|z| (z - notch).abs() < 1e-9), "{system:?}: {zoom:?}");
|
||||
}
|
||||
// ⌘/Ctrl+wheel (a touchpad pinch, on Windows) is egui's zoom gesture, which glides there…
|
||||
let whole = f64::from((o.scroll_zoom_speed * o.line_scroll_speed).exp());
|
||||
let mut a = app();
|
||||
let glide = wheel_zoom(&mut a, egui::Modifiers::COMMAND);
|
||||
assert!(glide[0] > 1.0 && glide[0] < glide[1] && glide[1] < whole, "{glide:?}");
|
||||
assert!((glide[39] - whole).abs() < 1e-3, "{glide:?}");
|
||||
// …and with animation effects off gets to the same zoom at once.
|
||||
a.start("ui.animations.off");
|
||||
let zoom = wheel_zoom(&mut a, egui::Modifiers::COMMAND);
|
||||
assert!(zoom.iter().all(|z| (z - whole).abs() < 1e-5), "{zoom:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_offers_the_three_choices_and_they_reach_the_setting() {
|
||||
let mut a = app();
|
||||
let view = menus::tree(&a).into_iter().find(|(m, _)| m == "View").map(|(_, e)| e).unwrap();
|
||||
let sub = |entries: &[menus::Entry], name: &str| {
|
||||
entries.iter().find_map(|e| match e {
|
||||
menus::Entry::Sub { label, children } if label == name => Some(children.clone()),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let choices = sub(&sub(&view, "Accessibility").unwrap(), "Animation Effects").unwrap();
|
||||
let items: Vec<(String, String)> = choices
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
menus::Entry::Item { label, id, .. } => Some((label.clone(), id.clone())),
|
||||
menus::Entry::Sub { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
let expected = [("Use System Setting", "ui.animations.system"), ("On", "ui.animations.on"), ("Off", "ui.animations.off")];
|
||||
assert_eq!(items, expected.map(|(label, id)| (label.to_string(), id.to_string())));
|
||||
|
||||
a.system_animations = Some(false);
|
||||
for (id, pref, shown) in [
|
||||
("ui.animations.on", AnimationPref::On, true),
|
||||
("ui.animations.off", AnimationPref::Off, false),
|
||||
("ui.animations.system", AnimationPref::System, false),
|
||||
] {
|
||||
menus::activate(&mut a, id);
|
||||
assert_eq!((a.ui.animations, a.shows_animations()), (pref, shown), "{id}");
|
||||
}
|
||||
// The JSON form sets and reports it; a bad choice changes nothing.
|
||||
assert_eq!(a.run("ui.animations", json!({ "animations": "on" })).ok(), Some(json!({ "animations": "on", "shown": true })));
|
||||
assert_eq!(a.run("ui.animations", json!({})).ok(), Some(json!({ "animations": "on", "shown": true })), "no choice: report it");
|
||||
assert_eq!(a.run("ui.animations", json!({ "animations": "system" })).ok(), Some(json!({ "animations": "system", "shown": false })));
|
||||
assert!(a.run("ui.animations", json!({ "animations": "wobbly" })).is_err());
|
||||
assert_eq!(a.ui.animations, AnimationPref::System);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_choice_is_a_saved_preference() {
|
||||
let mut a = app();
|
||||
a.start("ui.animations.off");
|
||||
// Saved and restored by the host; bad values are ignored.
|
||||
let mut b = app();
|
||||
b.load_prefs(&a.prefs_json());
|
||||
assert_eq!(b.ui.animations, AnimationPref::Off);
|
||||
b.load_prefs(r#"{"animations": "wobbly"}"#);
|
||||
b.load_prefs(r#"{"animations": 7}"#);
|
||||
assert_eq!(b.ui.animations, AnimationPref::Off);
|
||||
// Resetting the palettes keeps it, as it keeps the theme.
|
||||
b.start("ui.resetpalettes");
|
||||
assert_eq!(b.ui.animations, AnimationPref::Off);
|
||||
// OPTIONS reports and sets it, all or nothing, and its reset goes back to following the OS.
|
||||
assert_eq!(b.run("options", json!({})).unwrap()["animations"], "off");
|
||||
assert!(b.run("options", json!({ "animations": "on", "PICKBOX": "big" })).is_err());
|
||||
assert!(b.run("options", json!({ "animations": true })).is_err());
|
||||
assert_eq!(b.ui.animations, AnimationPref::Off);
|
||||
assert_eq!(b.run("options", json!({ "animations": "on" })).unwrap()["animations"], "on");
|
||||
assert_eq!(b.run("options", json!({ "reset": true })).unwrap()["animations"], "system");
|
||||
// `ui.set` (the UiState JSON) reaches it too.
|
||||
let ui: UiState = serde_json::from_value(json!({ "animations": "off" })).unwrap();
|
||||
assert_eq!(ui.animations, AnimationPref::Off);
|
||||
assert!(serde_json::from_value::<UiState>(json!({ "animations": "wobbly" })).is_err());
|
||||
}
|
||||
@@ -40,7 +40,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit
|
||||
| `ui.click` / `ui.move` | `{x, y, button?, shift?, ctrl?}` | Real egui pointer events in screen points (reach every widget). |
|
||||
| `ui.drag` | `{x, y, to: [x, y], button?}` | Real press, move and release (drag the command line's top edge, the ViewCube ring…). |
|
||||
| `ui.key` / `ui.text` | `{key, cmd?, shift?, alt?}` / `{text}` | Synthetic keyboard input. |
|
||||
| `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). `saveFormat: "dxf"\|"dwg"` (also `ui.saveformat {format}`) is the format Save As suggests for new drawings; `saveVersion: "2000"`…`"2018"` (also `ui.saveversion {version}`) the version it saves them in (SAVEAS also takes `version` and `binary` directly). |
|
||||
| `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). `animations: "system"\|"on"\|"off"` (also `ui.animations {animations}`) shows or hides animation effects (dialog and menu fades, smoothed scrolling), `system` following the operating system's setting; `ui.inspect` reports whether they show (`animations`: true/false). `saveFormat: "dxf"\|"dwg"` (also `ui.saveformat {format}`) is the format Save As suggests for new drawings; `saveVersion: "2000"`…`"2018"` (also `ui.saveversion {version}`) the version it saves them in (SAVEAS also takes `version` and `binary` directly). |
|
||||
| `ui.resize` | `{width, height}` | Resize the window. |
|
||||
| `ui.focus` | | Activate the native window. Does not assign focus to a widget. |
|
||||
| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Hatch and Gradient / Hatch Edit dialogs) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (animation effects follow the operating system's setting) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)
|
||||
|
||||
How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic
|
||||
input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD
|
||||
@@ -168,7 +168,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.
|
||||
| Options / Preferences dialog | many tabs | **no** (issue #23) |
|
||||
| Customisation (CUI, aliases editor, tool palettes) | yes | no |
|
||||
| Themes | Dark, Light | Dark, Light, System (#179) |
|
||||
| Accessibility | VoiceOver basics | AccessKit through eframe; screen-reader coverage untested; magnifier issue #39 |
|
||||
| Accessibility | VoiceOver basics | AccessKit through eframe; screen-reader coverage untested; magnifier issue #39; animation effects follow the OS setting (View ▸ Accessibility ▸ Animation Effects: System, On, Off) |
|
||||
|
||||
## Navigation (≈ 55%)
|
||||
|
||||
@@ -202,6 +202,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Animation effects (dialog and menu fades, smoothed wheel scrolling and ⌘/Ctrl+wheel zoom) follow the operating system's setting; View ▸ Accessibility ▸ Animation Effects chooses System, On or Off |
|
||||
| 2026-10-12 | minor | Hatch and Gradient / Hatch Edit dialogs (HATCH, GRADIENT, HATCHEDIT, double-click a hatch; HPDLGMODE) |
|
||||
| 2026-10-12 | minor | Block references now show their stored insertion X/Y/Z in the Properties Inspector, including nonzero Z; fields are read-only |
|
||||
| 2026-10-11 | minor | Isometric drafting (#556): isometric snap and grid, ISODRAFT, ISOPLANE (F5), ortho along isoplane axes, ELLIPSE Isocircle |
|
||||
|
||||
Reference in new issue
Block a user