on main help sign in
Add the style table and its generator

Each install picks a day and a night style from a precomputed table instead of generating one on the device. This adds the table asset (multigram_styles.bin: 4,096 styles on each of Blue, Arctic Blue, Day, Dark Blue and Night; accent, outgoing-bubble colours and a 2-4 colour wallpaper gradient without a pattern) and the tools that make and check it: - multigram/tools/sim: the colour-engine simulator, reading the theme sources of this repository; overlay A copied in. It recognises the palette fix's out-text guard (PaletteFix.isRuntimeAccent) as well as the stock one. - make_style_table.py: deterministic generator. Every entry passes all 99 readability pairs on overlay A: with headroom (4.6 text, 3.1 non-text, 1.19 bubble vs wallpaper) on every pair the style controls, and only at plain WCAG on the palette-limited pairs, neutral greys that overlay A sized at 4.5:1 (every entry has some at 4.50-4.54:1 until the palette fix gets headroom). It stops with a diagnosis when a theme's yield collapses instead of running on. - check_style_table.py and check.sh: CI check of the format and of every entry against the current theme sources, with or without the palette fix already in the tree; check.sh also runs palette_fix_check.py when the tree has it. The format and the rules are documented in multigram/tools/README.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mVy1fu8zBn38wHgJqmLNz

commita1aa43b8332c36cc7f3712d236b4dd69ab347072 authorClaude <noreply@anthropic.com> dateSun 27 Sep 2026, 01:41 (2 weeks ago) parent606daaa
17 files changed+6465
TMessagesProj/src/main/assets/multigram_styles.bin multigram/tools/.gitignore+4 multigram/tools/README.md+277 multigram/tools/check.sh+32 multigram/tools/check_style_table.py+283 multigram/tools/make_style_table.py+268 multigram/tools/sim/README.md+65 multigram/tools/sim/canvas.py+868 multigram/tools/sim/detmath.py+157 multigram/tools/sim/detmath_tables.py+144 multigram/tools/sim/engine.py+1348 multigram/tools/sim/palette-fix/overlay_A.json+178 multigram/tools/sim/palette-fix/overlay_B.json+185 multigram/tools/sim/readability.py+499 multigram/tools/sim/style_sim.py+919 multigram/tools/sim/tgsrc.py+653 multigram/tools/style_table.py+585
TMessagesProj/src/main/assets/multigram_styles.binnew file

Binary file not shown.

multigram/tools/.gitignorenew file+4
@@ -0,0 +1,4 @@
1+# Python bytecode and the simulator's parse cache (sim/cache/, keyed by a hash of the theme sources)
2+__pycache__/
3+*.pyc
4+sim/cache/
multigram/tools/README.mdnew file+277
@@ -0,0 +1,277 @@
1+# MultiGram tools
2+
3+Python 3.9+ and bash, standard library only. Run everything from the repository root.
4+
5+| File | What it does |
6+|---|---|
7+| `check.sh` | The checks CI runs after the compile (`bash multigram/tools/check.sh`). About a minute locally on 4 cores, 2 to 3 minutes on CI. |
8+| `make_style_table.py` | Regenerates `TMessagesProj/src/main/assets/multigram_styles.bin` (deterministic). |
9+| `check_style_table.py` | Checks the asset's format and re-validates every entry against the theme sources in this tree. |
10+| `style_table.py` | Shared code: binary format, generator, validator. |
11+| `sim/` | The colour-engine simulator both of them use (`sim/README.md`). |
12+| `palette_fix_check.py` | Part of the palette fix, when the tree has it (`multigram/palette-fix/README.md`). `check.sh` runs it first when it is present. |
13+| `sim/palette-fix/overlay_A.json` | The palette fix ("overlay A") the table is validated on. When the tree also has `multigram/palette-fix/overlay_A.json` (the palette fix itself), that file is used instead; they are the same. |
14+
15+## The style table: `multigram_styles.bin`
16+
17+Each install picks one day style and one night style from this table (by its seed). A style is:
18+
19+- a base theme: day from Blue, Arctic Blue, Day; night from Dark Blue, Night (never the Monet themes);
20+- an accent colour;
21+- outgoing-bubble colours: 1 to 4 colours, optionally animated;
22+- a wallpaper: a gradient of 2 to 4 colours and a rotation, and **no pattern** (a pattern slug would
23+ make the app fetch the pattern from Telegram's servers). A motion flag is stored too, but it has no
24+ visible effect today (see the record's flags).
25+
26+Every entry passes all 99 readability pairs of the simulator's model on top of the palette fix
27+(overlay A): every pair the style controls with headroom (4.6:1 text, 3.1:1 non-text, 1.19 bubble vs
28+wallpaper), and the **palette-limited pairs only at the plain WCAG threshold** (4.5:1 / 3.0:1). Those are
29+neutral greys that overlay A sized at exactly the threshold, so no style can give them headroom; every
30+entry has some of them between 4.50 and 4.54 (3.03 for the composer icons on the light themes). Only a
31+palette fix with headroom can raise them; see "Readability rule". Without the palette fix in the app,
32+the entries are not readable.
33+
34+### Format, version 1
35+
36+All multi-byte integers are **big-endian** (the default of `java.nio.ByteBuffer` and
37+`java.io.DataInputStream`). The file is three contiguous parts, with no padding and nothing after them:
38+
39+ header (24 bytes) | directory (theme_count x 32 bytes) | records (record_count x 40 bytes)
40+
41+so its size is exactly `24 + 32 * theme_count + 40 * record_count` (today 5 themes and 20,480 records:
42+819,384 bytes).
43+
44+**Header** (24 bytes)
45+
46+| Offset | Size | Field | Value |
47+|---|---|---|---|
48+| 0 | 4 | magic | ASCII `MGST` (`0x4D 0x47 0x53 0x54`) |
49+| 4 | 2 | version | `1` (u16). A parser rejects any other version. |
50+| 6 | 2 | header_size | `24` (u16) |
51+| 8 | 2 | theme_count | number of directory entries (u16), today 5 |
52+| 10 | 2 | dir_entry_size | `32` (u16) |
53+| 12 | 2 | record_size | `40` (u16) |
54+| 14 | 2 | reserved | `0` |
55+| 16 | 4 | record_count | total number of records (u32), today 20,480 |
56+| 20 | 4 | crc32 | CRC-32 (zlib / `java.util.zip.CRC32`) of every byte after the header, offset 24 to the end. Also a fingerprint of the table's contents. |
57+
58+**Directory entry** (32 bytes each, right after the header, in directory order)
59+
60+| Offset | Size | Field | Value |
61+|---|---|---|---|
62+| 0 | 20 | theme_key | the base theme's key, UTF-8, padded with NUL bytes to 20 (at least one NUL). It is `ThemeInfo.getKey()` of the bundled theme, i.e. the string `Theme.getTheme(key)` takes: `Blue`, `Arctic Blue`, `Day`, `Dark Blue`, `Night`. |
63+| 20 | 1 | flags | bit 0: night theme (`ThemeInfo.isDark()`); other bits 0 |
64+| 21 | 1 | reserved | `0` |
65+| 22 | 2 | reserved | `0` |
66+| 24 | 4 | first_record | index (u32) of the theme's first record |
67+| 28 | 4 | record_count | number of records (u32) of this theme, at least 1 |
68+
69+The themes' record ranges are contiguous and in directory order: the first theme starts at record 0 and
70+each next one starts where the previous one ends; together they cover all `record_count` records.
71+Today's order: Blue, Arctic Blue, Day, Dark Blue, Night, 4,096 records each.
72+
73+**Record** (40 bytes each). Record `i` starts at byte `24 + 32 * theme_count + 40 * i`.
74+
75+| Offset | Size | Field | Maps to `Theme.ThemeAccent` |
76+|---|---|---|---|
77+| 0 | 4 | accent | `accentColor` |
78+| 4 | 4 | bubble | `myMessagesAccentColor` |
79+| 8 | 4 | bubble_gradient1 | `myMessagesGradientAccentColor1`, never 0 |
80+| 12 | 4 | bubble_gradient2 | `myMessagesGradientAccentColor2`, 0 = unused |
81+| 16 | 4 | bubble_gradient3 | `myMessagesGradientAccentColor3`, 0 = unused |
82+| 20 | 4 | wallpaper1 | `backgroundOverrideColor` |
83+| 24 | 4 | wallpaper2 | `backgroundGradientOverrideColor1`, never 0 |
84+| 28 | 4 | wallpaper3 | `backgroundGradientOverrideColor2`, 0 = unused |
85+| 32 | 4 | wallpaper4 | `backgroundGradientOverrideColor3`, 0 = unused |
86+| 36 | 2 | rotation | `backgroundRotation`, degrees (u16): 0, 45, 90, ..., 315 |
87+| 38 | 1 | flags | bit 0: `myMessagesAnimated`; bit 1: `patternMotion` (see below); other bits 0 |
88+| 39 | 1 | reserved | `0` |
89+
90+Colours are 32-bit ARGB, `0xAARRGGBB`, exactly an Android `@ColorInt`: read them with `getInt()` /
91+`readInt()` and use the value as is. Every colour in the table is opaque (alpha `0xFF`), so `0` is never
92+a colour; it marks an unused slot.
93+
94+- Bubble colour count: 1 (solid) when `bubble_gradient1 == bubble` and `bubble_gradient2 == 0`; otherwise
95+ 2, 3 or 4 = 2 + (gradient2 != 0) + (gradient3 != 0). A solid bubble keeps `gradient1 == bubble`: pass it
96+ to the accent as is, do not turn it into 0 (`fillAccentColors` only applies the bubble colours when
97+ gradient1 is non-zero). gradient3 is set only when gradient2 is. The colours of a gradient bubble are
98+ all distinct. The animated flag is set only with 3 or 4 colours.
99+- Wallpaper colour count: 2, 3 or 4 = 2 + (wallpaper3 != 0) + (wallpaper4 != 0); wallpaper4 is set only
100+ when wallpaper3 is, and the colours are distinct. 2 colours are drawn as a linear gradient at
101+ `rotation`; 3 or 4 as Telegram's motion gradient, which ignores the rotation (it is stored anyway).
102+- No record has a pattern: every entry was validated as a plain gradient.
103+- Flag bit 1 (`patternMotion`, set on about half the records) has **no visible effect** with Telegram's
104+ code: `createBackgroundDrawable` uses the accent's `patternMotion` only when a pattern wallpaper file
105+ exists (`getPathToWallpaper()` is null for an empty slug); for a plain gradient the parallax follows
106+ the theme's own `ThemeInfo.isMotion` setting. It is stored so the app could apply it (for example by
107+ setting the theme's motion setting from it), which would be a Java-side decision; it does not affect
108+ readability.
109+- Within a theme no two records share an accent colour, and none uses a built-in preset accent colour.
110+
111+### Applying an entry
112+
113+This is exactly what the simulator validated (`style_sim.to_accent`, accent id 101):
114+
115+```java
116+Theme.ThemeInfo theme = Theme.getTheme(themeKey); // "Blue", "Arctic Blue", "Day", "Dark Blue", "Night"
117+Theme.ThemeAccent a = ...; // a runtime accent of that theme: id > 100 (++lastAccentId), e.g. from getAccent(true)
118+a.accentColor = accent;
119+a.accentColor2 = 0;
120+a.myMessagesAccentColor = bubble;
121+a.myMessagesGradientAccentColor1 = bubbleGradient1; // == bubble for a solid bubble
122+a.myMessagesGradientAccentColor2 = bubbleGradient2; // 0 when unused
123+a.myMessagesGradientAccentColor3 = bubbleGradient3; // 0 when unused
124+a.myMessagesAnimated = (flags & 1) != 0;
125+a.backgroundOverrideColor = wallpaper1; // int -> long, as ThemePreviewActivity assigns colours
126+a.backgroundGradientOverrideColor1 = wallpaper2;
127+a.backgroundGradientOverrideColor2 = wallpaper3 != 0 ? wallpaper3 : 0x100000000L;
128+a.backgroundGradientOverrideColor3 = wallpaper4 != 0 ? wallpaper4 : 0x100000000L;
129+a.backgroundRotation = rotation;
130+a.patternSlug = "";
131+a.patternIntensity = 0f;
132+a.patternMotion = (flags & 2) != 0; // no visible effect without a pattern (see above)
133+a.pattern = null;
134+a.overrideWallpaper = null; // getAccent(true) copies the current one; drop it
135+```
136+
137+- The accent must be a runtime accent (`PaletteFix.isRuntimeAccent`: id above 100 and `info == null`):
138+ the palette fix's on-accent rule and out-bubble text guard only run for runtime accents, and the
139+ entries are only readable with them.
140+- Unused wallpaper slots take `0x100000000L`, the "remove this key" value `setAccentColorOptions` uses,
141+ not 0 (0 would keep a base-theme value; the bundled themes have none today, but that is what was
142+ validated).
143+- Parsing cost: read the 24-byte header and the directory, then read or skip to the one record needed.
144+ The asset is compressed in the APK (no `noCompress` entry), so use `AssetManager.open()`, not
145+ `openFd()`; skipping to the last record inflates at most about 0.8 MB.
146+- Suggested pick: the day style uniformly over all records of the day themes (directory entries without
147+ bit 0, in directory order), the night style likewise over the night themes, so every entry is equally
148+ likely and the base theme follows from the entry. If the table can change in a later release, keep
149+ the chosen colours (the accent is persisted by Telegram's own accent storage) or store the header's
150+ crc32 with the index, so an update does not silently restyle an install.
151+
152+### Readability rule
153+
154+Each entry is evaluated by the simulator (`sim/engine.py` port of `refreshThemeColors`,
155+`sim/readability.py` 99 pairs) on this tree's Theme.java, ThemeColors.java and bundled `.attheme` files
156+plus overlay A, with the accent as a runtime accent (id 101). The simulator's solver stops exactly on
157+its thresholds, so the table is solved against thresholds with headroom:
158+
159+| Pair class | WCAG / project threshold | Table threshold |
160+|---|---|---|
161+| text (incl. date-pill text) | 4.5 | **4.6** |
162+| non-text (icons, marks, fills) | 3.0 | **3.1** |
163+| bubble vs wallpaper | 1.15 | **1.19** |
164+
165+Exception: **palette-limited pairs** are held only to the plain threshold, because no style can give them
166+headroom; the palette fix has to. **So the table does not meet the 4.6 / 3.1 headroom on these pairs**:
167+they are between 4.500 and 4.54:1 (3.034:1 for the composer icons) on the light themes whatever the
168+style, and between 4.500 and about 4.8:1 on Night. They are found per base theme
169+(`style_table.palette_limited`), over a fixed probe of accents (24 hues x 3 chromas x 9 lightnesses):
170+
171+- pairs whose contrast moves by less than 10 % of their threshold over the probe and falls below the table
172+ threshold for some of it. Overlay A sized these neutral greys at exactly the WCAG threshold;
173+- the digits on the muted unread badge (`chats_muted_badge_text`) while they fall below the table
174+ threshold for some probe accent: the palette fix fills that badge from a per-theme table sized at 4.5:1,
175+ and the style only decides which of its two entries is used.
176+
177+Both rules follow the palette: when the palette fix gives a pair headroom for every probe accent (greys
178+at 4.6:1, composer icons at 3.1:1, a muted-badge table sized at 4.6:1), the pair stops being
179+palette-limited and the generator holds it to the table threshold; regenerate the table then. On Night
180+the accent tint moves some of these pairs (`in_time`, `in_views`, `in_file_info` up to about 4.8:1), but
181+holding just them to 4.6 makes the solver reject orange and magenta accents (the 30-60 and 300-330 degree
182+hue ranges lose about half their entries) while every entry still keeps `list_gray_text2` near 4.50, so
183+they stay palette-limited until the palette has headroom.
184+
185+| Base theme | Palette-limited pairs (range over the table's entries) |
186+|---|---|
187+| Blue, Arctic Blue, Day | list_gray_text, list_gray_text2, chats_preview, chats_date, actionbar_subtitle, in_time, in_file_info, in_views, composer_hint, pinned_text, settings_footnote (text, constant 4.500-4.540 whatever the style); composer_icons (non-text, constant 3.034); chats_muted_badge_text (4.513, or 11.47 with near-black digits) |
188+| Dark Blue | settings_footnote (4.520), chats_muted_badge_text (4.502) |
189+| Night | list_gray_text, list_gray_text2, chats_preview, chats_date, actionbar_subtitle, in_time, in_file_info, in_views, composer_hint, pinned_text, chats_attach_label (4.500-4.752, moving at most 0.2 with the accent tint); settings_footnote (4.507); chats_muted_badge_text (4.501) |
190+
191+Smallest margins in the current table (contrast / WCAG threshold):
192+
193+| Base theme | text | non-text | bubble vs wallpaper | palette-limited |
194+|---|---|---|---|---|
195+| Blue | 1.0222 (picker_badge_text 4.600) | 1.0738 (in_media_button 3.221) | 1.0367 (1.192) | 1.0001 (chats_date 4.500), non-text 1.0112 (composer_icons 3.034) |
196+| Arctic Blue | 1.0222 (service_text_lite 4.600) | 1.2035 (unread_badge 3.611) | 1.0350 (1.190) | 1.0001 (chats_date 4.500), non-text 1.0112 |
197+| Day | 1.0222 (filled_button_text 4.600) | 1.2046 (in_media_button 3.614) | 1.0350 (1.190) | 1.0001 (list_gray_text 4.500), non-text 1.0112 |
198+| Dark Blue | 1.0223 (list_accent_text6 4.600) | 1.1005 (composer_icons 3.302) | 1.0351 (1.190) | 1.0004 (chats_muted_badge_text 4.502) |
199+| Night | 1.0222 (list_accent_text6 4.600) | 1.2234 (composer_icons 3.670) | 1.0348 (1.190) | 1.0001 (list_gray_text2 4.500) |
200+
201+The model works on 8-bit colours exactly as the app computes them, but it is a model: only a device
202+build confirms the result.
203+
204+### Generation
205+
206+`python3 multigram/tools/make_style_table.py` (about 30 s on 4 cores; `--procs N`, `--report r.json` for
207+statistics, `--check` to compare with the committed asset). It is deterministic: the same tree gives the
208+same bytes on every run and for any `--procs`.
209+
210+- Table seed `multigram-style-table-1`; candidate `k` of base theme `T` is seeded with the string
211+ `multigram-style-table-1/T/k` (SHA-256, first 8 bytes big-endian, SplitMix64).
212+- Each candidate runs the simulator's seeded OKLCH solver (accent: free hue, chroma 0.07-0.17,
213+ lightness solved; bubble: 1-4 colours near the accent hue, light or dark; wallpaper: 2 (25 %), 3 (20 %)
214+ or 4 (55 %) colours near the accent hue or its complement, lightness solved; rotation and the animated
215+ and motion flags drawn), up to 12 attempts, against the thresholds above, with the wallpaper always
216+ evaluated without a pattern.
217+- Candidates are taken in order of `k`. A candidate is kept when it passes, its record round-trips to
218+ the same 99 contrasts, and its accent colour is new for that theme, until 4,096 per theme.
219+- The generator gives up rather than run on when a theme's yield collapses (no entry from its first 256
220+ candidates, a yield below 20 % after 1,024, or more candidates than 4,096 / 20 % + 256; `--min-yield`
221+ changes the 20 %). It then prints the stages and pairs that failed most often and exits with status 4.
222+ Today's yield is 94 to 99 %.
223+
224+Current table: 4,096 entries per base theme, 20,480 in all, every accent-hue range of 30 degrees
225+represented (288-387 entries per range and theme); bubbles 1/2/3/4 colours about 30/30/15/25 %, about
226+20 % animated, on the day themes about 55 % light (dark text); wallpapers 2/3/4 colours about
227+25/20/55 %; the motion flag on for about half.
228+
229+### Checks
230+
231+`bash multigram/tools/check.sh` (CI runs it after the compile) runs:
232+
233+1. `palette_fix_check.py`, when the tree has it (the MultiGram branch): `PaletteFix.java`, its hooks and
234+ the theme sources apply exactly `multigram/palette-fix/overlay_A.json`. The table is validated
235+ against that file, so this is what ties the validation to the Java code the app runs.
236+2. `check_style_table.py`: the format rules above, every record; theme keys must be bundled base themes
237+ of this tree with the right night flag. Self-tests: the parser must recognise every form of the
238+ out-text guard (stock, `|| id > 100`, and the palette fix's exact line) with the rest of Theme.java
239+ parsed the same; 250 entries evaluated with overlay A applied by the simulator and with overlay A
240+ already written into the sources (Theme.java parsed with the palette fix's guard line) must give
241+ identical results; 25 entries are also compared against the simulator's full (non-lazy) port. Then
242+ all 20,480 entries are re-evaluated on the current sources: any pair below its WCAG threshold
243+ **fails** the check; a pair that is still readable but lost the table's headroom is a warning; a note
244+ counts the entries whose palette-limited pairs are below the headroom (today all of them). `--sample N`
245+ checks N entries per theme instead of all.
246+3. `make_style_table.py --check`: whether this tree would regenerate the same asset. A difference is only
247+ a warning (the theme sources changed since the table was made; the entries still passed step 2). A
248+ collapsed yield (exit status 4, see "Generation") **fails**: the table could not be regenerated.
249+
250+**With or without the palette fix in the tree.** The simulator detects whether the sources already carry
251+overlay A (the palette fix's out-text guard in `fillAccentColors`,
252+`if (!isMyMessagesGradientColorsNear || org.telegram.messenger.multigram.PaletteFix.isRuntimeAccent(this))`,
253+or every overlay value present):
254+
255+- not yet (a plain Forkgram tree): it applies overlay A itself: ThemeColors defaults, `.attheme` values,
256+ accent exclusions, the on-accent hook and the out-text guard;
257+- already there (the MultiGram branch): it takes the ThemeColors and `.attheme` values from the tree (so
258+ an upstream change to them is seen) and adds only the parts that live in Java code the parser does
259+ not read: the accent exclusions, the on-accent hook, the out-text guard. It takes those from
260+ `overlay_A.json`; `palette_fix_check.py` (step 1) checks that `PaletteFix.java` matches it. The app
261+ also applies the on-accent rule to the voice-record glyphs and the send-button glyphs, which overlay A
262+ does not; that only raises contrast, and the model has no pair for those glyphs.
263+
264+Every part of overlay A assigns an absolute value or adds to a set, so applying it to a tree that
265+already has it changes nothing; the self-test checks that on every run. The 12 on-accent ThemeColors
266+defaults, which the palette fix keeps stock, make no difference because the hook sets those keys for
267+every runtime accent.
268+
269+`--overlay apply|in-tree` forces either mode; `TG_REPO=/path/to/checkout` models another tree.
270+
271+### Replaying onto a new Forkgram release
272+
273+Run `bash multigram/tools/check.sh`. If it fails, or warns that entries lost headroom, regenerate the
274+table with `make_style_table.py`, re-run the check and commit the new asset (the header's crc32 changes).
275+If a pair the style cannot move fell below its threshold, regenerating does not help (the generator
276+stalls and names the pair): the palette fix has to change first. If the simulator cannot parse the new
277+Theme.java or ThemeColors.java, it says so; update `sim/tgsrc.py`.
multigram/tools/check.shnew file+32
@@ -0,0 +1,32 @@
1+#!/usr/bin/env bash
2+# MultiGram checks that need no Android SDK. CI runs this from the repository root after the compile.
3+# Requirements: bash and python3 (standard library only). About 2 to 3 minutes on a 4-core runner.
4+set -euo pipefail
5+cd "$(dirname "${BASH_SOURCE[0]}")/../.."
6+
7+if [ -f multigram/tools/palette_fix_check.py ]; then
8+ # The style table is validated against overlay_A.json; this checks that PaletteFix.java, its hooks and the
9+ # theme sources apply exactly that overlay (present once the palette fix is in the tree).
10+ echo "== palette fix: PaletteFix.java, hooks and theme sources match overlay_A.json"
11+ python3 multigram/tools/palette_fix_check.py
12+fi
13+
14+echo "== style table: format and readability on the current theme sources"
15+python3 multigram/tools/check_style_table.py
16+
17+echo "== style table: does this tree regenerate the same asset?"
18+log="$(mktemp)"
19+trap 'rm -f "$log"' EXIT
20+status=0
21+python3 multigram/tools/make_style_table.py --check > "$log" 2>&1 || status=$?
22+case "$status" in
23+ 0) tail -n 1 "$log" ;;
24+ 3) tail -n 1 "$log"
25+ echo "WARNING: make_style_table.py would now generate a different table (the theme sources changed since it was made)." \
26+ "The committed table still passed the readability check above; regenerate it when convenient." ;;
27+ 4) cat "$log"
28+ echo "FAIL: the style table can no longer be generated on these theme sources (see STALLED above)."; exit 1 ;;
29+ *) cat "$log"; echo "FAIL: make_style_table.py exited with status $status"; exit 1 ;;
30+esac
31+
32+echo "== all MultiGram checks passed"
multigram/tools/check_style_table.pynew file+283
@@ -0,0 +1,283 @@
1+#!/usr/bin/env python3
2+"""Check TMessagesProj/src/main/assets/multigram_styles.bin against its format and against the theme sources
3+in this tree (run by check.sh on CI).
4+
5+ python3 multigram/tools/check_style_table.py [--asset PATH] [--procs N] [--sample N] [--overlay MODE]
6+
7+1. Format: header, directory and every record follow README.md; theme keys are bundled base themes of this
8+ tree with the right day/night flag; no two entries of a theme share an accent colour.
9+2. Model self-test: the model gives the same 99 contrasts whether overlay A is applied by the simulator or is
10+ already written into the sources (as the palette fix does), and the per-key palette replay agrees with the
11+ full refreshThemeColors port, on a deterministic sample of entries.
12+3. Readability: every entry (or a deterministic sample of --sample N) is re-evaluated on the current theme
13+ sources plus overlay A. Any pair below its WCAG threshold (4.5 text, 3.0 non-text, 1.15 bubble vs
14+ wallpaper) fails the check; pairs that only lost the generator's headroom are reported as warnings, and
15+ palette-limited pairs (held only to WCAG, style_table.palette_limited) are counted in a note.
16+
17+Exit status 0 when everything passes (warnings allowed), 1 otherwise.
18+"""
19+import argparse
20+import os
21+import sys
22+import time
23+
24+HERE = os.path.dirname(os.path.abspath(__file__))
25+sys.path.insert(0, HERE)
26+
27+import style_table as T # noqa: E402 (puts sim/ on sys.path)
28+import readability as R # noqa: E402
29+import tgsrc # noqa: E402
30+
31+SELF_TEST_ENTRIES = 250 # entries compared across the overlay modes
32+FULL_PORT_ENTRIES = 25 # entries compared lazy vs full palette
33+
34+
35+class Report:
36+ def __init__(self):
37+ self.failures = []
38+ self.warnings = []
39+
40+ def fail(self, msg):
41+ self.failures.append(msg)
42+ print("FAIL: " + msg)
43+
44+ def warn(self, msg):
45+ self.warnings.append(msg)
46+ print("WARNING: " + msg)
47+
48+
49+def check_format(data, m, rep):
50+ """-> [(theme name, [record bytes])] or None when the file cannot be parsed."""
51+ try:
52+ hdr, dirs, recs = T.parse_asset(data)
53+ except T.FormatError as e:
54+ rep.fail("asset format: %s" % e)
55+ return None
56+ print("format: magic %s, version %d, %d base themes, %d records of %d bytes, CRC-32 %08X, %d bytes" % (
57+ hdr["magic"].decode(), hdr["version"], hdr["theme_count"], hdr["record_count"], hdr["record_size"], hdr["crc32"], len(data)))
58+ out = []
59+ kinds = set()
60+ for d in dirs:
61+ name = d["name"]
62+ th = m.themes.get(name)
63+ if th is None:
64+ rep.fail("theme %r is not one of this tree's bundled base themes (%s)" % (name, ", ".join(sorted(m.themes))))
65+ continue
66+ if d["dark"] != th.is_dark:
67+ rep.fail("theme %s: dark flag %s, but the theme is %s" % (name, d["dark"], "dark" if th.is_dark else "light"))
68+ kinds.add(th.is_dark)
69+ rs = recs[d["first"]: d["first"] + d["count"]]
70+ bad = 0
71+ for j, b in enumerate(rs):
72+ probs = T.record_problems(b)
73+ if probs:
74+ bad += 1
75+ if bad <= 5:
76+ rep.fail("%s entry %d: %s" % (name, j, "; ".join(probs)))
77+ if bad > 5:
78+ rep.fail("%s: %d malformed entries in all" % (name, bad))
79+ accents = [b[:4] for b in rs]
80+ if len(set(accents)) != len(accents):
81+ rep.fail("%s: %d entries repeat an accent colour" % (name, len(accents) - len(set(accents))))
82+ presets = set(T.u32(c) for c in T.forbidden_accents(th))
83+ clash = sum(1 for b in rs if int.from_bytes(b[:4], "big") in presets)
84+ if clash:
85+ rep.warn("%s: %d entries use a built-in preset accent colour of this tree" % (name, clash))
86+ if d["count"] != T.PER_THEME:
87+ rep.warn("%s: %d entries, the generator makes %d" % (name, d["count"], T.PER_THEME))
88+ print(" %-12s %-5s %5d entries (records %d-%d)" % (name, "night" if th.is_dark else "day", d["count"], d["first"], d["first"] + d["count"] - 1))
89+ out.append((name, rs))
90+ if kinds != {False, True}:
91+ rep.fail("the table needs at least one day and one night base theme")
92+ return out
93+
94+
95+def sample_indices(n, k):
96+ """k evenly spread indices out of n (all when k >= n)."""
97+ if k >= n:
98+ return list(range(n))
99+ return sorted(set((i * n) // k for i in range(k)))
100+
101+
102+def parser_self_test(parsed, rep):
103+ """The parser must recognise every form of the out-text guard (T.GUARD_FORMS, including the palette fix's real
104+ line) and parse the rest of Theme.java the same way with each."""
105+ ref = dict(parsed["theme_java"])
106+ ref.pop("out_block_guard_id_gt_100", None)
107+ ok = True
108+ for label, line, widened in T.GUARD_FORMS:
109+ try:
110+ tj = T.parse_theme_java_with_guard(line)
111+ except ValueError as e:
112+ rep.fail("self-test: Theme.java with the %s out-text guard does not parse: %s; update sim/tgsrc.py" % (label, e))
113+ ok = False
114+ continue
115+ got = tj.pop("out_block_guard_id_gt_100", None)
116+ if got != widened:
117+ rep.fail("self-test: the parser reads the %s out-text guard as %s; update sim/tgsrc.py OUT_BLOCK_GUARD_RE" % (
118+ label, "widened" if got else "stock"))
119+ ok = False
120+ elif tj != ref:
121+ rep.fail("self-test: Theme.java parses differently with the %s out-text guard" % label)
122+ ok = False
123+ print("self-test: out-text guard forms (%s): %s" % (", ".join(f[0] for f in T.GUARD_FORMS), "recognised" if ok else "NOT RECOGNISED"))
124+ return ok
125+
126+
127+def self_test(entries, mode, rep):
128+ parsed = tgsrc.load()
129+ if not parser_self_test(parsed, rep):
130+ return
131+ baked = T.baked_parsed(parsed)
132+ models = {
133+ "simulator applies overlay A to this tree": T.build_model(parsed, "apply"),
134+ "simulator applies overlay A to this tree with overlay A written in": T.build_model(baked, "apply"),
135+ "overlay A written in, simulator adds only the Java parts": T.build_model(baked, "in-tree"),
136+ }
137+ flat = [(name, b) for name, rs in entries for b in rs]
138+ picks = [flat[i] for i in sample_indices(len(flat), SELF_TEST_ENTRIES)]
139+ ref_name = None
140+ ref = None
141+ idempotent = True
142+ for label, mm in models.items():
143+ res = [T.evaluate(mm, T.decode_record(b, name)) for name, b in picks]
144+ if ref is None:
145+ ref_name, ref = label, res
146+ continue
147+ diff = sum(1 for a, b in zip(ref, res) if a != b)
148+ if diff:
149+ idempotent = False
150+ rep.fail("self-test: %d/%d entries evaluate differently with '%s' than with '%s'; the overlay application is "
151+ "not idempotent, fix style_table.build_model()" % (diff, len(picks), label, ref_name))
152+ if mode == "in-tree":
153+ mm = T.build_model(parsed, "in-tree")
154+ res = [T.evaluate(mm, T.decode_record(b, name)) for name, b in picks]
155+ diff = sum(1 for a, b in zip(ref, res) if a != b)
156+ if diff:
157+ rep.warn("self-test: this tree carries the palette fix but differs from overlay A (%d/%d sampled entries evaluate "
158+ "differently); the tree's own values are used" % (diff, len(picks)))
159+ m, _, _ = T.model(mode)
160+ lazy_full = 0
161+ full_picks = [picks[i] for i in sample_indices(len(picks), FULL_PORT_ENTRIES)]
162+ for name, b in full_picks:
163+ v = T.decode_record(b, name)
164+ if T.evaluate(m, v, lazy=True) != T.evaluate(m, v, lazy=False):
165+ lazy_full += 1
166+ if lazy_full:
167+ rep.fail("self-test: per-key palette replay and the full refreshThemeColors port disagree on %d/%d entries" % (lazy_full, len(full_picks)))
168+ print("self-test: overlay A applied by the simulator vs already in the sources, %d entries: %s; per-key replay vs full "
169+ "port, %d entries: %s" % (len(picks), "identical" if idempotent else "DIFFERENT", len(full_picks),
170+ "identical" if not lazy_full else "DIFFERENT"))
171+
172+
173+def _validate(job):
174+ name, idx, recs, mode, hthr = job
175+ m, _, _ = T.model(mode)
176+ wcag = T.wcag_thresholds()
177+ out = []
178+ for j, b in zip(idx, recs):
179+ res = T.evaluate(m, T.decode_record(b, name))
180+ below = [i for i in range(len(R.PAIRS)) if res[i] < wcag[i]]
181+ eroded = [i for i in range(len(R.PAIRS)) if res[i] < hthr[i] and res[i] >= wcag[i]]
182+ out.append((j, res, below, eroded))
183+ return name, out
184+
185+
186+def check_readability(entries, mode, procs, sample, rep):
187+ m, _, _ = T.model(mode)
188+ jobs = []
189+ for name, rs in entries:
190+ idx = sample_indices(len(rs), sample) if sample else list(range(len(rs)))
191+ hthr = T.headroom_thresholds(m.themes[name])
192+ step = 256
193+ for s in range(0, len(idx), step):
194+ part = idx[s: s + step]
195+ jobs.append((name, part, [rs[j] for j in part], mode, hthr))
196+ if procs > 1:
197+ from multiprocessing import Pool
198+ with Pool(procs) as pool:
199+ done = pool.map(_validate, jobs, chunksize=1)
200+ else:
201+ done = [_validate(j) for j in jobs]
202+ by = {}
203+ for name, out in done:
204+ by.setdefault(name, []).extend(out)
205+ for name, _ in entries:
206+ th = m.themes[name]
207+ lim = T.palette_limited(th)
208+ out = by.get(name, [])
209+ mins = {}
210+ n_below = n_eroded = n_limited = 0
211+ worst = {}
212+ eroded_pairs = {}
213+ limited_pairs = set()
214+ for j, res, below, eroded in out:
215+ short = [i for i in lim if res[i] < T.HEADROOM[R.PAIRS[i]["threshold"]]]
216+ if short:
217+ n_limited += 1
218+ limited_pairs.update(R.PAIRS[i]["name"] for i in short)
219+ for i, p in enumerate(R.PAIRS):
220+ key = ("palette-limited " if i in lim else "") + T.CLASS_NAME[p["threshold"]]
221+ r = res[i] / p["threshold"]
222+ if key not in mins or r < mins[key][0]:
223+ mins[key] = (r, p["name"], res[i], j)
224+ if below:
225+ n_below += 1
226+ for i in below:
227+ if i not in worst or res[i] < worst[i][0]:
228+ worst[i] = (res[i], j)
229+ if eroded and not below:
230+ n_eroded += 1
231+ for i in eroded:
232+ eroded_pairs[R.PAIRS[i]["name"]] = eroded_pairs.get(R.PAIRS[i]["name"], 0) + 1
233+ print(" %-12s %5d entries checked: %d below WCAG, %d more below the generator's thresholds" % (name, len(out), n_below, n_eroded))
234+ if n_limited:
235+ print(" note: %d entries have palette-limited pairs held only to WCAG, below the %s headroom (%s); only the "
236+ "palette fix can give them headroom (README.md, \"Readability rule\")" % (
237+ n_limited, "/".join("%g" % T.HEADROOM[t] for t in sorted(T.HEADROOM, reverse=True)), ", ".join(sorted(limited_pairs))))
238+ for k in sorted(mins):
239+ r, pn, c, j = mins[k]
240+ print(" min %-26s %.4f x WCAG (%s %.3f, entry %d)" % (k, r, pn, c, j))
241+ if n_below:
242+ rep.fail("%s: %d entries are unreadable on the current theme sources: %s" % (name, n_below, ", ".join(
243+ "%s %.3f < %.2f (entry %d)" % (R.PAIRS[i]["name"], c, R.PAIRS[i]["threshold"], j) for i, (c, j) in sorted(worst.items()))))
244+ if n_eroded:
245+ rep.warn("%s: %d entries lost the generator's headroom but still pass WCAG (%s); regenerate the table with "
246+ "make_style_table.py" % (name, n_eroded, ", ".join("%s x%d" % kv for kv in sorted(eroded_pairs.items()))))
247+
248+
249+def main():
250+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
251+ ap.add_argument("--asset", default=T.DEFAULT_ASSET)
252+ ap.add_argument("--procs", type=int, default=os.cpu_count() or 1)
253+ ap.add_argument("--sample", type=int, default=0, help="entries to re-validate per theme (default 0: all of them)")
254+ ap.add_argument("--overlay", choices=T.OVERLAY_MODES, default="auto",
255+ help="auto (default): detect whether the tree already carries overlay A; see style_table.build_model()")
256+ a = ap.parse_args()
257+ t0 = time.time()
258+ rep = Report()
259+ try:
260+ with open(a.asset, "rb") as f:
261+ data = f.read()
262+ except OSError as e:
263+ print("FAIL: cannot read %s: %s" % (a.asset, e))
264+ sys.exit(1)
265+ m, mode, state = T.model(a.overlay)
266+ print("theme sources: %s; overlay A: %s; %s mode (%d/%d overlay values already in the tree, out-text guard %s)" % (
267+ tgsrc.REPO, T.overlay_path(), mode, state["values_in_tree"], state["values"], "present" if state["out_block_guard"] else "absent"))
268+ if mode == "in-tree" and not state["out_block_guard"]:
269+ rep.warn("the tree carries overlay A's values but not the palette fix's out-text guard in Theme.java "
270+ "(`if (!isMyMessagesGradientColorsNear || ...PaletteFix.isRuntimeAccent(this))`)")
271+ if mode == "in-tree" and state["differs"]:
272+ rep.warn("the tree carries the palette fix but %d values differ from overlay A, e.g. %s" % (len(state["differs"]), state["differs"][0]))
273+ entries = check_format(data, m, rep)
274+ if entries:
275+ self_test(entries, mode, rep)
276+ check_readability(entries, mode, a.procs, a.sample, rep)
277+ print("style table check: %s (%d failures, %d warnings, %.1f s)" % (
278+ "FAIL" if rep.failures else "PASS", len(rep.failures), len(rep.warnings), time.time() - t0))
279+ sys.exit(1 if rep.failures else 0)
280+
281+
282+if __name__ == "__main__":
283+ main()
multigram/tools/make_style_table.pynew file+268
@@ -0,0 +1,268 @@
1+#!/usr/bin/env python3
2+"""Regenerate TMessagesProj/src/main/assets/multigram_styles.bin, the table of validated per-install styles.
3+
4+ python3 multigram/tools/make_style_table.py write the asset (same bytes on every run)
5+ python3 multigram/tools/make_style_table.py --check regenerate in memory, compare with the asset
6+ python3 multigram/tools/make_style_table.py --report r.json also write per-theme statistics
7+
8+For each base theme, candidate k uses the seed "<TABLE_SEED>/<theme>/<k>" (style_table.candidate_seed) and the
9+simulator's seeded solver (up to 12 attempts). Candidates are taken in k order; a candidate is kept when the
10+solver finds a style that passes every readability pair (headroom thresholds on the pairs the style controls,
11+plain WCAG on the palette-limited pairs) and no earlier entry of that theme has the same accent colour, until
12+PER_THEME records per theme. The result does not depend on --procs. Format: README.md next to this file.
13+
14+The generator gives up instead of running on when a theme's yield collapses (no entry from the first
15+STALL_CANDIDATES candidates, a yield below --min-yield, or more than per_theme / min_yield + STALL_CANDIDATES
16+candidates): it prints the pairs that failed most often and exits with status 4.
17+
18+Exit status: 0 written / identical, 3 --check found a different asset, 4 the yield collapsed, 1 on errors.
19+"""
20+import argparse
21+import hashlib
22+import json
23+import math
24+import os
25+import sys
26+import time
27+
28+HERE = os.path.dirname(os.path.abspath(__file__))
29+sys.path.insert(0, HERE)
30+
31+import style_table as T # noqa: E402 (puts sim/ on sys.path)
32+import detmath as D # noqa: E402
33+import readability as R # noqa: E402
34+
35+CHUNK = 64
36+STALL_CANDIDATES = 256 # first round per theme; no entry from these = stalled
37+YIELD_CANDIDATES = 1024 # the yield test starts after this many candidates of a theme
38+MIN_YIELD = 0.2 # default --min-yield (today's yield is 0.94 to 0.99)
39+EXIT_STALLED = 4
40+
41+
42+class Stalled(RuntimeError):
43+ pass
44+
45+
46+def _work(job):
47+ theme_name, k0, k1, mode, thr = job
48+ m, _, _ = T.model(mode)
49+ out = []
50+ for k in range(k0, k1):
51+ v, res, st = T.gen_candidate(m, theme_name, k, thr)
52+ if v is None:
53+ out.append((k, None, st.get("reject", {}), st["evals"], 0))
54+ continue
55+ rec = T.encode_record(v)
56+ # the record must decode to exactly the style that was validated
57+ full = [res[i] for i in range(len(R.PAIRS))]
58+ if T.evaluate(m, T.decode_record(rec, theme_name)) != full:
59+ raise RuntimeError("record round trip changed the evaluation: %s candidate %d" % (theme_name, k))
60+ out.append((k, rec, full, st["evals"], v["solver"]["attempt"]))
61+ return theme_name, k0, out
62+
63+
64+def stall_report(t, inf, n_accepted, min_yield):
65+ c = inf["candidates"]
66+ rej = inf["reject"]
67+ stages = sorted(((n, k[1:-1]) for k, n in rej.items() if k.startswith("<")), reverse=True)
68+ pairs = sorted(((n, k) for k, n in rej.items() if not k.startswith("<")), reverse=True)[:8]
69+ return ("%s: %d entries from %d candidates (yield %.1f %%, minimum %.0f %%). Failed attempts of the rejected candidates "
70+ "by stage: %s. Pairs failing most often: %s. A pair the style cannot move is below its threshold, or the "
71+ "headroom cannot be reached any more: run check_style_table.py and see README.md." % (
72+ t, n_accepted, c, 100.0 * n_accepted / max(1, c), 100.0 * min_yield,
73+ ", ".join("%s %d" % (k, n) for n, k in stages) or "none",
74+ ", ".join("%s %d" % (k, n) for n, k in pairs) or "none"))
75+
76+
77+def generate(per_theme, procs, mode="auto", log=print, min_yield=MIN_YIELD):
78+ m, mode, state = T.model(mode)
79+ for tn in T.TABLE_THEMES:
80+ if tn not in m.themes:
81+ raise SystemExit("base theme %r is not a bundled theme in this tree" % tn)
82+ log("overlay A (%s): %s mode, %d/%d overlay values already in the tree, out-text guard %s" % (
83+ T.overlay_path(), mode, state["values_in_tree"], state["values"],
84+ "present" if state["out_block_guard"] else "absent"))
85+ thresholds = {t: T.headroom_thresholds(m.themes[t]) for t in T.TABLE_THEMES}
86+ for t in T.TABLE_THEMES:
87+ lim = sorted(T.palette_limited(m.themes[t]))
88+ log(" %s: %d palette-limited pairs held to plain WCAG: %s" % (t, len(lim), ", ".join(R.PAIRS[i]["name"] for i in lim)))
89+ accepted = {t: [] for t in T.TABLE_THEMES}
90+ results = {t: [] for t in T.TABLE_THEMES}
91+ seen = {t: set() for t in T.TABLE_THEMES}
92+ info = {t: {"candidates": 0, "solver_failed": 0, "duplicates": 0, "evals": 0, "attempts": {}, "reject": {}} for t in T.TABLE_THEMES}
93+ cap = int(math.ceil(per_theme / min_yield)) + STALL_CANDIDATES
94+ next_k = {t: 0 for t in T.TABLE_THEMES}
95+ pool = None
96+ if procs > 1:
97+ from multiprocessing import Pool
98+ pool = Pool(procs)
99+ t0 = time.time()
100+ try:
101+ while True:
102+ open_themes = [t for t in T.TABLE_THEMES if len(accepted[t]) < per_theme]
103+ if not open_themes:
104+ break
105+ stalled = []
106+ for t in open_themes:
107+ c, a = info[t]["candidates"], len(accepted[t])
108+ if (c >= STALL_CANDIDATES and a == 0) or (c >= YIELD_CANDIDATES and a < min_yield * c) or c >= cap:
109+ stalled.append(stall_report(t, info[t], a, min_yield))
110+ if stalled:
111+ raise Stalled("\n".join(stalled))
112+ jobs = []
113+ for t in open_themes:
114+ want = per_theme - len(accepted[t])
115+ c = info[t]["candidates"]
116+ # round size from the yield so far (the first round is small, so a stall shows early); the output
117+ # does not depend on it: candidates are always taken in k order
118+ y = max(min_yield, len(accepted[t]) / c) if c else 1.0
119+ n = int(math.ceil((want / y * 1.05 + 8) / CHUNK)) * CHUNK
120+ if not c:
121+ n = min(n, STALL_CANDIDATES)
122+ left = int(math.ceil(max(0, cap - next_k[t]) / CHUNK)) * CHUNK
123+ n = max(CHUNK, min(n, left))
124+ for k0 in range(next_k[t], next_k[t] + n, CHUNK):
125+ jobs.append((t, k0, k0 + CHUNK, mode, thresholds[t]))
126+ next_k[t] += n
127+ done = pool.map(_work, jobs, chunksize=1) if pool else [_work(j) for j in jobs]
128+ done.sort(key=lambda x: (T.TABLE_THEMES.index(x[0]), x[1]))
129+ for t, _, out in done:
130+ for k, rec, res, evals, attempt in out:
131+ if len(accepted[t]) >= per_theme:
132+ break
133+ inf = info[t]
134+ inf["candidates"] += 1
135+ inf["evals"] += evals
136+ if rec is None:
137+ inf["solver_failed"] += 1
138+ for key, cnt in res.items():
139+ inf["reject"][key] = inf["reject"].get(key, 0) + cnt
140+ continue
141+ accent = rec[:4] # accentColor: every entry of a theme has its own
142+ if accent in seen[t]:
143+ inf["duplicates"] += 1
144+ continue
145+ seen[t].add(accent)
146+ accepted[t].append(rec)
147+ results[t].append(res)
148+ inf["attempts"][attempt] = inf["attempts"].get(attempt, 0) + 1
149+ log(" %.1f s: %s" % (time.time() - t0, ", ".join("%s %d" % (t, len(accepted[t])) for t in T.TABLE_THEMES)))
150+ finally:
151+ if pool:
152+ pool.close()
153+ pool.join()
154+ themes = [(t, m.themes[t].is_dark) for t in T.TABLE_THEMES]
155+ data = T.build_asset(accepted, themes)
156+ return data, accepted, results, info, m
157+
158+
159+def _count(d, k):
160+ d[k] = d.get(k, 0) + 1
161+
162+
163+def statistics(m, accepted, results, info):
164+ """Per-theme report: yield, smallest margins, variety."""
165+ rep = {}
166+ for t in T.TABLE_THEMES:
167+ th = m.themes[t]
168+ own = T.palette_limited(th)
169+ hthr = T.headroom_thresholds(th)
170+ cls = {}
171+ owned_min = {}
172+ for res in results[t]:
173+ for i, p in enumerate(R.PAIRS):
174+ key = ("palette-limited " if i in own else "") + T.CLASS_NAME[p["threshold"]]
175+ r = res[i] / p["threshold"]
176+ if key not in cls or r < cls[key][0]:
177+ cls[key] = (r, p["name"], res[i])
178+ if i in own:
179+ lo_hi = owned_min.setdefault(p["name"], [res[i], res[i]])
180+ lo_hi[0], lo_hi[1] = min(lo_hi[0], res[i]), max(lo_hi[1], res[i])
181+ headroom_ok = all(res[i] >= hthr[i] for res in results[t] for i in range(len(R.PAIRS)))
182+ var = {"hue_bins": {}, "bubble_colours": {}, "wall_colours": {}, "animated": 0, "motion": 0, "rotations": {},
183+ "light_bubbles": 0, "accent_L": [9.0, 0.0]}
184+ for rec in accepted[t]:
185+ v = T.decode_record(rec, t)
186+ a = v["accent"]
187+ L, _, h = D.rgb8_to_oklch((a >> 16) & 0xFF, (a >> 8) & 0xFF, a & 0xFF)
188+ _count(var["hue_bins"], "%03d" % (int(h // 30) % 12 * 30))
189+ var["accent_L"] = [min(var["accent_L"][0], L), max(var["accent_L"][1], L)]
190+ g = v["my_messages_gradient"]
191+ _count(var["bubble_colours"], 1 if (len(g) == 1 and g[0] == v["my_messages_accent"]) else 1 + len(g))
192+ _count(var["wall_colours"], len(v["wallpaper"]["colors"]))
193+ var["animated"] += 1 if v["my_messages_animated"] else 0
194+ var["motion"] += 1 if v["wallpaper"]["motion"] else 0
195+ _count(var["rotations"], v["wallpaper"]["rotation"])
196+ var["light_bubbles"] += 1 if D.luminance(v["my_messages_accent"] & 0xFFFFFF) > 0.4 else 0
197+ inf = info[t]
198+ rep[t] = {
199+ "dark": th.is_dark, "records": len(accepted[t]), "candidates": inf["candidates"],
200+ "solver_failed": inf["solver_failed"], "duplicate_accents": inf["duplicates"],
201+ "evals_per_candidate": round(inf["evals"] / max(1, inf["candidates"]), 2),
202+ "attempts": {str(k): v for k, v in sorted(inf["attempts"].items())},
203+ "all_pairs_meet_generator_thresholds": headroom_ok,
204+ "min_contrast_over_wcag": {k: {"ratio": round(v[0], 4), "pair": v[1], "contrast": round(v[2], 4)} for k, v in sorted(cls.items())},
205+ "palette_limited_pairs_min_max": {k: [round(v[0], 3), round(v[1], 3)] for k, v in sorted(owned_min.items())},
206+ "accent_hue_bins_of_30deg": dict(sorted(var["hue_bins"].items())),
207+ "accent_oklch_L_range": [round(x, 3) for x in var["accent_L"]],
208+ "bubble_colours": {str(k): v for k, v in sorted(var["bubble_colours"].items())},
209+ "light_bubbles": var["light_bubbles"], "animated_bubbles": var["animated"],
210+ "wallpaper_colours": {str(k): v for k, v in sorted(var["wall_colours"].items())},
211+ "parallax_motion": var["motion"], "rotations": {str(k): v for k, v in sorted(var["rotations"].items())},
212+ }
213+ return rep
214+
215+
216+def main():
217+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
218+ ap.add_argument("--out", default=T.DEFAULT_ASSET, help="asset path (default: %(default)s)")
219+ ap.add_argument("--check", action="store_true", help="compare with the asset at --out instead of writing it")
220+ ap.add_argument("--per-theme", type=int, default=T.PER_THEME)
221+ ap.add_argument("--procs", type=int, default=os.cpu_count() or 1)
222+ ap.add_argument("--overlay", choices=T.OVERLAY_MODES, default="auto",
223+ help="auto (default): detect whether the tree already carries overlay A; see style_table.build_model()")
224+ ap.add_argument("--report", help="write per-theme statistics as JSON to this path")
225+ ap.add_argument("--min-yield", type=float, default=MIN_YIELD,
226+ help="give up when fewer than this share of a theme's candidates become entries (default %(default)s)")
227+ a = ap.parse_args()
228+ if not 0.0 < a.min_yield <= 1.0:
229+ ap.error("--min-yield must be in (0, 1]")
230+ t0 = time.time()
231+ try:
232+ data, accepted, results, info, m = generate(a.per_theme, a.procs, a.overlay, min_yield=a.min_yield)
233+ except Stalled as e:
234+ print("STALLED: the generator cannot build the table on these theme sources:\n%s" % e)
235+ sys.exit(EXIT_STALLED)
236+ rep = statistics(m, accepted, results, info)
237+ for t, r in rep.items():
238+ print("%-12s %s %5d records from %5d candidates (%d solver failures, %d repeated accents), generator thresholds met: %s, "
239+ "accent hues per 30 deg: %s" % (t, "night" if r["dark"] else "day ", r["records"], r["candidates"], r["solver_failed"],
240+ r["duplicate_accents"], r["all_pairs_meet_generator_thresholds"],
241+ "/".join(str(x) for x in r["accent_hue_bins_of_30deg"].values())))
242+ for k, v in r["min_contrast_over_wcag"].items():
243+ print(" min %-26s %.4f x WCAG (%s %.3f)" % (k, v["ratio"], v["pair"], v["contrast"]))
244+ if a.report:
245+ with open(a.report, "w") as f:
246+ json.dump(rep, f, indent=1, sort_keys=True)
247+ digest = hashlib.sha256(data).hexdigest()
248+ print("asset: %d bytes, %d records, sha256 %s (%.1f s)" % (len(data), sum(len(v) for v in accepted.values()), digest, time.time() - t0))
249+ if a.check:
250+ try:
251+ with open(a.out, "rb") as f:
252+ old = f.read()
253+ except OSError as e:
254+ print("cannot read %s: %s" % (a.out, e))
255+ sys.exit(1)
256+ if old != data:
257+ print("DIFFERS from %s (sha256 %s)" % (a.out, hashlib.sha256(old).hexdigest()))
258+ sys.exit(3)
259+ print("identical to %s" % a.out)
260+ return
261+ os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True)
262+ with open(a.out, "wb") as f:
263+ f.write(data)
264+ print("wrote %s" % a.out)
265+
266+
267+if __name__ == "__main__":
268+ main()
multigram/tools/sim/README.mdnew file+65
@@ -0,0 +1,65 @@
1+# Style simulator
2+
3+A Python port of Telegram Android's accent engine (Theme.java `fillAccentColors`, the 30-degree
4+re-tint in `changeColorAccent`, outgoing-bubble and wallpaper code, the service pill), written against
5+DrKLO Telegram dc780e81ed1261c369c27870e8e0999a1eb0b600 and adapted to Forkgram (its Monet themes; the
6+palette fix's out-text guard, `|| PaletteFix.isRuntimeAccent(this)` or the research form `|| id > 100`,
7+which mean the same for a generated accent), plus a 99-pair readability model and the seeded style
8+generator. MultiGram uses it to build and check the per-install style table (see `../README.md`).
9+
10+Python 3.9+, standard library only.
11+
12+## Setup
13+
14+None. The engine reads Theme.java, ThemeColors.java and the five bundled `.attheme` files straight from
15+the repository this folder lives in (`TG_REPO`, default: three levels up, the repository root). Point
16+`TG_REPO` at another checkout to model that one instead.
17+
18+The parsed sources are cached in `cache/`, keyed by a hash of those files, so a changed tree re-parses
19+automatically; a cache file is written under a temporary name and renamed, and an unreadable one is
20+parsed again. The cache is not committed (`../.gitignore`).
21+
22+The palette fix the style table relies on, "overlay A", is in `palette-fix/overlay_A.json`; overlay B
23+(`overlay_B.json`, the rejected variant) is kept for the comparisons below.
24+
25+## Commands
26+
27+Run these from this folder.
28+
29+| What | Command |
30+|---|---|
31+| One install's style on the fixed palette (overlay A) | `python3 -c "import json, canvas as CV, style_sim as S; S.RULE['rule']='wcag'; print(json.dumps(S.genome('my-seed', mode='wcag', m=CV.overlay('A')), indent=1))"` |
32+| One install's style on the stock palette ("no worse than stock") | `python3 style_sim.py --seed my-seed --mode strict` |
33+| Measure N installs per base theme on the fixed palette | `python3 canvas.py --measure 5000 --variant A` |
34+| Same, with ablations | `--variant B`, `B-no-c` (no on-accent hook), `B-no-d` (no bubble guard); `--service off` drops the date-pill rule |
35+| Stock default accents, stock vs fixed palette | `python3 canvas.py --stock` |
36+| Rebuild the overlay from scratch | `python3 canvas.py --build` (writes `palette-fix/overlay_*.json`) |
37+| Stock-palette measurement (naive vs constrained generators) | `python3 style_sim.py --measure 5000` |
38+| Stock contrast table for all pairs | `python3 style_sim.py --baseline` |
39+
40+`canvas.py` reads and writes `palette-fix/` by default; set `CANVAS_OUT` to use another folder.
41+`--procs N` sets worker processes for measurements.
42+
43+The style table itself is made and checked by `../make_style_table.py` and `../check_style_table.py`,
44+which use this simulator as a library (`../style_table.py`).
45+
46+## How it generates a style
47+
48+- Seed: SHA-256 of the seed string, first 8 bytes big-endian, into SplitMix64. Three forks: day,
49+ night, misc. The same seed gives the same style on every machine (math is done in `detmath.py`, so no
50+ platform floating-point differences).
51+- Colour space: OKLCH. The accent, bubble and wallpaper are drawn inside bands, then lightness is
52+ solved (up to 8 bisection steps, up to 12 attempts) until every pair passes. If nothing passes, the
53+ stock accent is used; on the fixed palette that never happened in 125,000 installs.
54+- Rules: `strict` = every pair at least `min(WCAG threshold, stock contrast)`; `wcag` = plain WCAG
55+ (4.5 text, 3.0 non-text, 1.15 bubble vs wallpaper, 4.5 date-pill text).
56+
57+## Limits
58+
59+- The solver lands exactly on the threshold. The style table therefore solves against 4.6 / 3.1 / 1.19
60+ instead (`../README.md`, "Readability rule").
61+- `genome()` and the measurements may pick one of the built-in pattern wallpapers. A real Telegram
62+ pattern slug makes the app fetch it from Telegram's servers (`account.installWallPaper`), so the style
63+ table never uses patterns: its generator draws no pattern and validates the plain gradient.
64+- The bubble-vs-wallpaper 1.15:1 floor is a project threshold, not WCAG.
65+- Only a device build confirms the result.
multigram/tools/sim/canvas.pynew file+868
@@ -0,0 +1,868 @@
1+#!/usr/bin/env python3
2+"""canvas-fix: 'fix the canvas, then randomize'.
3+
4+Builds a small overlay for the five bundled base themes, applies it to the bit-exact engine port
5+(engine.Model(parsed, spec)) and runs the seeded OKLCH generator with FREE accents on all five base
6+themes under plain WCAG (4.5 text / 3.0 non-text / 1.15 bubble separation / 4.5 service text).
7+
8+ python3 canvas.py --build overlay spec for variants A and B -> overlay_A.json / overlay_B.json + table
9+ python3 canvas.py --stock stock default accent of each theme, stock model vs overlay model
10+ python3 canvas.py --crosscheck N lazy vs full getColor() on the overlay models
11+ python3 canvas.py --measure N --variant V [--service on|off]
12+ N-seed style measurement (free accents, plain WCAG); V = A | B | B-no-c | B-no-d
13+ python3 canvas.py --gallery --variant B gallery.json (36 genomes + 3 naive failures)
14+
15+Overlay parts (each maps 1:1 to an app change):
16+ (a) neutral/secondary keys failing WCAG on their background: same OKLCH hue+chroma, nearest lightness that
17+ meets the threshold (translucent keys: same RGB, alpha raised if that reaches the threshold).
18+ -> .attheme line (bundled theme) or ThemeColors default (Blue preset 99 serves ThemeColors defaults).
19+ A key that is neutral on one theme but accent-coloured on another (chats_attachMessage: grey on Dark Blue,
20+ blue on the light themes) cannot join the global exclusion set; on the theme where it is neutral it gets
21+ the nearest 8-bit grey instead (HSV s = 0 is outside every 30-degree re-tint window).
22+ (b) those keys + surfaces that should not follow the accent -> Theme.themeAccentExclusionKeys.
23+ Variant A: only windowBackgroundGray (Day's settings background) is added; Dark Blue surfaces keep
24+ following the accent. Variant B: the 8 surface keys are excluded too (surfaces stay stock).
25+ (c) on-accent content rule: after fillAccentColors, keys drawn on accent fills become white or NEAR_BLACK,
26+ whichever has the higher WCAG contrast with the fill; the muted unread badge (same text paint) gets a
27+ precomputed neutral fill that passes with the chosen text colour.
28+ (d) outgoing in-bubble secondary texts: the existing fillAccentColors black/white block (runs whenever
29+ myMessagesGradientAccentColor1 != 0, which every generated bubble sets), with its guard
30+ `!isMyMessagesGradientColorsNear` widened to `|| id > 100` so a runtime accent whose light bubble is near
31+ the theme's own bubble (Blue: 0xFFE6F2FC) still gets it, + the generator's bubble bands.
32+"""
33+import argparse
34+import copy
35+import json
36+import math
37+import os
38+import sys
39+import time
40+
41+HERE = os.path.dirname(os.path.abspath(__file__))
42+sys.path.insert(0, HERE)
43+
44+import detmath as D # noqa: E402
45+import engine as E # noqa: E402
46+import readability as R # noqa: E402
47+import style_sim as S # noqa: E402
48+import tgsrc # noqa: E402
49+
50+OUT = os.path.abspath(os.environ.get("CANVAS_OUT") or os.path.join(HERE, "palette-fix")) # overlay_*.json live here
51+NEAR_BLACK = E.s32(0xFF050505) # with white, guarantees max(contrast) >= 4.51 for ANY fill (crossover at Y ~ 0.182)
52+WHITE = E.s32(0xFFFFFFFF)
53+
54+ON_ACCENT_PAIRS = [
55+ # (content key, accent fill key) where it is drawn
56+ ("key_chats_actionIcon", "key_chats_actionBackground"), # chat-list FAB pencil
57+ ("key_chats_unreadCounterText", "key_chats_unreadCounter"), # unread badge digits (DialogCell)
58+ ("key_featuredStickers_buttonText", "key_featuredStickers_addButton"), # filled buttons (Join/Add/Start...)
59+ ("key_checkboxSquareCheck", "key_checkboxSquareBackground"), # square checkbox mark
60+ ("key_dialogRoundCheckBoxCheck", "key_dialogRoundCheckBox"), # share-sheet round checkbox mark
61+ ("key_chat_goDownButtonCounter", "key_chat_goDownButtonCounterBackground"), # CounterView on go-down button
62+ ("key_dialogCheckboxSquareCheck", "key_dialogCheckboxSquareBackground"), # CheckBoxSquare in alerts
63+ ("key_chat_attachCheckBoxCheck", "key_chat_attachCheckBoxBackground"), # photo picker check
64+ ("key_chat_inMediaIcon", "key_chat_inLoader"), # incoming play/download glyph
65+ ("key_chat_inContactIcon", "key_chat_inContactBackground"), # incoming contact glyph
66+ ("key_picker_badgeText", "key_picker_badge"), # picker Done badge count
67+ ("key_dialogFloatingIcon", "key_dialogFloatingButton"), # floating send button glyph
68+]
69+MUTED_KEY = "key_chats_unreadCounterMuted"
70+MUTED_TEXT = "key_chats_unreadCounterText"
71+
72+# Suite pairs that may be neutral (grey/near-white fg on a canvas surface). A pair is treated as neutral on a
73+# theme when its stock fg (composited on its bg) has OKLCH chroma < 0.05 there.
74+NEUTRAL_CANDIDATES = ["list_text", "list_gray_text", "list_gray_text2", "dialog_text", "chats_name", "chats_preview",
75+ "chats_preview_sender", "chats_date", "actionbar_title", "actionbar_icons", "actionbar_subtitle",
76+ "in_text", "in_time", "in_reply_text", "in_file_info", "composer_text", "composer_hint",
77+ "composer_icons", "settings_footnote", "chats_attach_label", "popup_menu_item", "pinned_text",
78+ "in_file_name", "in_views"]
79+NEUTRAL_CHROMA = 0.05
80+
81+SURFACES_B = ["key_windowBackgroundWhite", "key_windowBackgroundGray", "key_actionBarDefault", "key_dialogBackground",
82+ "key_chat_messagePanelBackground", "key_chat_topPanelBackground", "key_chat_inBubble",
83+ "key_actionBarDefaultSubmenuBackground"]
84+SURFACES_A = ["key_windowBackgroundGray"]
85+
86+ASSET = {"Blue": "bluebubbles.attheme", "Dark Blue": "darkblue.attheme", "Arctic Blue": "arctic.attheme",
87+ "Day": "day.attheme", "Night": "night.attheme"}
88+
89+
90+def hx(c):
91+ return "#%08X" % (c & 0xFFFFFFFF)
92+
93+
94+def oklch_of(c):
95+ return D.rgb8_to_oklch((c >> 16) & 0xFF, (c >> 8) & 0xFF, c & 0xFF)
96+
97+
98+# ------------------------------------------------------------------------------------------
99+# accent sweep used to size the overlay against accent-tinted surfaces
100+# ------------------------------------------------------------------------------------------
101+
102+def sweep_accents(step=10, Ls=(0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.92), Cs=(0.07, 0.12, 0.17)):
103+ out = []
104+ for h in range(0, 360, step):
105+ for L in Ls:
106+ for Cc in Cs:
107+ out.append(S.argb_from_oklch(L, Cc, float(h)))
108+ return out
109+
110+
111+def gen_palettes(theme, accents):
112+ return [E.palette(theme, E.Accent(id=S.GEN_ACCENT_ID, accentColor=a), S.GEN_ACCENT_ID) for a in accents]
113+
114+
115+def nominal_palette(theme):
116+ """Generated-accent path with accentColor == accentBaseColor: no re-tint, currentColors = the theme's own
117+ values (what an excluded key shows under every generated accent)."""
118+ return E.palette(theme, E.Accent(id=S.GEN_ACCENT_ID, accentColor=theme.accentBaseColor), S.GEN_ACCENT_ID)
119+
120+
121+def pair_surfaces(pal, p):
122+ ev = R.Evaluator(pal)
123+ return ev.surface(p["bg"])
124+
125+
126+def pair_contrast_with(fg, surfs):
127+ return min(D.contrast(R.over(fg, s), s) for s in surfs)
128+
129+
130+# ------------------------------------------------------------------------------------------
131+# nearest-lightness fix
132+# ------------------------------------------------------------------------------------------
133+
134+def fix_color(fg, surfs, thr):
135+ """Smallest change of fg that reaches thr against every surface sample.
136+ Opaque fg: same OKLCH hue and chroma, nearest L. Translucent fg: same RGB, alpha raised if that reaches thr,
137+ otherwise the composite (on the first surface) gets the OKLCH-L fix and becomes opaque."""
138+ if pair_contrast_with(fg, surfs) >= thr:
139+ return fg, "unchanged"
140+ a = (fg >> 24) & 0xFF
141+ if a < 255:
142+ for na in range(a + 1, 256):
143+ c = E.s32((na << 24) | (fg & 0xFFFFFF))
144+ if pair_contrast_with(c, surfs) >= thr:
145+ return c, "alpha 0x%02X->0x%02X" % (a, na)
146+ base = E.s32(0xFF000000 | R.over(fg, surfs[0]))
147+ else:
148+ base = fg
149+ L0, C0, h0 = oklch_of(base)
150+ yb = sum(D.luminance(s) for s in surfs) / len(surfs)
151+ lighter = D.luminance(base & 0xFFFFFF) > yb
152+ Lend = 1.0 if lighter else 0.0
153+
154+ def col(t):
155+ L = L0 + t * (Lend - L0)
156+ return S.argb_from_oklch(L, C0, h0)
157+
158+ lo, hi = 0.0, 1.0
159+ if pair_contrast_with(col(1.0), surfs) < thr:
160+ raise ValueError("cannot reach %.2f from %s" % (thr, hx(fg)))
161+ for _ in range(60):
162+ mid = (lo + hi) / 2
163+ if pair_contrast_with(col(mid), surfs) >= thr:
164+ hi = mid
165+ else:
166+ lo = mid
167+ t = hi
168+ c = col(t)
169+ while pair_contrast_with(c, surfs) < thr: # quantisation guard
170+ t = min(1.0, t + 1e-4)
171+ c = col(t)
172+ L1 = L0 + t * (Lend - L0)
173+ return c, "OKLCH L %.3f->%.3f (C %.3f h %.1f)" % (L0, L1, C0, h0)
174+
175+
176+def fix_grey(fg, surfs, thr):
177+ """Per-theme 'exclusion' by data: an 8-bit grey (r == g == b, HSV s = 0, SkRGBToHSV hue 0) is never re-tinted
178+ by changeColorAccent on these base themes (Theme.java:6182-6184: |0 - base hue ~207..216| > 30). Nearest
179+ grey level (in OKLCH L) to fg's composite that reaches thr against every surface sample."""
180+ assert (fg >> 24) & 0xFF == 0xFF, "grey route is for opaque keys"
181+ L0 = oklch_of(fg)[0]
182+ best = None
183+ for g in range(256):
184+ c = E.s32(0xFF000000 | (g << 16) | (g << 8) | g)
185+ if pair_contrast_with(c, surfs) >= thr:
186+ d = abs(oklch_of(c)[0] - L0)
187+ if best is None or d < best[0]:
188+ best = (d, c)
189+ if best is None:
190+ raise ValueError("no grey reaches %.2f" % thr)
191+ c = best[1]
192+ return c, "grey: OKLCH C %.3f->0, L %.3f->%.3f (HSV s=0: never re-tinted)" % (oklch_of(fg)[1], L0, oklch_of(c)[0])
193+
194+
195+# ------------------------------------------------------------------------------------------
196+# overlay builder
197+# ------------------------------------------------------------------------------------------
198+
199+def muted_pins(m):
200+ k = m.K[MUTED_KEY]
201+ return sorted(m.key_names[a] for a, b in m.fallback.items() if b == k)
202+
203+
204+def classify_neutral(theme, pal):
205+ out = []
206+ for p in R.PAIRS:
207+ if p["name"] not in NEUTRAL_CANDIDATES:
208+ continue
209+ surfs = pair_surfaces(pal, p)
210+ fg = pal.get(p["fg"])
211+ comp = R.over(fg, surfs[0])
212+ L, Cc, h = oklch_of(comp)
213+ if Cc < NEUTRAL_CHROMA:
214+ out.append(p)
215+ return out
216+
217+
218+def accent_coloured_keys(stock_model):
219+ """Keys that follow the accent on at least one base theme with a chromatic (OKLCH C >= NEUTRAL_CHROMA)
220+ theme value there. Two probe accents decide 'follows' (as style_sim.affected_keys does)."""
221+ out = set()
222+ for tn in S.ALL_THEMES:
223+ th = stock_model.themes[tn]
224+ pa = E.palette(th, E.Accent(id=S.GEN_ACCENT_ID, accentColor=S.argb_from_oklch(0.6, 0.12, 30.0)), S.GEN_ACCENT_ID)
225+ pb = E.palette(th, E.Accent(id=S.GEN_ACCENT_ID, accentColor=S.argb_from_oklch(0.6, 0.12, 150.0)), S.GEN_ACCENT_ID)
226+ nom = nominal_palette(th)
227+ for p in R.PAIRS:
228+ k = p["fg"]
229+ if k.startswith("@") or ("key_" + k) in out:
230+ continue
231+ if pa.get(k) != pb.get(k) and oklch_of(nom.get(k))[1] >= NEUTRAL_CHROMA:
232+ out.add("key_" + k)
233+ return out
234+
235+
236+def base_spec(variant, stock_model):
237+ surfaces = SURFACES_B if variant == "B" else SURFACES_A
238+ spec = {"tag": "canvas-%s" % variant, "variant": variant, "defaults": {}, "attheme": {a: {} for a in ASSET.values()},
239+ "exclusions": list(surfaces),
240+ "hook": {"near_black": NEAR_BLACK & 0xFFFFFFFF, "pairs": [list(x) for x in ON_ACCENT_PAIRS], "out_block_custom": True,
241+ "muted": {"key": MUTED_KEY, "text": MUTED_TEXT, "pins": muted_pins(stock_model), "table": {}}}}
242+ return spec
243+
244+
245+def build(variant, sweep_step=10, log=print):
246+ parsed = tgsrc.load()
247+ stock = E.model()
248+ spec = base_spec(variant, stock)
249+ rows = [] # overlay table rows
250+ K = stock.K
251+
252+ # --- phase 1: Blue preset 99 (fresh-install default) serves ThemeColors defaults for main keys ------------
253+ th = stock.themes["Blue"]
254+ sp = R.stock_palette(th)
255+ for p in classify_neutral(th, sp):
256+ surfs = pair_surfaces(sp, p)
257+ fg = sp.get(p["fg"])
258+ c0 = pair_contrast_with(fg, surfs)
259+ if c0 >= p["threshold"]:
260+ continue
261+ new, how = fix_color(fg, surfs, p["threshold"])
262+ key = "key_" + p["fg"]
263+ spec["defaults"][key] = new & 0xFFFFFFFF
264+ rows.append({"theme": "Blue (preset 99)", "key": p["fg"], "pair": p["name"], "stock": hx(fg), "new": hx(new),
265+ "L_stock": oklch_of(E.s32(0xFF000000 | R.over(fg, surfs[0])))[0], "L_new": oklch_of(E.s32(0xFF000000 | R.over(new, surfs[0])))[0],
266+ "before": c0, "after": pair_contrast_with(new, surfs), "thr": p["threshold"], "how": how,
267+ "where": "ThemeColors.java:%s default" % parsed["themecolors_java"]["default_lines"].get(key, "?"),
268+ "reason": "fails WCAG on the stock default theme"})
269+ if key not in spec["exclusions"]:
270+ spec["exclusions"].append(key)
271+
272+ # --- phase 2: generated accents (id 101) on every base theme, sized against an accent sweep --------------
273+ accents = sweep_accents(sweep_step)
274+ # themeAccentExclusionKeys is ONE global set (Theme.java:3496): a key that is accent-coloured on some base
275+ # theme (follows the accent there with OKLCH C >= NEUTRAL_CHROMA, e.g. chats_attachMessage on the light
276+ # themes) must not be excluded, or it freezes at its stock blue there. On the theme where it is neutral it
277+ # gets an 8-bit grey instead (fix_grey): HSV s = 0 puts it outside the 30-degree re-tint window, which is a
278+ # per-theme exclusion expressed in the .attheme alone.
279+ accent_coloured = accent_coloured_keys(stock)
280+ for it in range(6):
281+ m = E.Model(parsed, spec)
282+ changed = False
283+ for tn in S.ALL_THEMES:
284+ th = m.themes[tn]
285+ nom = nominal_palette(th)
286+ stock_th = stock.themes[tn]
287+ neutral = classify_neutral(stock_th, R.stock_palette(stock_th) if tn != "Blue" else nominal_palette(stock_th))
288+ pals = [nom] + gen_palettes(th, accents)
289+ if tn != "Blue":
290+ pals.append(R.stock_palette(th))
291+ for p in neutral:
292+ key = "key_" + p["fg"]
293+ mixed = key in accent_coloured
294+ allsurfs = []
295+ seen = set()
296+ worst = 1e9
297+ for pl in pals:
298+ ss = pair_surfaces(pl, p)
299+ worst = min(worst, pair_contrast_with(pl.get(p["fg"]), ss))
300+ for s_ in ss:
301+ if s_ not in seen:
302+ seen.add(s_)
303+ allsurfs.append(s_)
304+ if worst >= p["threshold"]:
305+ continue
306+ # the key will be excluded: its value under every generated accent = nominal value
307+ fg_nom = nom.get(p["fg"])
308+ # order surfaces: nominal first (reference for the translucent->opaque route)
309+ ref = pair_surfaces(nom, p)
310+ surfs = list(ref) + [x for x in allsurfs if x not in ref]
311+ new, how = (fix_grey if mixed else fix_color)(fg_nom, surfs, p["threshold"])
312+ asset = ASSET[tn]
313+ if new == fg_nom and (key in spec["exclusions"] or mixed):
314+ continue
315+ ki = m.K[key]
316+ fb = m.fallback.get(ki, -1)
317+ self_resolving = ki in th.no_accent or not (fb >= 0 and fb in th.no_accent)
318+ ln = parsed["atthemes"][asset]["values"].get(key)
319+ if new == fg_nom and self_resolving:
320+ where = "exclusion only" # the value is right; only the accent re-tint must stop
321+ else:
322+ spec["attheme"][asset][key] = new & 0xFFFFFFFF
323+ where = ("%s:%d change" % (asset, ln[1])) if ln else ("%s add line" % asset)
324+ if key not in spec["exclusions"] and not mixed:
325+ spec["exclusions"].append(key)
326+ stock_c = pair_contrast_with(R.stock_palette(stock_th).get(p["fg"]) if tn != "Blue" else nominal_palette(stock_th).get(p["fg"]),
327+ pair_surfaces(R.stock_palette(stock_th) if tn != "Blue" else nominal_palette(stock_th), p))
328+ rows = [r for r in rows if not (r["theme"] == tn and r["key"] == p["fg"])]
329+ rows.append({"theme": tn, "key": p["fg"], "pair": p["name"], "stock": hx(fg_nom), "new": hx(new),
330+ "before": stock_c, "before_sweep_worst": worst, "L_stock": oklch_of(E.s32(0xFF000000 | R.over(fg_nom, ref[0])))[0],
331+ "L_new": oklch_of(E.s32(0xFF000000 | R.over(new, ref[0])))[0],
332+ "after": pair_contrast_with(new, surfs), "thr": p["threshold"], "how": how, "where": where,
333+ "reason": ("fails WCAG at stock" if stock_c < p["threshold"] else "falls below WCAG under accent re-tint")
334+ + ("; accent-coloured on the light themes, so not in the global exclusion set: grey value instead" if mixed else "")})
335+ changed = True
336+ log(" build %s iteration %d: %d rows" % (variant, it + 1, len(rows)))
337+ if not changed:
338+ break
339+
340+ # --- muted unread badge: precomputed neutral fill per theme for white / near-black digits ---------------
341+ m = E.Model(parsed, spec)
342+ for tn in S.ALL_THEMES:
343+ th = m.themes[tn]
344+ nom = nominal_palette(th)
345+ page = nom.get("windowBackgroundWhite")
346+ m0 = nom._get_with(nom._base_entry, m.K[MUTED_KEY]) # theme's own muted fill (before the hook)
347+ m0 = E.s32(0xFF000000 | R.over(m0, page))
348+ fw, hw = fix_color(m0, [WHITE & 0xFFFFFF], R.TEXT)
349+ fb, hb = fix_color(m0, [NEAR_BLACK & 0xFFFFFF], R.TEXT)
350+ spec["hook"]["muted"]["table"][tn] = [fw & 0xFFFFFFFF, fb & 0xFFFFFFFF]
351+ rows.append({"theme": tn, "key": MUTED_KEY[4:] + " (hook, white digits)", "pair": "chats_muted_badge_text", "stock": hx(m0), "new": hx(fw),
352+ "before": D.contrast(0xFFFFFF, m0 & 0xFFFFFF), "after": D.contrast(0xFFFFFF, fw & 0xFFFFFF), "thr": R.TEXT, "how": hw,
353+ "where": "hook table", "reason": "muted badge shares chats_unreadCounterText"})
354+ rows.append({"theme": tn, "key": MUTED_KEY[4:] + " (hook, near-black digits)", "pair": "chats_muted_badge_text", "stock": hx(m0), "new": hx(fb),
355+ "before": D.contrast(NEAR_BLACK & 0xFFFFFF, m0 & 0xFFFFFF), "after": D.contrast(NEAR_BLACK & 0xFFFFFF, fb & 0xFFFFFF), "thr": R.TEXT, "how": hb,
356+ "where": "hook table", "reason": "muted badge shares chats_unreadCounterText"})
357+
358+ # --- Blue preset 99: the hook cannot reach it (getColor serves ThemeColors defaults), so bake the rule ----
359+ m = E.Model(parsed, spec)
360+ th = m.themes["Blue"]
361+ sp = R.stock_palette(th)
362+ page = sp.get("windowBackgroundWhite")
363+ for content, fill in ON_ACCENT_PAIRS:
364+ v = E.on_accent_color(sp.get(fill[4:]), page, NEAR_BLACK)
365+ old = sp.get(content[4:])
366+ if v != old:
367+ spec["defaults"][content] = v & 0xFFFFFFFF
368+ rows.append({"theme": "Blue (preset 99)", "key": content[4:], "pair": "on-accent (%s)" % fill[4:], "stock": hx(old), "new": hx(v),
369+ "before": D.contrast(R.over(old, R.over(sp.get(fill[4:]), page)), R.over(sp.get(fill[4:]), page)),
370+ "after": D.contrast(R.over(v, R.over(sp.get(fill[4:]), page)), R.over(sp.get(fill[4:]), page)), "thr": 0,
371+ "how": "on-accent rule baked", "where": "ThemeColors.java:%s default" % parsed["themecolors_java"]["default_lines"].get(content, "?"),
372+ "reason": "preset 99 reads defaults; rule result for the default fill"})
373+ text = E.s32(spec["defaults"].get(MUTED_TEXT, sp.get(MUTED_TEXT[4:]) & 0xFFFFFFFF))
374+ w, b = spec["hook"]["muted"]["table"]["Blue"]
375+ newm = w if text == WHITE else b
376+ for dep in spec["hook"]["muted"]["pins"]:
377+ if stock.defaults[stock.K[dep]] == 0:
378+ spec["defaults"][dep] = stock.get_default_color(stock.K[dep]) & 0xFFFFFFFF
379+ if E.s32(newm) != sp.get(MUTED_KEY[4:]):
380+ spec["defaults"][MUTED_KEY] = newm & 0xFFFFFFFF
381+ return spec, rows
382+
383+
384+def overlay(variant):
385+ """'A' / 'B', optionally with an ablation suffix: '-no-c' (on-accent rule and muted table off: no hook
386+ writes, the Blue preset-99 bake of the content keys dropped), '-no-d' (black/white out-text block keeps
387+ its stock isMyMessagesGradientColorsNear condition)."""
388+ base, _, abl = variant.partition("-")
389+ path = os.path.join(OUT, "overlay_%s.json" % base)
390+ with open(path) as f:
391+ spec = json.load(f)
392+ if abl:
393+ spec["tag"] += "-" + abl
394+ if abl == "no-c":
395+ spec["hook"]["pairs"] = []
396+ spec["hook"]["muted"] = None
397+ for content, _fill in ON_ACCENT_PAIRS:
398+ spec["defaults"].pop(content, None)
399+ elif abl == "no-d":
400+ spec["hook"]["out_block_custom"] = False
401+ else:
402+ raise ValueError(variant)
403+ return E.overlay_model(spec)
404+
405+
406+def fmt_rows(rows):
407+ lines = [" %-18s %-34s %-24s %-11s -> %-11s %6s -> %6s %4s %-44s %-32s %s" % (
408+ "theme", "key", "pair", "stock", "new", "c_bef", "c_aft", "thr", "change", "where", "reason")]
409+ for r in rows:
410+ lines.append(" %-18s %-34s %-24s %-11s -> %-11s %6.2f -> %6.2f %4.1f %-44s %-32s %s" % (
411+ r["theme"], r["key"], r["pair"], r["stock"], r["new"], r["before"], r["after"], r["thr"], r["how"], r["where"], r["reason"]))
412+ return "\n".join(lines)
413+
414+
415+# ------------------------------------------------------------------------------------------
416+# stock default accent: stock model vs overlay model, plain WCAG
417+# ------------------------------------------------------------------------------------------
418+
419+def failing_wcag(res):
420+ return [i for i, c in res.items() if c < R.PAIRS[i]["threshold"]]
421+
422+
423+def stock_check(variants=("A", "B")):
424+ lines = ["STOCK DEFAULT ACCENT UNDER PLAIN WCAG (4.5 text / 3.0 non-text / 1.15 separation / 4.5 service), %d pairs" % len(R.PAIRS)]
425+ models = [("stock", E.model())] + [("overlay " + v, overlay(v)) for v in variants]
426+ res = {}
427+ for tag, m in models:
428+ for tn in S.ALL_THEMES:
429+ ev = R.Evaluator(R.stock_palette(m.themes[tn]))
430+ res[(tag, tn)] = ev.all()
431+ lines.append(" %-16s" % "failing pairs" + "".join("%13s" % t for t in S.ALL_THEMES))
432+ for tag, _ in models:
433+ lines.append(" %-16s" % tag + "".join("%13d" % len(failing_wcag(res[(tag, tn)])) for tn in S.ALL_THEMES))
434+ lines.append("")
435+ for tag, _ in models[1:]:
436+ lines.append(" still failing with %s (pair: stock -> overlay contrast):" % tag)
437+ for tn in S.ALL_THEMES:
438+ f = failing_wcag(res[(tag, tn)])
439+ lines.append(" %-12s %2d: %s" % (tn, len(f), ", ".join("%s %.2f->%.2f" % (R.PAIRS[i]["name"], res[("stock", tn)][i], res[(tag, tn)][i]) for i in f)))
440+ fixed = {}
441+ for tn in S.ALL_THEMES:
442+ fixed[tn] = [i for i in failing_wcag(res[("stock", tn)]) if i not in failing_wcag(res[(tag, tn)])]
443+ lines.append(" fixed by %s (count per theme): %s" % (tag, ", ".join("%s %d" % (tn, len(fixed[tn])) for tn in S.ALL_THEMES)))
444+ lines.append("")
445+ return "\n".join(lines), res
446+
447+
448+# ------------------------------------------------------------------------------------------
449+# lazy vs full crosscheck of the overlay model (hook included)
450+# ------------------------------------------------------------------------------------------
451+
452+def crosscheck(variant, n=150):
453+ m = overlay(variant)
454+ bad = 0
455+ total = 0
456+ near_seen = [0]
457+ for tn in S.ALL_THEMES:
458+ th = m.themes[tn]
459+ rng = S.SplitMix64(S.seed_state("xcheck/" + variant + "/" + tn))
460+ accs = [th.stock_accent()]
461+ for k in range(n):
462+ acc = E.Accent(id=S.GEN_ACCENT_ID, accentColor=S.rand_rgb(rng))
463+ if k % 3 == 0:
464+ acc.myMessagesAccentColor = S.rand_rgb(rng)
465+ acc.myMessagesGradientAccentColor1 = S.rand_rgb(rng)
466+ elif k % 3 == 1:
467+ # light bubble in the accent's hue: exercises isMyMessagesGradientColorsNear (rule (d)) on Blue
468+ h = oklch_of(acc.accentColor)[2]
469+ acc.myMessagesAccentColor = S.argb_from_oklch(0.93, 0.03, h)
470+ acc.myMessagesGradientAccentColor1 = S.argb_from_oklch(0.95, 0.03, h + 10.0)
471+ accs.append(acc)
472+ for acc in accs:
473+ lz = E.palette(th, acc, acc.id, lazy=True)
474+ fu = E.palette(th, acc, acc.id, lazy=False)
475+ near_seen[0] += 1 if lz.is_near else 0
476+ for key in range(m.n):
477+ total += 1
478+ if lz.get(key) != fu.get(key):
479+ bad += 1
480+ return bad, total, near_seen[0]
481+
482+
483+# ------------------------------------------------------------------------------------------
484+# measurement: free accents on all five base themes, plain WCAG
485+# ------------------------------------------------------------------------------------------
486+
487+def _near_black(c):
488+ return (c & 0xFFFFFFFF) == (NEAR_BLACK & 0xFFFFFFFF)
489+
490+
491+def _measure_chunk(args):
492+ variant, service, tn, start, stop = args
493+ S.RULE["rule"] = "wcag"
494+ S.RULE["service"] = service
495+ m = overlay(variant)
496+ th = m.themes[tn]
497+ thr = S.thresholds(th, 0.0, "wcag")
498+ st = {"n": 0, "pass": 0, "first": 0, "attempts": 0, "max_attempts": 0, "fallback": 0, "fallback_pass": 0, "evals": 0,
499+ "reject": {}, "fail_pairs": {}, "min_margin": 9.0, "min_margin_pair": "", "accents": set(), "hue_bins": set(), "bubbles": set(),
500+ "walls": set(), "light_bubble": 0, "gradient_bubble": 0, "animated": 0, "kinds": {}, "pattern": 0, "negative": 0,
501+ "accent_L": [], "wall_L": {}, "nb": {}, "naive_pass": 0, "naive_fail_pairs": {}, "service_scores": [], "service_lite_scores": []}
502+ idx_srv = R.PAIR_INDEX["service_text"]
503+ idx_srvl = R.PAIR_INDEX["service_text_lite"]
504+ for k in range(start, stop):
505+ seed = "measure-%d" % k
506+ rng = S.SplitMix64(S.seed_state(seed + "/c_wcag/" + tn))
507+ stats = {"evals": 0, "reject": st["reject"]}
508+ v, res = S.gen_variant_c(th, rng, stats, mode="wcag")
509+ sv = v["solver"]
510+ st["n"] += 1
511+ st["evals"] += stats["evals"]
512+ st["attempts"] += sv["attempt"]
513+ st["max_attempts"] = max(st["max_attempts"], sv["attempt"])
514+ f = S.failing(res, thr)
515+ if sv["fallback"]:
516+ st["fallback"] += 1
517+ if not f:
518+ st["fallback_pass"] += 1
519+ else:
520+ if not f:
521+ st["pass"] += 1
522+ if sv["attempt"] == 1:
523+ st["first"] += 1
524+ mm = min((res[i] / thr[i], R.PAIRS[i]["name"]) for i in res if thr[i] > 0)
525+ if mm[0] < st["min_margin"]:
526+ st["min_margin"], st["min_margin_pair"] = mm
527+ st["accents"].add(v["accent"])
528+ st["hue_bins"].add(int(math.floor(sv["base_hue"] / 30.0)) % 12)
529+ st["bubbles"].add((v["my_messages_accent"],) + tuple(v["my_messages_gradient"]))
530+ w = v["wallpaper"]
531+ st["walls"].add(tuple(w["colors"]))
532+ st["light_bubble"] += 1 if D.luminance(v["my_messages_accent"]) > 0.4 else 0
533+ st["gradient_bubble"] += 1 if (len(v["my_messages_gradient"]) > 1 or (v["my_messages_gradient"] and v["my_messages_gradient"][0] != v["my_messages_accent"])) else 0
534+ st["animated"] += 1 if v["my_messages_animated"] else 0
535+ kind = {1: "solid", 2: "linear", 3: "motion", 4: "motion"}.get(len(w["colors"]), "other")
536+ st["kinds"][kind] = st["kinds"].get(kind, 0) + 1
537+ st["pattern"] += 1 if w["pattern_slug"] else 0
538+ st["negative"] += 1 if w["pattern_intensity"] < 0 else 0
539+ st["accent_L"].append(sv["accent_oklch"][0])
540+ st["wall_L"].setdefault(kind + ("-neg" if w["pattern_intensity"] < 0 else ""), []).append(sv["wallpaper_L"])
541+ pal = E.palette(th, S.to_accent(v), S.GEN_ACCENT_ID)
542+ for ck, _ in ON_ACCENT_PAIRS:
543+ if _near_black(pal.get(ck[4:])):
544+ st["nb"][ck[4:]] = st["nb"].get(ck[4:], 0) + 1
545+ st["service_scores"].append(res[idx_srv])
546+ st["service_lite_scores"].append(res[idx_srvl])
547+ for i in f:
548+ nm = R.PAIRS[i]["name"]
549+ st["fail_pairs"][nm] = st["fail_pairs"].get(nm, 0) + 1
550+ # naive reference on the same overlay model: uniform-RGB accent on the stock accent (the app's own path)
551+ rng2 = S.SplitMix64(S.seed_state(seed + "/a_acc/" + tn))
552+ vn = S.gen_naive(th, rng2, S.rand_rgb, False)
553+ _, rn = S.evaluate(th, vn)
554+ fn = S.failing(rn, thr)
555+ if not fn:
556+ st["naive_pass"] += 1
557+ for i in fn:
558+ nm = R.PAIRS[i]["name"]
559+ st["naive_fail_pairs"][nm] = st["naive_fail_pairs"].get(nm, 0) + 1
560+ return tn, st
561+
562+
563+def _merge(a, b):
564+ for k, v in b.items():
565+ if k not in a:
566+ a[k] = v
567+ elif isinstance(v, set):
568+ a[k] |= v
569+ elif isinstance(v, list):
570+ a[k] += v
571+ elif isinstance(v, dict):
572+ for kk, vv in v.items():
573+ if isinstance(vv, list):
574+ a[k].setdefault(kk, []).extend(vv)
575+ else:
576+ a[k][kk] = a[k].get(kk, 0) + vv
577+ elif k == "max_attempts":
578+ a[k] = max(a[k], v)
579+ elif k == "min_margin":
580+ if v < a[k]:
581+ a[k], a["min_margin_pair"] = v, b["min_margin_pair"]
582+ elif k == "min_margin_pair":
583+ pass
584+ else:
585+ a[k] += v
586+ return a
587+
588+
589+def measure(variant, n, procs, service="on"):
590+ jobs = []
591+ step = max(1, (n + 7) // 8)
592+ for t in S.ALL_THEMES:
593+ for s0 in range(0, n, step):
594+ jobs.append((variant, service, t, s0, min(n, s0 + step)))
595+ t0 = time.time()
596+ if procs > 1:
597+ from multiprocessing import Pool
598+ with Pool(procs) as pool:
599+ results = pool.map(_measure_chunk, jobs, chunksize=1)
600+ else:
601+ results = [_measure_chunk(j) for j in jobs]
602+ agg = {}
603+ for tn, st in results:
604+ if tn not in agg:
605+ agg[tn] = st
606+ else:
607+ _merge(agg[tn], st)
608+ dt = time.time() - t0
609+ T = S.ALL_THEMES
610+ L = []
611+ L.append("canvas-fix overlay %s; free OKLCH accent on all five base themes; %d seeds per base theme; %d pairs; "
612+ "pass = every pair >= plain WCAG (4.5 text, 3.0 non-text, 1.15 bubble/wallpaper%s)" % (
613+ variant, n, len(R.PAIRS), ", service text 4.5" if service == "on" else "; service_text/_lite scored but NOT in the rule"))
614+ L.append("fallbacks (stock accent after 12 rejected attempts) count as failures")
615+ L.append("")
616+ hdr = "%-38s" % "" + "".join("%13s" % t for t in T)
617+ L.append(hdr)
618+ row = lambda label, f: L.append("%-38s" % label + "".join(f(agg[t]) for t in T))
619+ row("pass rate (generated, all pairs)", lambda a: "%12.2f%%" % (100.0 * a["pass"] / a["n"]))
620+ row("first-attempt success", lambda a: "%12.2f%%" % (100.0 * a["first"] / a["n"]))
621+ row("mean attempts", lambda a: "%13.3f" % (a["attempts"] / a["n"]))
622+ row("max attempts", lambda a: "%13d" % a["max_attempts"])
623+ row("deterministic fallbacks used", lambda a: "%13d" % a["fallback"])
624+ row(" ... of which pass WCAG", lambda a: "%13d" % a["fallback_pass"])
625+ row("palette evaluations per seed", lambda a: "%13.2f" % (a["evals"] / a["n"]))
626+ row("min contrast/threshold (passes)", lambda a: "%13.4f" % a["min_margin"])
627+ row(" ... on pair", lambda a: "%13s" % a["min_margin_pair"][:12])
628+ row("distinct accent colours", lambda a: "%13d" % len(a["accents"]))
629+ row("base-hue 30-degree bins covered", lambda a: "%10d/12" % len(a["hue_bins"]))
630+ row("distinct outgoing-bubble colourings", lambda a: "%13d" % len(a["bubbles"]))
631+ row("distinct wallpaper colourings", lambda a: "%13d" % len(a["walls"]))
632+ row("accent OKLCH L: mean", lambda a: "%13.3f" % (sum(a["accent_L"]) / max(1, len(a["accent_L"]))))
633+ row("accent OKLCH L: min / max", lambda a: "%13s" % ("%.3f/%.3f" % (min(a["accent_L"]), max(a["accent_L"])) if a["accent_L"] else "-"))
634+ row("light (black-text) bubbles", lambda a: "%12.1f%%" % (100.0 * a["light_bubble"] / max(1, a["pass"])))
635+ row("gradient bubbles", lambda a: "%12.1f%%" % (100.0 * a["gradient_bubble"] / max(1, a["pass"])))
636+ row("animated bubble gradients", lambda a: "%12.1f%%" % (100.0 * a["animated"] / max(1, a["pass"])))
637+ for kind in ("solid", "linear", "motion"):
638+ row("wallpaper kind: " + kind, lambda a, kind=kind: "%12.1f%%" % (100.0 * a["kinds"].get(kind, 0) / max(1, a["pass"])))
639+ row("wallpapers with pattern", lambda a: "%12.1f%%" % (100.0 * a["pattern"] / max(1, a["pass"])))
640+ row("negative-intensity (dark) patterns", lambda a: "%12.1f%%" % (100.0 * a["negative"] / max(1, a["pass"])))
641+ for kind in ("solid", "linear", "motion", "motion-neg"):
642+ row("wallpaper OKLCH L mean: " + kind, lambda a, kind=kind: "%13s" % (("%.3f" % (sum(a["wall_L"][kind]) / len(a["wall_L"][kind]))) if a["wall_L"].get(kind) else "-"))
643+ row("service_text contrast: min", lambda a: "%13.2f" % (min(a["service_scores"]) if a["service_scores"] else 0))
644+ row("service_text_lite contrast: min", lambda a: "%13.2f" % (min(a["service_lite_scores"]) if a["service_lite_scores"] else 0))
645+ row(" share < 4.5 (service_text)", lambda a: "%12.2f%%" % (100.0 * sum(1 for x in a["service_scores"] if x < 4.5) / max(1, len(a["service_scores"]))))
646+ row(" share < 4.5 (service_text_lite)", lambda a: "%12.2f%%" % (100.0 * sum(1 for x in a["service_lite_scores"] if x < 4.5) / max(1, len(a["service_lite_scores"]))))
647+ L.append("on-accent rule: share of passing styles where the content is NEAR_BLACK")
648+ for ck, _ in ON_ACCENT_PAIRS:
649+ row(" " + ck[4:], lambda a, ck=ck: "%12.1f%%" % (100.0 * a["nb"].get(ck[4:], 0) / max(1, a["pass"])))
650+ row("(a) uniform-RGB accent, same overlay", lambda a: "%12.2f%%" % (100.0 * a["naive_pass"] / a["n"]))
651+ rej = set()
652+ for t in T:
653+ rej.update(agg[t]["reject"])
654+ if rej:
655+ L.append("rejected attempts by stage / final-check pair:")
656+ for k in sorted(rej, key=lambda k: (-sum(agg[t]["reject"].get(k, 0) for t in T), k)):
657+ L.append(" %-36s" % k + "".join("%13d" % agg[t]["reject"].get(k, 0) for t in T))
658+ names = set()
659+ for t in T:
660+ names.update(agg[t]["fail_pairs"])
661+ L.append("residual failing pairs (final variant incl. fallbacks), seeds per theme:")
662+ if not names:
663+ L.append(" (none)")
664+ for nm in sorted(names, key=lambda k: (-sum(agg[t]["fail_pairs"].get(k, 0) for t in T), k)):
665+ L.append(" %-36s" % nm + "".join("%13d" % agg[t]["fail_pairs"].get(nm, 0) for t in T))
666+ names = set()
667+ for t in T:
668+ names.update(agg[t]["naive_fail_pairs"])
669+ L.append("(a) uniform-RGB accent on the same overlay: seeds failing each pair (top 12)")
670+ for nm in sorted(names, key=lambda k: (-sum(agg[t]["naive_fail_pairs"].get(k, 0) for t in T), k))[:12]:
671+ L.append(" %-36s" % nm + "".join("%13d" % agg[t]["naive_fail_pairs"].get(nm, 0) for t in T))
672+ L.append("")
673+ L.append("elapsed %.1f s on %d process(es)" % (dt, procs))
674+ return "\n".join(L), agg
675+
676+
677+# ------------------------------------------------------------------------------------------
678+# gallery export
679+# ------------------------------------------------------------------------------------------
680+
681+GALLERY_KEYS = [
682+ # action bar
683+ "actionBarDefault", "actionBarDefaultTitle", "actionBarDefaultIcon", "actionBarDefaultSubtitle", "actionBarTabActiveText",
684+ "actionBarTabUnactiveText", "actionBarTabLine",
685+ # windows
686+ "windowBackgroundWhite", "windowBackgroundGray", "windowBackgroundWhiteBlackText", "windowBackgroundWhiteGrayText",
687+ "windowBackgroundWhiteGrayText4", "windowBackgroundWhiteBlueHeader", "windowBackgroundWhiteBlueText4",
688+ # switches
689+ "switchTrack", "switchTrackChecked",
690+ # chat list
691+ "chats_name", "chats_message", "chats_nameMessage", "chats_date", "chats_unreadCounter", "chats_unreadCounterText",
692+ "chats_unreadCounterMuted", "chats_actionBackground", "chats_actionIcon", "chats_sentReadCheck", "chats_pinnedIcon", "divider",
693+ # bubbles
694+ "chat_inBubble", "chat_outBubble", "chat_outBubbleGradient1", "chat_outBubbleGradient2", "chat_outBubbleGradient3",
695+ # message text and links
696+ "chat_messageTextIn", "chat_messageTextOut", "chat_messageLinkIn", "chat_messageLinkOut",
697+ # time and read check
698+ "chat_inTimeText", "chat_outTimeText", "chat_outSentCheckRead",
699+ # replies
700+ "chat_inReplyNameText", "chat_inReplyLine", "chat_outReplyNameText", "chat_outReplyLine", "chat_inReplyMessageText",
701+ "chat_outReplyMessageText",
702+ # header
703+ "chat_status",
704+ # composer
705+ "chat_messagePanelBackground", "chat_messagePanelText", "chat_messagePanelHint", "chat_messagePanelIcons", "chat_messagePanelSend",
706+ # filled buttons
707+ "featuredStickers_addButton", "featuredStickers_buttonText",
708+]
709+HEADLINE = ["list_gray_text", "list_accent_text", "chats_preview", "unread_badge_text", "fab_icon", "actionbar_subtitle", "in_text",
710+ "in_link", "in_time", "out_text", "out_time", "filled_button_text", "service_text"]
711+
712+
713+def variant_record(th, v, stock_res=None):
714+ """Genome variant -> gallery record: declared fields + FINAL getColor() values (overlay + fillAccentColors + hook)."""
715+ acc = S.to_accent(v)
716+ pal = E.palette(th, acc, S.GEN_ACCENT_ID)
717+ ev = R.Evaluator(pal)
718+ res = ev.all()
719+ thr = S.thresholds(th, 0.0, "wcag")
720+ wp = ev.wp
721+ w = v["wallpaper"]
722+ if w.get("source") == "theme":
723+ kind = "base theme's own"
724+ elif w.get("source") == "default":
725+ kind = "built-in default"
726+ else:
727+ kind = {1: "solid", 2: "linear-gradient"}.get(len(w["colors"]), "motion-gradient")
728+ # service message as rendered: worst sample of the pill against the text actually drawn
729+ srv_text = wp.service_text
730+ samples = list(wp.service)
731+ worst = min(samples, key=lambda c_: D.contrast(R.over(srv_text, c_), c_))
732+ mm_i = min((i for i in res if thr[i] > 0), key=lambda i: res[i] / thr[i])
733+ rec = {
734+ "base_theme": th.name,
735+ "accent": hx(v["accent"]),
736+ "my_messages_accent": hx(v["my_messages_accent"]),
737+ "my_messages_gradient": [hx(c) for c in v["my_messages_gradient"]],
738+ "my_messages_animated": bool(v["my_messages_animated"]),
739+ "wallpaper": {
740+ "colors": [hx(c) for c in w["colors"]] if w.get("source") != "theme" else [hx(E.s32(0xFF000000 | c)) for c in wp.colors if c != 0],
741+ "kind": kind,
742+ "rotation": w["rotation"],
743+ "pattern_intensity": w["pattern_intensity"],
744+ "pattern_slug": w["pattern_slug"],
745+ "parallax_motion": bool(w["motion"]),
746+ "rendered": {"kind": wp.kind, "intensity_percent": wp.intensity, "pattern": bool(wp.pattern),
747+ "stored_patternIntensity_float32": acc.patternIntensity},
748+ },
749+ "colors": {k: hx(pal.get(k)) for k in GALLERY_KEYS},
750+ "service_text": hx(srv_text),
751+ "service_pill_color": hx(E.s32(0xFF000000 | worst)),
752+ "service_mode": "gradient service shader (WHITE text on ColorMatrix(wallpaper))" if wp.shader else "pill: chat_serviceBackground or calcDrawableColor over the wallpaper, chat_serviceText",
753+ "contrast": {nm: round(res[R.PAIR_INDEX[nm]], 3) for nm in HEADLINE},
754+ "min_margin": round(res[mm_i] / thr[mm_i], 4),
755+ "min_margin_pair": R.PAIRS[mm_i]["name"],
756+ }
757+ if "solver" in v:
758+ rec["solver"] = v["solver"]
759+ fails = {R.PAIRS[i]["name"]: {"contrast": round(res[i], 3), "threshold": thr[i]} for i in sorted(res) if res[i] < thr[i]}
760+ return rec, fails, res
761+
762+
763+def _forks(seed):
764+ root = S.SplitMix64(S.seed_state(seed))
765+ return root.fork(), root.fork(), root.fork()
766+
767+
768+def gallery(variant="B", n=36, n_naive=3):
769+ S.RULE["rule"] = "wcag"
770+ S.RULE["service"] = "on"
771+ m = overlay(variant)
772+ dc, nc = S.DAY_CHOICES["wcag"], S.NIGHT_CHOICES["wcag"]
773+ lo, hi = S.C["bubble_radius"]
774+ genomes = []
775+ for k in range(n):
776+ seed = "gallery-%02d" % k
777+ day_rng, night_rng, misc_rng = _forks(seed)
778+ day_theme = m.themes[dc[day_rng.below(len(dc))]]
779+ night_theme = m.themes[nc[night_rng.below(len(nc))]]
780+ day, _ = S.gen_variant_c(day_theme, day_rng, mode="wcag")
781+ night, _ = S.gen_variant_c(night_theme, night_rng, mode="wcag")
782+ radius = lo + misc_rng.below(hi - lo + 1)
783+ # determinism cross-check against style_sim.genome() (same forks, same draws)
784+ g0 = S.genome(seed, mode="wcag", m=m)
785+ assert g0["day"] == S.variant_json(day) and g0["night"] == S.variant_json(night) and g0["bubble_radius"] == radius, seed
786+ rd, fd, _ = variant_record(day_theme, day)
787+ rn, fn, _ = variant_record(night_theme, night)
788+ assert not fd and not fn, (seed, fd, fn)
789+ genomes.append({"seed": seed, "day": rd, "night": rn, "bubble_radius": radius})
790+ # naive: uniform-RGB accent (the app's own change-accent path: stock accent copy with a new accentColor),
791+ # STOCK model (no overlay, no hook); the first seeds whose day and night variants both fail
792+ stock = E.model()
793+ naive = []
794+ k = 0
795+ while len(naive) < n_naive:
796+ seed = "naive-%02d" % k
797+ k += 1
798+ day_rng, night_rng, misc_rng = _forks(seed)
799+ day_theme = stock.themes[dc[day_rng.below(len(dc))]]
800+ night_theme = stock.themes[nc[night_rng.below(len(nc))]]
801+ day = S.gen_naive(day_theme, day_rng, S.rand_rgb, False)
802+ night = S.gen_naive(night_theme, night_rng, S.rand_rgb, False)
803+ radius = lo + misc_rng.below(hi - lo + 1)
804+ rd, fd, _ = variant_record(day_theme, day)
805+ rn, fn, _ = variant_record(night_theme, night)
806+ if not fd or not fn:
807+ continue
808+ for rec, fails, th in ((rd, fd, day_theme), (rn, fn, night_theme)):
809+ rec["failing_pairs"] = fails
810+ st = R.Evaluator(R.stock_palette(th)).all()
811+ thr = S.thresholds(th, 0.0, "wcag")
812+ rec["failing_pairs_that_pass_with_the_stock_accent"] = sorted(nm for nm in fails if st[R.PAIR_INDEX[nm]] >= thr[R.PAIR_INDEX[nm]])
813+ naive.append({"seed": seed, "generator": "uniform RGB accentColor on the stock accent, stock model", "day": rd, "night": rn,
814+ "bubble_radius": radius})
815+ out = {
816+ "format": "canvas-fix gallery v1; colours '#AARRGGBB'; 'colors' = Theme.getColor() after overlay + fillAccentColors + on-accent hook; "
817+ "service_pill_color = the rendered pill sample with the lowest contrast against service_text; contrast = WCAG ratio of the "
818+ "13 headline pairs; min_margin = min over all 99 pairs of contrast / threshold",
819+ "overlay": variant,
820+ "rule": "plain WCAG 2.x: 4.5 text, 3.0 non-text, 1.15 bubble vs wallpaper, service text 4.5",
821+ "prng": "SHA-256(seed)[0:8] big-endian -> SplitMix64; forks: day, night, misc (detmath OKLCH)",
822+ "genomes": genomes,
823+ "naive": naive,
824+ }
825+ return out
826+
827+
828+def main():
829+ ap = argparse.ArgumentParser()
830+ ap.add_argument("--build", action="store_true")
831+ ap.add_argument("--sweep-step", type=int, default=10)
832+ ap.add_argument("--stock", action="store_true")
833+ ap.add_argument("--crosscheck", type=int)
834+ ap.add_argument("--measure", type=int)
835+ ap.add_argument("--variant", default="B")
836+ ap.add_argument("--service", default="on", choices=["on", "off"])
837+ ap.add_argument("--procs", type=int, default=os.cpu_count() or 1)
838+ ap.add_argument("--gallery", action="store_true")
839+ a = ap.parse_args()
840+ if a.gallery:
841+ g = gallery(a.variant)
842+ path = os.path.join(OUT, "gallery.json")
843+ with open(path, "w") as f:
844+ json.dump(g, f, indent=1)
845+ print("wrote %s: %d genomes, %d naive" % (path, len(g["genomes"]), len(g["naive"])))
846+ if a.stock:
847+ print(stock_check()[0])
848+ if a.crosscheck:
849+ for v in ("A", "B"):
850+ bad, total, near = crosscheck(v, a.crosscheck)
851+ print("crosscheck overlay %s: lazy vs full getColor mismatches %d of %d (%d palettes with a near-theme bubble)" % (v, bad, total, near))
852+ if a.measure:
853+ print(measure(a.variant, a.measure, a.procs, a.service)[0])
854+ if a.build:
855+ for variant in ("B", "A"):
856+ t0 = time.time()
857+ spec, rows = build(variant, a.sweep_step)
858+ with open(os.path.join(OUT, "overlay_%s.json" % variant), "w") as f:
859+ json.dump(spec, f, indent=1, sort_keys=True)
860+ with open(os.path.join(OUT, "overlay_%s_rows.json" % variant), "w") as f:
861+ json.dump(rows, f, indent=1)
862+ print("VARIANT %s (%.1f s): %d defaults, %d attheme lines, %d exclusions" % (
863+ variant, time.time() - t0, len(spec["defaults"]), sum(len(v) for v in spec["attheme"].values()), len(spec["exclusions"])))
864+ print(fmt_rows(rows))
865+
866+
867+if __name__ == "__main__":
868+ main()
multigram/tools/sim/detmath.pynew file+157
@@ -0,0 +1,157 @@
1+"""Deterministic, portable math for the style generator (see detmath_tables.py for the tables).
2+
3+Every function here uses only IEEE-754 double +, -, *, /, sqrt, floor and comparisons, in a fixed
4+order, so Java (double, no FMA contraction by the language), JavaScript (Number) and Python (float)
5+produce bit-identical results.
6+"""
7+import math
8+
9+from detmath_tables import LINEAR_MIDPOINTS, SRGB8_TO_LINEAR
10+
11+PI = 3.141592653589793
12+DEG2RAD = PI / 180.0
13+# 1/n! as exact decimal literals rounded to double (same literal in every language)
14+_INV_FACT = {3: 0.16666666666666666, 5: 0.008333333333333333, 7: 0.0001984126984126984, 9: 2.7557319223985893e-06,
15+ 11: 2.505210838544172e-08, 13: 1.6059043836821613e-10, 15: 7.647163731819816e-13, 17: 2.8114572543455206e-15,
16+ 2: 0.5, 4: 0.041666666666666664, 6: 0.001388888888888889, 8: 2.48015873015873e-05, 10: 2.755731922398589e-07,
17+ 12: 2.08767569878681e-09, 14: 1.1470745597729725e-11, 16: 4.779477332387385e-14, 18: 1.5619206968586225e-16}
18+
19+
20+def _sin_poly(x):
21+ x2 = x * x
22+ p = _INV_FACT[17]
23+ for n in (15, 13, 11, 9, 7, 5, 3):
24+ p = _INV_FACT[n] - x2 * p
25+ # p = 1/3! - x^2/5! + ... -> sin = x - x^3 * p
26+ return x - x * x2 * p
27+
28+
29+def _cos_poly(x):
30+ x2 = x * x
31+ p = _INV_FACT[18]
32+ for n in (16, 14, 12, 10, 8, 6, 4, 2):
33+ p = _INV_FACT[n] - x2 * p
34+ return 1.0 - x2 * p
35+
36+
37+def sin_cos_deg(deg):
38+ """(sin, cos) of an angle in degrees; |error| < 1e-15 on [0, 360)."""
39+ d = deg - 360.0 * math.floor(deg / 360.0)
40+ k = math.floor((d + 45.0) / 90.0)
41+ r = (d - 90.0 * k) * DEG2RAD # r in [-pi/4, pi/4)
42+ s = _sin_poly(r)
43+ c = _cos_poly(r)
44+ q = int(k) % 4
45+ if q == 0:
46+ return s, c
47+ if q == 1:
48+ return c, -s
49+ if q == 2:
50+ return -s, -c
51+ return -c, s
52+
53+
54+def cbrt(x):
55+ """Cube root by 64 fixed Newton steps (analysis only; the generator never needs it)."""
56+ if x == 0.0:
57+ return 0.0
58+ neg = x < 0
59+ if neg:
60+ x = -x
61+ y = 1.0 if x < 1.0 else x
62+ for _ in range(64):
63+ y = (2.0 * y + x / (y * y)) / 3.0
64+ return -y if neg else y
65+
66+
67+def linear_to_srgb8(v):
68+ """Nearest 8-bit sRGB code of a linear-light value (rounding in the gamma-encoded domain),
69+ by binary search over the literal midpoint table."""
70+ if not v > LINEAR_MIDPOINTS[0]:
71+ return 0
72+ lo, hi = 0, 254 # answer = number of midpoints strictly below v
73+ if v > LINEAR_MIDPOINTS[254]:
74+ return 255
75+ while lo < hi:
76+ mid = (lo + hi) // 2
77+ if LINEAR_MIDPOINTS[mid] < v:
78+ lo = mid + 1
79+ else:
80+ hi = mid
81+ return lo
82+
83+
84+# ---- colour science (Oklab by Bjorn Ottosson, matrices as published) ----------------------
85+
86+def oklab_to_linear_srgb(L, a, b):
87+ l_ = L + 0.3963377774 * a + 0.2158037573 * b
88+ m_ = L - 0.1055613458 * a - 0.0638541728 * b
89+ s_ = L - 0.0894841775 * a - 1.2914855480 * b
90+ l = l_ * l_ * l_
91+ m = m_ * m_ * m_
92+ s = s_ * s_ * s_
93+ r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
94+ g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
95+ bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
96+ return r, g, bl
97+
98+
99+def linear_srgb_to_oklab(r, g, b):
100+ l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
101+ m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
102+ s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
103+ l_, m_, s_ = cbrt(l), cbrt(m), cbrt(s)
104+ return (0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
105+ 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
106+ 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_)
107+
108+
109+GAMUT_EPS = 1e-9
110+
111+
112+def oklch_to_rgb8(L, C, h_deg, gamut_steps=24):
113+ """OKLCH -> 8-bit sRGB. Out-of-gamut colours keep L and h and get the largest chroma
114+ (bisection, fixed step count) that fits in the sRGB cube. Returns (r, g, b, C_used)."""
115+ sn, cs = sin_cos_deg(h_deg)
116+
117+ def in_gamut(c):
118+ r, g, b = oklab_to_linear_srgb(L, c * cs, c * sn)
119+ return (r >= -GAMUT_EPS and r <= 1.0 + GAMUT_EPS and g >= -GAMUT_EPS and g <= 1.0 + GAMUT_EPS
120+ and b >= -GAMUT_EPS and b <= 1.0 + GAMUT_EPS), (r, g, b)
121+
122+ ok, rgb = in_gamut(C)
123+ c_used = C
124+ if not ok:
125+ lo, hi = 0.0, C
126+ for _ in range(gamut_steps):
127+ mid = (lo + hi) * 0.5
128+ if in_gamut(mid)[0]:
129+ lo = mid
130+ else:
131+ hi = mid
132+ c_used = lo
133+ rgb = in_gamut(lo)[1]
134+ r, g, b = rgb
135+ return linear_to_srgb8(r), linear_to_srgb8(g), linear_to_srgb8(b), c_used
136+
137+
138+def rgb8_to_oklch(r, g, b):
139+ L, a, bb = linear_srgb_to_oklab(SRGB8_TO_LINEAR[r], SRGB8_TO_LINEAR[g], SRGB8_TO_LINEAR[b])
140+ C = math.sqrt(a * a + bb * bb)
141+ h = math.degrees(math.atan2(bb, a)) % 360.0 # analysis only
142+ return L, C, h
143+
144+
145+# ---- WCAG 2.2 relative luminance / contrast ------------------------------------------------
146+
147+def luminance(c):
148+ return (0.2126 * SRGB8_TO_LINEAR[(c >> 16) & 0xFF] + 0.7152 * SRGB8_TO_LINEAR[(c >> 8) & 0xFF]
149+ + 0.0722 * SRGB8_TO_LINEAR[c & 0xFF])
150+
151+
152+def contrast(c1, c2):
153+ l1 = luminance(c1)
154+ l2 = luminance(c2)
155+ if l1 < l2:
156+ l1, l2 = l2, l1
157+ return (l1 + 0.05) / (l2 + 0.05)
multigram/tools/sim/detmath_tables.pynew file+144
@@ -0,0 +1,144 @@
1+"""Deterministic, portable math for the style generator.
2+
3+Only IEEE-754 double +, -, *, /, sqrt, floor and comparisons are used at run time (all correctly
4+rounded in Java, JavaScript and Python), so a Java or JS port that performs the same operations in
5+the same order produces bit-identical results. Transcendental functions are replaced by
6+(1) literal tables (sRGB transfer function, generated with 60-digit Decimal arithmetic) and
7+(2) fixed-degree polynomials (sin/cos) and fixed-iteration Newton steps (cbrt).
8+"""
9+
10+# WCAG 2.x / IEC 61966-2-1 sRGB decoding of 8-bit channel values: c/12.92 if c <= 0.04045 else ((c+0.055)/1.055)^2.4
11+SRGB8_TO_LINEAR = (
12+ 0.0, 0.0003035269835488375, 0.000607053967097675, 0.0009105809506465125,
13+ 0.00121410793419535, 0.0015176349177441874, 0.001821161901293025, 0.0021246888848418626,
14+ 0.0024282158683907, 0.0027317428519395373, 0.003035269835488375, 0.0033465357638991586,
15+ 0.003676507324047435, 0.004024717018496304, 0.0043914420374102925, 0.0047769534806937275,
16+ 0.005181516702338385, 0.005605391624202721, 0.006048833022857052, 0.006512090792594472,
17+ 0.006995410187265385, 0.00749903204322617, 0.008023192985384993, 0.008568125618069302,
18+ 0.009134058702220785, 0.009721217320237844, 0.010329823029626936, 0.01096009400648824,
19+ 0.011612245179743881, 0.012286488356915867, 0.012983032342173007, 0.013702083047289683,
20+ 0.014443843596092541, 0.015208514422912706, 0.015996293365509628, 0.016807375752887377,
21+ 0.017641954488384078, 0.01850022012837969, 0.019382360956935723, 0.02028856305665239,
22+ 0.021219010376003555, 0.022173884793387375, 0.023153366178110403, 0.02415763244850475,
23+ 0.025186859627361623, 0.02624122189484989, 0.02732089163907489, 0.028426039504420787,
24+ 0.029556834437808797, 0.03071344373299362, 0.03189603307301152, 0.03310476657088505,
25+ 0.03433980680868216, 0.03560131487502032, 0.03688945040110002, 0.03820437159534648,
26+ 0.03954623527673283, 0.04091519690685317, 0.042311410620809654, 0.04373502925697345,
27+ 0.04518620438567554, 0.04666508633688008, 0.048171824226889405, 0.04970656598412722,
28+ 0.051269458374043224, 0.05286064702318025, 0.054480276442442355, 0.05612849004960008,
29+ 0.05780543019106721, 0.059511238162981185, 0.061246054231617594, 0.06301001765316766,
30+ 0.06480326669290576, 0.06662593864377288, 0.06847816984440015, 0.07036009569659588,
31+ 0.07227185068231748, 0.07421356838014961, 0.07618538148130781, 0.07818742180518633,
32+ 0.08021982031446831, 0.0822827071298148, 0.08437621154414877, 0.08650046203654974,
33+ 0.08865558628577294, 0.09084171118340768, 0.09305896284668742, 0.09530746663096466,
34+ 0.09758734714186242, 0.09989872824711389, 0.10224173308810128, 0.10461648409110416,
35+ 0.10702310297826759, 0.10946171077829933, 0.11193242783690557, 0.11443537382697372,
36+ 0.11697066775851081, 0.1195384279883456, 0.12213877222960184, 0.12477181756095046,
37+ 0.12743768043564743, 0.13013647669036427, 0.13286832155381792, 0.13563332965520564,
38+ 0.13843161503245183, 0.14126329114027164, 0.14412847085805772, 0.14702726649759498,
39+ 0.14995978981060853, 0.15292615199615014, 0.15592646370782734, 0.15896083506088035,
40+ 0.162029375639111, 0.1651321945016676, 0.1682694001896907, 0.17144110073282254,
41+ 0.17464740365558498, 0.17788841598362912, 0.18116424424986013, 0.1844749945004409,
42+ 0.18782077230067779, 0.19120168274079136, 0.1946178304415757, 0.1980693195599488,
43+ 0.20155625379439707, 0.2050787363903169, 0.20863687014525567, 0.2122307574140551,
44+ 0.21586050011389915, 0.21952619972926918, 0.22322795731680842, 0.22696587351009834,
45+ 0.23074004852434896, 0.23455058216100508, 0.23839757381227095, 0.24228112246555472,
46+ 0.2462013267078354, 0.2501582847299533, 0.2541520943308267, 0.25818285292159576,
47+ 0.262250657529696, 0.2663556048028623, 0.27049779101306576, 0.27467731206038454,
48+ 0.27889426347681034, 0.28314874042999194, 0.2874408377269174, 0.29177064981753587,
49+ 0.29613827079832095, 0.3005437944157764, 0.3049873140698861, 0.30946892281750843,
50+ 0.3139887133757175, 0.31854677812509175, 0.3231432091129507, 0.32777809805654207,
51+ 0.3324515363461792, 0.33716361504833026, 0.34191442490866075, 0.3467040563550295,
52+ 0.3515325995004392, 0.3564001441459434, 0.36130677978350945, 0.3662525955988394,
53+ 0.37123768047414896, 0.3762621229909062, 0.38132601143253, 0.3864294337870489,
54+ 0.3915724777497231, 0.3967552307256268, 0.4019777798321956, 0.40724021190173665,
55+ 0.4125426134839036, 0.41788507084813725, 0.4232676699860715, 0.42869049661390657,
56+ 0.4341536361747488, 0.43965717384091874, 0.44520119451622775, 0.45078578283822335,
57+ 0.4564110231804045, 0.46207699965440685, 0.4677837961121588, 0.4735314961480093,
58+ 0.47932018310082664, 0.4851499400560704, 0.49102084984783545, 0.49693299506087035,
59+ 0.5028864580325684, 0.5088813208549335, 0.5149176653765213, 0.5209955732043541,
60+ 0.527115125705813, 0.533276404010505, 0.539479489012107, 0.5457244613701866,
61+ 0.5520114015119999, 0.5583403896342677, 0.5647115057049289, 0.5711248294648729,
62+ 0.5775804404296505, 0.584078417891164, 0.5906188409193368, 0.5972017883637631,
63+ 0.6038273388553375, 0.6104955708078647, 0.6172065624196509, 0.6239603916750759,
64+ 0.6307571363461467, 0.6375968739940324, 0.644479681970582, 0.6514056374198239,
65+ 0.6583748172794482, 0.6653872982822719, 0.6724431569576873, 0.6795424696330937,
66+ 0.6866853124353132, 0.6938717612919898, 0.7011018919329731, 0.7083757798916867,
67+ 0.7156935005064805, 0.7230551289219689, 0.7304607400903533, 0.7379104087727307,
68+ 0.7454042095403872, 0.7529422167760778, 0.7605245046752922, 0.7681511472475069,
69+ 0.7758222183174234, 0.7835377915261932, 0.79129794033263, 0.7991027380144087,
70+ 0.8069522576692514, 0.8148465722161011, 0.8227857543962833, 0.8307698767746545,
71+ 0.8387990117407399, 0.8468732315098577, 0.8549926081242336, 0.863157213454102,
72+ 0.8713671191987971, 0.8796223968878317, 0.8879231178819664, 0.8962693533742666,
73+ 0.9046611743911491, 0.9130986517934189, 0.9215818562772945, 0.9301108583754234,
74+ 0.9386857284578878, 0.9473065367331996, 0.955973353249286, 0.9646862478944651,
75+ 0.9734452903984123, 0.982250550333117, 0.9911020971138297, 1.0,
76+)
77+
78+# linear-light value of the 8-bit rounding boundaries (k + 0.5) / 255, k = 0..254
79+LINEAR_MIDPOINTS = (
80+ 0.00015176349177441876, 0.00045529047532325625, 0.0007588174588720937, 0.0010623444424209313,
81+ 0.0013658714259697686, 0.0016693984095186062, 0.0019729253930674436, 0.002276452376616281,
82+ 0.0025799793601651187, 0.0028835063437139563, 0.0031883009044305307, 0.003509259349581229,
83+ 0.0038483149330964255, 0.004205748030104946, 0.004581832740528378, 0.004976837250274022,
84+ 0.005391024159806378, 0.005824650784040896, 0.006277969426914104, 0.006751227633498622,
85+ 0.007244668422128917, 0.007758530498667859, 0.008293048454762324, 0.008848452951698497,
86+ 0.009424970891266087, 0.010022825574869033, 0.010642236851973573, 0.011283421258858294,
87+ 0.011946592148522126, 0.01263195981251186, 0.013339731595349031, 0.014070112002164466,
88+ 0.014823302800086412, 0.015599503113873269, 0.016398909516233674, 0.0172217161132341,
89+ 0.018068114625156378, 0.018938294463134067, 0.019832442801866853, 0.020750744648685503,
90+ 0.021693382909216234, 0.022660538449872057, 0.023652390157379497, 0.024669114995532,
91+ 0.02571088805934576, 0.02677788262677978, 0.027870270208169255, 0.02898822059350997,
92+ 0.030131901897720904, 0.03130148060400286, 0.03249712160540221, 0.03371898824468107,
93+ 0.03496724235258794, 0.03624204428461637, 0.0375435529563331, 0.03887192587735157,
94+ 0.04022731918402183, 0.04160988767090287, 0.0430197848210794, 0.04445716283538091,
95+ 0.045922172660557446, 0.04741496401646279, 0.04893568542229297, 0.05048448422192487,
96+ 0.052061506608397194, 0.05366689764757337, 0.055300801301023834, 0.05696336044816294,
97+ 0.05865471690767354, 0.06037501145825081, 0.06212438385869472, 0.06390297286737921,
98+ 0.0657109162611246, 0.06754835085349804, 0.06941541251256608, 0.07131223617812141,
99+ 0.07323895587840543, 0.07519570474634667, 0.07718261503533431, 0.079199818134545,
100+ 0.08124744458384038, 0.08332562408825164, 0.085434485532067, 0.0875741569925368,
101+ 0.08974476575321061, 0.09194643831691976, 0.09417930041841838, 0.09644347703669502,
102+ 0.09873909240696692, 0.10106627003236779, 0.10342513269534022, 0.10581580246874268,
103+ 0.10823840072668098, 0.11069304815507362, 0.11317986476196004, 0.1156989698875601,
104+ 0.11825048221409341, 0.12083451977536606, 0.12345119996613242, 0.12610063955123932,
105+ 0.1287829546745594, 0.13149826086772048, 0.13424667305863716, 0.13702830557985105,
106+ 0.1398432721766851, 0.14269168601521828, 0.14557365969008557, 0.14848930523210868,
107+ 0.1514387341157627, 0.15442205726648323, 0.15743938506781888, 0.16049082736843368,
108+ 0.1635764934889634, 0.16669649222873034, 0.1698509318723205, 0.17303992019602685,
109+ 0.1762635644741625, 0.1795219714852476, 0.18281524751807324, 0.18614349837764557,
110+ 0.18950682939101374, 0.19290534541298454, 0.19633915083172687, 0.19980834957426888,
111+ 0.2033130451118906, 0.206853340465415, 0.21042933821039972, 0.21404114048223244,
112+ 0.21768884898113217, 0.22137256497705868, 0.22509238931453274, 0.22884842241736905,
113+ 0.23264076429332445, 0.2364695145386629, 0.24033477234264, 0.24423663649190822,
114+ 0.24817520537484553, 0.25215057698580884, 0.25616284892931374, 0.2602121184241433,
115+ 0.26429848230738645, 0.26842203703840817, 0.2725828787027534, 0.27678110301598513,
116+ 0.28101680532745965, 0.2852900806240388, 0.28960102353374223, 0.29394972832933935,
117+ 0.29833628893188435, 0.3027607989141932, 0.3072233515042661, 0.311724039588655,
118+ 0.31626295571577834, 0.3208401920991836, 0.32545584062075916, 0.3301099928338964,
119+ 0.33480273996660287, 0.3395341729245681, 0.34430438229418253, 0.34911345834551083,
120+ 0.3539614910352206, 0.358848570009467, 0.3637747846067348, 0.368740223860638,
121+ 0.3737449765026788, 0.3787891309649657, 0.3838727753828925, 0.38899599759777825,
122+ 0.39415888515946956, 0.39936152532890534, 0.4046040050806453, 0.40988641110536267,
123+ 0.41520882981230184, 0.4205713473317014, 0.4259740495171839, 0.43141702194811204,
124+ 0.43690034993191285, 0.44242411850636965, 0.44798841244188314, 0.4535933162437015,
125+ 0.45923891415412066, 0.464925290154655, 0.4706525279681791, 0.4764207110610407,
126+ 0.4822299226451467, 0.48808024568002034, 0.4939717628748329, 0.4999045566904078,
127+ 0.5058787093411996, 0.511894302797247, 0.5179514187861012, 0.5240501387947287,
128+ 0.5301905440713919, 0.5363727156275035, 0.5425967342394596, 0.548862680450449,
129+ 0.5551706345722391, 0.5615206766869422, 0.5679128866487572, 0.5743473440856915,
130+ 0.5808241284012619, 0.5873433187761735, 0.5939049941699805, 0.6005092333227248,
131+ 0.6071561147565556, 0.613845716777331, 0.6205781174761987, 0.6273533947311589,
132+ 0.6341716262086089, 0.6410328893648691, 0.647937261447692, 0.6548848194977526,
133+ 0.6618756403501223, 0.6689098006357256, 0.6759873767827806, 0.6831084450182221,
134+ 0.690273081369109, 0.6974813616640163, 0.7047333615344105, 0.7120291564160101,
135+ 0.719368821550131, 0.7267524319850168, 0.734180062577154, 0.7416517879925731,
136+ 0.7491676827081358, 0.7567278210128071, 0.7643322770089145, 0.7719811246133927,
137+ 0.7796744375590163, 0.787412289395617, 0.7951947534912902, 0.8030219030335867,
138+ 0.8108938110306931, 0.8188105503125995, 0.826772193532254, 0.8347788131667057,
139+ 0.8428304815182367, 0.8509272707154805, 0.85906925271453, 0.867256499300034,
140+ 0.8754890820862817, 0.8837670725182769, 0.8920905418728011, 0.9004595612594652,
141+ 0.9088742016217514, 0.9173345337380436, 0.9258406282226487, 0.9343925555268064,
142+ 0.9429903859396901, 0.9516341895893966, 0.9603240364439273, 0.9690599963121588,
143+ 0.9778421388448043, 0.9866705335353662, 0.9955452497210775,
144+)
multigram/tools/sim/engine.pynew file+1348
@@ -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)
multigram/tools/sim/palette-fix/overlay_A.jsonnew file+178
@@ -0,0 +1,178 @@
1+{
2+ "attheme": {
3+ "arctic.attheme": {
4+ "key_actionBarDefaultSubtitle": 4285626239,
5+ "key_chat_inFileInfoText": 4285495168,
6+ "key_chat_topPanelMessage": 4285691770,
7+ "key_windowBackgroundWhiteGrayText": 4285495170
8+ },
9+ "bluebubbles.attheme": {
10+ "key_actionBarDefaultSubtitle": 4285495169,
11+ "key_windowBackgroundWhiteGrayText": 4285822841,
12+ "key_windowBackgroundWhiteGrayText2": 4285822841,
13+ "key_windowBackgroundWhiteGrayText4": 4285296240
14+ },
15+ "darkblue.attheme": {
16+ "key_actionBarDefaultSubtitle": 2463888127,
17+ "key_chat_inFileInfoText": 4287931827,
18+ "key_chat_inTimeText": 4287931572,
19+ "key_chat_inViews": 4287931827,
20+ "key_chat_messagePanelHint": 2530996223,
21+ "key_chat_topPanelMessage": 2480731391,
22+ "key_chats_attachMessage": 4288256409,
23+ "key_chats_date": 4287535782,
24+ "key_chats_message": 4287404969,
25+ "key_windowBackgroundWhiteGrayText": 4287404969,
26+ "key_windowBackgroundWhiteGrayText2": 2213674495,
27+ "key_windowBackgroundWhiteGrayText4": 4085091225
28+ },
29+ "day.attheme": {
30+ "key_actionBarDefaultSubtitle": 4285626486,
31+ "key_chat_inFileInfoText": 4285295984,
32+ "key_chat_inTimeText": 4285295985,
33+ "key_chat_inViews": 4285230705,
34+ "key_chat_topPanelMessage": 4285626487,
35+ "key_windowBackgroundWhiteGrayText": 4285888376
36+ },
37+ "night.attheme": {
38+ "key_actionBarDefaultSubtitle": 2163405554,
39+ "key_chat_inFileInfoText": 4287203723,
40+ "key_chat_inTimeText": 4287072908,
41+ "key_chat_inViews": 4286941838,
42+ "key_chat_messagePanelHint": 1979711487,
43+ "key_chat_topPanelMessage": 1962934271,
44+ "key_chats_date": 4286677376,
45+ "key_windowBackgroundWhiteGrayText": 1962934271,
46+ "key_windowBackgroundWhiteGrayText2": 1962802687,
47+ "key_windowBackgroundWhiteGrayText4": 4285887604
48+ }
49+ },
50+ "defaults": {
51+ "key_actionBarDefaultSubtitle": 4285626486,
52+ "key_chat_attachCheckBoxCheck": 4278519045,
53+ "key_chat_goDownButtonCounter": 4278519045,
54+ "key_chat_inContactIcon": 4278519045,
55+ "key_chat_inFileInfoText": 4285495168,
56+ "key_chat_inMediaIcon": 4278519045,
57+ "key_chat_inTimeText": 4285495168,
58+ "key_chat_inViews": 4285495168,
59+ "key_chat_messagePanelHint": 4285757555,
60+ "key_chat_topPanelMessage": 4285626487,
61+ "key_chats_actionIcon": 4278519045,
62+ "key_chats_date": 4285888376,
63+ "key_chats_message": 4285822841,
64+ "key_chats_unreadCounterText": 4278519045,
65+ "key_checkboxSquareCheck": 4278519045,
66+ "key_dialogCheckboxSquareCheck": 4278519045,
67+ "key_dialogFloatingIcon": 4278519045,
68+ "key_dialogRoundCheckBoxCheck": 4278519045,
69+ "key_featuredStickers_buttonText": 4278519045,
70+ "key_picker_badgeText": 4278519045,
71+ "key_windowBackgroundWhiteGrayText": 4285888376,
72+ "key_windowBackgroundWhiteGrayText2": 4285757307,
73+ "key_windowBackgroundWhiteGrayText4": 4285492846
74+ },
75+ "exclusions": [
76+ "key_windowBackgroundGray",
77+ "key_windowBackgroundWhiteGrayText",
78+ "key_windowBackgroundWhiteGrayText2",
79+ "key_chats_message",
80+ "key_chats_date",
81+ "key_actionBarDefaultSubtitle",
82+ "key_chat_inTimeText",
83+ "key_chat_inFileInfoText",
84+ "key_chat_messagePanelHint",
85+ "key_windowBackgroundWhiteGrayText4",
86+ "key_chat_topPanelMessage",
87+ "key_chat_inViews",
88+ "key_chat_messagePanelIcons"
89+ ],
90+ "hook": {
91+ "muted": {
92+ "key": "key_chats_unreadCounterMuted",
93+ "pins": [
94+ "key_avatar_backgroundArchived",
95+ "key_chats_archivePinBackground",
96+ "key_chats_archivePullDownBackground",
97+ "key_checkboxDisabled"
98+ ],
99+ "table": {
100+ "Arctic Blue": [
101+ 4285757307,
102+ 4290692039
103+ ],
104+ "Blue": [
105+ 4285757307,
106+ 4290692039
107+ ],
108+ "Dark Blue": [
109+ 4282274403,
110+ 4284709259
111+ ],
112+ "Day": [
113+ 4285757307,
114+ 4290692039
115+ ],
116+ "Night": [
117+ 4282729797,
118+ 4286019190
119+ ]
120+ },
121+ "text": "key_chats_unreadCounterText"
122+ },
123+ "near_black": 4278519045,
124+ "out_block_custom": true,
125+ "pairs": [
126+ [
127+ "key_chats_actionIcon",
128+ "key_chats_actionBackground"
129+ ],
130+ [
131+ "key_chats_unreadCounterText",
132+ "key_chats_unreadCounter"
133+ ],
134+ [
135+ "key_featuredStickers_buttonText",
136+ "key_featuredStickers_addButton"
137+ ],
138+ [
139+ "key_checkboxSquareCheck",
140+ "key_checkboxSquareBackground"
141+ ],
142+ [
143+ "key_dialogRoundCheckBoxCheck",
144+ "key_dialogRoundCheckBox"
145+ ],
146+ [
147+ "key_chat_goDownButtonCounter",
148+ "key_chat_goDownButtonCounterBackground"
149+ ],
150+ [
151+ "key_dialogCheckboxSquareCheck",
152+ "key_dialogCheckboxSquareBackground"
153+ ],
154+ [
155+ "key_chat_attachCheckBoxCheck",
156+ "key_chat_attachCheckBoxBackground"
157+ ],
158+ [
159+ "key_chat_inMediaIcon",
160+ "key_chat_inLoader"
161+ ],
162+ [
163+ "key_chat_inContactIcon",
164+ "key_chat_inContactBackground"
165+ ],
166+ [
167+ "key_picker_badgeText",
168+ "key_picker_badge"
169+ ],
170+ [
171+ "key_dialogFloatingIcon",
172+ "key_dialogFloatingButton"
173+ ]
174+ ]
175+ },
176+ "tag": "canvas-A",
177+ "variant": "A"
178+}
multigram/tools/sim/palette-fix/overlay_B.jsonnew file+185
@@ -0,0 +1,185 @@
1+{
2+ "attheme": {
3+ "arctic.attheme": {
4+ "key_actionBarDefaultSubtitle": 4285626239,
5+ "key_chat_inFileInfoText": 4285495168,
6+ "key_chat_topPanelMessage": 4285691770,
7+ "key_windowBackgroundWhiteGrayText": 4285495170
8+ },
9+ "bluebubbles.attheme": {
10+ "key_actionBarDefaultSubtitle": 4285495169,
11+ "key_windowBackgroundWhiteGrayText": 4285822841,
12+ "key_windowBackgroundWhiteGrayText2": 4285822841,
13+ "key_windowBackgroundWhiteGrayText4": 4285296240
14+ },
15+ "darkblue.attheme": {
16+ "key_actionBarDefaultSubtitle": 2262561535,
17+ "key_chat_inFileInfoText": 4286944932,
18+ "key_chat_inTimeText": 4286944933,
19+ "key_chat_inViews": 4286944932,
20+ "key_chat_messagePanelHint": 2312892415,
21+ "key_chat_topPanelMessage": 2245850367,
22+ "key_chats_attachMessage": 4287466893,
23+ "key_chats_date": 4286680730,
24+ "key_chats_message": 4286615196,
25+ "key_windowBackgroundWhiteGrayText": 4286615196,
26+ "key_windowBackgroundWhiteGrayText2": 2079456767,
27+ "key_windowBackgroundWhiteGrayText4": 4085091225
28+ },
29+ "day.attheme": {
30+ "key_actionBarDefaultSubtitle": 4285626486,
31+ "key_chat_inFileInfoText": 4285295984,
32+ "key_chat_inTimeText": 4285295985,
33+ "key_chat_inViews": 4285230705,
34+ "key_chat_topPanelMessage": 4285626487,
35+ "key_windowBackgroundWhiteGrayText": 4285888376
36+ },
37+ "night.attheme": {
38+ "key_actionBarDefaultSubtitle": 2146628338,
39+ "key_chat_inFileInfoText": 4287072136,
40+ "key_chat_inTimeText": 4286941322,
41+ "key_chat_inViews": 4286810252,
42+ "key_chat_messagePanelHint": 1979711487,
43+ "key_chat_topPanelMessage": 1962934271,
44+ "key_chats_date": 4286677120,
45+ "key_windowBackgroundWhiteGrayText": 1962934271,
46+ "key_windowBackgroundWhiteGrayText2": 1962802687,
47+ "key_windowBackgroundWhiteGrayText4": 4285887604
48+ }
49+ },
50+ "defaults": {
51+ "key_actionBarDefaultSubtitle": 4285626486,
52+ "key_chat_attachCheckBoxCheck": 4278519045,
53+ "key_chat_goDownButtonCounter": 4278519045,
54+ "key_chat_inContactIcon": 4278519045,
55+ "key_chat_inFileInfoText": 4285495168,
56+ "key_chat_inMediaIcon": 4278519045,
57+ "key_chat_inTimeText": 4285495168,
58+ "key_chat_inViews": 4285495168,
59+ "key_chat_messagePanelHint": 4285757555,
60+ "key_chat_topPanelMessage": 4285626487,
61+ "key_chats_actionIcon": 4278519045,
62+ "key_chats_date": 4285888376,
63+ "key_chats_message": 4285822841,
64+ "key_chats_unreadCounterText": 4278519045,
65+ "key_checkboxSquareCheck": 4278519045,
66+ "key_dialogCheckboxSquareCheck": 4278519045,
67+ "key_dialogFloatingIcon": 4278519045,
68+ "key_dialogRoundCheckBoxCheck": 4278519045,
69+ "key_featuredStickers_buttonText": 4278519045,
70+ "key_picker_badgeText": 4278519045,
71+ "key_windowBackgroundWhiteGrayText": 4285888376,
72+ "key_windowBackgroundWhiteGrayText2": 4285757307,
73+ "key_windowBackgroundWhiteGrayText4": 4285492846
74+ },
75+ "exclusions": [
76+ "key_windowBackgroundWhite",
77+ "key_windowBackgroundGray",
78+ "key_actionBarDefault",
79+ "key_dialogBackground",
80+ "key_chat_messagePanelBackground",
81+ "key_chat_topPanelBackground",
82+ "key_chat_inBubble",
83+ "key_actionBarDefaultSubmenuBackground",
84+ "key_windowBackgroundWhiteGrayText",
85+ "key_windowBackgroundWhiteGrayText2",
86+ "key_chats_message",
87+ "key_chats_date",
88+ "key_actionBarDefaultSubtitle",
89+ "key_chat_inTimeText",
90+ "key_chat_inFileInfoText",
91+ "key_chat_messagePanelHint",
92+ "key_windowBackgroundWhiteGrayText4",
93+ "key_chat_topPanelMessage",
94+ "key_chat_inViews",
95+ "key_chat_messagePanelIcons"
96+ ],
97+ "hook": {
98+ "muted": {
99+ "key": "key_chats_unreadCounterMuted",
100+ "pins": [
101+ "key_avatar_backgroundArchived",
102+ "key_chats_archivePinBackground",
103+ "key_chats_archivePullDownBackground",
104+ "key_checkboxDisabled"
105+ ],
106+ "table": {
107+ "Arctic Blue": [
108+ 4285757307,
109+ 4290692039
110+ ],
111+ "Blue": [
112+ 4285757307,
113+ 4290692039
114+ ],
115+ "Dark Blue": [
116+ 4282274403,
117+ 4284709259
118+ ],
119+ "Day": [
120+ 4285757307,
121+ 4290692039
122+ ],
123+ "Night": [
124+ 4282729797,
125+ 4286019190
126+ ]
127+ },
128+ "text": "key_chats_unreadCounterText"
129+ },
130+ "near_black": 4278519045,
131+ "out_block_custom": true,
132+ "pairs": [
133+ [
134+ "key_chats_actionIcon",
135+ "key_chats_actionBackground"
136+ ],
137+ [
138+ "key_chats_unreadCounterText",
139+ "key_chats_unreadCounter"
140+ ],
141+ [
142+ "key_featuredStickers_buttonText",
143+ "key_featuredStickers_addButton"
144+ ],
145+ [
146+ "key_checkboxSquareCheck",
147+ "key_checkboxSquareBackground"
148+ ],
149+ [
150+ "key_dialogRoundCheckBoxCheck",
151+ "key_dialogRoundCheckBox"
152+ ],
153+ [
154+ "key_chat_goDownButtonCounter",
155+ "key_chat_goDownButtonCounterBackground"
156+ ],
157+ [
158+ "key_dialogCheckboxSquareCheck",
159+ "key_dialogCheckboxSquareBackground"
160+ ],
161+ [
162+ "key_chat_attachCheckBoxCheck",
163+ "key_chat_attachCheckBoxBackground"
164+ ],
165+ [
166+ "key_chat_inMediaIcon",
167+ "key_chat_inLoader"
168+ ],
169+ [
170+ "key_chat_inContactIcon",
171+ "key_chat_inContactBackground"
172+ ],
173+ [
174+ "key_picker_badgeText",
175+ "key_picker_badge"
176+ ],
177+ [
178+ "key_dialogFloatingIcon",
179+ "key_dialogFloatingButton"
180+ ]
181+ ]
182+ },
183+ "tag": "canvas-B",
184+ "variant": "B"
185+}
multigram/tools/sim/readability.pynew file+499
@@ -0,0 +1,499 @@
1+"""Readability suite on top of the ported colour engine.
2+
3+A pair is (fg, bg): fg is a colour key read with Theme.getColor(); bg is a colour key or one of the
4+derived surfaces below. Contrast is WCAG 2.2 (relative luminance from the exact 8-bit sRGB table),
5+after compositing any translucent colour over what is under it (src-over in gamma-encoded sRGB, as
6+Android's legacy 8888 pipeline does). Gradient surfaces are sampled (stops, midpoints, centroid) and
7+the minimum contrast over the samples is reported.
8+
9+Derived surfaces
10+ @in_bubble chat_inBubble (composited over the wallpaper samples when translucent)
11+ @out_bubble chat_outBubble, or its gradient when currentColors has chat_outBubbleGradient1:
12+ linear top-to-bottom gradient {g3, g2, g1, outBubble} (MessageDrawable.setTop) or,
13+ with >= 3 colours and the animated flag, the 4-point MotionBackgroundDrawable.
14+ @wallpaper what createBackgroundDrawable() shows for this accent: motion gradient (pattern
15+ intensity >= 0: the gradient; < 0: black, gradient only inside pattern strokes),
16+ two-colour BackgroundGradientDrawable, ColorDrawable, or the built-in 'd' wallpaper.
17+ @service service-message pill on a device with the default "chat background" lite-mode
18+ flag: gradient wallpapers -> the gradient bitmap through the ColorMatrix of
19+ applyChatServiceMessageColor() with WHITE text; otherwise chat_serviceBackground
20+ (or calcDrawableColor) over the wallpaper with chat_serviceText.
21+ @service_lite same pill when the gradient service shader is off (LiteMode chat background off or
22+ PERFORMANCE_CLASS_LOW): chat_serviceBackground / calcDrawableColor + chat_serviceText.
23+"""
24+import engine as E
25+import detmath as D
26+
27+TEXT = 4.5 # WCAG 2.2 SC 1.4.3, normal-size text
28+NONTEXT = 3.0 # WCAG 2.2 SC 1.4.11 (UI components / graphical objects), SC 1.4.3 large text
29+SEPARATION = 1.15 # bubble vs wallpaper: not a WCAG criterion; see PAIRS doc
30+
31+
32+def P(name, fg, bg, thr, why):
33+ return {"name": name, "fg": fg, "bg": bg, "threshold": thr, "why": why}
34+
35+
36+PAIRS = [
37+ # --- settings / list surfaces -------------------------------------------------------
38+ P("list_text", "windowBackgroundWhiteBlackText", "windowBackgroundWhite", TEXT, "primary 16sp list/settings text"),
39+ P("list_gray_text", "windowBackgroundWhiteGrayText", "windowBackgroundWhite", TEXT, "secondary 13-14sp text (last seen, descriptions)"),
40+ P("list_gray_text2", "windowBackgroundWhiteGrayText2", "windowBackgroundWhite", TEXT, "secondary/value text in cells"),
41+ P("list_accent_text", "windowBackgroundWhiteBlueText", "windowBackgroundWhite", TEXT, "accent text (online status, highlighted values), normal size"),
42+ P("list_accent_text2", "windowBackgroundWhiteBlueText2", "windowBackgroundWhite", TEXT, "accent text variant used in cells"),
43+ P("list_accent_text4", "windowBackgroundWhiteBlueText4", "windowBackgroundWhite", TEXT, "accent text-buttons in lists (e.g. 'Add account')"),
44+ P("list_section_header", "windowBackgroundWhiteBlueHeader", "windowBackgroundWhite", TEXT, "section headers are 15sp medium: not WCAG 'large'"),
45+ P("list_accent_button", "windowBackgroundWhiteBlueButton", "windowBackgroundWhite", TEXT, "text buttons in settings cells"),
46+ P("list_accent_icon", "windowBackgroundWhiteBlueIcon", "windowBackgroundWhite", NONTEXT, "accent icons: graphical object"),
47+ P("list_link", "windowBackgroundWhiteLinkText", "windowBackgroundWhite", TEXT, "links inside settings text"),
48+ P("list_value", "windowBackgroundWhiteValueText", "windowBackgroundWhite", TEXT, "right-aligned values in settings"),
49+ P("input_focus_line", "windowBackgroundWhiteInputFieldActivated", "windowBackgroundWhite", NONTEXT, "focused input underline: state indicator"),
50+ P("switch_on", "switchTrackChecked", "windowBackgroundWhite", NONTEXT, "switch ON state must be perceivable"),
51+ P("checkbox_on", "checkboxSquareBackground", "windowBackgroundWhite", NONTEXT, "checked checkbox fill vs page"),
52+ P("checkbox_mark", "checkboxSquareCheck", "checkboxSquareBackground", NONTEXT, "check mark glyph on the fill"),
53+ P("radio_on", "radioBackgroundChecked", "windowBackgroundWhite", NONTEXT, "selected radio button"),
54+ P("profile_tab_selected", "profile_tabSelectedText", "windowBackgroundWhite", TEXT, "profile shared-media tab label"),
55+ # --- dialogs ---------------------------------------------------------------------------
56+ P("dialog_text", "dialogTextBlack", "dialogBackground", TEXT, "dialog body text"),
57+ P("dialog_accent_text", "dialogTextBlue", "dialogBackground", TEXT, "accent text in dialogs"),
58+ P("dialog_link", "dialogTextLink", "dialogBackground", TEXT, "links in dialogs"),
59+ P("dialog_button", "dialogButton", "dialogBackground", TEXT, "OK/Cancel labels are 14sp medium: normal text"),
60+ P("dialog_checkbox", "dialogRoundCheckBox", "dialogBackground", NONTEXT, "selected round checkbox in dialogs"),
61+ P("dialog_checkbox_mark", "dialogRoundCheckBoxCheck", "dialogRoundCheckBox", NONTEXT, "check glyph on it"),
62+ # --- chat list ------------------------------------------------------------------------
63+ P("chats_name", "chats_name", "windowBackgroundWhite", TEXT, "chat title in the list"),
64+ P("chats_preview", "chats_message", "windowBackgroundWhite", TEXT, "message preview"),
65+ P("chats_preview_sender", "chats_nameMessage", "windowBackgroundWhite", TEXT, "sender name in group previews (accent)"),
66+ P("chats_preview_action", "chats_actionMessage", "windowBackgroundWhite", TEXT, "typing.../media label (accent)"),
67+ P("chats_date", "chats_date", "windowBackgroundWhite", TEXT, "time/date in the list"),
68+ P("unread_badge_text", "chats_unreadCounterText", "chats_unreadCounter", TEXT, "13sp bold digits: below the 14pt-bold 'large' limit"),
69+ P("unread_badge", "chats_unreadCounter", "windowBackgroundWhite", NONTEXT, "badge must stand out from the row"),
70+ P("read_checks", "chats_sentReadCheck", "windowBackgroundWhite", NONTEXT, "read-status icon"),
71+ P("fab_icon", "chats_actionIcon", "chats_actionBackground", NONTEXT, "pencil icon on the floating action button"),
72+ P("fab", "chats_actionBackground", "windowBackgroundWhite", NONTEXT, "floating action button vs list"),
73+ P("folder_tab_active", "actionBarTabActiveText", "actionBarDefault", TEXT, "selected folder tab label (sits on the action bar)"),
74+ P("folder_tab_line", "actionBarTabLine", "actionBarDefault", NONTEXT, "selected folder tab indicator"),
75+ # --- action bar -----------------------------------------------------------------------
76+ P("actionbar_title", "actionBarDefaultTitle", "actionBarDefault", TEXT, "18sp medium title: not WCAG 'large' (needs 18.66px bold or 24px)"),
77+ P("actionbar_icons", "actionBarDefaultIcon", "actionBarDefault", NONTEXT, "back/menu icons"),
78+ P("actionbar_subtitle", "actionBarDefaultSubtitle", "actionBarDefault", TEXT, "subtitle text"),
79+ P("chat_header_status", "chat_status", "actionBarDefault", TEXT, "'online'/'typing' in the chat header (accent)"),
80+ # --- incoming bubble --------------------------------------------------------------------
81+ P("in_text", "chat_messageTextIn", "@in_bubble", TEXT, "message body"),
82+ P("in_link", "chat_messageLinkIn", "@in_bubble", TEXT, "links (accent)"),
83+ P("in_time", "chat_inTimeText", "@in_bubble", TEXT, "12sp time stamp"),
84+ P("in_reply_name", "chat_inReplyNameText", "@in_bubble", TEXT, "reply header name (accent)"),
85+ P("in_reply_text", "chat_inReplyMessageText", "@in_bubble", TEXT, "quoted reply text"),
86+ P("in_forward_name", "chat_inForwardedNameText", "@in_bubble", TEXT, "'Forwarded from' name (accent)"),
87+ P("in_site_name", "chat_inSiteNameText", "@in_bubble", TEXT, "link-preview site name (accent)"),
88+ P("in_file_info", "chat_inFileInfoText", "@in_bubble", TEXT, "file size / duration"),
89+ P("in_instant_button", "chat_inPreviewInstantText", "@in_bubble", TEXT, "Instant View button label (accent)"),
90+ P("in_reply_line", "chat_inReplyLine", "@in_bubble", NONTEXT, "reply bar (accent)"),
91+ P("in_media_button", "chat_inLoader", "@in_bubble", NONTEXT, "file/voice play button (accent)"),
92+ P("in_voice_progress", "chat_inVoiceSeekbarFill", "@in_bubble", NONTEXT, "voice-message waveform progress (accent)"),
93+ # --- outgoing bubble --------------------------------------------------------------------
94+ P("out_text", "chat_messageTextOut", "@out_bubble", TEXT, "message body (every gradient sample)"),
95+ P("out_link", "chat_messageLinkOut", "@out_bubble", TEXT, "links"),
96+ P("out_time", "chat_outTimeText", "@out_bubble", TEXT, "12sp time stamp"),
97+ P("out_read_check", "chat_outSentCheckRead", "@out_bubble", NONTEXT, "read-status checks"),
98+ P("out_reply_name", "chat_outReplyNameText", "@out_bubble", TEXT, "reply header name"),
99+ P("out_reply_text", "chat_outReplyMessageText", "@out_bubble", TEXT, "quoted reply text"),
100+ P("out_forward_name", "chat_outForwardedNameText", "@out_bubble", TEXT, "'Forwarded from' name"),
101+ P("out_site_name", "chat_outSiteNameText", "@out_bubble", TEXT, "link-preview site name"),
102+ P("out_file_info", "chat_outFileInfoText", "@out_bubble", TEXT, "file size / duration (subTextColor in gradient mode)"),
103+ P("out_instant_button", "chat_outPreviewInstantText", "@out_bubble", TEXT, "Instant View button label"),
104+ P("out_reply_line", "chat_outReplyLine", "@out_bubble", NONTEXT, "reply bar"),
105+ P("out_media_button", "chat_outLoader", "@out_bubble", NONTEXT, "file/voice play button"),
106+ P("out_voice_progress", "chat_outVoiceSeekbarFill", "@out_bubble", NONTEXT, "voice waveform progress"),
107+ # --- composer / panels -----------------------------------------------------------------
108+ P("composer_text", "chat_messagePanelText", "chat_messagePanelBackground", TEXT, "typed text"),
109+ P("composer_hint", "chat_messagePanelHint", "chat_messagePanelBackground", TEXT, "placeholder text (WCAG applies to placeholders)"),
110+ P("composer_send", "chat_messagePanelSend", "chat_messagePanelBackground", NONTEXT, "send button icon (accent)"),
111+ P("composer_icons", "chat_messagePanelIcons", "chat_messagePanelBackground", NONTEXT, "attach/emoji icons"),
112+ P("reply_panel_name", "chat_replyPanelName", "chat_messagePanelBackground", TEXT, "'Reply to X' name above the composer (accent)"),
113+ P("pinned_title", "chat_topPanelTitle", "chat_topPanelBackground", TEXT, "pinned-message bar title (accent)"),
114+ P("filled_button_text", "featuredStickers_buttonText", "featuredStickers_addButton", TEXT, "label of accent-filled buttons (Join, Add, Start)"),
115+ # --- added by sim-verify: frequently read pairs missing from the original 75 -------------
116+ P("settings_footnote", "windowBackgroundWhiteGrayText4", "windowBackgroundGray", TEXT, "TextInfoPrivacyCell footnotes on the grey settings background"),
117+ P("chats_attach_label", "chats_attachMessage", "windowBackgroundWhite", TEXT, "'Photo'/'Voice message' label in chat-list previews (accent)"),
118+ P("chats_muted_badge_text", "chats_unreadCounterText", "chats_unreadCounterMuted", TEXT, "digits on muted-chat unread badges"),
119+ P("popup_menu_item", "actionBarDefaultSubmenuItem", "actionBarDefaultSubmenuBackground", TEXT, "popup/overflow menu items"),
120+ P("pinned_text", "chat_topPanelMessage", "chat_topPanelBackground", TEXT, "pinned-message bar text"),
121+ P("go_down_counter", "chat_goDownButtonCounter", "chat_goDownButtonCounterBackground", TEXT, "unread counter on the scroll-down button (accent fill)"),
122+ P("list_accent_text3", "windowBackgroundWhiteBlueText3", "windowBackgroundWhite", TEXT, "accent text variant"),
123+ P("list_accent_text5", "windowBackgroundWhiteBlueText5", "windowBackgroundWhite", TEXT, "accent text variant"),
124+ P("list_accent_text6", "windowBackgroundWhiteBlueText6", "windowBackgroundWhite", TEXT, "accent text variant"),
125+ P("list_accent_text7", "windowBackgroundWhiteBlueText7", "windowBackgroundWhite", TEXT, "accent text variant"),
126+ P("dialog_accent_text2", "dialogTextBlue2", "dialogBackground", TEXT, "accent text in dialogs"),
127+ P("dialog_accent_text4", "dialogTextBlue4", "dialogBackground", TEXT, "accent text in dialogs"),
128+ P("in_file_name", "chat_inFileNameText", "@in_bubble", TEXT, "file name in incoming documents (accent)"),
129+ P("in_via_bot", "chat_inViaBotNameText", "@in_bubble", TEXT, "'via @bot' (accent)"),
130+ P("in_views", "chat_inViews", "@in_bubble", TEXT, "channel view counter"),
131+ P("out_file_name", "chat_outFileNameText", "@out_bubble", TEXT, "file name in outgoing documents"),
132+ P("out_views", "chat_outViews", "@out_bubble", TEXT, "view counter in outgoing channel posts"),
133+ P("out_audio_duration", "chat_outAudioDurationText", "@out_bubble", TEXT, "audio duration (subTextColor)"),
134+ # --- added by canvas-fix: content drawn on accent fills (the on-accent rule's other keys) ---------
135+ P("dialog_checkbox_square_mark", "dialogCheckboxSquareCheck", "dialogCheckboxSquareBackground", NONTEXT, "CheckBoxSquare(isAlert) mark (CheckBoxSquare.java:59)"),
136+ P("attach_checkbox_mark", "chat_attachCheckBoxCheck", "chat_attachCheckBoxBackground", NONTEXT, "photo-picker selection check (PhotoAttachPhotoCell.java:276)"),
137+ P("in_media_icon", "chat_inMediaIcon", "chat_inLoader", NONTEXT, "play/download glyph on the incoming loader circle (ChatMessageCell.java:15057)"),
138+ P("in_contact_icon", "chat_inContactIcon", "chat_inContactBackground", NONTEXT, "contact glyph on its circle (Theme.java:8533-8534)"),
139+ P("picker_badge_text", "picker_badgeText", "picker_badge", TEXT, "count on the picker Done badge (PickerBottomLayout.java:66)"),
140+ P("dialog_fab_icon", "dialogFloatingIcon", "dialogFloatingButton", NONTEXT, "floating send button glyph in share/caption sheets"),
141+ # --- wallpaper & service messages --------------------------------------------------------
142+ P("service_text", "chat_serviceText", "@service", TEXT, "date headers / service messages (14sp); WHITE is used instead of chat_serviceText when the gradient service shader is active"),
143+ P("service_text_lite", "chat_serviceText", "@service_lite", TEXT, "same, gradient service shader off (lite mode / low-end devices)"),
144+ P("in_bubble_vs_wallpaper", "chat_inBubble", "@wallpaper", SEPARATION, "bubble shape must separate from the wallpaper (1.15:1, min over wallpaper samples)"),
145+ P("out_bubble_vs_wallpaper", "chat_outBubble", "@wallpaper", SEPARATION, "outgoing bubble (all gradient samples) vs wallpaper samples"),
146+]
147+
148+PAIR_INDEX = {p["name"]: i for i, p in enumerate(PAIRS)}
149+
150+
151+def check_keys(model):
152+ """Every key named by the suite must exist in Theme.java."""
153+ missing = []
154+ for p in PAIRS:
155+ for k in (p["fg"], p["bg"]):
156+ if not k.startswith("@") and ("key_" + k) not in model.K:
157+ missing.append(k)
158+ return missing
159+
160+
161+# ------------------------------------------------------------------------------------------
162+# colour helpers (gamma-space, 8-bit)
163+# ------------------------------------------------------------------------------------------
164+
165+def rgb(c):
166+ return c & 0xFFFFFF
167+
168+
169+def over(fg, bg):
170+ """src-over of an ARGB colour on an opaque background (8-bit, rounded)."""
171+ a = (fg >> 24) & 0xFF
172+ if a == 255:
173+ return fg & 0xFFFFFF
174+ if a == 0:
175+ return bg & 0xFFFFFF
176+ ia = 255 - a
177+ r = (((fg >> 16) & 0xFF) * a + ((bg >> 16) & 0xFF) * ia + 127) // 255
178+ g = (((fg >> 8) & 0xFF) * a + ((bg >> 8) & 0xFF) * ia + 127) // 255
179+ b = ((fg & 0xFF) * a + (bg & 0xFF) * ia + 127) // 255
180+ return (r << 16) | (g << 8) | b
181+
182+
183+def mix(c1, c2):
184+ return ((((c1 >> 16) & 0xFF) + ((c2 >> 16) & 0xFF) + 1) // 2 << 16) | \
185+ ((((c1 >> 8) & 0xFF) + ((c2 >> 8) & 0xFF) + 1) // 2 << 8) | \
186+ (((c1 & 0xFF) + (c2 & 0xFF) + 1) // 2)
187+
188+
189+def centroid(cs):
190+ n = len(cs)
191+ r = sum((c >> 16) & 0xFF for c in cs)
192+ g = sum((c >> 8) & 0xFF for c in cs)
193+ b = sum(c & 0xFF for c in cs)
194+ return (((r + n // 2) // n) << 16) | (((g + n // 2) // n) << 8) | ((b + n // 2) // n)
195+
196+
197+def samples_motion(cs):
198+ """4-point (or 3-point) MotionBackgroundDrawable gradient: corners, pairwise midpoints, centroid."""
199+ cs = [c & 0xFFFFFF for c in cs]
200+ out = list(cs)
201+ for i in range(len(cs)):
202+ for j in range(i + 1, len(cs)):
203+ out.append(mix(cs[i], cs[j]))
204+ out.append(centroid(cs))
205+ return _uniq(out)
206+
207+
208+def samples_linear(cs):
209+ cs = [c & 0xFFFFFF for c in cs]
210+ out = list(cs)
211+ for i in range(len(cs) - 1):
212+ out.append(mix(cs[i], cs[i + 1]))
213+ return _uniq(out)
214+
215+
216+def _uniq(xs):
217+ seen = set()
218+ out = []
219+ for x in xs:
220+ if x not in seen:
221+ seen.add(x)
222+ out.append(x)
223+ return out
224+
225+
226+# ------------------------------------------------------------------------------------------
227+# wallpaper + service pill model
228+# ------------------------------------------------------------------------------------------
229+
230+BLACK = 0x000000
231+WHITE = 0xFFFFFF
232+
233+
234+def _java_hsv_double(c):
235+ """AndroidUtilities.rgbToHsv (double)."""
236+ rf, gf, bf = ((c >> 16) & 0xFF) / 255.0, ((c >> 8) & 0xFF) / 255.0, (c & 0xFF) / 255.0
237+ mx = rf if (rf > gf and rf > bf) else max(gf, bf)
238+ mn = rf if (rf < gf and rf < bf) else min(gf, bf)
239+ d = mx - mn
240+ s = 0 if mx == 0 else d / mx
241+ if mx == mn:
242+ h = 0.0
243+ else:
244+ if rf > gf and rf > bf:
245+ h = (gf - bf) / d + (6 if gf < bf else 0)
246+ elif gf > bf:
247+ h = (bf - rf) / d + 2
248+ else:
249+ h = (rf - gf) / d + 4
250+ h /= 6
251+ return h, s, mx
252+
253+
254+def _java_hsv_to_rgb(h, s, v):
255+ """AndroidUtilities.hsvToRgb (double, truncating)."""
256+ import math
257+ i = float(int(math.floor(h * 6)))
258+ f = h * 6 - i
259+ p = v * (1 - s)
260+ q = v * (1 - f * s)
261+ t = v * (1 - (1 - f) * s)
262+ k = int(i) % 6
263+ r, g, b = [(v, t, p), (q, v, p), (p, v, t), (p, q, v), (t, p, v), (v, p, q)][k]
264+ return int(r * 255), int(g * 255), int(b * 255)
265+
266+
267+def calc_drawable_color(kind, colors):
268+ """AndroidUtilities.calcDrawableColor(drawable)[0] (serviceMessageColor)."""
269+ if kind == "motion":
270+ return 0x2D000000
271+ if kind == "gradient":
272+ bc = E.average_color(colors[0], colors[1])
273+ else:
274+ bc = colors[0]
275+ h, s, v = _java_hsv_double(bc)
276+ s = min(1.0, s + 0.05 + 0.1 * (1.0 - s))
277+ v = max(0, v * 0.65)
278+ r, g, b = _java_hsv_to_rgb(h, s, v)
279+ return (0x66 << 24) | (r << 16) | (g << 8) | b
280+
281+
282+def service_matrix(model, is_dark, intensity):
283+ sm = model.parsed["theme_java"]["service_matrix"]
284+ br = sm["intensity_ge0"] if intensity >= 0 else sm["intensity_lt0"]
285+ sat = E.f32(br["sat"])
286+ mult = E.f32(br["mult_dark"] if is_dark else br["mult_light"])
287+ add = E.f32(E.f32(br["add_dark"] if is_dark else br["add_light"]) * 255.0)
288+ inv = E.f32(1.0 - sat)
289+ R, G, B = E.f32(E.f32(0.213) * inv), E.f32(E.f32(0.715) * inv), E.f32(E.f32(0.072) * inv)
290+ rows = [(R + sat, G, B), (R, G + sat, B), (R, G, B + sat)]
291+ return [(mult * a, mult * b, mult * c, add) for a, b, c in rows]
292+
293+
294+def apply_matrix(mat, c):
295+ r, g, b = (c >> 16) & 0xFF, (c >> 8) & 0xFF, c & 0xFF
296+ out = []
297+ for a, bb, cc, t in mat:
298+ v = a * r + bb * g + cc * b + t
299+ v = 0 if v < 0 else (255 if v > 255 else v)
300+ out.append(int(v + 0.5))
301+ return (out[0] << 16) | (out[1] << 8) | out[2]
302+
303+
304+def entry(pal, name):
305+ """(present, value) of a key in currentColors."""
306+ k = pal.model.K[name]
307+ if isinstance(pal, E.LazyPalette):
308+ return pal.cur_entry(k)
309+ return (k in pal.colors, pal.colors.get(k, 0))
310+
311+
312+class Wallpaper:
313+ __slots__ = ("kind", "colors", "intensity", "pattern", "rotation", "fill", "gradient", "source",
314+ "service", "service_text", "service_lite", "service_lite_text", "shader")
315+
316+
317+def wallpaper_model(pal):
318+ """Theme.loadWallpaper()/createBackgroundDrawable() for a theme+accent with no override wallpaper,
319+ plus applyChatServiceMessageColor()."""
320+ th, m, acc = pal.theme, pal.model, pal.accent
321+ w = Wallpaper()
322+ default_theme = th.firstAccentIsDefault and pal.current_id == m.DEFAULT_ACCENT_ID
323+ bg = 0 if default_theme else pal.raw("chat_wallpaper")
324+ g1 = pal.raw("chat_wallpaper_gradient_to1")
325+ g2 = pal.raw("chat_wallpaper_gradient_to2")
326+ g3 = pal.raw("chat_wallpaper_gradient_to3")
327+ rot_present, rot = entry(pal, "key_chat_wallpaper_gradient_rotation")
328+ w.rotation = rot if rot_present else 45
329+ # (int) (accent.patternIntensity * 100): float multiply, truncation
330+ w.intensity = E.f2i(E.f32(acc.patternIntensity * 100.0))
331+ has_pattern_file = bool(acc.patternSlug) # ThemeAccent.getPathToWallpaper() != null
332+ if bg != 0:
333+ w.source = "accent/theme colours"
334+ if g1 != 0 and g2 != 0:
335+ w.kind, w.colors, w.pattern = "motion", [bg, g1, g2, g3], has_pattern_file
336+ elif g1 == 0 or g1 == bg:
337+ w.kind, w.colors, w.pattern = "color", [bg], False
338+ else:
339+ w.kind, w.colors, w.pattern = "gradient", [bg, g1], False
340+ else:
341+ dw = m.parsed["theme_java"]["default_wallpaper"]
342+ w.source = "createDefaultWallpaper()"
343+ w.kind, w.colors, w.pattern, w.intensity = "motion", list(dw["colors"]), True, dw["intensity"]
344+ cs = [c for c in w.colors if c != 0]
345+ if w.kind == "motion":
346+ w.gradient = samples_motion(cs)
347+ if w.intensity < 0:
348+ w.fill = [BLACK] # black; the gradient only shows through pattern strokes
349+ else:
350+ w.fill = w.gradient
351+ elif w.kind == "gradient":
352+ w.gradient = samples_linear(cs)
353+ w.fill = w.gradient
354+ else:
355+ w.gradient = [cs[0] & 0xFFFFFF]
356+ w.fill = w.gradient
357+ # --- service pills ----------------------------------------------------------------------
358+ K = m.K
359+ present, sb = entry(pal, "key_chat_serviceBackground")
360+ pill_color = sb if present else calc_drawable_color(w.kind, cs)
361+ lite = [over(pill_color, f) for f in w.fill]
362+ lite_text = pal.get("chat_serviceText")
363+ w.service_lite, w.service_lite_text = lite, lite_text
364+ if w.kind == "motion":
365+ w.shader = True
366+ mat = service_matrix(m, th.is_dark, w.intensity)
367+ w.service = _uniq([apply_matrix(mat, c) for c in w.gradient])
368+ w.service_text = E.s32(0xFFFFFFFF)
369+ else:
370+ w.shader = False
371+ w.service, w.service_text = lite, lite_text
372+ return w
373+
374+
375+# ------------------------------------------------------------------------------------------
376+# evaluation
377+# ------------------------------------------------------------------------------------------
378+
379+def out_bubble_samples(pal):
380+ """Colours behind outgoing-message text. Solid: getColor(chat_outBubble) (ARGB, may be translucent);
381+ gradient: opaque RGB samples of the shader MessageDrawable.setTop() builds."""
382+ base = pal.get("chat_outBubble")
383+ g1 = pal.raw("chat_outBubbleGradient1")
384+ if g1 == 0:
385+ return [base], "solid"
386+ g2 = pal.raw("chat_outBubbleGradient2")
387+ g3 = pal.raw("chat_outBubbleGradient3")
388+ animated = pal.raw("chat_outBubbleGradientAnimated") != 0
389+ if g2 != 0 and animated:
390+ return samples_motion([base, g1, g2] + ([g3] if g3 != 0 else [])), "motion"
391+ if g2 != 0:
392+ stops = [g3, g2, g1, base] if g3 != 0 else [g2, g1, base]
393+ else:
394+ stops = [g1, base]
395+ return samples_linear(stops), "linear"
396+
397+
398+class Evaluator:
399+ """Computes every pair's contrast for one palette (lazily, memoised)."""
400+
401+ def __init__(self, pal):
402+ self.pal = pal
403+ self._wp = None
404+ self._in = None
405+ self._out = None
406+ self._surf = {}
407+
408+ @property
409+ def wp(self):
410+ if self._wp is None:
411+ self._wp = wallpaper_model(self.pal)
412+ return self._wp
413+
414+ def surface(self, bg):
415+ s = self._surf.get(bg)
416+ if s is not None:
417+ return s
418+ pal = self.pal
419+ if bg == "@wallpaper":
420+ s = self.wp.fill
421+ elif bg == "@in_bubble":
422+ c = pal.get("chat_inBubble")
423+ s = [c & 0xFFFFFF] if ((c >> 24) & 0xFF) == 255 else _uniq([over(c, f) for f in self.wp.fill])
424+ elif bg == "@out_bubble":
425+ cs, mode = out_bubble_samples(pal)
426+ if mode != "solid" or ((cs[0] >> 24) & 0xFF) == 255:
427+ s = [c & 0xFFFFFF for c in cs]
428+ else:
429+ s = _uniq([over(cs[0], f) for f in self.wp.fill])
430+ elif bg == "@service":
431+ s = self.wp.service
432+ elif bg == "@service_lite":
433+ s = self.wp.service_lite
434+ else:
435+ c = pal.get(bg)
436+ if ((c >> 24) & 0xFF) != 255:
437+ c = over(c, pal.get("windowBackgroundWhite"))
438+ s = [c & 0xFFFFFF]
439+ self._surf[bg] = s
440+ return s
441+
442+ def contrast(self, i):
443+ p = PAIRS[i]
444+ bg = p["bg"]
445+ if bg == "@wallpaper":
446+ # separation: bubble (all its samples, composited on the wallpaper) vs wallpaper samples
447+ bub = self.surface("@in_bubble" if p["fg"] == "chat_inBubble" else "@out_bubble")
448+ best = 1e9
449+ for f in self.wp.fill:
450+ for b in bub:
451+ c = D.contrast(b, f)
452+ if c < best:
453+ best = c
454+ return best
455+ if bg == "@service":
456+ fg = self.wp.service_text
457+ elif bg == "@service_lite":
458+ fg = self.wp.service_lite_text
459+ else:
460+ fg = self.pal.get(p["fg"])
461+ best = 1e9
462+ for s in self.surface(bg):
463+ c = D.contrast(over(fg, s), s)
464+ if c < best:
465+ best = c
466+ return best
467+
468+ def all(self, idx=None):
469+ if idx is None:
470+ idx = range(len(PAIRS))
471+ return {i: self.contrast(i) for i in idx}
472+
473+
474+# ------------------------------------------------------------------------------------------
475+# baseline
476+# ------------------------------------------------------------------------------------------
477+
478+_BASELINE = {}
479+
480+
481+def stock_palette(theme):
482+ acc = theme.stock_accent()
483+ return E.palette(theme, acc, acc.id)
484+
485+
486+def baseline(theme):
487+ key = (theme.model.tag, theme.name)
488+ b = _BASELINE.get(key)
489+ if b is None:
490+ ev = Evaluator(stock_palette(theme))
491+ b = ev.all()
492+ _BASELINE[key] = b
493+ return b
494+
495+
496+def effective_thresholds(theme):
497+ """Pass rule: contrast >= threshold OR contrast >= the stock contrast of that pair on this theme."""
498+ b = baseline(theme)
499+ return [min(PAIRS[i]["threshold"], b[i]) for i in range(len(PAIRS))]
multigram/tools/sim/style_sim.pynew file+919
@@ -0,0 +1,919 @@
1+#!/usr/bin/env python3
2+"""Seeded, readability-checked style generator for the Telegram-Android theme engine (simulation).
3+
4+ python3 style_sim.py --seed X genome JSON for seed string X
5+ python3 style_sim.py --measure N pass rates of (a) uniform RGB, (b) uniform HSV, (c) the
6+ constrained OKLCH generator, N seeds per base theme
7+ python3 style_sim.py --baseline the suite on each base theme with its stock default accent
8+
9+Everything colour-related runs through engine.py (bit-exact port of fillAccentColors / getColor,
10+verified against the verbatim Java) and readability.py (pair suite, wallpaper and service models).
11+Generator math uses only + - * / floor and comparisons on IEEE doubles plus literal tables
12+(detmath.py), so a Java or JavaScript port reproduces the same genome bit for bit.
13+"""
14+import argparse
15+import hashlib
16+import json
17+import math
18+import os
19+import sys
20+import time
21+
22+HERE = os.path.dirname(os.path.abspath(__file__))
23+sys.path.insert(0, HERE)
24+
25+import detmath as D # noqa: E402
26+import engine as E # noqa: E402
27+import readability as R # noqa: E402
28+
29+GENOME_VERSION = 1
30+LIGHT_THEMES = ["Blue", "Arctic Blue", "Day"]
31+DARK_THEMES = ["Dark Blue", "Night"]
32+ALL_THEMES = ["Blue", "Dark Blue", "Arctic Blue", "Day", "Night"]
33+GEN_ACCENT_ID = 101 # first id ThemeInfo.createNewAccent()/getAccent(true) hands out (lastAccentId = 100)
34+DELETE = 0x100000000 # ThemeAccent "remove this wallpaper key" sentinel (long), as setAccentColorOptions uses
35+BUBBLE_RADIUS_RANGE = (0, 17) # ThemeActivity bubble-radius slider (SharedConfig.bubbleRadius, default 17)
36+# The 25 distinct non-empty pattern slugs of the built-in accent presets, in source order
37+# (Theme.java:3909 Blue, 3933 Dark Blue, 3957 Arctic Blue, 3981 Day (none), 4005 Night).
38+# Checked against the parsed source at start-up (check_pattern_slugs).
39+PATTERN_SLUGS = [
40+ "p-pXcflrmFIBAAAAvXYQk-mCwZU", "JqSUrO0-mFIBAAAAWwTvLzoWGQI", "O-wmAfBPSFADAAAA4zINVfD_bro", "RepJ5uE_SVABAAAAr4d0YhgB850",
41+ "-Xc-np9y2VMCAAAARKr0yNNPYW0", "fqv01SQemVIBAAAApND8LDRUhRU", "lp0prF8ISFAEAAAA_p385_CvG0w", "heptcj-hSVACAAAAC9RrMzOa-cs",
42+ "PllZ-bf_SFAEAAAA8crRfwZiDNg", "dhf9pceaQVACAAAAbzdVo4SCiZA", "Ujx2TFcJSVACAAAARJ4vLa50MkM", "dk_wwlghOFACAAAAfz9xrxi6euw",
43+ "9LW_RcoOSVACAAAAFTk3DTyXN-M", "kO4jyq55SFABAAAA0WEpcLfahXk", "CJNyxPMgSVAEAAAAvW9sMwc51cw", "9GcNVISdSVADAAAAUcw5BYjELW4",
44+ "F5oWoCs7QFACAAAAgf2bD_mg8Bw", "9ShF73d1MFIIAAAAjWnm8_ZMe8Q", "3rX-PaKbSFACAAAAEiHNvcEm6X4", "MIo6r0qGSFAFAAAAtL8TsDzNX60",
45+ "pgJfpFNRSFABAAAACDT8s5sEjfc", "ptuUd96JSFACAAAATobI23sPpz0", "9iklpvIPQVABAAAAORQXKur_Eyc", "YIxYGEALQVADAAAAA3QbEH0AowY",
46+ "Nl8Pg2rBQVACAAAA25Lxtb8SDp0",
47+]
48+
49+
50+def check_pattern_slugs(model):
51+ seen = []
52+ for tn in ALL_THEMES:
53+ th = model.themes[tn]
54+ for aid in th.accent_order:
55+ x = th.accents[aid].patternSlug
56+ if x and x not in seen:
57+ seen.append(x)
58+ return seen == PATTERN_SLUGS
59+
60+MASK64 = (1 << 64) - 1
61+
62+
63+# ------------------------------------------------------------------------------------------
64+# PRNG: seed string -> SHA-256 -> first 8 bytes big-endian -> SplitMix64
65+# ------------------------------------------------------------------------------------------
66+
67+def seed_state(seed):
68+ return int.from_bytes(hashlib.sha256(seed.encode("utf-8")).digest()[:8], "big")
69+
70+
71+class SplitMix64:
72+ def __init__(self, state):
73+ self.s = state & MASK64
74+
75+ def next_u64(self):
76+ self.s = (self.s + 0x9E3779B97F4A7C15) & MASK64
77+ z = self.s
78+ z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & MASK64
79+ z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & MASK64
80+ return z ^ (z >> 31)
81+
82+ def uniform(self):
83+ """[0, 1) with 53 random bits: (x >>> 11) * 2^-53."""
84+ return (self.next_u64() >> 11) * (1.0 / 9007199254740992.0)
85+
86+ def between(self, a, b):
87+ return a + (b - a) * self.uniform()
88+
89+ def below(self, n):
90+ return int(math.floor(self.uniform() * n))
91+
92+ def fork(self):
93+ """Child stream seeded with the next output (keeps day/night/misc independent)."""
94+ return SplitMix64(self.next_u64())
95+
96+
97+# ------------------------------------------------------------------------------------------
98+# generator constants
99+# ------------------------------------------------------------------------------------------
100+
101+C = {
102+ # accent (OKLCH); L is solved per attempt by bisection inside [L_lo, L_hi] starting at L_start
103+ "accent_chroma": {"light": (0.07, 0.17), "dark": (0.07, 0.17)},
104+ "accent_L": {"light": (0.30, 0.80, (0.42, 0.70)), "dark": (0.45, 0.92, (0.62, 0.80))}, # lo, hi, start range (uniform)
105+ "accent_steps": 8,
106+ # outgoing bubble
107+ "bubble_count_weights": [(1, 0.30), (2, 0.30), (3, 0.15), (4, 0.25)], # 1 = solid (gradient1 == colour)
108+ "bubble_hue_offset": 50.0, # bubble hue = accent hue + U(-x, x)
109+ "bubble_extra_hue": 35.0, # each extra gradient stop: bubble hue + U(-x, x)
110+ "bubble_extra_L": 0.03, # each extra stop: L + U(-x, x)
111+ "bubble_light_share": 0.55, # light themes: share of light (black-text) bubbles
112+ "bubble_light": {"L": (0.905, 0.955), "C": (0.025, 0.075), "step": 0.012, "L_max": 0.975},
113+ "bubble_dark_on_light": {"L": (0.40, 0.50), "C": (0.08, 0.16), "step": -0.025, "L_min": 0.30},
114+ "bubble_dark_on_dark": {"L": (0.36, 0.50), "C": (0.06, 0.16), "step": -0.025, "L_min": 0.28},
115+ "bubble_steps": 4,
116+ "animated_share": 0.5, # >= 3 stops: animated flag
117+ # wallpaper
118+ "wall_count_weights": [(1, 0.15), (2, 0.20), (3, 0.15), (4, 0.50)],
119+ "wall_hue_offset": 50.0, # wallpaper hue = accent hue + U(-x, x) (+180 with p=complement_share)
120+ "wall_complement_share": 0.20,
121+ "wall_extra_hue": 25.0,
122+ "wall_extra_L": 0.05,
123+ "wall": {
124+ "light": {"C": (0.02, 0.07), "L": (0.50, 0.90), "L_start": (0.72, 0.84)},
125+ "dark": {"C": (0.015, 0.05), "L": (0.06, 0.40), "L_start": (0.12, 0.20)},
126+ "dark_neg": {"C": (0.03, 0.10), "L": (0.25, 0.75), "L_start": (0.40, 0.60)}, # intensity < 0: seen through the pattern only
127+ },
128+ "wall_steps": 7,
129+ "intensity": {"light": (0.25, 0.60), "dark_pos": (0.20, 0.50), "dark_neg": (-0.75, -0.35)},
130+ "dark_negative_share": 0.40,
131+ "pattern_share": 0.80, # motion wallpapers with a pattern (negative intensity always has one)
132+ "rotations": [0, 45, 90, 135, 180, 225, 270, 315],
133+ "bubble_radius": (6, 17),
134+ "max_attempts": 12,
135+ # accent policy under the strict pass rule (derived from the feasibility scan, see findings):
136+ # "free" = OKLCH hue/chroma drawn, lightness solved; "anchored" = accentColor kept at the theme's
137+ # stock default accent (main-accent keys stay exactly stock), only bubbles and wallpaper vary.
138+ # "wallpaper-only" = accent and outgoing bubble both kept at the stock default accent's values.
139+ "accent_policy": {"Blue": "wallpaper-only", "Dark Blue": "anchored", "Night": "anchored", "Arctic Blue": "free", "Day": "free"},
140+ "free_mode_slack": 0.10,
141+}
142+
143+
144+def weighted(rng, table):
145+ u = rng.uniform()
146+ acc = 0.0
147+ for v, w in table:
148+ acc += w
149+ if u < acc:
150+ return v
151+ return table[-1][0]
152+
153+
154+def argb_from_oklch(L, Cc, h):
155+ r, g, b, _ = D.oklch_to_rgb8(L, Cc, h)
156+ return E.s32(0xFF000000 | (r << 16) | (g << 8) | b)
157+
158+
159+def hex32(c):
160+ return "0x%08X" % (c & 0xFFFFFFFF)
161+
162+
163+# ------------------------------------------------------------------------------------------
164+# genome <-> ThemeAccent
165+# ------------------------------------------------------------------------------------------
166+
167+def to_accent(v):
168+ """Genome variant -> ThemeAccent fields (what the app would assign before refreshThemeColors())."""
169+ acc = E.Accent(id=GEN_ACCENT_ID)
170+ acc.accentColor = v["accent"]
171+ acc.myMessagesAccentColor = v["my_messages_accent"]
172+ g = v["my_messages_gradient"] + [0, 0, 0]
173+ acc.myMessagesGradientAccentColor1, acc.myMessagesGradientAccentColor2, acc.myMessagesGradientAccentColor3 = g[0], g[1], g[2]
174+ acc.myMessagesAnimated = bool(v["my_messages_animated"])
175+ w = v["wallpaper"]
176+ cs = list(w["colors"])
177+ if w.get("source") == "theme":
178+ slots = [0, 0, 0, 0] # no override: the base theme's chat_wallpaper* keys stay
179+ else: # "default" (no colours) -> all removed -> built-in wallpaper
180+ slots = [(cs[i] if i < len(cs) else DELETE) for i in range(4)]
181+ acc.backgroundOverrideColor = slots[0]
182+ acc.backgroundGradientOverrideColor1 = slots[1]
183+ acc.backgroundGradientOverrideColor2 = slots[2]
184+ acc.backgroundGradientOverrideColor3 = slots[3]
185+ acc.backgroundRotation = w["rotation"]
186+ acc.patternSlug = w["pattern_slug"]
187+ # sim-verify fix: the app reads (int)(patternIntensity * 100f) (Theme.java:9361), which truncates
188+ # 0.53f -> 52 and 0.59f -> 58. Store the percent p as (p + 0.5*sign(p)) / 100f so the
189+ # truncation yields exactly p for every p in [-100, 100].
190+ pct = int(math.floor(w["pattern_intensity"] * 100.0 + 0.5)) if w["pattern_intensity"] >= 0 else -int(math.floor(-w["pattern_intensity"] * 100.0 + 0.5))
191+ acc.patternIntensity = E.f32((pct + (0.5 if pct > 0 else (-0.5 if pct < 0 else 0.0))) / 100.0)
192+ acc.patternMotion = bool(w["motion"])
193+ return acc
194+
195+
196+def evaluate(theme, v, idx=None):
197+ acc = to_accent(v)
198+ ev = R.Evaluator(E.palette(theme, acc, GEN_ACCENT_ID))
199+ return ev, ev.all(idx)
200+
201+
202+# ------------------------------------------------------------------------------------------
203+# thresholds, accent-affected keys, pair groups
204+# ------------------------------------------------------------------------------------------
205+
206+_THR = {}
207+_AFFECTED = {}
208+BG_SPECIAL = ("@in_bubble", "@out_bubble", "@wallpaper", "@service", "@service_lite")
209+ACCENT_PAIRS = [i for i, p in enumerate(R.PAIRS) if p["bg"] not in ("@out_bubble", "@wallpaper", "@service", "@service_lite")]
210+BUBBLE_PAIRS = [i for i, p in enumerate(R.PAIRS) if p["bg"] == "@out_bubble"]
211+WALL_PAIRS = [i for i, p in enumerate(R.PAIRS) if p["bg"] in ("@wallpaper", "@service", "@service_lite")]
212+
213+
214+# canvas-fix: pass rule selector. "strict" = min(WCAG, stock) (sim/sim_fixed); "wcag" = plain WCAG
215+# thresholds (4.5 text, 3.0 non-text, 1.15 bubble separation). SERVICE = "on": the two service-text
216+# pairs count at 4.5; "off": they are scored but not part of the pass rule (to show what they force).
217+RULE = {"rule": "strict", "service": "on"}
218+SERVICE_PAIRS = ("service_text", "service_text_lite")
219+
220+
221+def thresholds(theme, slack=0.0, rule=None):
222+ """Effective per-pair thresholds. rule 'strict': min(WCAG threshold, stock contrast); with slack > 0
223+ the stock-relative ones (pairs Telegram's stock already fails) are relaxed to stock * (1 - slack).
224+ rule 'wcag': the plain WCAG threshold of every pair (service pairs 0.0 when RULE['service'] == 'off')."""
225+ rule = rule or RULE["rule"]
226+ key = (theme.model.tag, theme.name, slack, rule, RULE["service"])
227+ t = _THR.get(key)
228+ if t is None:
229+ if rule == "wcag":
230+ t = [(0.0 if (RULE["service"] == "off" and p["name"] in SERVICE_PAIRS) else p["threshold"]) for p in R.PAIRS]
231+ else:
232+ base = R.baseline(theme)
233+ t = []
234+ for i, p in enumerate(R.PAIRS):
235+ if base[i] >= p["threshold"]:
236+ t.append(p["threshold"])
237+ else:
238+ t.append(base[i] * (1.0 - slack))
239+ _THR[key] = t
240+ return t
241+
242+
243+def affected_keys(theme):
244+ """Keys whose resolved colour depends on accentColor for this base theme (two probe accents)."""
245+ a = _AFFECTED.get((theme.model.tag, theme.name))
246+ if a is None:
247+ names = set()
248+ for p in R.PAIRS:
249+ for k in (p["fg"], p["bg"]):
250+ if not k.startswith("@"):
251+ names.add(k)
252+ pa = E.palette(theme, E.Accent(id=GEN_ACCENT_ID, accentColor=argb_from_oklch(0.6, 0.12, 30.0)), GEN_ACCENT_ID)
253+ pb = E.palette(theme, E.Accent(id=GEN_ACCENT_ID, accentColor=argb_from_oklch(0.6, 0.12, 150.0)), GEN_ACCENT_ID)
254+ a = set(n for n in names if pa.get(n) != pb.get(n))
255+ _AFFECTED[(theme.model.tag, theme.name)] = a
256+ return a
257+
258+
259+def lum_over(c, bg):
260+ return D.luminance(R.over(c, bg))
261+
262+
263+def failing(res, thr):
264+ return [i for i, c in res.items() if c < thr[i]]
265+
266+
267+# ------------------------------------------------------------------------------------------
268+# (c) constrained generator
269+# ------------------------------------------------------------------------------------------
270+
271+def draw_attempt(rng, dark):
272+ """Every attempt consumes exactly the same number of draws, in this order."""
273+ d = {}
274+ d["accent_h"] = rng.between(0.0, 360.0)
275+ d["accent_C"] = rng.between(*C["accent_chroma"]["dark" if dark else "light"])
276+ d["bubble_count"] = weighted(rng, C["bubble_count_weights"])
277+ d["bubble_tone_u"] = rng.uniform()
278+ d["bubble_hue_off"] = rng.between(-C["bubble_hue_offset"], C["bubble_hue_offset"])
279+ d["bubble_L_u"] = rng.uniform()
280+ d["bubble_C_u"] = rng.uniform()
281+ d["bubble_extra"] = [(rng.between(-C["bubble_extra_hue"], C["bubble_extra_hue"]),
282+ rng.between(-C["bubble_extra_L"], C["bubble_extra_L"])) for _ in range(3)]
283+ d["animated_u"] = rng.uniform()
284+ d["wall_count"] = weighted(rng, C["wall_count_weights"])
285+ d["wall_complement_u"] = rng.uniform()
286+ d["wall_hue_off"] = rng.between(-C["wall_hue_offset"], C["wall_hue_offset"])
287+ d["wall_C_u"] = rng.uniform()
288+ d["wall_L_u"] = rng.uniform()
289+ d["wall_extra"] = [(rng.between(-C["wall_extra_hue"], C["wall_extra_hue"]),
290+ rng.between(-C["wall_extra_L"], C["wall_extra_L"])) for _ in range(3)]
291+ d["negative_u"] = rng.uniform()
292+ d["intensity_u"] = rng.uniform()
293+ d["pattern_u"] = rng.uniform()
294+ d["rotation_i"] = rng.below(len(C["rotations"]))
295+ d["motion_u"] = rng.uniform()
296+ d["accent_L_u"] = rng.uniform()
297+ d["pattern_i"] = rng.below(len(PATTERN_SLUGS))
298+ return d
299+
300+
301+def _direction(ev, i, moving_fg):
302+ """+1: the moving element must get lighter, -1: darker (move away from the other side)."""
303+ p = R.PAIRS[i]
304+ pal = ev.pal
305+ bgs = ev.surface(p["bg"])
306+ if p["bg"] == "@service":
307+ fg = ev.wp.service_text
308+ elif p["bg"] == "@service_lite":
309+ fg = ev.wp.service_lite_text
310+ elif p["bg"] == "@wallpaper":
311+ bub = ev.surface("@in_bubble" if p["fg"] == "chat_inBubble" else "@out_bubble")
312+ yb = sum(D.luminance(b) for b in bub) / len(bub)
313+ yw = sum(D.luminance(w) for w in ev.wp.fill) / len(ev.wp.fill)
314+ # moving element = wallpaper (bg)
315+ return 1 if yw > yb else -1
316+ else:
317+ fg = pal.get(p["fg"])
318+ yf = sum(lum_over(fg, s) for s in bgs) / len(bgs)
319+ yb = sum(D.luminance(s) for s in bgs) / len(bgs)
320+ if moving_fg:
321+ return 1 if yf > yb else -1
322+ return 1 if yb > yf else -1
323+
324+
325+def base_variant(theme, accent):
326+ return {"base_theme": theme.name, "accent": accent, "my_messages_accent": 0, "my_messages_gradient": [],
327+ "my_messages_animated": False,
328+ "wallpaper": {"source": "custom", "colors": [], "rotation": 45, "pattern_intensity": 0.0, "pattern_slug": "", "motion": False}}
329+
330+
331+def solve_accent(theme, d, thr, stats):
332+ dark = theme.is_dark
333+ lo, hi, (s0, s1) = C["accent_L"]["dark" if dark else "light"]
334+ L = s0 + (s1 - s0) * d["accent_L_u"]
335+ aff = affected_keys(theme)
336+ h, Cc = d["accent_h"], d["accent_C"]
337+ for _ in range(C["accent_steps"]):
338+ acc = argb_from_oklch(L, Cc, h)
339+ v = base_variant(theme, acc)
340+ ev, res = evaluate(theme, v, ACCENT_PAIRS)
341+ stats["evals"] += 1
342+ f = failing(res, thr)
343+ if not f:
344+ return acc, L
345+ up = down = False
346+ for i in f:
347+ p = R.PAIRS[i]
348+ fg_m = p["fg"] in aff
349+ bg_m = (not p["bg"].startswith("@")) and p["bg"] in aff
350+ if not fg_m and not bg_m:
351+ return None, None # the accent cannot fix this pair
352+ dr = _direction(ev, i, moving_fg=fg_m)
353+ if dr > 0:
354+ up = True
355+ else:
356+ down = True
357+ if up and down:
358+ return None, None
359+ if up:
360+ lo = L
361+ else:
362+ hi = L
363+ L = (lo + hi) * 0.5
364+ return None, None
365+
366+
367+def bubble_colors(theme, d, accent_h, L, Cc, count):
368+ h = accent_h + d["bubble_hue_off"]
369+ cols = [argb_from_oklch(L, Cc, h)]
370+ for k in range(count - 1):
371+ dh, dL = d["bubble_extra"][k]
372+ cols.append(argb_from_oklch(min(0.99, max(0.05, L + dL)), Cc, h + dh))
373+ return cols
374+
375+
376+def solve_bubble(theme, d, accent, accent_h, thr, stats):
377+ dark = theme.is_dark
378+ count = d["bubble_count"]
379+ if dark:
380+ band = C["bubble_dark_on_dark"]
381+ elif d["bubble_tone_u"] < C["bubble_light_share"]:
382+ band = C["bubble_light"]
383+ else:
384+ band = C["bubble_dark_on_light"]
385+ L = band["L"][0] + (band["L"][1] - band["L"][0]) * d["bubble_L_u"]
386+ Cc = band["C"][0] + (band["C"][1] - band["C"][0]) * d["bubble_C_u"]
387+ animated = count >= 3 and d["animated_u"] < C["animated_share"]
388+ for _ in range(C["bubble_steps"]):
389+ cols = bubble_colors(theme, d, accent_h, L, Cc, count)
390+ grad = cols[1:] if count > 1 else [cols[0]] # solid: gradient1 == colour (see notes)
391+ v = base_variant(theme, accent)
392+ v["my_messages_accent"] = cols[0]
393+ v["my_messages_gradient"] = grad
394+ v["my_messages_animated"] = animated
395+ ev, res = evaluate(theme, v, BUBBLE_PAIRS)
396+ stats["evals"] += 1
397+ if not failing(res, thr):
398+ return v, L
399+ L = L + band["step"]
400+ if ("L_max" in band and L > band["L_max"]) or ("L_min" in band and L < band["L_min"]):
401+ break
402+ return None, None
403+
404+
405+def wall_params(theme, d):
406+ dark = theme.is_dark
407+ count = d["wall_count"]
408+ negative = dark and count >= 3 and d["negative_u"] < C["dark_negative_share"]
409+ if negative:
410+ band = C["wall"]["dark_neg"]
411+ lo_i, hi_i = C["intensity"]["dark_neg"]
412+ elif dark:
413+ band = C["wall"]["dark"]
414+ lo_i, hi_i = C["intensity"]["dark_pos"]
415+ else:
416+ band = C["wall"]["light"]
417+ lo_i, hi_i = C["intensity"]["light"]
418+ pattern = count >= 3 and (negative or d["pattern_u"] < C["pattern_share"])
419+ intensity = (lo_i + (hi_i - lo_i) * d["intensity_u"]) if count >= 3 else 0.0
420+ intensity = math.floor(intensity * 100.0 + 0.5) / 100.0
421+ return count, negative, band, pattern, intensity
422+
423+
424+def wall_colors(d, accent_h, band, L, count):
425+ h = accent_h + d["wall_hue_off"] + (180.0 if d["wall_complement_u"] < C["wall_complement_share"] else 0.0)
426+ Cc = band["C"][0] + (band["C"][1] - band["C"][0]) * d["wall_C_u"]
427+ cols = [argb_from_oklch(L, Cc, h)]
428+ for k in range(count - 1):
429+ dh, dL = d["wall_extra"][k]
430+ cols.append(argb_from_oklch(min(0.99, max(0.02, L + dL)), Cc, h + dh))
431+ return cols
432+
433+
434+def solve_wallpaper(theme, d, v0, accent_h, thr, stats):
435+ count, negative, band, pattern, intensity = wall_params(theme, d)
436+ lo, hi = band["L"]
437+ L = band["L_start"][0] + (band["L_start"][1] - band["L_start"][0]) * d["wall_L_u"]
438+ for _ in range(C["wall_steps"]):
439+ v = json.loads(json.dumps(v0))
440+ v["wallpaper"] = {"source": "custom", "colors": wall_colors(d, accent_h, band, L, count), "rotation": C["rotations"][d["rotation_i"]],
441+ "pattern_intensity": intensity, "pattern_slug": PATTERN_SLUGS[d["pattern_i"]] if pattern else "",
442+ "motion": d["motion_u"] < 0.5}
443+ ev, res = evaluate(theme, v, WALL_PAIRS)
444+ stats["evals"] += 1
445+ f = failing(res, thr)
446+ if not f:
447+ return v, L
448+ up = down = False
449+ for i in f:
450+ if _direction(ev, i, moving_fg=False) > 0:
451+ up = True
452+ else:
453+ down = True
454+ if up and down:
455+ return None, None
456+ if up:
457+ lo = L
458+ else:
459+ hi = L
460+ L = (lo + hi) * 0.5
461+ return None, None
462+
463+
464+def fallback_variant(theme):
465+ """The theme's stock accent expressed as a generated accent: passes by construction (== baseline)."""
466+ a = theme.stock_accent()
467+ w = []
468+ source = "custom"
469+ if a.backgroundOverrideColor == 0:
470+ source = "theme"
471+ elif a.backgroundOverrideColor == DELETE:
472+ source = "default"
473+ else:
474+ for f in ("backgroundOverrideColor", "backgroundGradientOverrideColor1", "backgroundGradientOverrideColor2", "backgroundGradientOverrideColor3"):
475+ x = getattr(a, f)
476+ if x == 0 or x == DELETE:
477+ break
478+ w.append(E.s32(x))
479+ g = [c for c in (a.myMessagesGradientAccentColor1, a.myMessagesGradientAccentColor2, a.myMessagesGradientAccentColor3) if c != 0]
480+ return {"base_theme": theme.name, "accent": a.accentColor, "my_messages_accent": a.myMessagesAccentColor,
481+ "my_messages_gradient": g, "my_messages_animated": bool(a.myMessagesAnimated),
482+ "wallpaper": {"source": source, "colors": w, "rotation": a.backgroundRotation, "pattern_intensity": float(a.patternIntensity),
483+ "pattern_slug": a.patternSlug, "motion": bool(a.patternMotion)}}
484+
485+
486+def _tiered(tiers, fn):
487+ """Run a stage solver against each threshold tier in order; first success wins."""
488+ for k, thr in enumerate(tiers):
489+ out = fn(thr)
490+ if out[0] is not None:
491+ return out + (k,)
492+ return (None, None, None)
493+
494+
495+def gen_variant_c(theme, rng, stats=None, mode="strict"):
496+ """mode 'strict': pass rule of the task (>= min(WCAG, stock)); accent policy per theme.
497+ mode 'free': every theme gets a free OKLCH accent. Each stage is solved against the strict
498+ thresholds first and only if that fails against the slack tier (pairs Telegram's stock
499+ already fails may drop to stock * (1 - C['free_mode_slack'])); the final check accepts a
500+ strict pass first, then a slack pass (solver.used_slack records which)."""
501+ if stats is None:
502+ stats = {}
503+ stats.setdefault("evals", 0)
504+ stats.setdefault("reject", {})
505+ if mode == "wcag":
506+ # canvas-fix: free OKLCH accent on every base theme, plain WCAG thresholds, no slack tier
507+ thr_strict = thresholds(theme, 0.0, "wcag")
508+ tiers = [thr_strict]
509+ policy = "free"
510+ else:
511+ thr_strict = thresholds(theme, 0.0)
512+ tiers = [thr_strict]
513+ if mode == "free":
514+ tiers.append(thresholds(theme, C["free_mode_slack"]))
515+ policy = "free" if mode == "free" else C["accent_policy"][theme.name]
516+ for attempt in range(C["max_attempts"]):
517+ d = draw_attempt(rng, theme.is_dark)
518+ stage = "accent"
519+ if policy in ("anchored", "wallpaper-only"):
520+ accent, aL = theme.stock_accent().accentColor, None
521+ else:
522+ accent, aL, _ = _tiered(tiers, lambda thr: solve_accent(theme, d, thr, stats))
523+ if accent is not None:
524+ stage = "bubble"
525+ if policy == "wallpaper-only":
526+ sv = fallback_variant(theme)
527+ v = base_variant(theme, accent)
528+ v["my_messages_accent"], v["my_messages_gradient"] = sv["my_messages_accent"], sv["my_messages_gradient"]
529+ v["my_messages_animated"], bL = sv["my_messages_animated"], None
530+ else:
531+ v, bL, _ = _tiered(tiers, lambda thr: solve_bubble(theme, d, accent, d["accent_h"], thr, stats))
532+ if v is not None:
533+ stage = "wallpaper"
534+ v0 = v
535+ v, wL, _ = _tiered(tiers, lambda thr: solve_wallpaper(theme, d, v0, d["accent_h"], thr, stats))
536+ if v is not None:
537+ stage = "final"
538+ ev, res = evaluate(theme, v)
539+ stats["evals"] += 1
540+ for k, thr in enumerate(tiers):
541+ if not failing(res, thr):
542+ v["solver"] = {"attempt": attempt + 1, "fallback": False, "mode": mode, "accent_policy": policy,
543+ "used_slack": k > 0,
544+ "accent_oklch": [round(aL, 4), round(d["accent_C"], 4), round(d["accent_h"], 2)] if aL is not None else None,
545+ "base_hue": round(d["accent_h"], 2),
546+ "bubble_L": round(bL, 4) if bL is not None else None, "wallpaper_L": round(wL, 4),
547+ "min_margin": round(min(res[i] / thr_strict[i] for i in res if thr_strict[i] > 0), 4)}
548+ return v, res
549+ for i in failing(res, tiers[-1]):
550+ stats["reject"][R.PAIRS[i]["name"]] = stats["reject"].get(R.PAIRS[i]["name"], 0) + 1
551+ stats["reject"]["<" + stage + " stage>"] = stats["reject"].get("<" + stage + " stage>", 0) + 1
552+ v = fallback_variant(theme)
553+ ev, res = evaluate(theme, v)
554+ v["solver"] = {"attempt": C["max_attempts"], "fallback": True, "mode": mode, "accent_policy": policy, "used_slack": False}
555+ return v, res
556+
557+
558+# ------------------------------------------------------------------------------------------
559+# (a) / (b) naive generators
560+# ------------------------------------------------------------------------------------------
561+
562+def rand_rgb(rng):
563+ return E.s32(0xFF000000 | (rng.next_u64() >> 40))
564+
565+
566+def rand_hsv(rng):
567+ h = rng.between(0.0, 360.0)
568+ s = rng.uniform()
569+ v = rng.uniform()
570+ return E.hsv_to_color(255, E.f32(h), E.f32(s), E.f32(v))
571+
572+
573+def gen_naive(theme, rng, colour, full):
574+ if not full:
575+ # the app's own "change accent" path: copy of the current (stock) accent with a new accentColor
576+ v = fallback_variant(theme)
577+ v["accent"] = colour(rng)
578+ return v
579+ v = base_variant(theme, colour(rng))
580+ n_b = 1 + rng.below(4)
581+ cols = [colour(rng) for _ in range(n_b)]
582+ v["my_messages_accent"] = cols[0]
583+ v["my_messages_gradient"] = cols[1:] if n_b > 1 else [cols[0]]
584+ v["my_messages_animated"] = n_b >= 3 and rng.uniform() < 0.5
585+ n_w = 1 + rng.below(4)
586+ lo_i = -1.0 if theme.is_dark else 0.0
587+ intensity = rng.between(lo_i, 1.0) if n_w >= 3 else 0.0
588+ v["wallpaper"] = {"source": "custom", "colors": [colour(rng) for _ in range(n_w)], "rotation": C["rotations"][rng.below(8)],
589+ "pattern_intensity": math.floor(intensity * 100 + 0.5) / 100.0,
590+ "pattern_slug": PATTERN_SLUGS[rng.below(len(PATTERN_SLUGS))] if (n_w >= 3 and rng.uniform() < 0.8) else "",
591+ "motion": False}
592+ return v
593+
594+
595+# ------------------------------------------------------------------------------------------
596+# genome
597+# ------------------------------------------------------------------------------------------
598+
599+def variant_json(v):
600+ w = v["wallpaper"]
601+ out = {
602+ "base_theme": v["base_theme"],
603+ "accent": hex32(v["accent"]),
604+ "my_messages_accent": hex32(v["my_messages_accent"]),
605+ "my_messages_gradient": [hex32(c) for c in v["my_messages_gradient"]],
606+ "my_messages_animated": bool(v["my_messages_animated"]),
607+ "wallpaper": {
608+ "colors": [hex32(c) for c in w["colors"]],
609+ "kind": ("base theme's own" if w.get("source") == "theme" else
610+ {0: "built-in default", 1: "solid", 2: "linear-gradient"}.get(len(w["colors"]), "motion-gradient")),
611+ "rotation": w["rotation"],
612+ "pattern_intensity": w["pattern_intensity"],
613+ "pattern_slug": w["pattern_slug"],
614+ "parallax_motion": bool(w["motion"]),
615+ },
616+ }
617+ if "solver" in v:
618+ out["solver"] = v["solver"]
619+ return out
620+
621+
622+# base themes the genome may pick: under the strict rule only themes whose accent can vary are
623+# offered for the day variant (Blue would only vary its wallpaper); night keeps both dark themes
624+# (accent anchored, bubbles and wallpaper vary)
625+DAY_CHOICES = {"strict": ["Arctic Blue", "Day"], "free": ["Blue", "Arctic Blue", "Day"], "wcag": ["Blue", "Arctic Blue", "Day"]}
626+NIGHT_CHOICES = {"strict": ["Dark Blue", "Night"], "free": ["Dark Blue", "Night"], "wcag": ["Dark Blue", "Night"]}
627+
628+
629+def genome(seed, mode="strict", m=None):
630+ root = SplitMix64(seed_state(seed))
631+ day_rng, night_rng, misc_rng = root.fork(), root.fork(), root.fork()
632+ m = m or E.model()
633+ dc, nc = DAY_CHOICES[mode], NIGHT_CHOICES[mode]
634+ day_theme = m.themes[dc[day_rng.below(len(dc))]]
635+ night_theme = m.themes[nc[night_rng.below(len(nc))]]
636+ day, day_res = gen_variant_c(day_theme, day_rng, mode=mode)
637+ night, night_res = gen_variant_c(night_theme, night_rng, mode=mode)
638+ lo, hi = C["bubble_radius"]
639+ g = {
640+ "genome_version": GENOME_VERSION,
641+ "seed": seed,
642+ "mode": mode,
643+ "prng": "SHA-256(seed)[0:8] big-endian -> SplitMix64; forks: day, night, misc",
644+ "day": variant_json(day),
645+ "night": variant_json(night),
646+ "bubble_radius": lo + misc_rng.below(hi - lo + 1),
647+ "readability": {
648+ "rule": "strict: every pair >= min(WCAG threshold, stock contrast on that base theme)",
649+ "day_pairs_passed_strict": "%d/%d" % (sum(1 for i, c in day_res.items() if c >= thresholds(day_theme)[i]), len(R.PAIRS)),
650+ "night_pairs_passed_strict": "%d/%d" % (sum(1 for i, c in night_res.items() if c >= thresholds(night_theme)[i]), len(R.PAIRS)),
651+ "day_lowest_vs_wcag": {R.PAIRS[i]["name"]: round(day_res[i], 3) for i in sorted(day_res, key=lambda i: day_res[i] / R.PAIRS[i]["threshold"])[:3]},
652+ "night_lowest_vs_wcag": {R.PAIRS[i]["name"]: round(night_res[i], 3) for i in sorted(night_res, key=lambda i: night_res[i] / R.PAIRS[i]["threshold"])[:3]},
653+ },
654+ }
655+ return g
656+
657+
658+# ------------------------------------------------------------------------------------------
659+# measurement
660+# ------------------------------------------------------------------------------------------
661+
662+SHORT_BUCKETS = [1.0, 0.99, 0.98, 0.95, 0.0]
663+
664+GENERATORS = [
665+ ("a_acc", "(a) uniform RGB, accent only"),
666+ ("a_full", "(a) uniform RGB, full genome"),
667+ ("b_acc", "(b) uniform HSV, accent only"),
668+ ("b_full", "(b) uniform HSV, full genome"),
669+ ("c", "(c) OKLCH constrained + solve"),
670+ ("c_free", "(c') free accent, two-tier"),
671+]
672+
673+
674+def _measure_chunk(args):
675+ theme_name, start, stop = args
676+ m = E.model()
677+ th = m.themes[theme_name]
678+ thr = thresholds(th)
679+ out = {g: {"pass": 0, "fail_pairs": {}} for g, _ in GENERATORS}
680+ cstats = {"first": 0, "attempts": 0, "max_attempts": 0, "fallback": 0, "evals": 0, "reject": {}, "min_margin": 1e9}
681+ cfree = {"slack_pass": 0, "fallback": 0, "worst": (9.0, ""), "short": [0] * len(SHORT_BUCKETS)}
682+ cstats["cfree"] = cfree
683+ var = {"accents": set(), "bubbles": set(), "walls": set(), "hue_bins": set(), "light_bubble": 0, "gradient_bubble": 0,
684+ "animated": 0, "kinds": {}, "pattern": 0, "negative": 0}
685+ cstats["variety"] = var
686+ for k in range(start, stop):
687+ seed = "measure-%d" % k
688+ for g, _ in GENERATORS:
689+ rng = SplitMix64(seed_state(seed + "/" + g + "/" + theme_name))
690+ if g == "c_free":
691+ st = {"evals": 0, "reject": {}}
692+ v, res = gen_variant_c(th, rng, st, mode="free")
693+ if not failing(res, thresholds(th, C["free_mode_slack"])):
694+ cfree["slack_pass"] += 1
695+ cfree["fallback"] += 1 if v["solver"]["fallback"] else 0
696+ r0 = min(res[i] / thr[i] for i in res)
697+ for b, lim in enumerate(SHORT_BUCKETS):
698+ if r0 >= lim:
699+ cfree["short"][b] += 1
700+ break
701+ base = R.baseline(th)
702+ for i, c in res.items():
703+ if base[i] < R.PAIRS[i]["threshold"]:
704+ r = c / base[i]
705+ if r < cfree["worst"][0]:
706+ cfree["worst"] = (r, R.PAIRS[i]["name"])
707+ elif g == "c":
708+ st = {"evals": 0, "reject": cstats["reject"]}
709+ v, res = gen_variant_c(th, rng, st)
710+ s = v["solver"]
711+ cstats["attempts"] += s["attempt"]
712+ cstats["max_attempts"] = max(cstats["max_attempts"], s["attempt"])
713+ cstats["first"] += 1 if s["attempt"] == 1 and not s["fallback"] else 0
714+ cstats["fallback"] += 1 if s["fallback"] else 0
715+ cstats["evals"] += st["evals"]
716+ if not s["fallback"]:
717+ cstats["min_margin"] = min(cstats["min_margin"], s["min_margin"])
718+ var["accents"].add(v["accent"])
719+ bub = (v["my_messages_accent"],) + tuple(v["my_messages_gradient"])
720+ var["bubbles"].add(bub)
721+ w = v["wallpaper"]
722+ var["walls"].add(tuple(w["colors"]))
723+ var["hue_bins"].add(int(math.floor(s["base_hue"] / 30.0)) % 12)
724+ var["light_bubble"] += 1 if D.luminance(v["my_messages_accent"]) > 0.4 else 0
725+ var["gradient_bubble"] += 1 if (len(v["my_messages_gradient"]) > 1 or
726+ (v["my_messages_gradient"] and v["my_messages_gradient"][0] != v["my_messages_accent"])) else 0
727+ var["animated"] += 1 if v["my_messages_animated"] else 0
728+ kind = {1: "solid", 2: "linear", 3: "motion", 4: "motion"}.get(len(w["colors"]), "other")
729+ var["kinds"][kind] = var["kinds"].get(kind, 0) + 1
730+ var["pattern"] += 1 if w["pattern_slug"] else 0
731+ var["negative"] += 1 if w["pattern_intensity"] < 0 else 0
732+ else:
733+ colour = rand_rgb if g.startswith("a") else rand_hsv
734+ v = gen_naive(th, rng, colour, g.endswith("full"))
735+ _, res = evaluate(th, v)
736+ f = failing(res, thr)
737+ if not f:
738+ out[g]["pass"] += 1
739+ for i in f:
740+ nm = R.PAIRS[i]["name"]
741+ out[g]["fail_pairs"][nm] = out[g]["fail_pairs"].get(nm, 0) + 1
742+ return theme_name, out, cstats
743+
744+
745+def measure(n, procs):
746+ jobs = []
747+ step = max(1, (n + 7) // 8)
748+ for t in ALL_THEMES:
749+ for s in range(0, n, step):
750+ jobs.append((t, s, min(n, s + step)))
751+ t0 = time.time()
752+ if procs > 1:
753+ from multiprocessing import Pool
754+ with Pool(procs) as pool:
755+ results = pool.map(_measure_chunk, jobs, chunksize=1)
756+ else:
757+ results = [_measure_chunk(j) for j in jobs]
758+ agg = {t: {g: {"pass": 0, "fail_pairs": {}} for g, _ in GENERATORS} for t in ALL_THEMES}
759+ cst = {t: {"first": 0, "attempts": 0, "max_attempts": 0, "fallback": 0, "evals": 0, "reject": {}, "min_margin": 1e9} for t in ALL_THEMES}
760+ for t, out, cs in results:
761+ for g in out:
762+ agg[t][g]["pass"] += out[g]["pass"]
763+ for k, v in out[g]["fail_pairs"].items():
764+ agg[t][g]["fail_pairs"][k] = agg[t][g]["fail_pairs"].get(k, 0) + v
765+ c = cst[t]
766+ for k in ("first", "attempts", "fallback", "evals"):
767+ c[k] += cs[k]
768+ c["max_attempts"] = max(c["max_attempts"], cs["max_attempts"])
769+ c.setdefault("cfree", {"slack_pass": 0, "fallback": 0, "worst": (9.0, ""), "short": [0] * len(SHORT_BUCKETS)})
770+ for b in range(len(SHORT_BUCKETS)):
771+ c["cfree"]["short"][b] += cs["cfree"]["short"][b]
772+ vv = c.setdefault("variety", {"accents": set(), "bubbles": set(), "walls": set(), "hue_bins": set(), "light_bubble": 0,
773+ "gradient_bubble": 0, "animated": 0, "kinds": {}, "pattern": 0, "negative": 0})
774+ for k in ("accents", "bubbles", "walls", "hue_bins"):
775+ vv[k] |= cs["variety"][k]
776+ for k in ("light_bubble", "gradient_bubble", "animated", "pattern", "negative"):
777+ vv[k] += cs["variety"][k]
778+ for k, x in cs["variety"]["kinds"].items():
779+ vv["kinds"][k] = vv["kinds"].get(k, 0) + x
780+ c["cfree"]["slack_pass"] += cs["cfree"]["slack_pass"]
781+ c["cfree"]["fallback"] += cs["cfree"]["fallback"]
782+ c["cfree"]["worst"] = min(c["cfree"]["worst"], cs["cfree"]["worst"])
783+ c["min_margin"] = min(c["min_margin"], cs["min_margin"])
784+ for k, v in cs["reject"].items():
785+ c["reject"][k] = c["reject"].get(k, 0) + v
786+ dt = time.time() - t0
787+ lines = []
788+ lines.append("seeds per base theme: %d pairs in suite: %d pass = every pair >= min(WCAG threshold, stock contrast of that pair on that base theme)" % (n, len(R.PAIRS)))
789+ lines.append("")
790+ lines.append("PASS RATE (all %d pairs)" % len(R.PAIRS))
791+ hdr = "%-34s" % "generator" + "".join("%13s" % t for t in ALL_THEMES)
792+ lines.append(hdr)
793+ for g, label in GENERATORS:
794+ lines.append("%-34s" % label + "".join("%12.2f%%" % (100.0 * agg[t][g]["pass"] / n) for t in ALL_THEMES))
795+ lines.append("")
796+ lines.append("(c) SOLVER STATS")
797+ lines.append("%-34s" % "first-attempt success" + "".join("%12.2f%%" % (100.0 * cst[t]["first"] / n) for t in ALL_THEMES))
798+ lines.append("%-34s" % "mean attempts" + "".join("%13.3f" % (cst[t]["attempts"] / n) for t in ALL_THEMES))
799+ lines.append("%-34s" % "max attempts" + "".join("%13d" % cst[t]["max_attempts"] for t in ALL_THEMES))
800+ lines.append("%-34s" % "deterministic fallbacks used" + "".join("%13d" % cst[t]["fallback"] for t in ALL_THEMES))
801+ lines.append("%-34s" % "palette evaluations per seed" + "".join("%13.2f" % (cst[t]["evals"] / n) for t in ALL_THEMES))
802+ lines.append("%-34s" % "min contrast/threshold (passes)" + "".join("%13.4f" % cst[t]["min_margin"] for t in ALL_THEMES))
803+ lines.append("%-34s" % "accent policy (strict mode)" + "".join("%13s" % C["accent_policy"][t] for t in ALL_THEMES))
804+ lines.append("")
805+ lines.append("(c) VARIETY over the %d seeds per base theme" % n)
806+ vt = {t: cst[t]["variety"] for t in ALL_THEMES}
807+ lines.append("%-34s" % "distinct accent colours" + "".join("%13d" % len(vt[t]["accents"]) for t in ALL_THEMES))
808+ lines.append("%-34s" % "distinct outgoing-bubble colourings" + "".join("%13d" % len(vt[t]["bubbles"]) for t in ALL_THEMES))
809+ lines.append("%-34s" % "distinct wallpaper colourings" + "".join("%13d" % len(vt[t]["walls"]) for t in ALL_THEMES))
810+ lines.append("%-34s" % "base-hue 30-degree bins covered" + "".join("%10d/12" % len(vt[t]["hue_bins"]) for t in ALL_THEMES))
811+ lines.append("%-34s" % "light (black-text) bubbles" + "".join("%12.1f%%" % (100.0 * vt[t]["light_bubble"] / n) for t in ALL_THEMES))
812+ lines.append("%-34s" % "gradient bubbles" + "".join("%12.1f%%" % (100.0 * vt[t]["gradient_bubble"] / n) for t in ALL_THEMES))
813+ lines.append("%-34s" % "animated bubble gradients" + "".join("%12.1f%%" % (100.0 * vt[t]["animated"] / n) for t in ALL_THEMES))
814+ for kind in ("solid", "linear", "motion"):
815+ lines.append("%-34s" % ("wallpaper kind: " + kind) + "".join("%12.1f%%" % (100.0 * vt[t]["kinds"].get(kind, 0) / n) for t in ALL_THEMES))
816+ lines.append("%-34s" % "wallpapers with pattern" + "".join("%12.1f%%" % (100.0 * vt[t]["pattern"] / n) for t in ALL_THEMES))
817+ lines.append("%-34s" % "negative-intensity (dark) patterns" + "".join("%12.1f%%" % (100.0 * vt[t]["negative"] / n) for t in ALL_THEMES))
818+ lines.append("")
819+ lines.append("(c') free accent everywhere; each stage solved strict first, then with %d%% slack on pairs stock already fails" % int(C["free_mode_slack"] * 100))
820+ lines.append("%-34s" % "pass, strict rule (table above)" + "".join("%12.2f%%" % (100.0 * agg[t]["c_free"]["pass"] / n) for t in ALL_THEMES))
821+ lines.append("%-34s" % "pass, slack rule" + "".join("%12.2f%%" % (100.0 * cst[t]["cfree"]["slack_pass"] / n) for t in ALL_THEMES))
822+ lines.append("%-34s" % "deterministic fallbacks used" + "".join("%13d" % cst[t]["cfree"]["fallback"] for t in ALL_THEMES))
823+ lines.append("%-34s" % "worst contrast / stock contrast" + "".join("%13.4f" % cst[t]["cfree"]["worst"][0] for t in ALL_THEMES))
824+ lines.append("%-34s" % " ... on pair" + "".join("%13s" % cst[t]["cfree"]["worst"][1][:12] for t in ALL_THEMES))
825+ lines.append("strict-rule shortfall of (c'): seeds by min over pairs of contrast / strict threshold")
826+ prev = None
827+ for b, lim in enumerate(SHORT_BUCKETS):
828+ label = (" >= %.2f (strict pass)" % lim) if b == 0 else (" [%.2f, %.2f)" % (lim, prev))
829+ lines.append("%-34s" % label + "".join("%12.2f%%" % (100.0 * cst[t]["cfree"]["short"][b] / n) for t in ALL_THEMES))
830+ prev = lim
831+ rej = set()
832+ for t in ALL_THEMES:
833+ rej.update(cst[t]["reject"])
834+ if rej:
835+ lines.append("rejected attempts by stage / final-check pair:")
836+ for k in sorted(rej, key=lambda k: (-sum(cst[t]["reject"].get(k, 0) for t in ALL_THEMES), k)):
837+ lines.append(" %-32s" % k + "".join("%13d" % cst[t]["reject"].get(k, 0) for t in ALL_THEMES))
838+ lines.append("")
839+ lines.append("PER-PAIR FAILURE COUNTS (seeds failing the pair, summed over the 5 base themes = %d seeds per generator)" % (5 * n))
840+ short = {"a_acc": "a:acc", "a_full": "a:full", "b_acc": "b:acc", "b_full": "b:full", "c": "c", "c_free": "c'"}
841+ lines.append(" %-26s" % "pair" + "".join("%9s" % short[g] for g, _ in GENERATORS))
842+ tot = {g: {} for g, _ in GENERATORS}
843+ for g, _ in GENERATORS:
844+ for t in ALL_THEMES:
845+ for k, v in agg[t][g]["fail_pairs"].items():
846+ tot[g][k] = tot[g].get(k, 0) + v
847+ names = [p["name"] for p in R.PAIRS if any(tot[g].get(p["name"], 0) for g, _ in GENERATORS)]
848+ for nm in sorted(names, key=lambda k: (-sum(tot[g].get(k, 0) for g, _ in GENERATORS), k)):
849+ lines.append(" %-26s" % nm + "".join("%9d" % tot[g].get(nm, 0) for g, _ in GENERATORS))
850+ for g, label in GENERATORS:
851+ if g not in ("c", "c_free"):
852+ continue
853+ lines.append("")
854+ lines.append("RESIDUAL FAILURES PER BASE THEME " + label + " (strict rule)")
855+ names = set()
856+ for t in ALL_THEMES:
857+ names.update(agg[t][g]["fail_pairs"])
858+ if not names:
859+ lines.append(" (none)")
860+ continue
861+ lines.append(" %-26s" % "pair" + "".join("%13s" % t for t in ALL_THEMES))
862+ for nm in sorted(names, key=lambda k: (-sum(agg[t][g]["fail_pairs"].get(k, 0) for t in ALL_THEMES), k)):
863+ lines.append(" %-26s" % nm + "".join("%13d" % agg[t][g]["fail_pairs"].get(nm, 0) for t in ALL_THEMES))
864+ lines.append("")
865+ lines.append("elapsed %.1f s on %d process(es)" % (dt, procs))
866+ return "\n".join(lines)
867+
868+
869+def baseline_report():
870+ m = E.model()
871+ lines = []
872+ lines.append("STOCK BASELINE: each base theme with its stock default accent (Blue: id %d via ThemeColors defaults; others: id 0)" % m.DEFAULT_ACCENT_ID)
873+ for t in ALL_THEMES:
874+ th = m.themes[t]
875+ ev = R.Evaluator(R.stock_palette(th))
876+ w = ev.wp
877+ lines.append(" %-12s accent %s wallpaper %s %s intensity %d pattern %s service text %s (%s)" % (
878+ t, hex32(th.stock_accent().accentColor), w.kind, "/".join(hex32(c) for c in w.colors if c), w.intensity, w.pattern,
879+ hex32(w.service_text), "gradient shader" if w.shader else "flat pill"))
880+ lines.append("")
881+ lines.append(" %-26s %-40s %-34s %5s" % ("pair", "fg key", "bg", "thr") + "".join("%12s" % t for t in ALL_THEMES))
882+ base = {t: R.baseline(m.themes[t]) for t in ALL_THEMES}
883+ nfail = {t: 0 for t in ALL_THEMES}
884+ for i, p in enumerate(R.PAIRS):
885+ cells = []
886+ for t in ALL_THEMES:
887+ c = base[t][i]
888+ bad = c < p["threshold"]
889+ nfail[t] += 1 if bad else 0
890+ cells.append("%11.2f%s" % (c, "*" if bad else " "))
891+ lines.append(" %-26s %-40s %-34s %5.2f" % (p["name"], p["fg"], p["bg"], p["threshold"]) + "".join(cells))
892+ lines.append(" %-26s %-40s %-34s %5s" % ("pairs below threshold (*)", "", "", "") + "".join("%12d" % nfail[t] for t in ALL_THEMES))
893+ return "\n".join(lines)
894+
895+
896+def main():
897+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
898+ ap.add_argument("--seed")
899+ ap.add_argument("--measure", type=int)
900+ ap.add_argument("--baseline", action="store_true")
901+ ap.add_argument("--mode", choices=["strict", "free"], default="strict",
902+ help="--seed only: strict = task pass rule (accent policy per theme); free = free accent everywhere, 10%% slack")
903+ ap.add_argument("--procs", type=int, default=os.cpu_count() or 1)
904+ a = ap.parse_args()
905+ missing = R.check_keys(E.model())
906+ if missing:
907+ raise SystemExit("suite names unknown keys: %r" % missing)
908+ if not check_pattern_slugs(E.model()):
909+ raise SystemExit("PATTERN_SLUGS differs from the built-in accent presets in Theme.java")
910+ if a.baseline:
911+ print(baseline_report())
912+ if a.seed is not None:
913+ print(json.dumps(genome(a.seed, a.mode), indent=2))
914+ if a.measure:
915+ print(measure(a.measure, a.procs))
916+
917+
918+if __name__ == "__main__":
919+ main()
multigram/tools/sim/tgsrc.pynew file+653
@@ -0,0 +1,653 @@
1+"""Parse the Telegram-Android theme engine inputs straight from the source tree.
2+
3+Everything the simulator needs is read from the checked-out sources (never hand-copied):
4+ * Theme.java - colour key indices (declaration order of `key_X = colorsCount++`),
5+ myMessages*Index ranges, fallbackKeys, themeAccentExclusionKeys,
6+ myMessagesAccentExtraKeys, the five bundled ThemeInfo definitions
7+ (setAccentColorOptions arrays), the "override default themes" constants,
8+ isHome() rules, DEFALT_THEME_ACCENT_ID, MSG_OUT_COLOR_*.
9+ * ThemeColors.java - createDefaultColors() and createColorKeysMap() (attheme names).
10+ * assets/*.attheme - parsed with the exact semantics of Theme.getThemeFileValues().
11+
12+The parsed result is cached as JSON keyed by a SHA-256 of the inputs.
13+"""
14+import hashlib
15+import json
16+import os
17+import re
18+import tempfile
19+
20+HERE = os.path.dirname(os.path.abspath(__file__))
21+# The repository this copy lives in (multigram/tools/sim -> repository root); TG_REPO overrides it.
22+REPO = os.path.abspath(os.environ.get("TG_REPO") or os.path.join(HERE, "..", "..", ".."))
23+SRC = os.path.join(REPO, "TMessagesProj", "src", "main")
24+THEME_JAVA = os.path.join(SRC, "java", "org", "telegram", "ui", "ActionBar", "Theme.java")
25+THEMECOLORS_JAVA = os.path.join(SRC, "java", "org", "telegram", "ui", "ActionBar", "ThemeColors.java")
26+ASSETS = os.path.join(SRC, "assets")
27+ATTHEMES = ["bluebubbles.attheme", "darkblue.attheme", "arctic.attheme", "day.attheme", "night.attheme"]
28+CACHE_DIR = os.path.join(HERE, "cache")
29+PARSER_VERSION = 11
30+
31+
32+def s32(v):
33+ """Wrap an integer to a Java signed 32-bit int."""
34+ v &= 0xFFFFFFFF
35+ return v - 0x100000000 if v & 0x80000000 else v
36+
37+
38+def u32(v):
39+ return v & 0xFFFFFFFF
40+
41+
42+# ----------------------------------------------------------------------------------------
43+# Java helpers
44+# ----------------------------------------------------------------------------------------
45+
46+def strip_java_comments(src):
47+ """Remove // and /* */ comments while respecting string and char literals.
48+ Newlines are kept so that line numbers stay valid."""
49+ out = []
50+ i, n = 0, len(src)
51+ while i < n:
52+ c = src[i]
53+ if c == '"' or c == "'":
54+ q = c
55+ j = i + 1
56+ while j < n and src[j] != q:
57+ if src[j] == '\\':
58+ j += 1
59+ j += 1
60+ out.append(src[i:j + 1])
61+ i = j + 1
62+ elif src.startswith("//", i):
63+ j = src.find("\n", i)
64+ if j < 0:
65+ j = n
66+ i = j
67+ elif src.startswith("/*", i):
68+ j = src.find("*/", i + 2)
69+ if j < 0:
70+ j = n
71+ out.append("\n" * src.count("\n", i, j + 2))
72+ i = j + 2
73+ else:
74+ out.append(c)
75+ i += 1
76+ return "".join(out)
77+
78+
79+_NL_CACHE = {}
80+
81+
82+def line_of(src, pos):
83+ import bisect
84+ key = id(src)
85+ ent = _NL_CACHE.get(key)
86+ if ent is None or ent[0] is not src:
87+ idx = [i for i, ch in enumerate(src) if ch == "\n"]
88+ ent = (src, idx)
89+ _NL_CACHE[key] = ent
90+ return bisect.bisect_left(ent[1], pos) + 1
91+
92+
93+def java_int_literal(tok):
94+ tok = tok.strip()
95+ neg = False
96+ if tok.startswith("-"):
97+ neg = True
98+ tok = tok[1:].strip()
99+ if tok.endswith(("L", "l")):
100+ tok = tok[:-1]
101+ if tok.lower().startswith("0x"):
102+ v = int(tok, 16)
103+ else:
104+ v = int(tok, 10)
105+ return -v if neg else v
106+
107+
108+def split_top_level(s, sep=","):
109+ parts, depth, cur, i = [], 0, [], 0
110+ in_str = None
111+ while i < len(s):
112+ ch = s[i]
113+ if in_str:
114+ cur.append(ch)
115+ if ch == "\\":
116+ cur.append(s[i + 1])
117+ i += 2
118+ continue
119+ if ch == in_str:
120+ in_str = None
121+ elif ch in "\"'":
122+ in_str = ch
123+ cur.append(ch)
124+ elif ch in "({[":
125+ depth += 1
126+ cur.append(ch)
127+ elif ch in ")}]":
128+ depth -= 1
129+ cur.append(ch)
130+ elif ch == sep and depth == 0:
131+ parts.append("".join(cur))
132+ cur = []
133+ else:
134+ cur.append(ch)
135+ i += 1
136+ if "".join(cur).strip():
137+ parts.append("".join(cur))
138+ return [p.strip() for p in parts]
139+
140+
141+def matching_paren(s, open_pos):
142+ depth = 0
143+ in_str = None
144+ i = open_pos
145+ while i < len(s):
146+ ch = s[i]
147+ if in_str:
148+ if ch == "\\":
149+ i += 2
150+ continue
151+ if ch == in_str:
152+ in_str = None
153+ elif ch in "\"'":
154+ in_str = ch
155+ elif ch == "(":
156+ depth += 1
157+ elif ch == ")":
158+ depth -= 1
159+ if depth == 0:
160+ return i
161+ i += 1
162+ raise ValueError("unbalanced parentheses")
163+
164+
165+# ----------------------------------------------------------------------------------------
166+# Theme.java
167+# ----------------------------------------------------------------------------------------
168+
169+# Guard of the out-bubble black/white text block in ThemeAccent.fillAccentColors: stock, or widened for runtime
170+# accents by the palette fix (group 1 is set when widened).
171+OUT_BLOCK_GUARD_RE = re.compile(
172+ r"if\s*\(\s*!\s*isMyMessagesGradientColorsNear\s*"
173+ r"(\|\|\s*(?:id\s*>\s*100|(?:[A-Za-z_][\w.]*\.)?PaletteFix\s*\.\s*isRuntimeAccent\s*\(\s*this\s*\))\s*)?"
174+ r"\)\s*\{")
175+
176+
177+def parse_theme_java(path=THEME_JAVA):
178+ raw = open(path, encoding="utf-8").read()
179+ src = strip_java_comments(raw)
180+ res = {"file": os.path.relpath(path, REPO)}
181+
182+ # colour keys, in declaration order; also the *Index markers
183+ keys, key_lines, markers = [], {}, {}
184+ count = None
185+ decl_re = re.compile(r"\bstatic\s+final\s+int\s+(key_\w+)\s*=\s*colorsCount\s*\+\+\s*;")
186+ marker_re = re.compile(r"\bstatic\s+final\s+int\s+(\w+Index)\s*=\s*colorsCount\s*;")
187+ start_re = re.compile(r"\bpublic\s+static\s+int\s+colorsCount\s*;")
188+ for m in re.finditer(r"[^\n]*\n", src):
189+ line = m.group(0)
190+ ln = line_of(src, m.start())
191+ if count is None:
192+ if start_re.search(line):
193+ count = 0
194+ continue
195+ for dm in decl_re.finditer(line):
196+ name = dm.group(1)
197+ if name in key_lines:
198+ raise ValueError("duplicate key " + name)
199+ keys.append(name)
200+ key_lines[name] = ln
201+ count += 1
202+ mm = marker_re.search(line)
203+ if mm:
204+ markers[mm.group(1)] = {"value": count, "line": ln}
205+ res["keys"] = keys
206+ res["key_lines"] = key_lines
207+ res["markers"] = markers
208+ res["decl_count_grep"] = len(re.findall(r"=\s*colorsCount\s*\+\+\s*;", src))
209+
210+ # constants
211+ def const(name):
212+ m = re.search(r"\b" + name + r"\s*=\s*(-?(?:0x[0-9a-fA-F]+|\d+))\s*;", src)
213+ return {"value": s32(java_int_literal(m.group(1))), "line": line_of(src, m.start())}
214+ res["DEFALT_THEME_ACCENT_ID"] = const("DEFALT_THEME_ACCENT_ID")
215+ res["MSG_OUT_COLOR_BLACK"] = const("MSG_OUT_COLOR_BLACK")
216+ res["MSG_OUT_COLOR_WHITE"] = const("MSG_OUT_COLOR_WHITE")
217+
218+ # fallbackKeys.put(key_A, key_B)
219+ fb = []
220+ for m in re.finditer(r"fallbackKeys\.put\(\s*(?:Theme\.)?(key_\w+)\s*,\s*(?:Theme\.)?(key_\w+)\s*\)\s*;", src):
221+ fb.append([m.group(1), m.group(2), line_of(src, m.start())])
222+ res["fallbackKeys"] = fb
223+
224+ # int[] arrays of keys (keys_avatar_background etc.)
225+ arrays = {}
226+ for m in re.finditer(r"\bint\s*\[\]\s+(keys_\w+)\s*=\s*\{([^}]*)\}\s*;", src):
227+ arrays[m.group(1)] = {"keys": [t.strip() for t in m.group(2).split(",") if t.strip()],
228+ "line": line_of(src, m.start())}
229+ res["key_arrays"] = arrays
230+
231+ # themeAccentExclusionKeys
232+ excl = []
233+ for m in re.finditer(r"for\s*\(\s*int\s+(\w+)\s*=\s*0\s*;\s*\1\s*<\s*(keys_\w+)\.length\s*;\s*\1\+\+\s*\)\s*\{\s*themeAccentExclusionKeys\.add\(\s*\2\s*\[\s*\1\s*\]\s*\)\s*;\s*\}", src):
234+ for k in arrays[m.group(2)]["keys"]:
235+ excl.append([k, line_of(src, m.start()), m.group(2)])
236+ for m in re.finditer(r"themeAccentExclusionKeys\.add\(\s*(?:Theme\.)?(key_\w+)\s*\)\s*;", src):
237+ excl.append([m.group(1), line_of(src, m.start()), None])
238+ res["themeAccentExclusionKeys"] = excl
239+ n_add_calls = len(re.findall(r"themeAccentExclusionKeys\.add\(", src))
240+ res["exclusion_add_calls"] = n_add_calls
241+
242+ m = re.search(r"myMessagesAccentExtraKeys\s*=\s*\{([^}]*)\}\s*;", src)
243+ res["myMessagesAccentExtraKeys"] = {"keys": [t.strip() for t in m.group(1).split(",") if t.strip()],
244+ "line": line_of(src, m.start())}
245+
246+ # bundled themes
247+ themes = []
248+ for m in re.finditer(r"themeInfo\s*=\s*new\s+ThemeInfo\s*\(\s*\)\s*;", src):
249+ start = m.end()
250+ end = src.find("themesDict.put(", start)
251+ block = src[start:end]
252+ if not re.search(r'themeInfo\.assetName\s*=\s*"[^"]+\.attheme"\s*;', block) or "new ThemeInfo(" in block:
253+ continue
254+ t = {"line": line_of(src, m.start())}
255+ nm = re.search(r'themeInfo\.name\s*=\s*"([^"]*)"\s*;', block)
256+ t["name"] = nm.group(1)
257+ t["name_line"] = line_of(src, start + nm.start())
258+ am = re.search(r'themeInfo\.assetName\s*=\s*"([^"]*)"\s*;', block)
259+ t["assetName"] = am.group(1)
260+ t["firstAccentIsDefault"] = bool(re.search(r"themeInfo\.firstAccentIsDefault\s*=\s*true\s*;", block))
261+ cm = re.search(r"themeInfo\.currentAccentId\s*=\s*(\w+)\s*;", block)
262+ t["currentAccentId_init"] = cm.group(1) if cm else None
263+ sm = re.search(r"themeInfo\.setAccentColorOptions\s*\(", block)
264+ if sm is None:
265+ # Forkgram's Monet Light/Dark: colours come from the system palette at run time, no accents to model
266+ continue
267+ op = start + sm.end() - 1
268+ cp = matching_paren(src, op)
269+ args = split_top_level(src[op + 1:cp])
270+ t["setAccentColorOptions_line"] = line_of(src, start + sm.start())
271+ parsed_args = []
272+ for a in args:
273+ if a == "null":
274+ parsed_args.append(None)
275+ continue
276+ am2 = re.match(r"new\s+(int|String)\s*\[\]\s*\{(.*)\}\s*$", a, re.S)
277+ typ, body = am2.group(1), am2.group(2)
278+ items = [x.strip() for x in split_top_level(body) if x.strip()]
279+ if typ == "int":
280+ parsed_args.append([s32(java_int_literal(x)) for x in items])
281+ else:
282+ parsed_args.append([x.strip()[1:-1] for x in items])
283+ names = ["accent", "myMessages", "myMessagesGradient", "background", "backgroundGradient1",
284+ "backgroundGradient2", "backgroundGradient3", "ids", "patternSlugs", "patternRotations",
285+ "patternIntensities"]
286+ if len(parsed_args) == 1:
287+ parsed_args += [None] * 10
288+ t["options"] = dict(zip(names, parsed_args))
289+ themes.append(t)
290+ res["themes"] = themes
291+
292+ # setAccentColorOptions body: "override default themes" block, accentBaseColor assignment
293+ sm = re.search(r"private\s+void\s+setAccentColorOptions\s*\(\s*int\s*\[\]\s*accent\s*,", src)
294+ body_start = src.find("{", sm.end())
295+ depth, i = 0, body_start
296+ while True:
297+ if src[i] == "{":
298+ depth += 1
299+ elif src[i] == "}":
300+ depth -= 1
301+ if depth == 0:
302+ break
303+ i += 1
304+ body = src[body_start:i]
305+ res["setAccentColorOptions_line"] = line_of(src, sm.start())
306+ om = re.search(r'if\s*\(\s*isHome\(themeAccent\)\s*&&\s*name\.equals\("Dark Blue"\)\s*\|\|\s*name\.equals\("Night"\)\s*\)\s*\{', body)
307+ if not om:
308+ raise ValueError("override-default-themes condition changed; update the simulator")
309+ res["override_condition_line"] = line_of(src, body_start + om.start())
310+ ob_start = body_start + om.end()
311+ # take the block up to the matching brace
312+ depth, j = 1, ob_start
313+ while depth:
314+ if src[j] == "{":
315+ depth += 1
316+ elif src[j] == "}":
317+ depth -= 1
318+ j += 1
319+ ob = src[ob_start:j]
320+ ov = {}
321+ night_part = ob[ob.find('if (name.equals("Night"))'):] if 'if (name.equals("Night"))' in ob else ""
322+ common_part = ob[:ob.find('if (name.equals("Night"))')] if night_part else ob
323+ for fm in re.finditer(r"themeAccent\.(\w+)\s*=\s*(-?[0-9.]+f|-?0x[0-9a-fA-F]+|-?\d+)\s*;", common_part):
324+ ov[fm.group(1)] = fm.group(2)
325+ res["override_all"] = {k: (s32(java_int_literal(v)) if not v.endswith("f") else float(v[:-1])) for k, v in ov.items()}
326+ nv = {}
327+ for fm in re.finditer(r"themeAccent\.(\w+)\s*=\s*(-?[0-9.]+f|-?0x[0-9a-fA-F]+|-?\d+)\s*;", night_part):
328+ nv[fm.group(1)] = fm.group(2)
329+ res["override_night"] = {k: (s32(java_int_literal(v)) if not v.endswith("f") else float(v[:-1])) for k, v in nv.items()}
330+ bm = re.search(r"accentBaseColor\s*=\s*themeAccentsMap\.get\(\s*(\d+)\s*\)\.accentColor\s*;", body)
331+ res["accentBaseColor_id"] = int(bm.group(1))
332+ res["accentBaseColor_line"] = line_of(src, body_start + bm.start())
333+
334+ # isHome rules
335+ hm = re.search(r"public\s+static\s+boolean\s+isHome\s*\(\s*ThemeAccent\s+accent\s*\)", src)
336+ hbody = src[hm.end():src.find("return false;", hm.end())]
337+ home = {}
338+ for rm in re.finditer(r'\(?((?:accent\.parentTheme\.getKey\(\)\.equals\("[^"]+"\)\s*(?:\|\|\s*)?)+)\)?\s*&&\s*accent\.id\s*==\s*(\d+)', hbody):
339+ for nmx in re.findall(r'equals\("([^"]+)"\)', rm.group(1)):
340+ home[nmx] = int(rm.group(2))
341+ res["isHome"] = home
342+ res["isHome_line"] = line_of(src, hm.start())
343+
344+ # fillAccentColors: the black/white out-text block (keys written with textColor / subTextColor /
345+ # seekbarColor / myMessagesAccentColor) and the constants chosen by useBlackText
346+ fm0 = re.search(r"public\s+boolean\s+fillAccentColors\s*\(\s*SparseIntArray\s+currentColorsNoAccent\s*,\s*SparseIntArray\s+currentColors\s*\)", src)
347+ res["fillAccentColors_line"] = line_of(src, fm0.start())
348+ # MultiGram's palette fix widens this guard (rule (d)) to
349+ # if (!isMyMessagesGradientColorsNear || org.telegram.messenger.multigram.PaletteFix.isRuntimeAccent(this)) {
350+ # (id > 100 and no server theme behind the accent: true for the simulated accent, id 101 without info);
351+ # the research form `|| id > 100` means the same here. All three forms are accepted (OUT_BLOCK_GUARD_RE) and
352+ # a widened guard is recorded for engine.Model.
353+ gm = OUT_BLOCK_GUARD_RE.search(src, fm0.end())
354+ if gm is None:
355+ raise ValueError("fillAccentColors out-text block guard not found; update the simulator")
356+ blk0 = gm.start()
357+ res["out_block_guard_id_gt_100"] = gm.group(1) is not None
358+ blk1 = src.find("if (isMyMessagesGradientColorsNear) {", blk0)
359+ if blk1 < 0:
360+ raise ValueError("fillAccentColors isMyMessagesGradientColorsNear block not found; update the simulator")
361+ blk = src[blk0:blk1]
362+ res["out_text_block_line"] = line_of(src, blk0)
363+ cm_b = re.search(r"if\s*\(\s*useBlackText\s*\)\s*\{\s*textColor\s*=\s*(\w+)\s*;\s*subTextColor\s*=\s*(0x[0-9a-fA-F]+)\s*;\s*seekbarColor\s*=\s*(0x[0-9a-fA-F]+)\s*;\s*\}\s*else\s*\{\s*textColor\s*=\s*(\w+)\s*;\s*subTextColor\s*=\s*(0x[0-9a-fA-F]+)\s*;\s*seekbarColor\s*=\s*(0x[0-9a-fA-F]+)\s*;", blk)
364+ res["out_text_consts"] = {"black": [cm_b.group(1), s32(java_int_literal(cm_b.group(2))), s32(java_int_literal(cm_b.group(3)))],
365+ "white": [cm_b.group(4), s32(java_int_literal(cm_b.group(5))), s32(java_int_literal(cm_b.group(6)))]}
366+ a2 = blk.find("if (accentColor2 == 0) {")
367+ # end of the accentColor2 == 0 sub-block: matching brace
368+ depth, j = 0, a2 + len("if (accentColor2 == 0) ")
369+ while True:
370+ if blk[j] == "{":
371+ depth += 1
372+ elif blk[j] == "}":
373+ depth -= 1
374+ if depth == 0:
375+ break
376+ j += 1
377+ inner = blk[a2:j]
378+ outer = blk[j:]
379+ put_re = re.compile(r"currentColors\.put\(\s*(key_\w+)\s*,\s*(textColor|subTextColor|seekbarColor|myMessagesAccentColor)\s*\)\s*;")
380+ res["out_block_accent2_zero"] = [[m2.group(1), m2.group(2)] for m2 in put_re.finditer(inner)]
381+ res["out_block_always"] = [[m2.group(1), m2.group(2)] for m2 in put_re.finditer(outer)]
382+
383+ # createDefaultWallpaper(): 4 colours + pattern intensity of the built-in 'd' wallpaper
384+ dm = re.search(r"public\s+static\s+Drawable\s+createDefaultWallpaper\s*\(\s*int\s+w\s*,\s*int\s+h\s*\)", src)
385+ dbody = src[dm.end():src.find("return motionBackgroundDrawable;", dm.end())]
386+ cm2 = re.search(r"new\s+MotionBackgroundDrawable\(\s*(0x[0-9a-fA-F]+)\s*,\s*(0x[0-9a-fA-F]+)\s*,\s*(0x[0-9a-fA-F]+)\s*,\s*(0x[0-9a-fA-F]+)\s*,", dbody)
387+ im2 = re.search(r"setPatternBitmap\(\s*(\d+)\s*,\s*SvgHelper\.getBitmap\(\s*R\.raw\.default_pattern", dbody)
388+ res["default_wallpaper"] = {"colors": [s32(java_int_literal(cm2.group(i))) for i in range(1, 5)],
389+ "intensity": int(im2.group(1)), "line": line_of(src, dm.end() + cm2.start())}
390+
391+ # applyChatServiceMessageColor(): colour matrix applied to the gradient bitmap under service pills
392+ sm2 = re.search(r"if\s*\(\s*drawable\s+instanceof\s+MotionBackgroundDrawable\s*\)\s*\{\s*float\s+intensity\s*=\s*\(\(MotionBackgroundDrawable\)\s*drawable\)\.getIntensity\(\)\s*;", src)
393+ sbody = src[sm2.end():sm2.end() + 1500]
394+ f_re = r"(-?\+?\.?[0-9.]+)f"
395+ branch_re = re.compile(r"colorMatrix\.setSaturation\(\s*" + f_re + r"\s*\)\s*;\s*AndroidUtilities\.multiplyBrightnessColorMatrix\(\s*colorMatrix\s*,\s*isCurrentThemeDark\(\)\s*\?\s*" + f_re + r"\s*:\s*" + f_re + r"\s*\)\s*;\s*AndroidUtilities\.adjustBrightnessColorMatrix\(\s*colorMatrix\s*,\s*isCurrentThemeDark\(\)\s*\?\s*" + f_re + r"\s*:\s*" + f_re + r"\s*\)\s*;")
396+ br = branch_re.findall(sbody)
397+ def _f(x):
398+ return float(x.replace("+", ""))
399+ res["service_matrix"] = {
400+ "intensity_ge0": {"sat": _f(br[0][0]), "mult_dark": _f(br[0][1]), "mult_light": _f(br[0][2]), "add_dark": _f(br[0][3]), "add_light": _f(br[0][4])},
401+ "intensity_lt0": {"sat": _f(br[1][0]), "mult_dark": _f(br[1][1]), "mult_light": _f(br[1][2]), "add_dark": _f(br[1][3]), "add_light": _f(br[1][4])},
402+ "line": line_of(src, sm2.start())}
403+ unparsed = [x for x in re.findall(r"currentColors\.put\([^;]*;", blk) if not put_re.fullmatch(x)]
404+ res["out_block_unparsed"] = unparsed
405+
406+ # the fresh-install accent id: getInt("accent_current_" + ..., firstAccentIsDefault ? DEFALT : 0)
407+ fm = re.search(r'getInt\(\s*"accent_current_"\s*\+\s*info\.assetName\s*,\s*info\.firstAccentIsDefault\s*\?\s*DEFALT_THEME_ACCENT_ID\s*:\s*(\d+)\s*\)', src)
408+ res["fresh_install_accent_id_non_default"] = int(fm.group(1))
409+ res["fresh_install_accent_line"] = line_of(src, fm.start())
410+ return res
411+
412+
413+# ----------------------------------------------------------------------------------------
414+# ThemeColors.java
415+# ----------------------------------------------------------------------------------------
416+
417+COLOR_CONSTS = {"Color.WHITE": s32(0xFFFFFFFF), "Color.BLACK": s32(0xFF000000),
418+ "Color.TRANSPARENT": 0, "Color.RED": s32(0xFFFF0000), "Color.GREEN": s32(0xFF00FF00),
419+ "Color.BLUE": s32(0xFF0000FF)}
420+
421+
422+def eval_color_expr(expr, consts):
423+ e = expr.strip()
424+ if re.fullmatch(r"-?(0x[0-9a-fA-F]+|\d+)", e):
425+ return s32(java_int_literal(e))
426+ if e in consts:
427+ return consts[e]
428+ if e in COLOR_CONSTS:
429+ return COLOR_CONSTS[e]
430+ m = re.fullmatch(r"ColorUtils\.setAlphaComponent\((.*)\)", e)
431+ if m:
432+ a, b = split_top_level(m.group(1))
433+ base = eval_color_expr(a, consts)
434+ alpha = eval_color_expr(b, consts)
435+ if base is None or alpha is None:
436+ return None
437+ if not 0 <= alpha <= 255:
438+ raise ValueError("alpha must be 0..255")
439+ return s32((u32(base) & 0x00FFFFFF) | (alpha << 24))
440+ m = re.fullmatch(r"Color\.argb\((.*)\)", e)
441+ if m:
442+ vals = [eval_color_expr(x, consts) for x in split_top_level(m.group(1))]
443+ if None in vals:
444+ return None
445+ a, r, g, b = vals
446+ return s32((a << 24) | (r << 16) | (g << 8) | b)
447+ return None
448+
449+
450+def parse_themecolors_java(path=THEMECOLORS_JAVA):
451+ raw = open(path, encoding="utf-8").read()
452+ src = strip_java_comments(raw)
453+ res = {"file": os.path.relpath(path, REPO)}
454+ consts = {}
455+ const_lines = {}
456+ for m in re.finditer(r"public\s+static\s+final\s+int\s+(\w+)\s*=\s*(-?0x[0-9a-fA-F]+|-?\d+)\s*;", src):
457+ consts[m.group(1)] = s32(java_int_literal(m.group(2)))
458+ const_lines[m.group(1)] = line_of(src, m.start())
459+ res["consts"] = consts
460+ res["const_lines"] = const_lines
461+ cm = re.search(r"public\s+static\s+int\s*\[\]\s*createDefaultColors\s*\(\s*\)\s*\{", src)
462+ end = src.find("return defaultColors;", cm.end())
463+ body = src[cm.end():end]
464+ defaults, unevaluated, lines = {}, [], {}
465+ stmt_count = 0
466+ for m in re.finditer(r"defaultColors\s*\[\s*(key_\w+)\s*\]\s*=\s*([^;]+);", body):
467+ stmt_count += 1
468+ v = eval_color_expr(m.group(2), consts)
469+ ln = line_of(src, cm.end() + m.start())
470+ if v is None:
471+ unevaluated.append([m.group(1), m.group(2).strip(), ln])
472+ continue
473+ defaults[m.group(1)] = v # later assignments win, like Java
474+ lines[m.group(1)] = ln
475+ res["defaults"] = defaults
476+ res["default_lines"] = lines
477+ res["default_statements"] = stmt_count
478+ res["unevaluated"] = unevaluated
479+ km = re.search(r"public\s+static\s+SparseArray<String>\s+createColorKeysMap\s*\(\s*\)\s*\{", src)
480+ kend = src.find("return colorKeysMap;", km.end())
481+ kbody = src[km.end():kend]
482+ keymap = []
483+ for m in re.finditer(r'colorKeysMap\.put\(\s*(key_\w+)\s*,\s*"([^"]*)"\s*\)\s*;', kbody):
484+ keymap.append([m.group(1), m.group(2), line_of(src, km.end() + m.start())])
485+ res["colorKeysMap"] = keymap
486+ res["createColorKeysMap_line"] = line_of(src, km.start())
487+ res["createDefaultColors_line"] = line_of(src, cm.start())
488+ return res
489+
490+
491+# ----------------------------------------------------------------------------------------
492+# .attheme files, parsed like Theme.getThemeFileValues()
493+# ----------------------------------------------------------------------------------------
494+
495+def java_utilities_parse_int(value):
496+ """Port of org.telegram.messenger.Utilities.parseInt(CharSequence).
497+ Quirk: when a non [-0-9] char follows the number, `end++` makes the substring include
498+ that char, so Integer.parseInt throws and the result is 0."""
499+ if value is None:
500+ return 0
501+ start = -1
502+ end = 0
503+ n = len(value)
504+ while end < n:
505+ ch = value[end]
506+ allowed = ch == "-" or ("0" <= ch <= "9")
507+ if allowed and start < 0:
508+ start = end
509+ elif not allowed and start >= 0:
510+ end += 1
511+ break
512+ end += 1
513+ if start < 0:
514+ return 0
515+ s = value[start:end]
516+ if not re.fullmatch(r"[-+]?\d+", s):
517+ return 0
518+ v = int(s)
519+ if v < -2 ** 31 or v > 2 ** 31 - 1:
520+ return 0
521+ return v
522+
523+
524+def java_parse_color(s):
525+ """android.graphics.Color.parseColor for '#RRGGBB' / '#AARRGGBB'; raises like Java."""
526+ if s[0] != "#":
527+ raise ValueError("Unknown color")
528+ v = int(s[1:], 16) # Long.parseLong(..., 16): raises on bad digits
529+ if len(s) == 7:
530+ v |= 0xFF000000
531+ elif len(s) != 9:
532+ raise ValueError("Unknown color")
533+ return s32(v)
534+
535+
536+def parse_attheme(path, name_to_key):
537+ data = open(path, "rb").read()
538+ values = {}
539+ unknown = []
540+ dropped_tail = None
541+ wallpaper_offset = -1
542+ pos = 0
543+ ln = 0
544+ while True:
545+ nl = data.find(b"\n", pos)
546+ if nl < 0:
547+ tail = data[pos:]
548+ if tail:
549+ dropped_tail = tail.decode("latin-1")
550+ break
551+ ln += 1
552+ line = data[pos:nl].decode("latin-1")
553+ length = nl - pos + 1
554+ if line.startswith("WLS="):
555+ pass
556+ elif line.startswith("WPS"):
557+ wallpaper_offset = pos + length
558+ break
559+ else:
560+ idx = line.find("=")
561+ if idx != -1:
562+ key = line[:idx]
563+ param = line[idx + 1:]
564+ if len(param) > 0 and param[0] == "#":
565+ try:
566+ value = java_parse_color(param)
567+ except Exception:
568+ value = java_utilities_parse_int(param)
569+ else:
570+ value = java_utilities_parse_int(param)
571+ k = name_to_key.get(key)
572+ if k is not None:
573+ values[k] = [value, ln]
574+ else:
575+ unknown.append([key, ln])
576+ pos = nl + 1
577+ values["key_wallpaperFileOffset"] = [wallpaper_offset, None]
578+ return {"values": values, "unknown": unknown, "dropped_tail": dropped_tail,
579+ "wallpaperFileOffset": wallpaper_offset, "file": os.path.relpath(path, REPO)}
580+
581+
582+# ----------------------------------------------------------------------------------------
583+# cache
584+# ----------------------------------------------------------------------------------------
585+
586+def _inputs_digest():
587+ h = hashlib.sha256()
588+ h.update(str(PARSER_VERSION).encode())
589+ for p in [THEME_JAVA, THEMECOLORS_JAVA] + [os.path.join(ASSETS, a) for a in ATTHEMES]:
590+ h.update(p.encode())
591+ with open(p, "rb") as f:
592+ h.update(f.read())
593+ return h.hexdigest()[:16]
594+
595+
596+def load(force=False):
597+ digest = _inputs_digest()
598+ os.makedirs(CACHE_DIR, exist_ok=True)
599+ cpath = os.path.join(CACHE_DIR, "parsed_%s.json" % digest)
600+ if not force and os.path.exists(cpath):
601+ try:
602+ with open(cpath) as f:
603+ cached = json.load(f)
604+ if cached.get("digest") == digest:
605+ return cached
606+ except (OSError, ValueError):
607+ pass # unreadable or truncated cache file: parse again and rewrite it
608+ tj = parse_theme_java()
609+ tc = parse_themecolors_java()
610+ name_to_key = {}
611+ dup_names = []
612+ for k, nm, _ in tc["colorKeysMap"]:
613+ if nm in name_to_key:
614+ dup_names.append(nm)
615+ name_to_key[nm] = k
616+ at = {}
617+ for a in ATTHEMES:
618+ at[a] = parse_attheme(os.path.join(ASSETS, a), name_to_key)
619+ out = {"digest": digest, "repo": REPO, "theme_java": tj, "themecolors_java": tc, "atthemes": at,
620+ "keymap_duplicate_names": dup_names}
621+ # write-then-rename, so an interrupted run or two concurrent runs never leave a partial cache file
622+ fd, tmp = tempfile.mkstemp(prefix=".parsed_", suffix=".tmp", dir=CACHE_DIR)
623+ try:
624+ with os.fdopen(fd, "w") as f:
625+ json.dump(out, f)
626+ os.chmod(tmp, 0o644)
627+ os.replace(tmp, cpath)
628+ except BaseException:
629+ try:
630+ os.unlink(tmp)
631+ except OSError:
632+ pass
633+ raise
634+ return out
635+
636+
637+if __name__ == "__main__":
638+ d = load(force=True)
639+ tj, tc = d["theme_java"], d["themecolors_java"]
640+ print("keys:", len(tj["keys"]), "grep count:", tj["decl_count_grep"], "markers:", tj["markers"])
641+ print("fallbackKeys:", len(tj["fallbackKeys"]), "exclusions:", len(tj["themeAccentExclusionKeys"]),
642+ "add calls:", tj["exclusion_add_calls"])
643+ print("extra keys:", tj["myMessagesAccentExtraKeys"])
644+ print("defaults:", len(tc["defaults"]), "statements:", tc["default_statements"], "unevaluated:", tc["unevaluated"])
645+ print("keymap:", len(tc["colorKeysMap"]), "dups:", d["keymap_duplicate_names"])
646+ print("override_all:", tj["override_all"], "override_night:", tj["override_night"])
647+ print("isHome:", tj["isHome"], "accentBaseColor id:", tj["accentBaseColor_id"])
648+ for t in tj["themes"]:
649+ o = t["options"]
650+ print(t["name"], t["assetName"], "firstDefault", t["firstAccentIsDefault"], "init", t["currentAccentId_init"],
651+ "n", len(o["accent"]), "ids", o["ids"])
652+ for a, v in d["atthemes"].items():
653+ print(a, "values", len(v["values"]), "unknown", v["unknown"], "dropped tail", v["dropped_tail"])
multigram/tools/style_table.pynew file+585
@@ -0,0 +1,585 @@
1+"""MultiGram style table: binary format, generator and validator, shared by make_style_table.py and
2+check_style_table.py.
3+
4+A style is one runtime ThemeAccent on a bundled base theme: accent colour, outgoing-bubble colours, and a
5+2-4 colour wallpaper gradient with no pattern. Every entry is produced and checked by the colour-engine
6+simulator in sim/ (engine.py: port of Theme.fillAccentColors / getColor; readability.py: the 99 readability
7+pairs) on the fixed palette "overlay A". The format is documented in README.md next to this file; keep the
8+two in sync.
9+"""
10+import copy
11+import json
12+import os
13+import re
14+import struct
15+import sys
16+import tempfile
17+import zlib
18+
19+HERE = os.path.dirname(os.path.abspath(__file__))
20+SIM = os.path.join(HERE, "sim")
21+if SIM not in sys.path:
22+ sys.path.insert(0, SIM)
23+
24+import engine as E # noqa: E402
25+import readability as R # noqa: E402
26+import style_sim as S # noqa: E402
27+import tgsrc # noqa: E402
28+
29+REPO_ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
30+ASSET_REL = "TMessagesProj/src/main/assets/multigram_styles.bin"
31+DEFAULT_ASSET = os.path.join(REPO_ROOT, *ASSET_REL.split("/"))
32+# Overlay A as the palette fix ships it (multigram/palette-fix/ in the modelled tree, kept in sync with
33+# PaletteFix.java by palette_fix_check.py) when that tree has it, else the simulator's copy. Both are the same
34+# file today.
35+OVERLAY_A_CANDIDATES = [os.path.join(tgsrc.REPO, "multigram", "palette-fix", "overlay_A.json"),
36+ os.path.join(SIM, "palette-fix", "overlay_A.json")]
37+
38+# ------------------------------------------------------------------------------------------
39+# binary format (big-endian; see README.md "multigram_styles.bin")
40+# ------------------------------------------------------------------------------------------
41+
42+MAGIC = b"MGST"
43+VERSION = 1
44+HEADER = struct.Struct(">4sHHHHHHII") # magic, version, header size, theme count, dir entry size, record size, reserved, record count, crc32
45+DIR_ENTRY = struct.Struct(">20sBBHII") # theme key, flags, reserved, reserved, first record, record count
46+RECORD = struct.Struct(">9IHBB") # 9 ARGB colours, rotation, flags, reserved
47+assert (HEADER.size, DIR_ENTRY.size, RECORD.size) == (24, 32, 40)
48+THEME_KEY_BYTES = 20
49+THEME_DARK = 0x01 # directory flags
50+REC_ANIMATED = 0x01 # record flags: ThemeAccent.myMessagesAnimated
51+REC_MOTION = 0x02 # ThemeAccent.patternMotion
52+ROTATIONS = tuple(S.C["rotations"]) # 0, 45, ..., 315
53+COLOUR_FIELDS = ["accentColor", "myMessagesAccentColor", "myMessagesGradientAccentColor1",
54+ "myMessagesGradientAccentColor2", "myMessagesGradientAccentColor3", "wallpaperColor1",
55+ "wallpaperColor2", "wallpaperColor3", "wallpaperColor4"]
56+
57+# ------------------------------------------------------------------------------------------
58+# generator parameters (changing any of them changes the table)
59+# ------------------------------------------------------------------------------------------
60+
61+TABLE_SEED = "multigram-style-table-1"
62+PER_THEME = 4096
63+DAY_THEMES = ["Blue", "Arctic Blue", "Day"]
64+NIGHT_THEMES = ["Dark Blue", "Night"]
65+TABLE_THEMES = DAY_THEMES + NIGHT_THEMES # directory order
66+# Wallpapers are always gradients: 2 colours (linear, uses the rotation) or 3-4 (animated motion gradient).
67+WALL_COUNT_WEIGHTS = [(2, 0.25), (3, 0.20), (4, 0.55)]
68+# Headroom: the simulator's solver stops exactly on its thresholds, so the table is solved against these
69+# (+2.2 % text, +3.3 % non-text, +3.5 % bubble separation) instead of plain WCAG.
70+HEADROOM = {R.TEXT: 4.6, R.NONTEXT: 3.1, R.SEPARATION: 1.19}
71+CLASS_NAME = {R.TEXT: "text", R.NONTEXT: "non-text", R.SEPARATION: "separation"}
72+
73+
74+def wcag_thresholds():
75+ return [p["threshold"] for p in R.PAIRS]
76+
77+
78+# ------------------------------------------------------------------------------------------
79+# overlay A
80+# ------------------------------------------------------------------------------------------
81+
82+_SPEC = {}
83+
84+
85+def overlay_path():
86+ for p in OVERLAY_A_CANDIDATES:
87+ if os.path.exists(p):
88+ return p
89+ raise SystemExit("overlay_A.json not found (looked in %s)" % ", ".join(OVERLAY_A_CANDIDATES))
90+
91+
92+def overlay_spec():
93+ p = overlay_path()
94+ if p not in _SPEC:
95+ with open(p) as f:
96+ _SPEC[p] = json.load(f)
97+ return _SPEC[p]
98+
99+
100+def hook_content_keys(spec):
101+ return set(a for a, _ in spec["hook"]["pairs"])
102+
103+
104+# Probe of the accent space used to find the pairs a style cannot move: 24 hues x 3 chromas (the generator's
105+# band ends and middle) x 9 lightnesses over the generator's accent L band, each on the base theme with no
106+# bubble or wallpaper override.
107+PROBE_HUES = [15.0 * i for i in range(24)]
108+PROBE_L_STEPS = 9
109+# A pair is palette-limited when the probe moves its contrast by less than this share of its threshold.
110+PALETTE_LIMITED_RANGE = 0.10
111+
112+_PROBE = {}
113+
114+
115+def probe_ranges(theme):
116+ """{pair index: (min, max) contrast over the probe accents} for the accent-surface pairs (S.ACCENT_PAIRS)."""
117+ key = (theme.model.tag, id(theme.model), theme.name)
118+ r = _PROBE.get(key)
119+ if r is None:
120+ lo, hi, _ = S.C["accent_L"]["dark" if theme.is_dark else "light"]
121+ c0, c1 = S.C["accent_chroma"]["dark" if theme.is_dark else "light"]
122+ r = {}
123+ for h in PROBE_HUES:
124+ for cc in (c0, (c0 + c1) * 0.5, c1):
125+ for j in range(PROBE_L_STEPS):
126+ L = lo + (hi - lo) * j / (PROBE_L_STEPS - 1)
127+ _, res = S.evaluate(theme, S.base_variant(theme, S.argb_from_oklch(L, cc, h)), S.ACCENT_PAIRS)
128+ for i, c in res.items():
129+ a, b = r.get(i, (c, c))
130+ r[i] = (min(a, c), max(b, c))
131+ _PROBE[key] = r
132+ return r
133+
134+
135+def palette_limited(theme):
136+ """Indices of the pairs whose contrast the palette sets, not the style. They are held to plain WCAG instead
137+ of the headroom thresholds (README.md, "Readability rule"):
138+ - pairs no accent moves by more than PALETTE_LIMITED_RANGE of their threshold (probe_ranges) and that fall
139+ below the headroom level for some accent: neutral greys on the page, list, composer and incoming bubble,
140+ which overlay A sized at the plain WCAG threshold (on the light themes they are constant at 4.50-4.54:1,
141+ on Night the accent tint moves them by at most 0.2), and on Night the stock grey chats_attachMessage;
142+ - the digits on the muted unread badge, while they fall below the headroom level for some accent: the
143+ on-accent hook fills that badge from its per-theme table, one entry for white and one for near-black
144+ digits, each sized at 4.5:1 today; the style only picks which one.
145+ Both rules follow the palette: once the palette fix gives a pair headroom for every probe accent, the pair is
146+ no longer palette-limited and the generator holds it to HEADROOM (regenerate the table then)."""
147+ muted = overlay_spec()["hook"]["muted"]["key"]
148+ out = set()
149+ for i, (lo, hi) in probe_ranges(theme).items():
150+ p = R.PAIRS[i]
151+ t = p["threshold"]
152+ if lo < HEADROOM[t] and ((hi - lo) < PALETTE_LIMITED_RANGE * t or ("key_" + p["bg"]) == muted):
153+ out.add(i)
154+ return out
155+
156+
157+def headroom_thresholds(theme):
158+ """Generator thresholds: HEADROOM for every pair the style controls, plain WCAG for palette-limited pairs."""
159+ own = palette_limited(theme)
160+ return [p["threshold"] if i in own else HEADROOM[p["threshold"]] for i, p in enumerate(R.PAIRS)]
161+
162+
163+OVERLAY_MODES = ("auto", "apply", "in-tree")
164+
165+
166+# The out-text guard exactly as the palette fix writes it in Theme.ThemeAccent.fillAccentColors (the line
167+# multigram/tools/palette_fix_check.py requires). PaletteFix.isRuntimeAccent(accent) is `accent.id > 100 &&
168+# (accent.info == null || accent.info.creator)`: true for the table's accents (id > 100, no server theme), like
169+# the research form `|| id > 100`. tgsrc.OUT_BLOCK_GUARD_RE accepts both.
170+PALETTE_FIX_GUARD = ("if (!isMyMessagesGradientColorsNear || org.telegram.messenger.multigram.PaletteFix.isRuntimeAccent(this)) "
171+ "{ // MultiGram: runtime accents (generated/custom) always get readable black/white out-bubble texts")
172+GUARD_FORMS = [("stock", "if (!isMyMessagesGradientColorsNear) {", False),
173+ ("research `|| id > 100`", "if (!isMyMessagesGradientColorsNear || id > 100) {", True),
174+ ("palette fix `|| PaletteFix.isRuntimeAccent(this)`", PALETTE_FIX_GUARD, True)]
175+
176+
177+def parse_theme_java_with_guard(guard_line):
178+ """tgsrc.parse_theme_java() of this tree's Theme.java with its out-text guard line (the `if (...) {` and the
179+ rest of that line) replaced by guard_line; line numbers are unchanged."""
180+ with open(tgsrc.THEME_JAVA, encoding="utf-8") as f:
181+ raw = f.read()
182+ fm = re.search(r"public\s+boolean\s+fillAccentColors\s*\(", raw)
183+ gm = tgsrc.OUT_BLOCK_GUARD_RE.search(raw, fm.end()) if fm else None
184+ if gm is None:
185+ raise ValueError("fillAccentColors out-text block guard not found in %s" % tgsrc.THEME_JAVA)
186+ eol = raw.find("\n", gm.end())
187+ eol = len(raw) if eol < 0 else eol
188+ os.makedirs(tgsrc.CACHE_DIR, exist_ok=True)
189+ fd, tmp = tempfile.mkstemp(prefix=".Theme_", suffix=".java", dir=tgsrc.CACHE_DIR)
190+ try:
191+ with os.fdopen(fd, "w", encoding="utf-8") as f:
192+ f.write(raw[:gm.start()] + guard_line + raw[eol:])
193+ tj = tgsrc.parse_theme_java(tmp)
194+ finally:
195+ os.unlink(tmp)
196+ tj["file"] = os.path.relpath(tgsrc.THEME_JAVA, tgsrc.REPO)
197+ return tj
198+
199+
200+def overlay_state(parsed=None):
201+ """Which parts of overlay A the parsed tree already carries. The 12 on-accent ThemeColors defaults are
202+ left out: the app's palette fix keeps them stock and sets them at run time (runtime accents, id > 100)."""
203+ parsed = parsed or tgsrc.load()
204+ spec = overlay_spec()
205+ hook_content = hook_content_keys(spec)
206+ tc = parsed["themecolors_java"]["defaults"]
207+ differs = []
208+ n = 0
209+ for k, v in sorted(spec["defaults"].items()):
210+ if k in hook_content:
211+ continue
212+ n += 1
213+ if (tc.get(k, 0) & 0xFFFFFFFF) != v:
214+ differs.append("ThemeColors %s = %s (overlay A 0x%08X)" % (k, "0x%08X" % (tc[k] & 0xFFFFFFFF) if k in tc else "unset", v))
215+ for asset, kv in sorted(spec["attheme"].items()):
216+ vals = parsed["atthemes"][asset]["values"]
217+ for k, v in sorted(kv.items()):
218+ n += 1
219+ if k not in vals or (vals[k][0] & 0xFFFFFFFF) != v:
220+ differs.append("%s %s = %s (overlay A 0x%08X)" % (asset, k, "0x%08X" % (vals[k][0] & 0xFFFFFFFF) if k in vals else "unset", v))
221+ guard = bool(parsed["theme_java"].get("out_block_guard_id_gt_100", False))
222+ return {"values": n, "values_in_tree": n - len(differs), "differs": differs, "out_block_guard": guard}
223+
224+
225+def resolve_mode(mode, state):
226+ """auto: 'in-tree' when the tree carries the palette fix (its widened out-text guard, PALETTE_FIX_GUARD, or
227+ every overlay value), else 'apply'."""
228+ if mode != "auto":
229+ return mode
230+ return "in-tree" if (state["out_block_guard"] or not state["differs"]) else "apply"
231+
232+
233+def build_model(parsed, mode):
234+ """engine.Model for parsed theme sources plus overlay A.
235+ apply : the whole overlay on top of the sources: ThemeColors defaults and .attheme values assigned,
236+ accent exclusions added to the set, on-accent hook and the widened out-text guard switched on.
237+ Every part assigns an absolute value or adds to a set, so applying it to a tree that already
238+ carries overlay A changes nothing (check_style_table.py's self-test shows this).
239+ in-tree : the tree already carries the palette fix; its own ThemeColors/.attheme values are used as they
240+ are (so a later change to them is seen), and only the parts that live in Java code the parser
241+ does not read are added: the accent exclusions (PaletteFix.addAccentExclusions), the on-accent
242+ hook (PaletteFix.applyToCurrentColors) and the out-text guard."""
243+ spec = overlay_spec()
244+ if mode == "in-tree":
245+ spec = dict(spec, defaults={}, attheme={}, tag=spec["tag"] + "-in-tree")
246+ elif mode != "apply":
247+ raise ValueError(mode)
248+ return E.Model(parsed, spec)
249+
250+
251+_MODELS = {}
252+
253+
254+def model(mode="auto"):
255+ """(engine.Model, resolved mode, overlay state) for the tree's Theme.java / ThemeColors.java / .attheme
256+ files (TG_REPO, default: this repository) plus overlay A; see build_model()."""
257+ parsed = tgsrc.load()
258+ state = overlay_state(parsed)
259+ mode = resolve_mode(mode, state)
260+ m = _MODELS.get(mode)
261+ if m is None:
262+ m = build_model(parsed, mode)
263+ _MODELS[mode] = m
264+ return m, mode, state
265+
266+
267+def baked_parsed(parsed):
268+ """A copy of the parsed tree with overlay A written into it the way the palette fix does it (ThemeColors
269+ defaults except the 12 on-accent ones, .attheme values, and Theme.java parsed with PALETTE_FIX_GUARD), for
270+ the idempotency test."""
271+ spec = overlay_spec()
272+ hook_content = hook_content_keys(spec)
273+ p = copy.deepcopy(parsed)
274+ p["theme_java"] = parse_theme_java_with_guard(PALETTE_FIX_GUARD)
275+ for k, v in spec["defaults"].items():
276+ if k not in hook_content:
277+ p["themecolors_java"]["defaults"][k] = E.s32(v)
278+ for asset, kv in spec["attheme"].items():
279+ for k, v in kv.items():
280+ p["atthemes"][asset]["values"][k] = [E.s32(v), None]
281+ if not p["theme_java"]["out_block_guard_id_gt_100"]:
282+ raise ValueError("the simulator does not recognise the palette fix's out-text guard; update sim/tgsrc.py")
283+ return p
284+
285+
286+def forbidden_accents(theme):
287+ """Accent colours a generated accent must not equal: the theme's accentBaseColor (no re-tint at all) and
288+ every built-in preset accent (on Blue, preset 99's colour would switch getColor to ThemeColors defaults)."""
289+ out = {theme.accentBaseColor}
290+ for a in theme.accents.values():
291+ out.add(a.accentColor)
292+ return out
293+
294+
295+# ------------------------------------------------------------------------------------------
296+# generator: the simulator's seeded OKLCH solver, wallpaper always a 2-4 colour gradient, no pattern
297+# ------------------------------------------------------------------------------------------
298+
299+def draw_attempt(rng, dark):
300+ """style_sim.draw_attempt without the pattern draws and with WALL_COUNT_WEIGHTS; fixed draw order."""
301+ C = S.C
302+ d = {}
303+ d["accent_h"] = rng.between(0.0, 360.0)
304+ d["accent_C"] = rng.between(*C["accent_chroma"]["dark" if dark else "light"])
305+ d["bubble_count"] = S.weighted(rng, C["bubble_count_weights"])
306+ d["bubble_tone_u"] = rng.uniform()
307+ d["bubble_hue_off"] = rng.between(-C["bubble_hue_offset"], C["bubble_hue_offset"])
308+ d["bubble_L_u"] = rng.uniform()
309+ d["bubble_C_u"] = rng.uniform()
310+ d["bubble_extra"] = [(rng.between(-C["bubble_extra_hue"], C["bubble_extra_hue"]),
311+ rng.between(-C["bubble_extra_L"], C["bubble_extra_L"])) for _ in range(3)]
312+ d["animated_u"] = rng.uniform()
313+ d["wall_count"] = S.weighted(rng, WALL_COUNT_WEIGHTS)
314+ d["wall_complement_u"] = rng.uniform()
315+ d["wall_hue_off"] = rng.between(-C["wall_hue_offset"], C["wall_hue_offset"])
316+ d["wall_C_u"] = rng.uniform()
317+ d["wall_L_u"] = rng.uniform()
318+ d["wall_extra"] = [(rng.between(-C["wall_extra_hue"], C["wall_extra_hue"]),
319+ rng.between(-C["wall_extra_L"], C["wall_extra_L"])) for _ in range(3)]
320+ d["rotation_i"] = rng.below(len(C["rotations"]))
321+ d["motion_u"] = rng.uniform()
322+ d["accent_L_u"] = rng.uniform()
323+ return d
324+
325+
326+def wallpaper_of(d, band, L):
327+ """No pattern: empty slug and zero intensity, so the model renders the plain gradient (fill = the gradient
328+ samples; service pill through the intensity >= 0 colour matrix, as MotionBackgroundDrawable's default
329+ intensity 100 selects in Theme.applyChatServiceMessageColor), which is what the app draws when
330+ ThemeAccent.getPathToWallpaper() is null."""
331+ return {"source": "custom", "colors": S.wall_colors(d, d["accent_h"], band, L, d["wall_count"]),
332+ "rotation": S.C["rotations"][d["rotation_i"]], "pattern_intensity": 0.0, "pattern_slug": "",
333+ "motion": d["motion_u"] < 0.5}
334+
335+
336+def solve_wallpaper(theme, d, v0, thr, stats):
337+ """style_sim.solve_wallpaper for the no-pattern case: bisection on the wallpaper's OKLCH L."""
338+ band = S.C["wall"]["dark" if theme.is_dark else "light"]
339+ lo, hi = band["L"]
340+ L = band["L_start"][0] + (band["L_start"][1] - band["L_start"][0]) * d["wall_L_u"]
341+ for _ in range(S.C["wall_steps"]):
342+ v = dict(v0)
343+ v["wallpaper"] = wallpaper_of(d, band, L)
344+ ev, res = S.evaluate(theme, v, S.WALL_PAIRS)
345+ stats["evals"] += 1
346+ f = S.failing(res, thr)
347+ if not f:
348+ return v, L
349+ up = down = False
350+ for i in f:
351+ if S._direction(ev, i, moving_fg=False) > 0:
352+ up = True
353+ else:
354+ down = True
355+ if up and down:
356+ return None, None
357+ if up:
358+ lo = L
359+ else:
360+ hi = L
361+ L = (lo + hi) * 0.5
362+ return None, None
363+
364+
365+def shape_ok(v):
366+ """The colour counts the record declares are the ones the app renders: distinct bubble stops for a
367+ gradient bubble, distinct wallpaper colours (two equal colours would collapse to a solid ColorDrawable)."""
368+ bub = [v["my_messages_accent"]] + list(v["my_messages_gradient"])
369+ if not (len(v["my_messages_gradient"]) == 1 and bub[0] == bub[1]): # solid bubble: gradient1 == colour by design
370+ if len(set(bub)) != len(bub):
371+ return False
372+ w = v["wallpaper"]["colors"]
373+ return 2 <= len(w) <= 4 and len(set(w)) == len(w)
374+
375+
376+def _reject(stats, why, pairs=()):
377+ """Diagnostics of failed attempts (only read when the generator stalls): stage counts and failing pairs."""
378+ r = stats.setdefault("reject", {})
379+ r["<%s>" % why] = r.get("<%s>" % why, 0) + 1
380+ for i in pairs:
381+ r[R.PAIRS[i]["name"]] = r.get(R.PAIRS[i]["name"], 0) + 1
382+
383+
384+def gen_entry(theme, rng, thr, stats):
385+ """One candidate: up to max_attempts solver attempts; (variant, contrasts) or (None, None)."""
386+ bad_accents = forbidden_accents(theme)
387+ for attempt in range(S.C["max_attempts"]):
388+ d = draw_attempt(rng, theme.is_dark)
389+ accent, aL = S.solve_accent(theme, d, thr, stats)
390+ if accent is None:
391+ # which accent pairs fail at the attempt's starting lightness (diagnostics only; not counted in evals)
392+ lo, hi, (s0, s1) = S.C["accent_L"]["dark" if theme.is_dark else "light"]
393+ acc0 = S.argb_from_oklch(s0 + (s1 - s0) * d["accent_L_u"], d["accent_C"], d["accent_h"])
394+ _, res0 = S.evaluate(theme, S.base_variant(theme, acc0), S.ACCENT_PAIRS)
395+ _reject(stats, "accent stage", S.failing(res0, thr))
396+ continue
397+ if accent in bad_accents:
398+ _reject(stats, "preset accent")
399+ continue
400+ v, bL = S.solve_bubble(theme, d, accent, d["accent_h"], thr, stats)
401+ if v is None:
402+ _reject(stats, "bubble stage")
403+ continue
404+ v, wL = solve_wallpaper(theme, d, v, thr, stats)
405+ if v is None or not shape_ok(v):
406+ _reject(stats, "wallpaper stage" if v is None else "repeated colour")
407+ continue
408+ _, res = S.evaluate(theme, v)
409+ stats["evals"] += 1
410+ f = S.failing(res, thr)
411+ if f:
412+ _reject(stats, "final check", f)
413+ continue
414+ v["solver"] = {"attempt": attempt + 1, "accent_oklch": [aL, d["accent_C"], d["accent_h"]], "bubble_L": bL, "wallpaper_L": wL}
415+ return v, res
416+ return None, None
417+
418+
419+def candidate_seed(theme_name, k):
420+ return "%s/%s/%d" % (TABLE_SEED, theme_name, k)
421+
422+
423+def gen_candidate(m, theme_name, k, thr=None):
424+ th = m.themes[theme_name]
425+ rng = S.SplitMix64(S.seed_state(candidate_seed(theme_name, k)))
426+ stats = {"evals": 0}
427+ v, res = gen_entry(th, rng, thr or headroom_thresholds(th), stats)
428+ return v, res, stats
429+
430+
431+# ------------------------------------------------------------------------------------------
432+# records
433+# ------------------------------------------------------------------------------------------
434+
435+def u32(c):
436+ return c & 0xFFFFFFFF
437+
438+
439+def encode_record(v):
440+ grad = list(v["my_messages_gradient"]) + [0] * (3 - len(v["my_messages_gradient"]))
441+ w = v["wallpaper"]
442+ if w.get("pattern_slug") or w.get("pattern_intensity"):
443+ raise ValueError("table entries have no wallpaper pattern")
444+ wc = list(w["colors"]) + [0] * (4 - len(w["colors"]))
445+ flags = (REC_ANIMATED if v["my_messages_animated"] else 0) | (REC_MOTION if w["motion"] else 0)
446+ return RECORD.pack(u32(v["accent"]), u32(v["my_messages_accent"]), *[u32(c) for c in grad], *[u32(c) for c in wc],
447+ ROTATIONS[ROTATIONS.index(w["rotation"])], flags, 0)
448+
449+
450+def decode_record(b, theme_name):
451+ """Record bytes -> simulator variant (what the app assigns to its runtime ThemeAccent, see README.md)."""
452+ f = RECORD.unpack(b)
453+ grad = [E.s32(c) for c in f[2:5] if c != 0]
454+ wc = [E.s32(c) for c in f[5:9] if c != 0]
455+ return {"base_theme": theme_name, "accent": E.s32(f[0]), "my_messages_accent": E.s32(f[1]), "my_messages_gradient": grad,
456+ "my_messages_animated": bool(f[10] & REC_ANIMATED),
457+ "wallpaper": {"source": "custom", "colors": wc, "rotation": f[9], "pattern_intensity": 0.0, "pattern_slug": "",
458+ "motion": bool(f[10] & REC_MOTION)}}
459+
460+
461+def record_problems(b):
462+ """Format rules of one 40-byte record (README.md); [] when it is well-formed."""
463+ f = RECORD.unpack(b)
464+ out = []
465+ for i, c in enumerate(f[:9]):
466+ if c != 0 and (c >> 24) != 0xFF:
467+ out.append("%s 0x%08X is not opaque" % (COLOUR_FIELDS[i], c))
468+ for i in (0, 1, 2, 5, 6):
469+ if f[i] == 0:
470+ out.append("%s is 0 but required" % COLOUR_FIELDS[i])
471+ if f[4] != 0 and f[3] == 0:
472+ out.append("myMessagesGradientAccentColor3 set without myMessagesGradientAccentColor2")
473+ if f[8] != 0 and f[7] == 0:
474+ out.append("wallpaperColor4 set without wallpaperColor3")
475+ if f[9] not in ROTATIONS:
476+ out.append("rotation %d is not a multiple of 45 in [0, 315]" % f[9])
477+ if f[10] & ~(REC_ANIMATED | REC_MOTION):
478+ out.append("unknown flag bits 0x%02X" % f[10])
479+ if (f[10] & REC_ANIMATED) and f[3] == 0:
480+ out.append("animated bubble with fewer than 3 colours")
481+ if f[11] != 0:
482+ out.append("reserved byte is 0x%02X" % f[11])
483+ bub = [f[1], f[2]] + [c for c in f[3:5] if c]
484+ if (len(bub) > 2 or f[2] != f[1]) and len(set(bub)) != len(bub):
485+ out.append("repeated bubble gradient colour")
486+ wc = [c for c in f[5:9] if c]
487+ if len(set(wc)) != len(wc):
488+ out.append("repeated wallpaper colour")
489+ return out
490+
491+
492+def build_asset(entries_by_theme, themes):
493+ """entries_by_theme: {theme name: [record bytes]}; themes: [(name, is_dark)] in directory order."""
494+ dir_bytes = b""
495+ rec_bytes = b""
496+ first = 0
497+ for name, dark in themes:
498+ key = name.encode("utf-8")
499+ if not 0 < len(key) < THEME_KEY_BYTES:
500+ raise ValueError("theme key %r does not fit the %d-byte field" % (name, THEME_KEY_BYTES))
501+ recs = entries_by_theme[name]
502+ dir_bytes += DIR_ENTRY.pack(key, THEME_DARK if dark else 0, 0, 0, first, len(recs))
503+ rec_bytes += b"".join(recs)
504+ first += len(recs)
505+ body = dir_bytes + rec_bytes
506+ head = HEADER.pack(MAGIC, VERSION, HEADER.size, len(themes), DIR_ENTRY.size, RECORD.size, 0, first, zlib.crc32(body) & 0xFFFFFFFF)
507+ return head + body
508+
509+
510+class FormatError(ValueError):
511+ pass
512+
513+
514+def parse_asset(data):
515+ """-> (header dict, [directory dicts], [record bytes]); raises FormatError on any layout violation."""
516+ if len(data) < HEADER.size:
517+ raise FormatError("file shorter than the %d-byte header" % HEADER.size)
518+ magic, version, hsize, tcount, dsize, rsize, reserved, rcount, crc = HEADER.unpack_from(data, 0)
519+ hdr = {"magic": magic, "version": version, "header_size": hsize, "theme_count": tcount, "dir_entry_size": dsize,
520+ "record_size": rsize, "reserved": reserved, "record_count": rcount, "crc32": crc}
521+ if magic != MAGIC:
522+ raise FormatError("bad magic %r" % magic)
523+ if version != VERSION:
524+ raise FormatError("version %d, expected %d" % (version, VERSION))
525+ if (hsize, dsize, rsize) != (HEADER.size, DIR_ENTRY.size, RECORD.size):
526+ raise FormatError("sizes header/dir/record = %d/%d/%d, expected %d/%d/%d" % (hsize, dsize, rsize, HEADER.size, DIR_ENTRY.size, RECORD.size))
527+ if reserved != 0:
528+ raise FormatError("reserved header field is %d" % reserved)
529+ if tcount == 0:
530+ raise FormatError("no base themes")
531+ expect = hsize + tcount * dsize + rcount * rsize
532+ if len(data) != expect:
533+ raise FormatError("file is %d bytes, header implies %d" % (len(data), expect))
534+ if zlib.crc32(data[hsize:]) & 0xFFFFFFFF != crc:
535+ raise FormatError("CRC-32 mismatch")
536+ dirs = []
537+ nxt = 0
538+ for t in range(tcount):
539+ raw, flags, r1, r2, first, count = DIR_ENTRY.unpack_from(data, hsize + t * dsize)
540+ name_b = raw.rstrip(b"\0")
541+ if b"\0" in name_b or not name_b or len(name_b) == THEME_KEY_BYTES:
542+ raise FormatError("theme %d: key is empty, unterminated or not NUL-padded" % t)
543+ try:
544+ name = name_b.decode("utf-8")
545+ except UnicodeDecodeError:
546+ raise FormatError("theme %d: key is not UTF-8" % t)
547+ if flags & ~THEME_DARK or r1 or r2:
548+ raise FormatError("theme %s: unknown flag bits or non-zero reserved fields" % name)
549+ if first != nxt or count == 0:
550+ raise FormatError("theme %s: records [%d, +%d) are not contiguous after the previous theme" % (name, first, count))
551+ nxt = first + count
552+ dirs.append({"name": name, "dark": bool(flags & THEME_DARK), "first": first, "count": count})
553+ if nxt != rcount:
554+ raise FormatError("directory covers %d records, header says %d" % (nxt, rcount))
555+ if len(set(d["name"] for d in dirs)) != len(dirs):
556+ raise FormatError("duplicate theme key in the directory")
557+ base = hsize + tcount * dsize
558+ recs = [data[base + i * rsize: base + (i + 1) * rsize] for i in range(rcount)]
559+ return hdr, dirs, recs
560+
561+
562+# ------------------------------------------------------------------------------------------
563+# evaluation
564+# ------------------------------------------------------------------------------------------
565+
566+def evaluate(m, v, lazy=True):
567+ """All 99 pair contrasts of a variant on model m, as a list in R.PAIRS order. lazy=False runs the full
568+ refreshThemeColors port instead of the per-key replay (same result; used as a cross-check)."""
569+ th = m.themes[v["base_theme"]]
570+ acc = S.to_accent(v)
571+ ev = R.Evaluator(E.palette(th, acc, S.GEN_ACCENT_ID, lazy=lazy))
572+ res = ev.all()
573+ return [res[i] for i in range(len(R.PAIRS))]
574+
575+
576+def margins(res):
577+ """Per threshold class: (min contrast / WCAG threshold, pair name, contrast)."""
578+ out = {}
579+ for i, p in enumerate(R.PAIRS):
580+ t = p["threshold"]
581+ r = res[i] / t
582+ cur = out.get(t)
583+ if cur is None or r < cur[0]:
584+ out[t] = (r, p["name"], res[i])
585+ return out