diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index 159848f38b..613e934dce 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -1211,6 +1211,27 @@ bool DesignCanvas::inline_busy() const return m_sketch_tool.inline_busy(); } +bool DesignCanvas::inline_has_focus() const +{ + return m_inline_editor && m_inline_editor->has_focus(); +} + +void DesignCanvas::inline_commit() +{ + if (m_inline_editor) m_inline_editor->commit(); +} + +void DesignCanvas::inline_cancel() +{ + if (m_inline_editor) m_inline_editor->cancel(); +} + +void DesignCanvas::request_sketch_exit() +{ + m_sketch_tool.request_exit(); + request_repaint(); +} + bool DesignCanvas::live_sketch_has_work() const { return m_sketch_tool.live_sketch_has_work(); diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp index 17d601a415..aa14b36b82 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.hpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -224,6 +224,13 @@ public: void set_on_context_menu(std::function cb); void delete_selected_sketch_entities(); bool inline_busy() const; // a sketch value field is open (guard keys) + bool inline_has_focus() const; // the field itself holds keyboard focus + void inline_commit(); // accept the typed value (Enter/Tab) + void inline_cancel(); // discard the typed value (Esc) + // The layered Esc: abandon the points of the gesture in progress, else drop the armed tool + // back to Select, else leave the sketch. Same call GLCanvas3D::on_char makes, exposed so the + // panel can do it when focus is not on the canvas. + void request_sketch_exit(); bool live_sketch_has_work() const; // the live sketch holds entities a cancel would destroy bool undo_last_sketch_entity(); // Ctrl+Z in a sketch: drop the last entity bool delete_selected_or_last_sketch_entity(); // Delete in a sketch: selected, else last diff --git a/src/slic3r/GUI/CAD/DesignOffer.hpp b/src/slic3r/GUI/CAD/DesignOffer.hpp index 2c3038b183..de13eb6752 100644 --- a/src/slic3r/GUI/CAD/DesignOffer.hpp +++ b/src/slic3r/GUI/CAD/DesignOffer.hpp @@ -121,6 +121,7 @@ static const OfferVerb kOfferVerbs[] = { {"mass_props", "Mass", 6, nullptr, "btn:mass", nullptr, 0x000000feu, 1, 0, false, false, nullptr, "info", "Report the volume and surface area of the selected body"}, {"interference", "Interference", 6, nullptr, nullptr, nullptr, 0x00000200u, 2, 0, false, false, nullptr, nullptr, "Check whether two bodies overlap — reports, changes nothing"}, {"edit_feature", "Edit", 7, nullptr, "btn:edit", nullptr, 0x00007d8eu, 0, 0, false, false, nullptr, "design_edit", "Reopen the selected feature to change what it was made from"}, + {"rename", "Rename…", 7, "F2", "btn:rename", "Select a feature to rename it", 0x00004000u, 0, 0, false, false, nullptr, nullptr, "Give this feature a name you will recognise in the tree"}, {"delete_face", "Delete Face", 7, nullptr, "fly:dressup#3", "Delete Face needs a body — add or import one first", 0x0000000eu, 1, 0, false, false, nullptr, "design_delete", "Remove faces from a body and heal the solid"}, {"colour", "Colour", 7, nullptr, "btn:colour", nullptr, 0x000001feu, 1, 0, false, false, nullptr, "color_palette", "Set the selected body's display colour"}, {"delete", "Delete", 7, "Del", "btn:delete", nullptr, 0x000f7c00u, 0, 0, false, false, nullptr, "design_delete", "Delete what is selected"}, @@ -181,7 +182,7 @@ static const OfferVerb kOfferVerbs[] = { {"sk_radius", "Radius / diameter…", 7, "V", "key:V", nullptr, 0x00020000u, 0, 0, false, true, nullptr, "design_dimension", "Type the radius of this arc, or the diameter of this circle"}, {"sk_angdist", "Angle / distance…", 7, "V", "key:V", nullptr, 0x00080000u, 0, 0, false, true, nullptr, "design_dimension", "Type the angle between two lines, or the distance between the two picks"}, }; -static const int kOfferVerbCount = 90; +static const int kOfferVerbCount = 91; }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 4c5a60b1d7..5d416c7a8d 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -1027,6 +1027,21 @@ 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. + auto rename_feature = [this] { + const int sel = tree_selection(); + if (sel != wxNOT_FOUND && sel < int(m_tree_items.size())) { + m_tree->EditLabel(m_tree_items[sel]); // opens the in-place editor on that row + } else { + m_status->SetForegroundColour(wxNullColour); // "nothing selected" is not an error + set_status(_L("Select a feature first — click a sketch or feature row, then rename it")); + m_status->Refresh(); + } + }; + m_verb_actions["btn:rename"] = rename_feature; + m_keys_feature[WXK_F2] = rename_feature; // a function key, so no letter space spent // The sketch's own Delete. It used to share "btn:delete" with the feature tree, so // choosing Delete on a selected LINE ran on_delete_feature() and removed a tree row (or // nothing) while the line stayed — the reported "I click a line and cannot remove it". @@ -3003,7 +3018,7 @@ DesignPanel::DesignPanel(wxWindow* parent) FromDIP(SidebarProps::TitlebarMargin())); m_tree = new wxTreeCtrl(m_tree_box, wxID_ANY, wxDefaultPosition, wxSize(-1, 64), wxTR_HIDE_ROOT | wxTR_SINGLE | wxTR_NO_LINES | - wxTR_FULL_ROW_HIGHLIGHT | wxBORDER_SIMPLE); + wxTR_FULL_ROW_HIGHLIGHT | wxBORDER_SIMPLE | wxTR_EDIT_LABELS); if (!dp_dark()) m_tree->SetBackgroundColour(dp_panel_bg()); // Per-feature-type icons (indices match tree_icon_for): sketch/extrude/dressup/hole/thread. m_tree_images = new wxImageList(16, 16); @@ -3045,6 +3060,41 @@ DesignPanel::DesignPanel(wxWindow* parent) // Edit button in the section header. m_tree->Bind(wxEVT_TREE_ITEM_ACTIVATED, [this](wxTreeEvent&) { on_edit_feature(); }); + // 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; + }; + 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. { @@ -3924,7 +3974,6 @@ DesignPanel::DesignPanel(wxWindow* parent) // Delete/Ctrl+Z there must edit the text, not the model. const bool in_text = (dynamic_cast(wxWindow::FindFocus()) != nullptr) || (m_viewport && m_viewport->inline_busy()); - if (getenv("SNAPORCA_KEYTRACE")) { wxWindow* fw = wxWindow::FindFocus(); fprintf(stderr, "[KEYTRACE] key=%d ui_mode=%d is_sketching=%d in_text=%d inline_busy=%d focus=%s\n", @@ -3933,6 +3982,39 @@ DesignPanel::DesignPanel(wxWindow* parent) fw ? (const char*) fw->GetClassInfo()->GetClassName() : "(none)"); fflush(stderr); } + + // NOTE the position: this sits AFTER the KEYTRACE block on purpose. It returns early, + // and putting it first made every forwarded key invisible to the tracer — the one + // instrument that diagnosed this bug in the first place. + // The in-canvas value field is a borderless, always-on-top top-level frame, so whether it + // may take keyboard focus is the platform's decision, not ours: macOS denies key status to a + // borderless window, mutter's focus-stealing prevention refuses a re-mapped window, and the + // rig never grants it. When focus is refused, Enter/Esc/Tab are delivered HERE (to the panel) + // instead of the field, its own wxEVT_TEXT_ENTER / WXK_ESCAPE bindings never fire, and the + // queued-dimension chain (a line queues Length then Angle) becomes unwalkable. Forwarding them + // makes the field behave the same everywhere WITHOUT fighting the window manager for focus, + // which is what seven earlier attempts did unsuccessfully. When the field DOES hold focus we + // deliberately do nothing here, so its own bindings run and typing keeps working. + if (m_viewport && m_viewport->inline_busy() && !m_viewport->inline_has_focus()) { + if (key == WXK_RETURN || key == WXK_NUMPAD_ENTER || key == WXK_TAB) { + m_viewport->inline_commit(); + return; + } + if (key == WXK_ESCAPE) { + m_viewport->inline_cancel(); + return; + } + } + + // Esc must exit the sketch wherever focus happens to be. Which widget holds focus is an + // accident of where the user last clicked (a toolbar button, the Construction checkbox), + // and Esc must not depend on it — request_exit() is the layered behaviour the GL-canvas + // path already uses, so Esc means the same thing here as it does over the viewport. + if (key == WXK_ESCAPE && m_viewport && m_viewport->is_sketching()) { + m_viewport->request_sketch_exit(); + return; + } + const bool dismissable = m_active != Tool::None || (m_viewport && m_viewport->moving_body()); if (key == WXK_ESCAPE && dismissable) { tool_cancel(); return; } diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index 82e5efdbde..1f652f4580 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -241,6 +241,15 @@ void DesignSketchTool::set_tool(Mode mode) // the value away and reverted the corner to sharp, with nothing on screen saying so. reset_op(); // drop any in-progress (not yet ready) edit-op gizmo reset_tf(); // drop any in-progress transform gizmo + // A pick that survives the tool that made it is invisible, and the first sign of it is a + // constraint or a dimension landing on geometry the user did not choose. Drop the modal + // picks left armed by the tool we are leaving: the Dimension tool's first pick, the + // Constrain tool's picks, and the individual point selection. + m_dim_has0 = false; + m_dim_e0 = -1; + m_dim_r0 = SketchPointRole::P0; + m_pick0 = m_pick1 = m_pick2 = -1; + m_point_sel.clear(); m_selection.clear(); if (on_selection_changed) on_selection_changed(0); } @@ -328,6 +337,11 @@ void DesignSketchTool::delete_selected() m_dimensions.clear(); m_dim_has0 = false; m_pending_dim = -1; + // The Dimension tool's pending FIRST pick is the same dangling-reference hazard as the placed + // quotes just cleared above: it references an entity index that has shifted or gone away, and + // a stale m_dim_e0 would dereference out of range on the next click. Drop it too. + m_dim_e0 = -1; + m_dim_r0 = SketchPointRole::P0; if (on_selection_changed) on_selection_changed(0); } diff --git a/src/slic3r/GUI/CAD/SketchInlineEditor.hpp b/src/slic3r/GUI/CAD/SketchInlineEditor.hpp index e3f7670e5e..b132204071 100644 --- a/src/slic3r/GUI/CAD/SketchInlineEditor.hpp +++ b/src/slic3r/GUI/CAD/SketchInlineEditor.hpp @@ -4,7 +4,8 @@ #include #include -class wxWindow; +#include + class wxFrame; class wxTextCtrl; class wxStaticText; @@ -33,6 +34,9 @@ public: void cancel(); // if open, run the registered cancel (keep-as-drawn) void commit(); // if open, run the registered commit (accept the typed value) bool is_open() const { return m_open; } + // True when the field itself holds keyboard focus. Callers use this to decide whether the + // field will handle a key on its own or needs it forwarded — see DesignPanel's CHAR_HOOK. + bool has_focus() const { return m_ctrl != nullptr && wxWindow::FindFocus() == m_ctrl; } private: void do_commit();