From 420ccdeaceb31696830fee9477a221cb4e834a2b Mon Sep 17 00:00:00 2001
From: Bruce Allen <bdallen@nps.edu>
Date: Tue, 12 Mar 2024 13:36:02 -0700
Subject: [PATCH] add graph list table

---
 python/graph_list_filter_manager.py  |  3 +-
 python/graph_list_selection_model.py |  1 -
 python/graph_list_sort_manager.py    | 49 ++++++++++++++++----------
 python/graph_list_table_dialog.py    |  6 ++--
 python/graph_list_table_model.py     | 52 ++++++++++++++++++----------
 python/graph_list_view.py            | 22 +++++-------
 python/say_info.py                   | 46 ++++++++++++++++++++++++
 7 files changed, 121 insertions(+), 58 deletions(-)
 create mode 100644 python/say_info.py

diff --git a/python/graph_list_filter_manager.py b/python/graph_list_filter_manager.py
index bce5bf3..efa6480 100644
--- a/python/graph_list_filter_manager.py
+++ b/python/graph_list_filter_manager.py
@@ -15,8 +15,7 @@ class GraphListFilterManager(QObject):
 
     def __init__(self, gui_manager):
         super().__init__()
-        self.graph_list_proxy_model = gui_manager.graph_list_view \
-                                               .graph_list_proxy_model
+        self.graph_list_proxy_model = gui_manager.graph_list_proxy_model
 
         self.filter_event_dialog_wrapper = FilterEventDialogWrapper(
                           gui_manager.w, gui_manager.graphs_manager,
diff --git a/python/graph_list_selection_model.py b/python/graph_list_selection_model.py
index 7d677e5..292da31 100644
--- a/python/graph_list_selection_model.py
+++ b/python/graph_list_selection_model.py
@@ -5,7 +5,6 @@ from PySide6.QtCore import Qt
 from PySide6.QtCore import QModelIndex
 from PySide6.QtCore import QItemSelectionModel
 from PySide6.QtWidgets import QAbstractItemView, QTableView, QHeaderView
-from graph_list_table_model import COLUMN_COUNT
 from graph_list_sort_filter_proxy_model import GraphListSortFilterProxyModel
 from preferences import preferences
 from graph_item import GraphItem
diff --git a/python/graph_list_sort_manager.py b/python/graph_list_sort_manager.py
index e4850ef..92d8c44 100644
--- a/python/graph_list_sort_manager.py
+++ b/python/graph_list_sort_manager.py
@@ -20,7 +20,8 @@ class GraphListSortManager(QObject):
     def __init__(self, gui_manager):
         super().__init__()
         self.graph_list_proxy_model = \
-                   gui_manager.graph_list_view.graph_list_proxy_model
+                   gui_manager.graph_list_proxy_model
+        self.graph_list_table_dialog = gui_manager.graph_list_table_dialog
 
         # connect to reset selections on graphs loaded event
         gui_manager.graphs_manager.signal_graphs_loaded.connect(
@@ -63,32 +64,44 @@ class GraphListSortManager(QObject):
                                      self.sort_menu, "&Marked", MARK_COLUMN))
 
         # size
-        self.sort_menu_size = self.sort_menu.addMenu("&Size")
-        self.sort_menu_size.addAction(self._make_ascending_sort_action(
-                              self.sort_menu_size, "Ascending", SIZE_COLUMN))
-        self.sort_menu_size.addAction(self._make_descending_sort_action(
-                              self.sort_menu_size, "Descending", SIZE_COLUMN))
+        sort_menu_size = self.sort_menu.addMenu("&Size")
+        sort_menu_size.addAction(self._make_ascending_sort_action(
+                              sort_menu_size, "Ascending", SIZE_COLUMN))
+        sort_menu_size.addAction(self._make_descending_sort_action(
+                              sort_menu_size, "Descending", SIZE_COLUMN))
 
         # probability
-        self.sort_menu_size = self.sort_menu.addMenu("&Probability")
-        self.sort_menu_size.addAction(self._make_ascending_sort_action(
-                       self.sort_menu_size, "Ascending", PROBABILITY_COLUMN))
-        self.sort_menu_size.addAction(self._make_descending_sort_action(
-                       self.sort_menu_size, "Descending", PROBABILITY_COLUMN))
+        sort_menu_probability = self.sort_menu.addMenu("&Probability")
+        sort_menu_probability.addAction(self._make_ascending_sort_action(
+                       sort_menu_probability, "Ascending", PROBABILITY_COLUMN))
+        sort_menu_probability.addAction(self._make_descending_sort_action(
+                       sort_menu_probability, "Descending", PROBABILITY_COLUMN))
 
         # trace index
-        self.sort_menu_size = self.sort_menu.addMenu("&Trace index")
-        self.sort_menu_size.addAction(self._make_ascending_sort_action(
-                       self.sort_menu_size, "Ascending", TRACE_INDEX_COLUMN))
-        self.sort_menu_size.addAction(self._make_descending_sort_action(
-                       self.sort_menu_size, "Descending", TRACE_INDEX_COLUMN))
-
-        # button
+        sort_menu_trace_index = self.sort_menu.addMenu("&Trace index")
+        sort_menu_trace_index.addAction(self._make_ascending_sort_action(
+                       sort_menu_trace_index, "Ascending", TRACE_INDEX_COLUMN))
+        sort_menu_trace_index.addAction(self._make_descending_sort_action(
+                       sort_menu_trace_index, "Descending", TRACE_INDEX_COLUMN))
+
+        # sort by attribute
+        action_sort_by_attribute = QAction("Sort by &attribute...",
+                                           self.sort_menu)
+        action_sort_by_attribute.setToolTip("Sort traces by attribute value")
+        action_sort_by_attribute.triggered.connect(self._sort_by_attribute)
+        self.sort_menu.addSeparator()
+        self.sort_menu.addAction(action_sort_by_attribute)
+
+        # sort menu button
         self.sort_menu_button = mp_menu_button(self.sort_menu,
                                                QIcon(":/icons/sort_ascending"),
                                                "Sort",
                                                "Sort the events list")
 
+    @Slot()
+    def _sort_by_attribute(self):
+        self.graph_list_table_dialog.show()
+        
     @Slot()
     def _reset_selections(self):
         self.sort_group.actions()[0].setChecked(True)
diff --git a/python/graph_list_table_dialog.py b/python/graph_list_table_dialog.py
index c4260a4..8b6a97f 100644
--- a/python/graph_list_table_dialog.py
+++ b/python/graph_list_table_dialog.py
@@ -21,7 +21,7 @@ class GraphListTableDialog(QDialog):
         self.setFixedSize(self.width(), self.height())
         self.setWindowFlags(self.windowFlags() | Qt.Tool)
         self.setAttribute(Qt.WA_MacAlwaysShowToolWindow)
-        self.setWindowTitle("Sort traces by attribute")
+        self.setWindowTitle("Sort Traces by Attribute")
 
         # layout
         self.layout = QVBoxLayout(self)
@@ -37,6 +37,7 @@ class GraphListTableDialog(QDialog):
                          QAbstractItemView.SelectionMode.SingleSelection)
         self.table.setAlternatingRowColors(True)
         self.table.horizontalHeader().setStretchLastSection(True)
+        self.table.horizontalHeader().setDefaultAlignment(Qt.AlignLeft)
         self.table.hideColumn(GRAPH_COLUMN)
         self.layout.addWidget(self.table)
 
@@ -54,9 +55,6 @@ class GraphListTableDialog(QDialog):
         # slot
         graph_list_selection_model.selectionChanged.connect(self._select_row)
 
-#zz
-#        self.show()
-
     @Slot(QItemSelection, QItemSelection)
     def _select_row(self, selected, deselected):
         # get a lsit of the same row index for each column else an empty list
diff --git a/python/graph_list_table_model.py b/python/graph_list_table_model.py
index 6a47421..a78fb0a 100644
--- a/python/graph_list_table_model.py
+++ b/python/graph_list_table_model.py
@@ -3,6 +3,7 @@ from PySide6.QtCore import Slot # for signal/slot support
 from PySide6.QtCore import Qt, QAbstractTableModel, QModelIndex
 from graph_item import GraphItem
 from settings import settings
+from say_info import say_column_names, say_column_map, say_info
 from mp_logger import log_to_statusbar
 
 # graph list model column constants
@@ -11,9 +12,8 @@ TRACE_INDEX_COLUMN = 1 # values start at 1
 MARK_COLUMN = 2 # "M" or ""
 PROBABILITY_COLUMN = 3 # float 0.0 to 1.0
 SIZE_COLUMN = 4 # int, see implementation
-COLUMN_COUNT = 5 # number of columns
 
-_HEADERS = ["Graph", "index", "Mark", "Type 1 P", "Size"]
+_HARDCODED_HEADERS = ["Graph", "index", "Mark", "Type 1 P", "Size"]
 
 class GraphListTableModel(QAbstractTableModel):
     """Provides the graph list model for graph list accessors, consisting of
@@ -35,20 +35,25 @@ class GraphListTableModel(QAbstractTableModel):
     """
 
     def __init__(self):
+        self.headers = list()
         super().__init__()
 
-        graphs = list()
+        self.graphs = list()
+        self.headers = list()
+        self.say_map = say_column_map(self.graphs, len(_HARDCODED_HEADERS))
 
     def set_graph_list(self, graphs):
         self.beginResetModel() # because self.graphs change
         self.graphs = graphs
+        self.headers = _HARDCODED_HEADERS.copy()
+        self.headers.extend(say_column_names(graphs))
         self.endResetModel()
 
     def headerData(self, section, orientation,
                                    role=Qt.ItemDataRole.DisplayRole):
         if role == Qt.ItemDataRole.DisplayRole \
                             and orientation == Qt.Orientation.Horizontal:
-            return _HEADERS[section]
+            return self.headers[section]
         else:
             return None # QVariant()
 
@@ -57,7 +62,7 @@ class GraphListTableModel(QAbstractTableModel):
         return len(self.graphs)
 
     def columnCount(self, parent=QModelIndex()):
-        return COLUMN_COUNT
+        return len(self.headers)
 
     def data(self, model_index, role=Qt.ItemDataRole.DisplayRole):
 
@@ -66,28 +71,37 @@ class GraphListTableModel(QAbstractTableModel):
             row = model_index.row()
             column = model_index.column()
             gry_graph = self.graphs[row].gry_graph
+
+            # provide values for hardcoded columns
             if column == GRAPH_COLUMN:
                 # graph_list_view_delegate renders this
                 return None # QVariant()
             if column == TRACE_INDEX_COLUMN:
                 return row
+            if row == 0:
+                # these columns are for traces only
+                return "NA"
             if column == MARK_COLUMN:
-                if "trace" in gry_graph:
-                    return gry_graph["trace"]["mark"]
-                else:
-                    return "U"
+                return gry_graph["trace"]["mark"]
             if column == PROBABILITY_COLUMN:
-                if "trace" in gry_graph:
-                    return gry_graph["trace"]["probability"]
-                else:
-                    return 0
+                return gry_graph["trace"]["probability"]
             if column == SIZE_COLUMN:
-                if "trace" in gry_graph:
-                    gry_trace = gry_graph["trace"]
-                    return len(gry_trace["nodes"]) + len(gry_trace["edges"])
-                else:
-                    return 0
-            raise RuntimeError("bad %d"%column)
+                gry_trace = gry_graph["trace"]
+                return len(gry_trace["nodes"]) + len(gry_trace["edges"])
+            if column >= len(self.headers):
+                raise RuntimeError("bad %d"%column)
+
+            # provide values for columns dynamically created from SAY nodes
+            header = self.headers[column]
+            says = say_info(gry_graph)
+            if header in says:
+                # good, says matches a header so return its numeric value
+                return says[header]
+            else:
+                # return warning when say node text does not match a header
+                return "Unexpected %s"%header
+
         else:
+            # ignore this ItemDataRole
             return None # QVariant()
 
diff --git a/python/graph_list_view.py b/python/graph_list_view.py
index 812edb9..2be8107 100644
--- a/python/graph_list_view.py
+++ b/python/graph_list_view.py
@@ -5,7 +5,6 @@ from PySide6.QtCore import Qt
 from PySide6.QtCore import QModelIndex
 from PySide6.QtCore import QItemSelectionModel
 from PySide6.QtWidgets import QAbstractItemView, QTableView, QHeaderView
-from graph_list_table_model import COLUMN_COUNT
 from graph_list_sort_filter_proxy_model import GraphListSortFilterProxyModel
 from graph_list_view_delegate import GraphListViewDelegate
 from preferences import preferences
@@ -24,15 +23,10 @@ class GraphListView(QTableView):
                        signal_preferences_changed):
         super().__init__(main_splitter)
 
-        self.graph_list_proxy_model = graph_list_proxy_model
-
         # the data model
-        self.setModel(self.graph_list_proxy_model)
+        self.setModel(graph_list_proxy_model)
 
         # table appearance
-        for i in range(1, COLUMN_COUNT):
-            self.setColumnHidden(i, True)
-        self.setColumnWidth(0, 20) # otherwise minimum is 100
         self.horizontalHeader().hide()
         self.verticalHeader().hide()
         self.setShowGrid(False)
@@ -48,21 +42,21 @@ class GraphListView(QTableView):
                                               navigation_column_width_manager)
         self.setItemDelegate(view_delegate)
 
+        # connect model reset
+        graph_list_proxy_model.modelReset.connect(self._hide_unused_columns)
+
         # connect width changed
         navigation_column_width_manager.signal_navigation_column_width_changed\
                                       .connect(self._changed_width)
 
-
-        # connect move scrollbar to top when graph is reloaded
-#zz        graphs_manager.signal_graphs_loaded.connect(self._reset_scrollbar)
-        graph_list_proxy_model.sourceModelChanged.connect(self._reset_scrollbar)
-
         # connect scroll mode changed
         signal_preferences_changed.connect(self._set_scroll_mode)
 
     @Slot()
-    def _reset_scrollbar(self):
-        self.verticalScrollBar().setValue(0)
+    def _hide_unused_columns(self):
+        # hide all but the first column so the first column stretches
+        for i in range(1, self.model().columnCount()):
+            self.setColumnHidden(i, True)
 
     # width changed
     @Slot(int)
diff --git a/python/say_info.py b/python/say_info.py
new file mode 100644
index 0000000..43cc4cb
--- /dev/null
+++ b/python/say_info.py
@@ -0,0 +1,46 @@
+import re
+from collections import defaultdict
+"""Extract SAY titles and numbers from SAY trace nodes."""
+
+_pattern = re.compile(r"\s+[0-9.]+\s+")
+
+# Return {say text, [numbers]} where:
+#   * say_text is say text with index prepended and numbers removed
+#   * and numbers is a list of zero or more numbers.
+# Say nodes are type="T"."""
+def say_info(gry_graph):
+    if not "trace" in gry_graph:
+        raise RuntimeError("bad")
+
+    says = defaultdict(list)
+    for gry_node in gry_graph["trace"]["nodes"]:
+        if gry_node["type"] == "T":
+            label = gry_node["label"]
+            key = _pattern.sub(" ", label)
+            numbers = _pattern.findall(label)
+            for i, number in enumerate(numbers, 1):
+                try:
+                    says["%i: %s"%(i, key)] = float(number)
+                except Exception as e:
+                    # multiple decimal places can thwart _pattern so manage
+                    # invalid sortable numbers as text
+                    print("say_info.say_info: invalid number: %s"%number)
+                    says["%i: %s"%(i, key)] = number
+    return says
+
+# Return a list of "say text" column names.
+def say_column_names(graphs):
+    if len(graphs) >= 2:
+        says = say_info(graphs[1].gry_graph)
+        return sorted(says.keys())
+    else:
+        return list()
+
+# Return a dict of {key="say text", value=column index}
+def say_column_map(graphs, start_index):
+    column_names = say_column_names(graphs)
+    column_map = dict()
+    for i, column_name in enumerate(column_names, start_index):
+         column_map[column_name] = i
+    return column_map
+
-- 
GitLab