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/register.py ADDED
@@ -0,0 +1,284 @@
1
+ """Putting the program in the Start Menu, and taking it out again.
2
+
3
+ Two rules:
4
+
5
+ 1. **It is opt-in and you run it yourself.** A program that quietly claims a
6
+ file type or writes a shortcut the first time it runs is a program people
7
+ learn to distrust. So nothing here happens at start-up; `triplot
8
+ register` does it and `triplot register --remove` undoes it.
9
+ 2. **It writes a manifest of everything it created.** Removal then takes away
10
+ exactly what was added, rather than guessing at paths a later version
11
+ might get wrong - and that manifest, together with
12
+ `branding.LEGACY_NAMES`, is the whole answer to "we will probably rename
13
+ this later": the new name knows what the old one installed and can clean
14
+ it up with `--clean-legacy`.
15
+
16
+ Everything is per user. Nothing here needs administrator rights, nothing is
17
+ written outside the user's own profile, and no registry key outside
18
+ `HKCU\\Software\\Classes`.
19
+
20
+ Aliases are the same machinery pointed at a name the user picks:
21
+ `triplot alias tp` makes `tp` work in a terminal on any platform - a
22
+ `.cmd` shim beside the installed command on Windows, a symlink in
23
+ `~/.local/bin` elsewhere - and records it so `--remove` can take it away.
24
+ """
25
+
26
+ import json
27
+ import os
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+
32
+ from . import branding
33
+
34
+
35
+ def _load():
36
+ try:
37
+ with open(branding.manifest_path(), "r", encoding="utf-8") as fh:
38
+ return json.load(fh)
39
+ except (OSError, ValueError):
40
+ return {"entries": []}
41
+
42
+
43
+ def _save(manifest):
44
+ os.makedirs(branding.app_dir(), exist_ok=True)
45
+ with open(branding.manifest_path(), "w", encoding="utf-8") as fh:
46
+ json.dump(manifest, fh, indent=1)
47
+
48
+
49
+ def _record(manifest, kind, path, name=""):
50
+ entry = {"kind": kind, "path": str(path), "name": name,
51
+ "app": branding.APP_NAME}
52
+ if entry not in manifest["entries"]:
53
+ manifest["entries"].append(entry)
54
+
55
+
56
+ def launcher():
57
+ """The command a shortcut should run: the installed exe if there is one.
58
+
59
+ The windowed entry point is preferred, so no console window appears
60
+ behind the program. Falling back to `python -m dscpanel` keeps this
61
+ working from a source checkout that was never pip-installed.
62
+ """
63
+ for name in (branding.EXE_NAME_GUI, branding.EXE_NAME):
64
+ found = shutil.which(name)
65
+ if found:
66
+ return [found]
67
+ python = sys.executable
68
+ if sys.platform == "win32":
69
+ windowed = os.path.join(os.path.dirname(python), "pythonw.exe")
70
+ if os.path.isfile(windowed):
71
+ python = windowed
72
+ return [python, "-m", "dscpanel"]
73
+
74
+
75
+ # --------------------------------------------------------------- Windows
76
+ def _windows_start_menu():
77
+ base = os.environ.get("APPDATA") or os.path.expanduser("~")
78
+ return os.path.join(base, "Microsoft", "Windows", "Start Menu",
79
+ "Programs")
80
+
81
+
82
+ def _make_shortcut(link_path, command, description):
83
+ """Write a .lnk through PowerShell's WScript.Shell.
84
+
85
+ Via PowerShell rather than pywin32, so the program does not need a
86
+ dependency whose only job is to make one file.
87
+ """
88
+ target = command[0]
89
+ arguments = " ".join('"{}"'.format(a) for a in command[1:])
90
+ script = (
91
+ "$s = New-Object -ComObject WScript.Shell; "
92
+ "$l = $s.CreateShortcut('{link}'); "
93
+ "$l.TargetPath = '{target}'; "
94
+ "$l.Arguments = '{args}'; "
95
+ "$l.Description = '{desc}'; "
96
+ "$l.WorkingDirectory = '{cwd}'; "
97
+ "$l.Save()").format(
98
+ link=link_path.replace("'", "''"),
99
+ target=target.replace("'", "''"),
100
+ args=arguments.replace("'", "''"),
101
+ desc=description.replace("'", "''"),
102
+ cwd=os.path.expanduser("~").replace("'", "''"))
103
+ subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
104
+ "-Command", script], check=True,
105
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
106
+ return link_path
107
+
108
+
109
+ def _windows_scripts_dir():
110
+ """Where pip put the command, which is a directory already on PATH.
111
+
112
+ The fallback is the USER scripts directory rather than the interpreter's
113
+ own: a Python under `C:\\Program Files` has a Scripts folder that needs
114
+ administrator rights to write to, so an alias written there fails for
115
+ exactly the people who did not install Python themselves. The user
116
+ scheme is where `pip install --user` puts a command anyway.
117
+ """
118
+ found = shutil.which(branding.EXE_NAME) or shutil.which(
119
+ branding.EXE_NAME_GUI)
120
+ if found:
121
+ return os.path.dirname(found)
122
+ import sysconfig
123
+ try:
124
+ path = sysconfig.get_path("scripts", "nt_user")
125
+ if path:
126
+ return path
127
+ except (KeyError, ValueError):
128
+ pass
129
+ return os.path.join(os.path.dirname(sys.executable), "Scripts")
130
+
131
+
132
+ # ----------------------------------------------------------------- POSIX
133
+ def _desktop_entry_dir():
134
+ base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser(
135
+ "~/.local/share")
136
+ return os.path.join(base, "applications")
137
+
138
+
139
+ def _write_desktop_entry(path, command, name, description):
140
+ with open(path, "w", encoding="utf-8") as fh:
141
+ fh.write("[Desktop Entry]\n")
142
+ fh.write("Type=Application\n")
143
+ fh.write("Name={}\n".format(name))
144
+ fh.write("Comment={}\n".format(description))
145
+ fh.write("Exec={} %F\n".format(" ".join(command)))
146
+ fh.write("Terminal=false\n")
147
+ fh.write("Categories=Science;Education;\n")
148
+ return path
149
+
150
+
151
+ # ------------------------------------------------------------------- API
152
+ def register(desktop=False, dry_run=False):
153
+ """Make the program findable: Start Menu, or a .desktop entry."""
154
+ manifest = _load()
155
+ command = launcher()
156
+ made = []
157
+ if sys.platform == "win32":
158
+ link = os.path.join(_windows_start_menu(),
159
+ branding.APP_NAME + ".lnk")
160
+ made.append(("start menu", link))
161
+ if desktop:
162
+ made.append(("desktop", os.path.join(
163
+ os.path.expanduser("~/Desktop"),
164
+ branding.APP_NAME + ".lnk")))
165
+ if not dry_run:
166
+ for kind, path in made:
167
+ os.makedirs(os.path.dirname(path), exist_ok=True)
168
+ _make_shortcut(path, command, branding.DESCRIPTION)
169
+ _record(manifest, kind, path, branding.APP_NAME)
170
+ else:
171
+ path = os.path.join(_desktop_entry_dir(),
172
+ branding.EXE_NAME + ".desktop")
173
+ made.append(("desktop entry", path))
174
+ if not dry_run:
175
+ os.makedirs(os.path.dirname(path), exist_ok=True)
176
+ _write_desktop_entry(path, command, branding.APP_NAME,
177
+ branding.DESCRIPTION)
178
+ _record(manifest, "desktop entry", path, branding.APP_NAME)
179
+ if not dry_run:
180
+ _save(manifest)
181
+ return made
182
+
183
+
184
+ def unregister(names=None, dry_run=False):
185
+ """Remove what was registered, for this name or for the ones given."""
186
+ manifest = _load()
187
+ wanted = set(names or [branding.APP_NAME])
188
+ removed, kept = [], []
189
+ for entry in manifest["entries"]:
190
+ if entry.get("app") not in wanted:
191
+ kept.append(entry)
192
+ continue
193
+ path = entry.get("path", "")
194
+ removed.append(path)
195
+ if not dry_run and os.path.exists(path):
196
+ try:
197
+ os.remove(path)
198
+ except OSError:
199
+ kept.append(entry)
200
+ if not dry_run:
201
+ manifest["entries"] = kept
202
+ _save(manifest)
203
+ return removed
204
+
205
+
206
+ def clean_legacy(dry_run=False):
207
+ """Take away what the program's PREVIOUS names left behind.
208
+
209
+ This is the deregistration half of a rename. `branding.LEGACY_NAMES` is
210
+ the list a renamed release ships; everything here works from it, so the
211
+ old name never has to be remembered by hand.
212
+ """
213
+ names = [entry[0] for entry in branding.LEGACY_NAMES]
214
+ removed = unregister(names, dry_run=dry_run) if names else []
215
+ # Shortcuts made before the manifest existed, or by a version that could
216
+ # not write it, are looked for by name as well.
217
+ for app_name, exe_name, _prog_id in branding.LEGACY_NAMES:
218
+ guesses = []
219
+ if sys.platform == "win32":
220
+ guesses.append(os.path.join(_windows_start_menu(),
221
+ app_name + ".lnk"))
222
+ guesses.append(os.path.join(os.path.expanduser("~/Desktop"),
223
+ app_name + ".lnk"))
224
+ guesses.append(os.path.join(_windows_scripts_dir(),
225
+ exe_name + ".cmd"))
226
+ else:
227
+ guesses.append(os.path.join(_desktop_entry_dir(),
228
+ exe_name + ".desktop"))
229
+ guesses.append(os.path.expanduser("~/.local/bin/" + exe_name))
230
+ for path in guesses:
231
+ if os.path.exists(path):
232
+ removed.append(path)
233
+ if not dry_run:
234
+ try:
235
+ os.remove(path)
236
+ except OSError:
237
+ pass
238
+ return removed
239
+
240
+
241
+ def alias(name, remove=False, dry_run=False):
242
+ """Make `name` start the program too, and remember that it was made."""
243
+ manifest = _load()
244
+ command = launcher()
245
+ if sys.platform == "win32":
246
+ path = os.path.join(_windows_scripts_dir(), name + ".cmd")
247
+ body = '@echo off\r\n"{}" %*\r\n'.format(" ".join(command))
248
+ else:
249
+ path = os.path.expanduser(os.path.join("~/.local/bin", name))
250
+ body = "#!/bin/sh\nexec {} \"$@\"\n".format(" ".join(command))
251
+ if remove:
252
+ if not dry_run and os.path.exists(path):
253
+ os.remove(path)
254
+ manifest["entries"] = [e for e in manifest["entries"]
255
+ if e.get("path") != path]
256
+ if not dry_run:
257
+ _save(manifest)
258
+ return path
259
+ if not dry_run:
260
+ os.makedirs(os.path.dirname(path), exist_ok=True)
261
+ with open(path, "w", encoding="utf-8", newline="") as fh:
262
+ fh.write(body)
263
+ if sys.platform != "win32":
264
+ os.chmod(path, 0o755)
265
+ _record(manifest, "alias", path, branding.APP_NAME)
266
+ _save(manifest)
267
+ return path
268
+
269
+
270
+ def aliases():
271
+ """Every alias this program knows it made."""
272
+ return [e["path"] for e in _load()["entries"] if e.get("kind") == "alias"]
273
+
274
+
275
+ def describe():
276
+ """What is registered right now, as lines for a terminal."""
277
+ manifest = _load()
278
+ if not manifest["entries"]:
279
+ return ["nothing registered (run: {} register)".format(
280
+ branding.EXE_NAME)]
281
+ return ["{:<14} {}{}".format(e.get("kind", "?"), e.get("path", "?"),
282
+ "" if os.path.exists(e.get("path", ""))
283
+ else " (gone)")
284
+ for e in manifest["entries"]]
File without changes
@@ -0,0 +1,146 @@
1
+ """The rest of the window, in the plot's colours.
2
+
3
+ The plot paints its own theme (`ui/plot.py`, `THEMES`). Everything else - the
4
+ outliner, the menus, the status bar, every dialog - is Qt's, and it followed
5
+ the system's light look while the plot beside it was dark. When the plot
6
+ is dark, so should every window be.
7
+
8
+ So the application palette follows the plot's theme: dark greys (Fusion
9
+ style plus a Blender-ish palette) for `blender-default`, Fusion's own
10
+ light palette for `light`, the Boombox skin for `boombox`. Fusion in every
11
+ case, because a palette only fully applies under a style that draws with
12
+ it, and switching styles between two themes would change the widgets'
13
+ shapes as well as their colours.
14
+ """
15
+
16
+ from PySide6.QtCore import Qt
17
+ from PySide6.QtGui import QColor, QGuiApplication, QPalette
18
+ from PySide6.QtWidgets import QApplication
19
+
20
+ from . import plot as plot_module
21
+
22
+ #: The theme the application palette was last set for, so a refresh that
23
+ #: changes nothing does not repaint every widget in the program.
24
+ _applied = None
25
+
26
+
27
+ def dark_palette():
28
+ """The dark palette: Fusion's greys, Blender-ish."""
29
+ p = QPalette()
30
+ grey = QColor(53, 53, 53)
31
+ base = QColor(42, 42, 42)
32
+ text = QColor(220, 220, 220)
33
+ disabled = QColor(128, 128, 128)
34
+ p.setColor(QPalette.Window, grey)
35
+ p.setColor(QPalette.WindowText, text)
36
+ p.setColor(QPalette.Base, base)
37
+ p.setColor(QPalette.AlternateBase, QColor(58, 58, 58))
38
+ p.setColor(QPalette.ToolTipBase, QColor(42, 42, 42))
39
+ p.setColor(QPalette.ToolTipText, text)
40
+ p.setColor(QPalette.PlaceholderText, QColor(140, 140, 140))
41
+ p.setColor(QPalette.Text, text)
42
+ p.setColor(QPalette.Button, grey)
43
+ p.setColor(QPalette.ButtonText, text)
44
+ p.setColor(QPalette.BrightText, QColor(255, 120, 120))
45
+ p.setColor(QPalette.Link, QColor(110, 160, 220))
46
+ p.setColor(QPalette.Highlight, QColor(70, 105, 150))
47
+ p.setColor(QPalette.HighlightedText, QColor(240, 240, 240))
48
+ for role in (QPalette.WindowText, QPalette.Text, QPalette.ButtonText):
49
+ p.setColor(QPalette.Disabled, role, disabled)
50
+ p.setColor(QPalette.Disabled, QPalette.Highlight, QColor(80, 80, 80))
51
+ return p
52
+
53
+
54
+ def light_palette():
55
+ """Fusion's classic light palette, written out.
56
+
57
+ Not `standardPalette()`: that follows the colour scheme in force at the
58
+ moment it is asked, so taken while the dark scheme was still set it
59
+ handed back a DARK palette and the "light" theme stayed dark."""
60
+ p = QPalette()
61
+ window = QColor(239, 239, 239)
62
+ text = QColor(20, 20, 20)
63
+ disabled = QColor(150, 150, 150)
64
+ p.setColor(QPalette.Window, window)
65
+ p.setColor(QPalette.WindowText, text)
66
+ p.setColor(QPalette.Base, QColor(255, 255, 255))
67
+ p.setColor(QPalette.AlternateBase, QColor(245, 245, 245))
68
+ p.setColor(QPalette.ToolTipBase, QColor(255, 255, 220))
69
+ p.setColor(QPalette.ToolTipText, text)
70
+ p.setColor(QPalette.PlaceholderText, QColor(120, 120, 120))
71
+ p.setColor(QPalette.Text, text)
72
+ p.setColor(QPalette.Button, window)
73
+ p.setColor(QPalette.ButtonText, text)
74
+ p.setColor(QPalette.BrightText, QColor(200, 30, 30))
75
+ p.setColor(QPalette.Link, QColor(30, 90, 180))
76
+ p.setColor(QPalette.Highlight, QColor(48, 140, 198))
77
+ p.setColor(QPalette.HighlightedText, QColor(255, 255, 255))
78
+ for role in (QPalette.WindowText, QPalette.Text, QPalette.ButtonText):
79
+ p.setColor(QPalette.Disabled, role, disabled)
80
+ return p
81
+
82
+
83
+ def boombox_palette():
84
+ """The Boombox skin: brushed-metal greys, parchment text, the LCD
85
+ green."""
86
+ p = QPalette()
87
+ window = QColor("#232323")
88
+ text = QColor("#d6d6c2")
89
+ disabled = QColor("#6b7178")
90
+ p.setColor(QPalette.Window, window)
91
+ p.setColor(QPalette.WindowText, text)
92
+ p.setColor(QPalette.Base, QColor("#2a2d31"))
93
+ p.setColor(QPalette.AlternateBase, QColor("#2e3236"))
94
+ p.setColor(QPalette.ToolTipBase, QColor("#0c130c"))
95
+ p.setColor(QPalette.ToolTipText, QColor("#39ff7a"))
96
+ p.setColor(QPalette.PlaceholderText, QColor("#9a9a86"))
97
+ p.setColor(QPalette.Text, text)
98
+ p.setColor(QPalette.Button, QColor("#3a3f44"))
99
+ p.setColor(QPalette.ButtonText, text)
100
+ p.setColor(QPalette.BrightText, QColor("#ff5555"))
101
+ p.setColor(QPalette.Link, QColor("#39ff7a"))
102
+ p.setColor(QPalette.Highlight, QColor("#2f7d4a"))
103
+ p.setColor(QPalette.HighlightedText, QColor("#caffd9"))
104
+ p.setColor(QPalette.Mid, QColor("#15171a"))
105
+ for role in (QPalette.WindowText, QPalette.Text, QPalette.ButtonText):
106
+ p.setColor(QPalette.Disabled, role, disabled)
107
+ p.setColor(QPalette.Disabled, QPalette.Highlight, QColor("#3a3f44"))
108
+ return p
109
+
110
+
111
+ #: The window's palette for each of the plot's themes.
112
+ PALETTES = {plot_module.THEME_DARK: dark_palette,
113
+ plot_module.THEME_LIGHT: light_palette,
114
+ plot_module.THEME_BOOMBOX: boombox_palette}
115
+
116
+
117
+ def apply(theme, app=None):
118
+ """Give the whole application the palette that goes with `theme`.
119
+
120
+ Returns True when anything changed. Also tells Qt which colour scheme is
121
+ in force where it can be told (Qt 6.8+), which is what darkens the
122
+ Windows title bar to match.
123
+ """
124
+ global _applied
125
+ app = app or QApplication.instance()
126
+ if app is None or theme == _applied:
127
+ return False
128
+ if app.style().name().lower() != "fusion":
129
+ app.setStyle("Fusion")
130
+ dark = theme != plot_module.THEME_LIGHT
131
+ # The scheme FIRST, the palette after it: a scheme change makes Qt
132
+ # re-derive colours, and the explicit palette has to be the last word.
133
+ hints = QGuiApplication.styleHints()
134
+ try:
135
+ hints.setColorScheme(Qt.ColorScheme.Dark if dark
136
+ else Qt.ColorScheme.Light)
137
+ except AttributeError:
138
+ pass # an older Qt: the title bar stays as it is
139
+ app.setPalette(PALETTES.get(theme, dark_palette)())
140
+ _applied = theme
141
+ return True
142
+
143
+
144
+ def applied():
145
+ """The theme the application is currently dressed in. For tests."""
146
+ return _applied