@@ -0,0 +1,1348 @@
1+"""Faithful float32 port of the Telegram-Android accent engine.
2+
3+Ported (Java/Skia float semantics, every intermediate rounded to IEEE binary32):
4+ * android.graphics.Color.colorToHSV / HSVToColor -> Skia SkRGBToHSV / SkHSVToColor
5+ * AndroidUtilities.computePerceivedBrightness, getColorDistance, getAverageColor
6+ * Theme.changeColorAccent(float[],float[],int,boolean,int), getAccentColor, useBlackText,
7+ changeBrightness, ThemeAccent.fillAccentColors (+ its post-processing helpers),
8+ applyCalculatedTableColors / applyCalculatedArticleCodeColors, getColor / getDefaultColor.
9+
10+Colours are Java signed 32-bit ints throughout (the Java code compares colour values as signed
11+ints in a few places, e.g. `currentColorsNoAccent.get(fallbackKey, -1) >= 0`).
12+"""
13+import math
14+import struct
15+
16+import tgsrc
17+from detmath_tables import SRGB8_TO_LINEAR as _SRGB8_TO_LINEAR
18+
19+# ----------------------------------------------------------------------------------------
20+# IEEE binary32 helpers
21+# ----------------------------------------------------------------------------------------
22+_F = struct.Struct("<f")
23+_pack, _unpack = _F.pack, _F.unpack
24+INF = math.inf
25+NAN = math.nan
26+
27+# Skia rounding: current Skia rounds via double (floor((double)x + 0.5)); older Skia used
28+# floorf(x + 0.5f). Select with set_round_mode().
29+ROUND_MODE = "double"
30+
31+
32+def f32(x):
33+ try:
34+ return _unpack(_pack(x))[0]
35+ except OverflowError:
36+ return math.copysign(INF, x)
37+
38+
39+def fdiv(a, b):
40+ """Java float division (IEEE: x/0 = +-inf, 0/0 = NaN)."""
41+ if b == 0.0:
42+ if a == 0.0 or a != a:
43+ return NAN
44+ return math.copysign(INF, a) * math.copysign(1.0, b)
45+ return f32(a / b)
46+
47+
48+def fmul(a, b):
49+ return f32(a * b)
50+
51+
52+def fadd(a, b):
53+ return f32(a + b)
54+
55+
56+def fsub(a, b):
57+ return f32(a - b)
58+
59+
60+def f2i(x):
61+ """Java (int) cast of a float/double."""
62+ if x != x:
63+ return 0
64+ if x >= 2147483647.0:
65+ return 2147483647
66+ if x <= -2147483648.0:
67+ return -2147483648
68+ return int(x)
69+
70+
71+def jmin(a, b):
72+ """java.lang.Math.min(float,float) (NaN-propagating)."""
73+ if a != a or b != b:
74+ return NAN
75+ return a if a <= b else b
76+
77+
78+def jmax(a, b):
79+ if a != a or b != b:
80+ return NAN
81+ return a if a >= b else b
82+
83+
84+def s32(v):
85+ v &= 0xFFFFFFFF
86+ return v - 0x100000000 if v & 0x80000000 else v
87+
88+
89+def alpha(c):
90+ return (c >> 24) & 0xFF
91+
92+
93+def red(c):
94+ return (c >> 16) & 0xFF
95+
96+
97+def green(c):
98+ return (c >> 8) & 0xFF
99+
100+
101+def blue(c):
102+ return c & 0xFF
103+
104+
105+def argb(a, r, g, b):
106+ return s32(((a & 0xFFFFFFFF) << 24) | ((r & 0xFFFFFFFF) << 16) | ((g & 0xFFFFFFFF) << 8) | (b & 0xFFFFFFFF))
107+
108+
109+def hexc(c):
110+ return "0x%08X" % (c & 0xFFFFFFFF)
111+
112+
113+# float32 constants as the Java compiler stores them
114+F0_2126, F0_7152, F0_0722 = f32(0.2126), f32(0.7152), f32(0.0722)
115+F0_705 = f32(0.705)
116+F0_3 = f32(0.3)
117+F0_6 = f32(0.6)
118+F1_5 = 1.5
119+F30 = 30.0
120+F360 = 360.0
121+F255 = 255.0
122+SK_NEARLY_ZERO = 1.0 / 4096.0
123+
124+
125+# ----------------------------------------------------------------------------------------
126+# Skia colour conversions (what Color.colorToHSV / HSVToColor call through JNI)
127+# ----------------------------------------------------------------------------------------
128+
129+def color_to_hsv(c):
130+ """SkRGBToHSV(r, g, b) with float (SkScalar) math. Returns (h, s, v)."""
131+ r = (c >> 16) & 0xFF
132+ g = (c >> 8) & 0xFF
133+ b = c & 0xFF
134+ mx = r if r > g else g
135+ if b > mx:
136+ mx = b
137+ mn = r if r < g else g
138+ if b < mn:
139+ mn = b
140+ delta = mx - mn
141+ v = f32(mx / 255.0)
142+ if delta == 0:
143+ return (0.0, 0.0, v)
144+ s = f32(delta / mx)
145+ if r == mx:
146+ h = f32((g - b) / delta)
147+ elif g == mx:
148+ h = f32(2.0 + f32((b - r) / delta))
149+ else:
150+ h = f32(4.0 + f32((r - g) / delta))
151+ h = f32(h * 60.0)
152+ if h < 0:
153+ h = f32(h + 360.0)
154+ return (h, s, v)
155+
156+
157+def _sk_pin01(x):
158+ # SkTPin(x, 0, 1) == std::max(0.f, std::min(x, 1.f)); NaN -> 0
159+ m = 1.0 if 1.0 < x else x
160+ return m if 0.0 < m else 0.0
161+
162+
163+def sk_round2int(x):
164+ if x != x:
165+ return 2147483520
166+ if ROUND_MODE == "double":
167+ r = math.floor(x + 0.5) # (float)floor((double)x + 0.5)
168+ else:
169+ r = math.floor(f32(x + 0.5)) # floorf(x + 0.5f)
170+ if r > 2147483520:
171+ r = 2147483520
172+ if r < -2147483520:
173+ r = -2147483520
174+ return int(r)
175+
176+
177+class NaNHue(ValueError):
178+ pass
179+
180+
181+def hsv_to_color(a, h, s, v):
182+ """SkHSVToColor(a, hsv). Hue outside [0, 360) is treated as 0 (red) -- no wrapping."""
183+ s = _sk_pin01(s)
184+ v = _sk_pin01(v)
185+ v_byte = sk_round2int(f32(v * 255.0))
186+ if abs(s) <= SK_NEARLY_ZERO:
187+ return argb(a, v_byte, v_byte, v_byte)
188+ if h != h:
189+ raise NaNHue("NaN hue reached SkHSVToColor (undefined behaviour in C++)")
190+ hx = 0.0 if (h < 0 or h >= 360.0) else f32(h / 60.0)
191+ w = math.floor(hx)
192+ f = f32(hx - w)
193+ p = sk_round2int(f32(f32(f32(1.0 - s) * v) * 255.0))
194+ q = sk_round2int(f32(f32(f32(1.0 - f32(s * f)) * v) * 255.0))
195+ t = sk_round2int(f32(f32(f32(1.0 - f32(s * f32(1.0 - f))) * v) * 255.0))
196+ wi = int(w)
197+ if wi == 0:
198+ r, g, b = v_byte, t, p
199+ elif wi == 1:
200+ r, g, b = q, v_byte, p
201+ elif wi == 2:
202+ r, g, b = p, v_byte, t
203+ elif wi == 3:
204+ r, g, b = p, q, v_byte
205+ elif wi == 4:
206+ r, g, b = t, p, v_byte
207+ else:
208+ r, g, b = v_byte, p, q
209+ return argb(a, r, g, b)
210+
211+
212+# ----------------------------------------------------------------------------------------
213+# AndroidUtilities
214+# ----------------------------------------------------------------------------------------
215+
216+def perceived_brightness(c):
217+ """AndroidUtilities.computePerceivedBrightness (gamma-encoded Rec.709 weights, float)."""
218+ r, g, b = (c >> 16) & 0xFF, (c >> 8) & 0xFF, c & 0xFF
219+ return f32(f32(f32(f32(r * F0_2126) + f32(g * F0_7152)) + f32(b * F0_0722)) / 255.0)
220+
221+
222+def color_distance(c1, c2):
223+ r1, g1, b1 = red(c1), green(c1), blue(c1)
224+ r2, g2, b2 = red(c2), green(c2), blue(c2)
225+ r_mean = (r1 + r2) // 2 # both non-negative: Java int division == floor
226+ r = r1 - r2
227+ g = g1 - g2
228+ b = b1 - b2
229+ return (((512 + r_mean) * r * r) >> 8) + (4 * g * g) + (((767 - r_mean) * b * b) >> 8)
230+
231+
232+def average_color(c1, c2):
233+ return argb(255, red(c1) // 2 + red(c2) // 2, green(c1) // 2 + green(c2) // 2, blue(c1) // 2 + blue(c2) // 2)
234+
235+
236+def lerp_f(a, b, f):
237+ return f32(a + f32(f * f32(b - a)))
238+
239+
240+def lerp_i(a, b, f):
241+ return f2i(f32(a + f32(f * (b - a))))
242+
243+
244+def mathutils_clamp(v, lo, hi):
245+ if v < lo:
246+ return lo
247+ if v > hi:
248+ return hi
249+ return v
250+
251+
252+def utilities_clamp(v, mx, mn):
253+ """Utilities.clamp(float value, float maxValue, float minValue)."""
254+ if v != v:
255+ return mn
256+ if v in (INF, -INF):
257+ return mx
258+ return jmax(jmin(v, mx), mn)
259+
260+
261+def set_alpha_component(c, a):
262+ if a < 0 or a > 255:
263+ raise ValueError("alpha must be between 0 and 255.")
264+ return s32((c & 0x00FFFFFF) | (a << 24))
265+
266+
267+def blend_argb(c1, c2, ratio):
268+ inv = f32(1.0 - ratio)
269+ a = f32(f32(alpha(c1) * inv) + f32(alpha(c2) * ratio))
270+ r = f32(f32(red(c1) * inv) + f32(red(c2) * ratio))
271+ g = f32(f32(green(c1) * inv) + f32(green(c2) * ratio))
272+ b = f32(f32(blue(c1) * inv) + f32(blue(c2) * ratio))
273+ return argb(f2i(a), f2i(r), f2i(g), f2i(b))
274+
275+
276+# ----------------------------------------------------------------------------------------
277+# Theme.java statics
278+# ----------------------------------------------------------------------------------------
279+
280+def change_brightness(c, amount):
281+ r = f2i(f32(red(c) * amount))
282+ g = f2i(f32(green(c) * amount))
283+ b = f2i(f32(blue(c) * amount))
284+ r = 0 if r < 0 else min(r, 255)
285+ g = 0 if g < 0 else min(g, 255)
286+ b = 0 if b < 0 else min(b, 255)
287+ return argb(alpha(c), r, g, b)
288+
289+
290+def change_color_accent(base_hsv, accent_hsv, color, is_dark, fallback, trace=None):
291+ """Theme.changeColorAccent(float[] baseHsv, float[] accentHsv, int color, boolean isDarkTheme, int fallback)."""
292+ h, s, v = color_to_hsv(color)
293+ bh, bs, bv = base_hsv
294+ ah, as_, av = accent_hsv
295+ d = f32(h - bh)
296+ diff_h = jmin(abs(d), abs(f32(d - 360.0)))
297+ if diff_h > 30.0:
298+ return fallback
299+ dist = jmin(fdiv(f32(1.5 * s), bs), 1.0)
300+ nh = f32(f32(h + ah) - bh)
301+ ns = fdiv(f32(s * as_), bs)
302+ nv = f32(v * f32(f32(1.0 - dist) + fdiv(f32(dist * av), bv)))
303+ new = hsv_to_color(alpha(color), nh, ns, nv)
304+ orig_b = perceived_brightness(color)
305+ new_b = perceived_brightness(new)
306+ need_revert = (orig_b > new_b) if is_dark else (orig_b < new_b)
307+ if trace is not None:
308+ trace.update(h=h, s=s, v=v, nh=nh, ns=ns, nv=nv, hue_wrapped_to_red=(nh < 0 or nh >= 360.0) and ns > SK_NEARLY_ZERO,
309+ pre_revert=new, revert=need_revert)
310+ if need_revert:
311+ fallback_amount = f32(fdiv(f32(f32(1.0 - F0_6) * orig_b), new_b) + F0_6)
312+ new = change_brightness(new, fallback_amount)
313+ return new
314+
315+
316+def get_accent_color(base_hsv, base_color, element_color):
317+ """Theme.getAccentColor(float[] baseHsv, int baseColor, int elementColor)."""
318+ h3, s3, v3 = color_to_hsv(base_color)
319+ h4, s4, v4 = color_to_hsv(element_color)
320+ bh, bs, bv = base_hsv
321+ dist = jmin(fdiv(f32(1.5 * s3), bs), 1.0)
322+ nh = f32(f32(h4 - h3) + bh)
323+ ns = fdiv(f32(s4 * bs), s3)
324+ nv = fdiv(f32(f32(f32(fdiv(v4, v3) + dist) - 1.0) * bv), dist)
325+ if nv < F0_3:
326+ return element_color
327+ return hsv_to_color(255, nh, ns, nv)
328+
329+
330+def use_black_text(c1, c2):
331+ r1, r2 = f32(red(c1) / 255.0), f32(red(c2) / 255.0)
332+ g1, g2 = f32(green(c1) / 255.0), f32(green(c2) / 255.0)
333+ b1, b2 = f32(blue(c1) / 255.0), f32(blue(c2) / 255.0)
334+ r = f32(f32(r1 * 0.5) + f32(r2 * 0.5))
335+ g = f32(f32(g1 * 0.5) + f32(g2 * 0.5))
336+ b = f32(f32(b1 * 0.5) + f32(b2 * 0.5))
337+ lightness = f32(f32(f32(F0_2126 * r) + f32(F0_7152 * g)) + f32(F0_0722 * b))
338+ lightness2 = f32(f32(f32(F0_2126 * r1) + f32(F0_7152 * g1)) + f32(F0_0722 * b1))
339+ return lightness > F0_705 or lightness2 > F0_705
340+
341+
342+def mult_alpha(c, m):
343+ if m == 1.0:
344+ return c
345+ return set_alpha_component(c, mathutils_clamp(f2i(f32(alpha(c) * m)), 0, 0xFF))
346+
347+
348+def blend_over(A, B):
349+ aB = f32(alpha(B) / 255.0)
350+ aA = f32(alpha(A) / 255.0)
351+ one_m = f32(1.0 - aB)
352+ aC = f32(aB + f32(aA * one_m))
353+ if aC == 0.0:
354+ return 0
355+
356+ def ch(cb, ca):
357+ return f2i(fdiv(f32(f32(cb * aB) + f32(f32(ca * aA) * one_m)), aC))
358+ return argb(f2i(f32(aC * 255.0)), ch(red(B), red(A)), ch(green(B), green(A)), ch(blue(B), blue(A)))
359+
360+
361+def adapt_hue(color, hue_from):
362+ h, s, _ = color_to_hsv(hue_from)
363+ _, cs, cv = color_to_hsv(color)
364+ return hsv_to_color(alpha(color), h, lerp_f(cs, s, f32(0.25)), cv)
365+
366+
367+# ----------------------------------------------------------------------------------------
368+# Parsed model
369+# ----------------------------------------------------------------------------------------
370+
371+class Model:
372+ def __init__(self, parsed, spec=None):
373+ tj, tc = parsed["theme_java"], parsed["themecolors_java"]
374+ self.parsed = parsed
375+ self.spec = spec # canvas-fix overlay (None = stock Telegram)
376+ self.tag = spec["tag"] if spec else "stock"
377+ self.key_names = tj["keys"]
378+ self.K = {n: i for i, n in enumerate(self.key_names)}
379+ self.n = len(self.key_names)
380+ m = tj["markers"]
381+ self.bub_start = m["myMessagesBubblesStartIndex"]["value"]
382+ self.bub_end = m["myMessagesBubblesEndIndex"]["value"]
383+ self.my_start = m["myMessagesStartIndex"]["value"]
384+ self.my_end = m["myMessagesEndIndex"]["value"]
385+ self.my2_start = m["myMessages2StartIndex"]["value"]
386+ self.my2_end = m["myMessages2EndIndex"]["value"]
387+ self.defaults = [0] * self.n
388+ for k, v in tc["defaults"].items():
389+ self.defaults[self.K[k]] = v
390+ self.fallback = {}
391+ for a, b, _ in tj["fallbackKeys"]:
392+ self.fallback[self.K[a]] = self.K[b]
393+ self.exclusions = set(self.K[k] for k, _, _ in tj["themeAccentExclusionKeys"])
394+ self.extra_keys = [self.K[k] for k in tj["myMessagesAccentExtraKeys"]["keys"]]
395+ self.DEFAULT_ACCENT_ID = tj["DEFALT_THEME_ACCENT_ID"]["value"]
396+ self.MSG_OUT_COLOR_BLACK = tj["MSG_OUT_COLOR_BLACK"]["value"]
397+ self.MSG_OUT_COLOR_WHITE = tj["MSG_OUT_COLOR_WHITE"]["value"]
398+ self.name_of_key = {self.K[k]: nm for k, nm, _ in tc["colorKeysMap"]}
399+ oc = tj["out_text_consts"]
400+ self.out_consts = {}
401+ for which in ("black", "white"):
402+ txt, sub, seek = oc[which]
403+ self.out_consts[which] = (tj[txt]["value"], sub, seek)
404+ # MultiGram: rule (d) already in the parsed source (the out-text block guard widened with
405+ # `|| PaletteFix.isRuntimeAccent(this)` or `|| id > 100`, see tgsrc.OUT_BLOCK_GUARD_RE)
406+ self.src_out_block_custom = bool(tj.get("out_block_guard_id_gt_100", False))
407+ self.out_block_accent2_zero = [(self.K[k], v) for k, v in tj["out_block_accent2_zero"]]
408+ self.out_block_always = [(self.K[k], v) for k, v in tj["out_block_always"]]
409+ if tj["out_block_unparsed"]:
410+ raise ValueError("unparsed puts in fillAccentColors out-text block: %r" % tj["out_block_unparsed"])
411+ self.themes = {}
412+ for t in tj["themes"]:
413+ th = ThemeInfo(self, t, parsed["atthemes"][t["assetName"]])
414+ self.themes[th.name] = th
415+ self.hook = None
416+ if spec:
417+ self._apply_spec(spec)
418+
419+ def _apply_spec(self, spec):
420+ """canvas-fix overlay, applied exactly where the app would carry it:
421+ spec['defaults'] -> ThemeColors.createDefaultColors() values
422+ spec['attheme'] -> lines added/changed in the bundled .attheme files (currentColorsNoAccent)
423+ spec['exclusions'] -> keys added to Theme.themeAccentExclusionKeys
424+ spec['hook'] -> the on-accent content rule run at the end of refreshThemeColors()"""
425+ K = self.K
426+ for k, v in spec.get("defaults", {}).items():
427+ self.defaults[K[k]] = s32(v)
428+ for k in spec.get("exclusions", []):
429+ self.exclusions.add(K[k])
430+ by_asset = {th.assetName: th for th in self.themes.values()}
431+ for asset, kv in spec.get("attheme", {}).items():
432+ th = by_asset[asset]
433+ for k, v in kv.items():
434+ th.no_accent[K[k]] = s32(v)
435+ hk = spec.get("hook")
436+ if hk:
437+ mu = hk.get("muted") # None only in the '-no-c' ablation (rule (c) off)
438+ self.hook = {
439+ "near_black": s32(hk["near_black"]),
440+ "pairs": [(K[a], K[b]) for a, b in hk["pairs"]],
441+ "content": {K[a]: K[b] for a, b in hk["pairs"]},
442+ "muted": K[mu["key"]] if mu else -1, "muted_text": K[mu["text"]] if mu else -1,
443+ "muted_pins": [K[x] for x in mu["pins"]] if mu else [],
444+ "muted_table": {tn: (s32(w), s32(b)) for tn, (w, b) in mu["table"].items()} if mu else {},
445+ # (d) Theme.java:677 `if (!isMyMessagesGradientColorsNear)` -> `... || id > 100`: runtime-created
446+ # accents (ids from ++lastAccentId, first 101) always get the black/white out-text block
447+ "out_block_custom": bool(hk.get("out_block_custom", False)),
448+ }
449+
450+ def key(self, name):
451+ if not name.startswith("key_"):
452+ name = "key_" + name
453+ return self.K[name]
454+
455+ def get_default_color(self, key):
456+ """Theme.getDefaultColor(int key)."""
457+ value = self.defaults[key]
458+ if value == 0:
459+ fb = self.fallback.get(key, -1)
460+ if fb != -1:
461+ return self.get_default_color(fb)
462+ K = self.K
463+ if (self.bub_start <= key < self.bub_end or key in (K["key_chats_menuTopShadow"], K["key_chats_menuTopBackground"],
464+ K["key_chats_menuTopShadowCats"], K["key_chat_wallpaper_gradient_to2"], K["key_chat_wallpaper_gradient_to3"])):
465+ return 0
466+ return s32(0xFFFF0000)
467+ return value
468+
469+
470+class Accent:
471+ __slots__ = ("id", "accentColor", "accentColor2", "myMessagesAccentColor", "myMessagesGradientAccentColor1",
472+ "myMessagesGradientAccentColor2", "myMessagesGradientAccentColor3", "myMessagesAnimated",
473+ "backgroundOverrideColor", "backgroundGradientOverrideColor1", "backgroundGradientOverrideColor2",
474+ "backgroundGradientOverrideColor3", "backgroundRotation", "patternSlug", "patternIntensity",
475+ "patternMotion", "info", "isDefault")
476+
477+ def __init__(self, **kw):
478+ self.id = 0
479+ self.accentColor = 0
480+ self.accentColor2 = 0
481+ self.myMessagesAccentColor = 0
482+ self.myMessagesGradientAccentColor1 = 0
483+ self.myMessagesGradientAccentColor2 = 0
484+ self.myMessagesGradientAccentColor3 = 0
485+ self.myMessagesAnimated = False
486+ self.backgroundOverrideColor = 0 # Java long
487+ self.backgroundGradientOverrideColor1 = 0
488+ self.backgroundGradientOverrideColor2 = 0
489+ self.backgroundGradientOverrideColor3 = 0
490+ self.backgroundRotation = 45
491+ self.patternSlug = ""
492+ self.patternIntensity = 0.0
493+ self.patternMotion = False
494+ self.info = None
495+ self.isDefault = False
496+ for k, v in kw.items():
497+ setattr(self, k, v)
498+
499+ def copy(self, **kw):
500+ a = Accent(**{k: getattr(self, k) for k in self.__slots__})
501+ for k, v in kw.items():
502+ setattr(a, k, v)
503+ return a
504+
505+
506+def long_to_int(v):
507+ return s32(v)
508+
509+
510+class ThemeInfo:
511+ def __init__(self, model, t, at):
512+ self.model = model
513+ self.name = t["name"]
514+ self.assetName = t["assetName"]
515+ self.firstAccentIsDefault = t["firstAccentIsDefault"]
516+ tj = model.parsed["theme_java"]
517+ self.is_dark = self.name in ("Dark Blue", "Night") # ThemeInfo.isDark() name table
518+ self.no_accent = {}
519+ for kname, (val, _ln) in at["values"].items():
520+ self.no_accent[model.K[kname]] = val
521+ o = t["options"]
522+ self.accents = {}
523+ self.accent_order = []
524+ home = tj["isHome"]
525+ for a in range(len(o["accent"])):
526+ acc = Accent()
527+ acc.id = o["ids"][a] if o["ids"] is not None else a
528+ acc.isDefault = home.get(self.name) == acc.id
529+ acc.accentColor = o["accent"][a]
530+ if o["myMessages"] is not None:
531+ acc.myMessagesAccentColor = o["myMessages"][a]
532+ if o["myMessagesGradient"] is not None:
533+ acc.myMessagesGradientAccentColor1 = o["myMessagesGradient"][a]
534+ first_default = self.firstAccentIsDefault and acc.id == model.DEFAULT_ACCENT_ID
535+ if o["background"] is not None:
536+ acc.backgroundOverrideColor = 0x100000000 if first_default else o["background"][a]
537+ if o["backgroundGradient1"] is not None:
538+ acc.backgroundGradientOverrideColor1 = 0x100000000 if first_default else o["backgroundGradient1"][a]
539+ if o["backgroundGradient2"] is not None:
540+ acc.backgroundGradientOverrideColor2 = 0x100000000 if first_default else o["backgroundGradient2"][a]
541+ if o["backgroundGradient3"] is not None:
542+ acc.backgroundGradientOverrideColor3 = 0x100000000 if first_default else o["backgroundGradient3"][a]
543+ if o["patternSlugs"] is not None:
544+ acc.patternIntensity = f32(o["patternIntensities"][a] / 100.0)
545+ acc.backgroundRotation = o["patternRotations"][a]
546+ acc.patternSlug = o["patternSlugs"][a]
547+ # "override default themes": (isHome(accent) && name.equals("Dark Blue")) || name.equals("Night")
548+ if (acc.isDefault and self.name == "Dark Blue") or self.name == "Night":
549+ for k, v in tj["override_all"].items():
550+ setattr(acc, k, v)
551+ if self.name == "Night":
552+ for k, v in tj["override_night"].items():
553+ setattr(acc, k, f32(v) if isinstance(v, float) else v)
554+ self.accents[acc.id] = acc
555+ self.accent_order.append(acc.id)
556+ self.accentBaseColor = self.accents[tj["accentBaseColor_id"]].accentColor
557+ self.default_accent_id = model.DEFAULT_ACCENT_ID if self.firstAccentIsDefault else tj["fresh_install_accent_id_non_default"]
558+
559+ def stock_accent(self):
560+ return self.accents[self.default_accent_id]
561+
562+ # ThemeInfo.isDefaultMyMessagesBubbles / isDefaultMyMessages / isDefaultMainAccent
563+ def _default_accent_pair(self, accent, current_id):
564+ if not self.firstAccentIsDefault:
565+ return None, None, False
566+ if current_id == self.model.DEFAULT_ACCENT_ID:
567+ return None, None, True
568+ return self.accents.get(self.model.DEFAULT_ACCENT_ID), accent, None
569+
570+ def is_default_my_messages_bubbles(self, accent, current_id):
571+ d, a, short = self._default_accent_pair(accent, current_id)
572+ if short is not None:
573+ return short
574+ if d is None or a is None:
575+ return False
576+ return (d.myMessagesAccentColor == a.myMessagesAccentColor and d.myMessagesGradientAccentColor1 == a.myMessagesGradientAccentColor1
577+ and d.myMessagesGradientAccentColor2 == a.myMessagesGradientAccentColor2 and d.myMessagesGradientAccentColor3 == a.myMessagesGradientAccentColor3
578+ and d.myMessagesAnimated == a.myMessagesAnimated)
579+
580+ def is_default_my_messages(self, accent, current_id):
581+ d, a, short = self._default_accent_pair(accent, current_id)
582+ if short is not None:
583+ return short
584+ if d is None or a is None:
585+ return False
586+ return (d.accentColor2 == a.accentColor2 and d.myMessagesAccentColor == a.myMessagesAccentColor
587+ and d.myMessagesGradientAccentColor1 == a.myMessagesGradientAccentColor1 and d.myMessagesGradientAccentColor2 == a.myMessagesGradientAccentColor2
588+ and d.myMessagesGradientAccentColor3 == a.myMessagesGradientAccentColor3 and d.myMessagesAnimated == a.myMessagesAnimated)
589+
590+ def is_default_main_accent(self, accent, current_id):
591+ d, a, short = self._default_accent_pair(accent, current_id)
592+ if short is not None:
593+ return short
594+ return a is not None and d is not None and d.accentColor == a.accentColor
595+
596+
597+# ----------------------------------------------------------------------------------------
598+# ThemeAccent.fillAccentColors - literal port over full maps
599+# ----------------------------------------------------------------------------------------
600+
601+class Palette:
602+ """The result of Theme.refreshThemeColors() for (theme, accent): the currentColors map plus the
603+ getColor() resolution rules that sit on top of it."""
604+
605+ def __init__(self, theme, accent, current_id=None, colors=None, should_draw_gradient_icons=True):
606+ self.theme = theme
607+ self.model = theme.model
608+ self.accent = accent
609+ self.current_id = accent.id if current_id is None else current_id
610+ self.colors = colors
611+ self.should_draw_gradient_icons = should_draw_gradient_icons
612+ # Theme.serviceMessageColor / serviceSelectedMessageColor: computed from the wallpaper at
613+ # runtime (calcDrawableColor); 0 until a wallpaper is loaded.
614+ self.service_message_color = 0
615+ self.service_selected_message_color = 0
616+
617+ def get(self, key):
618+ """Theme.getColor(key) (no animation, serviceBitmapShader handled separately)."""
619+ m, th, K = self.model, self.theme, self.model.K
620+ if isinstance(key, str):
621+ key = m.key(key)
622+ if th.name == "Blue": # currentTheme == defaultTheme
623+ if m.bub_start <= key < m.bub_end:
624+ use_default = th.is_default_my_messages_bubbles(self.accent, self.current_id)
625+ elif m.my_start <= key < m.my_end:
626+ use_default = th.is_default_my_messages(self.accent, self.current_id)
627+ elif key in (K["key_chat_wallpaper"], K["key_chat_wallpaper_gradient_to1"], K["key_chat_wallpaper_gradient_to2"], K["key_chat_wallpaper_gradient_to3"]):
628+ use_default = False
629+ else:
630+ use_default = th.is_default_main_accent(self.accent, self.current_id)
631+ if use_default:
632+ if key == K["key_chat_serviceBackground"]:
633+ return self.service_message_color
634+ if key == K["key_chat_serviceBackgroundSelected"]:
635+ return self.service_selected_message_color
636+ return m.get_default_color(key)
637+ cur = self.colors
638+ if key not in cur:
639+ fb = m.fallback.get(key, -1)
640+ if fb != -1 and fb in cur:
641+ return cur[fb]
642+ if key == K["key_chat_serviceBackground"]:
643+ return self.service_message_color
644+ if key == K["key_chat_serviceBackgroundSelected"]:
645+ return self.service_selected_message_color
646+ return m.get_default_color(key)
647+ color = cur[key]
648+ if key in (K["key_windowBackgroundWhite"], K["key_windowBackgroundGray"], K["key_actionBarDefault"], K["key_actionBarDefaultArchived"]):
649+ color = s32(color | 0xFF000000)
650+ return color
651+
652+
653+def _force_out_block(m, acc):
654+ """canvas-fix (d): the black/white out-text block also runs for near-theme bubbles of runtime accents,
655+ whether the guard comes from the overlay spec or is already in the parsed Theme.java."""
656+ if acc.id <= 100:
657+ return False
658+ return m.src_out_block_custom or (m.hook is not None and m.hook.get("out_block_custom", False))
659+
660+
661+def fill_accent_colors(theme, acc, no_accent, cur, current_id=None):
662+ """Literal port of ThemeAccent.fillAccentColors(currentColorsNoAccent, currentColors).
663+ `cur` is mutated. Returns !isMyMessagesGradientColorsNear."""
664+ m = theme.model
665+ K = m.K
666+ defaults = m.defaults
667+ fallback = m.fallback
668+ is_near = False
669+ hsv1 = color_to_hsv(theme.accentBaseColor)
670+ hsv2 = color_to_hsv(acc.accentColor)
671+ is_dark = theme.is_dark
672+ if acc.accentColor != theme.accentBaseColor or acc.accentColor2 != 0:
673+ excl = m.exclusions
674+ for key in range(m.n):
675+ if key in excl:
676+ continue
677+ if key not in no_accent:
678+ fb = fallback.get(key, -1)
679+ if fb >= 0 and fb in no_accent:
680+ continue
681+ color = defaults[key]
682+ else:
683+ color = no_accent[key]
684+ new = change_color_accent(hsv1, hsv2, color, is_dark, color)
685+ if new != color:
686+ cur[key] = new
687+ my_acc = acc.myMessagesAccentColor
688+ g1, g2, g3 = acc.myMessagesGradientAccentColor1, acc.myMessagesGradientAccentColor2, acc.myMessagesGradientAccentColor3
689+ if (acc.myMessagesAccentColor != 0 or acc.accentColor != 0) and g1 != 0:
690+ first = acc.myMessagesAccentColor if acc.myMessagesAccentColor != 0 else acc.accentColor
691+ color = no_accent.get(K["key_chat_outBubble"], 0)
692+ if color == 0:
693+ color = defaults[K["key_chat_outBubble"]]
694+ new = change_color_accent(hsv1, hsv2, color, is_dark, color)
695+ d1 = color_distance(first, new)
696+ d2 = color_distance(first, g1)
697+ if g2 != 0:
698+ avg = average_color(acc.myMessagesAccentColor, g1)
699+ avg = average_color(avg, g2)
700+ if g3 != 0:
701+ avg = average_color(avg, g3)
702+ ubt = perceived_brightness(avg) > F0_705
703+ else:
704+ ubt = use_black_text(acc.myMessagesAccentColor, g1)
705+ is_near = (d1 <= 35000 and d2 <= 35000) if ubt else False
706+ my_acc = get_accent_color(hsv1, color, first)
707+ change_my = (my_acc != 0 and ((theme.accentBaseColor != 0 and my_acc != theme.accentBaseColor) or (acc.accentColor != 0 and acc.accentColor != my_acc)))
708+ if change_my or acc.accentColor2 != 0:
709+ hsv2 = color_to_hsv(acc.accentColor2 if acc.accentColor2 != 0 else my_acc)
710+ for key in range(m.my_start, m.my_end):
711+ if key not in no_accent:
712+ fb = fallback.get(key, -1)
713+ if fb >= 0 and no_accent.get(fb, -1) >= 0: # value test, as in Java
714+ continue
715+ color = defaults[key]
716+ else:
717+ color = no_accent[key]
718+ new = change_color_accent(hsv1, hsv2, color, is_dark, color)
719+ if new != color:
720+ cur[key] = new
721+ for key in m.extra_keys:
722+ color = defaults[key] if key not in no_accent else no_accent[key]
723+ new = change_color_accent(hsv1, hsv2, color, is_dark, color)
724+ if new != color:
725+ cur[key] = new
726+ if change_my:
727+ hsv2 = color_to_hsv(my_acc)
728+ for key in range(m.bub_start, m.bub_end):
729+ if key not in no_accent:
730+ fb = fallback.get(key, -1)
731+ if fb >= 0 and no_accent.get(fb, -1) >= 0:
732+ continue
733+ color = defaults[key]
734+ else:
735+ color = no_accent[key]
736+ new = change_color_accent(hsv1, hsv2, color, is_dark, color)
737+ if new != color:
738+ cur[key] = new
739+ if not is_near or _force_out_block(m, acc):
740+ if g1 != 0:
741+ if g2 != 0:
742+ c = average_color(acc.myMessagesAccentColor, g1)
743+ c = average_color(c, g2)
744+ if g3 != 0:
745+ c = average_color(c, g3)
746+ ubt = perceived_brightness(c) > F0_705
747+ else:
748+ ubt = use_black_text(acc.myMessagesAccentColor, g1)
749+ text, sub, seek = m.out_consts["black" if ubt else "white"]
750+ vals = {"textColor": text, "subTextColor": sub, "seekbarColor": seek,
751+ "myMessagesAccentColor": acc.myMessagesAccentColor}
752+ if acc.accentColor2 == 0:
753+ for k, var in m.out_block_accent2_zero:
754+ cur[k] = vals[var]
755+ for k, var in m.out_block_always:
756+ cur[k] = vals[var]
757+ if is_near:
758+ out_color = cur[K["key_chat_outLoader"]] if K["key_chat_outLoader"] in cur else 0
759+ if color_distance(s32(0xffffffff), out_color) < 5000:
760+ is_near = False
761+ if acc.myMessagesAccentColor != 0 and g1 != 0:
762+ cur[K["key_chat_outBubble"]] = acc.myMessagesAccentColor
763+ cur[K["key_chat_outBubbleGradient1"]] = g1
764+ if g2 != 0:
765+ cur[K["key_chat_outBubbleGradient2"]] = g2
766+ if g3 != 0:
767+ cur[K["key_chat_outBubbleGradient3"]] = g3
768+ cur[K["key_chat_outBubbleGradientAnimated"]] = 1 if acc.myMessagesAnimated else 0
769+ for fld, kname in (("backgroundOverrideColor", "key_chat_wallpaper"),
770+ ("backgroundGradientOverrideColor1", "key_chat_wallpaper_gradient_to1"),
771+ ("backgroundGradientOverrideColor2", "key_chat_wallpaper_gradient_to2"),
772+ ("backgroundGradientOverrideColor3", "key_chat_wallpaper_gradient_to3")):
773+ lv = getattr(acc, fld)
774+ iv = long_to_int(lv)
775+ if iv != 0:
776+ cur[K[kname]] = iv
777+ elif lv != 0:
778+ cur.pop(K[kname], None)
779+ if acc.backgroundRotation != 45:
780+ cur[K["key_chat_wallpaper_gradient_rotation"]] = acc.backgroundRotation
781+
782+ pal = Palette(theme, acc, current_id, cur) # getColor() during fill reads the same map
783+ out_bubble = cur.get(K["key_chat_outBubble"], 0)
784+ if out_bubble == 0:
785+ out_bubble = pal.get(K["key_chat_outBubble"])
786+ in_bubble = cur.get(K["key_chat_inBubble"], 0)
787+ if in_bubble == 0:
788+ in_bubble = pal.get(K["key_chat_inBubble"])
789+ # (info != null && info.emoticon != null) branch: cloud chat themes only; generated accents have info == null.
790+ if not is_dark:
791+ cur[K["key_chat_inTextSelectionHighlight"]] = _text_selection_background(False, in_bubble, acc.accentColor)
792+ cur[K["key_chat_outTextSelectionHighlight"]] = _text_selection_background(True, out_bubble, acc.accentColor)
793+ cur[K["key_chat_outTextSelectionCursor"]] = _text_selection_handle(out_bubble, acc.accentColor)
794+ accent_hue = color_to_hsv(pal.get(K["key_windowBackgroundWhiteBlueText"]))[0]
795+ cur[K["key_chat_outBubbleLocationPlaceholder"]] = _location_placeholder(accent_hue, out_bubble, is_dark)
796+ cur[K["key_chat_inBubbleLocationPlaceholder"]] = _location_placeholder(accent_hue, in_bubble, is_dark)
797+ in_link = cur.get(K["key_chat_messageLinkIn"], 0)
798+ if in_link == 0:
799+ in_link = pal.get(K["key_chat_messageLinkIn"])
800+ out_link = cur.get(K["key_chat_messageLinkOut"], 0)
801+ if out_link == 0:
802+ out_link = pal.get(K["key_chat_messageLinkOut"])
803+ cur[K["key_chat_linkSelectBackground"]] = _link_selection_background(in_link, in_bubble, is_dark)
804+ cur[K["key_chat_outLinkSelectBackground"]] = _link_selection_background(out_link, out_bubble, is_dark)
805+ sb = cur.get(K["key_actionBarDefaultSubmenuBackground"], 0)
806+ if sb == 0:
807+ sb = pal.get(K["key_actionBarDefaultSubmenuBackground"])
808+ cur[K["key_actionBarDefaultSubmenuSeparator"]] = argb(alpha(sb), max(0, red(sb) - 10), max(0, green(sb) - 10), max(0, blue(sb) - 10))
809+ if is_dark and cur.get(K["key_chat_outBubbleGradient1"], 0) != 0:
810+ avg = _average_color_keys(cur, [K["key_chat_outBubbleGradient1"], K["key_chat_outBubbleGradient2"], K["key_chat_outBubbleGradient3"]])
811+ h, s, v = color_to_hsv(avg)
812+ s = utilities_clamp(f32(s + f32(0.1)), 1.0, 0.0)
813+ v = utilities_clamp(f32(v - f32(0.8)), 1.0, 0.0)
814+ cur[K["key_chat_outCodeBackground"]] = hsv_to_color(0x40, h, s, v)
815+ else:
816+ cur[K["key_chat_outCodeBackground"]] = _code_background(out_bubble, is_dark)
817+ apply_calculated_table_colors(m, no_accent, cur, is_dark)
818+ apply_calculated_article_code_colors(m, no_accent, cur, is_dark)
819+ return not is_near
820+
821+
822+def _text_selection_background(is_out, bubble, accent_color):
823+ h_acc = color_to_hsv(accent_color)[0]
824+ h, s, v = color_to_hsv(bubble)
825+ if s <= 0 or (h > 45 and h < 85):
826+ h = h_acc
827+ s = jmax(0.0, jmin(1.0, f32(s + (f32(0.25) if v > f32(0.85) else f32(0.45)))))
828+ v = jmax(0.0, jmin(1.0, f32(v - f32(0.15))))
829+ return hsv_to_color(80, h, s, v)
830+
831+
832+def _text_selection_handle(bubble, accent_color):
833+ h_acc = color_to_hsv(accent_color)[0]
834+ h, s, v = color_to_hsv(bubble)
835+ if s <= 0 or (h > 45 and h < 85):
836+ h = h_acc
837+ s = jmax(0.0, jmin(1.0, f32(s + F0_6)))
838+ v = jmax(0.0, jmin(1.0, f32(v - (f32(0.25) if v > f32(0.7) else f32(0.125)))))
839+ return blend_over(bubble, hsv_to_color(255, h, s, v))
840+
841+
842+def _link_selection_background(link, bg, is_dark):
843+ h, s, v = color_to_hsv(blend_argb(link, bg, f32(0.25)))
844+ s = jmax(0.0, jmin(1.0, f32(s - f32(0.1))))
845+ v = jmax(0.0, jmin(1.0, f32(v + (f32(0.1) if is_dark else 0.0))))
846+ return hsv_to_color(0x33, h, s, v)
847+
848+
849+def _code_background(bubble, is_dark):
850+ h, s, v = color_to_hsv(bubble)
851+ a = 0x20
852+ if is_dark:
853+ a = 0x40
854+ s = utilities_clamp(f32(s - f32(0.08)), 1.0, 0.0)
855+ v = f32(0.03)
856+ else:
857+ if s <= 0 or v >= 1 or v <= 0:
858+ v = jmax(0.0, jmin(1.0, f32(v + f32(-0.2))))
859+ else:
860+ s = jmax(0.0, jmin(1.0, f32(s + f32(0.28))))
861+ v = jmax(0.0, jmin(1.0, f32(v + f32(-0.1))))
862+ return hsv_to_color(a, h, s, v)
863+
864+
865+def _location_placeholder(accent_hue, bubble, is_dark):
866+ if is_dark:
867+ return s32(0x1effffff)
868+ h, s, v = color_to_hsv(bubble)
869+ if s <= 0 or v >= 1 or v <= 0:
870+ h = accent_hue
871+ s = f32(0.2)
872+ else:
873+ h = mathutils_clamp(f32(h + f32(0.22)), 0.0, 1.0)
874+ s = mathutils_clamp(f32(s - f32(0.35)), 0.0, 1.0)
875+ v = mathutils_clamp(f32(v - f32(0.65)), 0.0, 1.0)
876+ return hsv_to_color(0x5a, h, s, v)
877+
878+
879+def _average_color_keys(colors, keys):
880+ r = g = b = c = 0
881+ for k in keys:
882+ if k not in colors:
883+ continue
884+ col = colors[k]
885+ r += red(col)
886+ g += green(col)
887+ b += blue(col)
888+ c += 1
889+ if c == 0:
890+ return 0
891+ return argb(255, r // c, g // c, b // c)
892+
893+
894+def _calculated_table_background(bubble, is_dark, is_out):
895+ if is_dark and is_out:
896+ return mult_alpha(s32(0xFFFFFFFF), f32(0.07))
897+ h, s, v = color_to_hsv(bubble)
898+ if is_dark:
899+ v = jmin(1.0, f32(v + f32(0.07)))
900+ if is_out:
901+ s = jmin(1.0, f32(s + f32(0.02)))
902+ else:
903+ v = jmax(0.0, f32(v - (f32(0.06) if is_out else f32(0.03))))
904+ if is_out and s > f32(0.02):
905+ s = jmin(1.0, f32(s + f32(0.02)))
906+ return hsv_to_color(alpha(bubble), h, s, v)
907+
908+
909+def _calculated_table_border(bubble, is_dark, is_out):
910+ if is_dark and is_out:
911+ return mult_alpha(s32(0xFFFFFFFF), f32(0.14))
912+ h, s, v = color_to_hsv(bubble)
913+ if is_dark:
914+ v = jmin(1.0, f32(v + f32(0.14)))
915+ if is_out:
916+ s = jmin(1.0, f32(s + f32(0.03)))
917+ else:
918+ v = jmax(0.0, f32(v - (f32(0.14) if is_out else f32(0.12))))
919+ if is_out and s > f32(0.02):
920+ s = jmin(1.0, f32(s + f32(0.04)))
921+ return hsv_to_color(alpha(bubble), h, s, v)
922+
923+
924+def _table_out_bubble(m, colors):
925+ K = m.K
926+ r = g = b = n = 0
927+ for kname in ("key_chat_outBubble", "key_chat_outBubbleGradient1", "key_chat_outBubbleGradient2", "key_chat_outBubbleGradient3"):
928+ k = K[kname]
929+ if kname != "key_chat_outBubble" and k not in colors:
930+ continue
931+ c = colors.get(k, m.defaults[k])
932+ r += red(c)
933+ g += green(c)
934+ b += blue(c)
935+ n += 1
936+ return argb(255, r // n, g // n, b // n)
937+
938+
939+def apply_calculated_table_colors(m, src, colors, is_dark):
940+ K = m.K
941+ in_b = colors.get(K["key_chat_inBubble"], m.defaults[K["key_chat_inBubble"]])
942+ out_b = _table_out_bubble(m, colors)
943+ if K["key_chat_inTableBackground"] not in src:
944+ colors[K["key_chat_inTableBackground"]] = _calculated_table_background(in_b, is_dark, False)
945+ if K["key_chat_outTableBackground"] not in src:
946+ colors[K["key_chat_outTableBackground"]] = _calculated_table_background(out_b, is_dark, True)
947+ if K["key_chat_inTableBorder"] not in src:
948+ colors[K["key_chat_inTableBorder"]] = _calculated_table_border(in_b, is_dark, False)
949+ if K["key_chat_outTableBorder"] not in src:
950+ colors[K["key_chat_outTableBorder"]] = _calculated_table_border(out_b, is_dark, True)
951+ if K["key_chat_outDivider"] not in src:
952+ rl = colors.get(K["key_chat_outReplyLine"], m.defaults[K["key_chat_outReplyLine"]])
953+ colors[K["key_chat_outDivider"]] = mult_alpha(rl, f32(0.2))
954+ if is_dark and K["key_chat_inDivider"] not in src:
955+ rt = colors.get(K["key_chat_inReplyMessageText"], m.defaults[K["key_chat_inReplyMessageText"]])
956+ colors[K["key_chat_inDivider"]] = mult_alpha(rt, f32(0.2))
957+
958+
959+def _article_details(bubble, is_dark, is_out, arrow):
960+ if is_dark:
961+ return mult_alpha(s32(0xFFFFFFFF), f32(0.62) if arrow else f32(0.18))
962+ base = s32(0xff9ea4a8) if arrow else s32(0xffd8d8d8)
963+ s = color_to_hsv(bubble)[1]
964+ return adapt_hue(base, bubble) if (is_out and s > f32(0.02)) else base
965+
966+
967+def _article_scrollbar(bubble, is_dark, thumb):
968+ if is_dark:
969+ return mult_alpha(s32(0xFFFFFFFF), f32(0.22) if thumb else f32(0.12))
970+ base = s32(0xffc5cdd5) if thumb else s32(0xffe1e6eb)
971+ s = color_to_hsv(bubble)[1]
972+ return adapt_hue(base, bubble) if s > f32(0.02) else base
973+
974+
975+def apply_calculated_article_code_colors(m, src, colors, is_dark):
976+ K = m.K
977+ if is_dark and K["key_chat_inArticleCodeBackground"] not in src:
978+ colors[K["key_chat_inArticleCodeBackground"]] = mult_alpha(s32(0xFFFFFFFF), f32(0.10))
979+ in_b = colors.get(K["key_chat_inBubble"], m.defaults[K["key_chat_inBubble"]])
980+ out_b = _table_out_bubble(m, colors)
981+ for kname, bub, thumb in (("key_chat_inArticleCodeScrollbarBackground", in_b, False), ("key_chat_inArticleCodeScrollbar", in_b, True),
982+ ("key_chat_outArticleCodeScrollbarBackground", out_b, False), ("key_chat_outArticleCodeScrollbar", out_b, True)):
983+ if K[kname] not in src:
984+ colors[K[kname]] = _article_scrollbar(bub, is_dark, thumb)
985+ for kname, bub, is_out, arrow in (("key_chat_inArticleDetailsArrow", in_b, False, True), ("key_chat_outArticleDetailsArrow", out_b, True, True),
986+ ("key_chat_inArticleDetailsLine", in_b, False, False), ("key_chat_outArticleDetailsLine", out_b, True, False)):
987+ if K[kname] not in src:
988+ colors[K[kname]] = _article_details(bub, is_dark, is_out, arrow)
989+
990+
991+def refresh_theme_colors(theme, accent, current_id=None):
992+ """Theme.refreshThemeColors(): currentColors = currentColorsNoAccent.clone(); accent.fillAccentColors(...);
993+ applyCalculatedTableColors(...); applyCalculatedArticleCodeColors(...)."""
994+ no_accent = theme.no_accent
995+ cur = dict(no_accent)
996+ sdgi = fill_accent_colors(theme, accent, no_accent, cur, current_id)
997+ apply_calculated_table_colors(theme.model, no_accent, cur, theme.is_dark)
998+ apply_calculated_article_code_colors(theme.model, no_accent, cur, theme.is_dark)
999+ if theme.model.hook is not None:
1000+ apply_on_accent_hook(theme, accent, current_id, cur)
1001+ return Palette(theme, accent, current_id, cur, sdgi)
1002+
1003+
1004+# ----------------------------------------------------------------------------------------
1005+# canvas-fix: on-accent content rule (the code hook at the end of refreshThemeColors)
1006+# ----------------------------------------------------------------------------------------
1007+
1008+def wcag_luminance(c):
1009+ L = _SRGB8_TO_LINEAR
1010+ return 0.2126 * L[(c >> 16) & 0xFF] + 0.7152 * L[(c >> 8) & 0xFF] + 0.0722 * L[c & 0xFF]
1011+
1012+
1013+def wcag_contrast(c1, c2):
1014+ l1, l2 = wcag_luminance(c1), wcag_luminance(c2)
1015+ if l1 < l2:
1016+ l1, l2 = l2, l1
1017+ return (l1 + 0.05) / (l2 + 0.05)
1018+
1019+
1020+def src_over_rgb(fg, bg):
1021+ """8-bit src-over of ARGB fg on opaque bg, (a*x + (255-a)*y + 127) / 255 per channel (integer)."""
1022+ a = (fg >> 24) & 0xFF
1023+ if a == 255:
1024+ return fg & 0xFFFFFF
1025+ ia = 255 - a
1026+ r = (((fg >> 16) & 0xFF) * a + ((bg >> 16) & 0xFF) * ia + 127) // 255
1027+ g = (((fg >> 8) & 0xFF) * a + ((bg >> 8) & 0xFF) * ia + 127) // 255
1028+ b = ((fg & 0xFF) * a + (bg & 0xFF) * ia + 127) // 255
1029+ return (r << 16) | (g << 8) | b
1030+
1031+
1032+def on_accent_color(fill, page, near_black):
1033+ """White or near-black, whichever has the higher WCAG contrast with the (composited) fill; ties -> white."""
1034+ f = src_over_rgb(fill, page)
1035+ return s32(0xFFFFFFFF) if wcag_contrast(0xFFFFFF, f) >= wcag_contrast(near_black & 0xFFFFFF, f) else near_black
1036+
1037+
1038+def apply_on_accent_hook(theme, accent, current_id, cur):
1039+ m = theme.model
1040+ hk = m.hook
1041+ K = m.K
1042+ pal = Palette(theme, accent, current_id, cur) # getColor() on the refreshed map
1043+ page = pal.get(K["key_windowBackgroundWhite"])
1044+ for content, fill in hk["pairs"]:
1045+ cur[content] = on_accent_color(pal.get(fill), page, hk["near_black"])
1046+ if hk["muted"] < 0:
1047+ return
1048+ text = cur[hk["muted_text"]]
1049+ for dep in hk["muted_pins"]: # keys that fall back to chats_unreadCounterMuted keep their colour
1050+ if dep not in cur:
1051+ cur[dep] = pal.get(dep)
1052+ w, b = hk["muted_table"][theme.name]
1053+ cur[hk["muted"]] = w if text == s32(0xFFFFFFFF) else b
1054+
1055+
1056+_MODEL = None
1057+
1058+
1059+def model():
1060+ global _MODEL
1061+ if _MODEL is None:
1062+ _MODEL = Model(tgsrc.load())
1063+ return _MODEL
1064+
1065+
1066+_OVERLAY_MODELS = {}
1067+
1068+
1069+def overlay_model(spec):
1070+ """Model with a canvas-fix overlay applied (cached by spec tag)."""
1071+ m = _OVERLAY_MODELS.get(spec["tag"])
1072+ if m is None:
1073+ m = Model(tgsrc.load(), spec)
1074+ _OVERLAY_MODELS[spec["tag"]] = m
1075+ return m
1076+
1077+
1078+# ----------------------------------------------------------------------------------------
1079+# Lazy evaluation of the same refresh (only the keys that are asked for)
1080+# ----------------------------------------------------------------------------------------
1081+
1082+POST_KEY_NAMES = [
1083+ "key_chat_inTextSelectionHighlight", "key_chat_outTextSelectionHighlight", "key_chat_outTextSelectionCursor",
1084+ "key_chat_outBubbleLocationPlaceholder", "key_chat_inBubbleLocationPlaceholder", "key_chat_linkSelectBackground",
1085+ "key_chat_outLinkSelectBackground", "key_actionBarDefaultSubmenuSeparator", "key_chat_outCodeBackground",
1086+ "key_chat_inTableBackground", "key_chat_outTableBackground", "key_chat_inTableBorder", "key_chat_outTableBorder",
1087+ "key_chat_outDivider", "key_chat_inDivider", "key_chat_inArticleCodeBackground", "key_chat_inArticleCodeScrollbarBackground",
1088+ "key_chat_inArticleCodeScrollbar", "key_chat_outArticleCodeScrollbarBackground", "key_chat_outArticleCodeScrollbar",
1089+ "key_chat_inArticleDetailsArrow", "key_chat_outArticleDetailsArrow", "key_chat_inArticleDetailsLine",
1090+ "key_chat_outArticleDetailsLine", "key_chat_selectedBackground", "key_chat_outBubbleSelectedOverlay",
1091+ "key_chat_outBubbleGradientSelectedOverlay", "key_chat_outBubbleSelected", "key_chat_inBubbleSelectedOverlay",
1092+ "key_chat_inBubbleSelected",
1093+]
1094+
1095+
1096+class LazyPalette(Palette):
1097+ """Same result as refresh_theme_colors(theme, accent).get(key) for every key, but each key's
1098+ value is computed on demand by replaying, for that key only, the writes fillAccentColors()
1099+ performs in order. Keys written by the post-processing tail fall back to the full port."""
1100+
1101+ def __init__(self, theme, acc, current_id=None):
1102+ super().__init__(theme, acc, current_id, None)
1103+ m = theme.model
1104+ K = m.K
1105+ self._memo = {}
1106+ self._memo_hook = {}
1107+ self._full = None
1108+ self._post = set(K[k] for k in POST_KEY_NAMES)
1109+ self.hsv1 = color_to_hsv(theme.accentBaseColor)
1110+ self.hsv2 = color_to_hsv(acc.accentColor)
1111+ self.main_on = acc.accentColor != theme.accentBaseColor or acc.accentColor2 != 0
1112+ my_acc = acc.myMessagesAccentColor
1113+ g1, g2, g3 = acc.myMessagesGradientAccentColor1, acc.myMessagesGradientAccentColor2, acc.myMessagesGradientAccentColor3
1114+ is_near = False
1115+ no_accent = theme.no_accent
1116+ if (acc.myMessagesAccentColor != 0 or acc.accentColor != 0) and g1 != 0:
1117+ first = acc.myMessagesAccentColor if acc.myMessagesAccentColor != 0 else acc.accentColor
1118+ color = no_accent.get(K["key_chat_outBubble"], 0)
1119+ if color == 0:
1120+ color = m.defaults[K["key_chat_outBubble"]]
1121+ new = change_color_accent(self.hsv1, self.hsv2, color, theme.is_dark, color)
1122+ d1 = color_distance(first, new)
1123+ d2 = color_distance(first, g1)
1124+ if g2 != 0:
1125+ avg = average_color(acc.myMessagesAccentColor, g1)
1126+ avg = average_color(avg, g2)
1127+ if g3 != 0:
1128+ avg = average_color(avg, g3)
1129+ ubt = perceived_brightness(avg) > F0_705
1130+ else:
1131+ ubt = use_black_text(acc.myMessagesAccentColor, g1)
1132+ is_near = (d1 <= 35000 and d2 <= 35000) if ubt else False
1133+ my_acc = get_accent_color(self.hsv1, color, first)
1134+ self.my_acc = my_acc
1135+ self.is_near = is_near
1136+ self.change_my = (my_acc != 0 and ((theme.accentBaseColor != 0 and my_acc != theme.accentBaseColor) or (acc.accentColor != 0 and acc.accentColor != my_acc)))
1137+ self.my_loop_on = self.change_my or acc.accentColor2 != 0
1138+ self.hsv_my = color_to_hsv(acc.accentColor2 if acc.accentColor2 != 0 else my_acc) if self.my_loop_on else None
1139+ self.hsv_bub = color_to_hsv(my_acc) if self.change_my else None
1140+ self.out_vals = None
1141+ if (not is_near or _force_out_block(m, acc)) and g1 != 0:
1142+ if g2 != 0:
1143+ c = average_color(acc.myMessagesAccentColor, g1)
1144+ c = average_color(c, g2)
1145+ if g3 != 0:
1146+ c = average_color(c, g3)
1147+ ubt = perceived_brightness(c) > F0_705
1148+ else:
1149+ ubt = use_black_text(acc.myMessagesAccentColor, g1)
1150+ text, sub, seek = m.out_consts["black" if ubt else "white"]
1151+ self.use_black_text_out = ubt
1152+ vals = {"textColor": text, "subTextColor": sub, "seekbarColor": seek, "myMessagesAccentColor": acc.myMessagesAccentColor}
1153+ ov = {}
1154+ if acc.accentColor2 == 0:
1155+ for k, var in m.out_block_accent2_zero:
1156+ ov[k] = vals[var]
1157+ for k, var in m.out_block_always:
1158+ ov[k] = vals[var]
1159+ self.out_vals = ov
1160+ else:
1161+ self.use_black_text_out = None
1162+ bub = {}
1163+ if acc.myMessagesAccentColor != 0 and g1 != 0:
1164+ bub[K["key_chat_outBubble"]] = acc.myMessagesAccentColor
1165+ bub[K["key_chat_outBubbleGradient1"]] = g1
1166+ if g2 != 0:
1167+ bub[K["key_chat_outBubbleGradient2"]] = g2
1168+ if g3 != 0:
1169+ bub[K["key_chat_outBubbleGradient3"]] = g3
1170+ bub[K["key_chat_outBubbleGradientAnimated"]] = 1 if acc.myMessagesAnimated else 0
1171+ self.bub_puts = bub
1172+ wp = {}
1173+ for fld, kname in (("backgroundOverrideColor", "key_chat_wallpaper"),
1174+ ("backgroundGradientOverrideColor1", "key_chat_wallpaper_gradient_to1"),
1175+ ("backgroundGradientOverrideColor2", "key_chat_wallpaper_gradient_to2"),
1176+ ("backgroundGradientOverrideColor3", "key_chat_wallpaper_gradient_to3")):
1177+ lv = getattr(acc, fld)
1178+ iv = long_to_int(lv)
1179+ if iv != 0:
1180+ wp[K[kname]] = ("put", iv)
1181+ elif lv != 0:
1182+ wp[K[kname]] = ("delete", None)
1183+ if acc.backgroundRotation != 45:
1184+ wp[K["key_chat_wallpaper_gradient_rotation"]] = ("put", acc.backgroundRotation)
1185+ self.wp_ops = wp
1186+
1187+ def full(self):
1188+ if self._full is None:
1189+ self._full = refresh_theme_colors(self.theme, self.accent, self.current_id)
1190+ return self._full
1191+
1192+ def cur_entry(self, key):
1193+ """(present, value) of `key` in currentColors after refreshThemeColors() (incl. the canvas-fix hook)."""
1194+ hk = self.model.hook
1195+ if hk is None:
1196+ return self._base_entry(key)
1197+ r = self._memo_hook.get(key)
1198+ if r is not None:
1199+ return r
1200+ if key in hk["content"]:
1201+ page = self.get(self.model.K["key_windowBackgroundWhite"])
1202+ r = (True, on_accent_color(self.get(hk["content"][key]), page, hk["near_black"]))
1203+ elif key == hk["muted"]:
1204+ text = self.cur_entry(hk["muted_text"])[1]
1205+ w, b = hk["muted_table"][self.theme.name]
1206+ r = (True, w if text == s32(0xFFFFFFFF) else b)
1207+ elif key in hk["muted_pins"]:
1208+ r = self._base_entry(key)
1209+ if not r[0]:
1210+ r = (True, self._get_with(self._base_entry, key))
1211+ else:
1212+ r = self._base_entry(key)
1213+ self._memo_hook[key] = r
1214+ return r
1215+
1216+ def _base_entry(self, key):
1217+ """(present, value) of `key` after fillAccentColors + calculated colours (before the hook)."""
1218+ r = self._memo.get(key)
1219+ if r is not None:
1220+ return r
1221+ if key in self._post:
1222+ full = self.full().colors
1223+ r = (key in full, full.get(key, 0))
1224+ self._memo[key] = r
1225+ return r
1226+ th, m, acc = self.theme, self.model, self.accent
1227+ no_accent = th.no_accent
1228+ present = key in no_accent
1229+ val = no_accent.get(key, 0)
1230+ is_dark = th.is_dark
1231+ if self.main_on and key not in m.exclusions:
1232+ skip = False
1233+ if key not in no_accent:
1234+ fb = m.fallback.get(key, -1)
1235+ if fb >= 0 and fb in no_accent:
1236+ skip = True
1237+ color = m.defaults[key]
1238+ else:
1239+ color = no_accent[key]
1240+ if not skip:
1241+ new = change_color_accent(self.hsv1, self.hsv2, color, is_dark, color)
1242+ if new != color:
1243+ present, val = True, new
1244+ if self.my_loop_on:
1245+ if m.my_start <= key < m.my_end:
1246+ skip = False
1247+ if key not in no_accent:
1248+ fb = m.fallback.get(key, -1)
1249+ if fb >= 0 and no_accent.get(fb, -1) >= 0:
1250+ skip = True
1251+ color = m.defaults[key]
1252+ else:
1253+ color = no_accent[key]
1254+ if not skip:
1255+ new = change_color_accent(self.hsv1, self.hsv_my, color, is_dark, color)
1256+ if new != color:
1257+ present, val = True, new
1258+ if key in m.extra_keys:
1259+ color = m.defaults[key] if key not in no_accent else no_accent[key]
1260+ new = change_color_accent(self.hsv1, self.hsv_my, color, is_dark, color)
1261+ if new != color:
1262+ present, val = True, new
1263+ if self.change_my and m.bub_start <= key < m.bub_end:
1264+ skip = False
1265+ if key not in no_accent:
1266+ fb = m.fallback.get(key, -1)
1267+ if fb >= 0 and no_accent.get(fb, -1) >= 0:
1268+ skip = True
1269+ color = m.defaults[key]
1270+ else:
1271+ color = no_accent[key]
1272+ if not skip:
1273+ new = change_color_accent(self.hsv1, self.hsv_bub, color, is_dark, color)
1274+ if new != color:
1275+ present, val = True, new
1276+ if self.out_vals is not None and key in self.out_vals:
1277+ present, val = True, self.out_vals[key]
1278+ if key in self.bub_puts:
1279+ present, val = True, self.bub_puts[key]
1280+ op = self.wp_ops.get(key)
1281+ if op is not None:
1282+ if op[0] == "put":
1283+ present, val = True, op[1]
1284+ else:
1285+ present, val = False, 0
1286+ r = (present, val)
1287+ self._memo[key] = r
1288+ return r
1289+
1290+ def get(self, key):
1291+ return self._get_with(self.cur_entry, key)
1292+
1293+ def _get_with(self, entry, key):
1294+ m, th, K = self.model, self.theme, self.model.K
1295+ if isinstance(key, str):
1296+ key = m.key(key)
1297+ if th.name == "Blue":
1298+ if m.bub_start <= key < m.bub_end:
1299+ use_default = th.is_default_my_messages_bubbles(self.accent, self.current_id)
1300+ elif m.my_start <= key < m.my_end:
1301+ use_default = th.is_default_my_messages(self.accent, self.current_id)
1302+ elif key in (K["key_chat_wallpaper"], K["key_chat_wallpaper_gradient_to1"], K["key_chat_wallpaper_gradient_to2"], K["key_chat_wallpaper_gradient_to3"]):
1303+ use_default = False
1304+ else:
1305+ use_default = th.is_default_main_accent(self.accent, self.current_id)
1306+ if use_default:
1307+ if key == K["key_chat_serviceBackground"]:
1308+ return self.service_message_color
1309+ if key == K["key_chat_serviceBackgroundSelected"]:
1310+ return self.service_selected_message_color
1311+ return m.get_default_color(key)
1312+ present, val = entry(key)
1313+ if not present:
1314+ fb = m.fallback.get(key, -1)
1315+ if fb != -1:
1316+ fp, fv = entry(fb)
1317+ if fp:
1318+ return fv
1319+ if key == K["key_chat_serviceBackground"]:
1320+ return self.service_message_color
1321+ if key == K["key_chat_serviceBackgroundSelected"]:
1322+ return self.service_selected_message_color
1323+ return m.get_default_color(key)
1324+ if key in (K["key_windowBackgroundWhite"], K["key_windowBackgroundGray"], K["key_actionBarDefault"], K["key_actionBarDefaultArchived"]):
1325+ val = s32(val | 0xFF000000)
1326+ return val
1327+
1328+ def raw(self, key):
1329+ """currentColors.get(key) (0 when absent) -- what the wallpaper code reads."""
1330+ if isinstance(key, str):
1331+ key = self.model.key(key)
1332+ p, v = self.cur_entry(key)
1333+ return v if p else 0
1334+
1335+
1336+def _palette_raw(self, key):
1337+ if isinstance(key, str):
1338+ key = self.model.key(key)
1339+ return self.colors.get(key, 0)
1340+
1341+
1342+Palette.raw = _palette_raw
1343+
1344+
1345+def palette(theme, accent, current_id=None, lazy=True):
1346+ if lazy:
1347+ return LazyPalette(theme, accent, current_id)
1348+ return refresh_theme_colors(theme, accent, current_id)