Show feature and body actions on their rows in the Design sidebar

Each Feature tree row now carries Edit, Show/hide and Delete, and each
Bodies row carries Move, Show/hide and Delete. These act on that row
instead of on the selection. The eye shows a closed eye when the item
is hidden. Every clickable icon in the sidebar now highlights on hover.

Rename and Color now come first in the right-click menu, as items of
their own instead of inside Modify. This applies wherever they appear,
whether opened from the viewport or from a Bodies row.

Clicking an already-selected row no longer starts a rename; use F2,
the row's right-click menu, or the Rename command.
This commit is contained in:
SoftFever
2026-10-04 21:27:12 +08:00
parent 1d06b8576b
commit 1f91fdf637
10 changed files with 824 additions and 369 deletions
+14 -2
View File
@@ -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 |
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#949494" stroke-width="0.85" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8.5C5 14.5 19 14.5 22 8.5"/><path d="M12 13v3M5.97 11.9l-1.4 2.5M18.03 11.9l1.4 2.5M3.2 10.1l-1.8 1.8M20.8 10.1l1.8 1.8"/></svg>

After

Width:  |  Height:  |  Size: 320 B

+11 -2
View File
@@ -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[] = {",
]
+8 -2
View File
@@ -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,
+2
View File
@@ -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
+18 -4
View File
@@ -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")},
+268 -337
View File
@@ -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 <wx/spinctrl.h>
#include <wx/listctrl.h>
#include <wx/string.h>
#include <wx/treebase.h>
#include <wx/tglbtn.h>
#include <wx/textctrl.h>
#include <wx/translation.h>
#include <wx/treectrl.h>
#include <wx/imaglist.h>
#include <wx/statline.h>
#include <wx/statbmp.h>
#include <wx/image.h>
@@ -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, int>(wxColour(DesignRowList::hover_chip), StateColor::Hovered), // dark-mapped at paint
std::pair<wxColour, int>(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<wxBitmap> 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<int> 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<const OfferVerb*> 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<void(wxWindow*)> walk = [&walk](wxWindow* w) {
if (auto* b = dynamic_cast<ScalableButton*>(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<DesignRowList*>(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<DesignRowList::Row> 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<DesignRowList::Row> 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;
+17 -22
View File
@@ -12,7 +12,6 @@
#include <wx/panel.h>
#include <wx/scrolwin.h>
#include <wx/string.h>
#include <wx/treebase.h> // wxTreeItemId
#include <vector>
#include <memory>
@@ -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<std::shared_ptr<void>> 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<std::function<void()>> 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<void(double)> m_value_cont; // deferred apply, run on Confirm
std::function<void()> 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<wxTreeItemId> 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<wxTreeItemId> 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
+377
View File
@@ -0,0 +1,377 @@
#include "slic3r/GUI/CAD/DesignRowList.hpp"
#include "slic3r/GUI/Widgets/StateColor.hpp"
#include <wx/control.h>
#include <wx/dc.h>
#include <wx/graphics.h>
#include <wx/scrolwin.h>
#include <wx/textctrl.h>
#include <algorithm>
#include <memory>
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<wxScrolledWindow*>(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<Row> 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<wxGraphicsContext> 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
+108
View File
@@ -0,0 +1,108 @@
#ifndef slic3r_DesignRowList_hpp_
#define slic3r_DesignRowList_hpp_
#include <wx/vlbox.h>
#include <functional>
#include <map>
#include <string>
#include <vector>
#include "slic3r/GUI/wxExtensions.hpp" // ScalableBitmap
class wxTextCtrl;
namespace Slic3r { namespace GUI {
// The Design tab's Feature tree and Bodies lists: a single-selection list whose rows carry their
// own action icons. A wxTreeCtrl cannot host a clickable icon per row (it is native on MSW), so
// this list draws its own: each row draws its actions at its right end, highlights the one under
// the pointer, and takes its icons from the owner, so the eye can show whether the row is hidden.
//
// Selection follows wxTreeCtrl's contract, which DesignPanel's mutual exclusion between the two
// lists relies on: on_select runs on every change, whether the user or select() made it, and
// selecting the row that is already selected changes nothing and notifies nobody.
class DesignRowList : public wxVListBox
{
public:
struct Action {
int id; // the owner's code for it, handed back to on_action
std::string icon; // icon name, e.g. "design_eye"
wxString tip;
};
struct Row {
std::string icon; // type icon at the left; empty for none
wxString label;
wxString edit_text; // what the rename editor opens with; empty for the label
wxColour colour; // label colour
std::vector<Action> actions; // icon cells at the right end, in display order
};
// The hover chip behind an action icon, a light colour dark-mapped at paint. The sidebar's
// icon buttons hover in it too.
static constexpr const char* hover_chip = "#D4D4D4";
// The list is as tall as its rows, at least one and at most `max_visible`; past that it scrolls.
DesignRowList(wxWindow* parent, int max_visible);
// Replace every row. Clears the selection without notifying and cancels a rename in progress;
// the owner re-selects the row it keeps, which notifies.
void set_rows(std::vector<Row> rows);
int selection() const { return GetSelection(); }
// Select `row` (wxNOT_FOUND clears) and call on_select if that changed the selection.
void select(int row);
void unselect() { select(wxNOT_FOUND); }
// Open the in-place editor on a row, once the current event has finished: opened from inside
// a popup menu's nested loop it would never appear. Enter or clicking away commits, Esc cancels.
void begin_rename(int row);
// Re-rasterise the icons and re-measure the rows after a DPI or theme change.
void Rescale();
std::function<void()> on_select; // the selection changed
std::function<void()> on_activate; // the selected row was double-clicked
// A row's action icon was clicked. Runs after the click has finished dispatching, and only
// while that row is still the selected one; the click itself selected it.
std::function<void(int row, int id)> on_action;
// Context menu on a row, at a screen position; the row under the pointer is selected first.
std::function<void(int row, const wxPoint& screen)> on_menu;
// The editor committed `name` for `row`, trimmed and never empty (an empty commit cancels).
// Runs after the editor's own events have finished, so the owner may rebuild the rows here.
std::function<void(int row, const wxString& name)> on_rename;
protected:
void OnDrawItem(wxDC& dc, const wxRect& rect, size_t n) const override;
void OnDrawBackground(wxDC& dc, const wxRect& rect, size_t n) const override;
wxCoord OnMeasureItem(size_t n) const override;
private:
struct Hit {
int row{wxNOT_FOUND};
int cell{-1}; // index into the row's actions, -1 for the rest of the row
bool operator==(const Hit& o) const { return row == o.row && cell == o.cell; }
bool operator!=(const Hit& o) const { return !(*this == o); }
};
Hit hit_test(const wxPoint& pt) const;
wxRect cell_rect(const wxRect& row, size_t count, size_t i) const;
wxRect label_rect(const wxRect& row, const Row& r) const;
const ScalableBitmap* icon(const std::string& name) const;
void load_icons();
void set_hover(const Hit& h);
void open_editor(int row);
void end_rename(bool commit);
void measure_row();
void fit_rows();
std::vector<Row> m_rows;
std::map<std::string, ScalableBitmap> m_icons;
int m_max_visible;
int m_row_h{0}; // every row's height, from measure_row()
Hit m_hover;
Hit m_pressed; // action cell under the last left press
wxTextCtrl* m_editor{nullptr}; // created on the first rename, then reused
int m_edit_row{wxNOT_FOUND};
};
}} // namespace Slic3r::GUI
#endif // slic3r_DesignRowList_hpp_