Merge main into Select-Color-Redesign

Keep both sides: main's ViewBackground colour target and PREVIEW icon
next to this branch's Interface target and LIST/SEARCH icons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8
This commit is contained in:
Claude committed 2026-10-14 19:10:33 +00:00
commit 451b0f5d78
231 files changed
+32217 -3423

No files matched your search

+331 -14
View File
@@ -15,6 +15,46 @@ use iced::widget::{
use iced::window;
use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme};
use iced_aw::ContextMenu;
/// Invisible event surface for accurate point picking. MouseArea emits a
/// positionless press message; capturing the cursor in the actual button
/// event avoids using the previous hover location when clicking quickly.
#[derive(Debug, Clone, Copy)]
struct ViewportPickSurface;
impl iced::widget::canvas::Program<Message> for ViewportPickSurface {
type State = ();
fn update(
&self,
_state: &mut Self::State,
event: &iced::Event,
bounds: iced::Rectangle,
cursor: iced::mouse::Cursor,
) -> Option<iced::widget::canvas::Action<Message>> {
if let iced::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)) =
event
{
return cursor.position_in(bounds).map(|position| {
iced::widget::canvas::Action::publish(Message::ViewportLeftPressAt(position))
.and_capture()
});
}
None
}
fn draw(
&self,
_state: &Self::State,
_renderer: &iced::Renderer,
_theme: &iced::Theme,
_bounds: iced::Rectangle,
_cursor: iced::mouse::Cursor,
) -> Vec<iced::widget::canvas::Geometry> {
Vec::new()
}
}
use std::sync::Arc;
mod controls;
@@ -182,6 +222,24 @@ impl std::fmt::Display for RenderModeChoice {
}
}
/// A named style (Realistic, …) the active viewport draws as, by name.
fn named_visual_style(
scene: &crate::scene::Scene,
mode: codec::entities::ViewportRenderMode,
) -> Option<String> {
match scene.document.objects.get(&scene.active_visual_style()) {
Some(codec::objects::ObjectType::VisualStyle(style)) => {
match crate::modules::view::visual_style::mode_for_style_name(&style.description) {
Some((named_mode, true)) if named_mode == mode => {
Some(crate::t!(style.description.as_str()).into_owned())
}
_ => None,
}
}
_ => None,
}
}
impl OpenCADStudio {
#[cfg(not(target_arch = "wasm32"))]
pub fn view(&self, window_id: window::Id) -> Element<'_, Message> {
@@ -321,6 +379,7 @@ impl OpenCADStudio {
&self.recent_thumbs,
self.recent_limit,
&self.recent_limit_input,
self.recent_view,
self.start_action_w.clone(),
self.start_section,
)
@@ -857,10 +916,16 @@ bg={bg_ms:.1}ms n={view_count}"
.hover_highlight
.map(|h| tab.scene.is_layer_locked(h))
.unwrap_or(false);
let point_cursor = tab
.active_cmd
.as_ref()
.is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering());
// Keep the hollow pickbox at rest. Use the standard LINE/RECTANG
// dot crosshair only after the first window/crossing corner.
// No OSNAP or selection behavior changes.
let selecting_window = tab.active_cmd.is_none()
&& tab.scene.selection.borrow().gesture.box_anchor.is_some();
let point_cursor = selecting_window
|| tab
.active_cmd
.as_ref()
.is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering());
let constraint_cursor_badge = if is_paper {
None
} else {
@@ -939,7 +1004,13 @@ bg={bg_ms:.1}ms n={view_count}"
|| c.is_selection_gathering()
}),
snap_color: self.snap_marker_color,
snap_size: self.model_space.snap_marker_size as f32,
},
// MATCHPROP paints properties onto destinations; it gets the
// paintbrush cursor instead of the crosshair.
tab.active_cmd
.as_ref()
.is_some_and(|c| c.name() == "MATCHPROP"),
crate::ui::overlay::SelectionVisualOptions {
area: self.model_space.selection_area,
opacity: self.model_space.selection_opacity,
@@ -961,9 +1032,15 @@ bg={bg_ms:.1}ms n={view_count}"
mark("viewport_mouse");
let viewport_mouse = mouse_area(container(
iced::widget::Space::new().width(Fill).height(Fill),
iced::widget::canvas(ViewportPickSurface)
.width(Fill)
.height(Fill),
))
.on_move(Message::ViewportMove)
// Left mouse presses are captured by the canvas with their exact
// event-time position. MouseArea::on_press has no coordinate, so
// queued cursor moves could otherwise make quick clicks use a stale
// snap position. Touch presses still use this fallback.
.on_press(Message::ViewportLeftPress)
.on_release(Message::ViewportLeftRelease)
.on_right_press(Message::ViewportRightPress)
@@ -1011,6 +1088,20 @@ bg={bg_ms:.1}ms n={view_count}"
{
let w = tab.last_cursor_world;
let base = tab.dyn_anchor.or(self.last_point);
// A single distance field on an acquired tracking ray is measured
// from the acquired OSNAP/OTRACK point, not from the command's
// previous point (or from WCS zero before the first pick).
// Keep typed fields and ordinary polar distance inputs unchanged.
let tracking_distance = if tab.dyn_fields.len() == 1
&& tab.dyn_fields[0].component == DynComponent::Distance
{
self.active_distance_ray(self.active_tab).and_then(|(origin, direction)| {
let axis = direction.try_normalize()?;
Some((w - origin).dot(axis).abs())
})
} else {
None
};
let label_screen = tab
.active_cmd
.as_ref()
@@ -1160,6 +1251,16 @@ bg={bg_ms:.1}ms n={view_count}"
format!("{lv:.4}")
}
}
// The displayed live distance must use the same
// acquired tracking origin as the guide and typed
// distance placement, not the last picked point.
(None, _) if f.component == DynComponent::Distance
&& tracking_distance.is_some() =>
{
crate::entities::common::format_length(
tracking_distance.unwrap() * f.role.value_scale() as f64,
)
}
_ => dyn_component_value(
f,
w,
@@ -1297,6 +1398,7 @@ bg={bg_ms:.1}ms n={view_count}"
let rect = tab.scene.active_model_tile_bounds(vw, vh);
// Unified control chip: split buttons + render-mode picker +
// grid / grid-snap toggles, for the active Model tile.
let style_name = named_visual_style(&tab.scene, tab.render_mode);
let bar = viewport_controls(
tab.render_mode,
self.show_grid,
@@ -1305,6 +1407,7 @@ bg={bg_ms:.1}ms n={view_count}"
tab.scene.model_tiles.borrow().len(),
self.render_mode_menu_open,
self.render_mode_preview,
style_name,
);
// Adaptive: DensitySwap measures the bar's real width every frame
// (reported into `render_bar_w`, which the ViewCube reads to decide
@@ -1397,6 +1500,7 @@ bg={bg_ms:.1}ms n={view_count}"
0,
self.render_mode_menu_open,
self.render_mode_preview,
named_visual_style(&tab.scene, vp_mode),
);
let adaptive: Element<'_, Message> = DensitySwap::new(vec![
iced::widget::opaque(bar),
@@ -1660,9 +1764,18 @@ bg={bg_ms:.1}ms n={view_count}"
// In-place block edit (REFEDIT): right-edge toolbar with Save / Discard
// so the edit can be finished by clicking. (#136)
if tab.refedit_session.is_some() && !tab.is_start {
if let Some(tb) = crate::ui::side_toolbar::view(
&crate::modules::draw::modify::refedit::refedit_tools(),
if let (Some(session), false) = (tab.refedit_session.as_ref(), tab.is_start) {
use crate::modules::draw::modify::refedit::{refedit_set_tools, refedit_tools};
let set_tools = refedit_set_tools();
let close_tools = refedit_tools();
let header = crate::ui::side_toolbar::refedit_header(
&session.block_name,
refedit_working_set_count(session, &tab.scene),
);
if let Some(tb) = crate::ui::side_toolbar::view_sections(
Some(header),
&[&set_tools, &close_tools],
&|_| false,
) {
viewport_stack = viewport_stack.push(tb);
}
@@ -1999,7 +2112,10 @@ bg={bg_ms:.1}ms n={view_count}"
// Clean-screen mode drops the ribbon for a full-canvas view; the
// status bar stays so the mode can be toggled back off.
let mut col = column![];
if !self.clean_screen {
if !self.clean_screen && self.show_menu_bar {
col = col.push(crate::ui::menu_bar::view());
}
if !self.clean_screen && !self.ribbon_closed {
col = col.push(self.ribbon.view(
is_paper,
self.tabs[self.active_tab].is_start,
@@ -2222,12 +2338,23 @@ bg={bg_ms:.1}ms n={view_count}"
iced::widget::Space::new().width(0).height(0).into()
};
// Key tips: the hint bar under the dropdowns, the codes above all.
let (keytip_bar, keytip_layer): (Element<'_, Message>, Element<'_, Message>) =
self.ribbon.keytip_layers().unwrap_or_else(|| {
(
iced::widget::Space::new().width(0).height(0).into(),
iced::widget::Space::new().width(0).height(0).into(),
)
});
let composed = stack![
main_ui,
keytip_bar,
dropdown_layer,
qselect_layer,
snap_override_layer,
open_progress_layer,
keytip_layer,
];
// If the Plot Style editor was launched from PLOT, keep the Plot dialog
@@ -2440,8 +2567,10 @@ impl OpenCADStudio {
let web_fonts = Subscription::none();
// Periodic autosave (SAVETIME): a `.sv$` recovery copy beside the file
// on native, an OPFS snapshot on web. SAVETIME is the interval in
// minutes; 0 disables it.
let autosave = if self.savetime_min > 0 {
// minutes; 0 disables it. The web build checks the deadline from the
// `PollWebFonts` tick instead: a long `time::every` there stalls every
// other timer for its whole period (#1775).
let autosave = if self.savetime_min > 0 && cfg!(not(target_arch = "wasm32")) {
iced::time::every(std::time::Duration::from_secs(
self.savetime_min as u64 * 60,
))
@@ -2495,9 +2624,18 @@ impl OpenCADStudio {
event::listen_with(|ev, status, win_id| {
use iced::event::Status;
match ev {
iced::Event::Mouse(iced::mouse::Event::ButtonPressed(_))
if crate::ui::ribbon::keytips_active() =>
{
crate::ui::ribbon::keytip_pointer_pressed();
Some(Message::KeyTip(crate::ui::ribbon::KeyTipInput::Hide))
}
iced::Event::Mouse(iced::mouse::Event::ButtonPressed(
iced::mouse::Button::Left,
)) => Some(Message::PropPointerPressed),
)) => {
crate::ui::ribbon::keytip_pointer_pressed();
Some(Message::PropPointerPressed)
}
iced::Event::Window(window::Event::CloseRequested) => {
Some(Message::WindowCloseRequested(win_id))
}
@@ -2510,6 +2648,9 @@ impl OpenCADStudio {
iced::Event::Window(window::Event::FileDropped(path)) => {
Some(Message::FileDropped(path))
}
iced::Event::Keyboard(keyboard::Event::KeyReleased { key, .. }) => {
crate::ui::ribbon::keytip_key_released(&key)
}
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(m)) => {
// `command()` is Cmd on macOS, Ctrl elsewhere — the
// platform multi-select modifier for the layer list.
@@ -2523,8 +2664,14 @@ impl OpenCADStudio {
physical_key,
modifiers,
text,
repeat,
..
}) => {
if let Some(keytip) =
crate::ui::ribbon::keytip_key_pressed(&key, modifiers, repeat)
{
return keytip;
}
#[cfg(target_arch = "wasm32")]
let accel = modifiers.command();
// AltGr reaches Windows apps as Ctrl+Alt, and a macOS
@@ -2670,6 +2817,7 @@ impl OpenCADStudio {
.map(|_| Message::SpaceMouseWake),
crate::input::trackpad::subscription().map(Message::TrackpadPinch),
event::listen_with(|event, _, id| match event {
iced::Event::Touch(touch) => Some(Message::Touch(touch)),
iced::Event::Window(window::Event::Focused) => {
Some(Message::SpaceMouseFocus(id, true))
}
@@ -3052,6 +3200,7 @@ pub(super) fn start_page_view<'a>(
thumbs: &'a std::collections::HashMap<std::path::PathBuf, Option<iced::widget::image::Handle>>,
recent_limit: usize,
recent_limit_input: &'a str,
recent_view: super::RecentView,
action_width_out: std::sync::Arc<std::sync::atomic::AtomicU32>,
active: super::StartSection,
) -> Element<'a, Message> {
@@ -3067,6 +3216,7 @@ pub(super) fn start_page_view<'a>(
thumbs,
recent_limit,
recent_limit_input,
recent_view,
size.width,
action_width_out.clone(),
active,
@@ -3086,6 +3236,7 @@ fn start_page_content<'a>(
thumbs: &'a std::collections::HashMap<std::path::PathBuf, Option<iced::widget::image::Handle>>,
recent_limit: usize,
recent_limit_input: &'a str,
recent_view: super::RecentView,
avail_w: f32,
action_width_out: std::sync::Arc<std::sync::atomic::AtomicU32>,
active: super::StartSection,
@@ -3309,6 +3460,7 @@ fn start_page_content<'a>(
thumbs,
recent_limit,
recent_limit_input,
recent_view,
match start_layout {
StartLayout::AllPanels
| StartLayout::WithoutVideos
@@ -3746,16 +3898,149 @@ fn start_page_content<'a>(
// Properties panel normally occupies, but only when the active tab is the
// Start tab. The list is restored from disk at boot and re-saved on every
// open — entries persist across sessions.
/// Recent files as tiles: small thumbnails wrapping across the panel, or one
/// large preview per row. A tile opens its file; the list view keeps the
/// remove buttons.
fn recent_tiles<'a>(
recents: &'a [std::path::PathBuf],
thumbs: &'a std::collections::HashMap<std::path::PathBuf, Option<iced::widget::image::Handle>>,
large: bool,
) -> iced::widget::Column<'a, Message> {
let (thumb_w, thumb_h, name_chars) = if large {
(Fill, iced::Length::Fixed(180.0), 40)
} else {
(iced::Length::Fixed(72.0), iced::Length::Fixed(54.0), 11)
};
let tile = |path: &'a std::path::PathBuf| -> Element<'a, Message> {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
let picture: Element<'a, Message> = match thumbs.get(path).and_then(|o| o.as_ref()) {
Some(h) => iced::widget::image(h.clone())
.content_fit(iced::ContentFit::Contain)
.width(Fill)
.height(Fill)
.into(),
None => crate::ui::icons::themed_secondary(crate::ui::icons::DOC, 28.0),
};
let picture = container(picture)
.width(thumb_w)
.height(thumb_h)
.center_x(thumb_w)
.center_y(thumb_h)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.base.color)),
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
});
let mut label = column![text(crate::ui::text_util::elide(&name, name_chars)).size(12)];
if large {
let dir = path
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
label = label.push(
text(crate::ui::text_util::elide(&dir, 52))
.size(10)
.style(start_muted_style),
);
}
button(column![picture, label.spacing(2)].spacing(4))
.on_press(Message::OpenRecent(path.clone()))
.padding(6)
.width(if large { Fill } else { iced::Length::Shrink })
.style(|theme: &Theme, status| {
let palette = theme.palette();
button::Style {
background: matches!(status, button::Status::Hovered)
.then_some(Background::Color(palette.background.strong.color)),
text_color: palette.background.base.text,
border: Border {
radius: 4.0.into(),
..Default::default()
},
..Default::default()
}
})
.into()
};
if large {
column(recents.iter().map(tile)).spacing(6)
} else {
column![iced::widget::Row::with_children(recents.iter().map(tile))
.spacing(4)
.wrap()]
.width(Fill)
}
}
pub(super) fn recent_files_panel<'a>(
recents: &'a [std::path::PathBuf],
thumbs: &'a std::collections::HashMap<std::path::PathBuf, Option<iced::widget::image::Handle>>,
limit: usize,
limit_input: &'a str,
view: super::RecentView,
width: iced::Length,
) -> Element<'a, Message> {
use super::RecentView;
// Title mirrors the Supporters rail: size 15 in the bright text colour,
// followed by a 12px gap before the content.
let title = text(crate::tr!("start", "recent-documents")).size(15);
// followed by a 12px gap before the content. The view-mode toggles sit
// on the title's right.
let mode_button = |icon: &'static [u8], mode: RecentView, tip: String| {
let active = view == mode;
iced::widget::tooltip(
button(crate::ui::icons::themed(icon, 13.0))
.on_press(Message::SetRecentView(mode))
.padding([3, 5])
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let background = if active {
Some(palette.primary.weak.color)
} else {
matches!(status, button::Status::Hovered)
.then_some(palette.background.strong.color)
};
button::Style {
background: background.map(Background::Color),
text_color: palette.background.base.text,
border: Border {
radius: 3.0.into(),
..Default::default()
},
..Default::default()
}
}),
container(text(tip).size(11)).padding([2, 6]).style(container::rounded_box),
iced::widget::tooltip::Position::Bottom,
)
};
let title = row![
text(crate::tr!("start", "recent-documents"))
.size(15)
.width(Fill),
mode_button(
crate::ui::icons::MENU,
RecentView::List,
crate::tr!("start", "view-list"),
),
mode_button(
crate::ui::icons::GRID,
RecentView::Icons,
crate::tr!("start", "view-icons"),
),
mode_button(
crate::ui::icons::PREVIEW,
RecentView::Preview,
crate::tr!("start", "view-preview"),
),
]
.spacing(2)
.align_y(iced::Center);
let body: Element<'a, Message> = if recents.is_empty() {
container(
@@ -3765,6 +4050,18 @@ pub(super) fn recent_files_panel<'a>(
)
.height(Fill)
.into()
} else if view != RecentView::List {
iced::widget::scrollable(
recent_tiles(recents, thumbs, view == RecentView::Preview).padding(
iced::Padding {
right: 12.0,
..iced::Padding::ZERO
},
),
)
.width(Fill)
.height(Fill)
.into()
} else {
// Right padding reserves a gutter for the scrollbar so it doesn't sit on
// top of the row's ✕ remove button.
@@ -3974,9 +4271,29 @@ mod tests {
&std::collections::HashMap::new(),
50,
"18446744073709551615",
super::super::RecentView::default(),
800.0,
std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
super::super::StartSection::default(),
);
}
}
/// The objects in a REFEDIT session's working set: the checked-out copies and
/// objects added with REFSET still in it, and everything created in the
/// space since.
// ponytail: walks the document per frame while a session is open; keep a
// running count on the session if large drawings feel it.
fn refedit_working_set_count(
session: &crate::modules::draw::modify::refedit::RefEditSession,
scene: &crate::scene::Scene,
) -> usize {
let space = scene.current_layout_block_handle_pub();
scene
.document
.entities()
.filter(|entity| {
entity.common().owner_handle == space && session.in_working_set(entity.common().handle)
})
.count()
}