Design tab: follow Orca's theme, scale, mouse settings, dialogs and undo

Felix14-v2's review of OrcaSlicer#16019 found the tab behaving as a world of its own.

- Icons: the design_* glyphs were drawn in a fixed light grey, made for the dark ribbon, and the
  toolbar re-tinted some of them by rebuilding the bitmap from a wxImage, which drops the HiDPI
  scale factor Orca sets on Windows: at 150 % the icons came out half again too large for buttons
  that were sized in raw pixels, overlapping and clipped. The glyphs now use Orca's sidebar icon
  grey (#949494), which the icon cache maps per theme, nothing is re-tinted, toolbar glyphs drawn
  for Prepare's light toolbar use their "_dark" twin, and every size is in DIP.
- Theme: the chrome colours were read once, at construction, and nothing in the tab answered a
  theme switch, so switching left light surfaces and unreadable text in a dark tab and the other
  way round. The colours are now {light, dark} token pairs; MainFrame::on_sys_color_changed
  reaches DesignPanel::on_sys_color_changed, which moves every token colour onto the other
  theme's, runs the app's dark pass and re-rasterises the icons. Card borders are StateColors,
  resolved at paint time.
- Scale: MainFrame::on_dpi_changed reaches DesignPanel::msw_rescale, which re-rasterises every
  icon (buttons, flyout rows, card headers, the tree's image list, now sized from its bitmaps)
  and re-measures the Orca widgets.
- Mouse: the canvas no longer forces middle-drag to orbit and right-drag to pan; it reads the
  drag actions in Preferences > Control like Prepare. Left-drag is shared with picking, so the
  whole-body rubber band takes Shift+left-drag while left-drag is given to the camera.
- FPS counter: ImGui's display size is shared and only refreshed when a canvas sees its own
  size change; the Design canvas now re-announces its size when the tab is entered (and the
  editor canvas when it is left), as Plater does between Prepare and Preview.
- Viewport text: the status line and the tool readout were top-level popups over GL. A popup
  does not follow its frame, so the empty-canvas hint floated over other applications, and the
  readout was never taken down with the tab. Both are drawn by the canvas in the tool's ImGui
  pass now, with the theme's overlay style.
- Dialogs: messages use MessageDialog/RichMessageDialog; Add/Edit Variable is one Orca dialog
  with Name and Expression fields instead of two native text prompts; the Text dialog uses
  TextInput, ComboBox and CheckBox (its height is a TextInput: SpinInput is integer-only), and
  enumerates the installed fonts once per session. The ribbon's Confirm/Cancel, the reference
  pick buttons and the expression buttons are Orca Buttons; the variable actions are icon
  buttons like the other cards'.
- Undo: the tab's own Undo/Redo buttons are gone. The top bar's Undo/Redo drive the Design
  history while the tab is shown, greyed to what an undo would do, as Ctrl+Z and Edit already
  did.
- The first build of the tab logs how long each phase took ("Design tab build: ..."): it is
  under a second here but was reported at about fifteen on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK4VgguuCAk2hZLWgcjJb9
This commit is contained in:
Claude committed 2026-10-01 15:38:24 +00:00
1 parent 96bf754af9
commit 410399fdb5
95 files changed
+666 -611

No files matched your search

+56 -202
View File
@@ -8,6 +8,7 @@
#include "slic3r/GUI/Camera.hpp" // N: look down the sketch plane normal
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/Plater.hpp"
#include "slic3r/GUI/ImGuiWrapper.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "slic3r/GUI/3DScene.hpp"
@@ -48,12 +49,6 @@ DesignCanvas::DesignCanvas(wxWindow* parent)
m_canvas->set_type(GLCanvas3D::ECanvasType::CanvasView3D);
m_canvas->set_studio_lighting(true); // see GLCanvas3D::m_studio_lighting and phong.fs
// CAD navigation, this canvas only: left-drag sweeps a selection rubber band, so orbit
// moves to middle-drag and pan to right-drag. Design is a different modality from
// Prepare/Preview and every CAD the user already knows maps the mouse this way; the other
// tabs are untouched.
m_canvas->set_cad_navigation(true);
m_canvas->enable_picking(false); // viewport face/edge picking is custom (TODO)
m_canvas->enable_moving(false);
m_canvas->enable_gizmos(false);
@@ -155,76 +150,11 @@ DesignCanvas::DesignCanvas(wxWindow* parent)
if (m_inline_editor) m_inline_editor->commit();
};
// Bottom-right viewport HUD: a borderless, non-focusable float label showing the active
// tool's current values. Top-level (a child widget is hidden by the GL surface, same as
// the inline editor). Fed every frame by the tool's on_readout; empty text hides it.
// NON-FOCUSABLE IS THE LOAD-BEARING WORD, and a wxFrame is not: see the header. The chip
// outlives the gesture that drew it, and while it held the X input focus every sketch
// shortcut was swallowed until the user clicked the canvas. Same window class as the status
// chip below for the same reason. Do not "simplify" it back to a wxFrame.
{
wxWindow* top = wxGetTopLevelParent(m_canvas_widget);
m_hud = new wxPopupWindow(top, wxBORDER_NONE);
m_hud->SetBackgroundColour(wxColour(28, 30, 34));
m_hud_label = new wxStaticText(m_hud, wxID_ANY, wxEmptyString);
m_hud_label->SetForegroundColour(wxColour(0x46, 0xE0, 0xC8)); // teal, reads on dark bed
wxFont f = m_hud_label->GetFont(); f.MakeBold(); m_hud_label->SetFont(f);
auto* hs = new wxBoxSizer(wxHORIZONTAL);
hs->Add(m_hud_label, 0, wxALL, 6);
m_hud->SetSizerAndFit(hs);
m_hud->Hide();
}
m_sketch_tool.on_readout = [this](const std::string& s) { set_readout(s); };
// Bottom-LEFT twin, carrying the status line. Top-level for the same reason as the readout
// (a child widget is hidden by the GL surface) but a wxPopupWindow rather than a wxFrame,
// because a popup cannot take keyboard focus. The readout gets away with a frame only
// because it appears mid-gesture and the next input is the mouse; this one is up
// permanently and is re-raised on every status change. As a frame it took the WM's focus
// each time and the canvas stopped receiving keys at all — every sketch shortcut silently
// dead, which reads as a broken tool. Do not "simplify" it back to a wxFrame.
// Its colour is set per message — the panel decides whether a line is neutral or an error.
{
wxWindow* top = wxGetTopLevelParent(m_canvas_widget);
m_status_hud = new wxPopupWindow(top, wxBORDER_NONE);
m_status_hud->SetBackgroundColour(wxColour(28, 30, 34));
m_status_hud_label = new wxStaticText(m_status_hud, wxID_ANY, wxEmptyString);
auto* ss = new wxBoxSizer(wxHORIZONTAL);
// The line never wraps — there is a whole window's width down here — so the chip is
// ONE LINE tall. Spacers rather than a wxALL border because the two axes want
// different numbers: roomy at the sides so it reads as a label, and just enough top
// and bottom to clear the descenders. Zero vertical clips the glyphs; 6 (what the
// readout chip uses) makes it look like a two-line box.
ss->AddSpacer(10);
ss->Add(m_status_hud_label, 0, wxTOP | wxBOTTOM, 3);
ss->AddSpacer(10);
m_status_hud->SetSizerAndFit(ss);
m_status_hud->Hide();
}
// A floating frame does not follow its parent, so the anchor has to be recomputed whenever
// the canvas changes size (that bind is below bind_event_handlers(), for the reason given
// there). The readout HUD gets away without this because it is transient; the status line is
// on screen almost permanently and would visibly detach.
// ...and it does not follow the WINDOW either. A popup is override-redirect: the window
// manager does not own it, so minimising the app leaves the chip sitting on the bare desktop
// (seen on the rig: whole screen black, chip still there), and it stacks above other
// applications rather than behind them. IsShownOnScreen does not catch this — an iconised
// frame still counts as shown — so the frame has to say so itself. Deactivating the app is
// the same case one step weaker: the chip belongs to a viewport the user is no longer
// looking at. Showing it back is safe because a popup cannot take focus, so neither event
// can be re-triggered by our own Show().
// Members rather than lambdas so unbind_canvas_event_handlers() can Unbind them: these sit on
// a frame that OUTLIVES this canvas, and a lambda cannot be unbound.
if (wxWindow* top = wxGetTopLevelParent(m_canvas_widget)) {
top->Bind(wxEVT_ICONIZE, &DesignCanvas::on_frame_iconize, this);
top->Bind(wxEVT_ACTIVATE, &DesignCanvas::on_frame_activate, this);
// The anchor is an ABSOLUTE SCREEN position (ClientToScreen below), so moving the window
// moves the canvas out from under a chip that stays where it was. Dragging the frame by
// its title bar left the chip stranded mid-viewport until the next size, status or tab
// change happened to re-place it. Nothing on the canvas fires for a move that does not
// also resize, so it has to come from the frame.
top->Bind(wxEVT_MOVE, &DesignCanvas::on_status_hud_reanchor, this);
}
// The two viewport chips — the active tool's values bottom-right, the status line bottom-left
// — are drawn by the canvas itself, in the tool's ImGui pass: they go away with the canvas,
// the tab and the window, and never take the keyboard.
m_sketch_tool.on_readout = [this](const std::string& s) { set_readout(s); };
m_sketch_tool.render_overlays = [this] { render_hud(); };
refresh_bed();
@@ -236,16 +166,10 @@ DesignCanvas::DesignCanvas(wxWindow* parent)
// reverse order of binding, and GLCanvas3D swallows several events without skipping them —
// wxEVT_RIGHT_UP and wxEVT_ENTER_WINDOW in on_mouse, and wxEVT_SIZE in on_size, which is
// just `m_dirty = true;`. For those, whatever is bound LAST is the only handler that runs.
// The context menu, the focus-follows-mouse and the status-chip re-anchor all depend on
// running first, which is only true while this call stays ahead of them.
// The context menu and the focus-follows-mouse depend on running first, which is only true
// while this call stays ahead of them.
m_canvas->bind_event_handlers();
// The status-chip re-anchor promised above, bound AFTER the call so it runs first — ahead of
// it the handler never ran at all, leaving a stale anchor and wrap width after any resize
// that did not also move the frame or change the text. Its e.Skip() is load-bearing the
// other way: it falls through to on_size, which is what still marks the canvas dirty.
m_canvas_widget->Bind(wxEVT_SIZE, &DesignCanvas::on_status_hud_reanchor, this);
// The Design GL canvas only receives key events (Esc to exit/enter Select, Ctrl+Z undo)
// while it holds keyboard focus. Clicking a side-panel button steals focus, after which
// Esc/Ctrl+Z silently do nothing until the viewport is clicked again. Restore focus
@@ -268,18 +192,6 @@ DesignCanvas::DesignCanvas(wxWindow* parent)
// Both are idempotent, and neither destroys anything: the destructor still owns that.
void DesignCanvas::unbind_canvas_event_handlers()
{
if (wxWindow* top = wxGetTopLevelParent(m_canvas_widget)) {
top->Unbind(wxEVT_ICONIZE, &DesignCanvas::on_frame_iconize, this);
top->Unbind(wxEVT_ACTIVATE, &DesignCanvas::on_frame_activate, this);
top->Unbind(wxEVT_MOVE, &DesignCanvas::on_status_hud_reanchor, this);
}
// Before the popups go down, or a resize still in flight re-places and re-shows the chip.
if (m_canvas_widget)
m_canvas_widget->Unbind(wxEVT_SIZE, &DesignCanvas::on_status_hud_reanchor, this);
// A popup is override-redirect: it does not go down with the frame, so one left showing sits
// on the bare desktop for however long the teardown takes.
show_status_hud(false);
if (m_hud) m_hud->Hide();
if (m_canvas) m_canvas->unbind_event_handlers();
}
@@ -290,7 +202,6 @@ void DesignCanvas::reset_canvas_volumes()
DesignCanvas::~DesignCanvas()
{
if (m_hud) m_hud->Destroy();
delete m_canvas;
delete m_canvas_widget;
}
@@ -346,14 +257,22 @@ void DesignCanvas::request_repaint()
}
}
// ImGui's display size is shared by every canvas and only refreshed when a canvas sees its own
// size change, so the canvas taking over must re-announce its size (Plater does the same between
// Prepare and Preview). Otherwise the overlays anchored to it, the FPS counter first, are laid
// out for the other canvas.
void DesignCanvas::enter_viewport()
{
if (!m_camera_swapped) swap_camera();
if (m_canvas) { m_canvas->reset_old_size(); m_canvas->set_as_dirty(); }
}
void DesignCanvas::leave_viewport()
{
if (m_camera_swapped) swap_camera();
if (Plater* plater = wxGetApp().plater())
if (GLCanvas3D* editor = plater->get_current_canvas3D())
editor->reset_old_size();
}
void DesignCanvas::swap_camera()
@@ -1188,124 +1107,59 @@ double DesignCanvas::model_mid_z() const
void DesignCanvas::set_readout(const std::string& text)
{
if (!m_hud || !m_hud_label || !m_canvas_widget) return;
if (text == m_hud_last) return; // only touch the WM on a real change
if (text == m_hud_last) return;
m_hud_last = text;
if (text.empty()) { m_hud->Hide(); return; }
m_hud_label->SetLabel(wxString::FromUTF8(text));
place_readout_hud();
}
void DesignCanvas::place_readout_hud()
{
if (!m_hud || !m_hud_label || !m_canvas_widget) return;
if (m_hud_last.empty() || !m_canvas_widget->IsShownOnScreen()) { m_hud->Hide(); return; }
m_hud->Fit();
// Anchor to the canvas's bottom-right corner with a small margin (screen coords).
const wxSize cs = m_canvas_widget->GetClientSize();
const wxSize hs = m_hud->GetSize();
const wxPoint br = m_canvas_widget->ClientToScreen(
wxPoint(cs.GetWidth() - hs.GetWidth() - 12, cs.GetHeight() - hs.GetHeight() - 12));
if (!m_hud->IsShown()) m_hud->Show(); // Show before Move (GTK ignores pre-map Move)
m_hud->Move(br);
m_hud->Raise();
}
// A popup is override-redirect: the window manager does not own it, so an iconised or
// deactivated app would leave the chip sitting on the bare desktop. The status chip already
// had to answer this; now that the readout is a popup too, it answers it the same way.
void DesignCanvas::show_readout_hud(bool on)
{
if (!m_hud) return;
if (on) place_readout_hud();
else m_hud->Hide();
if (m_canvas) m_canvas->set_as_dirty(); // drawn by the next frame (the tool feeds this from one)
}
// Clear of the view cube and the two round view buttons, which own the bottom-left corner.
// Shared by the placement and by the wrap width, which have to agree or the chip wraps to a
// width it is then not given.
static constexpr int kStatusHudLeftInsetDip = 190;
static constexpr float kStatusHudLeftInset = 190.f;
void DesignCanvas::set_status_text(const wxString& text, const wxColour& colour)
{
if (!m_status_hud || !m_status_hud_label || !m_canvas_widget) return;
if (text == m_status_hud_last && colour == m_status_hud_colour) return;
m_status_hud_last = text;
m_status_hud_colour = colour;
if (text.IsEmpty()) { m_status_hud->Hide(); return; }
m_status_hud_label->SetForegroundColour(colour);
apply_status_label();
place_status_hud();
request_repaint();
}
// SetLabel + Wrap + Fit, in that order and always together. Moving the status out of the panel
// removed the clipping of 8cc but not the underlying problem: the chip is a top-level
// popup that Fit()s to its text, so a long sentence simply grew past the right edge of the canvas
// and hung over the window. Wrapping to the room actually available is what makes the earlier
// promise — "a sentence can be a sentence" — true at every window width, including the charter's
// 1366 reach. Wrap() rewrites the label it is given, so it must follow a fresh SetLabel every
// time; that is the whole reason this is one function instead of three call sites.
void DesignCanvas::apply_status_label()
void DesignCanvas::render_hud()
{
if (!m_status_hud || !m_status_hud_label || !m_canvas_widget) return;
m_status_hud_label->SetLabel(m_status_hud_last);
const int avail = m_canvas_widget->GetClientSize().GetWidth()
- m_canvas_widget->FromDIP(kStatusHudLeftInsetDip)
- m_canvas_widget->FromDIP(24);
if (avail > m_canvas_widget->FromDIP(120)) // a uselessly narrow canvas: leave it unwrapped
m_status_hud_label->Wrap(avail);
m_status_hud->Fit();
}
void DesignCanvas::place_status_hud()
{
if (!m_status_hud || !m_canvas_widget || m_status_hud_last.IsEmpty()) return;
// The canvas has a client size even while its page is hidden, and it is not the size the
// page will have when shown — anchoring against it put the chip up on the tab bar, where it
// then stayed until the next status change moved it. Nothing to anchor to: stay down.
if (!m_canvas_widget->IsShownOnScreen()) { m_status_hud->Hide(); return; }
const wxSize cs = m_canvas_widget->GetClientSize();
// Re-wrap first: this also runs on resize, and a chip wrapped for the old width either
// overhangs a narrowed canvas or wastes a widened one.
apply_status_label();
const wxSize hs = m_status_hud->GetSize();
const int kLeftInset = m_canvas_widget->FromDIP(kStatusHudLeftInsetDip);
const wxPoint bl = m_canvas_widget->ClientToScreen(
wxPoint(kLeftInset, cs.GetHeight() - hs.GetHeight() - 12));
// No Raise() and no focus juggling: a popup neither takes focus nor falls behind. This was
// caught with ORCA_CAD_KEYTRACE — shift+S logged a line, the following R logged nothing, and
// the only thing between them was the first status update showing this window.
if (!m_status_hud->IsShown()) m_status_hud->Show(); // Show before Move (GTK ignores pre-map Move)
m_status_hud->Move(bl);
}
void DesignCanvas::on_frame_iconize(wxIconizeEvent& e)
{
show_status_hud(!e.IsIconized());
show_readout_hud(!e.IsIconized());
e.Skip();
}
void DesignCanvas::on_frame_activate(wxActivateEvent& e)
{
show_status_hud(e.GetActive());
show_readout_hud(e.GetActive());
e.Skip();
}
// wxEvent& so one handler serves both events that invalidate the anchor: the frame moving out
// from under the chip, and the canvas resizing under it.
void DesignCanvas::on_status_hud_reanchor(wxEvent& e)
{
place_status_hud();
e.Skip();
}
void DesignCanvas::show_status_hud(bool on)
{
if (!m_status_hud) return;
if (on) place_status_hud(); // re-anchors first: the page may have been resized while away
else m_status_hud->Hide();
if (m_hud_last.empty() && m_status_hud_last.IsEmpty()) return;
ImGuiWrapper& imgui = *wxGetApp().imgui();
const ImVec2 ds = ImGui::GetIO().DisplaySize;
const float em = imgui.get_style_scaling(); // follows the font, so the DPI
const float margin = 12.f * em;
const int flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize |
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoSavedSettings |
ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav;
auto chip = [&](const char* id, const std::string& text, float x, float pivot_x, float wrap,
const ImVec4* colour) {
ImGuiWrapper::push_common_window_style(m_canvas->get_scale());
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.f * em, 4.f * em));
imgui.set_next_window_pos(x, ds.y - margin, ImGuiCond_Always, pivot_x, 1.f);
imgui.begin(std::string(id), flags);
if (wrap > 0.f) ImGui::PushTextWrapPos(wrap);
if (colour) ImGui::PushStyleColor(ImGuiCol_Text, *colour);
ImGui::TextUnformatted(text.c_str());
if (colour) ImGui::PopStyleColor();
if (wrap > 0.f) ImGui::PopTextWrapPos();
imgui.end();
ImGui::PopStyleVar();
ImGuiWrapper::pop_common_window_style();
};
if (!m_status_hud_last.IsEmpty()) {
// A sentence can be a sentence: it wraps to the room left of the readout chip.
const float left = kStatusHudLeftInset * em;
const ImVec4 col = m_status_hud_colour.IsOk()
? ImVec4(m_status_hud_colour.Red() / 255.f, m_status_hud_colour.Green() / 255.f,
m_status_hud_colour.Blue() / 255.f, 1.f)
: ImVec4();
chip("##design_status", m_status_hud_last.ToUTF8().data(), left, 0.f,
std::max(ds.x * 0.6f - left, 120.f * em), m_status_hud_colour.IsOk() ? &col : nullptr);
}
if (!m_hud_last.empty())
chip("##design_readout", m_hud_last, ds.x - margin, 1.f, 0.f, &ImGuiWrapper::COL_ORCA);
}
void DesignCanvas::set_body_highlight(bool on)