diff --git a/README.md b/README.md index 561829fe..2a0f1b7b 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ Config is stored at `~/.config/docking/dock.json` (auto-created on first run). N | `update_check_interval_hours` | 24 | Minimum hours between automatic update checks | | `left_click_action` | toggle | Running-app left click: `toggle`, `cycle`, or `most-recent` | | `middle_click_action` | new-window | Application middle click: `new-window`, `minimize`, or `close-focused` | -| `theme` | default | Theme name (loads from `assets/themes/{name}.json`) | +| `theme` | default | Theme name (loads from `~/.config/docking/themes/{name}.json` first, then built-in themes) | | `transparency` | 1.0 | Multiplier applied to theme alpha from `0.15` to `1.0` (`1.0` = full theme opacity) | | `pinned` | [] | Ordered pinned entries for apps, applets, files, and folders. First run seeds a starter set. | | `applet_prefs` | `{}` | Per-applet preference storage | @@ -228,6 +228,47 @@ timestamp, is stored separately under XDG state storage in - **Right-click pinned app**: "Remove from Dock" to unpin - **Edit config**: Add desktop IDs to `"pinned"` in `dock.json` +## Theming + +Themes are JSON files. Docking loads user themes from: + +```text +~/.config/docking/themes/ +``` + +and then falls back to the built-in themes bundled in `docking/assets/themes/`. +Thirteen built-in themes are included: + +- `default` -- light theme +- `onyx` -- dark variant +- `slate` -- flat appearance +- `transparent` -- minimal, see-through +- `olive` -- rounded olive-green theme +- `ember` -- warm dark theme +- `nord` -- cool, desaturated dark +- `glass` -- translucent macOS-style floating pill +- `pill` -- dark floating pill with fully rounded borders +- `paper` -- matte warm floating pill +- `candy` -- playful pastel floating pill +- `gruvbox` -- warm earthy dark +- `solarized` -- soft light Solarized variant + +Theme examples: + +| Theme | Preview | +| --- | --- | +| Glass | ![Glass theme](images/glass.png) | +| Transparent | ![Transparent theme](images/transparent.png) | + +All layout values use a **scaling unit** (tenths of a percent of `icon_size`). This means themes adapt automatically to any icon size. + +Theme layout also controls edge spacing through `distance_from_edge`, which is how floating themes such as `slate` keep the dock visually separated from the screen edge. + +**Creating a custom theme:** +- Docking creates `~/.config/docking/themes/template.json` on startup. +- Copy `template.json` to a new name, such as `my-theme.json`, then edit it. +- `template.json` is hidden from the selector; renamed `.json` files appear as themes. + ## Applets Applets are custom widgets that live in the dock alongside application icons. Enable them via right-click on the dock background -> **Applets**. @@ -966,37 +1007,6 @@ Shows NASA's Astronomy Picture of the Day as a dock thumbnail. The tooltip inclu **Preferences stored:** `last_result` (date, title, explanation, media_type, image_url, page_url, copyright, cached_path) -## Theming - -Themes are JSON files in `docking/assets/themes/`. Thirteen built-in themes are included: - -- `default` -- light theme -- `onyx` -- dark variant -- `slate` -- flat appearance -- `transparent` -- minimal, see-through -- `olive` -- rounded olive-green theme -- `ember` -- warm dark theme -- `nord` -- cool, desaturated dark -- `glass` -- translucent macOS-style floating pill -- `pill` -- dark floating pill with fully rounded borders -- `paper` -- matte warm floating pill -- `candy` -- playful pastel floating pill -- `gruvbox` -- warm earthy dark -- `solarized` -- soft light Solarized variant - -Theme examples: - -| Theme | Preview | -| --- | --- | -| Glass | ![Glass theme](images/glass.png) | -| Transparent | ![Transparent theme](images/transparent.png) | - -All layout values use a **scaling unit** (tenths of a percent of `icon_size`). This means themes adapt automatically to any icon size. - -Theme layout also controls edge spacing through `distance_from_edge`, which is how floating themes such as `slate` keep the dock visually separated from the screen edge. - -**Creating a custom theme:** Copy an existing theme JSON and modify the colors and proportions. Place it in the `assets/themes/` directory -- it will appear in the right-click Themes menu. - ## Writing Custom Applets Applets extend the `Applet` abstract base class in `docking/applets/base.py`: diff --git a/docking/core/theme.py b/docking/core/theme.py index 6d903816..7866425c 100644 --- a/docking/core/theme.py +++ b/docking/core/theme.py @@ -121,6 +121,7 @@ import enum import json +import os from dataclasses import dataclass, replace from pathlib import Path from typing import Any @@ -129,12 +130,79 @@ # Bundled themes directory (relative to package) _BUILTIN_THEMES_DIR = Path(__file__).resolve().parent.parent / "assets" / "themes" +_USER_THEME_TEMPLATE_NAME = "template" log = get_logger("theme") # Color types as Cairo-compatible floats (0.0-1.0) RGB = tuple[float, float, float] RGBA = tuple[float, float, float, float] +_USER_THEME_TEMPLATE = { + "fill_start": [222, 222, 222, 240], + "fill_end": [247, 247, 247, 240], + "stroke": [145, 145, 145, 255], + "stroke_width": 1.0, + "inner_stroke": [248, 248, 248, 255], + "roundness": 5, + "indicator_color": [80, 80, 80, 200], + "active_indicator_color": [50, 50, 50, 255], + "indicator_size": 5, + "h_padding": 0, + "top_padding": -7, + "bottom_padding": 1, + "item_padding": 2.5, + "urgent_bounce_height": 1.66, + "launch_bounce_height": 0.625, + "urgent_bounce_time_ms": 600, + "launch_bounce_time_ms": 600, + "click_time_ms": 300, + "hover_lighten": 0.2, + "active_time_ms": 150, + "max_indicator_dots": 3, + "glow_opacity": 0.6, + "indicator_style": "dots", + "round_bottom": False, + "distance_from_edge": 0, +} + + +def user_themes_dir() -> Path: + """Return the user-writable theme directory.""" + config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + return config_home / "docking" / "themes" + + +def ensure_user_theme_template() -> None: + """Create the user theme directory and editable template if missing.""" + directory = user_themes_dir() + template = directory / f"{_USER_THEME_TEMPLATE_NAME}.json" + if template.exists(): + return + directory.mkdir(parents=True, exist_ok=True) + template.write_text( + json.dumps(_USER_THEME_TEMPLATE, indent=2) + "\n", + encoding="utf-8", + ) + + +def _theme_paths(name: str) -> list[Path]: + return [ + user_themes_dir() / f"{name}.json", + _BUILTIN_THEMES_DIR / f"{name}.json", + ] + + +def list_theme_names() -> list[str]: + """Return built-in and user theme names, excluding the copy/edit template.""" + ensure_user_theme_template() + names = {p.stem for p in _BUILTIN_THEMES_DIR.glob("*.json")} + names.update( + p.stem + for p in user_themes_dir().glob("*.json") + if p.stem != _USER_THEME_TEMPLATE_NAME + ) + return sorted(names) + class IndicatorStyle(str, enum.Enum): DOTS = "dots" @@ -280,11 +348,15 @@ def load(cls, name: str = "default", icon_size: int = 48) -> Theme: Returns: A Theme instance with all layout values in pixels. """ - path = _BUILTIN_THEMES_DIR / f"{name}.json" - if not path.exists(): + ensure_user_theme_template() + path = next( + (candidate for candidate in _theme_paths(name) if candidate.exists()), + None, + ) + if path is None: return cls() - with path.open() as f: + with path.open(encoding="utf-8") as f: data: dict[str, Any] = json.load(fp=f) # --- Scale factor --- diff --git a/docking/ui/settings.py b/docking/ui/settings.py index 8d41ad18..0d44b835 100644 --- a/docking/ui/settings.py +++ b/docking/ui/settings.py @@ -54,7 +54,7 @@ MiddleClickAction, ) from docking.core.position import Position -from docking.core.theme import _BUILTIN_THEMES_DIR, Theme +from docking.core.theme import Theme, list_theme_names from docking.core.updates import load_state from docking.i18n import _ from docking.log import get_logger @@ -277,7 +277,7 @@ def _build_appearance_tab(self) -> Gtk.Widget: self._update_status_label.set_line_wrap(True) self._theme_combo = Gtk.ComboBoxText() - for theme_name in sorted(p.stem for p in _BUILTIN_THEMES_DIR.glob("*.json")): + for theme_name in list_theme_names(): self._theme_combo.append(theme_name, theme_name.replace("-", " ").title()) self._position_combo = Gtk.ComboBoxText() diff --git a/tests/conftest.py b/tests/conftest.py index 9b6acab3..2c8f04e2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,6 +36,7 @@ def _isolate_config_paths(tmp_path, monkeypatch): from docking.core import config as config_mod tmp_file = tmp_path / "docking-test-dock.json" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg-config")) monkeypatch.setattr(config_mod, "DEFAULT_CONFIG_FILE", tmp_file) monkeypatch.setattr( config_mod, diff --git a/tests/core/test_theme.py b/tests/core/test_theme.py index 1af7971b..6fafefc9 100644 --- a/tests/core/test_theme.py +++ b/tests/core/test_theme.py @@ -5,7 +5,14 @@ import pytest -from docking.core.theme import Theme, _rgba +from docking.core.theme import ( + _USER_THEME_TEMPLATE_NAME, + Theme, + _rgba, + ensure_user_theme_template, + list_theme_names, + user_themes_dir, +) class TestRgba: @@ -71,6 +78,63 @@ def test_load_partial_theme(self, tmp_path): # Defaults for unspecified assert t.indicator_radius == 2.5 + def test_loads_user_theme_from_config_dir(self, tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + directory = user_themes_dir() + directory.mkdir(parents=True) + (directory / "custom.json").write_text( + json.dumps({"roundness": 14, "stroke_width": 3.0}), + encoding="utf-8", + ) + + t = Theme.load("custom", 48) + + assert t.roundness == 14.0 + assert t.stroke_width == 3.0 + + def test_user_theme_overrides_builtin_theme(self, tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + directory = user_themes_dir() + directory.mkdir(parents=True) + (directory / "default.json").write_text( + json.dumps({"roundness": 22}), + encoding="utf-8", + ) + + t = Theme.load("default", 48) + + assert t.roundness == 22.0 + + def test_user_theme_template_is_created_but_not_listed(self, tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + + names = list_theme_names() + + template = user_themes_dir() / f"{_USER_THEME_TEMPLATE_NAME}.json" + assert template.exists() + assert _USER_THEME_TEMPLATE_NAME not in names + assert "default" in names + + def test_load_creates_user_theme_template(self, tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + + Theme.load("default", 48) + + template = user_themes_dir() / f"{_USER_THEME_TEMPLATE_NAME}.json" + assert template.exists() + + def test_existing_user_theme_template_is_not_overwritten( + self, tmp_path, monkeypatch + ): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + template = user_themes_dir() / f"{_USER_THEME_TEMPLATE_NAME}.json" + template.parent.mkdir(parents=True) + template.write_text('{"roundness": 33}\n', encoding="utf-8") + + ensure_user_theme_template() + + assert template.read_text(encoding="utf-8") == '{"roundness": 33}\n' + @pytest.mark.parametrize( ("theme_name", "expected_roundness"), [