diff --git a/assets/icons/ucs_icon.svg b/assets/icons/ucs_icon.svg index bb892018d..1b607c9e7 100644 --- a/assets/icons/ucs_icon.svg +++ b/assets/icons/ucs_icon.svg @@ -1,8 +1,65 @@ - - - - X - - - Y + + + + + + + + diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 69b172f9a..c9468f776 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -99,6 +99,7 @@ impl OpenCADStudio { self.ucs_icon_selected = false; self.ucs_icon_hover = false; self.tabs[i].pan_mode = false; + self.tabs[i].orbit_mode = false; let _ = self.on_viewport_exit(); } diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index a863a0d75..f904a191e 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -4,8 +4,7 @@ impl OpenCADStudio { pub(super) fn dispatch_inquiry(&mut self, cmd: &str, i: usize) -> Option> { match cmd { "3DORBIT" => { - self.command_line - .push_info(crate::t!("3D Orbit: drag with right mouse button.").as_ref()); + self.tabs[i].orbit_mode = true; } // ── Selection utilities ─────────────────────────────────────── diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 377ae4057..1ce5cf0c6 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -76,9 +76,10 @@ impl OpenCADStudio { // template-property override too (#239). self.restore_add_selected_defaults(); } - // Starting any command leaves interactive PAN mode (the PAN arm below - // re-enables it). + // Starting any command leaves interactive navigation modes (their own + // command arms below re-enable the selected one). self.tabs[i].pan_mode = false; + self.tabs[i].orbit_mode = false; // Reset the last committed point so the first click of the new command // is not constrained by ortho/polar relative to a previous command's endpoint. self.last_point = None; diff --git a/src/app/document.rs b/src/app/document.rs index e4fd7db52..3927bf52e 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -209,6 +209,9 @@ pub(super) struct DocumentTab { /// device with no middle mouse button (a trackpad / web client). Exited /// with Esc or by starting another command. pub(super) pan_mode: bool, + /// Interactive 3-D orbit mode. While active, a left-button drag follows + /// the same camera-orbit path as Shift + middle-button drag. + pub(super) orbit_mode: bool, /// Per-plugin document state (`plugin::BuiltinPlugin` manifest id → state). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(super) plugin_state: HashMap<&'static str, Box>, @@ -479,6 +482,7 @@ impl DocumentTab { thumbnail_cache_key: None, is_start: false, pan_mode: false, + orbit_mode: false, plugin_state: HashMap::new(), suspended_cmd: None, } diff --git a/src/app/mod.rs b/src/app/mod.rs index 5463f3d88..5fc037f3a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1893,8 +1893,12 @@ pub enum Message { LayerStateEditorFilter(String), LayerStateEditorSave, LayerStateEditorCancel, - CursorMoved(Point), - ViewportClick, + /// ViewCube-local cursor movement, tagged with the floating viewport that + /// owned the overlay when the event was produced (`None` = Model layout). + CursorMoved(Point, Option), + /// ViewCube press with the same owner tag, so a stale overlay event can + /// never fall through and rotate a different camera. + ViewportClick(Option), ViewportMove(Point), ViewportLeftPress, ViewportLeftRelease, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 510aaa60c..5ce1823e4 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -569,16 +569,25 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].snap_result = None; return Task::none(); } - // Leave interactive PAN mode (and end any in-flight pan drag). - if self.tabs[self.active_tab].pan_mode { + // Leave an interactive navigation mode and end its in-flight + // drag. Orbit exits silently; PAN keeps its existing message. + if self.tabs[self.active_tab].pan_mode + || self.tabs[self.active_tab].orbit_mode + { let i = self.active_tab; + let was_pan = self.tabs[i].pan_mode; self.tabs[i].pan_mode = false; + self.tabs[i].orbit_mode = false; { let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.middle_down = false; sel.middle_last_pos = None; + sel.orbit_pivot = None; } - self.command_line.push_output(crate::t!("PAN ended.").as_ref()); + if was_pan { + self.command_line.push_output(crate::t!("PAN ended.").as_ref()); + } + self.ribbon.deactivate_tool(); return Task::none(); } // Grip popup intercepts Escape — dismisses the menu diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 88622e52c..96e16e360 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -138,7 +138,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven // dialog owners keep theirs; the command end / modal // close clears those. (#355) let i = self.active_tab; - if self.tabs[i].active_cmd.is_none() && self.active_modal.is_none() { + if self.tabs[i].active_cmd.is_none() + && self.active_modal.is_none() + && !self.tabs[i].pan_mode + && !self.tabs[i].orbit_mode + { self.ribbon.deactivate_tool(); } return task; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index aaf98fbc2..e444ca2d9 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2420,7 +2420,7 @@ impl OpenCADStudio { } // ── Cursor / viewport messages ───────────────────────────────── - Message::CursorMoved(p) => self.on_cursor_moved(p), + Message::CursorMoved(p, viewport) => self.on_cursor_moved(p, viewport), Message::ViewportMove(p) => self.on_viewport_move(p), @@ -2587,7 +2587,7 @@ impl OpenCADStudio { Message::ViewportScroll(delta) => self.on_viewport_scroll(delta), - Message::ViewportClick => self.on_viewport_click(), + Message::ViewportClick(viewport) => self.on_viewport_click(viewport), Message::WindowResized(w, h) => { self.vp_size = ((w - 440.0).max(200.0), h); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 03923bba5..76bb5d758 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -482,6 +482,12 @@ impl OpenCADStudio { if let Some(session) = self.tabs[i].active_block_edit_session_mut() { session.editor_camera = camera; } + } else if self.tabs[i].scene.active_viewport.is_some() { + // Floating-viewport navigation writes its camera straight to + // the viewport entity. Syncing the separate main camera here + // can overwrite the saved Model/Paper view with a camera that + // does not own this change. + self.tabs[i].dirty = true; } else if self.tabs[i].scene.sync_camera_to_document() { self.tabs[i].dirty = true; } @@ -541,7 +547,11 @@ impl OpenCADStudio { Task::none() } - pub(super) fn on_cursor_moved(&mut self, p: Point) -> Task { + pub(super) fn on_cursor_moved( + &mut self, + p: Point, + expected_viewport: Option, + ) -> Task { if self.color_pick_target.is_some() { return Task::none(); } @@ -552,6 +562,12 @@ impl OpenCADStudio { // the full canvas in model space, or of the active // viewport's screen rectangle in a paper layout. let i = self.active_tab; + if self.tabs[i].scene.active_viewport != expected_viewport + || (expected_viewport.is_none() + && self.tabs[i].scene.current_layout != "Model") + { + return Task::none(); + } let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; let (ox, oy) = match self.tabs[i] .scene @@ -716,7 +732,7 @@ impl OpenCADStudio { // — the requested Zoom=wheel / Pan=MMB / Rotate=Shift+MMB // scheme (#229). Floating viewports and paper keep the // plain MMB pan. - if self.shift_down { + if self.shift_down || self.tabs[i].orbit_mode { if self.tabs[i].scene.active_viewport.is_some() { // Orbit the floating viewport's own model view. drop(sel); @@ -746,8 +762,12 @@ impl OpenCADStudio { self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p); return Task::none(); } - // Paper sheet is top-locked: Shift+MMB falls through - // to the plain pan below. + // Paper sheet is top-locked. Shift+MMB keeps its existing + // pan fallback; the explicit orbit tool does nothing here. + if self.tabs[i].orbit_mode { + sel.middle_last_pos = Some(p); + return Task::none(); + } } // Pan scale uses the active tile's size (ortho size // is relative to viewport height), so a tiled pane @@ -2185,10 +2205,9 @@ impl OpenCADStudio { }; let (vw, vh) = vp_size; - // PAN mode: a left press begins a pan drag. Reuse the middle- - // button pan path (the move handler pans whenever `middle_down`), - // so no selection/pick logic runs while panning. - if self.tabs[i].pan_mode { + // Interactive navigation tools reuse the middle-button movement path, + // so no selection/pick logic runs while the left button drives them. + if self.tabs[i].orbit_mode || self.tabs[i].pan_mode { let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.middle_down = true; sel.middle_last_pos = Some(p); @@ -2357,12 +2376,13 @@ impl OpenCADStudio { pub(super) fn on_viewport_left_release(&mut self) -> Task { let i = self.active_tab; - // PAN mode: end the pan drag but stay in pan mode for the next - // drag (exit is Esc / another command). Mirror of the press. - if self.tabs[i].pan_mode { + // Navigation mode: end this drag but keep the tool armed for the next + // left drag (exit is Esc / another command). Mirror of the press. + if self.tabs[i].orbit_mode || self.tabs[i].pan_mode { let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.middle_down = false; sel.middle_last_pos = None; + sel.orbit_pivot = None; return Task::none(); } @@ -3886,8 +3906,17 @@ impl OpenCADStudio { } } - pub(super) fn on_viewport_click(&mut self) -> Task { + pub(super) fn on_viewport_click( + &mut self, + expected_viewport: Option, + ) -> Task { let i = self.active_tab; + if self.tabs[i].scene.active_viewport != expected_viewport + || (expected_viewport.is_none() + && self.tabs[i].scene.current_layout != "Model") + { + return Task::none(); + } let rot = self.tabs[i].scene.active_view_rotation_mat(); let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; // The ViewCube draws in the top-right of whichever area @@ -3929,17 +3958,17 @@ impl OpenCADStudio { } else { scene::CubeRegion::Corner(id) }; - return Task::done(Message::ViewCubeSnap(region)); + return self.on_view_cube_snap(region); } if let Some(region) = scene::hit_test(cx, cy, w, h, rot, VIEWCUBE_PX) { - return Task::done(Message::ViewCubeSnap(region)); + return self.on_view_cube_snap(region); } // Compass cardinals are world-fixed: hit-test through the camera- // only rotation (strip the UCS) so the target matches the drawn // N/E/S/W, and snap in world frame. let rot_world = rot * self.tabs[i].scene.viewcube_ucs_mat().inverse(); if let Some(card) = scene::hit_test_cardinal(cx, cy, w, h, rot_world, VIEWCUBE_PX) { - return Task::done(Message::ViewCubeSnapWorld(card.face_region())); + return self.on_view_cube_snap_world(card.face_region()); } Task::none() } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 9c478428d..25fc23070 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -671,7 +671,7 @@ impl OpenCADStudio { dividers, pane_move_rect, pane_drop_rect, - tab.pan_mode, + tab.pan_mode || tab.orbit_mode, self.ribbon.open_dropdown.is_some(), hover_locked, crosshair_background(tab, is_paper), @@ -886,15 +886,18 @@ impl OpenCADStudio { // both layered ABOVE the viewport mouse_area so they receive // clicks (the shader viewport sits below it). Positioned with // leading Spaces sized to the viewport's screen rectangle. - let active_vp_rect: Option = if is_paper && !tab.is_start { - tab.scene.active_viewport.and_then(|h| { - let (cw, ch) = tab.scene.selection.borrow().vp_size; - tab.scene.viewport_screen_rect(h, (cw, ch)) - }) - } else { - None - }; - if let Some(rect) = active_vp_rect { + let active_vp_rect: Option<(acadrust::Handle, iced::Rectangle)> = + if is_paper && !tab.is_start { + tab.scene.active_viewport.and_then(|h| { + let (cw, ch) = tab.scene.selection.borrow().vp_size; + tab.scene + .viewport_screen_rect(h, (cw, ch)) + .map(|rect| (h, rect)) + }) + } else { + None + }; + if let Some((active_vp, rect)) = active_vp_rect { // Clip the outline to the visible canvas. Clamping only the origin // (max(0.0)) while keeping the full width/height shifted the whole // outline inward when the viewport ran off the top/left edge, so @@ -964,7 +967,7 @@ impl OpenCADStudio { let cube_x = (rect.x + rect.width - VIEWCUBE_HIT_SIZE - VIEWCUBE_PAD).max(0.0); let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0); - let controls = iced::widget::pin(viewcube_nav_controls()) + let controls = iced::widget::pin(viewcube_nav_controls(Some(active_vp))) .position(iced::Point::new(cube_x, cube_y)); viewport_stack = viewport_stack.push(controls); @@ -1003,7 +1006,7 @@ impl OpenCADStudio { let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0); // Cube hit area + nav controls (home / roll / nudge) as one layer. - let controls = iced::widget::pin(viewcube_nav_controls()) + let controls = iced::widget::pin(viewcube_nav_controls(None)) .position(iced::Point::new(cube_x, cube_y)); viewport_stack = viewport_stack.push(controls); diff --git a/src/app/view/viewcube.rs b/src/app/view/viewcube.rs index 4d63fa519..12a92fe84 100644 --- a/src/app/view/viewcube.rs +++ b/src/app/view/viewcube.rs @@ -52,7 +52,9 @@ fn vc_btn<'a>(content: Element<'a, Message>, size: f32, msg: Message) -> Element /// Overlay of home / roll / nudge controls sized to the whole nav region, so /// the caller can position it exactly like the cube hit area. -pub(super) fn viewcube_nav_controls<'a>() -> Element<'a, Message> { +pub(super) fn viewcube_nav_controls<'a>( + viewport: Option, +) -> Element<'a, Message> { use crate::scene::NudgeDir; use crate::ui::icons; let r = VIEWCUBE_REGION_PX; @@ -81,8 +83,8 @@ pub(super) fn viewcube_nav_controls<'a>() -> Element<'a, Message> { .width(iced::Length::Fixed(r)) .height(iced::Length::Fixed(r)), ) - .on_move(Message::CursorMoved) - .on_press(Message::ViewportClick); + .on_move(move |point| Message::CursorMoved(point, viewport)) + .on_press(Message::ViewportClick(viewport)); let controls = stack![ cube_hit, diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 80c31f795..69e3a034c 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -1398,6 +1398,7 @@ fn collapse_button<'a>( compact: bool, ) -> Element<'a, Message> { let title = group.title; + let localized_title = t!(title).into_owned(); // Tightest form: one button = the panel's FIRST tool icon + its title + ▾. // Clicking opens the flyout listing every tool; no tool runs directly at this @@ -1407,23 +1408,31 @@ fn collapse_button<'a>( Some(ik) => make_icon(ik, SMALL_ICON), None => text("").into(), }; - return button( + let content = button( column![ icon, row![ - text(t!(title)).size(9).style(muted_text_style), + text(localized_title.clone()) + .size(9) + .width(Fill) + .align_x(iced::Center) + .wrapping(iced::advanced::text::Wrapping::WordOrGlyph) + .style(muted_text_style), crate::ui::icons::themed_secondary_arrow_down(8.0), ] .spacing(3) + .width(Fill) .align_y(iced::Center), ] .align_x(iced::Center) - .spacing(2), + .spacing(2) + .width(Fill), ) .on_press(Message::ToggleRibbonPanel(title.to_string())) .style(button::subtle) - .padding([3, 5]) - .into(); + .width(Fill) + .padding([3, 5]); + return automatic_large_button(localized_title, content.into()); } // Collapsed (not yet tight): a large representative-tool face — a live button @@ -1464,15 +1473,23 @@ fn collapse_button<'a>( let opener = button( row![ - text(title.to_string()).size(9).style(muted_text_style), + text(localized_title.clone()) + .size(9) + .width(Fill) + .align_x(iced::Center) + .wrapping(iced::advanced::text::Wrapping::WordOrGlyph) + .style(muted_text_style), crate::ui::icons::themed_secondary_arrow_down(8.0), ] .spacing(3) + .width(Fill) .align_y(iced::Center), ) .on_press(Message::ToggleRibbonPanel(title.to_string())) .style(button::subtle) + .width(Fill) .padding([1, 4]); + let opener = automatic_large_button(localized_title, opener.into()); // The large face fills a fixed slot so a collapsed panel is shorter than a full // 3-row panel, letting `CollapsePanels` shrink the ribbon row. diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 7667975f1..2f1550806 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -2,14 +2,21 @@ // free functions used by the Ribbon view/overlay methods. use rustc_hash::FxHashMap as HashMap; +use std::cell::RefCell; use std::time::Duration; use acadrust::types::{Color as AcadColor, LineWeight}; +use iced::advanced::{ + layout, mouse, overlay, renderer, text as advanced_text, widget, Layout, Shell, Widget, +}; // Ribbon tooltips anchor to the right of their button so the cursor — which // rests on the button itself — never covers the tip text. (#143) use iced::widget::tooltip::Position as TipPos; use iced::widget::{button, column, container, row, text, tooltip}; -use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; +use iced::{ + Background, Border, Color, Element, Event, Fill, Length, Padding, Pixels, Rectangle, Size, + Theme, Vector, +}; use crate::app::Message; use crate::modules::{IconKind, ModuleEvent, RibbonItem, StyleKey, ToolDef}; @@ -48,6 +55,11 @@ pub(super) const LARGE_ICON: f32 = ROW_H * 1.5; pub(super) const SMALL_ICON: f32 = ROW_H * 0.7; /// Width of a 3-row (large) button. pub(super) const LARGE_W: f32 = ROW_H * 2.2; +/// Horizontal button padding surrounding a large tool's label. +const LARGE_LABEL_HPAD: f32 = 8.0; +/// Large ribbon labels use at most two lines before their button grows. +const LARGE_LABEL_LINES: f32 = 2.0; +const LARGE_LABEL_SIZE: f32 = 10.0; /// Width of a 1-row (small) button. pub(super) const SMALL_W: f32 = ROW_H; /// Width of the ▾ strip on a small dropdown. @@ -61,6 +73,215 @@ pub(super) const TOOL_BAR_H: f32 = 3.0 * ROW_H + 18.0; /// shorter than a full 3-row panel — the ribbon height follows it down. pub(super) const COLLAPSED_FACE_H: f32 = LARGE_ICON + 20.0; +// ── Automatic large-button sizing ──────────────────────────────────────── + +thread_local! { + /// Ribbon layout runs on every pointer-driven view update. Cache the font- + /// measured width per translated label so the automatic sizing stays cheap. + static LARGE_WIDTH_CACHE: RefCell> = + RefCell::new(HashMap::default()); +} + +fn ribbon_label_bounds( + renderer: &iced::Renderer, + label: &str, + width: f32, + wrapping: advanced_text::Wrapping, +) -> Size { + use advanced_text::{Paragraph as _, Renderer as _}; + + let paragraph = ::Paragraph::with_text( + advanced_text::Text { + content: label, + bounds: Size::new(width, f32::INFINITY), + size: Pixels(LARGE_LABEL_SIZE), + line_height: advanced_text::LineHeight::default(), + font: renderer.default_font(), + align_x: advanced_text::Alignment::Center, + align_y: iced::alignment::Vertical::Center, + shaping: advanced_text::Shaping::default(), + wrapping, + ellipsis: advanced_text::Ellipsis::None, + hint_factor: None, + }, + ); + paragraph.min_bounds() +} + +/// Measure the translated label at the normal button width. It wraps first; +/// only labels that would need more than two lines widen their button. The +/// binary search uses the renderer's real font metrics, so locale and UI scale +/// changes do not rely on character-count estimates. +fn measure_large_width(renderer: &iced::Renderer, label: &str) -> f32 { + let base_inner = (LARGE_W - LARGE_LABEL_HPAD).max(1.0); + let line_height = advanced_text::LineHeight::default() + .to_absolute(Pixels(LARGE_LABEL_SIZE)) + .0; + let max_label_height = line_height * LARGE_LABEL_LINES + 0.5; + let fits = |width: f32| { + ribbon_label_bounds( + renderer, + label, + width, + advanced_text::Wrapping::WordOrGlyph, + ) + .height + <= max_label_height + }; + + if fits(base_inner) { + return LARGE_W; + } + + let natural = ribbon_label_bounds( + renderer, + label, + f32::INFINITY, + advanced_text::Wrapping::None, + ) + .width + .max(base_inner); + if !fits(natural) { + return (natural + LARGE_LABEL_HPAD).ceil(); + } + + let mut low = base_inner; + let mut high = natural; + for _ in 0..10 { + let mid = (low + high) * 0.5; + if fits(mid) { + high = mid; + } else { + low = mid; + } + } + (high + LARGE_LABEL_HPAD).ceil().max(LARGE_W) +} + +fn automatic_large_width(renderer: &iced::Renderer, label: &str) -> f32 { + if let Some(width) = LARGE_WIDTH_CACHE.with(|cache| cache.borrow().get(label).copied()) { + return width; + } + + let width = measure_large_width(renderer, label); + LARGE_WIDTH_CACHE.with(|cache| { + cache.borrow_mut().insert(label.to_string(), width); + }); + width +} + +struct AutomaticLargeWidth<'a> { + label: String, + content: Element<'a, Message>, +} + +impl Widget for AutomaticLargeWidth<'_> { + fn tag(&self) -> widget::tree::Tag { + self.content.as_widget().tag() + } + + fn state(&self) -> widget::tree::State { + self.content.as_widget().state() + } + + fn diff(&mut self, tree: &mut widget::Tree) { + self.content.as_widget_mut().diff(tree); + } + + fn size(&self) -> Size { + Size::new(Length::Shrink, self.content.as_widget().size().height) + } + + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &iced::Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let width = automatic_large_width(renderer, &self.label); + self.content.as_widget_mut().layout( + tree, + renderer, + &limits.width(Length::Fixed(width)), + ) + } + + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &iced::Renderer, + operation: &mut dyn widget::Operation, + ) { + self.content + .as_widget_mut() + .operate(tree, layout, renderer, operation); + } + + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &iced::Renderer, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + self.content.as_widget_mut().update( + tree, event, layout, cursor, renderer, shell, viewport, + ); + } + + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &iced::Renderer, + ) -> mouse::Interaction { + self.content + .as_widget() + .mouse_interaction(tree, layout, cursor, viewport, renderer) + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut iced::Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content + .as_widget() + .draw(tree, renderer, theme, style, layout, cursor, viewport); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut widget::Tree, + layout: Layout<'b>, + renderer: &iced::Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content + .as_widget_mut() + .overlay(tree, layout, renderer, viewport, translation) + } +} + +pub(super) fn automatic_large_button<'a>( + label: String, + content: Element<'a, Message>, +) -> Element<'a, Message> { + Element::new(AutomaticLargeWidth { label, content }) +} + // ── Tab-bar constants ────────────────────────────────────────────────────── pub(super) const TOP_ARR_W: f32 = 12.0; @@ -459,30 +680,40 @@ pub(super) fn render_large_dropdown<'a>( .and_then(|cmd| items.iter().find(|(c, _, _)| *c == cmd).map(|(_, lbl, _)| *lbl)) .or_else(|| items.first().map(|(_, lbl, _)| *lbl)) .unwrap_or(id); - let label = explicit_label.unwrap_or(cur_label); + let label = t!(explicit_label.unwrap_or(cur_label)).into_owned(); let tip_text = format!("{}\n{} {}", t!(cur_label), t!("Command:"), last); - let arr_tip = format!("{} {}", t!(label), t!("options")); + let arr_tip = format!("{} {}", label, t!("options")); - // Icon on top with the label beneath it, then the ▾ strip at the very bottom. + // The label owns the bottom of the face. The icon's Fill container centers + // it in all remaining space between the button top and the label. let top_btn = button( column![ - make_icon_dim(cur_icon, LARGE_ICON, dim), - text(t!(label)) + container(make_icon_dim(cur_icon, LARGE_ICON, dim)) + .width(Fill) + .height(Fill) + .align_x(iced::Center) + .align_y(iced::Center), + text(label.clone()) .size(10) + .width(Fill) + .align_x(iced::Center) + .wrapping(advanced_text::Wrapping::WordOrGlyph) .style(move |theme: &Theme| tool_label_style(theme, dim)), ] .align_x(iced::Center) - .spacing(3), + .spacing(0) + .width(Fill) + .height(Fill), ) .on_press(Message::RibbonToolClick { tool_id: last.to_string(), event: ModuleEvent::Command(last.to_string()), }) .style(move |theme: &Theme, status| tool_btn_style(theme, active, status)) - .width(Length::Fixed(LARGE_W)) + .width(Fill) .height(Fill) .padding(Padding { - top: 6.0, + top: 4.0, right: 4.0, bottom: 2.0, left: 4.0, @@ -499,7 +730,7 @@ pub(super) fn render_large_dropdown<'a>( .style(move |theme: &Theme, status| { tool_btn_style(theme, dd_open, status) }) - .width(Length::Fixed(LARGE_W)) + .width(Fill) .height(LARGE_ARR) .padding(0); @@ -512,13 +743,12 @@ pub(super) fn render_large_dropdown<'a>( .delay(Duration::from_millis(400)) .style(tip_style); - PosReport::new( - id, - column![top_with_tip, arr_with_tip] - .spacing(0) - .width(Length::Fixed(LARGE_W)) - .height(Fill), - ) + let content = column![top_with_tip, arr_with_tip] + .spacing(0) + .width(Fill) + .height(Fill); + + PosReport::new(id, automatic_large_button(label, content.into())) .into() } @@ -546,28 +776,42 @@ pub(super) fn render_large<'a>( let dim = start_dimmed(&state, &t.event); let event = t.event.clone(); let tool_id = t.id.to_string(); - let tip_text = format!("{}\n{} {}", t!(t.label), t!("Command:"), t.id); + let label = t!(t.label).into_owned(); + let tip_text = format!("{}\n{} {}", label, t!("Command:"), t.id); let btn = button( column![ - make_icon_dim(t.icon, LARGE_ICON, dim), - text(t!(t.label)) + container(make_icon_dim(t.icon, LARGE_ICON, dim)) + .width(Fill) + .height(Fill) + .align_x(iced::Center) + .align_y(iced::Center), + text(label.clone()) .size(10) + .width(Fill) + .align_x(iced::Center) + .wrapping(advanced_text::Wrapping::WordOrGlyph) .style(move |theme: &Theme| tool_label_style(theme, dim)), ] .align_x(iced::Center) - .spacing(3), + .spacing(0) + .width(Fill) + .height(Fill), ) .on_press(Message::RibbonToolClick { tool_id, event }) .style(move |theme: &Theme, status| tool_btn_style(theme, active, status)) - .width(Length::Fixed(LARGE_W)) + .width(Fill) .height(Fill) .padding(Padding { - top: 6.0, + top: 4.0, right: 4.0, bottom: 4.0, left: 4.0, }); - tooltip(btn, make_tip(tip_text), TipPos::Right) + tooltip( + automatic_large_button(label, btn.into()), + make_tip(tip_text), + TipPos::Right, + ) .gap(6.0) .delay(Duration::from_millis(400)) .style(tip_style) @@ -749,44 +993,20 @@ pub(super) fn render_large<'a>( state, ) } else { - let mp_active = is_active_tool(match_prop.id, active_tool, &state); - let mp_dim = start_dimmed(&state, &match_prop.event); - let mp_event = match_prop.event.clone(); - let mp_id = match_prop.id.to_string(); - let mp_tip = format!( - "{}\n{} {}", - t!(match_prop.label), - t!("Command:"), - match_prop.id - ); - let mp_btn = button( - column![ - make_icon_dim(match_prop.icon, LARGE_ICON, mp_dim), - text(t!(match_prop.label)) - .size(10) - .style(move |theme: &Theme| tool_label_style(theme, mp_dim)), - ] - .align_x(iced::Center) - .spacing(3), + render_large( + &RibbonItem::LargeTool(match_prop.clone()), + active_tool, + open_dd, + last_cmd, + state, + layer_infos, + active_layer, + active_color, + active_linetype, + active_lineweight, + style_ctx, + false, ) - .on_press(Message::RibbonToolClick { - tool_id: mp_id, - event: mp_event, - }) - .style(move |theme: &Theme, status| tool_btn_style(theme, mp_active, status)) - .width(Length::Fixed(LARGE_W)) - .height(Fill) - .padding(Padding { - top: 6.0, - right: 4.0, - bottom: 4.0, - left: 4.0, - }); - tooltip(mp_btn, make_tip(mp_tip), TipPos::Right) - .gap(6.0) - .delay(Duration::from_millis(400)) - .style(tip_style) - .into() }; const PROP_W: f32 = 130.0;