diff --git a/README.md b/README.md index 1386371..116de8e 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ fig.savefig('trig.pdf', folder=True) ## GUI Editor -gleplot also ships a desktop editor (PySide6) for building figures without writing code: load data, pick columns, style series, arrange subplots, and export -- all with a live preview. +gleplot also ships a desktop editor (PySide6) for building figures without writing code: load data, pick columns, style series, arrange subplots, annotate, and export -- all with a live preview. ### Install ```bash @@ -61,9 +61,11 @@ gleplot-gui ``` ### Features -✨ **Live Preview** - Debounced, async GLE compile-to-PNG as you edit +✨ **Live Preview** - Debounced, async GLE compile as you edit, as a vector SVG by default (with an automatic, sticky PNG fallback if SVG isn't usable) +✨ **On-Canvas Annotations** - Add, drag, double-click-edit, and delete free-form text labels directly on the preview, kept in sync with a dedicated Texts tab ✨ **Data Manager** - Load CSV/`.dat` files, pick columns, import data or reference the file in place -✨ **Property Panels** - Layout, Figure, Axes, and Series tabs for point-and-click styling +✨ **Named Data Columns** - Generated `.dat` sidecars carry real column names (from your series labels) instead of anonymous placeholders +✨ **Property Panels** - Layout, Figure, Axes, Series, and Texts tabs for point-and-click styling ✨ **Native `.gle` Files** - Save and open your work directly as `.gle` (the same format GLE renders) via File ▸ Save / Open -- unrecognized content is preserved as raw GLE ✨ **Export Dialog** - PDF, PNG, EPS, SVG, JPG, or raw GLE script, with an optional folder bundle ✨ **Undo/Redo** - Full editing history diff --git a/docs/guides/GUI_EDITOR.md b/docs/guides/GUI_EDITOR.md index b08a54a..3f035d1 100644 --- a/docs/guides/GUI_EDITOR.md +++ b/docs/guides/GUI_EDITOR.md @@ -6,9 +6,9 @@ gleplot includes a desktop editor -- built on PySide6 -- for building figures in The editor window (`MainWindow`) has: -- A central **live preview** showing the figure rendered to PNG via GLE, debounced so rapid edits don't trigger a compile per keystroke. +- A central **live preview** showing the figure rendered via GLE (vector SVG by default, PNG as a fallback -- see [Preview rendering](#preview-rendering)), debounced so rapid edits don't trigger a compile per keystroke, with draggable/editable text annotations directly on the canvas (see [Working with annotations](#working-with-annotations)). - A **Data** dock for loading delimited data files and creating series from their columns. -- A **Properties** dock with **Layout**, **Figure**, **Axes**, and **Series** tabs. +- A **Properties** dock with **Layout**, **Figure**, **Axes**, **Series**, and **Texts** tabs. - An **Output** dock listing structured compile errors (with line/column when GLE reports them) and any recovery warnings from opening a `.gle`, plus the raw compiler output. - A permanent **status bar** label showing whether GLE was detected, and where. @@ -75,7 +75,7 @@ In the **Data** dock, click **Load data file...** and choose a `.csv`, `.dat`, o - skips comment lines (`#` or `!`); - detects a header row automatically (present if any field in the first row isn't a plain number); - treats `*`, `?`, `-`, `.`, empty fields, and `nan`/`NaN` as missing values (`NaN` in the loaded arrays); -- shows up to 100 rows in the preview table. +- shows up to 100 rows in the preview table, with real column names as its header row whenever the source file has one (a positional `col1`/`col2`/... placeholder is used when it doesn't). Loaded files stay listed so you can switch between them or load several. @@ -95,7 +95,7 @@ Click **Add series**. It's added to the figure's current axes and the live previ Switch to the **Properties** dock's **Series** tab, which lists every series on the current axes. Select one to edit its label, color, line style, marker, line width, and marker size (control availability depends on the series kind -- e.g. bar charts only expose color, since GLE only supports one color per bar chart, and scatter series have no line style/line width). The Remove / Up / Down buttons delete or reorder the selected series (reordering only moves it within its own kind -- lines, then scatters, then bars, ... -- the between-kind draw order is fixed). -The **Axes** tab covers axis labels (title, X/Y/Y2), limits (leave a limit blank for "auto"), scale (linear/log per axis, including a secondary Y2 axis), and legend (on/off plus one of five placements). The legend checkbox reflects the *effective* state: with it left on auto, a legend appears automatically once any series has a label. The **Figure** tab covers figure-level settings: width and height (inches) and DPI. +The **Axes** tab covers axis labels (title, X/Y/Y2), limits (leave a limit blank for "auto"), scale (linear/log per axis, including a secondary Y2 axis), and legend (on/off plus one of five placements). The legend checkbox reflects the *effective* state: with it left on auto, a legend appears automatically once any series has a label. The **Figure** tab covers figure-level settings: width and height (inches) and DPI. The **Texts** tab lists and edits any free-form text annotations on the current axes -- see [Working with annotations](#working-with-annotations) for the full walkthrough, including adding and dragging annotations directly on the preview. ### 4. Arrange subplots @@ -109,10 +109,75 @@ Shrinking the grid or reshaping it (e.g. 2x3 to 3x2) refuses to relocate a popul **File ▸ Save** (Ctrl+S) writes a native `.gle` file (the same format GLE renders), prompting for a location the first time (or use **File ▸ Save As...**, Ctrl+Shift+S). Imported-data series are written out as `.dat` sidecars alongside the `.gle`. Saved and recently-opened files appear under **File ▸ Open Recent**. +Each generated `.dat` sidecar now begins with a named header row -- an `x` column plus one sanitized name per data column, derived from the series' label where one was given (lowercased, non-alphanumeric characters collapsed to underscores, de-duplicated with a `_2`/`_3` suffix if a name repeats, and never left looking like a plain number, since GLE would otherwise misread the header row as data). Opening the file in a text editor or spreadsheet shows real column names instead of anonymous numbers. Because GLE would otherwise invent its own legend text from a header row it can already read (its `auto_has_header` behavior), gleplot always writes an explicit `key` clause -- the series' real label, or an empty one -- alongside any headered sidecar, so adding a header never changes how the figure renders. + ### 6. Export **File ▸ Export...** (Ctrl+E) opens the export dialog: choose a destination path, format (pdf/png/eps/svg/jpg/gle), DPI (raster formats only), and optionally "Export as folder bundle" to write a `.gleplot/` folder containing the script, compiled output, and any data files together. Export always works from an immediate snapshot of the figure (`to_dict()` / `from_dict()`), never the live in-editing object, so it can't be affected by incidental edit order. On success the status bar shows "Exported \". +## Working with annotations + +Free-form text annotations (`Axes.text` entries) can be added, moved, edited, and removed directly on the live preview, in addition to the **Texts** tab in the Properties dock. Both stay in sync. + +### Adding text + +**Edit ▸ Add text annotation** (shortcut **T**) arms "click to place": the status bar shows "Click on the plot to place text — Esc to cancel", and the next left-click on the preview drops a new annotation at that point (in the data coordinates of whichever axes was clicked) and immediately opens it for inline editing. Press **Esc** before clicking to cancel without adding anything. The action is only enabled while the preview has a valid render to calibrate against -- see [Overlay availability](#overlay-availability) below. + +The Texts tab has its own **Add** button as an alternative entry point: it inserts a new annotation at the centre of the current axes' limits (not at a clicked point) and selects it, ready to reposition via the X/Y fields or by dragging it on the canvas. + +### Dragging + +Hover over an existing annotation on the preview and the cursor becomes an open hand; drag it to reposition. Because the rendered image only shows the annotation at its *old* location until the next debounced re-render lands, the drag shows a semi-transparent "ghost" of the text following the cursor -- the real glyphs snap into place a moment later once GLE recompiles. Dropping an annotation outside its owning axes' frame is allowed (GLE renders text outside the graph box fine); the annotation keeps its original owning axes and its coordinates are still computed through that axes' data transform. + +### Editing and deleting + +- **Double-click** an annotation on the preview to edit its text inline. **Enter** commits the change; **Esc** cancels and leaves the model untouched. Committing an empty string deletes the annotation. (GLE's `write` command is single-line, so any newlines you type are flattened to spaces on commit.) +- Select an annotation (click it, or select its row in the Texts tab) and press **Delete** or **Backspace** to remove it. +- The Texts tab's **Remove** button deletes whichever row is selected there. + +### Selection sync with the Texts tab + +Clicking an annotation on the canvas selects the matching row in the Texts tab (retargeting the tab to that annotation's axes first, if needed), and selecting a row in the Texts tab highlights the corresponding handle on the canvas. Either direction is one-way-triggered per action -- there's no feedback loop -- so clicking around in either place always leaves both views agreeing on the current selection. + +### The Texts tab + +The Properties dock's **Texts** tab lists every annotation on the current axes and edits whichever one is selected: + +- **Text** -- the annotation's content (multi-line input is accepted but flattened to a single line on commit, matching GLE's `write` command). +- **X** / **Y** -- the anchor position in data coordinates. +- **Color** -- opens a color picker; the chosen color is always written out explicitly (never left as "inherit default"). +- **Font size** -- a "Custom size" checkbox plus a point-size spinner; unchecking it reverts the annotation to the default text size instead of a specific value. +- **Horiz. align** -- `left`, `center`, or `right`, matching the annotation's anchor point on the x-axis. +- **Vert. align** and **Box color** -- shown but **disabled**, with a tooltip reading "Stored but not rendered by GLE output." These two fields exist for API/data-model compatibility (and, for box color, round-trip through the same color storage as other color fields) but gleplot's GLE writer never emits anything that would make GLE draw a vertical alignment or a background box for annotation text -- so editing them would silently do nothing. They're left visible rather than hidden so the underlying data isn't a mystery, but disabled so you don't spend time tuning a setting with no visible effect. + +### Overlay availability + +The on-canvas overlay depends on a per-render calibration that maps the figure's data coordinates onto the rendered page (see [Preview rendering](#preview-rendering) below for how the preview itself works). That calibration is only available when the document has actually rendered successfully: + +- It is unavailable before the first successful render (e.g. an empty figure, or the current edit doesn't yet compile) -- **Edit ▸ Add text annotation** is disabled, any active click-to-place is cancelled, and existing annotations aren't draggable until a render succeeds again. +- It is always unavailable in **GLE-preview mode** (see [Opening `.gle` files](#opening-gle-files)) -- that mode never renders through the document pipeline, so there is no calibration to compute from, and the Texts tab is disabled along with the rest of the Properties dock. +- It comes back automatically the moment a render succeeds again -- no user action needed. + +## Preview rendering + +The live preview renders either an SVG (vector) or a PNG (raster) image of the figure; either way, the same debounced recompile-on-edit behavior applies. + +### SVG by default, PNG as a sticky fallback + +The preview uses **SVG** by default in a session where GLE's Cairo-based SVG backend (`gle -d svg`) is confirmed to work. If SVG can't be used -- the optional SVG-rendering support isn't available, or a one-time startup check finds that this GLE install's SVG output doesn't load -- the preview starts in PNG instead. + +If an SVG render fails partway through a session (for example, because the figure's own configured font isn't compatible with GLE's Cairo backend), the preview permanently falls back to PNG for the rest of that session: the status bar shows "Vector preview unavailable; showing PNG instead.", and **View ▸ Vector preview (SVG)** is unchecked and greyed out with a tooltip explaining why. This fallback is sticky -- it does not retry SVG later in the same session -- but a fresh launch of the editor tries SVG again. + +### The toggle + +**View ▸ Vector preview (SVG)** switches the live preview between SVG and PNG rendering. It's checked when SVG is active, and it's disabled entirely (with a tooltip) if SVG isn't available in this session at all. Toggling it doesn't change anything about the saved `.gle` file or any exported output -- it only affects what's drawn in the editor's own preview pane. + +### Font substitution in the SVG preview (Cairo-safe fonts) + +GLE's Cairo SVG backend refuses to draw PostScript fonts -- the default font gleplot's figures use when no font is explicitly set falls into that category, so an SVG preview render would otherwise fail immediately. To avoid that, the preview silently substitutes a Cairo/TeX-safe font (`texcmr`) for the SVG render **only** when the figure doesn't already set its own font explicitly; if you've set a font yourself, that choice is always respected and never overridden. + +This substitution is applied only to the temporary copy of the script used to draw the live preview -- it never touches your saved `.gle` file, and it has no effect on PDF, PNG, EPS, or JPG **exports**, which compile the figure exactly as saved. In other words: if your SVG preview shows text in a different font than you expect, check your exported output before worrying -- the export is very likely using your actual configured font (or GLE's real default), and only the on-screen SVG preview needed the substitution to render at all. + ## The native `.gle` format `.gle` **is** the editor's native save format -- there is no separate project file. **File ▸ Save** writes a plain GLE script (via `Figure.savefig_gle`) that GLE renders directly, with any imported-data series written as `.dat` sidecars beside it. **File ▸ Open** parses a `.gle` file back into the editor with `gleplot.parser.recognizer.parse_gle_figure`, reconstructing the `Figure` object model so you can keep editing. @@ -129,6 +194,21 @@ Because GLE is a richer format than the object model, opening can apply a few us Missing `.dat` sidecars don't fail the open: the referencing series is marked broken (a `data:` warning plus a ⚠ marker in the Series tab), and you can repoint it at a real file with **Locate file...**. +On open, gleplot decides whether each `data` reference is one of its own imported sidecars (pulled in as editable series data) or an external file (referenced in place, read-only) **solely from the `! gleplot` metadata block** that gleplot writes into every file it saves. A hand-authored or third-party `.gle` has no such block, so *all* of its data references are treated as external references — gleplot never adopts and rewrites a file it can't prove it authored, even if the filename happens to look like one of its own `name_N.dat` sidecars. + +### Named headers and column names in the Data dock + +Sidecars gleplot writes carry the named header row described in [step 5](#5-save-the-figure) above, and both the loader and **File ▸ Open** read it back: the Data dock's preview table and any column-picking combo show the real names (`x`, `y_measured`, ...) instead of anonymous `col1`/`col2` placeholders, and re-saving a round-tripped figure reproduces the same names. + +**Double-click a column header** in the Data dock's preview table to rename it. On a table gleplot owns, this opens a dialog prefilled with the current name; typing a new one and confirming sanitizes it the same way series labels are sanitized for a sidecar header (lowercased, non-alphanumeric characters collapsed to underscores, auto-suffixed with `_2`/`_3` if the name collides with another column in the same table) and applies it everywhere that table's name is shown. + +Renaming is only offered for data gleplot itself owns: + +- **Figure-owned sidecars** -- a series added in **Import data** mode, or a file loaded straight from disk that isn't (yet) the reference target of a **Reference file** series -- are gleplot's own copies, so their column names can be renamed from the Data dock. +- **Externally referenced files** -- anything a **Reference file** series points at (`Axes.line_from_file` / `Axes.errorbar_from_file`) -- are read-only for naming purposes: gleplot never rewrites a file it doesn't own the contents of. Their header cells show a tooltip explaining the names come from the referenced file, and double-clicking one shows an explanatory message instead of a rename dialog. + +This mirrors the same Import-vs-Reference distinction from [step 2](#2-pick-columns-and-add-a-series) of the walkthrough -- if you can imagine gleplot rewriting the file on save, its headers are renameable; if the file lives outside the project and gleplot only reads it, they aren't. + ## Opening `.gle` files **File ▸ Open** accepts a `.gle` file (the file-type filter is `GLE figure (*.gle)`). Most files open straight into the editor as described above, with any recovery warnings listed in the **Output** dock. @@ -199,3 +279,6 @@ When a render or export fails, structured errors appear in the **Output** dock a - **Live preview and most exports require GLE installed.** Only the `.gle`-script export format works without a working GLE installation. - **Export runs synchronously** on the GUI thread (the live preview render does not -- it's async and debounced) -- large figures or a slow GLE install will briefly block the UI during File ▸ Export. - **Reference-mode scatter series** fall back to a plain line (no per-point markers) since `line_from_file` doesn't support markers; use Import mode for true scatter plots. +- **Annotation vertical alignment and box color are not rendered.** Both fields exist on the Texts tab for data-model compatibility, but gleplot's GLE output never draws either one, so their controls are disabled -- see [The Texts tab](#the-texts-tab). +- **Dragging an annotation never changes its owning axes.** Dropping it visually inside a different subplot still keeps it attached to (and positioned relative to) the axes it was created on. +- **The on-canvas annotation overlay needs a successful render to work.** It's unavailable before the first render, during a compile failure, and always in GLE-preview mode -- see [Overlay availability](#overlay-availability). diff --git a/docs/source/gui.rst b/docs/source/gui.rst index 4a3d6b7..73a7467 100644 --- a/docs/source/gui.rst +++ b/docs/source/gui.rst @@ -57,23 +57,34 @@ the main editor window and runs the Qt event loop. What it provides ----------------- -- A central **live preview** that recompiles the figure to PNG via GLE as you - edit, debounced so rapid edits don't trigger a compile per keystroke. +- A central **live preview** that recompiles the figure via GLE as you edit, + debounced so rapid edits don't trigger a compile per keystroke. It renders + as a vector **SVG** by default, with an automatic, sticky fallback to PNG + for the rest of the session if SVG output isn't usable (a **View ▸ Vector + preview (SVG)** toggle switches between the two manually). +- **On-canvas text annotations**: add, drag, double-click-edit, and delete + free-form text labels directly on the preview, in sync both ways with a + dedicated **Texts** tab in the Properties dock. - A **Data** dock for loading delimited data files (CSV/``.dat``/``.txt``) and creating series by mapping columns, either importing the data or - referencing the file in place. -- A **Properties** dock with **Layout**, **Figure**, **Axes**, and **Series** - tabs for point-and-click styling and subplot arrangement. + referencing the file in place. Generated ``.dat`` sidecars carry a named + header row (derived from each series' label) instead of anonymous column + numbers. +- A **Properties** dock with **Layout**, **Figure**, **Axes**, **Series**, + and **Texts** tabs for point-and-click styling, subplot arrangement, and + annotation editing. - An **Output** dock listing structured compile errors with line/column information when GLE reports it, plus any recovery warnings raised when a ``.gle`` file is opened. - **Native ``.gle`` files** via File |menu| Save / Open: Save writes a plain - GLE script (``Figure.savefig_gle``, with imported data as ``.dat`` + GLE script (``Figure.savefig_gle``, with imported data as named ``.dat`` sidecars) and Open parses it back into the editor (``gleplot.parser.recognizer.parse_gle_figure``), tolerantly preserving any unrecognized content as raw GLE (a read-only **Raw GLE** tab). - An **export dialog** producing PDF, PNG, EPS, SVG, JPG, or a raw ``.gle`` - script, optionally bundled into a folder with its data files. + script, optionally bundled into a folder with its data files. Exports + always compile the figure exactly as saved, independent of anything the + live preview substitutes for on-screen rendering. - **Undo/redo** and a read-only preview mode for ``.gle`` files that use GLE programming constructs. diff --git a/examples/gui/README.md b/examples/gui/README.md index 53be86f..896254f 100644 --- a/examples/gui/README.md +++ b/examples/gui/README.md @@ -10,12 +10,16 @@ A small, ready-to-open figure for the gleplot GUI editor (`gleplot-gui`). and series creation from scratch. - **`damped_oscillation.gle`** -- the finished figure as a native `.gle` file: an error-bar series for the measured data plus a dashed line for the - model, with axis labels, a title, and a legend. `.gle` is the editor's - native save format, so File > Open parses this straight back into the - editor -- open it to see a fully styled result, then keep editing. + model, with axis labels, a title, a legend, and three text annotations + ("Initial peak", "Decay envelope", "Settling") marking points of interest + on the curve. `.gle` is the editor's native save format, so File > Open + parses this straight back into the editor -- open it to see a fully styled + result, then keep editing. - **`data_0.dat`**, **`data_1.dat`** -- the two `.dat` sidecars the `.gle` references (the measured series and the model curve). They live alongside - the `.gle` and are read on open and rewritten on save. + the `.gle` and are read on open and rewritten on save. Each one starts with + a named header row (`x measured err`, `x model_fit`) instead of anonymous + column numbers -- open either in a text editor to see it. ## Try it @@ -23,7 +27,16 @@ A small, ready-to-open figure for the gleplot GUI editor (`gleplot-gui`). 2. **Open the finished figure**: File > Open > `damped_oscillation.gle`. It parses into the editor and renders immediately in the live preview -- fully editable, not a static preview. -3. **Or build it yourself** from the raw data: +3. **Try the annotations**: hover over any of the three text labels on the + preview and drag it to a new spot (a semi-transparent "ghost" follows the + cursor until the next render lands with the text at its new position); + double-click one to edit its wording inline; select one and press Delete + to remove it. Or press **T** (Edit > Add text annotation) and click + anywhere on the plot to place a new label. The Properties dock's **Texts** + tab lists all three and lets you edit position, color, size, and + horizontal alignment from a form instead -- selecting a row there + highlights the matching label on the canvas, and vice versa. +4. **Or build it yourself** from the raw data: - Data dock > "Load data file..." > `damped_oscillation.csv`. - Pick `x` for X, `y_measured` for Y, `yerr` for Y error, plot type "Error bars", mode "Import data" > Add series. @@ -33,16 +46,20 @@ A small, ready-to-open figure for the gleplot GUI editor (`gleplot-gui`). X label to "Time (s)", Y label to "Amplitude". - Properties dock > Series tab: style the model line (color, dash) and the measured series (marker, color) to taste; enable the legend. + - Add a label or two of your own with Edit > Add text annotation (or the + Texts tab's Add button). - File > Save As... to write your own `.gle`. -4. **Export**: File > Export (Ctrl+E) to produce a PDF/PNG/SVG/EPS/JPG, or a +5. **Export**: File > Export (Ctrl+E) to produce a PDF/PNG/SVG/EPS/JPG, or a `.gle` script, or a folder bundle containing the script plus its data files. ## Regenerating `damped_oscillation.gle` (and its `data_*.dat` sidecars) was generated -programmatically (not hand-edited) using the gleplot API plus -`Figure.savefig_gle`, mirroring what the Data dock's "Import data" mode -produces. It round-trips through `gleplot.parser.recognizer.parse_gle_figure` -with zero warnings. See `docs/guides/GUI_EDITOR.md` for the equivalent manual +programmatically (not hand-edited) using the gleplot API -- including the +three text annotations, added via `Axes.text()` -- plus `Figure.savefig_gle`, +mirroring what the Data dock's "Import data" mode plus manual annotation +placement produces. It round-trips through +`gleplot.parser.recognizer.parse_gle_figure` with zero warnings, and compiles +cleanly with GLE. See `docs/guides/GUI_EDITOR.md` for the equivalent manual workflow. diff --git a/examples/gui/damped_oscillation.csv b/examples/gui/damped_oscillation.csv index d95737b..db862bc 100644 --- a/examples/gui/damped_oscillation.csv +++ b/examples/gui/damped_oscillation.csv @@ -1,101 +1,101 @@ x,y_measured,yerr,y_model -0.000000,1.012189,0.050000,1.000000 -0.101010,0.908822,0.050000,0.950422 -0.202020,0.895427,0.050000,0.865409 -0.303030,0.788099,0.050000,0.750476 -0.404040,0.533978,0.050000,0.612020 -0.505051,0.404923,0.050000,0.457010 -0.606061,0.297789,0.050000,0.292676 -0.707071,0.113546,0.050000,0.126195 -0.808081,-0.036259,0.050000,-0.035587 -0.909091,-0.220541,0.050000,-0.186420 -1.010101,-0.285684,0.050000,-0.320860 -1.111111,-0.403336,0.050000,-0.434448 -1.212121,-0.521185,0.050000,-0.523826 -1.313131,-0.541722,0.050000,-0.586811 -1.414141,-0.603714,0.050000,-0.622415 -1.515152,-0.665181,0.050000,-0.630810 -1.616162,-0.598508,0.050000,-0.613258 -1.717172,-0.610348,0.050000,-0.571992 -1.818182,-0.474936,0.050000,-0.510074 -1.919192,-0.433212,0.050000,-0.431215 -2.020202,-0.346988,0.050000,-0.339593 -2.121212,-0.266894,0.050000,-0.239657 -2.222222,-0.087025,0.050000,-0.135927 -2.323232,-0.038994,0.050000,-0.032813 -2.424242,0.048429,0.050000,0.065562 -2.525253,0.141421,0.050000,0.155506 -2.626263,0.255179,0.050000,0.233887 -2.727273,0.312838,0.050000,0.298220 -2.828283,0.363247,0.050000,0.346737 -2.929293,0.395643,0.050000,0.378410 -3.030303,0.478617,0.050000,0.392952 -3.131313,0.374525,0.050000,0.390782 -3.232323,0.352482,0.050000,0.372972 -3.333333,0.308609,0.050000,0.341160 -3.434343,0.322091,0.050000,0.297452 -3.535354,0.289471,0.050000,0.244312 -3.636364,0.179882,0.050000,0.184440 -3.737374,0.087039,0.050000,0.120645 -3.838384,0.022755,0.050000,0.055735 -3.939394,0.018416,0.050000,-0.007608 -4.040404,-0.037188,0.050000,-0.066918 -4.141414,-0.098315,0.050000,-0.120041 -4.242424,-0.191816,0.050000,-0.165196 -4.343434,-0.191743,0.050000,-0.201030 -4.444444,-0.221977,0.050000,-0.226645 -4.545455,-0.232861,0.050000,-0.241608 -4.646465,-0.211086,0.050000,-0.245943 -4.747475,-0.231155,0.050000,-0.240098 -4.848485,-0.197753,0.050000,-0.224910 -4.949495,-0.198836,0.050000,-0.201539 -5.050505,-0.159846,0.050000,-0.171410 -5.151515,-0.110886,0.050000,-0.136137 -5.252525,-0.155731,0.050000,-0.097445 -5.353535,-0.069883,0.050000,-0.057096 -5.454545,-0.035631,0.050000,-0.016816 -5.555556,-0.003782,0.050000,0.021774 -5.656566,0.046210,0.050000,0.057215 -5.757576,0.148062,0.050000,0.088264 -5.858586,0.079293,0.050000,0.113926 -5.959596,0.172213,0.050000,0.133482 -6.060606,0.079186,0.050000,0.146501 -6.161616,0.139448,0.050000,0.152844 -6.262626,0.159156,0.050000,0.152646 -6.363636,0.169749,0.050000,0.146301 -6.464646,0.162874,0.050000,0.134425 -6.565657,0.149558,0.050000,0.117824 -6.666667,0.083495,0.050000,0.097444 -6.767677,0.055837,0.050000,0.074332 -6.868687,0.083898,0.050000,0.049579 -6.969697,0.016629,0.050000,0.024281 -7.070707,-0.051537,0.050000,-0.000509 -7.171717,-0.069152,0.050000,-0.023821 -7.272727,-0.081578,0.050000,-0.044800 -7.373737,-0.042852,0.050000,-0.062738 -7.474747,-0.071393,0.050000,-0.077090 -7.575758,-0.059868,0.050000,-0.087487 -7.676768,-0.110833,0.050000,-0.093743 -7.777778,-0.089506,0.050000,-0.095848 -7.878788,-0.068938,0.050000,-0.093962 -7.979798,-0.100769,0.050000,-0.088395 -8.080808,-0.061318,0.050000,-0.079589 -8.181818,-0.094566,0.050000,-0.068089 -8.282828,-0.069041,0.050000,-0.054518 -8.383838,-0.054815,0.050000,-0.039546 -8.484848,-0.071692,0.050000,-0.023858 -8.585859,0.011349,0.050000,-0.008130 -8.686869,-0.011775,0.050000,0.007001 -8.787879,0.021460,0.050000,0.020960 -8.888889,0.052482,0.050000,0.033252 -8.989899,0.061341,0.050000,0.043480 -9.090909,0.077967,0.050000,0.051352 -9.191919,0.052749,0.050000,0.056688 -9.292929,0.042492,0.050000,0.059424 -9.393939,0.056412,0.050000,0.059601 -9.494949,-0.010131,0.050000,0.057362 -9.595960,-0.004943,0.050000,0.052941 -9.696970,-0.006265,0.050000,0.046643 -9.797980,-0.001056,0.050000,0.038834 -9.898990,0.045908,0.050000,0.029917 -10.000000,-0.015902,0.050000,0.020317 +0.000000,1.006287,0.050000,1.000000 +0.101010,0.952432,0.050000,0.959037 +0.202020,0.930331,0.050000,0.898310 +0.303030,0.825630,0.050000,0.820385 +0.404040,0.701292,0.050000,0.728075 +0.505051,0.642441,0.050000,0.624361 +0.606061,0.577511,0.050000,0.512311 +0.707071,0.442359,0.050000,0.395005 +0.808081,0.240278,0.050000,0.275464 +0.909091,0.093314,0.050000,0.156585 +1.010101,0.009913,0.050000,0.041077 +1.111111,-0.066523,0.050000,-0.068589 +1.212121,-0.286471,0.050000,-0.170220 +1.313131,-0.272878,0.050000,-0.261939 +1.414141,-0.404504,0.050000,-0.342208 +1.515152,-0.446458,0.050000,-0.409845 +1.616162,-0.491242,0.050000,-0.464029 +1.717172,-0.520113,0.050000,-0.504298 +1.818182,-0.509959,0.050000,-0.530540 +1.919192,-0.490847,0.050000,-0.542973 +2.020202,-0.548548,0.050000,-0.542121 +2.121212,-0.460462,0.050000,-0.528785 +2.222222,-0.537268,0.050000,-0.504008 +2.323232,-0.451460,0.050000,-0.469036 +2.424242,-0.380102,0.050000,-0.425276 +2.525253,-0.369556,0.050000,-0.374256 +2.626263,-0.354760,0.050000,-0.317585 +2.727273,-0.302989,0.050000,-0.256903 +2.828283,-0.216737,0.050000,-0.193850 +2.929293,-0.119014,0.050000,-0.130024 +3.030303,-0.117425,0.050000,-0.066944 +3.131313,-0.016485,0.050000,-0.006027 +3.232323,0.043487,0.050000,0.051448 +3.333333,0.131396,0.050000,0.104353 +3.434343,0.162468,0.050000,0.151735 +3.535354,0.210591,0.050000,0.192822 +3.636364,0.194344,0.050000,0.227035 +3.737374,0.247506,0.050000,0.253987 +3.838384,0.312681,0.050000,0.273482 +3.939394,0.360179,0.050000,0.285507 +4.040404,0.227272,0.050000,0.290225 +4.141414,0.363651,0.050000,0.287955 +4.242424,0.346455,0.050000,0.279161 +4.343434,0.303495,0.050000,0.264430 +4.444444,0.257674,0.050000,0.244451 +4.545455,0.204300,0.050000,0.219996 +4.646465,0.264794,0.050000,0.191893 +4.747475,0.259018,0.050000,0.161005 +4.848485,0.218294,0.050000,0.128212 +4.949495,0.160138,0.050000,0.094383 +5.050505,0.078230,0.050000,0.060361 +5.151515,-0.033472,0.050000,0.026944 +5.252525,-0.005353,0.050000,-0.005131 +5.353535,-0.002377,0.050000,-0.035201 +5.454545,-0.127106,0.050000,-0.062688 +5.555556,-0.067354,0.050000,-0.087110 +5.656566,-0.086589,0.050000,-0.108082 +5.757576,-0.090520,0.050000,-0.125322 +5.858586,-0.197856,0.050000,-0.138650 +5.959596,-0.181074,0.050000,-0.147989 +6.060606,-0.175179,0.050000,-0.153358 +6.161616,-0.213355,0.050000,-0.154865 +6.262626,-0.065734,0.050000,-0.152703 +6.363636,-0.171933,0.050000,-0.147137 +6.464646,-0.122049,0.050000,-0.138497 +6.565657,-0.140092,0.050000,-0.127163 +6.666667,-0.034382,0.050000,-0.113556 +6.767677,-0.032105,0.050000,-0.098123 +6.868687,-0.049662,0.050000,-0.081330 +6.969697,-0.173819,0.050000,-0.063643 +7.070707,-0.042924,0.050000,-0.045525 +7.171717,0.006764,0.050000,-0.027420 +7.272727,0.040452,0.050000,-0.009746 +7.373737,-0.023781,0.050000,0.007114 +7.474747,0.113919,0.050000,0.022818 +7.575758,-0.028950,0.050000,0.037071 +7.676768,0.016553,0.050000,0.049629 +7.777778,0.107054,0.050000,0.060301 +7.878788,0.071404,0.050000,0.068951 +7.979798,0.175619,0.050000,0.075499 +8.080808,0.089342,0.050000,0.079916 +8.181818,0.050566,0.050000,0.082225 +8.282828,0.063620,0.050000,0.082498 +8.383838,0.026290,0.050000,0.080847 +8.484848,0.013540,0.050000,0.077424 +8.585859,0.103933,0.050000,0.072413 +8.686869,0.095080,0.050000,0.066021 +8.787879,0.123208,0.050000,0.058480 +8.888889,0.012299,0.050000,0.050030 +8.989899,0.125375,0.050000,0.040920 +9.090909,0.017030,0.050000,0.031399 +9.191919,0.100433,0.050000,0.021713 +9.292929,-0.009545,0.050000,0.012094 +9.393939,-0.034013,0.050000,0.002761 +9.494949,0.006403,0.050000,-0.006087 +9.595960,0.037299,0.050000,-0.014273 +9.696970,-0.013598,0.050000,-0.021649 +9.797980,-0.057366,0.050000,-0.028090 +9.898990,-0.100564,0.050000,-0.033503 +10.000000,-0.107899,0.050000,-0.037823 diff --git a/examples/gui/damped_oscillation.gle b/examples/gui/damped_oscillation.gle index 2cdd23e..5aab263 100644 --- a/examples/gui/damped_oscillation.gle +++ b/examples/gui/damped_oscillation.gle @@ -14,10 +14,25 @@ begin graph xtitle "Time (s)" ytitle "Amplitude" xaxis min 0 max 10 - yaxis min -0.715181 max 1.06219 + yaxis min -0.598548 max 1.05629 data data_1.dat d1=c1,c2 d1 line smooth color RED lwidth 0.05292 lstyle 2 key "Model fit" data data_0.dat d2=c1,c2 d3=c1,c3 - d2 marker FCIRCLE msize 0.15 color BLUE line lwidth 0.05292 err d3 errwidth 0.1059 key "Measured" + d2 marker FCIRCLE msize 0.15 color BLUE line lwidth 0.05292 err d3 key "Measured" key pos tr -end graph \ No newline at end of file +end graph +set hei 0.352734 +set color BLACK +set just left +amove xg(1) yg(0.92) +write "Initial peak" +set hei 0.352734 +set color DARKGREEN +set just left +amove xg(4.5) yg(-0.32) +write "Decay envelope" +set hei 0.352734 +set color PURPLE +set just right +amove xg(8.5) yg(0.25) +write "Settling" \ No newline at end of file diff --git a/examples/gui/data_0.dat b/examples/gui/data_0.dat index 90ac72e..96d8be1 100644 --- a/examples/gui/data_0.dat +++ b/examples/gui/data_0.dat @@ -1,100 +1,101 @@ -0 1.01219 0.05 -0.10101 0.908822 0.05 -0.20202 0.895427 0.05 -0.30303 0.788099 0.05 -0.40404 0.533978 0.05 -0.505051 0.404923 0.05 -0.606061 0.297789 0.05 -0.707071 0.113546 0.05 -0.808081 -0.036259 0.05 -0.909091 -0.220541 0.05 -1.0101 -0.285684 0.05 -1.11111 -0.403336 0.05 -1.21212 -0.521185 0.05 -1.31313 -0.541722 0.05 -1.41414 -0.603714 0.05 -1.51515 -0.665181 0.05 -1.61616 -0.598508 0.05 -1.71717 -0.610348 0.05 -1.81818 -0.474936 0.05 -1.91919 -0.433212 0.05 -2.0202 -0.346988 0.05 -2.12121 -0.266894 0.05 -2.22222 -0.087025 0.05 -2.32323 -0.038994 0.05 -2.42424 0.048429 0.05 -2.52525 0.141421 0.05 -2.62626 0.255179 0.05 -2.72727 0.312838 0.05 -2.82828 0.363247 0.05 -2.92929 0.395643 0.05 -3.0303 0.478617 0.05 -3.13131 0.374525 0.05 -3.23232 0.352482 0.05 -3.33333 0.308609 0.05 -3.43434 0.322091 0.05 -3.53535 0.289471 0.05 -3.63636 0.179882 0.05 -3.73737 0.087039 0.05 -3.83838 0.022755 0.05 -3.93939 0.018416 0.05 -4.0404 -0.037188 0.05 -4.14141 -0.098315 0.05 -4.24242 -0.191816 0.05 -4.34343 -0.191743 0.05 -4.44444 -0.221977 0.05 -4.54545 -0.232861 0.05 -4.64647 -0.211086 0.05 -4.74747 -0.231155 0.05 -4.84849 -0.197753 0.05 -4.94949 -0.198836 0.05 -5.05051 -0.159846 0.05 -5.15151 -0.110886 0.05 -5.25253 -0.155731 0.05 -5.35353 -0.069883 0.05 -5.45455 -0.035631 0.05 -5.55556 -0.003782 0.05 -5.65657 0.04621 0.05 -5.75758 0.148062 0.05 -5.85859 0.079293 0.05 -5.9596 0.172213 0.05 -6.06061 0.079186 0.05 -6.16162 0.139448 0.05 -6.26263 0.159156 0.05 -6.36364 0.169749 0.05 -6.46465 0.162874 0.05 -6.56566 0.149558 0.05 -6.66667 0.083495 0.05 -6.76768 0.055837 0.05 -6.86869 0.083898 0.05 -6.9697 0.016629 0.05 -7.07071 -0.051537 0.05 -7.17172 -0.069152 0.05 -7.27273 -0.081578 0.05 -7.37374 -0.042852 0.05 -7.47475 -0.071393 0.05 -7.57576 -0.059868 0.05 -7.67677 -0.110833 0.05 -7.77778 -0.089506 0.05 -7.87879 -0.068938 0.05 -7.9798 -0.100769 0.05 -8.08081 -0.061318 0.05 -8.18182 -0.094566 0.05 -8.28283 -0.069041 0.05 -8.38384 -0.054815 0.05 -8.48485 -0.071692 0.05 -8.58586 0.011349 0.05 -8.68687 -0.011775 0.05 -8.78788 0.02146 0.05 -8.88889 0.052482 0.05 -8.9899 0.061341 0.05 -9.09091 0.077967 0.05 -9.19192 0.052749 0.05 -9.29293 0.042492 0.05 -9.39394 0.056412 0.05 -9.49495 -0.010131 0.05 -9.59596 -0.004943 0.05 -9.69697 -0.006265 0.05 -9.79798 -0.001056 0.05 -9.89899 0.045908 0.05 -10 -0.015902 0.05 +x measured err +0 1.00629 0.05 +0.10101 0.952432 0.05 +0.20202 0.930331 0.05 +0.30303 0.82563 0.05 +0.40404 0.701292 0.05 +0.505051 0.642441 0.05 +0.606061 0.577511 0.05 +0.707071 0.442359 0.05 +0.808081 0.240278 0.05 +0.909091 0.0933143 0.05 +1.0101 0.00991303 0.05 +1.11111 -0.0665226 0.05 +1.21212 -0.286471 0.05 +1.31313 -0.272878 0.05 +1.41414 -0.404504 0.05 +1.51515 -0.446458 0.05 +1.61616 -0.491242 0.05 +1.71717 -0.520113 0.05 +1.81818 -0.509959 0.05 +1.91919 -0.490847 0.05 +2.0202 -0.548548 0.05 +2.12121 -0.460462 0.05 +2.22222 -0.537268 0.05 +2.32323 -0.45146 0.05 +2.42424 -0.380102 0.05 +2.52525 -0.369556 0.05 +2.62626 -0.35476 0.05 +2.72727 -0.302989 0.05 +2.82828 -0.216737 0.05 +2.92929 -0.119014 0.05 +3.0303 -0.117425 0.05 +3.13131 -0.0164854 0.05 +3.23232 0.0434869 0.05 +3.33333 0.131396 0.05 +3.43434 0.162468 0.05 +3.53535 0.210591 0.05 +3.63636 0.194344 0.05 +3.73737 0.247506 0.05 +3.83838 0.312681 0.05 +3.93939 0.360179 0.05 +4.0404 0.227272 0.05 +4.14141 0.363651 0.05 +4.24242 0.346455 0.05 +4.34343 0.303495 0.05 +4.44444 0.257674 0.05 +4.54545 0.2043 0.05 +4.64646 0.264794 0.05 +4.74747 0.259018 0.05 +4.84848 0.218294 0.05 +4.94949 0.160138 0.05 +5.05051 0.0782298 0.05 +5.15152 -0.0334719 0.05 +5.25253 -0.00535337 0.05 +5.35354 -0.00237678 0.05 +5.45455 -0.127106 0.05 +5.55556 -0.0673541 0.05 +5.65657 -0.0865888 0.05 +5.75758 -0.0905195 0.05 +5.85859 -0.197856 0.05 +5.9596 -0.181074 0.05 +6.06061 -0.175179 0.05 +6.16162 -0.213355 0.05 +6.26263 -0.0657341 0.05 +6.36364 -0.171933 0.05 +6.46465 -0.122049 0.05 +6.56566 -0.140092 0.05 +6.66667 -0.0343823 0.05 +6.76768 -0.0321053 0.05 +6.86869 -0.0496621 0.05 +6.9697 -0.173819 0.05 +7.07071 -0.042924 0.05 +7.17172 0.00676381 0.05 +7.27273 0.0404518 0.05 +7.37374 -0.0237815 0.05 +7.47475 0.113919 0.05 +7.57576 -0.0289502 0.05 +7.67677 0.0165528 0.05 +7.77778 0.107054 0.05 +7.87879 0.0714042 0.05 +7.9798 0.175619 0.05 +8.08081 0.0893417 0.05 +8.18182 0.0505655 0.05 +8.28283 0.0636197 0.05 +8.38384 0.0262898 0.05 +8.48485 0.0135402 0.05 +8.58586 0.103933 0.05 +8.68687 0.0950798 0.05 +8.78788 0.123208 0.05 +8.88889 0.0122993 0.05 +8.9899 0.125375 0.05 +9.09091 0.0170299 0.05 +9.19192 0.100433 0.05 +9.29293 -0.00954524 0.05 +9.39394 -0.0340129 0.05 +9.49495 0.00640275 0.05 +9.59596 0.0372994 0.05 +9.69697 -0.0135981 0.05 +9.79798 -0.0573662 0.05 +9.89899 -0.100564 0.05 +10 -0.107899 0.05 diff --git a/examples/gui/data_1.dat b/examples/gui/data_1.dat index 96ce645..fb286c1 100644 --- a/examples/gui/data_1.dat +++ b/examples/gui/data_1.dat @@ -1,100 +1,101 @@ +x model_fit 0 1 -0.10101 0.950422 -0.20202 0.865409 -0.30303 0.750476 -0.40404 0.61202 -0.505051 0.45701 -0.606061 0.292676 -0.707071 0.126195 -0.808081 -0.035587 -0.909091 -0.18642 -1.0101 -0.32086 -1.11111 -0.434448 -1.21212 -0.523826 -1.31313 -0.586811 -1.41414 -0.622415 -1.51515 -0.63081 -1.61616 -0.613258 -1.71717 -0.571992 -1.81818 -0.510074 -1.91919 -0.431215 -2.0202 -0.339593 -2.12121 -0.239657 -2.22222 -0.135927 -2.32323 -0.032813 -2.42424 0.065562 -2.52525 0.155506 -2.62626 0.233887 -2.72727 0.29822 -2.82828 0.346737 -2.92929 0.37841 -3.0303 0.392952 -3.13131 0.390782 -3.23232 0.372972 -3.33333 0.34116 -3.43434 0.297452 -3.53535 0.244312 -3.63636 0.18444 -3.73737 0.120645 -3.83838 0.055735 -3.93939 -0.007608 -4.0404 -0.066918 -4.14141 -0.120041 -4.24242 -0.165196 -4.34343 -0.20103 -4.44444 -0.226645 -4.54545 -0.241608 -4.64647 -0.245943 -4.74747 -0.240098 -4.84849 -0.22491 -4.94949 -0.201539 -5.05051 -0.17141 -5.15151 -0.136137 -5.25253 -0.097445 -5.35353 -0.057096 -5.45455 -0.016816 -5.55556 0.021774 -5.65657 0.057215 -5.75758 0.088264 -5.85859 0.113926 -5.9596 0.133482 -6.06061 0.146501 -6.16162 0.152844 -6.26263 0.152646 -6.36364 0.146301 -6.46465 0.134425 -6.56566 0.117824 -6.66667 0.097444 -6.76768 0.074332 -6.86869 0.049579 -6.9697 0.024281 -7.07071 -0.000509 -7.17172 -0.023821 -7.27273 -0.0448 -7.37374 -0.062738 -7.47475 -0.07709 -7.57576 -0.087487 -7.67677 -0.093743 -7.77778 -0.095848 -7.87879 -0.093962 -7.9798 -0.088395 -8.08081 -0.079589 -8.18182 -0.068089 -8.28283 -0.054518 -8.38384 -0.039546 -8.48485 -0.023858 -8.58586 -0.00813 -8.68687 0.007001 -8.78788 0.02096 -8.88889 0.033252 -8.9899 0.04348 -9.09091 0.051352 -9.19192 0.056688 -9.29293 0.059424 -9.39394 0.059601 -9.49495 0.057362 -9.59596 0.052941 -9.69697 0.046643 -9.79798 0.038834 -9.89899 0.029917 -10 0.020317 +0.10101 0.959037 +0.20202 0.89831 +0.30303 0.820385 +0.40404 0.728075 +0.505051 0.624361 +0.606061 0.512311 +0.707071 0.395005 +0.808081 0.275464 +0.909091 0.156585 +1.0101 0.0410768 +1.11111 -0.0685889 +1.21212 -0.17022 +1.31313 -0.261939 +1.41414 -0.342208 +1.51515 -0.409845 +1.61616 -0.464029 +1.71717 -0.504298 +1.81818 -0.53054 +1.91919 -0.542973 +2.0202 -0.542121 +2.12121 -0.528785 +2.22222 -0.504008 +2.32323 -0.469036 +2.42424 -0.425276 +2.52525 -0.374256 +2.62626 -0.317585 +2.72727 -0.256903 +2.82828 -0.19385 +2.92929 -0.130024 +3.0303 -0.0669443 +3.13131 -0.00602661 +3.23232 0.0514481 +3.33333 0.104353 +3.43434 0.151735 +3.53535 0.192822 +3.63636 0.227035 +3.73737 0.253987 +3.83838 0.273482 +3.93939 0.285507 +4.0404 0.290225 +4.14141 0.287955 +4.24242 0.279161 +4.34343 0.26443 +4.44444 0.244451 +4.54545 0.219996 +4.64646 0.191893 +4.74747 0.161005 +4.84848 0.128212 +4.94949 0.0943828 +5.05051 0.0603608 +5.15152 0.026944 +5.25253 -0.00513067 +5.35354 -0.0352005 +5.45455 -0.0626883 +5.55556 -0.0871102 +5.65657 -0.108082 +5.75758 -0.125322 +5.85859 -0.13865 +5.9596 -0.147989 +6.06061 -0.153358 +6.16162 -0.154865 +6.26263 -0.152703 +6.36364 -0.147137 +6.46465 -0.138497 +6.56566 -0.127163 +6.66667 -0.113556 +6.76768 -0.0981234 +6.86869 -0.0813297 +6.9697 -0.0636432 +7.07071 -0.0455254 +7.17172 -0.0274205 +7.27273 -0.00974629 +7.37374 0.00711389 +7.47475 0.0228181 +7.57576 0.0370713 +7.67677 0.0496292 +7.77778 0.0603012 +7.87879 0.0689515 +7.9798 0.0754989 +8.08081 0.0799158 +8.18182 0.0822252 +8.28283 0.0824979 +8.38384 0.0808471 +8.48485 0.0774242 +8.58586 0.0724126 +8.68687 0.0660215 +8.78788 0.0584798 +8.88889 0.0500296 +8.9899 0.0409196 +9.09091 0.0313993 +9.19192 0.0217129 +9.29293 0.012094 +9.39394 0.00276128 +9.49495 -0.00608652 +9.59596 -0.0142733 +9.69697 -0.0216486 +9.79798 -0.0280897 +9.89899 -0.0335026 +10 -0.0378226 diff --git a/src/gleplot/axes.py b/src/gleplot/axes.py index 60fcff1..2836c69 100644 --- a/src/gleplot/axes.py +++ b/src/gleplot/axes.py @@ -64,6 +64,81 @@ def _sanitize_data_stem(name: object) -> str: return text or "data" +def _looks_numeric(token: str) -> bool: + """True if ``token`` would parse as a float (int/float/exponent form). + + GLE's own header auto-detection (see ``graph.cpp: auto_has_header`` / + ``isFloatMiss``) treats the first row of a data file as a header ONLY + if *every* cell in that row fails float conversion; a single numeric- + looking header token would make GLE read the whole header row as data + instead. Column names must never satisfy this check. + """ + try: + float(token) + return True + except ValueError: + return False + + +def sanitize_column_name(name: object, fallback: str = "col") -> str: + """Sanitize an arbitrary label into a safe GLE data-file column header token. + + Rules (documented here as the single source of truth for the sanitizer): + + 1. Keep only ``[A-Za-z0-9_]`` characters; every other character + (whitespace, punctuation, unicode, ...) becomes a single ``_``. + 2. Lowercase the result. + 3. Collapse consecutive underscores to one and strip leading/trailing + underscores. + 4. If the result is empty, fall back to ``fallback``. + 5. If the result would itself parse as a number (e.g. a label of + ``"2024"``), prefix it with ``fallback + "_"`` so it can never be + mistaken for a data value -- GLE's header auto-detection requires + *every* first-row token to be non-numeric, and a purely numeric + column name would silently defeat the header row for the whole + file (see :func:`_looks_numeric`). + 6. The result never contains whitespace (guaranteed by step 1), since + header tokens are whitespace/space-separated on the header line. + + Uniqueness across a file's column names is NOT handled here (a single + label sanitizes deterministically); see :func:`_unique_column_names` + for de-duplication via ``_2``, ``_3``, ... suffixes. + """ + text = re.sub(r"[^A-Za-z0-9_]+", "_", str(name).strip().lower()) + text = re.sub(r"_+", "_", text).strip("_") + if not text: + text = fallback + if _looks_numeric(text): + text = f"{fallback}_{text}" + return text + + +def _unique_column_names(names: List[str]) -> List[str]: + """De-duplicate a list of column name tokens with stable ``_2``, ``_3``, ... suffixes. + + The first occurrence of a name is kept as-is; subsequent occurrences of + the same (already-sanitized) name are suffixed with ``_2``, ``_3``, etc. + (matching :func:`_reserve_data_filename`'s collision convention). This + keeps sanitize_column_name pure/stateless while still guaranteeing + uniqueness within one sidecar's header row. + """ + seen: dict = {} + result = [] + for name in names: + if name not in seen: + seen[name] = 1 + result.append(name) + else: + seen[name] += 1 + candidate = f"{name}_{seen[name]}" + while candidate in seen: + seen[name] += 1 + candidate = f"{name}_{seen[name]}" + seen[candidate] = 1 + result.append(candidate) + return result + + def _reserve_data_filename(filename: str, figure=None) -> str: """Reserve a data filename and avoid collisions within a figure.""" if not filename.endswith(".dat"): @@ -122,6 +197,94 @@ def _resolve_data_file(figure=None, data_name: object = None) -> str: return _reserve_data_filename(_sanitize_data_stem(data_name), figure) +def _build_errorbar_column_names( + label: Optional[str], + yerr_up, yerr_down, xerr_left, xerr_right, +) -> List[str]: + """Build the sidecar header row for an errorbar series. + + Mirrors :meth:`gleplot.writer.GLEWriter.add_errorbar`'s column-building + order exactly (x, y, then vertical error column(s), then horizontal + error column(s)), so the header row lines up 1:1 with the data columns + the writer actually emits: + + - symmetric y error (``yerr_up == yerr_down``, both given) -> one + ``'err'`` column + - asymmetric -> ``'err_up'`` and/or ``'err_down'`` columns, in that order + - symmetric x error (``xerr_left == xerr_right``, both given) -> one + ``'xerr'`` column + - asymmetric -> ``'xerr_left'`` and/or ``'xerr_right'`` columns + + The primary y column is named from ``label`` when given (else ``'y'``); + error columns always keep their stable suffix names (never derived from + the label) since GLE never auto-keys off an error dataset's column name + directly relevant here -- only the uniqueness pass can rename them. + """ + y_names = ['y'] + + has_yerr = yerr_up is not None or yerr_down is not None + has_xerr = xerr_left is not None or xerr_right is not None + yerr_symmetric = (has_yerr and yerr_up is not None and yerr_down is not None + and np.array_equal(yerr_up, yerr_down)) + xerr_symmetric = (has_xerr and xerr_left is not None and xerr_right is not None + and np.array_equal(xerr_left, xerr_right)) + + if has_yerr: + if yerr_symmetric: + y_names.append('err') + else: + if yerr_up is not None: + y_names.append('err_up') + if yerr_down is not None: + y_names.append('err_down') + + if has_xerr: + if xerr_symmetric: + y_names.append('xerr') + else: + if xerr_left is not None: + y_names.append('xerr_left') + if xerr_right is not None: + y_names.append('xerr_right') + + return _build_column_names('x', y_names, label) + + +def _build_column_names(x_name: str, y_names: List[str], label: Optional[str]) -> List[str]: + """Build a sidecar header row: one name for x, then one per y-like column. + + Parameters + ---------- + x_name : str + Base name for the x column (conventionally ``'x'``). + y_names : list of str + Base (pre-uniqueness) names for the remaining columns in file order, + e.g. ``['y']`` for a plain line, ``['y', 'err']`` for a symmetric + errorbar, ``['upper', 'lower']`` for a fill, ``['height']`` for a + bar chart. When ``label`` is given, the FIRST entry of ``y_names`` + (the primary data column) is derived from the sanitized label + instead of its own base name; the rest keep their stable suffixes. + label : str, optional + Series label (e.g. the ``label=`` argument to ``plot``/``errorbar``/ + ...). When present, sanitized and used as the primary data column's + name in place of its generic base name (e.g. ``'y'``). When absent + (``None`` or empty), the generic base name is kept as-is. + + Returns + ------- + list of str + ``[x_name] + y_names`` with the primary column optionally renamed + from ``label``, then de-duplicated for uniqueness within the file. + """ + names = [x_name] + for i, base in enumerate(y_names): + if i == 0 and label: + names.append(sanitize_column_name(label, fallback=base)) + else: + names.append(base) + return _unique_column_names(names) + + class Axes: """Matplotlib-like axes for plotting.""" @@ -248,6 +411,7 @@ def plot(self, x, y, linestyle: str = '-', color: Optional[str] = None, 'label': label, 'yaxis': yaxis, # 'y' or 'y2' 'data_file': _resolve_data_file(self.figure, data_name), + 'column_names': _build_column_names('x', ['y'], label), } if is_scatter: @@ -438,6 +602,9 @@ def errorbar(self, x, y, yerr=None, xerr=None, fmt: str = '-', 'gle_capsize': gle_capsize, # Separate field for the GLE-converted value 'yaxis': yaxis, # 'y' or 'y2' 'data_file': _resolve_data_file(self.figure, data_name), + 'column_names': _build_errorbar_column_names( + label, yerr_up, yerr_down, xerr_left, xerr_right + ), } self.errorbars.append(errbar_data) @@ -621,6 +788,7 @@ def bar(self, x, height, color: Optional[Union[str, List[str]]] = None, 'colors': colors, 'label': label, 'data_file': _resolve_data_file(self.figure, data_name), + 'column_names': _build_column_names('x', ['height'], label), } self.bars.append(bar_data) @@ -669,6 +837,7 @@ def fill_between(self, x, y1, y2, color: Optional[str] = None, 'alpha': alpha, 'label': label, 'data_file': _resolve_data_file(self.figure, data_name), + 'column_names': _unique_column_names(['x', 'upper', 'lower']), } self.fills.append(fill_data) @@ -869,6 +1038,34 @@ def has_y2_plots(self) -> bool: _SERIES_ATTRS = ('lines', 'scatters', 'bars', 'fills', 'errorbars', 'file_series', 'texts') + @staticmethod + def _default_column_names(attr: str, item: dict) -> Optional[List[str]]: + """Regenerate ``column_names`` for a series loaded from an older project. + + Projects saved before Track E3 (named sidecar column headers) have no + ``'column_names'`` key on their series dicts at all. Rather than + leaving it absent (which would produce a headerless sidecar on the + next save -- a silent format regression), recompute the same default + names :meth:`plot`/:meth:`errorbar`/:meth:`bar`/:meth:`fill_between` + would have produced for equivalent arguments, using the already + JSON-scalar/array-restored ``item``. Returns ``None`` for + ``file_series``/``texts`` (no generated sidecar, nothing to name). + """ + label = item.get('label') + if attr in ('lines', 'scatters'): + return _build_column_names('x', ['y'], label) + if attr == 'bars': + return _build_column_names('x', ['height'], label) + if attr == 'fills': + return _unique_column_names(['x', 'upper', 'lower']) + if attr == 'errorbars': + return _build_errorbar_column_names( + label, + item.get('yerr_up'), item.get('yerr_down'), + item.get('xerr_left'), item.get('xerr_right'), + ) + return None + def to_dict(self) -> dict: """Serialize this axes to a JSON-safe dictionary. @@ -972,6 +1169,14 @@ def from_dict(cls, figure, d: dict) -> 'Axes': item = dict(series) for key in array_keys: item[key] = _to_float_array(item.get(key)) + # Older projects (pre Track E3) have no 'column_names' key at + # all on their series dicts; regenerate the same defaults the + # plotting methods would produce so the next save still gets + # a named header row instead of silently reverting to none. + if 'column_names' not in item: + defaults = cls._default_column_names(attr, item) + if defaults is not None: + item['column_names'] = defaults restored.append(item) setattr(ax, attr, restored) diff --git a/src/gleplot/dataio.py b/src/gleplot/dataio.py index 988de86..7ff47cd 100644 --- a/src/gleplot/dataio.py +++ b/src/gleplot/dataio.py @@ -27,12 +27,58 @@ the sniffed delimiter doesn't actually separate the row into multiple fields. - Comment lines (leading ``#`` or ``!``, ignoring leading whitespace) are - skipped entirely and never considered for header/data detection. + skipped entirely and never considered for header/data detection, EXCEPT + for two narrow, conservative cases (tried in this order) when the file + has no inline header row at all: + + 1. **Indexed block** -- an instrument/export-style comment block with one + ``c = `` line per column (case-insensitive, whitespace + around ``=`` tolerated), e.g.:: + + ! some prose header + ! c 1 = run_id + ! c 2 = field_strength (G) + ! c 7 = err_rate (unit-1) + ! + ! run_id field_strength(G) ... (aligned prose row, ignored) + 1 2.5 ... + + The WHOLE comment block preceding the first data row is scanned (not + just the last line); accepted only if the discovered indices are + EXACTLY ``1..n_cols`` -- full coverage, no gaps/duplicates/out-of-range + (no partial adoption). Names may contain unicode and internal + whitespace verbatim (no tokenization: each line is matched whole). + Unambiguous, so it is tried FIRST and takes precedence over case 2 + below. See :func:`_recover_indexed_comment_header`. + 2. **Last-line heuristic** -- the LAST comment line immediately preceding + the first data row (blank lines tolerated in between) is checked as a + possible comment-embedded column-name line -- e.g. a GLE-style + fit-parameter export ending in ``! x y`` right before the numeric + data. Accepted as column names only if, once the comment marker is + stripped and the remainder tokenized with the same delimiter as the + data, the token count matches the data column count AND at least one + token is a genuine non-numeric label (same rule as header detection + below). See :func:`_recover_comment_header`. + + On acceptance (either case) ``DataTable.column_names`` gets the recovered + names and ``DataTable.header_source`` is set to ``"comment"``, but + ``DataTable.has_header`` stays ``False`` -- the line(s) are still + comments, so data-row indexing and any round-trip are unaffected; this + is display-name recovery only. - Header detection: the first non-comment, non-blank row is treated as a - header if and only if at least one of its fields fails float - conversion (after missing-value normalization the missing tokens count - as "not a float" too, so a header row of plain names is always - detected as a header). + header if and only if at least one of its fields is a genuine + non-numeric *label* -- i.e. it fails float conversion AND is not a + missing-value token. A first data row consisting entirely of missing + tokens (e.g. ``* * *``) is therefore kept as an all-NaN DATA row, not + mistaken for a header. +- Header/column alignment: if a detected header's token count does not + match the data column count (e.g. a whitespace-split multi-word header + like ``Temperature (K) Resistance (Ohm)`` yielding 4 tokens over 2 + data columns), the row is still skipped as a header, but its tokens are + not used to name columns; positional names (``col1``..``colN``) are + synthesized and a warning is recorded on ``DataTable.warnings``. + Delimited files (comma/tab/semicolon) split multi-word names correctly, + so their headers align and are used verbatim. - Missing values: empty fields and the GLE-convention tokens ``*``, ``?``, ``-``, ``.`` (only when they are the *entire* field, not part of a longer token) as well as ``nan``/``NaN`` (case-insensitive) become @@ -107,6 +153,15 @@ class DataTable: Whether the first non-comment row was consumed as a header. warnings : list of str Human-readable warnings, e.g. about ragged rows that were padded. + header_source : str or None + Where ``column_names`` came from: ``"row"`` when an inline header + row was consumed (``has_header`` is ``True``), ``"comment"`` when + no inline header row exists but names were recovered from a + trailing comment line (see :func:`load_data_file`'s comment-header + recovery; ``has_header`` stays ``False`` in this case -- the line + is still a comment, data-row indexing is unaffected), or ``None`` + when ``column_names`` are the synthesized positional + ``col1``..``colN`` placeholders. """ column_names: List[str] @@ -117,6 +172,7 @@ class DataTable: has_header: bool is_numeric: List[bool] = field(default_factory=list) warnings: List[str] = field(default_factory=list) + header_source: Optional[str] = None @property def n_cols(self) -> int: @@ -233,6 +289,225 @@ def _try_float(token: Optional[str]) -> Optional[float]: return None +def _strip_comment_marker(line: str) -> str: + """Strip a leading comment marker (``#`` or ``!``) and whitespace.""" + stripped = line.lstrip() + if stripped.startswith("#") or stripped.startswith("!"): + stripped = stripped[1:] + return stripped.strip() + + +def _find_last_comment_before_first_data_row(raw_lines: List[str]) -> Optional[str]: + """Return the comment line immediately preceding the first data row. + + "Immediately preceding" tolerates intervening blank lines (walking + backward from the first content line, skipping blanks) but stops as + soon as a non-blank, non-comment line is encountered first -- that + would mean there is no comment line directly above the first data row + (just more data / an earlier blank-separated block), so we + conservatively give up rather than risk grabbing an unrelated comment + from earlier in the file. + """ + # Find the raw-line index of the first content line (same predicate + # used to build content_lines: non-blank and not a comment). + first_idx = None + for i, line in enumerate(raw_lines): + if line.strip() and not _is_comment(line): + first_idx = i + break + if first_idx is None or first_idx == 0: + return None + + for i in range(first_idx - 1, -1, -1): + line = raw_lines[i] + if not line.strip(): + continue + if _is_comment(line): + return line + # Hit a non-comment, non-blank line before any comment -- no + # comment line is "immediately preceding" the first data row. + return None + return None + + +#: Matches a comment-body line of the form ``c = `` (case- +#: insensitive, whitespace-tolerant around the ``=``). Group 1 is the 1-based +#: column index, group 2 is everything after ``=`` (stripped once by the +#: caller; internal whitespace in the name is preserved verbatim). +_INDEXED_COMMENT_HEADER_RE = re.compile( + r"^c\s*(\d+)\s*=\s*(.*)$", re.IGNORECASE +) + + +def _comment_block_before_first_data_row(raw_lines: List[str]) -> List[str]: + """Return every comment/blank line before the first data row, in order. + + Unlike :func:`_find_last_comment_before_first_data_row` (which returns + only the single nearest comment line), this collects the WHOLE run of + comment lines preceding the first data row -- needed to scan a multi-line + ``! c N = name`` block where the column-naming lines are not necessarily + the very last comment line (a trailing ``!`` separator or an aligned + prose row -- see ``_recover_indexed_comment_header`` -- often follows + them). Blank lines are included (as empty strings) so ordering/spacing + is preserved for callers that care, but they never match the ``c N =`` + pattern so they are harmless filler. + + Stops (returns only what was collected so far) as soon as a non-blank, + non-comment line is reached while scanning backward from the first data + row -- i.e. only an unbroken run of comments/blanks immediately above + the data counts as "the comment block". + """ + first_idx = None + for i, line in enumerate(raw_lines): + if line.strip() and not _is_comment(line): + first_idx = i + break + if first_idx is None or first_idx == 0: + return [] + + block: List[str] = [] + for i in range(first_idx - 1, -1, -1): + line = raw_lines[i] + if not line.strip(): + block.append(line) + continue + if _is_comment(line): + block.append(line) + continue + break + block.reverse() + return block + + +def _recover_indexed_comment_header( + raw_lines: List[str], + data_col_count: int, +) -> Optional[List[str]]: + """Recover column names from a ``! c N = name`` comment block, if present. + + Real-world shape (e.g. an instrument export with a prose preamble):: + + ! some prose header + ! c 1 = run_id + ! c 2 = field_strength (G) + ... + ! c 7 = err_rate (unit-1) + ! + ! run_id field_strength(G) ... + 1 2.5 ... + + Scans the WHOLE comment block preceding the first data row (not just the + last line) for lines matching, case-insensitively and whitespace- + tolerantly, ``c = `` once the comment marker is stripped. + Everything after ``=`` becomes the column name, stripped only at the + ends (internal whitespace, and non-ASCII characters such as ``mu`` or + superscript ``-1``, are preserved verbatim -- multi-word names are fine + here since each line is matched whole, not tokenized). + + This is unambiguous (each column index is explicitly named), so it takes + PRECEDENCE over the last-comment-line positional heuristic + (:func:`_recover_comment_header`) -- callers should try this function + FIRST and only fall back to the positional heuristic if it returns + ``None``. + + Accepted only when the discovered indices are EXACTLY ``{1, ..., n_cols}`` + -- full coverage, no gaps, no duplicates, no out-of-range indices. This + is deliberately strict (no partial/subset adoption): an incomplete or + ambiguous match is much more likely to be a coincidental line (e.g. a + sentence containing "c 2 = ...") than a genuine column-naming block, and + a wrong-but-plausible mapping silently mislabels data. Any duplicate + index, any index outside ``1..n_cols``, or missing coverage rejects the + WHOLE block (returns ``None``) rather than guessing. + + A trailing aligned "prose name row" (e.g. a human-readable column + header whose whitespace-split token count does not match + ``data_col_count``) commonly follows the ``c N =`` lines in this file + shape; it is irrelevant here since this function only reads ``c N =`` + lines and ignores everything else in the block. + """ + block = _comment_block_before_first_data_row(raw_lines) + if not block: + return None + + names_by_index: dict = {} + for line in block: + if not _is_comment(line): + continue + candidate = _strip_comment_marker(line) + if not candidate: + continue + m = _INDEXED_COMMENT_HEADER_RE.match(candidate.strip()) + if not m: + continue + idx = int(m.group(1)) + name = m.group(2).strip() + if not name: + continue + if idx in names_by_index: + # Duplicate index -- ambiguous, reject the whole block. + return None + names_by_index[idx] = name + + if not names_by_index: + return None + + found = set(names_by_index) + expected = set(range(1, data_col_count + 1)) + if found != expected: + # Incomplete coverage, gap, or an out-of-range index -- reject + # rather than guess (see docstring: no partial adoption). + return None + + return [names_by_index[i] for i in range(1, data_col_count + 1)] + + +def _recover_comment_header( + raw_lines: List[str], + delimiter: Optional[str], + data_col_count: int, +) -> Optional[List[str]]: + """Recover column names from a trailing comment line, if it qualifies. + + Only called when the file has no inline header row. Looks at the + LAST comment line immediately preceding the first data row (tolerating + intervening blank lines), strips the comment marker, and tokenizes it + with the same delimiter used for the data. The candidate is accepted + as column names ONLY IF: + + - its token count equals the data column count, AND + - at least one token is a genuine non-numeric label (fails float + conversion AND is not a missing-value token) -- the same rule + ``load_data_file`` uses to decide a row is a real header rather + than data, reused here so an all-numeric comment (e.g. a stray + results line someone commented out) is never mistaken for names. + + Returns ``None`` when no comment line qualifies, leaving + ``column_names``/``has_header`` untouched by this feature entirely. + """ + comment_line = _find_last_comment_before_first_data_row(raw_lines) + if comment_line is None: + return None + + candidate_text = _strip_comment_marker(comment_line) + if not candidate_text: + return None + + tokens = _split_line(candidate_text, delimiter) + tokens = [t.strip() for t in tokens] + + if len(tokens) != data_col_count: + return None + + has_label = any( + _normalize_token(tok) is not None and _try_float(_normalize_token(tok)) is None + for tok in tokens + ) + if not has_label: + return None + + return tokens + + def load_data_file( path: Union[str, Path], max_preview_rows: Optional[int] = None ) -> DataTable: @@ -286,11 +561,16 @@ def load_data_file( split_rows = [_split_line(line, delimiter) for line in content_lines] - # Header detection: the first row is a header iff at least one field - # fails float conversion after missing-value normalization. + # Header detection: the first row is a header iff at least one field is a + # genuine non-numeric label -- i.e. it fails float conversion AND is not a + # missing-value token. A missing token ('*', '?', '-', '.', '', 'nan') + # also "fails float conversion", but a first data row consisting entirely + # of missing values (e.g. '* * *') is real DATA (all-NaN), not a header; + # only a field that is a real word/label marks the row as a header. first_row = split_rows[0] first_row_is_header = any( - _try_float(_normalize_token(tok)) is None for tok in first_row + _normalize_token(tok) is not None and _try_float(_normalize_token(tok)) is None + for tok in first_row ) has_header = first_row_is_header @@ -307,11 +587,27 @@ def load_data_file( if not data_rows: raise ValueError(f"No data rows found in {path}") - max_cols = max(len(row) for row in data_rows) - if header_fields is not None: - max_cols = max(max_cols, len(header_fields)) - warnings: List[str] = [] + + # Column count is determined by the DATA rows, not the header. A header + # whose token count differs from the data column count is a *misalignment* + # (typically a whitespace-split multi-word header, e.g. header + # 'Temperature (K) Resistance (Ohm)' = 4 tokens over 2 data columns): we + # still skip the row as a header (it is not data), but we cannot trust its + # tokens to name columns 1:1, so we synthesize positional names and warn. + data_col_count = max(len(row) for row in data_rows) + header_mismatch = ( + header_fields is not None and len(header_fields) != data_col_count + ) + if header_mismatch: + warnings.append( + f"Header row has {len(header_fields)} field(s) but the data has " + f"{data_col_count} column(s); the header does not align with the " + "columns (e.g. multi-word names split on whitespace). Using " + "positional column names (col1..colN) instead." + ) + max_cols = data_col_count + padded_rows: List[List[str]] = [] for row_idx, row in enumerate(data_rows): if len(row) < max_cols: @@ -322,8 +618,8 @@ def load_data_file( ) row = row + [""] * pad_count elif len(row) > max_cols: - # Shouldn't normally happen since max_cols is the max, but - # guard defensively in case header_fields was shorter. + # Shouldn't normally happen since max_cols is the max data-row + # width, but guard defensively. warnings.append( f"Row {row_idx + 1} has {len(row)} field(s), expected " f"{max_cols}; extra field(s) truncated." @@ -331,8 +627,59 @@ def load_data_file( row = row[:max_cols] padded_rows.append(row) - if header_fields is None: + header_source: Optional[str] = None + if header_fields is None or header_mismatch: + # No header, or a header that doesn't align 1:1 with the columns. column_names = [f"col{i + 1}" for i in range(max_cols)] + if header_mismatch: + # The row WAS consumed as a header (has_header is True) even + # though its tokens couldn't be trusted to name columns 1:1; + # header_source still reflects that an inline row was found. + header_source = "row" + + # Comment-header recovery: real-world .dat files (e.g. GLE-style + # fit-parameter exports) sometimes carry column names in a COMMENT + # line rather than an inline header row -- e.g. + # ! Fit parameter data for GLE export + # ! Global fitting parameters: + # ! A_1 (%) = 11.8654 +/- 0.0543966 + # ! x y + # 1.0 2.0 + # Only attempted when there is no inline header at all (a header + # row, even a mismatched one, always wins -- see the recognizer's + # `_recovered_column_names`, which only ever reads `column_names` + # when `has_header` is True, so this path can never be mistaken + # for a real header there). Deliberately conservative: most + # comment lines are NOT headers, so only the LAST comment line + # immediately before the first data row is even considered, and + # only accepted if its token count matches the data columns AND + # it contains a genuine non-numeric label (same rule as normal + # header detection above) -- see _recover_comment_header. + # + # `not header_mismatch` here means genuinely `has_header is False` + # (not just "mismatched"): we are inside the + # `header_fields is None or header_mismatch` branch, so if + # `header_mismatch` is False then `header_fields is None` must be + # the reason we're here -- i.e. there was no inline header row at + # all. A mismatched inline header (has_header True, tokens + # misaligned) is still a real header row and must NOT be + # second-guessed by a comment line. + if not header_mismatch: + # Indexed '! c N = name' block (see _recover_indexed_comment_header) + # is unambiguous -- every column is explicitly named by number -- + # so it takes PRECEDENCE over the positional last-comment-line + # heuristic below. Only fall back to the positional heuristic + # when no indexed block is found (or it doesn't achieve full + # 1..n_cols coverage). + recovered = _recover_indexed_comment_header(raw_lines, max_cols) + if recovered is not None: + column_names = recovered + header_source = "comment" + else: + recovered = _recover_comment_header(raw_lines, delimiter, max_cols) + if recovered is not None: + column_names = recovered + header_source = "comment" else: column_names = list(header_fields) # Pad header names if the header row itself was short. @@ -342,6 +689,7 @@ def load_data_file( column_names = [ name if name else f"col{i + 1}" for i, name in enumerate(column_names) ] + header_source = "row" # Build columns. columns: List[np.ndarray] = [] @@ -381,6 +729,7 @@ def load_data_file( has_header=has_header, is_numeric=is_numeric, warnings=warnings, + header_source=header_source, ) @@ -584,26 +933,6 @@ def _get(col_1based: int, key: str) -> np.ndarray: return result -#: Sidecar-naming heuristic: matches gleplot's own export naming -#: convention, ``{prefix}_{N}.dat`` where ``{prefix}`` is either the -#: literal default prefix ``"data"`` or a user-chosen -#: ``Figure.data_prefix``, and ``{N}`` is the (non-negative) integer -#: counter from ``gleplot.axes._get_next_data_file`` / -#: ``_reserve_data_filename``. See ``gleplot.axes`` for the writer side. -#: -#: CAUTION -- false positives: this is a syntactic heuristic only. Any -#: user-supplied data file that happens to be named -#: ``_.dat`` in the same directory as the ``.gle`` -#: script -- e.g. a perfectly ordinary ``results_2024.dat`` -- matches -#: this pattern and would be misclassified as a gleplot-generated -#: "import" sidecar rather than a user "reference". Prefer supplying -#: ``import_list`` (parsed from the ``.gle`` metadata comment block, if -#: gleplot wrote one) whenever available; the heuristic is a best-effort -#: fallback only for files gleplot didn't author metadata for (e.g. -#: hand-edited or third-party-generated ``.gle`` scripts). -_SIDECAR_NAME_RE = re.compile(r"^.+_\d+\.dat$", re.IGNORECASE) - - def classify_data_file( gle_path: Union[str, Path], referenced_name: str, @@ -622,20 +951,20 @@ def classify_data_file( Parameters ---------- gle_path : str or pathlib.Path - Path to the ``.gle`` file doing the referencing (only its parent - directory matters, for the same-directory heuristic check). + Path to the ``.gle`` file doing the referencing (unused since the + filename heuristic was removed; retained for API stability). referenced_name : str The file name/path as written in the GLE ``data`` command. import_list : list of str, optional Authoritative list of file names/paths that gleplot's own writer - recorded as "imported" (copied) data files, typically parsed - from a metadata comment block gleplot itself wrote into the - ``.gle`` file when exporting. When this is not ``None``, + recorded as "imported" (copied) data files, parsed from the + ``! gleplot`` metadata comment block gleplot itself wrote into + the ``.gle`` file when exporting. When this is not ``None``, membership in this list (matched against ``referenced_name``, both as given and as a bare filename) *fully decides* the - classification -- the heuristic below is not consulted at all. - Pass ``None`` only when no such metadata is available (e.g. a - hand-authored or third-party ``.gle`` script). + classification. When it is ``None`` (no metadata block present -- + e.g. a hand-authored or third-party ``.gle`` script), the + reference is ALWAYS classified ``"reference"``. Returns ------- @@ -644,40 +973,32 @@ def classify_data_file( Notes ----- - Heuristic (only used when ``import_list is None``): a reference is - classified ``"import"`` iff *both*: - - 1. It resolves to the *same directory* as ``gle_path`` (no - subdirectory, no ``..``, not absolute-elsewhere) -- gleplot always - writes sidecars next to the script. - 2. Its filename matches ``^.+_\\d+\\.dat$`` (case-insensitive) -- - i.e. some prefix, an underscore, one or more digits, then - ``.dat`` -- gleplot's own ``{prefix}_{N}.dat`` naming from - ``gleplot.axes._get_next_data_file``. - - Otherwise the reference is classified ``"reference"``. - - See the ``_SIDECAR_NAME_RE`` module-level docstring comment above for - a discussion of this heuristic's false-positive risk (e.g. a - plain user file named ``results_2024.dat`` sitting next to the - script matches the pattern and would be misclassified as - ``"import"``). **Prefer passing ``import_list`` whenever the - metadata block is available** -- the heuristic is a fallback of - last resort. + **Only the metadata block's import-data list can vouch a file as + gleplot-owned.** Every ``.gle`` gleplot itself saves carries the + ``! gleplot`` metadata block naming its import sidecars, so genuine + gleplot-authored sidecars are always vouched via ``import_list`` and + correctly classified ``"import"``. + + When ``import_list is None`` there is no authoritative signal that + gleplot owns any referenced file, so classification is conservative: + every reference is treated as an external ``"reference"``. This is a + deliberate safety property -- classifying a file as ``"import"`` + causes gleplot to adopt (and, on the next ``savefig_gle``, REWRITE) + that file. A hand-authored ``.gle`` sitting next to an ordinary + user data file whose name happens to look like a sidecar (e.g. + ``results_2024.dat``) must NEVER have its data silently overwritten. + Such files remain read-only references: still plottable and + editable-in-style, but never rewritten. + + (Historically a filename heuristic -- ``_.dat`` in + the same directory -- classified such files as ``"import"``; that + produced a silent user-data-overwrite false positive and has been + removed. The metadata block is now the sole source of truth.) """ - if import_list is not None: - candidates = {referenced_name, Path(referenced_name).name} - imported = {str(n) for n in import_list} | { - Path(str(n)).name for n in import_list - } - return "import" if candidates & imported else "reference" - - ref = Path(referenced_name) - if ref.is_absolute(): - return "reference" - # Same-directory only: no subdirectory components, no parent traversal. - if len(ref.parts) != 1: + if import_list is None: return "reference" - if _SIDECAR_NAME_RE.match(ref.name): - return "import" - return "reference" + candidates = {referenced_name, Path(referenced_name).name} + imported = {str(n) for n in import_list} | { + Path(str(n)).name for n in import_list + } + return "import" if candidates & imported else "reference" diff --git a/src/gleplot/figure.py b/src/gleplot/figure.py index c32e45c..143414c 100644 --- a/src/gleplot/figure.py +++ b/src/gleplot/figure.py @@ -724,9 +724,10 @@ def _write_axes_content(self, writer: GLEWriter, ax: Axes): fill_data['y2'], fill_data['data_file'], fill_data['color'], - fill_data['alpha'] + fill_data['alpha'], + column_names=fill_data.get('column_names'), ) - + # Add bar charts for bar_data in ax.bars: writer.add_bar_chart( @@ -734,9 +735,10 @@ def _write_axes_content(self, writer: GLEWriter, ax: Axes): bar_data['height'], bar_data['data_file'], bar_data['colors'], - bar_data['label'] + bar_data['label'], + column_names=bar_data.get('column_names'), ) - + # Add line plots for line_data in ax.lines: writer.add_plot_line( @@ -750,6 +752,7 @@ def _write_axes_content(self, writer: GLEWriter, ax: Axes): marker=line_data.get('marker'), markersize=line_data.get('markersize', 0.1), yaxis=line_data.get('yaxis', 'y'), + column_names=line_data.get('column_names'), ) # Add scatter plots @@ -764,8 +767,9 @@ def _write_axes_content(self, writer: GLEWriter, ax: Axes): markersize=scatter_data['markersize'], label=scatter_data['label'], yaxis=scatter_data.get('yaxis', 'y'), + column_names=scatter_data.get('column_names'), ) - + # Add errorbar plots for eb_data in ax.errorbars: writer.add_errorbar( @@ -784,6 +788,7 @@ def _write_axes_content(self, writer: GLEWriter, ax: Axes): xerr_right=eb_data['xerr_right'], capsize=eb_data.get('gle_capsize', eb_data.get('capsize')), yaxis=eb_data.get('yaxis', 'y'), + column_names=eb_data.get('column_names'), ) # Add external-file series (no generated data files). diff --git a/src/gleplot/gui/annotations.py b/src/gleplot/gui/annotations.py new file mode 100644 index 0000000..fda476c --- /dev/null +++ b/src/gleplot/gui/annotations.py @@ -0,0 +1,1126 @@ +"""Interactive text-annotation overlay for the live preview (Track F1). + +This module implements the marquee editor feature: dragging, editing, adding +and deleting the free-form text annotations (``Axes.text`` entries) directly on +the rendered preview image, in *data* coordinates, with the model as the single +source of truth. + +Coordinate pipeline (frozen contracts) +-------------------------------------- +Every annotation lives in the DATA coordinates of its owning axes. To place a +hit-target on the raster we chain the two frozen mappings: + + data (axis units) + -- AxesCalibration.data_to_cm --> page cm + -- ViewMapping.cm_to_view --> scene coordinates + +and the inverse on drop:: + + scene + -- ViewMapping.view_to_cm --> page cm + -- AxesCalibration.cm_to_data --> data + +The per-axes :class:`~gleplot.gui.geometry.AxesCalibration` arrives via +``PreviewController.geometry_ready`` (a :class:`~gleplot.gui.geometry.PreviewGeometry` +or ``None``); the ``cm <-> scene`` :class:`~gleplot.gui.preview.ViewMapping` +comes from ``PreviewView.view_mapping()`` and MUST be refetched after every +render (it is invalidated by ``show_image``/``set_geometry``; see the preview +module contract). Overlay items are placed in the *scene* so view zoom/pan +transforms carry them along with the image automatically -- we never apply a +view transform ourselves. + +Why the overlay must not "double draw" +-------------------------------------- +The rendered PNG/SVG already contains the baked text pixels. The overlay items +are therefore *transparent* hit-rectangles with only a selection outline; the +annotation's glyphs are drawn by GLE, not by us. The one exception is transient +UI: while dragging we show a semi-transparent "ghost" of the text (because the +baked pixels stay at the OLD position until the debounced re-render lands), and +while editing we show an editable text child. + +The no-jump sequencing problem +------------------------------ +A drag ends by writing new data coords into the model and calling +``document.notify_changed()``. That schedules a debounced (~300ms) re-render; +when it lands, ``geometry_ready`` + ``render_succeeded`` fire and the overlay +*rebuilds* every item against the fresh image -- which now has the text at the +new position. Between the drop and that rebuild there is a window where the +baked image still shows the OLD position. To avoid a visible "snap back", the +dragged item keeps its ghost visible at the drop location until the rebuild +replaces it. On rebuild the fresh render already has the glyphs in the right +place, so the ghost is simply dropped. + +Mid-render drags: if the user starts another drag *during* that render window, +the rebuild must not teleport the item they are actively holding. The overlay +therefore skips rebuilding any item that is currently being dragged or edited +(:attr:`AnnotationItem.is_interacting`) and re-syncs it on the next clean +rebuild. + +Cross-axes drags (Phase-later) +------------------------------ +Dropping an annotation inside a *different* axes' box does NOT re-home it to +that axes in this phase: the annotation stays owned by its original axes and +its new data coords are computed via that axes' transform even when the drop is +outside the box (GLE renders text outside the graph frame fine). Re-homing +between axes is deferred. +""" + +from __future__ import annotations + +import logging +from typing import List, Optional + +from PySide6.QtCore import QObject, QPointF, QRectF, Qt, Signal +from PySide6.QtGui import QColor, QFont, QFontMetricsF, QPen +from PySide6.QtWidgets import ( + QGraphicsItem, + QGraphicsRectItem, + QGraphicsSimpleTextItem, + QGraphicsTextItem, +) + +from gleplot.gui.geometry import CM_PER_INCH, AxesCalibration, PreviewGeometry + +__all__ = ["AnnotationItem", "AnnotationOverlay"] + +_log = logging.getLogger(__name__) + +#: Points per centimetre (72pt per inch). Used to translate a font's point +#: size into the cm-then-scene scale so a hit-rect roughly matches the baked +#: glyph height on the page. +_PT_PER_CM = 72.0 / CM_PER_INCH + +#: Default GLE text height in points when an annotation has ``fontsize is None`` +#: (GLE's own default is ~10pt for gleplot's style; the hit-rect only needs to +#: be a plausible click target, so an approximate default is fine). +_DEFAULT_FONTSIZE_PT = 10.0 + +#: Fractional padding added around the estimated text bounds so the click +#: target is generous (the estimate is deliberately not pixel-perfect). +_HIT_PAD_FRAC = 0.30 + +#: Selection outline pen colour/appearance. +_SELECT_COLOR = QColor(30, 120, 220) +#: Ghost text colour while dragging (semi-transparent). +_GHOST_ALPHA = 150 + + +class AnnotationItem(QGraphicsRectItem): + """One draggable/editable hit-rect for a single ``Axes.text`` dict. + + The item is a transparent rectangle approximating the rendered glyphs' + bounds in scene coordinates. It carries a reference to *the very dict* in + ``axes.texts`` it represents (identity, not a copy) plus its owning + :class:`~gleplot.gui.geometry.AxesCalibration`, so a drop can write new + coords straight back into the model. + + States + ------ + normal + Invisible fill, no outline; ``OpenHand`` cursor on hover. + selected + Dashed selection outline (still no fill). + dragging + A semi-transparent :class:`QGraphicsSimpleTextItem` ghost child shows + the text at the cursor position (the baked pixels are stale until the + next render). + editing + A :class:`QGraphicsTextItem` child with ``TextEditorInteraction``, + pre-filled with the current text; commit on focus-out/Enter, cancel on + Escape. + """ + + def __init__( + self, + overlay: "AnnotationOverlay", + text_dict: dict, + cal: AxesCalibration, + ) -> None: + super().__init__() + self._overlay = overlay + self.text_dict = text_dict + self.cal = cal + + self._ghost: Optional[QGraphicsSimpleTextItem] = None + self._editor: Optional[QGraphicsTextItem] = None + self._dragging = False + self._editing = False + # Fingerprint of the ViewMapping active when the current interaction + # (drag/edit) began. Compared on rebuild: if it changed the cm<->scene + # relationship shifted underneath the gesture (e.g. PNG<->SVG switch), + # so the stale scene position must not be decoded with the new mapping + # -- the overlay aborts the interaction instead (see + # AnnotationOverlay.rebuild / Finding 4). None when not interacting. + self._interaction_fingerprint: Optional[tuple] = None + # Guards itemChange during programmatic setPos in sync_position(). + self._syncing = False + # Guards itemChange during programmatic setSelected() in + # sync_selection(), so the overlay's own select_annotation() doesn't + # loop back into selection_changed (mirrors TextsPanel's _updating). + self._selecting = False + + self.setAcceptHoverEvents(True) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, True) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsFocusable, True) + self.setFlag( + QGraphicsItem.GraphicsItemFlag.ItemSendsScenePositionChanges, True + ) + # Transparent fill; outline only appears when selected. The rect is + # local (origin at item pos); we position the item via setPos. + self.setBrush(Qt.GlobalColor.transparent) + self.setPen(QPen(Qt.GlobalColor.transparent)) + # Draw above the image but let the ghost/editor children sit on top. + self.setZValue(10) + + # ------------------------------------------------------------------ + # State queries + # ------------------------------------------------------------------ + @property + def is_interacting(self) -> bool: + """True while the user is actively dragging or editing this item. + + The overlay skips rebuilding interacting items so a re-render landing + mid-gesture never teleports the handle out from under the cursor. + """ + return self._dragging or self._editing + + # ------------------------------------------------------------------ + # Geometry + # ------------------------------------------------------------------ + def _effective_fontsize_pt(self) -> float: + fs = self.text_dict.get("fontsize") + try: + return float(fs) if fs is not None else _DEFAULT_FONTSIZE_PT + except (TypeError, ValueError): + return _DEFAULT_FONTSIZE_PT + + def rebuild_rect(self, cm_per_scene_unit: float) -> None: + """Recompute the local hit-rect from the text/font and cm->scene scale. + + ``cm_per_scene_unit`` is the number of page-cm one scene unit spans + (measured by the overlay from the active :class:`ViewMapping`). The + font's point size -> cm -> scene units gives the glyph height in scene + units; :class:`QFontMetricsF` gives the text width for that height. + Horizontal-alignment offset (``ha``) shifts the rect so the item's + *position* (set by :meth:`sync_position`) always denotes the annotation + anchor point, matching GLE's ``amove xg()/yg()`` anchor. + + Vertical: GLE's writer emits no vertical ``just`` for annotations, so + the baked text sits with its baseline at the anchor. We centre the rect + vertically on the anchor as a pragmatic approximation (documented in the + module docstring); the generous padding absorbs the mismatch for click + targeting. + """ + if cm_per_scene_unit <= 0.0: + return + # scene units per cm is the inverse; glyph height in cm then scene. + scene_per_cm = 1.0 / cm_per_scene_unit + height_cm = self._effective_fontsize_pt() / _PT_PER_CM + height_scene = height_cm * scene_per_cm + if height_scene <= 0.0: + height_scene = 1.0 + + text = str(self.text_dict.get("text", "")) + font = QFont() + # setPointSizeF drives QFontMetricsF; we then scale metrics from the + # font's own pixel height to our target scene height so width tracks + # the string length at the correct aspect. + font.setPointSizeF(max(self._effective_fontsize_pt(), 1.0)) + fm = QFontMetricsF(font) + raw_h = fm.height() if fm.height() > 0 else 1.0 + raw_w = fm.horizontalAdvance(text) if text else raw_h * 0.6 + scale = height_scene / raw_h + w = max(raw_w * scale, height_scene * 0.4) + h = height_scene + + pad_w = w * _HIT_PAD_FRAC + pad_h = h * _HIT_PAD_FRAC + + ha = str(self.text_dict.get("ha", "left")).lower() + # Anchor is at local (0,0); place the rect so the anchor matches ha. + if ha == "center": + x0 = -w / 2.0 + elif ha == "right": + x0 = -w + else: # left (default) + x0 = 0.0 + # Vertically centre the rect on the anchor. + y0 = -h / 2.0 + self.setRect( + QRectF(x0 - pad_w, y0 - pad_h, w + 2 * pad_w, h + 2 * pad_h) + ) + + def sync_position(self, scene_pos: QPointF) -> None: + """Move the item to ``scene_pos`` without triggering a model write. + + Used by the overlay when rebuilding item positions from the model after + a render. Guarded so the resulting ``itemChange`` is ignored. + """ + self._syncing = True + try: + self.setPos(scene_pos) + finally: + self._syncing = False + + # ------------------------------------------------------------------ + # Selection appearance / sync + # ------------------------------------------------------------------ + def sync_selection(self, selected: bool) -> None: + """Set selection state without notifying the overlay (no re-emit). + + Used by :meth:`AnnotationOverlay.select_annotation` so a + panel-driven (or overlay-driven) programmatic selection doesn't loop + back through :data:`AnnotationOverlay.selection_changed`. Mirrors + :meth:`sync_position`'s ``_syncing`` guard discipline. + """ + self._selecting = True + try: + self.setSelected(selected) + finally: + self._selecting = False + + def itemChange(self, change, value): # noqa: N802 - Qt override + if ( + change + == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged + ): + self._update_selection_pen() + if not self._selecting: + self._overlay._on_item_selection_changed(self) + return super().itemChange(change, value) + + def _update_selection_pen(self) -> None: + if self.isSelected(): + pen = QPen(_SELECT_COLOR) + pen.setStyle(Qt.PenStyle.DashLine) + pen.setCosmetic(True) + pen.setWidthF(1.5) + self.setPen(pen) + else: + self.setPen(QPen(Qt.GlobalColor.transparent)) + + # ------------------------------------------------------------------ + # Hover cursor + # ------------------------------------------------------------------ + def hoverEnterEvent(self, event): # noqa: N802 - Qt override + if not self._editing: + self.setCursor(Qt.CursorShape.OpenHandCursor) + # Suspend view panning so a left-drag reaches this item instead of + # being grabbed by ScrollHandDrag (the two fight -- see + # PreviewView.suspend_pan / the panning-vs-drag note). + self._overlay.set_pan_suspended(True) + super().hoverEnterEvent(event) + + def hoverLeaveEvent(self, event): # noqa: N802 - Qt override + self.unsetCursor() + if not self._dragging: + self._overlay.set_pan_suspended(False) + super().hoverLeaveEvent(event) + + # ------------------------------------------------------------------ + # Drag + # ------------------------------------------------------------------ + def mousePressEvent(self, event): # noqa: N802 - Qt override + if self._editing: + super().mousePressEvent(event) + return + if event.button() == Qt.MouseButton.LeftButton: + self.setSelected(True) + self._begin_drag() + super().mousePressEvent(event) + + def _begin_drag(self) -> None: + self._dragging = True + self._interaction_fingerprint = self._overlay.current_mapping_fingerprint() + self.setCursor(Qt.CursorShape.ClosedHandCursor) + self._show_ghost() + + def mouseReleaseEvent(self, event): # noqa: N802 - Qt override + super().mouseReleaseEvent(event) + if self._dragging: + self._dragging = False + self._interaction_fingerprint = None + self.setCursor(Qt.CursorShape.OpenHandCursor) + self._commit_drag() + # The cursor may have left the (old) rect during the drag; if it is + # no longer hovering, restore panning. isUnderMouse() is a good + # enough proxy and errs toward restoring pan. + if not self.isUnderMouse(): + self._overlay.set_pan_suspended(False) + + def _commit_drag(self) -> None: + """Write the item's final scene position back to the model as data.""" + # Keep the ghost visible until the rebuild lands (no-jump): the baked + # pixels still show the old position until the debounced re-render. + self._overlay.commit_item_move(self) + + # ------------------------------------------------------------------ + # Inline edit + # ------------------------------------------------------------------ + def mouseDoubleClickEvent(self, event): # noqa: N802 - Qt override + self.begin_edit() + event.accept() + + def begin_edit(self) -> None: + """Enter inline text-edit mode (pre-filled, editable).""" + if self._editing: + return + self._editing = True + self._interaction_fingerprint = self._overlay.current_mapping_fingerprint() + self.setSelected(True) + self._clear_ghost() + editor = QGraphicsTextItem(self) + editor.setPlainText(str(self.text_dict.get("text", ""))) + editor.setDefaultTextColor(_SELECT_COLOR) + editor.setTextInteractionFlags(Qt.TextInteractionFlag.TextEditorInteraction) + # Position the editor at the anchor (item local origin). + editor.setPos(0.0, -editor.boundingRect().height() / 2.0) + editor.setZValue(20) + editor.installSceneEventFilter(self) + self._editor = editor + editor.setFocus(Qt.FocusReason.MouseFocusReason) + # Select-all so typing replaces the placeholder immediately. + cursor = editor.textCursor() + cursor.select(cursor.SelectionType.Document) + editor.setTextCursor(cursor) + + def sceneEventFilter(self, watched, event): # noqa: N802 - Qt override + """Handle Enter (commit) / Escape (cancel) in the inline editor.""" + from PySide6.QtCore import QEvent + + if watched is self._editor and event.type() == QEvent.Type.KeyPress: + key = event.key() + if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): + if not (event.modifiers() & Qt.KeyboardModifier.ShiftModifier): + self.commit_edit() + return True + elif key == Qt.Key.Key_Escape: + self.cancel_edit() + return True + return super().sceneEventFilter(watched, event) + + def focusOutEvent(self, event): # noqa: N802 - Qt override + super().focusOutEvent(event) + + def commit_edit(self) -> None: + """Commit the inline-edit buffer to the model (empty => delete).""" + if not self._editing or self._editor is None: + return + new_text = self._editor.toPlainText() + self._teardown_editor() + self._overlay.commit_item_text(self, new_text) + + def cancel_edit(self) -> None: + """Abandon the inline edit, leaving the model untouched.""" + if not self._editing: + return + self._teardown_editor() + + def _teardown_editor(self) -> None: + self._editing = False + self._interaction_fingerprint = None + if self._editor is not None: + self._editor.removeSceneEventFilter(self) + scene = self.scene() + if scene is not None: + scene.removeItem(self._editor) + self._editor = None + + # ------------------------------------------------------------------ + # Ghost (drag preview) + # ------------------------------------------------------------------ + def _show_ghost(self) -> None: + self._clear_ghost() + text = str(self.text_dict.get("text", "")) + if not text: + return + ghost = QGraphicsSimpleTextItem(text, self) + color = QColor(_SELECT_COLOR) + color.setAlpha(_GHOST_ALPHA) + ghost.setBrush(color) + # Scale the ghost font so its height ~ the hit-rect height. + rect = self.rect() + target_h = rect.height() / (1.0 + 2 * _HIT_PAD_FRAC) + raw_h = ghost.boundingRect().height() or 1.0 + ghost.setScale(max(target_h / raw_h, 0.05)) + ghost.setPos(0.0, -ghost.boundingRect().height() * ghost.scale() / 2.0) + ghost.setZValue(15) + self._ghost = ghost + + def keep_ghost(self) -> None: + """Ensure a drag ghost is showing (used to bridge the render window). + + No-op while editing: the inline editor owns the visual then, and a + ghost underneath would double-draw. + """ + if self._editing: + return + if self._dragging and self._ghost is None: + self._show_ghost() + + def _clear_ghost(self) -> None: + if self._ghost is not None: + scene = self.scene() + if scene is not None: + scene.removeItem(self._ghost) + self._ghost = None + + def clear_transients(self) -> None: + """Drop any ghost/editor children (called on teardown/rebuild).""" + self._clear_ghost() + self._teardown_editor() + + def abort_interaction(self) -> None: + """Cancel any in-progress drag/edit WITHOUT writing to the model. + + Used when the interaction must be discarded rather than committed: + an undo/redo (``figure_replaced``) landing mid-gesture (Finding 3) or a + mapping change (format switch) invalidating the held scene position + (Finding 4). A drag discards its ghost and never commits; an edit + discards its editor buffer. The item is left non-interacting so the + next rebuild re-syncs it (or removes it) from the model cleanly. + """ + self._dragging = False + self._interaction_fingerprint = None + # cancel_edit()/_teardown_editor() reset the editing flag + editor. + self.cancel_edit() + self._clear_ghost() + self._overlay.set_pan_suspended(False) + + # ------------------------------------------------------------------ + # Delete key + # ------------------------------------------------------------------ + def keyPressEvent(self, event): # noqa: N802 - Qt override + if self._editing: + super().keyPressEvent(event) + return + if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace): + self._overlay.delete_item(self) + event.accept() + return + super().keyPressEvent(event) + + +class AnnotationOverlay(QObject): + """Owns the annotation hit-items over the preview and syncs them to the model. + + Parameters + ---------- + document : FigureDocument + The shared document; mutations write into ``ax.texts`` dicts and call + ``document.notify_changed()`` (one notify == one undo step). + preview_view : PreviewView + The view whose scene the overlay items live in; its ``view_mapping()`` + supplies the ``cm <-> scene`` transform. + parent : QObject, optional + Qt parent. + + Signals + ------- + overlay_enabled_changed(bool) + Emitted when the overlay transitions between enabled (valid geometry + + mapping) and disabled (either is ``None``). The main window uses it to + enable/disable the "Add text annotation" action and status hints. + selection_changed(object) + Emitted with the selected :class:`AnnotationItem`'s ``text_dict`` + (object identity) when the *user* changes the on-canvas selection, or + ``None`` when the selection is cleared. NOT emitted for programmatic + selection made via :meth:`select_annotation` -- mirrors + ``TextsPanel.text_selected`` vs. ``TextsPanel.select_text``. This is + the sync hook the main window uses to drive the Texts panel from + canvas clicks (see :meth:`select_annotation` for the reverse + direction). + + Wiring (done by the main window, kept explicit) + ----------------------------------------------- + * ``PreviewController.geometry_ready`` -> :meth:`set_geometry` + * ``PreviewController.render_succeeded`` -> :meth:`on_render_succeeded` + """ + + overlay_enabled_changed = Signal(bool) + #: Emitted right after a click placed a new annotation (add-mode consumed). + #: The main window uses it to clear the "Click to place" status hint. + add_text_placed = Signal() + #: Emitted with the selected item's ``text_dict`` (object identity) when + #: the *user* changes the on-canvas selection (click/deselect via Qt's + #: selection machinery). NOT emitted for programmatic selection made via + #: :meth:`select_annotation` -- same discipline as + #: ``TextsPanel.text_selected`` vs. ``TextsPanel.select_text``. Emits + #: ``None`` when the selection is cleared. + selection_changed = Signal(object) + + def __init__(self, document, preview_view, parent: Optional[QObject] = None) -> None: + super().__init__(parent) + self._document = document + self._view = preview_view + self._geometry: Optional[PreviewGeometry] = None + self._items: List[AnnotationItem] = [] + self._enabled = False + + # Hard-disabled state (Finding 1): while entering the read-only + # GLE-preview mode the overlay must go completely inert -- items cleared, + # add-mode/interactions cancelled, and every model-mutation commit path a + # no-op -- so a stray drag/click cannot mutate the hidden document + # figure. This survives across renders (which never happen in preview + # mode) until set_disabled(False) is called on returning to document + # mode; the next document render then re-enables via geometry_ready. + self._disabled = False + + # Add-text mode: while active, the next click on the preview places a + # new annotation. Driven by begin_add_text()/cancel_add_text(). + self._add_mode = False + + # Pending inline-edit request: after adding a new annotation we want to + # enter edit mode once the item exists post-rebuild. Matched by (dict) + # identity captured at add time. + self._pending_edit_dict: Optional[dict] = None + + # Remembers the selected annotation's dict (identity) so selection + # survives a rebuild (items are recreated by rebuild(), so Qt's own + # per-item isSelected() state doesn't carry over). Mirrors + # _pending_edit_dict's "remember by identity, re-apply after rebuild" + # pattern. None means "no selection". + self._selected_dict: Optional[dict] = None + + # Install ourselves as a scene event filter so we can catch clicks on + # empty scene area for add-text placement. + scene = self._view.scene() + if scene is not None: + scene.installEventFilter(self) + + # ------------------------------------------------------------------ + # Public state + # ------------------------------------------------------------------ + @property + def enabled(self) -> bool: + """Whether the overlay currently has a usable geometry + mapping.""" + return self._enabled + + @property + def items(self) -> List[AnnotationItem]: + """The live annotation items (test/inspection helper).""" + return list(self._items) + + @property + def add_mode(self) -> bool: + """Whether the overlay is waiting for a click to place a new text.""" + return self._add_mode + + # ------------------------------------------------------------------ + # Geometry / render wiring + # ------------------------------------------------------------------ + def set_geometry(self, geometry: Optional[PreviewGeometry]) -> None: + """Install calibration geometry (connect to ``geometry_ready``). + + ``None`` (parse failure / failed render) disables the overlay and + clears all items. A valid geometry does not itself rebuild -- the paired + ``render_succeeded`` does (so items land on the fresh image with a valid + ``view_mapping``). Geometry arrives *before* ``render_succeeded`` per the + controller contract, so storing it here is enough. + + A valid geometry also lifts the hard-disabled state (Finding 1): a + document-mode render is the signal that we have left GLE-preview mode, + so the overlay may operate again once the paired render rebuilds it. + """ + self._geometry = geometry + if geometry is None: + self._set_enabled(False) + self._clear_items() + self.cancel_add_text() + elif self._disabled: + self._disabled = False + + def set_disabled(self, disabled: bool) -> None: + """Hard-disable (or re-enable) the whole overlay (Finding 1). + + Entering GLE-preview mode calls ``set_disabled(True)``: the overlay + clears its items, cancels add-mode, aborts any active interaction, and + every model-mutation commit path becomes a logged no-op -- so no stray + drag/click on the read-only preview can mutate the hidden document + figure. The disabled state survives across the (non-existent) preview + renders; it is lifted either by ``set_disabled(False)`` on returning to + document mode or automatically by the next valid ``set_geometry`` from a + document render (whichever comes first). + """ + if disabled == self._disabled: + return + self._disabled = disabled + if disabled: + self.cancel_add_text() + for it in list(self._items): + it.abort_interaction() + self._set_enabled(False) + self._clear_items() + + def on_render_succeeded(self, _path: str = "") -> None: + """Rebuild item positions against the freshly rendered image. + + Connect to ``PreviewController.render_succeeded``. Refetches + ``view_mapping`` (never cached across renders) and reconciles items with + the model's current ``texts`` lists. Skips any item the user is actively + dragging/editing so a mid-gesture render never teleports it. + """ + self.rebuild() + + def on_figure_replaced(self) -> None: + """Abort all active interactions and clear items (Finding 3). + + Connect to ``FigureDocument.figure_replaced`` (fires on New/Open and + every undo/redo). A brand-new figure object replaces the one every + ``text_dict`` was captured from, so any item mid-drag/edit now points at + an orphaned dict; committing on release would mutate a dead object and + leave a phantom item stuck ``is_interacting``. We therefore ABORT every + active interaction (discard the drag ghost with no commit; discard the + editor buffer) and drop all items now. They are rebuilt against the new + figure by the ``render_succeeded`` that follows the replacement's + re-render; until then the overlay simply has no items. + """ + for it in list(self._items): + it.abort_interaction() + self._clear_items() + + # ------------------------------------------------------------------ + # Rebuild + # ------------------------------------------------------------------ + def _mapping(self): + """Refetch the active ``cm <-> scene`` mapping (never cached).""" + return self._view.view_mapping() + + def current_mapping_fingerprint(self) -> Optional[tuple]: + """Fingerprint of the active mapping, or ``None`` if none/unsupported. + + Captured by an :class:`AnnotationItem` when a drag/edit begins so a + later rebuild can detect a mapping change (format switch, DPI change) + that would corrupt a stale scene position (Finding 4). Tolerant of a + mapping object without ``fingerprint()`` (returns ``None`` -> the + abort check is simply skipped, preserving prior behaviour). + """ + mapping = self._mapping() + if mapping is None: + return None + fp = getattr(mapping, "fingerprint", None) + if not callable(fp): + return None + try: + return fp() + except Exception: # noqa: BLE001 - never let a fingerprint crash a gesture + return None + + def _cm_per_scene_unit(self, mapping) -> float: + """Measure page-cm spanned by one scene unit along x (uniform scale).""" + c0 = mapping.view_to_cm(0.0, 0.0) + c1 = mapping.view_to_cm(1.0, 0.0) + return abs(c1[0] - c0[0]) + + def rebuild(self) -> None: + """Reconcile overlay items with the model's annotations + fresh render. + + The overlay is enabled iff both a geometry and a view mapping exist. On + enable it (re)builds one :class:`AnnotationItem` per ``ax.texts`` dict, + positioned at the annotation's data coords mapped through + data->cm->scene. Items the user is actively interacting with are left + in place (their model dict is not yet written for a drag, or is being + edited); every other item is rebuilt from scratch so identity stays + simple. + """ + if self._disabled: + # Hard-disabled (GLE-preview mode): stay inert until re-enabled. + self._set_enabled(False) + self._clear_items() + return + mapping = self._mapping() + fig = self._document.figure + if self._geometry is None or mapping is None or fig is None: + self._set_enabled(False) + self._clear_items() + return + + cm_per_scene = self._cm_per_scene_unit(mapping) + + # Determine the current mapping fingerprint once so preserved items can + # be checked for a mapping change that happened mid-gesture (Finding 4). + current_fp = self.current_mapping_fingerprint() + + # Preserve items currently being interacted with (drag/edit); rebuild + # everything else. Dropped ghosts from a just-committed drag are cleared + # here because the fresh render now bakes the text at the new position. + # + # Finding 4: if the mapping fingerprint changed since an interaction + # began (e.g. a PNG<->SVG format switch or DPI change), the held scene + # position no longer decodes correctly under the new mapping. Aborting + # the interaction reverts the item to its model position (it is then + # rebuilt from scratch below) instead of silently committing a jumped + # coordinate on release. + preserved: List[AnnotationItem] = [] + for it in self._items: + if not it.is_interacting: + continue + started_fp = it._interaction_fingerprint + if ( + current_fp is not None + and started_fp is not None + and started_fp != current_fp + ): + _log.debug( + "annotation overlay: mapping changed mid-interaction; " + "aborting to avoid a corrupted commit" + ) + it.abort_interaction() + # No longer interacting -> falls through to full rebuild below. + continue + preserved.append(it) + + for it in self._items: + if it not in preserved: + it.clear_transients() + self._remove_item(it) + + self._items = list(preserved) + + # Map each preserved item's dict so we don't duplicate it below. + preserved_dicts = {id(it.text_dict) for it in preserved} + + pending_edit_item: Optional[AnnotationItem] = None + axes_list = list(getattr(fig, "axes_list", []) or []) + for cal in self._geometry.axes: + if cal.index >= len(axes_list): + continue + ax = axes_list[cal.index] + for td in list(getattr(ax, "texts", []) or []): + if id(td) in preserved_dicts: + continue + item = self._make_item(td, cal, mapping, cm_per_scene) + if item is None: + continue + self._items.append(item) + if self._pending_edit_dict is not None and td is self._pending_edit_dict: + pending_edit_item = item + + # Re-sync preserved (interacting) items' rects to the new scale but not + # their position (the user owns it mid-gesture). Keep the drag ghost so + # the baked-pixel lag until this render is not seen as a snap-back. + for it in preserved: + it.rebuild_rect(cm_per_scene) + it.keep_ghost() + + self._set_enabled(True) + + # Re-apply the remembered selection (by dict identity) now that items + # have been recreated: rebuilding replaces AnnotationItem instances, so + # Qt's own isSelected() state is lost even though the *annotation* + # itself is still logically selected. Uses the no-emit path so a + # rebuild never re-fires selection_changed on its own. + if self._selected_dict is not None: + self._apply_selection(self._selected_dict, emit=False) + + # Fulfil a pending add-then-edit request now that the item exists. + if pending_edit_item is not None: + self._pending_edit_dict = None + pending_edit_item.begin_edit() + + def _make_item( + self, text_dict: dict, cal: AxesCalibration, mapping, cm_per_scene: float + ) -> Optional[AnnotationItem]: + try: + cx, cy = cal.data_to_cm(float(text_dict["x"]), float(text_dict["y"])) + vx, vy = mapping.cm_to_view(cx, cy) + except (KeyError, TypeError, ValueError): + return None + item = AnnotationItem(self, text_dict, cal) + scene = self._view.scene() + if scene is None: + return None + scene.addItem(item) + item.rebuild_rect(cm_per_scene) + item.sync_position(QPointF(vx, vy)) + return item + + # ------------------------------------------------------------------ + # Model mutations (called by AnnotationItem) + # ------------------------------------------------------------------ + def _dict_is_live(self, text_dict: dict) -> bool: + """True if ``text_dict`` (by identity) still lives in some axes' texts. + + A commit path must verify this before mutating (Finding 8): an inline + edit can be open on an annotation that is meanwhile removed via the + Texts panel's Remove button, leaving the item's ``text_dict`` orphaned. + Mutating the orphan would corrupt state and burn an undo step, so the + caller drops the item silently instead. + """ + fig = self._document.figure + if fig is None: + return False + for ax in list(getattr(fig, "axes_list", []) or []): + texts = getattr(ax, "texts", None) + if texts is None: + continue + for td in texts: + if td is text_dict: + return True + return False + + def _drop_orphaned_item(self, item: AnnotationItem) -> None: + """Silently discard an item whose dict is no longer in the model. + + No ``notify_changed`` (nothing was mutated): the model already reflects + the removal that orphaned this item. + """ + item.clear_transients() + self._remove_item(item) + if item in self._items: + self._items.remove(item) + if self._selected_dict is item.text_dict: + self._selected_dict = None + + def commit_item_move(self, item: AnnotationItem) -> None: + """Write ``item``'s dropped scene position into its model dict as data. + + Cross-axes policy: the annotation keeps its ORIGINAL owning axes even + when dropped outside that axes' box; coords are computed via that axes' + transform regardless (see module docstring). Keeps the ghost visible so + the baked-pixel lag until the next render is not seen as a snap-back. + """ + if self._disabled: + _log.debug("annotation overlay disabled: commit_item_move no-op") + return + if not self._dict_is_live(item.text_dict): + _log.debug("commit_item_move: item dict orphaned; dropping item") + self._drop_orphaned_item(item) + return + mapping = self._mapping() + if mapping is None: + return + scene_pos = item.pos() + cx, cy = mapping.view_to_cm(scene_pos.x(), scene_pos.y()) + x, y = item.cal.cm_to_data(cx, cy) + item.text_dict["x"] = float(x) + item.text_dict["y"] = float(y) + item.keep_ghost() + self._document.notify_changed() + + def commit_item_text(self, item: AnnotationItem, new_text: str) -> None: + """Commit an inline-edit result; empty text deletes the annotation.""" + if self._disabled: + _log.debug("annotation overlay disabled: commit_item_text no-op") + return + if not self._dict_is_live(item.text_dict): + _log.debug("commit_item_text: item dict orphaned; dropping item") + self._drop_orphaned_item(item) + return + stripped = new_text.strip() + if stripped == "": + self.delete_item(item) + return + if item.text_dict.get("text") == new_text: + # No change: still rebuild to drop the editor child cleanly. + item.clear_transients() + self.rebuild() + return + item.text_dict["text"] = new_text + self._document.notify_changed() + + def delete_item(self, item: AnnotationItem) -> None: + """Remove ``item``'s dict from its axes' ``texts`` list + notify. + + If the dict is already gone (orphaned via the Texts panel Remove, say), + the item is dropped silently with no ``notify_changed`` -- there is + nothing to mutate and no undo step should be burned (Finding 8). + """ + if self._disabled: + _log.debug("annotation overlay disabled: delete_item no-op") + return + fig = self._document.figure + removed = False + if fig is not None: + axes_list = list(getattr(fig, "axes_list", []) or []) + if item.cal.index < len(axes_list): + ax = axes_list[item.cal.index] + texts = getattr(ax, "texts", None) + if texts is not None and item.text_dict in texts: + texts.remove(item.text_dict) + removed = True + item.clear_transients() + self._remove_item(item) + if item in self._items: + self._items.remove(item) + if self._selected_dict is item.text_dict: + self._selected_dict = None + if removed: + self._document.notify_changed() + + # ------------------------------------------------------------------ + # Selection sync (F1 <-> TextsPanel contract) + # ------------------------------------------------------------------ + def select_annotation(self, text_dict: Optional[dict]) -> None: + """Programmatically select the item whose ``text_dict`` matches. + + Matched by object identity (the same dict as an ``ax.texts`` entry), + mirroring how :class:`AnnotationItem` carries the dict. Deselects all + other items. A no-op if no live item currently has that identity + (e.g. the dict belongs to a different axes not currently rendered, or + the overlay is disabled) -- except that the *remembered* selection is + still updated so a subsequent rebuild (which may bring the matching + item into existence) re-applies it. + + Does NOT emit :data:`selection_changed` -- same no-emit discipline as + ``TextsPanel.select_text``, since this is meant to be called *in + response to* an external selection (e.g. the Texts panel), not as a + user action on the canvas itself. Passing ``None`` clears the + selection. + """ + self._apply_selection(text_dict, emit=False) + + def _apply_selection(self, text_dict: Optional[dict], *, emit: bool) -> None: + """Shared implementation for programmatic and user-driven selection. + + Always updates ``_selected_dict`` (the identity remembered across + rebuilds) and syncs every live item's Qt selection state via the + no-emit ``sync_selection`` path (so this never re-enters + :meth:`_on_item_selection_changed`), then optionally emits + :data:`selection_changed` once for the caller. + """ + self._selected_dict = text_dict + for it in self._items: + it.sync_selection(it.text_dict is text_dict) + if emit: + self.selection_changed.emit(text_dict) + + def _on_item_selection_changed(self, item: AnnotationItem) -> None: + """Route a user-driven ``ItemSelectedHasChanged`` to the overlay. + + Called by :meth:`AnnotationItem.itemChange` (guarded there so + programmatic ``sync_selection`` calls never reach here). Deselects + any other item (single-selection discipline) and emits + :data:`selection_changed` with the newly-selected dict, or ``None`` + if the item was deselected and nothing else is selected. + """ + if item.isSelected(): + self._selected_dict = item.text_dict + for other in self._items: + if other is not item and other.isSelected(): + other.sync_selection(False) + self.selection_changed.emit(item.text_dict) + else: + if self._selected_dict is item.text_dict: + self._selected_dict = None + self.selection_changed.emit(None) + + # ------------------------------------------------------------------ + # Add-text mode + # ------------------------------------------------------------------ + def begin_add_text(self) -> None: + """Arm add-text mode: the next preview click places a new annotation.""" + if self._disabled or not self._enabled: + return + self._add_mode = True + + def cancel_add_text(self) -> None: + """Disarm add-text mode (e.g. Esc).""" + self._add_mode = False + + def _place_text_at(self, scene_pos: QPointF) -> bool: + """Add a new annotation at ``scene_pos`` and request an inline edit. + + Picks the containing axes (by cm containment); if none contains the + point, falls back to the nearest axes by cm-rect centre distance and + the point is simply expressed in that axes' coords (documented + fallback). Uses the public ``Axes.text`` API so the appended dict schema + matches exactly. Returns True if an annotation was added. + """ + if self._disabled: + _log.debug("annotation overlay disabled: add-placement no-op") + return False + mapping = self._mapping() + fig = self._document.figure + if not self._enabled or mapping is None or fig is None or self._geometry is None: + return False + + cx, cy = mapping.view_to_cm(scene_pos.x(), scene_pos.y()) + cal = self._axes_for_cm(cx, cy) + if cal is None: + return False + axes_list = list(getattr(fig, "axes_list", []) or []) + if cal.index >= len(axes_list): + return False + ax = axes_list[cal.index] + x, y = cal.cm_to_data(cx, cy) + + # Public API guarantees the exact dict schema; text() appends and + # returns self, so the new dict is the last entry. + ax.text(float(x), float(y), "text") + new_dict = ax.texts[-1] + self._pending_edit_dict = new_dict + self.cancel_add_text() + self._document.notify_changed() + self.add_text_placed.emit() + return True + + def _axes_for_cm(self, cx: float, cy: float) -> Optional[AxesCalibration]: + """Containing axes by cm, else nearest by cm-rect centre distance.""" + if self._geometry is None or not self._geometry.axes: + return None + for cal in self._geometry.axes: + if cal.contains_cm(cx, cy): + return cal + # Fallback: nearest axes centre (clamped placement, documented). + best = None + best_d = None + for cal in self._geometry.axes: + mx = (cal.cm_rect[0] + cal.cm_rect[2]) / 2.0 + my = (cal.cm_rect[1] + cal.cm_rect[3]) / 2.0 + d = (mx - cx) ** 2 + (my - cy) ** 2 + if best_d is None or d < best_d: + best_d = d + best = cal + return best + + # ------------------------------------------------------------------ + # Scene event filter (add-text click capture) + # ------------------------------------------------------------------ + def eventFilter(self, watched, event): # noqa: N802 - Qt override + from PySide6.QtCore import QEvent + from PySide6.QtWidgets import QGraphicsSceneMouseEvent + + if ( + self._add_mode + and event.type() == QEvent.Type.GraphicsSceneMousePress + and isinstance(event, QGraphicsSceneMouseEvent) + and event.button() == Qt.MouseButton.LeftButton + ): + if self._place_text_at(event.scenePos()): + event.accept() + return True + return super().eventFilter(watched, event) + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + def set_pan_suspended(self, suspend: bool) -> None: + """Ask the view to suspend/restore drag-panning (item-drag support).""" + suspend_pan = getattr(self._view, "suspend_pan", None) + if callable(suspend_pan): + suspend_pan(suspend) + + def _set_enabled(self, value: bool) -> None: + if value != self._enabled: + self._enabled = value + # Keep the view's informational flag in sync for status/UI. + try: + self._view.annotations_enabled = value + except AttributeError: + pass + self.overlay_enabled_changed.emit(value) + if not value: + self.cancel_add_text() + + def _remove_item(self, item: AnnotationItem) -> None: + scene = self._view.scene() + if scene is not None and item.scene() is scene: + scene.removeItem(item) + + def _clear_items(self) -> None: + for it in self._items: + it.clear_transients() + self._remove_item(it) + self._items = [] diff --git a/src/gleplot/gui/data/panel.py b/src/gleplot/gui/data/panel.py index 4f20783..92a69ac 100644 --- a/src/gleplot/gui/data/panel.py +++ b/src/gleplot/gui/data/panel.py @@ -11,12 +11,32 @@ in-memory arrays directly (``Import data``) or by referencing the file's columns in place (``Reference file``, via ``Axes.line_from_file`` / ``Axes.errorbar_from_file``). +4. Rename a table's column headers in place (double-click a header cell), + for figure-owned sidecars and in-memory tables only -- see "Header + editing" below. The panel talks to a ``document`` object via a small duck-typed contract (``.figure``, ``.notify_changed()``, ``.figure_replaced`` signal) rather than importing the concrete ``FigureDocument`` class, since that class is implemented by a parallel work track. See ``tests/gui/test_data_panel.py`` for a minimal local stub satisfying this contract. + +Header editing (Track G1) +-------------------------- +Column headers are editable ONLY for tables gleplot itself owns the data +of: figure-owned sidecars (generated ``.dat`` files behind an "Import +data" series) and tables loaded via ``load_file`` that are not (yet) +referenced by any file-reference series. They are NOT editable for +external files referenced in place (``ax.file_series``, i.e. "Reference +file" mode / ``line_from_file`` / ``errorbar_from_file``): gleplot never +rewrites a user's own source data file, so renaming its header would be +either a lie (cosmetic-only, not written back) or dangerous (silently +rewriting someone else's file). See :meth:`DataPanel._is_editable_table` +for the precise ownership rule and :meth:`DataPanel._on_header_double_clicked` +for the edit UX (a prefilled ``QInputDialog``, not an in-place header +delegate -- simpler to get right and consistent with how Qt table headers +are conventionally edited, since ``QHeaderView`` has no built-in item +delegate support the way table cells do). """ from __future__ import annotations @@ -33,10 +53,12 @@ QFormLayout, QGroupBox, QHBoxLayout, + QInputDialog, QLabel, QLineEdit, QListWidget, QListWidgetItem, + QMessageBox, QPushButton, QSizePolicy, QTableWidget, @@ -45,8 +67,16 @@ QWidget, ) +from gleplot.axes import _unique_column_names, sanitize_column_name + from .loader import DataTable, load_data_file +#: Tooltip shown on non-editable (external-file) column headers. +_EXTERNAL_HEADER_TOOLTIP = ( + "Column names come from the referenced file and are not edited by " + "gleplot." +) + #: Maximum number of rows shown in the preview table. _MAX_PREVIEW_ROWS = 100 @@ -95,15 +125,26 @@ class DataPanel(QWidget): series_added(str) Emitted with the new series' label after a series is successfully added to the figure. + column_renamed(str, int, str) + Emitted after a successful header rename (no-op renames and + rejected/cancelled edits do not emit this): the file key (the + resolved absolute path string used internally in ``_tables``, + matching the list item's ``_PATH_ROLE`` data), the 1-based column + index (matching GLE's own column numbering, as used elsewhere in + this panel for ``x_col``/``y_col``), and the new (sanitized) + column name. Intended for status-bar wiring by ``main_window``; + this panel does not display it itself. """ series_added = Signal(str) + column_renamed = Signal(str, int, str) def __init__(self, document, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self._document = document self._tables: Dict[str, DataTable] = {} # path string -> DataTable self._current_table: Optional[DataTable] = None + self._current_key: Optional[str] = None # key into self._tables self._last_dir: str = str(Path.home()) self._build_ui() @@ -173,6 +214,9 @@ def _connect_signals(self) -> None: self.label_edit.textEdited.connect(self._on_label_edited) self.add_series_button.clicked.connect(self.add_series) self.mode_combo.currentTextChanged.connect(self._on_mode_changed) + self.preview_table.horizontalHeader().sectionDoubleClicked.connect( + self._on_header_double_clicked + ) figure_replaced = getattr(self._document, "figure_replaced", None) if figure_replaced is not None: @@ -288,6 +332,7 @@ def _on_file_selected( ) -> None: if current is None: self._current_table = None + self._current_key = None self.preview_table.clear() self.preview_table.setRowCount(0) self.preview_table.setColumnCount(0) @@ -298,11 +343,14 @@ def _on_file_selected( key = current.data(_PATH_ROLE) table = self._tables.get(key) self._current_table = table - self._populate_preview(table) + self._current_key = key + self._populate_preview(table, key) self._populate_column_combos() self._update_add_button_state() - def _populate_preview(self, table: Optional[DataTable]) -> None: + def _populate_preview( + self, table: Optional[DataTable], key: Optional[str] = None + ) -> None: self.preview_table.clear() if table is None: self.preview_table.setRowCount(0) @@ -312,7 +360,13 @@ def _populate_preview(self, table: Optional[DataTable]) -> None: n_preview_rows = min(table.n_rows, _MAX_PREVIEW_ROWS) self.preview_table.setColumnCount(table.n_cols) self.preview_table.setRowCount(n_preview_rows) - self.preview_table.setHorizontalHeaderLabels(table.column_names) + + editable = key is not None and self._is_editable_table(key) + for col_idx, name in enumerate(table.column_names): + header_item = QTableWidgetItem(name) + if not editable: + header_item.setToolTip(_EXTERNAL_HEADER_TOOLTIP) + self.preview_table.setHorizontalHeaderItem(col_idx, header_item) grey = QColor(160, 160, 160) for col_idx, (col, numeric) in enumerate(zip(table.columns, table.is_numeric)): @@ -376,6 +430,259 @@ def _update_default_label(self) -> None: def _on_mode_changed(self, text: str) -> None: self.mode_combo.setToolTip(_MODE_TOOLTIPS.get(text, "")) + # ------------------------------------------------------------------ + # Header editing (Track G1) + # ------------------------------------------------------------------ + def _import_entries(self, fig): + """All series entries capable of owning a regenerated sidecar. + + These are exactly the collections the writer regenerates + ``data_file``/``column_names`` for on every save (see + ``Figure.savefig_gle`` pulling ``column_names`` straight off each + dict). ``ax.file_series`` is deliberately excluded: those entries + reference an external file in place and are never rewritten. + """ + entries = [] + for ax in getattr(fig, "axes_list", []): + entries.extend(ax.lines) + entries.extend(ax.scatters) + entries.extend(ax.bars) + entries.extend(ax.fills) + entries.extend(ax.errorbars) + return entries + + def _owning_series_for_key(self, key: str) -> List[dict]: + """Import-mode series entries whose sidecar resolves to ``key``. + + Ownership rule (the precise form of the "figure-owned sidecar" + concept described in the module docstring): a loaded table is + figure-owned iff its resolved absolute path (``key``, normcased) + equals the resolution of some import-mode series' ``data_file`` + against ``document.project_path``'s directory. This mirrors + exactly what :meth:`populate_from_figure` does when deciding + which files to surface, so "shows up via populate_from_figure" and + "is figure-owned/editable" are the same test by construction: + + - Import-mode series (``ax.lines``/``scatters``/``bars``/``fills``/ + ``errorbars``) store a ``data_file`` that is a bare/relative + sidecar filename (e.g. ``"myfig_0.dat"``) generated by + ``_resolve_data_file``. It only resolves to an absolute path + once a project directory is known (``document.project_path``); + before the first save (no project path yet), it can never match + anything already on disk, so nothing is editable-as-owned yet + (there is also nothing to rename propagation-wise: the table was + loaded via ``load_file``, i.e. user-loaded/in-memory, which is + separately editable -- see ``_is_editable_table``). + - ``ax.file_series`` entries (the "Reference file" / *EXTERNAL* + mode) store an absolute path to a user's own file and are never + considered here. + + A single sidecar can in principle be the ``data_file`` for more + than one series entry (e.g. a fill built from two datasets that + happen to share a file); all matching entries are returned so a + rename can be propagated to every one of them. + + Matching (Findings 4 & 5) + ------------------------- + A relative import ``data_file`` is resolved against + ``project_path`` AT RENAME TIME, while the table ``key`` was + resolved AT LOAD TIME. After a *Save As* to a new directory the + UI's ``project_path`` moves but the still-in-memory table key does + not, so a purely path-based match goes stale and silently drops the + rename. Because import sidecars are ALWAYS written beside the + ``.gle`` (their relative ``data_file`` is a bare filename), the + sidecar's *basename* is a stable identity independent of which + directory the project currently lives in. So a match succeeds when + EITHER the resolved absolute paths agree OR the basenames agree. + When ``project_path`` is ``None`` (before the first save there is + no directory to resolve against) the basename comparison is the + only signal available, and it is sufficient. + """ + fig = getattr(self._document, "figure", None) + if fig is None: + return [] + + project_path = getattr(self._document, "project_path", None) + base = Path(project_path).parent if project_path else None + + key_base = os.path.normcase(Path(key).name) + key_norm = os.path.normcase(key) + + owners = [] + for entry in self._import_entries(fig): + name = entry.get("data_file") + if not name or "column_names" not in entry: + continue + path = Path(name) + # Basename fallback: import sidecars live beside the .gle, so a + # relative data_file's basename is its stable identity. + if os.path.normcase(path.name) == key_base: + owners.append(entry) + continue + # Path match (absolute entries, or relative resolved against the + # current project dir when one is known). + if not path.is_absolute(): + if base is None: + continue + path = base / path + try: + resolved = os.path.normcase(str(path.resolve())) + except OSError: + continue + if resolved == key_norm: + owners.append(entry) + return owners + + def _is_referenced_externally(self, key: str) -> bool: + """Whether ``key`` is some ``ax.file_series`` entry's data file. + + These are EXTERNAL by definition (mode = "Reference file"): the + GLE script reads the user's file in place, so its header must + display the file's real column names and must never be rewritten. + """ + fig = getattr(self._document, "figure", None) + if fig is None: + return False + + project_path = getattr(self._document, "project_path", None) + base = Path(project_path).parent if project_path else None + + for ax in getattr(fig, "axes_list", []): + for entry in ax.file_series: + name = entry.get("data_file") + if not name: + continue + path = Path(name) + if not path.is_absolute(): + if base is None: + continue + path = base / path + if not path.exists(): + continue + if os.path.normcase(str(path.resolve())) == os.path.normcase(key): + return True + return False + + def _is_editable_table(self, key: str) -> bool: + """Whether the table at ``key`` may have its header renamed. + + Per the user-approved decision: header names are editable ONLY + for figure-owned sidecars (:meth:`_owning_series_for_key` returns + at least one entry) and for in-memory/user-loaded tables that are + not (yet) referenced by any "Reference file" series. Anything + referenced externally (``ax.file_series``) is never editable, + even if it also happens to look like it could be figure-owned + (external takes priority -- a file can't simultaneously be "we + generated this" and "we read the user's file in place"). + """ + if self._is_referenced_externally(key): + return False + return True + + def _on_header_double_clicked(self, col_idx: int) -> None: + """Prompt for a new column name via a prefilled ``QInputDialog``. + + UX choice: a modal prefilled text dialog rather than an in-place + header editor widget. ``QHeaderView`` has no built-in equivalent + of ``QTableWidget``'s cell item delegates, so an in-place editor + would mean hand-building a floating ``QLineEdit`` positioned over + the header section (tracking resizes/scrolling/click-outside-to- + commit). The dialog is a few lines, has an OK/Cancel affordance + for free, and is the conventional Qt pattern for header renames. + """ + table = self._current_table + key = self._current_key + if table is None or key is None or col_idx < 0 or col_idx >= table.n_cols: + return + + if not self._is_editable_table(key): + QMessageBox.information( + self, + "Column name not editable", + _EXTERNAL_HEADER_TOOLTIP, + ) + return + + current_name = table.column_names[col_idx] + new_name, ok = QInputDialog.getText( + self, "Rename column", "Column name:", text=current_name + ) + if not ok: + return + + self._rename_column(key, table, col_idx, new_name) + + def _rename_column( + self, key: str, table: DataTable, col_idx: int, raw_name: str + ) -> None: + """Validate and apply a column rename, propagating everywhere needed. + + Validation (in order): sanitize via + ``gleplot.axes.sanitize_column_name`` (strips to ``[A-Za-z0-9_]``, + lowercases, and prefixes purely-numeric results so a renamed + column can never be mistaken for a data value -- see that + function's docstring for the full rule set). If the sanitized + name is unchanged from the current name, this is a no-op (no + mutation, no ``notify_changed``, no signal). Otherwise uniqueness + within the table is enforced by auto-suffixing via + ``gleplot.axes._unique_column_names`` (the same ``_2``, ``_3``, + ... convention gleplot's own writer uses for sidecar headers) -- + chosen over reject-with-message because it matches how gleplot + already resolves naming collisions elsewhere (e.g. + ``_build_column_names``) and never blocks the user on a dialog + round-trip for what is, after sanitization, a purely mechanical + collision. + + Propagation for figure-owned sidecars: the in-memory + ``DataTable.column_names``, the preview header, the x/y/yerr + combo item text (by index, so current *selections* survive: combo + ``userData`` is the 0-based column index, unaffected by a name + change), and every owning series entry's ``column_names`` list at + the same column index (see :meth:`_owning_series_for_key` for why + index-alignment holds), followed by ``document.notify_changed()`` + so the next save writes the new header. Series *labels* (legend + text) are deliberately left untouched -- a label is user-facing + prose the user may have already customized (e.g. via the Series + tab), whereas the column header is purely a data/file concern; + conflating the two would silently overwrite a label the user + chose on purpose. + """ + sanitized = sanitize_column_name(raw_name) + if sanitized == table.column_names[col_idx]: + return # no-op: unchanged after sanitization + + other_names = [n for i, n in enumerate(table.column_names) if i != col_idx] + final_name = _unique_column_names(other_names + [sanitized])[-1] + + table.column_names[col_idx] = final_name + + header_item = self.preview_table.horizontalHeaderItem(col_idx) + if header_item is not None: + header_item.setText(final_name) + + for combo in (self.x_combo, self.y_combo, self.yerr_combo): + for i in range(combo.count()): + if combo.itemData(i) == col_idx: + combo.setItemText(i, final_name) + + for entry in self._owning_series_for_key(key): + names = entry.get("column_names") + # Finding 7 guard: only propagate into an owner whose + # column_names is index-aligned with THIS table (same column + # count). A mismatched-length list means the entry's columns do + # not correspond 1:1 to the previewed table (a stale/foreign + # entry that basename-matched), so writing names[col_idx] would + # corrupt the wrong column or raise. Skip it defensively. + if names is None: + continue + if len(names) != table.n_cols: + continue + if col_idx < len(names): + names[col_idx] = final_name + + self._document.notify_changed() + self.column_renamed.emit(key, col_idx + 1, final_name) + # ------------------------------------------------------------------ # Enabled-state management # ------------------------------------------------------------------ diff --git a/src/gleplot/gui/geometry.py b/src/gleplot/gui/geometry.py new file mode 100644 index 0000000..819bfc9 --- /dev/null +++ b/src/gleplot/gui/geometry.py @@ -0,0 +1,429 @@ +"""Preview geometry abstraction for the gleplot GUI (Track E1). + +This module is the pure-logic core that maps between three coordinate spaces so +draggable annotations can live in *data* coordinates while being drawn on a +raster preview: + + data (axis units) <-> page cm (GLE page) <-> view px (rendered raster) + +The two mappings are deliberately layered and independent: + +* :class:`AxesCalibration` owns ``data <-> cm`` for one graph block. The mapping + is affine in linear space and affine in ``log10`` space when the relevant axis + is logarithmic. It is renderer-agnostic: it knows only the axis data ranges + and the page-cm rectangle of the axes box (both obtained from GLE at compile + time; see :func:`parse_calibration_lines`). + +* :class:`PreviewGeometry` owns ``cm <-> view`` for the *active raster* renderer + (a PNG rendered at some DPI). The cm->px mapping here is intentionally thin so + that a future SVG backend can supply its own ``cm <-> view`` scale without + touching :class:`AxesCalibration` at all. + +Renderer contract (for the SVG track) +------------------------------------- +Overlay code must always go ``data -> cm`` via :meth:`AxesCalibration.data_to_cm` +and only then ``cm -> view`` via the *active renderer's* mapping. For the PNG +raster that is :meth:`PreviewGeometry.cm_to_px`. An SVG backend would provide an +equivalent ``cm -> svg-user-units`` transform (the SVG viewBox is authored in +the same page-cm units GLE uses, so its mapping is a pure scale with no Y flip); +it should expose a method with the same signature as :meth:`cm_to_px` / +:meth:`px_to_cm` and reuse the :class:`AxesCalibration` list verbatim. Nothing in +:class:`AxesCalibration` is raster-specific, so no changes there are required. + +Overlay usage (for the annotation track) +----------------------------------------- +Given ``geometry`` from ``PreviewController.geometry_ready`` and an annotation at +data ``(x, y)`` belonging to axes ``i``:: + + cal = geometry.axes[i] + cx, cy = cal.data_to_cm(x, y) # data -> page cm + px, py = geometry.cm_to_px(cx, cy) # page cm -> raster px (Y already flipped) + +and the inverse, when the user drops the handle at raster ``(px, py)``:: + + cx, cy = geometry.px_to_cm(px, py) + cal = geometry.axes_at_px(px, py) # which axes was it dropped in? + if cal is not None: + x, y = cal.cm_to_data(cx, cy) # cm -> data (may be None on log clamp) + +Log-axis edge case +------------------ +On a log axis the mapping is affine in ``log10(value)``, which is undefined for +non-positive values. A log axis whose *range* has a non-positive bound cannot be +calibrated at all, so :func:`parse_calibration_lines` rejects such an axes up +front (see :meth:`AxesCalibration.is_valid`); the range bounds fed to the +interpolation are therefore always strictly positive on a log axis. + +Only *point inputs* to :meth:`AxesCalibration.data_to_cm` are still clamped: a +caller may ask for the cm position of a data point that happens to be +non-positive on a log axis (e.g. an annotation the user dragged to the edge), +and clamping it to a tiny positive epsilon keeps the handle pinned at the axis +edge instead of vanishing to NaN. :meth:`AxesCalibration.cm_to_data` can always +produce a valid positive value; the inverse never yields non-positive data on a +log axis, so it returns finite floats. Callers that need to reject out-of-range +results should use :meth:`AxesCalibration.contains_cm`. +""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass, field +from typing import List, Optional, Sequence, Tuple + +__all__ = [ + "AxesCalibration", + "PreviewGeometry", + "parse_calibration_lines", + "CM_PER_INCH", +] + +#: Centimetres per inch (exact). Mirrors ``parser.units.INCH_TO_CM`` but kept +#: local so this Qt-free module has no cross-package import for a constant. +CM_PER_INCH = 2.54 + +#: Smallest positive value substituted for a non-positive coordinate on a log +#: axis before taking ``log10`` (see module docstring, "Log-axis edge case"). +_LOG_EPS = 1e-300 + + +def _to_log_space(value: float, is_log: bool, *, clamp: bool = False) -> float: + """Map ``value`` into the space the axis is affine in. + + Linear axis -> value unchanged. Log axis -> ``log10(value)``. + + ``clamp`` controls the non-positive-input case, which only arises for *point + inputs* to :meth:`AxesCalibration.data_to_cm` (a valid axes' *range* bounds + are guaranteed positive on a log axis by :func:`parse_calibration_lines`, so + they are mapped with ``clamp=False``). When ``clamp`` is true a non-positive + value is pinned to :data:`_LOG_EPS` so the result stays finite (a handle + dragged past the axis edge sticks at the edge instead of vanishing to NaN). + """ + if not is_log: + return value + if clamp and value <= 0.0: + value = _LOG_EPS + return math.log10(value) + + +def _from_log_space(value: float, is_log: bool) -> float: + """Inverse of :func:`_to_log_space`.""" + if not is_log: + return value + return 10.0 ** value + + +@dataclass +class AxesCalibration: + """Calibration for a single graph block: ``data <-> page cm``. + + Attributes + ---------- + index: + Position of this axes in the figure's ``axes_list`` (== graph-block + order == the ```` printed in the ``gleplot-cal`` line). + x_range, y_range: + ``(min, max)`` data-coordinate ranges of the axes, as reported by GLE + (``xgmin``/``xgmax`` and ``ygmin``/``ygmax``). + x_log, y_log: + Whether the corresponding axis is logarithmic. When true, the ``data + <-> cm`` mapping is affine in ``log10`` space for that axis. + cm_rect: + ``(x0, y0, x1, y1)`` page-cm coordinates of the axes box: ``(x0, y0)`` + is the corner at ``(x_range[0], y_range[0])`` and ``(x1, y1)`` the + corner at ``(x_range[1], y_range[1])``. In GLE page cm the origin is the + bottom-left of the page and y increases upward, so typically + ``y0 < y1``. + """ + + index: int + x_range: Tuple[float, float] + y_range: Tuple[float, float] + x_log: bool + y_log: bool + cm_rect: Tuple[float, float, float, float] + + # -- data -> cm --------------------------------------------------------- + def data_to_cm(self, x: float, y: float) -> Tuple[float, float]: + """Map data coordinates ``(x, y)`` to page cm. + + Affine in linear space, affine in ``log10`` space on a log axis. A + non-positive value on a log axis is clamped to a tiny positive epsilon + (see module docstring) so the result is always a finite cm pair. + """ + cx = self._interp( + x, self.x_range, (self.cm_rect[0], self.cm_rect[2]), self.x_log + ) + cy = self._interp( + y, self.y_range, (self.cm_rect[1], self.cm_rect[3]), self.y_log + ) + return cx, cy + + # -- cm -> data --------------------------------------------------------- + def cm_to_data(self, cx: float, cy: float) -> Tuple[float, float]: + """Map page-cm coordinates back to data coordinates. + + Exact inverse of :meth:`data_to_cm` for in-range inputs. On a log axis + the result is always a positive finite value (never non-positive), so + this method does not return ``None``; use :meth:`contains_cm` to test + whether the point lies inside the axes box. + """ + x = self._interp_inv( + cx, (self.cm_rect[0], self.cm_rect[2]), self.x_range, self.x_log + ) + y = self._interp_inv( + cy, (self.cm_rect[1], self.cm_rect[3]), self.y_range, self.y_log + ) + return x, y + + def contains_cm(self, cx: float, cy: float) -> bool: + """True if page-cm point ``(cx, cy)`` lies within this axes box. + + Tolerant of either corner ordering (GLE's y grows upward, but this does + not assume ``y0 < y1``). + """ + x0, y0, x1, y1 = self.cm_rect + xlo, xhi = (x0, x1) if x0 <= x1 else (x1, x0) + ylo, yhi = (y0, y1) if y0 <= y1 else (y1, y0) + return xlo <= cx <= xhi and ylo <= cy <= yhi + + def invalid_reason(self) -> Optional[str]: + """Return why this calibration is unusable, or ``None`` if it is valid. + + A calibration is *invalid* (and produces corrupted math if used) when: + + * a log axis has a non-positive range bound (``log10`` is undefined, so + the affine-in-log-space mapping cannot be built); + * either axis has a degenerate range (``min == max``), which collapses + the whole axis to a single cm edge and makes ``cm_to_data`` ambiguous; + * the cm rectangle is degenerate (zero width or height), which makes the + inverse ``cm -> data`` mapping divide by zero. + + :func:`parse_calibration_lines` calls this to skip a bad axes with a + warning rather than silently emitting NaN/garbage coordinates. + """ + x0d, x1d = self.x_range + y0d, y1d = self.y_range + if self.x_log and (x0d <= 0.0 or x1d <= 0.0): + return "x is a log axis but its range has a non-positive bound" + if self.y_log and (y0d <= 0.0 or y1d <= 0.0): + return "y is a log axis but its range has a non-positive bound" + if x0d == x1d: + return "x range is degenerate (min == max)" + if y0d == y1d: + return "y range is degenerate (min == max)" + cx0, cy0, cx1, cy1 = self.cm_rect + if cx0 == cx1: + return "cm rectangle has zero width" + if cy0 == cy1: + return "cm rectangle has zero height" + return None + + def is_valid(self) -> bool: + """True if this calibration can be used without corrupted math. + + Convenience wrapper over :meth:`invalid_reason`. + """ + return self.invalid_reason() is None + + # -- internals ---------------------------------------------------------- + @staticmethod + def _interp( + value: float, + data_range: Tuple[float, float], + cm_range: Tuple[float, float], + is_log: bool, + ) -> float: + d0 = _to_log_space(data_range[0], is_log) + d1 = _to_log_space(data_range[1], is_log) + # ``value`` is a point input (an annotation coord), which may legitimately + # be non-positive on a log axis (dragged past the edge): clamp it so the + # result is finite. The range bounds are validated positive up front. + v = _to_log_space(value, is_log, clamp=True) + span = d1 - d0 + if span == 0.0: + # Degenerate axis range: collapse to the low cm edge. + return cm_range[0] + frac = (v - d0) / span + return cm_range[0] + frac * (cm_range[1] - cm_range[0]) + + @staticmethod + def _interp_inv( + cm: float, + cm_range: Tuple[float, float], + data_range: Tuple[float, float], + is_log: bool, + ) -> float: + c_span = cm_range[1] - cm_range[0] + if c_span == 0.0: + return data_range[0] + frac = (cm - cm_range[0]) / c_span + d0 = _to_log_space(data_range[0], is_log) + d1 = _to_log_space(data_range[1], is_log) + v = d0 + frac * (d1 - d0) + return _from_log_space(v, is_log) + + +@dataclass +class PreviewGeometry: + """Full preview geometry: page size, raster DPI, and per-axes calibration. + + Attributes + ---------- + page_size_cm: + ``(width, height)`` of the GLE page in cm (from the figure ``figsize`` + via :func:`gleplot.parser.units.inches_to_cm`). + dpi: + Resolution the raster was rendered at. Drives the ``cm <-> px`` scale. + axes: + Per-graph-block :class:`AxesCalibration`, in ``axes_list`` order. + """ + + page_size_cm: Tuple[float, float] + dpi: int + axes: List[AxesCalibration] = field(default_factory=list) + + # -- cm <-> px for the raster (renderer-specific mapping) ---------------- + def cm_to_px(self, cx: float, cy: float) -> Tuple[float, float]: + """Map page-cm to raster pixel coordinates. + + ``px = cx * dpi / 2.54``. The raster's origin is the *top* left with y + growing downward, whereas GLE page cm grows upward, so the y axis is + flipped: ``py = (page_height - cy) * dpi / 2.54``. + """ + scale = self.dpi / CM_PER_INCH + px = cx * scale + py = (self.page_size_cm[1] - cy) * scale + return px, py + + def px_to_cm(self, px: float, py: float) -> Tuple[float, float]: + """Inverse of :meth:`cm_to_px` (undoes the raster Y flip).""" + scale = self.dpi / CM_PER_INCH + cx = px / scale + cy = self.page_size_cm[1] - py / scale + return cx, cy + + def axes_at_px(self, px: float, py: float) -> Optional[AxesCalibration]: + """Return the axes whose box contains raster point ``(px, py)``. + + Converts to cm first, then tests each calibration's box. Returns the + first (lowest-index) match, or ``None`` if the point is outside every + axes box. + """ + cx, cy = self.px_to_cm(px, py) + for cal in self.axes: + if cal.contains_cm(cx, cy): + return cal + return None + + +# ------------------------------------------------------------------------- # +# Parsing GLE calibration output +# ------------------------------------------------------------------------- # + +#: Matches one ``gleplot-cal <9 numbers>`` record, whitespace-tolerant. +#: GLE inserts variable spacing between fields, so we split on whitespace rather +#: than fixed columns. The leading token is matched loosely so the record can be +#: embedded in a longer stderr line. +_CAL_RE = re.compile(r"gleplot-cal\b(.*)") + +_NUM_RE = re.compile( + r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?" +) + + +def parse_calibration_lines( + text: str, + axes_meta: Sequence[Tuple[bool, bool]], +) -> Tuple[List[AxesCalibration], List[str]]: + """Scan GLE output for ``gleplot-cal`` records into calibrations. + + Parameters + ---------- + text: + Combined stdout + stderr of the GLE compile. ``gleplot-cal`` lines may + appear on either stream (they are on stderr for GLE 4.3.3) and carry + GLE-inserted variable whitespace between fields. + axes_meta: + Per-axes ``(x_log, y_log)`` flags in ``axes_list`` order, taken from the + model snapshot. Used to tag each calibration with its log flags (GLE's + printed ranges do not encode which axis is logarithmic). The record's + ```` indexes into this sequence. + + Returns + ------- + (calibrations, warnings): + ``calibrations`` is the list of successfully parsed + :class:`AxesCalibration`, sorted by ``index``. ``warnings`` is a list of + human-readable strings describing skipped/malformed records; parsing is + tolerant and never raises. + """ + calibrations: List[AxesCalibration] = [] + warnings: List[str] = [] + seen_indices = set() + + for raw_line in text.splitlines(): + m = _CAL_RE.search(raw_line) + if m is None: + continue + tail = m.group(1) + nums = _NUM_RE.findall(tail) + # Expected fields: idx + 4 data-range values (xgmin xgmax ygmin ygmax) + # + 4 cm-corner values (xg(xgmin) yg(ygmin) xg(xgmax) yg(ygmax)) = 9. + if len(nums) < 9: + warnings.append( + f"malformed gleplot-cal record (expected 9 fields, got " + f"{len(nums)}): {raw_line.strip()!r}" + ) + continue + try: + idx = int(float(nums[0])) + xgmin, xgmax, ygmin, ygmax = (float(nums[i]) for i in range(1, 5)) + cx0, cy0, cx1, cy1 = (float(nums[i]) for i in range(5, 9)) + except (ValueError, IndexError): + warnings.append( + f"unparseable gleplot-cal record: {raw_line.strip()!r}" + ) + continue + + if idx in seen_indices: + warnings.append(f"duplicate gleplot-cal index {idx}; keeping first") + continue + + if idx < 0 or idx >= len(axes_meta): + warnings.append( + f"gleplot-cal index {idx} out of range for {len(axes_meta)} " + f"axes; skipped" + ) + continue + + x_log, y_log = axes_meta[idx] + cal = AxesCalibration( + index=idx, + x_range=(xgmin, xgmax), + y_range=(ygmin, ygmax), + x_log=bool(x_log), + y_log=bool(y_log), + cm_rect=(cx0, cy0, cx1, cy1), + ) + reason = cal.invalid_reason() + if reason is not None: + # Mark the index seen so it is not also flagged "missing" below, but + # skip it: an invalid calibration would emit corrupted coordinates. + # The overlay simply has no items for this axes. + seen_indices.add(idx) + warnings.append( + f"gleplot-cal index {idx} skipped ({reason}): {raw_line.strip()!r}" + ) + continue + seen_indices.add(idx) + calibrations.append(cal) + + calibrations.sort(key=lambda c: c.index) + + # Warn about any axes we expected a calibration for but never saw. + for i in range(len(axes_meta)): + if i not in seen_indices: + warnings.append(f"missing gleplot-cal record for axes index {i}") + + return calibrations, warnings diff --git a/src/gleplot/gui/main_window.py b/src/gleplot/gui/main_window.py index 12e23fc..df161a8 100644 --- a/src/gleplot/gui/main_window.py +++ b/src/gleplot/gui/main_window.py @@ -53,6 +53,7 @@ from gleplot import compiler from gleplot.compiler import GLEError from gleplot.gui import file_ops, gle_viewer +from gleplot.gui.annotations import AnnotationOverlay from gleplot.gui.data.panel import DataPanel from gleplot.gui.document import FigureDocument from gleplot.gui.error_panel import ErrorPanel @@ -64,6 +65,7 @@ LayoutPanel, RawGlePanel, SeriesPanel, + TextsPanel, ) from gleplot.gui.preview import PreviewController, PreviewView from gleplot.gui.undo import UndoStack @@ -124,7 +126,7 @@ class MainWindow(QMainWindow): Debounced async GLE render engine driving ``preview_view``. data_panel : DataPanel Data-loading / series-creation panel (Data dock). - figure_panel, axes_panel, series_panel : QWidget + figure_panel, axes_panel, series_panel, texts_panel : QWidget Property panels hosted in the Properties dock's tab widget. error_panel : ErrorPanel Compile-error list (Output dock). @@ -192,6 +194,14 @@ def _create_central_widget(self) -> None: self.preview_controller = PreviewController(self.document, parent=self) + # Interactive annotation overlay (Track F1): draggable/editable text + # annotations on the live preview. Coupling is explicit -- the overlay + # exposes public slots the window connects to the controller's geometry + # and render signals (see _connect_preview_signals). + self.annotation_overlay = AnnotationOverlay( + self.document, self.preview_view, parent=self + ) + # ------------------------------------------------------------------ # Dock widgets # ------------------------------------------------------------------ @@ -213,11 +223,13 @@ def _create_docks(self) -> None: self.figure_panel = FigurePanel(self.document) self.axes_panel = AxesPanel(self.document) self.series_panel = SeriesPanel(self.document) + self.texts_panel = TextsPanel(self.document) self.raw_gle_panel = RawGlePanel(self.document) self.properties_tabs.addTab(self.layout_panel, "Layout") self.properties_tabs.addTab(self.figure_panel, "Figure") self.properties_tabs.addTab(self.axes_panel, "Axes") self.properties_tabs.addTab(self.series_panel, "Series") + self.properties_tabs.addTab(self.texts_panel, "Texts") self.properties_tabs.addTab(self.raw_gle_panel, "Raw GLE") self.properties_dock.setWidget(self.properties_tabs) self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.properties_dock) @@ -276,6 +288,14 @@ def _create_actions(self) -> None: self.action_redo.setEnabled(False) self.action_redo.triggered.connect(self._on_redo) + # Add text annotation: arms the overlay so the next preview click + # places a new text. Enabled only while the overlay is active (valid + # calibration geometry + a rendered image, i.e. document mode). + self.action_add_text = QAction("Add &text annotation", self) + self.action_add_text.setShortcut(QKeySequence("T")) + self.action_add_text.setEnabled(False) + self.action_add_text.triggered.connect(self._on_add_text_annotation) + # View menu (preview zoom + dock toggles) self.action_fit_window = QAction("&Fit to window", self) self.action_fit_window.setShortcut(QKeySequence("Ctrl+0")) @@ -285,6 +305,18 @@ def _create_actions(self) -> None: self.action_actual_size.setShortcut(QKeySequence("Ctrl+1")) self.action_actual_size.triggered.connect(self.preview_view.zoom_actual_size) + self.action_vector_preview = QAction("&Vector preview (SVG)", self) + self.action_vector_preview.setCheckable(True) + self.action_vector_preview.setChecked( + self.preview_controller.render_format == "svg" + ) + self.action_vector_preview.setEnabled(self.preview_controller.svg_available) + if not self.preview_controller.svg_available: + self.action_vector_preview.setToolTip( + "SVG preview is unavailable in this session; showing PNG." + ) + self.action_vector_preview.toggled.connect(self._on_toggle_vector_preview) + self.action_toggle_data = self.data_dock.toggleViewAction() self.action_toggle_properties = self.properties_dock.toggleViewAction() self.action_toggle_output = self.output_dock.toggleViewAction() @@ -319,11 +351,15 @@ def _create_menus(self) -> None: edit_menu = menu_bar.addMenu("&Edit") edit_menu.addAction(self.action_undo) edit_menu.addAction(self.action_redo) + edit_menu.addSeparator() + edit_menu.addAction(self.action_add_text) view_menu = menu_bar.addMenu("&View") view_menu.addAction(self.action_fit_window) view_menu.addAction(self.action_actual_size) view_menu.addSeparator() + view_menu.addAction(self.action_vector_preview) + view_menu.addSeparator() view_menu.addAction(self.action_toggle_data) view_menu.addAction(self.action_toggle_properties) view_menu.addAction(self.action_toggle_output) @@ -358,9 +394,10 @@ def _connect_document_signals(self) -> None: # entering/leaving GLE-preview mode. Undo/redo then preserves zoom via # PreviewView's same-size fast path. self.data_panel.series_added.connect(self._on_series_added) - # Layout tab drives which axes the Axes/Series property panels edit. + # Layout tab drives which axes the Axes/Series/Texts property panels edit. self.layout_panel.axes_selected.connect(self.axes_panel.set_axes) self.layout_panel.axes_selected.connect(self.series_panel.set_axes) + self.layout_panel.axes_selected.connect(self.texts_panel.set_axes) # A broken series repointed at a real data file: confirm via status bar. self.series_panel.series_repointed.connect(self._on_series_repointed) @@ -370,15 +407,70 @@ def _connect_preview_signals(self) -> None: pc.render_succeeded.connect(self._on_render_succeeded) pc.render_failed.connect(self._on_render_failed) pc.render_skipped_empty.connect(self._on_render_skipped_empty) + pc.geometry_ready.connect(self.preview_view.set_geometry) + pc.fallback_activated.connect(self._on_svg_fallback_activated) + + # Annotation overlay: geometry_ready installs calibration BEFORE the + # image lands (controller contract), then render_succeeded rebuilds the + # overlay items on the fresh render. Both connections are kept explicit. + pc.geometry_ready.connect(self.annotation_overlay.set_geometry) + pc.render_succeeded.connect(self.annotation_overlay.on_render_succeeded) + # figure_replaced (New/Open/undo/redo) can land mid-drag/mid-edit: the + # new figure orphans every text_dict, so the overlay aborts any active + # interaction and clears items, pending the follow-up render's rebuild + # (Finding 3 -- no phantom mid-drag item survives an undo). + self.document.figure_replaced.connect( + self.annotation_overlay.on_figure_replaced + ) + self.annotation_overlay.overlay_enabled_changed.connect( + self._on_overlay_enabled_changed + ) + self.annotation_overlay.add_text_placed.connect( + lambda: self.statusBar().showMessage("Text annotation added", _STATUS_MS) + ) + + # Texts panel <-> annotation overlay selection sync (F1/F2 contract). + # Both directions use each side's no-emit programmatic path + # (texts_panel.select_text / annotation_overlay.select_annotation), so + # this can never loop: a panel-driven overlay selection does not + # re-emit selection_changed, and an overlay-driven panel selection + # does not re-emit text_selected. + self.texts_panel.text_selected.connect(self._on_texts_panel_selected) + self.annotation_overlay.selection_changed.connect( + self._on_overlay_selection_changed + ) def _connect_undo_signals(self) -> None: us = self.undo_stack - us.can_undo_changed.connect(self.action_undo.setEnabled) - us.can_redo_changed.connect(self.action_redo.setEnabled) + # Route through mode-aware slots rather than wiring + # can_undo_changed/can_redo_changed straight to action.setEnabled: + # while in GLE-preview mode the Undo/Redo actions must stay disabled + # regardless of the (still-live) document undo stack's transitions + # (Finding 2). _update_gle_mode_actions remains the single authority for + # the enabled state; these slots simply defer to it in preview mode. + us.can_undo_changed.connect(self._on_can_undo_changed) + us.can_redo_changed.connect(self._on_can_redo_changed) # Seed initial enabled state (signals only fire on transitions). self.action_undo.setEnabled(us.can_undo) self.action_redo.setEnabled(us.can_redo) + def _on_can_undo_changed(self, can_undo: bool) -> None: + """Undo-availability changed on the document stack (Finding 2). + + Honoured only in document mode; in GLE-preview mode the action stays + disabled (the stack keeps ticking underneath, but Undo is meaningless + for a read-only ``.gle`` preview). + """ + if self.is_gle_preview_mode: + return + self.action_undo.setEnabled(can_undo) + + def _on_can_redo_changed(self, can_redo: bool) -> None: + """Redo-availability changed on the document stack (Finding 2).""" + if self.is_gle_preview_mode: + return + self.action_redo.setEnabled(can_redo) + # ------------------------------------------------------------------ # Preview slots # ------------------------------------------------------------------ @@ -405,6 +497,129 @@ def _on_render_skipped_empty(self) -> None: self.error_panel.clear() self.preview_view.show_placeholder(_EMPTY_PREVIEW_TEXT) + def _on_toggle_vector_preview(self, checked: bool) -> None: + """View ▸ Vector preview (SVG): switch ``render_format`` on toggle.""" + self.preview_controller.render_format = "svg" if checked else "png" + + def _on_svg_fallback_activated(self, reason: str) -> None: + """SVG rendering failed permanently this session; reflect it in the UI. + + Unchecks and disables the toggle (with a tooltip explaining why) and + shows a status-bar message. The controller has already scheduled an + automatic PNG re-render, so no further action is needed here. + """ + self.action_vector_preview.blockSignals(True) + self.action_vector_preview.setChecked(False) + self.action_vector_preview.blockSignals(False) + self.action_vector_preview.setEnabled(False) + self.action_vector_preview.setToolTip( + f"SVG preview disabled for this session: {reason}" + ) + self.statusBar().showMessage( + "Vector preview unavailable; showing PNG instead.", _STATUS_MS + ) + + # ------------------------------------------------------------------ + # Annotation overlay slots + # ------------------------------------------------------------------ + def _on_overlay_enabled_changed(self, enabled: bool) -> None: + """Enable/disable the Add-text action with the overlay's availability. + + The overlay is enabled only when a valid calibration geometry and view + mapping exist -- i.e. document mode with a successful render. It is + automatically disabled in GLE-preview mode (no document renders occur + there, so ``geometry_ready`` never fires with a geometry). + """ + self.action_add_text.setEnabled(enabled) + if not enabled and self.annotation_overlay.add_mode: + self._cancel_add_text_mode() + + def _on_add_text_annotation(self) -> None: + """Edit ▸ Add text annotation: arm the next-click placement.""" + if not self.annotation_overlay.enabled: + return + self.annotation_overlay.begin_add_text() + self.statusBar().showMessage( + "Click on the plot to place text — Esc to cancel", 0 + ) + + def _cancel_add_text_mode(self) -> None: + """Cancel add-text mode and clear its status hint.""" + if self.annotation_overlay.add_mode: + self.annotation_overlay.cancel_add_text() + self.statusBar().clearMessage() + + # ------------------------------------------------------------------ + # Texts panel <-> annotation overlay selection sync + # ------------------------------------------------------------------ + def _on_texts_panel_selected(self, index: int) -> None: + """User selected a row in the Texts panel: highlight it on canvas. + + Resolves ``index`` to the dict on the panel's *current target* axes + (``texts_panel.current_axes()``) and forwards it to the overlay via + the no-emit :meth:`AnnotationOverlay.select_annotation` path -- this + is a panel-driven selection, so it must not bounce back through + :data:`AnnotationOverlay.selection_changed`. + """ + ax = self.texts_panel.current_axes() + if ax is None: + return + texts = list(getattr(ax, "texts", []) or []) + text_dict = texts[index] if 0 <= index < len(texts) else None + self.annotation_overlay.select_annotation(text_dict) + + def _on_overlay_selection_changed(self, text_dict: Optional[dict]) -> None: + """User selected/deselected an item on canvas: reflect it in the panel. + + Finds ``text_dict``'s owning axes and index within that axes' texts. + If the owning axes differs from the panel's current target, retarget + the panel first (``set_axes``) so cross-axes canvas selection follows + the click to whichever axes it belongs to; then select the row via + the no-emit :meth:`TextsPanel.select_text` path (this is an + overlay-driven selection, so it must not bounce back through + :data:`TextsPanel.text_selected`). + + ``text_dict is None`` (selection cleared) deselects in the panel + without retargeting it. + """ + if text_dict is None: + self.texts_panel.select_text(-1) + return + + owning_ax, index = self._find_text_owner(text_dict) + if owning_ax is None: + return + + if self.texts_panel.current_axes() is not owning_ax: + self.texts_panel.set_axes(owning_ax) + self.texts_panel.select_text(index) + + def _find_text_owner(self, text_dict: dict): + """Return ``(axes, index)`` of the axes whose ``texts`` list contains + ``text_dict`` by identity, or ``(None, -1)`` if not found (e.g. the + figure has since changed underneath the overlay). + """ + figure = self.document.figure + if figure is None: + return None, -1 + for ax in list(getattr(figure, "axes_list", []) or []): + texts = getattr(ax, "texts", None) or [] + for i, td in enumerate(texts): + if td is text_dict: + return ax, i + return None, -1 + + def keyPressEvent(self, event) -> None: # noqa: N802 - Qt override + """Esc cancels an armed add-text mode; otherwise defer to the base.""" + if ( + event.key() == Qt.Key.Key_Escape + and self.annotation_overlay.add_mode + ): + self._cancel_add_text_mode() + event.accept() + return + super().keyPressEvent(event) + # ------------------------------------------------------------------ # Document slots # ------------------------------------------------------------------ @@ -695,6 +910,13 @@ def _enter_gle_preview_mode(self, gle_path: Path) -> None: self._cleanup_gle_temp_dirs() self._gle_preview_path = gle_path self._set_document_widgets_enabled(False) + # Hard-disable the annotation overlay before the read-only image lands + # (Finding 1): the preview is a static compile of a *file*, not the + # document, but overlay items from the previous document render are + # still live in the scene. Disabling clears them, cancels add-mode, and + # makes every commit path a no-op so a stray drag/click cannot mutate + # the hidden document figure while it is not on screen. + self.annotation_overlay.set_disabled(True) self._update_gle_mode_actions() QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) @@ -740,6 +962,12 @@ def _leave_gle_preview_mode(self) -> None: self._gle_preview_path = None self._cleanup_gle_temp_dirs() self._set_document_widgets_enabled(True) + # Lift the overlay's hard-disabled state (Finding 1). It stays cleared + # (no items) until the next *document* render rebuilds it via + # geometry_ready/render_succeeded -- which the caller's figure_replaced + # re-render triggers. set_geometry would also lift this, but doing it + # here makes the mode transition authoritative even if no render follows. + self.annotation_overlay.set_disabled(False) self._update_gle_mode_actions() self.statusBar().clearMessage() self._update_window_title() diff --git a/src/gleplot/gui/panels/__init__.py b/src/gleplot/gui/panels/__init__.py index bfd041e..2fa0c94 100644 --- a/src/gleplot/gui/panels/__init__.py +++ b/src/gleplot/gui/panels/__init__.py @@ -12,5 +12,6 @@ from .layout_panel import LayoutPanel from .raw_gle_panel import RawGlePanel from .series_panel import SeriesPanel +from .texts_panel import TextsPanel -__all__ = ["FigurePanel", "AxesPanel", "SeriesPanel", "LayoutPanel", "RawGlePanel"] +__all__ = ["FigurePanel", "AxesPanel", "SeriesPanel", "LayoutPanel", "RawGlePanel", "TextsPanel"] diff --git a/src/gleplot/gui/panels/texts_panel.py b/src/gleplot/gui/panels/texts_panel.py new file mode 100644 index 0000000..d1ebbfa --- /dev/null +++ b/src/gleplot/gui/panels/texts_panel.py @@ -0,0 +1,684 @@ +"""Text-annotation property panel. + +:class:`TextsPanel` shows the list of free-form text annotations +(``ax.texts``) on the current axes and an editor for whichever annotation is +selected. It is the property-panel half of Track F2; a parallel track +(``gleplot/gui/annotations.py``) builds an on-canvas drag overlay for the +same data. Integration (selection sync between the two) happens in a later +step -- see the "Integration notes" in this module's tests / the track +report for how ``select_text``/``current_index``/``text_selected`` are meant +to be wired to the overlay. + +Stored-representation conventions mirrored here (see ``gleplot/axes.py``, +``Axes.text``, ~line 719 at the time of writing): + +- Each entry in ``ax.texts`` is a plain dict with keys ``x``, ``y``, ``text``, + ``color``, ``fontsize``, ``ha``, ``va``, ``box_color``, all in **data + coordinates** for x/y. +- ``color``: GLE color name string, or ``None`` meaning "inherit default" + (rendered/written as ``'BLACK'`` -- see ``Axes.text`` and + ``GLEWriter.add_text``). This panel always writes an explicit GLE color + name (never re-introduces ``None``), matching how the color button works + in ``series_panel.py``: once a color is picked, it's stored explicitly. +- ``fontsize``: ``float`` points, or ``None`` meaning "inherit the default + text size". The editor exposes this as a "Custom size" checkbox + a + ``QDoubleSpinBox``; unchecking writes ``None`` back. +- ``ha``: one of ``'left'``, ``'center'``, ``'right'`` (``GLEWriter.add_text`` + maps anything else to ``'left'``). +- ``va`` and ``box_color`` are stored on the dict (and, in ``box_color``'s + case, round-tripped through ``rgb_to_gle`` by ``Axes.text``'s ``bbox`` + argument) but **neither has any effect on the GLE output**: + ``figure.py``'s render loop never passes ``va`` to + ``GLEWriter.add_text`` at all, and the writer explicitly ignores the + ``box_color`` parameter it does receive (see the comment by + ``_ = box_color`` in ``writer.py``). This panel displays both, but the + controls are disabled and carry a tooltip noting they are not rendered by + GLE output, so no in-place mutation path is wired for them. + +Mutations follow the standard panel pattern (see ``axes_panel.py`` / +``series_panel.py``): edit the dict in place, then +``document.notify_changed()``. +""" + +from __future__ import annotations + +import math +from typing import Optional + +from PySide6.QtCore import Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QCheckBox, + QColorDialog, + QComboBox, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLineEdit, + QListWidget, + QListWidgetItem, + QPlainTextEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + +from gleplot.colors import rgb_to_gle + +#: Best-effort inverse of the GLE named-color table, copied from +#: series_panel.py so the color dialog can show a representative swatch. +#: colors.py has no official GLE-name -> RGB inverse; unmapped names +#: (unlikely, since only this fixed palette is ever stored) fall back to +#: black. +_GLE_COLOR_TO_RGB = { + "BLUE": (0, 0, 255), + "RED": (255, 0, 0), + "GREEN": (0, 128, 0), + "CYAN": (0, 255, 255), + "MAGENTA": (255, 0, 255), + "YELLOW": (255, 255, 0), + "BLACK": (0, 0, 0), + "WHITE": (255, 255, 255), + "ORANGE": (255, 165, 0), + "PURPLE": (128, 0, 128), + "BROWN": (165, 42, 42), + "PINK": (255, 192, 203), + "GRAY": (128, 128, 128), + "LIGHTBLUE": (173, 216, 230), + "LIGHTGREEN": (144, 238, 144), + "LIGHTCYAN": (224, 255, 255), + "LIGHTGRAY": (211, 211, 211), + "DARKBLUE": (0, 0, 139), + "DARKGREEN": (0, 100, 0), + "DARKRED": (139, 0, 0), + "DARKGRAY": (169, 169, 169), +} + +#: Horizontal-alignment values accepted by Axes.text/GLEWriter.add_text. +_HA_VALUES = ("left", "center", "right") + +#: Vertical-alignment values Axes.text accepts for API compatibility. Stored +#: but never emitted by the writer -- see module docstring. +_VA_VALUES = ("top", "center", "bottom") + +#: Tooltip used on the va combo and box_color swatch: both are stored on the +#: text dict but have no effect on the GLE output. +_NO_EFFECT_TOOLTIP = "Stored but not rendered by GLE output." + +#: fontsize spin box range, in points. +_FONTSIZE_MIN = 4.0 +_FONTSIZE_MAX = 72.0 +_FONTSIZE_DEFAULT = 12.0 + +#: Number of characters of the annotation text shown in the list entry +#: before truncation (plus an ellipsis). +_LIST_TEXT_PREVIEW_LEN = 30 + + +def _preview_text(text: str) -> str: + text = text or "" + # Collapse embedded newlines for the one-line list preview. + flat = " ".join(text.splitlines()) + if len(flat) > _LIST_TEXT_PREVIEW_LEN: + return flat[:_LIST_TEXT_PREVIEW_LEN].rstrip() + "..." + return flat + + +def _format_coord(value) -> str: + try: + return f"{float(value):.3g}" + except (TypeError, ValueError): + return "?" + + +def _format_entry(entry: dict) -> str: + preview = _preview_text(entry.get("text", "")) or "(empty)" + x = _format_coord(entry.get("x")) + y = _format_coord(entry.get("y")) + return f"{preview} — ({x}, {y})" + + +class TextsPanel(QWidget): + """Annotation list + editor for the text annotations of the current axes. + + Parameters + ---------- + document + Duck-typed document exposing ``figure``, ``figure_changed``/ + ``figure_replaced`` signals, and ``notify_changed()``. + + Notes + ----- + Integration hook for the on-canvas drag overlay (built in parallel in + ``gleplot/gui/annotations.py``): external code can call + :meth:`select_text` to programmatically select an annotation (e.g. after + a canvas click) without triggering :data:`text_selected`, and can read + :attr:`current_index` to find out what's currently selected. When the + *user* changes the list selection (click, arrow keys, ...), + :data:`text_selected` fires so the overlay can highlight the + corresponding on-canvas handle. + + Signals + ------- + text_selected(int) + Emitted with the newly-selected row index when the user changes the + list selection. Not emitted for programmatic selection changes + (:meth:`select_text`, :meth:`refresh`, ``set_axes``). + """ + + text_selected = Signal(int) + + def __init__(self, document, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._document = document + self._updating = False + self._axes = None # explicit override, mirrors AxesPanel.set_axes + self._current_color_rgb = (0, 0, 0) + + self._build_ui() + self._connect_signals() + self.refresh() + + # ------------------------------------------------------------------ + # Axes targeting (mirrors AxesPanel/SeriesPanel) + # ------------------------------------------------------------------ + def set_axes(self, ax) -> None: + """Target a specific axes instead of the figure's current axes.""" + self._axes = ax + self.refresh() + + def _current_axes(self): + if self._axes is not None: + return self._axes + figure = self._document.figure + if figure is None: + return None + return figure.gca() + + def current_axes(self): + """Public accessor for the axes this panel currently targets. + + Sync hook for the annotation overlay (``gleplot/gui/annotations.py``): + used to decide whether a selected ``text_dict`` belongs to this + panel's target axes, or whether the panel needs retargeting first via + :meth:`set_axes`. + """ + return self._current_axes() + + # ------------------------------------------------------------------ + # Public selection API (sync hook for the on-canvas overlay) + # ------------------------------------------------------------------ + @property + def current_index(self) -> int: + """Row index of the currently-selected annotation, or -1 if none.""" + return self.text_list.currentRow() + + def select_text(self, index: int) -> None: + """Programmatically select annotation ``index``. + + Does not emit :data:`text_selected` (guarded), since this is meant + to be called *in response to* an external selection (e.g. a canvas + click on the drag overlay), not as a user action against this panel. + """ + was_updating = self._updating + self._updating = True + try: + self.text_list.setCurrentRow(index) + self._populate_editor(self._selected_entry()) + finally: + self._updating = was_updating + + # ------------------------------------------------------------------ + # UI construction + # ------------------------------------------------------------------ + def _build_ui(self) -> None: + outer = QVBoxLayout(self) + + self.text_list = QListWidget(self) + outer.addWidget(self.text_list) + + buttons_row = QHBoxLayout() + self.add_button = QPushButton("Add", self) + self.remove_button = QPushButton("Remove", self) + buttons_row.addWidget(self.add_button) + buttons_row.addWidget(self.remove_button) + outer.addLayout(buttons_row) + + form = QFormLayout() + + self.text_edit = QPlainTextEdit(self) + self.text_edit.setMaximumHeight(60) + self.text_edit.setTabChangesFocus(True) + + self.x_edit = QLineEdit(self) + self.y_edit = QLineEdit(self) + + color_row = QHBoxLayout() + self.color_button = QPushButton(self) + self.color_button.setFixedWidth(60) + color_row.addWidget(self.color_button) + color_row.addStretch(1) + + size_row = QHBoxLayout() + self.custom_size_check = QCheckBox("Custom size", self) + self.fontsize_spin = QDoubleSpinBox(self) + self.fontsize_spin.setRange(_FONTSIZE_MIN, _FONTSIZE_MAX) + self.fontsize_spin.setDecimals(1) + self.fontsize_spin.setSingleStep(1.0) + self.fontsize_spin.setSuffix(" pt") + size_row.addWidget(self.custom_size_check) + size_row.addWidget(self.fontsize_spin) + size_row.addStretch(1) + + self.ha_combo = QComboBox(self) + self.ha_combo.addItems(_HA_VALUES) + + self.va_combo = QComboBox(self) + self.va_combo.addItems(_VA_VALUES) + self.va_combo.setEnabled(False) + self.va_combo.setToolTip(_NO_EFFECT_TOOLTIP) + + self.box_color_button = QPushButton(self) + self.box_color_button.setFixedWidth(60) + self.box_color_button.setEnabled(False) + self.box_color_button.setToolTip(_NO_EFFECT_TOOLTIP) + + form.addRow("Text", self.text_edit) + form.addRow("X", self.x_edit) + form.addRow("Y", self.y_edit) + form.addRow("Color", color_row) + form.addRow("Font size", size_row) + form.addRow("Horiz. align", self.ha_combo) + form.addRow("Vert. align", self.va_combo) + form.addRow("Box color", self.box_color_button) + + outer.addLayout(form) + + self._style_form = form + self._set_editor_enabled(False) + + def _connect_signals(self) -> None: + self._document.figure_changed.connect(self.refresh) + self._document.figure_replaced.connect(self._on_figure_replaced) + + self.text_list.currentRowChanged.connect(self._on_list_selection_changed) + self.add_button.clicked.connect(self._on_add_clicked) + self.remove_button.clicked.connect(self._on_remove_clicked) + + # Commit on focus-out for the multi-line text editor: editingFinished + # doesn't exist on QPlainTextEdit, so we hook focusOutEvent via an + # event filter (documented choice below). + self.text_edit.installEventFilter(self) + + self.x_edit.editingFinished.connect(self._on_x_edited) + self.y_edit.editingFinished.connect(self._on_y_edited) + self.color_button.clicked.connect(self._on_color_clicked) + self.custom_size_check.toggled.connect(self._on_custom_size_toggled) + self.fontsize_spin.editingFinished.connect(self._on_fontsize_edited) + self.ha_combo.currentTextChanged.connect(self._on_ha_changed) + + def _on_figure_replaced(self) -> None: + """Handle a brand-new figure being installed (New/Open/undo/redo). + + Mirrors AxesPanel/SeriesPanel: the ``_axes`` override points at the + old figure's (now dead) Axes, so drop it and fall back to the live + ``document.figure.gca()``. + """ + self._axes = None + self.refresh() + + # ------------------------------------------------------------------ + # eventFilter: commit multi-line text on focus-out + # ------------------------------------------------------------------ + def eventFilter(self, obj, event) -> bool: # noqa: N802 (Qt override) + if obj is self.text_edit and event.type() == event.Type.FocusOut: + self._on_text_committed() + return super().eventFilter(obj, event) + + # ------------------------------------------------------------------ + # Model -> UI: list population + # ------------------------------------------------------------------ + def refresh(self) -> None: + """Full refresh: rebuild the annotation list and the editor.""" + ax = self._current_axes() + self.setEnabled(ax is not None) + + self._updating = True + try: + previous_row = self.text_list.currentRow() + self.text_list.clear() + + if ax is not None: + for entry in ax.texts: + item = QListWidgetItem(_format_entry(entry)) + self.text_list.addItem(item) + + if 0 <= previous_row < self.text_list.count(): + self.text_list.setCurrentRow(previous_row) + elif self.text_list.count() > 0: + self.text_list.setCurrentRow(0) + else: + self._populate_editor(None) + finally: + self._updating = False + + # Populate the editor for whatever ended up selected (outside the + # guard so this exercises the same code path as user selection, but + # without emitting text_selected -- selection here is a side effect + # of refresh(), not a user action). + self._populate_editor(self._selected_entry()) + + def _selected_entry(self) -> Optional[dict]: + ax = self._current_axes() + if ax is None: + return None + row = self.text_list.currentRow() + if 0 <= row < len(ax.texts): + return ax.texts[row] + return None + + def _on_list_selection_changed(self, row: int) -> None: + # Guard, don't just gate the emit: QListWidget.clear() (used by + # refresh()) fires *transient* currentRowChanged signals as items are + # removed (e.g. an intermediate row before settling on -1, then the + # final restored row) -- all while self._updating is True. Populating + # the editor unconditionally for those transient rows used to be + # "safe" only by accident of nesting inside refresh()'s/select_text()'s + # own _updating window; relying on that incidental nesting is + # fragile (see the "custom size" checkbox cross-annotation bug). Both + # refresh() and select_text() already perform their own explicit, + # correctly-targeted _populate_editor call once the row has settled, + # so skipping here while a programmatic update is in flight is not a + # loss -- it removes the redundant/transient populate entirely. + if self._updating: + return + self._populate_editor(self._selected_entry()) + self.text_selected.emit(row) + + # ------------------------------------------------------------------ + # Model -> UI: editor + # ------------------------------------------------------------------ + def _populate_editor(self, entry: Optional[dict]) -> None: + was_updating = self._updating + self._updating = True + try: + if entry is None: + self._set_editor_enabled(False) + # Preserve an in-progress edit: a focused editor must not be + # clobbered by an external refresh (e.g. a canvas drag commit + # firing figure_changed while the user is mid-type). This + # normally only fires when there is genuinely no selection, but + # guarding keeps the discipline uniform. + if not self.text_edit.hasFocus(): + self.text_edit.setPlainText("") + if not self.x_edit.hasFocus(): + self.x_edit.setText("") + if not self.y_edit.hasFocus(): + self.y_edit.setText("") + return + + self._set_editor_enabled(True) + + # Skip overwriting any editor the user is currently editing: a + # refresh triggered externally (canvas drag commit -> figure_changed) + # must not discard uncommitted typed text. The list and all + # non-focused fields still refresh. The focused field commits + # normally on blur via its editingFinished/focus-out path. + if not self.text_edit.hasFocus(): + self.text_edit.setPlainText(entry.get("text", "") or "") + if not self.x_edit.hasFocus(): + self.x_edit.setText(_format_coord(entry.get("x", 0.0))) + if not self.y_edit.hasFocus(): + self.y_edit.setText(_format_coord(entry.get("y", 0.0))) + + color_value = entry.get("color") or "BLACK" + rgb = _GLE_COLOR_TO_RGB.get(str(color_value).upper(), (0, 0, 0)) + self._current_color_rgb = rgb + self._update_color_swatch(rgb) + + fontsize = entry.get("fontsize") + has_custom = fontsize is not None + self.custom_size_check.setChecked(has_custom) + self.fontsize_spin.setEnabled(has_custom) + self.fontsize_spin.setValue(float(fontsize) if has_custom else _FONTSIZE_DEFAULT) + + self._set_combo_text(self.ha_combo, entry.get("ha") or "left") + + va = entry.get("va") + if va in _VA_VALUES: + self._set_combo_text(self.va_combo, va) + + box_color = entry.get("box_color") + box_rgb = _GLE_COLOR_TO_RGB.get(str(box_color).upper(), None) if box_color else None + if box_rgb is not None: + self.box_color_button.setStyleSheet( + f"background-color: rgb({box_rgb[0]}, {box_rgb[1]}, {box_rgb[2]});" + ) + self.box_color_button.setText(QColor(*box_rgb).name()) + else: + self.box_color_button.setStyleSheet("") + self.box_color_button.setText("(none)") + finally: + self._updating = was_updating + + def _set_editor_enabled(self, enabled: bool) -> None: + self.text_edit.setEnabled(enabled) + self.x_edit.setEnabled(enabled) + self.y_edit.setEnabled(enabled) + self.color_button.setEnabled(enabled) + self.custom_size_check.setEnabled(enabled) + self.fontsize_spin.setEnabled(enabled and self.custom_size_check.isChecked()) + self.ha_combo.setEnabled(enabled) + self.remove_button.setEnabled(enabled) + # va_combo / box_color_button stay disabled always (no-effect). + + def _update_color_swatch(self, rgb: tuple) -> None: + color = QColor(*rgb) + self.color_button.setStyleSheet( + f"background-color: rgb({rgb[0]}, {rgb[1]}, {rgb[2]});" + ) + self.color_button.setText(color.name()) + + @staticmethod + def _set_combo_text(combo: QComboBox, text: str) -> None: + index = combo.findText(text) + if index >= 0: + combo.setCurrentIndex(index) + + def _refresh_selected_list_text(self) -> None: + entry = self._selected_entry() + row = self.text_list.currentRow() + if entry is None or row < 0: + return + self._updating = True + try: + item = self.text_list.item(row) + item.setText(_format_entry(entry)) + finally: + self._updating = False + + # ------------------------------------------------------------------ + # UI -> Model: edits + # ------------------------------------------------------------------ + def _on_text_committed(self) -> None: + """Commit the multi-line text editor on focus-out. + + Commit-semantics choice: GLE's ``write`` command (emitted by + ``GLEWriter.add_text``) is single-line, so embedded newlines are + stripped (joined with a space) before being written back to the + dict. ``QPlainTextEdit`` has no ``editingFinished`` signal (that's + QLineEdit-only), so focus-out via an installed event filter is used + instead of an explicit Apply button -- it matches the + editingFinished-on-blur discipline used everywhere else in this + panel family without adding an extra always-visible button for a + field that's usually a one-line label anyway. + """ + if self._updating: + return + entry = self._selected_entry() + if entry is None: + return + + raw = self.text_edit.toPlainText() + flattened = " ".join(raw.splitlines()) + if flattened == entry.get("text", ""): + return + + entry["text"] = flattened + + # Reflect the stripped form back into the widget so the displayed + # text matches exactly what will be written to GLE. + self._updating = True + try: + if flattened != raw: + self.text_edit.setPlainText(flattened) + finally: + self._updating = False + + self._document.notify_changed() + self._refresh_selected_list_text() + + def _on_x_edited(self) -> None: + self._write_float(self.x_edit, "x") + + def _on_y_edited(self) -> None: + self._write_float(self.y_edit, "y") + + def _write_float(self, edit: QLineEdit, key: str) -> None: + if self._updating: + return + entry = self._selected_entry() + if entry is None: + return + + text = edit.text().strip() + try: + value = float(text) + except ValueError: + value = None + # Reject non-finite coordinates (nan/inf, incl. overflow like 1e400): + # they would produce corrupted GLE output and NaN geometry downstream. + if value is None or not math.isfinite(value): + # Invalid text: revert the field to the current model value. + self._updating = True + try: + edit.setText(_format_coord(entry.get(key, 0.0))) + finally: + self._updating = False + return + + entry[key] = value + self._updating = True + try: + edit.setText(_format_coord(value)) + finally: + self._updating = False + self._document.notify_changed() + self._refresh_selected_list_text() + + def _on_color_clicked(self) -> None: + if self._updating: + return + entry = self._selected_entry() + if entry is None: + return + initial = QColor(*self._current_color_rgb) + color = QColorDialog.getColor(initial, self, "Text color") + if not color.isValid(): + return + rgb01 = (color.redF(), color.greenF(), color.blueF()) + gle_color = rgb_to_gle(rgb01) + + entry["color"] = gle_color + self._current_color_rgb = ( + int(color.red()), + int(color.green()), + int(color.blue()), + ) + self._update_color_swatch(self._current_color_rgb) + self._document.notify_changed() + + def _on_custom_size_toggled(self, checked: bool) -> None: + if self._updating: + return + entry = self._selected_entry() + if entry is None: + return + + self._updating = True + try: + self.fontsize_spin.setEnabled(checked) + if checked: + self.fontsize_spin.setValue(_FONTSIZE_DEFAULT) + finally: + self._updating = False + + entry["fontsize"] = float(self.fontsize_spin.value()) if checked else None + self._document.notify_changed() + + def _on_fontsize_edited(self) -> None: + if self._updating: + return + entry = self._selected_entry() + if entry is None or not self.custom_size_check.isChecked(): + return + entry["fontsize"] = float(self.fontsize_spin.value()) + self._document.notify_changed() + + def _on_ha_changed(self, text: str) -> None: + if self._updating: + return + entry = self._selected_entry() + if entry is None: + return + entry["ha"] = text + self._document.notify_changed() + + # ------------------------------------------------------------------ + # Add / Remove + # ------------------------------------------------------------------ + def _on_add_clicked(self) -> None: + ax = self._current_axes() + if ax is None: + return + + xmin, xmax = ax.get_xlim() + ymin, ymax = ax.get_ylim() + x = (xmin + xmax) / 2.0 if xmin is not None and xmax is not None else 0.5 + y = (ymin + ymax) / 2.0 if ymin is not None and ymax is not None else 0.5 + + # Public Axes.text API, per spec, rather than appending to ax.texts + # directly -- keeps this panel's "add" path exercising the same + # validation/defaulting logic as any other caller (color -> 'BLACK', + # ha default, etc.). + ax.text(x, y, "New text") + self._document.notify_changed() + + self._updating = True + try: + self.refresh() + finally: + self._updating = False + # Select the newly-added (last) entry. + self.select_text(len(ax.texts) - 1) + + def _on_remove_clicked(self) -> None: + if self._updating: + return + ax = self._current_axes() + if ax is None: + return + row = self.text_list.currentRow() + if 0 <= row < len(ax.texts): + del ax.texts[row] + self._document.notify_changed() + + # notify_changed() above already drove a reentrant refresh() via + # figure_changed (mirrors _on_add_clicked); guard this explicit call + # the same way so it isn't left running unguarded relative to the + # caller once _populate_editor's transient-row calls are gated above. + self._updating = True + try: + self.refresh() + finally: + self._updating = False diff --git a/src/gleplot/gui/preview.py b/src/gleplot/gui/preview.py index e9bcb54..0155ee2 100644 --- a/src/gleplot/gui/preview.py +++ b/src/gleplot/gui/preview.py @@ -1,20 +1,22 @@ """Live preview engine for the gleplot GUI editor. This module implements Track D of the editor: a debounced, asynchronous GLE -render pipeline and the view that displays its output. +render pipeline and the view that displays its output. Track E2 extends it +with an optional SVG (vector) render path that falls back to PNG on failure. :class:`PreviewController` Observes a :class:`~gleplot.gui.document.FigureDocument` and, whenever the - figure changes, re-renders it to a PNG using the GLE compiler -- entirely - off the GUI thread via :class:`~PySide6.QtCore.QProcess`. It debounces - rapid edits, coalesces overlapping renders so only the newest state is - ever shown, and reports success/failure/empty via Qt signals. + figure changes, re-renders it using the GLE compiler -- entirely off the + GUI thread via :class:`~PySide6.QtCore.QProcess`. It debounces rapid + edits, coalesces overlapping renders so only the newest state is ever + shown, and reports success/failure/empty via Qt signals. See + :attr:`PreviewController.render_format` for the PNG/SVG switch. :class:`PreviewView` - A :class:`~PySide6.QtWidgets.QGraphicsView` that shows the rendered PNG - with wheel-zoom-around-cursor, drag panning, fit-to-window and 1:1 zoom, - and a placeholder for empty/error states. It keeps the last good image on - screen through transient compile errors. + A :class:`~PySide6.QtWidgets.QGraphicsView` that shows the rendered image + (raster PNG or vector SVG) with wheel-zoom-around-cursor, drag panning, + fit-to-window and 1:1 zoom, and a placeholder for empty/error states. It + keeps the last good image on screen through transient compile errors. Snapshot semantics ------------------- @@ -32,14 +34,41 @@ reads as a bug. So when the document has no figure, no axes, or no axes with any series, the controller skips the render and emits :data:`PreviewController.render_skipped_empty` instead of invoking GLE. + +SVG rendering and fallback (Track E2) +-------------------------------------- +GLE's Cairo-based SVG backend (``gle -d svg``) refuses to draw any PostScript +font (``>> Error: PostScript fonts not supported with '-cairo'``) but still +exits ``0`` and still writes a structurally valid (if incomplete -- missing +text, and any calibration ``print`` line placed after the affected graph block +never executes) SVG file. Since gleplot's default +:class:`~gleplot.config.GLEStyleConfig` emits no ``set font`` line at all, +GLE's own built-in default resolves to a PostScript font, so *every* SVG +render would hit this unless prevented. The controller therefore injects +``set font texcmr`` (a TeX/Cairo-safe font) into the SVG-mode copy of the +script whenever the user's own figure does not already set a font -- this is +pure preview-script surgery, exactly like the existing calibration +``print``-line injection, and never touches ``writer.py`` or a user's saved +file. If the user's *own* configured font is not Cairo-safe (or any other +SVG-only failure occurs), :meth:`PreviewController._on_process_finished` +detects it (exit code + parsed ``GLEError`` + output-file validity, see +:meth:`PreviewController._svg_output_is_valid`) and the controller +permanently falls back to PNG for the rest of the session (see +:attr:`PreviewController.render_format` and :data:`PreviewController. +fallback_activated`), automatically re-rendering in PNG so the user sees +output without intervention. """ from __future__ import annotations +import logging +import re import shutil +import subprocess import tempfile +from dataclasses import dataclass from pathlib import Path -from typing import List, Optional +from typing import List, Optional, Protocol from PySide6.QtCore import ( QObject, @@ -56,9 +85,24 @@ QGraphicsView, ) +try: + from PySide6.QtSvg import QSvgRenderer + from PySide6.QtSvgWidgets import QGraphicsSvgItem + + _QTSVG_AVAILABLE = True +except ImportError: # pragma: no cover - exercised only without the gui extra + QSvgRenderer = None # type: ignore[assignment, misc] + QGraphicsSvgItem = None # type: ignore[assignment, misc] + _QTSVG_AVAILABLE = False + from gleplot.compiler import GLEError, find_gle, parse_gle_errors from gleplot.figure import Figure from gleplot.gui.document import FigureDocument +from gleplot.gui.geometry import CM_PER_INCH, PreviewGeometry, parse_calibration_lines +from gleplot.parser.syntax import GraphBlock, parse_gle_source +from gleplot.parser.units import inches_to_cm + +_log = logging.getLogger(__name__) #: Base name (without suffix) of the GLE script written into the session dir. _SCRIPT_STEM = "preview" @@ -66,6 +110,173 @@ #: and referenced relatively from the script. _DATA_PREFIX = "preview" +#: Points per centimetre (1 inch = 72pt = 2.54cm), the SVG viewBox is authored +#: in points regardless of preview DPI. See :class:`SvgViewMapping`. +_PT_PER_CM = 72.0 / CM_PER_INCH + +#: GLE's Cairo SVG backend pads the page by exactly 1pt on every side (see +#: module docstring "SVG rendering and fallback"; empirically derived from +#: comparing ``gle -d svg`` output's ``viewBox`` to the requested page size). +_SVG_MARGIN_PT = 1.0 + +#: Cairo/TeX font forced into the SVG-mode script when the figure does not +#: already set one, so GLE's own PostScript-font default never triggers +#: ``>> Error: PostScript fonts not supported with '-cairo'``. +_SVG_SAFE_FONT = "texcmr" + +#: Matches a top-level ``set font ...`` line so we never override an explicit +#: user choice (see module docstring). +_SET_FONT_RE = re.compile(r"^\s*set\s+font\b", re.IGNORECASE) +_SIZE_LINE_RE = re.compile(r"^\s*size\s+[-+0-9.]+\s+[-+0-9.]+\s*$", re.IGNORECASE) + +#: Matches a genuine GLE diagnostic location line, e.g. +#: ``>> foo.gle (10) |end graph|`` -- the same anchor +#: :func:`gleplot.compiler.parse_gle_errors` uses to open an error block. +#: ``parse_gle_errors`` itself cannot be used as a general-purpose +#: "did-something-go-wrong" probe on an exit-0 compile: for *any* non-empty +#: text without such a block (e.g. the normal, harmless +#: ``GLE 4.3.3[foo.gle]-C-R-`` banner GLE always prints) it falls back to +#: wrapping the whole raw text as one synthetic :class:`GLEError`, which would +#: make every successful compile look like a failure. This narrower pattern +#: is what actually distinguishes a real diagnostic block from the banner. +_GLE_DIAGNOSTIC_RE = re.compile(r"^>>\s*.+?\s*\(\d+\)\s*\|.*\|\s*$", re.MULTILINE) + + +# --------------------------------------------------------------------------- # +# view_mapping() contract (frozen -- consumed by the annotation overlay, F1) +# --------------------------------------------------------------------------- # +class ViewMapping(Protocol): + """``cm <-> view`` mapping for whatever is currently displayed. + + This is the frozen contract between the preview track and the annotation + overlay track: :meth:`PreviewView.view_mapping` returns an object + satisfying this protocol (or ``None`` when nothing is displayed), and the + overlay uses it -- together with the per-axes calibration from + ``PreviewController.geometry_ready`` -- to place/read back handles: + + cal = geometry.axes[i] + cx, cy = cal.data_to_cm(x, y) # data -> page cm + mapping = view.view_mapping() + vx, vy = mapping.cm_to_view(cx, cy) # page cm -> view/scene + + and the inverse when the user drops a handle at scene point ``(vx, vy)``:: + + cx, cy = mapping.view_to_cm(vx, vy) + # then geometry.axes_at_... equivalent / cal.cm_to_data(cx, cy) + + ``(vx, vy)`` are in the :class:`PreviewView` scene's coordinate system -- + i.e. exactly the coordinates the displayed :class:`QGraphicsPixmapItem` / + :class:`QGraphicsSvgItem` itself is drawn in, *before* any view zoom/pan + transform (those are view-level, not scene-level, so overlay items placed + in the scene track zoom/pan automatically like the image does). This is + the same space :class:`~gleplot.gui.geometry.PreviewGeometry.cm_to_px` + already used for the raster case; the SVG case reuses the identical shape + with a different renderer-specific scale (see :class:`SvgViewMapping`). + """ + + def cm_to_view(self, cx: float, cy: float) -> tuple: + """Map page-cm ``(cx, cy)`` to scene/view coordinates.""" + ... + + def view_to_cm(self, vx: float, vy: float) -> tuple: + """Inverse of :meth:`cm_to_view`.""" + ... + + def fingerprint(self) -> tuple: + """A hashable identity of this mapping's concrete class + scale params. + + The annotation overlay captures a mapping's fingerprint when an + interaction (drag/edit) starts and compares it on each rebuild. A + change means the ``cm <-> scene`` relationship shifted underneath an + in-flight gesture -- e.g. a PNG<->SVG format switch, or a DPI change -- + so decoding the stale scene position with the new mapping would + silently corrupt the committed coordinates. The overlay aborts the + interaction (reverting to the model position) when the fingerprint + changes rather than preserving the stale scene position. Two mappings + that decode a scene point identically compare equal. + """ + ... + + +@dataclass +class RasterViewMapping: + """:class:`ViewMapping` for a displayed raster (PNG) image. + + Thin adapter around :class:`~gleplot.gui.geometry.PreviewGeometry`'s + existing ``cm <-> px`` methods -- the raster pixmap item is drawn at the + scene origin at 1 scene-unit-per-pixel, so pixel coordinates and scene + coordinates coincide. + """ + + geometry: PreviewGeometry + + def cm_to_view(self, cx: float, cy: float): + return self.geometry.cm_to_px(cx, cy) + + def view_to_cm(self, vx: float, vy: float): + return self.geometry.px_to_cm(vx, vy) + + def fingerprint(self) -> tuple: + """Identity for the raster ``cm <-> px`` mapping. + + The raster mapping is fully determined by the render DPI and the page + height in cm (the only inputs to :meth:`PreviewGeometry.cm_to_px` / + ``px_to_cm``). A change in either shifts the scene<->cm relationship, + so both go into the fingerprint. Page width does not affect the + mapping, so it is deliberately omitted. + """ + return ("raster", self.geometry.dpi, self.geometry.page_size_cm[1]) + + +@dataclass +class SvgViewMapping: + """:class:`ViewMapping` for a displayed vector (SVG) image. + + GLE's Cairo SVG backend authors page content directly in page-cm-derived + units, transformed once into the SVG viewBox (point units, y-down from + the top-left) via a single affine transform empirically confirmed to be:: + + svg_x = k * cx + margin + svg_y = -k * cy + (viewbox_height_pt - margin) + + where ``k = 72 / 2.54`` (points per cm) and ``margin = 1pt`` (GLE's fixed + page padding in this backend). ``QGraphicsSvgItem`` places its content in + exactly these viewBox units in the scene (its ``boundingRect()`` == + ``renderer().defaultSize()`` == the viewBox size), so this mapping's + output is directly usable as a scene coordinate -- no extra scale step + beyond what :class:`RasterViewMapping` needs for the raster case. + + Unlike :class:`RasterViewMapping`, this mapping is independent of preview + DPI (SVG has no raster resolution) and is derived purely from the page + size in cm, which is why it is constructed directly from + ``page_size_cm`` rather than from a full :class:`PreviewGeometry`. + """ + + page_size_cm: tuple + + def cm_to_view(self, cx: float, cy: float): + viewbox_h = self.page_size_cm[1] * _PT_PER_CM + 2 * _SVG_MARGIN_PT + vx = _PT_PER_CM * cx + _SVG_MARGIN_PT + vy = -_PT_PER_CM * cy + (viewbox_h - _SVG_MARGIN_PT) + return vx, vy + + def view_to_cm(self, vx: float, vy: float): + viewbox_h = self.page_size_cm[1] * _PT_PER_CM + 2 * _SVG_MARGIN_PT + cx = (vx - _SVG_MARGIN_PT) / _PT_PER_CM + cy = ((viewbox_h - _SVG_MARGIN_PT) - vy) / _PT_PER_CM + return cx, cy + + def fingerprint(self) -> tuple: + """Identity for the vector ``cm <-> svg-unit`` mapping. + + The SVG mapping is DPI-independent and fully determined by the page + size in cm (both dimensions feed the viewBox transform). The leading + tag differs from :meth:`RasterViewMapping.fingerprint` so a PNG<->SVG + format switch always registers as a change even in the unlikely event + the numeric params coincide. + """ + return ("svg", self.page_size_cm[0], self.page_size_cm[1]) + class PreviewController(QObject): """Debounced, asynchronous GLE render engine driven by a document. @@ -92,17 +303,41 @@ class PreviewController(QObject): render_skipped_empty() The document has nothing renderable (no figure / no series); no GLE process was launched. + geometry_ready(object) + Emitted on a *successful* render with the :class:`PreviewGeometry` + derived from the GLE calibration output, or with ``None`` when + calibration could not be parsed. **Always emitted before** + ``render_succeeded`` on a successful render so an overlay can install + the geometry and then react to the new image in one turn. On parse + failure the geometry is ``None`` (annotations overlay disables itself) + but ``render_succeeded`` still fires -- calibration never blocks a + render. + fallback_activated(str) + Emitted exactly once per session, the first time an SVG render fails + for an SVG-specific reason (compile error unique to ``-d svg``, + invalid/empty SVG output, or ``QtSvg`` unavailable). The argument is a + short human-readable reason. After this fires, :attr:`render_format` + is permanently ``'png'`` for the rest of the controller's life (the + setter is a no-op for ``'svg'`` from then on) and a PNG re-render of + the current state is launched automatically so the user sees output + without touching anything. Notes ----- Call :meth:`shutdown` (or ``deleteLater``) when done to kill any running process and remove the session temp directory. + + The calibration ``print`` lines are injected only into the preview's temp + copy of the script (see :meth:`_write_script`); user-facing saves go through + the untouched public figure API and never contain ``gleplot-cal``. """ render_started = Signal() render_succeeded = Signal(str) render_failed = Signal(list, str) render_skipped_empty = Signal() + geometry_ready = Signal(object) + fallback_activated = Signal(str) #: Default debounce interval in milliseconds. DEFAULT_DEBOUNCE_MS = 300 @@ -120,6 +355,17 @@ def __init__( self._gle_path = find_gle() self._preview_dpi = 150 + # Render format: 'svg' by default only when QtSvg imported successfully + # AND a one-time session probe (a trivial compile) confirms GLE's + # ``-d svg`` actually produces a loadable SVG in this environment; + # otherwise 'png'. See _probe_svg_support(). ``_svg_fallback_reason`` + # is set (once, permanently) the first time an SVG-specific failure is + # detected, and pins the format to 'png' from then on. + self._svg_fallback_reason: Optional[str] = None + self._render_format = "png" + if _QTSVG_AVAILABLE and self._gle_path and self._probe_svg_support(): + self._render_format = "svg" + # Session scratch directory for scripts, data files, and PNGs. Created # lazily on first render so constructing a controller is cheap. self._session_dir: Optional[Path] = None @@ -135,6 +381,21 @@ def __init__( self._process: Optional[QProcess] = None self._current_output: Optional[Path] = None + # Calibration geometry from the most recent successful render, or None + # if calibration could not be parsed. Consumed by the annotation + # overlay via the ``geometry_ready`` signal. + self.last_geometry: Optional[PreviewGeometry] = None + # Per-render calibration inputs captured at launch (from the snapshot, + # not the live figure): the page size in cm and the per-axes log flags, + # both needed to build a PreviewGeometry when the process finishes. + self._cal_page_size_cm: Optional[tuple] = None + self._cal_axes_meta: list = [] + # The format ('svg'/'png') the *currently running* (or just-finished) + # process was launched with -- captured at launch time so a mid-flight + # render_format change (or a fallback triggered by this very render) + # never confuses which validation path _on_process_finished takes. + self._running_format = "png" + # Debounce timer: collapses a burst of change signals into one render. self._debounce = QTimer(self) self._debounce.setSingleShot(True) @@ -174,6 +435,44 @@ def debounce_ms(self) -> int: def debounce_ms(self, value: int) -> None: self._debounce.setInterval(int(value)) + @property + def render_format(self) -> str: + """Current render format: ``'svg'`` (vector) or ``'png'`` (raster). + + Defaults to ``'svg'`` when ``QtSvg`` imported successfully *and* a + one-time session probe confirmed GLE's ``-d svg`` output loads, else + ``'png'``. Once :data:`fallback_activated` has fired, this is + permanently pinned to ``'png'`` -- the setter silently ignores any + attempt to set ``'svg'`` again for the rest of the session (see + :attr:`svg_available`). + """ + return self._render_format + + @render_format.setter + def render_format(self, value: str) -> None: + value = str(value).lower() + if value not in ("svg", "png"): + raise ValueError(f"render_format must be 'svg' or 'png', got {value!r}") + if value == "svg" and self._svg_fallback_reason is not None: + # Sticky fallback: never re-enable SVG once it has failed. + return + if value == "svg" and not _QTSVG_AVAILABLE: + return + if value != self._render_format: + self._render_format = value + self._on_document_changed() + + @property + def svg_available(self) -> bool: + """Whether SVG can currently be selected as :attr:`render_format`. + + ``False`` when ``QtSvg`` failed to import or a fallback has already + been triggered this session (permanently). Used by the main window to + disable/grey out the "Vector preview (SVG)" toggle with an explanatory + tooltip. + """ + return _QTSVG_AVAILABLE and self._svg_fallback_reason is None + # ------------------------------------------------------------------ # Change handling / scheduling # ------------------------------------------------------------------ @@ -256,6 +555,8 @@ def _launch(self, snap: dict) -> None: seq = self._requested_seq self._running_seq = seq + fmt = self._render_format + self._running_format = fmt session = self._ensure_session_dir() try: @@ -266,7 +567,22 @@ def _launch(self, snap: dict) -> None: project_path = getattr(self._document, "project_path", None) if project_path: work_fig.absolutize_file_references(Path(project_path).parent) - self._write_script(work_fig, session) + self._write_script(work_fig, session, fmt) + # Capture calibration inputs from the *snapshot* work figure (never + # the live document): page size in cm and per-axes log flags, in + # axes_list == graph-block order. Used to build the geometry when + # the process finishes. + self._cal_page_size_cm = ( + inches_to_cm(work_fig.figsize[0]), + inches_to_cm(work_fig.figsize[1]), + ) + self._cal_axes_meta = [ + ( + getattr(ax, "xscale", "linear") == "log", + getattr(ax, "yscale", "linear") == "log", + ) + for ax in work_fig.axes_list + ] except Exception as exc: # noqa: BLE001 - report, never crash the GUI err = GLEError( file=None, line=None, column=None, @@ -275,18 +591,23 @@ def _launch(self, snap: dict) -> None: self.render_failed.emit([err], str(exc)) return - output_name = f"render_{seq}.png" + output_name = f"render_{seq}.{fmt}" self._current_output = session / output_name proc = QProcess(self) proc.setWorkingDirectory(str(session)) - proc.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) + # Keep stdout and stderr separate: GLE 4.3.3 emits the ``gleplot-cal`` + # calibration records on *stderr* while errors and diagnostics can land + # on either stream. We read and concatenate both on finish so + # calibration parsing sees the records regardless of stream, and error + # parsing sees everything it did under the old merged mode. + proc.setProcessChannelMode(QProcess.ProcessChannelMode.SeparateChannels) proc.finished.connect(self._on_process_finished) proc.errorOccurred.connect(self._on_process_error) self._process = proc args = [ - "-d", "png", + "-d", fmt, "-r", str(self._preview_dpi), "-o", output_name, f"{_SCRIPT_STEM}.gle", @@ -295,7 +616,7 @@ def _launch(self, snap: dict) -> None: self.render_started.emit() proc.start(self._gle_path, args) - def _write_script(self, work_fig: Figure, session: Path) -> None: + def _write_script(self, work_fig: Figure, session: Path, fmt: str = "png") -> None: """Write the GLE script and its data sidecars into ``session``. Uses ``Figure.savefig_gle`` (the public GLE-export API), which emits @@ -315,7 +636,203 @@ def _write_script(self, work_fig: Figure, session: Path) -> None: # instead of accumulating collision-avoidance suffixes. work_fig._local_data_counter = 0 work_fig._used_data_files = set() - work_fig.savefig_gle(str(session / f"{_SCRIPT_STEM}.gle")) + script_path = session / f"{_SCRIPT_STEM}.gle" + work_fig.savefig_gle(str(script_path)) + # Post-process the preview-only copy to inject one calibration ``print`` + # per graph block so GLE emits its axis ranges + box corners at compile + # time. This touches only the temp script -- writer.py is untouched and + # user saves never see these lines. + self._inject_calibration(script_path) + if fmt == "svg": + # GLE's Cairo SVG backend rejects PostScript fonts (see module + # docstring "SVG rendering and fallback"). gleplot emits no + # ``set font`` line by default, so GLE's own built-in default + # would otherwise always trigger the error. Force a Cairo-safe + # font -- but only if the figure/script did not already request + # one explicitly, so a user's own font choice is never overridden + # (if that choice is itself PostScript-only, the normal + # SVG-validation/fallback path below handles it). + self._inject_svg_font(script_path) + + @staticmethod + def _inject_svg_font(script_path: Path) -> None: + """Insert ``set font texcmr`` after the ``size`` line, if needed. + + No-op if the script already contains a ``set font`` line (an explicit + user choice always wins). See the module docstring "SVG rendering and + fallback" for why this is necessary at all. + """ + text = script_path.read_text(encoding="utf-8") + newline = "\r\n" if "\r\n" in text else "\n" + raw_lines = text.split(newline) + + if any(_SET_FONT_RE.match(line) for line in raw_lines): + return # explicit user font already present; do not override + + for idx, line in enumerate(raw_lines): + if _SIZE_LINE_RE.match(line): + raw_lines.insert(idx + 1, f"set font {_SVG_SAFE_FONT}") + script_path.write_text(newline.join(raw_lines), encoding="utf-8") + return + # No ``size`` line found (should not happen for a gleplot-generated + # script): leave the script untouched rather than guess where to + # insert -- the SVG validation/fallback path will catch any resulting + # failure downstream. + + @staticmethod + def _inject_calibration(script_path: Path) -> None: + """Insert a ``gleplot-cal`` ``print`` after each graph block. + + Uses :func:`gleplot.parser.syntax.parse_gle_source` to locate every + top-level :class:`GraphBlock`'s ``end graph`` statement and inserts, on + the line *immediately after* it (i.e. after any deferred graph-text + lines GLE emits post-``end graph`` -- those are separate statements, and + we anchor on the ``end`` statement's own line), the exact empirically + proven print form:: + + print "gleplot-cal {i} " xgmin " " xgmax " " ygmin " " ygmax " " + xg(xgmin) " " yg(ygmin) " " xg(xgmax) " " yg(ygmax) + + ``xgmin``/``xg()``/``yg()`` in GLE refer to the most recent graph block, + so anchoring right after each block's ``end`` is correct. ``{i}`` is the + block order, which matches ``axes_list`` order (figure.py emits one + graph block per axes in list order). + + Insertions are done bottom-up (highest line number first) so earlier + line numbers stay valid as we splice. + """ + text = script_path.read_text(encoding="utf-8") + doc = parse_gle_source(text) + graphs = [n for n in doc.nodes if isinstance(n, GraphBlock)] + + # Map end-graph line number -> block order. Skip any unclosed block + # (end is None): without an ``end graph`` there is nothing to anchor to, + # and its calibration will simply be reported missing downstream. + anchors = { + g.end.line_no: i + for i, g in enumerate(graphs) + if g.end is not None + } + if not anchors: + return + + # Detect the dominant line ending so the injected lines match. + newline = "\r\n" if "\r\n" in text else "\n" + raw_lines = text.split(newline) + + # Insert bottom-up so indices below remain valid. ``raw_lines`` is + # 0-based; ``line_no`` is 1-based, so the end-graph line is at index + # ``line_no - 1`` and we insert immediately after it. + for line_no in sorted(anchors, reverse=True): + i = anchors[line_no] + print_line = ( + f'print "gleplot-cal {i} " xgmin " " xgmax " " ygmin " " ' + f'ygmax " " xg(xgmin) " " yg(ygmin) " " xg(xgmax) " " yg(ygmax)' + ) + raw_lines.insert(line_no, print_line) + + script_path.write_text(newline.join(raw_lines), encoding="utf-8") + + def _build_geometry(self, raw: str) -> Optional[PreviewGeometry]: + """Parse calibration records from ``raw`` into a :class:`PreviewGeometry`. + + Returns ``None`` (never raises) when the page size / axes metadata was + not captured or when no calibration record could be parsed, so a + parse failure disables the overlay rather than blocking the render. + """ + if self._cal_page_size_cm is None: + return None + calibrations, _warnings = parse_calibration_lines( + raw, self._cal_axes_meta + ) + if not calibrations: + return None + return PreviewGeometry( + page_size_cm=self._cal_page_size_cm, + dpi=self._preview_dpi, + axes=calibrations, + ) + + @staticmethod + def _svg_output_problem(output: Path, raw: str) -> Optional[str]: + """Return a reason string if ``output`` is not a usable SVG render. + + Checks, in order: ``QtSvg`` availability, that the file loads as a + valid SVG with a non-empty ``defaultSize()`` (catches truncated/ + corrupt output), and that the output contains a genuine GLE + diagnostic block (catches the exit-0-but-degraded case: GLE's + PostScript-font-on-Cairo error, or any other SVG-only compile error, + exits 0 and still writes a structurally valid SVG missing the + affected graph's content -- see module docstring). The check is + :data:`_GLE_DIAGNOSTIC_RE` directly, *not* + :func:`gleplot.compiler.parse_gle_errors` -- that function is a + fallback-wrap parser meant to be called only once GLE is already + known to have failed; on the harmless startup banner GLE always + prints (even on a clean compile) it would wrap the whole banner as a + synthetic error and make every successful render look failed. + Returns ``None`` when the output is good. + """ + if not _QTSVG_AVAILABLE: + return "QtSvg is not available" + try: + renderer = QSvgRenderer(str(output)) + except Exception as exc: # noqa: BLE001 - never crash the GUI + return f"SVG failed to load: {exc}" + if not renderer.isValid(): + return "SVG output is not a valid SVG document" + size = renderer.defaultSize() + if size.isEmpty(): + return "SVG output has an empty bounding size" + if _GLE_DIAGNOSTIC_RE.search(raw): + errors = parse_gle_errors(raw) + message = errors[0].message if errors else raw.strip() + return f"GLE reported an error during SVG compile: {message}" + return None + + def _probe_svg_support(self) -> bool: + """One-time session check that ``gle -d svg`` produces a loadable SVG. + + Compiles a minimal known-good script (an axes box, the same + Cairo-safe font forced for all SVG renders) in a throwaway temp + directory. Never raises; any exception or a failed/invalid probe + result means SVG is not available and the controller starts in + ``'png'`` mode instead. Runs synchronously (a bare ``gle`` invocation + on a trivial script is a small fraction of a second) so the format + decision is stable and available immediately in ``__init__`` -- + the render pipeline itself remains fully async for real renders. + """ + probe_dir = None + try: + probe_dir = Path(tempfile.mkdtemp(prefix="gleplot_svgprobe_")) + script = probe_dir / "probe.gle" + script.write_text( + "size 5 5\n" + f"set font {_SVG_SAFE_FONT}\n" + "begin graph\n" + " size 3 3\n" + " xaxis min 0 max 1\n" + " yaxis min 0 max 1\n" + "end graph\n", + encoding="utf-8", + ) + output = probe_dir / "probe.svg" + result = subprocess.run( + [self._gle_path, "-d", "svg", "-o", str(output), str(script)], + cwd=str(probe_dir), + capture_output=True, + timeout=10, + text=True, + ) + if result.returncode != 0 or not output.exists() or output.stat().st_size == 0: + return False + raw = (result.stdout or "") + (result.stderr or "") + problem = self._svg_output_problem(output, raw) + return problem is None + except Exception: # noqa: BLE001 - probe failure just disables SVG + return False + finally: + if probe_dir is not None: + shutil.rmtree(probe_dir, ignore_errors=True) # ------------------------------------------------------------------ # Process callbacks @@ -328,7 +845,12 @@ def _on_process_finished(self, exit_code: int, exit_status) -> None: raw = "" if proc is not None: - raw = bytes(proc.readAllStandardOutput()).decode("utf-8", "replace") + stdout = bytes(proc.readAllStandardOutput()).decode("utf-8", "replace") + stderr = bytes(proc.readAllStandardError()).decode("utf-8", "replace") + # Concatenate both streams: error parsing wants everything (as it + # did under the old merged mode) and calibration records live on + # stderr for this GLE build. + raw = stdout + stderr proc.deleteLater() finished_seq = self._running_seq @@ -346,9 +868,38 @@ def _on_process_finished(self, exit_code: int, exit_status) -> None: and output.exists() and output.stat().st_size > 0 ) + # For an SVG render, exit==0 and a non-empty file are *necessary + # but not sufficient*: GLE's Cairo backend can exit 0 and write a + # structurally valid-but-incomplete SVG on a PostScript-font error + # (see module docstring). Validate the file actually loads in + # QSvgRenderer with a non-empty size before treating it as success. + svg_problem: Optional[str] = None + if ok and self._running_format == "svg": + svg_problem = self._svg_output_problem(output, raw) + if svg_problem is not None: + ok = False + if ok: + # Build calibration geometry from the compile output and emit + # it BEFORE render_succeeded so an overlay installs geometry in + # the same turn it learns of the new image. Never blocks the + # render: any parse failure -> last_geometry = None, + # geometry_ready(None), but render_succeeded still fires. + self.last_geometry = self._build_geometry(raw) + self.geometry_ready.emit(self.last_geometry) self.render_succeeded.emit(str(output)) + elif self._running_format == "svg" and svg_problem is not None: + # SVG-specific failure: permanently fall back to PNG and + # re-render automatically (never surfaced as a render_failed + # -- from the user's perspective this is a silent, automatic + # substitution, not a compile error). + self.last_geometry = None + self._activate_svg_fallback(svg_problem) + restart_needed = True else: + # A failed render has no valid geometry; drop any stale one so + # an overlay does not keep drawing against an outdated page. + self.last_geometry = None errors = parse_gle_errors(raw) if not errors: errors = [GLEError( @@ -364,6 +915,21 @@ def _on_process_finished(self, exit_code: int, exit_status) -> None: elif self._process is None: self.render_skipped_empty.emit() + def _activate_svg_fallback(self, reason: str) -> None: + """Permanently pin :attr:`render_format` to ``'png'`` and notify. + + Idempotent: only the *first* call in a session logs/emits (mirrors + "log once" from the task brief); later SVG-side problems, if any, + are silently absorbed by the resulting ``'png'`` renders since + :attr:`render_format` setter is a no-op for ``'svg'`` from here on. + """ + if self._svg_fallback_reason is not None: + return + self._svg_fallback_reason = reason + self._render_format = "png" + _log.warning("SVG preview disabled for this session: %s", reason) + self.fallback_activated.emit(reason) + def _on_process_error(self, error) -> None: # A start/crash error; ``finished`` may or may not follow. If the # process failed to start there will be no ``finished`` signal, so @@ -434,13 +1000,18 @@ def deleteLater(self) -> None: # noqa: N802 - Qt override class PreviewView(QGraphicsView): - """Zoomable/pannable view of the rendered preview PNG. - - Displays a single pixmap in a :class:`QGraphicsScene`. Supports - wheel-zoom around the cursor, drag panning, fit-to-window, and 1:1 zoom. - Through transient compile errors the last successfully rendered image - stays visible; :meth:`show_placeholder` is only used when there is nothing - to show. + """Zoomable/pannable view of the rendered preview image. + + Displays a single image item (raster ``QGraphicsPixmapItem`` or vector + ``QGraphicsSvgItem``, whichever the controller last rendered) in a + :class:`QGraphicsScene`. Supports wheel-zoom around the cursor, drag + panning, fit-to-window, and 1:1 zoom. Through transient compile errors the + last successfully rendered image stays visible; :meth:`show_placeholder` + is only used when there is nothing to show. + + :meth:`view_mapping` exposes the frozen ``cm <-> view`` contract (see + :class:`ViewMapping`) the annotation overlay track consumes together with + ``PreviewController.geometry_ready``. """ #: Zoom scale clamps (absolute scene->view scale). @@ -454,11 +1025,30 @@ def __init__(self, parent=None) -> None: self.setScene(self._scene) self._pixmap_item: Optional[QGraphicsPixmapItem] = None + self._svg_item = None # Optional[QGraphicsSvgItem] self._placeholder_item = None self._last_good_path: Optional[str] = None self._last_image_size = None self._has_shown_image = False + # Mapping state captured at show_image() time, consumed by + # view_mapping(). For 'svg' this is derived purely from the page size + # in cm (see SvgViewMapping); for 'png' it needs a full + # PreviewGeometry (dpi + page size), installed separately via + # set_geometry() since PreviewController emits geometry_ready + # independently of render_succeeded/show_image. + self._current_format: Optional[str] = None + self._geometry: Optional[PreviewGeometry] = None + + # Annotation-overlay coordination (Track F1). The overlay sets + # ``annotations_enabled`` for status/UI, and suspends drag-panning + # while the cursor is over an annotation item so item dragging is not + # swallowed by ScrollHandDrag panning (the two fight -- see + # suspend_pan()). ``_pan_mode`` remembers the drag mode to restore. + self._annotations_enabled = False + self._pan_mode = QGraphicsView.DragMode.ScrollHandDrag + self._pan_suspended = False + self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter) @@ -473,14 +1063,99 @@ def last_good_path(self) -> Optional[str]: """Path of the most recently shown image, or ``None``.""" return self._last_good_path + @property + def annotations_enabled(self) -> bool: + """Whether the annotation overlay is active over this view. + + Driven by :class:`~gleplot.gui.annotations.AnnotationOverlay` (it sets + this from its ``overlay_enabled_changed`` signal). Purely informational + for the view/UI; the overlay owns the items and their behaviour. + """ + return self._annotations_enabled + + @annotations_enabled.setter + def annotations_enabled(self, value: bool) -> None: + self._annotations_enabled = bool(value) + + def suspend_pan(self, suspend: bool) -> None: + """Suspend (or restore) drag-panning so item dragging isn't swallowed. + + In :attr:`QGraphicsView.DragMode.ScrollHandDrag` the viewport grabs a + left-drag for panning *before* it ever reaches a movable + :class:`~gleplot.gui.annotations.AnnotationItem`, so the two fight and + the item never moves. The overlay calls ``suspend_pan(True)`` while the + cursor is over an annotation item (on hover-enter) and + ``suspend_pan(False)`` on hover-leave, temporarily switching the view to + :attr:`~QGraphicsView.DragMode.NoDrag` so the item receives the drag. + The previous mode is captured once and restored on un-suspend, so this + composes with any future drag-mode changes. + """ + if suspend: + if not self._pan_suspended: + self._pan_mode = self.dragMode() + self._pan_suspended = True + super().setDragMode(QGraphicsView.DragMode.NoDrag) + else: + if self._pan_suspended: + self._pan_suspended = False + super().setDragMode(self._pan_mode) + + def setDragMode(self, mode) -> None: # noqa: N802 - Qt override + """Track the pan drag-mode so :meth:`suspend_pan` restores it correctly.""" + if self._pan_suspended: + # Remember the requested mode; it becomes active on un-suspend. + self._pan_mode = mode + return + self._pan_mode = mode + super().setDragMode(mode) + + def set_geometry(self, geometry: Optional[PreviewGeometry]) -> None: + """Install the calibration geometry for the *raster* mapping. + + Connect this to ``PreviewController.geometry_ready``. Only consumed + when the currently displayed image is a PNG -- an SVG's mapping needs + only the page size (captured directly from the SVG file at + :meth:`show_image` time), never this geometry. Safe to call with + ``None`` (e.g. on a failed render); :meth:`view_mapping` simply + returns ``None`` for the raster case until a valid geometry arrives. + """ + self._geometry = geometry + + def view_mapping(self) -> Optional[ViewMapping]: + """Return the ``cm <-> view`` mapping for the currently shown image. + + Returns ``None`` when nothing is displayed, or (for the raster case + only) when no :class:`PreviewGeometry` has been installed yet via + :meth:`set_geometry`. See :class:`ViewMapping` for the frozen + contract the annotation overlay consumes. + """ + if self._current_format == "svg" and self._last_image_size is not None: + # page size in cm is recovered from the viewBox captured at + # show_image() time -- independent of dpi, unlike the raster case. + width_pt, height_pt = self._last_image_size + page_w_cm = (width_pt - 2 * _SVG_MARGIN_PT) / _PT_PER_CM + page_h_cm = (height_pt - 2 * _SVG_MARGIN_PT) / _PT_PER_CM + return SvgViewMapping(page_size_cm=(page_w_cm, page_h_cm)) + if self._current_format == "png" and self._geometry is not None: + return RasterViewMapping(geometry=self._geometry) + return None + def show_image(self, path: str) -> None: - """Display the image at ``path``. + """Display the image at ``path`` (``.png`` or ``.svg``). - Zoom and center are preserved when the new image has the same pixel - size as the previous one (the common live-preview case: re-render of - the same figure geometry). On the first image, or after a new figure - is installed (:meth:`reset_view`), the image is fit into the view. + Zoom and center are preserved when the new image has the same + size/viewBox as the previous one (the common live-preview case: a + re-render of the same figure geometry). On the first image, after a + new figure is installed (:meth:`reset_view`), or when the format + switches between raster and vector, the image is fit into the view. """ + is_svg = str(path).lower().endswith(".svg") + if is_svg: + self._show_svg(path) + else: + self._show_pixmap(path) + + def _show_pixmap(self, path: str) -> None: pixmap = QPixmap(path) if pixmap.isNull(): # Corrupt/partial file: keep whatever is currently shown. @@ -488,8 +1163,10 @@ def show_image(self, path: str) -> None: self._clear_placeholder() new_size = (pixmap.width(), pixmap.height()) - same_size = new_size == self._last_image_size + format_changed = self._current_format != "png" + same_size = (not format_changed) and new_size == self._last_image_size + self._clear_svg_item() if self._pixmap_item is None: self._pixmap_item = QGraphicsPixmapItem(pixmap) self._pixmap_item.setTransformationMode( @@ -500,6 +1177,40 @@ def show_image(self, path: str) -> None: self._pixmap_item.setPixmap(pixmap) self._scene.setSceneRect(QRectF(pixmap.rect())) + self._current_format = "png" + self._last_image_size = new_size + self._last_good_path = path + + if not self._has_shown_image or not same_size: + self._has_shown_image = True + self.fit_to_window() + + def _show_svg(self, path: str) -> None: + if not _QTSVG_AVAILABLE: + # Should never be reached: the controller falls back to PNG + # before emitting an .svg path when QtSvg is unavailable. Guard + # anyway so a stray call degrades to a no-op, not a crash. + return + renderer = QSvgRenderer(path) + if not renderer.isValid() or renderer.defaultSize().isEmpty(): + # Corrupt/partial file: keep whatever is currently shown. + return + + self._clear_placeholder() + size = renderer.defaultSize() + new_size = (size.width(), size.height()) + format_changed = self._current_format != "svg" + same_size = (not format_changed) and new_size == self._last_image_size + + self._clear_pixmap_item() + if self._svg_item is None: + self._svg_item = QGraphicsSvgItem(path) + self._scene.addItem(self._svg_item) + else: + self._svg_item.setSharedRenderer(renderer) + + self._scene.setSceneRect(self._svg_item.boundingRect()) + self._current_format = "svg" self._last_image_size = new_size self._last_good_path = path @@ -513,7 +1224,7 @@ def show_placeholder(self, text: str) -> None: If an image is currently displayed (e.g. a transient compile error), this is a no-op so the last good render stays on screen. """ - if self._pixmap_item is not None: + if self._pixmap_item is not None or self._svg_item is not None: return if self._placeholder_item is None: self._placeholder_item = self._scene.addText(text) @@ -539,9 +1250,9 @@ def reset_view(self) -> None: def clear_image(self) -> None: """Remove the current image (e.g. when the document goes empty).""" - if self._pixmap_item is not None: - self._scene.removeItem(self._pixmap_item) - self._pixmap_item = None + self._clear_pixmap_item() + self._clear_svg_item() + self._current_format = None self._last_image_size = None self._has_shown_image = False @@ -555,11 +1266,11 @@ def fit_to_window(self) -> None: self.fitInView(self._scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) def zoom_actual_size(self) -> None: - """Reset zoom to 1:1 (one image pixel per view pixel).""" + """Reset zoom to 1:1 (one image pixel/point per view pixel).""" self.resetTransform() def wheelEvent(self, event) -> None: # noqa: N802 - Qt override - if self._pixmap_item is None: + if self._pixmap_item is None and self._svg_item is None: return delta = event.angleDelta().y() if delta == 0: @@ -581,6 +1292,16 @@ def _apply_zoom(self, factor: float) -> None: # ------------------------------------------------------------------ # Internals # ------------------------------------------------------------------ + def _clear_pixmap_item(self) -> None: + if self._pixmap_item is not None: + self._scene.removeItem(self._pixmap_item) + self._pixmap_item = None + + def _clear_svg_item(self) -> None: + if self._svg_item is not None: + self._scene.removeItem(self._svg_item) + self._svg_item = None + def _clear_placeholder(self) -> None: if self._placeholder_item is not None: self._scene.removeItem(self._placeholder_item) diff --git a/src/gleplot/parser/recognizer.py b/src/gleplot/parser/recognizer.py index 3d84ea9..c0d5177 100644 --- a/src/gleplot/parser/recognizer.py +++ b/src/gleplot/parser/recognizer.py @@ -106,7 +106,12 @@ are case-insensitive; single-quoted strings and ``;``-joined statements are accepted; British ``GREY`` colors are accepted. Anything not recognized is preserved verbatim in the appropriate passthrough bucket (header / trailer / -axes) so it re-emits unchanged. +axes) so it re-emits unchanged. A blank (or comment-only) line separating two +post-graph text clusters, or between ``end graph`` and the first cluster, is +tolerated by :meth:`_Recognizer._try_one_text` (:meth:`_Recognizer._skip_blanks`) +-- the writer itself never emits such a blank line, but a human editing the +file for readability may add one; recognition proceeds exactly as if it were +absent, and re-emission is canonical (no blank line re-inserted). Warnings taxonomy ----------------- @@ -297,6 +302,14 @@ def __init__(self, text: str, gle_path: Path): self._import_list: Optional[List[str]] = None self._used_prefix_indices: Dict[str, int] = {} # prefix -> max index+1 self._used_data_files: set = set() + # Sticky text-cluster state (GLE 'set hei'/'set color'/'set just' are + # interpreter-global and persist across clusters/graphs until changed + # -- see _try_one_text). fontsize stays None until a 'set hei' is + # actually seen, matching the writer (which only emits 'set hei' when + # a text's fontsize is not None). + self._text_fontsize: Optional[float] = None + self._text_color: str = "BLACK" + self._text_just: str = "left" # -- public driver --------------------------------------------------- @@ -587,6 +600,16 @@ def _parse_graph_block(self, block: GraphBlock, marker_cfg, smooth_flags) -> dic "file_series": [], "passthrough": [], "series_order": [], # to preserve ordering info if needed + # Dataset names (e.g. 'd1') consumed by a 'bar'/'fill' command. + # The writer emits a standalone 'dN key ""' statement right after + # 'bar'/'fill' to neutralize GLE's auto-key-from-header behavior + # (bar/fill have no 'key' sub-option of their own -- see + # gleplot.writer.GLEWriter.add_bar_chart/add_fill_between). That + # bare 'dN key ""' would otherwise look like a brand-new, + # unlabeled line/scatter series to the generic dN-dispatch below; + # this set lets pass 2 recognize and skip it as a suppression + # marker instead of fabricating a phantom series. + "_key_suppress_datasets": set(), } # Local dataset map for THIS block (dataset refs are graph-local). @@ -648,8 +671,20 @@ def _parse_graph_block(self, block: GraphBlock, marker_cfg, smooth_flags) -> dic name = kw if name in emitted: continue + attr_toks = merged_attr_toks.get(name, []) + if name in info["_key_suppress_datasets"] and self._is_bare_key_suppression(attr_toks): + # 'dN key ""' with no other attributes, following a + # 'bar'/'fill' command that already consumed this + # dataset -- the writer's auto-key-from-header + # suppression marker (see writer.add_bar_chart / + # add_fill_between), not a real second series. Consume + # silently: no series, no passthrough (matches what the + # writer will regenerate from the bar/fill entry's + # column_names on next save). + emitted.add(name) + continue emitted.add(name) - merged = [Token(TokenType.WORD, name, (0, 0))] + merged_attr_toks.get(name, []) + merged = [Token(TokenType.WORD, name, (0, 0))] + attr_toks self._build_series_from_attrs( name, merged, datasets, info, marker_cfg, smooth_flags ) @@ -663,6 +698,26 @@ def _skip_meta_stmt(self, stmt) -> bool: """True if this statement's physical line is inside the metadata block.""" return stmt.source_line is not None and stmt.line_no in self._meta_lines + @staticmethod + def _is_bare_key_suppression(attr_toks: List[Token]) -> bool: + """True if a dataset's merged attribute tokens are exactly ``key ""``. + + Used to recognize the writer's auto-key-from-header suppression + marker (a standalone ``dN key ""`` statement emitted after a + ``bar``/``fill`` command -- see ``writer.add_bar_chart`` / + ``add_fill_between``) so it isn't mistaken for a real second series + on that dataset. Exactly 2 tokens: the ``key`` keyword and an empty + string literal. + """ + if len(attr_toks) != 2: + return False + kw_tok, val_tok = attr_toks + if kw_tok.value.lower() != "key": + return False + if val_tok.type is not TokenType.STRING: + return False + return _string_value(val_tok) == "" + def _build_series_from_attrs(self, name, merged_toks, datasets, info, marker_cfg, smooth_flags): """Build one series from a dataset's merged attribute tokens.""" @@ -969,6 +1024,7 @@ def _parse_bar_command(self, toks, datasets, info): if d_name is None or d_name not in datasets: info["passthrough"].append(" " + " ".join(t.value for t in toks)) return + info["_key_suppress_datasets"].add(d_name) data_file, xcol, ycol = datasets[d_name] loaded = self._load_series(data_file, xcol, ycol) entry = { @@ -991,6 +1047,9 @@ def _parse_bar_command(self, toks, datasets, info): entry["x"] = x entry["height"] = height entry["colors"] = [color] * len(height) + column_names = self._recovered_column_names(data_file, [xcol, ycol]) + if column_names is not None: + entry["column_names"] = column_names info["bars"].append(entry) def _parse_fill_command(self, toks, datasets, info): @@ -1013,6 +1072,8 @@ def _parse_fill_command(self, toks, datasets, info): if len(d_names) < 2 or d_names[0] not in datasets or d_names[1] not in datasets: info["passthrough"].append(" " + " ".join(t.value for t in toks)) return + info["_key_suppress_datasets"].add(d_names[0]) + info["_key_suppress_datasets"].add(d_names[1]) f1, xc1, yc1 = datasets[d_names[0]] f2, xc2, yc2 = datasets[d_names[1]] # fill data file has c1=x, c2=y1, c3=y2. d1=c1,c2 ; d2=c1,c3. @@ -1029,11 +1090,15 @@ def _parse_fill_command(self, toks, datasets, info): x = loaded["x"] y1 = loaded["y"] y2 = loaded.get(f"c{yc2}") - info["fills"].append({ + fill_entry = { "x": x, "y1": y1, "y2": y2, "color": color, "alpha": 0.3, "label": None, "data_file": f1, - }) + } + column_names = self._recovered_column_names(f1, [xc1, yc1, yc2]) + if column_names is not None: + fill_entry["column_names"] = column_names + info["fills"].append(fill_entry) # -- key ------------------------------------------------------------- @@ -1126,6 +1191,9 @@ def _parse_series_command(self, toks, datasets, info, marker_cfg, smooth_flags): "yaxis": "y2" if attrs["y2axis"] else "y", "data_file": data_file, } + column_names = self._recovered_column_names(data_file, [xcol, ycol]) + if column_names is not None: + entry["column_names"] = column_names if has_marker and not has_line: info["scatters"].append(entry) else: @@ -1224,7 +1292,14 @@ def _scan_series_attrs(self, toks) -> dict: i += 1 continue if w == "key" and i + 1 < m and toks[i + 1].type is TokenType.STRING: - a["label"] = _string_value(toks[i + 1]) + # 'key ""' is never a real user label -- the writer only ever + # emits it as an auto-key-from-header suppression marker + # (see writer._key_clause), for a series whose label is + # None. Recover it as None (not '') so the object model + # round-trips exactly; the writer regenerates the same + # 'key ""' from column_names + no label on next save. + label_value = _string_value(toks[i + 1]) + a["label"] = label_value if label_value != "" else None i += 2 continue i += 1 @@ -1403,6 +1478,24 @@ def const_arr(value, is_percent, horizontal): "yaxis": "y2" if attrs["y2axis"] else "y", "data_file": data_file, } + if not err_consts: + # Column indices in the SAME order the writer emits them (x, y, + # then y-error column(s) collapsed to one when symmetric i.e. + # yerr_up_col == yerr_down_col, then x-error likewise) -- see + # gleplot.writer.GLEWriter.add_errorbar. Constant/percentage + # errors (err_consts) have no backing file column at all (the + # array was synthesized above), so column_names is left absent + # and regenerated from stable defaults on next save, same as any + # pre-Track-E3 project. + cols = [xcol, ycol] + seen_err_cols = [] + for c in (yerr_up_col, yerr_down_col, xerr_left_col, xerr_right_col): + if c is not None and c not in seen_err_cols: + seen_err_cols.append(c) + cols.extend(seen_err_cols) + column_names = self._recovered_column_names(data_file, cols) + if column_names is not None: + entry["column_names"] = column_names info["errorbars"].append(entry) def _passthrough_original_dn(self, info, orig_toks): @@ -1485,6 +1578,71 @@ def _is_import(self, data_file) -> bool: cls = classify_data_file(self.gle_path, data_file, self._import_list) return cls == "import" + def _recovered_column_names(self, data_file, cols_1based) -> Optional[List[str]]: + """Recover a series' ``column_names`` from its sidecar's header row. + + Parameters + ---------- + data_file : str + The sidecar file name (an "import" series' data file). + cols_1based : list of int + 1-based column indices in the SAME order the object model's + ``column_names`` list expects them (x, then y, then any error/ + extra columns) -- matching how :mod:`gleplot.axes` builds + ``column_names`` and how :mod:`gleplot.writer` writes the + header row (one name per array passed to ``add_data_file``, in + that same order). + + Returns + ------- + list of str, or None + ``None`` when the table couldn't be resolved, or has no real + header row (:attr:`DataTable.has_header` is ``False`` -- + e.g. a hand-written headerless ``.dat``): in that case + ``column_names`` is left absent on the recovered series and + ``Axes.from_dict``-style default regeneration (mirrored here at + series-build time, see ``_default_column_names_like``) fills it + in on next save, same as any pre-Track-E3 project. + + A column index of ``0`` (GLE's synthesized point-index column, + no real file column behind it) recovers as ``'x'`` -- matching + the default a fresh ``ax.plot`` would assign -- since there is + no header cell to read for a column that doesn't exist in the + file. + + Invariant (post Finding-1) + -------------------------- + This copies the sidecar's header text VERBATIM into + ``column_names`` (no re-sanitization). That is only reached for + *import* series, and after the Finding-1 conservative + classification a file is only ever an import when the ``.gle`` + metadata block's ``import-data`` list vouches for it -- i.e. it + is a sidecar gleplot itself wrote, whose header was already + sanitized by the writer at export time. So verbatim copy is + exactly what preserves byte-identity on re-save: even a + hand-edited (unsanitary) header in a vouched sidecar round-trips + byte-for-byte, because the recovered ``column_names`` re-emit the + same header the file already holds. Hand-authored data files with + no metadata vouch are classified ``reference`` and never reach + this code, so an unsanitized user header can never be adopted and + rewritten here. + """ + resolved = self._resolve_table(data_file) + if resolved.error is not None or resolved.table is None: + return None + table = resolved.table + if not table.has_header: + return None + names = [] + for col in cols_1based: + if col == 0: + names.append("x") + continue + if col < 1 or col > table.n_cols: + return None + names.append(table.column_names[col - 1]) + return names + # -- amove / text cluster / trailer --------------------------------- def _match_amove(self, node) -> Optional[Tuple[float, float]]: @@ -1507,12 +1665,19 @@ def _match_amove(self, node) -> Optional[Tuple[float, float]]: def _consume_text_cluster(self, nodes, start) -> Tuple[List[dict], int]: """Greedily match the writer's deferred-text pattern after end graph. - Pattern per text (in order): + Pattern per text (every ``set`` is optional, in any subset, in order): [set hei H] - set color C - set just J + [set color C] + [set just J] amove xg(X) yg(Y) write "T" + + ``set hei``/``set color``/``set just`` are GLE *interpreter-global* + state: once set, they apply to every subsequent cluster (including + clusters in later graphs) until changed again -- mirroring GLE's own + stateful semantics. This lets a single ``set hei`` shared by multiple + clusters, or a cluster with no ``set just`` at all, still recover the + correct fontsize/color/ha via the sticky ``self._text_*`` state. Returns (texts, next_index). """ texts: List[dict] = [] @@ -1528,71 +1693,104 @@ def _consume_text_cluster(self, nodes, start) -> Tuple[List[dict], int]: return texts, i def _try_one_text(self, nodes, i) -> Tuple[int, Optional[dict]]: + """Try to match one text cluster starting at ``i``. + + On success returns ``(next_index, text_dict)``. On failure returns + ``(i, None)`` with the ORIGINAL ``i`` unchanged (never an + intermediate position), so the caller (:meth:`_consume_text_cluster`) + can safely discard partial progress and hand every skipped node back + to the ordinary passthrough walk untouched. + + Blank/comment-only lines are tolerated *between* cluster elements + (a hand-edited file may have a blank line separating annotations for + readability) via :meth:`_skip_blanks`, but this skip is provisional: + it only survives if the pattern goes on to match a full cluster. + Nothing here treats a blank line as a hard stop -- a blank line + followed by an unrelated statement simply falls through to the + ``return i, None`` below, restoring the untouched original ``i``. + """ + start = i n = len(nodes) - fontsize = None - color = "BLACK" - just = "left" x = y = None text_str = None - # Optional 'set hei H' - stmt = self._as_statement(nodes[i]) if i < n else None - if stmt is not None and stmt.keyword == "set": + # Optional 'set hei H' / 'set color C' / 'set just J', in any order, + # each independently optional. Every hit updates sticky state + # immediately so a later cluster with no 'set just' inherits the + # last-seen value (GLE semantics: 'set' is global interpreter state). + while True: + i = self._skip_blanks(nodes, i) + stmt = self._as_statement(nodes[i]) if i < n else None + if stmt is None or stmt.keyword != "set": + break toks = _words_and_values(stmt) - if len(toks) >= 3 and toks[1].value.lower() == "hei": + if len(toks) < 3: + break + sub = toks[1].value.lower() + if sub == "hei": v = _num(toks[2]) - if v is not None: - fontsize = fontsize_cm_to_pt(v) - i += 1 - stmt = self._as_statement(nodes[i]) if i < n else None - - # 'set color C' - if stmt is None or stmt.keyword != "set": - return i, None - toks = _words_and_values(stmt) - if len(toks) >= 3 and toks[1].value.lower() == "color": - color = toks[2].value - i += 1 - else: - return i, None - - # 'set just J' - stmt = self._as_statement(nodes[i]) if i < n else None - if stmt is None or stmt.keyword != "set": - return i, None - toks = _words_and_values(stmt) - if len(toks) >= 3 and toks[1].value.lower() == "just": - just = toks[2].value.lower() - i += 1 - else: - return i, None - - # 'amove xg(X) yg(Y)' + if v is None: + break + self._text_fontsize = fontsize_cm_to_pt(v) + i += 1 + continue + if sub == "color": + self._text_color = toks[2].value + i += 1 + continue + if sub == "just": + just = toks[2].value.lower() + if just in ("left", "center", "right"): + self._text_just = just + i += 1 + continue + break + + # 'amove xg(X) yg(Y)' -- mandatory: this is what makes the run of + # 'set' statements above a text cluster rather than unrelated + # passthrough. If absent, nothing was consumed as a text cluster (any + # 'set' statements walked above are left for the caller/passthrough + # by returning the ORIGINAL start index). + i = self._skip_blanks(nodes, i) stmt = self._as_statement(nodes[i]) if i < n else None if stmt is None or stmt.keyword != "amove": - return i, None + return start, None x, y = self._parse_xg_yg(_words_and_values(stmt)) if x is None or y is None: - return i, None + return start, None i += 1 - # 'write "T"' + # 'write "T"' -- mandatory (a blank line may separate the amove from + # its write in a hand-edited file; tolerate it the same way). + i = self._skip_blanks(nodes, i) stmt = self._as_statement(nodes[i]) if i < n else None if stmt is None or stmt.keyword != "write": - return i, None + return start, None toks = _words_and_values(stmt) text_str = self._first_string(toks) if text_str is None: - return i, None + return start, None i += 1 - just_norm = just if just in ("left", "center", "right") else "left" return i, { "x": x, "y": y, "text": text_str, - "color": color, "fontsize": fontsize, - "ha": just_norm, "va": "center", "box_color": None, + "color": self._text_color, "fontsize": self._text_fontsize, + "ha": self._text_just, "va": "center", "box_color": None, } + def _skip_blanks(self, nodes, i) -> int: + """Advance past any run of ``BlankOrComment`` nodes at ``i``. + + Used by :meth:`_try_one_text` to tolerate a blank line separating + the elements of a hand-edited text cluster (e.g. between + ``end graph`` and the first cluster, or between two clusters). + Never looks past the end of ``nodes``. + """ + n = len(nodes) + while i < n and isinstance(nodes[i], BlankOrComment): + i += 1 + return i + def _parse_xg_yg(self, toks) -> Tuple[Optional[float], Optional[float]]: """Parse ``amove xg(X) yg(Y)`` argument expressions.""" x = y = None @@ -1701,6 +1899,18 @@ def _populate_axes(self, ax, info): ax.texts = info["texts"] ax.passthrough = info["passthrough"] + # A series whose sidecar had no real header row (hand-written .dat, + # or a headerless import from an older gleplot version) has no + # 'column_names' recovered above. Regenerate the same stable + # defaults Axes.from_dict falls back to for pre-Track-E3 projects, + # so the next save still gets a named header row. + for attr in ("lines", "scatters", "bars", "fills", "errorbars"): + for item in getattr(ax, attr): + if "column_names" not in item: + defaults = Axes._default_column_names(attr, item) + if defaults is not None: + item["column_names"] = defaults + # Legend tri-state recovery. labels_present = self._labels_present(info) if info["key_off"]: diff --git a/src/gleplot/writer.py b/src/gleplot/writer.py index e9b5878..f455506 100644 --- a/src/gleplot/writer.py +++ b/src/gleplot/writer.py @@ -63,6 +63,21 @@ def __init__(self, figsize: Tuple[float, float] = (8, 6), dpi: int = 100, self.data_files = {} # {filename: data_content} self.dataset_index = 1 # Counter for unique dataset names (d1, d2, d3, ...) - GLE is 1-indexed self._pending_graph_text_lines: List[str] = [] + # Sticky GLE interpreter state as far as add_text is concerned: 'set + # hei'/'set color'/'set just' persist across `write` statements until + # changed again (real GLE semantics -- see recognizer._try_one_text, + # the read-side counterpart). Tracking the currently-active emitted + # value here lets add_text skip a redundant 'set ...' line when a + # later text asks for the same value already in effect, instead of + # restating it. Seeded with the preamble's 'set hei' (already emitted + # unconditionally by add_preamble) so the first add_text call also + # skips a redundant 'set hei' when its fontsize matches the style + # default. + self._text_state_hei_cm: Optional[str] = self._format_number( + fontsize_pt_to_cm(self.style.fontsize) + ) + self._text_state_color: str = 'BLACK' + self._text_state_just: str = 'left' def add_preamble(self, include_graph_begin: bool = True, metadata_lines: Optional[List[str]] = None, @@ -298,7 +313,7 @@ def add_data_file(self, filename: str, columns: List[np.ndarray], column_names: Optional[List[str]] = None): """ Add external data file. - + Parameters ---------- filename : str @@ -306,22 +321,39 @@ def add_data_file(self, filename: str, columns: List[np.ndarray], columns : list of arrays Column data column_names : list of str, optional - Column names/headers + Column names/headers, one per entry in ``columns`` (same order). + When given, emitted as a single space-separated header line + before the data rows. Column INDICES used by ``dN=cX,cY`` + references are unaffected by this header -- GLE auto-detects and + skips a non-numeric first row (see ``auto_has_header`` in the + GLE source), so ``c1`` etc. still address the first DATA column. + + Raises + ------ + ValueError + If ``column_names`` is given and its length does not match + ``len(columns)``. """ + if column_names and len(column_names) != len(columns): + raise ValueError( + f"column_names has {len(column_names)} entries but there are " + f"{len(columns)} columns for {filename!r}" + ) + lines = [] - + # Write header if provided if column_names: lines.append(' '.join(column_names)) - + # Convert columns to 2D array data = np.column_stack(columns) - + # Write data rows for row in data: line = ' '.join(self._format_number(val) for val in row) lines.append(line) - + # Add trailing newline for GLE compatibility self.data_files[filename] = '\n'.join(lines) + '\n' @@ -329,10 +361,11 @@ def add_plot_line(self, x: np.ndarray, y: np.ndarray, data_file: str, color: str = 'BLUE', linestyle: str = '-', linewidth: float = 1.0, label: Optional[str] = None, marker: Optional[str] = None, markersize: float = 0.1, - yaxis: str = 'y'): + yaxis: str = 'y', + column_names: Optional[List[str]] = None): """ Add line plot to graph. - + Parameters ---------- x, y : arrays @@ -353,6 +386,11 @@ def add_plot_line(self, x: np.ndarray, y: np.ndarray, data_file: str, Marker size for GLE (msize) yaxis : str Which y-axis to use: 'y' (left, default) or 'y2' (right) + column_names : list of str, optional + Sidecar header row (e.g. ``['x', 'signal']``). When given, an + explicit ``key`` clause is always emitted (the real label, or + ``key ""`` when unlabeled) to neutralize GLE's auto-key-from- + header behavior -- see :meth:`_key_clause`. """ # Sort data by x values (required for GLE smooth lines) # Reference: GLE manual - smooth requires sorted x values @@ -361,9 +399,9 @@ def add_plot_line(self, x: np.ndarray, y: np.ndarray, data_file: str, sorted_indices = np.argsort(x_array) x_sorted = x_array[sorted_indices] y_sorted = y_array[sorted_indices] - + # Add data file with sorted data - self.add_data_file(data_file, [x_sorted, y_sorted]) + self.add_data_file(data_file, [x_sorted, y_sorted], column_names) # Generate plot command with unique dataset name d_name = f'd{self.dataset_index}' @@ -415,20 +453,20 @@ def add_plot_line(self, x: np.ndarray, y: np.ndarray, data_file: str, # Add y2axis directive if using secondary y-axis if yaxis == 'y2': line_cmd += ' y2axis' - - if label: - line_cmd += f' key "{label}"' - + + line_cmd += self._key_clause(label, bool(column_names)) + self.lines_gle.append(line_cmd) - + def add_bar_chart(self, x: np.ndarray, heights: np.ndarray, data_file: str, - colors: Optional[List[str]] = None, label: Optional[str] = None): + colors: Optional[List[str]] = None, label: Optional[str] = None, + column_names: Optional[List[str]] = None): """ Add bar chart to graph. - + Uses a single fill color for all bars due to GLE bar rendering limitations in downstream format conversion. - + Parameters ---------- x : array @@ -442,19 +480,29 @@ def add_bar_chart(self, x: np.ndarray, heights: np.ndarray, data_file: str, the first color is used for all bars. label : str, optional Legend label (not currently supported by GLE for bar charts) + column_names : list of str, optional + Sidecar header row (e.g. ``['x', 'height']``). Unlike the + ``dN ... key "..."`` dataset-display commands, GLE's ``bar`` + command has its own restricted sub-grammar with NO ``key`` + option at all (``bar dN fill COLOR key ""`` is a parse error). + So when a header row is present, an explicit standalone + ``dN key ""`` statement is emitted right after the ``bar`` + command to neutralize GLE's auto-key-from-header behavior + (verified empirically: this statement alone draws nothing, and + makes rendering byte-identical to the headerless case). """ x = np.asarray(x, dtype=float) heights = np.asarray(heights, dtype=float) - + # Default to RED if no colors provided if colors is None: colors = ['RED'] * len(x) - + # GLE reliably supports one fill color per bar dataset. bar_color = colors[0] # Create single data file with all bars - self.add_data_file(data_file, [x, heights]) + self.add_data_file(data_file, [x, heights], column_names) d_name = f'd{self.dataset_index}' self.dataset_index += 1 @@ -463,6 +511,9 @@ def add_bar_chart(self, x: np.ndarray, heights: np.ndarray, data_file: str, bar_cmd = f' bar {d_name} fill {bar_color}' self.lines_gle.append(bar_cmd) + + if column_names and not label: + self.lines_gle.append(f' {d_name} key ""') def add_errorbar(self, x: np.ndarray, y: np.ndarray, data_file: str, color: str = 'BLUE', linestyle: str = '-', @@ -473,7 +524,8 @@ def add_errorbar(self, x: np.ndarray, y: np.ndarray, data_file: str, xerr_left: Optional[np.ndarray] = None, xerr_right: Optional[np.ndarray] = None, capsize: Optional[float] = None, - yaxis: str = 'y'): + yaxis: str = 'y', + column_names: Optional[List[str]] = None): """ Add plot with error bars to graph. @@ -519,6 +571,14 @@ def add_errorbar(self, x: np.ndarray, y: np.ndarray, data_file: str, Width of error bar caps in cm yaxis : str Which y-axis to use: 'y' (left, default) or 'y2' (right) + column_names : list of str, optional + Sidecar header row (e.g. ``['x', 'signal', 'err']``). Only the + MAIN dataset's ``key`` clause needs suppressing when unlabeled + (see :meth:`_key_clause`) -- error sub-datasets (``d{n}=c1,cN`` + referenced via ``err``/``errup``/``herr``/...) are never added + to GLE's key-rendering "used dataset" order on their own, so + they never draw an auto-key regardless of any header-derived + name on their column (verified empirically). """ # Sort data by x values x_array = np.asarray(x) @@ -581,8 +641,8 @@ def add_errorbar(self, x: np.ndarray, y: np.ndarray, data_file: str, col_idx += 1 # Write data file with all columns - self.add_data_file(data_file, columns) - + self.add_data_file(data_file, columns, column_names) + # Generate dataset name for main data d_main = f'd{self.dataset_index}' self.dataset_index += 1 @@ -693,10 +753,9 @@ def add_errorbar(self, x: np.ndarray, y: np.ndarray, data_file: str, # Add y2axis directive if using secondary y-axis if yaxis == 'y2': line_cmd += ' y2axis' - - if label: - line_cmd += f' key "{label}"' - + + line_cmd += self._key_clause(label, bool(column_names)) + self.lines_gle.append(line_cmd) def add_errorbar_from_file( @@ -782,10 +841,11 @@ def add_plot_line_from_file( self.lines_gle.append(line_cmd) def add_fill_between(self, x: np.ndarray, y1: np.ndarray, y2: np.ndarray, - data_file: str, color: str = 'LIGHTBLUE', alpha: float = 1.0): + data_file: str, color: str = 'LIGHTBLUE', alpha: float = 1.0, + column_names: Optional[List[str]] = None): """ Add fill between two curves. - + Parameters ---------- x, y1, y2 : arrays @@ -796,20 +856,34 @@ def add_fill_between(self, x: np.ndarray, y1: np.ndarray, y2: np.ndarray, GLE fill color alpha : float Transparency (not directly supported in GLE, but stored) + column_names : list of str, optional + Sidecar header row (e.g. ``['x', 'upper', 'lower']``). GLE's + ``fill dA,dB color X`` command (like ``bar``) has no ``key`` + option of its own, but the two datasets it references still go + through the generic dataset-key mechanism (they're registered + via the same "used dataset" bookkeeping as any ``dN`` display + command), so a header row would still risk an auto-key on + either one. Neutralize both with standalone ``dN key ""`` + statements when a header row is present (verified empirically + byte-identical to the headerless case). """ - self.add_data_file(data_file, [x, y1, y2]) - + self.add_data_file(data_file, [x, y1, y2], column_names) + # Create two unique dataset names for the fill between operation d1_name = f'd{self.dataset_index}' d2_name = f'd{self.dataset_index + 1}' self.dataset_index += 2 - + cmd = f' data {_format_data_filename(data_file)} {d1_name}=c1,c2 {d2_name}=c1,c3' self.lines_gle.append(cmd) - + # GLE fill between two datasets: fill d1,d2 color X self.lines_gle.append(f' fill {d1_name},{d2_name} color {color}') + if column_names: + self.lines_gle.append(f' {d1_name} key ""') + self.lines_gle.append(f' {d2_name} key ""') + def add_text( self, x: float, @@ -824,12 +898,14 @@ def add_text( escaped_text = self._escape_gle_string(text) if fontsize is not None: - self._pending_graph_text_lines.append( - f'set hei {self._format_number(fontsize_pt_to_cm(float(fontsize)))}' - ) + hei_cm = self._format_number(fontsize_pt_to_cm(float(fontsize))) + if hei_cm != self._text_state_hei_cm: + self._pending_graph_text_lines.append(f'set hei {hei_cm}') + self._text_state_hei_cm = hei_cm - if color: + if color and color != self._text_state_color: self._pending_graph_text_lines.append(f'set color {color}') + self._text_state_color = color just_map = { 'left': 'left', @@ -837,7 +913,9 @@ def add_text( 'right': 'right', } just = just_map.get(str(halign).lower(), 'left') - self._pending_graph_text_lines.append(f'set just {just}') + if just != self._text_state_just: + self._pending_graph_text_lines.append(f'set just {just}') + self._text_state_just = just # Boxed text in graph-data coordinates can produce invalid bounds in # some GLE versions; keep label export robust by emitting plain text. @@ -956,3 +1034,36 @@ def _format_number(val: float, precision: int = 6) -> str: def _escape_gle_string(value: str) -> str: """Escape string for inclusion in GLE quoted text.""" return str(value).replace('"', '\\"') + + @staticmethod + def _key_clause(label: Optional[str], has_header: bool) -> str: + """Build the trailing ``key "..."`` token for a dataset display line. + + GLE auto-detects a non-numeric first row of a data file as a column + header (``auto_has_header`` in the GLE source) and, when it finds + one, copies the header cell for a dataset's own column straight into + that dataset's legend text -- even if the script never writes a + ``key`` clause at all. An explicit ``key "..."`` (including the empty + string ``key ""``) on the ``dN ...`` display line always overrides + that auto-derived text, because GLE parses the ``data`` command + first (setting the auto key) and the dataset's own attribute line + second (whatever it sets wins). Verified empirically: rendering a + labeled dataset is byte-identical with/without a header row, and an + unlabeled dataset with an explicit ``key ""`` also renders + byte-identical with/without a header row (both match the historical + headerless-and-unlabeled rendering); only an unlabeled dataset with + NO explicit key clause changes rendering when a header row is + present (GLE silently invents a legend entry from the header text). + + So: whenever a header row is emitted for this series' data file, + this ALWAYS returns a non-empty clause -- real label if given, else + ``key ""`` to neutralize the auto-key -- to guarantee unchanged + rendering regardless of header presence. When there is no header + row, the historical behavior is preserved exactly: omit the clause + entirely for an unlabeled series (empty string). + """ + if label: + return f' key "{label}"' + if has_header: + return ' key ""' + return '' diff --git a/tests/gui/test_annotation_sync.py b/tests/gui/test_annotation_sync.py new file mode 100644 index 0000000..dbbcbc7 --- /dev/null +++ b/tests/gui/test_annotation_sync.py @@ -0,0 +1,369 @@ +"""Tests for the Texts-panel <-> annotation-overlay selection sync contract. + +Covers the integration point between the two Track-F pieces: + +* ``gleplot/gui/annotations.py`` -- ``AnnotationOverlay.select_annotation`` / + ``selection_changed``. +* ``gleplot/gui/panels/texts_panel.py`` -- ``TextsPanel.select_text`` / + ``current_index`` / ``text_selected``. +* ``gleplot/gui/main_window.py`` -- the wiring between the two. + +These drive the *real* GLE render pipeline on the offscreen Qt platform (the +same async harness pattern as ``test_annotations.py``), since the overlay only +has live ``AnnotationItem``s after a real render lands a calibration geometry. +All figures are synthetic. +""" + +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +pytest.importorskip("PySide6", reason="PySide6 not installed (gui extra)") + +from PySide6.QtCore import QEventLoop, QTimer # noqa: E402 +from PySide6.QtWidgets import QApplication # noqa: E402 + +import gleplot as glp # noqa: E402 +from gleplot.compiler import find_gle # noqa: E402 +from gleplot.gui.annotations import AnnotationOverlay # noqa: E402 +from gleplot.gui.document import FigureDocument # noqa: E402 +from gleplot.gui.main_window import MainWindow # noqa: E402 +from gleplot.gui.panels import TextsPanel # noqa: E402 +from gleplot.gui.preview import PreviewController, PreviewView # noqa: E402 + +_GLE_AVAILABLE = find_gle() is not None + + +@pytest.fixture +def qapp(): + app = QApplication.instance() + if app is None: + app = QApplication([]) + yield app + + +def _wait_until(predicate, timeout_ms=15000): + if predicate(): + return True + loop = QEventLoop() + timed_out = {"value": False} + poll = QTimer() + poll.setInterval(20) + deadline = QTimer() + deadline.setSingleShot(True) + deadline.setInterval(timeout_ms) + + def check(): + if predicate(): + loop.quit() + + def on_deadline(): + timed_out["value"] = True + loop.quit() + + poll.timeout.connect(check) + deadline.timeout.connect(on_deadline) + poll.start() + deadline.start() + loop.exec() + poll.stop() + deadline.stop() + return not timed_out["value"] + + +# ---------------------------------------------------------------------- +# Lightweight harness: document + PNG-mode controller + view + overlay + +# texts panel, wired exactly like MainWindow's _connect_preview_signals. +# ---------------------------------------------------------------------- +class _Harness: + def __init__(self, doc): + self.doc = doc + self.ctrl = PreviewController(doc, debounce_ms=50) + self.ctrl._render_format = "png" + self.view = PreviewView() + self.overlay = AnnotationOverlay(doc, self.view) + self.texts_panel = TextsPanel(doc) + self.renders = [] + self.ctrl.render_succeeded.connect(self._on_succeeded) + self.ctrl.geometry_ready.connect(self.view.set_geometry) + self.ctrl.geometry_ready.connect(self.overlay.set_geometry) + self.ctrl.render_succeeded.connect(self._show_and_rebuild) + + # The sync wiring under test (mirrors MainWindow). + self.texts_panel.text_selected.connect(self._on_panel_selected) + self.overlay.selection_changed.connect(self._on_overlay_selected) + self.panel_selected_calls = [] + self.overlay_selected_calls = [] + + def _on_panel_selected(self, index): + self.panel_selected_calls.append(index) + ax = self.texts_panel.current_axes() + if ax is None: + return + texts = list(getattr(ax, "texts", []) or []) + td = texts[index] if 0 <= index < len(texts) else None + self.overlay.select_annotation(td) + + def _on_overlay_selected(self, text_dict): + self.overlay_selected_calls.append(text_dict) + if text_dict is None: + self.texts_panel.select_text(-1) + return + owning_ax, index = self._find_text_owner(text_dict) + if owning_ax is None: + return + if self.texts_panel.current_axes() is not owning_ax: + self.texts_panel.set_axes(owning_ax) + self.texts_panel.select_text(index) + + def _find_text_owner(self, text_dict): + figure = self.doc.figure + if figure is None: + return None, -1 + for ax in list(getattr(figure, "axes_list", []) or []): + texts = getattr(ax, "texts", None) or [] + for i, td in enumerate(texts): + if td is text_dict: + return ax, i + return None, -1 + + def _on_succeeded(self, path): + self.renders.append(path) + + def _show_and_rebuild(self, path): + self.view.show_image(path) + self.overlay.on_render_succeeded(path) + + def render_and_wait(self, prev_count=None): + target = prev_count if prev_count is not None else len(self.renders) + self.ctrl.request_render() + return _wait_until(lambda: len(self.renders) > target) + + def shutdown(self): + self.ctrl.shutdown() + + +def _doc_with_two_annotations(): + doc = FigureDocument() + fig = glp.Figure(figsize=(4, 3)) + ax = fig.add_subplot(1, 1, 1) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.plot([0, 10], [0, 10], label="line") + ax.text(2.0, 8.0, "alpha") + ax.text(6.0, 3.0, "beta") + doc.set_figure(fig) + return doc + + +def _doc_with_two_axes(): + doc = FigureDocument() + fig = glp.Figure(figsize=(6, 3)) + ax1 = fig.add_subplot(1, 2, 1) + ax1.set_xlim(0, 10) + ax1.set_ylim(0, 10) + ax1.plot([0, 10], [0, 10], label="line1") + ax1.text(2.0, 8.0, "left-text") + + ax2 = fig.add_subplot(1, 2, 2) + ax2.set_xlim(0, 10) + ax2.set_ylim(0, 10) + ax2.plot([0, 10], [10, 0], label="line2") + ax2.text(5.0, 5.0, "right-text") + + doc.set_figure(fig) + return doc + + +# ---------------------------------------------------------------------- +# Panel -> overlay: selecting a row highlights the corresponding item. +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_panel_selection_highlights_overlay_item(qapp): + doc = _doc_with_two_annotations() + h = _Harness(doc) + try: + assert h.render_and_wait() + assert len(h.overlay.items) == 2 + + h.texts_panel.select_text(1) + # select_text is programmatic (no emit), so the sync wiring above is + # NOT triggered by it; drive it explicitly the way a user click would + # (text_list.setCurrentRow while not updating) -- simplest is to call + # the handler that a user-driven change would invoke. + h._on_panel_selected(1) + + beta_item = next(it for it in h.overlay.items if it.text_dict["text"] == "beta") + alpha_item = next(it for it in h.overlay.items if it.text_dict["text"] == "alpha") + assert beta_item.isSelected() is True + assert alpha_item.isSelected() is False + finally: + h.shutdown() + + +# ---------------------------------------------------------------------- +# Overlay -> panel: a user-driven canvas selection updates current_index. +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_overlay_selection_updates_panel_current_index(qapp): + doc = _doc_with_two_annotations() + h = _Harness(doc) + try: + assert h.render_and_wait() + beta_item = next(it for it in h.overlay.items if it.text_dict["text"] == "beta") + + # Simulate the *user* path: toggle Qt selection directly on the item + # (this is exactly what Qt's selection machinery does on a real + # mouse-press -- see AnnotationItem.mousePressEvent's setSelected(True) + # call), which fires itemChange(ItemSelectedHasChanged) unguarded and + # therefore routes to AnnotationOverlay._on_item_selection_changed. + beta_item.setSelected(True) + + assert h.overlay_selected_calls[-1] is beta_item.text_dict + assert h.texts_panel.current_index == 1 + finally: + h.shutdown() + + +# ---------------------------------------------------------------------- +# No infinite loop: bounded signal counts in both directions. +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_selection_sync_has_no_loop(qapp): + doc = _doc_with_two_annotations() + h = _Harness(doc) + try: + assert h.render_and_wait() + + # Panel-driven selection: exactly one panel->overlay call, and the + # overlay's no-emit select_annotation() must not produce any + # overlay->panel callback. + h.panel_selected_calls.clear() + h.overlay_selected_calls.clear() + h.texts_panel.select_text(1) + h._on_panel_selected(1) + assert len(h.overlay_selected_calls) == 0 + + # Overlay-driven (user) selection: exactly one selection_changed + # emission, and the panel's no-emit select_text() must not produce + # any panel->overlay callback. + h.panel_selected_calls.clear() + h.overlay_selected_calls.clear() + alpha_item = next(it for it in h.overlay.items if it.text_dict["text"] == "alpha") + alpha_item.setSelected(True) + assert len(h.overlay_selected_calls) == 1 + assert len(h.panel_selected_calls) == 0 + + # Deselecting also bounded: one call, dict is None. + h.overlay_selected_calls.clear() + alpha_item.setSelected(False) + assert len(h.overlay_selected_calls) == 1 + assert h.overlay_selected_calls[-1] is None + assert h.panel_selected_calls == [] + finally: + h.shutdown() + + +# ---------------------------------------------------------------------- +# Selection survives a rebuild (re-render replaces AnnotationItem instances). +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_selection_survives_rebuild(qapp): + doc = _doc_with_two_annotations() + h = _Harness(doc) + try: + assert h.render_and_wait() + beta_dict = doc.figure.axes_list[0].texts[1] + h.overlay.select_annotation(beta_dict) + + old_beta_item = next( + it for it in h.overlay.items if it.text_dict is beta_dict + ) + assert old_beta_item.isSelected() is True + + # Trigger a rebuild (a fresh render): items are recreated, but the + # dict identity is unchanged, so the remembered selection re-applies. + n_before = len(h.renders) + doc.notify_changed() + assert _wait_until(lambda: len(h.renders) > n_before) + + new_beta_item = next( + it for it in h.overlay.items if it.text_dict is beta_dict + ) + assert new_beta_item is not old_beta_item # rebuild really replaced it + assert new_beta_item.isSelected() is True + + alpha_item = next( + it for it in h.overlay.items if it.text_dict["text"] == "alpha" + ) + assert alpha_item.isSelected() is False + finally: + h.shutdown() + + +# ---------------------------------------------------------------------- +# Cross-axes selection retargets the panel. +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_cross_axes_selection_retargets_panel(qapp): + doc = _doc_with_two_axes() + h = _Harness(doc) + try: + assert h.render_and_wait() + assert len(h.overlay.items) == 2 + + left_ax = doc.figure.axes_list[0] + right_ax = doc.figure.axes_list[1] + + # Panel initially targets gca() (the last-added axes == right_ax). + h.texts_panel.set_axes(right_ax) + assert h.texts_panel.current_axes() is right_ax + + # Select the item that lives on the OTHER axes (left_ax). + left_item = next( + it for it in h.overlay.items if it.text_dict["text"] == "left-text" + ) + left_item.setSelected(True) + + # The panel must have been retargeted to left_ax and now shows index 0. + assert h.texts_panel.current_axes() is left_ax + assert h.texts_panel.current_index == 0 + finally: + h.shutdown() + + +# ---------------------------------------------------------------------- +# Full MainWindow smoke: everything constructs and wires without error. +# ---------------------------------------------------------------------- +def test_main_window_texts_tab_and_wiring_smoke(qapp): + window = MainWindow() + try: + # Texts tab present, positioned after Series and before Raw GLE. + tabs = window.properties_tabs + labels = [tabs.tabText(i) for i in range(tabs.count())] + assert "Texts" in labels + series_idx = labels.index("Series") + texts_idx = labels.index("Texts") + raw_idx = labels.index("Raw GLE") + assert series_idx < texts_idx < raw_idx + assert tabs.widget(texts_idx) is window.texts_panel + + # Signals exist and are connected (no exceptions constructing/wiring). + assert hasattr(window.annotation_overlay, "selection_changed") + assert hasattr(window.texts_panel, "text_selected") + + # Retargeting wiring: layout_panel.axes_selected also reaches texts_panel. + fig = glp.Figure(figsize=(4, 3)) + ax = fig.add_subplot(1, 1, 1) + ax.text(1.0, 1.0, "hello") + window.document.set_figure(fig) + window.layout_panel.axes_selected.emit(ax) + assert window.texts_panel.current_axes() is ax + finally: + window.preview_controller.shutdown() + window.deleteLater() diff --git a/tests/gui/test_annotations.py b/tests/gui/test_annotations.py new file mode 100644 index 0000000..225a2e4 --- /dev/null +++ b/tests/gui/test_annotations.py @@ -0,0 +1,561 @@ +"""Tests for the interactive annotation overlay (Track F1). + +These drive the *real* GLE render pipeline on the offscreen Qt platform (the +same async pattern as ``test_calibration_integration.py``) and exercise the +overlay's placement accuracy, drag/edit/delete/add flows, enable/disable +behaviour, and log-axis inverse. All figures are synthetic. + +The overlay is tested in PNG render mode: ``RasterViewMapping`` maps +``cm <-> scene`` directly from the installed :class:`PreviewGeometry` (no SVG +file to load), which keeps the position math deterministic and independent of +the SVG backend's availability. The overlay code path itself is identical for +both mappings. +""" + +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +pytest.importorskip("PySide6", reason="PySide6 not installed (gui extra)") + +from PySide6.QtCore import QEventLoop, QPointF, QTimer # noqa: E402 +from PySide6.QtWidgets import QApplication # noqa: E402 + +import gleplot as glp # noqa: E402 +from gleplot.compiler import find_gle # noqa: E402 +from gleplot.gui.annotations import AnnotationOverlay # noqa: E402 +from gleplot.gui.document import FigureDocument # noqa: E402 +from gleplot.gui.preview import PreviewController, PreviewView # noqa: E402 + +_GLE_AVAILABLE = find_gle() is not None + +#: Scene-position tolerance (px). GLE prints calibration to ~2-3 sig figs, and +#: our hit-rect is a generous approximation, so a few px is expected slack. +_SCENE_TOL = 8.0 +#: Data-coordinate tolerance for inverse-mapped drops. +_DATA_TOL = 0.15 + + +@pytest.fixture +def qapp(): + app = QApplication.instance() + if app is None: + app = QApplication([]) + yield app + + +def _wait_until(predicate, timeout_ms=15000): + if predicate(): + return True + loop = QEventLoop() + timed_out = {"value": False} + poll = QTimer() + poll.setInterval(20) + deadline = QTimer() + deadline.setSingleShot(True) + deadline.setInterval(timeout_ms) + + def check(): + if predicate(): + loop.quit() + + def on_deadline(): + timed_out["value"] = True + loop.quit() + + poll.timeout.connect(check) + deadline.timeout.connect(on_deadline) + poll.start() + deadline.start() + loop.exec() + poll.stop() + deadline.stop() + return not timed_out["value"] + + +class _Harness: + """A document + PNG-mode controller + view + overlay, wired like the window.""" + + def __init__(self, doc): + self.doc = doc + self.ctrl = PreviewController(doc, debounce_ms=50) + # Force PNG so RasterViewMapping is used deterministically. + self.ctrl._render_format = "png" + self.view = PreviewView() + self.overlay = AnnotationOverlay(doc, self.view) + self.renders = [] + self.ctrl.render_succeeded.connect(self._on_succeeded) + self.ctrl.geometry_ready.connect(self.view.set_geometry) + self.ctrl.geometry_ready.connect(self.overlay.set_geometry) + self.ctrl.render_succeeded.connect(self._show_and_rebuild) + + def _on_succeeded(self, path): + self.renders.append(path) + + def _show_and_rebuild(self, path): + # Mirror the main window: show the image (updates view_mapping), then + # the overlay rebuilds against the fresh render. + self.view.show_image(path) + self.overlay.on_render_succeeded(path) + + def render_and_wait(self, prev_count=None): + target = (prev_count if prev_count is not None else len(self.renders)) + self.ctrl.request_render() + return _wait_until(lambda: len(self.renders) > target) + + def shutdown(self): + self.ctrl.shutdown() + + +def _doc_with_annotation(x=6.0, y=0.0, text="hello", **text_kw): + doc = FigureDocument() + fig = glp.Figure(figsize=(4, 3)) + ax = fig.add_subplot(1, 1, 1) + ax.set_xlim(2, 12) + ax.set_ylim(-1, 1) + ax.plot([2, 12], [-1, 1], label="line") + ax.text(x, y, text, **text_kw) + doc.set_figure(fig) + return doc + + +# ---------------------------------------------------------------------- +# Position accuracy +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_item_lands_at_known_data_coords(qapp): + doc = _doc_with_annotation(x=6.0, y=0.0, text="hello") + h = _Harness(doc) + try: + assert h.render_and_wait() + assert h.overlay.enabled + assert len(h.overlay.items) == 1 + item = h.overlay.items[0] + + # Expected scene pos: data -> cm -> scene, via the same contracts. + geom = h.ctrl.last_geometry + mapping = h.view.view_mapping() + cal = geom.axes[0] + cx, cy = cal.data_to_cm(6.0, 0.0) + vx, vy = mapping.cm_to_view(cx, cy) + + assert item.pos().x() == pytest.approx(vx, abs=_SCENE_TOL) + assert item.pos().y() == pytest.approx(vy, abs=_SCENE_TOL) + finally: + h.shutdown() + + +# ---------------------------------------------------------------------- +# Drag: model updated + no jump on the follow-up render +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_drag_updates_model_and_no_jump(qapp): + doc = _doc_with_annotation(x=6.0, y=0.0, text="drag") + h = _Harness(doc) + notify_count = {"n": 0} + doc.figure_changed.connect(lambda: notify_count.__setitem__("n", notify_count["n"] + 1)) + try: + assert h.render_and_wait() + item = h.overlay.items[0] + + # Target a new data point (10, 0.5); compute its scene pos and move + # the item there, then commit via the overlay (the tested path). + mapping = h.view.view_mapping() + cal = h.ctrl.last_geometry.axes[0] + tcx, tcy = cal.data_to_cm(10.0, 0.5) + tvx, tvy = mapping.cm_to_view(tcx, tcy) + item.sync_position(QPointF(tvx, tvy)) + + n_before = len(h.renders) + h.overlay.commit_item_move(item) + + # Model updated to the target data coords (inverse transform). + td = doc.figure.axes_list[0].texts[0] + assert td["x"] == pytest.approx(10.0, abs=_DATA_TOL) + assert td["y"] == pytest.approx(0.5, abs=_DATA_TOL) + # Exactly one notify (one undo step) from the commit. + assert notify_count["n"] == 1 + + # The follow-up render rebuilds the item at the dropped position: no + # jump. Capture the dropped scene pos, wait for the re-render, compare. + dropped = QPointF(item.pos()) + assert _wait_until(lambda: len(h.renders) > n_before) + rebuilt = h.overlay.items[0] + assert rebuilt.pos().x() == pytest.approx(dropped.x(), abs=_SCENE_TOL) + assert rebuilt.pos().y() == pytest.approx(dropped.y(), abs=_SCENE_TOL) + finally: + h.shutdown() + + +# ---------------------------------------------------------------------- +# Inline edit: change text, and empty-commit deletes +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_edit_commit_updates_text(qapp): + doc = _doc_with_annotation(text="old") + h = _Harness(doc) + try: + assert h.render_and_wait() + item = h.overlay.items[0] + n_before = len(h.renders) + h.overlay.commit_item_text(item, "new text") + assert doc.figure.axes_list[0].texts[0]["text"] == "new text" + assert _wait_until(lambda: len(h.renders) > n_before) + assert h.overlay.items[0].text_dict["text"] == "new text" + finally: + h.shutdown() + + +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_empty_edit_commit_deletes(qapp): + doc = _doc_with_annotation(text="bye") + h = _Harness(doc) + try: + assert h.render_and_wait() + item = h.overlay.items[0] + assert len(doc.figure.axes_list[0].texts) == 1 + h.overlay.commit_item_text(item, " ") + assert doc.figure.axes_list[0].texts == [] + finally: + h.shutdown() + + +# ---------------------------------------------------------------------- +# Delete +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_delete_item_removes_from_model(qapp): + doc = _doc_with_annotation(text="rm") + h = _Harness(doc) + try: + assert h.render_and_wait() + item = h.overlay.items[0] + h.overlay.delete_item(item) + assert doc.figure.axes_list[0].texts == [] + assert h.overlay.items == [] + finally: + h.shutdown() + + +# ---------------------------------------------------------------------- +# Add-text flow (synthetic click) +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_add_text_appends_correct_dict(qapp): + doc = FigureDocument() + fig = glp.Figure(figsize=(4, 3)) + ax = fig.add_subplot(1, 1, 1) + ax.set_xlim(2, 12) + ax.set_ylim(-1, 1) + ax.plot([2, 12], [-1, 1], label="line") + doc.set_figure(fig) + + h = _Harness(doc) + placed = {"n": 0} + h.overlay.add_text_placed.connect(lambda: placed.__setitem__("n", placed["n"] + 1)) + try: + assert h.render_and_wait() + assert h.overlay.enabled + assert len(doc.figure.axes_list[0].texts) == 0 + + h.overlay.begin_add_text() + assert h.overlay.add_mode + + # Click at the scene position of data (7, 0.0). + mapping = h.view.view_mapping() + cal = h.ctrl.last_geometry.axes[0] + cx, cy = cal.data_to_cm(7.0, 0.0) + vx, vy = mapping.cm_to_view(cx, cy) + assert h.overlay._place_text_at(QPointF(vx, vy)) is True + + texts = doc.figure.axes_list[0].texts + assert len(texts) == 1 + td = texts[0] + # Schema matches Axes.text() exactly. + assert set(td.keys()) == { + "x", "y", "text", "color", "fontsize", "ha", "va", "box_color" + } + assert td["text"] == "text" + assert td["color"] == "BLACK" + assert td["fontsize"] is None + assert td["ha"] == "left" + assert td["va"] == "center" + assert td["box_color"] is None + assert td["x"] == pytest.approx(7.0, abs=_DATA_TOL) + assert td["y"] == pytest.approx(0.0, abs=_DATA_TOL) + assert placed["n"] == 1 + assert not h.overlay.add_mode + finally: + h.shutdown() + + +# ---------------------------------------------------------------------- +# Disable on None geometry; re-enable on next good geometry +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_overlay_disables_on_none_geometry_and_reenables(qapp): + doc = _doc_with_annotation(text="tog") + h = _Harness(doc) + states = [] + h.overlay.overlay_enabled_changed.connect(states.append) + try: + assert h.render_and_wait() + assert h.overlay.enabled + assert h.overlay.items + + # Simulate a failed render / parse failure: geometry_ready(None). + h.overlay.set_geometry(None) + assert h.overlay.enabled is False + assert h.overlay.items == [] + assert h.view.annotations_enabled is False + + # A subsequent good render re-enables and rebuilds. + n_before = len(h.renders) + doc.notify_changed() + assert _wait_until(lambda: len(h.renders) > n_before) + assert h.overlay.enabled is True + assert h.overlay.items + assert states[-1] is True + finally: + h.shutdown() + + +# ---------------------------------------------------------------------- +# Log axis: drag maps to correct data coords (exercises log inverse) +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_log_axis_drag_correct_data_coords(qapp): + doc = FigureDocument() + fig = glp.Figure(figsize=(4, 3)) + ax = fig.add_subplot(1, 1, 1) + ax.set_xscale("log") + ax.set_yscale("log") + ax.set_xlim(1, 100) + ax.set_ylim(0.1, 1000) + ax.plot([1, 100], [0.1, 1000], label="log") + ax.text(10.0, 10.0, "logtext") + doc.set_figure(fig) + + h = _Harness(doc) + try: + assert h.render_and_wait() + item = h.overlay.items[0] + cal = h.ctrl.last_geometry.axes[0] + assert cal.x_log and cal.y_log + + # Drag to data (50, 100) -- a geometric-space target. + mapping = h.view.view_mapping() + tcx, tcy = cal.data_to_cm(50.0, 100.0) + tvx, tvy = mapping.cm_to_view(tcx, tcy) + item.sync_position(QPointF(tvx, tvy)) + h.overlay.commit_item_move(item) + + td = doc.figure.axes_list[0].texts[0] + # Log inverse: allow a few percent relative slack. + assert td["x"] == pytest.approx(50.0, rel=0.05) + assert td["y"] == pytest.approx(100.0, rel=0.05) + finally: + h.shutdown() + + +# ---------------------------------------------------------------------- +# Mid-render drag: an active drag is not teleported by a rebuild +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_active_drag_not_rebuilt_midrender(qapp): + doc = _doc_with_annotation(x=6.0, y=0.0, text="mid") + h = _Harness(doc) + try: + assert h.render_and_wait() + item = h.overlay.items[0] + + # Simulate an in-progress drag: mark interacting + move to an arbitrary + # scene position the model does NOT reflect. + item._dragging = True + held = QPointF(37.0, 42.0) + item.sync_position(held) + + # A render lands (e.g. from a prior debounce): rebuild must preserve the + # interacting item at its held position, not snap it to the model coords. + h.overlay.on_render_succeeded(h.renders[-1]) + assert item in h.overlay.items + assert item.pos().x() == pytest.approx(held.x()) + assert item.pos().y() == pytest.approx(held.y()) + finally: + item._dragging = False + h.shutdown() + + +# ---------------------------------------------------------------------- +# Pan-vs-drag: hover suspends ScrollHandDrag, leave restores it +# ---------------------------------------------------------------------- +def test_suspend_pan_toggles_view_drag_mode(qapp): + from PySide6.QtWidgets import QGraphicsView + + view = PreviewView() + assert view.dragMode() == QGraphicsView.DragMode.ScrollHandDrag + view.suspend_pan(True) + assert view.dragMode() == QGraphicsView.DragMode.NoDrag + view.suspend_pan(False) + assert view.dragMode() == QGraphicsView.DragMode.ScrollHandDrag + # Idempotent: double-suspend / double-restore are safe. + view.suspend_pan(True) + view.suspend_pan(True) + assert view.dragMode() == QGraphicsView.DragMode.NoDrag + view.suspend_pan(False) + assert view.dragMode() == QGraphicsView.DragMode.ScrollHandDrag + + +def test_overlay_disabled_without_geometry(qapp): + """With no geometry ever installed, the overlay stays disabled (no GLE).""" + doc = _doc_with_annotation() + view = PreviewView() + overlay = AnnotationOverlay(doc, view) + assert overlay.enabled is False + assert overlay.items == [] + # begin_add_text is a no-op while disabled. + overlay.begin_add_text() + assert overlay.add_mode is False + + +# ---------------------------------------------------------------------- +# Finding 3: figure_replaced (undo) mid-drag must not leave a phantom item +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_figure_replaced_midgesture_aborts_and_no_phantom(qapp): + doc = _doc_with_annotation(x=6.0, y=0.0, text="phantom") + h = _Harness(doc) + # Wire figure_replaced -> overlay abort slot, as the main window does. + doc.figure_replaced.connect(h.overlay.on_figure_replaced) + try: + assert h.render_and_wait() + assert len(h.overlay.items) == 1 + item = h.overlay.items[0] + + # Begin a drag: mark interacting and move to a scene pos the model does + # not reflect (mid-gesture, no commit yet). + item._dragging = True + item._interaction_fingerprint = h.overlay.current_mapping_fingerprint() + item.sync_position(QPointF(37.0, 42.0)) + assert item.is_interacting + + # An undo lands mid-drag: figure_replaced fires. The overlay must ABORT + # the interaction (discard ghost, no commit) and clear all items. + doc.figure_replaced.emit() + assert item.is_interacting is False + assert h.overlay.items == [] + + # The follow-up render rebuilds against the (unchanged) model: exactly + # one item, none interacting, no phantom. + n_before = len(h.renders) + doc.notify_changed() + assert _wait_until(lambda: len(h.renders) > n_before) + assert len(h.overlay.items) == 1 + assert not any(it.is_interacting for it in h.overlay.items) + + # A further clean render still yields exactly one item (no accumulation). + n_before = len(h.renders) + doc.notify_changed() + assert _wait_until(lambda: len(h.renders) > n_before) + assert len(h.overlay.items) == 1 + finally: + h.shutdown() + + +# ---------------------------------------------------------------------- +# Finding 4: a mapping change mid-drag aborts the interaction (no jumped commit) +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_mapping_change_midgesture_aborts_no_committed_jump(qapp): + doc = _doc_with_annotation(x=6.0, y=0.0, text="fmt") + h = _Harness(doc) + try: + assert h.render_and_wait() + item = h.overlay.items[0] + model_x0 = doc.figure.axes_list[0].texts[0]["x"] + model_y0 = doc.figure.axes_list[0].texts[0]["y"] + + # Begin a drag under the current mapping; move somewhere arbitrary. + item._dragging = True + item._interaction_fingerprint = h.overlay.current_mapping_fingerprint() + assert item._interaction_fingerprint is not None + item.sync_position(QPointF(11.0, 13.0)) + + # Simulate a format/scale change: install a geometry with a DIFFERENT + # dpi so the RasterViewMapping fingerprint changes underneath the drag. + from gleplot.gui.geometry import PreviewGeometry + + geom = h.ctrl.last_geometry + changed = PreviewGeometry( + page_size_cm=geom.page_size_cm, dpi=geom.dpi + 37, axes=geom.axes + ) + h.view.set_geometry(changed) + assert h.overlay.current_mapping_fingerprint() != item._interaction_fingerprint + + # A rebuild now (as a render would trigger) must ABORT the interaction + # rather than preserve the stale scene position. + h.overlay.set_geometry(changed) # keep overlay geometry consistent + h.overlay.rebuild() + assert item.is_interacting is False + + # Release after the abort must commit NO change: the item is no longer + # dragging, and the model coords are exactly what they were. + rebuilt = h.overlay.items[0] + h.overlay.commit_item_move(rebuilt) # rebuilt sits at model position + assert doc.figure.axes_list[0].texts[0]["x"] == pytest.approx(model_x0) + assert doc.figure.axes_list[0].texts[0]["y"] == pytest.approx(model_y0) + finally: + item._dragging = False + h.shutdown() + + +# ---------------------------------------------------------------------- +# Finding 8: committing an item whose dict was removed elsewhere is a no-op +# ---------------------------------------------------------------------- +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_commit_on_orphaned_dict_drops_item_no_notify(qapp): + doc = _doc_with_annotation(text="orphan") + h = _Harness(doc) + notify_count = {"n": 0} + doc.figure_changed.connect( + lambda: notify_count.__setitem__("n", notify_count["n"] + 1) + ) + try: + assert h.render_and_wait() + item = h.overlay.items[0] + td = item.text_dict + + # The annotation is removed via a different path (e.g. the Texts panel + # Remove button): its dict leaves the model while the overlay item and + # any open inline edit still reference it. + doc.figure.axes_list[0].texts.remove(td) + assert td not in doc.figure.axes_list[0].texts + + n0 = notify_count["n"] + # A pending commit on the now-orphaned item must be a silent no-op: + # no mutation, no notify (no undo step burned), and the item is dropped. + h.overlay.commit_item_text(item, "late edit") + assert notify_count["n"] == n0 + assert item not in h.overlay.items + + # commit_item_move on an orphan is likewise inert. + item2 = None + # Re-add a fresh annotation + item to exercise move-on-orphan too. + doc.figure.axes_list[0].texts.append(dict(td, text="second")) + td2 = doc.figure.axes_list[0].texts[-1] + from gleplot.gui.annotations import AnnotationItem + + item2 = AnnotationItem(h.overlay, td2, item.cal) + h.overlay._items.append(item2) + doc.figure.axes_list[0].texts.remove(td2) + n1 = notify_count["n"] + h.overlay.commit_item_move(item2) + assert notify_count["n"] == n1 + assert item2 not in h.overlay.items + finally: + h.shutdown() diff --git a/tests/gui/test_calibration_integration.py b/tests/gui/test_calibration_integration.py new file mode 100644 index 0000000..1ad3646 --- /dev/null +++ b/tests/gui/test_calibration_integration.py @@ -0,0 +1,288 @@ +"""Integration tests for preview calibration (Track E1). + +These drive a *real* GLE compile through :class:`PreviewController` on the +offscreen Qt platform and assert that the ``gleplot-cal`` protocol produces a +:class:`PreviewGeometry` whose cm rectangles match GLE's own reported values. +They skip cleanly when PySide6 is unavailable and are ``xfail`` when GLE is not +installed (the pipeline is correct but cannot compile). +""" + +import os +import sys +from pathlib import Path + +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +pytest.importorskip("PySide6", reason="PySide6 not installed (gui extra)") + +from PySide6.QtCore import QEventLoop, QTimer # noqa: E402 +from PySide6.QtWidgets import QApplication # noqa: E402 + +import gleplot as glp # noqa: E402 +from gleplot.compiler import find_gle # noqa: E402 +from gleplot.gui.document import FigureDocument # noqa: E402 +from gleplot.gui.geometry import PreviewGeometry # noqa: E402 +from gleplot.gui.preview import PreviewController # noqa: E402 + +_GLE_AVAILABLE = find_gle() is not None + +# Tolerance for comparing our computed cm values against GLE's printed values. +# GLE prints with ~2-3 significant figures (e.g. "4.33"), so 0.05 cm is safe. +_CM_TOL = 0.05 + + +@pytest.fixture +def qapp(): + app = QApplication.instance() + if app is None: + app = QApplication([]) + yield app + + +class GeomRecorder: + """Records render_succeeded / geometry_ready ordering and payloads.""" + + def __init__(self, controller): + self.succeeded = [] + self.failed = [] + self.geometries = [] + self.events = [] # ordered ("geom"|"success", payload) + controller.render_succeeded.connect(self._on_succeeded) + controller.render_failed.connect(self._on_failed) + controller.geometry_ready.connect(self._on_geometry) + + def _on_succeeded(self, path): + self.succeeded.append(path) + self.events.append(("success", path)) + + def _on_failed(self, errors, raw): + self.failed.append((errors, raw)) + + def _on_geometry(self, geom): + self.geometries.append(geom) + self.events.append(("geom", geom)) + + +def _wait_until(predicate, timeout_ms=10000): + loop = QEventLoop() + timed_out = {"value": False} + poll = QTimer() + poll.setInterval(20) + deadline = QTimer() + deadline.setSingleShot(True) + deadline.setInterval(timeout_ms) + + def check(): + if predicate(): + loop.quit() + + def on_deadline(): + timed_out["value"] = True + loop.quit() + + poll.timeout.connect(check) + deadline.timeout.connect(on_deadline) + poll.start() + deadline.start() + if predicate(): + return True + loop.exec() + poll.stop() + deadline.stop() + return not timed_out["value"] + + +def _single_axes_doc(): + doc = FigureDocument() + fig = glp.Figure(figsize=(4, 3)) + ax = fig.add_subplot(1, 1, 1) + ax.set_xlim(2, 12) + ax.set_ylim(-1, 1) + ax.plot([2, 12], [-1, 1], label="line") + doc.set_figure(fig) + return doc + + +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_single_axes_calibration_matches_gle(qapp): + doc = _single_axes_doc() + ctrl = PreviewController(doc, debounce_ms=50) + rec = GeomRecorder(ctrl) + try: + ctrl.request_render() + assert _wait_until(lambda: rec.succeeded or rec.failed, 15000) + assert not rec.failed, rec.failed + assert rec.succeeded + + geom = ctrl.last_geometry + assert isinstance(geom, PreviewGeometry) + assert len(geom.axes) == 1 + cal = geom.axes[0] + assert cal.index == 0 + assert cal.x_range == pytest.approx((2.0, 12.0)) + assert cal.y_range == pytest.approx((-1.0, 1.0)) + + # data_to_cm of the low corner equals GLE's reported (x0, y0) corner. + cx, cy = cal.data_to_cm(2.0, -1.0) + assert cx == pytest.approx(cal.cm_rect[0], abs=1e-9) + assert cy == pytest.approx(cal.cm_rect[1], abs=1e-9) + # High corner too. + cx, cy = cal.data_to_cm(12.0, 1.0) + assert cx == pytest.approx(cal.cm_rect[2], abs=1e-9) + assert cy == pytest.approx(cal.cm_rect[3], abs=1e-9) + + # Page size from figsize (4x3 in -> cm). + assert geom.page_size_cm[0] == pytest.approx(4 * 2.54) + assert geom.page_size_cm[1] == pytest.approx(3 * 2.54) + assert geom.dpi == ctrl.preview_dpi + finally: + ctrl.shutdown() + + +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_geometry_ready_fires_before_render_succeeded(qapp): + doc = _single_axes_doc() + ctrl = PreviewController(doc, debounce_ms=50) + rec = GeomRecorder(ctrl) + try: + ctrl.request_render() + assert _wait_until(lambda: rec.succeeded or rec.failed, 15000) + assert not rec.failed, rec.failed + # The first two ordered events must be geom then success. + kinds = [k for k, _ in rec.events] + assert kinds[0] == "geom" + assert kinds[1] == "success" + finally: + ctrl.shutdown() + + +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_subplot_grid_yields_four_distinct_calibrations(qapp): + doc = FigureDocument() + fig = glp.Figure(figsize=(6, 5)) + data = { + 1: ([1, 2, 3], [1, 4, 9]), + 2: ([1, 2, 3], [9, 4, 1]), + 3: ([1, 2, 3], [2, 5, 2]), + 4: ([1, 2, 3], [3, 1, 3]), + } + for idx, (x, y) in data.items(): + ax = fig.add_subplot(2, 2, idx) + ax.plot(x, y, label=f"s{idx}") + doc.set_figure(fig) + + ctrl = PreviewController(doc, debounce_ms=50) + rec = GeomRecorder(ctrl) + try: + ctrl.request_render() + assert _wait_until(lambda: rec.succeeded or rec.failed, 15000) + assert not rec.failed, rec.failed + + geom = ctrl.last_geometry + assert geom is not None + assert len(geom.axes) == 4 + assert [c.index for c in geom.axes] == [0, 1, 2, 3] + + # All four boxes are distinct. + rects = [c.cm_rect for c in geom.axes] + assert len(set(rects)) == 4 + + # Row-major order: subplot 1 (top-left) has the largest y (top of page) + # and subplot 3 (bottom-left) a smaller y; both share the left x. + # cm_rect[3] is the top (high-y) cm edge. + top_left = geom.axes[0].cm_rect + bottom_left = geom.axes[2].cm_rect + assert top_left[0] == pytest.approx(bottom_left[0], abs=_CM_TOL) # same x0 + assert top_left[3] > bottom_left[3] # top row is higher on the page + finally: + ctrl.shutdown() + + +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_log_scale_calibration_maps_geometric_midpoint(qapp): + doc = FigureDocument() + fig = glp.Figure(figsize=(4, 3)) + ax = fig.add_subplot(1, 1, 1) + ax.set_xscale("log") + ax.set_yscale("log") + ax.set_xlim(1, 100) + ax.set_ylim(0.1, 1000) + ax.plot([1, 100], [0.1, 1000], label="log") + doc.set_figure(fig) + + ctrl = PreviewController(doc, debounce_ms=50) + rec = GeomRecorder(ctrl) + try: + ctrl.request_render() + assert _wait_until(lambda: rec.succeeded or rec.failed, 15000) + assert not rec.failed, rec.failed + + geom = ctrl.last_geometry + assert geom is not None + cal = geom.axes[0] + assert cal.x_log is True + assert cal.y_log is True + + # Geometric midpoint of x (10) maps to the cm midpoint of the box. + cx, _ = cal.data_to_cm(10.0, 1.0) + assert cx == pytest.approx((cal.cm_rect[0] + cal.cm_rect[2]) / 2, abs=1e-6) + # geomean(0.1, 1000) = 10 -> cm midpoint of y box. + _, cy = cal.data_to_cm(1.0, 10.0) + assert cy == pytest.approx((cal.cm_rect[1] + cal.cm_rect[3]) / 2, abs=1e-6) + finally: + ctrl.shutdown() + + +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_calibration_absent_still_renders_with_none_geometry(qapp, monkeypatch): + """If no calibration prints are injected, render still succeeds and + geometry_ready(None) fires -- calibration never blocks a render.""" + doc = _single_axes_doc() + ctrl = PreviewController(doc, debounce_ms=50) + rec = GeomRecorder(ctrl) + + # Skip the injection step: the script compiles fine but emits no cal lines. + monkeypatch.setattr(PreviewController, "_inject_calibration", staticmethod(lambda p: None)) + + try: + ctrl.request_render() + assert _wait_until(lambda: rec.succeeded or rec.failed, 15000) + assert not rec.failed, rec.failed + assert rec.succeeded # render still fires + assert ctrl.last_geometry is None + assert rec.geometries and rec.geometries[-1] is None + finally: + ctrl.shutdown() + + +def test_preview_script_never_in_user_saves(qapp, tmp_path): + """User-facing saves go through the public figure API, which is untouched; + they must never contain the preview-only 'gleplot-cal' marker.""" + fig = glp.Figure(figsize=(4, 3)) + ax = fig.add_subplot(1, 1, 1) + ax.plot([1, 2, 3], [1, 4, 9], label="s") + out = tmp_path / "user.gle" + fig.savefig_gle(str(out)) + assert "gleplot-cal" not in out.read_text(encoding="utf-8") + + +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_injection_present_in_preview_but_not_saved(qapp, tmp_path): + """The preview temp script DOES contain the marker; the injection helper is + what the controller applies to its private copy.""" + fig = glp.Figure(figsize=(4, 3)) + ax = fig.add_subplot(1, 1, 1) + ax.plot([1, 2, 3], [1, 4, 9], label="s") + script = tmp_path / "preview.gle" + fig.savefig_gle(str(script)) + assert "gleplot-cal" not in script.read_text(encoding="utf-8") + # Apply the controller's private injection to the temp copy. + PreviewController._inject_calibration(script) + injected = script.read_text(encoding="utf-8") + assert 'print "gleplot-cal 0 ' in injected + # Exactly one graph block -> exactly one calibration print. + assert injected.count("gleplot-cal") == 1 diff --git a/tests/gui/test_data_loader.py b/tests/gui/test_data_loader.py index 2fc7fdb..2882af3 100644 --- a/tests/gui/test_data_loader.py +++ b/tests/gui/test_data_loader.py @@ -196,3 +196,122 @@ def test_numeric_header_row_not_misdetected(tmp_path): table = load_data_file(p) assert table.has_header is False assert table.n_rows == 2 + + +# --------------------------------------------------------------------------- +# Finding 2: an all-missing first row is DATA (all-NaN), not a header. +# --------------------------------------------------------------------------- + + +def test_all_missing_first_row_is_data_not_header(tmp_path): + # First row '* * *' is entirely missing-value tokens -> it must be kept as + # an all-NaN DATA row, NOT consumed as a header (regression: missing tokens + # fail float conversion, but a missing token is not a real label). + p = _write(tmp_path, "a.dat", "* * *\n1 2 3\n4 5 6\n") + table = load_data_file(p) + assert table.has_header is False + assert table.n_rows == 3 + assert table.n_cols == 3 + # First row is all NaN, preserved as data. + for col in table.columns: + assert np.isnan(col[0]) + np.testing.assert_allclose(table.columns[0][1:], [1.0, 4.0]) + + +def test_real_header_still_detected_alongside_finding2(tmp_path): + # A genuine text header must still be detected as a header (the Finding 2 + # fix only excludes all-missing rows, not real labels). + p = _write(tmp_path, "a.dat", "x y z\n1 2 3\n4 5 6\n") + table = load_data_file(p) + assert table.has_header is True + assert table.column_names == ["x", "y", "z"] + assert table.n_rows == 2 + + +def test_partially_missing_first_row_with_label_is_header(tmp_path): + # A row mixing a real label with missing tokens ('name * *') still counts + # as a header (at least one field is a genuine non-numeric label). + p = _write(tmp_path, "a.dat", "name * *\n1 2 3\n") + table = load_data_file(p) + assert table.has_header is True + assert table.n_rows == 1 + + +# --------------------------------------------------------------------------- +# Finding 3: multi-word header token count mismatch -> positional names + warn. +# --------------------------------------------------------------------------- + + +def test_whitespace_multiword_header_mismatch_uses_positional_names(tmp_path): + # Whitespace-delimited header 'Temperature (K) Resistance (Ohm)' splits + # into 4 tokens over 2 data columns -> the row is still skipped as a + # header, but positional names are synthesized and a warning is recorded. + content = "Temperature (K) Resistance (Ohm)\n1.0 100.0\n2.0 200.0\n" + p = _write(tmp_path, "a.dat", content) + table = load_data_file(p) + assert table.has_header is True + assert table.n_cols == 2 + assert table.column_names == ["col1", "col2"] + # The two data rows survive (header consumed, not treated as data). + assert table.n_rows == 2 + np.testing.assert_allclose(table.columns[0], [1.0, 2.0]) + np.testing.assert_allclose(table.columns[1], [100.0, 200.0]) + assert any( + "does not align" in w or "positional" in w for w in table.warnings + ) + + +def test_delimited_multiword_header_unaffected(tmp_path): + # For a COMMA-delimited file the same multi-word names are single fields, + # so the header aligns 1:1 with the columns and is used verbatim (no + # positional fallback, no mismatch warning). + content = "Temperature (K),Resistance (Ohm)\n1.0,100.0\n2.0,200.0\n" + p = _write(tmp_path, "a.csv", content) + table = load_data_file(p) + assert table.delimiter == "," + assert table.has_header is True + assert table.column_names == ["Temperature (K)", "Resistance (Ohm)"] + assert not any( + "does not align" in w or "positional" in w for w in table.warnings + ) + + +# --------------------------------------------------------------------------- +# Comment-line column-header recovery (bug report): a real-world .dat file +# with GLE-style comment-only metadata (fit parameters etc.) whose LAST +# comment line before the data actually names the columns. The Data dock's +# combo boxes are built directly on load_data_file().column_names (see +# gleplot.gui.data.panel), so this is exactly the surface the reported bug +# (dock showing col1/col2 instead of real names) is fixed through. +# --------------------------------------------------------------------------- + + +def test_comment_header_names_surface_for_data_dock(tmp_path): + content = ( + "! Fit parameter data for GLE export\n" + "! Global fitting parameters:\n" + "! A_1 (%) = 11.8654 +/- 0.0543966\n" + "! temperature resistance\n" + "1.0 2.0\n" + "2.0 4.0\n" + ) + p = _write(tmp_path, "fit.dat", content) + table = load_data_file(p) + # The dock reads table.column_names directly and unconditionally -- + # this is the exact fix for the reported "shows col1/col2" bug. + assert table.column_names == ["temperature", "resistance"] + assert table.has_header is False + assert table.header_source == "comment" + assert table.numeric_column_names() == ["temperature", "resistance"] + + +def test_comment_header_rejected_mismatched_count_dock_sees_positional(tmp_path): + # A comment line with the wrong token count is rejected -- the dock + # falls back to the same positional col1/col2 names as any other + # headerless file, matching pre-fix behavior (no regression for + # genuinely non-header comment lines). + content = "! Global fitting parameters:\n1.0 2.0\n2.0 4.0\n" + p = _write(tmp_path, "fit.dat", content) + table = load_data_file(p) + assert table.column_names == ["col1", "col2"] + assert table.header_source is None diff --git a/tests/gui/test_geometry.py b/tests/gui/test_geometry.py new file mode 100644 index 0000000..df428fa --- /dev/null +++ b/tests/gui/test_geometry.py @@ -0,0 +1,408 @@ +"""Unit tests for the pure geometry layer (Track E1). + +These are Qt-free: :mod:`gleplot.gui.geometry` imports nothing from PySide6, so +the calibration math and the GLE-output parser can be exercised directly. +""" + +from __future__ import annotations + +import math + +import pytest + +from gleplot.gui.geometry import ( + AxesCalibration, + PreviewGeometry, + parse_calibration_lines, +) + + +# --------------------------------------------------------------------------- # +# AxesCalibration: affine round-trips +# --------------------------------------------------------------------------- # + +def _lin_cal(): + # Matches the empirically observed GLE output for a 2..12 / -1..1 axes: + # corner (2,-1) -> (1,1) cm, corner (12,1) -> (4.33,6.62) cm. + return AxesCalibration( + index=0, + x_range=(2.0, 12.0), + y_range=(-1.0, 1.0), + x_log=False, + y_log=False, + cm_rect=(1.0, 1.0, 4.33, 6.62), + ) + + +def test_linear_corners_map_to_cm_rect(): + cal = _lin_cal() + assert cal.data_to_cm(2.0, -1.0) == pytest.approx((1.0, 1.0)) + assert cal.data_to_cm(12.0, 1.0) == pytest.approx((4.33, 6.62)) + + +def test_linear_midpoint(): + cal = _lin_cal() + cx, cy = cal.data_to_cm(7.0, 0.0) # midpoints of both ranges + assert cx == pytest.approx((1.0 + 4.33) / 2) + assert cy == pytest.approx((1.0 + 6.62) / 2) + + +def test_linear_roundtrip(): + cal = _lin_cal() + for x, y in [(2.0, -1.0), (12.0, 1.0), (5.5, 0.3), (7.0, 0.0)]: + cx, cy = cal.data_to_cm(x, y) + rx, ry = cal.cm_to_data(cx, cy) + assert rx == pytest.approx(x) + assert ry == pytest.approx(y) + + +def test_log_x_roundtrip_and_geometric_midpoint(): + cal = AxesCalibration( + index=0, + x_range=(1.0, 100.0), + y_range=(-1.0, 1.0), + x_log=True, + y_log=False, + cm_rect=(5.83, 1.0, 9.16, 6.62), + ) + # Geometric midpoint (10) maps to the cm midpoint on a log axis. + cx, _ = cal.data_to_cm(10.0, 0.0) + assert cx == pytest.approx((5.83 + 9.16) / 2) + # Endpoints. + assert cal.data_to_cm(1.0, -1.0)[0] == pytest.approx(5.83) + assert cal.data_to_cm(100.0, 1.0)[0] == pytest.approx(9.16) + # Round-trip. + for x in [1.0, 10.0, 100.0, 3.16227766]: + cx, _ = cal.data_to_cm(x, 0.0) + rx, _ = cal.cm_to_data(cx, 0.0) + assert rx == pytest.approx(x) + + +def test_log_y_roundtrip_and_geometric_midpoint(): + cal = AxesCalibration( + index=0, + x_range=(2.0, 12.0), + y_range=(0.1, 1000.0), + x_log=False, + y_log=True, + cm_rect=(1.0, 1.0, 4.33, 6.62), + ) + # geomean(0.1, 1000) = 10 -> cm midpoint on y. + _, cy = cal.data_to_cm(7.0, 10.0) + assert cy == pytest.approx((1.0 + 6.62) / 2) + for y in [0.1, 1.0, 1000.0]: + _, cy = cal.data_to_cm(7.0, y) + _, ry = cal.cm_to_data(0.0, cy) + assert ry == pytest.approx(y) + + +def test_log_log_roundtrip(): + cal = AxesCalibration( + index=0, + x_range=(1.0, 1000.0), + y_range=(0.01, 100.0), + x_log=True, + y_log=True, + cm_rect=(2.0, 2.0, 12.0, 8.0), + ) + for x, y in [(1.0, 0.01), (1000.0, 100.0), (31.6227766, 1.0)]: + cx, cy = cal.data_to_cm(x, y) + rx, ry = cal.cm_to_data(cx, cy) + assert rx == pytest.approx(x) + assert ry == pytest.approx(y) + + +# --------------------------------------------------------------------------- # +# Non-positive on log axes +# --------------------------------------------------------------------------- # + +def test_nonpositive_on_log_axis_clamps_not_nan(): + cal = AxesCalibration( + index=0, + x_range=(1.0, 100.0), + y_range=(0.1, 1000.0), + x_log=True, + y_log=True, + cm_rect=(5.83, 1.0, 9.16, 6.62), + ) + cx, cy = cal.data_to_cm(0.0, -5.0) + assert math.isfinite(cx) + assert math.isfinite(cy) + # Clamped to a tiny epsilon -> maps far below the low cm edge, never NaN. + assert not math.isnan(cx) + assert not math.isnan(cy) + + +def test_cm_to_data_on_log_never_nonpositive(): + cal = AxesCalibration( + index=0, + x_range=(1.0, 100.0), + y_range=(0.1, 1000.0), + x_log=True, + y_log=True, + cm_rect=(5.83, 1.0, 9.16, 6.62), + ) + # Even well outside the box, log inverse stays strictly positive. + x, y = cal.cm_to_data(-100.0, -100.0) + assert x > 0.0 + assert y > 0.0 + + +def test_degenerate_zero_span_range(): + cal = AxesCalibration( + index=0, + x_range=(5.0, 5.0), + y_range=(1.0, 3.0), + x_log=False, + y_log=False, + cm_rect=(1.0, 1.0, 4.0, 6.0), + ) + # Zero-span x collapses to the low cm edge rather than dividing by zero. + cx, _ = cal.data_to_cm(5.0, 2.0) + assert cx == pytest.approx(1.0) + + +# --------------------------------------------------------------------------- # +# Containment +# --------------------------------------------------------------------------- # + +def test_contains_cm(): + cal = _lin_cal() # box (1,1)-(4.33,6.62) + assert cal.contains_cm(2.0, 3.0) + assert cal.contains_cm(1.0, 1.0) # corner inclusive + assert cal.contains_cm(4.33, 6.62) + assert not cal.contains_cm(0.5, 3.0) + assert not cal.contains_cm(2.0, 7.0) + + +def test_contains_cm_handles_inverted_corner_order(): + # y0 > y1: containment must still work. + cal = AxesCalibration( + index=0, + x_range=(0.0, 1.0), + y_range=(0.0, 1.0), + x_log=False, + y_log=False, + cm_rect=(1.0, 6.0, 4.0, 1.0), + ) + assert cal.contains_cm(2.0, 3.0) + + +# --------------------------------------------------------------------------- # +# PreviewGeometry: cm <-> px with raster Y-flip +# --------------------------------------------------------------------------- # + +def test_cm_to_px_yflip(): + geom = PreviewGeometry(page_size_cm=(10.16, 7.62), dpi=100, axes=[]) + scale = 100 / 2.54 + # Origin cm (0, 0) is the *bottom* left -> maps to bottom of the raster. + px, py = geom.cm_to_px(0.0, 0.0) + assert px == pytest.approx(0.0) + assert py == pytest.approx(7.62 * scale) + # Top-left cm (0, page_h) -> raster origin. + px, py = geom.cm_to_px(0.0, 7.62) + assert px == pytest.approx(0.0) + assert py == pytest.approx(0.0) + + +def test_px_cm_roundtrip(): + geom = PreviewGeometry(page_size_cm=(10.16, 7.62), dpi=150, axes=[]) + for cx, cy in [(0.0, 0.0), (5.0, 3.0), (10.16, 7.62)]: + px, py = geom.cm_to_px(cx, cy) + rx, ry = geom.px_to_cm(px, py) + assert rx == pytest.approx(cx) + assert ry == pytest.approx(cy) + + +def test_axes_at_px(): + cal0 = AxesCalibration(0, (2, 12), (-1, 1), False, False, (1.0, 1.0, 4.33, 6.62)) + cal1 = AxesCalibration(1, (1, 100), (0.1, 1000), True, True, (5.83, 1.0, 9.16, 6.62)) + geom = PreviewGeometry(page_size_cm=(10.16, 7.62), dpi=100, axes=[cal0, cal1]) + # A point in the middle of axes 0's box (cm ~2.5, 3.8). + px, py = geom.cm_to_px(2.5, 3.8) + assert geom.axes_at_px(px, py) is cal0 + # A point in axes 1's box. + px, py = geom.cm_to_px(7.5, 3.8) + assert geom.axes_at_px(px, py) is cal1 + # A point in neither. + px, py = geom.cm_to_px(5.0, 3.8) # gap between the two boxes + assert geom.axes_at_px(px, py) is None + + +def test_data_to_px_full_pipeline(): + # data -> cm -> px, the exact overlay pattern. + cal = _lin_cal() + geom = PreviewGeometry(page_size_cm=(10.16, 7.62), dpi=100, axes=[cal]) + cx, cy = cal.data_to_cm(2.0, -1.0) # bottom-left data corner + px, py = geom.cm_to_px(cx, cy) + scale = 100 / 2.54 + assert px == pytest.approx(1.0 * scale) + assert py == pytest.approx((7.62 - 1.0) * scale) + + +# --------------------------------------------------------------------------- # +# parse_calibration_lines: tolerance +# --------------------------------------------------------------------------- # + +_REAL_OUTPUT = ( + "GLE 4.3.3[t.gle]-C-R-\n" + "gleplot-cal 0 2 12 -1 1 1 1 4.33 6.62\n" + "gleplot-cal 1 1 100 0.1 1000 5.83 1 9.16 6.62\n" + "\n[out][.eps][.png]\n" +) + + +def test_parse_real_output(): + meta = [(False, False), (True, True)] + cals, warnings = parse_calibration_lines(_REAL_OUTPUT, meta) + assert warnings == [] + assert len(cals) == 2 + assert cals[0].index == 0 + assert cals[0].x_range == pytest.approx((2.0, 12.0)) + assert cals[0].y_range == pytest.approx((-1.0, 1.0)) + assert cals[0].cm_rect == pytest.approx((1.0, 1.0, 4.33, 6.62)) + assert cals[0].x_log is False and cals[0].y_log is False + assert cals[1].x_log is True and cals[1].y_log is True + assert cals[1].cm_rect == pytest.approx((5.83, 1.0, 9.16, 6.62)) + + +def test_parse_extra_whitespace_and_tabs(): + text = "gleplot-cal 0 \t 2 12 -1\t1 1 1 4.33 6.62" + cals, warnings = parse_calibration_lines(text, [(False, False)]) + assert len(cals) == 1 + assert cals[0].x_range == pytest.approx((2.0, 12.0)) + + +def test_parse_missing_line_reports_warning(): + text = "gleplot-cal 0 2 12 -1 1 1 1 4.33 6.62\n" + cals, warnings = parse_calibration_lines(text, [(False, False), (False, False)]) + assert len(cals) == 1 + assert any("missing" in w and "1" in w for w in warnings) + + +def test_parse_garbage_is_skipped(): + text = ( + "gleplot-cal 0 2 12 -1 1 1 1 4.33 6.62\n" + "gleplot-cal garbage not numbers here\n" + "totally unrelated line\n" + ) + cals, warnings = parse_calibration_lines(text, [(False, False)]) + assert len(cals) == 1 + assert any("malformed" in w or "unparseable" in w for w in warnings) + + +def test_parse_out_of_range_index_skipped(): + text = "gleplot-cal 5 2 12 -1 1 1 1 4.33 6.62\n" + cals, warnings = parse_calibration_lines(text, [(False, False)]) + assert cals == [] + assert any("out of range" in w for w in warnings) + + +def test_parse_no_records(): + cals, warnings = parse_calibration_lines("no cal here at all\n", [(False, False)]) + assert cals == [] + assert any("missing" in w for w in warnings) + + +def test_parse_scientific_notation(): + text = "gleplot-cal 0 1e-3 1.5e2 -1 1 1 1 4.33 6.62\n" + cals, warnings = parse_calibration_lines(text, [(False, False)]) + assert len(cals) == 1 + assert cals[0].x_range == pytest.approx((1e-3, 150.0)) + + +# --------------------------------------------------------------------------- # +# Invalid-calibration rejection (Finding 5) +# --------------------------------------------------------------------------- # + +def test_invalid_reason_valid_axes(): + assert _lin_cal().invalid_reason() is None + assert _lin_cal().is_valid() is True + + +def test_invalid_reason_log_nonpositive_bound(): + # x is log but its min is <= 0 -> undefined log space. + cal = AxesCalibration( + index=0, + x_range=(0.0, 100.0), + y_range=(0.1, 1000.0), + x_log=True, + y_log=True, + cm_rect=(1.0, 1.0, 4.0, 6.0), + ) + assert not cal.is_valid() + assert "log" in cal.invalid_reason() + + # negative bound on a log y axis. + cal_y = AxesCalibration( + index=0, + x_range=(1.0, 100.0), + y_range=(-5.0, 1000.0), + x_log=False, + y_log=True, + cm_rect=(1.0, 1.0, 4.0, 6.0), + ) + assert not cal_y.is_valid() + + +def test_invalid_reason_min_equals_max(): + cal = AxesCalibration( + index=0, + x_range=(5.0, 5.0), + y_range=(1.0, 3.0), + x_log=False, + y_log=False, + cm_rect=(1.0, 1.0, 4.0, 6.0), + ) + assert not cal.is_valid() + assert "degenerate" in cal.invalid_reason() + + +def test_invalid_reason_degenerate_cm_rect(): + cal = AxesCalibration( + index=0, + x_range=(2.0, 12.0), + y_range=(-1.0, 1.0), + x_log=False, + y_log=False, + cm_rect=(1.0, 1.0, 1.0, 6.0), # zero width + ) + assert not cal.is_valid() + assert "width" in cal.invalid_reason() + + +def test_parse_log_nonpositive_bound_skipped_with_warning(): + # A log axis (meta True/True) whose printed x-min is 0 must be skipped. + text = "gleplot-cal 0 0 100 0.1 1000 5.83 1 9.16 6.62\n" + cals, warnings = parse_calibration_lines(text, [(True, True)]) + assert cals == [] + assert any("skipped" in w and "log" in w for w in warnings) + # Not also reported as "missing" (it was seen, just rejected). + assert not any("missing" in w for w in warnings) + + +def test_parse_min_equals_max_skipped_with_warning(): + text = "gleplot-cal 0 5 5 -1 1 1 1 4.33 6.62\n" + cals, warnings = parse_calibration_lines(text, [(False, False)]) + assert cals == [] + assert any("skipped" in w and "degenerate" in w for w in warnings) + + +def test_parse_degenerate_cm_rect_skipped_with_warning(): + # cm corners give zero height (cy0 == cy1). + text = "gleplot-cal 0 2 12 -1 1 1 3 4.33 3\n" + cals, warnings = parse_calibration_lines(text, [(False, False)]) + assert cals == [] + assert any("skipped" in w and "height" in w for w in warnings) + + +def test_parse_valid_axes_unaffected_by_validation(): + # A mix: axes 0 valid, axes 1 invalid (log with non-positive bound). + text = ( + "gleplot-cal 0 2 12 -1 1 1 1 4.33 6.62\n" + "gleplot-cal 1 0 100 0.1 1000 5.83 1 9.16 6.62\n" + ) + cals, warnings = parse_calibration_lines(text, [(False, False), (True, True)]) + assert len(cals) == 1 + assert cals[0].index == 0 + assert any("skipped" in w for w in warnings) diff --git a/tests/gui/test_header_editing.py b/tests/gui/test_header_editing.py new file mode 100644 index 0000000..8d5d8d6 --- /dev/null +++ b/tests/gui/test_header_editing.py @@ -0,0 +1,530 @@ +"""Tests for Track G1: editable column headers in DataPanel (offscreen Qt). + +Covers the ownership rule (figure-owned sidecar vs. external reference vs. +user-loaded-in-memory), the rename UX entry point (``_on_header_double_clicked`` +/ ``_rename_column``), propagation to the DataTable/preview header/combos/ +series ``column_names``, validation (sanitize/uniqueness/float rejection), +no-op-on-unchanged, and persistence through a save+reparse round trip. + +Follows the local-stub-document pattern from ``tests/gui/test_data_panel.py``. +""" + +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +pyside6 = pytest.importorskip("PySide6", reason="PySide6 not installed (gui extra)") + +from PySide6.QtCore import QObject, Signal +from PySide6.QtWidgets import QApplication + +import gleplot as glp +from gleplot.gui.data.panel import DataPanel, _EXTERNAL_HEADER_TOOLTIP + + +class StubDocument(QObject): + """Minimal duck-typed FigureDocument stub wrapping a real Figure.""" + + figure_changed = Signal() + figure_replaced = Signal() + project_path_changed = Signal(str) + + def __init__(self, figure=None, project_path=None): + super().__init__() + self._figure = figure + self.project_path = project_path + self.notify_changed_count = 0 + + @property + def figure(self): + return self._figure + + @figure.setter + def figure(self, value): + self._figure = value + + def notify_changed(self): + self.notify_changed_count += 1 + self.figure_changed.emit() + + +@pytest.fixture +def qapp(): + app = QApplication.instance() + if app is None: + app = QApplication([]) + yield app + + +@pytest.fixture +def figure(): + fig = glp.figure() + fig.add_subplot(111) + return fig + + +@pytest.fixture +def document(figure): + return StubDocument(figure) + + +def _write_csv(tmp_path: Path, name: str = "data.csv") -> Path: + p = tmp_path / name + p.write_text("x,y,yerr\n1,2,0.1\n2,4,0.2\n3,6,0.3\n4,8,0.4\n") + return p + + +def _select_only_item(panel): + """Select the panel's sole file-list item (mirrors user click).""" + assert panel.file_list.count() == 1 + panel.file_list.setCurrentItem(panel.file_list.item(0)) + + +def _opened_document_with_sidecar(tmp_path, label="squares"): + """A document wrapping a figure parsed back from a real savefig_gle. + + The resulting import-mode line series' data_file is the regenerated + sidecar (e.g. "ownfig_0.dat"), making it FIGURE-OWNED once the panel's + populate_from_figure resolves it against project_path. + """ + from gleplot.parser.recognizer import parse_gle_figure + + fig = glp.figure(data_prefix="ownfig") + ax = fig.add_subplot(1, 1, 1) + ax.plot([0.0, 1.0, 2.0], [0.0, 1.0, 4.0], label=label) + gle_path = tmp_path / "ownfig.gle" + fig.savefig_gle(str(gle_path)) + rec = parse_gle_figure(gle_path) + doc = StubDocument(rec.figure, project_path=gle_path) + return doc, gle_path + + +# ---------------------------------------------------------------------- +# Ownership / editability matrix +# ---------------------------------------------------------------------- +class TestEditabilityMatrix: + def test_figure_owned_sidecar_is_editable(self, qapp, tmp_path): + doc, gle_path = _opened_document_with_sidecar(tmp_path) + panel = DataPanel(doc) + panel.populate_from_figure() + _select_only_item(panel) + + assert panel._is_editable_table(panel._current_key) is True + assert len(panel._owning_series_for_key(panel._current_key)) == 1 + + def test_user_loaded_not_yet_referenced_is_editable(self, qapp, document, tmp_path): + panel = DataPanel(document) + csv_path = _write_csv(tmp_path) + panel.load_file(str(csv_path)) + _select_only_item(panel) + + assert panel._is_editable_table(panel._current_key) is True + # Not figure-owned (no series references it at all). + assert panel._owning_series_for_key(panel._current_key) == [] + + def test_file_series_referenced_external_is_not_editable( + self, qapp, document, figure, tmp_path + ): + panel = DataPanel(document) + csv_path = _write_csv(tmp_path) + panel.load_file(str(csv_path)) + _select_only_item(panel) + + panel.mode_combo.setCurrentText("Reference file") + panel.plot_type_combo.setCurrentText("Line") + x_idx = panel.x_combo.findText("x") + y_idx = panel.y_combo.findText("y") + panel.x_combo.setCurrentIndex(x_idx) + panel.y_combo.setCurrentIndex(y_idx) + panel.add_series() + + assert panel._is_editable_table(panel._current_key) is False + assert panel._is_referenced_externally(panel._current_key) is True + + def test_header_tooltip_set_for_external_file(self, qapp, document, figure, tmp_path): + panel = DataPanel(document) + csv_path = _write_csv(tmp_path) + panel.load_file(str(csv_path)) + _select_only_item(panel) + + panel.mode_combo.setCurrentText("Reference file") + x_idx = panel.x_combo.findText("x") + y_idx = panel.y_combo.findText("y") + panel.x_combo.setCurrentIndex(x_idx) + panel.y_combo.setCurrentIndex(y_idx) + panel.add_series() + + # Re-select to force _populate_preview to re-run with the now + # up-to-date ownership state. + panel.file_list.setCurrentItem(None) + _select_only_item(panel) + + header_item = panel.preview_table.horizontalHeaderItem(0) + assert header_item.toolTip() == _EXTERNAL_HEADER_TOOLTIP + + +# ---------------------------------------------------------------------- +# Rename happy path + propagation +# ---------------------------------------------------------------------- +class TestRenamePropagation: + def test_rename_user_loaded_table_updates_table_header_and_combos( + self, qapp, document, tmp_path + ): + panel = DataPanel(document) + csv_path = _write_csv(tmp_path) + table = panel.load_file(str(csv_path)) + _select_only_item(panel) + + y_idx = table.column_names.index("y") + panel._rename_column(panel._current_key, table, y_idx, "Height (cm)") + + assert table.column_names[y_idx] == "height_cm" + header_item = panel.preview_table.horizontalHeaderItem(y_idx) + assert header_item.text() == "height_cm" + # Combo item text updated at the same 0-based index. + combo_idx = panel.y_combo.findData(y_idx) + assert panel.y_combo.itemText(combo_idx) == "height_cm" + + def test_rename_preserves_current_combo_selections(self, qapp, document, tmp_path): + panel = DataPanel(document) + csv_path = _write_csv(tmp_path) + table = panel.load_file(str(csv_path)) + _select_only_item(panel) + + yerr_idx = table.column_names.index("yerr") + sel = panel.yerr_combo.findText("yerr") + panel.yerr_combo.setCurrentIndex(sel) + + panel._rename_column(panel._current_key, table, yerr_idx, "sigma") + + # Selection (by underlying column index) survives the text change. + assert panel.yerr_combo.currentData() == yerr_idx + assert panel.yerr_combo.currentText() == "sigma" + + def test_rename_figure_owned_sidecar_propagates_to_series_column_names( + self, qapp, tmp_path + ): + doc, gle_path = _opened_document_with_sidecar(tmp_path) + panel = DataPanel(doc) + panel.populate_from_figure() + _select_only_item(panel) + + table = panel._current_table + key = panel._current_key + y_idx = 1 # ['x', 'squares'] for a labeled line -> index 1 is primary y + before_count = doc.notify_changed_count + + panel._rename_column(key, table, y_idx, "Renamed Y") + + assert table.column_names[y_idx] == "renamed_y" + owners = panel._owning_series_for_key(key) + assert len(owners) == 1 + assert owners[0]["column_names"][y_idx] == "renamed_y" + # Label (legend text) must NOT be retroactively renamed. + assert owners[0]["label"] == "squares" + assert doc.notify_changed_count == before_count + 1 + + def test_column_renamed_signal_emitted_with_1based_index( + self, qapp, document, tmp_path + ): + panel = DataPanel(document) + csv_path = _write_csv(tmp_path) + table = panel.load_file(str(csv_path)) + _select_only_item(panel) + + received = [] + panel.column_renamed.connect(lambda *args: received.append(args)) + + y_idx = table.column_names.index("y") + panel._rename_column(panel._current_key, table, y_idx, "height") + + assert len(received) == 1 + key, one_based_idx, new_name = received[0] + assert key == panel._current_key + assert one_based_idx == y_idx + 1 + assert new_name == "height" + + +# ---------------------------------------------------------------------- +# Validation: sanitize / uniqueness / float rejection / no-op +# ---------------------------------------------------------------------- +class TestValidation: + def test_rename_sanitizes_punctuation_and_case(self, qapp, document, tmp_path): + panel = DataPanel(document) + csv_path = _write_csv(tmp_path) + table = panel.load_file(str(csv_path)) + _select_only_item(panel) + + panel._rename_column(panel._current_key, table, 0, "My Column!!") + assert table.column_names[0] == "my_column" + + def test_rename_rejects_name_that_parses_as_float(self, qapp, document, tmp_path): + panel = DataPanel(document) + csv_path = _write_csv(tmp_path) + table = panel.load_file(str(csv_path)) + _select_only_item(panel) + + panel._rename_column(panel._current_key, table, 0, "2024") + # sanitize_column_name prefixes numeric-looking results with the + # fallback ("col"), so the header can never look like a data value. + assert table.column_names[0] != "2024" + assert not table.column_names[0].replace("_", "").isdigit() + + def test_rename_auto_suffixes_on_collision(self, qapp, document, tmp_path): + panel = DataPanel(document) + csv_path = _write_csv(tmp_path) + table = panel.load_file(str(csv_path)) + _select_only_item(panel) + + # Rename "yerr" (index 2) to collide with existing "y" (index 1). + panel._rename_column(panel._current_key, table, 2, "y") + + assert table.column_names[1] == "y" + assert table.column_names[2] == "y_2" + + def test_rename_noop_when_unchanged_after_sanitization( + self, qapp, document, tmp_path + ): + panel = DataPanel(document) + csv_path = _write_csv(tmp_path) + table = panel.load_file(str(csv_path)) + _select_only_item(panel) + + received = [] + panel.column_renamed.connect(lambda *args: received.append(args)) + before = document.notify_changed_count + + panel._rename_column(panel._current_key, table, 0, "X") # sanitizes to "x" + + assert table.column_names[0] == "x" + assert document.notify_changed_count == before + assert received == [] + + +# ---------------------------------------------------------------------- +# Double-click entry point +# ---------------------------------------------------------------------- +class TestHeaderDoubleClickEntryPoint: + def test_double_click_on_external_header_shows_message_and_does_not_rename( + self, qapp, document, figure, tmp_path, monkeypatch + ): + panel = DataPanel(document) + csv_path = _write_csv(tmp_path) + panel.load_file(str(csv_path)) + _select_only_item(panel) + + panel.mode_combo.setCurrentText("Reference file") + x_idx = panel.x_combo.findText("x") + y_idx = panel.y_combo.findText("y") + panel.x_combo.setCurrentIndex(x_idx) + panel.y_combo.setCurrentIndex(y_idx) + panel.add_series() + panel.file_list.setCurrentItem(None) + _select_only_item(panel) + + shown = [] + from PySide6.QtWidgets import QMessageBox + + monkeypatch.setattr( + QMessageBox, "information", lambda *a, **k: shown.append(a) + ) + + original_names = list(panel._current_table.column_names) + panel._on_header_double_clicked(0) + + assert len(shown) == 1 + assert panel._current_table.column_names == original_names + + def test_double_click_on_editable_header_prompts_and_renames( + self, qapp, document, tmp_path, monkeypatch + ): + panel = DataPanel(document) + csv_path = _write_csv(tmp_path) + table = panel.load_file(str(csv_path)) + _select_only_item(panel) + + from PySide6.QtWidgets import QInputDialog + + monkeypatch.setattr( + QInputDialog, "getText", lambda *a, **k: ("new name", True) + ) + + panel._on_header_double_clicked(0) + + assert table.column_names[0] == "new_name" + + def test_double_click_cancelled_dialog_does_not_rename( + self, qapp, document, tmp_path, monkeypatch + ): + panel = DataPanel(document) + csv_path = _write_csv(tmp_path) + table = panel.load_file(str(csv_path)) + _select_only_item(panel) + + from PySide6.QtWidgets import QInputDialog + + monkeypatch.setattr( + QInputDialog, "getText", lambda *a, **k: ("ignored", False) + ) + + panel._on_header_double_clicked(0) + + assert table.column_names[0] == "x" + + +# ---------------------------------------------------------------------- +# Findings 4 & 5: ownership resolution by basename (Save-As staleness / +# project_path None) so a rename still propagates to series column_names. +# ---------------------------------------------------------------------- +class TestOwnershipResolution: + def test_rename_after_save_as_propagates_to_series_column_names( + self, qapp, tmp_path + ): + """Finding 4: after Save As to a NEW directory, the document's + project_path moves but the already-loaded table key (resolved at + load time) does not. The owning import series must still be found + (by basename -- sidecars live beside the .gle), so the rename + propagates instead of being silently lost. + """ + doc, gle_path = _opened_document_with_sidecar(tmp_path) + panel = DataPanel(doc) + panel.populate_from_figure() + _select_only_item(panel) + + table = panel._current_table + key = panel._current_key # resolved at load time in the ORIGINAL dir + + # Simulate File > Save As into a fresh directory: project_path moves. + new_dir = tmp_path / "moved" + new_dir.mkdir() + doc.project_path = new_dir / "ownfig.gle" + + # The table key is now stale relative to project_path, but ownership + # still resolves via the sidecar basename. + owners = panel._owning_series_for_key(key) + assert len(owners) == 1 + + panel._rename_column(key, table, 1, "Renamed After SaveAs") + assert owners[0]["column_names"][1] == "renamed_after_saveas" + + def test_ownership_resolves_with_project_path_none(self, qapp, tmp_path): + """Finding 5: with project_path None (before the first save there is + no directory to resolve relative sidecar names against), ownership + must still resolve by basename rather than always returning []. + """ + doc, gle_path = _opened_document_with_sidecar(tmp_path) + panel = DataPanel(doc) + panel.populate_from_figure() + _select_only_item(panel) + + table = panel._current_table + key = panel._current_key + + # Drop the project path entirely (e.g. an unsaved re-parented doc). + doc.project_path = None + + owners = panel._owning_series_for_key(key) + assert len(owners) == 1 + + panel._rename_column(key, table, 1, "NoProjectPath") + assert owners[0]["column_names"][1] == "noprojectpath" + + def test_external_reference_priority_still_wins(self, qapp, document, tmp_path): + """The external-reference priority rule stays intact: a file_series + (Reference file) entry makes the table non-editable even though the + basename fallback might otherwise flag it as owned. + """ + panel = DataPanel(document) + csv_path = _write_csv(tmp_path) + panel.load_file(str(csv_path)) + _select_only_item(panel) + + panel.mode_combo.setCurrentText("Reference file") + panel.plot_type_combo.setCurrentText("Line") + panel.x_combo.setCurrentIndex(panel.x_combo.findText("x")) + panel.y_combo.setCurrentIndex(panel.y_combo.findText("y")) + panel.add_series() + + assert panel._is_referenced_externally(panel._current_key) is True + assert panel._is_editable_table(panel._current_key) is False + + +# ---------------------------------------------------------------------- +# Finding 7: _rename_column guards against a length-mismatched owner entry. +# ---------------------------------------------------------------------- +class TestRenameLengthGuard: + def test_rename_skips_owner_with_mismatched_column_names_length( + self, qapp, tmp_path + ): + """A basename-matched owner whose column_names length differs from the + previewed table's column count must be SKIPPED (not blindly written), + so a stale/foreign entry cannot be corrupted or raise IndexError. + """ + doc, gle_path = _opened_document_with_sidecar(tmp_path) + panel = DataPanel(doc) + panel.populate_from_figure() + _select_only_item(panel) + + table = panel._current_table + key = panel._current_key + + # Craft a second owning entry for the same sidecar basename but with a + # deliberately wrong-length column_names (3 names; table has 2 cols). + # col_idx 0 is IN RANGE for the mismatched list, so only the Finding 7 + # length guard -- not the pre-existing bounds check -- prevents the + # wrong column from being clobbered. + ax = doc.figure.axes_list[0] + good_entry = ax.lines[0] + mismatched = dict(good_entry) + mismatched["column_names"] = ["foo", "bar", "baz"] # wrong length (3 != 2) + ax.lines.append(mismatched) + + assert table.n_cols == 2 + # Both entries basename-match the loaded sidecar. + assert len(panel._owning_series_for_key(key)) == 2 + + panel._rename_column(key, table, 0, "Guarded") + + # The aligned entry is updated; the mismatched one is left untouched + # (its in-range index 0 would have been overwritten without the guard). + assert good_entry["column_names"][0] == "guarded" + assert mismatched["column_names"] == ["foo", "bar", "baz"] + + +# ---------------------------------------------------------------------- +# Persistence through save + reopen +# ---------------------------------------------------------------------- +class TestPersistence: + def test_renamed_header_persists_through_save_and_reparse(self, qapp, tmp_path): + from gleplot.parser.recognizer import parse_gle_figure + + doc, gle_path = _opened_document_with_sidecar(tmp_path) + panel = DataPanel(doc) + panel.populate_from_figure() + _select_only_item(panel) + + table = panel._current_table + key = panel._current_key + panel._rename_column(key, table, 1, "Distance") + + # Re-save: the writer reads column_names straight off the series + # dict, which _rename_column mutated in place. + doc.notify_changed() + fig = doc.figure + fig.savefig_gle(str(gle_path)) + + sidecar_text = (tmp_path / "ownfig_0.dat").read_text() + header_line = sidecar_text.splitlines()[0] + assert "distance" in header_line.split() + + # Reopen: the recognizer recovers column_names from the sidecar's + # header row for the reparsed series. + rec = parse_gle_figure(gle_path) + ax = rec.figure.axes_list[0] + assert ax.lines[0]["column_names"][1] == "distance" diff --git a/tests/gui/test_integration.py b/tests/gui/test_integration.py index 97c158f..90e0af6 100644 --- a/tests/gui/test_integration.py +++ b/tests/gui/test_integration.py @@ -638,3 +638,115 @@ def test_programmatic_file_prompts(qapp, tmp_path, monkeypatch): window2.preview_controller.shutdown() window2._cleanup_gle_temp_dirs() window2.deleteLater() + + +# ====================================================================== +# Track F1: annotation-overlay E2E (open multi-annotation .gle -> drag one +# -> save -> re-parse -> moved coords persist, others untouched). +# ====================================================================== +def _write_multi_annotation_gle(tmp_path: Path) -> Path: + """A synthetic .gle with a series and two text annotations (data coords). + + Built through the public figure API so the on-disk script is exactly what + the recognizer round-trips back into ``ax.texts`` on open. + """ + import gleplot as glp + + fig = glp.Figure(figsize=(4, 3)) + ax = fig.add_subplot(1, 1, 1) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.plot([0, 10], [0, 10], label="line") + ax.text(2.0, 8.0, "alpha") + ax.text(6.0, 3.0, "beta") + out = tmp_path / "annotated.gle" + fig.savefig_gle(str(out)) + return out + + +def _sig6(value: float) -> float: + """Normalise to 6 significant figures (matches the writer's %.6g output).""" + return float(f"{value:.6g}") + + +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_annotation_drag_persists_across_save_reparse(qapp, tmp_path): + """Open a multi-annotation .gle, drag one text via the overlay, save, and + re-parse: the moved annotation's coords persist (6-sig-fig normalised) and + the other annotation is untouched. + """ + from PySide6.QtCore import QPointF + + from gleplot.gui import file_ops + from gleplot.parser.recognizer import parse_gle_figure + + settings = _scratch_settings(tmp_path) + gle_path = _write_multi_annotation_gle(tmp_path) + + window = MainWindow() + window.preview_controller.debounce_ms = 50 + recorder = _RenderRecorder(window.preview_controller) + try: + # Open the .gle as an editable figure through the real dispatch path. + window._dispatch_open(str(gle_path)) + assert window.is_gle_preview_mode is False + ax = window.document.figure.gca() + assert len(ax.texts) == 2 + # Recognizer restored both annotations. + by_text = {t["text"]: t for t in ax.texts} + assert set(by_text) == {"alpha", "beta"} + + # First render lands, geometry installed, overlay enabled with 2 items. + assert _wait_until(lambda: recorder.succeeded or recorder.failed) + assert not recorder.failed, recorder.failed + overlay = window.annotation_overlay + assert _wait_until(lambda: overlay.enabled and len(overlay.items) == 2) + + # Find the overlay item for "beta" and drag it to data (8, 9). + beta_item = next(it for it in overlay.items if it.text_dict["text"] == "beta") + mapping = window.preview_view.view_mapping() + cal = window.preview_controller.last_geometry.axes[beta_item.cal.index] + tcx, tcy = cal.data_to_cm(8.0, 9.0) + tvx, tvy = mapping.cm_to_view(tcx, tcy) + beta_item.sync_position(QPointF(tvx, tvy)) + + n_before = len(recorder.succeeded) + overlay.commit_item_move(beta_item) + + # Model updated; document dirty. + assert by_text["beta"]["x"] == pytest.approx(8.0, abs=0.2) + assert by_text["beta"]["y"] == pytest.approx(9.0, abs=0.2) + assert window.document.is_dirty + # The follow-up render lands (overlay rebuilds -- no jump exercised in + # the unit tests; here we just ensure the pipeline recovers). + assert _wait_until(lambda: len(recorder.succeeded) > n_before) + assert not recorder.failed, recorder.failed + + # Alpha untouched. + assert by_text["alpha"]["x"] == pytest.approx(2.0) + assert by_text["alpha"]["y"] == pytest.approx(8.0) + + moved_x = _sig6(by_text["beta"]["x"]) + moved_y = _sig6(by_text["beta"]["y"]) + + # Save, then re-parse the on-disk .gle from scratch. + out = tmp_path / "annotated_moved.gle" + assert file_ops.save_project_as( + window, window.document, path=out, settings=settings, + ) is True + + rec = parse_gle_figure(out) + reparsed_ax = rec.figure.axes_list[0] + reparsed = {t["text"]: t for t in reparsed_ax.texts} + assert set(reparsed) == {"alpha", "beta"} + + # Moved coords persisted (6-sig-fig normalised, matching %.6g output). + assert _sig6(reparsed["beta"]["x"]) == pytest.approx(moved_x, abs=1e-4) + assert _sig6(reparsed["beta"]["y"]) == pytest.approx(moved_y, abs=1e-4) + # The other annotation is untouched. + assert reparsed["alpha"]["x"] == pytest.approx(2.0) + assert reparsed["alpha"]["y"] == pytest.approx(8.0) + finally: + window.preview_controller.shutdown() + window._cleanup_gle_temp_dirs() + window.deleteLater() diff --git a/tests/gui/test_main_window_fixes.py b/tests/gui/test_main_window_fixes.py index fe3f838..b8fd62d 100644 --- a/tests/gui/test_main_window_fixes.py +++ b/tests/gui/test_main_window_fixes.py @@ -166,3 +166,147 @@ def test_open_project_resets_preview_view(qapp, tmp_path): finally: window.preview_controller.shutdown() window.deleteLater() + + +# ---------------------------------------------------------------------- +# Finding 1: entering GLE-preview mode hard-disables the annotation overlay so +# a stray drag/click cannot mutate the hidden document figure. +# ---------------------------------------------------------------------- +def _enter_preview_stubbed(window, monkeypatch, path): + """Enter GLE-preview mode without a real GLE compile.""" + from gleplot.gui import gle_viewer + from gleplot.gui.gle_viewer import GlePreviewResult + + monkeypatch.setattr( + gle_viewer, + "compile_gle_preview", + lambda p, **kw: GlePreviewResult( + png_path=None, errors=[], raw_output="", success=False, work_dir=None + ), + ) + window._enter_gle_preview_mode(Path(path)) + + +def test_enter_gle_preview_disables_overlay(qapp, monkeypatch): + window = MainWindow() + try: + overlay = window.annotation_overlay + # Pretend a prior document render left the overlay live. + overlay._enabled = True + overlay._add_mode = True + + _enter_preview_stubbed(window, monkeypatch, "hand.gle") + + assert window.is_gle_preview_mode is True + # Overlay hard-disabled: no items, add-mode cancelled, disabled flag set. + assert overlay._disabled is True + assert overlay.enabled is False + assert overlay.add_mode is False + assert overlay.items == [] + finally: + window._gle_preview_path = None + window.preview_controller.shutdown() + window.deleteLater() + + +def test_overlay_commit_paths_noop_while_disabled(qapp, monkeypatch): + """Belt-and-braces: disabled commit paths mutate nothing and never notify.""" + window = MainWindow() + try: + # Build a document with one annotation and a live overlay item. + fig = window.document.new_figure() + ax = fig.gca() + ax.plot([0, 1], [0, 1], label="s") + ax.text(0.5, 0.5, "keepme") + + from gleplot.gui.annotations import AnnotationItem + from gleplot.gui.geometry import AxesCalibration + + cal = AxesCalibration(0, (0, 1), (0, 1), False, False, (1.0, 1.0, 4.0, 4.0)) + overlay = window.annotation_overlay + item = AnnotationItem(overlay, ax.texts[0], cal) + overlay._items = [item] + + _enter_preview_stubbed(window, monkeypatch, "hand.gle") + assert overlay._disabled is True + + notified = {"n": 0} + window.document.figure_changed.connect( + lambda: notified.__setitem__("n", notified["n"] + 1) + ) + # All commit paths must be inert while disabled. + overlay.commit_item_move(item) + overlay.commit_item_text(item, "changed") + overlay.delete_item(item) + assert notified["n"] == 0 + # The annotation dict is untouched. + assert ax.texts[0]["text"] == "keepme" + assert ax.texts[0]["x"] == pytest.approx(0.5) + finally: + window._gle_preview_path = None + window.preview_controller.shutdown() + window.deleteLater() + + +def test_leave_gle_preview_reenables_overlay(qapp, monkeypatch): + window = MainWindow() + try: + _enter_preview_stubbed(window, monkeypatch, "hand.gle") + assert window.annotation_overlay._disabled is True + + window._leave_gle_preview_mode() + assert window.is_gle_preview_mode is False + # Disabled state lifted; overlay is free to rebuild on the next render. + assert window.annotation_overlay._disabled is False + finally: + window.preview_controller.shutdown() + window.deleteLater() + + +# ---------------------------------------------------------------------- +# Finding 2: while in GLE-preview mode, can_undo/redo transitions must NOT +# re-enable the Undo/Redo actions; _update_gle_mode_actions is authoritative. +# ---------------------------------------------------------------------- +def test_undo_action_stays_disabled_in_preview_mode(qapp, monkeypatch): + window = MainWindow() + try: + _enter_preview_stubbed(window, monkeypatch, "hand.gle") + # _update_gle_mode_actions ran on enter: both actions disabled. + assert window.action_undo.isEnabled() is False + assert window.action_redo.isEnabled() is False + + # A can_undo_changed(True) landing while in preview mode (the underlying + # document stack keeps ticking) must NOT re-enable the action. + window.undo_stack.can_undo_changed.emit(True) + window.undo_stack.can_redo_changed.emit(True) + assert window.action_undo.isEnabled() is False + assert window.action_redo.isEnabled() is False + finally: + window._gle_preview_path = None + window.preview_controller.shutdown() + window.deleteLater() + + +def test_undo_action_restored_on_leaving_preview_mode(qapp, monkeypatch): + window = MainWindow() + try: + # Create a real undo step so the stack reports can_undo True. + fig = window.document.new_figure() + fig.gca().plot([0, 1], [0, 1], label="s") + window.document.notify_changed() + assert window.undo_stack.can_undo is True + assert window.action_undo.isEnabled() is True + + _enter_preview_stubbed(window, monkeypatch, "hand.gle") + assert window.action_undo.isEnabled() is False + + window._leave_gle_preview_mode() + # Back in document mode, the action reflects the live stack again. + assert window.action_undo.isEnabled() is True + + # And a subsequent transition once again drives the action. + window.undo_stack.can_undo_changed.emit(False) + assert window.action_undo.isEnabled() is False + finally: + window.preview_controller.shutdown() + window.deleteLater() diff --git a/tests/gui/test_preview.py b/tests/gui/test_preview.py index 9a68016..92a2d96 100644 --- a/tests/gui/test_preview.py +++ b/tests/gui/test_preview.py @@ -161,8 +161,8 @@ def test_render_failure_parses_structured_errors(qapp, monkeypatch): # with a structured, line-numbered error. original = ctrl._write_script - def broken_write(work_fig, session): - original(work_fig, session) + def broken_write(work_fig, session, fmt="png"): + original(work_fig, session, fmt) script = session / "preview.gle" text = script.read_text(encoding="utf-8") script.write_text(text + "\nthis_is_not_valid_gle @@@\n", encoding="utf-8") @@ -299,3 +299,52 @@ def test_view_placeholder_hidden_when_image_present(qapp, tmp_path): view.show_placeholder("compile error") assert view._placeholder_item is None assert view.last_good_path == str(p) + + +# ---------------------------------------------------------------------- +# Calibration geometry (Track E1) -- additive coverage +# ---------------------------------------------------------------------- +def test_geometry_not_emitted_on_empty_document(qapp): + """An empty document skips the render; geometry_ready must not fire and + last_geometry stays None.""" + doc = FigureDocument() + doc.new_figure() # one empty subplot, no series + ctrl = PreviewController(doc, debounce_ms=20) + geoms = [] + ctrl.geometry_ready.connect(geoms.append) + try: + ctrl.request_render() + assert not geoms + assert ctrl.last_geometry is None + finally: + ctrl.shutdown() + + +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_failed_render_resets_geometry_to_none(qapp, monkeypatch): + """A render failure clears any geometry and does not emit a stale one.""" + doc = _make_sin_document() + ctrl = PreviewController(doc, debounce_ms=50) + rec = SignalRecorder(ctrl) + geoms = [] + ctrl.geometry_ready.connect(geoms.append) + + original = ctrl._write_script + + def broken_write(work_fig, session, fmt="png"): + original(work_fig, session, fmt) + script = session / "preview.gle" + text = script.read_text(encoding="utf-8") + script.write_text(text + "\nthis_is_not_valid_gle @@@\n", encoding="utf-8") + + monkeypatch.setattr(ctrl, "_write_script", broken_write) + + try: + ctrl.request_render() + assert _wait_until(lambda: rec.failed or rec.succeeded, 10000) + assert rec.failed + # No successful geometry was emitted; last_geometry cleared to None. + assert ctrl.last_geometry is None + assert all(g is None for g in geoms) + finally: + ctrl.shutdown() diff --git a/tests/gui/test_svg_preview.py b/tests/gui/test_svg_preview.py new file mode 100644 index 0000000..9151f21 --- /dev/null +++ b/tests/gui/test_svg_preview.py @@ -0,0 +1,456 @@ +"""Tests for the SVG vector preview + PNG fallback (Track E2). + +These drive a *real* GLE compile (``gle -d svg`` / ``gle -d png``) through +:class:`PreviewController` on the offscreen Qt platform. They skip cleanly +when PySide6 or QtSvg are unavailable, and are ``xfail`` when GLE itself is +not installed (the pipeline is correct but cannot produce output). +""" + +import os +import sys +from pathlib import Path + +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +pytest.importorskip("PySide6", reason="PySide6 not installed (gui extra)") + +from PySide6.QtCore import QEventLoop, QTimer # noqa: E402 +from PySide6.QtWidgets import QApplication # noqa: E402 + +import gleplot as glp # noqa: E402 +from gleplot.compiler import find_gle # noqa: E402 +from gleplot.gui.document import FigureDocument # noqa: E402 +from gleplot.gui.preview import ( # noqa: E402 + PreviewController, + PreviewView, + RasterViewMapping, + SvgViewMapping, + _QTSVG_AVAILABLE, +) + +_GLE_AVAILABLE = find_gle() is not None + +try: + from PySide6.QtSvg import QSvgRenderer +except ImportError: # pragma: no cover + QSvgRenderer = None + + +@pytest.fixture +def qapp(): + app = QApplication.instance() + if app is None: + app = QApplication([]) + yield app + + +class SignalRecorder: + """Records emissions from a controller's signals and can wait for one.""" + + def __init__(self, controller): + self.started = 0 + self.succeeded = [] + self.failed = [] + self.skipped = 0 + self.geometries = [] + self.fallbacks = [] + controller.render_started.connect(self._on_started) + controller.render_succeeded.connect(self._on_succeeded) + controller.render_failed.connect(self._on_failed) + controller.render_skipped_empty.connect(self._on_skipped) + controller.geometry_ready.connect(self.geometries.append) + controller.fallback_activated.connect(self.fallbacks.append) + + def _on_started(self): + self.started += 1 + + def _on_succeeded(self, path): + self.succeeded.append(path) + + def _on_failed(self, errors, raw): + self.failed.append((errors, raw)) + + def _on_skipped(self): + self.skipped += 1 + + +def _wait_until(predicate, timeout_ms=10000): + loop = QEventLoop() + timed_out = {"value": False} + + poll = QTimer() + poll.setInterval(20) + + deadline = QTimer() + deadline.setSingleShot(True) + deadline.setInterval(timeout_ms) + + def check(): + if predicate(): + loop.quit() + + def on_deadline(): + timed_out["value"] = True + loop.quit() + + poll.timeout.connect(check) + deadline.timeout.connect(on_deadline) + poll.start() + deadline.start() + if predicate(): + return True + loop.exec() + poll.stop() + deadline.stop() + return not timed_out["value"] + + +def _make_sin_document(): + doc = FigureDocument() + fig = glp.Figure(figsize=(4, 3)) + ax = fig.add_subplot(1, 1, 1) + x = np.linspace(0, 2 * np.pi, 50) + ax.plot(x, np.sin(x), label="sin") + doc.set_figure(fig) + return doc + + +def _single_axes_doc(): + doc = FigureDocument() + fig = glp.Figure(figsize=(4, 3)) + ax = fig.add_subplot(1, 1, 1) + ax.set_xlim(2, 12) + ax.set_ylim(-1, 1) + ax.plot([2, 12], [-1, 1], label="line") + doc.set_figure(fig) + return doc + + +# --------------------------------------------------------------------------- # +# PreviewController: SVG render end-to-end +# --------------------------------------------------------------------------- # +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_svg_is_default_render_format_when_available(qapp): + """With QtSvg + a working GLE/Cairo install, SVG is the default format.""" + doc = _make_sin_document() + ctrl = PreviewController(doc, debounce_ms=50) + try: + assert ctrl.render_format == "svg" + assert ctrl.svg_available + finally: + ctrl.shutdown() + + +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_svg_render_succeeds_and_is_valid(qapp): + doc = _make_sin_document() + ctrl = PreviewController(doc, debounce_ms=50) + rec = SignalRecorder(ctrl) + try: + assert ctrl.render_format == "svg" + ctrl.request_render() + assert _wait_until(lambda: rec.succeeded or rec.failed, 10000) + assert not rec.failed, rec.failed + assert rec.succeeded + + path = rec.succeeded[-1] + assert path.endswith(".svg") + p = Path(path) + assert p.exists() + assert p.stat().st_size > 0 + + renderer = QSvgRenderer(path) + assert renderer.isValid() + assert not renderer.defaultSize().isEmpty() + + # geometry_ready fired with a non-None geometry before render_succeeded. + assert rec.geometries + assert rec.geometries[-1] is not None + assert ctrl.last_geometry is not None + # No fallback should have been triggered on a clean SVG render. + assert not rec.fallbacks + finally: + ctrl.shutdown() + + +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_svg_calibration_print_line_present(qapp): + """gleplot-cal is parsed from an SVG compile just like a PNG compile.""" + doc = _single_axes_doc() + ctrl = PreviewController(doc, debounce_ms=50) + rec = SignalRecorder(ctrl) + try: + ctrl.request_render() + assert _wait_until(lambda: rec.succeeded or rec.failed, 10000) + assert not rec.failed, rec.failed + geom = ctrl.last_geometry + assert geom is not None + assert len(geom.axes) == 1 + cal = geom.axes[0] + assert cal.x_range == pytest.approx((2.0, 12.0)) + assert cal.y_range == pytest.approx((-1.0, 1.0)) + finally: + ctrl.shutdown() + + +# --------------------------------------------------------------------------- # +# Fallback path +# --------------------------------------------------------------------------- # +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_svg_fallback_on_invalid_output(qapp, monkeypatch, tmp_path): + """A forced invalid-SVG condition triggers fallback and an automatic PNG.""" + doc = _make_sin_document() + ctrl = PreviewController(doc, debounce_ms=50) + rec = SignalRecorder(ctrl) + assert ctrl.render_format == "svg" + + # Force every SVG-output validation to report a problem, regardless of + # what GLE actually produced -- this simulates the exit-0-but-degraded + # PostScript-font-on-Cairo failure mode (see preview.py module docstring) + # without depending on a specific user font configuration. + monkeypatch.setattr( + ctrl, + "_svg_output_problem", + staticmethod(lambda output, raw: "forced failure for test"), + ) + + try: + ctrl.request_render() + # First render (svg, forced-invalid) triggers the fallback and an + # automatic re-render in png; wait for that png to land. + assert _wait_until( + lambda: rec.fallbacks and rec.succeeded, 15000 + ) + assert rec.fallbacks == ["forced failure for test"] + assert ctrl.render_format == "png" + assert not ctrl.svg_available + + final_path = rec.succeeded[-1] + assert final_path.endswith(".png") + assert Path(final_path).exists() + assert Path(final_path).stat().st_size > 0 + + # Sticky: attempting to re-enable SVG is a silent no-op. + ctrl.render_format = "svg" + assert ctrl.render_format == "png" + finally: + ctrl.shutdown() + + +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_render_format_setter_rejects_bad_value(qapp): + doc = _make_sin_document() + ctrl = PreviewController(doc, debounce_ms=50) + try: + with pytest.raises(ValueError): + ctrl.render_format = "pdf" + finally: + ctrl.shutdown() + + +# --------------------------------------------------------------------------- # +# PreviewView: SVG display + zoom preservation +# --------------------------------------------------------------------------- # +def _write_svg(path: Path, w: float, h: float): + path.write_text( + f'\n' + f'\n' + f'\n' + f"\n", + encoding="utf-8", + ) + + +@pytest.mark.skipif(not _QTSVG_AVAILABLE, reason="QtSvg not installed") +def test_view_show_svg_image_preserves_transform_on_same_size(qapp, tmp_path): + view = PreviewView() + view.resize(400, 300) + view.show() + + p1 = tmp_path / "a.svg" + p2 = tmp_path / "b.svg" + _write_svg(p1, 200, 150) + _write_svg(p2, 200, 150) + + view.show_image(str(p1)) + view.scale(1.5, 1.5) + before = view.transform().m11() + view.show_image(str(p2)) + after = view.transform().m11() + + assert abs(before - after) < 1e-6 + assert view.last_good_path == str(p2) + + +@pytest.mark.skipif(not _QTSVG_AVAILABLE, reason="QtSvg not installed") +def test_view_show_svg_image_refits_on_size_change(qapp, tmp_path): + view = PreviewView() + view.resize(400, 300) + view.show() + + p1 = tmp_path / "a.svg" + p2 = tmp_path / "big.svg" + _write_svg(p1, 200, 150) + _write_svg(p2, 600, 450) + + view.show_image(str(p1)) + view.scale(2.0, 2.0) + changed = view.transform().m11() + view.show_image(str(p2)) + refit = view.transform().m11() + + assert abs(changed - refit) > 1e-6 + + +@pytest.mark.skipif(not _QTSVG_AVAILABLE, reason="QtSvg not installed") +def test_view_switching_formats_refits(qapp, tmp_path): + """A format switch (png <-> svg) is treated as a size change: refit.""" + + def _write_png(path, w, h): + from PySide6.QtGui import QPixmap + + pix = QPixmap(w, h) + pix.fill() + pix.save(str(path), "PNG") + + view = PreviewView() + view.resize(400, 300) + view.show() + + png_path = tmp_path / "a.png" + svg_path = tmp_path / "a.svg" + _write_png(png_path, 200, 150) + _write_svg(svg_path, 200, 150) # same nominal size, different format + + view.show_image(str(png_path)) + view.scale(2.0, 2.0) + changed = view.transform().m11() + view.show_image(str(svg_path)) + refit = view.transform().m11() + + assert abs(changed - refit) > 1e-6 + + +@pytest.mark.skipif(not _QTSVG_AVAILABLE, reason="QtSvg not installed") +def test_view_mapping_none_when_nothing_shown(qapp): + view = PreviewView() + assert view.view_mapping() is None + + +@pytest.mark.skipif(not _QTSVG_AVAILABLE, reason="QtSvg not installed") +def test_view_mapping_svg_round_trip(qapp, tmp_path): + view = PreviewView() + view.resize(400, 300) + view.show() + + # A page 578x434pt (== 20.32x15.24cm + 1pt margin each side), matching + # the empirically derived GLE Cairo SVG viewBox convention. + svg_path = tmp_path / "page.svg" + _write_svg(svg_path, 578, 434) + view.show_image(str(svg_path)) + + mapping = view.view_mapping() + assert isinstance(mapping, SvgViewMapping) + assert mapping.page_size_cm == pytest.approx((20.32, 15.24), abs=1e-6) + + for cx, cy in [(0.0, 0.0), (20.32, 15.24), (10.0, 7.0), (2.25, 1.5)]: + vx, vy = mapping.cm_to_view(cx, cy) + cx2, cy2 = mapping.view_to_cm(vx, vy) + assert cx2 == pytest.approx(cx, abs=1e-6) + assert cy2 == pytest.approx(cy, abs=1e-6) + + # Y orientation: page origin (bottom-left in cm) is near the bottom of + # the view (large vy); the top of the page (cy = page height) is near + # the top of the view (vy close to the margin). + vx0, vy0 = mapping.cm_to_view(0.0, 0.0) + vx1, vy1 = mapping.cm_to_view(0.0, 15.24) + assert vy1 < vy0 + + +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_view_mapping_raster_round_trip_and_data_lands_in_axes_rect(qapp): + """cm->view->cm identity for the raster mapping, and a data point lands + inside the axes' on-screen rect once geometry + image are both installed. + """ + doc = _single_axes_doc() + ctrl = PreviewController(doc, debounce_ms=50) + ctrl.render_format = "png" + rec = SignalRecorder(ctrl) + view = PreviewView() + view.resize(400, 300) + view.show() + ctrl.geometry_ready.connect(view.set_geometry) + ctrl.render_succeeded.connect(view.show_image) + + try: + ctrl.request_render() + assert _wait_until(lambda: rec.succeeded or rec.failed, 10000) + assert not rec.failed, rec.failed + + mapping = view.view_mapping() + assert isinstance(mapping, RasterViewMapping) + + geom = ctrl.last_geometry + assert geom is not None + cal = geom.axes[0] + + for cx, cy in [ + (cal.cm_rect[0], cal.cm_rect[1]), + (cal.cm_rect[2], cal.cm_rect[3]), + ]: + vx, vy = mapping.cm_to_view(cx, cy) + cx2, cy2 = mapping.view_to_cm(vx, vy) + assert cx2 == pytest.approx(cx, abs=1e-6) + assert cy2 == pytest.approx(cy, abs=1e-6) + + # A data point at the low corner maps (data -> cm -> view) inside the + # scene rect (== "on screen", modulo view zoom/pan which only scales + # the whole scene uniformly and never moves content outside it). + x, y = 2.0, -1.0 # low corner of the axes' data range + cx, cy = cal.data_to_cm(x, y) + vx, vy = mapping.cm_to_view(cx, cy) + scene_rect = view._scene.sceneRect() + assert scene_rect.contains(vx, vy) + finally: + ctrl.shutdown() + + +# --------------------------------------------------------------------------- # +# main_window: toggle action state transitions +# --------------------------------------------------------------------------- # +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_main_window_vector_preview_toggle(qapp, tmp_path, monkeypatch): + from gleplot.gui.main_window import MainWindow + from PySide6.QtCore import QSettings + + settings = QSettings(str(tmp_path / "settings.ini"), QSettings.Format.IniFormat) + win = MainWindow(settings=settings) + try: + action = win.action_vector_preview + ctrl = win.preview_controller + + if not ctrl.svg_available: + pytest.skip("SVG not available in this environment") + + assert action.isChecked() == (ctrl.render_format == "svg") + assert action.isEnabled() + + # Toggling off switches the controller to png. + action.setChecked(False) + assert ctrl.render_format == "png" + + # Toggling back on switches back to svg. + action.setChecked(True) + assert ctrl.render_format == "svg" + + # Simulate a fallback: the action unchecks itself and disables. + ctrl.fallback_activated.emit("simulated failure") + assert not action.isChecked() + assert not action.isEnabled() + finally: + win.close() diff --git a/tests/gui/test_texts_panel.py b/tests/gui/test_texts_panel.py new file mode 100644 index 0000000..a9e6533 --- /dev/null +++ b/tests/gui/test_texts_panel.py @@ -0,0 +1,578 @@ +"""Tests for gleplot.gui.panels.texts_panel.TextsPanel (Track F2). + +Mirrors the stub-document + real-Figure pattern in ``tests/gui/test_panels.py`` +(Track F, Phase 1). All fixture data is synthetic. +""" + +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +pyside6 = pytest.importorskip("PySide6", reason="PySide6 not installed (gui extra)") + +from PySide6.QtCore import QObject, Signal +from PySide6.QtWidgets import QApplication + +import gleplot +from gleplot.colors import rgb_to_gle +from gleplot.gui.panels import TextsPanel + + +# ---------------------------------------------------------------------- +# Stub document (same shape as test_panels.py's StubDocument; duplicated +# here per this track's file-ownership rules rather than importing across +# test modules). +# ---------------------------------------------------------------------- +class StubDocument(QObject): + figure_changed = Signal() + figure_replaced = Signal() + + def __init__(self, figure=None): + super().__init__() + self._figure = figure + self.notify_count = 0 + + @property + def figure(self): + return self._figure + + def set_figure(self, figure): + self._figure = figure + self.figure_replaced.emit() + + def notify_changed(self): + self.notify_count += 1 + self.figure_changed.emit() + + +@pytest.fixture +def qapp(): + app = QApplication.instance() + if app is None: + app = QApplication([]) + yield app + + +def _make_figure(): + fig = gleplot.figure(figsize=(8, 6), dpi=100) + ax = fig.gca() + ax.plot([1, 2, 3], [1, 4, 9], color="blue", label="sin data") + ax.text(3.1, 0.98, "sin peak", color="red", fontsize=14, ha="left") + ax.text(1.0, 0.2, "second annotation with a longer body of text than thirty chars") + return fig + + +@pytest.fixture +def document(qapp): + return StubDocument(_make_figure()) + + +# ------------------------------------------------------------------ +# List population +# ------------------------------------------------------------------ +class TestPopulation: + def test_populate_from_figure(self, document): + panel = TextsPanel(document) + assert panel.text_list.count() == 2 + assert "sin peak" in panel.text_list.item(0).text() + assert "(3.1, 0.98)" in panel.text_list.item(0).text() + + def test_list_preview_truncates_long_text(self, document): + panel = TextsPanel(document) + text = panel.text_list.item(1).text() + assert "..." in text + assert len(text.split(" — ")[0]) <= 33 # 30 chars + ellipsis + + def test_empty_axes_disables_panel(self, qapp): + fig = gleplot.figure() + doc = StubDocument(fig) + panel = TextsPanel(doc) + assert panel.text_list.count() == 0 + assert panel.text_edit.isEnabled() is False + + def test_refresh_on_figure_replaced(self, document): + panel = TextsPanel(document) + new_fig = gleplot.figure() + new_fig.gca().text(0.0, 0.0, "only annotation") + document.set_figure(new_fig) + assert panel.text_list.count() == 1 + assert "only annotation" in panel.text_list.item(0).text() + + def test_set_axes_retarget(self, qapp): + fig = gleplot.figure() + ax1 = fig.gca() + ax1.text(0.0, 0.0, "ax1 text") + # add_subplot immediately retargets fig.gca() to the new axes. + ax2 = fig.add_subplot(1, 2, 2) + ax2.text(1.0, 1.0, "ax2 text") + + doc = StubDocument(fig) + panel = TextsPanel(doc) + # Panel targets gca() by default, which is now ax2. + assert panel.text_list.count() == 1 + assert "ax2 text" in panel.text_list.item(0).text() + + panel.set_axes(ax1) + assert panel.text_list.count() == 1 + assert "ax1 text" in panel.text_list.item(0).text() + + panel.set_axes(ax2) + assert "ax2 text" in panel.text_list.item(0).text() + + def test_keeps_selection_by_index_across_refresh(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(1) + document.notify_changed() # triggers figure_changed -> refresh() + assert panel.text_list.currentRow() == 1 + + +# ------------------------------------------------------------------ +# Selection -> editor fields +# ------------------------------------------------------------------ +class TestSelectionReflectsEditor: + def test_selecting_entry_populates_editor(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(0) + assert panel.text_edit.toPlainText() == "sin peak" + assert panel.x_edit.text() == "3.1" + assert panel.y_edit.text() == "0.98" + assert panel.ha_combo.currentText() == "left" + assert panel.custom_size_check.isChecked() is True + assert panel.fontsize_spin.value() == pytest.approx(14.0) + + def test_default_color_and_fontsize(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(1) + # second annotation has no explicit color/fontsize + assert panel.custom_size_check.isChecked() is False + assert panel.fontsize_spin.isEnabled() is False + + def test_va_and_box_color_disabled_with_tooltip(self, document): + panel = TextsPanel(document) + assert panel.va_combo.isEnabled() is False + assert "not rendered" in panel.va_combo.toolTip() + assert panel.box_color_button.isEnabled() is False + assert "not rendered" in panel.box_color_button.toolTip() + + +# ------------------------------------------------------------------ +# Edit paths: each writes the exact key + notifies +# ------------------------------------------------------------------ +class TestEdits: + def test_edit_text_commits_on_focus_out_via_event_filter(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(0) + before = document.notify_count + panel.text_edit.setPlainText("renamed peak") + panel._on_text_committed() # simulate focus-out (eventFilter hook) + ax = document.figure.gca() + assert ax.texts[0]["text"] == "renamed peak" + assert document.notify_count == before + 1 + + def test_edit_text_strips_newlines(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(0) + panel.text_edit.setPlainText("line one\nline two") + panel._on_text_committed() + ax = document.figure.gca() + assert ax.texts[0]["text"] == "line one line two" + assert "\n" not in panel.text_edit.toPlainText() + + def test_edit_x(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(0) + before = document.notify_count + panel.x_edit.setText("5.5") + panel.x_edit.editingFinished.emit() + ax = document.figure.gca() + assert ax.texts[0]["x"] == pytest.approx(5.5) + assert document.notify_count == before + 1 + + def test_edit_y(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(0) + before = document.notify_count + panel.y_edit.setText("-2.25") + panel.y_edit.editingFinished.emit() + ax = document.figure.gca() + assert ax.texts[0]["y"] == pytest.approx(-2.25) + assert document.notify_count == before + 1 + + def test_edit_invalid_coord_reverts(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(0) + before = document.notify_count + panel.x_edit.setText("not a number") + panel.x_edit.editingFinished.emit() + ax = document.figure.gca() + assert ax.texts[0]["x"] == pytest.approx(3.1) + assert panel.x_edit.text() == "3.1" + assert document.notify_count == before + + @pytest.mark.parametrize("bad", ["nan", "inf", "-inf", "1e400"]) + def test_edit_nonfinite_coord_reverts(self, document, bad): + # Finding 6: nan/inf/overflow parse as floats but are non-finite; they + # must be rejected like any other invalid input (revert, no notify). + panel = TextsPanel(document) + panel.text_list.setCurrentRow(0) + before = document.notify_count + panel.x_edit.setText(bad) + panel.x_edit.editingFinished.emit() + ax = document.figure.gca() + assert ax.texts[0]["x"] == pytest.approx(3.1) + assert panel.x_edit.text() == "3.1" + assert document.notify_count == before + + def test_edit_color_stores_gle_name(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(0) + before = document.notify_count + + # Bypass the modal QColorDialog, matching series_panel's test + # pattern: simulate the conversion _on_color_clicked performs. + ax = document.figure.gca() + entry = ax.texts[0] + entry["color"] = rgb_to_gle((0.0, 1.0, 0.0)) + document.notify_changed() + + assert ax.texts[0]["color"] == "GREEN" + assert document.notify_count == before + 1 + + def test_edit_fontsize_custom_to_value(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(0) + before = document.notify_count + panel.fontsize_spin.setValue(22.0) + panel.fontsize_spin.editingFinished.emit() + ax = document.figure.gca() + assert ax.texts[0]["fontsize"] == pytest.approx(22.0) + assert document.notify_count == before + 1 + + def test_toggle_custom_size_off_writes_none(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(0) # fontsize=14 (custom) + before = document.notify_count + panel.custom_size_check.setChecked(False) + ax = document.figure.gca() + assert ax.texts[0]["fontsize"] is None + assert panel.fontsize_spin.isEnabled() is False + assert document.notify_count == before + 1 + + def test_toggle_custom_size_on_writes_default(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(1) # fontsize=None (inherit) + before = document.notify_count + panel.custom_size_check.setChecked(True) + ax = document.figure.gca() + assert ax.texts[1]["fontsize"] is not None + assert panel.fontsize_spin.isEnabled() is True + assert document.notify_count == before + 1 + + def test_edit_ha(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(0) + before = document.notify_count + panel.ha_combo.setCurrentText("right") + ax = document.figure.gca() + assert ax.texts[0]["ha"] == "right" + assert document.notify_count == before + 1 + + +# ------------------------------------------------------------------ +# Add / Remove +# ------------------------------------------------------------------ +class TestAddRemove: + def test_add_uses_public_api_and_correct_schema(self, document): + panel = TextsPanel(document) + ax = document.figure.gca() + before_count = len(ax.texts) + before_notify = document.notify_count + + panel.add_button.click() + + assert len(ax.texts) == before_count + 1 + new_entry = ax.texts[-1] + assert set(new_entry.keys()) == { + "x", "y", "text", "color", "fontsize", "ha", "va", "box_color", + } + assert new_entry["color"] == "BLACK" + assert new_entry["fontsize"] is None + assert new_entry["ha"] == "left" + assert document.notify_count == before_notify + 1 + # newly-added entry is selected + assert panel.current_index == before_count + + def test_add_uses_limit_midpoint_when_limits_set(self, qapp): + fig = gleplot.figure() + ax = fig.gca() + ax.set_xlim(0.0, 10.0) + ax.set_ylim(-4.0, 4.0) + doc = StubDocument(fig) + panel = TextsPanel(doc) + panel.add_button.click() + entry = ax.texts[-1] + assert entry["x"] == pytest.approx(5.0) + assert entry["y"] == pytest.approx(0.0) + + def test_add_defaults_to_half_when_limits_unset(self, qapp): + fig = gleplot.figure() + ax = fig.gca() + doc = StubDocument(fig) + panel = TextsPanel(doc) + panel.add_button.click() + entry = ax.texts[-1] + assert entry["x"] == pytest.approx(0.5) + assert entry["y"] == pytest.approx(0.5) + + def test_remove_deletes_correct_entry(self, document): + panel = TextsPanel(document) + ax = document.figure.gca() + panel.text_list.setCurrentRow(0) + before = document.notify_count + panel.remove_button.click() + assert len(ax.texts) == 1 + assert ax.texts[0]["text"] == "second annotation with a longer body of text than thirty chars" + assert document.notify_count == before + 1 + + +# ------------------------------------------------------------------ +# Guards +# ------------------------------------------------------------------ +class TestGuards: + def test_refresh_does_not_notify(self, document): + panel = TextsPanel(document) + before = document.notify_count + panel.refresh() + assert document.notify_count == before + + def test_set_axes_does_not_notify(self, document): + panel = TextsPanel(document) + ax = document.figure.gca() + before = document.notify_count + panel.set_axes(ax) + assert document.notify_count == before + + +# ------------------------------------------------------------------ +# Finding 7: an external refresh must not clobber a focused, in-progress edit +# ------------------------------------------------------------------ +class TestFocusedEditPreserved: + def test_focused_x_edit_not_clobbered_by_external_refresh(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(0) + + # User types a new value into x_edit but has NOT committed + # (editingFinished not emitted yet -> the field still has focus). + panel.x_edit.setText("9.99") + # Simulate the field being focused (offscreen focus is unreliable, so + # force hasFocus() to report True for the widget under test). + panel.x_edit.hasFocus = lambda: True + + # An external change (e.g. a canvas drag commit) fires figure_changed, + # which drives refresh() -> _populate_editor. The typed-but-uncommitted + # text must survive. + document.notify_changed() + assert panel.x_edit.text() == "9.99" + + # Other, non-focused fields still refresh normally (texts[0] y == 0.98). + assert panel.y_edit.text() == "0.98" + + # After blur, committing works and writes the model. + del panel.x_edit.hasFocus # restore real hasFocus (returns False) + panel.x_edit.editingFinished.emit() + assert document.figure.gca().texts[0]["x"] == pytest.approx(9.99) + + def test_focused_text_edit_not_clobbered_by_external_refresh(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(0) + panel.text_edit.setPlainText("half-typed annotation") + panel.text_edit.hasFocus = lambda: True + + document.notify_changed() + assert panel.text_edit.toPlainText() == "half-typed annotation" + + +# ------------------------------------------------------------------ +# Selection sync hook: text_selected / select_text / current_index +# ------------------------------------------------------------------ +class TestSelectionSignal: + def test_user_selection_emits_text_selected(self, document): + panel = TextsPanel(document) + received = [] + panel.text_selected.connect(received.append) + panel.text_list.setCurrentRow(1) + assert received == [1] + + def test_select_text_does_not_emit(self, document): + panel = TextsPanel(document) + received = [] + panel.text_selected.connect(received.append) + panel.select_text(1) + assert received == [] + assert panel.current_index == 1 + + def test_refresh_does_not_emit_text_selected(self, document): + panel = TextsPanel(document) + received = [] + panel.text_selected.connect(received.append) + panel.refresh() + assert received == [] + + def test_current_index_tracks_list_selection(self, document): + panel = TextsPanel(document) + panel.text_list.setCurrentRow(1) + assert panel.current_index == 1 + panel.select_text(0) + assert panel.current_index == 0 + + +# ------------------------------------------------------------------ +# Regression: toggling "custom size" (or any other field) must only ever +# mutate the *currently selected* entry, never a neighbour. +# +# Root cause: _on_list_selection_changed unconditionally repopulated the +# editor for every currentRowChanged delivery, including the *transient* +# signals QListWidget.clear() fires while refresh() rebuilds the list (an +# intermediate row before settling on -1, then the row refresh() restores). +# That only avoided corrupting the model by accident of nesting inside +# refresh()'s/select_text()'s own _updating window -- fragile, not a real +# guard. _on_list_selection_changed now checks self._updating itself before +# populating, the same way it already gated the text_selected emit. +# ------------------------------------------------------------------ +@pytest.fixture +def multi_document(qapp): + """Four annotations with distinct, easily-distinguished field values.""" + fig = gleplot.figure(figsize=(8, 6), dpi=100) + ax = fig.gca() + ax.text(0.0, 0.0, "alpha", color="RED", fontsize=11.0, ha="left") + ax.text(1.0, 1.0, "bravo", color="BLUE", fontsize=None, ha="center") + ax.text(2.0, 2.0, "charlie", color="GREEN", fontsize=33.0, ha="right") + ax.text(3.0, 3.0, "delta", color="BLACK", fontsize=None, ha="left") + return StubDocument(fig) + + +class TestCustomSizeToggleIsolation: + """The exact reported scenario: toggling custom-size must only touch the + selected entry, in both directions, and leave every other entry's + fontsize untouched.""" + + def test_toggle_off_only_changes_selected_entry(self, multi_document): + panel = TextsPanel(multi_document) + ax = multi_document.figure.gca() + + panel.text_list.setCurrentRow(0) # alpha, fontsize=11.0 (custom) + panel.custom_size_check.setChecked(False) + + assert ax.texts[0]["fontsize"] is None + assert ax.texts[1]["fontsize"] is None # bravo: untouched (was already None) + assert ax.texts[2]["fontsize"] == pytest.approx(33.0) # charlie: untouched + assert ax.texts[3]["fontsize"] is None # delta: untouched (was already None) + + def test_toggle_on_only_changes_selected_entry(self, multi_document): + panel = TextsPanel(multi_document) + ax = multi_document.figure.gca() + + panel.text_list.setCurrentRow(1) # bravo, fontsize=None + panel.custom_size_check.setChecked(True) + + assert ax.texts[1]["fontsize"] is not None + assert ax.texts[0]["fontsize"] == pytest.approx(11.0) # alpha: untouched + assert ax.texts[2]["fontsize"] == pytest.approx(33.0) # charlie: untouched + assert ax.texts[3]["fontsize"] is None # delta: untouched + + def test_toggle_off_on_selected_entry_with_all_neighbour_states( + self, multi_document + ): + """Toggle off on the one entry with a custom size while neighbours + span every other fontsize state (None and two distinct values) -- + the exact "multiple annotations affected" complaint, both sides of + the selected entry.""" + panel = TextsPanel(multi_document) + ax = multi_document.figure.gca() + + panel.text_list.setCurrentRow(2) # charlie, fontsize=33.0 (custom) + panel.custom_size_check.setChecked(False) + + assert ax.texts[2]["fontsize"] is None + assert ax.texts[0]["fontsize"] == pytest.approx(11.0) + assert ax.texts[1]["fontsize"] is None + assert ax.texts[3]["fontsize"] is None + + def test_selection_change_causes_zero_writes(self, multi_document): + """Merely changing the list selection (which drives refresh()'s + clear()/rebuild and its transient currentRowChanged signals) must + never itself write to the model -- only explicit user edits do.""" + panel = TextsPanel(multi_document) + before = multi_document.notify_count + + for row in [0, 1, 2, 3, 0, 3, 1, 2]: + panel.text_list.setCurrentRow(row) + + assert multi_document.notify_count == before + + def test_selection_change_causes_zero_writes_via_select_text( + self, multi_document + ): + """Same invariant via the overlay-sync (no-emit) selection path.""" + panel = TextsPanel(multi_document) + before = multi_document.notify_count + + for row in [0, 2, 1, 3, 0]: + panel.select_text(row) + + assert multi_document.notify_count == before + + +class TestFieldEditIsolation: + """Whole-field audit: changing selection across entries with differing + values for EVERY editable field must never mutate the model. Each + field's dedicated edit path is exercised elsewhere (TestEdits); this + class specifically guards against the selection-change class of bug + (mechanism (b) in the investigation) for every field, not just + "custom size".""" + + @pytest.mark.parametrize( + "row_sequence", + [ + [0, 1, 2, 3], + [3, 2, 1, 0], + [0, 3, 1, 2], + [1, 0, 2, 0, 3, 1], + ], + ) + def test_selection_walk_mutates_nothing(self, multi_document, row_sequence): + panel = TextsPanel(multi_document) + ax = multi_document.figure.gca() + before_snapshot = [dict(e) for e in ax.texts] + before_notify = multi_document.notify_count + + for row in row_sequence: + panel.text_list.setCurrentRow(row) + + after_snapshot = [dict(e) for e in ax.texts] + assert after_snapshot == before_snapshot + assert multi_document.notify_count == before_notify + + @pytest.mark.parametrize("selected_row", [0, 1, 2, 3]) + def test_selecting_each_entry_in_turn_touches_no_field( + self, multi_document, selected_row + ): + """Selecting every entry in turn (exercising _populate_editor for + every field: text, x, y, color, fontsize/custom-size, ha) must + leave every entry -- selected or not -- byte-for-byte identical.""" + panel = TextsPanel(multi_document) + ax = multi_document.figure.gca() + before_snapshot = [dict(e) for e in ax.texts] + + panel.text_list.setCurrentRow(selected_row) + for row in range(len(ax.texts)): + panel.text_list.setCurrentRow(row) + + after_snapshot = [dict(e) for e in ax.texts] + assert after_snapshot == before_snapshot diff --git a/tests/integration/test_graphics_compilation.py b/tests/integration/test_graphics_compilation.py index e8251dc..852290d 100644 --- a/tests/integration/test_graphics_compilation.py +++ b/tests/integration/test_graphics_compilation.py @@ -374,5 +374,147 @@ def test_compile_with_multiple_colors(self): self.assertGreater(png_file.stat().st_size, 1024) +class TestHeaderRowRenderingUnchanged(unittest.TestCase): + """Track E3: named sidecar column headers must not change GLE rendering. + + GLE auto-detects a non-numeric first row of a data file as a column + header (``auto_has_header`` in the GLE source) and, when unset, copies + the header's own-column text into that dataset's ``key_name`` -- i.e. a + header row can silently invent a legend entry for a series that never + asked for one. gleplot's writer neutralizes this by always emitting an + explicit ``key`` clause (the real label, or ``key ""``) whenever a + header row is present -- see ``writer.GLEWriter._key_clause`` / + ``add_bar_chart`` / ``add_fill_between``. + + This test proves the fix holds for a REAL GLE compile: a battery figure + covering every generated-sidecar series type (line labeled + unlabeled, + bar unlabeled, errorbar labeled + unlabeled, fill unlabeled) renders to + byte-identical PNG bytes whether or not the sidecars carry a header row + and the corresponding key-suppression tokens -- i.e. identical to the + pre-Track-E3 (headerless, no explicit key for unlabeled series) output. + """ + + def setUp(self): + glp.close() + self.tempdir = make_tempdir() + try: + self.compiler = GLECompiler() + self.gle_available = True + except RuntimeError: + self.gle_available = False + + def tearDown(self): + glp.close() + import os + if not os.environ.get('KEEP_TEST_FILES'): + shutil.rmtree(self.tempdir, ignore_errors=True) + + def _build_battery_figure(self): + import numpy as np + fig = glp.figure(figsize=(8, 6), data_prefix='battery') + ax = fig.add_subplot(111) + x = np.linspace(0, 10, 20) + ax.plot(x, np.sin(x), color='red', label='sin wave') + ax.plot(x, np.cos(x), color='blue') # unlabeled line + ax.bar([1, 2, 3], [4, 5, 6], color='orange') # unlabeled bar + ax.errorbar([1, 2, 3], [2, 4, 6], yerr=0.3, color='green', + marker='o', label='measurements') + ax.errorbar([1, 2, 3], [5, 3, 1], yerr=0.2, color='purple', + marker='s') # unlabeled errorbar + ax.fill_between(x, np.zeros_like(x), np.abs(np.sin(x)) * 0.3, + color='lightblue') # unlabeled fill + ax.set_xlabel('x') + ax.set_ylabel('y') + ax.set_title('battery') + ax.legend() + return fig + + @staticmethod + def _strip_header_feature(gle_text: str, data_files: dict) -> tuple: + """Reconstruct the pre-Track-E3 equivalent: headerless sidecars and + no explicit ``key`` clause for unlabeled series (bare omission). + """ + stripped_text_lines = [] + for line in gle_text.splitlines(): + stripped = line.rstrip() + if stripped.strip().startswith('d') and stripped.strip().endswith('key ""'): + # Standalone 'dN key ""' suppression line (bar/fill) -> drop + # entirely, matching the pre-feature output which never had it. + if stripped.strip().split() == [stripped.strip().split()[0], 'key', '""']: + continue + # Inline ' key ""' suffix on a dataset display line -> remove. + stripped = stripped.replace(' key ""', '') + stripped_text_lines.append(stripped) + stripped_text = '\n'.join(stripped_text_lines) + + stripped_data = {} + for name, content in data_files.items(): + lines = content.splitlines() + # First line is a header iff it's non-numeric (mirrors GLE's own + # auto_has_header check well enough for this synthetic battery, + # whose data rows are always purely numeric). + if lines and not _looks_like_data_row(lines[0]): + lines = lines[1:] + stripped_data[name] = '\n'.join(lines) + '\n' + return stripped_text, stripped_data + + def test_battery_figure_renders_identically_with_and_without_headers(self): + if not self.gle_available: + self.skipTest("GLE compiler not available") + + fig = self._build_battery_figure() + with_headers_dir = self.tempdir / 'with_headers' + with_headers_dir.mkdir() + gle_path = with_headers_dir / 'battery.gle' + fig.savefig_gle(str(gle_path)) + + gle_text = gle_path.read_text(encoding='utf-8') + data_files = { + p.name: p.read_text(encoding='utf-8') + for p in with_headers_dir.glob('*.dat') + } + # Sanity: headers are actually present (the feature is active). + self.assertTrue(any( + not _looks_like_data_row(content.splitlines()[0]) + for content in data_files.values() + ), "expected at least one sidecar with a header row") + self.assertIn('key ""', gle_text) + + pre_change_dir = self.tempdir / 'pre_change' + pre_change_dir.mkdir() + stripped_text, stripped_data = self._strip_header_feature(gle_text, data_files) + (pre_change_dir / 'battery.gle').write_text(stripped_text, encoding='utf-8') + for name, content in stripped_data.items(): + (pre_change_dir / name).write_text(content, encoding='utf-8') + + with_headers_png = self.compiler.compile( + str(gle_path), output_format='png', dpi=100 + ) + pre_change_png = self.compiler.compile( + str(pre_change_dir / 'battery.gle'), output_format='png', dpi=100 + ) + + self.assertEqual( + with_headers_png.read_bytes(), pre_change_png.read_bytes(), + "sidecar header rows changed the compiled PNG output" + ) + + +def _looks_like_data_row(line: str) -> bool: + """True if every whitespace-separated token on ``line`` parses as a + float -- i.e. it's a DATA row, not a header row (mirrors GLE's own + ``auto_has_header`` / ``isFloatMiss`` check: a header row has at least + one non-numeric cell).""" + tokens = line.split() + if not tokens: + return True + for tok in tokens: + try: + float(tok) + except ValueError: + return False + return True + + if __name__ == '__main__': unittest.main() diff --git a/tests/parser/test_recognizer.py b/tests/parser/test_recognizer.py index d24974f..04140ca 100644 --- a/tests/parser/test_recognizer.py +++ b/tests/parser/test_recognizer.py @@ -160,10 +160,26 @@ def _snap_array(v): # Text annotations: fontsize round-trips through cm (snap); box_color # is accepted-but-ignored by the writer (never emitted) -> drop. + # + # Sticky-fontsize normalization: 'set hei' is GLE interpreter-global + # state (see recognizer._try_one_text / writer.add_text). The writer + # only emits 'set hei' when a text's fontsize DIFFERS from whatever is + # currently active -- so a text with fontsize=None (or one equal to + # the already-active height) genuinely renders in real GLE at the + # last-explicit height, not some independent default. The recognizer + # correctly resolves that inherited value on recovery (it cannot tell + # "no explicit set hei" apart from "same value restated"). Both sides + # are therefore normalized to the STICKY-RESOLVED height, seeded from + # the style default (the preamble's unconditional 'set hei'), matching + # the value real GLE would use when rendering each text in sequence. + sticky_fs = style.get("fontsize") for s in ax.get("texts", []): - if s.get("fontsize") is not None: - emitted = GLEWriter._format_number(fontsize_pt_to_cm(float(s["fontsize"]))) - s["fontsize"] = fontsize_cm_to_pt(float(emitted)) + fs = s.get("fontsize") + if fs is None: + fs = sticky_fs + emitted = GLEWriter._format_number(fontsize_pt_to_cm(float(fs))) + s["fontsize"] = fontsize_cm_to_pt(float(emitted)) + sticky_fs = s["fontsize"] s.pop("box_color", None) return d @@ -368,10 +384,13 @@ def test_opaque_block_inside_graph_becomes_axes_passthrough(tmp_path): assert "raw line two" in text -def test_no_metadata_block_uses_heuristics(tmp_path): - # No '! gleplot' metadata at all -> import/reference decided by heuristic. - # 'data_5.dat' matches the sidecar naming pattern -> import (loaded). - # 'other.csv' does not -> reference (file_series). +def test_no_metadata_block_all_references(tmp_path): + # Finding 1: with NO '! gleplot' metadata block, classification is + # conservative -- EVERY data reference is treated as an external + # 'reference' (file_series), regardless of filename. A sidecar-looking + # name like 'data_5.dat' is NOT adopted as an import (which would let a + # later save rewrite a user's file). Both files land in file_series and + # nothing is loaded into ax.lines. src = ( "size 20.32 15.24\n" "set hei 0.42328\n" @@ -388,11 +407,9 @@ def test_no_metadata_block_uses_heuristics(tmp_path): ) rec = parse_gle_figure(p) ax = rec.figure.axes_list[0] - # data_5.dat imported (arrays loaded into lines), other.csv referenced. - assert len(ax.lines) == 1 - assert ax.lines[0]["data_file"] == "data_5.dat" - assert len(ax.file_series) == 1 - assert ax.file_series[0]["data_file"] == "other.csv" + assert ax.lines == [] + referenced = {fs["data_file"] for fs in ax.file_series} + assert referenced == {"data_5.dat", "other.csv"} def test_missing_grid_multigraph_falls_back(tmp_path): diff --git a/tests/parser/test_recognizer_adversarial.py b/tests/parser/test_recognizer_adversarial.py index 415c18b..38472c0 100644 --- a/tests/parser/test_recognizer_adversarial.py +++ b/tests/parser/test_recognizer_adversarial.py @@ -25,19 +25,42 @@ def _write(tmp_path: Path, name: str, content: str, dats: dict | None = None) -> return p +def _meta(*import_dats: str) -> str: + """A ``! gleplot`` metadata block vouching ``import_dats`` as import + sidecars. + + After the Finding-1 conservative-classification fix, a ``data`` reference + is only adopted as an editable *import* series (arrays loaded, header + recovered as column_names) when the metadata block's import-data list + vouches for it. Hand-written .gle files with no such block are always + treated as external references. Tests that exercise import-mode recovery + (multi-line dN accumulation, auto/positional column mapping, literal + error-bar conversion, ...) prepend this block so their data files are + genuinely gleplot-owned, matching how gleplot's own writer marks them. + """ + listed = ", ".join(import_dats) + return ( + "! GLE graphics file\n" + "! Generated by gleplot\n" + "! gleplot-meta-begin v1\n" + f"! gleplot: import-data = {listed}\n" + "! gleplot-meta-end\n" + ) + + # --------------------------------------------------------------------------- # # Finding 1 -- quoted data filenames # --------------------------------------------------------------------------- # def test_finding1_quoted_data_filename_resolves_and_imports(tmp_path): - src = ( + src = _meta("data_3.dat") + ( "size 20.32 15.24\n" "begin graph\n" ' data "data_3.dat" d1=c1,c2\n' " d1 line color blue lwidth 0.05\n" "end graph\n" ) - # data_3.dat matches the sidecar heuristic -> import (arrays loaded). + # data_3.dat is vouched by the metadata block -> import (arrays loaded). p = _write(tmp_path, "q.gle", src, {"data_3.dat": "0 0\n5 1\n10 0\n"}) rec = parse_gle_figure(p) ax = rec.figure.axes_list[0] @@ -50,8 +73,9 @@ def test_finding1_quoted_data_filename_resolves_and_imports(tmp_path): def test_finding1_quoted_missing_file_warns_and_broken_ref(tmp_path): # Previously-masked path: quoted name + missing file -> data: warning + - # broken-reference entry (not silent). - src = ( + # broken-reference entry (not silent). The file is metadata-vouched as an + # import, so the recognizer attempts to load it and surfaces the failure. + src = _meta("data_9.dat") + ( "size 20.32 15.24\n" "begin graph\n" ' data "data_9.dat" d1=c1,c2\n' @@ -70,7 +94,7 @@ def test_finding1_quoted_missing_file_warns_and_broken_ref(tmp_path): def test_finding1_out_of_range_column_warns(tmp_path): # Column c9 doesn't exist -> data: warning + broken-reference entry. - src = ( + src = _meta("data_2.dat") + ( "size 20.32 15.24\n" "begin graph\n" ' data "data_2.dat" d1=c1,c9\n' @@ -103,7 +127,7 @@ def test_finding1_header_and_comment_and_ragged(tmp_path): " d1 line color blue lwidth 0.05\n" "end graph\n" ) - p = _write(tmp_path, "q.gle", src, {"data_1.dat": dat}) + p = _write(tmp_path, "q.gle", _meta("data_1.dat") + src, {"data_1.dat": dat}) rec = parse_gle_figure(p) ax = rec.figure.axes_list[0] # The quoted filename resolved (header + comment lines skipped by loader). @@ -127,7 +151,7 @@ def test_finding2_multiline_dn_attributes_single_series(tmp_path): ' d1 key "A"\n' "end graph\n" ) - p = _write(tmp_path, "m.gle", src, {"data_4.dat": "0 0\n1 1\n"}) + p = _write(tmp_path, "m.gle", _meta("data_4.dat") + src, {"data_4.dat": "0 0\n1 1\n"}) rec = parse_gle_figure(p) ax = rec.figure.axes_list[0] # ONE line series, not three. @@ -146,7 +170,7 @@ def test_finding10_forward_reference_before_data(tmp_path): " data data_5.dat d1=c1,c2\n" "end graph\n" ) - p = _write(tmp_path, "f.gle", src, {"data_5.dat": "0 0\n1 1\n"}) + p = _write(tmp_path, "f.gle", _meta("data_5.dat") + src, {"data_5.dat": "0 0\n1 1\n"}) rec = parse_gle_figure(p) ax = rec.figure.axes_list[0] assert len(ax.lines) == 1 @@ -162,7 +186,7 @@ def test_finding2_last_wins_warns_on_overwrite(tmp_path): " d1 color blue\n" # overwrites color -> last wins "end graph\n" ) - p = _write(tmp_path, "lw.gle", src, {"data_6.dat": "0 0\n1 1\n"}) + p = _write(tmp_path, "lw.gle", _meta("data_6.dat") + src, {"data_6.dat": "0 0\n1 1\n"}) rec = parse_gle_figure(p) ax = rec.figure.axes_list[0] assert len(ax.lines) == 1 @@ -410,7 +434,7 @@ def test_finding8_auto_mapping_multicolumn(tmp_path): " d2 line color red lwidth 0.05\n" "end graph\n" ) - p = _write(tmp_path, "am.gle", src, {"data_50.dat": "0 10 100\n1 20 200\n"}) + p = _write(tmp_path, "am.gle", _meta("data_50.dat") + src, {"data_50.dat": "0 10 100\n1 20 200\n"}) rec = parse_gle_figure(p) ax = rec.figure.axes_list[0] assert len(ax.lines) == 2 @@ -430,7 +454,7 @@ def test_finding8_auto_mapping_single_column(tmp_path): " d1 line color blue lwidth 0.05\n" "end graph\n" ) - p = _write(tmp_path, "am1.gle", src, {"data_51.dat": "5\n6\n7\n"}) + p = _write(tmp_path, "am1.gle", _meta("data_51.dat") + src, {"data_51.dat": "5\n6\n7\n"}) rec = parse_gle_figure(p) ax = rec.figure.axes_list[0] assert len(ax.lines) == 1 @@ -465,7 +489,7 @@ def test_finding9_positional_dataset_names(tmp_path): " d3 line color red lwidth 0.05\n" "end graph\n" ) - p = _write(tmp_path, "pos.gle", src, {"data_52.dat": "0 10 100\n1 20 200\n"}) + p = _write(tmp_path, "pos.gle", _meta("data_52.dat") + src, {"data_52.dat": "0 10 100\n1 20 200\n"}) rec = parse_gle_figure(p) ax = rec.figure.axes_list[0] assert len(ax.lines) == 2 @@ -487,7 +511,7 @@ def test_finding11_constant_error_converted(tmp_path): " d1 marker circle err 0.5\n" "end graph\n" ) - p = _write(tmp_path, "err.gle", src, {"data_53.dat": "0 2\n1 4\n2 8\n"}) + p = _write(tmp_path, "err.gle", _meta("data_53.dat") + src, {"data_53.dat": "0 2\n1 4\n2 8\n"}) rec = parse_gle_figure(p) ax = rec.figure.axes_list[0] assert len(ax.errorbars) == 1 @@ -506,7 +530,7 @@ def test_finding11_percentage_error_converted(tmp_path): " d1 marker circle err 10%\n" "end graph\n" ) - p = _write(tmp_path, "errp.gle", src, {"data_54.dat": "0 2\n1 4\n2 8\n"}) + p = _write(tmp_path, "errp.gle", _meta("data_54.dat") + src, {"data_54.dat": "0 2\n1 4\n2 8\n"}) rec = parse_gle_figure(p) ax = rec.figure.axes_list[0] assert len(ax.errorbars) == 1 @@ -545,7 +569,7 @@ def test_finding12_duplicate_data_redefinition_warns(tmp_path): " d1 line color blue lwidth 0.05\n" "end graph\n" ) - p = _write(tmp_path, "dup.gle", src, {"data_55.dat": "0 10 100\n1 20 200\n"}) + p = _write(tmp_path, "dup.gle", _meta("data_55.dat") + src, {"data_55.dat": "0 10 100\n1 20 200\n"}) rec = parse_gle_figure(p) ax = rec.figure.axes_list[0] # Last-wins: d1 uses c3. @@ -597,3 +621,736 @@ def test_finding13_normal_file_no_programmatic_warning(tmp_path): p = _write(tmp_path, "normal.gle", src, {"data_l.dat": "0 0\n1 1\n"}) rec = parse_gle_figure(p) assert not any(w.startswith("programmatic:") for w in rec.warnings) + + +# --------------------------------------------------------------------------- # +# CRITICAL FINDING 1 -- hand-written .gle must never overwrite a user's file +# --------------------------------------------------------------------------- # + +def _md5(path: Path) -> str: + import hashlib + + return hashlib.md5(path.read_bytes()).hexdigest() + + +def test_critical1_handwritten_gle_does_not_overwrite_user_datafile(tmp_path): + """Reviewer's exact repro: a hand-written .gle with NO gleplot metadata + block references 'results_2024.dat' -- an ordinary user file whose name + happens to match the old sidecar heuristic. Parsing then re-saving to the + SAME path must leave the referenced data file byte-for-byte UNCHANGED (the + header must not be corrupted, e.g. 'Temperature (K) Resistance (Ohm)' + silently truncated to 'Temperature (K)'). + """ + dat_content = ( + "Temperature (K) Resistance (Ohm)\n" + "1.0 100.0\n" + "2.0 200.0\n" + "3.0 300.0\n" + ) + src = ( + "size 20.32 15.24\n" + "begin graph\n" + " data results_2024.dat d1=c1,c2\n" + " d1 line color blue lwidth 0.05\n" + "end graph\n" + ) + gle = _write(tmp_path, "hand.gle", src, {"results_2024.dat": dat_content}) + dat_path = tmp_path / "results_2024.dat" + + before = _md5(dat_path) + rec = parse_gle_figure(gle) + + # The reference is treated as an external file reference (NOT adopted as an + # editable import), so nothing pulls the file's header into column_names. + ax = rec.figure.axes_list[0] + assert ax.lines == [] + assert len(ax.file_series) == 1 + assert ax.file_series[0]["data_file"] == "results_2024.dat" + + # Re-save to the SAME .gle path; the referenced data file must be untouched. + rec.figure.savefig_gle(str(gle)) + after = _md5(dat_path) + assert before == after, ( + "user's data file was silently rewritten by savefig_gle" + ) + # Belt-and-braces: the multi-word header survived verbatim. + assert dat_path.read_text() == dat_content + + +def test_critical1_vouched_sidecar_still_imports(tmp_path): + """Counterpart: a gleplot-authored .gle WITH a metadata block vouching the + same-shaped filename is still adopted as an editable import. + """ + src = ( + "! GLE graphics file\n" + "! Generated by gleplot\n" + "! gleplot-meta-begin v1\n" + "! gleplot: import-data = results_2024.dat\n" + "! gleplot-meta-end\n" + "size 20.32 15.24\n" + "begin graph\n" + " data results_2024.dat d1=c1,c2\n" + " d1 line color blue lwidth 0.05\n" + "end graph\n" + ) + gle = _write(tmp_path, "owned.gle", src, {"results_2024.dat": "0 0\n1 1\n"}) + rec = parse_gle_figure(gle) + ax = rec.figure.axes_list[0] + assert len(ax.lines) == 1 + assert ax.file_series == [] + + +def test_critical1_vouched_sidecar_unsanitary_header_roundtrips_byte_identical(tmp_path): + """ALSO (recognizer invariant): a VOUCHED sidecar whose header was + hand-edited to an unsanitary value round-trips BYTE-IDENTICALLY -- the + recovered column_names copy the header verbatim (no re-sanitization), so + re-save reproduces the same header bytes. + """ + # Single-token headers containing characters sanitize_column_name would + # normally strip/lower (uppercase, '-', digit-leading). They are copied + # VERBATIM (no re-sanitization) into column_names, so the writer re-emits + # the same header bytes on save -> byte-identical round-trip. (Whitespace + # token-count mismatch is a separate concern -- Finding 3 -- so headers + # here are single whitespace-delimited tokens matching the writer's own + # space-joined format.) + dat_content = "My-Col1 9Col2\n0 0\n1 1\n" + src = ( + "! GLE graphics file\n" + "! Generated by gleplot\n" + "! gleplot-meta-begin v1\n" + "! gleplot: import-data = data_9.dat\n" + "! gleplot-meta-end\n" + "size 20.32 15.24\n" + "begin graph\n" + " data data_9.dat d1=c1,c2\n" + " d1 line color blue lwidth 0.05\n" + "end graph\n" + ) + gle = _write(tmp_path, "owned2.gle", src, {"data_9.dat": dat_content}) + dat_path = tmp_path / "data_9.dat" + before = dat_path.read_bytes() + + rec = parse_gle_figure(gle) + ax = rec.figure.axes_list[0] + assert len(ax.lines) == 1 + # column_names came verbatim from the (unsanitary) header -- NOT lowercased, + # not stripped, not prefixed. + assert ax.lines[0]["column_names"] == ["My-Col1", "9Col2"] + + +def test_comment_header_vouched_sidecar_does_not_adopt_comment_names(tmp_path): + """Comment-line column-header recovery (gleplot.dataio) must NOT feed the + recognizer's metadata-vouch path: a VOUCHED sidecar whose ONLY column + names come from a trailing COMMENT line (no inline header row at all) + must behave EXACTLY like an ordinary headerless sidecar with no comment + at all -- the comment-derived names must never be adopted as + ``column_names`` for regeneration. + + Why this matters: gleplot.dataio.load_data_file now recovers + column_names from a trailing comment line when there is no inline + header (display-name recovery for the Data dock). But + _recovered_column_names (recognizer.py) is ALSO used to recover a + VOUCHED import series' column_names for regeneration on next save -- + if it read comment-derived names too, a hand-edited vouched sidecar + with a comment header would have those names adopted verbatim and + (per the existing Critical-1 invariant) re-emitted as a brand-new + INLINE header line reproducing that exact text on next save -- + changing the file's bytes for a "just display names" feature that is + supposed to be read-only with respect to save output. The guard + (`if not table.has_header: return None`) already prevents this since + comment-derived recovery always leaves has_header False. + + Note: a headerless sidecar (with or without a comment line) already + gets a *stable default* header line regenerated on next save (see + ``Axes._default_column_names``, "pre-Track-E3 projects" fallback -- + documented, pre-existing behavior, not something this feature changes + or should suppress). The invariant this test pins is narrower and + exact: the regenerated header must be the SAME stable default in both + cases, i.e. comment-header recovery makes no observable difference to + what the recognizer/writer produce. + """ + + def _round_trip(dat_content: str, gle_name: str, dat_name: str): + src = ( + "! GLE graphics file\n" + "! Generated by gleplot\n" + "! gleplot-meta-begin v1\n" + f"! gleplot: import-data = {dat_name}\n" + "! gleplot-meta-end\n" + "size 20.32 15.24\n" + "begin graph\n" + f' data {dat_name} d1=c1,c2\n' + " d1 line color blue lwidth 0.05\n" + "end graph\n" + ) + gle = _write(tmp_path, gle_name, src, {dat_name: dat_content}) + rec = parse_gle_figure(gle) + ax = rec.figure.axes_list[0] + assert len(ax.lines) == 1 + return ax, rec + + # (a) No comment line at all -- ordinary headerless sidecar; this is the + # existing, well-understood baseline behavior. + ax_plain, _ = _round_trip("0 0\n1 1\n", "plain.gle", "data_plain.dat") + + # (b) Same data, but with a trailing comment line that WOULD qualify as + # a comment-derived header at the dataio level (table.column_names == + # ['x', 'y'], header_source == 'comment'). + ax_comment, rec_comment = _round_trip("! x y\n0 0\n1 1\n", "owned3.gle", "data_9.dat") + + # Confirm the dataio-level recovery actually fired for sanity (the test + # would be vacuous otherwise). + from gleplot.dataio import load_data_file + + table = load_data_file(tmp_path / "data_9.dat") + assert table.has_header is False + assert table.column_names == ["x", "y"] + assert table.header_source == "comment" + + # The recognizer's regenerated column_names must be IDENTICAL between + # the plain and comment-header cases -- the comment text made no + # difference to what gets adopted for regeneration. + assert ax_comment.lines[0].get("column_names") == ax_plain.lines[0].get( + "column_names" + ) + + # End-to-end: re-saving the comment-header case must reproduce the + # stable default header line, not the original '! x y' comment text + # promoted to an inline header. + out_dir = tmp_path / "resaved" + out_dir.mkdir() + rec_comment.figure.savefig_gle(str(out_dir / "owned3.gle")) + resaved = (out_dir / "data_9.dat").read_text(encoding="utf-8") + first_line = resaved.splitlines()[0] + assert first_line == "x y" + assert not first_line.startswith("!") + + # (c) Same safety property for the newer INDEXED '! c N = name' block + # (gleplot.dataio._recover_indexed_comment_header): a vouched sidecar + # whose only names come from a 'c 1 = x' / 'c 2 = y' comment block must + # ALSO behave exactly like the plain headerless case -- these names are + # unambiguous (unlike the positional last-line heuristic) but are still + # comment-derived, so the same has_header-gated safety applies. + ax_indexed, rec_indexed = _round_trip( + "! c 1 = x\n! c 2 = y\n0 0\n1 1\n", "owned4.gle", "data_10.dat" + ) + + table_indexed = load_data_file(tmp_path / "data_10.dat") + assert table_indexed.has_header is False + assert table_indexed.column_names == ["x", "y"] + assert table_indexed.header_source == "comment" + + assert ax_indexed.lines[0].get("column_names") == ax_plain.lines[0].get( + "column_names" + ) + + rec_indexed.figure.savefig_gle(str(out_dir / "owned4.gle")) + resaved_indexed = (out_dir / "data_10.dat").read_text(encoding="utf-8") + first_line_indexed = resaved_indexed.splitlines()[0] + assert first_line_indexed == "x y" + assert not first_line_indexed.startswith("!") + + +# --------------------------------------------------------------------------- # +# Finding 14 -- post-graph text clusters: sticky/optional 'set hei'/'set +# color'/'set just', and string fidelity for backslash+brace content. +# --------------------------------------------------------------------------- # +# +# The writer (writer.py GLEWriter.add_text) always restates 'set color' and +# 'set just' for every text and conditionally restates 'set hei' -- but a +# hand-written (or hand-edited) .gle file may rely on GLE's real interpreter +# semantics instead: 'set hei'/'set color'/'set just' are GLOBAL state that +# persists across statements until changed again. A cluster may therefore +# omit any subset of the three 'set' lines and inherit whatever was last in +# effect. Before this fix, _try_one_text() required 'set color' and 'set +# just' on EVERY cluster and re-initialized fontsize/color/just to hardcoded +# defaults on each attempt (no memory across clusters) -- so any cluster +# missing 'set color' or 'set just' failed to match, and _consume_text_cluster +# aborts its ENTIRE loop on the first failed cluster, dumping the whole +# remainder (including already-matched-looking earlier lines) into +# fig.passthrough_trailer verbatim instead of ax.texts. + +def test_finding14_sticky_state_recovers_three_unevenly_annotated_clusters(tmp_path): + """The reported real-world shape: a single 'set hei' shared by multiple + clusters, a repeated (redundant) 'set just left', and a THIRD cluster with + no 'set just' (or 'set hei'/'set color') at all. All three must land in + ax.texts with the sticky-inherited fontsize/color/ha, in order, and the + third text's braces+backslash content must survive byte-exact. + """ + src = ( + "size 10 8\n" + "begin graph\n" + " size 10 8\n" + " xtitle \"X\"\n" + " ytitle \"Y\"\n" + " xaxis min 0 max 3000\n" + " yaxis min 0 max 1\n" + "end graph\n" + "set hei 0.7\n" + "set just left\n" + "amove xg(40) yg(0.34)\n" + "write \"beta\"\n" + "set just left\n" + "amove xg(40) yg(0.09)\n" + "write \"alpha\"\n" + "amove xg(2000) yg(0.6)\n" + "write \"{\\it{T}} = 0.1 K\"\n" + ) + gle = _write(tmp_path, "annot.gle", src) + rec = parse_gle_figure(gle) + ax = rec.figure.axes_list[0] + + # All three clusters recognized -- nothing left over in passthrough. + assert len(ax.texts) == 3 + assert ax.passthrough == [] + assert rec.figure.passthrough_trailer == [] + + from gleplot.parser.units import fontsize_cm_to_pt + + expected_fontsize = fontsize_cm_to_pt(0.7) # 0.7cm -> pt (28.35 divisor) + assert expected_fontsize == pytest.approx(19.845) + + beta, alpha, temp = ax.texts + + assert beta["x"] == pytest.approx(40.0) + assert beta["y"] == pytest.approx(0.34) + assert beta["text"] == "beta" + assert beta["ha"] == "left" + assert beta["fontsize"] == pytest.approx(expected_fontsize) + + assert alpha["x"] == pytest.approx(40.0) + assert alpha["y"] == pytest.approx(0.09) + assert alpha["text"] == "alpha" + assert alpha["ha"] == "left" + # Sticky: no 'set hei' before this cluster -> inherits the 0.7cm value. + assert alpha["fontsize"] == pytest.approx(expected_fontsize) + + # Third cluster has NO 'set' statements at all -- sticky hei/just/color + # from the earlier clusters carry forward. + assert temp["x"] == pytest.approx(2000.0) + assert temp["y"] == pytest.approx(0.6) + assert temp["ha"] == "left" + assert temp["fontsize"] == pytest.approx(expected_fontsize) + assert temp["color"] == "BLACK" + # Byte-exact string fidelity: braces and the backslash-escape-looking + # '\it' must survive completely unmodified (GLE inline-TeX italics). + assert temp["text"] == "{\\it{T}} = 0.1 K" + + +def test_finding14_resave_reproduces_minimal_sticky_gle(tmp_path): + """Re-saving the recovered figure must not restate redundant 'set + color'/'set just' lines (they match the already-sticky BLACK/left), and + must reproduce the write strings byte-identically including braces and + the backslash. + """ + src = ( + "size 10 8\n" + "begin graph\n" + " size 10 8\n" + " xaxis min 0 max 3000\n" + " yaxis min 0 max 1\n" + "end graph\n" + "set hei 0.7\n" + "set just left\n" + "amove xg(40) yg(0.34)\n" + "write \"beta\"\n" + "set just left\n" + "amove xg(40) yg(0.09)\n" + "write \"alpha\"\n" + "amove xg(2000) yg(0.6)\n" + "write \"{\\it{T}} = 0.1 K\"\n" + ) + gle = _write(tmp_path, "annot2.gle", src) + rec = parse_gle_figure(gle) + + out = tmp_path / "resaved.gle" + rec.figure.savefig_gle(str(out)) + resaved = out.read_text(encoding="utf-8") + + # The three write statements are present, byte-exact. + assert 'write "beta"' in resaved + assert 'write "alpha"' in resaved + assert 'write "{\\it{T}} = 0.1 K"' in resaved + + # Only ONE 'set hei 0.7' for the whole trailing block: color/just are + # already BLACK/left (the sticky default), so no redundant lines. + trailer_lines = resaved.splitlines()[resaved.splitlines().index("end graph") + 1:] + assert trailer_lines.count("set hei 0.7") == 1 + assert not any(l.strip().startswith("set color") for l in trailer_lines) + assert not any(l.strip().startswith("set just") for l in trailer_lines) + + +def test_finding14_resave_compiles_in_real_gle(tmp_path): + """The recovered + re-saved figure must be valid, compilable GLE.""" + import shutil + import subprocess + + gle_exe = shutil.which("gle") or r"C:\Program Files\GLE\bin\gle.exe" + if not Path(gle_exe).exists(): + pytest.skip("GLE not installed") + + src = ( + "size 10 8\n" + "begin graph\n" + " size 10 8\n" + " xaxis min 0 max 3000\n" + " yaxis min 0 max 1\n" + "end graph\n" + "set hei 0.7\n" + "set just left\n" + "amove xg(40) yg(0.34)\n" + "write \"beta\"\n" + "set just left\n" + "amove xg(40) yg(0.09)\n" + "write \"alpha\"\n" + "amove xg(2000) yg(0.6)\n" + "write \"{\\it{T}} = 0.1 K\"\n" + ) + gle = _write(tmp_path, "annot3.gle", src) + rec = parse_gle_figure(gle) + out = tmp_path / "out.gle" + rec.figure.savefig_gle(str(out)) + + res = subprocess.run( + [gle_exe, "-d", "png", "-o", str(tmp_path / "out.png"), str(out)], + capture_output=True, text=True, cwd=str(tmp_path), + ) + assert res.returncode == 0, f"GLE failed:\n{res.stdout}\n{res.stderr}" + assert (tmp_path / "out.png").exists() + + +def test_finding14_optional_set_color_alone_is_sticky(tmp_path): + """A cluster with only 'set color' (no hei, no just) still matches, and + a later cluster with no 'set' at all inherits that color. + """ + src = ( + "size 10 8\n" + "begin graph\n" + " size 10 8\n" + " xaxis min 0 max 10\n" + " yaxis min 0 max 10\n" + "end graph\n" + "set color RED\n" + "amove xg(1) yg(1)\n" + "write \"first\"\n" + "amove xg(2) yg(2)\n" + "write \"second\"\n" + ) + gle = _write(tmp_path, "coloronly.gle", src) + rec = parse_gle_figure(gle) + ax = rec.figure.axes_list[0] + assert len(ax.texts) == 2 + assert ax.texts[0]["color"] == "RED" + assert ax.texts[0]["ha"] == "left" + # Sticky color inherited by the second cluster despite no 'set color'. + assert ax.texts[1]["color"] == "RED" + assert ax.texts[1]["ha"] == "left" + + +def test_finding14_cluster_inside_graph_block_stays_passthrough(tmp_path): + """A hand-written text cluster placed BEFORE 'end graph' (inside the + graph body) is a different shape than the writer's post-graph pattern; + the recognizer does not attempt to recognize it as a Text there. It is + preserved losslessly as axes passthrough (not fabricated into ax.texts, + not dropped) -- documented behavior, not necessarily supported as an + editable annotation. + """ + src = ( + "size 10 8\n" + "begin graph\n" + " size 10 8\n" + " xaxis min 0 max 10\n" + " yaxis min 0 max 10\n" + " set hei 0.5\n" + " set just left\n" + " amove xg(1) yg(1)\n" + " write \"inside\"\n" + "end graph\n" + ) + gle = _write(tmp_path, "inside.gle", src) + rec = parse_gle_figure(gle) + ax = rec.figure.axes_list[0] + assert ax.texts == [] + assert any("write \"inside\"" in line for line in ax.passthrough) + + # Losslessly re-emitted (still valid, still contains the write). + out = tmp_path / "resaved.gle" + rec.figure.savefig_gle(str(out)) + resaved = out.read_text(encoding="utf-8") + assert 'write "inside"' in resaved + + +# --------------------------------------------------------------------------- # +# Finding 15 -- write-string fidelity: backslash + brace content must not be +# corrupted by string unescaping (only '\"'/"\\'" are ever unescaped). +# --------------------------------------------------------------------------- # + +def test_finding15_backslash_brace_string_round_trips_byte_exact(tmp_path): + """GLE inline-TeX-italics markup like '{\\it{T}} = 0.1 K' contains a + backslash not followed by a quote character. _string_value must not + treat '\\i' as an escape sequence (it only unescapes '\\"'/"\\'"), and the + lexer's raw token slice must not drop the backslash either. + """ + src = ( + "size 10 8\n" + "begin graph\n" + " size 10 8\n" + " xaxis min 0 max 10\n" + " yaxis min 0 max 10\n" + "end graph\n" + "amove xg(1) yg(1)\n" + "write \"{\\it{T}} = 0.1 K\"\n" + ) + gle = _write(tmp_path, "fidelity.gle", src) + rec = parse_gle_figure(gle) + ax = rec.figure.axes_list[0] + assert len(ax.texts) == 1 + assert ax.texts[0]["text"] == "{\\it{T}} = 0.1 K" + + # Re-save must reproduce the exact same quoted string. + out = tmp_path / "resaved.gle" + rec.figure.savefig_gle(str(out)) + resaved = out.read_text(encoding="utf-8") + assert 'write "{\\it{T}} = 0.1 K"' in resaved + + +def test_finding15_backslash_brace_survives_a_second_round_trip(tmp_path): + """Parse -> save -> parse -> save must be a fixed point for the + backslash+brace text (not just the first round trip).""" + src = ( + "size 10 8\n" + "begin graph\n" + " size 10 8\n" + " xaxis min 0 max 10\n" + " yaxis min 0 max 10\n" + "end graph\n" + "amove xg(1) yg(1)\n" + 'write "{\\it{T}} = 0.1 K"\n' + ) + gle = _write(tmp_path, "fidelity2.gle", src) + rec1 = parse_gle_figure(gle) + out1 = tmp_path / "out1.gle" + rec1.figure.savefig_gle(str(out1)) + text1 = out1.read_text(encoding="utf-8") + + rec2 = parse_gle_figure(out1) + out2 = tmp_path / "out2.gle" + rec2.figure.savefig_gle(str(out2)) + text2 = out2.read_text(encoding="utf-8") + + assert text1 == text2 + assert rec2.figure.axes_list[0].texts[0]["text"] == "{\\it{T}} = 0.1 K" + + +# --------------------------------------------------------------------------- # +# Finding 16 -- a blank line between text clusters (or between 'end graph' +# and the first cluster) aborted _consume_text_cluster's ENTIRE scan on real +# user data, silently dumping every cluster from that point on -- including +# already-matched-looking ones -- into fig.passthrough_trailer. +# +# Root cause: _try_one_text(nodes, i) is handed a BlankOrComment node at +# ``i`` whenever a blank line separates two clusters (a hand-edited file, not +# anything the writer itself emits -- see writer.add_text, which never +# inserts blank lines between clusters). _as_statement(BlankOrComment) always +# returned None, so the mandatory 'amove xg()/yg()' check failed immediately +# and _try_one_text returned (i, None) with no attempt to look past the +# blank line. _consume_text_cluster's outer while-loop then breaks on the +# very first failure, so EVERYTHING from that blank line onward -- the blank +# line itself, plus the next cluster's amove/write lines, one node per loop +# iteration in the top-level driver -- lands in passthrough_trailer. This is +# why a file with the blank line positioned right before the LAST cluster +# shows a 3-entry trailer for what looks like 2 physical lines: the blank +# line is entry 1, the final 'amove xg(...) yg(...)' is entry 2, and the +# final 'write "..."' is entry 3 (three separate Node objects each pushed +# individually by ``trailer.extend(self._raw_lines(node))`` in ``run()``). +# +# Explicitly NOT the cause (verified empirically before fixing): trailing +# whitespace after the closing quote, CRLF line endings, and a missing final +# newline at EOF are all handled correctly by the lexer/syntax layers already +# -- none of those three break _try_one_text in isolation. Only the blank +# line does. +# +# Fix: _try_one_text now calls _skip_blanks() to tolerate a run of +# BlankOrComment nodes before each mandatory element (the 'set' run, the +# 'amove', and the 'write'), but only PROVISIONALLY -- if the pattern still +# fails to complete, the ORIGINAL index (not the blank-skipped one) is +# returned, so the blank line and everything after it still falls through to +# ordinary passthrough handling untouched when it is NOT actually a text +# cluster. +# --------------------------------------------------------------------------- # + +def test_finding16_blank_line_before_final_cluster_is_recognized(tmp_path): + """Reproduces the reported real-file shape (synthetic recreation): a + graph, an earlier cluster that already parses fine, a blank line, then a + FINAL cluster whose write string has a trailing space after the closing + quote and is the last line of the file. Before the fix, the blank line + aborted the scan and the final cluster's amove+write leaked into + passthrough_trailer (3 entries: the blank line + the 2 leaked + statements). After the fix, both clusters recognize and the trailer is + empty. + """ + src = ( + "size 10 8\n" + "begin graph\n" + " size 10 8\n" + " xaxis min 0 max 3000\n" + " yaxis min 0 max 1\n" + "end graph\n" + "amove xg(500) yg(0.9)\n" + 'write "first"\n' + "\n" + "amove xg(2000) yg(0.6)\n" + 'write "{\\it{T}} = 0.1 K" \n' # trailing space after the closing quote + ) + gle = _write(tmp_path, "blankcluster.gle", src) + rec = parse_gle_figure(gle) + ax = rec.figure.axes_list[0] + + assert rec.figure.passthrough_trailer == [] + assert len(ax.texts) == 2 + first, temp = ax.texts + assert first["text"] == "first" + assert temp["x"] == pytest.approx(2000.0) + assert temp["y"] == pytest.approx(0.6) + # The trailing space after the closing quote is NOT part of the string + # token (it lies outside the quotes) -- recovered text is exactly the + # quoted content, byte-exact. + assert temp["text"] == "{\\it{T}} = 0.1 K" + + +@pytest.mark.parametrize( + "ending,final_newline", + [ + ("\n", True), + ("\n", False), + ("\r\n", True), + ("\r\n", False), + ], + ids=["lf_with_eof_nl", "lf_no_eof_nl", "crlf_with_eof_nl", "crlf_no_eof_nl"], +) +def test_finding16_eof_and_line_ending_variants_all_recognized( + tmp_path, ending, final_newline +): + """The blank-line fix must hold regardless of line-ending style and + regardless of whether the file ends with a trailing newline -- these + axes are independent of the blank-line bug but are verified together + here since they were the other real-file variants under suspicion. + """ + lines = [ + "size 10 8", + "begin graph", + " size 10 8", + " xaxis min 0 max 3000", + " yaxis min 0 max 1", + "end graph", + "amove xg(500) yg(0.9)", + 'write "first"', + "", + "amove xg(2000) yg(0.6)", + 'write "{\\it{T}} = 0.1 K" ', + ] + text = ending.join(lines) + if final_newline: + text += ending + path = tmp_path / "eofvariant.gle" + with open(path, "w", newline="") as f: + f.write(text) + + rec = parse_gle_figure(path) + ax = rec.figure.axes_list[0] + + assert rec.figure.passthrough_trailer == [] + assert len(ax.texts) == 2 + assert ax.texts[0]["text"] == "first" + assert ax.texts[1]["text"] == "{\\it{T}} = 0.1 K" + + +def test_finding16_blank_line_before_first_cluster_after_end_graph(tmp_path): + """A blank line directly after 'end graph', before the FIRST cluster, + must also be tolerated (not just blanks between two already-recognized + clusters) -- this was the other failing position identified while + isolating the bug. + """ + src = ( + "size 10 8\n" + "begin graph\n" + " size 10 8\n" + " xaxis min 0 max 10\n" + " yaxis min 0 max 10\n" + "end graph\n" + "\n" + "amove xg(1) yg(1)\n" + 'write "only"\n' + ) + gle = _write(tmp_path, "blankfirst.gle", src) + rec = parse_gle_figure(gle) + ax = rec.figure.axes_list[0] + + assert rec.figure.passthrough_trailer == [] + assert len(ax.texts) == 1 + assert ax.texts[0]["text"] == "only" + + +def test_finding16_blank_line_then_unrelated_content_stays_passthrough(tmp_path): + """A blank line followed by content that is NOT a valid text cluster + (e.g. a dangling 'set color' with nothing after it) must still fall + through to passthrough_trailer untouched -- the blank-line tolerance + must not turn genuinely non-cluster content into a swallowed/dropped + line. This pins the 'provisional skip, restore original index on + failure' half of the fix. + """ + src = ( + "size 10 8\n" + "begin graph\n" + " size 10 8\n" + " xaxis min 0 max 10\n" + " yaxis min 0 max 10\n" + "end graph\n" + "amove xg(1) yg(1)\n" + 'write "first"\n' + "\n" + "set color blue\n" + ) + gle = _write(tmp_path, "danglingset.gle", src) + rec = parse_gle_figure(gle) + ax = rec.figure.axes_list[0] + + assert len(ax.texts) == 1 + assert ax.texts[0]["text"] == "first" + # The blank line + the dangling 'set color blue' (no following amove) + # are preserved verbatim in the trailer, not silently dropped. + assert rec.figure.passthrough_trailer == ["", "set color blue"] + + +def test_finding16_resave_is_byte_exact_fixed_point(tmp_path): + """Parse -> save -> parse -> save must be a fixed point once the blank- + line-separated clusters are recognized (the recovered figure has no + blank lines between its own clusters, since the writer never emits + them -- see writer.add_text -- so re-emission is canonical, not a replay + of the original blank-line formatting).""" + src = ( + "size 10 8\n" + "begin graph\n" + " size 10 8\n" + " xaxis min 0 max 10\n" + " yaxis min 0 max 10\n" + "end graph\n" + "amove xg(1) yg(1)\n" + 'write "first"\n' + "\n" + "amove xg(2) yg(2)\n" + 'write "{\\it{T}} = 0.1 K" \n' + ) + gle = _write(tmp_path, "fixedpoint.gle", src) + rec1 = parse_gle_figure(gle) + out1 = tmp_path / "out1.gle" + rec1.figure.savefig_gle(str(out1)) + text1 = out1.read_text(encoding="utf-8") + + rec2 = parse_gle_figure(out1) + out2 = tmp_path / "out2.gle" + rec2.figure.savefig_gle(str(out2)) + text2 = out2.read_text(encoding="utf-8") + + assert text1 == text2 + assert rec2.figure.axes_list[0].texts[1]["text"] == "{\\it{T}} = 0.1 K" diff --git a/tests/unit/test_column_names.py b/tests/unit/test_column_names.py new file mode 100644 index 0000000..06e21f4 --- /dev/null +++ b/tests/unit/test_column_names.py @@ -0,0 +1,402 @@ +"""Unit tests for Track E3: named column headers in generated data sidecars. + +Covers: +- ``gleplot.axes.sanitize_column_name``: the sanitizer rules (charset, + lowercasing, de-duplication, numeric-collision guard). +- ``column_names`` schema on each series type (line/scatter/bar/fill/ + errorbar), as produced by the plotting methods at ``plot()``-time. +- ``GLEWriter.add_data_file`` header-row emission and the column-count + validation guard. +- Absent-``column_names`` tolerance on ``Axes.from_dict`` (older projects): + defaults are regenerated rather than the sidecar silently reverting to + headerless. +- Round-trip fidelity through ``gleplot.parser.recognizer``, including a + user-renamed column persisting across save -> parse -> save. + +The GLE-side "does a header row change rendering" question (auto-key from +header text) is a writer/GLE-compile concern, covered by +``tests/integration/test_graphics_compilation.py``, not here. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import gleplot as glp +from gleplot import axes as glp_axes +from gleplot.axes import ( + _build_column_names, + _build_errorbar_column_names, + _unique_column_names, + sanitize_column_name, +) +from gleplot.figure import Figure +from gleplot.parser.recognizer import parse_gle_figure +from gleplot.writer import GLEWriter + + +@pytest.fixture(autouse=True) +def _reset_counter(): + glp_axes._global_data_file_counter = 0 + glp.close() + yield + glp_axes._global_data_file_counter = 0 + glp.close() + + +# --------------------------------------------------------------------------- # +# Sanitizer rules +# --------------------------------------------------------------------------- # + + +def test_sanitizer_lowercases(): + assert sanitize_column_name("Signal") == "signal" + + +def test_sanitizer_replaces_non_word_chars_with_underscore(): + assert sanitize_column_name("V (mV)") == "v_mv" + + +def test_sanitizer_collapses_repeated_underscores(): + assert sanitize_column_name("a---b") == "a_b" + + +def test_sanitizer_strips_leading_trailing_underscores(): + assert sanitize_column_name(" !signal! ") == "signal" + + +def test_sanitizer_empty_falls_back(): + assert sanitize_column_name("", fallback="y") == "y" + assert sanitize_column_name("###", fallback="y") == "y" + + +def test_sanitizer_never_produces_whitespace(): + result = sanitize_column_name("a b\tc\nd") + assert " " not in result and "\t" not in result and "\n" not in result + + +def test_sanitizer_purely_numeric_label_gets_prefixed(): + # A bare numeric-looking name would defeat GLE's header auto-detection + # (GLE requires EVERY first-row cell to fail float parsing), so it must + # never be returned verbatim. + result = sanitize_column_name("2024", fallback="y") + assert result == "y_2024" + with pytest.raises(ValueError): + float(result) + + +def test_sanitizer_numeric_after_sanitizing_gets_prefixed(): + # "1e5" survives the charset filter untouched but IS a float. + result = sanitize_column_name("1e5", fallback="col") + with pytest.raises(ValueError): + float(result) + + +def test_sanitizer_unicode_becomes_underscore(): + result = sanitize_column_name("café Ω") + assert result != "" + for ch in result: + assert ch in "abcdefghijklmnopqrstuvwxyz0123456789_" + + +def test_unique_column_names_dedupes_with_suffixes(): + assert _unique_column_names(["x", "y", "y"]) == ["x", "y", "y_2"] + assert _unique_column_names(["a", "a", "a"]) == ["a", "a_2", "a_3"] + + +def test_unique_column_names_avoids_colliding_with_existing_suffix(): + # If 'y_2' already appears verbatim before the second 'y', the + # generated suffix must skip past it. + assert _unique_column_names(["y", "y_2", "y"]) == ["y", "y_2", "y_3"] + + +# --------------------------------------------------------------------------- # +# column_names schema per series type +# --------------------------------------------------------------------------- # + + +def test_line_column_names_default(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.plot([1, 2, 3], [1, 2, 3]) + assert ax.lines[0]["column_names"] == ["x", "y"] + + +def test_line_column_names_from_label(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.plot([1, 2, 3], [1, 2, 3], label="Signal A") + assert ax.lines[0]["column_names"] == ["x", "signal_a"] + + +def test_scatter_column_names(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.scatter([1, 2, 3], [1, 2, 3], label="pts") + assert ax.scatters[0]["column_names"] == ["x", "pts"] + + +def test_bar_column_names_default(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.bar([1, 2, 3], [4, 5, 6]) + assert ax.bars[0]["column_names"] == ["x", "height"] + + +def test_bar_column_names_from_label(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.bar([1, 2, 3], [4, 5, 6], label="counts") + assert ax.bars[0]["column_names"] == ["x", "counts"] + + +def test_fill_column_names(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.fill_between([1, 2, 3], [0, 0, 0], [1, 2, 3]) + assert ax.fills[0]["column_names"] == ["x", "upper", "lower"] + + +def test_errorbar_symmetric_column_names(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.errorbar([1, 2, 3], [1, 2, 3], yerr=0.5, label="temp") + assert ax.errorbars[0]["column_names"] == ["x", "temp", "err"] + + +def test_errorbar_asymmetric_column_names(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.errorbar([1, 2, 3], [1, 2, 3], yerr=([0.1, 0.1, 0.1], [0.2, 0.2, 0.2])) + assert ax.errorbars[0]["column_names"] == ["x", "y", "err_up", "err_down"] + + +def test_errorbar_xy_column_names(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.errorbar([1, 2, 3], [1, 2, 3], yerr=0.1, xerr=0.2) + assert ax.errorbars[0]["column_names"] == ["x", "y", "err", "xerr"] + + +def test_column_names_unique_within_file_when_label_collides_with_base_name(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + # Label sanitizes to 'x', colliding with the x column's own name. + ax.plot([1, 2, 3], [1, 2, 3], label="X") + names = ax.lines[0]["column_names"] + assert names[0] == "x" + assert names[1] == "x_2" + assert len(set(names)) == len(names) + + +# --------------------------------------------------------------------------- # +# Writer: header emission + column count guard +# --------------------------------------------------------------------------- # + + +def test_add_data_file_emits_header_row_first_line(): + writer = GLEWriter() + writer.add_data_file( + "d.dat", [np.array([1.0, 2.0]), np.array([3.0, 4.0])], + column_names=["x", "signal"], + ) + content = writer.data_files["d.dat"] + assert content.splitlines()[0] == "x signal" + assert content.splitlines()[1] == "1 3" + + +def test_add_data_file_no_header_when_column_names_absent(): + writer = GLEWriter() + writer.add_data_file("d.dat", [np.array([1.0]), np.array([2.0])]) + content = writer.data_files["d.dat"] + assert content.splitlines()[0] == "1 2" + + +def test_add_data_file_rejects_mismatched_column_count(): + writer = GLEWriter() + with pytest.raises(ValueError): + writer.add_data_file( + "d.dat", [np.array([1.0]), np.array([2.0])], + column_names=["x", "y", "extra"], + ) + + +# --------------------------------------------------------------------------- # +# key clause: header present but no label -> explicit key "" (never bare +# omission), preserving pre-header-row rendering (see writer._key_clause). +# --------------------------------------------------------------------------- # + + +def test_unlabeled_line_gets_explicit_empty_key_when_header_present(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.plot([1, 2, 3], [1, 2, 3]) # no label + text, _ = fig._generate_gle_with_files() + assert 'key ""' in text + + +def test_labeled_line_key_unaffected_by_header(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.plot([1, 2, 3], [1, 2, 3], label="sig") + text, _ = fig._generate_gle_with_files() + assert 'key "sig"' in text + assert 'key ""' not in text + + +def test_unlabeled_bar_gets_standalone_key_suppression_line(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.bar([1, 2, 3], [4, 5, 6]) + text, _ = fig._generate_gle_with_files() + assert 'd1 key ""' in text + + +def test_unlabeled_fill_gets_standalone_key_suppression_lines(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.fill_between([1, 2, 3], [0, 0, 0], [1, 2, 3]) + text, _ = fig._generate_gle_with_files() + assert text.count('key ""') == 2 + + +# --------------------------------------------------------------------------- # +# Absent-key tolerance: older projects regenerate defaults +# --------------------------------------------------------------------------- # + + +def test_from_dict_regenerates_column_names_when_absent(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.plot([1, 2, 3], [1, 2, 3], label="sig") + d = fig.to_dict() + del d["figure"]["axes"][0]["lines"][0]["column_names"] # simulate old project + + fig2 = Figure.from_dict(d) + assert fig2.axes_list[0].lines[0]["column_names"] == ["x", "sig"] + + +def test_from_dict_regenerates_errorbar_column_names_when_absent(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.errorbar([1, 2, 3], [1, 2, 3], yerr=0.5) + d = fig.to_dict() + del d["figure"]["axes"][0]["errorbars"][0]["column_names"] + + fig2 = Figure.from_dict(d) + assert fig2.axes_list[0].errorbars[0]["column_names"] == ["x", "y", "err"] + + +def test_from_dict_preserves_column_names_when_present(): + fig = glp.figure(data_prefix="t") + ax = fig.add_subplot(111) + ax.plot([1, 2, 3], [1, 2, 3], label="sig") + d = fig.to_dict() + d["figure"]["axes"][0]["lines"][0]["column_names"] = ["x", "renamed"] + + fig2 = Figure.from_dict(d) + assert fig2.axes_list[0].lines[0]["column_names"] == ["x", "renamed"] + + +# --------------------------------------------------------------------------- # +# Round-trip through the recognizer, including a renamed column +# --------------------------------------------------------------------------- # + + +def test_round_trip_recovers_column_names(tmp_path): + fig = glp.figure(data_prefix="rt") + ax = fig.add_subplot(111) + ax.plot([1, 2, 3], [4, 5, 6], label="Signal") + + gle_path = tmp_path / "f.gle" + fig.savefig_gle(str(gle_path)) + + recognized = parse_gle_figure(gle_path) + ax2 = recognized.figure.axes_list[0] + assert ax2.lines[0]["column_names"] == ["x", "signal"] + + +def test_round_trip_user_renamed_column_persists(tmp_path): + """A hand-edited header (simulating a user rename in an external editor, + or a future GUI rename feature) survives parse -> re-save verbatim. + """ + fig = glp.figure(data_prefix="rt") + ax = fig.add_subplot(111) + ax.plot([1, 2, 3], [4, 5, 6]) + + gle_path = tmp_path / "f.gle" + fig.savefig_gle(str(gle_path)) + + dat_path = tmp_path / "rt_0.dat" + lines = dat_path.read_text(encoding="utf-8").splitlines() + assert lines[0] == "x y" + lines[0] = "x custom_name" + dat_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + recognized = parse_gle_figure(gle_path) + ax2 = recognized.figure.axes_list[0] + assert ax2.lines[0]["column_names"] == ["x", "custom_name"] + + # Re-save: the renamed header must be regenerated verbatim. + out_dir = tmp_path / "resave" + out_dir.mkdir() + recognized.figure.savefig_gle(str(out_dir / "f.gle")) + resaved = (out_dir / "rt_0.dat").read_text(encoding="utf-8").splitlines() + assert resaved[0] == "x custom_name" + + +def test_round_trip_errorbar_column_names_full_cycle(tmp_path): + fig = glp.figure(data_prefix="rt") + ax = fig.add_subplot(111) + ax.errorbar([1, 2, 3], [4, 5, 6], yerr=0.5, label="temp") + + gle_path = tmp_path / "f.gle" + fig.savefig_gle(str(gle_path)) + + recognized = parse_gle_figure(gle_path) + ax2 = recognized.figure.axes_list[0] + assert ax2.errorbars[0]["column_names"] == ["x", "temp", "err"] + + out_dir = tmp_path / "resave" + out_dir.mkdir() + recognized.figure.savefig_gle(str(out_dir / "f.gle")) + original_bytes = (tmp_path / "rt_0.dat").read_bytes() + resaved_bytes = (out_dir / "rt_0.dat").read_bytes() + assert resaved_bytes == original_bytes + + +def test_headerless_hand_written_dat_gets_default_column_names(tmp_path): + """A gleplot-authored .gle referencing a headerless .dat (no header row + at all) still gets column_names populated with the stable defaults on + parse, so it acquires a header on next save rather than staying + headerless forever. + + Post Finding 1, a file is only recognized as an "import" series + (array-backed ax.lines entry, which is what carries column_names) when + the '! gleplot' metadata block's import-data list vouches for it. A + metadata-less reference is treated as an external "reference" + (file_series) instead. This fixture therefore includes the metadata + block that gleplot's own writer emits. + """ + dat_path = tmp_path / "series_1.dat" + dat_path.write_text("1 2\n2 4\n3 6\n", encoding="utf-8") + gle_path = tmp_path / "f.gle" + gle_path.write_text( + "! GLE graphics file\n" + "! Generated by gleplot\n" + "! gleplot-meta-begin v1\n" + "! gleplot: import-data = series_1.dat\n" + "! gleplot-meta-end\n" + "size 10 10\n" + "begin graph\n" + " data series_1.dat d1=c1,c2\n" + " d1 line color blue\n" + "end graph\n", + encoding="utf-8", + ) + + recognized = parse_gle_figure(gle_path) + ax = recognized.figure.axes_list[0] + assert len(ax.lines) == 1 + assert ax.lines[0]["column_names"] == ["x", "y"] diff --git a/tests/unit/test_dataio.py b/tests/unit/test_dataio.py index e39469e..6a62729 100644 --- a/tests/unit/test_dataio.py +++ b/tests/unit/test_dataio.py @@ -7,8 +7,10 @@ gleplot.gui. - resolve_data_reference: relative/absolute/missing/unreadable/malformed. - extract_columns: happy path, out-of-bounds, non-numeric. -- classify_data_file: metadata-list-driven and heuristic-driven - (including the documented false-positive case). +- classify_data_file: metadata-list-driven only. With no metadata block + (import_list is None) every reference is conservatively classified + 'reference' -- the old filename heuristic (and its user-data-overwrite + false positive) has been removed (Finding 1). tests/gui/test_data_loader.py and tests/gui/test_data_panel.py are run unmodified elsewhere to prove the shim in the GUI context; this file @@ -334,55 +336,506 @@ def test_classify_with_import_list_empty_list_means_reference(tmp_path): assert classify_data_file(gle_path, "anything_1.dat", import_list=[]) == "reference" -def test_classify_heuristic_sidecar_pattern_is_import(tmp_path): +def test_classify_no_metadata_is_always_reference(tmp_path): + """Finding 1: with no metadata block (import_list is None) EVERY + reference is classified 'reference' -- conservative by design so a + hand-authored .gle can never cause gleplot to adopt and rewrite a + user's data file. The old filename heuristic is gone. + """ gle_path = tmp_path / "plot.gle" - assert classify_data_file(gle_path, "data_1.dat", import_list=None) == "import" - assert classify_data_file(gle_path, "mystudy_42.dat", import_list=None) == "import" + # Names that used to match the sidecar heuristic are now 'reference'. + assert classify_data_file(gle_path, "data_1.dat", import_list=None) == "reference" + assert classify_data_file(gle_path, "mystudy_42.dat", import_list=None) == "reference" + # Ordinary names were always 'reference'; still are. + assert classify_data_file(gle_path, "readings.dat", import_list=None) == "reference" + assert classify_data_file(gle_path, "data.dat", import_list=None) == "reference" + # Subdirectory / absolute references -- still 'reference'. + assert classify_data_file(gle_path, "sub/data_1.dat", import_list=None) == "reference" + absolute = tmp_path / "data_1.dat" + assert classify_data_file(gle_path, str(absolute), import_list=None) == "reference" -def test_classify_heuristic_non_matching_name_is_reference(tmp_path): +def test_classify_no_metadata_ordinary_user_file_not_overwritten(tmp_path): + """Finding 1 root fix: an ordinary user file whose name happens to + look like a sidecar (results_2024.dat) sitting next to a + metadata-less .gle is classified 'reference', NOT 'import'. This is + the eliminated silent-user-data-overwrite false positive. + """ gle_path = tmp_path / "plot.gle" - assert classify_data_file(gle_path, "readings.dat", import_list=None) == "reference" - assert classify_data_file(gle_path, "data.dat", import_list=None) == "reference" + assert classify_data_file(gle_path, "results_2024.dat", import_list=None) == "reference" -def test_classify_heuristic_subdirectory_is_reference(tmp_path): +def test_classify_vouched_by_import_list_is_import(tmp_path): + """A file is 'import' iff the metadata block's import-data list vouches + for it (the sole source of truth after Finding 1). + """ gle_path = tmp_path / "plot.gle" - # Even a name matching the sidecar pattern is 'reference' if it's not - # in the same directory as the .gle file -- gleplot always writes - # sidecars alongside the script. - assert classify_data_file(gle_path, "sub/data_1.dat", import_list=None) == "reference" + assert ( + classify_data_file(gle_path, "results_2024.dat", import_list=["results_2024.dat"]) + == "import" + ) + # A vouched name is import even where the heuristic never would have + # matched (no digits suffix), proving the list -- not the name -- decides. + assert ( + classify_data_file(gle_path, "readings.dat", import_list=["readings.dat"]) + == "import" + ) -def test_classify_heuristic_absolute_path_is_reference(tmp_path): - gle_path = tmp_path / "plot.gle" - absolute = tmp_path / "data_1.dat" +# --------------------------------------------------------------------------- +# Track E3: named column headers in generated sidecars -- data-dock benefit +# --------------------------------------------------------------------------- +# +# gleplot's own generated sidecars now carry a header row by default (see +# gleplot.axes.sanitize_column_name / gleplot.writer.GLEWriter.add_data_file). +# These tests prove load_data_file (the function the GUI's data dock combo +# boxes are built on -- see gleplot.gui.data.panel) surfaces the REAL column +# names for such a sidecar with ZERO changes needed on the GUI side: the dock +# already renders whatever load_data_file().column_names contains. + + +def test_load_data_file_surfaces_gleplot_header_names(tmp_path): + """A gleplot-generated sidecar's header row becomes DataTable.column_names + verbatim (not the synthesized 'col1'/'col2' placeholders), so a GUI data + dock built on load_data_file shows the real names automatically. + """ + p = _write(tmp_path, "series.dat", "x signal\n1 2\n2 4\n3 6\n") - assert classify_data_file(gle_path, str(absolute), import_list=None) == "reference" + table = load_data_file(p) + + assert table.has_header is True + assert table.column_names == ["x", "signal"] + assert table.numeric_column_names() == ["x", "signal"] -def test_classify_heuristic_false_positive_documented_case(tmp_path): - """Documented false-positive: an ordinary user file that happens to - match '_.dat' in the same directory as the script is - misclassified as 'import' by the heuristic. This is expected/known - behavior (see classify_data_file's docstring) -- callers should - prefer passing import_list when available to avoid it. +def test_load_data_file_surfaces_gleplot_writer_output_end_to_end(tmp_path): + """End-to-end: gleplot.writer.GLEWriter.add_data_file's own header-row + output, round-tripped through load_data_file, yields the exact names + that were passed in -- proving the writer and the loader agree on + format with no adapter needed. """ - gle_path = tmp_path / "plot.gle" + from gleplot.writer import GLEWriter + + writer = GLEWriter() + writer.add_data_file( + "series.dat", + [np.array([1.0, 2.0, 3.0]), np.array([2.0, 4.0, 6.0]), np.array([0.1, 0.2, 0.3])], + column_names=["x", "signal", "err"], + ) + p = tmp_path / "series.dat" + p.write_text(writer.data_files["series.dat"], encoding="utf-8") - assert classify_data_file(gle_path, "results_2024.dat", import_list=None) == "import" + table = load_data_file(p) + + assert table.column_names == ["x", "signal", "err"] + assert table.n_rows == 3 + assert table.numeric_column_names() == ["x", "signal", "err"] -def test_classify_heuristic_false_positive_avoided_with_import_list(tmp_path): - """The same false-positive-prone name is correctly classified as - 'reference' once the caller supplies the authoritative import_list. +def test_load_data_file_renamed_column_survives(tmp_path): + """A user-renamed column header (as it would be after an edit + resave) + is exactly what the dock sees -- sanitized names are not re-derived on + load, the header row is authoritative. """ - gle_path = tmp_path / "plot.gle" + p = _write(tmp_path, "series.dat", "time amplitude_mv\n0 1\n1 2\n") - assert ( - classify_data_file(gle_path, "results_2024.dat", import_list=["data_1.dat"]) - == "reference" + table = load_data_file(p) + + assert table.column_names == ["time", "amplitude_mv"] + + +# --------------------------------------------------------------------------- +# Comment-line column-header recovery (bug report: GLE-style fit-parameter +# exports carry column names in a trailing COMMENT line, not an inline +# header row). See gleplot.dataio._recover_comment_header. +# --------------------------------------------------------------------------- + + +def test_comment_header_recovered_after_multi_comment_block(tmp_path): + """Real-world shape from the bug report: several prose/parameter + comment lines, then a final comment line naming the columns, then + numeric data with no inline header. The last comment line's tokens + become column_names; has_header stays False (still a comment) and + header_source records where the names came from. + """ + content = ( + "! Fit parameter data for GLE export\n" + "! Global fitting parameters:\n" + "! A_1 (%) = 11.8654 +/- 0.0543966\n" + "! x y\n" + "1.0 2.0\n" + "2.0 4.0\n" + "3.0 6.0\n" + ) + p = _write(tmp_path, "fit.dat", content) + + table = load_data_file(p) + + assert table.has_header is False + assert table.column_names == ["x", "y"] + assert table.header_source == "comment" + assert table.n_rows == 3 + np.testing.assert_allclose(table.columns[0], [1.0, 2.0, 3.0]) + np.testing.assert_allclose(table.columns[1], [2.0, 4.0, 6.0]) + + +def test_comment_header_rejected_trailing_prose_comment(tmp_path): + """The LAST comment line before the data is prose, not column names + ('Global fitting parameters:' -- one token, mismatched against 2 data + columns) -- rejected, positional col1/col2 names used instead. + """ + content = ( + "! Fit parameter data for GLE export\n" + "! Global fitting parameters:\n" + "1.0 2.0\n" + "2.0 4.0\n" + ) + p = _write(tmp_path, "fit.dat", content) + + table = load_data_file(p) + + assert table.has_header is False + assert table.column_names == ["col1", "col2"] + assert table.header_source is None + + +def test_comment_header_rejected_mismatched_token_count(tmp_path): + """A comment line naming only 1 column above 2 data columns is + rejected (token count must equal the data column count exactly). + """ + content = "! x\n1.0 2.0\n3.0 4.0\n" + p = _write(tmp_path, "fit.dat", content) + + table = load_data_file(p) + + assert table.has_header is False + assert table.column_names == ["col1", "col2"] + assert table.header_source is None + + +def test_comment_header_rejected_all_numeric_comment(tmp_path): + """A comment line that is itself all-numeric tokens (e.g. a stray + results line someone commented out) is not mistaken for column names + -- same 'genuine label' rule as ordinary header detection. + """ + content = "! 9.9 8.8\n1.0 2.0\n3.0 4.0\n" + p = _write(tmp_path, "fit.dat", content) + + table = load_data_file(p) + + assert table.has_header is False + assert table.column_names == ["col1", "col2"] + assert table.header_source is None + + +def test_comment_header_inline_header_takes_precedence(tmp_path): + """When the file already HAS an inline header row, comment-header + recovery must never run at all -- the inline header always wins, even + if a preceding comment line also looks like plausible column names. + """ + content = "! a b\nx y\n1.0 2.0\n3.0 4.0\n" + p = _write(tmp_path, "fit.dat", content) + + table = load_data_file(p) + + assert table.has_header is True + assert table.column_names == ["x", "y"] + assert table.header_source == "row" + + +def test_comment_header_not_attempted_when_inline_header_mismatched(tmp_path): + """A mismatched inline header (e.g. whitespace-split multi-word names) + is still a real header row -- comment-header recovery must not + second-guess it even though has_header ends up reporting True with + positional names. + """ + content = "! p q\nTemperature (K) Resistance (Ohm)\n1.0 100.0\n2.0 200.0\n" + p = _write(tmp_path, "fit.dat", content) + + table = load_data_file(p) + + assert table.has_header is True + assert table.column_names == ["col1", "col2"] + assert table.header_source == "row" + + +def test_comment_header_blank_lines_tolerated_between_comment_and_data(tmp_path): + """Blank lines between the comment header and the first data row are + tolerated -- 'immediately preceding' walks back past blanks. + """ + content = "! x y\n\n\n1.0 2.0\n3.0 4.0\n" + p = _write(tmp_path, "fit.dat", content) + + table = load_data_file(p) + + assert table.has_header is False + assert table.column_names == ["x", "y"] + assert table.header_source == "comment" + + +def test_comment_header_delimited_file(tmp_path): + """Comment-header recovery also applies to delimited (comma) files -- + tokenization uses the SAME delimiter logic as the data rows. + """ + content = "! x,signal\n1.0,2.0\n2.0,4.0\n" + p = _write(tmp_path, "fit.csv", content) + + table = load_data_file(p) + + assert table.has_header is False + assert table.delimiter == "," + assert table.column_names == ["x", "signal"] + assert table.header_source == "comment" + + +def test_comment_header_no_comment_line_no_recovery(tmp_path): + """No comment line at all -- ordinary headerless file, unaffected.""" + p = _write(tmp_path, "plain.dat", "1.0 2.0\n3.0 4.0\n") + + table = load_data_file(p) + + assert table.has_header is False + assert table.column_names == ["col1", "col2"] + assert table.header_source is None + + +def test_comment_header_not_immediately_preceding_is_rejected(tmp_path): + """A comment line that is NOT immediately before the first data row + (something else -- here, more comments only, so this is somewhat + degenerate) -- specifically: a comment line separated from the first + data row by a non-comment/non-blank line never happens in practice + (that line would itself be data), so this test instead confirms that + only the LAST qualifying comment line (nearest the data) is used, not + an earlier one with a different, non-matching token count. + """ + content = ( + "! one\n" + "! x y\n" + "1.0 2.0\n" + "3.0 4.0\n" + ) + p = _write(tmp_path, "fit.dat", content) + + table = load_data_file(p) + + assert table.column_names == ["x", "y"] + assert table.header_source == "comment" + + +# --------------------------------------------------------------------------- +# Indexed comment-header recovery ('! c N = name' block): a stricter, +# unambiguous sibling of the last-comment-line heuristic above. See +# gleplot.dataio._recover_indexed_comment_header. +# --------------------------------------------------------------------------- + + +def test_indexed_comment_header_recovered(tmp_path): + """Real-world shape: a prose preamble, one 'c N = name' line per column, + a separator comment, then an aligned prose name row whose token count + does NOT match the column count (so it could never qualify under the + positional last-comment-line heuristic on its own) -- then numeric data. + The indexed block alone is sufficient to recover full column_names. + """ + content = ( + "! some prose header\n" + "! c 1 = run_id\n" + "! c 2 = field_strength (G)\n" + "! c 3 = temperature (K)\n" + "! c 4 = phase (rad)\n" + "! c 5 = amplitude (mV)\n" + "! c 6 = decay_rate\n" + "! c 7 = err_rate (unit-1)\n" + "!\n" + "! run_id field_strength(G) temperature(K) phase(rad) amplitude(mV) decay_rate err_rate\n" + "1 100.0 0.1 0.5 1.2 0.02 0.001\n" + "2 200.0 0.2 0.6 1.3 0.03 0.002\n" + "3 300.0 0.3 0.7 1.4 0.04 0.003\n" + ) + p = _write(tmp_path, "run.dat", content) + + table = load_data_file(p) + + assert table.has_header is False + assert table.header_source == "comment" + assert table.column_names == [ + "run_id", + "field_strength (G)", + "temperature (K)", + "phase (rad)", + "amplitude (mV)", + "decay_rate", + "err_rate (unit-1)", + ] + assert table.n_rows == 3 + + +def test_indexed_comment_header_unicode_name_preserved(tmp_path): + """A column name containing unicode (mu, superscript -1) survives intact + into DataTable.column_names -- exercises the utf-8-sig-first decode path + end to end. + """ + content = ( + "! c 1 = t\n" + "! c 2 = rate (μs⁻¹)\n" + "0.0 1.0\n" + "1.0 2.0\n" + ) + p = _write(tmp_path, "unicode.dat", content, encoding="utf-8-sig") + + table = load_data_file(p) + + assert table.header_source == "comment" + assert table.column_names == ["t", "rate (μs⁻¹)"] + + +def test_indexed_comment_header_case_and_whitespace_tolerant(tmp_path): + """'c'/'C', and arbitrary whitespace around the index and '=', are all + tolerated. + """ + content = ( + "!C1=alpha\n" + "! c 2 = beta \n" + "1 2\n" + "3 4\n" + ) + p = _write(tmp_path, "case.dat", content) + + table = load_data_file(p) + + assert table.header_source == "comment" + # Trailing whitespace in 'beta ' is stripped (name stripped at the ends + # only; see docstring -- internal whitespace would be preserved). + assert table.column_names == ["alpha", "beta"] + + +def test_indexed_comment_header_precedence_over_last_line_heuristic(tmp_path): + """When BOTH an indexed 'c N = name' block and a qualifying last-comment + -line positional header are present, the indexed block wins -- it is + unambiguous, so it is tried FIRST regardless of what the last comment + line looks like. + """ + content = ( + "! c 1 = alpha\n" + "! c 2 = beta\n" + "! gamma delta\n" # would qualify under the positional heuristic + "1.0 2.0\n" + "3.0 4.0\n" + ) + p = _write(tmp_path, "precedence.dat", content) + + table = load_data_file(p) + + assert table.header_source == "comment" + assert table.column_names == ["alpha", "beta"] + + +def test_indexed_comment_header_rejected_incomplete_coverage(tmp_path): + """Only c2/c3 named for a 3-column file (c1 missing) -- coverage is not + exactly 1..n_cols, so the WHOLE indexed block is rejected (no partial + adoption); falls back to positional col1..col3. + """ + content = "! c 2 = b\n! c 3 = c\n1 2 3\n4 5 6\n" + p = _write(tmp_path, "incomplete.dat", content) + + table = load_data_file(p) + + assert table.header_source is None + assert table.column_names == ["col1", "col2", "col3"] + + +def test_indexed_comment_header_rejected_duplicate_index(tmp_path): + """Two lines both naming column 1 -- ambiguous, whole block rejected.""" + content = "! c 1 = a\n! c 1 = a2\n! c 2 = b\n1 2\n3 4\n" + p = _write(tmp_path, "dup.dat", content) + + table = load_data_file(p) + + assert table.header_source is None + assert table.column_names == ["col1", "col2"] + + +def test_indexed_comment_header_rejected_out_of_range_index(tmp_path): + """A named index beyond the data's column count -- coverage can never + be exactly 1..n_cols, so the whole block is rejected. + """ + content = "! c 1 = a\n! c 2 = b\n! c 5 = ghost\n1 2\n3 4\n" + p = _write(tmp_path, "oor.dat", content) + + table = load_data_file(p) + + assert table.header_source is None + assert table.column_names == ["col1", "col2"] + + +def test_indexed_comment_header_inline_header_takes_precedence(tmp_path): + """An inline header row always wins over an indexed comment block too + (same rule as the positional heuristic) -- comment-header recovery of + either kind is only attempted when there is no inline header at all. + """ + content = "! c 1 = a\n! c 2 = b\nx y\n1.0 2.0\n3.0 4.0\n" + p = _write(tmp_path, "inline_wins.dat", content) + + table = load_data_file(p) + + assert table.has_header is True + assert table.column_names == ["x", "y"] + assert table.header_source == "row" + + +def test_indexed_comment_header_leaves_has_header_false_for_vouch_safety(tmp_path): + """Same vouch-safety invariant as the positional heuristic: an indexed + comment-derived header must never flip has_header to True, or a vouched + sidecar would be adopted and rewritten with a new inline header line on + the next save (see gleplot.parser.recognizer._recovered_column_names, + which gates strictly on has_header). + """ + content = "! c 1 = x\n! c 2 = y\n1.0 2.0\n3.0 4.0\n" + p = _write(tmp_path, "data_1.dat", content) + + table = load_data_file(p) + + assert table.column_names == ["x", "y"] + assert table.header_source == "comment" + assert table.has_header is False, ( + "indexed comment-derived column names must not be reported as an " + "inline header row, or a vouched sidecar would be rewritten with a " + "new header line on next save" + ) + + +# --------------------------------------------------------------------------- +# Vouched-sidecar round-trip safety (interaction with +# gleplot.parser.recognizer._recovered_column_names): a sidecar whose ONLY +# header is a comment-derived one must NOT be adopted as an inline header +# by the recognizer's metadata-vouch path, because that would cause the +# writer to rewrite the file with a NEW inline header line on next save, +# changing its bytes. _recovered_column_names only reads column_names when +# has_header is True, and comment-header recovery always leaves has_header +# False, so this is a property test of that invariant at the dataio level +# (the full recognizer round-trip is covered in +# tests/parser/test_recognizer_adversarial.py). +# --------------------------------------------------------------------------- + + +def test_comment_header_leaves_has_header_false_for_vouch_safety(tmp_path): + """Direct dataio-level check of the interaction invariant: a + comment-derived header must never flip has_header to True, since + gleplot.parser.recognizer._recovered_column_names gates on has_header + (not on column_names being non-default) to decide whether a vouched + import series' column_names may be recovered and re-emitted as an + inline header on next save. + """ + content = "! x y\n1.0 2.0\n3.0 4.0\n" + p = _write(tmp_path, "data_1.dat", content) + + table = load_data_file(p) + + assert table.column_names == ["x", "y"] + assert table.has_header is False, ( + "comment-derived column names must not be reported as an inline " + "header row, or a vouched sidecar would be rewritten with a new " + "header line on next save" ) diff --git a/tests/unit/test_text.py b/tests/unit/test_text.py index 6e0fbd1..8496922 100644 --- a/tests/unit/test_text.py +++ b/tests/unit/test_text.py @@ -41,9 +41,25 @@ def test_text_generates_gle_commands(self): self.assertIn('amove xg(1.25) yg(0.45)', gle) self.assertIn('write "Comp 1"', gle) - self.assertIn('set just left', gle) + # 'BLACK'/'left' are GLE's own defaults (and the sticky state the + # writer starts in), so a text using them needs no 'set color'/'set + # just' restated -- the writer skips redundant lines that would not + # change anything (see GLEWriter.add_text sticky-state tracking). + self.assertNotIn('set color BLACK', gle) + self.assertNotIn('set just left', gle) self.assertLess(gle.find('end graph'), gle.find('amove xg(1.25) yg(0.45)')) + def test_text_with_non_default_just_emits_set_just(self): + # A halign that differs from GLE's sticky default ('left') must still + # emit an explicit 'set just' so the rendered alignment is correct. + self.ax.plot([0, 1, 2], [0.2, 0.5, 0.3], color='blue') + self.ax.text(1.25, 0.45, 'Comp 1', ha='center') + + gle = self.fig._generate_gle() + + self.assertIn('set just center', gle) + self.assertIn('write "Comp 1"', gle) + def test_module_level_text(self): glp.figure() glp.text(0.5, 0.25, 'A')