Source code for ascii_designer.toolkit_qt
'''
ToolkitQt-specific notes:
* Alignment / Stretch not 100% reliable so far, if using row/col-span.
* Tree / List widget not available so far
* closing of form with X button cannot be stopped in the default handler. If
you need to do this, replace (root).closeEvent function.
'''
import sys
import qtpy as qt
from qtpy.QtCore import Qt, QAbstractItemModel, QModelIndex
import qtpy.QtGui as qg
import qtpy.QtWidgets as qw
from .toolkit import ToolkitBase, ListBinding
from .list_model import ObsList
__all__ = [
'ToolkitQt',
]
_qtapp = qw.QApplication(sys.argv)
_qt_running=False
def _make_focusout(func):
def _pte_focusOutEvent(event):
if event.reason() != Qt.PopupFocusReason:
func()
return _pte_focusOutEvent
[docs]
class ToolkitQt(ToolkitBase):
default_shortcuts = {
'new': qg.QKeySequence.New,
'open': qg.QKeySequence.Open,
'save': qg.QKeySequence.Save,
'undo': qg.QKeySequence.Undo,
'redo': qg.QKeySequence.Redo,
'cut': qg.QKeySequence.Cut,
'copy': qg.QKeySequence.Copy,
'paste': qg.QKeySequence.Paste,
'find': qg.QKeySequence.Find,
'refresh': qg.QKeySequence.Refresh,
# these are in addition to the Toolkit.default_shortcuts
'save_as': qg.QKeySequence.SaveAs,
'delete': qg.QKeySequence.Delete,
'preferences': qg.QKeySequence.Preferences,
'quit': qg.QKeySequence.Quit,
}
widget_classes = {
"label": qw.QLabel,
"box": qw.QWidget,
"box_labeled": qw.QGroupBox,
"option": qw.QRadioButton,
"checkbox": qw.QCheckBox,
"slider": qw.QSlider,
"multiline": qw.QPlainTextEdit,
"textbox": qw.QLineEdit,
"treelist": qw.QTreeView,
"combo": qw.QComboBox,
"dropdown": qw.QComboBox,
"button": qw.QPushButton,
}
def __init__(self, **kwargs):
super().__init__(**kwargs)
# widget generators
[docs]
def root(self, title='Window', icon='', on_close=None):
'''make a root (window) widget'''
root = qw.QMainWindow()
cw = qw.QWidget(root)
root.setCentralWidget(cw)
cw.setLayout(qw.QGridLayout())
root.setWindowTitle(title)
if icon:
qicon = qg.QIcon(icon)
if _qtapp.windowIcon().isNull():
# Set icon for whole application; assuming that the first opened
# window is representative
_qtapp.setWindowIcon(qicon)
else:
root.setWindowIcon(qicon)
if on_close:
root.closeEvent = lambda ev: on_close()
return root
[docs]
def show(self, frame):
'''do what is necessary to make frame appear onscreen.'''
frame.show()
# now this is really not pretty, but as it says above, do what is necessary.
global _qt_running
if not _qt_running:
_qt_running=True
_qtapp.exec_()
_qt_running=False
[docs]
def place(self, widget, row=0, col=0, rowspan=1, colspan=1):
'''place widget'''
widget.parent().layout().addWidget(widget, row, col, rowspan, colspan)
[docs]
def connect(self, widget, function):
'''bind the widget's default event to function.
Default event is:
* click() for a button
* value_changed(new_value) for value-type controls;
usually fired after focus-lost or Return-press.
'''
if isinstance(widget, qw.QPushButton):
widget.clicked.connect(lambda *args: function())
return
elif isinstance(widget, qw.QTreeView):
def handler(*args, widget=widget):
mindex = widget.currentIndex()
sl = mindex.internalPointer()
item = sl[mindex.row()]
function(item)
widget.clicked.connect(handler)
return
# other cases
handler = lambda *args, widget=widget: function(self.getval(widget))
if isinstance(widget, (qw.QCheckBox, qw.QRadioButton)):
# FIXME: For radiobutton, give the selected ID as value
widget.toggled.connect(handler)
elif isinstance(widget, qw.QLineEdit):
widget.editingFinished.connect(handler)
elif isinstance(widget, qw.QPlainTextEdit):
widget.focusOutEvent = _make_focusout(handler)
elif isinstance(widget, qw.QComboBox):
if widget.isEditable():
widget.lineEdit().editingFinished.connect(handler)
else:
widget.currentIndexChanged.connect(handler)
elif isinstance(widget, qw.QSlider):
widget.valueChanged.connect(handler)
else:
raise TypeError('I do not know how to connect a %s'%(widget.__class__.__name__))
[docs]
def getval(self, widget):
cls = widget.__class__
if cls is qw.QWidget or issubclass(cls, qw.QGroupBox):
return widget._value
if issubclass(cls, qw.QPushButton): return widget.text()
# FIXME: for Radio Button, return checked ID
if issubclass(cls, qw.QRadioButton): return widget.isChecked()
if issubclass(cls, qw.QCheckBox): return widget.isChecked()
if issubclass(cls, qw.QLineEdit): return widget.text()
if issubclass(cls, qw.QPlainTextEdit): return widget.toPlainText()
if issubclass(cls, qw.QSlider): return widget.value()
if issubclass(cls, qw.QComboBox):
if widget.isEditable():
return widget.lineEdit().text()
else:
idx = widget.currentIndex()
return widget.itemText(idx) if idx >= 0 else None
if issubclass(cls, qw.QTreeView):
return widget.model().list
[docs]
def setval(self, widget, value):
from .autoframe import AutoFrame
if widget.hasFocus():
# FIXME: check the appropriate modified indicators for the wiget
# if not modified, go through
return
if type(widget) in (qw.QWidget, qw.QGroupBox):
# Replace content or frame itself.
# (If replacing the widget, it will be Qt-"destroyed" but it
# will still hold the ._value!)
oldwidget = widget._value
if isinstance(oldwidget, AutoFrame):
raise NotImplementedError("Qt: cannot replace AutoFrame in placeholders yet.")
oldwidget = oldwidget.f_controls[""]
# TODO: clear all children, run destroy-hook
if isinstance(value, AutoFrame):
# render the autoframe into the child frame
if not value.f_controls:
value.f_controls[""] = oldwidget
value.f_build(parent=oldwidget)
widget._value = value
else:
# Replace the frame with the given value
if value.parent() is not oldwidget.parent():
raise ValueError('Replacement widget must have the same parent')
# copy grid info
layout = oldwidget.parent().layout()
idx = layout.indexOf(oldwidget)
row, col, rowspan, colspan = layout.getItemPosition(idx)
# remove frame
oldwidget.deleteLater()
# place new widget
self.place(value, row, col, rowspan, colspan)
# FIXME: I could not figure out how to query the orignal widget's alignment.
self.anchor(value, True,True,True,True)
widget._value = value
elif isinstance(widget, qw.QPushButton):
widget.setText(value)
elif isinstance(widget, (qw.QCheckBox, qw.QRadioButton)):
widget.setChecked(value)
elif isinstance(widget, qw.QLineEdit):
widget.setText(value)
elif isinstance(widget, qw.QPlainTextEdit):
widget.document().setPlainText(value)
elif isinstance(widget, qw.QComboBox):
if widget.isEditable():
widget.lineEdit().setText(value)
else:
idx = widget.findText(value)
if idx<0:
raise ValueError('Tried to set value "%s" that is not in the list.'%value)
widget.setCurrentIndex(idx)
elif isinstance(widget, qw.QSlider):
widget.setValue(value)
elif isinstance(widget, qw.QTreeView):
widget.model().list = value
else:
raise TypeError('I do not know how to set the value of a %s'%(widget.__class__.__name__))
[docs]
def row_stretch(self, container, row, proportion):
'''set the given row to stretch according to the proportion.'''
if isinstance(container, qw.QMainWindow):
container = container.centralWidget()
container.layout().setRowStretch(row, proportion)
[docs]
def col_stretch(self, container, col, proportion):
'''set the given col to stretch according to the proportion.'''
if isinstance(container, qw.QMainWindow):
container = container.centralWidget()
container.layout().setColumnStretch(col, proportion)
[docs]
def anchor(self, widget, left=True, right=True, top=True, bottom=True):
'''anchor the widget. Depending on the anchors, widget will be left-,
right-, center-aligned or stretched.
'''
align = {
(False, False): Qt.AlignHCenter,
(True, False): Qt.AlignLeft,
(False, True): Qt.AlignRight,
(True, True): Qt.Alignment()
}[(left, right)]
widget.parent().layout().setAlignment(widget, align)
# Widgets
[docs]
def box(self, parent, id=None, text='', given_id=''):
"""Creates a QWidget or QGroupBox.
A ``_value`` property is created, to hold AutoFrame-reassigned value.
"""
if isinstance(parent, qw.QMainWindow):
parent = parent.centralWidget()
if given_id and text:
cls = self.widget_classes["box_labeled"]
f = cls(parent=parent, title=text)
f.setLayout(qw.QGridLayout())
self.row_stretch(f, 0, 1)
self.col_stretch(f, 0, 1)
inner = qw.QWidget(f)
self.place(inner, 0, 0)
inner.setLayout(qw.QGridLayout())
f._value = inner
else:
cls = self.widget_classes["box"]
f = cls(parent)
f.setLayout(qw.QGridLayout())
f._value = f
return f
[docs]
def label(self, parent, id=None, label_id=None, text=''):
'''label'''
if isinstance(parent, qw.QMainWindow):
parent = parent.centralWidget()
return self.widget_classes["label"](parent=parent, text=text)
[docs]
def button(self, parent, id=None, text=''):
'''button'''
if isinstance(parent, qw.QMainWindow):
parent = parent.centralWidget()
return self.widget_classes["button"](parent=parent, text=text)
[docs]
def textbox(self, parent, id=None, text=''):
'''single-line text entry box'''
if isinstance(parent, qw.QMainWindow):
parent = parent.centralWidget()
return self.widget_classes["textbox"](text, parent=parent)
[docs]
def multiline(self, parent, id=None, text=''):
'''multi-line text entry box'''
if isinstance(parent, qw.QMainWindow):
parent = parent.centralWidget()
return self.widget_classes["multiline"](text, parent=parent)
[docs]
def treelist(self, parent, id=None, text='', columns=None, first_column_editable=False):
'''treeview (also usable as plain list)
Qt notes: The model does no caching on its own, but retrieves
item data all the time. I.e. if your columns are costly to
calculate, roll your own caching please.
'''
if isinstance(parent, qw.QMainWindow):
parent = parent.centralWidget()
if text:
t = text
# make anonymous object
class first_column:
id = ""
text = t
editable = first_column_editable
columns.insert(0, first_column)
w = self.widget_classes["treelist"](parent)
model = ListBindingQt(w, columns)
w.setModel(model)
# connect events
w.expanded.connect(model.on_gui_expand)
w.setSortingEnabled(True)
w.sortByColumn(-1, Qt.AscendingOrder)
return w
[docs]
def dropdown(self, parent, id=None, text='', values=None, _clskey="dropdown"):
'''dropdown box; values is the raw string between the parens. Only preset choices allowed.'''
if isinstance(parent, qw.QMainWindow):
parent = parent.centralWidget()
w = self.widget_classes[_clskey](parent)
choices = [v.strip() for v in (values or '').split(',') if v.strip()]
w.addItems(choices)
return w
[docs]
def combo(self, parent, id=None, text='', values=None):
'''dropdown with editable values.'''
if isinstance(parent, qw.QMainWindow):
parent = parent.centralWidget()
w = self.dropdown(parent, id=id, text=text, values=values, _clskey="combo")
w.setEditable(True)
return w
[docs]
def option(self, parent, id=None, text='', checked=None):
'''Option button. Prefix 'O' for unchecked, '0' for checked.'''
if isinstance(parent, qw.QMainWindow):
parent = parent.centralWidget()
rb = self.widget_classes["option"](text, parent=parent)
rb.setChecked((checked=='x'))
return rb
[docs]
def checkbox(self, parent, id=None, text='', checked=None):
'''Checkbox'''
if isinstance(parent, qw.QMainWindow):
parent = parent.centralWidget()
cb = self.widget_classes["checkbox"](text, parent=parent)
cb.setChecked((checked=='x'))
return cb
[docs]
def slider(self, parent, id=None, min=None, max=None):
'''slider, integer values, from min to max'''
if isinstance(parent, qw.QMainWindow):
parent = parent.centralWidget()
s = self.widget_classes["slider"](Qt.Horizontal, parent=parent)
s.setMinimum(int(min))
s.setMaximum(int(max))
s.setTickPosition(qw.QSlider.TicksBelow)
return s
class ListBindingQt(QAbstractItemModel, ListBinding):
def __init__(self, parent, columns, **kwargs):
keys = [c.id for c in columns]
self._allow_sorting = True
self._tv = parent
super().__init__(parent=parent, keys=keys, **kwargs)
self._captions = [c.text for c in columns]
self._editable = [c.editable for c in columns]
self._list.toolkit_parent_id = QModelIndex()
@property
def allow_sorting(self):
return self._allow_sorting
@allow_sorting.setter
def allow_sorting(self, val):
val = bool(val)
self._allow_sorting = val
self._tv.setSortingEnabled(val)
@property
def allow_reorder(self):
return False
@allow_reorder.setter
def allow_reorder(self, val):
raise NotImplementedError("Drag and drop reordering of Qt Treeview is not implemented.")
def _set_list(self, val):
'''replace all current items by the new iterable ``val``.'''
self.modelAboutToBeReset.emit()
super()._set_list(val)
self._list.toolkit_parent_id = QModelIndex()
self.modelReset.emit()
def columnCount(self, parent):
return len(self.keys)
def headerData(self, section, orientation, role):
if orientation == Qt.Horizontal:
if role == Qt.DisplayRole:
return self._captions[section]
return None
def _idx2sl(self, model_index):
'''returns (sublist, item idx)'''
if not model_index.isValid():
return None, None
return (model_index.internalPointer(), model_index.row())
def hasChildren(self, parent):
sl, idx = self._idx2sl(parent)
if sl is None:
return True
return sl.has_children(sl[idx])
def rowCount(self, parent):
pl, idx = self._idx2sl(parent)
if parent.column() > 0:
return 0
if pl is None:
sl = self._list
else:
#sl = pl.get_children(idx)
sl = pl._childlists[idx]
if sl is not None:
sl.toolkit_parent_id = parent
return len(sl or [])
def index(self, row, column, parent):
if not self.hasIndex(row, column, parent):
return QModelIndex()
if column >= len(self.keys):
return QModelIndex()
pl, idx = self._idx2sl(parent)
if pl is None:
sl = self._list
else:
#sl = pl.get_children(idx)
sl = pl._childlists[idx]
if sl is not None:
sl.toolkit_parent_id = parent
else:
sl = []
if row < len(sl):
# internalPointer is the ObsList CONTAINING our item.
model_index = self.createIndex(row, column, sl)
sl.toolkit_ids[row] = model_index
return model_index
else:
return QModelIndex()
def data(self, index, role):
if not index.isValid(): return None
if role not in (Qt.DisplayRole, Qt.EditRole): return None
sl, idx = self._idx2sl(index)
item = sl[idx]
key = self.keys[index.column()]
return self.retrieve(item, key)
def setData(self, index, value, role):
if not index.isValid(): return False
if not self._editable[index.column()]:
return False
if role != Qt.EditRole: return None
sl, idx = self._idx2sl(index)
item = sl[idx]
key = self.keys[index.column()]
self.store(item, value, key)
# calculated fields might also have changed. Mutate whole item.
sl.item_mutated(item)
return True
def flags(self, index):
if not index.isValid(): return Qt.NoItemFlags
is_editable = self._editable[index.column()]
result = Qt.ItemIsEnabled | Qt.ItemIsSelectable
if is_editable:
result |= Qt.ItemIsEditable
return result
def parent(self, index):
if not index.isValid():
return QModelIndex()
sl, idx = self._idx2sl(index)
# root item
if sl is self._list:
return QModelIndex()
# otherwise
return sl.toolkit_parent_id
# === ObsList handlers ===
def on_insert(self, idx, item, toolkit_parent_id):
self.layoutChanged.emit()
def on_load_children(self, children):
self.layoutChanged.emit()
def on_replace(self, iid, item):
sl, idx = self._list.find(item)
top_left = self.createIndex(idx, 0, sl)
btm_right = self.createIndex(idx, len(self.keys)-1, sl)
self.dataChanged.emit(top_left, btm_right)
def on_remove(self, iid):
self.layoutChanged.emit()
def on_sort(self, sublist, info):
super().on_sort(sublist, info)
self.layoutChanged.emit()
def on_get_selection(self):
indexes = self._tv.selectedIndexes()
return [
sl[idx]
for model_index in indexes
if model_index.column() == 0
for sl, idx in [self._idx2sl(model_index)]
]
# === GUI event handlers ===
def on_gui_expand(self, mindex):
sl, idx = self._idx2sl(mindex)
sl.load_children(idx)
# actually not a connected handler
def sort(self, column, order):
# we need to do some trickery here, because QAbstractItemModel.sort
# collides with ListBinding.sort. We detect which is wanted by looking
# at column's type.
if isinstance(column, str):
# Forward to base class
ListBinding.sort(self, column, order)
else:
# Qt sort implementation.
if column < 0:
# do nothing
return
ascending = (order == Qt.AscendingOrder)
key = self.keys[column]
ListBinding.sort(self, key, ascending)