From 7688f7a16cb90cf4c160131c3a9699969d59d892 Mon Sep 17 00:00:00 2001 From: KrishManan Date: Sat, 30 May 2026 17:44:24 +0800 Subject: [PATCH 1/7] Add dropdown item action icons --- src/slic3r/GUI/Widgets/ComboBox.cpp | 37 ++++++++++++- src/slic3r/GUI/Widgets/ComboBox.hpp | 2 + src/slic3r/GUI/Widgets/DropDown.cpp | 82 +++++++++++++++++++++++++++-- src/slic3r/GUI/Widgets/DropDown.hpp | 4 ++ 4 files changed, 119 insertions(+), 6 deletions(-) diff --git a/src/slic3r/GUI/Widgets/ComboBox.cpp b/src/slic3r/GUI/Widgets/ComboBox.cpp index 783e1caadf..b0d3746ad2 100644 --- a/src/slic3r/GUI/Widgets/ComboBox.cpp +++ b/src/slic3r/GUI/Widgets/ComboBox.cpp @@ -69,6 +69,11 @@ ComboBox::ComboBox(wxWindow *parent, e.SetId(GetId()); GetEventHandler()->ProcessEvent(e); }); + drop.Bind(EVT_DROPDOWN_ITEM_ACTION, [this](wxCommandEvent &e) { + e.SetEventObject(this); + e.SetId(GetId()); + GetEventHandler()->ProcessEvent(e); + }); drop.Bind(EVT_DISMISS, [this](auto &) { drop_down = false; wxCommandEvent e(wxEVT_COMBOBOX_CLOSEUP); @@ -222,7 +227,7 @@ int ComboBox::Append(const wxString &text, int style) { auto valid_bit_map = (&bitmap && bitmap.IsOk()) ? bitmap : wxNullBitmap; - Item item{text, wxEmptyString, valid_bit_map, valid_bit_map, clientData, group_key, group_label}; + Item item{text, wxEmptyString, valid_bit_map, valid_bit_map, wxNullBitmap, clientData, group_key, group_label}; item.style = style; items.push_back(item); SetClientDataType(wxClientData_Void); @@ -268,6 +273,27 @@ void ComboBox::ForceDropdownOpen() } } +void ComboBox::RefreshDropdownIfOpen() +{ + if (!drop_down) + return; + + drop.hover_item = -1; + drop.offset = wxPoint(); + if (drop.subDropDown) { + drop.subDropDown->group.clear(); + drop.subDropDown->hover_item = -1; + drop.subDropDown->offset = wxPoint(); + if (drop.subDropDown->IsShown()) + drop.subDropDown->Dismiss(); + } + + drop.need_sync = true; + drop.messureSize(); + drop.autoPosition(); + drop.paintNow(); +} + unsigned int ComboBox::GetCount() const { return items.size(); } void ComboBox::set_replace_text(wxString text, wxString image_name) @@ -321,6 +347,13 @@ void ComboBox::SetItemBitmap(unsigned int n, wxBitmap const &bitmap) drop.Invalidate(); } +void ComboBox::SetItemActionBitmap(unsigned int n, wxBitmap const &bitmap) +{ + if (n >= items.size()) return; + items[n].action_icon = (&bitmap && bitmap.IsOk()) ? bitmap : wxNullBitmap; + drop.Invalidate(); +} + int ComboBox::DoInsertItems(const wxArrayStringsAdapter &items, unsigned int pos, void ** clientData, @@ -328,7 +361,7 @@ int ComboBox::DoInsertItems(const wxArrayStringsAdapter &items, { if (pos > this->items.size()) return -1; for (int i = 0; i < items.GetCount(); ++i) { - Item item { items[i], wxEmptyString, wxNullBitmap, wxNullBitmap, clientData ? clientData[i] : NULL }; + Item item { items[i], wxEmptyString, wxNullBitmap, wxNullBitmap, wxNullBitmap, clientData ? clientData[i] : NULL }; this->items.insert(this->items.begin() + pos, item); ++pos; } diff --git a/src/slic3r/GUI/Widgets/ComboBox.hpp b/src/slic3r/GUI/Widgets/ComboBox.hpp index 552909b477..241458a7dd 100644 --- a/src/slic3r/GUI/Widgets/ComboBox.hpp +++ b/src/slic3r/GUI/Widgets/ComboBox.hpp @@ -80,10 +80,12 @@ public: wxBitmap GetItemBitmap(unsigned int n); void SetItemBitmap(unsigned int n, wxBitmap const &bitmap); + void SetItemActionBitmap(unsigned int n, wxBitmap const &bitmap); bool is_drop_down(){return drop_down;} void DeleteOneItem(unsigned int pos) { DoDeleteOneItem(pos); } void ForceDropdownOpen(); + void RefreshDropdownIfOpen(); protected: virtual int DoInsertItems(const wxArrayStringsAdapter &items, diff --git a/src/slic3r/GUI/Widgets/DropDown.cpp b/src/slic3r/GUI/Widgets/DropDown.cpp index 973113d0ac..7cb9aa6709 100644 --- a/src/slic3r/GUI/Widgets/DropDown.cpp +++ b/src/slic3r/GUI/Widgets/DropDown.cpp @@ -13,6 +13,7 @@ #include wxDEFINE_EVENT(EVT_DISMISS, wxCommandEvent); +wxDEFINE_EVENT(EVT_DROPDOWN_ITEM_ACTION, wxCommandEvent); BEGIN_EVENT_TABLE(DropDown, PopupWindow) @@ -225,6 +226,29 @@ static wxSize GetBmpSize(wxBitmap & bmp) #endif } +wxSize DropDown::actionIconSize() const +{ + wxSize size; + std::set groups; + for (const Item& item : items) { + if (group.IsEmpty()) { + if (!item.group_key.IsEmpty()) { + if (groups.find(item.group_key) != groups.end()) + continue; + groups.insert(item.group_key); + } + } else if (item.group_key != group) { + continue; + } + + wxBitmap icon = item.action_icon; + wxSize icon_size = GetBmpSize(icon); + size.x = std::max(size.x, icon_size.x); + size.y = std::max(size.y, icon_size.y); + } + return size; +} + static void _DrawSplitItem(const wxWindow* w, wxDC& dc, wxString split_text, wxPoint start_pt, int item_width, int item_height) { // save dc @@ -354,6 +378,9 @@ void DropDown::render(wxDC &dc) rcContent.width -= szBmp.x + 5; } + const wxSize action_icon_size = actionIconSize(); + const int action_column_width = action_icon_size.x > 0 ? action_icon_size.x + FromDIP(16) : 0; + std::set groups; // draw texts & icons int index = 0; @@ -392,6 +419,7 @@ void DropDown::render(wxDC &dc) } if (rcContent.y > size.y) break; wxPoint pt = rcContent.GetLeftTop(); + int rcContent_width = rcContent.width; if (item.style & DD_ITEM_STYLE_SPLIT_ITEM) { _DrawSplitItem(this, dc, item.text, pt, rowSize.GetWidth(), rowSize.GetHeight()); @@ -417,13 +445,27 @@ void DropDown::render(wxDC &dc) auto text = group.IsEmpty() ? (item.group_key.IsEmpty() ? item.text : item.group_label) : item.text; + if (action_column_width > 0) + rcContent.width -= action_column_width; + + if (item.action_icon.IsOk()) { + auto action_size = GetBmpSize(item.action_icon); + wxPoint action_pt(rcContent.x + rcContent_width - action_size.x - FromDIP(8), + rcContent.y + (rcContent.height - action_size.y) / 2); + dc.DrawBitmap(item.action_icon, action_pt); + } + if (!text_off && !text.IsEmpty()) { wxSize tSize = dc.GetMultiLineTextExtent(text); if (pt.x + tSize.x > rcContent.GetRight()) { - if (is_hover && item.tip.IsEmpty()) - SetToolTip(text); - text = wxControl::Ellipsize(text, dc, wxELLIPSIZE_END, - rcContent.GetRight() - pt.x); + int max_text_width = rcContent.GetRight() - pt.x; + if (max_text_width <= 0) { + text.clear(); + } else { + if (is_hover && item.tip.IsEmpty()) + SetToolTip(text); + text = wxControl::Ellipsize(text, dc, wxELLIPSIZE_END, max_text_width); + } } pt.y += (rcContent.height - textSize.y) / 2; dc.SetFont(GetFont()); @@ -436,6 +478,7 @@ void DropDown::render(wxDC &dc) dc.DrawBitmap(arrow_bitmap.bmp(), pt); } } + rcContent.width = rcContent_width; rcContent.y += rowSize.y; } } @@ -545,6 +588,8 @@ void DropDown::messureSize() size1.x += size2.x + (text_off ? 0 : 5); } } + if (item.action_icon.IsOk()) + size1.x += GetBmpSize(item.action_icon).x + FromDIP(16); if (size1.x > textSize.x) textSize = size1; } if (!align_icon) iconSize.x = 0; @@ -616,6 +661,11 @@ void DropDown::messureSize() e.SetId(GetId()); GetEventHandler()->ProcessEvent(e); }); + subDropDown->Bind(EVT_DROPDOWN_ITEM_ACTION, [this](wxCommandEvent &e) { + e.SetEventObject(this); + e.SetId(GetId()); + GetEventHandler()->ProcessEvent(e); + }); } need_sync = false; } @@ -694,6 +744,19 @@ void DropDown::mouseReleased(wxMouseEvent& event) if (hover_item < 0) return; if (hover_item >= 0 && (subDropDown == nullptr || subDropDown->group.empty())) { // not moved + int index = hoverIndex(); + if (index >= 0 && items[index].action_icon.IsOk()) { + wxSize action_size = actionIconSize(); + int action_right = GetSize().x; + if (rowSize.y * count > GetSize().y) + action_right -= 6; + int action_left = action_right - action_size.x - FromDIP(16); + if (event.GetX() >= action_left && event.GetX() <= action_right) { + sendDropDownActionEvent(index); + paintNow(); + return; + } + } sendDropDownEvent(); if (mainDropDown) mainDropDown->hover_item = -1; // To Dismiss mainDropDown @@ -810,6 +873,17 @@ void DropDown::sendDropDownEvent() GetEventHandler()->ProcessEvent(event); } +void DropDown::sendDropDownActionEvent(int index) +{ + if (index < 0 || index >= int(items.size()) || (items[index].style & DD_ITEM_STYLE_DISABLED)) + return; + wxCommandEvent event(EVT_DROPDOWN_ITEM_ACTION, GetId()); + event.SetEventObject(this); + event.SetInt(index); + event.SetString(items[index].text); + GetEventHandler()->ProcessEvent(event); +} + void DropDown::Dismiss() { if (subDropDown && subDropDown->IsShown()) diff --git a/src/slic3r/GUI/Widgets/DropDown.hpp b/src/slic3r/GUI/Widgets/DropDown.hpp index 09041e3dc0..a2ad873b94 100644 --- a/src/slic3r/GUI/Widgets/DropDown.hpp +++ b/src/slic3r/GUI/Widgets/DropDown.hpp @@ -15,6 +15,7 @@ #define DD_ITEM_STYLE_DISABLED 0x0002 // ----text----, text with horizontal line arounds wxDECLARE_EVENT(EVT_DISMISS, wxCommandEvent); +wxDECLARE_EVENT(EVT_DROPDOWN_ITEM_ACTION, wxCommandEvent); class DropDown : public PopupWindow { @@ -25,6 +26,7 @@ public: wxString text_static_tips;// display static tips for TextInput.eg.PrinterInfoBox wxBitmap icon; wxBitmap icon_textctrl;// display icon for TextInput.eg.PrinterInfoBox + wxBitmap action_icon; void * data{nullptr}; wxString group_key{}; wxString group_label{}; @@ -123,6 +125,7 @@ private: int hoverIndex(); int selectedItem(); + wxSize actionIconSize() const; friend class ComboBox; void messureSize(); @@ -136,6 +139,7 @@ private: void mouseWheelMoved(wxMouseEvent &event); void sendDropDownEvent(); + void sendDropDownActionEvent(int index); DECLARE_EVENT_TABLE() From fb6c93963dae3f13bfec4859d9804f267f80af42 Mon Sep 17 00:00:00 2001 From: KrishManan Date: Sat, 30 May 2026 17:44:51 +0800 Subject: [PATCH 2/7] Add AMS filament favorites --- src/slic3r/GUI/AMSMaterialsSetting.cpp | 201 +++++++++++++++++++------ src/slic3r/GUI/AMSMaterialsSetting.hpp | 17 ++- 2 files changed, 170 insertions(+), 48 deletions(-) diff --git a/src/slic3r/GUI/AMSMaterialsSetting.cpp b/src/slic3r/GUI/AMSMaterialsSetting.cpp index 4a429a4a5a..f5aa15df94 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.cpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.cpp @@ -2,6 +2,7 @@ #include "ExtrusionCalibration.hpp" #include "MsgDialog.hpp" #include "GUI_App.hpp" +#include "libslic3r/Config.hpp" #include "libslic3r/Preset.hpp" #include "I18N.hpp" #include @@ -116,7 +117,8 @@ void AMSMaterialsSetting::create() Bind(wxEVT_PAINT, &AMSMaterialsSetting::paintEvent, this); Bind(EVT_SELECTED_COLOR, &AMSMaterialsSetting::on_picker_color, this); - m_comboBox_filament->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_filament), NULL, this); + m_comboBox_filament->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_filament), NULL, this); + m_comboBox_filament->Bind(EVT_DROPDOWN_ITEM_ACTION, &AMSMaterialsSetting::on_toggle_favorite_filament, this); m_comboBox_cali_result->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_cali_result), NULL, this); } @@ -135,11 +137,7 @@ void AMSMaterialsSetting::create_panel_normal(wxWindow* parent) m_sizer_filament->Add(0, 0, 0, wxEXPAND, 0); -#ifdef __APPLE__ - m_comboBox_filament = new wxComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, AMS_MATERIALS_SETTING_COMBOX_WIDTH, 0, nullptr, wxCB_READONLY); -#else m_comboBox_filament = new ::ComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, AMS_MATERIALS_SETTING_COMBOX_WIDTH, 0, nullptr, wxCB_READONLY); -#endif m_sizer_filament->Add(m_comboBox_filament, 1, wxALIGN_CENTER, 0); @@ -371,6 +369,7 @@ void AMSMaterialsSetting::paintEvent(wxPaintEvent &evt) AMSMaterialsSetting::~AMSMaterialsSetting() { m_comboBox_filament->Disconnect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_filament), NULL, this); + m_comboBox_filament->Unbind(EVT_DROPDOWN_ITEM_ACTION, &AMSMaterialsSetting::on_toggle_favorite_filament, this); m_comboBox_cali_result->Disconnect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_cali_result), NULL, this); } @@ -472,10 +471,9 @@ void AMSMaterialsSetting::on_select_reset(wxCommandEvent& event) { std::string selected_ams_id; PresetBundle *preset_bundle = wxGetApp().preset_bundle; if (preset_bundle) { + const FilamentInfos* selected_info = selected_filament_info(); for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) { - auto filament_item = map_filament_items[m_comboBox_filament->GetValue().ToStdString()]; - std::string filament_id = filament_item.filament_id; - if (it->filament_id.compare(filament_id) == 0) { + if (selected_info && it->filament_id.compare(selected_info->filament_id) == 0) { selected_ams_id = it->filament_id; break; } @@ -539,11 +537,9 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent &event) PresetBundle* preset_bundle = wxGetApp().preset_bundle; if (preset_bundle) { + const FilamentInfos* selected_info = selected_filament_info(); for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) { - - auto filament_item = map_filament_items[m_comboBox_filament->GetValue().ToStdString()]; - std::string filament_id = filament_item.filament_id; - if (it->filament_id.compare(filament_id) == 0) { + if (selected_info && it->filament_id.compare(selected_info->filament_id) == 0) { //check is it in the filament blacklist @@ -840,6 +836,136 @@ static void _collect_filament_info(const wxString& shown_name, query_filament_types[shown_name] = filament.config.get_filament_type(); } +static const char* AMS_FAVORITE_FILAMENT_IDS_KEY = "ams_favorite_filament_ids"; + +const AMSMaterialsSetting::FilamentInfos* AMSMaterialsSetting::selected_filament_info() const +{ + int selection = m_comboBox_filament->GetSelection(); + if (selection >= 0 && selection < int(m_filament_list_items.size()) && m_filament_list_items[selection].is_filament) + return &m_filament_list_items[selection].filament; + + auto iter = map_filament_items.find(m_comboBox_filament->GetValue().ToStdString()); + return iter == map_filament_items.end() ? nullptr : &iter->second; +} + +std::set AMSMaterialsSetting::load_favorite_filament_ids() const +{ + std::set favorite_ids; + std::vector values; + const std::string serialized = wxGetApp().app_config->get(AMS_FAVORITE_FILAMENT_IDS_KEY); + if (!serialized.empty() && Slic3r::unescape_strings_cstyle(serialized, values)) { + favorite_ids.insert(values.begin(), values.end()); + } + return favorite_ids; +} + +void AMSMaterialsSetting::save_favorite_filament_ids(const std::set& favorite_ids) +{ + std::vector values(favorite_ids.begin(), favorite_ids.end()); + wxGetApp().app_config->set(AMS_FAVORITE_FILAMENT_IDS_KEY, Slic3r::escape_strings_cstyle(values)); + wxGetApp().app_config->save(); +} + +void AMSMaterialsSetting::rebuild_filament_dropdown_items(const std::string& selected_filament_id) +{ + std::vector items; + m_filament_list_items.clear(); + + const std::set favorite_ids = load_favorite_filament_ids(); + wxBitmap star_filled = ScalableBitmap(this, "score_star_light", 16).bmp(); + wxBitmap star_empty = ScalableBitmap(this, "score_star_dark", 16).bmp(); + + auto append_header = [&items, this](const wxString& label) { + DropDown::Item item; + item.text = label; + item.style = DD_ITEM_STYLE_SPLIT_ITEM | DD_ITEM_STYLE_DISABLED; + items.push_back(item); + m_filament_list_items.push_back(FilamentListItem{}); + }; + + auto append_filament = [&items, this, &favorite_ids, &star_filled, &star_empty](const wxString& name) { + auto info_it = map_filament_items.find(name.ToStdString()); + if (info_it == map_filament_items.end()) + return; + + const bool is_favorite = favorite_ids.find(info_it->second.filament_id) != favorite_ids.end(); + DropDown::Item item; + item.text = name; + item.action_icon = is_favorite ? star_filled : star_empty; + item.style = 0; + items.push_back(item); + + FilamentListItem list_item; + list_item.filament = info_it->second; + list_item.is_filament = true; + m_filament_list_items.push_back(list_item); + }; + + bool has_favorites = false; + for (const wxString& name : m_sorted_filament_items) { + auto info_it = map_filament_items.find(name.ToStdString()); + if (info_it != map_filament_items.end() && favorite_ids.find(info_it->second.filament_id) != favorite_ids.end()) { + has_favorites = true; + break; + } + } + + if (has_favorites) { + append_header(_L("Favorite filaments")); + for (const wxString& name : m_sorted_filament_items) { + auto info_it = map_filament_items.find(name.ToStdString()); + if (info_it != map_filament_items.end() && favorite_ids.find(info_it->second.filament_id) != favorite_ids.end()) + append_filament(name); + } + } + + append_header(_L("All filaments")); + for (const wxString& name : m_sorted_filament_items) + append_filament(name); + + int selection_idx = -1; + if (!selected_filament_id.empty()) { + for (int i = 0; i < int(m_filament_list_items.size()); ++i) { + if (m_filament_list_items[i].is_filament && m_filament_list_items[i].filament.filament_id == selected_filament_id) { + selection_idx = i; + break; + } + } + } + + m_comboBox_filament->SetItems(items); + m_comboBox_filament->SetSelection(selection_idx); +} + +void AMSMaterialsSetting::toggle_favorite_filament(const std::string& filament_id) +{ + if (filament_id.empty()) + return; + + std::set favorite_ids = load_favorite_filament_ids(); + auto iter = favorite_ids.find(filament_id); + if (iter == favorite_ids.end()) + favorite_ids.insert(filament_id); + else + favorite_ids.erase(iter); + + save_favorite_filament_ids(favorite_ids); +} + +void AMSMaterialsSetting::on_toggle_favorite_filament(wxCommandEvent& evt) +{ + const int index = evt.GetInt(); + if (index < 0 || index >= int(m_filament_list_items.size()) || !m_filament_list_items[index].is_filament) + return; + + std::string selected_filament_id; + if (const FilamentInfos* info = selected_filament_info()) + selected_filament_id = info->filament_id; + toggle_favorite_filament(m_filament_list_items[index].filament.filament_id); + rebuild_filament_dropdown_items(selected_filament_id); + m_comboBox_filament->RefreshDropdownIfOpen(); +} + void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_min, wxString temp_max, wxString k, wxString n) { if (!obj) return; @@ -855,6 +981,9 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi int idx = 0; wxArrayString filament_items; + map_filament_items.clear(); + m_sorted_filament_items.clear(); + m_filament_list_items.clear(); wxString bambu_filament_name; wxString hint_filament_name; // the hint type to be selected std::unordered_map query_filament_vendors;// some information for sort @@ -1050,21 +1179,18 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi std::sort(filament_items.begin(), filament_items.end(), _filament_sorter); } - // traverse the hint selection idx - int selection_idx = -1; - { - for(int i = 0; i < filament_items.size(); i++) - { - if (hint_filament_name == filament_items[i]) - { - selection_idx = i; - break; - } + std::string selection_filament_id; + for (int i = 0; i < int(filament_items.size()); ++i) { + m_sorted_filament_items.push_back(filament_items[i]); + if (hint_filament_name == filament_items[i]) { + auto info_it = map_filament_items.find(filament_items[i].ToStdString()); + if (info_it != map_filament_items.end()) + selection_filament_id = info_it->second.filament_id; } } - m_comboBox_filament->Set(filament_items); - m_comboBox_filament->SetSelection(selection_idx); + rebuild_filament_dropdown_items(selection_filament_id); + int selection_idx = m_comboBox_filament->GetSelection(); post_select_event(selection_idx); if (selection_idx < 0) { @@ -1106,6 +1232,7 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt) int* from_printer = static_cast(m_comboBox_filament->GetClientData()); m_filament_type = ""; + const FilamentInfos* selected_info = selected_filament_info(); PresetBundle* preset_bundle = wxGetApp().preset_bundle; if (preset_bundle) { std::ostringstream stream; @@ -1124,10 +1251,8 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt) std::set printer_names = preset_bundle->get_printer_names_by_printer_type_and_nozzle(DevPrinterConfigUtil::get_printer_display_name(obj->printer_type), nozzle_diameter_str); for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) { - if (!m_comboBox_filament->GetValue().IsEmpty()) { - auto filament_item = map_filament_items[m_comboBox_filament->GetValue().ToStdString()]; - std::string filament_id = filament_item.filament_id; - if (it->filament_id.compare(filament_id) == 0) { + if (selected_info) { + if (it->filament_id.compare(selected_info->filament_id) == 0) { ConfigOption * printer_opt = it->config.option("compatible_printers"); ConfigOptionStrings *printer_strs = dynamic_cast(printer_opt); bool has_compatible_printer = false; @@ -1186,7 +1311,7 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt) //reset cali int cali_select_idx = -1; - if ( !this->obj || m_filament_selection < 0) { + if ( !this->obj || m_filament_selection < 0 || !selected_info) { m_input_k_val->Enable(false); m_input_n_val->Enable(false); m_button_confirm->Disable(); // ORCA No need to change style @@ -1205,21 +1330,9 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt) ams_filament_id = ""; ams_setting_id = ""; - if (preset_bundle) { - for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) { - auto itor = map_filament_items.find(m_comboBox_filament->GetValue().ToStdString()); - if ( itor != map_filament_items.end()) { - ams_filament_id = itor->second.filament_id; - ams_setting_id = itor->second.setting_id; - break; - } - - if (it->alias.compare(m_comboBox_filament->GetValue().ToStdString()) == 0) { - ams_filament_id = it->filament_id; - ams_setting_id = it->setting_id; - break; - } - } + if (selected_info) { + ams_filament_id = selected_info->filament_id; + ams_setting_id = selected_info->setting_id; } wxArrayString items; diff --git a/src/slic3r/GUI/AMSMaterialsSetting.hpp b/src/slic3r/GUI/AMSMaterialsSetting.hpp index 84688896e6..f00e55d0dc 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.hpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.hpp @@ -139,6 +139,7 @@ protected: void create_panel_kn(wxWindow* parent); void on_dpi_changed(const wxRect &suggested_rect) override; void on_select_filament(wxCommandEvent& evt); + void on_toggle_favorite_filament(wxCommandEvent& evt); void on_select_cali_result(wxCommandEvent &evt); void on_select_ok(wxCommandEvent &event); void on_select_reset(wxCommandEvent &event); @@ -146,6 +147,10 @@ protected: void on_clr_picker(wxMouseEvent &event); bool is_virtual_tray(); void update_widgets(); + void rebuild_filament_dropdown_items(const std::string& selected_filament_id); + std::set load_favorite_filament_ids() const; + void save_favorite_filament_ids(const std::set& favorite_ids); + void toggle_favorite_filament(const std::string& filament_id); void update_filament_editing(bool is_printing); @@ -183,11 +188,7 @@ protected: int m_pa_cali_select_id = 0; -#ifdef __APPLE__ - wxComboBox *m_comboBox_filament; -#else ComboBox *m_comboBox_filament; -#endif ComboBox * m_comboBox_cali_result; TextInput* m_readonly_filament; @@ -195,7 +196,15 @@ protected: std::string filament_id; std::string setting_id; }; + struct FilamentListItem { + FilamentInfos filament; + bool is_filament{false}; + }; + const FilamentInfos* selected_filament_info() const; + std::map map_filament_items; + std::vector m_sorted_filament_items; + std::vector m_filament_list_items; }; wxDECLARE_EVENT(EVT_SELECTED_COLOR, wxCommandEvent); From 546b9c621d3ee4915f6c4b347c605cc755cb3052 Mon Sep 17 00:00:00 2001 From: Boris Samorodov Date: Sun, 11 Oct 2026 00:49:25 +0200 Subject: [PATCH 3/7] Fix Qidi start G-code ignoring the selected plate type The start G-code of fdm_qidi_x3_common, Qidi Q1 Pro 0.4 nozzle and Qidi X-CF Pro 0.4 nozzle heated the bed to hot_plate_temp_initial_layer, so with "Support multi bed types" enabled every plate type got the Smooth PEI / High Temp Plate temperature. Use bed_temperature_initial_layer_single, which follows the selected plate. Co-Authored-By: Claude Opus 5.5 --- resources/profiles/Qidi.json | 2 +- resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json | 2 +- resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json | 2 +- resources/profiles/Qidi/machine/fdm_qidi_x3_common.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/profiles/Qidi.json b/resources/profiles/Qidi.json index 4b6496df31..dcb39ee957 100644 --- a/resources/profiles/Qidi.json +++ b/resources/profiles/Qidi.json @@ -1,6 +1,6 @@ { "name": "Qidi", - "version": "02.04.00.16", + "version": "02.04.00.17", "force_update": "0", "description": "Qidi configurations", "machine_model_list": [ diff --git a/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json b/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json index bd2398ae7a..30db0fc68b 100644 --- a/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json +++ b/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json @@ -83,7 +83,7 @@ "extruder_clearance_height_to_lid": "120", "layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\nLOG_Z\nG92 E0\n", "machine_end_gcode": "M141 S0\nM104 S0\nM140 S0\nG1 E-3 F1800\nG0 Z{min(max_print_height, max_layer_z + 3)} F600\nG0 X0 Y0 F12000\n{if max_layer_z < max_print_height / 2}G1 Z{max_print_height / 2 + 10} F600{else}G1 Z{min(max_print_height, max_layer_z + 3)}{endif}", - "machine_start_gcode": "PRINT_START BED=[hot_plate_temp_initial_layer] HOTEND=[nozzle_temperature_initial_layer] CHAMBER=[chamber_temperature]\nM83\nM140 S[hot_plate_temp_initial_layer]\nM104 S[nozzle_temperature_initial_layer]\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 3} Z0\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 6}\nG1 Z1 F600\n", + "machine_start_gcode": "PRINT_START BED=[bed_temperature_initial_layer_single] HOTEND=[nozzle_temperature_initial_layer] CHAMBER=[chamber_temperature]\nM83\nM140 S[bed_temperature_initial_layer_single]\nM104 S[nozzle_temperature_initial_layer]\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 3} Z0\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 6}\nG1 Z1 F600\n", "thumbnails_format": "PNG", "default_filament_profile": [ "Generic PLA @Qidi Q1 Pro 0.4 nozzle" diff --git a/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json b/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json index a41e2b6a11..968f7f63a9 100644 --- a/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json +++ b/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json @@ -100,7 +100,7 @@ "default_filament_profile": [ "Generic PLA @Qidi" ], - "machine_start_gcode": "G28\nM140 S[hot_plate_temp_initial_layer]\nM190 S[hot_plate_temp_initial_layer]\nM109 S[nozzle_temperature_initial_layer]\nG92 E-19\nG0 Y5 Z0.3 F3600\nG1 X5 E0 F2400\n", + "machine_start_gcode": "G28\nM140 S[bed_temperature_initial_layer_single]\nM190 S[bed_temperature_initial_layer_single]\nM109 S[nozzle_temperature_initial_layer]\nG92 E-19\nG0 Y5 Z0.3 F3600\nG1 X5 E0 F2400\n", "machine_end_gcode": "M104 S0\nM140 S0\n;Retract the filament\nG92 E0\nG1 E-3 F300\nG28\nM84\n", "scan_first_layer": "0" } diff --git a/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json b/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json index 9796856379..8fe450c3c6 100644 --- a/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json +++ b/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json @@ -24,7 +24,7 @@ "210x210/COLPIC", "110x110/PNG" ], - "machine_start_gcode": "PRINT_START\nG28\nM141 S0\nG0 Z50 F600\nM190 S[hot_plate_temp_initial_layer]\nG28 Z\nG29; mesh bed leveling ,comment this code to close it\nG0 X0 Y0 Z50 F6000\nM141 S{overall_chamber_temperature}\nM109 S[nozzle_temperature_initial_layer]\nM106 P3 S255\nM83\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 12} E{-10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 E{10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\n", + "machine_start_gcode": "PRINT_START\nG28\nM141 S0\nG0 Z50 F600\nM190 S[bed_temperature_initial_layer_single]\nG28 Z\nG29; mesh bed leveling ,comment this code to close it\nG0 X0 Y0 Z50 F6000\nM141 S{overall_chamber_temperature}\nM109 S[nozzle_temperature_initial_layer]\nM106 P3 S255\nM83\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 12} E{-10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 E{10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\n", "machine_end_gcode": "M141 S0\nM104 S0\nM140 S0\nG1 E-3 F1800\nG0 Z{min(max_print_height, max_layer_z + 3)} F600\nG0 X0 Y0 F12000\n{if max_layer_z < max_print_height / 2}G1 Z{max_print_height / 2 + 10} F600{else}G1 Z{min(max_print_height, max_layer_z + 3)}{endif}", "scan_first_layer": "0" } From 01b340dee16a327760211bb79cb4dc036a0d52bf Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 12 Oct 2026 14:54:17 +0800 Subject: [PATCH 4/7] Fix regressions in the AMS filament favorites dropdown --- src/slic3r/GUI/AMSMaterialsSetting.cpp | 126 +++++++++++++------------ src/slic3r/GUI/AMSMaterialsSetting.hpp | 8 +- src/slic3r/GUI/Widgets/ComboBox.cpp | 37 +++----- src/slic3r/GUI/Widgets/ComboBox.hpp | 2 +- src/slic3r/GUI/Widgets/DropDown.cpp | 56 +++++------ src/slic3r/GUI/Widgets/DropDown.hpp | 4 +- 6 files changed, 111 insertions(+), 122 deletions(-) diff --git a/src/slic3r/GUI/AMSMaterialsSetting.cpp b/src/slic3r/GUI/AMSMaterialsSetting.cpp index 671431dd2f..b2b5f46466 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.cpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.cpp @@ -16,6 +16,7 @@ #include #include "slic3r/GUI/GUI_Utils.hpp" #include "slic3r/GUI/Widgets/Button.hpp" +#include "slic3r/GUI/Widgets/DropDown.hpp" #include "slic3r/GUI/Widgets/Label.hpp" #include #include "slic3r/GUI/wxExtensions.hpp" @@ -175,6 +176,24 @@ void AMSMaterialsSetting::create() m_comboBox_filament->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_filament), NULL, this); m_comboBox_filament->Bind(EVT_DROPDOWN_ITEM_ACTION, &AMSMaterialsSetting::on_toggle_favorite_filament, this); + // Favorites toggled in the open list are regrouped just before it opens again, so rows never move + // under the cursor. These handlers run ahead of ComboBox's own, which then measure and show the + // rebuilt rows; wxEVT_COMBOBOX_DROPDOWN would be too late, as ComboBox sends it after showing the list. + auto regroup_favorites = [this]() { + if (!m_favorites_regroup || m_comboBox_filament->is_drop_down()) + return; + const FilamentInfos* info = selected_filament_info(); + const std::string filament_id = info ? info->filament_id : std::string(); + rebuild_filament_dropdown_items(filament_id); + }; + m_comboBox_filament->Bind(wxEVT_LEFT_DOWN, [regroup_favorites](wxMouseEvent& e) { regroup_favorites(); e.Skip(); }); + m_comboBox_filament->Bind(wxEVT_LEFT_DCLICK, [regroup_favorites](wxMouseEvent& e) { regroup_favorites(); e.Skip(); }); + m_comboBox_filament->Bind(wxEVT_KEY_DOWN, [regroup_favorites](wxKeyEvent& e) { + if (e.GetKeyCode() == WXK_RETURN || e.GetKeyCode() == WXK_SPACE) // the keys that open the list + regroup_favorites(); + e.Skip(); + }); + m_comboBox_cali_result->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_cali_result), NULL, this); } @@ -193,6 +212,8 @@ void AMSMaterialsSetting::create_panel_normal(wxWindow* parent) m_sizer_filament->Add(0, 0, 0, wxEXPAND, 0); m_comboBox_filament = new ::ComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, AMS_MATERIALS_SETTING_COMBOX_WIDTH, 0, nullptr, wxCB_READONLY); + m_star_filled = ScalableBitmap(this, "score_star_light", 16); + m_star_empty = ScalableBitmap(this, "score_star_dark", 16); m_sizer_filament->Add(m_comboBox_filament, 1, wxALIGN_CENTER, 0); @@ -988,12 +1009,10 @@ static const char* AMS_FAVORITE_FILAMENT_IDS_KEY = "ams_favorite_filament_ids"; const AMSMaterialsSetting::FilamentInfos* AMSMaterialsSetting::selected_filament_info() const { - int selection = m_comboBox_filament->GetSelection(); - if (selection >= 0 && selection < int(m_filament_list_items.size()) && m_filament_list_items[selection].is_filament) - return &m_filament_list_items[selection].filament; - - auto iter = map_filament_items.find(m_comboBox_filament->GetValue().ToStdString()); - return iter == map_filament_items.end() ? nullptr : &iter->second; + const int selection = m_comboBox_filament->GetSelection(); + if (selection < 0 || selection >= int(m_filament_list_items.size()) || !m_filament_list_items[selection].is_filament) + return nullptr; + return &m_filament_list_items[selection].filament; } std::set AMSMaterialsSetting::load_favorite_filament_ids() const @@ -1019,10 +1038,6 @@ void AMSMaterialsSetting::rebuild_filament_dropdown_items(const std::string& sel std::vector items; m_filament_list_items.clear(); - const std::set favorite_ids = load_favorite_filament_ids(); - wxBitmap star_filled = ScalableBitmap(this, "score_star_light", 16).bmp(); - wxBitmap star_empty = ScalableBitmap(this, "score_star_dark", 16).bmp(); - auto append_header = [&items, this](const wxString& label) { DropDown::Item item; item.text = label; @@ -1031,43 +1046,32 @@ void AMSMaterialsSetting::rebuild_filament_dropdown_items(const std::string& sel m_filament_list_items.push_back(FilamentListItem{}); }; - auto append_filament = [&items, this, &favorite_ids, &star_filled, &star_empty](const wxString& name) { - auto info_it = map_filament_items.find(name.ToStdString()); + auto append_filament = [&items, this](const wxString& name) { + auto info_it = map_filament_items.find(name); if (info_it == map_filament_items.end()) return; - const bool is_favorite = favorite_ids.find(info_it->second.filament_id) != favorite_ids.end(); DropDown::Item item; item.text = name; - item.action_icon = is_favorite ? star_filled : star_empty; - item.style = 0; items.push_back(item); - - FilamentListItem list_item; - list_item.filament = info_it->second; - list_item.is_filament = true; - m_filament_list_items.push_back(list_item); + m_filament_list_items.push_back(FilamentListItem{info_it->second, true}); }; - bool has_favorites = false; + // Favorites are listed again at the top under their own header. Without any, the list is the + // plain filament list. + const std::set favorite_ids = load_favorite_filament_ids(); + std::vector favorites; for (const wxString& name : m_sorted_filament_items) { - auto info_it = map_filament_items.find(name.ToStdString()); - if (info_it != map_filament_items.end() && favorite_ids.find(info_it->second.filament_id) != favorite_ids.end()) { - has_favorites = true; - break; - } + auto info_it = map_filament_items.find(name); + if (info_it != map_filament_items.end() && favorite_ids.count(info_it->second.filament_id) > 0) + favorites.push_back(name); } - - if (has_favorites) { + if (!favorites.empty()) { append_header(_L("Favorite filaments")); - for (const wxString& name : m_sorted_filament_items) { - auto info_it = map_filament_items.find(name.ToStdString()); - if (info_it != map_filament_items.end() && favorite_ids.find(info_it->second.filament_id) != favorite_ids.end()) - append_filament(name); - } + for (const wxString& name : favorites) + append_filament(name); + append_header(_L("All filaments")); } - - append_header(_L("All filaments")); for (const wxString& name : m_sorted_filament_items) append_filament(name); @@ -1081,8 +1085,25 @@ void AMSMaterialsSetting::rebuild_filament_dropdown_items(const std::string& sel } } + // Clear() first: SetItems() keeps the old selection, and SetSelection() ignores an index equal to it, + // which would leave the previous filament's name on the combo. + m_comboBox_filament->Clear(); m_comboBox_filament->SetItems(items); + update_favorite_stars(); m_comboBox_filament->SetSelection(selection_idx); + m_favorites_regroup = false; +} + +void AMSMaterialsSetting::update_favorite_stars() +{ + const std::set favorite_ids = load_favorite_filament_ids(); + for (size_t i = 0; i < m_filament_list_items.size(); ++i) { + const FilamentListItem& row = m_filament_list_items[i]; + if (row.is_filament) { + const bool favorite = favorite_ids.count(row.filament.filament_id) > 0; + m_comboBox_filament->SetItemActionBitmap(i, favorite ? m_star_filled.bmp() : m_star_empty.bmp()); + } + } } void AMSMaterialsSetting::toggle_favorite_filament(const std::string& filament_id) @@ -1106,12 +1127,9 @@ void AMSMaterialsSetting::on_toggle_favorite_filament(wxCommandEvent& evt) if (index < 0 || index >= int(m_filament_list_items.size()) || !m_filament_list_items[index].is_filament) return; - std::string selected_filament_id; - if (const FilamentInfos* info = selected_filament_info()) - selected_filament_id = info->filament_id; toggle_favorite_filament(m_filament_list_items[index].filament.filament_id); - rebuild_filament_dropdown_items(selected_filament_id); - m_comboBox_filament->RefreshDropdownIfOpen(); + update_favorite_stars(); + m_favorites_regroup = true; } void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_min, wxString temp_max, wxString k, wxString n) @@ -1127,16 +1145,12 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi m_input_k_val->GetTextCtrl()->SetValue(k); m_input_n_val->GetTextCtrl()->SetValue(n); - wxArrayString filament_items; - map_filament_items.clear(); - m_sorted_filament_items.clear(); - m_filament_list_items.clear(); wxString bambu_filament_name; - wxString hint_filament_name; // the hint type to be selected std::unordered_map query_filament_vendors;// some information for sort std::unordered_map query_filament_types; // std::set filament_id_set; + m_sorted_filament_items.clear(); // The alias keyed map has to start empty: it is a member, so a stale alias left by an earlier // popup (a different printer, a different nozzle) would resolve to that printer's filament_id. map_filament_items.clear(); @@ -1166,7 +1180,7 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi if (alias.empty()) continue; - filament_items.push_back(alias); + m_sorted_filament_items.push_back(alias); _collect_filament_info(alias, *filament_it, query_filament_vendors, query_filament_types); FilamentInfos filament_infos; @@ -1175,7 +1189,6 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi map_filament_items[alias] = filament_infos; if (filament_it->filament_id == ams_filament_id) { - hint_filament_name = from_u8(alias); bambu_filament_name = from_u8(alias); @@ -1346,20 +1359,10 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi return name_compare != 0 ? name_compare < 0 : left < right; }; - std::sort(filament_items.begin(), filament_items.end(), _filament_sorter); + std::sort(m_sorted_filament_items.begin(), m_sorted_filament_items.end(), _filament_sorter); } - std::string selection_filament_id; - for (int i = 0; i < int(filament_items.size()); ++i) { - m_sorted_filament_items.push_back(filament_items[i]); - if (hint_filament_name == filament_items[i]) { - auto info_it = map_filament_items.find(filament_items[i].ToStdString()); - if (info_it != map_filament_items.end()) - selection_filament_id = info_it->second.filament_id; - } - } - - rebuild_filament_dropdown_items(selection_filament_id); + rebuild_filament_dropdown_items(ams_filament_id); int selection_idx = m_comboBox_filament->GetSelection(); post_select_event(selection_idx); @@ -1645,6 +1648,11 @@ void AMSMaterialsSetting::on_dpi_changed(const wxRect &suggested_rect) m_button_reset->Rescale(); // ORCA m_button_confirm->Rescale(); // ORCA m_button_close->Rescale(); // ORCA + // An open list would keep the measurements of the old stars, so close it before replacing them. + m_comboBox_filament->CloseDropdown(); + m_star_filled.msw_rescale(); + m_star_empty.msw_rescale(); + update_favorite_stars(); this->Refresh(); } diff --git a/src/slic3r/GUI/AMSMaterialsSetting.hpp b/src/slic3r/GUI/AMSMaterialsSetting.hpp index 673ead26ea..6fa215063a 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.hpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.hpp @@ -166,6 +166,7 @@ protected: bool is_virtual_tray(); void update_widgets(); void rebuild_filament_dropdown_items(const std::string& selected_filament_id); + void update_favorite_stars(); std::set load_favorite_filament_ids() const; void save_favorite_filament_ids(const std::set& favorite_ids); void toggle_favorite_filament(const std::string& filament_id); @@ -222,9 +223,14 @@ protected: }; const FilamentInfos* selected_filament_info() const; - std::map map_filament_items; + // Keyed by the same wxString the dropdown shows, so a row's name never has to be converted back. + std::map map_filament_items; std::vector m_sorted_filament_items; std::vector m_filament_list_items; + ScalableBitmap m_star_filled; + ScalableBitmap m_star_empty; + // Favorites changed while the list was open; their rows are regrouped before it opens again. + bool m_favorites_regroup{false}; }; wxDECLARE_EVENT(EVT_SELECTED_COLOR, wxCommandEvent); diff --git a/src/slic3r/GUI/Widgets/ComboBox.cpp b/src/slic3r/GUI/Widgets/ComboBox.cpp index 500b8c364a..55e261d04d 100644 --- a/src/slic3r/GUI/Widgets/ComboBox.cpp +++ b/src/slic3r/GUI/Widgets/ComboBox.cpp @@ -302,25 +302,10 @@ void ComboBox::ForceDropdownOpen() } } -void ComboBox::RefreshDropdownIfOpen() +void ComboBox::CloseDropdown() { - if (!drop_down) - return; - - drop.hover_item = -1; - drop.offset = wxPoint(); - if (drop.subDropDown) { - drop.subDropDown->group.clear(); - drop.subDropDown->hover_item = -1; - drop.subDropDown->offset = wxPoint(); - if (drop.subDropDown->IsShown()) - drop.subDropDown->Dismiss(); - } - - drop.need_sync = true; - drop.messureSize(); - drop.autoPosition(); - drop.paintNow(); + if (drop.IsShown()) + drop.DismissAndNotify(); } unsigned int ComboBox::GetCount() const { return items.size(); } @@ -452,16 +437,18 @@ void ComboBox::keyDown(wxKeyEvent& event) case WXK_UP: case WXK_DOWN: case WXK_LEFT: - case WXK_RIGHT: - if ((event.GetKeyCode() == WXK_UP || event.GetKeyCode() == WXK_LEFT) && GetSelection() > 0) { - SetSelection(GetSelection() - 1); - } else if ((event.GetKeyCode() == WXK_DOWN || event.GetKeyCode() == WXK_RIGHT) && GetSelection() + 1 < items.size()) { - SetSelection(GetSelection() + 1); - } else { + case WXK_RIGHT: { + // Step over disabled rows: the mouse cannot select them either. + const int step = (event.GetKeyCode() == WXK_UP || event.GetKeyCode() == WXK_LEFT) ? -1 : 1; + int n = GetSelection() + step; + while (n >= 0 && n < int(items.size()) && (items[n].style & DD_ITEM_STYLE_DISABLED)) + n += step; + if (n < 0 || n >= int(items.size())) break; - } + SetSelection(n); sendComboBoxEvent(); break; + } case WXK_TAB: HandleAsNavigationKey(event); break; diff --git a/src/slic3r/GUI/Widgets/ComboBox.hpp b/src/slic3r/GUI/Widgets/ComboBox.hpp index 1ab04c3a79..1fbc2e257d 100644 --- a/src/slic3r/GUI/Widgets/ComboBox.hpp +++ b/src/slic3r/GUI/Widgets/ComboBox.hpp @@ -101,7 +101,7 @@ public: void DeleteOneItem(unsigned int pos) { DoDeleteOneItem(pos); } void ForceDropdownOpen(); - void RefreshDropdownIfOpen(); + void CloseDropdown(); protected: virtual int DoInsertItems(const wxArrayStringsAdapter &items, diff --git a/src/slic3r/GUI/Widgets/DropDown.cpp b/src/slic3r/GUI/Widgets/DropDown.cpp index 3fb1607551..966a090526 100644 --- a/src/slic3r/GUI/Widgets/DropDown.cpp +++ b/src/slic3r/GUI/Widgets/DropDown.cpp @@ -243,27 +243,17 @@ static wxSize GetBmpSize(wxBitmap & bmp) #endif } -wxSize DropDown::actionIconSize() const +// The x-range [first, second) of the column at the right of the rows that holds the items' action +// icons, empty when no item has one. render() draws the icons in it and mouseReleased() hit-tests +// clicks against it, so the drawn icon and its clickable area always match. +std::pair DropDown::actionColumn() const { - wxSize size; - std::set groups; - for (const Item& item : items) { - if (group.IsEmpty()) { - if (!item.group_key.IsEmpty()) { - if (groups.find(item.group_key) != groups.end()) - continue; - groups.insert(item.group_key); - } - } else if (item.group_key != group) { - continue; - } - - wxBitmap icon = item.action_icon; - wxSize icon_size = GetBmpSize(icon); - size.x = std::max(size.x, icon_size.x); - size.y = std::max(size.y, icon_size.y); - } - return size; + if (actionIconWidth <= 0) + return {0, 0}; + int right = rowSize.x; + if (rowSize.y * count > GetSize().y) // position bar + right -= 6; + return {right - actionIconWidth - FromDIP(16), right}; } static void _DrawSplitItem(const wxWindow* w, wxDC& dc, wxString split_text, wxPoint start_pt, int item_width, int item_height) @@ -395,8 +385,9 @@ void DropDown::render(wxDC &dc) rcContent.width -= szBmp.x + 5; } - const wxSize action_icon_size = actionIconSize(); - const int action_column_width = action_icon_size.x > 0 ? action_icon_size.x + FromDIP(16) : 0; + const auto action_column = actionColumn(); + if (action_column.second > action_column.first) + rcContent.width = action_column.first - rcContent.x; std::set groups; // draw texts & icons @@ -436,7 +427,6 @@ void DropDown::render(wxDC &dc) } if (rcContent.y > size.y) break; wxPoint pt = rcContent.GetLeftTop(); - int rcContent_width = rcContent.width; if (item.style & DD_ITEM_STYLE_SPLIT_ITEM) { _DrawSplitItem(this, dc, item.text, pt, rowSize.GetWidth(), rowSize.GetHeight()); @@ -462,12 +452,9 @@ void DropDown::render(wxDC &dc) auto text = group.IsEmpty() ? (item.group_key.IsEmpty() ? item.text : item.group_label) : item.text; - if (action_column_width > 0) - rcContent.width -= action_column_width; - if (item.action_icon.IsOk()) { auto action_size = GetBmpSize(item.action_icon); - wxPoint action_pt(rcContent.x + rcContent_width - action_size.x - FromDIP(8), + wxPoint action_pt(action_column.second - action_size.x - FromDIP(8), rcContent.y + (rcContent.height - action_size.y) / 2); dc.DrawBitmap(item.action_icon, action_pt); } @@ -498,7 +485,6 @@ void DropDown::render(wxDC &dc) dc.DrawBitmap(arrow_bitmap.bmp(), pt); } } - rcContent.width = rcContent_width; rcContent.y += rowSize.y; } } @@ -572,6 +558,7 @@ void DropDown::messureSize() if (!need_sync) return; textSize = wxSize(); iconSize = wxSize(); + actionIconWidth = 0; count = 0; wxClientDC dc(GetParent() ? GetParent() : this); dc.SetFont(GetFont()); @@ -609,9 +596,12 @@ void DropDown::messureSize() } } if (item.action_icon.IsOk()) - size1.x += GetBmpSize(item.action_icon).x + FromDIP(16); + actionIconWidth = std::max(actionIconWidth, GetBmpSize(item.action_icon).x); if (size1.x > textSize.x) textSize = size1; } + // Every row leaves room for the action column, as render() and actionColumn() lay it out. + if (actionIconWidth > 0) + textSize.x += actionIconWidth + FromDIP(16); if (!align_icon) iconSize.x = 0; wxSize szContent = textSize; if (szContent.x < FromDIP(120) && !use_content_width) @@ -766,12 +756,8 @@ void DropDown::mouseReleased(wxMouseEvent& event) if (hover_item >= 0 && (subDropDown == nullptr || subDropDown->group.empty())) { // not moved int index = hoverIndex(); if (index >= 0 && items[index].action_icon.IsOk()) { - wxSize action_size = actionIconSize(); - int action_right = GetSize().x; - if (rowSize.y * count > GetSize().y) - action_right -= 6; - int action_left = action_right - action_size.x - FromDIP(16); - if (event.GetX() >= action_left && event.GetX() <= action_right) { + const auto action_column = actionColumn(); + if (event.GetX() >= action_column.first && event.GetX() < action_column.second) { sendDropDownActionEvent(index); paintNow(); return; diff --git a/src/slic3r/GUI/Widgets/DropDown.hpp b/src/slic3r/GUI/Widgets/DropDown.hpp index adbe0eb95c..a0821e6fff 100644 --- a/src/slic3r/GUI/Widgets/DropDown.hpp +++ b/src/slic3r/GUI/Widgets/DropDown.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "slic3r/GUI/Widgets/StateColor.hpp" #include @@ -67,6 +68,7 @@ private: wxSize textSize; wxSize iconSize; + int actionIconWidth = 0; wxSize rowSize; StateHandler state_handler; @@ -137,7 +139,7 @@ private: int hoverIndex(); int selectedItem(); - wxSize actionIconSize() const; + std::pair actionColumn() const; friend class ComboBox; void messureSize(); From 6a1a5d51e99fbe7cb51424a620e397c1b60f7637 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 12 Oct 2026 16:03:49 +0800 Subject: [PATCH 5/7] Show an outline star for filaments that are not favorites --- resources/images/score_star_outline.svg | 3 +++ src/slic3r/GUI/AMSMaterialsSetting.cpp | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 resources/images/score_star_outline.svg diff --git a/resources/images/score_star_outline.svg b/resources/images/score_star_outline.svg new file mode 100644 index 0000000000..eb52be9e82 --- /dev/null +++ b/resources/images/score_star_outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/slic3r/GUI/AMSMaterialsSetting.cpp b/src/slic3r/GUI/AMSMaterialsSetting.cpp index b2b5f46466..41047dffd9 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.cpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.cpp @@ -213,7 +213,7 @@ void AMSMaterialsSetting::create_panel_normal(wxWindow* parent) m_comboBox_filament = new ::ComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, AMS_MATERIALS_SETTING_COMBOX_WIDTH, 0, nullptr, wxCB_READONLY); m_star_filled = ScalableBitmap(this, "score_star_light", 16); - m_star_empty = ScalableBitmap(this, "score_star_dark", 16); + m_star_empty = ScalableBitmap(this, "score_star_outline", 16); m_sizer_filament->Add(m_comboBox_filament, 1, wxALIGN_CENTER, 0); From 5371c853629e01b25a32058c9695875a55b56057 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:09:05 +0800 Subject: [PATCH 6/7] Refresh Dark Mode Colours When the System Theme Differs at Startup (#16390) On Linux and macOS the app follows the system theme: at startup it overwrites the saved dark_color_mode with the system appearance. The label colours and the cached dark mode flag were already set from the saved value by then, and the later "changed" check reads the mode only after the overwrite, so it never fires. For that session every dialog that maps its colours through UpdateDlgDarkUI keeps the old mode, e.g. white dialogs on a dark main window after switching the desktop to dark, or on a fresh install on a dark desktop. Refresh the label colours and the flag when the system theme differs from the saved mode. --- src/slic3r/GUI/GUI_App.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 8940e13e6e..636ba985f5 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -3232,9 +3232,16 @@ bool GUI_App::on_init_inner() #ifdef _MSW_DARK_MODE #ifndef __WINDOWS__ + // The label colours and the dark mode flag were set above from the saved mode. When the system theme + // differs from it, refresh them, or dialogs that map their colours stay in the old mode for this session. + const bool saved_dark_mode = dark_mode(); wxSystemAppearance app = wxSystemSettings::GetAppearance(); GUI::wxGetApp().app_config->set("dark_color_mode", app.IsDark() ? "1" : "0"); GUI::wxGetApp().app_config->save(); + if (dark_mode() != saved_dark_mode) { + init_label_colours(); + Update_dark_mode_flag(); + } #endif // __APPLE__ From c55f9c22518a2ea5c25387f5a7fabbf08ce3f57d Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:31:03 +0800 Subject: [PATCH 7/7] Add a /bot fix-tidy Command That Suggests clang-tidy's Missing Includes (#16398) * Add a /bot fix-tidy Command That Pushes clang-tidy's Missing Includes A maintainer comments /bot fix-tidy on a PR. A read-only job runs scripts/run_clang_tidy.sh --fix on the PR's merge commit and returns the patch; a second job, which never runs the PR's code, accepts it only when it adds nothing but #include lines to C++ files the PR already changes, then commits it on the PR head and pushes with CLANG_TIDY_BOT_TOKEN. * Post clang-tidy's Fix as Review Suggestions Instead of Pushing It A failed clang-tidy check now runs once more with --fix and uploads the patch as the clang-tidy-fix artifact; the check still fails. /bot fix-tidy downloads that patch for the PR's latest commit, checks it adds only includes to C++ files the PR changes, and posts one review: a one-click suggestion for each include on a line the PR's diff shows, and the whole patch with a git apply command. Nothing is pushed and no PR code runs in the bot, so the PR's author can run it too and no token is needed. * Make the clang-tidy Fix in Its Own PR-Scoped Workflow on Request clang_tidy.yml is back to main's version. clang_tidy_fix.yml runs on every PR push but skips its job on the first attempt; /bot fix-tidy re-runs it, so run_clang_tidy.sh --fix runs in the PR's own context with a read-only token and the PR's cache, never main's, and the PR's author can ask for it. When that re-run finishes, the bot posts the patch as review suggestions as before. When the clang-tidy step fails, the bot also comments asking the author to run the script or the command, edits that comment on later failures, and deletes it once the check passes. * Give the clang-tidy Bot Write Access to PRs It Answers * Tell /bot fix-tidy Users How to Start a Fix Run When There Is None to Re-Run --- .github/workflows/clang_tidy_fix.yml | 65 ++++ .github/workflows/pr-tidy-bot.yml | 476 +++++++++++++++++++++++++++ 2 files changed, 541 insertions(+) create mode 100644 .github/workflows/clang_tidy_fix.yml create mode 100644 .github/workflows/pr-tidy-bot.yml diff --git a/.github/workflows/clang_tidy_fix.yml b/.github/workflows/clang_tidy_fix.yml new file mode 100644 index 0000000000..6339e17570 --- /dev/null +++ b/.github/workflows/clang_tidy_fix.yml @@ -0,0 +1,65 @@ +name: clang-tidy fix + +# Makes the missing-include fix that /bot fix-tidy (pr-tidy-bot.yml) posts on a +# pull request: scripts/run_clang_tidy.sh --fix on the merge commit the +# clang-tidy check runs on, uploaded as the clang-tidy-fix artifact. +# +# Every push to a PR starts this workflow, and its job skips on that first +# attempt. The bot re-runs it on request. A re-run keeps the pull_request +# context, so the PR's code runs with a read-only token, no secrets and the +# PR's own cache, never main's, whoever asked for it. +on: + pull_request: + branches: + - main + - release/* + +permissions: + contents: read + +jobs: + fix: + if: github.run_attempt > 1 + # A hosted runner, so nothing the PR's code leaves behind outlives the job. + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + lfs: 'false' + # The PR merge commit plus its first parent, the base it is diffed against. + fetch-depth: 2 + persist-credentials: false + + # Parsing needs the dependency headers, not a build of this PR's deps/, so + # a PR that changes deps/ falls back to the newest cache main has. + - name: Restore cached deps + uses: actions/cache/restore@v6 + with: + path: ${{ github.workspace }}/deps/build/OrcaSlicer_dep + key: linux-clang-cache-orcaslicer_deps-build-${{ hashFiles('deps/**') }} + restore-keys: linux-clang-cache-orcaslicer_deps-build- + fail-on-cache-miss: true + + - name: Apt-Install Dependencies + uses: ./.github/actions/apt-install-deps + + # The status says whether findings remain that --fix cannot fix. + - name: Fix the changed lines + run: | + mkdir -p "$RUNNER_TEMP/clang-tidy-fix" + status=0 + scripts/run_clang_tidy.sh --base HEAD^1 --yes --fix || status=$? + echo "$status" > "$RUNNER_TEMP/clang-tidy-fix/status" + git -c core.quotePath=false diff --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ \ + > "$RUNNER_TEMP/clang-tidy-fix/fix.patch" + + - name: Upload the fix + uses: actions/upload-artifact@v7 + with: + name: clang-tidy-fix + path: ${{ runner.temp }}/clang-tidy-fix/ + retention-days: 7 + # A later re-run of the same run replaces the earlier fix. + overwrite: true diff --git a/.github/workflows/pr-tidy-bot.yml b/.github/workflows/pr-tidy-bot.yml new file mode 100644 index 0000000000..62f4a0002f --- /dev/null +++ b/.github/workflows/pr-tidy-bot.yml @@ -0,0 +1,476 @@ +name: PR clang-tidy Bot + +# Helps a pull request past the clang-tidy check. +# +# When the check fails on a PR's changes, a comment asks the author to fix it, +# either with scripts/run_clang_tidy.sh --fix or by commenting: +# /bot fix-tidy +# +# That command, from the PR's author or an account with write access, re-runs +# the PR's clang-tidy fix run (clang_tidy_fix.yml), which makes the fix in the +# PR's own context. When it finishes, the fix is posted as one review: each +# include whose place falls on a line the PR's diff shows is a suggestion GitHub +# can commit in one click, and the review's body holds the whole patch with a +# git apply command. Findings --fix cannot fix, such as a using in a header, +# are left for the check to report. +# +# No code from the PR runs here. Nothing is pushed, and the patch is only +# posted when every line it adds is an #include in a C++ file the PR changes. +# +# workflow_run matches the workflows by their name: keep "clang-tidy" and +# "clang-tidy fix" in step with clang_tidy.yml and clang_tidy_fix.yml, and +# TIDY_STEP below with the clang-tidy step's name in clang_tidy.yml. + +on: + issue_comment: + types: + - created + workflow_run: + workflows: + - clang-tidy + - clang-tidy fix + types: + - completed + +permissions: {} + +jobs: + request: + # Skips the job unless a PR comment mentions the command. + if: >- + github.repository == 'OrcaSlicer/OrcaSlicer' + && github.event_name == 'issue_comment' + && github.event.issue.pull_request != null + && contains(github.event.comment.body, '/bot fix-tidy') + permissions: + actions: write # re-run the fix run + pull-requests: write # react to and answer the comment, which is the PR's + issues: write # feedback comment + reactions + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Re-run the PR's clang-tidy fix + uses: actions/github-script@v9 + with: + script: | + const MARKER = ''; + const FIX_WORKFLOW = 'clang_tidy_fix.yml'; + + const { owner, repo } = context.repo; + const issue = context.payload.issue; + const comment = context.payload.comment; + + // Ignores a comment whose sender is not its author. + if (context.payload.sender.login !== comment.user.login || comment.user.type !== 'User') { + core.info('Ignoring a comment not written by its sender, or by a bot.'); + return; + } + + const commandLine = (comment.body || '') + .split('\n') + .map((line) => line.trim()) + .find((line) => /^\/bot\s+fix-tidy\b/i.test(line)); + if (!commandLine) { + core.info('No /bot fix-tidy command found.'); + return; + } + + const commenter = comment.user.login; + // Answers only the PR's author and maintainers, so the command cannot be used to spam. + if (commenter !== issue.user.login) { + const { data: access } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: commenter }); + if (!['admin', 'write'].includes(access.permission)) { + core.info(`Ignoring /bot fix-tidy from @${commenter}: not the PR's author and no write access.`); + return; + } + } + + const react = (content) => + github.rest.reactions.createForIssueComment({ owner, repo, comment_id: comment.id, content }); + async function refuse(reason) { + await react('-1'); + await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `${MARKER}\n@${commenter} ${reason}` }); + } + + await react('eyes'); + + if (!/^\/bot\s+fix-tidy$/i.test(commandLine)) { + return refuse('`/bot fix-tidy` takes no arguments.'); + } + + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue.number }); + if (pr.state !== 'open') { + return refuse('the PR is not open.'); + } + + const sha = pr.head.sha.slice(0, 10); + const { data: { workflow_runs: runs } } = await github.rest.actions.listWorkflowRuns({ + owner, repo, workflow_id: FIX_WORKFLOW, event: 'pull_request', head_sha: pr.head.sha, per_page: 1 + }); + const run = runs[0]; + // A PR last pushed before clang_tidy_fix.yml existed has no run to re-run. + if (!run) { + return refuse(`there is no clang-tidy fix run for ${sha}, the PR's latest commit. If you just pushed it, ` + + 'try again in a minute. Otherwise, push a commit or merge the base branch to start one, then comment `/bot fix-tidy` again.'); + } + if (run.status !== 'completed') { + // The first attempt is the one every push starts, and it makes no fix. + return refuse(run.run_attempt > 1 + ? `a clang-tidy fix for ${sha} is already [running](${run.html_url}). I'll post it when it finishes.` + : `the PR's checks on ${sha} are still starting. Comment \`/bot fix-tidy\` again in a minute.`); + } + // GitHub refuses to re-run a run created over 30 days ago. + try { + await github.rest.actions.reRunWorkflow({ owner, repo, run_id: run.id }); + } catch (error) { + core.warning(`Re-running ${run.html_url} failed: ${error.message}`); + return refuse(`GitHub won't re-run the [clang-tidy fix run](${run.html_url}) for ${sha} (${error.message}). ` + + 'Push a commit or merge the base branch to start a new one, then comment `/bot fix-tidy` again.'); + } + core.info(`Re-ran ${run.html_url}`); + + suggest: + # The fix run's re-runs only. Its first attempt makes no fix. + if: >- + github.repository == 'OrcaSlicer/OrcaSlicer' + && github.event_name == 'workflow_run' + && github.event.workflow_run.name == 'clang-tidy fix' + && github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.run_attempt > 1 + concurrency: + group: ${{ github.workflow }}-suggest-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + permissions: + actions: read # download the fix + contents: read # check out the PR head + pull-requests: write # the review + issues: write # feedback comment + reactions + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Find the PR and who asked + id: find + uses: actions/github-script@v9 + with: + script: | + const MARKER = ''; + const CPP = /^(src|tests)\/.+\.(cpp|cc|cxx|hpp|h|hxx)$/; + + const { owner, repo } = context.repo; + const run = context.payload.workflow_run; + + // workflow_run lists no PR for a fork's branch, so the PR is found by its branch. + const { data: prs } = await github.rest.pulls.list({ + owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}` + }); + const pr = prs[0]; + if (!pr) { + core.info('No open PR for this run.'); + return; + } + + // The latest /bot fix-tidy comment is the one this run answers. + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }); + const request = comments.reverse().find((c) => c.user.type === 'User' && /^\s*\/bot\s+fix-tidy\s*$/im.test(c.body || '')); + const mention = request ? `@${request.user.login} ` : ''; + async function refuse(reason) { + if (request) { + await github.rest.reactions.createForIssueComment({ owner, repo, comment_id: request.id, content: '-1' }); + } + await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: `${MARKER}\n${mention}${reason}` }); + } + + if (pr.head.sha !== run.head_sha) { + return refuse('the PR changed while I was making its clang-tidy fix. Comment `/bot fix-tidy` again.'); + } + if (run.conclusion !== 'success') { + return refuse(`I couldn't make the clang-tidy fix. See the [run log](${run.html_url}).`); + } + + // The right-side lines of each changed C++ file's diff: the lines a + // review comment can be placed on. A file too large for GitHub to + // show its diff has none, and gets no suggestions. + const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 }); + const ranges = {}; + for (const file of files) { + if (file.status === 'removed' || !CPP.test(file.filename)) { + continue; + } + ranges[file.filename] = [...(file.patch || '').matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)] + .map((m) => [Number(m[1]), Number(m[1]) + Number(m[2] ?? 1) - 1]) + .filter(([first, last]) => last >= first); + } + + core.setOutput('pr', pr.number); + core.setOutput('head-sha', pr.head.sha); + core.setOutput('head-repo', run.head_repository.full_name); + core.setOutput('ranges', JSON.stringify(ranges)); + core.setOutput('mention', mention); + core.setOutput('request-id', request ? request.id : ''); + + - name: Download the fix + if: steps.find.outputs.pr != '' + uses: actions/download-artifact@v8 + with: + name: clang-tidy-fix + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + path: ${{ runner.temp }}/clang-tidy-fix + + # The PR's own files, only to apply the patch to. Nothing in them is run. + - name: Checkout the PR head + if: steps.find.outputs.pr != '' + uses: actions/checkout@v7 + with: + repository: ${{ steps.find.outputs.head-repo }} + ref: ${{ steps.find.outputs.head-sha }} + path: head + fetch-depth: 1 + lfs: 'false' + persist-credentials: false + sparse-checkout: | + src + tests + + # The fix was made on the PR's merge commit, so it is applied to the head + # and diffed again, giving line numbers in the PR's own files. + - name: Check the fix and place its suggestions + id: place + if: steps.find.outputs.pr != '' + env: + RANGES: ${{ steps.find.outputs.ranges }} + run: | + python3 -I - "$RUNNER_TEMP/clang-tidy-fix/fix.patch" head "$RUNNER_TEMP" <<'EOF' + import json, os, re, subprocess, sys + + patch_path, head, out = sys.argv[1:] + ranges = json.loads(os.environ["RANGES"]) + + def output(**values): + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: + for key, value in values.items(): + f.write(f"{key}={value}\n") + + patch = open(patch_path, encoding="utf-8").read() + if not patch.strip(): + output(result="empty") + sys.exit() + + # Every line the patch adds must be an #include or blank, in a C++ file the + # PR changes, and it may not remove, create, rename or change the mode of + # anything. + include = re.compile(r'\s*#\s*include\s*(<[\w./+-]+>|"[\w./+-]+")\s*') + header = re.compile(r"(index [0-9a-f]+\.\.[0-9a-f]+( \d+)?|@@ -\d+(,\d+)? \+\d+(,\d+)? @@.*)") + problems = [] + path = None + for line in patch.splitlines(): + if line.startswith("diff --git "): + match = re.fullmatch(r"diff --git a/(.+) b/\1", line) + path = match.group(1) if match else None + if path not in ranges: + problems.append(f"{line}: not a C++ file the PR changes") + # git appends a tab to the --- and +++ lines of a path that contains a space. + elif line.removesuffix("\t") in (f"--- a/{path}", f"+++ b/{path}") or header.fullmatch(line): + pass + elif line.startswith(" ") or line == "\\ No newline at end of file": + pass + elif line.startswith("+") and (include.fullmatch(line[1:]) or not line[1:].strip()): + pass + else: + problems.append(f"{path}: {line}") + if problems: + for problem in problems: + print(f"::error::Refused patch line {problem}") + output(result="rejected") + sys.exit() + + if subprocess.run(["git", "-C", head, "apply", "--index", os.path.abspath(patch_path)]).returncode != 0: + output(result="apply-failed") + sys.exit() + + git = ["git", "-C", head, "-c", "core.quotePath=false", "diff", "--cached", "--no-color", "--no-ext-diff", + "--src-prefix=a/", "--dst-prefix=b/"] + with open(os.path.join(out, "head.patch"), "w", encoding="utf-8") as f: + f.write(subprocess.run(git, check=True, capture_output=True, encoding="utf-8").stdout) + + # Each run of added lines becomes one suggestion on the line it goes after + # (before, at the top of a file): that line, replaced by itself and the + # includes. It can only sit on a line the PR's diff shows. + hunks = [] + path = None + for line in subprocess.run(git + ["-U0"], check=True, capture_output=True, encoding="utf-8").stdout.splitlines(): + if line.startswith("diff --git "): + path = re.fullmatch(r"diff --git a/(.+) b/\1", line).group(1) + elif line.startswith("@@ "): + hunks.append((path, int(re.match(r"@@ -(\d+)", line).group(1)), [])) + elif hunks and line.startswith("+") and not line.startswith("+++ "): + hunks[-1][2].append(line[1:]) + + suggestions = [] + total = suggested = 0 + for path, after, added in hunks: + count = sum(bool(include.fullmatch(line)) for line in added) + total += count + original = subprocess.run(["git", "-C", head, "show", f"HEAD:{path}"], check=True, capture_output=True, + encoding="utf-8").stdout.splitlines() + line = max(after, 1) + if line > len(original) or not any(first <= line <= last for first, last in ranges[path]): + continue + body = "\n".join([original[line - 1], *added] if after else [*added, original[0]]) + # A fence longer than any run of backticks in the line it repeats. + fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", body)]) + suggestions.append({"path": path, "line": line, "side": "RIGHT", + "body": f"{fence}suggestion\n{body}\n{fence}"}) + suggested += count + with open(os.path.join(out, "suggestions.json"), "w", encoding="utf-8") as f: + json.dump(suggestions, f) + output(result="ok", includes=total, suggested=suggested) + EOF + + - name: Post the fix + if: always() && steps.find.outputs.pr != '' + uses: actions/github-script@v9 + env: + PR: ${{ steps.find.outputs.pr }} + MENTION: ${{ steps.find.outputs.mention }} + REQUEST_ID: ${{ steps.find.outputs.request-id }} + HEAD_SHA: ${{ steps.find.outputs.head-sha }} + RESULT: ${{ steps.place.outputs.result }} + INCLUDES: ${{ steps.place.outputs.includes }} + SUGGESTED: ${{ steps.place.outputs.suggested }} + with: + script: | + const fs = require('fs'); + const path = require('path'); + const MARKER = ''; + // GitHub's limit on a review body is 65536 characters. + const MAX_PATCH = 60000; + + const { owner, repo } = context.repo; + const { PR, MENTION, REQUEST_ID, HEAD_SHA, RESULT, INCLUDES, SUGGESTED, RUNNER_TEMP } = process.env; + const issue_number = Number(PR); + const sha = HEAD_SHA.slice(0, 10); + const fixUrl = context.payload.workflow_run.html_url; + const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + + const react = (content) => REQUEST_ID + ? github.rest.reactions.createForIssueComment({ owner, repo, comment_id: Number(REQUEST_ID), content }) + : Promise.resolve(); + async function reply(body, content) { + await react(content); + await github.rest.issues.createComment({ owner, repo, issue_number, body: `${MARKER}\n${MENTION}${body}` }); + } + + const read = (...parts) => fs.readFileSync(path.join(RUNNER_TEMP, ...parts), 'utf8'); + const passes = RESULT && read('clang-tidy-fix', 'status').trim() === '0'; + + if (RESULT === 'empty') { + return passes + ? reply(`clang-tidy passes on ${sha}. There is nothing to fix.`, '+1') + : reply(`clang-tidy has no automatic fix for what it reports on ${sha}. The findings, in the [fix run's log](${fixUrl}), need a manual edit.`, '-1'); + } + if (RESULT === 'rejected') { + return reply(`the clang-tidy fix changes more than includes, so I didn't post it. See the [run log](${runUrl}).`, '-1'); + } + if (RESULT === 'apply-failed') { + return reply(`the clang-tidy fix doesn't apply to ${sha}. Merge the base branch into the PR, then comment \`/bot fix-tidy\` again.`, '-1'); + } + if (RESULT !== 'ok') { + return reply(`something went wrong. See the [run log](${runUrl}).`, '-1'); + } + + const patch = read('head.patch'); + const suggestions = JSON.parse(read('suggestions.json')); + + const one = INCLUDES === '1'; + const includes = one ? '1 missing include' : `${INCLUDES} missing includes`; + const inline = SUGGESTED === '0' + ? (one ? 'It goes on a line this PR does not change, so I can\'t suggest it inline.' + : 'They go on lines this PR does not change, so I can\'t suggest them inline.') + : SUGGESTED === INCLUDES + ? (one ? 'It is suggested inline below.' : 'Each is suggested inline below.') + : `${SUGGESTED} of them are suggested inline below; the rest go on lines this PR does not change.`; + const remaining = passes + ? `With ${one ? 'it' : 'them'}, clang-tidy passes.` + : `Some findings remain that need a manual edit; see the [fix run's log](${fixUrl}).`; + // A fence longer than any run of backticks in the patch's context lines. + const fence = '`'.repeat(Math.max(3, ...[...patch.matchAll(/`+/g)].map((m) => m[0].length + 1))); + const apply = patch.length > MAX_PATCH + ? `The full patch is too large to show here. Download \`fix.patch\` from the [fix run](${fixUrl}) and run \`git apply fix.patch\`.` + : `To apply ${one ? 'it' : 'all of them'}, run this in your checkout of the PR branch:\n\n${fence}sh\ngit apply <<'EOF'\n${patch}EOF\n${fence}`; + const body = (withSuggestions) => `${MARKER}\n${MENTION}clang-tidy's fix for ${sha} adds ${includes}. ` + + `${withSuggestions ? `${inline} ` : ''}${remaining}\n\n${apply}`; + + const review = { owner, repo, pull_number: issue_number, commit_id: HEAD_SHA, event: 'COMMENT' }; + try { + await github.rest.pulls.createReview({ ...review, body: body(true), comments: suggestions }); + } catch (error) { + // A suggestion GitHub will not place still leaves the patch. + core.warning(`Posting the suggestions failed (${error.message}); posting the patch alone.`); + await github.rest.pulls.createReview({ ...review, body: body(false) }); + } + await react('+1'); + + nudge: + if: >- + github.repository == 'OrcaSlicer/OrcaSlicer' + && github.event_name == 'workflow_run' + && github.event.workflow_run.name == 'clang-tidy' + && github.event.workflow_run.event == 'pull_request' + concurrency: + group: ${{ github.workflow }}-nudge-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + permissions: + actions: read # the check's steps + pull-requests: write # comment on the PR + issues: write # the comment + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Ask for a fix when the check fails + uses: actions/github-script@v9 + with: + script: | + const MARKER = ''; + // The clang-tidy step's name in clang_tidy.yml. A failure in any other + // step is the CI's, not the PR's. + const TIDY_STEP = 'Run clang-tidy on the changed lines'; + + const { owner, repo } = context.repo; + const run = context.payload.workflow_run; + + // workflow_run lists no PR for a fork's branch, so the PR is found by its branch. + const { data: prs } = await github.rest.pulls.list({ + owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}` + }); + const pr = prs.find((p) => p.head.sha === run.head_sha); + if (!pr) { + core.info('No open PR at this run\'s commit; a newer run will answer.'); + return; + } + + const { data: { jobs } } = await github.rest.actions.listJobsForWorkflowRun({ owner, repo, run_id: run.id }); + const failed = jobs.some((job) => (job.steps || []).some((step) => step.name === TIDY_STEP && step.conclusion === 'failure')); + + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }); + const previous = comments.find((c) => c.user.type === 'Bot' && (c.body || '').startsWith(MARKER)); + + if (!failed) { + // Removes the request once the check passes. + if (previous && run.conclusion === 'success') { + await github.rest.issues.deleteComment({ owner, repo, comment_id: previous.id }); + } + return; + } + + const body = `${MARKER}\n@${pr.user.login} the clang-tidy check found problems in this PR's changes ` + + `at ${run.head_sha.slice(0, 10)} ([log](${run.html_url})). Missing includes can be fixed either way:\n\n` + + '- run `scripts/run_clang_tidy.sh --fix` (`scripts\\run_clang_tidy.ps1 -Fix` on Windows) and push the result, or\n' + + '- comment `/bot fix-tidy`, and I\'ll suggest the fix here for you to apply.\n\n' + + 'Other findings, such as a `using` in a header, need a manual edit.'; + // Edits the earlier request instead of posting another, so each push does not notify again. + if (previous) { + await github.rest.issues.updateComment({ owner, repo, comment_id: previous.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body }); + }