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/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)]
@@ -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
@@ -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