From dec4e8841442b8360e81a9b7c1cf82fce9e1ec3a Mon Sep 17 00:00:00 2001 From: rozyczko Date: Thu, 16 Apr 2026 13:35:50 +0200 Subject: [PATCH 01/14] accordion groups. combine layer editor with model editor --- .../Sidebar/Basic/Groups/AssemblyEditor.qml | 2 +- .../Sidebar/Basic/Groups/MaterialEditor.qml | 2 +- .../Sidebar/Basic/Groups/ModelEditor.qml | 23 ++++++++++++++++++- .../Gui/Pages/Sample/Sidebar/Basic/Layout.qml | 4 ---- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/AssemblyEditor.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/AssemblyEditor.qml index 99a033b3..b7ecb90b 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/AssemblyEditor.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/AssemblyEditor.qml @@ -9,7 +9,7 @@ import "./Assemblies" as Assemblies EaElements.GroupBox { title: qsTr("Layer editor: " + Globals.BackendWrapper.sampleCurrentAssemblyName) collapsible: true - collapsed: false + collapsed: true property string currentAssemblyType: Globals.BackendWrapper.sampleCurrentAssemblyType EaElements.GroupColumn { diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml index dab516b2..f3691ff7 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml @@ -10,7 +10,7 @@ import Gui.Globals as Globals EaElements.GroupBox { title: qsTr("Material editor") collapsible: true - collapsed: false + collapsed: true EaElements.GroupColumn { diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml index 4f74fe0f..84f909e7 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml @@ -6,11 +6,14 @@ import EasyApp.Gui.Elements as EaElements import EasyApp.Gui.Components as EaComponents import Gui.Globals as Globals +import "./Assemblies" as Assemblies EaElements.GroupBox { title: qsTr("Model editor: " + Globals.BackendWrapper.sampleCurrentModelName) collapsible: true - collapsed: false + collapsed: true + + property string currentAssemblyType: Globals.BackendWrapper.sampleCurrentAssemblyType EaElements.GroupColumn { @@ -130,5 +133,23 @@ EaElements.GroupBox { onClicked: Globals.BackendWrapper.sampleMoveSelectedAssemblyDown() } } + + // Layer editor + EaElements.Label { + text: qsTr("Layer editor: " + Globals.BackendWrapper.sampleCurrentAssemblyName) + font.bold: true + } + + Assemblies.MultiLayer { + visible: currentAssemblyType === 'Multi-layer' + } + + Assemblies.RepeatingMultiLayer { + visible: currentAssemblyType === 'Repeating Multi-layer' + } + + Assemblies.SurfactantLayer { + visible: currentAssemblyType === 'Surfactant Layer' + } } } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml index 7edf1a91..5db90142 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml @@ -20,8 +20,4 @@ EaComponents.SideBarColumn { id: modelEditor enabled: Globals.BackendWrapper.analysisIsFitFinished } - Groups.AssemblyEditor{ - id: assemblyEditor - enabled: Globals.BackendWrapper.analysisIsFitFinished - } } From 1b34e3cd83c58ffe918812a5b08b438b772e696e Mon Sep 17 00:00:00 2001 From: rozyczko Date: Thu, 16 Apr 2026 14:05:18 +0200 Subject: [PATCH 02/14] added fixes to other lists --- .../Backends/Py/logic/assemblies.py | 14 ++- .../Backends/Py/logic/layers.py | 49 +++++++++++ .../Backends/Py/logic/material.py | 18 ++++ .../Backends/Py/logic/models.py | 6 ++ EasyReflectometryApp/Backends/Py/sample.py | 87 +++++++++++++++++++ .../Gui/Globals/BackendWrapper.qml | 14 ++- .../Basic/Groups/Assemblies/MultiLayer.qml | 6 +- .../Groups/Assemblies/SurfactantLayer.qml | 12 +-- .../Sidebar/Basic/Groups/MaterialEditor.qml | 6 +- .../Sidebar/Basic/Groups/ModelEditor.qml | 2 +- .../Sidebar/Basic/Groups/ModelSelector.qml | 2 +- tests/test_logic_layers.py | 63 ++++++++++++++ 12 files changed, 261 insertions(+), 18 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/logic/assemblies.py b/EasyReflectometryApp/Backends/Py/logic/assemblies.py index 18b3ef4a..c02343d5 100644 --- a/EasyReflectometryApp/Backends/Py/logic/assemblies.py +++ b/EasyReflectometryApp/Backends/Py/logic/assemblies.py @@ -60,9 +60,17 @@ def move_selected_down(self) -> None: self._assemblies.move_down(self.index) self.index = self.index + 1 - def set_name_at_current_index(self, new_value: str) -> None: - self._assemblies[self.index].name = new_value - return True + def set_name_at_current_index(self, new_value: str) -> bool: + if self._assemblies[self.index].name != new_value: + self._assemblies[self.index].name = new_value + return True + return False + + def set_name_at_index(self, index: int, new_value: str) -> bool: + if self._assemblies[index].name != new_value: + self._assemblies[index].name = new_value + return True + return False def set_type_at_current_index(self, new_value: str) -> bool: if new_value == self._assemblies[self.index].type: diff --git a/EasyReflectometryApp/Backends/Py/logic/layers.py b/EasyReflectometryApp/Backends/Py/logic/layers.py index 92fa0e5f..55913c84 100644 --- a/EasyReflectometryApp/Backends/Py/logic/layers.py +++ b/EasyReflectometryApp/Backends/Py/logic/layers.py @@ -82,18 +82,36 @@ def set_name_at_current_index(self, new_value: str) -> bool: return True return False + def set_name_at_index(self, index: int, new_value: str) -> bool: + if self._layers[index].name != new_value: + self._layers[index].name = new_value + return True + return False + def set_thickness_at_current_index(self, new_value: float) -> bool: if self._layers[self.index].thickness.value != new_value: self._layers[self.index].thickness.value = new_value return True return False + def set_thickness_at_index(self, index: int, new_value: float) -> bool: + if self._layers[index].thickness.value != new_value: + self._layers[index].thickness.value = new_value + return True + return False + def set_roughness_at_current_index(self, new_value: float) -> bool: if self._layers[self.index].roughness.value != new_value: self._layers[self.index].roughness.value = new_value return True return False + def set_roughness_at_index(self, index: int, new_value: float) -> bool: + if self._layers[index].roughness.value != new_value: + self._layers[index].roughness.value = new_value + return True + return False + def set_material_at_current_index(self, new_value: int) -> bool: if self._layers[self.index].material != self._project_lib._materials[new_value]: self._layers[self.index].material = self._project_lib._materials[new_value] @@ -102,30 +120,61 @@ def set_material_at_current_index(self, new_value: int) -> bool: return True return False + def set_material_at_index(self, index: int, new_value: int) -> bool: + if self._layers[index].material != self._project_lib._materials[new_value]: + self._layers[index].material = self._project_lib._materials[new_value] + self._layers[index].name = self._project_lib._materials[new_value].name + ' Layer' + return True + return False + def set_solvent_at_current_index(self, new_value: int) -> bool: if self._layers[self.index].solvent != self._project_lib._materials[new_value]: self._layers[self.index].solvent = self._project_lib._materials[new_value] return True return False + def set_solvent_at_index(self, index: int, new_value: int) -> bool: + if self._layers[index].solvent != self._project_lib._materials[new_value]: + self._layers[index].solvent = self._project_lib._materials[new_value] + return True + return False + def set_apm_at_current_index(self, new_value: float) -> bool: if self._layers[self.index].area_per_molecule != new_value: self._layers[self.index].area_per_molecule = new_value return True return False + def set_apm_at_index(self, index: int, new_value: float) -> bool: + if self._layers[index].area_per_molecule != new_value: + self._layers[index].area_per_molecule = new_value + return True + return False + def set_solvation_at_current_index(self, new_value: float) -> bool: if self._layers[self.index].solvent_fraction != new_value: self._layers[self.index].solvent_fraction = new_value return True return False + def set_solvation_at_index(self, index: int, new_value: float) -> bool: + if self._layers[index].solvent_fraction != new_value: + self._layers[index].solvent_fraction = new_value + return True + return False + def set_formula(self, new_value: str) -> bool: if self._layers[self.index].molecular_formula != new_value: self._layers[self.index].molecular_formula = new_value return True return False + def set_formula_at_index(self, index: int, new_value: str) -> bool: + if self._layers[index].molecular_formula != new_value: + self._layers[index].molecular_formula = new_value + return True + return False + def _from_layers_collection_to_list_of_dicts( layers_collection: LayerCollection, assembly_type: str = 'regular' diff --git a/EasyReflectometryApp/Backends/Py/logic/material.py b/EasyReflectometryApp/Backends/Py/logic/material.py index 919b2977..ca480a0a 100644 --- a/EasyReflectometryApp/Backends/Py/logic/material.py +++ b/EasyReflectometryApp/Backends/Py/logic/material.py @@ -57,18 +57,36 @@ def set_name_at_current_index(self, new_value: str) -> bool: return True return False + def set_name_at_index(self, index: int, new_value: str) -> bool: + if self._materials[index].name != new_value: + self._materials[index].name = new_value + return True + return False + def set_sld_at_current_index(self, new_value: float) -> bool: if self._materials[self.index].sld.value != new_value: self._materials[self.index].sld.value = new_value return True return False + def set_sld_at_index(self, index: int, new_value: float) -> bool: + if self._materials[index].sld.value != new_value: + self._materials[index].sld.value = new_value + return True + return False + def set_isld_at_current_index(self, new_value: float) -> bool: if self._materials[self.index].isld.value != new_value: self._materials[self.index].isld.value = new_value return True return False + def set_isld_at_index(self, index: int, new_value: float) -> bool: + if self._materials[index].isld.value != new_value: + self._materials[index].isld.value = new_value + return True + return False + def _from_materials_collection_to_list_of_dicts(materials_collection: MaterialCollection) -> list[dict[str, str]]: materials_list = [] diff --git a/EasyReflectometryApp/Backends/Py/logic/models.py b/EasyReflectometryApp/Backends/Py/logic/models.py index 44349cf7..278ad6f5 100644 --- a/EasyReflectometryApp/Backends/Py/logic/models.py +++ b/EasyReflectometryApp/Backends/Py/logic/models.py @@ -57,6 +57,12 @@ def set_name_at_current_index(self, new_value: str) -> bool: return True return False + def set_name_at_index(self, index: int, new_value: str) -> bool: + if self._models[index].name != new_value: + self._models[index].name = new_value + return True + return False + def set_scaling_at_current_index(self, new_value: str) -> bool: if self._models[self.index].scale.value != float(new_value): self._models[self.index].scale.value = float(new_value) diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 1c73c226..5c5f6a31 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -118,6 +118,11 @@ def setCurrentMaterialName(self, new_value: str) -> None: if self._material_logic.set_name_at_current_index(new_value): self.materialsTableChanged.emit() + @Slot(int, str) + def setMaterialNameAtIndex(self, index: int, new_value: str) -> None: + if self._material_logic.set_name_at_index(index, new_value): + self.materialsTableChanged.emit() + @Slot(float) def setCurrentMaterialSld(self, new_value: float) -> None: if self._material_logic.set_sld_at_current_index(new_value): @@ -125,6 +130,13 @@ def setCurrentMaterialSld(self, new_value: float) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, float) + def setMaterialSldAtIndex(self, index: int, new_value: float) -> None: + if self._material_logic.set_sld_at_index(index, new_value): + self.materialsTableChanged.emit() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + @Slot(float) def setCurrentMaterialISld(self, new_value: float) -> None: if self._material_logic.set_isld_at_current_index(new_value): @@ -132,6 +144,13 @@ def setCurrentMaterialISld(self, new_value: float) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, float) + def setMaterialISldAtIndex(self, index: int, new_value: float) -> None: + if self._material_logic.set_isld_at_index(index, new_value): + self.materialsTableChanged.emit() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + # Actions @Slot(str) def removeMaterial(self, value: str) -> None: @@ -197,6 +216,13 @@ def setCurrentModelName(self, value: str) -> None: self.modelsIndexChanged.emit() self._clearCacheAndEmitLayersChanged() + @Slot(int, str) + def setModelNameAtIndex(self, index: int, value: str) -> None: + if self._models_logic.set_name_at_index(index, value): + self.modelsTableChanged.emit() + self.modelsIndexChanged.emit() + self._clearCacheAndEmitLayersChanged() + # Actions @Slot(str) def removeModel(self, value: str) -> None: @@ -264,6 +290,13 @@ def setCurrentAssemblyName(self, new_value: str) -> None: self.materialsTableChanged.emit() self.externalSampleChanged.emit() + @Slot(int, str) + def setAssemblyNameAtIndex(self, index: int, new_value: str) -> None: + if self._assemblies_logic.set_name_at_index(index, new_value): + self.assembliesTableChanged.emit() + self.materialsTableChanged.emit() + self.externalSampleChanged.emit() + @Slot(str) def setCurrentAssemblyType(self, new_value: str) -> None: self._assemblies_logic.set_type_at_current_index(new_value) @@ -367,6 +400,11 @@ def setCurrentLayerName(self, new_value: str) -> None: if self._layers_logic.set_name_at_current_index(new_value): self._clearCacheAndEmitLayersChanged() + @Slot(int, str) + def setLayerNameAtIndex(self, index: int, new_value: str) -> None: + if self._layers_logic.set_name_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + @Slot(int) def setCurrentLayerMaterial(self, new_value: int) -> None: if self._layers_logic.set_material_at_current_index(new_value): @@ -374,6 +412,13 @@ def setCurrentLayerMaterial(self, new_value: int) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, int) + def setLayerMaterialAtIndex(self, index: int, new_value: int) -> None: + if self._layers_logic.set_material_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + @Slot(int) def setCurrentLayerSolvent(self, new_value: int) -> None: if self._layers_logic.set_solvent_at_current_index(new_value): @@ -381,6 +426,13 @@ def setCurrentLayerSolvent(self, new_value: int) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, int) + def setLayerSolventAtIndex(self, index: int, new_value: int) -> None: + if self._layers_logic.set_solvent_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + @Slot(float) def setCurrentLayerThickness(self, new_value: float) -> None: if self._layers_logic.set_thickness_at_current_index(new_value): @@ -388,6 +440,13 @@ def setCurrentLayerThickness(self, new_value: float) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, float) + def setLayerThicknessAtIndex(self, index: int, new_value: float) -> None: + if self._layers_logic.set_thickness_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + @Slot(float) def setCurrentLayerRoughness(self, new_value: float) -> None: if self._layers_logic.set_roughness_at_current_index(new_value): @@ -395,6 +454,13 @@ def setCurrentLayerRoughness(self, new_value: float) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, float) + def setLayerRoughnessAtIndex(self, index: int, new_value: float) -> None: + if self._layers_logic.set_roughness_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + @Slot(str) def setCurrentLayerFormula(self, new_value: str) -> None: if self._layers_logic.set_formula(new_value): @@ -402,6 +468,13 @@ def setCurrentLayerFormula(self, new_value: str) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, str) + def setLayerFormulaAtIndex(self, index: int, new_value: str) -> None: + if self._layers_logic.set_formula_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + @Slot(float) def setCurrentLayerAPM(self, new_value: float) -> None: if self._layers_logic.set_apm_at_current_index(new_value): @@ -409,6 +482,13 @@ def setCurrentLayerAPM(self, new_value: float) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, float) + def setLayerAPMAtIndex(self, index: int, new_value: float) -> None: + if self._layers_logic.set_apm_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + @Slot(float) def setCurrentLayerSolvation(self, new_value: float) -> None: if self._layers_logic.set_solvation_at_current_index(new_value): @@ -416,6 +496,13 @@ def setCurrentLayerSolvation(self, new_value: float) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, float) + def setLayerSolvationAtIndex(self, index: int, new_value: float) -> None: + if self._layers_logic.set_solvation_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + # Actions @Slot(str) def removeLayer(self, value: str) -> None: diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 6c08039e..f1764f72 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -103,8 +103,11 @@ QtObject { function sampleSetCurrentMaterialIndex(value) { activeBackend.sample.setCurrentMaterialIndex(value) } function sampleSetCurrentMaterialName(value) { activeBackend.sample.setCurrentMaterialName(value) } - function sampleSetCurrentMaterialSld(value) { activeBackend.sample.setCurrentMaterialSld(value) } + function sampleSetMaterialNameAtIndex(index, value) { activeBackend.sample.setMaterialNameAtIndex(index, value) } + function sampleSetCurrentMaterialSld(value) { activeBackend.sample.setCurrentMaterialSld(value) } + function sampleSetMaterialSldAtIndex(index, value) { activeBackend.sample.setMaterialSldAtIndex(index, value) } function sampleSetCurrentMaterialISld(value) { activeBackend.sample.setCurrentMaterialISld(value) } + function sampleSetMaterialISldAtIndex(index, value) { activeBackend.sample.setMaterialISldAtIndex(index, value) } function sampleRemoveMaterial(value) { activeBackend.sample.removeMaterial(value) } function sampleAddNewMaterial() { activeBackend.sample.addNewMaterial() } function sampleDuplicateSelectedMaterial() { activeBackend.sample.duplicateSelectedMaterial() } @@ -120,6 +123,7 @@ QtObject { function sampleSetCurrentModelIndex(value) { activeBackend.sample.setCurrentModelIndex(value) } function sampleSetCurrentModelName(value) { activeBackend.sample.setCurrentModelName(value) } + function sampleSetModelNameAtIndex(index, value) { activeBackend.sample.setModelNameAtIndex(index, value) } function sampleRemoveModel(value) { activeBackend.sample.removeModel(value) } function sampleAddNewModel() { activeBackend.sample.addNewModel() } function sampleDuplicateSelectedModel() { activeBackend.sample.duplicateSelectedModel() } @@ -135,6 +139,7 @@ QtObject { function sampleSetCurrentAssemblyIndex(value) { activeBackend.sample.setCurrentAssemblyIndex(value) } function sampleSetCurrentAssemblyName(value) { activeBackend.sample.setCurrentAssemblyName(value) } + function sampleSetAssemblyNameAtIndex(index, value) { activeBackend.sample.setAssemblyNameAtIndex(index, value) } function sampleSetCurrentAssemblyType(value) { activeBackend.sample.setCurrentAssemblyType(value) } function sampleRemoveAssembly(value) { activeBackend.sample.removeAssembly(value) } function sampleAddNewAssembly() { activeBackend.sample.addNewAssembly() } @@ -162,12 +167,19 @@ QtObject { function sampleMoveSelectedLayerDown() { activeBackend.sample.moveSelectedLayerDown() } function sampleSetCurrentLayerFormula(value) { activeBackend.sample.setCurrentLayerFormula(value) } + function sampleSetLayerFormulaAtIndex(index, value) { activeBackend.sample.setLayerFormulaAtIndex(index, value) } function sampleSetCurrentLayerMaterial(value) { activeBackend.sample.setCurrentLayerMaterial(value) } + function sampleSetLayerMaterialAtIndex(index, value) { activeBackend.sample.setLayerMaterialAtIndex(index, value) } function sampleSetCurrentLayerSolvent(value) { activeBackend.sample.setCurrentLayerSolvent(value) } + function sampleSetLayerSolventAtIndex(index, value) { activeBackend.sample.setLayerSolventAtIndex(index, value) } function sampleSetCurrentLayerThickness(value) { activeBackend.sample.setCurrentLayerThickness(value) } + function sampleSetLayerThicknessAtIndex(index, value) { activeBackend.sample.setLayerThicknessAtIndex(index, value) } function sampleSetCurrentLayerRoughness(value) { activeBackend.sample.setCurrentLayerRoughness(value) } + function sampleSetLayerRoughnessAtIndex(index, value) { activeBackend.sample.setLayerRoughnessAtIndex(index, value) } function sampleSetCurrentLayerAPM(value) { activeBackend.sample.setCurrentLayerAPM(value) } + function sampleSetLayerAPMAtIndex(index, value) { activeBackend.sample.setLayerAPMAtIndex(index, value) } function sampleSetCurrentLayerSolvation(value) { activeBackend.sample.setCurrentLayerSolvation(value) } + function sampleSetLayerSolvationAtIndex(index, value) { activeBackend.sample.setLayerSolvationAtIndex(index, value) } // Constraints readonly property var sampleEnabledParameterNames: activeBackend.sample.enabledParameterNames diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml index 8697204f..19448059 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml @@ -61,7 +61,7 @@ EaElements.GroupColumn { horizontalAlignment: Text.AlignLeft model: Globals.BackendWrapper.sampleMaterialNames onActivated: { - Globals.BackendWrapper.sampleSetCurrentLayerMaterial(currentIndex) + Globals.BackendWrapper.sampleSetLayerMaterialAtIndex(index, currentIndex) } onModelChanged: { currentIndex = indexOfValue(Globals.BackendWrapper.sampleLayers[index].material) @@ -79,7 +79,7 @@ EaElements.GroupColumn { enabled: Globals.BackendWrapper.sampleLayers[index].thickness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].thickness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].thickness).toFixed(2) onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerThickness(text) + onEditingFinished: Globals.BackendWrapper.sampleSetLayerThicknessAtIndex(index, text) } EaComponents.TableViewTextInput { @@ -87,7 +87,7 @@ EaElements.GroupColumn { enabled: Globals.BackendWrapper.sampleLayers[index].roughness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].roughness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].roughness).toFixed(2) onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerRoughness(text) + onEditingFinished: Globals.BackendWrapper.sampleSetLayerRoughnessAtIndex(index, text) } EaComponents.TableViewButton { diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml index 231e02f1..e2cd7278 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml @@ -61,7 +61,7 @@ EaElements.GroupColumn { horizontalAlignment: Text.AlignHCenter text: Globals.BackendWrapper.sampleLayers[index].formula onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerFormula(text) + onEditingFinished: Globals.BackendWrapper.sampleSetLayerFormulaAtIndex(index, text) } EaComponents.TableViewTextInput { @@ -69,7 +69,7 @@ EaElements.GroupColumn { enabled: Globals.BackendWrapper.sampleLayers[index].thickness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].thickness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].thickness).toFixed(2) onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerThickness(text) + onEditingFinished: Globals.BackendWrapper.sampleSetLayerThicknessAtIndex(index, text) } EaComponents.TableViewTextInput { @@ -77,14 +77,14 @@ EaElements.GroupColumn { enabled: Globals.BackendWrapper.sampleLayers[index].roughness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].roughness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].roughness).toFixed(2) onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerRoughness(text) + onEditingFinished: Globals.BackendWrapper.sampleSetLayerRoughnessAtIndex(index, text) } EaComponents.TableViewTextInput { horizontalAlignment: Text.AlignHCenter text: (isNaN(Globals.BackendWrapper.sampleLayers[index].solvation)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].solvation).toFixed(2) onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerSolvation(text) + onEditingFinished: Globals.BackendWrapper.sampleSetLayerSolvationAtIndex(index, text) } EaComponents.TableViewTextInput { @@ -92,7 +92,7 @@ EaElements.GroupColumn { enabled: Globals.BackendWrapper.sampleLayers[index].apm_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].apm)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].apm).toFixed(2) onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerAPM(text) + onEditingFinished: Globals.BackendWrapper.sampleSetLayerAPMAtIndex(index, text) } EaComponents.TableViewComboBox{ @@ -100,7 +100,7 @@ EaElements.GroupColumn { horizontalAlignment: Text.AlignLeft model: Globals.BackendWrapper.sampleMaterialNames onActivated: { - Globals.BackendWrapper.sampleSetCurrentLayerSolvent(currentIndex) + Globals.BackendWrapper.sampleSetLayerSolventAtIndex(index, currentIndex) } onModelChanged: { currentIndex = indexOfValue(Globals.BackendWrapper.sampleLayers[index].solvent) diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml index f3691ff7..fa9af094 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml @@ -63,17 +63,17 @@ EaElements.GroupBox { EaComponents.TableViewTextInput { text: Globals.BackendWrapper.sampleMaterials[index].label - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentMaterialName(text) + onEditingFinished: Globals.BackendWrapper.sampleSetMaterialNameAtIndex(index, text) } EaComponents.TableViewTextInput { text: Number(Globals.BackendWrapper.sampleMaterials[index].sld).toFixed(3) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentMaterialSld(text) + onEditingFinished: Globals.BackendWrapper.sampleSetMaterialSldAtIndex(index, text) } EaComponents.TableViewTextInput { text: Number(Globals.BackendWrapper.sampleMaterials[index].isld).toFixed(3) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentMaterialISld(text) + onEditingFinished: Globals.BackendWrapper.sampleSetMaterialISldAtIndex(index, text) } EaComponents.TableViewButton { diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml index 84f909e7..d63372f6 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml @@ -64,7 +64,7 @@ EaElements.GroupBox { EaComponents.TableViewTextInput { horizontalAlignment: Text.AlignLeft text: Globals.BackendWrapper.sampleAssemblies[index].label - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentAssemblyName(text) + onEditingFinished: Globals.BackendWrapper.sampleSetAssemblyNameAtIndex(index, text) } EaComponents.TableViewComboBox{ diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelSelector.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelSelector.qml index 20a7e792..45b630b5 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelSelector.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelSelector.qml @@ -54,7 +54,7 @@ EaElements.GroupBox { EaComponents.TableViewTextInput { horizontalAlignment: Text.AlignLeft text: Globals.BackendWrapper.sampleModels[index].label - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentModelName(text) + onEditingFinished: Globals.BackendWrapper.sampleSetModelNameAtIndex(index, text) } EaComponents.TableViewButton { diff --git a/tests/test_logic_layers.py b/tests/test_logic_layers.py index ff69bcb7..337035a3 100644 --- a/tests/test_logic_layers.py +++ b/tests/test_logic_layers.py @@ -99,3 +99,66 @@ def test_layers_move_duplicate_and_setters_update_current_layer(monkeypatch): logic.remove_at_index('2') assert len(logic._layers) == 2 + + +def test_layers_index_based_setters_update_target_row_even_when_current_index_differs(monkeypatch): + monkeypatch.setattr(layers_module, 'Material', make_material) + monkeypatch.setattr(layers_module, 'LayerAreaPerMolecule', FakeLayerAreaPerMolecule) + + air = make_material('Air') + si = make_material('Si') + d2o = make_material('D2O') + materials = make_material_collection(air, si, d2o) + + surfactant_layer = FakeLayerAreaPerMolecule( + name='Headgroup', + material=air, + thickness=11.0, + roughness=2.0, + solvent=d2o, + area_per_molecule=44.0, + solvent_fraction=0.35, + molecular_formula='C12H25', + ) + + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(name='Top Layer', material=air)]), + make_assembly( + name='Middle', + layers=[ + make_layer(name='Layer A', material=air, thickness=10.0, roughness=1.0), + surfactant_layer, + ], + ), + make_assembly(name='Bottom', layers=[make_layer(name='Bottom Layer', material=si)]), + ) + model = make_model(sample=sample) + project = make_project(materials=materials, models=make_model_collection(model)) + project.current_assembly_index = 1 + project.current_layer_index = 1 + logic = layers_module.Layers(project) + + assert logic.set_thickness_at_index(0, 15.0) is True + assert logic._layers[0].thickness.value == 15.0 + assert logic._layers[1].thickness.value == 11.0 + + assert logic.set_roughness_at_index(0, 3.5) is True + assert logic._layers[0].roughness.value == 3.5 + assert logic._layers[1].roughness.value == 2.0 + + assert logic.set_material_at_index(0, 1) is True + assert logic._layers[0].material.name == 'Si' + assert logic._layers[0].name == 'Si Layer' + assert logic._layers[1].material.name == 'Air' + + assert logic.set_formula_at_index(1, 'C10H21') is True + assert logic._layers[1].molecular_formula == 'C10H21' + + assert logic.set_solvation_at_index(1, 0.5) is True + assert logic._layers[1].solvent_fraction == 0.5 + + assert logic.set_apm_at_index(1, 55.0) is True + assert logic._layers[1].area_per_molecule == 55.0 + + assert logic.set_solvent_at_index(1, 0) is True + assert logic._layers[1].solvent.name == 'Air' From 6137c009bb0a663cb9fe4d480dddd77517786872 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Thu, 16 Apr 2026 15:00:52 +0200 Subject: [PATCH 03/14] PR review fixes --- .../Backends/Py/logic/assemblies.py | 13 +++++---- EasyReflectometryApp/Backends/Py/sample.py | 9 +++++++ .../Gui/Globals/BackendWrapper.qml | 1 + .../Sidebar/Basic/Groups/ModelEditor.qml | 2 +- tests/test_logic_assemblies.py | 27 +++++++++++++++++++ 5 files changed, 46 insertions(+), 6 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/logic/assemblies.py b/EasyReflectometryApp/Backends/Py/logic/assemblies.py index c02343d5..31180b83 100644 --- a/EasyReflectometryApp/Backends/Py/logic/assemblies.py +++ b/EasyReflectometryApp/Backends/Py/logic/assemblies.py @@ -73,15 +73,18 @@ def set_name_at_index(self, index: int, new_value: str) -> bool: return False def set_type_at_current_index(self, new_value: str) -> bool: - if new_value == self._assemblies[self.index].type: + return self.set_type_at_index(self.index, new_value) + + def set_type_at_index(self, index: int, new_value: str) -> bool: + if new_value == self._assemblies[index].type: return False if new_value == 'Multi-layer': new_assembly = Multilayer() - new_assembly.layers[0].material = self._assemblies[self.index].layers.data[0].material + new_assembly.layers[0].material = self._assemblies[index].layers.data[0].material elif new_value == 'Repeating Multi-layer': new_assembly = RepeatingMultilayer(repetitions=1, name=new_value) - new_assembly.layers[0].material = self._assemblies[self.index].layers.data[0].material + new_assembly.layers[0].material = self._assemblies[index].layers.data[0].material elif new_value == 'Surfactant Layer': index_air = self._project_lib.get_index_air() index_d2o = self._project_lib.get_index_d2o() @@ -90,9 +93,9 @@ def set_type_at_current_index(self, new_value: str) -> bool: new_assembly.layers[1].solvent = self._project_lib._materials[index_d2o] if new_assembly.name is None: - new_assembly.name = self._assemblies[self.index].name + new_assembly.name = self._assemblies[index].name - self._assemblies[self.index] = new_assembly + self._assemblies[index] = new_assembly return True # Only for repeating multilayer diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 5c5f6a31..5122d13d 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -306,6 +306,15 @@ def setCurrentAssemblyType(self, new_value: str) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, str) + def setAssemblyTypeAtIndex(self, index: int, new_value: str) -> None: + if self._assemblies_logic.set_type_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + self.assembliesTableChanged.emit() + self.assembliesIndexChanged.emit() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + # Assembly specific @Property(str, notify=assembliesTableChanged) def currentAssemblyRepeatedLayerReptitions(self) -> str: diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index f1764f72..e77da1f1 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -141,6 +141,7 @@ QtObject { function sampleSetCurrentAssemblyName(value) { activeBackend.sample.setCurrentAssemblyName(value) } function sampleSetAssemblyNameAtIndex(index, value) { activeBackend.sample.setAssemblyNameAtIndex(index, value) } function sampleSetCurrentAssemblyType(value) { activeBackend.sample.setCurrentAssemblyType(value) } + function sampleSetAssemblyTypeAtIndex(index, value) { activeBackend.sample.setAssemblyTypeAtIndex(index, value) } function sampleRemoveAssembly(value) { activeBackend.sample.removeAssembly(value) } function sampleAddNewAssembly() { activeBackend.sample.addNewAssembly() } function sampleDuplicateSelectedAssembly() { activeBackend.sample.duplicateSelectedAssembly() } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml index d63372f6..e1f693bf 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml @@ -73,7 +73,7 @@ EaElements.GroupBox { property var limitedModel: ["Multi-layer", "Repeating Multi-layer"] model: index === 0 || index === assembliesView.model - 1 ? limitedModel : fullModel onActivated: { - Globals.BackendWrapper.sampleSetCurrentAssemblyType(currentValue) + Globals.BackendWrapper.sampleSetAssemblyTypeAtIndex(index, currentValue) } Component.onCompleted: { currentIndex = indexOfValue(Globals.BackendWrapper.sampleAssemblies[index].type) diff --git a/tests/test_logic_assemblies.py b/tests/test_logic_assemblies.py index 2926bc25..27d73d69 100644 --- a/tests/test_logic_assemblies.py +++ b/tests/test_logic_assemblies.py @@ -79,3 +79,30 @@ def test_assemblies_duplicate_move_and_remove(monkeypatch): logic.remove_at_index('2') assert len(logic._assemblies) == 2 + + +def test_assemblies_set_type_at_index_updates_target_row_when_current_index_differs(monkeypatch): + monkeypatch.setattr(assemblies_module, 'Multilayer', FakeMultilayer) + monkeypatch.setattr(assemblies_module, 'RepeatingMultilayer', FakeRepeatingMultilayer) + monkeypatch.setattr(assemblies_module, 'SurfactantLayer', FakeSurfactantLayer) + + materials = make_material_collection(make_material('Air'), make_material('Si'), make_material('D2O')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(name='Top Layer', material=materials[0])]), + make_assembly(name='Middle', layers=[make_layer(name='Middle Layer', material=materials[1])]), + make_assembly(name='Bottom', layers=[make_layer(name='Bottom Layer', material=materials[1])]), + ) + model = make_model(sample=sample) + project = make_project(materials=materials, models=make_model_collection(model)) + project.current_assembly_index = 2 + logic = assemblies_module.Assemblies(project) + + assert logic.set_type_at_index(1, 'Surfactant Layer') is True + + assert isinstance(logic._assemblies[1], FakeSurfactantLayer) + assert logic._assemblies[1].layers[0].solvent.name == 'Air' + assert logic._assemblies[1].layers[1].solvent.name == 'D2O' + + assert isinstance(logic._assemblies[2], FakeMultilayer) + assert logic._assemblies[2].name == 'Bottom' + assert project.current_assembly_index == 2 From 71393a6955245b26ded8f7a5400b14b71106cb34 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Thu, 16 Apr 2026 15:18:58 +0200 Subject: [PATCH 04/14] fixed treatment of multilayers --- .../Backends/Py/logic/assemblies.py | 9 +++++++ .../Backends/Py/logic/layers.py | 22 ++++++++++++++++ .../Basic/Groups/Assemblies/MultiLayer.qml | 5 ++-- .../Groups/Assemblies/SurfactantLayer.qml | 5 ++-- .../Sidebar/Basic/Groups/ModelEditor.qml | 5 ++-- tests/test_logic_assemblies.py | 22 ++++++++++++++++ tests/test_logic_layers.py | 25 +++++++++++++++++++ 7 files changed, 87 insertions(+), 6 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/logic/assemblies.py b/EasyReflectometryApp/Backends/Py/logic/assemblies.py index 31180b83..082ea20c 100644 --- a/EasyReflectometryApp/Backends/Py/logic/assemblies.py +++ b/EasyReflectometryApp/Backends/Py/logic/assemblies.py @@ -11,6 +11,9 @@ class Assemblies: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib + def _has_valid_assembly_index(self, index: int) -> bool: + return 0 <= index < len(self._assemblies) + @property def _assemblies(self) -> Sample: return self._project_lib._models[self._project_lib.current_model_index].sample # Sample is a collection of assemblies @@ -67,6 +70,8 @@ def set_name_at_current_index(self, new_value: str) -> bool: return False def set_name_at_index(self, index: int, new_value: str) -> bool: + if not self._has_valid_assembly_index(index): + return False if self._assemblies[index].name != new_value: self._assemblies[index].name = new_value return True @@ -76,6 +81,8 @@ def set_type_at_current_index(self, new_value: str) -> bool: return self.set_type_at_index(self.index, new_value) def set_type_at_index(self, index: int, new_value: str) -> bool: + if not self._has_valid_assembly_index(index): + return False if new_value == self._assemblies[index].type: return False @@ -91,6 +98,8 @@ def set_type_at_index(self, index: int, new_value: str) -> bool: new_assembly = SurfactantLayer() new_assembly.layers[0].solvent = self._project_lib._materials[index_air] new_assembly.layers[1].solvent = self._project_lib._materials[index_d2o] + else: + return False if new_assembly.name is None: new_assembly.name = self._assemblies[index].name diff --git a/EasyReflectometryApp/Backends/Py/logic/layers.py b/EasyReflectometryApp/Backends/Py/logic/layers.py index 55913c84..3b0c3220 100644 --- a/EasyReflectometryApp/Backends/Py/logic/layers.py +++ b/EasyReflectometryApp/Backends/Py/logic/layers.py @@ -11,6 +11,12 @@ class Layers: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib + def _has_valid_layer_index(self, index: int) -> bool: + return 0 <= index < len(self._layers) + + def _has_valid_material_index(self, index: int) -> bool: + return 0 <= index < len(self._project_lib._materials) + @property def _sample(self) -> Sample: return self._project_lib._models[self._project_lib.current_model_index].sample @@ -83,6 +89,8 @@ def set_name_at_current_index(self, new_value: str) -> bool: return False def set_name_at_index(self, index: int, new_value: str) -> bool: + if not self._has_valid_layer_index(index): + return False if self._layers[index].name != new_value: self._layers[index].name = new_value return True @@ -95,6 +103,8 @@ def set_thickness_at_current_index(self, new_value: float) -> bool: return False def set_thickness_at_index(self, index: int, new_value: float) -> bool: + if not self._has_valid_layer_index(index): + return False if self._layers[index].thickness.value != new_value: self._layers[index].thickness.value = new_value return True @@ -107,6 +117,8 @@ def set_roughness_at_current_index(self, new_value: float) -> bool: return False def set_roughness_at_index(self, index: int, new_value: float) -> bool: + if not self._has_valid_layer_index(index): + return False if self._layers[index].roughness.value != new_value: self._layers[index].roughness.value = new_value return True @@ -121,6 +133,8 @@ def set_material_at_current_index(self, new_value: int) -> bool: return False def set_material_at_index(self, index: int, new_value: int) -> bool: + if not self._has_valid_layer_index(index) or not self._has_valid_material_index(new_value): + return False if self._layers[index].material != self._project_lib._materials[new_value]: self._layers[index].material = self._project_lib._materials[new_value] self._layers[index].name = self._project_lib._materials[new_value].name + ' Layer' @@ -134,6 +148,8 @@ def set_solvent_at_current_index(self, new_value: int) -> bool: return False def set_solvent_at_index(self, index: int, new_value: int) -> bool: + if not self._has_valid_layer_index(index) or not self._has_valid_material_index(new_value): + return False if self._layers[index].solvent != self._project_lib._materials[new_value]: self._layers[index].solvent = self._project_lib._materials[new_value] return True @@ -146,6 +162,8 @@ def set_apm_at_current_index(self, new_value: float) -> bool: return False def set_apm_at_index(self, index: int, new_value: float) -> bool: + if not self._has_valid_layer_index(index): + return False if self._layers[index].area_per_molecule != new_value: self._layers[index].area_per_molecule = new_value return True @@ -158,6 +176,8 @@ def set_solvation_at_current_index(self, new_value: float) -> bool: return False def set_solvation_at_index(self, index: int, new_value: float) -> bool: + if not self._has_valid_layer_index(index): + return False if self._layers[index].solvent_fraction != new_value: self._layers[index].solvent_fraction = new_value return True @@ -170,6 +190,8 @@ def set_formula(self, new_value: str) -> bool: return False def set_formula_at_index(self, index: int, new_value: str) -> bool: + if not self._has_valid_layer_index(index): + return False if self._layers[index].molecular_formula != new_value: self._layers[index].molecular_formula = new_value return True diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml index 19448059..4edb0679 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml @@ -57,11 +57,12 @@ EaElements.GroupColumn { } EaComponents.TableViewComboBox{ + readonly property int rowIndex: index property string currentAssemblyName: Globals.BackendWrapper.sampleCurrentAssemblyName horizontalAlignment: Text.AlignLeft model: Globals.BackendWrapper.sampleMaterialNames - onActivated: { - Globals.BackendWrapper.sampleSetLayerMaterialAtIndex(index, currentIndex) + onActivated: function(comboIndex) { + Globals.BackendWrapper.sampleSetLayerMaterialAtIndex(rowIndex, comboIndex) } onModelChanged: { currentIndex = indexOfValue(Globals.BackendWrapper.sampleLayers[index].material) diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml index e2cd7278..50b50790 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml @@ -96,11 +96,12 @@ EaElements.GroupColumn { } EaComponents.TableViewComboBox{ + readonly property int rowIndex: index property string currentAssemblyName: Globals.BackendWrapper.sampleCurrentAssemblyName horizontalAlignment: Text.AlignLeft model: Globals.BackendWrapper.sampleMaterialNames - onActivated: { - Globals.BackendWrapper.sampleSetLayerSolventAtIndex(index, currentIndex) + onActivated: function(comboIndex) { + Globals.BackendWrapper.sampleSetLayerSolventAtIndex(rowIndex, comboIndex) } onModelChanged: { currentIndex = indexOfValue(Globals.BackendWrapper.sampleLayers[index].solvent) diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml index e1f693bf..947a3d21 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml @@ -68,12 +68,13 @@ EaElements.GroupBox { } EaComponents.TableViewComboBox{ + readonly property int rowIndex: index horizontalAlignment: Text.AlignLeft property var fullModel: ["Multi-layer", "Repeating Multi-layer", "Surfactant Layer"] property var limitedModel: ["Multi-layer", "Repeating Multi-layer"] model: index === 0 || index === assembliesView.model - 1 ? limitedModel : fullModel - onActivated: { - Globals.BackendWrapper.sampleSetAssemblyTypeAtIndex(index, currentValue) + onActivated: function(comboIndex) { + Globals.BackendWrapper.sampleSetAssemblyTypeAtIndex(rowIndex, model[comboIndex]) } Component.onCompleted: { currentIndex = indexOfValue(Globals.BackendWrapper.sampleAssemblies[index].type) diff --git a/tests/test_logic_assemblies.py b/tests/test_logic_assemblies.py index 27d73d69..b30a6958 100644 --- a/tests/test_logic_assemblies.py +++ b/tests/test_logic_assemblies.py @@ -106,3 +106,25 @@ def test_assemblies_set_type_at_index_updates_target_row_when_current_index_diff assert isinstance(logic._assemblies[2], FakeMultilayer) assert logic._assemblies[2].name == 'Bottom' assert project.current_assembly_index == 2 + + +def test_assemblies_index_based_setters_ignore_invalid_indices(monkeypatch): + monkeypatch.setattr(assemblies_module, 'Multilayer', FakeMultilayer) + monkeypatch.setattr(assemblies_module, 'RepeatingMultilayer', FakeRepeatingMultilayer) + monkeypatch.setattr(assemblies_module, 'SurfactantLayer', FakeSurfactantLayer) + + materials = make_material_collection(make_material('Air'), make_material('Si'), make_material('D2O')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(name='Top Layer', material=materials[0])]), + make_assembly(name='Middle', layers=[make_layer(name='Middle Layer', material=materials[1])]), + ) + model = make_model(sample=sample) + project = make_project(materials=materials, models=make_model_collection(model)) + logic = assemblies_module.Assemblies(project) + + assert logic.set_name_at_index(5, 'Ignored') is False + assert logic.set_type_at_index(4, 'Surfactant Layer') is False + assert logic.set_type_at_index(0, 'Unsupported') is False + + assert logic._assemblies[0].name == 'Top' + assert isinstance(logic._assemblies[0], FakeMultilayer) diff --git a/tests/test_logic_layers.py b/tests/test_logic_layers.py index 337035a3..aa2c8617 100644 --- a/tests/test_logic_layers.py +++ b/tests/test_logic_layers.py @@ -162,3 +162,28 @@ def test_layers_index_based_setters_update_target_row_even_when_current_index_di assert logic.set_solvent_at_index(1, 0) is True assert logic._layers[1].solvent.name == 'Air' + + +def test_layers_index_based_setters_ignore_invalid_indices(monkeypatch): + monkeypatch.setattr(layers_module, 'Material', make_material) + + air = make_material('Air') + si = make_material('Si') + materials = make_material_collection(air, si) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(name='Top Layer', material=air)]), + make_assembly(name='Middle', layers=[make_layer(name='Layer A', material=air, thickness=10.0, roughness=1.0)]), + make_assembly(name='Bottom', layers=[make_layer(name='Bottom Layer', material=si)]), + ) + model = make_model(sample=sample) + project = make_project(materials=materials, models=make_model_collection(model)) + project.current_assembly_index = 1 + logic = layers_module.Layers(project) + + assert logic.set_material_at_index(3, 1) is False + assert logic.set_material_at_index(0, 5) is False + assert logic.set_solvent_at_index(2, 0) is False + assert logic.set_thickness_at_index(4, 12.0) is False + + assert logic._layers[0].material.name == 'Air' + assert logic._layers[0].thickness.value == 10.0 From 2b3f965624793e00b33a9db8db622dc7c4a11ad4 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Thu, 16 Apr 2026 17:51:59 +0200 Subject: [PATCH 05/14] duplicate/add layer should not append after subphase --- .../Backends/Py/logic/assemblies.py | 33 ++++++++- EasyReflectometryApp/Backends/Py/sample.py | 4 ++ tests/test_logic_assemblies.py | 67 +++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/EasyReflectometryApp/Backends/Py/logic/assemblies.py b/EasyReflectometryApp/Backends/Py/logic/assemblies.py index 082ea20c..03918519 100644 --- a/EasyReflectometryApp/Backends/Py/logic/assemblies.py +++ b/EasyReflectometryApp/Backends/Py/logic/assemblies.py @@ -14,6 +14,26 @@ def __init__(self, project_lib: ProjectLib): def _has_valid_assembly_index(self, index: int) -> bool: return 0 <= index < len(self._assemblies) + def _target_insert_index(self, current_index: int, previous_length: int) -> int: + if previous_length <= 1: + return previous_length + return min(current_index + 1, previous_length - 1) + + def _move_new_assembly_into_position(self, existing_ids: set[int], target_index: int) -> int | None: + new_index = next((idx for idx, assembly in enumerate(self._assemblies) if id(assembly) not in existing_ids), None) + if new_index is None: + return None + + while new_index > target_index: + self._assemblies.move_up(new_index) + new_index -= 1 + + while new_index < target_index: + self._assemblies.move_down(new_index) + new_index += 1 + + return new_index + @property def _assemblies(self) -> Sample: return self._project_lib._models[self._project_lib.current_model_index].sample # Sample is a collection of assemblies @@ -46,12 +66,23 @@ def remove_at_index(self, value: str) -> None: self._assemblies.remove_assembly(int(value)) def add_new(self) -> None: + previous_length = len(self._assemblies) + target_index = self._target_insert_index(self.index, previous_length) + existing_ids = {id(assembly) for assembly in self._assemblies} self._assemblies.add_assembly() + new_index = self._move_new_assembly_into_position(existing_ids, target_index) index_si = self._project_lib.get_index_si() - self._assemblies[-1].layers[0].material = self._project_lib._materials[index_si] + if new_index is not None: + self._assemblies[new_index].layers[0].material = self._project_lib._materials[index_si] def duplicate_selected(self) -> None: + if not self._has_valid_assembly_index(self.index): + return + previous_length = len(self._assemblies) + target_index = self._target_insert_index(self.index, previous_length) + existing_ids = {id(assembly) for assembly in self._assemblies} self._assemblies.duplicate_assembly(self.index) + self._move_new_assembly_into_position(existing_ids, target_index) def move_selected_up(self) -> None: if self.index > 0: diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 5122d13d..5d23f065 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -351,14 +351,18 @@ def removeAssembly(self, value: str) -> None: @Slot() def addNewAssembly(self) -> None: self._assemblies_logic.add_new() + self._clearCacheAndEmitLayersChanged() self.assembliesTableChanged.emit() + self.assembliesIndexChanged.emit() self.externalRefreshPlot.emit() self.externalSampleChanged.emit() @Slot() def duplicateSelectedAssembly(self) -> None: self._assemblies_logic.duplicate_selected() + self._clearCacheAndEmitLayersChanged() self.assembliesTableChanged.emit() + self.assembliesIndexChanged.emit() self.externalRefreshPlot.emit() self.externalSampleChanged.emit() diff --git a/tests/test_logic_assemblies.py b/tests/test_logic_assemblies.py index b30a6958..09b77105 100644 --- a/tests/test_logic_assemblies.py +++ b/tests/test_logic_assemblies.py @@ -59,6 +59,73 @@ def test_assemblies_add_new_and_type_transitions(monkeypatch): assert logic.set_conformal_roughness('True') is True +def test_assemblies_add_new_inserts_after_current_row_and_keeps_subphase_last(monkeypatch): + monkeypatch.setattr(assemblies_module, 'Multilayer', FakeMultilayer) + monkeypatch.setattr(assemblies_module, 'RepeatingMultilayer', FakeRepeatingMultilayer) + monkeypatch.setattr(assemblies_module, 'SurfactantLayer', FakeSurfactantLayer) + + materials = make_material_collection(make_material('Air'), make_material('Si'), make_material('D2O')) + sample = make_sample( + make_assembly(name='Superphase', layers=[make_layer(name='Top Layer', material=materials[0])]), + make_assembly(name='Middle', layers=[make_layer(name='Middle Layer', material=materials[0])]), + make_assembly(name='Subphase', layers=[make_layer(name='Bottom Layer', material=materials[1])]), + ) + model = make_model(sample=sample) + project = make_project(materials=materials, models=make_model_collection(model)) + project.current_assembly_index = 1 + logic = assemblies_module.Assemblies(project) + + logic.add_new() + + assert [assembly.name for assembly in logic._assemblies] == ['Superphase', 'Middle', 'Assembly 4', 'Subphase'] + assert logic._assemblies[2].layers[0].material.name == 'Si' + + +def test_assemblies_duplicate_selected_inserts_after_current_row_and_keeps_subphase_last(monkeypatch): + monkeypatch.setattr(assemblies_module, 'Multilayer', FakeMultilayer) + monkeypatch.setattr(assemblies_module, 'RepeatingMultilayer', FakeRepeatingMultilayer) + monkeypatch.setattr(assemblies_module, 'SurfactantLayer', FakeSurfactantLayer) + + sample = make_sample( + make_assembly(name='Superphase', layers=[make_layer(name='Top Layer')]), + make_assembly(name='Middle', layers=[make_layer(name='Middle Layer')]), + make_assembly(name='Subphase', layers=[make_layer(name='Bottom Layer')]), + ) + model = make_model(sample=sample) + project = make_project(models=make_model_collection(model)) + project.current_assembly_index = 1 + logic = assemblies_module.Assemblies(project) + + logic.duplicate_selected() + + assert [assembly.name for assembly in logic._assemblies] == ['Superphase', 'Middle', 'Middle', 'Subphase'] + + +def test_assemblies_add_and_duplicate_selected_subphase_insert_before_last(monkeypatch): + monkeypatch.setattr(assemblies_module, 'Multilayer', FakeMultilayer) + monkeypatch.setattr(assemblies_module, 'RepeatingMultilayer', FakeRepeatingMultilayer) + monkeypatch.setattr(assemblies_module, 'SurfactantLayer', FakeSurfactantLayer) + + materials = make_material_collection(make_material('Air'), make_material('Si'), make_material('D2O')) + sample = make_sample( + make_assembly(name='Superphase', layers=[make_layer(name='Top Layer', material=materials[0])]), + make_assembly(name='Middle', layers=[make_layer(name='Middle Layer', material=materials[0])]), + make_assembly(name='Subphase', layers=[make_layer(name='Bottom Layer', material=materials[1])]), + ) + model = make_model(sample=sample) + project = make_project(materials=materials, models=make_model_collection(model)) + project.current_assembly_index = 2 + logic = assemblies_module.Assemblies(project) + + logic.add_new() + assert [assembly.name for assembly in logic._assemblies] == ['Superphase', 'Middle', 'Assembly 4', 'Subphase'] + assert logic._assemblies[-1].name == 'Subphase' + + logic.duplicate_selected() + assert [assembly.name for assembly in logic._assemblies] == ['Superphase', 'Middle', 'Assembly 4', 'Assembly 4', 'Subphase'] + assert logic._assemblies[-1].name == 'Subphase' + + def test_assemblies_duplicate_move_and_remove(monkeypatch): monkeypatch.setattr(assemblies_module, 'Multilayer', FakeMultilayer) monkeypatch.setattr(assemblies_module, 'RepeatingMultilayer', FakeRepeatingMultilayer) From 2adae91a8db954322441ea54e8764d10c24b34c6 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Thu, 16 Apr 2026 19:15:32 +0200 Subject: [PATCH 06/14] force collapse explicitly --- .../Gui/Pages/Sample/Sidebar/Basic/Layout.qml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml index 5db90142..84e1e885 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml @@ -8,16 +8,20 @@ import "./Groups" as Groups EaComponents.SideBarColumn { Groups.LoadSample{ + forceAutoCollapse: true enabled: Globals.BackendWrapper.analysisIsFitFinished } Groups.MaterialEditor{ + forceAutoCollapse: true enabled: Globals.BackendWrapper.analysisIsFitFinished } Groups.ModelSelector{ + forceAutoCollapse: true enabled: Globals.BackendWrapper.analysisIsFitFinished } Groups.ModelEditor { id: modelEditor + forceAutoCollapse: true enabled: Globals.BackendWrapper.analysisIsFitFinished } } From aa2390f5a36bb248cd8d092d1d7883d87a81d2ec Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Thu, 16 Apr 2026 21:08:50 +0200 Subject: [PATCH 07/14] fix for assembly removal (+test) --- EasyReflectometryApp/Backends/Py/sample.py | 40 ++++++++++++++++------ 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 5d23f065..5bc944d4 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -275,13 +275,34 @@ def currentAssemblyName(self) -> str: def currentAssemblyType(self) -> str: return self._assemblies_logic.type_at_current_index + def _refreshCurrentAssemblySelectionState(self) -> None: + sample = self._project_lib._models[self._project_lib.current_model_index].sample + assembly_count = len(sample) + + if assembly_count == 0: + self._project_lib.current_assembly_index = 0 + self._project_lib.current_layer_index = 0 + else: + self._project_lib.current_assembly_index = max( + 0, + min(self._project_lib.current_assembly_index, assembly_count - 1), + ) + layer_count = len(sample[self._project_lib.current_assembly_index].layers) + self._project_lib.current_layer_index = max( + 0, + min(self._project_lib.current_layer_index, max(layer_count - 1, 0)), + ) + + self._clearCacheAndEmitLayersChanged() + self.assembliesIndexChanged.emit() + self.layersIndexChanged.emit() + # Setters @Slot(int) def setCurrentAssemblyIndex(self, new_value: int) -> None: self._project_lib.current_assembly_index = new_value - self._clearCacheAndEmitLayersChanged() + self._refreshCurrentAssemblySelectionState() self.assembliesTableChanged.emit() - self.assembliesIndexChanged.emit() @Slot(str) def setCurrentAssemblyName(self, new_value: str) -> None: @@ -300,18 +321,16 @@ def setAssemblyNameAtIndex(self, index: int, new_value: str) -> None: @Slot(str) def setCurrentAssemblyType(self, new_value: str) -> None: self._assemblies_logic.set_type_at_current_index(new_value) - self._clearCacheAndEmitLayersChanged() + self._refreshCurrentAssemblySelectionState() self.assembliesTableChanged.emit() - self.assembliesIndexChanged.emit() self.externalRefreshPlot.emit() self.externalSampleChanged.emit() @Slot(int, str) def setAssemblyTypeAtIndex(self, index: int, new_value: str) -> None: if self._assemblies_logic.set_type_at_index(index, new_value): - self._clearCacheAndEmitLayersChanged() + self._refreshCurrentAssemblySelectionState() self.assembliesTableChanged.emit() - self.assembliesIndexChanged.emit() self.externalRefreshPlot.emit() self.externalSampleChanged.emit() @@ -344,6 +363,7 @@ def setCurrentAssemblyConformalRoughness(self, new_value: bool) -> None: @Slot(str) def removeAssembly(self, value: str) -> None: self._assemblies_logic.remove_at_index(value) + self._refreshCurrentAssemblySelectionState() self.assembliesTableChanged.emit() self.externalRefreshPlot.emit() self.externalSampleChanged.emit() @@ -351,30 +371,30 @@ def removeAssembly(self, value: str) -> None: @Slot() def addNewAssembly(self) -> None: self._assemblies_logic.add_new() - self._clearCacheAndEmitLayersChanged() + self._refreshCurrentAssemblySelectionState() self.assembliesTableChanged.emit() - self.assembliesIndexChanged.emit() self.externalRefreshPlot.emit() self.externalSampleChanged.emit() @Slot() def duplicateSelectedAssembly(self) -> None: self._assemblies_logic.duplicate_selected() - self._clearCacheAndEmitLayersChanged() + self._refreshCurrentAssemblySelectionState() self.assembliesTableChanged.emit() - self.assembliesIndexChanged.emit() self.externalRefreshPlot.emit() self.externalSampleChanged.emit() @Slot() def moveSelectedAssemblyUp(self) -> None: self._assemblies_logic.move_selected_up() + self._refreshCurrentAssemblySelectionState() self.assembliesTableChanged.emit() self.externalRefreshPlot.emit() @Slot() def moveSelectedAssemblyDown(self) -> None: self._assemblies_logic.move_selected_down() + self._refreshCurrentAssemblySelectionState() self.assembliesTableChanged.emit() self.externalRefreshPlot.emit() From 644201e330913b6b029f74495ec13b3928b55783 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 17 Apr 2026 09:26:50 +0200 Subject: [PATCH 08/14] more PR related fixes --- .../Backends/Py/logic/assemblies.py | 5 +-- .../Backends/Py/logic/material.py | 6 +++ .../Backends/Py/logic/models.py | 2 + EasyReflectometryApp/Backends/Py/sample.py | 10 ++--- .../Sidebar/Basic/Groups/AssemblyEditor.qml | 29 ------------- tests/test_py_sample.py | 42 +++++++++++++++++++ 6 files changed, 56 insertions(+), 38 deletions(-) delete mode 100644 EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/AssemblyEditor.qml create mode 100644 tests/test_py_sample.py diff --git a/EasyReflectometryApp/Backends/Py/logic/assemblies.py b/EasyReflectometryApp/Backends/Py/logic/assemblies.py index 03918519..27ec3c0b 100644 --- a/EasyReflectometryApp/Backends/Py/logic/assemblies.py +++ b/EasyReflectometryApp/Backends/Py/logic/assemblies.py @@ -95,10 +95,7 @@ def move_selected_down(self) -> None: self.index = self.index + 1 def set_name_at_current_index(self, new_value: str) -> bool: - if self._assemblies[self.index].name != new_value: - self._assemblies[self.index].name = new_value - return True - return False + return self.set_name_at_index(self.index, new_value) def set_name_at_index(self, index: int, new_value: str) -> bool: if not self._has_valid_assembly_index(index): diff --git a/EasyReflectometryApp/Backends/Py/logic/material.py b/EasyReflectometryApp/Backends/Py/logic/material.py index ca480a0a..d5ba815c 100644 --- a/EasyReflectometryApp/Backends/Py/logic/material.py +++ b/EasyReflectometryApp/Backends/Py/logic/material.py @@ -58,6 +58,8 @@ def set_name_at_current_index(self, new_value: str) -> bool: return False def set_name_at_index(self, index: int, new_value: str) -> bool: + if not (0 <= index < len(self._materials)): + return False if self._materials[index].name != new_value: self._materials[index].name = new_value return True @@ -70,6 +72,8 @@ def set_sld_at_current_index(self, new_value: float) -> bool: return False def set_sld_at_index(self, index: int, new_value: float) -> bool: + if not (0 <= index < len(self._materials)): + return False if self._materials[index].sld.value != new_value: self._materials[index].sld.value = new_value return True @@ -82,6 +86,8 @@ def set_isld_at_current_index(self, new_value: float) -> bool: return False def set_isld_at_index(self, index: int, new_value: float) -> bool: + if not (0 <= index < len(self._materials)): + return False if self._materials[index].isld.value != new_value: self._materials[index].isld.value = new_value return True diff --git a/EasyReflectometryApp/Backends/Py/logic/models.py b/EasyReflectometryApp/Backends/Py/logic/models.py index 278ad6f5..b5e22e2d 100644 --- a/EasyReflectometryApp/Backends/Py/logic/models.py +++ b/EasyReflectometryApp/Backends/Py/logic/models.py @@ -58,6 +58,8 @@ def set_name_at_current_index(self, new_value: str) -> bool: return False def set_name_at_index(self, index: int, new_value: str) -> bool: + if not (0 <= index < len(self._models)): + return False if self._models[index].name != new_value: self._models[index].name = new_value return True diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 5bc944d4..d3da25c7 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -320,11 +320,11 @@ def setAssemblyNameAtIndex(self, index: int, new_value: str) -> None: @Slot(str) def setCurrentAssemblyType(self, new_value: str) -> None: - self._assemblies_logic.set_type_at_current_index(new_value) - self._refreshCurrentAssemblySelectionState() - self.assembliesTableChanged.emit() - self.externalRefreshPlot.emit() - self.externalSampleChanged.emit() + if self._assemblies_logic.set_type_at_current_index(new_value): + self._refreshCurrentAssemblySelectionState() + self.assembliesTableChanged.emit() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() @Slot(int, str) def setAssemblyTypeAtIndex(self, index: int, new_value: str) -> None: diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/AssemblyEditor.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/AssemblyEditor.qml deleted file mode 100644 index b7ecb90b..00000000 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/AssemblyEditor.qml +++ /dev/null @@ -1,29 +0,0 @@ -import QtQuick -import QtQuick.Controls - -import EasyApp.Gui.Elements as EaElements - -import Gui.Globals as Globals -import "./Assemblies" as Assemblies - -EaElements.GroupBox { - title: qsTr("Layer editor: " + Globals.BackendWrapper.sampleCurrentAssemblyName) - collapsible: true - collapsed: true - property string currentAssemblyType: Globals.BackendWrapper.sampleCurrentAssemblyType - - EaElements.GroupColumn { - - Assemblies.MultiLayer{ - visible: (currentAssemblyType == 'Multi-layer') ? true : false - } - - Assemblies.RepeatingMultiLayer{ - visible: (currentAssemblyType == 'Repeating Multi-layer') ? true : false - } - - Assemblies.SurfactantLayer { - visible: (currentAssemblyType == 'Surfactant Layer') ? true : false - } - } -} diff --git a/tests/test_py_sample.py b/tests/test_py_sample.py new file mode 100644 index 00000000..3570c366 --- /dev/null +++ b/tests/test_py_sample.py @@ -0,0 +1,42 @@ +from EasyReflectometryApp.Backends.Py.sample import Sample +from tests.factories import make_assembly +from tests.factories import make_layer +from tests.factories import make_material +from tests.factories import make_material_collection +from tests.factories import make_model +from tests.factories import make_model_collection +from tests.factories import make_project +from tests.factories import make_sample + + +def test_remove_selected_assembly_refreshes_cached_layers_and_clamps_layer_index(qcore_application): + materials = make_material_collection(make_material('Air'), make_material('Si'), make_material('D2O')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(name='Top Layer', material=materials[0])]), + make_assembly( + name='Middle', + assembly_type='Surfactant Layer', + layers=[ + make_layer(name='Head Layer', material=materials[0]), + make_layer(name='Tail Layer', material=materials[2]), + ], + ), + make_assembly(name='Bottom', layers=[make_layer(name='Bottom Layer', material=materials[1])]), + ) + project = make_project(materials=materials, models=make_model_collection(make_model(sample=sample))) + project.current_model_index = 0 + project.current_assembly_index = 1 + project.current_layer_index = 1 + + backend = Sample(project) + + assert [layer['label'] for layer in backend.layers] == ['Head Layer', 'Tail Layer'] + assert backend.currentAssemblyType == 'Surfactant Layer' + assert backend.currentLayerIndex == 1 + + backend.removeAssembly('1') + + assert backend.currentAssemblyIndex == 1 + assert backend.currentAssemblyType == 'Multi-layer' + assert backend.currentLayerIndex == 0 + assert [layer['label'] for layer in backend.layers] == ['Bottom Layer'] \ No newline at end of file From 3678a9b69e0985cca91531ab963be4b30826f9a0 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 17 Apr 2026 11:21:49 +0200 Subject: [PATCH 09/14] added two standard icons for docs/issues --- EasyReflectometryApp/Gui/ApplicationWindow.qml | 11 +++++++++++ EasyReflectometryApp/Gui/Globals/ApplicationInfo.qml | 6 ++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/EasyReflectometryApp/Gui/ApplicationWindow.qml b/EasyReflectometryApp/Gui/ApplicationWindow.qml index a190fba9..cc3f89b4 100644 --- a/EasyReflectometryApp/Gui/ApplicationWindow.qml +++ b/EasyReflectometryApp/Gui/ApplicationWindow.qml @@ -51,6 +51,17 @@ EaComponents.ApplicationWindow { fontIcon: "cog" ToolTip.text: qsTr("Application preferences") onClicked: EaGlobals.Vars.showAppPreferencesDialog = true + }, + EaElements.ToolButton { + fontIcon: 'question-circle' + ToolTip.text: qsTr('Get online help') + onClicked: Qt.openUrlExternally(Globals.ApplicationInfo.about.docsUrl) + }, + + EaElements.ToolButton { + fontIcon: 'bug' + ToolTip.text: qsTr('Report a bug or issue') + onClicked: Qt.openUrlExternally(Globals.ApplicationInfo.about.issuesUrl) } ] diff --git a/EasyReflectometryApp/Gui/Globals/ApplicationInfo.qml b/EasyReflectometryApp/Gui/Globals/ApplicationInfo.qml index 38e3644e..73efb6eb 100644 --- a/EasyReflectometryApp/Gui/Globals/ApplicationInfo.qml +++ b/EasyReflectometryApp/Gui/Globals/ApplicationInfo.qml @@ -17,8 +17,10 @@ QtObject { 'url': 'https://ess.eu', 'icon': Qt.resolvedUrl('../Resources/Logo/ESS.png'), 'heightScale': 3.0 - } - ] + }, + ], + 'docsUrl': 'https://easyscience.github.io/EasyReflectometryApp/', + 'issuesUrl': 'https://github.com/easyscience/EasyReflectometryApp/issues' } } From 2f6f43c00a030f6fb204af58848a2e172568ed92 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 17 Apr 2026 11:36:22 +0200 Subject: [PATCH 10/14] can't move subphase and superphase. Can't replace these as well. --- .../Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml index 947a3d21..a10b6314 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml @@ -119,7 +119,7 @@ EaElements.GroupBox { } EaElements.SideBarButton { - enabled: (Globals.BackendWrapper.sampleCurrentAssemblyIndex !== 0 && Globals.BackendWrapper.sampleAssemblies.length > 0 ) ? true : false//When item is selected + enabled: (Globals.BackendWrapper.sampleCurrentAssemblyIndex > 1 && Globals.BackendWrapper.sampleCurrentAssemblyIndex < Globals.BackendWrapper.sampleAssemblies.length - 1 && Globals.BackendWrapper.sampleAssemblies.length > 0) ? true : false width: EaStyle.Sizes.tableRowHeight fontIcon: "arrow-up" ToolTip.text: qsTr("Move assembly up") @@ -127,7 +127,7 @@ EaElements.GroupBox { } EaElements.SideBarButton { - enabled: (Globals.BackendWrapper.sampleCurrentAssemblyIndex + 1 !== Globals.BackendWrapper.sampleAssemblies.length && Globals.BackendWrapper.sampleAssemblies.length > 0 ) ? true : false//When item is selected + enabled: (Globals.BackendWrapper.sampleCurrentAssemblyIndex > 0 && Globals.BackendWrapper.sampleCurrentAssemblyIndex < Globals.BackendWrapper.sampleAssemblies.length - 2 && Globals.BackendWrapper.sampleAssemblies.length > 0) ? true : false width: EaStyle.Sizes.tableRowHeight fontIcon: "arrow-down" ToolTip.text: qsTr("Move assembly down") From c95a1fcf1bc7383fe1580bff105ae7a9d2102d37 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 17 Apr 2026 13:39:21 +0200 Subject: [PATCH 11/14] remove redundant section in the report --- EasyReflectometryApp/Backends/Py/logic/summary.py | 5 +++-- tests/test_logic_summary.py | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/logic/summary.py b/EasyReflectometryApp/Backends/Py/logic/summary.py index 88b4dd90..024402ab 100644 --- a/EasyReflectometryApp/Backends/Py/logic/summary.py +++ b/EasyReflectometryApp/Backends/Py/logic/summary.py @@ -48,7 +48,8 @@ def plot_file_path(self) -> Path: @property def as_html(self) -> str: base_html = self._summary.compile_html_summary() - return self._inject_multimodel_multiexperiment_sections(base_html) + return base_html + # return self._inject_multimodel_multiexperiment_sections(base_html) def save_as_html(self, file_path: str | None = None) -> None: if not self._project_lib.path.exists(): @@ -57,7 +58,7 @@ def save_as_html(self, file_path: str | None = None) -> None: target_path = Path(file_path) if file_path else self.file_path.with_suffix('.html') target_path.parent.mkdir(parents=True, exist_ok=True) html_content = self._summary.compile_html_summary(figures=True) - html_content = self._inject_multimodel_multiexperiment_sections(html_content) + # html_content = self._inject_multimodel_multiexperiment_sections(html_content) with open(target_path, 'w', encoding='utf-8') as report_file: report_file.write(html_content) diff --git a/tests/test_logic_summary.py b/tests/test_logic_summary.py index 8cc40e13..b721baee 100644 --- a/tests/test_logic_summary.py +++ b/tests/test_logic_summary.py @@ -129,10 +129,10 @@ def test_summary_html_and_save_operations(tmp_path, monkeypatch): logic = summary_module.Summary(project) html = logic.as_html - assert 'All Samples' in html - assert 'All Experiments' in html - assert 'Model <1>' in html - assert 'Exp <1>' in html + # assert 'All Samples' in html + # assert 'All Experiments' in html + # assert 'Model <1>' in html + # assert 'Exp <1>' in html logic.save_as_html() html_path = project.path / 'summary.html' @@ -190,7 +190,7 @@ def test_summary_injection_and_explicit_paths(tmp_path, monkeypatch): injected = logic._inject_multimodel_multiexperiment_sections('
base
') - assert 'All Samples' in injected + # assert 'All Samples' in injected assert 'All Experiments' in injected assert logic.file_path == project.path / 'custom-summary' assert logic.plot_file_path == project.path / 'custom-plots' From 7554552b9ac0247095dada3a79a2c8962b6e0ee5 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Sat, 18 Apr 2026 17:42:59 +0200 Subject: [PATCH 12/14] Implement scatter series for measured data and synchronize colors with current experiment --- .../Analysis/MainContent/AnalysisView.qml | 102 ++++++++++++++---- .../Analysis/MainContent/CombinedView.qml | 85 +++++++++++---- .../Experiment/MainContent/ExperimentView.qml | 83 ++++++++++---- .../Basic/Groups/ExperimentalDataExplorer.qml | 48 ++------- 4 files changed, 214 insertions(+), 104 deletions(-) diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index 0f54baf5..46ced438 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml @@ -12,6 +12,7 @@ import EasyApp.Gui.Elements as EaElements import EasyApp.Gui.Charts as EaCharts import Gui.Globals as Globals +import "../../../Logic/MeasuredScatter.js" as MeasuredScatter Rectangle { @@ -33,6 +34,12 @@ Rectangle { bkgSerie.width: 1 bkgSerie.style: Qt.DotLine + // Track current experiment color for scatter series + property color currentExperimentColor: Globals.Variables.experimentColor( + Globals.BackendWrapper.analysisExperimentsCurrentIndex + ) + onCurrentExperimentColorChanged: MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) + anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 useOpenGL: EaGlobals.Vars.useOpenGL @@ -74,6 +81,9 @@ Rectangle { Globals.BackendWrapper.updateRefLines(backgroundRefLine, scaleRefLine, true) } + // Scatter series for measured data (single experiment, linear mode) + property var measuredScatterSerie: null + // Multi-experiment support property var multiExperimentSeries: [] property bool isMultiExperimentMode: { @@ -179,12 +189,12 @@ Rectangle { } else if (useLogQAxis) { // Single experiment, log mode: create dynamic series on log axis measured.visible = false + if (measuredScatterSerie) measuredScatterSerie.visible = false calculated.visible = false - var newMeasured = chartView.createSeries(ChartView.SeriesTypeLine, "measured_log", axisXLog, chartView.axisY) - newMeasured.color = measured.color - newMeasured.width = measured.width - newMeasured.useOpenGL = chartView.useOpenGL + var newMeasured = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + "measured_log", axisXLog, chartView.axisY, + measured.color) var newCalculated = chartView.createSeries(ChartView.SeriesTypeLine, "calculated_log", axisXLog, chartView.axisY) newCalculated.color = calculated.color @@ -201,11 +211,16 @@ Rectangle { Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', newCalculated) Globals.BackendWrapper.plottingRefreshAnalysis() } else { - // Single experiment, linear mode: restore static series - measured.visible = true + // Single experiment, linear mode: restore scatter series + measured.visible = false + if (!measuredScatterSerie) { + console.warn("AnalysisView.recreateForLogMode: measuredScatterSerie is null - linear mode will render no measured points") + } else { + measuredScatterSerie.visible = true + } calculated.visible = true - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measured) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) Globals.BackendWrapper.plottingRefreshAnalysis() } @@ -261,8 +276,19 @@ Rectangle { if (!isMultiExperimentMode) { // Show default series for single experiment console.log("Analysis: Single experiment mode - showing default series") - measured.visible = true + measured.visible = false + if (!measuredScatterSerie) { + console.warn("AnalysisView.updateMultiExperimentSeries: measuredScatterSerie is null - single mode will render no measured points") + } else { + measuredScatterSerie.visible = true + MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) + } calculated.visible = true + + // Re-register scatter series and refresh data + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + Globals.BackendWrapper.plottingRefreshAnalysis() return } @@ -273,13 +299,25 @@ Rectangle { // If no data available yet, keep default series visible as fallback if (experimentDataList.length === 0) { console.log("Analysis: No experiment data available - keeping default series visible") - measured.visible = true + measured.visible = false + if (!measuredScatterSerie) { + console.warn("AnalysisView.updateMultiExperimentSeries: measuredScatterSerie is null - no-data fallback will render no measured points") + } else { + measuredScatterSerie.visible = true + } calculated.visible = true + + // Re-register the scatter series and refresh so the chart + // matches what the single-experiment branch above does. + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + Globals.BackendWrapper.plottingRefreshAnalysis() return } // Hide default series in multi-experiment mode (only after we have data) measured.visible = false + if (measuredScatterSerie) measuredScatterSerie.visible = false calculated.visible = false console.log("Analysis: Hidden default series, creating " + experimentDataList.length + " experiment series") @@ -316,16 +354,11 @@ Rectangle { ? modelColors[expIndex] : color - // Create measured data series - var measuredSerie = chartView.createSeries(ChartView.SeriesTypeLine, - `${expName} - Measured`, - xAxis, chartView.axisY) - measuredSerie.color = color - measuredSerie.width = 2 - measuredSerie.opacity = 0.95 - measuredSerie.style = Qt.DotLine - measuredSerie.capStyle = Qt.RoundCap - measuredSerie.useOpenGL = chartView.useOpenGL + // Create measured data series (scatter points) + var measuredSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + `${expName} - Measured`, + xAxis, chartView.axisY, + color) // Create calculated data series using the model's own color var calculatedSerie = chartView.createSeries(ChartView.SeriesTypeLine, @@ -533,10 +566,21 @@ Rectangle { // Data is set in python backend (plotting_1d.py) Component.onCompleted: { + // Create scatter series for measured data (single experiment, linear mode) + measuredScatterSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + "measured_scatter", + chartView.axisX, chartView.axisY, + measured.color) + if (!measuredScatterSerie) { + console.warn("AnalysisView: failed to create measuredScatterSerie - measured data will not render") + } + measured.visible = false + Globals.References.pages.analysis.mainContent.analysisView = chartView + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', - measured) + measuredScatterSerie) Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) @@ -551,10 +595,22 @@ Rectangle { // Update series when chart becomes visible onVisibleChanged: { - if (visible && isMultiExperimentMode) { - updateMultiExperimentSeries() - } if (visible) { + if (isMultiExperimentMode) { + updateMultiExperimentSeries() + } else { + // Ensure scatter series has correct color and data after tab switch + if (!measuredScatterSerie) { + console.warn("AnalysisView.onVisibleChanged: measuredScatterSerie is null - tab switch will render no measured points") + } else { + MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) + measuredScatterSerie.visible = true + } + measured.visible = false + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + Globals.BackendWrapper.plottingRefreshAnalysis() + } updateReferenceLines() } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index b0585d18..09ea49d8 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -14,6 +14,7 @@ import EasyApp.Gui.Charts as EaCharts import Gui as Gui import Gui.Globals as Globals +import "../../../Logic/MeasuredScatter.js" as MeasuredScatter Rectangle { @@ -48,6 +49,15 @@ Rectangle { bkgSerie.width: 1 bkgSerie.style: Qt.DotLine + // Scatter series for measured data (single experiment, linear mode) + property var measuredScatterSerie: null + + // Track current experiment color for scatter series + property color currentExperimentColor: Globals.Variables.experimentColor( + Globals.BackendWrapper.analysisExperimentsCurrentIndex + ) + onCurrentExperimentColorChanged: MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) + anchors.fill: parent anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 @@ -156,9 +166,20 @@ Rectangle { clearMultiExperimentSeries() if (!isMultiExp) { - // Show default series for single experiment - measured.visible = true + // Show default scatter series for single experiment + measured.visible = false + if (!measuredScatterSerie) { + console.warn("CombinedView.updateMultiExperimentSeries: measuredScatterSerie is null - single mode will render no measured points") + } else { + measuredScatterSerie.visible = true + MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) + } calculated.visible = true + + // Re-register scatter series and refresh data + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + Globals.BackendWrapper.plottingRefreshAnalysis() return } @@ -167,13 +188,25 @@ Rectangle { // If no data available yet, keep default series visible as fallback if (experimentDataList.length === 0) { - measured.visible = true + measured.visible = false + if (!measuredScatterSerie) { + console.warn("CombinedView.updateMultiExperimentSeries: measuredScatterSerie is null - no-data fallback will render no measured points") + } else { + measuredScatterSerie.visible = true + } calculated.visible = true + + // Re-register and refresh so this branch matches the + // single-experiment branch above. + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + Globals.BackendWrapper.plottingRefreshAnalysis() return } // Hide default series in multi-experiment mode (only after we have data) measured.visible = false + if (measuredScatterSerie) measuredScatterSerie.visible = false calculated.visible = false // Create series for each experiment @@ -208,16 +241,11 @@ Rectangle { ? modelColors[expIndex] : color - // Create measured data series - var measuredSerie = analysisChartView.createSeries(ChartView.SeriesTypeLine, - `${expName} - Measured`, - xAxis, analysisChartView.axisY) - measuredSerie.color = color - measuredSerie.width = 2 - measuredSerie.opacity = 0.95 - measuredSerie.style = Qt.DotLine - measuredSerie.capStyle = Qt.RoundCap - measuredSerie.useOpenGL = analysisChartView.useOpenGL + // Create measured data series (scatter points) + var measuredSerie = MeasuredScatter.create(analysisChartView, ChartView, ScatterSeries, + `${expName} - Measured`, + xAxis, analysisChartView.axisY, + color) // Create calculated data series using the model's own color var calculatedSerie = analysisChartView.createSeries(ChartView.SeriesTypeLine, @@ -310,12 +338,12 @@ Rectangle { updateMultiExperimentSeries() } else if (useLogQAxis) { measured.visible = false + if (measuredScatterSerie) measuredScatterSerie.visible = false calculated.visible = false - var newMeasured = analysisChartView.createSeries(ChartView.SeriesTypeLine, "measured_log", analysisAxisXLog, analysisChartView.axisY) - newMeasured.color = measured.color - newMeasured.width = measured.width - newMeasured.useOpenGL = analysisChartView.useOpenGL + var newMeasured = MeasuredScatter.create(analysisChartView, ChartView, ScatterSeries, + "measured_log", analysisAxisXLog, analysisChartView.axisY, + measured.color) var newCalculated = analysisChartView.createSeries(ChartView.SeriesTypeLine, "calculated_log", analysisAxisXLog, analysisChartView.axisY) newCalculated.color = calculated.color @@ -331,10 +359,16 @@ Rectangle { Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', newCalculated) Globals.BackendWrapper.plottingRefreshAnalysis() } else { - measured.visible = true + // Single experiment, linear mode: restore scatter series + measured.visible = false + if (!measuredScatterSerie) { + console.warn("CombinedView.recreateForLogMode: measuredScatterSerie is null - linear mode will render no measured points") + } else { + measuredScatterSerie.visible = true + } calculated.visible = true - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measured) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) Globals.BackendWrapper.plottingRefreshAnalysis() } @@ -550,10 +584,21 @@ Rectangle { // Data is set in python backend (plotting_1d.py) Component.onCompleted: { + // Create scatter series for measured data (single experiment, linear mode) + measuredScatterSerie = MeasuredScatter.create(analysisChartView, ChartView, ScatterSeries, + "measured_scatter", + analysisChartView.axisX, analysisChartView.axisY, + measured.color) + if (!measuredScatterSerie) { + console.warn("CombinedView: failed to create measuredScatterSerie - measured data will not render") + } + measured.visible = false + Globals.References.pages.analysis.mainContent.analysisView = analysisChartView + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', - measured) + measuredScatterSerie) Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index 04e5f41f..2d3b1604 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -12,6 +12,7 @@ import EasyApp.Gui.Elements as EaElements import EasyApp.Gui.Charts as EaCharts import Gui.Globals as Globals +import "../../../Logic/MeasuredScatter.js" as MeasuredScatter Rectangle { @@ -34,6 +35,9 @@ Rectangle { measSerie.width: 1 bkgSerie.width: 1 + // Keep scatter series color in sync with selected experiment + onCurrentExperimentColorChanged: MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) + anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 useOpenGL: EaGlobals.Vars.useOpenGL @@ -75,6 +79,9 @@ Rectangle { Globals.BackendWrapper.updateRefLines(backgroundRefLine, scaleRefLine, false) } + // Scatter series for measured data (single experiment, linear mode) + property var measuredScatterSerie: null + // Multi-experiment support property var multiExperimentSeries: [] property bool isMultiExperimentMode: { @@ -267,13 +274,13 @@ Rectangle { } else if (useLogQAxis) { // Single experiment, log mode: create dynamic series on log axis measured.visible = false + if (measuredScatterSerie) measuredScatterSerie.visible = false errorUpper.visible = false errorLower.visible = false - var newMeasured = chartView.createSeries(ChartView.SeriesTypeLine, "measured_log", axisXLog, chartView.axisY) - newMeasured.color = chartView.currentExperimentColor - newMeasured.width = measured.width - newMeasured.useOpenGL = chartView.useOpenGL + var newMeasured = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + "measured_log", axisXLog, chartView.axisY, + chartView.currentExperimentColor) var newErrorUpper = chartView.createSeries(ChartView.SeriesTypeLine, "errorUpper_log", axisXLog, chartView.axisY) newErrorUpper.color = errorUpper.color @@ -297,13 +304,18 @@ Rectangle { Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorLowerSerie', newErrorLower) Globals.BackendWrapper.plottingRefreshExperiment() } else { - // Single experiment, linear mode: restore static series - measured.visible = true + // Single experiment, linear mode: restore scatter series + measured.visible = false + if (!measuredScatterSerie) { + console.warn("ExperimentView.recreateForLogMode: measuredScatterSerie is null - linear mode will render no measured points") + } else { + measuredScatterSerie.visible = true + } errorUpper.visible = true errorLower.visible = true - // Re-register static series - Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', measured) + // Re-register scatter series for measured, static series for errors + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', measuredScatterSerie) Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorUpperSerie', errorUpper) Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorLowerSerie', errorLower) Globals.BackendWrapper.plottingRefreshExperiment() @@ -351,9 +363,21 @@ Rectangle { if (!isMultiExperimentMode) { // Show default series for single experiment - measured.visible = true + measured.visible = false + if (!measuredScatterSerie) { + console.warn("ExperimentView.updateMultiExperimentSeries: measuredScatterSerie is null - single mode will render no measured points") + } else { + measuredScatterSerie.visible = true + MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) + } errorUpper.visible = true errorLower.visible = true + + // Re-register scatter series and refresh data + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorUpperSerie', errorUpper) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorLowerSerie', errorLower) + Globals.BackendWrapper.plottingRefreshExperiment() return } @@ -362,14 +386,27 @@ Rectangle { // If no data available yet, keep default series visible as fallback if (experimentDataList.length === 0) { console.log("No experiment data available - keeping default series visible") - measured.visible = true + measured.visible = false + if (!measuredScatterSerie) { + console.warn("ExperimentView.updateMultiExperimentSeries: measuredScatterSerie is null - no-data fallback will render no measured points") + } else { + measuredScatterSerie.visible = true + } errorUpper.visible = true errorLower.visible = true + + // Re-register and refresh so this branch matches the + // single-experiment branch above. + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorUpperSerie', errorUpper) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorLowerSerie', errorLower) + Globals.BackendWrapper.plottingRefreshExperiment() return } // Hide default series in multi-experiment mode (only after we have data) measured.visible = false + if (measuredScatterSerie) measuredScatterSerie.visible = false errorUpper.visible = false errorLower.visible = false @@ -404,14 +441,11 @@ Rectangle { var xAxis = currentXAxis() - // Create measured data series - var measuredSerie = chartView.createSeries(ChartView.SeriesTypeLine, - `${expName} - Data`, - xAxis, chartView.axisY) - measuredSerie.color = color - measuredSerie.width = 2 - measuredSerie.capStyle = Qt.RoundCap - measuredSerie.useOpenGL = chartView.useOpenGL + // Create measured data series (scatter points) + var measuredSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + `${expName} - Data`, + xAxis, chartView.axisY, + color) // Create error bound series (lighter colors) var errorColor = Qt.darker(color, 1.3) @@ -658,7 +692,18 @@ Rectangle { // Data is set in python backend (plotting_1d.py) Component.onCompleted: { + // Create scatter series for measured data (single experiment, linear mode) + measuredScatterSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + "measured_scatter", + chartView.axisX, chartView.axisY, + currentExperimentColor) + if (!measuredScatterSerie) { + console.warn("ExperimentView: failed to create measuredScatterSerie - measured data will not render") + } + measured.visible = false + Globals.References.pages.experiment.mainContent.experimentView = chartView + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorUpperSerie', errorUpper) @@ -667,7 +712,7 @@ Rectangle { errorLower) Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', - measured) + measuredScatterSerie) // Initialize multi-experiment support // console.log("ExperimentView initialized - checking multi-experiment mode...") diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml index 361f088b..dcb1228a 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml @@ -349,16 +349,16 @@ EaElements.GroupBox { if (selectedExperimentIndices.length === 0) { return } - - // If only one experiment is selected, use the existing single-selection logic + + // Always notify backend of the current selection (single or multi) + Globals.BackendWrapper.analysisSetSelectedExperimentIndices(selectedExperimentIndices) + if (selectedExperimentIndices.length === 1) { var currentIndex = selectedExperimentIndices[0] // If we were in multi-selection mode and now switching to single selection, // force a plot refresh by toggling the current index if (wasMultiSelected) { - // console.log("Switching from multi-selection to single selection - forcing plot refresh") - // Force refresh by temporarily setting a different index and then back var tempIndex = (currentIndex === 0) ? 1 : 0 if (tempIndex < Globals.BackendWrapper.analysisExperimentsAvailable.length) { Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(tempIndex) @@ -372,50 +372,14 @@ EaElements.GroupBox { } else { // Mark that we're in multi-selection mode wasMultiSelected = true - // For multiple experiments, call the new backend method - // console.log("Multi-experiment selection - checking backend method availability") - // console.log("Backend wrapper analysis available:", typeof Globals.BackendWrapper.analysis) - // console.log("analysisSetSelectedExperimentIndices available:", typeof Globals.BackendWrapper.analysisSetSelectedExperimentIndices) - - // Try multiple approaches to call the backend method - var methodCalled = false - - // Approach 1: Direct call to top-level method - if (typeof Globals.BackendWrapper.analysisSetSelectedExperimentIndices === 'function') { - // console.log("Approach 1: Calling analysisSetSelectedExperimentIndices with:", selectedExperimentIndices) - Globals.BackendWrapper.analysisSetSelectedExperimentIndices(selectedExperimentIndices) - methodCalled = true - } - - // Approach 2: Try through analysis object - if (!methodCalled && Globals.BackendWrapper.analysis && - typeof Globals.BackendWrapper.analysis.setSelectedExperimentIndices === 'function') { - // console.log("Approach 2: Calling through analysis object with:", selectedExperimentIndices) - Globals.BackendWrapper.analysis.setSelectedExperimentIndices(selectedExperimentIndices) - methodCalled = true - } - - if (methodCalled) { - console.log("Multi-experiment selection applied:", selectedExperimentIndices) - } else { - // Fallback: set the first selected experiment as current - Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(selectedExperimentIndices[0]) - console.log("Multi-experiment selection - fallback to single selection") - console.log("Selected experiments:", selectedExperimentIndices) - // console.log("Available backend methods:", Object.keys(Globals.BackendWrapper)) - } + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(selectedExperimentIndices[0]) } } function clearAllSelections() { - console.log("clearAllSelections called - clearing to empty array") wasMultiSelected = false selectedExperimentIndices = [] - // Notify backend that selection is cleared - if (typeof Globals.BackendWrapper.analysisSetSelectedExperimentIndices === 'function') { - // console.log("Calling backend with empty array to clear selection") - Globals.BackendWrapper.analysisSetSelectedExperimentIndices([]) - } + Globals.BackendWrapper.analysisSetSelectedExperimentIndices([]) } function selectAllExperiments() { From 587768b23e645da22606457d91db6e178dc7e9e8 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Sat, 18 Apr 2026 22:33:08 +0200 Subject: [PATCH 13/14] missed file --- .../Gui/Logic/MeasuredScatter.js | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 EasyReflectometryApp/Gui/Logic/MeasuredScatter.js diff --git a/EasyReflectometryApp/Gui/Logic/MeasuredScatter.js b/EasyReflectometryApp/Gui/Logic/MeasuredScatter.js new file mode 100644 index 00000000..4efbbf5c --- /dev/null +++ b/EasyReflectometryApp/Gui/Logic/MeasuredScatter.js @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project +// +// Shared helpers for the "measured data" scatter series rendered on the +// Analysis, Combined and Experiment chart views. +// +// Centralising the series style here keeps the three views in sync and +// gives us a single place to tweak marker shape / size / opacity on demand. + +// Style constants. These are the only place where these magic numbers live. +// MARKER_SHAPE expects one of `ScatterSeries.MarkerShape*` enum values; it is +// resolved lazily inside `applyStyle` so this file does not depend on QtCharts +// being importable at load time. +var MARKER_SIZE = 7 +var BORDER_WIDTH = 0 +var OPACITY = 0.95 +var MARKER_SHAPE = "Circle" // "Circle" or "Rectangle" (ScatterSeries.MarkerShape*) + +// Resolve a marker-shape name to the matching ScatterSeries enum value. +// `scatterSeriesType` is the QML type object (e.g. ScatterSeries) from which +// to read the enum. Falls back to 0 (Circle) if anything is missing. +function _resolveMarkerShape(scatterSeriesType) { + if (!scatterSeriesType) { + return 0 + } + var key = "MarkerShape" + MARKER_SHAPE + var value = scatterSeriesType[key] + return (value !== undefined) ? value : 0 +} + +// Apply the canonical measured-scatter style to an existing ScatterSeries. +// serie -- the ScatterSeries to style (must not be null) +// color -- foreground/border color +// scatterSeriesType -- the ScatterSeries QML type, used to resolve the +// marker-shape enum (pass `ScatterSeries` from QML) +function applyStyle(serie, color, scatterSeriesType) { + if (!serie) { + console.warn("MeasuredScatter.applyStyle: serie is null - style not applied") + return + } + serie.color = color + serie.borderColor = color + serie.markerSize = MARKER_SIZE + serie.borderWidth = BORDER_WIDTH + serie.opacity = OPACITY + serie.markerShape = _resolveMarkerShape(scatterSeriesType) +} + +// Create a styled measured-scatter series on the given chart. +// chartView -- ChartView instance +// chartViewType -- the ChartView QML type (for SeriesTypeScatter enum) +// scatterSeriesType -- the ScatterSeries QML type (for MarkerShape enum) +// name, axisX, axisY -- forwarded to createSeries +// color -- color applied via applyStyle +// Returns the new series, or null if creation failed. +function create(chartView, chartViewType, scatterSeriesType, name, axisX, axisY, color) { + if (!chartView) { + console.warn("MeasuredScatter.create: chartView is null") + return null + } + var seriesType = (chartViewType && chartViewType.SeriesTypeScatter !== undefined) + ? chartViewType.SeriesTypeScatter + : 2 // fallback: ChartView.SeriesTypeScatter == 2 + var serie = chartView.createSeries(seriesType, name, axisX, axisY) + if (!serie) { + console.warn("MeasuredScatter.create: createSeries returned null for '" + name + "'") + return null + } + applyStyle(serie, color, scatterSeriesType) + serie.useOpenGL = chartView.useOpenGL + return serie +} + +// Update color + borderColor in lockstep. Useful when the selected experiment +// changes and the series should track the new color. +function setColor(serie, color) { + if (!serie) { + return + } + serie.color = color + serie.borderColor = color +} From 25b4a0d2a38ac803bbc65ccc9cc7a340a8349784 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 20 Apr 2026 12:07:10 +0200 Subject: [PATCH 14/14] Fixed issue with Sample tab Added different ways to show experimental data (dots, circles, line) --- .../Backends/Py/plotting_1d.py | 51 ------------------- .../Gui/Globals/Variables.qml | 1 + .../Gui/Logic/MeasuredScatter.js | 48 ++++++++++++----- .../Analysis/MainContent/AnalysisView.qml | 39 ++++++++++++-- .../Analysis/MainContent/CombinedView.qml | 39 ++++++++++++-- .../Experiment/MainContent/ExperimentView.qml | 39 ++++++++++++-- .../Sidebar/Advanced/Groups/PlotControl.qml | 28 ++++++++++ .../Gui/Pages/Sample/Sidebar/Basic/Layout.qml | 8 +-- 8 files changed, 174 insertions(+), 79 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index b3f5fddd..84a5039e 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -562,22 +562,6 @@ def individualExperimentDataList(self) -> list: ) return qml_data_list - @Property(float, notify=sampleChartRangesChanged) - def residualMinX(self): - return self._get_residual_range()[0] - - @Property(float, notify=sampleChartRangesChanged) - def residualMaxX(self): - return self._get_residual_range()[1] - - @Property(float, notify=sampleChartRangesChanged) - def residualMinY(self): - return self._get_residual_range()[2] - - @Property(float, notify=sampleChartRangesChanged) - def residualMaxY(self): - return self._get_residual_range()[3] - @Slot(str, str, 'QVariant') def setQtChartsSerieRef(self, page: str, serie: str, ref: QObject): self._chartRefs['QtCharts'][page][serie] = ref @@ -758,41 +742,6 @@ def getResidualDataPoints(self, experiment_index: int) -> list: console.debug(f'Error getting residual data points for index {experiment_index}: {e}') return [] - def _get_residual_range(self) -> tuple[float, float, float, float]: - """Return residual plot ranges for the current selection.""" - try: - if self.is_multi_experiment_mode: - selected_indices = getattr(self._proxy._analysis, '_selected_experiment_indices', []) - else: - selected_indices = [self._project_lib.current_experiment_index] - - all_points = [] - for experiment_index in selected_indices: - all_points.extend(self.getResidualDataPoints(experiment_index)) - - if not all_points: - return 0.0, 1.0, -1.0, 1.0 - - x_values = np.asarray([point['x'] for point in all_points], dtype=float) - y_values = np.asarray([point['y'] for point in all_points], dtype=float) - if x_values.size == 0 or y_values.size == 0: - return 0.0, 1.0, -1.0, 1.0 - - min_x = float(np.min(x_values)) - max_x = float(np.max(x_values)) - min_y = float(np.min(y_values)) - max_y = float(np.max(y_values)) - - if min_y == max_y: - margin = max(abs(min_y) * 0.05, 1.0) - else: - margin = (max_y - min_y) * 0.05 - - return min_x, max_x, min_y - margin, max_y + margin - except Exception as e: - console.debug(f'Error getting residual range: {e}') - return 0.0, 1.0, -1.0, 1.0 - def refreshSamplePage(self): # Clear cached data so it gets recalculated self._sample_data = {} diff --git a/EasyReflectometryApp/Gui/Globals/Variables.qml b/EasyReflectometryApp/Gui/Globals/Variables.qml index d84791b1..f0ae2cd1 100644 --- a/EasyReflectometryApp/Gui/Globals/Variables.qml +++ b/EasyReflectometryApp/Gui/Globals/Variables.qml @@ -18,6 +18,7 @@ QtObject { // Sample page plot control settings property bool reverseSldZAxis: false property bool logarithmicQAxis: false + property int experimentMarkerStyle: 0 // 0: dots, 1: circles, 2: line // Shared experiment color palette — used by Data Explorer table, Experiment chart, and Analysis charts readonly property var experimentColorPalette: [ diff --git a/EasyReflectometryApp/Gui/Logic/MeasuredScatter.js b/EasyReflectometryApp/Gui/Logic/MeasuredScatter.js index 4efbbf5c..27a29db5 100644 --- a/EasyReflectometryApp/Gui/Logic/MeasuredScatter.js +++ b/EasyReflectometryApp/Gui/Logic/MeasuredScatter.js @@ -12,7 +12,8 @@ // MARKER_SHAPE expects one of `ScatterSeries.MarkerShape*` enum values; it is // resolved lazily inside `applyStyle` so this file does not depend on QtCharts // being importable at load time. -var MARKER_SIZE = 7 +var MARKER_SIZE_DOTS = 2 +var MARKER_SIZE_CIRCLES = 5 var BORDER_WIDTH = 0 var OPACITY = 0.95 var MARKER_SHAPE = "Circle" // "Circle" or "Rectangle" (ScatterSeries.MarkerShape*) @@ -20,11 +21,15 @@ var MARKER_SHAPE = "Circle" // "Circle" or "Rectangle" (ScatterSeries.MarkerS // Resolve a marker-shape name to the matching ScatterSeries enum value. // `scatterSeriesType` is the QML type object (e.g. ScatterSeries) from which // to read the enum. Falls back to 0 (Circle) if anything is missing. -function _resolveMarkerShape(scatterSeriesType) { +// `shape` is the shape name, defaults to MARKER_SHAPE +function _resolveMarkerShape(scatterSeriesType, shape) { + if (!shape) { + shape = MARKER_SHAPE + } if (!scatterSeriesType) { return 0 } - var key = "MarkerShape" + MARKER_SHAPE + var key = "MarkerShape" + shape var value = scatterSeriesType[key] return (value !== undefined) ? value : 0 } @@ -32,42 +37,57 @@ function _resolveMarkerShape(scatterSeriesType) { // Apply the canonical measured-scatter style to an existing ScatterSeries. // serie -- the ScatterSeries to style (must not be null) // color -- foreground/border color +// markerStyle -- 0: dots, 1: circles, 2: line // scatterSeriesType -- the ScatterSeries QML type, used to resolve the // marker-shape enum (pass `ScatterSeries` from QML) -function applyStyle(serie, color, scatterSeriesType) { +function applyStyle(serie, color, markerStyle, scatterSeriesType) { if (!serie) { console.warn("MeasuredScatter.applyStyle: serie is null - style not applied") return } + var markerSize = (markerStyle === 0) ? MARKER_SIZE_DOTS : MARKER_SIZE_CIRCLES serie.color = color serie.borderColor = color - serie.markerSize = MARKER_SIZE + serie.markerSize = markerSize serie.borderWidth = BORDER_WIDTH serie.opacity = OPACITY serie.markerShape = _resolveMarkerShape(scatterSeriesType) } -// Create a styled measured-scatter series on the given chart. +// Create a styled measured series on the given chart. // chartView -- ChartView instance -// chartViewType -- the ChartView QML type (for SeriesTypeScatter enum) -// scatterSeriesType -- the ScatterSeries QML type (for MarkerShape enum) +// chartViewType -- the ChartView QML type (for SeriesType enum) +// scatterSeriesType -- the ScatterSeries QML type (for MarkerShape enum, if scatter) // name, axisX, axisY -- forwarded to createSeries // color -- color applied via applyStyle +// markerStyle -- 0: dots, 1: circles, 2: line // Returns the new series, or null if creation failed. -function create(chartView, chartViewType, scatterSeriesType, name, axisX, axisY, color) { +function create(chartView, chartViewType, scatterSeriesType, name, axisX, axisY, color, markerStyle) { if (!chartView) { console.warn("MeasuredScatter.create: chartView is null") return null } - var seriesType = (chartViewType && chartViewType.SeriesTypeScatter !== undefined) + var seriesType + if (markerStyle === 2) { // line + seriesType = (chartViewType && chartViewType.SeriesTypeLine !== undefined) + ? chartViewType.SeriesTypeLine + : 1 // fallback: ChartView.SeriesTypeLine == 1 + } else { // scatter + seriesType = (chartViewType && chartViewType.SeriesTypeScatter !== undefined) ? chartViewType.SeriesTypeScatter : 2 // fallback: ChartView.SeriesTypeScatter == 2 + } var serie = chartView.createSeries(seriesType, name, axisX, axisY) if (!serie) { console.warn("MeasuredScatter.create: createSeries returned null for '" + name + "'") return null } - applyStyle(serie, color, scatterSeriesType) + if (markerStyle !== 2) { // apply scatter style only if not line + applyStyle(serie, color, markerStyle, scatterSeriesType) + } else { // line style + serie.color = color + serie.width = 2 // line width + } serie.useOpenGL = chartView.useOpenGL return serie } @@ -78,6 +98,8 @@ function setColor(serie, color) { if (!serie) { return } - serie.color = color - serie.borderColor = color + serie.color = color + if (serie.borderColor !== undefined) { + serie.borderColor = color + } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index 46ced438..d59d5458 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml @@ -128,6 +128,14 @@ Rectangle { } } + // Recreate series when marker style changes + Connections { + target: Globals.Variables + function onExperimentMarkerStyleChanged() { + chartView.recreateSeriesForCurrentMode() + } + } + Timer { id: analysisResetAxesTimer interval: 75 @@ -194,7 +202,7 @@ Rectangle { var newMeasured = MeasuredScatter.create(chartView, ChartView, ScatterSeries, "measured_log", axisXLog, chartView.axisY, - measured.color) + measured.color, Globals.Variables.experimentMarkerStyle) var newCalculated = chartView.createSeries(ChartView.SeriesTypeLine, "calculated_log", axisXLog, chartView.axisY) newCalculated.color = calculated.color @@ -358,7 +366,7 @@ Rectangle { var measuredSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, `${expName} - Measured`, xAxis, chartView.axisY, - color) + color, Globals.Variables.experimentMarkerStyle) // Create calculated data series using the model's own color var calculatedSerie = chartView.createSeries(ChartView.SeriesTypeLine, @@ -564,13 +572,38 @@ Rectangle { textFormat: Text.RichText } + function recreateSeriesForCurrentMode() { + if (isMultiExperimentMode) { + // Multi-experiment mode: recreate all multi-experiment series + updateMultiExperimentSeries() + } else if (useLogQAxis) { + // Single experiment, log mode: recreate log mode series + recreateForLogMode() + } else { + // Single experiment, linear mode: recreate scatter series + if (measuredScatterSerie) { + chartView.removeSeries(measuredScatterSerie) + measuredScatterSerie = null + } + measuredScatterSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + "measured_scatter", + chartView.axisX, chartView.axisY, + measured.color, Globals.Variables.experimentMarkerStyle) + if (measuredScatterSerie) { + measuredScatterSerie.visible = true + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingRefreshAnalysis() + } + } + } + // Data is set in python backend (plotting_1d.py) Component.onCompleted: { // Create scatter series for measured data (single experiment, linear mode) measuredScatterSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, "measured_scatter", chartView.axisX, chartView.axisY, - measured.color) + measured.color, Globals.Variables.experimentMarkerStyle) if (!measuredScatterSerie) { console.warn("AnalysisView: failed to create measuredScatterSerie - measured data will not render") } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index 09ea49d8..9df0710e 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -115,6 +115,14 @@ Rectangle { } } + // Recreate series when marker style changes + Connections { + target: Globals.Variables + function onExperimentMarkerStyleChanged() { + analysisChartView.recreateSeriesForCurrentMode() + } + } + // Background reference line series LineSeries { id: backgroundRefLine @@ -245,7 +253,7 @@ Rectangle { var measuredSerie = MeasuredScatter.create(analysisChartView, ChartView, ScatterSeries, `${expName} - Measured`, xAxis, analysisChartView.axisY, - color) + color, Globals.Variables.experimentMarkerStyle) // Create calculated data series using the model's own color var calculatedSerie = analysisChartView.createSeries(ChartView.SeriesTypeLine, @@ -343,7 +351,7 @@ Rectangle { var newMeasured = MeasuredScatter.create(analysisChartView, ChartView, ScatterSeries, "measured_log", analysisAxisXLog, analysisChartView.axisY, - measured.color) + measured.color, Globals.Variables.experimentMarkerStyle) var newCalculated = analysisChartView.createSeries(ChartView.SeriesTypeLine, "calculated_log", analysisAxisXLog, analysisChartView.axisY) newCalculated.color = calculated.color @@ -588,7 +596,7 @@ Rectangle { measuredScatterSerie = MeasuredScatter.create(analysisChartView, ChartView, ScatterSeries, "measured_scatter", analysisChartView.axisX, analysisChartView.axisY, - measured.color) + measured.color, Globals.Variables.experimentMarkerStyle) if (!measuredScatterSerie) { console.warn("CombinedView: failed to create measuredScatterSerie - measured data will not render") } @@ -610,6 +618,31 @@ Rectangle { // Initialize reference lines updateReferenceLines() } + + function recreateSeriesForCurrentMode() { + if (isMultiExperimentMode) { + // Multi-experiment mode: recreate all multi-experiment series + updateMultiExperimentSeries() + } else if (useLogQAxis) { + // Single experiment, log mode: recreate log mode series + recreateForLogMode() + } else { + // Single experiment, linear mode: recreate scatter series + if (measuredScatterSerie) { + analysisChartView.removeSeries(measuredScatterSerie) + measuredScatterSerie = null + } + measuredScatterSerie = MeasuredScatter.create(analysisChartView, ChartView, ScatterSeries, + "measured_scatter", + analysisChartView.axisX, analysisChartView.axisY, + measured.color, Globals.Variables.experimentMarkerStyle) + if (measuredScatterSerie) { + measuredScatterSerie.visible = true + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingRefreshAnalysis() + } + } + } } } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index 2d3b1604..8d30a1bf 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -75,8 +75,37 @@ Rectangle { } } - function updateReferenceLines() { - Globals.BackendWrapper.updateRefLines(backgroundRefLine, scaleRefLine, false) + // Recreate series when marker style changes + Connections { + target: Globals.Variables + function onExperimentMarkerStyleChanged() { + chartView.recreateSeriesForCurrentMode() + } + } + + function recreateSeriesForCurrentMode() { + if (isMultiExperimentMode) { + // Multi-experiment mode: recreate all multi-experiment series + updateMultiExperimentSeries() + } else if (useLogQAxis) { + // Single experiment, log mode: recreate log mode series + recreateForLogMode() + } else { + // Single experiment, linear mode: recreate scatter series + if (measuredScatterSerie) { + chartView.removeSeries(measuredScatterSerie) + measuredScatterSerie = null + } + measuredScatterSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + "measured_scatter", + chartView.axisX, chartView.axisY, + currentExperimentColor, Globals.Variables.experimentMarkerStyle) + if (measuredScatterSerie) { + measuredScatterSerie.visible = true + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingRefreshExperiment() + } + } } // Scatter series for measured data (single experiment, linear mode) @@ -280,7 +309,7 @@ Rectangle { var newMeasured = MeasuredScatter.create(chartView, ChartView, ScatterSeries, "measured_log", axisXLog, chartView.axisY, - chartView.currentExperimentColor) + chartView.currentExperimentColor, Globals.Variables.experimentMarkerStyle) var newErrorUpper = chartView.createSeries(ChartView.SeriesTypeLine, "errorUpper_log", axisXLog, chartView.axisY) newErrorUpper.color = errorUpper.color @@ -445,7 +474,7 @@ Rectangle { var measuredSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, `${expName} - Data`, xAxis, chartView.axisY, - color) + color, Globals.Variables.experimentMarkerStyle) // Create error bound series (lighter colors) var errorColor = Qt.darker(color, 1.3) @@ -696,7 +725,7 @@ Rectangle { measuredScatterSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, "measured_scatter", chartView.axisX, chartView.axisY, - currentExperimentColor) + currentExperimentColor, Globals.Variables.experimentMarkerStyle) if (!measuredScatterSerie) { console.warn("ExperimentView: failed to create measuredScatterSerie - measured data will not render") } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml index eec8e7fc..1a3fd816 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml @@ -56,6 +56,34 @@ EaElements.GroupBox { Globals.BackendWrapper.plottingFlipBkgShown() } } + + EaElements.Label { + text: qsTr("Marker style") + } + + EaElements.ComboBox { + model: ListModel { + ListElement { text: qsTr("Dots"); value: 0 } + ListElement { text: qsTr("Circles"); value: 1 } + ListElement { text: qsTr("Line"); value: 2 } + } + currentIndex: { + var val = Globals.Variables.experimentMarkerStyle + for (var i = 0; i < model.count; ++i) { + if (model.get(i).value === val) { + return i + } + } + return 0 // default to dots + } + onCurrentIndexChanged: { + if (currentIndex >= 0 && currentIndex < model.count) { + Globals.Variables.experimentMarkerStyle = model.get(currentIndex).value + } + } + textRole: "text" + valueRole: "value" + } } } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml index 84e1e885..33320963 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml @@ -8,20 +8,20 @@ import "./Groups" as Groups EaComponents.SideBarColumn { Groups.LoadSample{ - forceAutoCollapse: true + collapsed: false enabled: Globals.BackendWrapper.analysisIsFitFinished } Groups.MaterialEditor{ - forceAutoCollapse: true + collapsed: true enabled: Globals.BackendWrapper.analysisIsFitFinished } Groups.ModelSelector{ - forceAutoCollapse: true + collapsed: true enabled: Globals.BackendWrapper.analysisIsFitFinished } Groups.ModelEditor { id: modelEditor - forceAutoCollapse: true + collapsed: true enabled: Globals.BackendWrapper.analysisIsFitFinished } }