Source code for mu.interface.dialogs
"""
UI related code for dialogs used by Mu.
Copyright (c) 2015-2017 Nicholas H.Tollervey and others (see the AUTHORS file).
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import os
import logging
import sys
from PyQt5.QtCore import QSize, QProcess, QTimer, Qt
from PyQt5.QtWidgets import (
QHBoxLayout,
QVBoxLayout,
QGridLayout,
QListWidget,
QLabel,
QListWidgetItem,
QDialog,
QDialogButtonBox,
QPlainTextEdit,
QTabWidget,
QWidget,
QCheckBox,
QLineEdit,
QPushButton,
QFileDialog,
QGroupBox,
QComboBox,
)
from PyQt5.QtGui import QTextCursor
from mu.resources import load_icon
from mu.interface.widgets import DeviceSelector
from ..virtual_environment import venv
logger = logging.getLogger(__name__)
[docs]class ModeItem(QListWidgetItem):
"""
Represents an available mode listed for selection.
"""
def __init__(self, name, description, icon, parent=None):
super().__init__(parent)
self.name = name
self.description = description
self.icon = icon
text = "{}\n{}".format(name, description)
self.setText(text)
self.setIcon(load_icon(self.icon))
[docs]class ModeSelector(QDialog):
"""
Defines a UI for selecting the mode for Mu.
"""
def __init__(self, parent=None):
super().__init__(parent)
def setup(self, modes, current_mode):
self.setMinimumSize(600, 400)
self.setWindowTitle(_("Select Mode"))
widget_layout = QVBoxLayout()
label = QLabel(
_(
'Please select the desired mode then click "OK". '
'Otherwise, click "Cancel".'
)
)
label.setWordWrap(True)
widget_layout.addWidget(label)
self.setLayout(widget_layout)
self.mode_list = QListWidget()
self.mode_list.itemDoubleClicked.connect(self.select_and_accept)
widget_layout.addWidget(self.mode_list)
self.mode_list.setIconSize(QSize(48, 48))
for name, item in modes.items():
if not item.is_debugger:
litem = ModeItem(
item.name, item.description, item.icon, self.mode_list
)
if item.icon == current_mode:
self.mode_list.setCurrentItem(litem)
self.mode_list.sortItems()
instructions = QLabel(
_(
"Change mode at any time by clicking "
'the "Mode" button containing Mu\'s logo.'
)
)
instructions.setWordWrap(True)
widget_layout.addWidget(instructions)
button_box = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel
)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
widget_layout.addWidget(button_box)
[docs] def select_and_accept(self):
"""
Handler for when an item is double-clicked.
"""
self.accept()
[docs] def get_mode(self):
"""
Return details of the newly selected mode.
"""
if self.result() == QDialog.Accepted:
return self.mode_list.currentItem().icon
else:
raise RuntimeError("Mode change cancelled.")
[docs]class LogWidget(QWidget):
"""
Used to display Mu's logs.
"""
def setup(self, log):
widget_layout = QVBoxLayout()
self.setLayout(widget_layout)
label = QLabel(
_(
"When reporting a bug, copy and paste the content of "
"the following log file."
)
)
label.setWordWrap(True)
widget_layout.addWidget(label)
self.log_text_area = QPlainTextEdit()
self.log_text_area.setReadOnly(True)
self.log_text_area.setLineWrapMode(QPlainTextEdit.NoWrap)
self.log_text_area.setPlainText(log)
widget_layout.addWidget(self.log_text_area)
[docs]class EnvironmentVariablesWidget(QWidget):
"""
Used for editing and displaying environment variables used with Python 3
mode.
"""
def setup(self, envars):
widget_layout = QVBoxLayout()
self.setLayout(widget_layout)
label = QLabel(
_(
"The environment variables shown below will be "
"set each time you run a Python 3 script.\n\n"
"Each separate enviroment variable should be on a "
"new line and of the form:\nNAME=VALUE"
)
)
label.setWordWrap(True)
widget_layout.addWidget(label)
self.text_area = QPlainTextEdit()
self.text_area.setLineWrapMode(QPlainTextEdit.NoWrap)
self.text_area.setPlainText(envars)
widget_layout.addWidget(self.text_area)
[docs]class MicrobitSettingsWidget(QWidget):
"""
Used for configuring how to interact with the micro:bit:
* Minification flag.
* Override runtime version to use.
"""
def setup(self, minify, custom_runtime_path):
widget_layout = QVBoxLayout()
self.setLayout(widget_layout)
self.minify = QCheckBox(_("Minify Python code before flashing?"))
self.minify.setChecked(minify)
widget_layout.addWidget(self.minify)
label = QLabel(
_(
"Override the built-in MicroPython runtime with "
"the following hex file (empty means use the "
"default):"
)
)
label.setWordWrap(True)
widget_layout.addWidget(label)
self.runtime_path = QLineEdit()
self.runtime_path.setText(custom_runtime_path)
widget_layout.addWidget(self.runtime_path)
widget_layout.addStretch()
[docs]class PackagesWidget(QWidget):
"""
Used for editing and displaying 3rd party packages installed via pip to be
used with Python 3 mode.
"""
def setup(self, packages):
widget_layout = QVBoxLayout()
self.setLayout(widget_layout)
self.text_area = QPlainTextEdit()
self.text_area.setLineWrapMode(QPlainTextEdit.NoWrap)
label = QLabel(
_(
"The packages shown below will be available to "
"import in Python 3 mode. Delete a package from "
"the list to remove its availability.\n\n"
"Each separate package name should be on a new "
"line. Packages are installed from PyPI "
"(see: https://pypi.org/)."
)
)
label.setWordWrap(True)
widget_layout.addWidget(label)
self.text_area.setPlainText(packages)
widget_layout.addWidget(self.text_area)
[docs]class PythonAnywhereWidget(QWidget):
"""
For configuring the user's username and API token for interacting with
the PythonAnywhere API to deploy a website from web mode.
"""
#: Valid server hosting instances for PythonAnywhere.
valid_instances = [
"www",
"eu",
]
def setup(self, username, token, instance="www"):
widget_layout = QVBoxLayout()
self.setLayout(widget_layout)
label = QLabel(
_(
"The folks at "
"<a href='https://www.pythonanywhere.com/'>PythonAnywhere</a> "
"make it easy for learners and educators to host simple web "
"projects for free. You'll need to sign up for an account and "
"provide the following details for Mu to deploy your web "
"project."
)
)
label.setWordWrap(True)
label.setOpenExternalLinks(True)
widget_layout.addWidget(label)
username_label = QLabel(
_("\nCopy your username on PythonAnywhere into here:")
)
widget_layout.addWidget(username_label)
self.username_text = QLineEdit()
self.username_text.setPlaceholderText(_("username"))
if username:
self.username_text.setText(username)
widget_layout.addWidget(self.username_text)
token_label = QLabel(
_(
"Copy your "
"<a href='https://www.pythonanywhere.com/account/#api_token'>"
"secret API token from PythonAnywhere</a> into here:"
)
)
token_label.setOpenExternalLinks(True)
widget_layout.addWidget(token_label)
self.token_text = QLineEdit()
self.token_text.setPlaceholderText(_("secret api token"))
if token:
self.token_text.setText(token)
widget_layout.addWidget(self.token_text)
instance_label = QLabel(
_("Server location ('www' is a safe default):")
)
widget_layout.addWidget(instance_label)
self.instance_combo = QComboBox()
selected = 0
for pos, item in enumerate(self.valid_instances):
self.instance_combo.addItem(item)
if instance == item:
selected = pos
self.instance_combo.setCurrentIndex(selected)
widget_layout.addWidget(self.instance_combo)
widget_layout.addStretch()
[docs]class LocaleWidget(QWidget):
"""
Used for manually setting the locale (and thus the language) used by Mu.
"""
LANGUAGES = {
_("Automatically detect"): "",
"English": "en",
"Deutsch": "de_DE",
"Español": "es",
"Français": "fr",
"日本語": "ja",
"Nederlands": "nl",
"Polski": "pl",
"Português (Br)": "pt_BR",
"Português (Pt)": "pt_PT",
"русский язык": "ru_RU",
"Slovenský": "sk_SK",
"Svenska": "sv",
"tiếng Việt": "vi",
"中文": "zh_CN",
}
def setup(self, locale):
widget_layout = QVBoxLayout()
self.setLayout(widget_layout)
self.drop_down = QComboBox()
for k, v in self.LANGUAGES.items():
self.drop_down.addItem(k, v)
index = self.drop_down.findData(locale)
if index > -1:
self.drop_down.setCurrentIndex(index)
label = QLabel(
_(
"Please select the language for Mu's user interface from the "
"choices listed below. <strong>Restart Mu for these changes "
"to take effect.</strong>"
)
)
label.setWordWrap(True)
widget_layout.addWidget(label)
widget_layout.addWidget(self.drop_down)
widget_layout.addStretch()
[docs] def get_locale(self):
"""
Return the user-selected language code.
"""
return self.LANGUAGES.get(self.drop_down.currentText(), "")
[docs]class ESPFirmwareFlasherWidget(QWidget):
"""
Used for configuring how to interact with the ESP:
* Override MicroPython.
"""
def setup(self, mode, device_list):
widget_layout = QVBoxLayout()
self.setLayout(widget_layout)
# Instructions
grp_instructions = QGroupBox(
_("How to flash MicroPython to your device")
)
grp_instructions_vbox = QVBoxLayout()
grp_instructions.setLayout(grp_instructions_vbox)
# Note: we have to specify the link color here, to something
# that's suitable for both day/night/contrast themes, as the
# link color is not configurable in the Qt Stylesheets
instructions = _(
" 1. Determine the type of device (ESP8266 or ESP32)<br />"
" 2. Download firmware from the "
'<a href="https://micropython.org/download" '
'style="color:#039be5;">'
"https://micropython.org/download</a><br/>"
" 3. Connect your device<br/>"
" 4. Load the .bin file below using the 'Browse' button<br/>"
" 5. Press 'Erase & write firmware'"
# "<br /><br />Check the current MicroPython version using the "
# "following commands:<br />"
# ">>> import sys<br />"
# ">>> sys.implementation"
)
label = QLabel(instructions)
label.setTextFormat(Qt.RichText)
label.setTextInteractionFlags(Qt.TextBrowserInteraction)
label.setOpenExternalLinks(True)
label.setWordWrap(True)
grp_instructions_vbox.addWidget(label)
widget_layout.addWidget(grp_instructions)
# Device type, firmware path, flash button
device_selector_label = QLabel(_("Device:"))
self.device_selector = DeviceSelector(show_label=True, icon_first=True)
self.device_selector.set_device_list(device_list)
device_type_label = QLabel(_("Choose device type:"))
self.device_type = QComboBox(self)
self.device_type.addItem("ESP8266")
self.device_type.addItem("ESP32")
firmware_label = QLabel(_("Firmware (.bin):"))
self.txtFolder = QLineEdit()
self.btnFolder = QPushButton(_("Browse"))
self.btnExec = QPushButton(_("Erase && write firmware"))
self.btnExec.setEnabled(False)
form_set = QGridLayout()
form_set.addWidget(device_selector_label, 0, 0)
form_set.addWidget(self.device_selector, 0, 1, 1, 3)
form_set.addWidget(device_type_label, 1, 0)
form_set.addWidget(self.device_type, 1, 1)
form_set.addWidget(firmware_label, 2, 0)
form_set.addWidget(self.txtFolder, 2, 1)
form_set.addWidget(self.btnFolder, 2, 2)
form_set.addWidget(self.btnExec, 2, 3)
widget_layout.addLayout(form_set)
# Output area
self.log_text_area = QPlainTextEdit()
self.log_text_area.setReadOnly(True)
form_set = QHBoxLayout()
form_set.addWidget(self.log_text_area)
widget_layout.addLayout(form_set)
# Connect events
self.txtFolder.textChanged.connect(self.firmware_path_changed)
self.btnFolder.clicked.connect(self.show_folder_dialog)
self.btnExec.clicked.connect(self.update_firmware)
self.device_selector.device_changed.connect(self.toggle_exec_button)
self.mode = mode
def show_folder_dialog(self):
# open dialog and set to foldername
filename = QFileDialog.getOpenFileName(
self,
_("Select MicroPython firmware (.bin)"),
os.path.expanduser("."),
_("Firmware (*.bin)"),
)
if filename:
filename = filename[0].replace("/", os.sep)
self.txtFolder.setText(filename)
def update_firmware(self):
baudrate = 115200
if self.mode.repl:
self.mode.toggle_repl(None)
if self.mode.plotter:
self.mode.toggle_plotter(None)
if self.mode.fs is not None:
self.mode.toggle_files(None)
device = self.device_selector.selected_device()
if device is None:
return
esptool = "-mmu.contrib.esptool"
erase_command = '"{}" "{}" --port {} erase_flash'.format(
sys.executable, esptool, device.port
)
if self.device_type.currentText() == "ESP32":
write_command = (
'"{}" "{}" --chip esp32 --port {} --baud {} '
'write_flash -z 0x1000 "{}"'
).format(
sys.executable,
esptool,
device.port,
baudrate,
self.txtFolder.text(),
)
else:
write_command = (
'"{}" "{}" --chip esp8266 --port {} --baud {} '
'write_flash --flash_size=detect 0 "{}"'
).format(
sys.executable,
esptool,
device.port,
baudrate,
self.txtFolder.text(),
)
self.commands = [erase_command, write_command]
self.run_esptool()
def run_esptool(self):
self.process = QProcess(self)
self.process.setProcessChannelMode(QProcess.MergedChannels)
self.process.readyReadStandardError.connect(self.read_process)
self.process.readyReadStandardOutput.connect(self.read_process)
self.process.finished.connect(self.esptool_finished)
self.process.error.connect(self.esptool_error)
command = self.commands.pop(0)
self.log_text_area.appendPlainText(command + "\n")
self.process.start(command)
def esptool_error(self, error_num):
self.log_text_area.appendPlainText(
"Error occurred: Error {}\n".format(error_num)
)
self.process = None
[docs] def esptool_finished(self, exitCode, exitStatus):
"""
Called when the subprocess that executes 'esptool.py is finished.
"""
# Exit if a command fails
if exitCode != 0 or exitStatus == QProcess.CrashExit:
self.log_text_area.appendPlainText("Error on flashing. Aborting.")
return
if self.commands:
self.process = None
self.run_esptool()
[docs] def read_process(self):
"""
Read data from the child process and append it to the text area. Try
to keep reading until there's no more data from the process.
"""
msg = ""
data = self.process.readAll()
if data:
try:
msg = data.data().decode("utf-8")
self.append_data(msg)
except UnicodeDecodeError:
pass
QTimer.singleShot(2, self.read_process)
[docs] def append_data(self, msg):
"""
Add data to the end of the text area.
"""
cursor = self.log_text_area.textCursor()
cursor.movePosition(QTextCursor.End)
cursor.insertText(msg)
cursor.movePosition(QTextCursor.End)
self.log_text_area.setTextCursor(cursor)
def firmware_path_changed(self):
self.toggle_exec_button()
def toggle_exec_button(self):
if (
len(self.txtFolder.text()) > 0
and self.device_selector.selected_device() is not None
):
self.btnExec.setEnabled(True)
else:
self.btnExec.setEnabled(False)
[docs]class AdminDialog(QDialog):
"""
Displays administrative related information and settings (logs, environment
variables, third party packages etc...).
"""
def __init__(self, parent=None):
super().__init__(parent)
self.microbit_widget = None
self.package_widget = None
self.envar_widget = None
self.python_anywhere_widget = None
def setup(self, log, settings, packages, mode, device_list):
self.setMinimumSize(600, 400)
self.setWindowTitle(_("Mu Administration"))
widget_layout = QVBoxLayout()
self.setLayout(widget_layout)
self.tabs = QTabWidget()
widget_layout.addWidget(self.tabs)
button_box = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel
)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
widget_layout.addWidget(button_box)
# Tabs
self.log_widget = LogWidget(self)
self.log_widget.setup(log)
self.tabs.addTab(self.log_widget, _("Current Log"))
if mode.short_name in ["python", "web", "pygamezero"]:
self.envar_widget = EnvironmentVariablesWidget(self)
self.envar_widget.setup(settings.get("envars", ""))
self.tabs.addTab(self.envar_widget, _("Python3 Environment"))
if mode.short_name == "microbit":
self.microbit_widget = MicrobitSettingsWidget(self)
self.microbit_widget.setup(
settings.get("minify", False),
settings.get("microbit_runtime", ""),
)
self.tabs.addTab(self.microbit_widget, _("BBC micro:bit Settings"))
if mode.short_name in ["python", "web", "pygamezero"]:
self.package_widget = PackagesWidget(self)
self.package_widget.setup(packages)
self.tabs.addTab(self.package_widget, _("Third Party Packages"))
if mode.short_name == "esp":
self.esp_widget = ESPFirmwareFlasherWidget(self)
self.esp_widget.setup(mode, device_list)
self.tabs.addTab(self.esp_widget, _("ESP Firmware flasher"))
if mode.short_name == "web":
self.python_anywhere_widget = PythonAnywhereWidget(self)
self.python_anywhere_widget.setup(
settings.get("pa_username", ""),
settings.get("pa_token", ""),
settings.get("pa_instance", "www"),
)
self.tabs.addTab(
self.python_anywhere_widget, _("PythonAnywhere API")
)
# Configure local.
self.locale_widget = LocaleWidget(self)
self.locale_widget.setup(settings.get("locale"))
self.tabs.addTab(
self.locale_widget, load_icon("language.svg"), _("Select Language")
)
self.log_widget.log_text_area.setFocus()
[docs] def settings(self):
"""
Return a dictionary representation of the raw settings information
generated by this dialog. Such settings will need to be processed /
checked in the "logic" layer of Mu.
"""
settings = {}
if self.envar_widget:
settings["envars"] = self.envar_widget.text_area.toPlainText()
if self.microbit_widget:
settings["minify"] = self.microbit_widget.minify.isChecked()
settings[
"microbit_runtime"
] = self.microbit_widget.runtime_path.text()
if self.package_widget:
settings["packages"] = self.package_widget.text_area.toPlainText()
if self.python_anywhere_widget:
settings[
"pa_username"
] = self.python_anywhere_widget.username_text.text().strip()
settings[
"pa_token"
] = self.python_anywhere_widget.token_text.text().strip()
settings[
"pa_instance"
] = (
self.python_anywhere_widget.instance_combo.currentText().strip()
)
settings["locale"] = self.locale_widget.get_locale()
return settings
[docs]class FindReplaceDialog(QDialog):
"""
Display a dialog for getting:
* A term to find,
* An optional value to replace the search term,
* A flag to indicate if the user wishes to replace all.
"""
def __init__(self, parent=None):
super().__init__(parent)
def setup(self, find=None, replace=None, replace_flag=False):
self.setMinimumSize(600, 200)
self.setWindowTitle(_("Find / Replace"))
widget_layout = QVBoxLayout()
self.setLayout(widget_layout)
# Find.
find_label = QLabel(_("Find:"))
self.find_term = QLineEdit()
self.find_term.setText(find)
self.find_term.selectAll()
widget_layout.addWidget(find_label)
widget_layout.addWidget(self.find_term)
# Replace
replace_label = QLabel(_("Replace (optional):"))
self.replace_term = QLineEdit()
self.replace_term.setText(replace)
widget_layout.addWidget(replace_label)
widget_layout.addWidget(self.replace_term)
# Global replace.
self.replace_all_flag = QCheckBox(_("Replace all?"))
self.replace_all_flag.setChecked(replace_flag)
widget_layout.addWidget(self.replace_all_flag)
button_box = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel
)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
widget_layout.addWidget(button_box)
[docs] def find(self):
"""
Return the value the user entered to find.
"""
return self.find_term.text()
[docs] def replace(self):
"""
Return the value the user entered for replace.
"""
return self.replace_term.text()
[docs] def replace_flag(self):
"""
Return the value of the global replace flag.
"""
return self.replace_all_flag.isChecked()
[docs]class PackageDialog(QDialog):
"""
Display the output of the pip commands needed to remove or install
packages.
Because the QProcess mechanism we're using is asynchronous, we have to
manage the pip requests via `pip_queue`. When one request is signalled
as finished we start the next.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.pip_queue = []
[docs] def setup(self, to_remove, to_add):
"""
Create the UI for the dialog.
"""
# Basic layout.
self.setMinimumSize(600, 400)
self.setWindowTitle(_("Third Party Package Status"))
widget_layout = QVBoxLayout()
self.setLayout(widget_layout)
# Text area for pip output.
self.text_area = QPlainTextEdit()
self.text_area.setReadOnly(True)
self.text_area.setLineWrapMode(QPlainTextEdit.NoWrap)
widget_layout.addWidget(self.text_area)
# Buttons.
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok)
self.button_box.button(QDialogButtonBox.Ok).setEnabled(False)
self.button_box.accepted.connect(self.accept)
widget_layout.addWidget(self.button_box)
#
# Set up the commands to be issues to pip. Since we'll be popping
# from the list (as LIFO) we'll add the installs first so the
# removes are the first to happen
#
if to_add:
self.pip_queue.append(("install", to_add))
if to_remove:
self.pip_queue.append(("remove", to_remove))
QTimer.singleShot(2, self.next_pip_command)
[docs] def next_pip_command(self):
"""
Run the next pip command, finishing if there is none.
"""
if self.pip_queue:
command, packages = self.pip_queue.pop()
self.run_pip(command, packages)
else:
self.finish()
[docs] def finish(self):
"""
Set the UI to a valid end state.
"""
self.text_area.appendPlainText("\nFINISHED")
self.button_box.button(QDialogButtonBox.Ok).setEnabled(True)
[docs] def run_pip(self, command, packages):
"""
Run a pip command in a subprocess and pipe the output to the dialog's
text area.
"""
if command == "remove":
pip_fn = venv.remove_user_packages
elif command == "install":
pip_fn = venv.install_user_packages
else:
raise RuntimeError(
"Invalid pip command: %s %s" % (command, packages)
)
pip_fn(
packages,
slots=venv.Slots(
output=self.text_area.appendPlainText,
finished=self.next_pip_command,
),
)