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 <[email protected]>
Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt
This commit is contained in:
Claude committed 2026-10-11 18:44:00 +00:00
commit 2fca3fd18d
136 files changed
+12408 -949

No files matched your search

+48 -8
View File
@@ -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
View File
@@ -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,
+32
View File
@@ -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),
+88 -36
View File
@@ -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>,
);