diff --git a/README.md b/README.md index a85442b..99ae80b 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,9 @@ greenfix The UI provides: - A connected display output selector. -- Sliders for red gamma, green gamma, blue gamma, and brightness. +- A reference image panel with a color chart and photo reference. +- Large green-tint compensation, warm/cool trim, and brightness controls. +- Advanced red, green, and blue gamma controls for fine tuning. - Apply, Save, Reset, and Quit buttons. - Visible status and error messages. @@ -188,6 +190,10 @@ Use Reset in the UI or run: greenfix --reset ``` +Saved settings show in the UI but do not affect the screen + +Open greenfix again after saving, or run `greenfix --apply-saved`. The UI now applies saved settings on launch; if it cannot, the status area shows the `xrandr` error. + Saved settings do not apply after login Check that `~/.config/autostart/greenfix.desktop` exists and that `greenfix --apply-saved` works from a terminal. diff --git a/docs/superpowers/plans/2026-06-02-green-tint-controls.md b/docs/superpowers/plans/2026-06-02-green-tint-controls.md new file mode 100644 index 0000000..fbcf0a1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-green-tint-controls.md @@ -0,0 +1,1115 @@ +# Green Tint Controls Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the approved dark, accessible green-tint compensation UI and fix saved-settings startup application. + +**Architecture:** Keep `xrandr` as the only display backend and store canonical RGB gamma/brightness values for CLI compatibility. Add a pure `greenfix.compensation` helper for the friendly green-tint controls, extend config with optional UI metadata, and keep GTK-specific layout/rendering in `greenfix.ui`. + +**Tech Stack:** Python 3.10+, `unittest`, GTK 3/PyGObject, `xrandr`, setuptools package data for the generated reference image asset. + +--- + +## File Structure + +- Create `greenfix/compensation.py`: pure helper functions for basic controls to RGB gamma conversion. +- Create `tests/test_compensation.py`: unit tests for the compensation formula and clamping. +- Modify `greenfix/config.py`: optional UI metadata on `Settings`, safe load/save support, and validation helpers. +- Modify `tests/test_config.py`: backward compatibility and metadata round-trip coverage. +- Modify `greenfix/ui.py`: dark WCAG 7:1 styling, large controls, reference image panel, basic/advanced controls, and startup apply behavior. +- Modify `tests/test_ui.py`: pure helper tests for startup apply and theme contrast checks. +- Create `greenfix/assets/reference-photo.png`: generated bitmap reference image. +- Modify `pyproject.toml`: include `greenfix/assets/*.png` in package data. +- Modify `README.md`: document the new green-tint workflow, reference panel, and startup behavior. + +Use `python3 -m unittest ...` for local checks because this environment has no `pip`, no `python3-venv`, and no local `pytest`. + +--- + +### Task 1: Add Pure Green-Tint Compensation Math + +**Files:** +- Create: `greenfix/compensation.py` +- Create: `tests/test_compensation.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_compensation.py`: + +```python +import unittest + +from greenfix import compensation + + +class CompensationTests(unittest.TestCase): + def test_neutral_controls_produce_neutral_gamma(self) -> None: + self.assertEqual( + compensation.compute_rgb_gamma(green_tint_fix=0, warm_cool_trim=0), + (1.0, 1.0, 1.0), + ) + + def test_green_tint_fix_adds_magenta_compensation(self) -> None: + self.assertEqual( + compensation.compute_rgb_gamma(green_tint_fix=100, warm_cool_trim=0), + (1.2, 0.8, 1.2), + ) + + def test_warm_trim_raises_red_and_lowers_blue(self) -> None: + self.assertEqual( + compensation.compute_rgb_gamma(green_tint_fix=0, warm_cool_trim=50), + (1.12, 1.0, 0.88), + ) + + def test_cool_trim_lowers_red_and_raises_blue(self) -> None: + self.assertEqual( + compensation.compute_rgb_gamma(green_tint_fix=0, warm_cool_trim=-50), + (0.88, 1.0, 1.12), + ) + + def test_invalid_basic_control_ranges_are_rejected(self) -> None: + with self.assertRaisesRegex(compensation.CompensationError, "green_tint_fix"): + compensation.compute_rgb_gamma(green_tint_fix=101, warm_cool_trim=0) + with self.assertRaisesRegex(compensation.CompensationError, "warm_cool_trim"): + compensation.compute_rgb_gamma(green_tint_fix=0, warm_cool_trim=-51) + + def test_clamp_keeps_values_inside_xrandr_gamma_limits(self) -> None: + self.assertEqual(compensation.clamp_gamma(0.2), 0.6) + self.assertEqual(compensation.clamp_gamma(2.4), 1.8) + self.assertEqual(compensation.clamp_gamma(1.234), 1.23) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +python3 -m unittest tests.test_compensation -v +``` + +Expected: fail with `ImportError: cannot import name 'compensation' from 'greenfix'` or `ModuleNotFoundError: No module named 'greenfix.compensation'`. + +- [ ] **Step 3: Implement the minimal helper** + +Create `greenfix/compensation.py`: + +```python +"""Friendly display compensation controls for greenfix.""" + +from __future__ import annotations + +from greenfix import xrandr + +GREEN_TINT_FIX_MIN = 0 +GREEN_TINT_FIX_MAX = 100 +WARM_COOL_TRIM_MIN = -50 +WARM_COOL_TRIM_MAX = 50 + + +class CompensationError(ValueError): + """Raised when basic compensation controls are invalid.""" + + +def compute_rgb_gamma(green_tint_fix: int, warm_cool_trim: int) -> tuple[float, float, float]: + validate_basic_controls(green_tint_fix, warm_cool_trim) + strength = green_tint_fix / 100 + trim = warm_cool_trim / 50 + + red_gamma = clamp_gamma(1.0 + (0.20 * strength) + (0.12 * trim)) + green_gamma = clamp_gamma(1.0 - (0.20 * strength)) + blue_gamma = clamp_gamma(1.0 + (0.20 * strength) - (0.12 * trim)) + return red_gamma, green_gamma, blue_gamma + + +def validate_basic_controls(green_tint_fix: int, warm_cool_trim: int) -> None: + if not GREEN_TINT_FIX_MIN <= green_tint_fix <= GREEN_TINT_FIX_MAX: + raise CompensationError( + f"green_tint_fix must be between {GREEN_TINT_FIX_MIN} and {GREEN_TINT_FIX_MAX}." + ) + if not WARM_COOL_TRIM_MIN <= warm_cool_trim <= WARM_COOL_TRIM_MAX: + raise CompensationError( + f"warm_cool_trim must be between {WARM_COOL_TRIM_MIN} and {WARM_COOL_TRIM_MAX}." + ) + + +def clamp_gamma(value: float) -> float: + clamped = min(max(value, xrandr.GAMMA_MIN), xrandr.GAMMA_MAX) + return round(clamped, 2) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +python3 -m unittest tests.test_compensation -v +``` + +Expected: all `CompensationTests` pass. + +- [ ] **Step 5: Commit** + +```bash +git add greenfix/compensation.py tests/test_compensation.py +git commit -m "feat: add green tint compensation math" +``` + +--- + +### Task 2: Extend Settings With Optional UI Metadata + +**Files:** +- Modify: `greenfix/config.py` +- Modify: `tests/test_config.py` + +- [ ] **Step 1: Write failing config tests** + +Append these tests to `ConfigTests` in `tests/test_config.py`: + +```python + def test_settings_with_ui_metadata_round_trip(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "settings.json" + settings = config.Settings( + output="eDP-1", + red_gamma=1.1, + green_gamma=0.92, + blue_gamma=1.12, + brightness=1.0, + ui_mode="basic", + green_tint_fix=42, + warm_cool_trim=8, + ) + + config.save_settings(settings, path) + + self.assertEqual(config.load_settings(path), settings) + self.assertEqual( + json.loads(path.read_text(encoding="utf-8")), + { + "output": "eDP-1", + "red_gamma": 1.1, + "green_gamma": 0.92, + "blue_gamma": 1.12, + "brightness": 1.0, + "ui_mode": "basic", + "green_tint_fix": 42, + "warm_cool_trim": 8, + }, + ) + + def test_load_settings_ignores_invalid_optional_ui_metadata(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "settings.json" + path.write_text( + json.dumps( + { + "output": "eDP-1", + "red_gamma": 1.1, + "green_gamma": 0.92, + "blue_gamma": 1.12, + "brightness": 1.0, + "ui_mode": "expert", + "green_tint_fix": 200, + "warm_cool_trim": "warm", + } + ), + encoding="utf-8", + ) + + self.assertEqual( + config.load_settings(path), + config.Settings("eDP-1", 1.1, 0.92, 1.12, 1.0), + ) + + def test_invalid_settings_ui_metadata_is_rejected_when_constructed_directly(self) -> None: + with self.assertRaisesRegex(config.ConfigError, "ui_mode"): + config.Settings(output="eDP-1", ui_mode="expert") + with self.assertRaisesRegex(config.ConfigError, "green_tint_fix"): + config.Settings(output="eDP-1", green_tint_fix=-1) + with self.assertRaisesRegex(config.ConfigError, "warm_cool_trim"): + config.Settings(output="eDP-1", warm_cool_trim=51) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +python3 -m unittest tests.test_config.ConfigTests.test_settings_with_ui_metadata_round_trip tests.test_config.ConfigTests.test_load_settings_ignores_invalid_optional_ui_metadata tests.test_config.ConfigTests.test_invalid_settings_ui_metadata_is_rejected_when_constructed_directly -v +``` + +Expected: fail because `Settings.__init__()` does not accept `ui_mode`, `green_tint_fix`, or `warm_cool_trim`. + +- [ ] **Step 3: Implement metadata support** + +Update `greenfix/config.py`: + +```python +from dataclasses import dataclass +``` + +Keep `asdict` removed if it is no longer used. Add fields to `Settings`: + +```python + ui_mode: str | None = None + green_tint_fix: int | None = None + warm_cool_trim: int | None = None +``` + +Inside `Settings.__post_init__`, after xrandr validation, add: + +```python + validate_ui_metadata(self.ui_mode, self.green_tint_fix, self.warm_cool_trim) +``` + +Add helper functions in `greenfix/config.py`: + +```python +def validate_ui_metadata( + ui_mode: str | None, + green_tint_fix: int | None, + warm_cool_trim: int | None, +) -> None: + if ui_mode is not None and ui_mode not in {"basic", "advanced"}: + raise ConfigError("ui_mode must be 'basic' or 'advanced'.") + if green_tint_fix is not None and not 0 <= green_tint_fix <= 100: + raise ConfigError("green_tint_fix must be between 0 and 100.") + if warm_cool_trim is not None and not -50 <= warm_cool_trim <= 50: + raise ConfigError("warm_cool_trim must be between -50 and 50.") + + +def _optional_ui_metadata(data: Mapping[str, object]) -> dict[str, object]: + metadata: dict[str, object] = {} + ui_mode = data.get("ui_mode") + green_tint_fix = data.get("green_tint_fix") + warm_cool_trim = data.get("warm_cool_trim") + + if ui_mode in {"basic", "advanced"}: + metadata["ui_mode"] = ui_mode + if isinstance(green_tint_fix, int) and 0 <= green_tint_fix <= 100: + metadata["green_tint_fix"] = green_tint_fix + if isinstance(warm_cool_trim, int) and -50 <= warm_cool_trim <= 50: + metadata["warm_cool_trim"] = warm_cool_trim + return metadata +``` + +Update `load_settings()` so `Settings(...)` receives `**_optional_ui_metadata(data)`. + +Update `save_settings()` to write canonical values plus non-`None` UI metadata: + +```python + data: dict[str, object] = { + "output": settings.output, + "red_gamma": settings.red_gamma, + "green_gamma": settings.green_gamma, + "blue_gamma": settings.blue_gamma, + "brightness": settings.brightness, + } + if settings.ui_mode is not None: + data["ui_mode"] = settings.ui_mode + if settings.green_tint_fix is not None: + data["green_tint_fix"] = settings.green_tint_fix + if settings.warm_cool_trim is not None: + data["warm_cool_trim"] = settings.warm_cool_trim + settings_path.write_text( + json.dumps(data, indent=2, sort_keys=False) + "\n", + encoding="utf-8", + ) +``` + +- [ ] **Step 4: Run config tests** + +Run: + +```bash +python3 -m unittest tests.test_config -v +``` + +Expected: all config tests pass, including existing JSON compatibility tests. + +- [ ] **Step 5: Commit** + +```bash +git add greenfix/config.py tests/test_config.py +git commit -m "feat: persist optional ui metadata" +``` + +--- + +### Task 3: Add Startup Apply Helper And Tests + +**Files:** +- Modify: `greenfix/ui.py` +- Modify: `tests/test_ui.py` + +- [ ] **Step 1: Write failing helper tests** + +Add imports to `tests/test_ui.py`: + +```python +from unittest.mock import Mock + +from greenfix import config, xrandr +``` + +Add tests to `UiTests`: + +```python + def test_startup_apply_skips_when_saved_settings_do_not_match_output(self) -> None: + apply_settings = Mock() + + message = ui.apply_saved_on_startup( + config.Settings("HDMI-1"), + "eDP-1", + apply_settings=apply_settings, + ) + + self.assertIsNone(message) + apply_settings.assert_not_called() + + def test_startup_apply_applies_matching_saved_settings(self) -> None: + apply_settings = Mock() + + message = ui.apply_saved_on_startup( + config.Settings("eDP-1", 1.1, 0.92, 1.12, 1.0), + "eDP-1", + apply_settings=apply_settings, + ) + + self.assertEqual(message, "Applied saved settings for eDP-1.") + apply_settings.assert_called_once_with("eDP-1", 1.1, 0.92, 1.12, 1.0) + + def test_startup_apply_returns_error_message_when_xrandr_fails(self) -> None: + apply_settings = Mock(side_effect=xrandr.XrandrError("xrandr failed")) + + message = ui.apply_saved_on_startup( + config.Settings("eDP-1", 1.1, 0.92, 1.12, 1.0), + "eDP-1", + apply_settings=apply_settings, + ) + + self.assertEqual(message, "xrandr failed") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +python3 -m unittest tests.test_ui.UiTests.test_startup_apply_skips_when_saved_settings_do_not_match_output tests.test_ui.UiTests.test_startup_apply_applies_matching_saved_settings tests.test_ui.UiTests.test_startup_apply_returns_error_message_when_xrandr_fails -v +``` + +Expected: fail with `AttributeError: module 'greenfix.ui' has no attribute 'apply_saved_on_startup'`. + +- [ ] **Step 3: Implement helper and wire it into `GreenfixWindow.__init__`** + +Add to `greenfix/ui.py` near `startup_status_message`: + +```python +def apply_saved_on_startup( + saved_settings: config.Settings | None, + selected_output: str, + *, + apply_settings: Callable[[str, float, float, float, float], None] = xrandr.apply_settings, +) -> str | None: + if saved_settings is None or saved_settings.output != selected_output: + return None + try: + apply_settings( + saved_settings.output, + saved_settings.red_gamma, + saved_settings.green_gamma, + saved_settings.blue_gamma, + saved_settings.brightness, + ) + except xrandr.XrandrError as exc: + return str(exc) + return f"Applied saved settings for {saved_settings.output}." +``` + +In `GreenfixWindow.__init__`, after computing `startup_message`, set startup status in this order: + +```python + startup_message = startup_status_message( + self.outputs, + getattr(self, "_startup_error", None), + ) + if startup_message is None: + startup_message = apply_saved_on_startup(self.saved_settings, selected_output) + if startup_message is not None: + self._set_status(startup_message) +``` + +- [ ] **Step 4: Run UI tests** + +Run: + +```bash +python3 -m unittest tests.test_ui -v +``` + +Expected: all UI tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add greenfix/ui.py tests/test_ui.py +git commit -m "fix: apply saved settings on ui startup" +``` + +--- + +### Task 4: Add Accessible Dark Theme And Reference Constants + +**Files:** +- Modify: `greenfix/ui.py` +- Modify: `tests/test_ui.py` + +- [ ] **Step 1: Write failing contrast/reference tests** + +Add this helper in `tests/test_ui.py` above `UiTests`: + +```python +def contrast_ratio(foreground: str, background: str) -> float: + def channel(value: int) -> float: + normalized = value / 255 + if normalized <= 0.03928: + return normalized / 12.92 + return ((normalized + 0.055) / 1.055) ** 2.4 + + def luminance(color: str) -> float: + color = color.lstrip("#") + red = channel(int(color[0:2], 16)) + green = channel(int(color[2:4], 16)) + blue = channel(int(color[4:6], 16)) + return 0.2126 * red + 0.7152 * green + 0.0722 * blue + + first = luminance(foreground) + second = luminance(background) + lighter = max(first, second) + darker = min(first, second) + return (lighter + 0.05) / (darker + 0.05) +``` + +Add tests to `UiTests`: + +```python + def test_dark_theme_text_pairs_meet_wcag_7_to_1(self) -> None: + failures = [ + (name, contrast_ratio(foreground, background)) + for name, foreground, background in ui.DARK_THEME_TEXT_PAIRS + if contrast_ratio(foreground, background) < 7 + ] + + self.assertEqual(failures, []) + + def test_reference_color_chart_contains_required_labels(self) -> None: + labels = [label for label, _color in ui.REFERENCE_COLOR_SWATCHES] + + self.assertEqual( + labels, + [ + "White", + "Black", + "Gray", + "Skin", + "Red", + "Green", + "Blue", + "Yellow", + "Orange", + "Purple", + "Cyan", + "Magenta", + ], + ) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +python3 -m unittest tests.test_ui.UiTests.test_dark_theme_text_pairs_meet_wcag_7_to_1 tests.test_ui.UiTests.test_reference_color_chart_contains_required_labels -v +``` + +Expected: fail with `AttributeError` for missing `DARK_THEME_TEXT_PAIRS` and `REFERENCE_COLOR_SWATCHES`. + +- [ ] **Step 3: Add theme/reference constants and CSS loader** + +In `greenfix/ui.py`, import `Path`: + +```python +from pathlib import Path +``` + +Extend GTK imports to include `Gdk`: + +```python + from gi.repository import Gdk, GLib, Gtk +``` + +In the GTK import exception branch, set `Gdk = None` alongside `GLib = None` and `Gtk = None`. + +Add constants near `SLIDER_DEBOUNCE_MS`: + +```python +ASSET_DIR = Path(__file__).resolve().parent / "assets" +REFERENCE_PHOTO_PATH = ASSET_DIR / "reference-photo.png" + +DARK_THEME_TEXT_PAIRS = [ + ("body", "#f2f5f2", "#0f1215"), + ("window", "#f2f5f2", "#20252a"), + ("panel", "#f2f5f2", "#262c32"), + ("titlebar secondary", "#cad2d9", "#1a1f23"), + ("muted on panel", "#cad2d9", "#20252a"), + ("button", "#ffffff", "#3a444e"), + ("save button", "#ffffff", "#1f5f42"), + ("status", "#d7e5da", "#17221d"), + ("swatch label", "#f7fbf8", "#111315"), +] + +REFERENCE_COLOR_SWATCHES = [ + ("White", "#ffffff"), + ("Black", "#000000"), + ("Gray", "#808080"), + ("Skin", "#e8d1b6"), + ("Red", "#d93432"), + ("Green", "#1f8d4d"), + ("Blue", "#245bb8"), + ("Yellow", "#f4d03f"), + ("Orange", "#f47c28"), + ("Purple", "#7c3fa0"), + ("Cyan", "#29b7b7"), + ("Magenta", "#c23882"), +] + +DARK_THEME_CSS = b""" +window.greenfix-window { + background: #20252a; + color: #f2f5f2; +} +.greenfix-root { + background: #20252a; + color: #f2f5f2; +} +.greenfix-panel { + background: #262c32; + border: 1px solid #46505a; + border-radius: 6px; +} +.greenfix-card { + background: #1c2227; + border: 1px solid #46505a; + border-radius: 6px; +} +.greenfix-label-muted { + color: #cad2d9; + font-weight: 700; +} +.greenfix-primary-button { + min-height: 48px; + background: #3a444e; + color: #ffffff; + border-radius: 6px; + font-weight: 700; +} +.greenfix-save-button { + min-height: 48px; + background: #1f5f42; + color: #ffffff; + border-radius: 6px; + font-weight: 700; +} +.greenfix-switch-active { + background: #183328; + color: #f2f5f2; + border-color: #6fc18f; +} +.greenfix-status { + background: #17221d; + color: #d7e5da; + border: 1px solid #46505a; + border-radius: 6px; + padding: 8px; +} +.greenfix-swatch-label { + background: #111315; + color: #f7fbf8; + border-radius: 4px; + padding: 4px 6px; +} +""" +``` + +Add CSS helper: + +```python +def apply_dark_theme() -> None: + if Gtk is None or Gdk is None: + return + screen = Gdk.Screen.get_default() + if screen is None: + return + provider = Gtk.CssProvider() + provider.load_from_data(DARK_THEME_CSS) + Gtk.StyleContext.add_provider_for_screen( + screen, + provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) +``` + +Call `apply_dark_theme()` at the top of `GreenfixWindow.__init__` after the GTK guard and before `super().__init__`. + +After `super().__init__(title="greenfix")`, add: + +```python + self.get_style_context().add_class("greenfix-window") +``` + +- [ ] **Step 4: Run UI tests** + +Run: + +```bash +python3 -m unittest tests.test_ui -v +``` + +Expected: all UI tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add greenfix/ui.py tests/test_ui.py +git commit -m "feat: add accessible dark ui theme constants" +``` + +--- + +### Task 5: Generate And Package Reference Photo Asset + +**Files:** +- Create: `greenfix/assets/reference-photo.png` +- Modify: `pyproject.toml` + +- [ ] **Step 1: Generate the reference bitmap asset** + +Create the asset directory and generate a deterministic PNG using only the Python standard library: + +```bash +mkdir -p greenfix/assets +python3 - <<'PY' +import struct +import zlib +from pathlib import Path + +width, height = 512, 320 +pixels = bytearray() + +def lerp(a: int, b: int, t: float) -> int: + return round(a + ((b - a) * t)) + +def color_at(x: int, y: int) -> tuple[int, int, int]: + if y > 230: + strip = [ + (17, 19, 21), + (243, 238, 228), + (183, 93, 77), + (45, 111, 162), + (224, 181, 69), + (94, 63, 136), + (45, 125, 81), + (249, 249, 249), + ] + return strip[min(x * len(strip) // width, len(strip) - 1)] + if 112 < x < 196 and 86 < y < 182: + return (188, 126, 92) + if 292 < x < 388 and 78 < y < 184: + return (211, 158, 104) + if y < 138: + t = y / 138 + return (lerp(105, 159, t), lerp(152, 190, t), lerp(187, 212, t)) + if y < 190: + t = (y - 138) / 52 + return (lerp(191, 220, t), lerp(184, 212, t), lerp(168, 196, t)) + t = (y - 190) / 40 + return (lerp(73, 93, t), lerp(112, 134, t), lerp(70, 83, t)) + +for y in range(height): + row = bytearray([0]) + for x in range(width): + row.extend(color_at(x, y)) + pixels.extend(row) + +def chunk(kind: bytes, data: bytes) -> bytes: + payload = kind + data + return struct.pack(">I", len(data)) + payload + struct.pack(">I", zlib.crc32(payload) & 0xFFFFFFFF) + +png = ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(bytes(pixels), 9)) + + chunk(b"IEND", b"") +) +Path("greenfix/assets/reference-photo.png").write_bytes(png) +PY +``` + +The generated image is `512x320`, includes sky, foliage, skin-like tones, and a color strip, and avoids text labels so there is no contrast obligation inside the bitmap itself. + +- [ ] **Step 2: Add package-data configuration** + +Add this to `pyproject.toml` below `[tool.setuptools.packages.find]`: + +```toml +[tool.setuptools.package-data] +greenfix = ["assets/*.png"] +``` + +- [ ] **Step 3: Verify asset exists and is tracked** + +Run: + +```bash +test -s greenfix/assets/reference-photo.png +git status --short greenfix/assets/reference-photo.png pyproject.toml +``` + +Expected: both paths show as uncommitted additions/modifications. + +- [ ] **Step 4: Commit** + +```bash +git add greenfix/assets/reference-photo.png pyproject.toml +git commit -m "feat: add bundled reference photo asset" +``` + +--- + +### Task 6: Rebuild GTK Window Around Reference And Basic Controls + +**Files:** +- Modify: `greenfix/ui.py` + +- [ ] **Step 1: Add basic-control state in `GreenfixWindow.__init__`** + +After `initial_settings = self._initial_settings(selected_output)`, set: + +```python + self._updating_controls = False + self.ui_mode = initial_settings.ui_mode or "advanced" + self.green_tint_fix = initial_settings.green_tint_fix or 0 + self.warm_cool_trim = initial_settings.warm_cool_trim or 0 +``` + +- [ ] **Step 2: Add basic scales** + +Update `_create_scale` signature to accept a custom callback: + +```python + def _create_scale( + self, + value: float, + minimum: float, + maximum: float, + *, + digits: int = 2, + handler: Callable | None = None, + ): +``` + +Inside `_create_scale`, call `scale.set_digits(digits)` and connect the handler: + +```python + scale.connect("value-changed", handler or self._on_scale_changed, value_label) +``` + +Create these scales before the existing RGB gamma scales: + +```python + self.green_tint_scale, self.green_tint_value = self._create_scale( + float(self.green_tint_fix), + 0, + 100, + digits=0, + handler=self._on_basic_scale_changed, + ) + self.warm_cool_scale, self.warm_cool_value = self._create_scale( + float(self.warm_cool_trim), + -50, + 50, + digits=0, + handler=self._on_basic_scale_changed, + ) +``` + +- [ ] **Step 3: Add reference panel helpers** + +Add methods: + +```python + def _reference_panel(self): + panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + panel.get_style_context().add_class("greenfix-panel") + + switch_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self.color_chart_button = Gtk.ToggleButton(label="Color chart") + self.photo_button = Gtk.ToggleButton(label="Photo") + self.color_chart_button.set_active(True) + self.color_chart_button.connect("toggled", self._on_reference_toggled, "chart") + self.photo_button.connect("toggled", self._on_reference_toggled, "photo") + switch_row.pack_start(self.color_chart_button, True, True, 0) + switch_row.pack_start(self.photo_button, True, True, 0) + + self.reference_stack = Gtk.Stack() + self.reference_stack.add_named(self._color_chart(), "chart") + self.reference_stack.add_named(self._photo_reference(), "photo") + self.reference_stack.set_visible_child_name("chart") + + panel.pack_start(switch_row, False, False, 0) + panel.pack_start(self.reference_stack, True, True, 0) + return panel + + def _color_chart(self): + grid = Gtk.Grid() + grid.set_row_spacing(0) + grid.set_column_spacing(0) + for index, (label_text, color) in enumerate(REFERENCE_COLOR_SWATCHES): + event_box = Gtk.EventBox() + rgba = Gdk.RGBA() + rgba.parse(color) + event_box.override_background_color(Gtk.StateFlags.NORMAL, rgba) + label = Gtk.Label(label=label_text) + label.get_style_context().add_class("greenfix-swatch-label") + label.set_margin_top(24) + label.set_margin_bottom(8) + label.set_margin_start(8) + label.set_margin_end(8) + event_box.add(label) + grid.attach(event_box, index % 4, index // 4, 1, 1) + return grid + + def _photo_reference(self): + if REFERENCE_PHOTO_PATH.exists(): + return Gtk.Image.new_from_file(str(REFERENCE_PHOTO_PATH)) + return Gtk.Label(label="Photo reference unavailable.") +``` + +- [ ] **Step 4: Add reference toggle handler** + +```python + def _on_reference_toggled(self, button, target: str) -> None: + if not button.get_active(): + return + if target == "chart": + self.photo_button.set_active(False) + else: + self.color_chart_button.set_active(False) + self.reference_stack.set_visible_child_name(target) +``` + +- [ ] **Step 5: Add mode switch and basic-control handlers** + +Import `compensation` at the top: + +```python +from greenfix import compensation, config, xrandr +``` + +Add: + +```python + def _mode_switch_row(self): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self.basic_mode_button = Gtk.ToggleButton(label="Basic") + self.advanced_mode_button = Gtk.ToggleButton(label="Advanced") + self.basic_mode_button.set_active(self.ui_mode == "basic") + self.advanced_mode_button.set_active(self.ui_mode == "advanced") + self.basic_mode_button.connect("toggled", self._on_mode_toggled, "basic") + self.advanced_mode_button.connect("toggled", self._on_mode_toggled, "advanced") + row.pack_start(self.basic_mode_button, True, True, 0) + row.pack_start(self.advanced_mode_button, True, True, 0) + return row + + def _on_mode_toggled(self, button, mode: str) -> None: + if not button.get_active(): + return + self.ui_mode = mode + if mode == "basic": + self.advanced_mode_button.set_active(False) + else: + self.basic_mode_button.set_active(False) + + def _on_basic_scale_changed(self, scale, value_label) -> None: + value = int(round(scale.get_value())) + value_label.set_text(str(value)) + if self._updating_controls: + return + self._sync_advanced_from_basic() + self._schedule_preview() + + def _sync_advanced_from_basic(self) -> None: + red, green, blue = compensation.compute_rgb_gamma( + int(round(self.green_tint_scale.get_value())), + int(round(self.warm_cool_scale.get_value())), + ) + self._updating_controls = True + try: + self.red_scale.set_value(red) + self.green_scale.set_value(green) + self.blue_scale.set_value(blue) + finally: + self._updating_controls = False +``` + +Connect the basic scales to `_on_basic_scale_changed` instead of `_on_scale_changed`. + +- [ ] **Step 6: Restructure the root layout** + +Replace the current linear layout in `GreenfixWindow.__init__` with: + +```python + root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + root.get_style_context().add_class("greenfix-root") + self.add(root) +``` + +Keep the Wayland warning and output row. Add a horizontal body: + +```python + body = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + body.pack_start(self._reference_panel(), True, True, 0) + controls = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + controls.pack_start(self._mode_switch_row(), False, False, 0) + controls.pack_start(self._slider_row("Green tint fix", self.green_tint_scale, self.green_tint_value), False, False, 0) + controls.pack_start(self._slider_row("Warm / cool", self.warm_cool_scale, self.warm_cool_value), False, False, 0) + controls.pack_start(self._slider_row("Brightness", self.brightness_scale, self.brightness_value), False, False, 0) + controls.pack_start(self._slider_row("Red gamma", self.red_scale, self.red_value), False, False, 0) + controls.pack_start(self._slider_row("Green gamma", self.green_scale, self.green_value), False, False, 0) + controls.pack_start(self._slider_row("Blue gamma", self.blue_scale, self.blue_value), False, False, 0) + body.pack_start(controls, True, True, 0) + root.pack_start(body, True, True, 0) +``` + +Then pack button row and status label as before. Apply style classes to buttons in `_button_row`: + +```python + if label == "Save": + button.get_style_context().add_class("greenfix-save-button") + else: + button.get_style_context().add_class("greenfix-primary-button") +``` + +- [ ] **Step 7: Save UI metadata** + +Update `_current_settings()` so it returns: + +```python + return config.Settings( + output=output, + red_gamma=self.red_scale.get_value(), + green_gamma=self.green_scale.get_value(), + blue_gamma=self.blue_scale.get_value(), + brightness=self.brightness_scale.get_value(), + ui_mode=self.ui_mode, + green_tint_fix=int(round(self.green_tint_scale.get_value())), + warm_cool_trim=int(round(self.warm_cool_scale.get_value())), + ) +``` + +- [ ] **Step 8: Reset basic controls** + +In `_on_reset_clicked`, after `self._set_slider_values(settings)`, set: + +```python + self._updating_controls = True + try: + self.green_tint_scale.set_value(0) + self.warm_cool_scale.set_value(0) + finally: + self._updating_controls = False +``` + +- [ ] **Step 9: Run the test suite** + +Run: + +```bash +python3 -m unittest discover -s tests -v +``` + +Expected: all tests pass. + +- [ ] **Step 10: Commit** + +```bash +git add greenfix/ui.py +git commit -m "feat: add green tint reference ui" +``` + +--- + +### Task 7: Update README And Final Verification + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Update README UI description** + +In `README.md`, update the UI bullet list to mention: + +```markdown +- A reference image panel with a color chart and photo reference. +- Large green-tint compensation, warm/cool trim, and brightness controls. +- Advanced red, green, and blue gamma controls for fine tuning. +``` + +Add a troubleshooting note: + +```markdown +Saved settings show in the UI but do not affect the screen + +Open greenfix again after saving, or run `greenfix --apply-saved`. The UI now applies saved settings on launch; if it cannot, the status area shows the `xrandr` error. +``` + +- [ ] **Step 2: Run full local tests** + +Run: + +```bash +python3 -m unittest discover -s tests -v +``` + +Expected: 30+ tests pass with `OK`. + +- [ ] **Step 3: Inspect git diff** + +Run: + +```bash +git status --short +git diff --stat origin/main..HEAD +``` + +Expected: only intended files are changed. + +- [ ] **Step 4: Commit docs** + +```bash +git add README.md +git commit -m "docs: document green tint controls" +``` + +- [ ] **Step 5: Push branch** + +```bash +git push +``` + +Expected: PR `#4` updates with the implementation commits. diff --git a/docs/superpowers/specs/2026-06-02-green-tint-controls-design.md b/docs/superpowers/specs/2026-06-02-green-tint-controls-design.md new file mode 100644 index 0000000..7bc25ba --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-green-tint-controls-design.md @@ -0,0 +1,130 @@ +# greenfix Green-Tint Controls Design + +## Goal + +greenfix should focus on the user's real problem: a laptop panel with a permanent hardware green tint. The UI should make compensation easy with large, friendly controls, an accessible dark presentation, and a reference image area for judging whether whites, blacks, skin tones, and common colors look reasonable. + +This expansion addresses GitHub issues: + +- `#2`: Add a reference image to the UI window. +- `#3`: Saved settings must apply automatically on startup/reboot, not only appear in the sliders. + +Because this is expanded feature work, implementation will happen on a PR branch, not directly on `main`. + +## User Experience + +The approved direction is the dark-mode mockup from the visual companion. Text and its immediate background must meet a minimum WCAG contrast ratio of `7:1`. The mockup's lowest calculated text/background ratio was `7.57:1`; production styling should preserve that standard or improve it. + +The window uses larger controls than the MVP: + +- A display selector for the active `xrandr` output. +- A reference image panel with a large segmented switch: + - `Color chart`: white, black, gray, skin tone, and common saturated colors. + - `Photo`: a bundled generated PNG reference scene with skin-like tones, sky, foliage, and a small color strip. The mockup label `Photo TK` becomes `Photo` in production. +- A basic compensation mode with: + - `Green tint fix`: the primary correction control. + - `Warm / cool trim`: a secondary red/blue balance adjustment. + - `Brightness`: the existing software brightness control. +- An advanced RGB gamma section showing red, green, and blue values. Advanced controls remain available for fine tuning. +- Large Apply, Save, Reset, and Quit buttons. +- A status message area for saved/applied/error feedback. + +The UI should feel like a practical desktop utility, not a marketing page. It should be dense enough for repeated use while keeping controls large enough to use comfortably. + +## Compensation Model + +greenfix remains a simple `xrandr` wrapper. The backend still applies: + +```text +xrandr --output OUTPUT --gamma RED:GREEN:BLUE --brightness BRIGHTNESS +``` + +No saturation or contrast controls are added in this feature because portable `xrandr` support is limited to gamma and software brightness. + +The basic controls compute canonical RGB gamma values: + +- `Green tint fix` is a percentage from `0` to `100`. + - `0` is neutral RGB gamma `1.00:1.00:1.00`. + - Higher values add magenta compensation by lowering green and mildly raising red and blue. + - At maximum, values must stay inside the existing validation range of `0.60` to `1.80`. +- `Warm / cool trim` is an integer from `-50` to `50` and shifts red and blue in opposite directions. + - Negative values make the result cooler by lowering red and raising blue. + - Positive values make the result warmer by raising red and lowering blue. + - Trim is applied after green-tint compensation and clamped to valid gamma ranges. +- `Brightness` keeps the current range of `0.60` to `1.20`. + +The initial production formula is: + +```text +s = green_tint_fix / 100 +t = warm_cool_trim / 50 + +red_gamma = clamp(1.00 + (0.20 * s) + (0.12 * t), 0.60, 1.80) +green_gamma = clamp(1.00 - (0.20 * s), 0.60, 1.80) +blue_gamma = clamp(1.00 + (0.20 * s) - (0.12 * t), 0.60, 1.80) +``` + +The formula is intentionally conservative. At maximum green-tint fix with no warm/cool trim, the computed gamma is `1.20:0.80:1.20`, leaving room for advanced RGB fine tuning. + +## Persistence And Startup Behavior + +Saved settings remain compatible with the current CLI and autostart path. The canonical saved values are still: + +- `output` +- `red_gamma` +- `green_gamma` +- `blue_gamma` +- `brightness` + +The config also saves optional UI metadata so the GTK window can restore the selected mode and basic-control positions: + +- `ui_mode`: `basic` or `advanced` +- `green_tint_fix` +- `warm_cool_trim` + +Older settings files without those optional keys remain valid. + +On GTK startup, if saved settings exist for the selected connected output, greenfix must apply those saved display settings automatically after the window initializes. This fixes the reboot behavior where sliders showed saved values but the display remained uncorrected until Apply was clicked. + +The existing `greenfix --apply-saved` autostart behavior remains unchanged and continues to apply canonical RGB gamma and brightness without opening the UI. + +## Architecture + +The current module boundaries stay intact: + +- `greenfix.xrandr` remains responsible for validation and `xrandr` command construction/application. +- `greenfix.config` remains responsible for loading, validating, and saving settings. +- `greenfix.ui` owns GTK layout, basic-control interaction, advanced RGB controls, reference image rendering, startup apply behavior, and status messages. +- `greenfix.cli` continues to use canonical saved RGB settings and does not need green-tint UI concepts. + +Add `greenfix.compensation` as a small non-GTK helper module for converting basic compensation controls into RGB gamma values. This keeps the formula unit-testable without GTK. + +## Error Handling + +Startup apply must not crash the UI. If saved settings fail to apply, the window should stay open and show the `xrandr` or config error in the status area. + +If no connected display outputs are detected, the existing startup error behavior remains. The reference image and controls may still render, but applying settings should continue to report that no display output is selected. + +Malformed or unsupported optional UI metadata should fall back to neutral/basic defaults while preserving the existing behavior of ignoring malformed settings files that cannot produce valid canonical settings. + +## Testing + +Tests should cover behavior without changing the real display: + +- Pure compensation formula tests: + - Neutral basic controls produce `1.00:1.00:1.00`. + - Increasing green-tint fix lowers green and raises red/blue within range. + - Warm trim raises red and lowers blue. + - Cool trim lowers red and raises blue. + - Outputs are clamped to existing gamma limits. +- Config tests: + - Existing settings JSON without optional UI metadata still loads. + - Settings with optional UI metadata round trip. + - Invalid optional UI metadata falls back safely or is ignored. +- UI helper tests: + - Startup status behavior remains intact. + - Startup apply chooses saved settings when available and reports success/failure through status text. +- CLI tests: + - Existing `--apply-saved` behavior still applies canonical RGB values. + +Manual verification should include opening the GTK UI on an X11 session, confirming saved settings apply on launch, checking that the reference panel renders, and confirming the dark UI maintains readable contrast. diff --git a/greenfix/assets/reference-photo.png b/greenfix/assets/reference-photo.png new file mode 100644 index 0000000..d2f6073 Binary files /dev/null and b/greenfix/assets/reference-photo.png differ diff --git a/greenfix/compensation.py b/greenfix/compensation.py new file mode 100644 index 0000000..f7b4ab6 --- /dev/null +++ b/greenfix/compensation.py @@ -0,0 +1,41 @@ +"""Friendly display compensation controls for greenfix.""" + +from __future__ import annotations + +from greenfix import xrandr + +GREEN_TINT_FIX_MIN = 0 +GREEN_TINT_FIX_MAX = 100 +WARM_COOL_TRIM_MIN = -50 +WARM_COOL_TRIM_MAX = 50 + + +class CompensationError(ValueError): + """Raised when basic compensation controls are invalid.""" + + +def compute_rgb_gamma(green_tint_fix: int, warm_cool_trim: int) -> tuple[float, float, float]: + validate_basic_controls(green_tint_fix, warm_cool_trim) + strength = green_tint_fix / 100 + trim = warm_cool_trim / 50 + + red_gamma = clamp_gamma(1.0 + (0.20 * strength) + (0.12 * trim)) + green_gamma = clamp_gamma(1.0 - (0.20 * strength)) + blue_gamma = clamp_gamma(1.0 + (0.20 * strength) - (0.12 * trim)) + return red_gamma, green_gamma, blue_gamma + + +def validate_basic_controls(green_tint_fix: int, warm_cool_trim: int) -> None: + if not GREEN_TINT_FIX_MIN <= green_tint_fix <= GREEN_TINT_FIX_MAX: + raise CompensationError( + f"green_tint_fix must be between {GREEN_TINT_FIX_MIN} and {GREEN_TINT_FIX_MAX}." + ) + if not WARM_COOL_TRIM_MIN <= warm_cool_trim <= WARM_COOL_TRIM_MAX: + raise CompensationError( + f"warm_cool_trim must be between {WARM_COOL_TRIM_MIN} and {WARM_COOL_TRIM_MAX}." + ) + + +def clamp_gamma(value: float) -> float: + clamped = min(max(value, xrandr.GAMMA_MIN), xrandr.GAMMA_MAX) + return round(clamped, 2) diff --git a/greenfix/config.py b/greenfix/config.py index 09453f8..1224dc0 100644 --- a/greenfix/config.py +++ b/greenfix/config.py @@ -4,7 +4,7 @@ import json import os -from dataclasses import asdict, dataclass +from dataclasses import dataclass from pathlib import Path from typing import Mapping @@ -22,6 +22,9 @@ class Settings: green_gamma: float = xrandr.NEUTRAL_GAMMA blue_gamma: float = xrandr.NEUTRAL_GAMMA brightness: float = xrandr.NEUTRAL_BRIGHTNESS + ui_mode: str | None = None + green_tint_fix: int | None = None + warm_cool_trim: int | None = None def __post_init__(self) -> None: try: @@ -32,6 +35,7 @@ def __post_init__(self) -> None: xrandr.validate_brightness_value(self.brightness) except xrandr.XrandrError as exc: raise ConfigError(str(exc)) from exc + validate_ui_metadata(self.ui_mode, self.green_tint_fix, self.warm_cool_trim) def default_settings_path( @@ -49,6 +53,34 @@ def neutral_settings(output: str) -> Settings: return Settings(output=output) +def validate_ui_metadata( + ui_mode: str | None, + green_tint_fix: int | None, + warm_cool_trim: int | None, +) -> None: + if ui_mode is not None and ui_mode not in {"basic", "advanced"}: + raise ConfigError("ui_mode must be 'basic' or 'advanced'.") + if green_tint_fix is not None and not 0 <= green_tint_fix <= 100: + raise ConfigError("green_tint_fix must be between 0 and 100.") + if warm_cool_trim is not None and not -50 <= warm_cool_trim <= 50: + raise ConfigError("warm_cool_trim must be between -50 and 50.") + + +def _optional_ui_metadata(data: Mapping[str, object]) -> dict[str, object]: + metadata: dict[str, object] = {} + ui_mode = data.get("ui_mode") + green_tint_fix = data.get("green_tint_fix") + warm_cool_trim = data.get("warm_cool_trim") + + if ui_mode in {"basic", "advanced"}: + metadata["ui_mode"] = ui_mode + if isinstance(green_tint_fix, int) and 0 <= green_tint_fix <= 100: + metadata["green_tint_fix"] = green_tint_fix + if isinstance(warm_cool_trim, int) and -50 <= warm_cool_trim <= 50: + metadata["warm_cool_trim"] = warm_cool_trim + return metadata + + def load_settings(path: Path | None = None) -> Settings | None: settings_path = path or default_settings_path() try: @@ -64,6 +96,7 @@ def load_settings(path: Path | None = None) -> Settings | None: green_gamma=float(data.get("green_gamma", xrandr.NEUTRAL_GAMMA)), blue_gamma=float(data.get("blue_gamma", xrandr.NEUTRAL_GAMMA)), brightness=float(data.get("brightness", xrandr.NEUTRAL_BRIGHTNESS)), + **_optional_ui_metadata(data), ) except (FileNotFoundError, json.JSONDecodeError, KeyError, TypeError, ValueError): return None @@ -72,7 +105,20 @@ def load_settings(path: Path | None = None) -> Settings | None: def save_settings(settings: Settings, path: Path | None = None) -> None: settings_path = path or default_settings_path() settings_path.parent.mkdir(parents=True, exist_ok=True) + data: dict[str, object] = { + "output": settings.output, + "red_gamma": settings.red_gamma, + "green_gamma": settings.green_gamma, + "blue_gamma": settings.blue_gamma, + "brightness": settings.brightness, + } + if settings.ui_mode is not None: + data["ui_mode"] = settings.ui_mode + if settings.green_tint_fix is not None: + data["green_tint_fix"] = settings.green_tint_fix + if settings.warm_cool_trim is not None: + data["warm_cool_trim"] = settings.warm_cool_trim settings_path.write_text( - json.dumps(asdict(settings), indent=2, sort_keys=False) + "\n", + json.dumps(data, indent=2, sort_keys=False) + "\n", encoding="utf-8", ) diff --git a/greenfix/ui.py b/greenfix/ui.py index 1429f80..df2f5f6 100644 --- a/greenfix/ui.py +++ b/greenfix/ui.py @@ -2,17 +2,20 @@ from __future__ import annotations +from pathlib import Path from typing import Callable -from greenfix import config, xrandr +from greenfix import compensation, config, xrandr try: import gi + gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") - from gi.repository import GLib, Gtk + from gi.repository import Gdk, GLib, Gtk except (ImportError, ValueError) as exc: # pragma: no cover - depends on host packages gi = None + Gdk = None GLib = None Gtk = None GTK_IMPORT_ERROR = exc @@ -20,6 +23,96 @@ GTK_IMPORT_ERROR = None SLIDER_DEBOUNCE_MS = 250 +ASSET_DIR = Path(__file__).resolve().parent / "assets" +REFERENCE_PHOTO_PATH = ASSET_DIR / "reference-photo.png" + +DARK_THEME_TEXT_PAIRS = [ + ("body", "#f2f5f2", "#0f1215"), + ("window", "#f2f5f2", "#20252a"), + ("panel", "#f2f5f2", "#262c32"), + ("titlebar secondary", "#cad2d9", "#1a1f23"), + ("muted on panel", "#cad2d9", "#20252a"), + ("button", "#ffffff", "#3a444e"), + ("save button", "#ffffff", "#1f5f42"), + ("status", "#d7e5da", "#17221d"), + ("swatch label", "#f7fbf8", "#111315"), +] + +REFERENCE_COLOR_SWATCHES = [ + ("White", "#ffffff"), + ("Black", "#000000"), + ("Gray", "#808080"), + ("Skin", "#e8d1b6"), + ("Red", "#d93432"), + ("Green", "#1f8d4d"), + ("Blue", "#245bb8"), + ("Yellow", "#f4d03f"), + ("Orange", "#f47c28"), + ("Purple", "#7c3fa0"), + ("Cyan", "#29b7b7"), + ("Magenta", "#c23882"), +] + +DARK_THEME_CSS = b""" +window.greenfix-window { + background: #20252a; + color: #f2f5f2; +} +.greenfix-root { + background: #20252a; + color: #f2f5f2; +} +.greenfix-panel { + background: #262c32; + border: 1px solid #46505a; + border-radius: 6px; +} +.greenfix-card { + background: #1c2227; + border: 1px solid #46505a; + border-radius: 6px; +} +.greenfix-label-muted { + color: #cad2d9; + font-weight: 700; +} +.greenfix-primary-button { + min-height: 48px; + background: #3a444e; + color: #ffffff; + border-radius: 6px; + font-weight: 700; +} +.greenfix-save-button { + min-height: 48px; + background: #1f5f42; + color: #ffffff; + border-radius: 6px; + font-weight: 700; +} +.greenfix-switch-active { + background: #183328; + color: #f2f5f2; + border-color: #6fc18f; +} +.greenfix-switch-inactive { + background: #171c20; + color: #f2f5f2; +} +.greenfix-status { + background: #17221d; + color: #d7e5da; + border: 1px solid #46505a; + border-radius: 6px; + padding: 8px; +} +.greenfix-swatch-label { + background: #111315; + color: #f7fbf8; + border-radius: 4px; + padding: 4px 6px; +} +""" _GtkWindowBase = Gtk.Window if Gtk is not None else object @@ -27,15 +120,21 @@ class GreenfixWindow(_GtkWindowBase): # type: ignore[misc, valid-type] def __init__(self) -> None: if Gtk is None: raise RuntimeError("GTK/PyGObject is required for the desktop UI.") + apply_dark_theme() super().__init__(title="greenfix") + self.get_style_context().add_class("greenfix-window") self.set_border_width(12) - self.set_default_size(420, 300) + self.set_default_size(860, 520) self._debounce_id: int | None = None self.outputs = self._load_outputs() self.saved_settings = config.load_settings() selected_output = self._initial_output() initial_settings = self._initial_settings(selected_output) + self._updating_controls = False + self.ui_mode = initial_settings.ui_mode or ("advanced" if self.saved_settings else "basic") + self.green_tint_fix = initial_settings.green_tint_fix or 0 + self.warm_cool_trim = initial_settings.warm_cool_trim or 0 self.output_combo = Gtk.ComboBoxText() for output in self.outputs: @@ -44,6 +143,20 @@ def __init__(self) -> None: self.output_combo.set_active(self.outputs.index(selected_output)) self.output_combo.connect("changed", self._on_control_changed) + self.green_tint_scale, self.green_tint_value = self._create_scale( + float(self.green_tint_fix), + 0, + 100, + digits=0, + handler=self._on_basic_scale_changed, + ) + self.warm_cool_scale, self.warm_cool_value = self._create_scale( + float(self.warm_cool_trim), + -50, + 50, + digits=0, + handler=self._on_basic_scale_changed, + ) self.red_scale, self.red_value = self._create_scale(initial_settings.red_gamma, 0.60, 1.80) self.green_scale, self.green_value = self._create_scale(initial_settings.green_gamma, 0.60, 1.80) self.blue_scale, self.blue_value = self._create_scale(initial_settings.blue_gamma, 0.60, 1.80) @@ -56,8 +169,10 @@ def __init__(self) -> None: self.status_label = Gtk.Label() self.status_label.set_xalign(0) self.status_label.set_line_wrap(True) + self.status_label.get_style_context().add_class("greenfix-status") - root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + root.get_style_context().add_class("greenfix-root") self.add(root) if xrandr.is_wayland_session(): @@ -67,15 +182,34 @@ def __init__(self) -> None: root.pack_start(warning, False, False, 0) root.pack_start(self._output_row(), False, False, 0) - root.pack_start(self._slider_row("Red gamma", self.red_scale, self.red_value), False, False, 0) - root.pack_start(self._slider_row("Green gamma", self.green_scale, self.green_value), False, False, 0) - root.pack_start(self._slider_row("Blue gamma", self.blue_scale, self.blue_value), False, False, 0) - root.pack_start( + body = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + body.pack_start(self._reference_panel(), True, True, 0) + controls = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + controls.get_style_context().add_class("greenfix-card") + controls.pack_start(self._mode_switch_row(), False, False, 0) + controls.pack_start( + self._slider_row("Green tint fix", self.green_tint_scale, self.green_tint_value), + False, + False, + 0, + ) + controls.pack_start( + self._slider_row("Warm / cool", self.warm_cool_scale, self.warm_cool_value), + False, + False, + 0, + ) + controls.pack_start( self._slider_row("Brightness", self.brightness_scale, self.brightness_value), False, False, 0, ) + controls.pack_start(self._slider_row("Red gamma", self.red_scale, self.red_value), False, False, 0) + controls.pack_start(self._slider_row("Green gamma", self.green_scale, self.green_value), False, False, 0) + controls.pack_start(self._slider_row("Blue gamma", self.blue_scale, self.blue_value), False, False, 0) + body.pack_start(controls, True, True, 0) + root.pack_start(body, True, True, 0) root.pack_start(self._button_row(), False, False, 0) root.pack_start(self.status_label, False, False, 0) @@ -83,6 +217,8 @@ def __init__(self) -> None: self.outputs, getattr(self, "_startup_error", None), ) + if startup_message is None: + startup_message = apply_saved_on_startup(self.saved_settings, selected_output) if startup_message is not None: self._set_status(startup_message) @@ -112,11 +248,20 @@ def _output_row(self): row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) label = Gtk.Label(label="Display") label.set_xalign(0) + label.get_style_context().add_class("greenfix-label-muted") row.pack_start(label, False, False, 0) row.pack_start(self.output_combo, True, True, 0) return row - def _create_scale(self, value: float, minimum: float, maximum: float): + def _create_scale( + self, + value: float, + minimum: float, + maximum: float, + *, + digits: int = 2, + handler: Callable | None = None, + ): adjustment = Gtk.Adjustment( value=value, lower=minimum, @@ -126,23 +271,92 @@ def _create_scale(self, value: float, minimum: float, maximum: float): page_size=0, ) scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adjustment) - scale.set_digits(2) + scale.set_digits(digits) scale.set_hexpand(True) - value_label = Gtk.Label(label=f"{value:.2f}") - scale.connect("value-changed", self._on_scale_changed, value_label) + value_label = Gtk.Label(label=self._format_scale_value(value, digits)) + scale.connect("value-changed", handler or self._on_scale_changed, value_label) return scale, value_label + def _format_scale_value(self, value: float, digits: int) -> str: + if digits == 0: + return str(int(round(value))) + return f"{value:.{digits}f}" + def _slider_row(self, label_text: str, scale, value_label): row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) label = Gtk.Label(label=label_text) label.set_xalign(0) - label.set_size_request(96, -1) - value_label.set_size_request(48, -1) + label.get_style_context().add_class("greenfix-label-muted") + label.set_size_request(112, -1) + value_label.set_size_request(56, -1) row.pack_start(label, False, False, 0) row.pack_start(scale, True, True, 0) row.pack_start(value_label, False, False, 0) return row + def _reference_panel(self): + panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + panel.get_style_context().add_class("greenfix-panel") + + switch_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self.color_chart_button = Gtk.ToggleButton(label="Color chart") + self.photo_button = Gtk.ToggleButton(label="Photo") + self.color_chart_button.set_active(True) + self.color_chart_button.connect("toggled", self._on_reference_toggled, "chart") + self.photo_button.connect("toggled", self._on_reference_toggled, "photo") + self._set_toggle_style(self.color_chart_button, True) + self._set_toggle_style(self.photo_button, False) + switch_row.pack_start(self.color_chart_button, True, True, 0) + switch_row.pack_start(self.photo_button, True, True, 0) + + self.reference_stack = Gtk.Stack() + self.reference_stack.add_named(self._color_chart(), "chart") + self.reference_stack.add_named(self._photo_reference(), "photo") + self.reference_stack.set_visible_child_name("chart") + + panel.pack_start(switch_row, False, False, 0) + panel.pack_start(self.reference_stack, True, True, 0) + return panel + + def _color_chart(self): + grid = Gtk.Grid() + grid.set_row_spacing(0) + grid.set_column_spacing(0) + for index, (label_text, color) in enumerate(REFERENCE_COLOR_SWATCHES): + event_box = Gtk.EventBox() + event_box.set_size_request(88, 68) + rgba = Gdk.RGBA() + rgba.parse(color) + event_box.override_background_color(Gtk.StateFlags.NORMAL, rgba) + label = Gtk.Label(label=label_text) + label.get_style_context().add_class("greenfix-swatch-label") + label.set_margin_top(24) + label.set_margin_bottom(8) + label.set_margin_start(8) + label.set_margin_end(8) + event_box.add(label) + grid.attach(event_box, index % 4, index // 4, 1, 1) + return grid + + def _photo_reference(self): + if REFERENCE_PHOTO_PATH.exists(): + return Gtk.Image.new_from_file(str(REFERENCE_PHOTO_PATH)) + return Gtk.Label(label="Photo reference unavailable.") + + def _mode_switch_row(self): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self.basic_mode_button = Gtk.ToggleButton(label="Basic") + self.advanced_mode_button = Gtk.ToggleButton(label="Advanced") + self.basic_mode_button.set_active(self.ui_mode == "basic") + self.advanced_mode_button.set_active(self.ui_mode == "advanced") + self.basic_mode_button.connect("toggled", self._on_mode_toggled, "basic") + self.advanced_mode_button.connect("toggled", self._on_mode_toggled, "advanced") + self._set_toggle_style(self.basic_mode_button, self.ui_mode == "basic") + self._set_toggle_style(self.advanced_mode_button, self.ui_mode == "advanced") + row.pack_start(self.basic_mode_button, True, True, 0) + row.pack_start(self.advanced_mode_button, True, True, 0) + return row + def _button_row(self): row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) for label, handler in [ @@ -153,13 +367,72 @@ def _button_row(self): ]: button = Gtk.Button(label=label) button.connect("clicked", handler) + if label == "Save": + button.get_style_context().add_class("greenfix-save-button") + else: + button.get_style_context().add_class("greenfix-primary-button") row.pack_start(button, False, False, 0) return row def _on_scale_changed(self, scale, value_label) -> None: value_label.set_text(f"{scale.get_value():.2f}") + if self._updating_controls: + return self._schedule_preview() + def _on_reference_toggled(self, button, target: str) -> None: + if not button.get_active(): + if not self.color_chart_button.get_active() and not self.photo_button.get_active(): + button.set_active(True) + return + if target == "chart": + self.photo_button.set_active(False) + else: + self.color_chart_button.set_active(False) + self._set_toggle_style(self.color_chart_button, target == "chart") + self._set_toggle_style(self.photo_button, target == "photo") + self.reference_stack.set_visible_child_name(target) + + def _on_mode_toggled(self, button, mode: str) -> None: + if not button.get_active(): + if not self.basic_mode_button.get_active() and not self.advanced_mode_button.get_active(): + button.set_active(True) + return + self.ui_mode = mode + if mode == "basic": + self.advanced_mode_button.set_active(False) + else: + self.basic_mode_button.set_active(False) + self._set_toggle_style(self.basic_mode_button, mode == "basic") + self._set_toggle_style(self.advanced_mode_button, mode == "advanced") + + def _set_toggle_style(self, button, active: bool) -> None: + context = button.get_style_context() + context.remove_class("greenfix-switch-active") + context.remove_class("greenfix-switch-inactive") + context.add_class("greenfix-switch-active" if active else "greenfix-switch-inactive") + + def _on_basic_scale_changed(self, scale, value_label) -> None: + value = int(round(scale.get_value())) + value_label.set_text(str(value)) + if self._updating_controls: + return + self._sync_advanced_from_basic() + self._schedule_preview() + + def _sync_advanced_from_basic(self) -> None: + red, green, blue = compensation.compute_rgb_gamma( + int(round(self.green_tint_scale.get_value())), + int(round(self.warm_cool_scale.get_value())), + ) + self._updating_controls = True + try: + self.red_scale.set_value(red) + self.green_scale.set_value(green) + self.blue_scale.set_value(blue) + finally: + self._updating_controls = False + def _on_control_changed(self, *_args) -> None: self._schedule_preview() @@ -194,6 +467,12 @@ def _on_reset_clicked(self, _button) -> None: return settings = config.neutral_settings(output) self._set_slider_values(settings) + self._updating_controls = True + try: + self.green_tint_scale.set_value(0) + self.warm_cool_scale.set_value(0) + finally: + self._updating_controls = False try: xrandr.reset_output(output) except xrandr.XrandrError as exc: @@ -229,6 +508,9 @@ def _current_settings(self) -> config.Settings: green_gamma=self.green_scale.get_value(), blue_gamma=self.blue_scale.get_value(), brightness=self.brightness_scale.get_value(), + ui_mode=self.ui_mode, + green_tint_fix=int(round(self.green_tint_scale.get_value())), + warm_cool_trim=int(round(self.warm_cool_scale.get_value())), ) def _current_output(self) -> str | None: @@ -236,10 +518,14 @@ def _current_output(self) -> str | None: return str(active) if active is not None else None def _set_slider_values(self, settings: config.Settings) -> None: - self.red_scale.set_value(settings.red_gamma) - self.green_scale.set_value(settings.green_gamma) - self.blue_scale.set_value(settings.blue_gamma) - self.brightness_scale.set_value(settings.brightness) + self._updating_controls = True + try: + self.red_scale.set_value(settings.red_gamma) + self.green_scale.set_value(settings.green_gamma) + self.blue_scale.set_value(settings.blue_gamma) + self.brightness_scale.set_value(settings.brightness) + finally: + self._updating_controls = False def _set_status(self, message: str) -> None: self.status_label.set_text(message) @@ -259,9 +545,45 @@ def main() -> int: return 0 +def apply_dark_theme() -> None: + if Gtk is None or Gdk is None: + return + screen = Gdk.Screen.get_default() + if screen is None: + return + provider = Gtk.CssProvider() + provider.load_from_data(DARK_THEME_CSS) + Gtk.StyleContext.add_provider_for_screen( + screen, + provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + def startup_status_message(outputs: list[str], startup_error: str | None) -> str | None: if outputs: return None if startup_error: return startup_error return "No connected display outputs were detected by xrandr." + + +def apply_saved_on_startup( + saved_settings: config.Settings | None, + selected_output: str, + *, + apply_settings: Callable[[str, float, float, float, float], None] = xrandr.apply_settings, +) -> str | None: + if saved_settings is None or saved_settings.output != selected_output: + return None + try: + apply_settings( + saved_settings.output, + saved_settings.red_gamma, + saved_settings.green_gamma, + saved_settings.blue_gamma, + saved_settings.brightness, + ) + except xrandr.XrandrError as exc: + return str(exc) + return f"Applied saved settings for {saved_settings.output}." diff --git a/pyproject.toml b/pyproject.toml index 5767c08..7d3fd12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,9 @@ greenfix = "greenfix.app:main" [tool.setuptools.packages.find] include = ["greenfix*"] +[tool.setuptools.package-data] +greenfix = ["assets/*.png"] + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-q" diff --git a/tests/test_compensation.py b/tests/test_compensation.py new file mode 100644 index 0000000..434183a --- /dev/null +++ b/tests/test_compensation.py @@ -0,0 +1,44 @@ +import unittest + +from greenfix import compensation + + +class CompensationTests(unittest.TestCase): + def test_neutral_controls_produce_neutral_gamma(self) -> None: + self.assertEqual( + compensation.compute_rgb_gamma(green_tint_fix=0, warm_cool_trim=0), + (1.0, 1.0, 1.0), + ) + + def test_green_tint_fix_adds_magenta_compensation(self) -> None: + self.assertEqual( + compensation.compute_rgb_gamma(green_tint_fix=100, warm_cool_trim=0), + (1.2, 0.8, 1.2), + ) + + def test_warm_trim_raises_red_and_lowers_blue(self) -> None: + self.assertEqual( + compensation.compute_rgb_gamma(green_tint_fix=0, warm_cool_trim=50), + (1.12, 1.0, 0.88), + ) + + def test_cool_trim_lowers_red_and_raises_blue(self) -> None: + self.assertEqual( + compensation.compute_rgb_gamma(green_tint_fix=0, warm_cool_trim=-50), + (0.88, 1.0, 1.12), + ) + + def test_invalid_basic_control_ranges_are_rejected(self) -> None: + with self.assertRaisesRegex(compensation.CompensationError, "green_tint_fix"): + compensation.compute_rgb_gamma(green_tint_fix=101, warm_cool_trim=0) + with self.assertRaisesRegex(compensation.CompensationError, "warm_cool_trim"): + compensation.compute_rgb_gamma(green_tint_fix=0, warm_cool_trim=-51) + + def test_clamp_keeps_values_inside_xrandr_gamma_limits(self) -> None: + self.assertEqual(compensation.clamp_gamma(0.2), 0.6) + self.assertEqual(compensation.clamp_gamma(2.4), 1.8) + self.assertEqual(compensation.clamp_gamma(1.234), 1.23) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py index a41a140..f86fb25 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -78,6 +78,69 @@ def test_invalid_settings_values_are_rejected(self) -> None: with self.assertRaisesRegex(config.ConfigError, "brightness"): config.Settings(output="eDP-1", brightness=1.3) + def test_settings_with_ui_metadata_round_trip(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "settings.json" + settings = config.Settings( + output="eDP-1", + red_gamma=1.1, + green_gamma=0.92, + blue_gamma=1.12, + brightness=1.0, + ui_mode="basic", + green_tint_fix=42, + warm_cool_trim=8, + ) + + config.save_settings(settings, path) + + self.assertEqual(config.load_settings(path), settings) + self.assertEqual( + json.loads(path.read_text(encoding="utf-8")), + { + "output": "eDP-1", + "red_gamma": 1.1, + "green_gamma": 0.92, + "blue_gamma": 1.12, + "brightness": 1.0, + "ui_mode": "basic", + "green_tint_fix": 42, + "warm_cool_trim": 8, + }, + ) + + def test_load_settings_ignores_invalid_optional_ui_metadata(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "settings.json" + path.write_text( + json.dumps( + { + "output": "eDP-1", + "red_gamma": 1.1, + "green_gamma": 0.92, + "blue_gamma": 1.12, + "brightness": 1.0, + "ui_mode": "expert", + "green_tint_fix": 200, + "warm_cool_trim": "warm", + } + ), + encoding="utf-8", + ) + + self.assertEqual( + config.load_settings(path), + config.Settings("eDP-1", 1.1, 0.92, 1.12, 1.0), + ) + + def test_invalid_settings_ui_metadata_is_rejected_when_constructed_directly(self) -> None: + with self.assertRaisesRegex(config.ConfigError, "ui_mode"): + config.Settings(output="eDP-1", ui_mode="expert") + with self.assertRaisesRegex(config.ConfigError, "green_tint_fix"): + config.Settings(output="eDP-1", green_tint_fix=-1) + with self.assertRaisesRegex(config.ConfigError, "warm_cool_trim"): + config.Settings(output="eDP-1", warm_cool_trim=51) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ui.py b/tests/test_ui.py index d6529ed..d979755 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1,6 +1,28 @@ import unittest +from unittest.mock import Mock -from greenfix import ui +from greenfix import config, ui, xrandr + + +def contrast_ratio(foreground: str, background: str) -> float: + def channel(value: int) -> float: + normalized = value / 255 + if normalized <= 0.03928: + return normalized / 12.92 + return ((normalized + 0.055) / 1.055) ** 2.4 + + def luminance(color: str) -> float: + color = color.lstrip("#") + red = channel(int(color[0:2], 16)) + green = channel(int(color[2:4], 16)) + blue = channel(int(color[4:6], 16)) + return 0.2126 * red + 0.7152 * green + 0.0722 * blue + + first = luminance(foreground) + second = luminance(background) + lighter = max(first, second) + darker = min(first, second) + return (lighter + 0.05) / (darker + 0.05) class UiTests(unittest.TestCase): @@ -19,6 +41,71 @@ def test_startup_status_is_empty_when_outputs_exist(self) -> None: self.assertIsNone(message) + def test_startup_apply_skips_when_saved_settings_do_not_match_output(self) -> None: + apply_settings = Mock() + + message = ui.apply_saved_on_startup( + config.Settings("HDMI-1"), + "eDP-1", + apply_settings=apply_settings, + ) + + self.assertIsNone(message) + apply_settings.assert_not_called() + + def test_startup_apply_applies_matching_saved_settings(self) -> None: + apply_settings = Mock() + + message = ui.apply_saved_on_startup( + config.Settings("eDP-1", 1.1, 0.92, 1.12, 1.0), + "eDP-1", + apply_settings=apply_settings, + ) + + self.assertEqual(message, "Applied saved settings for eDP-1.") + apply_settings.assert_called_once_with("eDP-1", 1.1, 0.92, 1.12, 1.0) + + def test_startup_apply_returns_error_message_when_xrandr_fails(self) -> None: + apply_settings = Mock(side_effect=xrandr.XrandrError("xrandr failed")) + + message = ui.apply_saved_on_startup( + config.Settings("eDP-1", 1.1, 0.92, 1.12, 1.0), + "eDP-1", + apply_settings=apply_settings, + ) + + self.assertEqual(message, "xrandr failed") + + def test_dark_theme_text_pairs_meet_wcag_7_to_1(self) -> None: + failures = [ + (name, contrast_ratio(foreground, background)) + for name, foreground, background in ui.DARK_THEME_TEXT_PAIRS + if contrast_ratio(foreground, background) < 7 + ] + + self.assertEqual(failures, []) + + def test_reference_color_chart_contains_required_labels(self) -> None: + labels = [label for label, _color in ui.REFERENCE_COLOR_SWATCHES] + + self.assertEqual( + labels, + [ + "White", + "Black", + "Gray", + "Skin", + "Red", + "Green", + "Blue", + "Yellow", + "Orange", + "Purple", + "Cyan", + "Magenta", + ], + ) + if __name__ == "__main__": unittest.main()