diff --git a/assets/locales/en.json b/assets/locales/en.json index 2bb067ed..554d4158 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -1369,6 +1369,8 @@ "ui.reset_the_settings_on_this_page": "Reset the settings on this page", "ui.restore": "Restore", "ui.restore_defaults": "Restore Defaults", + "ui.reverse_horizontal_pan_direction": "Reverse horizontal pan direction", + "ui.reverse_horizontal_pan_tip": "Horizontal two-finger scrolling and the thumb wheel pan the other way", "ui.reverse_zoom_direction": "Reverse zoom direction", "ui.revolve": "Revolve", "ui.right": "Right", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 62a43a25..c680a69a 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -1369,6 +1369,8 @@ "ui.reset_the_settings_on_this_page": "このページの設定をリセット", "ui.restore": "復元", "ui.restore_defaults": "初期値に戻す", + "ui.reverse_horizontal_pan_direction": "水平パンの方向を反転", + "ui.reverse_horizontal_pan_tip": "水平方向の2本指スクロールとサムホイールで、反対方向にパンします", "ui.reverse_zoom_direction": "ズーム方向を反転", "ui.revolve": "回転", "ui.right": "右側面", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 2a19478f..2fc56387 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -1369,6 +1369,8 @@ "ui.reset_the_settings_on_this_page": "重置此页面上的设置", "ui.restore": "还原", "ui.restore_defaults": "恢复默认值", + "ui.reverse_horizontal_pan_direction": "反转水平平移方向", + "ui.reverse_horizontal_pan_tip": "水平双指滚动和拇指滚轮向相反方向平移", "ui.reverse_zoom_direction": "反转缩放方向", "ui.revolve": "旋转", "ui.right": "右视", diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs index 6f443da3..90351a8a 100644 --- a/crates/ui-egui/src/navigation.rs +++ b/crates/ui-egui/src/navigation.rs @@ -307,7 +307,8 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e // (its End was lost); a stuck flag would pan future mouse input. app.viewport.trackpad_gesture = false; } - let pan = if trackpad_pan { raw.pan } else { egui::Vec2::ZERO } + raw.mouse_pan; + let mut pan = if trackpad_pan { raw.pan } else { egui::Vec2::ZERO } + raw.mouse_pan; + pan.x *= crate::prefs::pan_sign_x(app); let scroll = if app.viewport.wheel_zoom_raw_until_idle || (trackpad_pan && app.viewport.trackpad_until_idle) { raw.zoom + if trackpad_pan { 0.0 } else { raw.point_zoom } } else { diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index b5acac54..1eb1691f 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -321,6 +321,8 @@ pub struct Prefs { pub smooth_wheel_zoom: bool, /// Two-finger trackpad scroll pans (off: it zooms like the wheel); pinch always zooms. pub trackpad_pan: bool, + /// Horizontal trackpad and thumb-wheel scrolling pans the view the other way (#2007). + pub pan_reverse_x: bool, /// View changes (axis view, Look At, Home) animate; `look_at_speed` scales how fast. pub look_at_anim: bool, pub look_at_speed: f32, @@ -432,6 +434,7 @@ impl Default for Prefs { zoom_to_cursor: true, smooth_wheel_zoom: true, trackpad_pan: true, + pan_reverse_x: false, look_at_anim: true, look_at_speed: 1.0, up_axis: "z".into(), @@ -626,6 +629,7 @@ fn defaults(app: &mut SolveApp, page: usize) { p.zoom_to_cursor = d.zoom_to_cursor; p.smooth_wheel_zoom = d.smooth_wheel_zoom; p.trackpad_pan = d.trackpad_pan; + p.pan_reverse_x = d.pan_reverse_x; p.orbit_around = d.orbit_around; p.orbit_cursor = d.orbit_cursor; p.space_mouse_enabled = d.space_mouse_enabled; @@ -729,6 +733,11 @@ pub fn nav_mode(app: &SolveApp, button: NavButton, mods: Modifiers) -> Option f32 { + if app.preferences.pan_reverse_x { -1.0 } else { 1.0 } +} + /// +1, or −1 when the wheel zooms the other way. pub fn zoom_sign(app: &SolveApp) -> f64 { if app.preferences.zoom_reverse { -1.0 } else { 1.0 } @@ -1158,6 +1167,10 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { crate::i18n::tr_label("Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms") }); ui.end_row(); + ui.label(crate::i18n::tr_label("Reverse horizontal pan direction")); + ui.checkbox(&mut app.preferences.pan_reverse_x, "") + .on_hover_text(crate::i18n::tr_label("Horizontal two-finger scrolling and the thumb wheel pan the other way")); + ui.end_row(); ui.label(crate::i18n::tr_label("Orbit around")); // Wrapped: three choices, and German or Russian labels are long. ui.horizontal_wrapped(|ui| { @@ -1838,6 +1851,18 @@ mod tests { assert_eq!(other.preferences.nav, OPENSCAD_NAV); } + #[test] + fn pan_reverse_x_defaults_off_persists_and_flips_the_sign() { + assert!(!serde_json::from_value::(json!({})).unwrap().pan_reverse_x); + let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + assert_eq!(pan_sign_x(&app), 1.0); + set(&mut app, &json!({"pan_reverse_x": true})).unwrap(); + assert_eq!(pan_sign_x(&app), -1.0); + let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + other.load_prefs(&app.prefs()); + assert!(other.preferences.pan_reverse_x); + } + #[test] fn trackpad_pan_defaults_on_persists_and_follows_dialog_buttons() { assert!(serde_json::from_value::(json!({})).unwrap().trackpad_pan); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b5ae2391..b94ce328 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -152,7 +152,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text | -| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `show_frame_rate` (default false: frames per second in the view's bottom-right corner; the view redraws every frame while it is on), `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `ui_font` (an installed font family or a font file for the interface; empty for the built-in font), `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file, `stand_up: false` leaves it as it is ("Leave it": the offer closes and `offer_stand_up` stays on); the offer's buttons are the handles `stand_up:stand`, `stand_up:leave` and `stand_up:never` | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | +| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `pan_reverse_x` (default false: horizontal scrolling pans the other way), `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `show_frame_rate` (default false: frames per second in the view's bottom-right corner; the view redraws every frame while it is on), `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `ui_font` (an installed font family or a font file for the interface; empty for the built-in font), `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file, `stand_up: false` leaves it as it is ("Leave it": the offer closes and `offer_stand_up` stays on); the offer's buttons are the handles `stand_up:stand`, `stand_up:leave` and `stand_up:never` | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |