From 1d3d2d6da0993503327a4aa345118417e614a210 Mon Sep 17 00:00:00 2001 From: Mike Hendricks Date: Thu, 6 Jun 2024 17:20:54 -0700 Subject: [PATCH] Add UI to enable optional distros when launching This is a gui interface allowing configs to define common optional distros and allowing a user to enable those distros. This interfaces with the `--requirement` hab cli argument. --- hab_gui/resources/README.md | 3 +- hab_gui/resources/arrow-left-top-bold.svg | 1 + hab_gui/settings.py | 18 +++ hab_gui/utils.py | 30 +++++ hab_gui/widgets/alias_button_grid.py | 4 - hab_gui/widgets/distro_picker.py | 73 +++++++++++ hab_gui/widgets/name_picker.py | 142 ++++++++++++++++++++++ hab_gui/widgets/ui/name_picker.ui | 60 +++++++++ hab_gui/windows/alias_launch_window.py | 21 ++++ 9 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 hab_gui/resources/arrow-left-top-bold.svg create mode 100644 hab_gui/widgets/distro_picker.py create mode 100644 hab_gui/widgets/name_picker.py create mode 100644 hab_gui/widgets/ui/name_picker.ui diff --git a/hab_gui/resources/README.md b/hab_gui/resources/README.md index 84d7883..fbaf8d7 100644 --- a/hab_gui/resources/README.md +++ b/hab_gui/resources/README.md @@ -5,11 +5,12 @@ Please make sure to update the sources table when adding or updating images. | File | Source | Notes | Author | |---|---|---|---| +| ![](hab_gui/resources/arrow-left-top-bold.svg) [arrow-left-top-bold.svg](hab_gui/resources/arrow-left-top-bold.svg) | https://pictogrammers.com/library/mdi/icon/arrow-left-top-bold/ | | [Colton Wiscombe](https://pictogrammers.com/contributor/Xenomorph99/) | | ![](hab_gui/resources/content-save.svg) [content-save.svg](hab_gui/resources/content-save.svg) | https://pictogrammers.com/library/mdi/icon/content-save/ | | Google | | ![](hab_gui/resources/habihat-white.svg) [habihat-white.svg](hab_gui/resources/habihat-white.svg) | | | Blur Studio | | ![](hab_gui/resources/habihat.svg) [habihat.svg](hab_gui/resources/habihat.svg) | | | Blur Studio | | ![](hab_gui/resources/menu.svg) [menu.svg](hab_gui/resources/menu.svg) | https://pictogrammers.com/library/mdi/icon/menu/ | | Google | -| ![](hab_gui/resources/minus-thick.svg) [minus-thick.svg](hab_gui/resources/minus-thick.svg) | https://pictogrammers.com/library/mdi/icon/minus-thick/ | | [Colton Wiscombe](https://pictogrammers.com/library/mdi/icon/minus-thick/) | +| ![](hab_gui/resources/minus-thick.svg) [minus-thick.svg](hab_gui/resources/minus-thick.svg) | https://pictogrammers.com/library/mdi/icon/minus-thick/ | | [Colton Wiscombe](https://pictogrammers.com/contributor/Xenomorph99/) | | ![](hab_gui/resources/pencil-box-outline.svg) [pencil-box-outline.svg](hab_gui/resources/pencil-box-outline.svg) | https://pictogrammers.com/library/mdi/icon/pencil-box-outline/ | | [Austin Andrews](https://pictogrammers.com/contributor/Templarian/) | | ![](hab_gui/resources/pin-off-outline.svg) [pin-off-outline.svg](hab_gui/resources/pin-off-outline.svg) | https://pictogrammers.com/library/mdi/icon/pin-off-outline/ | | [At Abbey's side](https://pictogrammers.com/library/mdi/icon/pin-off-outline/) | | ![](hab_gui/resources/pin-outline.svg) [pin-outline.svg](hab_gui/resources/pin-outline.svg) | https://pictogrammers.com/library/mdi/icon/pin-outline/ | | Google | diff --git a/hab_gui/resources/arrow-left-top-bold.svg b/hab_gui/resources/arrow-left-top-bold.svg new file mode 100644 index 0000000..2a7ee4c --- /dev/null +++ b/hab_gui/resources/arrow-left-top-bold.svg @@ -0,0 +1 @@ + diff --git a/hab_gui/settings.py b/hab_gui/settings.py index c5da7c4..ef974e3 100644 --- a/hab_gui/settings.py +++ b/hab_gui/settings.py @@ -69,6 +69,24 @@ def verbosity(self, value): user_prefs.save() logger.debug(f"User prefs verbosity saved to {user_prefs.filename}") + def user_pref(self, key, default=None): + """Returns the value for a specific user_prefs setting or default.""" + user_prefs = self.resolver.user_prefs() + if user_prefs.enabled: + user_prefs.load() + return user_prefs.get(key, default) + return default + + def set_user_pref(self, key, value): + """Update a specific user_pref and save prefs to disk.""" + user_prefs = self.resolver.user_prefs() + if user_prefs.enabled: + user_prefs.load() + user_prefs[key] = value + user_prefs.save() + return True + return False + @property def uri(self): return self._uri diff --git a/hab_gui/utils.py b/hab_gui/utils.py index cb9a6f4..cc37abc 100644 --- a/hab_gui/utils.py +++ b/hab_gui/utils.py @@ -190,3 +190,33 @@ def load_ui(filename, widget, ui_name=""): filename = filename.parent / "ui" / f"{ui_name}.ui" QtCompat.loadUi(filename, widget) + + +@contextmanager +def block_signals(objs): + """Block Qt signals while inside this with statement. + + Example:: + + with utils.block_signals(combo): + combo.setCurrentIndex(0) + + Args: + objs (list): List of Qt.QtCore.QObject's to block signals for. + + Yields: + List of (bool, QWidget) tuples where the bool is the widget's previous + blocking state. + """ + + # Store previous state + blocked = [(o, o.signalsBlocked()) for o in objs] + + for o in objs: + o.blockSignals(True) + + try: + yield list(blocked) + finally: + for o, b in blocked: + o.blockSignals(b) diff --git a/hab_gui/widgets/alias_button_grid.py b/hab_gui/widgets/alias_button_grid.py index c57b1c2..e8b3570 100644 --- a/hab_gui/widgets/alias_button_grid.py +++ b/hab_gui/widgets/alias_button_grid.py @@ -67,10 +67,6 @@ def refresh(self): for button_name, button_coord in button_coords.items(): button = self.button_cls(cfg, button_name) self.grid_layout.addWidget(button, button_coord[0], button_coord[1]) - self.spacer_item = QtWidgets.QSpacerItem( - 20, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding - ) - self.grid_layout.addItem(self.spacer_item, self.grid_layout.rowCount(), 0, 1, 1) def clear(self): while self.grid_layout.count(): diff --git a/hab_gui/widgets/distro_picker.py b/hab_gui/widgets/distro_picker.py new file mode 100644 index 0000000..3a2373e --- /dev/null +++ b/hab_gui/widgets/distro_picker.py @@ -0,0 +1,73 @@ +from hab.solvers import Solver +from hab.utils import NotSet +from Qt.QtCore import QTimer + +from .. import utils +from .name_picker import NamePicker + + +class DistroPicker(NamePicker): + """A widget for picking from a list of optional distros. + + This displays the `optional_distros` config setting. Any distros checked use + `hab.Resolver.forced_requirements` to load those distros. See the cli argument + `--requirement` for more info. + """ + + pref_name = "distro_picker" + + def __init__(self, settings, title="Options", label="Distro", parent=None): + super().__init__(settings, title=title, label=label, parent=parent) + # This widget needs to update the hab resolver before other widgets + # are updated. This signal is used to update forced_requirements. + self.settings.uri_changing.connect(self.refresh) + + def item_changed(self, item, column): + """Called when a item is modified, saves the user prefs when a checked + state is updated and updates the displayed aliases.""" + super().item_changed(item, column) + # Ensure the UI is updated with the new forced_requirements + self.update_requirements() + QTimer.singleShot(0, self.uri_changed) + + def reset_to_default(self): + """Reset the checked state of names to the default values, clearing + saved user_prefs for the current URI. + """ + super().reset_to_default() + # Refresh the alias button widget + self.update_requirements() + self.uri_changed() + + def update_requirements(self): + # Ensure the UI is updated with the new forced_requirements + selected = self.selected() + forced_requirements = Solver.simplify_requirements(list(selected)) + + # Preserve any requirements passed via the cli. + cli_reqs = self.settings.resolver.__forced_requirements__ + if cli_reqs: + # If the same distro is specified, the GUI's requirement should win. + forced_requirements = dict(cli_reqs, **forced_requirements) + + self.settings.resolver.forced_requirements = forced_requirements + + def uri_changed(self): + """Work function that forces the gui to update its aliases.""" + + # The refresh method is called when this signal is emitted, don't + # double process it by blocking signals. + with utils.block_signals([self.name_tree]): + self.settings.uri_changed.emit(self.settings.uri) + + def refresh(self, uri): + resolver = self.settings.resolver + cfg = resolver.resolve(uri) + optional = cfg.optional_distros + if optional is NotSet: + optional = {} + self.set_names(optional, uri=uri) + + # Ensure the alias_buttons widget has the updated requirements before + # it refreshes from the `uri_changed` signal emited later. + self.update_requirements() diff --git a/hab_gui/widgets/name_picker.py b/hab_gui/widgets/name_picker.py new file mode 100644 index 0000000..aacbe66 --- /dev/null +++ b/hab_gui/widgets/name_picker.py @@ -0,0 +1,142 @@ +from hab.parsers import HabBase +from Qt import QtCore, QtWidgets + +from .. import utils + + +class NamePicker(QtWidgets.QGroupBox): + """A widget for selecting from a set of names with a description. + + Provides a list view of names the user can check. A description can be shown + next to each name. + """ + + pref_name = None + """Defines the name of the user_pref key used to store selected names. If not + specified then saving is disabled for this class. If enabled then user_prefs + are saved when the check state of any item in this tree is updated. The saved + names are stored per modified URI, see :py:meth:`standardize_uri` for details. + """ + + def __init__(self, settings, title="Options", label="Distro", parent=None): + super().__init__(parent=parent) + self.settings = settings + self.default_selection = set() + utils.load_ui(__file__, self) + self.setTitle(title) + self.name_tree.setHeaderLabel(label) + self.reset_to_default_btn.setIcon(utils.Paths.icon("arrow-left-top-bold")) + self.name_tree.itemChanged.connect(self.item_changed) + self.reset_to_default_btn.released.connect(self.reset_to_default) + + def item_changed(self, item, column): + """Called when a item is modified, saves the user prefs when a checked + state is updated.""" + if column != 0: + return + self.save_user_selection() + + def names(self): + """Returns a dict of the current state of this widget. + + The key is the name shown in column 1. The value is a 2 item list containing + the description shown to the user in column 2 and if name is checked by + default. + """ + ret = {} + for index in range(self.name_tree.topLevelItemCount()): + item = self.name_tree.topLevelItem(index) + checked = item.checkState(0) == QtCore.Qt.Checked + ret[item.text(0)] = [item.text(1), checked] + return ret + + def set_names(self, names, uri=None): + self.name_tree.clear() + # Reset the default selection + self.default_selection = set() + with utils.block_signals([self.name_tree]): + for name, settings in names.items(): + item = QtWidgets.QTreeWidgetItem(self.name_tree, [name, settings[0]]) + item.setToolTip(1, settings[0]) + # Build the `default_selection` set for the current URI + if len(settings) > 1 and settings[1]: + self.default_selection.add(name) + item.setCheckState(0, QtCore.Qt.Unchecked) + + self.name_tree.resizeColumnToContents(0) + user_selection = self.user_selection(uri) + if user_selection is None: + self.set_selected(self.default_selection) + self.reset_to_default_btn.setDisabled(True) + else: + self.set_selected(user_selection) + self.reset_to_default_btn.setDisabled(False) + + def reset_to_default(self): + """Reset the checked state of names to the default values, clearing + saved user_prefs for the current URI. + """ + self.set_selected(self.default_selection) + self.save_user_selection(reset=True) + + def selected(self): + """Returns the checked names as a list.""" + ret = set() + for index in range(self.name_tree.topLevelItemCount()): + item = self.name_tree.topLevelItem(index) + if item.checkState(0) == QtCore.Qt.Checked: + ret.add(item.text(0)) + return ret + + def set_selected(self, selected): + """Update the checked state to just these names.""" + with utils.block_signals([self.name_tree]): + for index in range(self.name_tree.topLevelItemCount()): + item = self.name_tree.topLevelItem(index) + name = item.text(0) + item.setCheckState( + 0, QtCore.Qt.Checked if name in selected else QtCore.Qt.Unchecked + ) + + def sizeHint(self): # noqa: N802 + return QtCore.QSize(0, 160) + + def standardize_uri(self, uri): + """Modify the URI for saving in user_prefs. This implementation discards + all but the top level item.""" + return uri.split(HabBase.separator)[0] + + def user_selection(self, uri): + """Returns the names selected for the current URI saved in user_prefs + as a dict or None if user_prefs are disabled. + """ + if not self.pref_name: + return None + + if uri is None: + uri = self.settings.uri + + uri = self.standardize_uri(uri) + return self.settings.user_pref(self.pref_name, {}).get(uri, None) + + def save_user_selection(self, reset=False): + """Saves the currently selected names into user_prefs if enabled. + + Also always updates the enabled state of the reset button. + """ + uri = self.standardize_uri(self.settings.uri) + user_selections = self.settings.user_pref(self.pref_name, {}) + selected = self.selected() + is_default = selected == self.default_selection + if reset and is_default: + # Only remove the URI from user_prefs if reset was pressed so we + # can save having all optional dependencies disabled. + if uri in user_selections: + del user_selections[uri] + self.reset_to_default_btn.setDisabled(True) + else: + user_selections[uri] = list(selected) + self.reset_to_default_btn.setDisabled(False) + + if self.pref_name: + self.settings.set_user_pref(self.pref_name, user_selections) diff --git a/hab_gui/widgets/ui/name_picker.ui b/hab_gui/widgets/ui/name_picker.ui new file mode 100644 index 0000000..e2a485e --- /dev/null +++ b/hab_gui/widgets/ui/name_picker.ui @@ -0,0 +1,60 @@ + + + name_picker + + + + 0 + 0 + 395 + 104 + + + + Options + + + false + + + + + + false + + + false + + + + Distro + + + + + Description + + + + + + + + + 0 + 0 + + + + Reset to defaults + + + ... + + + + + + + + diff --git a/hab_gui/windows/alias_launch_window.py b/hab_gui/windows/alias_launch_window.py index 72137b9..7f622cc 100644 --- a/hab_gui/windows/alias_launch_window.py +++ b/hab_gui/windows/alias_launch_window.py @@ -80,6 +80,16 @@ def apply_layout(self): if self._cls_menu_button: self.layout.addWidget(self.menu_button, 0, column_uri_widget + 1) self.layout.addWidget(self.alias_buttons, 1, 0, 1, -1) + + # Add the distros_widget if used, otherwise add a spacer + if self._cls_optional_distros_widget: + self.layout.addWidget(self.optional_distros, 2, 0, 1, -1) + else: + self.spacer_item = QtWidgets.QSpacerItem( + 0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding + ) + self.layout.addItem(self.spacer_item, self.layout.rowCount(), 0, 1, -1) + self.main_widget.setLayout(self.layout) # Ensure the tab order is intuitive. This doesn't come for free because @@ -128,6 +138,12 @@ def process_entry_points(self): self._cls_uri_widget = self.settings.load_entry_point( "hab_gui.uri.widget", "hab_gui.widgets.uri_combobox:URIComboBox" ) + # Interface used to pick optional distros + self._cls_optional_distros_widget = self.settings.load_entry_point( + "hab_gui.distros.optional.widget", + "hab_gui.widgets.distro_picker:DistroPicker", + allow_none=True, + ) def init_gui(self, uri=None): self.main_widget = QtWidgets.QWidget() @@ -160,6 +176,11 @@ def init_gui(self, uri=None): parent=self, ) + # If enabled add a optional distros widget to allow users to choose from + # distros that have been exposed as optional + if self._cls_optional_distros_widget: + self.optional_distros = self._cls_optional_distros_widget(self.settings) + self.apply_layout() # Check for stored URI and apply it as the current text