Files
OneCAD-Draft/src/ui/statusbar/mod.rs
T

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, &current_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,
&current_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(&current_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())
}