forked from 1b-agent/opencadstudio-upstream
Add a click_hits_all hit-test and an SC pill that, when on, makes repeated clicks at the same spot step through the overlapping objects there (nearest first) instead of accumulating the selection. The whole cycling path is gated behind the toggle, so default picking is unchanged when it is off. Co-Authored-By: Claude Opus 4.8 <[email protected]>
907 lines
30 KiB
Rust
907 lines
30 KiB
Rust
//! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info
|
|
|
|
use iced::widget::tooltip::Position as TipPos;
|
|
use iced::widget::{button, container, mouse_area, row, text, text_input, tooltip, Row};
|
|
use iced::{Background, Border, Color, Element, Length, Theme};
|
|
|
|
use crate::app::Message;
|
|
use crate::snap::Snapper;
|
|
use crate::ui::statusbar_config::{StatusBarConfig, StatusPill};
|
|
|
|
#[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,
|
|
polar_increment_deg: f32,
|
|
show_grid: bool,
|
|
dyn_input: bool,
|
|
otrack: bool,
|
|
layouts: Vec<String>,
|
|
current_layout: String,
|
|
// If `Some((original, edit_value))`, the named tab shows a text input.
|
|
rename_state: Option<&'a (String, String)>,
|
|
// Scale of the first user viewport in the active paper layout.
|
|
viewport_scale: Option<f64>,
|
|
// Number of user viewports in the current paper layout (0 = model space).
|
|
viewport_count: usize,
|
|
// True when the user is editing inside a paper-space viewport (MSPACE).
|
|
in_mspace: bool,
|
|
// Whether the layout tabs (Model/Paper) are visible (LAYOUTTAB).
|
|
show_layout_tabs: bool,
|
|
// 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,
|
|
// LWDISPLAY header flag — controls lineweight visibility in the viewport.
|
|
lineweight_display: bool,
|
|
// 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,
|
|
// Drawing units (INSUNITS) for the units pill.
|
|
insertion_units: i16,
|
|
// True while the drawing-units picker is open.
|
|
units_popup_open: bool,
|
|
// True when objects are hidden by Isolate / Hide.
|
|
isolation_active: bool,
|
|
// Whether entity transparency is shown (Transparency pill state).
|
|
transparency_display: bool,
|
|
// Whether the Quick Properties floating panel is enabled.
|
|
quick_properties: bool,
|
|
// True when the selection filter is excluding at least one type.
|
|
selection_filter_active: bool,
|
|
// Whether selection cycling is enabled.
|
|
selection_cycling: bool,
|
|
// Which pills the user has chosen to show on the bar.
|
|
config: &'a StatusBarConfig,
|
|
) -> Element<'a, Message> {
|
|
let menu_btn = button(text("≡").size(14).color(ICON_COLOR))
|
|
.on_press(Message::Command("MENU".into()))
|
|
.style(|_: &Theme, _| button::Style {
|
|
background: Some(Background::Color(Color::TRANSPARENT)),
|
|
..Default::default()
|
|
})
|
|
.padding([2, 8]);
|
|
|
|
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()
|
|
})
|
|
.padding([3, 7]);
|
|
|
|
// ── Right side ────────────────────────────────────────────────────
|
|
let osnap_active = snapper.is_active();
|
|
let snap_grid_on = snapper.is_on(crate::snap::SnapType::Grid) && snapper.snap_enabled;
|
|
|
|
let vp_label = if viewport_count > 0 {
|
|
format!("{} VP", viewport_count)
|
|
} else {
|
|
String::new()
|
|
};
|
|
// 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 {
|
|
tip(
|
|
scale_popup_btn(&scale_label, scale_popup_open),
|
|
"Annotation / Viewport Scale\nClick to change",
|
|
)
|
|
} else {
|
|
status_pill(scale_label).into()
|
|
};
|
|
// Build the right-side pills, honouring the user's per-pill visibility.
|
|
let vis = |p: StatusPill| config.is_visible(p);
|
|
let mut right_status = Row::new().spacing(2).align_y(iced::Center);
|
|
if vis(StatusPill::Coords) {
|
|
right_status = right_status.push(tip(
|
|
status_pill(format_coords(cursor_world)).into(),
|
|
"Cursor coordinates (X, Y, Z)",
|
|
));
|
|
}
|
|
if vis(StatusPill::Snap) {
|
|
right_status = right_status.push(tip(
|
|
toggle_pill("SNAP", snap_grid_on, Message::ToggleGridSnap),
|
|
"Snap to Grid\nF9",
|
|
));
|
|
}
|
|
if vis(StatusPill::Grid) {
|
|
right_status = right_status.push(tip(
|
|
toggle_pill("GRID", show_grid, Message::ToggleGrid),
|
|
"Show Grid\nF7",
|
|
));
|
|
}
|
|
if vis(StatusPill::Ortho) {
|
|
right_status = right_status.push(tip(
|
|
toggle_pill("ORTHO", ortho_mode, Message::ToggleOrtho),
|
|
"Orthogonal Mode\nF8",
|
|
));
|
|
}
|
|
if vis(StatusPill::Lwt) {
|
|
right_status = right_status.push(tip(
|
|
toggle_pill("LWT", lineweight_display, Message::ToggleLineweightDisplay),
|
|
"Show Lineweight\nLWDISPLAY",
|
|
));
|
|
}
|
|
if vis(StatusPill::Polar) {
|
|
right_status = right_status.push(polar_pill(polar_mode, polar_increment_deg));
|
|
}
|
|
if vis(StatusPill::Dyn) {
|
|
right_status = right_status.push(tip(
|
|
toggle_pill("DYN", dyn_input, Message::ToggleDynInput),
|
|
"Dynamic Input\nF12",
|
|
));
|
|
}
|
|
if vis(StatusPill::Otrack) {
|
|
right_status = right_status.push(tip(
|
|
toggle_pill("OTRACK", otrack, Message::ToggleOTrack),
|
|
"Object Snap Tracking\nF11",
|
|
));
|
|
}
|
|
if vis(StatusPill::Osnap) {
|
|
right_status =
|
|
right_status.push(osnap_btn(osnap_active, snapper.snap_enabled, popup_open));
|
|
}
|
|
if vis(StatusPill::Space) {
|
|
right_status = right_status.push(tip(
|
|
space_mode_btn(¤t_layout, in_mspace),
|
|
"PAPER: double-click viewport to enter MSPACE\nMODEL: click to switch to Model Space",
|
|
));
|
|
}
|
|
if vis(StatusPill::Scale) {
|
|
right_status = right_status.push(scale_element);
|
|
}
|
|
if vis(StatusPill::Units) {
|
|
right_status = right_status.push(tip(
|
|
units_btn(
|
|
crate::ui::units_popup::unit_short(insertion_units),
|
|
units_popup_open,
|
|
),
|
|
"Drawing Units (INSUNITS)\nClick to change",
|
|
));
|
|
}
|
|
if vis(StatusPill::Transparency) {
|
|
right_status = right_status.push(tip(
|
|
toggle_pill(
|
|
"TPY",
|
|
transparency_display,
|
|
Message::ToggleTransparencyDisplay,
|
|
),
|
|
"Show Transparency\nForce opaque when off",
|
|
));
|
|
}
|
|
if vis(StatusPill::Isolate) {
|
|
right_status = right_status.push(tip(
|
|
toggle_pill("ISO", isolation_active, Message::ToggleIsolatePopup),
|
|
"Isolate Objects\nClick for Isolate / Hide / End",
|
|
));
|
|
}
|
|
if vis(StatusPill::QuickProps) {
|
|
right_status = right_status.push(tip(
|
|
toggle_pill("QP", quick_properties, Message::ToggleQuickProperties),
|
|
"Quick Properties\nFloating panel on selection",
|
|
));
|
|
}
|
|
if vis(StatusPill::SelFilter) {
|
|
right_status = right_status.push(tip(
|
|
toggle_pill(
|
|
"FILTER",
|
|
selection_filter_active,
|
|
Message::ToggleSelectionFilterPopup,
|
|
),
|
|
"Selection Filtering\nLimit which object types can be picked",
|
|
));
|
|
}
|
|
if vis(StatusPill::SelCycle) {
|
|
right_status = right_status.push(tip(
|
|
toggle_pill("SC", selection_cycling, Message::ToggleSelectionCycling),
|
|
"Selection Cycling\nRepeat-click to step through overlapping objects",
|
|
));
|
|
}
|
|
if vis(StatusPill::Vp) && !vp_label.is_empty() {
|
|
right_status = right_status.push(tip(
|
|
status_pill(vp_label).into(),
|
|
"Viewport count in active layout",
|
|
));
|
|
}
|
|
if vis(StatusPill::CleanScreen) {
|
|
right_status = right_status.push(tip(
|
|
toggle_pill("CLEAN", clean_screen, Message::ToggleCleanScreen),
|
|
"Clean Screen\nHide ribbon and panels",
|
|
));
|
|
}
|
|
// Customization handle: opens the pill show/hide menu.
|
|
right_status = right_status.push(tip(
|
|
customize_btn(),
|
|
"Customization\nShow or hide status-bar items",
|
|
));
|
|
let right_status = right_status;
|
|
|
|
let mut bar = Row::new().align_y(iced::Center).spacing(0);
|
|
bar = bar.push(menu_btn);
|
|
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());
|
|
bar = bar.push(space_tab(name, is_active, renaming));
|
|
}
|
|
bar = bar.push(add_btn);
|
|
}
|
|
bar = bar.push(iced::widget::Space::new().width(Length::Fill));
|
|
bar = bar.push(right_status);
|
|
|
|
container(bar)
|
|
.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)
|
|
.height(26)
|
|
.padding([0, 4])
|
|
.into()
|
|
}
|
|
}
|
|
|
|
// ── 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> {
|
|
button(text("≡").size(14).color(ICON_COLOR))
|
|
.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()
|
|
})
|
|
.padding([2, 7])
|
|
.into()
|
|
}
|
|
|
|
// ── Tooltip helper ────────────────────────────────────────────────────────
|
|
|
|
fn tip<'a>(content: Element<'a, Message>, label: &'static str) -> Element<'a, Message> {
|
|
tooltip(
|
|
content,
|
|
container(text(label).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.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 ────────────────────────────────────────────────────
|
|
|
|
fn toggle_pill(label: &'static str, active: bool, msg: Message) -> Element<'static, Message> {
|
|
button(text(label).size(10).color(if active {
|
|
OSNAP_ON_TEXT
|
|
} else {
|
|
OSNAP_OFF_TEXT
|
|
}))
|
|
.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: if active {
|
|
OSNAP_ON_TEXT
|
|
} else {
|
|
OSNAP_OFF_TEXT
|
|
},
|
|
shadow: iced::Shadow::default(),
|
|
snap: false,
|
|
})
|
|
.padding([2, 6])
|
|
.into()
|
|
}
|
|
|
|
// ── 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> {
|
|
let label = format!("POLAR {:.0}°", increment_deg);
|
|
let tooltip_text = format!(
|
|
"Polar Tracking ({}°)\nF10 — left-click on/off\nRight-click to change angle",
|
|
increment_deg as u32
|
|
);
|
|
|
|
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,
|
|
};
|
|
|
|
// 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,
|
|
};
|
|
|
|
let inner = container(text(label).size(10).color(if active {
|
|
OSNAP_ON_TEXT
|
|
} else {
|
|
OSNAP_OFF_TEXT
|
|
}))
|
|
.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()
|
|
})
|
|
.padding([2, 6]);
|
|
|
|
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 {
|
|
color: Color {
|
|
r: 0.35,
|
|
g: 0.35,
|
|
b: 0.35,
|
|
a: 1.0,
|
|
},
|
|
width: 1.0,
|
|
radius: 3.0.into(),
|
|
},
|
|
..Default::default()
|
|
})
|
|
.padding([4, 8]),
|
|
TipPos::Top,
|
|
)
|
|
.into()
|
|
}
|
|
|
|
// ── 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
|
|
};
|
|
|
|
let left = button(text("⚡ OSNAP").size(10).color(text_color))
|
|
.on_press(Message::ToggleSnapEnabled)
|
|
.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,
|
|
})
|
|
.padding([2, 6]);
|
|
|
|
let right = button(text("▾").size(9).color(text_color))
|
|
.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,
|
|
})
|
|
.padding([2, 4]);
|
|
|
|
row![
|
|
tip(left.into(), "Object Snap: toggle on/off\nF3"),
|
|
tip(right.into(), "Object Snap settings\nClick ▾"),
|
|
]
|
|
.spacing(0)
|
|
.into()
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
/// 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).
|
|
fn space_tab<'a>(
|
|
label: String,
|
|
is_active: bool,
|
|
rename_edit: Option<&'a str>,
|
|
) -> Element<'a, Message> {
|
|
let bg = move |is_active: bool, hovered: bool| {
|
|
if is_active {
|
|
TAB_ACTIVE
|
|
} else if hovered {
|
|
TAB_HOVER
|
|
} else {
|
|
Color::TRANSPARENT
|
|
}
|
|
};
|
|
|
|
let border = Border {
|
|
color: if is_active {
|
|
ACCENT
|
|
} else {
|
|
Color::TRANSPARENT
|
|
},
|
|
width: if is_active { 1.0 } else { 0.0 },
|
|
radius: 2.0.into(),
|
|
};
|
|
|
|
let text_color = if is_active {
|
|
Color::WHITE
|
|
} else {
|
|
Color {
|
|
r: 0.65,
|
|
g: 0.65,
|
|
b: 0.65,
|
|
a: 1.0,
|
|
}
|
|
};
|
|
|
|
if let Some(edit_val) = rename_edit {
|
|
// Inline rename text input with a cancel (✕) button.
|
|
let input = text_input("", edit_val)
|
|
.on_input(Message::LayoutRenameEdit)
|
|
.on_submit(Message::LayoutRenameCommit)
|
|
.size(11)
|
|
.style(|_: &Theme, _| text_input::Style {
|
|
background: Background::Color(TAB_ACTIVE),
|
|
border: Border {
|
|
color: ACCENT,
|
|
width: 1.0,
|
|
radius: 2.0.into(),
|
|
},
|
|
icon: Color::WHITE,
|
|
placeholder: Color {
|
|
r: 0.5,
|
|
g: 0.5,
|
|
b: 0.5,
|
|
a: 1.0,
|
|
},
|
|
value: Color::WHITE,
|
|
selection: Color {
|
|
r: 0.20,
|
|
g: 0.55,
|
|
b: 0.90,
|
|
a: 0.4,
|
|
},
|
|
})
|
|
.padding([2, 6])
|
|
.width(Length::Fixed(90.0));
|
|
|
|
let cancel_btn = button(text("✕").size(10).color(Color {
|
|
r: 0.65,
|
|
g: 0.65,
|
|
b: 0.65,
|
|
a: 1.0,
|
|
}))
|
|
.on_press(Message::LayoutRenameCancel)
|
|
.style(|_: &Theme, _| button::Style {
|
|
background: Some(Background::Color(Color::TRANSPARENT)),
|
|
border: Border::default(),
|
|
shadow: iced::Shadow::default(),
|
|
snap: false,
|
|
..Default::default()
|
|
})
|
|
.padding([2, 4]);
|
|
|
|
row![input, cancel_btn]
|
|
.spacing(0)
|
|
.align_y(iced::Center)
|
|
.into()
|
|
} else {
|
|
// Normal clickable tab — left click switches, right click opens context menu.
|
|
let display = container(text(label.clone()).size(11).color(text_color))
|
|
.style(move |_: &Theme| container::Style {
|
|
background: Some(Background::Color(bg(is_active, false))),
|
|
border,
|
|
..Default::default()
|
|
})
|
|
.padding([3, 10]);
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
("MODEL", false, None::<Message>)
|
|
} else if in_mspace {
|
|
("MODEL", true, Some(Message::ExitViewport))
|
|
} else {
|
|
("PAPER", false, Some(Message::MspaceCommand))
|
|
};
|
|
|
|
let text_color = if active {
|
|
SNAP_BORDER_ON
|
|
} else {
|
|
OSNAP_OFF_TEXT
|
|
};
|
|
let bg_normal = if active { SNAP_ON_BG } else { SNAP_OFF_BG };
|
|
let bg_hover = if active {
|
|
SNAP_ON_HOVER
|
|
} else {
|
|
SNAP_OFF_HOVER
|
|
};
|
|
let border_color = if active { SNAP_BORDER_ON } else { BORDER_COLOR };
|
|
|
|
let clickable = on_press.is_some();
|
|
let mut btn = button(text(label).size(10).color(text_color))
|
|
.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,
|
|
})
|
|
.padding([2, 6]);
|
|
|
|
if let Some(msg) = on_press {
|
|
btn = btn.on_press(msg);
|
|
}
|
|
|
|
btn.into()
|
|
}
|
|
|
|
fn status_pill(label: impl Into<String>) -> Element<'static, Message> {
|
|
container(text(label.into()).size(10).color(Color {
|
|
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()
|
|
})
|
|
.padding([2, 6])
|
|
.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,
|
|
};
|
|
|
|
// ── Scale popup button ────────────────────────────────────────────────────
|
|
|
|
fn units_btn(label: &str, open: bool) -> Element<'static, Message> {
|
|
let label = label.to_string();
|
|
button(
|
|
text(label)
|
|
.size(10)
|
|
.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,
|
|
})
|
|
.padding([2, 6])
|
|
.into()
|
|
}
|
|
|
|
fn scale_popup_btn(label: &str, open: bool) -> Element<'static, Message> {
|
|
let label = label.to_string();
|
|
button(
|
|
text(label)
|
|
.size(10)
|
|
.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,
|
|
})
|
|
.padding([2, 6])
|
|
.into()
|
|
}
|
|
|
|
// ── 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.
|
|
format!("{:.4}", s)
|
|
.trim_end_matches('0')
|
|
.trim_end_matches('.')
|
|
.to_string()
|
|
}
|