feat(cursor): add crosshair options

Add persistent cursor sizing, pick aperture, color, pointer mode, isometric drafting planes, and SNAPANG-aligned grid behavior.\n\nRefs #413
This commit is contained in:
Hakan Seven committed 2026-08-08 21:32:44 +03:00
1 parent 188ac80b2e
commit 1738f9a79b
25 files changed
+989 -126

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
use crate::app::settings::IsoPlane;
use crate::app::Message;
use crate::snap::{Snapper, ALL_SNAP_MODES};
use iced::widget::{button, checkbox, column, container, row, scrollable, text, Space};
use iced::{Element, Fill};
pub fn view_window<'a>(
snapper: &'a Snapper,
grid: bool,
grid_snap: bool,
ortho: bool,
polar: bool,
otrack: bool,
isometric: bool,
iso_plane: IsoPlane,
snap_angle_deg: f32,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let toggle = |value: bool, label: std::borrow::Cow<'a, str>, message: Message| {
row![
checkbox(value).on_toggle(move |_| message.clone()).size(15),
text(label).size(12),
]
.spacing(7)
.align_y(iced::Center)
};
let drafting_modes = column![
text(crate::t!("Drafting modes")).size(15),
Space::new().height(8),
toggle(grid, crate::t!("Grid display"), Message::ToggleGrid),
toggle(grid_snap, crate::t!("Grid snap"), Message::ToggleGridSnap),
toggle(ortho, crate::t!("Ortho"), Message::ToggleOrtho),
toggle(polar, crate::t!("Polar tracking"), Message::TogglePolar),
toggle(otrack, crate::t!("Object snap tracking"), Message::ToggleOTrack),
]
.spacing(6);
let mut planes = row![].spacing(5);
for plane in IsoPlane::ALL {
planes = planes.push(
button(text(crate::t!(plane.label())).size(11))
.on_press(Message::SetIsoPlane(plane))
.style(if isometric && iso_plane == plane {
button::primary
} else {
button::secondary
})
.padding([5, 12]),
);
}
let isometric_controls = column![
text(crate::t!("Isometric drafting")).size(15),
Space::new().height(8),
toggle(
isometric,
crate::t!("Enable isometric drafting"),
Message::ToggleIsometricDrafting,
),
planes,
text(crate::t!("F5 cycles Left, Top, and Right.")).size(11),
Space::new().height(6),
row![
text(crate::t!("Rotation: %{angle}°", angle = snap_angle_deg)).size(11),
button(text(crate::t!("Reset rotation")).size(10))
.on_press(Message::ResetDraftingRotation)
.style(button::secondary)
.padding([4, 10]),
]
.spacing(10)
.align_y(iced::Center),
]
.spacing(7);
let mut snap_modes = column![
text(crate::t!("Object snap modes")).size(15),
Space::new().height(8),
toggle(
snapper.snap_enabled,
crate::t!("Enable object snap"),
Message::ToggleSnapEnabled,
),
row![
button(text(crate::t!("Select All")).size(10))
.on_press(Message::SnapSelectAll)
.style(button::secondary)
.padding([4, 10]),
button(text(crate::t!("Clear All")).size(10))
.on_press(Message::SnapClearAll)
.style(button::secondary)
.padding([4, 10]),
]
.spacing(6),
]
.spacing(5);
for &(snap_type, _, label) in ALL_SNAP_MODES {
snap_modes = snap_modes.push(
row![
checkbox(snapper.is_on(snap_type))
.on_toggle(move |_| Message::ToggleSnap(snap_type))
.size(14),
text(crate::t!(label)).size(11),
]
.spacing(7)
.align_y(iced::Center),
);
}
let close = button(text(crate::tr!("action-close")).size(12))
.on_press(Message::CloseModal)
.padding([6, 18])
.style(button::secondary);
let content = column![
drafting_modes,
Space::new().height(20),
isometric_controls,
Space::new().height(20),
snap_modes,
]
.width(sizing.width);
let body = column![
scrollable(content).spacing(8).height(sizing.height),
Space::new().height(12),
row![Space::new().width(Fill), close],
]
.width(sizing.width)
.height(sizing.height);
container(body)
.style(container::rounded_box)
.padding([16, 18])
.width(sizing.width)
.height(sizing.height)
.into()
}
+1
View File
@@ -3,6 +3,7 @@ pub mod block_palette;
pub mod layout_manager;
pub mod layer_state_manager;
pub mod drawing_units;
pub mod drafting_settings;
pub mod layer_translator;
pub mod plot;
pub mod print_all;
+138 -4
View File
@@ -1,11 +1,20 @@
use crate::app::config::UiThemeConfig;
use crate::app::settings::CursorType;
use crate::app::Message;
use iced::widget::{
button, column, container, row, scrollable, text, text_input, Space,
button, column, container, row, scrollable, slider, text, text_input, Space,
};
use iced::{Background, Border, Element, Theme};
use iced::{Background, Border, Element, Fill, Theme};
use std::fmt;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OptionsTab {
#[default]
General,
Display,
Selection,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Labelled<T> {
value: T,
@@ -24,6 +33,12 @@ pub fn view_window<'a>(
ui_theme: &'a UiThemeConfig,
theme_color_inputs: &'a [String; 6],
language: crate::i18n::Language,
active_tab: OptionsTab,
cursor_size: i32,
pick_box: i32,
cursor_type: CursorType,
crosshair_color: Option<[u8; 3]>,
crosshair_color_input: &'a str,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let selected_format = crate::io::SAVE_FORMAT_OPTIONS
@@ -62,6 +77,18 @@ pub fn view_window<'a>(
.find(|choice| choice.value == language)
.cloned();
let cursor_options = CursorType::ALL
.into_iter()
.map(|value: CursorType| Labelled {
label: crate::t!(value.label()).into_owned(),
value,
})
.collect::<Vec<_>>();
let selected_cursor = cursor_options
.iter()
.find(|choice| choice.value == cursor_type)
.cloned();
let palette = ui_theme.palette.to_iced();
let colors = [
(crate::tr!("options-color-background"), palette.background),
@@ -104,7 +131,7 @@ pub fn view_window<'a>(
.padding([6, 18])
.style(button::secondary);
let content = column![
let general = column![
text(crate::tr!("options-language-section")).size(15),
Space::new().height(10),
row![
@@ -154,7 +181,29 @@ pub fn view_window<'a>(
))
.size(11)
.width(sizing.width),
Space::new().height(22),
]
.spacing(0)
.width(sizing.width);
let crosshair_rgb = crosshair_color.unwrap_or([255, 255, 255]);
let crosshair_swatch = container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(iced::Color::from_rgb8(
crosshair_rgb[0],
crosshair_rgb[1],
crosshair_rgb[2],
))),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
let display = column![
text(crate::tr!("options-theme-section")).size(15),
Space::new().height(10),
row![
@@ -175,11 +224,96 @@ pub fn view_window<'a>(
.width(sizing.width),
Space::new().height(12),
color_controls,
Space::new().height(24),
text(crate::t!("Crosshair")).size(15),
Space::new().height(10),
row![
text(crate::t!("Crosshair size")).size(12).width(140),
slider(1..=100, cursor_size.clamp(1, 100), Message::CursorSizeChanged)
.step(1)
.width(Fill),
text(format!("{}%", cursor_size.clamp(1, 100)))
.size(11)
.width(44),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(10),
row![
text(crate::t!("Cursor type")).size(12).width(140),
iced::widget::pick_list(
selected_cursor,
cursor_options,
|choice| choice.label.clone(),
)
.on_select(|choice| Message::CursorTypeChanged(choice.value))
.width(Fill),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(10),
row![
text(crate::t!("Crosshair color")).size(12).width(140),
crosshair_swatch,
text_input(crate::t!("#RRGGBB or blank").as_ref(), crosshair_color_input)
.on_input(Message::CrosshairColorChanged)
.width(150),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(6),
text(crate::t!("Leave the color blank to keep automatic viewport contrast."))
.size(11)
.width(sizing.width),
]
.spacing(0)
.width(sizing.width);
let selection = column![
text(crate::t!("Selection")).size(15),
Space::new().height(10),
row![
text(crate::t!("Pick box size")).size(12).width(140),
slider(0..=50, pick_box.clamp(0, 50), Message::PickBoxChanged)
.step(1)
.width(Fill),
text(pick_box.clamp(0, 50).to_string()).size(11).width(44),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(8),
text(crate::t!(
"Controls both the visible selection box and the click aperture."
))
.size(11)
.width(sizing.width),
]
.spacing(0)
.width(sizing.width);
let content: Element<'a, Message> = match active_tab {
OptionsTab::General => general.into(),
OptionsTab::Display => display.into(),
OptionsTab::Selection => selection.into(),
};
let tab_button = |label, tab| {
let selected = active_tab == tab;
button(text(label).size(12))
.on_press(Message::OptionsTabChanged(tab))
.padding([6, 14])
.style(if selected { button::primary } else { button::secondary })
};
let tabs = row![
tab_button(crate::t!("General"), OptionsTab::General),
tab_button(crate::t!("Display"), OptionsTab::Display),
tab_button(crate::t!("Selection"), OptionsTab::Selection),
]
.spacing(6);
let body = column![
tabs,
Space::new().height(12),
// Keep the scrollbar in its own lane instead of floating over the
// controls at the trailing edge of the Options content.
scrollable(content).spacing(8).height(sizing.height),