DotScramble 1.5 rebuilt: one token-based QSS file for 16 themes, a canvas with Detect preview and Compare, five new languages with CLDR plurals, a privacy tool that refuses to fail silently, and four packaging bugs my own CI never saw.
Part 3 ended with the PySide6 migration and the design of AI Evasion. Since then DotScramble went through its biggest update yet: 1.5. Four things changed:
- The whole interface was rebuilt, and every color now comes from one stylesheet.
- It went from two languages to seven, with plural forms that are actually correct.
- A batch of fixes, led by one rule: a privacy tool must never fail silently.
- Releases are now built, tested and signed by GitHub Actions, and a clean machine found four bugs my own CI had been hiding.
If you haven't seen what DotScramble does, the tour covers it in five minutes.
1. The UI rebuild
The 1.4 window had grown by accretion: a header, then a toolbar, then buttons that did the same thing as the toolbar, long radio lists, emoji as icons, and colors set in Python all over the place. Changing a theme meant hoping every widget had been told.
1.5 has a single toolbar, a sidebar in four numbered steps (What to hide → How to hide it → Fine-tune → Metadata on save), effect tiles instead of radio buttons, one primary Apply effect button, and SVG icons that recolor with the theme.
One stylesheet, sixteen themes
The rule I set: widgets never set a color or a font in Python. All styling lives in one file, assets/themes/theme_template.qss, written with tokens:
/* {key} is a theme color, {key@NN} the same color at NN % opacity, {on:key} a readable text color on top of it */ QPushButton[variant="primary"] { background-color: {accent_cyan}; border: 1px solid {accent_cyan}; color: {on:accent_cyan}; font-weight: 700; } QPushButton[variant="primary"]:hover { background-color: {accent_cyan@85}; } The theme manager fills the tokens from the active theme in themes.json with one regex:
_TOKEN_RE = re.compile(r"\{(on:)?([a-z_]+)(?:@(\d{1,3}))?\}") def _resolve_token(self, colors, match): on, key, alpha = match.groups() value = colors[key] if on: # pick white or near-black, whichever reads better on this color white = self._calculate_contrast_ratio(value, '#ffffff') black = self._calculate_contrast_ratio(value, '#000000') return '#ffffff' if white >= black else '#111111' if alpha is not None: r, g, b = self._hex_to_rgb(value) return f"rgba({r}, {g}, {b}, {min(int(alpha), 100) / 100:.2f})" return value Widgets only expose hooks: an objectName for unique pieces (#Toolbar, #Sidebar), a variant property for button flavours, a role for text styles. {on:key} turned out to be the most useful token: text on an accent-colored button is readable in all 16 themes without a single per-theme override. The image picker, the metadata dialogs and the batch window, which used to carry their own inline styles, now use the same file, so dialogs finally follow the theme too.
A canvas you can trust
The old preview was a scaled label. The new canvas has mouse-wheel zoom around the cursor, pan, fit / 100 %, and two features that change how the app feels:
- Detect preview (
Ctrl+F) outlines every region before anything is touched. Clicking one toggles it:
index = self._detection_at(pos) if index is not None: self._skipped ^= {index} # click again to include it back self.detectionsChanged.emit() When you press Apply, only the regions still outlined are hidden. In a group photo, you decide who stays visible.
- Compare (
Ctrl+Shift+C) draws the original on one side of a draggable divider and the result on the other, by clipping the "before" image to the divider's position.
Snapping to half the screen
A small bug with a lesson. On GNOME, dragging the window to the screen edge did nothing. The window refused to tile because its minimum width was 1,095 px: the toolbar with all its labels was that wide, and Qt adds up minimum sizes. Half of a 1920 px screen is 960.
The fix has two parts. The toolbar no longer reports a minimum width (QSizePolicy.Policy.Ignored), and an event filter switches it to icon-only buttons when there isn't room for the labels:
self.toolbar.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) def _set_toolbar_compact(self, compact): style = (Qt.ToolButtonStyle.ToolButtonIconOnly if compact else Qt.ToolButtonStyle.ToolButtonTextBesideIcon) for button in self._labelled_tools: button.setToolButtonStyle(style) The window's minimum is now 640 × 480, and it snaps like any other app.
2. Seven languages, and plurals that are actually right
1.5 adds Persian (right to left), Spanish, Russian, German and Turkish to English and Arabic, and starts in your system language when it's one of them. Every screen is translated, including the factory metadata presets. Those got stable IDs (strip_all, strip_gps…) and translated display names, so presets users saved under their own names keep working.
The interesting part was counting. My first translations dodged numbers ("Processed: 3") because "3 files" is easy in English and wrong almost everywhere else:
- Arabic has six plural forms: ملف واحد / ملفين / 3 ملفات / 11 ملفًا…
- Russian has three: 1 файл / 3 файла / 5 файлов, and 21 is "one" again.
- Persian uses the same form for 0 and 1.
So a counted string in the locale files is now a dict of CLDR plural forms, and get(key, count=n) picks one:
"detected": { "one": "{shown} of {count} region will be hidden — click it to skip it", "other": "{shown} of {count} regions will be hidden — click one to skip it" } The rules are small functions, straight from CLDR:
def _plural_ar(n: int) -> str: if n == 0: return "zero" if n == 1: return "one" if n == 2: return "two" if 3 <= n % 100 <= 10: return "few" if 11 <= n % 100 <= 99: return "many" return "other" def _plural_ru(n: int) -> str: if n % 10 == 1 and n % 100 != 11: return "one" if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14: return "few" return "many" One subtlety: in "3 of 4 regions", the noun agrees with the total, not with the number shown, so the total has to pick the form. 105 tests now pin the rules and every counted string in every language.
3. A privacy tool must never fail silently
This one scared me. Updating dependencies pulled in OpenCV 5, which no longer ships the Haar cascade files that free-tier face detection loads. The detectors returned an empty list, which looks exactly like "no faces found". The result: "0 regions processed", a success message, and the face still visible in the saved photo.
For most apps, "found nothing" is a fine default. For an app whose whole job is hiding faces, it's the worst possible outcome. Now the engine can tell the two apart:
@staticmethod def missing_cascades(mode: str) -> list[str]: """Cascade files `mode` needs but that can't be loaded. The detect_* methods return [] in that case, which is indistinguishable from "nothing found": callers must check this first so a face is never left visible silently.""" return [f for f in HAAR_CASCADES.get(mode, []) if _get_cascade(f) is None] The window shows an error before detecting, batch processing marks the file as failed instead of "done", and requirements.txt pins opencv-contrib-python<5 with a comment explaining why.
The same principle fixed a few quieter bugs:
- Live preview stacked the effect on itself. Each slider move blurred the already-blurred image, so the preview looked stronger than what you'd save. It now always re-applies to the original.
- Redo worked only once in a row: redo saved its result as a new history step, and saving a step clears the redo stack. Redo now saves with
clear_redo=False. - A startup error crashed the app (a core dump, because the error dialog was built before
QApplicationexisted) instead of telling you what was wrong.
Activation: "Failed to fetch"
When the DotSuite dashboard moved to dotsuite.dev, Pro activation broke with a bare "Failed to fetch" in the browser. Activation works through a small local HTTP server the app runs on 127.0.0.1: the website sends the licence key to it. Two things had changed underneath it. The new origin wasn't in the server's CORS allow-list, and Chrome's Private Network Access now sends a preflight before a public site may talk to localhost, which the server has to answer explicitly:
if (origin in ALLOWED_ORIGINS and self.headers.get('Access-Control-Request-Private-Network', '').lower() == 'true'): self.send_header('Access-Control-Allow-Private-Network', 'true') The second half of the bug was a 404 from a licence server that had moved. Two behaviour changes came with the fix: a rejected activation now shows the server's reason instead of a status code, and a Pro licence is no longer removed when the server simply can't be reached. Only an explicit rejection revokes it.
4. Shipping it: four bugs only a clean machine found
Until 1.4, releases were built on my laptop. That's how 1.4.1 ended up needing a much newer glibc than most users have: PyInstaller binaries need a glibc at least as new as the machine that built them.
In 1.5, pushing a tag runs one workflow on Ubuntu 22.04 (glibc 2.35): tests → build the AppImage, .deb, Arch package and tarball → inspect the binaries → smoke-test the AppImage → write SHA256SUMS → GPG-sign every file → publish. The built-in updater installs an update only after the checksum and the signature check out against a pinned key fingerprint. And version_policy.json in the repo lets an old, broken version show an "Update required" screen with a download button. It never blocks offline use.
That pipeline was green from day one. Then real machines found four bugs.
Bug 1: Tesseract crashed, but only in the packages
Text and single-word detection said "Tesseract not found" in the AppImage and the .deb, yet tesseract was installed. Running it with the bundle's library path showed why:
tesseract: /tmp/_MEI…/libstdc++.so.6: version `GLIBCXX_3.4.32' not found PyInstaller's bootloader points LD_LIBRARY_PATH at the bundle so the app finds its own libraries. Every program the app starts inherits that, so the system Tesseract loaded the bundle's older libstdc++ from Ubuntu 22.04 and died. The dynamic loader reads the variable once at startup, so the app can safely put the user's value back for its children:
def restore_child_library_path(environ=None, frozen=None): environ = os.environ if environ is None else environ frozen = getattr(sys, 'frozen', False) if frozen is None else frozen if not frozen or not sys.platform.startswith('linux'): return original = environ.get("LD_LIBRARY_PATH_ORIG") # saved by the bootloader if original is not None: environ["LD_LIBRARY_PATH"] = original else: environ.pop("LD_LIBRARY_PATH", None) That was 1.5.1.
Bug 2: the AppImage couldn't open a window
I submitted DotScramble to AppImageHub, whose test runs your AppImage on a clean Ubuntu 22.04 and takes a screenshot. It failed:
Cannot load library …/platforms/libqxcb.so: libxkbcommon-x11.so.0: cannot open shared object file This application failed to start because no Qt platform plugin could be initialized. Qt's X11 plugin needs five libraries (libxkbcommon-x11, libxcb-icccm, libxcb-keysyms, libxcb-shape, libxcb-xkb) that PyInstaller only bundles if they're installed on the build machine. They weren't, PyInstaller skipped them with a warning nobody read, and my smoke test passed because it ran Qt with the offscreen platform, which never loads the X11 plugin. On my desktop, those libraries happen to be installed, so I never saw it.
I reproduced their environment in Docker before changing anything: same failure, then Application initialized successfully once those five libraries were present. The fix was to install them on the builder. The real change was in the workflow: it now lists every library the X11 plugin links (readelf -d) and fails if one isn't in the binary, and it runs the AppImage on X11 in a clean ubuntu:22.04 container before publishing. Both checks failed on the old build and passed on 1.5.4, and AppImageHub's test turned green.
Bug 3: the check that almost broke the AppImage
To verify the AppImage's embedded update information (so AppImageUpdate and Gear Lever can update it), my first draft of the check read the ELF section with:
objcopy --dump-section .upd_info=/dev/stdout DotScramble-Linux-x86_64.AppImage With no output file, objcopy rewrites the input in place, and it drops everything after the ELF part, which in an AppImage is the entire SquashFS image. I only noticed because the next step, extracting the AppImage, failed locally with "This doesn't look like a squashfs image". In CI it would have published a broken file. readelf --string-dump=.upd_info only reads.
Bug 4: right metadata, wrong file name
AppImageHub builds its listing from the AppStream file inside the AppImage. I shipped it under the current AppStream name, ….metainfo.xml. Their scripts only read *.appdata.xml for the summary and description, so the listing would have been blank. Same content, different name: 1.5.3.
Bugs I Hit and What They Actually Were
| Symptom | Actual cause |
|---|---|
| Window won't snap to half the screen | toolbar's labels made the minimum width 1,095 px |
| "0 regions processed", face still visible | OpenCV 5 dropped the Haar cascade files; empty list looked like "no faces" |
| Preview stronger than the saved image | live preview re-applied the effect to its own output |
| "Failed to fetch" on activation | new origin missing from CORS, plus Chrome's Private Network Access preflight |
| "Tesseract not found" in packages only | children inherited the bundle's LD_LIBRARY_PATH and its older libstdc++ |
| AppImage quits on a clean system | five X11 libraries missing from the build; smoke test ran offscreen |
| AppImage corrupted by its own check | objcopy without an output file rewrites the input |
Honest Limitations
- Still Linux x86_64 only, glibc 2.35+. No Windows or macOS builds yet.
- The five new translations are new. They follow CLDR, but a native speaker will find phrasing to fix, and corrections are very welcome.
- The theme system covers Qt widgets. The canvas overlays read colors through
qproperty-*hooks, so a theme can't yet change, say, the detection outline's thickness. - AI Evasion works as before: slow on the CPU, and tested against MediaPipe only.
Key Takeaways
- One stylesheet with tokens beats colors scattered across Python.
{on:key}alone removed a whole class of unreadable-button bugs. - Plurals are data, not string formatting. Six Arabic forms don't fit in an f-string.
- In a privacy tool, "found nothing" and "couldn't look" must never look the same.
- Your CI is a machine you configured. Test on one you didn't: a clean container found two bugs that every green CI run had missed.
- Read the tool's man page before pointing it at your release artifact.
DotScramble 1.5.4 is out: AppImage, .deb, Arch package and tarball, all GPG-signed.
- GitHub: kareem2099/DotScramble
- Download: latest release
- Also on: AppImageHub · OpenDesktop
- Project page: dotsuite.dev/en/product/dotscramble




