2026-03-22 14:56:27 +03:00
|
|
|
//! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info
|
|
|
|
|
|
refactor: group ui/ statusbar and window subdirs
Continue tidying the flat ui/ root:
- ui/statusbar/: statusbar.rs becomes statusbar/mod.rs (its
crate::ui::statusbar::* paths are preserved) and absorbs
statusbar_config.rs + statusbar_menu.rs.
- ui/window/: the standalone manager/dialog windows move here —
about, layout_manager, page_setup, plugin_manager, shortcuts,
layers, update_notice, open_progress. The crate::ui::LayerPanel
re-export is repointed so callers are unchanged.
Call sites repointed to the new paths; no compat re-exports beyond the
existing LayerPanel one. ui/ root drops from 21 to 10 flat files with
five subdirs (ribbon, style, popup, statusbar, window).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 23:52:26 +03:00
|
|
|
pub mod statusbar_config;
|
|
|
|
|
pub mod statusbar_menu;
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
use iced::widget::tooltip::Position as TipPos;
|
2026-06-27 14:24:47 +03:00
|
|
|
use iced::widget::{
|
2026-07-05 16:17:44 +03:00
|
|
|
button, column, container, mouse_area, row, text, text_input, tooltip,
|
2026-06-27 14:24:47 +03:00
|
|
|
};
|
2026-03-22 14:56:27 +03:00
|
|
|
use iced::{Background, Border, Color, Element, Length, Theme};
|
|
|
|
|
|
2026-07-05 16:17:44 +03:00
|
|
|
/// Scrollable id of the status-bar layout-tab strip (retained so the existing
|
|
|
|
|
/// `Message::ScrollLayoutTabs` handler still resolves; the strip now flex-wraps
|
|
|
|
|
/// instead of scrolling).
|
2026-06-27 14:24:47 +03:00
|
|
|
pub const LAYOUT_TABS_SCROLL_ID: &str = "statusbar-layout-tabs";
|
|
|
|
|
|
2026-06-12 14:18:31 +03:00
|
|
|
/// Widget id of the inline layout-rename text input, so the rename can grab
|
|
|
|
|
/// keyboard focus the moment it opens (issue #86).
|
|
|
|
|
pub const LAYOUT_RENAME_INPUT_ID: &str = "layout_rename_input";
|
|
|
|
|
|
2026-03-22 22:44:32 +03:00
|
|
|
use crate::app::Message;
|
2026-05-12 10:36:15 +03:00
|
|
|
use crate::snap::Snapper;
|
refactor: group ui/ statusbar and window subdirs
Continue tidying the flat ui/ root:
- ui/statusbar/: statusbar.rs becomes statusbar/mod.rs (its
crate::ui::statusbar::* paths are preserved) and absorbs
statusbar_config.rs + statusbar_menu.rs.
- ui/window/: the standalone manager/dialog windows move here —
about, layout_manager, page_setup, plugin_manager, shortcuts,
layers, update_notice, open_progress. The crate::ui::LayerPanel
re-export is repointed so callers are unchanged.
Call sites repointed to the new paths; no compat re-exports beyond the
existing LayerPanel one. ui/ root drops from 21 to 10 flat files with
five subdirs (ribbon, style, popup, statusbar, window).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 23:52:26 +03:00
|
|
|
use crate::ui::statusbar::statusbar_config::{StatusBarConfig, StatusPill};
|
2026-07-05 16:17:44 +03:00
|
|
|
use crate::ui::wrap_bar::{WrapBar, WrapFlow};
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
|
|
|
#[derive(Clone, Default)]
|
|
|
|
|
pub struct StatusBar {
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
pub coord_display: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl StatusBar {
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
coord_display: "MODEL".into(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn view<'a>(
|
|
|
|
|
&'a self,
|
|
|
|
|
snapper: &'a Snapper,
|
|
|
|
|
popup_open: bool,
|
|
|
|
|
ortho_mode: bool,
|
|
|
|
|
polar_mode: bool,
|
2026-04-07 19:30:25 +03:00
|
|
|
polar_increment_deg: f32,
|
2026-04-08 00:12:09 +03:00
|
|
|
dyn_input: bool,
|
2026-04-08 00:26:54 +03:00
|
|
|
otrack: bool,
|
2026-03-22 14:56:27 +03:00
|
|
|
layouts: Vec<String>,
|
|
|
|
|
current_layout: String,
|
2026-03-31 07:13:36 +03:00
|
|
|
// If `Some((original, edit_value))`, the named tab shows a text input.
|
|
|
|
|
rename_state: Option<&'a (String, String)>,
|
2026-03-31 07:17:45 +03:00
|
|
|
// Scale of the first user viewport in the active paper layout.
|
|
|
|
|
viewport_scale: Option<f64>,
|
2026-04-02 23:52:35 +03:00
|
|
|
// Number of user viewports in the current paper layout (0 = model space).
|
|
|
|
|
viewport_count: usize,
|
2026-04-23 09:18:01 +03:00
|
|
|
// True when the user is editing inside a paper-space viewport (MSPACE).
|
|
|
|
|
in_mspace: bool,
|
2026-04-24 01:23:06 +03:00
|
|
|
// Whether the layout tabs (Model/Paper) are visible (LAYOUTTAB).
|
|
|
|
|
show_layout_tabs: bool,
|
2026-05-06 17:50:46 +03:00
|
|
|
// Current annotation scale for model space (1.0 = 1:1, 50.0 = 1:50, etc.).
|
|
|
|
|
annotation_scale: f32,
|
|
|
|
|
// True when the scale picker popup is open.
|
|
|
|
|
scale_popup_open: bool,
|
|
|
|
|
// True when the scale pill is interactive (always model space; paper space only when a viewport is active/selected).
|
|
|
|
|
scale_pill_enabled: bool,
|
2026-05-23 07:15:26 +03:00
|
|
|
// LWDISPLAY header flag — controls lineweight visibility in the viewport.
|
|
|
|
|
lineweight_display: bool,
|
2026-06-04 19:14:04 +03:00
|
|
|
// Live cursor position in model coordinates, for the coordinate readout.
|
|
|
|
|
cursor_world: glam::Vec3,
|
|
|
|
|
// True while clean-screen mode hides the ribbon and side panels.
|
|
|
|
|
clean_screen: bool,
|
2026-06-04 22:06:42 +03:00
|
|
|
// Drawing units (INSUNITS) for the units pill.
|
|
|
|
|
insertion_units: i16,
|
|
|
|
|
// True while the drawing-units picker is open.
|
|
|
|
|
units_popup_open: bool,
|
2026-06-04 22:51:23 +03:00
|
|
|
// True when objects are hidden by Isolate / Hide.
|
|
|
|
|
isolation_active: bool,
|
2026-06-04 23:25:09 +03:00
|
|
|
// Whether entity transparency is shown (Transparency pill state).
|
|
|
|
|
transparency_display: bool,
|
2026-06-04 23:31:36 +03:00
|
|
|
// Whether the Quick Properties floating panel is enabled.
|
|
|
|
|
quick_properties: bool,
|
2026-06-04 23:39:48 +03:00
|
|
|
// True when the selection filter is excluding at least one type.
|
|
|
|
|
selection_filter_active: bool,
|
2026-06-04 23:44:39 +03:00
|
|
|
// Whether selection cycling is enabled.
|
|
|
|
|
selection_cycling: bool,
|
2026-06-04 19:14:04 +03:00
|
|
|
// Which pills the user has chosen to show on the bar.
|
|
|
|
|
config: &'a StatusBarConfig,
|
2026-03-22 14:56:27 +03:00
|
|
|
) -> Element<'a, Message> {
|
2026-06-27 14:24:47 +03:00
|
|
|
// Leftmost hamburger: opens a dropdown listing Model + every layout, so
|
|
|
|
|
// a layout can be picked directly even when the tab strip is scrolled.
|
2026-07-01 09:33:00 +03:00
|
|
|
let menu_btn = button(crate::ui::icons::tinted(crate::ui::icons::MENU, 16.0, ICON_COLOR))
|
2026-06-27 14:24:47 +03:00
|
|
|
.on_press(Message::ToggleLayoutList)
|
|
|
|
|
.style(|_: &Theme, status| button::Style {
|
|
|
|
|
background: Some(Background::Color(match status {
|
|
|
|
|
button::Status::Hovered => PILL_BG,
|
|
|
|
|
_ => Color::TRANSPARENT,
|
|
|
|
|
})),
|
|
|
|
|
border: Border {
|
|
|
|
|
radius: 3.0.into(),
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
2026-03-22 14:56:27 +03:00
|
|
|
..Default::default()
|
|
|
|
|
})
|
2026-07-01 09:33:00 +03:00
|
|
|
.padding([4, 8]);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
|
|
|
let add_btn = button(text("+").size(12).color(ICON_COLOR))
|
|
|
|
|
.on_press(Message::LayoutCreate)
|
|
|
|
|
.style(|_: &Theme, _| button::Style {
|
|
|
|
|
background: Some(Background::Color(Color::TRANSPARENT)),
|
|
|
|
|
..Default::default()
|
|
|
|
|
})
|
2026-07-01 09:33:00 +03:00
|
|
|
.padding([4, 8]);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
|
|
|
// ── Right side ────────────────────────────────────────────────────
|
|
|
|
|
let osnap_active = snapper.is_active();
|
|
|
|
|
|
2026-04-02 23:52:35 +03:00
|
|
|
let vp_label = if viewport_count > 0 {
|
|
|
|
|
format!("{} VP", viewport_count)
|
|
|
|
|
} else {
|
|
|
|
|
String::new()
|
|
|
|
|
};
|
2026-05-06 17:50:46 +03:00
|
|
|
// Scale pill: opens the scale picker popup.
|
|
|
|
|
// Model space: always interactive, shows annotation scale.
|
|
|
|
|
// Paper space: interactive only when a viewport is active/selected.
|
|
|
|
|
let scale_label = if current_layout == "Model" {
|
|
|
|
|
format_scale(Some(1.0 / annotation_scale as f64))
|
|
|
|
|
} else {
|
|
|
|
|
format_scale(viewport_scale)
|
|
|
|
|
};
|
|
|
|
|
let scale_element: Element<'_, Message> = if scale_pill_enabled {
|
2026-05-12 10:36:15 +03:00
|
|
|
tip(
|
|
|
|
|
scale_popup_btn(&scale_label, scale_popup_open),
|
|
|
|
|
"Annotation / Viewport Scale\nClick to change",
|
|
|
|
|
)
|
2026-05-06 17:50:46 +03:00
|
|
|
} else {
|
|
|
|
|
status_pill(scale_label).into()
|
|
|
|
|
};
|
2026-06-04 19:14:04 +03:00
|
|
|
// Build the right-side pills, honouring the user's per-pill visibility.
|
2026-07-05 16:17:44 +03:00
|
|
|
// They live in a flex-wrap flow (WrapFlow) so they spill onto extra rows
|
|
|
|
|
// when the width can't hold them all on one line.
|
2026-06-04 19:14:04 +03:00
|
|
|
let vis = |p: StatusPill| config.is_visible(p);
|
2026-07-05 16:17:44 +03:00
|
|
|
let mut pills: Vec<Element<'_, Message>> = Vec::new();
|
2026-06-04 19:14:04 +03:00
|
|
|
if vis(StatusPill::Coords) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(
|
|
|
|
|
tip(
|
|
|
|
|
status_pill(format_coords(cursor_world)).into(),
|
|
|
|
|
"Cursor coordinates (X, Y, Z)",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
);
|
2026-06-04 19:14:04 +03:00
|
|
|
}
|
|
|
|
|
if vis(StatusPill::Ortho) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(
|
|
|
|
|
tip(
|
|
|
|
|
toggle_pill(crate::ui::icons::ST_ORTHO, ortho_mode, Message::ToggleOrtho),
|
|
|
|
|
"Orthogonal Mode\nF8",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
);
|
2026-06-04 19:14:04 +03:00
|
|
|
}
|
|
|
|
|
if vis(StatusPill::Lwt) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(
|
|
|
|
|
tip(
|
|
|
|
|
toggle_pill(crate::ui::icons::ST_LWT, lineweight_display, Message::ToggleLineweightDisplay),
|
|
|
|
|
"Show Lineweight\nLWDISPLAY",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
);
|
2026-06-04 19:14:04 +03:00
|
|
|
}
|
|
|
|
|
if vis(StatusPill::Polar) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(polar_pill(polar_mode, polar_increment_deg).into());
|
2026-06-04 19:14:04 +03:00
|
|
|
}
|
|
|
|
|
if vis(StatusPill::Dyn) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(
|
|
|
|
|
tip(
|
|
|
|
|
toggle_pill(crate::ui::icons::ST_DYN, dyn_input, Message::ToggleDynInput),
|
|
|
|
|
"Dynamic Input\nF12",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
);
|
2026-06-04 19:14:04 +03:00
|
|
|
}
|
|
|
|
|
if vis(StatusPill::Otrack) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(
|
|
|
|
|
tip(
|
|
|
|
|
toggle_pill(crate::ui::icons::ST_OTRACK, otrack, Message::ToggleOTrack),
|
|
|
|
|
"Object Snap Tracking\nF11",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
);
|
2026-06-04 19:14:04 +03:00
|
|
|
}
|
|
|
|
|
if vis(StatusPill::Osnap) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(osnap_btn(osnap_active, snapper.snap_enabled, popup_open).into());
|
2026-06-04 19:14:04 +03:00
|
|
|
}
|
|
|
|
|
if vis(StatusPill::Space) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(
|
|
|
|
|
tip(
|
|
|
|
|
space_mode_btn(¤t_layout, in_mspace),
|
|
|
|
|
"PAPER: double-click viewport to enter MSPACE\nMODEL: click to switch to Model Space",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
);
|
2026-06-04 19:14:04 +03:00
|
|
|
}
|
|
|
|
|
if vis(StatusPill::Scale) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(scale_element);
|
2026-06-04 19:14:04 +03:00
|
|
|
}
|
2026-06-04 22:06:42 +03:00
|
|
|
if vis(StatusPill::Units) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(
|
|
|
|
|
tip(
|
|
|
|
|
units_btn(
|
|
|
|
|
crate::ui::popup::units_popup::unit_short(insertion_units),
|
|
|
|
|
units_popup_open,
|
|
|
|
|
),
|
|
|
|
|
"Drawing Units (INSUNITS)\nClick to change",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
);
|
2026-06-04 22:06:42 +03:00
|
|
|
}
|
2026-06-04 23:25:09 +03:00
|
|
|
if vis(StatusPill::Transparency) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(
|
|
|
|
|
tip(
|
|
|
|
|
toggle_pill(
|
|
|
|
|
crate::ui::icons::ST_TRANSPARENCY,
|
|
|
|
|
transparency_display,
|
|
|
|
|
Message::ToggleTransparencyDisplay,
|
|
|
|
|
),
|
|
|
|
|
"Show Transparency\nForce opaque when off",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
);
|
2026-06-04 23:25:09 +03:00
|
|
|
}
|
2026-06-04 22:51:23 +03:00
|
|
|
if vis(StatusPill::Isolate) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(
|
|
|
|
|
tip(
|
|
|
|
|
toggle_pill(crate::ui::icons::ST_ISOLATE, isolation_active, Message::ToggleIsolatePopup),
|
|
|
|
|
"Isolate Objects\nClick for Isolate / Hide / End",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
);
|
2026-06-04 22:51:23 +03:00
|
|
|
}
|
2026-06-04 23:31:36 +03:00
|
|
|
if vis(StatusPill::QuickProps) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(
|
|
|
|
|
tip(
|
|
|
|
|
toggle_pill(crate::ui::icons::ST_QUICKPROPS, quick_properties, Message::ToggleQuickProperties),
|
|
|
|
|
"Quick Properties\nFloating panel on selection",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
);
|
2026-06-04 23:31:36 +03:00
|
|
|
}
|
2026-06-04 23:39:48 +03:00
|
|
|
if vis(StatusPill::SelFilter) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(
|
|
|
|
|
tip(
|
|
|
|
|
toggle_pill(
|
|
|
|
|
crate::ui::icons::ST_FILTER,
|
|
|
|
|
selection_filter_active,
|
|
|
|
|
Message::ToggleSelectionFilterPopup,
|
|
|
|
|
),
|
|
|
|
|
"Selection Filtering\nLimit which object types can be picked",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
);
|
2026-06-04 23:39:48 +03:00
|
|
|
}
|
2026-06-04 23:44:39 +03:00
|
|
|
if vis(StatusPill::SelCycle) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(
|
|
|
|
|
tip(
|
|
|
|
|
toggle_pill(crate::ui::icons::ST_SELCYCLE, selection_cycling, Message::ToggleSelectionCycling),
|
|
|
|
|
"Selection Cycling\nRepeat-click to step through overlapping objects",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
);
|
2026-06-04 23:44:39 +03:00
|
|
|
}
|
2026-06-04 19:14:04 +03:00
|
|
|
if vis(StatusPill::Vp) && !vp_label.is_empty() {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(
|
|
|
|
|
tip(
|
|
|
|
|
status_pill(vp_label).into(),
|
|
|
|
|
"Viewport count in active layout",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
);
|
2026-04-02 23:52:35 +03:00
|
|
|
}
|
2026-06-04 19:14:04 +03:00
|
|
|
if vis(StatusPill::CleanScreen) {
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(
|
|
|
|
|
tip(
|
|
|
|
|
toggle_pill(crate::ui::icons::ST_CLEANSCREEN, clean_screen, Message::ToggleCleanScreen),
|
|
|
|
|
"Clean Screen\nHide ribbon and panels",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
);
|
2026-06-04 19:14:04 +03:00
|
|
|
}
|
|
|
|
|
// Customization handle: opens the pill show/hide menu.
|
2026-07-05 16:17:44 +03:00
|
|
|
pills.push(
|
|
|
|
|
tip(
|
|
|
|
|
customize_btn(),
|
|
|
|
|
"Customization\nShow or hide status-bar items",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
);
|
|
|
|
|
let right_status = WrapFlow::new(pills).spacing_x(2.0).row_h(30.0);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
2026-07-05 16:17:44 +03:00
|
|
|
// Left area: hamburger menu + Model/layout tabs in a flex-wrap flow, so
|
|
|
|
|
// they spill onto lower rows when narrow (no scroll arrows). The pills
|
|
|
|
|
// wrap in their own flow; WrapBar stacks the two areas so a wrapped tab
|
|
|
|
|
// never shares a row with a pill.
|
|
|
|
|
let mut left: Vec<Element<'_, Message>> = Vec::new();
|
|
|
|
|
left.push(menu_btn.into());
|
2026-04-24 01:23:06 +03:00
|
|
|
if show_layout_tabs {
|
|
|
|
|
for name in layouts {
|
|
|
|
|
let is_active = name == current_layout;
|
|
|
|
|
let renaming = rename_state
|
|
|
|
|
.filter(|(orig, _)| *orig == name)
|
|
|
|
|
.map(|(_, edit)| edit.as_str());
|
2026-07-05 16:17:44 +03:00
|
|
|
left.push(space_tab(name, is_active, renaming).into());
|
2026-04-24 01:23:06 +03:00
|
|
|
}
|
2026-07-05 16:17:44 +03:00
|
|
|
left.push(add_btn.into());
|
2026-03-22 14:56:27 +03:00
|
|
|
}
|
2026-07-05 16:17:44 +03:00
|
|
|
let left_area = WrapFlow::new(left).spacing_x(2.0).row_h(30.0);
|
|
|
|
|
|
|
|
|
|
let wrap = WrapBar::new(left_area.into(), right_status.into())
|
|
|
|
|
.min_row_h(30.0)
|
|
|
|
|
.justify_end(true);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
2026-07-05 16:17:44 +03:00
|
|
|
container(wrap)
|
2026-03-22 14:56:27 +03:00
|
|
|
.style(|_: &Theme| container::Style {
|
|
|
|
|
background: Some(Background::Color(BAR_BG)),
|
|
|
|
|
border: Border {
|
|
|
|
|
color: BORDER_COLOR,
|
|
|
|
|
width: 1.0,
|
|
|
|
|
radius: 0.0.into(),
|
|
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
})
|
|
|
|
|
.width(Length::Fill)
|
2026-07-05 16:17:44 +03:00
|
|
|
// One row matches the drawing (document) tab bar height so the three
|
2026-07-01 09:33:00 +03:00
|
|
|
// horizontal strips — tabs, status bar, command line — line up.
|
2026-07-05 16:17:44 +03:00
|
|
|
// WrapBar vertically centres every pill (issue #216) and grows to a
|
|
|
|
|
// second row when the width can't hold both blocks.
|
2026-03-22 14:56:27 +03:00
|
|
|
.padding([0, 4])
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 19:14:04 +03:00
|
|
|
// ── Coordinate readout ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
fn format_coords(p: glam::Vec3) -> String {
|
|
|
|
|
format!("{:.4}, {:.4}, {:.4}", p.x, p.y, p.z)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Customization handle ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
fn customize_btn() -> Element<'static, Message> {
|
2026-07-01 09:33:00 +03:00
|
|
|
button(crate::ui::icons::tinted(crate::ui::icons::MENU, 16.0, ICON_COLOR))
|
2026-06-04 19:14:04 +03:00
|
|
|
.on_press(Message::ToggleStatusBarMenu)
|
|
|
|
|
.style(|_: &Theme, status| button::Style {
|
|
|
|
|
background: Some(Background::Color(match status {
|
|
|
|
|
button::Status::Hovered => PILL_BG,
|
|
|
|
|
_ => Color::TRANSPARENT,
|
|
|
|
|
})),
|
|
|
|
|
border: Border {
|
|
|
|
|
radius: 3.0.into(),
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
})
|
2026-07-01 09:33:00 +03:00
|
|
|
.padding([4, 8])
|
2026-06-04 19:14:04 +03:00
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
// ── Tooltip helper ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
fn tip<'a>(content: Element<'a, Message>, label: &'static str) -> Element<'a, Message> {
|
2026-06-25 14:11:06 +03:00
|
|
|
tip_node(content, text(label).size(11).color(Color::WHITE).into())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Like [`tip`] but the tooltip body is any element — used to embed an SVG
|
|
|
|
|
/// glyph (e.g. the dropdown caret) instead of a Unicode character that renders
|
|
|
|
|
/// as tofu on the web. (#138)
|
|
|
|
|
fn tip_node<'a>(content: Element<'a, Message>, body: Element<'a, Message>) -> Element<'a, Message> {
|
2026-03-22 14:56:27 +03:00
|
|
|
tooltip(
|
|
|
|
|
content,
|
2026-06-25 14:11:06 +03:00
|
|
|
container(body)
|
2026-03-22 14:56:27 +03:00
|
|
|
.style(|_: &Theme| container::Style {
|
|
|
|
|
background: Some(Background::Color(Color {
|
|
|
|
|
r: 0.13,
|
|
|
|
|
g: 0.13,
|
|
|
|
|
b: 0.13,
|
|
|
|
|
a: 0.97,
|
|
|
|
|
})),
|
|
|
|
|
border: Border {
|
|
|
|
|
color: Color {
|
|
|
|
|
r: 0.35,
|
|
|
|
|
g: 0.35,
|
|
|
|
|
b: 0.35,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
},
|
|
|
|
|
width: 1.0,
|
|
|
|
|
radius: 3.0.into(),
|
|
|
|
|
},
|
|
|
|
|
text_color: Some(Color::WHITE),
|
|
|
|
|
..Default::default()
|
|
|
|
|
})
|
|
|
|
|
.padding([4, 8]),
|
|
|
|
|
TipPos::Top,
|
|
|
|
|
)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Simple toggle pill ────────────────────────────────────────────────────
|
|
|
|
|
|
2026-07-01 09:33:00 +03:00
|
|
|
/// A status-bar toggle, drawn as a tinted icon (issue #216: the old size-10
|
|
|
|
|
/// text labels were too small to read). The name lives in the tooltip each
|
|
|
|
|
/// call site already wraps it with.
|
fix(ui): open the OSNAP settings popup near its button, not the corner
The snap popup used a hardcoded 4 px right offset, pinning it to the
window's right edge — but the Osnap button sits well to the left, with up
to ten pills after it (Space, Scale, Units, Transparency, Isolate, …).
Anchor it by the summed width of the pills that follow Osnap, honouring
the user's per-pill visibility. iced gives the view no real layout
metrics, so the pill widths are estimated (icon pills fixed, labelled
pills approximate); the popup now opens by the button instead of the
corner.
Closes #248
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:03:11 +03:00
|
|
|
/// Approximate distance (px) from the window's right edge to the right edge of
|
|
|
|
|
/// the Osnap button — used to anchor the OSNAP settings popup above the button
|
|
|
|
|
/// instead of at the window corner (#248). The status-bar pills that follow
|
|
|
|
|
/// Osnap are summed by estimated width (icon pills are fixed; labelled pills are
|
|
|
|
|
/// approximate), honouring the user's per-pill visibility. iced gives the view
|
|
|
|
|
/// no real layout metrics, so this positions the popup near the button rather
|
|
|
|
|
/// than pixel-exact.
|
|
|
|
|
pub fn osnap_popup_right_offset(config: &StatusBarConfig, viewport_count: usize) -> f32 {
|
|
|
|
|
const SP: f32 = 2.0; // right_status row spacing between pills
|
|
|
|
|
const TOGGLE: f32 = 33.0; // icon toggle pill (17px icon + [4,7] pad + border)
|
|
|
|
|
// Pills after Osnap, in bar order, with estimated widths.
|
|
|
|
|
let after = [
|
|
|
|
|
(StatusPill::Space, 58.0),
|
|
|
|
|
(StatusPill::Scale, 92.0),
|
|
|
|
|
(StatusPill::Units, 42.0),
|
|
|
|
|
(StatusPill::Transparency, TOGGLE),
|
|
|
|
|
(StatusPill::Isolate, TOGGLE),
|
|
|
|
|
(StatusPill::QuickProps, TOGGLE),
|
|
|
|
|
(StatusPill::SelFilter, TOGGLE),
|
|
|
|
|
(StatusPill::SelCycle, TOGGLE),
|
|
|
|
|
(StatusPill::Vp, 46.0),
|
|
|
|
|
(StatusPill::CleanScreen, TOGGLE),
|
|
|
|
|
];
|
|
|
|
|
// Bar right margin + the always-present customization button.
|
|
|
|
|
let mut w = 6.0 + 30.0 + SP;
|
|
|
|
|
for (pill, pw) in after {
|
|
|
|
|
let shown =
|
|
|
|
|
config.is_visible(pill) && !(matches!(pill, StatusPill::Vp) && viewport_count == 0);
|
|
|
|
|
if shown {
|
|
|
|
|
w += pw + SP;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
w
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 09:33:00 +03:00
|
|
|
fn toggle_pill(icon: &'static [u8], active: bool, msg: Message) -> Element<'static, Message> {
|
|
|
|
|
let color = if active { OSNAP_ON_TEXT } else { OSNAP_OFF_TEXT };
|
|
|
|
|
button(crate::ui::icons::tinted(icon, 17.0, color))
|
|
|
|
|
.on_press(msg)
|
|
|
|
|
.style(move |_: &Theme, status| button::Style {
|
|
|
|
|
background: Some(Background::Color(match (active, status) {
|
|
|
|
|
(true, button::Status::Hovered) => SNAP_ON_HOVER,
|
|
|
|
|
(true, _) => SNAP_ON_BG,
|
|
|
|
|
(false, button::Status::Hovered) => SNAP_OFF_HOVER,
|
|
|
|
|
(false, _) => SNAP_OFF_BG,
|
|
|
|
|
})),
|
|
|
|
|
border: Border {
|
|
|
|
|
color: if active { SNAP_BORDER_ON } else { BORDER_COLOR },
|
|
|
|
|
width: 1.0,
|
|
|
|
|
radius: 2.0.into(),
|
|
|
|
|
},
|
|
|
|
|
text_color: color,
|
|
|
|
|
shadow: iced::Shadow::default(),
|
|
|
|
|
snap: false,
|
|
|
|
|
})
|
|
|
|
|
.padding([4, 7])
|
|
|
|
|
.into()
|
2026-03-22 14:56:27 +03:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 19:30:25 +03:00
|
|
|
// ── Polar tracking pill ───────────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// Left-click toggles polar on/off.
|
|
|
|
|
// Right-click cycles through common angle increments: 15 → 30 → 45 → 90 → 15 …
|
|
|
|
|
|
|
|
|
|
fn polar_pill(active: bool, increment_deg: f32) -> Element<'static, Message> {
|
2026-07-01 09:33:00 +03:00
|
|
|
let angle = format!("{:.0}°", increment_deg);
|
2026-04-07 19:30:25 +03:00
|
|
|
let tooltip_text = format!(
|
|
|
|
|
"Polar Tracking ({}°)\nF10 — left-click on/off\nRight-click to change angle",
|
|
|
|
|
increment_deg as u32
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-12 10:36:15 +03:00
|
|
|
let bg_color = move |hovered: bool| match (active, hovered) {
|
|
|
|
|
(true, true) => SNAP_ON_HOVER,
|
|
|
|
|
(true, false) => SNAP_ON_BG,
|
|
|
|
|
(false, true) => SNAP_OFF_HOVER,
|
|
|
|
|
(false, false) => SNAP_OFF_BG,
|
2026-04-07 19:30:25 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Cycle to the next common angle on right-click.
|
|
|
|
|
let next_angle = match increment_deg as u32 {
|
|
|
|
|
15 => 30.0_f32,
|
|
|
|
|
30 => 45.0,
|
|
|
|
|
45 => 90.0,
|
|
|
|
|
_ => 15.0,
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-01 09:33:00 +03:00
|
|
|
let color = if active { OSNAP_ON_TEXT } else { OSNAP_OFF_TEXT };
|
|
|
|
|
let inner = container(
|
|
|
|
|
row![
|
|
|
|
|
crate::ui::icons::tinted(crate::ui::icons::ST_POLAR, 17.0, color),
|
|
|
|
|
text(angle).size(11).color(color),
|
|
|
|
|
]
|
|
|
|
|
.spacing(2)
|
|
|
|
|
.align_y(iced::Center),
|
|
|
|
|
)
|
2026-04-07 19:30:25 +03:00
|
|
|
.style(move |_: &Theme| container::Style {
|
|
|
|
|
background: Some(Background::Color(bg_color(false))),
|
|
|
|
|
border: Border {
|
|
|
|
|
color: if active { SNAP_BORDER_ON } else { BORDER_COLOR },
|
|
|
|
|
width: 1.0,
|
|
|
|
|
radius: 2.0.into(),
|
|
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
})
|
2026-07-01 09:33:00 +03:00
|
|
|
.padding([4, 6]);
|
2026-04-07 19:30:25 +03:00
|
|
|
|
|
|
|
|
let pill = mouse_area(inner)
|
|
|
|
|
.on_press(Message::TogglePolar)
|
|
|
|
|
.on_right_press(Message::SetPolarAngle(next_angle));
|
|
|
|
|
|
|
|
|
|
tooltip(
|
|
|
|
|
pill,
|
|
|
|
|
container(text(tooltip_text).size(11).color(Color::WHITE))
|
|
|
|
|
.style(|_: &Theme| container::Style {
|
|
|
|
|
background: Some(Background::Color(Color {
|
|
|
|
|
r: 0.13,
|
|
|
|
|
g: 0.13,
|
|
|
|
|
b: 0.13,
|
|
|
|
|
a: 0.95,
|
|
|
|
|
})),
|
|
|
|
|
border: Border {
|
2026-05-12 10:36:15 +03:00
|
|
|
color: Color {
|
|
|
|
|
r: 0.35,
|
|
|
|
|
g: 0.35,
|
|
|
|
|
b: 0.35,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
},
|
2026-04-07 19:30:25 +03:00
|
|
|
width: 1.0,
|
|
|
|
|
radius: 3.0.into(),
|
|
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
})
|
|
|
|
|
.padding([4, 8]),
|
|
|
|
|
TipPos::Top,
|
|
|
|
|
)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
// ── OSNAP split button ────────────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// Left part ("⚡ OSNAP"): toggles the global snap on/off.
|
|
|
|
|
// Right part ("▾"): opens the snap-type dropdown.
|
|
|
|
|
|
|
|
|
|
fn osnap_btn(active: bool, snap_enabled: bool, open: bool) -> Element<'static, Message> {
|
|
|
|
|
let bg = match (active || snap_enabled, open) {
|
|
|
|
|
(true, true) => SNAP_ON_HOVER,
|
|
|
|
|
(true, false) => SNAP_ON_BG,
|
|
|
|
|
(false, _) => SNAP_OFF_BG,
|
|
|
|
|
};
|
|
|
|
|
let border_color = if open {
|
|
|
|
|
ACCENT
|
|
|
|
|
} else if active {
|
|
|
|
|
SNAP_BORDER_ON
|
|
|
|
|
} else {
|
|
|
|
|
BORDER_COLOR
|
|
|
|
|
};
|
|
|
|
|
let text_color = if active {
|
|
|
|
|
OSNAP_ON_TEXT
|
|
|
|
|
} else {
|
|
|
|
|
OSNAP_OFF_TEXT
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-01 09:33:00 +03:00
|
|
|
let left = button(crate::ui::icons::tinted(
|
|
|
|
|
crate::ui::icons::ST_OSNAP,
|
|
|
|
|
17.0,
|
|
|
|
|
text_color,
|
|
|
|
|
))
|
2026-06-18 12:29:32 +03:00
|
|
|
.on_press(Message::ToggleSnapEnabled)
|
2026-03-22 14:56:27 +03:00
|
|
|
.style(move |_: &Theme, status| button::Style {
|
|
|
|
|
background: Some(Background::Color(match status {
|
|
|
|
|
button::Status::Hovered => {
|
|
|
|
|
if active || snap_enabled {
|
|
|
|
|
SNAP_ON_HOVER
|
|
|
|
|
} else {
|
|
|
|
|
SNAP_OFF_HOVER
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => bg,
|
|
|
|
|
})),
|
|
|
|
|
border: Border {
|
|
|
|
|
color: border_color,
|
|
|
|
|
width: 1.0,
|
|
|
|
|
radius: iced::border::Radius {
|
|
|
|
|
top_left: 2.0,
|
|
|
|
|
top_right: 0.0,
|
|
|
|
|
bottom_right: 0.0,
|
|
|
|
|
bottom_left: 2.0,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
text_color,
|
|
|
|
|
shadow: iced::Shadow::default(),
|
|
|
|
|
snap: false,
|
|
|
|
|
})
|
2026-07-01 09:33:00 +03:00
|
|
|
.padding([4, 6]);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
2026-07-01 09:33:00 +03:00
|
|
|
let right = button(crate::ui::icons::arrow_down(11.0, text_color))
|
2026-03-22 14:56:27 +03:00
|
|
|
.on_press(Message::ToggleSnapPopup)
|
|
|
|
|
.style(move |_: &Theme, status| button::Style {
|
|
|
|
|
background: Some(Background::Color(match status {
|
|
|
|
|
button::Status::Hovered => {
|
|
|
|
|
if active || snap_enabled {
|
|
|
|
|
SNAP_ON_HOVER
|
|
|
|
|
} else {
|
|
|
|
|
SNAP_OFF_HOVER
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => bg,
|
|
|
|
|
})),
|
|
|
|
|
border: Border {
|
|
|
|
|
color: border_color,
|
|
|
|
|
width: 1.0,
|
|
|
|
|
radius: iced::border::Radius {
|
|
|
|
|
top_left: 0.0,
|
|
|
|
|
top_right: 2.0,
|
|
|
|
|
bottom_right: 2.0,
|
|
|
|
|
bottom_left: 0.0,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
text_color,
|
|
|
|
|
shadow: iced::Shadow::default(),
|
|
|
|
|
snap: false,
|
|
|
|
|
})
|
2026-07-01 09:33:00 +03:00
|
|
|
.padding([4, 4]);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
|
|
|
row![
|
|
|
|
|
tip(left.into(), "Object Snap: toggle on/off\nF3"),
|
2026-06-25 14:11:06 +03:00
|
|
|
tip_node(
|
|
|
|
|
right.into(),
|
|
|
|
|
column![
|
|
|
|
|
text("Object Snap settings").size(11).color(Color::WHITE),
|
|
|
|
|
row![
|
|
|
|
|
text("Click").size(11).color(Color::WHITE),
|
|
|
|
|
crate::ui::icons::arrow_down(9.0, Color::WHITE),
|
|
|
|
|
text("to open the list").size(11).color(Color::WHITE),
|
|
|
|
|
]
|
|
|
|
|
.spacing(3)
|
|
|
|
|
.align_y(iced::Center),
|
|
|
|
|
]
|
|
|
|
|
.into(),
|
|
|
|
|
),
|
2026-03-22 14:56:27 +03:00
|
|
|
]
|
|
|
|
|
.spacing(0)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-31 07:13:36 +03:00
|
|
|
/// A layout tab button.
|
|
|
|
|
///
|
|
|
|
|
/// When `rename_edit` is `Some(value)` the tab shows an inline text input
|
|
|
|
|
/// instead of the normal button. The tab is not renameable when it is the
|
|
|
|
|
/// "Model" tab (callers simply never pass `Some` for that name).
|
2026-05-12 10:36:15 +03:00
|
|
|
fn space_tab<'a>(
|
|
|
|
|
label: String,
|
|
|
|
|
is_active: bool,
|
|
|
|
|
rename_edit: Option<&'a str>,
|
|
|
|
|
) -> Element<'a, Message> {
|
2026-03-31 07:13:36 +03:00
|
|
|
let bg = move |is_active: bool, hovered: bool| {
|
|
|
|
|
if is_active {
|
|
|
|
|
TAB_ACTIVE
|
|
|
|
|
} else if hovered {
|
|
|
|
|
TAB_HOVER
|
|
|
|
|
} else {
|
|
|
|
|
Color::TRANSPARENT
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let border = Border {
|
2026-05-12 10:36:15 +03:00
|
|
|
color: if is_active {
|
|
|
|
|
ACCENT
|
|
|
|
|
} else {
|
|
|
|
|
Color::TRANSPARENT
|
|
|
|
|
},
|
2026-03-31 07:13:36 +03:00
|
|
|
width: if is_active { 1.0 } else { 0.0 },
|
|
|
|
|
radius: 2.0.into(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let text_color = if is_active {
|
|
|
|
|
Color::WHITE
|
|
|
|
|
} else {
|
2026-05-12 10:36:15 +03:00
|
|
|
Color {
|
|
|
|
|
r: 0.65,
|
|
|
|
|
g: 0.65,
|
|
|
|
|
b: 0.65,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
}
|
2026-03-31 07:13:36 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if let Some(edit_val) = rename_edit {
|
|
|
|
|
// Inline rename text input with a cancel (✕) button.
|
|
|
|
|
let input = text_input("", edit_val)
|
2026-06-12 14:18:31 +03:00
|
|
|
.id(iced::widget::Id::new(LAYOUT_RENAME_INPUT_ID))
|
2026-03-31 07:13:36 +03:00
|
|
|
.on_input(Message::LayoutRenameEdit)
|
|
|
|
|
.on_submit(Message::LayoutRenameCommit)
|
2026-07-01 09:33:00 +03:00
|
|
|
.size(12)
|
2026-03-31 07:13:36 +03:00
|
|
|
.style(|_: &Theme, _| text_input::Style {
|
|
|
|
|
background: Background::Color(TAB_ACTIVE),
|
|
|
|
|
border: Border {
|
|
|
|
|
color: ACCENT,
|
|
|
|
|
width: 1.0,
|
|
|
|
|
radius: 2.0.into(),
|
2026-03-22 14:56:27 +03:00
|
|
|
},
|
2026-03-31 07:13:36 +03:00
|
|
|
icon: Color::WHITE,
|
2026-05-12 10:36:15 +03:00
|
|
|
placeholder: Color {
|
|
|
|
|
r: 0.5,
|
|
|
|
|
g: 0.5,
|
|
|
|
|
b: 0.5,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
},
|
2026-03-31 07:13:36 +03:00
|
|
|
value: Color::WHITE,
|
2026-05-12 10:36:15 +03:00
|
|
|
selection: Color {
|
|
|
|
|
r: 0.20,
|
|
|
|
|
g: 0.55,
|
|
|
|
|
b: 0.90,
|
|
|
|
|
a: 0.4,
|
|
|
|
|
},
|
2026-03-31 07:13:36 +03:00
|
|
|
})
|
2026-07-01 09:33:00 +03:00
|
|
|
.padding([3, 6])
|
2026-03-31 07:13:36 +03:00
|
|
|
.width(Length::Fixed(90.0));
|
|
|
|
|
|
2026-06-18 12:29:32 +03:00
|
|
|
let cancel_btn = button(crate::ui::icons::tinted(
|
|
|
|
|
crate::ui::icons::CLOSE,
|
|
|
|
|
10.0,
|
|
|
|
|
Color {
|
|
|
|
|
r: 0.65,
|
|
|
|
|
g: 0.65,
|
|
|
|
|
b: 0.65,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
},
|
|
|
|
|
))
|
2026-03-31 07:13:36 +03:00
|
|
|
.on_press(Message::LayoutRenameCancel)
|
|
|
|
|
.style(|_: &Theme, _| button::Style {
|
|
|
|
|
background: Some(Background::Color(Color::TRANSPARENT)),
|
|
|
|
|
border: Border::default(),
|
2026-03-22 14:56:27 +03:00
|
|
|
shadow: iced::Shadow::default(),
|
|
|
|
|
snap: false,
|
2026-03-31 07:13:36 +03:00
|
|
|
..Default::default()
|
2026-03-22 14:56:27 +03:00
|
|
|
})
|
2026-07-01 09:33:00 +03:00
|
|
|
.padding([4, 4]);
|
2026-03-31 07:13:36 +03:00
|
|
|
|
2026-05-12 10:36:15 +03:00
|
|
|
row![input, cancel_btn]
|
|
|
|
|
.spacing(0)
|
|
|
|
|
.align_y(iced::Center)
|
|
|
|
|
.into()
|
2026-03-31 07:13:36 +03:00
|
|
|
} else {
|
|
|
|
|
// Normal clickable tab — left click switches, right click opens context menu.
|
2026-07-01 09:33:00 +03:00
|
|
|
let display = container(text(label.clone()).size(12).color(text_color))
|
2026-05-12 10:36:15 +03:00
|
|
|
.style(move |_: &Theme| container::Style {
|
|
|
|
|
background: Some(Background::Color(bg(is_active, false))),
|
|
|
|
|
border,
|
|
|
|
|
..Default::default()
|
|
|
|
|
})
|
2026-07-01 09:33:00 +03:00
|
|
|
.padding([4, 10]);
|
2026-03-31 07:13:36 +03:00
|
|
|
|
|
|
|
|
let switch_msg = Message::LayoutSwitch(label.clone());
|
|
|
|
|
let ctx_msg = Message::LayoutContextMenu(label.clone());
|
|
|
|
|
|
|
|
|
|
// Use mouse_area so we can capture right-click for the context menu.
|
|
|
|
|
mouse_area(display)
|
|
|
|
|
.on_press(switch_msg)
|
|
|
|
|
.on_right_press(ctx_msg)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
}
|
|
|
|
|
|
2026-04-23 09:18:01 +03:00
|
|
|
/// Space-mode toggle button in the status bar.
|
|
|
|
|
///
|
|
|
|
|
/// - Model tab → "MODEL" (non-clickable, informational)
|
|
|
|
|
/// - Layout, PSPACE → "PAPER" (click → MspaceCommand: enter MSPACE)
|
|
|
|
|
/// - Layout, MSPACE → "MODEL" (click → ExitViewport: return to PSPACE)
|
|
|
|
|
fn space_mode_btn(current_layout: &str, in_mspace: bool) -> Element<'static, Message> {
|
|
|
|
|
let is_model_tab = current_layout == "Model";
|
|
|
|
|
|
|
|
|
|
// Labels and styling follow AutoCAD convention:
|
|
|
|
|
// PAPER = currently in paper-space editing
|
|
|
|
|
// MODEL = currently in model-space editing (either the Model tab or MSPACE)
|
|
|
|
|
let (label, active, on_press) = if is_model_tab {
|
2026-04-23 13:57:34 +03:00
|
|
|
("MODEL", false, None::<Message>)
|
2026-04-23 09:18:01 +03:00
|
|
|
} else if in_mspace {
|
2026-05-01 21:06:41 +03:00
|
|
|
("MODEL", true, Some(Message::ExitViewport))
|
2026-04-23 09:18:01 +03:00
|
|
|
} else {
|
|
|
|
|
("PAPER", false, Some(Message::MspaceCommand))
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-12 10:36:15 +03:00
|
|
|
let text_color = if active {
|
|
|
|
|
SNAP_BORDER_ON
|
|
|
|
|
} else {
|
|
|
|
|
OSNAP_OFF_TEXT
|
|
|
|
|
};
|
2026-04-23 09:18:01 +03:00
|
|
|
let bg_normal = if active { SNAP_ON_BG } else { SNAP_OFF_BG };
|
2026-05-12 10:36:15 +03:00
|
|
|
let bg_hover = if active {
|
|
|
|
|
SNAP_ON_HOVER
|
|
|
|
|
} else {
|
|
|
|
|
SNAP_OFF_HOVER
|
|
|
|
|
};
|
2026-04-23 09:18:01 +03:00
|
|
|
let border_color = if active { SNAP_BORDER_ON } else { BORDER_COLOR };
|
|
|
|
|
|
|
|
|
|
let clickable = on_press.is_some();
|
2026-07-01 09:33:00 +03:00
|
|
|
let mut btn = button(text(label).size(12).color(text_color))
|
2026-04-23 09:18:01 +03:00
|
|
|
.style(move |_: &Theme, status| button::Style {
|
|
|
|
|
background: Some(Background::Color(match status {
|
|
|
|
|
button::Status::Hovered if clickable => bg_hover,
|
|
|
|
|
_ => bg_normal,
|
|
|
|
|
})),
|
|
|
|
|
border: Border {
|
|
|
|
|
color: border_color,
|
|
|
|
|
width: 1.0,
|
|
|
|
|
radius: 2.0.into(),
|
|
|
|
|
},
|
|
|
|
|
text_color,
|
|
|
|
|
shadow: iced::Shadow::default(),
|
|
|
|
|
snap: false,
|
|
|
|
|
})
|
2026-07-01 09:33:00 +03:00
|
|
|
.padding([4, 7]);
|
2026-04-23 09:18:01 +03:00
|
|
|
|
|
|
|
|
if let Some(msg) = on_press {
|
|
|
|
|
btn = btn.on_press(msg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
btn.into()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 07:17:45 +03:00
|
|
|
fn status_pill(label: impl Into<String>) -> Element<'static, Message> {
|
2026-07-01 09:33:00 +03:00
|
|
|
container(text(label.into()).size(12).color(Color {
|
2026-03-22 14:56:27 +03:00
|
|
|
r: 0.65,
|
|
|
|
|
g: 0.65,
|
|
|
|
|
b: 0.65,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
}))
|
|
|
|
|
.style(|_: &Theme| container::Style {
|
|
|
|
|
background: Some(Background::Color(PILL_BG)),
|
|
|
|
|
border: Border {
|
|
|
|
|
color: BORDER_COLOR,
|
|
|
|
|
width: 1.0,
|
|
|
|
|
radius: 2.0.into(),
|
|
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
})
|
2026-07-01 09:33:00 +03:00
|
|
|
.padding([4, 8])
|
2026-03-22 14:56:27 +03:00
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Colours ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const BAR_BG: Color = Color {
|
|
|
|
|
r: 0.14,
|
|
|
|
|
g: 0.14,
|
|
|
|
|
b: 0.14,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
};
|
|
|
|
|
const TAB_ACTIVE: Color = Color {
|
|
|
|
|
r: 0.25,
|
|
|
|
|
g: 0.25,
|
|
|
|
|
b: 0.25,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
};
|
|
|
|
|
const TAB_HOVER: Color = Color {
|
|
|
|
|
r: 0.20,
|
|
|
|
|
g: 0.20,
|
|
|
|
|
b: 0.20,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
};
|
|
|
|
|
const PILL_BG: Color = Color {
|
|
|
|
|
r: 0.19,
|
|
|
|
|
g: 0.19,
|
|
|
|
|
b: 0.19,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
};
|
|
|
|
|
const BORDER_COLOR: Color = Color {
|
|
|
|
|
r: 0.28,
|
|
|
|
|
g: 0.28,
|
|
|
|
|
b: 0.28,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
};
|
|
|
|
|
const ICON_COLOR: Color = Color {
|
|
|
|
|
r: 0.70,
|
|
|
|
|
g: 0.70,
|
|
|
|
|
b: 0.70,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
};
|
|
|
|
|
const ACCENT: Color = Color {
|
|
|
|
|
r: 0.20,
|
|
|
|
|
g: 0.55,
|
|
|
|
|
b: 0.90,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const OSNAP_ON_TEXT: Color = Color {
|
|
|
|
|
r: 0.35,
|
|
|
|
|
g: 0.75,
|
|
|
|
|
b: 1.00,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
};
|
|
|
|
|
const OSNAP_OFF_TEXT: Color = Color {
|
|
|
|
|
r: 0.42,
|
|
|
|
|
g: 0.42,
|
|
|
|
|
b: 0.42,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
};
|
|
|
|
|
const SNAP_ON_BG: Color = Color {
|
|
|
|
|
r: 0.10,
|
|
|
|
|
g: 0.20,
|
|
|
|
|
b: 0.32,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
};
|
|
|
|
|
const SNAP_ON_HOVER: Color = Color {
|
|
|
|
|
r: 0.14,
|
|
|
|
|
g: 0.27,
|
|
|
|
|
b: 0.42,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
};
|
|
|
|
|
const SNAP_BORDER_ON: Color = Color {
|
|
|
|
|
r: 0.20,
|
|
|
|
|
g: 0.50,
|
|
|
|
|
b: 0.85,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
};
|
|
|
|
|
const SNAP_OFF_BG: Color = Color {
|
|
|
|
|
r: 0.17,
|
|
|
|
|
g: 0.17,
|
|
|
|
|
b: 0.17,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
};
|
|
|
|
|
const SNAP_OFF_HOVER: Color = Color {
|
|
|
|
|
r: 0.22,
|
|
|
|
|
g: 0.22,
|
|
|
|
|
b: 0.22,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
};
|
2026-03-31 07:17:45 +03:00
|
|
|
|
2026-05-06 17:50:46 +03:00
|
|
|
// ── Scale popup button ────────────────────────────────────────────────────
|
|
|
|
|
|
2026-06-04 22:06:42 +03:00
|
|
|
fn units_btn(label: &str, open: bool) -> Element<'static, Message> {
|
|
|
|
|
let label = label.to_string();
|
|
|
|
|
button(
|
|
|
|
|
text(label)
|
2026-07-01 09:33:00 +03:00
|
|
|
.size(12)
|
2026-06-04 22:06:42 +03:00
|
|
|
.color(if open { SNAP_BORDER_ON } else { OSNAP_OFF_TEXT }),
|
|
|
|
|
)
|
|
|
|
|
.on_press(Message::ToggleUnitsPopup)
|
|
|
|
|
.style(move |_: &Theme, status| button::Style {
|
|
|
|
|
background: Some(Background::Color(match (open, status) {
|
|
|
|
|
(true, button::Status::Hovered) => SNAP_ON_HOVER,
|
|
|
|
|
(true, _) => SNAP_ON_BG,
|
|
|
|
|
(false, button::Status::Hovered) => SNAP_OFF_HOVER,
|
|
|
|
|
(false, _) => SNAP_OFF_BG,
|
|
|
|
|
})),
|
|
|
|
|
border: Border {
|
|
|
|
|
color: if open { SNAP_BORDER_ON } else { BORDER_COLOR },
|
|
|
|
|
width: 1.0,
|
|
|
|
|
radius: 2.0.into(),
|
|
|
|
|
},
|
|
|
|
|
text_color: if open { SNAP_BORDER_ON } else { OSNAP_OFF_TEXT },
|
|
|
|
|
shadow: iced::Shadow::default(),
|
|
|
|
|
snap: false,
|
|
|
|
|
})
|
2026-07-01 09:33:00 +03:00
|
|
|
.padding([4, 7])
|
2026-06-04 22:06:42 +03:00
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 17:50:46 +03:00
|
|
|
fn scale_popup_btn(label: &str, open: bool) -> Element<'static, Message> {
|
|
|
|
|
let label = label.to_string();
|
2026-05-12 10:36:15 +03:00
|
|
|
button(
|
|
|
|
|
text(label)
|
2026-07-01 09:33:00 +03:00
|
|
|
.size(12)
|
2026-05-12 10:36:15 +03:00
|
|
|
.color(if open { SNAP_BORDER_ON } else { OSNAP_OFF_TEXT }),
|
|
|
|
|
)
|
|
|
|
|
.on_press(Message::ToggleScalePopup)
|
|
|
|
|
.style(move |_: &Theme, status| button::Style {
|
|
|
|
|
background: Some(Background::Color(match (open, status) {
|
|
|
|
|
(true, button::Status::Hovered) => SNAP_ON_HOVER,
|
|
|
|
|
(true, _) => SNAP_ON_BG,
|
|
|
|
|
(false, button::Status::Hovered) => SNAP_OFF_HOVER,
|
|
|
|
|
(false, _) => SNAP_OFF_BG,
|
|
|
|
|
})),
|
|
|
|
|
border: Border {
|
|
|
|
|
color: if open { SNAP_BORDER_ON } else { BORDER_COLOR },
|
|
|
|
|
width: 1.0,
|
|
|
|
|
radius: 2.0.into(),
|
|
|
|
|
},
|
|
|
|
|
text_color: if open { SNAP_BORDER_ON } else { OSNAP_OFF_TEXT },
|
|
|
|
|
shadow: iced::Shadow::default(),
|
|
|
|
|
snap: false,
|
|
|
|
|
})
|
2026-07-01 09:33:00 +03:00
|
|
|
.padding([4, 7])
|
2026-05-12 10:36:15 +03:00
|
|
|
.into()
|
2026-05-06 17:50:46 +03:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 07:17:45 +03:00
|
|
|
// ── Scale display ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Formats a viewport scale factor as a human-readable ratio string.
|
|
|
|
|
///
|
|
|
|
|
/// - `None` → "1:1" (model space or no viewport yet)
|
|
|
|
|
/// - `1.0` → "1:1"
|
|
|
|
|
/// - `0.02` → "1:50"
|
|
|
|
|
/// - `2.0` → "2:1"
|
|
|
|
|
fn format_scale(scale: Option<f64>) -> String {
|
|
|
|
|
let s = match scale {
|
|
|
|
|
None => return "1:1".to_string(),
|
|
|
|
|
Some(v) if v <= 0.0 => return "1:1".to_string(),
|
|
|
|
|
Some(v) => v,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Try to express as a clean integer ratio.
|
|
|
|
|
if s >= 1.0 {
|
|
|
|
|
let n = s.round() as u32;
|
|
|
|
|
if (s - n as f64).abs() < 0.01 * s {
|
|
|
|
|
return if n == 1 {
|
|
|
|
|
"1:1".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
format!("{}:1", n)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
let inv = (1.0 / s).round() as u32;
|
|
|
|
|
if (s - 1.0 / inv as f64).abs() < 0.01 * s {
|
|
|
|
|
return format!("1:{}", inv);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fall back to a decimal string.
|
2026-05-12 10:36:15 +03:00
|
|
|
format!("{:.4}", s)
|
|
|
|
|
.trim_end_matches('0')
|
|
|
|
|
.trim_end_matches('.')
|
|
|
|
|
.to_string()
|
2026-03-31 07:17:45 +03:00
|
|
|
}
|