diff --git a/docs/HLSD/design-tab.md b/docs/HLSD/design-tab.md
index 20299a51a0..4f8b61d755 100644
--- a/docs/HLSD/design-tab.md
+++ b/docs/HLSD/design-tab.md
@@ -197,7 +197,14 @@ The tab is a page of Orca's main window and answers to the same settings as Prep
Orca's sidebar grey, which the icon cache maps per theme, so they are re-rasterised rather
than re-tinted.
- **Scale.** Sizes are in DIP, and a DPI change reaches `DesignPanel::msw_rescale`, which
- re-rasterises every icon (button faces, flyout rows, card headers, the tree's image list).
+ re-rasterises every icon (button faces, flyout rows, card headers, the feature and body lists'
+ row icons).
+- **Sidebar icons.** Every clickable icon in the sidebar shows a hover chip. The card-header and
+ constraint-row buttons are Orca's self-painted `Button`, because a native button cannot take a
+ hover background on macOS. The Feature tree and Bodies lists are a custom-drawn
+ `DesignRowList` rather than a `wxTreeCtrl`, so each row carries its own actions — Edit,
+ Show/hide and Delete on a feature, Move, Show/hide and Delete on a body — and the eye shows
+ whether that row is hidden.
- **Viewport text.** The status line and the active tool's values are drawn by the canvas in
its ImGui pass, so they go with the canvas: a top-level window over GL does not follow its
frame and was left floating over other applications.
@@ -219,7 +226,11 @@ Right-clicking geometry opens the *offer*: eight families in a fixed order, each
permanent row index, verbs that do not apply shown disabled **in place with their reason**
rather than removed. The invariant is that a verb's row index is identical in every selection
where it appears and that adding a verb never moves an existing one — the hand learns the
-position, so the menu is never re-sorted, compacted or adaptively ordered.
+position, so the menu is never re-sorted, compacted or adaptively ordered. Above the families
+sits one *flat* row, holding Rename and Color — what a selection is opened for most: its verbs are
+items of their own at the top of the menu rather than a family's submenu. It is appended after
+the eight, so it moved no existing index, and it reads the same from the viewport and from a row
+of the Bodies list.
An invariant across 92 verbs and 20 selection kinds does not survive by review, so the map
exists once, as data: `scripts/CAD/tool_atlas.json` carries every verb with its row, key, icon,
@@ -250,6 +261,7 @@ scripted action and a clicked one cannot diverge. It is off unless the variable
| `src/libslic3r/CAD/SketchSolver.*` | constraint solving, over the vendored solver |
| `src/libslic3r/slvs/` | vendored 2D constraint solver (GPLv3) |
| `src/slic3r/GUI/CAD/DesignPanel.*` | the tab: toolbar, feature cards, tree, key maps |
+| `src/slic3r/GUI/CAD/DesignRowList.*` | the Feature tree and Bodies lists, with per-row actions |
| `src/slic3r/GUI/CAD/DesignCanvas.*` | viewport integration |
| `src/slic3r/GUI/CAD/DesignSketchTool.*` | in-canvas sketching |
| `src/slic3r/GUI/CAD/DesignInteraction.hpp` | the Esc level contract |
diff --git a/resources/images/design_eye_off.svg b/resources/images/design_eye_off.svg
new file mode 100644
index 0000000000..42f619305d
--- /dev/null
+++ b/resources/images/design_eye_off.svg
@@ -0,0 +1 @@
+
diff --git a/scripts/CAD/gen_offer_table.py b/scripts/CAD/gen_offer_table.py
index c6be64f891..3716b95db3 100644
--- a/scripts/CAD/gen_offer_table.py
+++ b/scripts/CAD/gen_offer_table.py
@@ -114,7 +114,7 @@ def main():
"struct OfferVerb {",
" const char* id;",
" const char* name; // drawing-office word (L10); marked L(); translated at use",
- " int row; // 0..7, the ratified index — NEVER reorder",
+ " int row; // index into kOfferRowNames, the ratified address — NEVER reorder",
" const char* key; // shortcut shown in the row, or nullptr",
" const char* action;",
" const char* refusal; // why this row is greyed, in the product's own words",
@@ -135,11 +135,20 @@ def main():
"// Row labels, in ratified order.",
"static const char* const kOfferRowNames[] = {",
]
+ # A flat row's label is never shown, so it is not marked for translation.
for s in A["slots"]:
- lines.append(f' {tstr(s["label"])},')
+ lines.append(f' {cstr(s["label"]) if s.get("flat") else tstr(s["label"])},')
lines += [
"};",
f"static const int kOfferRowCount = {len(slots)};",
+ "// A flat row is not a family: its verbs come first, at the top level of the offer, each",
+ "// an item of its own.",
+ "static const bool kOfferRowFlat[] = {",
+ ]
+ for s in A["slots"]:
+ lines.append(f' {"true" if s.get("flat") else "false"},')
+ lines += [
+ "};",
"",
"static const OfferVerb kOfferVerbs[] = {",
]
diff --git a/scripts/CAD/tool_atlas.json b/scripts/CAD/tool_atlas.json
index de3fc8a1ed..1e7f1bf968 100644
--- a/scripts/CAD/tool_atlas.json
+++ b/scripts/CAD/tool_atlas.json
@@ -67,6 +67,12 @@
"angle": 225,
"label": "Modify",
"why": "edits or removes what is already there"
+ },
+ {
+ "id": "top",
+ "label": "Top",
+ "flat": true,
+ "why": "the verbs a selection is opened for most. A flat slot is not a family: its verbs sit first, at the top level of the offer, each an item of its own, and its label is never shown. Appended last, so no ratified index moved."
}
],
"selections": [
@@ -1044,7 +1050,7 @@
{
"id": "rename",
"name": "Rename…",
- "slot": "modify",
+ "slot": "top",
"key": "F2",
"feature": "Tree",
"mcp": null,
@@ -1084,7 +1090,7 @@
{
"id": "colour",
"name": "Color",
- "slot": "modify",
+ "slot": "top",
"key": null,
"feature": null,
"mcp": null,
diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt
index 9611c0fa68..5f38507757 100644
--- a/src/slic3r/CMakeLists.txt
+++ b/src/slic3r/CMakeLists.txt
@@ -848,6 +848,8 @@ if (SLIC3R_CAD)
list(APPEND SLIC3R_GUI_SOURCES
GUI/CAD/DesignPanel.cpp
GUI/CAD/DesignPanel.hpp
+ GUI/CAD/DesignRowList.cpp
+ GUI/CAD/DesignRowList.hpp
GUI/CAD/DesignTextDialog.cpp
GUI/CAD/DesignTextDialog.hpp
GUI/CAD/DesignCanvas.cpp
diff --git a/src/slic3r/GUI/CAD/DesignOffer.hpp b/src/slic3r/GUI/CAD/DesignOffer.hpp
index fcfb908846..67133573a3 100644
--- a/src/slic3r/GUI/CAD/DesignOffer.hpp
+++ b/src/slic3r/GUI/CAD/DesignOffer.hpp
@@ -53,7 +53,7 @@ inline uint32_t offer_bit(OfferSel s) { return 1u << int(s); }
struct OfferVerb {
const char* id;
const char* name; // drawing-office word (L10); marked L(); translated at use
- int row; // 0..7, the ratified index — NEVER reorder
+ int row; // index into kOfferRowNames, the ratified address — NEVER reorder
const char* key; // shortcut shown in the row, or nullptr
const char* action;
const char* refusal; // why this row is greyed, in the product's own words
@@ -81,8 +81,22 @@ static const char* const kOfferRowNames[] = {
L("Transform"),
L("Reference"),
L("Modify"),
+ "Top",
+};
+static const int kOfferRowCount = 9;
+// A flat row is not a family: its verbs come first, at the top level of the offer, each
+// an item of its own.
+static const bool kOfferRowFlat[] = {
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
};
-static const int kOfferRowCount = 8;
static const OfferVerb kOfferVerbs[] = {
{"sketch", L("Sketch"), 0, "Shift+S", "key:S+S", L("Click a face or a reference plane in the viewport, then a sketch tool"), 0x00000403u, 0, 0, false, false, nullptr, "design_sketch", L("Click a face or a reference plane, then pick a drawing tool")},
@@ -125,9 +139,9 @@ static const OfferVerb kOfferVerbs[] = {
{"mass_props", L("Volume and area"), 6, nullptr, "btn:mass", nullptr, 0x000000feu, 1, 0, false, false, nullptr, "info", L("Report the volume and surface area of the selected body")},
{"interference", L("Interference"), 6, nullptr, "btn:interference", L("Interference needs at least two bodies"), 0x00000280u, 2, 0, false, false, nullptr, nullptr, L("Check whether two bodies overlap — reports, changes nothing")},
{"edit_feature", L("Edit"), 7, nullptr, "btn:edit", nullptr, 0x00007d8eu, 0, 0, false, false, nullptr, "design_edit", L("Reopen the selected feature to change what it was made from")},
- {"rename", L("Rename…"), 7, "F2", "btn:rename", L("Select a feature, or a body, to rename it"), 0x00004080u, 0, 0, false, false, nullptr, nullptr, L("Give this feature a name you will recognise in the tree (a body takes its name from the feature that makes it)")},
+ {"rename", L("Rename…"), 8, "F2", "btn:rename", L("Select a feature, or a body, to rename it"), 0x00004080u, 0, 0, false, false, nullptr, nullptr, L("Give this feature a name you will recognise in the tree (a body takes its name from the feature that makes it)")},
{"delete_face", L("Delete Face"), 7, nullptr, "fly:dressup#3", L("Delete Face needs a body — add or import one first"), 0x0000000eu, 1, 0, false, false, nullptr, "design_delete", L("Remove faces from a body and heal the solid")},
- {"colour", L("Color"), 7, nullptr, "btn:colour", nullptr, 0x000001feu, 1, 0, false, false, nullptr, "color_palette", L("Set the selected body's display color")},
+ {"colour", L("Color"), 8, nullptr, "btn:colour", nullptr, 0x000001feu, 1, 0, false, false, nullptr, "color_palette", L("Set the selected body's display color")},
{"delete", L("Delete"), 7, "Del", "btn:delete", nullptr, 0x000f7c00u, 0, 0, false, false, nullptr, "design_delete", L("Delete what is selected")},
{"delete_body", L("Delete Body"), 7, nullptr, "btn:delete_body", nullptr, 0x000001feu, 1, 0, false, false, nullptr, "design_delete", L("Delete this whole body — removes the feature it was made from")},
{"sk_line_t", L("Line"), 0, "L", "key:L", nullptr, 0x000f8000u, 0, 0, false, true, L("Line"), "design_line", L("Line — click start, then end")},
diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp
index 7d4f83824a..9c84a65942 100644
--- a/src/slic3r/GUI/CAD/DesignPanel.cpp
+++ b/src/slic3r/GUI/CAD/DesignPanel.cpp
@@ -3,6 +3,7 @@
#include "slic3r/GUI/CAD/DesignCanvas.hpp"
#include "slic3r/GUI/CAD/DesignSketchTool.hpp"
#include "slic3r/GUI/CAD/DesignTextDialog.hpp" // Text: font, height, live outline
+#include "slic3r/GUI/CAD/DesignRowList.hpp" // Feature tree and Bodies rows with their own actions
#include "slic3r/GUI/CAD/DesignOffer.hpp" // generated offer table — see scripts/CAD/tool_atlas.json
#include "libslic3r/CAD/GeometryEngine.hpp" // face_by_index for face-extrude gizmo anchor
#include "libslic3r/TriangleMesh.hpp" // mesh import: STL/OBJ -> indexed_triangle_set
@@ -58,12 +59,9 @@
#include
#include
#include
-#include
#include
#include
#include
-#include
-#include
#include
#include
#include
@@ -242,6 +240,43 @@ static StaticBox* make_card(wxWindow* parent)
return c;
}
+// The sidebar's icon-only buttons (card headers, constraint rows) highlight under the pointer with
+// a rounded chip. Orca's ::Button rather than a ScalableButton: a native button cannot take that
+// hover colour (macOS ignores a button background, MSW turns the button owner-drawn), while
+// ::Button paints itself the same on every platform. Its sizes are in pixels, and the panel
+// background is a Design token the dark map does not know, so refresh_icons() re-applies this
+// after a DPI or theme change to every button named "design_icon_btn".
+static void style_sidebar_icon_btn(::Button* b)
+{
+ b->SetPaddingSize(b->FromDIP(wxSize(2, 2)));
+ b->SetMinSize(b->FromDIP(wxSize(24, 24))); // square, whatever the icon size
+ b->SetCornerRadius(b->FromDIP(4));
+ b->SetBackgroundColor(StateColor(
+ std::pair(wxColour(DesignRowList::hover_chip), StateColor::Hovered), // dark-mapped at paint
+ std::pair(dp_panel_bg(), StateColor::Normal))); // must stay last
+}
+
+static ::Button* sidebar_icon_btn(wxWindow* parent, const char* icon, const wxString& tip, int px = 20)
+{
+ auto* b = new ::Button(parent, "", icon, wxBORDER_NONE, px);
+ b->SetName("design_icon_btn");
+ b->SetCanFocus(false);
+ b->SetIconSpacing(0); // off macOS an empty label still reserves the icon-text gap
+ b->SetBorderColor(StateColor());
+ b->SetToolTip(tip);
+ style_sidebar_icon_btn(b);
+ return b;
+}
+
+// The icons on each Feature tree and Bodies row (DesignRowList::Action::id).
+enum RowAction { RowEdit, RowMove, RowVisibility, RowDelete };
+
+// A row's eye shows the state the row is in; its tip names what a click does.
+static DesignRowList::Action eye_action(bool shown)
+{
+ return { RowVisibility, shown ? "design_eye" : "design_eye_off", shown ? _L("Hide") : _L("Show") };
+}
+
// Prepare outlines every numeric field (rounded, #4A4A51 on dark). wxSpinCtrlDouble is a
// native GTK control that cannot draw that, and Orca's own SpinInput is int-only — it would
// silently truncate a 2.5 mm radius. So the double spin keeps its arrows and validation and
@@ -1040,29 +1075,20 @@ DesignPanel::DesignPanel(wxWindow* parent)
fadd("color", b_color);
m_verb_actions["btn:colour"] = [this] { on_set_body_color(); };
m_verb_actions["btn:delete"] = [this] { on_delete_feature(); };
- // Rename exists as a slow double-click on the row too, but the offer is this tab's only
- // tool vocabulary — a rename that only a double-click reveals is not discoverable, and
- // the row IS the object, so it belongs in the menu (and on F2) as well as on the row.
+ // A row's double-click is Edit, so renaming needs a door of its own. The offer is this
+ // tab's only tool vocabulary and the row IS the object, so rename sits in the offer, in
+ // the feature row's right-click menu and on F2, and opens the editor on the row itself.
auto rename_feature = [this] {
// A BODY renames ITSELF. The earlier version resolved the body to
// CadBody::source_feature and renamed that feature, which is the wrong object: a
// body accumulates many features and the first one is not its name. The body row
// is editable now (CadBody::user_name), so the verb opens the editor there.
- const int b = tree_body_selection();
- if (b >= 0 && b < int(m_tree_body_items.size())) {
- const wxTreeItemId row = m_tree_body_items[b];
- // After the menu, not inside it: an editor opened from within PopupMenu's
- // nested loop never appears.
- CallAfter([this, row] { m_parts->SetFocus(); m_parts->EditLabel(row); });
- return;
- }
- const int sel = tree_selection();
- if (sel != wxNOT_FOUND && sel < int(m_tree_items.size())) {
- const wxTreeItemId row = m_tree_items[sel];
- CallAfter([this, row] { m_tree->SetFocus(); m_tree->EditLabel(row); });
- } else {
+ if (const int b = tree_body_selection(); b >= 0)
+ m_parts->begin_rename(b);
+ else if (const int sel = tree_selection(); sel != wxNOT_FOUND)
+ m_tree->begin_rename(sel);
+ else
set_status(_L("Select a feature, or a body, first — then rename it")); // not an error
- }
};
m_verb_actions["btn:rename"] = rename_feature;
m_keys_feature[WXK_F2] = rename_feature; // a function key, so no letter space spent
@@ -3047,8 +3073,8 @@ DesignPanel::DesignPanel(wxWindow* parent)
t->Wrap(240);
// Feature tree card. Same idiom as Prepare's sections (icon + Head_14 title + rule) via the
- // shared card_header helper, instead of the bare micro-label this used to be; the row-edit
- // actions live in the header, as Prepare puts its section actions.
+ // shared card_header helper, instead of the bare micro-label this used to be. What acts on one
+ // feature sits on that feature's row; the header keeps reordering and the interference check.
m_tree_box = make_card(m_form);
auto* tree_inner = new wxBoxSizer(wxVERTICAL);
m_tree_box->SetSizer(tree_inner);
@@ -3062,43 +3088,21 @@ DesignPanel::DesignPanel(wxWindow* parent)
FromDIP(SidebarProps::ContentMargin()));
tree_inner->Add(new wxStaticLine(m_tree_box), 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP,
FromDIP(SidebarProps::TitlebarMargin()));
- m_tree = new wxTreeCtrl(m_tree_box, wxID_ANY, wxDefaultPosition, FromDIP(wxSize(-1, 64)),
- wxTR_HIDE_ROOT | wxTR_SINGLE | wxTR_NO_LINES |
- wxTR_FULL_ROW_HIGHLIGHT | wxBORDER_SIMPLE | wxTR_EDIT_LABELS);
+ // Sized to its rows, so a short history wastes no block, and scrolling past 9.
+ m_tree = new DesignRowList(m_tree_box, 9);
m_tree->SetBackgroundColour(dp_panel_bg());
- // Per-feature-type icons (indices match tree_icon_for): sketch/extrude/dressup/hole/thread.
- // The list is sized from the bitmaps themselves (image-list sizes are physical and must match
- // them), and rebuilt with the other icons on a DPI or theme change.
- auto tree_images = [this] {
- static const char* const kIcons[] = { "design_sketch", "design_extrude", "design_dressup",
- "design_hole", "design_thread", "design_shell" };
- std::vector bmps;
- for (const char* name : kIcons)
- bmps.push_back(create_scaled_bitmap(name, this, 16));
- const wxSize sz = bmps.front().GetSize();
- m_tree_images = new wxImageList(sz.x, sz.y);
- for (const wxBitmap& b : bmps)
- m_tree_images->Add(b);
- m_tree->AssignImageList(m_tree_images); // takes ownership; frees the previous list
- };
- tree_images();
- m_icon_refresh.push_back(tree_images);
tree_inner->Add(m_tree, 0, wxEXPAND | wxALL, 12);
// Selecting a body-producing feature (Extrude/Fillet/Chamfer/Hole/Thread) in the
// tree highlights the solid in the viewport; a Sketch row clears the highlight
// (its face is already shown via the persistent sketch overlay).
- m_tree->Bind(wxEVT_TREE_SEL_CHANGED, [this](wxTreeEvent&) {
+ m_tree->on_select = [this] {
if (!m_viewport) return;
- // Bodies live in the Parts list now; picking a feature here drops any body selection
- // so the two lists can't both claim to be "the target".
- // ONLY when this tree actually has a selection. These two lists clear each other's
- // selection so that "the target" is never ambiguous, and that was harmless while both
- // calls were UnselectAll() — a no-op on a wxTR_SINGLE tree. Now that Unselect() really
- // clears, the pair became a loop: clicking a body row runs apply_body_row, which calls
- // m_tree->Unselect(), which fires THIS handler, which cleared the body row the user had
- // just clicked. The guard keeps the mutual-exclusion and drops the echo.
- if (m_parts && tree_selection() != wxNOT_FOUND) m_parts->Unselect();
+ // Picking a feature drops any body selection, so the two lists never both claim to be
+ // "the target" — but ONLY when this tree has a selection. Each list notifies on every
+ // change, so clicking a body row runs apply_body_row, whose m_tree->unselect() fires
+ // THIS handler, which would otherwise clear the body row the user had just clicked.
+ if (m_parts && tree_selection() != wxNOT_FOUND) m_parts->unselect();
const int sel = tree_selection();
const bool body = (sel >= 0 && sel < int(m_doc.features.size()) &&
m_doc.features[sel].type != CadFeatureType::Sketch &&
@@ -3116,27 +3120,31 @@ DesignPanel::DesignPanel(wxWindow* parent)
_L("%s selected — F2 or right-click renames it, double-click edits it"),
wxString::FromUTF8(m_doc.features[sel].name)));
}
- });
+ };
// Double-click a row = Edit, the same gesture that re-opens a committed sketch on the canvas.
- // Without it the row only highlights and the feature looks dead until the user finds the
- // Edit button in the section header.
- m_tree->Bind(wxEVT_TREE_ITEM_ACTIVATED, [this](wxTreeEvent&) { on_edit_feature(); });
+ m_tree->on_activate = [this] { on_edit_feature(); };
- // Right-click a row: the three things a row can do. Renaming had no discoverable route at
- // all — the header pencil is Edit, a double-click ACTIVATES the row and is also Edit (the
- // "slow double-click renames" the old comment promised does not survive wxGTK, which fires
- // ITEM_ACTIVATED first), none of the seven header icons renames, and F2 is a function key
- // nothing announces. A user who wants to name a sketch tries the row, and now the row
- // answers. rename.
- m_tree->Bind(wxEVT_TREE_ITEM_RIGHT_CLICK, [this](wxTreeEvent& e) {
- m_tree->SelectItem(e.GetItem()); // right-click targets what it points at
- const int sel = tree_selection();
- if (sel == wxNOT_FOUND) return;
- // EVERYTHING A ROW CAN DO, in one place. The header icons stay as a quick bar, but the
- // menu is the reference: the element you click answers with what applies to it, and a
- // menu grows without spending an icon nobody recognises. Split into what the row IS
- // (name, contents), where it SITS (order, visibility) and what removes it.
+ // The row's own Edit / Show-hide / Delete, on the row the click selected. The body list is
+ // cleared here too, not left to on_select, which re-clicking the selected row does not run:
+ // a body row still selected would be what on_toggle_visibility acts on.
+ m_tree->on_action = [this](int, int id) {
+ if (m_parts) m_parts->unselect();
+ switch (id) {
+ case RowEdit: on_edit_feature(); break;
+ case RowVisibility: on_toggle_visibility(); break;
+ case RowDelete: on_delete_feature(); break;
+ }
+ };
+
+ // Right-click a row: everything a row can do. Renaming had no discoverable route at all — a
+ // double-click is Edit, and F2 is a function key nothing announces. A user who wants to name
+ // a sketch tries the row, and the row answers.
+ m_tree->on_menu = [this](int row, const wxPoint& screen) {
+ // EVERYTHING A ROW CAN DO, in one place. The row's icons are the quick bar, but the menu
+ // is the reference: the element you click answers with what applies to it, and a menu
+ // grows without spending an icon nobody recognises. Split into what the row IS (name,
+ // contents), where it SITS (order, visibility) and what removes it.
wxMenu menu;
const int id_rename = wxWindow::NewControlId();
const int id_edit = wxWindow::NewControlId();
@@ -3150,8 +3158,8 @@ DesignPanel::DesignPanel(wxWindow* parent)
// Scale artwork acts on THIS feature's imported outline, so it belongs to the row and
// is offered only where it means something. It used to hide inside the header's Move
// button, which otherwise moved a body — two different subjects on one icon.
- const bool art = sel < int(m_doc.features.size()) &&
- !m_doc.features[sel].imported_regions.empty();
+ const bool art = row < int(m_doc.features.size()) &&
+ !m_doc.features[row].imported_regions.empty();
if (art) menu.Append(id_art, _L("Scale artwork"));
menu.AppendSeparator();
menu.Append(id_up, _L("Move up"));
@@ -3159,99 +3167,43 @@ DesignPanel::DesignPanel(wxWindow* parent)
menu.Append(id_vis, _L("Show / hide"));
menu.AppendSeparator();
menu.Append(id_del, _L("Delete"));
- menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) {
- const int row = tree_selection();
- if (row != wxNOT_FOUND && row < int(m_tree_items.size()))
- m_tree->EditLabel(m_tree_items[row]);
- }, id_rename);
+ menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { m_verb_actions["btn:rename"](); }, id_rename);
menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_edit_feature(); }, id_edit);
menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_move_feature(-1); }, id_up);
menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_move_feature(+1); }, id_down);
menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_toggle_visibility(); }, id_vis);
menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_delete_feature(); }, id_del);
if (art)
- menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) {
- const int row = tree_selection();
- if (row != wxNOT_FOUND) on_transform_imported(row);
- }, id_art);
- m_tree->PopupMenu(&menu);
- });
-
- // In-place rename of a feature row (slow double-click, the offer's Rename verb, or F2).
- // The name is what makes a tree of eight sketches readable, and the tree row IS the object —
- // so renaming belongs on the row, not in a side-panel field. Bodies are computed results,
- // not named features, so a body row must never open an editor (it cannot, they live in the
- // Parts list, but the guard keeps a future change from slipping a body into this tree).
- auto item_index = [this](const wxTreeItemId& it) -> int {
- for (size_t i = 0; i < m_tree_items.size(); ++i)
- if (m_tree_items[i] == it) return int(i);
- return wxNOT_FOUND;
+ menu.Bind(wxEVT_MENU, [this, row](wxCommandEvent&) { on_transform_imported(row); }, id_art);
+ m_tree->PopupMenu(&menu, m_tree->ScreenToClient(screen));
};
- m_tree->Bind(wxEVT_TREE_BEGIN_LABEL_EDIT, [this, item_index](wxTreeEvent& e) {
- // The event's item is the authority for WHAT is being edited; tree_selection() is not,
- // because the editor can open on a row that is not the current selection. A row that is
- // not a feature (a body, or a stray id) gets the edit vetoed before it can take a name.
- if (tree_body_selection() >= 0 || item_index(e.GetItem()) == wxNOT_FOUND) { e.Veto(); return; }
- e.Skip();
- });
- m_tree->Bind(wxEVT_TREE_END_LABEL_EDIT, [this, item_index](wxTreeEvent& e) {
- if (e.IsEditCancelled()) return;
- const int idx = item_index(e.GetItem());
- if (idx == wxNOT_FOUND) { e.Veto(); return; }
- wxString label = e.GetLabel();
- label.Trim(true).Trim(false);
- if (label.empty()) { e.Veto(); return; } // a nameless row is worse than a badly named one
- m_doc.features[idx].name = std::string(label.ToUTF8().data());
- sync_recipe_to_model(); // the name is part of the recipe, so the save path persists it
- e.Skip(); // let wx finish applying the label to the item it is holding
- // REBUILD LATER, NOT NOW. refresh_tree() deletes and re-creates every wxTreeItemId, and
- // we are inside wx's own END_LABEL_EDIT dispatch for one of them — destroying it here
- // frees the item the caller is still using and takes the process down. Measured: typing
- // a name and pressing Enter killed the app outright, with the keystrokes traced and no
- // trace for the Return. Deferring to the next event-loop turn lets wx finish first.
- CallAfter([this] { refresh_tree(); });
- });
- // Feature-tree edit actions: act on the selected feature (delete / reorder). These sit in the
- // card header (Prepare puts its section actions there too) rather than on a loose row below.
+ // In-place rename of a feature row (F2, the offer's Rename verb, or the row's menu). The name
+ // is what makes a tree of eight sketches readable, and the tree row IS the object — so
+ // renaming belongs on the row, not in a side-panel field. The list hands the name over after
+ // its editor's events have finished, so rebuilding the rows here is safe.
+ m_tree->on_rename = [this](int row, const wxString& name) {
+ if (row < 0 || row >= int(m_doc.features.size())) return;
+ m_doc.features[row].name = std::string(name.ToUTF8().data());
+ refresh_tree(); // which syncs the recipe, so the save path persists the name
+ };
+
+ // The header keeps what is not one row's own action: reordering, which moves the selected
+ // feature among the others, and the interference check, which reports on every body.
{
wxBoxSizer* trow = m_hdr_tree_row;
- auto edit_btn = [this](const char* icon, const wxString& tip) {
- // Header-sized: reads as a section action, not a primary control.
- auto* b = new ScalableButton(m_tree_box, wxID_ANY, icon, "", FromDIP(wxSize(24, 24)),
- wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 20);
- b->SetToolTip(tip);
- return b;
- };
- auto* edit = edit_btn("design_edit", _L("Edit"));
- edit->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_edit_feature(); });
// NO Move here. Moving a body is not a feature-row action — this header sits over the
// FEATURE tree, and the button had to guess its subject from whatever happened to be
// selected, answering a feature row with an instruction about bodies. It lives where a
- // body lives: the Bodies card's own action row, and the offer for a selected body.
+ // body lives: on each body's row in the Bodies list, and in the offer for a selected body.
// Scaling imported artwork, which shared this button, moved to the row's own menu.
- auto* vis = edit_btn("design_eye", _L("Show / hide"));
- vis->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_toggle_visibility(); });
- auto* del = edit_btn("design_delete", _L("Delete"));
- del->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
- // A body row deletes the feature that made it. on_delete_body() already resolves
- // CadBody::source_feature and asks for confirmation by name; it was reachable only
- // from the right-click offer, so this button answered a selected body row with
- // "select the FEATURE that created this body" — an instruction the user cannot act
- // on, since the tree does not say which feature that is. It does now.
- if (tree_body_selection() >= 0) on_delete_body();
- else on_delete_feature();
- });
- auto* up = edit_btn("design_moveup", _L("Move up"));
+ auto* up = sidebar_icon_btn(m_tree_box, "design_moveup", _L("Move up"));
up->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_move_feature(-1); });
- auto* down = edit_btn("design_movedown", _L("Move down"));
+ auto* down = sidebar_icon_btn(m_tree_box, "design_movedown", _L("Move down"));
down->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_move_feature(+1); });
- m_btn_interfere = edit_btn("color_palette", _L("Check interference — find overlapping bodies"));
+ m_btn_interfere = sidebar_icon_btn(m_tree_box, "color_palette", _L("Check interference — find overlapping bodies"));
m_btn_interfere->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_check_interference(); });
const int gap = FromDIP(SidebarProps::ElementSpacing());
- trow->Add(edit, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap);
- trow->Add(vis, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap);
- trow->Add(del, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap);
trow->Add(up, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap);
trow->Add(down, 0, wxALIGN_CENTER_VERTICAL);
// Interference check sits after a rule: it reports, it does not edit the recipe.
@@ -3274,45 +3226,20 @@ DesignPanel::DesignPanel(wxWindow* parent)
m_parts_hdr = new wxBoxSizer(wxHORIZONTAL);
m_parts_hdr->Add(card_header(m_parts_box, "design_extrude", _L("Bodies"), m_parts_label), 0,
wxALIGN_CENTER_VERTICAL);
- // The bodies card carries the actions that act on a BODY. Move came from the feature-tree
- // header, where it had to guess whether its subject was a body or a feature; Show/hide,
- // Delete and Colour are deliberate COPIES of feature-tree actions, because a body row is a
- // different subject and a user working in this list should not have to travel to another
- // card to hide or recolour what they have selected. Boolean is not a copy — it is the one
- // body-body operation, gated through on_boolean_tool. Each one already resolves the body row
- // itself (on_toggle_visibility, on_delete_body, on_set_body_color), so nothing here decides
- // policy — the card only gives them a home next to the rows they act on.
+ // The bodies card header carries the body actions that are not one row's own. Boolean is the
+ // one body-body operation, gated through on_boolean_tool, and Colour recolours the selected
+ // body, resolving it itself (on_set_body_color), so nothing here decides policy. Move,
+ // Show/hide and Delete act on one body, so they sit on its row, as a feature's actions do.
{
- auto body_btn = [this](const char* icon, const wxString& tip) {
- auto* b = new ScalableButton(m_parts_box, wxID_ANY, icon, "", FromDIP(wxSize(24, 24)),
- wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 20);
- b->SetToolTip(tip);
- return b;
- };
- auto* bmove = body_btn("design_move", _L("Move body"));
- bmove->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
- if (m_sel_solid_body >= 0 && m_sel_solid_body < int(m_doc.bodies.size())) {
- on_move_body();
- } else {
- set_status(StatusKind::Info, _L("Select a body row first, then move it"));
- }
- });
// Boolean lives here as well as on the toolbar: combining two bodies is a body action,
// and a user working in the body list should not have to leave it to find this.
- auto* bbool = body_btn("design_boolean", _L("Boolean — join, subtract or intersect with another body"));
+ auto* bbool = sidebar_icon_btn(m_parts_box, "design_boolean", _L("Boolean — join, subtract or intersect with another body"));
bbool->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_boolean_tool(); });
- auto* bvis = body_btn("design_eye", _L("Show / hide"));
- bvis->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_toggle_visibility(); });
- auto* bdel = body_btn("design_delete", _L("Delete"));
- bdel->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_delete_body(); });
- auto* bcol = body_btn("color_palette", _L("Color"));
+ auto* bcol = sidebar_icon_btn(m_parts_box, "color_palette", _L("Color"));
bcol->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_set_body_color(); });
const int bgap = FromDIP(SidebarProps::ElementSpacing());
m_parts_hdr->AddStretchSpacer(1);
- m_parts_hdr->Add(bmove, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, bgap);
m_parts_hdr->Add(bbool, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, bgap);
- m_parts_hdr->Add(bvis, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, bgap);
- m_parts_hdr->Add(bdel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, bgap);
m_parts_hdr->Add(bcol, 0, wxALIGN_CENTER_VERTICAL);
}
parts_inner->Add(m_parts_hdr, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP,
@@ -3322,9 +3249,7 @@ DesignPanel::DesignPanel(wxWindow* parent)
FromDIP(SidebarProps::TitlebarMargin()));
m_parts_hdr->ShowItems(false); // no bodies yet on a fresh document
m_parts_rule->Hide();
- m_parts = new wxTreeCtrl(m_parts_box, wxID_ANY, wxDefaultPosition, FromDIP(wxSize(-1, 48)),
- wxTR_HIDE_ROOT | wxTR_SINGLE | wxTR_NO_LINES |
- wxTR_FULL_ROW_HIGHLIGHT | wxBORDER_SIMPLE | wxTR_EDIT_LABELS);
+ m_parts = new DesignRowList(m_parts_box, 6); // scrolls past 6
m_parts->SetBackgroundColour(dp_panel_bg());
parts_inner->Add(m_parts, 0, wxEXPAND | wxALL, 12);
// Start hidden: a fresh document has no bodies, and refresh_parts() only runs on the first
@@ -3333,16 +3258,16 @@ DesignPanel::DesignPanel(wxWindow* parent)
m_parts_label->Hide();
// Taking a body from the list means the SAME state change however it was asked for, so the
// normalisation lives here and not inside a selection handler. That distinction is not
- // pedantry: SelectItem() on a row that is ALREADY selected fires no SEL_CHANGED at all, so a
- // version of this that only ran on selection left a stale vertex/edge from an earlier
- // viewport pick in place — and offer_selection_kind() tests vertex FIRST, so right-clicking
- // the body row served the VERTEX offer (Fillet greyed, Mirror in place of Repeat) while the
- // row sat highlighted. Measured on the rig 2026-08-02; it is invisible from the code alone.
+ // pedantry: selecting a row that is ALREADY selected notifies nobody, so a version of this
+ // that only ran on selection left a stale vertex/edge from an earlier viewport pick in place
+ // — and offer_selection_kind() tests vertex FIRST, so right-clicking the body row served the
+ // VERTEX offer (Fillet greyed, Mirror in place of Repeat) while the row sat highlighted.
+ // Measured on the rig 2026-08-02; it is invisible from the code alone.
auto apply_body_row = [this](int b) {
if (!m_viewport || b < 0) return;
// One selection at a time: a body row and a feature row mean different things to the
// op bar, so clear the feature tree's highlight when a body takes over.
- if (m_tree) m_tree->Unselect(); // wxTR_SINGLE: UnselectAll() does nothing here
+ if (m_tree) m_tree->unselect();
m_viewport->set_body_highlight(false); // the per-body overlay does the tint
m_viewport->select_body(b); // also drops the vertex/edge marker
m_sel_solid_body = b;
@@ -3351,53 +3276,41 @@ DesignPanel::DesignPanel(wxWindow* parent)
m_pick_face = m_pick_face_body = -1; // chosen from the list, no face was pointed at
set_status(StatusKind::Info, wxString::Format(_L("Body %d selected — right-click for what applies to it"), b + 1));
};
- m_parts->Bind(wxEVT_TREE_SEL_CHANGED, [this, apply_body_row](wxTreeEvent&) {
- apply_body_row(tree_body_selection());
- });
+ m_parts->on_select = [this, apply_body_row] { apply_body_row(tree_body_selection()); };
+ // The row's own Move / Show-hide / Delete. apply_body_row runs unconditionally, as for the
+ // menu below: a face picked in the viewport since the row was selected has moved
+ // m_sel_solid_body, which is the body on_move_body and on_delete_body act on.
+ m_parts->on_action = [this, apply_body_row](int row, int id) {
+ apply_body_row(row);
+ switch (id) {
+ case RowMove: on_move_body(); break;
+ case RowVisibility: on_toggle_visibility(); break;
+ case RowDelete: on_delete_body(); break;
+ }
+ };
// The third door onto the offer, after the viewport right-click and the Menu key. A body ROW
// is an unambiguous body, so the offer reports BodySolid and the body verbs act on the row you
// can see highlighted. That is the confirmation a face pick cannot give: pointing at a face
// lights the face, never the body the verb will actually change. The status line above has
// been promising this right-click since before it existed.
+ m_parts->on_menu = [this, apply_body_row](int row, const wxPoint& screen) {
+ apply_body_row(row); // unconditional — see above
+ // Let the modal menu take the loop after this handler returns — same CallAfter as the
+ // sketch path, which learned it the hard way.
+ CallAfter([this, screen] { show_offer_menu(screen); });
+ };
// Renaming a BODY names the body itself. It does NOT rename the feature that created it:
// an Extrude, a Cut and a Fillet all land on one body, so source_feature is one operation in
// its history and renaming that is renaming the wrong object — reported, correctly, as "you
// consider the extrusion = the body". CadBody::user_name is carried across recompute() by
// index and written into the recipe, so the name outlives both the rebuild and the save.
- m_parts->Bind(wxEVT_TREE_BEGIN_LABEL_EDIT, [this](wxTreeEvent& e) {
- if (tree_body_selection() < 0) { e.Veto(); return; }
- e.Skip();
- });
- m_parts->Bind(wxEVT_TREE_END_LABEL_EDIT, [this](wxTreeEvent& e) {
- if (e.IsEditCancelled()) return;
- const int b = tree_body_selection();
- if (b < 0 || b >= int(m_doc.bodies.size())) { e.Veto(); return; }
- wxString label = e.GetLabel();
- label.Trim(true).Trim(false);
- if (label.empty()) { e.Veto(); return; } // a nameless row is worse than a bad name
+ m_parts->on_rename = [this](int b, const wxString& name) {
+ if (b < 0 || b >= int(m_doc.bodies.size())) return;
m_doc.bodies[b].has_user_name = true;
- m_doc.bodies[b].user_name = std::string(label.ToUTF8().data());
+ m_doc.bodies[b].user_name = std::string(name.ToUTF8().data());
sync_recipe_to_model(); // the name is part of what gets saved
- e.Skip();
- // Rebuild on the NEXT event-loop turn: refresh_parts() destroys every wxTreeItemId and
- // we are inside wx's own END_LABEL_EDIT dispatch for one of them. The feature tree
- // learned this the hard way — doing it here took the process down.
- CallAfter([this] { refresh_parts(); });
- });
-
- m_parts->Bind(wxEVT_TREE_ITEM_MENU, [this, apply_body_row](wxTreeEvent& e) {
- if (e.GetItem().IsOk())
- m_parts->SelectItem(e.GetItem()); // the row under the cursor, never a stale one
- apply_body_row(tree_body_selection()); // unconditional — see above, SelectItem on an
- // already-selected row raises no event
- // GetPoint() is tree-client; it is (-1,-1) when the KEYBOARD menu key raised this, so fall
- // back to the shared anchor rather than popping the menu at a garbage coordinate.
- const wxPoint p = e.GetPoint();
- const wxPoint screen = (p.x >= 0 && p.y >= 0) ? m_parts->ClientToScreen(p) : offer_anchor();
- // Let the modal menu take the loop after this handler returns — same CallAfter as the
- // sketch path, which learned it the hard way.
- CallAfter([this, screen] { show_offer_menu(screen); });
- });
+ refresh_parts();
+ };
// --- Variables (document-scope named expressions) ---
// Below the feature tree + parts, always visible. wxListCtrl in report mode with two
@@ -3413,15 +3326,9 @@ DesignPanel::DesignPanel(wxWindow* parent)
wxALIGN_CENTER_VERTICAL);
var_hdr->AddStretchSpacer();
// Icon actions in the card header, as the Feature tree and Bodies cards have them.
- auto var_btn = [this](const char* icon, const wxString& tip) {
- auto* b = new ScalableButton(m_var_box, wxID_ANY, icon, "", FromDIP(wxSize(24, 24)),
- wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 20);
- b->SetToolTip(tip);
- return b;
- };
- m_btn_add_var = var_btn("add", _L("Add variable"));
- m_btn_edit_var = var_btn("design_edit", _L("Edit variable"));
- m_btn_del_var = var_btn("design_delete", _L("Delete variable"));
+ m_btn_add_var = sidebar_icon_btn(m_var_box, "add", _L("Add variable"));
+ m_btn_edit_var = sidebar_icon_btn(m_var_box, "design_edit", _L("Edit variable"));
+ m_btn_del_var = sidebar_icon_btn(m_var_box, "design_delete", _L("Delete variable"));
m_btn_add_var->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_add_variable(); });
m_btn_edit_var->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_edit_variable(); });
m_btn_del_var->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_remove_variable(); });
@@ -6687,7 +6594,15 @@ void DesignPanel::show_offer_menu(const wxPoint& screen_pos)
}
}
- for (int row = 0; row < kOfferRowCount; ++row) {
+ // Flat rows first, then the families, each group in ratified order. A flat row's verbs sit at
+ // the top level, each an item of its own: they are what a selection is opened for most.
+ std::vector rows;
+ for (const bool flat : {true, false})
+ for (int row = 0; row < kOfferRowCount; ++row)
+ if (kOfferRowFlat[row] == flat) rows.push_back(row);
+ bool flat_items = false;
+
+ for (const int row : rows) {
std::vector live, family;
for (int i = 0; i < kOfferVerbCount; ++i) {
const OfferVerb& v = kOfferVerbs[i];
@@ -6697,6 +6612,34 @@ void DesignPanel::show_offer_menu(const wxPoint& screen_pos)
}
if (family.empty())
continue; // no verb of this family in this mode
+
+ if (kOfferRowFlat[row]) {
+ // Only the verbs about this selection; one blocked by the document stays, greyed,
+ // with its reason, as it would inside a family.
+ for (const OfferVerb* v : family) {
+ if (!(v->accepts & bit)) continue;
+ const int id = base + int(bound.size());
+ if (applies(*v)) {
+ offer_trace("row=%d %s -> %s%s", row, kOfferRowNames[row], v->id,
+ v->action ? "" : " (no GUI route)");
+ append_offer_item(&menu, id, label(*v), *v)->Enable(v->action != nullptr);
+ bound.push_back(v);
+ } else {
+ offer_trace("row=%d %s DISABLED (%s)", row, kOfferRowNames[row],
+ v->refusal ? v->refusal : "blocked");
+ wxString s = tr(v->name);
+ if (v->refusal) s += wxString::FromUTF8(" — ") + tr(v->refusal);
+ append_offer_item(&menu, id, s, *v)->Enable(false);
+ bound.push_back(nullptr);
+ }
+ flat_items = true;
+ }
+ continue;
+ }
+ if (flat_items) {
+ menu.AppendSeparator(); // between the flat items and the families
+ flat_items = false;
+ }
const wxString fam = tr(kOfferRowNames[row]);
if (live.empty()) {
@@ -7170,43 +7113,44 @@ void DesignPanel::on_add_draft()
refresh_tree();
}
-int DesignPanel::tree_icon_for(CadFeatureType t)
+// The Feature tree's per-type row icon, by family.
+const char* DesignPanel::tree_icon_for(CadFeatureType t)
{
switch (t) {
- case CadFeatureType::Sketch: return 0;
- case CadFeatureType::Extrude: return 1;
+ case CadFeatureType::Sketch:
+ case CadFeatureType::Plane:
+ case CadFeatureType::Axis:
+ case CadFeatureType::CoordSys:
+ case CadFeatureType::Project: return "design_sketch"; // sketches, datums, projections
case CadFeatureType::Fillet:
- case CadFeatureType::Chamfer: return 2;
- case CadFeatureType::Hole: return 3;
- case CadFeatureType::Thread: return 4;
- case CadFeatureType::Shell: return 5;
- case CadFeatureType::Revolve: return 1;
- case CadFeatureType::Sweep: return 1;
- case CadFeatureType::Pattern: return 1;
- case CadFeatureType::Plane: return 0; // datum plane: sketch-family icon
- case CadFeatureType::Loft: return 1;
- case CadFeatureType::Draft: return 5; // dressup-family icon
- case CadFeatureType::Import: return 1; // imported solid: solid-family icon
- case CadFeatureType::Boolean: return 1; // body-body combine: solid-family icon
- case CadFeatureType::Cut: return 1; // plane split: solid-family icon
- case CadFeatureType::Axis: return 0; // datum axis: sketch-family icon
- case CadFeatureType::CoordSys: return 0; // datum coord sys: sketch-family icon
- case CadFeatureType::SurfaceExtrude: return 1;
- case CadFeatureType::SurfaceRevolve: return 1;
- case CadFeatureType::SurfaceLoft: return 1;
- case CadFeatureType::SurfaceFill: return 1;
- case CadFeatureType::ThickenSurface: return 1;
- case CadFeatureType::SurfaceOffset: return 1;
- case CadFeatureType::Transform: return 1; // solid-family icon
- case CadFeatureType::Mirror: return 1; // solid-family icon
- case CadFeatureType::Thicken: return 1; // solid-family icon
- case CadFeatureType::Rib: return 1; // solid-family icon
- case CadFeatureType::Project: return 0; // sketch-family icon (produces sketch)
- case CadFeatureType::DeleteFace: return 5; // dressup-family icon
- case CadFeatureType::Helix: return 4; // thread-family icon (curve)
- case CadFeatureType::Mate: return 2; // dressup-family icon (assembly)
+ case CadFeatureType::Chamfer:
+ case CadFeatureType::Mate: return "design_dressup";
+ case CadFeatureType::Hole: return "design_hole";
+ case CadFeatureType::Thread:
+ case CadFeatureType::Helix: return "design_thread";
+ case CadFeatureType::Shell:
+ case CadFeatureType::Draft:
+ case CadFeatureType::DeleteFace: return "design_shell";
+ case CadFeatureType::Extrude:
+ case CadFeatureType::Revolve:
+ case CadFeatureType::Sweep:
+ case CadFeatureType::Loft:
+ case CadFeatureType::Pattern:
+ case CadFeatureType::Import:
+ case CadFeatureType::Boolean:
+ case CadFeatureType::Cut:
+ case CadFeatureType::Transform:
+ case CadFeatureType::Mirror:
+ case CadFeatureType::Thicken:
+ case CadFeatureType::Rib:
+ case CadFeatureType::SurfaceExtrude:
+ case CadFeatureType::SurfaceRevolve:
+ case CadFeatureType::SurfaceLoft:
+ case CadFeatureType::SurfaceFill:
+ case CadFeatureType::ThickenSurface:
+ case CadFeatureType::SurfaceOffset: return "design_extrude"; // solids and surfaces
}
- return 0;
+ return "design_sketch";
}
// The reason detect_mate_conflicts() recorded for this feature, or nullptr. A linear scan: an
@@ -7269,8 +7213,12 @@ void DesignPanel::refresh_icons()
std::function walk = [&walk](wxWindow* w) {
if (auto* b = dynamic_cast(w))
b->msw_rescale();
- else if (auto* b = dynamic_cast<::Button*>(w))
+ else if (auto* b = dynamic_cast<::Button*>(w)) {
b->Rescale();
+ if (b->GetName() == "design_icon_btn")
+ style_sidebar_icon_btn(b); // pixel sizes and the panel colour, for the new scale and theme
+ } else if (auto* l = dynamic_cast(w))
+ l->Rescale();
else if (auto* c = dynamic_cast<::CheckBox*>(w))
c->Rescale();
else if (auto* c = dynamic_cast<::ComboBox*>(w))
@@ -7307,7 +7255,7 @@ void DesignPanel::on_sys_color_changed()
walk(child);
};
walk(this);
- // The native controls (trees, lists, spins) take the app's own dark pass.
+ // The native controls (lists, spins) take the app's own dark pass.
wxGetApp().UpdateDarkUIWin(this);
refresh_icons();
refresh_tree(); // the rows carry their own text colours
@@ -7475,23 +7423,22 @@ void DesignPanel::refresh_tree()
// last feature still clears it, through the tree-edit call site that always did.
if (!m_doc.features.empty()) sync_recipe_to_model();
- // Preserve the selected row across the rebuild — wxTreeCtrl::DeleteAllItems
- // drops the selection, which made every edit/add feel like it "lost" the
- // selection (and broke Edit/Move/Delete on the just-touched feature).
+ // Preserve the selected row across the rebuild — set_rows() drops the selection, which made
+ // every edit/add feel like it "lost" the selection (and broke Edit/Move/Delete on the
+ // just-touched feature).
const int keep = tree_selection();
- m_tree->DeleteAllItems();
- m_tree_items.clear();
- m_tree_body_items.clear();
- wxTreeItemId root = m_tree->AddRoot("root");
// Datum/reference planes carry no solid; feed them to the viewport so they render as
// translucent rectangles (otherwise a Plane feature is invisible in the canvas).
refresh_datum_planes();
update_reference_planes(); // body added/removed -> show/hide the XY/XZ/YZ origin planes
+ std::vector rows;
+ rows.reserve(m_doc.features.size());
for (size_t fi = 0; fi < m_doc.features.size(); ++fi) {
const CadFeature& f = m_doc.features[fi];
- const int img = tree_icon_for(f.type);
- wxTreeItemId id = m_tree->AppendItem(root, wxString::FromUTF8(f.name), img, img);
+ DesignRowList::Row row;
+ row.icon = tree_icon_for(f.type);
+ row.label = wxString::FromUTF8(f.name);
// Three states, in this order of precedence:
// disabled -> dim. A SUPPRESSED mate is the user's answer to a conflict, so it must
// read as suppressed rather than keep shouting about the conflict.
@@ -7502,23 +7449,19 @@ void DesignPanel::refresh_tree()
// A conflict is NOT a document error — the document still evaluates — so the row is
// marked and never hidden, and the reason goes to the status line on selection rather
// than into a modal that interrupts without offering an action.
- m_tree->SetItemTextColour(id, !f.enabled ? dp_item_dim()
- : mate_conflict_reason(int(fi)) != nullptr ? wxColour(235, 110, 110)
- : dp_item_text());
- m_tree_items.push_back(id);
+ row.colour = !f.enabled ? dp_item_dim()
+ : mate_conflict_reason(int(fi)) != nullptr ? wxColour(235, 110, 110)
+ : dp_item_text();
+ row.actions = {
+ { RowEdit, "design_edit", _L("Edit") },
+ eye_action(f.enabled),
+ { RowDelete, "design_delete", _L("Delete") },
+ };
+ rows.push_back(std::move(row));
}
+ m_tree->set_rows(std::move(rows));
refresh_parts(); // bodies live in their own list below the tree, never clipped by history
- if (keep >= 0 && keep < int(m_tree_items.size()))
- m_tree->SelectItem(m_tree_items[keep]);
-
- // Size the tree to its content (clamped) so it doesn't waste a fixed-height block when
- // there are few features, and scrolls internally past ~9 rows instead of growing forever.
- const int rows = int(m_tree_items.size()); // bodies are in their own list now
- const int rowH = std::max(m_tree->GetCharHeight() + 8, 20);
- const int shown = std::min(std::max(rows, 1), 9);
- const wxSize ts(-1, shown * rowH + 8);
- m_tree->SetMinSize(ts);
- m_tree->SetMaxSize(ts);
+ m_tree->select(keep);
if (m_form && m_form->GetSizer()) { update_cards_frame(); m_form->Layout(); m_form->FitInside(); }
}
@@ -7529,11 +7472,9 @@ void DesignPanel::refresh_parts()
if (m_parts == nullptr) return;
const int keep = tree_body_selection();
- m_parts->DeleteAllItems();
- m_tree_body_items.clear();
- wxTreeItemId proot = m_parts->AddRoot("root");
-
sync_body_visible(); // keep flags parallel before reading them for the row colour
+ std::vector rows;
+ rows.reserve(m_doc.bodies.size());
for (size_t b = 0; b < m_doc.bodies.size(); ++b) {
// "Body N" keeps the positional identity every status line and message uses ("Body 2
// selected", the interference report), and the NAME follows it because that is the part
@@ -7547,17 +7488,24 @@ void DesignPanel::refresh_parts()
const wxString bname = m_doc.bodies[b].has_user_name
? wxString::FromUTF8(m_doc.bodies[b].user_name)
: wxString::FromUTF8(m_doc.bodies[b].name);
- wxTreeItemId id = m_parts->AppendItem(proot,
- bname.IsEmpty() ? wxString::Format(_L("Body %zu"), b + 1)
- : wxString::Format(_L("Body %zu — %s"), b + 1, bname));
- // Hidden bodies are greyed so the show/hide state reads at a glance (eye toggle).
- m_parts->SetItemTextColour(id, vis ? dp_item_text() : dp_item_dim());
- m_tree_body_items.push_back(id);
+ DesignRowList::Row row;
+ row.label = bname.IsEmpty() ? wxString::Format(_L("Body %zu"), b + 1)
+ : wxString::Format(_L("Body %zu — %s"), b + 1, bname);
+ row.edit_text = bname; // the name is the body's to edit, not its number
+ // Hidden bodies are greyed and their eye is closed, so the state reads at a glance.
+ row.colour = vis ? dp_item_text() : dp_item_dim();
+ row.actions = {
+ { RowMove, "design_move", _L("Move") },
+ eye_action(vis),
+ { RowDelete, "design_delete", _L("Delete") },
+ };
+ rows.push_back(std::move(row));
}
+ m_parts->set_rows(std::move(rows));
// Hide the whole block until there is something to list, so an empty document doesn't
// show a stray empty box.
- const bool any = !m_tree_body_items.empty();
+ const bool any = m_parts->GetItemCount() > 0;
m_parts->Show(any);
if (m_parts_label) m_parts_label->Show(any);
if (m_parts_hdr) m_parts_hdr->ShowItems(any); // icon + title live in this sizer
@@ -7565,26 +7513,14 @@ void DesignPanel::refresh_parts()
// ...and the frame with it, or an empty bordered box floats there.
if (m_parts_box && m_form && m_form->GetSizer()) m_form->GetSizer()->Show(m_parts_box, any, false);
- if (any) {
- const int rowH = std::max(m_parts->GetCharHeight() + 8, 20);
- const int shown = std::min(int(m_tree_body_items.size()), 6); // scrolls past 6
- const wxSize ps(-1, shown * rowH + 8);
- m_parts->SetMinSize(ps);
- m_parts->SetMaxSize(ps);
- if (keep >= 0 && keep < int(m_tree_body_items.size()))
- m_parts->SelectItem(m_tree_body_items[keep]);
- }
+ if (any)
+ m_parts->select(keep);
if (m_form && m_form->GetSizer()) { update_cards_frame(); m_form->Layout(); m_form->FitInside(); }
}
int DesignPanel::tree_body_selection() const
{
- if (m_parts == nullptr) return -1;
- const wxTreeItemId sel = m_parts->GetSelection();
- if (!sel.IsOk()) return -1;
- for (size_t i = 0; i < m_tree_body_items.size(); ++i)
- if (m_tree_body_items[i] == sel) return int(i);
- return -1;
+ return m_parts == nullptr ? -1 : m_parts->selection();
}
void DesignPanel::update_section_flip_btn()
@@ -7824,17 +7760,13 @@ bool DesignPanel::place_on_face()
int DesignPanel::tree_selection() const
{
- const wxTreeItemId sel = m_tree->GetSelection();
- if (!sel.IsOk()) return wxNOT_FOUND;
- for (size_t i = 0; i < m_tree_items.size(); ++i)
- if (m_tree_items[i] == sel) return int(i);
- return wxNOT_FOUND;
+ return m_tree->selection();
}
void DesignPanel::set_tree_selection(int row)
{
- if (row >= 0 && row < int(m_tree_items.size()))
- m_tree->SelectItem(m_tree_items[row]);
+ if (row >= 0 && row < int(m_tree->GetItemCount()))
+ m_tree->select(row);
}
// The selection (the solid pick, the hit face and the committed-loop pick) names bodies, faces and
@@ -8004,12 +7936,11 @@ void DesignPanel::on_toggle_visibility()
&m_body_visible, &m_body_xform);
}
refresh_tree();
- // Keep the row selected for repeat toggles. m_parts, NOT m_tree: these ids belong
- // to the Bodies list, and handing a foreign item to the feature tree left the row
- // unselected — so the second press of the eye found tree_body_selection() == -1 and
- // fell through to the FEATURE-level branch below instead of un-hiding the body.
- if (m_parts != nullptr && bsel < int(m_tree_body_items.size()))
- m_parts->SelectItem(m_tree_body_items[bsel]);
+ // Keep the row selected for repeat toggles. m_parts, NOT m_tree: the row is a body,
+ // and selecting it in the feature tree left the body row unselected — so the second
+ // press of the eye found tree_body_selection() == -1 and fell through to the
+ // FEATURE-level branch below instead of un-hiding the body.
+ if (m_parts != nullptr) m_parts->select(bsel);
set_status(StatusKind::Info, wxString::Format(now_visible ? _L("Body %d shown")
: _L("Body %d hidden"), bsel + 1));
}
@@ -8412,11 +8343,11 @@ void DesignPanel::rebuild_constraint_list()
for (int i = 0; i < int(cons.size()); ++i) {
auto* row = new wxBoxSizer(wxHORIZONTAL);
// Delete button first (fixed left position, always visible — long labels can
- // horizontally scroll but ✗ stays put and clickable). BMP-safe ✗ glyph.
- auto* del = new ScalableButton(m_cards, wxID_ANY, "design_delete", "", FromDIP(wxSize(24, 24)),
- wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 16);
- del->SetToolTip(_L("Delete constraint"));
- del->Bind(wxEVT_BUTTON, [this, i](wxCommandEvent&) { delete_constraint(i); });
+ // horizontally scroll but ✗ stays put and clickable).
+ auto* del = sidebar_icon_btn(m_cards, "design_delete", _L("Delete constraint"), 16);
+ // After the click: deleting rebuilds these rows, and with them this button, which must
+ // not be destroyed while its own click is still being dispatched.
+ del->Bind(wxEVT_BUTTON, [this, i](wxCommandEvent&) { CallAfter([this, i] { delete_constraint(i); }); });
// Clickable label: selecting it highlights the referenced entities.
auto* lbl = new wxStaticText(m_cards, wxID_ANY, constraint_label(cons[i]));
lbl->SetCursor(wxCursor(wxCURSOR_HAND));
@@ -8467,8 +8398,8 @@ void DesignPanel::delete_constraint(int idx)
{
// Live session: the constraint lives in the sketch tool, not in any feature. Removing it
// re-solves and fires on_constraints_changed, which rebuilds these rows — so this branch
- // deliberately does NOT call rebuild_constraint_list() itself (it would run twice, and the
- // second run would delete the wxButton whose click handler is still on the stack).
+ // deliberately does NOT call rebuild_constraint_list() itself (it would run twice). A row's ✗
+ // calls this after its click has finished, so either rebuild may destroy that button.
if (live_constraint_scope()) {
if (!m_viewport->remove_sketch_constraint(idx))
return;
diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp
index 1235c98ac3..86fde6254e 100644
--- a/src/slic3r/GUI/CAD/DesignPanel.hpp
+++ b/src/slic3r/GUI/CAD/DesignPanel.hpp
@@ -12,7 +12,6 @@
#include
#include
#include
-#include // wxTreeItemId
#include
#include
@@ -30,8 +29,6 @@ class wxCheckBox;
class wxCheckListBox;
class wxSpinCtrl;
class wxSpinCtrlDouble;
-class wxTreeCtrl;
-class wxImageList;
class wxStaticText;
class wxStaticLine;
class Button; // Orca-styled button (Widgets/Button.hpp)
@@ -53,6 +50,7 @@ class ScalableButton;
namespace Slic3r { namespace GUI {
class DesignTextDialog;
+class DesignRowList;
class DesignCanvas;
@@ -539,8 +537,8 @@ private:
void set_active_tool_btn(ScalableButton* b); // nullptr clears the highlight
// Owns the themed DropDown flyouts (and the item vectors they hold by ref).
std::vector> m_flyout_keepalive;
- // Icons that are not a plain ScalableButton face (flyout rows, card headers, the tree's image
- // list, theme-twinned buttons): each re-creates its bitmaps for the current scale and theme.
+ // Icons that are not a plain ScalableButton face (flyout rows, card headers, theme-twinned
+ // buttons): each re-creates its bitmaps for the current scale and theme.
std::vector> m_icon_refresh;
void refresh_icons();
wxCheckBox* m_construction{nullptr}; // sketch-mode construction toggle
@@ -711,16 +709,16 @@ private:
// Document variables panel (below the feature tree / parts)
StaticBox* m_var_box{nullptr};
wxListCtrl* m_var_list{nullptr};
- ScalableButton* m_btn_add_var{nullptr};
- ScalableButton* m_btn_edit_var{nullptr};
- ScalableButton* m_btn_del_var{nullptr};
+ ::Button* m_btn_add_var{nullptr};
+ ::Button* m_btn_edit_var{nullptr};
+ ::Button* m_btn_del_var{nullptr};
void refresh_variables(); // rebuild m_var_list from m_doc.variables
void on_add_variable();
void on_edit_variable();
void on_remove_variable();
// Feature-tree button
- ScalableButton* m_btn_interfere{nullptr};
+ ::Button* m_btn_interfere{nullptr};
// Pattern controls (replicate the target body: linear or circular).
ComboBox* m_pattern_type{nullptr}; // 0 = Linear, 1 = Circular
@@ -903,21 +901,18 @@ private:
std::function m_value_cont; // deferred apply, run on Confirm
std::function m_value_cancel; // optional action when the card is cancelled
- // Feature tree: a wxTreeCtrl with per-feature-type icons. Callers keep using
- // integer row indices via tree_selection()/set_tree_selection(); m_tree_items
- // maps feature order -> tree node, rebuilt by refresh_tree().
- wxTreeCtrl* m_tree{nullptr};
- wxTreeCtrl* m_parts{nullptr}; // Bodies list under the feature tree
+ // Feature tree: one row per feature, in feature order, with a per-type icon and the row's
+ // own Edit / Show-hide / Delete icons. Callers use row indices via
+ // tree_selection()/set_tree_selection(); refresh_tree() rebuilds the rows.
+ DesignRowList* m_tree{nullptr};
+ // Bodies list under the feature tree: one row per body (parallel to m_doc.bodies). Selecting
+ // one highlights that body and makes it the target for the next op.
+ DesignRowList* m_parts{nullptr};
wxStaticText* m_parts_label{nullptr}; // its "Bodies" caption (hidden when empty)
- wxBoxSizer* m_parts_hdr{nullptr}; // Bodies card header (icon + title)
+ wxBoxSizer* m_parts_hdr{nullptr}; // Bodies card header (icon + title + body actions)
wxStaticLine* m_parts_rule{nullptr}; // rule under that header
- wxBoxSizer* m_hdr_tree_row{nullptr}; // Feature tree header: title + row actions
+ wxBoxSizer* m_hdr_tree_row{nullptr}; // Feature tree header: title + list actions
wxStaticText* m_hdr_tree{nullptr}; // its title label
- wxImageList* m_tree_images{nullptr};
- std::vector m_tree_items;
- // Parts list: tree rows for each body (parallel to m_doc.bodies). Selecting one
- // highlights that body and makes it the target for the next op.
- std::vector m_tree_body_items;
// Section views (non-destructive): named "Section View N" entries listed in the tree, each a
// horizontal clip height. View-only — NOT bodies/features, never serialized. Key X adds one;
@@ -953,7 +948,7 @@ private:
int tree_body_selection() const; // selected Parts-list body index, or -1
void refresh_parts(); // rebuild the Bodies list under the feature tree
void set_tree_selection(int row);
- static int tree_icon_for(CadFeatureType t);
+ static const char* tree_icon_for(CadFeatureType t);
wxStaticText* m_status{nullptr};
// m_status's foreground as created, captured before any caller touches it. Callers signal
diff --git a/src/slic3r/GUI/CAD/DesignRowList.cpp b/src/slic3r/GUI/CAD/DesignRowList.cpp
new file mode 100644
index 0000000000..55e09f5294
--- /dev/null
+++ b/src/slic3r/GUI/CAD/DesignRowList.cpp
@@ -0,0 +1,377 @@
+#include "slic3r/GUI/CAD/DesignRowList.hpp"
+
+#include "slic3r/GUI/Widgets/StateColor.hpp"
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+namespace Slic3r { namespace GUI {
+
+// Row and icon cell geometry, in DIP.
+static constexpr int kIconPx = 16; // type and action icons
+static constexpr int kCellDip = 20; // square action cell, hover chip included
+static constexpr int kPadDip = 4; // row edges, and the gap before the action cells
+static constexpr int kGapDip = 6; // type icon to label
+
+DesignRowList::DesignRowList(wxWindow* parent, int max_visible)
+ // wxVListBox defaults to wxBORDER_THEME; the sidebar's lists take a simple frame.
+ : wxVListBox(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_SIMPLE)
+ , m_max_visible(std::max(max_visible, 1))
+{
+ Bind(wxEVT_LISTBOX, [this](wxCommandEvent&) { if (on_select) on_select(); });
+ Bind(wxEVT_LISTBOX_DCLICK, [this](wxCommandEvent&) { if (on_activate) on_activate(); });
+
+ Bind(wxEVT_MOTION, [this](wxMouseEvent& e) {
+ set_hover(hit_test(e.GetPosition()));
+ e.Skip();
+ });
+ Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent& e) {
+ set_hover(Hit{});
+ m_pressed = Hit{};
+ e.Skip();
+ });
+ // A press on an action cell is remembered and skipped, so the list still selects the row.
+ Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& e) {
+ const Hit h = hit_test(e.GetPosition());
+ m_pressed = h.cell >= 0 ? h : Hit{};
+ e.Skip();
+ });
+ // The second press of a double-click on a cell is a press too, and is not skipped: the list
+ // would otherwise turn it into a row double-click, so a quick double toggle of the eye would
+ // also open the feature for editing.
+ Bind(wxEVT_LEFT_DCLICK, [this](wxMouseEvent& e) {
+ const Hit h = hit_test(e.GetPosition());
+ if (h.cell < 0) { m_pressed = Hit{}; e.Skip(); return; }
+ m_pressed = h;
+ });
+ Bind(wxEVT_LEFT_UP, [this](wxMouseEvent& e) {
+ e.Skip();
+ const Hit pressed = m_pressed;
+ m_pressed = Hit{};
+ if (pressed.cell < 0 || hit_test(e.GetPosition()) != pressed)
+ return;
+ const int id = m_rows[pressed.row].actions[pressed.cell].id;
+ // After the click has finished dispatching: the action may rebuild these rows. The row
+ // must still exist and still be the selected one, or the click is dropped rather than
+ // applied to whatever row took its place.
+ CallAfter([this, row = pressed.row, id] {
+ if (row < int(GetItemCount()) && row == GetSelection() && on_action)
+ on_action(row, id);
+ });
+ });
+ // Right-click targets the row under the pointer. Skipped, because on MSW a handled right
+ // press suppresses the wxEVT_CONTEXT_MENU that follows it.
+ Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& e) {
+ const Hit h = hit_test(e.GetPosition());
+ if (h.row != wxNOT_FOUND) select(h.row);
+ e.Skip();
+ });
+ Bind(wxEVT_CONTEXT_MENU, [this](wxContextMenuEvent& e) {
+ wxPoint screen = e.GetPosition(); // screen coordinates; wxDefaultPosition from the keyboard
+ int row = GetSelection();
+ if (screen == wxDefaultPosition) {
+ if (row == wxNOT_FOUND) return;
+ screen = ClientToScreen(GetItemRect(row).GetBottomLeft());
+ } else {
+ row = VirtualHitTest(ScreenToClient(screen).y);
+ }
+ if (row == wxNOT_FOUND || row >= int(m_rows.size()))
+ return;
+ // On MSW the menu comes with the button's release, and the pointer may have moved to
+ // another row since the press selected one: the menu is for the row it opens over.
+ select(row);
+ if (on_menu) on_menu(row, screen);
+ });
+ // wxVListBox takes every wheel event, even with nothing to scroll, so a short list would stop
+ // the sidebar it sits in from scrolling under the pointer. Hand it on instead. Not on GTK,
+ // where the sidebar scrolls natively and passes on an unhandled wheel by itself.
+ Bind(wxEVT_MOUSEWHEEL, [this](wxMouseEvent& e) {
+ end_rename(true);
+#ifndef __WXGTK__
+ if (GetVisibleRowsBegin() == 0 && GetVisibleRowsEnd() >= GetItemCount()) {
+ for (wxWindow* w = GetParent(); w != nullptr; w = w->GetParent())
+ if (auto* sw = dynamic_cast(w)) {
+ wxMouseEvent fwd(e);
+ fwd.SetEventObject(sw);
+ sw->GetEventHandler()->ProcessEvent(fwd);
+ return;
+ }
+ }
+#endif
+ e.Skip();
+ });
+ Bind(wxEVT_SIZE, [this](wxSizeEvent& e) {
+ end_rename(true); // the editor sits on a row position the new size may have moved
+ e.Skip();
+ });
+ measure_row();
+ fit_rows();
+}
+
+// Every row is one height. Measured once per font and scale rather than on each OnMeasureItem,
+// which wxVListBox calls for every row it walks on every paint and hit test.
+void DesignRowList::measure_row()
+{
+ m_row_h = std::max(GetCharHeight() + 8, FromDIP(kCellDip + 2));
+}
+
+void DesignRowList::set_rows(std::vector rows)
+{
+ end_rename(false); // the row it names may no longer be that row
+ SetSelection(wxNOT_FOUND);
+ m_rows = std::move(rows);
+ m_hover = Hit{};
+ m_pressed = Hit{};
+ UnsetToolTip();
+ load_icons();
+ SetItemCount(m_rows.size());
+ fit_rows();
+ Refresh();
+}
+
+void DesignRowList::select(int row)
+{
+ if (row < 0 || row >= int(GetItemCount()))
+ row = wxNOT_FOUND;
+ if (row == GetSelection())
+ return;
+ SetSelection(row);
+ if (on_select) on_select();
+}
+
+void DesignRowList::fit_rows()
+{
+ const int shown = std::clamp(int(GetItemCount()), 1, m_max_visible);
+ // Plus the frame, so the last row is not clipped by it.
+ const int h = shown * m_row_h + (GetSize().y - GetClientSize().y);
+ SetMinSize(wxSize(-1, h));
+ SetMaxSize(wxSize(-1, h));
+}
+
+void DesignRowList::Rescale()
+{
+ m_icons.clear();
+ load_icons();
+ measure_row();
+ // The scrollbar's total is only recomputed when the item count is set; RefreshAll() alone
+ // would leave it at the old scale.
+ SetItemCount(GetItemCount());
+ fit_rows();
+ Refresh();
+}
+
+void DesignRowList::begin_rename(int row)
+{
+ CallAfter([this, row] { open_editor(row); });
+}
+
+void DesignRowList::open_editor(int row)
+{
+ if (row < 0 || row >= int(m_rows.size()))
+ return;
+ end_rename(false);
+ if (m_editor == nullptr) {
+ // A raw wxTextCtrl, as wxTreeCtrl's own label editor is: it is a transient overlay on one
+ // row, not a form field, and ::TextInput's frame would not fit inside the row.
+ m_editor = new wxTextCtrl(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize,
+ wxTE_PROCESS_ENTER | wxBORDER_SIMPLE);
+ m_editor->Hide();
+ m_editor->Bind(wxEVT_TEXT_ENTER, [this](wxCommandEvent&) {
+ end_rename(true);
+ SetFocus();
+ });
+ // Esc must stop here. CHAR_HOOK reaches the focused editor before its ancestors, and the
+ // Design panel's own hook answers Esc whatever holds focus, so a skipped Esc would close
+ // the open tool as well as the editor.
+ m_editor->Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& e) {
+ if (e.GetKeyCode() != WXK_ESCAPE) { e.Skip(); return; }
+ end_rename(false);
+ SetFocus();
+ });
+ m_editor->Bind(wxEVT_KILL_FOCUS, [this](wxFocusEvent& e) {
+ e.Skip();
+ end_rename(true);
+ });
+ }
+ if (!IsRowVisible(row))
+ ScrollToRow(row);
+ const Row& r = m_rows[row];
+ const wxRect rc = label_rect(GetItemRect(row), r);
+ m_editor->SetFont(GetFont());
+ m_editor->SetBackgroundColour(GetBackgroundColour());
+ m_editor->SetForegroundColour(r.colour.IsOk() ? r.colour : GetForegroundColour());
+ m_editor->SetValue(r.edit_text.empty() ? r.label : r.edit_text);
+ const int h = std::max(rc.height, m_editor->GetBestSize().y);
+ const int y = std::clamp(rc.y + (rc.height - h) / 2, 0, std::max(0, GetClientSize().y - h));
+ m_editor->SetSize(rc.x - FromDIP(2), y, rc.width + FromDIP(2), h);
+ m_edit_row = row;
+ m_editor->Show();
+ m_editor->SetFocus();
+ m_editor->SelectAll();
+}
+
+void DesignRowList::end_rename(bool commit)
+{
+ if (m_edit_row == wxNOT_FOUND)
+ return;
+ // Cleared first: hiding the focused editor moves the focus, whose kill-focus comes back here.
+ const int row = m_edit_row;
+ wxString text = m_editor->GetValue();
+ m_edit_row = wxNOT_FOUND;
+ m_editor->Hide();
+ text.Trim(true).Trim(false);
+ if (commit && on_rename && !text.empty()) // a nameless row is worse than a badly named one
+ CallAfter([this, row, text] {
+ if (on_rename && row < int(GetItemCount()))
+ on_rename(row, text);
+ });
+}
+
+wxCoord DesignRowList::OnMeasureItem(size_t) const
+{
+ return m_row_h;
+}
+
+void DesignRowList::OnDrawBackground(wxDC& dc, const wxRect& rect, size_t n) const
+{
+ // ObjectList's selection teal, and the dropdowns' lighter hover teal; both are dark-mapped.
+ wxColour bg;
+ if (IsSelected(n))
+ bg = StateColor::darkModeColorFor(wxColour("#BFE1DE"));
+ else if (int(n) == m_hover.row)
+ bg = StateColor::darkModeColorFor(wxColour("#E5F0EE"));
+ if (!bg.IsOk())
+ return; // the list already cleared to its background colour
+ dc.SetPen(*wxTRANSPARENT_PEN);
+ dc.SetBrush(wxBrush(bg));
+ dc.DrawRectangle(rect);
+}
+
+void DesignRowList::OnDrawItem(wxDC& dc, const wxRect& rect, size_t n) const
+{
+ if (n >= m_rows.size())
+ return;
+ const Row& r = m_rows[n];
+
+ if (const ScalableBitmap* bmp = icon(r.icon)) {
+ const wxSize sz = bmp->GetBmpSize();
+ dc.DrawBitmap(bmp->bmp(), rect.x + FromDIP(kPadDip), rect.y + (rect.height - sz.y) / 2, true);
+ }
+
+ const wxRect lr = label_rect(rect, r);
+ dc.SetFont(GetFont());
+ dc.SetTextForeground(r.colour.IsOk() ? r.colour : GetForegroundColour());
+ const wxString text = wxControl::Ellipsize(r.label, dc, wxELLIPSIZE_END, std::max(lr.width, 0));
+ dc.DrawText(text, lr.x, lr.y + (lr.height - dc.GetCharHeight()) / 2);
+
+ for (size_t i = 0; i < r.actions.size(); ++i) {
+ const wxRect cell = cell_rect(rect, r.actions.size(), i);
+ if (m_hover.row == int(n) && m_hover.cell == int(i)) {
+ // Drawn through a graphics context so the rounded corners are anti-aliased on MSW too,
+ // and released before the icon goes on top.
+ const wxColour chip = StateColor::darkModeColorFor(wxColour(hover_chip));
+ const double rad = FromDIP(4);
+ std::unique_ptr gc(wxGraphicsContext::CreateFromUnknownDC(dc));
+ if (gc) {
+ gc->SetPen(*wxTRANSPARENT_PEN);
+ gc->SetBrush(wxBrush(chip));
+ gc->DrawRoundedRectangle(cell.x, cell.y, cell.width, cell.height, rad);
+ } else {
+ dc.SetPen(*wxTRANSPARENT_PEN);
+ dc.SetBrush(wxBrush(chip));
+ dc.DrawRoundedRectangle(cell, rad);
+ }
+ }
+ if (const ScalableBitmap* bmp = icon(r.actions[i].icon)) {
+ const wxSize sz = bmp->GetBmpSize();
+ dc.DrawBitmap(bmp->bmp(), cell.x + (cell.width - sz.x) / 2, cell.y + (cell.height - sz.y) / 2, true);
+ }
+ }
+}
+
+DesignRowList::Hit DesignRowList::hit_test(const wxPoint& pt) const
+{
+ Hit h;
+ h.row = VirtualHitTest(pt.y);
+ if (h.row == wxNOT_FOUND || h.row >= int(m_rows.size()))
+ return Hit{};
+ const wxRect rect = GetItemRect(h.row);
+ const auto& acts = m_rows[h.row].actions;
+ for (size_t i = 0; i < acts.size(); ++i)
+ if (cell_rect(rect, acts.size(), i).Contains(pt)) {
+ h.cell = int(i);
+ break;
+ }
+ return h;
+}
+
+wxRect DesignRowList::cell_rect(const wxRect& row, size_t count, size_t i) const
+{
+ const int side = FromDIP(kCellDip);
+ const int x = row.GetRight() + 1 - FromDIP(kPadDip) - int(count - i) * side;
+ return wxRect(x, row.y + (row.height - side) / 2, side, side);
+}
+
+wxRect DesignRowList::label_rect(const wxRect& row, const Row& r) const
+{
+ int left = row.x + FromDIP(kPadDip);
+ if (const ScalableBitmap* bmp = icon(r.icon))
+ left += bmp->GetBmpSize().x + FromDIP(kGapDip);
+ const int right = r.actions.empty() ? row.GetRight() + 1 - FromDIP(kPadDip)
+ : cell_rect(row, r.actions.size(), 0).x - FromDIP(kPadDip);
+ return wxRect(left, row.y, std::max(right - left, 0), row.height);
+}
+
+// nullptr for no icon: load_icons() never stores an empty name.
+const ScalableBitmap* DesignRowList::icon(const std::string& name) const
+{
+ const auto it = m_icons.find(name);
+ return it == m_icons.end() ? nullptr : &it->second;
+}
+
+void DesignRowList::load_icons()
+{
+ auto load = [this](const std::string& name) {
+ if (!name.empty() && m_icons.find(name) == m_icons.end())
+ m_icons.emplace(name, ScalableBitmap(this, name, kIconPx));
+ };
+ for (const Row& r : m_rows) {
+ load(r.icon);
+ for (const Action& a : r.actions)
+ load(a.icon);
+ }
+}
+
+void DesignRowList::set_hover(const Hit& h)
+{
+ if (h == m_hover)
+ return;
+ const int old = m_hover.row;
+ m_hover = h;
+ if (old != wxNOT_FOUND && old < int(GetItemCount()))
+ RefreshRow(old);
+ if (h.row != wxNOT_FOUND && h.row != old)
+ RefreshRow(h.row);
+
+ // The tip names the icon under the pointer, or the whole label where the row cuts it short.
+ wxString tip;
+ if (h.row != wxNOT_FOUND) {
+ const Row& r = m_rows[h.row];
+ if (h.cell >= 0)
+ tip = r.actions[h.cell].tip;
+ else if (GetTextExtent(r.label).x > label_rect(GetItemRect(h.row), r).width)
+ tip = r.label;
+ }
+ if (tip.empty())
+ UnsetToolTip();
+ else if (tip != GetToolTipText())
+ SetToolTip(tip);
+}
+
+}} // namespace Slic3r::GUI
diff --git a/src/slic3r/GUI/CAD/DesignRowList.hpp b/src/slic3r/GUI/CAD/DesignRowList.hpp
new file mode 100644
index 0000000000..ce00f75fa2
--- /dev/null
+++ b/src/slic3r/GUI/CAD/DesignRowList.hpp
@@ -0,0 +1,108 @@
+#ifndef slic3r_DesignRowList_hpp_
+#define slic3r_DesignRowList_hpp_
+
+#include
+
+#include
+#include