forked from 1b-agent/opencadstudio-upstream
Merge main into Pallet-placement
Resolved conflicts: - app/update/mod.rs: keep the dock's dock_open_at_default for the XREF manager; take main's stable xref row identity (row_index(&id)). - app/view/mod.rs: keep the edge layout from app::dock; drop main's copy of the old edge-stack code (main only reformatted it there). - ui/window/layers.rs: keep main's new visible_indices next to the branch's view_panel. Files the branch only reformatted take main's version. Adds the Lao (lo-LA) translations for the new dock texts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt
This commit is contained in:
136 files changed
+12408
-949
No files matched your search
+99
-9
@@ -162,6 +162,10 @@ pub struct CommandLine {
|
||||
/// means the first match is pre-selected (highlighted) before keyboard
|
||||
/// navigation begins. Reset when input changes.
|
||||
pub autocomplete_cursor: Option<usize>,
|
||||
/// Suggestions supplied by the active CAD command. When non-empty these
|
||||
/// replace normal command-name autocomplete, allowing commands to offer
|
||||
/// drawing-specific values such as layers, blocks or styles.
|
||||
pub contextual_suggestions: Vec<crate::command::CommandSuggestion>,
|
||||
/// Command names contributed by loaded plugins, refreshed whenever the
|
||||
/// enabled-plugin set changes. Merged into autocomplete alongside the
|
||||
/// compile-time command registry, so runtime plugin commands are typeable
|
||||
@@ -206,6 +210,7 @@ impl Default for CommandLine {
|
||||
history_open: false,
|
||||
history_height: 0.0,
|
||||
autocomplete_cursor: None,
|
||||
contextual_suggestions: Vec::new(),
|
||||
dynamic_commands: Vec::new(),
|
||||
command_aliases: rustc_hash::FxHashMap::default(),
|
||||
step_prompt: None,
|
||||
@@ -604,6 +609,34 @@ impl CommandLine {
|
||||
self.step_prompt = None;
|
||||
}
|
||||
|
||||
/// Replace the active command's context-sensitive suggestions.
|
||||
/// A new result set always resets keyboard navigation to the first item.
|
||||
pub fn set_contextual_suggestions(
|
||||
&mut self,
|
||||
suggestions: Vec<crate::command::CommandSuggestion>,
|
||||
) {
|
||||
let changed = self
|
||||
.contextual_suggestions
|
||||
.iter()
|
||||
.map(|entry| entry.value.as_str())
|
||||
.ne(suggestions.iter().map(|entry| entry.value.as_str()));
|
||||
|
||||
self.contextual_suggestions = suggestions;
|
||||
|
||||
if changed {
|
||||
self.autocomplete_cursor = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_contextual_suggestions(&mut self) {
|
||||
self.contextual_suggestions.clear();
|
||||
self.autocomplete_cursor = None;
|
||||
}
|
||||
|
||||
pub fn has_contextual_suggestions(&self) -> bool {
|
||||
!self.contextual_suggestions.is_empty()
|
||||
}
|
||||
|
||||
/// Move the autocomplete highlight up one entry. Wraps to the last match.
|
||||
pub fn autocomplete_prev(&mut self) -> bool {
|
||||
let len = self.autocomplete_matches().len();
|
||||
@@ -642,6 +675,14 @@ impl CommandLine {
|
||||
/// Autocomplete suggestions for the current input — see
|
||||
/// [`ranked_matches`]. Includes loaded plugins' commands (#272).
|
||||
pub fn autocomplete_matches(&self) -> Vec<String> {
|
||||
if !self.contextual_suggestions.is_empty() {
|
||||
return self
|
||||
.contextual_suggestions
|
||||
.iter()
|
||||
.map(|entry| entry.value.clone())
|
||||
.collect();
|
||||
}
|
||||
|
||||
ranked_matches(
|
||||
self.input.trim(),
|
||||
&self.dynamic_commands,
|
||||
@@ -821,15 +862,64 @@ impl CommandLine {
|
||||
let mut col = column![].spacing(0).width(Length::Fill);
|
||||
for (idx, cmd) in matches.iter().enumerate() {
|
||||
let is_selected = idx == cursor;
|
||||
// Every row keeps the icon's width so names line up.
|
||||
let icon: Element<'_, Message> =
|
||||
match crate::modules::registry::command_icon(cmd) {
|
||||
Some(bytes) => crate::ui::icons::semantic(bytes, 14.0),
|
||||
None => Space::new().width(14.0).into(),
|
||||
};
|
||||
let label = row![icon, text(cmd.clone()).size(11)]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center);
|
||||
let details = self.contextual_suggestions.get(idx);
|
||||
|
||||
let mut label = row![].spacing(6).align_y(iced::Center);
|
||||
|
||||
if let Some(details) = details {
|
||||
if let Some(visible) = details.visible {
|
||||
label = label.push(crate::ui::icons::semantic(
|
||||
crate::ui::icons::layer_visible(visible),
|
||||
14.0,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(frozen) = details.frozen {
|
||||
label = label.push(crate::ui::icons::semantic(
|
||||
crate::ui::icons::layer_freeze(frozen),
|
||||
14.0,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(locked) = details.locked {
|
||||
label = label.push(crate::ui::icons::semantic(
|
||||
crate::ui::icons::layer_lock(locked),
|
||||
14.0,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(color) = details.color.clone() {
|
||||
let swatch_color =
|
||||
crate::ui::window::layers::iced_color_from_acad(&color);
|
||||
|
||||
let swatch = container(text(""))
|
||||
.style(move |theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(swatch_color)),
|
||||
border: Border {
|
||||
color: theme.palette().background.strong.color,
|
||||
width: 1.0,
|
||||
radius: 1.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.width(12)
|
||||
.height(12);
|
||||
|
||||
label = label.push(swatch);
|
||||
}
|
||||
} else {
|
||||
// Normal command autocomplete keeps its existing command icon.
|
||||
let icon: Element<'_, Message> =
|
||||
match crate::modules::registry::command_icon(cmd) {
|
||||
Some(bytes) => crate::ui::icons::semantic(bytes, 14.0),
|
||||
None => Space::new().width(14.0).into(),
|
||||
};
|
||||
|
||||
label = label.push(icon);
|
||||
}
|
||||
|
||||
label = label.push(text(cmd.clone()).size(11));
|
||||
|
||||
let row = button(label)
|
||||
.on_press(Message::CommandSuggestionPick(cmd.clone()))
|
||||
.width(Length::Fill)
|
||||
|
||||
+206
-32
@@ -639,6 +639,30 @@ pub struct GridCanvasState {
|
||||
/// least `MIN_GRID_PX` pixels apart at the camera pivot. Both orthographic and
|
||||
/// perspective cameras have the same vertical scale there. Camera rotation is
|
||||
/// intentionally absent so orbiting cannot rescale the visible grid or snap.
|
||||
/// World → viewport-local screen via relative-to-eye: subtract the f64 eye
|
||||
/// first so points near the camera stay precise at UTM-scale coords.
|
||||
/// Rejects points on/behind the perspective eye plane before dividing by W —
|
||||
/// without this a behind-camera point wraps to a finite mirrored coordinate
|
||||
/// (screen centre for on-axis points) and strokes garbage (#46).
|
||||
fn project_point(
|
||||
view_rot: Mat4,
|
||||
eye: glam::DVec3,
|
||||
bounds: iced::Rectangle,
|
||||
world: glam::DVec3,
|
||||
) -> Option<Point> {
|
||||
let rel = (world - eye).as_vec3();
|
||||
let clip = view_rot * rel.extend(1.0);
|
||||
if !clip.is_finite() || clip.w <= 1e-7 {
|
||||
return None;
|
||||
}
|
||||
let ndc = clip.truncate() / clip.w;
|
||||
let screen = Point::new(
|
||||
(ndc.x + 1.0) * 0.5 * bounds.width,
|
||||
(1.0 - ndc.y) * 0.5 * bounds.height,
|
||||
);
|
||||
(screen.x.is_finite() && screen.y.is_finite()).then_some(screen)
|
||||
}
|
||||
|
||||
/// Clip the screen segment `p0`→`p1` to `bounds` (Liang–Barsky), returning the
|
||||
/// visible part, or `None` when it misses entirely.
|
||||
///
|
||||
@@ -2478,20 +2502,9 @@ pub fn grid_segments(
|
||||
|
||||
// World → viewport-local screen via relative-to-eye: subtract the f64 eye
|
||||
// first so grid points near the camera stay precise at UTM-scale coords.
|
||||
// Reject points on/behind the perspective eye plane before dividing by W.
|
||||
let project = |world: glam::DVec3| -> Option<Point> {
|
||||
let rel = (world - eye).as_vec3();
|
||||
let clip = view_rot * rel.extend(1.0);
|
||||
if !clip.is_finite() || clip.w <= 1e-7 {
|
||||
return None;
|
||||
}
|
||||
let ndc = clip.truncate() / clip.w;
|
||||
let screen = Point::new(
|
||||
(ndc.x + 1.0) * 0.5 * bounds.width,
|
||||
(1.0 - ndc.y) * 0.5 * bounds.height,
|
||||
);
|
||||
(screen.x.is_finite() && screen.y.is_finite()).then_some(screen)
|
||||
};
|
||||
// Shared eye-plane guard — see project_point (#46).
|
||||
let project =
|
||||
|world: glam::DVec3| -> Option<Point> { project_point(view_rot, eye, bounds, world) };
|
||||
|
||||
// Grid is intentionally restricted to the active UCS XY plane.
|
||||
let (gx, gy, gz) = grid_axes;
|
||||
@@ -3012,6 +3025,7 @@ pub fn grid_segments(
|
||||
|
||||
// ── Coloured UCS axes ──────────────────────────────────────────────────────
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_axes(
|
||||
frame: &mut canvas::Frame,
|
||||
@@ -3023,34 +3037,67 @@ fn draw_axes(
|
||||
axes: (Vec3, Vec3, Vec3),
|
||||
bg_luminance: f32,
|
||||
) {
|
||||
let w2s = |world: glam::DVec3| -> Point {
|
||||
let ndc = view_rot.project_point3((world - eye).as_vec3());
|
||||
Point::new(
|
||||
bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width,
|
||||
bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height,
|
||||
)
|
||||
};
|
||||
let e = extent;
|
||||
let (ax, ay, az) = axes;
|
||||
let axis_stroke = |r: f32, g: f32, b: f32| canvas::Stroke {
|
||||
width: 1.5,
|
||||
style: canvas::Style::Solid(Color { r, g, b, a: 0.85 }),
|
||||
..Default::default()
|
||||
};
|
||||
// Axes run through the UCS origin along the UCS axis directions.
|
||||
let mut line = |dir: Vec3, r: f32, g: f32, b: f32| {
|
||||
let y_green = if bg_luminance > 0.5 { 0.60 } else { 0.85 };
|
||||
for ((a, b), (r, g, bl)) in axes_segments(view_rot, eye, bounds, extent, origin, axes)
|
||||
.into_iter()
|
||||
.zip([
|
||||
(0.90, 0.20, 0.20),
|
||||
(0.20, y_green, 0.20),
|
||||
(0.20, 0.40, 0.90),
|
||||
])
|
||||
{
|
||||
frame.stroke(
|
||||
&canvas::Path::new(|p| {
|
||||
p.move_to(w2s(origin - (dir * e).as_dvec3()));
|
||||
p.line_to(w2s(origin + (dir * e).as_dvec3()));
|
||||
p.move_to(a);
|
||||
p.line_to(b);
|
||||
}),
|
||||
axis_stroke(r, g, b),
|
||||
axis_stroke(r, g, bl),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure geometry for [`draw_axes`]: per-axis canvas-space segments in X/Y/Z
|
||||
/// order after the eye-plane guard and frame clip. An axis with either end
|
||||
/// behind the eye is absent entirely (#46) — screen-space clipping of a
|
||||
/// mirror-wrapped segment would be garbage, same policy as grid lines.
|
||||
/// Extracted so the skip/clip/offset contract is unit-testable without a
|
||||
/// renderer (iced `Frame` needs one).
|
||||
fn axes_segments(
|
||||
view_rot: Mat4,
|
||||
eye: glam::DVec3,
|
||||
bounds: iced::Rectangle,
|
||||
extent: f32,
|
||||
origin: glam::DVec3,
|
||||
axes: (Vec3, Vec3, Vec3),
|
||||
) -> Vec<(Point, Point)> {
|
||||
// Axes run through the UCS origin along the UCS axis directions. NOTE: the
|
||||
// pane origin offset below reproduces draw_axes' historical canvas placement
|
||||
// verbatim — whether grid geometry as a whole should be pane-local or
|
||||
// canvas-placed is a separate open question (see #60), not this fix.
|
||||
let w2s = |world: glam::DVec3| -> Option<Point> {
|
||||
project_point(view_rot, eye, bounds, world)
|
||||
.map(|p| Point::new(p.x + bounds.x, p.y + bounds.y))
|
||||
};
|
||||
let y_green = if bg_luminance > 0.5 { 0.60 } else { 0.85 };
|
||||
line(ax, 0.90, 0.20, 0.20); // X — red
|
||||
line(ay, 0.20, y_green, 0.20); // Y — green
|
||||
line(az, 0.20, 0.40, 0.90); // Z — blue
|
||||
// clip_seg works frame-local: shift, clip, shift back.
|
||||
let local = |p: Point| Point::new(p.x - bounds.x, p.y - bounds.y);
|
||||
let back = |p: Point| Point::new(p.x + bounds.x, p.y + bounds.y);
|
||||
let (ax, ay, az) = axes;
|
||||
[ax, ay, az]
|
||||
.into_iter()
|
||||
.filter_map(|dir| {
|
||||
let (p0, p1) = (
|
||||
w2s(origin - (dir * extent).as_dvec3())?,
|
||||
w2s(origin + (dir * extent).as_dvec3())?,
|
||||
);
|
||||
let (c0, c1) = clip_seg(local(p0), local(p1), bounds)?;
|
||||
Some((back(c0), back(c1)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── UCS icon ──────────────────────────────────────────────────────────────
|
||||
@@ -4044,6 +4091,133 @@ impl canvas::Program<Message> for DynInputCanvas {
|
||||
|
||||
#[cfg(test)]
|
||||
mod clip_tests {
|
||||
// Shared eye-plane projection shared by grid_segments and draw_axes (#46).
|
||||
// Behind-eye points must project to None, never to a mirrored finite point.
|
||||
#[test]
|
||||
fn project_point_rejects_behind_eye() {
|
||||
use glam::camera::rh::proj::directx;
|
||||
let eye = glam::DVec3::new(0.0, 0.0, 500.0);
|
||||
let view_rot: glam::Mat4 =
|
||||
directx::perspective(60_f32.to_radians(), 800.0 / 600.0, 0.5, 5000.0);
|
||||
let bounds = iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 800.0,
|
||||
height: 600.0,
|
||||
};
|
||||
assert!(
|
||||
project_point(view_rot, eye, bounds, glam::DVec3::new(0.0, 0.0, 600.0)).is_none(),
|
||||
"behind-eye must be rejected"
|
||||
);
|
||||
assert!(
|
||||
project_point(view_rot, eye, bounds, glam::DVec3::new(0.0, 0.0, 400.0)).is_some(),
|
||||
"in-front must project"
|
||||
);
|
||||
}
|
||||
|
||||
fn axes_cam() -> (glam::Mat4, glam::DVec3) {
|
||||
use glam::camera::rh::proj::directx;
|
||||
let eye = glam::DVec3::new(0.0, 0.0, 500.0);
|
||||
let view_rot: glam::Mat4 =
|
||||
directx::perspective(60_f32.to_radians(), 800.0 / 600.0, 0.5, 5000.0);
|
||||
(view_rot, eye)
|
||||
}
|
||||
|
||||
/// draw_axes-level contract without a renderer: all three axes present up
|
||||
/// front, each clipped inside the frame.
|
||||
#[test]
|
||||
fn axes_segments_draws_three_when_in_front() {
|
||||
let (view_rot, eye) = axes_cam();
|
||||
let segs = super::axes_segments(
|
||||
view_rot,
|
||||
eye,
|
||||
b(),
|
||||
100.0,
|
||||
glam::DVec3::ZERO,
|
||||
(Vec3::X, Vec3::Y, Vec3::Z),
|
||||
);
|
||||
assert_eq!(segs.len(), 3, "all three axes must survive");
|
||||
for (a, c) in &segs {
|
||||
for p in [a, c] {
|
||||
assert!(
|
||||
p.x >= -16.0 && p.x <= 816.0 && p.y >= -16.0 && p.y <= 616.0,
|
||||
"segment must be frame-clipped, got ({}, {})",
|
||||
p.x,
|
||||
p.y
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Straddling axis (origin in front, far tip behind the eye) is skipped
|
||||
/// whole — screen-space clipping of a wrapped segment would be garbage.
|
||||
#[test]
|
||||
fn axes_segments_skips_straddling_axis() {
|
||||
let (view_rot, eye) = axes_cam();
|
||||
// Origin 100 in front; Z extent 200 puts the +Z tip 100 behind.
|
||||
let segs = super::axes_segments(
|
||||
view_rot,
|
||||
eye,
|
||||
b(),
|
||||
200.0,
|
||||
glam::DVec3::new(0.0, 0.0, 400.0),
|
||||
(Vec3::X, Vec3::Y, Vec3::Z),
|
||||
);
|
||||
assert_eq!(segs.len(), 2, "straddling Z axis must be skipped, X/Y kept");
|
||||
}
|
||||
|
||||
/// Fully behind-eye origin draws nothing at all.
|
||||
#[test]
|
||||
fn axes_segments_empty_when_behind_eye() {
|
||||
let (view_rot, eye) = axes_cam();
|
||||
let segs = super::axes_segments(
|
||||
view_rot,
|
||||
eye,
|
||||
b(),
|
||||
100.0,
|
||||
glam::DVec3::new(0.0, 0.0, 600.0),
|
||||
(Vec3::X, Vec3::Y, Vec3::Z),
|
||||
);
|
||||
assert!(segs.is_empty(), "nothing projectable, nothing drawn");
|
||||
}
|
||||
|
||||
/// Legacy canvas placement: a pane origin offset shifts every segment by
|
||||
/// exactly that offset (pins the historical draw_axes mapping).
|
||||
#[test]
|
||||
fn axes_segments_preserves_pane_offset() {
|
||||
let (view_rot, eye) = axes_cam();
|
||||
let plain = super::axes_segments(
|
||||
view_rot,
|
||||
eye,
|
||||
b(),
|
||||
100.0,
|
||||
glam::DVec3::ZERO,
|
||||
(Vec3::X, Vec3::Y, Vec3::Z),
|
||||
);
|
||||
let shifted_bounds = iced::Rectangle {
|
||||
x: 100.0,
|
||||
y: 50.0,
|
||||
width: 800.0,
|
||||
height: 600.0,
|
||||
};
|
||||
let shifted = super::axes_segments(
|
||||
view_rot,
|
||||
eye,
|
||||
shifted_bounds,
|
||||
100.0,
|
||||
glam::DVec3::ZERO,
|
||||
(Vec3::X, Vec3::Y, Vec3::Z),
|
||||
);
|
||||
assert_eq!(plain.len(), shifted.len());
|
||||
for ((a0, c0), (a1, c1)) in plain.iter().zip(shifted.iter()) {
|
||||
for (p0, p1, dx, dy) in [(a0, a1, 100.0, 50.0), (c0, c1, 100.0, 50.0)] {
|
||||
assert!(
|
||||
(p1.x - p0.x - dx).abs() < 1e-3 && (p1.y - p0.y - dy).abs() < 1e-3,
|
||||
"pane offset must carry through exactly"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
use super::*;
|
||||
|
||||
fn b() -> iced::Rectangle {
|
||||
|
||||
@@ -73,7 +73,6 @@ pub struct Ribbon {
|
||||
/// shows this tool on its button, defaulting to the panel's first tool.
|
||||
last_panel_tool: HashMap<&'static str, &'static str>,
|
||||
last_cmd: HashMap<&'static str, &'static str>,
|
||||
pub layer_names: Vec<String>,
|
||||
pub active_layer: String,
|
||||
pub layer_infos: Vec<LayerInfo>,
|
||||
/// Live filter for the layer dropdown's search box (#343). Cleared on
|
||||
@@ -189,7 +188,6 @@ impl Ribbon {
|
||||
last_panel_tool: HashMap::default(),
|
||||
last_cmd: HashMap::default(),
|
||||
// Empty until a document is open — populated by sync_ribbon_layers.
|
||||
layer_names: vec![],
|
||||
active_layer: String::new(),
|
||||
layer_infos: vec![],
|
||||
layer_filter: String::new(),
|
||||
@@ -285,7 +283,6 @@ impl Ribbon {
|
||||
|
||||
pub fn set_layers(&mut self, infos: Vec<LayerInfo>, active: &str) {
|
||||
self.active_layer = active.to_string();
|
||||
self.layer_names = infos.iter().map(|l| l.name.clone()).collect();
|
||||
self.layer_infos = infos;
|
||||
}
|
||||
|
||||
@@ -1106,9 +1103,9 @@ impl Ribbon {
|
||||
.spacing(2),
|
||||
)
|
||||
.style(popup_panel_style)
|
||||
.width(Length::Fixed(220.0));
|
||||
.width(Length::Fixed(300.0));
|
||||
|
||||
let (align_right, h_pad, top) = self.dd_anchor(LAYER_COMBO_ID, 220.0, win.0);
|
||||
let (align_right, h_pad, top) = self.dd_anchor(LAYER_COMBO_ID, 300.0, win.0);
|
||||
let positioned = position_ribbon_dropdown(panel.into(), align_right, h_pad, top);
|
||||
|
||||
Some(dropdown_backdrop(positioned))
|
||||
|
||||
@@ -1038,7 +1038,9 @@ pub(super) fn render_large<'a>(item: &RibbonItem, ctx: &RenderCtx<'_>) -> Elemen
|
||||
let tools_w = tool_count * TOOL_BUTTON_W
|
||||
+ (tool_count - 1.0).max(0.0) * TOOL_SPACING
|
||||
+ GROUP_PADDING;
|
||||
let combo_w = (LARGE_W * 2.5).max(tools_w);
|
||||
// Wide enough to read a typical layer name (about 25 characters)
|
||||
// in the closed combo.
|
||||
let combo_w = (LARGE_W * 2.5).max(tools_w).max(250.0);
|
||||
|
||||
let info = layer_infos.iter().find(|l| l.name == active_layer);
|
||||
let lc = info.map(|l| l.color).unwrap_or(Color::WHITE);
|
||||
|
||||
@@ -105,13 +105,17 @@ pub fn view_window<'a>(
|
||||
.iter()
|
||||
.find(|state| state.name.eq_ignore_ascii_case(selected))
|
||||
});
|
||||
let query = filter.trim().to_lowercase();
|
||||
let query: Cow<str> = if filter.trim().is_empty() {
|
||||
Cow::Borrowed("")
|
||||
} else {
|
||||
Cow::Owned(filter.trim().to_lowercase())
|
||||
};
|
||||
let rows: Vec<Element<'_, Message>> = states
|
||||
.iter()
|
||||
.filter(|state| {
|
||||
query.is_empty()
|
||||
|| state.name.to_lowercase().contains(&query)
|
||||
|| state.description.to_lowercase().contains(&query)
|
||||
|| state.name.to_lowercase().contains(query.as_ref())
|
||||
|| state.description.to_lowercase().contains(query.as_ref())
|
||||
})
|
||||
.map(|state| {
|
||||
let is_selected =
|
||||
@@ -424,7 +428,7 @@ fn editor_layer_row<'a>(
|
||||
index: usize,
|
||||
layer: &'a codec::LayerStateLayer,
|
||||
color_open: bool,
|
||||
linetypes: Vec<String>,
|
||||
linetypes: std::sync::Arc<[String]>,
|
||||
) -> Element<'a, Message> {
|
||||
let current_linetype = Some(layer.line_type.clone());
|
||||
let current_lineweight = Some(LwItem(layer.line_weight));
|
||||
@@ -501,7 +505,7 @@ pub fn view_editor<'a>(
|
||||
state: &'a LayerState,
|
||||
filter: &'a str,
|
||||
color_open: Option<usize>,
|
||||
linetypes: Vec<String>,
|
||||
linetypes: std::sync::Arc<[String]>,
|
||||
sizing: crate::ui::modal::ModalSizing,
|
||||
) -> Element<'a, Message> {
|
||||
let properties = [
|
||||
@@ -527,18 +531,24 @@ pub fn view_editor<'a>(
|
||||
.iter()
|
||||
.map(|layer| layer.layer_name.clone())
|
||||
.collect();
|
||||
let query = filter.trim().to_lowercase();
|
||||
let query: Cow<str> = if filter.trim().is_empty() {
|
||||
Cow::Borrowed("")
|
||||
} else {
|
||||
Cow::Owned(filter.trim().to_lowercase())
|
||||
};
|
||||
let rows = state
|
||||
.layers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, layer)| query.is_empty() || layer.layer_name.to_lowercase().contains(&query))
|
||||
.filter(|(_, layer)| {
|
||||
query.is_empty() || layer.layer_name.to_lowercase().contains(query.as_ref())
|
||||
})
|
||||
.fold(column![].spacing(0), |rows, (index, layer)| {
|
||||
rows.push(editor_layer_row(
|
||||
index,
|
||||
layer,
|
||||
color_open == Some(index),
|
||||
linetypes.clone(),
|
||||
std::sync::Arc::clone(&linetypes),
|
||||
))
|
||||
});
|
||||
|
||||
@@ -632,3 +642,33 @@ pub fn view_editor<'a>(
|
||||
.height(sizing.height)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod borrowed_linetypes_tests {
|
||||
// #21 RED: view_editor must take shared ownership of the linetype list
|
||||
// (a borrow cannot cross the twice-per-frame build closure) instead of
|
||||
// cloning per row — this call fails to compile until the signatures take
|
||||
// Arc<[String]>.
|
||||
use super::view_editor;
|
||||
|
||||
#[test]
|
||||
fn view_editor_accepts_shared_linetypes() {
|
||||
let state = codec::LayerState {
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
mask: codec::LayerStateMask::default(),
|
||||
hidden: false,
|
||||
current_layer: String::new(),
|
||||
layers: Vec::new(),
|
||||
};
|
||||
let linetypes: std::sync::Arc<[String]> =
|
||||
std::sync::Arc::from(vec!["Continuous".to_string()]);
|
||||
let sizing = crate::ui::modal::ModalSizing {
|
||||
width: iced::Length::Fixed(1180.0),
|
||||
height: iced::Length::Fixed(560.0),
|
||||
};
|
||||
let _ = view_editor(&state, "", None, std::sync::Arc::clone(&linetypes), sizing);
|
||||
// Shared ownership: the source stays usable after the call.
|
||||
assert_eq!(linetypes.len(), 1);
|
||||
}
|
||||
}
|
||||
+24
-10
@@ -127,11 +127,15 @@ pub struct LayerPanel {
|
||||
/// All selected rows (Ctrl/Shift extend it). Bulk property changes and
|
||||
/// deletion act on every row here. Empty ⇔ nothing selected.
|
||||
pub selected_multi: Vec<usize>,
|
||||
pub editing: Option<usize>,
|
||||
/// Layer being renamed (row text-input open). Keyed by NAME so a
|
||||
/// rebuild/sort between start and commit cannot retarget it (#44);
|
||||
/// `selected`/`selected_multi` below stay index-based and are re-resolved.
|
||||
pub editing: Option<String>,
|
||||
pub edit_buf: String,
|
||||
pub current_layer: String,
|
||||
pub linetype_items: Vec<LinetypeItem>,
|
||||
pub color_picker_row: Option<usize>,
|
||||
/// Row whose color swatch is open. Name-keyed for the same reason (#44).
|
||||
pub color_picker_row: Option<String>,
|
||||
pub color_full_palette: bool,
|
||||
pub linetype_combo: combo_box::State<LinetypeItem>,
|
||||
pub lw_combo: combo_box::State<LwItem>,
|
||||
@@ -320,6 +324,19 @@ impl LayerPanel {
|
||||
self.linetype_items = items;
|
||||
}
|
||||
|
||||
/// Row indices passing the live name filter — the rows the view actually
|
||||
/// draws. Index math over "what the user sees" (shift-ranges) must use
|
||||
/// these; raw `selected_multi` ranges would sweep in hidden rows (#50).
|
||||
pub fn visible_indices(&self) -> Vec<usize> {
|
||||
let filter = self.filter.to_lowercase();
|
||||
self.layers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, layer)| filter.is_empty() || layer.name.to_lowercase().contains(&filter))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The Layer Manager as a dock pallet: its title bar, the toolbar across
|
||||
/// the pallet (search box on the right), then the table, at least as wide
|
||||
/// as the pallet and scrolling sideways when the pallet is narrower.
|
||||
@@ -505,18 +522,15 @@ impl LayerPanel {
|
||||
|
||||
// ── Layer rows ────────────────────────────────────────────────────
|
||||
let mut rows_col = column![].spacing(0);
|
||||
let filter = self.filter.to_lowercase();
|
||||
for (i, layer) in self.layers.iter().enumerate() {
|
||||
if !filter.is_empty() && !layer.name.to_lowercase().contains(&filter) {
|
||||
continue;
|
||||
}
|
||||
for i in self.visible_indices() {
|
||||
let layer = &self.layers[i];
|
||||
// Highlight every selected row; show the editable combos only on the
|
||||
// anchor (a single shared combo state can't drive several rows).
|
||||
let is_anchor = self.selected == Some(i);
|
||||
let is_sel = is_anchor || self.selected_multi.contains(&i);
|
||||
let is_current = layer.name == self.current_layer;
|
||||
let is_editing = self.editing == Some(i);
|
||||
let color_open = self.color_picker_row == Some(i);
|
||||
let is_editing = self.editing.as_deref() == Some(layer.name.as_str());
|
||||
let color_open = self.color_picker_row.as_deref() == Some(layer.name.as_str());
|
||||
|
||||
let (ltc, lwc) = if is_anchor {
|
||||
(Some(&self.linetype_combo), Some(&self.lw_combo))
|
||||
@@ -962,7 +976,7 @@ fn layer_row<'a>(
|
||||
// Per-viewport freeze columns
|
||||
for (vp_idx, _vp_col) in vp_cols.iter().enumerate() {
|
||||
let is_vp_frozen = layer.vp_frozen.get(vp_idx).copied().unwrap_or(false);
|
||||
let vp_frz_svg = crate::ui::icons::layer_freeze(is_vp_frozen);
|
||||
let vp_frz_svg = crate::ui::icons::layer_vp_freeze(is_vp_frozen);
|
||||
row_content = row_content.push(
|
||||
container(svg_btn(
|
||||
vp_frz_svg,
|
||||
|
||||
@@ -55,6 +55,10 @@ pub struct AppPrefs {
|
||||
pub commandline_fade_ms: i32,
|
||||
/// ZOOMWHEEL: reverse the mouse-wheel zoom direction.
|
||||
pub zoom_wheel_reversed: bool,
|
||||
/// Scroll pans and Ctrl+scroll zooms (touchpads).
|
||||
pub touchpad_pan: bool,
|
||||
/// Interface scale in percent.
|
||||
pub ui_scale_percent: u16,
|
||||
/// ZOOMFACTOR. The slider sets the range the system variable has; the
|
||||
/// field beside it reaches `settings::ZOOM_FACTOR_MAX`.
|
||||
pub zoom_factor: i32,
|
||||
@@ -694,6 +698,25 @@ pub fn view_window<'a>(
|
||||
.width(sizing.width),
|
||||
Space::new().height(12),
|
||||
color_controls,
|
||||
Space::new().height(16),
|
||||
// A list, not a slider: the dialog rescales under the cursor as the
|
||||
// value changes, which would drag a slider along with it.
|
||||
row![
|
||||
text(crate::t!("Interface scale")).size(12).width(150),
|
||||
iced::widget::pick_list(
|
||||
Some(format!("{}%", prefs.ui_scale_percent)),
|
||||
[75u16, 100, 125, 150, 175, 200]
|
||||
.map(|p| format!("{p}%"))
|
||||
.to_vec(),
|
||||
|label: &String| label.clone(),
|
||||
)
|
||||
.on_select(|label: String| {
|
||||
Message::UiScaleChanged(label.trim_end_matches('%').parse().unwrap_or(100))
|
||||
})
|
||||
.width(sizing.width),
|
||||
]
|
||||
.spacing(12)
|
||||
.align_y(iced::Center),
|
||||
Space::new().height(24),
|
||||
row![
|
||||
text(crate::t!("Model Space Appearance")).size(15),
|
||||
@@ -1264,6 +1287,15 @@ pub fn view_window<'a>(
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
Space::new().height(8),
|
||||
row![
|
||||
iced::widget::checkbox(prefs.touchpad_pan)
|
||||
.on_toggle(Message::TouchpadPanChanged)
|
||||
.size(15),
|
||||
text(crate::t!("Touchpad: scroll pans, Ctrl+scroll or pinch zooms")).size(12),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
Space::new().height(12),
|
||||
row![
|
||||
text(crate::t!("Zoom factor")).size(12).width(150),
|
||||
|
||||
@@ -196,6 +196,25 @@ pub enum XrefPaletteOp {
|
||||
Pathtype(Pathtype),
|
||||
}
|
||||
|
||||
/// Stable row identity carried by row messages instead of the positional
|
||||
/// display index (#49): a rescan/reorder between view and dispatch must not
|
||||
/// retarget the row. Same `(key, saved_path)` pair `refresh` uses to preserve
|
||||
/// selection (key alone can collide across host/nested scopes).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct XrefRowId {
|
||||
pub key: u64,
|
||||
pub saved_path: String,
|
||||
}
|
||||
|
||||
impl XrefRowId {
|
||||
pub fn of(key: u64, saved_path: &str) -> Self {
|
||||
Self {
|
||||
key,
|
||||
saved_path: saved_path.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl XrefManagerPanel {
|
||||
/// Rebuild `entries` from `doc` via [`collect_entries_with_prev`] and
|
||||
/// recompute the parent → children linkage for tree mode. Selection/anchor
|
||||
@@ -310,13 +329,24 @@ impl XrefManagerPanel {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve a row id to its current display index (entries reorder on
|
||||
/// every rescan). `None` = row gone meanwhile; callers no-op (#49).
|
||||
pub fn row_index(&self, id: &XrefRowId) -> Option<usize> {
|
||||
self.entries
|
||||
.iter()
|
||||
.position(|e| e.key == id.key && e.saved_path == id.saved_path)
|
||||
}
|
||||
|
||||
/// Click handling for row picks with normal GUI list semantics:
|
||||
/// - plain click selects exactly one row (previous selection clears);
|
||||
/// - Ctrl/Cmd-click toggles one row;
|
||||
/// - Shift-click extends a contiguous range from the anchor (or the
|
||||
/// clicked row when there is no anchor yet).
|
||||
/// Tree mode always single-selects per spec, whatever the modifiers.
|
||||
pub fn click_select(&mut self, index: usize, extend: SelectExtend) {
|
||||
pub fn click_select(&mut self, id: &XrefRowId, extend: SelectExtend) {
|
||||
let Some(index) = self.row_index(id) else {
|
||||
return;
|
||||
};
|
||||
if index == HOST_ROW || index >= self.entries.len() {
|
||||
return;
|
||||
}
|
||||
@@ -369,19 +399,29 @@ impl XrefManagerPanel {
|
||||
/// Legacy toggle entry point: Ctrl-style toggle in list mode, single
|
||||
/// select in tree mode. Kept for tests and non-pointer callers.
|
||||
pub fn toggle_select(&mut self, index: usize) {
|
||||
self.click_select(index, SelectExtend::Toggle);
|
||||
let Some(id) = self
|
||||
.entries
|
||||
.get(index)
|
||||
.map(|e| XrefRowId::of(e.key, &e.saved_path))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.click_select(&id, SelectExtend::Toggle);
|
||||
}
|
||||
|
||||
/// Right-click selection: select the row alone when it is not already
|
||||
/// selected (standard right-click behavior — the menu then acts on the
|
||||
/// selection, which now includes this row). Keeps multi-selections when
|
||||
/// right-clicking inside them.
|
||||
pub fn right_click_select(&mut self, index: usize) {
|
||||
pub fn right_click_select(&mut self, id: &XrefRowId) {
|
||||
let Some(index) = self.row_index(id) else {
|
||||
return;
|
||||
};
|
||||
if index == HOST_ROW || index >= self.entries.len() {
|
||||
return;
|
||||
}
|
||||
if !self.selected.contains(&index) {
|
||||
self.click_select(index, SelectExtend::Single);
|
||||
self.click_select(id, SelectExtend::Single);
|
||||
} else {
|
||||
self.ensure_preview_for_anchor();
|
||||
}
|
||||
@@ -888,12 +928,7 @@ impl XrefManagerPanel {
|
||||
container(
|
||||
text(crate::t!("XREF No external references in this drawing."))
|
||||
.size(12)
|
||||
.color(iced::Color {
|
||||
r: 0.55,
|
||||
g: 0.55,
|
||||
b: 0.55,
|
||||
a: 1.0,
|
||||
}),
|
||||
.style(muted_style),
|
||||
)
|
||||
.center_x(Fill)
|
||||
.center_y(Fill)
|
||||
@@ -1641,14 +1676,14 @@ fn menu_separator() -> Element<'static, Message> {
|
||||
/// path tools. No Bind, no Attach/Overlay — those are DWG-only and
|
||||
/// hidden entirely. Change Path Type shows a right triangle and a
|
||||
/// second list adjacent to "Change Path Type" on hover.
|
||||
fn row_menu_for(index: usize, change_path_open: bool) -> Element<'static, Message> {
|
||||
fn row_menu_for(id: XrefRowId, change_path_open: bool) -> Element<'static, Message> {
|
||||
let item = |label: &str, op: XrefPaletteOp| -> Element<'static, Message> {
|
||||
menu_row(label.to_string(), Message::XrefRowOp(index, op))
|
||||
menu_row(label.to_string(), Message::XrefRowOp(id.clone(), op))
|
||||
};
|
||||
let pathtype_item = |label: &str, pt: Pathtype| -> Element<'static, Message> {
|
||||
submenu_row(
|
||||
label.to_string(),
|
||||
Message::XrefRowOp(index, XrefPaletteOp::Pathtype(pt)),
|
||||
Message::XrefRowOp(id.clone(), XrefPaletteOp::Pathtype(pt)),
|
||||
)
|
||||
};
|
||||
let change_path_row: Element<'static, Message> = mouse_area(
|
||||
@@ -1697,11 +1732,11 @@ fn row_menu_for(index: usize, change_path_open: bool) -> Element<'static, Messag
|
||||
menu_separator(),
|
||||
menu_row(
|
||||
crate::t!("Select New Path").into_owned(),
|
||||
Message::XrefRowPathPick(index),
|
||||
Message::XrefRowPathPick(id.clone()),
|
||||
),
|
||||
menu_row(
|
||||
crate::t!("Find and Replace...").into_owned(),
|
||||
Message::XrefRowFindReplacePrompt(index),
|
||||
Message::XrefRowFindReplacePrompt(id.clone()),
|
||||
),
|
||||
]
|
||||
.spacing(MENU_SPACING)
|
||||
@@ -1835,6 +1870,8 @@ fn xref_row<'a>(
|
||||
.align_y(iced::Center);
|
||||
|
||||
let index = display.index;
|
||||
// #49: messages carry stable identity; the index stays for zebra striping only.
|
||||
let row_id = XrefRowId::of(entry.key, &entry.saved_path);
|
||||
let row = mouse_area(
|
||||
container(content)
|
||||
.style(move |theme: &Theme| {
|
||||
@@ -1861,9 +1898,12 @@ fn xref_row<'a>(
|
||||
.height(Length::Fixed(ROW_H))
|
||||
.width(Fill),
|
||||
)
|
||||
.on_press(Message::XrefManagerSelect(index));
|
||||
let cm = iced_aw::ContextMenu::new(row, move || row_menu_for(index, change_path_open));
|
||||
RightClickArea::new(cm, Message::XrefRowRightClick(index)).into()
|
||||
.on_press(Message::XrefManagerSelect(row_id.clone()));
|
||||
let cm = iced_aw::ContextMenu::new(row, {
|
||||
let row_id = row_id.clone();
|
||||
move || row_menu_for(row_id.clone(), change_path_open)
|
||||
});
|
||||
RightClickArea::new(cm, Message::XrefRowRightClick(row_id)).into()
|
||||
}
|
||||
|
||||
/// One tree-mode row: indent + optional expand arrow + file icon + name.
|
||||
@@ -1900,6 +1940,8 @@ fn tree_row(
|
||||
);
|
||||
cells = cells.push(text(entry.name.clone()).size(TREE_FONT_SZ));
|
||||
let index = display.index;
|
||||
// #49: messages carry stable identity; the index stays for zebra striping only.
|
||||
let row_id = XrefRowId::of(entry.key, &entry.saved_path);
|
||||
let row = mouse_area(
|
||||
container(cells.spacing(4).width(Fill))
|
||||
.style(move |theme: &Theme| {
|
||||
@@ -1925,9 +1967,12 @@ fn tree_row(
|
||||
})
|
||||
.width(Fill),
|
||||
)
|
||||
.on_press(Message::XrefManagerSelect(index));
|
||||
let cm = iced_aw::ContextMenu::new(row, move || row_menu_for(index, change_path_open));
|
||||
RightClickArea::new(cm, Message::XrefRowRightClick(index)).into()
|
||||
.on_press(Message::XrefManagerSelect(row_id.clone()));
|
||||
let cm = iced_aw::ContextMenu::new(row, {
|
||||
let row_id = row_id.clone();
|
||||
move || row_menu_for(row_id.clone(), change_path_open)
|
||||
});
|
||||
RightClickArea::new(cm, Message::XrefRowRightClick(row_id)).into()
|
||||
}
|
||||
|
||||
/// Tree-mode host root: home icon + name with the current-drawing marker.
|
||||
@@ -2374,6 +2419,8 @@ mod tests {
|
||||
#[test]
|
||||
fn click_select_single_then_range() {
|
||||
use super::SelectExtend;
|
||||
use super::XrefRowId;
|
||||
let id = |key: u64, saved: &str| XrefRowId::of(key, saved);
|
||||
let mut panel = XrefManagerPanel::default();
|
||||
panel.entries = vec![
|
||||
entry(1, "A", "a.dwg"),
|
||||
@@ -2381,27 +2428,27 @@ mod tests {
|
||||
entry(3, "C", "c.dwg"),
|
||||
];
|
||||
// Plain click selects exactly one row.
|
||||
panel.click_select(0, SelectExtend::Single);
|
||||
panel.click_select(2, SelectExtend::Single);
|
||||
panel.click_select(&id(1, "a.dwg"), SelectExtend::Single);
|
||||
panel.click_select(&id(3, "c.dwg"), SelectExtend::Single);
|
||||
assert_eq!(panel.selected, HashSet::from([2]));
|
||||
assert_eq!(panel.anchor, Some(2));
|
||||
// Shift-click extends a contiguous range from the anchor.
|
||||
panel.click_select(2, SelectExtend::Single);
|
||||
panel.click_select(0, SelectExtend::Range);
|
||||
panel.click_select(&id(3, "c.dwg"), SelectExtend::Single);
|
||||
panel.click_select(&id(1, "a.dwg"), SelectExtend::Range);
|
||||
assert_eq!(panel.selected, HashSet::from([0, 1, 2]));
|
||||
// Repeated extends keep the original anchor — rows are never lost.
|
||||
panel.entries.push(entry(4, "D", "d.dwg"));
|
||||
panel.entries.push(entry(5, "E", "e.dwg"));
|
||||
panel.click_select(4, SelectExtend::Range);
|
||||
panel.click_select(&id(5, "e.dwg"), SelectExtend::Range);
|
||||
assert_eq!(panel.selected, HashSet::from([2, 3, 4]));
|
||||
assert_eq!(panel.anchor, Some(2));
|
||||
// Ctrl-click toggles one row.
|
||||
panel.click_select(3, SelectExtend::Toggle);
|
||||
panel.click_select(&id(4, "d.dwg"), SelectExtend::Toggle);
|
||||
assert_eq!(panel.selected, HashSet::from([2, 4]));
|
||||
// Range with no anchor starts at the clicked row.
|
||||
let mut fresh = XrefManagerPanel::default();
|
||||
fresh.entries = vec![entry(1, "A", "a.dwg"), entry(2, "B", "b.dwg")];
|
||||
fresh.click_select(1, SelectExtend::Range);
|
||||
fresh.click_select(&id(2, "b.dwg"), SelectExtend::Range);
|
||||
assert_eq!(fresh.selected, HashSet::from([1]));
|
||||
}
|
||||
|
||||
@@ -2596,8 +2643,9 @@ mod tests {
|
||||
/// opens it with a synthetic right-click on the underlay, mirroring a
|
||||
/// user right-clicking a reference row.
|
||||
fn open_row_menu(change_path_open: bool) -> MenuSimulator {
|
||||
let row_id = XrefRowId::of(0, "");
|
||||
let mut ui = iced_test::simulator(iced_aw::ContextMenu::new(text("anchor"), move || {
|
||||
row_menu_for(0, change_path_open)
|
||||
row_menu_for(row_id.clone(), change_path_open)
|
||||
}));
|
||||
let anchor = ui.find("anchor").expect("anchor underlay present");
|
||||
let center = anchor.visible_bounds().expect("anchor visible").center();
|
||||
@@ -2608,11 +2656,11 @@ mod tests {
|
||||
ui
|
||||
}
|
||||
|
||||
fn published_ops(ui: MenuSimulator) -> Vec<(usize, XrefPaletteOp)> {
|
||||
fn published_ops(ui: MenuSimulator) -> Vec<(XrefRowId, XrefPaletteOp)> {
|
||||
let mut ops = Vec::new();
|
||||
for message in ui.into_messages() {
|
||||
if let Message::XrefRowOp(index, op) = message {
|
||||
ops.push((index, op));
|
||||
if let Message::XrefRowOp(id, op) = message {
|
||||
ops.push((id, op));
|
||||
}
|
||||
}
|
||||
ops
|
||||
@@ -2625,8 +2673,8 @@ mod tests {
|
||||
ui.click(unload.as_str())
|
||||
.expect("Unload row is clickable inside the overlay");
|
||||
assert!(
|
||||
published_ops(ui).contains(&(0, XrefPaletteOp::Unload)),
|
||||
"clicking Unload must publish XrefRowOp(0, Unload)"
|
||||
published_ops(ui).contains(&(XrefRowId::of(0, ""), XrefPaletteOp::Unload)),
|
||||
"clicking Unload must publish XrefRowOp(row, Unload)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2662,7 +2710,10 @@ mod tests {
|
||||
ui.click(make_absolute.as_str())
|
||||
.expect("flyout row is clickable inside the overlay");
|
||||
assert!(
|
||||
published_ops(ui).contains(&(0, XrefPaletteOp::Pathtype(Pathtype::Full))),
|
||||
published_ops(ui).contains(&(
|
||||
XrefRowId::of(0, ""),
|
||||
XrefPaletteOp::Pathtype(Pathtype::Full)
|
||||
)),
|
||||
"clicking Make Absolute must publish the Full-pathtype op"
|
||||
);
|
||||
}
|
||||
@@ -2690,7 +2741,8 @@ mod tests {
|
||||
// buttons again.
|
||||
use iced_core::Widget;
|
||||
|
||||
let mut menu = iced_aw::ContextMenu::new(text("anchor"), || row_menu_for(0, false));
|
||||
let mut menu =
|
||||
iced_aw::ContextMenu::new(text("anchor"), || row_menu_for(XrefRowId::of(0, ""), false));
|
||||
let mut tree = iced_core::widget::Tree::new(
|
||||
&menu as &dyn Widget<Message, iced::Theme, iced::Renderer>,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user