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/shades.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Shades of one colour for a stack of curves: dark to light, one hue.
|
|
2
|
+
|
|
3
|
+
A stacked series of samples reads well in one colour, darkest at the top:
|
|
4
|
+
F3 gives the selected curves such a set.
|
|
5
|
+
A shade is the base colour mixed towards black (a negative amount) or
|
|
6
|
+
towards white (a positive one), so the hue stays and only the lightness
|
|
7
|
+
moves; the base itself is amount 0.
|
|
8
|
+
|
|
9
|
+
UI-free: colours are "#rrggbb" strings.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _rgb(name):
|
|
14
|
+
name = str(name).lstrip("#")
|
|
15
|
+
if len(name) == 3:
|
|
16
|
+
name = "".join(c * 2 for c in name)
|
|
17
|
+
if len(name) != 6:
|
|
18
|
+
raise ValueError("not a colour: {!r}".format(name))
|
|
19
|
+
return tuple(int(name[i:i + 2], 16) for i in (0, 2, 4))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _name(rgb):
|
|
23
|
+
return "#{:02x}{:02x}{:02x}".format(
|
|
24
|
+
*(int(round(min(max(c, 0.0), 255.0))) for c in rgb))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def shade(base, amount):
|
|
28
|
+
"""`base` mixed towards white by `amount` (0..1) or towards black by
|
|
29
|
+
-`amount` (-1..0)."""
|
|
30
|
+
amount = min(max(float(amount), -1.0), 1.0)
|
|
31
|
+
target = 255.0 if amount > 0 else 0.0
|
|
32
|
+
share = abs(amount)
|
|
33
|
+
return _name(c + (target - c) * share for c in _rgb(base))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def shades(base, count, darkest=-0.4, lightest=0.5):
|
|
37
|
+
"""`count` colours from `darkest` to `lightest` (amounts as in
|
|
38
|
+
`shade`), evenly spaced; one curve gets the middle of the range."""
|
|
39
|
+
count = int(count)
|
|
40
|
+
if count <= 0:
|
|
41
|
+
return []
|
|
42
|
+
if count == 1:
|
|
43
|
+
return [shade(base, (darkest + lightest) / 2.0)]
|
|
44
|
+
step = (lightest - darkest) / (count - 1)
|
|
45
|
+
return [shade(base, darkest + step * k) for k in range(count)]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def stack_order(scans):
|
|
49
|
+
"""The scans from the TOP of the stack down: by offset, highest first,
|
|
50
|
+
ties in the order given."""
|
|
51
|
+
indexed = list(enumerate(scans))
|
|
52
|
+
indexed.sort(key=lambda pair: (-float(getattr(pair[1], "offset", 0.0)
|
|
53
|
+
or 0.0), pair[0]))
|
|
54
|
+
return [scan for _k, scan in indexed]
|
dscpanel/core/style.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"""The house style: what a size is when nobody has chosen one.
|
|
2
|
+
|
|
3
|
+
Defaults persist between sessions and can still be overridden for one
|
|
4
|
+
figure. That is three places a value can come from, and a
|
|
5
|
+
fourth that is written here, looked up most specific first:
|
|
6
|
+
|
|
7
|
+
1. **the object.** A size typed into one analysis's own settings is a
|
|
8
|
+
decision about that analysis, and it wins over everything.
|
|
9
|
+
2. **the figure** (`Document.style`, a `FigureStyle`). Saved in the session
|
|
10
|
+
file, so "every analysis label in THIS figure at 7 pt" travels with the
|
|
11
|
+
figure and does not touch any other.
|
|
12
|
+
3. **the user's defaults**: `preferences.json` beside the registration
|
|
13
|
+
manifest (`branding.app_dir()`). Kept on this computer, shared by every
|
|
14
|
+
figure, and what a new figure starts from.
|
|
15
|
+
4. **the built-in value** in `SETTINGS` below, which is what the program
|
|
16
|
+
shipped with and what "reset" goes back to.
|
|
17
|
+
|
|
18
|
+
None at levels 1 and 2 means "not chosen here, ask the next level". So an
|
|
19
|
+
object attribute such as `Analysis.label_size` is None until somebody sets
|
|
20
|
+
it, and nothing may read it directly: every read goes through `value`, or a
|
|
21
|
+
figure made under one set of defaults will quietly ignore the next.
|
|
22
|
+
|
|
23
|
+
UI-free and Qt-free: the preferences are a JSON file, not QSettings, so this
|
|
24
|
+
module is testable with a temporary path and nothing else.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
|
|
30
|
+
from .. import branding
|
|
31
|
+
from . import numbers
|
|
32
|
+
|
|
33
|
+
#: How an analysis label sits against the arrow that points at its feature,
|
|
34
|
+
#: which is the DSC_Plotter template's `flush`: `left` puts the text's left
|
|
35
|
+
#: edge on the arrow, so the label reads to the right of it; `right` the
|
|
36
|
+
#: mirror image; `center` hangs it centred over the arrow.
|
|
37
|
+
FLUSH_LEFT = "left"
|
|
38
|
+
FLUSH_CENTER = "center"
|
|
39
|
+
FLUSH_RIGHT = "right"
|
|
40
|
+
FLUSHES = (FLUSH_LEFT, FLUSH_CENTER, FLUSH_RIGHT)
|
|
41
|
+
#: "By analysis kind": what the template itself chooses per artist - tangent
|
|
42
|
+
#: constructions (onset, endset, glass transition) flush left, everything
|
|
43
|
+
#: else centred.
|
|
44
|
+
FLUSH_AUTO = "auto"
|
|
45
|
+
|
|
46
|
+
#: Words for the choices, for the dialogs.
|
|
47
|
+
FLUSH_TITLES = {
|
|
48
|
+
FLUSH_AUTO: "by analysis kind",
|
|
49
|
+
FLUSH_LEFT: "left",
|
|
50
|
+
FLUSH_CENTER: "centred",
|
|
51
|
+
FLUSH_RIGHT: "right",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#: The lines an onset, endset or glass transition is drawn with
|
|
55
|
+
#: (`Analysis.construction`): the tangent construction - TRIOS's own for
|
|
56
|
+
#: a `.tri`'s analysis - or chords from the interval's bounds to the
|
|
57
|
+
#: point, or none. The interval's
|
|
58
|
+
#: dashes are a separate switch (`Analysis.show_interval`).
|
|
59
|
+
LINES_TANGENTS = "tangents"
|
|
60
|
+
LINES_CHORDS = "chords"
|
|
61
|
+
LINES_NONE = "none"
|
|
62
|
+
LINES = (LINES_TANGENTS, LINES_CHORDS, LINES_NONE)
|
|
63
|
+
|
|
64
|
+
LINES_TITLES = {
|
|
65
|
+
LINES_TANGENTS: "tangents",
|
|
66
|
+
LINES_CHORDS: "chords to the point",
|
|
67
|
+
LINES_NONE: "none",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#: How an integration's area is shaded (`Analysis.shading`):
|
|
71
|
+
#: translucent, or opaque in the colour the translucent fill
|
|
72
|
+
#: makes over the page, so nothing behind it shows through.
|
|
73
|
+
SHADING_TRANSLUCENT = "translucent"
|
|
74
|
+
SHADING_OPAQUE = "opaque"
|
|
75
|
+
SHADINGS = (SHADING_TRANSLUCENT, SHADING_OPAQUE)
|
|
76
|
+
SHADING_TITLES = {SHADING_TRANSLUCENT: "translucent",
|
|
77
|
+
SHADING_OPAQUE: "opaque (as seen over the page)"}
|
|
78
|
+
|
|
79
|
+
#: Whether an integration's label also gives its peak temperature, Tp
|
|
80
|
+
#: (`Analysis.show_peak`).
|
|
81
|
+
PEAK_OFF = "off"
|
|
82
|
+
PEAK_ON = "on"
|
|
83
|
+
PEAKS = (PEAK_OFF, PEAK_ON)
|
|
84
|
+
PEAK_TITLES = {PEAK_OFF: "not shown", PEAK_ON: "shown after the enthalpy"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Setting(object):
|
|
88
|
+
"""One row of the house style: a name, a built-in value and its limits."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, key, title, default, kind="size", low=None, high=None,
|
|
91
|
+
step=0.5, decimals=1, choices=(), note="", figure=True,
|
|
92
|
+
suffix="", titles=None):
|
|
93
|
+
self.key = key
|
|
94
|
+
#: Words for a choice's values, for the settings page.
|
|
95
|
+
self.titles = dict(titles or {})
|
|
96
|
+
#: Shown after the number in a settings field (" px").
|
|
97
|
+
self.suffix = suffix
|
|
98
|
+
self.title = title
|
|
99
|
+
self.default = default
|
|
100
|
+
#: True for the figure's style (a figure may override it and saves
|
|
101
|
+
#: it); False for how the program HANDLES, which is the user's alone
|
|
102
|
+
#: and has nothing to do with any one figure - the pick distance.
|
|
103
|
+
self.figure = figure
|
|
104
|
+
#: "size" is a number; "choice" is one of `choices`.
|
|
105
|
+
self.kind = kind
|
|
106
|
+
self.low = low
|
|
107
|
+
self.high = high
|
|
108
|
+
self.step = step
|
|
109
|
+
self.decimals = decimals
|
|
110
|
+
self.choices = tuple(choices)
|
|
111
|
+
self.note = note
|
|
112
|
+
|
|
113
|
+
def clean(self, value):
|
|
114
|
+
"""`value` if it is a legal one for this setting, else None.
|
|
115
|
+
|
|
116
|
+
Used on everything read from a file, so a hand-edited preferences file
|
|
117
|
+
or a session from a later version degrades to "not chosen" rather than
|
|
118
|
+
to an exception in a paint call.
|
|
119
|
+
"""
|
|
120
|
+
if value is None:
|
|
121
|
+
return None
|
|
122
|
+
if self.kind == "choice":
|
|
123
|
+
return value if value in self.choices else None
|
|
124
|
+
if self.kind == "format":
|
|
125
|
+
from . import labels
|
|
126
|
+
return labels.normalise_format(value)
|
|
127
|
+
if self.kind == "font":
|
|
128
|
+
return str(value).strip()
|
|
129
|
+
try:
|
|
130
|
+
number = float(value)
|
|
131
|
+
except (TypeError, ValueError):
|
|
132
|
+
return None
|
|
133
|
+
if number != number: # NaN
|
|
134
|
+
return None
|
|
135
|
+
if self.low is not None:
|
|
136
|
+
number = max(float(self.low), number)
|
|
137
|
+
if self.high is not None:
|
|
138
|
+
number = min(float(self.high), number)
|
|
139
|
+
return number
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
#: Every value that falls back on the house style, in the order the settings
|
|
143
|
+
#: page lists them. The built-in values for the analysis labels, the
|
|
144
|
+
#: captions and the numbers were settled on in use; the rest are what the
|
|
145
|
+
#: objects carried before there was a house style.
|
|
146
|
+
SETTINGS = (
|
|
147
|
+
# The typeface of everything on the figure. Sizes stay per element
|
|
148
|
+
# below, and italic is per character (the `*T*` markup), so the one
|
|
149
|
+
# thing all text shares is the family. Empty is the system's own.
|
|
150
|
+
Setting("font_family", "Font family", "Bahnschrift", kind="font",
|
|
151
|
+
note="Typeface of all figure text."),
|
|
152
|
+
# The element labels of a structure: a rounded face reads as a
|
|
153
|
+
# drawing rather than as running text. Empty is the figure's font
|
|
154
|
+
# family.
|
|
155
|
+
Setting("structure_font", "Structure labels", "Arial Rounded MT",
|
|
156
|
+
kind="font", note="Typeface of element labels in structures; "
|
|
157
|
+
"empty: the figure's."),
|
|
158
|
+
Setting("analysis_size", "Analysis labels", 11.0, low=5.0, high=40.0,
|
|
159
|
+
note="Onset, integral and Tg labels."),
|
|
160
|
+
Setting("analysis_flush", "Analysis label alignment", FLUSH_LEFT,
|
|
161
|
+
kind="choice", choices=(FLUSH_AUTO,) + FLUSHES,
|
|
162
|
+
titles=FLUSH_TITLES, note="Edge of the label on its arrow."),
|
|
163
|
+
# Tangents by default, drawn solid in the axis colour, each running
|
|
164
|
+
# this far past where it crosses the other.
|
|
165
|
+
Setting("analysis_construction", "Onset, endset and Tg lines",
|
|
166
|
+
LINES_TANGENTS, kind="choice", choices=LINES, titles=LINES_TITLES,
|
|
167
|
+
note="Tangent construction, chords to the point, or none."),
|
|
168
|
+
Setting("analysis_peak", "Integration peak temperature (Tp)", PEAK_OFF,
|
|
169
|
+
kind="choice", choices=PEAKS, titles=PEAK_TITLES,
|
|
170
|
+
note="Shown after the enthalpy: \u0394H = 141 J/g, "
|
|
171
|
+
"Tp = 124 \u00b0C."),
|
|
172
|
+
# Half the length of the dash at each end of an analysis's interval
|
|
173
|
+
# (4 was a bit too long).
|
|
174
|
+
Setting("interval_tick", "Interval marks", 3.0, low=0.5, high=30.0,
|
|
175
|
+
step=0.5, decimals=1, suffix=" px",
|
|
176
|
+
note="Half the length of the dash at each end of an "
|
|
177
|
+
"analysis's interval."),
|
|
178
|
+
Setting("analysis_shading", "Integration shading", SHADING_TRANSLUCENT,
|
|
179
|
+
kind="choice", choices=SHADINGS, titles=SHADING_TITLES,
|
|
180
|
+
note="Opaque: the colour the translucent fill makes over the "
|
|
181
|
+
"page, with nothing showing through."),
|
|
182
|
+
# 0 built in: the tangents meet exactly.
|
|
183
|
+
Setting("tangent_overshoot", "Tangent overshoot", 0.0, low=0.0,
|
|
184
|
+
high=72.0, step=1.0, decimals=1, suffix=" pt",
|
|
185
|
+
note="How far a tangent runs past its crossing."),
|
|
186
|
+
Setting("caption_size", "Axis captions", 14.0, low=5.0, high=40.0,
|
|
187
|
+
note="T / \u00b0C and Heat Flow / W/g."),
|
|
188
|
+
Setting("tick_size", "Axis numbers", 12.0, low=4.0, high=30.0),
|
|
189
|
+
# Between an axis's numbers and its caption. The caption used to sit a
|
|
190
|
+
# fixed 16 px below the axis line, which the numbers themselves nearly
|
|
191
|
+
# fill at 8 pt - and overlapped at anything bigger.
|
|
192
|
+
Setting("caption_gap", "Caption distance", 8.0, low=0.0, high=80.0,
|
|
193
|
+
step=1.0, decimals=0, suffix=" px",
|
|
194
|
+
note="Space between axis numbers and caption."),
|
|
195
|
+
Setting("legend_size", "Legend text", 9.0, low=5.0, high=30.0),
|
|
196
|
+
Setting("label_size", "Labels", 10.0, low=5.0, high=48.0,
|
|
197
|
+
note="Labels added with Ctrl+T."),
|
|
198
|
+
# The template's `add_yoffset_markers(..., fs=7)`.
|
|
199
|
+
Setting("offset_marker_size", "Y-offset markers", 7.0, low=4.0,
|
|
200
|
+
high=30.0, note="Size of the y-offset markers."),
|
|
201
|
+
# The template's `add_exo_arrow` writes 'Exo Down' at size=10.
|
|
202
|
+
Setting("arrow_size", "Heat-flow arrow text", 10.0, low=4.0, high=40.0),
|
|
203
|
+
# How numbers are WRITTEN (`core/numbers.py`): a percent format for one
|
|
204
|
+
# number. %.Ng is N significant figures, all written, never 1e+03.
|
|
205
|
+
Setting("temperature_format", "Temperatures (onset, endset, Tg)",
|
|
206
|
+
numbers.TEMPERATURE, kind="format",
|
|
207
|
+
note="Onset, endset, Tg. %.0f whole degrees; %.0f K "
|
|
208
|
+
"converts."),
|
|
209
|
+
Setting("value_format", "Enthalpies and other results", numbers.VALUE,
|
|
210
|
+
kind="format",
|
|
211
|
+
note="%.3g: three significant figures. A unit "
|
|
212
|
+
"converts."),
|
|
213
|
+
Setting("offset_format", "Offset markers", numbers.OFFSET, kind="format",
|
|
214
|
+
note="Text of each y-offset marker."),
|
|
215
|
+
Setting("mass_format", "Masses (m% at a temperature)", numbers.MASS,
|
|
216
|
+
kind="format",
|
|
217
|
+
note="%.0f whole percent; %.1f mg converts."),
|
|
218
|
+
Setting("line_width", "Curve width", 1.0, low=0.2, high=8.0, step=0.2,
|
|
219
|
+
decimals=2),
|
|
220
|
+
# How much room F leaves round the data on each side - the template's
|
|
221
|
+
# `set_side_margins` - as the SHARE OF THE AXIS left empty: left 0.1
|
|
222
|
+
# is the first tenth of the x axis. The margin gizmos on the page
|
|
223
|
+
# edges set them for one figure.
|
|
224
|
+
Setting("fit_left", "Fit margin, left", 0.0, low=0.0, high=0.9,
|
|
225
|
+
step=0.01, decimals=3,
|
|
226
|
+
note="Share of the x axis left empty left of the data: "
|
|
227
|
+
"0.1 is 10 %."),
|
|
228
|
+
Setting("fit_right", "Fit margin, right", 0.0, low=0.0, high=0.9,
|
|
229
|
+
step=0.01, decimals=3,
|
|
230
|
+
note="Share of the x axis left empty right of the data."),
|
|
231
|
+
Setting("fit_bottom", "Fit margin, bottom", 0.05, low=0.0, high=0.9,
|
|
232
|
+
step=0.01, decimals=3,
|
|
233
|
+
note="Share of the y axis left empty below the curves."),
|
|
234
|
+
Setting("fit_top", "Fit margin, top", 0.05, low=0.0, high=0.9,
|
|
235
|
+
step=0.01, decimals=3,
|
|
236
|
+
note="Share of the y axis left empty above the curves."),
|
|
237
|
+
# How close a press must be to a curve or a label to act on it (mark an
|
|
238
|
+
# interval, move the label) rather than start a box select. 60 px
|
|
239
|
+
# grabbed a neighbouring scan where curves run close; 14 is the old
|
|
240
|
+
# fixed value, and the hand that uses it decides.
|
|
241
|
+
Setting("pick_radius", "Pick distance", 14.0, low=2.0, high=60.0,
|
|
242
|
+
step=1.0, decimals=0, figure=False, suffix=" px",
|
|
243
|
+
note="How near a press acts on an object; further away "
|
|
244
|
+
"it draws a box."),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
BY_KEY = dict((setting.key, setting) for setting in SETTINGS)
|
|
248
|
+
|
|
249
|
+
#: The ones a figure can override and a session saves.
|
|
250
|
+
FIGURE_SETTINGS = tuple(s for s in SETTINGS if s.figure)
|
|
251
|
+
|
|
252
|
+
#: Which object attribute falls back on which setting, by `Obj.kind`.
|
|
253
|
+
FIELDS = {
|
|
254
|
+
("analysis", "label_size"): "analysis_size",
|
|
255
|
+
("analysis", "flush"): "analysis_flush",
|
|
256
|
+
("analysis", "construction"): "analysis_construction",
|
|
257
|
+
("analysis", "shading"): "analysis_shading",
|
|
258
|
+
("analysis", "show_peak"): "analysis_peak",
|
|
259
|
+
("analysis", "interval_size"): "interval_tick",
|
|
260
|
+
("axis", "label_size"): "caption_size",
|
|
261
|
+
("axis", "tick_size"): "tick_size",
|
|
262
|
+
("axis", "label_gap"): "caption_gap",
|
|
263
|
+
("legend", "size"): "legend_size",
|
|
264
|
+
("label", "size"): "label_size",
|
|
265
|
+
("scan", "line_width"): "line_width",
|
|
266
|
+
("arrow", "size"): "arrow_size",
|
|
267
|
+
("offset_marker", "size"): "offset_marker_size",
|
|
268
|
+
("offset_marker", "number_format"): "offset_format",
|
|
269
|
+
("molecule", "label_font"): "structure_font",
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class FigureStyle(object):
|
|
274
|
+
"""Level 2: one figure's own choices, every one None until made.
|
|
275
|
+
|
|
276
|
+
Plain attributes rather than a dict, so the undo stack's `SetProps` can
|
|
277
|
+
record a change to it exactly as it records a change to a scan.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
kind = "style"
|
|
281
|
+
|
|
282
|
+
def __init__(self):
|
|
283
|
+
for setting in FIGURE_SETTINGS:
|
|
284
|
+
setattr(self, setting.key, None)
|
|
285
|
+
|
|
286
|
+
def chosen(self):
|
|
287
|
+
"""`{key: value}` for what this figure has set, and nothing else."""
|
|
288
|
+
return dict((s.key, getattr(self, s.key)) for s in FIGURE_SETTINGS
|
|
289
|
+
if getattr(self, s.key) is not None)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ----------------------------------------------------------- level 3: user
|
|
293
|
+
#: The user's defaults, as loaded. Empty means "the built-in values".
|
|
294
|
+
_preferences = {}
|
|
295
|
+
|
|
296
|
+
#: The figure layout a NEW figure starts from (`core/figure.py`), as saved
|
|
297
|
+
#: state, or None for the built-in one. A session keeps its own.
|
|
298
|
+
_figure_default = None
|
|
299
|
+
|
|
300
|
+
#: Where the window was and where its docks were, as the UI saved them
|
|
301
|
+
#: (opaque strings), or None before the first close.
|
|
302
|
+
_window_state = None
|
|
303
|
+
|
|
304
|
+
#: Where the preferences live. Tests point this at a temporary file so that
|
|
305
|
+
#: running the suite never touches the defaults of whoever runs it.
|
|
306
|
+
PATH_OVERRIDE = None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
#: The fit margins, by side, and the side across from each.
|
|
310
|
+
FIT_SIDES = ("left", "right", "bottom", "top")
|
|
311
|
+
FIT_OPPOSITE = {"left": "right", "right": "left", "bottom": "top",
|
|
312
|
+
"top": "bottom"}
|
|
313
|
+
#: Two opposite margins together leave at least this share for the data.
|
|
314
|
+
FIT_MOST = 0.95
|
|
315
|
+
#: What the margins were before they were shares of the axis: percent of
|
|
316
|
+
#: the data's range, these built in.
|
|
317
|
+
_OLD_FIT_PERCENT = {"left": 0.0, "right": 0.0, "bottom": 6.0, "top": 6.0}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def convert_old_fit(entries):
|
|
321
|
+
"""Fit margins written as PERCENT OF THE DATA's range (the house style
|
|
322
|
+
of preferences version 1 and of sessions before version 6)
|
|
323
|
+
as shares of the axis, in place: p % on each side of a range D makes an
|
|
324
|
+
axis D (1 + (pa + pb) / 100) long, of which p / 100 D is empty."""
|
|
325
|
+
old = dict(_OLD_FIT_PERCENT)
|
|
326
|
+
present = []
|
|
327
|
+
for side in FIT_SIDES:
|
|
328
|
+
try:
|
|
329
|
+
raw = entries.get("fit_" + side)
|
|
330
|
+
if raw is not None:
|
|
331
|
+
old[side] = float(raw)
|
|
332
|
+
present.append(side)
|
|
333
|
+
except (TypeError, ValueError):
|
|
334
|
+
entries.pop("fit_" + side, None)
|
|
335
|
+
for side in present:
|
|
336
|
+
total = 1.0 + (old[side] + old[FIT_OPPOSITE[side]]) / 100.0
|
|
337
|
+
entries["fit_" + side] = round(old[side] / 100.0 / total, 4)
|
|
338
|
+
return entries
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def preferences_path():
|
|
342
|
+
if PATH_OVERRIDE:
|
|
343
|
+
return str(PATH_OVERRIDE)
|
|
344
|
+
return os.path.join(branding.app_dir(), "preferences.json")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def load_preferences(path=None):
|
|
348
|
+
"""Read the user's defaults. A missing or unreadable file means none.
|
|
349
|
+
|
|
350
|
+
Unreadable is not an error worth stopping the program for: the file is a
|
|
351
|
+
convenience, and the built-in values are always a correct figure.
|
|
352
|
+
"""
|
|
353
|
+
global _figure_default, _window_state
|
|
354
|
+
_preferences.clear()
|
|
355
|
+
_figure_default = None
|
|
356
|
+
_window_state = None
|
|
357
|
+
path = path or preferences_path()
|
|
358
|
+
try:
|
|
359
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
360
|
+
stored = json.load(fh)
|
|
361
|
+
except (OSError, ValueError):
|
|
362
|
+
return dict(_preferences)
|
|
363
|
+
if not isinstance(stored, dict):
|
|
364
|
+
return dict(_preferences)
|
|
365
|
+
if isinstance(stored.get("figure"), dict):
|
|
366
|
+
_figure_default = dict(stored["figure"])
|
|
367
|
+
if isinstance(stored.get("window"), dict):
|
|
368
|
+
set_window_state(stored["window"])
|
|
369
|
+
version = stored.get("version", 1)
|
|
370
|
+
for section in ("style", "handling"):
|
|
371
|
+
entries = stored.get(section)
|
|
372
|
+
if not isinstance(entries, dict):
|
|
373
|
+
continue
|
|
374
|
+
if section == "style" and not (isinstance(version, int)
|
|
375
|
+
and version >= 2):
|
|
376
|
+
entries = convert_old_fit(dict(entries))
|
|
377
|
+
for key, raw in entries.items():
|
|
378
|
+
setting = BY_KEY.get(key)
|
|
379
|
+
cleaned = setting.clean(raw) if setting is not None else None
|
|
380
|
+
if cleaned is not None:
|
|
381
|
+
_preferences[key] = cleaned
|
|
382
|
+
return dict(_preferences)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def save_preferences(path=None):
|
|
386
|
+
"""Write the user's defaults. Only what differs from the built-in.
|
|
387
|
+
|
|
388
|
+
Two sections: `style` is what a figure can also override, `handling` is
|
|
389
|
+
how the program responds to the hand (the pick distance)."""
|
|
390
|
+
path = path or preferences_path()
|
|
391
|
+
folder = os.path.dirname(path)
|
|
392
|
+
if folder and not os.path.isdir(folder):
|
|
393
|
+
os.makedirs(folder)
|
|
394
|
+
changed = dict((key, value) for key, value in _preferences.items()
|
|
395
|
+
if value != BY_KEY[key].default)
|
|
396
|
+
state = {"format": "preferences", "version": 2,
|
|
397
|
+
"style": dict((k, v) for k, v in changed.items()
|
|
398
|
+
if BY_KEY[k].figure),
|
|
399
|
+
"handling": dict((k, v) for k, v in changed.items()
|
|
400
|
+
if not BY_KEY[k].figure)}
|
|
401
|
+
if _figure_default is not None:
|
|
402
|
+
state["figure"] = dict(_figure_default)
|
|
403
|
+
if _window_state is not None:
|
|
404
|
+
state["window"] = dict(_window_state)
|
|
405
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
406
|
+
json.dump(state, fh, indent=1)
|
|
407
|
+
return path
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def window_state():
|
|
411
|
+
"""The window's saved place and docks, or None."""
|
|
412
|
+
return dict(_window_state) if _window_state else None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def set_window_state(state):
|
|
416
|
+
global _window_state
|
|
417
|
+
_window_state = (dict((str(k), str(v)) for k, v in state.items())
|
|
418
|
+
if state else None)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def preferences():
|
|
422
|
+
"""A copy of the user's defaults, for a dialog's Cancel."""
|
|
423
|
+
return dict(_preferences)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def figure_default():
|
|
427
|
+
"""A fresh layout from the user's default for new figures, or None."""
|
|
428
|
+
if _figure_default is None:
|
|
429
|
+
return None
|
|
430
|
+
from . import figure
|
|
431
|
+
return figure.FigureLayout().load_state(_figure_default)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def set_figure_default(layout):
|
|
435
|
+
"""Make `layout` (or None: the built-in one) what new figures start
|
|
436
|
+
from. Written with the other defaults by `save_preferences`."""
|
|
437
|
+
global _figure_default
|
|
438
|
+
_figure_default = None if layout is None else dict(layout.to_state())
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def restore_preferences(saved, figure_state=False):
|
|
442
|
+
"""Put the defaults back (a Revert, a test). `figure_state` other than
|
|
443
|
+
False replaces the figure default too."""
|
|
444
|
+
global _figure_default
|
|
445
|
+
_preferences.clear()
|
|
446
|
+
_preferences.update(saved or {})
|
|
447
|
+
if figure_state is not False:
|
|
448
|
+
_figure_default = figure_state
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def builtin(key):
|
|
452
|
+
return BY_KEY[key].default
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def user_preference(key):
|
|
456
|
+
"""The user's own default for `key`, or None where they have none."""
|
|
457
|
+
return _preferences.get(key)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def preference(key):
|
|
461
|
+
"""Level 3 and below: the user's default, else the built-in value."""
|
|
462
|
+
value = _preferences.get(key)
|
|
463
|
+
return builtin(key) if value is None else value
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def set_preference(key, value):
|
|
467
|
+
"""Change a default. None (or the built-in value) forgets it."""
|
|
468
|
+
setting = BY_KEY[key]
|
|
469
|
+
value = setting.clean(value)
|
|
470
|
+
if value is None or value == setting.default:
|
|
471
|
+
_preferences.pop(key, None)
|
|
472
|
+
else:
|
|
473
|
+
_preferences[key] = value
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# ------------------------------------------------------------- resolution
|
|
477
|
+
def figure_value(doc, key):
|
|
478
|
+
"""Level 2 and below: what an object that chose nothing gets."""
|
|
479
|
+
own = getattr(getattr(doc, "style", None), key, None)
|
|
480
|
+
return preference(key) if own is None else own
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def key_for(obj, attr):
|
|
484
|
+
if getattr(obj, "kind", "") == "analysis" and attr == "number_format":
|
|
485
|
+
# By what the number IS, not by the kind of object.
|
|
486
|
+
quantity = getattr(obj, "quantity", "")
|
|
487
|
+
if quantity == "temperature":
|
|
488
|
+
return "temperature_format"
|
|
489
|
+
if quantity == "mass":
|
|
490
|
+
return "mass_format"
|
|
491
|
+
return "value_format"
|
|
492
|
+
return FIELDS.get((getattr(obj, "kind", ""), attr))
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def value(doc, obj, attr):
|
|
496
|
+
"""The value `obj.attr` is DRAWN with: its own, or the figure's, or the
|
|
497
|
+
user's default, or the built-in one.
|
|
498
|
+
|
|
499
|
+
Every read of a styled attribute goes through here. `doc` may be None
|
|
500
|
+
(a dialog opened outside a window), which skips the figure level.
|
|
501
|
+
"""
|
|
502
|
+
own = getattr(obj, attr, None)
|
|
503
|
+
if own is not None:
|
|
504
|
+
return own
|
|
505
|
+
key = key_for(obj, attr)
|
|
506
|
+
if key is None:
|
|
507
|
+
return None
|
|
508
|
+
return figure_value(doc, key)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def inherited(doc, obj, attr):
|
|
512
|
+
"""What `obj.attr` would be if it chose nothing - for "use the default"."""
|
|
513
|
+
key = key_for(obj, attr)
|
|
514
|
+
return figure_value(doc, key) if key is not None else None
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def flush_for(analysis, flush):
|
|
518
|
+
"""A flush that is a side, never `auto`, for this analysis.
|
|
519
|
+
|
|
520
|
+
`auto` is the template's own choice: `add_tangent` and
|
|
521
|
+
`add_glass_transition` flush left, `add_integral` and `mark_spot` centre.
|
|
522
|
+
"""
|
|
523
|
+
if flush in FLUSHES:
|
|
524
|
+
return flush
|
|
525
|
+
name = str(getattr(analysis, "model_name", ""))
|
|
526
|
+
if "Onset" in name or "Endset" in name or "Glass" in name:
|
|
527
|
+
return FLUSH_LEFT
|
|
528
|
+
return FLUSH_CENTER
|