fix: feet-inch dynamic input, STRETCH preselection, ribbon title wrap

- Dynamic-input fields take ' and " and read 2'9" / 3' as lengths (#937)
- STRETCH on a selection made by a crossing window stretches through that
  window and goes straight to the base point (#1068)
- STRETCH's crossing-window steps show no coordinate boxes (#657)
- Collapsed ribbon panel titles wrap at words, never mid-word (#920)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-06 16:15:35 +03:00
1 parent 178c7dff41
commit 1cbcc8f4c5
9 files changed
+67 -5

No files matched your search

+23 -1
View File
@@ -976,7 +976,29 @@ impl OpenCADStudio {
} else {
self.tabs[i].scene.wire_models_for(&handles)
};
let new_cmd = StretchCommand::new(handles, wires);
// Selected by a crossing window just before: stretch through
// that window, no second one asked for (#1068). Every picked
// object must touch it, or the selection was changed since.
let window = self.tabs[i].last_crossing_window.and_then(|(lo, hi)| {
let touches = |wire: &crate::scene::WireModel| {
let [x0, y0, x1, y1] = wire.aabb.map(f64::from);
x0 <= hi[0] && x1 >= lo[0] && y0 <= hi[1] && y1 >= lo[1]
};
(!handles.is_empty()
&& handles.iter().all(|h| {
self.tabs[i].scene.wire_models_for(&[*h]).iter().any(touches)
}))
.then(|| {
(
glam::DVec3::new(lo[0], lo[1], f64::NEG_INFINITY),
glam::DVec3::new(hi[0], hi[1], f64::INFINITY),
)
})
});
let new_cmd = match window {
Some(window) => StretchCommand::preselected(handles, wires, window),
None => StretchCommand::new(handles, wires),
};
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
+4
View File
@@ -268,6 +268,9 @@ pub(super) struct DocumentTab {
/// `suspended_cmd` was parked by a transparent command (`'ZOOM`) and is
/// restored as soon as the transparent one ends.
pub(super) transparent_resume: bool,
/// Model-space corners of the last crossing window drawn with no command
/// running: STRETCH on that selection stretches through it (#1068).
pub(super) last_crossing_window: Option<([f64; 2], [f64; 2])>,
}
impl DocumentTab {
@@ -669,6 +672,7 @@ impl DocumentTab {
plugin_state: HashMap::new(),
suspended_cmd: None,
transparent_resume: false,
last_crossing_window: None,
}
}
+4
View File
@@ -381,6 +381,10 @@ impl Parser {
/// Evaluate a string as an f64 expression.
/// Returns None on parse error.
pub fn eval_number(input: &str) -> Option<f64> {
// Feet and inches (`2'9"`, `3'`, `9 1/2"`) are lengths, not arithmetic (#937).
if input.contains(['\'', '"']) {
return crate::entities::common::parse_typed_length(input);
}
let tokens = tokenize(input.trim()).ok()?;
let mut parser = Parser::new(tokens);
let result = parser.parse_expr(0).ok()?;
+1 -1
View File
@@ -269,7 +269,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
let dyn_field_char = !s.is_empty()
&& s.chars().all(|c| {
c.is_ascii_digit()
|| matches!(c, '.' | '-' | '+' | '*' | '/' | '^' | '%' | '(' | ')')
|| matches!(c, '.' | '-' | '+' | '*' | '/' | '^' | '%' | '(' | ')' | '\'' | '"')
});
if dyn_field_char
&& self.command_line.input.is_empty()
+3 -1
View File
@@ -191,7 +191,9 @@ impl OpenCADStudio {
let picks_object = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_entity_pick() || c.needs_structure_point_pick())
.map(|c| {
c.needs_entity_pick() || c.needs_structure_point_pick() || c.selects_by_window()
})
.unwrap_or(false);
let has_base = self.last_point.is_some();
// While aligned to an OTRACK ray, the point step reads a single
+3
View File
@@ -4728,6 +4728,9 @@ impl OpenCADStudio {
};
if let Some((fence, window)) = drag_geometry {
if self.tabs[i].active_cmd.is_none() {
self.tabs[i].last_crossing_window = window.filter(|_| box_crossing);
}
let result = self.tabs[i].active_cmd.as_mut().and_then(|command| {
command.set_shift(self.shift_down);
command.on_drag_selection(&fence, window)
+6
View File
@@ -2908,6 +2908,12 @@ pub trait CadCommand: Send {
false
}
/// The step draws a window that selects objects rather than placing
/// geometry: no coordinate boxes at the cursor. Default `false`.
fn selects_by_window(&self) -> bool {
false
}
/// The already-picked first corner of the selection window (world space)
/// while `window_corner_pick()` is true and the opposite corner is being
/// dragged. The host projects it and draws a filled selection marquee to the
+21
View File
@@ -67,6 +67,21 @@ impl StretchCommand {
}
}
/// A selection made by a crossing window before the command: that window
/// marks what moves, so the command goes straight to the base point.
pub fn preselected(
handles: Vec<Handle>,
wire_models: Vec<WireModel>,
window: (DVec3, DVec3),
) -> Self {
Self {
handles,
wire_models,
windows: vec![window],
step: Step::Base,
}
}
/// Continue gathering crossing windows after the host has resolved the
/// entities touched by the latest window.
pub fn with_windows(
@@ -223,6 +238,12 @@ impl CadCommand for StretchCommand {
matches!(self.step, Step::WindowCorner1 | Step::WindowCorner2(_))
}
fn selects_by_window(&self) -> bool {
// Crossing windows select what stretches; dynamic input has nothing
// to enter there (#657).
self.window_corner_pick()
}
fn window_first_corner(&self) -> Option<DVec3> {
// Expose the first corner so the host draws a filled crossing marquee to
// the cursor, matching a normal box selection instead of a bare outline.
+2 -2
View File
@@ -1569,7 +1569,7 @@ fn collapse_button<'a>(
.size(9)
.width(Fill)
.align_x(iced::Center)
.wrapping(iced::advanced::text::Wrapping::WordOrGlyph)
.wrapping(iced::advanced::text::Wrapping::Word)
.style(muted_text_style),
crate::ui::icons::themed_secondary_arrow_down(8.0),
]
@@ -1640,7 +1640,7 @@ fn collapse_button<'a>(
.size(9)
.width(Fill)
.align_x(iced::Center)
.wrapping(iced::advanced::text::Wrapping::WordOrGlyph)
.wrapping(iced::advanced::text::Wrapping::Word)
.style(muted_text_style),
crate::ui::icons::themed_secondary_arrow_down(8.0),
]