diff --git a/README.md b/README.md
index 4576b5e..e5496c7 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ to take hab out of the shell.
- Gui for selecting hab URI's and launching aliases.
- Gui for setting the current uri.
+- Gui for selecting [optional distros](#optional-distros-gui).
- [hab gui sub-command](#hab-gui-sub-command)
- [habw](#habwexe) command allows using hab without popup consoles on windows.
- Customization of hab-gui using [entry_points](#hab-gui-entry-points) defined
@@ -94,6 +95,7 @@ you can implement your own widgets extending or completely re-implementing them.
|---|---|---|---|
| hab_gui.alias.widget | Widget used to display and launch a specific alias for the current URI. | [AliasLaunchWindow](hab_gui/windows/alias_launch_window.py) | [First][tt-multi-first] |
| hab_gui.aliases.widget | Class used to display the `hab_gui.alias.widget`'s. | [AliasLaunchWindow](hab_gui/windows/alias_launch_window.py) | [First][tt-multi-first] |
+| hab_gui.footer.widget | A widget class shown under the alias buttons in the AliasLaunchWindow. For example, [Optinal Distros](#optional-distros-gui) is a interface for choosing optional distros for the current URI. | [AliasLaunchWindow](hab_gui/windows/alias_launch_window.py) | [First][tt-multi-first] |
| hab_gui.init | Used to customize the init of hab gui's launched from the command line. By default this installs a `sys.excepthook` that captures any python exceptions and shows them in a QMessageBox dialog. See [hab-gui-init.json](tests/site/hab-gui-init.json). | [hab_gui.cli](hab_gui/cli.py) when starting a QApplication instance. | [First][tt-multi-first] |
| hab_gui.uri.menu.actions | Used to customize the menu shown by `hab_gui.uri.menu.widget`. This should reference `QAction` subclasses conforming to [hab_gui.actions.refresh_action.RefreshAction](hab_gui/actions/refresh_action.py). | [MenuButton](hab_gui/widgets/menu_button.py) | [All][tt-multi-all] |
| hab_gui.uri.menu.widget | Class used to show a menu interface on the right of `hab_gui.uri.widget`. This can be omitted by setting this entry_point to `null`. | [AliasLaunchWindow](hab_gui/windows/alias_launch_window.py) | [First][tt-multi-first] |
@@ -200,3 +202,32 @@ configure this interval by setting `hab_gui_refresh_inverval` in your site
configuration. This accepts a string in `%H:%M:%S` format using
[time.strptime](https://docs.python.org/3/library/time.html#time.strptime). An
empty string will disable this auto-refresh feature.
+
+## Optional Distros GUI
+
+This widget allows you to present users with additional plugins that only some
+of them might need. The default implementation respects the
+["enabled by default option"](https://github.com/blurstudio/hab#optional-distros). It is shown below the Alias button grid.
+
+Once a user modifies any of the check boxes these changes will be saved in the user
+prefs if enabled. These user prefs are stored per top level URI so users don't have
+to micromanage the settings for every single URI they use. This behavior can be changed
+by sub-classing `DistroPicker` and re-implementing the `standardize_uri` method.
+The `Reset to defaults` button on the right allows users to clear their preferences
+for that URI and reset it to the defaults.
+
+The optional distros widget can be enabled by setting the entry point
+`hab_gui.footer.widget` to the `hab_gui.widgets.distro_picker:DistroPicker`
+class in your site json file.
+```json5
+{
+ "prepend": {
+ "entry_points": {
+ "hab_gui.footer.widget": {
+ "default": "hab_gui.widgets.distro_picker:DistroPicker"
+ }
+ }
+ }
+}
+
+```
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..709993d 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 footer_widget if used, otherwise add a spacer
+ if self._cls_footer_widget:
+ self.layout.addWidget(self.footer_widget, 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"
)
+ # A footer widget shown under the aliases widget
+ self._cls_footer_widget = self.settings.load_entry_point(
+ "hab_gui.footer.widget",
+ None,
+ allow_none=True,
+ )
def init_gui(self, uri=None):
self.main_widget = QtWidgets.QWidget()
@@ -160,6 +176,10 @@ def init_gui(self, uri=None):
parent=self,
)
+ # If specified add a footer widget under the aliases widget
+ if self._cls_footer_widget:
+ self.footer_widget = self._cls_footer_widget(self.settings)
+
self.apply_layout()
# Check for stored URI and apply it as the current text
diff --git a/pyproject.toml b/pyproject.toml
index 5435cc6..94bc68e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -27,7 +27,7 @@ classifiers = [
requires-python = ">=3.6"
dependencies = [
"click>=7.1.2",
- "hab>=0.39.0",
+ "hab>=0.41.0",
"Pygments",
"Qt.py",
]
diff --git a/requirements.txt b/requirements.txt
index 91ec53e..5dbe018 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
click>=7.1.2
-hab>=0.39.0
+hab>=0.41.0
Pygments
Qt.py