Skip to content

Commit

Permalink
Add UI to enable optional distros when launching
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
MHendricks committed Jul 22, 2024
1 parent fc39f82 commit 7b58bb8
Show file tree
Hide file tree
Showing 10 changed files with 377 additions and 5 deletions.
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] |
Expand Down Expand Up @@ -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"
}
}
}
}
```
3 changes: 2 additions & 1 deletion hab_gui/resources/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions hab_gui/resources/arrow-left-top-bold.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions hab_gui/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions hab_gui/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 0 additions & 4 deletions hab_gui/widgets/alias_button_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
73 changes: 73 additions & 0 deletions hab_gui/widgets/distro_picker.py
Original file line number Diff line number Diff line change
@@ -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()
142 changes: 142 additions & 0 deletions hab_gui/widgets/name_picker.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit 7b58bb8

Please sign in to comment.