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:
Claude committed 2026-10-14 19:10:33 +00:00
commit 451b0f5d78
231 files changed
+32217 -3423

No files matched your search

+3 -6
View File
@@ -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
View File
@@ -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 },
+1
View File
@@ -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");
+271
View File
@@ -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:?}");
}
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+30 -4
View File
@@ -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),
+122
View File
@@ -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()
}
+52
View File
@@ -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()
}
+10
View File
@@ -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,
+8
View File
@@ -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)
+3
View File
@@ -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;
+75
View File
@@ -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),
+733 -3
View File
@@ -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()
}
+247
View File
@@ -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
View File
@@ -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],