Files
OneCAD-Draft/src/app/options_session.rs
T
PanosandClaude Opus 5 9fe2e4fa58 fix(view): hold ZOOMFACTOR to its own range, finish the exponential zoom
The exponential camera step is the right call. `0.9 ^ delta` is strictly
positive for every real delta, in and out cancel exactly where the
subtraction lost a percent of the view on every round trip, and the file's
own header has promised an "exponential feel" since it was written. Three
surfaces were left behind it, and the wider range exposed a fourth thing.

The range of the system variable. 3 to 100 is the range ZOOMFACTOR has, and
the User Preferences page was built against it; `SETVAR ZOOMFACTOR` accepts
that and nothing else again. A faster wheel is the Options window's business
instead: the slider covers the variable's own range, the field beside it
reaches 500, and a read-out says what a notch actually does ("10.0% a
notch") so the number being set is readable rather than implied. A value
above 100 therefore lives only in this application's own configuration file,
where it is nobody else's to read, and a bare `SETVAR ZOOMFACTOR` still
reports it. The bounds and the clamp live in one place now
(`settings::clamp_zoom_factor`): four literal copies of `3, 100` are what
let a surface be missed in the first place.

A floating viewport in MSPACE. It zooms its own view through
`zoom_active_viewport`, which kept subtracting: the factor reached zero at
6.67 steps, so from ZOOMFACTOR 360 up every notch inside a viewport bottomed
out on the clamp — a tenfold jump a notch, and the top third of the slider
doing nothing at all. Exponential there too, from a base of 0.85 so the
default notch still moves a viewport the 15% it always has. Each surface
keeps its own long-standing feel rather than inheriting the other's.

The trackpad pinch. `pinch_zoom_steps` was calibrated against the
subtraction (`1 - step / 10 = 1 / (1 + m)`) so that a pinch of m leaves the
view exactly 1 + m times closer — the property docs/trackpad.md documents
and the reason the gesture tracks the fingers at all. Inverting the zoom law
by hand is what let the two drift apart, so the step is asked of the camera
now (`Camera::zoom_steps_for_ratio`), which cannot drift by construction.
`pinch_is_one_to_one` recomputed the old formula rather than the camera's,
so it kept passing through all of this; it zooms a real camera and measures
the distance it lands at instead.

Also: `zoom` had a floor and no ceiling, and zooming out multiplies. At
ZOOMFACTOR 500 a notch out is x2.4, so about ninety notches — a couple of
seconds of scrolling — ran the distance past what an f32 far plane
(`distance * 1000`) can hold, and the viewport went black on an infinite
projection. A degenerate pinch magnification of -1 or beyond reached the
same place through the other side. Both are bounded now.

Also: two doc comments quoted the linear law (`zoom_view_at_cursor` and the
one on `pinch_zoom_steps`), and docs/trackpad.md stated the pinch identity
and claimed the tests covered it against the camera. All updated, with what
a notch does and where each surface's range comes from written down.

Tests, each confirmed against the behaviour it describes: the zoom law at
every factor the application can hold (positive, monotonic in the setting,
in and out cancelling), the ceiling under a long scroll out in both
projections, the published inverse and what it does with a nonsense ratio,
the viewport factor and a real viewport zoomed three notches at the top of
the range, the SETVAR range including a value the Options field set, the
Options field (above 100, above the ceiling, cleared, not a number), its
agreement with the slider, Close putting both the value and the field back,
and the read-out's percentages. 1882 lib tests pass.

The two new Options strings are not in the locale catalog yet, so they
render in English until it is regenerated.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 10:51:26 +03:00

283 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The Options window's OK / Apply / Close. Settings still change the moment
//! a control is touched, so a theme, a cursor size or a language is seen at
//! once — but nothing is committed until Apply or OK. Close (and the window's
//! × or Esc) puts everything back the way it was when the window opened, or
//! when Apply was last pressed, asking first when something would be lost.
//!
//! The commit point is a snapshot of the persisted configuration plus the
//! few drawing variables the window edits (the 3D Modeling page writes into
//! the active drawing's header); "dirty" is simply "the current snapshot
//! differs from the saved one", so every control the window gains later is
//! covered without registering it here.
use super::OpenCADStudio;
/// The drawing-header variables the Options window edits, for the active
/// tab, with the tab's modified flag so a revert leaves it as it found it.
#[derive(Clone, PartialEq)]
struct HeaderVars {
isolines: i16,
display_silhouette: bool,
surface_u_density: i16,
surface_v_density: i16,
surface_type: i16,
record_solid_history: bool,
show_solid_history: i16,
tab_dirty: bool,
}
/// What the Options window can change, as it stood at the last commit.
#[derive(Clone, PartialEq)]
pub(super) struct OptionsSnapshot {
config: super::config::AppConfig,
header: Option<HeaderVars>,
textfill: bool,
}
impl OpenCADStudio {
fn options_snapshot(&self) -> OptionsSnapshot {
let header = self.tabs.get(self.active_tab).map(|tab| {
let h = &tab.scene.document.header;
HeaderVars {
isolines: h.isolines,
display_silhouette: h.display_silhouette,
surface_u_density: h.surface_u_density,
surface_v_density: h.surface_v_density,
surface_type: h.surface_type,
record_solid_history: h.record_solid_history,
show_solid_history: h.show_solid_history,
tab_dirty: tab.dirty,
}
});
OptionsSnapshot {
config: self.current_config(),
header,
textfill: crate::scene::text::sdf_atlas::textfill(),
}
}
/// The window opens: remember the state to come back to.
pub(super) fn options_open(&mut self) {
self.active_modal = Some(super::ModalKind::Options);
self.options_saved = Some(self.options_snapshot());
self.options_close_confirm = false;
}
/// Whether anything changed since the window opened or Apply was pressed.
pub(super) fn options_dirty(&self) -> bool {
match &self.options_saved {
Some(saved) => *saved != self.options_snapshot(),
None => false,
}
}
/// Apply: the current state becomes the state to come back to, and is
/// written to disk.
pub(super) fn options_apply(&mut self) {
self.options_saved = Some(self.options_snapshot());
self.options_close_confirm = false;
self.save_config();
}
/// Close: put everything back to the last commit. The caller closes the
/// window afterwards.
pub(super) fn options_discard(&mut self) {
self.options_close_confirm = false;
if let Some(saved) = self.options_saved.take() {
self.options_revert_to(saved);
}
}
/// Forget the commit point without reverting (OK, or a close that had
/// nothing to lose).
pub(super) fn options_forget(&mut self) {
self.options_saved = None;
self.options_close_confirm = false;
}
fn options_revert_to(&mut self, saved: OptionsSnapshot) {
let current = self.options_snapshot();
if current == saved {
return;
}
if current.config != saved.config {
// `apply_config` distributes every persisted preference, the
// theme and the language the way start-up does.
self.apply_config(saved.config.clone());
}
if current.textfill != saved.textfill {
// The glyph atlas was re-baked for the other setting.
self.invalidate_text_everywhere();
}
if let (Some(now), Some(then)) = (&current.header, &saved.header) {
if now != then {
let i = self.active_tab;
if let Some(tab) = self.tabs.get_mut(i) {
let h = &mut tab.scene.document.header;
h.isolines = then.isolines;
h.display_silhouette = then.display_silhouette;
h.surface_u_density = then.surface_u_density;
h.surface_v_density = then.surface_v_density;
h.surface_type = then.surface_type;
h.record_solid_history = then.record_solid_history;
h.show_solid_history = then.show_solid_history;
tab.dirty = then.tab_dirty;
if now.show_solid_history != then.show_solid_history
|| now.display_silhouette != then.display_silhouette
{
tab.scene.bump_geometry();
}
}
if now.isolines != then.isolines {
self.isolines_awaiting_regen = false;
self.regenerate_meshes();
}
}
}
self.save_config();
}
}
#[cfg(test)]
mod tests {
use crate::app::{settings, Message, OpenCADStudio};
fn app() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app
}
#[test]
fn changes_show_at_once_but_close_puts_them_back() {
let mut app = app();
let _ = app.update(Message::OptionsOpen);
assert!(!app.options_dirty());
let before = app.zoom_factor;
let _ = app.update(Message::ZoomFactorChanged(before + 7));
assert_eq!(app.zoom_factor, before + 7, "the change is live");
assert!(app.options_dirty());
// The first Close asks; the discard puts the value back.
let _ = app.update(Message::OptionsClose);
assert!(app.options_close_confirm);
assert_eq!(app.active_modal, Some(crate::app::ModalKind::Options));
let _ = app.update(Message::OptionsCloseDiscard);
assert_eq!(app.zoom_factor, before);
assert_eq!(app.active_modal, None);
assert!(!app.options_dirty());
}
#[test]
fn apply_makes_the_change_the_new_baseline() {
let mut app = app();
let _ = app.update(Message::OptionsOpen);
let before = app.zoom_factor;
let _ = app.update(Message::ZoomFactorChanged(before + 7));
let _ = app.update(Message::OptionsApply);
assert!(!app.options_dirty(), "Apply commits");
let _ = app.update(Message::ZoomFactorChanged(before + 9));
let _ = app.update(Message::OptionsClose);
let _ = app.update(Message::OptionsCloseDiscard);
assert_eq!(app.zoom_factor, before + 7, "back to the applied value, not the original");
}
#[test]
fn ok_commits_and_closes_and_a_clean_close_asks_nothing() {
let mut app = app();
let _ = app.update(Message::OptionsOpen);
let before = app.zoom_factor;
let _ = app.update(Message::ZoomFactorChanged(before + 7));
let _ = app.update(Message::OptionsOk);
assert_eq!(app.active_modal, None);
assert_eq!(app.zoom_factor, before + 7);
let _ = app.update(Message::OptionsOpen);
let _ = app.update(Message::OptionsClose);
assert_eq!(app.active_modal, None, "nothing to lose, nothing to ask");
assert!(!app.options_close_confirm);
// Keep editing returns to the window with the change intact.
let _ = app.update(Message::OptionsOpen);
let _ = app.update(Message::ZoomFactorChanged(before + 11));
let _ = app.update(Message::CloseModal);
assert!(app.options_close_confirm, "the × asks like Close does");
let _ = app.update(Message::OptionsCloseKeep);
assert_eq!(app.active_modal, Some(crate::app::ModalKind::Options));
assert_eq!(app.zoom_factor, before + 11);
}
#[test]
fn drawing_variables_of_the_modeling_page_are_reverted_too() {
let mut app = app();
let i = app.active_tab;
let isolines = app.tabs[i].scene.document.header.isolines;
let silhouette = app.tabs[i].scene.document.header.display_silhouette;
let _ = app.update(Message::OptionsOpen);
let _ = app.update(Message::IsolinesChanged(isolines + 4));
let _ = app.update(Message::DispSilhChanged(!silhouette));
assert!(app.options_dirty());
assert!(app.tabs[i].dirty);
let _ = app.update(Message::OptionsClose);
let _ = app.update(Message::OptionsCloseDiscard);
let h = &app.tabs[i].scene.document.header;
assert_eq!(h.isolines, isolines);
assert_eq!(h.display_silhouette, silhouette);
assert!(!app.tabs[i].dirty, "the drawing is as unmodified as it was");
}
/// The Options window is the surface that reaches past the range the
/// ZOOMFACTOR system variable has, and its field is what reaches: a
/// number above the variable's own ceiling is kept, one above what the
/// application can store is held there, and text that is not yet a
/// number leaves the setting alone so the field can be cleared and
/// retyped.
#[test]
fn the_zoom_factor_field_reaches_past_the_system_variable() {
let mut app = app();
let _ = app.update(Message::ZoomFactorInputChanged("250".into()));
assert_eq!(app.zoom_factor, 250);
let _ = app.update(Message::ZoomFactorInputChanged("600".into()));
assert_eq!(app.zoom_factor, settings::ZOOM_FACTOR_MAX);
let _ = app.update(Message::ZoomFactorInputChanged(String::new()));
assert_eq!(
app.zoom_factor,
settings::ZOOM_FACTOR_MAX,
"a cleared field keeps the value it set"
);
assert_eq!(app.zoom_factor_input, "", "while the field stays as typed");
let _ = app.update(Message::ZoomFactorInputChanged("nonsense".into()));
assert_eq!(app.zoom_factor, settings::ZOOM_FACTOR_MAX);
let _ = app.update(Message::ZoomFactorInputChanged("1".into()));
assert_eq!(app.zoom_factor, settings::ZOOM_FACTOR_MIN, "the floor holds");
}
/// The slider and the field are two views of one setting: dragging the
/// slider has to leave the number beside it reading what it just set, or
/// the window shows two different answers for what the wheel does.
#[test]
fn the_zoom_factor_slider_and_field_stay_in_step() {
let mut app = app();
let _ = app.update(Message::ZoomFactorInputChanged("250".into()));
let _ = app.update(Message::ZoomFactorChanged(40));
assert_eq!(app.zoom_factor, 40);
assert_eq!(app.zoom_factor_input, "40");
}
/// Close puts a zoom factor back like every other preference — the
/// field's text included, since it is edited in front of the value.
#[test]
fn closing_puts_the_zoom_factor_and_its_field_back() {
let mut app = app();
let _ = app.update(Message::OptionsOpen);
let before = app.zoom_factor;
let _ = app.update(Message::ZoomFactorInputChanged("250".into()));
assert!(app.options_dirty());
let _ = app.update(Message::OptionsClose);
let _ = app.update(Message::OptionsCloseDiscard);
assert_eq!(app.zoom_factor, before);
assert_eq!(app.zoom_factor_input, before.to_string());
}
}