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 +#include +#include + +#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 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 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 on_select; // the selection changed + std::function 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 on_action; + // Context menu on a row, at a screen position; the row under the pointer is selected first. + std::function 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 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 m_rows; + std::map 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_