import sys from functools import partial import math import click from PyQt5 import QtGui from PyQt5 import QtWidgets from PyQt5 import QtCore try: import qdarkstyle _has_qdarkstyle = True except ModuleNotFoundError: _has_qdarkstyle = False _GTypeRole = QtCore.Qt.UserRole _missing = object() class GStyle(object): _base_style = """ ._OptionLabel { font-size: 16px; font: bold; font-family: monospace; } ._HelpLabel { font-family: serif; font-size: 14px; } ._InputComboBox{ font-size: 16px; } ._InputLineEdit{ font-size: 16px; } ._InputCheckBox{ font-size: 16px; } ._InputSpinBox{ font-size: 16px; } ._InputTabWidget{ font: bold; font-size: 16px; } .GListView{ font-size: 16px; } QToolTip{ font-family: serif; } """ def __init__(self, style=""): if not GStyle.check_style(style): self.text_color = "black" self.placehoder_color = "#898b8d" self.stylesheet = GStyle._base_style +\ """ ._Spliter{ border: 1px inset gray; } """ elif style == "qdarkstyle": self.text_color = '#eff0f1' self.placehoder_color = "#898b8d" self.stylesheet = qdarkstyle.load_stylesheet_pyqt5() +\ GStyle._base_style +\ """ .GListView{ padding: 5px; } ._Spliter{ border: 5px solid gray; } """ @staticmethod def check_style(style): if style == "qdarkstyle": return _has_qdarkstyle return False _gstyle = GStyle() class GListView(QtWidgets.QListView): def __init__(self, opt): super(GListView, self).__init__() self.nargs = opt.nargs self.model = GItemModel(opt.nargs, parent=self, opt_type=opt.type, default=opt.default) self.setModel(self.model) self.delegate = GEditDelegate(self) self.setItemDelegate(self.delegate) self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) if self.nargs == -1: self.keyPressEvent = self.key_press self.setToolTip( "'a': add a new item blow the selected one\n" "'d': delete the selected item" ) def key_press(self, e): if self.nargs == -1: if e.key() == QtCore.Qt.Key_A: if len(self.selectedIndexes()) == 0: self.model.insertRow(0) else: for i in self.selectedIndexes(): self.model.insertRow(i.row()+1) if e.key() == QtCore.Qt.Key_D: si = self.selectedIndexes() for i in si: self.model.removeRow(i.row()) super(GListView, self).keyPressEvent(e) class GItemModel(QtGui.QStandardItemModel): def __init__(self, n, parent=None, opt_type=click.STRING, default=None): super(QtGui.QStandardItemModel, self).__init__(0, 1, parent) self.type = opt_type for row in range(n): if hasattr(default, "__len__"): self.insertRow(row, default[row]) else: self.insertRow(row, default) def insertRow(self, idx, val=""): super(GItemModel, self).insertRow(idx) index = self.index(idx, 0, QtCore.QModelIndex()) if val is None or val == "": self.setData(index, QtGui.QBrush(QtGui.QColor(_gstyle.placehoder_color)), role=QtCore.Qt.ForegroundRole) else: self.setData(index, val) def data(self, index, role=QtCore.Qt.DisplayRole): if role == QtCore.Qt.DisplayRole: dstr = QtGui.QStandardItemModel.data(self, index, role) if dstr == "" or dstr is None: if isinstance(self.type, click.types.Tuple): row = index.row() if 0 <= row < len(self.type.types): tp = self.type.types[row] dstr = tp.name else: dstr = self.type.name return dstr if role == _GTypeRole: tp = click.STRING if isinstance(self.type, click.types.Tuple): row = index.row() if 0 <= row < len(self.type.types): tp = self.type.types[row] elif isinstance(self.type, click.types.ParamType): tp = self.type return tp return QtGui.QStandardItemModel.data(self, index, role) class GEditDelegate(QtWidgets.QStyledItemDelegate): def createEditor(self, parent, option, index): tp = index.data(role=_GTypeRole) if isinstance(tp, click.Path): led = GLineEdit_path.from_option(tp, parent) else: led = QtWidgets.QLineEdit(parent) led.setPlaceholderText(tp.name) led.setValidator(select_type_validator(tp)) return led def setEditorData(self, editor, index): item_var = index.data(role=QtCore.Qt.EditRole) if item_var is not None: editor.setText(str(item_var)) def setModelData(self, editor, model, index): data_str = editor.text() if data_str == "" or data_str is None: model.setData(index, QtGui.QBrush(QtGui.QColor(_gstyle.placehoder_color)), role=QtCore.Qt.ForegroundRole) else: model.setData(index, QtGui.QBrush(QtGui.QColor(_gstyle.text_color)), role=QtCore.Qt.ForegroundRole) QtWidgets.QStyledItemDelegate.setModelData(self, editor, model, index) def generate_label(opt): show_name = getattr(opt, 'show_name', _missing) show_name = opt.name if show_name is _missing else show_name param = _OptionLabel(show_name) param.setToolTip(getattr(opt, 'help', None)) return param class GStringLineEditor(click.types.StringParamType): def to_widget(self, opt, validator=None): value = _InputLineEdit() value.setPlaceholderText(self.name) if opt.default: value.setText(str(opt.default)) if getattr(opt, "hide_input", False): value.setEchoMode(QtWidgets.QLineEdit.Password) value.setValidator(validator) def to_command(): return [opt.opts[0], value.text()] return [generate_label(opt), value], to_command class GIntLineEditor(GStringLineEditor): def to_widget(self, opt): return GStringLineEditor.to_widget(self, opt, validator=QtGui.QIntValidator()) class GFloatLineEditor(GStringLineEditor): def to_widget(self, opt): return GStringLineEditor.to_widget(self, opt, validator=QtGui.QDoubleValidator()) class GFileDialog(QtWidgets.QFileDialog): def __init__(self, *args, exists = False, file_okay = True, dir_okay= True, **kwargs): super(GFileDialog, self).__init__(*args, **kwargs) self.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, True) self.setLabelText(QtWidgets.QFileDialog.Accept, "Select") if (exists, file_okay, dir_okay) == (True, True, False): self.setFileMode(QtWidgets.QFileDialog.ExistingFile) elif (exists, file_okay, dir_okay) == (False, True, False): self.setFileMode(QtWidgets.QFileDialog.AnyFile) elif (exists, file_okay, dir_okay) == (True, False, True): self.setFileMode(QtWidgets.QFileDialog.Directory) elif (exists, file_okay, dir_okay) == (False, False, True): self.setFileMode(QtWidgets.QFileDialog.Directory) elif exists == True: self.setFileMode(QtWidgets.QFileDialog.ExistingFile) self.accept = self.accept_all elif exists == False: self.setFileMode(QtWidgets.QFileDialog.AnyFile) self.accept = self.accept_all def accept_all(self): super(GFileDialog, self).done(QtWidgets.QFileDialog.Accepted) class GLineEdit_path(QtWidgets.QLineEdit): def __init__(self, parent=None, exists = False, file_okay = True, dir_okay= True): super(GLineEdit_path, self).__init__(parent) self.action = self.addAction( self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon), QtWidgets.QLineEdit.TrailingPosition ) self.fdlg = GFileDialog(self, "Select File Dialog", "./", "*", exists = exists, file_okay = file_okay, dir_okay= dir_okay) self.action.triggered.connect(self.run_dialog) def run_dialog(self): if self.fdlg.exec() == QtWidgets.QFileDialog.Accepted: self.setText(self.fdlg.selectedFiles()[0]) @staticmethod def from_option(opt, parent=None): return GLineEdit_path( parent=parent, exists=opt.exists, file_okay=opt.file_okay, dir_okay=opt.dir_okay ) class GPathGLindEidt_path(click.types.Path): def to_widget(self, opt): value = GLineEdit_path( exists=self.exists, file_okay=self.file_okay, dir_okay=self.dir_okay ) value.setPlaceholderText(self.name) if opt.default: value.setText(str(opt.default)) def to_command(): return [opt.opts[0], value.text()] return [generate_label(opt), value], to_command class _GLabeledSlider(QtWidgets.QSlider): def __init__(self, min, max, val): super(_GLabeledSlider, self).__init__(QtCore.Qt.Horizontal) self.min, self.max = min, max self.setMinimum(min) self.setMaximum(max) self.setValue(val) self.label = self.__init_label() def __init_label(self): l = max( [ math.ceil(math.log10(abs(x))) if x != 0 else 1 for x in [self.min, self.max] ]) l += 1 return QtWidgets.QLabel('0'*l) def argument_command(to_command): def tc(): a = to_command() return a[1:] return tc class GSlider(QtWidgets.QHBoxLayout): def __init__(self, min=0, max=10, default=None, *args, **kwargs): super(QtWidgets.QHBoxLayout, self).__init__() self.min, self.max, self.default = min, max, default self.label = self.__init_label() self.slider = self.__init_slider() self.label.setText(str(self.default)) self.addWidget(self.slider) self.addWidget(self.label) def value(self): return self.slider.value() def __init_slider(self): slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) slider.setMinimum(self.min) slider.setMaximum(self.max) default_val = (self.min+self.max)//2 if isinstance(self.default, int): if self.min <= self.default <= self.max: default_val = self.default self.default = default_val slider.setValue(default_val) slider.valueChanged.connect(lambda x: self.label.setText(str(x))) return slider def __init_label(self): l = max( [ math.ceil(math.log10(abs(x))) if x != 0 else 1 for x in [self.min, self.max] ]) l += 1 return QtWidgets.QLabel('0'*l) class GIntRangeGSlider(click.types.IntRange): def to_widget(self, opt): value = GSlider( min=self.min, max=self.max, default=opt.default ) def to_command(): return [opt.opts[0], str(value.value())] return [generate_label(opt), value], to_command class GIntRangeSlider(click.types.IntRange): def to_widget(self, opt): value = QtWidgets.QSlider(QtCore.Qt.Horizontal) value.setMinimum(self.min) value.setMaximum(self.max) default_val = (self.min+self.max)//2 if isinstance(opt.default, int): if self.min <= opt.default <= self.max: default_val = opt.default value.setValue(default_val) def to_command(): return [opt.opts[0], str(value.value())] return [generate_label(opt), value], to_command class GIntRangeLineEditor(click.types.IntRange): def to_widget(self, opt): value = QtWidgets.QLineEdit() # TODO: set validator def to_command(): return [opt.opts[0], value.text()] return [generate_label(opt), value], to_command def bool_flag_option(opt): checkbox = _InputCheckBox(opt.name) if opt.default: checkbox.setCheckState(2) # set tip checkbox.setToolTip(opt.help) def to_command(): if checkbox.checkState(): return [opt.opts[0]] else: return opt.secondary_opts return [checkbox], to_command class GChoiceComboBox(click.types.Choice): def to_widget(self, opt): cb = _InputComboBox() cb.addItems(self.choices) def to_command(): return [opt.opts[0], cb.currentText()] return [generate_label(opt), cb], to_command def count_option(opt): sb = _InputSpinBox() def to_command(): return [opt.opts[0]] * int(sb.text()) return [generate_label(opt), sb], to_command class GTupleGListView(click.Tuple): def to_widget(self, opt): view = GListView(opt) def to_command(): _ = [opt.opts[0]] for idx in range(view.model.rowCount()): _.append(view.model.item(idx).text()) return _ return [generate_label(opt), view], to_command def multi_text_arguement(opt): value = GListView(opt) def to_command(): _ = [] for idx in range(value.model.rowCount()): _.append(value.model.item(idx).text()) # if opt.required and value.model.rowCount() == 0: # raise click.exceptions.BadParameter("Required") # print(opt.__dict__) return _ # return [QtWidgets.QLabel(opt.name), value], to_command return [_OptionLabel(opt.name), value], to_command def select_type_validator(tp: click.types.ParamType)-> QtGui.QValidator: """ select the right validator for `tp`""" if isinstance(tp, click.types.IntParamType): return QtGui.QIntValidator() elif isinstance(tp, click.types.FloatParamType): return QtGui.QDoubleValidator() return None def select_opt_validator(opt): """ select the right validator for `opt`""" return select_type_validator(opt.type) def opt_to_widget(opt): if opt.nargs > 1 : return GTupleGListView.to_widget(opt.type, opt) elif getattr(opt, "is_bool_flag", False): return bool_flag_option(opt) elif getattr(opt, "count", False): return count_option(opt) elif isinstance(opt.type, click.types.Choice): return GChoiceComboBox.to_widget(opt.type, opt) elif isinstance(opt.type, click.types.Path): return GPathGLindEidt_path.to_widget(opt.type, opt) elif isinstance(opt.type, click.types.IntRange): return GIntRangeGSlider.to_widget(opt.type, opt) elif isinstance(opt.type, click.types.IntParamType): return GIntLineEditor.to_widget(opt.type, opt) elif isinstance(opt.type, click.types.FloatParamType): return GFloatLineEditor.to_widget(opt.type, opt) else: return GStringLineEditor.to_widget(opt.type, opt) def _to_widget(opt): #customed widget if isinstance(opt.type, click.types.FuncParamType): if hasattr(opt.type.func, 'to_widget'): return opt.type.func.to_widget() elif hasattr(opt.type, 'to_widget'): return opt.type.to_widget() if isinstance(opt, click.core.Argument): if opt.nargs == 1: w, tc = opt_to_widget(opt) return w, argument_command(tc) elif (opt.nargs > 1 or opt.nargs == -1): return multi_text_arguement(opt) else: return opt_to_widget(opt) def layout_append_opts(layout, opts): params_func = [] widgets = [] i = 0 for i, para in enumerate(opts): widget, value_func = _to_widget(para) widgets.append(widget) params_func.append(value_func) for idx, w in enumerate(widget): if isinstance(w, QtWidgets.QLayout): layout.addLayout(w, i, idx) else: layout.addWidget(w, i, idx) return layout, params_func, widgets def generate_sysargv(cmd_list): argv_list = [] for name, func_list in cmd_list: argv_list.append(name) for value_func in func_list: argv_list += value_func() return argv_list class _Spliter(QtWidgets.QFrame): def __init__(self, parent=None): super(_Spliter, self).__init__( parent=parent) self.setFrameShape(QtWidgets.QFrame.HLine) class _InputComboBox(QtWidgets.QComboBox): pass class _InputTabWidget(QtWidgets.QTabWidget): pass class _HelpLabel(QtWidgets.QLabel): pass class _OptionLabel(QtWidgets.QLabel): pass class _InputLineEdit(QtWidgets.QLineEdit): pass class _InputCheckBox(QtWidgets.QCheckBox): pass class _InputSpinBox(QtWidgets.QSpinBox): pass class CommandLayout(QtWidgets.QGridLayout): def __init__(self, func, run_exit, parent_layout=None): super(CommandLayout, self).__init__() self.parent_layout = parent_layout self.func = func self.run_exit = run_exit if func.help: label = _HelpLabel(func.help) label.setWordWrap(True) self.addWidget(label, 0, 0, 1, 2) frame = _Spliter() self.addWidget(frame, 1, 0, 1, 2) self.params_func, self.widgets = self.append_opts(self.func.params) def add_sysargv(self): if hasattr(self.parent_layout, "add_sysargv"): self.parent_layout.add_sysargv() sys.argv += generate_sysargv( [(self.func.name, self.params_func)] ) def append_opts(self, opts): params_func = [] widgets = [] for i, para in enumerate(opts, self.rowCount()): widget, value_func = _to_widget(para) widgets.append(widget) params_func.append(value_func) for idx, w in enumerate(widget): if isinstance(w, QtWidgets.QLayout): self.addLayout(w, i, idx) else: self.addWidget(w, i, idx) self.setRowStretch(i, 5) return params_func, widgets def generate_cmd_button(self, label, cmd_slot, tooltip=""): button = QtWidgets.QPushButton(label) button.setToolTip(tooltip) button.clicked.connect(self.clean_sysargv) button.clicked.connect(self.add_sysargv) button.clicked.connect(cmd_slot) return button def add_cmd_button(self, label, cmd_slot, pos=None): run_button = self.generate_cmd_button(label, cmd_slot) if pos is None: pos = self.rowCount()+1, 0 self.addWidget( run_button, pos[0], pos[1] ) def add_cmd_buttons(self, args): row = self.rowCount()+1 cmd_layout = QtWidgets.QGridLayout() cmd_layout.setHorizontalSpacing(20) for col, arg in enumerate(args): button = self.generate_cmd_button(**arg) cmd_layout.addWidget(button, 0, col) self.addLayout(cmd_layout, row, 0, 1, 2) @QtCore.pyqtSlot() def clean_sysargv(self): sys.argv = [] class RunCommand(QtCore.QRunnable): def __init__(self, func, run_exit): super(RunCommand, self).__init__() self.func = func self.run_exit = run_exit @QtCore.pyqtSlot() def run(self): print(sys.argv) try: self.func(standalone_mode=self.run_exit) except click.exceptions.BadParameter as bpe: # warning message msg = QtWidgets.QMessageBox() msg.setIcon(QtWidgets.QMessageBox.Warning) msg.setText(bpe.format_message()) msg.exec_() except Exception as bpe: msg = QtWidgets.QMessageBox() msg.setIcon(QtWidgets.QMessageBox.Warning) msg.setText(repr(bpe)) msg.exec_() # if self.outputEdit is not None: # self.outputEdit.show() class GCommand(click.Command): def __init__(self, new_thread=True, *arg, **args): super(GCommand, self).__init__(*arg, **args) self.new_thread = new_thread class GOption(click.Option): def __init__(self, *arg, show_name=_missing, **args): super(GOption, self).__init__(*arg, **args) self.show_name = show_name # def normalOutputWritten(t): # """Append text to the QTextEdit.""" # Maybe QTextEdit.append() works as well, but this is how I do it: # cursor = text.textCursor() # cursor.movePosition(QtGui.QTextCursor.End) # cursor.insertText(t) # text.setTextCursor(cursor) # text.ensureCursorVisible() class GuiStream(QtCore.QObject): textWritten = QtCore.pyqtSignal(str) def flush(self): pass def write(self, text): self.textWritten.emit(str(text)) class OutputEdit(QtWidgets.QTextEdit): def print(self, text): cursor = self.textCursor() cursor.movePosition(QtGui.QTextCursor.End) cursor.insertText(text) self.setTextCursor(cursor) self.ensureCursorVisible() class App(QtWidgets.QWidget): def __init__(self, func, run_exit, new_thread, output='gui', left=10, top=10, width=400, height=140): """ Parameters ---------- output : str 'gui': [default] redirect screen output to the gui 'term': do nothing """ super().__init__() self.new_thread = new_thread self.title = func.name self.func = func self.initUI(run_exit, QtCore.QRect(left, top, width, height)) self.threadpool = QtCore.QThreadPool() self.outputEdit = self.initOutput(output) def initOutput(self, output): if output == 'gui': sys.stdout = GuiStream() sys.stderr = sys.stdout text = OutputEdit() text.setReadOnly(True) sys.stdout.textWritten.connect(text.print) sys.stdout.textWritten.connect(text.show) # sys.stdout.textWritten.connect(text.append) # text.show() return text else: return None def initCommandUI(self, func, run_exit, parent_layout=None): opt_set = CommandLayout(func, run_exit, parent_layout=parent_layout) if isinstance(func, click.MultiCommand): tabs = _InputTabWidget() for cmd, f in func.commands.items(): sub_opt_set = self.initCommandUI(f, run_exit, parent_layout=opt_set) tab = QtWidgets.QWidget() tab.setLayout(sub_opt_set) tabs.addTab(tab, cmd) opt_set.addWidget( tabs, opt_set.rowCount(), 0, 1, 2 ) # return opt_set elif isinstance(func, click.Command): new_thread = getattr(func, "new_thread", self.new_thread) opt_set.add_cmd_buttons( args= [ { 'label':'&Run', 'cmd_slot': partial(self.run_cmd,\ new_thread=new_thread), "tooltip":"run command" }, { 'label':'&Copy', 'cmd_slot': self.copy_cmd, "tooltip":"copy command to clipboard" }, ] ) return opt_set def initUI(self, run_exit, geometry): self.run_exit = run_exit self.setWindowTitle(self.title) # self.setGeometry(self.left, self.top, self.width, self.height) self.setGeometry(geometry) self.opt_set = self.initCommandUI(self.func, run_exit, ) self.setLayout(self.opt_set) self.show() @QtCore.pyqtSlot() def copy_cmd(self): cb = QtWidgets.QApplication.clipboard() cb.clear(mode=cb.Clipboard ) cmd_text = ' '.join(sys.argv) cb.setText(cmd_text, mode=cb.Clipboard) msg = QtWidgets.QMessageBox() msg.setIcon(QtWidgets.QMessageBox.Information) msg.setText(f"copy '{cmd_text}' to clipboard") msg.exec_() def run_cmd(self, new_thread): runcmd = RunCommand(self.func, self.run_exit) if new_thread: self.threadpool.start(runcmd) else: runcmd.run() def gui_it(click_func, style="qdarkstyle", **argvs)->None: """ Parameters ---------- click_func `new_thread` is used for qt-based func, like matplotlib """ global _gstyle _gstyle = GStyle(style) app = QtWidgets.QApplication(sys.argv) app.setStyleSheet(_gstyle.stylesheet) # set the default value for argvs argvs["run_exit"] = argvs.get("run_exit", False) argvs["new_thread"] = argvs.get("new_thread", False) ex = App(click_func, **argvs) sys.exit(app.exec_()) def gui_option(f:click.core.BaseCommand)->click.core.BaseCommand: """decorator for adding '--gui' option to command""" # TODO: add run_exit, new_thread def run_gui_it(ctx, param, value): if not value or ctx.resilient_parsing: return f.params = [p for p in f.params if not p.name == "gui"] gui_it(f) ctx.exit() return click.option('--gui', is_flag=True, callback=run_gui_it, help="run with gui", expose_value=False, is_eager=False)(f)