forked from 1b-agent/opencadstudio-upstream
Merge main into Select-Color-Redesign
Keep both sides: main's ViewBackground colour target and PREVIEW icon next to this branch's Interface target and LIST/SEARCH icons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8
This commit is contained in:
231 files changed
+32217
-3423
No files matched your search
@@ -57,16 +57,13 @@ pub fn aci_string_to_color(s: &str) -> AcadColor {
|
||||
}
|
||||
|
||||
/// Display name for a colour: the standard name for ACI 1-9 / ByLayer /
|
||||
/// ByBlock, otherwise the "R,G,B" values (0-255) so unnamed palette colours
|
||||
/// read meaningfully.
|
||||
/// ByBlock, the index for any other palette colour and "R,G,B" for a true
|
||||
/// colour, so the two kinds read apart (#1748).
|
||||
pub fn color_display_name(c: AcadColor) -> String {
|
||||
let (_, label) = acad_color_display(c);
|
||||
if label == "Index" || label == "Custom" {
|
||||
match c {
|
||||
AcadColor::Index(i) => {
|
||||
let (r, g, b) = codec::types::aci_table::aci_to_rgb(i).unwrap_or((128, 128, 128));
|
||||
format!("{r},{g},{b}")
|
||||
}
|
||||
AcadColor::Index(i) => i.to_string(),
|
||||
AcadColor::Rgb { r, g, b } => format!("{r},{g},{b}"),
|
||||
_ => label.to_string(),
|
||||
}
|
||||
|
||||
+64
-12
@@ -191,6 +191,12 @@ pub struct CommandLine {
|
||||
/// in milliseconds (0–60000, default 3000). 0 means history never fades;
|
||||
/// the pinned step prompt still shows.
|
||||
fade_ms: u32,
|
||||
/// COMMANDLINEOPACITY: how opaque the command line's panels are, in
|
||||
/// percent (20–100, default 80), so the drawing shows through.
|
||||
opacity_percent: u8,
|
||||
/// Since when the pointer rests on the overlay history: lines still
|
||||
/// showing then stay until it leaves, and fade from that moment.
|
||||
hover_since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for CommandLine {
|
||||
@@ -217,6 +223,8 @@ impl Default for CommandLine {
|
||||
step_options: Vec::new(),
|
||||
cliprompt_lines: 3,
|
||||
fade_ms: DEFAULT_COMMANDLINE_FADE_MS,
|
||||
opacity_percent: 80,
|
||||
hover_since: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -509,12 +517,46 @@ impl CommandLine {
|
||||
self.fade_ms
|
||||
}
|
||||
|
||||
/// COMMANDLINEOPACITY mirror (percent, 20–100).
|
||||
pub fn set_opacity_percent(&mut self, percent: u8) {
|
||||
self.opacity_percent = percent.clamp(20, 100);
|
||||
}
|
||||
|
||||
pub fn opacity_percent(&self) -> u8 {
|
||||
self.opacity_percent
|
||||
}
|
||||
|
||||
fn fade_secs(&self) -> f32 {
|
||||
self.fade_ms as f32 / 1000.0
|
||||
}
|
||||
|
||||
fn entry_visible(&self, e: &HistoryEntry) -> bool {
|
||||
e.pinned || self.fade_ms == 0 || e.created_at.elapsed().as_secs_f32() < self.fade_secs()
|
||||
let held = self.hover_since.is_some_and(|since| {
|
||||
since.saturating_duration_since(e.created_at).as_secs_f32() < self.fade_secs()
|
||||
});
|
||||
e.pinned
|
||||
|| self.fade_ms == 0
|
||||
|| held
|
||||
|| e.created_at.elapsed().as_secs_f32() < self.fade_secs()
|
||||
}
|
||||
|
||||
/// The pointer entered or left the overlay history. Leaving restarts the
|
||||
/// fade of every line it held.
|
||||
pub fn set_history_hover(&mut self, hovered: bool) {
|
||||
if hovered {
|
||||
self.hover_since.get_or_insert_with(Instant::now);
|
||||
return;
|
||||
}
|
||||
let Some(since) = self.hover_since.take() else {
|
||||
return;
|
||||
};
|
||||
let fade = self.fade_secs();
|
||||
let now = Instant::now();
|
||||
for entry in &mut self.history {
|
||||
if since.saturating_duration_since(entry.created_at).as_secs_f32() < fade {
|
||||
entry.created_at = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Height of the overlaid prompt lines currently shown above the input
|
||||
@@ -1127,17 +1169,24 @@ impl CommandLine {
|
||||
// The transient recent lines and the full archive are two views of the
|
||||
// same history. Showing both while the archive is open creates a fake
|
||||
// second history region and visually disconnects the input row (#555).
|
||||
let opacity = f32::from(self.opacity_percent) / 100.0;
|
||||
let recent_history: Element<'a, Message> = if self.history_open || !has_recent_history {
|
||||
container(column![]).height(0).into()
|
||||
} else {
|
||||
container(history_rows)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(theme.palette().background.base.color)),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.padding([2, 0])
|
||||
.into()
|
||||
iced::widget::mouse_area(
|
||||
container(history_rows)
|
||||
.style(move |theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(
|
||||
theme.palette().background.base.color.scale_alpha(opacity),
|
||||
)),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.padding([2, 0]),
|
||||
)
|
||||
.on_enter(Message::CommandHistoryHover(true))
|
||||
.on_exit(Message::CommandHistoryHover(false))
|
||||
.into()
|
||||
};
|
||||
let history_divider: Element<'a, Message> = if self.history_open {
|
||||
rule::horizontal(1).into()
|
||||
@@ -1153,10 +1202,11 @@ impl CommandLine {
|
||||
recent_history,
|
||||
history_divider,
|
||||
container(input_row)
|
||||
.style(|theme: &Theme| {
|
||||
.style(move |theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
let color = palette.background.weakest.color.scale_alpha(opacity);
|
||||
container::Style {
|
||||
background: Some(Background::Color(palette.background.weakest.color)),
|
||||
background: Some(Background::Color(color)),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
@@ -1170,7 +1220,9 @@ impl CommandLine {
|
||||
.style(move |theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(palette.background.base.color)),
|
||||
background: Some(Background::Color(
|
||||
palette.background.base.color.scale_alpha(opacity),
|
||||
)),
|
||||
border: Border {
|
||||
color: palette.background.neutral.color,
|
||||
width: if history_open { 1.0 } else { 0.0 },
|
||||
|
||||
@@ -83,6 +83,7 @@ pub static SPLIT_H: &[u8] = include_bytes!("../../assets/icons/ui/split_h.svg");
|
||||
pub static GRID: &[u8] = include_bytes!("../../assets/icons/ui/grid.svg");
|
||||
pub static LIST: &[u8] = include_bytes!("../../assets/icons/ui/list.svg");
|
||||
pub static SEARCH: &[u8] = include_bytes!("../../assets/icons/ui/search.svg");
|
||||
pub static PREVIEW: &[u8] = include_bytes!("../../assets/icons/ui/preview.svg");
|
||||
pub static SNAP: &[u8] = include_bytes!("../../assets/icons/ui/snap.svg");
|
||||
pub static DOC_NEW: &[u8] = include_bytes!("../../assets/icons/ui/doc_new.svg");
|
||||
pub static DOC: &[u8] = include_bytes!("../../assets/icons/ui/doc.svg");
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Classic pull-down menus above the ribbon (MENUBAR 1). Every entry runs a
|
||||
//! command, so the menus stay a view of the command set rather than a second
|
||||
//! implementation of it.
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::{button, container, text};
|
||||
use iced::{Background, Border, Element, Fill, Theme};
|
||||
use iced_aw::menu::{Item, Menu, MenuBar};
|
||||
|
||||
type Entries = &'static [(&'static str, &'static str)];
|
||||
|
||||
/// Menu title and its (label, command) entries, in menu-bar order.
|
||||
const MENUS: &[(&str, Entries)] = &[
|
||||
(
|
||||
"File",
|
||||
&[
|
||||
("New", "QNEW"),
|
||||
("Open", "OPEN"),
|
||||
("Close", "CLOSE"),
|
||||
("Save", "QSAVE"),
|
||||
("Save As", "SAVEAS"),
|
||||
("Page Setup Manager", "PAGESETUP"),
|
||||
("Plot", "PLOT"),
|
||||
("Publish", "PUBLISH"),
|
||||
("Audit", "AUDIT"),
|
||||
("Purge", "PURGE"),
|
||||
("Exit", "QUIT"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Edit",
|
||||
&[
|
||||
("Undo", "UNDO"),
|
||||
("Redo", "REDO"),
|
||||
("Cut", "CUTCLIP"),
|
||||
("Copy", "COPYCLIP"),
|
||||
("Copy with Base Point", "COPYBASE"),
|
||||
("Paste", "PASTECLIP"),
|
||||
("Paste as Block", "PASTEBLOCK"),
|
||||
("Paste to Original Coordinates", "PASTEORIG"),
|
||||
("Paste Special", "PASTESPEC"),
|
||||
("Erase", "ERASE"),
|
||||
("Find and Replace", "FIND"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"View",
|
||||
&[
|
||||
("Redraw", "REDRAW"),
|
||||
("Regen", "REGEN"),
|
||||
("Regen All", "REGENALL"),
|
||||
("Zoom", "ZOOM"),
|
||||
("Pan", "PAN"),
|
||||
("Orbit", "3DORBIT"),
|
||||
("Named Views", "VIEW"),
|
||||
("View Twist", "DVIEW"),
|
||||
("Viewports", "VPORTS"),
|
||||
("Clean Screen", "CLEANSCREEN"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Insert",
|
||||
&[
|
||||
("Block", "INSERT"),
|
||||
("External References", "EXTERNALREFERENCES"),
|
||||
("Raster Image", "IMAGEATTACH"),
|
||||
("PDF Underlay", "PDFATTACH"),
|
||||
("Field", "FIELD"),
|
||||
("Hyperlink", "HYPERLINK"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Format",
|
||||
&[
|
||||
("Layer", "LAYER"),
|
||||
("Color", "COLOR"),
|
||||
("Linetype", "LINETYPE"),
|
||||
("Text Style", "STYLE"),
|
||||
("Dimension Style", "DIMSTYLE"),
|
||||
("Table Style", "TABLESTYLE"),
|
||||
("Multileader Style", "MLEADERSTYLE"),
|
||||
("Multiline Style", "MLSTYLE"),
|
||||
("Point Style", "PTYPE"),
|
||||
("Units", "UNITS"),
|
||||
("Drawing Limits", "LIMITS"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Tools",
|
||||
&[
|
||||
("Quick Select", "QSELECT"),
|
||||
("Draw Order", "DRAWORDER"),
|
||||
("Distance", "DIST"),
|
||||
("Area", "AREA"),
|
||||
("List", "LIST"),
|
||||
("ID Point", "ID"),
|
||||
("Run Script", "SCRIPT"),
|
||||
("New UCS", "UCS"),
|
||||
("Drafting Settings", "DSETTINGS"),
|
||||
("Options", "OPTIONS"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Draw",
|
||||
&[
|
||||
("Line", "LINE"),
|
||||
("Ray", "RAY"),
|
||||
("Construction Line", "XLINE"),
|
||||
("Multiline", "MLINE"),
|
||||
("Polyline", "PLINE"),
|
||||
("3D Polyline", "3DPOLY"),
|
||||
("Polygon", "POLYGON"),
|
||||
("Rectangle", "RECTANG"),
|
||||
("Arc", "ARC"),
|
||||
("Circle", "CIRCLE"),
|
||||
("Donut", "DONUT"),
|
||||
("Spline", "SPLINE"),
|
||||
("Ellipse", "ELLIPSE"),
|
||||
("Block", "BLOCK"),
|
||||
("Table", "TABLE"),
|
||||
("Point", "POINT"),
|
||||
("Hatch", "HATCH"),
|
||||
("Boundary", "BOUNDARY"),
|
||||
("Region", "REGION"),
|
||||
("Wipeout", "WIPEOUT"),
|
||||
("Revision Cloud", "REVCLOUD"),
|
||||
("Text", "TEXT"),
|
||||
("Multiline Text", "MTEXT"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Dimension",
|
||||
&[
|
||||
("Quick Dimension", "QDIM"),
|
||||
("Linear", "DIMLINEAR"),
|
||||
("Aligned", "DIMALIGNED"),
|
||||
("Arc Length", "DIMARC"),
|
||||
("Ordinate", "DIMORDINATE"),
|
||||
("Radius", "DIMRADIUS"),
|
||||
("Jogged", "DIMJOGGED"),
|
||||
("Diameter", "DIMDIAMETER"),
|
||||
("Angular", "DIMANGULAR"),
|
||||
("Baseline", "DIMBASELINE"),
|
||||
("Continue", "DIMCONTINUE"),
|
||||
("Multileader", "MLEADER"),
|
||||
("Tolerance", "TOLERANCE"),
|
||||
("Center Mark", "DIMCENTER"),
|
||||
("Dimension Style", "DIMSTYLE"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Modify",
|
||||
&[
|
||||
("Properties", "PROPERTIES"),
|
||||
("Match Properties", "MATCHPROP"),
|
||||
("Erase", "ERASE"),
|
||||
("Copy", "COPY"),
|
||||
("Mirror", "MIRROR"),
|
||||
("Offset", "OFFSET"),
|
||||
("Array", "ARRAY"),
|
||||
("Move", "MOVE"),
|
||||
("Rotate", "ROTATE"),
|
||||
("Scale", "SCALE"),
|
||||
("Stretch", "STRETCH"),
|
||||
("Lengthen", "LENGTHEN"),
|
||||
("Trim", "TRIM"),
|
||||
("Extend", "EXTEND"),
|
||||
("Break", "BREAK"),
|
||||
("Join", "JOIN"),
|
||||
("Chamfer", "CHAMFER"),
|
||||
("Fillet", "FILLET"),
|
||||
("Explode", "EXPLODE"),
|
||||
("Explode Text", "TXTEXP"),
|
||||
("Edit Polyline", "PEDIT"),
|
||||
("Edit Spline", "SPLINEDIT"),
|
||||
("Edit Hatch", "HATCHEDIT"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Parametric",
|
||||
&[
|
||||
("Auto Constrain", "AUTOCONSTRAIN"),
|
||||
("Coincident", "GCCOINCIDENT"),
|
||||
("Parallel", "PCONSTRAINT"),
|
||||
("Perpendicular", "GCPERPENDICULAR"),
|
||||
("Horizontal", "GCHORIZONTAL"),
|
||||
("Vertical", "GCVERTICAL"),
|
||||
("Tangent", "TCONSTRAINT"),
|
||||
("Equal", "GCEQUAL"),
|
||||
("Fix", "GCFIX"),
|
||||
("Dimensional", "DIMCONSTRAINT"),
|
||||
("Delete Constraints", "DELCONSTRAINT"),
|
||||
("Parameters Manager", "PARAMETERS"),
|
||||
],
|
||||
),
|
||||
("Help", &[("Help", "HELP"), ("About", "ABOUT")]),
|
||||
];
|
||||
|
||||
fn entry_style(theme: &Theme, status: button::Status) -> button::Style {
|
||||
let palette = theme.palette();
|
||||
let pair = match status {
|
||||
button::Status::Hovered | button::Status::Pressed => palette.primary.weak,
|
||||
_ => palette.background.base,
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(pair.color)),
|
||||
text_color: pair.text,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu bar, one pull-down per menu.
|
||||
pub fn view<'a>() -> Element<'a, Message> {
|
||||
let roots = MENUS
|
||||
.iter()
|
||||
.map(|&(title, entries)| {
|
||||
let items = entries
|
||||
.iter()
|
||||
.map(|&(label, command)| {
|
||||
Item::new(
|
||||
button(text(crate::t!(label).into_owned()).size(12))
|
||||
.on_press(Message::Command(command.to_string()))
|
||||
.style(entry_style)
|
||||
.padding([4, 12])
|
||||
.width(Fill),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Item::with_menu(
|
||||
container(text(crate::t!(title).into_owned()).size(12)).padding([3, 8]),
|
||||
Menu::new(items).width(240.0).offset(2.0),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
MenuBar::new(roots)
|
||||
.close_on_item_click_global(true)
|
||||
.style(|theme: &Theme, _| {
|
||||
let palette = theme.palette();
|
||||
iced_aw::style::menu_bar::Style {
|
||||
bar_background: Background::Color(palette.background.weakest.color),
|
||||
bar_border: Border::default(),
|
||||
bar_shadow: Default::default(),
|
||||
menu_background: Background::Color(palette.background.base.color),
|
||||
menu_border: Border {
|
||||
color: palette.background.strong.color,
|
||||
width: 1.0,
|
||||
radius: 3.0.into(),
|
||||
},
|
||||
menu_shadow: Default::default(),
|
||||
path: Background::Color(palette.primary.weak.color),
|
||||
path_border: Border::default(),
|
||||
}
|
||||
})
|
||||
.width(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn every_menu_entry_runs_a_known_command() {
|
||||
let mut known = crate::command::all_registered_command_names();
|
||||
known.extend(["UNDO", "REDO", "QSAVE", "OPEN", "ZOOM"]);
|
||||
let unknown: Vec<_> = super::MENUS
|
||||
.iter()
|
||||
.flat_map(|(_, entries)| entries.iter())
|
||||
.filter(|(_, command)| !known.contains(command))
|
||||
.collect();
|
||||
assert!(unknown.is_empty(), "{unknown:?}");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod dock;
|
||||
pub mod icons;
|
||||
pub mod modal;
|
||||
pub mod node_graph;
|
||||
pub mod menu_bar;
|
||||
pub mod overlay;
|
||||
pub mod popup;
|
||||
pub mod properties;
|
||||
|
||||
+437
-315
@@ -71,6 +71,70 @@ fn draw_concentric_constraint_glyph(frame: &mut canvas::Frame, center: Point, co
|
||||
frame.stroke(&canvas::Path::circle(center, 2.15), stroke);
|
||||
}
|
||||
|
||||
/// Strokes a closed four-corner polygon — the outline half of the brush glyph.
|
||||
fn stroke_quad(frame: &mut canvas::Frame, corners: [Point; 4], stroke: &canvas::Stroke<'_>) {
|
||||
let path = canvas::Path::new(|p| {
|
||||
p.move_to(corners[0]);
|
||||
p.line_to(corners[1]);
|
||||
p.line_to(corners[2]);
|
||||
p.line_to(corners[3]);
|
||||
p.close();
|
||||
});
|
||||
frame.stroke(&path, stroke.clone());
|
||||
}
|
||||
|
||||
/// MATCHPROP's paintbrush cursor.
|
||||
///
|
||||
/// While the user is transferring properties the CAD crosshair is replaced by a
|
||||
/// paintbrush whose tip sits exactly on the pick point, as the reference does. The
|
||||
/// body leans up-and-right so it never covers the object being picked.
|
||||
///
|
||||
/// Deliberately monochrome and geometric: the crosshair's own colour, a single
|
||||
/// stroke weight and one filled wedge, so the glyph reads as part of the CAD
|
||||
/// cursor family rather than clip-art.
|
||||
fn draw_match_brush(frame: &mut canvas::Frame, tip: Point, color: Color) {
|
||||
let inv = std::f32::consts::FRAC_1_SQRT_2;
|
||||
// `along` runs up-and-right (handle direction); `across` is perpendicular.
|
||||
let along = (inv, -inv);
|
||||
let across = (inv, inv);
|
||||
let at = |a: f32, w: f32| {
|
||||
Point::new(
|
||||
tip.x + along.0 * a + across.0 * w,
|
||||
tip.y + along.1 * a + across.1 * w,
|
||||
)
|
||||
};
|
||||
let stroke = canvas::Stroke {
|
||||
width: 1.25,
|
||||
style: canvas::Style::Solid(color),
|
||||
line_join: canvas::LineJoin::Miter,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Bristles: the only solid mass, so the working end carries the weight and
|
||||
// the silhouette still reads at cursor size.
|
||||
let bristles = canvas::Path::new(|p| {
|
||||
p.move_to(at(0.0, 0.0));
|
||||
p.line_to(at(9.0, -3.2));
|
||||
p.line_to(at(12.0, -3.2));
|
||||
p.line_to(at(12.0, 3.2));
|
||||
p.line_to(at(9.0, 3.2));
|
||||
p.close();
|
||||
});
|
||||
frame.fill(&bristles, color);
|
||||
|
||||
// Ferrule and handle stay outline-only: line work, not clip-art.
|
||||
stroke_quad(
|
||||
frame,
|
||||
[at(12.0, -3.2), at(15.5, -3.2), at(15.5, 3.2), at(12.0, 3.2)],
|
||||
&stroke,
|
||||
);
|
||||
stroke_quad(
|
||||
frame,
|
||||
[at(15.5, -2.3), at(30.0, -1.9), at(30.0, 1.9), at(15.5, 2.3)],
|
||||
&stroke,
|
||||
);
|
||||
}
|
||||
|
||||
/// The reference bar's padlock: white with a small orange point marker
|
||||
/// when one point is held, red when a whole curve or segment is.
|
||||
fn draw_fixed_constraint_glyph(
|
||||
@@ -243,6 +307,8 @@ pub struct CrosshairOptions {
|
||||
/// Explicit object-snap marker colour; `None` picks one that reads on
|
||||
/// the canvas.
|
||||
pub snap_color: Option<[u8; 3]>,
|
||||
/// Object-snap marker half-size in pixels (SNAPMARKERSIZE, 1–20).
|
||||
pub snap_size: f32,
|
||||
}
|
||||
|
||||
/// Rendering style for the viewport grid.
|
||||
@@ -901,6 +967,8 @@ pub fn selection_overlay<'a>(
|
||||
hover_locked: bool,
|
||||
crosshair_bg: [f32; 4],
|
||||
crosshair: CrosshairOptions,
|
||||
// MATCHPROP is armed: paintbrush cursor replaces the crosshair.
|
||||
show_match_brush: bool,
|
||||
selection_visual: SelectionVisualOptions,
|
||||
constraint_glyphs: Arc<[GlyphEntry]>,
|
||||
// Per-glyph selection flags, parallel to `constraint_glyphs`, applied
|
||||
@@ -931,6 +999,7 @@ pub fn selection_overlay<'a>(
|
||||
hover_locked,
|
||||
crosshair_bg,
|
||||
crosshair,
|
||||
show_match_brush,
|
||||
selection_visual,
|
||||
constraint_glyphs,
|
||||
constraint_glyph_selected,
|
||||
@@ -995,6 +1064,9 @@ struct SelectionCanvas {
|
||||
/// rather than the UI theme, which may be light over a dark model viewport.
|
||||
crosshair_bg: [f32; 4],
|
||||
crosshair: CrosshairOptions,
|
||||
/// MATCHPROP is armed: the crosshair gives way to a paintbrush cursor
|
||||
/// whose tip marks the pick point.
|
||||
show_match_brush: bool,
|
||||
selection_visual: SelectionVisualOptions,
|
||||
/// Memoised constraint-glyph entries (precomputed anchor/outward/label +
|
||||
/// layout) shared with the hit-test and tooltip paths — draw reads
|
||||
@@ -1609,110 +1681,231 @@ impl canvas::Program<Message> for SelectionCanvas {
|
||||
style: canvas::Style::Solid(marker),
|
||||
..Default::default()
|
||||
};
|
||||
match snap_type {
|
||||
SnapType::ObjectPick => {
|
||||
// Target box + center dot (object-acquisition glyph).
|
||||
let h = 7.0_f32;
|
||||
let rect = canvas::Path::rectangle(
|
||||
Point::new(sp.x - h, sp.y - h),
|
||||
Size::new(h * 2.0, h * 2.0),
|
||||
);
|
||||
frame.stroke(&rect, stroke.clone());
|
||||
let r = 3.0_f32;
|
||||
frame.fill(
|
||||
&canvas::Path::circle(sp, r),
|
||||
Color {
|
||||
r: 0.95,
|
||||
g: 0.50,
|
||||
b: 0.08,
|
||||
a: 0.85,
|
||||
},
|
||||
);
|
||||
}
|
||||
SnapType::Endpoint => {
|
||||
let h = 5.0_f32;
|
||||
let rect = canvas::Path::rectangle(
|
||||
Point::new(sp.x - h, sp.y - h),
|
||||
Size::new(h * 2.0, h * 2.0),
|
||||
);
|
||||
frame.stroke(&rect, stroke);
|
||||
}
|
||||
SnapType::Midpoint => {
|
||||
let r = 6.0_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r * 0.866, sp.y + r * 0.5));
|
||||
b.line_to(Point::new(sp.x - r * 0.866, sp.y + r * 0.5));
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&path, stroke);
|
||||
}
|
||||
SnapType::Center => {
|
||||
let r = 5.5_f32;
|
||||
let path = canvas::Path::circle(sp, r);
|
||||
frame.stroke(&path, stroke);
|
||||
}
|
||||
SnapType::GeometricCenter => {
|
||||
// Pentagon around a centre dot: the area centroid.
|
||||
let r = 6.5_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
for k in 0..5 {
|
||||
let a = -std::f32::consts::FRAC_PI_2
|
||||
+ k as f32 * std::f32::consts::TAU / 5.0;
|
||||
let p = Point::new(sp.x + r * a.cos(), sp.y + r * a.sin());
|
||||
if k == 0 {
|
||||
b.move_to(p);
|
||||
} else {
|
||||
b.line_to(p);
|
||||
// SNAPMARKERSIZE scales the glyphs drawn for the default 5 px.
|
||||
let scale = self.crosshair.snap_size.clamp(1.0, 20.0) / 5.0;
|
||||
frame.with_save(|frame| {
|
||||
frame.translate(iced::Vector::new(sp.x, sp.y));
|
||||
frame.scale(scale);
|
||||
frame.translate(iced::Vector::new(-sp.x, -sp.y));
|
||||
match snap_type {
|
||||
SnapType::ObjectPick => {
|
||||
// Target box + center dot (object-acquisition glyph).
|
||||
let h = 7.0_f32;
|
||||
let rect = canvas::Path::rectangle(
|
||||
Point::new(sp.x - h, sp.y - h),
|
||||
Size::new(h * 2.0, h * 2.0),
|
||||
);
|
||||
frame.stroke(&rect, stroke.clone());
|
||||
let r = 3.0_f32;
|
||||
frame.fill(
|
||||
&canvas::Path::circle(sp, r),
|
||||
Color {
|
||||
r: 0.95,
|
||||
g: 0.50,
|
||||
b: 0.08,
|
||||
a: 0.85,
|
||||
},
|
||||
);
|
||||
}
|
||||
SnapType::Endpoint => {
|
||||
let h = 5.0_f32;
|
||||
let rect = canvas::Path::rectangle(
|
||||
Point::new(sp.x - h, sp.y - h),
|
||||
Size::new(h * 2.0, h * 2.0),
|
||||
);
|
||||
frame.stroke(&rect, stroke);
|
||||
}
|
||||
SnapType::Midpoint => {
|
||||
let r = 6.0_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r * 0.866, sp.y + r * 0.5));
|
||||
b.line_to(Point::new(sp.x - r * 0.866, sp.y + r * 0.5));
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&path, stroke);
|
||||
}
|
||||
SnapType::Center => {
|
||||
let r = 5.5_f32;
|
||||
let path = canvas::Path::circle(sp, r);
|
||||
frame.stroke(&path, stroke);
|
||||
}
|
||||
SnapType::GeometricCenter => {
|
||||
// Pentagon around a centre dot: the area centroid.
|
||||
let r = 6.5_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
for k in 0..5 {
|
||||
let a = -std::f32::consts::FRAC_PI_2
|
||||
+ k as f32 * std::f32::consts::TAU / 5.0;
|
||||
let p = Point::new(sp.x + r * a.cos(), sp.y + r * a.sin());
|
||||
if k == 0 {
|
||||
b.move_to(p);
|
||||
} else {
|
||||
b.line_to(p);
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&path, stroke.clone());
|
||||
frame.fill(&canvas::Path::circle(sp, 1.6), marker);
|
||||
}
|
||||
SnapType::Node => {
|
||||
// Circle with an inscribed X.
|
||||
let r = 5.5_f32;
|
||||
let cpath = canvas::Path::circle(sp, r);
|
||||
frame.stroke(&cpath, stroke.clone());
|
||||
let d = r * std::f32::consts::FRAC_1_SQRT_2;
|
||||
let x1 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - d, sp.y - d));
|
||||
b.line_to(Point::new(sp.x + d, sp.y + d));
|
||||
});
|
||||
let x2 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - d, sp.y + d));
|
||||
b.line_to(Point::new(sp.x + d, sp.y - d));
|
||||
});
|
||||
frame.stroke(&x1, stroke.clone());
|
||||
frame.stroke(&x2, stroke);
|
||||
}
|
||||
SnapType::Quadrant => {
|
||||
let r = 6.0_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y));
|
||||
b.line_to(Point::new(sp.x, sp.y + r));
|
||||
b.line_to(Point::new(sp.x - r, sp.y));
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&path, stroke);
|
||||
}
|
||||
SnapType::Intersection => {
|
||||
// An extended intersection sets one or two extension bases;
|
||||
// draw a dashed guide from each endpoint through the crossing
|
||||
// so both contributing extension paths stay visible. A real
|
||||
// on-segment crossing carries no bases, so nothing draws here.
|
||||
// (#247, #259)
|
||||
for base in [self.snap_ext_base, self.snap_ext_base2]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let dx = sp.x - base.x;
|
||||
let dy = sp.y - base.y;
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if len > 1e-3 {
|
||||
let dash = canvas::Stroke {
|
||||
line_dash: canvas::LineDash {
|
||||
segments: &[4.0, 4.0],
|
||||
offset: 0,
|
||||
},
|
||||
..canvas::Stroke::default().with_color(marker).with_width(1.0)
|
||||
};
|
||||
let tip = Point::new(sp.x + dx / len * 18.0, sp.y + dy / len * 18.0);
|
||||
// `base` is a projected entity endpoint: clip before
|
||||
// stroking (see `clip_seg`).
|
||||
if let Some((a, b)) = clip_seg(base, tip, bounds) {
|
||||
frame.stroke(&canvas::Path::line(a, b), dash);
|
||||
}
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&path, stroke.clone());
|
||||
frame.fill(&canvas::Path::circle(sp, 1.6), marker);
|
||||
}
|
||||
SnapType::Node => {
|
||||
// Circle with an inscribed X.
|
||||
let r = 5.5_f32;
|
||||
let cpath = canvas::Path::circle(sp, r);
|
||||
frame.stroke(&cpath, stroke.clone());
|
||||
let d = r * std::f32::consts::FRAC_1_SQRT_2;
|
||||
let x1 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - d, sp.y - d));
|
||||
b.line_to(Point::new(sp.x + d, sp.y + d));
|
||||
});
|
||||
let x2 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - d, sp.y + d));
|
||||
b.line_to(Point::new(sp.x + d, sp.y - d));
|
||||
});
|
||||
frame.stroke(&x1, stroke.clone());
|
||||
frame.stroke(&x2, stroke);
|
||||
}
|
||||
SnapType::Quadrant => {
|
||||
let r = 6.0_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y));
|
||||
b.line_to(Point::new(sp.x, sp.y + r));
|
||||
b.line_to(Point::new(sp.x - r, sp.y));
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&path, stroke);
|
||||
}
|
||||
SnapType::Intersection => {
|
||||
// An extended intersection sets one or two extension bases;
|
||||
// draw a dashed guide from each endpoint through the crossing
|
||||
// so both contributing extension paths stay visible. A real
|
||||
// on-segment crossing carries no bases, so nothing draws here.
|
||||
// (#247, #259)
|
||||
for base in [self.snap_ext_base, self.snap_ext_base2]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let dx = sp.x - base.x;
|
||||
let dy = sp.y - base.y;
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if len > 1e-3 {
|
||||
let r = 5.0_f32;
|
||||
let p1 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y + r));
|
||||
});
|
||||
let p2 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y + r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y - r));
|
||||
});
|
||||
frame.stroke(&p1, stroke.clone());
|
||||
frame.stroke(&p2, stroke);
|
||||
}
|
||||
SnapType::ApparentIntersection => {
|
||||
// X like Intersection, framed by a small square so the
|
||||
// two are visually distinguishable.
|
||||
let r = 5.0_f32;
|
||||
let rect = canvas::Path::rectangle(
|
||||
Point::new(sp.x - r, sp.y - r),
|
||||
Size::new(r * 2.0, r * 2.0),
|
||||
);
|
||||
frame.stroke(&rect, stroke.clone());
|
||||
let xr = r - 1.5;
|
||||
let p1 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - xr, sp.y - xr));
|
||||
b.line_to(Point::new(sp.x + xr, sp.y + xr));
|
||||
});
|
||||
let p2 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - xr, sp.y + xr));
|
||||
b.line_to(Point::new(sp.x + xr, sp.y - xr));
|
||||
});
|
||||
frame.stroke(&p1, stroke.clone());
|
||||
frame.stroke(&p2, stroke);
|
||||
}
|
||||
SnapType::Insertion => {
|
||||
// Two overlapping rectangles (a small "tag" glyph).
|
||||
let r = 5.0_f32;
|
||||
let inner = canvas::Path::rectangle(
|
||||
Point::new(sp.x - r * 0.5, sp.y - r),
|
||||
Size::new(r, r * 2.0),
|
||||
);
|
||||
let outer = canvas::Path::rectangle(
|
||||
Point::new(sp.x - r, sp.y - r * 0.5),
|
||||
Size::new(r * 2.0, r),
|
||||
);
|
||||
frame.stroke(&outer, stroke.clone());
|
||||
frame.stroke(&inner, stroke);
|
||||
}
|
||||
SnapType::Perpendicular => {
|
||||
// Right-angle hook in the lower-left quadrant.
|
||||
let r = 6.0_f32;
|
||||
let p = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x - r, sp.y + r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y + r));
|
||||
});
|
||||
let foot = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y));
|
||||
b.line_to(Point::new(sp.x, sp.y));
|
||||
b.line_to(Point::new(sp.x, sp.y + r));
|
||||
});
|
||||
frame.stroke(&p, stroke.clone());
|
||||
frame.stroke(&foot, stroke);
|
||||
}
|
||||
SnapType::Tangent => {
|
||||
// Circle with a tangent bar across the top.
|
||||
let r = 5.5_f32;
|
||||
let c = canvas::Path::circle(sp, r);
|
||||
frame.stroke(&c, stroke.clone());
|
||||
let bar = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y - r));
|
||||
});
|
||||
frame.stroke(&bar, stroke);
|
||||
}
|
||||
SnapType::Nearest => {
|
||||
// Bowtie / hourglass — two opposed triangles meeting at sp.
|
||||
let r = 5.5_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x - r, sp.y + r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y + r));
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&path, stroke);
|
||||
}
|
||||
SnapType::Extension => {
|
||||
// Unit direction from the endpoint the snap extends from
|
||||
// toward the snap point, so the guide line and the three
|
||||
// dots follow the actual extension path (#238).
|
||||
let dir = self.snap_ext_base.and_then(|base| {
|
||||
let dx = sp.x - base.x;
|
||||
let dy = sp.y - base.y;
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
(len > 1e-3).then(|| (base, Point::new(dx / len, dy / len)))
|
||||
});
|
||||
// Dashed guide line from that endpoint, through the snap
|
||||
// point and a little beyond, so the extension path is
|
||||
// visible as the cursor tracks along it.
|
||||
if let Some((base, u)) = dir {
|
||||
let dash = canvas::Stroke {
|
||||
line_dash: canvas::LineDash {
|
||||
segments: &[4.0, 4.0],
|
||||
@@ -1720,229 +1913,115 @@ impl canvas::Program<Message> for SelectionCanvas {
|
||||
},
|
||||
..canvas::Stroke::default().with_color(marker).with_width(1.0)
|
||||
};
|
||||
let tip = Point::new(sp.x + dx / len * 18.0, sp.y + dy / len * 18.0);
|
||||
let tip = Point::new(sp.x + u.x * 18.0, sp.y + u.y * 18.0);
|
||||
// `base` is a projected entity endpoint: clip before
|
||||
// stroking (see `clip_seg`).
|
||||
if let Some((a, b)) = clip_seg(base, tip, bounds) {
|
||||
frame.stroke(&canvas::Path::line(a, b), dash);
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = 5.0_f32;
|
||||
let p1 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y + r));
|
||||
});
|
||||
let p2 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y + r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y - r));
|
||||
});
|
||||
frame.stroke(&p1, stroke.clone());
|
||||
frame.stroke(&p2, stroke);
|
||||
}
|
||||
SnapType::ApparentIntersection => {
|
||||
// X like Intersection, framed by a small square so the
|
||||
// two are visually distinguishable.
|
||||
let r = 5.0_f32;
|
||||
let rect = canvas::Path::rectangle(
|
||||
Point::new(sp.x - r, sp.y - r),
|
||||
Size::new(r * 2.0, r * 2.0),
|
||||
);
|
||||
frame.stroke(&rect, stroke.clone());
|
||||
let xr = r - 1.5;
|
||||
let p1 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - xr, sp.y - xr));
|
||||
b.line_to(Point::new(sp.x + xr, sp.y + xr));
|
||||
});
|
||||
let p2 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - xr, sp.y + xr));
|
||||
b.line_to(Point::new(sp.x + xr, sp.y - xr));
|
||||
});
|
||||
frame.stroke(&p1, stroke.clone());
|
||||
frame.stroke(&p2, stroke);
|
||||
}
|
||||
SnapType::Insertion => {
|
||||
// Two overlapping rectangles (a small "tag" glyph).
|
||||
let r = 5.0_f32;
|
||||
let inner = canvas::Path::rectangle(
|
||||
Point::new(sp.x - r * 0.5, sp.y - r),
|
||||
Size::new(r, r * 2.0),
|
||||
);
|
||||
let outer = canvas::Path::rectangle(
|
||||
Point::new(sp.x - r, sp.y - r * 0.5),
|
||||
Size::new(r * 2.0, r),
|
||||
);
|
||||
frame.stroke(&outer, stroke.clone());
|
||||
frame.stroke(&inner, stroke);
|
||||
}
|
||||
SnapType::Perpendicular => {
|
||||
// Right-angle hook in the lower-left quadrant.
|
||||
let r = 6.0_f32;
|
||||
let p = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x - r, sp.y + r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y + r));
|
||||
});
|
||||
let foot = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y));
|
||||
b.line_to(Point::new(sp.x, sp.y));
|
||||
b.line_to(Point::new(sp.x, sp.y + r));
|
||||
});
|
||||
frame.stroke(&p, stroke.clone());
|
||||
frame.stroke(&foot, stroke);
|
||||
}
|
||||
SnapType::Tangent => {
|
||||
// Circle with a tangent bar across the top.
|
||||
let r = 5.5_f32;
|
||||
let c = canvas::Path::circle(sp, r);
|
||||
frame.stroke(&c, stroke.clone());
|
||||
let bar = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y - r));
|
||||
});
|
||||
frame.stroke(&bar, stroke);
|
||||
}
|
||||
SnapType::Nearest => {
|
||||
// Bowtie / hourglass — two opposed triangles meeting at sp.
|
||||
let r = 5.5_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x - r, sp.y + r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y + r));
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&path, stroke);
|
||||
}
|
||||
SnapType::Extension => {
|
||||
// Unit direction from the endpoint the snap extends from
|
||||
// toward the snap point, so the guide line and the three
|
||||
// dots follow the actual extension path (#238).
|
||||
let dir = self.snap_ext_base.and_then(|base| {
|
||||
let dx = sp.x - base.x;
|
||||
let dy = sp.y - base.y;
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
(len > 1e-3).then(|| (base, Point::new(dx / len, dy / len)))
|
||||
});
|
||||
// Dashed guide line from that endpoint, through the snap
|
||||
// point and a little beyond, so the extension path is
|
||||
// visible as the cursor tracks along it.
|
||||
if let Some((base, u)) = dir {
|
||||
let dash = canvas::Stroke {
|
||||
line_dash: canvas::LineDash {
|
||||
segments: &[4.0, 4.0],
|
||||
offset: 0,
|
||||
},
|
||||
..canvas::Stroke::default().with_color(marker).with_width(1.0)
|
||||
};
|
||||
let tip = Point::new(sp.x + u.x * 18.0, sp.y + u.y * 18.0);
|
||||
// `base` is a projected entity endpoint: clip before
|
||||
// stroking (see `clip_seg`).
|
||||
if let Some((a, b)) = clip_seg(base, tip, bounds) {
|
||||
frame.stroke(&canvas::Path::line(a, b), dash);
|
||||
// Three dots at the snap point, strung along the extension
|
||||
// direction (horizontal fallback when the base is unknown).
|
||||
let u = dir.map(|(_, u)| u).unwrap_or(Point::new(1.0, 0.0));
|
||||
let r = 1.4_f32;
|
||||
for k in [-7.0_f32, 0.0, 7.0] {
|
||||
let dot =
|
||||
canvas::Path::circle(Point::new(sp.x + u.x * k, sp.y + u.y * k), r);
|
||||
frame.fill(&dot, marker);
|
||||
}
|
||||
}
|
||||
// Three dots at the snap point, strung along the extension
|
||||
// direction (horizontal fallback when the base is unknown).
|
||||
let u = dir.map(|(_, u)| u).unwrap_or(Point::new(1.0, 0.0));
|
||||
let r = 1.4_f32;
|
||||
for k in [-7.0_f32, 0.0, 7.0] {
|
||||
let dot =
|
||||
canvas::Path::circle(Point::new(sp.x + u.x * k, sp.y + u.y * k), r);
|
||||
frame.fill(&dot, marker);
|
||||
SnapType::Parallel => {
|
||||
// Two short parallel diagonal bars.
|
||||
let r = 6.0_f32;
|
||||
let off = 3.0_f32;
|
||||
let b1 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r - off, sp.y + r));
|
||||
b.line_to(Point::new(sp.x + r - off, sp.y - r));
|
||||
});
|
||||
let b2 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r + off, sp.y + r));
|
||||
b.line_to(Point::new(sp.x + r + off, sp.y - r));
|
||||
});
|
||||
frame.stroke(&b1, stroke.clone());
|
||||
frame.stroke(&b2, stroke);
|
||||
}
|
||||
SnapType::Grid => {
|
||||
let arm = 4.0_f32;
|
||||
let h = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - arm, sp.y));
|
||||
b.line_to(Point::new(sp.x + arm, sp.y));
|
||||
});
|
||||
let v = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x, sp.y - arm));
|
||||
b.line_to(Point::new(sp.x, sp.y + arm));
|
||||
});
|
||||
frame.stroke(&h, stroke.clone());
|
||||
frame.stroke(&v, stroke);
|
||||
}
|
||||
SnapType::Vertex => {
|
||||
// Filled square: the solid rhyme of Endpoint's hollow box.
|
||||
let h = 5.0_f32;
|
||||
frame.fill(
|
||||
&canvas::Path::rectangle(
|
||||
Point::new(sp.x - h, sp.y - h),
|
||||
Size::new(h * 2.0, h * 2.0),
|
||||
),
|
||||
marker,
|
||||
);
|
||||
}
|
||||
SnapType::EdgeMidpoint => {
|
||||
// Filled triangle: the solid rhyme of Midpoint's outline.
|
||||
let r = 6.0_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r * 0.866, sp.y + r * 0.5));
|
||||
b.line_to(Point::new(sp.x - r * 0.866, sp.y + r * 0.5));
|
||||
b.close();
|
||||
});
|
||||
frame.fill(&path, marker);
|
||||
}
|
||||
SnapType::FaceCenter => {
|
||||
// Filled disc: the solid rhyme of Center's outline.
|
||||
frame.fill(&canvas::Path::circle(sp, 5.5_f32), marker);
|
||||
}
|
||||
SnapType::Knot => {
|
||||
// Filled diamond: distinct from Quadrant's outline.
|
||||
let r = 5.5_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y));
|
||||
b.line_to(Point::new(sp.x, sp.y + r));
|
||||
b.line_to(Point::new(sp.x - r, sp.y));
|
||||
b.close();
|
||||
});
|
||||
frame.fill(&path, marker);
|
||||
}
|
||||
SnapType::FacePerpendicular => {
|
||||
// Right-angle hook like the 2D marker, plus a filled foot
|
||||
// dot marking the face contact.
|
||||
let r = 6.0_f32;
|
||||
let p = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x - r, sp.y + r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y + r));
|
||||
});
|
||||
frame.stroke(&p, stroke.clone());
|
||||
frame.fill(&canvas::Path::circle(sp, 2.0_f32), marker);
|
||||
}
|
||||
SnapType::NearestFace => {
|
||||
// Filled bowtie: the solid rhyme of Nearest's outline.
|
||||
let r = 5.5_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x - r, sp.y + r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y + r));
|
||||
b.close();
|
||||
});
|
||||
frame.fill(&path, marker);
|
||||
}
|
||||
}
|
||||
SnapType::Parallel => {
|
||||
// Two short parallel diagonal bars.
|
||||
let r = 6.0_f32;
|
||||
let off = 3.0_f32;
|
||||
let b1 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r - off, sp.y + r));
|
||||
b.line_to(Point::new(sp.x + r - off, sp.y - r));
|
||||
});
|
||||
let b2 = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r + off, sp.y + r));
|
||||
b.line_to(Point::new(sp.x + r + off, sp.y - r));
|
||||
});
|
||||
frame.stroke(&b1, stroke.clone());
|
||||
frame.stroke(&b2, stroke);
|
||||
}
|
||||
SnapType::Grid => {
|
||||
let arm = 4.0_f32;
|
||||
let h = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - arm, sp.y));
|
||||
b.line_to(Point::new(sp.x + arm, sp.y));
|
||||
});
|
||||
let v = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x, sp.y - arm));
|
||||
b.line_to(Point::new(sp.x, sp.y + arm));
|
||||
});
|
||||
frame.stroke(&h, stroke.clone());
|
||||
frame.stroke(&v, stroke);
|
||||
}
|
||||
SnapType::Vertex => {
|
||||
// Filled square: the solid rhyme of Endpoint's hollow box.
|
||||
let h = 5.0_f32;
|
||||
frame.fill(
|
||||
&canvas::Path::rectangle(
|
||||
Point::new(sp.x - h, sp.y - h),
|
||||
Size::new(h * 2.0, h * 2.0),
|
||||
),
|
||||
marker,
|
||||
);
|
||||
}
|
||||
SnapType::EdgeMidpoint => {
|
||||
// Filled triangle: the solid rhyme of Midpoint's outline.
|
||||
let r = 6.0_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r * 0.866, sp.y + r * 0.5));
|
||||
b.line_to(Point::new(sp.x - r * 0.866, sp.y + r * 0.5));
|
||||
b.close();
|
||||
});
|
||||
frame.fill(&path, marker);
|
||||
}
|
||||
SnapType::FaceCenter => {
|
||||
// Filled disc: the solid rhyme of Center's outline.
|
||||
frame.fill(&canvas::Path::circle(sp, 5.5_f32), marker);
|
||||
}
|
||||
SnapType::Knot => {
|
||||
// Filled diamond: distinct from Quadrant's outline.
|
||||
let r = 5.5_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y));
|
||||
b.line_to(Point::new(sp.x, sp.y + r));
|
||||
b.line_to(Point::new(sp.x - r, sp.y));
|
||||
b.close();
|
||||
});
|
||||
frame.fill(&path, marker);
|
||||
}
|
||||
SnapType::FacePerpendicular => {
|
||||
// Right-angle hook like the 2D marker, plus a filled foot
|
||||
// dot marking the face contact.
|
||||
let r = 6.0_f32;
|
||||
let p = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x - r, sp.y + r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y + r));
|
||||
});
|
||||
frame.stroke(&p, stroke.clone());
|
||||
frame.fill(&canvas::Path::circle(sp, 2.0_f32), marker);
|
||||
}
|
||||
SnapType::NearestFace => {
|
||||
// Filled bowtie: the solid rhyme of Nearest's outline.
|
||||
let r = 5.5_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y - r));
|
||||
b.line_to(Point::new(sp.x - r, sp.y + r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y + r));
|
||||
b.close();
|
||||
});
|
||||
frame.fill(&path, marker);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── CAD crosshair cursor ──────────────────────────────────────────────
|
||||
@@ -1961,11 +2040,54 @@ impl canvas::Program<Message> for SelectionCanvas {
|
||||
// arrow (see `mouse_interaction`); drawing the CAD crosshair on
|
||||
// top of it would double up the visual feedback.
|
||||
let over_divider = self.divider_under(cursor, bounds);
|
||||
// ── MATCHPROP paintbrush cursor ───────────────────────────────
|
||||
// While MATCHPROP is armed the user is transferring properties from a
|
||||
// source object onto others, so the crosshair gives way to a paintbrush
|
||||
// whose tip rides the pick point, as the reference does. Drawn *instead of*
|
||||
// the crosshair below, never on top of it.
|
||||
if !over_viewcube
|
||||
&& !over_divider
|
||||
&& self.nav == NavCursor::None
|
||||
&& !self.suppressed
|
||||
&& self.show_match_brush
|
||||
{
|
||||
if let Some(cp) = self
|
||||
.grips
|
||||
.iter()
|
||||
.find(|grip| grip.is_hovered)
|
||||
.map(|grip| grip.pos)
|
||||
.or(self.selection.borrow().input.last_move_pos)
|
||||
{
|
||||
// Inherit the crosshair's own colour (including a custom
|
||||
// crosshair colour) so only the shape changes, never the ink.
|
||||
let [cr, cg, cb, ca] = self.crosshair.color.map_or_else(
|
||||
|| {
|
||||
crate::scene::view::render::adapt_to_bg(
|
||||
[1.0, 1.0, 1.0, 0.90],
|
||||
self.crosshair_bg,
|
||||
)
|
||||
},
|
||||
|[r, g, b]| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 0.90],
|
||||
);
|
||||
draw_match_brush(
|
||||
&mut frame,
|
||||
cp,
|
||||
Color {
|
||||
r: cr,
|
||||
g: cg,
|
||||
b: cb,
|
||||
a: ca,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// An armed navigation tool replaces the crosshair with its own cursor.
|
||||
if !over_viewcube
|
||||
&& !over_divider
|
||||
&& self.nav == NavCursor::None
|
||||
&& !self.suppressed
|
||||
&& !self.show_match_brush
|
||||
&& self.crosshair.cursor_type == CursorType::Crosshair
|
||||
{
|
||||
// Over a grip the crosshair locks onto it, so the click lands on
|
||||
|
||||
+61
-19
@@ -510,6 +510,13 @@ pub fn build_field_key_map(sections: &[PropSection]) -> HashMap<iced::widget::Id
|
||||
/// the `WebFieldCopy` operation idiom: `focusable` fires for every focusable
|
||||
/// widget, and we record the one that `is_focused()`.
|
||||
pub fn sync_active_field_task() -> iced::Task<Message> {
|
||||
focused_widget_task(Message::PropSyncActive)
|
||||
}
|
||||
|
||||
/// Ask the widget tree which field holds keyboard focus.
|
||||
pub fn focused_widget_task(
|
||||
on_result: fn(Option<iced::advanced::widget::Id>) -> Message,
|
||||
) -> iced::Task<Message> {
|
||||
use iced::advanced::widget::operation::{Focusable, Outcome};
|
||||
use iced::advanced::widget::{Id, Operation};
|
||||
#[derive(Default)]
|
||||
@@ -534,7 +541,7 @@ pub fn sync_active_field_task() -> iced::Task<Message> {
|
||||
Outcome::Some(self.focused.clone())
|
||||
}
|
||||
}
|
||||
iced::advanced::widget::operate(FocusedId::default()).map(Message::PropSyncActive)
|
||||
iced::advanced::widget::operate(FocusedId::default()).map(on_result)
|
||||
}
|
||||
|
||||
/// A translated choice label paired with the unchanged value stored in the
|
||||
@@ -614,6 +621,12 @@ pub struct PropertiesPanel {
|
||||
pub collapsed_sections: HashSet<String>,
|
||||
/// Whether the editable-dropdown (block Name) option list is open.
|
||||
pub edit_choice_open: bool,
|
||||
/// The open dropdown belongs to Quick Properties, not the docked panel:
|
||||
/// both render these rows, and two open copies swallow each other's
|
||||
/// clicks (#1766).
|
||||
pub picker_in_quick: bool,
|
||||
/// Set while `quick_view` builds its rows.
|
||||
pub rendering_quick: std::cell::Cell<bool>,
|
||||
/// Field key of the value row currently being edited, or `None`. Marked when
|
||||
/// the user focuses an editable value field and cleared on commit / when the
|
||||
/// selection changes, so the active row can be highlighted. Geometry fields
|
||||
@@ -652,6 +665,8 @@ impl Default for PropertiesPanel {
|
||||
expanded_groups: HashSet::default(),
|
||||
collapsed_sections: HashSet::default(),
|
||||
edit_choice_open: false,
|
||||
picker_in_quick: false,
|
||||
rendering_quick: std::cell::Cell::new(false),
|
||||
active_field: None,
|
||||
field_key_by_id: HashMap::default(),
|
||||
}
|
||||
@@ -673,6 +688,11 @@ impl PropertiesPanel {
|
||||
self.field_key_by_id.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Whether the open dropdown belongs to the surface being built.
|
||||
fn picker_here(&self) -> bool {
|
||||
self.picker_in_quick == self.rendering_quick.get()
|
||||
}
|
||||
|
||||
pub fn selected_handles(&self) -> Vec<Handle> {
|
||||
self.selected_group
|
||||
.as_ref()
|
||||
@@ -737,11 +757,13 @@ impl PropertiesPanel {
|
||||
for section in &self.sections {
|
||||
col = col.push(self.render_section(section));
|
||||
}
|
||||
scrollable(col.width(Length::Fill))
|
||||
.spacing(8)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
Element::from(
|
||||
scrollable(col.width(Length::Fill))
|
||||
.spacing(8)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
)
|
||||
.map(|message| tag_picker(false, message))
|
||||
};
|
||||
|
||||
container(column![header, title_bar, content])
|
||||
@@ -790,14 +812,19 @@ impl PropertiesPanel {
|
||||
.padding([4, 10]);
|
||||
|
||||
let mut sections = column![].spacing(0);
|
||||
self.rendering_quick.set(true);
|
||||
for section in &self.sections {
|
||||
sections = sections.push(self.render_section(section));
|
||||
}
|
||||
self.rendering_quick.set(false);
|
||||
|
||||
let content = scrollable(sections.width(Length::Fill))
|
||||
.spacing(8)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Shrink);
|
||||
let content = Element::from(
|
||||
scrollable(sections.width(Length::Fill))
|
||||
.spacing(8)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Shrink),
|
||||
)
|
||||
.map(|message| tag_picker(true, message));
|
||||
|
||||
Some(
|
||||
container(column![title, content].spacing(0))
|
||||
@@ -995,7 +1022,7 @@ impl PropertiesPanel {
|
||||
if field == "background_color" {
|
||||
let selector = crate::ui::color_select::color_selector(
|
||||
color,
|
||||
self.bg_color_picker_open,
|
||||
self.bg_color_picker_open && self.picker_here(),
|
||||
crate::ui::color_select::ColorExtras {
|
||||
by_layer: true,
|
||||
by_block: true,
|
||||
@@ -1028,7 +1055,7 @@ impl PropertiesPanel {
|
||||
| "background_fill_color"
|
||||
| "indicator_fill_color"
|
||||
) {
|
||||
let open = self.open_color_field.as_deref() == Some(field);
|
||||
let open = self.open_color_field.as_deref() == Some(field) && self.picker_here();
|
||||
let fsel = field.to_string();
|
||||
let full_palette_field = field.to_string();
|
||||
let extras = if field == "dim_text_fill_color" {
|
||||
@@ -1071,7 +1098,7 @@ impl PropertiesPanel {
|
||||
let selector = crate::ui::color_select::color_selector_with_name(
|
||||
color,
|
||||
display_name,
|
||||
self.color_picker_open,
|
||||
self.color_picker_open && self.picker_here(),
|
||||
crate::ui::color_select::ColorExtras {
|
||||
by_layer: true,
|
||||
by_block: true,
|
||||
@@ -1086,7 +1113,7 @@ impl PropertiesPanel {
|
||||
|
||||
fn render_color_varies_row<'a>(&'a self, label: &'a str) -> Element<'a, Message> {
|
||||
let selector = crate::ui::color_select::color_selector_varies(
|
||||
self.color_picker_open,
|
||||
self.color_picker_open && self.picker_here(),
|
||||
crate::ui::color_select::ColorExtras {
|
||||
by_layer: true,
|
||||
by_block: true,
|
||||
@@ -1345,7 +1372,7 @@ impl PropertiesPanel {
|
||||
})
|
||||
.width(Length::Fill);
|
||||
let caret = button(
|
||||
container(if self.edit_choice_open {
|
||||
container(if self.edit_choice_open && self.picker_here() {
|
||||
crate::ui::icons::themed_arrow_up(9.0)
|
||||
} else {
|
||||
crate::ui::icons::themed_arrow_down(9.0)
|
||||
@@ -1398,7 +1425,7 @@ impl PropertiesPanel {
|
||||
})
|
||||
.width(Length::Fill);
|
||||
|
||||
if !self.edit_choice_open {
|
||||
if !(self.edit_choice_open && self.picker_here()) {
|
||||
return prop_row_with_active(label, head.into(), active);
|
||||
}
|
||||
|
||||
@@ -1602,7 +1629,7 @@ impl PropertiesPanel {
|
||||
text(crate::ui::text_util::elide(current, 16))
|
||||
.size(FONT_SZ)
|
||||
.width(Length::Fill),
|
||||
if self.hatch_pattern_picker_open {
|
||||
if self.hatch_pattern_picker_open && self.picker_here() {
|
||||
crate::ui::icons::themed_arrow_up(FONT_SZ)
|
||||
} else {
|
||||
crate::ui::icons::themed_arrow_down(FONT_SZ)
|
||||
@@ -1622,7 +1649,7 @@ impl PropertiesPanel {
|
||||
})),
|
||||
text_color: palette.background.base.text,
|
||||
border: Border {
|
||||
color: if self.hatch_pattern_picker_open {
|
||||
color: if self.hatch_pattern_picker_open && self.picker_here() {
|
||||
palette.primary.base.color
|
||||
} else {
|
||||
palette.background.neutral.color
|
||||
@@ -1636,7 +1663,7 @@ impl PropertiesPanel {
|
||||
.padding([COMBO_PAD_V, 6.0])
|
||||
.width(Length::Fill);
|
||||
|
||||
if !self.hatch_pattern_picker_open {
|
||||
if !(self.hatch_pattern_picker_open && self.picker_here()) {
|
||||
return prop_row_widget(label, head.into());
|
||||
}
|
||||
|
||||
@@ -2363,3 +2390,18 @@ mod tests {
|
||||
assert!((0.01..=100.0).contains(&scale));
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a dropdown toggle with the surface it came from (see
|
||||
/// `PropertiesPanel::picker_in_quick`).
|
||||
fn tag_picker(quick: bool, message: Message) -> Message {
|
||||
match message {
|
||||
Message::PropColorPickerToggle
|
||||
| Message::PropBgColorPickerToggle
|
||||
| Message::PropColorFieldToggle(_)
|
||||
| Message::PropEditChoiceToggle
|
||||
| Message::PropHatchPatternPickerToggle(_) => {
|
||||
Message::PropPickerSurface(quick, Box::new(message))
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
+52
-20
@@ -9,7 +9,7 @@ use super::widgets::{
|
||||
make_icon, make_tip, muted_text_style, popup_panel_style, popup_row_style, tip_style,
|
||||
tool_btn_style, LARGE_DROPDOWN_ARROW_SIZE,
|
||||
};
|
||||
use super::{dropdown_backdrop, position_ribbon_dropdown, Ribbon};
|
||||
use super::{dropdown_backdrop, keytips, position_ribbon_dropdown, Ribbon};
|
||||
use crate::app::Message;
|
||||
use crate::modules::IconKind;
|
||||
use crate::t;
|
||||
@@ -248,6 +248,32 @@ fn panel_for_group(title: &str, group_ids: &[&'static str]) -> Option<Panel> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The slide-out under the group `title`, if it has one.
|
||||
pub(super) fn slide_out_id(title: &str, group_ids: &[&'static str]) -> Option<&'static str> {
|
||||
panel_for_group(title, group_ids).map(|panel| panel.id)
|
||||
}
|
||||
|
||||
/// What the open slide-out or tool submenu `id` offers, as (dropdown id,
|
||||
/// command, opens a submenu) in display order.
|
||||
pub(super) fn keytip_items(id: &str) -> Vec<(&'static str, &'static str, bool)> {
|
||||
let Some(panel) = panel_for_dropdown(id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if panel.id == id {
|
||||
return panel
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| (panel.id, tool.command, !tool.options.is_empty()))
|
||||
.collect();
|
||||
}
|
||||
panel
|
||||
.tools
|
||||
.iter()
|
||||
.filter(|tool| tool.command == id)
|
||||
.flat_map(|tool| tool.options.iter().map(|(cmd, _)| (tool.command, *cmd, false)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every command the slide-out of the group `title` offers: each tool and
|
||||
/// each of its submenu options.
|
||||
pub(super) fn slide_out_commands(
|
||||
@@ -311,7 +337,8 @@ pub(super) fn group_title<'a>(
|
||||
if panel.id != REFERENCE_PANEL_ID {
|
||||
return title_button;
|
||||
}
|
||||
// The corner arrow opens the External References palette.
|
||||
// The corner arrow opens the External References palette, or closes it
|
||||
// when it is open.
|
||||
let launcher = tooltip(
|
||||
// A right arrow turned to point into the corner.
|
||||
button(
|
||||
@@ -323,10 +350,7 @@ pub(super) fn group_title<'a>(
|
||||
color: Some(theme.palette().background.base.text),
|
||||
}),
|
||||
)
|
||||
.on_press(Message::RibbonToolClick {
|
||||
tool_id: "EXTERNALREFERENCES".to_string(),
|
||||
event: crate::modules::ModuleEvent::Command("EXTERNALREFERENCES".to_string()),
|
||||
})
|
||||
.on_press(Message::ToggleXrefManager)
|
||||
.style(move |theme: &Theme, status| tool_btn_style(theme, false, status))
|
||||
.padding([0, 3]),
|
||||
make_tip(format!(
|
||||
@@ -339,6 +363,7 @@ pub(super) fn group_title<'a>(
|
||||
.delay(Duration::from_millis(400))
|
||||
.style(tip_style);
|
||||
// Shrink-sized: a Fill here would make the panel claim the whole row.
|
||||
let launcher = keytips::anchor(keytips::XREF_LAUNCHER, launcher);
|
||||
row![title_button, launcher]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center)
|
||||
@@ -364,6 +389,7 @@ fn tool_button(tool: &Tool, active: bool, panel_id: &'static str) -> Element<'st
|
||||
.delay(Duration::from_millis(400))
|
||||
.style(tip_style)
|
||||
.into();
|
||||
let face = keytips::anchor_with(|| keytips::item_anchor(panel_id, tool.command), face);
|
||||
if !tool.options.is_empty() {
|
||||
row![
|
||||
face,
|
||||
@@ -403,6 +429,7 @@ fn reference_overlay<'a>(ribbon: &Ribbon, panel: Panel, win: (f32, f32)) -> Elem
|
||||
.style(popup_row_style)
|
||||
.width(Fill)
|
||||
.padding([3, 6]);
|
||||
let edit = keytips::anchor_with(|| keytips::item_anchor(panel.id, tool.command), edit);
|
||||
let fade = ribbon.xref_fade;
|
||||
let on = fade > 0;
|
||||
let switch = button(make_icon(
|
||||
@@ -413,18 +440,23 @@ fn reference_overlay<'a>(ribbon: &Ribbon, panel: Panel, win: (f32, f32)) -> Elem
|
||||
.style(move |theme: &Theme, status| tool_btn_style(theme, on, status))
|
||||
.padding(3);
|
||||
let amount = fade.unsigned_abs().min(90) as u8;
|
||||
let switch = tooltip(
|
||||
switch,
|
||||
make_tip(format!(
|
||||
"{}\n{} XDWGFADECTL",
|
||||
t!("Xref fading"),
|
||||
t!("Command:")
|
||||
)),
|
||||
tooltip::Position::Bottom,
|
||||
)
|
||||
.delay(Duration::from_millis(400))
|
||||
.style(tip_style);
|
||||
let switch = keytips::anchor_with(
|
||||
|| keytips::item_anchor(panel.id, keytips::XREF_FADE),
|
||||
switch,
|
||||
);
|
||||
let fading = row![
|
||||
tooltip(
|
||||
switch,
|
||||
make_tip(format!(
|
||||
"{}\n{} XDWGFADECTL",
|
||||
t!("Xref fading"),
|
||||
t!("Command:")
|
||||
)),
|
||||
tooltip::Position::Bottom
|
||||
)
|
||||
.delay(Duration::from_millis(400))
|
||||
.style(tip_style),
|
||||
switch,
|
||||
text(t!("Xref fading")).size(11),
|
||||
iced::widget::slider(0..=90u8, amount, Message::XrefFadeSlide)
|
||||
.on_release(Message::XrefFadeCommit)
|
||||
@@ -470,7 +502,7 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element
|
||||
tool.options
|
||||
.iter()
|
||||
.map(|&(cmd, label)| {
|
||||
button(text(t!(label)).size(11))
|
||||
let item = button(text(t!(label)).size(11))
|
||||
.on_press(Message::DropdownSelectItem {
|
||||
dropdown_id: tool.command,
|
||||
cmd,
|
||||
@@ -478,8 +510,8 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element
|
||||
.style(popup_row_style)
|
||||
.width(Fill)
|
||||
.height(OPTION_HEIGHT)
|
||||
.padding([3, 7])
|
||||
.into()
|
||||
.padding([3, 7]);
|
||||
keytips::anchor_with(|| keytips::item_anchor(tool.command, cmd), item)
|
||||
})
|
||||
.collect::<Vec<Element<'static, Message>>>(),
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+6
-15
@@ -68,15 +68,11 @@ impl Ribbon {
|
||||
fn entries(&self) -> impl Iterator<Item = (usize, Entry<'_>)> {
|
||||
let order = std::iter::once(self.active)
|
||||
.chain((0..self.modules.len()).filter(move |i| *i != self.active));
|
||||
order
|
||||
.filter_map(|i| self.modules.get(i).map(|m| (i, m)))
|
||||
.flat_map(|(i, module)| {
|
||||
module
|
||||
.ribbon_groups()
|
||||
.iter()
|
||||
.flat_map(group_entries)
|
||||
.map(move |entry| (i, entry))
|
||||
})
|
||||
order.flat_map(|i| {
|
||||
self.groups_for(i)
|
||||
.flat_map(group_entries)
|
||||
.map(move |entry| (i, entry))
|
||||
})
|
||||
}
|
||||
|
||||
/// The button id that runs the command line `cmd`: an exact match first,
|
||||
@@ -111,12 +107,7 @@ impl Ribbon {
|
||||
/// module has no tab (its tools are on the side toolbar).
|
||||
pub(super) fn tab_holding_active_tool(&self) -> Option<usize> {
|
||||
let id = self.active_tool.as_deref()?;
|
||||
let holds = |i: usize| {
|
||||
self.modules[i]
|
||||
.ribbon_groups()
|
||||
.iter()
|
||||
.any(|group| group_holds(group, id))
|
||||
};
|
||||
let holds = |i: usize| self.groups_for(i).any(|group| group_holds(group, id));
|
||||
if self
|
||||
.modules
|
||||
.get(self.active)
|
||||
|
||||
+153
-42
@@ -31,6 +31,12 @@ use widgets::{StyleContext, *};
|
||||
pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID};
|
||||
mod collapse;
|
||||
mod locate;
|
||||
mod keytips;
|
||||
pub use keytips::{
|
||||
active as keytips_active, key_pressed as keytip_key_pressed,
|
||||
key_released as keytip_key_released, pointer_pressed as keytip_pointer_pressed, KeyTipInput,
|
||||
KeyTipStep,
|
||||
};
|
||||
use crate::t;
|
||||
use crate::ui::wrap_bar::{PosReport, WrapBar, WrapFlow};
|
||||
pub use collapse::CollapseMode;
|
||||
@@ -120,8 +126,14 @@ pub struct Ribbon {
|
||||
xref_ctx: bool,
|
||||
/// Only point clouds are selected: whether the first shows its crops.
|
||||
point_cloud_ctx: Option<bool>,
|
||||
/// A block editor session is open: the tab that was active before the
|
||||
/// Block Editor tab took over.
|
||||
block_editing: Option<usize>,
|
||||
block_testing: Option<usize>,
|
||||
/// XDWGFADECTL as the Reference slide-out shows it (negative = off).
|
||||
pub xref_fade: i32,
|
||||
/// Open key tips (Alt).
|
||||
keytips: Option<keytips::State>,
|
||||
}
|
||||
|
||||
/// Per-layer display data shown in the ribbon layer dropdown.
|
||||
@@ -240,7 +252,10 @@ impl Ribbon {
|
||||
underlay_ctx: None,
|
||||
xref_ctx: false,
|
||||
point_cloud_ctx: None,
|
||||
block_editing: None,
|
||||
block_testing: None,
|
||||
xref_fade: 50,
|
||||
keytips: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +329,62 @@ impl Ribbon {
|
||||
self.available_linetypes = items;
|
||||
}
|
||||
|
||||
/// Show the Block Editor tab while a block is being edited, and return to
|
||||
/// the previous tab when the editing ends.
|
||||
pub fn set_block_editing(&mut self, editing: bool) {
|
||||
let Some(index) = self.modules.iter().position(|module| module.id() == "block_editor")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
match (editing, self.block_editing) {
|
||||
(true, None) => {
|
||||
self.block_editing = Some(self.active);
|
||||
self.active = index;
|
||||
}
|
||||
(false, Some(previous)) => {
|
||||
self.block_editing = None;
|
||||
if self.active == index {
|
||||
self.active = previous.min(self.modules.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_block_testing(&mut self, testing: bool) {
|
||||
if testing != self.block_testing.is_some() {
|
||||
self.close_dropdown();
|
||||
let _ = self.hide_keytips();
|
||||
}
|
||||
let Some(index) = self.modules.iter().position(|module| module.id() == "draw") else {
|
||||
return;
|
||||
};
|
||||
match (testing, self.block_testing) {
|
||||
(true, None) => {
|
||||
self.block_testing = Some(self.active);
|
||||
self.active = index;
|
||||
}
|
||||
(false, Some(previous)) => {
|
||||
self.block_testing = None;
|
||||
if self.active == index {
|
||||
self.active = previous.min(self.modules.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Contextual panels share their normal tab with the drawing tools.
|
||||
fn groups_for(&self, index: usize) -> impl Iterator<Item = &RibbonGroup> {
|
||||
let testing = self.block_testing.is_some();
|
||||
self.modules.get(index).into_iter().flat_map(move |module| {
|
||||
module
|
||||
.ribbon_groups()
|
||||
.iter()
|
||||
.filter(move |group| module.id() != "draw" || group.title != "Close" || testing)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn select(&mut self, index: usize) {
|
||||
if index < self.modules.len() {
|
||||
self.active = index;
|
||||
@@ -571,12 +642,14 @@ impl Ribbon {
|
||||
|mut acc, (i, module)| {
|
||||
// The Layout module no longer has a ribbon tab — its paper-space
|
||||
// tools live in the right-edge side toolbar (see ui::side_toolbar).
|
||||
if module.id() == "layout" {
|
||||
if module.id() == "layout"
|
||||
|| (module.id() == "block_editor" && self.block_editing.is_none())
|
||||
{
|
||||
return acc;
|
||||
}
|
||||
|
||||
let is_active = i == self.active;
|
||||
let is_contextual = module.id() == "layout";
|
||||
let is_contextual = matches!(module.id(), "layout" | "block_editor");
|
||||
let holds_tool = holding_tab == Some(i);
|
||||
let btn = container(
|
||||
button(
|
||||
@@ -655,7 +728,7 @@ impl Ribbon {
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
acc.push(btn.into());
|
||||
acc.push(keytips::anchor_with(|| format!("tab:{}", module.id()), btn));
|
||||
acc
|
||||
},
|
||||
);
|
||||
@@ -717,38 +790,36 @@ impl Ribbon {
|
||||
} else {
|
||||
self.active
|
||||
};
|
||||
let tool_area: Element<'_, Message> =
|
||||
if let Some(module) = self.modules.get(effective_active) {
|
||||
let groups = module.ribbon_groups();
|
||||
let style_ctx = StyleContext {
|
||||
text_style_names: &self.text_style_names,
|
||||
active_text_style: &self.active_text_style,
|
||||
dim_style_names: &self.dim_style_names,
|
||||
active_dim_style: &self.active_dim_style,
|
||||
mleader_style_names: &self.mleader_style_names,
|
||||
active_mleader_style: &self.active_mleader_style,
|
||||
table_style_names: &self.table_style_names,
|
||||
active_table_style: &self.active_table_style,
|
||||
};
|
||||
|
||||
// Adaptive tool area: panels sit on one row; when they don't all
|
||||
// fit they degrade from the right — a panel's large buttons first
|
||||
// shrink to compact icon columns, then it collapses to a ▾ flyout
|
||||
// button. See `CollapsePanels`.
|
||||
let ts = self.toggle_state(show_block_palette);
|
||||
let panels: Vec<Panel<'_>> = groups
|
||||
.iter()
|
||||
.map(|g| self.panel(g, ts, &style_ctx, &|_| Vec::new()))
|
||||
.collect();
|
||||
CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H)
|
||||
.report_height(self.tool_bar_h.clone())
|
||||
.report_tight(self.collapse_tight.clone())
|
||||
.mode(self.collapse_mode)
|
||||
.into()
|
||||
} else {
|
||||
text("").into()
|
||||
let tool_area: Element<'_, Message> = if self.modules.get(effective_active).is_some() {
|
||||
let groups = self.groups_for(effective_active);
|
||||
let style_ctx = StyleContext {
|
||||
text_style_names: &self.text_style_names,
|
||||
active_text_style: &self.active_text_style,
|
||||
dim_style_names: &self.dim_style_names,
|
||||
active_dim_style: &self.active_dim_style,
|
||||
mleader_style_names: &self.mleader_style_names,
|
||||
active_mleader_style: &self.active_mleader_style,
|
||||
table_style_names: &self.table_style_names,
|
||||
active_table_style: &self.active_table_style,
|
||||
};
|
||||
|
||||
// Adaptive tool area: panels sit on one row; when they don't all
|
||||
// fit they degrade from the right — a panel's large buttons first
|
||||
// shrink to compact icon columns, then it collapses to a ▾ flyout
|
||||
// button. See `CollapsePanels`.
|
||||
let ts = self.toggle_state(show_block_palette);
|
||||
let panels: Vec<Panel<'_>> = groups
|
||||
.map(|g| self.panel(g, ts, &style_ctx, &|_| Vec::new()))
|
||||
.collect();
|
||||
CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H)
|
||||
.report_height(self.tool_bar_h.clone())
|
||||
.report_tight(self.collapse_tight.clone())
|
||||
.mode(self.collapse_mode)
|
||||
.into()
|
||||
} else {
|
||||
text("").into()
|
||||
};
|
||||
|
||||
let tool_bar: Element<'_, Message> = container(tool_area)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(theme.palette().background.weakest.color)),
|
||||
@@ -793,7 +864,7 @@ impl Ribbon {
|
||||
tool_bar
|
||||
};
|
||||
|
||||
column![tab_bar, tool_bar].into()
|
||||
keytips::ribbon_area(column![tab_bar, tool_bar])
|
||||
}
|
||||
|
||||
// ── Dropdown overlay ──────────────────────────────────────────────────
|
||||
@@ -915,8 +986,7 @@ impl Ribbon {
|
||||
return Some(ov);
|
||||
}
|
||||
|
||||
let module = self.modules.get(self.active)?;
|
||||
let groups = module.ribbon_groups();
|
||||
let groups = self.groups_for(self.active);
|
||||
let mut items_list: Option<Vec<(&'static str, &'static str, IconKind)>> = None;
|
||||
let mut dd_default = "";
|
||||
let mut dd_id: &'static str = "";
|
||||
@@ -964,7 +1034,7 @@ impl Ribbon {
|
||||
.then_some(theme.palette().background.base.text.scale_alpha(0.72)),
|
||||
});
|
||||
|
||||
button(
|
||||
let item = button(
|
||||
row![checkmark, icon_el, label_el]
|
||||
.spacing(4)
|
||||
.align_y(iced::Center),
|
||||
@@ -975,8 +1045,8 @@ impl Ribbon {
|
||||
}))
|
||||
.style(popup_row_style)
|
||||
.width(Fill)
|
||||
.padding([4, 10])
|
||||
.into()
|
||||
.padding([4, 10]);
|
||||
keytips::anchor_with(|| keytips::item_anchor(dd_id, cmd), item)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1145,7 +1215,7 @@ impl Ribbon {
|
||||
/// Draw-tab dropdowns — so the list grows to fit its entries instead of
|
||||
/// being clipped to the ribbon-row height. (#153)
|
||||
fn style_combo_overlay(&self, open_id: &str, win: (f32, f32)) -> Option<Element<'_, Message>> {
|
||||
let groups = self.modules.get(self.active)?.ribbon_groups();
|
||||
let groups = self.groups_for(self.active);
|
||||
|
||||
// Locate the open style combo; capture its style key + manager command.
|
||||
let mut found: Option<(crate::modules::StyleKey, Option<&'static str>)> = None;
|
||||
@@ -1568,7 +1638,10 @@ fn collapse_button<'a>(
|
||||
.style(opener_style(holds))
|
||||
.width(Fill)
|
||||
.padding([3, 5]);
|
||||
return automatic_large_button(localized_title, content.into());
|
||||
return keytips::anchor_with(
|
||||
|| keytips::collapsed_anchor(title),
|
||||
automatic_large_button(localized_title, content.into()),
|
||||
);
|
||||
}
|
||||
|
||||
// Collapsed (not yet tight): a large representative-tool face — a live button
|
||||
@@ -1633,7 +1706,10 @@ fn collapse_button<'a>(
|
||||
.style(opener_style(holds && !face_lit))
|
||||
.width(Fill)
|
||||
.padding([1, 4]);
|
||||
let opener = automatic_large_button(localized_title, opener.into());
|
||||
let opener = keytips::anchor_with(
|
||||
|| keytips::collapsed_anchor(title),
|
||||
automatic_large_button(localized_title, opener.into()),
|
||||
);
|
||||
|
||||
// The large face fills a fixed slot so a collapsed panel is shorter than a full
|
||||
// 3-row panel, letting `CollapsePanels` shrink the ribbon row.
|
||||
@@ -1659,6 +1735,41 @@ mod tests {
|
||||
use super::widgets::is_active_tool;
|
||||
use super::Ribbon;
|
||||
|
||||
#[test]
|
||||
fn block_test_close_panel_extends_draw_only_while_testing() {
|
||||
let mut ribbon = Ribbon::default();
|
||||
let draw = ribbon
|
||||
.modules
|
||||
.iter()
|
||||
.position(|module| module.id() == "draw")
|
||||
.unwrap();
|
||||
assert!(!ribbon
|
||||
.modules
|
||||
.iter()
|
||||
.any(|module| module.id() == "block_test"));
|
||||
let ordinary: Vec<_> = ribbon.groups_for(draw).map(|group| group.title).collect();
|
||||
assert!(!ordinary.contains(&"Close"));
|
||||
ribbon.set_block_editing(true);
|
||||
ribbon.set_block_editing(false);
|
||||
ribbon.set_block_testing(true);
|
||||
assert_eq!(ribbon.active, draw);
|
||||
let testing: Vec<_> = ribbon.groups_for(draw).map(|group| group.title).collect();
|
||||
assert_eq!(&testing[..ordinary.len()], ordinary.as_slice());
|
||||
assert_eq!(testing.last(), Some(&"Close"));
|
||||
ribbon.toggle_collapsed_panel("Close");
|
||||
ribbon.set_block_testing(false);
|
||||
assert!(ribbon.collapsed_open.is_none());
|
||||
assert_eq!(
|
||||
ribbon
|
||||
.groups_for(draw)
|
||||
.map(|group| group.title)
|
||||
.collect::<Vec<_>>(),
|
||||
ordinary
|
||||
);
|
||||
ribbon.set_block_editing(true);
|
||||
assert_eq!(ribbon.modules[ribbon.active].id(), "block_editor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_palette_highlight_follows_the_app_state_passed_to_the_view() {
|
||||
// The Block Palette button highlight is derived from the app's
|
||||
|
||||
+35
-27
@@ -99,7 +99,7 @@ thread_local! {
|
||||
|
||||
/// Dropdown items that report a state and cannot be picked.
|
||||
pub(super) fn is_disabled_item(cmd: &str) -> bool {
|
||||
cmd == "FRAMES3"
|
||||
matches!(cmd, "FRAMES3" | "UOSNAP2")
|
||||
}
|
||||
|
||||
fn ribbon_label_bounds(
|
||||
@@ -543,11 +543,13 @@ pub(super) fn render_small<'a>(
|
||||
.width(Length::Fixed(SMALL_W))
|
||||
.height(ROW_H)
|
||||
.padding([4, 4]);
|
||||
tooltip(btn, make_tip(tip_text), TipPos::Right)
|
||||
.gap(6.0)
|
||||
.delay(Duration::from_millis(400))
|
||||
.style(tip_style)
|
||||
.into()
|
||||
super::keytips::anchor(
|
||||
t.id,
|
||||
tooltip(btn, make_tip(tip_text), TipPos::Right)
|
||||
.gap(6.0)
|
||||
.delay(Duration::from_millis(400))
|
||||
.style(tip_style),
|
||||
)
|
||||
}
|
||||
|
||||
RibbonItem::LabeledTool(t) => {
|
||||
@@ -568,11 +570,13 @@ pub(super) fn render_small<'a>(
|
||||
.width(Length::Fixed(LABELED_SMALL_W + ARROW_W))
|
||||
.height(ROW_H)
|
||||
.padding([3, 4]);
|
||||
tooltip(btn, make_tip(tip_text), TipPos::Right)
|
||||
.gap(6.0)
|
||||
.delay(Duration::from_millis(400))
|
||||
.style(tip_style)
|
||||
.into()
|
||||
super::keytips::anchor(
|
||||
t.id,
|
||||
tooltip(btn, make_tip(tip_text), TipPos::Right)
|
||||
.gap(6.0)
|
||||
.delay(Duration::from_millis(400))
|
||||
.style(tip_style),
|
||||
)
|
||||
}
|
||||
|
||||
RibbonItem::Dropdown {
|
||||
@@ -895,7 +899,7 @@ fn tool_row<'a>(tools: &[ToolDef], active_tool: &Option<String>) -> Element<'a,
|
||||
let event = t.event.clone();
|
||||
let icon_el: Element<Message> = make_icon(t.icon, 16.0);
|
||||
let msg = module_event_to_message(event);
|
||||
tooltip(
|
||||
let btn = tooltip(
|
||||
button(icon_el)
|
||||
.on_press(msg)
|
||||
.style(move |theme: &Theme, status| tool_btn_style(theme, is_active, status))
|
||||
@@ -905,8 +909,8 @@ fn tool_row<'a>(tools: &[ToolDef], active_tool: &Option<String>) -> Element<'a,
|
||||
)
|
||||
.gap(4.0)
|
||||
.delay(Duration::from_millis(400))
|
||||
.style(tip_style)
|
||||
.into()
|
||||
.style(tip_style);
|
||||
super::keytips::anchor(t.id, btn)
|
||||
})
|
||||
.collect();
|
||||
row(btns).spacing(2).align_y(iced::Center).into()
|
||||
@@ -974,15 +978,17 @@ pub(super) fn render_large<'a>(item: &RibbonItem, ctx: &RenderCtx<'_>) -> Elemen
|
||||
bottom: 4.0,
|
||||
left: 4.0,
|
||||
});
|
||||
tooltip(
|
||||
automatic_large_button(label, btn.into()),
|
||||
make_tip(tip_text),
|
||||
TipPos::Right,
|
||||
super::keytips::anchor(
|
||||
t.id,
|
||||
tooltip(
|
||||
automatic_large_button(label, btn.into()),
|
||||
make_tip(tip_text),
|
||||
TipPos::Right,
|
||||
)
|
||||
.gap(6.0)
|
||||
.delay(Duration::from_millis(400))
|
||||
.style(tip_style),
|
||||
)
|
||||
.gap(6.0)
|
||||
.delay(Duration::from_millis(400))
|
||||
.style(tip_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
RibbonItem::LargeDropdown {
|
||||
@@ -1313,11 +1319,13 @@ pub(super) fn quick_access_btn<'a>(
|
||||
.width(Length::Fixed(TOP_HIST_W))
|
||||
.height(24)
|
||||
.padding([2, 0]);
|
||||
tooltip(btn, make_tip(t!(label).into_owned()), TipPos::Bottom)
|
||||
.gap(6.0)
|
||||
.delay(Duration::from_millis(400))
|
||||
.style(tip_style)
|
||||
.into()
|
||||
super::keytips::anchor_with(
|
||||
|| super::keytips::quick_access_anchor(cmd),
|
||||
tooltip(btn, make_tip(t!(label).into_owned()), TipPos::Bottom)
|
||||
.gap(6.0)
|
||||
.delay(Duration::from_millis(400))
|
||||
.style(tip_style),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn render_history_control<'a>(
|
||||
|
||||
+76
-2
@@ -49,6 +49,53 @@ fn tip_panel(label: &'static str) -> Element<'static, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The REFEDIT session toolbar's head: the definition being edited as an
|
||||
/// accent pill (REFEDITNAME) and how many objects the working set holds.
|
||||
pub fn refedit_header(name: &str, count: usize) -> Element<'static, Message> {
|
||||
// Long names are cut so the bar stays narrow; the full name is in the tip.
|
||||
let short: String = if name.chars().count() > 7 {
|
||||
format!("{}…", name.chars().take(6).collect::<String>())
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
let pill = container(text(short).size(10))
|
||||
.padding([1, 6])
|
||||
.style(|theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(palette.primary.base.color)),
|
||||
border: Border {
|
||||
radius: 8.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
text_color: Some(palette.primary.base.text),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
let tip = container(text(name.to_string()).size(11)).padding([2, 6]).style(|theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(palette.background.strong.color)),
|
||||
border: Border {
|
||||
color: palette.background.neutral.color,
|
||||
width: 1.0,
|
||||
radius: 3.0.into(),
|
||||
},
|
||||
text_color: Some(palette.background.strong.text),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
column![
|
||||
tooltip(pill, tip, tooltip::Position::Left).gap(6),
|
||||
text(format!("{} {}", count, crate::tr!("ref-edit", "toolbar-objects")))
|
||||
.size(9)
|
||||
.style(crate::ui::style::common::muted_style),
|
||||
]
|
||||
.spacing(2)
|
||||
.align_x(iced::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Build the floating right-edge vertical toolbar from `tools`, vertically
|
||||
/// centred over the canvas. Returns `None` when `tools` is empty so the caller
|
||||
/// can skip pushing an overlay.
|
||||
@@ -61,12 +108,39 @@ pub fn view_with_active(
|
||||
tools: &[ToolDef],
|
||||
active: &dyn Fn(&str) -> bool,
|
||||
) -> Option<Element<'static, Message>> {
|
||||
if tools.is_empty() {
|
||||
view_sections(None, &[tools], active)
|
||||
}
|
||||
|
||||
/// As [`view_with_active`], with `header` on top and a rule between the
|
||||
/// groups of tools.
|
||||
pub fn view_sections(
|
||||
header: Option<Element<'static, Message>>,
|
||||
groups: &[&[ToolDef]],
|
||||
active: &dyn Fn(&str) -> bool,
|
||||
) -> Option<Element<'static, Message>> {
|
||||
if groups.iter().all(|g| g.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut col = column![].spacing(4).align_x(iced::Center);
|
||||
for t in tools {
|
||||
if let Some(header) = header {
|
||||
col = col.push(header);
|
||||
}
|
||||
let tools = groups.iter().filter(|g| !g.is_empty()).enumerate().flat_map(|(group, tools)| {
|
||||
tools.iter().enumerate().map(move |(k, t)| (group > 0 && k == 0, t))
|
||||
});
|
||||
for (starts_group, t) in tools {
|
||||
if starts_group {
|
||||
col = col.push(
|
||||
container(iced::widget::Space::new())
|
||||
.width(Length::Fixed(BTN_SIZE - 4.0))
|
||||
.height(Length::Fixed(1.0))
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(theme.palette().background.neutral.color)),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
}
|
||||
let on = active(t.id);
|
||||
let btn = button(icon_el(t.icon))
|
||||
.on_press(Message::RibbonToolClick {
|
||||
|
||||
@@ -59,6 +59,8 @@ pub struct AttrRow {
|
||||
pub color: AcadColor,
|
||||
pub linetype: String,
|
||||
pub line_weight: LineWeight,
|
||||
/// Constant attributes cannot be edited.
|
||||
pub is_constant: bool,
|
||||
}
|
||||
|
||||
/// Combined justification options (horizontal × vertical), matching the
|
||||
@@ -215,7 +217,24 @@ fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled_field_style(theme: &Theme, _status: text_input::Status) -> text_input::Style {
|
||||
let palette = theme.palette();
|
||||
text_input::Style {
|
||||
background: Background::Color(palette.background.weak.color),
|
||||
border: Border {
|
||||
color: palette.background.neutral.color,
|
||||
width: 1.0,
|
||||
radius: 3.0.into(),
|
||||
},
|
||||
icon: palette.background.base.text.scale_alpha(0.38),
|
||||
placeholder: palette.background.base.text.scale_alpha(0.38),
|
||||
value: palette.background.base.text.scale_alpha(0.48),
|
||||
selection: palette.primary.base.color.scale_alpha(0.2),
|
||||
}
|
||||
}
|
||||
|
||||
/// One `label : widget` row with a fixed-width label column.
|
||||
|
||||
fn field_row<'a>(
|
||||
label: Cow<'static, str>,
|
||||
widget: Element<'a, Message>,
|
||||
@@ -469,14 +488,21 @@ fn attribute_tab<'a>(
|
||||
}
|
||||
|
||||
let sel = selected.min(rows.len() - 1);
|
||||
let value_box = text_input("", rows[sel].value.as_str())
|
||||
.on_input(move |v| Message::AttrEditorInput { idx: sel, value: v })
|
||||
.on_submit(Message::AttrEditorApply)
|
||||
.style(field_style)
|
||||
let is_constant = rows[sel].is_constant;
|
||||
let mut value_box = text_input("", rows[sel].value.as_str())
|
||||
.size(13)
|
||||
.padding([4, 6])
|
||||
.width(width);
|
||||
|
||||
if is_constant {
|
||||
value_box = value_box.style(disabled_field_style);
|
||||
} else {
|
||||
value_box = value_box
|
||||
.on_input(move |v| Message::AttrEditorInput { idx: sel, value: v })
|
||||
.on_submit(Message::AttrEditorApply)
|
||||
.style(field_style);
|
||||
}
|
||||
|
||||
column![
|
||||
head,
|
||||
scrollable(list).width(width).height(height),
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
use crate::app::Message;
|
||||
use crate::scene::WireModel;
|
||||
use crate::ui::style::common::muted_style;
|
||||
use crate::ui::style::form::{hdivider, vsep};
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
|
||||
use iced::{Element, Fill, Length, Theme};
|
||||
|
||||
pub(crate) struct State {
|
||||
pub document_id: u64,
|
||||
pub name: String,
|
||||
pub existing_names: Vec<String>,
|
||||
pub preview: Vec<WireModel>,
|
||||
pub description: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn list_item_style(selected: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
|
||||
move |theme: &Theme, status| {
|
||||
if selected {
|
||||
button::primary(theme, status)
|
||||
} else {
|
||||
button::subtle(theme, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn view(state: &State, sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> {
|
||||
let can_apply = state.error.is_none() && !state.name.trim().is_empty();
|
||||
|
||||
// ── Left: Block name field and list of existing blocks ─────────────────────
|
||||
let name_input = text_input(crate::t!("Block name").as_ref(), &state.name)
|
||||
.on_input(Message::BlockEditPickerName)
|
||||
.on_submit_maybe(can_apply.then_some(Message::BlockEditPickerApply))
|
||||
.style(crate::ui::style::form::field_style);
|
||||
|
||||
let list_items: Vec<Element<'_, Message>> = state
|
||||
.existing_names
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let is_sel = name.eq_ignore_ascii_case(state.name.trim());
|
||||
button(text(name.clone()).size(12))
|
||||
.on_press(Message::BlockEditPickerSelect(name.clone()))
|
||||
.style(list_item_style(is_sel))
|
||||
.padding([4, 8])
|
||||
.width(Fill)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list_scrollable = container(scrollable(column(list_items).spacing(2)).height(Fill))
|
||||
.style(container::bordered_box)
|
||||
.padding(2)
|
||||
.height(Fill);
|
||||
|
||||
let left_col = column![
|
||||
text(crate::t!("Block to create or edit").into_owned()).size(12),
|
||||
name_input,
|
||||
list_scrollable,
|
||||
]
|
||||
.spacing(8)
|
||||
.width(Length::Fixed(240.0))
|
||||
.height(Fill);
|
||||
|
||||
// ── Right: Preview and Description ─────────────────────────────────────────
|
||||
let preview_box = column![
|
||||
text(crate::t!("Preview").into_owned()).size(12),
|
||||
container(super::block_palette::preview_canvas(&state.preview, 150.0))
|
||||
.style(container::bordered_box)
|
||||
.width(Fill)
|
||||
.height(Length::Fixed(150.0)),
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
let desc_text = if state.description.is_empty() {
|
||||
crate::t!("(No description)").into_owned()
|
||||
} else {
|
||||
state.description.clone()
|
||||
};
|
||||
|
||||
let desc_box = column![
|
||||
text(crate::t!("Description").into_owned()).size(12),
|
||||
container(scrollable(text(desc_text).size(11).style(muted_style)).height(Fill))
|
||||
.style(container::bordered_box)
|
||||
.padding(6)
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
]
|
||||
.spacing(4)
|
||||
.height(Fill);
|
||||
|
||||
let right_col = column![preview_box, desc_box]
|
||||
.spacing(8)
|
||||
.width(Fill)
|
||||
.height(Fill);
|
||||
|
||||
// ── Body ───────────────────────────────────────────────────────────────────
|
||||
let body = row![left_col, vsep(sizing.height), right_col]
|
||||
.spacing(12)
|
||||
.height(Fill);
|
||||
|
||||
// ── Bottom: Error message and OK / Cancel buttons ───────────────────────────
|
||||
let error_text = text(state.error.clone().unwrap_or_default())
|
||||
.size(11)
|
||||
.style(text::danger);
|
||||
|
||||
let buttons = row![
|
||||
error_text,
|
||||
Space::new().width(Fill),
|
||||
button(text(crate::t!("OK").into_owned()))
|
||||
.on_press_maybe(can_apply.then_some(Message::BlockEditPickerApply))
|
||||
.style(button::primary),
|
||||
button(text(crate::t!("Cancel").into_owned())).on_press(Message::CloseModal),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center);
|
||||
|
||||
container(column![body, hdivider(sizing.width), buttons].spacing(10))
|
||||
.padding(16)
|
||||
.width(sizing.width)
|
||||
.height(sizing.height)
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use crate::app::Message;
|
||||
use crate::scene::WireModel;
|
||||
use iced::widget::{button, column, container, pick_list, row, text, text_input, Space};
|
||||
use iced::{Element, Fill};
|
||||
|
||||
pub(crate) struct State {
|
||||
pub document_id: u64,
|
||||
pub source: codec::Handle,
|
||||
pub name: String,
|
||||
pub existing_names: Vec<String>,
|
||||
pub preview: Vec<WireModel>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn view(state: &State, sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> {
|
||||
let field = text_input(crate::t!("Block name").as_ref(), &state.name)
|
||||
.on_input(Message::BlockSaveAsName)
|
||||
.on_submit_maybe(state.error.is_none().then_some(Message::BlockSaveAsApply))
|
||||
.style(crate::ui::style::form::field_style);
|
||||
let names = pick_list(
|
||||
None::<String>,
|
||||
state.existing_names.as_slice(),
|
||||
|name: &String| name.clone(),
|
||||
)
|
||||
.on_select(Message::BlockSaveAsName)
|
||||
.placeholder(crate::t!("Existing blocks").into_owned())
|
||||
.width(Fill);
|
||||
container(
|
||||
column![
|
||||
text(crate::t!("Block name").into_owned()).size(13),
|
||||
field,
|
||||
names,
|
||||
super::block_palette::preview_canvas(&state.preview, 160.0),
|
||||
text(state.error.clone().unwrap_or_default())
|
||||
.size(12)
|
||||
.style(text::danger),
|
||||
row![
|
||||
Space::new().width(Fill),
|
||||
button(text(crate::t!("OK").into_owned()))
|
||||
.on_press_maybe(state.error.is_none().then_some(Message::BlockSaveAsApply))
|
||||
.style(button::primary),
|
||||
button(text(crate::t!("Cancel").into_owned())).on_press(Message::CloseModal),
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(10),
|
||||
)
|
||||
.padding(16)
|
||||
.width(sizing.width)
|
||||
.height(sizing.height)
|
||||
.into()
|
||||
}
|
||||
@@ -44,6 +44,7 @@ pub struct DraftingSettingsState {
|
||||
pub polar_increment_deg: f32,
|
||||
// Object Snap
|
||||
pub osnap_on: bool,
|
||||
pub osnap_aperture_input: String,
|
||||
pub otrack_on: bool,
|
||||
pub snap_modes: rustc_hash::FxHashSet<SnapType>,
|
||||
// 3D Object Snap
|
||||
@@ -78,6 +79,7 @@ impl DraftingSettingsState {
|
||||
|| self.ortho_on != saved.ortho_on
|
||||
|| (self.polar_increment_deg - saved.polar_increment_deg).abs() > 0.001
|
||||
|| self.osnap_on != saved.osnap_on
|
||||
|| self.osnap_aperture_input != saved.osnap_aperture_input
|
||||
|| self.otrack_on != saved.otrack_on
|
||||
|| self.snap_modes != saved.snap_modes
|
||||
|| self.osnap3d_on != saved.osnap3d_on
|
||||
@@ -508,6 +510,14 @@ pub fn view_window<'a>(
|
||||
|
||||
column![
|
||||
row![osnap_on_toggle, Space::new().width(24), otrack_on_toggle].align_y(iced::Center),
|
||||
row![
|
||||
text("OSNAP capture radius (pixels)").size(11),
|
||||
text_input("25", &state.osnap_aperture_input)
|
||||
.on_input(Message::DraftingSettingsOsnapApertureChanged)
|
||||
.width(70),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center),
|
||||
action_buttons,
|
||||
Space::new().height(4),
|
||||
snap_modes_group,
|
||||
|
||||
@@ -737,6 +737,13 @@ fn name_tip<'a>(name: &'a str) -> Element<'a, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
|
||||
/// The layer-name field while a rename is open; a click that moves focus
|
||||
/// elsewhere commits the typed name.
|
||||
pub fn rename_input_id() -> iced::widget::Id {
|
||||
iced::widget::Id::new("layer-rename-input")
|
||||
}
|
||||
|
||||
fn layer_row<'a>(
|
||||
index: usize,
|
||||
layer: &'a Layer,
|
||||
@@ -803,6 +810,7 @@ fn layer_row<'a>(
|
||||
// Name cell
|
||||
let name_cell: Element<'_, Message> = if is_editing {
|
||||
text_input("", edit_buf)
|
||||
.id(rename_input_id())
|
||||
.on_input(Message::LayerRenameEdit)
|
||||
.on_submit(Message::LayerRenameCommit)
|
||||
.size(FONT_SZ)
|
||||
|
||||
@@ -5,6 +5,8 @@ pub mod attdef_dialog;
|
||||
pub mod attribute_editor;
|
||||
pub mod auto_constrain_settings;
|
||||
pub mod block_definition;
|
||||
pub mod block_edit_picker;
|
||||
pub mod block_save_as;
|
||||
pub mod block_palette;
|
||||
pub mod browser;
|
||||
pub mod count_palette;
|
||||
@@ -29,6 +31,7 @@ pub mod plot;
|
||||
pub mod plugin_manager;
|
||||
pub mod print_all;
|
||||
pub mod recovery;
|
||||
pub mod refedit_dialog;
|
||||
pub mod sheet_set;
|
||||
pub mod shortcuts;
|
||||
pub mod update_notice;
|
||||
|
||||
@@ -53,6 +53,14 @@ pub struct AppPrefs {
|
||||
pub cliprompt_lines: i32,
|
||||
/// COMMANDLINEFADETIME: how long overlay history lines stay visible, in ms.
|
||||
pub commandline_fade_ms: i32,
|
||||
/// COMMANDLINEOPACITY: command-line panel opacity in percent.
|
||||
pub command_line_opacity: u8,
|
||||
/// MENUBAR: the pull-down menus show above the ribbon.
|
||||
pub show_menu_bar: bool,
|
||||
/// The ribbon shows (RIBBON / RIBBONCLOSE).
|
||||
pub show_ribbon: bool,
|
||||
/// HPASSOC: new hatches follow their boundary objects.
|
||||
pub hatch_associative: bool,
|
||||
/// ZOOMWHEEL: reverse the mouse-wheel zoom direction.
|
||||
pub zoom_wheel_reversed: bool,
|
||||
/// Scroll pans and Ctrl+scroll zooms (touchpads).
|
||||
@@ -856,6 +864,30 @@ pub fn view_window<'a>(
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.push(Space::new().height(24))
|
||||
.push(text(crate::t!("Window Elements")).size(15))
|
||||
.push(Space::new().height(10))
|
||||
.push(
|
||||
row![
|
||||
iced::widget::checkbox(prefs.show_menu_bar)
|
||||
.on_toggle(Message::MenuBarChanged)
|
||||
.size(15),
|
||||
text(crate::t!("Show the menu bar (MENUBAR)")).size(12),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.push(Space::new().height(8))
|
||||
.push(
|
||||
row![
|
||||
iced::widget::checkbox(prefs.show_ribbon)
|
||||
.on_toggle(Message::RibbonVisibleChanged)
|
||||
.size(15),
|
||||
text(crate::t!("Show the ribbon (RIBBON / RIBBONCLOSE)")).size(12),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.push(Space::new().height(24))
|
||||
.push(text(crate::t!("Command Line")).size(15))
|
||||
.push(Space::new().height(10))
|
||||
.push(
|
||||
@@ -912,6 +944,22 @@ pub fn view_window<'a>(
|
||||
))
|
||||
.size(11)
|
||||
.width(sizing.width),
|
||||
)
|
||||
.push(Space::new().height(12))
|
||||
.push(
|
||||
row![
|
||||
text(crate::t!("Command line opacity")).size(12).width(140),
|
||||
slider(
|
||||
20..=100,
|
||||
prefs.command_line_opacity.clamp(20, 100),
|
||||
Message::CommandLineOpacityChanged,
|
||||
)
|
||||
.step(5u8)
|
||||
.width(Fill),
|
||||
text(format!("{}%", prefs.command_line_opacity)).size(11).width(44),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
|
||||
let display_element = display.spacing(0).width(sizing.width);
|
||||
@@ -1399,6 +1447,17 @@ pub fn view_window<'a>(
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center),
|
||||
Space::new().height(24),
|
||||
text(crate::t!("Hatch")).size(15),
|
||||
Space::new().height(10),
|
||||
row![
|
||||
iced::widget::checkbox(prefs.hatch_associative)
|
||||
.on_toggle(Message::HatchAssociativeChanged)
|
||||
.size(15),
|
||||
text(crate::t!("New hatches follow their boundaries (HPASSOC)")).size(12),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
]
|
||||
.spacing(0)
|
||||
.width(sizing.width);
|
||||
@@ -1457,6 +1516,22 @@ pub fn view_window<'a>(
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center),
|
||||
Space::new().height(14),
|
||||
row![
|
||||
text(crate::t!("Snap marker size")).size(12).width(150),
|
||||
slider(
|
||||
1..=20,
|
||||
model_space.snap_marker_size.clamp(1, 20),
|
||||
Message::SnapMarkerSizeChanged,
|
||||
)
|
||||
.step(1)
|
||||
.width(Fill),
|
||||
text(format!("{} px", model_space.snap_marker_size.clamp(1, 20)))
|
||||
.size(11)
|
||||
.width(44),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center),
|
||||
Space::new().height(24),
|
||||
text(crate::t!("Snap and Grid")).size(15),
|
||||
Space::new().height(10),
|
||||
|
||||
@@ -111,6 +111,35 @@ pub enum PdfDialogMsg {
|
||||
ImportOk,
|
||||
Options,
|
||||
Help(&'static str),
|
||||
// View background
|
||||
BgKind(BgKind),
|
||||
/// Open the colour window for a slot: 0 solid, 1 top, 2 middle, 3 bottom.
|
||||
BgPickColor(u8),
|
||||
BgColor(u8, [u8; 3]),
|
||||
BgThree(bool),
|
||||
BgRotation(String),
|
||||
BgImage(String),
|
||||
BgBrowse,
|
||||
BgPosition(BgPosition),
|
||||
BgAspect(bool),
|
||||
/// An image background's offset (pixels, x and y) and scale, as typed.
|
||||
BgOffset([String; 2]),
|
||||
BgScale([String; 2]),
|
||||
/// A spinner step on offset x / y (0, 1) or scale x / y (2, 3).
|
||||
BgStep(u8, i8),
|
||||
/// Offset 0, scale 1.
|
||||
BgReset,
|
||||
BgApply,
|
||||
// Image Adjust: rows 0 brightness, 1 contrast, 2 fade.
|
||||
/// A row's value from its bar.
|
||||
IaValue(u8, u8),
|
||||
/// A row's box, as typed.
|
||||
IaText(u8, String),
|
||||
/// One step down (-1) or up (+1).
|
||||
IaStep(u8, i8),
|
||||
/// Brightness 50, contrast 50, fade 0.
|
||||
IaReset,
|
||||
IaApply,
|
||||
}
|
||||
|
||||
fn msg(m: PdfDialogMsg) -> Message {
|
||||
@@ -283,7 +312,7 @@ pub(crate) fn well_style(theme: &Theme) -> container::Style {
|
||||
}
|
||||
|
||||
/// A titled card.
|
||||
fn card<'a>(title: String, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
pub(crate) fn card<'a>(title: String, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
container(
|
||||
column![
|
||||
text(title.to_uppercase()).size(10).style(accent_text),
|
||||
@@ -1018,10 +1047,16 @@ pub fn view_layers<'a>(
|
||||
) -> Element<'a, Message> {
|
||||
let names: Vec<String> = state.targets.iter().map(|t| t.name.clone()).collect();
|
||||
let target = state.targets.get(state.current);
|
||||
let has_underlays = !names.is_empty();
|
||||
let underlay = pick_list(target.map(|t| t.name.clone()), names, |n: &String| {
|
||||
n.clone()
|
||||
})
|
||||
.on_select(|n| msg(PdfDialogMsg::LayersUnderlay(n)))
|
||||
});
|
||||
// No underlays: the list stays empty and disabled.
|
||||
let underlay = if has_underlays {
|
||||
underlay.on_select(|n| msg(PdfDialogMsg::LayersUnderlay(n)))
|
||||
} else {
|
||||
underlay
|
||||
}
|
||||
.text_size(12)
|
||||
.padding([5, 8])
|
||||
.width(Fill);
|
||||
@@ -2131,3 +2166,698 @@ pub fn view_pc_section<'a>(
|
||||
.width(sizing.width)
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── View background (-VIEW sEttings Background) ─────────────────────────
|
||||
|
||||
/// What a named view shows behind the drawing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BgKind {
|
||||
None,
|
||||
Solid,
|
||||
Gradient,
|
||||
Image,
|
||||
}
|
||||
|
||||
/// Where an image background sits: centred, stretched over the view, or
|
||||
/// repeated.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BgPosition {
|
||||
Center,
|
||||
Stretch,
|
||||
Tile,
|
||||
}
|
||||
|
||||
impl BgPosition {
|
||||
pub const ALL: [BgPosition; 3] = [BgPosition::Center, BgPosition::Stretch, BgPosition::Tile];
|
||||
}
|
||||
|
||||
impl fmt::Display for BgPosition {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&match self {
|
||||
BgPosition::Center => crate::tr!("view-background", "center"),
|
||||
BgPosition::Stretch => crate::tr!("view-background", "stretch"),
|
||||
BgPosition::Tile => crate::tr!("view-background", "tile"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A background image as the preview draws it.
|
||||
pub struct BgPreview {
|
||||
pub size: [u32; 2],
|
||||
/// Unmirrored, mirrored in x, in y, in both.
|
||||
pub handles: [image::Handle; 4],
|
||||
}
|
||||
|
||||
/// Loads a background image for the preview, at most 512 pixels a side.
|
||||
pub fn bg_preview(path: &str) -> Option<BgPreview> {
|
||||
let decoded = ::image::open(path.trim()).ok()?;
|
||||
let size = [decoded.width(), decoded.height()];
|
||||
let small = if size[0].max(size[1]) > 512 { decoded.thumbnail(512, 512) } else { decoded }.to_rgba8();
|
||||
let handle = |pixels: ::image::RgbaImage| image::Handle::from_rgba(pixels.width(), pixels.height(), pixels.into_raw());
|
||||
let flip_x = ::image::imageops::flip_horizontal(&small);
|
||||
let flip_y = ::image::imageops::flip_vertical(&small);
|
||||
let flip_xy = ::image::imageops::flip_vertical(&flip_x);
|
||||
Some(BgPreview { size, handles: [handle(small), handle(flip_x), handle(flip_y), handle(flip_xy)] })
|
||||
}
|
||||
|
||||
/// Image offsets the reference's Adjust Image sliders reach, in pixels.
|
||||
pub const BG_OFFSET_LIMIT: f64 = 2000.0;
|
||||
/// Image scales the reference's sliders reach (slider value / 10).
|
||||
pub const BG_SCALE_LIMIT: f64 = 10.0;
|
||||
|
||||
/// One spinner step on an image background field: a pixel for an offset, a
|
||||
/// tenth for a scale, which skips 0 (the reference ignores a zero scale).
|
||||
pub fn bg_step(field: u8, value: f64, dir: i8) -> f64 {
|
||||
let dir = f64::from(dir.signum());
|
||||
if field < 2 {
|
||||
return (value.round() + dir).clamp(-BG_OFFSET_LIMIT, BG_OFFSET_LIMIT);
|
||||
}
|
||||
let mut next = ((value * 10.0).round() + dir) / 10.0;
|
||||
if next.abs() < 0.05 {
|
||||
next += dir / 10.0;
|
||||
}
|
||||
next.clamp(-BG_SCALE_LIMIT, BG_SCALE_LIMIT)
|
||||
}
|
||||
|
||||
/// The Background dialog's fields for one named view.
|
||||
pub struct ViewBackgroundState {
|
||||
pub view: String,
|
||||
pub kind: BgKind,
|
||||
pub solid: [u8; 3],
|
||||
pub top: [u8; 3],
|
||||
pub middle: [u8; 3],
|
||||
pub bottom: [u8; 3],
|
||||
pub three: bool,
|
||||
pub rotation: String,
|
||||
pub image: String,
|
||||
pub position: BgPosition,
|
||||
pub aspect: bool,
|
||||
/// The image's offset (pixels) and scale as typed, and the scale the
|
||||
/// view's image had (kept for a zero scale).
|
||||
pub offset: [String; 2],
|
||||
pub scale: [String; 2],
|
||||
pub scale_base: [f64; 2],
|
||||
/// The chosen image for the preview: its size in pixels and a small copy
|
||||
/// as it is and mirrored (x, y, both), for negative scales.
|
||||
pub preview: Option<BgPreview>,
|
||||
/// The drawing viewport's size, which the preview stands for.
|
||||
pub viewport: [f32; 2],
|
||||
/// The drawing area colour, shown for no background.
|
||||
pub canvas: [u8; 3],
|
||||
}
|
||||
|
||||
pub fn view_background<'a>(
|
||||
state: &'a ViewBackgroundState,
|
||||
sizing: crate::ui::modal::ModalSizing,
|
||||
) -> Element<'a, Message> {
|
||||
let kind = segmented(
|
||||
vec![
|
||||
(BgKind::None, crate::tr!("view-background", "none")),
|
||||
(BgKind::Solid, crate::tr!("view-background", "solid")),
|
||||
(BgKind::Gradient, crate::tr!("view-background", "gradient")),
|
||||
(BgKind::Image, crate::tr!("view-background", "image")),
|
||||
],
|
||||
state.kind,
|
||||
PdfDialogMsg::BgKind,
|
||||
);
|
||||
let rgb = |c: [u8; 3]| iced::Color::from_rgb8(c[0], c[1], c[2]);
|
||||
let fill = |background: Background| {
|
||||
move |_: &Theme| container::Style {
|
||||
background: Some(background),
|
||||
border: Border {
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
color: iced::Color::from_rgb8(0x4a, 0x4a, 0x4a),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
let note = |label: String| text(label).size(11).style(muted_style);
|
||||
let preview: Element<'a, Message> = match state.kind {
|
||||
BgKind::None => container(note(crate::tr!("view-background", "canvas")))
|
||||
.center(Fill)
|
||||
.style(fill(Background::Color(rgb(state.canvas))))
|
||||
.into(),
|
||||
BgKind::Solid => container(Space::new())
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(fill(Background::Color(rgb(state.solid))))
|
||||
.into(),
|
||||
BgKind::Gradient => {
|
||||
// Bottom colour at the start, top at the end, turned
|
||||
// counter-clockwise by the rotation.
|
||||
let angle = state.rotation.trim().parse::<f32>().unwrap_or(0.0);
|
||||
let mut linear = iced::gradient::Linear::new(iced::Radians(-angle.to_radians()))
|
||||
.add_stop(0.0, rgb(state.bottom));
|
||||
if state.three {
|
||||
linear = linear.add_stop(0.5, rgb(state.middle));
|
||||
}
|
||||
linear = linear.add_stop(1.0, rgb(state.top));
|
||||
container(Space::new())
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(fill(Background::Gradient(iced::Gradient::Linear(linear))))
|
||||
.into()
|
||||
}
|
||||
BgKind::Image => {
|
||||
if std::path::Path::new(state.image.trim()).is_file() {
|
||||
let canvas = Background::Color(rgb(state.canvas));
|
||||
if state.position == BgPosition::Stretch {
|
||||
// Filling the view; keeping the aspect ratio it covers
|
||||
// the view and the overflow is cut off.
|
||||
let fit = if state.aspect { iced::ContentFit::Cover } else { iced::ContentFit::Fill };
|
||||
let handle = state.preview.as_ref().map_or_else(
|
||||
|| image::Handle::from_path(state.image.trim()),
|
||||
|preview| preview.handles[0].clone(),
|
||||
);
|
||||
container(image(handle).content_fit(fit).width(Fill).height(Fill))
|
||||
.center(Fill)
|
||||
.clip(true)
|
||||
.style(fill(canvas))
|
||||
.into()
|
||||
} else {
|
||||
// The image at its size times its scale, moved by its
|
||||
// offset, in a view of the drawing viewport's shape.
|
||||
let parse = |v: &String, d: f64| v.trim().parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(d);
|
||||
let offset = [parse(&state.offset[0], 0.0), parse(&state.offset[1], 0.0)];
|
||||
let scale = [0, 1].map(|k| {
|
||||
let v = parse(&state.scale[k], state.scale_base[k]);
|
||||
if v.abs() < 1e-9 { state.scale_base[k] } else { v }
|
||||
});
|
||||
let [iw, ih] = state.preview.as_ref().map_or([1, 1], |preview| preview.size);
|
||||
// A negative scale mirrors the image.
|
||||
let mirrored = usize::from(scale[0] < 0.0) | usize::from(scale[1] < 0.0) << 1;
|
||||
let handle = state.preview.as_ref().map_or_else(
|
||||
|| image::Handle::from_path(state.image.trim()),
|
||||
|preview| preview.handles[mirrored].clone(),
|
||||
);
|
||||
let viewport = state.viewport;
|
||||
// The image's pixels and offsets are device pixels; the
|
||||
// viewport is measured in logical ones.
|
||||
let device = crate::scene::view::device_scale().max(0.1);
|
||||
let tile = state.position == BgPosition::Tile;
|
||||
container(iced::widget::responsive(move |size| {
|
||||
let factor = size.height / viewport[1].max(1.0) / device;
|
||||
let w = (iw as f32 * scale[0].abs() as f32 * factor).max(1.0);
|
||||
let h = (ih as f32 * scale[1].abs() as f32 * factor).max(1.0);
|
||||
let x = size.width / 2.0 + offset[0] as f32 * factor - w / 2.0;
|
||||
let y = size.height / 2.0 + offset[1] as f32 * factor - h / 2.0;
|
||||
let (cols, rows) = if tile {
|
||||
((size.width / w).ceil() as i32 + 1, (size.height / h).ceil() as i32 + 1)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
let mut layers = iced::widget::Stack::new()
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.push(container(Space::new()).width(Fill).height(Fill));
|
||||
let x0 = if tile { x - (x / w).ceil() * w } else { x };
|
||||
let y0 = if tile { y - (y / h).ceil() * h } else { y };
|
||||
for row_index in 0..=rows.min(60) {
|
||||
for col_index in 0..=cols.min(60) {
|
||||
let at = iced::Point::new(x0 + col_index as f32 * w, y0 + row_index as f32 * h);
|
||||
layers = layers.push(
|
||||
iced::widget::pin(
|
||||
image(handle.clone())
|
||||
.content_fit(iced::ContentFit::Fill)
|
||||
.width(Length::Fixed(w))
|
||||
.height(Length::Fixed(h)),
|
||||
)
|
||||
.position(at),
|
||||
);
|
||||
}
|
||||
}
|
||||
Element::<Message>::from(layers)
|
||||
}))
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.clip(true)
|
||||
.style(fill(canvas))
|
||||
.into()
|
||||
}
|
||||
} else {
|
||||
container(note(crate::tr!("view-background", "no-image")))
|
||||
.center(Fill)
|
||||
.style(fill(Background::Color(iced::Color::from_rgb8(0x1a, 0x1a, 0x1a))))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
};
|
||||
let preview = card(
|
||||
crate::tr!("view-background", "preview"),
|
||||
container(preview).width(Fill).height(Length::Fixed(150.0)),
|
||||
);
|
||||
let label = |value: String, enabled: bool| {
|
||||
text(value)
|
||||
.size(11)
|
||||
.style(move |theme: &Theme| {
|
||||
if enabled {
|
||||
muted_style(theme)
|
||||
} else {
|
||||
text::Style {
|
||||
color: Some(theme.palette().background.strong.color),
|
||||
}
|
||||
}
|
||||
})
|
||||
.width(Length::Fixed(92.0))
|
||||
};
|
||||
let color_row = |name: String, slot: u8, color: [u8; 3], enabled: bool| -> Element<'a, Message> {
|
||||
let mut pick = button(text(crate::tr!("view-background", "select")).size(11))
|
||||
.style(button_style(false))
|
||||
.padding([4, 10]);
|
||||
if enabled {
|
||||
pick = pick.on_press(msg(PdfDialogMsg::BgPickColor(slot)));
|
||||
}
|
||||
row![
|
||||
label(name, enabled),
|
||||
swatch(color, 64.0, 22.0),
|
||||
text(format!("{},{},{}", color[0], color[1], color[2]))
|
||||
.size(11)
|
||||
.style(muted_style),
|
||||
Space::new().width(Fill),
|
||||
pick,
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
};
|
||||
let options: Element<'a, Message> = match state.kind {
|
||||
BgKind::None => card(
|
||||
crate::tr!("view-background", "info"),
|
||||
note(crate::tr!("view-background", "none-info")),
|
||||
),
|
||||
BgKind::Solid => card(
|
||||
crate::tr!("view-background", "solid-color"),
|
||||
color_row(crate::tr!("view-background", "color"), 0, state.solid, true),
|
||||
),
|
||||
BgKind::Gradient => card(
|
||||
crate::tr!("view-background", "gradient"),
|
||||
column![
|
||||
row![
|
||||
chip(
|
||||
crate::tr!("view-background", "three-colors"),
|
||||
state.three,
|
||||
msg(PdfDialogMsg::BgThree(!state.three))
|
||||
),
|
||||
Space::new().width(Fill),
|
||||
text(crate::tr!("view-background", "rotation"))
|
||||
.size(11)
|
||||
.style(muted_style),
|
||||
text_input("", &state.rotation)
|
||||
.on_input(|v| msg(PdfDialogMsg::BgRotation(v)))
|
||||
.size(11)
|
||||
.padding([4, 8])
|
||||
.width(Length::Fixed(64.0))
|
||||
.style(field_style),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
color_row(crate::tr!("view-background", "top"), 1, state.top, true),
|
||||
color_row(crate::tr!("view-background", "middle"), 2, state.middle, state.three),
|
||||
color_row(crate::tr!("view-background", "bottom"), 3, state.bottom, true),
|
||||
]
|
||||
.spacing(8),
|
||||
),
|
||||
BgKind::Image => card(
|
||||
crate::tr!("view-background", "image"),
|
||||
column![
|
||||
row![
|
||||
text_input("", &state.image)
|
||||
.on_input(|v| msg(PdfDialogMsg::BgImage(v)))
|
||||
.size(11)
|
||||
.padding([4, 8])
|
||||
.width(Fill)
|
||||
.style(field_style),
|
||||
button(text(crate::tr!("view-background", "browse")).size(11))
|
||||
.on_press(msg(PdfDialogMsg::BgBrowse))
|
||||
.style(button_style(false))
|
||||
.padding([4, 10]),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
row![
|
||||
label(crate::tr!("view-background", "position"), true),
|
||||
pick_list(Some(state.position), BgPosition::ALL.to_vec(), |p: &BgPosition| {
|
||||
p.to_string()
|
||||
})
|
||||
.on_select(|p| msg(PdfDialogMsg::BgPosition(p)))
|
||||
.text_size(12)
|
||||
.padding([4, 8])
|
||||
.width(Fill),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
chip(
|
||||
crate::tr!("view-background", "aspect"),
|
||||
state.aspect,
|
||||
msg(PdfDialogMsg::BgAspect(!state.aspect))
|
||||
),
|
||||
]
|
||||
.spacing(8),
|
||||
),
|
||||
};
|
||||
let adjust = (state.kind == BgKind::Image).then(|| adjust_card(state));
|
||||
let mut body = column![kind, preview, options].spacing(10);
|
||||
if let Some(adjust) = adjust {
|
||||
body = body.push(adjust);
|
||||
}
|
||||
column![
|
||||
body,
|
||||
footer(
|
||||
"background",
|
||||
None,
|
||||
crate::tr!("view-background", "apply").into(),
|
||||
PdfDialogMsg::BgApply
|
||||
),
|
||||
]
|
||||
.spacing(10)
|
||||
.padding([10, 12])
|
||||
.width(sizing.width)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The image's offset and scale (the reference's Adjust Image): pixel offsets
|
||||
/// and scales with spinners and a reset; inactive while the image is
|
||||
/// stretched over the view, where the reference ignores them.
|
||||
fn adjust_card<'a>(state: &'a ViewBackgroundState) -> Element<'a, Message> {
|
||||
let enabled = state.position != BgPosition::Stretch;
|
||||
let dim = move |theme: &Theme| {
|
||||
if enabled {
|
||||
muted_style(theme)
|
||||
} else {
|
||||
text::Style { color: Some(theme.palette().background.strong.color) }
|
||||
}
|
||||
};
|
||||
let spin = |field: u8| -> Element<'a, Message> {
|
||||
let value = if field < 2 { &state.offset[field as usize] } else { &state.scale[field as usize - 2] };
|
||||
let mut input = text_input("", value)
|
||||
.size(11)
|
||||
.padding([4, 8])
|
||||
.width(Length::Fixed(64.0))
|
||||
.style(move |theme: &Theme, status| {
|
||||
let mut style = field_style(theme, status);
|
||||
if !enabled {
|
||||
style.value = theme.palette().background.strong.color;
|
||||
}
|
||||
style
|
||||
});
|
||||
if enabled {
|
||||
input = input.on_input(move |v| {
|
||||
if field < 2 {
|
||||
let mut pair = state.offset.clone();
|
||||
pair[field as usize] = v;
|
||||
msg(PdfDialogMsg::BgOffset(pair))
|
||||
} else {
|
||||
let mut pair = state.scale.clone();
|
||||
pair[field as usize - 2] = v;
|
||||
msg(PdfDialogMsg::BgScale(pair))
|
||||
}
|
||||
});
|
||||
}
|
||||
let arrow = |up: bool| {
|
||||
let mut b = button(crate::ui::icons::themed_arrow_toggle(up, 7.0))
|
||||
.style(button_style(false))
|
||||
.padding([1, 4]);
|
||||
if enabled {
|
||||
b = b.on_press(msg(PdfDialogMsg::BgStep(field, if up { 1 } else { -1 })));
|
||||
}
|
||||
b
|
||||
};
|
||||
row![input, column![arrow(true), arrow(false)].spacing(1)].spacing(2).align_y(iced::Center).into()
|
||||
};
|
||||
let axis = |name: &'static str| text(name).size(11).style(dim);
|
||||
let line = |label: String, a: u8, b: u8, unit: bool| -> Element<'a, Message> {
|
||||
let mut r = row![
|
||||
text(label).size(11).style(dim).width(Length::Fixed(72.0)),
|
||||
axis("X"),
|
||||
spin(a),
|
||||
axis("Y"),
|
||||
spin(b),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center);
|
||||
if unit {
|
||||
r = r.push(text(crate::tr!("view-background", "pixels")).size(11).style(dim));
|
||||
}
|
||||
r.into()
|
||||
};
|
||||
let mut reset = button(text(crate::tr!("view-background", "reset")).size(11))
|
||||
.style(button_style(false))
|
||||
.padding([3, 10]);
|
||||
if enabled {
|
||||
reset = reset.on_press(msg(PdfDialogMsg::BgReset));
|
||||
}
|
||||
container(
|
||||
column![
|
||||
row![
|
||||
text(crate::tr!("view-background", "adjust").to_uppercase()).size(10).style(move |theme: &Theme| {
|
||||
if enabled { accent_text(theme) } else { dim(theme) }
|
||||
}),
|
||||
Space::new().width(Fill),
|
||||
reset,
|
||||
]
|
||||
.align_y(iced::Center),
|
||||
line(crate::tr!("view-background", "offset"), 0, 1, true),
|
||||
line(crate::tr!("view-background", "scale"), 2, 3, false),
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.padding([10, 12])
|
||||
.width(Fill)
|
||||
.style(card_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── Image Adjust (IMAGEADJUST) ──────────────────────────────────────────
|
||||
|
||||
/// The Image Adjust dialog: brightness, contrast and fade (0–100) of the
|
||||
/// chosen raster images, with a before / after preview of the first.
|
||||
pub struct ImageAdjustState {
|
||||
pub handles: Vec<codec::Handle>,
|
||||
/// The first image's file name, shown over the preview.
|
||||
pub name: String,
|
||||
/// Brightness, contrast, fade.
|
||||
pub values: [u8; 3],
|
||||
/// The boxes as shown; a box may be empty while it is typed in.
|
||||
pub texts: [String; 3],
|
||||
/// The first image, at most 512 pixels a side, as RGBA.
|
||||
pub source: Option<(u32, u32, Vec<u8>)>,
|
||||
/// The drawing area colour the image fades into.
|
||||
pub canvas: [u8; 3],
|
||||
/// Left half as it is, right half adjusted, over the drawing area colour.
|
||||
pub preview: Option<image::Handle>,
|
||||
}
|
||||
|
||||
/// Brightness 50, contrast 50, fade 0.
|
||||
pub const IMAGE_ADJUST_DEFAULTS: [u8; 3] = [50, 50, 0];
|
||||
|
||||
impl ImageAdjustState {
|
||||
pub fn new(handles: Vec<codec::Handle>, path: &str, values: [u8; 3], canvas: [u8; 3]) -> Self {
|
||||
let name = std::path::Path::new(path.trim())
|
||||
.file_name()
|
||||
.map_or_else(|| path.trim().to_string(), |n| n.to_string_lossy().into_owned());
|
||||
let source = crate::scene::model::image_model::resolve_image(path).and_then(|decoded| {
|
||||
let full = ::image::RgbaImage::from_raw(decoded.width, decoded.height, decoded.pixels.to_vec())?;
|
||||
let small = if full.width().max(full.height()) > 512 {
|
||||
::image::imageops::thumbnail(&full, 512, 512)
|
||||
} else {
|
||||
full
|
||||
};
|
||||
Some((small.width(), small.height(), small.into_raw()))
|
||||
});
|
||||
let mut state = Self {
|
||||
handles,
|
||||
name,
|
||||
values: [0; 3],
|
||||
texts: Default::default(),
|
||||
source,
|
||||
canvas,
|
||||
preview: None,
|
||||
};
|
||||
state.set_all(values);
|
||||
state
|
||||
}
|
||||
|
||||
pub fn set(&mut self, row: usize, value: u8) {
|
||||
let mut values = self.values;
|
||||
values[row] = value.min(100);
|
||||
self.set_all(values);
|
||||
}
|
||||
|
||||
pub fn set_all(&mut self, values: [u8; 3]) {
|
||||
self.values = values.map(|v| v.min(100));
|
||||
self.texts = self.values.map(|v| v.to_string());
|
||||
self.preview = self.source.as_ref().map(|(w, h, pixels)| {
|
||||
// As the drawing shows it: brightness then contrast per channel,
|
||||
// fade as the image's opacity over the drawing area.
|
||||
let lut = crate::scene::model::image_model::brightness_contrast_lut(self.values[0], self.values[1]);
|
||||
let opacity = 1.0 - f32::from(self.values[2]) / 100.0;
|
||||
let mut out = Vec::with_capacity(pixels.len());
|
||||
for (k, px) in pixels.chunks_exact(4).enumerate() {
|
||||
let after = k as u32 % w >= w / 2;
|
||||
let alpha = f32::from(px[3]) / 255.0 * if after { opacity } else { 1.0 };
|
||||
for c in 0..3 {
|
||||
let value = if after { lut[px[c] as usize] } else { px[c] };
|
||||
let bg = f32::from(self.canvas[c]);
|
||||
out.push((bg + (f32::from(value) - bg) * alpha).round() as u8);
|
||||
}
|
||||
out.push(255);
|
||||
}
|
||||
image::Handle::from_rgba(*w, *h, out)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn image_adjust<'a>(
|
||||
state: &'a ImageAdjustState,
|
||||
sizing: crate::ui::modal::ModalSizing,
|
||||
) -> Element<'a, Message> {
|
||||
let badge = |label: String, accent: bool| {
|
||||
container(text(label).size(10))
|
||||
.padding([2, 8])
|
||||
.style(move |theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(if accent {
|
||||
theme.palette().primary.base.color.scale_alpha(0.2)
|
||||
} else {
|
||||
iced::Color::from_rgba8(0x1e, 0x1e, 0x1e, 0.85)
|
||||
})),
|
||||
border: Border {
|
||||
width: 1.0,
|
||||
radius: 10.0.into(),
|
||||
color: if accent {
|
||||
theme.palette().primary.base.color
|
||||
} else {
|
||||
iced::Color::from_rgb8(0x4a, 0x4a, 0x4a)
|
||||
},
|
||||
},
|
||||
text_color: Some(iced::Color::WHITE),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let canvas = iced::Color::from_rgb8(state.canvas[0], state.canvas[1], state.canvas[2]);
|
||||
let picture: Element<'a, Message> = match &state.preview {
|
||||
Some(handle) => image(handle.clone())
|
||||
.content_fit(iced::ContentFit::Cover)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into(),
|
||||
None => container(text(crate::tr!("image-adjust", "no-preview")).size(11).style(muted_style))
|
||||
.center(Fill)
|
||||
.into(),
|
||||
};
|
||||
let divider = container(
|
||||
container(Space::new().width(2.0).height(Fill)).style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(theme.palette().primary.base.color)),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.center_x(Fill)
|
||||
.height(Fill);
|
||||
let badges = row![
|
||||
badge(crate::tr!("image-adjust", "before"), false),
|
||||
Space::new().width(Fill),
|
||||
badge(crate::tr!("image-adjust", "after"), true),
|
||||
]
|
||||
.padding(8);
|
||||
let mut layers = iced::widget::Stack::new().width(Fill).height(Fill).push(picture);
|
||||
if state.preview.is_some() {
|
||||
layers = layers.push(divider).push(badges);
|
||||
}
|
||||
let preview = card(
|
||||
format!("{} · {}", crate::tr!("image-adjust", "preview"), state.name),
|
||||
container(layers)
|
||||
.width(Fill)
|
||||
.height(Length::Fixed(150.0))
|
||||
.clip(true)
|
||||
.style(move |_: &Theme| container::Style {
|
||||
background: Some(Background::Color(canvas)),
|
||||
border: Border { radius: 5.0.into(), ..Default::default() },
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let ramp = |row: u8| -> Background {
|
||||
let (from, to) = match row {
|
||||
// Contrast: grey, the flat image its zero gives.
|
||||
1 => (iced::Color::from_rgb8(0x77, 0x77, 0x77), iced::Color::from_rgb8(0x77, 0x77, 0x77)),
|
||||
_ => (iced::Color::from_rgb8(0x22, 0x22, 0x22), iced::Color::from_rgb8(0xee, 0xee, 0xee)),
|
||||
};
|
||||
Background::Gradient(iced::Gradient::Linear(
|
||||
iced::gradient::Linear::new(iced::Radians(std::f32::consts::FRAC_PI_2))
|
||||
.add_stop(0.0, from)
|
||||
.add_stop(1.0, to),
|
||||
))
|
||||
};
|
||||
let line = |row: u8, label: String| -> Element<'a, Message> {
|
||||
let k = row as usize;
|
||||
let bar = container(container(Space::new()).width(Fill).height(8.0).style(move |_: &Theme| {
|
||||
container::Style {
|
||||
background: Some(ramp(row)),
|
||||
border: Border { radius: 4.0.into(), ..Default::default() },
|
||||
..Default::default()
|
||||
}
|
||||
}))
|
||||
.center_y(16.0)
|
||||
.width(Fill);
|
||||
let slide = iced::widget::slider(0..=100u8, state.values[k], move |v| msg(PdfDialogMsg::IaValue(row, v)))
|
||||
.height(16.0)
|
||||
.style(|theme: &Theme, _| iced::widget::slider::Style {
|
||||
rail: iced::widget::slider::Rail {
|
||||
backgrounds: (iced::Color::TRANSPARENT.into(), iced::Color::TRANSPARENT.into()),
|
||||
width: 0.0,
|
||||
border: Border::default(),
|
||||
},
|
||||
handle: iced::widget::slider::Handle {
|
||||
shape: iced::widget::slider::HandleShape::Rectangle { width: 4, border_radius: 2.0.into() },
|
||||
background: theme.palette().primary.base.color.into(),
|
||||
border_width: 0.0,
|
||||
border_color: iced::Color::TRANSPARENT,
|
||||
},
|
||||
});
|
||||
let step = |dir: i8| {
|
||||
button(text(if dir < 0 { "\u{2212}" } else { "+" }).size(12))
|
||||
.on_press(msg(PdfDialogMsg::IaStep(row, dir)))
|
||||
.style(button_style(false))
|
||||
.padding([2, 8])
|
||||
};
|
||||
let value = text_input("", &state.texts[k])
|
||||
.on_input(move |v| msg(PdfDialogMsg::IaText(row, v)))
|
||||
.size(11)
|
||||
.padding([3, 4])
|
||||
.width(Length::Fixed(40.0))
|
||||
.align_x(iced::alignment::Horizontal::Right)
|
||||
.style(field_style);
|
||||
row![
|
||||
text(label).size(11).width(Length::Fixed(72.0)),
|
||||
iced::widget::Stack::new().width(Fill).push(bar).push(slide),
|
||||
container(row![step(-1), value, step(1)].spacing(2).align_y(iced::Center))
|
||||
.padding(2)
|
||||
.style(well_style),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
};
|
||||
let adjust = card(
|
||||
crate::tr!("image-adjust", "adjust"),
|
||||
column![
|
||||
line(0, crate::tr!("image-adjust", "brightness")),
|
||||
line(1, crate::tr!("image-adjust", "contrast")),
|
||||
line(2, crate::tr!("image-adjust", "fade")),
|
||||
text(crate::tr!("image-adjust", "hint")).size(10).style(muted_style),
|
||||
]
|
||||
.spacing(10),
|
||||
);
|
||||
let reset = button(text(crate::tr!("image-adjust", "reset")).size(11).style(accent_text))
|
||||
.on_press(msg(PdfDialogMsg::IaReset))
|
||||
.style(button::text)
|
||||
.padding([5, 4]);
|
||||
column![
|
||||
preview,
|
||||
adjust,
|
||||
footer("imageadjust", Some(reset.into()), crate::tr!("image-adjust", "apply").into(), PdfDialogMsg::IaApply),
|
||||
]
|
||||
.spacing(10)
|
||||
.padding([10, 12])
|
||||
.width(sizing.width)
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Edit Reference dialog (REFEDIT): the nesting level to check out, shown as
|
||||
//! a chain of chips beside a preview of the chosen definition, how the
|
||||
//! working set is filled, whether attribute definitions come along, and the
|
||||
//! session settings (lock what is outside the working set, give clashing
|
||||
//! named objects of an external reference unique names).
|
||||
|
||||
use iced::widget::{button, column, container, row, text, toggler, Space};
|
||||
use iced::{Border, Element, Fill, Length, Theme};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use crate::ui::style::common::muted_style;
|
||||
use crate::ui::style::form::{button_style, dialog_button};
|
||||
use crate::ui::window::pdf_dialogs::{card, well_style};
|
||||
|
||||
/// One edit in the Edit Reference dialog.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RefEditDialogMsg {
|
||||
/// The nesting level picked in the chain (0 = the selected reference).
|
||||
Level(usize),
|
||||
/// Pick the working set's objects in the drawing (else all of them).
|
||||
Nested(bool),
|
||||
Attributes(bool),
|
||||
Lock(bool),
|
||||
Unique(bool),
|
||||
Help,
|
||||
Ok,
|
||||
}
|
||||
|
||||
fn msg(m: RefEditDialogMsg) -> Message {
|
||||
Message::RefEditDialog(m)
|
||||
}
|
||||
|
||||
/// One level of the nesting chain as the dialog shows it.
|
||||
pub struct RefEditLevel {
|
||||
/// The reference's handle: the picked object for level 0, the
|
||||
/// definition-owned reference below it.
|
||||
pub insert: codec::Handle,
|
||||
pub name: String,
|
||||
/// The definition's preview.
|
||||
pub wires: Vec<WireModel>,
|
||||
/// The definition holds attribute definitions (the attribute switch is
|
||||
/// only offered then).
|
||||
pub has_attdefs: bool,
|
||||
/// The referenced file of an external reference.
|
||||
pub xref_path: Option<String>,
|
||||
}
|
||||
|
||||
pub struct RefEditDialogState {
|
||||
pub top: codec::Handle,
|
||||
/// The picked chain, outermost first.
|
||||
pub chain: Vec<crate::modules::draw::modify::refedit::ChainLevel>,
|
||||
pub levels: Vec<RefEditLevel>,
|
||||
pub level: usize,
|
||||
pub nested: bool,
|
||||
pub attributes: bool,
|
||||
pub lock: bool,
|
||||
pub unique: bool,
|
||||
}
|
||||
|
||||
impl RefEditDialogState {
|
||||
/// The attribute switch counts only where the chosen definition has
|
||||
/// attribute definitions.
|
||||
pub fn attributes_enabled(&self) -> bool {
|
||||
self.levels.get(self.level).is_some_and(|l| l.has_attdefs)
|
||||
}
|
||||
}
|
||||
|
||||
fn label<'a>(value: String, enabled: bool) -> Element<'a, Message> {
|
||||
text(value)
|
||||
.size(12)
|
||||
.style(move |theme: &Theme| {
|
||||
if enabled {
|
||||
text::Style::default()
|
||||
} else {
|
||||
text::Style {
|
||||
color: Some(theme.palette().background.strong.color),
|
||||
}
|
||||
}
|
||||
})
|
||||
.width(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A labelled switch on one row; inactive while `on_toggle` is None.
|
||||
fn switch_row<'a>(
|
||||
name: String,
|
||||
value: bool,
|
||||
on_toggle: Option<fn(bool) -> RefEditDialogMsg>,
|
||||
) -> Element<'a, Message> {
|
||||
let enabled = on_toggle.is_some();
|
||||
let mut switch = toggler(value).size(18.0);
|
||||
if let Some(on) = on_toggle {
|
||||
switch = switch.on_toggle(move |v| msg(on(v)));
|
||||
}
|
||||
row![label(name, enabled), switch]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A level of the chain as a pill; the chosen one framed in the accent colour.
|
||||
fn level_chip<'a>(name: &str, index: usize, chosen: bool) -> Element<'a, Message> {
|
||||
button(text(name.to_string()).size(12))
|
||||
.on_press(msg(RefEditDialogMsg::Level(index)))
|
||||
.padding([5, 12])
|
||||
.style(move |theme: &Theme, status| {
|
||||
let mut style = button_style(false)(theme, status);
|
||||
style.border = Border {
|
||||
width: if chosen { 1.5 } else { 1.0 },
|
||||
radius: 14.0.into(),
|
||||
color: if chosen {
|
||||
theme.palette().primary.base.color
|
||||
} else {
|
||||
theme.palette().background.strong.color
|
||||
},
|
||||
};
|
||||
if chosen {
|
||||
style.text_color = theme.palette().primary.base.color;
|
||||
}
|
||||
style
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn view<'a>(
|
||||
state: &'a RefEditDialogState,
|
||||
sizing: crate::ui::modal::ModalSizing,
|
||||
) -> Element<'a, Message> {
|
||||
let current = state.levels.get(state.level);
|
||||
let preview: Element<'a, Message> = container(match current {
|
||||
Some(level) => crate::ui::window::block_palette::preview_canvas(&level.wires, 104.0),
|
||||
None => Space::new().into(),
|
||||
})
|
||||
.width(Length::Fixed(150.0))
|
||||
.height(Length::Fixed(104.0))
|
||||
.padding(4)
|
||||
.style(well_style)
|
||||
.into();
|
||||
let mut chips = row![].spacing(6).align_y(iced::Center);
|
||||
for (index, level) in state.levels.iter().enumerate() {
|
||||
if index > 0 {
|
||||
chips = chips.push(text("›").size(13).style(muted_style));
|
||||
}
|
||||
chips = chips.push(level_chip(&level.name, index, index == state.level));
|
||||
}
|
||||
let mut about = column![text(crate::tr!("ref-edit", "dialog-chain-hint"))
|
||||
.size(11)
|
||||
.style(muted_style)]
|
||||
.spacing(4);
|
||||
if let Some(path) = current.and_then(|l| l.xref_path.as_deref()) {
|
||||
// A long path keeps its end, where the file name is.
|
||||
const MAX: usize = 64;
|
||||
let count = path.chars().count();
|
||||
let shown = if count > MAX {
|
||||
format!("…{}", path.chars().skip(count - (MAX - 1)).collect::<String>())
|
||||
} else {
|
||||
path.to_string()
|
||||
};
|
||||
about = about.push(
|
||||
text(format!("{} {}", crate::tr!("ref-edit", "dialog-path"), shown))
|
||||
.size(11)
|
||||
.style(muted_style),
|
||||
);
|
||||
}
|
||||
let reference = card(
|
||||
crate::tr!("ref-edit", "dialog-reference"),
|
||||
row![
|
||||
preview,
|
||||
column![iced::widget::scrollable(chips).direction(
|
||||
iced::widget::scrollable::Direction::Horizontal(
|
||||
iced::widget::scrollable::Scrollbar::new().width(4).scroller_width(4)
|
||||
)
|
||||
), about]
|
||||
.spacing(8)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(12),
|
||||
);
|
||||
|
||||
let segment = |nested: bool, name: String| {
|
||||
button(text(name).size(12).width(Fill).align_x(iced::Center))
|
||||
.on_press(msg(RefEditDialogMsg::Nested(nested)))
|
||||
.style(button_style(state.nested == nested))
|
||||
.padding([6, 8])
|
||||
.width(Fill)
|
||||
};
|
||||
let method = container(
|
||||
row![
|
||||
segment(false, crate::tr!("ref-edit", "dialog-all-objects")),
|
||||
segment(true, crate::tr!("ref-edit", "dialog-pick-objects")),
|
||||
]
|
||||
.spacing(2),
|
||||
)
|
||||
.padding(2)
|
||||
.width(Fill)
|
||||
.style(well_style);
|
||||
let attributes_enabled = state.attributes_enabled();
|
||||
let working_set = card(
|
||||
crate::tr!("ref-edit", "dialog-working-set"),
|
||||
column![
|
||||
method,
|
||||
switch_row(
|
||||
crate::tr!("ref-edit", "dialog-attributes"),
|
||||
state.attributes && attributes_enabled,
|
||||
attributes_enabled.then_some(RefEditDialogMsg::Attributes as fn(bool) -> _),
|
||||
),
|
||||
]
|
||||
.spacing(10),
|
||||
);
|
||||
let session = card(
|
||||
crate::tr!("ref-edit", "dialog-session"),
|
||||
column![
|
||||
switch_row(
|
||||
crate::tr!("ref-edit", "dialog-lock"),
|
||||
state.lock,
|
||||
Some(RefEditDialogMsg::Lock),
|
||||
),
|
||||
switch_row(
|
||||
crate::tr!("ref-edit", "dialog-unique"),
|
||||
state.unique,
|
||||
Some(RefEditDialogMsg::Unique),
|
||||
),
|
||||
]
|
||||
.spacing(10),
|
||||
);
|
||||
let footer = row![
|
||||
button(text("?").size(12))
|
||||
.on_press(msg(RefEditDialogMsg::Help))
|
||||
.style(button_style(false))
|
||||
.padding([5, 11]),
|
||||
Space::new().width(Fill),
|
||||
dialog_button(crate::t!("Cancel"), Message::CloseModal, false),
|
||||
dialog_button(
|
||||
std::borrow::Cow::Owned(crate::tr!("ref-edit", "dialog-start")),
|
||||
msg(RefEditDialogMsg::Ok),
|
||||
true
|
||||
),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center);
|
||||
column![reference, working_set, session, footer]
|
||||
.spacing(10)
|
||||
.padding([10, 12])
|
||||
.width(sizing.width)
|
||||
.into()
|
||||
}
|
||||
+37
-4
@@ -34,14 +34,46 @@ use crate::app::Message;
|
||||
thread_local! {
|
||||
/// Screen bounds of every ribbon dropdown button, keyed by dropdown id, so
|
||||
/// an open dropdown's overlay can anchor directly below its widget at any
|
||||
/// size. Written by `PosReport` on draw, read by `dropdown_bounds`.
|
||||
static DD_BOUNDS: RefCell<FxHashMap<String, Rectangle>> =
|
||||
/// size. Written by `PosReport` on draw, read by `dropdown_bounds`. Each
|
||||
/// entry keeps the frame it was drawn in (see `fresh_bounds`).
|
||||
static DD_BOUNDS: RefCell<FxHashMap<String, (Rectangle, u64)>> =
|
||||
RefCell::new(FxHashMap::default());
|
||||
/// Advanced by `end_frame` once everything above the widgets has drawn.
|
||||
static FRAME: Cell<u64> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Last-drawn screen bounds of the dropdown button with this id.
|
||||
pub fn dropdown_bounds(id: &str) -> Option<Rectangle> {
|
||||
DD_BOUNDS.with(|m| m.borrow().get(id).copied())
|
||||
DD_BOUNDS.with(|m| m.borrow().get(id).map(|(bounds, _)| *bounds))
|
||||
}
|
||||
|
||||
/// Bounds of the widget `id` only when it was drawn in the current frame, so
|
||||
/// a widget that left the screen does not report its old place.
|
||||
pub fn fresh_bounds(id: &str) -> Option<Rectangle> {
|
||||
let frame = FRAME.with(Cell::get);
|
||||
DD_BOUNDS.with(|m| {
|
||||
m.borrow()
|
||||
.get(id)
|
||||
.filter(|(_, drawn)| *drawn == frame)
|
||||
.map(|(bounds, _)| *bounds)
|
||||
})
|
||||
}
|
||||
|
||||
/// Bounds of the widget `id` when it was drawn in the current or the last
|
||||
/// finished frame (for use between frames).
|
||||
pub fn recent_bounds(id: &str) -> Option<Rectangle> {
|
||||
let frame = FRAME.with(Cell::get);
|
||||
DD_BOUNDS.with(|m| {
|
||||
m.borrow()
|
||||
.get(id)
|
||||
.filter(|(_, drawn)| drawn + 1 >= frame)
|
||||
.map(|(bounds, _)| *bounds)
|
||||
})
|
||||
}
|
||||
|
||||
/// Close the current frame for `fresh_bounds`.
|
||||
pub fn end_frame() {
|
||||
FRAME.with(|frame| frame.set(frame.get() + 1));
|
||||
}
|
||||
|
||||
pub struct WrapBar<'a> {
|
||||
@@ -1038,7 +1070,8 @@ impl<'a> Widget<Message, Theme, Renderer> for PosReport<'a> {
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
DD_BOUNDS.with(|m| {
|
||||
m.borrow_mut().insert(self.id.to_string(), layout.bounds());
|
||||
m.borrow_mut()
|
||||
.insert(self.id.to_string(), (layout.bounds(), FRAME.with(Cell::get)));
|
||||
});
|
||||
self.child.as_widget().draw(
|
||||
&tree.children[0],
|
||||
|
||||
Reference in new issue
Block a user