diff --git a/activity_browser/layouts/pages/activity_details/exchanges_tab.py b/activity_browser/layouts/pages/activity_details/exchanges_tab.py
index 90d45ff89..41bbe6339 100644
--- a/activity_browser/layouts/pages/activity_details/exchanges_tab.py
+++ b/activity_browser/layouts/pages/activity_details/exchanges_tab.py
@@ -195,7 +195,7 @@ def dragEnterEvent(self, event):
return
if event.mimeData().hasFormat("application/bw-nodekeylist"):
- self.overlay = DropOverlay(self)
+ self.overlay = widgets.ABDropOverlay(self)
self.overlay.show()
event.accept()
@@ -239,28 +239,6 @@ def get_exchange_type(activity_key: tuple) -> str | None:
return None
-class DropOverlay(QtWidgets.QWidget):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setAttribute(Qt.WA_TransparentForMouseEvents)
- self.setAttribute(Qt.WA_NoSystemBackground)
- self.setAttribute(Qt.WA_TranslucentBackground)
- self.setAutoFillBackground(False)
- self.resize(parent.size())
-
- def paintEvent(self, event):
- painter = QtGui.QPainter(self)
- painter.setRenderHint(QtGui.QPainter.Antialiasing)
- painter.fillRect(self.rect(), QtGui.QColor(0, 100, 255, 200)) # Semi-transparent blue
- painter.setPen(Qt.white)
-
- font = self.font()
- font.setBold(True)
-
- painter.setFont(font)
- painter.drawText(self.rect(), Qt.AlignCenter, "Drop here to create new exchanges")
-
-
class RelinkDelegate(delegates.StringDelegate):
matched: pd.DataFrame
column: str
diff --git a/activity_browser/layouts/pages/activity_details/graph_tab.py b/activity_browser/layouts/pages/activity_details/graph_tab.py
index c4196bbd5..b25759b80 100644
--- a/activity_browser/layouts/pages/activity_details/graph_tab.py
+++ b/activity_browser/layouts/pages/activity_details/graph_tab.py
@@ -9,7 +9,8 @@
import bw_functional as bf
from activity_browser import static, bwutils, actions
-from .exchanges_tab import DropOverlay, get_exchange_type
+from activity_browser.ui import widgets
+from .exchanges_tab import get_exchange_type
log = getLogger(__name__)
@@ -208,7 +209,7 @@ def dragEnterEvent(self, event):
return
if event.mimeData().hasFormat("application/bw-nodekeylist"):
- self.overlay = DropOverlay(self)
+ self.overlay = widgets.ABDropOverlay(self)
self.overlay.show()
event.accept()
diff --git a/activity_browser/layouts/pages/impact_category_details/impact_category_details.py b/activity_browser/layouts/pages/impact_category_details/impact_category_details.py
index 2eae9a9b8..7f11a6be7 100644
--- a/activity_browser/layouts/pages/impact_category_details/impact_category_details.py
+++ b/activity_browser/layouts/pages/impact_category_details/impact_category_details.py
@@ -6,7 +6,7 @@
from activity_browser import actions, signals
from activity_browser.ui import widgets, icons, delegates
-from activity_browser.bwutils import AB_metadata
+from activity_browser.bwutils import AB_metadata, is_node_biosphere
from .impact_category_header import ImpactCategoryHeader
@@ -16,6 +16,7 @@ def __init__(self, name: tuple, parent=None):
super().__init__(parent)
self.name = name
self.impact_category = bd.Method(name)
+ self.is_editable = False
self.setObjectName(" | ".join(name))
@@ -74,8 +75,9 @@ def build_df(self):
df = df.merge(other, left_on="id", right_on="id").rename(columns={"id": "_id", "data": "_cf"})
df["_impact_category_name"] = [self.name for i in range(len(df))]
+ df["_editable"] = self.is_editable
- cols = ["name", "categories", "database", "amount", "unit", "uncertainty", "_id", "_impact_category_name", "_cf"]
+ cols = ["name", "categories", "database", "amount", "unit", "uncertainty", "_id", "_impact_category_name", "_cf", "_editable"]
return df[cols]
@@ -86,7 +88,91 @@ class CharacterizationFactorsView(widgets.ABTreeView):
"uncertainty": delegates.UncertaintyDelegate,
}
+ class ContextMenu(widgets.ABMenu):
+ menuSetup = [
+ lambda m: m.add(actions.CFRemove, m.impact_category_name, m.char_factors,
+ enable=bool(m.char_factors) and m.is_editable,
+ text="Remove characterization factor(s)"),
+ ]
+ @property
+ def is_editable(self):
+ return self.parent().parent().is_editable
+
+ @property
+ def impact_category_name(self):
+ return self.parent().parent().name
+
+ @property
+ def char_factors(self):
+ indexes = self.parent().selectedIndexes()
+ return [(idx.internalPointer()["_id"], idx.internalPointer()["_cf"])
+ for idx in indexes if idx.isValid() and idx.column() == 0]
+
+ def __init__(self, parent):
+ super().__init__(parent)
+ self.setAcceptDrops(True)
+ self.setSortingEnabled(True)
+ self.overlay = None
+
+ def dragEnterEvent(self, event):
+ """
+ Handles the drag enter event.
+
+ Args:
+ event: The drag enter event.
+ """
+ if not self.parent().is_editable:
+ event.ignore()
+ return
+
+ if event.mimeData().hasFormat("application/bw-nodekeylist"):
+ self.overlay = widgets.ABDropOverlay(self)
+ self.overlay.show()
+ event.accept()
+ else:
+ event.ignore()
+
+ def dragMoveEvent(self, event):
+ """Handles the drag move event - required for proper drop indicator."""
+ if not self.parent().is_editable:
+ event.ignore()
+ return
+
+ if event.mimeData().hasFormat("application/bw-nodekeylist"):
+ event.accept()
+ else:
+ event.ignore()
+
+ def dragLeaveEvent(self, event):
+ """
+ Handles the drag leave event.
+
+ Args:
+ event: The drag leave event.
+ """
+ if not self.overlay is None:
+ # Reset the palette on drag leave
+ self.overlay.deleteLater()
+ self.overlay = None
+
+ def dropEvent(self, event):
+ """
+ Handles the drop event.
+
+ Args:
+ event: The drop event.
+ """
+ self.overlay.deleteLater()
+ self.overlay = None
+
+ keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist")
+
+ # Filter to only biosphere flows
+ biosphere_keys = [key for key in keys if is_node_biosphere(key)]
+
+ if biosphere_keys:
+ actions.CFNew.run(self.parent().name, biosphere_keys)
class ExchangesItem(widgets.ABDataItem):
@@ -102,7 +188,8 @@ def flags(self, col: int, key: str):
QtCore.Qt.ItemFlags: The item flags.
"""
flags = super().flags(col, key)
- if key in ["amount", "uncertainty"]:
+
+ if key in ["amount", "uncertainty"] and self["_editable"]:
return flags | Qt.ItemFlag.ItemIsEditable
return flags
diff --git a/activity_browser/layouts/pages/impact_category_details/impact_category_header.py b/activity_browser/layouts/pages/impact_category_details/impact_category_header.py
index 716d06f98..c853bdc0d 100644
--- a/activity_browser/layouts/pages/impact_category_details/impact_category_header.py
+++ b/activity_browser/layouts/pages/impact_category_details/impact_category_header.py
@@ -7,7 +7,8 @@ class ImpactCategoryHeader(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget):
"""
- Initializes the ActivityHeader widget.
+ Initializes the ImpactCategoryHeader widget with a stack layout
+ that switches between editable and view-only headers.
Args:
parent (QtWidgets.QWidget): The parent widget.
@@ -15,65 +16,169 @@ def __init__(self, parent: QtWidgets.QWidget):
super().__init__(parent)
self.impact_category = parent.impact_category
- layout = QtWidgets.QVBoxLayout()
- layout.setContentsMargins(0, 0, 0, 0)
- self.setLayout(layout)
+ # Set size policy to only take needed vertical space
+ self.setSizePolicy(
+ QtWidgets.QSizePolicy.Policy.Expanding,
+ QtWidgets.QSizePolicy.Policy.Maximum
+ )
+
+ # Create stack layout to hold both header types
+ self.stack = QtWidgets.QStackedLayout()
+ self.stack.setContentsMargins(0, 0, 0, 0)
+ self.stack.setSizeConstraint(QtWidgets.QLayout.SizeConstraint.SetMinimumSize)
+
+ # Create both header widgets
+ self.view_only_header = ViewOnlyHeader(self)
+ self.editable_header = EditableHeader(self)
+
+ # Add headers to stack
+ self.stack.addWidget(self.view_only_header) # Index 0
+ self.stack.addWidget(self.editable_header) # Index 1
+
+ self.setLayout(self.stack)
def sync(self):
"""
- Synchronizes the widget with the current state of the activity.
+ Synchronizes the widget with the current state of the impact category.
+ Switches between editable and view-only headers based on edit mode.
"""
self.impact_category = self.parent().impact_category
+
+ # Update both headers with current data
+ self.view_only_header.sync()
+ self.editable_header.sync()
+
+ # Switch to appropriate header based on edit mode
+ if self.parent().is_editable:
+ self.stack.setCurrentIndex(1) # Show editable header
+ else:
+ self.stack.setCurrentIndex(0) # Show view-only header
+
+ def on_editable_changed(self):
+ """
+ Called when the edit button is clicked.
+ Notifies the parent page to update the view accordingly.
+ """
+ self.parent().is_editable = not self.parent().is_editable
+ self.parent().sync()
- self.clear_layout()
- self.layout().addLayout(self.build_grid())
- def clear_layout(self, layout: QtWidgets.QLayout = None):
- layout = layout or self.layout()
+class ViewOnlyHeader(QtWidgets.QWidget):
+ """
+ A read-only header widget that displays impact category information.
+ """
- if layout is None:
- return
+ def __init__(self, parent: ImpactCategoryHeader):
+ """
+ Initializes the view-only header.
- while layout.count():
- item = layout.takeAt(0)
- widget = item.widget()
- if widget is not None:
- widget.deleteLater()
- elif item.layout() is not None:
- self.clear_layout(item.layout())
+ Args:
+ parent (ImpactCategoryHeader): The parent header widget.
+ """
+ super().__init__(parent)
+ self.grid = QtWidgets.QGridLayout()
+ self.grid.setContentsMargins(0, 5, 0, 5)
+ self.grid.setSpacing(10)
+ self.grid.setColumnStretch(0, 0) # Column 0 doesn't stretch
+ self.grid.setColumnStretch(1, 1) # Column 1 takes remaining space
+ self.grid.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
+ self.setLayout(self.grid)
+
+ # Create widgets
+ self.name_label = QtWidgets.QLabel(self)
+ self.unit_label = QtWidgets.QLabel(self)
+ self.edit_button = QtWidgets.QPushButton("Edit Impact Category", self)
+ self.edit_button.clicked.connect(parent.on_editable_changed)
+
+ # Layout widgets
+ self.grid.addWidget(widgets.ABLabel.demiBold("Name:", self), 0, 0)
+ self.grid.addWidget(self.name_label, 0, 1)
+ self.grid.addWidget(self.edit_button, 0, 1, QtCore.Qt.AlignmentFlag.AlignRight)
+ self.grid.addWidget(widgets.ABLabel.demiBold("Unit:", self), 1, 0)
+ self.grid.addWidget(self.unit_label, 1, 1)
- def build_grid(self) -> QtWidgets.QGridLayout:
- grid = QtWidgets.QGridLayout(self)
- grid.setContentsMargins(0, 5, 0, 5)
- grid.setSpacing(10)
- grid.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
+ def sync(self):
+ """
+ Updates the displayed information from the current impact category.
+ """
+ impact_category = self.parent().impact_category
+ self.name_label.setText(" | ".join(impact_category.name))
+ self.unit_label.setText(impact_category.metadata.get("unit", "Undefined"))
- name_label = QtWidgets.QLabel(f"{' | '.join(self.impact_category.name)}", self)
- name_label.linkActivated.connect(lambda: actions.MethodRename.run(self.impact_category.name))
- setup = [
- ("Name:", name_label),
- ("Unit:", ImpactCategoryUnit(self)),
- ]
+class EditableHeader(QtWidgets.QWidget):
+ """
+ An editable header widget that allows modifying impact category information.
+ """
- # Arrange widgets for display as a grid
- for i, (title, widget) in enumerate(setup):
- grid.addWidget(widgets.ABLabel.demiBold(title, self), i, 1)
- grid.addWidget(widget, i, 2, 1, 4)
+ def __init__(self, parent: ImpactCategoryHeader):
+ """
+ Initializes the editable header.
- return grid
+ Args:
+ parent (ImpactCategoryHeader): The parent header widget.
+ """
+ super().__init__(parent)
+ self.grid = QtWidgets.QGridLayout()
+ self.grid.setContentsMargins(0, 5, 0, 5)
+ self.grid.setSpacing(10)
+ self.grid.setColumnStretch(0, 0) # Column 0 doesn't stretch
+ self.grid.setColumnStretch(1, 1) # Column 1 takes remaining space
+ self.grid.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
+ self.setLayout(self.grid)
+
+ # Create widgets
+ self.name_label = QtWidgets.QLabel(self)
+ self.name_label.linkActivated.connect(self._rename_method)
+ self.unit_edit = ImpactCategoryUnit(self)
+ self.done_button = QtWidgets.QPushButton("Done editing", self)
+ self.done_button.clicked.connect(parent.on_editable_changed)
+
+ # Layout widgets
+ self.grid.addWidget(widgets.ABLabel.demiBold("Name:", self), 0, 0)
+ self.grid.addWidget(self.name_label, 0, 1)
+ self.grid.addWidget(self.done_button, 0, 1, QtCore.Qt.AlignmentFlag.AlignRight)
+ self.grid.addWidget(widgets.ABLabel.demiBold("Unit:", self), 1, 0)
+ self.grid.addWidget(self.unit_edit, 1, 1)
+ def sync(self):
+ """
+ Updates the displayed information from the current impact category.
+ """
+ impact_category = self.parent().impact_category
+ self.name_label.setText(f"{' | '.join(impact_category.name)}")
+ self.unit_edit.setText(impact_category.metadata.get("unit", "Undefined"))
-class ImpactCategoryUnit(QtWidgets.QLineEdit):
+ def _rename_method(self):
+ """
+ Triggers the method rename action.
+ """
+ actions.MethodRename.run(self.parent().impact_category.name)
- def __init__(self, parent: ImpactCategoryHeader):
- name = parent.impact_category.metadata.get("unit", "Undefined")
- super().__init__(name, parent)
+class ImpactCategoryUnit(QtWidgets.QLineEdit):
+ """
+ A line edit widget for editing the impact category unit.
+ """
+ def __init__(self, parent: EditableHeader):
+ """
+ Initializes the unit edit widget.
+
+ Args:
+ parent (EditableHeader): The parent editable header widget.
+ """
+ super().__init__(parent)
self.editingFinished.connect(self.change_unit)
def change_unit(self):
- if self.text() == self.parent().impact_category.metadata.get("unit", "Undefined"):
+ """
+ Updates the impact category unit when editing is finished.
+ """
+ impact_category = self.parent().parent().impact_category
+ current_unit = impact_category.metadata.get("unit", "Undefined")
+
+ if self.text() == current_unit:
return
- actions.MethodMetaModify.run(self.parent().impact_category.name, "unit", self.text())
\ No newline at end of file
+
+ actions.MethodMetaModify.run(impact_category.name, "unit", self.text())
\ No newline at end of file
diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py
index e4aee7b86..9031f6e35 100644
--- a/activity_browser/ui/widgets/__init__.py
+++ b/activity_browser/ui/widgets/__init__.py
@@ -22,3 +22,4 @@
from .central import CentralTabWidget
from .menu import ABMenu
from .list_edit_dialog import ABListEditDialog
+from .drop_overlay import ABDropOverlay
\ No newline at end of file
diff --git a/activity_browser/ui/widgets/drop_overlay.py b/activity_browser/ui/widgets/drop_overlay.py
new file mode 100644
index 000000000..326b2e2d8
--- /dev/null
+++ b/activity_browser/ui/widgets/drop_overlay.py
@@ -0,0 +1,24 @@
+from qtpy import QtWidgets, QtGui
+from qtpy.QtCore import Qt
+
+
+class ABDropOverlay(QtWidgets.QWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setAttribute(Qt.WA_TransparentForMouseEvents)
+ self.setAttribute(Qt.WA_NoSystemBackground)
+ self.setAttribute(Qt.WA_TranslucentBackground)
+ self.setAutoFillBackground(False)
+ self.resize(parent.size())
+
+ def paintEvent(self, event):
+ painter = QtGui.QPainter(self)
+ painter.setRenderHint(QtGui.QPainter.Antialiasing)
+ painter.fillRect(self.rect(), QtGui.QColor(0, 100, 255, 200)) # Semi-transparent blue
+ painter.setPen(Qt.white)
+
+ font = self.font()
+ font.setBold(True)
+
+ painter.setFont(font)
+ painter.drawText(self.rect(), Qt.AlignCenter, "Drop here to create new exchanges")