Skip to content
Permalink
Branch: master
Find file Copy path
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
821 lines (627 sloc) 28 KB
"""The view may access a controller, but not vice versa"""
import os
import logging
from itertools import chain
from functools import partial
from collections import OrderedDict as odict
from .vendor.Qt import QtWidgets, QtCore, QtGui
from .vendor import qargparse
from .version import version
from . import resources as res, dock, model
from . import allzparkconfig
px = res.px
class Window(QtWidgets.QMainWindow):
title = "Allzpark %s" % version
def __init__(self, ctrl, parent=None):
super(Window, self).__init__(parent)
self.setWindowTitle(self.title)
self.setAttribute(QtCore.Qt.WA_StyledBackground)
self.setWindowIcon(QtGui.QIcon(res.find("Logo_64.png")))
self._count = 0
pages = odict((
("home", QtWidgets.QWidget()),
# Pages matching a particular state
("booting", QtWidgets.QWidget()),
("loading", QtWidgets.QWidget()),
("errored", QtWidgets.QWidget()),
("noapps", QtWidgets.QWidget()),
("noprofiles", QtWidgets.QWidget()),
))
panels = {
"pages": QtWidgets.QStackedWidget(),
"header": QtWidgets.QWidget(),
"body": QtWidgets.QWidget(),
"footer": QtWidgets.QWidget(),
}
widgets = {
"bootMessage": QtWidgets.QLabel("Loading.."),
"loadingMessage": QtWidgets.QLabel("Loading"),
"errorMessage": QtWidgets.QLabel("Uh oh..<br>"
"See Console for details"),
"noappsMessage": QtWidgets.QTextBrowser(),
"noprofileMessage": QtWidgets.QLabel("No profiles was found"),
"pkgnotfoundMessage": QtWidgets.QLabel(
"One or more packages could not be found"
),
# Header
"logo": QtWidgets.QToolButton(),
"appVersion": QtWidgets.QLabel(version),
"profileBtn": dock.PushButtonWithMenu(),
"profileName": dock.LineEditWithCompleter(),
"profileVersion": dock.LineEditWithCompleter(),
"apps": dock.SlimTableView(),
"fullCommand": FullCommand(ctrl),
# Error page
"continue": QtWidgets.QPushButton("Continue"),
"reset": QtWidgets.QPushButton("Reset"),
"dockToggles": QtWidgets.QWidget(),
"stateIndicator": QtWidgets.QLabel(),
}
# The order is reflected in the UI
docks = odict((
("app", dock.App(ctrl)),
("packages", dock.Packages(ctrl)),
("context", dock.Context(ctrl)),
("environment", dock.Environment(ctrl)),
("console", dock.Console()),
("commands", dock.Commands()),
("preferences", dock.Preferences(self, ctrl)),
))
# Expose to CSS
for name, widget in chain(panels.items(),
widgets.items(),
pages.items()):
widget.setAttribute(QtCore.Qt.WA_StyledBackground)
widget.setObjectName(name)
self.setCentralWidget(panels["pages"])
# Add header to top-most portion of GUI above movable docks
toolbar = self.addToolBar("header")
toolbar.setObjectName("Header")
toolbar.addWidget(panels["header"])
toolbar.setMovable(False)
# Fill horizontal space
panels["header"].setSizePolicy(QtWidgets.QSizePolicy.Expanding,
QtWidgets.QSizePolicy.Preferred)
for page in pages.values():
panels["pages"].addWidget(page)
# Layout
layout = QtWidgets.QGridLayout(pages["home"])
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(panels["body"], 1, 0)
layout = QtWidgets.QVBoxLayout(pages["booting"])
layout.addWidget(QtWidgets.QWidget(), 1)
layout.addWidget(widgets["bootMessage"], 0, QtCore.Qt.AlignHCenter)
layout.addWidget(QtWidgets.QWidget(), 1)
layout = QtWidgets.QVBoxLayout(pages["loading"])
layout.addWidget(QtWidgets.QWidget(), 1)
layout.addWidget(widgets["loadingMessage"], 0, QtCore.Qt.AlignHCenter)
layout.addWidget(QtWidgets.QWidget(), 1)
layout = QtWidgets.QVBoxLayout(pages["errored"])
layout.addWidget(QtWidgets.QWidget(), 1)
layout.addWidget(widgets["errorMessage"], 0, QtCore.Qt.AlignHCenter)
layout.addWidget(widgets["continue"], 0, QtCore.Qt.AlignHCenter)
layout.addWidget(widgets["reset"], 0, QtCore.Qt.AlignHCenter)
layout.addWidget(QtWidgets.QWidget(), 1)
layout = QtWidgets.QVBoxLayout(pages["noapps"])
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(widgets["noappsMessage"], 0)
layout = QtWidgets.QVBoxLayout(pages["noprofiles"])
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(QtWidgets.QWidget(), 1)
layout.addWidget(widgets["noprofileMessage"], 0, QtCore.Qt.AlignHCenter)
layout.addWidget(QtWidgets.QWidget(), 1)
# _______________________________________________________
# | | | | | |
# | logo | profile |---------| |--------|
# |__________|_________|_________|_______________|________|
#
layout = QtWidgets.QGridLayout(panels["header"])
layout.setHorizontalSpacing(px(10))
layout.setVerticalSpacing(0)
def addColumn(widgets, *args, **kwargs):
"""Convenience function for adding columns to GridLayout"""
addColumn.row = getattr(addColumn, "row", -1)
addColumn.row += kwargs.get("offset", 1)
for row, widget in enumerate(widgets):
layout.addWidget(widget, row, addColumn.row, *args)
if kwargs.get("stretch"):
layout.setColumnStretch(addColumn.row, kwargs["stretch"])
addColumn([widgets["profileBtn"]], 2, 1)
addColumn([widgets["profileName"],
widgets["profileVersion"]])
addColumn([QtWidgets.QWidget()], stretch=1)
addColumn([widgets["dockToggles"]], 2, 1)
addColumn([QtWidgets.QWidget()], stretch=2)
addColumn([QtWidgets.QLabel("allzpark"),
widgets["appVersion"]])
addColumn([widgets["logo"]], 2, 1)
layout = QtWidgets.QHBoxLayout(widgets["dockToggles"])
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
for name, widget in docks.items():
toggle = QtWidgets.QPushButton()
toggle.setSizePolicy(QtWidgets.QSizePolicy.Expanding,
QtWidgets.QSizePolicy.Expanding)
toggle.setObjectName(name + "Toggle")
toggle.setCheckable(True)
toggle.setFlat(True)
toggle.setProperty("type", "toggle")
toggle.setIcon(res.icon(widget.icon))
toggle.setIconSize(QtCore.QSize(px(32), px(32)))
toggle.setToolTip("\n".join([
type(widget).__name__, widget.__doc__ or ""]))
def on_toggled(widget, toggle):
widget.setVisible(toggle.isChecked())
self.on_dock_toggled(widget, toggle.isChecked())
def on_visible(widget, toggle, state):
toggle.setChecked(widget.isVisible())
toggle.clicked.connect(partial(on_toggled, widget, toggle))
# Store reference for showEvent
widget.toggle = toggle
# Store reference for update_advanced_controls
toggle.dock = widget
# Create two-way connection; when widget is programatically
# closed, or closed by other means, update toggle to reflect this.
widget.visibilityChanged.connect(
partial(on_visible, widget, toggle))
# Forward any messages
widget.message.connect(self.tell)
layout.addWidget(toggle)
layout = QtWidgets.QVBoxLayout(panels["body"])
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(widgets["apps"])
layout.addWidget(widgets["fullCommand"])
status_bar = self.statusBar()
status_bar.addPermanentWidget(widgets["stateIndicator"])
# Setup
css = "QWidget { border-image: url(%s); }"
widgets["logo"].setStyleSheet(css % res.find("Logo_64"))
widgets["noappsMessage"].setReadOnly(True)
widgets["noappsMessage"].setOpenExternalLinks(True)
widgets["profileBtn"].setIconSize(QtCore.QSize(px(32), px(32)))
widgets["profileBtn"].setToolTip("Click to change profile\n"
"Right-click for options")
widgets["profileBtn"].setIcon(res.icon("Default_Profile"))
css = "QWidget { border-image: url(%s); }"
widgets["logo"].setCursor(QtCore.Qt.PointingHandCursor)
widgets["logo"].setToolTip(allzparkconfig.help_url)
widgets["profileBtn"].setCursor(QtCore.Qt.PointingHandCursor)
widgets["profileName"].setCursor(QtCore.Qt.PointingHandCursor)
widgets["profileVersion"].setCursor(QtCore.Qt.PointingHandCursor)
widgets["profileName"].setToolTip("Click to change profile")
widgets["profileName"].setModel(ctrl.models["profileNames"])
widgets["profileVersion"].setToolTip("Click to change profile version")
widgets["profileVersion"].setModel(ctrl.models["profileVersions"])
docks["packages"].set_model(ctrl.models["packages"])
docks["context"].set_model(ctrl.models["context"])
docks["environment"].set_model(ctrl.models["environment"])
docks["commands"].set_model(ctrl.models["commands"])
proxy_model = model.ProxyModel(ctrl.models["apps"])
widgets["apps"].setModel(proxy_model)
widgets["errorMessage"].setAlignment(QtCore.Qt.AlignHCenter)
widgets["logo"].clicked.connect(self.on_logo_clicked)
widgets["profileBtn"].clicked.connect(self.on_profilebtn_pressed)
widgets["profileBtn"].customContextMenuRequested.connect(
self.on_profilebtn_contextmenu)
widgets["profileName"].changed.connect(self.on_profilename_changed)
widgets["profileVersion"].changed.connect(
self.on_profileversion_changed)
widgets["reset"].clicked.connect(self.on_reset_clicked)
widgets["continue"].clicked.connect(self.on_continue_clicked)
widgets["apps"].activated.connect(self.on_app_clicked)
widgets["apps"].customContextMenuRequested.connect(
self.on_app_right_click)
selection_model = widgets["apps"].selectionModel()
selection_model.selectionChanged.connect(self.on_app_selection_changed)
ctrl.models["apps"].modelReset.connect(self.on_apps_reset)
ctrl.models["profileNames"].modelReset.connect(
self.on_profilename_reset)
ctrl.models["profileVersions"].modelReset.connect(
self.on_profileversion_reset)
ctrl.resetted.connect(self.on_reset)
ctrl.state_changed.connect(self.on_state_changed)
ctrl.logged.connect(self.on_logged)
ctrl.profile_changed.connect(self.on_profile_changed)
ctrl.repository_changed.connect(self.on_repository_changed)
ctrl.command_changed.connect(self.on_command_changed)
ctrl.application_changed.connect(self.on_app_changed)
self._pages = pages
self._widgets = widgets
self._panels = panels
self._docks = docks
self._ctrl = ctrl
self.setup_docks()
self.on_state_changed("booting")
self.update_advanced_controls()
self.setFocus()
# Enable mouse tracking for tooltips
QtWidgets.QApplication.instance().installEventFilter(self)
def eventFilter(self, obj, event):
"""Forward tooltips to status bar whenever the mouse moves"""
if event.type() == QtCore.QEvent.MouseMove:
try:
tooltip = obj.toolTip()
# Some tooltips are multi-line, and the statusbar
# typically ignores newlines and writes it all out
# as one long line.
tooltip = tooltip.splitlines()[0]
self.statusBar().showMessage(tooltip, 2000)
except (AttributeError, IndexError):
self.statusBar().clearMessage()
# Forward the event to subsequent listeners
return False
def createPopupMenu(self):
"""Disables default popup for QDockWidget and QToolBar"""
def update_advanced_controls(self):
shown = bool(self._ctrl.state.retrieve("showAdvancedControls"))
self._widgets["profileVersion"].setVisible(shown)
self._widgets["fullCommand"].setVisible(shown)
# Update dock toggles
toggles = self._widgets["dockToggles"].layout()
for index in range(toggles.count()):
item = toggles.itemAt(index)
widget = item.widget()
dock = widget.dock
visible = (not dock.advanced) or shown
widget.setVisible(visible)
if not visible:
dock.hide()
def setup_docks(self):
for widget in self._docks.values():
widget.hide()
self.setTabPosition(QtCore.Qt.RightDockWidgetArea,
QtWidgets.QTabWidget.North)
self.setTabPosition(QtCore.Qt.LeftDockWidgetArea,
QtWidgets.QTabWidget.North)
area = QtCore.Qt.RightDockWidgetArea
first = list(self._docks.values())[0]
self.addDockWidget(area, first)
for widget in self._docks.values():
if widget is first:
continue
self.addDockWidget(area, widget)
self.tabifyDockWidget(first, widget)
def reset(self):
self._ctrl.reset()
def on_reset(self):
pass
def on_command_changed(self, command):
self._widgets["fullCommand"].setText(command)
def on_command_copied(self, cmd):
self.tell("Copied command '%s'" % cmd)
def on_reset_clicked(self):
self.reset()
def on_continue_clicked(self):
self._ctrl.state.to_ready()
def on_app_right_click(self, position):
view = self._widgets["apps"]
index = view.indexAt(position)
if not index.isValid():
# Clicked outside any item
return
model = index.model()
tools = model.data(index, "tools")
menu = dock.MenuWithTooltip(self)
separator = QtWidgets.QWidgetAction(menu)
separator.setDefaultWidget(QtWidgets.QLabel("Quick Launch"))
menu.addAction(separator)
def handle(action):
tool = action.text()
self._ctrl.launch(command=tool)
for tool in tools:
action = QtWidgets.QAction(tool, menu)
action.triggered.connect(lambda _=False, a=action: handle(a))
menu.addAction(action)
menu.addSeparator()
extras = (
["start cmd", "start powershell"]
if os.name == "nt" else
["bash"]
)
for tool in extras:
action = QtWidgets.QAction(tool, menu)
action.triggered.connect(lambda _=False, a=action: handle(a))
menu.addAction(action)
menu.move(QtGui.QCursor.pos())
menu.show()
def on_setting_changed(self, argument):
if isinstance(argument, qargparse.Button):
if argument["name"] == "resetLayout":
self.tell("Restoring layout..")
geometry = self._ctrl.state.retrieve("default/geometry")
window = self._ctrl.state.retrieve("default/windowState")
self.restoreGeometry(geometry)
self.restoreState(window)
return
key = argument["name"]
value = argument.read()
self.tell("Storing %s = %s" % (key, value))
self._ctrl.state.store(argument["name"], argument.read())
# Subsequent settings are stored to disk
if key == "showAdvancedControls":
self.update_advanced_controls()
if key in ("showAllApps",
"showHiddenApps",
"patchWithFilter"):
self._ctrl.reset()
if key == "exclusionFilter":
allzparkconfig.exclude_filter = value
self._ctrl.reset()
def on_dock_toggled(self, dock, visible):
"""Make toggled dock the active dock"""
if not visible:
return
# Handle the easy cases first
app = QtWidgets.QApplication.instance()
ctrl_held = app.keyboardModifiers() & QtCore.Qt.ControlModifier
allow_multiple = bool(self._ctrl.state.retrieve("allowMultipleDocks"))
if ctrl_held or not allow_multiple:
for d in self._docks.values():
d.setVisible(d == dock)
return
# Otherwise we'll want to make the newly visible dock the active tab.
# Turns out to not be that easy
# https://forum.qt.io/topic/42044/
# tabbed-qdockwidgets-how-to-fetch-the-qwidgets-under-a-qtabbar/10
# TabBar's are dynamically created as the user
# moves docks around, and not all of them are
# visible or in use at all times. (Poor garbage collection)
bars = self.findChildren(QtWidgets.QTabBar)
# The children of a QTabBar isn't the dock directly, but rather
# the buttons in the tab, which are of type QToolButton.
uid = dock.windowTitle() # note: This must be unique
# Find which tab is associated to this QDockWidget, if any
def find_dock(bar):
for index in range(bar.count()):
if uid == bar.tabText(index):
return index
for bar in bars:
index = find_dock(bar)
if index is not None:
break
else:
# Dock isn't part of any tab and is directly visible
return
bar.setCurrentIndex(index)
def on_profilename_clicked(self):
pass
def on_profilename_changed(self, profile):
"""User changed the profile"""
self._ctrl.select_profile(profile)
self._ctrl.state.store("startupProfile", profile)
self.setFocus()
def on_profileversion_changed(self, version):
"""User changed the profile version"""
profile = self._ctrl.current_profile
self._ctrl.select_profile(profile, version)
self.setFocus()
def on_profilebtn_pressed(self):
widget = self._widgets["profileName"]
widget.setFocus()
widget.selectAll()
completer = widget.completer()
completer.complete()
def on_profilebtn_contextmenu(self):
name = self._widgets["profileName"].text()
menu = dock.MenuWithTooltip(self)
separator = QtWidgets.QWidgetAction(menu)
separator.setDefaultWidget(QtWidgets.QLabel(name))
menu.addAction(separator)
def on_reset():
self.reset()
reset = QtWidgets.QAction("Reset", menu)
reset.triggered.connect(on_reset)
reset.setToolTip("Re-scan repository for new Rez packages")
menu.addAction(reset)
menu.addSeparator()
menu.move(QtGui.QCursor.pos())
menu.show()
def on_logo_clicked(self):
url = allzparkconfig.help_url
QtGui.QDesktopServices.openUrl(QtCore.QUrl(url))
def on_profile_changed(self, profile, version, refreshed=False):
"""Profile changed in the controller"""
# Happens when editing requirements
action = "Refreshing" if refreshed else "Changing"
icon = res.find("Default_Profile")
label = profile
package = self._ctrl.state["rezProfiles"][profile][version]
data = allzparkconfig.metadata_from_package(package)
label = data["label"]
# Facilitate overriding of icon via package metadata
if data.get("icon"):
try:
values = {
"root": os.path.dirname(package.uri),
"width": px(32),
"height": px(32),
}
icon = data["icon"].format(**values).replace("\\", "/")
except KeyError:
self.tell("Misformatted %s.icon" % package.name)
except TypeError:
self.tell("Unsupported package repository "
"for icon of %s" % package.uri)
except Exception:
self.tell("Unexpected error coming from icon of %s"
% package.uri)
self.tell("%s %s-%s" % (action, profile, version))
self.setWindowTitle("%s | %s" % (label, self.title))
self._widgets["profileName"].setText(label)
self._widgets["profileVersion"].setText(version)
button = self._widgets["profileBtn"]
button.setIcon(res.icon(icon))
# Determine aspect ratio
height = px(32)
pixmap = res.pixmap(icon)
if pixmap.isNull():
pixmap = res.pixmap("Default_Profile")
pixmap = pixmap.scaledToHeight(height)
width = pixmap.width()
button.setIconSize(QtCore.QSize(width, height))
button.setAutoFillBackground(True)
def setStyleSheet(self, style):
style = style % {
"root": os.path.dirname(__file__).replace("\\", "/"),
"res": res.dirname.replace("\\", "/"),
}
super(Window, self).setStyleSheet(style)
def on_repository_changed(self):
self.reset()
def on_show_error(self):
self._docks["console"].append(self._ctrl.current_error)
self._docks["console"].raise_()
def tell(self, message, level=logging.INFO):
self._docks["console"].append(message, level)
self.statusBar().showMessage(message, 2000)
def on_logged(self, message, level):
self._docks["console"].append(message, level)
def on_state_changed(self, state):
self.tell("State: %s" % state, logging.DEBUG)
page = self._pages.get(str(state), self._pages["home"])
page_name = page.objectName()
self._panels["pages"].setCurrentWidget(page)
self._widgets["profileName"].setEnabled(True)
self._widgets["profileVersion"].setEnabled(True)
self._widgets["profileBtn"].setEnabled(True)
launch_btn = self._docks["app"]._widgets["launchBtn"]
launch_btn.setText("Launch")
launch_btn.setEnabled(True)
for widget in self._docks.values():
widget.setEnabled(True)
if page_name == "home":
self._widgets["apps"].setEnabled(state == "ready")
self._widgets["profileBtn"].setEnabled(state == "ready")
self._widgets["profileVersion"].setEnabled(state == "ready")
elif page_name == "noapps":
message = self._ctrl.state["error"]
self._widgets["profileBtn"].setEnabled(True)
self._widgets["noappsMessage"].setText(message)
elif page_name == "noprofiles":
self._widgets["profileBtn"].setEnabled(True)
if state == "launching":
self._docks["app"].setEnabled(False)
if state == "loading":
self._widgets["profileBtn"].setEnabled(False)
self._widgets["profileName"].setEnabled(False)
self._widgets["profileVersion"].setEnabled(False)
for widget in self._docks.values():
widget.setEnabled(False)
if state in ("pkgnotfound", "errored"):
console = self._docks["console"]
console.show()
self.on_dock_toggled(console, visible=True)
page = self._pages["errored"]
self._panels["pages"].setCurrentWidget(page)
self._widgets["apps"].setEnabled(False)
launch_btn.setEnabled(False)
launch_btn.setText("Package not found")
if state == "notresolved":
self._widgets["apps"].setEnabled(False)
launch_btn.setEnabled(False)
launch_btn.setText("Failed to resolve")
self._widgets["stateIndicator"].setText(str(state))
self.update_advanced_controls()
def on_launch_clicked(self):
self._ctrl.launch()
def on_profilename_reset(self):
pass
def on_profileversion_reset(self):
pass
def on_apps_reset(self):
app = self._ctrl.state.retrieve("startupApplication")
row = 0
model = self._ctrl.models["apps"]
if app:
for row_ in range(model.rowCount()):
index = model.index(row_, 0, QtCore.QModelIndex())
name = model.data(index, "name")
if app == name:
row = row_
self.tell("Using startup application %s" % name)
break
self._widgets["apps"].selectRow(row)
def on_app_clicked(self, index):
"""An app was double-clicked or Return was hit"""
app = self._docks["app"]
app.show()
self.on_dock_toggled(app, visible=True)
def on_app_selection_changed(self, selected, deselected):
"""The current app was changed
Arguments:
selected (QtCore.QItemSelection): ..
deselected (QtCore.QItemSelection): ..
"""
try:
index = selected.indexes()[0]
except IndexError:
# No app was selected
return
model = index.model()
app_request = model.data(index, "name")
self._ctrl.select_application(app_request)
def on_app_changed(self):
selection_model = self._widgets["apps"].selectionModel()
index = selection_model.selectedIndexes()[0]
self._docks["app"].refresh(index)
def showEvent(self, event):
super(Window, self).showEvent(event)
self._ctrl.state.store("default/geometry", self.saveGeometry())
self._ctrl.state.store("default/windowState", self.saveState())
if self._ctrl.state.retrieve("geometry"):
self.tell("Restoring layout..", logging.DEBUG)
self.restoreGeometry(self._ctrl.state.retrieve("geometry"))
self.restoreState(self._ctrl.state.retrieve("windowState"))
def closeEvent(self, event):
self._ctrl.state.store("geometry", self.saveGeometry())
self._ctrl.state.store("windowState", self.saveState())
return super(Window, self).closeEvent(event)
class FullCommand(QtWidgets.QWidget):
def __init__(self, ctrl, parent=None):
super(FullCommand, self).__init__(parent)
widgets = {
"options": QtWidgets.QComboBox(),
"text": QtWidgets.QLineEdit(),
}
layout = QtWidgets.QHBoxLayout(self)
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(widgets["options"])
layout.addWidget(widgets["text"])
up_icon = res.icon("Action_GoUp_3_Large_32.png")
down_icon = res.icon("Action_GoDown_3_32.png")
widgets["text"].setReadOnly(True)
widgets["options"].addItem(up_icon, "Used Request")
widgets["options"].addItem(down_icon, "Used Resolve")
widgets["options"].currentIndexChanged.connect(self.on_option_changed)
self._widgets = widgets
self._ctrl = ctrl
index = (
0
if ctrl.state.retrieve("serialisationMode") == "used_request"
else 1
)
widgets["options"].setCurrentIndex(index)
def setText(self, text):
self._widgets["text"].setText(text)
def on_option_changed(self, option):
if option == 0:
mode = "used_request"
self._widgets["options"].setToolTip(
"Used Request\n"
"Use the final request made to Rez,\n"
"excluding any indirect requirements."
)
else:
mode = "used_resolve"
self._widgets["options"].setToolTip(
"Used Resolve\n"
"Use the fully resolved list of packages,\n"
"including packages that may not be suitable\n"
"for another platform."
)
self._ctrl.update_command(mode)
You can’t perform that action at this time.