From 086b299f70a4ffbc4e09ff166d5d3fd771e71972 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Wed, 7 Oct 2026 11:15:56 -0300 Subject: [PATCH] feat: limit the vertical stack of widgets with collapsible sections Add a SectionStack widget that keeps at most two sections open, saves the state, and shows a summary in the title of a collapsed section. Use it in steps 1 to 3 and in the feature details panel. Each page has one scroll area, at page level. The stratigraphic layers are a section of the column page, the problems list of step 4 is one line, and the dock footer is one line. --- docs/development/usability-plan.md | 272 +++++++++++++++++- loopstructural/gui/modelling/base_tab.py | 52 +--- .../gui/modelling/geological_history_tab.py | 2 +- .../feature_details_panel/_base.py | 39 ++- .../feature_details_panel/fault_panel.py | 1 - .../feature_details_panel/folded_panel.py | 1 - .../feature_details_panel/foliation_panel.py | 3 - .../geological_model_tab.py | 10 +- .../model_definition/model_definition_tab.py | 29 +- .../gui/modelling/modelling_widget.py | 11 +- loopstructural/gui/modelling/steps/pages.py | 72 +++-- .../gui/modelling/steps/section_limit.py | 37 +++ .../gui/modelling/steps/section_stack.py | 158 ++++++++++ .../stratigraphic_column.py | 32 ++- tests/unit/test_section_limit.py | 52 ++++ 15 files changed, 658 insertions(+), 113 deletions(-) create mode 100644 loopstructural/gui/modelling/steps/section_limit.py create mode 100644 loopstructural/gui/modelling/steps/section_stack.py create mode 100644 tests/unit/test_section_limit.py diff --git a/docs/development/usability-plan.md b/docs/development/usability-plan.md index bf23bd3..e14da21 100644 --- a/docs/development/usability-plan.md +++ b/docs/development/usability-plan.md @@ -467,21 +467,21 @@ Rules: Tasks: -- [ ] Add a small `SectionStack` widget in `gui/modelling/steps/`. It holds +- [x] Add a small `SectionStack` widget in `gui/modelling/steps/`. It holds collapsible sections, the maximum number of open sections, and the collapsed state of each section. It saves the state in the widget settings. -- [ ] Use it in steps 1, 2, 3 and in the feature details panel of step 4 +- [x] Use it in steps 1, 2, 3 and in the feature details panel of step 4 (`Data Layers`, `Interpolator Settings`, `Preview`, `Export Feature`). -- [ ] Remove the scroll areas that are inside other scroll areas +- [x] Remove the scroll areas that are inside other scroll areas (`BaseTab(scrollable=True)`, the scroll area of `feature_details_panel/_base.py`). -- [ ] Give the page a header summary for each collapsed section, for example +- [x] Give the page a header summary for each collapsed section, for example "Geology layer: Geology, UNITNAME", so the user does not need to expand it to read the value. -- [ ] Reduce the vertical use of the dock: the header and the footer use one +- [x] Reduce the vertical use of the dock: the header and the footer use one row each. The footer text is one line with a tooltip. -- [ ] In step 4, the problems list is collapsed to one line ("3 problems") with +- [x] In step 4, the problems list is collapsed to one line ("3 problems") with the list in a tooltip or a popup. Acceptance: at a window height of 700 px, the main widget of each step has at @@ -587,6 +587,256 @@ comes after 6.2, because it must lay out the final content of the pages. 6.4 does not depend on the others. 6.5 comes after 6.2, because it changes the same checks. +### Phase 7: Fold modelling + +Problem: LoopStructural can model folds with a fold frame and the discrete +fold interpolator (DFI). The plugin gives access to a part of this code only, +and the access is not complete: + +- "Attach fold frame" (`FoliationFeatureDetailsPanel`) and "Convert to + Structural Frame" change the feature in the current model only. They are not + in the spec of the feature. The next build (`update_model`) loses them, and + they are not saved with the state. The `folded_feature_name` key of + `add_foliation` is not used. +- `fold_frames` returns all structural frames. The user cannot make a fold + frame from axial surface data directly. +- The fold weights in `FoldedFeatureDetailsPanel` set only a value. The user + cannot turn a fold constraint off. LoopStructural turns a constraint off when + its weight is `None`, not `0`. +- The S-plot dialog (`splot.py`) exists, but no button opens it. It shows the + limb rotation data only. It does not show the fitted curve, and the user + cannot change the wavelength or the profile type. +- A fold frame cannot be folded by an older fold frame. Thus the user cannot + make a refolded fold. + +Aim: the user can model a fold with the axial surface constraint, the fold +axis constraint and the S-plot, all at one time, one at a time, or in pairs. +The user can make a polyphase fold model, for example the "Refolded folds" +example of the LoopStructural documentation +(`examples/2_fold/plot_2_refolded_folds.py`, data from `load_laurent2016`). + +#### Terms + +| Term | Meaning | LoopStructural | +|---|---|---| +| Fold event | One fold generation (for example F1). It has a name, an axial surface, a fold axis setting and S-plot settings. | `FoldEvent` | +| Fold frame | The curvilinear coordinate system of a fold event. Coordinate 0 is the axial surface. Coordinate 1 is the fold axis direction field. Coordinate 2 is normal to both. | `FoldFrame`, `create_and_add_fold_frame` | +| Folded feature | A foliation (for example bedding S0) that a fold event folds. | `create_and_add_folded_foliation` | +| Folded fold frame | The fold frame of an older fold event that a younger fold event folds (for example S1 folded by F2). | `create_and_add_folded_fold_frame` | +| Axial surface constraint | The gradient of the folded feature is normal to the fold direction. The fold direction comes from the fold frame and the fold limb rotation angle. | DFI `fold_orientation` | +| Fold axis constraint | The gradient of the folded feature is normal to the fold axis. | DFI `fold_axis_w` | +| S-plot | A plot of a fold rotation angle against a fold frame coordinate, with a fitted profile. The limb S-plot uses coordinate 0. The axis S-plot uses coordinate 1. | `fold_limb_rotation`, `fold_axis_rotation`, `SVariogram`, `limb_wl`, `axis_wl` | + +#### The three fold controls + +Each folded feature has three controls. Each control has a check box. The +user can select any combination that is in the table below. + +1. **Axial surface.** The user selects the fold event (and thus its fold + frame). This adds the axial surface constraint (`fold_orientation`). +2. **Fold axis.** The user selects the source of the fold axis: + - constant: plunge and azimuth (as now); + - average intersection lineation of the folded feature and the axial + surface (`av_fold_axis`, as now); + - lineation data: a layer of fold axis or intersection lineation + measurements. The plugin fits the fold axis rotation angle to + coordinate 1 (the axis S-plot). + + This adds the fold axis constraint (`fold_axis_w`). +3. **S-plot.** The user controls the rotation angle profiles: the profile + type (Fourier series, trigonometric), the wavelength, and fixed values for + the profile parameters. Without this control, the plugin fits the + profiles automatically (the LoopStructural default: Fourier series, and a + wavelength from the S-variogram). + +| Axial surface | Fold axis | S-plot | Result | +|---|---|---|---| +| - | - | - | A standard foliation. No fold constraint. | +| x | - | - | Fold frame and DFI. Axial surface constraint on. The fold axis is the average intersection lineation, but the fold axis constraint is off (`fold_axis_w = None`). Automatic profiles. | +| - | x | - | No fold frame. The plugin adds the fold axis as tangent constraints on a regular grid in the bounding box (gradient . axis = 0). The interpolator of the feature does not change. Only a constant fold axis is possible. | +| x | x | - | Fold frame and DFI. Both constraints on. Automatic profiles. | +| x | - | x | As "axial surface only", but with the limb profile of the user. | +| x | x | x | All constraints on. The limb profile of the user. With "lineation data", also the axis profile of the user. | +| - | - | x, or - x x | Not possible. An S-plot needs a fold frame coordinate. The S-plot check box is disabled until the user selects an axial surface. The tooltip says why. | + +Rules: + +- A control that is off sets its weight to `None`. Do not use `0` to turn a + constraint off. +- The weight of each constraint is in an "Advanced" section under its + control. The defaults are the LoopStructural defaults (`fold_orientation` + 10, `fold_axis_w` 10, `fold_normalisation` 1, `fold_norm` 1, + `fold_regularisation` [0.1, 0.01, 0.01]). +- A folded feature always uses DFI. The interpolator combo shows "DFI (fold)" + and is disabled. A fold frame uses the interpolator of the settings. + +#### Polyphase folds + +- A fold event can have "Folded by": an other, younger fold event. Then its + fold frame is a folded fold frame, and its own three fold controls apply to + coordinate 0 of that frame. +- A foliation, or the stratigraphic column group of the map workflow, can + have "Folded by": one fold event. +- The fold events and the folded features make a graph. The build order comes + from this graph: the youngest fold event first, then each feature after the + fold event that folds it. A cycle (F1 folded by F2, F2 folded by F1) is an + error. The UI does not let the user make a cycle. +- The feature list in step 4 shows the graph as a tree: each fold event, then + the features that it folds, under it. + +Workflow for the "Refolded folds" example. The user has three point layers +with orientations: `s2`, `s1` and `s0`. + +1. Step 1: set the bounding box. Select "Interpolate surfaces from + constraints". +2. Step 4: "Add Fold Event" F2. Axial surface data: the `s2` layer + (orientation) and one value point. Fold axis: off. Build F2. The plugin + shows the fold frame. +3. "Add Fold Event" F1. Axial surface data: the `s1` layer. Folded by: F2. + Axial surface: on. Fold axis: average. S-plot: on. The plugin builds the F2 + frame (current, so not again) and calculates the rotation angles of `s1`. + The S-plot shows the limb rotation angle of `s1` against F2 coordinate 0. + The user sets the wavelength to 4 (the S-variogram suggests a value). + Build F1. +4. "Add Foliation" S0 from the `s0` layer. Folded by: F1. Axial surface: on. + Fold axis: average. S-plot: on. The S-plot shows `s0` against F1 + coordinate 0. Build the model. +5. Step 5: view S0, S1 and S2 in the 3D view, and export the surfaces. + +#### Data model + +- Add `fold_events: Dict[str, dict]` to the model manager, in the form of + `manual_foliations` and `parametric_faults`. A spec has: + - `name`; + - `axial_surface_data`: layer dicts in the form of `add_foliation`, with a + `coord` key (0 for the axial foliation and the axial traces, 1 for the + fold axis direction data); + - `folded_by`: the name of a fold event, or `None`; + - `fold`: the fold controls of the fold frame (only when `folded_by` is + set), see below; + - the interpolator settings of the frame. +- Add a `fold` key to the spec of a manual foliation and to the generated + stratigraphic group: + - `fold_event`: the name of the fold event, or `None`; + - `axial_surface`: on or off, and `fold_orientation` weight; + - `fold_axis`: off, `constant` (plunge, azimuth), `average`, or `data` + (layer dicts), and `fold_axis_w` weight; + - `splot`: off or on; for the limb and for the axis profile: the type, the + wavelength (or "automatic"), and the fixed parameters; + - `fold_normalisation`, `fold_norm`, `fold_regularisation`. +- Replace the `folded_feature_name` key with `fold.fold_event`. Load old + state files with `fold_event = None`. +- `update_model` builds the fold events in the graph order, then the + features. It uses `create_and_add_fold_frame`, + `create_and_add_folded_fold_frame` and `create_and_add_folded_foliation`. + It does not use `add_fold_to_feature` on the current model. +- Save `fold_events` and the `fold` keys with the state + (`*_to_dict` / `*_from_dict`). +- Put the logic that does not need QGIS (the graph order, the cycle check, + the conversion from the controls to the LoopStructural arguments) in + `main/fold_spec.py`, so that unit tests can use it. + +#### Staged build for the S-plot + +The S-plot needs the fold frame before the folded feature is built. Thus: + +- "Calculate rotation angles" builds the fold frame (and the fold events that + fold it) if it is not current. Then it calculates the rotation angles of the + data of the feature. It does not build the folded feature. Run it in a + background task with progress. +- The S-plot panel shows the data points, the fitted curve, the S-variogram + and the suggested wavelengths. When the user changes the profile type, the + wavelength or a parameter, the plugin fits the curve again at once. This + does not interpolate. +- The values of the S-plot go into the spec. The next build uses them. +- When the fold frame changes (new data, a new interpolator setting), the + rotation angles are out of date. Use the derived-data record of phase 1: + the S-plot shows "Rotation angles are out of date" and an **Update** + button. The build calculates them again before it builds the folded + feature. + +#### Tasks + +Do the parts in this order. Each part is a separate pull request. + +7.1 Fold specs and build order + +- [ ] `main/fold_spec.py`: the spec form, the graph order, the cycle check, + and the conversion of the fold controls to the arguments of + `create_and_add_folded_foliation` (`fold_weights`, `av_fold_axis`, + `fold_axis`, `limb_wl`, `axis_wl`, profile types). +- [ ] Model manager: `fold_events`, the `fold` key, the build in graph order, + save and load. Remove the uses of `add_fold_to_feature` and of + `convert_feature_to_structural_frame` from the UI, or make them write + the spec. +- [ ] `fold_frames` returns the fold frames of the fold events only. + +7.2 Fold events in step 4 + +- [ ] "Add Feature -> Add Fold Event": a dialog with the name, the axial + surface layers (orientation, value, form line, with coordinate 0 or 1) + and "Folded by". +- [ ] A details panel for a fold event: data, "Folded by", interpolator + settings, and (for a folded fold frame) the fold controls. Use + `SectionStack` (6.3). +- [ ] The feature list shows the fold graph as a tree. +- [ ] Step check: a fold event with no orientation data for coordinate 0 is a + problem. A coordinate 0 with no value constraint is a warning ("Add an + axial trace or a point with a value"). + +7.3 The three fold controls + +- [ ] Replace "Attach fold frame" and the weight boxes of + `FoldedFeatureDetailsPanel` with one "Fold" section: "Folded by", and + the three controls with their check boxes and "Advanced" weights. +- [ ] Apply the rules of the combination table. Disable the S-plot control + when there is no axial surface. +- [ ] Fold axis "lineation data": a layer picker with the trend and plunge + fields. +- [ ] Fold axis without axial surface: make the tangent constraints on a grid + (`main/fold_spec.py`). The grid step comes from the bounding box and + the number of elements. +- [ ] Add "Folded by" to the stratigraphic column group in the map workflow. + +7.4 S-plot panel + +- [ ] Replace `SPlotDialog` with an S-plot panel: limb and axis tabs, data + points, fitted curve, S-variogram with suggested wavelengths, profile + type, wavelength, fixed parameters, misfit. +- [ ] "Calculate rotation angles" as a background task (staged build). +- [ ] Out-of-date state of the rotation angles in the derived-data record. + +7.5 Polyphase example and docs + +- [ ] Test data: the `load_laurent2016` data as three GeoPackage point layers + in `tests/data/`. +- [ ] A user guide page in `docs/usage` for the "Refolded folds" workflow, + with the S-plots. + +Acceptance: a user makes the "Refolded folds" model of the LoopStructural +documentation from the three layers, with the steps above and without Python. +The result is the same as the result of the example script (see the tests). +For one folded feature, the user can turn each of the three controls on and +off, and the build uses only the constraints that are on. After the user saves +and opens the project, the fold events, the "Folded by" links and the S-plot +values are the same, and a build gives the same model. + +Tests: + +- Unit tests (no QGIS) for `main/fold_spec.py`: the graph order of + F2 -> F1 -> S0; a cycle is an error; each row of the combination table gives + the correct arguments (a control that is off gives `None`); the S-plot is + refused without an axial surface; old specs with `folded_feature_name` load. +- A QGIS test that builds the refolded fold from the test layers and compares + the S0 scalar field on a coarse grid with the result of the LoopStructural + calls of the example (same data, same arguments). +- A test that the save and load of the state keeps the fold specs. + +Order and links: 7.1 first, because the other parts use the spec. 7.2 and 7.3 +depend on 7.1. 7.4 depends on 7.3 (the S-plot control). 7.5 comes last. Phase 7 +depends on 6.1 (the workflow mode), 6.3 (`SectionStack`) and 6.4 (the number of +elements of each feature, which is also used for the fold frames). + ## Risks - **Large UI change.** Users of the current version must learn the new layout. @@ -613,6 +863,16 @@ same checks. does not use it. Show a short message in step 4 ("The column and the map layers are not used in this mode"), so the user knows why a unit is not in the model. +- **Fold build time (phase 7).** A folded feature uses DFI, and a polyphase + model builds a chain of frames. The build is slow. Build only the frames + that are out of date, and show the progress of each frame. +- **Fold changes in old state files (phase 7).** A fold that the user added + with "Attach fold frame" is not in the saved state. Thus an old project + opens without that fold. Tell the user in the change log. +- **LoopStructural API (phase 7).** The plugin uses the fold builder + arguments (`limb_wl`, `axis_wl`, `av_fold_axis`, the profile types). Some of + them are keyword arguments, not public API. Pin the LoopStructural version + and add a test for each argument. ## Open questions diff --git a/loopstructural/gui/modelling/base_tab.py b/loopstructural/gui/modelling/base_tab.py index 386f85a..de8ce91 100644 --- a/loopstructural/gui/modelling/base_tab.py +++ b/loopstructural/gui/modelling/base_tab.py @@ -1,54 +1,16 @@ from qgis.gui import QgsCollapsibleGroupBox -from qgis.PyQt.QtCore import Qt -from qgis.PyQt.QtWidgets import QScrollArea, QSizePolicy, QVBoxLayout, QWidget +from qgis.PyQt.QtWidgets import QVBoxLayout, QWidget class BaseTab(QWidget): - def __init__(self, parent=None, data_manager=None, scrollable=False): + """A tab with a vertical layout. The page that holds the tab has the scroll area.""" + + def __init__(self, parent=None, data_manager=None): super().__init__(parent) self.data_manager = data_manager - # Initialize a default layout for all tabs - if scrollable: - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) - self.scroll_area = QScrollArea(self) - self.scroll_area.setWidgetResizable(True) - self.scroll_area.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) - # Create a container widget for the scroll area - self.container_widget = QWidget() - self.scroll_area.setWidget(self.container_widget) - # Ensure the scroll area and its container widget can handle focus and mouse events - self.scroll_area.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.scroll_area.setFrameShape( - QScrollArea.Shape.NoFrame - ) # Remove any unnecessary frame - - # Explicitly set size policies to ensure proper interaction - self.scroll_area.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding - ) - # Set up a layout for the container widget - self.container_layout = QVBoxLayout(self.container_widget) - # Set the main layout for the BaseTab - self.main_layout = QVBoxLayout(self) - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) - self.main_layout.addWidget(self.scroll_area) - - self.container_widget.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred - ) - - # Ensure the container widget propagates mouse events properly - self.container_widget.setAttribute( - Qt.WidgetAttribute.WA_TransparentForMouseEvents, False - ) - - self.setLayout(self.main_layout) - else: - # If not scrollable, use a simple layout - self.container_layout = QVBoxLayout(self) - self.setLayout(self.container_layout) - - # Set the layout for the tab + self.container_layout = QVBoxLayout(self) + self.container_layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.container_layout) def add_widget(self, widget, name=None, group_box=True): """Add a widget to the tab.""" diff --git a/loopstructural/gui/modelling/geological_history_tab.py b/loopstructural/gui/modelling/geological_history_tab.py index 4403f58..856c7a8 100644 --- a/loopstructural/gui/modelling/geological_history_tab.py +++ b/loopstructural/gui/modelling/geological_history_tab.py @@ -4,7 +4,7 @@ class GeologialHistoryTab(BaseTab): def __init__(self, parent=None, data_manager=None): - super().__init__(parent, data_manager, scrollable=False) + super().__init__(parent, data_manager) # Load the UI file for Tab 1 self.stratigraphic_column_widget = StratColumnWidget(self, data_manager=data_manager) # Add the loaded UI widget to the container layout diff --git a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py index 3bce57d..675eebc 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py @@ -11,7 +11,6 @@ QLabel, QMessageBox, QPushButton, - QScrollArea, QSpinBox, QVBoxLayout, QWidget, @@ -23,6 +22,7 @@ from ....background_task import finish_background_task, start_background_task from ....messages import push_info, push_warning +from ...steps.section_stack import SectionStack from ..bounding_box_widget import BoundingBoxWidget from ..layer_selection_table import LayerSelectionTable @@ -85,24 +85,13 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage self.feature = feature self.model_manager = model_manager self.data_manager = data_manager - # Create a scroll area for horizontal scrolling - scroll = QScrollArea(self) - scroll.setWidgetResizable(True) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - - # Create content widget to hold the form layout - content = QWidget() - self.layout = QVBoxLayout(content) - # Set the content widget as the scroll area's widget - scroll.setWidget(content) - - # Add scroll area to main layout + # The sections of the panel. At most two are open. The page that holds + # the panel has the scroll area. mainLayout = QVBoxLayout(self) - mainLayout.addWidget(scroll) - - # Set the main layout - self.setLayout(mainLayout) + mainLayout.setContentsMargins(0, 0, 0, 0) + self.layout = SectionStack(self, 'feature_details', data_manager) + mainLayout.addWidget(self.layout) + mainLayout.addStretch(1) ## define interpolator parameters # Regularisation spin box @@ -176,9 +165,17 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage form_layout.addRow('Orientation point weight', self.npw_spin_box) group_box = QgsCollapsibleGroupBox('Interpolator Settings') group_box.setLayout(form_layout) - self.layout.addWidget(group_box) - self.layout.addWidget(self._build_preview_widget()) - self.layout.addWidget(table_group_box) + self.layout.add_section( + group_box, + 'interpolator', + 'Interpolator Settings', + summary=lambda: ( + f"{self.interpolator_type_combo.currentText()}, " + f"{int(self.n_elements_spinbox.value())} elements" + ), + ) + self.layout.add_section(self._build_preview_widget(), 'preview', 'Preview', collapsed=True) + self.layout.add_section(table_group_box, 'data_layers', 'Data Layers') # this will call the addMidBlock and addExportBlock methods self.addMidBlock() self.addExportBlock() diff --git a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/fault_panel.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/fault_panel.py index fab6f05..e9b37ef 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/fault_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/fault_panel.py @@ -115,4 +115,3 @@ def update_dip(value): self.layout.addWidget(orientation_group) self.layout.addWidget(ellipsoid_group) - self.setLayout(self.layout) diff --git a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/folded_panel.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/folded_panel.py index b657e28..7be5577 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/folded_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/folded_panel.py @@ -123,7 +123,6 @@ def addMidBlock(self): group_box.setLayout(form_layout) self.layout.addWidget(group_box) # Remove redundant layout setting - self.setLayout(self.layout) def open_splot_dialog(self): dialog = SPlotDialog( diff --git a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/foliation_panel.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/foliation_panel.py index 9d11642..95591f7 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/foliation_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/foliation_panel.py @@ -29,8 +29,5 @@ def addMidBlock(self): group_box.setLayout(form_layout) self.layout.addWidget(group_box) - # Remove redundant layout setting - self.setLayout(self.layout) - def on_fold_frame_changed(self, text): self.model_manager.add_fold_to_feature(self.feature.name, fold_frame_name=text) diff --git a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py index f7b81c4..b38be09 100644 --- a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py @@ -23,6 +23,7 @@ from ....main.workflow_mode import DEFAULT_WORKFLOW_MODE, WORKFLOW_MODE_CONSTRAINTS from ...messages import push_info from ..steps import build_plan +from ..steps.section_stack import page_scroll_area from .add_fault_dialog import AddFaultDialog from .add_foliation_dialog import AddFoliationDialog from .add_unconformity_dialog import AddUnconformityDialog @@ -187,7 +188,7 @@ def __init__(self, parent=None, *, model_manager=None, data_manager=None): # The problems of all steps. They show before the build, so the user # sees why a build can fail or can give a poor model. self.problemsLabel = QLabel() - self.problemsLabel.setWordWrap(True) + self.problemsLabel.setWordWrap(False) self.problemsLabel.setTextFormat(Qt.TextFormat.RichText) self.problemsLabel.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) mainLayout.insertWidget(1, self.problemsLabel, 0) @@ -316,9 +317,11 @@ def refresh_primary_action(self, *args, **kwargs): problems = self._problems_provider() if self._problems_provider is not None else [] if problems: lines = "".join(f"
  • {html.escape(message)}
  • " for _key, message in problems) + count = len(problems) self.problemsLabel.setText( - f"Check before the build:" + f"Check before the build: {count} problem{'s' if count != 1 else ''}" ) + self.problemsLabel.setToolTip(f"") self.problemsLabel.show() else: self.problemsLabel.hide() @@ -756,7 +759,8 @@ def _set_details_panel(self, panel, feature_name, feature): # Dynamically replace the featureDetailsPanel widget splitter = self._splitter splitter.widget(1).deleteLater() # Remove the existing widget - splitter.addWidget(panel) # Add the new widget + # The page has one scroll area, here. The panel does not have its own. + splitter.addWidget(page_scroll_area(panel)) def _on_model_update_started(self): """Show a non-blocking indeterminate progress dialog for model updates. diff --git a/loopstructural/gui/modelling/model_definition/model_definition_tab.py b/loopstructural/gui/modelling/model_definition/model_definition_tab.py index e25da31..da89f8d 100644 --- a/loopstructural/gui/modelling/model_definition/model_definition_tab.py +++ b/loopstructural/gui/modelling/model_definition/model_definition_tab.py @@ -1,6 +1,7 @@ from qgis.PyQt.QtWidgets import QSizePolicy from loopstructural.gui.modelling.base_tab import BaseTab +from loopstructural.gui.modelling.steps.section_stack import SectionStack from .bounding_box import BoundingBoxWidget from .dem import DEMWidget @@ -14,8 +15,7 @@ class ModelDefinitionTab(BaseTab): """ def __init__(self, parent=None, data_manager=None): - super().__init__(parent, data_manager, scrollable=True) - # Add widgets to the QToolBox + super().__init__(parent, data_manager) self.bounding_box = BoundingBoxWidget(self, data_manager) self.dem = DEMWidget(self, data_manager) @@ -23,5 +23,26 @@ def __init__(self, parent=None, data_manager=None): for widget in [self.bounding_box, self.dem]: widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - self.add_widget(self.bounding_box, 'Bounding Box') - self.add_widget(self.dem, 'DEM') + self.sections = SectionStack(self, 'data', data_manager) + self.sections.add_section( + self.bounding_box, + 'bounding_box', + 'Bounding Box', + summary=self._bounding_box_summary, + ) + self.sections.add_section(self.dem, 'dem', 'DEM', summary=self._dem_summary) + self.container_layout.addWidget(self.sections) + self.container_layout.addStretch(1) + + def _bounding_box_summary(self): + if self.data_manager is None or not self.data_manager.is_bounding_box_set(): + return "not set" + return "set" + + def _dem_summary(self): + if self.data_manager is None: + return "" + layer = getattr(self.data_manager, 'dem_layer', None) + if getattr(self.data_manager, 'use_dem', False) and layer is not None: + return layer.name() + return "flat elevation" diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index cf1b4be..cae62c2 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -3,6 +3,7 @@ QHBoxLayout, QLabel, QPushButton, + QSizePolicy, QStackedWidget, QVBoxLayout, QWidget, @@ -18,6 +19,7 @@ StratigraphyStep, ViewStep, ) +from loopstructural.gui.modelling.steps.section_stack import SectionStack from loopstructural.gui.modelling.steps.step_bar import StepBar # How often the checks of the steps run again, in milliseconds. Several @@ -88,7 +90,10 @@ def __init__( self.stack.addWidget(page) self.footer_label = QLabel(self) - self.footer_label.setWordWrap(True) + # One line. The tooltip has all messages. The label can be shorter than + # its text, so a long message does not make the dock wider or taller. + self.footer_label.setWordWrap(False) + self.footer_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) self.back_button = QPushButton("< Back", self) self.next_button = QPushButton("Next >", self) self.back_button.clicked.connect(lambda _checked=False: self.go_to(self._neighbour(-1))) @@ -99,6 +104,8 @@ def __init__( footer.addWidget(self.next_button) mainLayout = QVBoxLayout(self) + mainLayout.setContentsMargins(4, 2, 4, 2) + mainLayout.setSpacing(2) mainLayout.addWidget(self.header) mainLayout.addWidget(self.step_bar) mainLayout.addWidget(self.stack, 1) @@ -203,6 +210,8 @@ def refresh_status(self): self.model_step.tab.refresh_primary_action() self.view_step.refresh() self.next_button.setText(self._next_text(self.current_index)) + for section_stack in self.findChildren(SectionStack): + section_stack.refresh_summaries() page = self.pages[self.current_index] check = results[page.key] if check.messages: diff --git a/loopstructural/gui/modelling/steps/pages.py b/loopstructural/gui/modelling/steps/pages.py index 9ab23af..d5c6be6 100644 --- a/loopstructural/gui/modelling/steps/pages.py +++ b/loopstructural/gui/modelling/steps/pages.py @@ -5,17 +5,13 @@ """ from qgis.core import QgsApplication -from qgis.gui import QgsCollapsibleGroupBox from qgis.PyQt.QtCore import Qt, pyqtSignal from qgis.PyQt.QtWidgets import ( QComboBox, QHBoxLayout, QLabel, QMenu, - QFrame, QPushButton, - QScrollArea, - QTabWidget, QToolButton, QVBoxLayout, QWidget, @@ -31,10 +27,12 @@ StratigraphicLayersWidget, ) +from loopstructural.main import layer_roles from loopstructural.main.workflow_mode import WORKFLOW_MODE_LABELS, WORKFLOW_MODES from . import checks from .export_panel import ExportPanel +from .section_stack import SectionStack, page_scroll_area class StepPage(QWidget): @@ -101,8 +99,7 @@ def __init__(self, parent=None, **kwargs): mode_row.addWidget(self.mode_combo, 1) layout.addLayout(mode_row) self.tab = ModelDefinitionTab(self, data_manager=self.data_manager) - layout.addWidget(self.tab, 1) - + layout.addWidget(page_scroll_area(self.tab, self), 1) def set_workflow_mode(self, mode): index = self.mode_combo.findData(mode) @@ -140,11 +137,18 @@ def __init__(self, parent=None, **kwargs): "Paint the order on the map...", lambda: self._show_tool(launchers.PAINT_STRAT_ORDER) ).setToolTip("Show the order of the column on the geology polygons.") + # The source layers are a section of the column stack, so the page has + # at most two open sections and one scroll area. self.stratigraphy_layers = StratigraphicLayersWidget(self, self.data_manager) - layers_scroll = QScrollArea(self) - layers_scroll.setWidgetResizable(True) - layers_scroll.setFrameShape(QFrame.Shape.NoFrame) - layers_scroll.setWidget(self.stratigraphy_layers) + sections = self.tab.stratigraphic_column_widget.sections + sections.add_section( + self.stratigraphy_layers, + 'layers', + 'Stratigraphic layers', + summary=self._layers_summary, + collapsed=self._layers_are_set(), + index=1, + ) derive = self._tool_button( "Derive from map", @@ -160,13 +164,28 @@ def __init__(self, parent=None, **kwargs): row.addStretch(1) row.addWidget(derive) layout.addLayout(row) - - # Two tabs, so the page is not tall: the source layers and the column - self.tabs = QTabWidget(self) - self.tabs.addTab(layers_scroll, "Source layers") - self.tabs.addTab(self.tab, "Column") - self.tabs.setCurrentWidget(self.tab) - layout.addWidget(self.tabs, 1) + layout.addWidget(page_scroll_area(self.tab, self), 1) + + def _layers_are_set(self): + """The section starts collapsed after the first setup.""" + if self.data_manager is None: + return False + return self.data_manager.get_layer_role(layer_roles.STRUCTURE) is not None + + def _layers_summary(self): + if self.data_manager is None: + return "" + roles = self.data_manager.layer_roles + role = ( + layer_roles.GEOLOGY + if roles.contacts_source == layer_roles.CONTACTS_FROM_GEOLOGY + else layer_roles.BASAL_CONTACTS + ) + parts = [] + for value in (self.data_manager.get_layer_role(role), roles.get(layer_roles.STRUCTURE)): + if value is not None: + parts.append(value.name() if hasattr(value, 'name') else str(value)) + return ", ".join(parts) if parts else "not selected" class FaultsStep(StepPage): @@ -179,10 +198,11 @@ def __init__(self, parent=None, **kwargs): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) self.fault_layers = FaultLayersWidget(self, self.data_manager) - group = QgsCollapsibleGroupBox("Fault layer", self) - group_layout = QVBoxLayout(group) - group_layout.addWidget(self.fault_layers) - layout.addWidget(group) + self.sections = SectionStack(self, 'faults', self.data_manager) + self.sections.add_section( + self.fault_layers, 'fault_layer', 'Fault layer', summary=self._fault_layer_summary + ) + layout.addWidget(self.sections) topology = QPushButton("Calculate topology...", self) topology.setToolTip("Find which faults touch each other, from the fault traces.") @@ -193,7 +213,15 @@ def __init__(self, parent=None, **kwargs): layout.addLayout(row) self.adjacency = FaultAdjacencyTab(self, data_manager=self.data_manager) - layout.addWidget(self.adjacency, 1) + layout.addWidget(page_scroll_area(self.adjacency, self), 1) + + def _fault_layer_summary(self): + if self.data_manager is None: + return "" + layer = self.data_manager.get_layer_role(layer_roles.FAULT_TRACES) + if layer is None: + return "not selected" + return layer.name() if hasattr(layer, 'name') else str(layer) class ModelStep(StepPage): diff --git a/loopstructural/gui/modelling/steps/section_limit.py b/loopstructural/gui/modelling/steps/section_limit.py new file mode 100644 index 0000000..f9d8073 --- /dev/null +++ b/loopstructural/gui/modelling/steps/section_limit.py @@ -0,0 +1,37 @@ +"""The rule for the open sections of a page. This module does not import QGIS.""" + +DEFAULT_MAX_OPEN = 2 + + +class OpenSectionLimiter: + """Keep the number of open sections at or below a maximum. + + The sections are in the order in which the user opened them. When a section + opens and the maximum is passed, the section that opened first closes. + """ + + def __init__(self, max_open=DEFAULT_MAX_OPEN): + self.max_open = max_open + self._open = [] + + @property + def open_keys(self): + """The keys of the open sections, the oldest first.""" + return list(self._open) + + def is_open(self, key): + return key in self._open + + def set_open(self, key, is_open): + """Open or close a section. Return the keys of the sections that closed.""" + if not is_open: + if key in self._open: + self._open.remove(key) + return [] + if key in self._open: + return [] + self._open.append(key) + closed = [] + while len(self._open) > self.max_open: + closed.append(self._open.pop(0)) + return closed diff --git a/loopstructural/gui/modelling/steps/section_stack.py b/loopstructural/gui/modelling/steps/section_stack.py new file mode 100644 index 0000000..b01609a --- /dev/null +++ b/loopstructural/gui/modelling/steps/section_stack.py @@ -0,0 +1,158 @@ +"""A stack of collapsible sections that limits the height of a page. + +`OpenSectionLimiter` has the rule and does not need Qt. `SectionStack` shows +the sections and saves the state in the widget settings of the data manager. +""" + +from qgis.gui import QgsCollapsibleGroupBox +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget + +from .section_limit import DEFAULT_MAX_OPEN, OpenSectionLimiter + + +def page_scroll_area(widget, parent=None): + """Return the scroll area of a page. A page has only this one scroll area.""" + scroll = QScrollArea(parent) + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll.setWidget(widget) + return scroll + + +class SectionStack(QWidget): + """A vertical stack of `QgsCollapsibleGroupBox` sections. + + At most ``max_open`` sections are open. A collapsed section can show a + summary of its value in its title, for example "Geology layer: Geology". + + Parameters + ---------- + name : str + The name for the saved state. Use one name for each stack. + data_manager : optional + The state of the sections is saved in its widget settings. + """ + + def __init__(self, parent=None, name='sections', data_manager=None, max_open=DEFAULT_MAX_OPEN): + super().__init__(parent) + self.name = name + self.data_manager = data_manager + self.limiter = OpenSectionLimiter(max_open) + self._sections = {} # key -> (group, title, summary) + self._updating = False + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self._layout = layout + + @property + def _settings_key(self): + return f'section_stack_{self.name}' + + def _saved_state(self): + if self.data_manager is None: + return {} + return dict(self.data_manager.get_widget_settings(self._settings_key, {}) or {}) + + def _save_state(self): + if self.data_manager is not None: + self.data_manager.set_widget_settings( + self._settings_key, {key: self.is_open(key) for key in self._sections} + ) + + def add_section( + self, widget, key, title, *, summary=None, collapsed=False, index=None + ): + """Add a section and return its group box. + + ``widget`` is the content, or a `QgsCollapsibleGroupBox` that the stack + uses as the section. ``summary`` is a function that returns the text for + the title of a collapsed section. A saved state replaces ``collapsed``. + """ + if isinstance(widget, QgsCollapsibleGroupBox): + group = widget + group.setTitle(title) + else: + group = QgsCollapsibleGroupBox(title) + group_layout = QVBoxLayout(group) + group_layout.addWidget(widget) + saved = self._saved_state() + if key in saved: + collapsed = not saved[key] + self._sections[key] = (group, title, summary) + if index is None: + self._layout.addWidget(group) + else: + self._layout.insertWidget(index, group) + self._updating = True + try: + group.setCollapsed(collapsed) + for closed in self.limiter.set_open(key, not collapsed): + self._set_group_collapsed(closed, True) + finally: + self._updating = False + group.collapsedStateChanged.connect( + lambda is_collapsed, k=key: self._on_collapsed_changed(k, is_collapsed) + ) + self.refresh_summaries() + return group + + def addWidget(self, widget): + """Add a group box as a section, or any other widget as it is. + + This lets the code that fills a layout fill a stack. + """ + if isinstance(widget, QgsCollapsibleGroupBox) and widget.title(): + title = widget.title() + self.add_section(widget, title, title, collapsed=widget.isCollapsed()) + else: + self._layout.addWidget(widget) + + def section(self, key): + return self._sections[key][0] + + def is_open(self, key): + return self.limiter.is_open(key) + + def set_open(self, key, is_open): + """Open or close a section, with the same rules as a click of the user.""" + self.section(key).setCollapsed(not is_open) + + def _set_group_collapsed(self, key, collapsed): + group = self._sections[key][0] + group.setCollapsed(collapsed) + self._update_title(key) + + def _on_collapsed_changed(self, key, is_collapsed): + if self._updating: + return + self._updating = True + try: + for closed in self.limiter.set_open(key, not is_collapsed): + self._set_group_collapsed(closed, True) + finally: + self._updating = False + self.refresh_summaries() + self._save_state() + + def _update_title(self, key): + group, title, summary = self._sections[key] + text = title + if summary is not None and group.isCollapsed(): + try: + value = summary() + except Exception: + value = '' + if value: + text = f"{title}: {value}" + group.setTitle(text) + + def refresh_summaries(self): + """Write the summary into the title of each collapsed section.""" + for key in self._sections: + self._update_title(key) + + def showEvent(self, event): + super().showEvent(event) + self.refresh_summaries() diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 313e33d..7749b9a 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -8,7 +8,6 @@ QComboBox, QDialog, QFormLayout, - QGroupBox, QHBoxLayout, QLabel, QListWidget, @@ -28,6 +27,8 @@ from loopstructural.main import derived_data, layer_roles from loopstructural.main.helpers import ColumnMatcher, get_layer_names +from loopstructural.gui.modelling.steps.section_stack import SectionStack + from .init_from_field_dialog import InitFromLayerFieldDialog from .stratigraphic_unit import StratigraphicUnitWidget @@ -91,7 +92,22 @@ def __init__(self, parent=None, data_manager=None): # The geology layer and its unit name field. The layer is shared with # the map2loop tools, and the "Style map layer" group writes to it. - layout.addWidget(self._build_geology_group()) + # The sections are in one stack, so at most two are open. The page adds + # more sections (see `StratigraphyStep`). + self.sections = SectionStack(self, 'stratigraphy', data_manager) + self.sections.add_section( + self._build_geology_group(), + 'geology', + 'Geology layer', + summary=self._geology_summary, + ) + self.sections.add_section( + self._build_style_group(), + 'style', + 'Style map layer', + collapsed=True, + ) + layout.addWidget(self.sections) layout.addLayout(self._build_actions_row()) @@ -106,8 +122,6 @@ def __init__(self, parent=None, data_manager=None): layout.addWidget(self.unitList, 1) layout.addWidget(QLabel("Oldest")) - layout.addWidget(self._build_style_group()) - self._add_derived_data_panel(layout) self._guess_units_layer() @@ -283,7 +297,7 @@ def _set_update_buttons_enabled(self, enabled): def _build_geology_group(self): """Build the group with the geology layer, the unit name field and a summary of the unit names that have no match in the layer.""" - group = QGroupBox("Geology layer", self) + group = QgsCollapsibleGroupBox("Geology layer", self) form = QFormLayout(group) self.unitsLayerComboBox = QgsMapLayerComboBox() configure_layer_combo( @@ -302,6 +316,14 @@ def _build_geology_group(self): form.addRow(self.unitNamesSummaryLabel) return group + def _geology_summary(self): + """The layer and the field, for the title of the collapsed section.""" + layer = self.unitsLayerComboBox.currentLayer() + if layer is None: + return "not selected" + field = self.unitsLayerFieldComboBox.currentField() + return f"{layer.name()}, {field}" if field else layer.name() + def _build_actions_row(self): """Build the row of buttons that change the column.""" addUnitButton = QPushButton("+ Unit", self) diff --git a/tests/unit/test_section_limit.py b/tests/unit/test_section_limit.py new file mode 100644 index 0000000..2f40b65 --- /dev/null +++ b/tests/unit/test_section_limit.py @@ -0,0 +1,52 @@ +"""Pytest tests for the rule that limits the open sections of a page. + +The module does not import QGIS, so the tests run in the fast tests/unit/ job. +""" + +from loopstructural.gui.modelling.steps.section_limit import OpenSectionLimiter + + +class TestOpenSectionLimiter: + def test_default_maximum_is_two(self): + assert OpenSectionLimiter().max_open == 2 + + def test_two_sections_stay_open(self): + limiter = OpenSectionLimiter(2) + assert limiter.set_open('a', True) == [] + assert limiter.set_open('b', True) == [] + assert limiter.open_keys == ['a', 'b'] + + def test_third_section_closes_the_first_opened(self): + limiter = OpenSectionLimiter(2) + limiter.set_open('a', True) + limiter.set_open('b', True) + assert limiter.set_open('c', True) == ['a'] + assert limiter.open_keys == ['b', 'c'] + assert not limiter.is_open('a') + + def test_order_is_the_order_of_opening(self): + limiter = OpenSectionLimiter(2) + limiter.set_open('a', True) + limiter.set_open('b', True) + limiter.set_open('a', False) + limiter.set_open('a', True) + # b opened before the second opening of a + assert limiter.set_open('c', True) == ['b'] + + def test_closing_gives_no_other_closes(self): + limiter = OpenSectionLimiter(2) + limiter.set_open('a', True) + assert limiter.set_open('a', False) == [] + assert limiter.set_open('never_opened', False) == [] + assert limiter.open_keys == [] + + def test_open_again_does_nothing(self): + limiter = OpenSectionLimiter(2) + limiter.set_open('a', True) + assert limiter.set_open('a', True) == [] + assert limiter.open_keys == ['a'] + + def test_other_maximum(self): + limiter = OpenSectionLimiter(1) + limiter.set_open('a', True) + assert limiter.set_open('b', True) == ['a']