|
| 1 | +# Color theme |
| 2 | + |
| 3 | +labelme exposes a `color_theme` Setting with three values — `system` (default), |
| 4 | +`light`, and `dark` — surfaced at the top of the "General" section of the Settings |
| 5 | +dialog as an enum control labelled "System default" / "Light" / "Dark" (mirroring |
| 6 | +the language picker's "System default"). It is applied by calling |
| 7 | +`QStyleHints.setColorScheme(...)`, which lets the pinned Fusion style generate the |
| 8 | +matching palette itself (`Unknown` follows the OS, `Light`/`Dark` force a scheme |
| 9 | +regardless of the OS). This replaces the previous unconditional force-light |
| 10 | +override in `__main__.py`. |
| 11 | + |
| 12 | +Qt swaps the palette, but cached `QIcon` pixmaps do not follow it. The monochrome |
| 13 | +toolbar icons are therefore authored as `fill="currentColor"` and tinted to the |
| 14 | +active palette's `WindowText` color by a `QIconEngine` that reads the palette at |
| 15 | +paint time, so they re-tint live when `colorSchemeChanged` fires (the single |
| 16 | +re-theme hook for both user changes and OS flips). Selection is by content, not an |
| 17 | +allowlist: `new_icon` tints any SVG containing the `currentColor` token, so a new |
| 18 | +monochrome icon authored with `fill="currentColor"` is theme-aware automatically. |
| 19 | +The five semantic accent icons (blue `floppy-disk`/`folders`/`folder-open`, red |
| 20 | +`trash`/`file-x`) keep their fixed colors, since their color carries meaning and is |
| 21 | +legible on both backgrounds; the other 26 are tinted. |
| 22 | + |
| 23 | +Theming is scoped to *chrome*, not annotation *data*. Chrome colors must follow |
| 24 | +the palette: the toolbar icons, the AI-button highlight (previously a hardcoded |
| 25 | +`#FFFFCC`/`#E6E6A0` in `_app.py`), and the canvas crosshair (previously a |
| 26 | +hardcoded `QColor(0, 0, 0)` in `canvas.py`, which is invisible against the dark |
| 27 | +margin in out-of-bounds mode). Annotation colors drawn over the image — the shape |
| 28 | +line/fill colors and white vertex fills in `_shape_render.py` — are data, not |
| 29 | +chrome, and stay fixed across themes. Adding a chrome color must go through a |
| 30 | +palette role, not a literal; the audit is a sweep of `_widgets/` and `_app.py` |
| 31 | +for color literals, triaged chrome-vs-data, not a single fix. |
| 32 | + |
| 33 | +## Considered options |
| 34 | + |
| 35 | +- **App-owned light/dark `QPalette`s** — rejected: Qt 6.8's `setColorScheme` |
| 36 | + makes Fusion produce good light and dark palettes for free, so hand-maintaining |
| 37 | + ~12 role colors per theme is redundant. The cost is that "System" dark adopts |
| 38 | + the platform palette and so varies slightly per OS; this is an acceptable trade |
| 39 | + for not owning palette tables. |
| 40 | +- **Two icon sets (separate light/dark SVGs)** — rejected: doubles the icon |
| 41 | + assets and every future icon, and does not scale to a third theme. Tinting one |
| 42 | + monochrome source (the template-image pattern) is the standard approach. |
| 43 | +- **Restart-required theme change** — rejected: it would be the only Setting that |
| 44 | + is not immediate-apply (see ADR-0001) and would make "follow system" sample the |
| 45 | + OS only at launch instead of tracking `colorSchemeChanged` live. |
| 46 | +- **A Qt theming library (`qdarktheme`/PyQtDarkTheme, `qdarkstyle`, `qt-material`)** |
| 47 | + — rejected: these are the pre-6.8 way to get dark Qt, each shipping a full QSS |
| 48 | + stylesheet that would fight the pinned Fusion style. `setColorScheme` makes the |
| 49 | + native style produce both palettes with no dependency and no stylesheet to |
| 50 | + maintain, so the library's core value no longer applies. (Recorded here so the |
| 51 | + dependency is not reintroduced later as an apparent simplification.) |
| 52 | + |
| 53 | +## Consequences |
| 54 | + |
| 55 | +- The PySide6 floor moves from `>=6.5` to `>=6.8`, since `setColorScheme` and |
| 56 | + `Qt.ColorScheme` are 6.8 APIs. |
| 57 | +- The default changes observable behavior on upgrade: users on a dark-themed OS, |
| 58 | + who were previously forced to light, now get dark labelme. With the icon tinting |
| 59 | + in place this is the intended result, not a regression. |
| 60 | +- New icons must be authored with `fill="currentColor"` to participate in |
| 61 | + theming; an icon shipped with a hardcoded dark fill will be invisible on a dark |
| 62 | + background. |
| 63 | +- `colorSchemeChanged` is the one place the running app re-themes. Two things do |
| 64 | + not follow Qt's live palette swap on their own and the handler fixes both: |
| 65 | + cached `QIcon` pixmaps (cleared via `QPixmapCache`), and any widget carrying a |
| 66 | + stylesheet — `QStyleSheetStyle` resolves `palette(...)` references and pins the |
| 67 | + widget's palette at polish time, so a styled toolbar (the vertical toolbars use |
| 68 | + `QToolBar::separator { background: palette(mid) }`) stays stuck on the old |
| 69 | + scheme until its stylesheet is re-applied. The handler re-applies each widget's |
| 70 | + stylesheet and repaints all widgets. (`grab()`-based screenshots cannot catch |
| 71 | + this: grab re-renders the tree fresh, so it always shows the new palette even |
| 72 | + when the on-screen widget never repainted.) |
0 commit comments