Skip to content

Commit

Permalink
ui: improved views behaviour
Browse files Browse the repository at this point in the history
 - Fixed painting rows while scrolling using the mouse.
   Sometimes blank lines were inserted at the bottom of the views when
   scrolling with the mouse.
 - Avoid to rerepaint rows when switching views or scrolling.
 - Selecting a row marks it for tracking, ensuring it's deselected when
   the row is not visible, and reselected when the row becomes visible
   during scrolling.

The following behaviour has not changed:
 - Selecting a row that was previously selected, deselects it.
 - Keyboard navigation.

Not fixed yet:
 - Selecting all the rows of a view with the mouse, visibles and not visibles.
 - Entering into a detailed view, going back to the previous view, and
   select (restore) the row that was previously selected (causes a
   segfault in a particular case).

Related: #1037
  • Loading branch information
gustavo-iniguez-goya committed Oct 17, 2023
1 parent 5fd7da8 commit 174c63c
Show file tree
Hide file tree
Showing 3 changed files with 103 additions and 65 deletions.
3 changes: 3 additions & 0 deletions ui/opensnitch/customwidgets/firewalltableview.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,6 @@ def setModel(self, model):
self.model().columnCountChanged.connect(self._cb_column_count_changed)
model.rowsUpdated.connect(self._cb_rows_updated)
model.rowsReordered.connect(self._cb_rows_reordered)

def setTrackingColumn(self, col):
pass
130 changes: 77 additions & 53 deletions ui/opensnitch/customwidgets/generictableview.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from PyQt5.QtGui import QColor, QStandardItemModel, QStandardItem, QMouseEvent
from PyQt5.QtGui import QColor, QStandardItemModel, QStandardItem
from PyQt5.QtSql import QSqlQueryModel, QSqlQuery, QSql
from PyQt5.QtWidgets import QTableView, QAbstractSlider
from PyQt5.QtCore import QItemSelectionModel, pyqtSignal, QEvent, Qt
Expand Down Expand Up @@ -53,6 +53,10 @@ def lastError(self):
def clear(self):
pass

def rowCount(self, index=None):
"""ensures that only the needed rows is created"""
return len(self.items)

def data(self, index, role=Qt.DisplayRole):
"""Paint rows with the data stored in self.items
"""
Expand Down Expand Up @@ -198,16 +202,19 @@ class GenericTableView(QTableView):
maxRowsInViewport = 0
vScrollBar = None
curSelection = None
trackingCol = 0

def __init__(self, parent):
QTableView.__init__(self, parent)
self.mousePressed = False

#eventFilter to catch key up/down events and wheel events
self.installEventFilter(self)
self.verticalHeader().setVisible(True)
self.horizontalHeader().setDefaultAlignment(Qt.AlignCenter)
self.horizontalHeader().setStretchLastSection(True)
#the built-in vertical scrollBar of this view is always off
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.installEventFilter(self)

def setVerticalScrollBar(self, vScrollBar):
self.vScrollBar = vScrollBar
Expand All @@ -217,26 +224,64 @@ def setVerticalScrollBar(self, vScrollBar):
def setModel(self, model):
super().setModel(model)
model.rowCountChanged.connect(self.onRowCountChanged)
model.rowsInserted.connect(self.onRowsInsertedOrRemoved)
model.rowsRemoved.connect(self.onRowsInsertedOrRemoved)
model.beginViewPortRefresh.connect(self.onBeginViewportRefresh)
model.endViewPortRefresh.connect(self.onEndViewportRefresh)
self.horizontalHeader().sortIndicatorChanged.disconnect()
self.setSortingEnabled(False)

def setTrackingColumn(self, col):
"""column used to track a selected row while scrolling"""
self.trackingCol = col

def clear(self):
pass

def refresh(self):
self.calculateRowsInViewport()
self.model().setRowCount(min(self.maxRowsInViewport, self.model().totalRowCount))
self.model().refreshViewport(self.vScrollBar.value(), self.maxRowsInViewport, force=True)

def forceViewRefresh(self):
return (self.vScrollBar.minimum() == self.vScrollBar.value() or self.vScrollBar.maximum() == self.vScrollBar.value())

def calculateRowsInViewport(self):
rowHeight = self.verticalHeader().defaultSectionSize()
#columnSize = self.horizontalHeader().defaultSectionSize()
# we don't want partial-height rows in viewport, hence .floor()
self.maxRowsInViewport = math.floor(self.viewport().height() / rowHeight)+1

def currentChanged(self, cur, prev):
#super().currentChanged(cur, prev)
if not self.mousePressed or prev.row() == cur.row():
return
maxVal = self.maxRowsInViewport-1
if cur.row() >= maxVal or prev.row() >= maxVal:
self.vScrollBar.setValue(self.vScrollBar.value() + 1)
elif cur.row() == 0:
self.vScrollBar.setValue(max(0, self.vScrollBar.value() - 1))

def mouseReleaseEvent(self, event):
super().mouseReleaseEvent(event)
self.mousePressed = False

# save the selected index, to preserve selection when moving around.
def mousePressEvent(self, event):
# we need to call upper class to paint selections properly
super().mousePressEvent(event)
if event.button() != Qt.LeftButton:
return
self.mousePressed = True

item = self.indexAt(event.pos())
clickedItem = self.model().index(item.row(), self.trackingCol)
if clickedItem.data() == None:
return

if item == None and self.curSelection == None:
return
elif item != None and self.curSelection == None:
# force selecting the row below
self.curSelection = ""
clickedItem = self.model().index(item.row(), 0)
if clickedItem == None:
return

Expand All @@ -252,23 +297,15 @@ def mousePressEvent(self, event):
flags
)

# model().rowCount() is always <= self.maxRowsInViewport
# stretch the bottom row; we don't want partial-height rows at the bottom
# this will only trigger if rowCount value was changed
def onRowsInsertedOrRemoved(self, parent, start, end):
if self.model().rowCount() == self.maxRowsInViewport:
self.verticalHeader().setStretchLastSection(True)
else:
self.verticalHeader().setStretchLastSection(False)

def onBeginViewportRefresh(self):
# if the selected row due to scrolling up/down doesn't match with the
# saved index, deselect the row, because the saved index is out of the
# view.
index = self.selectionModel().selectedRows(0)
if len(index) > 0:
if index[0].data() != self.curSelection:
self.selectionModel().clear()
index = self.selectionModel().selectedRows(self.trackingCol)
if len(index) == 0:
return
if index[0].data() != self.curSelection:
self.selectionModel().clear()

def onEndViewportRefresh(self):
self._selectSavedIndex()
Expand All @@ -278,21 +315,6 @@ def resizeEvent(self, event):
#refresh the viewport data based on new geometry
self.refresh()

def refresh(self):
self.calculateRowsInViewport()
self.model().setRowCount(min(self.maxRowsInViewport, self.model().totalRowCount))
self.model().refreshViewport(self.vScrollBar.value(), self.maxRowsInViewport, force=True)


def forceViewRefresh(self):
return (self.vScrollBar.minimum() == self.vScrollBar.value() or self.vScrollBar.maximum() == self.vScrollBar.value())

def calculateRowsInViewport(self):
rowHeight = self.verticalHeader().defaultSectionSize()
columnSize = self.horizontalHeader().defaultSectionSize()
# we don't want partial-height rows in viewport, hence .floor()
self.maxRowsInViewport = math.floor(self.viewport().height() / rowHeight)+1

def onRowCountChanged(self):
totalCount = self.model().totalRowCount
self.vScrollBar.setVisible(True if totalCount > self.maxRowsInViewport else False)
Expand Down Expand Up @@ -337,34 +359,34 @@ def selectItem(self, _data, _column):
)

def _selectSavedIndex(self):
if self.curSelection != None:
items = self.model().findItems(self.curSelection)
if len(items) > 0:
self.selectionModel().setCurrentIndex(
items[0].index(),
QItemSelectionModel.Rows | QItemSelectionModel.SelectCurrent
)
if self.curSelection == None or self.mousePressed:
return

items = self.model().findItems(self.curSelection, column=self.trackingCol)
if len(items) > 0:
self.selectionModel().setCurrentIndex(
items[0].index(),
QItemSelectionModel.Rows | QItemSelectionModel.SelectCurrent
)

def _selectLastRow(self):
if self.curSelection != None:
return
internalId = self.getCurrentIndex()
self.selectionModel().setCurrentIndex(
self.model().createIndex(self.maxRowsInViewport-2, 0, internalId),
self.model().createIndex(self.maxRowsInViewport-2, self.trackingCol, internalId),
QItemSelectionModel.Rows | QItemSelectionModel.SelectCurrent
)

def _selectRow(self, pos):
internalId = self.getCurrentIndex()
self.selectionModel().setCurrentIndex(
self.model().createIndex(pos, 1, internalId),
self.model().createIndex(pos, self.trackingCol, internalId),
QItemSelectionModel.Rows | QItemSelectionModel.SelectCurrent
)

def onScrollbarValueChanged(self, vSBNewValue):
self.onBeginViewportRefresh()
self.model().refreshViewport(vSBNewValue, self.maxRowsInViewport, force=True)
self.onEndViewportRefresh()

def onKeyUp(self):
self.curSelection = self.selectionModel().currentIndex().data()
Expand All @@ -373,9 +395,16 @@ def onKeyUp(self):

def onKeyDown(self):
self.curSelection = self.selectionModel().currentIndex().data()
if self.selectionModel().currentIndex().row() >= self.maxRowsInViewport-2:
self.vScrollBar.setValue(self.vScrollBar.value() + 1)
if self.curSelection == None:
self._selectLastRow()
return

curRow = self.selectionModel().currentIndex().row()
if curRow >= self.maxRowsInViewport-2:
self.onKeyPageDown()
self._selectRow(0)
else:
self._selectRow(curRow)

def onKeyHome(self):
self.vScrollBar.setValue(0)
Expand All @@ -384,6 +413,7 @@ def onKeyHome(self):
def onKeyEnd(self):
self.vScrollBar.setValue(self.vScrollBar.maximum())
self.selectionModel().clear()
self._selectLastRow()

def onKeyPageUp(self):
newValue = max(0, self.vScrollBar.value() - self.maxRowsInViewport)
Expand All @@ -394,13 +424,7 @@ def onKeyPageDown(self):
return

newValue = self.vScrollBar.value() + (self.maxRowsInViewport-2)
if newValue >= self.model().rowCount():
self._selectLastRow()
return

if newValue < self.model().rowCount():
self.vScrollBar.setValue(newValue)
self._selectRow(0)
self.vScrollBar.setValue(newValue)

def eventFilter(self, obj, event):
if event.type() == QEvent.KeyPress:
Expand Down
35 changes: 23 additions & 12 deletions ui/opensnitch/dialogs/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ class StatsDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]):
"rule as Rule",
"group_by": LAST_GROUP_BY,
"last_order_by": "1",
"last_order_to": 1
"last_order_to": 1,
"tracking_column:": COL_TIME
},
TAB_NODES: {
"name": "nodes",
Expand All @@ -168,7 +169,8 @@ class StatsDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]):
"version as Version",
"header_labels": [],
"last_order_by": "1",
"last_order_to": 1
"last_order_to": 1,
"tracking_column:": COL_TIME
},
TAB_RULES: {
"name": "rules",
Expand All @@ -189,7 +191,8 @@ class StatsDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]):
"created as Created",
"header_labels": [],
"last_order_by": "2",
"last_order_to": 0
"last_order_to": 0,
"tracking_column:": COL_R_NAME
},
TAB_FIREWALL: {
"name": "firewall",
Expand All @@ -203,7 +206,8 @@ class StatsDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]):
"display_fields": "*",
"header_labels": [],
"last_order_by": "2",
"last_order_to": 0
"last_order_to": 0,
"tracking_column:": COL_TIME
},
TAB_HOSTS: {
"name": "hosts",
Expand All @@ -217,7 +221,8 @@ class StatsDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]):
"display_fields": "*",
"header_labels": [],
"last_order_by": "2",
"last_order_to": 1
"last_order_to": 1,
"tracking_column:": COL_TIME
},
TAB_PROCS: {
"name": "procs",
Expand All @@ -231,7 +236,8 @@ class StatsDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]):
"display_fields": "*",
"header_labels": [],
"last_order_by": "2",
"last_order_to": 1
"last_order_to": 1,
"tracking_column:": COL_TIME
},
TAB_ADDRS: {
"name": "addrs",
Expand All @@ -245,7 +251,8 @@ class StatsDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]):
"display_fields": "*",
"header_labels": [],
"last_order_by": "2",
"last_order_to": 1
"last_order_to": 1,
"tracking_column:": COL_TIME
},
TAB_PORTS: {
"name": "ports",
Expand All @@ -259,7 +266,8 @@ class StatsDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]):
"display_fields": "*",
"header_labels": [],
"last_order_by": "2",
"last_order_to": 1
"last_order_to": 1,
"tracking_column:": COL_TIME
},
TAB_USERS: {
"name": "users",
Expand All @@ -273,7 +281,8 @@ class StatsDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]):
"display_fields": "*",
"header_labels": [],
"last_order_by": "2",
"last_order_to": 1
"last_order_to": 1,
"tracking_column:": COL_TIME
}
}

Expand Down Expand Up @@ -479,7 +488,8 @@ def __init__(self, parent=None, address=None, db=None, dbname="db", appicon=None
verticalScrollBar=self.rulesScrollBar,
delegate=self.TABLES[self.TAB_RULES]['delegate'],
order_by="2",
sort_direction=self.SORT_ORDER[0])
sort_direction=self.SORT_ORDER[0],
tracking_column=self.COL_R_NAME)
self.TABLES[self.TAB_FIREWALL]['view'] = self._setup_table(QtWidgets.QTableView,
self.fwTable, "firewall",
model=FirewallTableModel("firewall"),
Expand Down Expand Up @@ -2654,14 +2664,15 @@ def _on_menu_export_csv_clicked(self, triggered):
values.append(table.model().index(row, col).data())
w.writerow(values)

def _setup_table(self, widget, tableWidget, table_name, fields="*", group_by="", order_by="2", sort_direction=SORT_ORDER[1], limit="", resize_cols=(), model=None, delegate=None, verticalScrollBar=None):
def _setup_table(self, widget, tableWidget, table_name, fields="*", group_by="", order_by="2", sort_direction=SORT_ORDER[1], limit="", resize_cols=(), model=None, delegate=None, verticalScrollBar=None, tracking_column=COL_TIME):
tableWidget.setSortingEnabled(True)
if model == None:
model = self._db.get_new_qsql_model()
if verticalScrollBar != None:
tableWidget.setVerticalScrollBar(verticalScrollBar)
tableWidget.verticalScrollBar().sliderPressed.connect(self._cb_scrollbar_pressed)
tableWidget.verticalScrollBar().sliderReleased.connect(self._cb_scrollbar_released)
tableWidget.setTrackingColumn(tracking_column)

self.setQuery(model, "SELECT " + fields + " FROM " + table_name + group_by + " ORDER BY " + order_by + " " + sort_direction + limit)
tableWidget.setModel(model)
Expand Down Expand Up @@ -2761,7 +2772,7 @@ def setQuery(self, model, q):
print("setQuery() error: ", model.lastError().text())

if self.tabWidget.currentIndex() != self.TAB_MAIN:
self.labelRowsCount.setText("{0}".format(model.rowCount()))
self.labelRowsCount.setText("{0}".format(model.totalRowCount))
else:
self.labelRowsCount.setText("")
except Exception as e:
Expand Down

0 comments on commit 174c63c

Please sign in to comment.