From 8807106ab85cc60d96174e980287d267547d9026 Mon Sep 17 00:00:00 2001 From: David Kaufman Date: Mon, 19 Feb 2024 08:04:04 -0500 Subject: [PATCH 1/8] [Path] disable wire sorting for ZigZagOffset and Offset Also enable the Min Travel checkbox unconditionally, since it can now be used to override this "disable" feature and enable wire sorting --- src/Mod/Path/Path/Op/Area.py | 4 ++++ src/Mod/Path/Path/Op/Gui/PocketBase.py | 9 --------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/Mod/Path/Path/Op/Area.py b/src/Mod/Path/Path/Op/Area.py index 5a3756cacd..c887839b64 100644 --- a/src/Mod/Path/Path/Op/Area.py +++ b/src/Mod/Path/Path/Op/Area.py @@ -272,6 +272,10 @@ class ObjectOp(PathOp.ObjectOp): # Note that emitting preambles between moves breaks some dressups and prevents path optimization on some controllers pathParams["preamble"] = False + # disable path sorting for offset and zigzag-offset paths + if hasattr(obj, "OffsetPattern") and obj.OffsetPattern in ["ZigZagOffset", "Offset"] and hasattr(obj, "MinTravel") and not obj.MinTravel: + pathParams["sort_mode"] = 0 + if not self.areaOpRetractTool(obj): pathParams["threshold"] = 2.001 * self.radius diff --git a/src/Mod/Path/Path/Op/Gui/PocketBase.py b/src/Mod/Path/Path/Op/Gui/PocketBase.py index b45410242f..f6ebd7635d 100644 --- a/src/Mod/Path/Path/Op/Gui/PocketBase.py +++ b/src/Mod/Path/Path/Op/Gui/PocketBase.py @@ -93,19 +93,10 @@ class TaskPanelOpPage(PathOpGui.TaskPanelPage): if not (FeatureRestMachining & self.pocketFeatures()): form.useRestMachining.hide() - # if True: - # # currently doesn't have an effect or is experimental - # form.minTravel.hide() return form def updateMinTravel(self, obj, setModel=True): - if obj.UseStartPoint: - self.form.minTravel.setEnabled(True) - else: - self.form.minTravel.setChecked(False) - self.form.minTravel.setEnabled(False) - if setModel and obj.MinTravel != self.form.minTravel.isChecked(): obj.MinTravel = self.form.minTravel.isChecked() From 583c86d1575c439e162599469a11eab4c2401fc7 Mon Sep 17 00:00:00 2001 From: David Kaufman Date: Mon, 19 Feb 2024 11:51:33 -0500 Subject: [PATCH 2/8] [Path] Change ZigZagOffset so the profile starts at the end of the zigzag --- src/Mod/Path/libarea/Area.cpp | 103 +++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 3 deletions(-) diff --git a/src/Mod/Path/libarea/Area.cpp b/src/Mod/Path/libarea/Area.cpp index 4346eb18a0..3b0f12b8c3 100644 --- a/src/Mod/Path/libarea/Area.cpp +++ b/src/Mod/Path/libarea/Area.cpp @@ -624,11 +624,108 @@ void CArea::MakePocketToolpath(std::list &curve_list, const CAreaPocketP if(params.mode == SingleOffsetPocketMode || params.mode == ZigZagThenSingleOffsetPocketMode) { + // if there are already curves, attempt to start the offset from the current tool position + bool done = false; + if (!curve_list.empty() && !curve_list.back().m_vertices.empty()) { + // find the closest curve to the start point + const Point start = curve_list.back().m_vertices.back().m_p; + auto curve_itmin = a_offset.m_curves.begin(); + double dmin = Point::tolerance; + for (auto it = a_offset.m_curves.begin(); it != a_offset.m_curves.end(); it++) { + const double dist = it->NearestPoint(start).dist(start); + if (dist < dmin) { + dmin = dist; + curve_itmin = it; + } + } + + // if the start point is on that curve (within Point::tolerance), do the profile starting on that curve + if (dmin < Point::tolerance) { + // split the curve into two parts -- starting with this point, and ending with this point + CCurve startCurve; + CCurve endCurve; + + std::list spans; + curve_itmin->GetSpans(spans); + int imin = -1; + double dmin = std::numeric_limits::max(); + Point nmin; + Span smin; + { + int i = 0; + for (auto it = spans.begin(); it != spans.end(); i++, it++) { + const Point nearest = it->NearestPoint(start); + const double dist = nearest.dist(start); + if (dist < dmin) { + dmin = dist; + imin = i; + nmin = nearest; + smin = *it; + } + } + } + + startCurve.append(CVertex(nmin)); + endCurve.append(curve_itmin->m_vertices.front()); + { + int i =0; + for (auto it = spans.begin(); it != spans.end(); i++, it++) { + if (i < imin) { + endCurve.append(it->m_v); + } else if (i > imin) { + startCurve.append(it->m_v); + } else { + if (nmin != endCurve.m_vertices.back().m_p) { + endCurve.append(CVertex(smin.m_v.m_type, nmin, smin.m_v.m_c, smin.m_v.m_user_data)); + } + if (nmin != it->m_v.m_p) { + startCurve.append(CVertex(smin.m_v.m_type, it->m_v.m_p, smin.m_v.m_c, smin.m_v.m_user_data)); + } + } + } + } + + // append curves to the curve list: start curve, other curves wrapping around, end curve + const auto appendCurve = [&curve_list](const CCurve &curve) { + if (curve_list.size() > 0 && curve_list.back().m_vertices.back().m_p == curve.m_vertices.front().m_p) { + auto it = curve.m_vertices.begin(); + for (it++; it != curve.m_vertices.end(); it++) { + curve_list.back().append(*it); + } + } else { + curve_list.push_back(curve); + } + }; + + if (startCurve.m_vertices.size() > 1) { + appendCurve(startCurve); + } + { + auto it = curve_itmin; + for(it++; it != a_offset.m_curves.end(); it++) { + appendCurve(*it); + } + } + for(auto it = a_offset.m_curves.begin(); it != curve_itmin; it++) { + appendCurve(*it); + } + if (endCurve.m_vertices.size() > 1) { + appendCurve(endCurve); + } + + + done = true; + } + } + // add the single offset too - for(std::list::iterator It = a_offset.m_curves.begin(); It != a_offset.m_curves.end(); It++) + if (!done) { - CCurve& curve = *It; - curve_list.push_back(curve); + for(std::list::iterator It = a_offset.m_curves.begin(); It != a_offset.m_curves.end(); It++) + { + CCurve& curve = *It; + curve_list.push_back(curve); + } } } } From 101121f0d70990c037c00c92d062940ef804a591 Mon Sep 17 00:00:00 2001 From: jffmichi <> Date: Sun, 3 Mar 2024 03:57:17 +0100 Subject: [PATCH 3/8] Path: fix error in Tag dressup when adding a point. --- src/Mod/Path/Path/Base/Gui/GetPoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mod/Path/Path/Base/Gui/GetPoint.py b/src/Mod/Path/Path/Base/Gui/GetPoint.py index 9a2a94bd35..2e4c4f917e 100644 --- a/src/Mod/Path/Path/Base/Gui/GetPoint.py +++ b/src/Mod/Path/Path/Base/Gui/GetPoint.py @@ -206,7 +206,7 @@ class TaskPanel: if cleanup: self.removeGlobalCallbacks() - FreeCADGui.Snapper.off(True) + FreeCADGui.Snapper.off() if self.buttonBox: self.buttonBox.setEnabled(True) self.removeEscapeShortcut() From cc8fbf83e7dc156643d6c343286cf00a4ef95658 Mon Sep 17 00:00:00 2001 From: wmayer Date: Mon, 4 Mar 2024 10:27:18 +0100 Subject: [PATCH 4/8] Gui: Enable SoModelMatrixElement The element type SoModelMatrixElement must be enabled for the node types SoHighlightElementAction and SoSelectionElementAction. Otherwise an assert() will fail and causes a crash in debug mode. --- src/Gui/SoFCUnifiedSelection.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Gui/SoFCUnifiedSelection.cpp b/src/Gui/SoFCUnifiedSelection.cpp index ff66d3a678..c853ce333b 100644 --- a/src/Gui/SoFCUnifiedSelection.cpp +++ b/src/Gui/SoFCUnifiedSelection.cpp @@ -782,6 +782,7 @@ void SoHighlightElementAction::initClass() SO_ACTION_INIT_CLASS(SoHighlightElementAction,SoAction); SO_ENABLE(SoHighlightElementAction, SoSwitchElement); + SO_ENABLE(SoHighlightElementAction, SoModelMatrixElement); SO_ACTION_ADD_METHOD(SoNode,nullAction); @@ -849,6 +850,7 @@ void SoSelectionElementAction::initClass() SO_ACTION_INIT_CLASS(SoSelectionElementAction,SoAction); SO_ENABLE(SoSelectionElementAction, SoSwitchElement); + SO_ENABLE(SoSelectionElementAction, SoModelMatrixElement); SO_ACTION_ADD_METHOD(SoNode,nullAction); From debc7df1488fd5ded03817d3a9d46605e844cd97 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Sun, 3 Mar 2024 22:47:11 -0600 Subject: [PATCH 5/8] Draft: Translate annotation styles editor --- .../gui_annotationstyleeditor.py | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Mod/Draft/draftguitools/gui_annotationstyleeditor.py b/src/Mod/Draft/draftguitools/gui_annotationstyleeditor.py index 012f12badc..d0545687b7 100644 --- a/src/Mod/Draft/draftguitools/gui_annotationstyleeditor.py +++ b/src/Mod/Draft/draftguitools/gui_annotationstyleeditor.py @@ -212,20 +212,20 @@ class AnnotationStyleEditor(gui_base.GuiCommandSimplest): elif index == 1: # Add new... entry reply = QtWidgets.QInputDialog.getText(None, - "Create new style", - "Style name:") + translate("draft", "Create new style"), + translate("draft", "Style name:")) if reply[1]: # OK or Enter pressed name = reply[0].strip() if name == "": QtWidgets.QMessageBox.information(None, - "Style name required", - "No style name specified") + translate("draft", "Style name required"), + translate("draft", "No style name specified")) self.form.comboBoxStyles.setCurrentIndex(0) elif name in self.styles: QtWidgets.QMessageBox.information(None, - "Style exists", - "This style name already exists") + translate("draft", "Style exists"), + translate("draft", "This style name already exists")) self.form.comboBoxStyles.setCurrentIndex(0) else: # create new style from current editor values @@ -253,10 +253,10 @@ class AnnotationStyleEditor(gui_base.GuiCommandSimplest): if self.get_style_users(style): reply = QtWidgets.QMessageBox.question(None, - "Style in use", - "This style is used by some objects in this document. Are you sure?", - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.No) + translate("draft", "Style in use"), + translate("draft", "This style is used by some objects in this document. Are you sure?"), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No) if reply == QtWidgets.QMessageBox.No: return self.form.comboBoxStyles.removeItem(index) @@ -268,8 +268,8 @@ class AnnotationStyleEditor(gui_base.GuiCommandSimplest): style = self.form.comboBoxStyles.itemText(index) reply = QtWidgets.QInputDialog.getText(None, - "Rename style", - "New name:", + translate("draft", "Rename style"), + translate("draft", "New name:"), QtWidgets.QLineEdit.Normal, style) if reply[1]: @@ -277,8 +277,8 @@ class AnnotationStyleEditor(gui_base.GuiCommandSimplest): newname = reply[0] if newname in self.styles: reply = QtWidgets.QMessageBox.information(None, - "Style exists", - "This style name already exists") + translate("draft", "Style exists"), + translate("draft", "This style name already exists")) else: self.form.comboBoxStyles.setItemText(index, newname) value = self.styles[style] From 8e6a308e03a405c13568c3b7bacb6dac6e66f7d5 Mon Sep 17 00:00:00 2001 From: Roy-043 Date: Mon, 4 Mar 2024 11:33:10 +0100 Subject: [PATCH 6/8] Draft: applyConstructionStyle was missing in params.py --- src/Mod/Draft/draftutils/params.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Mod/Draft/draftutils/params.py b/src/Mod/Draft/draftutils/params.py index 8839452c7f..c46dbac31f 100644 --- a/src/Mod/Draft/draftutils/params.py +++ b/src/Mod/Draft/draftutils/params.py @@ -393,6 +393,7 @@ def _get_param_dictionary(): # Arch parameters that are not in the preferences: param_dict["Mod/Arch"] = { + "applyConstructionStyle": ("bool", True), "ClaimHosted": ("bool", True), "CustomIfcSchema": ("string", ""), # importIFClegacy.py "createIfcGroups": ("bool", False), # importIFClegacy.py From e0ecd213b2c34673cb58b9ee04f3a3db152e4b09 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Sun, 3 Mar 2024 20:56:29 -0600 Subject: [PATCH 7/8] PD: Translate wire not closed exception This is a user-visible exception, shown in a dialog box --- src/Mod/Part/App/FaceMakerBullseye.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mod/Part/App/FaceMakerBullseye.cpp b/src/Mod/Part/App/FaceMakerBullseye.cpp index 1937ae69d8..72ac281225 100644 --- a/src/Mod/Part/App/FaceMakerBullseye.cpp +++ b/src/Mod/Part/App/FaceMakerBullseye.cpp @@ -71,7 +71,7 @@ void FaceMakerBullseye::Build_Essence() //validity check for (TopoDS_Wire& w : myWires) { if (!BRep_Tool::IsClosed(w)) - throw Base::ValueError("Wire is not closed."); + throw Base::ValueError(QT_TRANSLATE_NOOP("Exception", "Wire is not closed.")); } From cf88b0e5f6c35196a3c95df4977ff5622fa49985 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Mon, 4 Mar 2024 08:56:55 -0600 Subject: [PATCH 8/8] Addon Manager: Refactor primary view and enable composite (#12693) * Addon Manager: Refactor primary view and enable composite This brings back something akin to the original Addon Manager display, with the side-by-side display of the list and details views. --- src/Mod/AddonManager/AddonManager.py | 112 ++++++---------- src/Mod/AddonManager/CMakeLists.txt | 3 +- src/Mod/AddonManager/TODO.md | 6 - .../addonmanager_widget_addon_buttons.py | 5 + ...ddonmanager_widget_package_details_view.py | 4 + .../addonmanager_widget_readme_browser.py | 2 + .../addonmanager_widget_view_selector.py | 1 - src/Mod/AddonManager/addonmanager_git.py | 2 - ...addonmanager_package_details_controller.py | 49 ++++--- .../addonmanager_preferences_defaults.json | 1 + src/Mod/AddonManager/composite_view.py | 120 ++++++++++++++++-- src/Mod/AddonManager/package_list.py | 56 ++++++-- 12 files changed, 242 insertions(+), 119 deletions(-) diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py index 03e2f503b1..b234b3e538 100644 --- a/src/Mod/AddonManager/AddonManager.py +++ b/src/Mod/AddonManager/AddonManager.py @@ -54,10 +54,9 @@ from addonmanager_update_all_gui import UpdateAllGUI import addonmanager_utilities as utils import addonmanager_freecad_interface as fci import AddonManager_rc # This is required by Qt, it's not unused -from package_list import PackageList, PackageListItemModel -from addonmanager_package_details_controller import PackageDetailsController -from Widgets.addonmanager_widget_package_details_view import PackageDetailsView +from composite_view import CompositeView from Widgets.addonmanager_widget_global_buttons import WidgetGlobalButtonBar +from package_list import PackageListItemModel from Addon import Addon from AddonStats import AddonStats from manage_python_dependencies import ( @@ -138,11 +137,13 @@ class CommandAddonManager: self.update_all_worker = None self.developer_mode = None self.installer_gui = None + self.composite_view = None self.button_bar = None self.update_cache = False self.dialog = None self.startup_sequence = [] + self.packages_with_updates = set() # Set up the connection checker self.connection_checker = ConnectionCheckerGUI() @@ -193,7 +194,7 @@ class CommandAddonManager: pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") w = pref.GetInt("WindowWidth", 800) h = pref.GetInt("WindowHeight", 600) - self.dialog.resize(w, h) + self.composite_view = CompositeView(self.dialog) self.button_bar = WidgetGlobalButtonBar(self.dialog) # If we are checking for updates automatically, hide the Check for updates button: @@ -204,19 +205,11 @@ class CommandAddonManager: self.button_bar.update_all_addons.hide() # Set up the listing of packages using the model-view-controller architecture - self.package_list = PackageList(self.dialog) self.item_model = PackageListItemModel() - self.package_list.setModel(self.item_model) - self.dialog.layout().addWidget(self.package_list) + self.composite_view.setModel(self.item_model) + self.dialog.layout().addWidget(self.composite_view) self.dialog.layout().addWidget(self.button_bar) - # Package details start out hidden - self.packageDetails = PackageDetailsView(self.dialog) - self.package_details_controller = PackageDetailsController(self.packageDetails) - self.packageDetails.hide() - index = self.dialog.layout().indexOf(self.package_list) - self.dialog.layout().insertWidget(index, self.packageDetails) - # set nice icons to everything, by theme with fallback to FreeCAD icons self.dialog.setWindowIcon(QtGui.QIcon(":/icons/AddonManager.svg")) @@ -243,17 +236,16 @@ class CommandAddonManager: ) self.button_bar.python_dependencies.clicked.connect(self.show_python_updates_dialog) self.button_bar.developer_tools.clicked.connect(self.show_developer_tools) - self.package_list.ui.progressBar.stop_clicked.connect(self.stop_update) - self.package_list.itemSelected.connect(self.table_row_activated) - self.package_list.setEnabled(False) - self.package_details_controller.execute.connect(self.executemacro) - self.package_details_controller.install.connect(self.launch_installer_gui) - self.package_details_controller.uninstall.connect(self.remove) - self.package_details_controller.update.connect(self.update) - self.package_details_controller.back.connect(self.on_buttonBack_clicked) - self.package_details_controller.update_status.connect(self.status_updated) + self.composite_view.package_list.ui.progressBar.stop_clicked.connect(self.stop_update) + self.composite_view.package_list.setEnabled(False) + self.composite_view.execute.connect(self.executemacro) + self.composite_view.install.connect(self.launch_installer_gui) + self.composite_view.uninstall.connect(self.remove) + self.composite_view.update.connect(self.update) + self.composite_view.update_status.connect(self.status_updated) # center the dialog over the FreeCAD window + self.dialog.resize(w, h) mw = FreeCADGui.getMainWindow() self.dialog.move( mw.frameGeometry().topLeft() + mw.rect().center() - self.dialog.rect().center() @@ -400,14 +392,11 @@ class CommandAddonManager: self.check_python_updates, self.fetch_addon_stats, self.fetch_addon_score, + self.select_addon, ] pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") if pref.GetBool("DownloadMacros", False): self.startup_sequence.append(self.load_macro_metadata) - selection = pref.GetString("SelectedAddon", "") - if selection: - self.startup_sequence.insert(2, functools.partial(self.select_addon, selection)) - pref.SetString("SelectedAddon", "") self.number_of_progress_regions = len(self.startup_sequence) self.current_progress_region = 0 self.do_next_startup_phase() @@ -428,7 +417,7 @@ class CommandAddonManager: ) pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") pref.SetString("LastCacheUpdate", date.today().isoformat()) - self.package_list.item_filter.invalidateFilter() + self.composite_view.package_list.item_filter.invalidateFilter() def populate_packages_table(self) -> None: self.item_model.clear() @@ -481,8 +470,8 @@ class CommandAddonManager: f.write(json.dumps(self.package_cache, indent=" ")) def activate_table_widgets(self) -> None: - self.package_list.setEnabled(True) - self.package_list.ui.view_bar.search.setFocus() + self.composite_view.package_list.setEnabled(True) + self.composite_view.package_list.ui.view_bar.search.setFocus() self.do_next_startup_phase() def populate_macros(self) -> None: @@ -581,17 +570,12 @@ class CommandAddonManager: else: self.do_next_startup_phase() - def select_addon(self, name: str) -> None: - found = False - for addon in self.item_model.repos: - if addon.name == name: - self.table_row_activated(addon) - found = True - break - if not found: - FreeCAD.Console.PrintWarning( - translate("AddonsInstaller", "Could not find addon '{}' to select\n").format(name) - ) + def select_addon(self) -> None: + prefs = fci.Preferences() + selection = prefs.get("SelectedAddon") + if selection: + self.composite_view.package_list.select_addon(selection) + prefs.set("SelectedAddon", "") self.do_next_startup_phase() def check_updates(self) -> None: @@ -648,7 +632,11 @@ class CommandAddonManager: if number_of_updates: self.button_bar.set_number_of_available_updates(number_of_updates) - elif hasattr(self, "check_worker") and self.check_worker.isRunning(): + elif ( + hasattr(self, "check_worker") + and self.check_worker is not None + and self.check_worker.isRunning() + ): self.button_bar.update_all_addons.setText( translate("AddonsInstaller", "Checking for updates...") ) @@ -698,14 +686,14 @@ class CommandAddonManager: self.get_addon_score_worker.update_addon_score.connect(self.update_addon_score) self.get_addon_score_worker.start() else: - self.package_list.ui.view_bar.set_rankings_available(False) + self.composite_view.package_list.ui.view_bar.set_rankings_available(False) self.do_next_startup_phase() def update_addon_score(self, addon: Addon): self.item_model.reload_item(addon) def score_fetched_successfully(self): - self.package_list.ui.view_bar.set_rankings_available(True) + self.composite_view.package_list.ui.view_bar.set_rankings_available(True) def show_developer_tools(self) -> None: """Display the developer tools dialog""" @@ -780,27 +768,11 @@ class CommandAddonManager: return addonicon - def table_row_activated(self, selected_repo: Addon) -> None: - """a row was activated, show the relevant data""" - - self.package_list.hide() - self.packageDetails.show() - self.package_details_controller.show_repo(selected_repo) - def show_information(self, message: str) -> None: """shows generic text in the information pane""" - self.package_list.ui.progressBar.set_status(message) - self.package_list.ui.progressBar.repaint() - - def show_workbench(self, repo: Addon) -> None: - self.package_list.hide() - self.packageDetails.show() - self.package_details_controller.show_repo(repo) - - def on_buttonBack_clicked(self) -> None: - self.packageDetails.hide() - self.package_list.show() + self.composite_view.package_list.ui.progressBar.set_status(message) + self.composite_view.package_list.ui.progressBar.repaint() def append_to_repos_list(self, repo: Addon) -> None: """this function allows threads to update the main list of workbenches""" @@ -815,7 +787,7 @@ class CommandAddonManager: else: repo.set_status(Addon.Status.NO_UPDATE_AVAILABLE) self.item_model.reload_item(repo) - self.package_details_controller.show_repo(repo) + self.composite_view.package_details_controller.show_repo(repo) def launch_installer_gui(self, addon: Addon) -> None: if self.installer_gui is not None: @@ -861,12 +833,12 @@ class CommandAddonManager: def hide_progress_widgets(self) -> None: """hides the progress bar and related widgets""" - self.package_list.ui.progressBar.hide() - self.package_list.ui.view_bar.search.setFocus() + self.composite_view.package_list.ui.progressBar.hide() + self.composite_view.package_list.ui.view_bar.search.setFocus() def show_progress_widgets(self) -> None: - if self.package_list.ui.progressBar.isHidden(): - self.package_list.ui.progressBar.show() + if self.composite_view.package_list.ui.progressBar.isHidden(): + self.composite_view.package_list.ui.progressBar.show() def update_progress_bar(self, current_value: int, max_value: int) -> None: """Update the progress bar, showing it if it's hidden""" @@ -883,10 +855,10 @@ class CommandAddonManager: completed_region_portion = (self.current_progress_region - 1) * region_size current_region_portion = (float(current_value) / float(max_value)) * region_size value = completed_region_portion + current_region_portion - self.package_list.ui.progressBar.set_value( + self.composite_view.package_list.ui.progressBar.set_value( value * 10 ) # Out of 1000 segments, so it moves sort of smoothly - self.package_list.ui.progressBar.repaint() + self.composite_view.package_list.ui.progressBar.repaint() def stop_update(self) -> None: self.cleanup_workers() @@ -910,7 +882,7 @@ class CommandAddonManager: if repo.status() == Addon.Status.PENDING_RESTART: self.restart_required = True self.item_model.reload_item(repo) - self.package_details_controller.show_repo(repo) + self.composite_view.package_details_controller.show_repo(repo) if repo in self.packages_with_updates: self.packages_with_updates.remove(repo) self.enable_updates(len(self.packages_with_updates)) diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index 042ff80363..a311537cbf 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -31,6 +31,7 @@ SET(AddonManager_SRCS addonmanager_macro_parser.py addonmanager_metadata.py addonmanager_package_details_controller.py + addonmanager_preferences_defaults.json addonmanager_pyside_interface.py addonmanager_readme_controller.py addonmanager_update_all_gui.py @@ -47,6 +48,7 @@ SET(AddonManager_SRCS change_branch.py change_branch.ui compact_view.py + composite_view.py dependency_resolution_dialog.ui developer_mode.ui developer_mode_add_content.ui @@ -69,7 +71,6 @@ SET(AddonManager_SRCS loading.html manage_python_dependencies.py NetworkManager.py - addonmanager_package_details_controller.py package_list.py PythonDependencyUpdateDialog.ui select_toolbar_dialog.ui diff --git a/src/Mod/AddonManager/TODO.md b/src/Mod/AddonManager/TODO.md index 2c51912303..b1d3e7735f 100644 --- a/src/Mod/AddonManager/TODO.md +++ b/src/Mod/AddonManager/TODO.md @@ -1,14 +1,8 @@ # Addon Manager Future Work -* Restructure widgets into logical groups to better enable showing and hiding those groups all at once. * Reduce coupling between data and UI, switching logical groupings of widgets into a MVC or similar framework. * Particularly in the addons list -* Download Addon statistics from central location. - * Allow sorting on those statistics. -* Download a "rank" from user-specified locations. - * Allow sorting on that rank. * Implement a server-side cache of Addon metadata. * Implement an "offline mode" that does not attempt to use remote data for anything. * When installing a Preference Pack, offer to apply it once installed, and to undo after that. * Better support "headless" mode, with no GUI. -* Add "Composite" display mode, showing compact list and details at the same time diff --git a/src/Mod/AddonManager/Widgets/addonmanager_widget_addon_buttons.py b/src/Mod/AddonManager/Widgets/addonmanager_widget_addon_buttons.py index 21583e8850..8c65c5998c 100644 --- a/src/Mod/AddonManager/Widgets/addonmanager_widget_addon_buttons.py +++ b/src/Mod/AddonManager/Widgets/addonmanager_widget_addon_buttons.py @@ -75,6 +75,8 @@ class WidgetAddonButtons(QtWidgets.QWidget): self.retranslateUi(None) def _setup_ui(self): + if self.layout(): + self.setLayout(None) # TODO: Check this self.horizontal_layout = QtWidgets.QHBoxLayout() self.horizontal_layout.setContentsMargins(0, 0, 0, 0) self.back = QtWidgets.QToolButton(self) @@ -98,6 +100,9 @@ class WidgetAddonButtons(QtWidgets.QWidget): self.horizontal_layout.addWidget(self.change_branch) self.setLayout(self.horizontal_layout) + def set_show_back_button(self, show: bool) -> None: + self.back.setVisible(show) + def _set_icons(self): self.back.setIcon(QtGui.QIcon.fromTheme("back", QtGui.QIcon(":/icons/button_left.svg"))) diff --git a/src/Mod/AddonManager/Widgets/addonmanager_widget_package_details_view.py b/src/Mod/AddonManager/Widgets/addonmanager_widget_package_details_view.py index ce83b6af6e..fbe892cbb5 100644 --- a/src/Mod/AddonManager/Widgets/addonmanager_widget_package_details_view.py +++ b/src/Mod/AddonManager/Widgets/addonmanager_widget_package_details_view.py @@ -65,6 +65,7 @@ class MessageType(Enum): @dataclass class UpdateInformation: + unchecked: bool = True check_in_progress: bool = False update_available: bool = False detached_head: bool = False @@ -112,6 +113,7 @@ class PackageDetailsView(QtWidgets.QWidget): self.vertical_layout.addWidget(self.message_label) self.vertical_layout.addWidget(self.location_label) self.vertical_layout.addWidget(self.readme_browser) + self.button_bar.hide() # Start with no bar def set_location(self, location: Optional[str]): if location is not None: @@ -274,6 +276,8 @@ class PackageDetailsView(QtWidgets.QWidget): def _get_update_status_string(self) -> str: if self.update_info.check_in_progress: return translate("AddonsInstaller", "Update check in progress") + "." + elif self.update_info.unchecked: + return "" if self.update_info.detached_head: return ( translate( diff --git a/src/Mod/AddonManager/Widgets/addonmanager_widget_readme_browser.py b/src/Mod/AddonManager/Widgets/addonmanager_widget_readme_browser.py index 8049a5a434..0677e6f305 100644 --- a/src/Mod/AddonManager/Widgets/addonmanager_widget_readme_browser.py +++ b/src/Mod/AddonManager/Widgets/addonmanager_widget_readme_browser.py @@ -63,6 +63,7 @@ class WidgetReadmeBrowser(QtWidgets.QTextBrowser): def setMarkdown(self, md: str): """Provides an optional fallback to the markdown library for older versions of Qt (prior to 5.15) that did not have native markdown support. Lacking that, plaintext is displayed.""" + geometry = self.geometry() if hasattr(super(), "setMarkdown"): super().setMarkdown(md) else: @@ -76,6 +77,7 @@ class WidgetReadmeBrowser(QtWidgets.QTextBrowser): FreeCAD.Console.Warning( "Qt < 5.15 and no `import markdown` -- falling back to plain text display\n" ) + self.setGeometry(geometry) def set_resource(self, resource_url: str, image: Optional[QtGui.QImage]): """Once a resource has been fetched (or the fetch has failed), this method should be used to inform the widget diff --git a/src/Mod/AddonManager/Widgets/addonmanager_widget_view_selector.py b/src/Mod/AddonManager/Widgets/addonmanager_widget_view_selector.py index 2bfa935bd8..dca81ec6f8 100644 --- a/src/Mod/AddonManager/Widgets/addonmanager_widget_view_selector.py +++ b/src/Mod/AddonManager/Widgets/addonmanager_widget_view_selector.py @@ -119,7 +119,6 @@ class WidgetViewSelector(QtWidgets.QWidget): self.composite_button.setIcon( QtGui.QIcon.fromTheme("composite_button", QtGui.QIcon(":/icons/composite_view.svg")) ) - self.composite_button.hide() # TODO: Implement this view self.horizontal_layout.addWidget(self.compact_button) self.horizontal_layout.addWidget(self.expanded_button) diff --git a/src/Mod/AddonManager/addonmanager_git.py b/src/Mod/AddonManager/addonmanager_git.py index 3a63d74619..e252283493 100644 --- a/src/Mod/AddonManager/addonmanager_git.py +++ b/src/Mod/AddonManager/addonmanager_git.py @@ -442,10 +442,8 @@ class GitManager: on the Mac actually requires us to check for that installation.""" try: subprocess.check_output(["xcode-select", "-p"]) - fci.Console.PrintMessage("XCode command line tools are installed: git is available\n") return True except subprocess.CalledProcessError: - fci.Console.PrintMessage("XCode command line tools are not installed: not using git\n") return False def _synchronous_call_git(self, args: List[str]) -> str: diff --git a/src/Mod/AddonManager/addonmanager_package_details_controller.py b/src/Mod/AddonManager/addonmanager_package_details_controller.py index 13cb5c90b4..9c934039fb 100644 --- a/src/Mod/AddonManager/addonmanager_package_details_controller.py +++ b/src/Mod/AddonManager/addonmanager_package_details_controller.py @@ -55,7 +55,6 @@ class PackageDetailsController(QtCore.QObject): update = QtCore.Signal(Addon) execute = QtCore.Signal(Addon) update_status = QtCore.Signal(Addon) - check_for_update = QtCore.Signal(Addon) def __init__(self, widget=None): super().__init__() @@ -63,9 +62,10 @@ class PackageDetailsController(QtCore.QObject): self.readme_controller = ReadmeController(self.ui.readme_browser) self.worker = None self.addon = None - self.status_update_thread = None + self.update_check_thread = None self.original_disabled_state = None self.original_status = None + self.check_for_update_worker = None try: self.git_manager = GitManager() except NoGitFound: @@ -76,9 +76,6 @@ class PackageDetailsController(QtCore.QObject): self.ui.button_bar.install.clicked.connect(lambda: self.install.emit(self.addon)) self.ui.button_bar.uninstall.clicked.connect(lambda: self.uninstall.emit(self.addon)) self.ui.button_bar.update.clicked.connect(lambda: self.update.emit(self.addon)) - self.ui.button_bar.check_for_update.clicked.connect( - lambda: self.check_for_update.emit(self.addon) - ) self.ui.button_bar.change_branch.clicked.connect(self.change_branch_clicked) self.ui.button_bar.enable.clicked.connect(self.enable_clicked) self.ui.button_bar.disable.clicked.connect(self.disable_clicked) @@ -89,6 +86,10 @@ class PackageDetailsController(QtCore.QObject): self.addon = repo self.readme_controller.set_addon(repo) self.original_disabled_state = self.addon.is_disabled() + if repo is not None: + self.ui.button_bar.show() + else: + self.ui.button_bar.hide() if self.worker is not None: if not self.worker.isFinished(): @@ -99,6 +100,7 @@ class PackageDetailsController(QtCore.QObject): self.ui.set_installed(installed) update_info = UpdateInformation() if installed: + update_info.unchecked = self.addon.status() == Addon.Status.UNCHECKED update_info.update_available = self.addon.status() == Addon.Status.UPDATE_AVAILABLE update_info.check_in_progress = False # TODO: Implement the "check in progress" status if repo.metadata: @@ -117,25 +119,30 @@ class PackageDetailsController(QtCore.QObject): self.update_macro_info(repo) if repo.status() == Addon.Status.UNCHECKED: - if not self.status_update_thread: - self.status_update_thread = QtCore.QThread() - self.status_create_addon_list_worker = CheckSingleUpdateWorker(repo) - self.status_create_addon_list_worker.moveToThread(self.status_update_thread) - self.status_update_thread.finished.connect( - self.status_create_addon_list_worker.deleteLater + self.ui.button_bar.check_for_update.show() + self.ui.button_bar.check_for_update.setText( + translate("AddonsInstaller", "Check for " "update") ) - self.check_for_update.connect(self.status_create_addon_list_worker.do_work) - self.status_create_addon_list_worker.update_status.connect(self.display_repo_status) - self.status_update_thread.start() - update_info.check_in_progress = True - self.ui.set_update_available(update_info) - self.check_for_update.emit(self.addon) + self.ui.button_bar.check_for_update.setEnabled(True) + if not self.update_check_thread: + self.update_check_thread = QtCore.QThread() + self.check_for_update_worker = CheckSingleUpdateWorker(repo) + self.check_for_update_worker.moveToThread(self.update_check_thread) + self.update_check_thread.finished.connect(self.check_for_update_worker.deleteLater) + self.ui.button_bar.check_for_update.clicked.connect( + self.check_for_update_worker.do_work + ) + self.check_for_update_worker.update_status.connect(self.display_repo_status) + self.update_check_thread.start() + else: + self.ui.button_bar.check_for_update.hide() flags = WarningFlags() flags.required_freecad_version = self.requires_newer_freecad() flags.obsolete = repo.obsolete flags.python2 = repo.python2 self.ui.set_warning_flags(flags) + self.set_change_branch_button_state() def requires_newer_freecad(self) -> Optional[Version]: """If the current package is not installed, returns the first supported version of @@ -159,7 +166,7 @@ class PackageDetailsController(QtCore.QObject): """The change branch button is only available for installed Addons that have a .git directory and in runs where the git is available.""" - self.ui.button_bar.change_branch_button.hide() + self.ui.button_bar.change_branch.hide() pref = fci.ParamGet("User parameter:BaseApp/Preferences/Addons") show_switcher = pref.GetBool("ShowBranchSwitcher", False) @@ -186,7 +193,7 @@ class PackageDetailsController(QtCore.QObject): # If all four above checks passed, then it's possible for us to switch # branches, if there are any besides the one we are on: show the button - self.ui.button_bar.change_branch_button.show() + self.ui.button_bar.change_branch.show() def update_macro_info(self, repo: Addon) -> None: if not repo.macro.url: @@ -256,3 +263,7 @@ class PackageDetailsController(QtCore.QObject): self.addon.set_status(Addon.Status.PENDING_RESTART) self.ui.set_new_branch(name) self.update_status.emit(self.addon) + + def display_repo_status(self, addon): + self.update_status.emit(self.addon) + self.show_repo(self.addon) diff --git a/src/Mod/AddonManager/addonmanager_preferences_defaults.json b/src/Mod/AddonManager/addonmanager_preferences_defaults.json index c785c154a6..783d699b2b 100644 --- a/src/Mod/AddonManager/addonmanager_preferences_defaults.json +++ b/src/Mod/AddonManager/addonmanager_preferences_defaults.json @@ -5,6 +5,7 @@ "AddonsStatsURL": "https://freecad.org/addon_stats.json", "AutoCheck": false, "BlockedMacros": "BOLTS,WorkFeatures,how to install,documentation,PartsLibrary,FCGear", + "CompositeSplitterState": "", "CustomRepoHash": "", "CustomRepositories": "", "CustomToolbarName": "Auto-Created Macro Toolbar", diff --git a/src/Mod/AddonManager/composite_view.py b/src/Mod/AddonManager/composite_view.py index b89041adad..bba9dff9ba 100644 --- a/src/Mod/AddonManager/composite_view.py +++ b/src/Mod/AddonManager/composite_view.py @@ -23,7 +23,15 @@ """ Provides a class for showing the list view and detail view at the same time. """ -import addonmanager_freecad_interface +import base64 + +from addonmanager_freecad_interface import Preferences + +from Addon import Addon +from Widgets.addonmanager_widget_package_details_view import PackageDetailsView +from addonmanager_package_details_controller import PackageDetailsController +from Widgets.addonmanager_widget_view_selector import AddonManagerDisplayStyle +from package_list import PackageList # Get whatever version of PySide we can try: @@ -43,14 +51,110 @@ from PySide import QtCore, QtWidgets class CompositeView(QtWidgets.QWidget): """A widget that displays the Addon Manager's top bar, the list of Addons, and the detail - view, all on a single pane (with no switching). Detail view is shown in its "icon-only" mode - for the installation, etc. buttons. The bottom bar remains visible throughout.""" + view. Depending on the view mode selected, these may all be displayed at once, or selecting + an addon in the list may case the list to hide and the detail view to show.""" + + install = QtCore.Signal(Addon) + uninstall = QtCore.Signal(Addon) + update = QtCore.Signal(Addon) + execute = QtCore.Signal(Addon) + update_status = QtCore.Signal(Addon) + check_for_update = QtCore.Signal(Addon) def __init__(self, parent=None): super().__init__(parent) + self.package_details = PackageDetailsView(self) + self.package_details_controller = PackageDetailsController(self.package_details) + self.package_list = PackageList(self) + prefs = Preferences() + self.display_style = prefs.get("ViewStyle") + self.main_layout = QtWidgets.QHBoxLayout(self) + self.splitter = QtWidgets.QSplitter(self) + self.splitter.addWidget(self.package_list) + self.package_list.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + self.splitter.addWidget(self.package_details) + self.package_details.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + self.splitter.setOrientation(QtCore.Qt.Horizontal) + self.splitter.setContentsMargins(0, 0, 0, 0) + self.splitter.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) + self.main_layout.addWidget(self.splitter) + self.layout().setContentsMargins(0, 0, 0, 0) + self._setup_ui() + self._setup_connections() + self._restore_splitter_state() - # TODO: Refactor the Addon Manager's display into four custom widgets: - # 1) The top bar showing the filter and search - # 2) The package list widget, which can take three forms (expanded, compact, and list) - # 3) The installer bar, which can take two forms (text and icon) - # 4) The bottom bar + def _save_splitter_state(self): + """Write the splitter state into an Addon manager preference, CompositeSplitterState""" + prefs = Preferences() + state = self.splitter.saveState() + encoded = base64.b64encode(state).decode("ASCII") + prefs.set("CompositeSplitterState", encoded) + + def _restore_splitter_state(self): + """Restore the splitter state from CompositeSplitterState""" + prefs = Preferences() + encoded = prefs.get("CompositeSplitterState") + if encoded: + state = base64.b64decode(encoded) + self.splitter.restoreState(state) + + def setModel(self, model): + self.package_list.setModel(model) + + def set_display_style(self, style: AddonManagerDisplayStyle): + self.display_style = style + self._setup_ui() + + def _setup_ui(self): + if self.display_style == AddonManagerDisplayStyle.EXPANDED: + self._setup_expanded_ui() + elif self.display_style == AddonManagerDisplayStyle.COMPACT: + self._setup_compact_ui() + elif self.display_style == AddonManagerDisplayStyle.COMPOSITE: + self._setup_composite_ui() + else: + raise RuntimeError("Invalid display style") + self.package_list.set_view_style(self.display_style) + + def _setup_expanded_ui(self): + self.package_list.show() + self.package_details.hide() + self.package_details.button_bar.set_show_back_button(True) + + def _setup_compact_ui(self): + self.package_list.show() + self.package_details.hide() + self.package_details.button_bar.set_show_back_button(True) + + def _setup_composite_ui(self): + self.package_list.show() + self.package_details.show() + self.package_details.button_bar.set_show_back_button(False) + + def _setup_connections(self): + self.package_list.itemSelected.connect(self.addon_selected) + self.package_details_controller.back.connect(self._back_button_clicked) + self.package_details_controller.install.connect(self.install) + self.package_details_controller.uninstall.connect(self.uninstall) + self.package_details_controller.update.connect(self.update) + self.package_details_controller.execute.connect(self.execute) + self.package_details_controller.update_status.connect(self.update_status) + self.package_list.ui.view_bar.view_changed.connect(self.set_display_style) + self.splitter.splitterMoved.connect(self._splitter_moved) + + def addon_selected(self, addon): + self.package_details_controller.show_repo(addon) + if self.display_style != AddonManagerDisplayStyle.COMPOSITE: + self.package_list.hide() + self.package_details.show() + self.package_details.button_bar.set_show_back_button(True) + + def _back_button_clicked(self): + if self.display_style != AddonManagerDisplayStyle.COMPOSITE: + self.package_list.show() + self.package_details.hide() + + def _splitter_moved(self, position: int, index: int) -> None: + self._save_splitter_state() diff --git a/src/Mod/AddonManager/package_list.py b/src/Mod/AddonManager/package_list.py index 78c7086239..c7a2787aab 100644 --- a/src/Mod/AddonManager/package_list.py +++ b/src/Mod/AddonManager/package_list.py @@ -65,7 +65,6 @@ class PackageList(QtWidgets.QWidget): self.ui.listPackages.setItemDelegate(self.item_delegate) self.ui.listPackages.clicked.connect(self.on_listPackages_clicked) - self.ui.view_bar.view_changed.connect(self.set_view_style) self.ui.view_bar.filter_changed.connect(self.update_status_filter) self.ui.view_bar.search_changed.connect(self.item_filter.setFilterRegularExpression) self.ui.view_bar.sort_changed.connect(self.item_filter.setSortRole) @@ -105,6 +104,20 @@ class PackageList(QtWidgets.QWidget): ) self.item_filter.setHideUnlicensed(pref.GetBool("HideUnlicensed", False)) + def select_addon(self, addon_name: str): + for index, addon in enumerate(self.item_model.repos): + if addon.name == addon_name: + row_index = self.item_model.createIndex(index, 0) + if self.item_filter.filterAcceptsRow(index): + self.ui.listPackages.setCurrentIndex(row_index) + else: + FreeCAD.Console.PrintLog( + f"Addon {addon_name} is not visible given current " + "filter: not selecting it." + ) + return + FreeCAD.Console.PrintLog(f"Could not find addon '{addon_name}' to select it") + def on_listPackages_clicked(self, index: QtCore.QModelIndex): """Determine what addon was selected and emit the itemSelected signal with it as an argument.""" @@ -124,10 +137,10 @@ class PackageList(QtWidgets.QWidget): def set_view_style(self, style: AddonManagerDisplayStyle) -> None: """Set the style (compact or expanded) of the list""" - self.item_model.layoutAboutToBeChanged.emit() + if self.item_model: + self.item_model.layoutAboutToBeChanged.emit() self.item_delegate.set_view(style) - # TODO: Update to support composite - if style == AddonManagerDisplayStyle.COMPACT: + if style == AddonManagerDisplayStyle.COMPACT or style == AddonManagerDisplayStyle.COMPOSITE: self.ui.listPackages.setSpacing(2) self.ui.listPackages.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerItem) self.ui.listPackages.verticalScrollBar().setSingleStep(-1) @@ -135,7 +148,8 @@ class PackageList(QtWidgets.QWidget): self.ui.listPackages.setSpacing(5) self.ui.listPackages.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) self.ui.listPackages.verticalScrollBar().setSingleStep(24) - self.item_model.layoutChanged.emit() + if self.item_model: + self.item_model.layoutChanged.emit() pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") pref.SetInt("ViewStyle", style) @@ -288,6 +302,9 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate): elif self.displayStyle == AddonManagerDisplayStyle.COMPACT: self.widget = self.compact self._setup_compact_view(repo) + elif self.displayStyle == AddonManagerDisplayStyle.COMPOSITE: + self.widget = self.compact # For now re-use the compact list + self._setup_composite_view(repo) self.widget.adjustSize() def _setup_expanded_view(self, addon: Addon) -> None: @@ -334,6 +351,22 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate): else: self.widget.ui.labelDescription.setText(self._get_sort_label_text(addon)) + def _setup_composite_view(self, addon: Addon) -> None: + self.widget.ui.labelPackageName.setText(f"{addon.display_name}") + self.widget.ui.labelIcon.setPixmap(addon.icon.pixmap(QtCore.QSize(16, 16))) + self.widget.ui.labelStatus.setText(self.get_compact_update_string(addon)) + self.widget.ui.labelIcon.setText("") + if addon.metadata: + self.widget.ui.labelVersion.setText(f"v{addon.metadata.version}") + elif addon.macro: + self._set_macro_version_label(addon) + else: + self.widget.ui.labelVersion.setText("") + if self.sort_order != SortOptions.Alphabetical: + self.widget.ui.labelDescription.setText(self._get_sort_label_text(addon)) + else: + self.widget.ui.labelDescription.setText("") + def _set_package_maintainer_label(self, addon: Addon): maintainers = addon.metadata.maintainer maintainers_string = "" @@ -395,14 +428,13 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate): return "" def _get_compact_description(self, addon: Addon) -> str: + description = "" if addon.metadata: - trimmed_text = addon.metadata.description - # TODO: Un-hardcode the 25 character limiter - return trimmed_text.replace("\r\n", " ")[:25] + "..." - if addon.macro and addon.macro.comment: - trimmed_text = addon.macro.comment - return trimmed_text.replace("\r\n", " ")[:25] + "..." - return "" + description = addon.metadata.description + elif addon.macro and addon.macro.comment: + description = addon.macro.comment + trimmed_text, _, _ = description.partition(".") + return trimmed_text.replace("\n", " ") @staticmethod def get_compact_update_string(repo: Addon) -> str: