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")