diff --git a/CHANGELOG.md b/CHANGELOG.md index 4deec11a..d9d8a166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Unreleased +- Moment direction is now drawn, not just numbered. One convention, defined once + in the library: arrows are a top view along the surface normal, screen right is + the guide field H, and the angle drawn is φ = θM − 270° (a negative ρM points + the other way, with the signed parameter in the tooltip). A magnetic layer with + a negligible moment gets a hollow dot; a non-magnetic layer gets nothing, and a + gradient assembly - which has no single moment - gets nothing either. + - **Structure tab**: an arrow in every magnetic layer's box, between the name + and the thickness annotation, with no switch to find - attaching magnetism is + the request. Tooltips lead with φ, then θM and signed ρM, then the M∥/M⊥ split. + A magnetism edit now refreshes the boxes; previously a θM change updated the + chart while the boxes kept the old value. + - **Magnetism group**: the selected magnetic layer's angle as a 0-360° slider, + with the H reference and the resulting arrow beside it. Dragging it sets θM + in 5° steps - the same write the θM column makes - and it is read-only while + a fit runs or θM follows a constraint. + - An "H →" reference is on screen wherever an arrow is. + - A project with no magnetic layer is unchanged: no arrows, no slider, and the + same Structure and SLD layout as before. + - Added a **Structure** tab on the Model page: a schematic view of the layer stack with one colored box per layer (colors per material, heights following thickness, "× N" badges for collapsed repeating multilayers, legend and total-thickness caption). Boxes show tooltips diff --git a/EasyReflectometryApp/Backends/Mock/Sample.qml b/EasyReflectometryApp/Backends/Mock/Sample.qml index 409f1cc8..8239a2b9 100644 --- a/EasyReflectometryApp/Backends/Mock/Sample.qml +++ b/EasyReflectometryApp/Backends/Mock/Sample.qml @@ -306,9 +306,9 @@ QtObject { // Layer magnetism (polarized analysis) readonly property bool magnetismSupported: true readonly property var layersMagnetism: [ - { 'label': 'label 1', 'magnetic': 'True', 'rho_m': '5.0', 'theta_m': '40.0' }, - { 'label': 'label 2', 'magnetic': 'False', 'rho_m': '0.0', 'theta_m': '270.0' }, - { 'label': 'label 3', 'magnetic': 'False', 'rho_m': '0.0', 'theta_m': '270.0' }, + { 'label': 'label 1', 'magnetic': 'True', 'rho_m': '5.0', 'theta_m': '40.0', 'phi': '130.0', 'editable': 'True' }, + { 'label': 'label 2', 'magnetic': 'False', 'rho_m': '0.0', 'theta_m': '270.0', 'phi': '', 'editable': '' }, + { 'label': 'label 3', 'magnetic': 'False', 'rho_m': '0.0', 'theta_m': '270.0', 'phi': '', 'editable': '' }, ] function setLayerMagneticAtIndex(index, value) { console.debug(`setLayerMagneticAtIndex ${index} ${value}`) diff --git a/EasyReflectometryApp/Backends/Py/logic/layers.py b/EasyReflectometryApp/Backends/Py/logic/layers.py index 3a6f55da..c326f109 100644 --- a/EasyReflectometryApp/Backends/Py/logic/layers.py +++ b/EasyReflectometryApp/Backends/Py/logic/layers.py @@ -3,6 +3,7 @@ from typing import Union from easyreflectometry import Project as ProjectLib +from easyreflectometry.project import magnetic_vector_for_layer from easyreflectometry.sample import LayerAreaPerMolecule from easyreflectometry.sample import LayerCollection from easyreflectometry.sample import LayerMagnetism @@ -224,7 +225,11 @@ def magnetism(self) -> list[dict[str, str]]: ``magnetic`` is 'True'/'False'; ``rho_m``/``theta_m`` carry the defaults of a fresh :class:`LayerMagnetism` for non-magnetic layers so the fields - show what attaching magnetism would start from. + show what attaching magnetism would start from. ``phi`` is the direction + the moment points, in degrees from the guide field - what the compass + draws - and ``editable`` whether that direction can be set by dragging + it (a constrained ``theta_m`` follows its expression, not the pointer). + Both are empty for a non-magnetic layer, which has no direction. """ rows = [] for layer in self._layers: @@ -235,6 +240,8 @@ def magnetism(self) -> list[dict[str, str]]: 'magnetic': str(magnetism is not None), 'rho_m': str(magnetism.rho_m.value if magnetism is not None else _DEFAULT_RHO_M), 'theta_m': str(magnetism.theta_m.value if magnetism is not None else _DEFAULT_THETA_M), + 'phi': '' if magnetism is None else str(magnetic_vector_for_layer(magnetism)['phi']), + 'editable': '' if magnetism is None else str(magnetism.theta_m.independent), } ) return rows diff --git a/EasyReflectometryApp/Backends/Py/logic/structure.py b/EasyReflectometryApp/Backends/Py/logic/structure.py index 2c0d6f64..dadb1b56 100644 --- a/EasyReflectometryApp/Backends/Py/logic/structure.py +++ b/EasyReflectometryApp/Backends/Py/logic/structure.py @@ -1,5 +1,7 @@ from easyreflectometry import Project as ProjectLib from easyreflectometry.model.model import COLORS +from easyreflectometry.project import MAGNETIC_MOMENT_FLOOR_FRACTION +from easyreflectometry.project import magnetic_vector_for_layer # An assembly whose expanded box count would exceed this collapses to its repeat unit MAX_EXPANDED_BOXES_PER_ASSEMBLY = 12 @@ -19,6 +21,11 @@ def flatten(project_lib: ProjectLib) -> tuple[list[dict], list[dict], float]: assembly assembly name, and assembly_index/layer_index to address the layer kind 'layer' | 'gradient' | 'superphase' | 'subphase' repetitions n for a collapsed repeating multilayer, else 1 + A magnetic layer carries, in addition, `magnetic` (True), `has_moment` and the + keys of `magnetic_vector_for_layer` (rho_m, theta_m, phi_param, phi, m, m_par, + m_perp). Every other box omits them entirely, so the view gates on + `magnetic === true` and a non-magnetic project's boxes are exactly as before. + Gradient boxes never carry them: a gradient has no assembly-level moment. - legend: distinct {label, color} pairs in stack order - total_thickness: physical total in Angstrom (collapsed repeats counted n times, caps excluded) """ @@ -28,6 +35,7 @@ def flatten(project_lib: ProjectLib) -> tuple[list[dict], list[dict], float]: sample = project_lib._models[model_index].sample colors = _ColorMap(project_lib._materials) + moment_floor = _moment_floor(sample) boxes = [] total_thickness = 0.0 @@ -45,7 +53,7 @@ def flatten(project_lib: ProjectLib) -> tuple[list[dict], list[dict], float]: for _ in range(1 if collapsed else repetitions): for layer_index, layer in enumerate(assembly.layers): - boxes.append(_layer_box(layer, assembly, assembly_index, layer_index, colors)) + boxes.append(_layer_box(layer, assembly, assembly_index, layer_index, colors, moment_floor)) if collapsed: boxes[-len(assembly.layers)]['repetitions'] = repetitions @@ -73,9 +81,24 @@ def _value(quantity) -> float: return float(getattr(quantity, 'value', quantity)) -def _layer_box(layer, assembly, assembly_index: int, layer_index: int, colors: '_ColorMap') -> dict: +def _moment_floor(sample) -> float: + """Below this |rho_m| a layer's moment has no direction worth drawing. + + The same relative floor that masks the theta_m depth curve, so a box never + shows an arrow while the angle curve beside it is hidden. + """ + moments = [ + abs(float(layer.magnetism.rho_m.value)) + for assembly in sample + for layer in assembly.layers + if getattr(layer, 'magnetism', None) is not None + ] + return MAGNETIC_MOMENT_FLOOR_FRACTION * max(moments, default=0.0) + + +def _layer_box(layer, assembly, assembly_index: int, layer_index: int, colors: '_ColorMap', moment_floor: float) -> dict: material = layer.material - return { + box = { 'label': layer.name, 'material': material.name, 'color': colors.get(material), @@ -90,6 +113,13 @@ def _layer_box(layer, assembly, assembly_index: int, layer_index: int, colors: ' 'kind': 'layer', 'repetitions': 1, } + magnetism = getattr(layer, 'magnetism', None) + if magnetism is not None: + vector = magnetic_vector_for_layer(magnetism) + box.update(vector) + box['magnetic'] = True + box['has_moment'] = vector['m'] > moment_floor + return box def _gradient_box(assembly, assembly_index: int, colors: '_ColorMap') -> dict: diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 9945a011..07e62e32 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -808,6 +808,12 @@ def setLayerThetaMAtIndex(self, index: int, new_value: float) -> None: def _emitMagnetismChanged(self) -> None: """Magnetism edits change the model, its parameters and every curve.""" self._clearCacheAndEmitLayersChanged() + # The structure boxes carry the moment direction, so a theta_m or rho_m + # edit changes them without changing their number: without dropping the + # cache the Structure arrows keep pointing the old way until an + # unrelated layer edit happens to rebuild it. `externalRefreshPlot` is + # no substitute - it refreshes the charts, not the structure model. + self._clearStructureCacheAndEmit() self.externalRefreshPlot.emit() self.externalSampleChanged.emit() diff --git a/EasyReflectometryApp/Gui/GuideFieldLegend.qml b/EasyReflectometryApp/Gui/GuideFieldLegend.qml new file mode 100644 index 00000000..0cec0f58 --- /dev/null +++ b/EasyReflectometryApp/Gui/GuideFieldLegend.qml @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Elements as EaElements + +// The "H →" reference every arrow view puts on screen, so the direction the +// moment angles are measured from is visible rather than remembered. Drawn with +// the same component as the moment arrows, at phi = 0 by definition: the guide +// field *is* the zero of phi. +Row { + id: root + + property real glyphSize: EaStyle.Sizes.fontPixelSize * 1.5 + + spacing: EaStyle.Sizes.fontPixelSize * 0.25 + + EaElements.Label { + anchors.verticalCenter: parent.verticalCenter + text: "H" + color: EaStyle.Colors.themeForegroundMinor + } + + MagnetizationArrow { + anchors.verticalCenter: parent.verticalCenter + width: root.glyphSize + height: root.glyphSize + phi: 0 + color: EaStyle.Colors.themeForegroundMinor + outlineColor: "transparent" + } + + ToolTip.visible: hover.hovered + ToolTip.text: qsTr("Guide field direction. Moment angles are measured from it: θM = 270° points along H (no spin flip).") + + HoverHandler { + id: hover + } +} diff --git a/EasyReflectometryApp/Gui/MagnetizationAngleSlider.qml b/EasyReflectometryApp/Gui/MagnetizationAngleSlider.qml new file mode 100644 index 00000000..a0152af7 --- /dev/null +++ b/EasyReflectometryApp/Gui/MagnetizationAngleSlider.qml @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Elements as EaElements + +// The selected layer's moment angle as a slider across the full 0-360° range, +// with the guide field reference and the resulting arrow beside it. +// +// The slider edits θM - the parameter the table shows and a fit varies - so it +// writes exactly what the θM text field writes, and 0-360 is that parameter's +// own range. The arrow draws φ, the direction the moment *physically* points, +// which the backend derives: a negative ρM is the same moment reversed, so the +// arrow can turn while θM stays where it was put. +Row { + id: root + + // The parameter being edited, in degrees. + property real thetaM: 270 + // Physical moment direction, degrees counterclockwise from H. Display only. + property real phi: 0 + property bool hasMoment: true + // Whether the slider may be moved at all. + property bool editable: false + // The drag resolution, as on the dial this replaces. + property int snapDegrees: 5 + + signal thetaMRequested(real thetaM) + + readonly property real glyphSize: EaStyle.Sizes.fontPixelSize * 1.5 + + width: EaStyle.Sizes.sideBarContentWidth + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.Label { + id: nameLabel + anchors.verticalCenter: parent.verticalCenter + width: EaStyle.Sizes.fontPixelSize * 2 + text: qsTr("θM") + } + + EaElements.Slider { + id: slider + + anchors.verticalCenter: parent.verticalCenter + // Whatever the fixed columns do not take. + width: root.width - nameLabel.width - valueLabel.width - guideField.width - root.glyphSize + - 4 * root.spacing + + from: 0 + to: 360 + stepSize: root.snapDegrees + // stepSize alone only snaps the keyboard and the wheel; the dial this + // replaces snapped the drag too. + snapMode: Slider.SnapAlways + enabled: root.editable + + onMoved: root.thetaMRequested(value) + } + + // The model owns the value; a drag only borrows it. Not a plain `value:` + // binding: dragging assigns `value` imperatively and would break it for + // good, after which the handle stops following the model. A Binding stands + // aside while the handle is held and reasserts itself on release, so a + // write the model refused or clamped snaps the handle back instead of + // leaving it showing an angle the layer does not have. + Binding { + target: slider + property: "value" + value: root.thetaM + when: !slider.pressed + restoreMode: Binding.RestoreNone + } + + EaElements.Label { + id: valueLabel + anchors.verticalCenter: parent.verticalCenter + width: EaStyle.Sizes.fontPixelSize * 3.5 + horizontalAlignment: Text.AlignRight + text: root.thetaM.toFixed(1) + "°" + color: EaStyle.Colors.themeForegroundMinor + } + + // The reference the arrow is measured from, next to the arrow itself. + GuideFieldLegend { + id: guideField + anchors.verticalCenter: parent.verticalCenter + glyphSize: root.glyphSize + } + + MagnetizationArrow { + anchors.verticalCenter: parent.verticalCenter + width: root.glyphSize + height: root.glyphSize + phi: root.phi + hasMoment: root.hasMoment + color: EaStyle.Colors.themeForegroundHovered + outlineColor: "transparent" + } + + ToolTip.visible: hover.hovered + ToolTip.text: root.hasMoment + ? qsTr("Moment: %1° from H (θM %2°)").arg(root.phi.toFixed(1)).arg(root.thetaM.toFixed(1)) + + (root.editable ? '\n' + qsTr("Drag to set θM (%1° steps)").arg(root.snapDegrees) + : '\n' + qsTr("θM follows a constraint or a running fit and cannot be dragged")) + : qsTr("ρM is zero: there is no direction to show") + + HoverHandler { + id: hover + } +} diff --git a/EasyReflectometryApp/Gui/MagnetizationArrow.qml b/EasyReflectometryApp/Gui/MagnetizationArrow.qml new file mode 100644 index 00000000..df007538 --- /dev/null +++ b/EasyReflectometryApp/Gui/MagnetizationArrow.qml @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick + +// One layer's in-plane magnetic moment, drawn as a compass arrow seen along the +// surface normal: screen +x is the guide field H, and `phi` is the physical +// moment direction in degrees counterclockwise from it (see +// `magnetic_vector_for_layer` in the library, which is where phi is defined). +// +// The screen mapping is here and NOWHERE else. phi is counterclockwise in a +// y-up frame; `Item.rotation` is clockwise, so it takes exactly one negation. +// The canvas below draws a right-pointing arrow in local coordinates and +// contains no trigonometry at all, so Canvas's y-down axis never enters the +// picture and the two corrections cannot cancel into a mirrored arrow. +// +// Drawn with Canvas rather than QtQuick.Shapes: the installer excludes the +// Shapes plugin (pyproject.toml). +Item { + id: root + + // Physical moment direction, degrees counterclockwise from the guide field. + property real phi: 0 + // False for a magnetic layer whose moment is negligible: the direction of a + // zero-length vector means nothing, so a hollow dot is drawn instead of an + // arrow. Not the same as a non-magnetic layer, which draws nothing at all. + property bool hasMoment: true + property color color: "black" + // Thin light outline so the glyph stays readable on a saturated box fill. + property color outlineColor: Qt.rgba(1, 1, 1, 0.75) + + implicitWidth: 16 + implicitHeight: implicitWidth + width: implicitWidth + height: implicitHeight + + rotation: -phi + + onPhiChanged: canvas.requestPaint() + onHasMomentChanged: canvas.requestPaint() + onColorChanged: canvas.requestPaint() + + Canvas { + id: canvas + + anchors.fill: parent + antialiasing: true + + onPaint: { + const ctx = getContext("2d") + ctx.reset() + + const cx = width / 2 + const cy = height / 2 + const half = 0.45 * width + const head = 0.34 * width + + ctx.lineJoin = "round" + ctx.lineCap = "round" + ctx.fillStyle = root.color + ctx.strokeStyle = root.outlineColor + ctx.lineWidth = 1 + + if (!root.hasMoment) { + // "Magnetic layer, no moment": a hollow dot, not an arrow. + ctx.beginPath() + ctx.arc(cx, cy, 0.16 * width, 0, 2 * Math.PI) + ctx.strokeStyle = root.color + ctx.stroke() + return + } + + // Shaft, tail at -x, tip at +x. + ctx.beginPath() + ctx.moveTo(cx - half, cy) + ctx.lineTo(cx + half - head, cy) + ctx.strokeStyle = root.color + ctx.lineWidth = Math.max(1, 0.1 * width) + ctx.stroke() + + // Head, pointing at +x. + ctx.beginPath() + ctx.moveTo(cx + half, cy) + ctx.lineTo(cx + half - head, cy - 0.5 * head) + ctx.lineTo(cx + half - head, cy + 0.5 * head) + ctx.closePath() + ctx.fill() + ctx.lineWidth = 1 + ctx.strokeStyle = root.outlineColor + ctx.stroke() + } + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/StructureView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/StructureView.qml index 91e2914f..2af450c9 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/StructureView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/StructureView.qml @@ -4,6 +4,7 @@ import QtQuick.Controls import EasyApplication.Gui.Style as EaStyle import EasyApplication.Gui.Elements as EaElements +import Gui as Gui import Gui.Globals as Globals @@ -18,6 +19,12 @@ Rectangle { readonly property real capPx: 28 readonly property real minBoxPx: 22 readonly property real maxBoxPx: 120 + // Moment arrows: below the floor the glyph is unreadable and is dropped + // (the tooltip still carries the direction); the cap keeps it a marker + // rather than a picture. + readonly property real minGlyphPx: 12 + readonly property real maxGlyphPx: 32 + readonly property bool anyBoxMagnetic: boxes.some(box => box.magnetic === true) readonly property real stackWidth: Math.min(600, Math.max(Math.min(300, width - 4 * EaStyle.Sizes.fontPixelSize), 0.4 * width)) // Sum of proportional (non-cap) thicknesses readonly property real totalT: boxes.reduce((sum, box) => sum + (isCap(box) ? 0 : box.thickness), 0) @@ -60,6 +67,16 @@ Rectangle { color: EaStyle.Colors.themeForegroundMinor } + // The reference the arrows are measured from, on screen rather than in the + // user's memory. Only where there is an arrow to reference. + Gui.GuideFieldLegend { + z: 1 + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: EaStyle.Sizes.fontPixelSize + visible: root.anyBoxMagnetic + } + Flickable { id: flickable anchors.top: parent.top @@ -80,9 +97,21 @@ Rectangle { model: root.boxes Rectangle { + id: box + readonly property bool selected: modelData.assembly_index === Globals.BackendWrapper.sampleCurrentAssemblyIndex && modelData.layer_index === Globals.BackendWrapper.sampleCurrentLayerIndex + // Arrow zone: a square in the middle of the box, as tall as + // the box allows. It exists only for a magnetic layer, so a + // non-magnetic project keeps the two-column layout (centred + // name, right-pinned thickness) it has always had. + readonly property real gap: EaStyle.Sizes.fontPixelSize * 0.5 + readonly property real glyphPx: modelData.magnetic === true ? Math.min(height, root.maxGlyphPx) : 0 + readonly property bool showArrow: glyphPx >= root.minGlyphPx + readonly property real nameWidthLimit: showArrow ? Math.max(0, (width - glyphPx) / 2 - 2 * gap) + : width - EaStyle.Sizes.fontPixelSize + width: stack.width height: root.pixelHeight(modelData) color: root.fillColor(modelData.color) @@ -97,11 +126,26 @@ Rectangle { } EaElements.Label { - anchors.centerIn: parent + anchors.verticalCenter: parent.verticalCenter + x: box.showArrow ? box.gap : (box.width - width) / 2 visible: parent.height >= root.minBoxPx text: `${index} ${modelData.label}` elide: Text.ElideRight - width: Math.min(implicitWidth, parent.width - EaStyle.Sizes.fontPixelSize) + width: Math.min(implicitWidth, box.nameWidthLimit) + } + + // The layer's in-plane moment, in the middle column between + // the name and the thickness annotation. + Gui.MagnetizationArrow { + anchors.centerIn: parent + visible: box.showArrow + width: box.glyphPx + height: box.glyphPx + phi: modelData.phi ?? 0 + hasMoment: modelData.has_moment === true + // Not `channel_shade`: that only knows the spin channels. + // Against the box fill, a darker shade of the box colour. + color: Qt.darker(Qt.color(String(modelData.color)), 1.6) } // Thickness annotation @@ -144,6 +188,21 @@ Rectangle { ] if (modelData.repetitions > 1) lines.push(qsTr('Repeated × %1').arg(modelData.repetitions)) + if (modelData.magnetic === true) { + // Lead with the arrow's own quantity, then the + // parameters the sidebar table edits. + lines.push(modelData.has_moment + ? qsTr('Moment: %1° from H (θM %2°, ρM %3)') + .arg(modelData.phi.toFixed(1)) + .arg(modelData.theta_m.toFixed(1)) + .arg(modelData.rho_m.toFixed(3)) + : qsTr('Magnetic, no moment (ρM %1)').arg(modelData.rho_m.toFixed(3))) + lines.push(qsTr('M∥ %1 (no spin flip), M⊥ %2 (spin flip)') + .arg(modelData.m_par.toFixed(3)) + .arg(modelData.m_perp.toFixed(3))) + if (modelData.repetitions > 1) + lines.push(qsTr('The arrow is the repeat unit: every repeat shares these parameters.')) + } return lines.join('\n') } } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Magnetism.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Magnetism.qml index 76a0be5d..5064a24a 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Magnetism.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Magnetism.qml @@ -9,6 +9,7 @@ import EasyApplication.Gui.Style as EaStyle import EasyApplication.Gui.Elements as EaElements import EasyApplication.Gui.Components as EaComponents +import Gui as Gui import Gui.Globals as Globals @@ -30,6 +31,10 @@ EaElements.GroupBox { } property string errorMessage: '' + // The row the table has selected; null while the selection outruns the list. + readonly property var currentRow: + Globals.BackendWrapper.sampleLayersMagnetism[Globals.BackendWrapper.sampleCurrentLayerIndex] ?? null + EaElements.GroupColumn { EaElements.Label { @@ -143,6 +148,23 @@ EaElements.GroupBox { } } + // The selected row's angle as a slider: the same number the θM column + // holds, and the arrow the Structure view draws, side by side. Follows + // the selection the table already writes - no second "focused row". + Gui.MagnetizationAngleSlider { + visible: magnetismGroup.currentRow !== null && magnetismGroup.currentRow.magnetic === "True" + height: visible ? implicitHeight : 0 + phi: visible ? Number(magnetismGroup.currentRow.phi) : 0 + thetaM: visible ? Number(magnetismGroup.currentRow.theta_m) : 0 + hasMoment: visible && Number(magnetismGroup.currentRow.rho_m) !== 0 + // A constrained θM follows its expression, and a running fit owns + // every parameter: in both cases the slider is read-only. + editable: visible && magnetismGroup.currentRow.editable === "True" + && !Globals.BackendWrapper.analysisFittingRunning + onThetaMRequested: (thetaM) => Globals.BackendWrapper.sampleSetLayerThetaMAtIndex( + Globals.BackendWrapper.sampleCurrentLayerIndex, thetaM) + } + EaElements.Label { visible: magnetismGroup.supported color: EaStyle.Colors.themeForegroundMinor diff --git a/EasyReflectometryApp/Gui/qmldir b/EasyReflectometryApp/Gui/qmldir index 018d661c..55e580b0 100644 --- a/EasyReflectometryApp/Gui/qmldir +++ b/EasyReflectometryApp/Gui/qmldir @@ -2,6 +2,9 @@ module Gui ApplicationWindow ApplicationWindow.qml CalculationEngineControl CalculationEngineControl.qml +GuideFieldLegend GuideFieldLegend.qml +MagnetizationAngleSlider MagnetizationAngleSlider.qml +MagnetizationArrow MagnetizationArrow.qml MagneticProfileControl MagneticProfileControl.qml PlotControlRefLines PlotControlRefLines.qml SpinAsymmetryChart SpinAsymmetryChart.qml diff --git a/docs/src/tutorials/magnetism.md b/docs/src/tutorials/magnetism.md index ddfb3a4b..244ae421 100644 --- a/docs/src/tutorials/magnetism.md +++ b/docs/src/tutorials/magnetism.md @@ -33,6 +33,22 @@ example `Magnetism: Multi-layer`, and shows one row per layer of that assembly. scattering. This is the value to start from for a simple saturated film. ``` +### The moment angle slider + +Selecting a magnetic row shows a slider below the table, spanning the whole `0-360°` +range of `θM`, with the guide field reference **H →** and the resulting arrow beside it - +the same arrow the [Structure tab](#moment-arrows-on-the-structure-tab) draws. Its +tooltip gives the angle both ways: `φ` from **H**, and the `θM` the table edits. + +Dragging the slider sets `θM`, snapped to 5°; the text field remains the precise input. +The slider is read-only - and says so in its tooltip - while a fit is running, or when +`θM` follows a constraint, because then the parameter is not the user's to set. + +The slider edits `θM` itself, exactly as the table column does. The arrow beside it shows +`φ`, the direction the moment physically points, so with a **negative `ρM`** the arrow +points opposite the angle on the slider - the moment is reversed while the parameter +stays where it was put. + ### Switching the calculation engine Ticking **Magn.** while the project uses an engine that cannot model magnetism opens the @@ -78,6 +94,12 @@ The y-range of the SLD chart covers every visible curve and grows when a curve i on, so `ρ + ρM` is never clipped. If no model is magnetic, the chart, its legend and the sidebar are unchanged. +```{note} +The `refl1d` calculator cannot repeat slabs that carry magnetism, so a magnetic model with +a repeating multilayer has no magnetic depth profile at all - and therefore none of these +curves. The **Magnetic profile** group reports the reason. +``` + ```{note} For a magnetic sample the plain model curve is **not** an unpolarised average - the calculator returns the ↑↑ cross-section - so `R↑↑` is drawn on top of it. The sidebar says @@ -86,3 +108,49 @@ so as well. The `Analysis` reflectivity chart is unaffected by this switch: it already draws one calculated curve per measured spin channel when the experiment is polarised. + +## Which way the moments point + +`θM` is a number, and a stack of numbers does not show at a glance whether a model is +collinear, canted or twisted. The app therefore draws the moment as an arrow, in a single +convention shared by every view: + +- the arrows are a **top view along the surface normal** - a compass laid over the sample; +- screen **right is the guide field H**, and the angle drawn is `φ`, measured from **H** + counterclockwise: `φ = θM − 270°`; +- a **negative `ρM`** is the same moment reversed, so the arrow points the opposite way + and the tooltip carries the signed parameter; +- a magnetic layer whose `ρM` is below 1 % of the largest one in the model gets a **hollow + dot** - "magnetic, but no moment": the direction of a zero-length vector means nothing. + A layer with no magnetism at all gets nothing. + +| `θM` | Arrow | Physics | +|---|---|---| +| 270° (default) | → along **H** | collinear, no spin flip | +| 90° | ← against **H** | collinear reversed, no spin flip | +| 0° / 180° | ↑ / ↓ | fully transverse, maximal spin flip | +| 40° | ↖ (`φ` = 130°) | canted | + +Every arrow view shows the **H →** reference on screen. Tooltips lead with `φ`, then the +`θM` and signed `ρM` the sidebar edits, then the split `M∥` / `M⊥` - the components the +non-spin-flip and spin-flip channels see. + +Arrows are constant length everywhere: they encode direction only. The magnitude is the +`ρM` curve's job, and the exact value is in the tooltip. + +(moment-arrows-on-the-structure-tab)= +### On the Structure tab + +Each magnetic layer's box gets an arrow between its name and its thickness annotation. +This needs no switch: attaching magnetism *is* the request to see it. Boxes too short for +a readable glyph drop the arrow and keep it in the tooltip, and the box layout of a +non-magnetic sample is unchanged. + +The Structure tab draws the **current model**; switch models in the header to inspect +another one. A repeating multilayer that the tab collapses to its repeat unit shows one +arrow per drawn box - the direction of the repeat unit, which every repeat shares. + +Gradient layers get no arrow. A gradient has no single moment of its own, and one +"representative" arrow would be actively misleading when its slices oppose; the `ρM(z)` +and `θM(z)` curves remain the truth for graded structures. + diff --git a/pyproject.toml b/pyproject.toml index fc5d1417..0add971f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ requires-python = '>=3.12' dependencies = [ 'easyapplication', - 'easyreflectometry @ git+https://github.com/easyscience/reflectometry-lib.git@develop', + 'easyreflectometry @ git+https://github.com/easyscience/reflectometry-lib.git@show-spin', #'easyreflectometry', 'asteval', 'PySide6', diff --git a/tests/factories.py b/tests/factories.py index dd022373..3adac500 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -10,9 +10,11 @@ def __init__(self, value): class FlaggedValueHolder(ValueHolder): - def __init__(self, value, enabled=True): + def __init__(self, value, enabled=True, independent=True): super().__init__(value) self.enabled = enabled + # As on a real Parameter: False once the value follows a constraint. + self.independent = independent class FakeMaterial: @@ -22,6 +24,14 @@ def __init__(self, name, sld=0.0, isld=0.0): self.isld = ValueHolder(isld) +class FakeLayerMagnetism: + """Mirrors LayerMagnetism: rho_m/theta_m as fittable value holders.""" + + def __init__(self, rho_m=0.0, theta_m=270.0): + self.rho_m = FlaggedValueHolder(rho_m) + self.theta_m = FlaggedValueHolder(theta_m) + + class FakeLayer: def __init__( self, @@ -33,8 +43,10 @@ def __init__( area_per_molecule=0.1, solvent_fraction=0.2, molecular_formula='formula', + magnetism=None, ): self.name = name + self.magnetism = magnetism self.material = material or FakeMaterial('Air') self.solvent = solvent or FakeMaterial('D2O') self._thickness = FlaggedValueHolder(thickness) @@ -372,6 +384,7 @@ def __init__( name for name in (calculator_interfaces or ['refnx', 'refl1d']) if name == 'refl1d' ] self.models_have_magnetism = False + self.calculator_supports_magnetism = calculator_name == 'refl1d' self.minimizer = FakeMinimizerValue(minimizer_name) self._fitter = None self.fitter = None @@ -387,6 +400,9 @@ def __init__( self.inequality_constraints = [] self.calls = [] + def _sync_parameter_states(self): + self.calls.append(('_sync_parameter_states',)) + def violated_inequality_constraints(self): return [spec for spec in self.inequality_constraints if getattr(spec, 'violated', False)] diff --git a/tests/test_logic_layers.py b/tests/test_logic_layers.py index aa2c8617..9da99c75 100644 --- a/tests/test_logic_layers.py +++ b/tests/test_logic_layers.py @@ -1,5 +1,6 @@ from EasyReflectometryApp.Backends.Py.logic import layers as layers_module from tests.factories import FakeLayerAreaPerMolecule +from tests.factories import FakeLayerMagnetism from tests.factories import make_assembly from tests.factories import make_layer from tests.factories import make_layer_collection @@ -187,3 +188,49 @@ def test_layers_index_based_setters_ignore_invalid_indices(monkeypatch): assert logic._layers[0].material.name == 'Air' assert logic._layers[0].thickness.value == 10.0 + + +# The moment compass (spin-direction design A5/A6): phi is reported per row and +# set back through the guide-field convention, which never appears in QML. + + +def _magnetism_logic(rho_m=3.0, theta_m=40.0, independent=True): + magnetism = FakeLayerMagnetism(rho_m=rho_m, theta_m=theta_m) + magnetism.theta_m.independent = independent + materials = make_material_collection(make_material('Air'), make_material('Fe')) + sample = make_sample( + make_assembly( + name='Fe', + layers=[ + make_layer(name='Plain Layer', material=materials[0]), + make_layer(name='Fe Layer', material=materials[1], magnetism=magnetism), + ], + ) + ) + project = make_project(materials=materials, models=make_model_collection(make_model(sample=sample))) + return layers_module.Layers(project), magnetism + + +def test_magnetism_rows_report_the_drawn_direction(): + logic, _ = _magnetism_logic(theta_m=40.0) + + plain, magnetic = logic.magnetism + + assert magnetic['phi'] == '130.0' # 40 deg is 130 deg from the guide field + assert magnetic['editable'] == 'True' + # A non-magnetic layer has no direction and nothing to drag. + assert (plain['phi'], plain['editable']) == ('', '') + + +def test_a_constrained_theta_m_is_reported_as_not_editable(): + logic, _ = _magnetism_logic(theta_m=40.0, independent=False) + + # The slider reads this to go read-only rather than to write a refused value. + assert logic.magnetism[1]['editable'] == 'False' + + +def test_a_negative_moment_points_the_arrow_the_other_way(): + logic, _ = _magnetism_logic(rho_m=-3.0, theta_m=270.0) + + # theta_m is along the guide field, so the moment itself points against it. + assert logic.magnetism[1]['phi'] == '180.0' diff --git a/tests/test_logic_structure.py b/tests/test_logic_structure.py index 5391e8e4..6c15f5ba 100644 --- a/tests/test_logic_structure.py +++ b/tests/test_logic_structure.py @@ -1,6 +1,9 @@ +import pytest + from EasyReflectometryApp.Backends.Py.logic.structure import COLORS from EasyReflectometryApp.Backends.Py.logic.structure import flatten from tests.factories import FakeGradientLayer +from tests.factories import FakeLayerMagnetism from tests.factories import FakeRepeatingMultilayer from tests.factories import FakeSolvatedMaterial from tests.factories import make_assembly @@ -200,3 +203,115 @@ def test_legend_lists_only_used_materials_once(): {'label': 'Air', 'color': COLORS[0]}, {'label': 'Si', 'color': COLORS[1]}, ] + + +# Moment arrows (spin-direction design A3): a magnetic layer's box carries the +# in-plane direction, every other box is untouched. + + +def _magnetic_sample(materials, rho_m=3.0, theta_m=40.0): + return make_sample( + make_assembly(name='Top', layers=[make_layer(name='Air Layer', material=materials[0], thickness=0.0)]), + make_assembly( + name='Fe', + layers=[ + make_layer( + name='Fe Layer', + material=materials[1], + thickness=40.0, + magnetism=FakeLayerMagnetism(rho_m=rho_m, theta_m=theta_m), + ) + ], + ), + make_assembly(name='Bottom', layers=[make_layer(name='Si Layer', material=materials[2], thickness=0.0)]), + ) + + +def test_a_magnetic_layer_box_carries_the_moment_direction(): + materials = make_material_collection(make_material('Air'), make_material('Fe'), make_material('Si')) + + boxes, _, _ = flatten(_project(_magnetic_sample(materials), materials)) + + magnetic = boxes[1] + assert magnetic['magnetic'] is True + assert magnetic['has_moment'] is True + assert magnetic['phi'] == pytest.approx(130.0) # theta_m 40 is 130 deg from the guide field + assert magnetic['theta_m'] == pytest.approx(40.0) + assert magnetic['rho_m'] == pytest.approx(3.0) + + +def test_box_values_are_types_qml_can_read(): + """Every box value must be a builtin, never a numpy scalar. + + PySide6 hands a numpy.float64 in a QVariantList to QML as an opaque + PyObjectWrapper: `.toFixed()` on it throws, and the exception takes down + the whole binding that touched it - the layer tooltip renders empty rather + than reporting an error. + """ + materials = make_material_collection(make_material('Air'), make_material('Fe'), make_material('Si')) + + boxes, _, _ = flatten(_project(_magnetic_sample(materials), materials)) + + for box in boxes: + for key, value in box.items(): + assert type(value) in (str, int, float, bool), f'{key} is {type(value).__name__}' + + +def test_non_magnetic_boxes_omit_the_arrow_keys_entirely(): + materials = make_material_collection(make_material('Air'), make_material('Fe'), make_material('Si')) + + boxes, _, _ = flatten(_project(_magnetic_sample(materials), materials)) + + for box in (boxes[0], boxes[2]): + assert 'magnetic' not in box + assert 'phi' not in box + + +def test_a_negative_rho_m_flips_the_drawn_direction(): + materials = make_material_collection(make_material('Air'), make_material('Fe'), make_material('Si')) + + positive, _, _ = flatten(_project(_magnetic_sample(materials, rho_m=3.0), materials)) + negative, _, _ = flatten(_project(_magnetic_sample(materials, rho_m=-3.0), materials)) + + assert negative[1]['phi'] == pytest.approx((positive[1]['phi'] + 180.0) % 360.0) + assert negative[1]['m'] == pytest.approx(3.0) + + +def test_a_negligible_moment_is_flagged_as_having_none(): + materials = make_material_collection(make_material('Air'), make_material('Fe'), make_material('Si')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(material=materials[0], thickness=0.0)]), + make_assembly( + name='Strong', + layers=[make_layer(material=materials[1], thickness=40.0, magnetism=FakeLayerMagnetism(rho_m=4.0))], + ), + make_assembly( + name='Faint', + layers=[make_layer(material=materials[1], thickness=40.0, magnetism=FakeLayerMagnetism(rho_m=0.01))], + ), + make_assembly(name='Bottom', layers=[make_layer(material=materials[2], thickness=0.0)]), + ) + + boxes, _, _ = flatten(_project(sample, materials)) + + assert boxes[1]['has_moment'] is True + assert boxes[2]['has_moment'] is False # below 1 % of the largest moment + + +def test_gradient_boxes_stay_arrow_free(): + materials = make_material_collection(make_material('Air'), make_material('D2O')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(material=materials[0], thickness=0.0)]), + FakeGradientLayer(name='Grad', front_material=materials[0], back_material=materials[1], thickness=2.0), + make_assembly( + name='Fe', + layers=[make_layer(material=materials[1], thickness=40.0, magnetism=FakeLayerMagnetism(rho_m=3.0))], + ), + make_assembly(name='Bottom', layers=[make_layer(material=materials[1], thickness=0.0)]), + ) + + boxes, _, _ = flatten(_project(sample, materials)) + + # A gradient has no assembly-level moment vector to draw; the curves are the truth. + assert 'magnetic' not in boxes[1] + assert boxes[2]['magnetic'] is True diff --git a/tests/test_py_sample.py b/tests/test_py_sample.py index f7cf99e2..d92be442 100644 --- a/tests/test_py_sample.py +++ b/tests/test_py_sample.py @@ -1,4 +1,5 @@ from EasyReflectometryApp.Backends.Py.sample import Sample +from tests.factories import FakeLayerMagnetism from tests.factories import make_assembly from tests.factories import make_layer from tests.factories import make_material @@ -108,3 +109,68 @@ def test_set_current_model_index_refreshes_layers_and_selection(qcore_applicatio assert backend.currentLayerIndex == 0 assert [layer['material'] for layer in backend.layers] == ['D2O'] assert set(fired) == {'assembliesIndexChanged', 'layersIndexChanged', 'layersChange'} + + +def test_magnetism_edits_invalidate_the_structure_cache(qcore_application): + """The Structure boxes carry the moment direction, so a theta_m/rho_m edit + changes them without changing their number - the CR-Mo1 failure mode.""" + materials = make_material_collection(make_material('Air'), make_material('Fe'), make_material('Si')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(material=materials[0], thickness=0.0)]), + make_assembly( + name='Fe', + layers=[ + make_layer( + name='Fe Layer', + material=materials[1], + thickness=40.0, + magnetism=FakeLayerMagnetism(rho_m=3.0, theta_m=270.0), + ) + ], + ), + make_assembly(name='Bottom', layers=[make_layer(material=materials[2], thickness=0.0)]), + ) + project = make_project(materials=materials, models=make_model_collection(make_model(sample=sample))) + project.current_assembly_index = 1 + backend = Sample(project) + emitted = [] + backend.structureChanged.connect(lambda: emitted.append(True)) + + assert backend.structure[1]['phi'] == 0.0 # theta_m 270 points along the guide field + + backend.setLayerThetaMAtIndex(0, 40.0) + assert emitted == [True] + assert backend.structure[1]['phi'] == 130.0 + + backend.setLayerRhoMAtIndex(0, -3.0) + assert emitted == [True, True] + assert backend.structure[1]['phi'] == 310.0 # a negative moment points the other way + assert backend.structure[1]['m'] == 3.0 + + +def test_attaching_and_detaching_magnetism_rebuilds_the_structure(qcore_application): + materials = make_material_collection(make_material('Air'), make_material('Fe'), make_material('Si')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(material=materials[0], thickness=0.0)]), + make_assembly(name='Fe', layers=[make_layer(name='Fe Layer', material=materials[1], thickness=40.0)]), + make_assembly(name='Bottom', layers=[make_layer(material=materials[2], thickness=0.0)]), + ) + project = make_project( + materials=materials, + models=make_model_collection(make_model(sample=sample)), + calculator_name='refl1d', + ) + project.current_assembly_index = 1 + backend = Sample(project) + emitted = [] + backend.structureChanged.connect(lambda: emitted.append(True)) + + assert 'magnetic' not in backend.structure[1] + + backend.setLayerMagneticAtIndex(0, True) + assert emitted == [True] + assert backend.structure[1]['magnetic'] is True + + backend.setLayerMagneticAtIndex(0, False) + assert emitted == [True, True] + assert 'magnetic' not in backend.structure[1] diff --git a/tests/test_qml_magnetization_arrow.py b/tests/test_qml_magnetization_arrow.py new file mode 100644 index 00000000..1dce5f05 --- /dev/null +++ b/tests/test_qml_magnetization_arrow.py @@ -0,0 +1,72 @@ +"""Source-level assertions on the shared arrow components (no QML engine is +instantiated; rendering is verified by running the app). + +The physics convention itself - which phi an angle maps to - is pinned in the +library (`tests/test_magnetic_markers.py` there, all four cardinals plus the +canted example). What can go wrong *here* is the screen mapping: phi is +counterclockwise, Qt rotates clockwise and Canvas's y points down, so applying +both corrections silently mirrors every arrow. The contract that rules that out +is "one negation on the item, no trigonometry in the paint code", and that is +what these tests hold to. +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GUI = ROOT / 'EasyReflectometryApp' / 'Gui' +ARROW = GUI / 'MagnetizationArrow.qml' +LEGEND = GUI / 'GuideFieldLegend.qml' + + +def test_the_screen_mapping_is_a_single_negation_on_the_item(): + arrow_qml = ARROW.read_text(encoding='utf-8') + + assert 'rotation: -phi' in arrow_qml + + +def test_the_paint_code_carries_no_second_mapping(): + paint = ARROW.read_text(encoding='utf-8').split('onPaint:', 1)[1] + + # A sin/cos in the paint code would be the second half of the same + # correction and would cancel the item rotation into a mirrored arrow. + for banned in ('Math.sin', 'Math.cos', 'Math.atan', 'phi'): + assert banned not in paint, f'{banned} in the paint code is a second angle mapping' + # ... except for the hollow "no moment" dot, which is a circle, not a direction. + assert 'ctx.arc(' in paint + + +def test_arrows_are_drawn_with_canvas_because_shapes_is_not_packaged(): + arrow_qml = ARROW.read_text(encoding='utf-8') + + assert 'Canvas {' in arrow_qml + assert 'import QtQuick.Shapes' not in arrow_qml + assert 'ShapePath' not in arrow_qml + + +def test_the_arrow_repaints_when_its_inputs_change(): + arrow_qml = ARROW.read_text(encoding='utf-8') + + for handler in ('onPhiChanged', 'onHasMomentChanged', 'onColorChanged'): + assert f'{handler}: canvas.requestPaint()' in arrow_qml + + +def test_a_negligible_moment_draws_a_dot_rather_than_a_direction(): + arrow_qml = ARROW.read_text(encoding='utf-8') + + assert 'property bool hasMoment: true' in arrow_qml + assert 'if (!root.hasMoment)' in arrow_qml + + +def test_the_guide_field_legend_reuses_the_arrow_at_phi_zero(): + legend_qml = LEGEND.read_text(encoding='utf-8') + + assert 'MagnetizationArrow {' in legend_qml + assert 'phi: 0' in legend_qml + assert 'text: "H"' in legend_qml + + +def test_both_components_are_registered_in_the_gui_module(): + qmldir = (GUI / 'qmldir').read_text(encoding='utf-8') + + assert 'MagnetizationArrow MagnetizationArrow.qml' in qmldir + assert 'GuideFieldLegend GuideFieldLegend.qml' in qmldir diff --git a/tests/test_qml_magnetization_slider.py b/tests/test_qml_magnetization_slider.py new file mode 100644 index 00000000..3ff20854 --- /dev/null +++ b/tests/test_qml_magnetization_slider.py @@ -0,0 +1,90 @@ +"""Source-level assertions on the moment angle slider in the Magnetism group +(spin-direction design A5/A6). No QML engine is instantiated; the angle +convention itself is pinned in the library and in `test_logic_layers.py`. +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GUI = ROOT / 'EasyReflectometryApp' / 'Gui' +SLIDER = GUI / 'MagnetizationAngleSlider.qml' +GROUP = GUI / 'Pages' / 'Sample' / 'Sidebar' / 'Basic' / 'Groups' / 'Magnetism.qml' + + +def test_the_slider_spans_the_whole_theta_m_range(): + slider_qml = SLIDER.read_text(encoding='utf-8') + + assert 'from: 0' in slider_qml + assert 'to: 360' in slider_qml + # The dial snapped the drag to 5 deg; stepSize alone would only snap the + # keyboard and the wheel. + assert 'property int snapDegrees: 5' in slider_qml + assert 'stepSize: root.snapDegrees' in slider_qml + assert 'snapMode: Slider.SnapAlways' in slider_qml + + +def test_the_slider_reuses_the_shared_arrow_and_guide_field_reference(): + slider_qml = SLIDER.read_text(encoding='utf-8') + + assert 'MagnetizationArrow {' in slider_qml + assert 'phi: root.phi' in slider_qml + # An arrow is never on screen without the reference it is measured from. + assert 'GuideFieldLegend {' in slider_qml + assert 'import QtQuick.Shapes' not in slider_qml + + +def test_the_slider_edits_theta_m_and_leaves_phi_to_the_backend(): + slider_qml = SLIDER.read_text(encoding='utf-8') + + assert 'signal thetaMRequested(real thetaM)' in slider_qml + assert 'onMoved: root.thetaMRequested(value)' in slider_qml + # phi is drawn, never written: deriving it here would put the guide field + # convention in QML, where it is already wrong once. + assert 'phiRequested' not in slider_qml + assert '270' not in slider_qml.split('EaElements.Slider {', 1)[1] + + +def test_the_backend_stays_authoritative_after_a_drag(): + slider_qml = SLIDER.read_text(encoding='utf-8') + + # Dragging assigns `value` imperatively, so the model value is applied by a + # Binding that stands aside while the handle is held: a refused write must + # move the handle back rather than leave it where the drag left it. + assert 'Binding {' in slider_qml + assert 'property: "value"' in slider_qml + assert 'value: root.thetaM' in slider_qml + assert 'when: !slider.pressed' in slider_qml + + +def test_the_slider_follows_the_selection_the_table_already_writes(): + group_qml = GROUP.read_text(encoding='utf-8') + + assert 'Globals.BackendWrapper.sampleLayersMagnetism[Globals.BackendWrapper.sampleCurrentLayerIndex]' in group_qml + assert 'Gui.MagnetizationAngleSlider {' in group_qml + # Gone entirely - not just greyed out - for a layer with no magnetism. + assert 'visible: magnetismGroup.currentRow !== null && magnetismGroup.currentRow.magnetic === "True"' in group_qml + assert 'height: visible ? implicitHeight : 0' in group_qml + + +def test_the_group_writes_through_the_backend_and_refuses_when_it_must_not(): + group_qml = GROUP.read_text(encoding='utf-8') + + # The same call the theta_m column makes: one write path for one parameter. + assert 'sampleSetLayerThetaMAtIndex' in group_qml + assert 'magnetismGroup.currentRow.editable === "True"' in group_qml + assert '!Globals.BackendWrapper.analysisFittingRunning' in group_qml + + +def test_the_backend_contract_is_mirrored_in_the_wrapper_and_the_mock(): + wrapper = (GUI / 'Globals' / 'BackendWrapper.qml').read_text(encoding='utf-8') + mock = (ROOT / 'EasyReflectometryApp' / 'Backends' / 'Mock' / 'Sample.qml').read_text(encoding='utf-8') + + assert 'sampleSetLayerThetaMAtIndex' in wrapper + assert 'function setLayerThetaMAtIndex(index, value)' in mock + assert "'phi': '130.0'" in mock + assert "'editable': 'True'" in mock + + +def test_the_dial_is_gone(): + assert not (GUI / 'MagnetizationCompass.qml').exists() + assert 'MagnetizationCompass' not in (GUI / 'qmldir').read_text(encoding='utf-8') diff --git a/tests/test_qml_structure_view.py b/tests/test_qml_structure_view.py index 25824060..2d586ebf 100644 --- a/tests/test_qml_structure_view.py +++ b/tests/test_qml_structure_view.py @@ -37,3 +37,34 @@ def test_backend_wrapper_and_mock_expose_structure_properties(): assert name in mock_qml # Mock must keep the numeric thickness convention (not the all-string layers style) assert "'thickness': 2.5" in mock_qml + + +def test_structure_view_draws_moment_arrows_only_for_magnetic_layers(): + view_qml = (GUI / 'Pages' / 'Sample' / 'MainContent' / 'StructureView.qml').read_text(encoding='utf-8') + + # The arrow zone (and therefore the three-column layout) exists only where + # the backend marked the box magnetic; every other box is laid out as before. + assert "modelData.magnetic === true ? Math.min(height, root.maxGlyphPx) : 0" in view_qml + assert 'Gui.MagnetizationArrow {' in view_qml + assert 'visible: box.showArrow' in view_qml + assert 'phi: modelData.phi ?? 0' in view_qml + assert 'hasMoment: modelData.has_moment === true' in view_qml + # The name column yields to the arrow; the thickness column is untouched. + assert 'x: box.showArrow ? box.gap : (box.width - width) / 2' in view_qml + assert 'width: Math.min(implicitWidth, box.nameWidthLimit)' in view_qml + + +def test_guide_field_legend_is_gated_on_a_magnetic_box(): + view_qml = (GUI / 'Pages' / 'Sample' / 'MainContent' / 'StructureView.qml').read_text(encoding='utf-8') + + assert 'Gui.GuideFieldLegend {' in view_qml + assert 'visible: root.anyBoxMagnetic' in view_qml + assert 'boxes.some(box => box.magnetic === true)' in view_qml + + +def test_magnetic_tooltip_leads_with_the_angle_the_arrow_draws(): + view_qml = (GUI / 'Pages' / 'Sample' / 'MainContent' / 'StructureView.qml').read_text(encoding='utf-8') + + assert "qsTr('Moment: %1° from H (θM %2°, ρM %3)')" in view_qml + assert "qsTr('Magnetic, no moment (ρM %1)')" in view_qml + assert 'M∥ %1 (no spin flip), M⊥ %2 (spin flip)' in view_qml