forked from 1b-agent/opencadstudio-upstream
1219 lines
45 KiB
Rust
1219 lines
45 KiB
Rust
//! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info
|
|
|
|
pub mod status_menu;
|
|
pub(crate) mod spacemouse;
|
|
pub mod statusbar_config;
|
|
pub mod statusbar_menu;
|
|
|
|
use iced::widget::tooltip::Position as TipPos;
|
|
use iced::widget::{button, column, container, mouse_area, row, text, text_input, tooltip};
|
|
use iced::{Background, Border, Color, Element, Length, Theme};
|
|
use iced_aw::ContextMenu;
|
|
use std::sync::Arc;
|
|
|
|
/// 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).
|
|
pub const LAYOUT_TABS_SCROLL_ID: &str = "statusbar-layout-tabs";
|
|
|
|
/// 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";
|
|
|
|
use crate::app::Message;
|
|
use crate::snap::Snapper;
|
|
use crate::t;
|
|
use crate::ui::statusbar::status_menu::Entry as StatusMenuEntry;
|
|
use crate::ui::statusbar::statusbar_config::{StatusBarConfig, StatusPill};
|
|
use crate::ui::wrap_bar::WrapBar;
|
|
|
|
/// Height of one status-bar row. Matches the drawing-tab strip above it so the
|
|
/// three horizontal strips — tabs, status bar, command line — line up, and it
|
|
/// is also the reach a status-bar menu keeps around itself (see
|
|
/// `status_menu::menu_bar`).
|
|
pub const ROW_HEIGHT: f32 = 30.0;
|
|
|
|
const ST_ANNO_VISIBILITY: &[u8] = include_bytes!("../../../assets/icons/scale_list.svg");
|
|
const ST_ANNO_AUTO_ADD: &[u8] = include_bytes!("../../../assets/icons/add_scale.svg");
|
|
const ST_VP_SCALE_SYNC: &[u8] = include_bytes!("../../../assets/icons/sync.svg");
|
|
|
|
pub struct StatusMenuData<'a> {
|
|
pub layout_names: Vec<String>,
|
|
pub polar_custom_input: &'a str,
|
|
pub scale_is_model: bool,
|
|
pub current_scale_name: String,
|
|
pub scale_list: Vec<(String, f32, f64)>,
|
|
pub has_selection: bool,
|
|
pub selection_types: Vec<String>,
|
|
pub selection_filter: &'a rustc_hash::FxHashSet<String>,
|
|
pub tooltip_hidden: bool,
|
|
}
|
|
|
|
#[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,
|
|
ortho_mode: bool,
|
|
polar_mode: bool,
|
|
polar_increment_deg: f32,
|
|
dyn_input: bool,
|
|
otrack: bool,
|
|
isometric_drafting: bool,
|
|
iso_plane: crate::app::settings::IsoPlane,
|
|
layouts: Vec<String>,
|
|
block_tabs: Vec<String>,
|
|
reorderable_layouts: Vec<String>,
|
|
current_layout: String,
|
|
active_block: Option<String>,
|
|
// Start/welcome view has no drawing to own layouts.
|
|
is_start: bool,
|
|
// 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 pill is interactive (always model space; paper space only when a viewport is active/selected).
|
|
scale_pill_enabled: bool,
|
|
annotation_all_visible: bool,
|
|
annotation_auto_add: bool,
|
|
viewport_scale_synced: Option<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::DVec3,
|
|
// $COORDS readout mode: 0 = static (updates only on a pick), 1 = live
|
|
// absolute, 2 = polar (distance<angle from the last point while picking).
|
|
coords_mode: i16,
|
|
// The last committed point, for the static (0) and polar (2) readouts.
|
|
last_point: Option<glam::DVec3>,
|
|
// True while a command is prompting for a point (enables the polar readout).
|
|
picking: bool,
|
|
// True while clean-screen mode hides the ribbon and side panels.
|
|
clean_screen: bool,
|
|
// LUNITS — how lengths are written, for the units pill.
|
|
linear_format: i16,
|
|
// 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,
|
|
menu_data: StatusMenuData<'a>,
|
|
// Remaining degrees of freedom for the current parametric scope.
|
|
// `None` when the scope
|
|
// has no `ParametricConstraintSet` yet (nothing constrained), so the
|
|
// badge stays invisible until it's actually relevant.
|
|
parametric_dof: Option<usize>,
|
|
// Number of redundant/conflicting constraints in the current scope.
|
|
// Zero hides
|
|
// the pill entirely, so an ordinarily/fully-constrained drawing sees
|
|
// no new clutter.
|
|
parametric_conflicts: usize,
|
|
// What is drawing the scene. Only a degraded verdict shows anything.
|
|
gpu_status: &'a crate::scene::pipeline::GpuStatus,
|
|
spacemouse: Option<Element<'a, Message>>,
|
|
) -> Element<'a, Message> {
|
|
let StatusMenuData {
|
|
layout_names,
|
|
polar_custom_input,
|
|
scale_is_model,
|
|
current_scale_name,
|
|
scale_list,
|
|
has_selection,
|
|
selection_types,
|
|
selection_filter,
|
|
tooltip_hidden,
|
|
} = menu_data;
|
|
|
|
// Leftmost hamburger: opens a dropdown listing Model + every layout, so
|
|
// a layout can be picked directly even when the tab strip is scrolled.
|
|
let menu_icon = if is_start {
|
|
crate::ui::icons::themed_disabled(crate::ui::icons::MENU, 16.0)
|
|
} else {
|
|
crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 16.0)
|
|
};
|
|
let menu_button = button(menu_icon).style(button::subtle).padding([4, 8]);
|
|
let menu_btn = if is_start {
|
|
tip(
|
|
menu_button.into(),
|
|
t!("Open or create a drawing to manage layouts."),
|
|
)
|
|
} else {
|
|
status_menu::menu_bar(
|
|
menu_tip(
|
|
menu_button
|
|
.on_press(Message::StatusMenuTooltipHidden(true))
|
|
.into(),
|
|
t!("Model and layout list"),
|
|
tooltip_hidden,
|
|
),
|
|
statusbar_menu::layout_entries(&layout_names, ¤t_layout),
|
|
200.0,
|
|
)
|
|
};
|
|
|
|
let add_button = button(text("+").size(12))
|
|
.style(button::subtle)
|
|
.padding([4, 8]);
|
|
let add_btn = if is_start {
|
|
tip(
|
|
add_button.into(),
|
|
t!("Open or create a drawing to add a layout."),
|
|
)
|
|
} else {
|
|
add_button.on_press(Message::LayoutCreate).into()
|
|
};
|
|
|
|
// ── Right side ────────────────────────────────────────────────────
|
|
let osnap_active = snapper.is_active();
|
|
|
|
let vp_label = if viewport_count > 0 {
|
|
t!("%{n} VP", n = viewport_count).into_owned()
|
|
} 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.
|
|
// Keep its text identical to the active drawing-defined scale. Rebuilding
|
|
// the label from the numeric factor turns an architectural
|
|
// `1/2" = 1'-0"` scale into `1:24`, mixing formats in the same control.
|
|
let scale_label = if current_scale_name.is_empty() {
|
|
active_scale_label(
|
|
scale_is_model,
|
|
annotation_scale,
|
|
viewport_scale,
|
|
&scale_list,
|
|
)
|
|
.unwrap_or_else(|| {
|
|
if scale_is_model {
|
|
format_scale(Some(1.0 / annotation_scale as f64))
|
|
} else {
|
|
format_scale(viewport_scale)
|
|
}
|
|
})
|
|
} else {
|
|
current_scale_name.clone()
|
|
};
|
|
let scale_element: Element<'_, Message> = if scale_pill_enabled {
|
|
status_menu::menu_bar(
|
|
menu_tip(
|
|
popup_pill(&scale_label),
|
|
t!("Annotation / Viewport Scale\nClick to change"),
|
|
tooltip_hidden,
|
|
),
|
|
crate::ui::popup::scale_popup::menu_entries(
|
|
scale_is_model,
|
|
¤t_scale_name,
|
|
viewport_scale,
|
|
scale_list,
|
|
),
|
|
if scale_is_model { 150.0 } else { 120.0 },
|
|
)
|
|
} else {
|
|
status_pill(scale_label).into()
|
|
};
|
|
// Build the right-side pills, honouring the user's per-pill visibility.
|
|
// 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.
|
|
let vis = |p: StatusPill| config.is_visible(p);
|
|
let mut pills: Vec<Element<'_, Message>> = Vec::new();
|
|
if vis(StatusPill::SpaceMouse) {
|
|
if let Some(pill) = spacemouse {
|
|
pills.push(pill);
|
|
}
|
|
}
|
|
// Not a `StatusPill` and not hideable: the scene is on a software
|
|
// rasterizer or not drawn at all, and the popup that said so has been
|
|
// dismissed. This stays for the session and reopens it. Many people
|
|
// never read the command line, so this is the one place the fact
|
|
// remains visible.
|
|
if let Some(label) = gpu_pill_label(gpu_status) {
|
|
let detail = match gpu_status {
|
|
crate::scene::pipeline::GpuStatus::Software(adapter) => {
|
|
format!("{}\n{}", adapter.name, crate::tr!("gpu", "pill-tip"))
|
|
}
|
|
_ => crate::tr!("gpu", "pill-tip"),
|
|
};
|
|
pills.push(tip(warning_pill(label, Message::GpuWarningOpen), detail.into()).into());
|
|
}
|
|
if vis(StatusPill::Coords) {
|
|
let coords_label = format_coords(cursor_world, last_point, coords_mode, picking);
|
|
pills.push(
|
|
tip(
|
|
action_pill(&coords_label, Message::CycleCoordsMode),
|
|
t!("Cursor coordinates ($COORDS)\nClick to cycle: static / live / polar"),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
// Not a `StatusPill` (so not user-hideable yet): invisible until the
|
|
// current scope actually has a ParametricConstraintSet, so a drawing
|
|
// that never uses parametric constraints sees no new clutter.
|
|
if let Some(dof) = parametric_dof {
|
|
let pill = if dof == 0 {
|
|
success_pill(crate::tf!("DOF: {dof}").into_owned())
|
|
} else {
|
|
status_pill(crate::tf!("DOF: {dof}").into_owned())
|
|
};
|
|
pills.push(
|
|
tip(
|
|
pill,
|
|
if dof == 0 {
|
|
t!("Fully constrained — no remaining degrees of freedom")
|
|
} else {
|
|
t!("Remaining degrees of freedom in the current parametric constraint scope")
|
|
},
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
// Each click removes one flagged constraint.
|
|
if parametric_conflicts > 0 {
|
|
pills.push(
|
|
tip(
|
|
action_pill(
|
|
crate::tf!("⚠ {parametric_conflicts} conflicting").into_owned(),
|
|
Message::ResolveOneParametricConflict,
|
|
),
|
|
t!("One or more parametric constraints conflict or are redundant\nClick to remove one and re-solve"),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::Ortho) {
|
|
pills.push(
|
|
tip(
|
|
toggle_pill(crate::ui::icons::ST_ORTHO, ortho_mode, Message::ToggleOrtho),
|
|
t!("Orthogonal Mode\nF8"),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::Lwt) {
|
|
pills.push(
|
|
tip(
|
|
toggle_pill(
|
|
crate::ui::icons::ST_LWT,
|
|
lineweight_display,
|
|
Message::ToggleLineweightDisplay,
|
|
),
|
|
t!("Show Lineweight\nLWDISPLAY"),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::Polar) {
|
|
pills.push(
|
|
polar_pill(
|
|
polar_mode,
|
|
polar_increment_deg,
|
|
tooltip_hidden,
|
|
crate::ui::popup::polar_popup::menu_entries(
|
|
polar_increment_deg,
|
|
polar_custom_input,
|
|
),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::Dyn) {
|
|
pills.push(
|
|
tip(
|
|
toggle_pill(crate::ui::icons::ST_DYN, dyn_input, Message::ToggleDynInput),
|
|
t!("Dynamic Input\nF12"),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::Otrack) {
|
|
pills.push(
|
|
tip(
|
|
toggle_pill(crate::ui::icons::ST_OTRACK, otrack, Message::ToggleOTrack),
|
|
t!("Object Snap Tracking\nF11"),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::Osnap) {
|
|
pills.push(
|
|
osnap_btn(
|
|
osnap_active,
|
|
snapper.snap_enabled,
|
|
tooltip_hidden,
|
|
crate::ui::popup::snap_popup::menu_entries(
|
|
snapper,
|
|
isometric_drafting,
|
|
iso_plane,
|
|
),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::Space) {
|
|
pills.push(
|
|
tip(
|
|
space_mode_btn(¤t_layout, in_mspace),
|
|
t!("PAPER: double-click viewport to enter MSPACE\nMODEL: click to switch to Model Space"),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::Scale) {
|
|
pills.push(scale_element);
|
|
}
|
|
if vis(StatusPill::AnnoVisibility) {
|
|
pills.push(
|
|
tip(
|
|
toggle_pill(
|
|
ST_ANNO_VISIBILITY,
|
|
annotation_all_visible,
|
|
Message::ToggleAnnotationVisibility,
|
|
),
|
|
t!("Show Annotation Objects"),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::AnnoAutoAdd) {
|
|
pills.push(
|
|
tip(
|
|
toggle_pill(
|
|
ST_ANNO_AUTO_ADD,
|
|
annotation_auto_add,
|
|
Message::ToggleAnnotationAutoAdd,
|
|
),
|
|
t!("Automatically Add Scales"),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::VpScaleSync) {
|
|
if let Some(synced) = viewport_scale_synced {
|
|
pills.push(
|
|
tip(
|
|
toggle_pill(
|
|
ST_VP_SCALE_SYNC,
|
|
synced,
|
|
Message::SyncViewportAnnotationScale,
|
|
),
|
|
t!("Viewport / Annotation Scale Sync"),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
}
|
|
if vis(StatusPill::Units) {
|
|
pills.push(
|
|
status_menu::menu_bar(
|
|
menu_tip(
|
|
popup_pill(t!(crate::modules::draw::units::linear_format_short(
|
|
linear_format
|
|
))),
|
|
t!("Units (LUNITS)\nHow lengths are written\nClick to change"),
|
|
tooltip_hidden,
|
|
),
|
|
crate::ui::popup::units_popup::menu_entries(linear_format),
|
|
140.0,
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::Transparency) {
|
|
pills.push(
|
|
tip(
|
|
toggle_pill(
|
|
crate::ui::icons::ST_TRANSPARENCY,
|
|
transparency_display,
|
|
Message::ToggleTransparencyDisplay,
|
|
),
|
|
t!("Show Transparency\nForce opaque when off"),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::Isolate) {
|
|
pills.push(
|
|
status_menu::menu_bar(
|
|
menu_tip(
|
|
toggle_pill(
|
|
crate::ui::icons::ST_ISOLATE,
|
|
isolation_active,
|
|
Message::StatusMenuTooltipHidden(true),
|
|
),
|
|
t!("Isolate Objects\nClick for Isolate / Hide / End"),
|
|
tooltip_hidden,
|
|
),
|
|
crate::ui::popup::isolate_popup::menu_entries(has_selection, isolation_active),
|
|
160.0,
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::QuickProps) {
|
|
pills.push(
|
|
tip(
|
|
toggle_pill(
|
|
crate::ui::icons::ST_QUICKPROPS,
|
|
quick_properties,
|
|
Message::ToggleQuickProperties,
|
|
),
|
|
t!("Quick Properties\nFloating panel on selection"),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::SelFilter) {
|
|
pills.push(
|
|
status_menu::menu_bar(
|
|
menu_tip(
|
|
toggle_pill(
|
|
crate::ui::icons::ST_FILTER,
|
|
selection_filter_active,
|
|
Message::StatusMenuTooltipHidden(true),
|
|
),
|
|
t!("Selection Filtering\nLimit which object types can be picked"),
|
|
tooltip_hidden,
|
|
),
|
|
crate::ui::popup::selection_filter_popup::menu_entries(
|
|
selection_types,
|
|
selection_filter,
|
|
),
|
|
180.0,
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::SelCycle) {
|
|
pills.push(
|
|
tip(
|
|
toggle_pill(
|
|
crate::ui::icons::ST_SELCYCLE,
|
|
selection_cycling,
|
|
Message::ToggleSelectionCycling,
|
|
),
|
|
t!("Selection Cycling\nRepeat-click to step through overlapping objects"),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::Vp) && !vp_label.is_empty() {
|
|
pills.push(
|
|
tip(
|
|
status_pill(vp_label).into(),
|
|
t!("Viewport count in active layout"),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
if vis(StatusPill::CleanScreen) {
|
|
pills.push(
|
|
tip(
|
|
toggle_pill(
|
|
crate::ui::icons::ST_CLEANSCREEN,
|
|
clean_screen,
|
|
Message::ToggleCleanScreen,
|
|
),
|
|
t!("Clean Screen\nHide ribbon and panels"),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
// Customization handle: opens the pill show/hide menu.
|
|
pills.push(
|
|
status_menu::menu_bar(
|
|
menu_tip(
|
|
customize_btn(),
|
|
t!("Customization\nShow or hide status-bar items"),
|
|
tooltip_hidden,
|
|
),
|
|
statusbar_menu::customization_entries(config),
|
|
200.0,
|
|
)
|
|
.into(),
|
|
);
|
|
let right_status = iced::widget::Row::with_children(pills)
|
|
.spacing(2.0)
|
|
.align_y(iced::Center)
|
|
.wrap()
|
|
.vertical_spacing(0.0)
|
|
.align_x(iced::alignment::Horizontal::Right);
|
|
|
|
// 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
|
|
// use the remaining space on the final tab row when they fit; otherwise
|
|
// WrapBar adds another right-aligned row.
|
|
let mut left: Vec<Element<'_, Message>> = Vec::new();
|
|
left.push(menu_btn);
|
|
if show_layout_tabs {
|
|
let reorderable_layouts: Arc<[String]> = reorderable_layouts.into();
|
|
for name in layouts {
|
|
let is_active = active_block.is_none() && name == current_layout;
|
|
let renaming = rename_state
|
|
.filter(|(orig, _)| *orig == name)
|
|
.map(|(_, edit)| edit.as_str());
|
|
let switch_msg = Message::LayoutSwitch(name.clone());
|
|
left.push(
|
|
space_tab(
|
|
name,
|
|
is_active,
|
|
renaming,
|
|
!is_start,
|
|
reorderable_layouts.clone(),
|
|
switch_msg,
|
|
"SB_LAYOUT_TAB",
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
for name in block_tabs {
|
|
let is_active = active_block.as_deref() == Some(name.as_str());
|
|
let switch_msg = Message::BlockEditSwitch(name.clone());
|
|
left.push(
|
|
space_tab(
|
|
name,
|
|
is_active,
|
|
None,
|
|
!is_start,
|
|
Arc::from(Vec::<String>::new()),
|
|
switch_msg,
|
|
"SB_BLOCK_TAB",
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
left.push(add_btn.into());
|
|
}
|
|
let left_area = iced::widget::Row::with_children(left)
|
|
.spacing(2.0)
|
|
.align_y(iced::Center)
|
|
.wrap()
|
|
.vertical_spacing(0.0);
|
|
|
|
let wrap = WrapBar::new(left_area.into(), right_status.into())
|
|
.min_row_h(ROW_HEIGHT)
|
|
.justify_end(true);
|
|
|
|
container(wrap)
|
|
.style(|theme: &Theme| {
|
|
let palette = theme.palette();
|
|
container::Style {
|
|
background: Some(Background::Color(palette.background.base.color)),
|
|
border: Border {
|
|
color: palette.background.neutral.color,
|
|
width: 1.0,
|
|
radius: 0.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
})
|
|
.width(Length::Fill)
|
|
// One row matches the drawing (document) tab bar height so the three
|
|
// horizontal strips — tabs, status bar, command line — line up.
|
|
// WrapBar vertically centres every pill (issue #216) and grows to a
|
|
// second row when the width can't hold both blocks.
|
|
.padding([0, 4])
|
|
.into()
|
|
}
|
|
}
|
|
|
|
// ── Coordinate readout ────────────────────────────────────────────────────
|
|
|
|
/// The readout, in the drawing's own linear units.
|
|
///
|
|
/// Lengths went out at four decimal places whatever the drawing asked for, so a
|
|
/// drawing set to architectural units read its coordinates in decimals, and one
|
|
/// asking for two places got four. `format_length` is the same helper the
|
|
/// properties panel formats through, so both now say a length the same way.
|
|
fn format_coords(
|
|
cursor: glam::DVec3,
|
|
last: Option<glam::DVec3>,
|
|
mode: i16,
|
|
picking: bool,
|
|
) -> String {
|
|
use crate::entities::common::format_length as len;
|
|
let abs = |p: glam::DVec3| format!("{}, {}, {}", len(p.x), len(p.y), len(p.z));
|
|
match mode {
|
|
// Static: show the last picked point; the readout freezes between picks.
|
|
0 => abs(last.unwrap_or(cursor)),
|
|
// Polar: distance < angle relative to the last point while a command is
|
|
// prompting for a point; absolute otherwise.
|
|
2 => match (picking, last) {
|
|
(true, Some(l)) => {
|
|
let d = cursor - l;
|
|
let dist = (d.x * d.x + d.y * d.y).sqrt();
|
|
// A bearing, so it is counted from the drawing's zero and runs
|
|
// the way the drawing says — and comes back out in the same
|
|
// form the polar input accepts.
|
|
let ang = d.y.atan2(d.x);
|
|
format!(
|
|
"{} < {}",
|
|
len(dist),
|
|
crate::entities::common::format_direction(ang)
|
|
)
|
|
}
|
|
_ => abs(cursor),
|
|
},
|
|
// 1 (default) and anything else: live absolute.
|
|
_ => abs(cursor),
|
|
}
|
|
}
|
|
|
|
// ── Customization handle ──────────────────────────────────────────────────
|
|
|
|
fn customize_btn() -> Element<'static, Message> {
|
|
button(crate::ui::icons::themed_secondary(
|
|
crate::ui::icons::MENU,
|
|
16.0,
|
|
))
|
|
.on_press(Message::StatusMenuTooltipHidden(true))
|
|
.style(button::subtle)
|
|
.padding([4, 8])
|
|
.into()
|
|
}
|
|
|
|
// ── Tooltip helper ────────────────────────────────────────────────────────
|
|
|
|
fn tip<'a>(
|
|
content: Element<'a, Message>,
|
|
label: std::borrow::Cow<'static, str>,
|
|
) -> Element<'a, Message> {
|
|
tip_node(content, text(label).size(11).into())
|
|
}
|
|
|
|
/// A menu root shows its tooltip until clicked. Moving from the root into the
|
|
/// opened menu resets suppression for the next hover without covering the menu.
|
|
fn menu_tip<'a>(
|
|
content: Element<'a, Message>,
|
|
label: std::borrow::Cow<'static, str>,
|
|
hidden: bool,
|
|
) -> Element<'a, Message> {
|
|
let content = if hidden { content } else { tip(content, label) };
|
|
|
|
mouse_area(content)
|
|
.on_exit(Message::StatusMenuTooltipHidden(false))
|
|
.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> {
|
|
tooltip(
|
|
content,
|
|
container(body)
|
|
.style(container::bordered_box)
|
|
.padding([4, 8]),
|
|
TipPos::Top,
|
|
)
|
|
.into()
|
|
}
|
|
|
|
// ── Simple toggle pill ────────────────────────────────────────────────────
|
|
|
|
/// 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.
|
|
fn toggle_pill(icon: &'static [u8], active: bool, msg: Message) -> Element<'static, Message> {
|
|
let icon = if active {
|
|
crate::ui::icons::themed_primary_weak_text(icon, 17.0)
|
|
} else {
|
|
crate::ui::icons::themed_secondary(icon, 17.0)
|
|
};
|
|
button(icon)
|
|
.on_press(msg)
|
|
.style(move |theme: &Theme, status| {
|
|
let mut style = button::subtle(theme, status);
|
|
if active {
|
|
let palette = theme.palette();
|
|
style.background = Some(Background::Color(palette.primary.weak.color));
|
|
style.text_color = palette.primary.weak.text;
|
|
style.border.color = match status {
|
|
button::Status::Hovered => palette.primary.strong.color,
|
|
_ => palette.primary.base.color,
|
|
};
|
|
style.border.width = 1.0;
|
|
}
|
|
style
|
|
})
|
|
.padding([4, 7])
|
|
.into()
|
|
}
|
|
|
|
// ── Split pill (toggle + dropdown caret) ──────────────────────────────────
|
|
//
|
|
// Shared chrome for every status-bar pill that pairs a toggle with a picker
|
|
// popup (OSNAP, POLAR, …): one outer border wraps the caller's `main` region
|
|
// and a dropdown caret, so both halves read as a single integrated control and
|
|
// the border / background / caret / sizing live in exactly ONE place.
|
|
|
|
/// Wrap a caller-built, already-click-wired `main` element together with its
|
|
/// menu-bearing dropdown caret.
|
|
fn split_pill<'a>(
|
|
main: Element<'static, Message>,
|
|
caret: Element<'a, Message>,
|
|
active: bool,
|
|
) -> Element<'a, Message> {
|
|
container(row![main, caret].spacing(3).align_y(iced::Center))
|
|
.style(move |theme: &Theme| {
|
|
let palette = theme.palette();
|
|
container::Style {
|
|
background: Some(Background::Color(if active {
|
|
palette.primary.weak.color
|
|
} else {
|
|
palette.background.weakest.color
|
|
})),
|
|
border: Border {
|
|
color: if active {
|
|
palette.primary.base.color
|
|
} else {
|
|
palette.background.neutral.color
|
|
},
|
|
width: 1.0,
|
|
radius: 2.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
})
|
|
.padding([4, 6])
|
|
.into()
|
|
}
|
|
|
|
// ── Polar tracking pill ───────────────────────────────────────────────────
|
|
//
|
|
// Main: left-click toggles polar on/off; right-click cycles the increment.
|
|
// Caret: opens the angle picker.
|
|
|
|
fn polar_pill<'a>(
|
|
active: bool,
|
|
increment_deg: f32,
|
|
tooltip_hidden: bool,
|
|
entries: Vec<StatusMenuEntry<'a>>,
|
|
) -> Element<'a, Message> {
|
|
let angle = crate::ui::popup::polar_popup::angle_label(increment_deg);
|
|
let tooltip_text = t!(
|
|
"Polar Tracking (%{angle})\nF10 — left-click on/off\nRight-click cycles · ▾ picks angle",
|
|
angle = angle,
|
|
);
|
|
|
|
// Right-click quick-cycles through the same increments the picker lists.
|
|
const CYCLE: &[f32] = &[90.0, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0, 1.0];
|
|
let next_angle = match CYCLE.iter().position(|&a| (a - increment_deg).abs() < 1e-3) {
|
|
Some(i) => CYCLE[(i + 1) % CYCLE.len()],
|
|
None => 45.0,
|
|
};
|
|
|
|
let polar_icon = if active {
|
|
crate::ui::icons::themed_primary_weak_text(crate::ui::icons::ST_POLAR, 17.0)
|
|
} else {
|
|
crate::ui::icons::themed_secondary(crate::ui::icons::ST_POLAR, 17.0)
|
|
};
|
|
let main = mouse_area(
|
|
row![
|
|
polar_icon,
|
|
text(angle).size(11).style(move |theme: &Theme| {
|
|
let palette = theme.palette();
|
|
text::Style {
|
|
color: Some(if active {
|
|
palette.primary.weak.text
|
|
} else {
|
|
palette.background.base.text.scale_alpha(0.72)
|
|
}),
|
|
}
|
|
}),
|
|
]
|
|
.spacing(2)
|
|
.align_y(iced::Center),
|
|
)
|
|
.on_press(Message::TogglePolar)
|
|
.on_right_press(Message::SetPolarAngle(next_angle));
|
|
let main = tooltip(
|
|
main,
|
|
container(text(tooltip_text).size(11))
|
|
.style(container::bordered_box)
|
|
.padding([4, 8]),
|
|
TipPos::Top,
|
|
);
|
|
|
|
let caret = status_menu::menu_bar(
|
|
menu_tip(
|
|
mouse_area(
|
|
container(if active {
|
|
crate::ui::icons::themed_primary_weak_arrow_down(9.0)
|
|
} else {
|
|
crate::ui::icons::themed_secondary_arrow_down(9.0)
|
|
})
|
|
.padding([4, 7]),
|
|
)
|
|
.on_press(Message::StatusMenuTooltipHidden(true))
|
|
.into(),
|
|
t!("Polar angle\nClick to choose"),
|
|
tooltip_hidden,
|
|
),
|
|
entries,
|
|
120.0,
|
|
);
|
|
|
|
split_pill(main.into(), caret, active)
|
|
}
|
|
|
|
// ── OSNAP pill ─────────────────────────────────────────────────────────────
|
|
//
|
|
// Main: toggles the global snap on/off. Caret: opens the snap-type dropdown.
|
|
|
|
fn osnap_btn<'a>(
|
|
active: bool,
|
|
snap_enabled: bool,
|
|
tooltip_hidden: bool,
|
|
entries: Vec<StatusMenuEntry<'a>>,
|
|
) -> Element<'a, Message> {
|
|
let on = active || snap_enabled;
|
|
let snap_icon = if on {
|
|
crate::ui::icons::themed_primary_weak_text(crate::ui::icons::ST_OSNAP, 17.0)
|
|
} else {
|
|
crate::ui::icons::themed_secondary(crate::ui::icons::ST_OSNAP, 17.0)
|
|
};
|
|
let main = tip(
|
|
mouse_area(snap_icon)
|
|
.on_press(Message::ToggleSnapEnabled)
|
|
.into(),
|
|
t!("Object Snap: toggle on/off\nF3"),
|
|
);
|
|
|
|
let caret = status_menu::menu_bar(
|
|
menu_tip(
|
|
mouse_area(
|
|
container(if on {
|
|
crate::ui::icons::themed_primary_weak_arrow_down(9.0)
|
|
} else {
|
|
crate::ui::icons::themed_secondary_arrow_down(9.0)
|
|
})
|
|
.padding([4, 7]),
|
|
)
|
|
.on_press(Message::StatusMenuTooltipHidden(true))
|
|
.into(),
|
|
t!("Object Snap list\nClick to choose snap types"),
|
|
tooltip_hidden,
|
|
),
|
|
entries,
|
|
210.0,
|
|
);
|
|
|
|
split_pill(main, caret, on)
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
fn layout_tab_context_menu(name: String) -> Element<'static, Message> {
|
|
let rename = mouse_area(
|
|
container(text(t!("Rename")).size(12))
|
|
.padding([4, 12])
|
|
.width(Length::Fill),
|
|
)
|
|
.on_press(Message::LayoutRenameStart(name.clone()))
|
|
.interaction(iced::mouse::Interaction::Pointer);
|
|
|
|
let delete = mouse_area(
|
|
container(text(t!("Delete")).size(12))
|
|
.padding([4, 12])
|
|
.width(Length::Fill),
|
|
)
|
|
.on_press(Message::LayoutDelete(name))
|
|
.interaction(iced::mouse::Interaction::Pointer);
|
|
|
|
container(column![rename, delete].spacing(0).width(160))
|
|
.style(container::bordered_box)
|
|
.padding([4, 0])
|
|
.into()
|
|
}
|
|
|
|
/// 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>,
|
|
enabled: bool,
|
|
reorderable_layouts: Arc<[String]>,
|
|
switch_msg: Message,
|
|
report_key_prefix: &'static str,
|
|
) -> Element<'a, Message> {
|
|
let tab_style = move |theme: &Theme| {
|
|
let palette = theme.palette();
|
|
let text_color = if !enabled {
|
|
palette.background.base.text.scale_alpha(0.42)
|
|
} else if is_active {
|
|
palette.primary.weak.text
|
|
} else {
|
|
palette.background.base.text.scale_alpha(0.72)
|
|
};
|
|
container::Style {
|
|
background: is_active.then_some(Background::Color(palette.primary.weak.color)),
|
|
border: Border {
|
|
color: if is_active {
|
|
palette.primary.base.color
|
|
} else {
|
|
Color::TRANSPARENT
|
|
},
|
|
width: if is_active { 1.0 } else { 0.0 },
|
|
radius: 2.0.into(),
|
|
},
|
|
text_color: Some(text_color),
|
|
..Default::default()
|
|
}
|
|
};
|
|
|
|
if !enabled {
|
|
let display = container(text(label.clone()).size(12))
|
|
.style(tab_style)
|
|
.padding([4, 10]);
|
|
crate::ui::wrap_bar::PosReport::owned(
|
|
format!("{report_key_prefix}:{label}"),
|
|
tip(
|
|
display.into(),
|
|
t!("Open or create a drawing to switch layouts."),
|
|
),
|
|
)
|
|
.into()
|
|
} else if let Some(edit_val) = rename_edit {
|
|
// Inline rename text input with a cancel (✕) button.
|
|
let input = text_input("", edit_val)
|
|
.id(iced::widget::Id::new(LAYOUT_RENAME_INPUT_ID))
|
|
.on_input(Message::LayoutRenameEdit)
|
|
.on_submit(Message::LayoutRenameCommit)
|
|
.size(12)
|
|
.padding([3, 6])
|
|
.width(Length::Fixed(90.0));
|
|
|
|
let cancel_btn = button(crate::ui::icons::themed_secondary(
|
|
crate::ui::icons::CLOSE,
|
|
10.0,
|
|
))
|
|
.on_press(Message::LayoutRenameCancel)
|
|
.style(button::subtle)
|
|
.padding([4, 4]);
|
|
|
|
row![input, cancel_btn]
|
|
.spacing(0)
|
|
.align_y(iced::Center)
|
|
.into()
|
|
} else {
|
|
// Normal clickable tab — left click switches. Paper-layout tabs are
|
|
// wrapped in `ContextMenu`, which owns right-click handling.
|
|
let display = container(text(label.clone()).size(12))
|
|
.style(tab_style)
|
|
.padding([4, 10]);
|
|
|
|
let has_context_menu = reorderable_layouts.contains(&label);
|
|
let report_key = format!("{report_key_prefix}:{label}");
|
|
let tab = mouse_area(display).on_press(switch_msg);
|
|
let tab: Element<'a, Message> = if has_context_menu {
|
|
crate::ui::wrap_bar::ReorderTab::layout(label.clone(), reorderable_layouts, tab).into()
|
|
} else {
|
|
tab.into()
|
|
};
|
|
|
|
let tab = if has_context_menu {
|
|
ContextMenu::new(tab, move || layout_tab_context_menu(label.clone())).into()
|
|
} else {
|
|
tab
|
|
};
|
|
crate::ui::wrap_bar::PosReport::owned(report_key, tab).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 the conventional drafting layout:
|
|
// 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 {
|
|
(t!("MODEL"), false, None::<Message>)
|
|
} else if in_mspace {
|
|
(t!("MODEL"), true, Some(Message::ExitViewport))
|
|
} else {
|
|
(t!("PAPER"), false, Some(Message::MspaceCommand))
|
|
};
|
|
|
|
let clickable = on_press.is_some();
|
|
let mut btn = button(text(label).size(12))
|
|
.style(move |theme: &Theme, status| {
|
|
let mut style = button::subtle(theme, status);
|
|
if active {
|
|
let palette = theme.palette();
|
|
style.background = Some(Background::Color(match status {
|
|
button::Status::Hovered if clickable => palette.primary.base.color,
|
|
_ => palette.primary.weak.color,
|
|
}));
|
|
style.text_color = palette.primary.weak.text;
|
|
style.border.color = palette.primary.base.color;
|
|
style.border.width = 1.0;
|
|
}
|
|
style
|
|
})
|
|
.padding([4, 7]);
|
|
|
|
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(12))
|
|
.style(container::bordered_box)
|
|
.padding([4, 8])
|
|
.into()
|
|
}
|
|
|
|
/// A success-colored status pill used when no degrees of freedom remain.
|
|
/// The status-bar text for a degraded graphics verdict; `None` when there is
|
|
/// nothing to say, which is the case the bar must not clutter.
|
|
fn gpu_pill_label(status: &crate::scene::pipeline::GpuStatus) -> Option<String> {
|
|
use crate::scene::pipeline::GpuStatus;
|
|
match status {
|
|
GpuStatus::Software(_) => Some(crate::tr!("gpu", "pill-software")),
|
|
GpuStatus::NoRenderer => Some(crate::tr!("gpu", "pill-no-renderer")),
|
|
GpuStatus::Unknown | GpuStatus::Hardware(_) => None,
|
|
}
|
|
}
|
|
|
|
/// A pill in the theme's warning colours: the only status-bar item that
|
|
/// means "something is wrong", so it must not look like a toggle.
|
|
fn warning_pill(label: impl Into<String>, msg: Message) -> Element<'static, Message> {
|
|
button(text(label.into()).size(12))
|
|
.on_press(msg)
|
|
.style(button::warning)
|
|
.padding([4, 8])
|
|
.into()
|
|
}
|
|
|
|
fn success_pill(label: impl Into<String>) -> Element<'static, Message> {
|
|
container(text(label.into()).size(12))
|
|
.style(|theme: &Theme| {
|
|
let palette = theme.palette();
|
|
container::Style {
|
|
background: Some(Background::Color(palette.success.weak.color)),
|
|
text_color: Some(palette.success.weak.text),
|
|
border: Border {
|
|
color: palette.success.base.color,
|
|
width: 1.0,
|
|
radius: 4.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
})
|
|
.padding([4, 8])
|
|
.into()
|
|
}
|
|
|
|
// ── Scale popup button ────────────────────────────────────────────────────
|
|
|
|
/// Shared visual root for labelled status-bar menus (units, scale, …).
|
|
fn popup_pill(label: impl Into<String>) -> Element<'static, Message> {
|
|
action_pill(label, Message::StatusMenuTooltipHidden(true))
|
|
}
|
|
|
|
fn action_pill(label: impl Into<String>, msg: Message) -> Element<'static, Message> {
|
|
let label = label.into();
|
|
button(text(label).size(12))
|
|
.on_press(msg)
|
|
.style(button::subtle)
|
|
.padding([4, 7])
|
|
.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()
|
|
}
|
|
|
|
fn active_scale_label(
|
|
is_model: bool,
|
|
annotation_scale: f32,
|
|
viewport_scale: Option<f64>,
|
|
scales: &[(String, f32, f64)],
|
|
) -> Option<String> {
|
|
scales
|
|
.iter()
|
|
.find(|(_, anno_scale, vp_scale)| {
|
|
if is_model {
|
|
(annotation_scale - *anno_scale).abs() < 0.001 * annotation_scale.max(0.001)
|
|
} else {
|
|
viewport_scale
|
|
.map(|current| (current - *vp_scale).abs() < 0.001 * vp_scale.max(0.001))
|
|
.unwrap_or(false)
|
|
}
|
|
})
|
|
.map(|(label, _, _)| label.clone())
|
|
}
|