gui: redesign table/trees to avoid slow and buggy qt/pyqt autosize. Closes #182. Closes #187.

This commit is contained in:
Sebastien Bourdeauducq 2016-03-25 18:33:22 +08:00
parent b190581102
commit bebd89c959
4 changed files with 142 additions and 72 deletions

View File

@ -130,6 +130,7 @@ def main():
d_datasets = datasets.DatasetsDock(sub_clients["datasets"], d_datasets = datasets.DatasetsDock(sub_clients["datasets"],
rpc_clients["dataset_db"]) rpc_clients["dataset_db"])
smgr.register(d_datasets)
d_applets = applets.AppletsDock(main_window, sub_clients["datasets"]) d_applets = applets.AppletsDock(main_window, sub_clients["datasets"])
atexit_register_coroutine(d_applets.stop) atexit_register_coroutine(d_applets.stop)
@ -142,6 +143,7 @@ def main():
d_schedule = schedule.ScheduleDock( d_schedule = schedule.ScheduleDock(
status_bar, rpc_clients["schedule"], sub_clients["schedule"]) status_bar, rpc_clients["schedule"], sub_clients["schedule"])
smgr.register(d_schedule)
logmgr = log.LogDockManager(main_window, sub_clients["log"]) logmgr = log.LogDockManager(main_window, sub_clients["log"])
smgr.register(logmgr) smgr.register(logmgr)
@ -172,7 +174,7 @@ def main():
# create first log dock if not already in state # create first log dock if not already in state
d_log0 = logmgr.first_log_dock() d_log0 = logmgr.first_log_dock()
if d_log0 is not None: if d_log0 is not None:
main_window.tabifyDockWidget(d_shortcuts, d_log0) main_window.tabifyDockWidget(d_schedule, d_log0)
# run # run
main_window.show() main_window.show()

View File

@ -47,8 +47,6 @@ class DatasetsDock(QtWidgets.QDockWidget):
self.table = QtWidgets.QTreeView() self.table = QtWidgets.QTreeView()
self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self.table.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) self.table.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
self.table.header().setSectionResizeMode(
QtWidgets.QHeaderView.ResizeToContents)
grid.addWidget(self.table, 1, 0) grid.addWidget(self.table, 1, 0)
self.table.setContextMenuPolicy(QtCore.Qt.ActionsContextMenu) self.table.setContextMenuPolicy(QtCore.Qt.ActionsContextMenu)
@ -79,3 +77,9 @@ class DatasetsDock(QtWidgets.QDockWidget):
key = self.table_model.index_to_key(idx) key = self.table_model.index_to_key(idx)
if key is not None: if key is not None:
asyncio.ensure_future(self.dataset_ctl.delete(key)) asyncio.ensure_future(self.dataset_ctl.delete(key))
def save_state(self):
return bytes(self.table.header().saveState())
def restore_state(self, state):
self.table.header().restoreState(QtCore.QByteArray(state))

View File

@ -10,20 +10,24 @@ from artiq.gui.tools import (LayoutWidget, log_level_to_name,
QDockWidgetCloseDetect) QDockWidgetCloseDetect)
def _make_wrappable(row, width=30): class ModelItem:
level, source, time, msg = row def __init__(self, parent, row):
msg = re.sub("(\\S{{{}}})".format(width), "\\1\u200b", msg) self.parent = parent
return [level, source, time, msg] self.row = row
self.children_by_row = []
class Model(QtCore.QAbstractTableModel): class Model(QtCore.QAbstractItemModel):
def __init__(self, init): def __init__(self, init):
QtCore.QAbstractTableModel.__init__(self) QtCore.QAbstractTableModel.__init__(self)
self.headers = ["Source", "Message"] self.headers = ["Source", "Message"]
self.children_by_row = []
self.entries = list(map(_make_wrappable, init)) self.entries = []
self.pending_entries = [] self.pending_entries = []
for entry in init:
self.append(entry)
self.depth = 1000 self.depth = 1000
timer = QtCore.QTimer(self) timer = QtCore.QTimer(self)
timer.timeout.connect(self.timer_tick) timer.timeout.connect(self.timer_tick)
@ -44,7 +48,11 @@ class Model(QtCore.QAbstractTableModel):
return None return None
def rowCount(self, parent): def rowCount(self, parent):
return len(self.entries) if parent.isValid():
item = parent.internalPointer()
return len(item.children_by_row)
else:
return len(self.entries)
def columnCount(self, parent): def columnCount(self, parent):
return len(self.headers) return len(self.headers)
@ -53,7 +61,9 @@ class Model(QtCore.QAbstractTableModel):
pass pass
def append(self, v): def append(self, v):
self.pending_entries.append(_make_wrappable(v)) severity, source, timestamp, message = v
self.pending_entries.append((severity, source, timestamp,
message.split("\n")))
def insertRows(self, position, rows=1, index=QtCore.QModelIndex()): def insertRows(self, position, rows=1, index=QtCore.QModelIndex()):
self.beginInsertRows(QtCore.QModelIndex(), position, position+rows-1) self.beginInsertRows(QtCore.QModelIndex(), position, position+rows-1)
@ -70,44 +80,82 @@ class Model(QtCore.QAbstractTableModel):
records = self.pending_entries records = self.pending_entries
self.pending_entries = [] self.pending_entries = []
self.entries.extend(records) self.entries.extend(records)
for rec in records:
item = ModelItem(self, len(self.children_by_row))
self.children_by_row.append(item)
for i in range(len(rec[3])-1):
item.children_by_row.append(ModelItem(item, i))
self.insertRows(nrows, len(records)) self.insertRows(nrows, len(records))
if len(self.entries) > self.depth: if len(self.entries) > self.depth:
start = len(self.entries) - self.depth start = len(self.entries) - self.depth
self.entries = self.entries[start:] self.entries = self.entries[start:]
self.children_by_row = self.children_by_row[start:]
for child in self.children_by_row:
child.row -= start
self.removeRows(0, start) self.removeRows(0, start)
def data(self, index, role): def index(self, row, column, parent):
if parent.isValid():
parent_item = parent.internalPointer()
return self.createIndex(row, column,
parent_item.children_by_row[row])
else:
return self.createIndex(row, column, self.children_by_row[row])
def parent(self, index):
if index.isValid(): if index.isValid():
if (role == QtCore.Qt.FontRole parent = index.internalPointer().parent
and index.column() == 1): if parent is self:
return self.fixed_font return QtCore.QModelIndex()
elif role == QtCore.Qt.TextAlignmentRole: else:
return QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop return self.createIndex(parent.row, 0, parent)
elif role == QtCore.Qt.BackgroundRole: else:
level = self.entries[index.row()][0] return QtCore.QModelIndex()
if level >= logging.ERROR:
return self.error_bg def data(self, index, role):
elif level >= logging.WARNING: if not index.isValid():
return self.warning_bg return
else:
return self.white item = index.internalPointer()
elif role == QtCore.Qt.ForegroundRole: if item.parent is self:
level = self.entries[index.row()][0] msgnum = item.row
if level <= logging.DEBUG: else:
return self.debug_fg msgnum = item.parent.row
else:
return self.black if role == QtCore.Qt.FontRole and index.column() == 1:
elif role == QtCore.Qt.DisplayRole: return self.fixed_font
v = self.entries[index.row()] elif role == QtCore.Qt.BackgroundRole:
column = index.column() level = self.entries[msgnum][0]
if level >= logging.ERROR:
return self.error_bg
elif level >= logging.WARNING:
return self.warning_bg
else:
return self.white
elif role == QtCore.Qt.ForegroundRole:
level = self.entries[msgnum][0]
if level <= logging.DEBUG:
return self.debug_fg
else:
return self.black
elif role == QtCore.Qt.DisplayRole:
v = self.entries[msgnum]
column = index.column()
if item.parent is self:
if column == 0: if column == 0:
return v[1] return v[1]
else: else:
return v[3] return v[3][0]
elif role == QtCore.Qt.ToolTipRole: else:
v = self.entries[index.row()] if column == 0:
return (log_level_to_name(v[0]) + ", " + return ""
time.strftime("%m/%d %H:%M:%S", time.localtime(v[2]))) else:
return v[3][item.row+1]
elif role == QtCore.Qt.ToolTipRole:
v = self.entries[msgnum]
return (log_level_to_name(v[0]) + ", " +
time.strftime("%m/%d %H:%M:%S", time.localtime(v[2])))
class _LogFilterProxyModel(QtCore.QSortFilterProxyModel): class _LogFilterProxyModel(QtCore.QSortFilterProxyModel):
@ -118,14 +166,19 @@ class _LogFilterProxyModel(QtCore.QSortFilterProxyModel):
def filterAcceptsRow(self, sourceRow, sourceParent): def filterAcceptsRow(self, sourceRow, sourceParent):
model = self.sourceModel() model = self.sourceModel()
if sourceParent.isValid():
parent_item = sourceParent.internalPointer()
msgnum = parent_item.row
else:
msgnum = sourceRow
accepted_level = model.entries[sourceRow][0] >= self.min_level accepted_level = model.entries[msgnum][0] >= self.min_level
if self.freetext: if self.freetext:
data_source = model.entries[sourceRow][1] data_source = model.entries[msgnum][1]
data_message = model.entries[sourceRow][3] data_message = model.entries[msgnum][3]
accepted_freetext = (self.freetext in data_source accepted_freetext = (self.freetext in data_source
or self.freetext in data_message) or any(self.freetext in m for m in data_message))
else: else:
accepted_freetext = True accepted_freetext = True
@ -176,26 +229,30 @@ class _LogDock(QDockWidgetCloseDetect):
grid.addWidget(newdock, 0, 4) grid.addWidget(newdock, 0, 4)
grid.layout.setColumnStretch(2, 1) grid.layout.setColumnStretch(2, 1)
self.log = QtWidgets.QTableView() self.log = QtWidgets.QTreeView()
self.log.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) self.log.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection)
self.log.horizontalHeader().setSectionResizeMode(
QtWidgets.QHeaderView.ResizeToContents)
self.log.horizontalHeader().setStretchLastSection(True)
self.log.verticalHeader().setSectionResizeMode(
QtWidgets.QHeaderView.ResizeToContents)
self.log.verticalHeader().hide()
self.log.setHorizontalScrollMode( self.log.setHorizontalScrollMode(
QtWidgets.QAbstractItemView.ScrollPerPixel) QtWidgets.QAbstractItemView.ScrollPerPixel)
self.log.setVerticalScrollMode( self.log.setVerticalScrollMode(
QtWidgets.QAbstractItemView.ScrollPerPixel) QtWidgets.QAbstractItemView.ScrollPerPixel)
self.log.setShowGrid(False) self.log.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
self.log.setTextElideMode(QtCore.Qt.ElideNone)
grid.addWidget(self.log, 1, 0, colspan=5) grid.addWidget(self.log, 1, 0, colspan=5)
self.scroll_at_bottom = False self.scroll_at_bottom = False
self.scroll_value = 0 self.scroll_value = 0
# If Qt worked correctly, this would be nice to have. Alas, resizeSections
# is broken when the horizontal scrollbar is enabled.
# self.log.setContextMenuPolicy(QtCore.Qt.ActionsContextMenu)
# sizeheader_action = QtWidgets.QAction("Resize header", self.log)
# sizeheader_action.triggered.connect(
# lambda: self.log.header().resizeSections(QtWidgets.QHeaderView.ResizeToContents))
# self.log.addAction(sizeheader_action)
log_sub.add_setmodel_callback(self.set_model) log_sub.add_setmodel_callback(self.set_model)
cw = QtGui.QFontMetrics(self.font()).averageCharWidth()
self.log.header().resizeSection(0, 26*cw)
def filter_level_changed(self): def filter_level_changed(self):
if not hasattr(self, "table_model_filter"): if not hasattr(self, "table_model_filter"):
return return
@ -219,21 +276,13 @@ class _LogDock(QDockWidgetCloseDetect):
if self.scroll_at_bottom: if self.scroll_at_bottom:
self.log.scrollToBottom() self.log.scrollToBottom()
# HACK:
# If we don't do this, after we first add some rows, the "Time"
# column gets undersized and the text in it gets wrapped.
# We can call self.log.resizeColumnsToContents(), which fixes
# that problem, but now the message column is too large and
# a horizontal scrollbar appears.
# This is almost certainly a Qt layout bug.
self.log.horizontalHeader().reset()
# HACK: # HACK:
# Qt intermittently likes to scroll back to the top when rows are removed. # Qt intermittently likes to scroll back to the top when rows are removed.
# Work around this by restoring the scrollbar to the previously memorized # Work around this by restoring the scrollbar to the previously memorized
# position, after the removal. # position, after the removal.
# Note that this works because _LogModel always does the insertion right # Note that this works because _LogModel always does the insertion right
# before the removal. # before the removal.
# TODO: check if this is still required after moving to QTreeView
def rows_removed(self): def rows_removed(self):
if self.scroll_at_bottom: if self.scroll_at_bottom:
self.log.scrollToBottom() self.log.scrollToBottom()
@ -257,7 +306,8 @@ class _LogDock(QDockWidgetCloseDetect):
def save_state(self): def save_state(self):
return { return {
"min_level_idx": self.filter_level.currentIndex(), "min_level_idx": self.filter_level.currentIndex(),
"freetext_filter": self.filter_freetext.text() "freetext_filter": self.filter_freetext.text(),
"header": bytes(self.log.header().saveState())
} }
def restore_state(self, state): def restore_state(self, state):
@ -279,6 +329,13 @@ class _LogDock(QDockWidgetCloseDetect):
# manually here, unlike for the combobox. # manually here, unlike for the combobox.
self.filter_freetext_changed() self.filter_freetext_changed()
try:
header = state["header"]
except KeyError:
pass
else:
self.log.header().restoreState(QtCore.QByteArray(header))
class LogDockManager: class LogDockManager:
def __init__(self, main_window, log_sub): def __init__(self, main_window, log_sub):

View File

@ -2,7 +2,7 @@ import asyncio
import time import time
from functools import partial from functools import partial
from PyQt5 import QtCore, QtWidgets from PyQt5 import QtCore, QtWidgets, QtGui
from artiq.gui.models import DictSyncModel from artiq.gui.models import DictSyncModel
from artiq.tools import elide from artiq.tools import elide
@ -67,8 +67,6 @@ class ScheduleDock(QtWidgets.QDockWidget):
self.table = QtWidgets.QTableView() self.table = QtWidgets.QTableView()
self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self.table.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) self.table.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
self.table.horizontalHeader().setSectionResizeMode(
QtWidgets.QHeaderView.ResizeToContents)
self.table.verticalHeader().setSectionResizeMode( self.table.verticalHeader().setSectionResizeMode(
QtWidgets.QHeaderView.ResizeToContents) QtWidgets.QHeaderView.ResizeToContents)
self.table.verticalHeader().hide() self.table.verticalHeader().hide()
@ -89,17 +87,20 @@ class ScheduleDock(QtWidgets.QDockWidget):
self.table_model = Model(dict()) self.table_model = Model(dict())
schedule_sub.add_setmodel_callback(self.set_model) schedule_sub.add_setmodel_callback(self.set_model)
def rows_inserted_after(self): cw = QtGui.QFontMetrics(self.font()).averageCharWidth()
# HACK: h = self.table.horizontalHeader()
# workaround the usual Qt layout bug when the first row is inserted h.resizeSection(0, 7*cw)
# (columns are undersized if an experiment with a due date is scheduled h.resizeSection(1, 12*cw)
# and the schedule was empty) h.resizeSection(2, 16*cw)
self.table.horizontalHeader().reset() h.resizeSection(3, 6*cw)
h.resizeSection(4, 16*cw)
h.resizeSection(5, 30*cw)
h.resizeSection(6, 20*cw)
h.resizeSection(7, 20*cw)
def set_model(self, model): def set_model(self, model):
self.table_model = model self.table_model = model
self.table.setModel(self.table_model) self.table.setModel(self.table_model)
self.table_model.rowsInserted.connect(self.rows_inserted_after)
async def delete(self, rid, graceful): async def delete(self, rid, graceful):
if graceful: if graceful:
@ -118,3 +119,9 @@ class ScheduleDock(QtWidgets.QDockWidget):
msg = "Deleted RID {}".format(rid) msg = "Deleted RID {}".format(rid)
self.status_bar.showMessage(msg) self.status_bar.showMessage(msg)
asyncio.ensure_future(self.delete(rid, graceful)) asyncio.ensure_future(self.delete(rid, graceful))
def save_state(self):
return bytes(self.table.horizontalHeader().saveState())
def restore_state(self, state):
self.table.horizontalHeader().restoreState(QtCore.QByteArray(state))