diff --git a/activity_browser/app/bwutils/commontasks.py b/activity_browser/app/bwutils/commontasks.py index 2acc9e589..3f88791d1 100644 --- a/activity_browser/app/bwutils/commontasks.py +++ b/activity_browser/app/bwutils/commontasks.py @@ -109,6 +109,7 @@ def is_technosphere_db(db_name): "Location": "location", "Database": "database", "Uncertainty": "uncertainty type", + "Formula": "formula", } bw_keys_to_AB_names = {v: k for k, v in AB_names_to_bw_keys.items()} diff --git a/activity_browser/app/controller.py b/activity_browser/app/controller.py index e2569dd5b..7653a517e 100644 --- a/activity_browser/app/controller.py +++ b/activity_browser/app/controller.py @@ -4,7 +4,7 @@ import uuid import brightway2 as bw -from PyQt5 import QtWidgets +from PyQt5 import QtCore, QtWidgets from bw2data.backends.peewee import Exchange, sqlite3_lci_db from bw2data.project import ProjectDataset, SubstitutableDatabase @@ -61,6 +61,7 @@ def connect_signals(self): signals.exchanges_deleted.connect(self.delete_exchanges) signals.exchanges_add.connect(self.add_exchanges) signals.exchange_amount_modified.connect(self.modify_exchange_amount) + signals.exchange_modified.connect(self.modify_exchange) # Calculation Setups signals.new_calculation_setup.connect(self.new_calculation_setup) signals.rename_calculation_setup.connect(self.rename_calculation_setup) @@ -475,3 +476,25 @@ def modify_exchange_amount(self, exchange, value): exchange['amount'] = value exchange.save() signals.database_changed.emit(exchange['output'][0]) + + @staticmethod + @QtCore.pyqtSlot(object, str, object) + def modify_exchange(exchange, field, value): + # The formula field needs special handling. + if field == "formula": + if field in exchange and value == "": + # Remove formula entirely. + del exchange[field] + if "original_amount" in exchange: + # Restore the original amount, if possible + exchange["amount"] = exchange["original_amount"] + del exchange["original_amount"] + if value: + # At least set the formula, possibly also store the amount + if field not in exchange: + exchange["original_amount"] = exchange["amount"] + exchange[field] = value + else: + exchange[field] = value + exchange.save() + signals.database_changed.emit(exchange['output'][0]) diff --git a/activity_browser/app/signals.py b/activity_browser/app/signals.py index 25642c266..a986af0dc 100644 --- a/activity_browser/app/signals.py +++ b/activity_browser/app/signals.py @@ -55,6 +55,7 @@ class Signals(QtCore.QObject): exchanges_deleted = QtCore.pyqtSignal(list) exchanges_add = QtCore.pyqtSignal(list, tuple) exchange_amount_modified = QtCore.pyqtSignal(object, float) + exchange_modified = QtCore.pyqtSignal(object, str, object) # Calculation Setups new_calculation_setup = QtCore.pyqtSignal() diff --git a/activity_browser/app/ui/tables/__init__.py b/activity_browser/app/ui/tables/__init__.py index 58fe964db..db2aea253 100644 --- a/activity_browser/app/ui/tables/__init__.py +++ b/activity_browser/app/ui/tables/__init__.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- -from .activity import ExchangeTable +from .activity import (BiosphereExchangeTable, DownstreamExchangeTable, + ProductExchangeTable, TechnosphereExchangeTable) from .history import ActivitiesHistoryTable from .impact_categories import CFTable, MethodsTable from .inventory import ActivitiesBiosphereTable, DatabasesTable diff --git a/activity_browser/app/ui/tables/activity.py b/activity_browser/app/ui/tables/activity.py index aa035747f..955e519df 100644 --- a/activity_browser/app/ui/tables/activity.py +++ b/activity_browser/app/ui/tables/activity.py @@ -1,281 +1,315 @@ # -*- coding: utf-8 -*- +import pandas as pd from PyQt5 import QtCore, QtGui, QtWidgets -from .inventory import ActivitiesBiosphereTable -from .table import ABTableWidget, ABTableItem -from ..icons import icons +from .delegates import FloatDelegate, StringDelegate, ViewOnlyDelegate +from .views import ABDataFrameEdit, dataframe_sync +from ..icons import qicons from ...signals import signals -from ...bwutils.commontasks import bw_keys_to_AB_names - - -class ExchangeTable(ABTableWidget): - """ All tables shown in the ActivityTab are instances of this class (inc. non-exchange types) - Differing Views and Behaviours of tables are handled based on their tableType - todo(?): possibly preferable to subclass for distinct table functionality, rather than conditionals in one class - The tables include functionalities: drag-drop, context menus, in-line value editing - The read-only/editable status of tables is handled in ActivityTab.set_exchange_tables_read_only() - Instantiated with headers but without row-data - Then set_queryset() called from ActivityTab with params - set_queryset calls Sync() to fill and format table data items - todo(?): the variables which are initiated as defaults then later populated in set_queryset() can be passed at init - Therefore this class could be simplified by removing self.qs,upstream,database defaults etc. - todo(?): column names determined by properties included in the activity and exchange? - this would mean less hard-coding of column titles and behaviour. But rather dynamic generation - and flexible editing based on assumptions about data types etc. - """ - COLUMN_LABELS = { # {exchangeTableName: headers} - "products": ["Amount", "Unit", "Product"], #, "Location", "Uncertainty"], - # technosphere inputs & Downstream product-consuming activities included as "technosphere" - # todo(?) should the table functionality for downstream activities really be identical to technosphere inputs? - "technosphere": ["Amount", "Unit", "Product", "Activity", "Location", "Database", "Uncertainty", "Formula"], - "biosphere": ["Amount", "Unit", "Flow Name", "Compartments", "Database", "Uncertainty"], - } - def __init__(self, parent=None, tableType=None): - super(ExchangeTable, self).__init__() +from ...bwutils.commontasks import AB_names_to_bw_keys + + +class BaseExchangeTable(ABDataFrameEdit): + COLUMNS = [] + # Signal used to correctly control `DetailsGroupBox` + updated = QtCore.pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) self.setDragEnabled(True) self.setAcceptDrops(False) - self.setSortingEnabled(True) - - self.tableType = tableType - self.column_labels = self.COLUMN_LABELS[self.tableType] - self.setColumnCount(len(self.column_labels)) - # default values, updated later in set_queryset() - self.qs, self.downstream, self.database = None, False, None - # ignore_changes set to True whilst sync() executes to prevent conflicts(?) - self.ignore_changes = False - self.setup_context_menu() - self.connect_signals() self.setSizePolicy(QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Maximum) ) - def setup_context_menu(self): - # todo: different table types require different context menu actions self.delete_exchange_action = QtWidgets.QAction( - QtGui.QIcon(icons.delete), "Delete exchange(s)", None + qicons.delete, "Delete exchange(s)", None ) - self.addAction(self.delete_exchange_action) - self.delete_exchange_action.triggered.connect(self.delete_exchanges) - - def connect_signals(self): - # todo: different table types require different signals connected - signals.database_changed.connect(self.update_when_database_has_changed) - self.cellChanged.connect(self.filter_change) - self.cellDoubleClicked.connect(self.handle_double_clicks) - - def delete_exchanges(self, event): - signals.exchanges_deleted.emit( - [x.exchange for x in self.selectedItems()] + self.remove_formula_action = QtWidgets.QAction( + qicons.delete, "Unset formula(s)", None ) - def dragEnterEvent(self, event): - acceptable = ( - ExchangeTable, - ActivitiesBiosphereTable, - ) - if isinstance(event.source(), acceptable): - event.accept() + self.downstream = False + self.key = None if not hasattr(parent, "key") else parent.key + self.exchanges = [] + self._connect_signals() + + def _connect_signals(self): + signals.database_changed.connect(lambda: self.sync()) + self.delete_exchange_action.triggered.connect(self.delete_exchanges) + self.remove_formula_action.triggered.connect(self.remove_formula) + + @dataframe_sync + def sync(self, exchanges=None): + """ Build the table using either new or stored exchanges iterable. + """ + if exchanges is not None: + self.exchanges = exchanges + self.dataframe = self.build_df() + + def build_df(self) -> pd.DataFrame: + """ Use the Exchanges Iterable to construct a dataframe. + + Make sure to store the Exchange object itself in the dataframe as well. + """ + df = pd.DataFrame([ + self.create_row(exchange=exc)[0] for exc in self.exchanges + ], columns=self.COLUMNS + ["exchange"]) + return df + + def create_row(self, exchange) -> (dict, object): + """ Take the given Exchange object and extract a number of attributes. + """ + adj_act = exchange.output if self.downstream else exchange.input + row = { + "Amount": exchange.get("amount"), + "Unit": adj_act.get("unit", "Unknown"), + "exchange": exchange, + } + return row, adj_act + + def get_key(self, proxy: QtCore.QModelIndex) -> tuple: + """ Get the activity key from the exchange. """ + index = self.get_source_index(proxy) + exchange = self.dataframe.iloc[index.row(), ]["exchange"] + return exchange.output if self.downstream else exchange.input + + @QtCore.pyqtSlot() + def delete_exchanges(self) -> None: + """ Remove all of the selected exchanges from the activity. + """ + indexes = [self.get_source_index(p) for p in self.selectedIndexes()] + rows = [index.row() for index in indexes] + exchanges = self.dataframe.iloc[rows, ]["exchange"].to_list() + signals.exchanges_deleted.emit(exchanges) + + def remove_formula(self) -> None: + """ Remove the formulas for all of the selected exchanges. + + This will also check if the exchange has `original_amount` and + attempt to overwrite the `amount` with that value after removing the + `formula` field. + + This can have the additional effect of removing the ActivityParameter + if it was set for the current activity and all formulas are gone. + """ + indexes = [self.get_source_index(p) for p in self.selectedIndexes()] + rows = [index.row() for index in indexes] + for exchange in self.dataframe.iloc[rows, ]["exchange"]: + signals.exchange_modified.emit(exchange, "formula", "") + + def contextMenuEvent(self, a0) -> None: + menu = QtWidgets.QMenu() + menu.addAction(self.delete_exchange_action) + menu.addAction(self.remove_formula_action) + menu.exec(a0.globalPos()) + + def dataChanged(self, topLeft, bottomRight, roles=None) -> None: + """ Override the slot which handles data changes in the model. + + Whenever data is changed, call an update to the relevant exchange + or activity. + + Four possible editable fields: + Amount, Unit, Product and Formula + """ + # A single cell was edited. + if topLeft == bottomRight: + index = self.get_source_index(topLeft) + field = AB_names_to_bw_keys[self.dataframe.columns[index.column()]] + exchange = self.dataframe.iloc[index.row(), ]["exchange"] + if field in {"amount", "formula"}: + if field == "amount": + value = float(topLeft.data()) + else: + value = str(topLeft.data()) if topLeft.data() is not None else "" + signals.exchange_modified.emit(exchange, field, value) + else: + value = str(topLeft.data()) + act_key = exchange.output.key + signals.activity_modified.emit(act_key, field, value) + else: + super().dataChanged(topLeft, bottomRight, roles) + + def dragMoveEvent(self, event) -> None: + """ For some reason, this method existing is required for allowing + dropEvent to occur _everywhere_ in the table. + """ + pass def dropEvent(self, event): source_table = event.source() - print('Dropevent from:', source_table) keys = [source_table.get_key(i) for i in source_table.selectedIndexes()] - signals.exchanges_add.emit(keys, self.qs._key) - - # items = event.source().selectedItems() - # if isinstance(items[0], ABTableItem): - # signals.exchanges_add.emit([x.key for x in items], self.qs._key) - # else: - # print(items) - # print(items.exchange) - # signals.exchanges_output_modified.emit( - # [x.exchange for x in items], self.qs._key - # ) event.accept() - - def update_when_database_has_changed(self, database): - if self.database == database: - self.sync() - - def filter_change(self, row, col): - try: - item = self.item(row, col) - if self.ignore_changes: # todo: check or remove - return - elif item.text() == item.previous: - return - else: - print("row:", row) - if col == 0: # expect number todo: improve substantially! - value = float(item.text()) - item.previous = item.text() - exchange = item.exchange - signals.exchange_amount_modified.emit(exchange, value) - else: # exepct string - fields = {1: "unit", 2: "reference product"} - print("here 2") - act = item.exchange.output.key - value = str(item.text()) - item.previous = item.text() - signals.activity_modified.emit(act, fields[col], value) - except ValueError: - print('You can only enter numbers here.') - item.setText(item.previous) - - def handle_double_clicks(self, row, col): - """ handles double-click events rather than clicks... rename? """ - item = self.item(row, col) - print("double-clicked on:", row, col, item.text()) - # double clicks ignored for these table types and item flags (until an 'exchange edit' interface is written) - if self.tableType == "products" or self.tableType == "biosphere" or (item.flags() & QtCore.Qt.ItemIsEditable): + signals.exchanges_add.emit(keys, self.key) + + def mouseDoubleClickEvent(self, e: QtGui.QMouseEvent) -> None: + """ Be very strict in how double click events work. + """ + proxy = self.indexAt(e.pos()) + delegate = self.itemDelegateForColumn(proxy.column()) + + # If the column we're clicking on is not view-only try and edit + if not isinstance(delegate, ViewOnlyDelegate): + self.doubleClicked.emit(proxy) + # But only edit if the editTriggers contain DoubleClicked + if self.editTriggers() & self.DoubleClicked: + self.edit(proxy) return - if hasattr(item, "exchange"): - # open the activity of the row which was double clicked in the table - if self.upstream: - key = item.exchange['output'] - else: - key = item.exchange['input'] - signals.open_activity_tab.emit(key) - signals.add_activity_to_history.emit(key) - - def set_queryset(self, database, qs, limit=100, downstream=False): - # todo(?): rename function: it calls sync() - which appears to do more than just setting the queryset - # todo: use table paging rather than a hard arbitrary 'limit'. Could also increase load speed - # .upstream() exposes the exchanges which consume this activity. - self.database, self.qs, self.upstream = database, qs, downstream - self.sync(limit) - - @ABTableWidget.decorated_sync - def sync(self, limit=100): - """ populates an exchange table view with data about the exchanges, bios flows, and adjacent activities """ - self.ignore_changes = True - self.setRowCount(min(len(self.qs), limit)) - self.setHorizontalHeaderLabels(self.column_labels) - - if self.upstream: - # ideally these should not be set in the data syncing function - # todo: refactor so that on initialisation, the 'upstream' state is known so state can be set there - self.setDragEnabled(False) - self.setAcceptDrops(False) - - # edit_flag is passed to table items which should be user-editable. - # Default flag for cells is uneditable - which still allows cell-selection/highlight - edit_flag = [QtCore.Qt.ItemIsEditable] - - # todo: add a setting which allows user to choose their preferred number formatting, for use in tables - # e.g. a choice between all standard form: {0:.3e} and current choice: {:.3g}. Or more flexibility - amount_format_string = "{:.3g}" - for row, exc in enumerate(self.qs): - # adj_act is not the open activity, but rather one of the activities connected adjacently via an exchange - # When open activity is upstream of the two... - # The adjacent activity we want to view is the output of the exchange which connects them. And vice versa - adj_act = exc.output if self.upstream else exc.input - if row == limit: - - break - - if self.tableType == "products": - # headers: "Amount", "Unit", "Product", "Location", "Uncertainty" - self.setItem(row, 0, ABTableItem( - amount_format_string.format(exc.get('amount')), exchange=exc, set_flags=edit_flag, color="amount")) - - self.setItem(row, 1, ABTableItem( - adj_act.get('unit', 'Unknown'), exchange=exc, set_flags=edit_flag, color="unit")) - - self.setItem(row, 2, ABTableItem( - # correct reference product name is stored in the exchange itself and not the activity - # adj_act.get('reference product') or adj_act.get("name") if self.upstream else - adj_act.get('reference product') or adj_act.get("name"), - exchange=exc, set_flags=edit_flag, color="reference product")) - - # self.setItem(row, 3, ABTableItem( - # # todo: remove? it makes no sense to show the (open) activity location... - # # showing exc locations (as now) makes sense. But they rarely have one... - # # I believe they usually implicitly inherit the location of the producing activity - # str(exc.get('location', '')), color="location")) - - # # todo: can both outputs and inputs of a process both have uncertainty data? - # self.setItem(row, 3, ABTableItem( - # str(exc.get("uncertainty type", "")))) - - elif self.tableType == "technosphere": - # headers: "Amount", "Unit", "Product", "Activity", "Location", "Database", "Uncertainty", "Formula" - - self.setItem(row, 0, ABTableItem( - amount_format_string.format(exc.get('amount')), exchange=exc, set_flags=edit_flag, color="amount")) - - self.setItem(row, 1, ABTableItem( - adj_act.get('unit', 'Unknown'), exchange=exc, color="unit")) - - self.setItem(row, 2, ABTableItem( # product - # if statement used to show different activities for products and downstream consumers tables - # reference product shown, and if absent, just the name of the activity or exchange... - # would this produce inconsistent/unclear behaviour for users? - adj_act.get('reference product') or adj_act.get("name") if self.upstream else - exc.get('reference product') or exc.get("name"), - exchange=exc, color="reference product")) - - self.setItem(row, 3, ABTableItem( # name of adjacent activity (up or downstream depending on table) - adj_act.get('name'), exchange=exc, color="name")) - - self.setItem(row, 4, ABTableItem( - str(adj_act.get('location', '')), exchange=exc, color="location")) + # No opening anything from the 'product' or 'biosphere' tables + if self.table_name in {"product", "biosphere"}: + return - self.setItem(row, 5, ABTableItem( - adj_act.get('database'), exchange=exc, color="database")) + # Grab the activity key from the exchange and open a tab + index = self.get_source_index(proxy) + row = self.dataframe.iloc[index.row(), ] + key = row["exchange"]["output"] if self.downstream else row["exchange"]["input"] + signals.open_activity_tab.emit(key) + signals.add_activity_to_history.emit(key) - self.setItem(row, 6, ABTableItem( - str(exc.get("uncertainty type", "")), exchange=exc,)) - self.setItem(row, 7, ABTableItem( - exc.get('formula', ''), exchange=exc,)) +class ProductExchangeTable(BaseExchangeTable): + COLUMNS = ["Amount", "Unit", "Product"] - elif self.tableType == "biosphere": - # headers: "Amount", "Unit", "Flow Name", "Compartments", "Database", "Uncertainty" - self.setItem(row, 0, ABTableItem( - amount_format_string.format(exc.get('amount')), exchange=exc, set_flags=edit_flag, color="amount")) + def __init__(self, parent=None): + super().__init__(parent) + self.setItemDelegateForColumn(0, FloatDelegate(self)) + self.setItemDelegateForColumn(1, StringDelegate(self)) + self.setItemDelegateForColumn(2, StringDelegate(self)) + self.setDragDropMode(QtWidgets.QTableView.DragDrop) + self.table_name = "product" + + def create_row(self, exchange) -> (dict, object): + row, adj_act = super().create_row(exchange) + row.update({ + "Product": adj_act.get("reference product") or adj_act.get("name"), + }) + return row, adj_act + + def _resize(self) -> None: + """ Ensure the `exchange` column is hidden whenever the table is shown. + """ + self.setColumnHidden(3, True) - self.setItem(row, 1, ABTableItem( - adj_act.get('unit', 'Unknown'), exchange=exc, color="unit")) + def dragEnterEvent(self, event): + """ Accept exchanges from a technosphere database table, and the + technosphere exchanges table. + """ + source = event.source() + if hasattr(source, "table_name") and source.table_name == "technosphere": + event.accept() + elif hasattr(source, "technosphere") and source.technosphere is True: + event.accept() - self.setItem(row, 2, ABTableItem( - adj_act.get('name'), exchange=exc, color="product")) - self.setItem(row, 3, ABTableItem( - " - ".join(adj_act.get('categories', [])), exchange=exc, color="categories")) +class TechnosphereExchangeTable(BaseExchangeTable): + COLUMNS = [ + "Amount", "Unit", "Product", "Activity", "Location", "Database", + "Uncertainty", "Formula" + ] - self.setItem(row, 4, ABTableItem( - adj_act.get('database'), exchange=exc, color="database")) + def __init__(self, parent=None): + super().__init__(parent) + self.setItemDelegateForColumn(0, FloatDelegate(self)) + self.setItemDelegateForColumn(1, ViewOnlyDelegate(self)) + self.setItemDelegateForColumn(2, ViewOnlyDelegate(self)) + self.setItemDelegateForColumn(3, ViewOnlyDelegate(self)) + self.setItemDelegateForColumn(4, ViewOnlyDelegate(self)) + self.setItemDelegateForColumn(5, ViewOnlyDelegate(self)) + self.setItemDelegateForColumn(6, ViewOnlyDelegate(self)) + self.setItemDelegateForColumn(7, StringDelegate(self)) + self.setDragDropMode(QtWidgets.QTableView.DragDrop) + self.table_name = "technosphere" + self.drag_model = True + + def create_row(self, exchange) -> (dict, object): + row, adj_act = super().create_row(exchange) + row.update({ + "Product": adj_act.get("reference product") or adj_act.get("name"), + "Activity": adj_act.get("name"), + "Location": adj_act.get("location", "Unknown"), + "Database": adj_act.get("database"), + "Uncertainty": adj_act.get("uncertainty type", 0), + "Formula": exchange.get("formula", ""), + }) + return row, adj_act + + def _resize(self) -> None: + """ Ensure the `exchange` column is hidden whenever the table is shown. + """ + self.setColumnHidden(8, True) - self.setItem(row, 5, ABTableItem( - str(exc.get("uncertainty type", "")), exchange=exc)) + def dragEnterEvent(self, event): + """ Accept exchanges from a technosphere database table, and the + downstream exchanges table. + """ + source = event.source() + if isinstance(source, DownstreamExchangeTable): + event.accept() + elif hasattr(source, "technosphere") and source.technosphere is True: + event.accept() - # todo: investigate BW: can flows have both a Formula and an Amount? Or mutually exclusive? - # what if they have both, and they contradict? Is this handled in BW - so AB doesn't need to worry? - # is the amount calculated and persisted separately to the formula? - # if not - optimal behaviour of this table is: show Formula instead of amount in 1st col when present? - self.setItem(row, 6, ABTableItem(exc.get('formula', ''), exchange=exc, )) - self.ignore_changes = False +class BiosphereExchangeTable(BaseExchangeTable): + COLUMNS = [ + "Amount", "Unit", "Flow Name", "Compartments", "Database", + "Uncertainty", "Formula" + ] -# start of a simplified way to handle these tables... + def __init__(self, parent=None): + super().__init__(parent) + self.setItemDelegateForColumn(0, FloatDelegate(self)) + self.setItemDelegateForColumn(1, ViewOnlyDelegate(self)) + self.setItemDelegateForColumn(2, ViewOnlyDelegate(self)) + self.setItemDelegateForColumn(3, ViewOnlyDelegate(self)) + self.setItemDelegateForColumn(4, ViewOnlyDelegate(self)) + self.setItemDelegateForColumn(5, ViewOnlyDelegate(self)) + self.setItemDelegateForColumn(6, StringDelegate(self)) + self.table_name = "biosphere" + self.setDragDropMode(QtWidgets.QTableView.DropOnly) + + def create_row(self, exchange) -> (dict, object): + row, adj_act = super().create_row(exchange) + row.update({ + "Flow Name": adj_act.get("name"), + "Compartments": " - ".join(adj_act.get('categories', [])), + "Database": adj_act.get("database"), + "Uncertainty": adj_act.get("uncertainty type", 0), + "Formula": exchange.get("formula"), + }) + return row, adj_act + + def _resize(self) -> None: + self.setColumnHidden(7, True) -class ExchangesTablePrototype(ABTableWidget): - amount_format_string = "{:.3g}" + def dragEnterEvent(self, event): + """ Only accept exchanges from a technosphere database table + """ + source = event.source() + if hasattr(source, "technosphere") and source.technosphere is False: + event.accept() +class DownstreamExchangeTable(TechnosphereExchangeTable): + """ Inherit from the `TechnosphereExchangeTable` as the downstream class is + very similar. + """ def __init__(self, parent=None): - super(ExchangesTablePrototype, self).__init__() - self.column_labels = [bw_keys_to_AB_names[val] for val in self.COLUMNS.values()] - self.setColumnCount(len(self.column_labels)) - self.setSizePolicy(QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Preferred, - QtWidgets.QSizePolicy.Maximum) - ) - - def set_queryset(self, database, qs, limit=100, upstream=False): - self.database, self.qs, self.upstream = database, qs, upstream - # print("Queryset:", self.database, self.qs, self.upstream) - self.sync(limit) + super().__init__(parent) + # Override the amount column to be a view-only delegate + self.setItemDelegateForColumn(0, ViewOnlyDelegate(self)) + self.downstream = True + self.table_name = "downstream" + self.drag_model = True + self.setDragDropMode(QtWidgets.QTableView.DragOnly) + + def _resize(self) -> None: + """ Next to `exchange`, also hide the `formula` column. + """ + self.setColumnHidden(7, True) + self.setColumnHidden(8, True) + + def contextMenuEvent(self, a0) -> None: + pass diff --git a/activity_browser/app/ui/tables/views.py b/activity_browser/app/ui/tables/views.py index 4c765814b..33298953b 100644 --- a/activity_browser/app/ui/tables/views.py +++ b/activity_browser/app/ui/tables/views.py @@ -2,8 +2,8 @@ import os from functools import wraps -from PyQt5.QtCore import (QAbstractTableModel, QSize, QSortFilterProxyModel, - Qt, pyqtSlot) +from PyQt5.QtCore import (QAbstractTableModel, QModelIndex, QSize, + QSortFilterProxyModel, Qt, pyqtSlot) from PyQt5.QtWidgets import QFileDialog, QTableView from activity_browser.app.settings import ab_settings @@ -43,8 +43,8 @@ class ABDataFrameView(QTableView): def __init__(self, parent=None): super().__init__(parent) - self.setVerticalScrollMode(1) - self.setHorizontalScrollMode(1) + self.setVerticalScrollMode(QTableView.ScrollPerPixel) + self.setHorizontalScrollMode(QTableView.ScrollPerPixel) self.setWordWrap(True) self.setAlternatingRowColors(True) self.setSortingEnabled(True) @@ -54,13 +54,18 @@ def __init__(self, parent=None): self.table_name = 'LCA results' self.dataframe = None - def get_max_height(self): + def get_max_height(self) -> int: return (self.verticalHeader().count())*self.verticalHeader().defaultSectionSize() + \ self.horizontalHeader().height() + self.horizontalScrollBar().height() + 5 - def sizeHint(self): + def sizeHint(self) -> QSize: return QSize(self.width(), self.get_max_height()) + def rowCount(self) -> int: + if hasattr(self, "model") and self.model is not None: + return self.model.rowCount() + return 0 + def _select_model(self) -> QAbstractTableModel: """ Select which model to use for the proxy model. """ @@ -74,7 +79,7 @@ def _resize(self): self.setMaximumHeight(self.get_max_height()) @staticmethod - def get_source_index(proxy_index): + def get_source_index(proxy_index: QModelIndex) -> QModelIndex: """ Returns the index of the original model from a proxymodel index. This way data from the self._dataframe can be obtained correctly. @@ -84,7 +89,7 @@ def get_source_index(proxy_index): # We are a proxy model source_index = model.mapToSource(proxy_index) return source_index - return None + return QModelIndex() # Returns an invalid index def to_clipboard(self): """ Copy dataframe to clipboard diff --git a/activity_browser/app/ui/tabs/activity.py b/activity_browser/app/ui/tabs/activity.py index 3d7a3afa2..b7a2aacd9 100644 --- a/activity_browser/app/ui/tabs/activity.py +++ b/activity_browser/app/ui/tabs/activity.py @@ -3,7 +3,8 @@ from PyQt5 import QtCore, QtWidgets, QtGui from ..style import style_activity_tab -from ..tables import ExchangeTable +from ..tables import (BiosphereExchangeTable, DownstreamExchangeTable, + ProductExchangeTable, TechnosphereExchangeTable) from ..widgets import ActivityDataGrid, DetailsGroupBox, SignalledPlainTextEdit from ..panels import ABTab from ..icons import icons @@ -115,11 +116,10 @@ def __init__(self, key, parent=None, read_only=True): self.activity_data_grid = ActivityDataGrid(read_only=self.read_only, parent=self) # 4 data tables displayed after the activity data - self.production = ExchangeTable(self, tableType="products") - # self.production = ProductTable(self) - self.technosphere = ExchangeTable(self, tableType="technosphere") - self.biosphere = ExchangeTable(self, tableType="biosphere") - self.downstream = ExchangeTable(self, tableType="technosphere") + self.production = ProductExchangeTable(self) + self.technosphere = TechnosphereExchangeTable(self) + self.biosphere = BiosphereExchangeTable(self) + self.downstream = DownstreamExchangeTable(self) self.exchange_tables = [ (self.production, "Products:"), @@ -142,8 +142,7 @@ def __init__(self, key, parent=None, read_only=True): layout.setAlignment(QtCore.Qt.AlignTop) self.setLayout(layout) - self.populate(self.key) - + self.populate() self.update_tooltips() self.update_style() self.connect_signals() @@ -155,14 +154,17 @@ def connect_signals(self): def open_graph(self): signals.open_activity_graph_tab.emit(self.key) - def populate(self, key): + def populate(self): # fill in the values of the ActivityTab widgets, excluding the ActivityDataGrid which is populated separately # todo: add count of results for each exchange table, to label above each table - db_name = key[0] - self.production.set_queryset(db_name, self.activity.production()) - self.technosphere.set_queryset(db_name, self.activity.technosphere()) - self.biosphere.set_queryset(db_name, self.activity.biosphere()) - self.downstream.set_queryset(db_name, self.activity.upstream(), downstream=True) + self.production.sync(self.activity.production()) + self.technosphere.sync(self.activity.technosphere()) + self.biosphere.sync(self.activity.biosphere()) + self.downstream.sync(self.activity.upstream()) + + # Potentially update `DetailsGroupBox` now that tables are populated + for table, _ in self.exchange_tables: + table.updated.emit() self.populate_description_box() @@ -207,14 +209,16 @@ def exchange_tables_read_only_changed(self): for table, label in self.exchange_tables: if self.read_only: - table.setEditTriggers(QtWidgets.QTableWidget.NoEditTriggers) + table.setEditTriggers(QtWidgets.QTableView.NoEditTriggers) table.setAcceptDrops(False) table.delete_exchange_action.setEnabled(False) + table.remove_formula_action.setEnabled(False) else: - table.setEditTriggers(QtWidgets.QTableWidget.DoubleClicked) - if table != self.downstream: # downstream consumers table never accepts drops + table.setEditTriggers(QtWidgets.QTableView.DoubleClicked) + table.delete_exchange_action.setEnabled(True) + table.remove_formula_action.setEnabled(True) + if not table.downstream: # downstream consumers table never accepts drops table.setAcceptDrops(True) - table.delete_exchange_action.setEnabled(True) def db_read_only_changed(self, db_name, db_read_only): """ If database of open activity is set to read-only, the read-only checkbox cannot now be unchecked by user """ diff --git a/activity_browser/app/ui/widgets/activity.py b/activity_browser/app/ui/widgets/activity.py index d1274b612..10b69f206 100644 --- a/activity_browser/app/ui/widgets/activity.py +++ b/activity_browser/app/ui/widgets/activity.py @@ -24,6 +24,8 @@ def __init__(self, label, widget): self.setLayout(layout) if isinstance(self.widget, QtWidgets.QTableWidget): self.widget.itemChanged.connect(self.toggle_empty_table) + if hasattr(self.widget, "updated"): + self.widget.updated.connect(self.toggle_empty_table) def showhide(self): self.widget.setVisible(self.isChecked())