triplot 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dscpanel/__init__.py +4 -0
- dscpanel/__main__.py +155 -0
- dscpanel/branding.py +142 -0
- dscpanel/core/__init__.py +0 -0
- dscpanel/core/arrange.py +143 -0
- dscpanel/core/chem.py +252 -0
- dscpanel/core/dtg.py +135 -0
- dscpanel/core/export.py +649 -0
- dscpanel/core/figure.py +171 -0
- dscpanel/core/labels.py +430 -0
- dscpanel/core/loader.py +202 -0
- dscpanel/core/log.py +147 -0
- dscpanel/core/measure.py +636 -0
- dscpanel/core/model.py +1993 -0
- dscpanel/core/molar.py +344 -0
- dscpanel/core/numbers.py +208 -0
- dscpanel/core/ops.py +161 -0
- dscpanel/core/presets.py +312 -0
- dscpanel/core/profile.py +15 -0
- dscpanel/core/session.py +667 -0
- dscpanel/core/shades.py +54 -0
- dscpanel/core/style.py +528 -0
- dscpanel/core/trios_analysis.py +636 -0
- dscpanel/core/trios_io.py +1311 -0
- dscpanel/core/undo.py +230 -0
- dscpanel/core/units.py +220 -0
- dscpanel/register.py +284 -0
- dscpanel/ui/__init__.py +0 -0
- dscpanel/ui/appearance.py +146 -0
- dscpanel/ui/colour.py +629 -0
- dscpanel/ui/dialogs.py +3639 -0
- dscpanel/ui/loading.py +95 -0
- dscpanel/ui/numbox.py +103 -0
- dscpanel/ui/outliner.py +818 -0
- dscpanel/ui/palette.py +193 -0
- dscpanel/ui/plot.py +8349 -0
- dscpanel/ui/settings.py +256 -0
- dscpanel/ui/window.py +4129 -0
- triplot-1.1.0.dist-info/METADATA +315 -0
- triplot-1.1.0.dist-info/RECORD +44 -0
- triplot-1.1.0.dist-info/WHEEL +5 -0
- triplot-1.1.0.dist-info/entry_points.txt +5 -0
- triplot-1.1.0.dist-info/licenses/LICENSE +22 -0
- triplot-1.1.0.dist-info/top_level.txt +1 -0
dscpanel/core/ops.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Operator registry - the backbone of the F3 search (Blender-style).
|
|
2
|
+
|
|
3
|
+
Every user-facing action registers here once, with an `enabled` predicate
|
|
4
|
+
evaluated against the live app context, so the search can sieve by what is
|
|
5
|
+
SELECTED - "Distribute offsets evenly" lights up with two or more scans
|
|
6
|
+
selected, "Set molar mass" with one sample, "Flip heat-flow arrow" only when
|
|
7
|
+
the arrow is in the selection. docs/OPERATORS.md is the human-readable log of
|
|
8
|
+
this list; keep the two in sync when adding operators.
|
|
9
|
+
|
|
10
|
+
UI-free: `ctx` is duck-typed (the main window), predicates and callbacks are
|
|
11
|
+
plain callables, so the registry is testable with a stub context.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Callable, List, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Operator:
|
|
18
|
+
def __init__(self, op_id, label, run, enabled=None, category="General",
|
|
19
|
+
shortcut="", aliases=(), key="", extra_keys=()):
|
|
20
|
+
# type: (str, str, Callable, Optional[Callable], str, str, tuple, str) -> None
|
|
21
|
+
self.id = op_id
|
|
22
|
+
self.label = label
|
|
23
|
+
self.run = run
|
|
24
|
+
self._enabled = enabled
|
|
25
|
+
self.category = category
|
|
26
|
+
# `shortcut` is PROSE for the palette ("G, then X/Y/Z, number");
|
|
27
|
+
# `key` is the machine-readable QKeySequence string the UI actually
|
|
28
|
+
# binds ("G"). Two fields because the useful thing to tell a user and
|
|
29
|
+
# the thing Qt can parse are rarely the same text - and because the
|
|
30
|
+
# binding must not depend on an operator happening to sit in a menu.
|
|
31
|
+
self.shortcut = shortcut
|
|
32
|
+
self.key = key
|
|
33
|
+
# Extra words the search should match - the same action goes by
|
|
34
|
+
# different names in different programs ("re-perceive" vs
|
|
35
|
+
# "recalculate" bonds), and a palette that only knows one of them
|
|
36
|
+
# looks empty to everyone who learned the other.
|
|
37
|
+
self.aliases = tuple(aliases)
|
|
38
|
+
# Additional bindings for the SAME operator - a second key people
|
|
39
|
+
# already have in their fingers from another program (Blender deletes
|
|
40
|
+
# with X, this program has always used Del). They are bound alongside `key`
|
|
41
|
+
# and counted by `duplicate_keys` like any other claim.
|
|
42
|
+
self.extra_keys = tuple(k for k in extra_keys if k)
|
|
43
|
+
|
|
44
|
+
def enabled(self, ctx):
|
|
45
|
+
# type: (object) -> bool
|
|
46
|
+
if self._enabled is None:
|
|
47
|
+
return True
|
|
48
|
+
try:
|
|
49
|
+
return bool(self._enabled(ctx))
|
|
50
|
+
except Exception:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
def __repr__(self):
|
|
54
|
+
return "Operator({!r})".format(self.id)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OperatorRegistry:
|
|
58
|
+
def __init__(self):
|
|
59
|
+
self._ops = [] # type: List[Operator]
|
|
60
|
+
self._by_id = {}
|
|
61
|
+
|
|
62
|
+
def register(self, op_id, label, run, enabled=None, category="General",
|
|
63
|
+
shortcut="", aliases=(), key="", extra_keys=()):
|
|
64
|
+
# type: (str, str, Callable, Optional[Callable], str, str, tuple, str, tuple) -> Operator
|
|
65
|
+
if op_id in self._by_id:
|
|
66
|
+
raise ValueError("duplicate operator id: {}".format(op_id))
|
|
67
|
+
op = Operator(op_id, label, run, enabled, category, shortcut, aliases,
|
|
68
|
+
key, extra_keys)
|
|
69
|
+
self._ops.append(op)
|
|
70
|
+
self._by_id[op_id] = op
|
|
71
|
+
return op
|
|
72
|
+
|
|
73
|
+
def unregister(self, op_id):
|
|
74
|
+
# type: (str) -> bool
|
|
75
|
+
"""Remove an operator. True if there was one.
|
|
76
|
+
|
|
77
|
+
For ADD-ONS: `register` raises on a duplicate id, so an add-on that
|
|
78
|
+
registers an operator and cannot take it away again fails to enable the
|
|
79
|
+
second time it is switched on - which is an ordinary thing for a user
|
|
80
|
+
to do while trying one out.
|
|
81
|
+
|
|
82
|
+
Safe for an operator registered AFTER startup, which is every add-on's:
|
|
83
|
+
`MainWindow._install_shortcuts` builds its QActions once, so a late
|
|
84
|
+
operator has no action or menu entry to leave dangling, and F3 reads
|
|
85
|
+
the registry live. Removing a built-in one would strand its QAction,
|
|
86
|
+
so do not.
|
|
87
|
+
"""
|
|
88
|
+
op = self._by_id.pop(op_id, None)
|
|
89
|
+
if op is None:
|
|
90
|
+
return False
|
|
91
|
+
self._ops = [o for o in self._ops if o.id != op_id]
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
def get(self, op_id):
|
|
95
|
+
# type: (str) -> Optional[Operator]
|
|
96
|
+
return self._by_id.get(op_id)
|
|
97
|
+
|
|
98
|
+
def all(self):
|
|
99
|
+
# type: () -> List[Operator]
|
|
100
|
+
return list(self._ops)
|
|
101
|
+
|
|
102
|
+
def keyed(self):
|
|
103
|
+
# type: () -> List[Operator]
|
|
104
|
+
"""Operators that own a key binding, in registration order."""
|
|
105
|
+
return [op for op in self._ops if op.key]
|
|
106
|
+
|
|
107
|
+
def duplicate_keys(self):
|
|
108
|
+
# type: () -> dict
|
|
109
|
+
"""{key: [op ids]} for keys claimed more than once.
|
|
110
|
+
|
|
111
|
+
Qt does NOT pick a winner when two QActions share a shortcut: it
|
|
112
|
+
reports an ambiguous overload and fires NEITHER. F3 was bound twice
|
|
113
|
+
(Edit menu and App menu) and the operator search silently stopped
|
|
114
|
+
opening - so a clash is a hard error, not a warning.
|
|
115
|
+
"""
|
|
116
|
+
seen = {}
|
|
117
|
+
for op in self._ops:
|
|
118
|
+
for key in ((op.key,) if op.key else ()) + op.extra_keys:
|
|
119
|
+
seen.setdefault(key, []).append(op.id)
|
|
120
|
+
return {k: v for k, v in seen.items() if len(v) > 1}
|
|
121
|
+
|
|
122
|
+
def search(self, text, ctx):
|
|
123
|
+
# type: (str, object) -> List[Tuple[Operator, bool]]
|
|
124
|
+
"""Filter + rank for the F3 dialog. Returns (op, enabled) pairs:
|
|
125
|
+
substring match on label/category/id (case-insensitive, all words
|
|
126
|
+
must match), enabled ops first, then label-prefix matches, then
|
|
127
|
+
registration order."""
|
|
128
|
+
words = [w for w in (text or "").lower().split() if w]
|
|
129
|
+
out = []
|
|
130
|
+
for k, op in enumerate(self._ops):
|
|
131
|
+
hay = " ".join((op.label, op.category, op.id)
|
|
132
|
+
+ op.aliases).lower()
|
|
133
|
+
if all(w in hay for w in words):
|
|
134
|
+
en = op.enabled(ctx)
|
|
135
|
+
prefix = op.label.lower().startswith(words[0]) if words else False
|
|
136
|
+
out.append((not en, not prefix, k, op, en))
|
|
137
|
+
out.sort(key=lambda t: t[:3])
|
|
138
|
+
return [(op, en) for _e, _p, _k, op, en in out]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def chord_variants(key):
|
|
142
|
+
# type: (str) -> list
|
|
143
|
+
"""Every spelling of a key that a user pressing it might actually produce.
|
|
144
|
+
|
|
145
|
+
A two-part chord like `Shift+Space, L` only matches if Shift is RELEASED
|
|
146
|
+
before the second key - hold it through, as anyone naturally does, and Qt
|
|
147
|
+
sees `Shift+Space, Shift+L` and fires nothing. That is why the lasso
|
|
148
|
+
"hotkey does not work" while the box-select one appeared to: it is not a
|
|
149
|
+
case problem in the string, it is whether the modifier is still down.
|
|
150
|
+
|
|
151
|
+
So a chord whose first part carries Shift is registered BOTH ways. UI-free
|
|
152
|
+
and returning plain strings, so `_install_shortcuts` stays a loop.
|
|
153
|
+
"""
|
|
154
|
+
key = str(key or "").strip()
|
|
155
|
+
if not key or "," not in key:
|
|
156
|
+
return [key] if key else []
|
|
157
|
+
head, tail = key.split(",", 1)
|
|
158
|
+
head, tail = head.strip(), tail.strip()
|
|
159
|
+
if not tail or "Shift" not in head or "Shift" in tail:
|
|
160
|
+
return [key]
|
|
161
|
+
return [key, "{}, Shift+{}".format(head, tail)]
|
dscpanel/core/presets.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Style presets: a figure's look in a file, chosen from a menu.
|
|
2
|
+
|
|
3
|
+
The house style (`core/style.py`) already has a figure's own column: sizes,
|
|
4
|
+
formats, fonts and alignment saved with ONE session. A preset is that column
|
|
5
|
+
taken out of the session and given a name, so the same look - "thesis",
|
|
6
|
+
"poster", "ACS single column" - is put on any figure in one step, and passed
|
|
7
|
+
to a colleague as a file.
|
|
8
|
+
|
|
9
|
+
What a preset holds:
|
|
10
|
+
|
|
11
|
+
* **the style**: every figure setting, RESOLVED (the value the figure is
|
|
12
|
+
drawn with, not "follow my defaults"), so a preset looks the same on a
|
|
13
|
+
machine whose defaults differ. A hand-written preset may name only some
|
|
14
|
+
settings; the others are then left as they are.
|
|
15
|
+
* **the size and margins**, optionally (`core/figure.py`): with them, two
|
|
16
|
+
figures made with one preset have the same axes box to the hundredth of a
|
|
17
|
+
millimetre - two stacks side by side in Word.
|
|
18
|
+
* **the frame and the furniture** every figure has (the arrow's place and
|
|
19
|
+
the axis captions once did not come across): both axes - caption,
|
|
20
|
+
sizes, distances, ticks, numbers, sides - and the weight axis of an SDT
|
|
21
|
+
run (all but its side, which follows the heat flow's), the heat-flow
|
|
22
|
+
arrow's place,
|
|
23
|
+
shape and text size, and the legend's place and look. Not what the arrow
|
|
24
|
+
SAYS (exo up or down is a fact about the data) and nothing that belongs
|
|
25
|
+
to one figure's data: scans, labels, analyses, pictures.
|
|
26
|
+
|
|
27
|
+
What it does not touch: the other objects' own choices (a label sized by
|
|
28
|
+
hand stays so), the handling settings (the pick distance is about a hand,
|
|
29
|
+
not a figure) and the screen theme.
|
|
30
|
+
|
|
31
|
+
Applying one is ONE undo step on the figure's column. The files are JSON
|
|
32
|
+
with the extension in `branding.PRESET_EXT`, in a `presets` folder beside
|
|
33
|
+
the preferences; dropping one onto the window installs it there.
|
|
34
|
+
|
|
35
|
+
UI-free, like the rest of `core`.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import io
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import shutil
|
|
43
|
+
|
|
44
|
+
from .. import branding
|
|
45
|
+
from . import figure as figure_module
|
|
46
|
+
from . import labels
|
|
47
|
+
from . import model
|
|
48
|
+
from . import style
|
|
49
|
+
|
|
50
|
+
FORMAT = "style-preset"
|
|
51
|
+
VERSION = 1
|
|
52
|
+
|
|
53
|
+
_AXIS_FIELDS = ("visible", "label", "label_size", "label_along", "label_gap",
|
|
54
|
+
"show_numbers", "tick_size", "number_format", "side",
|
|
55
|
+
"ticks_inward", "minor_ticks", "minor_count", "major_step",
|
|
56
|
+
"tick_length", "minor_length", "mirror", "mirror_ticks",
|
|
57
|
+
"show_grid")
|
|
58
|
+
|
|
59
|
+
#: Which fields of which of the figure's own objects a preset carries.
|
|
60
|
+
OBJECT_FIELDS = {
|
|
61
|
+
"axis_x": _AXIS_FIELDS,
|
|
62
|
+
"axis_y": _AXIS_FIELDS,
|
|
63
|
+
# The weight axis of an SDT run: its side is always the one opposite
|
|
64
|
+
# the heat flow's, and it has no line of its own on the far side.
|
|
65
|
+
"axis_y2": tuple(f for f in _AXIS_FIELDS
|
|
66
|
+
if f not in ("side", "mirror", "mirror_ticks")),
|
|
67
|
+
"arrow": ("visible", "x", "y", "space", "anchor", "colour", "size",
|
|
68
|
+
"head_length", "head_width", "tail_width", "tail_length",
|
|
69
|
+
"lock"),
|
|
70
|
+
"legend": ("visible", "x", "y", "space", "anchor", "colour", "rotation",
|
|
71
|
+
"size", "show_frame", "sample", "spacing", "line_width"),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#: What each carried field may hold, for checking a file: "bool", "number",
|
|
75
|
+
#: "number?" (or None), "int", "text?", "format?", or a tuple of choices.
|
|
76
|
+
_KINDS = {
|
|
77
|
+
"visible": "bool", "show_numbers": "bool", "ticks_inward": "bool",
|
|
78
|
+
"minor_ticks": "bool", "mirror": "bool", "mirror_ticks": "bool",
|
|
79
|
+
"show_grid": "bool", "show_frame": "bool",
|
|
80
|
+
"label": "text?", "number_format": "format?", "colour": "text",
|
|
81
|
+
"label_size": "number?", "label_gap": "number?", "tick_size": "number?",
|
|
82
|
+
"major_step": "number?", "size": "number?", "line_width": "number?",
|
|
83
|
+
"minor_count": "int", "label_along": "number", "tick_length": "number",
|
|
84
|
+
"minor_length": "number", "x": "number", "y": "number",
|
|
85
|
+
"rotation": "number", "head_length": "number", "head_width": "number",
|
|
86
|
+
"tail_width": "number", "tail_length": "number", "sample": "number",
|
|
87
|
+
"spacing": "number",
|
|
88
|
+
"space": (model.SPACE_RELATIVE, model.SPACE_DATA),
|
|
89
|
+
"anchor": tuple(model.ANCHORS),
|
|
90
|
+
"lock": (None,) + tuple(model.ARROW_LOCKS),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#: An axis's side, by which axis it is.
|
|
94
|
+
_SIDES = {"axis_x": ("bottom", "top"), "axis_y": ("left", "right")}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def figure_objects(doc):
|
|
98
|
+
"""`{name: object}` for the objects a preset styles."""
|
|
99
|
+
return {"axis_x": doc.axes["x"], "axis_y": doc.axes["y"],
|
|
100
|
+
"axis_y2": doc.axes["y2"],
|
|
101
|
+
"arrow": doc.arrow, "legend": doc.legend}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _checked(name, field, raw):
|
|
105
|
+
"""`(True, value)` when `raw` is a legal value of `field`, else
|
|
106
|
+
`(False, None)`."""
|
|
107
|
+
kind = _SIDES[name] if field == "side" and name in _SIDES \
|
|
108
|
+
else _KINDS.get(field)
|
|
109
|
+
if isinstance(kind, tuple):
|
|
110
|
+
return (raw in kind, raw if raw in kind else None)
|
|
111
|
+
if kind is None:
|
|
112
|
+
return False, None
|
|
113
|
+
if kind.endswith("?") and raw is None:
|
|
114
|
+
return True, None
|
|
115
|
+
kind = kind.rstrip("?")
|
|
116
|
+
if kind == "bool":
|
|
117
|
+
return (isinstance(raw, bool), raw)
|
|
118
|
+
if kind in ("text", "format"):
|
|
119
|
+
if not isinstance(raw, str):
|
|
120
|
+
return False, None
|
|
121
|
+
if kind == "format":
|
|
122
|
+
cleaned = labels.normalise_format(raw)
|
|
123
|
+
return (cleaned is not None, cleaned)
|
|
124
|
+
return True, raw
|
|
125
|
+
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
|
|
126
|
+
return False, None
|
|
127
|
+
if raw != raw or abs(raw) == float("inf"):
|
|
128
|
+
return False, None
|
|
129
|
+
if kind == "int":
|
|
130
|
+
return (raw == int(raw) and raw >= 1, int(raw))
|
|
131
|
+
return True, float(raw)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Preset(object):
|
|
135
|
+
"""A named look: `values` {setting key: value}; `layout` - a
|
|
136
|
+
`FigureLayout` state - or None to leave the size alone; and `objects`
|
|
137
|
+
{"axis_x" / "axis_y" / "axis_y2" / "arrow" / "legend": {field:
|
|
138
|
+
value}}."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, name, values=None, layout=None, path="",
|
|
141
|
+
objects=None):
|
|
142
|
+
self.name = str(name)
|
|
143
|
+
self.values = dict(values or {})
|
|
144
|
+
self.layout = dict(layout) if layout else None
|
|
145
|
+
self.objects = dict((k, dict(v)) for k, v in (objects or {}).items())
|
|
146
|
+
self.path = str(path or "")
|
|
147
|
+
|
|
148
|
+
def __repr__(self):
|
|
149
|
+
return "Preset({!r})".format(self.name)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def folder():
|
|
153
|
+
"""Where presets live: beside the preferences (so a test's temporary
|
|
154
|
+
preferences have their own presets too)."""
|
|
155
|
+
return os.path.join(os.path.dirname(style.preferences_path()), "presets")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def from_figure(doc, name, with_layout=True):
|
|
159
|
+
"""This figure's look as a preset: every figure setting as it is DRAWN,
|
|
160
|
+
and the size and margins when `with_layout`."""
|
|
161
|
+
values = dict((setting.key, style.figure_value(doc, setting.key))
|
|
162
|
+
for setting in style.FIGURE_SETTINGS)
|
|
163
|
+
layout = doc.figure.to_state() if with_layout else None
|
|
164
|
+
objects = dict((name, dict((field, getattr(obj, field))
|
|
165
|
+
for field in OBJECT_FIELDS[name]))
|
|
166
|
+
for name, obj in figure_objects(doc).items())
|
|
167
|
+
return Preset(name, values, layout, objects=objects)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def to_state(preset):
|
|
171
|
+
state = {"format": FORMAT, "version": VERSION, "name": preset.name,
|
|
172
|
+
"style": dict(preset.values)}
|
|
173
|
+
if preset.layout:
|
|
174
|
+
state["figure"] = dict(preset.layout)
|
|
175
|
+
if preset.objects:
|
|
176
|
+
state["objects"] = dict((k, dict(v))
|
|
177
|
+
for k, v in preset.objects.items())
|
|
178
|
+
return state
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def from_state(state, path=""):
|
|
182
|
+
"""A preset from its JSON state. Every value is checked the way the
|
|
183
|
+
preferences are (`Setting.clean`): an unknown key or an illegal value is
|
|
184
|
+
left out rather than raised about. ValueError when it is no preset."""
|
|
185
|
+
if not isinstance(state, dict) or state.get("format") != FORMAT:
|
|
186
|
+
raise ValueError("not a style preset")
|
|
187
|
+
values = {}
|
|
188
|
+
for key, raw in (state.get("style") or {}).items():
|
|
189
|
+
setting = style.BY_KEY.get(key)
|
|
190
|
+
if setting is None or not setting.figure:
|
|
191
|
+
continue
|
|
192
|
+
cleaned = setting.clean(raw)
|
|
193
|
+
if cleaned is not None:
|
|
194
|
+
values[key] = cleaned
|
|
195
|
+
layout = state.get("figure")
|
|
196
|
+
if isinstance(layout, dict):
|
|
197
|
+
checked = figure_module.FigureLayout().load_state(layout)
|
|
198
|
+
layout = (dict((name, getattr(checked, name))
|
|
199
|
+
for name in figure_module.FigureLayout.FIELDS
|
|
200
|
+
if name in layout)
|
|
201
|
+
if checked.is_valid() else None)
|
|
202
|
+
else:
|
|
203
|
+
layout = None
|
|
204
|
+
objects = {}
|
|
205
|
+
for name, fields in (state.get("objects") or {}).items():
|
|
206
|
+
if name not in OBJECT_FIELDS or not isinstance(fields, dict):
|
|
207
|
+
continue
|
|
208
|
+
kept = {}
|
|
209
|
+
for field, raw in fields.items():
|
|
210
|
+
if field not in OBJECT_FIELDS[name]:
|
|
211
|
+
continue
|
|
212
|
+
ok, value = _checked(name, field, raw)
|
|
213
|
+
if ok:
|
|
214
|
+
kept[field] = value
|
|
215
|
+
if kept:
|
|
216
|
+
objects[name] = kept
|
|
217
|
+
name = str(state.get("name") or "").strip() or os.path.splitext(
|
|
218
|
+
os.path.basename(path))[0] or "Preset"
|
|
219
|
+
return Preset(name, values, layout, path, objects)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def read(path):
|
|
223
|
+
"""The preset in the file at `path`. ValueError when it is none."""
|
|
224
|
+
try:
|
|
225
|
+
with io.open(path, "r", encoding="utf-8") as fh:
|
|
226
|
+
state = json.load(fh)
|
|
227
|
+
except (OSError, ValueError) as exc:
|
|
228
|
+
raise ValueError("cannot read {}: {}".format(
|
|
229
|
+
os.path.basename(path), exc))
|
|
230
|
+
return from_state(state, path)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def file_name(name):
|
|
234
|
+
"""A file name for a preset called `name`: its letters, digits, spaces
|
|
235
|
+
and dashes, and the preset extension."""
|
|
236
|
+
stem = re.sub(r"[^\w\- ]+", "", str(name)).strip() or "preset"
|
|
237
|
+
return stem + branding.PRESET_EXT
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def save(preset, where=None):
|
|
241
|
+
"""Write `preset` into the presets folder (or `where`), replacing one of
|
|
242
|
+
the same name. Returns the path."""
|
|
243
|
+
where = where or folder()
|
|
244
|
+
if not os.path.isdir(where):
|
|
245
|
+
os.makedirs(where)
|
|
246
|
+
path = os.path.join(where, file_name(preset.name))
|
|
247
|
+
temporary = path + ".tmp"
|
|
248
|
+
with io.open(temporary, "w", encoding="utf-8") as fh:
|
|
249
|
+
json.dump(to_state(preset), fh, indent=1)
|
|
250
|
+
os.replace(temporary, path)
|
|
251
|
+
preset.path = path
|
|
252
|
+
return path
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def available(where=None):
|
|
256
|
+
"""`(presets, problems)`: every preset in the folder, by name, and a
|
|
257
|
+
line for each file that could not be read."""
|
|
258
|
+
where = where or folder()
|
|
259
|
+
found, problems = [], []
|
|
260
|
+
try:
|
|
261
|
+
names = sorted(os.listdir(where))
|
|
262
|
+
except OSError:
|
|
263
|
+
return [], []
|
|
264
|
+
for entry in names:
|
|
265
|
+
if not entry.lower().endswith(branding.PRESET_EXT):
|
|
266
|
+
continue
|
|
267
|
+
try:
|
|
268
|
+
found.append(read(os.path.join(where, entry)))
|
|
269
|
+
except ValueError as exc:
|
|
270
|
+
problems.append(str(exc))
|
|
271
|
+
found.sort(key=lambda p: p.name.lower())
|
|
272
|
+
return found, problems
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def install(path, where=None):
|
|
276
|
+
"""A preset file from elsewhere (a drop, a colleague's), copied into the
|
|
277
|
+
presets folder and returned. ValueError when it is no preset."""
|
|
278
|
+
preset = read(path)
|
|
279
|
+
where = where or folder()
|
|
280
|
+
if not os.path.isdir(where):
|
|
281
|
+
os.makedirs(where)
|
|
282
|
+
target = os.path.join(where, file_name(preset.name))
|
|
283
|
+
if os.path.normcase(os.path.abspath(path)) != os.path.normcase(
|
|
284
|
+
os.path.abspath(target)):
|
|
285
|
+
shutil.copyfile(path, target)
|
|
286
|
+
preset.path = target
|
|
287
|
+
return preset
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def changes(doc, preset):
|
|
291
|
+
"""`[(obj, attr, value), ...]` that put `preset` on `doc`: the figure's
|
|
292
|
+
style column and, when the preset has one, its size and margins. Only
|
|
293
|
+
what differs, so an undo step holds exactly what changed."""
|
|
294
|
+
out = []
|
|
295
|
+
for key, value in preset.values.items():
|
|
296
|
+
if getattr(doc.style, key, None) != value:
|
|
297
|
+
out.append((doc.style, key, value))
|
|
298
|
+
if preset.layout:
|
|
299
|
+
layout = doc.figure
|
|
300
|
+
wanted = layout.copy().load_state(preset.layout)
|
|
301
|
+
for name in figure_module.FigureLayout.FIELDS:
|
|
302
|
+
if getattr(wanted, name) != getattr(layout, name):
|
|
303
|
+
out.append((layout, name, getattr(wanted, name)))
|
|
304
|
+
targets = figure_objects(doc)
|
|
305
|
+
for name, fields in preset.objects.items():
|
|
306
|
+
obj = targets.get(name)
|
|
307
|
+
if obj is None:
|
|
308
|
+
continue
|
|
309
|
+
for field, value in fields.items():
|
|
310
|
+
if getattr(obj, field, None) != value:
|
|
311
|
+
out.append((obj, field, value))
|
|
312
|
+
return out
|
dscpanel/core/profile.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""What is particular to THIS plotter's data: DSC and SDT/TGA.
|
|
2
|
+
|
|
3
|
+
This program is the first of a family of stacked-trace plotters on one
|
|
4
|
+
handling. The handling is shared; what a kind of data wants differently -
|
|
5
|
+
how F frames it, which analyses exist, which decorators a figure starts
|
|
6
|
+
with - is gathered here, so a sibling changes this module rather than the
|
|
7
|
+
handling code.
|
|
8
|
+
|
|
9
|
+
UI-free: plain values.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
#: F frames everything at once: a two-step F - x first, then y - is of
|
|
13
|
+
#: no use for DSC. A spectra plotter, where the x range is the question,
|
|
14
|
+
#: sets True.
|
|
15
|
+
FIT_STAGED = False
|