Repository navigation
Expand file tree
/
Copy pathapp-theme
More file actions
executable file
·298 lines (253 loc) · 11.3 KB
/
Copy pathapp-theme
File metadata and controls
executable file
·298 lines (253 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
#!/usr/bin/env python3
"""Make the tools Omarchy does not cover follow the theme.
app-theme scan JSON: which tools are installed and which follow
app-theme on <tool> start following the theme
app-theme off <tool> stop, leaving the file as it was
app-theme reapply rewrite every block that is on (the theme hook)
Omarchy already retints terminals, btop, helix, tmux, VS Code, Obsidian, the
browsers and more on every theme switch. These three it does not:
bat follows the terminal's own colours (--theme=ansi)
delta git's diff colours, from the theme's palette
fzf the picker's colours, from the theme's palette
Each lives between Lacquer's fences in the tool's own config, so what was
already there is untouched and switching off removes exactly what was added.
"""
import json, os, re, subprocess, sys, tempfile
HOME = os.environ.get("HOME", "")
STATE = os.path.join(HOME, ".local/state/omarchy/current/theme/colors.toml")
GEN_DIR = os.path.join(HOME, ".config/lacquer")
BEGIN = ">>> lacquer managed block >>>"
END = "<<< lacquer managed block <<<"
READ_LIMIT = 512 * 1024
HEX = re.compile(r"^#?([0-9a-fA-F]{6})$")
def read_capped(path):
try:
if not os.path.isfile(path) or os.path.getsize(path) > READ_LIMIT:
return None
with open(path, encoding="utf-8") as f:
return f.read(READ_LIMIT + 1)[:READ_LIMIT]
except (OSError, UnicodeDecodeError):
return None
def read_or_refuse(path):
"""The file's text, "" when there is no file yet — never a silent empty.
A file that exists but cannot be read here (too big, not UTF-8, not a
regular file) must not be treated as an empty one: writing would replace
somebody's config with nothing but Lacquer's block. Say so and stop.
"""
if not os.path.lexists(path):
return ""
text = read_capped(path)
if text is None:
raise SystemExit(
"%s cannot be read as text (too large, not UTF-8, or not a regular\n"
"file), so Lacquer will not write to it. Nothing was changed."
% path.replace(HOME, "~"))
return text
def colors():
"""The theme's palette, as #rrggbb strings."""
out = {}
for line in (read_capped(STATE) or "").splitlines():
m = re.match(r'^\s*([a-z0-9_]+)\s*=\s*"?(#?[0-9a-fA-F]{6})"?', line)
if m:
out.setdefault(m.group(1), "#" + HEX.match(m.group(2)).group(1).lower())
out.setdefault("background", "#1e1e2e")
out.setdefault("foreground", "#cdd6f4")
out.setdefault("accent", out["foreground"])
return out
def installed(command):
return subprocess.run(["bash", "-lc", "command -v " + command + " >/dev/null"],
capture_output=True, timeout=10).returncode == 0
def write_text(path, text):
# A file left holding nothing but whitespace is a file Lacquer created and
# then emptied; take it away rather than leaving litter behind. A symlink
# is never one Lacquer made — it is a dotfile manager's — so empty the file
# it points at and leave the link where the manager put it.
if text.strip() == "" and os.path.isfile(path) and not os.path.islink(path):
os.remove(path)
# And the folder it lived in, when nothing else is in it: switching a
# tool on can create ~/.config/bat, and switching it off again should
# not leave an empty one behind. Only a folder directly inside
# ~/.config is considered, so a home folder is never a candidate, and
# rmdir refuses anything that still has something in it.
folder = os.path.dirname(path)
if folder == GEN_DIR or os.path.dirname(folder) == os.path.join(HOME, ".config"):
try:
os.rmdir(folder)
except OSError:
pass
return
if text.strip() == "" and not os.path.islink(path):
return
# Write through a symlink, not over it: these configs are often links into
# a dotfile repository, and replacing the link would quietly stop the
# repository tracking the file.
target = os.path.realpath(path)
os.makedirs(os.path.dirname(target), exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".lacquer.", dir=os.path.dirname(target))
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
if os.path.exists(target):
os.chmod(tmp, os.stat(target).st_mode & 0o777)
os.replace(tmp, target)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def replace_block(text, body, comment="#"):
"""Put body between the fences, or take the block out when body is empty."""
begin, end = comment + " " + BEGIN, comment + " " + END
start = text.find(begin)
if start < 0:
if not body:
return text
head = text if text == "" or text.endswith("\n") else text + "\n"
return head + ("\n" if head else "") + begin + "\n" + body + "\n" + end + "\n"
stop = text.find(end, start)
after = "" if stop < 0 else text[stop + len(end):].lstrip("\n")
before = text[:start]
if not body:
return (before.rstrip("\n") + "\n" + after).lstrip("\n") if after else before.rstrip("\n") + "\n"
return before + begin + "\n" + body + "\n" + end + "\n" + (after if after else "")
# ------------------------------------------------------------------- tools
def bat_body(c):
return "--theme=ansi"
def delta_body(c):
# Added lines take the theme's green and removed lines its red, darkened so
# the text stays readable on top; a theme without either falls back to the
# accent rather than colouring both the same.
added = c.get("green", c.get("bright_green", c["accent"]))
removed = c.get("red", c.get("urgent", c.get("bright_red", c["accent"])))
return "\n".join([
"[delta]",
" syntax-theme = ansi",
" plus-style = syntax \"%s\"" % shade(added, 0.5),
" minus-style = normal \"%s\"" % shade(removed, 0.5),
" line-numbers-zero-style = \"%s\"" % c["foreground"],
" file-style = \"%s\"" % c["accent"],
])
def fzf_body(c):
opts = ",".join([
"bg+:-1", "bg:-1",
"fg:%s" % c["foreground"], "fg+:%s" % c["foreground"],
"hl:%s" % c["accent"], "hl+:%s" % c["accent"],
"info:%s" % c["accent"], "prompt:%s" % c["accent"],
"pointer:%s" % c["accent"], "marker:%s" % c["accent"],
"border:%s" % c["accent"], "header:%s" % c["foreground"],
])
return 'export FZF_DEFAULT_OPTS="${FZF_DEFAULT_OPTS:+$FZF_DEFAULT_OPTS }--color=%s"' % opts
def shade(hex_color, factor):
"""Darken towards black, so a diff background stays readable."""
m = HEX.match(hex_color)
if not m:
return hex_color
value = int(m.group(1), 16)
parts = [(value >> 16) & 255, (value >> 8) & 255, value & 255]
return "#" + "".join("%02x" % max(0, min(255, int(p * factor))) for p in parts)
TOOLS = {
"bat": {
"name": "File viewer (bat)",
"command": "bat",
"path": os.path.join(HOME, ".config/bat/config"),
"body": bat_body,
"what": "Shows files in the terminal in colour. It will follow your theme.",
},
"delta": {
"name": "Code changes (delta)",
"command": "delta",
"path": os.path.join(HOME, ".gitconfig"),
"body": delta_body,
"what": "Colours the before-and-after view when you compare code with git, using your theme's colours.",
},
"fzf": {
"name": "Quick search list (fzf)",
"command": "fzf",
"path": os.path.join(GEN_DIR, "fzf.sh"),
"body": fzf_body,
"what": "The pop-up search list some terminal commands use, in your theme's colours.",
"sourced": True,
},
}
BASHRC = os.path.join(HOME, ".bashrc")
SOURCE_LINE = '[ -r "$HOME/.config/lacquer/fzf.sh" ] && . "$HOME/.config/lacquer/fzf.sh"'
def is_on(tool):
spec = TOOLS[tool]
text = read_capped(spec["path"]) or ""
return BEGIN in text
def writable(path):
"""Whether this file could be written where it is."""
target = os.path.realpath(path)
if os.path.exists(target):
return os.access(target, os.W_OK) and os.access(os.path.dirname(target), os.W_OK)
folder = os.path.dirname(target)
while folder and not os.path.exists(folder):
folder = os.path.dirname(folder)
return bool(folder) and os.access(folder, os.W_OK)
def apply(tool, on):
spec = TOOLS[tool]
text = read_or_refuse(spec["path"])
rc = read_or_refuse(BASHRC) if spec.get("sourced") else None
# fzf is switched on by writing two files. Check both before writing
# either, so a refusal never leaves one written and the other not.
for path in [spec["path"]] + ([BASHRC] if spec.get("sourced") else []):
if not writable(path):
raise SystemExit("%s cannot be written. Nothing was changed."
% path.replace(HOME, "~"))
body = spec["body"](colors()) if on else ""
write_text(spec["path"], replace_block(text, body))
if spec.get("sourced"):
write_text(BASHRC, replace_block(rc, SOURCE_LINE if on else ""))
def scan():
out = []
for key in sorted(TOOLS):
spec = TOOLS[key]
# fzf is switched on by writing two files, so both have to be readable
# for the switch to be offered at all.
paths = [spec["path"]] + ([BASHRC] if spec.get("sourced") else [])
bad = [p for p in paths if os.path.lexists(p) and read_capped(p) is None]
out.append({"id": key, "name": spec["name"], "what": spec["what"],
"installed": installed(spec["command"]), "on": is_on(key),
"readable": not bad,
"blockedBy": bad[0].replace(HOME, "~") if bad else "",
"path": spec["path"].replace(HOME, "~")})
print(json.dumps({"tools": out}))
def main():
argv = sys.argv[1:]
if not argv:
sys.exit(__doc__)
if argv[0] == "scan":
scan()
elif argv[0] in ("on", "off") and len(argv) == 2 and argv[1] in TOOLS:
apply(argv[1], argv[0] == "on")
elif argv[0] == "reapply":
# The theme hook calls this. One tool whose config has become
# unreadable must not stop the others from following the new theme.
failed = []
for key in TOOLS:
if is_on(key):
try:
apply(key, True)
except SystemExit as why:
failed.append("%s: %s" % (key, why))
if failed:
sys.exit("\n".join(failed))
else:
sys.exit(__doc__)
def _refused(err):
"""A readable line for a write the filesystem would not allow.
A read-only ~/.config (home-manager and friends put one there), a file made
immutable, or a full disk all arrive here as an OSError. The panel shows
whatever comes back on stderr, and a Python traceback is no use to anybody.
"""
home = os.environ.get("HOME", "")
where = os.path.dirname(getattr(err, "filename", "") or "") or "that folder"
return ("Could not write in %s: %s. Lacquer stopped there.\n"
"A read-only folder, an immutable file or a full disk will do this."
% (where.replace(home, "~") if home else where, err.strerror or err))
if __name__ == "__main__":
try:
main()
except OSError as err:
sys.exit(_refused(err))