Move the IMEX modes editor into its own file and fix four defects in it

Closes review comments 21, 4 and 12, and the widget half of 19.

- 21: IMEXModesCtrl was 572 lines inside Tab.cpp. It now lives in
  IMEXModesCtrl.{hpp,cpp} next to IMEXFilamentPickerPopover, which was the
  precedent named in the comment. The move itself is exact -- member order,
  comments and every string literal unchanged -- and the class had no file-local
  dependencies in Tab.cpp, only its include list, so the new source states those
  explicitly.
- 4: a mode row with an empty Name was silently dropped on save, tools and G-code
  with it, and matches_config() compared against that same filtered output so the
  preset never went dirty and the row stayed on screen. Rows are now given a
  generated unique name instead of being discarded, and add_row() pre-fills one so
  the common path never produces a blank. Names are deliberately not translated:
  objects store a mode name in imex_parallel_mode and GCode.cpp matches it by
  string, so a localized name would break a project reopened in another language.
- 12: the editor had a third parser that read a bare token and an unknown role
  suffix as Primary, while parse_imex_active_tools reads both as Copy -- so the
  editor and the slicer could read one imex_mode_active_tools string two different
  ways. Deleted; the editor now uses the same two helpers the slicer does.
- 19: tile state was an int shadowing ImexRole, with the role letters duplicated in
  a second switch that wrote the on-disk format. The tile now holds
  optional<ImexRole>, with Inactive spelled as the absence of a role rather than a
  fifth integer, and the letters come from kImexRoleTable.

Four further changes, from testing rather than the review:

- Deleting a mode reported only the row's current name, so renaming a mode and then
  deleting it left every plate using it stranded on a name that no longer exists.
  Both the build-time and current names are now reported, minus any a surviving row
  still carries.
- The instruction text and colour legend were built once in the constructor and
  never rebuilt, so raising gantry count to 2 gave the tiles a Span role the legend
  never explained until the preset was saved and the page reopened. Both are
  rebuilt with the grid, and the per-role detail moved into legend tooltips so the
  panel no longer opens with a paragraph.
- The tile holding Primary is now read-only. Primary is tool 0 and moves only via
  Tool 0 Position; a click could previously demote the only Primary, leaving a mode
  that parses to no primary at all, which degrades the plate to an ordinary
  single-tool print with nothing in the editor showing what is wrong. A mode
  arriving without a Primary keeps every tile live so it can still be repaired.
- Names and G-code were read with ToStdString() (the ANSI codepage on Windows) and
  written with from_u8() (UTF-8). On a non-UTF-8 codepage a name like "Modus A"
  with a diaeresis was stored as invalid UTF-8, came back blank, and was then
  silently renamed by the auto-naming above. Every read is now into_u8() and every
  write from_u8(); EditGCodeDialog was affected in both directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clifford Garwood
2026-09-03 00:56:26 -04:00
co-authored by Claude Opus 5
parent 5b30af463d
commit f75bdd6013
3 changed files with 941 additions and 0 deletions
+2
View File
@@ -274,6 +274,8 @@ set(SLIC3R_GUI_SOURCES
GUI/IconManager.hpp
GUI/IMEXFilamentPickerPopover.cpp
GUI/IMEXFilamentPickerPopover.hpp
GUI/IMEXModesCtrl.cpp
GUI/IMEXModesCtrl.hpp
GUI/ImageGrid.cpp
GUI/ImageGrid.h
GUI/ImGuiWrapper.cpp
+697
View File
@@ -0,0 +1,697 @@
#include "slic3r/GUI/IMEXModesCtrl.hpp"
#include <wx/app.h>
#include <wx/font.h>
#include <wx/stattext.h>
#include <algorithm>
#include <utility>
#include "slic3r/GUI/EditGCodeDialog.hpp"
#include "slic3r/GUI/GUI.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/wxExtensions.hpp"
namespace Slic3r {
namespace GUI {
IMEXModesCtrl::IMEXModesCtrl(wxWindow* parent, int n_cols, int n_rows, int layout)
: wxPanel(parent, wxID_ANY), m_n_cols(std::max(1, n_cols)), m_n_rows(std::max(1, n_rows)), m_layout(layout)
{
// Pull the app's window-default colour explicitly. Without this, GTK gives
// child wxPanels a slightly lighter "widget bg" instead of the app's dark
// theme — making chromeless ScalableButtons inside the panel render with a
// visible light box around the icon. Sub-panels inherit this colour.
SetBackgroundColour(wxGetApp().get_window_default_clr());
m_outer = new wxBoxSizer(wxVERTICAL);
m_rows_sizer = new wxBoxSizer(wxVERTICAL);
// Info panel (instructions + legend) and the column-header row. Both depend on the grid
// shape, so their CONTENTS are (re)built by rebuild_info_and_header(), which set_grid_size()
// calls again whenever the grid changes. The panels themselves are stable so m_outer's
// ordering never has to be rearranged.
m_info_panel = new wxPanel(this, wxID_ANY);
m_info_panel->SetBackgroundColour(GetBackgroundColour());
m_hdr_panel = new wxPanel(this, wxID_ANY);
m_hdr_panel->SetBackgroundColour(GetBackgroundColour());
rebuild_info_and_header();
auto* add_btn = new wxButton(this, wxID_ANY, _L("+ Add Mode"),
wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT);
add_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { add_row(); notify(); });
m_outer->Add(m_info_panel, 0, wxEXPAND | wxBOTTOM, FromDIP(4));
m_outer->Add(m_hdr_panel, 0, wxEXPAND | wxBOTTOM, FromDIP(2));
m_outer->Add(m_rows_sizer, 0, wxEXPAND);
m_outer->AddSpacer(FromDIP(4));
m_outer->Add(add_btn, 0);
// SetSizer(), not SetSizerAndFit(): no row exists yet, so there is nothing to fit to --
// rows arrive from add_row() / load_from_config() / set_grid_size(). Deliberately NOT
// followed by m_outer->SetSizeHints(this) once they do: this panel owns a sizer, so
// wxWindow::GetBestSize() bypasses the best-size cache and re-runs m_outer->CalcMin()
// on every parent layout, and that is what the enclosing sizer already reserves space
// for via GetEffectiveMinSize(). An explicitly set min size takes PRIORITY over the best
// size in that call, so a size hint would pin the reserved height to the row count that
// happened to be on screen when it ran -- adding a mode after that would clip the bottom
// row and the "+ Add Mode" button, which is the very failure the hint is meant to avoid.
SetSizer(m_outer);
}
void IMEXModesCtrl::rebuild_info_and_header() {
// --- Info panel: instruction text + colour legend ---
m_info_panel->DestroyChildren();
auto* info_sizer = new wxBoxSizer(wxVERTICAL);
// Keep this to one line. Per-role detail lives in the legend tooltips below, so the
// panel does not open with a paragraph the user has to read before touching anything.
const wxString instructions =
_L("Each mode names the tool heads that take part and the role each one plays. "
"Click a tool button to cycle its role — hover a colour below for what each role does.");
auto* inst = new wxStaticText(m_info_panel, wxID_ANY, instructions);
inst->Wrap(FromDIP(620));
info_sizer->Add(inst, 0, wxBOTTOM, FromDIP(6));
// Color legend — swatches sized to the body text height so they read as
// matched pairs with their labels regardless of system DPI / font scale,
// matching the on-hover ghost tooltip swatch's visual weight.
// One entry per role this printer offers, in kImexRoleTable order — the same order the
// tiles cycle in. A role added to that table appears here with no edit of its own.
auto* leg_sizer = new wxBoxSizer(wxHORIZONTAL);
const int swatch_side = m_info_panel->GetCharHeight();
for (const ImexRoleDesc& d : kImexRoleTable) {
if (!role_offered(d.role)) continue;
const RoleStyle style = role_style(d.role);
auto* swatch = new wxPanel(m_info_panel, wxID_ANY, wxDefaultPosition, wxSize(swatch_side, swatch_side));
swatch->SetBackgroundColour(style.bg);
auto* leg_label = new wxStaticText(m_info_panel, wxID_ANY, style.label);
const wxString hint = role_hint(d.role);
swatch->SetToolTip(hint);
leg_label->SetToolTip(hint);
leg_sizer->Add(swatch, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4));
leg_sizer->Add(leg_label, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(16));
}
info_sizer->Add(leg_sizer, 0, wxBOTTOM, FromDIP(6));
m_info_panel->SetSizerAndFit(info_sizer);
// --- Column header row ---
// Spacers sized to align with the mode-row fields below. Name field: FromDIP(130) + FromDIP(6)
// gap; tool grid: n_cols*(FromDIP(36)+FromDIP(2))-FromDIP(2) + FromDIP(6) gap. grid_px tracks
// the live column count, so the G-code header stays aligned after a grid change.
m_hdr_panel->DestroyChildren();
auto* hdr_sizer = new wxBoxSizer(wxHORIZONTAL);
auto* hdr_name = new wxStaticText(m_hdr_panel, wxID_ANY, _L("Name"));
auto* hdr_tools = new wxStaticText(m_hdr_panel, wxID_ANY, _L("Tools"));
auto* hdr_gcode = new wxStaticText(m_hdr_panel, wxID_ANY, _L("G-code"));
hdr_name->SetToolTip(_L("How the mode is labelled in the plate's IMEX mode selector. "
"Required — a mode with no name cannot be selected."));
hdr_tools->SetToolTip(_L("Which tool heads take part in the mode and what role each one plays. "
"Click a tile to cycle its role."));
hdr_gcode->SetToolTip(_L("G-code run at print start to put the printer into this mode "
"(for example a Klipper SET_PRINT_MODE call). The slicer only emits "
"the Primary tool's paths; the firmware drives the others."));
const int grid_px = m_n_cols * FromDIP(38) - FromDIP(2); // approx grid panel width
const int name_col_px = FromDIP(136);
hdr_sizer->Add(hdr_name, 0, wxALIGN_CENTER_VERTICAL);
hdr_sizer->AddSpacer(std::max(0, name_col_px - hdr_name->GetBestSize().x));
hdr_sizer->Add(hdr_tools, 0, wxALIGN_CENTER_VERTICAL);
hdr_sizer->AddSpacer(std::max(0, grid_px + FromDIP(6) - hdr_tools->GetBestSize().x));
hdr_sizer->Add(hdr_gcode, 1, wxALIGN_CENTER_VERTICAL);
m_hdr_panel->SetSizerAndFit(hdr_sizer);
}
void IMEXModesCtrl::set_grid_size(int n_cols, int n_rows, int layout) {
n_cols = std::max(1, n_cols);
n_rows = std::max(1, n_rows);
if (layout < 0) layout = m_layout;
if (n_cols == m_n_cols && n_rows == m_n_rows && layout == m_layout) return;
auto [names, tools, gcodes] = get_mode_data();
clear_rows();
m_n_cols = n_cols;
m_n_rows = n_rows;
m_layout = layout;
// Span becomes available the moment a second gantry exists, and the header spacing tracks the
// column count -- so the legend and headers are rebuilt here rather than only at construction.
// Without this, adding a gantry gives the tiles a Span role the legend never explains until
// the preset is saved and the page reopened.
rebuild_info_and_header();
for (size_t i = 0; i < names.size(); ++i)
add_row(names[i], tools[i], gcodes[i], /*is_primary=*/(names[i] == kImexPrimaryMode));
Layout();
}
void IMEXModesCtrl::load_from_config(const DynamicPrintConfig& cfg) {
clear_rows();
// One ImexMode per row of imex_mode_names, with any sibling array too short for a row
// padded to an empty string -- exactly what the three per-index bounds checks this
// replaced were doing, now in one place shared with every other IMEX consumer.
const std::vector<ImexMode> table = imex_mode_table(cfg);
// Primary row is always first and non-deletable. Look for an existing
// "primary" entry in the config (present in configs saved after #8 was
// implemented); fall back to empty tool/gcode for older configs.
const ImexMode primary = find_imex_mode(cfg, kImexPrimaryMode);
add_row(kImexPrimaryMode, primary.active_tools, primary.gcode, /*is_primary=*/true);
for (const ImexMode& m : table) {
if (m.index == primary.index) continue; // already added above
add_row(m.name, m.active_tools, m.gcode);
}
refresh_reset_buttons();
Layout();
}
std::tuple<std::vector<std::string>, std::vector<std::string>, std::vector<std::string>>
IMEXModesCtrl::get_mode_data() const {
std::vector<std::string> names, tools, gcodes;
for (auto& r : m_rows) {
// into_u8(), not ToStdString(): everything downstream of here -- the preset, the 3MF
// metadata, the plate's stored mode name -- is UTF-8, while ToStdString() encodes
// through wxConvLibc, which on Windows is the ANSI codepage. The two agree only on a
// UTF-8 locale, so mixing them turns a non-ASCII mode name into mojibake (or an empty
// field, once from_u8() rejects it) on the round trip through the preset.
std::string nm = r.is_primary ? std::string(kImexPrimaryMode) : into_u8(r.name->GetValue());
if (nm.empty()) nm = unique_mode_name(names);
names.push_back(nm);
tools.push_back(active_tools_string(r));
gcodes.push_back(into_u8(r.gcode->GetValue()));
}
return {names, tools, gcodes};
}
bool IMEXModesCtrl::matches_config(const DynamicPrintConfig& cfg) const {
auto [names, tools, gcodes] = get_mode_data();
auto cfg_strings = [&cfg](const char* key) {
std::vector<std::string> v;
if (auto* o = cfg.option<ConfigOptionStrings>(key)) v = o->values;
return v;
};
return names == cfg_strings("imex_mode_names")
&& tools == cfg_strings("imex_mode_active_tools")
&& gcodes == cfg_strings("imex_mode_gcodes");
}
std::map<int, ImexRole> IMEXModesCtrl::roles_for_mode(const std::string& active_tools) {
std::map<int, ImexRole> roles;
for (const auto& [phys, role] : parse_imex_active_tools(active_tools))
roles[phys] = role;
// Promote the mode's primary, but never invent a tool that isn't in the
// slicer's own active-tool list: parse_imex_active_tools() drops negative and
// out-of-range indices, imex_primary_tool_for_mode() does not range-check the
// upper bound, and a junk index round-tripped back out on save would be a
// token the slicer ignores.
const int primary = imex_primary_tool_for_mode(active_tools);
if (primary >= 0) {
auto it = roles.find(primary);
if (it != roles.end())
it->second = ImexRole::Primary;
}
return roles;
}
std::string IMEXModesCtrl::unique_mode_name(const std::vector<std::string>& also_taken) const {
auto is_taken = [&](const std::string& cand) {
if (std::find(also_taken.begin(), also_taken.end(), cand) != also_taken.end())
return true;
for (const auto& r : m_rows)
if (!r.is_primary && r.name && into_u8(r.name->GetValue()) == cand)
return true;
return false;
};
for (int n = 2; ; ++n) {
std::string cand = "Mode " + std::to_string(n);
if (!is_taken(cand))
return cand;
}
}
IMEXModesCtrl::RoleStyle IMEXModesCtrl::role_style(std::optional<ImexRole> role) {
// Grey / "Inactive" is the no-role answer; every role gets an explicit case so a new
// one is a compile-time -Wswitch prompt rather than a tile that silently renders grey.
if (!role)
return { wxColour(90, 90, 90), *wxWHITE, "Inactive" };
switch (*role) {
case ImexRole::Primary: return { wxColour(50, 160, 50), *wxWHITE, "Primary" }; // green
case ImexRole::Copy: return { wxColour(60, 120, 210), *wxWHITE, "Copy" }; // blue
case ImexRole::Mirror: return { wxColour(210, 130, 20), *wxWHITE, "Mirror" }; // amber
case ImexRole::Span: return { wxColour(180, 180, 40), *wxWHITE, "Span" }; // yellow
}
return { wxColour(90, 90, 90), *wxWHITE, "Inactive" }; // keeps every compiler quiet
}
wxString IMEXModesCtrl::role_hint(ImexRole role) {
switch (role) {
case ImexRole::Primary:
return _L("Drives every sliced path. Always tool 0 — use Tool 0 Position above to choose "
"which corner of the bed it occupies. It cannot be moved from the grid.");
case ImexRole::Copy:
return _L("Follows the Primary at the firmware level, printing the same paths offset into "
"its own zone.");
case ImexRole::Mirror:
return _L("Follows the Primary at the firmware level, printing the same paths reflected "
"across the boundary between the two zones.");
case ImexRole::Span:
return _L("Marks a tool on the Primary's gantry as its multicolor partner. Required to "
"allow multi-color printing in paired-gantry modes.");
}
return wxEmptyString;
}
bool IMEXModesCtrl::role_offered(ImexRole role) const {
// Span declares a within-gantry multicolor partner of the Primary, which only means
// something when there is a second gantry running in parallel with it.
if (role == ImexRole::Span)
return m_n_rows >= 2;
return true;
}
bool IMEXModesCtrl::role_allowed_on_tile(ImexRole role, int tool_idx,
bool other_primary, int primary_gantry) const {
if (!role_offered(role))
return false;
// Exactly one tile drives the sliced paths.
if (role == ImexRole::Primary)
return !other_primary;
// Span only on a tile sharing the Primary's gantry — that is what the marker declares.
if (role == ImexRole::Span)
return primary_gantry >= 0 && (tool_idx / m_n_cols) == primary_gantry;
return true;
}
std::optional<ImexRole> IMEXModesCtrl::next_tile_role(std::optional<ImexRole> current,
const std::function<bool(ImexRole)>& allowed) {
const int n = (int)(sizeof(kImexRoleTable) / sizeof(kImexRoleTable[0]));
int start = 0; // no current role (Inactive) → start at the first role
if (current) {
for (int i = 0; i < n; ++i)
if (kImexRoleTable[i].role == *current) { start = i + 1; break; }
}
for (int i = start; i < n; ++i)
if (allowed(kImexRoleTable[i].role))
return kImexRoleTable[i].role;
return std::nullopt; // walked off the end → back to Inactive
}
void IMEXModesCtrl::apply_btn(wxButton* btn, int tool_idx, std::optional<ImexRole> role) {
// Always label as T{n} — the button color already encodes the role.
const RoleStyle style = role_style(role);
btn->SetLabel(wxString::Format("T%d", tool_idx));
btn->SetBackgroundColour(style.bg);
btn->SetForegroundColour(style.fg);
btn->Refresh();
}
IMEXModesCtrl::RowSnapshot IMEXModesCtrl::snapshot_row(const Row& r) const {
RowSnapshot s;
// UTF-8 throughout -- this snapshot is compared against the preset's own values.
s.name = r.is_primary ? std::string(kImexPrimaryMode) : into_u8(r.name->GetValue());
s.tools = active_tools_string(r);
s.gcode = into_u8(r.gcode->GetValue());
return s;
}
bool IMEXModesCtrl::row_differs_from_parent(int row_idx) const {
if (!m_parent_lookup) return false;
const DynamicPrintConfig* parent = m_parent_lookup();
if (!parent) return false;
auto* p_names = parent->option<ConfigOptionStrings>("imex_mode_names");
auto* p_tools = parent->option<ConfigOptionStrings>("imex_mode_active_tools");
auto* p_gcodes = parent->option<ConfigOptionStrings>("imex_mode_gcodes");
if (!p_names || row_idx < 0 || row_idx >= (int)p_names->values.size()) return false;
const Row& r = m_rows[row_idx];
const RowSnapshot s = snapshot_row(r);
if (!r.is_primary && s.name != p_names->values[row_idx]) return true;
if (p_tools && row_idx < (int)p_tools->values.size() && s.tools != p_tools->values[row_idx]) return true;
if (p_gcodes && row_idx < (int)p_gcodes->values.size() && s.gcode != p_gcodes->values[row_idx]) return true;
return false;
}
bool IMEXModesCtrl::row_has_parent_counterpart(int row_idx) const {
if (!m_parent_lookup) return false;
const DynamicPrintConfig* parent = m_parent_lookup();
if (!parent) return false;
auto* p_names = parent->option<ConfigOptionStrings>("imex_mode_names");
return p_names && row_idx >= 0 && row_idx < (int)p_names->values.size();
}
void IMEXModesCtrl::add_row(const std::string& name,
const std::string& active_tools,
const std::string& gcode,
bool is_primary)
{
Row r;
r.is_primary = is_primary;
r.panel = new wxPanel(this, wxID_ANY);
r.panel->SetBackgroundColour(GetBackgroundColour());
auto* sizer = new wxBoxSizer(wxHORIZONTAL);
if (is_primary) {
r.orig_name = kImexPrimaryMode;
r.name = nullptr;
auto* lbl = new wxStaticText(r.panel, wxID_ANY, _L("Primary"),
wxDefaultPosition, FromDIP(wxSize(130, -1)));
wxFont f = lbl->GetFont();
f.SetWeight(wxFONTWEIGHT_BOLD);
lbl->SetFont(f);
sizer->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6));
} else {
// A nameless mode cannot be selected on a plate and used to be dropped on
// save together with its tool roles and G-code, so a name is pre-filled
// here and restored below if the field is left empty. That keeps the
// "+ Add Mode → assign tools → never typed a name" path from losing work
// without ever showing an error.
const std::string nm = name.empty() ? unique_mode_name({}) : name;
// The name as first shown, i.e. the one the plate's mode selector offered while this
// row looked like this. remove_row() reports it alongside the current name, because
// renaming is not routed through on_mode_removed and the two then diverge.
r.orig_name = nm;
r.name = new wxTextCtrl(r.panel, wxID_ANY, from_u8(nm), wxDefaultPosition, FromDIP(wxSize(130, -1)));
r.name->SetHint(_L("Mode name (required)"));
r.name->SetToolTip(_L("Name of this parallel mode, as it appears in the plate's IMEX mode "
"selector. Stored in the project by name, so renaming a mode that "
"plates already use makes them fall back to Primary. Cannot be empty "
"— a blank name is replaced with a generated one."));
r.name->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { notify(); });
// Restore a name rather than let the row reach get_mode_data() unnamed.
// Row is located by panel pointer (stable across add/remove) so a
// kill-focus delivered while the rows are being torn down is a no-op.
r.name->Bind(wxEVT_KILL_FOCUS, [this, panel = r.panel](wxFocusEvent& e) {
e.Skip();
if (m_clearing_rows) return; // focus-out emitted while the rows are being deleted
for (auto& row_ref : m_rows) {
if (row_ref.panel != panel) continue;
if (!row_ref.name || !row_ref.name->GetValue().empty()) return;
// ChangeValue(), not SetValue(): no nested wxEVT_TEXT.
row_ref.name->ChangeValue(from_u8(unique_mode_name({})));
notify();
return;
}
});
}
auto* grid_panel = new wxPanel(r.panel, wxID_ANY);
grid_panel->SetBackgroundColour(GetBackgroundColour());
auto* grid_sizer = new wxGridSizer(m_n_rows, m_n_cols, FromDIP(2), FromDIP(2));
auto tool_roles = roles_for_mode(active_tools);
if (tool_roles.empty())
tool_roles[0] = ImexRole::Primary; // default T0 → Primary when no assignment is stored
r.all_tool_roles = tool_roles; // preserve all assignments, including off-screen tools
wxPanel* this_panel = r.panel;
// Buttons rendered top=rear (high Y), bottom=front (low Y).
// Layout remapping: m_layout encodes which corner T0 is at physically.
// flip_x (layout 1,3): col 0 is on the right (max-X) instead of left
// flip_y (layout 2,3): row 0 is at the rear (max-Y) instead of front
// Display iterates: row from n_rows-1 down to 0 (rear→front = top→bottom).
// raw_row = flip_y ? (n_rows-1-row) : row
// raw_col = flip_x ? (n_cols-1-col) : col
//
// row_start: anchor displayed rows to the gantry row containing the Primary
// assignment, so reducing gantry count keeps the meaningful row visible.
int primary_gantry_row = 0;
for (const auto& [idx, role] : tool_roles) {
if (role == ImexRole::Primary) { primary_gantry_row = idx / m_n_cols; break; }
}
int row_start = primary_gantry_row; // display rows [row_start .. row_start+m_n_rows-1]
bool flip_x = (m_layout == 1 || m_layout == 3);
bool flip_y = (m_layout == 2 || m_layout == 3);
for (int row = m_n_rows - 1; row >= 0; --row) {
for (int col = 0; col < m_n_cols; ++col) {
int raw_row = flip_y ? (m_n_rows - 1 - row) : row;
raw_row += row_start; // anchor to Primary's gantry row
int raw_col = flip_x ? (m_n_cols - 1 - col) : col;
int tool_idx = raw_row * m_n_cols + raw_col;
std::optional<ImexRole> role; // nullopt == Inactive
auto it = tool_roles.find(tool_idx);
if (it != tool_roles.end()) role = it->second;
auto* btn = new wxButton(grid_panel, wxID_ANY, wxEmptyString,
wxDefaultPosition, FromDIP(wxSize(36, 26)), wxBU_EXACTFIT);
apply_btn(btn, tool_idx, role);
int btn_pos = (int)r.btns.size();
r.btns.push_back(btn);
r.btn_roles.push_back(role);
r.btn_tool_idx.push_back(tool_idx);
// Two tiles are read-only.
//
// The whole reserved `primary` row: it is the IMEX-off mode, so cycling roles there
// would be a no-op and confusing.
//
// And, on any row, the tile that currently HOLDS Primary. Primary is tool 0 and moves
// only via Tool 0 Position; letting a click demote it would leave the mode with no
// Primary at all, which parses to -1 and degrades the plate to an ordinary single-tool
// print with no zones — authored silently, in one click, with nothing in the editor
// showing what is wrong. The other tiles already never OFFER Primary while one is held
// (role_allowed_on_tile), so this closes the only remaining route to that state.
// A mode that reaches us WITHOUT a Primary (hand-edited preset, foreign 3MF) still has
// every tile live, so it can be repaired by promoting one.
if (is_primary || role == ImexRole::Primary) {
btn->Disable();
} else {
btn->Bind(wxEVT_BUTTON, [this, this_panel, btn_pos](wxCommandEvent&) {
// Find the row by panel pointer (stable across add/remove)
for (auto& row_ref : m_rows) {
if (row_ref.panel != this_panel) continue;
std::optional<ImexRole>& tile = row_ref.btn_roles[btn_pos];
const int tidx = row_ref.btn_tool_idx[btn_pos];
// Check if another button already holds the Primary role,
// and locate the primary's gantry row for Span eligibility.
bool other_primary = false;
int primary_gantry = -1;
for (int j = 0; j < (int)row_ref.btn_roles.size(); ++j) {
if (row_ref.btn_roles[j] == ImexRole::Primary) {
if (j != btn_pos) other_primary = true;
primary_gantry = row_ref.btn_tool_idx[j] / m_n_cols;
}
}
// Cycle Inactive → Primary → Copy → Mirror → Span → Inactive,
// skipping whatever this tile may not take right now.
tile = next_tile_role(tile, [&](ImexRole cand) {
return role_allowed_on_tile(cand, tidx, other_primary, primary_gantry);
});
// Keep all_tool_roles in sync so off-screen tools are preserved
if (tile)
row_ref.all_tool_roles[tidx] = *tile;
else
row_ref.all_tool_roles.erase(tidx);
apply_btn(row_ref.btns[btn_pos], row_ref.btn_tool_idx[btn_pos], tile);
notify();
break;
}
});
}
grid_sizer->Add(btn, 0);
}
}
grid_panel->SetSizerAndFit(grid_sizer);
// from_u8(): `gcode` arrives from the preset as UTF-8. Handing the raw std::string to
// wxString would decode it through the current locale's encoding instead.
r.gcode = new wxTextCtrl(r.panel, wxID_ANY, from_u8(gcode),
wxDefaultPosition, FromDIP(wxSize(220, 54)), wxTE_MULTILINE);
// Tooltip only, no SetHint(): wxTextEntry has no native placeholder for a
// multiline control, so wxWidgets emulates one by writing the hint into the
// control as grey text — indistinguishable from real G-code in this box.
r.gcode->SetToolTip(_L("G-code emitted once at the start of a print that uses this mode, before "
"the machine start G-code. This is where the printer is put into the "
"matching firmware mode — for example a Klipper SET_PRINT_MODE call or a "
"RepRapFirmware M567 — since the slicer only emits the Primary tool's "
"paths and the firmware drives the Copy / Mirror tools. Placeholders are "
"supported; use the edit button to browse them. Leave empty if the mode "
"needs no firmware setup."));
r.gcode->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { notify(); });
if (!is_primary) {
sizer->Add(r.name, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6));
}
sizer->Add(grid_panel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6));
sizer->Add(r.gcode, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4));
// Edit + remove (for non-primary) — stacked vertically.
auto* btn_col = new wxBoxSizer(wxVERTICAL);
wxTextCtrl* gcode_ctrl = r.gcode;
auto* ph_btn = new ScalableButton(r.panel, wxID_ANY, "edit", wxEmptyString,
wxDefaultSize, wxDefaultPosition,
wxBU_EXACTFIT | wxNO_BORDER, 16);
ph_btn->SetToolTip(_L("Edit G-code / browse placeholders"));
ph_btn->Bind(wxEVT_BUTTON, [this, gcode_ctrl](wxCommandEvent&) {
// EditGCodeDialog takes and returns UTF-8 (get_edited_gcode() is a ToUTF8()).
EditGCodeDialog dlg(this, "imex_mode_gcode", into_u8(gcode_ctrl->GetValue()));
if (dlg.ShowModal() == wxID_OK)
gcode_ctrl->SetValue(from_u8(dlg.get_edited_gcode()));
});
btn_col->Add(ph_btn, 0, wxBOTTOM, FromDIP(2));
if (!is_primary) {
auto* rm = new ScalableButton(r.panel, wxID_ANY, "imex_remove", wxEmptyString,
wxDefaultSize, wxDefaultPosition,
wxBU_EXACTFIT | wxNO_BORDER, 16);
rm->SetToolTip(_L("Remove mode"));
rm->Bind(wxEVT_BUTTON, [this, this_panel](wxCommandEvent&) {
const std::vector<std::string> removed = remove_row(this_panel);
// Copied before notify(): on_change ends in on_value_change, which can send
// the tab through update() and hence load_from_config(), and that rebuilds
// every row. Reading the member afterwards would be reading through a
// handler whose owner has just been rebuilt underneath it.
auto removed_cb = on_mode_removed;
notify();
if (removed_cb && !removed.empty())
removed_cb(removed);
});
btn_col->Add(rm, 0);
}
sizer->Add(btn_col, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4));
// Per-row reset gets its own column on the right so the icon reads as
// distinct from the edit/remove column. Reset only renders when this row
// has a counterpart in the saved preset (user-added rows beyond the saved
// mode count get no reset — the X button covers "remove user-added row").
auto* reset_col = new wxBoxSizer(wxVERTICAL);
wxPanel* this_panel_for_reset = r.panel;
if (row_has_parent_counterpart(static_cast<int>(m_rows.size()))) {
r.reset_btn = new ScalableButton(r.panel, wxID_ANY, "dot", wxEmptyString,
wxDefaultSize, wxDefaultPosition,
wxBU_EXACTFIT | wxNO_BORDER, 16);
r.reset_btn->SetToolTip(_L("Discard in-session edits to this mode (snap back to saved value)"));
r.reset_btn->Bind(wxEVT_BUTTON, [this, this_panel_for_reset](wxCommandEvent&) {
reset_row_to_parent(this_panel_for_reset);
});
reset_col->Add(r.reset_btn, 0, wxALIGN_CENTER_VERTICAL);
}
sizer->Add(reset_col, 0, wxALIGN_CENTER_VERTICAL);
r.panel->SetSizerAndFit(sizer);
m_rows_sizer->Add(r.panel, 0, wxEXPAND | wxBOTTOM, FromDIP(4));
m_rows.push_back(std::move(r));
Layout();
}
std::vector<std::string> IMEXModesCtrl::remove_row(wxPanel* panel) {
for (size_t i = 0; i < m_rows.size(); ++i) {
if (m_rows[i].panel != panel) continue;
if (m_rows[i].is_primary) return {}; // primary row is non-deletable
// Both names a plate can be holding for this row: the one the row was built with and
// the one in the field now. They diverge as soon as the row is renamed, because a
// rename does not rebuild the row and is deliberately not reported as a removal (the
// name field notifies on every keystroke). Reporting only the current name is what
// let "rename, then delete" leave the plate on a mode that no longer exists.
std::vector<std::string> removed_names;
const std::string current = m_rows[i].name ? into_u8(m_rows[i].name->GetValue())
: std::string();
for (const std::string& n : {m_rows[i].orig_name, current})
if (!n.empty() && std::find(removed_names.begin(), removed_names.end(), n) == removed_names.end())
removed_names.push_back(n);
m_rows_sizer->Detach(panel);
m_rows.erase(m_rows.begin() + i);
// A name a surviving row still carries is not stale: the user can rename row A to
// row B's old name and then delete B. Plates holding it now resolve to A, so drop it
// rather than reset them.
auto still_in_use = [this](const std::string& n) {
return std::any_of(m_rows.begin(), m_rows.end(), [&n](const Row& r) {
return r.is_primary ? n == kImexPrimaryMode
: r.name && into_u8(r.name->GetValue()) == n;
});
};
removed_names.erase(std::remove_if(removed_names.begin(), removed_names.end(), still_in_use),
removed_names.end());
Layout();
// Defer widget destruction so any in-flight GTK events for
// panel's children (including the × button we're inside) finish
// processing before the wxEvtHandlers are freed.
wxTheApp->CallAfter([panel]() { panel->Destroy(); });
return removed_names;
}
return {};
}
void IMEXModesCtrl::clear_rows() {
// Destroying a focused wxTextCtrl emits wxEVT_KILL_FOCUS, and the Name field's
// handler would then read m_rows while it is half-destroyed. Flag the teardown
// so that handler stays out of the way.
m_clearing_rows = true;
for (auto& r : m_rows) { m_rows_sizer->Detach(r.panel); r.panel->Destroy(); }
m_rows.clear();
m_clearing_rows = false;
}
std::string IMEXModesCtrl::active_tools_string(const Row& r) const {
// Serialize from all_tool_roles (not just visible buttons) so assignments
// for off-screen tools are preserved across grid size changes. Inactive tools are
// simply absent from the map, so there is nothing to filter out here.
//
// The letters come from imex_role_letter(), the same table parse_imex_active_tools()
// reads back — this used to be a second, independent int → letter switch, which is
// exactly where the editor and the slicer could come to disagree about a mode string.
std::string s;
for (const auto& [idx, role] : r.all_tool_roles) {
if (!s.empty()) s += ',';
s += std::to_string(idx) + ':' + imex_role_letter(role);
}
return s;
}
void IMEXModesCtrl::notify() {
refresh_reset_buttons();
if (on_change) on_change();
}
void IMEXModesCtrl::refresh_reset_buttons() {
for (size_t i = 0; i < m_rows.size(); ++i) {
Row& r = m_rows[i];
if (!r.reset_btn || !r.reset_btn->IsEnabled()) continue;
const bool dirty = row_differs_from_parent(static_cast<int>(i));
if (dirty == r.reset_dirty_cached) continue;
r.reset_dirty_cached = dirty;
r.reset_btn->SetBitmap_(dirty ? "undo" : "dot");
}
}
void IMEXModesCtrl::reset_row_to_parent(wxPanel* panel) {
if (!m_parent_lookup) return;
const DynamicPrintConfig* parent = m_parent_lookup();
if (!parent) return;
auto* p_names = parent->option<ConfigOptionStrings>("imex_mode_names");
auto* p_tools = parent->option<ConfigOptionStrings>("imex_mode_active_tools");
auto* p_gcodes = parent->option<ConfigOptionStrings>("imex_mode_gcodes");
for (size_t i = 0; i < m_rows.size(); ++i) {
Row& r = m_rows[i];
if (r.panel != panel) continue;
if (!p_names || i >= p_names->values.size()) return;
if (!r.is_primary && r.name)
r.name->ChangeValue(from_u8(p_names->values[i]));
if (p_gcodes && i < p_gcodes->values.size())
r.gcode->ChangeValue(from_u8(p_gcodes->values[i]));
if (p_tools && i < p_tools->values.size()) {
// Reapply tool roles from the parent's serialized form. all_tool_roles
// is the source of truth for round-tripping; rebuild it then re-paint
// the visible buttons.
r.all_tool_roles = roles_for_mode(p_tools->values[i]);
for (size_t j = 0; j < r.btns.size(); ++j) {
int tidx = r.btn_tool_idx[j];
auto it = r.all_tool_roles.find(tidx);
std::optional<ImexRole> role;
if (it != r.all_tool_roles.end()) role = it->second;
r.btn_roles[j] = role;
apply_btn(r.btns[j], tidx, role);
}
}
notify();
return;
}
}
}} // namespace Slic3r::GUI
+242
View File
@@ -0,0 +1,242 @@
#ifndef slic3r_IMEXModesCtrl_hpp_
#define slic3r_IMEXModesCtrl_hpp_
#include <wx/panel.h>
#include <wx/string.h>
#include <wx/sizer.h>
#include <wx/button.h>
#include <wx/textctrl.h>
#include <functional>
#include <map>
#include <optional>
#include <string>
#include <tuple>
#include <vector>
#include "libslic3r/IMEXHelpers.hpp"
class ScalableButton;
namespace Slic3r {
namespace GUI {
// ---------------------------------------------------------------------------
// IMEXModesCtrl — visual parallel-mode editor for the IDEX/IQEX printer tab
//
// A tool tile holds an `std::optional<ImexRole>`: the role the mode gives that tool, or
// nullopt for Inactive (grey — the tool does not take part in this mode). Inactive is the
// editor's own state and the only reason the tile is not a bare ImexRole; it is the absence
// of a role, so it is spelled as one rather than as an extra enumerator that libslic3r would
// then have to keep rejecting. Roles themselves are never re-encoded here — no int state
// table, no second copy of the on-disk letters.
//
// nullopt (grey) — not participating in this mode
// ImexRole::Primary (green) — the tool the slicer generates paths for
// ImexRole::Copy (blue) — firmware duplicates Primary at an offset
// ImexRole::Mirror (amber) — firmware mirrors Primary about an axis
// ImexRole::Span (yellow)— multicolor partner of Primary on the same gantry;
// declares paired-gantry multicolor topology and
// unlocks the multicolor block at slice time. Only
// offered on tools sharing primary's gantry, and
// only on multi-gantry printers (n_rows >= 2).
//
// Colour and legend text are the editor's business and live in role_style(); the tile cycle
// order and the serialized letters come from kImexRoleTable in IMEXHelpers.hpp, so the
// editor cannot drift from the slicer's reading of a mode string.
//
// Active-tools string format: "idx:P,idx:C,idx:M,idx:S" (backwards-compat: plain "idx" = Primary)
// ---------------------------------------------------------------------------
class IMEXModesCtrl : public wxPanel {
public:
std::function<void()> on_change;
// Fired after on_change has written the shortened table back to the preset, with EVERY
// name the deleted row could be known by: the name the row was built with (what a plate
// set from the saved preset holds) and the name in the field at delete time (what a plate
// set after an in-session rename holds). Renaming is deliberately not routed through here
// -- the name field notifies on every keystroke -- so by the time the row is deleted the
// two can differ, and reporting only the current name leaves the plate pointing at a mode
// that no longer exists. Names a surviving row still carries are filtered out, so renaming
// one row to another's old name and then deleting cannot reset a plate that legitimately
// resolves elsewhere. Plates still set to any reported name are stale from this point on
// -- see the handler in TabPrinter::build_fff.
std::function<void(const std::vector<std::string>&)> on_mode_removed;
// Lazy lookup so the widget always sees the current parent (preset switches
// change the parent under us). Returns nullptr when the active preset has no
// parent (system preset itself), which disables per-row reset arrows.
using ParentConfigLookup = std::function<const DynamicPrintConfig*()>;
void set_parent_config_lookup(ParentConfigLookup f) { m_parent_lookup = std::move(f); }
// layout: 0=front-left, 1=front-right, 2=rear-left, 3=rear-right (T0 corner).
// The enum values intentionally line up 1:1 so this is a no-op cast — the helper
// is kept to make the conversion explicit at call sites.
static int parse_layout(ImexToolLayout layout) { return static_cast<int>(layout); }
IMEXModesCtrl(wxWindow* parent, int n_cols, int n_rows, int layout = 0);
void set_grid_size(int n_cols, int n_rows, int layout = -1);
void load_from_config(const DynamicPrintConfig& cfg);
// Every row on screen produces exactly one entry — a row is never skipped.
// Skipping empty-named rows used to drop the row's tool roles and G-code
// silently, and because matches_config() compares against this same filtered
// output the preset did not even go dirty to hint that something had gone
// missing; the row stayed on screen until the next load_from_config() quietly
// removed it. add_row() pre-fills a name and the Name field restores one when
// it is left empty, so the substitution below is the last-resort guard for a
// name that arrives empty from a hand-edited preset or 3MF.
std::tuple<std::vector<std::string>, std::vector<std::string>, std::vector<std::string>>
get_mode_data() const;
// True when the widget's current rows already mirror the three IMEX-mode option
// vectors in cfg. Used by TabPrinter::update_fff() to skip a destroy-and-rebuild
// pass when the only thing that just changed was the widget itself writing back
// to config — without this guard, every keystroke in the gcode textbox triggers
// a load_from_config() that detaches the textbox the user is typing in.
bool matches_config(const DynamicPrintConfig& cfg) const;
private:
// map<physical tool idx, role> for one `imex_mode_active_tools` entry. A tool absent
// from the map is Inactive — the same convention the serialized string uses, so the
// round trip needs no separate "off" value.
//
// Deliberately NOT a parser of its own: the slicer decides what actually prints,
// so the editor has to read the string exactly the way the slicer does or the
// same mode means two different things in the two places. Roles come from
// parse_imex_active_tools(); the primary index comes from
// imex_primary_tool_for_mode(), which is where the "a bare index is Primary"
// backwards-compat rule lives. Both are needed: parse_imex_active_tools()
// defaults a bare token to Copy (deliberately — see the note above
// parse_imex_active_tools() in IMEXHelpers.hpp), so only the combination
// reproduces the slicer's reading of a legacy "0,1,2" string, which is
// "T0 primary, the rest copies".
//
// Indices are PHYSICAL tool indices (T0..TN-1). all_tool_roles preserves
// off-grid entries so the tile widget can round-trip indices that fall
// outside the currently visible rows × cols without losing data on save.
static std::map<int, ImexRole> roles_for_mode(const std::string& active_tools);
// "Mode N" for the lowest N >= 2 not already used by a row or by `also_taken`
// (N == 1 is conceptually the fixed Primary row). Deterministic for a given set
// of rows, so get_mode_data() is stable across calls and matches_config() stays
// honest. Intentionally NOT translated: mode names are identifiers — objects
// store one in `imex_parallel_mode` and GCode.cpp matches it against
// `imex_mode_names` by string — so a locale-dependent name would break a project
// opened under a different language.
std::string unique_mode_name(const std::vector<std::string>& also_taken) const;
// Everything the editor draws for one role, in ONE place: a new role needs a colour and
// a legend name here and nowhere else in this file. nullopt is Inactive (grey).
// `label` is deliberately untranslated — it names a role, and the legend sits next to
// the same letters the preset stores.
struct RoleStyle { wxColour bg; wxColour fg; const char* label; };
static RoleStyle role_style(std::optional<ImexRole> role);
// True when this printer offers `role` at all, regardless of which tile is clicked.
// Span needs a second gantry to be a partner across, so a single-gantry printer neither
// cycles to it nor lists it in the legend.
bool role_offered(ImexRole role) const;
// True when the tile for `tool_idx` may take `role` right now. Adds the positional rules
// on top of role_offered(): only one tile may hold Primary, and Span only on a tile
// sharing the Primary's gantry.
bool role_allowed_on_tile(ImexRole role, int tool_idx,
bool other_primary, int primary_gantry) const;
// Next state in the tile cycle: Inactive → kImexRoleTable order → Inactive, stepping
// over any role `allowed` rejects. Inactive is always reachable, so it terminates.
static std::optional<ImexRole> next_tile_role(std::optional<ImexRole> current,
const std::function<bool(ImexRole)>& allowed);
void apply_btn(wxButton* btn, int tool_idx, std::optional<ImexRole> role);
struct Row {
wxPanel* panel;
wxTextCtrl* name; // nullptr for primary row (name is fixed)
wxTextCtrl* gcode;
std::vector<wxButton*> btns;
std::vector<std::optional<ImexRole>> btn_roles; // nullopt == Inactive
std::vector<int> btn_tool_idx;
std::map<int, ImexRole> all_tool_roles; // absent == Inactive
// Name the row was built with (UTF-8), i.e. the name that was in the preset — and so
// in the plate's mode selector — when the row appeared. Kept because the name field is
// free to drift from it without the row being rebuilt; remove_row() needs both to find
// the plates a delete strands. Rebuilding the rows (load_from_config / set_grid_size)
// re-seeds it, which is correct: config and rows agree again at that point.
std::string orig_name;
bool is_primary {false};
ScalableButton* reset_btn {nullptr}; // nullptr when row has no parent counterpart
bool reset_dirty_cached {false};
};
// Snapshot of row content as it would be saved (matches get_mode_data() per-row).
struct RowSnapshot { std::string name, tools, gcode; };
RowSnapshot snapshot_row(const Row& r) const;
// True when row at `row_idx` differs from the parent preset's value at the same
// index. Returns false when there is no parent (system preset), when the row is
// beyond the parent's mode count (user added it — no defined "default"), or when
// the lookup callback isn't wired. The Primary row's name is sentinel-fixed so
// a name diff doesn't count for it; only tools/gcode do.
bool row_differs_from_parent(int row_idx) const;
// True when row index has a parent counterpart at all (i.e., the reset arrow
// should be present on the row at all, regardless of dirty state).
bool row_has_parent_counterpart(int row_idx) const;
void add_row(const std::string& name = "",
const std::string& active_tools = "",
const std::string& gcode = "",
bool is_primary = false);
// Returns every name the removed row was known by (UTF-8, deduplicated: its build-time
// name and its current name, which differ after an in-session rename), minus any name a
// surviving row still carries. The caller uses them to find the plates the delete
// stranded. Empty when nothing was removed, when the row is the non-deletable Primary,
// or when both names are blank -- a blank name is only reachable on a row that was never
// named, which no plate can be holding.
std::vector<std::string> remove_row(wxPanel* panel);
void clear_rows();
std::string active_tools_string(const Row& r) const;
void notify();
// Update each row's reset bitmap to reflect current dirty state. Cached so we
// only swap the bitmap when state actually transitions — avoids flicker on
// every keystroke. Disabled buttons (no saved counterpart) keep their dot.
void refresh_reset_buttons();
// Reset row identified by `panel` (stable across add/remove) to the parent
// preset's value at the same index. Primary row's name stays sentinel-fixed —
// only tools and gcode are restored.
void reset_row_to_parent(wxPanel* panel);
// Rebuild the instruction text, colour legend and column headers for the CURRENT grid.
// These depend on the grid shape -- the Span role is only offered on a multi-gantry printer,
// and the header spacing is derived from the column count -- so they cannot be built once in
// the constructor: adding a second gantry has to reveal Span without a save-and-reopen cycle.
void rebuild_info_and_header();
// One-line explanation per role, shown as a tooltip on that role's legend swatch and label.
// Keeps the per-role detail out of the instruction paragraph.
static wxString role_hint(ImexRole role);
wxBoxSizer* m_outer;
wxBoxSizer* m_rows_sizer;
wxPanel* m_info_panel = nullptr;
wxPanel* m_hdr_panel = nullptr;
std::vector<Row> m_rows;
int m_n_cols, m_n_rows;
int m_layout {0}; // 0=front-left, 1=front-right, 2=rear-left, 3=rear-right
bool m_clearing_rows {false}; // guards handlers against teardown-time events
ParentConfigLookup m_parent_lookup;
};
}} // namespace Slic3r::GUI
#endif // slic3r_IMEXModesCtrl_hpp_